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.
This commit is contained in:
1 parent
c1cd4898e8
commit
e7ee177c31
13 files changed
+628
-11
No files matched your search
@@ -0,0 +1,169 @@
|
||||
#!/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())
|
||||
Reference in new issue
Block a user