feat: 추락 · 동시 다발 쓰러짐 · 구역 프리셋 (1.2.0)
AI CCTV 목적을 원론부터 재점검한 결과. 셋 다 새 추론 모델 없이 지금 있는 자세추정·구역·트랙 이력만으로 구현했다. ■ 추락 (fall_from_height) 잡고 있던 것은 같은 높이의 '넘어짐'이었고, 사망 1위 유형인 '떨어짐'은 못 잡았다. GhostIncidentDetector 가 트랙이 이미 위험 자세일 때만 무장하므로 (_DANGEROUS), 발판에 서 있다가 떨어지는 사람은 소실 순간 STANDING 이라 아무것도 안 걸렸다. 판정의 핵심은 bbox 중심이 아니라 하단(y2)이다. 쭈그려 앉으면 중심은 내려가도 발은 그 자리이고, 추락은 발 밑선이 통째로 내려간다 — 중심으로 재면 쭈그려 앉아 작업하는 사람이 전부 추락이 된다. 원근 접근은 y2 도 내려가므로 높이 증가 가드로 따로 막는다. Track.frames 가 90프레임을 들고 있어 별도 버퍼가 없다. 발보는 두 갈래로 나뉘되 한 사고는 한 건이다: 프레임 안에서 넘어짐이 확정되면 유형을 승격하고, 화각 밖으로 사라지면 ghost 무장을 넓혀 그쪽으로 낸다. 심각도 CRITICAL — 척추·두부 손상 주의가 통보에 실리려면 넘어짐(HIGH)과 등급이 갈라져야 한다. 고소 구역은 rules_json 의 elevated 키로 붙어 스키마 변경이 없고, 구역이 없어도 하강 신호만으로 동작한다. ■ 동시 다발 쓰러짐 (multi_down) 같은 구역에서 짧은 시간에 둘이 쓰러진 것은 넘어짐 두 건이 아니라 질식· 가스·폭발이다. 이 기능의 본체는 감지가 아니라 통보 문구다 — 밀폐공간 사망자의 상당수가 구조하러 들어간 사람이므로, 링크를 열기 전에 메일 본문에서 "공기호흡기 없이 진입하지 마십시오"가 읽혀야 한다. Channels::guidance 로 유형별 안내를 붙였고 문구를 테스트로 고정했다(문구가 사라지면 기능이 사라진 것과 같다). 서버에 둔 이유: 가스는 카메라 화각을 존중하지 않아 노드를 넘는 판단이 필요하고, 현장 즉시 대응은 첫 쓰러짐의 경광등이 이미 확보한다. 오탐 방어의 핵심은 근접성 — 넓은 공장에서 2분 안의 무관한 넘어짐 두 건은 실제로 일어나므로 같은 구역이거나 좌표가 가까워야 묶는다. 한 사람이 두 번 넘어진 것은 한 명으로 센다. ■ 구역 프리셋 6종 + max_workers:0 수정 밀폐공간 감시에 필요한 것(제한구역+2인1조+체류상한)은 이미 전부 있었는데 화면이 "최소 인원을 넣으세요"라고만 물어 아무도 그렇게 쓰지 못했다. 안전관리자는 min_workers 가 아니라 밀폐공간을 생각한다. 그 과정에서 잠복 결함 하나: max_workers:0 이 falsy 로 걸러져 "아무도 있으면 안 되는 구역"(크레인 짐 아래·굴착기 선회 반경 = 깔림 사고의 전형)이 통째로 꺼져 있었다. 운영자가 저장해 놓고도 아무 일이 안 일어났다. 감지기·입력란 (min=1)·저장 직렬화(>0)·목록 표시 네 곳에서 막혀 있었다. ■ 검증 pytest 322 passed / phpunit 263 passed. 신규 테스트 30건. 이벤트 유형 2종 추가는 test_event_type_parity 가 강제하는 5곳(엣지 enum · EventValidator · schema CHECK · 프런트 라벨 5파일 · 필터 옵션) 전부 동기화했고 기존 설치용 마이그레이션을 추가했다. ■ Pi 번들 1.1.0 과 달리 이번에는 edge/ 가 바뀌었으므로 Pi 번들 재빌드가 필수다. 낡은 1.0.0 번들은 dist/ 에서 제거했다 — 두면 추락 감지가 빠진 노드가 그대로 납품된다. arm64 + 네트워크 빌드 머신에서 재빌드해야 Pi 납품이 가능하다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EBjuR7DW6PNNMows4KnbYU
This commit is contained in:
parent
ccbc233fdd
commit
4bfb7e0274
33 changed files with 1169 additions and 45 deletions
55
CHANGELOG.md
55
CHANGELOG.md
|
|
@ -6,6 +6,61 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 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 은 "감지에 대응하는 시스템"이었습니다. 그런데 **감시 장비 자신이 멈춘 것**은 여전히
|
||||||
|
|
|
||||||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
1.1.0
|
1.2.0
|
||||||
|
|
@ -156,3 +156,13 @@
|
||||||
- 검증: 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-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
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
# 신규 감지 설계 — 추락 · 동시 다발 쓰러짐 · 구역 프리셋
|
# 신규 감지 설계 — 추락 · 동시 다발 쓰러짐 · 구역 프리셋
|
||||||
|
|
||||||
> 상태: **설계안. 구현 전.** 2026-09-04
|
> 상태: **구현 완료 (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` ·
|
> 근거 코드: `edge/detectors/{fall,ghost,zone,occupancy}.py` · `edge/tracking.py` ·
|
||||||
> `server/src/Alert/DeviceHealthWatch.php`(서버측 감시자 패턴)
|
> `server/src/Alert/DeviceHealthWatch.php`(서버측 감시자 패턴)
|
||||||
|
|
@ -249,8 +252,8 @@ max_workers = rules.get("max_workers")
|
||||||
if max_workers and n > max_workers: # edge/detectors/occupancy.py
|
if max_workers and n > max_workers: # edge/detectors/occupancy.py
|
||||||
```
|
```
|
||||||
|
|
||||||
`0`은 falsy라 규칙이 통째로 꺼집니다. 즉 **"아무도 있으면 안 되는 구역"을 표현할 방법이
|
`0`은 falsy라 규칙이 통째로 꺼졌습니다. **1.2.0 에서 수정** — 감지기·입력란·저장 직렬화·
|
||||||
현재 없습니다.** 크레인 짐 아래·굴착기 선회 반경은 정확히 그 규칙이고, 깔림 사고의 전형입니다.
|
목록 표시 네 곳 모두에서 막혀 있었습니다. 크레인 짐 아래·굴착기 선회 반경은 정확히 그 규칙이고, 깔림 사고의 전형입니다.
|
||||||
|
|
||||||
수정은 한 줄입니다 — `if max_workers is not None and n > max_workers`.
|
수정은 한 줄입니다 — `if max_workers is not None and n > max_workers`.
|
||||||
`min_workers`도 같은 형태이므로 함께 봐야 합니다.
|
`min_workers`도 같은 형태이므로 함께 봐야 합니다.
|
||||||
|
|
|
||||||
|
|
@ -15,28 +15,46 @@ 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")
|
||||||
if state in _DANGEROUS:
|
height = self._height.get(tr.track_id)
|
||||||
|
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, bystanders)
|
self._candidates[tr.track_id] = ((b.cx, b.cy), state, b.height, now,
|
||||||
|
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) in list(self._candidates.items()):
|
for tid, (spatial, _state, height, _lost_at, bystanders, _h) 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
|
||||||
|
|
@ -47,9 +65,14 @@ class GhostIncidentDetector:
|
||||||
break
|
break
|
||||||
# 대기 초과 방출
|
# 대기 초과 방출
|
||||||
out = []
|
out = []
|
||||||
for tid, (spatial, state, _height, lost_at, _by) in list(self._candidates.items()):
|
for tid, (spatial, state, _height, lost_at, _by, hinfo) in list(self._candidates.items()):
|
||||||
if now - lost_at >= self.cfg.reappear_s:
|
if now - lost_at >= self.cfg.reappear_s:
|
||||||
out.append({"track_id": tid, "spatial": spatial, "last_state": state,
|
rec = {"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
|
||||||
|
|
|
||||||
100
edge/detectors/height.py
Normal file
100
edge/detectors/height.py
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
"""추락(높이에서 떨어짐) 판정의 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
|
||||||
|
|
@ -56,9 +56,12 @@ 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 and n > max_workers:
|
if max_workers is not None 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)
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,21 @@ 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}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ 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
|
||||||
|
|
@ -25,7 +26,8 @@ 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
|
||||||
|
|
@ -33,6 +35,7 @@ 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] = {}
|
||||||
|
|
||||||
|
|
@ -48,6 +51,7 @@ 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)}
|
||||||
|
|
@ -80,6 +84,14 @@ 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")
|
||||||
|
|
@ -87,9 +99,20 @@ 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" and self._allow((EventType.FALL, tr.track_id), now):
|
if sig == "fall":
|
||||||
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))
|
||||||
|
|
@ -150,10 +173,19 @@ 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)
|
||||||
events.append(self._mk(EventType.GHOST, occurred_at, track_id=g["track_id"],
|
gmeta = {"last_state": g["last_state"], "waited_s": g["waited_s"]}
|
||||||
spatial=gs,
|
# 추락 후보로 무장돼 있었으면 '위험 상태 소실'이 아니라 '추락'이다. 화각 밖으로
|
||||||
metrics={"last_state": g["last_state"],
|
# 떨어진 사람에게는 이쪽이 유일한 발보 경로이므로 유형이 정확해야 한다.
|
||||||
"waited_s": g["waited_s"]}))
|
if g.get("height_fall"):
|
||||||
|
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:
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ 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"
|
||||||
|
|
@ -44,6 +45,9 @@ _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,
|
||||||
|
|
|
||||||
39
server/migrations/2026-09-04-fall-from-height.sql
Normal file
39
server/migrations/2026-09-04-fall-from-height.sql
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
-- 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'
|
||||||
|
));
|
||||||
|
|
@ -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: '넘어짐', no_motion: '넘어짐 후 미동 없음', immobility: '장시간 부동',
|
fall: '넘어짐', fall_from_height: '추락', 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: '미확인 경보 에스컬레이션',
|
unacked_escalation: '미확인 경보 에스컬레이션', multi_down: '동시 다발 쓰러짐',
|
||||||
// 모델 확보 후 활성화 (엣지는 아직 발화하지 않음).
|
// 모델 확보 후 활성화 (엣지는 아직 발화하지 않음).
|
||||||
ppe_missing: '보호구 미착용', proximity: '중장비 근접', fire_smoke: '화재·연기'
|
ppe_missing: '보호구 미착용', proximity: '중장비 근접', fire_smoke: '화재·연기'
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -13,13 +13,13 @@
|
||||||
|
|
||||||
var csrf = null;
|
var csrf = null;
|
||||||
var TYPE_LABELS = {
|
var TYPE_LABELS = {
|
||||||
fall: '넘어짐', no_motion: '넘어짐 후 미동 없음', immobility: '장시간 부동',
|
fall: '넘어짐', fall_from_height: '추락', 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: '미확인 경보 에스컬레이션',
|
unacked_escalation: '미확인 경보 에스컬레이션', multi_down: '동시 다발 쓰러짐',
|
||||||
ppe_missing: '보호구 미착용', proximity: '중장비 근접', fire_smoke: '화재·연기'
|
ppe_missing: '보호구 미착용', proximity: '중장비 근접', fire_smoke: '화재·연기'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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: '넘어짐', no_motion: '넘어짐 후 미동 없음', immobility: '장시간 부동',
|
fall: '넘어짐', fall_from_height: '추락', 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: '미확인 경보 에스컬레이션',
|
unacked_escalation: '미확인 경보 에스컬레이션', multi_down: '동시 다발 쓰러짐',
|
||||||
// 모델 확보 후 활성화 (엣지는 아직 발화하지 않음).
|
// 모델 확보 후 활성화 (엣지는 아직 발화하지 않음).
|
||||||
ppe_missing: '보호구 미착용', proximity: '중장비 근접', fire_smoke: '화재·연기'
|
ppe_missing: '보호구 미착용', proximity: '중장비 근접', fire_smoke: '화재·연기'
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -22,13 +22,13 @@
|
||||||
var id = m[1];
|
var id = m[1];
|
||||||
|
|
||||||
var TYPE_LABELS = {
|
var TYPE_LABELS = {
|
||||||
fall: '넘어짐', no_motion: '넘어짐 후 미동 없음', immobility: '장시간 부동',
|
fall: '넘어짐', fall_from_height: '추락', 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: '미확인 경보 에스컬레이션',
|
unacked_escalation: '미확인 경보 에스컬레이션', multi_down: '동시 다발 쓰러짐',
|
||||||
ppe_missing: '보호구 미착용', proximity: '중장비 근접', fire_smoke: '화재·연기'
|
ppe_missing: '보호구 미착용', proximity: '중장비 근접', fire_smoke: '화재·연기'
|
||||||
};
|
};
|
||||||
var SEV_LABELS = { critical: '위급', high: '높음', warning: '경고', info: '정보' };
|
var SEV_LABELS = { critical: '위급', high: '높음', warning: '경고', info: '정보' };
|
||||||
|
|
|
||||||
|
|
@ -12,14 +12,14 @@
|
||||||
var B = window.APP_BASE || '';
|
var B = window.APP_BASE || '';
|
||||||
|
|
||||||
var TYPE_LABELS = {
|
var TYPE_LABELS = {
|
||||||
fall: '넘어짐', no_motion: '넘어짐 후 미동 없음', immobility: '장시간 부동',
|
fall: '넘어짐', fall_from_height: '추락', 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: '미확인 경보 에스컬레이션',
|
unacked_escalation: '미확인 경보 에스컬레이션', multi_down: '동시 다발 쓰러짐',
|
||||||
// 모델 확보 후 활성화 (엣지는 아직 발화하지 않음).
|
// 모델 확보 후 활성화 (엣지는 아직 발화하지 않음).
|
||||||
ppe_missing: '보호구 미착용', proximity: '중장비 근접', fire_smoke: '화재·연기'
|
ppe_missing: '보호구 미착용', proximity: '중장비 근접', fire_smoke: '화재·연기'
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -233,14 +233,20 @@
|
||||||
|
|
||||||
// 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;
|
||||||
if (maxW > 0) rules.max_workers = maxW;
|
// 최대 인원은 0 도 유효한 값이다 — "아무도 있으면 안 되는 구역"(크레인 짐 아래·선회 반경).
|
||||||
|
// `> 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -249,7 +255,8 @@
|
||||||
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) parts.push('최대 ' + r.max_workers + '인');
|
if (r.max_workers === 0) parts.push('출입 금지');
|
||||||
|
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(' · ') : '';
|
||||||
}
|
}
|
||||||
|
|
@ -308,6 +315,56 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 용도 프리셋 -------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// 밀폐공간 감시에 필요한 것(제한구역 + 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
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,7 @@ $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));
|
||||||
|
|
|
||||||
|
|
@ -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','no_motion','immobility','ghost','zone_warning',
|
'fall','fall_from_height','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',
|
'unacked_escalation','multi_down',
|
||||||
-- 모델 확보 후 활성화되는 자리. 엣지 감지기는 있으나 기본 비활성이다.
|
-- 모델 확보 후 활성화되는 자리. 엣지 감지기는 있으나 기본 비활성이다.
|
||||||
'ppe_missing','proximity','fire_smoke'
|
'ppe_missing','proximity','fire_smoke'
|
||||||
)),
|
)),
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,9 @@ use App\DeviceScope;
|
||||||
final class Channels
|
final class Channels
|
||||||
{
|
{
|
||||||
private const TYPE_LABELS = [
|
private const TYPE_LABELS = [
|
||||||
'fall' => '넘어짐', 'no_motion' => '넘어짐 후 미동 없음', 'immobility' => '장시간 부동',
|
'fall' => '넘어짐', 'fall_from_height' => '추락',
|
||||||
|
'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' => '체류 상한 초과',
|
||||||
|
|
@ -55,6 +57,45 @@ 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 {
|
||||||
|
|
@ -107,7 +148,7 @@ final class Channels
|
||||||
"",
|
"",
|
||||||
"이 메일에는 개인정보 보호를 위해 현장 이미지가 포함되지 않습니다.",
|
"이 메일에는 개인정보 보호를 위해 현장 이미지가 포함되지 않습니다.",
|
||||||
"이미지는 위 링크에서 로그인 후에만 열람할 수 있습니다.",
|
"이미지는 위 링크에서 로그인 후에만 열람할 수 있습니다.",
|
||||||
]);
|
]) . self::guidance($event);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function sendEmail(array $event, array $config): void
|
private function sendEmail(array $event, array $config): void
|
||||||
|
|
|
||||||
162
server/src/Alert/MultiDownWatch.php
Normal file
162
server/src/Alert/MultiDownWatch.php
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
102
server/src/Api/ZonePresetCatalog.php
Normal file
102
server/src/Api/ZonePresetCatalog.php
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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','no_motion','immobility','ghost','zone_warning',
|
private const TYPES = ['fall','fall_from_height','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',
|
'unacked_escalation','multi_down',
|
||||||
// 모델 확보 후 활성화 (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'];
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ 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>
|
||||||
|
|
@ -49,6 +50,7 @@ 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>
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,13 @@ 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>
|
||||||
|
|
@ -67,8 +74,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">정원 초과 감지</span></span>
|
<span class="field-label">최대 인원 <span class="field-hint">0 = 아무도 있으면 안 됨</span></span>
|
||||||
<input type="number" id="rule-max-workers" min="1" max="50" placeholder="없음">
|
<input type="number" id="rule-max-workers" min="0" 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>
|
||||||
|
|
|
||||||
69
server/tests/AlertGuidanceTest.php
Normal file
69
server/tests/AlertGuidanceTest.php
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
<?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'])));
|
||||||
|
}
|
||||||
|
}
|
||||||
138
server/tests/MultiDownWatchTest.php
Normal file
138
server/tests/MultiDownWatchTest.php
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
<?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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -34,7 +34,8 @@ 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/heatmap/{camera}', 'GET /api/fp-stats',
|
'GET /api/zones/{camera}', 'GET /api/zone-presets',
|
||||||
|
'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}',
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ declare(strict_types=1);
|
||||||
|
|
||||||
require __DIR__ . '/bootstrap.php';
|
require __DIR__ . '/bootstrap.php';
|
||||||
|
|
||||||
use App\Alert\{AlertDispatcher, Channels, DeviceHealthWatch, EscalationWatch};
|
use App\Alert\{AlertDispatcher, Channels, DeviceHealthWatch, EscalationWatch, MultiDownWatch};
|
||||||
use App\Repository\Pdo\{PdoEventRepository, PdoAlertRepository, PdoSuppressionRepository,
|
use App\Repository\Pdo\{PdoEventRepository, PdoAlertRepository, PdoSuppressionRepository,
|
||||||
PdoDeviceRepository, PdoDeviceMetricRepository};
|
PdoDeviceRepository, PdoDeviceMetricRepository};
|
||||||
|
|
||||||
|
|
@ -102,6 +102,24 @@ 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);
|
||||||
|
|
||||||
|
|
@ -162,8 +180,8 @@ if (!$dryRun) {
|
||||||
}
|
}
|
||||||
|
|
||||||
fwrite(STDOUT, sprintf(
|
fwrite(STDOUT, sprintf(
|
||||||
"%s considered=%d sent=%d suppressed=%d failed=%d skipped=%d retried=%d escalated=%d%s\n",
|
"%s considered=%d sent=%d suppressed=%d failed=%d skipped=%d retried=%d escalated=%d health=%d multi=%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), $dryRun ? ' (dry-run)' : ''
|
$stats['failed'], $stats['skipped'], $retried, count($escalated), count($healthFiled), count($multiFiled), $dryRun ? ' (dry-run)' : ''
|
||||||
));
|
));
|
||||||
exit(0);
|
exit(0);
|
||||||
|
|
|
||||||
|
|
@ -306,3 +306,108 @@ 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"
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ LABEL_FILES = [
|
||||||
]
|
]
|
||||||
|
|
||||||
# 서버가 스스로 발행하는 유형 — 엣지는 이것을 만들지 않는다 (EscalationWatch).
|
# 서버가 스스로 발행하는 유형 — 엣지는 이것을 만들지 않는다 (EscalationWatch).
|
||||||
SERVER_ONLY = {"unacked_escalation"}
|
SERVER_ONLY = {"unacked_escalation", "multi_down"}
|
||||||
|
|
||||||
|
|
||||||
def _edge_types() -> set[str]:
|
def _edge_types() -> set[str]:
|
||||||
|
|
|
||||||
107
tests/test_height_fall.py
Normal file
107
tests/test_height_fall.py
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
"""추락(높이에서 떨어짐) 판정.
|
||||||
|
|
||||||
|
이 프로젝트가 잡고 있던 것은 같은 높이에서의 **넘어짐**이고, 산업재해 사망 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
|
||||||
|
|
@ -112,3 +112,33 @@ 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)] == []
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue