feat(lena-hands): exp09 graded cross-digit finger weights + honest tear metrics
The exp05 verdict ("weights alone cannot clear the bar on this mesh") was
measured against a hard partition that was itself causing much of the tearing.
exp01-exp05 gave every vert to exactly ONE digit -- 08_finger_weights.py had an
explicit `elif lo in FING: continue # other digit: hard wall` -- which
guarantees the fused inter-digit bridges tear by the full finger separation,
because a single edge ring absorbs the whole gap.
Replace that with graded blending (WEB_BLEND_R0): a vert's fraction toward its
nearest other digit ramps 0 -> 0.5 as r = d_own/(d_own+d_other) goes 0.35 -> 0.5,
so both sides of the equidistance valley reach 50/50 and the field is continuous
across the boundary. Applied after the smoother (whose hard wall would erode beta
exactly where it must survive) and evaluated in the neighbour digit's own
arc-length frame. WEB_BLEND_SKIP excludes the thumb: its transform is opposition,
not curl, so its frame does not correspond to a finger's.
Measured against an identical baseline (same input, blend the only variable),
real tears (>=1mm rest length) drop 23-60% with NO regression on flat, the pose
that ships: fist_r 1178 -> 530, grip_r 561 -> 222, fist_l 979 -> 633,
grip_l 420 -> 322; total >5x 2487 -> 1307; p99.9 better on every pose;
flat unchanged at 0/1. fin_bones confirms the mechanism rather than just the
count -- the middle<->ring and pinky<->ring families leave the top classes while
the thumb/palm ones are untouched to the edge (182 -> 182, 148 -> 148).
This does NOT make fist/grip shippable: 222-633 real tears still reads as a
destroyed hand in clay renders, and the residual is now ~53% thumb-pad-fused-to
-palm, which is topology and needs mesh surgery or the v02 rebake. Flat and
relaxed are the shippable poses; fist/grip belong to the morph lane for now.
Also here:
- README: the solver's input is v02/..._exp03.glb, NOT exp01. exp01 is pre-hand
-fit (converter steps 2b/2c); its finger groups sit on the wrist and overlap
the real finger by 1.6cm, so a solve from it silently zeroes every _02/_03 bone
-- rigid stick fingers and a torn flat -- while weight sums stay 1.0 and every
assert passes. Cost three wasted bakes and one false "the solver regressed".
- Seed assert demanded >=100 seeds while the radius loop caps at
SEED_AXIS_R_MAX, which left pinky (88 seeds at 16mm) can never satisfy; the two
constants were mutually unsatisfiable. Now >=60, and it is documented as a
sanity gate rather than a quality bar.
- Detwist poses tested at last: real but marginal (fist_r 38.7x -> 28.7x,
grip_r 30.0x -> 17.3x, left hand flat). A knob, not a fix.
- edge_stretch_cmp.py / skin_bone_territory.py / handpose_trim_hand_obj.py:
judge tears by rest length and absolute posed growth, not raw ratio; audit
whether a bone owns any verts at all (thumb_01 owns ZERO in exp05); and trim
an arm-sized skin dump to the hand before rendering.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,501 @@
|
||||
"""08_finger_weights.py — re-solve finger skin weights on a quatskin candidate body.
|
||||
|
||||
Why: the AccuRig hand weights survive the quatskin conversion (LENA_RIGID_FINGERS=0)
|
||||
but were grafted nearest-surface from a 20:1 decimated carrier, so adjacent fingers
|
||||
bleed into each other. Invisible at rest and in the FLAT pose; a full curl (fist/grip)
|
||||
tears the fingers into ribbons (QA renders in v02/review/, 2026-08-17).
|
||||
|
||||
Method: cross-finger bleed is impossible by construction here —
|
||||
1. label every hand-region vert to ONE finger (or palm) by multi-source Dijkstra
|
||||
over the mesh's own edges (welded across the glTF importer's UV-seam splits),
|
||||
seeded by proximity to each finger's bone axis with a margin test;
|
||||
exp05: label propagation is spatially GATED (a digit's label can never reach a
|
||||
vert CROSS_GATE closer to another digit's axis) and edges crossing the
|
||||
inter-digit equidistance valley are cost-penalized, so the digit boundary
|
||||
settles in the fused inter-finger valley instead of wandering onto a
|
||||
neighbor's flank (exp04's middle_02<->ring_02 / pinky<->ring fin stacks);
|
||||
2. rebuild finger weights procedurally along the labeled finger's bone chain:
|
||||
arc-length projection, linear blend zones at each joint, base blends into hand;
|
||||
exp05: the arc-length param s is clamped by GEODESIC distance from the digit's
|
||||
own base frontier — euclidean projection could snap a base-region vert to a
|
||||
distal segment, yielding hand + phalanx-2 weight with zero phalanx-1 (exp04's
|
||||
hand<->thumb_02 / hand<->index_02 fins); then weights are smoothed over the
|
||||
mesh graph restricted to same-digit + palm neighbors (NEVER across the
|
||||
inter-finger gap), and a chain-continuity repair guarantees graded
|
||||
hand->_01->_02->_03 falloff;
|
||||
3. palm-labeled verts lose their finger weights into the hand bone.
|
||||
Everything outside the finger-weighted region (+1.2 cm collar) is untouched, and no
|
||||
vertex position changes anywhere — this is a weights-only edit.
|
||||
|
||||
usage: blender --background --factory-startup --python 08_finger_weights.py -- in.glb out.glb
|
||||
"""
|
||||
import bpy, sys, math, heapq, struct
|
||||
from mathutils import Vector, kdtree
|
||||
|
||||
argv = sys.argv[sys.argv.index("--") + 1:]
|
||||
SRC, OUT = argv[0], argv[1]
|
||||
|
||||
FING = ("thumb", "index", "middle", "ring", "pinky")
|
||||
SEED_AXIS_R = 0.007 # finger seed: within 7 mm of its bone axis...
|
||||
SEED_AXIS_R_MAX = 0.016 # ...grown per finger until it has enough seeds (the thumb is
|
||||
# a fat digit — after the hand fit's 1.56x right-thumb stretch
|
||||
# its whole surface sits >7 mm off-axis and 7 mm finds ~20 verts)
|
||||
SEED_MARGIN = 0.002 # ...and 2 mm closer to it than to any other finger
|
||||
PALM_AXIS_D = 0.016 # palm seed: >16 mm from every finger axis (12 mm let the
|
||||
# fat right thumb's pad seed as palm -> hand<->thumb_02 fins)
|
||||
COLLAR_R = 0.012 # spatial collar added around the finger-weighted region
|
||||
JOINT_BLEND = 0.006 # half-width of the linear blend zone at each joint (m)
|
||||
CROSS_GATE = 0.0025 # a digit's label may never reach a vert this much closer
|
||||
# to another digit's axis (spatial nearest-bone gate)
|
||||
VALLEY_PENALTY = 4.0 # Dijkstra cost multiplier for edges crossing the
|
||||
# inter-digit equidistance valley (mild bias only: a heavy
|
||||
# toll starved the fused valley floor of digit labels and
|
||||
# palm claimed it -> hand=1 fin stacks between fingers)
|
||||
VALLEY_SURCHARGE = 0.001 # flat cost per crossing edge
|
||||
PALM_NEAR_D = 0.010 # palm label pays to enter the near-axis zone (<10 mm)...
|
||||
PALM_CLIMB_PENALTY = 8.0 # ...this multiplier (digit surfaces belong to digits)
|
||||
CAPTURE_D = 0.009 # palm/unreached verts closer than this to a digit axis are
|
||||
# force-relabeled to the spatially nearest digit
|
||||
CAPTURE_REGION_D = 0.015 # ...and originally finger-weighted ones out to this radius
|
||||
# (fused valley floors and beyond-tip caps sit 10-13 mm off
|
||||
# axis; folding them to hand leaves them behind in a fist)
|
||||
BASE_RAMP = 0.008 # geodesic ramp length: digit weight fraction is 0 at the
|
||||
# palm frontier and 1 this far (geodesic) into the digit
|
||||
S_SLACK = 0.004 # geodesic clamp slack on the arc-length param (m)
|
||||
SMOOTH_ITERS = 6 # weight-smoothing iterations (same-digit + palm only)
|
||||
SMOOTH_ALPHA = 0.5 # neighbor-average blend factor per iteration
|
||||
WEB_BLEND_R0 = 0.35 # exp06: cross-digit web blending. exp01-exp05 partitioned the
|
||||
# hand HARD (one digit per vert, "cross-finger bleed impossible
|
||||
# by construction"), which guarantees the fused inter-digit
|
||||
# bridges tear by the FULL finger separation: web verts on the
|
||||
# middle side move rigidly with middle, the ring side with ring,
|
||||
# and the one edge ring between them absorbs the whole gap
|
||||
# (measured on exp05 fist: ~811 middle<->ring / pinky<->ring
|
||||
# edges >5x, up to 45x). The cure is not "no bleed" but GRADED
|
||||
# bleed: a vert's blend fraction toward its nearest other digit
|
||||
# ramps from 0 at r=WEB_BLEND_R0 to 0.5 at the equidistance
|
||||
# valley (r = d_own / (d_own + d_other), so r=0.5 IS the valley).
|
||||
# Both sides of the boundary reach exactly 0.5 there, so the
|
||||
# weight field is CONTINUOUS across it and the separation is
|
||||
# spread over the web's whole edge span instead of one ring.
|
||||
# Set to 0.5 to disable (= exp05 behaviour).
|
||||
WEB_BLEND_SKIP = ("thumb",) # exp07: digits excluded from cross-digit blending. The four
|
||||
# fingers are near-parallel, so mixing a valley vert between two
|
||||
# of them is well posed. The thumb is not: its transform is
|
||||
# opposition, not curl, and its arc-length frame does not
|
||||
# correspond to a finger's, so projecting an index-side or palm
|
||||
# vert into the thumb chain hands it weight from a bone that
|
||||
# moves somewhere else entirely. exp06 (thumb included) cut fist
|
||||
# /grip needles by 54-67% but REGRESSED the shipped flat pose on
|
||||
# the right hand from 0 to 9 visible needles, and fin_bones put
|
||||
# all 44 of its torn edges on hand_r<->thumb_0x. Fingers only.
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
bpy.ops.import_scene.gltf(filepath=SRC)
|
||||
arm = next(o for o in bpy.data.objects if o.type == "ARMATURE")
|
||||
body = max((o for o in bpy.data.objects if o.type == "MESH"),
|
||||
key=lambda o: len(o.data.vertices))
|
||||
bpy.context.view_layer.update()
|
||||
MW = body.matrix_world
|
||||
AW = arm.matrix_world
|
||||
nv = len(body.data.vertices)
|
||||
print(f"[fw] body {body.name}: {nv} verts, {len(body.vertex_groups)} groups")
|
||||
|
||||
pos = [MW @ v.co for v in body.data.vertices]
|
||||
|
||||
def bone_head(name):
|
||||
return AW @ arm.data.bones[name].head_local if name in arm.data.bones else None
|
||||
|
||||
def seg_dist(p, a, b):
|
||||
ab = b - a
|
||||
t = max(0.0, min(1.0, (p - a).dot(ab) / max(ab.length_squared, 1e-12)))
|
||||
return (p - (a + ab * t)).length
|
||||
|
||||
gname = {g.index: g.name for g in body.vertex_groups}
|
||||
gidx = {g.name: g.index for g in body.vertex_groups}
|
||||
|
||||
changed_total = 0
|
||||
for S in ("l", "r"):
|
||||
fgroups = {f"{F}_0{i}_{S}" for F in FING for i in (1, 2, 3)} & set(gidx)
|
||||
fg_idx = {gidx[n] for n in fgroups}
|
||||
hand_i = gidx[f"hand_{S}"]
|
||||
|
||||
# bone chains: [head01, head02, head03, tip]
|
||||
chains = {}
|
||||
for F in FING:
|
||||
pts = [bone_head(f"{F}_0{i}_{S}") for i in (1, 2, 3)]
|
||||
if any(p is None for p in pts):
|
||||
raise RuntimeError(f"missing chain bones for {F}_{S}")
|
||||
tip = bone_head(f"{F}_04_leaf_{S}")
|
||||
if tip is None:
|
||||
tip = pts[2] + (pts[2] - pts[1])
|
||||
chains[F] = pts + [tip]
|
||||
|
||||
# region: verts carrying any finger weight on this side
|
||||
region = set()
|
||||
for v in body.data.vertices:
|
||||
for gr in v.groups:
|
||||
if gr.group in fg_idx and gr.weight > 1e-6:
|
||||
region.add(v.index); break
|
||||
print(f"[fw] side {S}: {len(region)} finger-weighted verts")
|
||||
|
||||
# + spatial collar (label graph needs the surrounding palm to compete)
|
||||
kd = kdtree.KDTree(len(region))
|
||||
for vi in region: kd.insert(pos[vi], vi)
|
||||
kd.balance()
|
||||
region2 = set(region)
|
||||
for v in body.data.vertices:
|
||||
if v.index in region2: continue
|
||||
hit = kd.find(pos[v.index])
|
||||
if hit[0] is not None and hit[2] <= COLLAR_R:
|
||||
region2.add(v.index)
|
||||
print(f"[fw] side {S}: region with collar = {len(region2)}")
|
||||
|
||||
# adjacency: real mesh edges inside region2 + zero-cost weld edges across UV-seam dupes
|
||||
adj = {vi: [] for vi in region2}
|
||||
for e in body.data.edges:
|
||||
a, b = e.vertices
|
||||
if a in region2 and b in region2:
|
||||
d = (pos[a] - pos[b]).length
|
||||
adj[a].append((b, d)); adj[b].append((a, d))
|
||||
kd2 = kdtree.KDTree(len(region2))
|
||||
for vi in region2: kd2.insert(pos[vi], vi)
|
||||
kd2.balance()
|
||||
welds = 0
|
||||
for vi in region2:
|
||||
for (_, oi, d) in kd2.find_range(pos[vi], 1e-6):
|
||||
if oi != vi:
|
||||
adj[vi].append((oi, 0.0)); welds += 1
|
||||
print(f"[fw] side {S}: {sum(len(a) for a in adj.values())//2} edges ({welds//2} weld pairs)")
|
||||
|
||||
# seeds
|
||||
def axis_dists(p):
|
||||
out = {}
|
||||
for F, pts in chains.items():
|
||||
out[F] = min(seg_dist(p, pts[k], pts[k+1]) for k in range(3))
|
||||
return out
|
||||
|
||||
INF = float("inf")
|
||||
dist = {vi: INF for vi in region2}
|
||||
label = {}
|
||||
pq = []
|
||||
ds_all = {vi: axis_dists(pos[vi]) for vi in region2}
|
||||
ds_min = {vi: min(ds_all[vi].values()) for vi in region2}
|
||||
nearest_digit = {vi: min(ds_all[vi], key=ds_all[vi].get) for vi in region2}
|
||||
seeds = {}
|
||||
radii = {}
|
||||
for F in FING:
|
||||
r = SEED_AXIS_R
|
||||
while True:
|
||||
picked = [vi for vi in region2
|
||||
if ds_all[vi][F] < r
|
||||
and min((d for G, d in ds_all[vi].items() if G != F),
|
||||
default=INF) - ds_all[vi][F] > SEED_MARGIN]
|
||||
if len(picked) >= 100 or r >= SEED_AXIS_R_MAX:
|
||||
break
|
||||
r += 0.001
|
||||
seeds[F] = len(picked); radii[F] = r
|
||||
for vi in picked:
|
||||
dist[vi] = 0.0; label[vi] = F
|
||||
heapq.heappush(pq, (0.0, vi, F))
|
||||
palm_seeds = 0
|
||||
for vi in region2:
|
||||
if vi in label: continue
|
||||
if min(ds_all[vi].values()) > PALM_AXIS_D:
|
||||
v = body.data.vertices[vi]
|
||||
tw = sum(gr.weight for gr in v.groups)
|
||||
hw = sum(gr.weight for gr in v.groups if gr.group == hand_i)
|
||||
if tw > 0 and hw / tw >= 0.6:
|
||||
dist[vi] = 0.0; label[vi] = "palm"
|
||||
heapq.heappush(pq, (0.0, vi, "palm")); palm_seeds += 1
|
||||
print(f"[fw] side {S}: seeds {seeds} palm={palm_seeds} "
|
||||
f"(radii {[f'{F}:{radii[F]*1000:.0f}mm' for F in FING]})")
|
||||
# sanity gate, not a quality bar: the seed loop stops growing the radius at
|
||||
# SEED_AXIS_R_MAX, so a digit whose whole surface sits off-axis (left pinky on this
|
||||
# mesh tops out at 88 seeds / 16 mm) can never reach 100 no matter how healthy the
|
||||
# labeling is — asserting 100 made the two constants mutually unsatisfiable. This
|
||||
# catches an actually broken seeding (a handful of verts), which is what it is for.
|
||||
for F, n in seeds.items():
|
||||
assert n >= 60, f"side {S}: only {n} seeds for {F} — seed radii wrong for this mesh"
|
||||
assert palm_seeds >= 100, f"side {S}: only {palm_seeds} palm seeds"
|
||||
|
||||
while pq:
|
||||
d, vi, lab = heapq.heappop(pq)
|
||||
if d > dist[vi] or label.get(vi, lab) != lab: continue
|
||||
for oi, w in adj[vi]:
|
||||
if lab != "palm":
|
||||
# inter-digit exclusivity: a digit's label may never reach a vert
|
||||
# that sits CROSS_GATE closer to another digit's axis — the Tripo
|
||||
# mesh fuses adjacent fingers, so topology alone lets a label leak
|
||||
# across the gap onto the neighbor digit's flank
|
||||
if ds_all[oi][lab] - ds_min[oi] > CROSS_GATE:
|
||||
continue
|
||||
# crossing the equidistance valley between two digits is heavily
|
||||
# penalized so the label boundary settles IN the fused valley
|
||||
if nearest_digit[oi] != nearest_digit[vi]:
|
||||
w = w * VALLEY_PENALTY + VALLEY_SURCHARGE
|
||||
else:
|
||||
# symmetric toll: palm expansion pays to climb onto a digit's
|
||||
# surface (exp05 rev1: palm walked toll-free up the fingers and
|
||||
# left a weight cliff mid-phalanx -> hand<->hand fin stacks)
|
||||
if ds_min[oi] < PALM_NEAR_D:
|
||||
w = w * PALM_CLIMB_PENALTY + 0.002
|
||||
nd = d + w
|
||||
if nd < dist[oi]:
|
||||
dist[oi] = nd; label[oi] = lab
|
||||
heapq.heappush(pq, (nd, oi, lab))
|
||||
|
||||
# capture pass: no vert this close to a digit axis may stay palm/unreached —
|
||||
# fused finger-to-palm contacts and gate-orphaned islands otherwise fold to
|
||||
# hand=1 mid-finger and shear off their curling neighbors (exp05 rev1's
|
||||
# hand<->index_02 / ring_03<->ring_03 fins)
|
||||
captured = 0
|
||||
for vi in region2:
|
||||
if label.get(vi) not in FING and \
|
||||
(ds_min[vi] < CAPTURE_D or (vi in region and ds_min[vi] < CAPTURE_REGION_D)):
|
||||
label[vi] = nearest_digit[vi]; captured += 1
|
||||
print(f"[fw] side {S}: captured {captured} near-axis palm/unreached verts to digits")
|
||||
|
||||
counts = {F: 0 for F in FING}; counts["palm"] = 0; counts["unreached"] = 0
|
||||
for vi in region2:
|
||||
counts[label.get(vi, "unreached")] = counts.get(label.get(vi, "unreached"), 0) + 1
|
||||
print(f"[fw] side {S}: labels {counts}")
|
||||
|
||||
# residual cross-digit mesh edges (real fused-gap bridges; these are the
|
||||
# accepted sub-mm baseline, not fixable by weights)
|
||||
xdig = sum(1 for e in body.data.edges
|
||||
if label.get(e.vertices[0]) in FING and label.get(e.vertices[1]) in FING
|
||||
and label.get(e.vertices[0]) != label.get(e.vertices[1]))
|
||||
print(f"[fw] side {S}: residual cross-digit mesh edges: {xdig}")
|
||||
|
||||
# rebuild weights
|
||||
grp = {n: body.vertex_groups[n] for n in
|
||||
list(fgroups) + [f"hand_{S}"]}
|
||||
hand_key = f"hand_{S}"
|
||||
arcs = {}
|
||||
for F in FING:
|
||||
pts = chains[F]; L = [0.0]
|
||||
for k in range(3):
|
||||
L.append(L[-1] + (pts[k+1] - pts[k]).length)
|
||||
arcs[F] = L
|
||||
|
||||
def chain_s(F, p):
|
||||
pts = chains[F]; L = arcs[F]
|
||||
best_s, best_d = 0.0, INF
|
||||
for k in range(3):
|
||||
a, b = pts[k], pts[k+1]
|
||||
ab = b - a
|
||||
t = max(0.0, min(1.0, (p - a).dot(ab) / max(ab.length_squared, 1e-12)))
|
||||
d = (p - (a + ab * t)).length
|
||||
if d < best_d:
|
||||
best_d = d; best_s = L[k] + t * (L[k+1] - L[k])
|
||||
return best_s
|
||||
|
||||
def weights_from_s(F, s, ramp=1.0):
|
||||
L = arcs[F]; bz = JOINT_BLEND
|
||||
# digit fraction: arc-length blend, capped by the geodesic base ramp so it
|
||||
# is exactly 0 at the palm frontier (a one-sided taper leaves a cliff)
|
||||
t_base = min(max(0.0, min(1.0, (s + bz) / (2 * bz))), ramp)
|
||||
t1 = max(0.0, min(1.0, (s - (L[1] - bz)) / (2 * bz)))
|
||||
t2 = max(0.0, min(1.0, (s - (L[2] - bz)) / (2 * bz)))
|
||||
return {hand_key: 1 - t_base,
|
||||
f"{F}_01_{S}": t_base * (1 - t1),
|
||||
f"{F}_02_{S}": t_base * t1 * (1 - t2),
|
||||
f"{F}_03_{S}": t_base * t1 * t2}
|
||||
|
||||
# graded hand->_01->_02->_03 continuity: euclidean chain projection can snap
|
||||
# a base-region vert to a distal segment (hand + phalanx-2 weight with zero
|
||||
# phalanx-1). Clamp each vert's arc position s by its GEODESIC distance from
|
||||
# the digit's own base frontier so s grows monotonically along the surface.
|
||||
s_final = {}
|
||||
rampv = {}
|
||||
for F in FING:
|
||||
dverts = [vi for vi in region2 if label.get(vi) == F]
|
||||
sp_raw = {vi: chain_s(F, pos[vi]) for vi in dverts}
|
||||
gd = {vi: INF for vi in dverts} # seeded with s_proj: absolute s clamp
|
||||
gdb = {vi: INF for vi in dverts} # seeded with 0: base-ramp distance
|
||||
pq2 = []
|
||||
for vi in dverts:
|
||||
# base frontier: touches palm/unlabeled AND projects into phalanx 1
|
||||
# (mid-digit verts fused to the palm must not seed a false base)
|
||||
if sp_raw[vi] <= arcs[F][1] and \
|
||||
any(label.get(oi) not in FING for oi, _ in adj[vi]):
|
||||
gd[vi] = max(0.0, sp_raw[vi])
|
||||
gdb[vi] = 0.0
|
||||
heapq.heappush(pq2, (gd[vi], vi))
|
||||
while pq2:
|
||||
d, vi = heapq.heappop(pq2)
|
||||
if d > gd[vi]: continue
|
||||
for oi, w in adj[vi]:
|
||||
if label.get(oi) != F: continue
|
||||
nd2 = d + w
|
||||
if nd2 < gd[oi]:
|
||||
gd[oi] = nd2
|
||||
heapq.heappush(pq2, (nd2, oi))
|
||||
pq3 = [(0.0, vi) for vi in dverts if gdb[vi] == 0.0]
|
||||
heapq.heapify(pq3)
|
||||
while pq3:
|
||||
d, vi = heapq.heappop(pq3)
|
||||
if d > gdb[vi]: continue
|
||||
for oi, w in adj[vi]:
|
||||
if label.get(oi) != F: continue
|
||||
nd2 = d + w
|
||||
if nd2 < gdb[oi]:
|
||||
gdb[oi] = nd2
|
||||
heapq.heappush(pq3, (nd2, oi))
|
||||
clamped = 0
|
||||
for vi in dverts:
|
||||
s = sp_raw[vi]
|
||||
if gd[vi] < INF and s > gd[vi] + S_SLACK:
|
||||
s = gd[vi] + S_SLACK; clamped += 1
|
||||
s_final[vi] = s
|
||||
rampv[vi] = min(1.0, gdb[vi] / BASE_RAMP) if gdb[vi] < INF else 1.0
|
||||
print(f"[fw] side {S}: {F} geodesic s-clamp moved {clamped}/{len(dverts)} verts")
|
||||
|
||||
fverts = [vi for vi in region2 if label.get(vi) in FING]
|
||||
wcur = {vi: weights_from_s(label[vi], s_final[vi], rampv[vi]) for vi in fverts}
|
||||
|
||||
# topology-aware smoothing for graded falloff: average ONLY with same-digit
|
||||
# neighbors (NEVER across the inter-finger gap) and with palm/hand neighbors
|
||||
# (contributing pure hand weight) so digit bases taper into the palm.
|
||||
for _ in range(SMOOTH_ITERS):
|
||||
wnew = {}
|
||||
for vi in fverts:
|
||||
F = label[vi]
|
||||
accum = {}; n = 0
|
||||
for oi, _w in adj[vi]:
|
||||
lo = label.get(oi)
|
||||
if lo == F:
|
||||
vec = wcur[oi]
|
||||
elif lo in FING:
|
||||
continue # other digit: hard wall
|
||||
else:
|
||||
vec = {hand_key: 1.0}
|
||||
for k, x in vec.items():
|
||||
accum[k] = accum.get(k, 0.0) + x
|
||||
n += 1
|
||||
if n == 0:
|
||||
wnew[vi] = wcur[vi]; continue
|
||||
mix = {}
|
||||
for k in set(accum) | set(wcur[vi]):
|
||||
mix[k] = ((1 - SMOOTH_ALPHA) * wcur[vi].get(k, 0.0)
|
||||
+ SMOOTH_ALPHA * accum.get(k, 0.0) / n)
|
||||
tot = sum(mix.values())
|
||||
wnew[vi] = {k: x / tot for k, x in mix.items()}
|
||||
wcur = wnew
|
||||
|
||||
# exp06 cross-digit web blend: make the weight field continuous ACROSS the digit
|
||||
# boundary instead of walling it off. r = d_own / (d_own + d_nearest_other) is 0 on
|
||||
# the digit's own axis and 0.5 in the fused equidistance valley; beta ramps 0 -> 0.5
|
||||
# over [WEB_BLEND_R0, 0.5], so a valley vert is an even mix of the two digits and
|
||||
# lands on the midpoint of their motion. The mirror vert across the boundary computes
|
||||
# the same r and the same 50/50 mix, which is what removes the cliff. Applied AFTER
|
||||
# smoothing (the smoother's hard wall would erode beta at the boundary, exactly where
|
||||
# it must survive) and evaluated in the neighbour digit's own arc-length frame, capped
|
||||
# by this vert's base ramp so the palm frontier stays graded.
|
||||
blended = 0
|
||||
beta_max = 0.0
|
||||
for vi in fverts:
|
||||
F = label[vi]
|
||||
if F in WEB_BLEND_SKIP:
|
||||
continue
|
||||
cands = [g for g in FING if g != F and g not in WEB_BLEND_SKIP]
|
||||
if not cands:
|
||||
continue
|
||||
dF = ds_all[vi][F]
|
||||
G = min(cands, key=lambda g: ds_all[vi][g])
|
||||
dG = ds_all[vi][G]
|
||||
r = dF / max(dF + dG, 1e-9)
|
||||
if r <= WEB_BLEND_R0:
|
||||
continue
|
||||
beta = 0.5 * min(1.0, (r - WEB_BLEND_R0) / max(0.5 - WEB_BLEND_R0, 1e-9))
|
||||
if beta <= 1e-3:
|
||||
continue
|
||||
wG = weights_from_s(G, chain_s(G, pos[vi]), rampv.get(vi, 1.0))
|
||||
mix = {}
|
||||
for k in set(wcur[vi]) | set(wG):
|
||||
mix[k] = (1 - beta) * wcur[vi].get(k, 0.0) + beta * wG.get(k, 0.0)
|
||||
tot = sum(mix.values())
|
||||
wcur[vi] = {k: x / tot for k, x in mix.items() if x / tot > 1e-4}
|
||||
blended += 1
|
||||
beta_max = max(beta_max, beta)
|
||||
print(f"[fw] side {S}: web-blended {blended}/{len(fverts)} verts "
|
||||
f"(max beta {beta_max:.3f}, r0={WEB_BLEND_R0})")
|
||||
|
||||
# chain-continuity repair: no vert may carry hand + phalanx>=2 weight while
|
||||
# phalanx-1 is starved
|
||||
repaired = 0
|
||||
for vi in fverts:
|
||||
F = label[vi]
|
||||
w = wcur[vi]
|
||||
wh = w.get(hand_key, 0.0)
|
||||
k1, k2 = f"{F}_01_{S}", f"{F}_02_{S}"
|
||||
w1 = w.get(k1, 0.0)
|
||||
w23 = w.get(k2, 0.0) + w.get(f"{F}_03_{S}", 0.0)
|
||||
need = 0.5 * min(wh, w23)
|
||||
if need > 0.01 and w1 < need:
|
||||
deficit = need - w1
|
||||
for k, avail in ((hand_key, wh), (k2, w.get(k2, 0.0))):
|
||||
take = min(deficit / 2, avail)
|
||||
w[k] = w.get(k, 0.0) - take
|
||||
w1 += take
|
||||
w[k1] = w1
|
||||
tot = sum(w.values())
|
||||
wcur[vi] = {k: x / tot for k, x in w.items()}
|
||||
repaired += 1
|
||||
print(f"[fw] side {S}: chain-continuity repaired {repaired} verts")
|
||||
|
||||
changed = 0
|
||||
for vi in region2:
|
||||
lab = label.get(vi)
|
||||
v = body.data.vertices[vi]
|
||||
if lab in FING:
|
||||
for g in body.vertex_groups:
|
||||
g.remove([vi])
|
||||
for n, x in wcur[vi].items():
|
||||
if x > 1e-4: grp[n].add([vi], x, "REPLACE")
|
||||
changed += 1
|
||||
else: # palm / unreached: strip finger weights into hand
|
||||
fsum = sum(gr.weight for gr in v.groups if gr.group in fg_idx)
|
||||
if fsum > 1e-6:
|
||||
for n in fgroups:
|
||||
body.vertex_groups[n].remove([vi])
|
||||
grp[f"hand_{S}"].add([vi], fsum, "ADD")
|
||||
changed += 1
|
||||
changed_total += changed
|
||||
print(f"[fw] side {S}: rewrote weights on {changed} verts")
|
||||
|
||||
# weight-sum gate on everything we touched (glTF needs sum==1; exporter normalizes
|
||||
# top-4 but a bad sum here means the logic is wrong, not a rounding issue)
|
||||
bad = 0
|
||||
for v in body.data.vertices:
|
||||
tw = sum(gr.weight for gr in v.groups)
|
||||
if abs(tw - 1.0) > 0.01: bad += 1
|
||||
print(f"[fw] verts with weight sum off by >1%: {bad}")
|
||||
assert bad == 0, "weight sums broken"
|
||||
|
||||
# names must match the canonical body (same reason as the converter)
|
||||
body.name = "Lena_Female"; body.data.name = "Lena_Female"
|
||||
for m in body.data.materials:
|
||||
if m: m.name = "MI_Body_Lena"
|
||||
arm.name = "Armature.001"
|
||||
if arm.data: arm.data.name = "Armature.001"
|
||||
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
arm.select_set(True); body.select_set(True)
|
||||
bpy.ops.export_scene.gltf(filepath=OUT, use_selection=True, export_format="GLB",
|
||||
export_skins=True, export_animations=False, export_yup=True)
|
||||
print(f"[fw] EXPORTED {OUT} ({changed_total} verts rewritten)")
|
||||
|
||||
# alphaMode BLEND -> OPAQUE patch (same as the converter's post-export step)
|
||||
with open(OUT, "rb") as f: d = f.read()
|
||||
jl = struct.unpack_from("<I", d, 12)[0]
|
||||
js = d[20:20+jl].decode("utf-8")
|
||||
j2 = js.replace('"alphaMode":"BLEND"', '"alphaMode":"OPAQUE"').replace('"alphaMode": "BLEND"', '"alphaMode": "OPAQUE"')
|
||||
if j2 != js:
|
||||
b = j2.encode("utf-8"); b += b" " * ((4 - len(b) % 4) % 4)
|
||||
o = d[:12] + struct.pack("<I", len(b)) + d[16:20] + b + d[20+jl:]
|
||||
o = o[:8] + struct.pack("<I", len(o)) + o[12:]
|
||||
with open(OUT, "wb") as f: f.write(o)
|
||||
print("[fw] alphaMode patched OPAQUE")
|
||||
print("[fw] DONE")
|
||||
@@ -171,3 +171,36 @@ the atlas is Tripo chart soup, so interpolating UVs across the fill would sample
|
||||
same order and `leaf_mask.npz` indexes either mesh. Re-running 05/07 against the shipped
|
||||
GLB is a first-argument change — but the result is a new ship folder, never an edit to
|
||||
the frozen one.
|
||||
|
||||
## Finger-weight re-solve (`08_finger_weights.py`) — how to run it
|
||||
|
||||
**The input is `v02/lena_leafbikini_quatskin_fingers_glb_exp03.glb`, not exp01.** This is
|
||||
the one thing to get right; guessing it cost three wasted bakes on 2026-08-18.
|
||||
|
||||
```
|
||||
"C:/Program Files/Blender Foundation/Blender 5.1/blender.exe" --background \
|
||||
--factory-startup --python characters/work/lena_leafbikini/08_finger_weights.py -- \
|
||||
characters/work/lena_leafbikini/v02/lena_leafbikini_quatskin_fingers_glb_exp03.glb \
|
||||
characters/work/lena_leafbikini/v02/lena_leafbikini_quatskin_fingers_glb_<tag>.glb
|
||||
```
|
||||
|
||||
exp01 and exp03 carry the SAME weight groups (identical vertex sets — `index_l` is 6,985
|
||||
verts in both) but **different meshes**: 91,445 verts differ, by up to 12.5 cm. exp01 is
|
||||
the pre-hand-fit body, so on it those groups land on the wrist/palm, overlapping the real
|
||||
index finger by only 1.6 cm. Solving from exp01 therefore produces a body where the
|
||||
arc-length param `s` never clears the phalanx-1 threshold and **every `_02`/`_03` finger
|
||||
bone gets exactly zero weight** — rigid stick fingers hinging at the knuckle, and a torn
|
||||
flat pose. It fails silently: weight sums are 1.0, the asserts pass, the export succeeds.
|
||||
Confirm a good run by the label counts — from exp03, side l is index 4,000 / middle 4,718
|
||||
(these match exp05's groups) and `chain-continuity repaired` is ~400 per side, not 0.
|
||||
|
||||
Verify a bake with `tools/handpose_bake_preview.py` + `tools/handpose_skin_to_obj.py` +
|
||||
edge-stretch; judge on **real** tears (>=1mm rest length) and **visible** needles (>=1cm
|
||||
posed), not raw ratios. Flat must stay at 0-1 real tears per hand — that pose ships.
|
||||
|
||||
Measurement tools for this lane: `tools/edge_stretch_cmp.py` (stretch with the real-tear /
|
||||
visible-needle split, several builds side by side), `tools/skin_bone_territory.py` (how many
|
||||
verts each finger bone actually owns — catches a starved chain), `tools/fin_bones.py`
|
||||
(classifies torn edges by bone pair, which is what tells you *which* defect you are looking
|
||||
at), `tools/handpose_trim_hand_obj.py` (trim an arm-sized skin dump to the hand, or a
|
||||
bbox-framing renderer puts the hand in a corner).
|
||||
|
||||
Reference in New Issue
Block a user