diff --git a/TODO.md b/TODO.md index 28a5fef..84126e9 100644 --- a/TODO.md +++ b/TODO.md @@ -414,8 +414,11 @@ facade evidence is still the 2009 CC BY frame. The 8 downloaded candidates are i `scene_concepts._reference_source` requiring a refs-board `SIDECAR.md` row (or an explicit `--reference-unchecked`). Verified live on the El Capitan day concept anchored on the CC BY 2.0 2009 facade photo; tests in `ReferenceGateTests`. -- [ ] Add a sidecar-driven variant pass: for each real photo, generate day, night and weathered - variants of the same framing so the concept board stays comparable with the photo board. +- [x] Add a sidecar-driven variant pass: `scene_concepts.py anchor --reference + --treatments day,night,weathered` generates same-framing variants with treatment clauses, + one `.refgen.json` provenance sidecar per frame. Verified live on the facade photo (night + + overcast frames committed; the weathered treatment reads as mild overcast — noted on the + board). Tests in `AnchorTests`. - [-] Generate facade, street and signage concept frames for the target block (`scene_concepts.py generate --reference ...`), commit only the selected frames, and record model, prompt, spend **and source photo** in the block sidecar. (First frame done: diff --git a/docs/asset-tooling.md b/docs/asset-tooling.md index 6b7a0f9..dc417bb 100644 --- a/docs/asset-tooling.md +++ b/docs/asset-tooling.md @@ -207,6 +207,14 @@ generation writes a `.refgen.json` sidecar next to the frame (`model`, reference geometry suffix) so a concept frame can always be traced back to its evidence photo. Where a generated frame and its reference photo disagree, the photo wins for shape. +### Photo-anchored variant pass + +`tools/scene_concepts.py anchor --reference --context "..." --treatments day,night,weathered +--out-dir docs/screens/concepts/` generates several restaged conditions (day, night, +weathered) of one reference photo's framing, each with its `.refgen.json` provenance sidecar, so a +concept board stays comparable with its photo board. The reference passes the same SIDECAR licence +gate as `--reference` on `generate`. + ### Reference-board research (`refboard`) `tools/refboard.py` searches, fetches and documents Wikimedia Commons photos of a real location for diff --git a/docs/screens/concepts/hollywood_boulevard/README.md b/docs/screens/concepts/hollywood_boulevard/README.md index c144ad8..afd3b7a 100644 --- a/docs/screens/concepts/hollywood_boulevard/README.md +++ b/docs/screens/concepts/hollywood_boulevard/README.md @@ -22,6 +22,21 @@ The facade geometry (motto band, column rhythm, lattice windows, banner slots, blade sign) follows the photograph; the street is restaged clean for art direction. +## Facade — night (photo-anchored) + +![El Capitan facade at night, anchored on the facade photo](el_capitan_facade_night.png) + +Same facade photo as the day frame, restaged to night with practical lighting +only (marquee, signage, street lamps) and wet pavement. The blade sign, banner +slots and lattice windows follow the photograph. + +## Facade — overcast (photo-anchored) + +![El Capitan facade under flat overcast light](el_capitan_facade_weathered.png) + +Same framing under flat overcast light. The weathering requested came out +mild — treat this as the overcast target, not a decay reference. + ## Entrance — night ![El Capitan Entertainment Centre entrance at night](hollywood_boulevard_entrance_night.png) diff --git a/docs/screens/concepts/hollywood_boulevard/el_capitan_facade_night.png b/docs/screens/concepts/hollywood_boulevard/el_capitan_facade_night.png new file mode 100644 index 0000000..3f375af Binary files /dev/null and b/docs/screens/concepts/hollywood_boulevard/el_capitan_facade_night.png differ diff --git a/docs/screens/concepts/hollywood_boulevard/el_capitan_facade_night.refgen.json b/docs/screens/concepts/hollywood_boulevard/el_capitan_facade_night.refgen.json new file mode 100644 index 0000000..de63b77 --- /dev/null +++ b/docs/screens/concepts/hollywood_boulevard/el_capitan_facade_night.refgen.json @@ -0,0 +1,12 @@ +{ + "concept": "el_capitan_facade_night", + "generated": "el_capitan_facade_night.png", + "model": "google/gemini-2.5-flash-image", + "reference": { + "file": "docs/screens/refs/hollywood/el-capitan-facade-day-wikimedia-2009-10-27.jpg", + "licence": "CC BY 2.0", + "page": "https://commons.wikimedia.org/wiki/File:El_Capitan_Theater_frontal_fachade-The_Masonic_Temple.jpg", + "licence_checked": true + }, + "suffix": "Use the attached reference photograph as the fixed source of geometry and composition: keep the building's proportions, column rhythm, band heights, signage placement and camera angle exactly as photographed. Only restage lighting, weather, people and surface condition where the prompt asks for it. Do not invent extra storeys, wings or ornaments that the photo does not show." +} diff --git a/docs/screens/concepts/hollywood_boulevard/el_capitan_facade_weathered.png b/docs/screens/concepts/hollywood_boulevard/el_capitan_facade_weathered.png new file mode 100644 index 0000000..f0e3771 Binary files /dev/null and b/docs/screens/concepts/hollywood_boulevard/el_capitan_facade_weathered.png differ diff --git a/docs/screens/concepts/hollywood_boulevard/el_capitan_facade_weathered.refgen.json b/docs/screens/concepts/hollywood_boulevard/el_capitan_facade_weathered.refgen.json new file mode 100644 index 0000000..714ded1 --- /dev/null +++ b/docs/screens/concepts/hollywood_boulevard/el_capitan_facade_weathered.refgen.json @@ -0,0 +1,12 @@ +{ + "concept": "el_capitan_facade_weathered", + "generated": "el_capitan_facade_weathered.png", + "model": "google/gemini-2.5-flash-image", + "reference": { + "file": "docs/screens/refs/hollywood/el-capitan-facade-day-wikimedia-2009-10-27.jpg", + "licence": "CC BY 2.0", + "page": "https://commons.wikimedia.org/wiki/File:El_Capitan_Theater_frontal_fachade-The_Masonic_Temple.jpg", + "licence_checked": true + }, + "suffix": "Use the attached reference photograph as the fixed source of geometry and composition: keep the building's proportions, column rhythm, band heights, signage placement and camera angle exactly as photographed. Only restage lighting, weather, people and surface condition where the prompt asks for it. Do not invent extra storeys, wings or ornaments that the photo does not show." +} diff --git a/tools/scene_concepts.py b/tools/scene_concepts.py index 679e545..f0d6ccf 100644 --- a/tools/scene_concepts.py +++ b/tools/scene_concepts.py @@ -31,6 +31,7 @@ import argparse import json import math import os +import re import sys from pathlib import Path @@ -1088,6 +1089,62 @@ def render_markdown(report: dict) -> str: # --------------------------------------------------------------------------- +# Treatment clauses for the photo-anchored variant pass: one framing, several +# restaged conditions, so a concept board stays comparable with its photo board. +TREATMENTS = { + "day": "clean midday daylight, soft haze fading the far street to beige", + "night": "night scene lit by practical lights only (marquee, signage, street " + "lamps), dark sky, wet pavement reflections", + "weathered": "overcast flat light, weathered and grimy facade surfaces, faded " + "signage, scattered litter near the kerb", +} + + +def _anchor_treatment_prompt(context: str, treatment: str) -> str: + if treatment not in TREATMENTS: + sys.exit(f"error: unknown treatment '{treatment}'; " + f"choose from {sorted(TREATMENTS)}") + return f"{context}\n\n{REFERENCE_PROMPT_SUFFIX}\n\nRestage the scene: {TREATMENTS[treatment]}" + + +def cmd_anchor(args) -> int: + """Generate day/night/weathered variants of one reference photo's framing.""" + _load_env_file() + reference = _reference_source(args.reference, args.reference_unchecked) + treatments = [t.strip() for t in args.treatments.split(",") if t.strip()] + unknown = [t for t in treatments if t not in TREATMENTS] + if unknown: + sys.exit(f"error: unknown treatment(s) {unknown}; " + f"choose from {sorted(TREATMENTS)}") + out_dir = ROOT / args.out_dir + out_dir.mkdir(parents=True, exist_ok=True) + stem = args.name or re.sub(r"-[0-9]{4}-[0-9]{2}-[0-9]{2}$", "", + reference["path"].stem) + stem = re.sub(r"[^a-z0-9_]+", "_", stem.lower()).strip("_") + failed = [] + for treatment in treatments: + prompt = _anchor_treatment_prompt(args.context, treatment) + print(f"anchoring {stem}_{treatment} ...", flush=True) + try: + image = generate_textures.generate_image( + prompt, args.model, args.request_size, + reference_path=reference["path"]) + image = ImageOps.fit(image.convert("RGB"), (SCENE_W, SCENE_H), + method=Image.Resampling.LANCZOS) + except SystemExit as exc: # generate_textures exits on API errors + failed.append(treatment) + print(f" FAILED {treatment}: {exc}", file=sys.stderr) + continue + out = next_free_path(out_dir / f"{stem}_{treatment}.png") + image.save(out) + _write_reference_provenance(out, reference, f"{stem}_{treatment}", args.model) + print(f"wrote {_rel(out)} {image.size}") + if failed: + print("failed: " + ", ".join(failed), file=sys.stderr) + return 1 + return 0 + + def cmd_list(_args) -> int: for name, (group, _) in CATALOGUE.items(): have = len(raw_variants(name)) @@ -1123,6 +1180,23 @@ def build_parser(): prom.add_argument("--pick", default="", help="name=candidate overrides") prom.set_defaults(handler=cmd_promote) sub.add_parser("audit").set_defaults(handler=cmd_audit) + anchor = sub.add_parser("anchor", + help="generate day/night/weathered variants of one " + "reference photo's framing") + anchor.add_argument("--reference", required=True, + help="licence-cleared photo (listed in its refs SIDECAR.md)") + anchor.add_argument("--context", required=True, + help="what the photo shows and how to restage it") + anchor.add_argument("--treatments", default="day,night,weathered", + help="comma list from: " + ", ".join(sorted(TREATMENTS))) + anchor.add_argument("--out-dir", required=True, + help="concepts subfolder for the frames, relative to the repo root") + anchor.add_argument("--name", default="", + help="output stem (default: derived from the photo filename)") + anchor.add_argument("--model", default=DEFAULT_MODEL) + anchor.add_argument("--request-size", type=int, default=1536) + anchor.add_argument("--reference-unchecked", action="store_true") + anchor.set_defaults(handler=cmd_anchor) real = sub.add_parser("realism") real.add_argument("--run", required=True) real.add_argument("--judge", action="store_true") diff --git a/tools/test_scene_concepts.py b/tools/test_scene_concepts.py index ef3098b..b82b4aa 100644 --- a/tools/test_scene_concepts.py +++ b/tools/test_scene_concepts.py @@ -1,4 +1,5 @@ import json +import re import tempfile import unittest from unittest import mock @@ -245,3 +246,59 @@ class RealismTests(unittest.TestCase): if __name__ == "__main__": unittest.main() + + +class AnchorTests(unittest.TestCase): + ROOT = Path(sc.__file__).resolve().parent.parent + + def test_treatment_prompt_carries_context_suffix_and_clause(self): + prompt = sc._anchor_treatment_prompt("A 1920s masonic facade.", "night") + self.assertTrue(prompt.startswith("A 1920s masonic facade.")) + self.assertIn("keep the building", prompt.lower()) + self.assertIn("Restage the scene:", prompt) + self.assertIn("practical lights", prompt) + + def test_unknown_treatment_names_are_rejected(self): + with self.assertRaises(SystemExit): + sc._anchor_treatment_prompt("c", "underwater") + + def test_name_derivation_strips_the_capture_date(self): + reference = {"path": self.ROOT / "x" / "some-facade-2009-10-27", + "licence": "CC0", "page": "", "checked": True} + # exercised through cmd_anchor's stem logic via a tiny local copy + import re as _re + stem = _re.sub(r"-[0-9]{4}-[0-9]{2}-[0-9]{2}$", "", + "el-capitan-facade-day-wikimedia-2009-10-27") + self.assertEqual(stem, "el-capitan-facade-day-wikimedia") + + def test_cmd_anchor_generates_each_treatment_with_provenance(self): + reference = {"path": self.ROOT / "docs/screens/refs/hollywood/" + "el-capitan-facade-day-wikimedia-2009-10-27.jpg", + "licence": "CC BY 2.0", "page": "https://commons/x", + "checked": True} + class FakeImage: + def convert(self, mode): + return Image.new("RGB", (1600, 900)) + with tempfile.TemporaryDirectory() as tmp: + out_dir = Path(tmp) / "frames" + with mock.patch.object(sc, "_reference_source", return_value=reference), \ + mock.patch.object(sc.generate_textures, "generate_image", + return_value=FakeImage()) as mocked, \ + mock.patch.object(sc, "next_free_path", + side_effect=lambda p: p), \ + mock.patch.object(sc, "_rel", side_effect=str): + args = mock.Mock(reference="r", reference_unchecked=False, + context="A facade.", treatments="day,night", + out_dir=str(out_dir), model="m", + request_size=1536) + args.name = "" # `name` is reserved on Mock; set it explicitly + self.assertEqual(sc.cmd_anchor(args), 0) + stems = [c.args[0] for c in mocked.call_args_list] + self.assertEqual(len(stems), 2) + for treatment, call in zip(("day", "night"), mocked.call_args_list): + self.assertIn("Restage the scene:", call.args[0]) + self.assertEqual(call.kwargs["reference_path"], + reference["path"]) + self.assertTrue((out_dir / + f"el_capitan_facade_day_wikimedia_{treatment}.png") + .exists())