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

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"