← back to CelebritySignatures
scripts/build-collages.py
297 lines
#!/usr/bin/env python3
"""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).
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")
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 — the public Kamatera deployment so a phone scanning
# the printed mural resolves the named "key" from anywhere (not just the LAN).
BASE_URL = os.environ.get("PUBLIC_BASE_URL", "https://celebrity.designerwallcoverings.com").rstrip("/")
os.makedirs(CACHE, exist_ok=True)
os.makedirs(OUT, exist_ok=True)
# layout
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 # 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)
MUTED = (120, 120, 120)
def font(sz, bold=False):
for p in ([
"/System/Library/Fonts/Supplemental/Georgia Bold.ttf" if bold else "/System/Library/Fonts/Supplemental/Georgia.ttf",
"/System/Library/Fonts/Supplemental/Arial Bold.ttf" if bold else "/System/Library/Fonts/Supplemental/Arial.ttf",
"/Library/Fonts/Arial.ttf",
]):
try:
return ImageFont.truetype(p, sz)
except Exception:
continue
return ImageFont.load_default()
def file_title(url):
name = urllib.parse.unquote(url.rstrip("/").split("/")[-1])
return "File:" + name
def resolve_thumbs(titles):
"""title -> raster PNG thumburl via Commons API (batches of 50)."""
out = {}
for i in range(0, len(titles), 50):
batch = titles[i:i + 50]
q = urllib.parse.urlencode({
"action": "query", "format": "json", "prop": "imageinfo",
"iiprop": "url", "iiurlwidth": "480", "titles": "|".join(batch),
})
req = urllib.request.Request(COMMONS + "?" + q, headers={"User-Agent": UA})
try:
j = json.load(urllib.request.urlopen(req, timeout=30))
except Exception as e:
print(" api batch failed:", e); time.sleep(1); continue
norm = {n["to"]: n["from"] for n in j.get("query", {}).get("normalized", [])}
for p in j.get("query", {}).get("pages", {}).values():
ii = (p.get("imageinfo") or [{}])[0]
key = norm.get(p.get("title"), p.get("title"))
out[key] = ii.get("thumburl") or ii.get("url")
time.sleep(0.2)
return out
def fetch_image(url):
h = re.sub(r"[^a-zA-Z0-9]", "_", url)[-80:]
cp = os.path.join(CACHE, h + ".png")
if os.path.exists(cp):
try: return Image.open(cp).convert("RGBA")
except Exception: pass
for attempt in range(6):
try:
req = urllib.request.Request(url, headers={"User-Agent": UA})
raw = urllib.request.urlopen(req, timeout=30).read()
im = Image.open(BytesIO(raw)).convert("RGBA")
im.save(cp)
time.sleep(0.35) # be polite to Wikimedia
return im
except urllib.error.HTTPError as e:
if e.code == 429:
wait = 8 * (attempt + 1)
print(f" 429 backoff {wait}s ({url[-40:]})"); time.sleep(wait); continue
print(" img fail:", url[:60], e); return None
except Exception as e:
print(" img fail:", url[:60], e); return None
return None
def signature_like(im):
"""Reject document scans / photos only. A real signature (even thin/sparse) is
low-saturation dark ink on a mostly-light ground. The stray cells are COLOR
photos (high saturation) or full-page/dark scans (low light or heavy ink) — so
gate on those, and never on how *little* ink there is (that drops real sigs)."""
rgb = Image.alpha_composite(Image.new("RGBA", im.size, (255, 255, 255, 255)), im).convert("RGB")
s = rgb.resize((72, 72))
g = list(s.convert("L").getdata())
n = len(g)
light = sum(1 for v in g if v >= 200) / n # paper / background fraction
dark = sum(1 for v in g if v <= 90) / n # ink coverage
sat = sum(p[1] for p in s.convert("HSV").getdata()) / n
return sat < 60 and light >= 0.30 and dark <= 0.30
def fit(im):
"""Scale to fit BOX while preserving aspect; flatten transparency onto white."""
bg = Image.new("RGBA", im.size, (255, 255, 255, 255))
im = Image.alpha_composite(bg, im).convert("RGB")
r = min(BOX_W / im.width, BOX_H / im.height, 1.0)
return im.resize((max(1, int(im.width * r)), max(1, int(im.height * r))), Image.LANCZOS)
# Single-letter QR codes (mirror server.js SHORT map) → short URL → low module
# count → reliably scannable at a modest size, even printed and viewed at distance.
CODE = {
"declaration-of-independence": "d", "politics": "p", "sports": "s",
"hollywood": "h", "movies-classic": "c", "tv": "t",
}
QR_PX = 180 # functional utility-mark size (~7px/module on the short /k/<code> URL)
def make_qr(data, target_px):
"""Render a crisp QR for `data`, scaled to ~target_px with integer px-per-module
(NEAREST) so the modules stay razor-sharp — critical for camera scanning."""
qr = qrcode.QRCode(
error_correction=qrcode.constants.ERROR_CORRECT_M, # ~15% damage tolerance
box_size=10, border=2, # built-in quiet zone
)
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
box = max(4, round(target_px / modules))
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
canvas = Image.new("RGB", (W, H), (255, 255, 255))
d = ImageDraw.Draw(canvas)
# header
d.rectangle([0, 0, W, HEADER_H], fill=(247, 245, 240))
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 · {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
ox = cx + (CELL_W - im.width) // 2
oy = cy + PAD + (BOX_H - im.height) // 2
canvas.paste(im, (ox, oy))
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 mural only) — a functional utility mark (not a signature),
# sized to actually scan in print; encodes the short /k/<code> redirect URL.
if not named:
grid_h = rows_ct * cell_h
qr = make_qr(f"{BASE_URL}/k/{CODE.get(safe, safe)}", QR_PX)
cf = font(14, bold=True)
cap = "SCAN TO REVEAL NAMES"
cap_w = int(d.textlength(cap, font=cf))
pad_in, gap, cap_h = 16, 10, 18
panel_w = max(qr.width, cap_w) + pad_in * 2
panel_h = qr.height + gap + cap_h + pad_in * 2
px = (W - panel_w) // 2
py = HEADER_H + (grid_h - panel_h) // 2
d.rectangle([px + 5, py + 6, px + panel_w + 5, py + panel_h + 6], fill=(214, 210, 202)) # soft shadow
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 + 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)
suffix = "-named" if named else ""
path = os.path.join(OUT, f"collage-{safe}{suffix}.png")
canvas.save(path, "PNG")
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 (centre QR)
]
return made
def _death_year(r):
m = re.search(r"\b(1[0-9]\d\d|20\d\d)\b", str(r.get("death_date") or ""))
return int(m.group(1)) if m else None
def main():
# SAFE_ONLY: keep only people who died ≤1956 — clear of California's 70-year
# postmortem right of publicity (§3344.1) for commercial sale.
safe_only = bool(os.environ.get("SAFE_ONLY"))
data = json.load(open(DATA))
by = {}
for r in data:
if r["usable_in_commercial_collage"] != "yes":
continue
if safe_only:
y = _death_year(r)
if y is None or y > 1956:
continue
by.setdefault(r["category"], []).append(r)
if safe_only:
print("⚖️ PUBLICITY-SAFE mode: only people who died ≤1956\n")
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:
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)} PNGs in output/ ({len(made)//2} murals + their named keys)")
if __name__ == "__main__":
main()