← back to CelebritySignatures
scripts/build-theme-murals.py
206 lines
#!/usr/bin/env python3
"""
Themed signature-mural factory.
Reads data/mural-themes.json (list of {slug,title,blurb,met_query,roster:[names]}).
For each theme:
- resolves every roster name's REAL signature via Wikidata P109 (signature image)
-> Commons thumbnail. Names without a P109 signature are SKIPPED (never invented).
- pulls one PUBLIC-DOMAIN Met artifact (Met Open Access API) as a header accent.
- composes output/collage-<slug>.png and collage-<slug>-named.png (the murals
page toggles base<->named), matching the existing storefront image convention.
Then appends/updates each theme in data/murals-catalog.json with its resolved roster.
Free / local + public no-key HTTP (Wikidata + Commons + Met). $0.
"""
import json, os, re, time, urllib.parse, urllib.request, sys
from io import BytesIO
from concurrent.futures import ThreadPoolExecutor
from PIL import Image, ImageDraw, ImageFont
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
THEMES = os.path.join(ROOT, "data", "mural-themes.json")
CATALOG = os.path.join(ROOT, "data", "murals-catalog.json")
CACHE = os.path.join(ROOT, "tmp_theme_cache")
OUT = os.path.join(ROOT, "output")
UA = "CelebritySignatures-research/1.0 (steve@designerwallcoverings.com)"
WD = "https://www.wikidata.org/w/api.php"
COMMONS = "https://commons.wikimedia.org/w/api.php"
MET = "https://collectionapi.metmuseum.org/public/collection/v1"
os.makedirs(CACHE, exist_ok=True); os.makedirs(OUT, exist_ok=True)
def get(url, tries=5):
for i in range(tries):
try:
req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=25) as r:
return r.read()
except Exception:
if i == tries - 1:
return None
time.sleep(1.0 * (i + 1)) # gentle backoff to avoid Wikidata 429s
def getjson(url):
b = get(url)
try:
return json.loads(b) if b else None
except Exception:
return None
def wd_signature(name):
"""name -> (commons thumb url) via Wikidata P109, or None."""
s = getjson(f"{WD}?action=wbsearchentities&search={urllib.parse.quote(name)}"
f"&language=en&format=json&type=item&limit=1&maxlag=5")
try:
qid = s["search"][0]["id"]
except Exception:
return None
c = getjson(f"{WD}?action=wbgetclaims&entity={qid}&property=P109&format=json&maxlag=5")
try:
fname = c["claims"]["P109"][0]["mainsnak"]["datavalue"]["value"]
except Exception:
return None
title = "File:" + fname
q = (f"{COMMONS}?action=query&titles={urllib.parse.quote(title)}"
f"&prop=imageinfo&iiprop=url&iiurlwidth=600&format=json")
d = getjson(q)
try:
info = next(iter(d["query"]["pages"].values()))["imageinfo"][0]
return info.get("thumburl") or info.get("url")
except Exception:
return None
def met_public_domain(query):
s = getjson(f"{MET}/search?q={urllib.parse.quote(query)}&hasImages=true")
for oid in (s or {}).get("objectIDs", [])[:10]:
o = getjson(f"{MET}/objects/{oid}")
if o and o.get("isPublicDomain") and (o.get("primaryImageSmall") or o.get("primaryImage")):
return o.get("primaryImageSmall") or o.get("primaryImage"), o.get("title", "")
return None, ""
def cache_img(url, key):
if not url:
return None
p = os.path.join(CACHE, re.sub(r"[^a-z0-9]+", "-", key.lower()).strip("-") + ".png")
if os.path.exists(p) and os.path.getsize(p) > 200:
return p
b = get(url)
if not b:
return None
try:
Image.open(BytesIO(b)).convert("RGBA").save(p)
return p
except Exception:
return None
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"]):
try:
return ImageFont.truetype(p, sz)
except Exception:
continue
return ImageFont.load_default()
def resolve_theme(theme):
roster = theme["roster"]
workers = int(os.environ.get("WORKERS", "3"))
with ThreadPoolExecutor(max_workers=workers) as ex:
thumbs = list(ex.map(wd_signature, roster))
sigs = []
for name, url in zip(roster, thumbs):
if url:
local = cache_img(url, theme["slug"] + "-" + name)
if local:
sigs.append({"name": name, "local": local})
met_url, met_title = met_public_domain(theme.get("met_query", theme["title"]))
met_local = cache_img(met_url, theme["slug"] + "-met") if met_url else None
return sigs, met_local, met_title
def build(theme, sigs, met_local, named):
n = len(sigs)
COLS = 5 if n > 12 else 4 if n > 6 else 3
rows = max(1, (n + COLS - 1) // COLS)
BOX_W, BOX_H = 300, 110
LABEL_H = 30 if named else 0
PAD = 22
CELL_W = BOX_W + PAD
CELL_H = BOX_H + LABEL_H + PAD
HEADER_H = 180
FOOT = 54
W = COLS * CELL_W + PAD
H = HEADER_H + rows * CELL_H + FOOT
INK = (26, 24, 22); MUTED = (122, 118, 112); LINE = (226, 222, 216)
img = Image.new("RGB", (W, H), (250, 249, 246))
d = ImageDraw.Draw(img)
# Met accent top-right
if met_local and os.path.exists(met_local):
try:
im = Image.open(met_local).convert("RGB"); im.thumbnail((150, 150), Image.LANCZOS)
img.paste(im, (W - im.width - PAD - 6, 22))
except Exception:
pass
d.text((PAD + 6, 46), theme["title"], font=font(46, True), fill=INK)
d.text((PAD + 8, 110), theme.get("blurb", "")[:120], font=font(19), fill=MUTED)
d.line([(PAD + 6, HEADER_H - 16), (W - PAD - 6, HEADER_H - 16)], fill=LINE, width=2)
for i, s in enumerate(sigs):
cx = PAD + (i % COLS) * CELL_W
cy = HEADER_H + (i // COLS) * CELL_H
try:
im = Image.open(s["local"]).convert("RGBA")
bg = Image.new("RGBA", im.size, (255, 255, 255, 255))
im = Image.alpha_composite(bg, im).convert("RGB")
im.thumbnail((BOX_W, BOX_H), Image.LANCZOS)
img.paste(im, (cx + (BOX_W - im.width) // 2, cy + (BOX_H - im.height) // 2))
except Exception:
pass
if named:
d.text((cx + 4, cy + BOX_H + 6), s["name"][:30], font=font(17, True), fill=INK)
d.text((PAD + 6, H - 38),
f"{n} signatures · every name traced to a public-domain Wikimedia source · accent artifact: The Met (public domain).",
font=font(13), fill=MUTED)
suffix = "-named" if named else ""
img.save(os.path.join(OUT, f"collage-{theme['slug']}{suffix}.png"))
def main():
only = sys.argv[1] if len(sys.argv) > 1 else None
themes = json.load(open(THEMES))
if only:
themes = [t for t in themes if t["slug"] == only]
catalog = json.load(open(CATALOG))
by_slug = {m["slug"]: m for m in catalog["murals"]}
built = []
for t in themes:
sigs, met_local, met_title = resolve_theme(t)
if len(sigs) < 4:
print(f"[{t['slug']}] only {len(sigs)} signatures resolved — SKIP (need >=4)")
continue
build(t, sigs, met_local, named=True)
build(t, sigs, met_local, named=False)
entry = {"code": t.get("code", t["slug"][:2]), "slug": t["slug"], "title": t["title"],
"blurb": t["blurb"], "sigs": len(sigs),
"roster": [s["name"] for s in sigs], "met_accent": met_title}
by_slug[t["slug"]] = entry
built.append((t["slug"], len(sigs)))
print(f"[{t['slug']}] -> {len(sigs)} sigs · met:{met_title[:30]}")
# preserve original first three, then themed by order
order = ["oldest-signatures", "declaration-of-independence", "politics"]
murals = [by_slug[s] for s in order if s in by_slug]
murals += [by_slug[t["slug"]] for t in json.load(open(THEMES)) if t["slug"] in by_slug and t["slug"] not in order]
catalog["murals"] = murals
json.dump(catalog, open(CATALOG, "w"), indent=2, ensure_ascii=False)
print(f"\nBuilt {len(built)} themed murals. Catalog now lists {len(murals)} murals total.")
if __name__ == "__main__":
main()