Files

502 lines
24 KiB
Python
Raw Permalink Normal View History

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