124 lines
5.1 KiB
Python
124 lines
5.1 KiB
Python
|
|
"""loop_qc.py — measure how cleanly a clip loops.
|
||
|
|
|
||
|
|
The game loops dance clips with Godot LoopMode.Linear: after the last keyframe it
|
||
|
|
wraps straight back to frame 0. A loop is smooth only if, at that seam, both the
|
||
|
|
POSE and the MOTION are continuous:
|
||
|
|
|
||
|
|
1. pose gap — per-bone local-rotation difference (deg) between last & first frame.
|
||
|
|
Large gap => the character visibly snaps to a new pose on wrap.
|
||
|
|
2. velocity gap — per-bone angular velocity just BEFORE the seam vs just AFTER it.
|
||
|
|
Mismatch => motion direction jerks even if the pose matches.
|
||
|
|
3. root drift — pelvis world-position difference end vs start. Nonzero => the body
|
||
|
|
teleports sideways/vertically on wrap (only matters for in-place clips).
|
||
|
|
|
||
|
|
Run headless:
|
||
|
|
blender --background --python tools/loop_qc.py -- --src <clip.glb> [--name <Clip>] \
|
||
|
|
[--pose-deg 5] [--vel-deg 3] [--drift-cm 3]
|
||
|
|
|
||
|
|
Exit code 0 = SMOOTH (within thresholds), 1 = NOT SMOOTH, 2 = error. So it can gate a
|
||
|
|
pipeline step. Prints a ranked list of the worst-offending bones to say WHERE the pop is.
|
||
|
|
"""
|
||
|
|
import bpy, sys, math, argparse
|
||
|
|
from mathutils import Quaternion
|
||
|
|
|
||
|
|
def parse():
|
||
|
|
argv = sys.argv[sys.argv.index("--")+1:] if "--" in sys.argv else []
|
||
|
|
p = argparse.ArgumentParser()
|
||
|
|
p.add_argument("--src", required=True)
|
||
|
|
p.add_argument("--name", default=None, help="clip/action name; default = first action")
|
||
|
|
p.add_argument("--pose-deg", type=float, default=5.0, help="max per-bone pose gap (deg)")
|
||
|
|
p.add_argument("--vel-deg", type=float, default=3.0, help="max per-bone velocity gap (deg/frame)")
|
||
|
|
p.add_argument("--drift-cm", type=float, default=3.0, help="max pelvis drift (cm)")
|
||
|
|
p.add_argument("--top", type=int, default=8, help="how many worst bones to list")
|
||
|
|
return p.parse_args(argv)
|
||
|
|
|
||
|
|
def load(src, name):
|
||
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||
|
|
bpy.ops.import_scene.gltf(filepath=src)
|
||
|
|
arm = next(o for o in bpy.data.objects if o.type == 'ARMATURE')
|
||
|
|
if name:
|
||
|
|
act = bpy.data.actions.get(name)
|
||
|
|
if act is None:
|
||
|
|
acts = [a.name for a in bpy.data.actions]
|
||
|
|
raise SystemExit(f"[loop_qc] no action '{name}'; have {acts}")
|
||
|
|
arm.animation_data.action = act
|
||
|
|
else:
|
||
|
|
act = arm.animation_data.action
|
||
|
|
return arm, act
|
||
|
|
|
||
|
|
def local_quats(arm, frame):
|
||
|
|
bpy.context.scene.frame_set(frame)
|
||
|
|
out = {}
|
||
|
|
for pb in arm.pose.bones:
|
||
|
|
q = pb.matrix_basis.to_quaternion().normalized()
|
||
|
|
out[pb.name] = q
|
||
|
|
return out
|
||
|
|
|
||
|
|
def ang(q1, q2):
|
||
|
|
d = abs(max(-1.0, min(1.0, q1.dot(q2))))
|
||
|
|
return math.degrees(2.0 * math.acos(d))
|
||
|
|
|
||
|
|
def main():
|
||
|
|
a = parse()
|
||
|
|
arm, act = load(a.src, a.name)
|
||
|
|
f0, f1 = int(act.frame_range[0]), int(act.frame_range[1])
|
||
|
|
scene = bpy.context.scene
|
||
|
|
|
||
|
|
q_first = local_quats(arm, f0)
|
||
|
|
q_last = local_quats(arm, f1)
|
||
|
|
q_first1 = local_quats(arm, f0 + 1) # for velocity just after the seam
|
||
|
|
q_last1 = local_quats(arm, f1 - 1) # for velocity just before the seam
|
||
|
|
|
||
|
|
bones = [b for b in q_first if b in q_last]
|
||
|
|
|
||
|
|
# 1. pose gap
|
||
|
|
pose = sorted(((ang(q_last[b], q_first[b]), b) for b in bones), reverse=True)
|
||
|
|
pose_max, pose_bone = pose[0]
|
||
|
|
pose_mean = sum(v for v, _ in pose) / len(pose)
|
||
|
|
|
||
|
|
# 2. velocity gap: v_before = last-1 -> last ; v_after = first -> first+1
|
||
|
|
vel = []
|
||
|
|
for b in bones:
|
||
|
|
v_before = ang(q_last1[b], q_last[b])
|
||
|
|
v_after = ang(q_first[b], q_first1[b])
|
||
|
|
vel.append((abs(v_before - v_after), b, v_before, v_after))
|
||
|
|
vel.sort(reverse=True)
|
||
|
|
vel_max, vel_bone = vel[0][0], vel[0][1]
|
||
|
|
vel_mean = sum(v for v, *_ in vel) / len(vel)
|
||
|
|
|
||
|
|
# 3. root drift (pelvis world pos)
|
||
|
|
root = 'pelvis' if 'pelvis' in arm.pose.bones else arm.pose.bones[0].name
|
||
|
|
scene.frame_set(f0); p0 = (arm.matrix_world @ arm.pose.bones[root].head).copy()
|
||
|
|
scene.frame_set(f1); p1 = (arm.matrix_world @ arm.pose.bones[root].head).copy()
|
||
|
|
drift_cm = (p1 - p0).length * 100.0
|
||
|
|
|
||
|
|
ok_pose = pose_max <= a.pose_deg
|
||
|
|
ok_vel = vel_max <= a.vel_deg
|
||
|
|
ok_drift = drift_cm <= a.drift_cm
|
||
|
|
smooth = ok_pose and ok_vel and ok_drift
|
||
|
|
|
||
|
|
print("=== LOOP QC ===")
|
||
|
|
print(f"clip={act.name} frames={f0}..{f1}")
|
||
|
|
print(f"[{'PASS' if ok_pose else 'FAIL'}] pose gap : max {pose_max:6.2f}° (limit {a.pose_deg}) mean {pose_mean:.2f}° worst={pose_bone}")
|
||
|
|
print(f"[{'PASS' if ok_vel else 'FAIL'}] velocity gap: max {vel_max:6.2f}°/f (limit {a.vel_deg}) mean {vel_mean:.2f} worst={vel_bone}")
|
||
|
|
print(f"[{'PASS' if ok_drift else 'FAIL'}] root drift : {drift_cm:6.2f} cm (limit {a.drift_cm}) bone={root}")
|
||
|
|
print(f"\nVERDICT: {'SMOOTH ✓' if smooth else 'NOT SMOOTH ✗ — will pop on loop'}")
|
||
|
|
|
||
|
|
print(f"\nworst {a.top} bones by pose gap (deg end→start):")
|
||
|
|
for v, b in pose[:a.top]:
|
||
|
|
print(f" {v:7.2f} {b}")
|
||
|
|
print(f"\nworst {a.top} bones by velocity mismatch (deg/frame across seam):")
|
||
|
|
for v, b, vb, va in vel[:a.top]:
|
||
|
|
print(f" {v:7.2f} {b} (before {vb:.2f} / after {va:.2f})")
|
||
|
|
|
||
|
|
sys.exit(0 if smooth else 1)
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
try:
|
||
|
|
main()
|
||
|
|
except SystemExit:
|
||
|
|
raise
|
||
|
|
except Exception as e:
|
||
|
|
print(f"[loop_qc] ERROR: {e}")
|
||
|
|
sys.exit(2)
|