Files
animation/tools/handpose_extract.py
T

100 lines
3.9 KiB
Python
Raw Normal View History

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