mirror of
https://github.com/barkeser2002/VMD-Motion-Optimizer.git
synced 2026-09-25 01:50:14 +03:00
.gitignore *.vmd dosyalarını engellediği için fixture'lar depoya konamıyor; tests/conftest.py spesifikasyona uygun VMD baytlarını çalışma anında üretiyor. Kapsam: kayıpsız gidiş-dönüş, interpolasyon eğrilerinin kare bazında korunması, kamera/gölge bloklarının hayatta kalması, VMD 1.0 başlığı, bozuk dosyanın hızlı reddi, çıktı==girdi reddi, atomik yazma, cp932 çevriyazısı, _moving_average uzunluğu, depth eğilim çıkarma, ground IK dışlama ve geri alma, morph sabit aralık sonu, normalize edilmemiş quaternionlar, RDP özyineleme derinliği, iptal ve GUI duman testleri.
215 lines
8.3 KiB
Python
215 lines
8.3 KiB
Python
# VMD Motion Optimizer by Barış Keser (barkeser2002)
|
||
# License: GNU General Public License v3.0 (GPL-3.0)
|
||
|
||
"""Optimizasyon matematiği ve v1.0.4 hatalarının regresyon testleri."""
|
||
|
||
import math
|
||
|
||
import numpy as np
|
||
import pytest
|
||
|
||
from conftest import build_vmd, simple_bone
|
||
import optimize_vmd as ov
|
||
|
||
|
||
# ---------- _moving_average ----------
|
||
|
||
@pytest.mark.parametrize("n,window", [(10, 3), (10, 5), (6, 6), (100, 11), (1, 5)])
|
||
def test_moving_average_preserves_length(n, window):
|
||
"""v1.0.4 n + window - 1 eleman döndürüyordu; tüm ofsetler kayıyordu."""
|
||
values = list(range(n))
|
||
assert len(ov._moving_average(values, window)) == n
|
||
|
||
|
||
def test_moving_average_smooths_spike():
|
||
values = [0.0] * 10 + [10.0] + [0.0] * 10
|
||
out = ov._moving_average(values, 5)
|
||
assert len(out) == len(values)
|
||
assert out[10] == pytest.approx(2.0)
|
||
|
||
|
||
def test_moving_average_window_one_is_identity():
|
||
values = [1.0, 2.0, 3.0]
|
||
assert ov._moving_average(values, 1) == values
|
||
|
||
|
||
# ---------- depth ----------
|
||
|
||
def test_remove_depth_does_not_zero_motion(vmd_path, out_path):
|
||
"""v1.0.4 kökün Z'sini kendinden çıkarıp TÜM Z hareketini sıfırlıyordu."""
|
||
# Sabit hızlı sürüklenme + gerçek salınım
|
||
bones = []
|
||
for f in range(60):
|
||
drift = 0.1 * f
|
||
wobble = math.sin(f * 0.5)
|
||
bones.append(simple_bone(frame=f, z=drift + wobble))
|
||
src = vmd_path(build_vmd(bones=bones))
|
||
|
||
ov.optimize_vmd(src, out_path, pos_eps=0.0, rot_eps_deg=0.0,
|
||
remove_depth=True, depth_smooth_window=15)
|
||
|
||
m = ov.read_vmd(out_path)
|
||
zs = [float(b.pos[2]) for b in sorted(m.bones, key=lambda b: b.frame)]
|
||
assert any(abs(z) > 1e-6 for z in zs), "tüm Z hareketi sıfırlandı"
|
||
# Sürüklenme kaldırılmalı: son değer ilk değerden çok uzaklaşmamalı
|
||
assert abs(zs[-1] - zs[0]) < 1.5
|
||
# Salınım korunmalı
|
||
assert max(zs) - min(zs) > 1.0
|
||
|
||
|
||
def test_remove_depth_requires_smooth_window(vmd_path, out_path):
|
||
"""Pencere olmadan 'eğilim' sinyalin kendisi olur; işlem atlanmalı."""
|
||
bones = [simple_bone(frame=f, z=float(f)) for f in range(20)]
|
||
src = vmd_path(build_vmd(bones=bones))
|
||
|
||
ov.optimize_vmd(src, out_path, pos_eps=0.0, rot_eps_deg=0.0,
|
||
remove_depth=True, depth_smooth_window=0)
|
||
|
||
m = ov.read_vmd(out_path)
|
||
zs = [float(b.pos[2]) for b in sorted(m.bones, key=lambda b: b.frame)]
|
||
assert zs[-1] == pytest.approx(19.0) # dokunulmadı
|
||
|
||
|
||
# ---------- ground ----------
|
||
|
||
def test_ground_excludes_ik_bones_from_measurement():
|
||
"""exclude_ik gerçekten filtrelemeli (v1.0.4'te tamamen ölü koddu)."""
|
||
bones = [
|
||
ov.BoneFrame("センター", 0, np.array([0, 5.0, 0], np.float32), np.array([0, 0, 0, 1], np.float32)),
|
||
ov.BoneFrame("左足", 0, np.array([0, 2.0, 0], np.float32), np.array([0, 0, 0, 1], np.float32)),
|
||
ov.BoneFrame("左足IK", 0, np.array([0, -9.0, 0], np.float32), np.array([0, 0, 0, 1], np.float32)),
|
||
]
|
||
m = ov.Motion("t", bones, [])
|
||
ov.stabilize_ground(m, exclude_ik=True)
|
||
root = [b for b in m.bones if b.name == "センター"][0]
|
||
# IK dışlandı -> ölçüm 左足'in 2.0'ı -> kök 5.0 - 2.0 = 3.0
|
||
assert float(root.pos[1]) == pytest.approx(3.0)
|
||
|
||
|
||
def test_ground_includes_ik_when_asked():
|
||
bones = [
|
||
ov.BoneFrame("センター", 0, np.array([0, 5.0, 0], np.float32), np.array([0, 0, 0, 1], np.float32)),
|
||
ov.BoneFrame("左足", 0, np.array([0, 2.0, 0], np.float32), np.array([0, 0, 0, 1], np.float32)),
|
||
ov.BoneFrame("左足IK", 0, np.array([0, -9.0, 0], np.float32), np.array([0, 0, 0, 1], np.float32)),
|
||
]
|
||
m = ov.Motion("t", bones, [])
|
||
ov.stabilize_ground(m, exclude_ik=False)
|
||
root = [b for b in m.bones if b.name == "センター"][0]
|
||
assert float(root.pos[1]) == pytest.approx(5.0 - (-9.0))
|
||
|
||
|
||
def test_ground_falls_back_when_excluding_ik_leaves_nothing():
|
||
"""IK dışlandığında ölçülebilir veri kalmazsa IK geri alınmalı."""
|
||
bones = [
|
||
ov.BoneFrame("センター", 0, np.array([0, 5.0, 0], np.float32), np.array([0, 0, 0, 1], np.float32)),
|
||
ov.BoneFrame("左足IK", 0, np.array([0, -2.0, 0], np.float32), np.array([0, 0, 0, 1], np.float32)),
|
||
]
|
||
m = ov.Motion("t", bones, [])
|
||
ov.stabilize_ground(m, exclude_ik=True)
|
||
root = [b for b in m.bones if b.name == "センター"][0]
|
||
assert float(root.pos[1]) == pytest.approx(7.0)
|
||
|
||
|
||
def test_foot_matching_does_not_catch_leg_chain():
|
||
"""Çıplak '足' eşleşmesi 左足先EX / 左足D gibi kemikleri de yakalıyordu."""
|
||
assert "左足先EX" not in ov.DEFAULT_FEET_NAMES
|
||
assert "足" not in ov.DEFAULT_FEET_NAMES
|
||
assert ov._is_ik_bone("左足IK", ov.DEFAULT_IK_MARKERS)
|
||
assert not ov._is_ik_bone("左足首", ov.DEFAULT_IK_MARKERS)
|
||
|
||
|
||
# ---------- morph ----------
|
||
|
||
def test_morph_plateau_end_is_kept(vmd_path, out_path):
|
||
"""Açık tutulan bir göz, kırpma karesine kadar yavaşça kapanmamalı."""
|
||
morphs = [("まばたき", f, 0.0) for f in range(0, 101, 10)]
|
||
morphs += [("まばたき", 105, 1.0), ("まばたき", 110, 0.0)]
|
||
src = vmd_path(build_vmd(bones=[simple_bone()], morphs=morphs))
|
||
|
||
ov.optimize_vmd(src, out_path, morph_eps=1e-3)
|
||
|
||
m = ov.read_vmd(out_path)
|
||
frames = {mf.frame: mf.weight for mf in m.morphs}
|
||
assert 100 in frames, "sabit aralığın son karesi düştü -> tutuş rampaya döndü"
|
||
assert frames[100] == pytest.approx(0.0)
|
||
assert frames[105] == pytest.approx(1.0)
|
||
|
||
|
||
def test_morph_constant_channel_collapses(vmd_path, out_path):
|
||
morphs = [("あ", f, 0.5) for f in range(20)]
|
||
src = vmd_path(build_vmd(bones=[simple_bone()], morphs=morphs))
|
||
ov.optimize_vmd(src, out_path)
|
||
m = ov.read_vmd(out_path)
|
||
assert len(m.morphs) <= 2
|
||
|
||
|
||
# ---------- quaternion ----------
|
||
|
||
def _quat_y(deg: float, scale: float = 1.0) -> np.ndarray:
|
||
a = math.radians(deg) / 2.0
|
||
return (np.array([0.0, math.sin(a), 0.0, math.cos(a)], dtype=np.float32) * scale).astype(np.float32)
|
||
|
||
|
||
def test_unnormalized_quats_do_not_collapse_channel():
|
||
"""|q| > 1 olan dosyalarda acos 1.0'a kırpılıp hata 0 görünüyordu.
|
||
|
||
Git-gel bir dönüş kullanılır: slerp uç noktalar arasında bunu üretemez, yani
|
||
gerçek bir açısal hata vardır. Normalize edilmemiş girdide eski kod bu hatayı
|
||
0 olarak ölçüp kanalı iki kareye indiriyordu.
|
||
"""
|
||
keys = []
|
||
for f in range(21):
|
||
deg = 40.0 * math.sin(math.pi * f / 20.0) # 0 -> 40 -> 0
|
||
keys.append((f, _quat_y(deg, scale=1.15))) # bilerek normalize DEĞİL
|
||
|
||
out = ov.simplify_quat_curve(keys, math.radians(0.5))
|
||
assert len(out) > 2, "kanal iki keyframe'e çöktü"
|
||
assert all(abs(float(np.linalg.norm(q)) - 1.0) < 1e-5 for _, q in out)
|
||
|
||
|
||
def test_normalized_and_unnormalized_agree():
|
||
"""Aynı dönüş, ölçekli ve ölçeksiz quaternionlarla aynı sonucu vermeli."""
|
||
plain, scaled = [], []
|
||
for f in range(21):
|
||
deg = 40.0 * math.sin(math.pi * f / 20.0)
|
||
plain.append((f, _quat_y(deg)))
|
||
scaled.append((f, _quat_y(deg, scale=1.15)))
|
||
eps = math.radians(0.5)
|
||
assert [f for f, _ in ov.simplify_quat_curve(plain, eps)] == \
|
||
[f for f, _ in ov.simplify_quat_curve(scaled, eps)]
|
||
|
||
|
||
def test_quat_simplify_keeps_endpoints():
|
||
keys = [(f, np.array([0.0, 0.0, 0.0, 1.0], dtype=np.float32)) for f in range(10)]
|
||
out = ov.simplify_quat_curve(keys, math.radians(0.5))
|
||
assert out[0][0] == 0
|
||
assert out[-1][0] == 9
|
||
|
||
|
||
# ---------- RDP ----------
|
||
|
||
def test_rdp_survives_pathological_channel():
|
||
"""Sönümlü salınım O(n) özyineleme derinliği üretip RecursionError veriyordu."""
|
||
keys = []
|
||
for i in range(2000):
|
||
v = (0.999 ** i) * (1.0 if i % 2 == 0 else -1.0) * 100.0
|
||
keys.append((i, np.array([v, 0.0, 0.0], dtype=np.float32)))
|
||
out = ov.simplify_curve(keys, 0.01) # RecursionError yükseltmemeli
|
||
assert out[0][0] == 0
|
||
assert out[-1][0] == 1999
|
||
|
||
|
||
def test_rdp_reduces_straight_line():
|
||
keys = [(f, np.array([float(f), 0.0, 0.0], dtype=np.float32)) for f in range(100)]
|
||
out = ov.simplify_curve(keys, 0.001)
|
||
assert len(out) == 2
|
||
|
||
|
||
# ---------- iptal ----------
|
||
|
||
def test_cancel_raises(vmd_path, out_path):
|
||
bones = [simple_bone(name=f"bone{i}", frame=0) for i in range(10)]
|
||
src = vmd_path(build_vmd(bones=bones))
|
||
with pytest.raises(ov.CancelledError):
|
||
ov.optimize_vmd(src, out_path, should_cancel=lambda: True)
|