4개 관점 병렬 감사 후 각 발견을 반증 시도해 확정. 반증 5건은 문서에 남겼다. 이번 감사의 요지: **구현도 단위테스트도 초록인데 납품 경로에서 동작하지 않는** 결함이 셋 있었다. 전부 같은 모양이다 — 테스트가 기본값이 아닌 설정을 썼거나, 값이 존재해도 되는지 결정하는 상류 허용 목록을 보지 않았다. CRITICAL 4건: R4-1 추락 승격이 기본 설정에서 한 번도 발화하지 않았다. link_s(3.0)를 now 기준으로 쟀는데 넘어짐은 prone_confirm_s(3.0) 지속 후 확정되므로 확정 시점에는 항상 3초 넘게 지나 있다. 다른 테스트가 전부 prone_confirm_s=0.5 를 써서 초록이었다. 착지(prone 시작) 시각과 비교하도록 고치고, 기본값 그대로 도는 회귀 테스트를 넣었다. R4-2 max_workers:0 이 상류 두 곳에서 막혀 있었다 — 서버 규칙 경계가 [1,50] 이라 저장이 400 이고, 엣지 파서가 v<=0 으로 규칙 전체를 무효화해 같이 넣은 2인1조까지 사라졌다. 앞 커밋에서 고친 네 곳은 전부 이 둘보다 하류라 도달 불가였다. R4-3 고소작업 프리셋(elevated)이 완전히 무동작이었다. 서버 경계에 키가 없어 400, 통과해도 엣지 파서가 침묵 삭제. 프리셋 안내문과 커밋 메시지가 시스템이 하지 않는 일을 약속하고 있었다. R4-4 동시 다발이 한 사람을 두 명으로 셀 수 있었다. 트래커는 1초 가려짐에 트랙을 버리고 새 id 를 주고(max_age=1.0) 누운 사람은 새 트랙에서도 즉시 prone 이라 두 번째 fall 이 난다. 그러면 다친 한 사람에게 "공기호흡기 없이 진입 금지"가 나가 구조가 늦어진다 — 기능의 가치가 정반대로 뒤집힌다. ghost 를 인원 집계에서 빼고, 거의 같은 자리의 사건은 같은 사람으로 본다. HIGH 5건: 구역 그룹핑 사문화(엣지가 zone_id 를 안 실어 카메라가 다른 두 희생자가 절대 안 묶였다 — 밀폐공간이 정확히 그 상황), 창 미끄러짐으로 한 사고가 매 분 새 위급 건으로 쏟아지고 인원이 줄면 앞선 알림을 스스로 뒤집던 문제, 무장 미만료·forget 누락·이중 발보, 업그레이드 안내의 마이그레이션 2개 누락(신규 유형 INSERT 가 전부 DB CHECK 에 걸린다), BUILD-INFO 커밋 스탬프. BUILD-INFO 는 1.1.0 에서 고친 R3-6 의 재발이다. 절차로 막으려다 두 번 실패했으므로 HEAD 일치를 검사하는 테스트로 막았다. README 마이그레이션 목록도 같은 이유로 테스트를 붙였다. MEDIUM 14건 중 눈에 띄는 것: 정비 억제가 SCBA 경고를 삼켰고(허가가 발급된 그 구역·그 시간이 질식이 일어나는 자리다) 위급 3종을 억제 예외로 뺐다. 데모 시더 심각도가 제품과 7종 어긋나 fall 이 위급으로 찍혀 1.2.0 의 요점인 넘어짐/추락 구분이 데모에서 지워져 있었다 — 라벨과 달리 화면이 안 깨져 눈으로는 못 잡으므로 엣지를 단일 출처로 강제하는 테스트를 붙였다. 문서의 "17종"은 실제 20종(엣지 16 + 서버 4)으로 정정하고 신규 2종을 소개서 감지 표에 넣었다. 검증: pytest 335 passed / phpunit 269 passed. 신규 테스트 30건. dist 는 이 커밋 이후 재생성한다(BUILD-INFO 가 HEAD 를 가리켜야 한다). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EBjuR7DW6PNNMows4KnbYU
482 lines
22 KiB
Python
482 lines
22 KiB
Python
from collections import deque
|
|
from edge.models import Keypoint, BBox
|
|
from edge.tracking import Track, TrackFrame
|
|
from edge.events import EventType, Severity
|
|
from edge.detectors.fall import FallDetector, FallConfig
|
|
from edge.detectors.immobility import ImmobilityWatchdog, ImmobilityConfig
|
|
from edge.detectors.ghost import GhostIncidentDetector, GhostConfig
|
|
from edge.detectors.zone import ZoneIntrusionDetector, ZoneConfig, Zone
|
|
from edge.engine import EventEngine, EngineConfig
|
|
|
|
def _lying_kps():
|
|
kps = [Keypoint(150, 300, 0.9) for _ in range(17)]
|
|
kps[5] = Keypoint(100, 300, 0.9); kps[6] = Keypoint(100, 310, 0.9)
|
|
kps[11] = Keypoint(200, 300, 0.9); kps[12] = Keypoint(200, 310, 0.9)
|
|
kps[0] = Keypoint(90, 320, 0.9)
|
|
return kps
|
|
|
|
def _lying_track(track, t):
|
|
track.frames.append(TrackFrame(t, BBox(90, 290, 210, 320), _lying_kps()))
|
|
return track
|
|
|
|
def _standing_kps(cx=150):
|
|
kps = [Keypoint(cx, 150, 0.9) for _ in range(17)]
|
|
kps[5] = Keypoint(cx - 10, 100, 0.9); kps[6] = Keypoint(cx + 10, 100, 0.9) # shoulders above
|
|
kps[11] = Keypoint(cx - 10, 200, 0.9); kps[12] = Keypoint(cx + 10, 200, 0.9) # hips below
|
|
kps[0] = Keypoint(cx, 60, 0.9) # head above hips
|
|
return kps
|
|
|
|
def _standing_track(track, t, cx=150):
|
|
track.frames.append(TrackFrame(t, BBox(cx - 20, 60, cx + 20, 210), _standing_kps(cx)))
|
|
return track
|
|
|
|
def _engine():
|
|
return EventEngine(
|
|
FallDetector(FallConfig(prone_confirm_s=0.5)),
|
|
ImmobilityWatchdog(ImmobilityConfig(immobile_s=999)),
|
|
GhostIncidentDetector(GhostConfig()),
|
|
ZoneIntrusionDetector([], ZoneConfig()),
|
|
EngineConfig(camera_id="cam-01", cooldown_s=30.0),
|
|
)
|
|
|
|
def test_fall_produces_fall_event():
|
|
eng = _engine()
|
|
tr = Track(1, deque(maxlen=90))
|
|
_lying_track(tr, 0.0)
|
|
eng.process([tr], [], now=0.0)
|
|
_lying_track(tr, 0.6)
|
|
events = eng.process([tr], [], now=0.6)
|
|
types = [e.event_type for e in events]
|
|
assert EventType.FALL in types
|
|
assert events[0].camera_id == "cam-01"
|
|
|
|
def test_cooldown_suppresses_duplicate():
|
|
eng = _engine()
|
|
tr = Track(1, deque(maxlen=90))
|
|
_lying_track(tr, 0.0); eng.process([tr], [], now=0.0)
|
|
_lying_track(tr, 0.6); eng.process([tr], [], now=0.6) # FALL 1회
|
|
_lying_track(tr, 0.7)
|
|
again = eng.process([tr], [], now=0.7)
|
|
assert all(e.event_type != EventType.FALL for e in again)
|
|
|
|
def test_no_motion_event_carries_duration():
|
|
eng = EventEngine(
|
|
FallDetector(FallConfig(prone_confirm_s=0.5, no_motion_s=1.0, motion_thr=0.05)),
|
|
ImmobilityWatchdog(ImmobilityConfig(immobile_s=999)),
|
|
GhostIncidentDetector(GhostConfig()),
|
|
ZoneIntrusionDetector([], ZoneConfig()),
|
|
EngineConfig(camera_id="cam-01", cooldown_s=30.0),
|
|
)
|
|
tr = Track(1, deque(maxlen=90))
|
|
_lying_track(tr, 0.0); eng.process([tr], [], now=0.0)
|
|
_lying_track(tr, 0.6); eng.process([tr], [], now=0.6) # -> fall
|
|
_lying_track(tr, 1.0); eng.process([tr], [], now=1.0) # still
|
|
_lying_track(tr, 2.0)
|
|
events = eng.process([tr], [], now=2.0) # immobile >1.0s -> no_motion
|
|
no_motion_events = [e for e in events if e.event_type == EventType.NO_MOTION]
|
|
assert len(no_motion_events) == 1
|
|
e = no_motion_events[0]
|
|
assert e.duration_s > 0
|
|
assert e.duration_s == e.metrics["immobility_s"]
|
|
|
|
|
|
def test_forget_prunes_state_on_lost():
|
|
eng = _engine()
|
|
tr = Track(1, deque(maxlen=90))
|
|
_lying_track(tr, 0.0); eng.process([tr], [], now=0.0)
|
|
_lying_track(tr, 0.6)
|
|
events = eng.process([tr], [], now=0.6) # FALL fires
|
|
assert any(e.event_type == EventType.FALL for e in events)
|
|
assert (EventType.FALL, 1) in eng._last_emit
|
|
assert 1 in eng.fall._states
|
|
|
|
# track is now lost -> engine must prune its per-track state
|
|
eng.process([], [tr], now=1.0)
|
|
assert not any(len(k) >= 2 and k[1] == 1 for k in eng._last_emit)
|
|
assert 1 not in eng.fall._states
|
|
|
|
|
|
def test_pinch_zone_emits_pinch_event_not_warning():
|
|
# A 협착(pinch) zone is the highest-danger zone type (machinery entrapment). It must fire
|
|
# as PINCH/HIGH, not be silently downgraded to a generic ZONE_WARNING.
|
|
poly = [(100, 100), (200, 100), (200, 200), (100, 200)]
|
|
zdet = ZoneIntrusionDetector([Zone("z1", "pinch", poly)], ZoneConfig(debounce_s=0.0))
|
|
eng = EventEngine(
|
|
FallDetector(FallConfig(prone_confirm_s=999)), # keep fall/no_motion quiet
|
|
ImmobilityWatchdog(ImmobilityConfig(immobile_s=999)),
|
|
GhostIncidentDetector(GhostConfig()),
|
|
zdet, EngineConfig(camera_id="cam-01", cooldown_s=30.0))
|
|
kps = [Keypoint(150, 150, 0.9) for _ in range(17)] # both ankles inside the zone
|
|
tr = Track(2, deque(maxlen=90))
|
|
tr.frames.append(TrackFrame(0.0, BBox(140, 140, 160, 160), kps))
|
|
events = eng.process([tr], [], now=0.0)
|
|
pinch = [e for e in events if e.event_type == EventType.PINCH]
|
|
assert len(pinch) == 1
|
|
assert pinch[0].severity == Severity.HIGH
|
|
assert pinch[0].zone_id == "z1"
|
|
assert not any(e.event_type == EventType.ZONE_WARNING for e in events)
|
|
|
|
|
|
def test_ghost_emit_does_not_leak_a_permanent_dedup_key():
|
|
# A GHOST fires reappear_s AFTER the track was lost (and already forgotten). Routing it through
|
|
# the engine cooldown used to insert a (GHOST, track_id) key that forget() could never reclaim
|
|
# (the track never appears in lost_tracks again) — a slow 24/7 memory leak. Emitting directly
|
|
# must leave no such key behind.
|
|
eng = _engine()
|
|
tr = Track(1, deque(maxlen=90))
|
|
_lying_track(tr, 0.0); eng.process([tr], [], now=0.0)
|
|
_lying_track(tr, 0.6); eng.process([tr], [], now=0.6) # confirmed fallen -> dangerous state
|
|
eng.process([], [tr], now=1.0) # lost -> ghost candidate registered
|
|
events = eng.process([], [], now=12.0) # > reappear_s (10s), no reappearance
|
|
assert any(e.event_type == EventType.GHOST for e in events)
|
|
assert not any(k[0] == EventType.GHOST for k in eng._last_emit) # no leaked ghost dedup key
|
|
|
|
|
|
def test_two_simultaneous_tracks_have_independent_fall_state():
|
|
# Per-track detector state must not leak between two people monitored at once: a fall on track A
|
|
# must arm and emit for A only, never for a standing track B. All other engine/detector tests
|
|
# exercise a single person, so this pins the multi-person isolation the fall/immobility/zone
|
|
# timers rely on (they key every bit of state off track_id).
|
|
eng = _engine() # prone_confirm_s=0.5
|
|
a = Track(1, deque(maxlen=90)); b = Track(2, deque(maxlen=90))
|
|
_lying_track(a, 0.0); _standing_track(b, 0.0, cx=500)
|
|
eng.process([a, b], [], now=0.0)
|
|
_lying_track(a, 0.6); _standing_track(b, 0.6, cx=500)
|
|
events = eng.process([a, b], [], now=0.6)
|
|
falls = [e for e in events if e.event_type == EventType.FALL]
|
|
assert len(falls) == 1 # exactly one fall...
|
|
assert falls[0].track_id == 1 # ...for the lying track A, not the standing track B
|
|
|
|
|
|
def test_fall_fires_at_exactly_prone_confirm_s_inclusive_boundary():
|
|
# The confirm is `now - prone_since >= prone_confirm_s`, so a fall must fire at EXACTLY the
|
|
# threshold, not one frame later. Pins the inclusive boundary against a >= -> > regression that
|
|
# would silently delay every fall confirmation by a frame — up to +0.5s at a thermally throttled
|
|
# 2 fps, on the single most safety-critical event. Existing tests only feed comfortably-past
|
|
# values (0.6 vs 0.5), so none of them would catch that shift.
|
|
eng = _engine() # prone_confirm_s=0.5
|
|
tr = Track(1, deque(maxlen=90))
|
|
_lying_track(tr, 0.0); eng.process([tr], [], now=0.0) # prone_since = 0.0
|
|
_lying_track(tr, 0.5) # exactly prone_confirm_s elapsed
|
|
events = eng.process([tr], [], now=0.5)
|
|
assert any(e.event_type == EventType.FALL for e in events)
|
|
|
|
|
|
def test_occurred_at_uses_wall_clock_while_timers_use_monotonic():
|
|
# A fall is CONFIRMED by the monotonic timer (prone_confirm_s over the injected `now`), but the
|
|
# event must be STAMPED with wall time so an NTP step does not mis-timestamp evidence (and the
|
|
# server's wall-time retention purge does not delete it). Timer `now` and `wall_now` are
|
|
# independent axes here to prove occurred_at follows wall time, not the monotonic clock.
|
|
eng = _engine() # prone_confirm_s=0.5
|
|
tr = Track(1, deque(maxlen=90))
|
|
_lying_track(tr, 0.0)
|
|
eng.process([tr], [], now=0.0, wall_now=1_700_000_000.0)
|
|
_lying_track(tr, 0.6)
|
|
events = eng.process([tr], [], now=0.6, wall_now=1_700_000_000.6)
|
|
fall = [e for e in events if e.event_type == EventType.FALL]
|
|
assert len(fall) == 1
|
|
assert fall[0].occurred_at == 1_700_000_000.6 # wall time, NOT the monotonic 0.6
|
|
|
|
|
|
def test_process_defaults_occurred_at_to_now_when_no_wall_clock():
|
|
# Single-clock callers (unit tests, direct callers) omit wall_now — occurred_at must fall back
|
|
# to the timer `now` so existing behavior is unchanged.
|
|
eng = _engine()
|
|
tr = Track(1, deque(maxlen=90))
|
|
_lying_track(tr, 0.0); eng.process([tr], [], now=0.0)
|
|
_lying_track(tr, 0.6)
|
|
events = eng.process([tr], [], now=0.6)
|
|
fall = [e for e in events if e.event_type == EventType.FALL]
|
|
assert len(fall) == 1 and fall[0].occurred_at == 0.6
|
|
|
|
|
|
def test_lone_worker_rule_fires_through_engine():
|
|
# End-to-end wiring of the occupancy rules: a 2인 1조 zone with one standing worker inside
|
|
# past the confirm window must emit LONE_WORKER/HIGH with the zone id attached.
|
|
from edge.detectors.occupancy import OccupancyWatchdog, OccupancyConfig
|
|
poly = [(400, 100), (600, 100), (600, 300), (400, 300)]
|
|
zdet = ZoneIntrusionDetector([Zone("z1", "restricted", poly, {"min_workers": 2})],
|
|
ZoneConfig(debounce_s=0.0))
|
|
eng = EventEngine(
|
|
FallDetector(FallConfig(prone_confirm_s=999)),
|
|
ImmobilityWatchdog(ImmobilityConfig(immobile_s=999)),
|
|
GhostIncidentDetector(GhostConfig()),
|
|
zdet, EngineConfig(camera_id="cam-01", cooldown_s=30.0),
|
|
occupancy=OccupancyWatchdog(OccupancyConfig(lone_confirm_s=5.0)))
|
|
tr = Track(1, deque(maxlen=90))
|
|
_standing_track(tr, 0.0, cx=500) # ankles at (490,200)/(510,200) — inside the zone
|
|
events = eng.process([tr], [], now=0.0)
|
|
assert not any(e.event_type == EventType.LONE_WORKER for e in events)
|
|
_standing_track(tr, 6.0, cx=500)
|
|
events = eng.process([tr], [], now=6.0)
|
|
lone = [e for e in events if e.event_type == EventType.LONE_WORKER]
|
|
assert len(lone) == 1
|
|
assert lone[0].severity == Severity.HIGH
|
|
assert lone[0].zone_id == "z1" and lone[0].track_id == 1
|
|
assert lone[0].metrics["required"] == 2
|
|
|
|
|
|
def test_fall_event_carries_normalized_foot_spatial():
|
|
# Every per-track event now carries the foot point in the zones' 0..1 space — the raw
|
|
# material of the server-side heatmap. frame_wh drives the normalization.
|
|
eng = _engine() # prone_confirm_s=0.5
|
|
tr = Track(1, deque(maxlen=90))
|
|
_lying_track(tr, 0.0); eng.process([tr], [], now=0.0, frame_wh=(640, 360))
|
|
_lying_track(tr, 0.6)
|
|
events = eng.process([tr], [], now=0.6, frame_wh=(640, 360))
|
|
fall = [e for e in events if e.event_type == EventType.FALL][0]
|
|
assert fall.spatial is not None
|
|
x, y = fall.spatial
|
|
assert 0.0 <= x <= 1.0 and 0.0 <= y <= 1.0
|
|
|
|
|
|
def test_near_fall_emits_info_event():
|
|
eng = EventEngine(
|
|
FallDetector(FallConfig(prone_confirm_s=5.0, falling_confirm_s=0.5, recover_grace_s=1.0)),
|
|
ImmobilityWatchdog(ImmobilityConfig(immobile_s=999)),
|
|
GhostIncidentDetector(GhostConfig()),
|
|
ZoneIntrusionDetector([], ZoneConfig()),
|
|
EngineConfig(camera_id="cam-01", cooldown_s=30.0))
|
|
tr = Track(1, deque(maxlen=90))
|
|
_lying_track(tr, 0.0); eng.process([tr], [], now=0.0)
|
|
_lying_track(tr, 1.0); eng.process([tr], [], now=1.0) # FALLING
|
|
_standing_track(tr, 2.0); eng.process([tr], [], now=2.0) # 회복 시작
|
|
_standing_track(tr, 3.5)
|
|
events = eng.process([tr], [], now=3.5) # grace 경과 → near_fall
|
|
near = [e for e in events if e.event_type == EventType.NEAR_FALL]
|
|
assert len(near) == 1
|
|
assert near[0].severity == Severity.INFO
|
|
assert near[0].metrics["prone_s"] == 2.0
|
|
|
|
|
|
def test_ghost_spatial_is_normalized_with_frame_size():
|
|
eng = _engine()
|
|
tr = Track(1, deque(maxlen=90))
|
|
_lying_track(tr, 0.0); eng.process([tr], [], now=0.0, frame_wh=(640, 360))
|
|
_lying_track(tr, 0.6); eng.process([tr], [], now=0.6, frame_wh=(640, 360)) # FALLEN
|
|
eng.process([], [tr], now=1.0, frame_wh=(640, 360)) # lost while dangerous
|
|
events = eng.process([], [], now=12.0, frame_wh=(640, 360))
|
|
ghost = [e for e in events if e.event_type == EventType.GHOST][0]
|
|
x, y = ghost.spatial
|
|
assert 0.0 <= x <= 1.0 and 0.0 <= y <= 1.0 # not raw pixels (150, 305)
|
|
|
|
|
|
def test_ergo_report_emitted_anonymous_and_info():
|
|
from edge.detectors.ergonomics import ErgoMonitor, ErgoConfig
|
|
eng = EventEngine(
|
|
FallDetector(FallConfig(prone_confirm_s=999)),
|
|
ImmobilityWatchdog(ImmobilityConfig(immobile_s=999)),
|
|
GhostIncidentDetector(GhostConfig()),
|
|
ZoneIntrusionDetector([], ZoneConfig()),
|
|
EngineConfig(camera_id="cam-01"),
|
|
ergonomics=ErgoMonitor(ErgoConfig(report_interval_s=0.5)))
|
|
tr = Track(1, deque(maxlen=90))
|
|
_standing_track(tr, 0.0); eng.process([tr], [], now=0.0)
|
|
_standing_track(tr, 0.6)
|
|
events = eng.process([tr], [], now=0.6)
|
|
rep = [e for e in events if e.event_type == EventType.ERGO_REPORT]
|
|
assert len(rep) == 1
|
|
assert rep[0].severity == Severity.INFO
|
|
assert rep[0].track_id is None and rep[0].spatial is None # 익명 합계: 개인 좌표 없음
|
|
assert rep[0].metrics["observed_s"] == 0.6
|
|
|
|
|
|
def test_incident_state_track_does_not_accrue_ergo_exposure():
|
|
# 쓰러진 사람의 90° 몸통은 인체공학 노출이 아니라 사고다 — FALLING 이후는 집계 제외.
|
|
from edge.detectors.ergonomics import ErgoMonitor, ErgoConfig
|
|
eng = EventEngine(
|
|
FallDetector(FallConfig(prone_confirm_s=999, falling_confirm_s=0.05)),
|
|
ImmobilityWatchdog(ImmobilityConfig(immobile_s=999)),
|
|
GhostIncidentDetector(GhostConfig()),
|
|
ZoneIntrusionDetector([], ZoneConfig()),
|
|
EngineConfig(camera_id="cam-01"),
|
|
ergonomics=ErgoMonitor(ErgoConfig(report_interval_s=0.2)))
|
|
tr = Track(1, deque(maxlen=90))
|
|
for t in (0.0, 0.1, 0.2, 0.3):
|
|
_lying_track(tr, t)
|
|
events = eng.process([tr], [], now=t)
|
|
assert not any(e.event_type == EventType.ERGO_REPORT for e in events)
|
|
|
|
|
|
def test_allow_cooldown_primitive():
|
|
eng = _engine() # EngineConfig(camera_id="cam-01", cooldown_s=30.0)
|
|
key = ("FALL", 1)
|
|
assert eng._allow(key, now=0.0) is True # first time allowed
|
|
assert eng._allow(key, now=5.0) is False # within cooldown -> suppressed
|
|
assert eng._allow(key, now=40.0) is True # after cooldown_s elapsed -> allowed again
|
|
# a different key is independent
|
|
assert eng._allow(("FALL", 2), now=5.0) is True
|
|
|
|
|
|
# --- 추락 (fall_from_height) ----------------------------------------------------------
|
|
#
|
|
# 같은 높이의 '넘어짐'과 높이에서의 '떨어짐'은 다른 사건이다. 떨어짐은 척추 손상 가능성이
|
|
# 있어 함부로 옮기면 안 되고, 그 주의가 통보에 실리려면 유형과 심각도가 갈라져야 한다.
|
|
# 발보 경로는 두 갈래다: 프레임 안에서 확정 / 화각 밖으로 사라짐.
|
|
|
|
from edge.detectors.height import DescentWatch, DescentConfig
|
|
|
|
|
|
def _height_engine(zones=None):
|
|
return EventEngine(
|
|
FallDetector(FallConfig(prone_confirm_s=0.5)),
|
|
ImmobilityWatchdog(ImmobilityConfig(immobile_s=999)),
|
|
GhostIncidentDetector(GhostConfig(reappear_s=1.0)),
|
|
ZoneIntrusionDetector(zones or [], ZoneConfig()),
|
|
EngineConfig(camera_id="cam-01", cooldown_s=30.0),
|
|
descent=DescentWatch(DescentConfig(drop_ratio=1.2, drop_window_s=1.5, link_s=3.0)),
|
|
)
|
|
|
|
|
|
def _falling_track(track, t, y2, height=150.0, cx=150.0):
|
|
"""발 밑선 y2 로 서 있는 사람 프레임을 붙인다."""
|
|
track.frames.append(TrackFrame(t, BBox(cx - 20, y2 - height, cx + 20, y2),
|
|
_standing_kps(int(cx))))
|
|
return track
|
|
|
|
|
|
def test_visible_drop_then_prone_is_fall_from_height_not_fall():
|
|
eng = _height_engine()
|
|
tr = Track(1, deque(maxlen=90))
|
|
_falling_track(tr, 0.0, y2=200.0) # 발판 위
|
|
_falling_track(tr, 0.5, y2=320.0)
|
|
_falling_track(tr, 1.0, y2=420.0) # 220px 하강 = 키의 1.47배
|
|
eng.process([tr], [], now=1.0)
|
|
_lying_track(tr, 1.2)
|
|
eng.process([tr], [], now=1.2) # prone 시작
|
|
_lying_track(tr, 2.0)
|
|
out = eng.process([tr], [], now=2.0) # 0.5초 지속 -> 확정
|
|
|
|
types = [e.event_type for e in out]
|
|
assert EventType.FALL_FROM_HEIGHT in types, types
|
|
assert EventType.FALL not in types, "한 번의 사고가 두 건으로 올라가면 안 된다"
|
|
ev = next(e for e in out if e.event_type == EventType.FALL_FROM_HEIGHT)
|
|
assert ev.severity == Severity.CRITICAL
|
|
assert ev.metrics["drop_ratio"] >= 1.2
|
|
|
|
|
|
def test_plain_fall_without_descent_stays_fall():
|
|
eng = _height_engine()
|
|
tr = Track(2, deque(maxlen=90))
|
|
_lying_track(tr, 0.0)
|
|
eng.process([tr], [], now=0.0)
|
|
_lying_track(tr, 0.6)
|
|
out = eng.process([tr], [], now=0.6)
|
|
assert [e.event_type for e in out] == [EventType.FALL]
|
|
|
|
|
|
def test_drop_then_vanish_emits_fall_from_height_not_ghost():
|
|
# 화각 밖으로 떨어진 사람에게는 이쪽이 유일한 발보 경로다.
|
|
eng = _height_engine()
|
|
tr = Track(3, deque(maxlen=90))
|
|
_falling_track(tr, 0.0, y2=200.0)
|
|
_falling_track(tr, 0.5, y2=330.0)
|
|
_falling_track(tr, 0.9, y2=430.0)
|
|
eng.process([tr], [], now=0.9) # 하강 확정 = ghost 무장
|
|
eng.process([], [tr], now=1.0) # 소실
|
|
out = eng.process([], [], now=3.0) # 재출현 대기 초과
|
|
|
|
types = [e.event_type for e in out]
|
|
assert EventType.FALL_FROM_HEIGHT in types, types
|
|
assert EventType.GHOST not in types
|
|
ev = next(e for e in out if e.event_type == EventType.FALL_FROM_HEIGHT)
|
|
assert ev.metrics["exited_frame"] is True
|
|
assert ev.metrics["armed_by"] == "descent"
|
|
|
|
|
|
def test_standing_person_vanishing_without_descent_is_not_a_height_fall():
|
|
# 장애물 뒤로 걸어 들어간 사람까지 추락으로 올리면 안 된다.
|
|
eng = _height_engine()
|
|
tr = Track(4, deque(maxlen=90))
|
|
_falling_track(tr, 0.0, y2=200.0)
|
|
_falling_track(tr, 1.0, y2=205.0)
|
|
eng.process([tr], [], now=1.0)
|
|
eng.process([], [tr], now=1.1)
|
|
out = eng.process([], [], now=3.0)
|
|
assert [e.event_type for e in out] == []
|
|
|
|
|
|
def test_elevated_zone_arms_without_a_visible_drop():
|
|
# 발판 위에서 화각 밖으로 사라지면 하강을 못 봤어도 추락 후보다.
|
|
poly = [(0.0, 0.0), (400.0, 0.0), (400.0, 400.0), (0.0, 400.0)]
|
|
zones = [Zone("zhigh", "restricted", poly, {"elevated": True})]
|
|
eng = _height_engine(zones)
|
|
tr = Track(5, deque(maxlen=90))
|
|
_falling_track(tr, 0.0, y2=200.0)
|
|
_falling_track(tr, 1.0, y2=205.0)
|
|
eng.process([tr], [], now=1.0)
|
|
eng.process([], [tr], now=1.1)
|
|
out = eng.process([], [], now=3.0)
|
|
|
|
fh = [e for e in out if e.event_type == EventType.FALL_FROM_HEIGHT]
|
|
assert len(fh) == 1, [e.event_type for e in out]
|
|
assert fh[0].metrics["armed_by"] == "zone"
|
|
|
|
|
|
def test_height_fall_upgrade_works_at_PRODUCTION_defaults():
|
|
"""기본 설정 그대로 — 이 테스트가 없어서 승격이 죽어 있는 채로 커밋됐다.
|
|
|
|
첫 구현은 하강을 `now` 와 비교했다. 넘어짐은 prone_confirm_s(기본 3초) 지속 후에 확정되므로
|
|
확정 시점에는 하강으로부터 항상 3초 넘게 지나 있었고, link_s 도 3.0 이라 승격이 **한 번도**
|
|
일어나지 않았다. 다른 테스트들이 prone_confirm_s=0.5 를 써서 전부 초록이었다.
|
|
|
|
그래서 이 테스트는 감지기 설정을 건드리지 않는다. 기본값끼리 부딪히는 것이 요점이다.
|
|
"""
|
|
eng = EventEngine(
|
|
FallDetector(FallConfig()), # ← 기본값. 절대 낮추지 말 것.
|
|
ImmobilityWatchdog(ImmobilityConfig(immobile_s=999)),
|
|
GhostIncidentDetector(GhostConfig()),
|
|
ZoneIntrusionDetector([], ZoneConfig()),
|
|
EngineConfig(camera_id="cam-01", cooldown_s=30.0),
|
|
descent=DescentWatch(DescentConfig()), # ← 기본값
|
|
)
|
|
tr = Track(77, deque(maxlen=90))
|
|
# 발판(y2=200)에서 바닥(y2=410)으로 1.17초 낙하 — 키 150px 대비 1.4배
|
|
for t, y2 in [(0.0, 200), (0.25, 250), (0.5, 310), (0.83, 370), (1.17, 410)]:
|
|
_falling_track(tr, t, y2=float(y2))
|
|
eng.process([tr], [], now=t)
|
|
# 착지 후 12fps 로 계속 누워 있음
|
|
t = 1.17
|
|
out = []
|
|
while t < 8.0 and not out:
|
|
t = round(t + 1 / 12, 3)
|
|
_lying_track(tr, t)
|
|
out = eng.process([tr], [], now=t)
|
|
|
|
types = [e.event_type for e in out]
|
|
assert EventType.FALL_FROM_HEIGHT in types, f"기본 설정에서 승격 실패: {types}"
|
|
assert EventType.FALL not in types
|
|
|
|
|
|
def test_one_height_fall_does_not_also_fire_on_track_loss():
|
|
# 프레임 안에서 추락으로 냈는데 그 사람의 검출이 끊기면, 소실 경로가 같은 사고를 또 낸다.
|
|
eng = _height_engine()
|
|
tr = Track(88, deque(maxlen=90))
|
|
_falling_track(tr, 0.0, y2=200.0)
|
|
_falling_track(tr, 0.5, y2=330.0)
|
|
_falling_track(tr, 0.9, y2=430.0)
|
|
eng.process([tr], [], now=0.9)
|
|
_lying_track(tr, 1.0); eng.process([tr], [], now=1.0)
|
|
_lying_track(tr, 1.6)
|
|
first = eng.process([tr], [], now=1.6)
|
|
assert EventType.FALL_FROM_HEIGHT in [e.event_type for e in first]
|
|
|
|
eng.process([], [tr], now=2.0) # 검출 끊김
|
|
later = [e.event_type for e in eng.process([], [], now=5.0)]
|
|
# 추락은 한 번만. ghost(위험 상태 소실)는 별개 사건이라 그대로 둔다 — 쓰러진 사람이
|
|
# 화면에서 사라진 것은 옮겨졌을 수 있다는 뜻이고, 1.0.0 부터의 동작이다.
|
|
assert EventType.FALL_FROM_HEIGHT not in later, f"같은 사고가 두 번 올라갔다: {later}"
|
|
assert later == [EventType.GHOST], later
|
|
|
|
|
|
def test_forget_clears_height_arming_so_it_does_not_ride_to_another_person():
|
|
# 두 사람이 교차해 트랙이 뒤바뀌면 엔진이 forget 을 부른다. 무장이 남으면 살아남은
|
|
# 다른 사람이 나중에 화면 밖으로 걸어 나갈 때 추락 경보가 된다.
|
|
eng = _height_engine()
|
|
tr = Track(99, deque(maxlen=90))
|
|
_falling_track(tr, 0.0, y2=200.0)
|
|
_falling_track(tr, 0.9, y2=430.0)
|
|
eng.process([tr], [], now=0.9)
|
|
eng.forget(99)
|
|
eng.process([], [tr], now=1.0)
|
|
assert eng.process([], [], now=5.0) == []
|