MIRPAMO/bpy/retarget.py
jing a148b4a543 MIRPAMO v0: local Mixamo — autorig + retarget via headless Blender
- autorig: landmark detection from vertex cloud (markers JSON override),
  mixamorig 22-bone skeleton fit, bone-heat weights + nearest-bone rescue
- retarget: world-space quaternion delta transfer, hip-height scale
  compensation, fuzzy/explicit bone mapping (CMU map bundled)
- smoke: procedural test humanoid + synthetic BVH -> rig -> retarget -> verify
- deploy: push to gitea, pull + smoke on m3ultra and m1ultra

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 01:26:13 +10:00

231 lines
8.2 KiB
Python

"""MIRPAMO stage 2: retarget motion clips onto a rigged character.
Replicates Mixamo's animation retargeter locally:
- Rotational data transfer: for every mapped bone, the source bone's
world-space rotation *delta from rest* is applied to the target bone's
rest orientation. Pure quaternion transfer — no positional copying —
so limb proportions never stretch.
- Scale compensation: root (Hips) translation is scaled by the ratio of
target hip height to source hip height, so a short character takes
short steps from the same clip.
Bone mapping: exact name match first, then normalized fuzzy match
("mixamorig:LeftArm" == "LeftArm" == "left_arm"), plus optional explicit
map JSON {"source_bone": "target_bone", "skip_me": null}.
Usage:
blender -b --python bpy/retarget.py -- --rig rigged.glb \
--clip walk.bvh [--clip run.fbx ...] --out out.glb \
[--bonemap bonemaps/cmu_mb_to_mixamo.json] [--inplace] [--fps 30]
"""
import argparse
import json
import os
import re
import sys
import bpy
from mathutils import Matrix, Vector
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from common import (clean_scene, die, export_glb, find_armature, find_meshes,
import_any, stage_args)
def norm_name(n):
n = n.lower()
n = re.sub(r"mixamo[_:]?rig", "", n)
n = re.sub(r"[^a-z0-9]", "", n)
return n
def build_bone_map(src_arm, tgt_arm, explicit):
"""Map source bone name -> target bone name."""
tgt_names = {b.name for b in tgt_arm.data.bones}
tgt_by_norm = {norm_name(b.name): b.name for b in tgt_arm.data.bones}
mapping = {}
for sb in src_arm.data.bones:
if explicit and sb.name in explicit:
t = explicit[sb.name]
if t and t in tgt_names:
mapping[sb.name] = t
continue # explicit null = deliberately skipped
if sb.name in tgt_names:
mapping[sb.name] = sb.name
elif norm_name(sb.name) in tgt_by_norm:
mapping[sb.name] = tgt_by_norm[norm_name(sb.name)]
return mapping
def hip_bone(arm, mapping_values=None):
candidates = mapping_values or [b.name for b in arm.data.bones]
for b in candidates:
if norm_name(b) in ("hips", "hip", "pelvis", "root"):
return b
# fall back to a root bone (no parent) that has children
for b in arm.data.bones:
if b.parent is None and b.children:
return b.name
return None
def world_rest(arm_obj, bone_name):
return arm_obj.matrix_world @ arm_obj.data.bones[bone_name].matrix_local
def retarget_clip(src_arm, tgt_arm, mapping, clip_name, inplace, frame_range):
scene = bpy.context.scene
f0, f1 = frame_range
print(f"[mirpamo] retargeting '{clip_name}' frames {f0}..{f1} "
f"({len(mapping)} bones mapped)")
tgt_arm.animation_data_create()
action = bpy.data.actions.new(clip_name)
action.use_fake_user = True
tgt_arm.animation_data.action = action
# rest matrices (world space)
src_rest = {s: world_rest(src_arm, s) for s in mapping}
tgt_rest = {t: world_rest(tgt_arm, t) for t in mapping.values()}
# scale compensation: hip heights at rest
s_hip = hip_bone(src_arm, list(mapping.keys()))
t_hip = mapping.get(s_hip) if s_hip else None
scale = 1.0
if s_hip and t_hip:
sh = (src_arm.matrix_world @
src_arm.data.bones[s_hip].head_local).z
th = (tgt_arm.matrix_world @
tgt_arm.data.bones[t_hip].head_local).z
if sh > 1e-6:
scale = th / sh
print(f"[mirpamo] hip scale compensation: {scale:.4f}")
# target bones in parent-before-child order
ordered = []
def walk(bone):
if bone.name in tgt_rest:
ordered.append(bone.name)
for c in bone.children:
walk(c)
for b in tgt_arm.data.bones:
if b.parent is None:
walk(b)
tgt_to_src = {t: s for s, t in mapping.items()}
mw_tgt_inv = tgt_arm.matrix_world.inverted()
depsgraph = bpy.context.evaluated_depsgraph_get()
for f in range(f0, f1 + 1):
scene.frame_set(f)
src_ev = src_arm.evaluated_get(depsgraph)
src_world = {s: src_ev.matrix_world @ src_ev.pose.bones[s].matrix
for s in mapping}
# desired world matrix per target bone, computed top-down
desired = {}
for t in ordered:
s = tgt_to_src[t]
delta = (src_world[s] @ src_rest[s].inverted())
m = delta @ tgt_rest[t]
desired[t] = m
for t in ordered:
pb = tgt_arm.pose.bones[t]
bone = tgt_arm.data.bones[t]
m_des = mw_tgt_inv @ desired[t] # into armature space
if bone.parent:
m_parent_des = mw_tgt_inv @ desired.get(
bone.parent.name,
tgt_arm.matrix_world @ tgt_arm.pose.bones[bone.parent.name].matrix)
basis = (bone.matrix_local.inverted() @
bone.parent.matrix_local @
m_parent_des.inverted() @ m_des)
else:
basis = bone.matrix_local.inverted() @ m_des
loc, rot, _ = basis.decompose()
pb.rotation_mode = "QUATERNION"
pb.rotation_quaternion = rot
pb.keyframe_insert("rotation_quaternion", frame=f)
if t == t_hip:
# scale-compensated root translation
src_hip_world = src_world[tgt_to_src[t]].to_translation()
rest_hip_world = tgt_rest[t].to_translation()
target_world = src_hip_world * scale
if inplace:
target_world.x = rest_hip_world.x
target_world.y = rest_hip_world.y
m = desired[t].copy()
m.translation = target_world
m_des2 = mw_tgt_inv @ m
basis2 = bone.matrix_local.inverted() @ m_des2 \
if not bone.parent else basis
pb.location = basis2.to_translation()
pb.keyframe_insert("location", frame=f)
return action
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--rig", required=True, help="rigged character (glb/fbx)")
ap.add_argument("--clip", action="append", required=True,
help="motion clip (bvh/fbx/glb); repeatable")
ap.add_argument("--out", required=True)
ap.add_argument("--bonemap", default=None)
ap.add_argument("--inplace", action="store_true",
help="strip horizontal root motion")
ap.add_argument("--fps", type=int, default=None)
args = ap.parse_args(stage_args())
explicit = None
if args.bonemap:
with open(args.bonemap) as f:
explicit = json.load(f)
clean_scene()
rig_objs = import_any(args.rig)
tgt_arm = find_armature(rig_objs)
if not tgt_arm:
die("rig file contains no armature — run autorig first")
tgt_meshes = find_meshes(rig_objs)
if args.fps:
bpy.context.scene.render.fps = args.fps
for clip_path in args.clip:
clip_objs = import_any(clip_path)
src_arm = find_armature(clip_objs)
if not src_arm:
die(f"{clip_path}: no armature/motion found")
mapping = build_bone_map(src_arm, tgt_arm, explicit)
if len(mapping) < 3:
die(f"{clip_path}: only {len(mapping)} bones mapped — "
"provide a --bonemap")
ad = src_arm.animation_data
if not ad or not ad.action:
die(f"{clip_path}: armature has no animation")
src_action = ad.action
fr = src_action.frame_range
frame_range = (int(fr[0]), int(fr[1]))
clip_name = os.path.splitext(os.path.basename(clip_path))[0]
action = retarget_clip(src_arm, tgt_arm, mapping, clip_name,
args.inplace, frame_range)
# drop the source skeleton + its action so only retargeted
# animations reach the export
for o in clip_objs:
bpy.data.objects.remove(o, do_unlink=True)
bpy.data.actions.remove(src_action)
action.name = clip_name # reclaim the name if it collided
export_glb(args.out, objects=[tgt_arm] + tgt_meshes)
print("[mirpamo] retarget OK")
main()