313 lines
12 KiB
Python
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:]))
|