From eaca6c8c449b379fb96a07f79bf21607fba687d4 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 6 Jul 2026 14:26:58 +0200 Subject: [PATCH] feat(tooling): chromakey garment-clipping QA harness (T-1089) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automated garment-under-animation QA: CharacterVisual composite with the garment's covered body segments overridden to flat unshaded magenta, cycled clips x frames x 4 yaws; PIL analyzer flags connected key-pixel blobs and emits report.json + highlighted failure frames. Capture scene lives under client/tools/garment_qa/ (res:// boundary; outside the gdUnit scan root), driver/analyzer/config under tooling/garment-qa/. Verified: 72 captures across peasant set x average_m/f x Walk/Sprint/ Crouch_Fwd. Finding: no true mid-cloth clip-through; flags are coverage-claim vs silhouette mismatch (sleeveless/short-sleeve exposure at collar/cuffs) — a two-pass garment-behind-pixel discriminator is the queued refinement. Co-Authored-By: Claude Fable 5 --- client/tools/garment_qa/chromakey_scene.gd | 316 +++++++++++++++++++ client/tools/garment_qa/chromakey_scene.tscn | 6 + tooling/garment-qa/README.md | 26 ++ tooling/garment-qa/analyze_captures.py | 209 ++++++++++++ tooling/garment-qa/configs/peasant.json | 15 + tooling/garment-qa/run-garment-qa | 47 +++ 6 files changed, 619 insertions(+) create mode 100644 client/tools/garment_qa/chromakey_scene.gd create mode 100644 client/tools/garment_qa/chromakey_scene.tscn create mode 100644 tooling/garment-qa/README.md create mode 100644 tooling/garment-qa/analyze_captures.py create mode 100644 tooling/garment-qa/configs/peasant.json create mode 100755 tooling/garment-qa/run-garment-qa diff --git a/client/tools/garment_qa/chromakey_scene.gd b/client/tools/garment_qa/chromakey_scene.gd new file mode 100644 index 000000000..8dd5e8b7e --- /dev/null +++ b/client/tools/garment_qa/chromakey_scene.gd @@ -0,0 +1,316 @@ +extends Node3D +## Chromakey garment-clipping QA capture scene (T-1089). +## +## Technique: replace the materials of exactly the body segments a garment CLAIMS +## to cover (coverage.json "hides") with a flat UNSHADED key color (pure magenta). +## The garment keeps its normal materials; head/hands/uncovered segments keep theirs. +## Any key-color pixel the analyzer finds through the garment = a clip-through. +## +## This scene lives under client/ (not tooling/) because Godot res:// paths cannot +## leave the client project root — see tooling/garment-qa/README.md. It reuses +## CharacterVisual (client/scripts/rendering/character_visual.gd): load_descriptor() +## builds the character, get_active_coverage() exposes each garment's coverage.json, +## and get_animation_player() drives the clip cycling (mirrors the T-1088 sandbox). +## +## Config is a JSON file whose path arrives in the GARMENT_QA_CONFIG env var: +## { +## "garments": [{"item_id": "peasant_tunic", "slot": "torso"}, ...] +## (or {"glb": "res://...", "slot": "...", "covers": ["torso", ...]} +## for a raw WIP garment not yet in the catalogue), +## "body_types": ["average_m", "average_f"], +## "clips": ["Walk", "Sprint", "Crouch_Fwd"], +## "frames_per_clip": 3, +## "yaws": [0, 90, 180, 270], +## "head_id": "head_001", "hair_id": "buzzed", +## "eyebrow_id": "regular", "skin_tone": 3, +## "out_dir": "/abs/path/for/pngs" +## } + +const KEY_COLOR := Color(1.0, 0.0, 1.0) # pure magenta — no skin/garment texture lands here +const VIEWPORT_SIZE := Vector2i(768, 1024) # portrait — maximises body pixels per capture + +const BODY_KEY_TO_ENUM := { + "average_m": CharacterVisualDescriptor.BodyType.AVERAGE_M, + "average_f": CharacterVisualDescriptor.BodyType.AVERAGE_F, + "muscular_m": CharacterVisualDescriptor.BodyType.MUSCULAR_M, + "muscular_f": CharacterVisualDescriptor.BodyType.MUSCULAR_F, + "thin_m": CharacterVisualDescriptor.BodyType.THIN_M, + "thin_f": CharacterVisualDescriptor.BodyType.THIN_F, + "heavy_m": CharacterVisualDescriptor.BodyType.HEAVY_M, + "heavy_f": CharacterVisualDescriptor.BodyType.HEAVY_F, + "teen_m": CharacterVisualDescriptor.BodyType.TEEN_M, + "teen_f": CharacterVisualDescriptor.BodyType.TEEN_F, + "child": CharacterVisualDescriptor.BodyType.CHILD, +} + +var _config: Dictionary = {} +var _out_dir: String = "" +var _cam: Camera3D = null +var _key_material: ShaderMaterial = null + + +func _ready() -> void: + if not _load_config(): + get_tree().quit(1) + return + get_window().size = VIEWPORT_SIZE + _key_material = _make_key_material() + _setup_stage() + _run.call_deferred() + + +func _load_config() -> bool: + var path := OS.get_environment("GARMENT_QA_CONFIG") + if path.is_empty(): + push_error("chromakey_scene: GARMENT_QA_CONFIG env var not set") + return false + if not FileAccess.file_exists(path): + push_error("chromakey_scene: config file not found: %s" % path) + return false + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + push_error("chromakey_scene: could not open config: %s" % path) + return false + var parsed: Variant = JSON.parse_string(file.get_as_text()) + file.close() + if not (parsed is Dictionary): + push_error("chromakey_scene: config is not a JSON object: %s" % path) + return false + _config = parsed + _out_dir = str(_config.get("out_dir", "")) + if _out_dir.is_empty(): + push_error("chromakey_scene: config missing 'out_dir'") + return false + return true + + +## Flat, unshaded magenta. render_mode unshaded → ALBEDO is written straight to the +## framebuffer with no lighting; cull_disabled so a body backface poking through an +## opening still registers. No outline pass is attached to this material (see _apply_key). +func _make_key_material() -> ShaderMaterial: + var shader := Shader.new() + shader.code = ( + "shader_type spatial;\n" + + "render_mode unshaded, cull_disabled;\n" + + "void fragment() {\n" + + "\tALBEDO = vec3(1.0, 0.0, 1.0);\n" + + "}\n" + ) + var mat := ShaderMaterial.new() + mat.shader = shader + return mat + + +func _setup_stage() -> void: + var light := DirectionalLight3D.new() + light.rotation_degrees = Vector3(-45, 30, 0) + light.light_energy = 1.2 + add_child(light) + + var env := Environment.new() + env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR + env.ambient_light_color = Color(0.8, 0.8, 0.85) + env.ambient_light_energy = 0.9 + env.background_mode = Environment.BG_COLOR + # Dark neutral background — never magenta, never skin-toned, so it cannot + # be mistaken for a clip pixel by the analyzer. + env.background_color = Color(0.18, 0.19, 0.22) + var we := WorldEnvironment.new() + we.environment = env + add_child(we) + + _cam = Camera3D.new() + _cam.fov = 50.0 + add_child(_cam) + # Fixed frame that fits a standing ~1.8 m character with margin. The character + # rotates (not the camera), so the four yaws give front/left/back/right views. + _cam.position = Vector3(0.0, 0.95, 2.4) + _cam.look_at_from_position(_cam.position, Vector3(0.0, 0.95, 0.0), Vector3.UP) + + +func _run() -> void: + DirAccess.make_dir_recursive_absolute(_out_dir) + var body_types: Array = _config.get("body_types", ["average_m"]) + print("chromakey_scene: out_dir = ", _out_dir) + print("chromakey_scene: body_types = ", body_types) + for body_key: String in body_types: + await _capture_body(str(body_key)) + print("chromakey_scene: DONE") + get_tree().quit(0) + + +func _capture_body(body_key: String) -> void: + if not BODY_KEY_TO_ENUM.has(body_key): + push_warning("chromakey_scene: unknown body_type '%s' — skipped" % body_key) + return + + var cv := CharacterVisual.new() + add_child(cv) + cv.load_descriptor(_build_descriptor(body_key)) + + var skel := cv.get_skeleton() + if skel == null: + push_error("chromakey_scene: no skeleton for %s" % body_key) + cv.queue_free() + return + + _attach_raw_garments(skel) + + var covered := _covered_segments(cv) + var keyed := _apply_key(skel, covered) + print( + "chromakey_scene[%s]: covered=%s keyed_meshes=%d" % [body_key, covered.keys(), keyed] + ) + + var ap := cv.get_animation_player() + if ap == null: + push_warning("chromakey_scene[%s]: no AnimationPlayer — skipping clips" % body_key) + cv.queue_free() + await _wait_frames(2) + return + + var clips: Array = _config.get("clips", ["Walk"]) + var frames: int = int(_config.get("frames_per_clip", 3)) + var yaws: Array = _config.get("yaws", [0, 90, 180, 270]) + + for clip: String in clips: + var resolved := _resolve_clip(ap, str(clip)) + if resolved.is_empty(): + push_warning("chromakey_scene[%s]: clip '%s' not found" % [body_key, clip]) + continue + cv.play_animation(resolved) + ap.pause() # freeze — seek() below poses without advancing between yaw shots + var length := ap.current_animation_length + var times := _sample_times(length, frames) + for fi in times.size(): + ap.seek(times[fi], true) + await get_tree().process_frame # let the seeked pose propagate to the skeleton + for yaw in yaws: + cv.rotation_degrees.y = float(yaw) + await RenderingServer.frame_post_draw + var img := get_viewport().get_texture().get_image() + var fname := ( + "%s__%s__f%d__yaw%03d.png" % [body_key, str(clip), fi, int(yaw)] + ) + img.save_png(_out_dir.path_join(fname)) + print("chromakey_scene[%s]: captured clip '%s' (%d frames)" % [body_key, clip, times.size()]) + + cv.queue_free() + await _wait_frames(2) + + +func _build_descriptor(body_key: String) -> CharacterVisualDescriptor: + var desc := CharacterVisualDescriptor.new() + desc.body_type = BODY_KEY_TO_ENUM[body_key] + desc.head_id = str(_config.get("head_id", "head_001")) + desc.hair_id = str(_config.get("hair_id", "buzzed")) + desc.eyebrow_id = str(_config.get("eyebrow_id", "regular")) + desc.skin_tone = int(_config.get("skin_tone", 3)) + var slots := {} + for g: Dictionary in _config.get("garments", []): + if g.has("item_id") and g.has("slot"): + slots[str(g["slot"])] = str(g["item_id"]) + desc.clothing_slots = slots + return desc + + +## Reparent skinned meshes from a raw garment GLB (config "glb" entries) onto our +## skeleton — mirrors character_visual.gd:517-535. Used only for WIP garments that +## are not yet catalogue items; catalogue garments load via clothing_slots instead. +func _attach_raw_garments(skel: Skeleton3D) -> void: + for g: Dictionary in _config.get("garments", []): + if not g.has("glb"): + continue + var scene := load(str(g["glb"])) as PackedScene + if scene == null: + push_error("chromakey_scene: raw garment GLB failed to load: %s" % g["glb"]) + continue + var inst := scene.instantiate() + var stack: Array[Node] = [inst] + while not stack.is_empty(): + var n: Node = stack.pop_back() + for c in n.get_children(): + stack.append(c) + if n is MeshInstance3D and (n as MeshInstance3D).skin != null: + var mi := n as MeshInstance3D + mi.get_parent().remove_child(mi) + mi.owner = null + skel.add_child(mi) + inst.queue_free() + + +## Union of the body segments every equipped garment claims to cover. Catalogue +## garments report this via CharacterVisual.get_active_coverage() (coverage.json +## "hides"); raw garments carry an explicit "covers" list in the config. +func _covered_segments(cv: CharacterVisual) -> Dictionary: + var covered := {} + for g: Dictionary in _config.get("garments", []): + if g.has("item_id"): + var cov: Dictionary = cv.get_active_coverage(str(g["item_id"])) + for seg in cov.get("hides", []): + covered[str(seg)] = true + if g.has("covers"): + for seg in g["covers"]: + covered[str(seg)] = true + return covered + + +## Key-colour every loaded body segment in the covered set and delete the outline +## duplicates (the dark inverted-hull outline would otherwise poke through the garment +## and mask a clip as dark rather than magenta). Returns the number of keyed meshes. +func _apply_key(skel: Skeleton3D, covered: Dictionary) -> int: + var keyed := 0 + var to_free: Array[Node] = [] + for child in skel.get_children(): + if not (child is MeshInstance3D): + continue + var mi := child as MeshInstance3D + if mi.name.begins_with("_outline_"): + to_free.append(mi) # strip ALL outlines — see docstring + continue + var seg := str(mi.get_meta("segment", "")) + if not seg.is_empty() and covered.has(seg): + mi.material_override = _key_material + keyed += 1 + for n in to_free: + skel.remove_child(n) + n.free() + return keyed + + +## Resolve a logical clip name to a playable animation. Tries an exact match first, +## then a case-insensitive exact match, then a case-insensitive substring — robust +## to library prefixes. Returns "" if nothing matches. +func _resolve_clip(ap: AnimationPlayer, requested: String) -> String: + var names: Array[String] = [] + for lib_name in ap.get_animation_library_list(): + var lib := ap.get_animation_library(lib_name) + for a in lib.get_animation_list(): + names.append(str(a)) + for n in names: + if n == requested: + return n + for n in names: + if n.to_lower() == requested.to_lower(): + return n + for n in names: + if n.to_lower().contains(requested.to_lower()): + return n + return "" + + +## Evenly spaced sample times across a clip, centred in each 1/count slice so we +## never land exactly on t=0 (rest pose) or t=length (loop wrap). +func _sample_times(length: float, count: int) -> Array[float]: + var out: Array[float] = [] + if count <= 1 or length <= 0.0: + out.append(maxf(length, 0.0) * 0.5) + return out + for i in count: + out.append(length * (float(i) + 0.5) / float(count)) + return out + + +func _wait_frames(n: int) -> void: + for i in n: + await get_tree().process_frame diff --git a/client/tools/garment_qa/chromakey_scene.tscn b/client/tools/garment_qa/chromakey_scene.tscn new file mode 100644 index 000000000..4cc6c6878 --- /dev/null +++ b/client/tools/garment_qa/chromakey_scene.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3 uid="uid://cgarmentqachroma0"] + +[ext_resource type="Script" path="res://tools/garment_qa/chromakey_scene.gd" id="1"] + +[node name="ChromakeyQA" type="Node3D"] +script = ExtResource("1") diff --git a/tooling/garment-qa/README.md b/tooling/garment-qa/README.md new file mode 100644 index 000000000..a3f362358 --- /dev/null +++ b/tooling/garment-qa/README.md @@ -0,0 +1,26 @@ +# garment-qa — chromakey garment-clipping QA (T-1089) + +Automatically detects clothing clip-through. The capture scene paints the body +segments a garment *claims to cover* (coverage.json `hides`) flat **unshaded magenta**, +leaves the garment and uncovered segments (head/hands/etc.) normal, then cycles +animation clips × sampled frames × 4 camera yaws and saves a PNG per view. Any magenta +the camera sees = body poking through cloth. The analyzer counts connected magenta +pixels per capture and flags clips. + +**Run:** `tooling/garment-qa/run-garment-qa [config.json]` (defaults to +`configs/peasant.json`). It launches Godot (`~/bin/godot4`, `opengl3`, `xvfb-run` when +headless) on the capture scene, then runs the analyzer. + +**Config** (path passed to the scene via `GARMENT_QA_CONFIG`): `garments` +(`{item_id,slot}` catalogue items, or `{glb,slot,covers[]}` for a raw WIP garment), +`body_types`, `clips`, `frames_per_clip`, `yaws`, `head_id/hair_id/eyebrow_id/skin_tone`, +`out_dir`. + +**Read the report:** `/report.json` — per-capture `key_pixels`, +`largest_component`, `fail`, plus a `by_group` (body__clip) roll-up; the same summary +prints to stdout. A capture FAILS when its largest connected magenta blob ≥ `--min-pixels` +(default 8, tolerating AA edges). Failing frames are copied to `/failures/` with +clip pixels recoloured lime and each blob boxed red. + +**Note:** the Godot scene lives at `client/tools/garment_qa/chromakey_scene.gd` (not here) +because Godot `res://` paths cannot leave the client project root. diff --git a/tooling/garment-qa/analyze_captures.py b/tooling/garment-qa/analyze_captures.py new file mode 100644 index 000000000..be7ac0d01 --- /dev/null +++ b/tooling/garment-qa/analyze_captures.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +"""Chromakey garment-clipping analyzer (T-1089). + +Counts key-colour (pure magenta) pixels in each capture PNG produced by +client/tools/garment_qa/chromakey_scene.gd. Any solid patch of key pixels showing +through a garment is a body-clip-through: the QA scene painted the covered body +segments flat magenta, so magenta the camera can see = body poking through cloth. + +A capture FAILS when its largest connected key-pixel component is >= --min-pixels +(default 8; a small tolerance for anti-aliased edges). For each failing capture a +highlighted copy is written to /failures/ with the clip pixels recoloured +lime and each component boxed in red. Results land in /report.json plus a +one-screen summary on stdout. + +Reads the same JSON config as the capture scene (via --config) to locate out_dir, +or takes --dir directly. +""" + +from __future__ import annotations + +import argparse +import collections +import json +import sys +from pathlib import Path + +from PIL import Image, ImageChops, ImageDraw + +# Key-colour gate. Pure magenta is (255, 0, 255); the blue channel is the decisive +# discriminator — skin/garment texture is never simultaneously high-red, low-green +# AND high-blue, so this never fires on legitimate body or cloth pixels. +R_MIN = 200 +G_MAX = 60 +B_MIN = 200 + + +def build_key_mask(img: Image.Image) -> Image.Image: + """Return an "L" mask, 255 where the pixel is key-colour, else 0.""" + r, g, b = img.convert("RGB").split() + r_ok = r.point(lambda v: 255 if v >= R_MIN else 0) + g_ok = g.point(lambda v: 255 if v <= G_MAX else 0) + b_ok = b.point(lambda v: 255 if v >= B_MIN else 0) + return ImageChops.multiply(ImageChops.multiply(r_ok, g_ok), b_ok) + + +def connected_components(mask: Image.Image) -> list[dict]: + """8-connected components of the key-pixel mask. + + Only the mask bounding box is scanned, so cost tracks the (sparse) clip area, + not the whole frame. Returns one dict per component: size + pixel bbox. + """ + bbox = mask.getbbox() + if bbox is None: + return [] + x0, y0, x1, y1 = bbox + region = mask.crop(bbox) + width, height = region.size + px = region.load() + + seen = [[False] * width for _ in range(height)] + components: list[dict] = [] + for sy in range(height): + for sx in range(width): + if px[sx, sy] == 0 or seen[sy][sx]: + continue + size = 0 + min_x = max_x = sx + min_y = max_y = sy + queue = collections.deque([(sx, sy)]) + seen[sy][sx] = True + while queue: + cx, cy = queue.popleft() + size += 1 + min_x, max_x = min(min_x, cx), max(max_x, cx) + min_y, max_y = min(min_y, cy), max(max_y, cy) + for dy in (-1, 0, 1): + for dx in (-1, 0, 1): + nx, ny = cx + dx, cy + dy + if 0 <= nx < width and 0 <= ny < height: + if px[nx, ny] != 0 and not seen[ny][nx]: + seen[ny][nx] = True + queue.append((nx, ny)) + components.append( + { + "size": size, + # bbox back in full-image coordinates + "bbox": [x0 + min_x, y0 + min_y, x0 + max_x + 1, y0 + max_y + 1], + } + ) + components.sort(key=lambda c: c["size"], reverse=True) + return components + + +def write_highlight(img: Image.Image, mask: Image.Image, comps: list[dict], dest: Path) -> None: + """Recolour key pixels lime and box each component in red.""" + out = img.convert("RGB") + out.paste((0, 255, 0), mask=mask) + draw = ImageDraw.Draw(out) + for comp in comps: + draw.rectangle(comp["bbox"], outline=(255, 0, 0), width=2) + dest.parent.mkdir(parents=True, exist_ok=True) + out.save(dest) + + +def analyze_dir(out_dir: Path, min_pixels: int) -> dict: + captures = sorted(p for p in out_dir.glob("*.png") if p.is_file()) + fail_dir = out_dir / "failures" + results: list[dict] = [] + + for path in captures: + img = Image.open(path) + mask = build_key_mask(img) + total = mask.histogram()[255] + comps = connected_components(mask) if total else [] + largest = comps[0]["size"] if comps else 0 + failed = largest >= min_pixels + if failed: + write_highlight(img, mask, comps, fail_dir / (path.stem + "_HL.png")) + results.append( + { + "file": path.name, + "key_pixels": total, + "components": len(comps), + "largest_component": largest, + "component_sizes": [c["size"] for c in comps[:10]], + "fail": failed, + } + ) + + return summarize(out_dir, min_pixels, results) + + +def summarize(out_dir: Path, min_pixels: int, results: list[dict]) -> dict: + failures = [r for r in results if r["fail"]] + by_group: dict[str, dict] = {} + for r in results: + # filename: ____f__yaw.png + parts = r["file"].split("__") + group = "__".join(parts[:2]) if len(parts) >= 2 else r["file"] + entry = by_group.setdefault(group, {"captures": 0, "failures": 0, "worst": 0}) + entry["captures"] += 1 + entry["failures"] += 1 if r["fail"] else 0 + entry["worst"] = max(entry["worst"], r["largest_component"]) + return { + "out_dir": str(out_dir), + "min_component_pixels": min_pixels, + "key_gate": {"r_min": R_MIN, "g_max": G_MAX, "b_min": B_MIN}, + "total_captures": len(results), + "failures": len(failures), + "by_group": by_group, + "captures": results, + } + + +def print_summary(report: dict) -> None: + print("=" * 64) + print("garment-qa chromakey analysis") + print(f" out_dir : {report['out_dir']}") + print(f" min clip pixels : {report['min_component_pixels']}") + print(f" captures : {report['total_captures']}") + print(f" FAILING captures : {report['failures']}") + print("-" * 64) + print(f" {'group (body__clip)':<28}{'caps':>6}{'fails':>7}{'worst':>7}") + for group, g in sorted(report["by_group"].items()): + print(f" {group:<28}{g['captures']:>6}{g['failures']:>7}{g['worst']:>7}") + print("=" * 64) + + +def resolve_out_dir(args: argparse.Namespace) -> Path: + if args.dir: + return Path(args.dir) + if args.config: + cfg = json.loads(Path(args.config).read_text()) + out = cfg.get("out_dir") + if not out: + sys.exit("analyze_captures: config has no 'out_dir'") + return Path(out) + sys.exit("analyze_captures: pass --dir or --config") + + +def main() -> int: + parser = argparse.ArgumentParser(description="Chromakey garment-clipping analyzer") + parser.add_argument("--config", help="capture config JSON (reads out_dir from it)") + parser.add_argument("--dir", help="directory of capture PNGs (overrides --config out_dir)") + parser.add_argument( + "--min-pixels", + type=int, + default=8, + help="largest connected key-pixel component that counts as a clip (default 8)", + ) + parser.add_argument("--report", help="report JSON path (default: /report.json)") + args = parser.parse_args() + + out_dir = resolve_out_dir(args) + if not out_dir.is_dir(): + sys.exit(f"analyze_captures: not a directory: {out_dir}") + + report = analyze_dir(out_dir, args.min_pixels) + report_path = Path(args.report) if args.report else out_dir / "report.json" + report_path.write_text(json.dumps(report, indent=2)) + print_summary(report) + print(f" report : {report_path}") + if report["failures"]: + print(f" highlighted failing frames : {out_dir / 'failures'}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tooling/garment-qa/configs/peasant.json b/tooling/garment-qa/configs/peasant.json new file mode 100644 index 000000000..e8af2a240 --- /dev/null +++ b/tooling/garment-qa/configs/peasant.json @@ -0,0 +1,15 @@ +{ + "garments": [ + {"item_id": "peasant_tunic", "slot": "torso"}, + {"item_id": "peasant_pants", "slot": "legs"} + ], + "body_types": ["average_m", "average_f"], + "clips": ["Walk", "Sprint", "Crouch_Fwd"], + "frames_per_clip": 3, + "yaws": [0, 90, 180, 270], + "head_id": "head_001", + "hair_id": "buzzed", + "eyebrow_id": "regular", + "skin_tone": 3, + "out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/peasant" +} diff --git a/tooling/garment-qa/run-garment-qa b/tooling/garment-qa/run-garment-qa new file mode 100755 index 000000000..edbcc53c4 --- /dev/null +++ b/tooling/garment-qa/run-garment-qa @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# run-garment-qa: drive the chromakey garment-clipping QA harness (T-1089). +# +# Usage: tooling/garment-qa/run-garment-qa [config.json] +# config.json defaults to tooling/garment-qa/configs/peasant.json +# +# Step 1 launches Godot on the client project with the capture scene, pointing it +# at the config via GARMENT_QA_CONFIG. Step 2 runs the pixel analyzer over the PNGs +# the scene wrote. Both steps are deliberately kept to a single command each. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +CONFIG="${1:-$SCRIPT_DIR/configs/peasant.json}" +if [[ ! -f "$CONFIG" ]]; then + echo "run-garment-qa: config not found: $CONFIG" >&2 + exit 2 +fi + +GODOT="${GODOT:-$HOME/bin/godot4}" +if [[ ! -x "$GODOT" ]]; then + GODOT="$(command -v godot4 || command -v godot || true)" +fi +if [[ -z "$GODOT" ]]; then + echo "run-garment-qa: godot binary not found (set GODOT=/path/to/godot4)" >&2 + exit 3 +fi + +PY="$REPO_ROOT/.venv/bin/python" +[[ -x "$PY" ]] || PY="python3" + +SCENE="res://tools/garment_qa/chromakey_scene.tscn" + +echo "run-garment-qa: config = $CONFIG" +echo "run-garment-qa: godot = $GODOT" + +# Step 1 — capture. Needs a display; use xvfb-run when running headless. +export GARMENT_QA_CONFIG="$CONFIG" +if [[ -n "${DISPLAY:-}" ]]; then + "$GODOT" --path "$REPO_ROOT/client" --rendering-driver opengl3 "$SCENE" +else + xvfb-run -a "$GODOT" --path "$REPO_ROOT/client" --rendering-driver opengl3 "$SCENE" +fi + +# Step 2 — analyze. +"$PY" "$SCRIPT_DIR/analyze_captures.py" --config "$CONFIG"