feat(tooling): chromakey garment-clipping QA harness (T-1089)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user