refactor(tooling): T-1290 — the character domain, and six payloads the map misfiled
`reach character {logo, strip-glb, qa, qa-analyze}` replaces make_logo.py,
glb_strip_utility_nodes.py, analyze_captures.py and the run-garment-qa bash
driver. The QA configs and method doc move beside the domain (qa_configs/,
GARMENT_QA.md), and `qa` takes a config name (`reach character qa hoodie_modern`)
or a path.
Parity, from baselines taken before anything moved:
- the logo PNG is byte-identical
- a synthetic GLB with three real utility nodes strips to identical bytes
(the committed bodies strip 0 nodes, so they proved nothing)
- re-analyzing a cached capture set gives a byte-identical report.json and
summary
run-garment-qa is rewritten, not wrapped (D-263). Its decisions — which config,
which Godot ($GODOT, then ~/bin/godot4, then PATH), and whether xvfb-run is
needed — are capture_plan(), pinned by tooling/test_character.py without
launching Godot. The bash exit codes are kept: 2 for a missing config, 3 for
no Godot.
The T-1271 domain map was wrong about this domain. Six of its ten files import
bpy: convert_outfit, inspect_glb, check_hair_symmetry, check_icosphere,
render_quaternius_test and test_quaternius_raw. They are Blender payloads and
joined the carve-out as blender_* (41 payloads now). The 22 existing payloads'
docstrings still cited tooling/garment-fit/ from before T-1273; fixed.
Archived, with reasons in tooling/archive/README.md:
- setup_clothing_metadata.py wrote coverage data for five garments that no
longer exist in the 24-garment wardrobe
- wipe-bodies.sh ran raw DELETEs on systems.db
segment_reference_distribution.md moved to docs/assets/visual/.
Behaviour changes:
- The QA analyzer exited 0 whatever it found, though its own README says
clip-through "is the real defect and it gates". qa and qa-analyze now exit 1
on clip-through, and the remedy names --min-pixels (Wave 1/2 were accepted
at 150). The cached peasant set has 33 failures at the default 8 px.
- glb strip re-reported the same nodes as stripped on every re-run and
rewrote an unchanged file: it left them as orphans and then found them
again. Only nodes still linked into the graph count now, and a first pass
writes the same bytes as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Executable → Regular
Executable → Regular
@@ -0,0 +1,41 @@
|
||||
# garment-qa — chromakey garment-clipping QA (T-1089)
|
||||
|
||||
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:** `reach character qa [config]` — a name from `qa_configs/` (`peasant`, the
|
||||
default, `hoodie_modern`, …) or a path. It launches Godot (`$GODOT`, else `~/bin/godot4`,
|
||||
`opengl3`, `xvfb-run` when headless) on the capture scene, then runs the analyzer.
|
||||
Re-analyze existing captures without re-rendering: `reach character qa-analyze --config …`
|
||||
(or `--dir`). **Both exit 1 when any capture clips through** — the gate the text below
|
||||
describes; before T-1290 the analyzer exited 0 whatever it found.
|
||||
|
||||
**Config** (path passed to the scene via `GARMENT_QA_CONFIG`): `garments`
|
||||
(`{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` — 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.
|
||||
@@ -0,0 +1,13 @@
|
||||
"""`character` — bodies, garments and GLB handling (D-263).
|
||||
|
||||
`character` rather than `garment`: the tooling covers bodies, hair, GLB
|
||||
utilities and garments, and naming it for one of those would leave the others
|
||||
looking misfiled (tooling/DOMAINS.md).
|
||||
|
||||
What lives here is only the part that runs under reach's own Python: the
|
||||
brand-logo stub, the GLB utility-node strip, and the garment-QA harness
|
||||
(its configs in qa_configs/, its method in GARMENT_QA.md). Everything that runs
|
||||
INSIDE Blender is a payload in tooling/scripts/blender/ and runs via
|
||||
`reach blender run` — six files the domain map had assigned here turned out to
|
||||
import bpy and went there instead (T-1290).
|
||||
"""
|
||||
@@ -0,0 +1,195 @@
|
||||
"""
|
||||
glb_strip — remove utility nodes from a GLB's scene graph.
|
||||
Usage: reach character strip-glb <input.glb> [<output.glb>]
|
||||
reach character strip-glb --dir <directory>
|
||||
|
||||
Formerly tooling/glb_strip_utility_nodes.py (T-1290); the GLB rewriting is
|
||||
unchanged and writes the same bytes.
|
||||
|
||||
Removes utility mesh nodes from a GLB's GLTF scene graph.
|
||||
A "utility node" is any mesh node where:
|
||||
- The mesh has 0 primitives, OR
|
||||
- The mesh name matches a known utility pattern (Icosphere, WGT-*, etc.)
|
||||
- The mesh has no skinning joint data (vgroups=0 in Blender terms → no skin references)
|
||||
|
||||
Strips the node from the scene graph (and its parent's children list) without
|
||||
touching the underlying mesh/accessor data. The mesh data becomes orphaned
|
||||
(not referenced by any node) and most GLTF consumers (Godot, Blender) ignore it.
|
||||
|
||||
GLB format:
|
||||
- 12-byte header: magic (0x46546C67), version (2), total length
|
||||
- Chunk 0: JSON (type 0x4E4F534A)
|
||||
- Chunk 1: BIN (type 0x004E4942)
|
||||
|
||||
If no output path is given, overwrites the input file.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import struct
|
||||
|
||||
from tooling.core import console
|
||||
|
||||
GLB_MAGIC = 0x46546C67
|
||||
CHUNK_JSON = 0x4E4F534A
|
||||
CHUNK_BIN = 0x004E4942
|
||||
|
||||
# Mesh names considered utility/non-geometry
|
||||
UTILITY_PATTERNS = [
|
||||
re.compile(r'^Icosphere', re.IGNORECASE),
|
||||
re.compile(r'^WGT-', re.IGNORECASE),
|
||||
re.compile(r'^DEF-', re.IGNORECASE),
|
||||
re.compile(r'^ORG-', re.IGNORECASE),
|
||||
]
|
||||
|
||||
|
||||
def is_utility_mesh(mesh: dict) -> bool:
|
||||
name = mesh.get('name', '')
|
||||
for pat in UTILITY_PATTERNS:
|
||||
if pat.match(name):
|
||||
return True
|
||||
# Also treat meshes with no primitives as utility
|
||||
if not mesh.get('primitives'):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def read_glb(path: str):
|
||||
"""Read a GLB file and return (gltf_dict, bin_data)."""
|
||||
with open(path, 'rb') as f:
|
||||
data = f.read()
|
||||
|
||||
magic, version, length = struct.unpack_from('<III', data, 0)
|
||||
assert magic == GLB_MAGIC, f"Not a GLB file (magic={magic:#010x})"
|
||||
assert version == 2, f"Unexpected GLB version {version}"
|
||||
|
||||
offset = 12
|
||||
gltf_json = None
|
||||
bin_data = b''
|
||||
|
||||
while offset < length:
|
||||
chunk_len, chunk_type = struct.unpack_from('<II', data, offset)
|
||||
offset += 8
|
||||
chunk_data = data[offset: offset + chunk_len]
|
||||
offset += chunk_len
|
||||
|
||||
if chunk_type == CHUNK_JSON:
|
||||
gltf_json = json.loads(chunk_data.decode('utf-8'))
|
||||
elif chunk_type == CHUNK_BIN:
|
||||
bin_data = chunk_data
|
||||
|
||||
assert gltf_json is not None, "No JSON chunk found in GLB"
|
||||
return gltf_json, bin_data
|
||||
|
||||
|
||||
def write_glb(gltf_dict: dict, bin_data: bytes, path: str):
|
||||
"""Write a GLB file from a GLTF dict and binary data."""
|
||||
json_bytes = json.dumps(gltf_dict, separators=(',', ':'), ensure_ascii=False).encode('utf-8')
|
||||
# Pad JSON to 4-byte alignment with spaces
|
||||
pad = (4 - len(json_bytes) % 4) % 4
|
||||
json_bytes += b' ' * pad
|
||||
|
||||
# Pad binary to 4-byte alignment with zeros
|
||||
bin_pad = (4 - len(bin_data) % 4) % 4 if bin_data else 0
|
||||
bin_padded = bin_data + b'\x00' * bin_pad
|
||||
|
||||
json_chunk = struct.pack('<II', len(json_bytes), CHUNK_JSON) + json_bytes
|
||||
bin_chunk = (struct.pack('<II', len(bin_padded), CHUNK_BIN) + bin_padded) if bin_data else b''
|
||||
|
||||
total_length = 12 + len(json_chunk) + len(bin_chunk)
|
||||
header = struct.pack('<III', GLB_MAGIC, 2, total_length)
|
||||
|
||||
with open(path, 'wb') as f:
|
||||
f.write(header + json_chunk + bin_chunk)
|
||||
|
||||
|
||||
def strip_utility_nodes(gltf: dict) -> tuple[int, list[str]]:
|
||||
"""
|
||||
Remove utility mesh nodes from the scene graph.
|
||||
Returns (count_removed, names_removed).
|
||||
"""
|
||||
meshes = gltf.get('meshes', [])
|
||||
nodes = gltf.get('nodes', [])
|
||||
scenes = gltf.get('scenes', [])
|
||||
|
||||
# Find utility mesh indices
|
||||
utility_mesh_indices = {
|
||||
i for i, m in enumerate(meshes)
|
||||
if is_utility_mesh(m)
|
||||
}
|
||||
|
||||
if not utility_mesh_indices:
|
||||
return 0, []
|
||||
|
||||
# Only nodes still LINKED into the graph count. Stripping leaves nodes in
|
||||
# place as orphans (see below), so without this a second run re-reported the
|
||||
# same nodes as stripped and rewrote an unchanged file (T-1290).
|
||||
linked = {n for scene in scenes for n in scene.get('nodes', [])}
|
||||
linked.update(c for node in nodes for c in node.get('children', []))
|
||||
|
||||
# Find linked node indices that reference utility meshes
|
||||
utility_node_indices = {
|
||||
i for i, n in enumerate(nodes)
|
||||
if n.get('mesh') in utility_mesh_indices and i in linked
|
||||
}
|
||||
|
||||
if not utility_node_indices:
|
||||
return 0, []
|
||||
|
||||
removed_names = [nodes[i].get('name', f'node_{i}') for i in sorted(utility_node_indices)]
|
||||
|
||||
# Remove utility nodes from scene top-level node lists
|
||||
for scene in scenes:
|
||||
scene['nodes'] = [n for n in scene.get('nodes', []) if n not in utility_node_indices]
|
||||
|
||||
# Remove utility nodes from every other node's children list
|
||||
for node in nodes:
|
||||
if 'children' in node:
|
||||
node['children'] = [c for c in node['children'] if c not in utility_node_indices]
|
||||
if not node['children']:
|
||||
del node['children'] # clean up empty children array
|
||||
|
||||
# Note: we intentionally leave the mesh data and nodes in place as orphaned entries.
|
||||
# Reindexing would require updating all skin joint arrays and is complex.
|
||||
# Orphaned nodes/meshes are ignored by Godot and Blender on import.
|
||||
|
||||
return len(utility_node_indices), removed_names
|
||||
|
||||
|
||||
def process_file(input_path: str, output_path: str) -> dict:
|
||||
console.event(f"Processing: {input_path}")
|
||||
gltf, bin_data = read_glb(input_path)
|
||||
|
||||
count, names = strip_utility_nodes(gltf)
|
||||
|
||||
if count == 0:
|
||||
console.event("No utility nodes found — file unchanged")
|
||||
if output_path != input_path:
|
||||
shutil.copy2(input_path, output_path)
|
||||
return {"file": output_path, "stripped": 0, "names": []}
|
||||
|
||||
write_glb(gltf, bin_data, output_path)
|
||||
console.event(f"Stripped {count} utility node(s): {names}")
|
||||
console.event(f"Written: {output_path} ({os.path.getsize(output_path):,} bytes)")
|
||||
return {"file": output_path, "stripped": count, "names": names}
|
||||
|
||||
|
||||
def process_directory(input_dir: str) -> dict:
|
||||
"""Process all .glb files in a directory tree, in place."""
|
||||
total = 0
|
||||
changed = []
|
||||
for root, _dirs, files in os.walk(input_dir):
|
||||
for fname in sorted(files):
|
||||
if fname.lower().endswith(".glb"):
|
||||
fpath = os.path.join(root, fname)
|
||||
gltf, bin_data = read_glb(fpath)
|
||||
count, names = strip_utility_nodes(gltf)
|
||||
if count > 0:
|
||||
write_glb(gltf, bin_data, fpath)
|
||||
rel = os.path.relpath(fpath, input_dir)
|
||||
console.event(f"{rel}: stripped {count} nodes {names}")
|
||||
changed.append({"file": rel, "stripped": count, "names": names})
|
||||
total += count
|
||||
return {"dir": input_dir, "stripped": total, "files": changed}
|
||||
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
make_logo.py (T-1089 gap G4 — brand-logo supply stub)
|
||||
|
||||
Generate a flat 2D wordmark PNG for a clothing brand decal (D-244: flat 2D
|
||||
artwork on 3D surfaces). White-on-transparent, ~256x256, drawn with its own
|
||||
crisp edge so the inverted-hull character outline (which never samples the
|
||||
decal) leaves it untouched. Must stay legible at 16-32 px gameplay zoom (D-044).
|
||||
|
||||
This is a PLACEHOLDER supply stub. The production path is /image-gen (Gemini)
|
||||
per the feasibility §4 pipeline; this script gives the engine a real decal to
|
||||
render now, and doubles as the deterministic fallback generator.
|
||||
|
||||
Run:
|
||||
reach character logo <text> <out_png> [--size 256]
|
||||
|
||||
Example (the canonical Braemar fiber co-op, wiki/corporations/thrds.md — always
|
||||
lowercase):
|
||||
reach character logo thrds client/assets/characters/logos/thrds.png
|
||||
|
||||
Formerly tooling/garment-fit/make_logo.py (T-1290); the drawing is unchanged
|
||||
and renders the same PNG bytes.
|
||||
"""
|
||||
import os
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from tooling.core import console
|
||||
|
||||
FONT_CANDIDATES = [
|
||||
"/usr/share/fonts/fira-code/FiraCode-Bold.ttf",
|
||||
"/usr/share/fonts/adwaita-mono-fonts/AdwaitaMono-Bold.ttf",
|
||||
"/usr/share/fonts/dejavu-sans-fonts/DejaVuSans-Bold.ttf",
|
||||
]
|
||||
|
||||
|
||||
def load_font(px):
|
||||
for path in FONT_CANDIDATES:
|
||||
if os.path.isfile(path):
|
||||
return ImageFont.truetype(path, px), os.path.basename(path)
|
||||
return ImageFont.load_default(), "PIL-default"
|
||||
|
||||
|
||||
def make_logo(text, out_png, size=256):
|
||||
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Grow the font until the wordmark fills ~82% of the width.
|
||||
target_w = int(size * 0.82)
|
||||
px = size
|
||||
font, font_name = load_font(px)
|
||||
for px in range(size, 8, -2):
|
||||
font, font_name = load_font(px)
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
if (bbox[2] - bbox[0]) <= target_w and (bbox[3] - bbox[1]) <= int(size * 0.5):
|
||||
break
|
||||
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||||
x = (size - tw) / 2 - bbox[0]
|
||||
y = (size - th) / 2 - bbox[1]
|
||||
# White wordmark, fully opaque.
|
||||
draw.text((x, y), text, font=font, fill=(255, 255, 255, 255))
|
||||
# A thin underscore bar under the wordmark — reads as a modern corporate mark
|
||||
# and gives the decal a stable baseline anchor at low zoom.
|
||||
bar_y = y + bbox[3] + int(size * 0.03)
|
||||
bar_h = max(2, int(size * 0.02))
|
||||
draw.rectangle(
|
||||
[(size * 0.12, bar_y), (size * 0.88, bar_y + bar_h)],
|
||||
fill=(255, 255, 255, 255),
|
||||
)
|
||||
|
||||
os.makedirs(os.path.dirname(os.path.abspath(out_png)), exist_ok=True)
|
||||
img.save(out_png)
|
||||
console.event(f"wrote {out_png} ({size}x{size}, font={font_name}, glyph_px={px})")
|
||||
return {"file": out_png, "size": size, "font": font_name, "glyph_px": px}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Drive the chromakey garment-clipping QA harness (T-1089) — capture, then analyze.
|
||||
|
||||
Step 1 launches Godot on the client project with the capture scene, handing it
|
||||
the config through GARMENT_QA_CONFIG. Step 2 runs the pixel analyzer over the
|
||||
PNGs the scene wrote. See GARMENT_QA.md beside this file for what the two
|
||||
passes measure.
|
||||
|
||||
Formerly tooling/garment-qa/run-garment-qa, a bash script (T-1290). Rewritten
|
||||
per D-263's guarded-exec rule: the DECISIONS — which config, which Godot,
|
||||
whether a virtual display is needed — are `capture_plan()`, pure and testable
|
||||
without launching anything; only the launch goes through core/process. The
|
||||
bash exit codes are kept: 2 for a missing config, 3 for no Godot.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from tooling.core import config, console, process
|
||||
from tooling.core.errors import ReachError
|
||||
|
||||
CONFIG_DIR = Path(__file__).resolve().parent / "qa_configs"
|
||||
DEFAULT_CONFIG = "peasant"
|
||||
SCENE = "res://tools/garment_qa/chromakey_scene.tscn"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CapturePlan:
|
||||
config: Path
|
||||
argv: list[str]
|
||||
env_config: str # the value for GARMENT_QA_CONFIG
|
||||
|
||||
|
||||
def resolve_config(name_or_path: str | None) -> Path:
|
||||
"""A config name (`peasant`) resolves inside qa_configs/; anything else is a path."""
|
||||
given = name_or_path or DEFAULT_CONFIG
|
||||
if "/" not in given and not given.endswith(".json"):
|
||||
path = CONFIG_DIR / f"{given}.json"
|
||||
else:
|
||||
path = Path(given)
|
||||
if not path.is_file():
|
||||
known = ", ".join(sorted(p.stem for p in CONFIG_DIR.glob("*.json")))
|
||||
raise ReachError(
|
||||
f"garment-qa config not found: {path}",
|
||||
fix=f"pass a config path, or one of: {known}",
|
||||
exit_code=2,
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
def resolve_godot(env: dict[str, str], home: Path, which=shutil.which) -> str:
|
||||
"""$GODOT, else ~/bin/godot4, else godot4/godot on PATH — the bash script's order."""
|
||||
candidate = env.get("GODOT") or str(home / "bin" / "godot4")
|
||||
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
|
||||
return candidate
|
||||
found = which("godot4") or which("godot")
|
||||
if found:
|
||||
return found
|
||||
raise ReachError(
|
||||
"godot binary not found",
|
||||
fix="set GODOT=/path/to/godot4, or install it: make setup-godot",
|
||||
exit_code=3,
|
||||
)
|
||||
|
||||
|
||||
def capture_plan(config_arg: str | None, env: dict[str, str], home: Path, which=shutil.which) -> CapturePlan:
|
||||
"""What the capture step would run — decided, not performed."""
|
||||
cfg = resolve_config(config_arg)
|
||||
godot = resolve_godot(env, home, which)
|
||||
argv = [godot, "--path", str(config.repo_root() / "client"), "--rendering-driver", "opengl3", SCENE]
|
||||
# The capture needs a display. Headless (no $DISPLAY) runs under a virtual one.
|
||||
if not env.get("DISPLAY"):
|
||||
argv = ["xvfb-run", "-a", *argv]
|
||||
return CapturePlan(config=cfg, argv=argv, env_config=str(cfg.resolve()))
|
||||
|
||||
|
||||
def capture(plan: CapturePlan) -> None:
|
||||
"""Perform the capture step. Godot's own output streams straight through."""
|
||||
console.event(f"config = {plan.config}")
|
||||
console.event(f"godot = {plan.argv[2] if plan.argv[0] == 'xvfb-run' else plan.argv[0]}")
|
||||
env = {**os.environ, "GARMENT_QA_CONFIG": plan.env_config}
|
||||
process.run(
|
||||
plan.argv,
|
||||
env=env,
|
||||
capture=False,
|
||||
missing_fix="install xvfb-run (it provides the virtual display a headless capture needs), "
|
||||
"or run from a session with $DISPLAY set",
|
||||
)
|
||||
@@ -0,0 +1,262 @@
|
||||
"""Chromakey garment-clipping analyzer (T-1089, two-pass).
|
||||
|
||||
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 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. Formerly tooling/garment-qa/analyze_captures.py (T-1290);
|
||||
the analysis is unchanged and produces the same report.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageChops, ImageDraw
|
||||
|
||||
from tooling.core.errors import ReachError
|
||||
|
||||
# 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:
|
||||
""""L" mask, 255 where the pass-A pixel is key-colour (magenta), else 0."""
|
||||
r, g, b = img.convert("RGB").split()
|
||||
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 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.
|
||||
"""
|
||||
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": [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, 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=exposed)
|
||||
out.paste((255, 0, 0), mask=clip)
|
||||
draw = ImageDraw.Draw(out)
|
||||
for comp in comps:
|
||||
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() 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)
|
||||
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 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": 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,
|
||||
}
|
||||
)
|
||||
|
||||
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: <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, "clip_failures": 0, "worst_clip": 0, "worst_exposed": 0}
|
||||
)
|
||||
entry["captures"] += 1
|
||||
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": 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),
|
||||
"clip_through_failures": len(failures),
|
||||
"by_group": by_group,
|
||||
"captures": results,
|
||||
}
|
||||
|
||||
|
||||
def summary_lines(report: dict) -> list[str]:
|
||||
"""The human summary table — the command's stdout."""
|
||||
lines = [
|
||||
"=" * 74,
|
||||
"garment-qa chromakey analysis (two-pass: clip_through gates, exposed_skin info)",
|
||||
f" out_dir : {report['out_dir']}",
|
||||
f" min clip blob pixels : {report['min_component_pixels']}",
|
||||
f" captures : {report['total_captures']}",
|
||||
f" CLIP-THROUGH failures: {report['clip_through_failures']}",
|
||||
"-" * 74,
|
||||
f" {'group (body__clip)':<26}{'caps':>6}{'clipfail':>10}{'worstClip':>11}{'worstExp':>10}",
|
||||
]
|
||||
for group, g in sorted(report["by_group"].items()):
|
||||
lines.append(
|
||||
f" {group:<26}{g['captures']:>6}{g['clip_failures']:>10}"
|
||||
f"{g['worst_clip']:>11}{g['worst_exposed']:>10}"
|
||||
)
|
||||
lines.append("=" * 74)
|
||||
return lines
|
||||
|
||||
|
||||
def resolve_out_dir(config_path: str | None, dir_path: str | None) -> Path:
|
||||
"""The capture directory: --dir wins, else the config's out_dir."""
|
||||
if dir_path:
|
||||
return Path(dir_path)
|
||||
if config_path:
|
||||
cfg = json.loads(Path(config_path).read_text())
|
||||
out = cfg.get("out_dir")
|
||||
if not out:
|
||||
raise ReachError(f"{config_path} has no 'out_dir'", fix="add out_dir to the config, or pass --dir")
|
||||
return Path(out)
|
||||
raise ReachError("no capture directory given", fix="pass --dir <captures> or --config <config.json>")
|
||||
|
||||
|
||||
def run(
|
||||
config_path: str | None = None,
|
||||
dir_path: str | None = None,
|
||||
min_pixels: int = 8,
|
||||
report_path: str | None = None,
|
||||
) -> tuple[dict, Path]:
|
||||
"""Analyze a capture directory; writes report.json and returns (report, path)."""
|
||||
out_dir = resolve_out_dir(config_path, dir_path)
|
||||
if not out_dir.is_dir():
|
||||
raise ReachError(
|
||||
f"not a directory: {out_dir}",
|
||||
fix="run the capture first (reach character qa <config>), or pass --dir",
|
||||
)
|
||||
report = analyze_dir(out_dir, min_pixels)
|
||||
path = Path(report_path) if report_path else out_dir / "report.json"
|
||||
path.write_text(json.dumps(report, indent=2))
|
||||
return report, path
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"garments": [
|
||||
{"item_id": "boots_modern", "slot": "feet", "covers": ["leg_lower_l", "leg_lower_r"]}
|
||||
],
|
||||
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
|
||||
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
|
||||
"frames_per_clip": 3,
|
||||
"yaws": [0, 90, 180, 270],
|
||||
"clip_epsilon_m": 0.03,
|
||||
"head_id": "head_001",
|
||||
"hair_id": "buzzed",
|
||||
"eyebrow_id": "regular",
|
||||
"skin_tone": 3,
|
||||
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/boots_modern"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"garments": [
|
||||
{"item_id": "buttondown_modern", "slot": "torso"}
|
||||
],
|
||||
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
|
||||
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
|
||||
"frames_per_clip": 3,
|
||||
"yaws": [0, 90, 180, 270],
|
||||
"clip_epsilon_m": 0.03,
|
||||
"head_id": "head_001",
|
||||
"hair_id": "buzzed",
|
||||
"eyebrow_id": "regular",
|
||||
"skin_tone": 3,
|
||||
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/buttondown_modern"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"garments": [
|
||||
{"item_id": "cargo_pants", "slot": "legs"}
|
||||
],
|
||||
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
|
||||
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
|
||||
"frames_per_clip": 3,
|
||||
"yaws": [0, 90, 180, 270],
|
||||
"clip_epsilon_m": 0.03,
|
||||
"head_id": "head_001",
|
||||
"hair_id": "buzzed",
|
||||
"eyebrow_id": "regular",
|
||||
"skin_tone": 3,
|
||||
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/cargo_pants"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"garments": [
|
||||
{"item_id": "hoodie_modern", "slot": "torso"}
|
||||
],
|
||||
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
|
||||
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
|
||||
"frames_per_clip": 3,
|
||||
"yaws": [0, 90, 180, 270],
|
||||
"clip_epsilon_m": 0.03,
|
||||
"head_id": "head_001",
|
||||
"hair_id": "buzzed",
|
||||
"eyebrow_id": "regular",
|
||||
"skin_tone": 3,
|
||||
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/hoodie_modern"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"garments": [
|
||||
{"item_id": "jacket_modern", "slot": "torso"}
|
||||
],
|
||||
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
|
||||
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
|
||||
"frames_per_clip": 3,
|
||||
"yaws": [0, 90, 180, 270],
|
||||
"clip_epsilon_m": 0.03,
|
||||
"head_id": "head_001",
|
||||
"hair_id": "buzzed",
|
||||
"eyebrow_id": "regular",
|
||||
"skin_tone": 3,
|
||||
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/jacket_modern"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"garments": [
|
||||
{"item_id": "jeans_modern", "slot": "legs"}
|
||||
],
|
||||
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
|
||||
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
|
||||
"frames_per_clip": 3,
|
||||
"yaws": [0, 90, 180, 270],
|
||||
"clip_epsilon_m": 0.03,
|
||||
"head_id": "head_001",
|
||||
"hair_id": "buzzed",
|
||||
"eyebrow_id": "regular",
|
||||
"skin_tone": 3,
|
||||
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/jeans_modern"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"garments": [
|
||||
{"item_id": "joggers_modern", "slot": "legs"}
|
||||
],
|
||||
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
|
||||
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
|
||||
"frames_per_clip": 3,
|
||||
"yaws": [0, 90, 180, 270],
|
||||
"clip_epsilon_m": 0.03,
|
||||
"head_id": "head_001",
|
||||
"hair_id": "buzzed",
|
||||
"eyebrow_id": "regular",
|
||||
"skin_tone": 3,
|
||||
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/joggers_modern"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"garments": [
|
||||
{"item_id": "pants_formal", "slot": "legs"}
|
||||
],
|
||||
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
|
||||
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
|
||||
"frames_per_clip": 3,
|
||||
"yaws": [0, 90, 180, 270],
|
||||
"clip_epsilon_m": 0.03,
|
||||
"head_id": "head_001",
|
||||
"hair_id": "buzzed",
|
||||
"eyebrow_id": "regular",
|
||||
"skin_tone": 3,
|
||||
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/pants_formal"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"garments": [
|
||||
{"item_id": "parka_thrds", "slot": "torso"}
|
||||
],
|
||||
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
|
||||
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
|
||||
"frames_per_clip": 3,
|
||||
"yaws": [0, 90, 180, 270],
|
||||
"clip_epsilon_m": 0.03,
|
||||
"head_id": "head_001",
|
||||
"hair_id": "buzzed",
|
||||
"eyebrow_id": "regular",
|
||||
"skin_tone": 3,
|
||||
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/parka_thrds"
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"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],
|
||||
"clip_epsilon_m": 0.03,
|
||||
"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"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"garments": [
|
||||
{"item_id": "shoes_formal", "slot": "feet", "covers": ["foot_l", "foot_r"]}
|
||||
],
|
||||
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
|
||||
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
|
||||
"frames_per_clip": 3,
|
||||
"yaws": [0, 90, 180, 270],
|
||||
"clip_epsilon_m": 0.03,
|
||||
"head_id": "head_001",
|
||||
"hair_id": "buzzed",
|
||||
"eyebrow_id": "regular",
|
||||
"skin_tone": 3,
|
||||
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/shoes_formal"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"garments": [
|
||||
{"item_id": "shorts_modern", "slot": "legs"}
|
||||
],
|
||||
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
|
||||
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
|
||||
"frames_per_clip": 3,
|
||||
"yaws": [0, 90, 180, 270],
|
||||
"clip_epsilon_m": 0.03,
|
||||
"head_id": "head_001",
|
||||
"hair_id": "buzzed",
|
||||
"eyebrow_id": "regular",
|
||||
"skin_tone": 3,
|
||||
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/shorts_modern"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"garments": [
|
||||
{"item_id": "slides", "slot": "feet", "covers": ["foot_l", "foot_r"]}
|
||||
],
|
||||
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
|
||||
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
|
||||
"frames_per_clip": 3,
|
||||
"yaws": [0, 90, 180, 270],
|
||||
"clip_epsilon_m": 0.012,
|
||||
"head_id": "head_001",
|
||||
"hair_id": "buzzed",
|
||||
"eyebrow_id": "regular",
|
||||
"skin_tone": 3,
|
||||
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/slides"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"garments": [
|
||||
{"item_id": "sneakers_modern", "slot": "feet"}
|
||||
],
|
||||
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
|
||||
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
|
||||
"frames_per_clip": 3,
|
||||
"yaws": [0, 90, 180, 270],
|
||||
"clip_epsilon_m": 0.03,
|
||||
"head_id": "head_001",
|
||||
"hair_id": "buzzed",
|
||||
"eyebrow_id": "regular",
|
||||
"skin_tone": 3,
|
||||
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/sneakers_modern"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"garments": [
|
||||
{"item_id": "suit_jacket_black", "slot": "torso"}
|
||||
],
|
||||
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
|
||||
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
|
||||
"frames_per_clip": 3,
|
||||
"yaws": [0, 90, 180, 270],
|
||||
"clip_epsilon_m": 0.03,
|
||||
"head_id": "head_001",
|
||||
"hair_id": "buzzed",
|
||||
"eyebrow_id": "regular",
|
||||
"skin_tone": 3,
|
||||
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/suit_jacket_black"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"garments": [
|
||||
{"item_id": "sweater_modern", "slot": "torso"}
|
||||
],
|
||||
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
|
||||
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
|
||||
"frames_per_clip": 3,
|
||||
"yaws": [0, 90, 180, 270],
|
||||
"clip_epsilon_m": 0.03,
|
||||
"head_id": "head_001",
|
||||
"hair_id": "buzzed",
|
||||
"eyebrow_id": "regular",
|
||||
"skin_tone": 3,
|
||||
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/sweater_modern"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"garments": [
|
||||
{"item_id": "swim_trunks", "slot": "legs"}
|
||||
],
|
||||
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
|
||||
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
|
||||
"frames_per_clip": 3,
|
||||
"yaws": [0, 90, 180, 270],
|
||||
"clip_epsilon_m": 0.03,
|
||||
"head_id": "head_001",
|
||||
"hair_id": "buzzed",
|
||||
"eyebrow_id": "regular",
|
||||
"skin_tone": 3,
|
||||
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/swim_trunks"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"garments": [
|
||||
{"item_id": "swimsuit_onepiece", "slot": "full_body"}
|
||||
],
|
||||
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
|
||||
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
|
||||
"frames_per_clip": 3,
|
||||
"yaws": [0, 90, 180, 270],
|
||||
"clip_epsilon_m": 0.03,
|
||||
"head_id": "head_001",
|
||||
"hair_id": "buzzed",
|
||||
"eyebrow_id": "regular",
|
||||
"skin_tone": 3,
|
||||
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/swimsuit_onepiece"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"garments": [
|
||||
{"item_id": "tank_top", "slot": "torso"}
|
||||
],
|
||||
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
|
||||
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
|
||||
"frames_per_clip": 3,
|
||||
"yaws": [0, 90, 180, 270],
|
||||
"clip_epsilon_m": 0.03,
|
||||
"head_id": "head_001",
|
||||
"hair_id": "buzzed",
|
||||
"eyebrow_id": "regular",
|
||||
"skin_tone": 3,
|
||||
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/tank_top"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"garments": [
|
||||
{"item_id": "track_jacket", "slot": "torso"}
|
||||
],
|
||||
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
|
||||
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
|
||||
"frames_per_clip": 3,
|
||||
"yaws": [0, 90, 180, 270],
|
||||
"clip_epsilon_m": 0.03,
|
||||
"head_id": "head_001",
|
||||
"hair_id": "buzzed",
|
||||
"eyebrow_id": "regular",
|
||||
"skin_tone": 3,
|
||||
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/track_jacket"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"garments": [
|
||||
{"item_id": "tshirt_modern", "slot": "torso"}
|
||||
],
|
||||
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
|
||||
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
|
||||
"frames_per_clip": 3,
|
||||
"yaws": [0, 90, 180, 270],
|
||||
"clip_epsilon_m": 0.03,
|
||||
"head_id": "head_001",
|
||||
"hair_id": "buzzed",
|
||||
"eyebrow_id": "regular",
|
||||
"skin_tone": 3,
|
||||
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/tshirt_modern"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"garments": [
|
||||
{"item_id": "uniform_utility", "slot": "full_body"}
|
||||
],
|
||||
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child"],
|
||||
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
|
||||
"frames_per_clip": 3,
|
||||
"yaws": [0, 90, 180, 270],
|
||||
"clip_epsilon_m": 0.03,
|
||||
"head_id": "head_001",
|
||||
"hair_id": "buzzed",
|
||||
"eyebrow_id": "regular",
|
||||
"skin_tone": 3,
|
||||
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/uniform_utility"
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Transport for the `character` domain — args in, delegate, format out."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
|
||||
from tooling.core import cli, console
|
||||
from tooling.core.command import command
|
||||
from tooling.core.errors import ReachError
|
||||
|
||||
app = cli.domain("character", "Bodies, garments, GLBs — logos, the GLB strip, garment QA.")
|
||||
|
||||
|
||||
@app.callback()
|
||||
def _domain() -> None:
|
||||
"""Keeps `character` a group (Typer collapses a single-command app)."""
|
||||
|
||||
|
||||
@app.command("logo")
|
||||
@command
|
||||
def logo(
|
||||
text: str = typer.Argument(..., help="The wordmark, e.g. thrds (brands are lowercase)."),
|
||||
out_png: Path = typer.Argument(..., help="Where to write the PNG."),
|
||||
size: int = typer.Option(256, "--size", help="Square edge in px."),
|
||||
) -> None:
|
||||
"""Render a flat white-on-transparent brand wordmark decal (placeholder supply)."""
|
||||
from tooling.domains.character import logo as service
|
||||
|
||||
result = service.make_logo(text, str(out_png), size)
|
||||
console.out(json.dumps(result))
|
||||
|
||||
|
||||
@app.command("strip-glb")
|
||||
@command
|
||||
def strip_glb(
|
||||
input: Path = typer.Argument(None, help="A .glb to strip."),
|
||||
output: Path = typer.Argument(None, help="Output .glb (default: overwrite the input)."),
|
||||
dir: Path = typer.Option(None, "--dir", help="Strip every .glb under a directory, in place."),
|
||||
) -> None:
|
||||
"""Remove utility nodes (Icosphere, WGT-, DEF-, ORG-, empty meshes) from GLB scene graphs."""
|
||||
from tooling.domains.character import glb_strip
|
||||
|
||||
if dir:
|
||||
result = glb_strip.process_directory(str(dir))
|
||||
elif input:
|
||||
result = glb_strip.process_file(str(input), str(output or input))
|
||||
else:
|
||||
raise ReachError("nothing to strip", fix="pass a .glb, or --dir <directory>")
|
||||
console.out(json.dumps(result))
|
||||
console.verdict(f"strip-glb: {result['stripped']} utility node(s) removed")
|
||||
|
||||
|
||||
@app.command("qa")
|
||||
@command
|
||||
def qa(
|
||||
config: str = typer.Argument(None, help="Config name in qa_configs/ (e.g. peasant) or a path."),
|
||||
min_pixels: int = typer.Option(8, "--min-pixels", help="Smallest clip blob that fails a capture."),
|
||||
) -> None:
|
||||
"""Capture in Godot, then analyze — the garment acceptance gate. Fails on clip-through."""
|
||||
from tooling.domains.character import qa as capture_service
|
||||
from tooling.domains.character import qa_analyze
|
||||
|
||||
plan = capture_service.capture_plan(config, dict(os.environ), Path.home())
|
||||
capture_service.capture(plan)
|
||||
report, path = qa_analyze.run(str(plan.config), None, min_pixels)
|
||||
_report(report, path, min_pixels)
|
||||
|
||||
|
||||
@app.command("qa-analyze")
|
||||
@command
|
||||
def qa_analyze_cmd(
|
||||
config: Path = typer.Option(None, "--config", help="Capture config (reads its out_dir)."),
|
||||
dir: Path = typer.Option(None, "--dir", help="Capture directory (overrides --config)."),
|
||||
min_pixels: int = typer.Option(8, "--min-pixels", help="Smallest clip blob that fails a capture."),
|
||||
report: Path = typer.Option(None, "--report", help="Report path (default: <out_dir>/report.json)."),
|
||||
) -> None:
|
||||
"""Re-analyze existing captures without re-rendering. Fails on clip-through."""
|
||||
from tooling.domains.character import qa_analyze
|
||||
|
||||
result, path = qa_analyze.run(
|
||||
str(config) if config else None, str(dir) if dir else None, min_pixels, str(report) if report else None
|
||||
)
|
||||
_report(result, path, min_pixels)
|
||||
|
||||
|
||||
def _report(report: dict, path: Path, min_pixels: int) -> None:
|
||||
"""Summary to stdout; a clip-through failure fails the command.
|
||||
|
||||
The analyzer used to exit 0 whatever it found, although its own README says
|
||||
clip-through "is the real defect and it gates" (T-1290).
|
||||
"""
|
||||
from tooling.domains.character import qa_analyze
|
||||
|
||||
console.out("\n".join(qa_analyze.summary_lines(report)))
|
||||
console.out(f" report : {path}")
|
||||
failures = report["clip_through_failures"]
|
||||
if failures:
|
||||
out_dir = Path(report["out_dir"])
|
||||
console.out(f" highlighted frames : {out_dir / 'failures'}")
|
||||
raise ReachError(
|
||||
f"garment-qa: {failures} of {report['total_captures']} captures clip through "
|
||||
f"(largest blob >= {min_pixels}px)",
|
||||
fix=f"inspect {out_dir / 'failures'}/*_HL.png; fix the garment, or re-run with a "
|
||||
"deliberately looser --min-pixels (Wave 1/2 were accepted at 150)",
|
||||
)
|
||||
console.verdict(f"garment-qa: {report['total_captures']} captures, no clip-through")
|
||||
Reference in New Issue
Block a user