move_detect/docs/설계-신규감지-3종.md
유창욱 4bfb7e0274
Some checks are pending
verify / edge (push) Waiting to run
verify / server (push) Waiting to run
feat: 추락 · 동시 다발 쓰러짐 · 구역 프리셋 (1.2.0)
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
2026-09-05 12:21:50 +09:00

12 KiB
Raw Blame History

신규 감지 설계 — 추락 · 동시 다발 쓰러짐 · 구역 프리셋

상태: 구현 완료 (1.2.0, 2026-09-04). 이 문서는 판정 근거의 기록으로 남깁니다. 구현: edge/detectors/height.py · edge/detectors/ghost.py(무장 확장) · edge/engine.py(유형 승격) · server/src/Alert/MultiDownWatch.php · server/src/Api/ZonePresetCatalog.php 셋 다 새 추론 모델이 필요 없습니다 — 지금 있는 자세추정·구역·트랙 이력만 씁니다. 근거 코드: edge/detectors/{fall,ghost,zone,occupancy}.py · edge/tracking.py · server/src/Alert/DeviceHealthWatch.php(서버측 감시자 패턴)


0. 왜 이 셋인가

지금 상태 왜 필요한가
추락 없음 산업재해 사망 1위 유형. 지금 잡는 건 같은 높이에서의 넘어짐이고 둘은 다른 사건
동시 다발 쓰러짐 없음 밀폐공간 사고에서 구조하러 들어간 사람이 같이 죽는 것을 막는 유일한 신호
구역 프리셋 없음 밀폐공간·고소작업은 이미 되는데 아무도 그렇게 쓸 줄 모름

사망 유형 순위는 통념 기준입니다. 고객 대면 문서에 넣으려면 고용노동부 산업재해 통계 최신본으로 확인이 필요합니다 — 이 문서 안에서는 우선순위 근거로만 씁니다.


1. 추락 (fall_from_height)

1-1. 지금 왜 안 잡히는가

GhostIncidentDetector는 트랙이 이미 위험 자세일 때만 무장합니다:

_DANGEROUS = {"FALLING", "FALLEN", "NO_MOTION"}   # edge/detectors/ghost.py:5

발판 위에 서 있다가 아래로 떨어지는 사람은 소실 순간 STANDING입니다. 아무것도 안 걸립니다. 화각 밖으로 떨어지면 넘어진 자세를 볼 기회조차 없습니다.

1-2. 핵심 신호 — 중심이 아니라 발 밑선(y2)

이게 이 설계에서 가장 중요한 판단입니다.

동작 bbox 중심(cy) bbox 하단(y2) 높이
쭈그려 앉기 내려감 거의 고정 줄어듦
계단 내려가기 천천히 내려감 천천히 내려감 유지
카메라 쪽으로 걸어옴 내려감 내려감 커짐
추락 빠르게 내려감 빠르게 내려감 유지~약간

앉기와 추락을 가르는 건 y2입니다. 앉을 때 발은 그 자리에 있습니다. cy로 판정하면 쭈그려 앉는 작업자가 전부 추락이 됩니다.

원근 접근(카메라로 걸어옴)은 y2도 내려가므로 높이 증가 가드로 따로 막습니다.

1-3. 판정

경로 A — 프레임 안에서 보이는 추락

하강량 = (y2_현재 − y2_시작) / baseline_height
조건:  하강량 ≥ drop_ratio            (기본 1.2)
       구간 ≤ drop_window_s           (기본 1.2초)
       높이_현재 / 높이_시작 ≤ grow_guard   (기본 1.35, 원근 접근 기각)

자유낙하 3m는 약 0.78초입니다. 12fps에서 9~10프레임이므로 1.2초 창이면 충분히 담깁니다. Track.frames가 90프레임(7.5초) 이력을 들고 있어 별도 버퍼가 필요 없습니다.

하강 확정 후 link_s(기본 3초) 안에 FallDetector가 넘어짐을 확정하면 이벤트 유형을 fall이 아니라 fall_from_height로 승격합니다. 두 건이 아니라 한 건입니다.

경로 B — 화각 밖으로 사라지는 추락

GhostIncidentDetector의 무장 조건을 넓힙니다. 재출현 대기·방관자 판별 로직은 그대로 재사용합니다.

무장 조건 (기존) 위험 자세였다
         (추가) 하강이 감지됐다                       ← 경로 A 신호
         (추가) 소실 지점이 프레임 하단 edge_margin 안 (기본 6%)
         (추가) 소실 시점에 '고소' 구역 안에 있었다     ← 아래 1-4

방출 시 last_state가 아니라 무장 사유를 실어 fall_from_height로 발보합니다.

1-4. 구역으로 보강 — 스키마 변경 없음

구역 rules_json은 자유 형식이고 edge/zones.py가 파싱합니다. 새 키 하나만 씁니다.

{ "elevated": true }

운영자가 발판·개구부·사다리 상부를 구역으로 그려두면, 그 안에 있던 트랙의 소실·하강은 훨씬 강한 신호가 됩니다. 구역이 없어도 경로 A·B는 동작합니다 — 구역은 정밀도를 올릴 뿐 전제 조건이 아닙니다. (이 제품의 원칙: 위험은 운영자가 선언하되, 선언이 없어도 최소한은 본다.)

1-5. 파라미터

이름 기본값 근거
drop_ratio 1.2 사람 키의 1.2배 = 약 2m 하강. 계단 한 층은 못 미침
drop_window_s 1.2 3m 자유낙하 0.78초를 담되 걷기는 못 담는 길이
grow_guard 1.35 이 이상 커지면 원근 접근
edge_margin 0.06 프레임 하단 6%
link_s 3.0 하강→넘어짐 확정 연결 창

1-6. 이벤트 계약

event_type : fall_from_height
severity   : CRITICAL      ← 넘어짐(HIGH)보다 한 단계 위
metrics    : { drop_ratio, drop_s, exited_frame: bool, zone_id: str|null,
               armed_by: "descent"|"zone"|"frame_edge" }

CRITICAL인 이유: 추락은 척추·두부 손상 가능성이 있어 함부로 옮기면 안 됩니다. 통보 문구에 그 주의가 들어가야 하고, 그건 심각도가 갈라져야 가능합니다.

1-7. 오탐 방어 요약

오탐원 방어
쭈그려 앉기·눕기 y2 기준(발은 안 움직임)
계단·경사로 하강량이 1.2배에 못 미침
카메라 쪽 접근 높이 증가 가드
장애물 뒤로 걸어감 ghost의 재출현 창(10초) + 방관자 판별 재사용
트래커 ID 스위치 동상. 기존 로직 그대로

1-8. 테스트할 것

  • 쭈그려 앉기 시퀀스가 발보하지 않음 (y2 고정)
  • 계단 하강이 발보하지 않음
  • 원근 접근이 발보하지 않음 (높이 증가)
  • 하강 후 prone → fall이 아니라 fall_from_height 1건만
  • 하강 후 화각 이탈 → 재출현 없으면 발보, 재출현하면 취소
  • 고소 구역 안 소실 → 하강을 못 봤어도 발보

2. 동시 다발 쓰러짐 (multi_down)

2-1. 왜 별도 이벤트인가

같은 자리에서 짧은 시간에 두 명이 쓰러진 것은 넘어짐 두 건이 아닙니다. 질식·가스 누출·폭발입니다. 그리고 이때 사람이 가장 많이 죽는 방식은

첫 번째 사람이 왜 쓰러졌는지 모르고 구조하러 들어간 사람이 같이 죽는 것

입니다. 그래서 이 이벤트의 값어치는 감지가 아니라 통보 문구에 있습니다.

2-2. 어디에 두는가 — 서버

엣지가 아니라 서버에 둡니다. 이유:

  • 가스는 카메라 한 대의 화각을 존중하지 않습니다. 노드가 다르면 엣지는 서로를 모릅니다.
  • 현장 즉시 대응은 이미 확보돼 있습니다 — 첫 번째 쓰러짐에서 경광등이 이미 울립니다. 서버가 늦게 판단해도 잃는 것이 없습니다.

DeviceHealthWatch·EscalationWatch와 같은 자리(dispatch_alerts.php 크론)에 붙입니다.

2-3. 판정

후보 유형 : fall, fall_from_height, no_motion, ghost
사람 구분 : (device_id, camera_id, track_id) 조합이 서로 달라야 한다
            — 한 사람이 넘어졌다 회복했다 다시 넘어진 것은 1명이다
창       : window_s (기본 120)
인원     : min_people (기본 2)
근접성   : 같은 zone_id  또는  spatial 거리 ≤ radius (기본 0.35, 프레임 정규화)
           둘 다 없으면 같은 camera_id

근접성 조건이 핵심 방어입니다. 큰 공장에서 2분 안에 무관한 넘어짐 두 건은 일어납니다. 공간적으로 붙어 있어야 같은 원인입니다. spatial_x/y가 이미 이벤트에 실려 있으니 재료가 있습니다.

2-4. 멱등

DeviceHealthWatch와 같은 방식 — 결정적 uuid + UNIQUE 충돌.

uuid = hash("multi_down", scope_key, 최초_event_id, 인원수)

인원수를 넣는 이유: 3번째 사람이 쓰러지면 새 사건으로 한 번 더 올라와야 합니다. 인원은 현장 인원수로 자연히 한정되므로 폭주하지 않습니다.

2-5. 이벤트 계약

event_type : multi_down
severity   : CRITICAL
metrics    : { people: 3, origin_event_ids: [...], window_s, scope: "zone:z1"|"camera:cam-01",
               types: ["fall","fall"], first_at, last_at }

2-6. 통보 문구 — 이 이벤트의 본체

Channels에 유형별 예외를 둡니다. 이 한 문장이 사람을 살립니다.

[위급] 동시 다발 쓰러짐 — 3번 라인

같은 구역에서 2분 안에 2명이 쓰러졌습니다.
가스 누출·산소 결핍·폭발을 의심하십시오.

⚠ 공기호흡기 없이 진입하지 마십시오.
   밀폐공간 사고 사망자의 상당수가 구조하러 들어간 사람입니다.

2-7. 테스트할 것

  • 같은 구역 2명 / 창 안 → 발보
  • 같은 사람이 2번 넘어짐 → 발보하지 않음 (track_id 동일)
  • 멀리 떨어진 2명 → 발보하지 않음 (근접성)
  • 창 밖 2명 → 발보하지 않음
  • 3번째 추가 → 한 번 더 발보, 4번째까지 총 3건 이하
  • 크론 반복 실행 → 중복 없음

3. 구역 프리셋

3-1. 문제

밀폐공간 작업을 감시하는 데 필요한 것은 이미 전부 있습니다:

제한구역 + 2인1조 + 체류 상한 + 정원

그런데 제품은 "폴리곤을 그리고 min_workers를 넣으세요"라고만 말합니다. 안전관리자는 min_workers를 생각하지 않습니다. 밀폐공간을 생각합니다.

3-2. 설계 — 코드가 아니라 값

프리셋은 (level, rules_json, 안내문) 묶음입니다. 스키마 변경도 감지기 변경도 없습니다. 구역 편집기에 선택 목록을 놓고, 고르면 레벨과 규칙이 채워집니다.

프리셋 level rules_json 잡는 것
밀폐공간 restricted min_workers:2, max_dwell_s:1800 단독 진입 · 장시간 체류
고소작업 개구부 restricted elevated:true, min_workers:2 추락(§1) · 단독 고소작업
프레스·협착 pinch max_workers:1 협착 침입 · 2인 동시 조작
지게차 통로 warning max_dwell_s:60 통로 내 보행·체류
화기작업 restricted min_workers:2, max_dwell_s:3600 화기 감시자 부재
크레인 하부 · 붐 선회 restricted max_workers:0 사람이 있으면 안 되는 곳

3-3. ⚠ 마지막 줄에 걸림돌이 있습니다

max_workers: 0이 지금은 동작하지 않습니다.

max_workers = rules.get("max_workers")
if max_workers and n > max_workers:      # edge/detectors/occupancy.py

0은 falsy라 규칙이 통째로 꺼졌습니다. 1.2.0 에서 수정 — 감지기·입력란·저장 직렬화· 목록 표시 네 곳 모두에서 막혀 있었습니다. 크레인 짐 아래·굴착기 선회 반경은 정확히 그 규칙이고, 깔림 사고의 전형입니다.

수정은 한 줄입니다 — if max_workers is not None and n > max_workers. min_workers도 같은 형태이므로 함께 봐야 합니다.

3-4. 프리셋을 어디에 두는가

server/src/Api/ 에 카탈로그로 두고 구역 편집기가 읽어갑니다 — ThresholdCatalog가 튜너블에 대해 하는 것과 같은 구조(화이트리스트 + 한국어 설명)입니다. 프리셋을 고른 뒤 값을 손으로 바꾸는 것은 그대로 허용합니다. 프리셋은 출발점이지 잠금이 아닙니다.


4. 구현 순서 제안

무엇 규모 왜 이 순서
1 max_workers: 0 수정 한 줄 + 테스트 프리셋의 전제. 지금 조용히 꺼져 있는 버그이기도 함
2 구역 프리셋 값 + 화면 코드 위험 0. 감지 범위 체감 확대가 가장 큼
3 추락 감지기 1개 + ghost 확장 사망 1위. 재료는 다 있음
4 동시 다발 서버 감시자 1개 추락이 들어간 뒤 후보 유형이 완성됨

1·2는 위험이 거의 없고, 3·4는 새 이벤트 유형이라 엣지·서버·프런트 5곳 동기화가 필요합니다 (edge/events.py · EventValidator · schema.sql CHECK · TYPE_LABELS · events.php). tests/test_event_type_parity.py가 이미 그 다섯을 묶어 검사하므로, 빠뜨리면 CI가 잡습니다.