181 lines
7.1 KiB
Python
181 lines
7.1 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",
|
|
"npc_crowd_a",
|
|
"npc_crowd_b",
|
|
"npc_crowd_c",
|
|
"npc_crowd_d",
|
|
]
|
|
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, views: list[str] | None = None) -> Path:
|
|
"""Tile one capture per (variant, view). `views` defaults to the six baseline
|
|
views; the pose harness passes pose names instead (see the module docstring)."""
|
|
from PIL import Image, ImageDraw
|
|
|
|
views = views or VIEWS
|
|
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)")
|
|
sheet.add_argument("--views", default="",
|
|
help="comma-separated view tokens instead of the six baseline views "
|
|
"(pose harness: idle,walk_contact,walk_pass,sit)")
|
|
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)
|
|
views = [v.strip() for v in args.views.split(",") if v.strip()] or None
|
|
try:
|
|
written = build_sheet(source, output, views)
|
|
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())
|