Compare commits
2 commits
4bfb7e0274
...
5db0a4a403
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5db0a4a403 | ||
|
|
93f896af87 |
27 changed files with 794 additions and 81 deletions
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
---
|
||||
|
||||
## 1.2.0 — 2026-09-04 · 추락 · 동시 다발 쓰러짐 · 구역 프리셋
|
||||
## 1.2.0 — 2026-09-05 · 추락 · 동시 다발 쓰러짐 · 구역 프리셋
|
||||
|
||||
AI CCTV 의 목적을 원론부터 다시 점검한 결과입니다. 셋 다 **새 추론 모델 없이** 지금 있는
|
||||
자세추정·구역·트랙 이력만으로 구현했습니다.
|
||||
|
|
|
|||
|
|
@ -166,3 +166,5 @@
|
|||
- 산출물 `move-detect-cafe24-1.2.0-20260905.zip`
|
||||
- **⚠ Pi 번들은 이번 릴리스에서 필수 재빌드** — edge/ 가 바뀌었다. 낡은 1.0.0 번들은 제거했다(추락 감지가 빠진 노드가 납품되는 것을 막기 위해). arm64+네트워크 빌드 머신에서 재빌드해야 Pi 납품 가능
|
||||
- 검증: pytest 322 passed(5 skip: 환경·Pi번들 미빌드) · phpunit 263 passed
|
||||
|
||||
- 2026-09-05 — 1.2.0 4차 재감사: 확정 23건 전량 해소, 반증 5건 기록. CRITICAL 4건은 전부 "구현·단위테스트는 초록인데 납품 경로에서 무동작"이었다 — 추락 승격이 기본 설정에서 미발화(테스트가 prone_confirm_s=0.5 사용), max_workers:0·elevated 가 상류 허용 목록 두 곳에서 차단, 동시 다발이 트래커 재획득으로 한 사람을 두 명으로 셈(구조 지연). 재발 방지 테스트 추가: BUILD-INFO↔HEAD, README↔마이그레이션, 시더 심각도↔엣지, 프리셋 왕복. 문서 "17종"→실제 20종 정정. 산출물 `move-detect-cafe24-1.2.0-20260905.zip`(BUILD-INFO=93f896a). 검증 pytest 338 / phpunit 269. `93f896a`
|
||||
|
|
|
|||
|
|
@ -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회, 보존기간) ·
|
||||
|
|
|
|||
|
|
@ -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건 전량 해소 — 판정 적합",
|
||||
"폐쇄망 설치 패키지 · 역할별 사용설명서"
|
||||
|
|
|
|||
|
|
@ -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 잔여 (실장비 커미셔닝에서만 검증 가능)
|
||||
|
||||
코드·문서로 닫을 수 없는 항목. 납품 판정을 막지 않되, 현장 설치 시 반드시 수행한다.
|
||||
|
|
|
|||
|
|
@ -304,7 +304,7 @@
|
|||
```
|
||||
[1] 감지 [2] 대응 [3] 증거
|
||||
───────────── ───────────────── ─────────────────
|
||||
17종 이벤트 → 현장 경광등 (즉시) → 사고조사 패키지
|
||||
20종 이벤트 → 현장 경광등 (즉시) → 사고조사 패키지
|
||||
엣지 AI 판정 담당자 통보 (1분) 증거 3프레임
|
||||
얼굴 자동 블러 상위 확대 (5분) 통보·조치 이력
|
||||
위·변조 탐지 기록
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@
|
|||
- **얼굴 블러** — 엣지에서, 업로드 전에
|
||||
- **개인 식별자 없음** — 사람을 임시 추적번호로만 다루고, **누구인지 모릅니다.**
|
||||
사번·이름·얼굴 인식 어느 것도 없습니다
|
||||
- **근무태도 관련 이벤트 0종** — 감지 17종 전부가 *위험 상태* 이고,
|
||||
- **근무태도 관련 이벤트 0종** — 감지 20종 전부가 *위험 상태* 이고,
|
||||
자리 비움·작업 속도·휴게 시간 같은 항목은 **설계상 존재하지 않습니다**
|
||||
- **위·변조 탐지 감사기록** — 설정 변경·확인·오탐 처리가 해시 체인으로 남아
|
||||
지우거나 고치면 검증에서 드러납니다
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -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건 전량 해소 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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. 위 표의 순서대로 파일 내용을 **하나씩** 붙여넣고 실행합니다
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<int,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'] ?? '');
|
||||
$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;
|
||||
// 좌표가 둘 다 있으면 거리로 다시 쪼갠다 — 같은 카메라라도 반대편 끝은 다른 사건이다.
|
||||
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;
|
||||
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;
|
||||
if (!$placed) $clusters[] = [$ev];
|
||||
}
|
||||
continue;
|
||||
foreach ($clusters as $ci => $c) {
|
||||
// 군집이 하나면 좌표를 키에 넣지 않는다 — 넣으면 창이 미끄러져 대표점이
|
||||
// 바뀔 때마다 scope 가 달라져 같은 사고가 새 건으로 올라간다.
|
||||
$out[count($clusters) === 1 ? $key : $key . '#' . $ci] = $c;
|
||||
}
|
||||
$out[$key][] = $ev;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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} 이 없습니다 — 업그레이드한 현장은 이 마이그레이션을 "
|
||||
"영영 실행하지 않습니다")
|
||||
|
|
|
|||
|
|
@ -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) == []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
# --- 잡으면 안 되는 것 ----------------------------------------------------------------
|
||||
|
|
|
|||
56
tests/test_seed_demo_severity.py
Normal file
56
tests/test_seed_demo_severity.py
Normal file
|
|
@ -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)}"
|
||||
84
tests/test_zone_preset_roundtrip.py
Normal file
84
tests/test_zone_preset_roundtrip.py
Normal file
|
|
@ -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 이어야 합니다)"
|
||||
Loading…
Reference in a new issue