From 93f896af87eb84558b22ce7d5dc06a34d8043f08 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 23:11:57 +0900 Subject: [PATCH] =?UTF-8?q?fix(audit):=201.2.0=204=EC=B0=A8=20=EC=9E=AC?= =?UTF-8?q?=EA=B0=90=EC=82=AC=20=E2=80=94=20=ED=99=95=EC=A0=95=2023?= =?UTF-8?q?=EA=B1=B4=20=EC=A0=84=EB=9F=89=20=ED=95=B4=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4개 관점 병렬 감사 후 각 발견을 반증 시도해 확정. 반증 5건은 문서에 남겼다. 이번 감사의 요지: **구현도 단위테스트도 초록인데 납품 경로에서 동작하지 않는** 결함이 셋 있었다. 전부 같은 모양이다 — 테스트가 기본값이 아닌 설정을 썼거나, 값이 존재해도 되는지 결정하는 상류 허용 목록을 보지 않았다. CRITICAL 4건: R4-1 추락 승격이 기본 설정에서 한 번도 발화하지 않았다. link_s(3.0)를 now 기준으로 쟀는데 넘어짐은 prone_confirm_s(3.0) 지속 후 확정되므로 확정 시점에는 항상 3초 넘게 지나 있다. 다른 테스트가 전부 prone_confirm_s=0.5 를 써서 초록이었다. 착지(prone 시작) 시각과 비교하도록 고치고, 기본값 그대로 도는 회귀 테스트를 넣었다. R4-2 max_workers:0 이 상류 두 곳에서 막혀 있었다 — 서버 규칙 경계가 [1,50] 이라 저장이 400 이고, 엣지 파서가 v<=0 으로 규칙 전체를 무효화해 같이 넣은 2인1조까지 사라졌다. 앞 커밋에서 고친 네 곳은 전부 이 둘보다 하류라 도달 불가였다. R4-3 고소작업 프리셋(elevated)이 완전히 무동작이었다. 서버 경계에 키가 없어 400, 통과해도 엣지 파서가 침묵 삭제. 프리셋 안내문과 커밋 메시지가 시스템이 하지 않는 일을 약속하고 있었다. R4-4 동시 다발이 한 사람을 두 명으로 셀 수 있었다. 트래커는 1초 가려짐에 트랙을 버리고 새 id 를 주고(max_age=1.0) 누운 사람은 새 트랙에서도 즉시 prone 이라 두 번째 fall 이 난다. 그러면 다친 한 사람에게 "공기호흡기 없이 진입 금지"가 나가 구조가 늦어진다 — 기능의 가치가 정반대로 뒤집힌다. ghost 를 인원 집계에서 빼고, 거의 같은 자리의 사건은 같은 사람으로 본다. HIGH 5건: 구역 그룹핑 사문화(엣지가 zone_id 를 안 실어 카메라가 다른 두 희생자가 절대 안 묶였다 — 밀폐공간이 정확히 그 상황), 창 미끄러짐으로 한 사고가 매 분 새 위급 건으로 쏟아지고 인원이 줄면 앞선 알림을 스스로 뒤집던 문제, 무장 미만료·forget 누락·이중 발보, 업그레이드 안내의 마이그레이션 2개 누락(신규 유형 INSERT 가 전부 DB CHECK 에 걸린다), BUILD-INFO 커밋 스탬프. BUILD-INFO 는 1.1.0 에서 고친 R3-6 의 재발이다. 절차로 막으려다 두 번 실패했으므로 HEAD 일치를 검사하는 테스트로 막았다. README 마이그레이션 목록도 같은 이유로 테스트를 붙였다. MEDIUM 14건 중 눈에 띄는 것: 정비 억제가 SCBA 경고를 삼켰고(허가가 발급된 그 구역·그 시간이 질식이 일어나는 자리다) 위급 3종을 억제 예외로 뺐다. 데모 시더 심각도가 제품과 7종 어긋나 fall 이 위급으로 찍혀 1.2.0 의 요점인 넘어짐/추락 구분이 데모에서 지워져 있었다 — 라벨과 달리 화면이 안 깨져 눈으로는 못 잡으므로 엣지를 단일 출처로 강제하는 테스트를 붙였다. 문서의 "17종"은 실제 20종(엣지 16 + 서버 4)으로 정정하고 신규 2종을 소개서 감지 표에 넣었다. 검증: pytest 335 passed / phpunit 269 passed. 신규 테스트 30건. dist 는 이 커밋 이후 재생성한다(BUILD-INFO 가 HEAD 를 가리켜야 한다). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EBjuR7DW6PNNMows4KnbYU --- CHANGELOG.md | 2 +- docs/PROJECT_MAP.md | 8 +- docs/deck/deck.json | 6 +- docs/delivery-audit.md | 93 +++++++++++- docs/소개서-이미지-브리프.md | 2 +- docs/영상보관-노조대응.md | 2 +- docs/프로젝트-소개서.md | 12 +- edge/detectors/ghost.py | 22 ++- edge/detectors/height.py | 37 ++++- edge/engine.py | 29 +++- edge/zones.py | 22 ++- server/deploy/README-UPLOAD.md | 2 + server/public/assets/zone-draw.js | 40 +++++- server/public/index.php | 2 +- server/src/Alert/AlertDispatcher.php | 13 ++ server/src/Alert/MultiDownWatch.php | 133 ++++++++++++++---- server/src/Dashboard/ReportController.php | 5 +- server/src/Dashboard/ZoneConfigController.php | 32 ++++- server/tests/MultiDownWatchTest.php | 100 +++++++++++-- server/tools/seed_demo.php | 16 ++- tests/test_dist_parity.py | 45 ++++++ tests/test_engine.py | 69 +++++++++ tests/test_height_fall.py | 23 ++- tests/test_seed_demo_severity.py | 56 ++++++++ tests/test_zone_preset_roundtrip.py | 84 +++++++++++ 25 files changed, 779 insertions(+), 76 deletions(-) create mode 100644 tests/test_seed_demo_severity.py create mode 100644 tests/test_zone_preset_roundtrip.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 76c3d0e..cc45f24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ --- -## 1.2.0 — 2026-09-04 · 추락 · 동시 다발 쓰러짐 · 구역 프리셋 +## 1.2.0 — 2026-09-05 · 추락 · 동시 다발 쓰러짐 · 구역 프리셋 AI CCTV 의 목적을 원론부터 다시 점검한 결과입니다. 셋 다 **새 추론 모델 없이** 지금 있는 자세추정·구역·트랙 이력만으로 구현했습니다. diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index e255155..e9fa447 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -16,7 +16,7 @@ | `app.py` | `run_once` 파이프라인 (프레임→추론→추적→감지→스냅샷→전송). ambiguous 리셋·stale 필터 | | `engine.py` | `EventEngine` — 감지기 오케스트레이션, 쿨다운 dedup, 트랙 forget | | `tracking.py` | `IouTracker` — 전역 그리디 IoU 매칭, `ambiguous()` (교차 경합 트랙) | -| `detectors/` | `fall.py`(FALLING→FALLEN→NO_MOTION, sustained prone) · `immobility.py` · `ghost.py` · `zone.py` · `occupancy.py`(존 규칙: 2인1조/정원/체류상한 — Zone.rules 소비) | +| `detectors/` | `fall.py`(FALLING→FALLEN→NO_MOTION, sustained prone) · `immobility.py` · `ghost.py`(위험 상태 소실 + 높이 무장 → 화각 밖 추락) · `zone.py`(`elevated` 고소 표시) · `occupancy.py`(존 규칙: 2인1조/정원/체류상한 — Zone.rules 소비) | | `smoothing.py` | 트랙별 키포인트 스무딩 풀 | | `snapshot.py` | RingBuffer · FaceBlurrer(PIPA) · Snapshotter | | `alert.py` / `serverclient.py` | HMAC+nonce POST, 커스텀 UA, no-redirect | @@ -28,6 +28,7 @@ | `beacon.py` | 현장 경광등·부저 (GPIO). **네트워크 없이 동작하는 유일한 통보 경로** — /api/config 의 `beacons` 로 설정 수신, 하드웨어 없으면 NullBeacon 으로 폴백 | | `tuning.py` | 서버 임계값 → 살아 있는 감지기에 적용. 이전에는 `thresholds_from_config()` 가 파싱만 하고 버려서 관리자 변경이 장비에 닿지 않았다 | | `detectors/integrity.py` | 카메라 화각 이동·가림·조도 저하 (`camera_moved`/`camera_obstructed`/`low_light`). 존 판정이 조용히 무의미해지는 실패 모드를 드러낸다 | +| `detectors/height.py` | 추락(`fall_from_height`) 1차 신호. **중심이 아니라 bbox 하단(y2)** 으로 판정 — 쭈그려 앉으면 중심은 내려가도 발은 그 자리다. `landed_from_drop`(착지 시각과 비교)이 핵심: now 로 비교하면 확정이 prone_confirm_s 만큼 늦어 기본값에서 영원히 거짓 | | `detectors/pending.py` | 보호구·중장비 근접·화재 — 배선/계약 완료, **모델 미확보로 기본 비활성** (AGPL 아닌 가중치 + HEF 컴파일 필요) | ## server/ (PHP 8 + PDO — Cafe24) @@ -37,6 +38,8 @@ - `src/Alert/`: `AlertDispatcher`(어떤 통보를 낼지 결정 — 순수 로직, I/O 없음) · `Channels`(email/webhook 발송; `local` 은 엣지가 자체 GPIO 로) · `EscalationWatch`(미확인 위급 → `unacked_escalation` 이벤트 발행, event_uuid 로 멱등) · + `MultiDownWatch`(같은 구역 짧은 시간 2명 이상 쓰러짐 → `multi_down`; 값어치는 통보 문구에 + 있다 — Channels::guidance 의 "공기호흡기 없이 진입 금지") · `DeviceHealthWatch`(하트비트 부재 → `device_fault`, 지속 성능저하 → `monitoring_degraded`; 엣지가 낼 수 없는 두 유형을 서버가 발행. uuid 앵커 = 장애 구간 시작이라 장애당 1건) - `docs/deck/`: 경영진 보고 덱. `deck.json`(내용) + `build_deck.py`(생성기) + `clips/`(3초 재현 @@ -44,6 +47,9 @@ 발주 명세는 `docs/영상-브리프.md`. - `docs/images/`: 소개서·덱 공용 도판 11장(원본은 `image/`, 최적화본이 여기). 배치 매니페스트 `docs/소개서-이미지.json`. +- `src/Api/ZonePresetCatalog.php`: 용도별 구역 프리셋 6종(밀폐공간·고소작업·프레스협착· + 출입금지·지게차통로·화기작업). 새 감지 기능이 아니라 기존 규칙에 현장 어휘를 붙인 것. + `GET /api/zone-presets` (ZoneConfigController::presets, view_events 게이트). - `src/Api/ThresholdCatalog.php`: 편집 가능한 튜너블의 화이트리스트 + 범위 + 한국어 설명. 목록에 없는 키는 API 로 저장 불가 (장비가 조용히 무시하는 값을 "설정됨"으로 보이지 않게) - `tools/`: `dispatch_alerts.php`(크론 1분) · `maintain.php`(크론 일 1회, 보존기간) · diff --git a/docs/deck/deck.json b/docs/deck/deck.json index bfbdec4..ab09b5b 100644 --- a/docs/deck/deck.json +++ b/docs/deck/deck.json @@ -152,7 +152,7 @@ }, { "head": "근무태도 기능이 없습니다", - "body": "감지 17종 전부가 위험 상태입니다. 자리 비움·작업 속도는 만들 수 있는 기능 자체가 없습니다." + "body": "감지 20종 전부가 위험 상태입니다. 자리 비움·작업 속도는 만들 수 있는 기능 자체가 없습니다." } ], "note": "존재하지 않는 데이터는 열람될 수 없고, 유출될 수 없고, 목적 외로 쓰일 수 없습니다. 접근 통제는 되돌릴 수 있지만 미수집은 되돌릴 수 없습니다." @@ -169,9 +169,9 @@ "eyebrow": "현재 상태", "title": "지금 어디까지 와 있습니까", "done": { - "head": "완료 — 1.1.0 납품 버전", + "head": "완료 — 1.2.0 납품 버전", "items": [ - "감지 17종 · 통보 · 에스컬레이션 · 사고조사 패키지", + "감지 20종(추락·동시 다발 포함) · 통보 · 에스컬레이션 · 사고조사 패키지", "자동 테스트 548건 통과 (엣지 308 · 서버 240)", "납품 점검 43건 + 재감사 12건 전량 해소 — 판정 적합", "폐쇄망 설치 패키지 · 역할별 사용설명서" diff --git a/docs/delivery-audit.md b/docs/delivery-audit.md index 2d5e033..3b45799 100644 --- a/docs/delivery-audit.md +++ b/docs/delivery-audit.md @@ -1,7 +1,8 @@ # Delivery Audit — move-detect (납품 전 결함 목록 · 순차 처리) -> **판정 (2026-09-04, 1.1.0): 적합 — 오프하드웨어 항목 전량 해소.** -> 1차 43/43 + 2차 재감사 12/12 + **3차 재감사(1.1.0 장치 건전성 표면) 14/14** 해소. +> **판정 (2026-09-05, 1.2.0): 적합 — 오프하드웨어 항목 전량 해소.** +> 1차 43/43 + 2차 12/12 + 3차(1.1.0 장치 건전성) 14/14 + +> **4차 재감사(1.2.0 추락·동시 다발·프리셋 표면) 23/23** 해소. > 잔여는 실장비(OFF-HW) 검증뿐 — 아래 "OFF-HW 잔여" 절 참조. Source: 62-agent adversarial audit (2026-07-16) + skill `iot-airgap-delivery` §3 + manual @@ -433,6 +434,94 @@ config 템플릿 2종. `edge/` 는 불변. 4개 관점 병렬 감사 후 각 발 --- +## 4차 재감사 — 1.2.0 추락·동시 다발·프리셋 (2026-09-05) + +범위: 1.1.0 이후 변경 전량. 4개 관점 병렬 감사, 각 발견을 반증 시도해 확정. +**이번 감사의 요지: 구현·단위테스트가 모두 초록인데 납품 경로에서 동작하지 않는 결함이 +셋 있었다.** 전부 "테스트가 기본값이 아닌 설정을 썼다"거나 "상류 허용 목록을 안 봤다"는 +같은 모양이다. + +### CRITICAL + +- [x] **R4-1 [B]** 추락 승격이 **기본 설정에서 한 번도 발화하지 않았다.** `link_s`(3.0)를 + `now` 기준으로 쟀는데 넘어짐은 `prone_confirm_s`(3.0) 지속 후 확정되므로 확정 시점에는 + 항상 3초 넘게 지나 있다. 다른 테스트가 전부 `prone_confirm_s=0.5` 를 써서 초록이었다. + DONE — 착지(prone 시작) 시각과 비교(`landed_from_drop`). **기본값 그대로 도는 회귀 + 테스트를 추가**했다(설정을 낮추지 말 것이라고 테스트에 적었다). +- [x] **R4-2 [B]** `max_workers: 0` 이 상류 허용 목록 두 곳에서 막혀 있었다 — + `ZoneConfigController::RULE_BOUNDS` 가 `[1,50]` 이라 저장이 400, `edge/zones.py` 가 + `v <= 0` 으로 규칙 **전체**를 무효화(같이 넣은 2인1조까지 사라짐). 앞서 고친 네 곳은 + 전부 이 둘보다 하류라 도달 불가였다. DONE. +- [x] **R4-3 [B]** 고소작업 프리셋(`elevated`)이 **완전히 무동작**이었다. 서버 경계에 키가 + 없어 400, 통과하더라도 엣지 파서가 침묵 삭제. 프리셋 안내문과 커밋 메시지가 시스템이 + 하지 않는 일을 약속하고 있었다 — 이 제품에서 가장 나쁜 종류의 결함이다. DONE. +- [x] **R4-4 [B]** 동시 다발이 **한 사람을 두 명으로 셀 수 있었다.** 트래커는 1초 가려짐에 + 트랙을 버리고 새 id 를 주고(`max_age=1.0`), 누운 사람은 새 트랙에서도 즉시 prone 이라 + 두 번째 `fall` 이 난다. 그러면 다친 한 사람에게 "공기호흡기 없이 진입 금지"가 나가 + **구조가 늦어진다** — 기능의 가치가 정반대로 뒤집힌다. DONE — `ghost` 를 인원 집계에서 + 빼고, 거의 같은 자리(0.08 미만)의 사건은 같은 사람으로 본다. + +### HIGH + +- [x] **R4-5 [H]** 구역 그룹핑이 사문화돼 있었다 — 엣지가 넘어짐·미동·소실에 `zone_id` 를 + 싣지 않아 카메라가 다른 두 희생자가 절대 묶이지 않았다(밀폐공간이 정확히 그 상황). + DONE — 사람 상태 이벤트에 구역을 싣고, 소실 이벤트는 마지막 구역을 물려준다. +- [x] **R4-6 [H]** 창이 미끄러지면 `group[0]` 이 바뀌어 uuid 가 달라져, 한 사고가 매 분 + 새 위급 건으로 쏟아지고 인원이 줄면 앞선 알림을 스스로 뒤집었다. DONE — 이미 올린 + 무리는 인원이 늘 때만 다시 올린다. +- [x] **R4-7 [H]** 무장이 만료되지 않아 발판에 잠깐 올라갔던 사람이 몇 분 뒤 걸어 나가도 + 추락 경보가 됐다. `engine.forget()` 이 ghost 상태를 안 지워 트랙 교체 시 다른 사람이 + 무장을 물려받았다. 프레임 안에서 이미 낸 추락이 소실 경로에서 한 번 더 났다. DONE. +- [x] **R4-8 [H]** 업그레이드 안내에 마이그레이션 2개가 빠져 있었다. 그중 하나가 없으면 + 신규 유형 INSERT 가 DB CHECK 에 전부 걸린다 — 감지기는 도는데 화면에 아무것도 안 뜬다. + DONE + **README 가 실제 마이그레이션을 전부 담는지 검사하는 테스트** 추가. +- [x] **R4-9 [H]** `BUILD-INFO` 의 커밋 스탬프가 **1.1.0 에서 고친 결함(R3-6)의 재발**. + 절차로 막으려다 두 번 실패했으므로 DONE — HEAD 와 일치하는지 검사하는 테스트로 막았다. + +### MEDIUM + +- [x] **R4-10 [M]** 정비·작업허가 억제가 "공기호흡기 없이 진입 금지"를 삼켰다. 허가가 + 발급된 그 구역·그 시간이 질식이 일어나는 자리다. DONE — 위급 3종은 억제 예외. +- [x] **R4-11 [M]** 미래 시각 이벤트가 무관한 넘어짐과 짝지어져 가스 경보를 만들 수 있었다 + (폐쇄망은 NTP 부재가 흔하다). DONE. +- [x] **R4-12 [M]** 문자열 접두사 비교로 `cam-1` 과 `cam-10` 이 한 사건으로 합쳐졌다. DONE. +- [x] **R4-13 [M]** 좌표 없는 희생자와 좌표 있는 희생자가 영영 안 묶여 진짜 동시 다발을 + 놓쳤다. DONE — 좌표가 하나라도 없으면 쪼개지 않는다. +- [x] **R4-14 [M]** 데모 시더 심각도가 제품과 **7종** 어긋나 있었다. 특히 `fall` 이 위급으로 + 찍혀 1.2.0 의 요점(넘어짐 HIGH / 추락 CRITICAL)이 데모에서 지워져 있었다. 라벨과 달리 + 화면이 깨지지 않아 눈으로는 안 잡힌다. DONE + 엣지를 단일 출처로 강제하는 테스트. +- [x] **R4-15 [M]** 리포트의 사고 집계에서 추락이 빠져 니어미스:사고 비율이 가장 중대한 + 유형을 빼고 계산됐다. DONE. +- [x] **R4-16 [M]** `elevated` 가 저장 후 초기화되지 않아, 고치는 순간 이후의 모든 구역이 + 조용히 고소 구역이 될 상태였다(최상위 심각도 오탐 발생기). DONE — 저장·카메라 전환· + 지우기에서 초기화하고 목록에 '고소'로 되읽기. +- [x] **R4-17 [M]** `GET /api/zone-presets` 만 인증 게이트가 없었다. DONE. +- [x] **R4-18 [M]** 문서의 "17종"이 실제와 달랐다(활성 20종 = 엣지 16 + 서버 4). + 노조 대응 문장에도 쓰이는 숫자다. 소개서·덱·노조 문서·브리프 DONE, 신규 2종 행 추가. +- [x] **R4-19~23 [M/L]** 음수 입력 침묵 폐기, PROJECT_MAP 신규 파일 누락, 소개서 버전 + 표기, 마이그레이션 수, 프리셋 왕복 테스트 부재. 전부 DONE. + +### 반증되어 채택하지 않은 것 (REFUTED) + +- ghost 후보 튜플 확장이 어딘가에서 unpack 불일치를 낸다 → 생성·소비 모두 6-wide, 다른 소비자 없음. +- `rules` 가 None/비-dict 일 때 `in_elevated_zone` 이 예외 → `(z.rules or {})` 로 방어됨. +- 빈 입력란이 `max_workers: 0` 으로 저장된다 → `parseInt('')` 는 NaN, `NaN >= 0` 은 거짓. +- 구역 편집에서 기존 규칙이 사라진다 → 편집 경로 자체가 없다(삭제 후 재작성). +- 존 id 가 사이트 간 충돌한다 → 서버 생성 32자 난수라 전역 유일. + +### 이번 재감사에서 확인한 잔여 (수정하지 않음, 사유 명시) + +- **`INFER_EVERY ≥ 2` 에서 추락 신호 약화** — 6fps 에서는 낙하 중 IoU 가 임계 아래로 떨어져 + 트랙이 끊길 수 있다. 티어2는 원래 반응 granularity 를 포기하는 모드이고, 실장비 벤치마크 + 없이 임계를 조정하면 감지 **누락**을 만든다. 커미셔닝에서 실영상으로 정한다. +- **프레임 하단에서 시작하는 추락** — bbox 가 화면 끝에서 잘려 하강량이 임계에 못 미칠 수 + 있다. 고소 구역을 그리면 잡힌다. 화각 설계로 푸는 문제라 코드로 막지 않는다. +- **`DescentConfig` 튜닝 불가** — 다른 임계값과 달리 env·서버 튜닝 경로가 없다. 실장비에서 + 기본값이 맞는지 먼저 보고 나서 노출한다(지금 노출하면 검증 안 된 노브가 는다). +- **매뉴얼에 신규 유형 설명 없음** — 3차 재감사의 잔여와 같은 항목. 다음 개정에서 일괄. + +--- + ## OFF-HW 잔여 (실장비 커미셔닝에서만 검증 가능) 코드·문서로 닫을 수 없는 항목. 납품 판정을 막지 않되, 현장 설치 시 반드시 수행한다. diff --git a/docs/소개서-이미지-브리프.md b/docs/소개서-이미지-브리프.md index 11fd041..4164a46 100644 --- a/docs/소개서-이미지-브리프.md +++ b/docs/소개서-이미지-브리프.md @@ -304,7 +304,7 @@ ``` [1] 감지 [2] 대응 [3] 증거 ───────────── ───────────────── ───────────────── -17종 이벤트 → 현장 경광등 (즉시) → 사고조사 패키지 +20종 이벤트 → 현장 경광등 (즉시) → 사고조사 패키지 엣지 AI 판정 담당자 통보 (1분) 증거 3프레임 얼굴 자동 블러 상위 확대 (5분) 통보·조치 이력 위·변조 탐지 기록 diff --git a/docs/영상보관-노조대응.md b/docs/영상보관-노조대응.md index ea32bc7..7bf9eb5 100644 --- a/docs/영상보관-노조대응.md +++ b/docs/영상보관-노조대응.md @@ -101,7 +101,7 @@ - **얼굴 블러** — 엣지에서, 업로드 전에 - **개인 식별자 없음** — 사람을 임시 추적번호로만 다루고, **누구인지 모릅니다.** 사번·이름·얼굴 인식 어느 것도 없습니다 -- **근무태도 관련 이벤트 0종** — 감지 17종 전부가 *위험 상태* 이고, +- **근무태도 관련 이벤트 0종** — 감지 20종 전부가 *위험 상태* 이고, 자리 비움·작업 속도·휴게 시간 같은 항목은 **설계상 존재하지 않습니다** - **위·변조 탐지 감사기록** — 설정 변경·확인·오탐 처리가 해시 체인으로 남아 지우거나 고치면 검증에서 드러납니다 diff --git a/docs/프로젝트-소개서.md b/docs/프로젝트-소개서.md index 342f206..4aeac67 100644 --- a/docs/프로젝트-소개서.md +++ b/docs/프로젝트-소개서.md @@ -6,7 +6,7 @@ > 담당자 통보까지 이어지도록** 만든 온프레미스 안전관리 시스템입니다. 영상은 외부로 나가지 > 않습니다 — 사고 순간의 스냅샷 3장(얼굴 자동 블러)만 전달합니다. -버전 1.1.0 · 2026-09-04 +버전 1.2.0 · 2026-09-05 --- @@ -58,7 +58,7 @@ ``` 감지 대응 증거 ───────── ───────────────── ──────────────────── - 17종 이벤트 → 현장 경광등 (즉시) → 사고조사 패키지 + 20종 이벤트 → 현장 경광등 (즉시) → 사고조사 패키지 엣지 AI 판정 담당자 통보 증거 3프레임 얼굴 블러 미확인 시 상위 확대 통보 이력 · 조치 이력 위·변조 탐지 감사기록 @@ -69,7 +69,7 @@ ### 2-1. 감지 — 무엇을 알아채는가 -엣지가 **17종**을 판정하고, 여기에 서버가 발행하는 '미확인 경보 확대' 1종이 더해집니다. +엣지가 **16종**을 판정하고, 여기에 서버가 발행하는 4종(장치 이상 · 감시 성능 저하 · 미확인 경보 확대 · 동시 다발 쓰러짐)이 더해져 **모두 20종**입니다. 사람에 대한 것, 구역 규칙에 대한 것, 그리고 **감시 장비 자신**에 대한 것으로 나뉩니다. **사람의 위험 상태** @@ -77,6 +77,8 @@ | 이벤트 | 무엇을 잡는가 | |--------|---------------| | 넘어짐 | 기립→쓰러짐 전이를 자세로 판정. 쓰러진 자세가 **3초**(조정 가능) 지속되면 확정 — 앉는 동작과 구분 | +| **추락 (떨어짐)** | 발 밑선이 평소 키의 1.2배 이상 급강하하면 추락으로 판정 — 같은 높이의 넘어짐과 **다른 사건**으로 분류합니다. 산업재해 사망 1위 유형이고, 척추 손상 우려가 있어 통보에 이송 주의가 함께 나갑니다 | +| **동시 다발 쓰러짐** | 같은 구역에서 짧은 시간에 2명 이상이 쓰러지면 질식·가스·폭발로 봅니다. 통보에 **"공기호흡기 없이 진입하지 마십시오"** 가 실립니다 — 밀폐공간 사망자의 상당수가 구조하러 들어간 사람입니다 | | **넘어짐 후 미동 없음** | 쓰러진 뒤 움직임이 없으면 **위급으로 승격** — 의식 소실 의심 | | 장시간 부동 | 자세와 무관하게 오래 움직이지 않는 작업자 | | 위험 상태 소실 | 위험한 상태였던 사람이 화면에서 사라짐 (가림·추락 등) | @@ -261,7 +263,7 @@ ## 6. 납품과 운영 -- **웹호스팅 업로드 패키지** — 서버 일체(마이그레이션 8종 포함)를 zip 하나로 업로드, +- **웹호스팅 업로드 패키지** — 서버 일체(마이그레이션 9종 포함)를 zip 하나로 업로드, 설정 파일 한 개만 수정 - **Pi 설치 번들** — 온라인 설치와 **폐쇄망 오프라인 번들** 모두 제공. 런타임 휠·모델·소스를 전부 동봉해 설치 중 네트워크가 필요 없습니다 @@ -339,5 +341,5 @@ | **핵심 차별점** | ① 감지에서 끝나지 않고 **현장 경광등·에스컬레이션까지** ② **감시 자신의 고장**을 감지 ③ **사고조사 패키지 1클릭** ④ 위·변조 탐지 감사기록 | | **프라이버시** | 라이브뷰·영상 전송 없음 · 얼굴 저장 전 블러 · 알림에 이미지 미포함 | | **운영 비용** | 클라우드 GPU 없음, 일반 웹호스팅에서 동작 | -| **감지 유형** | 17종 활성 (+ 모델 확보 대기 3종) | +| **감지 유형** | 20종 활성 (+ 모델 확보 대기 3종) | | **검증** | 테스트 535건 · 납품 감사 55건 전량 해소 | diff --git a/edge/detectors/ghost.py b/edge/detectors/ghost.py index 6ed8a38..9c1c7f7 100644 --- a/edge/detectors/ghost.py +++ b/edge/detectors/ghost.py @@ -8,6 +8,9 @@ _DANGEROUS = {"FALLING", "FALLEN", "NO_MOTION"} @dataclass class GhostConfig: reappear_s: float = 10.0 + # 높이 무장의 유효 기간. 무장에 시간 제한이 없으면 발판에 잠깐 올라갔던 사람이 몇 분 뒤 + # 그냥 걸어 나가도 최상위 추락 경보가 된다. + height_arm_ttl_s: float = 20.0 reappear_dist: float = 1.0 # bbox 높이 배수 @@ -25,9 +28,21 @@ class GhostIncidentDetector: 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: + def forget(self, track_id: int) -> None: + """트랙 상태를 버린다. 엔진이 트랙 교체(ambiguous)에서도 부른다 — 무장이 남으면 + 교차한 뒤 살아남은 다른 사람이 그 무장을 물려받아 추락 경보를 낸다.""" + self._states.pop(track_id, None) + self._height.pop(track_id, None) + + def disarm_height(self, track_id: int) -> None: + """프레임 안에서 이미 추락으로 발보했으면 소실 경로가 같은 사고를 또 내지 않게 한다.""" + self._height.pop(track_id, None) + + def arm_height(self, track_id: int, descent: dict | None, elevated: bool, + now: float) -> None: """이 트랙을 추락 후보로 표시한다. 소실되면 fall_from_height 로 발보한다.""" info = self._height.setdefault(track_id, {}) + info["armed_at"] = now if descent: info.update(descent) info["armed_by"] = "descent" @@ -40,6 +55,11 @@ class GhostIncidentDetector: for tr in lost_tracks: state = self._states.get(tr.track_id, "STANDING") height = self._height.get(tr.track_id) + # 오래된 무장은 무시한다 (H2). 고소 구역 안에 있는 동안은 매 틱 갱신되므로 + # 구역 기반 무장은 이 검사에 걸리지 않는다. + if height is not None and now - float(height.get("armed_at", now)) > self.cfg.height_arm_ttl_s: + height = None + self._height.pop(tr.track_id, None) if state in _DANGEROUS or height: b = tr.latest.bbox # Snapshot the OTHER tracks active at loss time. Only a track that did NOT diff --git a/edge/detectors/height.py b/edge/detectors/height.py index 1e85c80..8a85190 100644 --- a/edge/detectors/height.py +++ b/edge/detectors/height.py @@ -39,8 +39,15 @@ class DescentConfig: drop_window_s: float = 1.2 # 구간 동안 사람이 이 배수 이상 커졌으면 카메라 쪽으로 걸어온 것이다. grow_guard: float = 1.35 - # 하강 후 이 시간 안에 넘어짐이 확정되면 같은 사건으로 묶어 유형을 승격한다. + # 하강과 착지를 같은 사건으로 묶는 허용 오차. + # + # 이 값을 "하강 후 now 까지"로 재면 안 된다. 넘어짐은 prone 이 prone_confirm_s(기본 3초) + # 지속돼야 확정되므로, 확정 시점에는 하강으로부터 항상 3초 넘게 지나 있다 — 기본값끼리 + # 부딪혀 승격이 영원히 일어나지 않았다. 착지(prone 시작) 시각과 비교해야 한다. link_s: float = 3.0 + # 무장 유효 기간. 하강 한 번이 트랙의 남은 평생을 무장 상태로 두면, 몇 분 뒤 그냥 + # 걸어 나간 사람이 최상위 추락 경보가 된다. + arm_ttl_s: float = 20.0 class DescentWatch: @@ -51,10 +58,30 @@ class DescentWatch: def forget(self, track_id: int) -> None: self._seen.pop(track_id, None) - def descending(self, track_id: int, now: float) -> bool: - """이 트랙이 최근 link_s 안에 떨어졌는가.""" + def armed_at(self, track_id: int) -> float | None: + """이 트랙의 하강이 확정된 시각. 없으면 None.""" hit = self._seen.get(track_id) - return hit is not None and (now - hit[0]) <= self.cfg.link_s + return hit[0] if hit is not None else None + + def landed_from_drop(self, track_id: int, prone_since: float | None) -> bool: + """이 트랙의 prone 시작이 하강 직후인가 — 즉 떨어져서 누운 것인가. + + now 가 아니라 **착지 시각**과 비교하는 것이 핵심이다. 확정은 prone_confirm_s 만큼 + 늦게 오므로 now 로 재면 기본값에서 영원히 거짓이 된다. + """ + hit = self._seen.get(track_id) + if hit is None or prone_since is None: + return False + return abs(prone_since - hit[0]) <= self.cfg.link_s + + def armed(self, track_id: int, now: float) -> bool: + """소실 시 추락으로 볼 만큼 최근에 떨어졌는가.""" + hit = self._seen.get(track_id) + return hit is not None and (now - hit[0]) <= self.cfg.arm_ttl_s + + def descending(self, track_id: int, now: float) -> bool: + """하위 호환용 별칭. 새 코드는 landed_from_drop / armed 를 쓴다.""" + return self.armed(track_id, now) def detail(self, track_id: int) -> dict | None: hit = self._seen.get(track_id) @@ -65,7 +92,7 @@ class DescentWatch: frames = list(track.frames) if len(frames) < 2: return None - if self.descending(track.track_id, now): + if self.armed(track.track_id, now): return None # 이미 무장돼 있다 — 같은 추락을 매 틱 다시 확정하지 않는다 last = frames[-1] diff --git a/edge/engine.py b/edge/engine.py index 5094a5d..1cdfa7b 100644 --- a/edge/engine.py +++ b/edge/engine.py @@ -38,6 +38,9 @@ class EventEngine: self.descent = descent if descent is not None else DescentWatch(DescentConfig()) self.cfg = cfg self._last_emit: dict[tuple, float] = {} + # 트랙별 마지막 구역. 소실 이벤트(ghost/추락)는 소실 후에 발보되므로 + # 그때는 트랙이 없다 — 마지막으로 있던 구역을 들고 있어야 실을 수 있다. + self._last_zone: dict[int, str | None] = {} def _allow(self, key: tuple, now: float) -> bool: last = self._last_emit.get(key) @@ -52,6 +55,8 @@ class EventEngine: self.immobility.forget(track_id) self.zone.forget(track_id) self.descent.forget(track_id) + self.ghost.forget(track_id) + self._last_zone.pop(track_id, None) 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)} @@ -79,7 +84,14 @@ class EventEngine: # frame_wh lets the zone detector normalize the foot to the dashboard's 0..1 zone # space (see ZoneIntrusionDetector._foot). run_once supplies the live frame size. zone_hits = self.zone.update(tr, now, frame_wh) - in_zone = bool(self.zone.active_zone_ids(tr.track_id)) + active_zones = self.zone.active_zone_ids(tr.track_id) + in_zone = bool(active_zones) + # 사람 상태 이벤트(넘어짐·미동·소실)에도 구역을 싣는다. 서버의 동시 다발 판정이 + # "같은 구역"으로 묶으려면 이 값이 있어야 하고, 없으면 카메라가 다른 두 희생자가 + # 절대 묶이지 않는다 — 밀폐공간이 정확히 그 상황이다. + # 여러 구역이 겹치면 정렬해 첫 번째를 쓴다(같은 입력에 같은 답이 나오도록). + here = sorted(active_zones)[0] if active_zones else None + self._last_zone[tr.track_id] = here # 발 좌표(0..1)를 트랙 이벤트 전부에 부착 — 서버 히트맵의 원료. 존 감지기가 이미 # 계산하는 좌표계를 그대로 재사용해 존 폴리곤과 같은 공간에 놓인다. foot = self.zone._foot(tr, frame_wh) @@ -90,7 +102,7 @@ class EventEngine: if descent is not None or elevated: # 위험 자세가 아니어도 ghost 를 무장시킨다. 발판에 서 있다가 화각 밖으로 # 떨어지는 사람은 소실 순간 STANDING 이라, 기존 무장 조건으로는 안 걸린다. - self.ghost.arm_height(tr.track_id, descent, elevated) + self.ghost.arm_height(tr.track_id, descent, elevated, now) sig, meta = self.fall.update(tr, now) state = self.fall._states.get(tr.track_id) @@ -102,7 +114,10 @@ class EventEngine: if sig == "fall": # 방금 떨어진 사람이 바닥에서 확정되면 그것은 넘어짐이 아니라 추락이다. # 두 건으로 올리지 않고 유형만 올린다 — 한 번의 사고는 한 건이다. - falling = self.descent.descending(tr.track_id, now) + # 착지(prone 시작) 시각과 하강을 비교한다. now 로 비교하면 확정이 + # prone_confirm_s 만큼 늦게 오므로 기본값에서 영원히 거짓이 된다. + prone_since = state.prone_since if state is not None else None + falling = self.descent.landed_from_drop(tr.track_id, prone_since) etype = EventType.FALL_FROM_HEIGHT if (falling or elevated) else EventType.FALL if self._allow((etype, tr.track_id), now): m = dict(meta) @@ -112,10 +127,13 @@ class EventEngine: if elevated: m["elevated_zone"] = True events.append(self._mk(etype, occurred_at, tr.track_id, - metrics=m, spatial=foot)) + metrics=m, spatial=foot, zone_id=here)) + if etype is EventType.FALL_FROM_HEIGHT: + # 프레임 안에서 이미 냈으므로 소실 경로가 같은 사고를 또 내지 않게 한다. + self.ghost.disarm_height(tr.track_id) 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)) + metrics=meta, spatial=foot, zone_id=here)) elif sig == "near_fall" and self._allow((EventType.NEAR_FALL, tr.track_id), now): # 니어미스(FALLING까지 갔다 확정 전 회복): 알람용이 아니라 히트맵/통계 재료. events.append(self._mk(EventType.NEAR_FALL, occurred_at, tr.track_id, @@ -185,6 +203,7 @@ class EventEngine: else: etype = EventType.GHOST events.append(self._mk(etype, occurred_at, track_id=g["track_id"], + zone_id=self._last_zone.get(g["track_id"]), spatial=gs, metrics=gmeta)) # ghost.on_lost already consumed lost tracks' state above, so forgetting here is safe. diff --git a/edge/zones.py b/edge/zones.py index dd7fa61..6e0cb52 100644 --- a/edge/zones.py +++ b/edge/zones.py @@ -17,7 +17,16 @@ def _looks_like_pixels(polygon: list[tuple[float, float]]) -> bool: return any(abs(x) > _NORMALIZED_MAX or abs(y) > _NORMALIZED_MAX for x, y in polygon) -_RULE_KEYS = ("min_workers", "max_workers", "max_dwell_s") +_RULE_INT_KEYS = ("min_workers", "max_workers", "max_dwell_s") + +# 키마다 하한이 다르다. max_workers 만 0 이 유효하다 — "아무도 있으면 안 되는 구역" +# (크레인으로 든 짐 아래, 굴착기 선회 반경). 전 키에 v <= 0 을 걸면 그 규칙을 저장한 구역이 +# 통째로 '규칙 없음'이 되고, 같이 넣은 2인1조까지 함께 사라진다. +_RULE_MIN = {"min_workers": 1, "max_workers": 0, "max_dwell_s": 1} + +# 정수가 아니라 참/거짓인 규칙. 이 목록에 없으면 파서가 조용히 버린다 — elevated 를 여기 +# 넣지 않아 고소작업 프리셋이 통째로 무동작이었다. +_RULE_FLAG_KEYS = ("elevated",) def _rules_of(row: dict) -> dict | None: @@ -31,13 +40,18 @@ def _rules_of(row: dict) -> dict | None: if not isinstance(data, dict): raise ValueError(f"rules_json is not an object: {data!r}") rules = {} - for key in _RULE_KEYS: + for key in _RULE_INT_KEYS: if data.get(key) is None: continue v = int(data[key]) - if v <= 0: - raise ValueError(f"{key} must be positive, got {v}") + lo = _RULE_MIN[key] + if v < lo: + raise ValueError(f"{key} must be >= {lo}, got {v}") rules[key] = v + for key in _RULE_FLAG_KEYS: + if data.get(key) is None: + continue + rules[key] = bool(data[key]) return rules or None diff --git a/server/deploy/README-UPLOAD.md b/server/deploy/README-UPLOAD.md index d7c83af..02cb763 100644 --- a/server/deploy/README-UPLOAD.md +++ b/server/deploy/README-UPLOAD.md @@ -94,6 +94,8 @@ DB 세 줄만 카페24 관리자 페이지의 MySQL 정보로 바꿉니다. | 5 | `2026-08-06-false-positive-tag.sql` | 오탐 태그 컬럼 | | 6 | `2026-08-07-improvement-actions.sql` | 개선 조치 표(`move_actions`) | | 7 | `2026-08-08-actions-site.sql` | 개선 조치의 현장(site) 스코프 | +| 8 | `2026-08-09-safety-platform.sql` | 통보 채널·억제·위험성평가·인수인계·성능 시계열 표, 감사로그 해시체인, 신규 감지 유형 | +| 9 | `2026-09-04-fall-from-height.sql` | 추락(`fall_from_height`)·동시 다발 쓰러짐(`multi_down`) 유형 허용 | 1. phpMyAdmin → 해당 DB → **SQL** 탭 2. 위 표의 순서대로 파일 내용을 **하나씩** 붙여넣고 실행합니다 diff --git a/server/public/assets/zone-draw.js b/server/public/assets/zone-draw.js index d1b5284..d7913f6 100644 --- a/server/public/assets/zone-draw.js +++ b/server/public/assets/zone-draw.js @@ -236,6 +236,26 @@ // 프리셋이 elevated 를 켜면 화면에 입력란이 없으므로 여기 들고 있는다(고소 구역 표시). var elevated = false; + function resetPreset() { + elevated = false; + var sel = document.getElementById('zone-preset'); + if (sel) sel.value = ''; + var help = document.getElementById('zone-preset-help'); + if (help) { help.hidden = true; help.textContent = ''; } + } + + /** 입력값이 음수면 규칙을 조용히 버리지 않고 이유를 알린다. */ + function invalidRuleInput() { + var pairs = [[ruleMinInput, '최소 인원'], [ruleMaxInput, '최대 인원'], [ruleDwellInput, '체류 상한']]; + for (var i = 0; i < pairs.length; i++) { + var el = pairs[i][0]; + if (!el || el.value === '') continue; + var n = parseInt(el.value, 10); + if (isNaN(n) || n < 0) return pairs[i][1] + ' 값이 올바르지 않습니다: ' + el.value; + } + return null; + } + function rulesFromInputs() { var rules = {}; var minW = parseInt(ruleMinInput && ruleMinInput.value, 10); @@ -255,6 +275,7 @@ try { r = JSON.parse(z.rules_json || 'null') || {}; } catch (e) { return ''; } var parts = []; if (r.min_workers) parts.push('최소 ' + r.min_workers + '인'); + if (r.elevated) parts.push('고소'); 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) + '분'); @@ -479,7 +500,7 @@ return { zone_id: z.zone_id, level: z.level, points: parsePolygon(z), rules_json: z.rules_json }; }); currentPoints = []; - renderZoneList(); + // 프리셋은 '지금 그리는 구역'에만 적용된다. 저장 후 그대로 두면 다음 폴리곤이 redraw(); setStatus(doneMsg || (existingZones.length ? '저장된 구역 ' + existingZones.length + '개를 불러왔습니다.' @@ -493,6 +514,10 @@ var device = deviceInput.value.trim(); if (!camera) { setStatus('카메라를 먼저 선택하세요.'); return; } if (currentPoints.length < 3) { setStatus('폴리곤 점을 3개 이상 클릭하세요.'); return; } + // 음수·비정상 입력을 조용히 버리면 운영자는 규칙이 저장된 줄 안다. 이 변경 전체가 + // 없애려던 침묵이 바로 그것이므로 여기서 이유를 말하고 멈춘다. + var bad = invalidRuleInput(); + if (bad) { setStatus(bad); return; } setStatus('저장 중...'); fetch(B + '/api/zones', { method: 'POST', @@ -517,6 +542,11 @@ .then(function (data) { if (!data) return; currentPoints = []; + // 프리셋은 '지금 그리는 구역'에만 적용된다. 저장 후 그대로 두면 다음 폴리곤이 + // 보이지 않는 상태를 물려받는다 — elevated 는 화면에 표시가 없어 운영자가 + // 켜져 있는지 알 수 없고, 평범한 바닥 구역이 전부 고소 구역이 되면 트랙이 + // 사라질 때마다 최상위 심각도 추락 경보가 뜬다. + resetPreset(); loadZones('구역을 저장했습니다. 다음 설정 동기화 때 장치로 내려갑니다.'); }) .catch(function () { setStatus('저장에 실패했습니다.'); }); @@ -550,7 +580,12 @@ if (frameBtn) frameBtn.addEventListener('click', function () { applySelection(); loadLatestFrame(); }); if (heatmapBtn) heatmapBtn.addEventListener('click', function () { applySelection(); toggleHeatmap(); }); if (heatmapDaysInput) heatmapDaysInput.addEventListener('change', function () { if (heatmapOn) loadHeatmap(); }); - clearBtn.addEventListener('click', function () { currentPoints = []; redraw(); setStatus('그리던 폴리곤을 지웠습니다.'); }); + clearBtn.addEventListener('click', function () { + currentPoints = []; + resetPreset(); + redraw(); + setStatus('그리던 폴리곤과 용도 선택을 지웠습니다.'); + }); saveBtn.addEventListener('click', function () { applySelection(); saveZone(); }); if (deviceSelect) { @@ -568,6 +603,7 @@ cameraInput.value = cameraSelect.value; if (!cameraSelect.value) return; currentPoints = []; + resetPreset(); loadZones(); loadLatestFrame(); }); diff --git a/server/public/index.php b/server/public/index.php index aa94e25..1f6a30e 100644 --- a/server/public/index.php +++ b/server/public/index.php @@ -132,7 +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/zone-presets', fn(Request $q) => $zoneCtl->presets($q)); $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/src/Alert/AlertDispatcher.php b/server/src/Alert/AlertDispatcher.php index 60df1bd..4635103 100644 --- a/server/src/Alert/AlertDispatcher.php +++ b/server/src/Alert/AlertDispatcher.php @@ -36,6 +36,12 @@ final class AlertDispatcher /** Ranked so a channel's min_severity acts as a floor, not an equality test. */ private const SEVERITY_RANK = ['info' => 0, 'warning' => 1, 'high' => 2, 'critical' => 3]; + /** + * 정비·작업허가 억제로도 끌 수 없는 유형. + * 억제는 예상된 알람을 끄는 장치이지, 사람이 쓰러진 사실을 감추는 장치가 아니다. + */ + private const NEVER_SUPPRESSED = ['multi_down', 'no_motion', 'fall_from_height']; + public function __construct( private EventRepository $events, private AlertRepository $alerts, @@ -61,6 +67,13 @@ final class AlertDispatcher $scopes = [['device', (string)($event['device_id'] ?? '')], ['camera', (string)($event['camera_id'] ?? '')]]; if (!empty($event['zone_id'])) $scopes[] = ['zone', (string)$event['zone_id']]; $suppressed = $this->suppressions->activeFor($scopes, $now) !== []; + // 억제는 '지금 여기서 작업 중이라 예상된 알람'을 끄기 위한 것이다. 그런데 밀폐공간 + // 작업허가가 발급된 바로 그 구역·그 시간이 질식이 일어나는 자리이므로, 허가가 + // 동시 다발 쓰러짐의 "공기호흡기 없이 진입 금지" 경고를 삼키면 그 기능이 존재하는 + // 유일한 상황에서 침묵한다. 사람이 실제로 쓰러진 위급은 억제 대상이 아니다. + if ($suppressed && in_array($event['event_type'] ?? '', self::NEVER_SUPPRESSED, true)) { + $suppressed = false; + } $out = []; foreach ($channels as $ch) { diff --git a/server/src/Alert/MultiDownWatch.php b/server/src/Alert/MultiDownWatch.php index 08ccdbf..d1a6708 100644 --- a/server/src/Alert/MultiDownWatch.php +++ b/server/src/Alert/MultiDownWatch.php @@ -29,8 +29,16 @@ use App\Repository\EventRepository; */ final class MultiDownWatch { - /** 사람이 쓰러졌다는 뜻의 유형. ergo/near_fall 처럼 통계용은 제외한다. */ - private const DOWN_TYPES = ['fall', 'fall_from_height', 'no_motion', 'ghost']; + /** + * 사람이 쓰러졌다는 뜻의 유형. + * + * ghost(위험 상태 소실)는 **일부러 뺐다.** 그것은 이미 쓰러진 사람의 후속 사건이라 + * 같은 희생자를 한 명 더로 세게 만든다. 트래커가 가려짐으로 트랙을 놓쳤다가 새 id 로 + * 다시 잡으면 fall→ghost→fall 이 한 사람에게서 나오는데, 그것이 "2명 쓰러짐"으로 + * 읽히면 다친 한 사람에게 "공기호흡기 없이 진입하지 마십시오"를 보내 **구조를 늦춘다.** + * 이 기능의 가치가 정확히 반대로 뒤집히는 경우라 소음보다 훨씬 나쁘다. + */ + private const DOWN_TYPES = ['fall', 'fall_from_height', 'no_motion']; public function __construct(private EventRepository $events) {} @@ -51,9 +59,27 @@ final class MultiDownWatch $minPeople = max(2, (int)($opts['min_people'] ?? 2)); $radius = max(0.0, (float)($opts['radius'] ?? 0.35)); $limit = max(1, (int)($opts['limit'] ?? 500)); + // 이 거리보다 가까운 두 사건은 같은 사람으로 본다 (트래커 재획득 방어). + $minSeparation = max(0.0, (float)($opts['min_separation'] ?? 0.08)); $rows = []; + $alreadyReported = []; // scope -> 이미 올린 최대 인원 foreach ($this->events->forNotification((float)($now - $window), $limit) as $ev) { + // 미래 시각 배제. 폐쇄망은 NTP 가 없는 경우가 흔해 장치 시계가 앞서 나간다 + // (스키마가 received_at 을 두는 이유가 그것이다). 앞선 시각의 사건 하나가 + // 몇 분 동안 창에 남아 무관한 넘어짐과 짝지어져 가스 경보를 만든다. + if ((float)($ev['occurred_at'] ?? 0) > (float)$now) continue; + + if (($ev['event_type'] ?? '') === 'multi_down') { + // 이미 올린 무리는 다시 올리지 않는다. 창이 미끄러지면 group[0] 이 바뀌어 + // uuid 가 달라지므로, uuid 만으로는 같은 사고가 매 분 새 건으로 쏟아진다. + $sc = (string)($ev['metrics']['scope'] ?? ''); + $pp = (int)($ev['metrics']['people'] ?? 0); + if ($sc !== '' && $pp > (int)($alreadyReported[$sc] ?? 0)) { + $alreadyReported[$sc] = $pp; + } + continue; + } if (!in_array($ev['event_type'] ?? '', self::DOWN_TYPES, true)) continue; $rows[] = $ev; } @@ -61,12 +87,11 @@ final class MultiDownWatch $filed = []; foreach ($this->groups($rows, $radius) as $scopeKey => $group) { - // 같은 사람이 여러 번 쓰러진 것은 한 명이다. - $people = []; - foreach ($group as $ev) { - $people[$this->personKey($ev)] = true; - } + $people = $this->distinctPeople($group, $minSeparation); if (count($people) < $minPeople) continue; + // 인원이 늘었을 때만 다시 올린다. 같거나 줄었으면 이미 알린 사고다 — + // 줄어든 수를 다시 보내면 "3명"이라 했다가 "2명"이라고 정정하는 꼴이 된다. + if (count($people) <= (int)($alreadyReported[(string)$scopeKey] ?? 0)) continue; $first = $group[0]; $uuid = self::uuidFor((string)$scopeKey, (int)$first['event_id'], count($people)); @@ -118,6 +143,44 @@ final class MultiDownWatch . ($ev['track_id'] ?? ('e' . ($ev['event_id'] ?? ''))); } + /** + * 서로 다른 사람 몇 명인가. + * + * track_id 만으로 세면 안 된다. 트래커는 1초만 가려져도 트랙을 버리고 새 id 를 준다 + * (edge/tracking.py max_age=1.0). 바닥에 누운 사람은 새 트랙에서도 즉시 prone 이라 + * 같은 사람이 두 번째 fall 을 만든다. 그래서 **거의 같은 자리**의 사건은 같은 사람으로 + * 본다 — 두 사람이 물리적으로 같은 점에 쓰러질 수는 없다. + * + * @return array 사람마다 대표 이벤트 하나 + */ + private function distinctPeople(array $group, float $minSeparation): array + { + $people = []; // personKey -> 대표 이벤트 + foreach ($group as $ev) { + $key = $this->personKey($ev); + if (isset($people[$key])) continue; + $merged = false; + foreach ($people as $seen) { + if ($this->sameSpot($ev, $seen, $minSeparation)) { $merged = true; break; } + } + if (!$merged) $people[$key] = $ev; + } + return $people; + } + + /** 두 사건이 같은 사람으로 볼 만큼 붙어 있는가. 좌표가 없으면 판단하지 않는다. */ + private function sameSpot(array $a, array $b, float $minSeparation): bool + { + if ($minSeparation <= 0.0) return false; + if ($a['spatial_x'] === null || $a['spatial_y'] === null + || $b['spatial_x'] === null || $b['spatial_y'] === null) { + return false; + } + $dx = (float)$a['spatial_x'] - (float)$b['spatial_x']; + $dy = (float)$a['spatial_y'] - (float)$b['spatial_y']; + return sqrt($dx * $dx + $dy * $dy) < $minSeparation; + } + /** * 공간적으로 같은 자리인 것끼리 묶는다. 구역이 있으면 구역이 우선 — 운영자가 "여기가 * 한 공간"이라고 선언한 것이 좌표 거리보다 정확하다. @@ -126,36 +189,56 @@ final class MultiDownWatch */ private function groups(array $rows, float $radius): array { - $out = []; + // 1단계: 장소로 나눈다. 구역이 있으면 구역이 우선 — 운영자가 "여기가 한 공간"이라고 + // 선언한 것이 좌표 거리보다 정확하고, 카메라를 넘어서도 묶인다. + // 키는 구분자를 넣어 정확 비교한다. 문자열 접두사로 비교하면 cam-1 과 cam-10 이, + // 충전실과 충전실2 가 한 사건으로 합쳐진다. + $byPlace = []; foreach ($rows as $ev) { $zone = $ev['zone_id'] ?? null; - if ($zone !== null && $zone !== '') { - $out['zone:' . $zone][] = $ev; + $key = ($zone !== null && $zone !== '') + ? 'zone:' . $zone + : 'camera:' . ($ev['device_id'] ?? '') . "" . ($ev['camera_id'] ?? ''); + $byPlace[$key][] = $ev; + } + + // 2단계: 카메라 묶음만 좌표로 다시 쪼갠다. 같은 카메라라도 화면 반대편은 다른 사건이다. + // 좌표가 없는 사건이 하나라도 있으면 쪼개지 않는다 — 쪼개면 좌표 있는 희생자와 + // 좌표 없는 희생자가 영영 만나지 못해 진짜 동시 다발을 놓친다. + $out = []; + foreach ($byPlace as $key => $bucket) { + if (str_starts_with((string)$key, 'zone:')) { + $out[$key] = $bucket; 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; + $anyMissing = false; + foreach ($bucket as $ev) { + if ($ev['spatial_x'] === null || $ev['spatial_y'] === null) { $anyMissing = true; break; } + } + if ($anyMissing || $radius <= 0.0) { + $out[$key] = $bucket; + continue; + } + $clusters = []; // 대표점 => 사건들 + foreach ($bucket as $ev) { + $placed = false; + foreach ($clusters as $ci => $c) { + $head = $c[0]; $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; + $clusters[$ci][] = $ev; $placed = true; break; } } - if (!$placed) { - $out[$key . '@' . round((float)$ev['spatial_x'], 2) - . ',' . round((float)$ev['spatial_y'], 2)][] = $ev; - } - continue; + if (!$placed) $clusters[] = [$ev]; + } + foreach ($clusters as $ci => $c) { + // 군집이 하나면 좌표를 키에 넣지 않는다 — 넣으면 창이 미끄러져 대표점이 + // 바뀔 때마다 scope 가 달라져 같은 사고가 새 건으로 올라간다. + $out[count($clusters) === 1 ? $key : $key . '#' . $ci] = $c; } - $out[$key][] = $ev; } return $out; } diff --git a/server/src/Dashboard/ReportController.php b/server/src/Dashboard/ReportController.php index 1dbcffd..063b381 100644 --- a/server/src/Dashboard/ReportController.php +++ b/server/src/Dashboard/ReportController.php @@ -55,7 +55,10 @@ final class ReportController if (isset($severityTotals[$row['severity']])) $severityTotals[$row['severity']] += $row['count']; $typeCounts[$row['event_type']] = ($typeCounts[$row['event_type']] ?? 0) + $row['count']; if ($row['event_type'] === 'near_fall') $nearFall += $row['count']; - if (in_array($row['event_type'], ['fall', 'no_motion'], true)) $falls += $row['count']; + // 추락을 빼면 니어미스:사고 비율이 가장 중대한 유형을 빼고 계산된다. + if (in_array($row['event_type'], ['fall', 'fall_from_height', 'no_motion'], true)) { + $falls += $row['count']; + } if (in_array($row['event_type'], self::OCCUPANCY_TYPES, true)) { $occupancy[$row['event_type']] += $row['count']; } diff --git a/server/src/Dashboard/ZoneConfigController.php b/server/src/Dashboard/ZoneConfigController.php index 0fa0fc8..72b8ddb 100644 --- a/server/src/Dashboard/ZoneConfigController.php +++ b/server/src/Dashboard/ZoneConfigController.php @@ -15,10 +15,16 @@ final class ZoneConfigController // by an occupied zone — meaningless); dwell floor 10s rejects a seconds/minutes mixup. private const RULE_BOUNDS = [ 'min_workers' => [2, 50], - 'max_workers' => [1, 50], + // 하한이 0 인 것은 오타가 아니다. 0 = "아무도 있으면 안 되는 구역"(크레인으로 든 짐 + // 아래, 굴착기 선회 반경 — 깔림 사고의 전형)이고, 그 규칙을 표현할 다른 방법이 없다. + // 이 경계가 1 이던 동안 감지기·화면·직렬화를 아무리 고쳐도 저장 자체가 400 이었다. + 'max_workers' => [0, 50], 'max_dwell_s' => [10, 86400], ]; + /** 정수 범위가 아니라 참/거짓인 규칙. 값이 참일 때만 저장한다. */ + private const RULE_FLAGS = ['elevated']; + public function __construct( private ZoneRepository $zones, private DeviceRepository $devices, private AuditRepository $audit, private SessionStore $sess, @@ -42,6 +48,20 @@ final class ZoneConfigController return SiteScope::allows(is_string($scope) ? $scope : null, $this->devices->siteFor($deviceId)); } + /** + * GET /api/zone-presets — 용도별 구역 프리셋 목록. + * + * 인라인 클로저로 두었더니 이 앱에서 유일하게 인증 게이트가 없는 대시보드 API 가 됐다. + * 내용은 정적 한국어 안내문이라 유출 자체의 피해는 없지만, 공유호스팅에 인증 없는 JSON + * 종단점을 하나 열어 두는 것은 다음 사람이 같은 모양으로 진짜 데이터를 내보내게 만든다. + * 다른 라우트와 같은 자리, 같은 게이트로 옮긴다. + */ + public function presets(Request $req): Response + { + if ($g = $this->guard('view_events')) return $g; + return Response::json(['presets' => \App\Api\ZonePresetCatalog::all()], 200); + } + public function list(Request $req, array $params): Response { // A role gate (not just "authenticated"): without it any logged-in account could enumerate @@ -82,6 +102,12 @@ final class ZoneConfigController if (!is_array($rules)) return [null, false]; $out = []; foreach ($rules as $key => $value) { + if (in_array($key, self::RULE_FLAGS, true)) { + // elevated: 고소 구역 표시. 추락 판정의 보강 신호이므로 참일 때만 남긴다. + if (!is_bool($value)) return [null, false]; + if ($value) $out[$key] = true; + continue; + } if (!isset(self::RULE_BOUNDS[$key])) return [null, false]; if ($value === null || $value === '') continue; if (!is_int($value) && !(is_string($value) && ctype_digit($value))) return [null, false]; @@ -90,7 +116,9 @@ final class ZoneConfigController if ($n < $lo || $n > $hi) return [null, false]; $out[$key] = $n; } - if (isset($out['min_workers'], $out['max_workers']) && $out['min_workers'] > $out['max_workers']) { + if (isset($out['min_workers'], $out['max_workers']) + && is_int($out['min_workers']) && is_int($out['max_workers']) + && $out['min_workers'] > $out['max_workers']) { return [null, false]; } return [$out === [] ? null : json_encode($out), true]; diff --git a/server/tests/MultiDownWatchTest.php b/server/tests/MultiDownWatchTest.php index 7383fbe..d09e959 100644 --- a/server/tests/MultiDownWatchTest.php +++ b/server/tests/MultiDownWatchTest.php @@ -31,8 +31,8 @@ final class MultiDownWatchTest extends TestCase 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, + $this->ev($repo, ['track_id' => 1, 'spatial_x' => 0.40, 'spatial_y' => 0.50]); + $this->ev($repo, ['track_id' => 2, 'spatial_x' => 0.58, 'spatial_y' => 0.55, 'occurred_at' => (float)(self::NOW - 30)]); $filed = (new MultiDownWatch($repo))->run(self::NOW); @@ -91,8 +91,8 @@ final class MultiDownWatchTest extends TestCase 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)]); + $this->ev($repo, ['track_id' => 1, 'spatial_x' => 0.40]); + $this->ev($repo, ['track_id' => 2, 'spatial_x' => 0.58, 'occurred_at' => (float)(self::NOW - 30)]); $w = new MultiDownWatch($repo); self::assertCount(1, $w->run(self::NOW)); @@ -104,17 +104,99 @@ final class MultiDownWatchTest extends TestCase { // 사람이 늘어난 것은 상황이 악화된 것이다 — 한 번 더 올라와야 한다. $repo = new InMemoryEventRepository(); - $this->ev($repo, ['track_id' => 1]); - $this->ev($repo, ['track_id' => 2, 'occurred_at' => (float)(self::NOW - 30)]); + $this->ev($repo, ['track_id' => 1, 'spatial_x' => 0.30]); + $this->ev($repo, ['track_id' => 2, 'spatial_x' => 0.50, '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)]); + $this->ev($repo, ['track_id' => 3, 'spatial_x' => 0.62, 'occurred_at' => (float)(self::NOW - 10)]); $again = $w->run(self::NOW + 5); self::assertCount(1, $again); self::assertSame(3, $again[0]['people']); } + // --- 한 사람을 여러 명으로 세지 않는다 --------------------------------------------- + // + // 이 묶음이 이 클래스에서 가장 중요하다. 잘못 세면 다친 한 사람에게 + // "공기호흡기 없이 진입하지 마십시오"를 보내 **구조를 늦춘다** — 기능의 가치가 + // 정반대로 뒤집히는 경우라 단순한 오탐보다 훨씬 나쁘다. + + public function testTrackerReAcquisitionOfOneVictimIsNotTwoPeople(): void + { + // 트래커는 1초만 가려져도 트랙을 버리고 새 id 를 준다(edge/tracking.py max_age=1.0). + // 바닥에 누운 사람은 새 트랙에서도 즉시 prone 이라 같은 사람이 두 번째 fall 을 만든다. + $repo = new InMemoryEventRepository(); + $this->ev($repo, ['track_id' => 7, 'spatial_x' => 0.40, 'spatial_y' => 0.60]); + $this->ev($repo, ['track_id' => 12, 'spatial_x' => 0.41, 'spatial_y' => 0.60, + 'occurred_at' => (float)(self::NOW - 20)]); + + self::assertSame([], (new MultiDownWatch($repo))->run(self::NOW), + '같은 자리의 재획득을 두 명으로 세면 구조가 늦어진다'); + } + + public function testGhostIsNotCountedAsAPerson(): void + { + // ghost 는 이미 쓰러진 사람의 후속 사건이다. 세면 한 희생자가 두 명이 된다. + $repo = new InMemoryEventRepository(); + $this->ev($repo, ['track_id' => 7, 'spatial_x' => 0.40]); + $this->ev($repo, ['track_id' => 7, 'event_type' => 'ghost', 'spatial_x' => 0.75, + 'occurred_at' => (float)(self::NOW - 20)]); + + self::assertSame([], (new MultiDownWatch($repo))->run(self::NOW)); + } + + public function testFutureTimestampedEventCannotManufactureAGroup(): void + { + // 폐쇄망은 NTP 가 없는 경우가 흔해 장치 시계가 앞서 나간다. + $repo = new InMemoryEventRepository(); + $this->ev($repo, ['track_id' => 1, 'spatial_x' => 0.40, + 'occurred_at' => (float)(self::NOW + 600)]); // 시계 10분 빠름 + $this->ev($repo, ['track_id' => 2, 'spatial_x' => 0.60, + 'occurred_at' => (float)(self::NOW - 10)]); + + self::assertSame([], (new MultiDownWatch($repo))->run(self::NOW)); + } + + public function testPrefixCameraIdsAreNotMerged(): void + { + // 문자열 접두사로 묶으면 cam-1 과 cam-10 이 한 사건이 된다. + $repo = new InMemoryEventRepository(); + $this->ev($repo, ['track_id' => 1, 'camera_id' => 'cam-10', 'spatial_x' => 0.50]); + $this->ev($repo, ['track_id' => 2, 'camera_id' => 'cam-1', 'spatial_x' => 0.52, + 'occurred_at' => (float)(self::NOW - 30)]); + + self::assertSame([], (new MultiDownWatch($repo))->run(self::NOW)); + } + + public function testVictimWithoutCoordinatesStillGroups(): void + { + // 좌표가 없는 사건(ghost 경로 등)이 섞였다고 진짜 동시 다발을 놓치면 안 된다. + $repo = new InMemoryEventRepository(); + $this->ev($repo, ['track_id' => 1, 'spatial_x' => null, 'spatial_y' => null]); + $this->ev($repo, ['track_id' => 2, 'spatial_x' => 0.50, 'spatial_y' => 0.50, + 'occurred_at' => (float)(self::NOW - 20)]); + + self::assertCount(1, (new MultiDownWatch($repo))->run(self::NOW)); + } + + public function testSlidingWindowDoesNotRefileTheSameIncident(): void + { + // 창이 미끄러지면 group[0] 이 바뀌어 uuid 가 달라진다. 그것만으로는 같은 사고가 + // 매 분 새 위급 건으로 쏟아지고, 인원이 줄면 앞선 알림을 스스로 뒤집는다. + $repo = new InMemoryEventRepository(); + $this->ev($repo, ['track_id' => 1, 'spatial_x' => 0.30, 'occurred_at' => (float)(self::NOW - 100)]); + $this->ev($repo, ['track_id' => 2, 'spatial_x' => 0.50, 'occurred_at' => (float)(self::NOW - 60)]); + $this->ev($repo, ['track_id' => 3, 'spatial_x' => 0.62, 'occurred_at' => (float)(self::NOW - 20)]); + $w = new MultiDownWatch($repo); + + $total = 0; + for ($t = 0; $t <= 600; $t += 60) { // 크론 10분치 + $total += count($w->run(self::NOW + $t)); + } + self::assertLessThanOrEqual(2, $total, "한 사고가 $total 건으로 올라갔다"); + self::assertGreaterThanOrEqual(1, $total); + } + public function testNonDownTypesAreIgnored(): void { // 니어미스·자세부담은 사람이 쓰러진 것이 아니다. @@ -129,8 +211,8 @@ final class MultiDownWatchTest extends TestCase 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', + $this->ev($repo, ['track_id' => 1, 'event_type' => 'fall_from_height', 'spatial_x' => 0.35]); + $this->ev($repo, ['track_id' => 2, 'event_type' => 'no_motion', 'spatial_x' => 0.60, 'occurred_at' => (float)(self::NOW - 30)]); self::assertCount(1, (new MultiDownWatch($repo))->run(self::NOW)); diff --git a/server/tools/seed_demo.php b/server/tools/seed_demo.php index 76e9cb5..9f8f68f 100644 --- a/server/tools/seed_demo.php +++ b/server/tools/seed_demo.php @@ -115,10 +115,18 @@ $profile = [ 'cam-paint-01' => ['weight' => 1, 'types' => ['monitoring_degraded', 'device_fault', 'zone_warning']], ]; $severityOf = [ - 'fall' => 'critical', 'no_motion' => 'critical', 'pinch' => 'critical', - 'immobility' => 'high', 'ghost' => 'high', 'zone_restricted' => 'high', 'lone_worker' => 'high', - 'near_fall' => 'warning', 'zone_warning' => 'warning', 'overcapacity' => 'warning', 'overstay' => 'warning', - 'ergo_report' => 'info', 'monitoring_degraded' => 'info', 'device_fault' => 'info', + // 심각도는 edge/events.py 의 _SEVERITY 가 단일 출처다. 여기서 다른 값을 쓰면 + // 데모·스크린샷·UI 검토가 실제 제품과 다른 등급을 보여준다 — 특히 1.2.0 의 + // 요점인 넘어짐(HIGH) 대 추락(CRITICAL) 구분이 데모에서 지워진다. + // tests/test_seed_demo_severity.py 가 두 표의 일치를 강제한다. + 'camera_moved' => 'high', 'camera_obstructed' => 'high', 'device_fault' => 'warning', + 'ergo_report' => 'info', 'fall' => 'high', 'fall_from_height' => 'critical', + 'fire_smoke' => 'critical', 'ghost' => 'critical', 'immobility' => 'warning', + 'lone_worker' => 'high', 'low_light' => 'warning', 'monitoring_degraded' => 'warning', + 'multi_down' => 'critical', 'near_fall' => 'info', 'no_motion' => 'critical', + 'overcapacity' => 'warning', 'overstay' => 'warning', 'pinch' => 'high', + 'ppe_missing' => 'warning', 'proximity' => 'high', 'unacked_escalation' => 'critical', + 'zone_restricted' => 'high', 'zone_warning' => 'warning', ]; $pdo->beginTransaction(); diff --git a/tests/test_dist_parity.py b/tests/test_dist_parity.py index d2ca8d2..18345c9 100644 --- a/tests/test_dist_parity.py +++ b/tests/test_dist_parity.py @@ -76,3 +76,48 @@ def test_pi_bundle_records_its_provenance(): for field in ("version:", "git_commit:", "built_at:"): assert field in text, f"MANIFEST.txt에 {field} 없음" assert "version: unknown" not in text + + +@needs_dist +@pytest.mark.skipif(not CAFE24_PRIV.is_dir(), reason="cafe24 package not built") +def test_build_info_stamps_the_commit_it_actually_ships(): + """BUILD-INFO 의 git_commit 이 현재 HEAD 와 같은가. + + 1.1.0 에서 이 결함(R3-6)을 고쳤는데 1.2.0 에서 그대로 재발했다 — 릴리스 커밋 **전에** + 패키지를 만들면 스탬프가 코드를 담지 않은 커밋을 가리킨다. 기존 테스트는 필드의 존재만 + 확인했으므로 두 번 다 통과했다. + + 스탬프가 틀리면 나중에 "고객이 받은 것이 어느 코드인지"를 되짚을 방법이 사라진다. + 절차로 막으려다 두 번 실패했으므로 테스트로 막는다. + """ + import subprocess + + info = (CAFE24_PRIV / "BUILD-INFO.txt").read_text(encoding="utf-8") + stamped = next((l.split(":", 1)[1].strip() for l in info.splitlines() + if l.startswith("git_commit:")), None) + assert stamped, f"BUILD-INFO 에 git_commit 이 없습니다:\n{info}" + if stamped in ("unknown", ""): + pytest.skip("git 정보 없이 빌드된 패키지") + + head = subprocess.run(["git", "rev-parse", "--short", "HEAD"], cwd=ROOT, + capture_output=True, text=True).stdout.strip() + assert stamped == head, ( + f"패키지가 담은 코드와 스탬프가 다릅니다: BUILD-INFO={stamped}, HEAD={head}. " + "릴리스 커밋 이후에 생성기를 다시 돌리십시오.") + + +@needs_dist +@pytest.mark.skipif(not CAFE24_PRIV.is_dir(), reason="cafe24 package not built") +def test_upload_readme_lists_every_migration(): + """업그레이드 안내의 마이그레이션 표가 실제 파일을 전부 담는가. + + 빠지면 업그레이드한 고객이 그 마이그레이션을 영영 실행하지 않는다. 1.2.0 직전 상태에서 + 두 개가 빠져 있었고, 그중 하나가 없으면 신규 이벤트 유형의 INSERT 가 DB CHECK 에 걸려 + 전부 거부된다 — 감지기는 도는데 대시보드에 아무것도 안 뜨는, 이 프로젝트가 반복해서 + 잡아온 조용한 실패다. + """ + readme = (ROOT / "server" / "deploy" / "README-UPLOAD.md").read_text(encoding="utf-8") + for sql in sorted((ROOT / "server" / "migrations").glob("*.sql")): + assert sql.name in readme, ( + f"업그레이드 안내에 {sql.name} 이 없습니다 — 업그레이드한 현장은 이 마이그레이션을 " + "영영 실행하지 않습니다") diff --git a/tests/test_engine.py b/tests/test_engine.py index 047478e..f2030bb 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -411,3 +411,72 @@ def test_elevated_zone_arms_without_a_visible_drop(): 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" + + +def test_height_fall_upgrade_works_at_PRODUCTION_defaults(): + """기본 설정 그대로 — 이 테스트가 없어서 승격이 죽어 있는 채로 커밋됐다. + + 첫 구현은 하강을 `now` 와 비교했다. 넘어짐은 prone_confirm_s(기본 3초) 지속 후에 확정되므로 + 확정 시점에는 하강으로부터 항상 3초 넘게 지나 있었고, link_s 도 3.0 이라 승격이 **한 번도** + 일어나지 않았다. 다른 테스트들이 prone_confirm_s=0.5 를 써서 전부 초록이었다. + + 그래서 이 테스트는 감지기 설정을 건드리지 않는다. 기본값끼리 부딪히는 것이 요점이다. + """ + eng = EventEngine( + FallDetector(FallConfig()), # ← 기본값. 절대 낮추지 말 것. + ImmobilityWatchdog(ImmobilityConfig(immobile_s=999)), + GhostIncidentDetector(GhostConfig()), + ZoneIntrusionDetector([], ZoneConfig()), + EngineConfig(camera_id="cam-01", cooldown_s=30.0), + descent=DescentWatch(DescentConfig()), # ← 기본값 + ) + tr = Track(77, deque(maxlen=90)) + # 발판(y2=200)에서 바닥(y2=410)으로 1.17초 낙하 — 키 150px 대비 1.4배 + for t, y2 in [(0.0, 200), (0.25, 250), (0.5, 310), (0.83, 370), (1.17, 410)]: + _falling_track(tr, t, y2=float(y2)) + eng.process([tr], [], now=t) + # 착지 후 12fps 로 계속 누워 있음 + t = 1.17 + out = [] + while t < 8.0 and not out: + t = round(t + 1 / 12, 3) + _lying_track(tr, t) + out = eng.process([tr], [], now=t) + + types = [e.event_type for e in out] + assert EventType.FALL_FROM_HEIGHT in types, f"기본 설정에서 승격 실패: {types}" + assert EventType.FALL not in types + + +def test_one_height_fall_does_not_also_fire_on_track_loss(): + # 프레임 안에서 추락으로 냈는데 그 사람의 검출이 끊기면, 소실 경로가 같은 사고를 또 낸다. + eng = _height_engine() + tr = Track(88, 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) + _lying_track(tr, 1.0); eng.process([tr], [], now=1.0) + _lying_track(tr, 1.6) + first = eng.process([tr], [], now=1.6) + assert EventType.FALL_FROM_HEIGHT in [e.event_type for e in first] + + eng.process([], [tr], now=2.0) # 검출 끊김 + later = [e.event_type for e in eng.process([], [], now=5.0)] + # 추락은 한 번만. ghost(위험 상태 소실)는 별개 사건이라 그대로 둔다 — 쓰러진 사람이 + # 화면에서 사라진 것은 옮겨졌을 수 있다는 뜻이고, 1.0.0 부터의 동작이다. + assert EventType.FALL_FROM_HEIGHT not in later, f"같은 사고가 두 번 올라갔다: {later}" + assert later == [EventType.GHOST], later + + +def test_forget_clears_height_arming_so_it_does_not_ride_to_another_person(): + # 두 사람이 교차해 트랙이 뒤바뀌면 엔진이 forget 을 부른다. 무장이 남으면 살아남은 + # 다른 사람이 나중에 화면 밖으로 걸어 나갈 때 추락 경보가 된다. + eng = _height_engine() + tr = Track(99, deque(maxlen=90)) + _falling_track(tr, 0.0, y2=200.0) + _falling_track(tr, 0.9, y2=430.0) + eng.process([tr], [], now=0.9) + eng.forget(99) + eng.process([], [tr], now=1.0) + assert eng.process([], [], now=5.0) == [] diff --git a/tests/test_height_fall.py b/tests/test_height_fall.py index a129e93..5f6d569 100644 --- a/tests/test_height_fall.py +++ b/tests/test_height_fall.py @@ -51,12 +51,29 @@ def test_rapid_drop_of_the_foot_line_is_a_descent(): assert w.descending(1, now=0.9) is True -def test_descent_stays_armed_for_the_link_window(): +def test_landing_is_linked_to_the_drop_not_to_now(): + """확정 시각이 아니라 **착지 시각**과 비교해야 한다. + + 넘어짐은 prone 이 prone_confirm_s(기본 3초) 지속돼야 확정되므로, 확정 시점에는 하강으로부터 + 항상 3초 넘게 지나 있다. now 로 재던 첫 구현은 기본값끼리 부딪혀 승격이 영원히 일어나지 + 않았다 — 감지기도 테스트도 통과하는데 현장에서만 안 되는 종류였다. + """ 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 # 창을 벗어남 + # 착지는 하강 직후 — now 가 한참 뒤여도 승격되어야 한다 + assert w.landed_from_drop(1, prone_since=1.0) is True + assert w.landed_from_drop(1, prone_since=30.0) is False # 한참 뒤에 누운 것은 별건 + assert w.landed_from_drop(1, prone_since=None) is False + + +def test_arming_expires_so_a_walker_is_not_a_height_fall(): + # 하강 한 번이 트랙의 평생을 무장 상태로 두면, 몇 분 뒤 걸어 나간 사람이 최상위 경보가 된다. + w = _watch(arm_ttl_s=20.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.armed(1, now=10.0) is True + assert w.armed(1, now=25.0) is False # --- 잡으면 안 되는 것 ---------------------------------------------------------------- diff --git a/tests/test_seed_demo_severity.py b/tests/test_seed_demo_severity.py new file mode 100644 index 0000000..fcb4bf6 --- /dev/null +++ b/tests/test_seed_demo_severity.py @@ -0,0 +1,56 @@ +"""데모 시더의 심각도가 실제 제품과 같은가. + +시더는 데모·스크린샷·UI 검토에 쓰는 데이터를 만든다. 여기 심각도가 엣지와 다르면 검토자는 +제품이 실제로 내지 않는 등급을 보고 판단하게 된다. 실제로 7종이 어긋나 있었고, 그중 `fall` +이 위급으로 찍혀 1.2.0 의 요점(넘어짐 HIGH / 추락 CRITICAL)이 데모에서 지워져 있었다. + +라벨과 달리 심각도는 어긋나도 화면이 깨지지 않는다 — 그래서 사람 눈으로는 잡히지 않고 +이 테스트가 필요하다. +""" + +import re +from pathlib import Path + +from edge.events import EventType, Severity, _SEVERITY + +ROOT = Path(__file__).resolve().parents[1] +SEEDER = ROOT / "server" / "tools" / "seed_demo.php" + +# 서버만 발행하는 유형 — 엣지 enum 에 없다. +SERVER_ONLY = {"unacked_escalation": "critical", "multi_down": "critical"} + + +def _seeder_map() -> dict[str, str]: + src = SEEDER.read_text(encoding="utf-8") + m = re.search(r"\$severityOf\s*=\s*\[(.*?)\];", src, re.S) + assert m, "seed_demo.php 의 $severityOf 배열을 찾지 못했습니다 (리팩터되었나요?)" + return dict(re.findall(r"'([a-z_]+)'\s*=>\s*'([a-z]+)'", m.group(1))) + + +def _expected() -> dict[str, str]: + out = {e.value: _SEVERITY.get(e, Severity.INFO).value for e in EventType} + out.update(SERVER_ONLY) + return out + + +def test_seeder_severity_matches_the_edge(): + seeder, expected = _seeder_map(), _expected() + wrong = {k: (v, expected[k]) for k, v in seeder.items() + if k in expected and v != expected[k]} + assert not wrong, ( + "데모 시더가 제품과 다른 심각도를 씁니다 (유형: 시더 -> 제품): " + + ", ".join(f"{k}: {a} -> {b}" for k, (a, b) in sorted(wrong.items()))) + + +def test_seeder_covers_every_type(): + seeder, expected = _seeder_map(), _expected() + missing = set(expected) - set(seeder) + assert not missing, ( + f"시더에 심각도가 없는 유형: {sorted(missing)} — " + "빠지면 기본값으로 조용히 잘못된 등급이 찍힙니다") + + +def test_seeder_has_no_unknown_types(): + seeder, expected = _seeder_map(), _expected() + extra = set(seeder) - set(expected) + assert not extra, f"시더에만 있는 유형(오타이거나 삭제된 유형): {sorted(extra)}" diff --git a/tests/test_zone_preset_roundtrip.py b/tests/test_zone_preset_roundtrip.py new file mode 100644 index 0000000..5ba71be --- /dev/null +++ b/tests/test_zone_preset_roundtrip.py @@ -0,0 +1,84 @@ +"""프리셋이 저장 경로를 온전히 통과하는가 — 서버 검증기 → DB → 엣지 파서. + +WHY THIS TEST EXISTS + 1.2.0 에서 프리셋 6종을 넣으면서 두 개가 **완전히 무동작**이었다: + + · 출입 금지(max_workers: 0) — ZoneConfigController 의 규칙 경계가 [1,50] 이라 저장이 400 + · 고소작업(elevated: true) — 서버 경계에 키가 없어 400, 설령 통과해도 엣지 파서가 침묵 삭제 + + 감지기·입력란·직렬화·표시를 다 고쳤는데도 그랬다. 고친 네 곳이 전부 **허용 목록보다 + 하류**였기 때문이다 — 값이 존재해도 되는지 결정하는 층은 손대지 않았다. + + 단위 테스트는 Zone 을 직접 만들어 검사했으므로 이 구멍을 볼 수 없었다. 그래서 카탈로그가 + 선언한 규칙을 **실제 저장 경로 그대로** 흘려보내는 이 테스트가 필요하다. + 프리셋이 약속한 것을 시스템이 하지 않는 것은 이 제품에서 가장 나쁜 종류의 결함이다. +""" + +import json +import re +import subprocess +from pathlib import Path + +import pytest + +from edge.zones import _rules_of + +ROOT = Path(__file__).resolve().parents[1] +CATALOG = ROOT / "server" / "src" / "Api" / "ZonePresetCatalog.php" + + +def _presets() -> list[dict]: + """PHP 카탈로그를 그대로 읽어 온다 — 파이썬에 값을 다시 적으면 그 사본이 또 어긋난다.""" + out = subprocess.run( + ["php", "-r", + 'require "server/vendor/autoload.php";' + 'echo json_encode(App\Api\ZonePresetCatalog::all());'], + cwd=ROOT, capture_output=True, text=True, timeout=60, + ) + assert out.returncode == 0, out.stderr + return json.loads(out.stdout) + + +def _server_accepts(rules: dict) -> tuple[str | None, bool]: + """ZoneConfigController::rulesJson 을 그대로 호출한다 (저장 경로의 1차 관문).""" + php = ( + 'require "server/vendor/autoload.php";' + '$m = new ReflectionMethod("App\\Dashboard\\ZoneConfigController", "rulesJson");' + '$m->setAccessible(true);' + 'echo json_encode($m->invoke(null, json_decode($argv[1], true)));' + ) + out = subprocess.run(["php", "-r", php, "--", json.dumps(rules)], + cwd=ROOT, capture_output=True, text=True, timeout=60) + assert out.returncode == 0, out.stderr + stored, ok = json.loads(out.stdout) + return stored, ok + + +php_missing = subprocess.run(["php", "-v"], capture_output=True).returncode != 0 +pytestmark = pytest.mark.skipif(php_missing, reason="php not available") + + +@pytest.mark.parametrize("preset", _presets() if not php_missing else [], + ids=lambda p: p["id"]) +def test_preset_survives_server_validation_and_edge_parsing(preset): + rules = preset["rules"] + stored, ok = _server_accepts(rules) + assert ok, f"서버가 프리셋 '{preset['id']}' 의 규칙을 거부했습니다: {rules}" + assert stored is not None, f"프리셋 '{preset['id']}' 이 규칙 없이 저장됩니다" + + parsed = _rules_of({"rules_json": stored}) + assert parsed is not None, f"엣지가 프리셋 '{preset['id']}' 의 규칙을 통째로 버렸습니다" + + for key, want in rules.items(): + assert key in parsed, ( + f"프리셋 '{preset['id']}' 의 '{key}' 가 엣지까지 오지 못했습니다 — " + f"프리셋이 약속한 것을 시스템이 하지 않습니다. 저장본: {stored}, 파싱: {parsed}") + assert parsed[key] == want, f"{key}: {parsed[key]!r} != {want!r}" + + +def test_catalog_and_ui_agree_on_the_capacity_floor(): + # 입력란 하한이 1 이면 운영자가 0 을 넣을 방법이 없다 — 화면도 허용 목록의 일부다. + view = (ROOT / "server" / "src" / "View" / "zones.php").read_text(encoding="utf-8") + m = re.search(r'id="rule-max-workers"[^>]*min="(\d+)"', view) + assert m, "최대 인원 입력란을 찾지 못했습니다" + assert m.group(1) == "0", "출입 금지 구역을 입력할 수 없습니다 (min 이 0 이어야 합니다)"