Files
animation/clothing/gates/g1_drape.py
T

283 lines
12 KiB
Python
Raw Normal View History

#!/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())