refactor(tools): group the sixty scripts by what they talk to

One directory with sixty scripts stopped being navigable, and the grouping is also the sandbox boundary:

  Tools/unreal/    commandlets and the two map builders, with ue_common.py and ue_placement.py as the
                   shared imports (they need the engine)
  Tools/blender/   builders, exporters, the FBX preset, the MPFB character pipeline, blender_screenshots
                   (they need the Blender Flatpak)
  Tools/review/    the visual loop: render_screenshots.sh, judge_screenshot.py, hero_compare.py,
                   build_comparison_md.py, e2e_asset_check.py, hero_iterate.sh, visual_loop.py,
                   visual_targets.json
  Tools/gen/       image and texture generation: the ComfyUI driver, deterministic texture writers,
                   reference fetching
  Tools/input/     OS-level input injection
  root             _common.py, check-lfs.sh, addon_compat_probe.py, sibling_asset_audit.py, tests/

Every moved script now resolves the repository root as ../.. and puts the tools root plus its siblings on
sys.path, so 'import ue_common' still works from unreal/. The test suite bootstrap does the same for the five
folders. render_screenshots.sh takes the folder as a third argument to run_cmdlet, and the e2e driver,
hero_iterate.sh and the comparison generator were repointed.

51 README rows now link to their new location, the README gains a Layout section naming each folder's
sandbox, and the architecture notes and Blender README follow the moves.

Verified: 108 tests pass, prettier is clean, judge_screenshot.py and hero_compare.py run from their new
homes, and the e2e run was aborted mid-way (it had been rebuilding an empty DemoMap for 15 minutes) and is
being rerun against the new layout.
This commit is contained in:
zwitschi committed 2026-11-10 12:40:30 +01:00
1 parent f681d83736
commit 4d27109668
168 files changed
+799 -407

No files matched your search

+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env python3
"""Build the Blender-vs-in-game comparison page.
python3 Tools/review/build_comparison_md.py
Reads every in-game capture in `Screenshots/demo/` and every Blender render in
`Screenshots/blender/`, pairs them by name, and writes `Screenshots/COMPARISON.md`:
* one table with the two images **side by side per row**, so a mismatch is visible in a
single glance rather than by opening two files;
* one signoff table, one row per in-game screen, for the owner's verdict.
Pairing is by name: `review-demo-vehicle.png` pairs with `vehicle-angle-*.png` because
both mention `vehicle`; anything unmatched is shown with an em dash rather than being
quietly dropped, since a missing Blender render is itself information.
"""
from __future__ import annotations
import os
import re
import shutil
import subprocess
DEMO_DIR = "Screenshots/demo"
REVIEW_DIR = "Screenshots/review"
BLENDER_DIR = "Screenshots/blender"
OUT = "Screenshots/README.md"
BEGIN = "<!-- blender-comparison:begin -->"
END = "<!-- blender-comparison:end -->"
# Shots that are about a modelled asset pair with a Blender render; the street and
# building shots have no Blender counterpart because those meshes come from the Geo
# pipeline, not from a .blend.
ASSET_KEYWORDS = ("vehicle",)
# Shots are named after the camera, renders after the asset. Without this the character
# shots never pair with the character renders, because "third-person-front" and
# "character-angle-035" share no words.
SHOT_ALIASES = {
"third-person": "character",
"third-person-front": "character",
"third-person-walk": "character",
}
def review_shots():
if not os.path.isdir(REVIEW_DIR):
return []
return sorted(f for f in os.listdir(REVIEW_DIR) if f.endswith(".png"))
def demo_shots():
if not os.path.isdir(DEMO_DIR):
return []
return sorted(f for f in os.listdir(DEMO_DIR) if f.endswith(".png"))
def blender_renders():
if not os.path.isdir(BLENDER_DIR):
return []
return sorted(f for f in os.listdir(BLENDER_DIR) if f.endswith(".png"))
def shot_label(filename):
name = os.path.splitext(filename)[0]
name = re.sub(r"^review-demo-", "", name)
return name.replace("-", " ")
def best_blender_match(shot, renders):
"""Pick the Blender render whose name shares the most words with the shot."""
stem = os.path.splitext(shot)[0].lower().replace("review-demo-", "")
words = set(re.split(r"[-_]", stem))
for alias, asset in SHOT_ALIASES.items():
if alias in stem:
words.add(asset)
scored = []
for render in renders:
render_words = set(re.split(r"[-_]", os.path.splitext(render)[0].lower()))
score = len(words & render_words)
if score:
scored.append((score, render))
if not scored:
return None
scored.sort(key=lambda item: (-item[0], item[1]))
return scored[0][1]
def relative_to_page(path):
"""Paths in the page are relative to Screenshots/, where COMPARISON.md lives."""
if not path:
return path
base = os.path.dirname(OUT)
return os.path.relpath(path, base).replace(os.sep, "/")
def image_cell(path, width=520):
if not path:
return "—"
return f'<img src="{relative_to_page(path)}" width="{width}">'
def build():
shots = review_shots() + demo_shots()
renders = blender_renders()
lines = [
"### Blender vs in-game",
"",
"Regenerated by `Tools/review/build_comparison_md.py`; everything between the markers is",
"rewritten, so edit outside them.",
"",
f"- Captures: `{REVIEW_DIR}/` and `{DEMO_DIR}/` ({len(shots)} files)",
f"- Blender renders: `{BLENDER_DIR}/` ({len(renders)} files), made by `Tools/blender/blender_screenshots.py`",
"",
"Left is what the engine draws, right is what Blender has. A gap between them is the",
"import problem, not a taste problem: scale, material colour, missing parts and split",
"systems all show up here before they show up in a review.",
"",
"### Side by side",
"",
"| In-game | Blender | Shot |",
"| --- | --- | --- |",
]
paired = {}
for shot in shots:
match = best_blender_match(shot, renders)
paired[shot] = match
lines.append(
f"| {image_cell(f'{REVIEW_DIR if shot in review_shots() else DEMO_DIR}/{shot}')} "
f"| {image_cell(f'{BLENDER_DIR}/{match}' if match else None)} "
f"| `{shot}` |")
if not shots:
lines.append("| — | — | no captures |")
matched = sum(1 for v in paired.values() if v)
lines += [
"",
f"{matched} of {len(shots)} in-game captures have a Blender counterpart; the rest are",
"street and building shots whose meshes come from the Geo pipeline rather than a .blend.",
"",
"Approval and issue notes are recorded only in the status table at the top of this file.",
"",
]
block = "\n".join([BEGIN, ""] + lines + ["", END])
if os.path.exists(OUT):
with open(OUT) as handle:
existing = handle.read()
else:
existing = "# Screenshots\n"
# The comparison lives inside the main review README, between markers, so it is
# regenerated in place: the rest of the README is hand-written and must not be
# rewritten by a tool.
if BEGIN in existing and END in existing:
head = existing[:existing.index(BEGIN)]
tail = existing[existing.index(END) + len(END):]
updated = head + block + tail
else:
updated = existing.rstrip("\n") + "\n\n" + block + "\n"
with open(OUT, "w") as handle:
handle.write(updated)
# The README is checked by `prettier --check` in the repo's gates, and a generated
# markdown table is never byte-identical to what prettier produces. Format it here so
# regenerating the section cannot leave the tree failing a format check.
formatter = shutil.which("npx")
if formatter:
try:
subprocess.run([formatter, "--no-install", "prettier", "--write", OUT],
check=False, capture_output=True, timeout=120)
except (OSError, subprocess.SubprocessError):
pass
print(f"updated {OUT} ({len(shots)} shots, {matched} paired, {len(renders)} renders)")
return 0
if __name__ == "__main__":
raise SystemExit(build())
+130
View File
@@ -0,0 +1,130 @@
"""End-to-end asset check: Blender render -> Unreal build -> captures -> judge -> README.
python3 Tools/review/e2e_asset_check.py # everything
python3 Tools/review/e2e_asset_check.py --skip-blender # reuse the existing Blender renders
python3 Tools/review/e2e_asset_check.py --skip-judge # no API calls, just the images
The chain, in order, each step failing loudly with its log under /tmp/gigline/:
1. **Blender renders** - `Art/blender/*/` assets rendered by `Tools/blender/blender_screenshots.py`, so the
source the imports came from has an image of its own.
2. **Empty maps** - `Tools/unreal/ue_recreate_maps.py` rebuilds both maps, so a run never inherits an actor
from an experiment. The builders also clear the level they load; this is the belt-and-braces step.
3. **Unreal build** - `Tools/unreal/ue_build_demo_map.py` (game map) and `Tools/unreal/ue_build_review_map.py`
(staging map for individual assets).
4. **Captures** - `Tools/review/render_screenshots.sh`, which renders both maps into `Screenshots/`.
5. **Judge** - every shot in `Tools/review/visual_targets.json` judged by **two models** through
`Tools/review/judge_screenshot.py --shots`, which reconciles agreement and reports disagreement.
6. **Review file** - `Tools/review/build_comparison_md.py` rewrites the Blender-vs-UE block in
`Screenshots/README.md`, then prettier formats it.
The report lands in `/tmp/gigline/e2e-report.txt`; the exit code is non-zero if any step failed.
"""
from __future__ import annotations
import argparse
import os
import subprocess
import sys
import time
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
ENGINE = os.environ.get("ENGINE", "/run/media/system/DATA/Engines/UE_5.8.4")
UPROJECT = os.path.join(ROOT, "Game", "GigLine", "GigLine.uproject")
BLENDER = os.environ.get(
"BLENDER_FILES", "/var/lib/flatpak/app/org.blender.Blender/x86_64/stable/active/files")
UNREAL = os.path.join(ENGINE, "Engine", "Binaries", "Linux", "UnrealEditor-Cmd")
REPORT = "/tmp/gigline/e2e-report.txt"
def run(step, cmd, env=None, timeout=None, result_file=None, expect=None):
"""Run one step and record it. A step fails on a non-zero exit or a missing marker."""
started = time.time()
print(f"== {step}")
try:
proc = subprocess.run(cmd, cwd=ROOT, env={**os.environ, **(env or {})},
capture_output=True, text=True, timeout=timeout)
except subprocess.TimeoutExpired:
return step, False, f"timed out after {timeout}s"
elapsed = time.time() - started
if proc.returncode != 0:
tail = (proc.stderr or proc.stdout or "").strip().splitlines()[-3:]
return step, False, f"exit {proc.returncode} ({elapsed:.0f}s): {' / '.join(tail)}"
if result_file and expect:
if not os.path.exists(result_file):
return step, False, f"no result file {result_file}"
text = open(result_file, errors="replace").read()
if expect not in text:
return step, False, f"{os.path.basename(result_file)} lacks {expect!r}"
return step, True, f"{elapsed:.0f}s"
def unreal_script(script, timeout=1800):
return [UNREAL, UPROJECT, "-run=pythonscript", f"-script={os.path.join(ROOT, script)}",
"-nullrhi", "-unattended", "-nopause", "-stdout"]
def main():
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--skip-blender", action="store_true", help="reuse the Blender renders")
parser.add_argument("--skip-judge", action="store_true", help="no API calls")
parser.add_argument("--skip-recreate", action="store_true",
help="keep the existing maps (the builders still clear their levels)")
args = parser.parse_args()
steps = []
if not args.skip_blender:
steps.append(run("1/6 blender renders",
[os.path.join(BLENDER, "blender", "blender"), "-b", "--factory-startup",
"--python", os.path.join(ROOT, "Tools", "blender", "blender_screenshots.py"),
"--", "--mode", "both", "--out", os.path.join(ROOT, "Screenshots", "blender")],
env={"LD_LIBRARY_PATH": os.path.join(BLENDER, "blender", "lib")},
timeout=1800))
else:
steps.append(("1/6 blender renders", True, "skipped, reusing Screenshots/blender"))
if not args.skip_recreate:
steps.append(run("2/6 empty maps", unreal_script("Tools/unreal/ue_recreate_maps.py"),
timeout=900, result_file="/tmp/gigline/recreate-maps.txt",
expect="VERDICT: PASS"))
else:
steps.append(("2/6 empty maps", True, "skipped"))
steps.append(run("3/6 unreal build: demo map", unreal_script("Tools/unreal/ue_build_demo_map.py"),
timeout=1800, result_file="/tmp/gigline/demo-map.txt", expect="save_map"))
steps.append(run("3/6 unreal build: review map", unreal_script("Tools/unreal/ue_build_review_map.py"),
timeout=1800, result_file="/tmp/ue-review-map-result.txt", expect="save_map"))
steps.append(run("4/6 captures", ["bash", os.path.join(ROOT, "Tools", "review", "render_screenshots.sh")],
timeout=2400))
if not args.skip_judge:
steps.append(run("5/6 judge (two models)",
[sys.executable, os.path.join(ROOT, "Tools", "review", "judge_screenshot.py"), "--shots"],
timeout=1800))
else:
steps.append(("5/6 judge (two models)", True, "skipped"))
steps.append(run("6/6 review file",
[sys.executable, os.path.join(ROOT, "Tools", "review", "build_comparison_md.py")],
timeout=600))
lines = [f"e2e asset check {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}", ""]
ok = True
for step, passed, detail in steps:
lines.append(f"{'PASS' if passed else 'FAIL'} {step}: {detail}")
ok = ok and passed
lines += ["", f"VERDICT: {'PASS' if ok else 'FAIL'}",
"Review: Screenshots/README.md (status table at the top)."]
os.makedirs(os.path.dirname(REPORT), exist_ok=True)
with open(REPORT, "w") as handle:
handle.write("\n".join(lines) + "\n")
print()
print("\n".join(lines))
return 0 if ok else 1
if __name__ == "__main__":
raise SystemExit(main())
+56
View File
@@ -0,0 +1,56 @@
"""Build a side-by-side plate from a reference photograph and a rendered capture.
python3 Tools/review/hero_compare.py \
--reference Art/reference/hero-masonic/02-masonictemple01.jpg \
--render Screenshots/review/review-hero-facade-front.png \
--out Screenshots/review/hero-compare-front.png
Both halves are scaled to the same height and labelled, so the two can be read at a glance instead of
flipping between files. Reference photography is git-ignored and never committed; only the plate is.
Result: the plate path on stdout.
"""
import argparse
import os
from PIL import Image, ImageDraw
LABEL_H = 56
TARGET_H = 900
def load_scaled(path, height):
image = Image.open(path).convert("RGB")
width = int(image.width * height / image.height)
return image.resize((width, height), Image.LANCZOS)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--reference", required=True)
parser.add_argument("--render", required=True)
parser.add_argument("--out", required=True)
parser.add_argument("--left-label", default="reference")
parser.add_argument("--right-label", default="rendered")
args = parser.parse_args()
left = load_scaled(args.reference, TARGET_H)
right = load_scaled(args.render, TARGET_H)
gap = 16
width = left.width + right.width + gap * 3
plate = Image.new("RGB", (width, TARGET_H + LABEL_H + gap * 2), (24, 24, 26))
plate.paste(left, (gap, LABEL_H + gap))
plate.paste(right, (gap * 2 + left.width, LABEL_H + gap))
draw = ImageDraw.Draw(plate)
draw.text((gap, 18), args.left_label, fill=(235, 235, 235))
draw.text((gap * 2 + left.width, 18), args.right_label, fill=(235, 235, 235))
os.makedirs(os.path.dirname(os.path.abspath(args.out)), exist_ok=True)
plate.save(args.out)
print(args.out)
main()
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# Full hero-facade iteration: build -> import -> render -> judge -> comparison plate.
#
# bash Tools/review/hero_iterate.sh [shot]
#
# `shot` defaults to review-hero-facade-front. Every step writes to /tmp/gigline/ so a failure can be
# read back: Unreal commandlets swallow print().
#
# Why a script and not four remembered commands: the loop is run once per modelling change, and the
# order is load-bearing. The Blender save happens BEFORE the FBX export, so a read-only .blend makes
# the build look successful while the old FBX is reused silently - hence the chmod at the top.
set -uo pipefail
cd "$(dirname "$0")/.." || exit 1
ENGINE="${ENGINE:-/run/media/system/DATA/Engines/UE_5.8.4}"
BLENDER_FILES="${BLENDER_FILES:-/var/lib/flatpak/app/org.blender.Blender/x86_64/stable/active/files}"
UPROJECT="$(pwd)/Game/GigLine/GigLine.uproject"
SHOT="${1:-review-hero-facade-front}"
REFERENCE="${REFERENCE:-Art/reference/hero-masonic/02-masonictemple01.jpg}"
step() { printf '\n== %s\n' "$1"; }
step "1/6 blender files writable"
find Art -name '*.blend*' -exec chmod u+w {} \;
step "2/6 build the facade"
LD_LIBRARY_PATH="$BLENDER_FILES/blender/lib" "$BLENDER_FILES/blender/blender" -b --factory-startup \
--python Tools/blender/hero_facade_build.py -- \
--out /tmp/gigline/hero-facade.fbx \
--save-blend Art/blender/hero/hero-facade-2026-11-07.blend \
--report Art/blender/hero/hero-facade-2026-11-07.json \
> /tmp/gigline/hero-build.log 2>&1 || { echo "build failed - see /tmp/gigline/hero-build.log"; exit 1; }
grep -E 'exported FBX' /tmp/gigline/hero-build.log || { echo "no FBX written"; exit 1; }
step "3/6 import into Unreal"
"$ENGINE/Engine/Binaries/Linux/UnrealEditor-Cmd" "$UPROJECT" -run=pythonscript \
-script="$(pwd)/Tools/unreal/ue_hero_facade_import.py" -nullrhi -unattended -nopause -stdout \
> /tmp/gigline/hero-import.log 2>&1
grep -q 'save_map' /tmp/gigline/hero-facade-import.txt || { echo "import failed - see /tmp/gigline/hero-facade-import.txt"; exit 1; }
step "4/6 rebuild DemoMap (materials live in the map builder)"
"$ENGINE/Engine/Binaries/Linux/UnrealEditor-Cmd" "$UPROJECT" -run=pythonscript \
-script="$(pwd)/Tools/unreal/ue_build_demo_map.py" -nullrhi -unattended -nopause -stdout \
> /tmp/gigline/hero-demomap.log 2>&1
grep -q 'save_map' /tmp/gigline/demo-map.txt || { echo "demo map rebuild failed - see /tmp/gigline/demo-map.txt"; exit 1; }
step "5/6 render the capture set"
bash Tools/review/render_screenshots.sh > /tmp/gigline/hero-render.log 2>&1 || { echo "render failed - see /tmp/gigline/hero-render.log"; exit 1; }
step "6/6 judge and compare"
python3 Tools/review/judge_screenshot.py --image "Screenshots/review/${SHOT}.png" \
--expect "A neoclassical Masonic temple facade: warm yellow-grey sandstone with horizontal ashlar courses, six giant fluted Ionic columns on square pedestals, three tall red vertical banners reading JIMMY KIMMEL LIVE hanging between the columns, dark lattice metal grilles in the window bays behind the colonnade, three recessed dark entrance doorways with steps, above the columns a carved inscription band flanked by two round medallions, topped by a crested parapet." \
| tee /tmp/gigline/hero-judge.txt | tail -2
python3 Tools/review/hero_compare.py --reference "$REFERENCE" \
--render "Screenshots/review/${SHOT}.png" \
--out "Screenshots/review/hero-compare-${SHOT#review-hero-}.png"
echo
echo "plate: Screenshots/review/hero-compare-${SHOT#review-hero-}.png"
+238
View File
@@ -0,0 +1,238 @@
#!/usr/bin/env python3
"""Judge a screenshot against an expected-scene description using OpenRouter
multimodal image understanding.
Reads OPENROUTER_API_KEY from the project .env (ignored, never committed) and
sends the screenshot as a base64 data-URI image alongside a description of what
the shot is expected to show. Returns a verdict (yes / no / uncertain), a
confidence, and the model's reasoning, so a screenshot can be pre-checked before
a human reviews it.
API: OpenRouter chat/completions, OpenAI-compatible image_url content part:
{"type": "image_url", "image_url": {"url": "data:image/png;base64,<...>"}}
Docs: https://openrouter.ai/docs/guides/overview/multimodal/image-understanding
Usage:
judge_screenshot.py --image Screenshots/review-overview.png \
--expect "a character and two 1 m cubes on a large grey floor"
judge_screenshot.py --image Screenshots/review-character.png \
--expect "the idle upright human figure with warm skin, feet on the plane" \
--model openai/gpt-4o-mini --verbose
judge_screenshot.py --shots # evaluate every review-NN shot in README
.env format:
OPENROUTER_API_KEY=sk-or-v1-...
"""
import argparse
import base64
import json
import os
import re
import sys
import requests
API_URL = "https://openrouter.ai/api/v1/chat/completions"
DEFAULT_MODEL = "openai/gpt-4o-mini"
# Two models by default. A single vision model is advisory and has been wrong in both directions on this
# project, so every image is judged twice by different families and the pair is reconciled below.
DEFAULT_MODELS = (
"openai/gpt-4o-mini",
"qwen/qwen3.7-flash",
)
def combine(judgements):
"""Reconcile two or more model judgements into one verdict.
Agreement carries the verdict and the mean confidence. Disagreement is reported as `uncertain` with
both reasons kept, because picking the more confident model would hide exactly the case worth looking
at - the models seeing different things.
"""
usable = [j for j in judgements if j.get("verdict") in ("yes", "no", "uncertain")]
if not usable:
return {"verdict": "unknown", "confidence": 0.0,
"reason": "no usable verdicts", "models": {}}
verdicts = {j["verdict"] for j in usable}
by_model = {j.get("model", "?"): {"verdict": j.get("verdict"),
"confidence": j.get("confidence", 0.0),
"reason": j.get("reason", "")} for j in usable}
if len(verdicts) == 1:
verdict = verdicts.pop()
confidence = sum(j.get("confidence", 0.0) for j in usable) / len(usable)
reason = usable[0].get("reason", "")
else:
verdict = "uncertain"
confidence = min(j.get("confidence", 0.0) for j in usable)
reason = "models disagree: " + " | ".join(
f"{j.get('model')}={j.get('verdict')}: {j.get('reason', '')[:120]}" for j in usable)
return {"verdict": verdict, "confidence": confidence, "reason": reason, "models": by_model}
# Project root = parent of the Tools/ directory the script lives in.
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
ENV_PATH = os.path.join(PROJECT_ROOT, ".env")
def load_api_key():
"""Read OPENROUTER_API_KEY from the project .env."""
if not os.path.exists(ENV_PATH):
sys.exit(f"error: no {ENV_PATH} — create it with OPENROUTER_API_KEY=sk-or-v1-...")
with open(ENV_PATH, encoding="utf-8") as f:
for line in f:
line = line.strip()
if line and not line.startswith("#"):
key, _, val = line.partition("=")
if key.strip() == "OPENROUTER_API_KEY":
return val.strip()
sys.exit("error: OPENROUTER_API_KEY not found in .env")
def image_data_uri(path):
"""Return a base64 data: URI for a local image file."""
if not os.path.isfile(path):
sys.exit(f"error: image not found: {path}")
ext = os.path.splitext(path)[1].lower().lstrip(".") or "png"
mime = {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
"webp": "image/webp", "gif": "image/gif"}.get(ext, "image/png")
with open(path, "rb") as fh:
b64 = base64.b64encode(fh.read()).decode("ascii")
return f"data:{mime};base64,{b64}"
def build_messages(image_path, expect):
user = (
f"You are an objective QA reviewer for a 3D game scene. Look at the "
f"rendered screenshot and judge whether it shows what is expected.\n\n"
f"Expected scene:\n{expect}\n\n"
f"Answer STRICTLY in this JSON shape, with no other text:\n"
f'{{"verdict": "yes"|"no"|"uncertain", "confidence": 0.0-1.0, '
f'"reason": "<short reason, up to 3 sentences>", '
f'"missing": ["<brief gap, if any>"]}}'
)
return [{
"role": "user",
"content": [
{"type": "text", "text": user},
{"type": "image_url", "image_url": {"url": image_data_uri(image_path)}},
],
}]
def judge(api_key, image_path, expect, model, timeout=60):
"""One model's judgement. `combine()` reconciles several of these."""
payload = {
"model": model,
"messages": build_messages(image_path, expect),
"temperature": 0,
}
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
# Identifies the caller to OpenRouter; HTTP-Referer/X-Title are optional.
"HTTP-Referer": "https://github.com/zwitschi/gig-line",
"X-Title": "gig-line screenshot judge",
}
resp = requests.post(API_URL, headers=headers, json=payload, timeout=timeout)
if resp.status_code != 200:
body = resp.text[:500]
sys.exit(f"error: OpenRouter returned {resp.status_code}: {body}")
data = resp.json()
content = data["choices"][0]["message"]["content"]
# Trim any markdown fences and take the first JSON object we can parse.
m = re.search(r"\{.*\}", content, re.S)
if not m:
sys.exit(f"error: model returned non-JSON:\n{content[:500]}")
verdict = json.loads(m.group(0))
verdict["model"] = model
return verdict
def parse_shots_from_targets():
"""Read the review shots and their expectations from Tools/review/visual_targets.json.
Parsing the README's tables broke when the README was restructured into per-shot
sections with the comparison table appended: the old parser looked for a table whose
first cell held a backticked `review-` name. The targets file is the same source the
render gate uses, so it is both stable and the thing the expectations are written to.
"""
targets_path = os.path.join(PROJECT_ROOT, "Tools", "review", "visual_targets.json")
if not os.path.isfile(targets_path):
sys.exit(f"error: no {targets_path}")
import json
with open(targets_path) as handle:
data = json.load(handle)
rows = []
for target in data.get("targets", []):
shot = target.get("shot") or ""
expect = target.get("expect") or ""
if not shot or not expect:
continue
rows.append((shot, expect))
return rows
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
g = ap.add_mutually_exclusive_group(required=True)
g.add_argument("--image", help="path to a screenshot PNG to judge")
g.add_argument("--shots", action="store_true",
help="evaluate every review shot listed in Screenshots/README.md")
ap.add_argument("--expect", help="what the shot is expected to show (required with --image)")
ap.add_argument("--models", default=",".join(DEFAULT_MODELS),
help=f"comma-separated OpenRouter models; every image is judged by all of them "
f"and the results reconciled (default {','.join(DEFAULT_MODELS)})")
ap.add_argument("--model", default=None,
help="single model, overriding --models (one judgement, no reconciliation)")
ap.add_argument("--verbose", action="store_true")
ap.add_argument("--timeout", type=int, default=60)
args = ap.parse_args()
api_key = load_api_key()
models = [args.model] if args.model else [m.strip() for m in args.models.split(",") if m.strip()]
if args.shots:
shots = parse_shots_from_targets()
if not shots:
sys.exit("error: no shots found in Tools/review/visual_targets.json")
n = len(shots)
print(f"Judging {n} screenshots with {' + '.join(models)} ...")
ok = 0
for i, (fname, expect) in enumerate(shots, 1):
path = os.path.join(PROJECT_ROOT, fname)
if not os.path.isfile(path):
print(f"[{i}/{n}] {fname}: MISSING (skip)")
continue
try:
judged = [judge(api_key, path, expect, model, args.timeout) for model in models]
except SystemExit as e:
print(f"[{i}/{n}] {fname}: ERROR {e}")
continue
res = combine(judged) if len(judged) > 1 else judged[0]
verdict = res.get("verdict", "unknown")
conf = res.get("confidence", 0.0)
reason = res.get("reason", "")
mark = {"yes": "OK", "no": "FAIL", "uncertain": "?"}.get(verdict, "?")
print(f"[{i}/{n}] {fname}: {mark} ({conf:.2f}) {reason}")
if verdict == "yes":
ok += 1
print(f"\nSummary: {ok}/{n} judged yes")
return
if not args.expect:
ap.error("--expect is required with --image")
judged = [judge(api_key, args.image, args.expect, model, args.timeout) for model in models]
res = combine(judged) if len(judged) > 1 else judged[0]
if args.verbose:
print(json.dumps(res, indent=2))
else:
print(f"verdict={res.get('verdict')} confidence={res.get('confidence')} "
f"reason={res.get('reason', '')}")
for model, detail in res.get("models", {}).items():
print(f" {model}: {detail['verdict']} ({detail['confidence']:.2f})")
if __name__ == "__main__":
main()
+136
View File
@@ -0,0 +1,136 @@
# UE-side screenshot renderer for visual review (commandlet, real RHI).
#
# Builds a small review scene in the default world (floor, sky, key light,
# MPFB character, the three scale-test cubes) and renders three fixed camera
# angles synchronously via SceneCapture2D render targets, writing PNGs
# directly into <repo>/Screenshots/ (gitignored).
#
# Invoked by Tools/review/render_screenshots.sh (UnrealEditor-Cmd -run=pythonscript).
# NOTE: no -nullrhi — rendering needs the real Vulkan RHI. In 5.8 the
# commandlet swallows print(); results go to /tmp/ue-screenshots-result.txt.
# Do NOT load_map here: after a map load the commandlet's spawn context dies
# and every spawn_actor call returns None.
import os
import unreal
RESULT = "/tmp/ue-screenshots-result.txt"
lines = []
def log(msg):
lines.append(str(msg))
REPO = os.path.abspath(os.path.join(unreal.Paths.project_dir(), "..", ".."))
OUT_DIR = os.path.join(REPO, "Screenshots")
WORLD = unreal.EditorLevelLibrary.get_editor_world()
def spawn(cls, loc, label=None, scale=None, rot=None):
a = unreal.EditorLevelLibrary.spawn_actor_from_class(cls, loc)
if a is None:
log(f"spawn {cls.__name__} returned None")
return None
if rot is not None:
a.set_actor_rotation(rot, False)
if scale is not None:
a.set_actor_scale3d(scale)
if label:
a.set_actor_label(label)
return a
def spawn_mesh(mesh, loc, label=None, scale=None):
a = unreal.EditorLevelLibrary.spawn_actor_from_object(mesh, loc)
if a is None:
log(f"spawn mesh {label} returned None")
return None
if scale is not None:
a.set_actor_scale3d(scale)
if label:
a.set_actor_label(label)
return a
# --- stage content -----------------------------------------------------------
try:
floor_mesh = unreal.EditorAssetLibrary.load_asset("/Game/Test/ScaleTest/ScaleTest_A")
if floor_mesh:
spawn_mesh(floor_mesh, unreal.Vector(450, 450, -50.0), "ReviewFloor", scale=(45.0, 45.0, 0.1))
log("staged: floor (45 m plane from ScaleTest_A)")
dl = spawn(unreal.DirectionalLight, unreal.Vector(0, 0, 900), "RenderKeyLight",
rot=unreal.Rotator(pitch=-38.0, yaw=35.0, roll=0.0))
if dl:
log("staged: DirectionalLight")
if spawn(unreal.SkyLight, unreal.Vector(0, 0, 1200), "RenderFillSky"):
log("staged: SkyLight")
if spawn(unreal.SkyAtmosphere, unreal.Vector(0, 0, 0), "RenderSky"):
log("staged: SkyAtmosphere")
char_mesh = unreal.EditorAssetLibrary.load_asset("/Game/Test/Character/character")
if char_mesh:
if spawn_mesh(char_mesh, unreal.Vector(0, 0, 0), "ReviewCharacter"):
log("staged: MPFB character at origin")
else:
log("MISSING: /Game/Test/Character/character")
for name, x, y in (("ScaleTest_A", 0.0, 0.0), ("ScaleTest_B", 400.0, 0.0), ("ScaleTest_C", 0.0, 400.0)):
m = unreal.EditorAssetLibrary.load_asset(f"/Game/Test/ScaleTest/{name}")
if m:
spawn_mesh(m, unreal.Vector(x, y, 50.0), f"Cube_{name}")
log(f"staged: {name} at ({x}, {y}, 50)")
except Exception as exc: # noqa
log(f"staging failed: {exc!r}")
# --- render ------------------------------------------------------------------
def render_shot(name, loc, target, fov=70.0):
rt = unreal.RenderingLibrary.create_render_target2d(
WORLD, 1920, 1080, unreal.TextureRenderTargetFormat.RTF_RGBA8_SRGB,
unreal.LinearColor(0.0, 0.0, 0.0, 1.0))
cap = spawn(unreal.SceneCapture2D, loc)
if cap is None:
log(f"{name}: no capture actor")
return
cap.set_actor_rotation(unreal.MathLibrary.find_look_at_rotation(loc, target), False)
comp = cap.get_component_by_class(unreal.SceneCaptureComponent2D)
comp.set_editor_property("texture_target", rt)
comp.set_editor_property("capture_source", unreal.SceneCaptureSource.SCS_FINAL_COLOR_LDR)
comp.set_editor_property("fov_angle", fov)
comp.set_editor_property("capture_every_frame", False)
comp.set_editor_property("capture_on_movement", False)
try:
pps = comp.get_editor_property("post_process_settings")
pps.set_editor_property("auto_exposure_method", unreal.AutomaticExposureMethod.AEM_MANUAL)
pps.set_editor_property("auto_exposure_bias", 1.0)
comp.set_editor_property("post_process_settings", pps)
except Exception as exc: # noqa
log(f"{name}: exposure control skipped ({exc!r})")
comp.capture_scene()
os.makedirs(OUT_DIR, exist_ok=True)
ok = unreal.RenderingLibrary.export_render_target(WORLD, rt, OUT_DIR, name)
out = os.path.join(OUT_DIR, f"{name}.png")
size = os.path.getsize(out) if os.path.exists(out) else 0
log(f"rendered {name}: export ok={ok}, {size} bytes")
unreal.EditorLevelLibrary.destroy_actor(cap)
SHOTS = [
("01-overview", unreal.Vector(900, -1100, 500), unreal.Vector(200, 100, 80), 70.0),
("02-character", unreal.Vector(180, -260, 160), unreal.Vector(0, 0, 85), 55.0),
("03-scale-cubes", unreal.Vector(550, -700, 150), unreal.Vector(200, 200, 50), 60.0),
]
for shot_name, loc, target, fov in SHOTS:
try:
render_shot(shot_name, loc, target, fov)
except Exception as exc: # noqa
log(f"shot {shot_name} failed: {exc!r}")
with open(RESULT, "w") as f:
f.write("\n".join(lines) + "\n")
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env bash
# Render the demo-map screenshots used for visual review into <repo>/Screenshots/demo/.
#
# Usage: Tools/review/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/review/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"
# Tools are grouped by discipline now: Unreal commandlets under Tools/unreal, review helpers under
# Tools/review. The script name alone is no longer enough to find one, so each call names its folder.
run_cmdlet() {
local script="$1" label="$2" folder="${3:-unreal}"
echo "== $label =="
( cd "$ENGINE" && ./Engine/Binaries/Linux/UnrealEditor-Cmd "$PROJ" \
-run=pythonscript -script="$REPO/Tools/$folder/$script" \
-nullrhi -unattended -nopause -stdout )
}
# Order matters. The demo builder rebuilds the site materials, so any actor staged
# by another tool must be (re)staged after it or it keeps a reference to a material
# that no longer exists - that is how 604 actors ended up untextured once.
run_cmdlet ue_build_demo_map.py "1/5 rebuild the demo map (materials, site, frontage)"
run_cmdlet ue_apply_facade_kit.py "2/5 assemble the facade kit"
run_cmdlet ue_stage_theatre_trim.py "3/5 stage the theatre trim"
run_cmdlet ue_audit_scene.py "4/6 audit the scene (materials, placement, labels)"
run_cmdlet ue_build_review_map.py "5/6 stage the demo shot cameras"
tail -3 /tmp/ue-review-map-result.txt 2>/dev/null || true
echo "== 6/6 capture the demo map =="
# Clear the engine's screenshot folder first. The collect step below copies every
# review-*.png it finds in Saved/, so without this a shot retired from the camera list
# keeps coming back as a stale file - that is how a removed walk shot reappeared with
# byte-identical content after a run that never captured it.
rm -rf "$REPO"/Game/GigLine/Saved/Screenshots/* "$HOME"/.config/Epic/GigLine/Saved/Screenshots/* 2>/dev/null || true
# Recreate the folder: the engine writes into it and does not recreate a deleted
# directory, which silently produced zero captures in the run that removed it.
mkdir -p "$REPO/Game/GigLine/Saved/Screenshots/LinuxEditor"
# The engine path is absolute: this step runs from the repo, not from $ENGINE, and a
# bare ./Engine/... failed with "No such file or directory" while every commandlet
# above succeeded through run_cmdlet's cd.
"$ENGINE/Engine/Binaries/Linux/UnrealEditor-Cmd" "$PROJ" /Game/Hollywood/DemoMap \
-game -windowed -ResX=1920 -ResY=1080 -nopause -nosplash -thirdperson -stdout
echo "== collect the demo screenshots =="
mkdir -p "$REPO/Screenshots/demo"
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
case "$(basename "$src")" in
*demo-*) cp -f "$src" "$REPO/Screenshots/demo/"; found=1 ;;
esac
done
[ "$found" = "1" ] || { echo "ERROR: no demo screenshots found"; exit 1; }
ls -1 "$REPO/Screenshots/demo"
echo "== 6b/7 capture the review map (asset staging) =="
# The ReviewMap stages the models on their own - imported assets, camera angles and the
# lighting comparison - which is a different question from "does the game map look right".
# Its shots collect into Screenshots/review/ so the two reviews stay separate.
rm -rf "$REPO"/Game/GigLine/Saved/Screenshots/* 2>/dev/null || true
mkdir -p "$REPO/Game/GigLine/Saved/Screenshots/LinuxEditor"
"$ENGINE/Engine/Binaries/Linux/UnrealEditor-Cmd" "$PROJ" /Game/Test/ReviewMap \
-game -windowed -ResX=1920 -ResY=1080 -nopause -nosplash -stdout
echo "== 6c/7 collect the review-map screenshots =="
mkdir -p "$REPO/Screenshots/review"
rm -f "$REPO"/Screenshots/review/review-*.png
found_review=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
case "$(basename "$src")" in
*demo-*) : ;;
*) cp -f "$src" "$REPO/Screenshots/review/"; found_review=1 ;;
esac
done
[ "$found_review" = "1" ] || echo "WARN: no review-map screenshots found"
ls -1 "$REPO/Screenshots/review" 2>/dev/null | head -20
# The scene audit is a gate: a wrong-material or misplaced actor is exactly the
# kind of failure that still renders plausibly.
if grep -q "failure(s) ==" /tmp/gigline/scene-audit.txt 2>/dev/null; then
grep -E "FAIL|failure\(s\)" /tmp/gigline/scene-audit.txt | tail -5
grep -q "== 0 failure(s) ==" /tmp/gigline/scene-audit.txt || {
echo "ERROR: scene audit failed — see /tmp/gigline/scene-audit.txt"; exit 1; }
fi
if [ "$VERIFY" = "yes" ]; then
echo "== verify: does each shot show what it should? =="
cd "$REPO"
python3 Tools/review/visual_loop.py --verify
fi
+152
View File
@@ -0,0 +1,152 @@
#!/usr/bin/env python3
"""Measure how far an object sits above its own shadow.
python3 Tools/review/shadow_gap.py --image Screenshots/demo/review-demo-spawn-view.png \
--object-box 0.52,0.55,0.60,0.75 --out /tmp/gigline/shadow-gap.json
Why a measurement instead of another opinion: a still cannot distinguish "the object floats"
from "the object stands on a raised kerb and its shadow falls past it onto the road", and a
vision model asked to arbitrate gives a different answer each run. The vertical pixel gap
between the lowest object pixel and the top of its shadow is objective.
Method, deliberately simple and stated so it can be argued with:
1. Crop the region of interest (the object box, fractions of the frame).
2. The object is the set of pixels that differ from the local background - the median colour of
the crop's border.
3. The shadow is the darkest connected region *below* the object.
4. Report the gap in pixels between the object's lowest row and the shadow's top row, and the
object's height in pixels, so the gap can be read as a fraction of the object.
A large gap relative to the object's height is the floating signal; a small gap is contact.
The thresholds are arguments, not constants, because the right values depend on the camera.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from PIL import Image
DEFAULT_GAP_FRACTION = 0.12 # gap > 12 % of the object's pixel height reads as floating
SHADOW_DELTA = 38 # how much darker than the background a pixel must be to be shadow
def border_median(image: Image.Image):
"""Median colour of the crop's border, used as the local background."""
pixels = image.load()
width, height = image.size
samples = []
step = max(1, width // 60)
for x in range(0, width, step):
samples.append(pixels[x, 1])
samples.append(pixels[x, height - 2])
for y in range(0, height, max(1, height // 60)):
samples.append(pixels[1, y])
samples.append(pixels[width - 2, y])
if not samples:
return (128, 128, 128)
channels = list(zip(*samples))
return tuple(sorted(channel)[len(channel) // 2] for channel in channels)
def classify_pixels(image: Image.Image, background, object_delta=18):
"""Return two masks: the object (differs from background) and the shadow (darker)."""
pixels = image.load()
width, height = image.size
object_rows = [0] * height
shadow_rows = [0] * height
for y in range(height):
for x in range(width):
r, g, b = pixels[x, y]
delta = max(abs(r - background[0]), abs(g - background[1]), abs(b - background[2]))
luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b
background_luminance = (0.2126 * background[0] + 0.7152 * background[1]
+ 0.0722 * background[2])
# A shadow is a darkened version of the surface; an object is much darker or a
# different colour. Classifying by absolute delta alone put every shadow in the
# object mask, which is why the first runs reported "no shadow found".
ratio = luminance / max(1.0, background_luminance)
# A shadow darkens every channel by the same proportion; an object does not.
# Comparing the per-channel scale factors is what separates them - an absolute
# delta cannot, because a shadow's delta is as large as a dark object's.
scales = [rgb / max(1.0, base) for rgb, base in zip((r, g, b), background)]
spread = max(scales) - min(scales)
if 0.45 <= ratio < 0.90 and spread < 0.15:
shadow_rows[y] += 1
elif ratio < 0.45 or delta > object_delta:
object_rows[y] += 1
return object_rows, shadow_rows
def measure(image_path: str, box, gap_fraction=DEFAULT_GAP_FRACTION) -> dict:
image = Image.open(image_path).convert("RGB")
width, height = image.size
x0, y0, x1, y1 = (int(box[0] * width), int(box[1] * height),
int(box[2] * width), int(box[3] * height))
crop = image.crop((x0, y0, x1, y1))
background = border_median(crop)
object_rows, shadow_rows = classify_pixels(crop, background)
object_rows_nonzero = [y for y, count in enumerate(object_rows) if count > 4]
if not object_rows_nonzero:
return {"error": "no object found in the box", "background": background}
object_top, object_bottom = object_rows_nonzero[0], object_rows_nonzero[-1]
object_height = max(1, object_bottom - object_top)
# Shadow rows below the object's lowest row.
shadow_below = [y for y, count in enumerate(shadow_rows)
if y > object_bottom and count > 4]
shadow_top = shadow_below[0] if shadow_below else None
gap = None if shadow_top is None else shadow_top - object_bottom
verdict = "unknown"
if gap is not None:
verdict = "floating" if gap > object_height * gap_fraction else "contact"
return {
"image": os.path.abspath(image_path),
"box_pixels": [x0, y0, x1, y1],
"background": list(background),
"object_top_row": object_top,
"object_bottom_row": object_bottom,
"object_height_px": object_height,
"shadow_top_row": shadow_top,
"gap_px": gap,
"gap_fraction_of_object": None if gap is None else round(gap / object_height, 3),
"threshold_fraction": gap_fraction,
"verdict": verdict,
}
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--image", required=True)
ap.add_argument("--object-box", required=True,
help="x0,y0,x1,y1 as fractions of the frame around the object and its shadow")
ap.add_argument("--gap-fraction", type=float, default=DEFAULT_GAP_FRACTION,
help="gap/height above this reads as floating (default 0.12)")
ap.add_argument("--out", default="/tmp/gigline/shadow-gap.json")
args = ap.parse_args(argv)
box = [float(v) for v in args.object_box.split(",")]
if len(box) != 4:
print("error: --object-box needs four comma-separated fractions", file=sys.stderr)
return 2
if not os.path.exists(args.image):
print(f"error: no such image: {args.image}", file=sys.stderr)
return 2
result = measure(args.image, box, args.gap_fraction)
os.makedirs(os.path.dirname(os.path.abspath(args.out)) or ".", exist_ok=True)
with open(args.out, "w") as handle:
json.dump(result, handle, indent=2)
print(json.dumps(result, indent=2))
return 0 if result.get("verdict") != "unknown" else 1
if __name__ == "__main__":
raise SystemExit(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/review/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/review/visual_loop.py --verify # gate the current set
python3 Tools/review/visual_loop.py --compare --iteration 3 # measure vs references
python3 Tools/review/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", "review", "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())
+137
View File
@@ -0,0 +1,137 @@
{
"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": "A daytime street view from the north sidewalk: a light-coloured neoclassical facade with columns across the road, a red car and a walking person on the pavement, road in the foreground Nothing may float: the person's feet and the car's wheels must meet the pavement and their shadows must touch them. A shadow on the ground more than a hand's width below an object means the object is floating."
},
{
"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 looking along the road: a colonnade of columns with a light facade above, pavement and road visible, daylight, no floating geometry"
},
{
"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": "A road surface with white crossing markings, a light-coloured building frontage 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": "Tall light-grey building masses along the street on both sides, seen from road level, daylight, no missing faces or floating parts"
},
{
"name": "demo-retail-frontage",
"shot": "Screenshots/demo/review-demo-retail-frontage.png",
"size": [
1920,
1080
],
"references": [
"Art/reference/retail-neighbours/01-lee-drug-and-neighbor.jpg"
],
"expect": "A row of light shopfront facades with dark openings at street level, seen from across the road, daylight"
},
{
"name": "demo-third-person",
"shot": "Screenshots/demo/review-demo-third-person.png",
"size": [
1920,
1080
],
"references": [
"Art/reference/hollywood/el-capitan-facade-day-wikimedia-2009-10-27.jpg"
],
"expect": "A third-person view of a clothed human character standing on a pavement, seen from behind, a large building facade across the road"
},
{
"name": "demo-third-person-front",
"shot": "Screenshots/demo/review-demo-third-person-front.png",
"size": [
1920,
1080
],
"references": [
"Art/reference/hollywood/el-capitan-facade-day-wikimedia-2009-10-27.jpg"
],
"expect": "A clothed human character standing on a pavement seen from the front, dark navy top and dark trousers, light building behind, daylight"
},
{
"name": "demo-third-person-walk",
"shot": "Screenshots/demo/review-demo-third-person-walk.png",
"size": [
1920,
1080
],
"references": [
"Art/reference/hollywood/el-capitan-facade-day-wikimedia-2009-10-27.jpg"
],
"expect": "A clothed human character walking along a pavement, legs apart in mid-stride, seen from the side, daylight"
},
{
"name": "demo-vehicle",
"shot": "Screenshots/demo/review-demo-vehicle.png",
"size": [
1920,
1080
],
"references": [
"Art/blender/vehicle/hatch-reference.png"
],
"expect": "A red compact hatchback car parked on a pavement beside a person, with dark windows, four dark wheels and a dark bumper, daytime"
},
{
"name": "demo-feet",
"shot": "Screenshots/demo/review-demo-feet.png",
"size": [
1920,
1080
],
"references": [
"Screenshots/blender/character-angle-035.png"
],
"expect": "Low view of a person's feet standing on a pavement, seen along the ground, daylight. The object must touch the surface: feet or wheels in contact, with the shadow starting at that contact point, not on the ground far below."
},
{
"name": "demo-wheel",
"shot": "Screenshots/demo/review-demo-wheel.png",
"size": [
1920,
1080
],
"references": [
"Screenshots/blender/vehicle-angle-035.png"
],
"expect": "Low view of a car's wheels standing on a pavement, seen along the ground, daylight. The object must touch the surface: feet or wheels in contact, with the shadow starting at that contact point, not on the ground far below."
}
]
}