Files
security-game/tools/npc_baseline.py
T
zwitschi e7ee177c31
Full build / Import + headless tests (push) Successful in 38s
Full build / Full desktop build + optional release (push) Skipped
Establish the 0.9.4 NPC visual baseline
Add a neutral 48-frame character contact sheet, acceptance criteria and ranked baseline gaps, plus repeatable sheet/comparison tooling. No character GLB or runtime mesh changes.
2026-11-02 18:13:57 +01:00

170 lines
6.5 KiB
Python

#!/usr/bin/env python3
"""Assemble the local NPC visual-baseline contact sheet.
The frames are produced by `src/test/capture_npc_baseline.gd`; this script only
labels and tiles them so the eight variants and six views can be reviewed in
one image. It is deliberately offline and has no game-engine dependency.
"""
from __future__ import annotations
import argparse
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
VARIANTS = [
"npc_casual",
"npc_jacket",
"npc_coat",
"npc_vest",
"npc_dress",
"npc_hoodie",
"npc_firefighter",
"npc_guard",
]
VIEWS = ["front", "three_quarter", "profile", "back", "full_body", "crowd"]
def expected_files() -> list[tuple[str, str, str]]:
"""Return (variant, view, filename) for the complete 8 x 6 baseline."""
return [(variant, view, f"{variant}__{view}.png")
for variant in VARIANTS for view in VIEWS]
def latest_for(directory: Path, stem: str) -> Path | None:
"""Return the newest capture for a stem, ignoring the no-overwrite -vN copies."""
candidates = [directory / f"{stem}.png"]
candidates.extend(directory.glob(f"{stem}-v*.png"))
existing = [path for path in candidates if path.is_file()]
if not existing:
return None
return max(existing, key=lambda path: path.stat().st_mtime_ns)
def next_free_path(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 build_sheet(source: Path, output: Path) -> Path:
from PIL import Image, ImageDraw
cell_w, cell_h = 320, 180
label_h = 22
header_h = 28
sheet_w = cell_w * len(VIEWS)
sheet_h = header_h + (cell_h + label_h) * len(VARIANTS)
sheet = Image.new("RGB", (sheet_w, sheet_h), (24, 26, 30))
draw = ImageDraw.Draw(sheet)
for column, view in enumerate(VIEWS):
draw.text((column * cell_w + 8, 8), view.replace("_", " ").title(),
fill=(220, 224, 230))
for row, variant in enumerate(VARIANTS):
for column, view in enumerate(VIEWS):
path = latest_for(source, f"{variant}__{view}")
if path is None:
raise FileNotFoundError(
f"missing baseline frame: {variant}__{view}.png in {source}")
with Image.open(path) as image:
tile = image.convert("RGB").resize((cell_w, cell_h),
Image.Resampling.LANCZOS)
x = column * cell_w
y = header_h + row * (cell_h + label_h)
sheet.paste(tile, (x, y))
draw.text((x + 8, y + cell_h + 4), variant, fill=(230, 230, 230))
output.parent.mkdir(parents=True, exist_ok=True)
sheet.save(output, quality=86, optimize=True)
return output
def build_comparison(before: Path, after: Path, output: Path) -> Path:
"""Tile the before and after frames as one review sheet.
The layout is variant-major, with each view represented by a before/after
pair. Frames are deliberately smaller than the single-run sheet so all 96
cells remain viewable on a normal desktop.
"""
from PIL import Image, ImageDraw
cell_w, cell_h = 200, 112
label_h = 16
header_h = 28
pair_w = cell_w * 2
sheet_w = pair_w * len(VIEWS)
sheet_h = header_h + (cell_h + label_h) * len(VARIANTS)
sheet = Image.new("RGB", (sheet_w, sheet_h), (24, 26, 30))
draw = ImageDraw.Draw(sheet)
for column, view in enumerate(VIEWS):
x = column * pair_w
draw.text((x + 4, 8), f"{view.replace('_', ' ').title()} before",
fill=(220, 224, 230))
draw.text((x + cell_w + 4, 8), "after", fill=(220, 224, 230))
for row, variant in enumerate(VARIANTS):
for column, view in enumerate(VIEWS):
for offset, source in enumerate((before, after)):
path = latest_for(source, f"{variant}__{view}")
if path is None:
raise FileNotFoundError(
f"missing {variant}__{view}.png in {source}")
with Image.open(path) as image:
tile = image.convert("RGB").resize((cell_w, cell_h),
Image.Resampling.LANCZOS)
x = column * pair_w + offset * cell_w
y = header_h + row * (cell_h + label_h)
sheet.paste(tile, (x, y))
draw.text((x + 4, y + cell_h + 2), variant, fill=(230, 230, 230))
output.parent.mkdir(parents=True, exist_ok=True)
sheet.save(output, quality=86, optimize=True)
return output
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="NPC baseline contact sheet")
sub = parser.add_subparsers(dest="command", required=True)
sheet = sub.add_parser("sheet")
sheet.add_argument("--dir", required=True, type=Path,
help="directory containing the capture_npc_baseline.gd PNGs")
sheet.add_argument("--out", type=Path, default=None,
help="output JPEG (default: <dir>/sheet.jpg; never overwritten)")
compare = sub.add_parser("compare")
compare.add_argument("--before", required=True, type=Path)
compare.add_argument("--after", required=True, type=Path)
compare.add_argument("--out", type=Path, required=True)
args = parser.parse_args(argv)
if args.command == "sheet":
source = args.dir if args.dir.is_absolute() else ROOT / args.dir
output = args.out or source / "sheet.jpg"
if not output.is_absolute():
output = ROOT / output
output = next_free_path(output)
try:
written = build_sheet(source, output)
except FileNotFoundError as exc:
print(exc)
return 2
print(f"wrote {written.relative_to(ROOT)}")
return 0
if args.command == "compare":
before = args.before if args.before.is_absolute() else ROOT / args.before
after = args.after if args.after.is_absolute() else ROOT / args.after
output = args.out if args.out.is_absolute() else ROOT / args.out
output = next_free_path(output)
try:
written = build_comparison(before, after, output)
except FileNotFoundError as exc:
print(exc)
return 2
print(f"wrote {written.relative_to(ROOT)}")
return 0
return 1
if __name__ == "__main__":
raise SystemExit(main())