Files
Barış Keser cc959dc16e Test paketi ekle: 47 test, düzeltilen her hata için regresyon
.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.
2026-08-03 10:12:31 +03:00

215 lines
8.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)