Files
gig-line/Tools/facade_detail_concepts.py
T
zwitschi fe8a23e5ee feat(tools): rephrase the concept studies as frontal building views
Owner instruction: show the building, its entrance, the neighbouring buildings,
front-on, and drop the close-up detail framing.

- Studies replaced: door-recess / entrance-wide / column-order /
  entablature-cornice / parapet-motto / window-rhythm / street-wall /
  banner-detail -> building-front, building-entrance, building-with-neighbours,
  building-street-front, building-front-oblique, theatre-front-with-neighbours.
- ANCHOR_SUFFIX now permits REFRAMING (step back, zoom out, widen the view) so the
  whole building can fit, while the architecture stays fixed; previously it pinned
  the camera angle to the reference, which forced cropped results.
- Prompts state 'exterior' explicitly (the entrance study otherwise produced an
  interior) and require sky above the roofline and nothing cut off.

Vision pre-check on the regenerated set: building-entrance and building-street-front
read as full-elevation frontal plates; the studies anchored on the tightly cropped
2009 facade photo stay cropped, because the model will not invent the parapet and
sky the photograph does not contain. That limitation is recorded in the study table
comment - a full 6840 plate needs a full-elevation photograph we do not hold.

Tests: 40 pass (study table assertions unchanged).
2026-11-08 17:35:20 +01:00

329 lines
14 KiB
Python

#!/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/facade_detail_concepts.py --list
python3 Tools/facade_detail_concepts.py --only door-recess,parapet-motto
python3 Tools/facade_detail_concepts.py --all
python3 Tools/facade_detail_concepts.py --all --model google/gemini-2.5-flash-image
Output: `Art/reference/facade-details/<study>.png` plus a `<study>.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:
"""`<study>.png`, or `<study>-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 <study>.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())