Files
SignageSystem/tests/test_head_pose_smoothing.py
T

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)