Adopt the realism pass and refresh screenshots/docs
- add the v2 runtime picks: per-phase skyline panoramas, brick, sidewalk pavers, dry/wet asphalt, awning canvas, and their normal maps - add the realism tooling and evidence: tools/scene_concepts.py, capture_realism.gd, concept frames, reports/sheets, and tests - retune phase lighting/fog/exposure, add sun yaw, brighten the night glow, add the flashlight beam mesh, and rebuild the planter/tree assets - regenerate all eleven staged screenshots from the current build; compare is 11 good / 0 bad - finish the visual-alignment closeout in DONE/TODO, update architecture, blender-assets, PLAN, and ignore rules (editable Blender sources untracked) - keep the judge retry and document the verified default judge model Verification: headless 227/227; Python tooling 86/86; check_screenshots 11/11.
This commit is contained in:
1 parent
26ecb93fd4
commit
d7e523f63c
70 files changed
+4042
-101
No files matched your search
+32
-15
@@ -1056,20 +1056,37 @@ def build_gear(name):
|
||||
# Props (grounded, front = Blender -Y)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _shrub(name, center, radii, mat, lumps=7, seed=1):
|
||||
"""Lumpy foliage cluster from overlapping low-poly spheres."""
|
||||
def _shrub(name, center, radii, mat, lumps=7, seed=1, lump=0.42):
|
||||
"""Dense foliage cluster: many small flat-shaded clumps, not one smooth blob.
|
||||
|
||||
Reads as leaves at game distance because the silhouette is broken up and the
|
||||
facets catch the light, which is what the realism-pass foliage note asks for.
|
||||
"""
|
||||
parts = []
|
||||
import random
|
||||
rng = random.Random(seed)
|
||||
parts.append(sphere(name, 1.0, center, mat, seg=10, rings=7, scale=radii))
|
||||
for i in range(lumps):
|
||||
a = math.tau * i / lumps + rng.uniform(-0.3, 0.3)
|
||||
r = rng.uniform(0.45, 0.6)
|
||||
off = (center[0] + math.cos(a) * radii[0] * 0.6,
|
||||
center[1] + math.sin(a) * radii[1] * 0.6,
|
||||
center[2] + rng.uniform(-0.1, 0.35) * radii[2])
|
||||
parts.append(sphere(name, 1.0, center, mat, seg=10, rings=7, scale=radii,
|
||||
smooth=False))
|
||||
for i in range(lumps * 2): # inner clumps fill the volume
|
||||
a = math.tau * i / (lumps * 2) + rng.uniform(-0.25, 0.25)
|
||||
ring = rng.uniform(0.25, 0.75)
|
||||
r = rng.uniform(lump * 0.8, lump * 1.25)
|
||||
off = (center[0] + math.cos(a) * radii[0] * ring,
|
||||
center[1] + math.sin(a) * radii[1] * ring,
|
||||
center[2] + rng.uniform(-0.2, 0.55) * radii[2])
|
||||
parts.append(sphere(name, 1.0, off, mat, seg=8, rings=6,
|
||||
scale=(radii[0] * r, radii[1] * r, radii[2] * r * 0.9)))
|
||||
scale=(radii[0] * r, radii[1] * r, radii[2] * r * 0.9),
|
||||
smooth=False))
|
||||
for i in range(max(3, lumps // 2)): # outer clumps break the silhouette
|
||||
a = math.tau * i / max(3, lumps // 2) + rng.uniform(-0.3, 0.3)
|
||||
r = rng.uniform(lump * 0.6, lump * 0.85)
|
||||
off = (center[0] + math.cos(a) * radii[0] * rng.uniform(0.85, 1.0),
|
||||
center[1] + math.sin(a) * radii[1] * rng.uniform(0.85, 1.0),
|
||||
center[2] + rng.uniform(-0.05, 0.4) * radii[2])
|
||||
parts.append(sphere(name, 1.0, off, mat, seg=8, rings=6,
|
||||
scale=(radii[0] * r, radii[1] * r, radii[2] * r * 0.8),
|
||||
rot=(rng.uniform(0, 1), rng.uniform(0, 1), rng.uniform(0, 3)),
|
||||
smooth=False))
|
||||
return parts
|
||||
|
||||
|
||||
@@ -1115,7 +1132,7 @@ def build_prop(name):
|
||||
bevel=0.025, segs=2))
|
||||
p.append(box("Lip", (1.0, 1.0, 0.05), (0, 0, 0.515), M("stone_pot"), bevel=0.015))
|
||||
p.append(box("Soil", (0.86, 0.86, 0.02), (0, 0, 0.535), M("soil")))
|
||||
p += _shrub("Shrub", (0, 0, 0.78), (0.42, 0.42, 0.30), M("foliage"), lumps=8, seed=3)
|
||||
p += _shrub("Shrub", (0, 0, 0.78), (0.42, 0.42, 0.30), M("foliage"), lumps=5, seed=3)
|
||||
elif name == "newspaper_box": # 0.44 x 0.38, h ~0.92
|
||||
p.append(box("Stand", (0.40, 0.34, 0.10), (0, 0, 0.05), M("news_blue"), bevel=0.01))
|
||||
p.append(box("Body", (0.42, 0.34, 0.70), (0, 0, 0.45), M("news_blue"), bevel=0.02))
|
||||
@@ -1159,9 +1176,9 @@ def build_prop(name):
|
||||
M("bark"), seg=8))
|
||||
p.append(tube("Branch", [(0, 0, 1.6), (0.35, 0.1, 2.2)], 0.04, M("bark"), seg=6))
|
||||
p.append(tube("Branch", [(0, 0, 1.7), (-0.3, -0.2, 2.3)], 0.04, M("bark"), seg=6))
|
||||
p += _shrub("Crown", (0, 0, 2.55), (0.95, 0.95, 0.85), M("foliage"), lumps=9, seed=7)
|
||||
p += _shrub("CrownTop", (0.05, 0.05, 3.15), (0.6, 0.6, 0.5), M("foliage_dark"),
|
||||
lumps=5, seed=11)
|
||||
p += _shrub("Crown", (0, 0, 2.55), (0.95, 0.95, 0.85), M("foliage"), lumps=7, seed=7)
|
||||
p += _shrub("CrownTop", (0.05, 0.05, 3.1), (0.62, 0.62, 0.52), M("foliage_dark"),
|
||||
lumps=4, seed=11)
|
||||
p.append(box("Grate", (1.0, 1.0, 0.02), (0, 0, 0.01), M("dark_metal")))
|
||||
elif name == "palm_tree": # ~7 m Hollywood palm
|
||||
prof = []
|
||||
@@ -1200,7 +1217,7 @@ def build_prop(name):
|
||||
p.append(lathe("Pot", [(0.0, 0.0), (0.22, 0.0), (0.26, 0.05), (0.33, 0.50),
|
||||
(0.36, 0.52), (0.36, 0.58), (0.31, 0.58), (0.0, 0.56)],
|
||||
M("stone_pot"), seg=20, sharp=30))
|
||||
p += _shrub("Topiary", (0, 0, 0.82), (0.34, 0.34, 0.30), M("foliage"), lumps=7, seed=5)
|
||||
p += _shrub("Topiary", (0, 0, 0.82), (0.34, 0.34, 0.30), M("foliage"), lumps=4, seed=5)
|
||||
elif name == "a_frame_sign": # chalkboard sidewalk sign
|
||||
for s in (-1.0, 1.0):
|
||||
tilt = math.radians(14) * s
|
||||
|
||||
@@ -0,0 +1,982 @@
|
||||
#!/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")),
|
||||
"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",
|
||||
# The venues share the Hollywood geometry for now, so these two compare
|
||||
# lighting and atmosphere rather than layout (noted in TODO.md).
|
||||
"joke_shop_door": "joke_shop_door_closeup",
|
||||
"studio_door": "studio_stage_door_night",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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())
|
||||
@@ -991,7 +991,13 @@ def judge(args):
|
||||
"differences": [f"dry run: {len(body['messages'][0]['content'])} parts"],
|
||||
})
|
||||
continue
|
||||
verdict = parse_judge_response(_call_openrouter(body))
|
||||
# Providers occasionally answer a valid key with a transient HTTP 400;
|
||||
# one retry keeps a whole judge run from dying on a single hiccup.
|
||||
try:
|
||||
verdict = parse_judge_response(_call_openrouter(body))
|
||||
except ValueError as exc:
|
||||
print(f"retrying {capture.name}: {exc}", file=sys.stderr)
|
||||
verdict = parse_judge_response(_call_openrouter(body))
|
||||
verdict.update({"capture": capture.name, "screen": design_name,
|
||||
"class": classify(verdict, args.fail_confidence)})
|
||||
results.append(verdict)
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
import scene_concepts as sc
|
||||
|
||||
|
||||
class CatalogueTests(unittest.TestCase):
|
||||
def test_groups_and_prompts(self):
|
||||
groups = {g for g, _ in sc.CATALOGUE.values()}
|
||||
self.assertEqual(groups, {"scene", "backdrop", "texture"})
|
||||
for name in sc.CATALOGUE:
|
||||
prompt = sc.compose_prompt(name)
|
||||
self.assertGreater(len(prompt), 80, name)
|
||||
for day in ("skyline_day", "skyline_dusk", "skyline_night"):
|
||||
self.assertEqual(sc.CATALOGUE[day][0], "backdrop")
|
||||
|
||||
def test_shots_point_at_scene_concepts(self):
|
||||
for shot, concept in sc.SHOTS.items():
|
||||
self.assertEqual(sc.CATALOGUE[concept][0], "scene", shot)
|
||||
|
||||
def test_paths_stay_in_their_folders(self):
|
||||
self.assertTrue(sc.raw_path("boulevard_skyline_day").is_relative_to(sc.CONCEPT_DIR))
|
||||
self.assertTrue(sc.raw_path("skyline_day").is_relative_to(sc.RAW_DIR))
|
||||
self.assertTrue(sc.raw_path("asphalt").is_relative_to(sc.RAW_DIR))
|
||||
|
||||
|
||||
class NoOverwriteTests(unittest.TestCase):
|
||||
def test_next_free_path_never_returns_an_existing_file(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
base = Path(tmp) / "a.png"
|
||||
self.assertEqual(sc.next_free_path(base), base)
|
||||
base.write_bytes(b"x")
|
||||
second = sc.next_free_path(base)
|
||||
self.assertEqual(second.name, "a-v2.png")
|
||||
second.write_bytes(b"x")
|
||||
self.assertEqual(sc.next_free_path(base).name, "a-v3.png")
|
||||
|
||||
|
||||
class ProcessingTests(unittest.TestCase):
|
||||
def _skyline(self, opaque=False):
|
||||
image = Image.new("RGBA", (300, 200), (120, 170, 230, 255 if opaque else 0))
|
||||
for x in range(300):
|
||||
top = 80 + (x * 7) % 50
|
||||
for y in range(top, 200):
|
||||
image.putpixel((x, y), (40 + x % 30, 40, 50, 255))
|
||||
return image
|
||||
|
||||
def test_key_sky_makes_an_opaque_sky_transparent(self):
|
||||
keyed = sc.key_sky(self._skyline(opaque=True))
|
||||
self.assertEqual(keyed.getpixel((5, 5))[3], 0)
|
||||
self.assertEqual(keyed.getpixel((5, 199))[3], 255)
|
||||
|
||||
def test_band_keeps_tower_tops(self):
|
||||
band = sc._band(self._skyline())
|
||||
self.assertAlmostEqual(band.size[0] / band.size[1], 2.0, delta=0.6)
|
||||
alpha = band.getchannel("A")
|
||||
self.assertEqual(alpha.getpixel((0, band.size[1] - 1)), 255)
|
||||
|
||||
def test_processed_backdrop_audits_clean(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
src = Path(tmp) / "raw.png"
|
||||
self._skyline().save(src)
|
||||
out = sc.process_backdrop(src, Path(tmp) / "skyline.png")
|
||||
result = sc.audit_backdrop(out)
|
||||
self.assertEqual(result["errors"], [])
|
||||
|
||||
def test_texture_processing_writes_tile_and_normal(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
src = Path(tmp) / "raw.png"
|
||||
Image.effect_noise((128, 128), 40).convert("RGB").save(src)
|
||||
out = sc.process_texture(src, Path(tmp) / "tile.png", size=64)
|
||||
self.assertEqual(Image.open(out).size, (64, 64))
|
||||
normal = Image.open(out.with_name("tile_normal.png"))
|
||||
self.assertEqual(normal.size, (64, 64))
|
||||
r, g, b = normal.getpixel((10, 10))
|
||||
self.assertGreater(b, 128) # normals point out of the surface
|
||||
|
||||
|
||||
def _striped(period=64, size=256):
|
||||
image = Image.new("RGB", (size, size), (200, 190, 170))
|
||||
for x in range(size):
|
||||
if (x // (period // 2)) % 2 == 0:
|
||||
for y in range(size):
|
||||
image.putpixel((x, y), (150, 40, 45))
|
||||
return image
|
||||
|
||||
|
||||
class StripeTests(unittest.TestCase):
|
||||
def test_period_found_and_tile_wraps(self):
|
||||
tile = _striped()
|
||||
self.assertEqual(sc.stripe_period(tile), 64)
|
||||
aligned = sc.align_stripes(tile)
|
||||
self.assertEqual(aligned.size, tile.size)
|
||||
# The wrap step along x matches a normal neighbouring-column step.
|
||||
profile = sc._column_profile(aligned)
|
||||
wrap = sc._step(profile[-1], profile[0])
|
||||
hardest = max(sc._step(profile[x], profile[x + 1]) for x in range(aligned.size[0] - 1))
|
||||
self.assertLessEqual(wrap, hardest * 1.25 + 0.5)
|
||||
|
||||
def test_flat_texture_has_no_period(self):
|
||||
self.assertEqual(sc.stripe_period(Image.new("RGB", (128, 128), (90, 90, 90))), 0)
|
||||
flat = Image.new("RGB", (64, 64), (128, 128, 128))
|
||||
self.assertEqual(sc.align_stripes(flat).size, flat.size)
|
||||
|
||||
def test_striped_audit_accepts_a_boundary_on_the_edge(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "awning_canvas.png"
|
||||
image = _striped(period=64, size=sc.TILE)
|
||||
image.save(path)
|
||||
result = sc.audit_striped_tile(path)
|
||||
self.assertEqual(result["errors"], [])
|
||||
|
||||
|
||||
class PromoteTests(unittest.TestCase):
|
||||
def test_promote_copies_and_never_overwrites(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
picks = Path(tmp) / "picks"
|
||||
global_dir = sc.PICKS_DIR
|
||||
sc.PICKS_DIR = picks
|
||||
try:
|
||||
sc.READY_DIR = Path(tmp)
|
||||
Image.new("RGB", (16, 16), (10, 20, 30)).save(Path(tmp) / "src.png")
|
||||
Image.new("RGB", (16, 16), (128, 128, 255)).save(Path(tmp) / "src_normal.png")
|
||||
target = sc.promote("pick", "src")
|
||||
self.assertTrue(target.is_file())
|
||||
self.assertTrue((picks / "pick_normal.png").is_file())
|
||||
Image.new("RGB", (16, 16), (0, 0, 0)).save(Path(tmp) / "src2.png")
|
||||
again = sc.promote("pick", "src2")
|
||||
self.assertEqual(again, target)
|
||||
self.assertNotEqual(Image.open(target).getpixel((0, 0)), (0, 0, 0))
|
||||
finally:
|
||||
sc.PICKS_DIR = global_dir
|
||||
sc.READY_DIR = Path(tmp).parent # restored by the loader below
|
||||
|
||||
def test_picks_exist_in_catalogue(self):
|
||||
for name, source in sc.PICKS.items():
|
||||
self.assertTrue(source.startswith(name), f"{name} <- {source}")
|
||||
|
||||
|
||||
class RealismTests(unittest.TestCase):
|
||||
def test_metrics_and_gap(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
flat = Path(tmp) / "flat.png"
|
||||
busy = Path(tmp) / "busy.png"
|
||||
Image.new("RGB", (320, 180), (128, 128, 128)).save(flat)
|
||||
Image.effect_noise((320, 180), 60).convert("RGB").save(busy)
|
||||
m_flat, m_busy = sc.image_metrics(flat), sc.image_metrics(busy)
|
||||
self.assertLess(m_flat["edge_density"], m_busy["edge_density"])
|
||||
self.assertEqual(sc.metric_gap(m_busy, m_busy), 0.0)
|
||||
|
||||
def test_parse_realism_response_clamps_and_limits(self):
|
||||
payload = {"choices": [{"message": {"content":
|
||||
'{"scores": {"overall": 14, "lighting": "3"}, "fixes": ["a","b","c","d","e","f"]}'}}]}
|
||||
verdict = sc.parse_realism_response(payload)
|
||||
self.assertEqual(verdict["scores"], {"overall": 10.0, "lighting": 3.0})
|
||||
self.assertEqual(len(verdict["fixes"]), 5)
|
||||
self.assertEqual(sc.parse_realism_response({})["scores"], {})
|
||||
|
||||
def test_summary_averages_per_set(self):
|
||||
report = {"sets": {"a": {
|
||||
"x": {"metric_gap": 0.2, "judge": {"scores": {"overall": 2.0}}},
|
||||
"y": {"metric_gap": 0.4, "judge": {"scores": {"overall": 4.0}}}}}}
|
||||
summary = sc.summarize(report)
|
||||
self.assertEqual(summary["a"]["overall"], 3.0)
|
||||
self.assertAlmostEqual(summary["a"]["metric_gap"], 0.3)
|
||||
self.assertIn("Mean scores", sc.render_markdown(
|
||||
{"run": "r", "judge_model": None, "sets": report["sets"] | {}} | {
|
||||
"sets": {"a": {k: dict(v, target="t.png", metrics={
|
||||
"edge_density": 1, "fine_detail": 1, "contrast": 1})
|
||||
for k, v in report["sets"]["a"].items()}}}))
|
||||
|
||||
def test_request_carries_both_images(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
a = Path(tmp) / "a.png"
|
||||
Image.new("RGB", (8, 8)).save(a)
|
||||
body = sc.build_realism_request("m", a, a)
|
||||
parts = body["messages"][0]["content"]
|
||||
self.assertEqual([p["type"] for p in parts], ["text", "image_url", "image_url"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user