You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
99 lines
5.0 KiB
99 lines
5.0 KiB
from src.config_loader import TurnDetectionSettings
|
|
from src.models import FrameInfo, HeadPoseResult
|
|
from src.turn_detector import TurnDetector
|
|
|
|
|
|
def config(**overrides: object) -> TurnDetectionSettings:
|
|
values = dict(enabled=True, signage_yaw_direction="positive", baseline_strategy="first_valid",
|
|
baseline_window_sec=.8, baseline_min_samples=3, subtle_threshold_deg=10,
|
|
weak_threshold_deg=20, medium_threshold_deg=35, strong_threshold_deg=50,
|
|
min_subtle_duration_sec=.2, min_turn_duration_sec=.3, max_pose_gap_sec=.3,
|
|
min_pose_samples_for_evaluation=5, min_face_detection_rate_for_evaluation=.2,
|
|
min_pose_estimation_rate_for_evaluation=.2, face_appearance_enabled=True,
|
|
face_missing_before_appearance_sec=.3, weak_or_higher_is_turn=True)
|
|
values.update(overrides)
|
|
return TurnDetectionSettings(**values) # type: ignore[arg-type]
|
|
|
|
|
|
def info(frame: int, elapsed: float) -> FrameInfo:
|
|
return FrameInfo(frame, f"t{frame}", elapsed, 100, 100, 20)
|
|
|
|
|
|
def pose(frame: int, elapsed: float, yaw: float | None, face: bool = True) -> HeadPoseResult:
|
|
success = yaw is not None
|
|
return HeadPoseResult(1, frame, f"t{frame}", elapsed, face, success,
|
|
0 if success else None, yaw, 0 if success else None,
|
|
6 if face else 0, None, .9 if face else None,
|
|
"" if success else ("solvepnp_failed" if face else "face_not_detected"))
|
|
|
|
|
|
def feed(detector: TurnDetector, samples: list[tuple[float, float | None, bool]]) -> tuple[list, list]:
|
|
results, events = [], []
|
|
for frame, (elapsed, yaw, face) in enumerate(samples, 1):
|
|
frame_results, new_events = detector.update([pose(frame, elapsed, yaw, face)], info(frame, elapsed))
|
|
results.extend(frame_results)
|
|
events.extend(new_events)
|
|
return results, events
|
|
|
|
|
|
def test_first_valid_baseline_and_threshold_levels() -> None:
|
|
detector = TurnDetector(config())
|
|
results, events = feed(detector, [(0, 0, True), (.1, 2, True), (.2, -1, True), (.3, 9, True), (.4, 11, True)])
|
|
state = detector.get_state(1)
|
|
assert state and state.baseline_acquired and state.baseline_yaw == 0
|
|
assert state.baseline_sample_count == 3
|
|
assert sum(event.event_type == "baseline_acquired" for event in events) == 1
|
|
assert results[-2].turn_level == "none"
|
|
assert results[-1].turn_level == "subtle"
|
|
|
|
|
|
def test_baseline_requires_minimum_samples() -> None:
|
|
detector = TurnDetector(config(baseline_min_samples=3))
|
|
results, _ = feed(detector, [(0, 0, True), (.1, 1, True)])
|
|
assert not detector.get_state(1).baseline_acquired # type: ignore[union-attr]
|
|
assert results[-1].failure_reason == "baseline_not_acquired"
|
|
|
|
|
|
def test_weak_duration_confirms_once_and_tracks_max_delta() -> None:
|
|
detector = TurnDetector(config())
|
|
_, events = feed(detector, [(0, 0, True), (.1, 0, True), (.2, 0, True),
|
|
(.3, 25, True), (.45, 30, True), (.61, 40, True), (.8, 45, True)])
|
|
state = detector.get_state(1)
|
|
assert state and state.turn_confirmed and state.max_yaw_delta_toward_signage == 45
|
|
assert state.strongest_level == "medium"
|
|
assert sum(event.event_type == "turn_confirmed" for event in events) == 1
|
|
|
|
|
|
def test_short_candidate_and_pose_gap_behaviour() -> None:
|
|
detector = TurnDetector(config())
|
|
results, _ = feed(detector, [(0, 0, True), (.1, 0, True), (.2, 0, True),
|
|
(.3, 25, True), (.4, 25, True), (.5, None, False), (.61, 25, True)])
|
|
assert results[3].turn_candidate and not results[3].turn_confirmed
|
|
assert detector.get_state(1).turn_confirmed # type: ignore[union-attr]
|
|
detector2 = TurnDetector(config())
|
|
_, events = feed(detector2, [(0, 0, True), (.1, 0, True), (.2, 0, True),
|
|
(.3, 25, True), (.4, None, False), (.8, None, False)])
|
|
assert any(event.event_type == "turn_lost" for event in events)
|
|
|
|
|
|
def test_face_appeared_and_finalize_classifications() -> None:
|
|
detector = TurnDetector(config(min_pose_samples_for_evaluation=3))
|
|
_, events = feed(detector, [(0, None, False), (.4, 0, True), (.5, 0, True), (.6, 0, True)])
|
|
state = detector.get_state(1)
|
|
assert state and state.face_appeared_after_missing
|
|
assert any(event.event_type == "face_appeared" for event in events)
|
|
final, final_events = detector.finalize_track(1, info(5, .7))
|
|
assert final and final.valid_for_turn_analysis and final.exclusion_reason == "evaluable_no_turn"
|
|
assert final_events[0].event_type == "turn_track_finalized"
|
|
|
|
no_pose = TurnDetector(config())
|
|
no_pose.update([pose(1, 0, None, True)], info(1, 0))
|
|
final, _ = no_pose.finalize_track(1, info(2, .1))
|
|
assert final and not final.evaluable and final.exclusion_reason == "no_pose_estimated"
|
|
|
|
|
|
def test_baseline_not_acquired_finalize() -> None:
|
|
detector = TurnDetector(config(baseline_min_samples=3))
|
|
feed(detector, [(0, 0, True), (.1, 1, True)])
|
|
final, _ = detector.finalize_track(1, info(3, .2))
|
|
assert final and final.exclusion_reason == "baseline_not_acquired"
|
|
|