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
280 lines
12 KiB
Markdown
280 lines
12 KiB
Markdown
# 신규 감지 설계 — 추락 · 동시 다발 쓰러짐 · 구역 프리셋
|
||
|
||
> 상태: **구현 완료 (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`는 트랙이 **이미 위험 자세**일 때만 무장합니다:
|
||
|
||
```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라 규칙이 통째로 꺼졌습니다. **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가 잡습니다.
|