再投影誤差による破綻フレームの除外および窓の時間指定の実装と実験陽設定の更新
This commit is contained in:
1 parent
d042ef9790
commit
ca10876101
10 files changed
+256
-30
No files matched your search
@@ -0,0 +1,84 @@
|
||||
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)
|
||||
@@ -77,3 +77,48 @@ def test_reset_and_validation() -> None:
|
||||
create_angle_filter("hampel_ema", window=2)
|
||||
with pytest.raises(ValueError):
|
||||
create_angle_filter("one_euro", one_euro_min_cutoff=0.0)
|
||||
|
||||
|
||||
def _spike_series(run: int) -> list[float]:
|
||||
return [-20.0] * 15 + [25.0] * run + [-20.0] * 15
|
||||
|
||||
|
||||
def _max_dev(kind: str, values: list[float], dt: float, **params: float) -> float:
|
||||
out = _run(kind, values, dt=dt, **params)
|
||||
return max(abs(v + 20.0) for v in out)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dt", [0.043, 0.0565])
|
||||
def test_time_window_removes_spike_runs_at_any_update_rate(dt: float) -> None:
|
||||
# 0.45 s holds >= 7 samples at both ~23 Hz and ~17.7 Hz, enough for a 3-sample run.
|
||||
assert _max_dev("median_ema", _spike_series(3), dt, alpha=0.5, window=5, window_sec=0.45) < 1e-6
|
||||
# A fixed 5-sample window cannot remove a 3-sample run.
|
||||
assert _max_dev("median_ema", _spike_series(3), dt, alpha=0.5, window=5) > 10.0
|
||||
|
||||
|
||||
def test_time_window_adapts_its_sample_count_to_the_rate() -> None:
|
||||
fast = create_angle_filter("median_ema", alpha=1.0, window=5, window_sec=0.45)
|
||||
slow = create_angle_filter("median_ema", alpha=1.0, window=5, window_sec=0.45)
|
||||
# 4-sample spike run: removed at 23 Hz (11-sample window) but not at ~17.7 Hz (7 samples).
|
||||
fast_out = [fast.update(v, i * 0.043) for i, v in enumerate(_spike_series(4))]
|
||||
slow_out = [slow.update(v, i * 0.0565) for i, v in enumerate(_spike_series(4))]
|
||||
assert max(abs(v + 20.0) for v in fast_out) < 1e-6
|
||||
assert max(abs(v + 20.0) for v in slow_out) > 10.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["median_ema", "hampel_ema", "median_one_euro"])
|
||||
def test_time_window_keeps_constant_signal_and_follows_steps(kind: str) -> None:
|
||||
out = _run(kind, [0.0] * 20 + [30.0] * 60, dt=0.043, alpha=0.5, window=5, window_sec=0.45)
|
||||
assert out[19] == pytest.approx(0.0, abs=1e-6)
|
||||
assert out[-1] == pytest.approx(30.0, abs=0.5)
|
||||
assert _run(kind, [7.0] * 20, dt=0.043, window_sec=0.45) == pytest.approx([7.0] * 20)
|
||||
|
||||
|
||||
def test_time_window_validation_and_reset() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
create_angle_filter("median_ema", window_sec=-0.1)
|
||||
f = create_angle_filter("median_ema", alpha=1.0, window_sec=0.45, reset_gap_sec=1.0)
|
||||
for i in range(10):
|
||||
f.update(0.0, i * 0.043)
|
||||
f.reset()
|
||||
assert f.update(40.0, 5.0) == 40.0
|
||||
Reference in new issue
Block a user