Compare commits

..

No commits in common. "4bfb7e0274a586de4c489ca2f680402ba3c5486f" and "cfdbcef640dfc9c07b5fc9c38f7de097b5d1cd5f" have entirely different histories.

33 changed files with 42 additions and 1445 deletions

View file

@ -6,61 +6,6 @@
--- ---
## 1.2.0 — 2026-09-04 · 추락 · 동시 다발 쓰러짐 · 구역 프리셋
AI CCTV 의 목적을 원론부터 다시 점검한 결과입니다. 셋 다 **새 추론 모델 없이** 지금 있는
자세추정·구역·트랙 이력만으로 구현했습니다.
### 추가 — 추락 (`fall_from_height`)
이 제품이 잡던 것은 같은 높이에서의 **넘어짐**이었습니다. 산업재해 사망 1위 유형인
**떨어짐**은 잡지 못했습니다 — `GhostIncidentDetector` 가 트랙이 이미 위험 자세일 때만
무장하므로, 발판 위에 서 있다가 떨어지는 사람은 소실 순간 정상 자세라 아무것도 걸리지
않았습니다.
- 판정은 bbox 중심이 아니라 **발 밑선(y2)** 으로 합니다. 쭈그려 앉으면 중심은 내려가도 발은
그 자리이고, 추락은 발 밑선이 통째로 내려갑니다. 중심으로 재면 쭈그려 앉아 작업하는
사람이 전부 추락이 됩니다. 원근 접근은 높이 증가 가드로 막습니다.
- 프레임 안에서 확정되면 넘어짐 유형을 **승격**합니다 — 한 번의 사고는 한 건입니다.
- 화각 밖으로 사라지면 ghost 경로로 발보합니다. 떨어져 나간 사람에게는 이쪽이 유일한 경로입니다.
- 심각도 **위급**. 척추·두부 손상 가능성이 있어 함부로 옮기면 안 되고, 그 주의가 통보에
실리려면 넘어짐(높음)과 등급이 갈라져야 합니다.
- 구역 규칙 `elevated: true` 로 발판·개구부를 표시하면 정확해집니다. 구역이 없어도 동작합니다.
### 추가 — 동시 다발 쓰러짐 (`multi_down`)
같은 구역에서 짧은 시간에 두 명 이상이 쓰러진 것은 넘어짐 두 건이 아니라 **질식·가스 누출·
폭발**입니다. 그리고 이때 사람이 가장 많이 죽는 방식은 첫 번째 사람이 왜 쓰러졌는지 모르고
**구조하러 들어간 사람이 같이 죽는 것**입니다.
그래서 이 기능의 본체는 감지가 아니라 **통보 문구**입니다. 메일 본문에 링크를 열기 전에
"공기호흡기 없이 진입하지 마십시오"가 바로 읽힙니다.
- 서버에서 판단합니다 — 가스는 카메라 화각을 존중하지 않고, 현장 즉시 대응은 첫 쓰러짐의
경광등이 이미 확보합니다.
- 오탐 방어의 핵심은 **근접성**입니다. 넓은 공장에서 2분 안의 무관한 넘어짐 두 건은 실제로
일어나므로, 같은 구역이거나 좌표가 가까워야 묶습니다. 한 사람이 두 번 넘어진 것은 한 명입니다.
### 추가 — 용도별 구역 프리셋
밀폐공간 감시에 필요한 것(제한구역 + 2인1조 + 체류 상한)은 이미 전부 있었는데, 화면이
"최소 인원을 넣으세요"라고만 물어서 아무도 그렇게 쓰지 못했습니다. 안전관리자는
`min_workers` 가 아니라 **밀폐공간**을 생각합니다.
밀폐공간 · 고소작업 개구부 · 프레스 협착 · **출입 금지(크레인 하부·선회 반경)** ·
지게차 통로 · 화기작업 6종. 고른 뒤 값을 바꾸는 것은 그대로 허용합니다.
### 수정 — "아무도 있으면 안 되는 구역"이 조용히 꺼져 있었습니다
`max_workers: 0` 이 falsy 로 걸러져 규칙이 통째로 동작하지 않았습니다. 크레인으로 든 짐
아래, 굴착기 선회 반경처럼 **아무도 있어서는 안 되는 범위**가 정확히 그 규칙이고 깔림
사고의 전형입니다. 운영자가 저장해 놓고도 아무 일이 일어나지 않았습니다.
네 곳에서 막혀 있었습니다 — 감지기(`and` 검사), 입력란(`min="1"`), 저장 직렬화(`> 0`),
목록 표시. 전부 고쳤습니다.
---
## 1.1.0 — 2026-09-04 · 감시 자신의 고장을 실제로 신고하기 ## 1.1.0 — 2026-09-04 · 감시 자신의 고장을 실제로 신고하기
1.0.0 은 "감지에 대응하는 시스템"이었습니다. 그런데 **감시 장비 자신이 멈춘 것**은 여전히 1.0.0 은 "감지에 대응하는 시스템"이었습니다. 그런데 **감시 장비 자신이 멈춘 것**은 여전히

View file

@ -1 +1 @@
1.2.0 1.1.0

View file

@ -154,15 +154,3 @@
- **보안** 실제 자격증명 파일을 납품 폴더 `dist/` 밖(`secrets/`)으로 이동. **잔여: 대시보드 비밀번호·장치 HMAC 키 교체 권고** - **보안** 실제 자격증명 파일을 납품 폴더 `dist/` 밖(`secrets/`)으로 이동. **잔여: 대시보드 비밀번호·장치 HMAC 키 교체 권고**
- **Pi 번들** 미갱신 — edge 불변이라 내용은 동일하나 파일명 표기가 1.0.0. arm64+네트워크 빌드 머신에서 재빌드 필요(Pi 납품 시) - **Pi 번들** 미갱신 — edge 불변이라 내용은 동일하나 파일명 표기가 1.0.0. arm64+네트워크 빌드 머신에서 재빌드 필요(Pi 납품 시)
- 검증: pytest 308 passed(3 skip: 환경) · phpunit 250 passed(신규 10) - 검증: 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).
## 2026-09-05 — 1.2.0 (추락 · 동시 다발 쓰러짐 · 구역 프리셋)
- **추락(`fall_from_height`)** — 사망 1위 유형인 "떨어짐"을 못 잡던 공백 해소. 판정은 bbox 중심이 아니라 **발 밑선(y2)**(쭈그려 앉기와 구분되는 유일한 신호). 프레임 안 확정은 넘어짐 유형을 승격, 화각 이탈은 ghost 경로. 심각도 CRITICAL(척추 손상 주의가 통보에 실려야 함). `edge/detectors/height.py` 신규
- **동시 다발 쓰러짐(`multi_down`)** — 같은 구역 짧은 시간 2명 이상 = 질식·가스·폭발. 기능의 본체는 통보 문구("공기호흡기 없이 진입하지 마십시오"). 서버측 감시자. 오탐 방어는 근접성(구역 또는 좌표 거리)
- **구역 프리셋 6종** — 밀폐공간·고소작업·프레스협착·출입금지·지게차통로·화기작업. `GET /api/zone-presets`
- **수정** `max_workers: 0`이 falsy로 걸러져 "아무도 있으면 안 되는 구역"(크레인 하부·선회 반경)이 조용히 꺼져 있던 문제. 감지기·입력란·직렬화·표시 4곳
- 산출물 `move-detect-cafe24-1.2.0-20260905.zip`
- **⚠ Pi 번들은 이번 릴리스에서 필수 재빌드** — edge/ 가 바뀌었다. 낡은 1.0.0 번들은 제거했다(추락 감지가 빠진 노드가 납품되는 것을 막기 위해). arm64+네트워크 빌드 머신에서 재빌드해야 Pi 납품 가능
- 검증: pytest 322 passed(5 skip: 환경·Pi번들 미빌드) · phpunit 263 passed

View file

@ -1,280 +0,0 @@
# 신규 감지 설계 — 추락 · 동시 다발 쓰러짐 · 구역 프리셋
> 상태: **구현 완료 (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가 잡습니다.

View file

@ -15,46 +15,28 @@ class GhostIncidentDetector:
def __init__(self, cfg: GhostConfig): def __init__(self, cfg: GhostConfig):
self.cfg = cfg self.cfg = cfg
self._states: dict[int, str] = {} self._states: dict[int, str] = {}
# 높이 기반 무장: 위험 자세가 아니어도 '떨어지는 중'이거나 '고소 구역 안'이면
# 소실 시 추락 후보로 잡는다. 발판에 서 있다가 화각 밖으로 떨어지는 사람은
# 소실 순간 STANDING 이라 _DANGEROUS 조건만으로는 영원히 안 걸린다.
self._height: dict[int, dict] = {}
# 후보: track_id -> (spatial(x,y), last_state, height, lost_at) # 후보: track_id -> (spatial(x,y), last_state, height, lost_at)
self._candidates: dict[int, tuple] = {} self._candidates: dict[int, tuple] = {}
def note_state(self, track_id: int, state: str) -> None: def note_state(self, track_id: int, state: str) -> None:
self._states[track_id] = state self._states[track_id] = state
def arm_height(self, track_id: int, descent: dict | None, elevated: bool) -> None:
"""이 트랙을 추락 후보로 표시한다. 소실되면 fall_from_height 로 발보한다."""
info = self._height.setdefault(track_id, {})
if descent:
info.update(descent)
info["armed_by"] = "descent"
elif elevated and "armed_by" not in info:
info["armed_by"] = "zone"
if elevated:
info["elevated_zone"] = True
def on_lost(self, lost_tracks: list[Track], now: float) -> None: def on_lost(self, lost_tracks: list[Track], now: float) -> None:
for tr in lost_tracks: for tr in lost_tracks:
state = self._states.get(tr.track_id, "STANDING") state = self._states.get(tr.track_id, "STANDING")
height = self._height.get(tr.track_id) if state in _DANGEROUS:
if state in _DANGEROUS or height:
b = tr.latest.bbox b = tr.latest.bbox
# Snapshot the OTHER tracks active at loss time. Only a track that did NOT # Snapshot the OTHER tracks active at loss time. Only a track that did NOT
# exist then can be the victim re-appearing (a re-acquired track gets a new # exist then can be the victim re-appearing (a re-acquired track gets a new
# id); a track already present is a bystander/colleague and must not cancel # id); a track already present is a bystander/colleague and must not cancel
# the incident by walking over the loss point. # the incident by walking over the loss point.
bystanders = set(self._states) - {tr.track_id} bystanders = set(self._states) - {tr.track_id}
self._candidates[tr.track_id] = ((b.cx, b.cy), state, b.height, now, self._candidates[tr.track_id] = ((b.cx, b.cy), state, b.height, now, bystanders)
bystanders, height)
self._states.pop(tr.track_id, None) self._states.pop(tr.track_id, None)
self._height.pop(tr.track_id, None)
def update(self, active_tracks: list[Track], now: float) -> list[dict]: def update(self, active_tracks: list[Track], now: float) -> list[dict]:
# 인근 재출현이면 후보 취소 — 단, 손실 시점에 없던 새 트랙만 (진짜 재출현). # 인근 재출현이면 후보 취소 — 단, 손실 시점에 없던 새 트랙만 (진짜 재출현).
for tid, (spatial, _state, height, _lost_at, bystanders, _h) in list(self._candidates.items()): for tid, (spatial, _state, height, _lost_at, bystanders) in list(self._candidates.items()):
for tr in active_tracks: for tr in active_tracks:
if tr.track_id in bystanders: if tr.track_id in bystanders:
continue # a colleague who was already here — not the reappearing victim continue # a colleague who was already here — not the reappearing victim
@ -65,14 +47,9 @@ class GhostIncidentDetector:
break break
# 대기 초과 방출 # 대기 초과 방출
out = [] out = []
for tid, (spatial, state, _height, lost_at, _by, hinfo) in list(self._candidates.items()): for tid, (spatial, state, _height, lost_at, _by) in list(self._candidates.items()):
if now - lost_at >= self.cfg.reappear_s: if now - lost_at >= self.cfg.reappear_s:
rec = {"track_id": tid, "spatial": spatial, "last_state": state, out.append({"track_id": tid, "spatial": spatial, "last_state": state,
"waited_s": round(now - lost_at, 1)} "waited_s": round(now - lost_at, 1)})
if hinfo:
# 추락 후보였다 — 엔진이 ghost 가 아니라 fall_from_height 로 올린다.
rec["height_fall"] = True
rec.update(hinfo)
out.append(rec)
self._candidates.pop(tid, None) self._candidates.pop(tid, None)
return out return out

View file

@ -1,100 +0,0 @@
"""추락(높이에서 떨어짐) 판정의 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

View file

@ -56,12 +56,9 @@ class OccupancyWatchdog:
self._lone_since.pop(zid, None) self._lone_since.pop(zid, None)
self._lone_fired.discard(zid) self._lone_fired.discard(zid)
# 정원 초과. `is not None` 이어야 한다 — 0 은 falsy 라 `if max_workers` 로 쓰면 # 정원 초과
# "아무도 있으면 안 되는 구역"(크레인 짐 아래·굴착기 선회 반경 = 깔림 사고의 전형)이
# 화면에는 규칙으로 저장돼 있는데 감지기는 통째로 꺼진 상태가 된다.
# 키가 아예 없는 것(제한 없음)과 0(아무도 안 됨)은 다른 뜻이다.
max_workers = rules.get("max_workers") max_workers = rules.get("max_workers")
if max_workers is not None and n > max_workers: if max_workers and n > max_workers:
start = self._over_since.setdefault(zid, now) start = self._over_since.setdefault(zid, now)
if now - start >= self.cfg.over_confirm_s and zid not in self._over_fired: if now - start >= self.cfg.over_confirm_s and zid not in self._over_fired:
self._over_fired.add(zid) self._over_fired.add(zid)

View file

@ -71,21 +71,6 @@ class ZoneIntrusionDetector:
fx, fy = fx / w, fy / h fx, fy = fx / w, fy / h
return (fx, fy) return (fx, fy)
def in_elevated_zone(self, track_id: int) -> bool:
"""이 트랙이 '고소' 표시된 구역 안에 있는가.
rules_json 의 `elevated: true` 한 키로 붙는다 — 스키마 변경이 없다. 운영자가 발판·
비계·개구부를 그려두면 추락 판정이 훨씬 정확해지지만, 구역이 없어도 하강 신호만으로
동작한다. 위험은 운영자가 선언하되, 선언이 없어도 최소한은 본다.
"""
active = self.active_zone_ids(track_id)
if not active:
return False
for z in self.zones:
if z.zone_id in active and (z.rules or {}).get("elevated"):
return True
return False
def active_zone_ids(self, track_id: int) -> set: def active_zone_ids(self, track_id: int) -> set:
return {z for (tid, z) in self._inside_since if tid == track_id} return {z for (tid, z) in self._inside_since if tid == track_id}

View file

@ -8,7 +8,6 @@ from edge.detectors.ghost import GhostIncidentDetector
from edge.detectors.zone import ZoneIntrusionDetector from edge.detectors.zone import ZoneIntrusionDetector
from edge.detectors.occupancy import OccupancyWatchdog, OccupancyConfig from edge.detectors.occupancy import OccupancyWatchdog, OccupancyConfig
from edge.detectors.ergonomics import ErgoMonitor, ErgoConfig from edge.detectors.ergonomics import ErgoMonitor, ErgoConfig
from edge.detectors.height import DescentWatch, DescentConfig
@dataclass @dataclass
@ -26,8 +25,7 @@ class EventEngine:
def __init__(self, fall: FallDetector, immobility: ImmobilityWatchdog, def __init__(self, fall: FallDetector, immobility: ImmobilityWatchdog,
ghost: GhostIncidentDetector, zone: ZoneIntrusionDetector, ghost: GhostIncidentDetector, zone: ZoneIntrusionDetector,
cfg: EngineConfig, occupancy: OccupancyWatchdog | None = None, cfg: EngineConfig, occupancy: OccupancyWatchdog | None = None,
ergonomics: ErgoMonitor | None = None, ergonomics: ErgoMonitor | None = None):
descent: DescentWatch | None = None):
self.fall = fall self.fall = fall
self.immobility = immobility self.immobility = immobility
self.ghost = ghost self.ghost = ghost
@ -35,7 +33,6 @@ class EventEngine:
# Keyword-with-default so the existing 5-arg constructors (runtime, tests) keep working. # Keyword-with-default so the existing 5-arg constructors (runtime, tests) keep working.
self.occupancy = occupancy if occupancy is not None else OccupancyWatchdog(OccupancyConfig()) self.occupancy = occupancy if occupancy is not None else OccupancyWatchdog(OccupancyConfig())
self.ergonomics = ergonomics if ergonomics is not None else ErgoMonitor(ErgoConfig()) self.ergonomics = ergonomics if ergonomics is not None else ErgoMonitor(ErgoConfig())
self.descent = descent if descent is not None else DescentWatch(DescentConfig())
self.cfg = cfg self.cfg = cfg
self._last_emit: dict[tuple, float] = {} self._last_emit: dict[tuple, float] = {}
@ -51,7 +48,6 @@ class EventEngine:
self.fall.forget(track_id) self.fall.forget(track_id)
self.immobility.forget(track_id) self.immobility.forget(track_id)
self.zone.forget(track_id) self.zone.forget(track_id)
self.descent.forget(track_id)
self.occupancy.forget(track_id) self.occupancy.forget(track_id)
self._last_emit = {k: v for k, v in self._last_emit.items() self._last_emit = {k: v for k, v in self._last_emit.items()
if not (len(k) >= 2 and k[1] == track_id)} if not (len(k) >= 2 and k[1] == track_id)}
@ -84,14 +80,6 @@ class EventEngine:
# 계산하는 좌표계를 그대로 재사용해 존 폴리곤과 같은 공간에 놓인다. # 계산하는 좌표계를 그대로 재사용해 존 폴리곤과 같은 공간에 놓인다.
foot = self.zone._foot(tr, frame_wh) foot = self.zone._foot(tr, frame_wh)
# 하강 감시는 낙상 판정보다 먼저 — 같은 틱에 확정된 넘어짐을 승격할 수 있어야 한다.
descent = self.descent.update(tr, now)
elevated = self.zone.in_elevated_zone(tr.track_id)
if descent is not None or elevated:
# 위험 자세가 아니어도 ghost 를 무장시킨다. 발판에 서 있다가 화각 밖으로
# 떨어지는 사람은 소실 순간 STANDING 이라, 기존 무장 조건으로는 안 걸린다.
self.ghost.arm_height(tr.track_id, descent, elevated)
sig, meta = self.fall.update(tr, now) sig, meta = self.fall.update(tr, now)
state = self.fall._states.get(tr.track_id) state = self.fall._states.get(tr.track_id)
self.ghost.note_state(tr.track_id, state.name if state else "STANDING") self.ghost.note_state(tr.track_id, state.name if state else "STANDING")
@ -99,20 +87,9 @@ class EventEngine:
# 인체공학 노출이 아니라 이미 사고로 처리 중인 상태다. # 인체공학 노출이 아니라 이미 사고로 처리 중인 상태다.
if state is None or state.name == "STANDING": if state is None or state.name == "STANDING":
self.ergonomics.update(tr, now) self.ergonomics.update(tr, now)
if sig == "fall": if sig == "fall" and self._allow((EventType.FALL, tr.track_id), now):
# 방금 떨어진 사람이 바닥에서 확정되면 그것은 넘어짐이 아니라 추락이다. events.append(self._mk(EventType.FALL, occurred_at, tr.track_id,
# 두 건으로 올리지 않고 유형만 올린다 — 한 번의 사고는 한 건이다. metrics=meta, spatial=foot))
falling = self.descent.descending(tr.track_id, now)
etype = EventType.FALL_FROM_HEIGHT if (falling or elevated) else EventType.FALL
if self._allow((etype, tr.track_id), now):
m = dict(meta)
d = self.descent.detail(tr.track_id)
if d:
m.update(d)
if elevated:
m["elevated_zone"] = True
events.append(self._mk(etype, occurred_at, tr.track_id,
metrics=m, spatial=foot))
elif sig == "no_motion" and self._allow((EventType.NO_MOTION, tr.track_id), now): elif sig == "no_motion" and self._allow((EventType.NO_MOTION, tr.track_id), now):
events.append(self._mk(EventType.NO_MOTION, occurred_at, tr.track_id, events.append(self._mk(EventType.NO_MOTION, occurred_at, tr.track_id,
metrics=meta, spatial=foot)) metrics=meta, spatial=foot))
@ -173,19 +150,10 @@ class EventEngine:
w, h = frame_wh w, h = frame_wh
if w > 0 and h > 0: if w > 0 and h > 0:
gs = (gs[0] / w, gs[1] / h) gs = (gs[0] / w, gs[1] / h)
gmeta = {"last_state": g["last_state"], "waited_s": g["waited_s"]} events.append(self._mk(EventType.GHOST, occurred_at, track_id=g["track_id"],
# 추락 후보로 무장돼 있었으면 '위험 상태 소실'이 아니라 '추락'이다. 화각 밖으로 spatial=gs,
# 떨어진 사람에게는 이쪽이 유일한 발보 경로이므로 유형이 정확해야 한다. metrics={"last_state": g["last_state"],
if g.get("height_fall"): "waited_s": g["waited_s"]}))
for k in ("drop_ratio", "drop_s", "from_y2", "to_y2", "armed_by", "elevated_zone"):
if k in g:
gmeta[k] = g[k]
gmeta["exited_frame"] = True
etype = EventType.FALL_FROM_HEIGHT
else:
etype = EventType.GHOST
events.append(self._mk(etype, occurred_at, track_id=g["track_id"],
spatial=gs, metrics=gmeta))
# ghost.on_lost already consumed lost tracks' state above, so forgetting here is safe. # ghost.on_lost already consumed lost tracks' state above, so forgetting here is safe.
for tr in lost_tracks: for tr in lost_tracks:

View file

@ -5,7 +5,6 @@ from enum import Enum
class EventType(str, Enum): class EventType(str, Enum):
FALL = "fall" FALL = "fall"
FALL_FROM_HEIGHT = "fall_from_height" # 높이에서 떨어짐 — 넘어짐과 다른 사건
NO_MOTION = "no_motion" NO_MOTION = "no_motion"
IMMOBILITY = "immobility" IMMOBILITY = "immobility"
GHOST = "ghost" GHOST = "ghost"
@ -45,9 +44,6 @@ _SEVERITY = {
EventType.NO_MOTION: Severity.CRITICAL, EventType.NO_MOTION: Severity.CRITICAL,
EventType.GHOST: Severity.CRITICAL, EventType.GHOST: Severity.CRITICAL,
EventType.FALL: Severity.HIGH, EventType.FALL: Severity.HIGH,
# 추락은 척추·두부 손상 가능성이 있어 함부로 옮기면 안 된다. 그 주의가 통보에
# 실리려면 넘어짐(HIGH)과 등급이 갈라져야 한다.
EventType.FALL_FROM_HEIGHT: Severity.CRITICAL,
EventType.ZONE_RESTRICTED: Severity.HIGH, EventType.ZONE_RESTRICTED: Severity.HIGH,
EventType.PINCH: Severity.HIGH, EventType.PINCH: Severity.HIGH,
EventType.IMMOBILITY: Severity.WARNING, EventType.IMMOBILITY: Severity.WARNING,

View file

@ -1,39 +0,0 @@
-- 1.2.0 — 추락(fall_from_height) 유형 추가
--
-- 이 제품이 잡던 것은 같은 높이에서의 '넘어짐'(fall)이었고, 산업재해 사망 1위 유형인
-- '떨어짐'은 잡지 못했다. 둘은 다른 사건이라 유형을 나눈다:
-- fall — 같은 높이에서 쓰러짐. 심각도 HIGH.
-- fall_from_height — 높이에서 떨어짐. 심각도 CRITICAL.
-- 척추·두부 손상 가능성이 있어 함부로 옮기면 안 되고, 그 주의가 통보에 실려야 한다.
--
-- 적용 조건: 2026-08-09-safety-platform.sql 이후.
--
-- ★ 이 블록은 문법이 DB 엔진마다 다릅니다 — 2026-08-09 마이그레이션 §9 의 안내와 동일합니다.
--
-- 이 CHECK 는 **2차 방어선**이지 검증 계층이 아닙니다. 1차 검증은 App\Ingest\EventValidator
-- 이고(그쪽은 이미 신규 유형을 받습니다), tests/test_event_type_parity.py 가 둘의 일치를
-- 강제합니다. 따라서 이 블록을 건너뛰어도 시스템은 동작하지만, **신규 유형의 INSERT 가
-- 제약 위반으로 거부**되므로 아래 셋 중 맞는 것을 반드시 하나 실행하십시오.
--
-- (A) MySQL 8.0.16 이상:
-- ALTER TABLE move_events DROP CHECK chk_events_type;
--
-- (B) MariaDB (10.2.22+):
-- ALTER TABLE move_events DROP CONSTRAINT chk_events_type;
--
-- (C) 제약이 애초에 없는 환경 (MySQL 5.7):
-- DROP 을 건너뛰고 아래 ADD 만 실행하십시오.
--
-- 어느 것인지 모르겠다면: SELECT VERSION();
ALTER TABLE move_events DROP CHECK chk_events_type;
ALTER TABLE move_events
ADD CONSTRAINT chk_events_type CHECK (event_type IN (
'fall','fall_from_height','no_motion','immobility','ghost','zone_warning',
'zone_restricted','pinch','monitoring_degraded','device_fault',
'lone_worker','overcapacity','overstay','near_fall','ergo_report',
'camera_moved','camera_obstructed','low_light',
'unacked_escalation','multi_down',
'ppe_missing','proximity','fire_smoke'
));

View file

@ -25,14 +25,14 @@
// Event types arrive as wire codes ("fall") but the console reads in Korean. An unknown // Event types arrive as wire codes ("fall") but the console reads in Korean. An unknown
// code (a newer edge than this dashboard) falls through as the raw code — never blank. // code (a newer edge than this dashboard) falls through as the raw code — never blank.
var TYPE_LABELS = { var TYPE_LABELS = {
fall: '넘어짐', fall_from_height: '추락', no_motion: '넘어짐 후 미동 없음', immobility: '장시간 부동', fall: '넘어짐', no_motion: '넘어짐 후 미동 없음', immobility: '장시간 부동',
ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입', ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입',
pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상', pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상',
lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과', lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과',
near_fall: '넘어질 뻔함 (니어미스)', ergo_report: '작업자세 부담 리포트', near_fall: '넘어질 뻔함 (니어미스)', ergo_report: '작업자세 부담 리포트',
// 감시 자체의 건전성 — 존 판정이 무의미해졌음을 알리는 유형들. // 감시 자체의 건전성 — 존 판정이 무의미해졌음을 알리는 유형들.
camera_moved: '카메라 화각 변경', camera_obstructed: '카메라 가림', low_light: '조도 저하', camera_moved: '카메라 화각 변경', camera_obstructed: '카메라 가림', low_light: '조도 저하',
unacked_escalation: '미확인 경보 에스컬레이션', multi_down: '동시 다발 쓰러짐', unacked_escalation: '미확인 경보 에스컬레이션',
// 모델 확보 후 활성화 (엣지는 아직 발화하지 않음). // 모델 확보 후 활성화 (엣지는 아직 발화하지 않음).
ppe_missing: '보호구 미착용', proximity: '중장비 근접', fire_smoke: '화재·연기' ppe_missing: '보호구 미착용', proximity: '중장비 근접', fire_smoke: '화재·연기'
}; };

View file

@ -13,13 +13,13 @@
var csrf = null; var csrf = null;
var TYPE_LABELS = { var TYPE_LABELS = {
fall: '넘어짐', fall_from_height: '추락', no_motion: '넘어짐 후 미동 없음', immobility: '장시간 부동', fall: '넘어짐', no_motion: '넘어짐 후 미동 없음', immobility: '장시간 부동',
ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입', ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입',
pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상', pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상',
lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과', lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과',
near_fall: '넘어질 뻔함 (니어미스)', ergo_report: '작업자세 부담 리포트', near_fall: '넘어질 뻔함 (니어미스)', ergo_report: '작업자세 부담 리포트',
camera_moved: '카메라 화각 변경', camera_obstructed: '카메라 가림', low_light: '조도 저하', camera_moved: '카메라 화각 변경', camera_obstructed: '카메라 가림', low_light: '조도 저하',
unacked_escalation: '미확인 경보 에스컬레이션', multi_down: '동시 다발 쓰러짐', unacked_escalation: '미확인 경보 에스컬레이션',
ppe_missing: '보호구 미착용', proximity: '중장비 근접', fire_smoke: '화재·연기' ppe_missing: '보호구 미착용', proximity: '중장비 근접', fire_smoke: '화재·연기'
}; };

View file

@ -29,14 +29,14 @@
// Same wire-code -> Korean map as app.js (the two pages share no module system); // Same wire-code -> Korean map as app.js (the two pages share no module system);
// an unknown code falls through as the raw code — never blank. // an unknown code falls through as the raw code — never blank.
var TYPE_LABELS = { var TYPE_LABELS = {
fall: '넘어짐', fall_from_height: '추락', no_motion: '넘어짐 후 미동 없음', immobility: '장시간 부동', fall: '넘어짐', no_motion: '넘어짐 후 미동 없음', immobility: '장시간 부동',
ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입', ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입',
pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상', pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상',
lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과', lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과',
near_fall: '넘어질 뻔함 (니어미스)', ergo_report: '작업자세 부담 리포트', near_fall: '넘어질 뻔함 (니어미스)', ergo_report: '작업자세 부담 리포트',
// 감시 자체의 건전성 — 존 판정이 무의미해졌음을 알리는 유형들. // 감시 자체의 건전성 — 존 판정이 무의미해졌음을 알리는 유형들.
camera_moved: '카메라 화각 변경', camera_obstructed: '카메라 가림', low_light: '조도 저하', camera_moved: '카메라 화각 변경', camera_obstructed: '카메라 가림', low_light: '조도 저하',
unacked_escalation: '미확인 경보 에스컬레이션', multi_down: '동시 다발 쓰러짐', unacked_escalation: '미확인 경보 에스컬레이션',
// 모델 확보 후 활성화 (엣지는 아직 발화하지 않음). // 모델 확보 후 활성화 (엣지는 아직 발화하지 않음).
ppe_missing: '보호구 미착용', proximity: '중장비 근접', fire_smoke: '화재·연기' ppe_missing: '보호구 미착용', proximity: '중장비 근접', fire_smoke: '화재·연기'
}; };

View file

@ -22,13 +22,13 @@
var id = m[1]; var id = m[1];
var TYPE_LABELS = { var TYPE_LABELS = {
fall: '넘어짐', fall_from_height: '추락', no_motion: '넘어짐 후 미동 없음', immobility: '장시간 부동', fall: '넘어짐', no_motion: '넘어짐 후 미동 없음', immobility: '장시간 부동',
ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입', ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입',
pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상', pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상',
lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과', lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과',
near_fall: '넘어질 뻔함 (니어미스)', ergo_report: '작업자세 부담 리포트', near_fall: '넘어질 뻔함 (니어미스)', ergo_report: '작업자세 부담 리포트',
camera_moved: '카메라 화각 변경', camera_obstructed: '카메라 가림', low_light: '조도 저하', camera_moved: '카메라 화각 변경', camera_obstructed: '카메라 가림', low_light: '조도 저하',
unacked_escalation: '미확인 경보 에스컬레이션', multi_down: '동시 다발 쓰러짐', unacked_escalation: '미확인 경보 에스컬레이션',
ppe_missing: '보호구 미착용', proximity: '중장비 근접', fire_smoke: '화재·연기' ppe_missing: '보호구 미착용', proximity: '중장비 근접', fire_smoke: '화재·연기'
}; };
var SEV_LABELS = { critical: '위급', high: '높음', warning: '경고', info: '정보' }; var SEV_LABELS = { critical: '위급', high: '높음', warning: '경고', info: '정보' };

View file

@ -12,14 +12,14 @@
var B = window.APP_BASE || ''; var B = window.APP_BASE || '';
var TYPE_LABELS = { var TYPE_LABELS = {
fall: '넘어짐', fall_from_height: '추락', no_motion: '넘어짐 후 미동 없음', immobility: '장시간 부동', fall: '넘어짐', no_motion: '넘어짐 후 미동 없음', immobility: '장시간 부동',
ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입', ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입',
pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상', pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상',
lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과', lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과',
near_fall: '넘어질 뻔함 (니어미스)', ergo_report: '작업자세 부담 리포트', near_fall: '넘어질 뻔함 (니어미스)', ergo_report: '작업자세 부담 리포트',
// 감시 자체의 건전성 — 존 판정이 무의미해졌음을 알리는 유형들. // 감시 자체의 건전성 — 존 판정이 무의미해졌음을 알리는 유형들.
camera_moved: '카메라 화각 변경', camera_obstructed: '카메라 가림', low_light: '조도 저하', camera_moved: '카메라 화각 변경', camera_obstructed: '카메라 가림', low_light: '조도 저하',
unacked_escalation: '미확인 경보 에스컬레이션', multi_down: '동시 다발 쓰러짐', unacked_escalation: '미확인 경보 에스컬레이션',
// 모델 확보 후 활성화 (엣지는 아직 발화하지 않음). // 모델 확보 후 활성화 (엣지는 아직 발화하지 않음).
ppe_missing: '보호구 미착용', proximity: '중장비 근접', fire_smoke: '화재·연기' ppe_missing: '보호구 미착용', proximity: '중장비 근접', fire_smoke: '화재·연기'
}; };

View file

@ -233,20 +233,14 @@
// Occupancy rules the operator typed (dwell in MINUTES on screen, seconds on the wire). // Occupancy rules the operator typed (dwell in MINUTES on screen, seconds on the wire).
// Only filled fields are sent; an empty form means "no rules" and stores NULL. // Only filled fields are sent; an empty form means "no rules" and stores NULL.
// 프리셋이 elevated 를 켜면 화면에 입력란이 없으므로 여기 들고 있는다(고소 구역 표시).
var elevated = false;
function rulesFromInputs() { function rulesFromInputs() {
var rules = {}; var rules = {};
var minW = parseInt(ruleMinInput && ruleMinInput.value, 10); var minW = parseInt(ruleMinInput && ruleMinInput.value, 10);
var maxW = parseInt(ruleMaxInput && ruleMaxInput.value, 10); var maxW = parseInt(ruleMaxInput && ruleMaxInput.value, 10);
var dwellMin = parseInt(ruleDwellInput && ruleDwellInput.value, 10); var dwellMin = parseInt(ruleDwellInput && ruleDwellInput.value, 10);
if (minW > 0) rules.min_workers = minW; if (minW > 0) rules.min_workers = minW;
// 최대 인원은 0 도 유효한 값이다 — "아무도 있으면 안 되는 구역"(크레인 짐 아래·선회 반경). if (maxW > 0) rules.max_workers = maxW;
// `> 0` 으로 거르면 운영자가 0 을 넣어도 규칙이 저장되지 않는다.
if (maxW >= 0) rules.max_workers = maxW;
if (dwellMin > 0) rules.max_dwell_s = dwellMin * 60; if (dwellMin > 0) rules.max_dwell_s = dwellMin * 60;
if (elevated) rules.elevated = true;
return rules; return rules;
} }
@ -255,8 +249,7 @@
try { r = JSON.parse(z.rules_json || 'null') || {}; } catch (e) { return ''; } try { r = JSON.parse(z.rules_json || 'null') || {}; } catch (e) { return ''; }
var parts = []; var parts = [];
if (r.min_workers) parts.push('최소 ' + r.min_workers + '인'); if (r.min_workers) parts.push('최소 ' + r.min_workers + '인');
if (r.max_workers === 0) parts.push('출입 금지'); if (r.max_workers) parts.push('최대 ' + r.max_workers + '인');
else if (r.max_workers) parts.push('최대 ' + r.max_workers + '인');
if (r.max_dwell_s) parts.push('체류 ' + Math.round(r.max_dwell_s / 60) + '분'); if (r.max_dwell_s) parts.push('체류 ' + Math.round(r.max_dwell_s / 60) + '분');
return parts.length ? ' · ' + parts.join(' · ') : ''; return parts.length ? ' · ' + parts.join(' · ') : '';
} }
@ -315,56 +308,6 @@
}); });
} }
// --- 용도 프리셋 -------------------------------------------------------------------
//
// 밀폐공간 감시에 필요한 것(제한구역 + 2인1조 + 체류 상한)은 전부 이미 있었는데, 화면이
// "최소 인원을 넣으세요"라고만 물어서 아무도 그렇게 쓰지 못했다. 안전관리자는 min_workers 가
// 아니라 '밀폐공간'을 생각한다. 프리셋은 새 기능이 아니라 그 어휘를 붙인 것이다.
//
// 고른 뒤 값을 손으로 바꾸는 것은 그대로 허용한다 — 프리셋은 출발점이지 잠금이 아니다.
var presetSelect = document.getElementById('zone-preset');
var presetHelp = document.getElementById('zone-preset-help');
var presets = {};
function applyPreset(id) {
var p = presets[id];
if (!p) {
elevated = false;
if (presetHelp) { presetHelp.hidden = true; presetHelp.textContent = ''; }
return;
}
if (levelSelect) levelSelect.value = p.level;
var r = p.rules || {};
if (ruleMinInput) ruleMinInput.value = r.min_workers != null ? r.min_workers : '';
if (ruleMaxInput) ruleMaxInput.value = r.max_workers != null ? r.max_workers : '';
if (ruleDwellInput) ruleDwellInput.value = r.max_dwell_s != null ? Math.round(r.max_dwell_s / 60) : '';
elevated = !!r.elevated;
if (presetHelp) {
presetHelp.textContent = p.help + ' (잡는 것: ' + p.catches + ')';
presetHelp.hidden = false;
}
}
function loadPresets() {
if (!presetSelect) return Promise.resolve();
return fetch(B + '/api/zone-presets', { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (data) {
var list = (data && data.presets) || [];
list.forEach(function (p) {
presets[p.id] = p;
var o = document.createElement('option');
o.value = p.id;
o.textContent = p.label + ' — ' + p.catches;
presetSelect.appendChild(o);
});
presetSelect.addEventListener('change', function () { applyPreset(presetSelect.value); });
})
.catch(function () { /* 프리셋이 없어도 직접 설정으로 그대로 쓸 수 있다 */ });
}
loadPresets();
// --- 장치 · 카메라 선택기 --------------------------------------------------------- // --- 장치 · 카메라 선택기 ---------------------------------------------------------
// The editor used to demand two free-text ids the operator had to remember, while the // The editor used to demand two free-text ids the operator had to remember, while the
// events console already knew both. Devices come from /api/devices; the cameras under each // events console already knew both. Devices come from /api/devices; the cameras under each

View file

@ -132,7 +132,6 @@ $r->add('GET', '/api/devices', fn(Request $q) => $deviceStatus->json($q, $now));
$r->add('GET', '/events/{id}', fn(Request $q, array $p) => $detail->show($q, $p)); $r->add('GET', '/events/{id}', fn(Request $q, array $p) => $detail->show($q, $p));
$r->add('POST', '/events/{id}/ack', fn(Request $q, array $p) => $detail->acknowledge($q, $p)); $r->add('POST', '/events/{id}/ack', fn(Request $q, array $p) => $detail->acknowledge($q, $p));
$r->add('POST', '/events/{id}/false-positive', fn(Request $q, array $p) => $detail->falsePositive($q, $p)); $r->add('POST', '/events/{id}/false-positive', fn(Request $q, array $p) => $detail->falsePositive($q, $p));
$r->add('GET', '/api/zone-presets', fn(Request $q) => \App\Http\Response::json(['presets' => \App\Api\ZonePresetCatalog::all()]));
$r->add('GET', '/api/zones/{camera}', fn(Request $q, array $p) => $zoneCtl->list($q, $p)); $r->add('GET', '/api/zones/{camera}', fn(Request $q, array $p) => $zoneCtl->list($q, $p));
$r->add('GET', '/api/heatmap/{camera}', fn(Request $q, array $p) => $heatmap->json($q, $p)); $r->add('GET', '/api/heatmap/{camera}', fn(Request $q, array $p) => $heatmap->json($q, $p));
$r->add('GET', '/api/fp-stats', fn(Request $q) => $fpStats->json($q)); $r->add('GET', '/api/fp-stats', fn(Request $q) => $fpStats->json($q));

View file

@ -163,13 +163,13 @@ CREATE TABLE move_events (
KEY ix_events_device (device_id), KEY ix_events_device (device_id),
KEY ix_events_camera (camera_id, event_id), -- zone editor: newest snapshot per camera KEY ix_events_camera (camera_id, event_id), -- zone editor: newest snapshot per camera
CONSTRAINT chk_events_type CHECK (event_type IN ( CONSTRAINT chk_events_type CHECK (event_type IN (
'fall','fall_from_height','no_motion','immobility','ghost','zone_warning', 'fall','no_motion','immobility','ghost','zone_warning',
'zone_restricted','pinch','monitoring_degraded','device_fault', 'zone_restricted','pinch','monitoring_degraded','device_fault',
'lone_worker','overcapacity','overstay','near_fall','ergo_report', 'lone_worker','overcapacity','overstay','near_fall','ergo_report',
-- 감시 자체의 건전성: 카메라가 틀어지거나 가려지면 존 판정이 조용히 무의미해진다. -- 감시 자체의 건전성: 카메라가 틀어지거나 가려지면 존 판정이 조용히 무의미해진다.
'camera_moved','camera_obstructed','low_light', 'camera_moved','camera_obstructed','low_light',
-- 아무도 확인하지 않았다는 사실 자체를 사건으로 만든다 (에스컬레이션 디스패처가 발행). -- 아무도 확인하지 않았다는 사실 자체를 사건으로 만든다 (에스컬레이션 디스패처가 발행).
'unacked_escalation','multi_down', 'unacked_escalation',
-- 모델 확보 후 활성화되는 자리. 엣지 감지기는 있으나 기본 비활성이다. -- 모델 확보 후 활성화되는 자리. 엣지 감지기는 있으나 기본 비활성이다.
'ppe_missing','proximity','fire_smoke' 'ppe_missing','proximity','fire_smoke'
)), )),

View file

@ -28,9 +28,7 @@ use App\DeviceScope;
final class Channels final class Channels
{ {
private const TYPE_LABELS = [ private const TYPE_LABELS = [
'fall' => '넘어짐', 'fall_from_height' => '추락', 'fall' => '넘어짐', 'no_motion' => '넘어짐 후 미동 없음', 'immobility' => '장시간 부동',
'multi_down' => '동시 다발 쓰러짐',
'no_motion' => '넘어짐 후 미동 없음', 'immobility' => '장시간 부동',
'ghost' => '위험 상태 소실', 'zone_warning' => '경고구역 진입', 'zone_restricted' => '제한구역 침입', 'ghost' => '위험 상태 소실', 'zone_warning' => '경고구역 진입', 'zone_restricted' => '제한구역 침입',
'pinch' => '협착구역 침입', 'monitoring_degraded' => '감시 성능 저하', 'device_fault' => '장치 이상', 'pinch' => '협착구역 침입', 'monitoring_degraded' => '감시 성능 저하', 'device_fault' => '장치 이상',
'lone_worker' => '위험구역 단독 작업', 'overcapacity' => '정원 초과', 'overstay' => '체류 상한 초과', 'lone_worker' => '위험구역 단독 작업', 'overcapacity' => '정원 초과', 'overstay' => '체류 상한 초과',
@ -57,45 +55,6 @@ final class Channels
) {} ) {}
/** Wall-clock stamp in the configured zone, without touching the process-wide default. */ /** Wall-clock stamp in the configured zone, without touching the process-wide default. */
/**
* 유형별 대응 안내. 통보 본문 끝에 붙는다.
*
* multi_down 은 이 함수가 존재하는 이유다. 밀폐공간 사고에서 사람이 가장 많이 죽는 방식은
* 첫 번째 사람이 왜 쓰러졌는지 모르고 **구조하러 들어간 사람이 같이 죽는 것**이다.
* 그 상황에서 사람을 살리는 것은 감지가 아니라 이 문장이므로, 링크를 눌러 화면을 열기
* 전에 메일 본문에서 바로 읽혀야 한다.
*/
private static function guidance(array $event): string
{
$type = (string)($event['event_type'] ?? '');
if ($type === 'multi_down') {
$people = (int)(($event['metrics']['people'] ?? 0));
$n = $people > 0 ? "{$people}명" : "2명 이상";
return "
" . implode("
", [
"",
"──────────────────────────────",
"같은 구역에서 짧은 시간에 {$n}이 쓰러졌습니다.",
"가스 누출 · 산소 결핍 · 폭발을 의심하십시오.",
"",
"⚠ 공기호흡기 없이 진입하지 마십시오.",
" 밀폐공간 사고 사망자의 상당수가 구조하러 들어간 사람입니다.",
"──────────────────────────────",
]);
}
if ($type === 'fall_from_height') {
return "
" . implode("
", [
"",
"⚠ 추락입니다. 척추·두부 손상 가능성이 있으므로",
" 119 도착 전까지 함부로 옮기지 마십시오.",
]);
}
return '';
}
private function stamp(int $ts): string private function stamp(int $ts): string
{ {
try { try {
@ -148,7 +107,7 @@ final class Channels
"", "",
"이 메일에는 개인정보 보호를 위해 현장 이미지가 포함되지 않습니다.", "이 메일에는 개인정보 보호를 위해 현장 이미지가 포함되지 않습니다.",
"이미지는 위 링크에서 로그인 후에만 열람할 수 있습니다.", "이미지는 위 링크에서 로그인 후에만 열람할 수 있습니다.",
]) . self::guidance($event); ]);
} }
private function sendEmail(array $event, array $config): void private function sendEmail(array $event, array $config): void

View file

@ -1,162 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Alert;
use App\Repository\EventRepository;
/**
* 같은 자리에서 짧은 시간에 두 명 이상이 쓰러진 것을 하나의 사건으로 올린다.
*
* WHY THIS IS NOT "두 건의 넘어짐"
* 한 사람이 넘어지는 것은 사고다. 같은 구역에서 2분 안에 두 사람이 쓰러지는 것은
* **원인이 공기에 있다**는 뜻이다 — 산소 결핍, 유해가스, 폭발.
*
* WHY THE VALUE IS IN THE MESSAGE, NOT THE DETECTION
* 밀폐공간 사고에서 사람이 가장 많이 죽는 방식은 첫 번째 사람이 왜 쓰러졌는지 모르고
* **구조하러 들어간 사람이 같이 죽는 것**이다. 그래서 이 이벤트의 통보에는
* "공기호흡기 없이 진입하지 마십시오"가 반드시 실려야 한다 (App\Alert\Channels).
* 감지가 사람을 살리는 것이 아니라 그 문장이 살린다.
*
* WHY SERVER-SIDE
* 가스는 카메라 화각을 존중하지 않는다. 노드가 다르면 엣지는 서로를 모른다. 그리고
* 현장 즉시 대응은 이미 확보돼 있다 — 첫 번째 쓰러짐에서 경광등이 벌써 울린다.
* 서버가 몇십 초 늦게 판단해도 잃는 것이 없다.
*
* FALSE-POSITIVE DEFENCE
* 넓은 공장에서 2분 안의 무관한 넘어짐 두 건은 실제로 일어난다. 그래서 시간만으로는
* 묶지 않는다: **같은 구역**이거나, 두 사건의 spatial 좌표가 가까워야 한다.
* 그리고 같은 사람이 넘어졌다 회복했다 다시 넘어진 것은 한 명으로 센다.
*/
final class MultiDownWatch
{
/** 사람이 쓰러졌다는 뜻의 유형. ergo/near_fall 처럼 통계용은 제외한다. */
private const DOWN_TYPES = ['fall', 'fall_from_height', 'no_motion', 'ghost'];
public function __construct(private EventRepository $events) {}
/** 이 무리를 식별하는 결정적 uuid. 인원수를 넣어 사람이 늘면 한 번 더 올라오게 한다. */
public static function uuidFor(string $scopeKey, int $firstEventId, int $people): string
{
$seed = "multi_down:$scopeKey:$firstEventId:$people";
return substr('4d0117d0' . substr(hash('sha256', $seed), 0, 24), 0, 32);
}
/**
* @param array{window_s?:int,min_people?:int,radius?:float,limit?:int} $opts
* @return array<int,array{event_id:int,people:int,scope:string}>
*/
public function run(int $now, array $opts = []): array
{
$window = max(30, (int)($opts['window_s'] ?? 120));
$minPeople = max(2, (int)($opts['min_people'] ?? 2));
$radius = max(0.0, (float)($opts['radius'] ?? 0.35));
$limit = max(1, (int)($opts['limit'] ?? 500));
$rows = [];
foreach ($this->events->forNotification((float)($now - $window), $limit) as $ev) {
if (!in_array($ev['event_type'] ?? '', self::DOWN_TYPES, true)) continue;
$rows[] = $ev;
}
if (count($rows) < $minPeople) return [];
$filed = [];
foreach ($this->groups($rows, $radius) as $scopeKey => $group) {
// 같은 사람이 여러 번 쓰러진 것은 한 명이다.
$people = [];
foreach ($group as $ev) {
$people[$this->personKey($ev)] = true;
}
if (count($people) < $minPeople) continue;
$first = $group[0];
$uuid = self::uuidFor((string)$scopeKey, (int)$first['event_id'], count($people));
if ($this->events->idByUuid($uuid) !== null) continue;
$ids = array_map(static fn (array $e): int => (int)$e['event_id'], $group);
$last = $group[count($group) - 1];
try {
$id = $this->events->insert([
'event_uuid' => $uuid,
'device_id' => $first['device_id'],
'site' => $first['site'] ?? null,
'camera_id' => $first['camera_id'],
'event_type' => 'multi_down',
'severity' => 'critical',
'track_id' => null,
'zone_id' => $first['zone_id'] ?? null,
'occurred_at' => (float)$now,
'duration_s' => (float)((int)$last['occurred_at'] - (int)$first['occurred_at']),
'metrics' => [
'people' => count($people),
'origin_event_ids' => $ids,
'types' => array_values(array_unique(
array_map(static fn (array $e): string => (string)$e['event_type'], $group))),
'window_s' => $window,
'scope' => (string)$scopeKey,
'first_at' => (int)$first['occurred_at'],
'last_at' => (int)$last['occurred_at'],
],
'spatial_x' => $first['spatial_x'] ?? null,
'spatial_y' => $first['spatial_y'] ?? null,
]);
$filed[] = ['event_id' => $id, 'people' => count($people), 'scope' => (string)$scopeKey];
} catch (\Throwable $e) {
$m = $e->getMessage();
if (!str_contains($m, 'uq_events_uuid') && !str_contains($m, 'UNIQUE')
&& !str_contains($m, 'Duplicate')) {
throw $e;
}
}
}
return $filed;
}
/** 한 사람을 가리키는 키. 카메라가 다르면 track_id 가 같아도 다른 사람이다. */
private function personKey(array $ev): string
{
return ($ev['device_id'] ?? '') . '|' . ($ev['camera_id'] ?? '') . '|'
. ($ev['track_id'] ?? ('e' . ($ev['event_id'] ?? '')));
}
/**
* 공간적으로 같은 자리인 것끼리 묶는다. 구역이 있으면 구역이 우선 — 운영자가 "여기가
* 한 공간"이라고 선언한 것이 좌표 거리보다 정확하다.
*
* @return array<string,array<int,array>>
*/
private function groups(array $rows, float $radius): array
{
$out = [];
foreach ($rows as $ev) {
$zone = $ev['zone_id'] ?? null;
if ($zone !== null && $zone !== '') {
$out['zone:' . $zone][] = $ev;
continue;
}
$key = 'camera:' . ($ev['device_id'] ?? '') . '/' . ($ev['camera_id'] ?? '');
$placed = false;
// 좌표가 둘 다 있으면 거리로 다시 쪼갠다 — 같은 카메라라도 반대편 끝은 다른 사건이다.
if ($ev['spatial_x'] !== null && $ev['spatial_y'] !== null) {
foreach ($out as $k => $bucket) {
if (!str_starts_with((string)$k, $key)) continue;
$head = $bucket[0];
if ($head['spatial_x'] === null || $head['spatial_y'] === null) continue;
$dx = (float)$ev['spatial_x'] - (float)$head['spatial_x'];
$dy = (float)$ev['spatial_y'] - (float)$head['spatial_y'];
if (sqrt($dx * $dx + $dy * $dy) <= $radius) {
$out[$k][] = $ev;
$placed = true;
break;
}
}
if (!$placed) {
$out[$key . '@' . round((float)$ev['spatial_x'], 2)
. ',' . round((float)$ev['spatial_y'], 2)][] = $ev;
}
continue;
}
$out[$key][] = $ev;
}
return $out;
}
}

View file

@ -1,102 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Api;
/**
* 용도별 구역 프리셋 — 안전관리자의 어휘로 구역을 만들게 한다.
*
* WHY THIS EXISTS
* 밀폐공간 작업을 감시하는 데 필요한 것은 이미 전부 있다:
* 제한구역 + 2인1조(min_workers) + 체류 상한(max_dwell_s) + 정원(max_workers).
* 그런데 제품은 "폴리곤을 그리고 min_workers 를 넣으세요"라고만 말한다.
* 안전관리자는 min_workers 를 생각하지 않는다 — **밀폐공간**을 생각한다.
*
* 그래서 이 카탈로그는 새 감지 기능이 아니다. 이미 있는 규칙에 현장의 이름을 붙인 것이고,
* 코드 한 줄 없이 감지 범위를 체감상 넓히는 유일한 방법이다.
*
* NOT A LOCK
* 프리셋은 출발점이지 잠금이 아니다. 고른 뒤 값을 손으로 바꾸는 것은 그대로 허용한다 —
* 현장마다 체류 시간도 인원도 다르다. ThresholdCatalog 와 달리 여기 없는 조합을
* 막지도 않는다: 이것은 화이트리스트가 아니라 출발점 목록이다.
*
* RULE KEYS
* edge/detectors/occupancy.py 가 소비하는 키만 쓴다.
* min_workers : 이 인원 미만이 지속되면 단독 작업
* max_workers : 초과하면 정원 초과. **0 = 아무도 있으면 안 됨** (0 도 유효한 값이다)
* max_dwell_s : 한 사람이 이 시간을 넘겨 머무르면 체류 상한 초과
* elevated : 고소 구역 표시. 추락 판정의 보강 신호 (docs/설계-신규감지-3종.md §1-4)
*/
final class ZonePresetCatalog
{
/**
* @return array<int,array{id:string,label:string,level:string,rules:array<string,mixed>,
* catches:string,help:string}>
*/
public static function all(): array
{
return [
[
'id' => 'confined_space',
'label' => '밀폐공간',
'level' => 'restricted',
'rules' => ['min_workers' => 2, 'max_dwell_s' => 1800],
'catches' => '단독 진입 · 장시간 체류',
'help' => '탱크·맨홀·피트 등 산소 결핍이나 유해가스 위험이 있는 공간의 출입구를 감싸도록 그립니다. '
. '혼자 들어가 30초가 지나면 단독 작업으로, 30분을 넘겨 머무르면 체류 상한 초과로 올라옵니다.',
],
[
'id' => 'fall_edge',
'label' => '고소작업 · 개구부',
'level' => 'restricted',
'rules' => ['elevated' => true, 'min_workers' => 2],
'catches' => '추락 · 단독 고소작업',
'help' => '발판·비계·지붕 가장자리, 바닥 개구부를 그립니다. 이 구역 안에 있던 사람이 '
. '아래로 사라지면 추락으로 판정합니다. 떨어짐은 산업재해 사망 원인 1위입니다.',
],
[
'id' => 'pinch_point',
'label' => '프레스 · 협착',
'level' => 'pinch',
'rules' => ['max_workers' => 1],
'catches' => '협착 구역 침입 · 2인 동시 조작',
'help' => '프레스·롤러·컨베이어처럼 몸이 끼일 수 있는 기계 앞을 그립니다. '
. '두 사람이 동시에 들어가면 한 명이 조작하는 동안 다른 한 명이 손을 넣는 상황이 됩니다.',
],
[
'id' => 'no_go',
'label' => '출입 금지 (크레인 하부 · 선회 반경)',
'level' => 'restricted',
'rules' => ['max_workers' => 0],
'catches' => '사람이 있으면 안 되는 곳에 사람이 있음',
'help' => '크레인으로 든 짐 아래, 굴착기·크레인의 선회 반경처럼 작업 중에는 '
. '아무도 있어서는 안 되는 범위를 그립니다. 깔림 사고의 전형적인 위치입니다.',
],
[
'id' => 'forklift_lane',
'label' => '지게차 통로',
'level' => 'warning',
'rules' => ['max_dwell_s' => 60],
'catches' => '통로 내 보행 · 체류',
'help' => '지게차·대차가 다니는 통로를 그립니다. 지나가는 것은 정상이므로 경고 등급으로 두고, '
. '1분 넘게 머무르면 올라옵니다. 통로에 서서 작업하는 습관을 찾는 데 씁니다.',
],
[
'id' => 'hot_work',
'label' => '화기작업',
'level' => 'restricted',
'rules' => ['min_workers' => 2, 'max_dwell_s' => 3600],
'catches' => '화기감시자 부재 · 장시간 작업',
'help' => '용접·절단 등 화기작업 구역을 그립니다. 화기감시자 없이 혼자 작업하는 상태를 잡습니다.',
],
];
}
/** @return array<string,mixed>|null */
public static function find(string $id): ?array
{
foreach (self::all() as $preset) {
if ($preset['id'] === $id) return $preset;
}
return null;
}
}

View file

@ -18,13 +18,13 @@ final class EventValidator
* implemented, is fully tested on the device, and produces nothing. That is exactly what * implemented, is fully tested on the device, and produces nothing. That is exactly what
* happened to camera_moved/camera_obstructed/low_light when they were added. * happened to camera_moved/camera_obstructed/low_light when they were added.
*/ */
private const TYPES = ['fall','fall_from_height','no_motion','immobility','ghost','zone_warning', private const TYPES = ['fall','no_motion','immobility','ghost','zone_warning',
'zone_restricted','pinch','monitoring_degraded','device_fault', 'zone_restricted','pinch','monitoring_degraded','device_fault',
'lone_worker','overcapacity','overstay','near_fall','ergo_report', 'lone_worker','overcapacity','overstay','near_fall','ergo_report',
// 감시 자체의 건전성 (edge/detectors/integrity.py) // 감시 자체의 건전성 (edge/detectors/integrity.py)
'camera_moved','camera_obstructed','low_light', 'camera_moved','camera_obstructed','low_light',
// 서버가 발행하지만(EscalationWatch, 검증기를 거치지 않음) 유형 집합은 한 곳에 모은다. // 서버가 발행하지만(EscalationWatch, 검증기를 거치지 않음) 유형 집합은 한 곳에 모은다.
'unacked_escalation','multi_down', 'unacked_escalation',
// 모델 확보 후 활성화 (edge/detectors/pending.py) — 지금은 엣지가 발화하지 않는다. // 모델 확보 후 활성화 (edge/detectors/pending.py) — 지금은 엣지가 발화하지 않는다.
'ppe_missing','proximity','fire_smoke']; 'ppe_missing','proximity','fire_smoke'];
private const SEVERITIES = ['critical','high','warning','info']; private const SEVERITIES = ['critical','high','warning','info'];

View file

@ -30,7 +30,6 @@ render_header('이벤트');
<select id="type-filter" aria-label="이벤트 유형 필터"> <select id="type-filter" aria-label="이벤트 유형 필터">
<option value="">전체 유형</option> <option value="">전체 유형</option>
<option value="fall">넘어짐</option> <option value="fall">넘어짐</option>
<option value="fall_from_height">추락</option>
<option value="no_motion">넘어짐 후 미동 없음</option> <option value="no_motion">넘어짐 후 미동 없음</option>
<option value="immobility">장시간 부동</option> <option value="immobility">장시간 부동</option>
<option value="ghost">위험 상태 소실</option> <option value="ghost">위험 상태 소실</option>
@ -50,7 +49,6 @@ render_header('이벤트');
<option value="camera_obstructed">카메라 가림</option> <option value="camera_obstructed">카메라 가림</option>
<option value="low_light">조도 저하</option> <option value="low_light">조도 저하</option>
<option value="unacked_escalation">미확인 경보 에스컬레이션</option> <option value="unacked_escalation">미확인 경보 에스컬레이션</option>
<option value="multi_down">동시 다발 쓰러짐</option>
<!-- 모델 확보 후 활성화 (엣지는 아직 발화하지 않음). --> <!-- 모델 확보 후 활성화 (엣지는 아직 발화하지 않음). -->
<option value="ppe_missing">보호구 미착용</option> <option value="ppe_missing">보호구 미착용</option>
<option value="proximity">중장비 근접</option> <option value="proximity">중장비 근접</option>

View file

@ -41,13 +41,6 @@ render_header('구역 설정');
<section class="zone-step"> <section class="zone-step">
<h2 class="rail-title"><span class="step-no">2</span> 구역 그리기</h2> <h2 class="rail-title"><span class="step-no">2</span> 구역 그리기</h2>
<p class="step-hint">캔버스를 클릭해 점을 3개 이상 찍으면 폴리곤이 됩니다.</p> <p class="step-hint">캔버스를 클릭해 점을 3개 이상 찍으면 폴리곤이 됩니다.</p>
<label class="field" for="zone-preset">
<span class="field-label">용도 <span class="field-hint">고르면 등급과 규칙이 채워집니다</span></span>
<select id="zone-preset">
<option value="">직접 설정</option>
</select>
</label>
<p id="zone-preset-help" class="step-hint" hidden></p>
<label class="field">등급 <label class="field">등급
<select id="zone-level"> <select id="zone-level">
<option value="warning">경고 — 진입 시 알림</option> <option value="warning">경고 — 진입 시 알림</option>
@ -74,8 +67,8 @@ render_header('구역 설정');
<input type="number" id="rule-min-workers" min="2" max="50" placeholder="없음"> <input type="number" id="rule-min-workers" min="2" max="50" placeholder="없음">
</label> </label>
<label class="field" for="rule-max-workers"> <label class="field" for="rule-max-workers">
<span class="field-label">최대 인원 <span class="field-hint">0 = 아무도 있으면 안 됨</span></span> <span class="field-label">최대 인원 <span class="field-hint">정원 초과 감지</span></span>
<input type="number" id="rule-max-workers" min="0" max="50" placeholder="없음"> <input type="number" id="rule-max-workers" min="1" max="50" placeholder="없음">
</label> </label>
<label class="field" for="rule-max-dwell"> <label class="field" for="rule-max-dwell">
<span class="field-label">체류 상한 <span class="field-hint">분</span></span> <span class="field-label">체류 상한 <span class="field-hint">분</span></span>

View file

@ -1,69 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests;
use App\Alert\Channels;
use PHPUnit\Framework\TestCase;
/**
* 통보 본문의 유형별 대응 안내.
*
* multi_down 에서 이것은 부가 설명이 아니라 기능의 본체다. 밀폐공간 사고에서 사람이 가장
* 많이 죽는 방식은 첫 번째 사람이 왜 쓰러졌는지 모르고 구조하러 들어간 사람이 같이 죽는
* 것이고, 그 상황을 막는 것은 감지가 아니라 이 문장이다. 링크를 눌러 화면을 열기 전에
* 메일 본문에서 바로 읽혀야 하므로, 문구가 사라지면 기능이 사라진 것과 같다.
*/
final class AlertGuidanceTest extends TestCase
{
private function channels(): Channels
{
return new Channels('https://example.com/move_detect', 8, 'Asia/Seoul');
}
private function event(array $over = []): array
{
return $over + [
'event_id' => 42, 'device_id' => 'pi-01', 'camera_id' => 'cam-01',
'event_type' => 'fall', 'severity' => 'high',
'occurred_at' => 1_700_000_000, 'duration_s' => 3.0, 'metrics' => [],
];
}
public function testMultiDownBodyCarriesTheDoNotEnterWarning(): void
{
$body = $this->channels()->body($this->event([
'event_type' => 'multi_down', 'severity' => 'critical',
'metrics' => ['people' => 3],
]));
self::assertStringContainsString('공기호흡기 없이 진입하지 마십시오', $body);
self::assertStringContainsString('가스 누출', $body);
self::assertStringContainsString('3명', $body, '몇 명이 쓰러졌는지가 본문에 있어야 한다');
self::assertStringContainsString('구조하러 들어간 사람', $body);
}
public function testFallFromHeightBodyWarnsAgainstMovingTheCasualty(): void
{
$body = $this->channels()->body($this->event([
'event_type' => 'fall_from_height', 'severity' => 'critical',
]));
self::assertStringContainsString('척추', $body);
self::assertStringContainsString('옮기지 마십시오', $body);
}
public function testOrdinaryFallGetsNoExtraGuidance(): void
{
// 모든 알림에 경고가 붙으면 경고가 의미를 잃는다.
$body = $this->channels()->body($this->event());
self::assertStringNotContainsString('공기호흡기', $body);
self::assertStringNotContainsString('척추', $body);
}
public function testSubjectsUseKoreanLabelsForTheNewTypes(): void
{
self::assertStringContainsString('추락',
$this->channels()->subject($this->event(['event_type' => 'fall_from_height'])));
self::assertStringContainsString('동시 다발 쓰러짐',
$this->channels()->subject($this->event(['event_type' => 'multi_down'])));
}
}

View file

@ -1,138 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests;
use App\Alert\MultiDownWatch;
use App\Tests\Support\InMemoryEventRepository;
use PHPUnit\Framework\TestCase;
/**
* 같은 자리에서 짧은 시간에 둘이 쓰러지면 그것은 넘어짐 두 건이 아니라 질식·가스·폭발이다.
*
* 이 테스트가 지키는 두 가지: (1) 진짜 무리를 놓치지 않는다, (2) 무관한 넘어짐을 묶지 않는다.
* 넓은 공장에서 2분 안의 무관한 넘어짐 두 건은 실제로 일어나므로 (2)가 (1)만큼 중요하다.
*/
final class MultiDownWatchTest extends TestCase
{
private const NOW = 1_700_000_000;
private function ev(InMemoryEventRepository $repo, array $over = []): int
{
return $repo->insert($over + [
'event_uuid' => bin2hex(random_bytes(16)),
'device_id' => 'pi-01', 'site' => 'site-a', 'camera_id' => 'cam-01',
'event_type' => 'fall', 'severity' => 'high', 'track_id' => 1,
'zone_id' => null, 'occurred_at' => (float)(self::NOW - 60),
'duration_s' => 0.0, 'metrics' => [],
'spatial_x' => 0.50, 'spatial_y' => 0.50,
]);
}
public function testTwoPeopleDownInTheSameSpotFilesOneCriticalEvent(): void
{
$repo = new InMemoryEventRepository();
$this->ev($repo, ['track_id' => 1, 'spatial_x' => 0.50, 'spatial_y' => 0.50]);
$this->ev($repo, ['track_id' => 2, 'spatial_x' => 0.55, 'spatial_y' => 0.52,
'occurred_at' => (float)(self::NOW - 30)]);
$filed = (new MultiDownWatch($repo))->run(self::NOW);
self::assertCount(1, $filed);
self::assertSame(2, $filed[0]['people']);
$row = $repo->find($filed[0]['event_id']);
self::assertSame('multi_down', $row['event_type']);
self::assertSame('critical', $row['severity']);
self::assertCount(2, $row['metrics']['origin_event_ids']);
}
public function testOnePersonFallingTwiceIsNotAGroup(): void
{
// 넘어졌다 회복했다 다시 넘어진 것은 한 명이다.
$repo = new InMemoryEventRepository();
$this->ev($repo, ['track_id' => 7]);
$this->ev($repo, ['track_id' => 7, 'occurred_at' => (float)(self::NOW - 20)]);
self::assertSame([], (new MultiDownWatch($repo))->run(self::NOW));
}
public function testFarApartFallsAreNotAGroup(): void
{
// 같은 카메라라도 화면 반대편은 다른 사건이다.
$repo = new InMemoryEventRepository();
$this->ev($repo, ['track_id' => 1, 'spatial_x' => 0.05, 'spatial_y' => 0.05]);
$this->ev($repo, ['track_id' => 2, 'spatial_x' => 0.95, 'spatial_y' => 0.95,
'occurred_at' => (float)(self::NOW - 30)]);
self::assertSame([], (new MultiDownWatch($repo))->run(self::NOW, ['radius' => 0.35]));
}
public function testFallsOutsideTheWindowAreNotAGroup(): void
{
$repo = new InMemoryEventRepository();
$this->ev($repo, ['track_id' => 1, 'occurred_at' => (float)(self::NOW - 1000)]);
$this->ev($repo, ['track_id' => 2, 'occurred_at' => (float)(self::NOW - 10)]);
self::assertSame([], (new MultiDownWatch($repo))->run(self::NOW, ['window_s' => 120]));
}
public function testSameZoneGroupsEvenWithoutSpatialProximity(): void
{
// 운영자가 "여기가 한 공간"이라고 선언한 것이 좌표 거리보다 정확하다.
$repo = new InMemoryEventRepository();
$this->ev($repo, ['track_id' => 1, 'zone_id' => 'z-tank', 'spatial_x' => 0.05, 'spatial_y' => 0.05]);
$this->ev($repo, ['track_id' => 2, 'zone_id' => 'z-tank', 'spatial_x' => 0.95, 'spatial_y' => 0.95,
'occurred_at' => (float)(self::NOW - 20)]);
$filed = (new MultiDownWatch($repo))->run(self::NOW);
self::assertCount(1, $filed);
self::assertSame('zone:z-tank', $filed[0]['scope']);
}
public function testRepeatedRunsDoNotDuplicate(): void
{
$repo = new InMemoryEventRepository();
$this->ev($repo, ['track_id' => 1]);
$this->ev($repo, ['track_id' => 2, 'occurred_at' => (float)(self::NOW - 30)]);
$w = new MultiDownWatch($repo);
self::assertCount(1, $w->run(self::NOW));
self::assertSame([], $w->run(self::NOW + 60));
self::assertSame([], $w->run(self::NOW + 90));
}
public function testAThirdPersonRaisesItAgain(): void
{
// 사람이 늘어난 것은 상황이 악화된 것이다 — 한 번 더 올라와야 한다.
$repo = new InMemoryEventRepository();
$this->ev($repo, ['track_id' => 1]);
$this->ev($repo, ['track_id' => 2, 'occurred_at' => (float)(self::NOW - 30)]);
$w = new MultiDownWatch($repo);
self::assertCount(1, $w->run(self::NOW));
$this->ev($repo, ['track_id' => 3, 'occurred_at' => (float)(self::NOW - 10)]);
$again = $w->run(self::NOW + 5);
self::assertCount(1, $again);
self::assertSame(3, $again[0]['people']);
}
public function testNonDownTypesAreIgnored(): void
{
// 니어미스·자세부담은 사람이 쓰러진 것이 아니다.
$repo = new InMemoryEventRepository();
$this->ev($repo, ['track_id' => 1, 'event_type' => 'near_fall']);
$this->ev($repo, ['track_id' => 2, 'event_type' => 'ergo_report',
'occurred_at' => (float)(self::NOW - 30)]);
self::assertSame([], (new MultiDownWatch($repo))->run(self::NOW));
}
public function testFallFromHeightCountsAsDown(): void
{
$repo = new InMemoryEventRepository();
$this->ev($repo, ['track_id' => 1, 'event_type' => 'fall_from_height']);
$this->ev($repo, ['track_id' => 2, 'event_type' => 'no_motion',
'occurred_at' => (float)(self::NOW - 30)]);
self::assertCount(1, (new MultiDownWatch($repo))->run(self::NOW));
}
}

View file

@ -34,8 +34,7 @@ final class RouteTableTest extends TestCase
'POST /api/events', 'POST /api/heartbeat', 'GET /api/config', 'GET /api/csrf', 'POST /api/events', 'POST /api/heartbeat', 'GET /api/config', 'GET /api/csrf',
'POST /login', 'POST /logout', 'GET /api/events', 'GET /api/devices', 'POST /login', 'POST /logout', 'GET /api/events', 'GET /api/devices',
'GET /events/{id}', 'POST /events/{id}/ack', 'POST /events/{id}/false-positive', 'GET /events/{id}', 'POST /events/{id}/ack', 'POST /events/{id}/false-positive',
'GET /api/zones/{camera}', 'GET /api/zone-presets', 'GET /api/zones/{camera}', 'GET /api/heatmap/{camera}', 'GET /api/fp-stats',
'GET /api/heatmap/{camera}', 'GET /api/fp-stats',
'GET /api/report', 'GET /api/export/labels', 'GET /api/actions', 'POST /api/actions', 'GET /api/report', 'GET /api/export/labels', 'GET /api/actions', 'POST /api/actions',
'GET /api/cameras/{camera}/latest-snapshot', 'POST /api/zones', 'GET /api/cameras/{camera}/latest-snapshot', 'POST /api/zones',
'POST /api/zones/{id}/delete', 'GET /snap/{uuid}', 'POST /api/zones/{id}/delete', 'GET /snap/{uuid}',

View file

@ -30,7 +30,7 @@ declare(strict_types=1);
require __DIR__ . '/bootstrap.php'; require __DIR__ . '/bootstrap.php';
use App\Alert\{AlertDispatcher, Channels, DeviceHealthWatch, EscalationWatch, MultiDownWatch}; use App\Alert\{AlertDispatcher, Channels, DeviceHealthWatch, EscalationWatch};
use App\Repository\Pdo\{PdoEventRepository, PdoAlertRepository, PdoSuppressionRepository, use App\Repository\Pdo\{PdoEventRepository, PdoAlertRepository, PdoSuppressionRepository,
PdoDeviceRepository, PdoDeviceMetricRepository}; PdoDeviceRepository, PdoDeviceMetricRepository};
@ -102,24 +102,6 @@ if ($escalated !== []) {
fwrite(STDOUT, date('c') . " escalated: " . implode(',', $escalated) . "\n"); fwrite(STDOUT, date('c') . " escalated: " . implode(',', $escalated) . "\n");
} }
// --- 2b. 동시 다발 쓰러짐 -------------------------------------------------------------
// 같은 자리에서 짧은 시간에 둘이 쓰러진 것은 넘어짐 두 건이 아니라 질식·가스·폭발이다.
// 에스컬레이션 뒤, 통보 앞에 둔다 — 같은 실행에서 바로 통보되어야 한다.
// 1단계와 같은 이유로 격리한다: 부가 판단이 통보를 죽이면 안 된다.
$multiFiled = [];
if (!$dryRun) {
try {
$multiFiled = (new MultiDownWatch($events))->run($now, ['limit' => $limit]);
} catch (Throwable $e) {
fwrite(STDERR, date('c') . ' multi-down error (통보는 계속 진행): '
. $e->getMessage() . PHP_EOL);
}
}
foreach ($multiFiled as $f) {
fwrite(STDOUT, sprintf('%s multi_down: %d명 %s (event=%d)' . PHP_EOL,
date('c'), $f['people'], $f['scope'], $f['event_id']));
}
// --- 3. 통보 -------------------------------------------------------------------------- // --- 3. 통보 --------------------------------------------------------------------------
$dispatcher = new AlertDispatcher($events, $alerts, $suppressions); $dispatcher = new AlertDispatcher($events, $alerts, $suppressions);
@ -180,8 +162,8 @@ if (!$dryRun) {
} }
fwrite(STDOUT, sprintf( fwrite(STDOUT, sprintf(
"%s considered=%d sent=%d suppressed=%d failed=%d skipped=%d retried=%d escalated=%d health=%d multi=%d%s\n", "%s considered=%d sent=%d suppressed=%d failed=%d skipped=%d retried=%d escalated=%d%s\n",
date('c'), $stats['considered'], $stats['sent'], $stats['suppressed'], date('c'), $stats['considered'], $stats['sent'], $stats['suppressed'],
$stats['failed'], $stats['skipped'], $retried, count($escalated), count($healthFiled), count($multiFiled), $dryRun ? ' (dry-run)' : '' $stats['failed'], $stats['skipped'], $retried, count($escalated), $dryRun ? ' (dry-run)' : ''
)); ));
exit(0); exit(0);

View file

@ -306,108 +306,3 @@ def test_allow_cooldown_primitive():
assert eng._allow(key, now=40.0) is True # after cooldown_s elapsed -> allowed again assert eng._allow(key, now=40.0) is True # after cooldown_s elapsed -> allowed again
# a different key is independent # a different key is independent
assert eng._allow(("FALL", 2), now=5.0) is True assert eng._allow(("FALL", 2), now=5.0) is True
# --- 추락 (fall_from_height) ----------------------------------------------------------
#
# 같은 높이의 '넘어짐'과 높이에서의 '떨어짐'은 다른 사건이다. 떨어짐은 척추 손상 가능성이
# 있어 함부로 옮기면 안 되고, 그 주의가 통보에 실리려면 유형과 심각도가 갈라져야 한다.
# 발보 경로는 두 갈래다: 프레임 안에서 확정 / 화각 밖으로 사라짐.
from edge.detectors.height import DescentWatch, DescentConfig
def _height_engine(zones=None):
return EventEngine(
FallDetector(FallConfig(prone_confirm_s=0.5)),
ImmobilityWatchdog(ImmobilityConfig(immobile_s=999)),
GhostIncidentDetector(GhostConfig(reappear_s=1.0)),
ZoneIntrusionDetector(zones or [], ZoneConfig()),
EngineConfig(camera_id="cam-01", cooldown_s=30.0),
descent=DescentWatch(DescentConfig(drop_ratio=1.2, drop_window_s=1.5, link_s=3.0)),
)
def _falling_track(track, t, y2, height=150.0, cx=150.0):
"""발 밑선 y2 로 서 있는 사람 프레임을 붙인다."""
track.frames.append(TrackFrame(t, BBox(cx - 20, y2 - height, cx + 20, y2),
_standing_kps(int(cx))))
return track
def test_visible_drop_then_prone_is_fall_from_height_not_fall():
eng = _height_engine()
tr = Track(1, deque(maxlen=90))
_falling_track(tr, 0.0, y2=200.0) # 발판 위
_falling_track(tr, 0.5, y2=320.0)
_falling_track(tr, 1.0, y2=420.0) # 220px 하강 = 키의 1.47배
eng.process([tr], [], now=1.0)
_lying_track(tr, 1.2)
eng.process([tr], [], now=1.2) # prone 시작
_lying_track(tr, 2.0)
out = eng.process([tr], [], now=2.0) # 0.5초 지속 -> 확정
types = [e.event_type for e in out]
assert EventType.FALL_FROM_HEIGHT in types, types
assert EventType.FALL not in types, "한 번의 사고가 두 건으로 올라가면 안 된다"
ev = next(e for e in out if e.event_type == EventType.FALL_FROM_HEIGHT)
assert ev.severity == Severity.CRITICAL
assert ev.metrics["drop_ratio"] >= 1.2
def test_plain_fall_without_descent_stays_fall():
eng = _height_engine()
tr = Track(2, deque(maxlen=90))
_lying_track(tr, 0.0)
eng.process([tr], [], now=0.0)
_lying_track(tr, 0.6)
out = eng.process([tr], [], now=0.6)
assert [e.event_type for e in out] == [EventType.FALL]
def test_drop_then_vanish_emits_fall_from_height_not_ghost():
# 화각 밖으로 떨어진 사람에게는 이쪽이 유일한 발보 경로다.
eng = _height_engine()
tr = Track(3, deque(maxlen=90))
_falling_track(tr, 0.0, y2=200.0)
_falling_track(tr, 0.5, y2=330.0)
_falling_track(tr, 0.9, y2=430.0)
eng.process([tr], [], now=0.9) # 하강 확정 = ghost 무장
eng.process([], [tr], now=1.0) # 소실
out = eng.process([], [], now=3.0) # 재출현 대기 초과
types = [e.event_type for e in out]
assert EventType.FALL_FROM_HEIGHT in types, types
assert EventType.GHOST not in types
ev = next(e for e in out if e.event_type == EventType.FALL_FROM_HEIGHT)
assert ev.metrics["exited_frame"] is True
assert ev.metrics["armed_by"] == "descent"
def test_standing_person_vanishing_without_descent_is_not_a_height_fall():
# 장애물 뒤로 걸어 들어간 사람까지 추락으로 올리면 안 된다.
eng = _height_engine()
tr = Track(4, deque(maxlen=90))
_falling_track(tr, 0.0, y2=200.0)
_falling_track(tr, 1.0, y2=205.0)
eng.process([tr], [], now=1.0)
eng.process([], [tr], now=1.1)
out = eng.process([], [], now=3.0)
assert [e.event_type for e in out] == []
def test_elevated_zone_arms_without_a_visible_drop():
# 발판 위에서 화각 밖으로 사라지면 하강을 못 봤어도 추락 후보다.
poly = [(0.0, 0.0), (400.0, 0.0), (400.0, 400.0), (0.0, 400.0)]
zones = [Zone("zhigh", "restricted", poly, {"elevated": True})]
eng = _height_engine(zones)
tr = Track(5, deque(maxlen=90))
_falling_track(tr, 0.0, y2=200.0)
_falling_track(tr, 1.0, y2=205.0)
eng.process([tr], [], now=1.0)
eng.process([], [tr], now=1.1)
out = eng.process([], [], now=3.0)
fh = [e for e in out if e.event_type == EventType.FALL_FROM_HEIGHT]
assert len(fh) == 1, [e.event_type for e in out]
assert fh[0].metrics["armed_by"] == "zone"

View file

@ -38,7 +38,7 @@ LABEL_FILES = [
] ]
# 서버가 스스로 발행하는 유형 — 엣지는 이것을 만들지 않는다 (EscalationWatch). # 서버가 스스로 발행하는 유형 — 엣지는 이것을 만들지 않는다 (EscalationWatch).
SERVER_ONLY = {"unacked_escalation", "multi_down"} SERVER_ONLY = {"unacked_escalation"}
def _edge_types() -> set[str]: def _edge_types() -> set[str]:

View file

@ -1,107 +0,0 @@
"""추락(높이에서 떨어짐) 판정.
이 프로젝트가 잡고 있던 것은 같은 높이에서의 **넘어짐**이고, 산업재해 사망 1위 유형인
**떨어짐**은 잡지 못했다. GhostIncidentDetector 는 트랙이 이미 위험 자세일 때만 무장하므로,
발판 위에 서 있다가 떨어지는 사람은 소실 순간 STANDING 이라 아무것도 걸리지 않는다.
판정의 핵심은 bbox 중심이 아니라 **하단(y2, 발 밑선)** 이다:
쭈그려 앉기 → 중심은 내려가지만 발은 그 자리에 있다
추락 → 발 밑선이 통째로 내려간다
중심으로 판정하면 쭈그려 앉는 작업자가 전부 추락이 된다. 아래 테스트가 그 구분을 고정한다.
"""
from edge.detectors.height import DescentWatch, DescentConfig
from edge.models import BBox, Keypoint
from edge.tracking import Track, TrackFrame
from collections import deque
def _kp():
return [Keypoint(0.5, 0.5, 0.9) for _ in range(17)]
def _track(track_id: int, boxes: list[tuple[float, BBox]]) -> Track:
frames = deque(maxlen=90)
for t, b in boxes:
frames.append(TrackFrame(t, b, _kp()))
return Track(track_id=track_id, frames=frames)
def _box(y2: float, height: float, cx: float = 0.5) -> BBox:
"""발 밑선 y2 와 높이로 bbox 를 만든다 (좌표는 프레임 정규화 0..1)."""
return BBox(x1=cx - 0.05, y1=y2 - height, x2=cx + 0.05, y2=y2)
def _watch(**cfg):
return DescentWatch(DescentConfig(**cfg))
# --- 잡아야 하는 것 -------------------------------------------------------------------
def test_rapid_drop_of_the_foot_line_is_a_descent():
w = _watch(drop_ratio=1.2, drop_window_s=1.2)
# 키 0.25 인 사람의 발 밑선이 0.9초 만에 0.35(=1.4배) 내려간다
tr = _track(1, [(0.0, _box(0.30, 0.25)), (0.3, _box(0.42, 0.25)),
(0.6, _box(0.55, 0.25)), (0.9, _box(0.65, 0.25))])
v = w.update(tr, now=0.9)
assert v is not None
assert v["drop_ratio"] >= 1.2
assert w.descending(1, now=0.9) is True
def test_descent_stays_armed_for_the_link_window():
w = _watch(link_s=3.0)
tr = _track(1, [(0.0, _box(0.30, 0.25)), (0.9, _box(0.65, 0.25))])
w.update(tr, now=0.9)
assert w.descending(1, now=3.5) is True # 아직 연결 창 안
assert w.descending(1, now=4.5) is False # 창을 벗어남
# --- 잡으면 안 되는 것 ----------------------------------------------------------------
def test_crouching_is_not_a_descent():
# 쭈그려 앉으면 높이는 줄지만 발 밑선은 그 자리다. 중심으로 재면 이게 전부 추락이 된다.
w = _watch()
tr = _track(1, [(0.0, _box(0.60, 0.25)), (0.4, _box(0.60, 0.18)),
(0.8, _box(0.60, 0.12)), (1.1, _box(0.60, 0.10))])
assert w.update(tr, now=1.1) is None
def test_walking_down_stairs_is_not_a_descent():
# 계단은 내려가지만 창 안에서 1.2배에 못 미친다.
w = _watch(drop_ratio=1.2, drop_window_s=1.2)
tr = _track(1, [(0.0, _box(0.40, 0.25)), (0.4, _box(0.45, 0.25)),
(0.8, _box(0.50, 0.25)), (1.2, _box(0.55, 0.25))])
assert w.update(tr, now=1.2) is None
def test_walking_towards_the_camera_is_not_a_descent():
# 원근 접근은 발 밑선도 내려가지만 사람이 커진다. 높이 증가 가드가 막는다.
w = _watch(drop_ratio=1.2, grow_guard=1.35)
tr = _track(1, [(0.0, _box(0.30, 0.15)), (0.4, _box(0.50, 0.24)),
(0.8, _box(0.68, 0.34))])
assert w.update(tr, now=0.8) is None
def test_slow_drop_outside_the_window_is_not_a_descent():
w = _watch(drop_ratio=1.2, drop_window_s=1.2)
tr = _track(1, [(0.0, _box(0.30, 0.25)), (2.0, _box(0.50, 0.25)),
(4.0, _box(0.70, 0.25))])
assert w.update(tr, now=4.0) is None
def test_forget_clears_state():
w = _watch()
tr = _track(9, [(0.0, _box(0.30, 0.25)), (0.9, _box(0.65, 0.25))])
w.update(tr, now=0.9)
assert w.descending(9, now=1.0) is True
w.forget(9)
assert w.descending(9, now=1.0) is False
def test_single_frame_track_is_safe():
w = _watch()
tr = _track(1, [(0.0, _box(0.30, 0.25))])
assert w.update(tr, now=0.0) is None

View file

@ -112,33 +112,3 @@ def test_deleted_zone_state_is_pruned():
w.update([], {}, now=1.0) # 존 삭제 (config 동기) w.update([], {}, now=1.0) # 존 삭제 (config 동기)
assert w._lone_since == {} and w._over_since == {} assert w._lone_since == {} and w._over_since == {}
assert w._lone_fired == set() and w._over_fired == set() assert w._lone_fired == set() and w._over_fired == set()
# --- "아무도 있으면 안 되는 구역" ------------------------------------------------------
#
# 크레인 짐 아래, 굴착기 선회 반경처럼 인원 상한이 0인 구역이 있다. 깔림 사고의 전형이다.
# max_workers=0 을 falsy 로 걸러내면 운영자가 규칙을 저장해 놓고도 아무 일이 일어나지 않는다
# — 화면에는 규칙이 있는데 감지기는 꺼져 있는, 이 프로젝트가 반복해서 잡아온 조용한 실패다.
def test_zero_capacity_zone_fires_on_a_single_person():
w = _watch(over_confirm_s=10.0)
zones = [_zone(max_workers=0)]
assert w.update(zones, {"z1": {1}}, now=0.0) == [] # 유예 안
out = w.update(zones, {"z1": {1}}, now=11.0)
assert out == [{"kind": "overcapacity", "zone_id": "z1", "track_id": None,
"metrics": {"workers": 1, "limit": 0}}]
def test_zero_capacity_zone_stays_quiet_when_empty():
w = _watch(over_confirm_s=10.0)
zones = [_zone(max_workers=0)]
assert w.update(zones, {"z1": set()}, now=0.0) == []
assert w.update(zones, {"z1": set()}, now=999.0) == []
def test_absent_max_workers_still_disables_the_rule():
# 규칙을 안 넣은 것과 0 을 넣은 것은 다르다. 전자는 "제한 없음", 후자는 "아무도 안 됨".
w = _watch(over_confirm_s=10.0)
zones = [_zone(min_workers=2)] # max_workers 키 자체가 없음
assert w.update(zones, {"z1": {1, 2, 3}}, now=0.0) == []
assert [o["kind"] for o in w.update(zones, {"z1": {1, 2, 3}}, now=11.0)] == []