Files
jeremy 3d8825f5a9 reorg(characters): ship-time folders — lena_base_v01 ships, lane moves to work/lena
REGISTRY rewritten around the central rule: a character folder is born only
when a body ships to ariki-game (<character>_base_v<NN> = ship ordinal).
lena_nude dissolves accordingly:
- characters/female/lena_base_v01/ — SHIPPED 2026-08-10: AccuRig GLB carrier,
  T-pose/rig FBX + JSON, previews, frozen README
- characters/work/lena/ — the live lane: recipes 01-47 (incl. new 36-47:
  refill/sheets/clay/despeckle/musculature/spin/AccuRig export/graft/pose QC),
  masters (athletic_v04 blend + textures, accurig blend), lane-history README
- hires_claude/hires_work intermediates (blends, logs, probes) pruned

Supporting docs: AGENTS.md, working-files rule, rig-graft plan addendum,
originals README, prune_lane.py.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 07:17:15 -07:00

429 lines
19 KiB
Python

# Stage 24: a proper human UV atlas — ~10 anatomical charts instead of Tripo's 5,870 blobs.
#
# blender --background --python 24_seams.py -- <mesh.glb|blend> <out_dir> [decimate_ratio]
#
# Every seam is placed off a MEASURED landmark, not a guessed threshold, so the same rules port
# between the hires sculpt and the decimated game body:
# neck / wrist / ankle = local minimum of cross-section radius (the narrow part)
# shoulder = smallest |s| whose slice is short in u (arm, not torso)
# hip = lowest slice that still has vertices on the midline (the crotch)
# Lengthwise cuts open each tube flat, hidden where nobody looks:
# torso + head -> back midline; arms -> back of the arm; legs -> inner side.
# Hands / feet / head need no lengthwise cut: a tube with one closed end is already a disc.
import bpy, bmesh, sys, os, time
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
SRC = argv[0]
OUTDIR = os.path.abspath(argv[1])
RATIO = float(argv[2]) if len(argv) > 2 else 0.0
os.makedirs(OUTDIR, exist_ok=True)
t0 = time.time()
def log(m):
print(f"[seam {time.time()-t0:6.1f}s] {m}", flush=True)
if SRC.lower().endswith(".glb"):
bpy.ops.wm.read_homefile(use_empty=True)
bpy.ops.import_scene.gltf(filepath=SRC)
else:
bpy.ops.wm.open_mainfile(filepath=SRC)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
bpy.context.view_layer.objects.active = ob
for o in bpy.data.objects:
o.select_set(o is ob)
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
log(f"body '{ob.name}' {len(ob.data.vertices)}v {len(ob.data.polygons)}f")
if 0 < RATIO < 1:
m = ob.modifiers.new("dec", 'DECIMATE')
m.ratio = RATIO
bpy.ops.object.modifier_apply(modifier=m.name)
log(f"decimated -> {len(ob.data.vertices)}v {len(ob.data.polygons)}f")
# The source mesh is non-manifold (1,649 edges with >2 faces) and decimation turns that into a
# scatter of degenerate/duplicate triangles. Each one becomes its own UV island — that is where
# the confetti comes from, not the seam rules. Clean it here, before any of it reaches the unwrap.
def clean_mesh(tag):
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.mesh.remove_doubles(threshold=1e-5)
bpy.ops.mesh.dissolve_degenerate(threshold=1e-6)
bpy.ops.mesh.delete_loose(use_verts=True, use_edges=True, use_faces=False)
# (Splitting the 1,649 non-manifold edges was tried here and rejected: it cost +3,512 verts
# and made the collapsed-face count worse, because the collapse is a solver problem, not a
# topology one — see the unwrap method below.)
bpy.ops.mesh.normals_make_consistent(inside=False)
bpy.ops.object.mode_set(mode='OBJECT')
bm_ = bmesh.new(); bm_.from_mesh(ob.data)
nm = sum(1 for e in bm_.edges if len(e.link_faces) > 2)
dg = sum(1 for f in bm_.faces if f.calc_area() < 1e-12)
bm_.free()
log(f"clean[{tag}]: {len(ob.data.vertices)}v {len(ob.data.polygons)}f "
f"nonmanifold_edges={nm} degenerate_faces={dg}")
clean_mesh("after decimate")
me = ob.data
n_v = len(me.vertices)
co = np.empty(n_v * 3); me.vertices.foreach_get("co", co); co = co.reshape(-1, 3)
lo, hi = co.min(axis=0), co.max(axis=0)
span = hi - lo
UP = int(np.argmax(span))
LR = int(np.argmax(np.where(np.arange(3) == UP, -1, span)))
FB = 3 - UP - LR
u = (co[:, UP] - lo[UP]) / span[UP]
s = co[:, LR] - 0.5 * (lo[LR] + hi[LR])
d = co[:, FB] - 0.5 * (lo[FB] + hi[FB])
HALF = 0.5 * span[LR]
sn = s / HALF # left-right, normalised to +-1
print(f"axes up={'xyz'[UP]} lr={'xyz'[LR]} fb={'xyz'[FB]} height {span[UP]:.4f} units")
# ---------------------------------------------------------------- landmarks
def radius_profile(mask, coord, lo_c, hi_c, nb):
"""mean in-slice radius vs coord, over `nb` bins — the narrow parts are the joints."""
ed = np.linspace(lo_c, hi_c, nb + 1)
mid = 0.5 * (ed[:-1] + ed[1:])
out = np.full(nb, np.nan)
for i in range(nb):
m = mask & (coord >= ed[i]) & (coord < ed[i + 1])
if m.sum() < 30:
continue
A = np.stack([s[m], d[m], u[m]], axis=1)
A = np.delete(A, 0 if coord is sn else 2, axis=1) if False else A
# radius measured in the two axes perpendicular to `coord`
if coord is u:
P = np.stack([s[m], d[m]], axis=1)
else:
P = np.stack([d[m], (u[m] - u[m].mean()) * span[UP]], axis=1)
out[i] = np.linalg.norm(P - P.mean(axis=0), axis=1).mean()
return mid, out
def local_min(mid, prof, lo_c, hi_c):
m = (mid >= lo_c) & (mid <= hi_c) & np.isfinite(prof)
if not m.any():
return 0.5 * (lo_c + hi_c)
return float(mid[m][np.argmin(prof[m])])
def taper_end(mid, prof, lo_c, hi_c, from_high, tol=0.08):
"""Where the limb stops tapering, taken from the EXTREMITY side.
The global minimum of the radius profile is not the joint: on the leg it sits up the shin,
well above where the foot ends. The joint is the end of the taper nearest the extremity —
the first bin (walking in from that side) that reaches within `tol` of the minimum.
"""
m = (mid >= lo_c) & (mid <= hi_c) & np.isfinite(prof)
if not m.any():
return 0.5 * (lo_c + hi_c)
mm, pp = mid[m], prof[m]
close = np.nonzero(pp <= pp.min() * (1.0 + tol))[0]
return float(mm[close[-1] if from_high else close[0]])
torso_side = np.abs(sn) < 0.30
mid_u, prof_u = radius_profile(torso_side, u, 0.0, 1.0, 60)
NECK_U = local_min(mid_u, prof_u, 0.80, 0.93)
# The ankle must be measured on ONE leg. Over both, the "radius" is really the gap between
# them, which falls monotonically from the feet up and has no minimum to find.
one_leg = (sn > 0.05) & (u < 0.35)
mid_l, prof_l = radius_profile(one_leg, u, 0.0, 0.35, 35)
print(" single-leg radius profile (u -> radius), the ankle is the narrow point:")
print(" " + " ".join(f"{m:.2f}:{r*1000:.0f}" for m, r in zip(mid_l, prof_l)
if np.isfinite(r)))
ANKLE_U = taper_end(mid_l, prof_l, 0.02, 0.14, from_high=False)
# shoulder: the smallest |s| whose slice is SHORT in u (an arm), scanning outward
absn = np.abs(sn)
ARM_IN = 0.35
for cut in np.arange(0.15, 0.60, 0.01):
m = (absn >= cut) & (absn < cut + 0.03)
if m.sum() < 30:
continue
if (u[m].max() - u[m].min()) < 0.13:
ARM_IN = float(cut)
break
mid_a, prof_a = radius_profile(absn > ARM_IN, absn, ARM_IN, 1.0, 40)
print(" arm radius profile (|s| -> radius), the wrist is the narrow point before the hand:")
print(" " + " ".join(f"{m:.2f}:{r*1000:.0f}" for m, r in zip(mid_a, prof_a)
if np.isfinite(r)))
# The profile reads: radius falls to the wrist, bulges again over the palm, then tapers down
# the fingers. Search below the palm bulge or the "wrist" lands among the fingers.
WRIST = taper_end(mid_a, prof_a, 0.60, 0.80, from_high=True)
# hip: lowest slice that still has vertices on the midline -> that is the crotch
HIP_U = 0.45
for lv in np.arange(0.60, 0.20, -0.005):
m = (u >= lv) & (u < lv + 0.01) & (absn < 0.025)
if m.sum() < 3:
HIP_U = float(lv + 0.01)
break
print(f"LANDMARKS neck_u {NECK_U:.3f} hip_u {HIP_U:.3f} ankle_u {ANKLE_U:.3f} "
f"arm_in {ARM_IN:.3f} wrist {WRIST:.3f} (fractions of height / half-span)")
is_arm = absn > ARM_IN
is_hand = absn > WRIST
is_head = u > NECK_U
is_leg = (u < HIP_U) & ~is_arm
is_foot = u < ANKLE_U
# centre lines for the lengthwise cuts
# A constant centre works only for a perfectly axis-aligned limb. Hers droop, so a level set of
# u wanders off the arm and cuts it twice. Use a measured centre LINE: median u per |s| bin for
# the arms, median |s| per u bin for each leg, linearly interpolated.
def centre_line(mask, along, of, nb, lo_a, hi_a):
ed = np.linspace(lo_a, hi_a, nb + 1)
mid = 0.5 * (ed[:-1] + ed[1:])
val = np.full(nb, np.nan)
for i in range(nb):
m = mask & (along >= ed[i]) & (along < ed[i + 1])
if m.sum() >= 20:
val[i] = np.median(of[m])
ok = np.isfinite(val)
if ok.sum() < 2:
return lambda q: np.full_like(q, np.nanmedian(of[mask]) if mask.any() else 0.0)
mid_o, val_o = mid[ok], val[ok]
return lambda q: np.interp(np.asarray(q), mid_o, val_o)
arm_u_of = centre_line(is_arm & ~is_hand, absn, u, 24, ARM_IN, WRIST)
leg_s_of = {}
for sg in (-1, 1):
m = is_leg & ~is_foot & (np.sign(sn) == sg)
leg_s_of[sg] = centre_line(m, u, absn, 20, ANKLE_U, HIP_U)
# A foot cut at the ankle is an L (ankle-heel-toes) and a hand is a flat paddle; neither
# flattens as one chart. Split each along its silhouette, exactly where an artist would:
# the foot into upper/sole at its mid-height, the hand into back/palm at its mid-thickness.
U_SOLE = float(np.median(u[is_foot])) if is_foot.sum() else ANKLE_U * 0.5
D_PALM = float(np.median(d[is_hand])) if is_hand.sum() else 0.0
print(f" foot split at u {U_SOLE:.3f} (upper|sole) hand split at d {D_PALM:+.4f} (back|palm)")
qa = np.linspace(ARM_IN, WRIST, 5)
print(f" arm centre line u at |s|={np.round(qa,2).tolist()}: "
f"{np.round(arm_u_of(qa), 3).tolist()}")
ql = np.linspace(ANKLE_U, HIP_U, 5)
print(f" leg(+) centre line |s| at u={np.round(ql,2).tolist()}: "
f"{np.round(leg_s_of[1](ql), 3).tolist()}")
# The torso is a tube with FOUR holes (neck, two armholes, hip). The back midline joins neck to
# hip; two more cuts are needed or the chart stays multiply-connected and the unwrap stretches it
# badly. Run a relief cut across the BACK at armpit height, from each armhole in to the midline.
# upper body only — at |s| = ARM_IN the feet also splay out that far, and they win a p5
m_pit = (np.abs(absn - ARM_IN) < 0.03) & (u > 0.5)
U_PIT = float(np.percentile(u[m_pit], 5)) if m_pit.sum() > 30 else 0.62
print(f" armpit u {U_PIT:.3f} -> relief cut across the back at that height")
# ---------------------------------------------------------------- mark seams
bm = bmesh.new()
bm.from_mesh(me)
tally = {}
def hit(k):
tally[k] = tally.get(k, 0) + 1
return True
for e in bm.edges:
e.seam = False
for e in bm.edges:
a, b = e.verts[0].index, e.verts[1].index
# rings, in order of priority
if is_head[a] != is_head[b]:
e.seam = hit("neck ring"); continue
if is_hand[a] != is_hand[b]:
e.seam = hit("wrist rings"); continue
if is_foot[a] != is_foot[b]:
e.seam = hit("ankle rings"); continue
if is_arm[a] != is_arm[b]:
e.seam = hit("shoulder rings"); continue
if (u[a] < HIP_U) != (u[b] < HIP_U):
e.seam = hit("hip ring"); continue
# lengthwise cuts
if is_foot[a] and is_foot[b]:
if (u[a] > U_SOLE) != (u[b] > U_SOLE):
e.seam = hit("foot sole line"); continue
elif is_hand[a] and is_hand[b]:
if (d[a] > D_PALM) != (d[b] > D_PALM):
e.seam = hit("hand palm line"); continue
elif is_arm[a] and is_arm[b] and not (is_hand[a] or is_hand[b]):
ca, cb = arm_u_of(absn[a]), arm_u_of(absn[b])
if d[a] > 0 and d[b] > 0 and (u[a] > ca) != (u[b] > cb):
e.seam = hit("arm back line"); continue
elif is_leg[a] and is_leg[b] and not (is_foot[a] or is_foot[b]):
sg = int(1 if sn[a] + sn[b] >= 0 else -1)
if absn[a] < leg_s_of[sg](u[a]) and absn[b] < leg_s_of[sg](u[b]) \
and (d[a] > 0) != (d[b] > 0):
e.seam = hit("leg inner line"); continue
elif not (is_arm[a] or is_arm[b] or is_foot[a] or is_foot[b]):
# torso and head share one continuous back midline
if d[a] > 0 and d[b] > 0 and (sn[a] > 0) != (sn[b] > 0):
e.seam = hit("back midline"); continue
# armpit relief: back only, from each armhole inward to the midline
if d[a] > 0 and d[b] > 0 and not is_head[a] and not is_head[b] \
and (u[a] > U_PIT) != (u[b] > U_PIT):
e.seam = hit("armpit relief"); continue
for k in sorted(tally):
print(f" seam '{k}': {tally[k]} edges")
bm.to_mesh(me)
bm.free()
log(f"marked {sum(tally.values())} seam edges")
# ---------------------------------------------------------------- unwrap
UNWRAP_METHOD = os.environ.get("UNWRAP_METHOD", "MINIMUM_STRETCH")
def do_unwrap(pack):
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
# Angle-based (ABF) is conformal: it preserves angles and is free to crush area. At her folds
# and flaps that means whole patches land under one texel and come out untextured. The
# minimum-stretch (SLIM) solver optimises area distortion instead, which is what a texture
# transfer actually needs.
try:
bpy.ops.uv.unwrap(method=UNWRAP_METHOD, margin=0.0)
except TypeError:
log(f"unwrap method {UNWRAP_METHOD} unavailable — falling back to ANGLE_BASED")
bpy.ops.uv.unwrap(method='ANGLE_BASED', margin=0.0)
bpy.ops.uv.average_islands_scale()
if pack:
try:
bpy.ops.uv.pack_islands(rotate=True, margin=0.003, scale=True)
except TypeError:
bpy.ops.uv.pack_islands(margin=0.003)
bpy.ops.object.mode_set(mode='OBJECT')
def get_islands():
m_ = ob.data
n_l_, n_f_ = len(m_.loops), len(m_.polygons)
lv = np.empty(n_l_, dtype=np.int32); m_.loops.foreach_get("vertex_index", lv)
uv_ = np.empty(n_l_ * 2); m_.uv_layers.active.data.foreach_get("uv", uv_)
uv_ = uv_.reshape(-1, 2)
ls = np.empty(n_f_, dtype=np.int32); m_.polygons.foreach_get("loop_start", ls)
lt = np.empty(n_f_, dtype=np.int32); m_.polygons.foreach_get("loop_total", lt)
li_ = ls[lt == 3]
Q = 1 << 20
k = (lv.astype(np.int64) * Q * Q
+ np.round(np.clip(uv_[:, 0], 0, 1) * (Q - 1)).astype(np.int64) * Q
+ np.round(np.clip(uv_[:, 1], 0, 1) * (Q - 1)).astype(np.int64))
_, uvv_ = np.unique(k, return_inverse=True)
n_uvv_ = uvv_.max() + 1
T_ = np.stack([uvv_[li_], uvv_[li_ + 1], uvv_[li_ + 2]], axis=1)
par = np.arange(n_uvv_, dtype=np.int64)
def find(x):
r = x
while par[r] != r:
r = par[r]
while par[x] != r:
par[x], x = r, par[x]
return r
for a_, b_, c_ in T_:
ra, rb, rc = find(a_), find(b_), find(c_)
if ra != rb:
par[rb] = ra
if ra != rc:
par[rc] = ra
_, isl_ = np.unique(np.array([find(i) for i in range(n_uvv_)]), return_inverse=True)
return isl_, isl_[T_[:, 0]], li_, lv, uv_, isl_.max() + 1
# Keep the source UVs on a second layer. Stage 31 needs them to resample the existing maps into
# the new layout — unwrapping over them in place would throw the textures away.
src_layer = ob.data.uv_layers.active
src_layer.name = "UVMap_tripo"
new_layer = ob.data.uv_layers.new(name="UVMap_atlas", do_init=True)
ob.data.uv_layers.active = new_layer
for i, l in enumerate(ob.data.uv_layers):
if l is new_layer:
ob.data.uv_layers.active_index = i
log(f"uv layers: {[l.name for l in ob.data.uv_layers]} active="
f"{ob.data.uv_layers.active.name}")
do_unwrap(pack=True)
isl, fisl, li, loops_v, uv, n_isl = get_islands()
log("unwrapped + packed")
tuv = np.stack([np.clip(uv, 0, 1)[li], np.clip(uv, 0, 1)[li + 1], np.clip(uv, 0, 1)[li + 2]], 1)
auv = 0.5 * np.abs((tuv[:, 1, 0] - tuv[:, 0, 0]) * (tuv[:, 2, 1] - tuv[:, 0, 1])
- (tuv[:, 2, 0] - tuv[:, 0, 0]) * (tuv[:, 1, 1] - tuv[:, 0, 1]))
me = ob.data
co = np.empty(len(me.vertices) * 3); me.vertices.foreach_get("co", co); co = co.reshape(-1, 3)
P3 = co[np.stack([loops_v[li], loops_v[li + 1], loops_v[li + 2]], axis=1)]
a3 = 0.5 * np.linalg.norm(np.cross(P3[:, 1] - P3[:, 0], P3[:, 2] - P3[:, 0]), axis=1)
isl_auv = np.bincount(fisl, weights=auv, minlength=n_isl)
isl_a3 = np.bincount(fisl, weights=a3, minlength=n_isl)
isl_nf = np.bincount(fisl, minlength=n_isl)
UNITM = 1.777 / span[UP]
W = 4096
dens = np.where(isl_a3 > 0, np.sqrt(np.maximum(isl_auv, 0) / np.maximum(isl_a3, 1e-12))
* W / (UNITM * 1000.0), np.nan)
o = np.argsort(-isl_auv)
cum = np.cumsum(isl_auv[o]) / max(isl_auv.sum(), 1e-12)
n99 = int(np.searchsorted(cum, 0.99)) + 1
print(f"\n=== NEW ATLAS (Tripo baseline in brackets) ===")
print(f"islands {n_isl} [5870] 99%-of-area islands {n99} [84]")
print(f"coverage {isl_auv.sum()*100:.1f}% [62.0] confetti <20 faces {int((isl_nf<20).sum())} [5763]")
db = dens[o[:n99]]; db = db[np.isfinite(db)]
print(f"texel density {db.min():.2f}..{db.max():.2f} px/mm -> {db.max()/max(db.min(),1e-9):.2f}x "
f"spread [1.8x]")
# distortion, measured only over the real charts — the confetti islands are degenerate
# triangles whose ratios are meaningless and would own the tail
real = np.isin(fisl, o[:n99])
ok = real & (a3 > 1e-12) & (auv > 1e-14)
sc = np.sqrt(auv[ok] / a3[ok]); sc /= np.median(sc)
print(f"per-triangle area-scale vs median, real charts only: p05 {np.percentile(sc,5):.2f} "
f"p95 {np.percentile(sc,95):.2f} p99 {np.percentile(sc,99):.2f} (1.0 = undistorted)")
print("\nthe charts: # faces uv_area% 3D cm2 stretch_p95 where")
for i in o[:16]:
if isl_auv[i] * 100 < 0.05:
break
m = fisl == i
mo = m & (a3 > 1e-12) & (auv > 1e-14)
st = np.sqrt(auv[mo] / a3[mo])
st = st / np.median(st) if len(st) else np.array([1.0])
vs = np.unique(np.stack([loops_v[li], loops_v[li + 1], loops_v[li + 2]], 1)[m].ravel())
lab = []
for nm, msk in (("head", is_head), ("hand", is_hand), ("foot", is_foot),
("arm", is_arm & ~is_hand), ("leg", is_leg & ~is_foot)):
if msk[vs].mean() > 0.6:
lab.append(nm)
side = "L" if sn[vs].mean() > 0.15 else ("R" if sn[vs].mean() < -0.15 else "mid")
print(f" {i:6d} {isl_nf[i]:8d} {isl_auv[i]*100:9.3f} {isl_a3[i]*UNITM*UNITM*1e4:9.1f} "
f"{np.percentile(st,95):11.2f} {'+'.join(lab) or 'torso'} {side}")
# picture
R = 1024
rng = np.random.RandomState(3)
pal = rng.rand(n_isl, 3) * 0.7 + 0.25
IS = np.zeros((R, R, 3))
tp = tuv * (R - 1)
for fi in range(len(tp)):
P = tp[fi]
x0, x1 = int(P[:, 0].min()), int(np.ceil(P[:, 0].max()))
y0, y1 = int(P[:, 1].min()), int(np.ceil(P[:, 1].max()))
if x1 < x0 or y1 < y0 or x1 - x0 > 64 or y1 - y0 > 64:
continue
dt = ((P[1, 1] - P[2, 1]) * (P[0, 0] - P[2, 0]) + (P[2, 0] - P[1, 0]) * (P[0, 1] - P[2, 1]))
if abs(dt) < 1e-12:
continue
gx, gy = np.meshgrid(np.arange(x0, min(x1, R - 1) + 1), np.arange(y0, min(y1, R - 1) + 1))
aa = ((P[1, 1] - P[2, 1]) * (gx - P[2, 0]) + (P[2, 0] - P[1, 0]) * (gy - P[2, 1])) / dt
bb = ((P[2, 1] - P[0, 1]) * (gx - P[2, 0]) + (P[0, 0] - P[2, 0]) * (gy - P[2, 1])) / dt
ins = (aa >= 0) & (bb >= 0) & (1 - aa - bb >= 0)
if ins.any():
IS[gy[ins], gx[ins]] = pal[fisl[fi]]
img = bpy.data.images.new("isl", R, R, alpha=False)
A = np.ones((R, R, 4), dtype=np.float32); A[:, :, :3] = IS
img.pixels.foreach_set(A.reshape(-1))
p = os.path.join(OUTDIR, "charts.png")
img.file_format = 'PNG'; img.filepath_raw = p; img.save(filepath=p)
bpy.ops.wm.save_as_mainfile(filepath=os.path.join(OUTDIR, "seamed.blend"))
log(f"wrote {p} + seamed.blend")
print("SEAMS_DONE")