Gives Claude eyes: `make screenshot` captures a rendered frame, `make test-visual` compares against golden PNGs, `make visual-update` regenerates goldens. Built to debug the Sprint 22 fog regression and prevent future visual regressions across fog, HUD, dialogue, and UI. Config-driven via tests/visual.json (11 scenarios, 2 flows). Capture engine boots main.tscn with real GPU rendering (not --headless), waits for NoiseTexture2D async gen, uses deterministic shader time. Components: - visual_capture.gd: SceneTree capture engine (scenario + movie modes) - visual_scenarios.gd: per-scenario setup hooks - tooling/visual-diff: pixel comparator (PIL primary, struct fallback) - tooling/visual-thumbnail: contact sheet + crop tool - tests/run-visual: suite script (xvfb wrapping, golden workflow) - fog_state.gd: override_time for deterministic captures - fog_shader.gd: fog_noise_ready signal for settle sequencing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
218 lines
6.6 KiB
Python
Executable File
218 lines
6.6 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Contact sheet and crop tool for visual QA flow captures.
|
|
|
|
Two modes:
|
|
|
|
Grid mode (default):
|
|
tooling/visual-thumbnail DIR [--config PATH]
|
|
Reads {flow}_{NNN}.png frames + {flow}_manifest.txt sidecar from DIR,
|
|
generates a contact sheet grid with timecodes and labels.
|
|
Output: DIR/{flow}_sheet.png
|
|
|
|
Crop mode:
|
|
tooling/visual-thumbnail --crop REGION IMAGE [--config PATH]
|
|
Extracts a named region from IMAGE at 1:1 scale.
|
|
Output: IMAGE_crop_{REGION}.png
|
|
|
|
Config: reads thumbnail dimensions, columns, and crop regions from
|
|
tests/visual.json (auto-detected from script location, or --config).
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
try:
|
|
from PIL import Image, ImageDraw
|
|
except ImportError:
|
|
print("visual-thumbnail requires Pillow: pip install Pillow", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
DEFAULT_CONFIG = ROOT / "tests" / "visual.json"
|
|
|
|
# Fallbacks when config keys are missing
|
|
DEFAULT_THUMB_WIDTH = 320
|
|
DEFAULT_THUMB_HEIGHT = 180
|
|
DEFAULT_COLUMNS = 4
|
|
LABEL_HEIGHT = 24 # pixels reserved below each thumbnail for text
|
|
|
|
|
|
def load_config(config_path: Path) -> dict:
|
|
"""Load configuration from JSON file."""
|
|
if not config_path.exists():
|
|
print(f"Config not found: {config_path}", file=sys.stderr)
|
|
print("Continuing with built-in defaults.", file=sys.stderr)
|
|
return {}
|
|
with open(config_path) as f:
|
|
return json.load(f)
|
|
|
|
|
|
def parse_manifest(manifest_path: Path) -> list[dict]:
|
|
"""Parse a flow manifest file.
|
|
|
|
Each line: NNN TIMECODE LABEL
|
|
Example: 001 0:03 dialogue opens
|
|
"""
|
|
entries = []
|
|
with open(manifest_path) as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if not line or line.startswith("#"):
|
|
continue
|
|
parts = line.split(None, 2)
|
|
if len(parts) < 2:
|
|
continue
|
|
entry = {
|
|
"frame": parts[0],
|
|
"timecode": parts[1],
|
|
"label": parts[2] if len(parts) > 2 else "",
|
|
}
|
|
entries.append(entry)
|
|
return entries
|
|
|
|
|
|
def detect_flow(directory: Path) -> str | None:
|
|
"""Detect flow name from manifest sidecar in directory."""
|
|
manifests = list(directory.glob("*_manifest.txt"))
|
|
if len(manifests) == 1:
|
|
# {flow}_manifest.txt -> flow
|
|
stem = manifests[0].stem
|
|
return stem.removesuffix("_manifest")
|
|
if len(manifests) > 1:
|
|
print(f"Multiple manifests found in {directory}:", file=sys.stderr)
|
|
for m in manifests:
|
|
print(f" {m.name}", file=sys.stderr)
|
|
return None
|
|
return None
|
|
|
|
|
|
def grid_mode(directory: Path, config: dict) -> int:
|
|
"""Generate a contact sheet from flow captures."""
|
|
directory = directory.resolve()
|
|
if not directory.is_dir():
|
|
print(f"Not a directory: {directory}", file=sys.stderr)
|
|
return 1
|
|
|
|
flow = detect_flow(directory)
|
|
if flow is None:
|
|
print(f"No manifest found in {directory}. Expected {{flow}}_manifest.txt", file=sys.stderr)
|
|
return 1
|
|
|
|
manifest_path = directory / f"{flow}_manifest.txt"
|
|
entries = parse_manifest(manifest_path)
|
|
if not entries:
|
|
print(f"Empty manifest: {manifest_path}", file=sys.stderr)
|
|
return 1
|
|
|
|
# Read thumbnail config
|
|
thumb_cfg = config.get("thumbnail", {})
|
|
tw = thumb_cfg.get("width", DEFAULT_THUMB_WIDTH)
|
|
th = thumb_cfg.get("height", DEFAULT_THUMB_HEIGHT)
|
|
cols = thumb_cfg.get("columns", DEFAULT_COLUMNS)
|
|
|
|
rows = (len(entries) + cols - 1) // cols
|
|
cell_h = th + LABEL_HEIGHT
|
|
|
|
sheet_w = tw * cols
|
|
sheet_h = cell_h * rows
|
|
sheet = Image.new("RGB", (sheet_w, sheet_h), color=(30, 30, 30))
|
|
draw = ImageDraw.Draw(sheet)
|
|
|
|
for idx, entry in enumerate(entries):
|
|
frame_file = directory / f"{flow}_{entry['frame']}.png"
|
|
if not frame_file.exists():
|
|
print(f" Missing frame: {frame_file.name}", file=sys.stderr)
|
|
continue
|
|
|
|
img = Image.open(frame_file)
|
|
img.thumbnail((tw, th), Image.LANCZOS)
|
|
|
|
col = idx % cols
|
|
row = idx // cols
|
|
x = col * tw
|
|
y = row * cell_h
|
|
|
|
# Center thumbnail within its cell if it's smaller than tw x th
|
|
offset_x = x + (tw - img.width) // 2
|
|
offset_y = y + (th - img.height) // 2
|
|
sheet.paste(img, (offset_x, offset_y))
|
|
|
|
# Draw timecode + label below thumbnail
|
|
text = entry["timecode"]
|
|
if entry["label"]:
|
|
text += f" {entry['label']}"
|
|
text_y = y + th + 2
|
|
draw.text((x + 4, text_y), text, fill=(200, 200, 200))
|
|
|
|
output_path = directory / f"{flow}_sheet.png"
|
|
sheet.save(output_path)
|
|
print(f"Sheet: {output_path}")
|
|
return 0
|
|
|
|
|
|
def crop_mode(region_name: str, image_path: Path, config: dict) -> int:
|
|
"""Extract a named crop region from an image at 1:1 scale."""
|
|
image_path = image_path.resolve()
|
|
if not image_path.exists():
|
|
print(f"Image not found: {image_path}", file=sys.stderr)
|
|
return 1
|
|
|
|
crops = config.get("crops", {})
|
|
if region_name not in crops:
|
|
available = ", ".join(sorted(crops.keys())) if crops else "(none)"
|
|
print(f"Unknown crop region: {region_name}", file=sys.stderr)
|
|
print(f"Available regions: {available}", file=sys.stderr)
|
|
return 1
|
|
|
|
coords = crops[region_name]
|
|
if not isinstance(coords, list) or len(coords) != 4:
|
|
print(f"Invalid crop coords for '{region_name}': expected [x, y, w, h]", file=sys.stderr)
|
|
return 1
|
|
|
|
x, y, w, h = coords
|
|
img = Image.open(image_path)
|
|
cropped = img.crop((x, y, x + w, y + h))
|
|
|
|
stem = image_path.stem
|
|
suffix = image_path.suffix
|
|
output_path = image_path.parent / f"{stem}_crop_{region_name}{suffix}"
|
|
cropped.save(output_path)
|
|
print(output_path)
|
|
return 0
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(
|
|
description="Contact sheet and crop tool for visual QA flow captures.",
|
|
)
|
|
parser.add_argument(
|
|
"--config", type=Path, default=DEFAULT_CONFIG,
|
|
help=f"Config JSON path (default: {DEFAULT_CONFIG.relative_to(ROOT)})",
|
|
)
|
|
|
|
# Crop mode
|
|
parser.add_argument(
|
|
"--crop", metavar="REGION",
|
|
help="Crop mode: extract named region from IMAGE",
|
|
)
|
|
|
|
# Positional: DIR (grid mode) or IMAGE (crop mode)
|
|
parser.add_argument(
|
|
"target", type=Path,
|
|
help="Directory of flow captures (grid mode) or image file (crop mode)",
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
config = load_config(args.config)
|
|
|
|
if args.crop:
|
|
return crop_mode(args.crop, args.target, config)
|
|
else:
|
|
return grid_mode(args.target, config)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|