from pathlib import Path from typing import Any import pytest from src.config_loader import HeadPoseSettings, load_config from src.exceptions import ConfigurationError from src.face_processor import FaceProcessor from src.head_pose import HeadPoseEstimator 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 estimator(ratio: float) -> HeadPoseEstimator: return HeadPoseEstimator(HeadPoseSettings(True, "solvepnp", True, "ema", 0.4, 120.0, max_reprojection_error_ratio=ratio)) def test_new_options_default_to_the_previous_behaviour() -> None: settings = build().head_pose assert settings.smoothing_window_sec == 0.0 assert settings.max_reprojection_error_ratio == 0.0 @pytest.mark.parametrize("override", [{"smoothing_window_sec": -0.1}, {"max_reprojection_error_ratio": -1}, {"smoothing_window_sec": True}, {"max_reprojection_error_ratio": "0.3"}]) def test_invalid_new_options_are_rejected(override: dict[str, Any]) -> None: with pytest.raises(ConfigurationError): build(override) def test_reprojection_gate_rejects_only_degenerate_solutions() -> None: gate = estimator(0.3) normal = gate.result_from_angles((-9.0, -20.0, 1.0), reprojection_error=1.6, eye_distance_px=24.0) assert normal.success and normal.yaw == pytest.approx(-20.0) degenerate = gate.result_from_angles((-9.0, -66.0, 1.0), reprojection_error=2261.0, eye_distance_px=15.0) assert not degenerate.success assert degenerate.failure_reason == "reprojection_error_too_large" assert degenerate.reprojection_error == 2261.0 and degenerate.yaw == pytest.approx(-66.0) # Scales with the face size: 12 px is fine for a 60 px eye distance, not for a 24 px one. assert gate.result_from_angles((0.0, 0.0, 0.0), 12.0, 60.0).success assert not gate.result_from_angles((0.0, 0.0, 0.0), 12.0, 24.0).success # The limit itself is accepted (strictly greater is rejected); 0.5 * 24 is exact in floating point. assert estimator(0.5).result_from_angles((0.0, 0.0, 0.0), 12.0, 24.0).success assert not estimator(0.5).result_from_angles((0.0, 0.0, 0.0), 12.01, 24.0).success def test_reprojection_gate_is_off_by_default_and_needs_both_inputs() -> None: assert estimator(0.0).result_from_angles((0.0, 10.0, 0.0), 2261.0, 15.0).success assert estimator(0.3).result_from_angles((0.0, 10.0, 0.0), 2261.0, None).success assert estimator(0.3).result_from_angles((0.0, 10.0, 0.0), None, 24.0).success assert estimator(0.3).result_from_angles((0.0, 10.0, 0.0)).success # original call form def test_out_of_range_angles_are_still_reported_after_the_gate() -> None: result = estimator(0.3).result_from_angles((0.0, 150.0, 0.0), 1.0, 24.0) assert not result.success and result.failure_reason == "angle_out_of_range" def _spike_series(run: int) -> list[float]: return [-20.0] * 20 + [25.0] * run + [-20.0] * 20 @pytest.mark.parametrize("dt", [0.043, 0.0565]) def test_face_processor_time_window_removes_short_spike_runs(dt: float) -> None: config = build({"smoothing_method": "median_ema", "ema_alpha": 0.5, "smoothing_window_sec": 0.55}) proc = FaceProcessor(config.face, config.head_pose) outputs = [proc._smooth(1, 0.0, value, 0.0, index * dt)[1] for index, value in enumerate(_spike_series(3))] assert max(abs(value + 20.0) for value in outputs) < 1e-6 count_based = processor_with({"smoothing_method": "median_ema", "ema_alpha": 0.5, "smoothing_window": 5}) plain = [count_based._smooth(1, 0.0, value, 0.0, index * dt)[1] for index, value in enumerate(_spike_series(3))] assert max(abs(value + 20.0) for value in plain) > 10.0 def processor_with(head_pose: dict[str, Any]) -> FaceProcessor: config = build(head_pose) return FaceProcessor(config.face, config.head_pose)