← back to CelebritySignatures
scripts/build-tifs.py
189 lines
#!/usr/bin/env python3
"""Build 150-DPI TIF PRINT MASTERS of the signature murals at 24 ft wide × 12 ft
tall — the file a client orders for a full feature wall.
24 ft × 150 dpi = 43,200 px wide
12 ft × 150 dpi = 21,600 px tall → ~933 megapixels, 2:1
The mural is RE-RENDERED at print scale (not upscaled from the small web PNG):
every signature is fetched as a large raster straight from Wikimedia's SVG
thumbnailer, so the ink stays crisp. Output is LZW-compressed TIF with the DPI
tag pinned to 150 — because the art is mostly white, the files stay modest
despite the pixel count.
Honest resolution note: signatures are vector SVGs but Commons caps the raster
width it will render, so very large cells are mild upscales of a ~2–3k px raster.
For a 12-ft wall viewed at room distance this is well past visually-sharp; for
true vector-perfect output we'd rasterize the SVGs locally (needs cairosvg).
Usage (via the venv that has Pillow + qrcode):
.venv/bin/python scripts/build-tifs.py tv # one category (by slug)
.venv/bin/python scripts/build-tifs.py all # every usable category
Set PUBLIC_BASE_URL to match the deployed QR host (defaults to the live site).
"""
import json, os, re, sys, time, urllib.parse, urllib.request
from io import BytesIO
from PIL import Image, ImageDraw
import qrcode
Image.MAX_IMAGE_PIXELS = None # 933 MP is intentional, not a decompression bomb
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"
BASE_URL = os.environ.get("PUBLIC_BASE_URL", "https://celebrity.designerwallcoverings.com").rstrip("/")
DPI = 150
W_FT, H_FT = 24, 12
TARGET_W, TARGET_H = W_FT * 12 * DPI, H_FT * 12 * DPI # 43200 x 21600
MARGIN = 90 # white gutter inside each cell (px @150dpi ≈ 0.6")
FETCH_W = 2600 # raster width requested per signature from Commons
QR_IN = 9 # printed QR size in inches on the wall
CODE = {"declaration-of-independence": "d", "politics": "p", "sports": "s",
"hollywood": "h", "movies-classic": "c", "tv": "t"}
os.makedirs(CACHE, exist_ok=True)
os.makedirs(OUT, exist_ok=True)
def file_title(url):
return "File:" + urllib.parse.unquote(url.rstrip("/").split("/")[-1])
def resolve_thumbs(titles, width):
out = {}
for i in range(0, len(titles), 50):
q = urllib.parse.urlencode({
"action": "query", "format": "json", "prop": "imageinfo",
"iiprop": "url", "iiurlwidth": str(width), "titles": "|".join(titles[i:i+50]),
})
try:
j = json.load(urllib.request.urlopen(
urllib.request.Request(COMMONS + "?" + q, headers={"User-Agent": UA}), 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]
out[norm.get(p.get("title"), p.get("title"))] = 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:] + f"_{FETCH_W}"
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:
raw = urllib.request.urlopen(
urllib.request.Request(url, headers={"User-Agent": UA}), timeout=60).read()
im = Image.open(BytesIO(raw)).convert("RGBA")
im.save(cp); time.sleep(0.35); return im
except urllib.error.HTTPError as e:
if e.code == 429:
w = 8 * (attempt + 1); print(f" 429 backoff {w}s"); time.sleep(w); continue
print(" img fail:", url[:60], e); return None
except Exception as e:
print(" img fail:", url[:60], e); return None
return None
def signature_like(im):
rgb = Image.alpha_composite(Image.new("RGBA", im.size, (255,)*4), im).convert("RGB")
s = rgb.resize((72, 72)); g = list(s.convert("L").getdata()); n = len(g)
light = sum(1 for v in g if v >= 200) / n
dark = sum(1 for v in g if v <= 90) / n
sat = sum(p[1] for p in s.convert("HSV").getdata()) / n
return sat < 60 and light >= 0.30 and dark <= 0.30
def fit_cell(im, cw, ch):
"""Flatten onto white, scale to fit (cw,ch) preserving aspect (upscaling ok)."""
im = Image.alpha_composite(Image.new("RGBA", im.size, (255,)*4), im).convert("RGB")
r = min(cw / im.width, ch / im.height)
return im.resize((max(1, int(im.width*r)), max(1, int(im.height*r))), Image.LANCZOS)
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")
modules = qr.modules_count + 2 * qr.border
box = max(4, round(px / modules))
return img.resize((box * modules, box * modules), Image.NEAREST)
def build(category, rows, slug):
rows = [r for r in rows if r["usable_in_commercial_collage"] == "yes"]
n = len(rows)
if not n:
print(f"[{category}] no usable rows"); return None
cols = min(10, max(1, round(n ** 0.5)))
rows_ct = (n + cols - 1) // cols
cw, ch = TARGET_W // cols, TARGET_H // rows_ct
print(f"[{category}] {n} sigs · {cols}×{rows_ct} grid · cell {cw}×{ch}px · canvas {TARGET_W}×{TARGET_H}")
titles = [file_title(r["signature_image_url"]) for r in rows]
thumbs = resolve_thumbs(titles, FETCH_W)
canvas = Image.new("RGB", (TARGET_W, TARGET_H), (255, 255, 255))
placed = 0
for r in rows:
url = thumbs.get(file_title(r["signature_image_url"])) or r["signature_image_url"]
im = fetch_image(url)
if not im or not signature_like(im):
continue
sig = fit_cell(im, cw - 2*MARGIN, ch - 2*MARGIN)
cx = (placed % cols) * cw
cy = (placed // cols) * ch
canvas.paste(sig, (cx + (cw - sig.width)//2, cy + (ch - sig.height)//2))
placed += 1
if placed % 20 == 0:
print(f" placed {placed}/{n}")
# centred print QR — same "scan to reveal names" feature, sized for the wall
qpx = QR_IN * DPI
qr = make_qr(f"{BASE_URL}/k/{CODE.get(slug, slug)}", qpx)
qx, qy = (TARGET_W - qr.width)//2, (TARGET_H - qr.height)//2
pad = int(0.6 * DPI)
ImageDraw.Draw(canvas).rectangle(
[qx-pad, qy-pad, qx+qr.width+pad, qy+qr.height+pad], fill="white", outline=(150,146,138), width=6)
canvas.paste(qr, (qx, qy))
path = os.path.join(OUT, f"mural-{slug}-24x12ft-150dpi.tif")
print(f" encoding LZW TIF ({placed} sigs placed)…")
canvas.save(path, format="TIFF", compression="tiff_lzw", dpi=(DPI, DPI))
mb = os.path.getsize(path) / 1e6
print(f"[{category}] ✅ {os.path.basename(path)} ({mb:.1f} MB, {TARGET_W}×{TARGET_H} @ {DPI}dpi)\n")
return path
def main():
which = (sys.argv[1] if len(sys.argv) > 1 else "all").lower()
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 (Classic)", "TV"]
print(f"Print masters → {W_FT}ft × {H_FT}ft @ {DPI}dpi ({TARGET_W}×{TARGET_H}px) 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:
p = build(cat, sorted(by[cat], key=lambda x: x["rank"]), slug)
if p: made.append(p)
print(f"Built {len(made)} TIF print master(s) in output/")
if __name__ == "__main__":
main()