[object Object]

← back to CelebritySignatures

Murals: hide names + centre QR to the named key

b04a4a906839e2f8e32d1a1178b353a067a88d3f · 2026-06-18 16:23:18 -0700 · SteveStudio2

Each category now renders two PNGs — collage-<cat>.png (no names under
signatures, QR code in the centre captioned SCAN TO REVEAL NAMES) and
collage-<cat>-named.png (the labelled key the QR points at). QR encodes
${PUBLIC_BASE_URL}/output/collage-<cat>-named.png (default LAN IP:9920) so a
phone on the same wifi opens the named version. qrcode[pil] runs in a project
.venv (PEP 668). All 6 QRs decoded-verified to the correct target; LAN serve 200.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files touched

Diff

commit b04a4a906839e2f8e32d1a1178b353a067a88d3f
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date:   Thu Jun 18 16:23:18 2026 -0700

    Murals: hide names + centre QR to the named key
    
    Each category now renders two PNGs — collage-<cat>.png (no names under
    signatures, QR code in the centre captioned SCAN TO REVEAL NAMES) and
    collage-<cat>-named.png (the labelled key the QR points at). QR encodes
    ${PUBLIC_BASE_URL}/output/collage-<cat>-named.png (default LAN IP:9920) so a
    phone on the same wifi opens the named version. qrcode[pil] runs in a project
    .venv (PEP 668). All 6 QRs decoded-verified to the correct target; LAN serve 200.
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
 .gitignore                                         |   2 +
 README.md                                          |  17 +++
 .../collage-declaration-of-independence-named.png  | Bin 0 -> 673944 bytes
 output/collage-declaration-of-independence.png     | Bin 673849 -> 605845 bytes
 output/collage-hollywood-named.png                 | Bin 0 -> 857611 bytes
 output/collage-hollywood.png                       | Bin 857476 -> 762984 bytes
 output/collage-movies-classic-named.png            | Bin 0 -> 889669 bytes
 output/collage-movies-classic.png                  | Bin 889540 -> 788039 bytes
 output/collage-politics-named.png                  | Bin 0 -> 630429 bytes
 output/collage-politics.png                        | Bin 630372 -> 567976 bytes
 output/collage-sports-named.png                    | Bin 0 -> 373208 bytes
 output/collage-sports.png                          | Bin 373088 -> 330588 bytes
 output/collage-tv-named.png                        | Bin 0 -> 170758 bytes
 output/collage-tv.png                              | Bin 170688 -> 145377 bytes
 scripts/build-collages.py                          | 151 +++++++++++++++------
 15 files changed, 132 insertions(+), 38 deletions(-)

diff --git a/.gitignore b/.gitignore
index ff8a575..2f40e25 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,3 +9,5 @@ build/
 tmp_wdqs.log
 tmp_*.log
 tmp_collage_cache/
+
+.venv/
diff --git a/README.md b/README.md
index bd49b03..59ca870 100644
--- a/README.md
+++ b/README.md
@@ -22,12 +22,29 @@ file's license from the Commons API and HTTP-verify the image resolves.
 |---|---|---|---|
 | `scripts/fetch-declaration.mjs` | All 56 Declaration of Independence signers | MediaWiki / Commons / Wikidata-entity | ✅ works anytime |
 | `scripts/fetch-wikidata.mjs [cat\|all]` | The 5 celebrity categories, ranked by notability | Wikidata SPARQL (WDQS) | ⏳ runs when WDQS is up (has 1-req/min outage backoff) |
+| `scripts/build-collages.py` | Two murals per category (see below) | Wikimedia thumbnailer + `qrcode` | ✅ run via `.venv` |
 
 ```bash
 node scripts/fetch-declaration.mjs          # → data/declaration.json (56 rows)
 node scripts/fetch-wikidata.mjs all         # → data/celebrity_signatures.{json,csv}
+
+# Murals (needs the venv — qrcode[pil] is installed there, not globally):
+python3 -m venv .venv && .venv/bin/pip install "qrcode[pil]" Pillow
+.venv/bin/python scripts/build-collages.py  # → output/collage-<cat>.png + -named.png
 ```
 
+### Murals (`output/`)
+Each category renders **two** PNGs:
+- `collage-<cat>.png` — the displayed mural. **No names** under the signatures, with
+  a **QR code in the centre** ("SCAN TO REVEAL NAMES"). This is what the viewer shows.
+- `collage-<cat>-named.png` — the labelled "key", same grid with every full name. The
+  centre QR links here.
+
+The QR encodes `${PUBLIC_BASE_URL}/output/collage-<cat>-named.png` (default
+`PUBLIC_BASE_URL=http://<LAN-IP>:9920`), so a phone on the same wifi opens the named
+version while the local viewer (`node server.js`) is running. Set `PUBLIC_BASE_URL`
+to a public origin if this ever deploys.
+
 ## Schema (per row)
 `category, rank, full_name, wikidata, reason_for_ranking, ranking_source_urls,
 signature_image_url, signature_source_type, image_license, image_author, deceased,
diff --git a/output/collage-declaration-of-independence-named.png b/output/collage-declaration-of-independence-named.png
new file mode 100644
index 0000000..9fe7a04
Binary files /dev/null and b/output/collage-declaration-of-independence-named.png differ
diff --git a/output/collage-declaration-of-independence.png b/output/collage-declaration-of-independence.png
index 6efcc9f..1e2b48c 100644
Binary files a/output/collage-declaration-of-independence.png and b/output/collage-declaration-of-independence.png differ
diff --git a/output/collage-hollywood-named.png b/output/collage-hollywood-named.png
new file mode 100644
index 0000000..d320698
Binary files /dev/null and b/output/collage-hollywood-named.png differ
diff --git a/output/collage-hollywood.png b/output/collage-hollywood.png
index faf8d20..ef77859 100644
Binary files a/output/collage-hollywood.png and b/output/collage-hollywood.png differ
diff --git a/output/collage-movies-classic-named.png b/output/collage-movies-classic-named.png
new file mode 100644
index 0000000..f425ad7
Binary files /dev/null and b/output/collage-movies-classic-named.png differ
diff --git a/output/collage-movies-classic.png b/output/collage-movies-classic.png
index 65a3092..5025d6c 100644
Binary files a/output/collage-movies-classic.png and b/output/collage-movies-classic.png differ
diff --git a/output/collage-politics-named.png b/output/collage-politics-named.png
new file mode 100644
index 0000000..277831b
Binary files /dev/null and b/output/collage-politics-named.png differ
diff --git a/output/collage-politics.png b/output/collage-politics.png
index 5ba235f..14a04f1 100644
Binary files a/output/collage-politics.png and b/output/collage-politics.png differ
diff --git a/output/collage-sports-named.png b/output/collage-sports-named.png
new file mode 100644
index 0000000..2b4c028
Binary files /dev/null and b/output/collage-sports-named.png differ
diff --git a/output/collage-sports.png b/output/collage-sports.png
index 5ba7efb..3c8b422 100644
Binary files a/output/collage-sports.png and b/output/collage-sports.png differ
diff --git a/output/collage-tv-named.png b/output/collage-tv-named.png
new file mode 100644
index 0000000..ce16a7a
Binary files /dev/null and b/output/collage-tv-named.png differ
diff --git a/output/collage-tv.png b/output/collage-tv.png
index 9511895..e1398e3 100644
Binary files a/output/collage-tv.png and b/output/collage-tv.png differ
diff --git a/scripts/build-collages.py b/scripts/build-collages.py
index 63b8601..7afedb0 100644
--- a/scripts/build-collages.py
+++ b/scripts/build-collages.py
@@ -1,14 +1,28 @@
 #!/usr/bin/env python3
-"""Build one collage PNG per category from ONLY the collage-usable rows.
+"""Build TWO collage PNGs per category from ONLY the collage-usable rows.
+
+For each category we render:
+  • collage-<cat>-named.png  — every signature labelled with its full name
+    (this is the "key" / answer sheet, and the QR target below)
+  • collage-<cat>.png        — NO names under the signatures, with a QR code in
+    the CENTRE of the mural that links to the named version. This is the mural
+    the viewer displays: the names are deliberately hidden so the wall of
+    signatures reads as art; scan the centre code to reveal who's who.
 
 Every signature is fetched as a PNG raster from Wikimedia's thumbnailer (so SVG
 signatures rasterize cleanly server-side), composited onto white at UNIFORM cell
 size — no signature is rendered larger or more prominent than another.
-Free APIs only ($0)."""
+Free APIs only ($0).
+
+QR target host: PUBLIC_BASE_URL env (default = detected LAN IP) so a phone on
+the same wifi can open the named version while the local viewer is running.
+Run via the project venv:  .venv/bin/python scripts/build-collages.py
+"""
 
 import json, os, re, time, urllib.parse, urllib.request
 from io import BytesIO
 from PIL import Image, ImageDraw, ImageFont
+import qrcode
 
 ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 DATA = os.path.join(ROOT, "data", "celebrity_signatures.json")
@@ -16,6 +30,9 @@ CACHE = os.path.join(ROOT, "tmp_collage_cache")
 OUT = os.path.join(ROOT, "output")
 UA = "CelebritySignatures-research/1.0 (steve@designerwallcoverings.com)"
 COMMONS = "https://commons.wikimedia.org/w/api.php"
+# Host the QR code points at. Defaults to the LAN IP so phone scans work on the
+# same network; override with PUBLIC_BASE_URL=https://… when this ever deploys.
+BASE_URL = os.environ.get("PUBLIC_BASE_URL", "http://192.168.1.53:9920").rstrip("/")
 os.makedirs(CACHE, exist_ok=True)
 os.makedirs(OUT, exist_ok=True)
 
@@ -24,7 +41,8 @@ BOX_W, BOX_H = 240, 84        # signature draw area (uniform for all)
 LABEL_H = 26
 PAD = 16
 CELL_W = BOX_W + PAD
-CELL_H = BOX_H + LABEL_H + PAD
+CELL_H = BOX_H + LABEL_H + PAD   # named version (room for a label)
+CELL_H_PLAIN = BOX_H + PAD       # no-names version (tighter, no label band)
 HEADER_H = 132
 FOOTER_H = 46
 INK = (22, 22, 22)
@@ -119,29 +137,30 @@ def fit(im):
     return im.resize((max(1, int(im.width * r)), max(1, int(im.height * r))), Image.LANCZOS)
 
 
-def build(category, rows):
-    titles = [file_title(r["signature_image_url"]) for r in rows]
-    thumbs = resolve_thumbs(titles)
-    cells = []
-    dropped = []
-    for r in rows:
-        url = thumbs.get(file_title(r["signature_image_url"])) or r["signature_image_url"]
-        im = fetch_image(url)
-        if not im:
-            continue
-        if not signature_like(im):
-            dropped.append(r["full_name"]); continue
-        cells.append((r["full_name"], fit(im)))
-    n = len(cells)
-    if dropped:
-        print(f"  [{category}] dropped {len(dropped)} non-signature scans: {', '.join(dropped[:6])}{'…' if len(dropped)>6 else ''}")
-    if not n:
-        print(f"[{category}] no images resolved, skipping"); return None
+def make_qr(data, target_px):
+    """Render a crisp QR for `data`, scaled to ~target_px (snapped to module grid)."""
+    qr = qrcode.QRCode(
+        error_correction=qrcode.constants.ERROR_CORRECT_M,  # ~15% damage tolerance
+        box_size=10, border=2,
+    )
+    qr.add_data(data)
+    qr.make(fit=True)
+    img = qr.make_image(fill_color="black", back_color="white").convert("RGB")
+    modules = qr.modules_count + 2 * qr.border          # total grid incl. quiet zone
+    box = max(4, round(target_px / modules))            # integer px/module → no blur
+    side = box * modules
+    return img.resize((side, side), Image.NEAREST)
+
 
+def render(category, cells, dropped, named, safe):
+    """Render one collage. named=True draws a label under each sig; named=False
+    hides names and stamps a centred QR linking to the named version."""
+    n = len(cells)
+    cell_h = CELL_H if named else CELL_H_PLAIN
     cols = min(10, max(1, round(n ** 0.5)))
     rows_ct = (n + cols - 1) // cols
     W = cols * CELL_W + PAD
-    H = HEADER_H + rows_ct * CELL_H + FOOTER_H
+    H = HEADER_H + rows_ct * cell_h + FOOTER_H
     canvas = Image.new("RGB", (W, H), (255, 255, 255))
     d = ImageDraw.Draw(canvas)
 
@@ -150,36 +169,92 @@ def build(category, rows):
     d.line([0, HEADER_H, W, HEADER_H], fill=(230, 227, 220), width=2)
     d.text((PAD + 8, 30), category, font=font(40, bold=True), fill=INK)
     drop_note = f" · {len(dropped)} non-signature scans omitted" if dropped else ""
+    mode_note = "names shown" if named else "names hidden · scan centre code for the key"
     d.text((PAD + 10, 84),
-           f"{n} signatures · collage-usable only · public domain / openly licensed · source: Wikimedia Commons{drop_note}",
+           f"{n} signatures · {mode_note} · public domain / openly licensed · source: Wikimedia Commons{drop_note}",
            font=font(17), fill=MUTED)
 
+    # signatures
     for idx, (name, im) in enumerate(cells):
         cx = PAD + (idx % cols) * CELL_W
-        cy = HEADER_H + (idx // cols) * CELL_H
+        cy = HEADER_H + (idx // cols) * cell_h
         ox = cx + (CELL_W - im.width) // 2
         oy = cy + PAD + (BOX_H - im.height) // 2
         canvas.paste(im, (ox, oy))
-        f = font(12)
-        label = name if d.textlength(name, font=f) <= CELL_W - 6 else name
-        while d.textlength(label, font=f) > CELL_W - 8 and len(label) > 4:
-            label = label[:-2]
-        if label != name:
-            label = label[:-1] + "…"
-        tw = d.textlength(label, font=f)
-        d.text((cx + (CELL_W - tw) / 2, cy + PAD + BOX_H + 4), label, font=f, fill=MUTED)
+        if named:
+            f = font(12)
+            label = name
+            while d.textlength(label, font=f) > CELL_W - 8 and len(label) > 4:
+                label = label[:-2]
+            if label != name:
+                label = label[:-1] + "…"
+            tw = d.textlength(label, font=f)
+            d.text((cx + (CELL_W - tw) / 2, cy + PAD + BOX_H + 4), label, font=f, fill=MUTED)
+
+    # centred QR (no-names version only)
+    if not named:
+        grid_h = rows_ct * cell_h
+        target = int(min(W, grid_h) / 3.4)
+        target = max(190, min(320, target))
+        qr_url = f"{BASE_URL}/output/collage-{safe}-named.png"
+        qr = make_qr(qr_url, target)
+        cap = "SCAN TO REVEAL NAMES"
+        cf = font(15, bold=True)
+        cap_w = d.textlength(cap, font=cf)
+        pad_in = 18
+        cap_gap = 12
+        cap_h = 20
+        panel_w = max(qr.width, int(cap_w)) + pad_in * 2
+        panel_h = qr.height + cap_gap + cap_h + pad_in * 2
+        px = (W - panel_w) // 2
+        py = HEADER_H + (grid_h - panel_h) // 2
+        # soft drop shadow + white panel + hairline border
+        d.rectangle([px + 5, py + 6, px + panel_w + 5, py + panel_h + 6], fill=(214, 210, 202))
+        d.rectangle([px, py, px + panel_w, py + panel_h], fill=(255, 255, 255), outline=(150, 146, 138), width=2)
+        qx = px + (panel_w - qr.width) // 2
+        qy = py + pad_in
+        canvas.paste(qr, (qx, qy))
+        d.text((px + (panel_w - cap_w) / 2, qy + qr.height + cap_gap), cap, font=cf, fill=INK)
 
+    # footer
     d.text((PAD + 8, H - FOOTER_H + 14),
            "Uniform sizing — no signature emphasized. Deceased or public-domain only; living-celebrity rows excluded (right of publicity).",
            font=font(13), fill=MUTED)
 
-    safe = re.sub(r"[^a-z0-9]+", "-", category.lower()).strip("-")
-    path = os.path.join(OUT, f"collage-{safe}.png")
+    suffix = "-named" if named else ""
+    path = os.path.join(OUT, f"collage-{safe}{suffix}.png")
     canvas.save(path, "PNG")
-    print(f"[{category}] -> {path}  ({n} sigs, {cols}×{rows_ct}, {W}×{H}px)")
+    tag = "named key" if named else "no-names + QR"
+    print(f"[{category}] -> {os.path.basename(path)}  ({tag}; {n} sigs, {cols}×{rows_ct}, {W}×{H}px)")
     return path
 
 
+def build(category, rows):
+    titles = [file_title(r["signature_image_url"]) for r in rows]
+    thumbs = resolve_thumbs(titles)
+    cells = []
+    dropped = []
+    for r in rows:
+        url = thumbs.get(file_title(r["signature_image_url"])) or r["signature_image_url"]
+        im = fetch_image(url)
+        if not im:
+            continue
+        if not signature_like(im):
+            dropped.append(r["full_name"]); continue
+        cells.append((r["full_name"], fit(im)))
+    if dropped:
+        print(f"  [{category}] dropped {len(dropped)} non-signature scans: {', '.join(dropped[:6])}{'…' if len(dropped)>6 else ''}")
+    if not cells:
+        print(f"[{category}] no images resolved, skipping"); return []
+
+    safe = re.sub(r"[^a-z0-9]+", "-", category.lower()).strip("-")
+    made = [
+        render(category, cells, dropped, named=True, safe=safe),    # the key (QR target)
+        render(category, cells, dropped, named=False, safe=safe),   # the mural (no names + QR)
+    ]
+    return made
+
+
 def main():
     data = json.load(open(DATA))
     by = {}
@@ -188,13 +263,13 @@ def main():
             by.setdefault(r["category"], []).append(r)
     order = ["Declaration of Independence", "Politics", "Sports", "Hollywood", "Movies (Classic)", "Movies", "TV"]
     made = []
+    print(f"QR target host: {BASE_URL}\n")
     for cat in order:
         if cat in by:
-            p = build(cat, sorted(by[cat], key=lambda x: x["rank"]))
-            if p: made.append(p)
+            made += build(cat, sorted(by[cat], key=lambda x: x["rank"]))
         else:
             print(f"[{cat}] 0 usable rows — no collage")
-    print(f"\nBuilt {len(made)} collages in output/")
+    print(f"\nBuilt {len(made)} PNGs in output/ ({len(made)//2} murals + their named keys)")
 
 
 if __name__ == "__main__":

← 3a492fb All 3: Collages tab in viewer + deceased-only Movies(Classic  ·  back to CelebritySignatures  ·  Murals: QR is now a signature-sized centre cell (drop panel/ a8e8de1 →