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>
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
# 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:]))
|
||||
Reference in New Issue
Block a user