再投影誤差による破綻フレームの除外および窓の時間指定の実装と実験陽設定の更新

This commit is contained in:
agomizi1cm committed 2026-10-02 16:45:21 +09:00
1 parent d042ef9790
commit ca10876101
10 files changed
+256 -30

No files matched your search

+84
View File
@@ -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)
+45
View File
@@ -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