Files
security-game/tools/scene_concepts.py
T
zwitschi 8b0845bcce
Full build / Import + headless tests (push) Successful in 40s
Full build / Full desktop build + optional release (push) Successful in 4m57s
Build distinct Joke Shop and TV Studio venues
Add the alley walls, warm lantern, shutters, dumpster, AC clutter and door
step for the Joke Shop. Build the studio stone facade, bulb canopy, glossy
black car, stanchions and barriers. Rebuild only the world container when the
venue changes, so the guard and UI survive; add per-venue navigation checks.
Capture gallery shots 12/13, update the concept mapping and docs. Headless
231/231, Python 87/87, screenshot gate 13 good/0 bad. The vision-judge retry is
tracked separately because the API key is invalid.
2026-11-01 19:09:47 +01:00

992 lines
43 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
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 = "google/gemini-2.5-flash-lite"
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(model: str, target: Path, capture: Path, attempts: int = 2,
repeats: int = 1) -> dict:
"""Vision-judge one capture; retry transient errors, average repeat scores.
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.
"""
runs = []
error = None
for _ in range(max(1, repeats)):
for _attempt in range(attempts):
try:
runs.append(parse_realism_response(_call_openrouter(
build_realism_request(model, target, capture))))
break
except (ValueError, OSError) as exc:
error = exc
if not runs:
return {"scores": {}, "fixes": [f"judge failed: {error}"]}
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),
"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
if args.judge:
_load_env_file()
report = {"run": _rel(run), "judge_model": args.model if args.judge else None,
"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:
row["judge"] = judge_capture(args.model, target, capture,
repeats=args.repeats)
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)
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())