#!/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())