← 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
M .gitignoreM README.mdA output/collage-declaration-of-independence-named.pngM output/collage-declaration-of-independence.pngA output/collage-hollywood-named.pngM output/collage-hollywood.pngA output/collage-movies-classic-named.pngM output/collage-movies-classic.pngA output/collage-politics-named.pngM output/collage-politics.pngA output/collage-sports-named.pngM output/collage-sports.pngA output/collage-tv-named.pngM output/collage-tv.pngM scripts/build-collages.py
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 →