# 신규 감지 설계 — 추락 · 동시 다발 쓰러짐 · 구역 프리셋 > 상태: **설계안. 구현 전.** 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가 잡습니다.