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)