From 74fa16260d531bbd0252f68f1395a7540160bf11 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 6 Jul 2026 14:51:04 +0200 Subject: [PATCH] feat(tooling): two-pass clip discriminator for garment QA (T-1089) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second garment-only render pass per view at the identical paused animation time; the analyzer intersects so body-key pixels split into exposed_skin (no garment behind — informational: collars, sleeveless arms) vs clip_through (garment behind — gating). Highlights differ: lime exposed, red clip. Peasant re-run: 72 captures, 56 clip-through flags — real collar micro-clips under crouch/walk plus suspected 1px boundary artifacts; gate threshold + garment-mask dilation are the tuning knobs, to be calibrated against the first real modern garments. Co-Authored-By: Claude Fable 5 --- client/tools/garment_qa/chromakey_scene.gd | 190 +++++++++++++++------ tooling/garment-qa/README.md | 36 ++-- tooling/garment-qa/analyze_captures.py | 169 ++++++++++++------ 3 files changed, 274 insertions(+), 121 deletions(-) diff --git a/client/tools/garment_qa/chromakey_scene.gd b/client/tools/garment_qa/chromakey_scene.gd index 8dd5e8b7e..37ebc1fd7 100644 --- a/client/tools/garment_qa/chromakey_scene.gd +++ b/client/tools/garment_qa/chromakey_scene.gd @@ -1,33 +1,49 @@ 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. +## Two-pass depth-proximity technique per view, sampling the identical (paused) +## animation frame twice so the passes are pixel-aligned: +## Pass A (full) — the body segments a garment CLAIMS to cover (coverage.json +## "hides") are painted flat UNSHADED magenta; garment + head/hands +## keep normal materials. Magenta the camera sees = body in FRONT of +## the garment (or exposed beyond it). +## Pass B (shift) — identical, except the garment is rendered flat CYAN and every +## garment vertex is nudged CLIP_EPSILON metres toward the camera +## (a view-space Z bias). Where the body sits within epsilon in +## front of the cloth, the nudged garment now covers it → the pixel +## flips magenta→cyan. +## +## The analyzer flags a pixel as a REAL clip only if it is magenta in A AND cyan in B: +## body ≤ epsilon in front of cloth = poking through. A limb passing ~15 cm in front of +## the torso, or an open collar with the back panel far behind, stays magenta in B and +## is reported as exposed_skin (informational), not a clip. This is why "is there any +## garment behind the ray" is not enough — proximity is the whole signal. +## +## To hold exact references to the garment meshes (head-template + hair are skinned, +## unmeta'd, and structurally indistinguishable from garment meshes on the skeleton), +## the scene builds body+head+hair via CharacterVisual with NO clothing, then attaches +## the garment GLBs itself — mirroring character_visual.gd:517-535. ## ## 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: +## leave the client project root — see tooling/garment-qa/README.md. 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), +## (or {"glb": "res://...", "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], +## "clip_epsilon_m": 0.03, # body-in-front-of-cloth tolerance (default 3 cm) ## "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 DEFAULT_EPSILON := 0.03 # metres — body within this far in front of cloth = clip const VIEWPORT_SIZE := Vector2i(768, 1024) # portrait — maximises body pixels per capture +const CLOTHING_DIR := "res://assets/characters/clothing/" const BODY_KEY_TO_ENUM := { "average_m": CharacterVisualDescriptor.BodyType.AVERAGE_M, @@ -47,6 +63,7 @@ var _config: Dictionary = {} var _out_dir: String = "" var _cam: Camera3D = null var _key_material: ShaderMaterial = null +var _shift_material: ShaderMaterial = null func _ready() -> void: @@ -55,6 +72,7 @@ func _ready() -> void: return get_window().size = VIEWPORT_SIZE _key_material = _make_key_material() + _shift_material = _make_shift_material(float(_config.get("clip_epsilon_m", DEFAULT_EPSILON))) _setup_stage() _run.call_deferred() @@ -84,9 +102,8 @@ func _load_config() -> bool: 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). +## Flat unshaded magenta (pass A key). ALBEDO written straight to the framebuffer with +## no lighting; cull_disabled so a backface poking through an opening still registers. func _make_key_material() -> ShaderMaterial: var shader := Shader.new() shader.code = ( @@ -101,6 +118,31 @@ func _make_key_material() -> ShaderMaterial: return mat +## Flat unshaded cyan whose vertices are nudged `shift` metres toward the camera in +## view space (pass B). Cull is left default so only camera-facing cloth counts as the +## near layer — a far back panel shifted epsilon closer is still far and won't cover the +## body-key. Works on skinned garments: VERTEX arrives already skinned in model space. +func _make_shift_material(epsilon: float) -> ShaderMaterial: + var shader := Shader.new() + shader.code = ( + "shader_type spatial;\n" + + "render_mode unshaded;\n" + + "uniform float shift = 0.03;\n" + + "void vertex() {\n" + + "\tvec4 view_pos = MODELVIEW_MATRIX * vec4(VERTEX, 1.0);\n" + + "\tview_pos.z += shift;\n" # camera looks down -Z; +Z is toward the camera + + "\tPOSITION = PROJECTION_MATRIX * view_pos;\n" + + "}\n" + + "void fragment() {\n" + + "\tALBEDO = vec3(0.0, 1.0, 1.0);\n" + + "}\n" + ) + var mat := ShaderMaterial.new() + mat.shader = shader + mat.set_shader_parameter("shift", epsilon) + return mat + + func _setup_stage() -> void: var light := DirectionalLight3D.new() light.rotation_degrees = Vector3(-45, 30, 0) @@ -112,8 +154,8 @@ func _setup_stage() -> void: 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. + # Dark neutral background — never magenta, never cyan, never skin-toned, so it + # cannot be mistaken for a key or garment pixel by the analyzer. env.background_color = Color(0.18, 0.19, 0.22) var we := WorldEnvironment.new() we.environment = env @@ -133,6 +175,7 @@ func _run() -> void: var body_types: Array = _config.get("body_types", ["average_m"]) print("chromakey_scene: out_dir = ", _out_dir) print("chromakey_scene: body_types = ", body_types) + print("chromakey_scene: clip_epsilon_m = ", _config.get("clip_epsilon_m", DEFAULT_EPSILON)) for body_key: String in body_types: await _capture_body(str(body_key)) print("chromakey_scene: DONE") @@ -146,6 +189,8 @@ func _capture_body(body_key: String) -> void: var cv := CharacterVisual.new() add_child(cv) + # Build body + head + hair only; the garments are attached below so the scene + # holds exact references to garment geometry for the pass-B shift material. cv.load_descriptor(_build_descriptor(body_key)) var skel := cv.get_skeleton() @@ -154,13 +199,17 @@ func _capture_body(body_key: String) -> void: cv.queue_free() return - _attach_raw_garments(skel) - - var covered := _covered_segments(cv) + var garments: Array[MeshInstance3D] = [] + var covered := {} + _attach_garments(skel, body_key, garments, covered) var keyed := _apply_key(skel, covered) + print( - "chromakey_scene[%s]: covered=%s keyed_meshes=%d" % [body_key, covered.keys(), keyed] + "chromakey_scene[%s]: covered=%s keyed=%d garment_meshes=%d" + % [body_key, covered.keys(), keyed, garments.size()] ) + if garments.is_empty(): + push_warning("chromakey_scene[%s]: no garment meshes attached" % body_key) var ap := cv.get_animation_player() if ap == null: @@ -179,7 +228,7 @@ func _capture_body(body_key: String) -> void: 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 + ap.pause() # freeze — seek() below poses without advancing; both passes share it var length := ap.current_animation_length var times := _sample_times(length, frames) for fi in times.size(): @@ -187,18 +236,36 @@ func _capture_body(body_key: String) -> void: 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)) + var stem := "%s__%s__f%d__yaw%03d" % [body_key, str(clip), fi, int(yaw)] + await _capture_pair(garments, stem) print("chromakey_scene[%s]: captured clip '%s' (%d frames)" % [body_key, clip, times.size()]) cv.queue_free() await _wait_frames(2) +## Capture pass A (garment normal) then pass B (garment cyan + shifted) at the current, +## frozen pose. Only the garment material changes between passes — pixel-aligned. +func _capture_pair(garments: Array[MeshInstance3D], stem: String) -> void: + for g in garments: + g.material_override = null + await RenderingServer.frame_post_draw + _save(stem + ".png") + + for g in garments: + g.material_override = _shift_material + await RenderingServer.frame_post_draw + _save(stem + "__shift.png") + + for g in garments: + g.material_override = null + + +func _save(fname: String) -> void: + var img := get_viewport().get_texture().get_image() + img.save_png(_out_dir.path_join(fname)) + + func _build_descriptor(body_key: String) -> CharacterVisualDescriptor: var desc := CharacterVisualDescriptor.new() desc.body_type = BODY_KEY_TO_ENUM[body_key] @@ -206,24 +273,37 @@ func _build_descriptor(body_key: String) -> CharacterVisualDescriptor: 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 + desc.clothing_slots = {} # garments attached manually in _attach_garments 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: +## Attach every configured garment's skinned meshes onto the skeleton (mirrors +## character_visual.gd:517-535) and accumulate the covered-segment set. Catalogue +## garments resolve to clothing//.glb with hides read from the +## sibling coverage.json; raw garments carry a "glb" path + explicit "covers" list. +## Garments keep their embedded GLB materials (opaque, depth-writing) in pass A — +## geometry, not shading, is what clip detection needs. +func _attach_garments( + skel: Skeleton3D, body_key: String, out_meshes: Array[MeshInstance3D], out_covered: Dictionary +) -> void: for g: Dictionary in _config.get("garments", []): - if not g.has("glb"): + var glb_path := "" + if g.has("item_id"): + glb_path = CLOTHING_DIR + "%s/%s.glb" % [str(g["item_id"]), body_key] + for seg in _read_coverage_hides(str(g["item_id"])): + out_covered[str(seg)] = true + elif g.has("glb"): + glb_path = str(g["glb"]) + if g.has("covers"): + for seg in g["covers"]: + out_covered[str(seg)] = true + + if glb_path.is_empty() or not ResourceLoader.exists(glb_path): + push_warning("chromakey_scene: garment GLB missing: %s" % glb_path) continue - var scene := load(str(g["glb"])) as PackedScene + var scene := load(glb_path) as PackedScene if scene == null: - push_error("chromakey_scene: raw garment GLB failed to load: %s" % g["glb"]) + push_error("chromakey_scene: garment GLB failed to load: %s" % glb_path) continue var inst := scene.instantiate() var stack: Array[Node] = [inst] @@ -236,23 +316,23 @@ func _attach_raw_garments(skel: Skeleton3D) -> void: mi.get_parent().remove_child(mi) mi.owner = null skel.add_child(mi) + out_meshes.append(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 +func _read_coverage_hides(item_id: String) -> Array: + var path := CLOTHING_DIR + "%s/coverage.json" % item_id + if not FileAccess.file_exists(path): + push_warning("chromakey_scene: coverage.json missing for %s" % item_id) + return [] + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + return [] + var parsed: Variant = JSON.parse_string(file.get_as_text()) + file.close() + if parsed is Dictionary and (parsed as Dictionary).has("hides"): + return (parsed as Dictionary)["hides"] + return [] ## Key-colour every loaded body segment in the covered set and delete the outline diff --git a/tooling/garment-qa/README.md b/tooling/garment-qa/README.md index a3f362358..4da2a7a48 100644 --- a/tooling/garment-qa/README.md +++ b/tooling/garment-qa/README.md @@ -1,26 +1,38 @@ # 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. +Automatically detects clothing clip-through with a **two-pass depth-proximity** +chromakey. Per view (clips × sampled frames × 4 camera yaws), the capture scene renders +the SAME frozen animation frame twice: **pass A** paints the body segments a garment +*claims to cover* (coverage.json `hides`) flat **unshaded magenta**, garment + head/hands +normal; **pass B** is identical except the garment is flat **cyan** and every garment +vertex is nudged `clip_epsilon_m` (default 3 cm) **toward the camera**. A body-key pixel +that is magenta in A but cyan in B means the ε-shifted cloth now covers it — the body sat +within ε *in front of* the cloth = poking through. This separates a true clip from a limb +merely crossing in front of the torso, an open collar, or a bare arm over background +(all of which stay magenta because the cloth behind them is > ε away). **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), +(`{item_id,slot}` catalogue items, or `{glb,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. +**Read the report:** `/report.json` — the same summary prints to stdout. Two +metrics per capture: +- **`clip_through_pixels`** / `largest_clip_component` — key pixels the ε-shifted cloth + covers (skin ≤ ε in front of cloth). **This is the real defect and it gates:** a capture + `fail`s when its largest connected clip blob ≥ `--min-pixels` (default 8, tolerating AA + edges). `clip_through_failures` + the `by_group` `clipfail`/`worstClip` columns roll it up. +- **`exposed_skin_pixels`** — key pixels the shift did NOT cover (skin well in front of any + cloth: collar/sleeve/ankle gaps, a limb crossing the torso). Informational; does not gate. + +Tune sensitivity with `clip_epsilon_m` in the config (smaller = only tighter pokes count) +and `--min-pixels` on the analyzer. Failing/flagged frames are copied to +`/failures/*_HL.png` with **exposed_skin lime**, **clip_through filled red**, and +each clip blob boxed. **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 index be7ac0d01..359779141 100644 --- a/tooling/garment-qa/analyze_captures.py +++ b/tooling/garment-qa/analyze_captures.py @@ -1,16 +1,24 @@ #!/usr/bin/env python3 -"""Chromakey garment-clipping analyzer (T-1089). +"""Chromakey garment-clipping analyzer (T-1089, two-pass). -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. +The capture scene writes two PNGs per view at the identical (paused) animation frame: + .png pass A — covered body segments flat magenta, garment normal. + __shift.png pass B — same, but the garment is flat CYAN and nudged + CLIP_EPSILON metres toward the camera (a view-space depth bias). -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. +A body-key pixel that is magenta in A but CYAN in B means the epsilon-shifted garment +now covers it — the body sat within epsilon in front of the cloth. Intersecting the two +separates depth-proximate clip-through from mere overlap: + clip_through — magenta in A AND cyan in B: skin <= epsilon in front of cloth = poking + through. The true defect. Largest connected blob >= --min-pixels + (default 8, tolerating AA edges) FAILS the capture. + exposed_skin — magenta in A AND still magenta in B: skin well in front of any cloth + (a limb crossing the torso, an open collar, a bare arm over background). + Informational only; does not gate. + +For each capture a highlighted copy lands in /failures/ with exposed_skin +recoloured lime and clip_through filled red (+ red box per clip blob). 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. @@ -26,25 +34,51 @@ 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 +# Key-colour gate (pass A). 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. +KEY_R_MIN = 200 +KEY_G_MAX = 60 +KEY_B_MIN = 200 + +# Shifted-garment gate (pass B). Flat cyan is (0, 255, 255); low red + high green/blue +# isolates the shifted cloth from magenta body-key, skin, and the dark background. +CYAN_R_MAX = 60 +CYAN_G_MIN = 190 +CYAN_B_MIN = 190 + + +def _threshold(band: Image.Image, lo: int | None, hi: int | None) -> Image.Image: + def f(v: int) -> int: + if lo is not None and v < lo: + return 0 + if hi is not None and v > hi: + return 0 + return 255 + + return band.point(f) def build_key_mask(img: Image.Image) -> Image.Image: - """Return an "L" mask, 255 where the pixel is key-colour, else 0.""" + """"L" mask, 255 where the pass-A pixel is key-colour (magenta), 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) + r_ok = _threshold(r, KEY_R_MIN, None) + g_ok = _threshold(g, None, KEY_G_MAX) + b_ok = _threshold(b, KEY_B_MIN, None) + return ImageChops.multiply(ImageChops.multiply(r_ok, g_ok), b_ok) + + +def build_cyan_mask(img: Image.Image) -> Image.Image: + """"L" mask, 255 where the pass-B pixel is the shifted flat-cyan garment, else 0.""" + r, g, b = img.convert("RGB").split() + r_ok = _threshold(r, None, CYAN_R_MAX) + g_ok = _threshold(g, CYAN_G_MIN, None) + b_ok = _threshold(b, CYAN_B_MIN, None) 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. + """8-connected components of a 0/255 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. @@ -83,7 +117,6 @@ def connected_components(mask: Image.Image) -> list[dict]: 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], } ) @@ -91,39 +124,59 @@ def connected_components(mask: Image.Image) -> list[dict]: 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.""" +def write_highlight( + img: Image.Image, exposed: Image.Image, clip: Image.Image, comps: list[dict], dest: Path +) -> None: + """Recolour exposed_skin lime, fill clip_through red, box each clip blob.""" out = img.convert("RGB") - out.paste((0, 255, 0), mask=mask) + out.paste((0, 255, 0), mask=exposed) + out.paste((255, 0, 0), mask=clip) draw = ImageDraw.Draw(out) for comp in comps: - draw.rectangle(comp["bbox"], outline=(255, 0, 0), width=2) + draw.rectangle(comp["bbox"], outline=(255, 80, 80), 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()) + captures = sorted( + p for p in out_dir.glob("*.png") if p.is_file() and not p.stem.endswith("__shift") + ) fail_dir = out_dir / "failures" results: list[dict] = [] for path in captures: + shift_path = path.with_name(path.stem + "__shift.png") img = Image.open(path) - mask = build_key_mask(img) - total = mask.histogram()[255] - comps = connected_components(mask) if total else [] + key = build_key_mask(img) + + if shift_path.exists(): + cyan = build_cyan_mask(Image.open(shift_path)) + clip = ImageChops.multiply(key, cyan) + exposed = ImageChops.multiply(key, ImageChops.invert(cyan)) + shift_missing = False + else: + # No shift pass — cannot classify; treat all key as exposed, clip empty. + clip = Image.new("L", img.size, 0) + exposed = key + shift_missing = True + + comps = connected_components(clip) 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")) + if failed or exposed.getbbox() is not None: + write_highlight(img, exposed, clip, 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]], + "key_pixels": key.histogram()[255], + "exposed_skin_pixels": exposed.histogram()[255], + "clip_through_pixels": clip.histogram()[255], + "clip_components": len(comps), + "largest_clip_component": largest, + "clip_component_sizes": [c["size"] for c in comps[:10]], "fail": failed, + "shift_pass_missing": shift_missing, } ) @@ -137,33 +190,41 @@ def summarize(out_dir: Path, min_pixels: int, results: list[dict]) -> dict: # 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 = by_group.setdefault( + group, {"captures": 0, "clip_failures": 0, "worst_clip": 0, "worst_exposed": 0} + ) entry["captures"] += 1 - entry["failures"] += 1 if r["fail"] else 0 - entry["worst"] = max(entry["worst"], r["largest_component"]) + entry["clip_failures"] += 1 if r["fail"] else 0 + entry["worst_clip"] = max(entry["worst_clip"], r["largest_clip_component"]) + entry["worst_exposed"] = max(entry["worst_exposed"], r["exposed_skin_pixels"]) 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}, + "key_gate": {"r_min": KEY_R_MIN, "g_max": KEY_G_MAX, "b_min": KEY_B_MIN}, + "shift_gate": {"r_max": CYAN_R_MAX, "g_min": CYAN_G_MIN, "b_min": CYAN_B_MIN}, "total_captures": len(results), - "failures": len(failures), + "clip_through_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}") + print("=" * 74) + print("garment-qa chromakey analysis (two-pass: clip_through gates, exposed_skin info)") + print(f" out_dir : {report['out_dir']}") + print(f" min clip blob pixels : {report['min_component_pixels']}") + print(f" captures : {report['total_captures']}") + print(f" CLIP-THROUGH failures: {report['clip_through_failures']}") + print("-" * 74) + hdr = f" {'group (body__clip)':<26}{'caps':>6}{'clipfail':>10}{'worstClip':>11}{'worstExp':>10}" + print(hdr) for group, g in sorted(report["by_group"].items()): - print(f" {group:<28}{g['captures']:>6}{g['failures']:>7}{g['worst']:>7}") - print("=" * 64) + print( + f" {group:<26}{g['captures']:>6}{g['clip_failures']:>10}" + f"{g['worst_clip']:>11}{g['worst_exposed']:>10}" + ) + print("=" * 74) def resolve_out_dir(args: argparse.Namespace) -> Path: @@ -186,7 +247,7 @@ def main() -> int: "--min-pixels", type=int, default=8, - help="largest connected key-pixel component that counts as a clip (default 8)", + help="largest connected clip-through blob that counts as a failure (default 8)", ) parser.add_argument("--report", help="report JSON path (default: /report.json)") args = parser.parse_args() @@ -200,8 +261,8 @@ def main() -> int: 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'}") + if report["clip_through_failures"]: + print(f" highlighted frames : {out_dir / 'failures'}") return 0