cf336f3b5b
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>
273 lines
10 KiB
Python
273 lines
10 KiB
Python
"""Repair cross-midline finger skin bindings by INPAINTING from the mesh's own healthy
|
|
neighbours.
|
|
|
|
Mako's shipped rig binds ~444 left-hand verts to RIGHT middle-finger bones (many at weight
|
|
1.0, lever arm ~1.8 m), so any middle-finger rotation hurls them across the body. A blunt
|
|
_r -> _l mirror remap does NOT fix it: those verts sit 5.8-10.5 cm from the mirrored bone and
|
|
their nearest correct joints are thumb/pinky, so remapping would bind thumb skin to the middle
|
|
finger and tear. Instead we discard the corrupt influences and refill each vert from its
|
|
HEALTHY neighbours on the same mesh (topological BFS first, spatial fallback), which is exactly
|
|
what the surrounding 13k correctly-bound left-hand verts already encode.
|
|
|
|
usage:
|
|
skin_crosshand_repair.py in.glb out.glb [--diagnose] [--k 8] [--report]
|
|
|
|
--diagnose analyse and print only, write nothing
|
|
--k N neighbours to blend per repaired vert (default 8)
|
|
"""
|
|
import json, struct, sys, math
|
|
from pathlib import Path
|
|
from collections import deque, defaultdict
|
|
|
|
FING = ("thumb", "index", "middle", "ring", "pinky")
|
|
MID = 0.02 # metres either side of x=0 that counts as "across the midline"
|
|
|
|
|
|
def read_glb(p):
|
|
d = Path(p).read_bytes()
|
|
length = struct.unpack_from("<I", d, 8)[0]
|
|
off = 12
|
|
chunks = []
|
|
g = None
|
|
while off < length:
|
|
clen, ct = struct.unpack_from("<II", d, off)
|
|
off += 8
|
|
if ct == 0x4E4F534A:
|
|
g = json.loads(d[off:off + clen].decode("utf-8"))
|
|
chunks.append([ct, None])
|
|
else:
|
|
chunks.append([ct, bytearray(d[off:off + clen])])
|
|
off += clen
|
|
return g, chunks
|
|
|
|
|
|
def write_glb(path, g, chunks):
|
|
js = json.dumps(g, separators=(",", ":")).encode("utf-8")
|
|
js += b" " * ((4 - len(js) % 4) % 4)
|
|
body = b""
|
|
for ct, payload in chunks:
|
|
if ct == 0x4E4F534A:
|
|
payload = js
|
|
body += struct.pack("<II", len(payload), ct) + bytes(payload)
|
|
Path(path).write_bytes(struct.pack("<III", 0x46546C67, 2, 12 + len(body)) + body)
|
|
|
|
|
|
def acc_info(g, i):
|
|
a = g["accessors"][i]
|
|
bv = g["bufferViews"][a["bufferView"]]
|
|
nc = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4, "MAT4": 16}[a["type"]]
|
|
fmt = {5121: "B", 5123: "H", 5125: "I", 5126: "f"}[a["componentType"]]
|
|
size = struct.calcsize(fmt) * nc
|
|
stride = bv.get("byteStride") or size
|
|
off = bv.get("byteOffset", 0) + a.get("byteOffset", 0)
|
|
return a, bv, nc, fmt, stride, off
|
|
|
|
|
|
def read_acc(g, buf, i):
|
|
a, bv, nc, fmt, stride, off = acc_info(g, i)
|
|
return [struct.unpack_from("<%d%s" % (nc, fmt), buf, off + k * stride)
|
|
for k in range(a["count"])]
|
|
|
|
|
|
def quat_mat(q):
|
|
x, y, z, w = q
|
|
return [[1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)],
|
|
[2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)],
|
|
[2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)]]
|
|
|
|
|
|
def node_local(nd):
|
|
t = nd.get("translation", [0, 0, 0])
|
|
r = nd.get("rotation", [0, 0, 0, 1])
|
|
s = nd.get("scale", [1, 1, 1])
|
|
R = quat_mat(r)
|
|
return [[R[i][j] * s[j] for j in range(3)] + [t[i]] for i in range(3)] + [[0, 0, 0, 1]]
|
|
|
|
|
|
def matmul(A, B):
|
|
return [[sum(A[i][k] * B[k][j] for k in range(4)) for j in range(4)] for i in range(4)]
|
|
|
|
|
|
def global_mats(g):
|
|
loc = [node_local(nd) for nd in g["nodes"]]
|
|
parent = {}
|
|
for i, nd in enumerate(g["nodes"]):
|
|
for c in nd.get("children", []):
|
|
parent[c] = i
|
|
memo = {}
|
|
|
|
def gm(i):
|
|
if i in memo:
|
|
return memo[i]
|
|
m = loc[i]
|
|
p = parent.get(i)
|
|
if p is not None:
|
|
m = matmul(gm(p), m)
|
|
memo[i] = m
|
|
return m
|
|
|
|
return [gm(i) for i in range(len(g["nodes"]))]
|
|
|
|
|
|
# ---------------------------------------------------------------- main
|
|
argv = sys.argv[1:]
|
|
src = argv[0]
|
|
dst = argv[1] if len(argv) > 1 and not argv[1].startswith("--") else None
|
|
DIAG = "--diagnose" in argv
|
|
K = int(argv[argv.index("--k") + 1]) if "--k" in argv else 8
|
|
|
|
g, chunks = read_glb(src)
|
|
buf = next(p for ct, p in chunks if ct == 0x004E4942)
|
|
names = [nd.get("name", "") for nd in g["nodes"]]
|
|
GM = global_mats(g)
|
|
|
|
total_fixed = 0
|
|
for mi, mesh in enumerate(g.get("meshes", [])):
|
|
for pi, prim in enumerate(mesh.get("primitives", [])):
|
|
att = prim["attributes"]
|
|
if "JOINTS_0" not in att:
|
|
continue
|
|
skin_idx = next((nd.get("skin") for nd in g["nodes"]
|
|
if nd.get("mesh") == mi and "skin" in nd), None)
|
|
if skin_idx is None:
|
|
continue
|
|
joints = g["skins"][skin_idx]["joints"]
|
|
jname = [names[j] for j in joints]
|
|
jpos = [(GM[j][0][3], GM[j][1][3], GM[j][2][3]) for j in joints]
|
|
|
|
P = read_acc(g, buf, att["POSITION"])
|
|
J = [list(r) for r in read_acc(g, buf, att["JOINTS_0"])]
|
|
Wr = read_acc(g, buf, att["WEIGHTS_0"])
|
|
_, _, _, wfmt, _, _ = acc_info(g, att["WEIGHTS_0"])
|
|
wsc = 1.0 if wfmt == "f" else (1 / 255 if wfmt == "B" else 1 / 65535)
|
|
W = [[w * wsc for w in r] for r in Wr]
|
|
|
|
is_fing = [any(t in n.lower() for t in FING) for n in jname]
|
|
side = ["l" if n.lower().endswith("_l") else ("r" if n.lower().endswith("_r") else "")
|
|
for n in jname]
|
|
|
|
# ---- classify corrupt refs
|
|
corrupt = defaultdict(list) # vert -> [slot,...]
|
|
for vi, (p, jrow, wrow) in enumerate(zip(P, J, W)):
|
|
for s, (j, w) in enumerate(zip(jrow, wrow)):
|
|
if w <= 0.001 or not is_fing[j]:
|
|
continue
|
|
if (side[j] == "r" and p[0] > MID) or (side[j] == "l" and p[0] < -MID):
|
|
corrupt[vi].append(s)
|
|
|
|
if not corrupt:
|
|
print(f" mesh[{mi}] prim{pi}: no cross-midline finger refs — nothing to do")
|
|
continue
|
|
|
|
bad_verts = set(corrupt)
|
|
nref = sum(len(v) for v in corrupt.values())
|
|
print(f" mesh[{mi}] '{mesh.get('name','')}' prim{pi}: {len(P)} verts")
|
|
print(f" corrupt refs {nref} across {len(bad_verts)} verts")
|
|
|
|
# ---- topology adjacency
|
|
adj = defaultdict(set)
|
|
if "indices" in prim:
|
|
idx = [r[0] for r in read_acc(g, buf, prim["indices"])]
|
|
for t in range(0, len(idx) - 2, 3):
|
|
a_, b_, c_ = idx[t], idx[t + 1], idx[t + 2]
|
|
adj[a_].update((b_, c_))
|
|
adj[b_].update((a_, c_))
|
|
adj[c_].update((a_, b_))
|
|
|
|
# healthy = not corrupt AND has some weight
|
|
def healthy(v):
|
|
return v not in bad_verts and sum(W[v]) > 0.5
|
|
|
|
# spatial fallback pool: healthy verts near the affected region
|
|
cx = sum(P[v][0] for v in bad_verts) / len(bad_verts)
|
|
cy = sum(P[v][1] for v in bad_verts) / len(bad_verts)
|
|
cz = sum(P[v][2] for v in bad_verts) / len(bad_verts)
|
|
pool = [v for v in range(len(P))
|
|
if healthy(v) and abs(P[v][0] - cx) < 0.30
|
|
and abs(P[v][1] - cy) < 0.30 and abs(P[v][2] - cz) < 0.30]
|
|
print(f" healthy donor pool near region: {len(pool)} verts")
|
|
|
|
topo_used = spatial_used = 0
|
|
newJ, newW = {}, {}
|
|
|
|
for vi in sorted(bad_verts):
|
|
# BFS out to healthy neighbours through the mesh
|
|
found = []
|
|
seen = {vi}
|
|
q = deque([(vi, 0)])
|
|
while q and len(found) < K:
|
|
v, d = q.popleft()
|
|
if d > 4:
|
|
continue
|
|
for nb in adj.get(v, ()):
|
|
if nb in seen:
|
|
continue
|
|
seen.add(nb)
|
|
if healthy(nb):
|
|
found.append(nb)
|
|
if len(found) >= K:
|
|
break
|
|
q.append((nb, d + 1))
|
|
if found:
|
|
topo_used += 1
|
|
else:
|
|
# spatial fallback
|
|
ds = sorted(((math.dist(P[vi], P[v]), v) for v in pool))[:K]
|
|
found = [v for _, v in ds]
|
|
spatial_used += 1
|
|
|
|
# inverse-distance blend of neighbour weight sets
|
|
accw = defaultdict(float)
|
|
for nb in found:
|
|
d = math.dist(P[vi], P[nb])
|
|
wgt = 1.0 / max(d, 1e-4)
|
|
for j, w in zip(J[nb], W[nb]):
|
|
if w > 0.001:
|
|
accw[j] += w * wgt
|
|
# keep top 4, renormalise
|
|
top = sorted(accw.items(), key=lambda kv: -kv[1])[:4]
|
|
tot = sum(w for _, w in top)
|
|
if tot <= 0:
|
|
continue
|
|
nj = [0, 0, 0, 0]
|
|
nw = [0.0, 0.0, 0.0, 0.0]
|
|
for s, (j, w) in enumerate(top):
|
|
nj[s] = j
|
|
nw[s] = w / tot
|
|
newJ[vi] = nj
|
|
newW[vi] = nw
|
|
|
|
print(f" repaired {len(newJ)} verts (topological {topo_used}, spatial fallback {spatial_used})")
|
|
total_fixed += len(newJ)
|
|
|
|
if DIAG:
|
|
# show what the repair decided for a few verts
|
|
for vi in sorted(newJ)[:6]:
|
|
before = " ".join(f"{jname[j]}={w:.3f}" for j, w in zip(J[vi], W[vi]) if w > 0.001)
|
|
after = " ".join(f"{jname[j]}={w:.3f}" for j, w in zip(newJ[vi], newW[vi]) if w > 0.001)
|
|
print(f" v{vi}\n before: {before}\n after : {after}")
|
|
continue
|
|
|
|
# ---- write back
|
|
aJ, bvJ, ncJ, fmtJ, strideJ, offJ = acc_info(g, att["JOINTS_0"])
|
|
aW, bvW, ncW, fmtW, strideW, offW = acc_info(g, att["WEIGHTS_0"])
|
|
for vi in newJ:
|
|
struct.pack_into("<4%s" % fmtJ, buf, offJ + vi * strideJ, *newJ[vi])
|
|
if fmtW == "f":
|
|
vals = newW[vi]
|
|
elif fmtW == "B":
|
|
vals = [max(0, min(255, int(round(w * 255)))) for w in newW[vi]]
|
|
vals[0] += 255 - sum(vals)
|
|
else:
|
|
vals = [max(0, min(65535, int(round(w * 65535)))) for w in newW[vi]]
|
|
vals[0] += 65535 - sum(vals)
|
|
struct.pack_into("<4%s" % fmtW, buf, offW + vi * strideW, *vals)
|
|
|
|
if DIAG:
|
|
print("\ndiagnose only — nothing written")
|
|
elif dst:
|
|
write_glb(dst, g, chunks)
|
|
print(f"\nwrote {dst} ({total_fixed} verts repaired)")
|
|
else:
|
|
print("\nno output path given — nothing written")
|