# 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 -- # # 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")