Files
animation/tools/tailor/qc_placement.py
T
jeremy 3ba86b2ea8 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>
2026-08-06 15:55:43 -07:00

313 lines
12 KiB
Python

# Placement QC for drape screenshots: measures where garment fabric actually
# sits on Lena's body (in meters + against named landmarks) so fit is judged
# by numbers, not eyeballing.
#
# python tools/tailor/qc_placement.py <snapshot.png> # legacy report
# python tools/tailor/qc_placement.py <snapshot.png> --json # machine-readable
# [--colors 173,37,39 36,32,32 ...] [--tol 30] [--cover 0.4] [--scale 3]
#
# Method: classify pixels into background (desaturated gray gradient), skin
# (warm hue), and garment (everything else saturated / high-contrast pattern).
# Body rows (skin+garment) give the pixel<->meter scale via Lena's known
# height (1.777 m, feet on floor). Garment row-coverage profile then yields
# each garment band's top/bottom in meters, compared to the landmark table
# from tools/tailor/lena_measurements.json heights.
#
# TWO ENTRY POINTS (2026-07-31):
# main(path) -- the original standalone report. UNCHANGED behaviour;
# the auto classifier is honest but crude (it counts the
# body's baked-in underwear, dark brows and shaded skin
# as garment).
# measure_bands() -- importable, parameterised measurement used by
# clothing/gates/g1_drape.py. The landmark table, body
# height, garment mask and row thresholds are all
# arguments, so per-garment colour masks can be supplied
# from a config's `expect` block instead of relying on
# the crude auto heuristic. Returns a plain dict.
import sys
import json
from PIL import Image
LANDMARKS = { # meters, from lena_measurements.json / bone heights
"shoulder": 1.397, "bust": 1.264, "waist": 1.089,
"hip": 0.946, "mid_thigh": 0.73, "knee": 0.517, "ankle": 0.106,
}
HEIGHT = 1.777
def classify(px):
r, g, b = px[:3]
mx, mn = max(r, g, b), min(r, g, b)
sat = 0 if mx == 0 else (mx - mn) / mx
# background: gray gradient (low saturation, r~g~b)
if sat < 0.08 and abs(r - g) < 12 and abs(g - b) < 12:
return "bg"
# skin: warm, r > g > b with moderate saturation
if r > 120 and r > g > b and (r - b) > 25 and sat < 0.55:
return "skin"
return "garment"
def main(path):
img = Image.open(path).convert("RGB")
w, h = img.size
img = img.resize((w // 3, h // 3)) # speed
w, h = img.size
pix = img.load()
rows = []
for y in range(h):
counts = {"bg": 0, "skin": 0, "garment": 0}
for x in range(w):
counts[classify(pix[x, y])] += 1
rows.append(counts)
body_rows = [y for y, c in enumerate(rows) if c["skin"] + c["garment"] > w * 0.02]
if not body_rows:
print("no body found");
return
top_y, bot_y = min(body_rows), max(body_rows)
px_per_m = (bot_y - top_y) / HEIGHT
def to_m(y):
return (bot_y - y) / px_per_m
# garment bands: consecutive rows where garment pixels exceed threshold
gar_rows = [y for y, c in enumerate(rows) if c["garment"] > w * 0.02]
bands = []
for y in gar_rows:
if bands and y - bands[-1][1] <= 3:
bands[-1][1] = y
else:
bands.append([y, y])
print(f"body: {HEIGHT:.3f} m over {bot_y - top_y} px ({px_per_m:.1f} px/m)")
print(f"landmarks: " + ", ".join(f"{k}={v:.2f}" for k, v in LANDMARKS.items()))
print()
for i, (y0, y1) in enumerate(bands):
t, b = to_m(y0), to_m(y1)
near_t = min(LANDMARKS, key=lambda k: abs(LANDMARKS[k] - t))
near_b = min(LANDMARKS, key=lambda k: abs(LANDMARKS[k] - b))
print(f"garment band {i}: top {t:.2f} m (~{near_t}, "
f"{(t - LANDMARKS[near_t]) * 100:+.0f} cm), "
f"bottom {b:.2f} m (~{near_b}, {(b - LANDMARKS[near_b]) * 100:+.0f} cm), "
f"span {t - b:.2f} m")
# --------------------------------------------------------------------------
# Parameterised measurement (gate G1 calls this; nothing below changes main())
# --------------------------------------------------------------------------
DEFAULT_MASK = {"mode": "auto"}
def _is_bg(px):
"""Background = MD's desaturated gray backdrop gradient (the floor shadow too)."""
r, g, b = px[:3]
mx, mn = max(r, g, b), min(r, g, b)
sat = 0 if mx == 0 else (mx - mn) / mx
return sat < 0.08 and abs(r - g) < 12 and abs(g - b) < 12
def _garment_test(mask):
"""Build a pixel -> bool garment predicate from a mask spec.
mask = {"mode": "auto"} the crude classify() heuristic
| {"mode": "colors", "colors": [[r,g,b], ...], "tol": 30}
per-garment colour mask -- the
accurate path when a garment has
a known palette (a generated
texture always does)
| {"mode": "sat", "min_sat": 0.6, "min_value": 0.15, "max_value": 1.0}
anything strongly saturated
"""
mode = (mask or {}).get("mode", "auto")
if mode == "auto":
return lambda px: classify(px) == "garment"
if mode == "colors":
cols = [tuple(int(c) for c in col[:3]) for col in mask.get("colors") or []]
if not cols:
raise ValueError("mask mode 'colors' needs a non-empty `colors` list")
tol2 = float(mask.get("tol", 30)) ** 2
def near(px):
r, g, b = px[:3]
for cr, cg, cb in cols:
if (r - cr) ** 2 + (g - cg) ** 2 + (b - cb) ** 2 <= tol2:
return True
return False
return near
if mode == "sat":
lo_s = float(mask.get("min_sat", 0.6))
lo_v = float(mask.get("min_value", 0.15)) * 255.0
hi_v = float(mask.get("max_value", 1.0)) * 255.0
def sat_ok(px):
r, g, b = px[:3]
mx, mn = max(r, g, b), min(r, g, b)
s = 0 if mx == 0 else (mx - mn) / mx
return s >= lo_s and lo_v <= mx <= hi_v
return sat_ok
raise ValueError("unknown mask mode {!r} (auto|colors|sat)".format(mode))
def _bands_from_rows(flags, gap_rows):
"""Consecutive True rows -> [start, end] spans, bridging gaps <= gap_rows."""
out = []
for y, on in enumerate(flags):
if not on:
continue
if out and y - out[-1][1] <= gap_rows:
out[-1][1] = y
else:
out.append([y, y])
return out
def measure_bands(path, landmarks=None, height=HEIGHT, mask=None, scale=3,
min_body_frac=0.02, min_row_cover=0.40, min_row_extent=0.05,
gap_rows=3, z_range=None):
"""Measure garment band positions in a drape snapshot. Returns a dict.
landmarks {name: metres} table; defaults to LANDMARKS (Lena).
height body height in metres for the px->m calibration (Lena 1.777).
mask garment mask spec, see _garment_test().
scale integer downscale for speed (3 = the legacy 1/3).
min_body_frac a row counts as "body" when non-background pixels exceed
this fraction of image width.
min_row_cover a row belongs to a band when garment pixels are at least
this fraction of that row's BODY pixels. Fraction-of-body
(not fraction-of-image) is what makes the number stable in
a T-pose, where outstretched arms dominate image width.
0.4 separates a full band from straps/ties.
min_row_extent the looser threshold used for `extent_bands`, which include
straps, ties and stray fringes.
z_range optional [low_m, high_m]; rows outside are ignored. Use it to
exclude hair/brows/props whose colours collide with a dark
garment palette.
Bands are ordered top-first (highest metres first). Metres are measured from
the lowest body row (feet on floor) upward, exactly like main().
"""
landmarks = dict(landmarks or LANDMARKS)
mask = dict(mask or DEFAULT_MASK)
is_garment = _garment_test(mask)
scale = max(1, int(scale))
img = Image.open(path).convert("RGB")
full_w, full_h = img.size
if scale > 1:
# NEAREST: bicubic invents in-between colours and breaks a colour mask.
img = img.resize((full_w // scale, full_h // scale), Image.NEAREST)
w, h = img.size
pix = img.load()
body_counts, gar_counts = [], []
for y in range(h):
nb = ng = 0
for x in range(w):
px = pix[x, y]
if not _is_bg(px):
nb += 1
if is_garment(px):
ng += 1
body_counts.append(nb)
gar_counts.append(ng)
body_rows = [y for y, n in enumerate(body_counts) if n > w * min_body_frac]
if not body_rows:
raise ValueError("no body found in {} -- is this an MD 3D snapshot?".format(path))
top_y, bot_y = min(body_rows), max(body_rows)
px_per_m = (bot_y - top_y) / float(height)
def to_m(y):
return (bot_y - y) / px_per_m
def cover(y):
return gar_counts[y] / float(body_counts[y]) if body_counts[y] else 0.0
lo_m, hi_m = (float(z_range[0]), float(z_range[1])) if z_range else (None, None)
def in_range(y):
return z_range is None or lo_m <= to_m(y) <= hi_m
def collect(threshold):
spans = _bands_from_rows([cover(y) >= threshold and gar_counts[y] > 0 and in_range(y)
for y in range(h)], gap_rows)
out = []
for y0, y1 in spans:
t, b = to_m(y0), to_m(y1)
near_t = min(landmarks, key=lambda k: abs(landmarks[k] - t)) if landmarks else None
near_b = min(landmarks, key=lambda k: abs(landmarks[k] - b)) if landmarks else None
out.append({
"top_m": round(t, 4), "bottom_m": round(b, 4), "span_m": round(t - b, 4),
"rows": [y0, y1],
"peak_cover": round(max(cover(y) for y in range(y0, y1 + 1)), 3),
"px": int(sum(gar_counts[y0:y1 + 1])),
"near_top": near_t, "near_bottom": near_b,
"near_top_delta_m": None if near_t is None else round(t - landmarks[near_t], 4),
"near_bottom_delta_m": None if near_b is None else round(b - landmarks[near_b], 4),
})
return out
return {
"image": path,
"size": [full_w, full_h],
"scale": scale,
"mask": mask,
"height_m": height,
"px_per_m": round(px_per_m, 3),
"body_rows": [top_y, bot_y],
"garment_px": int(sum(gar_counts)),
"min_row_cover": min_row_cover,
"min_row_extent": min_row_extent,
"z_range": list(z_range) if z_range else None,
"landmarks": landmarks,
"bands": collect(min_row_cover),
"extent_bands": collect(min_row_extent),
}
def _cli(argv):
if not argv:
print(__doc__ or "usage: qc_placement.py <snapshot.png> [--json ...]", file=sys.stderr)
return 2
path = argv[0]
rest = argv[1:]
if "--json" not in rest:
main(path) # unchanged legacy report
return 0
kwargs, mask = {}, None
i = 0
while i < len(rest):
arg = rest[i]
if arg == "--json":
i += 1
elif arg == "--colors":
cols, i = [], i + 1
while i < len(rest) and not rest[i].startswith("--"):
cols.append([int(v) for v in rest[i].split(",")])
i += 1
mask = {"mode": "colors", "colors": cols}
elif arg in ("--tol", "--cover", "--extent", "--scale", "--height"):
key = {"--tol": "tol", "--cover": "min_row_cover", "--extent": "min_row_extent",
"--scale": "scale", "--height": "height"}[arg]
val = float(rest[i + 1])
if key == "tol":
mask = dict(mask or {"mode": "colors", "colors": []}, tol=val)
elif key == "scale":
kwargs["scale"] = int(val)
else:
kwargs[key] = val
i += 2
else:
print("unknown flag {}".format(arg), file=sys.stderr)
return 2
print(json.dumps(measure_bands(path, mask=mask, **kwargs), indent=2))
return 0
if __name__ == "__main__":
sys.exit(_cli(sys.argv[1:]))