docs: 신규 감지 3종 설계안 (추락·동시 다발·구역 프리셋)

AI CCTV 목적 재점검에서 나온 세 가지. 공통점은 새 추론 모델이 필요 없다는
것 — 지금 있는 자세추정·구역·트랙 이력만으로 판정이 선다.

추락: 지금 가장 큰 공백이다. GhostIncidentDetector 는 트랙이 이미 위험 자세일
때만 무장하므로(_DANGEROUS), 발판에 서 있다가 떨어지는 사람은 소실 순간
STANDING 이라 아무것도 안 걸린다. 잡고 있는 것은 같은 높이의 '넘어짐'이고
사망 1위 유형인 '떨어짐'과는 다른 사건이다.

설계의 핵심은 bbox 중심이 아니라 **하단(y2)** 을 본다는 것. 쭈그려 앉으면
중심은 내려가지만 발은 그 자리에 있고, 추락은 발 밑선이 통째로 내려간다.
중심으로 판정하면 쭈그려 앉는 작업자가 전부 추락이 된다. 원근 접근은 높이
증가 가드로 따로 막는다. Track.frames 가 90프레임(7.5초)을 들고 있어 별도
버퍼가 필요 없고, 고소 구역은 rules_json 의 elevated 키로 스키마 변경 없이
붙는다(구역이 없어도 동작 — 선언이 없어도 최소한은 본다).

동시 다발: 같은 자리에서 짧은 시간에 둘이 쓰러진 것은 넘어짐 두 건이 아니라
질식·가스·폭발이다. 이 이벤트의 값어치는 감지가 아니라 통보 문구에 있다 —
밀폐공간 사망자의 상당수가 구조하러 들어간 사람이므로, "공기호흡기 없이
진입 금지"가 메시지에 실려야 한다. 가스는 화각을 존중하지 않으므로 노드를
넘는 판단이 필요해 서버에 둔다. 현장 즉시 대응은 첫 쓰러짐의 경광등이 이미
확보하므로 서버가 늦어도 잃는 것이 없다. 오탐 방어의 핵심은 근접성 —
넓은 공장에서 2분 안의 무관한 넘어짐 두 건은 실제로 일어난다.

프리셋: 밀폐공간 감시에 필요한 것(제한구역+2인1조+체류상한+정원)은 이미
전부 있는데, 제품이 "min_workers 를 넣으세요"라고만 말한다. 안전관리자는
min_workers 가 아니라 밀폐공간을 생각한다. 값과 화면만으로 감지 범위가
체감상 크게 넓어진다.

설계 중 확인한 잠복 결함: occupancy.py 의 `if max_workers and ...` 때문에
max_workers: 0 이 falsy 로 걸러져 규칙이 통째로 꺼진다. 즉 "아무도 있으면
안 되는 구역"(크레인 짐 아래·굴착기 선회 반경 — 깔림 사고의 전형)을 표현할
방법이 현재 없다. 운영자가 설정할 수 있는데 조용히 아무 일도 하지 않는 종류라
프리셋보다 먼저 고쳐야 한다.

구현하지 않았다. 설계안만.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBjuR7DW6PNNMows4KnbYU
This commit is contained in:
유창욱 2026-09-05 07:29:48 +09:00
parent cfdbcef640
commit ccbc233fdd
2 changed files with 279 additions and 0 deletions

View file

@ -154,3 +154,5 @@
- **보안** 실제 자격증명 파일을 납품 폴더 `dist/` 밖(`secrets/`)으로 이동. **잔여: 대시보드 비밀번호·장치 HMAC 키 교체 권고**
- **Pi 번들** 미갱신 — edge 불변이라 내용은 동일하나 파일명 표기가 1.0.0. arm64+네트워크 빌드 머신에서 재빌드 필요(Pi 납품 시)
- 검증: pytest 308 passed(3 skip: 환경) · phpunit 250 passed(신규 10)
- 2026-09-04 — 신규 감지 3종 설계안 작성(`docs/설계-신규감지-3종.md`): 추락(fall_from_height)·동시 다발 쓰러짐(multi_down)·구역 프리셋. 셋 다 새 추론 모델 불필요. 설계 중 확인한 잠복 결함: `max_workers: 0`이 falsy로 걸러져 "아무도 있으면 안 되는 구역"을 표현할 수 없음(occupancy.py).

View file

@ -0,0 +1,277 @@
# 신규 감지 설계 — 추락 · 동시 다발 쓰러짐 · 구역 프리셋
> 상태: **설계안. 구현 전.** 2026-09-04
> 셋 다 **새 추론 모델이 필요 없습니다** — 지금 있는 자세추정·구역·트랙 이력만 씁니다.
> 근거 코드: `edge/detectors/{fall,ghost,zone,occupancy}.py` · `edge/tracking.py` ·
> `server/src/Alert/DeviceHealthWatch.php`(서버측 감시자 패턴)
---
## 0. 왜 이 셋인가
| | 지금 상태 | 왜 필요한가 |
|---|---|---|
| **추락** | **없음** | 산업재해 사망 1위 유형. 지금 잡는 건 같은 높이에서의 *넘어짐*이고 둘은 다른 사건 |
| **동시 다발 쓰러짐** | 없음 | 밀폐공간 사고에서 구조하러 들어간 사람이 같이 죽는 것을 막는 유일한 신호 |
| **구역 프리셋** | 없음 | 밀폐공간·고소작업은 **이미 되는데** 아무도 그렇게 쓸 줄 모름 |
> 사망 유형 순위는 통념 기준입니다. 고객 대면 문서에 넣으려면 고용노동부 산업재해 통계
> 최신본으로 확인이 필요합니다 — 이 문서 안에서는 우선순위 근거로만 씁니다.
---
## 1. 추락 (`fall_from_height`)
### 1-1. 지금 왜 안 잡히는가
`GhostIncidentDetector`는 트랙이 **이미 위험 자세**일 때만 무장합니다:
```python
_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`가 파싱합니다. 새 키 하나만 씁니다.
```json
{ "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`이 **지금은 동작하지 않습니다.**
```python
max_workers = rules.get("max_workers")
if max_workers and n > max_workers: # edge/detectors/occupancy.py
```
`0`은 falsy라 규칙이 통째로 꺼집니다. 즉 **"아무도 있으면 안 되는 구역"을 표현할 방법이
현재 없습니다.** 크레인 짐 아래·굴착기 선회 반경은 정확히 그 규칙이고, 깔림 사고의 전형입니다.
수정은 한 줄입니다 — `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가 잡습니다.