← back to CelebritySignatures

scripts/build-portrait-murals.py

291 lines

#!/usr/bin/env python3
"""Build PORTRAIT murals: each cell = the person's PORTRAIT above their SIGNATURE.

Same legal spine as the signature murals — nothing is included unless it is
provably safe. The portrait is pulled from the person's Wikidata P18 image and is
ONLY used if its Wikimedia Commons license is public-domain / CC0 / CC-BY(-SA).
Any person whose portrait is missing or restricted falls back to a clean
signature-only cell, so the mural never leans on an unlicensed photo.

Outputs (web resolution, for the storefront + gallery):
  output/portrait-<slug>.png        — names hidden, centre QR ("scan to reveal")
  output/portrait-<slug>-named.png  — labelled key (QR target)

Run via the venv:  .venv/bin/python scripts/build-portrait-murals.py [slug|all]
"""

import json, os, re, sys, 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 = {"User-Agent": "CelebritySignatures-research/1.0 (steve@designerwallcoverings.com)"}
COMMONS = "https://commons.wikimedia.org/w/api.php"
WIKIDATA = "https://www.wikidata.org/w/api.php"
BASE_URL = os.environ.get("PUBLIC_BASE_URL", "https://celebrity.designerwallcoverings.com").rstrip("/")
SAFE = ("public domain", "pd", "cc0", "cc by", "cc-by")   # license substrings we accept
BAD = ("fair use", "non-free", "all rights", "copyright")  # hard-reject substrings
CODE = {"declaration-of-independence": "d", "politics": "p", "sports": "s",
        "hollywood": "h", "movies-classic": "c", "tv": "t"}

# layout
POR_W, POR_H = 210, 250          # portrait box (cropped to fill)
SIG_W, SIG_H = 220, 76
GAP = 10
PAD = 18
LABEL_H = 24
BOX_W = max(POR_W, SIG_W)
BOX_H = POR_H + GAP + SIG_H
CELL_W = BOX_W + PAD
CELL_H = BOX_H + PAD
CELL_H_NAMED = CELL_H + LABEL_H
HEADER_H = 132
FOOTER_H = 46
INK = (22, 22, 22); MUTED = (120, 120, 120)
os.makedirs(CACHE, exist_ok=True); os.makedirs(OUT, exist_ok=True)


def get_json(u):
    return json.load(urllib.request.urlopen(urllib.request.Request(u, headers=UA), timeout=40))


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.ttf", "/Library/Fonts/Arial.ttf"]:
        try: return ImageFont.truetype(p, sz)
        except Exception: continue
    return ImageFont.load_default()


def fetch(url):
    h = re.sub(r"[^a-zA-Z0-9]", "_", url)[-90:]
    cp = os.path.join(CACHE, h + ".img.png")
    if os.path.exists(cp):
        try: return Image.open(cp).convert("RGBA")
        except Exception: pass
    for attempt in range(5):
        try:
            raw = urllib.request.urlopen(urllib.request.Request(url, headers=UA), timeout=60).read()
            im = Image.open(BytesIO(raw)).convert("RGBA"); im.save(cp); time.sleep(0.3); return im
        except urllib.error.HTTPError as e:
            if e.code == 429: time.sleep(8*(attempt+1)); continue
            return None
        except Exception: return None
    return None


def p18_for(qids):
    """Q-id -> P18 filename (batched)."""
    out = {}
    for i in range(0, len(qids), 50):
        b = qids[i:i+50]
        j = get_json(WIKIDATA + "?" + urllib.parse.urlencode(
            {"action": "wbgetentities", "format": "json", "props": "claims", "ids": "|".join(b)}))
        for q in b:
            cl = j.get("entities", {}).get(q, {}).get("claims", {}).get("P18")
            if cl:
                try: out[q] = cl[0]["mainsnak"]["datavalue"]["value"]
                except Exception: pass
        time.sleep(0.2)
    return out


def portrait_info(filenames):
    """File title -> (thumburl@width, license) for the portrait box, batched."""
    out = {}
    titles = ["File:" + f.replace(" ", "_") for f in filenames]
    for i in range(0, len(titles), 40):
        b = titles[i:i+40]
        j = get_json(COMMONS + "?" + urllib.parse.urlencode(
            {"action": "query", "format": "json", "prop": "imageinfo",
             "iiprop": "url|extmetadata", "iiurlwidth": str(POR_W*2), "titles": "|".join(b)}))
        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]
            lic = ii.get("extmetadata", {}).get("LicenseShortName", {}).get("value", "")
            key = norm.get(p.get("title"), p.get("title"))
            out[key] = (ii.get("thumburl") or ii.get("url"), lic)
        time.sleep(0.2)
    return out


def license_ok(lic):
    l = (lic or "").lower()
    if any(b in l for b in BAD): return False
    return any(s in l for s in SAFE)


def cover(im, w, h):
    """Scale to COVER w×h then centre-crop (for portraits)."""
    im = Image.alpha_composite(Image.new("RGBA", im.size, (255,)*4), im).convert("RGB")
    r = max(w/im.width, h/im.height)
    im = im.resize((max(1,int(im.width*r)), max(1,int(im.height*r))), Image.LANCZOS)
    x = (im.width-w)//2; y = (im.height-h)//2
    return im.crop((x, y, x+w, y+h))


def fit(im, w, h):
    """Scale to FIT w×h preserving aspect on white (for signatures)."""
    im = Image.alpha_composite(Image.new("RGBA", im.size, (255,)*4), im).convert("RGB")
    r = min(w/im.width, h/im.height, 1.0)
    return im.resize((max(1,int(im.width*r)), max(1,int(im.height*r))), Image.LANCZOS)


def sig_title(url):
    return "File:" + urllib.parse.unquote(url.rstrip("/").split("/")[-1])


def sig_thumbs(rows):
    out = {}
    titles = [sig_title(r["signature_image_url"]) for r in rows]
    for i in range(0, len(titles), 50):
        b = titles[i:i+50]
        j = get_json(COMMONS + "?" + urllib.parse.urlencode(
            {"action": "query", "format": "json", "prop": "imageinfo",
             "iiprop": "url", "iiurlwidth": "480", "titles": "|".join(b)}))
        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]
            out[norm.get(p.get("title"), p.get("title"))] = ii.get("thumburl") or ii.get("url")
        time.sleep(0.2)
    return out


def make_qr(data, px):
    qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_M, box_size=10, border=2)
    qr.add_data(data); qr.make(fit=True)
    img = qr.make_image(fill_color="black", back_color="white").convert("RGB")
    m = qr.modules_count + 2*qr.border; box = max(4, round(px/m))
    return img.resize((box*m, box*m), Image.NEAREST)


def render(category, cells, named, slug, with_portrait, total):
    n = len(cells)
    cell_h = CELL_H_NAMED if named else CELL_H
    cols = min(8, 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)
    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)
    mode = "names shown" if named else "names hidden · scan centre code for the key"
    d.text((PAD+10,84), f"{n} portraits & signatures · {with_portrait} with portrait · {mode} · public domain / openly licensed · Wikimedia Commons",
           font=font(16), fill=MUTED)

    for idx, c in enumerate(cells):
        cx = PAD + (idx % cols)*CELL_W
        cy = HEADER_H + (idx // cols)*cell_h + PAD//2
        # portrait
        bx = cx + (CELL_W - BOX_W)//2
        if c["portrait"] is not None:
            canvas.paste(c["portrait"], (bx + (BOX_W-POR_W)//2, cy))
            d.rectangle([bx+(BOX_W-POR_W)//2, cy, bx+(BOX_W-POR_W)//2+POR_W, cy+POR_H], outline=(214,210,202), width=1)
        # signature under portrait
        s = c["sig"]
        sy = cy + POR_H + GAP
        canvas.paste(s, (cx + (CELL_W - s.width)//2, sy + (SIG_H - s.height)//2))
        if named:
            f = font(12); label = c["name"]
            while d.textlength(label, font=f) > CELL_W-8 and len(label) > 4: label = label[:-2]
            if label != c["name"]: label = label[:-1]+"…"
            tw = d.textlength(label, font=f)
            d.text((cx+(CELL_W-tw)/2, cy+BOX_H+4), label, font=f, fill=MUTED)

    if not named:
        grid_h = rows_ct*cell_h
        qr = make_qr(f"{BASE_URL}/k/{CODE.get(slug, slug)}", 200)
        cf = font(14, bold=True); cap = "SCAN TO REVEAL NAMES"
        cw = int(d.textlength(cap, font=cf)); pin = 16; gap = 10
        pw = max(qr.width, cw)+pin*2; ph = qr.height+gap+18+pin*2
        px = (W-pw)//2; py = HEADER_H + (grid_h-ph)//2
        d.rectangle([px+5,py+6,px+pw+5,py+ph+6], fill=(214,210,202))
        d.rectangle([px,py,px+pw,py+ph], fill=(255,255,255), outline=(150,146,138), width=2)
        canvas.paste(qr, (px+(pw-qr.width)//2, py+pin))
        d.text((px+(pw-cw)/2, py+pin+qr.height+gap), cap, font=cf, fill=INK)

    d.text((PAD+8, H-FOOTER_H+14),
           "Portraits used only where public-domain / openly licensed; others shown by signature alone. Living-celebrity rows excluded.",
           font=font(13), fill=MUTED)
    suffix = "-named" if named else ""
    prefix = "portrait-safe-" if os.environ.get("SAFE_ONLY") else "portrait-"
    path = os.path.join(OUT, f"{prefix}{slug}{suffix}.png")
    canvas.save(path, "PNG")
    print(f"[{category}] -> {os.path.basename(path)} ({n} cells, {with_portrait} portraits, {cols}×{rows_ct}, {W}×{H})")
    return path


def build(category, rows, slug):
    rows = [r for r in rows if r["usable_in_commercial_collage"] == "yes"]
    qids = [r["wikidata"].rstrip("/").split("/")[-1] for r in rows]
    p18 = p18_for(qids)
    pinfo = portrait_info(list(set(p18.values()))) if p18 else {}
    sigs = sig_thumbs(rows)

    cells = []; with_portrait = 0
    for r, q in zip(rows, qids):
        # signature (required)
        surl = sigs.get(sig_title(r["signature_image_url"])) or r["signature_image_url"]
        sim = fetch(surl)
        if not sim: continue
        sig = fit(sim, SIG_W, SIG_H)
        # portrait (optional, license-gated)
        por = None
        fn = p18.get(q)
        if fn:
            url, lic = pinfo.get("File:" + fn.replace(" ", "_"), (None, ""))
            if url and license_ok(lic):
                pim = fetch(url)
                if pim: por = cover(pim, POR_W, POR_H); with_portrait += 1
        cells.append({"name": r["full_name"], "sig": sig, "portrait": por})
    if not cells:
        print(f"[{category}] no cells"); return []
    return [render(category, cells, True, slug, with_portrait, len(cells)),
            render(category, cells, False, slug, with_portrait, len(cells))]


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


# Publicity-rights gate: California §3344.1 protects a deceased personality's
# name/likeness/signature for 70 YEARS after death. So only people who died on or
# before this cutoff are clear of right-of-publicity exposure for commercial sale.
PUBLICITY_SAFE_CUTOFF = 1956   # 2026 − 70


def main():
    which = (sys.argv[1] if len(sys.argv) > 1 else "all").lower()
    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 > PUBLICITY_SAFE_CUTOFF:   # unknown/too-recent → exclude
                continue
        by.setdefault(r["category"], []).append(r)
    if safe_only:
        print("⚖️  PUBLICITY-SAFE mode: only people who died ≤", PUBLICITY_SAFE_CUTOFF, "\n")
    order = ["Declaration of Independence", "Politics", "Sports", "Hollywood", "Movies (Classic)", "TV"]
    print(f"Portrait murals · QR host {BASE_URL}\n")
    made = []
    for cat in order:
        slug = re.sub(r"[^a-z0-9]+", "-", cat.lower()).strip("-")
        if which not in ("all", slug): continue
        if cat in by:
            made += build(cat, sorted(by[cat], key=lambda x: x["rank"]), slug)
    print(f"\nBuilt {len(made)} portrait-mural PNGs in output/")


if __name__ == "__main__":
    main()