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