Files
Papay-Shooter/tools/build_cmu_locomotion_library.py
2026-08-02 02:20:02 -04:00

169 lines
5.5 KiB
Python

#!/usr/bin/env python3
"""Build the compact CMU backward-locomotion animation library.
Usage:
blender --background --python tools/build_cmu_locomotion_library.py -- \
<76_11.bvh> <output.glb>
Subject 76 trial 11 is CMU's optical capture described as "quick large steps
backwards." The script finds a naturally matching gait-cycle boundary, keeps
the captured bone motion intact, removes gameplay-owned horizontal root travel,
and exports only that cycle.
"""
import os
import sys
import bpy
argv = sys.argv
argv = argv[argv.index("--") + 1:] if "--" in argv else []
if len(argv) != 2:
print(__doc__)
sys.exit(1)
SOURCE, OUTPUT = argv
SEARCH_START = range(170, 281)
SEARCH_END = range(300, 411)
MIN_CYCLE_FRAMES = 95
MAX_CYCLE_FRAMES = 175
SIGNATURE_BONES = (
"Hips", "LowerBack", "Spine", "Spine1", "Neck", "Head",
"LeftArm", "LeftForeArm", "RightArm", "RightForeArm",
"LeftUpLeg", "LeftLeg", "LeftFoot",
"RightUpLeg", "RightLeg", "RightFoot",
)
def action_fcurves(action):
"""Blender 5.x channelbag f-curves for an imported BVH action."""
curves = []
for layer in action.layers:
for strip in layer.strips:
for slot in action.slots:
channelbag = strip.channelbag(slot)
if channelbag is not None:
curves.extend(channelbag.fcurves)
return curves
def pose_signature(armature, frame):
bpy.context.scene.frame_set(frame)
signature = []
for name in SIGNATURE_BONES:
bone = armature.pose.bones.get(name)
if bone is None:
continue
signature.append(bone.matrix.to_quaternion().normalized())
hips = armature.pose.bones["Hips"].matrix.translation
return signature, hips.copy()
def find_cycle(armature):
samples = {
frame: pose_signature(armature, frame)
for frame in range(min(SEARCH_START), max(SEARCH_END) + 1)
}
best = None
for start in SEARCH_START:
start_rotations, start_hips = samples[start]
for end in SEARCH_END:
duration = end - start
if duration < MIN_CYCLE_FRAMES or duration > MAX_CYCLE_FRAMES:
continue
end_rotations, end_hips = samples[end]
rotation_error = sum(
rotation.rotation_difference(other).angle ** 2
for rotation, other in zip(start_rotations, end_rotations)
) / max(len(start_rotations), 1)
# Match the captured vertical phase but intentionally ignore
# horizontal displacement: it proves the performer travelled.
vertical_error = (end_hips.z - start_hips.z) ** 2 * 0.02
score = rotation_error + vertical_error
if best is None or score < best[0]:
best = (score, start, end)
if best is None:
raise RuntimeError("No plausible gait cycle found in the capture")
return best
def crop_action(action, start, end):
shift = start - 1
curves = action_fcurves(action)
for curve in curves:
points = curve.keyframe_points
for index in range(len(points) - 1, -1, -1):
if points[index].co.x < start or points[index].co.x > end:
points.remove(points[index])
for point in points:
point.co.x -= shift
point.handle_left.x -= shift
point.handle_right.x -= shift
# The gameplay body owns horizontal travel. Preserve every captured
# rotation and the vertical compression/lift, but keep the cycle in place.
hips_path = 'pose.bones["Hips"].location'
for curve in curves:
if curve.data_path != hips_path or curve.array_index not in (0, 1):
continue
baseline = curve.evaluate(1.0)
for point in curve.keyframe_points:
point.co.y -= baseline
point.handle_left.y -= baseline
point.handle_right.y -= baseline
def main():
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.context.scene.render.fps = 120
bpy.context.scene.render.fps_base = 1.0
bpy.ops.import_anim.bvh(
filepath=SOURCE,
axis_forward="-Z",
axis_up="Y",
)
armature = next(
(obj for obj in bpy.data.objects if obj.type == "ARMATURE"), None
)
if armature is None or armature.animation_data is None:
raise RuntimeError(f"{SOURCE}: no animated armature")
action = armature.animation_data.action
if action is None:
raise RuntimeError(f"{SOURCE}: no action")
score, start, end = find_cycle(armature)
crop_action(action, start, end)
action.name = "RunBackward"
action.use_fake_user = True
bpy.context.scene.frame_start = 1
bpy.context.scene.frame_end = end - start + 1
track = armature.animation_data.nla_tracks.new()
track.name = "RunBackward"
strip = track.strips.new("RunBackward", 0, action)
strip.name = "RunBackward"
track.mute = True
armature.animation_data.action = None
os.makedirs(os.path.dirname(os.path.abspath(OUTPUT)), exist_ok=True)
bpy.ops.export_scene.gltf(
filepath=OUTPUT,
export_format="GLB",
export_yup=True,
export_skins=True,
export_animations=True,
export_animation_mode="NLA_TRACKS",
export_bake_animation=False,
export_optimize_animation_size=True,
export_optimize_animation_keep_anim_armature=False,
)
print(
f"CMU 76_11 frames {start}..{end} -> RunBackward "
f"({(end - start) / 120.0:.3f}s, phase score {score:.6f})"
)
print(f"Wrote {OUTPUT}")
main()