feat(facades): 0.1.4 — module kit, textured materials, theatre trim #30

Merged
zwitschi merged 13 commits from facades-0.1.4 into main 2026-11-08 20:29:12 +01:00
25 changed files with 688 additions and 121 deletions
Showing only changes of commit bd1f8a9c2e - Show all commits

No files matched your search

+4
View File
@@ -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
Binary file not shown.
Binary file not shown.
+24
View File
@@ -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.
+33 -45
View File
@@ -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` | [<img src="review-overview.png" alt="overview" width="180">](review-overview.png) | Review | 7 m up, NE | Whole review layout: character + both cubes on the floor | ☑ 2026-11-09 |
| 2 | `review-character.png` | [<img src="review-character.png" alt="character" width="180">](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` | [<img src="review-scale-cubes.png" alt="scale cubes" width="180">](review-scale-cubes.png) | Review | south | Floating vs grounded cube: cast vs contact shadow | ☑ 2026-11-09 |
| 4 | `review-player-placement.png` | [<img src="review-player-placement.png" alt="player placement" width="180">](review-player-placement.png) | Review | spawn | The `AGigLineCharacter` pawn, capsule height and landing | ☑ 2026-11-09 |
| 5 | `review-npc-prefab.png` | [<img src="review-npc-prefab.png" alt="npc prefab" width="180">](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` | [<img src="review-ground-contact.png" alt="ground contact" width="180">](review-ground-contact.png) | Review | knee height, feet | Floating/sinking character is obvious here | ☑ 2026-11-09 |
| 7 | `review-shadow-compare.png` | [<img src="review-shadow-compare.png" alt="shadow compare" width="180">](review-shadow-compare.png) | Review | low angle | Detached (floating) vs touching (grounded) shadows | ☑ 2026-11-09 |
| 8 | `review-hero-portals.png` | [<img src="review-hero-portals.png" alt="hero portals" width="180">](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` | [<img src="review-hero-facade-front.png" alt="hero facade front" width="180">](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` | [<img src="review-hero-facade-oblique.png" alt="hero facade oblique" width="180">](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` | [<img src="demo/review-demo-spawn-view.png" alt="spawn view" width="180">](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` | [<img src="demo/review-demo-street-level.png" alt="street level" width="180">](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` | [<img src="demo/review-demo-crossing.png" alt="crossing" width="180">](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` | [<img src="demo/review-demo-north-buildings.png" alt="north buildings" width="180">](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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+3 -1
View File
@@ -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
+32 -34
View File
@@ -1,56 +1,54 @@
#!/usr/bin/env bash
# Render in-game screenshots for visual review into <repo>/Screenshots/ (gitignored).
# Render the demo-map screenshots used for visual review into <repo>/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"
[ "$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
+139
View File
@@ -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()
+389
View File
@@ -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/<iteration>/`, 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())
+53
View File
@@ -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"
}
]
}
+1 -1
View File
@@ -1 +1 @@
requests>=2.28
Pillow>=9.2