feat: clothing lane, character sources, and DCC bridges
Bulk import of the working lanes that were living untracked on the PC. Content: - characters/ Lena/male body lanes, bakes, texture work, run logs - clothing/ garment pipeline, configs, gates, contract docs - garments/ MD-authored garment sources (.zprj/.zpac) - UAL-Lib/ Universal Animation Library 2 source (.blend/.fbx/.glb) - tools/ blender_bridge, iclone_bridge, md_bridge, tailor, glm_agent - docs/, plans/, dev/, .agents/plans/ Repo hygiene: - .gitattributes: LFS now covers .blend, .zprj, .zpac, .obj, .npy and the Reallusion .iAvatar/.ccAvatar/.ccRestore containers. Without this the ~3.8 GB in this commit would land as raw blobs. .png/.jpg are left out on purpose — ~250 are already tracked raw and converting them would rewrite every one without shrinking history. - .gitignore: exclude /accurig/ (~1 GB AccuRig program files, redistributable from Reallusion, nothing authored here) and /dev/null/ (git-lfs hook copies dropped by a `>/dev/null` redirect on Windows). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
#!/usr/bin/env python
|
||||
"""G1 -- drape placement gate (runs after the MD `drape` stage).
|
||||
|
||||
python clothing/gates/g1_drape.py --config <resolved.json> --work work/<name>
|
||||
[--snapshot <png>] [--quiet]
|
||||
|
||||
Exit codes (pipeline contract): 0 pass · 2 fail (thresholds violated) · 3 error
|
||||
(could not evaluate). Writes `<work>/qc/g1.json`.
|
||||
|
||||
WHY THIS GATE IS IMAGE-BASED. MD exposes no mesh introspection to Python
|
||||
(`GetClothPositions()` returns nothing), so every upstream drape judgement has to
|
||||
be made from the rendered viewport. G1 turns the prose QC targets that used to
|
||||
live in each garment script's header -- `md_pari.py:4` "band top ~1.31 m (above
|
||||
bust), hem ~1.05 m (waist) +-3 cm" -- into machine-checked numbers.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
`expect` KEYS THIS GATE READS
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
```jsonc
|
||||
"expect": {
|
||||
"bands": { // REQUIRED -- the gate map keys off this
|
||||
"taniko": {
|
||||
"top_m": 1.31, // where the band's top edge should sit, metres
|
||||
"bottom_m": 1.05, // where its bottom edge (hem) should sit
|
||||
"tol_m": 0.03, // symmetric tolerance for both edges
|
||||
"top_tol_m": 0.03, // optional per-edge override
|
||||
"bottom_tol_m": 0.04, // optional per-edge override
|
||||
"from": "cover", // "cover" (default) = rows where the garment
|
||||
// covers >= min_row_cover of the body's width
|
||||
// -- the band proper.
|
||||
// "extent" = the loose mask, which also picks up
|
||||
// straps, ties and fringes.
|
||||
"band_index": 0 // optional: force which measured band to use
|
||||
// (default: the one nearest the expectation)
|
||||
}
|
||||
},
|
||||
|
||||
"drape": { // OPTIONAL measurement parameters
|
||||
"snapshot": "work/pari/qc/drape.png", // overrides md.snapshot
|
||||
"height_m": 1.777, // body height for the px->m calibration
|
||||
"landmarks": {"waist": 1.089, ...}, // overrides the Lena landmark table
|
||||
"mask": { // HOW garment pixels are recognised
|
||||
"mode": "colors", // "auto" crude skin/bg heuristic (default;
|
||||
"colors": [[173, 37, 39]], // counts baked-in underwear and
|
||||
"tol": 30 // shaded skin as garment)
|
||||
}, // "colors" per-garment palette + tolerance --
|
||||
// use this whenever the garment has a
|
||||
// generated texture, i.e. always
|
||||
// "sat" anything strongly saturated
|
||||
"min_row_cover": 0.40, // band threshold, fraction of that row's BODY px
|
||||
"min_row_extent": 0.05, // loose threshold for `extent` bands
|
||||
"scale": 3, // integer downscale for speed
|
||||
"z_range": [0.2, 1.6] // ignore rows outside this height window (kills
|
||||
// hair/brow pixels that collide with a dark
|
||||
// garment palette)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Snapshot resolution order: `--snapshot` > `expect.drape.snapshot` > `md.snapshot`.
|
||||
Relative paths resolve against the config's directory, then the repo root.
|
||||
|
||||
The measurement itself lives in `tools/tailor/qc_placement.measure_bands()` so the
|
||||
standalone `python tools/tailor/qc_placement.py <png>` report and this gate share
|
||||
one implementation.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
REPO_ROOT = os.path.dirname(os.path.dirname(HERE))
|
||||
sys.path.insert(0, os.path.join(REPO_ROOT, "tools", "tailor"))
|
||||
|
||||
GATE_ID = "g1"
|
||||
STAGE = "drape"
|
||||
|
||||
PASS, FAIL, ERROR = 0, 2, 3
|
||||
|
||||
|
||||
def _resolve(path, config_dir):
|
||||
if not path:
|
||||
return None
|
||||
p = os.path.expanduser(str(path))
|
||||
if os.path.isabs(p):
|
||||
return os.path.normpath(p)
|
||||
for base in (config_dir, REPO_ROOT, os.getcwd()):
|
||||
cand = os.path.normpath(os.path.join(base, p))
|
||||
if os.path.exists(cand):
|
||||
return cand
|
||||
return os.path.normpath(os.path.join(config_dir, p))
|
||||
|
||||
|
||||
def _write(work, payload):
|
||||
qc_dir = os.path.join(work, "qc")
|
||||
os.makedirs(qc_dir, exist_ok=True)
|
||||
out = os.path.join(qc_dir, "{}.json".format(GATE_ID))
|
||||
with open(out, "w", encoding="utf-8") as fh:
|
||||
json.dump(payload, fh, indent=2)
|
||||
return out
|
||||
|
||||
|
||||
def _error(work, detail, quiet=False):
|
||||
payload = {"gate": GATE_ID, "pass": False, "checked_at_stage": STAGE,
|
||||
"metrics": {}, "failures": [{"part": None, "detail": detail}],
|
||||
"artifacts": [], "error": detail}
|
||||
try:
|
||||
_write(work, payload)
|
||||
except OSError:
|
||||
pass
|
||||
if not quiet:
|
||||
print("g1 ERROR: {}".format(detail), file=sys.stderr)
|
||||
return ERROR
|
||||
|
||||
|
||||
def evaluate(measurement, bands_spec):
|
||||
"""Match each expected band to a measured one and check both edges.
|
||||
|
||||
Matching is greedy nearest-first on |top - top_m| + |bottom - bottom_m|, so the
|
||||
ordering of `expect.bands` does not matter and one measured band is never
|
||||
claimed twice.
|
||||
"""
|
||||
failures, metrics = [], {}
|
||||
claimed = {"cover": set(), "extent": set()}
|
||||
order = []
|
||||
for name, spec in bands_spec.items():
|
||||
source = "extent" if str(spec.get("from", "cover")).lower() == "extent" else "cover"
|
||||
pool = measurement["extent_bands"] if source == "extent" else measurement["bands"]
|
||||
order.append((name, spec, source, pool))
|
||||
|
||||
for name, spec, source, pool in order:
|
||||
top_m, bot_m = spec.get("top_m"), spec.get("bottom_m")
|
||||
tol = float(spec.get("tol_m", 0.03))
|
||||
top_tol = float(spec.get("top_tol_m", tol))
|
||||
bot_tol = float(spec.get("bottom_tol_m", tol))
|
||||
|
||||
idx = spec.get("band_index")
|
||||
if idx is None:
|
||||
best, best_d = None, None
|
||||
for i, band in enumerate(pool):
|
||||
if i in claimed[source]:
|
||||
continue
|
||||
d = 0.0
|
||||
if top_m is not None:
|
||||
d += abs(band["top_m"] - float(top_m))
|
||||
if bot_m is not None:
|
||||
d += abs(band["bottom_m"] - float(bot_m))
|
||||
if best_d is None or d < best_d:
|
||||
best, best_d = i, d
|
||||
idx = best
|
||||
if idx is None or not (0 <= int(idx) < len(pool)):
|
||||
metrics[name] = {"matched": False, "source": source,
|
||||
"expected": {"top_m": top_m, "bottom_m": bot_m}}
|
||||
failures.append({"part": name,
|
||||
"detail": "no {} band found to match (measured {} band(s)); the "
|
||||
"garment may not have draped, or the mask matched "
|
||||
"nothing".format(source, len(pool))})
|
||||
continue
|
||||
idx = int(idx)
|
||||
claimed[source].add(idx)
|
||||
band = pool[idx]
|
||||
|
||||
entry = {"matched": True, "source": source, "band_index": idx,
|
||||
"measured_top_m": band["top_m"], "measured_bottom_m": band["bottom_m"],
|
||||
"span_m": band["span_m"], "peak_cover": band["peak_cover"],
|
||||
"near_top": band["near_top"], "near_bottom": band["near_bottom"],
|
||||
"expected": {"top_m": top_m, "bottom_m": bot_m,
|
||||
"top_tol_m": top_tol, "bottom_tol_m": bot_tol}}
|
||||
for edge, want, got, etol in (("top", top_m, band["top_m"], top_tol),
|
||||
("bottom", bot_m, band["bottom_m"], bot_tol)):
|
||||
if want is None:
|
||||
continue
|
||||
delta = got - float(want)
|
||||
entry["{}_delta_m".format(edge)] = round(delta, 4)
|
||||
entry["{}_pass".format(edge)] = abs(delta) <= etol
|
||||
if abs(delta) > etol:
|
||||
failures.append({
|
||||
"part": name,
|
||||
"detail": "{} edge at {:.3f} m, expected {:.3f} +-{:.3f} m "
|
||||
"({:+.1f} cm, {:+.1f} cm outside tolerance)".format(
|
||||
edge, got, float(want), etol, delta * 100.0,
|
||||
(abs(delta) - etol) * 100.0 * (1 if delta > 0 else -1)),
|
||||
})
|
||||
metrics[name] = entry
|
||||
return metrics, failures
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
ap = argparse.ArgumentParser(description="G1 -- drape placement gate.")
|
||||
ap.add_argument("--config", required=True, help="resolved.json")
|
||||
ap.add_argument("--work", required=True, help="work/<name> directory")
|
||||
ap.add_argument("--snapshot", help="override the drape snapshot PNG to measure")
|
||||
ap.add_argument("--quiet", action="store_true")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
work = os.path.abspath(args.work)
|
||||
try:
|
||||
with open(args.config, "r", encoding="utf-8") as fh:
|
||||
config = json.load(fh)
|
||||
except (OSError, ValueError) as exc:
|
||||
return _error(work, "could not read config {}: {}".format(args.config, exc), args.quiet)
|
||||
config_dir = os.path.dirname(os.path.abspath(args.config))
|
||||
|
||||
expect = config.get("expect") or {}
|
||||
bands_spec = expect.get("bands")
|
||||
if not isinstance(bands_spec, dict) or not bands_spec:
|
||||
return _error(work, "config has no `expect.bands` -- nothing for G1 to check",
|
||||
args.quiet)
|
||||
drape = expect.get("drape") or {}
|
||||
md = config.get("md") or {}
|
||||
|
||||
snapshot = _resolve(args.snapshot or drape.get("snapshot") or md.get("snapshot"), config_dir)
|
||||
if not snapshot:
|
||||
return _error(work, "no drape snapshot: set md.snapshot, expect.drape.snapshot, "
|
||||
"or pass --snapshot", args.quiet)
|
||||
if not os.path.exists(snapshot):
|
||||
return _error(work, "drape snapshot does not exist: {} (did the drape stage run?)"
|
||||
.format(snapshot), args.quiet)
|
||||
|
||||
try:
|
||||
from qc_placement import measure_bands, LANDMARKS
|
||||
except ImportError as exc:
|
||||
return _error(work, "cannot import tools/tailor/qc_placement.py ({}) -- G1 needs "
|
||||
"Pillow".format(exc), args.quiet)
|
||||
|
||||
kwargs = {"mask": drape.get("mask"),
|
||||
"landmarks": drape.get("landmarks") or LANDMARKS,
|
||||
"height": float(drape.get("height_m", 1.777)),
|
||||
"scale": int(drape.get("scale", 3)),
|
||||
"min_row_cover": float(drape.get("min_row_cover", 0.40)),
|
||||
"min_row_extent": float(drape.get("min_row_extent", 0.05)),
|
||||
"gap_rows": int(drape.get("gap_rows", 3)),
|
||||
"z_range": drape.get("z_range")}
|
||||
try:
|
||||
measurement = measure_bands(snapshot, **kwargs)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return _error(work, "measurement failed on {}: {}".format(snapshot, exc), args.quiet)
|
||||
|
||||
metrics, failures = evaluate(measurement, bands_spec)
|
||||
payload = {
|
||||
"gate": GATE_ID,
|
||||
"pass": not failures,
|
||||
"checked_at_stage": STAGE,
|
||||
"metrics": {"snapshot": snapshot,
|
||||
"px_per_m": measurement["px_per_m"],
|
||||
"body_rows": measurement["body_rows"],
|
||||
"garment_px": measurement["garment_px"],
|
||||
"bands": metrics,
|
||||
"measured": {"bands": measurement["bands"],
|
||||
"extent_bands": measurement["extent_bands"]}},
|
||||
"failures": failures,
|
||||
"artifacts": [snapshot],
|
||||
}
|
||||
out = _write(work, payload)
|
||||
|
||||
if not args.quiet:
|
||||
print("g1 drape placement -- {}".format(os.path.basename(snapshot)))
|
||||
print(" scale {:.1f} px/m over body rows {}".format(
|
||||
measurement["px_per_m"], measurement["body_rows"]))
|
||||
for name, m in metrics.items():
|
||||
if not m.get("matched"):
|
||||
print(" {:<16} NO MATCH ({} bands)".format(name, m["source"]))
|
||||
continue
|
||||
exp = m["expected"]
|
||||
bits = []
|
||||
for edge in ("top", "bottom"):
|
||||
if exp.get("{}_m".format(edge)) is None:
|
||||
continue
|
||||
bits.append("{} {:.3f} m (want {:.3f} +-{:.3f}, {:+.1f} cm) {}".format(
|
||||
edge, m["measured_{}_m".format(edge)], float(exp["{}_m".format(edge)]),
|
||||
exp["{}_tol_m".format(edge)], m["{}_delta_m".format(edge)] * 100.0,
|
||||
"PASS" if m["{}_pass".format(edge)] else "FAIL"))
|
||||
print(" {:<16} {}".format(name, ("\n" + " " * 19).join(bits)))
|
||||
print(" -> {} ({})".format("PASS" if not failures else "FAIL", out))
|
||||
return PASS if not failures else FAIL
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user