#!/usr/bin/env python """G1 -- drape placement gate (runs after the MD `drape` stage). python clothing/gates/g1_drape.py --config --work work/ [--snapshot ] [--quiet] Exit codes (pipeline contract): 0 pass · 2 fail (thresholds violated) · 3 error (could not evaluate). Writes `/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 ` 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/ 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())