85 lines
4.0 KiB
Python
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)
|