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
100 lines
4.5 KiB
Python
100 lines
4.5 KiB
Python
"""추락(높이에서 떨어짐) 판정의 1차 신호 — 급격한 하강.
|
|
|
|
WHY THIS EXISTS
|
|
이 제품이 잡고 있던 것은 같은 높이에서의 **넘어짐**이다. 그런데 산업재해 사망 1위 유형은
|
|
**떨어짐**이고, 둘은 다른 사건이다. GhostIncidentDetector 는 트랙이 이미 위험 자세일 때만
|
|
무장하므로(_DANGEROUS), 발판 위에 서 있다가 떨어지는 사람은 소실 순간 STANDING 이라
|
|
아무것도 걸리지 않는다. 화각 밖으로 떨어지면 넘어진 자세를 볼 기회조차 없다.
|
|
|
|
WHY THE FOOT LINE (y2) AND NOT THE CENTRE
|
|
이 파일에서 가장 중요한 판단이다.
|
|
|
|
쭈그려 앉기 → 중심(cy)은 내려가지만 **발 밑선(y2)은 그 자리**다. 높이만 줄어든다.
|
|
계단 내려가기 → 둘 다 천천히 내려간다.
|
|
카메라 쪽 접근 → 둘 다 내려가지만 **사람이 커진다**.
|
|
추락 → 발 밑선이 통째로, 빠르게 내려간다.
|
|
|
|
중심으로 판정하면 쭈그려 앉아 작업하는 사람이 전부 추락이 된다. 원근 접근은 y2 도
|
|
내려가므로 높이 증가 가드로 따로 막는다.
|
|
|
|
WHAT THIS DOES NOT DO
|
|
이 감시자는 이벤트를 만들지 않는다. "이 트랙이 방금 떨어졌다"는 사실만 들고 있다가
|
|
EventEngine 이 물어보면 답한다. 실제 발보는 두 갈래로 갈린다:
|
|
· 프레임 안에서 넘어짐이 확정되면 -> 유형을 fall 대신 fall_from_height 로 승격
|
|
· 화각 밖으로 사라지면 -> GhostIncidentDetector 가 무장해 발보
|
|
한 번의 추락이 두 건으로 올라가지 않게 하려는 분리다.
|
|
|
|
Track.frames 가 90프레임(약 7.5초) 이력을 들고 있어 별도 버퍼가 필요 없다.
|
|
"""
|
|
from dataclasses import dataclass
|
|
|
|
from edge.tracking import Track
|
|
|
|
|
|
@dataclass
|
|
class DescentConfig:
|
|
# 평소 키의 몇 배를 내려가야 추락으로 보는가. 1.2배 ≈ 2m — 계단 한 층은 못 미친다.
|
|
drop_ratio: float = 1.2
|
|
# 그 하강이 이 시간 안에 일어나야 한다. 3m 자유낙하가 약 0.78초이므로 1.2초면 담긴다.
|
|
drop_window_s: float = 1.2
|
|
# 구간 동안 사람이 이 배수 이상 커졌으면 카메라 쪽으로 걸어온 것이다.
|
|
grow_guard: float = 1.35
|
|
# 하강 후 이 시간 안에 넘어짐이 확정되면 같은 사건으로 묶어 유형을 승격한다.
|
|
link_s: float = 3.0
|
|
|
|
|
|
class DescentWatch:
|
|
def __init__(self, cfg: DescentConfig):
|
|
self.cfg = cfg
|
|
self._seen: dict[int, tuple[float, dict]] = {} # track_id -> (감지 시각, 상세)
|
|
|
|
def forget(self, track_id: int) -> None:
|
|
self._seen.pop(track_id, None)
|
|
|
|
def descending(self, track_id: int, now: float) -> bool:
|
|
"""이 트랙이 최근 link_s 안에 떨어졌는가."""
|
|
hit = self._seen.get(track_id)
|
|
return hit is not None and (now - hit[0]) <= self.cfg.link_s
|
|
|
|
def detail(self, track_id: int) -> dict | None:
|
|
hit = self._seen.get(track_id)
|
|
return dict(hit[1]) if hit is not None else None
|
|
|
|
def update(self, track: Track, now: float) -> dict | None:
|
|
"""이번 틱에 하강이 확정되면 상세를 돌려준다. 같은 하강은 한 번만 확정한다."""
|
|
frames = list(track.frames)
|
|
if len(frames) < 2:
|
|
return None
|
|
if self.descending(track.track_id, now):
|
|
return None # 이미 무장돼 있다 — 같은 추락을 매 틱 다시 확정하지 않는다
|
|
|
|
last = frames[-1]
|
|
# 창 안에서 가장 오래된 프레임을 기준으로 삼는다. 그보다 앞은 다른 사건이다.
|
|
start = None
|
|
for f in frames:
|
|
if last.t - f.t <= self.cfg.drop_window_s:
|
|
start = f
|
|
break
|
|
if start is None or start is last:
|
|
return None
|
|
|
|
baseline = start.bbox.height
|
|
if baseline <= 0:
|
|
return None
|
|
|
|
drop = (last.bbox.y2 - start.bbox.y2) / baseline
|
|
if drop < self.cfg.drop_ratio:
|
|
return None
|
|
|
|
# 원근 접근 기각: 떨어지는 사람의 겉보기 크기는 그렇게 커지지 않는다.
|
|
if last.bbox.height > baseline * self.cfg.grow_guard:
|
|
return None
|
|
|
|
info = {
|
|
"drop_ratio": round(drop, 2),
|
|
"drop_s": round(last.t - start.t, 2),
|
|
"from_y2": round(start.bbox.y2, 3),
|
|
"to_y2": round(last.bbox.y2, 3),
|
|
}
|
|
self._seen[track.track_id] = (now, info)
|
|
return info
|