#!/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/.png python3 tools/scene_concepts.py audit # asset_pipeline checks on v2 python3 tools/scene_concepts.py realism --run docs/screens/realism/ [--judge] [--model ...] Paths: raw AI output assets/textures/ai/v2/raw//.png (gitignored) game-ready candidates assets/textures/ai/game_ready/v2/.png (+ _normal) scene references docs/screens/concepts/.png trial captures/reports docs/screens/realism//{baseline,candidate}/ + report The API key comes from OPENROUTER_API_KEY or the repo `.env`. """ import argparse import json import math import os import re 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" ## Realistic-block references live in a dedicated folder so the original ## Hollywood entrance concepts stay comparable. CONCEPT_SUBDIRS = { "hollywood_boulevard_entrance_day": "hollywood_boulevard", "hollywood_boulevard_entrance_night": "hollywood_boulevard", "hollywood_boulevard_across_street": "hollywood_boulevard", "joke_shop_entrance_day": "joke_shop", "joke_shop_entrance_night": "joke_shop", "joke_shop_across_street": "joke_shop", } 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_entrance_day": ("scene", ( "the Comedy Store comedy club on the Sunset Strip in West Hollywood, a low " "black building with a deep sloped black canopy covered in white celebrity " "signatures, a large circular white sign on a pole reading THE JOKE SHOP, " "red curtains hanging under the canopy, a black recessed entrance at the " "left, palm trees along the sidewalk, dry scrub hillside with houses behind, " "bright hazy afternoon, a few pedestrians and parked cars")), "joke_shop_entrance_night": ("scene", ( "the Comedy Store comedy club on the Sunset Strip at night, low black " "building and deep sloped canopy with white signatures, the circular THE " "JOKE SHOP sign lit above the roof, red curtains glowing under warm practical " "lights, a small queue at the recessed entrance, palm silhouettes, lit houses " "on the dark hillside behind, wet asphalt reflections, neon spill from the " "strip")), "joke_shop_across_street": ("scene", ( "view across Sunset Boulevard toward the black Comedy Store building, its " "circular THE JOKE SHOP sign and signature-covered canopy, red curtains and " "warm entrance, palm trees, traffic and billboards on the Sunset Strip, dry " "hillside houses rising behind the club, late afternoon haze")), "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")), # -- Real-world Hollywood block (map optimization) ---------------------- "hollywood_boulevard_entrance_day": ("scene", ( "photorealistic eye-level view of the El Capitan Entertainment Centre at " "6840 Hollywood Boulevard, the 1921 Hollywood Masonic Temple: light limestone " "Neoclassical facade, six oversized Ionic columns, eight stone steps up to " "recessed dark double doors with ornamental iron grilles, a continuous " "crosscut-grille transom, carved Masonic motto and circular medallions on the " "parapet, three tall red event banners, the Hollywood Walk of Fame terrazzo " "sidewalk with brass stars, palm trees, the neighbouring El Capitan Theatre " "marquee at the left edge, a four-lane boulevard, clear morning light")), "hollywood_boulevard_entrance_night": ("scene", ( "the same El Capitan Entertainment Centre on Hollywood Boulevard after dark: " "six Ionic columns and limestone facade lit by warm practical lamps, red event " "banners, the El Capitan Theatre marquee glowing next door, wet asphalt " "reflecting the lights, a few pedestrians on the Walk of Fame terrazzo, palm " "silhouettes, distant city haze, cinematic night photography")), "hollywood_boulevard_across_street": ("scene", ( "view from the steps of the El Capitan Entertainment Centre across Hollywood " "Boulevard: a four-lane street with yellow centre lines, red-painted kerb and " "crosswalk, the Ovation Hollywood retail frontage opposite with a broad stone " "arcade and sign band, palm trees, the Walk of Fame sidewalk with brass stars, " "traffic and pedestrians, late-afternoon haze, realistic game environment")), # -- 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/.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", "stone_steps": "stone_steps-v2", "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_boulevard_entrance_day", "boulevard_day": "boulevard_skyline_day", "street_day": "hollywood_boulevard_across_street", "boulevard_night": "hollywood_entrance_rain_night", "street_night": "hollywood_boulevard_entrance_night", "entrance_late": "hollywood_boulevard_entrance_night", "street_late": "hollywood_boulevard_across_street", # Each venue is judged on its own frame; the real geometry no longer shares # the Hollywood layout. "joke_shop_door": "joke_shop_entrance_night", "12-joke-shop": "joke_shop_entrance_night", "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 `-vN`.""" 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 / CONCEPT_SUBDIRS.get(name, "") / 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}." REFERENCE_PROMPT_SUFFIX = ( "Use the attached reference photograph as the fixed source of geometry and " "composition: keep the building's proportions, column rhythm, band heights, " "signage placement and camera angle exactly as photographed. Only restage " "lighting, weather, people and surface condition where the prompt asks for it. " "Do not invent extra storeys, wings or ornaments that the photo does not show." ) REFS_DIR = ROOT / "docs" / "screens" / "refs" def _reference_source(path_str: str, allow_unchecked: bool = False) -> dict: """Resolve and licence-check a reference photo for anchored generation. A file under `docs/screens/refs/` must already be listed in that venue's `SIDECAR.md` (the licence gate); its licence and Commons page are returned for provenance. Anything else requires the explicit `--reference-unchecked` override, which keeps unreviewed material out of the committed pipeline by default. """ path = Path(path_str).expanduser().resolve() if not path.is_file(): sys.exit(f"error: reference photo not found: {path}") if REFS_DIR in path.parents: sidecar = path.parent / "SIDECAR.md" text = sidecar.read_text(encoding="utf-8") if sidecar.is_file() else "" row = next((line for line in text.splitlines() if f"`{path.name}`" in line), None) if not row: sys.exit(f"error: {path.name} is not listed in {sidecar}; add the " "sidecar row (licence gate) before anchoring generation on it") cells = [c.strip() for c in row.split("|")] licence = next((c for c in cells if c and c.lower().startswith(("cc", "public"))), "?") page = next((c.strip("<> ") for c in cells if c.startswith(" Path: """Record which photo anchored a generated frame (`.refgen.json` sidecar).""" sidecar = out_path.with_suffix(".refgen.json") sidecar.write_text(json.dumps({ "concept": name, "generated": out_path.name, "model": model, "reference": {"file": str(reference["path"].relative_to(ROOT)) if reference["path"].is_relative_to(ROOT) else str(reference["path"]), "licence": reference["licence"], "page": reference["page"], "licence_checked": reference["checked"]}, "suffix": REFERENCE_PROMPT_SUFFIX, }, indent=2) + "\n", encoding="utf-8") return sidecar def generate_one(name: str, model: str, request_size: int, reference: dict | None = None) -> Path: group = CATALOGUE[name][0] prompt = compose_prompt(name) if reference: prompt = f"{prompt}\n\n{REFERENCE_PROMPT_SUFFIX}" if group == "scene": image = generate_textures.generate_image(prompt, model, request_size, transparent=False, aspect_ratio="3:2", reference_path=reference["path"] if reference else None) 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", reference_path=reference["path"] if reference else None) else: image = generate_textures.generate_image(prompt, model, request_size, transparent=False, aspect_ratio="1:1", reference_path=reference["path"] if reference else None) image = image.convert("RGB") out = next_free_path(raw_path(name)) out.parent.mkdir(parents=True, exist_ok=True) image.save(out) if reference: provenance = _write_reference_provenance(out, reference, name, model) print(f"provenance {_rel(provenance)} <- {reference['licence']}") print(f"wrote {_rel(out)} {image.size}") return out def cmd_generate(args) -> int: _load_env_file() reference = _reference_source(args.reference, args.reference_unchecked) \ if args.reference else None 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, reference=reference) 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/.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 = raw_path(concept_name) 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 = [raw_path(SHOTS[shot])] + [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" # --------------------------------------------------------------------------- # Treatment clauses for the photo-anchored variant pass: one framing, several # restaged conditions, so a concept board stays comparable with its photo board. TREATMENTS = { "day": "clean midday daylight, soft haze fading the far street to beige", "night": "night scene lit by practical lights only (marquee, signage, street " "lamps), dark sky, wet pavement reflections", "weathered": "overcast flat light, weathered and grimy facade surfaces, faded " "signage, scattered litter near the kerb", } def _anchor_treatment_prompt(context: str, treatment: str) -> str: if treatment not in TREATMENTS: sys.exit(f"error: unknown treatment '{treatment}'; " f"choose from {sorted(TREATMENTS)}") return f"{context}\n\n{REFERENCE_PROMPT_SUFFIX}\n\nRestage the scene: {TREATMENTS[treatment]}" def cmd_anchor(args) -> int: """Generate day/night/weathered variants of one reference photo's framing.""" _load_env_file() reference = _reference_source(args.reference, args.reference_unchecked) treatments = [t.strip() for t in args.treatments.split(",") if t.strip()] unknown = [t for t in treatments if t not in TREATMENTS] if unknown: sys.exit(f"error: unknown treatment(s) {unknown}; " f"choose from {sorted(TREATMENTS)}") out_dir = ROOT / args.out_dir out_dir.mkdir(parents=True, exist_ok=True) stem = args.name or re.sub(r"-[0-9]{4}-[0-9]{2}-[0-9]{2}$", "", reference["path"].stem) stem = re.sub(r"[^a-z0-9_]+", "_", stem.lower()).strip("_") failed = [] for treatment in treatments: prompt = _anchor_treatment_prompt(args.context, treatment) print(f"anchoring {stem}_{treatment} ...", flush=True) try: image = generate_textures.generate_image( prompt, args.model, args.request_size, reference_path=reference["path"]) image = ImageOps.fit(image.convert("RGB"), (SCENE_W, SCENE_H), method=Image.Resampling.LANCZOS) except SystemExit as exc: # generate_textures exits on API errors failed.append(treatment) print(f" FAILED {treatment}: {exc}", file=sys.stderr) continue out = next_free_path(out_dir / f"{stem}_{treatment}.png") image.save(out) _write_reference_provenance(out, reference, f"{stem}_{treatment}", args.model) print(f"wrote {_rel(out)} {image.size}") if failed: print("failed: " + ", ".join(failed), file=sys.stderr) return 1 return 0 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.add_argument("--reference", default="", help="anchor generation on a licence-cleared reference photo " "(must be listed in its refs-board SIDECAR.md)") gen.add_argument("--reference-unchecked", action="store_true", help="allow a reference photo from outside docs/screens/refs/ " "(you take responsibility for its licence)") 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) anchor = sub.add_parser("anchor", help="generate day/night/weathered variants of one " "reference photo's framing") anchor.add_argument("--reference", required=True, help="licence-cleared photo (listed in its refs SIDECAR.md)") anchor.add_argument("--context", required=True, help="what the photo shows and how to restage it") anchor.add_argument("--treatments", default="day,night,weathered", help="comma list from: " + ", ".join(sorted(TREATMENTS))) anchor.add_argument("--out-dir", required=True, help="concepts subfolder for the frames, relative to the repo root") anchor.add_argument("--name", default="", help="output stem (default: derived from the photo filename)") anchor.add_argument("--model", default=DEFAULT_MODEL) anchor.add_argument("--request-size", type=int, default=1536) anchor.add_argument("--reference-unchecked", action="store_true") anchor.set_defaults(handler=cmd_anchor) 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())