Files
animation/tools/mixamo_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

258 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Mixamo FBX → Quaternius-skeleton GLB batch retargeter (Blender headless).
#
# Sibling of tools/kevin_retarget.py — same world-rotation-delta method, adapted for the
# Mixamo rig ("mixamorig:*" bones, prefix auto-detected). Both rigs rest in a T-pose, so
# for every mapped bone the world rotation delta from rest transfers 1:1:
# q_target_world(f) = (q_src_world(f) · q_src_rest⁻¹) · q_target_rest
# Pelvis translation transfers as a WORLD-space delta from rest (rotated through the
# frame-alignment quaternion C, scaled by the hips→head size ratio) — unlike Kevin's
# raw-fcurve read, because Mixamo roots the armature at the hips with no demo-scene
# offset bone, and world deltas survive whatever local axes the importer picked.
# Unmapped target bones (leaf/ball bones etc.) ride their parent rigidly at rest offset.
#
# Usage:
# blender --background --python tools/mixamo_retarget.py -- \
# --src "<dir-or-fbx>[,<dir-or-fbx>…]" --out <out.glb> [--limit N] [--target <gltf>]
import bpy, sys, os, re
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")
# Mixamo (prefix-stripped) → Quaternius UE names. Fingers map 1:1 (both rigs 3-segment).
BONE_MAP = {
"Hips": "pelvis",
"Spine": "spine_01",
"Spine1": "spine_02",
"Spine2": "spine_03",
"Neck": "neck_01",
"Head": "Head",
}
for S, T in (("Left", "l"), ("Right", "r")):
BONE_MAP[f"{S}Shoulder"] = f"clavicle_{T}"
BONE_MAP[f"{S}Arm"] = f"upperarm_{T}"
BONE_MAP[f"{S}ForeArm"] = f"lowerarm_{T}"
BONE_MAP[f"{S}Hand"] = f"hand_{T}"
BONE_MAP[f"{S}UpLeg"] = f"thigh_{T}"
BONE_MAP[f"{S}Leg"] = f"calf_{T}"
BONE_MAP[f"{S}Foot"] = f"foot_{T}"
BONE_MAP[f"{S}ToeBase"] = f"ball_{T}"
for i in (1, 2, 3):
BONE_MAP[f"{S}HandIndex{i}"] = f"index_0{i}_{T}"
BONE_MAP[f"{S}HandMiddle{i}"] = f"middle_0{i}_{T}"
BONE_MAP[f"{S}HandRing{i}"] = f"ring_0{i}_{T}"
BONE_MAP[f"{S}HandPinky{i}"] = f"pinky_0{i}_{T}"
BONE_MAP[f"{S}HandThumb{i}"] = f"thumb_0{i}_{T}"
def detect_prefix(arm):
"""Mixamo bones are usually 'mixamorig:Hips' but exports vary ('mixamorig1:', none)."""
for b in arm.data.bones:
if b.name.endswith("Hips"):
return b.name[: -len("Hips")]
raise RuntimeError("no *Hips bone found — not a Mixamo rig?")
def apply_object_transform(obj):
"""Bake importer object-level rotation/scale into the armature data (meters, Z-up)."""
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 clip_name(path):
base = os.path.splitext(os.path.basename(path))[0]
base = base.replace(" - ", "_").replace(" ", "")
return re.sub(r"[^A-Za-z0-9_\-]", "", base)
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 char_frame(rest, hips, head, upper_arm_l):
"""Orthonormal character frame from the rest pose: columns = (left-arm axis, hips→head up,
their cross) — aligns the two rigs whatever world orientation the importer left them in."""
u = (rest[head].translation - rest[hips].translation).normalized()
l = (rest[upper_arm_l].to_quaternion() @ Vector((0, 1, 0))).normalized() # bone Y = along bone
f = l.cross(u).normalized()
l = u.cross(f).normalized()
m = Matrix((l, u, f)).transposed()
return m.to_quaternion().normalized()
def retarget_one(src_arm, tgt_arm, action, name, scn, prefix, unit):
f0, f1 = int(action.frame_range[0]), int(action.frame_range[1])
if src_arm.animation_data is None:
src_arm.animation_data_create()
src_arm.animation_data.action = action
# Blender 5 slotted actions: bind the first slot so the action actually evaluates.
if getattr(action, "slots", None) and src_arm.animation_data.action_slot is None:
src_arm.animation_data.action_slot = action.slots[0]
inv_map = {v: prefix + k for k, v in BONE_MAP.items()}
tgt_rest = {b.name: (tgt_arm.matrix_world @ b.matrix_local) for b in tgt_arm.data.bones}
src_rest = {b.name: (src_arm.matrix_world @ b.matrix_local) for b in src_arm.data.bones}
src_rest_q = {n: m.to_quaternion().normalized() for n, m in src_rest.items()}
tgt_rest_q = {n: m.to_quaternion().normalized() for n, m in tgt_rest.items()}
C = (char_frame(tgt_rest, "pelvis", "Head", "upperarm_l")
@ char_frame(src_rest, prefix + "Hips", prefix + "Head", prefix + "LeftArm").inverted()).normalized()
Cinv = C.inverted()
# Uniform size ratio from hips→head world distance (unit/scale agnostic).
h_src = (src_rest[prefix + "Head"].translation - src_rest[prefix + "Hips"].translation).length
h_tgt = (tgt_rest["Head"].translation - tgt_rest["pelvis"].translation).length
tscale = h_tgt / h_src if h_src > 1e-5 else 1.0
new_act = bpy.data.actions.new(name)
if tgt_arm.animation_data is None:
tgt_arm.animation_data_create()
tgt_arm.animation_data.action = new_act
order = hierarchy_order(tgt_arm)
tw_inv = tgt_arm.matrix_world.inverted()
for pb in tgt_arm.pose.bones:
pb.rotation_mode = "QUATERNION"
src_hips_rest_t = src_rest[prefix + "Hips"].translation
for f in range(f0, f1 + 1):
scn.frame_set(f)
dg = bpy.context.evaluated_depsgraph_get()
se = src_arm.evaluated_get(dg)
spose = {pb.name: (src_arm.matrix_world @ pb.matrix) for pb in se.pose.bones}
tgt_world = {}
for tb in order:
nt = tb.name
rest_t = tgt_rest[nt]
ns = inv_map.get(nt)
if ns is None or ns not in spose:
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
q_delta = (spose[ns].to_quaternion().normalized() @ src_rest_q[ns].inverted()).normalized()
q_delta = (C @ q_delta @ Cinv).normalized() # express the delta in TARGET world axes
q_world = (q_delta @ tgt_rest_q[nt]).normalized()
if nt == "pelvis":
# World-space hips delta from rest → target world, size-scaled. Carries the
# dance's floor work (crouches, drops) without caring about local axes.
# × unit: transform_apply bakes the importer's cm→m scale into bone REST data,
# but the hips location FCURVES stay in cm — the raw world delta is 100× real.
d = C @ (spose[ns].translation - src_hips_rest_t) * tscale * unit
t_world = rest_t.translation + d
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=f)
if tb.name == "pelvis" or tb.parent is None:
pb.keyframe_insert("location", frame=f)
tgt_arm.animation_data.action = None
return new_act
def main():
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
args = dict(zip(argv[::2], argv[1::2]))
srcs, out = args["--src"].split(","), args["--out"]
limit = int(args.get("--limit", 0))
target = args.get("--target", DEFAULT_TARGET_GLTF)
fbx_files = []
for s in srcs:
s = s.strip()
if os.path.isdir(s):
for root, _, files in os.walk(s):
fbx_files += [os.path.join(root, x) for x in sorted(files) if x.lower().endswith(".fbx")]
else:
fbx_files.append(s)
if limit:
fbx_files = fbx_files[:limit]
print(f"[mixamo] {len(fbx_files)} clips → {out}")
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"
# Drop meshes — animations-only GLB (the 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)
bpy.context.view_layer.update()
apply_object_transform(tgt_arm)
done = []
for i, f in enumerate(fbx_files):
name = clip_name(f)
pre = set(bpy.data.objects)
pre_actions = set(bpy.data.actions)
bpy.ops.import_scene.fbx(filepath=f)
new_objs = [o for o in bpy.data.objects if o not in pre]
src_arm = next((o for o in new_objs if o.type == "ARMATURE"), None)
new_acts = [a for a in bpy.data.actions if a not in pre_actions]
if src_arm is None or not new_acts:
print(f"[mixamo] SKIP (no armature/action): {f}")
else:
bpy.context.view_layer.update()
prefix = detect_prefix(src_arm)
# Importer unit scale (cm→m 0.01) — capture BEFORE transform_apply wipes it;
# the hips translation math needs it (fcurves stay in source units).
unit = src_arm.scale.x
apply_object_transform(src_arm)
act = retarget_one(src_arm, tgt_arm, new_acts[0], name, scn, prefix, unit)
done.append(act)
print(f"[mixamo] {i + 1}/{len(fbx_files)} {name} ({int(act.frame_range[1])}f, prefix='{prefix}')")
for o in new_objs:
bpy.data.objects.remove(o, do_unlink=True)
for a in new_acts:
bpy.data.actions.remove(a)
# Stash every baked action on an NLA track so the glTF exporter emits it as an animation.
for act in done:
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"[mixamo] EXPORTED {len(done)} clips → {out}")
main()