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.
1084 lines
48 KiB
Python
1084 lines
48 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate screen-design reference images for Gig Line and compare captures.
|
|
|
|
This tool turns the screen descriptions that already live in `docs/`
|
|
(README screenshot captions, ADRs 0007-0010, PLAN.md, architecture.md, and the
|
|
HUD strings in `content/strings.en.json`) into reference "design" images, then
|
|
uses those images as the expected state to check the game's captured
|
|
screenshots against.
|
|
|
|
Two commands:
|
|
|
|
generate write one runtime reference image per screen to assets/images/ui/.
|
|
Two modes:
|
|
--ai realistic 3D game scenes and in-game screen designs via
|
|
the OpenRouter image API (default; requires
|
|
OPENROUTER_API_KEY, same as tools/asset_pipeline.py).
|
|
--draft deterministic wireframe sketches drawn with Pillow
|
|
(no network or API key; good for CI and layout checks).
|
|
|
|
judge send each capture together with its reference design to a vision
|
|
model and ask whether they depict the same screen. Pixel and
|
|
perceptual metrics cannot judge composition; this closes that gap.
|
|
Requires OPENROUTER_API_KEY (falls back to the repo .env).
|
|
--dry-run build and print the requests without calling the API.
|
|
|
|
compare compare docs/screenshots/*.png against the runtime reference art and
|
|
flag gross drift. Semantic composition is judged with coarse,
|
|
Pillow-only signals (aspect ratio, frame size, blank-frame
|
|
contrast, grayscale MAE, colour-histogram distance, edge density).
|
|
These catch "wrong screen / blank frame / wrong time of day"
|
|
regressions; they are NOT a pixel-exact or semantic match. A
|
|
vision-model pass is the documented next step (see PLAN.md).
|
|
|
|
list print the catalogue: name, kind, whether a reference design
|
|
exists, and which capture pairs with it.
|
|
|
|
Run from the repo root:
|
|
|
|
python3 tools/screen_designs.py list
|
|
python3 tools/screen_designs.py generate
|
|
python3 tools/screen_designs.py generate --draft
|
|
python3 tools/screen_designs.py compare
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from PIL import Image, ImageChops, ImageDraw, ImageFilter, ImageOps, ImageStat
|
|
|
|
import generate_textures
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
## Runtime reference art (ADR-0014); docs/screens/designs/ is the reference-only
|
|
## snapshot kept for docs and historical comparison.
|
|
DESIGNS_DIR = ROOT / "assets" / "images" / "ui"
|
|
SCREENSHOTS_DIR = ROOT / "docs" / "screenshots"
|
|
|
|
# Reference "design" dimensions — match the capture tool and the README gallery.
|
|
WIDTH, HEIGHT = 1280, 720
|
|
ASPECT = 16 / 9
|
|
|
|
# Default image model for screen mockups (validated role-aware model, per
|
|
# MEMORY.md; seedream rejects small sizes and ignores transparency, so we use
|
|
# the model asset_pipeline.py already trusts for non-alpha roles).
|
|
DEFAULT_MODEL = "openai/gpt-5-image-mini"
|
|
|
|
# Vision model used by `judge`. Cheap, multimodal, and good at "same screen?".
|
|
DEFAULT_JUDGE_MODEL = "google/gemini-2.5-flash-lite"
|
|
OPENROUTER_CHAT_URL = "https://openrouter.ai/api/v1/chat/completions"
|
|
|
|
# Shared art direction, appended to every prompt so the generated frames read
|
|
# as one coherent game. "scene" entries are first-person 3D world views;
|
|
# "ui" entries are in-game panels overlaid on a softened scene.
|
|
SCENE_STYLE = (
|
|
"rendered in a modern 3D game engine, first-person perspective, cinematic "
|
|
"lighting, physically based materials, soft shadows, ambient occlusion, "
|
|
"subtle depth of field, photorealistic detail, no watermark or UI clutter"
|
|
)
|
|
UI_STYLE = (
|
|
"clean modern in-game user interface, dark translucent panels, readable "
|
|
"sans-serif labels, consistent game UI colour theme, crisp vector text"
|
|
)
|
|
|
|
# Landscape source ratio requested from the API before cropping to 16:9.
|
|
# 3:2 is the validated non-square ratio for the default model (see MEMORY.md).
|
|
SOURCE_ASPECT = "3:2"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Screen catalogue. Each entry is grounded in a doc; `prompt` feeds --ai and
|
|
# `regions` feed --draft (normalised rects + a short label). `ref` names the
|
|
# captured screenshot, if one exists, so `compare` can pair them up.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
SCREENS = [
|
|
{
|
|
"name": "venue_overview",
|
|
"kind": "scene",
|
|
"title": "Venue overview",
|
|
"source": "README screenshot 01",
|
|
"ref": "01-venue-overview.png",
|
|
"description": "First-person view from the guard post at the top of the "
|
|
"steps: Hollywood Boulevard entrance, wide concrete steps, "
|
|
"sidewalk, street lamps, planters, crosswalk, and building "
|
|
"signage/marquee under early-morning light.",
|
|
"prompt": (
|
|
"Realistic 3D first-person game scene: a security guard's view from "
|
|
"the top of wide concrete steps outside a grand Hollywood Boulevard "
|
|
"building entrance, early-morning golden light, sidewalk with street "
|
|
"lamps, planters, a crosswalk and distant pedestrians, detailed brick "
|
|
"facade with a striped awning and glowing marquee sign"),
|
|
"regions": [
|
|
(0.00, 0.55, 1.00, 1.00, "steps + sidewalk"),
|
|
(0.20, 0.08, 0.80, 0.60, "building facade / marquee"),
|
|
(0.05, 0.60, 0.20, 0.75, "street lamp"),
|
|
(0.78, 0.60, 0.95, 0.75, "street lamp"),
|
|
],
|
|
},
|
|
{
|
|
"name": "targeting_npc",
|
|
"kind": "scene",
|
|
"title": "Targeting an NPC",
|
|
"source": "README screenshot 02 · ADR-0008 cone focus + focus circle",
|
|
"ref": "02-targeting-npc.png",
|
|
"description": "An approaching NPC is in the focus cone: a blue "
|
|
"semi-transparent circle in front of their face, a display "
|
|
"name under the circle, and the HUD shows a target line "
|
|
"with the interaction hint.",
|
|
"prompt": (
|
|
"Realistic 3D first-person game scene: a friendly pedestrian "
|
|
"standing on a city sidewalk, a blue semi-transparent circle in front "
|
|
"of their face as a game selection marker, a small name label under "
|
|
"the circle, minimal HUD target text at the bottom, daytime"),
|
|
"regions": [
|
|
(0.35, 0.15, 0.65, 0.60, "NPC in focus"),
|
|
(0.44, 0.10, 0.56, 0.16, "face circle"),
|
|
(0.05, 0.85, 0.95, 0.95, "HUD: name + hint"),
|
|
],
|
|
},
|
|
{
|
|
"name": "access_inspection",
|
|
"kind": "ui",
|
|
"title": "Access inspection",
|
|
"source": "README screenshot 03 · ADR-0003 access verification",
|
|
"ref": "03-access-inspection.png",
|
|
"description": "An inspection panel shows the access artifact (wristband / "
|
|
"ID / ticket / credential / guest list) with its colour, "
|
|
"pattern, date, and event marking, plus allow and deny "
|
|
"prompts.",
|
|
"prompt": (
|
|
"Realistic 3D game screenshot with an in-game access inspection panel "
|
|
"overlaid: a centered card showing a wristband or ID credential with a "
|
|
"photo placeholder, colour band, date and event marking, and two "
|
|
"buttons labelled Allow and Deny, dark translucent panel over a "
|
|
"softly blurred city street background"),
|
|
"regions": [
|
|
(0.25, 0.10, 0.75, 0.90, "inspection panel"),
|
|
(0.30, 0.18, 0.70, 0.60, "artifact card"),
|
|
(0.30, 0.65, 0.48, 0.72, "allow"),
|
|
(0.52, 0.65, 0.70, 0.72, "deny"),
|
|
],
|
|
},
|
|
{
|
|
"name": "radio_report",
|
|
"kind": "ui",
|
|
"title": "Radio report",
|
|
"source": "README screenshot 04 · PLAN level 4 radio assistance",
|
|
"ref": "04-radio-report.png",
|
|
"description": "A radio dispatch panel with fields for location, "
|
|
"description, behaviour, whether weapons were seen, and "
|
|
"whether anyone is in immediate danger, plus a send action.",
|
|
"prompt": (
|
|
"Realistic 3D game screenshot with an in-game radio dispatch panel "
|
|
"overlaid: form fields for location, description, behaviour, a threat "
|
|
"assessment and whether anyone is in danger, and a Send button, dark "
|
|
"translucent panel over a softly blurred city street background"),
|
|
"regions": [
|
|
(0.30, 0.08, 0.70, 0.92, "radio panel"),
|
|
(0.34, 0.16, 0.66, 0.30, "location / description"),
|
|
(0.34, 0.34, 0.66, 0.56, "behaviour / weapons / danger"),
|
|
(0.42, 0.60, 0.58, 0.66, "send"),
|
|
],
|
|
},
|
|
{
|
|
"name": "late_night_avenue",
|
|
"kind": "scene",
|
|
"title": "Late night avenue",
|
|
"source": "README screenshot 05",
|
|
"ref": "05-late-night-avenue.png",
|
|
"description": "Night-time view down Hollywood Boulevard: street lamps "
|
|
"lit, marquee glowing, dark blue street, no flashlight.",
|
|
"prompt": (
|
|
"Realistic 3D first-person game scene at night: a dark Hollywood "
|
|
"Boulevard avenue receding into the distance, lit street lamps, a "
|
|
"glowing neon marquee sign, deep blue night sky, wet reflective "
|
|
"pavement, no flashlight"),
|
|
"regions": [
|
|
(0.00, 0.55, 1.00, 1.00, "dark avenue"),
|
|
(0.15, 0.25, 0.85, 0.55, "glowing marquee"),
|
|
(0.10, 0.45, 0.30, 0.70, "lit street lamp"),
|
|
(0.70, 0.45, 0.90, 0.70, "lit street lamp"),
|
|
],
|
|
},
|
|
{
|
|
"name": "night_flashlight",
|
|
"kind": "scene",
|
|
"title": "Night flashlight",
|
|
"source": "README screenshot 06",
|
|
"ref": "06-night-flashlight.png",
|
|
"description": "Night shift: the guard's flashlight beam cuts through the "
|
|
"dark toward the entrance, illuminating the steps and door.",
|
|
"prompt": (
|
|
"Realistic 3D first-person game scene at night: a flashlight beam "
|
|
"cutting through darkness toward a building entrance and steps, a "
|
|
"bright spotlight pool illuminating the door, dark surroundings and "
|
|
"deep shadows"),
|
|
"regions": [
|
|
(0.35, 0.30, 0.65, 0.80, "flashlight beam pool"),
|
|
(0.40, 0.20, 0.60, 0.35, "lit door"),
|
|
(0.00, 0.00, 1.00, 0.25, "dark sky"),
|
|
],
|
|
},
|
|
{
|
|
"name": "radial_menu",
|
|
"kind": "ui",
|
|
"title": "Radial action menu",
|
|
"source": "ADR-0009 · content/strings.en.json",
|
|
"ref": None,
|
|
"description": "A round doughnut menu centred on screen, 12 slices in "
|
|
"de-escalation order: Stop, Look, Point, No, Ask, Inspect, "
|
|
"Wand, Warn, Block, Reason, Recognize, Radio. The focused "
|
|
"person's display name sits in the centre.",
|
|
"prompt": (
|
|
"Realistic 3D game screenshot with an in-game radial action menu "
|
|
"overlaid: a round doughnut wheel centered on screen with twelve "
|
|
"labelled slices reading Stop, Look, Point, No, Ask, Inspect, Wand, "
|
|
"Warn, Block, Reason, Recognize, Radio, one slice highlighted, a name "
|
|
"in the center of the ring, over a softly blurred city street scene"),
|
|
"regions": [
|
|
(0.38, 0.12, 0.62, 0.88, "radial wheel (12 slices)"),
|
|
(0.46, 0.44, 0.54, 0.56, "name"),
|
|
(0.62, 0.12, 0.70, 0.20, "highlight"),
|
|
],
|
|
},
|
|
{
|
|
"name": "shift_review",
|
|
"kind": "ui",
|
|
"title": "Shift review",
|
|
"source": "PLAN reputation / shift review · architecture.md ShiftReview",
|
|
"ref": None,
|
|
"description": "End-of-shift performance review: access decisions, "
|
|
"escalation choices, incident list, battery and crowd "
|
|
"outcomes, and reputation/score feedback.",
|
|
"prompt": (
|
|
"Realistic 3D game end-of-shift review screen: a full-screen "
|
|
"performance summary panel listing incident decisions, escalation "
|
|
"choices, a reputation score, crowd reaction and battery use, with a "
|
|
"Continue button, dark themed game UI"),
|
|
"regions": [
|
|
(0.30, 0.06, 0.70, 0.94, "review panel"),
|
|
(0.36, 0.14, 0.64, 0.24, "score / reputation"),
|
|
(0.36, 0.28, 0.64, 0.70, "incident list"),
|
|
(0.42, 0.76, 0.58, 0.84, "continue"),
|
|
],
|
|
},
|
|
{
|
|
"name": "start_menu",
|
|
"kind": "ui",
|
|
"title": "Start menu",
|
|
"source": "MEMORY session shell (StartMenu, layer 20)",
|
|
"ref": None,
|
|
"description": "The boot screen: a venue-selection menu (Hollywood "
|
|
"Boulevard, The Joke Shop, TV Studio) before a shift.",
|
|
"prompt": (
|
|
"Realistic 3D game start menu screen: a game title and three venue "
|
|
"selection buttons labelled Hollywood Boulevard, The Joke Shop and TV "
|
|
"Studio, dark themed menu over a softly blurred city street backdrop"),
|
|
"regions": [
|
|
(0.25, 0.10, 0.75, 0.26, "title"),
|
|
(0.36, 0.36, 0.64, 0.44, "Hollywood Boulevard"),
|
|
(0.36, 0.48, 0.64, 0.56, "The Joke Shop"),
|
|
(0.36, 0.60, 0.64, 0.68, "TV Studio"),
|
|
],
|
|
},
|
|
{
|
|
"name": "pause_overlay",
|
|
"kind": "ui",
|
|
"title": "Pause overlay",
|
|
"source": "MEMORY session shell (PauseOverlay, layer 18)",
|
|
"ref": None,
|
|
"description": "Pause menu: resume, restart, settings, and quit-to-menu.",
|
|
"prompt": (
|
|
"Realistic 3D game pause menu overlay: buttons labelled Resume, "
|
|
"Restart, Settings and Quit to Menu, dark translucent overlay over a "
|
|
"paused, softly blurred city street scene"),
|
|
"regions": [
|
|
(0.38, 0.26, 0.62, 0.74, "pause menu"),
|
|
(0.42, 0.32, 0.58, 0.38, "Resume"),
|
|
(0.42, 0.42, 0.58, 0.48, "Restart"),
|
|
(0.42, 0.52, 0.58, 0.58, "Settings"),
|
|
(0.42, 0.62, 0.58, 0.68, "Quit to Menu"),
|
|
],
|
|
},
|
|
{
|
|
"name": "settings_panel",
|
|
"kind": "ui",
|
|
"title": "Settings",
|
|
"source": "MEMORY session shell (SettingsPanel, layer 22)",
|
|
"ref": None,
|
|
"description": "Settings: look sensitivity and HUD font scale controls.",
|
|
"prompt": (
|
|
"Realistic 3D game settings screen: sliders labelled look sensitivity "
|
|
"and HUD font scale, dark themed settings panel"),
|
|
"regions": [
|
|
(0.30, 0.16, 0.70, 0.84, "settings panel"),
|
|
(0.36, 0.30, 0.64, 0.40, "look sensitivity"),
|
|
(0.36, 0.46, 0.64, 0.56, "HUD font scale"),
|
|
],
|
|
},
|
|
{
|
|
"name": "emergency_radio",
|
|
"kind": "ui",
|
|
"title": "Emergency radio",
|
|
"source": "PLAN level 5 · strings radio.emergency_dispatched",
|
|
"ref": None,
|
|
"description": "Hold-to-confirm emergency radio panel with a confirmation "
|
|
"prompt and a cooldown indicator.",
|
|
"prompt": (
|
|
"Realistic 3D game emergency radio hold-to-confirm panel: a large "
|
|
"warning prompt with a hold progress bar and a cooldown label, red "
|
|
"accent, dark translucent overlay over a softly blurred city street "
|
|
"scene"),
|
|
"regions": [
|
|
(0.30, 0.28, 0.70, 0.72, "emergency panel"),
|
|
(0.36, 0.36, 0.64, 0.46, "confirm prompt"),
|
|
(0.38, 0.52, 0.62, 0.60, "hold progress"),
|
|
(0.36, 0.64, 0.64, 0.68, "cooldown"),
|
|
],
|
|
},
|
|
{
|
|
"name": "wand_screening",
|
|
"kind": "ui",
|
|
"title": "Wand screening",
|
|
"source": "README screenshot gallery · PLAN The Joke Shop · src/core/wand_screener.gd",
|
|
"ref": "07-wand-screening.png",
|
|
"description": "Handheld wand screening panel for The Joke Shop: the alert "
|
|
"result, the items the guest is carrying, and allow/deny "
|
|
"buttons. A phone, keys or a belt buckle can raise a false "
|
|
"alarm, so the alert alone never proves a weapon.",
|
|
"prompt": (
|
|
"Realistic 3D game screenshot with an in-game security wand screening "
|
|
"panel overlaid: a handheld metal-detector alert indicator, a short "
|
|
"list of carried items such as keys, phone and belt buckle, a warning "
|
|
"note that an alert can be a false alarm, and two buttons labelled "
|
|
"Allow and Deny, dark translucent panel over a softly blurred comedy "
|
|
"club entrance at night"),
|
|
"regions": [
|
|
(0.26, 0.12, 0.74, 0.88, "wand panel"),
|
|
(0.32, 0.20, 0.68, 0.32, "alert result"),
|
|
(0.32, 0.38, 0.68, 0.56, "carried items"),
|
|
(0.30, 0.70, 0.48, 0.78, "allow"),
|
|
(0.52, 0.70, 0.70, 0.78, "deny"),
|
|
],
|
|
},
|
|
{
|
|
"name": "joke_shop_queue",
|
|
"kind": "scene",
|
|
"title": "Joke Shop door queue",
|
|
"source": "PLAN.md The Joke Shop — long line, ID and ticket checks",
|
|
"ref": "12-joke-shop.png",
|
|
"description": "Evening view of a small comedy-club entrance: a short queue "
|
|
"on the sidewalk, a door person checking an ID and a ticket "
|
|
"under a warm bulb, a red awning, and the show board.",
|
|
"prompt": (
|
|
"Realistic 3D first-person game scene: a narrow comedy club entrance at "
|
|
"dusk with a short queue of guests on the sidewalk, a door person "
|
|
"checking an ID and a paper ticket under a warm lamp, red fabric awning, "
|
|
"a hand-lettered show board, wet pavement reflections, warm interior "
|
|
"light spilling through the door"),
|
|
"regions": [
|
|
(0.33, 0.30, 0.67, 0.86, "door + door person"),
|
|
(0.00, 0.55, 0.30, 0.95, "queue"),
|
|
(0.24, 0.16, 0.76, 0.36, "awning + show board"),
|
|
(0.72, 0.55, 1.00, 0.95, "sidewalk"),
|
|
],
|
|
},
|
|
{
|
|
"name": "studio_arrival",
|
|
"kind": "scene",
|
|
"title": "TV Studio celebrity arrival",
|
|
"source": "PLAN.md Late Night TV Studio — crowd gathers for an arrival",
|
|
"ref": "13-studio-arrival.png",
|
|
"description": "A celebrity arrival at the studio entrance: tourists and "
|
|
"influencers pressing toward the door, cameras raised, the "
|
|
"guard holding a line on the steps, studio marquee behind.",
|
|
"prompt": (
|
|
"Realistic 3D first-person game scene: a busy late-night television "
|
|
"studio entrance with a small crowd of tourists and influencers raising "
|
|
"cameras toward a black car at the curb, a security guard holding a line "
|
|
"on the steps, glowing studio marquee and lit canopy, night time"),
|
|
"regions": [
|
|
(0.30, 0.30, 0.70, 0.80, "crowd + cameras"),
|
|
(0.40, 0.10, 0.60, 0.34, "studio marquee"),
|
|
(0.00, 0.60, 0.25, 0.95, "car at the curb"),
|
|
(0.78, 0.55, 1.00, 0.90, "guard line"),
|
|
],
|
|
},
|
|
{
|
|
"name": "crowd_panic",
|
|
"kind": "scene",
|
|
"title": "Crowd panic",
|
|
"source": "PLAN.md Public Crowd Reaction · CrowdSystem.reaction_for_event",
|
|
"ref": None,
|
|
"description": "After a panic event the sidewalk empties away from the "
|
|
"door: people running toward the street, one person still "
|
|
"near the steps, the guard between them and the entrance.",
|
|
"prompt": (
|
|
"Realistic 3D first-person game scene: bystanders scattering away from a "
|
|
"building entrance across a wide sidewalk, motion blur on the fleeing "
|
|
"figures, one person left near the steps, a security guard standing "
|
|
"between them and the door, tense evening light, long shadows"),
|
|
"regions": [
|
|
(0.00, 0.45, 0.42, 0.90, "fleeing crowd"),
|
|
(0.55, 0.40, 0.78, 0.85, "guard between crowd and door"),
|
|
(0.38, 0.10, 0.80, 0.45, "building entrance"),
|
|
(0.72, 0.50, 1.00, 0.95, "empty sidewalk"),
|
|
],
|
|
},
|
|
{
|
|
"name": "responder_arrival",
|
|
"kind": "scene",
|
|
"title": "Responder arrival",
|
|
"source": "PLAN.md Level 5 Emergency Response · radio_system.gd responder dispatch",
|
|
"ref": None,
|
|
"description": "A dispatched responder walks up the steps toward a locked "
|
|
"door while the entrance is being cleared; blue light washes "
|
|
"the facade and the lock indicator reads red.",
|
|
"prompt": (
|
|
"Realistic 3D first-person game scene: an emergency responder in dark "
|
|
"uniform walking up wide concrete steps toward a locked building door, "
|
|
"blue light washing the brick facade, a red lock indicator beside the "
|
|
"door, people clearing from the entrance, night"),
|
|
"regions": [
|
|
(0.40, 0.28, 0.60, 0.85, "responder on the steps"),
|
|
(0.42, 0.14, 0.58, 0.32, "locked door + indicator"),
|
|
(0.00, 0.00, 1.00, 0.30, "facade + blue light"),
|
|
(0.00, 0.70, 0.28, 0.98, "people clearing"),
|
|
],
|
|
},
|
|
{
|
|
"name": "body_camera_timeline",
|
|
"kind": "ui",
|
|
"title": "Body-camera timeline",
|
|
"source": "README screenshot gallery · ADR-0005 · src/game/ui/event_timeline.gd",
|
|
"ref": "09-body-camera-timeline.png",
|
|
"description": "The shift review's body-camera timeline: a text list of every "
|
|
"recorded incident event stamped mm:ss with the person and the "
|
|
"action or outcome, one row highlighted, and previous/next "
|
|
"buttons.",
|
|
"prompt": (
|
|
"Realistic 3D game screenshot with an in-game body-camera event timeline "
|
|
"overlaid: a tall dark panel listing time-stamped rows such as "
|
|
"02:14 formal warning, 02:31 body camera on and 03:02 access denied, one "
|
|
"row highlighted, Previous and Next buttons at the bottom, over a "
|
|
"softly blurred night street background"),
|
|
"regions": [
|
|
(0.28, 0.10, 0.72, 0.90, "timeline panel"),
|
|
(0.32, 0.16, 0.68, 0.26, "timeline header"),
|
|
(0.32, 0.28, 0.68, 0.72, "timestamped rows"),
|
|
(0.32, 0.78, 0.68, 0.85, "previous / next"),
|
|
],
|
|
},
|
|
{
|
|
"name": "escalation_hud",
|
|
"kind": "ui",
|
|
"title": "Escalation HUD",
|
|
"source": "README accessibility note · Hud.escalation_meter (shape meter · level)",
|
|
"ref": "08-escalation-hud.png",
|
|
"description": "The in-shift HUD: a shape meter plus numeric escalation level "
|
|
"(`Escalation: Blocking [###--] L3/5`), the targeted person's "
|
|
"name, flashlight / door / body-camera indicators, shift clock, "
|
|
"and the reaction log.",
|
|
"prompt": (
|
|
"Third-person over-the-shoulder 3D game screenshot at night: a security "
|
|
"guard in the foreground facing a guest in front of a venue entrance, "
|
|
"with a minimal in-game HUD overlaid. Bottom left: an escalation meter "
|
|
"drawn as a shape bar reading Escalation: Blocking [###--] L3/5. Centre: "
|
|
"a small target name label above the guest. Top right: a shift clock, "
|
|
"reputation and crowd percentage. Left: a short stacked reaction log. "
|
|
"Bottom centre: a control hint bar. Realistic lighting, muted colours"),
|
|
"regions": [
|
|
(0.02, 0.80, 0.34, 0.95, "escalation shape meter"),
|
|
(0.35, 0.28, 0.75, 0.40, "target name label"),
|
|
(0.78, 0.02, 0.98, 0.16, "shift clock + crowd"),
|
|
(0.02, 0.20, 0.36, 0.65, "reaction log"),
|
|
(0.02, 0.94, 0.98, 0.99, "control hints"),
|
|
],
|
|
}, {
|
|
"name": "movement_precision",
|
|
"kind": "scene",
|
|
"title": "Precision walk",
|
|
"source": "src/game/guard_controller.gd · README controls",
|
|
"ref": "11-precision-walk.png",
|
|
"description": "First-person view at the venue steps while the guard uses the "
|
|
"precision-walk modifier: a small Precision walk chip on the HUD, "
|
|
"the doorway ahead, and a narrower field of view.",
|
|
"prompt": (
|
|
"First-person 3D game screenshot at night from a security guard standing at "
|
|
"the bottom of venue steps, looking at a lit entrance door with a striped "
|
|
"awning and GIG LINE sign. Subtle head-height view, brick facade, dark blue "
|
|
"night sky, minimal HUD: a small cyan 'Precision walk (Shift)' chip at the "
|
|
"bottom left, shift clock top right, thin control hint line at the bottom. "
|
|
"Realistic lighting, muted colours, no people"),
|
|
"regions": [
|
|
(0.00, 0.80, 0.20, 0.88, "precision walk chip"),
|
|
(0.30, 0.05, 0.70, 0.60, "entrance door and steps"),
|
|
(0.78, 0.02, 0.98, 0.16, "shift clock"),
|
|
],
|
|
},
|
|
{
|
|
"name": "prop_gallery",
|
|
"kind": "scene",
|
|
"title": "Prop gallery",
|
|
"source": "tools/model_pipeline.py · docs screenshot set",
|
|
"ref": "10-prop-gallery.png",
|
|
"description": "Line-up of the six generated street-furniture props — a bollard, "
|
|
"bench, trash bin, planter box, newspaper box and fire hydrant — on "
|
|
"a plain studio backdrop, used as a close-range art check.",
|
|
"prompt": (
|
|
"Product line-up photograph of six low-poly 3D street-furniture props on a "
|
|
"plain light grey studio backdrop with a low display platform: a black steel "
|
|
"bollard, a wooden bench with black legs, a dark green trash bin, a white "
|
|
"planter box with a green hedge, a blue newspaper vending box and a red fire "
|
|
"hydrant. Even soft studio lighting, straight-on wide shot, muted realistic "
|
|
"colours, no people, no street context"),
|
|
"regions": [
|
|
(0.22, 0.14, 0.80, 0.47, "back row: planter, newspaper box, hydrant"),
|
|
(0.22, 0.38, 0.80, 0.68, "front row: bollard, bench, bin"),
|
|
],
|
|
},
|
|
]
|
|
|
|
|
|
def resolve_path(value):
|
|
path = Path(value).expanduser()
|
|
return path if path.is_absolute() else ROOT / path
|
|
|
|
|
|
def list_screens(args):
|
|
"""Print the screen catalogue and what state each entry is in."""
|
|
for screen in SCREENS:
|
|
design = _design_path(screen["name"])
|
|
state = "design" if design.is_file() else "- "
|
|
capture = screen["ref"] or "-"
|
|
print(f"{screen['name']:22} {screen['kind']:5} {state} "
|
|
f"capture={capture:24} {screen['title']}")
|
|
scenes = sum(1 for screen in SCREENS if screen["kind"] == "scene")
|
|
print(f"Total: {len(SCREENS)} screens "
|
|
f"({scenes} scene, {len(SCREENS) - scenes} ui)")
|
|
return 0
|
|
|
|
|
|
def _design_path(name):
|
|
return DESIGNS_DIR / f"{name}.png"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Draft (wireframe) rendering — deterministic, offline.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _draw_wireframe(screen, path):
|
|
image = Image.new("RGB", (WIDTH, HEIGHT), (24, 26, 38))
|
|
draw = ImageDraw.Draw(image)
|
|
title_font = generate_textures.font(52)
|
|
body_font = generate_textures.font(24)
|
|
label_font = generate_textures.font(26)
|
|
|
|
draw.text((40, 28), screen["title"], font=title_font, fill=(240, 240, 245))
|
|
draw.text((40, 98), screen["source"], font=body_font, fill=(150, 155, 175))
|
|
# wrap the description into a small caption block
|
|
words = screen["description"].split()
|
|
lines, line = [], ""
|
|
for word in words:
|
|
if draw.textlength(line + " " + word, font=body_font) < WIDTH - 80:
|
|
line = (line + " " + word).strip()
|
|
else:
|
|
lines.append(line)
|
|
line = word
|
|
lines.append(line)
|
|
for index, text in enumerate(lines[:4]):
|
|
draw.text((40, 132 + index * 30), text,
|
|
font=body_font, fill=(120, 125, 145))
|
|
|
|
for region in screen["regions"]:
|
|
x0, y0, x1, y1, label = region
|
|
box = (int(x0 * WIDTH), int(y0 * HEIGHT),
|
|
int(x1 * WIDTH), int(y1 * HEIGHT))
|
|
draw.rounded_rectangle(box, radius=10,
|
|
outline=(120, 130, 170), width=2)
|
|
# Anchor the label to the top-left corner: nested regions (a panel plus
|
|
# the cards inside it) would otherwise stack their centred labels on top
|
|
# of each other.
|
|
draw.text((box[0] + 10, box[1] + 6), label,
|
|
font=label_font, fill=(170, 180, 210))
|
|
|
|
image.save(path)
|
|
shown = path.relative_to(ROOT) if path.is_relative_to(ROOT) else path
|
|
print(f"wrote {shown} {image.size}")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AI generation (reuses the OpenRouter backend from generate_textures).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _compose_prompt(screen):
|
|
"""Append the shared art direction to a screen's content prompt."""
|
|
style = UI_STYLE if screen.get("kind") == "ui" else SCENE_STYLE
|
|
return f"{screen['prompt']}. {style}. 16:9 widescreen composition."
|
|
|
|
|
|
def _generate_ai(screen, model, request_size):
|
|
prompt = _compose_prompt(screen)
|
|
# Request a landscape source and crop to the 16:9 capture dimensions rather
|
|
# than a square that loses framing when centre-cropped.
|
|
image = generate_textures.generate_image(
|
|
prompt, model, request_size, transparent=False,
|
|
aspect_ratio=SOURCE_ASPECT)
|
|
image = ImageOps.fit(image, (WIDTH, HEIGHT),
|
|
method=Image.Resampling.LANCZOS)
|
|
if image.mode in ("RGBA", "LA", "P"):
|
|
image = image.convert("RGB")
|
|
path = _design_path(screen["name"])
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
image.save(path)
|
|
shown = path.relative_to(ROOT) if path.is_relative_to(ROOT) else path
|
|
print(f"wrote {shown} {image.size}")
|
|
|
|
|
|
def generate(args):
|
|
_load_env_file()
|
|
DESIGNS_DIR.mkdir(parents=True, exist_ok=True)
|
|
failed = []
|
|
selected = [screen for screen in SCREENS
|
|
if not args.only or args.only in screen["name"]]
|
|
if not selected:
|
|
print(f"no screen matches --only {args.only!r}", file=sys.stderr)
|
|
return 2
|
|
for screen in selected:
|
|
path = _design_path(screen["name"])
|
|
# Draft mode is the offline fallback; do not clobber existing AI-crafted
|
|
# references unless the caller explicitly asks for it.
|
|
if path.is_file() and not args.force:
|
|
print(
|
|
f"skip {screen['name']} (reference exists; --force to replace)")
|
|
continue
|
|
print(f"generating {screen['name']} ...")
|
|
try:
|
|
if args.draft:
|
|
_draw_wireframe(screen, path)
|
|
else:
|
|
_generate_ai(screen, args.model, args.request_size)
|
|
except SystemExit as exc:
|
|
failed.append(screen["name"])
|
|
print(f" FAILED {screen['name']}: {exc}", file=sys.stderr)
|
|
print("done")
|
|
if failed:
|
|
print(f"failed: {', '.join(failed)}", file=sys.stderr)
|
|
return 1
|
|
return 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Comparison metrics (Pillow only; no numpy/scikit-image dependency).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _load_rgb(path):
|
|
with Image.open(path) as image:
|
|
image.load()
|
|
return image.convert("RGB")
|
|
|
|
|
|
def _grayscale(image):
|
|
return ImageOps.grayscale(image)
|
|
|
|
|
|
def _mae(left, right):
|
|
"""Mean absolute error between two same-size grayscale images, 0..255."""
|
|
left_gray = _grayscale(left).resize((256, 144))
|
|
right_gray = _grayscale(right).resize((256, 144))
|
|
diff = ImageChops.difference(left_gray, right_gray)
|
|
return ImageStat.Stat(diff).mean[0]
|
|
|
|
|
|
def _histogram_distance(left, right):
|
|
"""Normalised L1 distance between 3x256-bin colour histograms, 0..1."""
|
|
left_hist = left.resize((256, 144)).histogram()
|
|
right_hist = right.resize((256, 144)).histogram()
|
|
total = sum(left_hist)
|
|
if total == 0:
|
|
return 1.0
|
|
distance = sum(abs(a - b) for a, b in zip(left_hist, right_hist))
|
|
return distance / (2 * total)
|
|
|
|
|
|
def _edge_density(image):
|
|
"""Mean brightness of an edge-detected frame; higher = more detail."""
|
|
edges = _grayscale(image).filter(ImageFilter.FIND_EDGES)
|
|
return ImageStat.Stat(edges).mean[0]
|
|
|
|
|
|
def _contrast(image):
|
|
"""Standard deviation of grayscale brightness; low = near-blank frame."""
|
|
return ImageStat.Stat(_grayscale(image)).stddev[0]
|
|
|
|
|
|
def _aspect_ok(image, tolerance=0.12):
|
|
width, height = image.size
|
|
actual = width / height
|
|
return abs(actual - ASPECT) / ASPECT <= tolerance
|
|
|
|
|
|
def compare_screenshot(reference, capture):
|
|
"""Compare one capture against its reference design; return a dict report."""
|
|
ref = _load_rgb(reference)
|
|
cap = _load_rgb(capture)
|
|
width, height = cap.size
|
|
report = {
|
|
"capture": str(capture),
|
|
"reference": str(reference),
|
|
"size": [width, height],
|
|
}
|
|
|
|
errors, warnings = [], []
|
|
|
|
if width < WIDTH or height < HEIGHT:
|
|
errors.append(f"too small: {width}x{height}; minimum {WIDTH}x{HEIGHT}")
|
|
if not _aspect_ok(cap):
|
|
errors.append(f"wrong aspect ratio: {width / height:.3f}; "
|
|
f"expected about {ASPECT:.3f}")
|
|
|
|
contrast = _contrast(cap)
|
|
report["contrast"] = round(contrast, 1)
|
|
if contrast < 5.0:
|
|
errors.append(f"near-blank frame: contrast {contrast:.1f} below 5.0")
|
|
|
|
mae = _mae(ref, cap)
|
|
hist = _histogram_distance(ref, cap)
|
|
edge_ref = _edge_density(ref)
|
|
edge_cap = _edge_density(cap)
|
|
report["mae"] = round(mae, 1)
|
|
report["hist_distance"] = round(hist, 3)
|
|
report["edge_density_ref"] = round(edge_ref, 1)
|
|
report["edge_density_cap"] = round(edge_cap, 1)
|
|
|
|
# Coarse drift warnings (not hard failures: an AI mockup will never match a
|
|
# real render pixel-for-pixel, but a wildly different frame will still
|
|
# stand out on these signals).
|
|
if mae > 90:
|
|
warnings.append(
|
|
f"grayscale MAE {mae:.1f} is high (very different layout)")
|
|
if hist > 0.75:
|
|
warnings.append(f"colour histogram distance {hist:.3f} is high "
|
|
"(very different palette / time of day)")
|
|
edge_delta = abs(edge_cap - edge_ref)
|
|
report["edge_delta"] = round(edge_delta, 1)
|
|
if edge_delta > 18:
|
|
warnings.append(f"edge density differs by {edge_delta:.1f} "
|
|
"(very different amount of detail)")
|
|
|
|
report["quality"] = "bad" if errors else "good"
|
|
report["errors"] = errors
|
|
report["warnings"] = warnings
|
|
return report
|
|
|
|
|
|
def compare(args):
|
|
if not SCREENSHOTS_DIR.is_dir():
|
|
sys.exit(f"error: no screenshots directory at {SCREENSHOTS_DIR}; "
|
|
"run src/test/capture_screenshots.gd first")
|
|
captures = sorted(SCREENSHOTS_DIR.glob("*.png"))
|
|
if not captures:
|
|
sys.exit(f"error: no .png screenshots found in {SCREENSHOTS_DIR}")
|
|
|
|
refs = {screen["name"]: screen["ref"]
|
|
for screen in SCREENS if screen["ref"]}
|
|
results = []
|
|
for capture in captures:
|
|
name = capture.name
|
|
# find the screen whose ref matches this capture filename
|
|
design_name = next((n for n, ref in refs.items() if ref == name), None)
|
|
if design_name is None:
|
|
results.append({"capture": str(capture), "reference": None,
|
|
"errors": [f"no design reference for {name}"],
|
|
"warnings": [], "quality": "bad"})
|
|
continue
|
|
reference = _design_path(design_name)
|
|
if not reference.is_file():
|
|
results.append({"capture": str(capture),
|
|
"reference": str(reference),
|
|
"errors": [f"missing reference design "
|
|
f"({reference.name}); run generate first"],
|
|
"warnings": [], "quality": "bad"})
|
|
continue
|
|
results.append(compare_screenshot(reference, capture))
|
|
|
|
if args.json:
|
|
import json
|
|
print(json.dumps(results, indent=2))
|
|
else:
|
|
good_count = sum(r["quality"] == "good" for r in results)
|
|
for result in results:
|
|
ref = (Path(result["reference"]).name
|
|
if result.get("reference") else "-")
|
|
size = ("x".join(map(str, result["size"]))
|
|
if result.get("size") else "unreadable")
|
|
print(f"{result['quality'].upper():4} {result['capture']}")
|
|
print(f" ref={ref} size={size} "
|
|
f"mae={result.get('mae', '-')} "
|
|
f"hist={result.get('hist_distance', '-')}")
|
|
for message in result["errors"] + result["warnings"]:
|
|
print(f" - {message}")
|
|
print(f"Summary: {good_count} good, {len(results) - good_count} bad")
|
|
|
|
return 1 if any(r["quality"] == "bad" for r in results) else 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Vision judge — semantic "same screen?" check against the reference designs.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
JUDGE_INSTRUCTIONS = (
|
|
"You are reviewing screenshots of a low-poly 3D security-guard game. "
|
|
"IMAGE 1 is a CONCEPT MOCKUP of the intended screen (it may be painted in a "
|
|
"different art style and show different actors); IMAGE 2 is the ACTUAL "
|
|
"capture from the game. Different art style, fidelity, fonts, and character "
|
|
"models are EXPECTED and must be ignored.\n\n"
|
|
"Judge only whether IMAGE 2 realises the same SCREEN and LAYOUT INTENT as "
|
|
"IMAGE 1: is it the same kind of screen (world view, HUD overlay, modal "
|
|
"panel), are the main interface regions present in roughly the same places "
|
|
"(target line, inspection card, report form, on-screen buttons), and is the "
|
|
"subject of the shot comparable (entrance, street, person)?\n\n"
|
|
"Answer with JSON only: {\"match\": true|false, \"confidence\": 0..1, "
|
|
"\"differences\": [\"short point\", ...]}. Set match=true when the capture "
|
|
"realises the mockup's screen even if it looks much simpler."
|
|
)
|
|
|
|
|
|
def _load_env_file(path=None):
|
|
"""Populate os.environ from a .env file without overriding real variables."""
|
|
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("\"'"))
|
|
|
|
|
|
def _data_url(path):
|
|
import base64
|
|
payload = Path(path).read_bytes()
|
|
return "data:image/png;base64," + base64.b64encode(payload).decode("ascii")
|
|
|
|
|
|
def build_judge_request(model, screen, capture_path, design_path):
|
|
"""Build the OpenRouter chat payload for one capture/design pair."""
|
|
prompt = (f"{JUDGE_INSTRUCTIONS}\n\nScreen: {screen['title']} "
|
|
f"({screen['source']}). Design notes: {screen['description']}")
|
|
return {
|
|
"model": model,
|
|
"messages": [{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "text", "text": prompt},
|
|
{"type": "image_url", "image_url": {
|
|
"url": _data_url(design_path)}},
|
|
{"type": "image_url", "image_url": {
|
|
"url": _data_url(capture_path)}},
|
|
],
|
|
}],
|
|
"temperature": 0,
|
|
"response_format": {"type": "json_object"},
|
|
}
|
|
|
|
|
|
def parse_judge_response(payload):
|
|
"""Extract a verdict dict from an OpenRouter chat response."""
|
|
try:
|
|
text = payload["choices"][0]["message"]["content"]
|
|
except (KeyError, IndexError, TypeError):
|
|
return {"match": None, "confidence": 0.0,
|
|
"differences": ["unreadable model response"]}
|
|
if isinstance(text, list): # some providers return content parts
|
|
text = "".join(part.get("text", "") for part in text
|
|
if isinstance(part, dict))
|
|
text = str(text).strip()
|
|
start, end = text.find("{"), text.rfind("}")
|
|
if start >= 0 and end > start:
|
|
text = text[start:end + 1]
|
|
import json
|
|
try:
|
|
data = json.loads(text)
|
|
except ValueError:
|
|
return {"match": None, "confidence": 0.0,
|
|
"differences": [f"non-JSON response: {text[:120]}"]}
|
|
differences = data.get("differences") or []
|
|
if not isinstance(differences, list):
|
|
differences = [str(differences)]
|
|
return {
|
|
"match": data.get("match") if isinstance(data.get("match"), bool) else None,
|
|
"confidence": float(data.get("confidence", 0.0) or 0.0),
|
|
"differences": [str(item) for item in differences],
|
|
}
|
|
|
|
|
|
def classify(verdict, threshold=0.6):
|
|
"""Turn a model verdict into a QA class.
|
|
|
|
The reference images are concept mockups, so a low-confidence disagreement is
|
|
a review note, not a failure. Only a confident mismatch fails the check.
|
|
"""
|
|
if verdict.get("match") is True:
|
|
return "match"
|
|
if verdict.get("match") is False:
|
|
return "bad" if float(verdict.get("confidence", 0.0)) >= threshold else "review"
|
|
return "unknown"
|
|
|
|
|
|
def _call_openrouter(body, timeout=180):
|
|
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 (repo .env is read "
|
|
"automatically); use --dry-run to inspect requests")
|
|
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:
|
|
detail = exc.read().decode("utf-8", "replace")
|
|
raise ValueError(f"OpenRouter HTTP {exc.code}: {detail[:300]}")
|
|
|
|
|
|
def judge(args):
|
|
_load_env_file()
|
|
if not SCREENSHOTS_DIR.is_dir():
|
|
sys.exit(f"error: no screenshots directory at {SCREENSHOTS_DIR}")
|
|
refs = {screen["name"]: screen["ref"]
|
|
for screen in SCREENS if screen["ref"]}
|
|
by_name = {screen["name"]: screen for screen in SCREENS}
|
|
results = []
|
|
for capture in sorted(SCREENSHOTS_DIR.glob("*.png")):
|
|
if args.only and args.only not in capture.name:
|
|
continue
|
|
design_name = next((n for n, ref in refs.items()
|
|
if ref == capture.name), None)
|
|
if design_name is None:
|
|
results.append({"capture": capture.name, "screen": None,
|
|
"match": None, "confidence": 0.0,
|
|
"differences": ["no design reference for this capture"]})
|
|
continue
|
|
reference = _design_path(design_name)
|
|
if not reference.is_file():
|
|
results.append({"capture": capture.name, "screen": design_name,
|
|
"match": None, "confidence": 0.0,
|
|
"differences": ["missing reference design; run generate"]})
|
|
continue
|
|
body = build_judge_request(args.model, by_name[design_name],
|
|
capture, reference)
|
|
if args.dry_run:
|
|
results.append({
|
|
"capture": capture.name, "screen": design_name, "match": None,
|
|
"confidence": 0.0, "model": body["model"], "class": "unknown",
|
|
"differences": [f"dry run: {len(body['messages'][0]['content'])} parts"],
|
|
})
|
|
continue
|
|
# Providers occasionally answer a valid key with a transient HTTP 400;
|
|
# one retry keeps a whole judge run from dying on a single hiccup.
|
|
try:
|
|
verdict = parse_judge_response(_call_openrouter(body))
|
|
except ValueError as exc:
|
|
print(f"retrying {capture.name}: {exc}", file=sys.stderr)
|
|
verdict = parse_judge_response(_call_openrouter(body))
|
|
verdict.update({"capture": capture.name, "screen": design_name,
|
|
"class": classify(verdict, args.fail_confidence)})
|
|
results.append(verdict)
|
|
|
|
if args.json:
|
|
print(json.dumps(results, indent=2))
|
|
else:
|
|
labels = {"match": "MATCH", "review": "REVIEW", "bad": "DIFF",
|
|
"unknown": "UNKNOWN"}
|
|
for result in results:
|
|
print(f"{labels[result['class']]:7} {result['capture']} "
|
|
f"(design: {result['screen']}) "
|
|
f"confidence={result['confidence']:.2f}")
|
|
for difference in result["differences"]:
|
|
print(f" - {difference}")
|
|
counts = {key: sum(1 for r in results if r["class"] == key)
|
|
for key in labels}
|
|
print(f"Summary: {counts['match']}/{len(results)} match, "
|
|
f"{counts['review']} review, {counts['bad']} diff "
|
|
f"(fail threshold confidence >= {args.fail_confidence:.2f})")
|
|
|
|
if args.dry_run:
|
|
return 0
|
|
return 1 if any(r["class"] == "bad" for r in results) else 0
|
|
|
|
|
|
def build_parser():
|
|
parser = argparse.ArgumentParser(
|
|
description="Generate and compare Gig Line screen-design references.")
|
|
commands = parser.add_subparsers(dest="command", required=True)
|
|
|
|
gen = commands.add_parser(
|
|
"generate", help="write reference design images to assets/images/ui/")
|
|
mode = gen.add_mutually_exclusive_group()
|
|
mode.add_argument("--draft", action="store_true",
|
|
help="draw deterministic wireframes (no API/network)")
|
|
mode.add_argument("--ai", action="store_true",
|
|
help="generate mockups via OpenRouter (default)")
|
|
gen.add_argument("--model", default=DEFAULT_MODEL)
|
|
gen.add_argument("--request-size", type=int,
|
|
default=generate_textures.GEN_SIZE)
|
|
gen.add_argument("--only", default="",
|
|
help="only generate screens whose name contains this text")
|
|
gen.add_argument("--force", action="store_true",
|
|
help="replace reference designs that already exist")
|
|
gen.set_defaults(handler=generate, draft=False)
|
|
|
|
judge_cmd = commands.add_parser(
|
|
"judge", help="ask a vision model whether captures match the designs")
|
|
judge_cmd.add_argument("--model", default=DEFAULT_JUDGE_MODEL)
|
|
judge_cmd.add_argument("--json", action="store_true")
|
|
judge_cmd.add_argument("--dry-run", action="store_true",
|
|
help="print requests instead of calling the API")
|
|
judge_cmd.add_argument("--only", default="",
|
|
help="only judge captures whose name contains this text")
|
|
judge_cmd.add_argument("--fail-confidence", type=float, default=0.6,
|
|
help="a mismatch at or above this confidence fails")
|
|
judge_cmd.set_defaults(handler=judge)
|
|
|
|
cmp = commands.add_parser(
|
|
"compare", help="compare docs/screenshots/*.png against the designs")
|
|
cmp.add_argument("--json", action="store_true")
|
|
cmp.set_defaults(handler=compare)
|
|
|
|
listing = commands.add_parser(
|
|
"list", help="print the screen catalogue and its state")
|
|
listing.set_defaults(handler=list_screens)
|
|
|
|
return parser
|
|
|
|
|
|
def main(argv=None):
|
|
args = build_parser().parse_args(argv)
|
|
try:
|
|
result = args.handler(args)
|
|
return result if isinstance(result, int) else 0
|
|
except (OSError, ValueError) as exc:
|
|
print(f"error: {exc}", file=sys.stderr)
|
|
return 2
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|