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
144 lines
6.2 KiB
Python
144 lines
6.2 KiB
Python
from edge.detectors.zone import Zone
|
|
from edge.detectors.occupancy import OccupancyWatchdog, OccupancyConfig
|
|
|
|
_POLY = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)]
|
|
|
|
|
|
def _zone(zone_id="z1", **rules):
|
|
return Zone(zone_id, "restricted", _POLY, rules or None)
|
|
|
|
|
|
def _watch(**cfg):
|
|
return OccupancyWatchdog(OccupancyConfig(**cfg))
|
|
|
|
|
|
def test_no_rules_emits_nothing():
|
|
w = _watch()
|
|
zones = [Zone("z1", "restricted", _POLY)]
|
|
assert w.update(zones, {"z1": {1}}, now=0.0) == []
|
|
assert w.update(zones, {"z1": {1}}, now=999.0) == []
|
|
|
|
|
|
def test_lone_worker_fires_after_sustained_solo():
|
|
w = _watch(lone_confirm_s=30.0)
|
|
zones = [_zone(min_workers=2)]
|
|
assert w.update(zones, {"z1": {7}}, now=0.0) == [] # 방금 혼자가 됨
|
|
assert w.update(zones, {"z1": {7}}, now=29.0) == [] # 아직 유예
|
|
out = w.update(zones, {"z1": {7}}, now=31.0)
|
|
assert out == [{"kind": "lone_worker", "zone_id": "z1", "track_id": 7,
|
|
"metrics": {"workers": 1, "required": 2, "alone_s": 31.0}}]
|
|
# 조건이 계속돼도 재발보 없음 (해소 후 재충족으로만)
|
|
assert w.update(zones, {"z1": {7}}, now=60.0) == []
|
|
|
|
|
|
def test_colleague_return_resets_lone_timer():
|
|
w = _watch(lone_confirm_s=30.0)
|
|
zones = [_zone(min_workers=2)]
|
|
w.update(zones, {"z1": {7}}, now=0.0)
|
|
w.update(zones, {"z1": {7, 8}}, now=20.0) # 동료 복귀 → 리셋
|
|
assert w.update(zones, {"z1": {7}}, now=40.0) == [] # 새로 30초 필요
|
|
assert len(w.update(zones, {"z1": {7}}, now=71.0)) == 1
|
|
|
|
|
|
def test_empty_zone_is_not_a_lone_violation():
|
|
w = _watch(lone_confirm_s=0.0)
|
|
zones = [_zone(min_workers=2)]
|
|
assert w.update(zones, {}, now=0.0) == []
|
|
assert w.update(zones, {}, now=100.0) == []
|
|
|
|
|
|
def test_overcapacity_fires_after_sustained_excess():
|
|
w = _watch(over_confirm_s=10.0)
|
|
zones = [_zone(max_workers=2)]
|
|
assert w.update(zones, {"z1": {1, 2, 3}}, now=0.0) == []
|
|
out = w.update(zones, {"z1": {1, 2, 3}}, now=11.0)
|
|
assert out == [{"kind": "overcapacity", "zone_id": "z1", "track_id": None,
|
|
"metrics": {"workers": 3, "limit": 2}}]
|
|
# 해소 후 재충족이면 다시 발보
|
|
w.update(zones, {"z1": {1, 2}}, now=20.0)
|
|
w.update(zones, {"z1": {1, 2, 3}}, now=30.0)
|
|
assert len(w.update(zones, {"z1": {1, 2, 3}}, now=41.0)) == 1
|
|
|
|
|
|
def test_overstay_fires_per_track():
|
|
w = _watch()
|
|
zones = [_zone(max_dwell_s=60)]
|
|
w.update(zones, {"z1": {1}}, now=0.0)
|
|
w.update(zones, {"z1": {1, 2}}, now=30.0) # 2번은 30초에 진입
|
|
out = w.update(zones, {"z1": {1, 2}}, now=61.0) # 1번만 60초 초과
|
|
assert [o["track_id"] for o in out] == [1]
|
|
assert out[0]["kind"] == "overstay"
|
|
out = w.update(zones, {"z1": {1, 2}}, now=91.0) # 이제 2번도 초과
|
|
assert [o["track_id"] for o in out] == [2]
|
|
|
|
|
|
def test_dwell_survives_short_detection_dropout():
|
|
# run_once의 stale 필터로 1~2틱 미검출이 흔하다. grace 안의 부재는 체류 연속으로
|
|
# 간주해야 10분 규칙이 실사용에서 무력화되지 않는다.
|
|
w = _watch(dwell_absence_grace_s=5.0)
|
|
zones = [_zone(max_dwell_s=60)]
|
|
w.update(zones, {"z1": {1}}, now=0.0)
|
|
w.update(zones, {}, now=30.0) # 드롭아웃 (grace보다 김? 아님: 아래 재검출로 판단)
|
|
out = w.update(zones, {"z1": {1}}, now=61.0)
|
|
# 30.0의 부재는 last_seen(0.0)에서 5초 grace를 초과 → 새 체류로 리셋되었다.
|
|
assert out == []
|
|
# 연속 체류가 실제로 60초를 넘기면 발보
|
|
w.update(zones, {"z1": {1}}, now=90.0)
|
|
w.update(zones, {"z1": {1}}, now=120.0)
|
|
assert len(w.update(zones, {"z1": {1}}, now=122.0)) == 1
|
|
|
|
|
|
def test_dwell_short_gap_within_grace_keeps_timer():
|
|
w = _watch(dwell_absence_grace_s=5.0)
|
|
zones = [_zone(max_dwell_s=60)]
|
|
w.update(zones, {"z1": {1}}, now=0.0)
|
|
w.update(zones, {}, now=2.0) # 2초 드롭아웃 — grace 이내
|
|
out = w.update(zones, {"z1": {1}}, now=61.0)
|
|
assert len(out) == 1 and out[0]["metrics"]["dwell_s"] == 61.0
|
|
|
|
|
|
def test_forget_clears_dwell_state():
|
|
w = _watch()
|
|
zones = [_zone(max_dwell_s=60)]
|
|
w.update(zones, {"z1": {1}}, now=0.0)
|
|
w.forget(1)
|
|
assert w.update(zones, {"z1": {1}}, now=61.0) == [] # 새 체류로 시작
|
|
|
|
|
|
def test_deleted_zone_state_is_pruned():
|
|
w = _watch(lone_confirm_s=0.0, over_confirm_s=0.0)
|
|
zones = [_zone(min_workers=2, max_workers=1)]
|
|
w.update(zones, {"z1": {1}}, now=0.0)
|
|
w.update([], {}, now=1.0) # 존 삭제 (config 동기)
|
|
assert w._lone_since == {} and w._over_since == {}
|
|
assert w._lone_fired == set() and w._over_fired == set()
|
|
|
|
|
|
# --- "아무도 있으면 안 되는 구역" ------------------------------------------------------
|
|
#
|
|
# 크레인 짐 아래, 굴착기 선회 반경처럼 인원 상한이 0인 구역이 있다. 깔림 사고의 전형이다.
|
|
# max_workers=0 을 falsy 로 걸러내면 운영자가 규칙을 저장해 놓고도 아무 일이 일어나지 않는다
|
|
# — 화면에는 규칙이 있는데 감지기는 꺼져 있는, 이 프로젝트가 반복해서 잡아온 조용한 실패다.
|
|
|
|
def test_zero_capacity_zone_fires_on_a_single_person():
|
|
w = _watch(over_confirm_s=10.0)
|
|
zones = [_zone(max_workers=0)]
|
|
assert w.update(zones, {"z1": {1}}, now=0.0) == [] # 유예 안
|
|
out = w.update(zones, {"z1": {1}}, now=11.0)
|
|
assert out == [{"kind": "overcapacity", "zone_id": "z1", "track_id": None,
|
|
"metrics": {"workers": 1, "limit": 0}}]
|
|
|
|
|
|
def test_zero_capacity_zone_stays_quiet_when_empty():
|
|
w = _watch(over_confirm_s=10.0)
|
|
zones = [_zone(max_workers=0)]
|
|
assert w.update(zones, {"z1": set()}, now=0.0) == []
|
|
assert w.update(zones, {"z1": set()}, now=999.0) == []
|
|
|
|
|
|
def test_absent_max_workers_still_disables_the_rule():
|
|
# 규칙을 안 넣은 것과 0 을 넣은 것은 다르다. 전자는 "제한 없음", 후자는 "아무도 안 됨".
|
|
w = _watch(over_confirm_s=10.0)
|
|
zones = [_zone(min_workers=2)] # max_workers 키 자체가 없음
|
|
assert w.update(zones, {"z1": {1, 2, 3}}, now=0.0) == []
|
|
assert [o["kind"] for o in w.update(zones, {"z1": {1, 2, 3}}, now=11.0)] == []
|