Files
2026-01-02 01:47:19 -08:00

355 lines
12 KiB
Python

"""
Blender Headless FBX Extraction Script
This script runs INSIDE Blender via subprocess.
It extracts joint positions from FBX files and outputs JSON.
Usage (called by mixamo_nodes.py):
blender --background --python blender_extract_mixamo.py
Environment variables:
FBX_PATH: Path to input FBX file
OUTPUT_JSON: Path to output JSON file
"""
import bpy
import json
import os
import sys
import math
from mathutils import Vector, Matrix
# FBX bone names to OpenPose 18 joint indices
# Supports "mixamorig:" prefixed bones (common in rigged FBX exports)
# OpenPose: 0=nose, 1=neck, 2=r_shoulder, 3=r_elbow, 4=r_wrist,
# 5=l_shoulder, 6=l_elbow, 7=l_wrist, 8=r_hip, 9=r_knee,
# 10=r_ankle, 11=l_hip, 12=l_knee, 13=l_ankle,
# 14=r_eye, 15=l_eye, 16=r_ear, 17=l_ear
MIXAMO_TO_OPENPOSE = {
# Head - will be used to synthesize face
"mixamorig:Head": "head", # Special - used for face synthesis
"mixamorig:Neck": 1,
# Right arm
"mixamorig:RightShoulder": 2,
"mixamorig:RightArm": 2, # Fallback
"mixamorig:RightForeArm": 3,
"mixamorig:RightHand": 4,
# Left arm
"mixamorig:LeftShoulder": 5,
"mixamorig:LeftArm": 5, # Fallback
"mixamorig:LeftForeArm": 6,
"mixamorig:LeftHand": 7,
# Right leg
"mixamorig:RightUpLeg": 8,
"mixamorig:RightLeg": 9,
"mixamorig:RightFoot": 10,
# Left leg
"mixamorig:LeftUpLeg": 11,
"mixamorig:LeftLeg": 12,
"mixamorig:LeftFoot": 13,
# Hips for reference
"mixamorig:Hips": "hips",
}
# Alternative naming (some Mixamo exports don't have "mixamorig:" prefix)
MIXAMO_ALT_NAMES = {
"Head": "mixamorig:Head",
"Neck": "mixamorig:Neck",
"RightShoulder": "mixamorig:RightShoulder",
"RightArm": "mixamorig:RightArm",
"RightForeArm": "mixamorig:RightForeArm",
"RightHand": "mixamorig:RightHand",
"LeftShoulder": "mixamorig:LeftShoulder",
"LeftArm": "mixamorig:LeftArm",
"LeftForeArm": "mixamorig:LeftForeArm",
"LeftHand": "mixamorig:LeftHand",
"RightUpLeg": "mixamorig:RightUpLeg",
"RightLeg": "mixamorig:RightLeg",
"RightFoot": "mixamorig:RightFoot",
"LeftUpLeg": "mixamorig:LeftUpLeg",
"LeftLeg": "mixamorig:LeftLeg",
"LeftFoot": "mixamorig:LeftFoot",
"Hips": "mixamorig:Hips",
}
def get_bone_world_position(armature, bone_name, frame):
"""Get world position of a bone at a specific frame."""
bpy.context.scene.frame_set(frame)
pose_bone = armature.pose.bones.get(bone_name)
if pose_bone is None:
# Try alternative name
alt_name = MIXAMO_ALT_NAMES.get(bone_name.replace("mixamorig:", ""))
if alt_name:
pose_bone = armature.pose.bones.get(alt_name.replace("mixamorig:", ""))
if pose_bone is None:
return None
# Get world matrix and extract position
world_matrix = armature.matrix_world @ pose_bone.matrix
return world_matrix.translation.copy()
def find_armature():
"""Find the armature object in the scene."""
for obj in bpy.context.scene.objects:
if obj.type == 'ARMATURE':
return obj
return None
def get_available_bones(armature):
"""Get list of available bone names."""
return [bone.name for bone in armature.pose.bones]
def normalize_bone_name(name):
"""Try to match bone name to Mixamo standard."""
if name in MIXAMO_TO_OPENPOSE:
return name
# Try with prefix
prefixed = f"mixamorig:{name}"
if prefixed in MIXAMO_TO_OPENPOSE:
return prefixed
# Try without prefix (mixamorig:)
if name.startswith("mixamorig:"):
unprefixed = name[10:]
normalized = f"mixamorig:{unprefixed}"
if normalized in MIXAMO_TO_OPENPOSE:
return normalized
# Try without prefix (mixamorig1: - alternate naming with "1")
if name.startswith("mixamorig1:"):
unprefixed = name[11:]
normalized = f"mixamorig:{unprefixed}"
if normalized in MIXAMO_TO_OPENPOSE:
return normalized
# Try without prefix (mixamorig2:, mixamorig3:, etc - just in case)
import re
match = re.match(r'mixamorig\d+:(.*)', name)
if match:
unprefixed = match.group(1)
normalized = f"mixamorig:{unprefixed}"
if normalized in MIXAMO_TO_OPENPOSE:
return normalized
return None
def extract_frame(armature, frame, bone_map):
"""Extract OpenPose 18-joint positions for a single frame."""
# Initialize 18 joints with zeros
joints = [[0.0, 0.0, 0.0] for _ in range(18)]
# Storage for special bones needed for face synthesis
head_pos = None
neck_pos = None
r_shoulder_pos = None
l_shoulder_pos = None
r_hip_pos = None
l_hip_pos = None
# Extract mapped bones
for mixamo_name, openpose_idx in bone_map.items():
pos = get_bone_world_position(armature, mixamo_name, frame)
if pos is None:
continue
# Handle special markers
if openpose_idx == "head":
head_pos = pos
elif openpose_idx == "hips":
# Use hips to help calculate mid-hip if needed
pass
elif isinstance(openpose_idx, int):
# Blender Z-up → Screen Y-vertical: swap Y and Z
joints[openpose_idx] = [pos.x, pos.z, pos.y]
# Store key positions for face synthesis
if openpose_idx == 1:
neck_pos = pos
elif openpose_idx == 2:
r_shoulder_pos = pos
elif openpose_idx == 5:
l_shoulder_pos = pos
elif openpose_idx == 8:
r_hip_pos = pos
elif openpose_idx == 11:
l_hip_pos = pos
# Synthesize face keypoints (nose, eyes, ears) from head/neck
if head_pos and neck_pos and r_shoulder_pos and l_shoulder_pos:
# Calculate torso length for stable head sizing
if r_hip_pos and l_hip_pos:
mid_hip = (r_hip_pos + l_hip_pos) / 2
spine_vec = neck_pos - mid_hip
torso_len = spine_vec.length
else:
torso_len = (neck_pos - head_pos).length * 2.5
stable_head_len = torso_len * 0.4
# Orientation vectors
head_vec = head_pos - neck_pos
raw_len = head_vec.length
if raw_len < 0.01 or raw_len < (torso_len * 0.1):
up_dir = spine_vec.normalized() if r_hip_pos and l_hip_pos else Vector((0, 0, 1))
else:
up_dir = head_vec.normalized()
sh_vec = l_shoulder_pos - r_shoulder_pos
right_dir = sh_vec.normalized()
fwd_dir = right_dir.cross(up_dir).normalized()
# Synthesize nose (index 0)
nose = neck_pos + (up_dir * stable_head_len * 0.7) + (fwd_dir * stable_head_len * 0.25)
joints[0] = [nose.x, nose.z, nose.y]
# Right eye (index 14)
r_eye = nose + (right_dir * -0.15 * stable_head_len) + (up_dir * 0.15 * stable_head_len) - (fwd_dir * 0.05 * stable_head_len)
joints[14] = [r_eye.x, r_eye.z, r_eye.y]
# Left eye (index 15)
l_eye = nose + (right_dir * 0.15 * stable_head_len) + (up_dir * 0.15 * stable_head_len) - (fwd_dir * 0.05 * stable_head_len)
joints[15] = [l_eye.x, l_eye.z, l_eye.y]
# Right ear (index 16)
r_ear = nose + (right_dir * -0.4 * stable_head_len) - (fwd_dir * 0.3 * stable_head_len)
joints[16] = [r_ear.x, r_ear.z, r_ear.y]
# Left ear (index 17)
l_ear = nose + (right_dir * 0.4 * stable_head_len) - (fwd_dir * 0.3 * stable_head_len)
joints[17] = [l_ear.x, l_ear.z, l_ear.y]
return joints
def main():
# Get paths from environment
fbx_path = os.environ.get("FBX_PATH")
output_path = os.environ.get("OUTPUT_JSON")
if not fbx_path or not output_path:
print("ERROR: FBX_PATH and OUTPUT_JSON environment variables required")
sys.exit(1)
if not os.path.exists(fbx_path):
print(f"ERROR: FBX file not found: {fbx_path}")
sys.exit(1)
# Clear scene
bpy.ops.wm.read_factory_settings(use_empty=True)
# Import FBX
print(f"Importing FBX: {fbx_path}")
try:
bpy.ops.import_scene.fbx(filepath=fbx_path)
except Exception as e:
print(f"ERROR: Failed to import FBX: {e}")
sys.exit(1)
# Find armature
armature = find_armature()
if armature is None:
print("ERROR: No armature found in FBX")
sys.exit(1)
print(f"Found armature: {armature.name}")
# Get available bones and build mapping
available_bones = get_available_bones(armature)
print(f"Available bones: {available_bones[:10]}...") # Print first 10
# Build bone mapping
bone_map = {}
for bone_name in available_bones:
normalized = normalize_bone_name(bone_name)
if normalized and normalized in MIXAMO_TO_OPENPOSE:
bone_map[bone_name] = MIXAMO_TO_OPENPOSE[normalized]
print(f"Mapped {len(bone_map)} bones")
# Get animation range from the actual action, not scene defaults
scene = bpy.context.scene
fps = scene.render.fps
# Try to get frame range from armature's action
frame_start = None
frame_end = None
if armature.animation_data and armature.animation_data.action:
action = armature.animation_data.action
frame_start, frame_end = action.frame_range
frame_start = int(frame_start)
frame_end = int(frame_end)
print(f"Using action '{action.name}' frame range: {frame_start}-{frame_end}")
# Fallback: check all actions in the file
if frame_start is None and bpy.data.actions:
for action in bpy.data.actions:
f_start, f_end = action.frame_range
print(f"Found action '{action.name}': frames {int(f_start)}-{int(f_end)}")
if frame_start is None or f_start < frame_start:
frame_start = int(f_start)
if frame_end is None or f_end > frame_end:
frame_end = int(f_end)
# Assign the first action to the armature if none assigned
if armature.animation_data is None:
armature.animation_data_create()
if armature.animation_data.action is None and bpy.data.actions:
armature.animation_data.action = bpy.data.actions[0]
print(f"Assigned action '{bpy.data.actions[0].name}' to armature")
# Final fallback to scene range
if frame_start is None:
frame_start = int(scene.frame_start)
frame_end = int(scene.frame_end)
print(f"Using scene frame range (fallback): {frame_start}-{frame_end}")
print(f"Animation: frames {frame_start}-{frame_end} @ {fps} fps")
# Extract all frames
frames_data = []
for frame in range(frame_start, frame_end + 1):
joints = extract_frame(armature, frame, bone_map)
frames_data.append(joints)
# Build output
output = {
"fps": fps,
"frame_count": len(frames_data),
"frame_start": frame_start,
"frame_end": frame_end,
"joints_per_frame": 18,
"frames": frames_data,
"joint_names": [
"nose", "neck", "r_shoulder", "r_elbow", "r_wrist",
"l_shoulder", "l_elbow", "l_wrist", "r_hip", "r_knee",
"r_ankle", "l_hip", "l_knee", "l_ankle",
"r_eye", "l_eye", "r_ear", "l_ear"
]
}
# Write JSON
with open(output_path, 'w') as f:
json.dump(output, f)
print(f"Extracted {len(frames_data)} frames to {output_path}")
if __name__ == "__main__":
main()