Files
animation/tools/mocap_retarget.py
T
jeremy 31ba2911df init: animation pipeline hub — Mac↔PC bridge for converting and implementing animations
Skills (.claude/skills/): animation-creation, iclone-video-mocap,
pose-estimation (MediaPipe models via LFS), retarget-animations (deprecated).
Tools: cc/mixamo/kevin/mocap retargeters + composite baker.
Plans: animation-gen-pipeline + skills-adoption.
exchange/: incoming-fbx, converted-glb, reference-video (LFS for fbx/glb/mp4).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 10:29:48 -07:00

275 lines
13 KiB
Python

# MediaPipe pose JSON → Quaternius-skeleton GLB animation (Blender headless).
#
# Sibling of tools/mixamo_retarget.py — same target rig, same NLA/export tail, but the
# source is not an armature: it's per-frame 3D world landmarks from the pose-estimation
# skill (extract_pose.py). Rotations are SOLVED, not transferred:
# - limb bones (arms/legs/hands/feet): shortest-arc delta from the bone's REST world
# direction to the landmark-derived direction, applied on top of the rest rotation —
# preserves the rig's rest roll, so skinning stays sane (no twist control in v1).
# - pelvis + spine chain: full-basis deltas built from the hip line and the hips→
# shoulders up vector; spine_01..03 slerp from pelvis delta toward chest delta.
# - pelvis HEIGHT: MediaPipe world landmarks are hip-centered (origin = mid-hips every
# frame), so global translation is lost. We reconstruct the vertical: per-frame
# pelvis-above-ankle extent vs the standing extent (95th pct) scales the rest height
# — carries squats/crouches, which is most of a haka.
# Landmarks are box-smoothed over time before solving (mocap jitter).
#
# MediaPipe world axes: x=subject-image right, y=down, z=depth (smaller=nearer camera).
# Converted here to Blender Z-up with the subject facing -Y: b = (x, z, -y).
#
# Usage:
# blender --background --python tools/mocap_retarget.py -- \
# --pose <extract_pose.json> --out <out.glb> --name <ClipName> \
# [--range lo:hi] [--smooth 5] [--target <gltf>] [--render-check <dir>]
import bpy, sys, os, json
import numpy as np
from mathutils import Matrix, Quaternion, Vector
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DEFAULT_TARGET_GLTF = os.path.join(
REPO_ROOT, "assets", "quaternius", "base-characters",
"Universal Base Characters[Standard]", "Base Characters", "Godot - UE",
"Superhero_Male_FullBody.gltf")
# MediaPipe pose landmark indices
NOSE, L_SHO, R_SHO, L_ELB, R_ELB, L_WRI, R_WRI = 0, 11, 12, 13, 14, 15, 16
L_PINK, R_PINK, L_IDX, R_IDX = 17, 18, 19, 20
L_HIP, R_HIP, L_KNE, R_KNE, L_ANK, R_ANK, L_FT, R_FT = 23, 24, 25, 26, 27, 28, 31, 32
# target bone -> (landmark_from, landmark_to): desired bone direction (bone +Y points from→to)
AIM_BONES = {
"upperarm_l": (L_SHO, L_ELB), "lowerarm_l": (L_ELB, L_WRI),
"upperarm_r": (R_SHO, R_ELB), "lowerarm_r": (R_ELB, R_WRI),
"thigh_l": (L_HIP, L_KNE), "calf_l": (L_KNE, L_ANK), "foot_l": (L_ANK, L_FT),
"thigh_r": (R_HIP, R_KNE), "calf_r": (R_KNE, R_ANK), "foot_r": (R_ANK, R_FT),
}
HAND_BONES = {"hand_l": (L_WRI, L_IDX, L_PINK), "hand_r": (R_WRI, R_IDX, R_PINK)}
SPINE_WEIGHTS = {"spine_01": 0.35, "spine_02": 0.7, "spine_03": 1.0,
"neck_01": 1.0, "Head": 1.0}
def apply_object_transform(obj):
bpy.ops.object.select_all(action="DESELECT")
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
bpy.context.view_layer.update()
def hierarchy_order(arm):
out, stack = [], [b for b in arm.data.bones if b.parent is None]
while stack:
b = stack.pop(0)
out.append(b)
stack.extend(b.children)
return out
def torso_quat(pts):
"""Orthonormal torso basis from landmarks (Blender axes): columns = (subject-left,
up, forward). Subject-left = l_hip - r_hip; up = mid-shoulders - mid-hips."""
left = Vector(pts[L_HIP] - pts[R_HIP]).normalized()
up = Vector((pts[L_SHO] + pts[R_SHO]) / 2 - (pts[L_HIP] + pts[R_HIP]) / 2).normalized()
fwd = left.cross(up).normalized()
left = up.cross(fwd).normalized()
return Matrix((left, up, fwd)).transposed().to_quaternion().normalized()
def chest_quat(pts):
left = Vector(pts[L_SHO] - pts[R_SHO]).normalized()
up = Vector((pts[L_SHO] + pts[R_SHO]) / 2 - (pts[L_HIP] + pts[R_HIP]) / 2).normalized()
fwd = left.cross(up).normalized()
left = up.cross(fwd).normalized()
return Matrix((left, up, fwd)).transposed().to_quaternion().normalized()
def main():
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
args = dict(zip(argv[::2], argv[1::2]))
pose_path, out, clip = args["--pose"], args["--out"], args.get("--name", "Mocap01")
target = args.get("--target", DEFAULT_TARGET_GLTF)
smooth = int(args.get("--smooth", 5))
render_dir = args.get("--render-check")
lo, hi = 0.0, 1e9
if "--range" in args:
a, _, b = args["--range"].partition(":")
lo, hi = float(a or 0), float(b) if b else 1e9
doc = json.load(open(pose_path))
frames = [f for f in doc["frames"]
if f.get("detected") and "world_landmarks" in f and lo <= f["t"] <= hi]
if len(frames) < 4:
raise RuntimeError(f"only {len(frames)} usable frames in range {lo}:{hi}")
ts = np.array([f["t"] for f in frames]); ts -= ts[0]
# MediaPipe (x right, y down, z depth) → Blender (x right, y=z_mp, z=-y_mp)
raw = np.array([[[p["x"], p["z"], -p["y"]] for p in f["world_landmarks"]] for f in frames])
if smooth > 1: # box smooth over time, per landmark/axis
k = np.ones(smooth) / smooth
pad = smooth // 2
padded = np.concatenate([raw[:1].repeat(pad, 0), raw, raw[-1:].repeat(pad, 0)])
raw = np.apply_along_axis(lambda v: np.convolve(v, k, mode="valid"), 0, padded)[:len(ts)]
print(f"[mocap] {len(frames)} frames, {ts[-1]:.1f}s, smooth={smooth}")
# ── target rig ──
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete()
scn = bpy.context.scene
scn.render.fps = 30
bpy.ops.import_scene.gltf(filepath=target)
tgt_arm = next(o for o in bpy.data.objects if o.type == "ARMATURE")
tgt_arm.name = "Armature"
bpy.context.view_layer.update()
apply_object_transform(tgt_arm)
tgt_rest = {b.name: (tgt_arm.matrix_world @ b.matrix_local) for b in tgt_arm.data.bones}
tgt_rest_q = {n: m.to_quaternion().normalized() for n, m in tgt_rest.items()}
# Char-frame alignment, mixamo_retarget style: A maps landmark-space vectors and
# rotation deltas into target world. Landmark space after conversion: subject-left=+X,
# up=+Z, facing=-Y (NOT identity — it carries a 90° X rotation vs standard basis).
up_t = (tgt_rest["Head"].translation - tgt_rest["pelvis"].translation).normalized()
left_t = (tgt_rest_q["upperarm_l"] @ Vector((0, 1, 0))).normalized() # bone Y = along bone
fwd_t = left_t.cross(up_t).normalized()
left_t = up_t.cross(fwd_t).normalized()
Ct = Matrix((left_t, up_t, fwd_t)).transposed().to_quaternion().normalized()
Cs = Matrix((Vector((1, 0, 0)), Vector((0, 0, 1)), Vector((0, -1, 0)))).transposed() \
.to_quaternion().normalized()
A = (Ct @ Cs.inverted()).normalized()
Ainv = A.inverted()
Cs_inv = Cs.inverted()
# Size + pelvis-height model
h_tgt = (tgt_rest["Head"].translation - tgt_rest["pelvis"].translation).length
pelvis_up = raw[:, [L_ANK, R_ANK], 2].min(axis=1) * -1.0 # pelvis height above lowest ankle
standing_h = float(np.percentile(pelvis_up, 95))
rest_pelvis = tgt_rest["pelvis"].translation.copy()
act = bpy.data.actions.new(clip)
if tgt_arm.animation_data is None:
tgt_arm.animation_data_create()
tgt_arm.animation_data.action = act
order = hierarchy_order(tgt_arm)
tw_inv = tgt_arm.matrix_world.inverted()
for pb in tgt_arm.pose.bones:
pb.rotation_mode = "QUATERNION"
check = []
for i in range(len(ts)):
pts = raw[i]
frame = int(round(ts[i] * 30)) + 1
# delta from landmark rest basis (Cs), conjugated into target world axes
q_pelvis_d = (A @ (torso_quat(pts) @ Cs_inv) @ Ainv).normalized()
q_chest_d = (A @ (chest_quat(pts) @ Cs_inv) @ Ainv).normalized()
tgt_world = {}
for tb in order:
nt = tb.name
rest_t = tgt_rest[nt]
q_world = None
if nt == "pelvis":
q_world = (q_pelvis_d @ tgt_rest_q[nt]).normalized()
elif nt in SPINE_WEIGHTS:
qd = Quaternion(q_pelvis_d).slerp(q_chest_d, SPINE_WEIGHTS[nt])
q_world = (qd @ tgt_rest_q[nt]).normalized()
elif nt in AIM_BONES:
a, b = AIM_BONES[nt]
d_lm = Vector(pts[b] - pts[a])
if d_lm.length > 1e-6:
d_tgt = A @ d_lm
rest_dir = tgt_rest_q[nt] @ Vector((0, 1, 0))
q_delta = rest_dir.rotation_difference(d_tgt.normalized())
q_world = (q_delta @ tgt_rest_q[nt]).normalized()
elif nt in HAND_BONES:
w, ix, pk = HAND_BONES[nt]
d_lm = Vector((pts[ix] + pts[pk]) / 2 - pts[w])
if d_lm.length > 1e-6:
d_tgt = A @ d_lm
rest_dir = tgt_rest_q[nt] @ Vector((0, 1, 0))
q_delta = rest_dir.rotation_difference(d_tgt.normalized())
q_world = (q_delta @ tgt_rest_q[nt]).normalized()
if q_world is None: # unmapped: ride parent rigidly at rest offset
if tb.parent is None:
tgt_world[nt] = rest_t
else:
rel = tgt_rest[tb.parent.name].inverted() @ rest_t
tgt_world[nt] = tgt_world[tb.parent.name] @ rel
continue
if nt == "pelvis":
ratio = float(np.clip(pelvis_up[i] / max(standing_h, 1e-5), 0.35, 1.15))
t_world = Vector((rest_pelvis.x, rest_pelvis.y, rest_pelvis.z * ratio))
else:
rel = tgt_rest[tb.parent.name].inverted() @ rest_t
t_world = (tgt_world[tb.parent.name] @ rel).translation
tgt_world[nt] = Matrix.LocRotScale(t_world, q_world, Vector((1, 1, 1)))
for tb in order:
pb = tgt_arm.pose.bones[tb.name]
m_arm = tw_inv @ tgt_world[tb.name]
if tb.parent:
m_parent_arm = tw_inv @ tgt_world[tb.parent.name]
rel_rest = tb.parent.matrix_local.inverted() @ tb.matrix_local
basis = rel_rest.inverted() @ (m_parent_arm.inverted() @ m_arm)
else:
basis = tb.matrix_local.inverted() @ m_arm
pb.matrix_basis = basis
pb.keyframe_insert("rotation_quaternion", frame=frame)
if tb.name == "pelvis" or tb.parent is None:
pb.keyframe_insert("location", frame=frame)
# numeric sanity: solved elbow angle vs landmark elbow angle (should match closely)
if i % max(1, len(ts) // 6) == 0:
def ang(u, v):
u, v = u.normalized(), v.normalized()
return np.degrees(np.arccos(np.clip(u.dot(v), -1, 1)))
lm = ang(Vector(pts[L_SHO] - pts[L_ELB]), Vector(pts[L_WRI] - pts[L_ELB]))
ua = (tgt_world["upperarm_l"].to_quaternion() @ Vector((0, 1, 0)))
la = (tgt_world["lowerarm_l"].to_quaternion() @ Vector((0, 1, 0)))
rig = ang(-ua, la)
check.append((ts[i], lm, rig))
print("[mocap] elbow-angle sanity (t, landmark°, rig°):")
for t, a, b in check:
print(f" t={t:5.1f} lm={a:6.1f} rig={b:6.1f} d={abs(a-b):5.1f}")
# optional visual check renders (with mesh, before it's dropped)
if render_dir:
os.makedirs(render_dir, exist_ok=True)
cam_data = bpy.data.cameras.new("ChkCam")
cam = bpy.data.objects.new("ChkCam", cam_data)
scn.collection.objects.link(cam)
cam.location = (0, -3.2, 1.0)
cam.rotation_euler = (np.radians(87), 0, 0)
scn.camera = cam
sun = bpy.data.objects.new("Sun", bpy.data.lights.new("Sun", "SUN"))
scn.collection.objects.link(sun)
sun.rotation_euler = (np.radians(45), 0, np.radians(30))
scn.render.resolution_x, scn.render.resolution_y = 480, 640
for i in range(0, len(ts), max(1, len(ts) // 6)):
scn.frame_set(int(round(ts[i] * 30)) + 1)
scn.render.filepath = os.path.join(render_dir, f"chk_{ts[i]:05.1f}s.png")
bpy.ops.render.render(write_still=True)
print(f"[mocap] check renders → {render_dir}")
# animations-only GLB (game binds clips to its own body)
for o in [o for o in bpy.data.objects if o.type == "MESH"]:
bpy.data.objects.remove(o, do_unlink=True)
tgt_arm.animation_data.action = None
tr = tgt_arm.animation_data.nla_tracks.new()
tr.name = act.name
tr.strips.new(act.name, 1, act)
bpy.ops.object.select_all(action="DESELECT")
tgt_arm.select_set(True)
bpy.context.view_layer.objects.active = tgt_arm
bpy.ops.export_scene.gltf(
filepath=out, use_selection=True,
export_animations=True, export_animation_mode="NLA_TRACKS",
export_force_sampling=True, export_optimize_animation_size=False)
print(f"[mocap] EXPORTED '{clip}' ({len(ts)} keyed frames, {ts[-1]:.1f}s) → {out}")
main()