← 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()