← 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
M .gitignoreA output/collage-declaration-of-independence.pngA output/collage-hollywood.pngA output/collage-politics.pngA output/collage-sports.pngA output/collage-tv.pngA scripts/build-collages.py
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 →