Merge branch 'main' of https://tinqs.com/tinqs/animation
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,61 @@
|
||||
"""Cluster the worst-stretched edges by REST position to name the digit region."""
|
||||
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(sum((p[i]-q[i])**2 for i in range(3)))
|
||||
|
||||
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)
|
||||
xs = sorted(v[0] for v in rest_v)
|
||||
print(f'hand_{hand}: rest x range {xs[0]*100:.1f}..{xs[-1]*100:.1f} cm, '
|
||||
f'y range {min(v[1] for v in rest_v)*100:.1f}..{max(v[1] for v in rest_v)*100:.1f}, '
|
||||
f'z range {min(v[2] for v in rest_v)*100:.1f}..{max(v[2] for v in rest_v)*100:.1f}')
|
||||
for pose in ('fist', 'grip'):
|
||||
v, _ = load_obj(os.path.join(d, f'{pose}_hand_{hand}.obj'))
|
||||
bad = []
|
||||
for a, b in edges:
|
||||
rl = dist(rest_v[a], rest_v[b])
|
||||
if rl <= 1e-9:
|
||||
continue
|
||||
r = dist(v[a], v[b]) / rl
|
||||
if r > 5:
|
||||
bad.append((r, a))
|
||||
# bounding box of bad verts in rest space
|
||||
pts = [rest_v[a] for _, a in bad]
|
||||
if not pts:
|
||||
print(f' {pose}: no >5x edges')
|
||||
continue
|
||||
bx = (min(p[0] for p in pts)*100, max(p[0] for p in pts)*100)
|
||||
by = (min(p[1] for p in pts)*100, max(p[1] for p in pts)*100)
|
||||
bz = (min(p[2] for p in pts)*100, max(p[2] for p in pts)*100)
|
||||
cx = sum(p[0] for p in pts)/len(pts)*100
|
||||
cy = sum(p[1] for p in pts)/len(pts)*100
|
||||
cz = sum(p[2] for p in pts)/len(pts)*100
|
||||
print(f' {pose}: {len(bad)} edges>5x rest-bbox x[{bx[0]:.1f},{bx[1]:.1f}] '
|
||||
f'y[{by[0]:.1f},{by[1]:.1f}] z[{bz[0]:.1f},{bz[1]:.1f}] centroid ({cx:.1f},{cy:.1f},{cz:.1f}) cm')
|
||||
# z-histogram (palm axis?) to see if it's one digit or spread
|
||||
zs = sorted(p[2]*100 for p in pts)
|
||||
q = lambda f: zs[int(f*(len(zs)-1))]
|
||||
print(f' rest z quartiles: {q(0):.1f} / {q(0.25):.1f} / {q(0.5):.1f} / {q(0.75):.1f} / {q(1):.1f}')
|
||||
@@ -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})")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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")
|
||||
@@ -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