[object Object]

← back to CelebritySignatures

Build per-category collages (usable-only): Declaration(56), Politics(75), Sports(42), Hollywood(100), TV(22); Movies skipped (0 usable). 429-throttled Wikimedia raster fetch

933e944d33286550f482ff462b3083b8dcd6fd9d · 2026-06-18 10:23:00 -0700 · Steve

Files touched

Diff

commit 933e944d33286550f482ff462b3083b8dcd6fd9d
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Jun 18 10:23:00 2026 -0700

    Build per-category collages (usable-only): Declaration(56), Politics(75), Sports(42), Hollywood(100), TV(22); Movies skipped (0 usable). 429-throttled Wikimedia raster fetch
---
 .gitignore                                     |   1 +
 output/collage-declaration-of-independence.png | Bin 0 -> 673849 bytes
 output/collage-hollywood.png                   | Bin 0 -> 973533 bytes
 output/collage-politics.png                    | Bin 0 -> 674383 bytes
 output/collage-sports.png                      | Bin 0 -> 373088 bytes
 output/collage-tv.png                          | Bin 0 -> 245751 bytes
 scripts/build-collages.py                      | 179 +++++++++++++++++++++++++
 7 files changed, 180 insertions(+)

diff --git a/.gitignore b/.gitignore
index 654e015..ff8a575 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,4 @@ build/
 .next/
 tmp_wdqs.log
 tmp_*.log
+tmp_collage_cache/
diff --git a/output/collage-declaration-of-independence.png b/output/collage-declaration-of-independence.png
new file mode 100644
index 0000000..6efcc9f
Binary files /dev/null and b/output/collage-declaration-of-independence.png differ
diff --git a/output/collage-hollywood.png b/output/collage-hollywood.png
new file mode 100644
index 0000000..bd035de
Binary files /dev/null and b/output/collage-hollywood.png differ
diff --git a/output/collage-politics.png b/output/collage-politics.png
new file mode 100644
index 0000000..73fa55f
Binary files /dev/null and b/output/collage-politics.png differ
diff --git a/output/collage-sports.png b/output/collage-sports.png
new file mode 100644
index 0000000..5ba7efb
Binary files /dev/null and b/output/collage-sports.png differ
diff --git a/output/collage-tv.png b/output/collage-tv.png
new file mode 100644
index 0000000..548f764
Binary files /dev/null and b/output/collage-tv.png differ
diff --git a/scripts/build-collages.py b/scripts/build-collages.py
new file mode 100644
index 0000000..8670669
--- /dev/null
+++ b/scripts/build-collages.py
@@ -0,0 +1,179 @@
+#!/usr/bin/env python3
+"""Build one collage PNG per category from ONLY the collage-usable rows.
+
+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)."""
+
+import json, os, re, time, urllib.parse, urllib.request
+from io import BytesIO
+from PIL import Image, ImageDraw, ImageFont
+
+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"
+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
+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 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)
+
+
+def build(category, rows):
+    titles = [file_title(r["signature_image_url"]) for r in rows]
+    thumbs = resolve_thumbs(titles)
+    cells = []
+    for r in rows:
+        url = thumbs.get(file_title(r["signature_image_url"])) or r["signature_image_url"]
+        im = fetch_image(url)
+        if im:
+            cells.append((r["full_name"], fit(im)))
+    n = len(cells)
+    if not n:
+        print(f"[{category}] no images resolved, skipping"); return None
+
+    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)
+    d.text((PAD + 10, 84),
+           f"{n} signatures · collage-usable only · public domain / openly licensed · source: Wikimedia Commons",
+           font=font(17), fill=MUTED)
+
+    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))
+        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)
+
+    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")
+    canvas.save(path, "PNG")
+    print(f"[{category}] -> {path}  ({n} sigs, {cols}×{rows_ct}, {W}×{H}px)")
+    return path
+
+
+def main():
+    data = json.load(open(DATA))
+    by = {}
+    for r in data:
+        if r["usable_in_commercial_collage"] == "yes":
+            by.setdefault(r["category"], []).append(r)
+    order = ["Declaration of Independence", "Politics", "Sports", "Hollywood", "Movies", "TV"]
+    made = []
+    for cat in order:
+        if cat in by:
+            p = build(cat, sorted(by[cat], key=lambda x: x["rank"]))
+            if p: made.append(p)
+        else:
+            print(f"[{cat}] 0 usable rows — no collage")
+    print(f"\nBuilt {len(made)} collages in output/")
+
+
+if __name__ == "__main__":
+    main()

← 44a7d34 Add zero-dep grid viewer (server.js + public/index.html): so  ·  back to CelebritySignatures  ·  All 3: Collages tab in viewer + deceased-only Movies(Classic 3a492fb →