feat(tooling): two-pass clip discriminator for garment QA (T-1089)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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/<item_id>/<body_key>.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
|
||||
|
||||
@@ -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:** `<out_dir>/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 `<out_dir>/failures/` with
|
||||
clip pixels recoloured lime and each blob boxed red.
|
||||
**Read the report:** `<out_dir>/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
|
||||
`<out_dir>/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.
|
||||
|
||||
@@ -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:
|
||||
<stem>.png pass A — covered body segments flat magenta, garment normal.
|
||||
<stem>__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 <out_dir>/failures/ with the clip pixels recoloured
|
||||
lime and each component boxed in red. Results land in <out_dir>/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 <out_dir>/failures/ with exposed_skin
|
||||
recoloured lime and clip_through filled red (+ red box per clip blob). Results land in
|
||||
<out_dir>/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: <body>__<clip>__f<n>__yaw<deg>.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: <out_dir>/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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user