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