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