Files
animation/characters/female/lena_nude/hires_claude/32_ref_measure.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

189 lines
7.8 KiB
Python

# Stage 32: measure the approved concept turnaround against the current mesh, on the same
# landmarks, so "guided by the reference" becomes a list of numbers instead of an impression.
#
# blender --background --python 32_ref_measure.py -- <mesh.blend|glb> <front.png> <side.png>
#
# The reference (exchange/INBOX/lena-base-turnaround, GPT Image 2, approved by Can 2026-08-06) is
# a stylised mannequin: small high bust, fuller thighs, big head. The current sculpt is the Tripo
# scan lineage. Reshaping toward the reference is only safe once we know WHICH dimensions differ
# and by how much — a small-bust edit is a parameter change, whereas a head-size change is a
# different character and needs Jeremy's call, not mine.
#
# METHOD. Both sides are reduced to the same normalised profile so they are directly comparable:
# * reference: silhouette by saturation (the background is a desaturated dark grey, skin is not),
# then width w(y) from the front view and depth d(y) from the side view;
# * mesh: the same width/depth profiles taken straight from vertex positions per z-slice.
# Everything is divided by total body height, so image pixels and mesh units land in one table.
# Landmarks are found as extrema of those profiles (shoulder = widest upper slice, waist = the
# minimum between bust and hip, and so on) rather than at guessed heights.
import bpy, sys, os
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
MESH, FRONT, SIDE = argv[0], argv[1], argv[2]
def load_rgb(path):
img = bpy.data.images.load(path, check_existing=False)
w, h = img.size
b = np.empty(w * h * 4, dtype=np.float32)
img.pixels.foreach_get(b)
a = b.reshape(h, w, 4)[:, :, :3]
return a[::-1] # Blender rows are bottom-up; flip so row 0 is the top of the image
def silhouette(rgb):
mx = rgb.max(axis=2)
mn = rgb.min(axis=2)
sat = np.where(mx > 1e-5, (mx - mn) / np.maximum(mx, 1e-5), 0.0)
return (sat > 0.14) & (mx > 0.22)
def profile(mask):
"""Per-row extent of the silhouette: (first, last, width) in pixels, NaN on empty rows."""
rows = []
for y in range(mask.shape[0]):
xs = np.nonzero(mask[y])[0]
if len(xs) < 2:
rows.append((np.nan, np.nan, 0.0))
else:
rows.append((xs.min(), xs.max(), float(xs.max() - xs.min())))
return np.array(rows)
def band_max(vals, ys, lo, hi):
m = (ys >= lo) & (ys <= hi) & np.isfinite(vals)
if not m.any():
return np.nan, np.nan
i = np.nanargmax(np.where(m, vals, -np.inf))
return vals[i], ys[i]
def band_min(vals, ys, lo, hi):
m = (ys >= lo) & (ys <= hi) & np.isfinite(vals) & (vals > 0)
if not m.any():
return np.nan, np.nan
i = np.nanargmin(np.where(m, vals, np.inf))
return vals[i], ys[i]
# ============================================================ reference
print("=== REFERENCE ===")
ref = {}
for tag, path in (("front", FRONT), ("side", SIDE)):
rgb = load_rgb(path)
m = silhouette(rgb)
ys = np.nonzero(m.any(axis=1))[0]
top, bot = ys.min(), ys.max()
H = float(bot - top)
pr = profile(m)
# u = 0 at the soles, 1 at the top of the head
u = (bot - np.arange(m.shape[0])) / H
ref[tag] = dict(mask=m, pr=pr, u=u, top=top, bot=bot, H=H)
print(f"{tag}: image {rgb.shape[1]}x{rgb.shape[0]}, silhouette rows {top}..{bot}, "
f"height {H:.0f} px, area {int(m.sum())} px")
f = ref["front"]
w = f["pr"][:, 2] / f["H"]
u = f["u"]
# head: from the crown down to the narrowest slice above the shoulders
neck_w, neck_u = band_min(w, u, 0.80, 0.95)
sh_w, sh_u = band_max(w, u, 0.70, 0.88)
# the widest slice overall is the T-pose arm span; torso landmarks must exclude the arms, so
# restrict to below the armpit, which sits just under the shoulder slice
bust_w, bust_u = band_max(w, u, 0.62, neck_u - 0.02 if np.isfinite(neck_u) else 0.72)
waist_w, waist_u = band_min(w, u, 0.55, 0.68)
hip_w, hip_u = band_max(w, u, 0.44, 0.58)
thigh_w, thigh_u = band_max(w, u, 0.30, 0.44)
print(f" head top u=1.000 neck-min u={neck_u:.3f} (w {neck_w:.4f}) "
f"-> head height {1.0-neck_u:.3f} H => {1.0/(1.0-neck_u):.2f} heads tall")
print(f" shoulder u={sh_u:.3f} w={sh_w:.4f} (T-pose: includes arm root)")
print(f" waist u={waist_u:.3f} w={waist_w:.4f}")
print(f" hip u={hip_u:.3f} w={hip_w:.4f}")
print(f" thigh u={thigh_u:.3f} w={thigh_w:.4f}")
print(f" waist/hip {waist_w/hip_w:.3f} hip/shoulder {hip_w/sh_w:.3f}")
s = ref["side"]
d = s["pr"][:, 2] / s["H"]
su = s["u"]
front_x = s["pr"][:, 0] # smaller x = further front (figure faces -x in this render)
back_x = s["pr"][:, 1]
bust_d, bust_du = band_max(d, su, 0.66, 0.80)
under_d, under_du = band_min(d, su, 0.60, 0.70)
glute_d, glute_du = band_max(d, su, 0.46, 0.58)
print(f" side depth: bust u={bust_du:.3f} d={bust_d:.4f} | underbust u={under_du:.3f} "
f"d={under_d:.4f} | glute u={glute_du:.3f} d={glute_d:.4f}")
# bust projection = how much further forward the bust reaches than the underbust slice
if np.isfinite(bust_du) and np.isfinite(under_du):
ib = int(np.argmin(np.abs(su - bust_du)))
iu = int(np.argmin(np.abs(su - under_du)))
proj = (front_x[iu] - front_x[ib]) / s["H"]
print(f" BUST PROJECTION (front-most bust vs underbust) = {proj:+.4f} H")
# ============================================================ mesh
print("\n=== MESH ===")
if MESH.lower().endswith(".glb"):
bpy.ops.wm.read_homefile(use_empty=True)
bpy.ops.import_scene.gltf(filepath=MESH)
else:
bpy.ops.wm.open_mainfile(filepath=MESH)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
n = len(me.vertices)
co = np.empty(n * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
z0, z1 = co[:, 2].min(), co[:, 2].max()
Hm = z1 - z0
mu = (co[:, 2] - z0) / Hm
print(f"{os.path.basename(MESH)}: {n} v, height {Hm:.4f} units")
# arms must be excluded from torso widths: in a T-pose they dominate every slice they cross
NB = 220
uu = np.linspace(0, 1, NB)
wm = np.full(NB, np.nan)
dm = np.full(NB, np.nan)
fm = np.full(NB, np.nan)
bm_ = np.full(NB, np.nan)
for i, uc in enumerate(uu):
sel = np.abs(mu - uc) < (0.5 / NB) * 1.6
if sel.sum() < 8:
continue
sl = co[sel]
torso = sl[np.abs(sl[:, 0]) < 0.16] # drop the outstretched arms
if len(torso) >= 8:
wm[i] = (torso[:, 0].max() - torso[:, 0].min()) / Hm
dm[i] = (torso[:, 1].max() - torso[:, 1].min()) / Hm
fm[i] = torso[:, 1].min() / Hm
bm_[i] = torso[:, 1].max() / Hm
neck_wm, neck_um = band_min(wm, uu, 0.80, 0.95)
sh_wm, sh_um = band_max(wm, uu, 0.70, 0.88)
bust_wm, bust_um = band_max(wm, uu, 0.62, (neck_um - 0.02) if np.isfinite(neck_um) else 0.72)
waist_wm, waist_um = band_min(wm, uu, 0.55, 0.68)
hip_wm, hip_um = band_max(wm, uu, 0.44, 0.58)
thigh_wm, thigh_um = band_max(wm, uu, 0.30, 0.44)
print(f" neck-min u={neck_um:.3f} -> head height {1.0-neck_um:.3f} H "
f"=> {1.0/(1.0-neck_um):.2f} heads tall")
print(f" shoulder u={sh_um:.3f} w={sh_wm:.4f}")
print(f" waist u={waist_um:.3f} w={waist_wm:.4f}")
print(f" hip u={hip_um:.3f} w={hip_wm:.4f}")
print(f" thigh u={thigh_um:.3f} w={thigh_wm:.4f}")
print(f" waist/hip {waist_wm/hip_wm:.3f} hip/shoulder {hip_wm/sh_wm:.3f}")
bd, bdu = band_max(dm, uu, 0.66, 0.80)
ud, udu = band_min(dm, uu, 0.60, 0.70)
gd, gdu = band_max(dm, uu, 0.46, 0.58)
print(f" depth: bust u={bdu:.3f} d={bd:.4f} | underbust u={udu:.3f} d={ud:.4f} "
f"| glute u={gdu:.3f} d={gd:.4f}")
if np.isfinite(bdu) and np.isfinite(udu):
ib = int(np.argmin(np.abs(uu - bdu)))
iu = int(np.argmin(np.abs(uu - udu)))
print(f" BUST PROJECTION (front-most bust vs underbust) = {fm[iu]-fm[ib]:+.4f} H")
print("\n=== normalised width profile, mesh (u: 0=soles 1=crown) ===")
for i in range(NB - 1, -1, -8):
if np.isfinite(wm[i]):
print(f" u={uu[i]:.3f} w={wm[i]:.4f} d={dm[i]:.4f}")
print("REF_DONE")