From 4bfb7e0274a586de4c489ca2f680402ba3c5486f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EC=B0=BD=EC=9A=B1?= Date: Sat, 5 Sep 2026 12:21:50 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=B6=94=EB=9D=BD=20=C2=B7=20=EB=8F=99?= =?UTF-8?q?=EC=8B=9C=20=EB=8B=A4=EB=B0=9C=20=EC=93=B0=EB=9F=AC=EC=A7=90=20?= =?UTF-8?q?=C2=B7=20=EA=B5=AC=EC=97=AD=20=ED=94=84=EB=A6=AC=EC=85=8B=20(1.?= =?UTF-8?q?2.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01EBjuR7DW6PNNMows4KnbYU --- CHANGELOG.md | 55 ++++++ VERSION | 2 +- docs/INDEX.md | 10 ++ docs/설계-신규감지-3종.md | 9 +- edge/detectors/ghost.py | 35 +++- edge/detectors/height.py | 100 +++++++++++ edge/detectors/occupancy.py | 7 +- edge/detectors/zone.py | 15 ++ edge/engine.py | 48 +++++- edge/events.py | 4 + .../2026-09-04-fall-from-height.sql | 39 +++++ server/public/assets/app.js | 4 +- server/public/assets/compliance.js | 4 +- server/public/assets/detail.js | 4 +- server/public/assets/package.js | 4 +- server/public/assets/report.js | 4 +- server/public/assets/zone-draw.js | 61 ++++++- server/public/index.php | 1 + server/schema.sql | 4 +- server/src/Alert/Channels.php | 45 ++++- server/src/Alert/MultiDownWatch.php | 162 ++++++++++++++++++ server/src/Api/ZonePresetCatalog.php | 102 +++++++++++ server/src/Ingest/EventValidator.php | 4 +- server/src/View/events.php | 2 + server/src/View/zones.php | 11 +- server/tests/AlertGuidanceTest.php | 69 ++++++++ server/tests/MultiDownWatchTest.php | 138 +++++++++++++++ server/tests/RouteTableTest.php | 3 +- server/tools/dispatch_alerts.php | 24 ++- tests/test_engine.py | 105 ++++++++++++ tests/test_event_type_parity.py | 2 +- tests/test_height_fall.py | 107 ++++++++++++ tests/test_occupancy.py | 30 ++++ 33 files changed, 1169 insertions(+), 45 deletions(-) create mode 100644 edge/detectors/height.py create mode 100644 server/migrations/2026-09-04-fall-from-height.sql create mode 100644 server/src/Alert/MultiDownWatch.php create mode 100644 server/src/Api/ZonePresetCatalog.php create mode 100644 server/tests/AlertGuidanceTest.php create mode 100644 server/tests/MultiDownWatchTest.php create mode 100644 tests/test_height_fall.py diff --git a/CHANGELOG.md b/CHANGELOG.md index cac47a3..76c3d0e 100644 --- a/CHANGELOG.md +++ b/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.0.0 은 "감지에 대응하는 시스템"이었습니다. 그런데 **감시 장비 자신이 멈춘 것**은 여전히 diff --git a/VERSION b/VERSION index 1cc5f65..867e524 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.0 \ No newline at end of file +1.2.0 \ No newline at end of file diff --git a/docs/INDEX.md b/docs/INDEX.md index bf1b10c..8282d32 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -156,3 +156,13 @@ - 검증: 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 diff --git a/docs/설계-신규감지-3종.md b/docs/설계-신규감지-3종.md index ce7ceed..af0e911 100644 --- a/docs/설계-신규감지-3종.md +++ b/docs/설계-신규감지-3종.md @@ -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` · > `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 ``` -`0`은 falsy라 규칙이 통째로 꺼집니다. 즉 **"아무도 있으면 안 되는 구역"을 표현할 방법이 -현재 없습니다.** 크레인 짐 아래·굴착기 선회 반경은 정확히 그 규칙이고, 깔림 사고의 전형입니다. +`0`은 falsy라 규칙이 통째로 꺼졌습니다. **1.2.0 에서 수정** — 감지기·입력란·저장 직렬화· +목록 표시 네 곳 모두에서 막혀 있었습니다. 크레인 짐 아래·굴착기 선회 반경은 정확히 그 규칙이고, 깔림 사고의 전형입니다. 수정은 한 줄입니다 — `if max_workers is not None and n > max_workers`. `min_workers`도 같은 형태이므로 함께 봐야 합니다. diff --git a/edge/detectors/ghost.py b/edge/detectors/ghost.py index 4d1f3d0..6ed8a38 100644 --- a/edge/detectors/ghost.py +++ b/edge/detectors/ghost.py @@ -15,28 +15,46 @@ class GhostIncidentDetector: def __init__(self, cfg: GhostConfig): self.cfg = cfg self._states: dict[int, str] = {} + # 높이 기반 무장: 위험 자세가 아니어도 '떨어지는 중'이거나 '고소 구역 안'이면 + # 소실 시 추락 후보로 잡는다. 발판에 서 있다가 화각 밖으로 떨어지는 사람은 + # 소실 순간 STANDING 이라 _DANGEROUS 조건만으로는 영원히 안 걸린다. + self._height: dict[int, dict] = {} # 후보: track_id -> (spatial(x,y), last_state, height, lost_at) self._candidates: dict[int, tuple] = {} def note_state(self, track_id: int, state: str) -> None: 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: for tr in lost_tracks: 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 # 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 # id); a track already present is a bystander/colleague and must not cancel # the incident by walking over the loss point. 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._height.pop(tr.track_id, None) 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: if tr.track_id in bystanders: continue # a colleague who was already here — not the reappearing victim @@ -47,9 +65,14 @@ class GhostIncidentDetector: break # 대기 초과 방출 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: - out.append({"track_id": tid, "spatial": spatial, "last_state": state, - "waited_s": round(now - lost_at, 1)}) + rec = {"track_id": tid, "spatial": spatial, "last_state": state, + "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) return out diff --git a/edge/detectors/height.py b/edge/detectors/height.py new file mode 100644 index 0000000..1e85c80 --- /dev/null +++ b/edge/detectors/height.py @@ -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 diff --git a/edge/detectors/occupancy.py b/edge/detectors/occupancy.py index 734bc3e..a8ec1ec 100644 --- a/edge/detectors/occupancy.py +++ b/edge/detectors/occupancy.py @@ -56,9 +56,12 @@ class OccupancyWatchdog: self._lone_since.pop(zid, None) self._lone_fired.discard(zid) - # 정원 초과 + # 정원 초과. `is not None` 이어야 한다 — 0 은 falsy 라 `if max_workers` 로 쓰면 + # "아무도 있으면 안 되는 구역"(크레인 짐 아래·굴착기 선회 반경 = 깔림 사고의 전형)이 + # 화면에는 규칙으로 저장돼 있는데 감지기는 통째로 꺼진 상태가 된다. + # 키가 아예 없는 것(제한 없음)과 0(아무도 안 됨)은 다른 뜻이다. 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) if now - start >= self.cfg.over_confirm_s and zid not in self._over_fired: self._over_fired.add(zid) diff --git a/edge/detectors/zone.py b/edge/detectors/zone.py index 55e13a6..1bf1ae6 100644 --- a/edge/detectors/zone.py +++ b/edge/detectors/zone.py @@ -71,6 +71,21 @@ class ZoneIntrusionDetector: fx, fy = fx / w, fy / h 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: return {z for (tid, z) in self._inside_since if tid == track_id} diff --git a/edge/engine.py b/edge/engine.py index 92176ca..5094a5d 100644 --- a/edge/engine.py +++ b/edge/engine.py @@ -8,6 +8,7 @@ from edge.detectors.ghost import GhostIncidentDetector from edge.detectors.zone import ZoneIntrusionDetector from edge.detectors.occupancy import OccupancyWatchdog, OccupancyConfig from edge.detectors.ergonomics import ErgoMonitor, ErgoConfig +from edge.detectors.height import DescentWatch, DescentConfig @dataclass @@ -25,7 +26,8 @@ class EventEngine: def __init__(self, fall: FallDetector, immobility: ImmobilityWatchdog, ghost: GhostIncidentDetector, zone: ZoneIntrusionDetector, cfg: EngineConfig, occupancy: OccupancyWatchdog | None = None, - ergonomics: ErgoMonitor | None = None): + ergonomics: ErgoMonitor | None = None, + descent: DescentWatch | None = None): self.fall = fall self.immobility = immobility self.ghost = ghost @@ -33,6 +35,7 @@ class EventEngine: # 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.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._last_emit: dict[tuple, float] = {} @@ -48,6 +51,7 @@ class EventEngine: self.fall.forget(track_id) self.immobility.forget(track_id) self.zone.forget(track_id) + self.descent.forget(track_id) self.occupancy.forget(track_id) self._last_emit = {k: v for k, v in self._last_emit.items() if not (len(k) >= 2 and k[1] == track_id)} @@ -80,6 +84,14 @@ class EventEngine: # 계산하는 좌표계를 그대로 재사용해 존 폴리곤과 같은 공간에 놓인다. 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) state = self.fall._states.get(tr.track_id) 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": self.ergonomics.update(tr, now) - 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)) + if sig == "fall": + # 방금 떨어진 사람이 바닥에서 확정되면 그것은 넘어짐이 아니라 추락이다. + # 두 건으로 올리지 않고 유형만 올린다 — 한 번의 사고는 한 건이다. + 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): events.append(self._mk(EventType.NO_MOTION, occurred_at, tr.track_id, metrics=meta, spatial=foot)) @@ -150,10 +173,19 @@ class EventEngine: w, h = frame_wh if w > 0 and h > 0: gs = (gs[0] / w, gs[1] / h) - events.append(self._mk(EventType.GHOST, occurred_at, track_id=g["track_id"], - spatial=gs, - metrics={"last_state": g["last_state"], - "waited_s": g["waited_s"]})) + gmeta = {"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. for tr in lost_tracks: diff --git a/edge/events.py b/edge/events.py index 37b24e3..a16ca1a 100644 --- a/edge/events.py +++ b/edge/events.py @@ -5,6 +5,7 @@ from enum import Enum class EventType(str, Enum): FALL = "fall" + FALL_FROM_HEIGHT = "fall_from_height" # 높이에서 떨어짐 — 넘어짐과 다른 사건 NO_MOTION = "no_motion" IMMOBILITY = "immobility" GHOST = "ghost" @@ -44,6 +45,9 @@ _SEVERITY = { EventType.NO_MOTION: Severity.CRITICAL, EventType.GHOST: Severity.CRITICAL, EventType.FALL: Severity.HIGH, + # 추락은 척추·두부 손상 가능성이 있어 함부로 옮기면 안 된다. 그 주의가 통보에 + # 실리려면 넘어짐(HIGH)과 등급이 갈라져야 한다. + EventType.FALL_FROM_HEIGHT: Severity.CRITICAL, EventType.ZONE_RESTRICTED: Severity.HIGH, EventType.PINCH: Severity.HIGH, EventType.IMMOBILITY: Severity.WARNING, diff --git a/server/migrations/2026-09-04-fall-from-height.sql b/server/migrations/2026-09-04-fall-from-height.sql new file mode 100644 index 0000000..f576308 --- /dev/null +++ b/server/migrations/2026-09-04-fall-from-height.sql @@ -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' + )); diff --git a/server/public/assets/app.js b/server/public/assets/app.js index b599db5..1281d17 100644 --- a/server/public/assets/app.js +++ b/server/public/assets/app.js @@ -25,14 +25,14 @@ // 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. var TYPE_LABELS = { - fall: '넘어짐', no_motion: '넘어짐 후 미동 없음', immobility: '장시간 부동', + 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: '미확인 경보 에스컬레이션', + unacked_escalation: '미확인 경보 에스컬레이션', multi_down: '동시 다발 쓰러짐', // 모델 확보 후 활성화 (엣지는 아직 발화하지 않음). ppe_missing: '보호구 미착용', proximity: '중장비 근접', fire_smoke: '화재·연기' }; diff --git a/server/public/assets/compliance.js b/server/public/assets/compliance.js index 6eed194..cc47a18 100644 --- a/server/public/assets/compliance.js +++ b/server/public/assets/compliance.js @@ -13,13 +13,13 @@ var csrf = null; var TYPE_LABELS = { - fall: '넘어짐', no_motion: '넘어짐 후 미동 없음', immobility: '장시간 부동', + 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: '미확인 경보 에스컬레이션', + unacked_escalation: '미확인 경보 에스컬레이션', multi_down: '동시 다발 쓰러짐', ppe_missing: '보호구 미착용', proximity: '중장비 근접', fire_smoke: '화재·연기' }; diff --git a/server/public/assets/detail.js b/server/public/assets/detail.js index b76a751..d7ebe1a 100644 --- a/server/public/assets/detail.js +++ b/server/public/assets/detail.js @@ -29,14 +29,14 @@ // 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. var TYPE_LABELS = { - fall: '넘어짐', no_motion: '넘어짐 후 미동 없음', immobility: '장시간 부동', + 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: '미확인 경보 에스컬레이션', + unacked_escalation: '미확인 경보 에스컬레이션', multi_down: '동시 다발 쓰러짐', // 모델 확보 후 활성화 (엣지는 아직 발화하지 않음). ppe_missing: '보호구 미착용', proximity: '중장비 근접', fire_smoke: '화재·연기' }; diff --git a/server/public/assets/package.js b/server/public/assets/package.js index 6b6025f..e0505a4 100644 --- a/server/public/assets/package.js +++ b/server/public/assets/package.js @@ -22,13 +22,13 @@ var id = m[1]; var TYPE_LABELS = { - fall: '넘어짐', no_motion: '넘어짐 후 미동 없음', immobility: '장시간 부동', + 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: '미확인 경보 에스컬레이션', + unacked_escalation: '미확인 경보 에스컬레이션', multi_down: '동시 다발 쓰러짐', ppe_missing: '보호구 미착용', proximity: '중장비 근접', fire_smoke: '화재·연기' }; var SEV_LABELS = { critical: '위급', high: '높음', warning: '경고', info: '정보' }; diff --git a/server/public/assets/report.js b/server/public/assets/report.js index 09c4573..cb74470 100644 --- a/server/public/assets/report.js +++ b/server/public/assets/report.js @@ -12,14 +12,14 @@ var B = window.APP_BASE || ''; var TYPE_LABELS = { - fall: '넘어짐', no_motion: '넘어짐 후 미동 없음', immobility: '장시간 부동', + 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: '미확인 경보 에스컬레이션', + unacked_escalation: '미확인 경보 에스컬레이션', multi_down: '동시 다발 쓰러짐', // 모델 확보 후 활성화 (엣지는 아직 발화하지 않음). ppe_missing: '보호구 미착용', proximity: '중장비 근접', fire_smoke: '화재·연기' }; diff --git a/server/public/assets/zone-draw.js b/server/public/assets/zone-draw.js index 7fa3af7..d1b5284 100644 --- a/server/public/assets/zone-draw.js +++ b/server/public/assets/zone-draw.js @@ -233,14 +233,20 @@ // 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. + // 프리셋이 elevated 를 켜면 화면에 입력란이 없으므로 여기 들고 있는다(고소 구역 표시). + var elevated = false; + function rulesFromInputs() { var rules = {}; var minW = parseInt(ruleMinInput && ruleMinInput.value, 10); var maxW = parseInt(ruleMaxInput && ruleMaxInput.value, 10); var dwellMin = parseInt(ruleDwellInput && ruleDwellInput.value, 10); 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 (elevated) rules.elevated = true; return rules; } @@ -249,7 +255,8 @@ try { r = JSON.parse(z.rules_json || 'null') || {}; } catch (e) { return ''; } var parts = []; 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) + '분'); 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 // events console already knew both. Devices come from /api/devices; the cameras under each diff --git a/server/public/index.php b/server/public/index.php index 5c96ffd..aa94e25 100644 --- a/server/public/index.php +++ b/server/public/index.php @@ -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('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('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/heatmap/{camera}', fn(Request $q, array $p) => $heatmap->json($q, $p)); $r->add('GET', '/api/fp-stats', fn(Request $q) => $fpStats->json($q)); diff --git a/server/schema.sql b/server/schema.sql index f5d4ad5..f074e48 100644 --- a/server/schema.sql +++ b/server/schema.sql @@ -163,13 +163,13 @@ CREATE TABLE move_events ( KEY ix_events_device (device_id), KEY ix_events_camera (camera_id, event_id), -- zone editor: newest snapshot per camera 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', 'lone_worker','overcapacity','overstay','near_fall','ergo_report', -- 감시 자체의 건전성: 카메라가 틀어지거나 가려지면 존 판정이 조용히 무의미해진다. 'camera_moved','camera_obstructed','low_light', -- 아무도 확인하지 않았다는 사실 자체를 사건으로 만든다 (에스컬레이션 디스패처가 발행). - 'unacked_escalation', + 'unacked_escalation','multi_down', -- 모델 확보 후 활성화되는 자리. 엣지 감지기는 있으나 기본 비활성이다. 'ppe_missing','proximity','fire_smoke' )), diff --git a/server/src/Alert/Channels.php b/server/src/Alert/Channels.php index 548b80b..e58c1c4 100644 --- a/server/src/Alert/Channels.php +++ b/server/src/Alert/Channels.php @@ -28,7 +28,9 @@ use App\DeviceScope; final class Channels { private const TYPE_LABELS = [ - 'fall' => '넘어짐', 'no_motion' => '넘어짐 후 미동 없음', 'immobility' => '장시간 부동', + 'fall' => '넘어짐', 'fall_from_height' => '추락', + 'multi_down' => '동시 다발 쓰러짐', + 'no_motion' => '넘어짐 후 미동 없음', 'immobility' => '장시간 부동', 'ghost' => '위험 상태 소실', 'zone_warning' => '경고구역 진입', 'zone_restricted' => '제한구역 침입', 'pinch' => '협착구역 침입', 'monitoring_degraded' => '감시 성능 저하', 'device_fault' => '장치 이상', 'lone_worker' => '위험구역 단독 작업', 'overcapacity' => '정원 초과', 'overstay' => '체류 상한 초과', @@ -55,6 +57,45 @@ final class Channels ) {} /** 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 { try { @@ -107,7 +148,7 @@ final class Channels "", "이 메일에는 개인정보 보호를 위해 현장 이미지가 포함되지 않습니다.", "이미지는 위 링크에서 로그인 후에만 열람할 수 있습니다.", - ]); + ]) . self::guidance($event); } private function sendEmail(array $event, array $config): void diff --git a/server/src/Alert/MultiDownWatch.php b/server/src/Alert/MultiDownWatch.php new file mode 100644 index 0000000..08ccdbf --- /dev/null +++ b/server/src/Alert/MultiDownWatch.php @@ -0,0 +1,162 @@ + + */ + 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> + */ + 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; + } +} diff --git a/server/src/Api/ZonePresetCatalog.php b/server/src/Api/ZonePresetCatalog.php new file mode 100644 index 0000000..5f1aa8b --- /dev/null +++ b/server/src/Api/ZonePresetCatalog.php @@ -0,0 +1,102 @@ +, + * 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|null */ + public static function find(string $id): ?array + { + foreach (self::all() as $preset) { + if ($preset['id'] === $id) return $preset; + } + return null; + } +} diff --git a/server/src/Ingest/EventValidator.php b/server/src/Ingest/EventValidator.php index 37e7790..c12da6e 100644 --- a/server/src/Ingest/EventValidator.php +++ b/server/src/Ingest/EventValidator.php @@ -18,13 +18,13 @@ final class EventValidator * 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. */ - 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', 'lone_worker','overcapacity','overstay','near_fall','ergo_report', // 감시 자체의 건전성 (edge/detectors/integrity.py) 'camera_moved','camera_obstructed','low_light', // 서버가 발행하지만(EscalationWatch, 검증기를 거치지 않음) 유형 집합은 한 곳에 모은다. - 'unacked_escalation', + 'unacked_escalation','multi_down', // 모델 확보 후 활성화 (edge/detectors/pending.py) — 지금은 엣지가 발화하지 않는다. 'ppe_missing','proximity','fire_smoke']; private const SEVERITIES = ['critical','high','warning','info']; diff --git a/server/src/View/events.php b/server/src/View/events.php index 9c9e715..0c3a597 100644 --- a/server/src/View/events.php +++ b/server/src/View/events.php @@ -30,6 +30,7 @@ render_header('이벤트'); + + + +