Files
security-game/tools/scene_concepts.py
T
zwitschi 659b0e35b2
Full build / Import + headless tests (push) Successful in 40s
Full build / Full desktop build + optional release (push) Successful in 4m6s
Make the vision judge discover cost-effective OpenRouter models
Add openrouter_judge.py to rank live image+JSON models by advertised price,
with ordered fallbacks, auth-error handling and auto selection. Wire both judges
to --model auto, record the model used, and add fake-catalogue/mocked-call
tests. Re-run the judges with the valid key: 13/13 screen designs match, 0 diff;
the Forward+ A/B report records the selected model and ~$0.006 spend. Headless
231/231, Python 94/94.
2026-11-01 19:55:43 +01:00

1013 lines
44 KiB
Python

#!/usr/bin/env python3
"""Realism pass: AI scene references, skyline backdrops and surface textures.
Generates example 3D-game scene frames (art direction), realistic skyline
panoramas that replace the outdated flat `assets/textures/skyline.png`, and
higher-detail seamless surface tiles; processes them into game-ready
candidates; and scores in-game captures against the realistic target.
**No command ever overwrites an existing image.** When a target path exists the
next free `-vN` name is used (generate) or the step is skipped (process).
python3 tools/scene_concepts.py list
python3 tools/scene_concepts.py generate [--group scene|backdrop|texture]
[--only name,...] [--variants N]
python3 tools/scene_concepts.py process # raw -> game_ready/v2 (+ normals)
python3 tools/scene_concepts.py promote # validated picks -> picks/<name>.png
python3 tools/scene_concepts.py audit # asset_pipeline checks on v2
python3 tools/scene_concepts.py realism --run docs/screens/realism/<run>
[--judge] [--model ...]
Paths:
raw AI output assets/textures/ai/v2/raw/<group>/<name>.png (gitignored)
game-ready candidates assets/textures/ai/game_ready/v2/<name>.png (+ _normal)
scene references docs/screens/concepts/<name>.png
trial captures/reports docs/screens/realism/<run>/{baseline,candidate}/ + report
The API key comes from OPENROUTER_API_KEY or the repo `.env`.
"""
import argparse
import json
import math
import os
import sys
from pathlib import Path
from PIL import Image, ImageFilter, ImageOps, ImageStat
import asset_pipeline
import generate_textures
import openrouter_judge
ROOT = Path(__file__).resolve().parent.parent
RAW_DIR = ROOT / "assets" / "textures" / "ai" / "v2" / "raw"
READY_DIR = ROOT / "assets" / "textures" / "ai" / "game_ready" / "v2"
CONCEPT_DIR = ROOT / "docs" / "screens" / "concepts"
REALISM_DIR = ROOT / "docs" / "screens" / "realism"
DEFAULT_MODEL = "openai/gpt-5-image-mini"
DEFAULT_JUDGE_MODEL = openrouter_judge.DEFAULT_MODEL
OPENROUTER_CHAT_URL = "https://openrouter.ai/api/v1/chat/completions"
SCENE_W, SCENE_H = 1280, 720
# 2:1 matches one UV repeat of the backdrop ring (400 m x 60 m plane, 3 repeats
# = ~133 m x 60 m per tile), so buildings are not stretched vertically.
BACKDROP_W, BACKDROP_H = 2048, 1024
TILE = 1024
SCENE_STYLE = (
"screenshot from a modern realistic 3D video game rendered in a real-time "
"engine, eye-level first-person camera at 1.7 m, physically based materials, "
"believable global illumination and soft shadows, atmospheric haze for depth, "
"high texture detail, no HUD, no text overlays, no watermark, 16:9 widescreen")
BACKDROP_STYLE = (
"wide straight-on orthographic-looking panorama for a game skybox backdrop: "
"the city silhouette sits in a narrow horizontal band across the middle of the "
"image, flat level horizon, no foreground street, no people, no vehicles, "
"fully transparent empty sky above the buildings, the bottom edge is a solid "
"continuous band of low buildings and trees with no gaps, absolutely no "
"lettering, no signs, no Hollywood sign, no text, no watermark")
TEXTURE_STYLE = (
"seamless tileable PBR albedo texture, photographed straight-on and "
"orthographic, flat even diffuse lighting with no cast shadows or highlights, "
"uniform scale across the whole image, no perspective, no vignette, no text")
# name -> (group, prompt). Scene names describe venue + time of day.
CATALOGUE = {
# -- Example 3D game scenes (art direction) ------------------------------
"hollywood_entrance_morning": ("scene", (
"Hollywood Boulevard brick building entrance in early morning sun, short "
"concrete steps up to a dark glazed double door under a bulb-lit marquee, "
"black bollards, a green litter bin, potted shrubs, palm trees and hazy "
"mid-rise buildings down the boulevard")),
"hollywood_entrance_golden_hour": ("scene", (
"the same Hollywood brick venue entrance at golden hour, long warm shadows "
"across worn concrete sidewalk pavers, marquee bulbs starting to glow, a few "
"pedestrians, palm silhouettes against an orange sky")),
"hollywood_entrance_rain_night": ("scene", (
"Hollywood brick venue entrance late at night in light rain, wet asphalt "
"reflecting the marquee and sodium street lamps, puddles on the sidewalk, "
"lit windows in the distant skyline, a lone security guard post by the door")),
"boulevard_skyline_day": ("scene", (
"view from a Hollywood Boulevard sidewalk across a four-lane street toward a "
"layered Los Angeles skyline of mid-rise and high-rise buildings, palm trees "
"lining the street, the Hollywood hills hazy on the horizon, clear blue sky")),
"boulevard_skyline_dusk": ("scene", (
"Hollywood Boulevard at dusk looking down the street, street lamps switching "
"on, purple and orange sky, distant skyline windows lighting up, car light "
"trails, palm trees in silhouette")),
"joke_shop_alley_evening": ("scene", (
"a narrow brick side street outside a small comedy club in the evening, a red "
"scalloped awning over a wooden door, a warm wall lantern, a chalkboard "
"A-frame sign on the sidewalk, a short queue of people in jackets")),
"joke_shop_door_closeup": ("scene", (
"close view of a comedy club door: aged dark-red brick with mortar detail, a "
"brass door handle, a glowing wall lantern, a chalkboard sign, evening light")),
"studio_stage_door_night": ("scene", (
"a TV studio stage door at night: granite facade, a large bulb-lit marquee "
"canopy, a black sedan at the curb, chrome stanchions with a red velvet rope, "
"fans with phones behind a steel crowd barrier")),
"sidewalk_material_closeup": ("scene", (
"low camera close-up along a city sidewalk: weathered concrete pavers with "
"expansion joints and stains, a granite curb, asphalt with aggregate and lane "
"paint, a cast-iron tree grate, morning sun")),
"entrance_steps_podium": ("scene", (
"a security check-in podium at the foot of three granite entrance steps with "
"black metal handrails, glass double doors with a card reader, a bulb marquee "
"above, daylight")),
"crowd_barrier_line": ("scene", (
"a row of steel bike-rack crowd barriers along a Hollywood sidewalk before a "
"premiere, people waiting behind them, a red carpet, palm trees and a hazy "
"skyline, late afternoon")),
"npc_portrait_day": ("scene", (
"eye-level close portrait of an ordinary adult pedestrian standing on a "
"sunlit Hollywood sidewalk, upper body and face filling the frame, plain "
"casual jacket, natural skin detail, shallow depth of field, blurred brick "
"facade and palm trees behind")),
"responder_arrival_wide": ("scene", (
"wide shot of a fire engine and firefighters in dark turnout gear arriving at "
"a brick venue entrance at night, red and blue light reflections on wet "
"pavement, onlookers kept back by a guard")),
# -- Skyline backdrops (replace assets/textures/skyline.png) -------------
"skyline_day": ("backdrop", (
"Los Angeles Hollywood skyline in daylight: varied mid-rise and high-rise "
"buildings with realistic facades and windows, a few palm tree silhouettes, "
"soft blue atmospheric haze, low green-brown hills far behind")),
"skyline_dusk": ("backdrop", (
"Los Angeles Hollywood skyline just after sunset: neutral grey-blue building "
"facades, scattered warm lit windows, palm silhouettes, deep violet-blue sky "
"haze, low hills far behind, no red or orange floodlighting, no neon signs")),
"skyline_night": ("backdrop", (
"Los Angeles Hollywood skyline at night: dark building masses with many warm "
"and cool lit windows, rooftop red aviation lights, palm silhouettes, faint "
"city glow on the haze")),
# -- Surface tiles (replace / extend the game_ready textures) ------------
"brick_facade": ("texture", (
"aged red-brown clay brick wall in running bond, recessed grey mortar joints, "
"slight colour variation between bricks, small chips and soot, 12 courses")),
"granite_facade": ("texture", (
"polished grey speckled granite wall cladding in large rectangular panels "
"with thin dark joints")),
"sidewalk_pavers": ("texture", (
"weathered light-grey city sidewalk concrete in 1.2 m square slabs with saw-cut "
"joints, faint stains, fine aggregate and hairline cracks, top-down")),
"asphalt": ("texture", (
"dry dark-grey road asphalt with visible aggregate stones, slight wear, "
"top-down, no lane markings")),
"asphalt_wet": ("texture", (
"wet dark road asphalt at night with small damp patches and glossy aggregate, "
"top-down, no lane markings, no reflections of objects")),
"stone_steps": ("texture", (
"light grey limestone step surface with fine pitting and worn edges, top-down")),
"awning_canvas": ("texture", (
"outdoor awning canvas with broad vertical burgundy and cream stripes, "
"visible woven fabric weave, slight weathering")),
"stucco_wall": ("texture", (
"warm beige exterior stucco wall with fine sand-float texture and subtle "
"weathering")),
}
# Validated picks promoted to stable paths by `promote`. The name on the left is
# what the game loads (`assets/textures/ai/game_ready/v2/picks/<name>.png`); the
# value is the candidate in READY_DIR it came from. Promoted files are tracked
# and never overwritten: a re-promote writes the next free `-vN` name.
PICKS = {
"skyline_day": "skyline_day-v4",
"skyline_dusk": "skyline_dusk-v5",
"skyline_night": "skyline_night-v4",
"brick_facade": "brick_facade",
"sidewalk_pavers": "sidewalk_pavers",
"asphalt": "asphalt",
"asphalt_wet": "asphalt_wet",
"awning_canvas": "awning_canvas-v3",
}
# Realism trial: capture shot -> concept it is judged against.
SHOTS = {
"entrance_day": "hollywood_entrance_morning",
"boulevard_day": "boulevard_skyline_day",
"street_day": "boulevard_skyline_day",
"boulevard_night": "hollywood_entrance_rain_night",
"street_night": "boulevard_skyline_dusk",
"entrance_late": "hollywood_entrance_rain_night",
"street_late": "boulevard_skyline_dusk",
# Each venue is judged on its own frame; the real geometry no longer shares
# the Hollywood layout.
"joke_shop_door": "joke_shop_door_closeup",
"12-joke-shop": "joke_shop_alley_evening",
"studio_door": "studio_stage_door_night",
"studio_stage_door_night": "studio_stage_door_night",
"13-studio-arrival": "studio_stage_door_night",
"npc_portrait": "npc_portrait_day",
}
# ---------------------------------------------------------------------------
# Paths (never overwrite)
# ---------------------------------------------------------------------------
def next_free_path(path: Path) -> Path:
"""Return `path` if unused, else the first unused `<stem>-vN<suffix>`."""
path = Path(path)
if not path.exists():
return path
index = 2
while True:
candidate = path.with_name(f"{path.stem}-v{index}{path.suffix}")
if not candidate.exists():
return candidate
index += 1
def raw_path(name: str) -> Path:
group = CATALOGUE[name][0]
if group == "scene":
return CONCEPT_DIR / f"{name}.png"
return RAW_DIR / group / f"{name}.png"
def raw_variants(name: str):
"""All raw files generated for `name` (base + -vN), oldest first."""
base = raw_path(name)
found = [base] if base.is_file() else []
found += sorted(base.parent.glob(f"{base.stem}-v*{base.suffix}"),
key=lambda p: int(p.stem.rsplit("-v", 1)[1])
if p.stem.rsplit("-v", 1)[1].isdigit() else 0)
return found
def _pixels(image: Image.Image) -> list:
"""Flat pixel list without the Pillow 12 `getdata` deprecation warning."""
getter = getattr(image, "get_flattened_data", None)
return list(getter() if getter else image.getdata())
def _rel(path: Path) -> str:
path = Path(path)
return str(path.relative_to(ROOT)) if path.is_relative_to(ROOT) else str(path)
def _load_env_file(path=None):
path = Path(path) if path else ROOT / ".env"
if not path.is_file():
return
for raw in path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
os.environ.setdefault(key.strip(), value.strip().strip("\"'"))
# ---------------------------------------------------------------------------
# Generate
# ---------------------------------------------------------------------------
def compose_prompt(name: str) -> str:
group, prompt = CATALOGUE[name]
style = {"scene": SCENE_STYLE, "backdrop": BACKDROP_STYLE,
"texture": TEXTURE_STYLE}[group]
return f"{prompt}. {style}."
def generate_one(name: str, model: str, request_size: int) -> Path:
group = CATALOGUE[name][0]
prompt = compose_prompt(name)
if group == "scene":
image = generate_textures.generate_image(prompt, model, request_size,
transparent=False, aspect_ratio="3:2")
image = ImageOps.fit(image.convert("RGB"), (SCENE_W, SCENE_H),
method=Image.Resampling.LANCZOS)
elif group == "backdrop":
image = generate_textures.generate_image(prompt, model, request_size,
transparent=True, aspect_ratio="3:2")
else:
image = generate_textures.generate_image(prompt, model, request_size,
transparent=False, aspect_ratio="1:1")
image = image.convert("RGB")
out = next_free_path(raw_path(name))
out.parent.mkdir(parents=True, exist_ok=True)
image.save(out)
print(f"wrote {_rel(out)} {image.size}")
return out
def cmd_generate(args) -> int:
_load_env_file()
names = _select(args.group, args.only)
if not names:
print("nothing selected", file=sys.stderr)
return 2
failed = []
for name in names:
for _ in range(max(1, args.variants)):
print(f"generating {name} ...", flush=True)
try:
generate_one(name, args.model, args.request_size)
except SystemExit as exc: # generate_textures exits on API errors
failed.append(name)
print(f" FAILED {name}: {exc}", file=sys.stderr)
break
if failed:
print("failed: " + ", ".join(failed), file=sys.stderr)
return 1
return 0
def _select(group, only):
wanted = {n.strip() for n in (only or "").split(",") if n.strip()}
return [n for n, (g, _) in CATALOGUE.items()
if (not group or g == group) and (not wanted or n in wanted)]
# ---------------------------------------------------------------------------
# Process (raw -> game-ready candidates)
# ---------------------------------------------------------------------------
def key_sky(image: Image.Image, threshold: float = 34.0) -> Image.Image:
"""Make the sky transparent when the model returned an opaque image.
Walks every column from the top while pixels stay close to a running sky
colour (follows vertical gradients); everything above the first building
pixel becomes transparent.
"""
rgba = image.convert("RGBA")
if asset_pipeline.has_transparency(rgba):
return rgba
width, height = rgba.size
px = rgba.load()
for x in range(width):
seed = [px[x, y] for y in range(min(6, height))]
sky = [sum(c[i] for c in seed) / len(seed) for i in range(3)]
for y in range(height):
r, g, b, _ = px[x, y]
dist = math.sqrt((r - sky[0]) ** 2 + (g - sky[1]) ** 2 + (b - sky[2]) ** 2)
if dist > threshold:
break
sky = [sky[0] * 0.9 + r * 0.1, sky[1] * 0.9 + g * 0.1, sky[2] * 0.9 + b * 0.1]
px[x, y] = (r, g, b, 0)
return rgba
def _band(image: Image.Image, aspect: float = 2.0) -> Image.Image:
"""Crop to the skyline's alpha bounds, bottom-aligned on an `aspect` canvas.
Tower tops are never cropped: when the content is shorter than the
target aspect, transparent sky is added above it.
"""
width, height = image.size
bbox = image.getchannel("A").point(lambda a: 255 if a >= 128 else 0).getbbox()
top, bottom = (bbox[1], bbox[3]) if bbox else (0, height)
content = image.crop((0, top, width, bottom))
canvas_h = max(content.size[1], int(round(width / aspect)))
canvas = Image.new("RGBA", (width, canvas_h), (0, 0, 0, 0))
canvas.paste(content, (0, canvas_h - content.size[1]))
return canvas
def tile_horizontally(image: Image.Image, band: int) -> Image.Image:
"""Crossfade the right edge into the left so the strip repeats seamlessly."""
width, height = image.size
band = max(1, min(band, width // 4))
out = image.crop((0, 0, width - band, height)).copy()
left = image.crop((0, 0, band, height))
right = image.crop((width - band, 0, width, height))
mask = Image.linear_gradient("L").rotate(90, expand=True).resize((band, height))
# mask: 255 at x=0 (take the right edge), fading to 0 (take the left edge)
mixed = Image.composite(right, left, ImageOps.mirror(mask))
out.paste(mixed, (0, 0))
return out
def process_backdrop(source: Path, target: Path) -> Path:
with Image.open(source) as image:
image.load()
rgba = key_sky(image)
strip = _band(rgba)
strip = tile_horizontally(strip, band=max(16, strip.size[0] // 10))
strip = strip.resize((BACKDROP_W, BACKDROP_H), Image.Resampling.LANCZOS)
# Alpha-scissor material: hard alpha, and a solid base so no sky shows
# through gaps at the street line.
alpha = strip.getchannel("A").point(lambda a: 255 if a >= 128 else 0)
base = int(BACKDROP_H * 0.9)
alpha.paste(255, (0, base, BACKDROP_W, BACKDROP_H))
strip.putalpha(alpha)
target.parent.mkdir(parents=True, exist_ok=True)
strip.save(target)
return target
def normal_from_height(image: Image.Image, strength: float = 2.0) -> Image.Image:
"""Tangent-space normal map from luminance (wraps for tiling)."""
gray = image.convert("L")
width, height = gray.size
px = _pixels(gray)
out = bytearray(width * height * 3)
for y in range(height):
up = ((y - 1) % height) * width
down = ((y + 1) % height) * width
row = y * width
for x in range(width):
dx = (px[row + (x + 1) % width] - px[row + (x - 1) % width]) / 255.0
dy = (px[down + x] - px[up + x]) / 255.0
nx, ny, nz = -dx * strength, -dy * strength, 1.0
inv = 1.0 / math.sqrt(nx * nx + ny * ny + 1.0)
o = (row + x) * 3
out[o] = int((nx * inv * 0.5 + 0.5) * 255)
out[o + 1] = int((ny * inv * 0.5 + 0.5) * 255)
out[o + 2] = int((nz * inv * 0.5 + 0.5) * 255)
return Image.frombytes("RGB", (width, height), bytes(out))
def stripe_period(image: Image.Image, minimum: int = 8) -> int:
"""Column period of a striped texture (0 when no clear period is found)."""
gray = image.convert("L")
width, height = gray.size
profile = [sum(_pixels(gray.crop((x, 0, x + 1, height)))) / height
for x in range(width)]
# Detrend with a moving average: a slow lighting gradient otherwise
# correlates strongly at short periods and wins over the real stripes.
window = max(8, width // 16)
smoothed = [sum(profile[(x + i) % width] for i in range(-window, window + 1))
/ (2 * window + 1) for x in range(width)]
centred = [profile[x] - smoothed[x] for x in range(width)]
def correlation(period: int) -> float:
return sum(centred[x] * centred[(x + period) % width]
for x in range(width)) / width
scores = {period: correlation(period) for period in range(minimum, width // 2)}
if not scores:
return 0
period = max(scores, key=scores.get)
# A real pattern repeats about as strongly as it correlates with itself.
return period if scores[period] > 0.35 * correlation(0) else 0
def align_stripes(image: Image.Image) -> Image.Image:
"""Resample a striped texture to a whole number of periods so it tiles.
The edge crossfade in `make_seamless` smears broad stripes. Here the period
is measured, then the tile is wrapped and rescaled so exactly `n` periods
fit the width: seamless by construction, with a <1 % scale change.
"""
period = stripe_period(image)
if period <= 0:
return image
width, height = image.size
periods = max(1, round(width / period))
target_width = int(round(period * periods))
columns = max(1, -(-target_width // width)) # ceil
strip = Image.new("RGB", (width * columns, height))
for index in range(columns):
strip.paste(image, (index * width, 0))
strip = strip.crop((0, 0, target_width, height))
return strip.resize((width, height), Image.Resampling.LANCZOS)
STRIPED = {"awning_canvas"}
def process_texture(source: Path, target: Path, size: int = TILE, name: str = "") -> Path:
with Image.open(source) as image:
image.load()
tile = ImageOps.fit(image.convert("RGB"), (size, size),
method=Image.Resampling.LANCZOS)
if name in STRIPED:
aligned = align_stripes(tile)
tile = aligned.resize((size, size), Image.Resampling.LANCZOS)
else:
tile = asset_pipeline.make_seamless(tile, band=max(16, size // 16))
target.parent.mkdir(parents=True, exist_ok=True)
tile.save(target)
normal_target = target.with_name(target.stem + "_normal.png")
if not normal_target.exists():
normal_from_height(tile.filter(ImageFilter.GaussianBlur(1.0))).save(normal_target)
return target
def cmd_process(args) -> int:
written = 0
for name in _select(args.group, args.only):
group = CATALOGUE[name][0]
if group == "scene":
continue
for index, source in enumerate(raw_variants(name), start=1):
suffix = "" if index == 1 else f"-v{index}"
target = READY_DIR / f"{name}{suffix}.png"
if args.new:
target = next_free_path(target)
elif target.exists():
print(f"skip {_rel(target)} (exists)")
continue
if group == "backdrop":
process_backdrop(source, target)
else:
process_texture(source, target, name=name)
written += 1
print(f"wrote {_rel(target)}")
print(f"processed {written} new candidate(s)")
return 0
def audit_backdrop(path: Path, seam_limit: float = 24.0) -> dict:
"""Backdrop rules: 2:1 size, real transparency, opaque base, tiling edge."""
errors = []
with Image.open(path) as image:
image.load()
rgba = image.convert("RGBA")
width, height = rgba.size
if (width, height) != (BACKDROP_W, BACKDROP_H):
errors.append(f"size {width}x{height}, expected {BACKDROP_W}x{BACKDROP_H}")
alpha = rgba.getchannel("A")
if alpha.getextrema()[0] == 255:
errors.append("no transparent sky")
if min(_pixels(alpha.crop((0, height - 2, width, height)))) < 255:
errors.append("bottom edge has holes")
# Compare what the alpha-scissor material shows: transparent pixels carry
# arbitrary RGB, so flatten onto grey first.
flat = Image.alpha_composite(Image.new("RGBA", rgba.size, (128, 128, 128, 255)), rgba)
left = flat.crop((0, 0, 1, height)).convert("RGB")
right = flat.crop((width - 1, 0, width, height)).convert("RGB")
diffs = [sum(abs(a - b) for a, b in zip(lp, rp)) / 3.0
for lp, rp in zip(_pixels(left), _pixels(right))]
seam = sum(diffs) / len(diffs)
if seam > seam_limit:
errors.append(f"horizontal seam {seam:.1f} > {seam_limit}")
return {"path": str(path), "size": [width, height], "seam": round(seam, 1),
"quality": "bad" if errors else "good", "errors": errors}
def _column_profile(image: Image.Image) -> list:
"""Mean RGB of every column, as one flattened row (fast, all edges kept)."""
width, height = image.size
column = image.convert("RGB").resize((width, 1), Image.Resampling.BOX)
return _pixels(column)
def _step(a, b) -> float:
return sum(abs(x - y) for x, y in zip(a[:3], b[:3])) / 3.0
def audit_striped_tile(path: Path, seam_limit: float = 18.0) -> dict:
"""Tile audit for broad stripes: compare the wrap step with a normal step.
A hard stripe boundary can sit exactly on the tile edge, which makes the
plain edge-difference check fail even though the tile wraps perfectly.
"""
with Image.open(path) as image:
image.load()
rgb = image.convert("RGB")
width, height = rgb.size
profile = _column_profile(rgb)
wrap = _step(profile[-1], profile[0])
steps = [_step(profile[x], profile[x + 1]) for x in range(width - 1)]
typical = sum(steps) / len(steps)
# Stripes are hard-edged, so a hard step at the edge is expected. What
# matters is that the wrap is no harsher than the harshest step inside the
# tile: that means the pattern continues instead of jumping.
hardest = max(steps)
errors = []
if (width, height) != (TILE, TILE):
errors.append(f"size {width}x{height}, expected {TILE}x{TILE}")
if wrap > 1.25 * hardest + seam_limit * 0.25:
errors.append(f"tile edge step {wrap:.1f} exceeds the harshest interior step "
f"{hardest:.1f}")
return {"path": str(path), "size": [width, height], "quality": "bad" if errors else "good",
"errors": errors, "wrap_step": round(wrap, 1), "typical_step": round(typical, 1),
"hardest_step": round(hardest, 1), "stripe_period": stripe_period(rgb)}
def cmd_audit(args) -> int:
bad = 0
for path in sorted(READY_DIR.glob("*.png")):
if path.stem.endswith("_normal"):
role = "texture"
elif path.stem.startswith("skyline"):
role = "backdrop"
result = audit_backdrop(path)
bad += result["quality"] == "bad"
print(f"{result['quality'].upper():4} {role:8} {_rel(path)} {result['size']}")
for error in result["errors"]:
print(" -", error)
continue
else:
role = "tile"
if role == "tile" and path.stem.split("-v")[0] in STRIPED:
result = audit_striped_tile(path)
else:
result = asset_pipeline.audit_image(path, role=role)
flag = result["quality"].upper()
bad += result["quality"] == "bad"
print(f"{flag:4} {role:8} {_rel(path)} {result['size']}")
for error in result["errors"]:
print(" -", error)
return 1 if bad else 0
PICKS_DIR = READY_DIR / "picks"
def promote(name: str, source_name: str) -> Path:
"""Copy a validated candidate to the stable tracked path `picks/<name>.png`.
Never overwrites: an existing pick is left alone (the promotion is a no-op
with a warning) and a renamed pick gets the next free `-vN` path.
"""
source = READY_DIR / f"{source_name}.png"
if not source.is_file():
raise FileNotFoundError(source)
target = PICKS_DIR / f"{name}.png"
if target.is_file():
print(f"keep {_rel(target)} (already promoted; remove it to re-promote)")
return target
PICKS_DIR.mkdir(parents=True, exist_ok=True)
with Image.open(source) as image:
image.load()
image.save(target)
source_normal = READY_DIR / f"{source_name}_normal.png"
target_normal = PICKS_DIR / f"{name}_normal.png"
if source_normal.is_file() and not target_normal.exists():
with Image.open(source_normal) as image:
image.load()
image.save(target_normal)
print(f"promoted {_rel(source)} -> {_rel(target)}")
return target
def cmd_promote(args) -> int:
picks = dict(PICKS)
for override in (args.pick or "").split(","):
if "=" in override:
key, value = override.split("=", 1)
picks[key.strip()] = value.strip()
if not PICKS_DIR.joinpath("..").exists():
pass
failures = 0
for name, source in picks.items():
try:
promote(name, source)
except FileNotFoundError:
print(f"missing candidate for {name}: {source}", file=sys.stderr)
failures += 1
return 1 if failures else 0
# ---------------------------------------------------------------------------
# Realism report
# ---------------------------------------------------------------------------
def image_metrics(path: Path) -> dict:
"""Coarse detail/realism signals (Pillow only)."""
with Image.open(path) as image:
image.load()
rgb = image.convert("RGB").resize((640, 360), Image.Resampling.LANCZOS)
gray = ImageOps.grayscale(rgb)
edges = ImageStat.Stat(gray.filter(ImageFilter.FIND_EDGES)).mean[0]
# High-frequency texture: difference between the frame and a blurred copy.
blur = gray.filter(ImageFilter.GaussianBlur(2))
detail = ImageStat.Stat(Image.eval(
Image.blend(gray, ImageOps.invert(blur), 0.5), lambda v: abs(v - 128) * 2)).mean[0]
hsv = rgb.convert("HSV")
sat = ImageStat.Stat(hsv.getchannel("S"))
return {
"edge_density": round(edges, 2),
"fine_detail": round(detail, 2),
"contrast": round(ImageStat.Stat(gray).stddev[0], 2),
"saturation_mean": round(sat.mean[0], 2),
"colour_spread": round(sum(ImageStat.Stat(rgb).stddev) / 3.0, 2),
}
def metric_gap(capture: dict, target: dict) -> float:
"""Mean relative distance of the capture's metrics from the target (0 = same)."""
keys = ("edge_density", "fine_detail", "contrast", "colour_spread")
gaps = [abs(capture[k] - target[k]) / max(target[k], 1e-6) for k in keys]
return round(sum(gaps) / len(gaps), 3)
JUDGE_INSTRUCTIONS = (
"You are an art director for a realistic 3D game. Image 1 is the TARGET look "
"(an AI concept frame). Image 2 is an in-game CAPTURE of the same kind of "
"scene. Ignore HUD text and exact layout. Score how realistic the CAPTURE "
"looks compared with the target, each 0-10: lighting, materials, "
"texture_detail, backdrop (sky, skyline and distance haze), characters, overall. "
"Then list up to five concrete, actionable fixes ranked by impact. Reply ONLY "
"with JSON: {\"scores\": {\"lighting\": n, \"materials\": n, \"texture_detail\": n, "
"\"backdrop\": n, \"characters\": n, \"overall\": n}, \"fixes\": [\"...\"]}")
def _data_url(path: Path) -> str:
import base64
return "data:image/png;base64," + base64.b64encode(Path(path).read_bytes()).decode("ascii")
def build_realism_request(model: str, target: Path, capture: Path) -> dict:
return {
"model": model,
"messages": [{"role": "user", "content": [
{"type": "text", "text": JUDGE_INSTRUCTIONS},
{"type": "image_url", "image_url": {"url": _data_url(target)}},
{"type": "image_url", "image_url": {"url": _data_url(capture)}},
]}],
"temperature": 0,
"response_format": {"type": "json_object"},
}
def parse_realism_response(payload: dict) -> dict:
try:
text = payload["choices"][0]["message"]["content"]
except (KeyError, IndexError, TypeError):
return {"scores": {}, "fixes": ["unreadable model response"]}
if isinstance(text, list):
text = "".join(p.get("text", "") for p in text if isinstance(p, dict))
text = str(text)
start, end = text.find("{"), text.rfind("}")
try:
data = json.loads(text[start:end + 1]) if start >= 0 else {}
except ValueError:
return {"scores": {}, "fixes": [f"non-JSON response: {text[:120]}"]}
scores = {}
for key, value in (data.get("scores") or {}).items():
try:
scores[str(key)] = max(0.0, min(10.0, float(value)))
except (TypeError, ValueError):
continue
fixes = data.get("fixes") or []
if not isinstance(fixes, list):
fixes = [str(fixes)]
return {"scores": scores, "fixes": [str(f) for f in fixes][:5]}
def _call_openrouter(body: dict, timeout: int = 180) -> dict:
import urllib.error
import urllib.request
key = os.environ.get("OPENROUTER_API_KEY", "").strip()
if not key:
raise ValueError("OPENROUTER_API_KEY is not set")
req = urllib.request.Request(
OPENROUTER_CHAT_URL, data=json.dumps(body).encode("utf-8"),
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json",
"HTTP-Referer": "https://git.allucanget.biz/zwitschi/security-game",
"X-Title": "Gig Line"}, method="POST")
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
raise ValueError(f"OpenRouter HTTP {exc.code}: {exc.read().decode('utf-8', 'replace')[:300]}")
def judge_capture(models, target: Path, capture: Path, attempts: int = 2,
repeats: int = 1) -> dict:
"""Vision-judge one capture; retry transient errors, average repeat scores.
`models` is a fallback-ordered list from `openrouter_judge.resolve_models`.
A single score from a small vision model swings by ±1-2, so `repeats` runs
are averaged and the spread is kept (`score_range`) for the report.
"""
if isinstance(models, str):
models = [models]
runs = []
error = None
used = set()
for _ in range(max(1, repeats)):
try:
model, payload = openrouter_judge.call_with_fallback(
models, lambda selected: build_realism_request(selected, target, capture),
_call_openrouter, attempts=attempts)
used.add(model)
runs.append(parse_realism_response(payload))
except (ValueError, OSError) as exc:
if openrouter_judge.is_auth_error(exc):
raise
error = exc
if not runs:
return {"scores": {}, "fixes": [f"judge failed: {error}"], "model": ""}
keys = {key for run in runs for key in run["scores"]}
scores = {}
spread = {}
for key in keys:
values = [run["scores"][key] for run in runs if key in run["scores"]]
scores[key] = round(sum(values) / len(values), 2)
spread[key] = [min(values), max(values)]
fixes = []
for run in runs:
for fix in run["fixes"]:
if fix not in fixes:
fixes.append(fix)
return {"scores": scores, "score_range": spread, "runs": len(runs),
"model": ", ".join(sorted(used)), "fixes": fixes[:5]}
def cmd_realism(args) -> int:
run = Path(args.run)
run = run if run.is_absolute() else ROOT / run
sets = [d for d in sorted(run.iterdir()) if d.is_dir()] if run.is_dir() else []
if not sets:
print(f"no capture sets under {_rel(run)}", file=sys.stderr)
return 2
judge_models = []
if args.judge:
_load_env_file()
judge_models = openrouter_judge.resolve_models(args.model)
print(f"judge models: {', '.join(judge_models)}", file=sys.stderr)
report = {"run": _rel(run),
"judge_model": (judge_models[0] if judge_models else None),
"judge_fallbacks": judge_models, "sets": {}}
for capture_set in sets:
rows = {}
for capture in sorted(capture_set.glob("*.png")):
concept_name = SHOTS.get(capture.stem)
target = CONCEPT_DIR / f"{concept_name}.png" if concept_name else None
if target is None or not target.is_file():
continue
cap_m, tgt_m = image_metrics(capture), image_metrics(target)
row = {"target": _rel(target), "metrics": cap_m, "target_metrics": tgt_m,
"metric_gap": metric_gap(cap_m, tgt_m)}
if args.judge:
try:
row["judge"] = judge_capture(judge_models, target, capture,
repeats=args.repeats)
except ValueError as exc:
if openrouter_judge.is_auth_error(exc):
print(f"error: OpenRouter authentication failed: {exc}",
file=sys.stderr)
return 2
raise
rows[capture.stem] = row
report["sets"][capture_set.name] = rows
json_path = next_free_path(run / "report.json")
json_path.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
md_path = next_free_path(run / "report.md")
md_path.write_text(render_markdown(report), encoding="utf-8")
sheet = write_sheet(run, sets)
print(render_markdown(report))
print(f"wrote {_rel(json_path)}, {_rel(md_path)}" + (f" and {_rel(sheet)}" if sheet else ""))
return 0
def write_sheet(run: Path, sets) -> Path:
"""Committed JPEG contact sheet: one row per shot, target + one column per set."""
from PIL import ImageDraw
shots = sorted({p.stem for d in sets for p in d.glob("*.png") if p.stem in SHOTS})
if not shots:
return None
cw, ch = 480, 270
cols = 1 + len(sets)
sheet = Image.new("RGB", (cw * cols, (ch + 20) * len(shots) + 20), (22, 24, 28))
draw = ImageDraw.Draw(sheet)
for c, label in enumerate(["target"] + [d.name for d in sets]):
draw.text((c * cw + 6, 4), label, fill=(235, 235, 235))
for r, shot in enumerate(shots):
y = 20 + r * (ch + 20)
tiles = [CONCEPT_DIR / f"{SHOTS[shot]}.png"] + [d / f"{shot}.png" for d in sets]
for c, path in enumerate(tiles):
if path.is_file():
with Image.open(path) as image:
sheet.paste(image.convert("RGB").resize((cw, ch), Image.Resampling.LANCZOS),
(c * cw, y))
draw.text((6, y + ch + 3), f"{shot} (target: {SHOTS[shot]})", fill=(200, 200, 200))
out = next_free_path(run / "sheet.jpg")
sheet.save(out, quality=85, optimize=True)
return out
SCORE_KEYS = ("overall", "lighting", "materials", "texture_detail", "backdrop", "characters")
def summarize(report: dict) -> dict:
"""Mean metric gap and judge scores per capture set.
Also reports the mean over shots every set has (`*_common`), because a set
that captured fewer shots otherwise compares different material.
"""
sets = {name: rows for name, rows in report.get("sets", {}).items() if rows}
common = set.intersection(*[set(rows) for rows in sets.values()]) if sets else set()
out = {}
for set_name, rows in sets.items():
subset = {k: v for k, v in rows.items() if k in common}
def mean_gap(items):
return round(sum(r["metric_gap"] for r in items.values()) / max(1, len(items)), 3)
def mean_scores(items):
means = {}
for key in SCORE_KEYS:
values = [r["judge"]["scores"][key] for r in items.values()
if key in r.get("judge", {}).get("scores", {})]
if values:
means[key] = round(sum(values) / len(values), 2)
return means
row = {"metric_gap": mean_gap(rows)}
row.update(mean_scores(rows))
row["shots"] = len(rows)
if subset and len(subset) != len(rows):
row["metric_gap_common"] = mean_gap(subset)
for key, value in mean_scores(subset).items():
row[key + "_common"] = value
row["common_shots"] = len(subset)
out[set_name] = row
return out
def render_markdown(report: dict) -> str:
lines = [f"# Realism report: `{report['run']}`", ""]
if report.get("judge_model"):
lines.append(f"Vision judge: `{report['judge_model']}` (scores 0-10, higher is "
"more realistic compared with the concept target).")
lines.append("")
lines.append("| set | shot | target | gap ↓ | edges | detail | contrast | overall | "
"lighting | materials | texture | backdrop |")
lines.append("| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |")
for set_name, rows in report["sets"].items():
for shot, row in rows.items():
s = row.get("judge", {}).get("scores", {})
m = row["metrics"]
lines.append(
f"| {set_name} | {shot} | {Path(row['target']).stem} | {row['metric_gap']} | "
f"{m['edge_density']} | {m['fine_detail']} | {m['contrast']} | "
f"{s.get('overall', '-')} | {s.get('lighting', '-')} | "
f"{s.get('materials', '-')} | {s.get('texture_detail', '-')} | "
f"{s.get('backdrop', '-')} |")
summary = summarize(report)
if summary:
lines += ["", "Mean scores per set (all captured shots):", "",
"| set | shots | gap ↓ | " + " | ".join(SCORE_KEYS) + " |",
"| --- | --- | --- | " + " | ".join("---" for _ in SCORE_KEYS) + " |"]
for set_name, row in summary.items():
lines.append(f"| {set_name} | {row['shots']} | {row['metric_gap']} | " +
" | ".join(str(row.get(k, "-")) for k in SCORE_KEYS) + " |")
shared = [row.get("common_shots") for row in summary.values()]
if any(count for count in shared):
lines += ["", "Mean over the shots every set captured (like for like):", "",
"| set | shots | gap ↓ | " + " | ".join(SCORE_KEYS) + " |",
"| --- | --- | --- | " + " | ".join("---" for _ in SCORE_KEYS) + " |"]
for set_name, row in summary.items():
if "common_shots" not in row:
continue
lines.append(
f"| {set_name} | {row['common_shots']} | {row['metric_gap_common']} | " +
" | ".join(str(row.get(k + "_common", "-")) for k in SCORE_KEYS) + " |")
for set_name, rows in report["sets"].items():
for shot, row in rows.items():
fixes = row.get("judge", {}).get("fixes", [])
if fixes:
lines += ["", f"**{set_name} / {shot}** fixes:"]
lines += [f"- {fix}" for fix in fixes]
return "\n".join(lines) + "\n"
# ---------------------------------------------------------------------------
def cmd_list(_args) -> int:
for name, (group, _) in CATALOGUE.items():
have = len(raw_variants(name))
print(f"{group:8} {name:32} raw={have}")
return 0
def build_parser():
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
sub = parser.add_subparsers(dest="cmd", required=True)
sub.add_parser("list").set_defaults(handler=cmd_list)
gen = sub.add_parser("generate")
gen.add_argument("--group", choices=("scene", "backdrop", "texture"))
gen.add_argument("--only", default="")
gen.add_argument("--variants", type=int, default=1)
gen.add_argument("--model", default=DEFAULT_MODEL)
gen.add_argument("--request-size", type=int, default=1536)
gen.set_defaults(handler=cmd_generate)
proc = sub.add_parser("process")
proc.add_argument("--group", choices=("backdrop", "texture"))
proc.add_argument("--only", default="")
proc.add_argument("--new", action="store_true",
help="write new variants even when candidates exist")
proc.set_defaults(handler=cmd_process)
prom = sub.add_parser("promote")
prom.add_argument("--pick", default="", help="name=candidate overrides")
prom.set_defaults(handler=cmd_promote)
sub.add_parser("audit").set_defaults(handler=cmd_audit)
real = sub.add_parser("realism")
real.add_argument("--run", required=True)
real.add_argument("--judge", action="store_true")
real.add_argument("--model", default=DEFAULT_JUDGE_MODEL,
help="OpenRouter model id, or 'auto' to pick the cheapest "
"available image+JSON model with fallbacks")
real.add_argument("--repeats", type=int, default=1,
help="average N judge runs per shot (scores are noisy)")
real.set_defaults(handler=cmd_realism)
return parser
def main(argv=None) -> int:
args = build_parser().parse_args(argv)
return args.handler(args)
if __name__ == "__main__":
sys.exit(main())