189 lines
7.8 KiB
Python
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")
|