feat(hand-shapes): morph-target hand-pose solver — fist/grip that actually deform
The morph lane's blocking bug was believed to be coincident duplicate verts hidden
from the solver's convergence gate. Welding was necessary but nowhere near
sufficient: the previous relaxation could satisfy every stretch bar without posing
anything, so the numbers it reported were not measuring the thing they claimed.
Measured on Ariki_Female_QuatSkin_LowPoly_40, the tracked "left hand is fully
clean" baseline was a morph that moved fingertips 0.3cm, and the right hand was a
rigid 6.5cm translation of the whole hand with its shape intact. Gated Laplacian
diffusion of a delta field has a null space — constants — and edge stretch cannot
see any of it: a rigid translation stretches no edge and neither does a collapse to
zero. Both exits report perfect bars.
Six defects fixed, each with its measurement in the code comments:
weld_roi 356 duplicate groups solved twice, deltas up to 2.55cm apart
stitch_components hand is 6 overlapping sheets with 1-10mm gaps; Dijkstra
cannot cross one, so each bone saw only its own sheet
refit_fingers skeleton finger chain ran to 19.8cm; the flesh ends at 14.0cm,
so curl_02/curl_03 drove almost no weight (shipped skeleton
untouched — this is solver-local scaffolding)
solve_weights chain-arc partition of unity; the old 8mm isotropic kernels
left ZERO of 17,480 verts owned above 0.85, and a 3-way blend
averages the curl away. Plus a support cutoff: outside every
kernel, renormalized 1e-81 noise had handed a mid-palm vertex
index_02_r=0.50 and flung it 29cm
build_roi joint-sphere ROI, so the rim is a wrist band and not a fractal
of interior chart holes
relax strain-only edge projection + ROI-border seam constraints,
replacing the diffusion described above
All 8 shapes now pass every bar with real deformation: p99.9 <= 1.58x, zero edges
over 5x, zero needles with the sliver floor removed, seam <= 3.2mm, cross-hand
independence exactly 0.0000cm, mesh fingertip travel 4.6-6.2cm on fist and
3.5-4.7cm on grip (both hands). Gates now report mesh travel and seam alongside
stretch, because stretch alone cannot gate this lane.
Verified in anim_hand_test_bed: fist and grip read as a real curl on both hands,
static and mid-dance, no fins/shards/needles. It is a loose fist rather than a
clenched one — her fingers are ~4-5cm past the knuckles.
Demo GLB stays out of git (122.6 MB, ariki-game/scratchpad/). Ship decision, the
thumb-axis refit, and the TDR crash from 8 dense targets are open — see README.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
"""Verify the emitted hand-morph GLB numerically:
|
||||
1. CROSS-HAND INDEPENDENCE: each hand_<pose>_<side> shape must move ONLY that side's verts.
|
||||
2. CURL DIRECTION: per shape, left-hand fingertip-region deltas must point toward the palm
|
||||
(dot with palm normal < 0) — catches a flipped normal making fingers bend backward.
|
||||
"""
|
||||
import json, struct, sys
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
|
||||
def read_glb(path):
|
||||
d = Path(path).read_bytes()
|
||||
ln = struct.unpack_from("<I", d, 8)[0]
|
||||
off = 12; g = b = None
|
||||
while off < ln:
|
||||
clen, ct = struct.unpack_from("<II", d, off); off += 8
|
||||
if ct == 0x4E4F534A: g = json.loads(d[off:off+clen])
|
||||
elif ct == 0x004E4942: b = d[off:off+clen]
|
||||
off += clen
|
||||
return g, b
|
||||
|
||||
def acc(g, b, i):
|
||||
a = g["accessors"][i]; bv = g["bufferViews"][a["bufferView"]]
|
||||
dt = np.dtype({5126: "<f4", 5123: "<u2", 5121: "u1", 5125: "<u4", 5122: "<i2"}[a["componentType"]])
|
||||
nc = {"VEC3": 3, "SCALAR": 1, "VEC4": 4}[a["type"]]
|
||||
size = dt.itemsize * nc
|
||||
stride = bv.get("byteStride") or size
|
||||
off2 = bv.get("byteOffset", 0) + a.get("byteOffset", 0)
|
||||
raw = np.frombuffer(b, np.uint8, a["count"] * stride, off2).reshape(a["count"], stride)
|
||||
return raw[:, :size].copy().view(dt).reshape(a["count"], nc).astype(np.float64)
|
||||
|
||||
argv = sys.argv
|
||||
argv = argv[argv.index("--") + 1:] if "--" in argv else argv[1:]
|
||||
g, b = read_glb(argv[0])
|
||||
prim = g["meshes"][0]["primitives"][0]
|
||||
P = acc(g, b, prim["attributes"]["POSITION"])
|
||||
names = [g["nodes"][j].get("name", "") for j in g["skins"][0]["joints"]]
|
||||
idx = {n: i for i, n in enumerate(names)}
|
||||
|
||||
# bone heads in world (mesh) space: node global translations
|
||||
def node_local(nd):
|
||||
t = np.array(nd.get("translation", [0, 0, 0])); s = np.array(nd.get("scale", [1, 1, 1]))
|
||||
x, y, z, w = nd.get("rotation", [0, 0, 0, 1])
|
||||
n = np.sqrt(x*x+y*y+z*z+w*w); x, y, z, w = x/n, y/n, z/n, w/n
|
||||
R = np.array([[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)]])
|
||||
M = np.eye(4); M[:3, :3] = R * s[None, :]; M[:3, 3] = t
|
||||
return M
|
||||
|
||||
node_to_joint = {j: i for i, j in enumerate(g["skins"][0]["joints"])}
|
||||
parent = {}
|
||||
for i, j in enumerate(g["skins"][0]["joints"]):
|
||||
for c in g["nodes"][j].get("children", []):
|
||||
if c in node_to_joint: parent[node_to_joint[c]] = i
|
||||
G = {}
|
||||
def glob(i):
|
||||
if i in G: return G[i]
|
||||
j = g["skins"][0]["joints"][i]
|
||||
M = node_local(g["nodes"][j])
|
||||
G[i] = M if parent.get(i, -1) < 0 else glob(parent[i]) @ M
|
||||
return G[i]
|
||||
|
||||
def head(n): return glob(idx[n])[:3, 3]
|
||||
|
||||
# palm plane per side: normal via middle/pinky/index MCP + hand
|
||||
for side in ("l", "r"):
|
||||
h = head(f"hand_{side}")
|
||||
mid, ix, pk = head(f"middle_01_{side}"), head(f"index_01_{side}"), head(f"pinky_01_{side}")
|
||||
n = np.cross(mid - h, pk - ix); n /= np.linalg.norm(n)
|
||||
print(f"side {side}: palm normal (world) = {np.round(n, 3)}")
|
||||
|
||||
# nearest-bone segmentation for vert labeling (euclidean, fingers only — verification only)
|
||||
finger_bones = [f"{d}_{p}{s}" for s in ("l", "r") for d in ("thumb","index","middle","ring","pinky")
|
||||
for p in ("01","02","03") if f"{d}_{p}{s}" in idx]
|
||||
heads = {bn: head(bn) for bn in finger_bones}
|
||||
dmin = np.full(len(P), 1e9); label = np.full(len(P), -1, dtype=int)
|
||||
for bi, bn in enumerate(finger_bones):
|
||||
d = np.linalg.norm(P - heads[bn], axis=1)
|
||||
closer = d < dmin
|
||||
dmin[closer] = d[closer]; label[closer] = bi
|
||||
# keep verts within 8cm of their nearest finger bone (fingertip region <= 3.5cm for direction)
|
||||
targets = prim["targets"]
|
||||
tnames = g["meshes"][0]["extras"]["targetNames"]
|
||||
for ti, tname in enumerate(tnames):
|
||||
delta = acc(g, b, targets[ti]["POSITION"])
|
||||
mag = np.linalg.norm(delta, axis=1)
|
||||
side = tname[-1]
|
||||
# 1) cross-hand: verts whose nearest bone is the OTHER side must not move
|
||||
other = np.array([finger_bones[l][-1] != side if l >= 0 else False for l in label])
|
||||
other &= dmin < 0.08
|
||||
cross = float(mag[other].max()) if other.any() else 0.0
|
||||
# 2) curl direction: fingertip verts of THIS side (non-thumb, nearest <= 3.5cm of _03 bone)
|
||||
tip = np.zeros(len(P), dtype=bool)
|
||||
for d in ("index", "middle", "ring", "pinky"):
|
||||
bn = f"{d}_03_{side}"
|
||||
if bn in heads:
|
||||
tip |= (np.linalg.norm(P - heads[bn], axis=1) < 0.035)
|
||||
if tip.any():
|
||||
h = head(f"hand_{side}")
|
||||
mid, ix, pk = head(f"middle_01_{side}"), head(f"index_01_{side}"), head(f"pinky_01_{side}")
|
||||
n = np.cross(mid - h, pk - ix); n /= np.linalg.norm(n)
|
||||
dots = delta[tip] @ n
|
||||
toward = float((dots < 0).mean())
|
||||
else:
|
||||
toward = -1
|
||||
own = ~other & (dmin < 0.08)
|
||||
ownmax = float(mag[own].max()) * 100 if own.any() else -1.0
|
||||
print(f"{tname}: max|delta| other-hand={cross*100:.2f}cm tip-toward-palm={toward*100:.0f}% "
|
||||
f"max|delta| own={ownmax:.1f}cm")
|
||||
Reference in New Issue
Block a user