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` | [
](review-overview.png) | Review | 7 m up, NE | Whole review layout: character + both cubes on the floor | ☑ 2026-11-09 |
-| 2 | `review-character.png` | [
](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` | [
](review-scale-cubes.png) | Review | south | Floating vs grounded cube: cast vs contact shadow | ☑ 2026-11-09 |
-| 4 | `review-player-placement.png` | [
](review-player-placement.png) | Review | spawn | The `AGigLineCharacter` pawn, capsule height and landing | ☑ 2026-11-09 |
-| 5 | `review-npc-prefab.png` | [
](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` | [
](review-ground-contact.png) | Review | knee height, feet | Floating/sinking character is obvious here | ☑ 2026-11-09 |
-| 7 | `review-shadow-compare.png` | [
](review-shadow-compare.png) | Review | low angle | Detached (floating) vs touching (grounded) shadows | ☑ 2026-11-09 |
-| 8 | `review-hero-portals.png` | [
](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` | [
](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` | [
](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` | [
](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` | [
](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` | [
](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` | [
](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