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,174 @@
|
||||
"""Diagnose cross-midline finger bindings: for every offending ref, compare the lever arm
|
||||
to the WRONG joint against the lever to its MIRRORED counterpart, so we can tell whether a
|
||||
straight _r <-> _l joint remap is geometrically correct (small mirrored lever) or would tear
|
||||
(vert nowhere near the mirrored bone either).
|
||||
|
||||
Also reports each offending vert's full influence list, and the nearest correct finger joint.
|
||||
|
||||
usage: crosshand_diagnose.py body.glb
|
||||
"""
|
||||
import json, struct, sys, math
|
||||
from pathlib import Path
|
||||
from collections import Counter, defaultdict
|
||||
|
||||
FING = ("thumb", "index", "middle", "ring", "pinky")
|
||||
|
||||
|
||||
def read_glb(p):
|
||||
d = Path(p).read_bytes()
|
||||
length = struct.unpack_from("<I", d, 8)[0]
|
||||
off = 12
|
||||
g = b_ = None
|
||||
while off < length:
|
||||
clen, ct = struct.unpack_from("<II", d, off)
|
||||
off += 8
|
||||
if ct == 0x4E4F534A:
|
||||
g = json.loads(d[off:off + clen])
|
||||
else:
|
||||
b_ = d[off:off + clen]
|
||||
off += clen
|
||||
return g, b_
|
||||
|
||||
|
||||
def acc(g, b, 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 [struct.unpack_from("<%d%s" % (nc, fmt), b, off + k * stride)
|
||||
for k in range(a["count"])], a["componentType"]
|
||||
|
||||
|
||||
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"]))]
|
||||
|
||||
|
||||
def mirror_name(n):
|
||||
if n.endswith("_r"):
|
||||
return n[:-2] + "_l"
|
||||
if n.endswith("_l"):
|
||||
return n[:-2] + "_r"
|
||||
return None
|
||||
|
||||
|
||||
path = sys.argv[1]
|
||||
g, b = read_glb(path)
|
||||
names = [nd.get("name", "") for nd in g["nodes"]]
|
||||
GM = global_mats(g)
|
||||
|
||||
mesh = g["meshes"][0]
|
||||
prim = mesh["primitives"][0]
|
||||
att = prim["attributes"]
|
||||
skin_idx = next(nd.get("skin") for nd in g["nodes"] if nd.get("mesh") == 0 and "skin" in nd)
|
||||
joints = g["skins"][skin_idx]["joints"]
|
||||
jname = [names[j] for j in joints]
|
||||
jpos = {jname[k]: (GM[j][0][3], GM[j][1][3], GM[j][2][3]) for k, j in enumerate(joints)}
|
||||
|
||||
P, _ = acc(g, b, att["POSITION"])
|
||||
J, _ = acc(g, b, att["JOINTS_0"])
|
||||
W, wt = acc(g, b, att["WEIGHTS_0"])
|
||||
wsc = 1.0 if wt == 5126 else (1 / 255 if wt == 5121 else 1 / 65535)
|
||||
|
||||
# hand-bone anchors, to describe where verts sit
|
||||
print(f"== {Path(path).name} ==")
|
||||
for hb in ("hand_l", "hand_r", "middle_01_l", "middle_01_r", "middle_03_l", "middle_03_r"):
|
||||
if hb in jpos:
|
||||
p = jpos[hb]
|
||||
print(f" {hb:14s} rest pos = ({p[0]*100:7.1f}, {p[1]*100:7.1f}, {p[2]*100:7.1f}) cm")
|
||||
|
||||
bad = []
|
||||
for vi, (p, jrow, wrow) in enumerate(zip(P, J, W)):
|
||||
for j, w in zip(jrow, wrow):
|
||||
w *= wsc
|
||||
if w <= 0.001:
|
||||
continue
|
||||
n = jname[j]
|
||||
nl = n.lower()
|
||||
if not any(t in nl for t in FING):
|
||||
continue
|
||||
if (nl.endswith("_r") and p[0] > 0.02) or (nl.endswith("_l") and p[0] < -0.02):
|
||||
bad.append((vi, n, w, p))
|
||||
|
||||
print(f"\n cross-midline finger refs: {len(bad)}")
|
||||
vids = sorted({v for v, _, _, _ in bad})
|
||||
print(f" distinct verts affected : {len(vids)} (index range {min(vids)}..{max(vids)})")
|
||||
|
||||
# lever comparison: wrong joint vs mirrored joint vs nearest correct-side finger joint
|
||||
print(f"\n {'joint':16s} {'n':>5s} {'lever_wrong':>12s} {'lever_mirror':>13s} {'nearest_correct'}")
|
||||
groups = defaultdict(list)
|
||||
for vi, n, w, p in bad:
|
||||
groups[n].append((vi, w, p))
|
||||
|
||||
for n in sorted(groups):
|
||||
rows = groups[n]
|
||||
mn = mirror_name(n)
|
||||
lw = [math.dist(p, jpos[n]) * 100 for _, _, p in rows]
|
||||
lm = [math.dist(p, jpos[mn]) * 100 for _, _, p in rows] if mn in jpos else [float("nan")]
|
||||
# nearest correct-side finger joint for a sample vert
|
||||
side = "_l" if rows[0][2][0] > 0 else "_r"
|
||||
cand = [(math.dist(rows[0][2], jpos[k]) * 100, k) for k in jpos
|
||||
if any(t in k.lower() for t in FING) and k.endswith(side)]
|
||||
cand.sort()
|
||||
print(f" {n:16s} {len(rows):5d} {sum(lw)/len(lw):9.1f}cm {sum(lm)/len(lm):10.1f}cm "
|
||||
f" {cand[0][1]} @ {cand[0][0]:.1f}cm")
|
||||
|
||||
# full influence list for a few offenders
|
||||
print("\n sample offending verts (full influence list):")
|
||||
for vi in vids[:6]:
|
||||
p = P[vi]
|
||||
infl = []
|
||||
for j, w in zip(J[vi], W[vi]):
|
||||
w *= wsc
|
||||
if w > 0.001:
|
||||
infl.append(f"{jname[j]}={w:.3f}")
|
||||
print(f" v{vi} pos=({p[0]*100:6.1f},{p[1]*100:6.1f},{p[2]*100:6.1f})cm {' '.join(infl)}")
|
||||
|
||||
# how many offending verts are FULLY (>0.99) bound to a wrong joint
|
||||
full = sum(1 for vi, n, w, p in bad if w > 0.99)
|
||||
print(f"\n refs at weight > 0.99 (rigid, no blend to soften): {full}")
|
||||
|
||||
# what fraction of total left-hand-region verts are affected
|
||||
hl = jpos.get("hand_l")
|
||||
if hl:
|
||||
near = [vi for vi, p in enumerate(P) if math.dist(p, hl) < 0.20]
|
||||
aff = set(vids) & set(near)
|
||||
print(f" verts within 20cm of hand_l: {len(near)}; of those affected: {len(aff)}")
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Edge-stretch fin detector (pure python): posed OBJ edge lengths vs rest OBJ."""
|
||||
import sys, os, math
|
||||
|
||||
def load_obj(path):
|
||||
vs, faces = [], []
|
||||
with open(path) as f:
|
||||
for line in f:
|
||||
if line.startswith('v '):
|
||||
p = line.split()
|
||||
vs.append((float(p[1]), float(p[2]), float(p[3])))
|
||||
elif line.startswith('f '):
|
||||
idx = [int(tok.split('/')[0]) - 1 for tok in line.split()[1:]]
|
||||
for i in range(1, len(idx) - 1):
|
||||
faces.append((idx[0], idx[i], idx[i + 1]))
|
||||
return vs, faces
|
||||
|
||||
def edge_set(faces):
|
||||
es = set()
|
||||
for a, b, c in faces:
|
||||
for u, v in ((a, b), (b, c), (c, a)):
|
||||
es.add((u, v) if u < v else (v, u))
|
||||
return sorted(es)
|
||||
|
||||
def dist(p, q):
|
||||
return math.sqrt((p[0]-q[0])**2 + (p[1]-q[1])**2 + (p[2]-q[2])**2)
|
||||
|
||||
d = sys.argv[1]
|
||||
for hand in ('l', 'r'):
|
||||
rest_v, rest_f = load_obj(os.path.join(d, f'rest_hand_{hand}.obj'))
|
||||
edges = edge_set(rest_f)
|
||||
rest_len = [dist(rest_v[a], rest_v[b]) for a, b in edges]
|
||||
for pose in ('flat', 'fist', 'grip'):
|
||||
v, _ = load_obj(os.path.join(d, f'{pose}_hand_{hand}.obj'))
|
||||
if len(v) != len(rest_v):
|
||||
print(f'{pose}_hand_{hand}: VERTEX COUNT MISMATCH {len(v)} vs {len(rest_v)}')
|
||||
continue
|
||||
ratios = []
|
||||
for (a, b), rl in zip(edges, rest_len):
|
||||
if rl <= 1e-9:
|
||||
continue
|
||||
ratios.append((dist(v[a], v[b]) / rl, a, b))
|
||||
ratios.sort(key=lambda t: t[0])
|
||||
n = len(ratios)
|
||||
mx = ratios[-1][0]
|
||||
p999 = ratios[int(n * 0.999)][0]
|
||||
n2 = sum(1 for r, _, _ in ratios if r > 2)
|
||||
n3 = sum(1 for r, _, _ in ratios if r > 3)
|
||||
n5 = sum(1 for r, _, _ in ratios if r > 5)
|
||||
print(f'{pose}_hand_{hand}: edges={n} max={mx:.2f}x p99.9={p999:.2f}x >2x={n2} >3x={n3} >5x={n5}')
|
||||
for r, a, b in ratios[-min(max(n3, 3), 8):][::-1]:
|
||||
pa = [c * 100 for c in v[a]]
|
||||
rl = dist(rest_v[a], rest_v[b]) * 100
|
||||
print(f' {r:7.1f}x rest {rl:5.2f}cm -> {r*rl:7.1f}cm at posed ({pa[0]:.1f}, {pa[1]:.1f}, {pa[2]:.1f}) cm')
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Edge-stretch comparison across OBJ dirs, with a rest-length split.
|
||||
|
||||
usage: stretch_cmp.py <rest_dir> <label>=<dir> [<label>=<dir> ...]
|
||||
Rest OBJs (rest_hand_l/r.obj) come from <rest_dir>; every compared dir must share the
|
||||
body's vertex order. Reports, per pose and hand: max / p99.9 / >2x / >5x over ALL edges,
|
||||
then the subset that is real geometry (>=1mm rest length) and the subset that is
|
||||
VISIBLE (posed length >=1cm) — the count that decides whether a render shows a needle.
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def load_obj(path):
|
||||
vs, faces = [], []
|
||||
with open(path) as f:
|
||||
for line in f:
|
||||
if line.startswith("v "):
|
||||
p = line.split()
|
||||
vs.append((float(p[1]), float(p[2]), float(p[3])))
|
||||
elif line.startswith("f "):
|
||||
idx = [int(t.split("/")[0]) - 1 for t in line.split()[1:]]
|
||||
for i in range(1, len(idx) - 1):
|
||||
faces.append((idx[0], idx[i], idx[i + 1]))
|
||||
return vs, faces
|
||||
|
||||
|
||||
def edge_set(faces):
|
||||
es = set()
|
||||
for a, b, c in faces:
|
||||
for u, v in ((a, b), (b, c), (c, a)):
|
||||
es.add((u, v) if u < v else (v, u))
|
||||
return sorted(es)
|
||||
|
||||
|
||||
def dist(p, q):
|
||||
return math.sqrt(sum((p[i] - q[i]) ** 2 for i in range(3)))
|
||||
|
||||
|
||||
rest_dir = sys.argv[1]
|
||||
cols = [a.split("=", 1) for a in sys.argv[2:]]
|
||||
|
||||
for hand in ("l", "r"):
|
||||
rest_v, rest_f = load_obj(os.path.join(rest_dir, f"rest_hand_{hand}.obj"))
|
||||
edges = edge_set(rest_f)
|
||||
rest_len = [dist(rest_v[a], rest_v[b]) for a, b in edges]
|
||||
print(f"\n=== hand_{hand} ({len(rest_v)} verts, {len(edges)} edges) ===")
|
||||
print(f"{'pose / build':22s} {'max':>8s} {'p99.9':>7s} {'>2x':>6s} {'>5x':>6s}"
|
||||
f" {'>5x real':>9s} {'>=1cm':>7s}")
|
||||
for pose in ("flat", "fist", "grip"):
|
||||
for label, d in cols:
|
||||
f = os.path.join(d, f"{pose}_hand_{hand}.obj")
|
||||
if not os.path.exists(f):
|
||||
continue
|
||||
v, _ = load_obj(f)
|
||||
if len(v) != len(rest_v):
|
||||
print(f"{pose+' '+label:22s} VERT COUNT MISMATCH {len(v)} vs {len(rest_v)}")
|
||||
continue
|
||||
rs = []
|
||||
gt5 = gt2 = real5 = vis = 0
|
||||
for (a, b), rl in zip(edges, rest_len):
|
||||
if rl <= 1e-9:
|
||||
continue
|
||||
lq = dist(v[a], v[b])
|
||||
r = lq / rl
|
||||
rs.append(r)
|
||||
if r > 2:
|
||||
gt2 += 1
|
||||
if r > 5:
|
||||
gt5 += 1
|
||||
if rl >= 0.001:
|
||||
real5 += 1
|
||||
if lq >= 0.01:
|
||||
vis += 1
|
||||
rs.sort()
|
||||
print(f"{pose+' '+label:22s} {rs[-1]:7.1f}x {rs[int(len(rs)*0.999)]:6.2f}x"
|
||||
f" {gt2:6d} {gt5:6d} {real5:9d} {vis:7d}")
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Classify torn edges (posed stretch >5x) by the dominant joint of each endpoint.
|
||||
usage: fin_bones.py posed.glb"""
|
||||
import json, struct, sys, math
|
||||
from pathlib import Path
|
||||
from collections import Counter
|
||||
|
||||
def read_glb(path):
|
||||
d = Path(path).read_bytes()
|
||||
length = struct.unpack_from("<I", d, 8)[0]
|
||||
off = 12; g = None; b = None
|
||||
while off < length:
|
||||
clen, ct = struct.unpack_from("<II", d, off); off += 8
|
||||
if ct == 0x4E4F534A: g = json.loads(d[off:off+clen])
|
||||
else: b = d[off:off+clen]
|
||||
off += clen
|
||||
return g, b
|
||||
|
||||
def acc(g, b, 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 [struct.unpack_from("<%d%s"%(nc,fmt), b, off+i*stride) for i in range(a["count"])], a["componentType"]
|
||||
|
||||
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)
|
||||
M = [[R[i][j]*s[j] for j in range(3)]+[t[i]] for i in range(3)]
|
||||
return M+[[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)]
|
||||
|
||||
g, b = read_glb(sys.argv[1])
|
||||
names = [nd.get("name","") for nd in g["nodes"]]
|
||||
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] if i not in parent else matmul(gm(parent[i]), loc[i])
|
||||
memo[i] = m; return m
|
||||
G = [gm(i) for i in range(len(g["nodes"]))]
|
||||
skin = g["skins"][0]; joints = skin["joints"]
|
||||
ibm,_ = acc(g,b,skin["inverseBindMatrices"])
|
||||
def m16(row): return [[row[c*4+r] for c in range(4)] for r in range(4)]
|
||||
JM = [matmul(G[joints[j]], m16(ibm[j])) for j in range(len(joints))]
|
||||
prim = g["meshes"][0]["primitives"][0]
|
||||
P,_ = acc(g,b,prim["attributes"]["POSITION"])
|
||||
J,_ = acc(g,b,prim["attributes"]["JOINTS_0"])
|
||||
W,wt = acc(g,b,prim["attributes"]["WEIGHTS_0"])
|
||||
wsc = 1.0 if wt==5126 else (1/255 if wt==5121 else 1/65535)
|
||||
IDX,_ = acc(g,b,prim["indices"])
|
||||
idx = [i[0] for i in IDX]
|
||||
|
||||
def skin_pos(vi):
|
||||
p = P[vi]; x=y=z=0.0
|
||||
for j,w in zip(J[vi],W[vi]):
|
||||
w*=wsc
|
||||
if w<=0: continue
|
||||
M=JM[j]
|
||||
x+=w*(M[0][0]*p[0]+M[0][1]*p[1]+M[0][2]*p[2]+M[0][3])
|
||||
y+=w*(M[1][0]*p[0]+M[1][1]*p[1]+M[1][2]*p[2]+M[1][3])
|
||||
z+=w*(M[2][0]*p[0]+M[2][1]*p[1]+M[2][2]*p[2]+M[2][3])
|
||||
return (x,y,z)
|
||||
|
||||
def dom(vi):
|
||||
best, bw = None, 0
|
||||
for j,w in zip(J[vi],W[vi]):
|
||||
w*=wsc
|
||||
if w>bw: bw, best = w, j
|
||||
return names[joints[best]] if best is not None else "?"
|
||||
|
||||
edges = set()
|
||||
for t in range(0, len(idx), 3):
|
||||
a_,b_,c_ = idx[t], idx[t+1], idx[t+2]
|
||||
for u,v in ((a_,b_),(b_,c_),(c_,a_)):
|
||||
edges.add((u,v) if u<v else (v,u))
|
||||
|
||||
pos_cache = {}
|
||||
def sp(vi):
|
||||
if vi not in pos_cache: pos_cache[vi] = skin_pos(vi)
|
||||
return pos_cache[vi]
|
||||
|
||||
pairs = Counter(); n_bad = 0; maxr = 0
|
||||
for u,v in edges:
|
||||
rl = math.dist(P[u], P[v])
|
||||
if rl <= 1e-9: continue
|
||||
# cheap prefilter: only edges where an endpoint is finger/hand weighted
|
||||
dn_u, dn_v = dom(u), dom(v)
|
||||
lu, lv = dn_u.lower(), dn_v.lower()
|
||||
keys = ("thumb","index","middle","ring","pinky","hand","lower_arm","wrist")
|
||||
if not any(k in lu or k in lv for k in keys): continue
|
||||
r = math.dist(sp(u), sp(v)) / rl
|
||||
if r > 5:
|
||||
n_bad += 1; maxr = max(maxr, r)
|
||||
pairs[tuple(sorted((dn_u, dn_v)))] += 1
|
||||
print(f"edges>5x: {n_bad} max stretch {maxr:.0f}x")
|
||||
for (a_,b_), n in pairs.most_common(20):
|
||||
print(f" {n:5d} {a_} <-> {b_}")
|
||||
@@ -0,0 +1,84 @@
|
||||
"""For each finger bone, report how much geometry it actually OWNS (verts where it is the
|
||||
dominant influence) and where that geometry sits. On a two-finger hand rig the five-finger
|
||||
bone set is present but several chains own nothing, or several chains share one fused mass.
|
||||
|
||||
usage: hand_bone_ownership.py body.glb [side l|r]
|
||||
"""
|
||||
import json, struct, sys, math
|
||||
from pathlib import Path
|
||||
from collections import defaultdict
|
||||
|
||||
FING = ("thumb", "index", "middle", "ring", "pinky")
|
||||
side = sys.argv[2] if len(sys.argv) > 2 else None
|
||||
|
||||
|
||||
def read_glb(p):
|
||||
d = Path(p).read_bytes()
|
||||
length = struct.unpack_from("<I", d, 8)[0]
|
||||
off = 12
|
||||
g = b_ = None
|
||||
while off < length:
|
||||
clen, ct = struct.unpack_from("<II", d, off)
|
||||
off += 8
|
||||
if ct == 0x4E4F534A:
|
||||
g = json.loads(d[off:off + clen])
|
||||
else:
|
||||
b_ = d[off:off + clen]
|
||||
off += clen
|
||||
return g, b_
|
||||
|
||||
|
||||
def acc(g, b, 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 [struct.unpack_from("<%d%s" % (nc, fmt), b, off + k * stride)
|
||||
for k in range(a["count"])], a["componentType"]
|
||||
|
||||
|
||||
g, b = read_glb(sys.argv[1])
|
||||
names = [nd.get("name", "") for nd in g["nodes"]]
|
||||
prim = g["meshes"][0]["primitives"][0]
|
||||
att = prim["attributes"]
|
||||
skin = next(nd["skin"] for nd in g["nodes"] if nd.get("mesh") == 0 and "skin" in nd)
|
||||
joints = g["skins"][skin]["joints"]
|
||||
jname = [names[j] for j in joints]
|
||||
|
||||
P, _ = acc(g, b, att["POSITION"])
|
||||
J, _ = acc(g, b, att["JOINTS_0"])
|
||||
W, wt = acc(g, b, att["WEIGHTS_0"])
|
||||
wsc = 1.0 if wt == 5126 else (1 / 255 if wt == 5121 else 1 / 65535)
|
||||
|
||||
own = defaultdict(list)
|
||||
for vi, (p, jrow, wrow) in enumerate(zip(P, J, W)):
|
||||
best = (0.0, None)
|
||||
for j, w in zip(jrow, wrow):
|
||||
w *= wsc
|
||||
if w > best[0]:
|
||||
best = (w, jname[j])
|
||||
if best[1] and any(t in best[1].lower() for t in FING):
|
||||
if side and not best[1].lower().endswith("_" + side):
|
||||
continue
|
||||
own[best[1]].append(p)
|
||||
|
||||
print(f"{Path(sys.argv[1]).name} dominant-owner geometry per finger bone"
|
||||
f"{' (side ' + side + ')' if side else ''}\n")
|
||||
print(f" {'bone':20s} {'verts':>7s} {'z-centre':>9s} {'z-span':>8s} {'x-centre':>9s}")
|
||||
for fam in FING:
|
||||
rows = [(n, v) for n, v in own.items() if fam in n.lower()]
|
||||
if not rows:
|
||||
print(f" {fam:20s} {'0':>7s} -- owns no geometry --")
|
||||
continue
|
||||
for n in sorted(r[0] for r in rows):
|
||||
ps = own[n]
|
||||
zc = sum(p[2] for p in ps) / len(ps) * 100
|
||||
zs = (max(p[2] for p in ps) - min(p[2] for p in ps)) * 100
|
||||
xc = sum(p[0] for p in ps) / len(ps) * 100
|
||||
print(f" {n:20s} {len(ps):7d} {zc:8.1f}cm {zs:7.1f}cm {xc:8.1f}cm")
|
||||
print()
|
||||
tot = sum(len(v) for v in own.values())
|
||||
print(f" total finger-owned verts: {tot}")
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Bake a hand-pose JSON into a body GLB by rewriting finger node rest rotations.
|
||||
IBMs untouched -> mesh deforms to the pose. usage: bake_preview.py body.glb pose.json out.glb"""
|
||||
import json, struct, sys
|
||||
from pathlib import Path
|
||||
|
||||
body, posef, out = sys.argv[1:4]
|
||||
data = Path(body).read_bytes()
|
||||
length = struct.unpack_from("<I", data, 8)[0]
|
||||
off = 12
|
||||
chunks = []
|
||||
gltf = None
|
||||
while off < length:
|
||||
clen, ctype = struct.unpack_from("<II", data, off)
|
||||
off += 8
|
||||
if ctype == 0x4E4F534A:
|
||||
gltf = json.loads(data[off:off+clen].decode("utf-8"))
|
||||
chunks.append([ctype, None])
|
||||
else:
|
||||
chunks.append([ctype, data[off:off+clen]])
|
||||
off += clen
|
||||
|
||||
pose = json.loads(Path(posef).read_text())["bones"]
|
||||
byname = {nd.get("name"): nd for nd in gltf["nodes"]}
|
||||
|
||||
canon = None
|
||||
if len(sys.argv) > 4: # canonical-rest GLB: apply pose as rest-relative delta
|
||||
cdata = Path(sys.argv[4]).read_bytes()
|
||||
clen2 = struct.unpack_from("<I", cdata, 12)[0]
|
||||
cg = json.loads(cdata[20:20+clen2].decode("utf-8"))
|
||||
canon = {nd.get("name"): nd.get("rotation", [0, 0, 0, 1]) for nd in cg["nodes"]}
|
||||
|
||||
def qmul(a, b):
|
||||
ax, ay, az, aw = a; bx, by, bz, bw = b
|
||||
return [aw*bx + ax*bw + ay*bz - az*by,
|
||||
aw*by - ax*bz + ay*bw + az*bx,
|
||||
aw*bz + ax*by - ay*bx + az*bw,
|
||||
aw*bw - ax*bx - ay*by - az*bz]
|
||||
|
||||
n = 0
|
||||
for bone, quat in pose.items():
|
||||
if bone in byname:
|
||||
if canon is not None:
|
||||
cr = canon.get(bone, [0, 0, 0, 1])
|
||||
delta = qmul([-cr[0], -cr[1], -cr[2], cr[3]], quat) # canon_rest^-1 * pose
|
||||
body_rest = byname[bone].get("rotation", [0, 0, 0, 1])
|
||||
quat = qmul(body_rest, delta)
|
||||
byname[bone]["rotation"] = quat
|
||||
n += 1
|
||||
# strip animations so nothing overrides the pose
|
||||
gltf.pop("animations", None)
|
||||
|
||||
js = json.dumps(gltf, separators=(",", ":")).encode("utf-8")
|
||||
js += b" " * ((4 - len(js) % 4) % 4)
|
||||
body_out = b""
|
||||
for ctype, payload in chunks:
|
||||
if ctype == 0x4E4F534A:
|
||||
payload = js
|
||||
body_out += struct.pack("<II", len(payload), ctype) + payload
|
||||
hdr = struct.pack("<III", 0x46546C67, 2, 12 + len(body_out))
|
||||
Path(out).write_bytes(hdr + body_out)
|
||||
print(f"baked {n}/{len(pose)} bones -> {out}")
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Strip axial roll (twist about the bone axis) from a hand-pose JSON, keeping the curl.
|
||||
The pose delta vs the body's rest is swing-twist decomposed per bone; the twist factor is
|
||||
dropped and the pose rebuilt as rest*swing. Thumb chains are left untouched (their roll is
|
||||
functional opposition). usage: handpose_detwist.py body.glb pose_in.json pose_out.json"""
|
||||
import json, math, struct, sys
|
||||
from pathlib import Path
|
||||
|
||||
body, pose_in, pose_out = sys.argv[1:4]
|
||||
|
||||
data = Path(body).read_bytes()
|
||||
jlen = struct.unpack_from("<I", data, 12)[0]
|
||||
gltf = json.loads(data[20:20 + jlen].decode("utf-8"))
|
||||
nodes = gltf["nodes"]
|
||||
byname = {n.get("name"): i for i, n in enumerate(nodes)}
|
||||
|
||||
|
||||
def qmul(a, b):
|
||||
ax, ay, az, aw = a
|
||||
bx, by, bz, bw = b
|
||||
return [aw * bx + ax * bw + ay * bz - az * by,
|
||||
aw * by - ax * bz + ay * bw + az * bx,
|
||||
aw * bz + ax * by - ay * bx + az * bw,
|
||||
aw * bw - ax * bx - ay * by - az * bz]
|
||||
|
||||
|
||||
def qinv(q):
|
||||
return [-q[0], -q[1], -q[2], q[3]]
|
||||
|
||||
|
||||
def qnorm(q):
|
||||
m = math.sqrt(sum(v * v for v in q))
|
||||
return [v / m for v in q]
|
||||
|
||||
|
||||
def bone_axis(i):
|
||||
for c in nodes[i].get("children", []):
|
||||
t = nodes[c].get("translation")
|
||||
if t:
|
||||
m = math.sqrt(sum(v * v for v in t))
|
||||
if m > 1e-8:
|
||||
return [v / m for v in t]
|
||||
return None
|
||||
|
||||
|
||||
pose = json.loads(Path(pose_in).read_text())["bones"]
|
||||
out = {}
|
||||
report = []
|
||||
for name, p in pose.items():
|
||||
i = byname.get(name)
|
||||
if i is None or name.startswith("thumb"):
|
||||
out[name] = p
|
||||
continue
|
||||
a = bone_axis(i)
|
||||
if a is None: # leaf tips: twist is invisible, keep as-is
|
||||
out[name] = p
|
||||
continue
|
||||
r = nodes[i].get("rotation", [0, 0, 0, 1])
|
||||
d = qmul(qinv(r), p) # delta in the bone's rest-local frame
|
||||
dot = d[0] * a[0] + d[1] * a[1] + d[2] * a[2]
|
||||
twist = qnorm([dot * a[0], dot * a[1], dot * a[2], d[3]])
|
||||
swing = qmul(d, qinv(twist))
|
||||
out[name] = [round(v, 6) for v in qnorm(qmul(r, swing))]
|
||||
deg = 2 * math.degrees(math.atan2(abs(dot), abs(d[3])))
|
||||
if deg > 1.0:
|
||||
report.append((deg, name))
|
||||
|
||||
Path(pose_out).write_text(json.dumps({"bones": out}, indent=1))
|
||||
report.sort(reverse=True)
|
||||
print("wrote %s (%d bones, thumbs untouched)" % (pose_out, len(out)))
|
||||
for deg, name in report[:6]:
|
||||
print(" stripped %5.1f deg %s" % (deg, name))
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Extract a hand pose (40 finger-bone quaternions) from a GLB clip at a chosen frame,
|
||||
report per-bone curl (deviation from skeleton rest), optionally dump JSON.
|
||||
|
||||
usage: python extract_pose.py <glb> <animName> [--frame N | --max-curl] [--dump out.json]
|
||||
python extract_pose.py <glb> --rest --dump out.json (rest pose itself)
|
||||
"""
|
||||
import json, struct, sys, math
|
||||
from pathlib import Path
|
||||
|
||||
FINGER_TOKENS = ("thumb", "index", "middle", "ring", "pinky")
|
||||
|
||||
def read_glb(path):
|
||||
data = Path(path).read_bytes()
|
||||
magic, ver, length = struct.unpack_from("<III", data, 0)
|
||||
off = 12
|
||||
gltf = None; binc = None
|
||||
while off < length:
|
||||
clen, ctype = struct.unpack_from("<II", data, off)
|
||||
off += 8
|
||||
chunk = data[off:off+clen]
|
||||
if ctype == 0x4E4F534A: gltf = json.loads(chunk.decode("utf-8"))
|
||||
elif ctype == 0x004E4942: binc = chunk
|
||||
off += clen
|
||||
return gltf, binc
|
||||
|
||||
def acc_data(gltf, binc, idx):
|
||||
acc = gltf["accessors"][idx]
|
||||
bv = gltf["bufferViews"][acc["bufferView"]]
|
||||
n = {"SCALAR":1, "VEC3":3, "VEC4":4}[acc["type"]]
|
||||
off = bv.get("byteOffset", 0) + acc.get("byteOffset", 0)
|
||||
vals = struct.unpack_from("<%d%s" % (acc["count"]*n, "f"), binc, off)
|
||||
return [vals[i*n:(i+1)*n] for i in range(acc["count"])]
|
||||
|
||||
def qangle(a, b):
|
||||
d = min(1.0, abs(sum(x*y for x, y in zip(a, b))))
|
||||
return 2*math.degrees(math.acos(d))
|
||||
|
||||
def main():
|
||||
glb = sys.argv[1]
|
||||
gltf, binc = read_glb(glb)
|
||||
nodes = gltf["nodes"]
|
||||
names = [nd.get("name", f"n{i}") for i, nd in enumerate(nodes)]
|
||||
finger_idx = {i: names[i] for i, nd in enumerate(nodes)
|
||||
if any(t in names[i].lower() for t in FINGER_TOKENS)}
|
||||
rest = {i: tuple(nodes[i].get("rotation", [0, 0, 0, 1])) for i in finger_idx}
|
||||
|
||||
dump = None
|
||||
if "--dump" in sys.argv:
|
||||
dump = sys.argv[sys.argv.index("--dump")+1]
|
||||
|
||||
if "--rest" in sys.argv:
|
||||
pose = {names[i]: list(rest[i]) for i in finger_idx}
|
||||
label = "REST"
|
||||
else:
|
||||
aname = sys.argv[2]
|
||||
anim = next(a for a in gltf["animations"] if a.get("name") == aname)
|
||||
# collect finger rotation samplers
|
||||
tracks = {}
|
||||
times_ref = None
|
||||
for ch in anim["channels"]:
|
||||
t = ch["target"]
|
||||
if t.get("path") != "rotation" or t["node"] not in finger_idx: continue
|
||||
samp = anim["samplers"][ch["sampler"]]
|
||||
quats = acc_data(gltf, binc, samp["output"])
|
||||
tracks[t["node"]] = quats
|
||||
times_ref = acc_data(gltf, binc, samp["input"])
|
||||
nframes = min(len(q) for q in tracks.values())
|
||||
if "--max-curl" in sys.argv:
|
||||
best, bestf = -1, 0
|
||||
for f in range(nframes):
|
||||
curl = sum(qangle(tracks[i][f], rest[i]) for i in tracks)
|
||||
if curl > best: best, bestf = curl, f
|
||||
frame = bestf
|
||||
elif "--frame" in sys.argv:
|
||||
frame = int(sys.argv[sys.argv.index("--frame")+1])
|
||||
else:
|
||||
frame = 0
|
||||
t = times_ref[min(frame, len(times_ref)-1)][0] if times_ref else 0
|
||||
pose = {names[i]: list(tracks[i][frame]) for i in tracks}
|
||||
# fill missing finger bones from rest
|
||||
for i in finger_idx:
|
||||
pose.setdefault(names[i], list(rest[i]))
|
||||
label = f"{aname} frame {frame} (t={t:.2f}s)"
|
||||
|
||||
# curl report per finger chain (sum of deviations from rest), L hand only for brevity
|
||||
print(f"pose: {label} ({len(pose)} bones)")
|
||||
for hand in ("_l", "_r"):
|
||||
parts = []
|
||||
for fing in ("thumb", "index", "middle", "ring", "pinky"):
|
||||
tot = sum(qangle(pose[n], rest[i]) for i, n in finger_idx.items()
|
||||
if n.startswith(fing) and n.endswith(hand))
|
||||
parts.append(f"{fing} {tot:.0f}")
|
||||
print(f" {hand}: curl-vs-rest deg " + " ".join(parts))
|
||||
if dump:
|
||||
Path(dump).write_text(json.dumps({"source": f"{Path(glb).name}:{label}",
|
||||
"bones": pose}, indent=1))
|
||||
print("dumped ->", dump)
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Clay-render each OBJ in a directory, 3 angles, framed on its bbox.
|
||||
Only files matching *_hand_*.obj are picked up, and outdir MUST be absolute — a relative
|
||||
one makes Blender write outside the tree and silently produce nothing.
|
||||
|
||||
usage: blender --background --factory-startup --python render_objs.py -- objdir outdir [res] [dist]
|
||||
res square render resolution in px (default 900)
|
||||
dist camera distance as a multiple of the mesh radius (default 2.6; lower = tighter)"""
|
||||
import bpy, sys, math, glob, os
|
||||
from mathutils import Vector
|
||||
|
||||
argv = sys.argv[sys.argv.index("--") + 1:]
|
||||
objdir, outdir = argv[0], argv[1]
|
||||
RES = int(argv[2]) if len(argv) > 2 else 900
|
||||
DIST = float(argv[3]) if len(argv) > 3 else 2.6
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
scn = bpy.context.scene
|
||||
scn.render.engine = 'BLENDER_EEVEE' if bpy.app.version >= (4, 2) else 'BLENDER_EEVEE_NEXT'
|
||||
scn.render.resolution_x = scn.render.resolution_y = RES
|
||||
|
||||
mat = bpy.data.materials.new("Clay")
|
||||
mat.use_nodes = True
|
||||
bsdf = mat.node_tree.nodes["Principled BSDF"]
|
||||
bsdf.inputs["Base Color"].default_value = (0.72, 0.55, 0.45, 1.0)
|
||||
bsdf.inputs["Roughness"].default_value = 0.65
|
||||
|
||||
for rot, energy in (((50, 0, 30), 3.0), ((-40, 0, -140), 1.2), ((10, 0, 180), 0.8)):
|
||||
sun = bpy.data.objects.new("Sun", bpy.data.lights.new("Sun", 'SUN'))
|
||||
sun.data.energy = energy
|
||||
sun.rotation_euler = tuple(math.radians(a) for a in rot)
|
||||
scn.collection.objects.link(sun)
|
||||
|
||||
cam = bpy.data.objects.new("Cam", bpy.data.cameras.new("Cam"))
|
||||
cam.data.lens = 60
|
||||
scn.collection.objects.link(cam)
|
||||
scn.camera = cam
|
||||
|
||||
for path in sorted(glob.glob(os.path.join(objdir, "*_hand_*.obj"))):
|
||||
bpy.ops.wm.obj_import(filepath=path)
|
||||
obj = bpy.context.selected_objects[0]
|
||||
obj.data.materials.clear()
|
||||
obj.data.materials.append(mat)
|
||||
for p in obj.data.polygons: p.use_smooth = True
|
||||
bb = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
|
||||
ctr = sum(bb, Vector()) / 8
|
||||
rad = max((v - ctr).length for v in bb)
|
||||
tag = os.path.splitext(os.path.basename(path))[0]
|
||||
# OBJ import is -Z forward +Y up by default: gltf Y-up mesh arrives Z-up in Blender
|
||||
for label, direction in (("palm", Vector((0, -1, -0.25))),
|
||||
("back", Vector((0, 1, 0.35))),
|
||||
("side", Vector((-1, -0.3, 0.1)))):
|
||||
d = direction.normalized()
|
||||
cam.location = ctr - d * (rad * DIST)
|
||||
cam.rotation_euler = d.to_track_quat('-Z', 'Y').to_euler()
|
||||
scn.render.filepath = os.path.join(outdir, f"{tag}_{label}.png")
|
||||
bpy.ops.render.render(write_still=True)
|
||||
print("[objr] wrote", scn.render.filepath)
|
||||
bpy.data.objects.remove(obj, do_unlink=True)
|
||||
print("[objr] DONE")
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Scan GLBs: list finger joints and which animations have live (non-frozen) finger rotation tracks."""
|
||||
import json, struct, sys, math
|
||||
from pathlib import Path
|
||||
|
||||
FINGER_TOKENS = ("thumb", "index", "middle", "ring", "pinky", "finger")
|
||||
|
||||
def read_glb(path):
|
||||
data = Path(path).read_bytes()
|
||||
magic, ver, length = struct.unpack_from("<III", data, 0)
|
||||
assert magic == 0x46546C67, "not glb"
|
||||
off = 12
|
||||
gltf = None
|
||||
bin_chunk = None
|
||||
while off < length:
|
||||
clen, ctype = struct.unpack_from("<II", data, off)
|
||||
off += 8
|
||||
chunk = data[off:off+clen]
|
||||
if ctype == 0x4E4F534A:
|
||||
gltf = json.loads(chunk.decode("utf-8"))
|
||||
elif ctype == 0x004E4942:
|
||||
bin_chunk = chunk
|
||||
off += clen
|
||||
return gltf, bin_chunk
|
||||
|
||||
def accessor_data(gltf, binc, idx):
|
||||
acc = gltf["accessors"][idx]
|
||||
bv = gltf["bufferViews"][acc["bufferView"]]
|
||||
comp = {5126: ("f", 4)}[acc["componentType"]]
|
||||
n = {"SCALAR":1, "VEC3":3, "VEC4":4}[acc["type"]]
|
||||
off = bv.get("byteOffset", 0) + acc.get("byteOffset", 0)
|
||||
count = acc["count"]
|
||||
vals = struct.unpack_from("<%d%s" % (count*n, comp[0]), binc, off)
|
||||
return [vals[i*n:(i+1)*n] for i in range(count)]
|
||||
|
||||
def scan(path, verbose_joints=False):
|
||||
gltf, binc = read_glb(path)
|
||||
nodes = gltf.get("nodes", [])
|
||||
names = [nd.get("name", f"node{i}") for i, nd in enumerate(nodes)]
|
||||
# joints from skins
|
||||
joint_set = set()
|
||||
for skin in gltf.get("skins", []):
|
||||
joint_set.update(skin.get("joints", []))
|
||||
fingers = sorted(n for i in joint_set for n in [names[i]] if any(t in n.lower() for t in FINGER_TOKENS))
|
||||
print(f"\n== {Path(path).name} ==")
|
||||
print(f"joints: {len(joint_set)}, finger joints: {len(fingers)}")
|
||||
if verbose_joints:
|
||||
for n in sorted(names[i] for i in joint_set):
|
||||
print(" ", n)
|
||||
elif fingers:
|
||||
print(" finger joints:", ", ".join(fingers))
|
||||
for anim in gltf.get("animations", []):
|
||||
aname = anim.get("name", "?")
|
||||
live, frozen = [], []
|
||||
for ch in anim.get("channels", []):
|
||||
tgt = ch["target"]
|
||||
if tgt.get("path") != "rotation":
|
||||
continue
|
||||
nname = names[tgt["node"]]
|
||||
if not any(t in nname.lower() for t in FINGER_TOKENS):
|
||||
continue
|
||||
samp = anim["samplers"][ch["sampler"]]
|
||||
quats = accessor_data(gltf, binc, samp["output"])
|
||||
# measure max angular deviation from first frame
|
||||
q0 = quats[0]
|
||||
maxdot = 1.0
|
||||
for q in quats[1:]:
|
||||
d = abs(sum(a*b for a, b in zip(q0, q)))
|
||||
maxdot = min(maxdot, min(d, 1.0))
|
||||
ang = 2*math.degrees(math.acos(maxdot))
|
||||
(live if ang > 2.0 else frozen).append((nname, ang))
|
||||
total = len(live) + len(frozen)
|
||||
if total:
|
||||
print(f" anim '{aname}': {total} finger rot tracks, {len(live)} live (>2deg), {len(frozen)} frozen")
|
||||
if live:
|
||||
top = sorted(live, key=lambda x: -x[1])[:4]
|
||||
print(" top movers:", ", ".join(f"{n} {a:.0f}deg" for n, a in top))
|
||||
else:
|
||||
print(f" anim '{aname}': NO finger tracks")
|
||||
|
||||
if __name__ == "__main__":
|
||||
for p in sys.argv[1:]:
|
||||
scan(p, verbose_joints="--joints" in sys.argv)
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Skin a baked-pose GLB's hand region with Godot-exact LBS and write it as a plain OBJ.
|
||||
usage: godot_skin_hand_obj.py posed.glb side(l|r) out.obj"""
|
||||
import json, struct, sys
|
||||
|
||||
def read_glb(path):
|
||||
d = open(path, "rb").read()
|
||||
length = struct.unpack_from("<I", d, 8)[0]
|
||||
off = 12; g = None; b = None
|
||||
while off < length:
|
||||
clen, ct = struct.unpack_from("<II", d, off); off += 8
|
||||
if ct == 0x4E4F534A: g = json.loads(d[off:off+clen])
|
||||
else: b = d[off:off+clen]
|
||||
off += clen
|
||||
return g, b
|
||||
|
||||
def acc(g, b, 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 [struct.unpack_from("<%d%s"%(nc,fmt), b, off+k*stride) for k in range(a["count"])], a["componentType"]
|
||||
|
||||
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)
|
||||
M = [[R[i][j]*s[j] for j in range(3)]+[t[i]] for i in range(3)]
|
||||
return M+[[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)]
|
||||
|
||||
glb, side, out = sys.argv[1:4]
|
||||
g, b = read_glb(glb)
|
||||
names = [nd.get("name","") for nd in g["nodes"]]
|
||||
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] if i not in parent else matmul(gm(parent[i]), loc[i])
|
||||
memo[i] = m; return m
|
||||
G = [gm(i) for i in range(len(g["nodes"]))]
|
||||
|
||||
skin = g["skins"][0]; joints = skin["joints"]
|
||||
ibm,_ = acc(g,b,skin["inverseBindMatrices"])
|
||||
def m16(row):
|
||||
return [[row[c*4+r] for c in range(4)] for r in range(4)]
|
||||
JM = [matmul(G[joints[j]], m16(ibm[j])) for j in range(len(joints))]
|
||||
|
||||
REGION = ("hand_", "thumb_", "index_", "middle_", "ring_", "pinky_", "lowerarm_")
|
||||
region_j = {j for j in range(len(joints))
|
||||
if any(names[joints[j]].startswith(p) for p in REGION)
|
||||
and names[joints[j]].endswith("_"+side)}
|
||||
|
||||
prim = g["meshes"][0]["primitives"][0]
|
||||
P,_ = acc(g,b,prim["attributes"]["POSITION"])
|
||||
J,_ = acc(g,b,prim["attributes"]["JOINTS_0"])
|
||||
W,wt = acc(g,b,prim["attributes"]["WEIGHTS_0"])
|
||||
I,_ = acc(g,b,prim["indices"])
|
||||
wsc = 1.0 if wt==5126 else (1/255 if wt==5121 else 1/65535)
|
||||
|
||||
keep = {}
|
||||
for vi,(p,jr,wr) in enumerate(zip(P,J,W)):
|
||||
if not any(j in region_j and w>0 for j,w in zip(jr,wr)): continue
|
||||
x=y=z=0.0
|
||||
for j,w in zip(jr,wr):
|
||||
w*=wsc
|
||||
if w<=0: continue
|
||||
M=JM[j]
|
||||
x+=w*(M[0][0]*p[0]+M[0][1]*p[1]+M[0][2]*p[2]+M[0][3])
|
||||
y+=w*(M[1][0]*p[0]+M[1][1]*p[1]+M[1][2]*p[2]+M[1][3])
|
||||
z+=w*(M[2][0]*p[0]+M[2][1]*p[1]+M[2][2]*p[2]+M[2][3])
|
||||
keep[vi]=(x,y,z)
|
||||
|
||||
remap = {vi:k+1 for k,vi in enumerate(keep)}
|
||||
tris = []
|
||||
flat = [ix[0] for ix in I]
|
||||
for t in range(0, len(flat), 3):
|
||||
a1,a2,a3 = flat[t], flat[t+1], flat[t+2]
|
||||
if a1 in remap and a2 in remap and a3 in remap:
|
||||
tris.append((remap[a1], remap[a2], remap[a3]))
|
||||
|
||||
with open(out, "w") as f:
|
||||
for vi in keep:
|
||||
x,y,z = keep[vi]
|
||||
f.write(f"v {x:.6f} {y:.6f} {z:.6f}\n")
|
||||
for t in tris:
|
||||
f.write(f"f {t[0]} {t[1]} {t[2]}\n")
|
||||
print(f"[skinobj] {out}: {len(keep)} verts, {len(tris)} tris")
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Trim a skin_to_obj dump to the HAND only, so a bbox-framing renderer actually frames
|
||||
the hand. The dumps span the whole arm (76cm in x); the hand is the outer ~13cm.
|
||||
usage: trim_hand.py <in.obj> <out.obj> <l|r>"""
|
||||
import sys
|
||||
|
||||
src, dst, side = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
verts, faces = [], []
|
||||
for line in open(src):
|
||||
if line.startswith("v "):
|
||||
verts.append([float(x) for x in line.split()[1:4]])
|
||||
elif line.startswith("f "):
|
||||
faces.append([int(t.split("/")[0]) - 1 for t in line.split()[1:]])
|
||||
|
||||
xs = [v[0] for v in verts]
|
||||
# hand sits at the far end in |x|; keep the outer 15cm of the limb
|
||||
cut = (max(xs) - 0.15) if side == "l" else (min(xs) + 0.15)
|
||||
keep = [(v[0] >= cut) if side == "l" else (v[0] <= cut) for v in verts]
|
||||
remap, out_v = {}, []
|
||||
for i, v in enumerate(verts):
|
||||
if keep[i]:
|
||||
remap[i] = len(out_v)
|
||||
out_v.append(v)
|
||||
out_f = [f for f in faces if all(i in remap for i in f)]
|
||||
with open(dst, "w") as f:
|
||||
for v in out_v:
|
||||
f.write(f"v {v[0]:.6f} {v[1]:.6f} {v[2]:.6f}\n")
|
||||
for fc in out_f:
|
||||
f.write("f " + " ".join(str(remap[i] + 1) for i in fc) + "\n")
|
||||
print(f"[trim] {dst}: {len(out_v)}/{len(verts)} verts, {len(out_f)} faces (cut x={cut:.3f})")
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Regenerate hand-poses/canonical_rest.json — the canonical Quaternius finger-bone REST
|
||||
rotations, taken from the Kevin pack the hand poses were harvested against.
|
||||
|
||||
The runtime needs this to apply a pose REST-RELATIVE on rigs whose finger rest differs from
|
||||
canonical (Mako's *_01 knuckles sit 11.5 deg off, so applying a canonical pose directly
|
||||
rotates his knuckles away from his own rest and tears the palm/wrist boundary):
|
||||
|
||||
delta = canonical_rest^-1 * pose
|
||||
target = body_rest * delta
|
||||
|
||||
usage: make_canonical_rest.py [kevin.glb] [out.json]
|
||||
"""
|
||||
import json, struct, sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
GAME = REPO.parent / "ariki-game"
|
||||
|
||||
src = Path(sys.argv[1]) if len(sys.argv) > 1 else \
|
||||
GAME / "assets/quaternius/kevin/kevin_female_combat.glb"
|
||||
out = Path(sys.argv[2]) if len(sys.argv) > 2 else REPO / "hand-poses/canonical_rest.json"
|
||||
|
||||
d = src.read_bytes()
|
||||
jl = struct.unpack_from("<I", d, 12)[0]
|
||||
g = json.loads(d[20:20 + jl].decode("utf-8"))
|
||||
|
||||
pose_bones = list(json.loads((REPO / "hand-poses/pose_flat.json").read_text())["bones"])
|
||||
rest = {nd.get("name"): nd.get("rotation", [0, 0, 0, 1]) for nd in g["nodes"]}
|
||||
missing = [b for b in pose_bones if b not in rest]
|
||||
if missing:
|
||||
raise SystemExit(f"canonical source lacks pose bones: {missing}")
|
||||
|
||||
payload = {
|
||||
"_comment": ("Canonical Quaternius finger-bone REST rotations. The runtime applies a "
|
||||
"pose rest-relative: delta = canonical_rest^-1 * pose, "
|
||||
"target = body_rest * delta. Regenerate with tools/make_canonical_rest.py."),
|
||||
"source": src.name,
|
||||
"bones": {b: [round(v, 8) for v in rest[b]] for b in pose_bones},
|
||||
}
|
||||
out.write_text(json.dumps(payload, indent=1))
|
||||
print(f"wrote {out} with {len(payload['bones'])} bones from {src.name}")
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Crop an OBJ to the faces fully inside a sphere, so clay renders can frame the hand
|
||||
instead of the whole arm. Keeps vertex order stable across files (same face set in/out)
|
||||
only when the inputs share topology, so pass --like to reuse a reference file's face mask.
|
||||
|
||||
usage: obj_crop.py in.obj out.obj CX CY CZ R (metres)
|
||||
obj_crop.py in.obj out.obj --mask mask.txt
|
||||
obj_crop.py in.obj --write-mask mask.txt CX CY CZ R
|
||||
"""
|
||||
import sys, math
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def load(path):
|
||||
vs, faces = [], []
|
||||
for line in Path(path).read_text().splitlines():
|
||||
if line.startswith("v "):
|
||||
p = line.split()
|
||||
vs.append((float(p[1]), float(p[2]), float(p[3])))
|
||||
elif line.startswith("f "):
|
||||
faces.append([int(t.split("/")[0]) - 1 for t in line.split()[1:]])
|
||||
return vs, faces
|
||||
|
||||
|
||||
args = sys.argv[1:]
|
||||
src = args[0]
|
||||
vs, faces = load(src)
|
||||
|
||||
if "--write-mask" in args:
|
||||
maskfile = args[args.index("--write-mask") + 1]
|
||||
cx, cy, cz, r = (float(x) for x in args[-4:])
|
||||
keep = [i for i, f in enumerate(faces)
|
||||
if all(math.dist(vs[k], (cx, cy, cz)) <= r for k in f)]
|
||||
Path(maskfile).write_text("\n".join(map(str, keep)))
|
||||
print(f"mask {len(keep)}/{len(faces)} faces -> {maskfile}")
|
||||
sys.exit()
|
||||
|
||||
dst = args[1]
|
||||
if "--mask" in args:
|
||||
keep = [int(x) for x in Path(args[args.index("--mask") + 1]).read_text().split()]
|
||||
else:
|
||||
cx, cy, cz, r = (float(x) for x in args[-4:])
|
||||
keep = [i for i, f in enumerate(faces)
|
||||
if all(math.dist(vs[k], (cx, cy, cz)) <= r for k in f)]
|
||||
|
||||
used = sorted({k for i in keep for k in faces[i]})
|
||||
remap = {old: n + 1 for n, old in enumerate(used)}
|
||||
out = [f"v {vs[o][0]:.6f} {vs[o][1]:.6f} {vs[o][2]:.6f}" for o in used]
|
||||
out += ["f " + " ".join(str(remap[k]) for k in faces[i]) for i in keep]
|
||||
Path(dst).write_text("\n".join(out) + "\n")
|
||||
print(f"{Path(dst).name}: {len(used)} verts, {len(keep)} faces")
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Count connected components in a slab of an OBJ, to tell separated digits from a fused
|
||||
mitt. Slice just past the knuckles: 4 components = separate fingers, 1 = fused paddle.
|
||||
|
||||
usage: obj_slice_components.py mesh.obj AXIS LO HI (axis x|y|z, bounds in metres)
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from collections import defaultdict, deque
|
||||
|
||||
path, axis, lo, hi = sys.argv[1], sys.argv[2], float(sys.argv[3]), float(sys.argv[4])
|
||||
ai = {"x": 0, "y": 1, "z": 2}[axis]
|
||||
|
||||
vs, faces = [], []
|
||||
for line in Path(path).read_text().splitlines():
|
||||
if line.startswith("v "):
|
||||
p = line.split()
|
||||
vs.append((float(p[1]), float(p[2]), float(p[3])))
|
||||
elif line.startswith("f "):
|
||||
faces.append([int(t.split("/")[0]) - 1 for t in line.split()[1:]])
|
||||
|
||||
inslab = [i for i, v in enumerate(vs) if lo <= v[ai] <= hi]
|
||||
sel = set(inslab)
|
||||
adj = defaultdict(set)
|
||||
kept = 0
|
||||
for f in faces:
|
||||
if all(k in sel for k in f):
|
||||
kept += 1
|
||||
for a in f:
|
||||
for b in f:
|
||||
if a != b:
|
||||
adj[a].add(b)
|
||||
|
||||
seen = set()
|
||||
comps = []
|
||||
for v in inslab:
|
||||
if v in seen:
|
||||
continue
|
||||
q = deque([v]); seen.add(v); c = []
|
||||
while q:
|
||||
u = q.popleft(); c.append(u)
|
||||
for w in adj.get(u, ()):
|
||||
if w not in seen:
|
||||
seen.add(w); q.append(w)
|
||||
comps.append(c)
|
||||
|
||||
comps.sort(key=len, reverse=True)
|
||||
print(f"{Path(path).name} slab {axis} in [{lo}, {hi}]")
|
||||
print(f" verts in slab {len(inslab)}, faces kept {kept}, components {len(comps)}")
|
||||
for n, c in enumerate(comps[:10]):
|
||||
if len(c) < 4:
|
||||
continue
|
||||
ext = [(min(vs[k][d] for k in c) * 100, max(vs[k][d] for k in c) * 100) for d in range(3)]
|
||||
span = [f"{e[1]-e[0]:.1f}" for e in ext]
|
||||
ctr = [f"{(e[0]+e[1])/2:.1f}" for e in ext]
|
||||
print(f" comp{n}: {len(c):5d} verts span(cm) x{span[0]} y{span[1]} z{span[2]}"
|
||||
f" centre({ctr[0]}, {ctr[1]}, {ctr[2]})")
|
||||
big = [c for c in comps if len(c) >= 20]
|
||||
print(f" components with >=20 verts: {len(big)}")
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Compare finger-bone REST rotations between GLB skeletons.
|
||||
First GLB is the reference; each other is reported as per-bone angle deviation (degrees).
|
||||
usage: rest_deviation.py canonical.glb other.glb [more.glb ...]
|
||||
"""
|
||||
import json, struct, sys, math
|
||||
from pathlib import Path
|
||||
|
||||
FING = ("thumb", "index", "middle", "ring", "pinky")
|
||||
|
||||
|
||||
def read_gltf(path):
|
||||
d = Path(path).read_bytes()
|
||||
jlen = struct.unpack_from("<I", d, 12)[0]
|
||||
return json.loads(d[20:20 + jlen].decode("utf-8"))
|
||||
|
||||
|
||||
def rests(path):
|
||||
g = read_gltf(path)
|
||||
out = {}
|
||||
for nd in g["nodes"]:
|
||||
n = nd.get("name", "")
|
||||
if any(t in n.lower() for t in FING):
|
||||
out[n] = nd.get("rotation", [0, 0, 0, 1])
|
||||
return out
|
||||
|
||||
|
||||
def angle_between(a, b):
|
||||
"""Geodesic angle (deg) between two unit quaternions, sign-insensitive."""
|
||||
d = abs(sum(x * y for x, y in zip(a, b)))
|
||||
d = max(-1.0, min(1.0, d))
|
||||
return math.degrees(2 * math.acos(d))
|
||||
|
||||
|
||||
ref_path = sys.argv[1]
|
||||
ref = rests(ref_path)
|
||||
print(f"reference: {Path(ref_path).name} ({len(ref)} finger bones)")
|
||||
|
||||
for p in sys.argv[2:]:
|
||||
other = rests(p)
|
||||
print(f"\n== {Path(p).name} == {len(other)} finger bones")
|
||||
missing = sorted(set(ref) - set(other))
|
||||
extra = sorted(set(other) - set(ref))
|
||||
if missing:
|
||||
print(f" MISSING vs ref ({len(missing)}): {', '.join(missing)}")
|
||||
if extra:
|
||||
print(f" EXTRA vs ref ({len(extra)}): {', '.join(extra)}")
|
||||
devs = []
|
||||
for n in sorted(set(ref) & set(other)):
|
||||
devs.append((angle_between(ref[n], other[n]), n))
|
||||
devs.sort(reverse=True)
|
||||
if not devs:
|
||||
continue
|
||||
over = [d for d in devs if d[0] > 1.0]
|
||||
print(f" shared {len(devs)} | deviating >1deg: {len(over)} | max {devs[0][0]:.1f}deg ({devs[0][1]})")
|
||||
for d, n in devs[:12]:
|
||||
print(f" {n:24s} {d:6.1f}deg")
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Per-finger-bone territory audit: how many verts does each finger bone actually OWN
|
||||
(dominant weight) and how much total weight mass does it carry?
|
||||
|
||||
Why: fin_bones classifies exp05's fist tears as hand<->thumb_02 and hand<->index_02 —
|
||||
the chain skips the _01 joints. If the _01 bones own no territory, every curl lands as a
|
||||
hard one-edge step from the palm to phalanx 2, which must stretch. This measures that
|
||||
directly instead of inferring it. usage: bone_territory.py body.glb
|
||||
"""
|
||||
import json
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
DIGITS = ("thumb", "index", "middle", "ring", "pinky")
|
||||
|
||||
|
||||
def read_glb(path):
|
||||
d = Path(path).read_bytes()
|
||||
ln = struct.unpack_from("<I", d, 8)[0]
|
||||
off, g, b = 12, None, None
|
||||
while off < ln:
|
||||
clen, ct = struct.unpack_from("<II", d, off)
|
||||
off += 8
|
||||
if ct == 0x4E4F534A:
|
||||
g = json.loads(d[off:off + clen])
|
||||
else:
|
||||
b = d[off:off + clen]
|
||||
off += clen
|
||||
return g, b
|
||||
|
||||
|
||||
def acc(g, b, 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"]]
|
||||
sz = struct.calcsize(fmt) * nc
|
||||
stride = bv.get("byteStride") or sz
|
||||
off = bv.get("byteOffset", 0) + a.get("byteOffset", 0)
|
||||
out = []
|
||||
for k in range(a["count"]):
|
||||
out.append(struct.unpack_from("<" + fmt * nc, b, off + k * stride))
|
||||
return out
|
||||
|
||||
|
||||
g, b = read_glb(sys.argv[1])
|
||||
prim = g["meshes"][0]["primitives"][0]
|
||||
joints = g["skins"][0]["joints"]
|
||||
names = [g["nodes"][j].get("name", f"node{j}") for j in joints]
|
||||
J = acc(g, b, prim["attributes"]["JOINTS_0"])
|
||||
W = acc(g, b, prim["attributes"]["WEIGHTS_0"])
|
||||
wt = g["accessors"][prim["attributes"]["WEIGHTS_0"]]["componentType"]
|
||||
sc = 1.0 if wt == 5126 else (1 / 255 if wt == 5121 else 1 / 65535)
|
||||
|
||||
own = {n: 0 for n in names} # verts whose LARGEST weight is this bone
|
||||
mass = {n: 0.0 for n in names} # total weight mass
|
||||
any_w = {n: 0 for n in names} # verts with any weight >1%
|
||||
for ji, wi in zip(J, W):
|
||||
ws = [w * sc for w in wi]
|
||||
best, bw = None, 0.0
|
||||
for jj, w in zip(ji, ws):
|
||||
n = names[jj]
|
||||
mass[n] += w
|
||||
if w > 0.01:
|
||||
any_w[n] += 1
|
||||
if w > bw:
|
||||
best, bw = n, w
|
||||
if best is not None and bw > 0:
|
||||
own[best] += 1
|
||||
|
||||
print(f"{Path(sys.argv[1]).name} {len(J)} verts, {len(joints)} joints")
|
||||
print(f"{'bone':16s} {'owns':>7s} {'any>1%':>8s} {'mass':>9s}")
|
||||
for side in ("l", "r"):
|
||||
print(f"--- hand_{side} chain ---")
|
||||
for nm in [f"hand_{side}"] + [f"{d}_{p}_{side}" for d in DIGITS
|
||||
for p in ("01", "02", "03")]:
|
||||
if nm not in own:
|
||||
print(f"{nm:16s} (absent from skin)")
|
||||
continue
|
||||
flag = " <-- STARVED" if own[nm] == 0 else ""
|
||||
print(f"{nm:16s} {own[nm]:7d} {any_w[nm]:8d} {mass[nm]:9.1f}{flag}")
|
||||
@@ -0,0 +1,272 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Pure-python linear-blend skinning check: skin a GLB's verts with its current node TRS
|
||||
(what Godot would render) and report max displacement of finger-weighted verts vs the
|
||||
original file. usage: skin_check.py posed.glb original.glb"""
|
||||
import json, struct, sys, math
|
||||
from pathlib import Path
|
||||
|
||||
FING = ("thumb", "index", "middle", "ring", "pinky")
|
||||
|
||||
def read_glb(path):
|
||||
d = Path(path).read_bytes()
|
||||
length = struct.unpack_from("<I", d, 8)[0]
|
||||
off = 12; g = None; b = None
|
||||
while off < length:
|
||||
clen, ct = struct.unpack_from("<II", d, off); off += 8
|
||||
if ct == 0x4E4F534A: g = json.loads(d[off:off+clen])
|
||||
else: b = d[off:off+clen]
|
||||
off += clen
|
||||
return g, b
|
||||
|
||||
def acc(g, b, 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 [struct.unpack_from("<%d%s"%(nc,fmt), b, off+i*stride) for i in range(a["count"])], a["componentType"]
|
||||
|
||||
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)
|
||||
M = [[R[i][j]*s[j] for j in range(3)]+[t[i]] for i in range(3)]
|
||||
return M+[[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 globals_(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] if i not in parent else matmul(gm(parent[i]), loc[i])
|
||||
memo[i] = m; return m
|
||||
return [gm(i) for i in range(len(g["nodes"]))]
|
||||
|
||||
def skinned_positions(g, b, only_finger=True):
|
||||
names = [nd.get("name","") for nd in g["nodes"]]
|
||||
G = globals_(g)
|
||||
skin = g["skins"][0]
|
||||
joints = skin["joints"]
|
||||
ibm, _ = acc(g, b, skin["inverseBindMatrices"])
|
||||
# glTF matrices are column-major
|
||||
def m16(row):
|
||||
return [[row[c*4+r] for c in range(4)] for r in range(4)]
|
||||
JM = [matmul(G[joints[j]], m16(ibm[j])) for j in range(len(joints))]
|
||||
prim = g["meshes"][0]["primitives"][0]
|
||||
P,_ = acc(g,b,prim["attributes"]["POSITION"])
|
||||
J,_ = acc(g,b,prim["attributes"]["JOINTS_0"])
|
||||
W,wt = acc(g,b,prim["attributes"]["WEIGHTS_0"])
|
||||
wsc = 1.0 if wt==5126 else (1/255 if wt==5121 else 1/65535)
|
||||
out = {}
|
||||
for vi,(p,jr,wr) in enumerate(zip(P,J,W)):
|
||||
if only_finger and not any(any(t in names[joints[j]].lower() for t in FING)
|
||||
for j,w in zip(jr,wr) if w>0):
|
||||
continue
|
||||
x=y=z=0.0
|
||||
for j,w in zip(jr,wr):
|
||||
w*=wsc
|
||||
if w<=0: continue
|
||||
M=JM[j]
|
||||
x+=w*(M[0][0]*p[0]+M[0][1]*p[1]+M[0][2]*p[2]+M[0][3])
|
||||
y+=w*(M[1][0]*p[0]+M[1][1]*p[1]+M[1][2]*p[2]+M[1][3])
|
||||
z+=w*(M[2][0]*p[0]+M[2][1]*p[1]+M[2][2]*p[2]+M[2][3])
|
||||
out[vi]=(x,y,z)
|
||||
return out, names, joints
|
||||
|
||||
posed_g, posed_b = read_glb(sys.argv[1])
|
||||
orig_g, orig_b = read_glb(sys.argv[2])
|
||||
a, names, joints = skinned_positions(posed_g, posed_b)
|
||||
c, _, _ = skinned_positions(orig_g, orig_b)
|
||||
dmax = 0; worst = None
|
||||
for vi in a:
|
||||
d = math.dist(a[vi], c[vi])
|
||||
if d > dmax: dmax, worst = d, vi
|
||||
print(f"finger-weighted verts: {len(a)}; max displacement posed-vs-original: {dmax*100:.1f} cm (vert {worst})")
|
||||
import statistics
|
||||
ds = sorted(math.dist(a[vi], c[vi]) for vi in a)
|
||||
print(f"median: {ds[len(ds)//2]*100:.2f} cm, p95: {ds[int(len(ds)*0.95)]*100:.2f} cm")
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Find corrupt skin bindings: verts bound to a joint that is implausibly far away in
|
||||
REST pose (long bind lever arm), and verts bound across the body midline to the opposite
|
||||
hand's bones. These are invisible at rest and only explode once the joint rotates.
|
||||
|
||||
usage: skin_lever_audit.py body.glb [--lever CM] [--dump N]
|
||||
"""
|
||||
import json, struct, sys, math
|
||||
from pathlib import Path
|
||||
from collections import Counter, defaultdict
|
||||
|
||||
FING = ("thumb", "index", "middle", "ring", "pinky")
|
||||
LEVER_CM = 20.0
|
||||
DUMP = 0
|
||||
|
||||
args = [a for a in sys.argv[1:]]
|
||||
path = args[0]
|
||||
if "--lever" in args:
|
||||
LEVER_CM = float(args[args.index("--lever") + 1])
|
||||
if "--dump" in args:
|
||||
DUMP = int(args[args.index("--dump") + 1])
|
||||
|
||||
|
||||
def read_glb(p):
|
||||
d = Path(p).read_bytes()
|
||||
length = struct.unpack_from("<I", d, 8)[0]
|
||||
off = 12
|
||||
g = b_ = None
|
||||
while off < length:
|
||||
clen, ct = struct.unpack_from("<II", d, off)
|
||||
off += 8
|
||||
if ct == 0x4E4F534A:
|
||||
g = json.loads(d[off:off + clen])
|
||||
else:
|
||||
b_ = d[off:off + clen]
|
||||
off += clen
|
||||
return g, b_
|
||||
|
||||
|
||||
def acc(g, b, 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 [struct.unpack_from("<%d%s" % (nc, fmt), b, off + k * stride)
|
||||
for k in range(a["count"])], a["componentType"]
|
||||
|
||||
|
||||
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"]))]
|
||||
|
||||
|
||||
g, b = read_glb(path)
|
||||
names = [nd.get("name", "") for nd in g["nodes"]]
|
||||
GM = global_mats(g)
|
||||
|
||||
print(f"== {Path(path).name} ==")
|
||||
print(f" lever threshold {LEVER_CM:.0f} cm\n")
|
||||
|
||||
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]
|
||||
# joint rest world positions
|
||||
jpos = [(GM[j][0][3], GM[j][1][3], GM[j][2][3]) for j in joints]
|
||||
|
||||
P, _ = acc(g, b, att["POSITION"])
|
||||
J, _ = acc(g, b, att["JOINTS_0"])
|
||||
W, wt = acc(g, b, att["WEIGHTS_0"])
|
||||
wsc = 1.0 if wt == 5126 else (1 / 255 if wt == 5121 else 1 / 65535)
|
||||
|
||||
long_lever = []
|
||||
cross = []
|
||||
by_joint = Counter()
|
||||
cross_by_joint = Counter()
|
||||
|
||||
for vi, (p, jrow, wrow) in enumerate(zip(P, J, W)):
|
||||
for j, w in zip(jrow, wrow):
|
||||
w *= wsc
|
||||
if w <= 0.001:
|
||||
continue
|
||||
n = jname[j]
|
||||
nl = n.lower()
|
||||
if not any(t in nl for t in FING):
|
||||
continue
|
||||
jp = jpos[j]
|
||||
d = math.dist(p, jp) * 100.0 # cm (glTF metres)
|
||||
if d > LEVER_CM:
|
||||
long_lever.append((d, vi, n, w, p))
|
||||
by_joint[n] += 1
|
||||
# cross-hand: vert on opposite side of midline from the joint
|
||||
if nl.endswith("_r") and p[0] > 0.02:
|
||||
cross.append((d, vi, n, w, p)); cross_by_joint[n] += 1
|
||||
elif nl.endswith("_l") and p[0] < -0.02:
|
||||
cross.append((d, vi, n, w, p)); cross_by_joint[n] += 1
|
||||
|
||||
print(f" mesh[{mi}] '{mesh.get('name','')}' prim{pi}: {len(P)} verts")
|
||||
print(f" finger refs with lever > {LEVER_CM:.0f} cm : {len(long_lever)}")
|
||||
if long_lever:
|
||||
mx = max(long_lever)
|
||||
print(f" worst {mx[0]:.1f} cm vert {mx[1]} joint {mx[2]} w={mx[3]:.3f}")
|
||||
for n, c in by_joint.most_common(10):
|
||||
print(f" {n:22s} {c}")
|
||||
print(f" cross-midline finger refs : {len(cross)}")
|
||||
if cross:
|
||||
mx = max(cross)
|
||||
print(f" worst {mx[0]:.1f} cm vert {mx[1]} joint {mx[2]} w={mx[3]:.3f}")
|
||||
for n, c in cross_by_joint.most_common(10):
|
||||
print(f" {n:22s} {c}")
|
||||
for d, vi, n, w, p in sorted(long_lever, reverse=True)[:DUMP]:
|
||||
print(f" v{vi} {n} w={w:.3f} lever={d:.1f}cm pos=({p[0]*100:.1f},{p[1]*100:.1f},{p[2]*100:.1f})cm")
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Sum skin weights per bone group (finger vs hand vs rest) for each GLB. usage: weight_audit.py *.glb"""
|
||||
import json, struct, sys
|
||||
from pathlib import Path
|
||||
|
||||
FING = ("thumb", "index", "middle", "ring", "pinky")
|
||||
|
||||
def read_glb(path):
|
||||
data = Path(path).read_bytes()
|
||||
length = struct.unpack_from("<I", data, 8)[0]
|
||||
off = 12; gltf = None; binc = None
|
||||
while off < length:
|
||||
clen, ctype = struct.unpack_from("<II", data, off); off += 8
|
||||
if ctype == 0x4E4F534A: gltf = json.loads(data[off:off+clen])
|
||||
elif ctype == 0x004E4942: binc = data[off:off+clen]
|
||||
off += clen
|
||||
return gltf, binc
|
||||
|
||||
def acc(gltf, binc, idx):
|
||||
a = gltf["accessors"][idx]
|
||||
bv = gltf["bufferViews"][a["bufferView"]]
|
||||
ncomp = {"SCALAR":1, "VEC2":2, "VEC3":3, "VEC4":4}[a["type"]]
|
||||
fmt = {5121:"B", 5123:"H", 5125:"I", 5126:"f"}[a["componentType"]]
|
||||
stride = bv.get("byteStride")
|
||||
size = struct.calcsize(fmt)*ncomp
|
||||
off = bv.get("byteOffset",0) + a.get("byteOffset",0)
|
||||
out = []
|
||||
for i in range(a["count"]):
|
||||
o = off + i*(stride or size)
|
||||
out.append(struct.unpack_from("<%d%s" % (ncomp, fmt), binc, o))
|
||||
return out, a["componentType"]
|
||||
|
||||
for path in sys.argv[1:]:
|
||||
gltf, binc = read_glb(path)
|
||||
names = [nd.get("name", "") for nd in gltf["nodes"]]
|
||||
print(f"\n== {Path(path).name} ==")
|
||||
for mi, mesh in enumerate(gltf.get("meshes", [])):
|
||||
for pi, prim in enumerate(mesh.get("primitives", [])):
|
||||
att = prim["attributes"]
|
||||
if "JOINTS_0" not in att: continue
|
||||
# which skin uses this mesh
|
||||
skin_idx = next((nd.get("skin") for nd in gltf["nodes"]
|
||||
if nd.get("mesh") == mi and "skin" in nd), None)
|
||||
if skin_idx is None: continue
|
||||
joints = gltf["skins"][skin_idx]["joints"]
|
||||
jn = [names[j] for j in joints]
|
||||
J, _ = acc(gltf, binc, att["JOINTS_0"])
|
||||
W, wt = acc(gltf, binc, att["WEIGHTS_0"])
|
||||
wsc = 1.0 if wt == 5126 else (1/255 if wt == 5121 else 1/65535)
|
||||
fing_w = hand_w = 0.0
|
||||
fing_verts = 0
|
||||
for jrow, wrow in zip(J, W):
|
||||
fv = 0
|
||||
for j, w in zip(jrow, wrow):
|
||||
w *= wsc
|
||||
if w <= 0: continue
|
||||
n = jn[j].lower()
|
||||
if any(t in n for t in FING): fing_w += w; fv = 1
|
||||
elif n.startswith("hand"): hand_w += w
|
||||
fing_verts += fv
|
||||
print(f" mesh[{mi}] '{mesh.get('name','')}' prim{pi}: {len(J)} verts | "
|
||||
f"finger-weighted verts: {fing_verts} | total finger W: {fing_w:.0f} | hand W: {hand_w:.0f}")
|
||||
Reference in New Issue
Block a user