105 lines
4.2 KiB
Python
105 lines
4.2 KiB
Python
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from src.config_loader import HeadPoseSettings, _make, load_config
|
|
from src.exceptions import ConfigurationError
|
|
from src.face_processor import FaceProcessor
|
|
from src.yaw_filters import FILTER_KINDS
|
|
|
|
DEFAULT = Path(__file__).parents[1] / "config" / "default.yaml"
|
|
|
|
|
|
def build(head_pose: dict[str, Any] | None = None):
|
|
overrides: dict[str, Any] = {"face": {"enabled": False}} # no MediaPipe needed
|
|
if head_pose is not None:
|
|
overrides["head_pose"] = head_pose
|
|
return load_config(DEFAULT, None, overrides)
|
|
|
|
|
|
def processor(head_pose: dict[str, Any] | None = None) -> FaceProcessor:
|
|
config = build(head_pose)
|
|
return FaceProcessor(config.face, config.head_pose)
|
|
|
|
|
|
def test_defaults_reproduce_the_original_ema() -> None:
|
|
settings = build().head_pose
|
|
assert settings.smoothing_method == "ema" and settings.smoothing_enabled
|
|
assert settings.filter_reset_gap_sec == 0.0
|
|
proc = processor()
|
|
first = proc._smooth(1, 0.0, 10.0, 1.0, 0.0)
|
|
second = proc._smooth(1, 10.0, 20.0, 3.0, 0.05)
|
|
assert first == pytest.approx((0.0, 10.0, 1.0))
|
|
alpha = settings.ema_alpha
|
|
assert second == pytest.approx((alpha * 10.0, alpha * 20.0 + (1 - alpha) * 10.0, alpha * 3.0 + (1 - alpha) * 1.0))
|
|
|
|
|
|
def test_smoothing_disabled_passes_values_through() -> None:
|
|
proc = processor({"smoothing_enabled": False})
|
|
assert proc._smooth(1, 1.0, 2.0, 3.0, 0.0) == (1.0, 2.0, 3.0)
|
|
assert proc._smooth(1, 9.0, 8.0, 7.0, 0.1) == (9.0, 8.0, 7.0)
|
|
|
|
|
|
@pytest.mark.parametrize("kind", FILTER_KINDS)
|
|
def test_every_filter_kind_loads_and_runs(kind: str) -> None:
|
|
proc = processor({"smoothing_method": kind})
|
|
for index in range(10):
|
|
pitch, yaw, roll = proc._smooth(1, 1.0, -20.0, 0.5, index * 0.05)
|
|
assert (pitch, yaw, roll) == pytest.approx((1.0, -20.0, 0.5))
|
|
|
|
|
|
@pytest.mark.parametrize("kind", ["median_ema", "hampel_ema", "median_one_euro"])
|
|
def test_spike_is_suppressed_by_robust_filters(kind: str) -> None:
|
|
proc = processor({"smoothing_method": kind, "smoothing_window": 5})
|
|
outputs = [proc._smooth(1, 0.0, 25.0 if index == 10 else -20.0, 0.0, index * 0.056)[1] for index in range(21)]
|
|
assert max(abs(value + 20.0) for value in outputs) < 1.0
|
|
legacy = processor()
|
|
plain = [legacy._smooth(1, 0.0, 25.0 if index == 10 else -20.0, 0.0, index * 0.056)[1] for index in range(21)]
|
|
assert max(abs(value + 20.0) for value in plain) > 10.0
|
|
|
|
|
|
def test_tracks_are_independent_and_reset_clears_state() -> None:
|
|
proc = processor()
|
|
proc._smooth(1, 0.0, 0.0, 0.0, 0.0)
|
|
assert proc._smooth(2, 0.0, 30.0, 0.0, 0.0)[1] == 30.0 # track 2 starts fresh
|
|
proc.reset()
|
|
assert proc._smooth(1, 0.0, 30.0, 0.0, 1.0)[1] == 30.0
|
|
|
|
|
|
def test_gap_reset_drops_stale_state() -> None:
|
|
proc = processor({"filter_reset_gap_sec": 0.5})
|
|
proc._smooth(1, 0.0, 0.0, 0.0, 0.0)
|
|
assert proc._smooth(1, 0.0, 40.0, 0.0, 3.0)[1] == 40.0
|
|
no_reset = processor()
|
|
no_reset._smooth(1, 0.0, 0.0, 0.0, 0.0)
|
|
assert no_reset._smooth(1, 0.0, 40.0, 0.0, 3.0)[1] == pytest.approx(16.0)
|
|
|
|
|
|
def test_new_keys_are_optional_in_yaml_sections() -> None:
|
|
legacy_section = {"enabled": True, "method": "solvepnp", "smoothing_enabled": True,
|
|
"smoothing_method": "ema", "ema_alpha": 0.4, "max_abs_angle": 120.0}
|
|
settings = _make(HeadPoseSettings, legacy_section, "head_pose")
|
|
assert settings.smoothing_window == 5 and settings.one_euro_beta == 0.0
|
|
with pytest.raises(ConfigurationError):
|
|
_make(HeadPoseSettings, {**legacy_section, "not_a_setting": 1}, "head_pose")
|
|
with pytest.raises(ConfigurationError):
|
|
_make(HeadPoseSettings, {key: value for key, value in legacy_section.items() if key != "method"}, "head_pose")
|
|
|
|
|
|
@pytest.mark.parametrize("override", [
|
|
{"smoothing_method": "kalman"},
|
|
{"smoothing_window": 0},
|
|
{"smoothing_window": 2.5},
|
|
{"smoothing_method": "hampel_ema", "smoothing_window": 2},
|
|
{"hampel_threshold": 0},
|
|
{"one_euro_min_cutoff": 0},
|
|
{"one_euro_d_cutoff": -1},
|
|
{"one_euro_beta": -0.1},
|
|
{"filter_reset_gap_sec": -1},
|
|
{"smoothing_window": True},
|
|
])
|
|
def test_invalid_filter_settings_are_rejected(override: dict[str, Any]) -> None:
|
|
with pytest.raises(ConfigurationError):
|
|
build(override)
|