1221 lines
56 KiB
Python
1221 lines
56 KiB
Python
#!/usr/bin/env python3
|
|
"""Realism pass: AI scene references, skyline backdrops and surface textures.
|
|
|
|
Generates example 3D-game scene frames (art direction), realistic skyline
|
|
panoramas that replace the outdated flat `assets/textures/skyline.png`, and
|
|
higher-detail seamless surface tiles; processes them into game-ready
|
|
candidates; and scores in-game captures against the realistic target.
|
|
|
|
**No command ever overwrites an existing image.** When a target path exists the
|
|
next free `-vN` name is used (generate) or the step is skipped (process).
|
|
|
|
python3 tools/scene_concepts.py list
|
|
python3 tools/scene_concepts.py generate [--group scene|backdrop|texture]
|
|
[--only name,...] [--variants N]
|
|
python3 tools/scene_concepts.py process # raw -> game_ready/v2 (+ normals)
|
|
python3 tools/scene_concepts.py promote # validated picks -> picks/<name>.png
|
|
python3 tools/scene_concepts.py audit # asset_pipeline checks on v2
|
|
python3 tools/scene_concepts.py realism --run docs/screens/realism/<run>
|
|
[--judge] [--model ...]
|
|
|
|
Paths:
|
|
raw AI output assets/textures/ai/v2/raw/<group>/<name>.png (gitignored)
|
|
game-ready candidates assets/textures/ai/game_ready/v2/<name>.png (+ _normal)
|
|
scene references docs/screens/concepts/<name>.png
|
|
trial captures/reports docs/screens/realism/<run>/{baseline,candidate}/ + report
|
|
|
|
The API key comes from OPENROUTER_API_KEY or the repo `.env`.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import math
|
|
import os
|
|
import 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/<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",
|
|
"stone_steps": "stone_steps-v2",
|
|
"limestone_ashlar": "limestone_ashlar",
|
|
"dark_stucco": "dark_stucco",
|
|
"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 `<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 / 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("<http")), "")
|
|
return {"path": path, "licence": licence, "page": page, "checked": True}
|
|
if not allow_unchecked:
|
|
sys.exit(f"error: {path} is outside docs/screens/refs/ and not licence-checked; "
|
|
"move it into a refs board with a SIDECAR row, or pass "
|
|
"--reference-unchecked to accept the licence responsibility")
|
|
return {"path": path, "licence": "unchecked", "page": "", "checked": False}
|
|
|
|
|
|
def _write_reference_provenance(out_path: Path, reference: dict, name: str,
|
|
model: str) -> 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/<name>.png`.
|
|
|
|
Never overwrites: an existing pick is left alone (the promotion is a no-op
|
|
with a warning) and a renamed pick gets the next free `-vN` path.
|
|
"""
|
|
source = READY_DIR / f"{source_name}.png"
|
|
if not source.is_file():
|
|
raise FileNotFoundError(source)
|
|
target = PICKS_DIR / f"{name}.png"
|
|
if target.is_file():
|
|
print(f"keep {_rel(target)} (already promoted; remove it to re-promote)")
|
|
return target
|
|
PICKS_DIR.mkdir(parents=True, exist_ok=True)
|
|
with Image.open(source) as image:
|
|
image.load()
|
|
image.save(target)
|
|
source_normal = READY_DIR / f"{source_name}_normal.png"
|
|
target_normal = PICKS_DIR / f"{name}_normal.png"
|
|
if source_normal.is_file() and not target_normal.exists():
|
|
with Image.open(source_normal) as image:
|
|
image.load()
|
|
image.save(target_normal)
|
|
print(f"promoted {_rel(source)} -> {_rel(target)}")
|
|
return target
|
|
|
|
|
|
def cmd_promote(args) -> int:
|
|
picks = dict(PICKS)
|
|
for override in (args.pick or "").split(","):
|
|
if "=" in override:
|
|
key, value = override.split("=", 1)
|
|
picks[key.strip()] = value.strip()
|
|
if not PICKS_DIR.joinpath("..").exists():
|
|
pass
|
|
failures = 0
|
|
for name, source in picks.items():
|
|
try:
|
|
promote(name, source)
|
|
except FileNotFoundError:
|
|
print(f"missing candidate for {name}: {source}", file=sys.stderr)
|
|
failures += 1
|
|
return 1 if failures else 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Realism report
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def image_metrics(path: Path) -> dict:
|
|
"""Coarse detail/realism signals (Pillow only)."""
|
|
with Image.open(path) as image:
|
|
image.load()
|
|
rgb = image.convert("RGB").resize((640, 360), Image.Resampling.LANCZOS)
|
|
gray = ImageOps.grayscale(rgb)
|
|
edges = ImageStat.Stat(gray.filter(ImageFilter.FIND_EDGES)).mean[0]
|
|
# High-frequency texture: difference between the frame and a blurred copy.
|
|
blur = gray.filter(ImageFilter.GaussianBlur(2))
|
|
detail = ImageStat.Stat(Image.eval(
|
|
Image.blend(gray, ImageOps.invert(blur), 0.5), lambda v: abs(v - 128) * 2)).mean[0]
|
|
hsv = rgb.convert("HSV")
|
|
sat = ImageStat.Stat(hsv.getchannel("S"))
|
|
return {
|
|
"edge_density": round(edges, 2),
|
|
"fine_detail": round(detail, 2),
|
|
"contrast": round(ImageStat.Stat(gray).stddev[0], 2),
|
|
"saturation_mean": round(sat.mean[0], 2),
|
|
"colour_spread": round(sum(ImageStat.Stat(rgb).stddev) / 3.0, 2),
|
|
}
|
|
|
|
|
|
def metric_gap(capture: dict, target: dict) -> float:
|
|
"""Mean relative distance of the capture's metrics from the target (0 = same)."""
|
|
keys = ("edge_density", "fine_detail", "contrast", "colour_spread")
|
|
gaps = [abs(capture[k] - target[k]) / max(target[k], 1e-6) for k in keys]
|
|
return round(sum(gaps) / len(gaps), 3)
|
|
|
|
|
|
JUDGE_INSTRUCTIONS = (
|
|
"You are an art director for a realistic 3D game. Image 1 is the TARGET look "
|
|
"(an AI concept frame). Image 2 is an in-game CAPTURE of the same kind of "
|
|
"scene. Ignore HUD text and exact layout. Score how realistic the CAPTURE "
|
|
"looks compared with the target, each 0-10: lighting, materials, "
|
|
"texture_detail, backdrop (sky, skyline and distance haze), characters, overall. "
|
|
"Then list up to five concrete, actionable fixes ranked by impact. Reply ONLY "
|
|
"with JSON: {\"scores\": {\"lighting\": n, \"materials\": n, \"texture_detail\": n, "
|
|
"\"backdrop\": n, \"characters\": n, \"overall\": n}, \"fixes\": [\"...\"]}")
|
|
|
|
|
|
def _data_url(path: Path) -> str:
|
|
import base64
|
|
return "data:image/png;base64," + base64.b64encode(Path(path).read_bytes()).decode("ascii")
|
|
|
|
|
|
def build_realism_request(model: str, target: Path, capture: Path) -> dict:
|
|
return {
|
|
"model": model,
|
|
"messages": [{"role": "user", "content": [
|
|
{"type": "text", "text": JUDGE_INSTRUCTIONS},
|
|
{"type": "image_url", "image_url": {"url": _data_url(target)}},
|
|
{"type": "image_url", "image_url": {"url": _data_url(capture)}},
|
|
]}],
|
|
"temperature": 0,
|
|
"response_format": {"type": "json_object"},
|
|
}
|
|
|
|
|
|
def parse_realism_response(payload: dict) -> dict:
|
|
try:
|
|
text = payload["choices"][0]["message"]["content"]
|
|
except (KeyError, IndexError, TypeError):
|
|
return {"scores": {}, "fixes": ["unreadable model response"]}
|
|
if isinstance(text, list):
|
|
text = "".join(p.get("text", "") for p in text if isinstance(p, dict))
|
|
text = str(text)
|
|
start, end = text.find("{"), text.rfind("}")
|
|
try:
|
|
data = json.loads(text[start:end + 1]) if start >= 0 else {}
|
|
except ValueError:
|
|
return {"scores": {}, "fixes": [f"non-JSON response: {text[:120]}"]}
|
|
scores = {}
|
|
for key, value in (data.get("scores") or {}).items():
|
|
try:
|
|
scores[str(key)] = max(0.0, min(10.0, float(value)))
|
|
except (TypeError, ValueError):
|
|
continue
|
|
fixes = data.get("fixes") or []
|
|
if not isinstance(fixes, list):
|
|
fixes = [str(fixes)]
|
|
return {"scores": scores, "fixes": [str(f) for f in fixes][:5]}
|
|
|
|
|
|
def _call_openrouter(body: dict, timeout: int = 180) -> dict:
|
|
import urllib.error
|
|
import urllib.request
|
|
key = os.environ.get("OPENROUTER_API_KEY", "").strip()
|
|
if not key:
|
|
raise ValueError("OPENROUTER_API_KEY is not set")
|
|
req = urllib.request.Request(
|
|
OPENROUTER_CHAT_URL, data=json.dumps(body).encode("utf-8"),
|
|
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json",
|
|
"HTTP-Referer": "https://git.allucanget.biz/zwitschi/security-game",
|
|
"X-Title": "Gig Line"}, method="POST")
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
return json.loads(resp.read().decode("utf-8"))
|
|
except urllib.error.HTTPError as exc:
|
|
raise ValueError(f"OpenRouter HTTP {exc.code}: {exc.read().decode('utf-8', 'replace')[:300]}")
|
|
|
|
|
|
def judge_capture(models, target: Path, capture: Path, attempts: int = 2,
|
|
repeats: int = 1) -> dict:
|
|
"""Vision-judge one capture; retry transient errors, average repeat scores.
|
|
|
|
`models` is a fallback-ordered list from `openrouter_judge.resolve_models`.
|
|
A single score from a small vision model swings by ±1-2, so `repeats` runs
|
|
are averaged and the spread is kept (`score_range`) for the report.
|
|
"""
|
|
if isinstance(models, str):
|
|
models = [models]
|
|
runs = []
|
|
error = None
|
|
used = set()
|
|
for _ in range(max(1, repeats)):
|
|
try:
|
|
model, payload = openrouter_judge.call_with_fallback(
|
|
models, lambda selected: build_realism_request(selected, target, capture),
|
|
_call_openrouter, attempts=attempts)
|
|
used.add(model)
|
|
runs.append(parse_realism_response(payload))
|
|
except (ValueError, OSError) as exc:
|
|
if openrouter_judge.is_auth_error(exc):
|
|
raise
|
|
error = exc
|
|
if not runs:
|
|
return {"scores": {}, "fixes": [f"judge failed: {error}"], "model": ""}
|
|
keys = {key for run in runs for key in run["scores"]}
|
|
scores = {}
|
|
spread = {}
|
|
for key in keys:
|
|
values = [run["scores"][key] for run in runs if key in run["scores"]]
|
|
scores[key] = round(sum(values) / len(values), 2)
|
|
spread[key] = [min(values), max(values)]
|
|
fixes = []
|
|
for run in runs:
|
|
for fix in run["fixes"]:
|
|
if fix not in fixes:
|
|
fixes.append(fix)
|
|
return {"scores": scores, "score_range": spread, "runs": len(runs),
|
|
"model": ", ".join(sorted(used)), "fixes": fixes[:5]}
|
|
|
|
|
|
def cmd_realism(args) -> int:
|
|
run = Path(args.run)
|
|
run = run if run.is_absolute() else ROOT / run
|
|
sets = [d for d in sorted(run.iterdir()) if d.is_dir()] if run.is_dir() else []
|
|
if not sets:
|
|
print(f"no capture sets under {_rel(run)}", file=sys.stderr)
|
|
return 2
|
|
judge_models = []
|
|
if args.judge:
|
|
_load_env_file()
|
|
judge_models = openrouter_judge.resolve_models(args.model)
|
|
print(f"judge models: {', '.join(judge_models)}", file=sys.stderr)
|
|
report = {"run": _rel(run),
|
|
"judge_model": (judge_models[0] if judge_models else None),
|
|
"judge_fallbacks": judge_models, "sets": {}}
|
|
for capture_set in sets:
|
|
rows = {}
|
|
for capture in sorted(capture_set.glob("*.png")):
|
|
concept_name = SHOTS.get(capture.stem)
|
|
target = 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())
|