# 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 --out --name \ # [--range lo:hi] [--smooth 5] [--target ] [--render-check ] 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()