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

104 lines
3.0 KiB
Python

#!/usr/bin/env python3
"""
Measure authored ground speed from root-motion animation clips.
Usage:
blender --background --python tools/audit_animation_speeds.py -- \
<library.glb> <clip> [<clip> ...]
The reported metres/second is the ground-plane displacement of the resolved
hips bone over the clip's own duration. Use the root-motion edition of an
animation library; an in-place edition correctly reports approximately zero.
"""
import os
import sys
import bpy
from mathutils import Vector
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from rig_map import RigRoles
argv = sys.argv
argv = argv[argv.index("--") + 1:] if "--" in argv else []
if len(argv) < 2:
print(__doc__)
sys.exit(1)
LIBRARY = argv[0]
CLIPS = argv[1:]
def assign_action(armature, action):
if not armature.animation_data:
armature.animation_data_create()
armature.animation_data.action = action
try:
if len(action.slots):
armature.animation_data.action_slot = action.slots[0]
except (AttributeError, TypeError):
pass
def hips_world_position(armature, hips_name, frame):
bpy.context.scene.frame_set(int(round(frame)))
depsgraph = bpy.context.evaluated_depsgraph_get()
evaluated = armature.evaluated_get(depsgraph)
return (
evaluated.matrix_world @ evaluated.pose.bones[hips_name].matrix
).translation
def main():
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete()
bpy.ops.import_scene.gltf(filepath=LIBRARY)
armatures = [obj for obj in bpy.data.objects if obj.type == "ARMATURE"]
if len(armatures) != 1:
print(f"ERROR: expected one armature, found {len(armatures)}")
sys.exit(1)
armature = armatures[0]
roles = RigRoles(armature)
if not roles.hips:
print("ERROR: could not resolve hips bone")
sys.exit(1)
fps = (
bpy.context.scene.render.fps
/ bpy.context.scene.render.fps_base
)
if CLIPS == ["--list"]:
for action in sorted(bpy.data.actions, key=lambda item: item.name):
print(action.name)
return
failed = False
print(f"AUTHORED_SPEED_AUDIT fps={fps:.3f} hips={roles.hips}")
for clip in CLIPS:
action = bpy.data.actions.get(clip)
if action is None:
print(f"ERROR: missing clip {clip}")
failed = True
continue
assign_action(armature, action)
first, last = (float(value) for value in action.frame_range)
start = hips_world_position(armature, roles.hips, first)
end = hips_world_position(armature, roles.hips, last)
displacement = end - start
displacement.z = 0.0
duration = (last - first) / fps
speed = displacement.length / duration if duration > 1e-6 else 0.0
print(
f"AUTHORED_SPEED {clip} speed={speed:.5f}m/s "
f"distance={displacement.length:.5f}m "
f"duration={duration:.5f}s "
f"delta=({displacement.x:.5f},{displacement.y:.5f})"
)
sys.exit(1 if failed else 0)
main()