#!/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())