#!/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: