Add three-up evidence boards for El Capitan and Joke Shop
This commit is contained in:
1 parent
ad91e71eeb
commit
343b3348c2
10 files changed
+254
-4
No files matched your search
@@ -125,8 +125,12 @@ except the rebuilt assets themselves.
|
|||||||
navigation obstacles and readable storytelling clusters.
|
navigation obstacles and readable storytelling clusters.
|
||||||
- [ ] Camera height: decide 1.7 m gameplay framing vs the ~1.55 m concept frames using
|
- [ ] Camera height: decide 1.7 m gameplay framing vs the ~1.55 m concept frames using
|
||||||
`camera-cinematography`, and record the decision instead of leaving the mismatch open.
|
`camera-cinematography`, and record the decision instead of leaving the mismatch open.
|
||||||
- [ ] Build the venue three-up boards (real photo | generated concept | current capture) and
|
- [x] Build the venue three-up boards (real photo | generated concept | current capture):
|
||||||
attach them to `docs/hollywood-boulevard-map.md` and `docs/joke-shop-map.md`.
|
`tools/three_up.py compose` renders labelled strips into `docs/screens/boards/` (caption
|
||||||
|
sidecar per board, `rows` emits README rows). Boards committed: El Capitan day, El Capitan
|
||||||
|
night, Joke Shop day. Tests in `tools/test_three_up.py`.
|
||||||
|
- [ ] Link each committed board from `docs/hollywood-boulevard-map.md` and
|
||||||
|
`docs/joke-shop-map.md`.
|
||||||
- [ ] Use the generated concept lighting and palette as the target for the `lighting`/`lookdev`
|
- [ ] Use the generated concept lighting and palette as the target for the `lighting`/`lookdev`
|
||||||
pass, and record the deviations that stay accepted.
|
pass, and record the deviations that stay accepted.
|
||||||
- [ ] After the pass, regenerate the venue captures and update README plus both venue documents.
|
- [ ] After the pass, regenerate the venue captures and update README plus both venue documents.
|
||||||
@@ -173,8 +177,10 @@ and never ship a generated or downloaded image without provenance.
|
|||||||
- [ ] Generate one concept board per venue with `tools/scene_concepts.py generate` (palette, facade
|
- [ ] Generate one concept board per venue with `tools/scene_concepts.py generate` (palette, facade
|
||||||
rhythm, lighting), keep raw output gitignored, commit only the selected board, and record the
|
rhythm, lighting), keep raw output gitignored, commit only the selected board, and record the
|
||||||
model, prompt and spend in the sidecar.
|
model, prompt and spend in the sidecar.
|
||||||
- [ ] Build a three-up sheet per venue (real photo | generated concept | current build capture) and
|
- [x] Build a three-up sheet per venue (real photo | generated concept | current build capture) and
|
||||||
use it as the reference for the character, prop and venue verdicts.
|
use it as the reference for the character, prop and venue verdicts. (Done for the venue
|
||||||
|
exteriors via `docs/screens/boards/`; character/prop verdicts still use the Blender
|
||||||
|
preview sheets.)
|
||||||
- [ ] Apply the screenshot rule to every workstream: each accepted fix updates the relevant row in
|
- [ ] Apply the screenshot rule to every workstream: each accepted fix updates the relevant row in
|
||||||
`README.md` plus the matching docs page (`docs/blender_previews/README.md`,
|
`README.md` plus the matching docs page (`docs/blender_previews/README.md`,
|
||||||
`docs/screenshots/README.md`, `docs/3d-model-review.md` or the venue map doc).
|
`docs/screenshots/README.md`, `docs/3d-model-review.md` or the venue map doc).
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Three-up boards: photo | concept | capture
|
||||||
|
|
||||||
|
Each strip lines up the three image sources for one venue state so the evidence
|
||||||
|
chain stays visible: the licensed real photo is the **evidence**, the generated
|
||||||
|
frame is the **proposal** (geometry anchored on the photo; see the
|
||||||
|
[concepts board](../concepts/README.md) and each frame's `.refgen.json`), and
|
||||||
|
the game capture is the **proof** of the current build. Where the concept and
|
||||||
|
the photo disagree, the photo wins for shape; the capture shows the gap the
|
||||||
|
facade-realism work has to close.
|
||||||
|
|
||||||
|
Composed with `tools/three_up.py compose` (labels baked in; the photo panel's
|
||||||
|
attribution lives in the caption and the [refs sidecars](../refs/README.md)).
|
||||||
|
|
||||||
|
| Board | Caption |
|
||||||
|
| --- | --- |
|
||||||
|
|  | El Capitan facade, day. Photo: [CC BY 2.0, woo, 2009](https://commons.wikimedia.org/wiki/File:El_Capitan_Theater_frontal_fachade-The_Masonic_Temple.jpg) via `refs/hollywood`. Concept: photo-anchored generation. Capture: current build. |
|
||||||
|
|  | El Capitan block, night. Photo: CC BY-SA 3.0 (reviewed; redistribution only, no derived assets) via `refs/hollywood`. Concept: photo-anchored night restage. Capture: current build. |
|
||||||
|
|  | Joke Shop frontage, day. Photo: public domain (2006) via `refs/joke-shop`. Concept: generated. Capture: current build. |
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 173 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"board": "elcapitan-day-board.jpg",
|
||||||
|
"caption": "El Capitan facade, day: 2009 CC BY photo | photo-anchored concept | current capture",
|
||||||
|
"photo": "docs/screens/refs/hollywood/el-capitan-facade-day-wikimedia-2009-10-27.jpg",
|
||||||
|
"concept": "docs/screens/concepts/hollywood_boulevard/hollywood_boulevard_entrance_day-v2.png",
|
||||||
|
"capture": "docs/screenshots/01-venue-overview.png"
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"board": "elcapitan-night-board.jpg",
|
||||||
|
"caption": "El Capitan block, night: CC BY-SA photo (redistribution only) | photo-anchored concept | current capture",
|
||||||
|
"photo": "docs/screens/refs/hollywood/hollywood-boulevard-night-context-ccbysa.jpg",
|
||||||
|
"concept": "docs/screens/concepts/hollywood_boulevard/el_capitan_facade_night.png",
|
||||||
|
"capture": "docs/screenshots/05-late-night-avenue.png"
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 116 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"board": "jokeshop-day-board.jpg",
|
||||||
|
"caption": "Joke Shop, day: licensed photo | generated concept | current capture",
|
||||||
|
"photo": "docs/screens/refs/joke-shop/comedy-store-frontage-palms-day-pd-2006-12-29.jpg",
|
||||||
|
"concept": "docs/screens/concepts/joke_shop/joke_shop_entrance_day.png",
|
||||||
|
"capture": "docs/screenshots/12-joke-shop.png"
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Tests for the three-up board composer (no network, no fixture images)."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
import three_up as tu
|
||||||
|
|
||||||
|
|
||||||
|
def _img(w, h, colour=(200, 100, 50)):
|
||||||
|
return Image.new("RGB", (w, h), colour)
|
||||||
|
|
||||||
|
|
||||||
|
class ComposeTests(unittest.TestCase):
|
||||||
|
def test_panels_share_the_height_and_labels_are_baked_in(self):
|
||||||
|
board = tu.compose_board(_img(1600, 800), _img(1280, 720), _img(640, 480))
|
||||||
|
self.assertEqual(board.height, tu.PANEL_H + tu.LABEL_H)
|
||||||
|
widths = [p.width for p in
|
||||||
|
[board.crop((tu.GUTTER, tu.LABEL_H,
|
||||||
|
tu.GUTTER + 720, board.height))]]
|
||||||
|
# three panels + four gutters span the full width
|
||||||
|
expected = (720 + 640 + 480) + tu.GUTTER * 4
|
||||||
|
self.assertEqual(board.width, expected)
|
||||||
|
|
||||||
|
def test_board_is_never_darker_than_the_frame_colour(self):
|
||||||
|
board = tu.compose_board(_img(100, 100, (0, 0, 0)), _img(100, 100),
|
||||||
|
_img(100, 100))
|
||||||
|
# the label strip keeps the frame colour even with black panels
|
||||||
|
strip = board.crop((0, 0, board.width, tu.LABEL_H))
|
||||||
|
colours = set(strip.getdata())
|
||||||
|
self.assertIn((24, 24, 24), colours)
|
||||||
|
|
||||||
|
|
||||||
|
class NamingTests(unittest.TestCase):
|
||||||
|
def test_board_name_must_be_a_slug_ending_in_board(self):
|
||||||
|
for bad in ("", "with space-board", "slash/board", "no-suffix", "x.jpg"):
|
||||||
|
with self.assertRaises(SystemExit, msg=bad):
|
||||||
|
tu._board_path(bad)
|
||||||
|
|
||||||
|
def test_board_path_lands_in_the_boards_dir(self):
|
||||||
|
self.assertEqual(tu._board_path("elcapitan-day-board"),
|
||||||
|
tu.BOARDS_DIR / "elcapitan-day-board.jpg")
|
||||||
|
|
||||||
|
|
||||||
|
class MissingInputTests(unittest.TestCase):
|
||||||
|
def test_compose_exits_on_a_missing_image(self):
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
tu._load("definitely/not/here.png")
|
||||||
|
|
||||||
|
|
||||||
|
class RowsTests(unittest.TestCase):
|
||||||
|
def test_rows_emit_captions_from_the_sidecars(self):
|
||||||
|
with mock.patch.object(tu, "BOARDS_DIR",
|
||||||
|
Path(self.enterContext(TemporaryDirectory()))):
|
||||||
|
board = tu.BOARDS_DIR / "x-board.jpg"
|
||||||
|
board.write_bytes(b"jpg")
|
||||||
|
board.with_suffix(".json").write_text(json.dumps(
|
||||||
|
{"caption": "A venue, day."}))
|
||||||
|
with mock.patch("builtins.print") as printed:
|
||||||
|
self.assertEqual(tu.cmd_rows(None), 0)
|
||||||
|
out = "\n".join(str(c[0][0]) for c in printed.call_args_list)
|
||||||
|
self.assertIn("", out)
|
||||||
|
self.assertIn("A venue, day.", out)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Compose three-up evidence boards: real photo | generated concept | capture.
|
||||||
|
|
||||||
|
Each board row puts the three image sources for one venue state side by side so
|
||||||
|
the evidence chain stays visible: the licensed photo is the *evidence*, the
|
||||||
|
generated frame is the *proposal*, the game capture is the *proof*. Boards are
|
||||||
|
written to `docs/screens/boards/` as JPEG strips with column labels; the
|
||||||
|
attribution for the photo panel lives in the folder README (the composite keeps
|
||||||
|
only the labels, so licence text never gets cropped by the art).
|
||||||
|
|
||||||
|
python3 tools/three_up.py compose \
|
||||||
|
--photo <path> --concept <path> --capture <path> \
|
||||||
|
--name <board-name> [--caption "..."]
|
||||||
|
|
||||||
|
`boards.json` (see --manifest) drives batch composition; `rows` emits the
|
||||||
|
README table rows for the generated boards.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
BOARDS_DIR = ROOT / "docs" / "screens" / "boards"
|
||||||
|
PANEL_H = 360
|
||||||
|
LABEL_H = 34
|
||||||
|
GUTTER = 8
|
||||||
|
LABELS = ("Real photo (evidence)", "Generated concept (proposal)",
|
||||||
|
"Game capture (proof)")
|
||||||
|
|
||||||
|
|
||||||
|
def _load(path_str: str) -> Image.Image:
|
||||||
|
path = Path(path_str)
|
||||||
|
if not path.is_absolute():
|
||||||
|
path = ROOT / path
|
||||||
|
if not path.is_file():
|
||||||
|
sys.exit(f"error: image not found: {path}")
|
||||||
|
with Image.open(path) as img:
|
||||||
|
img.load()
|
||||||
|
return img.convert("RGB")
|
||||||
|
|
||||||
|
|
||||||
|
def _label_font(size: int = 18):
|
||||||
|
try:
|
||||||
|
return ImageFont.truetype(
|
||||||
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", size)
|
||||||
|
except OSError:
|
||||||
|
try:
|
||||||
|
return ImageFont.truetype("DejaVuSans-Bold", size)
|
||||||
|
except OSError:
|
||||||
|
return ImageFont.load_default()
|
||||||
|
|
||||||
|
|
||||||
|
def compose_board(photo: Image.Image, concept: Image.Image,
|
||||||
|
capture: Image.Image) -> Image.Image:
|
||||||
|
"""Return one labelled three-panel strip at a shared panel height."""
|
||||||
|
panels = []
|
||||||
|
for image in (photo, concept, capture):
|
||||||
|
scale = PANEL_H / image.height
|
||||||
|
panels.append(image.resize((max(1, round(image.width * scale)), PANEL_H),
|
||||||
|
Image.Resampling.LANCZOS))
|
||||||
|
width = sum(p.width for p in panels) + GUTTER * 4
|
||||||
|
board = Image.new("RGB", (width, PANEL_H + LABEL_H), (24, 24, 24))
|
||||||
|
draw = ImageDraw.Draw(board)
|
||||||
|
font = _label_font(16)
|
||||||
|
x = GUTTER
|
||||||
|
for panel, label in zip(panels, LABELS):
|
||||||
|
board.paste(panel, (x, LABEL_H))
|
||||||
|
draw.text((x + 4, 8), label, fill=(232, 232, 232), font=font)
|
||||||
|
x += panel.width + GUTTER
|
||||||
|
return board
|
||||||
|
|
||||||
|
|
||||||
|
def _board_path(name: str) -> Path:
|
||||||
|
if not name or any(c in name for c in "/\\ ") or not name.endswith("-board"):
|
||||||
|
sys.exit("error: board name must be a slug ending in '-board' "
|
||||||
|
"(e.g. 'elcapitan-day-board')")
|
||||||
|
return BOARDS_DIR / f"{name}.jpg"
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_compose(args) -> int:
|
||||||
|
board = compose_board(_load(args.photo), _load(args.concept),
|
||||||
|
_load(args.capture))
|
||||||
|
BOARDS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
out = _board_path(args.name)
|
||||||
|
board.save(out, quality=85)
|
||||||
|
print(f"wrote {out.relative_to(ROOT)} {board.size[0]}x{board.size[1]}")
|
||||||
|
if args.caption:
|
||||||
|
sidecar = out.with_suffix(".json")
|
||||||
|
sidecar.write_text(json.dumps({
|
||||||
|
"board": out.name, "caption": args.caption,
|
||||||
|
"photo": args.photo, "concept": args.concept,
|
||||||
|
"capture": args.capture}, indent=2) + "\n")
|
||||||
|
print(f"wrote {sidecar.relative_to(ROOT)}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_rows(_args) -> int:
|
||||||
|
for board in sorted(BOARDS_DIR.glob("*-board.jpg")):
|
||||||
|
sidecar = board.with_suffix(".json")
|
||||||
|
caption = ""
|
||||||
|
if sidecar.is_file():
|
||||||
|
caption = json.loads(sidecar.read_text()).get("caption", "")
|
||||||
|
name = board.stem.removesuffix("-board").replace("-", " ")
|
||||||
|
print(f"|  | {caption} |")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__,
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||||
|
compose = sub.add_parser("compose", help="compose one three-up board")
|
||||||
|
compose.add_argument("--photo", required=True)
|
||||||
|
compose.add_argument("--concept", required=True)
|
||||||
|
compose.add_argument("--capture", required=True)
|
||||||
|
compose.add_argument("--name", required=True)
|
||||||
|
compose.add_argument("--caption", default="")
|
||||||
|
compose.set_defaults(handler=cmd_compose)
|
||||||
|
sub.add_parser("rows", help="emit README table rows for all boards"
|
||||||
|
).set_defaults(handler=cmd_rows)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None) -> int:
|
||||||
|
args = build_parser().parse_args(argv)
|
||||||
|
return args.handler(args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Reference in new issue
Block a user