Files
SignageSystem/tests/test_head_pose_quality_gate.py
T

85 lines
4.0 KiB
Python

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)