"""추락(높이에서 떨어짐) 판정. 이 프로젝트가 잡고 있던 것은 같은 높이에서의 **넘어짐**이고, 산업재해 사망 1위 유형인 **떨어짐**은 잡지 못했다. GhostIncidentDetector 는 트랙이 이미 위험 자세일 때만 무장하므로, 발판 위에 서 있다가 떨어지는 사람은 소실 순간 STANDING 이라 아무것도 걸리지 않는다. 판정의 핵심은 bbox 중심이 아니라 **하단(y2, 발 밑선)** 이다: 쭈그려 앉기 → 중심은 내려가지만 발은 그 자리에 있다 추락 → 발 밑선이 통째로 내려간다 중심으로 판정하면 쭈그려 앉는 작업자가 전부 추락이 된다. 아래 테스트가 그 구분을 고정한다. """ from edge.detectors.height import DescentWatch, DescentConfig from edge.models import BBox, Keypoint from edge.tracking import Track, TrackFrame from collections import deque def _kp(): return [Keypoint(0.5, 0.5, 0.9) for _ in range(17)] def _track(track_id: int, boxes: list[tuple[float, BBox]]) -> Track: frames = deque(maxlen=90) for t, b in boxes: frames.append(TrackFrame(t, b, _kp())) return Track(track_id=track_id, frames=frames) def _box(y2: float, height: float, cx: float = 0.5) -> BBox: """발 밑선 y2 와 높이로 bbox 를 만든다 (좌표는 프레임 정규화 0..1).""" return BBox(x1=cx - 0.05, y1=y2 - height, x2=cx + 0.05, y2=y2) def _watch(**cfg): return DescentWatch(DescentConfig(**cfg)) # --- 잡아야 하는 것 ------------------------------------------------------------------- def test_rapid_drop_of_the_foot_line_is_a_descent(): w = _watch(drop_ratio=1.2, drop_window_s=1.2) # 키 0.25 인 사람의 발 밑선이 0.9초 만에 0.35(=1.4배) 내려간다 tr = _track(1, [(0.0, _box(0.30, 0.25)), (0.3, _box(0.42, 0.25)), (0.6, _box(0.55, 0.25)), (0.9, _box(0.65, 0.25))]) v = w.update(tr, now=0.9) assert v is not None assert v["drop_ratio"] >= 1.2 assert w.descending(1, now=0.9) is True def test_descent_stays_armed_for_the_link_window(): w = _watch(link_s=3.0) tr = _track(1, [(0.0, _box(0.30, 0.25)), (0.9, _box(0.65, 0.25))]) w.update(tr, now=0.9) assert w.descending(1, now=3.5) is True # 아직 연결 창 안 assert w.descending(1, now=4.5) is False # 창을 벗어남 # --- 잡으면 안 되는 것 ---------------------------------------------------------------- def test_crouching_is_not_a_descent(): # 쭈그려 앉으면 높이는 줄지만 발 밑선은 그 자리다. 중심으로 재면 이게 전부 추락이 된다. w = _watch() tr = _track(1, [(0.0, _box(0.60, 0.25)), (0.4, _box(0.60, 0.18)), (0.8, _box(0.60, 0.12)), (1.1, _box(0.60, 0.10))]) assert w.update(tr, now=1.1) is None def test_walking_down_stairs_is_not_a_descent(): # 계단은 내려가지만 창 안에서 1.2배에 못 미친다. w = _watch(drop_ratio=1.2, drop_window_s=1.2) tr = _track(1, [(0.0, _box(0.40, 0.25)), (0.4, _box(0.45, 0.25)), (0.8, _box(0.50, 0.25)), (1.2, _box(0.55, 0.25))]) assert w.update(tr, now=1.2) is None def test_walking_towards_the_camera_is_not_a_descent(): # 원근 접근은 발 밑선도 내려가지만 사람이 커진다. 높이 증가 가드가 막는다. w = _watch(drop_ratio=1.2, grow_guard=1.35) tr = _track(1, [(0.0, _box(0.30, 0.15)), (0.4, _box(0.50, 0.24)), (0.8, _box(0.68, 0.34))]) assert w.update(tr, now=0.8) is None def test_slow_drop_outside_the_window_is_not_a_descent(): w = _watch(drop_ratio=1.2, drop_window_s=1.2) tr = _track(1, [(0.0, _box(0.30, 0.25)), (2.0, _box(0.50, 0.25)), (4.0, _box(0.70, 0.25))]) assert w.update(tr, now=4.0) is None def test_forget_clears_state(): w = _watch() tr = _track(9, [(0.0, _box(0.30, 0.25)), (0.9, _box(0.65, 0.25))]) w.update(tr, now=0.9) assert w.descending(9, now=1.0) is True w.forget(9) assert w.descending(9, now=1.0) is False def test_single_frame_track_is_safe(): w = _watch() tr = _track(1, [(0.0, _box(0.30, 0.25))]) assert w.update(tr, now=0.0) is None