# 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 # legacy report # python tools/tailor/qc_placement.py --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 [--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:]))