Files
animation/characters/work/lena/26_finish.py
T
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

290 lines
11 KiB
Python

# Stage 26: the right operator for the lines (ring-median despeckle) + a real cleavage fillet.
#
# blender --background --python 26_finish.py -- <in.blend> <out.blend> [iters] [alpha]
#
# PART A — LINES, with a median filter instead of a membrane.
# 19_wide_heal measured the seam cross-section: the relief is ONE VERTEX WIDE (|offset| 0.23 mm
# median at the centre and already back to the 0.074 mm background by ring 2). That measurement
# picks the operator, and it is not a membrane:
# * a collar-fixed membrane needs clean ground to stand on. With a 1-vertex defect the collar
# lands ON the defect's shoulders, so the interpolant faithfully reproduces what it is pinned
# to — which is why stages 11/14/17 moved 12k+ verts for almost no visible change, and why
# widening the band (GROW 4, 8) made it worse rather than better.
# * a MEDIAN over the 1-ring is exact for this defect class. Take the scalar offset of each
# vertex from the locally smooth surface and replace it with the median of its neighbourhood:
# an isolated 1-vertex ridge or groove is a rank outlier and is deleted completely, while a
# feature many vertices wide has offset ~= its own median and is returned UNCHANGED. So the
# underbust fold, the clavicles, the navel and the gluteal fold survive by construction
# rather than by a hand-tuned threshold — which is what every earlier stage got wrong.
# Only the normal component is filtered, so no vertex slides tangentially and the UV atlas stays
# valid. Displacement is clamped, as in stage 17, so nothing can reshape her.
#
# PART B — CLEAVAGE, with a blended membrane in the medial corridor.
# 20_cleavage's fill-only curvature filter took the sternum radius from 9.5 mm to only 15.8 mm and
# left the notch depth at 44 mm, because a uniform-Laplacian curvature test measures sharpness at
# the ~2 mm vertex scale and the defect is a 44 mm-deep macro V. Fixing macro shape needs an
# operator with macro reach: a bi-harmonic membrane across the corridor, which interpolates the
# cups' own slopes into a smooth valley. It is applied at a fraction alpha so the cleavage is
# rounded rather than erased — alpha=1 would bridge the cups into a web.
#
# NOTE ON A BUG THIS FIXES: 20_cleavage.py (and 18/25) render clay LAST and then save, so the
# saved .blend keeps the clay material in the slots and loses the texture wiring — that is why
# 21_tone.py aborted with "could not find the wired basecolor" on 23_cleavage.blend. Here the
# original materials are restored and the file is saved BEFORE any render.
import bpy, sys, time
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, OUT = argv[0], argv[1]
ITERS = int(argv[2]) if len(argv) > 2 else 3
ALPHA = float(argv[3]) if len(argv) > 3 else 0.65
t0 = time.time()
UNIT_MM = 1815.0
SMOOTH_K = 6 # scale the offset is measured against
CLAMP_MM = 2.0 # lines are <=1.5 mm of relief
Z_LO, Z_HI = 0.04, 0.90
X_MAX = 0.36
# cleavage corridor
CZ0, CZ1 = 0.690, 0.782
CX = 0.028
COLLAR = 3
CLV_CLAMP_MM = 26.0 # the notch is 44 mm deep; allow a real fillet but not a bridge
def log(m):
print(f"[fin {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
n_v = len(me.vertices)
orig_mats = [ms.material for ms in ob.material_slots]
log(f"in: {n_v}v {len(me.polygons)}f mats={[m.name if m else None for m in orig_mats]}")
co = np.empty(n_v * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
ev = np.empty(len(me.edges) * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev)
ev = ev.reshape(-1, 2)
order = np.concatenate([ev[:, 0], ev[:, 1]])
nbr = np.concatenate([ev[:, 1], ev[:, 0]])
srt = np.argsort(order, kind="stable")
o_s, n_s = order[srt], nbr[srt]
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
cnt = np.maximum(np.diff(ptr), 1)
empty = np.diff(ptr) == 0
MAXV = min(int(np.diff(ptr).max()), 24)
log(f"valence: mean {np.diff(ptr).mean():.2f}, max {np.diff(ptr).max()} (using {MAXV})")
# padded neighbour index table for the ring median
cols = np.arange(MAXV)[None, :]
base = ptr[:-1][:, None]
lim = ptr[1:][:, None]
idx = np.minimum(base + cols, max(len(n_s) - 1, 0))
valid = (base + cols) < lim
nb_tab = n_s[idx]
def nbr_mean(X):
a = np.add.reduceat(X[n_s], ptr[:-1], axis=0)
a[empty] = X[empty]
return a / cnt[:, None]
def smooth_n(X, k):
Y = X.copy()
for _ in range(k):
Y = nbr_mean(Y)
return Y
def ring_median(d):
"""Median of each vertex's 1-ring plus itself. Invalid slots -> nan, nanmedian ignores them."""
T = np.where(valid, d[nb_tab], np.nan)
T = np.concatenate([T, d[:, None]], axis=1)
return np.nanmedian(T, axis=1)
def vnormals():
nrm = np.empty(n_v * 3)
me.vertices.foreach_get("normal", nrm)
return nrm.reshape(-1, 3)
zone = (co[:, 2] > Z_LO) & (co[:, 2] < Z_HI) & (np.abs(co[:, 0]) < X_MAX)
log(f"zone: {int(zone.sum())} verts")
def kink_stats(P):
nrm = vnormals()
N = nrm.copy()
for _ in range(5):
N = nbr_mean(N)
N /= np.maximum(np.linalg.norm(N, axis=1, keepdims=True), 1e-12)
ang = np.degrees(np.arccos(np.clip((nrm * N).sum(axis=1), -1, 1)))
t = (P[:, 2] > 0.28) & (P[:, 2] < 0.90)
return [int((t & (ang > k)).sum()) for k in (6, 12, 20)]
log(f"kink [>6,>12,>20] before: {kink_stats(co)}")
# =============================================================================
# PART A — ring-median despeckle
# =============================================================================
P = co.copy()
for it in range(1, ITERS + 1):
me.vertices.foreach_set("co", P.reshape(-1))
me.update()
N = vnormals()
S = smooth_n(P, SMOOTH_K)
d = ((P - S) * N).sum(axis=1)
dm = ring_median(d)
delta = np.where(zone, dm - d, 0.0)
dmm = np.abs(delta) * UNIT_MM
over = dmm > CLAMP_MM
if over.any():
delta[over] = np.sign(delta[over]) * (CLAMP_MM / UNIT_MM)
Q = P + N * delta[:, None]
mv = np.abs(delta) * UNIT_MM
log(f"median pass {it}: {int((mv>0.01).sum())} verts adjusted, "
f"max {mv.max():.3f} mm, median(adjusted) "
f"{np.median(mv[mv>0.01]) if (mv>0.01).any() else 0:.3f} mm, clamped {int(over.sum())}")
P = Q
me.vertices.foreach_set("co", P.reshape(-1))
me.update()
log(f"kink [>6,>12,>20] after median: {kink_stats(P)}")
dA = np.linalg.norm(P - co, axis=1) * UNIT_MM
log(f"part A displacement: max {dA.max():.3f} mm, p99 {np.percentile(dA,99):.3f} mm")
# =============================================================================
# PART B — cleavage fillet
# =============================================================================
def grow(mask, rings):
m = mask.copy()
for _ in range(rings):
hit = m[ev[:, 0]] | m[ev[:, 1]]
m2 = m.copy()
m2[ev[:, 0]] |= hit
m2[ev[:, 1]] |= hit
m = m2
return m
def bilaplacian(X, free_m, collar_rings=COLLAR, maxit=8000):
collar = grow(free_m, collar_rings) & ~free_m
S = np.nonzero(free_m | collar)[0]
in_S = np.zeros(n_v, dtype=bool)
in_S[S] = True
glb = np.full(n_v, -1, dtype=np.int64)
glb[S] = np.arange(len(S))
se = ev[in_S[ev].all(axis=1)]
a_ = glb[se[:, 0]]
b_ = glb[se[:, 1]]
deg = np.zeros(len(S))
np.add.at(deg, a_, 1.0)
np.add.at(deg, b_, 1.0)
free = free_m[S]
def Ls(Y):
out = deg[:, None] * Y
np.add.at(out, a_, -Y[b_])
np.add.at(out, b_, -Y[a_])
return out
def A_op(U):
Y = np.zeros((len(S), 3))
Y[free] = U
return Ls(Ls(Y))[free]
Xc = np.zeros((len(S), 3))
Xc[~free] = X[S[~free]]
rhs = -Ls(Ls(Xc))[free]
U = X[S[free]].copy()
r = rhs - A_op(U)
p = r.copy()
rs = (r * r).sum()
rs0 = max(rs, 1e-30)
it = 0
for it in range(maxit):
Ap = A_op(p)
den = (p * Ap).sum()
if abs(den) < 1e-30:
break
al = rs / den
U += al * p
r -= al * Ap
rs2 = (r * r).sum()
if rs2 < 1e-20 or rs2 < rs0 * 1e-13:
rs = rs2
break
p = r + (rs2 / rs) * p
rs = rs2
Y = X.copy()
Y[S[free]] = U
return Y, int(free.sum()), it, rs / rs0
def probe(X, tag):
front = X[:, 1] < 0
print(f"\n=== CLEAVAGE PROFILE [{tag}] ===")
print(" z sternum y fillet radius notch vs apex")
for z0 in np.arange(0.650, 0.7801, 0.015):
row = []
for x0 in np.arange(-0.05, 0.0501, 0.005):
m = front & (np.abs(X[:, 0] - x0) < 0.0035) & (np.abs(X[:, 2] - z0) < 0.004)
row.append(X[m, 1].min() if m.sum() else np.nan)
row = np.array(row)
if np.isnan(row).all():
continue
mid = len(row) // 2
seg = row[max(0, mid - 3):mid + 4]
rad = float('inf')
if len(seg) >= 3 and not np.isnan(seg).any():
d2 = (seg[:-2] - 2 * seg[1:-1] + seg[2:]) / (0.005 ** 2)
k = float(np.nanmax(d2))
rad = 1.0 / k if k > 1e-6 else float('inf')
ma = front & (np.abs(np.abs(X[:, 0]) - 0.034) < 0.005) & (np.abs(X[:, 2] - z0) < 0.004)
notch = ((row[mid] - X[ma, 1].min()) * UNIT_MM) if ma.sum() else np.nan
print(f" {z0:.3f} {row[mid]:+.4f} {rad*UNIT_MM:8.1f} mm {notch:+7.1f} mm"
+ (" <-- SHARP" if rad * UNIT_MM < 30 else ""))
probe(P, "before fillet")
corridor = (P[:, 1] < 0) & (np.abs(P[:, 0]) < CX) & (P[:, 2] > CZ0) & (P[:, 2] < CZ1)
log(f"corridor: {int(corridor.sum())} verts")
Q, nf, it, rel = bilaplacian(P, corridor)
disp = (Q - P) * ALPHA
dmag = np.linalg.norm(disp, axis=1) * UNIT_MM
ov = dmag > CLV_CLAMP_MM
if ov.any():
disp[ov] *= (CLV_CLAMP_MM / dmag[ov])[:, None]
P2 = P + disp
d = np.linalg.norm(P2 - P, axis=1) * UNIT_MM
log(f"fillet: {nf} verts, CG it={it} rel={rel:.1e}, alpha={ALPHA}, "
f"moved max {d.max():.2f} mm, median(corridor) {np.median(d[corridor]):.2f} mm, "
f"clamped {int(ov.sum())}")
me.vertices.foreach_set("co", P2.reshape(-1))
me.update()
vn = np.empty(n_v * 3, dtype=np.float32)
me.vertices.foreach_get("normal", vn)
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
probe(P2, "after fillet")
log(f"kink [>6,>12,>20] final: {kink_stats(P2)}")
dT = np.linalg.norm(P2 - co, axis=1) * UNIT_MM
log(f"TOTAL displacement: max {dT.max():.2f} mm, p99 {np.percentile(dT,99):.3f} mm, "
f"{int((dT>0.05).sum())} verts moved >0.05 mm")
# restore materials BEFORE saving (see header note) and save
for i, ms in enumerate(ob.material_slots):
ms.material = orig_mats[i]
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT} with materials {[m.name if m else None for m in orig_mats]}")
print("FIN_DONE")