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