AI CCTV 목적을 원론부터 재점검한 결과. 셋 다 새 추론 모델 없이 지금 있는 자세추정·구역·트랙 이력만으로 구현했다. ■ 추락 (fall_from_height) 잡고 있던 것은 같은 높이의 '넘어짐'이었고, 사망 1위 유형인 '떨어짐'은 못 잡았다. GhostIncidentDetector 가 트랙이 이미 위험 자세일 때만 무장하므로 (_DANGEROUS), 발판에 서 있다가 떨어지는 사람은 소실 순간 STANDING 이라 아무것도 안 걸렸다. 판정의 핵심은 bbox 중심이 아니라 하단(y2)이다. 쭈그려 앉으면 중심은 내려가도 발은 그 자리이고, 추락은 발 밑선이 통째로 내려간다 — 중심으로 재면 쭈그려 앉아 작업하는 사람이 전부 추락이 된다. 원근 접근은 y2 도 내려가므로 높이 증가 가드로 따로 막는다. Track.frames 가 90프레임을 들고 있어 별도 버퍼가 없다. 발보는 두 갈래로 나뉘되 한 사고는 한 건이다: 프레임 안에서 넘어짐이 확정되면 유형을 승격하고, 화각 밖으로 사라지면 ghost 무장을 넓혀 그쪽으로 낸다. 심각도 CRITICAL — 척추·두부 손상 주의가 통보에 실리려면 넘어짐(HIGH)과 등급이 갈라져야 한다. 고소 구역은 rules_json 의 elevated 키로 붙어 스키마 변경이 없고, 구역이 없어도 하강 신호만으로 동작한다. ■ 동시 다발 쓰러짐 (multi_down) 같은 구역에서 짧은 시간에 둘이 쓰러진 것은 넘어짐 두 건이 아니라 질식· 가스·폭발이다. 이 기능의 본체는 감지가 아니라 통보 문구다 — 밀폐공간 사망자의 상당수가 구조하러 들어간 사람이므로, 링크를 열기 전에 메일 본문에서 "공기호흡기 없이 진입하지 마십시오"가 읽혀야 한다. Channels::guidance 로 유형별 안내를 붙였고 문구를 테스트로 고정했다(문구가 사라지면 기능이 사라진 것과 같다). 서버에 둔 이유: 가스는 카메라 화각을 존중하지 않아 노드를 넘는 판단이 필요하고, 현장 즉시 대응은 첫 쓰러짐의 경광등이 이미 확보한다. 오탐 방어의 핵심은 근접성 — 넓은 공장에서 2분 안의 무관한 넘어짐 두 건은 실제로 일어나므로 같은 구역이거나 좌표가 가까워야 묶는다. 한 사람이 두 번 넘어진 것은 한 명으로 센다. ■ 구역 프리셋 6종 + max_workers:0 수정 밀폐공간 감시에 필요한 것(제한구역+2인1조+체류상한)은 이미 전부 있었는데 화면이 "최소 인원을 넣으세요"라고만 물어 아무도 그렇게 쓰지 못했다. 안전관리자는 min_workers 가 아니라 밀폐공간을 생각한다. 그 과정에서 잠복 결함 하나: max_workers:0 이 falsy 로 걸러져 "아무도 있으면 안 되는 구역"(크레인 짐 아래·굴착기 선회 반경 = 깔림 사고의 전형)이 통째로 꺼져 있었다. 운영자가 저장해 놓고도 아무 일이 안 일어났다. 감지기·입력란 (min=1)·저장 직렬화(>0)·목록 표시 네 곳에서 막혀 있었다. ■ 검증 pytest 322 passed / phpunit 263 passed. 신규 테스트 30건. 이벤트 유형 2종 추가는 test_event_type_parity 가 강제하는 5곳(엣지 enum · EventValidator · schema CHECK · 프런트 라벨 5파일 · 필터 옵션) 전부 동기화했고 기존 설치용 마이그레이션을 추가했다. ■ Pi 번들 1.1.0 과 달리 이번에는 edge/ 가 바뀌었으므로 Pi 번들 재빌드가 필수다. 낡은 1.0.0 번들은 dist/ 에서 제거했다 — 두면 추락 감지가 빠진 노드가 그대로 납품된다. arm64 + 네트워크 빌드 머신에서 재빌드해야 Pi 납품이 가능하다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EBjuR7DW6PNNMows4KnbYU
413 lines
19 KiB
Python
413 lines
19 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"
|