#!/usr/bin/env python3 """Generate frontal building plates from the local reference photography. Each study takes one photograph we already hold (`Art/reference/`, gitignored) as the geometric anchor and asks an image model to re-stage it as a clean **frontal view of the building** — the whole elevation, the entrance in its facade, or the building with its neighbours — evenly lit, no crowd, no staging, so the modelling pass has something measurable instead of a tourist photo. Deliberately **not** close-up detail crops: a doorway or cornice crop cannot be measured for proportion, which is what the modelling pass actually needs. python3 Tools/gen/facade_detail_concepts.py --list python3 Tools/gen/facade_detail_concepts.py --only door-recess,parapet-motto python3 Tools/gen/facade_detail_concepts.py --all python3 Tools/gen/facade_detail_concepts.py --all --model google/gemini-2.5-flash-image Output: `Art/reference/facade-details/.png` plus a `.refgen.json` sidecar recording the model, the source photograph, the prompt and the SHA-256 of the result. `Art/reference/` is gitignored, so **neither the images nor the sidecars are committed** — they are local working material. The study list and the tool are the committed part. Design notes ------------ * Standard library only: the OpenRouter chat-completions call is `urllib`, and the returned `data:` URL is decoded with `base64` and written straight to disk. * An existing output is never overwritten; `-v2`, `-v3` … are used instead, so re-running after a prompt edit keeps both studies comparable. * Generation costs money, so it is never automatic: `--list` is free, and every generating run prints the model and the study count first. * The token is read from `OPENROUTER_API_KEY` or the ignored `.env` and is never written to a sidecar or a log line. """ from __future__ import annotations import argparse import base64 import json import os import re import sys import time import urllib.error import urllib.request ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) REFERENCE_DIR = os.path.join(ROOT, "Art", "reference") OUT_DIR = os.path.join(REFERENCE_DIR, "facade-details") API_URL = "https://openrouter.ai/api/v1/chat/completions" DEFAULT_MODEL = "google/gemini-2.5-flash-image" # The instruction appended to every study. Kept identical to the sibling # project's convention so the two sets stay comparable: the photograph fixes the # geometry, the prompt only restages light, weather, people and surface. ANCHOR_SUFFIX = ( "Use the attached reference photograph as the source of the architecture: keep " "the building's proportions, storey count, column rhythm, band heights and " "signage placement exactly as photographed. You may REFRAME — step back across " "the street, zoom out or widen the field of view — so that the whole building " "fits in the frame from the pavement to the parapet; the reference is often " "cropped and the plate must not be. Keep the view exterior and frontal (or " "three-quarter where asked). Restage only lighting, weather, people and surface " "condition. Do not invent extra storeys, wings or ornaments, and do not change " "the architecture to fit the frame." ) # name -> (reference photo relative to Art/reference/, what the plate must show) # # Verified framing (vision pre-check, 2026-11-09): the model follows the reference # crop closely, so a plate is only as wide as its source photo. `building-entrance` # and `building-street-front` come back as full-elevation frontal views; the studies # anchored on the tightly cropped 2009 facade photo stay cropped, because the model # will not invent the parapet and sky that the photograph does not contain. A full # building plate for 6840 needs a full-elevation photograph, which the local set # does not have (see Docs/reference-notes-hollywood.md). # # Every study asks for a *frontal view of a building*, not a crop of one detail: # the whole elevation, the entrance in its facade, or the building together with # its neighbours. Close-up framing is deliberately absent — a detail crop cannot # be measured for proportion, which is what the modelling pass needs. STUDIES: dict[str, tuple[str, str]] = { "building-front": ( "hollywood/el-capitan-facade-day-wikimedia-2009-10-27.jpg", "A frontal view of the complete building shown in the reference " "photograph: the whole elevation from the pavement to the parapet fits in " "the frame with clear sky above the roofline and pavement below the ground " "floor, shot straight on and centred. Nothing may be cut off at the top or " "bottom. Even daylight, no crowd, no vehicles.", ), "building-entrance": ( "el-capitan-entrance/01-hollywood-el-capitan-entrance.jpg", "A frontal view of the building in the reference photograph, framed so its " "street entrance is clearly visible as part of the facade — the doors and " "the wall around them in proportion, not a close-up of the doorway. " "Include the pavement in front. Even daylight, no crowd.", ), "building-with-neighbours": ( "hollywood/el-capitan-theatre-blade-sign-day-wikimedia-2008-10.jpg", "A straight-on view of the street wall in the reference photograph: the " "building and its neighbouring buildings either side, each at full height " "from pavement to parapet with sky above, the joins between them visible, " "nothing cut off. Shot from across the road, parallel to the frontage. " "Even daylight, no crowd.", ), "building-street-front": ( "hollywood/walk-of-fame-terrazzo-ovation-day-cc0-2019-06-20.jpg", "A frontal view along the pavement of the building in the reference " "photograph: the building's frontage seen from street level with its " "neighbours continuing either side and the pavement in the foreground. " "Straight on, even daylight, no crowd.", ), "building-front-oblique": ( "hollywood/el-capitan-facade-day-wikimedia-2009-10-27.jpg", "A three-quarter view of the complete building in the reference " "photograph: the frontal elevation and one side visible together so the " "building's depth and mass read, with its neighbouring building at one " "edge. The whole building stays in frame. Even daylight, no crowd.", ), "theatre-front-with-neighbours": ( "el-capitan-theater-detail/01-el-capitan-marquee.jpg", "A frontal view of the theatre in the reference photograph together with " "the buildings either side of it, the whole frontage and its full height " "in frame, shot from across the road. Even daylight, no crowd, marquee " "lettering blocked out as unreadable texture.", ), } def load_token() -> str: token = os.environ.get("OPENROUTER_API_KEY") if token: return token.strip() env_path = os.path.join(ROOT, ".env") if os.path.exists(env_path): with open(env_path, encoding="utf-8") as handle: for line in handle: line = line.strip() if line.startswith("OPENROUTER_API_KEY="): return line.split("=", 1)[1].strip().strip('"').strip("'") raise SystemExit("no OpenRouter key: set OPENROUTER_API_KEY or add it to .env") def build_prompt(study: str) -> str: """The full prompt for a study: its request plus the fixed anchor suffix.""" if study not in STUDIES: raise KeyError(f"unknown study {study!r}; known: {sorted(STUDIES)}") return f"{STUDIES[study][1]}\n\n{ANCHOR_SUFFIX}" def reference_path(study: str) -> str: return os.path.join(REFERENCE_DIR, STUDIES[study][0]) def missing_references() -> list[str]: """Studies whose source photograph is not present locally.""" return [name for name in STUDIES if not os.path.isfile(reference_path(name))] def next_free_path(study: str, directory: str = OUT_DIR) -> str: """`.png`, or `-v2.png`, `-v3` … if the name is taken.""" base = os.path.join(directory, f"{study}.png") if not os.path.exists(base): return base index = 2 while True: candidate = os.path.join(directory, f"{study}-v{index}.png") if not os.path.exists(candidate): return candidate index += 1 def data_url(path: str) -> str: suffix = os.path.splitext(path)[1].lower() mime = {".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png"}.get(suffix) if not mime: raise SystemExit(f"unsupported reference type: {path}") with open(path, "rb") as handle: return f"data:{mime};base64,{base64.b64encode(handle.read()).decode('ascii')}" def decode_image(payload: dict) -> bytes: """Extract the returned image bytes from a chat-completions payload.""" message = (payload.get("choices") or [{}])[0].get("message", {}) urls = [item.get("image_url", {}).get("url", "") for item in (message.get("images") or []) if isinstance(item, dict)] if not urls: match = re.search(r"data:image/[a-z]+;base64,[A-Za-z0-9+/=]+", str(message.get("content") or "")) if match: urls.append(match.group(0)) if not urls or not urls[0]: raise SystemExit(f"model returned no image data: {json.dumps(payload)[:400]}") url = urls[0] if url.startswith("data:"): return base64.b64decode(url.split(",", 1)[1]) with urllib.request.urlopen(urllib.request.Request(url), timeout=120) as response: return response.read() def generate(study: str, token: str, model: str, force: bool = False) -> str: """Generate one study; returns the written path. Never overwrites by default.""" path = next_free_path(study) if force and os.path.exists(os.path.join(OUT_DIR, f"{study}.png")): path = os.path.join(OUT_DIR, f"{study}.png") # Write to a temp name then replace, so a failed call cannot destroy the # previous result. target = path + ".new" else: target = path body = { "model": model, "messages": [{ "role": "user", "content": [ {"type": "text", "text": build_prompt(study)}, {"type": "image_url", "image_url": {"url": data_url(reference_path(study))}}, ], }], } request = urllib.request.Request( API_URL, data=json.dumps(body).encode("utf-8"), headers={ "Authorization": f"Bearer {token}", "Content-Type": "application/json", "HTTP-Referer": "https://git.allucanget.biz/zwitschi/gig-line", "X-Title": "gig-line facade details", }, method="POST", ) payload = None for attempt in range(3): try: with urllib.request.urlopen(request, timeout=180) as response: payload = json.load(response) break except urllib.error.HTTPError as exc: if exc.code == 429 or exc.code >= 500: wait = 5 * (attempt + 1) sys.stderr.write(f" HTTP {exc.code}; retrying in {wait}s\n") time.sleep(wait) continue detail = exc.read().decode("utf-8", "replace")[:300] raise SystemExit(f"{study}: HTTP {exc.code} — {detail}") except urllib.error.URLError as exc: sys.stderr.write(f" network error ({exc.reason}); retrying\n") time.sleep(5 * (attempt + 1)) if payload is None: raise SystemExit(f"{study}: no response after retries") image = decode_image(payload) os.makedirs(OUT_DIR, exist_ok=True) with open(target, "wb") as handle: handle.write(image) if target != path: os.replace(target, path) import hashlib sidecar = { "study": study, "generated": os.path.basename(path), "generated_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "model": model, "prompt": build_prompt(study), "reference": { "file": STUDIES[study][0], "note": "local reference photography; Art/reference is gitignored and never shipped", }, "sha256": hashlib.sha256(image).hexdigest(), "bytes": len(image), "redistribution": "local development reference only; not committed, not shipped", } sidecar_path = os.path.splitext(path)[0] + ".refgen.json" with open(sidecar_path, "w", encoding="utf-8") as handle: json.dump(sidecar, handle, indent=2) if token in json.dumps(sidecar): raise SystemExit("refusing to write a sidecar containing the token") return path def main(argv=None) -> int: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--list", action="store_true", help="list studies and exit (free)") ap.add_argument("--all", action="store_true", help="generate every study") ap.add_argument("--only", help="comma-separated study names") ap.add_argument("--model", default=DEFAULT_MODEL) ap.add_argument("--force", action="store_true", help="replace the existing .png instead of writing a -vN") args = ap.parse_args(argv) if args.list or not (args.all or args.only): missing = set(missing_references()) print(f"{len(STUDIES)} studies; model {args.model}") for name, (ref, _prompt) in STUDIES.items(): state = "MISSING reference" if name in missing else "ready" print(f" {name:22} {state:18} <- {ref}") if missing: print(f"\n{len(missing)} study(ies) cannot run without their reference photograph.") return 0 names = list(STUDIES) if args.all else [s.strip() for s in args.only.split(",") if s.strip()] unknown = [n for n in names if n not in STUDIES] if unknown: raise SystemExit(f"unknown study(ies): {unknown}; known: {sorted(STUDIES)}") blocked = [n for n in names if n in set(missing_references())] if blocked: raise SystemExit(f"missing reference photograph for: {blocked}") token = load_token() print(f"generating {len(names)} study(ies) with {args.model} -> {OUT_DIR}") for index, name in enumerate(names, 1): print(f"[{index}/{len(names)}] {name}") try: path = generate(name, token, args.model, force=args.force) except SystemExit as exc: sys.stderr.write(f" FAILED: {exc}\n") continue print(f" wrote {os.path.relpath(path, ROOT)}") return 0 if __name__ == "__main__": raise SystemExit(main())