From bd1f8a9c2ea91a4e851b2604d5c9e7d8bde10ae7 Mon Sep 17 00:00:00 2001 From: Zwitschi Date: Sun, 8 Nov 2026 19:52:25 +0100 Subject: [PATCH] feat(qa): reference-vs-render iteration loop; screenshots are demo-only Tools/visual_loop.py: - --verify gates every capture against its expectation in Tools/visual_targets.json (missing, blank, tone-less or wrong-size shots fail), so 'each screenshot shows what was expected' is checked rather than assumed. - --compare measures luminance, RMS contrast, edge density, palette distance and a 3x3 luma grid against the reference photograph, with a 0-1 similarity score. - --iteration N writes Art/reference/iterations/N/{metrics.json,feedback.md}; the feedback names the skill to consult (blender-modeler, archviz, materials, lookdev, camera-cinematography), so the next modelling pass starts from a measurement. - Iterations are gitignored until the owner confirms a shot; only confirmed shots are promoted into Screenshots/. Screenshots/ holds demo-map captures only now: render_screenshots.sh renders just DemoMap, collects the shots and runs the verification gate (--verify); the review staging map is no longer rendered for review and its PNGs are removed. The README is rewritten around the four demo shots with per-shot expected content and a verified column, and no longer claims the review staging set. Tests: 67 pass (13 new) covering metrics on synthetic images, comparison scoring, the feedback rules and the target spec (including that every target is a demo shot and that iterations are gitignored). --- .gitignore | 4 + Game/GigLine/Content/Hollywood/DemoMap.umap | 2 +- Game/GigLine/Content/Test/ReviewMap.umap | 2 +- MEMORY.md | 24 ++ Screenshots/README.md | 78 ++-- Screenshots/demo/review-demo-crossing.png | 4 +- .../demo/review-demo-north-buildings.png | 4 +- Screenshots/demo/review-demo-spawn-view.png | 4 +- Screenshots/demo/review-demo-street-level.png | 4 +- Screenshots/review-character.png | 3 - Screenshots/review-ground-contact.png | 3 - Screenshots/review-hero-facade-front.png | 3 - Screenshots/review-hero-facade-oblique.png | 3 - Screenshots/review-hero-portals.png | 3 - Screenshots/review-npc-prefab.png | 3 - Screenshots/review-overview.png | 3 - Screenshots/review-player-placement.png | 3 - Screenshots/review-scale-cubes.png | 3 - Screenshots/review-shadow-compare.png | 3 - Tools/README.md | 4 +- Tools/render_screenshots.sh | 66 ++- Tools/tests/test_visual_loop.py | 139 +++++++ Tools/visual_loop.py | 389 ++++++++++++++++++ Tools/visual_targets.json | 53 +++ requirements-tools.txt | 2 +- 25 files changed, 688 insertions(+), 121 deletions(-) delete mode 100644 Screenshots/review-character.png delete mode 100644 Screenshots/review-ground-contact.png delete mode 100644 Screenshots/review-hero-facade-front.png delete mode 100644 Screenshots/review-hero-facade-oblique.png delete mode 100644 Screenshots/review-hero-portals.png delete mode 100644 Screenshots/review-npc-prefab.png delete mode 100644 Screenshots/review-overview.png delete mode 100644 Screenshots/review-player-placement.png delete mode 100644 Screenshots/review-scale-cubes.png delete mode 100644 Screenshots/review-shadow-compare.png create mode 100644 Tools/tests/test_visual_loop.py create mode 100644 Tools/visual_loop.py create mode 100644 Tools/visual_targets.json diff --git a/.gitignore b/.gitignore index fc01586..f8ea7bf 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,10 @@ Thumbs.db /Geo/raw/mapbox-cache/ /Geo/verification/*mapbox* +# Visual-loop iterations: generated comparisons stay uncommitted until the owner +# confirms a shot. Only Screenshots/ is committed. +/Art/reference/iterations/ + # Local, private reference material — never committed (see Docs/reference-notes-hollywood.md) /Art/reference/ /Docs/data-inventory.md diff --git a/Game/GigLine/Content/Hollywood/DemoMap.umap b/Game/GigLine/Content/Hollywood/DemoMap.umap index 499e509..aa39faa 100644 --- a/Game/GigLine/Content/Hollywood/DemoMap.umap +++ b/Game/GigLine/Content/Hollywood/DemoMap.umap @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6947def46f970b8fcd21a77312c5d9c683723d6c2443e742a41c45769cc7c009 +oid sha256:78027bd7ca19b89d4c8026c8429d6dde5a220427c2627de2c50a19e7686b9e03 size 949399 diff --git a/Game/GigLine/Content/Test/ReviewMap.umap b/Game/GigLine/Content/Test/ReviewMap.umap index cb2a9be..8babc6b 100644 --- a/Game/GigLine/Content/Test/ReviewMap.umap +++ b/Game/GigLine/Content/Test/ReviewMap.umap @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a6f170a72a1c3c220b9e52a1c2099d6bcd3dc96dc640038cef039b2c41f5fdaf +oid sha256:f66abdc90461601dfb4fe45f6afd16d7cf80965beaecf06c3377c6b2c75ed5e7 size 28811 diff --git a/MEMORY.md b/MEMORY.md index 5795c56..cca7e7e 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -626,3 +626,27 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO. - `ue_import_facade_kit.py` now deletes kit assets that the current build no longer produces, so a rename cannot leave two generations of the same piece in the folder (the rename from `Kit_*` to `SM_Kit_*` did exactly that until it did). + +## 2026-11-09 — visual comparison loop; screenshots are demo-only + +- `Tools/visual_loop.py` closes the reference → render → feedback loop. + `--verify` gates captures against `Tools/visual_targets.json` (blank, tone-less, + wrong-size fail); `--compare` measures luma, RMS contrast, edge density, palette + distance and a 3x3 luma grid against the reference and prints a 0–1 score; + `--iteration N` writes `Art/reference/iterations/N/{metrics.json,feedback.md}`. + The feedback names the skill to consult, so the next modelling pass starts from a + measurement rather than an opinion. +- **Iterations are gitignored** (`/Art/reference/iterations/`); only confirmed shots + are promoted into `Screenshots/`. A test asserts the ignore rule and that every + target is a demo-map shot. +- `Screenshots/` now holds **demo-map captures only** — the review staging map is no + longer rendered for review. `render_screenshots.sh --verify` renders the demo map, + collects the shots and runs the verification gate in one step. +- **PIL gotchas:** `FIND_EDGES` leaves its own border ring at 128 (236/3600 px on a + 60x60 frame = 6.5% "detail" on a perfectly flat image), so crop **after** + filtering — cropping before does not help, the new border gets the same + treatment. Palette shares must divide by the full-image area, not the cropped one. +- First comparison (iteration 2): spawn view 0.70, street level 0.35, crossing 0.41, + north buildings 0.34. Edge density is the dominant gap everywhere (0.04–0.11 + against 0.20–0.32 in the references) — the render still lacks the relief the + photographs carry, which is exactly what the `0.1.4` façade work is for. diff --git a/Screenshots/README.md b/Screenshots/README.md index 04c3f64..9669d96 100644 --- a/Screenshots/README.md +++ b/Screenshots/README.md @@ -1,63 +1,51 @@ -# Screenshots — visual review +# Screenshots — demo-map visual review -Rendered in-game captures of the current project content, generated by [`Tools/render_screenshots.sh`](../Tools/render_screenshots.sh). The PNGs are committed alongside this README so a reviewer sees exactly what the build rendered. +Rendered captures of **the demo map only** — what a player sees when the demo runs. Generated by [`Tools/render_screenshots.sh`](../Tools/render_screenshots.sh); the PNGs are committed so a reviewer sees exactly what the build rendered. + +The review staging map (`/Game/Test/ReviewMap`) is not part of this set: it exists to hold the camera list and the hero detail rig, and it is not what ships. ## How to regenerate ```bash -Tools/render_screenshots.sh +Tools/render_screenshots.sh --verify ``` -Rebuilds `Game/GigLine/Content/Test/ReviewMap.umap` from current content, runs the game on it (and on the DemoMap for the walkable views), walks the fixed camera list via `AGigLineShotActor`, captures each shot, copies the PNGs here and exits — about a minute. The camera list lives in `Tools/ue_build_review_map.py`; add new shots there, remove retired ones here. +Stages the demo shot cameras, runs the game on `/Game/Hollywood/DemoMap`, copies the PNGs into `Screenshots/demo/` and then runs [`Tools/visual_loop.py`](../Tools/visual_loop.py) `--verify` to check that each shot still shows the content its expectation describes. About a minute. -> Naming: shots use their stable camera name (no index prefix), so adding or reordering cameras never renumbers files or orphans the old ones. Click an image for the full-size PNG. +## The shot list -## Shot list +Each shot has an **expectation** in [`Tools/visual_targets.json`](../Tools/visual_targets.json) — the content the capture must show — and a verification state produced by `--verify`: it fails if the frame is blank, the wrong size, or has no tonal range. -**Status: whole set ☑ approved 2026-11-09** (owner review of PR #27). Every shot was re-rendered from current `main` content after the building-height calibration was adopted (`v0.1.2`): Mapbox-derived **3.1 m per level** (`Geo/tools/block_height_report.py`), explicit overrides for three rear outbuildings, and the regenerated blockout and frontage that follow — 6922 is now 39.1 m (was 42.1 m) and the El Capitan Theatre 20.5 m (was 20.0 m). +| # | File | View | Expected content | Verified | +| --- | --- | --- | --- | --- | +| 1 | `demo/review-demo-spawn-view.png` | north sidewalk | The whole south block: the neoclassical hero façade between its neighbours, both sidewalks and the road in the foreground | ☑ 2026-11-09 | +| 2 | `demo/review-demo-street-level.png` | eye height, north side | Street level: the hero colonnade with the adjacent frontage buildings either side, shopfronts at pavement level | ☑ 2026-11-09 | +| 3 | `demo/review-demo-crossing.png` | along the crosswalk | The designated mid-block crossing (s=130 m, UE X −14956): zebra bars on the asphalt, south frontage and colonnade behind | ☑ 2026-11-09 | +| 4 | `demo/review-demo-north-buildings.png` | across the Boulevard | The two north-side buildings (TCL Chinese Theatre, Madame Tussaud's) from the south side, full height in frame | ☑ 2026-11-09 | -| Round | What changed | -| --- | --- | -| 2026-11-07 | First approved set; block frontage pass corrected hero and north-building placement | -| 2026-11-08 | Re-rendered after `0.1.1` street geometry (sidewalk upstand flush at the building edge, crossing ramps graded ≤ 8 %); `demo-north-buildings` camera re-pointed (was 9.5 km west, 85 % sky) | -| 2026-11-09 | Re-rendered after the `0.1.2` building-height calibration (3.1 m per level). 12 of 14 PNGs changed; `review-demo-spawn-view` and `review-demo-street-level` are byte-identical to the `0.1.1` round, so the height change reads mostly on the frontage silhouettes | +Approval is per shot, in the table above. Add a row when a shot is added; remove the file in the same change when a shot is retired. -| # | File | Preview | Map | View | What it shows | ☑ | -| --- | --- | --- | --- | --- | --- | --- | -| 1 | `review-overview.png` | [overview](review-overview.png) | Review | 7 m up, NE | Whole review layout: character + both cubes on the floor | ☑ 2026-11-09 | -| 2 | `review-character.png` | [character](review-character.png) | Review | eye height, south | MPFB mannequin, idle pose + warm skin; feet meet the plane | ☑ 2026-11-09 | -| 3 | `review-scale-cubes.png` | [scale cubes](review-scale-cubes.png) | Review | south | Floating vs grounded cube: cast vs contact shadow | ☑ 2026-11-09 | -| 4 | `review-player-placement.png` | [player placement](review-player-placement.png) | Review | spawn | The `AGigLineCharacter` pawn, capsule height and landing | ☑ 2026-11-09 | -| 5 | `review-npc-prefab.png` | [npc prefab](review-npc-prefab.png) | Review | mid, west | The `AGigLineNPCPerson` prefab: collision capsule + visible MPFB body, feet on Z 0 | ☑ 2026-11-09 | -| 6 | `review-ground-contact.png` | [ground contact](review-ground-contact.png) | Review | knee height, feet | Floating/sinking character is obvious here | ☑ 2026-11-09 | -| 7 | `review-shadow-compare.png` | [shadow compare](review-shadow-compare.png) | Review | low angle | Detached (floating) vs touching (grounded) shadows | ☑ 2026-11-09 | -| 8 | `review-hero-portals.png` | [hero portals](review-hero-portals.png) | Review | 8 m, eye height | Three genuine 1.2 m portal recesses with flights and plinth | ☑ 2026-11-09 | -| 9 | `review-hero-facade-front.png` | [hero facade front](review-hero-facade-front.png) | Review | road side, straight-on | Hero façade elevation and column rhythm | ☑ 2026-11-09 | -| 10 | `review-hero-facade-oblique.png` | [hero facade oblique](review-hero-facade-oblique.png) | Review | north-west | Body depth and column line standing proud of the mass | ☑ 2026-11-09 | -| 11 | `demo/review-demo-spawn-view.png` | [spawn view](demo/review-demo-spawn-view.png) | Demo | north sidewalk | The whole block: hero façade between its neighbours, both sidewalks and the road | ☑ 2026-11-09 | -| 12 | `demo/review-demo-street-level.png` | [street level](demo/review-demo-street-level.png) | Demo | eye height, north side | The hero colonnade with the adjacent frontage buildings either side | ☑ 2026-11-09 | -| 13 | `demo/review-demo-crossing.png` | [crossing](demo/review-demo-crossing.png) | Demo | along the crosswalk | The designated mid-block crossing at s=130 m (UE X -14956): three zebra bars on the asphalt, with the south frontage and the hero colonnade behind | ☑ 2026-11-09 | -| 14 | `demo/review-demo-north-buildings.png` | [north buildings](demo/review-demo-north-buildings.png) | Demo | across the Blvd | North-side buildings (TCL, Tussaud's) seen from the south side across the road. **2026-11-08:** camera re-pointed to the buildings' mass centre (was 9.5 km west → 85% sky); now frames both north buildings. Flat surfaces = `0.1.4` façade scope. | ☑ 2026-11-09 | +## The iteration loop (uncommitted) + +`Tools/visual_loop.py` measures each capture against its reference photograph and turns the deltas into modelling actions for the Blender skill packs: + +```bash +python3 Tools/visual_loop.py --compare --iteration 3 # writes Art/reference/iterations/3/ +``` + +That directory is **gitignored**: iterations stay uncommitted until a shot is confirmed, and only confirmed shots are promoted into `Screenshots/demo/`. The feedback file names the affected skill (`blender-modeler`, `archviz`, `materials`, `lookdev`, `camera-cinematography`) so the next modelling pass has a starting point rather than an opinion. + +Note the comparison is only meaningful when the shot and the reference frame the same subject — a wide block view measured against a tight façade crop will always report low edge similarity. ## Scene conventions -- **Review map** (`/Game/Test/ReviewMap`): 160 m floor, top face at Z 0; the character measures Z 0..83 cm. One warm key light plus sky fill. -- **Cubes**: `Cube_ScaleTest_A` rests on the floor, `Cube_ScaleTest_B` floats at Z 2 m; both are exactly 1 m — the scale reference. -- **Demo map** (`/Game/Hollywood/DemoMap`): fixed daytime — sun at pitch −50 / yaw 120 (intensity 4, warm), sky light 2.6, sky atmosphere, height fog, fixed exposure. -- **Materials**: every exported surface carries a look-developed material built in `Tools/ue_build_demo_map.py` (`ensure_material`) because Blender's flat diffuse colours do not survive the FBX → Unreal import. Each has an explicit roughness and a subtle world-space noise blend between two shades: `M_Asphalt` (0.92), `M_Concrete` (0.88), `M_KerbConcrete` (0.85), `M_Ground` (0.98), `M_RoadPaint` (0.62), `M_FacadeStone` (0.72) and `M_Stucco` (0.86) for the north-side buildings. -- **Lighting is dynamic-only** (`r.AllowStaticLighting=False`, all lights Movable), so no lighting build is ever required. - -## What to look for - -- **Ground contact**: feet meet the plane with no gap and no shadow seam. -- **Scale**: everything checks against the 1 m cubes. -- **Façade rhythm**: six columns at 4.96 m across 25.75 m should read as the photographs in silhouette. -- **Frontage heights (new in this round)**: 6922 and the El Capitan Theatre were re-cut to the 3.1 m storey height. Check they still read as the taller west end and the lower cinema respectively against `Art/reference/hollywood/el-capitan-theatre-blade-sign-day-wikimedia-2008-10.jpg`, and that the ordering of the six frontage buildings is unchanged. +- **Demo map** (`/Game/Hollywood/DemoMap`): fixed daytime — sun pitch −62 / yaw 120 (intensity 4, warm), sky light 2.6, sky atmosphere, height fog, fixed exposure. +- **Cameras**: elevation shots sit at their target height (pitch 0, so verticals stay parallel) and use a 42–55° lens to fill the frame from across the street. +- **Materials**: every surface carries a look-developed material from `Tools/ue_build_demo_map.py` — noise-blended base colour, masonry coursing, and a generated texture set (`Tools/generate_facade_textures.py`) sampled world-aligned. Explicit roughness and specular per surface type. ## Known cosmetic issues -- Floor speckle from the `Constant3Vector` wiring in `Tools/ue_build_review_map.py`. -- Hero façade renders pale — Blender flat colours with no normal/roughness, so it reads as untextured stone (the `0.1.4` façade patch, not a lighting bug). -- Banners sit on the column line rather than the wall (acceptable: they hang on the columns in the photographs). -- ~~Banners and window panels float detached from the colonnade wall~~ — **window panes fixed 2026-11-09**: they sat at Y −1.98, 1.44 m in _front_ of the wall and ahead of the column line; they now sit on the wall plane at Y −0.56, behind the columns, and read as dark openings (see `review-hero-facade-front`). -- Between kerb and building line, the walkable surface is the terrain mesh, not a modelled pavement. +- Hero façade renders pale: the stone material is a graph and a texture set, not a scanned surface (the `0.1.4` façade pass is in progress). +- Banners sit on the column line rather than flush to the wall (they hang on the columns in the photographs, so this is acceptable). +- Between kerb and building line the walkable surface is the terrain mesh, not a modelled pavement. +- The El Capitan Theatre has no marquee or blade sign yet — the feature that makes it read as itself. diff --git a/Screenshots/demo/review-demo-crossing.png b/Screenshots/demo/review-demo-crossing.png index 4d9596d..91ed79e 100644 --- a/Screenshots/demo/review-demo-crossing.png +++ b/Screenshots/demo/review-demo-crossing.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f105779e16a28cfe166df9efd3c98c38f71875380ab1fae1ff2e160ff510f1e1 -size 3078975 +oid sha256:65530cfd12bc783e9a40af40b91926abf09156d27e0ae57f02b625eec8fc96bc +size 3078175 diff --git a/Screenshots/demo/review-demo-north-buildings.png b/Screenshots/demo/review-demo-north-buildings.png index 5c7a939..c31577c 100644 --- a/Screenshots/demo/review-demo-north-buildings.png +++ b/Screenshots/demo/review-demo-north-buildings.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:05861cdcb4e67da8e2fc257e95c3345fb4f6ff37fcf37bc2fc6d454822cc82cf -size 2443779 +oid sha256:a16d37433312a7e5ee2fecbaba5bee822566a58a1a7c45e6bb7c05c3b7c76689 +size 2443382 diff --git a/Screenshots/demo/review-demo-spawn-view.png b/Screenshots/demo/review-demo-spawn-view.png index 99b4ab2..900adca 100644 --- a/Screenshots/demo/review-demo-spawn-view.png +++ b/Screenshots/demo/review-demo-spawn-view.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2788f8c6e57fa0bc2076fd9febf1ef632ebfbe5bceae9aa0b8d71b49ef705914 -size 2914546 +oid sha256:c0145fc8e60aff6d7981c74f950f06ccb31fac4b85fdc80f58ec9aaa50397d8d +size 2942005 diff --git a/Screenshots/demo/review-demo-street-level.png b/Screenshots/demo/review-demo-street-level.png index 516869b..d4a549d 100644 --- a/Screenshots/demo/review-demo-street-level.png +++ b/Screenshots/demo/review-demo-street-level.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5ebcc70ac33b142657ba96a6165559be98a4c6e66b5fdb414f9e9f60115f22c6 -size 2864294 +oid sha256:b7c2afd07d9d24aa196a8151f66452f532e10a8f52bc9f232d64a61c783a6189 +size 2863148 diff --git a/Screenshots/review-character.png b/Screenshots/review-character.png deleted file mode 100644 index 575f9ef..0000000 --- a/Screenshots/review-character.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:cb9307133d8f02dcc4da2f4e3b91588f57244f6d5cbb7ab6f2e755dc65021f20 -size 2538731 diff --git a/Screenshots/review-ground-contact.png b/Screenshots/review-ground-contact.png deleted file mode 100644 index c822d56..0000000 --- a/Screenshots/review-ground-contact.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:2bfbe4847a98fdbc42050406d4b26569bf910f5376994eeea3d646e71e0f36d5 -size 1748441 diff --git a/Screenshots/review-hero-facade-front.png b/Screenshots/review-hero-facade-front.png deleted file mode 100644 index 715236c..0000000 --- a/Screenshots/review-hero-facade-front.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b1dfe09f9de990c18fab1f9289119f6a98566523168b746c408d2da1aca8203c -size 2283522 diff --git a/Screenshots/review-hero-facade-oblique.png b/Screenshots/review-hero-facade-oblique.png deleted file mode 100644 index 5a4f65e..0000000 --- a/Screenshots/review-hero-facade-oblique.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ee46da9476f90434c8a676d0d4726f9bc4543d72d22b5db44493b44072ed8729 -size 1518947 diff --git a/Screenshots/review-hero-portals.png b/Screenshots/review-hero-portals.png deleted file mode 100644 index ff90639..0000000 --- a/Screenshots/review-hero-portals.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:cd2350c9bf572a05cd7323368a3f435b7dea5b1daaa530f9512bfea1314c79f1 -size 1328667 diff --git a/Screenshots/review-npc-prefab.png b/Screenshots/review-npc-prefab.png deleted file mode 100644 index 239857d..0000000 --- a/Screenshots/review-npc-prefab.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:555db441a3a709057db8f61b367772d5b0b8148391be3299a60f6c13bd05ac20 -size 1736426 diff --git a/Screenshots/review-overview.png b/Screenshots/review-overview.png deleted file mode 100644 index 936cdc2..0000000 --- a/Screenshots/review-overview.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:42eeaadb8795d84af81931b13fe4644601e8aadf33539b02d171e86730dc8269 -size 2622214 diff --git a/Screenshots/review-player-placement.png b/Screenshots/review-player-placement.png deleted file mode 100644 index 887dc65..0000000 --- a/Screenshots/review-player-placement.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ffb788897558080e8100cdf174b0998f6b69ea4ffe0bf3d25a79d94f6aafb3d8 -size 2026302 diff --git a/Screenshots/review-scale-cubes.png b/Screenshots/review-scale-cubes.png deleted file mode 100644 index 7a713c0..0000000 --- a/Screenshots/review-scale-cubes.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:bae1665389a83d7c7b7339ba626dd1eb5362322f3c4c05c93c5808aaa1fd4014 -size 1934977 diff --git a/Screenshots/review-shadow-compare.png b/Screenshots/review-shadow-compare.png deleted file mode 100644 index e0a75c2..0000000 --- a/Screenshots/review-shadow-compare.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:23457d81639ea5dcdb4fdea9af0a424a968f25b3559c30da5ede45355c6eac66 -size 1722925 diff --git a/Tools/README.md b/Tools/README.md index 97c90cb..c73337b 100644 --- a/Tools/README.md +++ b/Tools/README.md @@ -56,7 +56,9 @@ Geographic tools live in [`Geo/tools/`](../Geo/README.md) and the Blender-side b | Tool | Purpose | | --- | --- | | `render_screenshots.py` / `render_screenshots.sh` | Rebuilds the review map, runs the game, captures the shot list into `Screenshots/` | -| `judge_screenshot.py` | Vision-model pre-check: judges a screenshot against an expected-scene description (`--image` + `--expect`, or `--shots` for every shot in the README). Reads `OPENROUTER_API_KEY` from `.env` (ignored). Optional helper — a human must still approve. | +| `judge_screenshot.py` | Vision-model pre-check: judges a screenshot against an expected-scene description (`--image` + `--expect`, or `--shots` for every shot in the README). Reads `OPENROUTER_API_KEY` from `.env` (ignored). Optional, advisory only — the model is unreliable on this content and never replaces a human verdict. | +| `visual_loop.py` | The reference-vs-render loop. `--verify` gates each capture against its expectation in `visual_targets.json` (blank/short/wrong-size/tone-less shots fail); `--compare` measures luminance, contrast, edge density, palette and 3×3 composition against the reference photograph and turns the deltas into modelling actions naming the skill to consult (`blender-modeler`, `archviz`, `materials`, `lookdev`, `camera-cinematography`); `--iteration N` writes the artefacts under `Art/reference/iterations/N/`, which is **gitignored until the owner confirms a shot**. | +| `visual_targets.json` | Per-shot expectation and reference photograph for `visual_loop.py`. Every entry must be a demo-map shot. | ## Gameplay QA diff --git a/Tools/render_screenshots.sh b/Tools/render_screenshots.sh index 1fd3bd4..6a9fd90 100755 --- a/Tools/render_screenshots.sh +++ b/Tools/render_screenshots.sh @@ -1,56 +1,54 @@ #!/usr/bin/env bash -# Render in-game screenshots for visual review into /Screenshots/ (gitignored). +# Render the demo-map screenshots used for visual review into /Screenshots/demo/. # -# Usage: Tools/render_screenshots.sh -# Rebuilds /Game/Test/ReviewMap and /Game/Hollywood/DemoMap (shot lists), runs -# the game on each map (the GigLineShotActor walks the cameras, captures, quits), -# and copies the PNGs into Screenshots/. PNGs are never committed. +# Usage: Tools/render_screenshots.sh [--verify] +# +# Only the **demo map** is captured: the review staging map exists to hold the +# hero/camera list, not to produce review screenshots. Everything here is what a +# player actually sees when the demo runs. +# +# --verify run Tools/visual_loop.py --verify afterwards and fail if a shot +# does not show the content its expectation describes +# +# The PNGs are committed (see Screenshots/README.md): a reviewer must see exactly +# what the build rendered. set -euo pipefail REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ENGINE="${UE_ENGINE_DIR:-/run/media/system/DATA/Engines/UE_5.8.4}" PROJ="$REPO/Game/GigLine/GigLine.uproject" +VERIFY="no" +[ "${1:-}" = "--verify" ] && VERIFY="yes" -echo "== 1/4 build review + demo shot maps ==" +echo "== 1/3 stage the demo shot cameras ==" cd "$ENGINE" ./Engine/Binaries/Linux/UnrealEditor-Cmd "$PROJ" \ -run=pythonscript -script="$REPO/Tools/ue_build_review_map.py" \ -nullrhi -unattended -nopause -stdout -cat /tmp/ue-review-map-result.txt | tail -3 +tail -3 /tmp/ue-review-map-result.txt 2>/dev/null || true -run_capture() { - local map="$1" label="$2" - echo "== capture: $label ==" - ./Engine/Binaries/Linux/UnrealEditor-Cmd "$PROJ" "$map" \ - -game -windowed -ResX=1920 -ResY=1080 -nopause -nosplash -stdout -} +echo "== 2/3 capture the demo map ==" +./Engine/Binaries/Linux/UnrealEditor-Cmd "$PROJ" /Game/Hollywood/DemoMap \ + -game -windowed -ResX=1920 -ResY=1080 -nopause -nosplash -stdout -echo "== 2/4 run game on review map (hero/scene shots) ==" -run_capture "/Game/Test/ReviewMap" "review" - -echo "== 3/4 run game on demo map (walkable-demo shots) ==" -run_capture "/Game/Hollywood/DemoMap" "demo" - -echo "== 4/4 collect screenshots ==" +echo "== 3/3 collect the demo screenshots ==" mkdir -p "$REPO/Screenshots/demo" -rm -f "$REPO"/Screenshots/review-*.png "$REPO"/Screenshots/demo/review-*.png +rm -f "$REPO"/Screenshots/demo/review-*.png found=0 for src in \ "$REPO/Game/GigLine/Saved/Screenshots"/*/review-*.png \ "$REPO/Game/GigLine/Saved/Screenshots"/*/*/review-*.png \ "$HOME/.config/Epic/GigLine/Saved/Screenshots"/*/review-*.png; do [ -e "$src" ] || continue - name=$(basename "$src") - if [[ "$name" == review-?("")-demo-* || "$name" == review-*-demo-* || "$name" == review-0?-demo-* ]] \ - || [[ "$name" == *demo-* ]]; then - cp -f "$src" "$REPO/Screenshots/demo/$name" - else - cp -f "$src" "$REPO/Screenshots/$name" - fi - found=1 + case "$(basename "$src")" in + *demo-*) cp -f "$src" "$REPO/Screenshots/demo/"; found=1 ;; + esac done -[ "$found" = "1" ] || { echo "ERROR: no review-*.png found"; exit 1; } -echo "--- Screenshots/ ---" -ls -1 "$REPO/Screenshots" -echo "--- Screenshots/demo/ ---" -ls -1 "$REPO/Screenshots/demo" \ No newline at end of file +[ "$found" = "1" ] || { echo "ERROR: no demo screenshots found"; exit 1; } +ls -1 "$REPO/Screenshots/demo" + +if [ "$VERIFY" = "yes" ]; then + echo "== verify: does each shot show what it should? ==" + cd "$REPO" + python3 Tools/visual_loop.py --verify +fi diff --git a/Tools/tests/test_visual_loop.py b/Tools/tests/test_visual_loop.py new file mode 100644 index 0000000..f7e4210 --- /dev/null +++ b/Tools/tests/test_visual_loop.py @@ -0,0 +1,139 @@ +"""Tests for Tools/visual_loop.py — metrics, comparison and feedback rules. + +Synthetic 8-bit images are written to a temp directory and measured, so the +numbers can be reasoned about directly (a mid-grey flat frame has near-zero edge +density and contrast; a checkerboard has a lot of both). +""" + +import os +import sys +import tempfile +import unittest + +from PIL import Image + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +sys.path.insert(0, os.path.join(ROOT, "Tools")) + +import visual_loop as vl # noqa: E402 + + +def write_image(path, kind, size=(64, 64)): + img = Image.new("RGB", size) + if kind == "flat": + img.paste((128, 128, 128), (0, 0, size[0], size[1])) + elif kind == "dark": + img.paste((10, 10, 10), (0, 0, size[0], size[1])) + elif kind == "checker": + px = img.load() + for y in range(size[1]): + for x in range(size[0]): + px[x, y] = (240, 240, 240) if (x // 4 + y // 4) % 2 else (20, 20, 20) + elif kind == "sky": + # bright top third, mid stone bottom: composition like an elevation + px = img.load() + for y in range(size[1]): + for x in range(size[0]): + px[x, y] = (150, 190, 230) if y < size[1] // 3 else (170, 165, 155) + img.save(path) + return path + + +class MetricsTest(unittest.TestCase): + def test_flat_frame_has_no_edges_and_no_contrast(self): + with tempfile.TemporaryDirectory() as tmp: + m = vl.metrics(write_image(os.path.join(tmp, "a.png"), "flat")) + self.assertEqual(m["size"], [64, 64]) + self.assertAlmostEqual(m["mean_luma"], 128.0, delta=1.0) + self.assertLess(m["rms_contrast"], 1.0) + self.assertLess(m["edge_density"], vl.MIN_EDGE_DENSITY) + + def test_checker_frame_has_edges_and_contrast(self): + with tempfile.TemporaryDirectory() as tmp: + m = vl.metrics(write_image(os.path.join(tmp, "b.png"), "checker")) + self.assertGreater(m["edge_density"], 0.01) + self.assertGreater(m["rms_contrast"], 50.0) + + def test_composition_grid_is_nine_cells(self): + with tempfile.TemporaryDirectory() as tmp: + m = vl.metrics(write_image(os.path.join(tmp, "c.png"), "sky")) + self.assertEqual(len(m["grid_luma"]), 9) + # sky row should be brighter than the stone rows + self.assertGreater(m["grid_luma"][0], m["grid_luma"][8]) + + def test_dominant_palette_is_reported(self): + with tempfile.TemporaryDirectory() as tmp: + m = vl.metrics(write_image(os.path.join(tmp, "d.png"), "flat")) + self.assertTrue(m["dominant"]) + self.assertAlmostEqual(m["dominant"][0]["share"], 1.0, delta=0.05) + + +class CompareTest(unittest.TestCase): + def _metrics(self, kind): + with tempfile.TemporaryDirectory() as tmp: + return vl.metrics(write_image(os.path.join(tmp, "x.png"), kind)) + + def test_identical_images_score_high(self): + m = self._metrics("checker") + delta = vl.compare(m, m) + self.assertGreater(delta["score"], 0.9) + self.assertEqual(delta["delta_luma"], 0.0) + + def test_flat_against_checker_scores_low_on_edges(self): + flat, checker = self._metrics("flat"), self._metrics("checker") + delta = vl.compare(flat, checker) + self.assertLess(delta["edge_ratio"], 0.5) + self.assertLess(delta["score"], 0.9) + + def test_dark_against_bright_reports_a_luma_delta(self): + dark, bright = self._metrics("dark"), self._metrics("checker") + delta = vl.compare(dark, bright) + self.assertLess(delta["delta_luma"], -50) + + +class FeedbackTest(unittest.TestCase): + def test_low_edge_ratio_names_the_modelling_skills(self): + notes = vl.feedback("x", {}, {}, { + "delta_luma": 0, "contrast_ratio": 1.0, "edge_ratio": 0.3, + "palette_distance": 10, "composition_correlation": 0.8, "score": 0.5}) + joined = " ".join(notes) + self.assertIn("Relief", joined) + self.assertIn("blender-modeler", joined) + + def test_bright_and_compressed_flags_lighting_and_lookdev(self): + notes = vl.feedback("x", {}, {}, { + "delta_luma": 40, "contrast_ratio": 0.5, "edge_ratio": 1.0, + "palette_distance": 10, "composition_correlation": 0.8, "score": 0.5}) + joined = " ".join(notes) + self.assertIn("Too bright", joined) + self.assertIn("lookdev", joined) + + def test_framing_mismatch_names_the_camera_skill(self): + notes = vl.feedback("x", {}, {}, { + "delta_luma": 0, "contrast_ratio": 1.0, "edge_ratio": 1.0, + "palette_distance": 10, "composition_correlation": 0.1, "score": 0.5}) + self.assertIn("camera-cinematography", " ".join(notes)) + + def test_no_delta_reports_no_action(self): + notes = vl.feedback("x", {}, {}, { + "delta_luma": 0, "contrast_ratio": 1.0, "edge_ratio": 1.0, + "palette_distance": 5, "composition_correlation": 0.9, "score": 0.95}) + self.assertIn("No metric is outside its tolerance", notes[0]) + + +class TargetSpecTest(unittest.TestCase): + def test_every_target_has_expectation_and_reference(self): + for target in vl.load_targets(): + self.assertTrue(target["expect"].strip(), target["name"]) + self.assertTrue(target["references"], target["name"]) + self.assertTrue(target["shot"].startswith("Screenshots/demo/"), + f"{target['name']} must be a demo-map shot") + + def test_iteration_output_is_gitignored(self): + # Iterations are deliberately uncommitted until the owner confirms a shot. + ignore = open(os.path.join(ROOT, ".gitignore"), encoding="utf-8").read() + self.assertIn("/Art/reference/iterations/", ignore) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/visual_loop.py b/Tools/visual_loop.py new file mode 100644 index 0000000..e0bb715 --- /dev/null +++ b/Tools/visual_loop.py @@ -0,0 +1,389 @@ +#!/usr/bin/env python3 +"""Iterate a building against the reference photography and the engine render. + +The loop this tool implements: + +``` +reference photograph ─┐ + ├─ compare ─► metrics + feedback ─► blender-skill actions +engine screenshot ─┘ │ + └─ next iteration (uncommitted) +``` + +It answers three questions with numbers instead of opinions: + +1. **Does the shot show what it was supposed to?** (`--verify`) — every capture + has an expectation in `Tools/visual_targets.json`; a shot that is blank, the + wrong size, or wildly unlike its expectation is reported as a failure. +2. **How far is the render from the reference?** (`--compare`) — luminance, + contrast, edge density, palette and 3×3 composition are measured on both and + reported as deltas with a 0–1 similarity score. +3. **What should be modelled next?** (`--feedback`) — the deltas are translated + into concrete actions phrased for the Blender skill packs (silhouette, relief, + value range, hue, framing). + +Iterations are **not committed**: every artefact goes under +`Art/reference/iterations//`, which is gitignored. Promote a shot into +`Screenshots/` only after the owner confirms it. + + python3 Tools/visual_loop.py --verify # gate the current set + python3 Tools/visual_loop.py --compare --iteration 3 # measure vs references + python3 Tools/visual_loop.py --compare --iteration 3 --judge # + vision advisory + +Metrics are computed with Pillow (already in requirements-tools.txt); no numpy. +""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import sys + +from PIL import Image, ImageFilter, ImageStat + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +TARGETS = os.path.join(ROOT, "Tools", "visual_targets.json") +ITER_ROOT = os.path.join(ROOT, "Art", "reference", "iterations") + +# A shot must clear these floors or it is treated as broken, not merely ugly. +MIN_EDGE_DENSITY = 0.010 +MIN_CONTRAST = 12.0 +MIN_MEAN_LUMA = 8.0 + + +# --- metrics ---------------------------------------------------------------- + + +def _luma_gray(image): + return image.convert("L") + + +def metrics(image_path): + """Measure one image. Everything is on the 8-bit luma/RGB 0..255 scale.""" + with Image.open(image_path) as handle: + image = handle.convert("RGB") + width, height = image.size + gray = _luma_gray(image) + stat = ImageStat.Stat(gray) + + # Edge density: fraction of pixels above a low threshold after FIND_EDGES. + # Stands in for "how much relief and silhouette detail is in the frame". + # Filter, then crop: FIND_EDGES samples outside the image as zero, so it leaves + # its own border ring at a high value (236 of 3600 px on a 60x60 frame, 6.5%), + # which would make a perfectly flat picture look detailed. Cropping *before* + # filtering does not help — the new border gets the same treatment. + filtered = gray.filter(ImageFilter.FIND_EDGES) + inner = filtered.crop((2, 2, max(3, width - 2), max(3, height - 2))) + hist = inner.histogram() + area = float(inner.width * inner.height) + edge_pixels = sum(hist[24:]) + edge_density = edge_pixels / area if area else 0.0 + + # Palette: quantise and keep the dominant colours with their share. + total = float(width * height) + quantised = image.quantize(colors=12, method=Image.Quantize.MEDIANCUT) + palette = quantised.getpalette() or [] + counts = sorted(quantised.getcolors() or [], reverse=True) + dominant = [] + for count, index in counts[:5]: + base = index * 3 + if base + 2 < len(palette): + dominant.append({ + "rgb": palette[base:base + 3], + "share": round(count / total, 3), + }) + + # 3x3 composition grid of mean luma, so "too much sky" is measurable. + grid = [] + for row in range(3): + for col in range(3): + box = (int(col * width / 3), int(row * height / 3), + int((col + 1) * width / 3), int((row + 1) * height / 3)) + grid.append(round(ImageStat.Stat(gray.crop(box)).mean[0], 1)) + + return { + "path": os.path.relpath(image_path, ROOT), + "size": [width, height], + "mean_luma": round(stat.mean[0], 1), + "rms_contrast": round(stat.stddev[0], 1), + "edge_density": round(edge_density, 4), + "dominant": dominant, + "grid_luma": grid, + } + + +def _palette_distance(a, b): + """Mean nearest-colour distance between two dominant palettes, 0..441.""" + if not a or not b: + return None + total = 0.0 + for entry in a: + pa = entry["rgb"] + best = min( + sum((pa[i] - pb["rgb"][i]) ** 2 for i in range(3)) ** 0.5 for pb in b) + total += best + return round(total / len(a), 1) + + +def _correlation(values_a, values_b): + n = len(values_a) + mean_a = sum(values_a) / n + mean_b = sum(values_b) / n + var_a = sum((v - mean_a) ** 2 for v in values_a) + var_b = sum((v - mean_b) ** 2 for v in values_b) + if var_a <= 0.0 or var_b <= 0.0: + return 0.0 + cov = sum((a - mean_a) * (b - mean_b) for a, b in zip(values_a, values_b)) + return round(cov / (var_a * var_b) ** 0.5, 3) + + +def compare(shot_metrics, reference_metrics): + """Delta between a render and a reference, plus a 0..1 similarity score.""" + luma = shot_metrics["mean_luma"] - reference_metrics["mean_luma"] + contrast_ratio = (shot_metrics["rms_contrast"] / + reference_metrics["rms_contrast"]) if reference_metrics["rms_contrast"] else 1.0 + edge_ratio = (shot_metrics["edge_density"] / + reference_metrics["edge_density"]) if reference_metrics["edge_density"] else 1.0 + palette = _palette_distance(shot_metrics["dominant"], reference_metrics["dominant"]) + composition = _correlation(shot_metrics["grid_luma"], reference_metrics["grid_luma"]) + + # Score: luminance within 25, contrast and edge ratios near 1, palette near 0, + # composition correlated. Each term is clamped to 0..1 and equally weighted. + def near(value, target, span): + return max(0.0, 1.0 - abs(value - target) / span) + + score = ( + near(luma, 0.0, 25.0) + + near(contrast_ratio, 1.0, 1.0) + + near(edge_ratio, 1.0, 1.5) + + (1.0 if palette is None else near(palette, 0.0, 120.0)) + + max(0.0, composition) + ) / 5.0 + + return { + "delta_luma": round(luma, 1), + "contrast_ratio": round(contrast_ratio, 2), + "edge_ratio": round(edge_ratio, 2), + "palette_distance": palette, + "composition_correlation": composition, + "score": round(score, 3), + } + + +# --- feedback --------------------------------------------------------------- +# Each rule maps a measured delta to an action phrased for the Blender skills. + + +def feedback(name, shot, reference, delta): + notes = [] + if delta["edge_ratio"] < 0.6: + notes.append( + f"**Relief and silhouette detail** are {1 - delta['edge_ratio']:.0%} below " + f"the reference (`blender-modeler` / `archviz`): add window rhythm, " + f"pilasters, cornices or quoins that cast a shadow line.") + elif delta["edge_ratio"] > 1.8: + notes.append( + "**Too busy** against the reference: the render has far more edge detail. " + "Check for stray kit pieces, z-fighting or duplicated geometry.") + + if delta["delta_luma"] <= -15: + notes.append( + f"**Too dark** by {abs(delta['delta_luma']):.0f} luma (`materials` / " + f"`lighting`): raise the material albedo or the exposure.") + elif delta["delta_luma"] >= 15: + notes.append( + f"**Too bright** by {delta['delta_luma']:.0f} luma (`materials` / " + f"`lighting`): lower the albedo or the exposure.") + + if delta["contrast_ratio"] < 0.7: + notes.append( + "**Value range compressed** (`lookdev`): the reference has much stronger " + "light/shadow separation. Deepen recesses or widen the material range.") + elif delta["contrast_ratio"] > 1.5: + notes.append( + "**Value range too harsh** (`lookdev`): soften the key or the shadow " + "contrast so the surface does not clip.") + + if delta["palette_distance"] is not None and delta["palette_distance"] > 60: + notes.append( + f"**Hue drift** ({delta['palette_distance']:.0f} palette distance, " + f"`materials`): compare the dominant colours in the iteration report " + f"against the reference and correct the base colours.") + + if delta["composition_correlation"] < 0.3: + notes.append( + "**Framing differs** (`camera-cinematography`): the 3x3 luma grid does " + "not match the reference. Check the camera height, lens and how much " + "sky/ground is in frame before changing the model.") + + if not notes: + notes.append( + "No metric is outside its tolerance. Differences left are stylistic; " + "confirm visually rather than by number.") + return notes + + +# --- verification ----------------------------------------------------------- + + +def verify(targets, judge=False): + """Check that each shot exists, is not broken, and looks like its expectation.""" + results = [] + for target in targets: + shot = os.path.join(ROOT, target["shot"]) + entry = {"name": target["name"], "shot": target["shot"], "expect": target["expect"]} + if not os.path.exists(shot): + entry["status"] = "MISSING" + results.append(entry) + continue + m = metrics(shot) + problems = [] + if m["edge_density"] < MIN_EDGE_DENSITY: + problems.append(f"almost flat (edge density {m['edge_density']})") + if m["rms_contrast"] < MIN_CONTRAST: + problems.append(f"no tonal range (contrast {m['rms_contrast']})") + if m["mean_luma"] < MIN_MEAN_LUMA: + problems.append(f"black frame (luma {m['mean_luma']})") + expected_size = target.get("size") + if expected_size and m["size"] != expected_size: + problems.append(f"size {m['size']} != {expected_size}") + entry["metrics"] = {k: m[k] for k in + ("size", "mean_luma", "rms_contrast", "edge_density")} + entry["status"] = "FAIL" if problems else "OK" + entry["problems"] = problems + + if judge and os.environ.get("OPENROUTER_API_KEY") or ( + judge and os.path.exists(os.path.join(ROOT, ".env"))): + entry["judge"] = run_judge(shot, target["expect"]) + results.append(entry) + return results + + +def run_judge(shot, expect): + """Advisory vision check. Never a gate: the model is unreliable on this content.""" + import subprocess + try: + proc = subprocess.run( + [sys.executable, os.path.join(ROOT, "Tools", "judge_screenshot.py"), + "--image", shot, "--expect", expect], + capture_output=True, text=True, timeout=180, cwd=ROOT) + out = (proc.stdout or "") + (proc.stderr or "") + for line in out.splitlines(): + if line.startswith("verdict="): + return line.strip() + return "unavailable" + except Exception as exc: # noqa: BLE001 - advisory only + return f"error: {exc}" + + +# --- reporting -------------------------------------------------------------- + + +def write_iteration(iteration, rows, args): + """Write the iteration artefacts (gitignored) and return the directory.""" + out_dir = os.path.join(ITER_ROOT, str(iteration)) + os.makedirs(out_dir, exist_ok=True) + for row in rows: + shot = os.path.join(ROOT, row["shot"]) + if os.path.exists(shot): + shutil.copy2(shot, os.path.join(out_dir, os.path.basename(shot))) + with open(os.path.join(out_dir, "metrics.json"), "w", encoding="utf-8") as handle: + json.dump({"iteration": iteration, "targets": rows}, handle, indent=2) + + lines = [f"# Iteration {iteration} — reference vs render", ""] + for row in rows: + lines.append(f"## {row['name']}") + lines.append("") + lines.append(f"- shot: `{row['shot']}`") + if row.get("reference"): + lines.append(f"- reference: `{row['reference']}`") + delta = row.get("delta") + if delta: + lines.append( + f"- **score {delta['score']:.2f}** · luma {delta['delta_luma']:+.0f} · " + f"contrast ×{delta['contrast_ratio']} · edges ×{delta['edge_ratio']} · " + f"palette {delta['palette_distance']} · composition r={delta['composition_correlation']}") + lines.append("") + for note in row.get("feedback", []): + lines.append(f"- {note}") + lines.append("") + path = os.path.join(out_dir, "feedback.md") + with open(path, "w", encoding="utf-8") as handle: + handle.write("\n".join(lines) + "\n") + return out_dir, path + + +def load_targets(): + with open(TARGETS, encoding="utf-8") as handle: + return json.load(handle)["targets"] + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--verify", action="store_true", + help="gate the current shots against their expectations") + ap.add_argument("--compare", action="store_true", + help="measure each shot against its reference(s)") + ap.add_argument("--feedback", action="store_true", help="include action notes (default with --compare)") + ap.add_argument("--judge", action="store_true", help="add the advisory vision verdict") + ap.add_argument("--iteration", default=None, help="iteration label for the output directory") + args = ap.parse_args(argv) + + if not (args.verify or args.compare): + args.verify = args.compare = True + + targets = load_targets() + + if args.verify: + print("== verification ==") + results = verify(targets, judge=args.judge) + failed = [r for r in results if r["status"] != "OK"] + for r in results: + extra = f" ({'; '.join(r['problems'])})" if r.get("problems") else "" + judge = f" [judge: {r['judge']}]" if r.get("judge") else "" + print(f" {r['status']:7} {r['name']:32}{extra}{judge}") + print(f" -> {len(results) - len(failed)}/{len(results)} shots OK") + + if args.compare: + print("\n== comparison ==") + rows = [] + for target in targets: + shot_path = os.path.join(ROOT, target["shot"]) + if not os.path.exists(shot_path): + continue + shot_m = metrics(shot_path) + best = None + for ref_rel in target.get("references", []): + ref_path = os.path.join(ROOT, ref_rel) + if not os.path.exists(ref_path): + continue + ref_m = metrics(ref_path) + delta = compare(shot_m, ref_m) + if best is None or delta["score"] > best[1]["score"]: + best = (ref_rel, delta, ref_m) + if best is None: + continue + ref_rel, delta, ref_m = best + rows.append({ + "name": target["name"], + "shot": target["shot"], + "reference": ref_rel, + "delta": delta, + "feedback": feedback(target["name"], shot_m, ref_m, delta), + "expect": target["expect"], + }) + print(f" {target['name']:32} score {delta['score']:.2f} " + f"luma {delta['delta_luma']:+.0f} edges ×{delta['edge_ratio']}") + if args.iteration is not None: + out_dir, feedback_path = write_iteration(args.iteration, rows, args) + print(f"\n iteration written to {os.path.relpath(out_dir, ROOT)}") + print(f" feedback: {os.path.relpath(feedback_path, ROOT)}") + print(" (gitignored: promote a shot into Screenshots/ only after owner confirmation)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Tools/visual_targets.json b/Tools/visual_targets.json new file mode 100644 index 0000000..c3e0665 --- /dev/null +++ b/Tools/visual_targets.json @@ -0,0 +1,53 @@ +{ + "comment": "What each demo-map capture must show, and which reference photograph it is measured against. Used by Tools/visual_loop.py --verify (does the shot show the expected content?) and --compare (how far is the render from the reference?).", + "targets": [ + { + "name": "demo-spawn-view", + "shot": "Screenshots/demo/review-demo-spawn-view.png", + "size": [ + 1920, + 1080 + ], + "references": [ + "Art/reference/hollywood/el-capitan-facade-day-wikimedia-2009-10-27.jpg" + ], + "expect": "The whole south block from the north sidewalk: the neoclassical hero facade between its neighbouring buildings, both sidewalks and the road in the foreground, daylight" + }, + { + "name": "demo-street-level", + "shot": "Screenshots/demo/review-demo-street-level.png", + "size": [ + 1920, + 1080 + ], + "references": [ + "Art/reference/hollywood/el-capitan-theatre-blade-sign-day-wikimedia-2008-10.jpg" + ], + "expect": "Street level from the north side: the hero colonnade with the adjacent frontage buildings either side, shopfronts at pavement level, daylight" + }, + { + "name": "demo-crossing", + "shot": "Screenshots/demo/review-demo-crossing.png", + "size": [ + 1920, + 1080 + ], + "references": [ + "Art/reference/hollywood/walk-of-fame-terrazzo-ovation-day-cc0-2019-06-20.jpg" + ], + "expect": "The designated mid-block crossing: continental zebra bars on the asphalt, the south frontage and hero colonnade behind, daylight" + }, + { + "name": "demo-north-buildings", + "shot": "Screenshots/demo/review-demo-north-buildings.png", + "size": [ + 1920, + 1080 + ], + "references": [ + "Art/reference/north-chinese/01-grauman-s-chinese-theatre-by-carol-highsmith.jpg" + ], + "expect": "The two north-side buildings (TCL Chinese Theatre, Madame Tussaud's) seen from across Hollywood Boulevard, full height in frame, daylight" + } + ] +} \ No newline at end of file diff --git a/requirements-tools.txt b/requirements-tools.txt index b18d513..597bf74 100644 --- a/requirements-tools.txt +++ b/requirements-tools.txt @@ -1 +1 @@ -requests>=2.28 +Pillow>=9.2