Compare commits
No commits in common. "delivery-hardening" and "main" have entirely different histories.
delivery-h
...
main
|
|
@ -1,44 +0,0 @@
|
||||||
# The repo had no CI at all, which meant PdoIntegrationTest — the only test that runs the
|
|
||||||
# delivered SQL rather than an in-memory double — had almost certainly never executed anywhere:
|
|
||||||
# it skips itself unless pdo_sqlite is loaded, and no machine was loading it.
|
|
||||||
#
|
|
||||||
# Same two commands as the local `verify` in CLAUDE.md. Keep them identical; a CI that runs
|
|
||||||
# something else is a CI that proves something else.
|
|
||||||
name: verify
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
edge:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: '3.11' # Pi OS Bookworm ships CPython 3.11; match the target
|
|
||||||
- run: pip install -e ".[dev]" # deps + pytest come from pyproject.toml
|
|
||||||
- run: python -m pytest tests -q
|
|
||||||
|
|
||||||
server:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: shivammathur/setup-php@v2
|
|
||||||
with:
|
|
||||||
php-version: '8.2' # Cafe24's floor (see server/docs/server-deploy.md)
|
|
||||||
# pdo_sqlite is what un-skips the DB integration test; gd/sodium mirror the host's
|
|
||||||
# image + Argon2id requirements so a missing-extension failure shows up here, not
|
|
||||||
# on the customer's shared hosting.
|
|
||||||
extensions: pdo_sqlite, gd, sodium
|
|
||||||
- run: composer install --no-interaction --prefer-dist
|
|
||||||
working-directory: server
|
|
||||||
- run: php vendor/bin/phpunit
|
|
||||||
working-directory: server
|
|
||||||
- name: fail if any test skipped
|
|
||||||
working-directory: server
|
|
||||||
# A skip is how the DB coverage silently disappeared in the first place. In CI every
|
|
||||||
# test must actually run, so a re-introduced skip gate fails the build instead of
|
|
||||||
# quietly shrinking coverage back down.
|
|
||||||
run: php vendor/bin/phpunit --do-not-cache-result | tee out.txt; ! grep -q "Skipped:" out.txt
|
|
||||||
26
.gitignore
vendored
|
|
@ -11,11 +11,7 @@ dist/
|
||||||
|
|
||||||
# Edge runtime artifacts
|
# Edge runtime artifacts
|
||||||
*.db
|
*.db
|
||||||
# 모델 가중치는 커밋하지 않는다. 둘 다 고정된 fetch 스크립트로 받는다
|
|
||||||
# (edge/tools/fetch_hailo_hef.py · fetch_movenet.py) — sha256 가 저장소보다 확실한 보증이고,
|
|
||||||
# 9~10MB 바이너리를 히스토리에 쌓을 이유가 없다.
|
|
||||||
*.hef
|
*.hef
|
||||||
*.onnx
|
|
||||||
# Root-anchored on purpose. A bare `snapshots/` matches at EVERY depth, which swallowed
|
# Root-anchored on purpose. A bare `snapshots/` matches at EVERY depth, which swallowed
|
||||||
# cloud/storage/snapshots/ — and since git cannot re-include a file whose parent directory
|
# cloud/storage/snapshots/ — and since git cannot re-include a file whose parent directory
|
||||||
# is excluded, it silently killed cloud/.gitignore's `!/storage/snapshots/.gitkeep`. The
|
# is excluded, it silently killed cloud/.gitignore's `!/storage/snapshots/.gitkeep`. The
|
||||||
|
|
@ -37,25 +33,3 @@ Thumbs.db
|
||||||
|
|
||||||
# Pi provisioning (real device secret) — never commit
|
# Pi provisioning (real device secret) — never commit
|
||||||
deploy/device.env
|
deploy/device.env
|
||||||
|
|
||||||
# 생성되는 사용 설명서. 소스는 server/deploy/manual/{manual.json,shots/} 이고,
|
|
||||||
# 납품 패키저가 빌드할 때마다 다시 만든다. 커밋된 사본을 두면 화면이 바뀐 뒤에도 그것이
|
|
||||||
# 최신처럼 보이는데, 구 UI 캡처가 남은 매뉴얼은 납품물 자체의 결함이다.
|
|
||||||
server/deploy/manual/사용설명서.html
|
|
||||||
|
|
||||||
# 경영진 보고 덱 — 생성물. 매뉴얼(사용설명서.html)과 같은 이유로 커밋하지 않는다:
|
|
||||||
# 낡은 사본이 최신인 척하는 것이 가장 위험하다. build_deck.py 로 다시 뽑을 것.
|
|
||||||
docs/deck/경영진-보고.html
|
|
||||||
# 3초 클립은 바이너리다. 저장소가 아니라 발표 노트북에 둔다.
|
|
||||||
docs/deck/clips/*
|
|
||||||
!docs/deck/clips/.gitkeep
|
|
||||||
|
|
||||||
# 프로비저닝 자격증명 — 납품 폴더(dist/) 밖에 둔다.
|
|
||||||
# dist/ 안에 있으면 폴더째 압축할 때 같이 나간다.
|
|
||||||
/secrets/
|
|
||||||
|
|
||||||
# archify 생성물. 소스는 docs/*.archify.json 이고 `archify deliver` 로 언제든 다시 만든다.
|
|
||||||
# 사용설명서와 같은 이유로 커밋하지 않는다 — 소스가 바뀐 뒤에도 낡은 HTML 이 최신처럼 보인다.
|
|
||||||
# visual-check 는 검증 증거(PNG·리포트)라 저장소가 아니라 그때의 판정 기록으로만 쓴다.
|
|
||||||
docs/move-detect-*.html
|
|
||||||
docs/move-detect-*.visual-check.*
|
|
||||||
|
|
|
||||||
288
CHANGELOG.md
|
|
@ -1,288 +0,0 @@
|
||||||
# CHANGELOG — move-detect (작업장 안전 AI CCTV)
|
|
||||||
|
|
||||||
버전 표기는 저장소 루트 `VERSION` 파일이 단일 출처입니다.
|
|
||||||
납품 산출물 파일명에도 같은 버전이 들어갑니다:
|
|
||||||
`move-detect-cafe24-<VERSION>-<YYYYMMDD>.zip` / `move-detect-pi-<VERSION>-<YYYYMMDD>-<arch>.tar.gz`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1.3.0 — 2026-09-25 · 읽기 감사 · 증거 보존 · 감시자 감시
|
|
||||||
|
|
||||||
이해관계자 5인(작업자·노조·안전관리자·공장장·설치기사) 관점으로 제품을 다시 검증한 결과
|
|
||||||
나온 네 가지입니다. 넷 다 같은 모양이었습니다 — **구현도 테스트도 초록인데, 그 기능이
|
|
||||||
존재해야 할 이유가 닿는 지점에서 아무 일도 일어나지 않는 상태.**
|
|
||||||
|
|
||||||
### 추가 — 열람·반출 감사기록
|
|
||||||
|
|
||||||
이 시스템은 쓰기를 전부 감사했습니다. 임계값 한 번 바꾼 것까지 해시 체인에 들어갑니다.
|
|
||||||
그런데 **작업자 사진을 열어보는 것은 하나도 남지 않았습니다.**
|
|
||||||
|
|
||||||
감시 시스템과 안전 시스템을 가르는 선이 정확히 거기입니다. 나머지 프라이버시 설계
|
|
||||||
(RAM 링버퍼 7.5초·전송 전 얼굴 블러·라이브뷰 부재)가 전부 이 한 줄이 없어서 검증 불가능한
|
|
||||||
*주장* 에 머물러 있었습니다. 교섭에서 "지난달에 우리 조합원 사진 누가 봤습니까"에 답이
|
|
||||||
없으면 "안 봤습니다"는 증명이 아니라 주장입니다.
|
|
||||||
|
|
||||||
- 스냅샷 열람 → 감사 동작 `snapshot_view` (세션당 사진 1회)
|
|
||||||
- 라벨 데이터셋 대량 반출 → `label_export` (건수·기간과 함께)
|
|
||||||
|
|
||||||
### 추가 — 증거 보존 보류 (`hold_until`)
|
|
||||||
|
|
||||||
스냅샷은 나이만 보고 지워졌습니다. 기본 7일인데 중대재해 조사는 몇 주에서 몇 달이라,
|
|
||||||
**우리가 내건 목적("사고 후 원인 분석")이 8일째에 스스로 사라지는** 상태였습니다.
|
|
||||||
|
|
||||||
admin 만, 건별로, 최대 1년, 감사기록과 함께 보류를 겁니다. 확인 처리만으로 자동 연장되지
|
|
||||||
않습니다 — 자동이면 사실상 전량 보존이 되어 7일 약속이 무너집니다. 만료되면 사람이 해제하지
|
|
||||||
않아도 다시 삭제 대상이 됩니다. 사건 상세 화면에 버튼이 생깁니다.
|
|
||||||
|
|
||||||
### 추가 — 통보 디스패처 생존 표시
|
|
||||||
|
|
||||||
죽은 노드는 스스로 신고할 수 없어서 크론이 하트비트의 **부재**로 판정합니다. 그런데 그
|
|
||||||
크론이 멈추면 아무도 판정하지 않습니다 — 통보·5분 미확인 확대·장치 이상 발행이 전부 그 한
|
|
||||||
프로세스에 걸려 있어서, 멈춘 순간부터 화면은 조용해지고 **그 침묵은 "사고 없음"과 구별되지
|
|
||||||
않습니다.** 관리자 화면이 이에 대해 하던 일은 "크론으로 실행 중이어야 합니다"라는 안내문
|
|
||||||
한 줄이었습니다.
|
|
||||||
|
|
||||||
이제 크론이 매 실행마다 시각을 남기고, 대시보드가 5분 넘게 갱신이 없으면 배너를 띄웁니다.
|
|
||||||
감시자의 생존은 감시자가 아닌 쪽이 읽어야 하기 때문입니다.
|
|
||||||
현장 경광등은 GPIO 직결이라 이 상황에서도 영향받지 않습니다.
|
|
||||||
|
|
||||||
### 변경 — 체류 상한 초과에는 사진을 붙이지 않습니다
|
|
||||||
|
|
||||||
증거 프레임은 `ergo_report` 하나만 빼고 **모든** 이벤트에 붙었습니다. 체류 상한 초과
|
|
||||||
(`overstay`)도 포함이라, 밀폐공간 프리셋 기본값(30분) 기준으로 **정상 작업자가 규정대로
|
|
||||||
일하는 동안 주기적으로 본인 사진이 서버로 가는** 상태였습니다. 위험해서가 아니라 오래
|
|
||||||
있어서.
|
|
||||||
|
|
||||||
기준을 명문화했습니다 — **시간 경과 자체가 조건인 이벤트에는 사진을 붙이지 않는다.**
|
|
||||||
규칙 위반(단독작업·정원 초과)은 사진이 곧 위반의 증명이므로 그대로 둡니다.
|
|
||||||
|
|
||||||
### 마이그레이션
|
|
||||||
|
|
||||||
`2026-09-25-evidence-hold-and-view-audit.sql` — `move_events.hold_until` 컬럼과
|
|
||||||
`move_app_state` 표. README-UPLOAD.md 의 10번입니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1.2.1 — 2026-09-21 · 납품 정비 (기능 변경 없음)
|
|
||||||
|
|
||||||
감지 동작은 1.2.0 과 같습니다. 납품·운영에서 손으로 하던 일 세 가지를 닫았습니다.
|
|
||||||
|
|
||||||
### 추가 — 자격증명 회전 도구
|
|
||||||
|
|
||||||
`tools/rotate_credentials.php`. 장치 HMAC 키와 관리자 비밀번호 교체를 명령 하나로 만듭니다.
|
|
||||||
DB 에 접속하지 않고 phpMyAdmin 에 붙여넣을 SQL 만 출력하므로, 접속 정보 없이 어느 머신에서나
|
|
||||||
실행할 수 있습니다.
|
|
||||||
|
|
||||||
```
|
|
||||||
php tools/rotate_credentials.php --device=pi-01 # 장치 HMAC 키
|
|
||||||
php tools/rotate_credentials.php --admin=admin # 대시보드 비밀번호
|
|
||||||
```
|
|
||||||
|
|
||||||
장치 키는 **서버와 Pi 를 같이** 바꿔야 합니다. 한쪽만 바꾸면 그 장치의 이벤트가 전부 거부되고,
|
|
||||||
화면에는 `device_fault` 하나만 뜹니다. 출력에 양쪽 절차가 함께 나옵니다.
|
|
||||||
비밀번호는 STDIN 으로만 받습니다 — 명령행에 적으면 `ps` 와 셸 히스토리에 남기 때문입니다.
|
|
||||||
|
|
||||||
### 복구 — MoveNet CPU 폴백
|
|
||||||
|
|
||||||
Hailo 없이 돌리는 CPU 폴백 모델의 배포 원본이 사라져 있어(원 저장소가 모델을 외부 저장소로
|
|
||||||
옮김) 번들에 넣을 수 없었습니다. 유효한 출처를 찾아 sha256 으로 고정했습니다. Hailo 납품에는
|
|
||||||
영향이 없습니다(폴백은 선택).
|
|
||||||
|
|
||||||
### Pi 번들
|
|
||||||
|
|
||||||
1.2.0 에서 `edge/` 가 바뀌었는데 번들이 재빌드되지 않은 상태였습니다. 재빌드했습니다 —
|
|
||||||
`move-detect-pi-1.2.1-<날짜>-aarch64-cp311-hailo8.tar.gz`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1.2.0 — 2026-09-05 · 추락 · 동시 다발 쓰러짐 · 구역 프리셋
|
|
||||||
|
|
||||||
AI CCTV 의 목적을 원론부터 다시 점검한 결과입니다. 셋 다 **새 추론 모델 없이** 지금 있는
|
|
||||||
자세추정·구역·트랙 이력만으로 구현했습니다.
|
|
||||||
|
|
||||||
### 추가 — 추락 (`fall_from_height`)
|
|
||||||
|
|
||||||
이 제품이 잡던 것은 같은 높이에서의 **넘어짐**이었습니다. 산업재해 사망 1위 유형인
|
|
||||||
**떨어짐**은 잡지 못했습니다 — `GhostIncidentDetector` 가 트랙이 이미 위험 자세일 때만
|
|
||||||
무장하므로, 발판 위에 서 있다가 떨어지는 사람은 소실 순간 정상 자세라 아무것도 걸리지
|
|
||||||
않았습니다.
|
|
||||||
|
|
||||||
- 판정은 bbox 중심이 아니라 **발 밑선(y2)** 으로 합니다. 쭈그려 앉으면 중심은 내려가도 발은
|
|
||||||
그 자리이고, 추락은 발 밑선이 통째로 내려갑니다. 중심으로 재면 쭈그려 앉아 작업하는
|
|
||||||
사람이 전부 추락이 됩니다. 원근 접근은 높이 증가 가드로 막습니다.
|
|
||||||
- 프레임 안에서 확정되면 넘어짐 유형을 **승격**합니다 — 한 번의 사고는 한 건입니다.
|
|
||||||
- 화각 밖으로 사라지면 ghost 경로로 발보합니다. 떨어져 나간 사람에게는 이쪽이 유일한 경로입니다.
|
|
||||||
- 심각도 **위급**. 척추·두부 손상 가능성이 있어 함부로 옮기면 안 되고, 그 주의가 통보에
|
|
||||||
실리려면 넘어짐(높음)과 등급이 갈라져야 합니다.
|
|
||||||
- 구역 규칙 `elevated: true` 로 발판·개구부를 표시하면 정확해집니다. 구역이 없어도 동작합니다.
|
|
||||||
|
|
||||||
### 추가 — 동시 다발 쓰러짐 (`multi_down`)
|
|
||||||
|
|
||||||
같은 구역에서 짧은 시간에 두 명 이상이 쓰러진 것은 넘어짐 두 건이 아니라 **질식·가스 누출·
|
|
||||||
폭발**입니다. 그리고 이때 사람이 가장 많이 죽는 방식은 첫 번째 사람이 왜 쓰러졌는지 모르고
|
|
||||||
**구조하러 들어간 사람이 같이 죽는 것**입니다.
|
|
||||||
|
|
||||||
그래서 이 기능의 본체는 감지가 아니라 **통보 문구**입니다. 메일 본문에 링크를 열기 전에
|
|
||||||
"공기호흡기 없이 진입하지 마십시오"가 바로 읽힙니다.
|
|
||||||
|
|
||||||
- 서버에서 판단합니다 — 가스는 카메라 화각을 존중하지 않고, 현장 즉시 대응은 첫 쓰러짐의
|
|
||||||
경광등이 이미 확보합니다.
|
|
||||||
- 오탐 방어의 핵심은 **근접성**입니다. 넓은 공장에서 2분 안의 무관한 넘어짐 두 건은 실제로
|
|
||||||
일어나므로, 같은 구역이거나 좌표가 가까워야 묶습니다. 한 사람이 두 번 넘어진 것은 한 명입니다.
|
|
||||||
|
|
||||||
### 추가 — 용도별 구역 프리셋
|
|
||||||
|
|
||||||
밀폐공간 감시에 필요한 것(제한구역 + 2인1조 + 체류 상한)은 이미 전부 있었는데, 화면이
|
|
||||||
"최소 인원을 넣으세요"라고만 물어서 아무도 그렇게 쓰지 못했습니다. 안전관리자는
|
|
||||||
`min_workers` 가 아니라 **밀폐공간**을 생각합니다.
|
|
||||||
|
|
||||||
밀폐공간 · 고소작업 개구부 · 프레스 협착 · **출입 금지(크레인 하부·선회 반경)** ·
|
|
||||||
지게차 통로 · 화기작업 6종. 고른 뒤 값을 바꾸는 것은 그대로 허용합니다.
|
|
||||||
|
|
||||||
### 수정 — "아무도 있으면 안 되는 구역"이 조용히 꺼져 있었습니다
|
|
||||||
|
|
||||||
`max_workers: 0` 이 falsy 로 걸러져 규칙이 통째로 동작하지 않았습니다. 크레인으로 든 짐
|
|
||||||
아래, 굴착기 선회 반경처럼 **아무도 있어서는 안 되는 범위**가 정확히 그 규칙이고 깔림
|
|
||||||
사고의 전형입니다. 운영자가 저장해 놓고도 아무 일이 일어나지 않았습니다.
|
|
||||||
|
|
||||||
네 곳에서 막혀 있었습니다 — 감지기(`and` 검사), 입력란(`min="1"`), 저장 직렬화(`> 0`),
|
|
||||||
목록 표시. 전부 고쳤습니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1.1.0 — 2026-09-04 · 감시 자신의 고장을 실제로 신고하기
|
|
||||||
|
|
||||||
1.0.0 은 "감지에 대응하는 시스템"이었습니다. 그런데 **감시 장비 자신이 멈춘 것**은 여전히
|
|
||||||
아무도 신고하지 않았습니다 — 타입은 있는데 발행하는 코드가 없었습니다. 1.1.0 이 그것을 채웁니다.
|
|
||||||
|
|
||||||
소개서가 내세우던 "감지 17종"은 그때까지 실제로 15종이었습니다. 이제 17종이 사실입니다.
|
|
||||||
|
|
||||||
### 재감사에서 잡은 것 (3차, 14건 전량 해소)
|
|
||||||
|
|
||||||
위 기능을 넣으면서 만든 결함들입니다. 상세는 `docs/delivery-audit.md` 3차 재감사 절.
|
|
||||||
|
|
||||||
- **통보가 통째로 멈출 수 있었습니다.** 장치 건전성 단계가 예외 처리 밖에 있어, 그 단계에서
|
|
||||||
난 오류가 통보와 재시도까지 죽였습니다. 새벽에 넘어진 사람이 통보되지 않고 콘솔은 멀쩡해
|
|
||||||
보이는 상태가 매분 반복됩니다. 단계를 격리했습니다.
|
|
||||||
- **간헐 고장을 못 잡거나, 반대로 쏟아냈습니다.** 카메라가 깜빡이며 살아났다 죽으면
|
|
||||||
(리본 접촉 불량의 전형) 저하 구간이 계속 끊겨 두 시간을 고장 나 있어도 아무것도 올라오지
|
|
||||||
않고, 동시에 같은 고장이 수십 건으로 갈라졌습니다. 회복을 '지속된 정상'으로만 인정하고,
|
|
||||||
재고지 주기당 1건으로 묶었습니다.
|
|
||||||
- **`__device__` 라는 내부 값이 메일·웹훅으로 회사 밖에 나갔습니다.** 이제 어디서나
|
|
||||||
"장치 전체"로 표시됩니다.
|
|
||||||
- 사건 상세에서 **존재하지 않는 카메라에 위험구역을 저장**할 수 있었습니다(저장되면 아무것도
|
|
||||||
지키지 않는 구역이 '활성'으로 남습니다). 막았습니다.
|
|
||||||
- 카메라별 위험 순위·카메라 대수·오탐율에 유령 카메라가 섞이던 것을 제외했습니다.
|
|
||||||
|
|
||||||
### 수정 — 아무도 발행하지 않던 이벤트 2종
|
|
||||||
|
|
||||||
`monitoring_degraded`(감시 성능 저하)와 `device_fault`(장치 이상)는 타입 정의·서버 검증·
|
|
||||||
콘솔 필터·통보 라벨까지 전 구간에 있었으나 **생성하는 코드가 어디에도 없었습니다.** 실제로
|
|
||||||
값을 넣는 것은 데모 시더뿐이었고, 소개서가 내세우던 "17종"은 실제로 15종이었습니다.
|
|
||||||
|
|
||||||
엣지가 낼 수 없는 유형입니다 — 전원이나 회선이 끊긴 노드는 자기가 죽었다고 신고할 수
|
|
||||||
없습니다. 그래서 서버가 하트비트의 **부재**로 판정합니다.
|
|
||||||
|
|
||||||
- `App\Alert\DeviceHealthWatch` 추가. 1분 크론(`dispatch_alerts.php`)의 첫 단계로 실행되어,
|
|
||||||
같은 실행 안에서 발행된 장치 이벤트가 곧바로 통보 대상이 됩니다.
|
|
||||||
- 장애당 1건. event_uuid 앵커가 장애 구간의 시작(`device_fault` 는 마지막 하트비트 시각)이라
|
|
||||||
크론이 60번 돌아도 이벤트는 하나입니다. 복구 후 재발은 별건으로 잡힙니다.
|
|
||||||
- 성능 저하는 **지속**을 요구합니다(기본 5분). 한 번 튄 프레임률로 알람이 울리면 운영자는
|
|
||||||
이 유형을 무시하게 되고, 그러면 정작 감시가 멈춘 날에도 아무도 보지 않습니다.
|
|
||||||
- 한 번도 하트비트를 보낸 적 없는 장치는 **장애가 아닙니다**(미설치 노드). 새 현장 첫날이
|
|
||||||
빨간 벽이 되지 않도록.
|
|
||||||
- 설정: `device_stale_seconds`(180) · `degraded_sustain_seconds`(300) ·
|
|
||||||
`degraded_fps_ratio`(0.6) · `target_fps`(12). 노드를 낮은 프레임으로 운용하는 현장은
|
|
||||||
`target_fps` 를 실제 값으로 맞춰야 합니다.
|
|
||||||
|
|
||||||
장치 단위 이벤트는 카메라가 없으므로 `camera_id` 에 `__device__` 센티넬을 씁니다. 콘솔은
|
|
||||||
"장치 전체"로 표시하고, 구역 편집기의 카메라 목록에서는 제외합니다(존재하지 않는 카메라에
|
|
||||||
구역을 그리려는 일이 없도록).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1.0.0 — 2026-08-18 · 첫 정식 납품 버전
|
|
||||||
|
|
||||||
0.9.0 까지가 "감지하는 시스템"이었다면, 1.0.0 은 **"감지에 대응하는 시스템"** 입니다.
|
|
||||||
그 차이를 만드는 것이 아래 첫 항목입니다.
|
|
||||||
|
|
||||||
### 추가 — 통보 (이 버전의 이유)
|
|
||||||
|
|
||||||
이전까지 감지는 브라우저 탭이 열려 있고 사람이 보고 있어야만 전달됐습니다. 새벽 3시에
|
|
||||||
넘어진 작업자는 DB 에 행 하나를 남기고 아침을 맞았습니다.
|
|
||||||
|
|
||||||
- **현장 경광등·부저** (엣지 GPIO). 서버도 인터넷도 거치지 않고 감지한 노드가 직접 울립니다.
|
|
||||||
정전·회선 장애로 업링크가 끊긴 순간은 오히려 위험이 높아지는 때이므로, 어떤 현장이든
|
|
||||||
이것부터 설정합니다.
|
|
||||||
- **메일 · 웹훅** 채널과 1분 주기 디스패처. 알림에는 현장 이미지를 싣지 않고 링크만 보냅니다
|
|
||||||
(스냅샷은 개인정보이고, 메일링 리스트로 나가는 순간 인증 프록시의 통제를 벗어납니다).
|
|
||||||
- **미확인 경보 에스컬레이션**. 위급/높음이 정해진 시간 동안 확인되지 않으면 그 사실 자체가
|
|
||||||
사건이 되어 상위 담당자에게 통보됩니다. "아무도 보지 않았다"가 처음으로 기록에 남습니다.
|
|
||||||
|
|
||||||
### 추가 — 조용한 실패 드러내기
|
|
||||||
|
|
||||||
- **카메라 화각 변경 · 가림 · 조도 저하 감지.** 위험구역 판정은 카메라가 어제와 같은 곳을
|
|
||||||
본다는 전제 위에 서 있는데, 청소하다 삼각대를 스치면 제한구역이 엉뚱한 바닥을 가리키면서
|
|
||||||
시스템은 계속 초록불이었습니다.
|
|
||||||
- **저장 공간 부족 경고.** 디스크가 차면 이미지 저장만 조용히 실패하고 화면은 멀쩡해 보입니다.
|
|
||||||
- **설정 지연 표시가 실제로 동작**하게 됐습니다. 지표는 있었지만 그것을 켜는 사건이 없었습니다.
|
|
||||||
|
|
||||||
### 추가 — 관리 화면 (그전에는 DB 직접 수정이 유일한 방법)
|
|
||||||
|
|
||||||
- 감지 임계값 편집 (허용 범위·단위·설명을 화면이 강제)
|
|
||||||
- 통보 경로 설정
|
|
||||||
- 정비 중 알람 억제 — 사유 필수, 최대 8시간, 자동 만료.
|
|
||||||
**감지와 기록은 그대로 계속되고 통보만 보류**되며, 보류했다는 사실도 기록에 남습니다.
|
|
||||||
- 위험성평가 연계 · 교대 인수인계 · 설비 성능 추이 · 설치 점검(`/setup`)
|
|
||||||
|
|
||||||
### 추가 — 증거와 규정
|
|
||||||
|
|
||||||
- **감사로그 위·변조 탐지** (해시체인). 수정·삭제·우회삽입이 전부 드러납니다.
|
|
||||||
- **사고조사 패키지 1클릭** — 전후 타임라인, 통보 이력, 위험요인, 조치 이력, 서명란.
|
|
||||||
인쇄해서 그대로 제출할 수 있습니다.
|
|
||||||
- 현장별 현황 (본사 계정)
|
|
||||||
|
|
||||||
### 변경 — 화면 (UI/UX 점검 21건)
|
|
||||||
|
|
||||||
- **감지 스트림이 다시 시간순이 됐습니다.** 확인 여부가 순서를 바꾸던 탓에 사고 재구성이
|
|
||||||
불가능했고, 확인하면 그 행이 사라지며 아래가 밀려 올라와 다른 이벤트를 잘못 확인할 수
|
|
||||||
있었습니다. 확인은 이제 제자리에서 처리됩니다.
|
|
||||||
- 이벤트 상세에서 바로 확인/오탐 판정 · 구역 설정에 카메라 선택기 · 날짜로 조회 ·
|
|
||||||
경보음 음소거(새 위급 오면 자동 해제) · 일괄 확인(위급 제외) · 키보드 조작
|
|
||||||
- 표본이 부족한 수치는 비율 대신 원시 숫자만 보여줍니다. 6건→8건을 "▲33% 악화"로 칠하면
|
|
||||||
안전회의에서 반박당하고, 그 순간 그 화면 전체가 신뢰를 잃습니다.
|
|
||||||
|
|
||||||
### 도구 (`move_detect_app/tools/`, 웹에서 접근 불가)
|
|
||||||
|
|
||||||
| 도구 | 용도 |
|
|
||||||
|------|------|
|
|
||||||
| `dispatch_alerts.php` | 알림 발송 (크론 1분) · `--dry-run` 지원 |
|
|
||||||
| `maintain.php` | 보존기간 정리 (크론 일 1회) |
|
|
||||||
| `backup.php` | DB 덤프 + 사진 아카이브 + 복구 안내 |
|
|
||||||
| `verify_audit_chain.php` | 감사로그 무결성 검증 |
|
|
||||||
| `seed_demo.php` | 시연·시험용 데모 데이터 (운영 DB 실행 금지) |
|
|
||||||
|
|
||||||
### 업그레이드 (0.9.x → 1.0.0)
|
|
||||||
|
|
||||||
`migrations/2026-08-09-safety-platform.sql` 을 적용합니다. **파일 안의 9번 블록은 DB 종류에
|
|
||||||
따라 문법이 다릅니다** — MySQL 8.0.16+ / MariaDB / 제약 없음 세 경우의 안내가 파일에
|
|
||||||
적혀 있으니 `SELECT VERSION();` 으로 확인 후 맞는 것을 실행하세요.
|
|
||||||
|
|
||||||
적용 후 `config.php` 에 `public_base_url`, `timezone`, `escalate_after_seconds` 를 추가하고
|
|
||||||
(템플릿 참조), 자동실행(크론) 2개를 등록해야 알림이 동작합니다 — README-UPLOAD.md 8번.
|
|
||||||
|
|
||||||
### 알려진 제약
|
|
||||||
|
|
||||||
- **보호구 미착용 · 중장비 근접 · 화재 감지**: 감지기 배선과 이벤트 계약은 준비되어 있으나
|
|
||||||
**추론 모델이 확보되지 않아 기본 비활성**입니다. 라이선스 조건을 만족하는 가중치와 Hailo
|
|
||||||
컴파일이 선행되어야 합니다. 켜져 있지 않다는 사실을 화면에서도 숨기지 않습니다.
|
|
||||||
- 실장비 검증 잔여 항목은 `docs/delivery-audit.md` 의 "OFF-HW 잔여" 절을 참조하세요.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0.9.0 — 2026-08-08
|
|
||||||
|
|
||||||
납품 감사(43건) 대응. CSP nonce 도입, 보안 헤더 PHP 이관, /logout CSRF, 계정 잠금 응답에서
|
|
||||||
사용자명 열거 제거. 엣지 큐 flush 상한, 타이머 env 전수 검증, 프레임 스킵 시 미블러 얼굴
|
|
||||||
유출 차단, systemd 워치독. PDO 통합 테스트 복구, 라우트 테이블 대조, 산출물↔소스 패리티
|
|
||||||
검사, CI 신설. 납품물에 version/git_commit/built_at 스탬프.
|
|
||||||
28
CLAUDE.md
|
|
@ -1,28 +0,0 @@
|
||||||
# move_detect — 작업장 안전 AI CCTV
|
|
||||||
|
|
||||||
Pi5+Hailo-8 엣지(Python `edge/`) → HMAC POST → Cafe24 PHP+MySQL 대시보드(`server/`).
|
|
||||||
납품·폐쇄망 대상 프로젝트 — `~/.claude/rules/common/build-deployment.md` 적용.
|
|
||||||
|
|
||||||
## 시작
|
|
||||||
|
|
||||||
- 위치 참조: `docs/PROJECT_MAP.md` 먼저 읽기 (전체 재스캔 금지). 변경 시 MAP 갱신.
|
|
||||||
- 작업 기록: 단락 종료마다 `docs/INDEX.md`에 결과만 추가.
|
|
||||||
- 감사 상태: `docs/delivery-audit.md` (1차 43/43 + 재감사 12/12 — **적합**, 실장비 OFF-HW 검증만 잔여).
|
|
||||||
|
|
||||||
## 검증 (verify)
|
|
||||||
|
|
||||||
```
|
|
||||||
python -m pytest tests -q # edge (247+)
|
|
||||||
cd server && php -d extension=pdo_sqlite vendor/bin/phpunit # server (162+, skip 0)
|
|
||||||
```
|
|
||||||
|
|
||||||
`-d extension=pdo_sqlite` 없이 돌리면 PDO 통합 테스트(납품되는 실제 SQL)가 통째로 스킵된다.
|
|
||||||
CI(`.forgejo/workflows/verify.yml`)는 같은 명령을 쓰고 스킵이 하나라도 있으면 실패시킨다.
|
|
||||||
|
|
||||||
## 제약
|
|
||||||
|
|
||||||
- ultralytics(AGPL) 금지 — Hailo HEF/MoveNet만. 라이브뷰·영상 전송 금지(스냅샷만, 얼굴 블러).
|
|
||||||
- 감지기 타이머는 monotonic `now`, 이벤트 스탬프만 wall clock.
|
|
||||||
- DB 테이블 prefix `move_`. 프런트 JS는 textContent만(innerHTML 금지), 바닐라(빌드 없음).
|
|
||||||
- UI는 라이트·고밀도 운영 콘솔 방향 확정 (메모리 참조; 다크·미니멀 반려됨).
|
|
||||||
- 원격: Forgejo(`tea` CLI, gh 금지).
|
|
||||||
1
VERSION
|
|
@ -1 +0,0 @@
|
||||||
1.3.0
|
|
||||||
|
|
@ -6,13 +6,11 @@ performs **zero network fetches** at install or run time. The only runtime
|
||||||
network use is the server POST / config-poll / heartbeat, and even that tolerates
|
network use is the server POST / config-poll / heartbeat, and even that tolerates
|
||||||
being offline (events buffer in the local sqlite queue and flush on reconnect).
|
being offline (events buffer in the local sqlite queue and flush on reconnect).
|
||||||
|
|
||||||
> Scope: the delivered bundle is **Hailo-8 + `yolov8s_pose.hef`**, which
|
> Scope: this covers the **CPU MoveNet + USB (UVC) webcam** path — the one the
|
||||||
> `build_offline_bundle.sh` fetches (sha256-pinned) and stages into `models/`.
|
> daemon wires today (`edge/daemon.py:main`) and the one verified end-to-end.
|
||||||
> The CPU MoveNet fallback is **not** bundled: its upstream ONNX URL 404s, so
|
> Hailo-8 acceleration (`edge/adapters/hailo_pose.py`) and the CSI camera
|
||||||
> `install_offline.sh` configures the unit for whatever backend is actually
|
> (`edge/adapters/picamera_source.py`) are separate device-validated paths — see
|
||||||
> present and refuses to install if none is. Camera side: USB (UVC) and CSI are
|
> `docs/hardware-bringup.md`.
|
||||||
> both wired; CSI needs `hailo-all`/`picamera2` from the Pi OS image.
|
|
||||||
> Device validation for both is `docs/hardware-bringup.md` (still OFF-HW).
|
|
||||||
|
|
||||||
## Files
|
## Files
|
||||||
|
|
||||||
|
|
@ -81,17 +79,15 @@ deploy/bundle/build_offline_bundle.sh --cross # target aarch64 / cp311
|
||||||
|
|
||||||
Output: the folder `dist/2_pi_install/` (+ `MANIFEST.txt` listing the bundled
|
Output: the folder `dist/2_pi_install/` (+ `MANIFEST.txt` listing the bundled
|
||||||
wheels). Copy the WHOLE FOLDER across the air gap as-is — no archive step.
|
wheels). Copy the WHOLE FOLDER across the air gap as-is — no archive step.
|
||||||
Set `MAKE_TARBALL=1` if you would rather transfer one file. The tarball is named
|
Set `MAKE_TARBALL=1` if you would rather transfer one
|
||||||
`dist/move-detect-pi-<VERSION>-<YYYYMMDD>-aarch64-cp311-hailo8.tar.gz` — version and
|
`dist/2_pi_install-aarch64-cp311-hailo8.tar.gz` file.
|
||||||
build date so a site can say exactly what it is running, and the arch/ABI/Hailo triple
|
|
||||||
because a hailo8 and a hailo8l bundle are byte-different and crash-loop on the wrong board.
|
|
||||||
|
|
||||||
## 2. Transfer + install (on the air-gapped Pi)
|
## 2. Transfer + install (on the air-gapped Pi)
|
||||||
|
|
||||||
Copy `dist/2_pi_install/` across the gap (USB, one-way transfer, etc.), then:
|
Copy `dist/2_pi_install/` across the gap (USB, one-way transfer, etc.), then:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# tarball variant only (MAKE_TARBALL=1): tar xzf move-detect-pi-<VERSION>-<날짜>-aarch64-cp311-hailo8.tar.gz
|
# tarball variant only (MAKE_TARBALL=1): tar xzf 2_pi_install-aarch64-cp311-hailo8.tar.gz
|
||||||
cd 2_pi_install
|
cd 2_pi_install
|
||||||
sudo ./install_offline.sh
|
sudo ./install_offline.sh
|
||||||
```
|
```
|
||||||
|
|
@ -109,18 +105,17 @@ sudo systemctl enable --now move-detect-edge
|
||||||
journalctl -u move-detect-edge -f
|
journalctl -u move-detect-edge -f
|
||||||
```
|
```
|
||||||
|
|
||||||
`DEVICE_ID`/`DEVICE_SECRET` must match a `move_devices` row in the server (see
|
`DEVICE_ID`/`DEVICE_SECRET` must match a `devices` row in the server (see
|
||||||
`server/docs/server-deploy.md` §6). `HAILO_HEF` points at the bundled model
|
`server/docs/server-deploy.md` §6). `MOVENET_ONNX` should point at the bundled
|
||||||
(`/opt/move-detect/models/yolov8s_pose.hef`); the installer sets it for you and
|
model (`/opt/move-detect/models/movenet_lightning.onnx`).
|
||||||
only falls back to `MOVENET_ONNX` if a MoveNet bundle was built.
|
|
||||||
|
|
||||||
## 4. Verify the air gap (do this before trusting a build)
|
## 4. Verify the air gap (do this before trusting a build)
|
||||||
|
|
||||||
- [ ] On a host with **networking disabled**, `install_offline.sh` completes —
|
- [ ] On a host with **networking disabled**, `install_offline.sh` completes —
|
||||||
the `--no-index` installs prove nothing is fetched. If pip tries to reach
|
the `--no-index` installs prove nothing is fetched. If pip tries to reach
|
||||||
PyPI, a wheel is missing from the bundle: rebuild (step 1) natively.
|
PyPI, a wheel is missing from the bundle: rebuild (step 1) natively.
|
||||||
- [ ] Step [4/6] of the installer prints `edge import OK` (cv2 + numpy + requests
|
- [ ] Step [4/6] of the installer prints `edge import OK` (onnxruntime + cv2 +
|
||||||
import from the venv; onnxruntime only if a MoveNet bundle was built).
|
numpy + requests all import from the venv).
|
||||||
- [ ] `systemctl status move-detect-edge` is `active (running)`; the log shows
|
- [ ] `systemctl status move-detect-edge` is `active (running)`; the log shows
|
||||||
frames processed and (if the server is reachable) heartbeats.
|
frames processed and (if the server is reachable) heartbeats.
|
||||||
- [ ] Pull the network cable → events keep queueing (`QUEUE_DB` grows) → restore
|
- [ ] Pull the network cable → events keep queueing (`QUEUE_DB` grows) → restore
|
||||||
|
|
@ -171,10 +166,6 @@ Each `camera_id` must match the `camera_id` on that camera's zones in the server
|
||||||
## Not bundled (by design)
|
## Not bundled (by design)
|
||||||
|
|
||||||
- **MySQL / the server app** — a separate host; see `server/docs/server-deploy.md`.
|
- **MySQL / the server app** — a separate host; see `server/docs/server-deploy.md`.
|
||||||
- **HailoRT + picamera2** — Hailo's runtime/driver and the CSI camera stack are Pi
|
- **HailoRT + `yolov8s_pose.hef` + picamera2** — Hailo's runtime/driver, the compiled
|
||||||
system packages, installed out-of-band (above). Note the HEF itself **is** bundled
|
HEF, and the CSI camera stack are Pi system packages installed out-of-band (above);
|
||||||
(`models/yolov8s_pose.hef`); only the driver is not.
|
the default CPU MoveNet + USB path needs none of them.
|
||||||
- **`python3-venv`** — `install_offline.sh` runs `python3 -m venv`, which Pi OS Lite
|
|
||||||
does not always include. Install it with the Hailo packages, before going air-gapped.
|
|
||||||
- **MoveNet ONNX (CPU fallback)** — its upstream URL 404s, so the bundle is Hailo-only.
|
|
||||||
If you need the CPU path, supply the ONNX to the build machine and rebuild.
|
|
||||||
|
|
|
||||||
|
|
@ -155,26 +155,8 @@ sort -u -o "$STAGE/requirements.lock" "$STAGE/requirements.lock"
|
||||||
# air gap. Hailo needs the HEF (present) + system hailo_platform on the Pi; MoveNet needs the ONNX.
|
# air gap. Hailo needs the HEF (present) + system hailo_platform on the Pi; MoveNet needs the ONNX.
|
||||||
HAS_ONNX=no; [ -f "$STAGE/models/movenet_lightning.onnx" ] && HAS_ONNX=yes
|
HAS_ONNX=no; [ -f "$STAGE/models/movenet_lightning.onnx" ] && HAS_ONNX=yes
|
||||||
SIGNED=no; [ -n "${SIGN_KEY:-}" ] && SIGNED=yes
|
SIGNED=no; [ -n "${SIGN_KEY:-}" ] && SIGNED=yes
|
||||||
# Provenance. Without this, a bundle is unidentifiable once it leaves the build machine: the
|
|
||||||
# SHA256SUMS inside a STALE bundle is perfectly self-consistent, so integrity checks pass while
|
|
||||||
# the contents are months behind the source. version+commit is how a site reports what it runs.
|
|
||||||
# VERSION is mandatory (rules/release-versioning.md). An "unknown" bundle cannot be matched to
|
|
||||||
# a commit after it leaves the build machine, which is exactly when the question gets asked.
|
|
||||||
[ -f "$REPO_ROOT/VERSION" ] || {
|
|
||||||
echo "ERROR: $REPO_ROOT/VERSION is missing. Refusing to build an unversioned bundle." >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
BUNDLE_VERSION="$(tr -d '[:space:]' < "$REPO_ROOT/VERSION")"
|
|
||||||
[ -n "$BUNDLE_VERSION" ] || { echo "ERROR: VERSION is empty." >&2; exit 1; }
|
|
||||||
GIT_REV="$(cd "$REPO_ROOT" && git rev-parse --short HEAD 2>/dev/null || echo unknown)"
|
|
||||||
GIT_DIRTY=""
|
|
||||||
(cd "$REPO_ROOT" && git diff --quiet HEAD -- edge deploy 2>/dev/null) || GIT_DIRTY=" (uncommitted changes)"
|
|
||||||
BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
||||||
{
|
{
|
||||||
echo "bundle: move-detect-edge"
|
echo "bundle: move-detect-edge"
|
||||||
echo "version: $BUNDLE_VERSION"
|
|
||||||
echo "git_commit: ${GIT_REV}${GIT_DIRTY}"
|
|
||||||
echo "built_at: $BUILD_DATE"
|
|
||||||
echo "built_mode: $MODE"
|
echo "built_mode: $MODE"
|
||||||
echo "python: $PYVER ($PYABI)"
|
echo "python: $PYVER ($PYABI)"
|
||||||
echo "target_arch: $TARGET_ARCH"
|
echo "target_arch: $TARGET_ARCH"
|
||||||
|
|
@ -215,7 +197,7 @@ if [ "${MAKE_TARBALL:-0}" != "1" ]; then
|
||||||
fi
|
fi
|
||||||
# Embed the Hailo arch in the filename: hailo8 and hailo8l bundles are byte-different but were
|
# Embed the Hailo arch in the filename: hailo8 and hailo8l bundles are byte-different but were
|
||||||
# otherwise identically named, so the wrong one could be shipped to a board and crash-loop.
|
# otherwise identically named, so the wrong one could be shipped to a board and crash-loop.
|
||||||
TARBALL="$OUT_DIR/move-detect-pi-${BUNDLE_VERSION}-$(date -u +%Y%m%d)-${TARGET_ARCH}-${PYABI}-${HAILO_ARCH}.tar.gz"
|
TARBALL="$OUT_DIR/2_pi_install-${TARGET_ARCH}-${PYABI}-${HAILO_ARCH}.tar.gz"
|
||||||
tar -C "$OUT_DIR" -czf "$TARBALL" "2_pi_install"
|
tar -C "$OUT_DIR" -czf "$TARBALL" "2_pi_install"
|
||||||
echo ">> DONE: $TARBALL"
|
echo ">> DONE: $TARBALL"
|
||||||
echo ">> transfer this single file across the air gap, then run install_offline.sh on the Pi."
|
echo ">> transfer this single file across the air gap, then run install_offline.sh on the Pi."
|
||||||
|
|
|
||||||
|
|
@ -19,15 +19,12 @@ CAFE24_URL=https://your-domain.example/move_detect
|
||||||
CAMERA_ID=cam-01
|
CAMERA_ID=cam-01
|
||||||
|
|
||||||
# --- pose backend / capture ---
|
# --- pose backend / capture ---
|
||||||
# POSE_BACKEND: "hailo" (Pi 5 + AI HAT+, needs HAILO_HEF) or "movenet" (CPU, needs MOVENET_ONNX).
|
# POSE_BACKEND: "movenet" (CPU, needs MOVENET_ONNX) or "hailo" (needs HAILO_HEF).
|
||||||
# The delivered bundle carries the HEF only — the MoveNet ONNX has no reachable upstream, so a
|
POSE_BACKEND=movenet
|
||||||
# movenet unit would point at a file that is not there and fail to start. install_offline.sh
|
# MOVENET_ONNX: required when POSE_BACKEND=movenet.
|
||||||
# rewrites these to match whatever the bundle actually contains; edit them only if you know the
|
MOVENET_ONNX=/opt/move-detect/models/movenet_lightning.onnx
|
||||||
# file exists on this device.
|
# HAILO_HEF: required when POSE_BACKEND=hailo (uncomment and set).
|
||||||
POSE_BACKEND=hailo
|
#HAILO_HEF=/opt/move-detect/models/yolov8s_pose.hef
|
||||||
HAILO_HEF=/opt/move-detect/models/yolov8s_pose.hef
|
|
||||||
# MOVENET_ONNX: required only when POSE_BACKEND=movenet (CPU fallback; not bundled by default).
|
|
||||||
#MOVENET_ONNX=/opt/move-detect/models/movenet_lightning.onnx
|
|
||||||
# CAMERA_BACKEND: "uvc" (USB webcam, uses UVC_DEVICE) or "csi" (Pi CSI camera, uses CSI_DEVICE).
|
# CAMERA_BACKEND: "uvc" (USB webcam, uses UVC_DEVICE) or "csi" (Pi CSI camera, uses CSI_DEVICE).
|
||||||
CAMERA_BACKEND=uvc
|
CAMERA_BACKEND=uvc
|
||||||
# UVC_DEVICE: OpenCV VideoCapture index for the USB (UVC) webcam.
|
# UVC_DEVICE: OpenCV VideoCapture index for the USB (UVC) webcam.
|
||||||
|
|
|
||||||
|
|
@ -43,21 +43,6 @@ ExecStart=/opt/move-detect/venv/bin/python -m edge
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|
||||||
# Catch the failure Restart=always CANNOT: alive but wedged. run_loop pings WATCHDOG=1 at the
|
|
||||||
# top of every tick, so a blocked camera read / accelerator call / sqlite lock stops the pings
|
|
||||||
# and systemd restarts the unit instead of leaving an unattended site silently unmonitored.
|
|
||||||
# 120s, not a tight value: one tick may legitimately include an offline-queue flush slice whose
|
|
||||||
# POSTs each carry a 5s timeout. Too tight would restart a HEALTHY daemon mid-recovery.
|
|
||||||
WatchdogSec=120
|
|
||||||
|
|
||||||
# Memory ceiling. The evidence ring buffers ~90 frames per camera, so a mis-set capture size or
|
|
||||||
# a leak grows RSS until the kernel OOM killer fires — and the kernel may kill something else
|
|
||||||
# entirely, leaving the Pi in an unpredictable state. Let systemd kill THIS unit instead, where
|
|
||||||
# Restart=always brings it straight back. MemoryHigh throttles first to ride out a brief spike.
|
|
||||||
MemoryHigh=1200M
|
|
||||||
MemoryMax=1500M
|
|
||||||
OOMPolicy=restart
|
|
||||||
|
|
||||||
# Writable state (offline queue sqlite lives here; keep QUEUE_DB pointed at it).
|
# Writable state (offline queue sqlite lives here; keep QUEUE_DB pointed at it).
|
||||||
# systemd creates/owns /var/lib/move-detect for the service user.
|
# systemd creates/owns /var/lib/move-detect for the service user.
|
||||||
StateDirectory=move-detect
|
StateDirectory=move-detect
|
||||||
|
|
|
||||||
222
docs/INDEX.md
|
|
@ -1,222 +0,0 @@
|
||||||
# INDEX — 작업 기록 (완료 결과만)
|
|
||||||
|
|
||||||
- **2026-08-18** **VERSION 1.0.0 — 첫 정식 납품 버전** — `9596d1b`.
|
|
||||||
산출물: `dist/move-detect-cafe24-1.0.0-20260818.zip` ·
|
|
||||||
`dist/move-detect-pi-1.0.0-20260818-aarch64-cp311-hailo8.tar.gz`
|
|
||||||
(둘 다 스탬프 version 1.0.0 / git_commit 9596d1b).
|
|
||||||
릴리스 규칙 위반 3건 시정: 산출물 파일명에 버전·날짜가 없던 것, VERSION 부재 시
|
|
||||||
'unknown' 으로 빌드되던 것(이제 exit 1), 문서의 stale 파일명.
|
|
||||||
납품 동봉물 확충: CHANGELOG.md 신설 · 역할별 사용 설명서(3역할 14절 + FAQ 6항, 실제 화면
|
|
||||||
캡처 8장 base64 임베드 단일 HTML 665KB — 소스는 `server/deploy/manual/`, 생성물은
|
|
||||||
커밋하지 않고 패키저가 매번 빌드) · 감사 리포트를 '납품점검-리포트.md' 로 동봉.
|
|
||||||
**README-UPLOAD 에 크론 안내 신설** — 설치 안내서에 자동 실행 등록이 아예 없어 이번
|
|
||||||
릴리스의 핵심인 통보가 한 건도 나가지 않을 뻔했다(8·9번 절 + 체크리스트 3항목).
|
|
||||||
부수: 셸 스크립트 CRLF 로 bash 가 죽던 것 정규화 + Pi 쪽에 없던 CRLF·`bash -n` 가드 추가.
|
|
||||||
검증: pytest 통과(skip 3) · PHPUnit 224 (skip 0) · 매뉴얼 실렌더 · 패키지 내부 검증
|
|
||||||
(문서 4종 동봉, 매뉴얼 외부 참조 0·임베드 이미지 8, 마이그레이션 8/8, config.php 는
|
|
||||||
CHANGEME 템플릿, tools/ 는 www 밖, 웹 루트 PHP 는 2개, Pi 휠 11 + HEF 1 + SHA256SUMS).
|
|
||||||
|
|
||||||
- **2026-08-18** 납품 재감사 — `7daee0b`. 확장된 표면(라우트 17·표 6종·CLI 도구 5·엣지 모듈 4·
|
|
||||||
통보 발송·감사 해시체인)을 8개 관점으로 재감사, 확정 12건 전량 해소 (CRITICAL 3 · HIGH 3 ·
|
|
||||||
MEDIUM 4 · LOW 2). 최대 발견은 **신규 감지 유형이 ingest 검증기에만 빠져 카메라 무결성
|
|
||||||
감지가 end-to-end 무력화**되어 있던 것 — DB CHECK·프런트·엣지는 다 갱신됐는데 가운데
|
|
||||||
계층만 빠졌고, 파이썬·PHP 테스트가 각자 절반만 보기 때문에 양쪽 다 통과했다.
|
|
||||||
재발 방지로 언어 경계를 넘는 파리티 테스트 신설(`tests/test_event_type_parity.py`).
|
|
||||||
그 외 운영 DB 파괴 가능한 `--purge` 범위, 백업이 docroot 자체에 떨어지던 경로 가드,
|
|
||||||
통보 메일 시각이 콘솔과 9시간 어긋나던 타임존, MariaDB 에서 실패하는 `DROP CHECK`.
|
|
||||||
판정: **적합** (`docs/delivery-audit.md` Phase 6). 잔여는 실장비 OFF-HW 5건.
|
|
||||||
검증: PHPUnit 224 (skip 0) · pytest 통과 · 양 산출물 재생성 + 패리티 통과.
|
|
||||||
|
|
||||||
- **2026-08-09** 안전 플랫폼 확장 — `e00ad69`. 로드맵 23건 중 소프트웨어로 완결 가능한 19건 반영.
|
|
||||||
마이그레이션 `2026-08-09-safety-platform.sql` (표 6종 신설 + 컬럼 4종 + event_type CHECK 확장).
|
|
||||||
**통보(제품 정의 수준의 구멍)**: 지금까지 감지는 브라우저를 보고 있는 사람에게만 전달됐다.
|
|
||||||
① 엣지 GPIO 경광등(`edge/beacon.py`) — 네트워크 없이 동작하는 유일한 경로, /api/config 로 설정 수신
|
|
||||||
② 메일·웹훅 채널 + 크론 디스패처(`tools/dispatch_alerts.php`, `--dry-run` 지원)
|
|
||||||
③ 미확인 위급 → `unacked_escalation` 이벤트 발행(멱등) → 상위 채널로 에스컬레이션.
|
|
||||||
중복 발송은 (event,channel) UNIQUE 로 막는다(크론 중첩 실행 대비).
|
|
||||||
**조용한 실패 제거**: 카메라 화각 이동·가림·조도 저하 감지(`detectors/integrity.py`) —
|
|
||||||
존 판정이 무의미해진 것을 처음으로 알 수 있게 됨. 스토리지 여유 감시.
|
|
||||||
**엣지에 닿지 않던 배선 2건 수리**: `thresholds_from_config()` 가 파싱만 하고 버려서 임계값이
|
|
||||||
장비에 적용되지 않던 것(`edge/tuning.py`), 존/임계값 저장 시 `config_version` 을 올리지 않아
|
|
||||||
설정 드리프트 표시가 영원히 꺼져 있던 것.
|
|
||||||
**관리 화면 신설**: 임계값 편집(카탈로그 기반 범위 강제 — 엣지가 기동 시 거부하는 값을 키보드
|
|
||||||
앞에서 막는다) · 통보 채널 · 정비 억제(사유 필수·최대 8시간·감지는 계속되고 통보만 보류) ·
|
|
||||||
위험성평가 · 교대 인수인계 · 설비 성능 추이 · 설치 점검(`/setup`, 실제 상태 조회 기반).
|
|
||||||
**증거·규정**: 감사로그 해시체인(수정·삭제·우회삽입 전부 탐지, `tools/verify_audit_chain.php`) ·
|
|
||||||
사고조사 패키지 1클릭(전후 타임라인+통보 이력+위험요인+조치 이력+서명란, 인쇄 CSS) ·
|
|
||||||
이벤트→조치 직접 기록 · 현장별 롤업(전역 계정만).
|
|
||||||
**운영**: 일괄 확인(위급 제외·2단계) · 키보드 트리아지(J/K/A/F/Enter) · 모바일 레이아웃
|
|
||||||
(44px 타깃) · 보존기간 정리(`tools/maintain.php`) · 백업/복구(`tools/backup.php`, www 안 출력 거부) ·
|
|
||||||
데모 시드(`tools/seed_demo.php`).
|
|
||||||
검증: pytest 통과(3 skip = 하드웨어·환경) · PHPUnit 221 (skip 0, +59) · Chrome 실렌더로
|
|
||||||
신규 4화면 + 임계값 저장→config_version 증가→감사 기록→체인 검증→변조 탐지 전 경로 확인 ·
|
|
||||||
cafe24 번들 + Pi 오프라인 번들(aarch64 휠 11개) 재생성, 소스 패리티 통과.
|
|
||||||
**미착수 4건(모델·하드웨어 선행)**: 보호구·중장비 근접·화재 감지는 AGPL 아닌 가중치 확보와
|
|
||||||
HEF 컴파일이 관문이라 감지기 배선·이벤트 계약·테스트만 완성하고 기본 비활성으로 둠
|
|
||||||
(`edge/detectors/pending.py`). PLC 접점 협착 게이팅도 같은 이유로 인터페이스만.
|
|
||||||
|
|
||||||
- **2026-08-09** UI/UX 전면 점검 후 21건 일괄 반영 (동선 7 · 전달 9 · 시각 5) — `e00ad69`
|
|
||||||
(위 안전 플랫폼 확장과 같은 커밋: 후자가 전자에서 고친 화면 위에 얹혀 있다).
|
|
||||||
**동선**: ① 스트림 정렬을 '미확인 우선'에서 **엄격 시간순**으로 되돌림 — 확인 상태가 연대기를
|
|
||||||
뒤집어 사고 재구성이 불가능했고, 확인 시 행이 목록 밖으로 사라져 아래 행이 밀려 올라오며
|
|
||||||
오클릭으로 다른 이벤트가 확인 처리될 수 있었다. 확인은 제자리 전환 + 확정 토스트로 변경.
|
|
||||||
② 처리 큐는 정렬이 아니라 뷰모드(전체/미확인만) 필터로 분리. ③ 이벤트 상세에 확인/오탐 액션
|
|
||||||
신설(판정 화면에 판정 도구가 없던 문제). ④ 오탐·구역삭제 2단계 확정(되돌릴 수 없는 동작).
|
|
||||||
⑤ 구역 설정에 장치·카메라 선택기(자유 텍스트 ID 암기 제거, 가짜 플레이스홀더 제거).
|
|
||||||
⑥ 날짜 조회(키셋 커서 시딩, 최대 6페이지) — 사고 조사 진입로. ⑦ 경보음 세션 음소거
|
|
||||||
(새 위급 도착 시 자동 해제).
|
|
||||||
**전달**: KPI 스트립에 실제 집계 범위 표기(24h 브리프와 숫자 충돌 해소), 중대=위급+높음
|
|
||||||
인라인 정의, 페이지 명칭 3종·날짜 포맷 3종 통일, '확인 필요' 칩을 전 심각도로 확장,
|
|
||||||
상세 `CONFIDENCE`→`검출 신뢰도 %`, 오탐율·시간패턴 표본 게이트, 개선조치 효과에 푸아송
|
|
||||||
잡음 범위(√before) 판정 보류, 카메라 위험 순위 강조 기준을 상대값으로, 날짜 구분선.
|
|
||||||
**시각**: 구역 설정·이벤트 상세를 카드 셸로 재구성(둘 다 스타일 없는 기본 폼이었음),
|
|
||||||
히트맵 토글 aria-pressed 시각 상태 신설(CSS 규칙 자체가 없었음), 존 색을 팔레트 정합
|
|
||||||
(팔레트 외 보라 제거, 채움+라벨칩+협착 점선), 주 동작 accent 승격, 장치명 플리커 제거.
|
|
||||||
검증: pytest 통과 · PHPUnit 162 (skip 0) · Chrome 실렌더로 4개 화면 + 확인/오탐/날짜조회/
|
|
||||||
삭제 2단계/히트맵 토글 실동작 확인 · cafe24 번들 재생성(소스 패리티 통과).
|
|
||||||
**미해결(설계 판단)**: 확인 취소(undo)는 넣지 않음 — `acknowledged_by`는 감사 로그가 남는
|
|
||||||
추가 전용 기록이라, 무음 되돌리기는 안전 시스템에서 더 나쁜 속성. 서버에 unack 라우트를
|
|
||||||
두는 안은 별도 결정 필요.
|
|
||||||
|
|
||||||
- **2026-08-08** 감사 잔여 확정 항목 일괄 처리 (VERSION 0.9.0 신설). **보안**: CSP nonce
|
|
||||||
도입(인라인 2블록, unsafe-inline 없음)+보안헤더를 PHP로 이관(개발/납품 동일), /logout CSRF,
|
|
||||||
계정 잠금 423→401(사용자명 열거 제거). **엣지**: 큐 flush 배치 상한(정전 복구 시 감지 정지
|
|
||||||
해소), 타이머 env 전수 NaN/음수 검증, T2 프레임 스킵(`INFER_EVERY`, 스킵 틱은 링버퍼 미투입
|
|
||||||
=미블러 얼굴 유출 차단), systemd 워치독(sd_notify)+메모리 상한, UVC 해상도·버퍼 고정.
|
|
||||||
**테스트**: PDO 통합 테스트 스킵 해소(`-d extension=pdo_sqlite`, 리포트·히트맵·익스포트
|
|
||||||
집계 SQL 최초 실행), 얼굴 블러 테스트를 실제 증거로 교체(무동작 퇴화 감지), index.php 라우트
|
|
||||||
테이블 대조(프런트 호출 15개 교차검증), dist↔소스 패리티 테스트, CI 신설
|
|
||||||
(`.forgejo/workflows/verify.yml`, 스킵 발생 시 실패). **프런트**: 이벤트 목록 시그니처 기반
|
|
||||||
재렌더 스킵+포커스 복원(5초마다 포커스 소실·전체 재낭독 해소), 폴링 중첩 가드, 콜드스타트
|
|
||||||
실패 표시. **문서**: deploy/README·env.example을 Hailo 단독 번들 실제와 일치, bringup의
|
|
||||||
미존재 설정 키를 실제 env 노브로 교체. 납품물에 version/git_commit/built_at 스탬프.
|
|
||||||
pytest 251 / PHPUnit 162 (스킵 0).
|
|
||||||
|
|
||||||
- **2026-08-08** 프로젝트 8개 차원 감사(종합 82/100) 후 상위 3건 수정. ①개선 조치 site 스코프
|
|
||||||
누락(스코프 운영자가 타 현장 조치 기록 열람·기록 가능) → `move_actions.site` 추가
|
|
||||||
(`2026-08-08-actions-site.sql`, 이벤트에서 카메라 소유 현장 해석, 오타 카메라 400·타 현장
|
|
||||||
404). ②README-UPLOAD 업그레이드 절차가 마이그레이션 1/7만 지시 → 순서·적용조건 표로 교체
|
|
||||||
(`move_` 접두 리네임 누락 시 전 요청 500). ③두 납품 번들 재생성(4커밋 스테일 해소 —
|
|
||||||
Report/Export/Actions 컨트롤러·마이그레이션 누락 복구, 엣지 소스 패리티 회복).
|
|
||||||
문서 테이블 수·프리픽스 표기 정정. pytest 235 / PHPUnit 147.
|
|
||||||
|
|
||||||
- **2026-08-07** 데이터 활용 5안 중 4안 구현: ②시간패턴 히트맵+전조 급증 경보 ③KPI 전기간
|
|
||||||
추이(`d8b8e1f`) ①라벨 JSONL 익스포트(`/api/export/labels`, 관리자) ④개선 조치 기록·전후
|
|
||||||
효과 비교(`move_actions`, 동일 창 비교, 3일 미만 "측정 중"). ⑤멀티사이트 벤치마크는 다현장
|
|
||||||
이후(사이트 스코프 구조가 이미 수용). PHPUnit 145 + Chrome 실렌더.
|
|
||||||
|
|
||||||
- **2026-08-07** 증거 타임라인 + 24시간 캐치업: 이벤트 스냅샷이 링버퍼 기반 과거→발생
|
|
||||||
3장(프레임별 자기 시점 얼굴 블러, P1-B 잔여 종결), 상세뷰 이전/직전/발생 캡션. 콘솔
|
|
||||||
첫 진입 '지난 24시간' 요약 배너(/api/report?days=1 재사용, 리포트 링크). pytest 235/
|
|
||||||
PHPUnit 138 + Chrome 실렌더.
|
|
||||||
|
|
||||||
- **2026-08-07** 안전 운영 리포트 `/report` (주간 안전회의용 인쇄 가능 한 장: 중대 이벤트·
|
|
||||||
TTA(FP 제외)·니어미스 피라미드·오탐율·유형/카메라 순위·인체공학 노출·존 규칙 위반) +
|
|
||||||
이벤트 스트림 유형 필터. PHPUnit 138 + Chrome 실렌더(KPI/바리스트/타일/필터).
|
|
||||||
|
|
||||||
- **2026-08-06** 인체공학 자세부담 스코어링 (`ergo_report`/INFO, 카메라 단위 익명·스냅샷
|
|
||||||
미부착): REBA 밴드 노출시간(허리 20~60°/60°+·팔 90°+·웅크림<110°), 사고 상태 트랙 제외,
|
|
||||||
검출 공백 dt 상한. 상세뷰 지표 한글 렌더 — `b358822`. pytest 232/PHPUnit 135 + 실렌더.
|
|
||||||
- **2026-08-06** 납품 번들 재생성: `dist/1_cafe24_upload` + `move-detect-cafe24.zip`
|
|
||||||
(마이그레이션 5종 동봉 — 빌드 스크립트에 staging 추가) · `dist/2_pi_install` --cross 재빌드
|
|
||||||
(aarch64/cp311 휠 11종 + HEF 다이제스트 검증 + SHA256SUMS).
|
|
||||||
**FLAG:** MoveNet ONNX 업스트림 URL 404 → 이번 Pi 번들은 Hailo 단독(CPU 폴백 모델 미포함).
|
|
||||||
`edge/tools/fetch_movenet.py`의 소스 URL 갱신 필요.
|
|
||||||
|
|
||||||
- **2026-08-06** 오탐 피드백 (결정 a: 통계만, 자동 튜닝 없음): 이벤트 오탐 태그
|
|
||||||
(`POST /events/{id}/false-positive`, ack 겸함, 최초 태거 보존) + `GET /api/fp-stats` +
|
|
||||||
콘솔 오탐율 패널(30일, 30%+ 강조). PHPUnit 135 + Chrome 실렌더(태그→행 전환→패널 즉시 갱신).
|
|
||||||
|
|
||||||
- **2026-08-06** 대시보드 장치→카메라 트리 레일 + (device,camera) 쌍 카메라 필터 —
|
|
||||||
`eaff176`. PHPUnit 119 통과 + Chrome 실렌더 검증(트리 상태·필터·접기·콘솔 무오류).
|
|
||||||
- **2026-08-06** 감사 잔여 P2-6/7/8 종결 (ID 스왑 게이트 · sustained-prone FALLING ·
|
|
||||||
stale 트랙 no-data) — `32f2237`. TDD(RED 확인), pytest 203 통과/2 skip.
|
|
||||||
→ delivery-audit 오프하드웨어 항목 43/43 완료. 잔여는 실장비(OFF-HW) 검증뿐.
|
|
||||||
- **2026-08-06** 연속성 문서 신설: `CLAUDE.md` · `docs/PROJECT_MAP.md` · 본 INDEX.
|
|
||||||
- **2026-08-06** 니어미스 히트맵: `near_fall`(FALLING→확정 전 회복, INFO) + 전 트랙 이벤트에
|
|
||||||
정규화 발좌표(spatial) 부착 → `move_events.spatial_x/y` 적재 → `GET /api/heatmap/{camera}` →
|
|
||||||
존 편집기 '위험 히트맵' 오버레이. **직전 커밋의 chk_events_type 제약 누락(신규 타입 INSERT
|
|
||||||
거부) 마이그레이션으로 수정.** pytest 222/PHPUnit 130 + Chrome 실렌더.
|
|
||||||
- **2026-08-06** 존 점유 규칙 엔진 (2인1조 `lone_worker`/HIGH · 정원 `overcapacity` ·
|
|
||||||
체류상한 `overstay`): edge `occupancy.py` + `move_zones.rules_json` + 존 편집기 규칙 입력 +
|
|
||||||
이벤트 유형 한글 라벨. pytest 216/PHPUnit 123 통과 + Chrome 실렌더(편집기 저장·목록·콘솔 라벨).
|
|
||||||
|
|
||||||
- 2026-08-24 — 소개서 이미지 브리프에 감지 시점 설명 이미지 4종 추가(§B 신설: 넘어짐 감지 시퀀스·앉기 vs 넘어짐·구역 판정·2인1조). 수치는 edge/detectors 실코드 대조. 발주 총 11장(P0 6장). `107f7d9`
|
|
||||||
- 2026-08-25 — device_fault·monitoring_degraded 구현(App\Alert\DeviceHealthWatch, dispatch_alerts 1분 크론 1단계). 전 구간에 있으나 아무도 발행하지 않던 2종 — 소개서 "17종"이 실제 15종이던 문제 해소. 장애당 1건(uuid 앵커=장애 시작), 저하는 5분 지속 요구, 미설치 노드 제외. camera_id 센티넬 `__device__`. 검증 pytest 308 / phpunit 240(신규 16). `9c0cfd2`
|
|
||||||
- 2026-08-25 — 도판 11장 수령·최적화(37.6MB→3.3MB) 후 소개서 HTML 재생성(11장 배치). 경영진 보고 덱 신설(docs/deck/, 14장, 생성기 방식) + 3초 영상 브리프 6종. 실렌더 검증에서 백지 슬라이드·한국어 어절 끊김·스냅 컨테이너 smooth 스크롤 취소 등 6건 교정, 접근성 대비 실패 0건. `9ee3dab`
|
|
||||||
- 2026-08-28 — P0 클립 2종(v01 넘어짐·경광등, v02 앉기·정상) 배치. HEVC라 브라우저 디코딩 실패(videoWidth 0) → prepare_clips.py 신설, VP9/WebM 변환 + 클립 마지막 프레임 포스터 자동 추출(cv2.imwrite 한글경로 실패 우회). 비교 슬라이드 2패널 전환. 재생 진행은 자동화 환경 제약으로 미검증 — 발표 노트북 확인 필요. `dea12a2`
|
|
||||||
|
|
||||||
## 2026-09-04 — 1.1.0 릴리스 (서버 전용)
|
|
||||||
|
|
||||||
- **버전** 1.0.0 → 1.1.0. `edge/`는 1.0.0 이후 불변이라 서버 전용 릴리스. dist 재생성으로 DeviceHealthWatch가 이미 패키지에 들어가 있는데 VERSION이 1.0.0이던 불일치를 해소. `bebf426`
|
|
||||||
- **3차 재감사** — 1.1.0 신규 표면(장치 건전성) 4개 관점 병렬 감사, 확정 14건 전량 해소·반증 4건 기록. CRITICAL 2건은 이번 기능이 만든 회귀였다: 통보 전체를 죽일 수 있던 크론 미격리, 간헐 고장에 감지기가 양방향으로 무너지던 문제. `43a3d06`
|
|
||||||
- **산출물** `move-detect-cafe24-1.1.0-20260904.zip` (BUILD-INFO git_commit=43a3d06, 문서 4종 동봉: README-UPLOAD·CHANGELOG·납품점검-리포트·사용설명서)
|
|
||||||
- **보안** 실제 자격증명 파일을 납품 폴더 `dist/` 밖(`secrets/`)으로 이동. **잔여: 대시보드 비밀번호·장치 HMAC 키 교체 권고**
|
|
||||||
- **Pi 번들** 미갱신 — edge 불변이라 내용은 동일하나 파일명 표기가 1.0.0. arm64+네트워크 빌드 머신에서 재빌드 필요(Pi 납품 시)
|
|
||||||
- 검증: pytest 308 passed(3 skip: 환경) · phpunit 250 passed(신규 10)
|
|
||||||
|
|
||||||
- 2026-09-04 — 신규 감지 3종 설계안 작성(`docs/설계-신규감지-3종.md`): 추락(fall_from_height)·동시 다발 쓰러짐(multi_down)·구역 프리셋. 셋 다 새 추론 모델 불필요. 설계 중 확인한 잠복 결함: `max_workers: 0`이 falsy로 걸러져 "아무도 있으면 안 되는 구역"을 표현할 수 없음(occupancy.py).
|
|
||||||
|
|
||||||
## 2026-09-05 — 1.2.0 (추락 · 동시 다발 쓰러짐 · 구역 프리셋)
|
|
||||||
|
|
||||||
- **추락(`fall_from_height`)** — 사망 1위 유형인 "떨어짐"을 못 잡던 공백 해소. 판정은 bbox 중심이 아니라 **발 밑선(y2)**(쭈그려 앉기와 구분되는 유일한 신호). 프레임 안 확정은 넘어짐 유형을 승격, 화각 이탈은 ghost 경로. 심각도 CRITICAL(척추 손상 주의가 통보에 실려야 함). `edge/detectors/height.py` 신규
|
|
||||||
- **동시 다발 쓰러짐(`multi_down`)** — 같은 구역 짧은 시간 2명 이상 = 질식·가스·폭발. 기능의 본체는 통보 문구("공기호흡기 없이 진입하지 마십시오"). 서버측 감시자. 오탐 방어는 근접성(구역 또는 좌표 거리)
|
|
||||||
- **구역 프리셋 6종** — 밀폐공간·고소작업·프레스협착·출입금지·지게차통로·화기작업. `GET /api/zone-presets`
|
|
||||||
- **수정** `max_workers: 0`이 falsy로 걸러져 "아무도 있으면 안 되는 구역"(크레인 하부·선회 반경)이 조용히 꺼져 있던 문제. 감지기·입력란·직렬화·표시 4곳
|
|
||||||
- 산출물 `move-detect-cafe24-1.2.0-20260905.zip`
|
|
||||||
- **⚠ Pi 번들은 이번 릴리스에서 필수 재빌드** — edge/ 가 바뀌었다. 낡은 1.0.0 번들은 제거했다(추락 감지가 빠진 노드가 납품되는 것을 막기 위해). arm64+네트워크 빌드 머신에서 재빌드해야 Pi 납품 가능
|
|
||||||
- 검증: pytest 322 passed(5 skip: 환경·Pi번들 미빌드) · phpunit 263 passed
|
|
||||||
|
|
||||||
- 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`
|
|
||||||
|
|
||||||
- 2026-09-18 — 설계 도해 3종 작성(archify, showcase 9/9 · 오류 0 · 경고 0, 1440~2048 4해상도 양 테마 브라우저 검증):
|
|
||||||
시스템 구성 · 낙상 시퀀스 · 낙상 상태기계. 시퀀스를 그리다 확인한 사실 — **경광등(3초)과 메일(최대 63초)은
|
|
||||||
같은 "통보"가 아니다.** 상태기계 쪽은 확정 창 3초와 추락 연결 창 3초가 겹쳐 있음을 한 장으로 보여준다
|
|
||||||
(4차 감사의 CRITICAL, 시간을 그렸다면 코드보다 먼저 보였을 충돌). HTML 은 gitignore, 소스 JSON 만 커밋.
|
|
||||||
|
|
||||||
## 2026-09-21 — 납품 정비 (잔여 3건 해소)
|
|
||||||
|
|
||||||
- **Pi 번들 재빌드** `move-detect-pi-1.2.0-20260921-aarch64-cp311-hailo8.tar.gz` (82MB).
|
|
||||||
arm64 빌드 머신이 필요하다고 봤던 것은 과한 가정이었다 — `--cross` 로 pip 이 태그를 보고
|
|
||||||
aarch64/cp311 휠을 받으므로 네트워크만 있으면 된다. 휠 11개 전부 aarch64. 이걸로
|
|
||||||
"Pi 번들 미빌드" 로 스킵되던 테스트가 실제로 돌아 스킵이 5 → 2로 줄었다.
|
|
||||||
- **MoveNet CPU 폴백 복구** — P3-3 의 잔여였던 죽은 원본을 유효한 tf2onnx 변환본으로 교체·고정
|
|
||||||
(sha256 + size). 고정 **전에** 어댑터 계약과 대조했다: 입력 `[1,192,192,3] int32` NHWC,
|
|
||||||
출력 `[1,1,17,3]`. 레이아웃이 다른 변환본은 로드는 되고 좌표만 틀리므로 그 계약을 테스트로
|
|
||||||
박았다. 실사진 2장으로 코<어깨<엉덩이<발목 순서까지 확인. `edge/tools/fetch_movenet.py`
|
|
||||||
- **자격증명 회전 도구** `server/tools/rotate_credentials.php` — 손으로 적던 SQL 절차를 명령
|
|
||||||
하나로. DB 에 접속하지 않고 붙여넣을 SQL 만 낸다(운영자의 창구가 phpMyAdmin 이므로).
|
|
||||||
비밀번호는 STDIN 으로만 받는다 — 명령행은 `ps` 와 셸 히스토리에 남는다. 테스트가 잠그는
|
|
||||||
것은 "서버 SQL 의 키와 Pi 에 넣으라는 키가 같은가"와 "앱이 그 해시를 검증하는가" 두 가지다.
|
|
||||||
**교체 실행 자체는 운영 계정 조작이라 사용자 몫으로 남긴다.**
|
|
||||||
- 검증: pytest 스킵 2(shellcheck 미설치·Windows AF_UNIX) · phpunit 276 passed, 스킵 0
|
|
||||||
- **1.2.1 릴리스** — 감지 동작은 1.2.0 과 동일(기능 변경 없음). 납품 패키지 내용이 바뀌었으므로
|
|
||||||
(회전 도구 추가) 버전을 올렸다. 산출물 `move-detect-cafe24-1.2.1-20260921.zip` ·
|
|
||||||
`move-detect-pi-1.2.1-20260921-aarch64-cp311-hailo8.tar.gz`(91MB, MoveNet 폴백 포함)
|
|
||||||
|
|
||||||
## 2026-09-25 — 1.3.0 (이해관계자 페르소나 검증 4건)
|
|
||||||
|
|
||||||
앞선 감사 네 번은 "이 코드가 제대로 도는가"를 물었다. 이번엔 작업자·노조·안전관리자·공장장·
|
|
||||||
설치기사 5인 관점으로 3단논법을 세우고 전제를 코드로 확인한 뒤 반문으로 깼다. **5인 중 2인의
|
|
||||||
논증이 완전히, 2인이 부분적으로 무너졌다.** 넷 다 "구현도 테스트도 초록인데 그 기능이 지켜야
|
|
||||||
할 것이 안 지켜지는" 같은 모양이었다.
|
|
||||||
|
|
||||||
- **열람 감사** — 쓰기는 임계값 변경까지 전부 해시 체인에 남는데 **사진 열람은 하나도 남지
|
|
||||||
않았다.** 감시 시스템과 안전 시스템을 가르는 선이 거기라서, 나머지 프라이버시 설계
|
|
||||||
(RAM 링버퍼·전송 전 블러·라이브뷰 부재)가 전부 검증 불가능한 주장이었다. `snapshot_view`
|
|
||||||
(세션당 1회) · `label_export`(건수·기간). 거부된 열람은 기록하지 않는다 — 그러면 감사 로그가
|
|
||||||
"본 사람"이 아니라 "요청한 사람" 목록이 되어 정작 쓸 수 없다
|
|
||||||
- **크론 생존** — 통보·5분 확대·`device_fault` 발행이 전부 한 프로세스에 걸려 있는데, 그게
|
|
||||||
멈추면 화면이 조용해지고 **그 침묵이 "사고 없음"과 구별되지 않았다.** 관리자 화면의 조치는
|
|
||||||
산문 안내 한 줄. 이제 크론이 실행 시각을 남기고 대시보드가 그 나이를 읽는다(5분 기준)
|
|
||||||
- **증거 보존 보류** — 스냅샷 삭제가 나이만 봤다. 기본 7일인데 중대재해 조사는 몇 주~몇 달이라
|
|
||||||
우리가 내건 목적이 8일째에 사라졌다. admin 만·건별·최대 1년·감사기록과 함께.
|
|
||||||
확인 처리로 자동 연장하지 않는다 — 자동이면 사실상 전량 보존이라 7일 약속이 무너진다
|
|
||||||
- **증거 정책** — 프레임이 `ergo_report` 빼고 **모든** 이벤트에 붙었다. 체류 상한 초과 포함이라
|
|
||||||
밀폐공간 기본값(30분) 기준 정상 작업자가 규정대로 일하는 동안 주기적으로 사진이 갔다.
|
|
||||||
기준 명문화: **시간 경과 자체가 조건인 이벤트에는 사진을 붙이지 않는다**
|
|
||||||
- 마이그레이션 `2026-09-25-evidence-hold-and-view-audit.sql`(README-UPLOAD 10번).
|
|
||||||
노조 대응 문서의 "가장 강한 카드가 비어 있다"던 항목이 닫혔고, 7일 원칙의 예외(보류)를
|
|
||||||
정직하게 적었다
|
|
||||||
- 산출물 `move-detect-cafe24-1.3.0-20260925.zip` · `move-detect-pi-1.3.0-20260925-aarch64-cp311-hailo8.tar.gz`
|
|
||||||
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
# PROJECT_MAP — move_detect
|
|
||||||
|
|
||||||
> 작업 전 이 문서로 위치를 찾고 필요한 파일만 연다 (전체 재스캔 금지).
|
|
||||||
> 새 파일·라우트·스키마 변경 시 해당 섹션을 갱신할 것.
|
|
||||||
|
|
||||||
## 개요
|
|
||||||
|
|
||||||
작업장 안전 AI CCTV. Raspberry Pi 5 + Hailo-8 엣지 추론(Python) → HMAC POST → Cafe24
|
|
||||||
웹호스팅 PHP+MySQL 대시보드. 감지: 넘어짐 · 넘어짐 후 미동 · 장시간 부동 · 위험구역
|
|
||||||
침입(경고/절대금지/협착) · 유령사고(위험 상태로 소실). 스냅샷만 전송, 라이브뷰·영상 없음.
|
|
||||||
|
|
||||||
## edge/ (Python — Pi에서 구동)
|
|
||||||
|
|
||||||
| 파일 | 역할 |
|
|
||||||
|------|------|
|
|
||||||
| `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`(`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 |
|
|
||||||
| `queue.py` | 오프라인 전송 큐 |
|
|
||||||
| `config.py` | env → Config (빈 CAFE24_URL/SECRET는 기동 실패) |
|
|
||||||
| `daemon.py` / `runtime.py` / `health.py` | 상주 루프 · 하트비트 텔레메트리 |
|
|
||||||
| `adapters/` | hailo_pose · movenet_pose(CPU 폴백) · picamera_source(CSI) · uvc_source(USB) |
|
|
||||||
| `probe.py` / `tools/` | 카메라 사전 점검 · HEF/모델 fetch (빌드머신 전용) |
|
|
||||||
| `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)
|
|
||||||
|
|
||||||
- 진입: `public/index.php` (라우트 전부 여기),
|
|
||||||
뷰: `src/View/{layout,events,login,zones,report,event_detail,admin,compliance,setup,incident_package}.php`
|
|
||||||
- `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초 재현
|
|
||||||
영상, 없으면 스틸로 자동 대체). 출력 `경영진-보고.html` 은 gitignore — 낡은 사본 방지.
|
|
||||||
발주 명세는 `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회, 보존기간) ·
|
|
||||||
`backup.php`(DB 덤프+스냅샷) · `verify_audit_chain.php`(감사 무결성) · `seed_demo.php`(시연·QA) ·
|
|
||||||
`rotate_credentials.php`(장치 HMAC 키·관리자 비번 회전 — DB 미접속, 붙여넣을 SQL 만 출력).
|
|
||||||
`dispatch_alerts.php` 는 매 실행마다 `move_app_state['dispatch_alerts.last_run']` 을 남기고,
|
|
||||||
대시보드(`/api/devices` → `dispatcher`)가 그 나이를 읽어 크론이 멈춘 것을 표시한다.
|
|
||||||
전부 CLI 전용(`bootstrap.php` 가 SAPI 검사), 납품 시 `move_detect_app/tools/` 로 www 밖에 배치
|
|
||||||
- 라우트: `POST /api/events`(ingest, HMAC) · `POST /api/heartbeat` · `GET /api/config` ·
|
|
||||||
`GET /api/events` · `GET /api/devices` · `GET /api/heatmap/{camera}`(spatial 집계) ·
|
|
||||||
`GET /api/fp-stats`(오탐율) · `GET/POST /api/zones*` · `POST /login|/logout` ·
|
|
||||||
`GET /snap/{uuid}` · `POST /events/{id}/ack` · `POST /events/{id}/false-positive`(오탐 태그,
|
|
||||||
ack 겸함) · `GET /api/report`(운영 지표+시간패턴+전조경보+추이) ·
|
|
||||||
`GET /api/export/labels`(라벨 JSONL, 관리자) · `GET/POST /api/actions`(개선 조치·전후 효과) ·
|
|
||||||
`GET /api/devices/{id}/metrics`(성능 시계열) · `GET/POST /api/admin/thresholds*`(임계값 편집,
|
|
||||||
admin) · `GET/POST /api/admin/channels*`(통보 채널, admin) · `GET/POST /api/suppressions*`(정비
|
|
||||||
억제) · `GET/POST /api/risks*`(위험성평가) · `GET/POST /api/handovers`(교대 인수인계) ·
|
|
||||||
`GET /api/events/{id}/package`(사고조사 패키지) ·
|
|
||||||
페이지 `/`, `/login`, `/zones`, `/report`(안전 리포트), `/compliance`(안전 관리),
|
|
||||||
`/admin`(시스템 설정), `/setup`(설치 점검), `/events/{id}/view`, `/events/{id}/package/view`
|
|
||||||
- `src/Ingest/` HMAC+nonce+rate-limit+replay 방지 · `src/Auth/` argon2id/세션/RBAC/CSRF ·
|
|
||||||
`src/Dashboard/` 이벤트목록·디바이스상태·존편집·스냅샷프록시·시스템설정·억제·규정 ·
|
|
||||||
`src/Alert/` 통보 결정·발송·에스컬레이션 · `src/Repository/` 14종 (인터페이스+PDO)
|
|
||||||
- 감사로그는 **해시체인**(prev_hash/entry_hash). 수정·삭제·우회삽입이 전부 탐지된다 —
|
|
||||||
`tools/verify_audit_chain.php`, 손상 시 종료 코드 1
|
|
||||||
- 프런트: `public/assets/app.js`(이벤트 콘솔 — 엄격 시간순 정렬 · 뷰모드(전체/미확인만) ·
|
|
||||||
날짜 조회(커서 시딩) · 확인 제자리 전환+토스트 · 오탐 2단계 · 경보음 음소거 · 날짜 구분선) ·
|
|
||||||
`device-tree.js`(장치→카메라 트리 레일) · `zone-draw.js`(장치·카메라 선택기 · 존 채움/라벨칩 ·
|
|
||||||
삭제 2단계) · `detail.js`(상세 + 확인/오탐 판정) · `report.js`(표본·유의성 게이트) · `app.css`
|
|
||||||
- UI 불변식: 스트림 정렬은 **항상 occurred_at 내림차순** — 확인 상태가 순서를 바꾸면 안 됨
|
|
||||||
(연대기 = 사고 재구성 근거). 처리 큐는 정렬이 아니라 필터(`filters.unackedOnly`)로만 만든다.
|
|
||||||
- 페이지 명칭은 nav 라벨 · `<h1>` · `<title>` 세 곳이 동일 문자열 (`이벤트`/`구역 설정`/`안전 리포트`).
|
|
||||||
날짜 표기는 `YYYY-MM-DD` 로 3개 화면 통일 (각 파일의 로컬 `formatTime`/`fmtDate`).
|
|
||||||
- DB: `schema.sql` (테이블 prefix `move_`), 마이그레이션 `migrations/`.
|
|
||||||
존 행의 `rules_json`(선택) = 점유 규칙 {min_workers,max_workers,max_dwell_s} — 편집기 저장 시
|
|
||||||
ZoneConfigController가 검증, /api/config로 엣지 전달
|
|
||||||
- 배포: `server/deploy/build_cafe24_package.sh` → zip, `server/docs/server-deploy.md`
|
|
||||||
|
|
||||||
## deploy/ · dist/ (납품)
|
|
||||||
|
|
||||||
- `deploy/`: Pi 설치 — `setup_online.sh`, `bundle/build_offline_bundle.sh`(폐쇄망),
|
|
||||||
`systemd/move-detect-edge.service`, `device.env`(사전 프로비저닝)
|
|
||||||
- `dist/`: `1_cafe24_upload/` · `2_pi_install/` · `PROVISION_SECRETS.md`
|
|
||||||
|
|
||||||
## tests/
|
|
||||||
|
|
||||||
- Python: `tests/` 31파일 (pytest) — 하드웨어 필요 테스트는 `tests/hardware/` + skip 마커
|
|
||||||
- PHP: `server/tests/` 19파일 (phpunit, in-memory repo; PDO 통합은 skip 게이트)
|
|
||||||
|
|
||||||
## docs/
|
|
||||||
|
|
||||||
`delivery-audit.md`(감사 43건 + 재감사 4회, 전부 완료 — OFF-HW 실장비 검증만 잔여) · `pilot-runbook.md` ·
|
|
||||||
`hardware-bringup.md` · `INDEX.md`(작업 기록)
|
|
||||||
|
|
||||||
도해 3종. 소스는 `*.archify.json` 이고 HTML 은 생성물이라 커밋하지 않는다
|
|
||||||
(`node ~/.claude/skills/archify/bin/archify.mjs deliver <type> <소스> <출력> --quality showcase`):
|
|
||||||
|
|
||||||
- `architecture.archify.json` — 시스템 구성(현장 ↔ Cafe24). 무엇이 경계를 넘는가.
|
|
||||||
- `fall-sequence.archify.json` — 낙상 1건의 시간축. 경광등 즉시 vs 통보 최대 60초의 간격.
|
|
||||||
- `fall-lifecycle.archify.json` — 낙상 판정 상태기계. 확정·회복 양쪽이 지속을 요구하는 구조.
|
|
||||||
|
|
@ -1,343 +0,0 @@
|
||||||
{
|
|
||||||
"schema_version": 1,
|
|
||||||
"diagram_type": "architecture",
|
|
||||||
"meta": {
|
|
||||||
"title": "작업장 안전 AI CCTV — 시스템 구성",
|
|
||||||
"output": "move-detect-architecture.html",
|
|
||||||
"quality_profile": "showcase",
|
|
||||||
"views": [
|
|
||||||
{
|
|
||||||
"id": "detection-chain",
|
|
||||||
"label": "감지 → 대응",
|
|
||||||
"focus": [
|
|
||||||
"cameras",
|
|
||||||
"edge",
|
|
||||||
"beacon"
|
|
||||||
],
|
|
||||||
"note": "카메라에서 경광등까지. 서버도 인터넷도 거치지 않는 즉시 대응 경로."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "privacy-path",
|
|
||||||
"label": "프라이버시 경로",
|
|
||||||
"focus": [
|
|
||||||
"edge",
|
|
||||||
"blur",
|
|
||||||
"ingest",
|
|
||||||
"db"
|
|
||||||
],
|
|
||||||
"note": "영상은 나가지 않는다. 얼굴 블러 후 스냅샷 3장만, 7일 뒤 삭제."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "server-issued",
|
|
||||||
"label": "서버가 발행하는 사건",
|
|
||||||
"focus": [
|
|
||||||
"cron",
|
|
||||||
"db",
|
|
||||||
"notify"
|
|
||||||
],
|
|
||||||
"note": "죽은 노드는 자기가 죽었다고 신고할 수 없다 — 하트비트의 부재로 판정한다."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"components": [
|
|
||||||
{
|
|
||||||
"id": "cameras",
|
|
||||||
"type": "external",
|
|
||||||
"label": "카메라",
|
|
||||||
"sublabel": "CSI · USB",
|
|
||||||
"tag": "노드당 2~4대",
|
|
||||||
"pos": [
|
|
||||||
40,
|
|
||||||
340
|
|
||||||
],
|
|
||||||
"size": [
|
|
||||||
130,
|
|
||||||
64
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "edge",
|
|
||||||
"type": "backend",
|
|
||||||
"label": "엣지 노드",
|
|
||||||
"sublabel": "Pi 5 + Hailo-8",
|
|
||||||
"tag": "판정 16종",
|
|
||||||
"pos": [
|
|
||||||
230,
|
|
||||||
332
|
|
||||||
],
|
|
||||||
"size": [
|
|
||||||
165,
|
|
||||||
80
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "beacon",
|
|
||||||
"type": "external",
|
|
||||||
"label": "현장 경광등",
|
|
||||||
"sublabel": "GPIO 직결",
|
|
||||||
"tag": "네트워크 불필요",
|
|
||||||
"pos": [
|
|
||||||
230,
|
|
||||||
180
|
|
||||||
],
|
|
||||||
"size": [
|
|
||||||
165,
|
|
||||||
64
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "queue",
|
|
||||||
"type": "messagebus",
|
|
||||||
"label": "오프라인 큐",
|
|
||||||
"sublabel": "단절 시 보관",
|
|
||||||
"pos": [
|
|
||||||
230,
|
|
||||||
500
|
|
||||||
],
|
|
||||||
"size": [
|
|
||||||
165,
|
|
||||||
64
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "blur",
|
|
||||||
"type": "security",
|
|
||||||
"label": "얼굴 블러",
|
|
||||||
"sublabel": "전송 전",
|
|
||||||
"tag": "영상 녹화 없음",
|
|
||||||
"pos": [
|
|
||||||
465,
|
|
||||||
336
|
|
||||||
],
|
|
||||||
"size": [
|
|
||||||
160,
|
|
||||||
72
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ingest",
|
|
||||||
"type": "security",
|
|
||||||
"label": "수집 API",
|
|
||||||
"sublabel": "HMAC + nonce",
|
|
||||||
"tag": "재전송 차단",
|
|
||||||
"pos": [
|
|
||||||
715,
|
|
||||||
332
|
|
||||||
],
|
|
||||||
"size": [
|
|
||||||
155,
|
|
||||||
80
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "app",
|
|
||||||
"type": "backend",
|
|
||||||
"label": "대시보드 서버",
|
|
||||||
"sublabel": "PHP 8",
|
|
||||||
"pos": [
|
|
||||||
960,
|
|
||||||
332
|
|
||||||
],
|
|
||||||
"size": [
|
|
||||||
160,
|
|
||||||
80
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "db",
|
|
||||||
"type": "database",
|
|
||||||
"label": "MySQL",
|
|
||||||
"sublabel": "move_* · 7일",
|
|
||||||
"pos": [
|
|
||||||
1150,
|
|
||||||
340
|
|
||||||
],
|
|
||||||
"size": [
|
|
||||||
160,
|
|
||||||
64
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "cron",
|
|
||||||
"type": "backend",
|
|
||||||
"label": "1분 크론",
|
|
||||||
"sublabel": "이벤트 발행",
|
|
||||||
"tag": "서버 발행 4종",
|
|
||||||
"pos": [
|
|
||||||
1150,
|
|
||||||
480
|
|
||||||
],
|
|
||||||
"size": [
|
|
||||||
160,
|
|
||||||
76
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "console",
|
|
||||||
"type": "frontend",
|
|
||||||
"label": "운영 콘솔",
|
|
||||||
"sublabel": "바닐라 JS",
|
|
||||||
"tag": "라이브뷰 없음",
|
|
||||||
"pos": [
|
|
||||||
960,
|
|
||||||
180
|
|
||||||
],
|
|
||||||
"size": [
|
|
||||||
160,
|
|
||||||
76
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "notify",
|
|
||||||
"type": "external",
|
|
||||||
"label": "메일 · 사내 웹훅",
|
|
||||||
"sublabel": "링크만 전송",
|
|
||||||
"pos": [
|
|
||||||
1150,
|
|
||||||
630
|
|
||||||
],
|
|
||||||
"size": [
|
|
||||||
160,
|
|
||||||
72
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"boundaries": [
|
|
||||||
{
|
|
||||||
"kind": "region",
|
|
||||||
"label": "현장 (폐쇄망 가능)",
|
|
||||||
"wraps": [
|
|
||||||
"cameras",
|
|
||||||
"edge",
|
|
||||||
"beacon",
|
|
||||||
"queue",
|
|
||||||
"blur"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": "region",
|
|
||||||
"label": "Cafe24 공유호스팅",
|
|
||||||
"wraps": [
|
|
||||||
"ingest",
|
|
||||||
"app",
|
|
||||||
"db",
|
|
||||||
"cron"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"connections": [
|
|
||||||
{
|
|
||||||
"id": "capture",
|
|
||||||
"from": "cameras",
|
|
||||||
"to": "edge",
|
|
||||||
"label": "프레임"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "local-beacon",
|
|
||||||
"from": "edge",
|
|
||||||
"to": "beacon",
|
|
||||||
"label": "즉시 점등",
|
|
||||||
"variant": "emphasis",
|
|
||||||
"fromSide": "top",
|
|
||||||
"toSide": "bottom"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "evidence",
|
|
||||||
"from": "edge",
|
|
||||||
"to": "blur",
|
|
||||||
"label": "이벤트 시"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "spool",
|
|
||||||
"from": "edge",
|
|
||||||
"to": "queue",
|
|
||||||
"label": "회선 단절 시",
|
|
||||||
"variant": "dashed",
|
|
||||||
"fromSide": "bottom",
|
|
||||||
"toSide": "top",
|
|
||||||
"labelDy": 30
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "upload",
|
|
||||||
"from": "blur",
|
|
||||||
"to": "ingest",
|
|
||||||
"label": "HMAC POST",
|
|
||||||
"variant": "emphasis"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "resend",
|
|
||||||
"from": "queue",
|
|
||||||
"to": "ingest",
|
|
||||||
"label": "복구 후 재전송",
|
|
||||||
"variant": "dashed",
|
|
||||||
"fromSide": "right",
|
|
||||||
"toSide": "bottom"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "accept",
|
|
||||||
"from": "ingest",
|
|
||||||
"to": "app",
|
|
||||||
"label": "검증 통과"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "persist",
|
|
||||||
"from": "app",
|
|
||||||
"to": "db",
|
|
||||||
"label": "SQL"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "cron-db",
|
|
||||||
"from": "cron",
|
|
||||||
"to": "db",
|
|
||||||
"label": "하트비트 부재로 판정",
|
|
||||||
"fromSide": "top",
|
|
||||||
"toSide": "bottom"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "dispatch",
|
|
||||||
"from": "cron",
|
|
||||||
"to": "notify",
|
|
||||||
"label": "1분 주기 통보",
|
|
||||||
"fromSide": "bottom",
|
|
||||||
"toSide": "top",
|
|
||||||
"labelDy": 30
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "serve",
|
|
||||||
"from": "app",
|
|
||||||
"to": "console",
|
|
||||||
"label": "HTTPS · 역할 기반 접근",
|
|
||||||
"fromSide": "top",
|
|
||||||
"toSide": "bottom"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"cards": [
|
|
||||||
{
|
|
||||||
"dot": "cyan",
|
|
||||||
"title": "현장에서 끝나는 대응",
|
|
||||||
"items": [
|
|
||||||
"판정은 엣지에서 완결 — 서버 왕복 없음",
|
|
||||||
"경광등은 GPIO 직결이라 회선·서버가 죽어도 울린다",
|
|
||||||
"끊긴 동안의 이벤트는 큐에 쌓였다가 복구 시 재전송"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"dot": "rose",
|
|
||||||
"title": "영상을 남기지 않는다",
|
|
||||||
"items": [
|
|
||||||
"링버퍼는 RAM 7.5초 — 디스크에 쓰지 않는다",
|
|
||||||
"이벤트 순간에만 3프레임, 얼굴 블러는 전송 전 노드에서",
|
|
||||||
"스냅샷 7일 후 자동 삭제 · 라이브뷰 화면 자체가 없음"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"dot": "emerald",
|
|
||||||
"title": "서버가 보는 것",
|
|
||||||
"items": [
|
|
||||||
"HMAC + nonce 로 재전송 공격 차단",
|
|
||||||
"죽은 노드는 자기가 죽었다고 신고할 수 없다 — 크론이 하트비트의 부재로 판정",
|
|
||||||
"통보는 링크만 — 현장 이미지는 메일로 나가지 않는다"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,509 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""경영진 보고 덱 생성기 — 이미지를 base64 로 박아 넣은 단일 HTML.
|
|
||||||
|
|
||||||
WHY A GENERATOR AND NOT HAND-WRITTEN HTML
|
|
||||||
이 덱은 내용이 계속 바뀐다(수치·현재 상태·클립 추가). 손으로 쓴 HTML 은 두 번째 수정부터
|
|
||||||
낡기 시작하고, 낡은 덱은 경영진 앞에서 틀린 숫자를 띄운다. 내용은 deck.json, 도판은
|
|
||||||
docs/images/, 클립은 clips/ 에 두고 이 스크립트로 다시 뽑는다 — server/deploy/manual/
|
|
||||||
build_manual.py 와 같은 구조다.
|
|
||||||
|
|
||||||
WHY CLIPS ARE REFERENCED, NOT EMBEDDED
|
|
||||||
이미지는 3MB 라 통째로 넣어도 되지만 3초 클립 6개는 10MB 를 넘어 base64 로는 감당이 안 된다.
|
|
||||||
clips/ 를 상대경로로 참조하므로 **HTML 과 clips/ 폴더는 같이 옮겨야 한다.** 클립이 없는
|
|
||||||
자리는 포스터 스틸로 자동 대체되므로, 클립을 하나도 안 넣어도 덱은 완성된 상태로 나온다.
|
|
||||||
|
|
||||||
WHY EVERY CLIP CARRIES A "재현" BADGE
|
|
||||||
이 클립들은 제품 동작 녹화가 아니라 연출된 재현이다. 실제 동작처럼 보여주면 허위 시연이
|
|
||||||
된다. 발표자가 깜빡할 수 있는 종류의 것이라 배지를 템플릿에 박아 지울 수 없게 했다.
|
|
||||||
실화면 캡처(server/deploy/manual/shots/)에는 이 배지가 붙지 않는다 — 그건 진짜다.
|
|
||||||
|
|
||||||
사용법:
|
|
||||||
python docs/deck/build_deck.py # -> docs/deck/경영진-보고.html
|
|
||||||
python docs/deck/build_deck.py --out X.html
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import base64
|
|
||||||
import datetime
|
|
||||||
import html
|
|
||||||
import json
|
|
||||||
import pathlib
|
|
||||||
import sys
|
|
||||||
|
|
||||||
HERE = pathlib.Path(__file__).resolve().parent
|
|
||||||
REPO = HERE.parents[1]
|
|
||||||
IMAGES = REPO / "docs" / "images"
|
|
||||||
CLIPS = HERE / "clips"
|
|
||||||
|
|
||||||
MIME = {".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp"}
|
|
||||||
|
|
||||||
# deck.json 은 클립을 논리 이름(v01-....mp4)으로 적는다. 실제 파일은 prepare_clips.py 가
|
|
||||||
# 변환한 결과라 확장자가 다를 수 있으므로, stem 이 같은 재생 가능한 파일을 전부 찾아
|
|
||||||
# <source> 로 늘어놓고 브라우저가 고르게 한다.
|
|
||||||
#
|
|
||||||
# webm 을 먼저 두는 이유: 받은 원본은 HEVC(H.265) 였고 브라우저에서 디코딩되지 않았다
|
|
||||||
# (같은 페이지 대조: HEVC videoWidth 0 / VP9 videoWidth 1600). 원본 mp4 도 뒤에 남겨 둬서
|
|
||||||
# VP9 를 못 쓰는 환경이면 그쪽으로 떨어지게 한다. 자세한 근거는 prepare_clips.py 머리말.
|
|
||||||
#
|
|
||||||
# 어느 쪽이든 재생이 안 되더라도 poster 가 남으므로 슬라이드가 비지는 않는다.
|
|
||||||
CLIP_TYPES = [(".webm", "video/webm"), (".m4v", "video/mp4"), (".mp4", "video/mp4")]
|
|
||||||
|
|
||||||
|
|
||||||
def file_uri(p: pathlib.Path) -> str:
|
|
||||||
mime = MIME.get(p.suffix.lower())
|
|
||||||
if mime is None:
|
|
||||||
raise SystemExit(f"ERROR: 지원하지 않는 이미지 형식: {p}")
|
|
||||||
return f"data:{mime};base64," + base64.b64encode(p.read_bytes()).decode("ascii")
|
|
||||||
|
|
||||||
|
|
||||||
def clip_poster(clip: str | None) -> pathlib.Path | None:
|
|
||||||
"""클립에서 뽑아 둔 포스터(prepare_clips.py 생성). 있으면 deck.json 의 포스터보다 우선한다."""
|
|
||||||
if not clip:
|
|
||||||
return None
|
|
||||||
p = CLIPS / (pathlib.Path(clip).stem + ".poster.jpg")
|
|
||||||
return p if p.is_file() else None
|
|
||||||
|
|
||||||
|
|
||||||
def data_uri(name: str) -> str:
|
|
||||||
p = IMAGES / name
|
|
||||||
if not p.is_file():
|
|
||||||
# 조용히 넘기면 도판 빠진 덱이 회의실에서 발견된다.
|
|
||||||
raise SystemExit(f"ERROR: 이미지가 없습니다: {p}")
|
|
||||||
mime = MIME.get(p.suffix.lower())
|
|
||||||
if mime is None:
|
|
||||||
raise SystemExit(f"ERROR: 지원하지 않는 이미지 형식: {p}")
|
|
||||||
return f"data:{mime};base64," + base64.b64encode(p.read_bytes()).decode("ascii")
|
|
||||||
|
|
||||||
|
|
||||||
CSS = """
|
|
||||||
:root{
|
|
||||||
--ink:#131A26; --ink-2:#3A4354; --ink-3:#5C6474;
|
|
||||||
--line:#DCE2EA; --line-2:#EDF1F5; --paper:#FFFFFF; --wash:#F4F6F9;
|
|
||||||
--navy:#003478; --navy-2:#0A4C9E; --navy-wash:#EAF0F9;
|
|
||||||
--alarm:#C1121F; --alarm-wash:#FBEDEE;
|
|
||||||
--caution:#8A6200; --caution-wash:#FBF4E4;
|
|
||||||
--ok:#14614A; --ok-wash:#E9F3EF;
|
|
||||||
--rail:64px;
|
|
||||||
--font:"Pretendard","Noto Sans KR","Malgun Gothic","Apple SD Gothic Neo",system-ui,sans-serif;
|
|
||||||
--mono:ui-monospace,"Cascadia Mono",Consolas,"D2Coding",monospace;
|
|
||||||
}
|
|
||||||
*{box-sizing:border-box}
|
|
||||||
@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
|
|
||||||
body{margin:0;background:var(--wash);color:var(--ink);font-family:var(--font);
|
|
||||||
line-height:1.6;-webkit-font-smoothing:antialiased;
|
|
||||||
/* CJK 기본 줄바꿈은 글자 단위라 "설명할 수 있습/니다" 처럼 어절이 잘린다. */
|
|
||||||
word-break:keep-all;overflow-wrap:break-word}
|
|
||||||
|
|
||||||
/* 좌측 레일 — 모든 슬라이드에 같은 자리에 있어 덱의 정체성이 된다. 가운데정렬 카드 나열을
|
|
||||||
피하려는 의도적 선택: 시선이 항상 왼쪽 같은 지점에서 출발한다. */
|
|
||||||
.rail{position:fixed;left:0;top:0;bottom:0;width:var(--rail);background:var(--navy);z-index:20;
|
|
||||||
display:flex;flex-direction:column;align-items:center;justify-content:space-between;
|
|
||||||
padding:1.1rem 0;color:#fff}
|
|
||||||
.rail .mark{font-size:.62rem;font-weight:700;letter-spacing:.18em;writing-mode:vertical-rl;
|
|
||||||
text-orientation:mixed;opacity:.92}
|
|
||||||
.rail .dots{display:flex;flex-direction:column}
|
|
||||||
.rail .dots a{width:24px;height:24px;display:flex;align-items:center;justify-content:center}
|
|
||||||
.rail .dots a::before{content:"";width:7px;height:7px;border-radius:50%;
|
|
||||||
background:rgba(255,255,255,.34);transition:background .15s}
|
|
||||||
.rail .dots a:hover::before,.rail .dots a:focus-visible::before{background:#fff}
|
|
||||||
.rail .dots a[aria-current="true"]::before{background:#fff}
|
|
||||||
.rail a:focus-visible{outline:2px solid #fff;outline-offset:3px}
|
|
||||||
|
|
||||||
.deck{margin-left:var(--rail);scroll-snap-type:y mandatory;overflow-y:auto;height:100vh}
|
|
||||||
section.slide{min-height:100vh;scroll-snap-align:start;display:flex;flex-direction:column;
|
|
||||||
justify-content:center;padding:4.2vh 5vw 6vh;position:relative;background:var(--paper);
|
|
||||||
border-bottom:1px solid var(--line)}
|
|
||||||
/* 표지·마무리는 자기 배경을 가진다. :nth-child(even) 는 명시도가 더 높아 그대로 두면
|
|
||||||
네이비 배경을 덮어쓰고, 그 위의 흰 글자가 흰 배경에 얹혀 슬라이드가 통째로 백지가 된다. */
|
|
||||||
section.slide:not(.cover):not(.closing):nth-child(even){background:var(--wash)}
|
|
||||||
|
|
||||||
.num{position:absolute;right:2.2vw;top:3.4vh;font-family:var(--mono);font-size:.72rem;
|
|
||||||
color:var(--ink-3);letter-spacing:.06em}
|
|
||||||
.eyebrow{margin:0 0 .55rem;font-size:.72rem;font-weight:700;letter-spacing:.16em;
|
|
||||||
color:var(--navy);text-transform:none}
|
|
||||||
.eyebrow::before{content:"";display:inline-block;width:22px;height:2px;background:var(--navy);
|
|
||||||
vertical-align:middle;margin-right:.6rem}
|
|
||||||
h2.title{margin:0 0 1.1rem;font-size:clamp(1.7rem,3.3vw,2.9rem);line-height:1.24;
|
|
||||||
letter-spacing:-.025em;font-weight:800;max-width:22ch}
|
|
||||||
h2.title.wide{max-width:34ch}
|
|
||||||
.lead{margin:0 0 1.4rem;font-size:clamp(1rem,1.35vw,1.18rem);color:var(--ink-2);max-width:56ch}
|
|
||||||
.note{margin:1.15rem 0 0;padding-left:.95rem;border-left:3px solid var(--navy);
|
|
||||||
font-size:.94rem;color:var(--ink-2);max-width:74ch}
|
|
||||||
|
|
||||||
/* 표지 */
|
|
||||||
section.cover{padding:0;background:var(--navy);color:#fff;justify-content:flex-end;overflow:hidden}
|
|
||||||
section.cover .shot{position:absolute;inset:0}
|
|
||||||
section.cover .shot img{width:100%;height:100%;object-fit:cover;opacity:.52}
|
|
||||||
section.cover .veil{position:absolute;inset:0;
|
|
||||||
background:linear-gradient(180deg,rgba(0,26,60,.22) 0%,rgba(0,26,60,.62) 55%,rgba(0,20,46,.94) 100%)}
|
|
||||||
section.cover .inner{position:relative;padding:0 5vw 8vh}
|
|
||||||
section.cover .eyebrow{color:#9DC0EE}
|
|
||||||
section.cover .eyebrow::before{background:#9DC0EE}
|
|
||||||
section.cover h1{margin:.2rem 0 .8rem;font-size:clamp(2.2rem,5.4vw,4.4rem);line-height:1.1;
|
|
||||||
letter-spacing:-.035em;font-weight:800}
|
|
||||||
section.cover .lead{color:#DCE7F6;font-size:clamp(1.02rem,1.7vw,1.4rem);max-width:44ch;margin-bottom:1.6rem}
|
|
||||||
section.cover .foot{font-family:var(--mono);font-size:.76rem;color:#8FB2E2;letter-spacing:.04em;
|
|
||||||
border-top:1px solid rgba(255,255,255,.22);padding-top:.9rem;display:inline-block}
|
|
||||||
|
|
||||||
/* 3열 진술 */
|
|
||||||
.cols{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:0;margin-top:.5rem;
|
|
||||||
border-top:2px solid var(--ink)}
|
|
||||||
.cols .col{padding:1.15rem 1.5rem 0 0;min-width:0}
|
|
||||||
.cols .col + .col{padding-left:1.5rem;border-left:1px solid var(--line)}
|
|
||||||
.cols h3{margin:0 0 .5rem;font-size:1.02rem;font-weight:700;letter-spacing:-.01em}
|
|
||||||
.cols p{margin:0;font-size:.92rem;color:var(--ink-2)}
|
|
||||||
|
|
||||||
/* 도판 */
|
|
||||||
.figure{margin:.3rem 0 0;display:grid;grid-template-columns:minmax(0,1fr);gap:.7rem}
|
|
||||||
.figure img{width:100%;height:auto;display:block;border:1px solid var(--line);
|
|
||||||
background:#fff;max-height:50vh;object-fit:contain}
|
|
||||||
.media{position:relative;border:1px solid var(--line);background:#0B1220;overflow:hidden;
|
|
||||||
display:flex;align-items:center;justify-content:center}
|
|
||||||
.media video,.media img{width:auto;max-width:100%;height:auto;display:block;max-height:46vh}
|
|
||||||
/* 스틸 대체 컷은 도판 자체가 흰 바탕이라, 영상용 검은 레터박스를 두르면 무겁고 지저분하다. */
|
|
||||||
.media.still{background:#fff}
|
|
||||||
.media.still.tall img{max-height:54vh}
|
|
||||||
/* 재현 배지 — 지울 수 없게 템플릿에 박아 둔다. 파일 D-0 참조. */
|
|
||||||
.badge-reenact{position:absolute;left:.7rem;top:.7rem;z-index:2;background:var(--alarm);color:#fff;
|
|
||||||
font-size:.68rem;font-weight:700;letter-spacing:.1em;padding:.28rem .6rem;
|
|
||||||
border-radius:2px}
|
|
||||||
.missing{position:absolute;right:.7rem;top:.7rem;z-index:2;background:rgba(255,255,255,.86);
|
|
||||||
color:var(--ink-3);font-size:.64rem;padding:.28rem .6rem;border-radius:2px;
|
|
||||||
font-family:var(--mono);border:1px solid var(--line)}
|
|
||||||
figcaption{font-size:.86rem;color:var(--ink-3)}
|
|
||||||
|
|
||||||
.split{display:grid;grid-template-columns:minmax(0,1.35fr) minmax(0,1fr);gap:2rem;align-items:start}
|
|
||||||
@media (max-width:900px){.split{grid-template-columns:minmax(0,1fr)}}
|
|
||||||
ul.points{margin:.2rem 0 0;padding:0;list-style:none}
|
|
||||||
ul.points li{position:relative;padding:.6rem 0 .6rem 1.15rem;border-bottom:1px solid var(--line-2);
|
|
||||||
font-size:.95rem;color:var(--ink-2)}
|
|
||||||
ul.points li::before{content:"";position:absolute;left:0;top:1.05rem;width:5px;height:5px;
|
|
||||||
background:var(--navy);border-radius:50%}
|
|
||||||
.kicker{margin:1rem 0 0;padding:.75rem .9rem;background:var(--navy-wash);
|
|
||||||
border-left:3px solid var(--navy);font-size:.9rem;color:var(--ink-2)}
|
|
||||||
|
|
||||||
/* 좌우 비교 */
|
|
||||||
.compare{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1.4rem}
|
|
||||||
@media (max-width:900px){.compare{grid-template-columns:minmax(0,1fr)}}
|
|
||||||
.pane{min-width:0}
|
|
||||||
.pane .tag{display:inline-block;font-size:.74rem;font-weight:700;letter-spacing:.06em;
|
|
||||||
padding:.3rem .7rem;border-radius:2px;margin-bottom:.55rem}
|
|
||||||
.pane.ok .tag{background:var(--ok-wash);color:var(--ok);border:1px solid #BFD9CF}
|
|
||||||
.pane.alarm .tag{background:var(--alarm-wash);color:var(--alarm);border:1px solid #EFC9CC}
|
|
||||||
.pane p{margin:.55rem 0 0;font-size:.92rem;color:var(--ink-2)}
|
|
||||||
|
|
||||||
/* 지표 */
|
|
||||||
.metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0;border-top:2px solid var(--ink)}
|
|
||||||
@media (max-width:800px){.metrics{grid-template-columns:minmax(0,1fr)}}
|
|
||||||
.metrics .m{padding:1.1rem 1.6rem 1.1rem 0;border-bottom:1px solid var(--line);min-width:0}
|
|
||||||
.metrics .m:nth-child(even){padding-left:1.6rem;border-left:1px solid var(--line)}
|
|
||||||
.metrics h3{margin:0 0 .35rem;font-size:1.02rem;color:var(--navy);font-weight:700}
|
|
||||||
.metrics p{margin:0;font-size:.92rem;color:var(--ink-2)}
|
|
||||||
|
|
||||||
/* 현재 상태 */
|
|
||||||
.status{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1.6rem}
|
|
||||||
@media (max-width:900px){.status{grid-template-columns:minmax(0,1fr)}}
|
|
||||||
.status .box{padding:1.05rem 1.2rem;border:1px solid var(--line);background:#fff;min-width:0}
|
|
||||||
.status .box.done{border-top:3px solid var(--ok)}
|
|
||||||
.status .box.pending{border-top:3px solid var(--caution)}
|
|
||||||
.status h3{margin:0 0 .6rem;font-size:.98rem;font-weight:700}
|
|
||||||
.status .box.done h3{color:var(--ok)}
|
|
||||||
.status .box.pending h3{color:var(--caution)}
|
|
||||||
.status ul{margin:0;padding-left:1.05rem}
|
|
||||||
.status li{font-size:.91rem;color:var(--ink-2);margin:.32rem 0}
|
|
||||||
|
|
||||||
/* 마무리 */
|
|
||||||
section.closing{background:var(--navy);color:#fff}
|
|
||||||
section.closing .eyebrow{color:#9DC0EE}
|
|
||||||
section.closing .eyebrow::before{background:#9DC0EE}
|
|
||||||
section.closing h2.title{color:#fff}
|
|
||||||
section.closing .lines{margin:.6rem 0 0;font-size:clamp(1.05rem,2vw,1.6rem);line-height:1.7;
|
|
||||||
max-width:38ch;color:#DCE7F6}
|
|
||||||
section.closing .lines b{color:#fff;font-weight:800}
|
|
||||||
section.closing .num{color:#8FB2E2}
|
|
||||||
section.closing .foot{margin-top:2rem;font-family:var(--mono);font-size:.78rem;color:#8FB2E2;
|
|
||||||
letter-spacing:.22em;border-top:1px solid rgba(255,255,255,.22);
|
|
||||||
padding-top:.9rem;display:inline-block}
|
|
||||||
|
|
||||||
.nav{position:fixed;right:1.1rem;bottom:3.2rem;z-index:21;display:flex;gap:.5rem}
|
|
||||||
.nav button{width:46px;height:46px;border-radius:3px;border:1px solid var(--line);
|
|
||||||
background:rgba(255,255,255,.94);color:var(--navy);font-size:1.1rem;line-height:1;
|
|
||||||
cursor:pointer;display:flex;align-items:center;justify-content:center}
|
|
||||||
.nav button:hover{background:var(--navy);color:#fff;border-color:var(--navy)}
|
|
||||||
.nav button:focus-visible{outline:2px solid var(--navy);outline-offset:2px}
|
|
||||||
.deck:focus{outline:none}
|
|
||||||
.help{position:fixed;right:1rem;bottom:1rem;z-index:20;font-family:var(--mono);font-size:.68rem;
|
|
||||||
color:var(--ink-3);background:rgba(255,255,255,.9);border:1px solid var(--line);
|
|
||||||
padding:.35rem .6rem;border-radius:3px}
|
|
||||||
|
|
||||||
@media print{
|
|
||||||
.rail,.help{display:none}
|
|
||||||
.deck{margin-left:0;height:auto;overflow:visible}
|
|
||||||
section.slide{min-height:auto;break-after:page;border-bottom:none;padding:1.2cm}
|
|
||||||
section.cover,section.closing{-webkit-print-color-adjust:exact;print-color-adjust:exact}
|
|
||||||
.media video,.media img,.figure img{max-height:12cm}
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
JS = """
|
|
||||||
(function(){
|
|
||||||
var slides = Array.prototype.slice.call(document.querySelectorAll('section.slide'));
|
|
||||||
var dots = Array.prototype.slice.call(document.querySelectorAll('.rail .dots a'));
|
|
||||||
var idx = 0;
|
|
||||||
|
|
||||||
function show(i){
|
|
||||||
dots.forEach(function(d, k){ d.setAttribute('aria-current', k === i ? 'true' : 'false'); });
|
|
||||||
slides.forEach(function(s, k){
|
|
||||||
s.querySelectorAll('video').forEach(function(v){
|
|
||||||
// 슬라이드에 들어올 때만 처음부터 재생. 자동 반복은 하지 않는다 — 넘어지는 장면이
|
|
||||||
// 무한 루프로 도는 것은 회의실에서 보기 불편하다.
|
|
||||||
if (k === i) { try { v.currentTime = 0; v.play(); } catch (err) {} }
|
|
||||||
else { v.pause(); }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (window.IntersectionObserver) {
|
|
||||||
var obs = new IntersectionObserver(function(entries){
|
|
||||||
entries.forEach(function(e){
|
|
||||||
if (!e.isIntersecting) return;
|
|
||||||
idx = slides.indexOf(e.target); // 스크롤·클릭으로 옮겨간 경우도 따라잡는다
|
|
||||||
show(idx);
|
|
||||||
});
|
|
||||||
}, { threshold: 0.55 });
|
|
||||||
slides.forEach(function(s){ obs.observe(s); });
|
|
||||||
}
|
|
||||||
|
|
||||||
// idx 를 즉시 올리고 이동시킨다. 연타해도 누른 횟수만큼 넘어간다.
|
|
||||||
function go(delta){
|
|
||||||
idx = Math.max(0, Math.min(slides.length - 1, idx + delta));
|
|
||||||
slides[idx].scrollIntoView({ block: 'start' }); // 'smooth' 는 스냅 컨테이너에서 취소된다
|
|
||||||
show(idx);
|
|
||||||
}
|
|
||||||
|
|
||||||
var NEXT = { ArrowDown:1, ArrowRight:1, PageDown:1, ' ':1, Enter:1 };
|
|
||||||
var PREV = { ArrowUp:1, ArrowLeft:1, PageUp:1 };
|
|
||||||
document.addEventListener('keydown', function(e){
|
|
||||||
if (e.ctrlKey || e.altKey || e.metaKey) return;
|
|
||||||
if (NEXT[e.key]) { e.preventDefault(); go(1); }
|
|
||||||
else if (PREV[e.key]) { e.preventDefault(); go(-1); }
|
|
||||||
else if (e.key === 'Home') { e.preventDefault(); idx = 1; go(-1); }
|
|
||||||
else if (e.key === 'End') { e.preventDefault(); idx = slides.length - 2; go(1); }
|
|
||||||
});
|
|
||||||
|
|
||||||
dots.forEach(function(d, k){
|
|
||||||
d.addEventListener('click', function(){ idx = k; });
|
|
||||||
});
|
|
||||||
|
|
||||||
var prevBtn = document.querySelector('.nav-prev');
|
|
||||||
var nextBtn = document.querySelector('.nav-next');
|
|
||||||
if (prevBtn) prevBtn.addEventListener('click', function(){ go(-1); });
|
|
||||||
if (nextBtn) nextBtn.addEventListener('click', function(){ go(1); });
|
|
||||||
|
|
||||||
// .deck 이 스크롤 컨테이너인데 포커스를 못 받으면 방향키가 문서 스크롤러로 가버려
|
|
||||||
// 아무 일도 일어나지 않는다. 포커스 가능하게 만들고 처음에 한 번 준다.
|
|
||||||
var deck = document.querySelector('.deck');
|
|
||||||
if (deck) { deck.setAttribute('tabindex', '-1'); try { deck.focus({preventScroll:true}); } catch (e) {} }
|
|
||||||
|
|
||||||
show(0);
|
|
||||||
})();
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def esc(s: str) -> str:
|
|
||||||
return html.escape(str(s), quote=True)
|
|
||||||
|
|
||||||
|
|
||||||
def media_block(clip: str | None, poster: str, alt: str) -> str:
|
|
||||||
"""클립이 있으면 video, 없으면 포스터 스틸. 어느 쪽이든 재현 배지를 단다.
|
|
||||||
|
|
||||||
배지 문구는 실제 매체를 따른다 — 정지 이미지에 "재현 영상"이라고 붙이는 것 자체가
|
|
||||||
작은 거짓말이고, 이 덱의 존재 이유가 그 반대다.
|
|
||||||
"""
|
|
||||||
sources = clip_sources(clip)
|
|
||||||
live = sources != []
|
|
||||||
out = [f'<div class="media{"" if live else " still"}">',
|
|
||||||
f'<span class="badge-reenact">재현 {"영상" if live else "이미지"}</span>']
|
|
||||||
if live:
|
|
||||||
tags = "".join(f'<source src="clips/{esc(f)}" type="{m}">' for f, m in sources)
|
|
||||||
shot = clip_poster(clip)
|
|
||||||
uri = file_uri(shot) if shot else data_uri(poster)
|
|
||||||
out.append(
|
|
||||||
f'<video poster="{uri}" muted playsinline preload="metadata" '
|
|
||||||
f'controls aria-label="{esc(alt)} (재현 영상)">{tags}</video>')
|
|
||||||
else:
|
|
||||||
out.append('<span class="missing">클립 대기</span>')
|
|
||||||
out.append(f'<img src="{data_uri(poster)}" alt="{esc(alt)} (재현 이미지)">')
|
|
||||||
out.append("</div>")
|
|
||||||
return "".join(out)
|
|
||||||
|
|
||||||
|
|
||||||
def clip_sources(name: str | None) -> list[tuple[str, str]]:
|
|
||||||
"""@return [(파일명, MIME), ...] — 재생 가능한 형식만, 선호 순서대로. 없으면 빈 목록."""
|
|
||||||
if not name:
|
|
||||||
return []
|
|
||||||
stem = pathlib.Path(name).stem
|
|
||||||
return [(stem + ext, mime) for ext, mime in CLIP_TYPES if (CLIPS / (stem + ext)).is_file()]
|
|
||||||
|
|
||||||
|
|
||||||
def has_clip(name: str | None) -> bool:
|
|
||||||
return clip_sources(name) != []
|
|
||||||
|
|
||||||
|
|
||||||
def head(sl: dict) -> str:
|
|
||||||
o = []
|
|
||||||
if sl.get("eyebrow"):
|
|
||||||
o.append(f'<p class="eyebrow">{esc(sl["eyebrow"])}</p>')
|
|
||||||
if sl.get("title"):
|
|
||||||
wide = " wide" if len(sl["title"]) > 34 else ""
|
|
||||||
o.append(f'<h2 class="title{wide}">{sl["title"]}</h2>') # 의도된 인라인 마크업 허용
|
|
||||||
if sl.get("lead"):
|
|
||||||
o.append(f'<p class="lead">{esc(sl["lead"])}</p>')
|
|
||||||
return "".join(o)
|
|
||||||
|
|
||||||
|
|
||||||
def note(sl: dict) -> str:
|
|
||||||
return f'<p class="note">{esc(sl["note"])}</p>' if sl.get("note") else ""
|
|
||||||
|
|
||||||
|
|
||||||
def render_slide(sl: dict, idx: int) -> str:
|
|
||||||
kind = sl["kind"]
|
|
||||||
body = []
|
|
||||||
|
|
||||||
if kind == "cover":
|
|
||||||
return (f'<section class="slide cover" id="s{idx}">'
|
|
||||||
f'<div class="shot"><img src="{data_uri(sl["image"])}" alt=""></div>'
|
|
||||||
f'<div class="veil"></div><div class="inner">'
|
|
||||||
f'<p class="eyebrow">{esc(sl["eyebrow"])}</p>'
|
|
||||||
f'<h1>{esc(sl["title"])}</h1>'
|
|
||||||
f'<p class="lead">{esc(sl["lead"])}</p>'
|
|
||||||
f'<span class="foot">{esc(sl["foot"])}</span>'
|
|
||||||
f'</div></section>')
|
|
||||||
|
|
||||||
if kind == "closing":
|
|
||||||
lines = "".join(f"<div>{ln}</div>" for ln in sl["lines"]) # 의도된 <b> 허용
|
|
||||||
return (f'<section class="slide closing" id="s{idx}">'
|
|
||||||
f'<span class="num">{idx:02d}</span>'
|
|
||||||
f'<p class="eyebrow">{esc(sl["eyebrow"])}</p>'
|
|
||||||
f'<h2 class="title">{esc(sl["title"])}</h2>'
|
|
||||||
f'<div class="lines">{lines}</div>'
|
|
||||||
f'<span class="foot">{esc(sl["foot"])}</span></section>')
|
|
||||||
|
|
||||||
body.append(head(sl))
|
|
||||||
|
|
||||||
if kind == "statement":
|
|
||||||
cols = "".join(f'<div class="col"><h3>{esc(c["head"])}</h3><p>{esc(c["body"])}</p></div>'
|
|
||||||
for c in sl["columns"])
|
|
||||||
body.append(f'<div class="cols">{cols}</div>')
|
|
||||||
|
|
||||||
elif kind == "figure":
|
|
||||||
body.append(f'<figure class="figure"><img src="{data_uri(sl["image"])}" '
|
|
||||||
f'alt="{esc(sl.get("title", ""))}"></figure>')
|
|
||||||
|
|
||||||
elif kind == "clip":
|
|
||||||
pts = "".join(f"<li>{esc(p)}</li>" for p in sl.get("points", []))
|
|
||||||
right = f'<ul class="points">{pts}</ul>'
|
|
||||||
if sl.get("kicker"):
|
|
||||||
right += f'<p class="kicker">{esc(sl["kicker"])}</p>'
|
|
||||||
body.append(
|
|
||||||
'<div class="split"><figure class="figure">'
|
|
||||||
+ media_block(sl.get("clip"), sl["poster"], sl.get("title", ""))
|
|
||||||
+ (f'<figcaption>{esc(sl["caption"])}</figcaption>' if sl.get("caption") else "")
|
|
||||||
+ f'</figure><div>{right}</div></div>')
|
|
||||||
|
|
||||||
elif kind == "compare":
|
|
||||||
# 09 번 스틸은 그 자체가 이미 좌우 비교 그림이다. 클립이 없는 상태에서 좌우 패널로
|
|
||||||
# 쪼개 각 칸에 넣으면 같은 비교가 두 겹으로 들어가 오히려 읽히지 않는다.
|
|
||||||
if sl.get("fallback") and not (has_clip(sl["left"].get("clip"))
|
|
||||||
or has_clip(sl["right"].get("clip"))):
|
|
||||||
body.append('<figure class="figure"><div class="media still tall">'
|
|
||||||
'<span class="badge-reenact">재현 이미지</span>'
|
|
||||||
f'<img src="{data_uri(sl["fallback"])}" '
|
|
||||||
f'alt="{esc(sl.get("title", ""))} (재현 이미지)"></div></figure>')
|
|
||||||
else:
|
|
||||||
panes = []
|
|
||||||
for side in ("left", "right"):
|
|
||||||
pane = sl[side]
|
|
||||||
panes.append(
|
|
||||||
f'<div class="pane {esc(pane["tone"])}"><span class="tag">{esc(pane["label"])}</span>'
|
|
||||||
+ media_block(pane.get("clip"), pane["poster"], pane["label"])
|
|
||||||
+ f'<p>{esc(pane["body"])}</p></div>')
|
|
||||||
body.append(f'<div class="compare">{"".join(panes)}</div>')
|
|
||||||
|
|
||||||
elif kind == "metrics":
|
|
||||||
items = "".join(f'<div class="m"><h3>{esc(m["head"])}</h3><p>{esc(m["body"])}</p></div>'
|
|
||||||
for m in sl["items"])
|
|
||||||
body.append(f'<div class="metrics">{items}</div>')
|
|
||||||
|
|
||||||
elif kind == "status":
|
|
||||||
def box(cls: str, blk: dict) -> str:
|
|
||||||
li = "".join(f"<li>{esc(i)}</li>" for i in blk["items"])
|
|
||||||
return f'<div class="box {cls}"><h3>{esc(blk["head"])}</h3><ul>{li}</ul></div>'
|
|
||||||
body.append('<div class="status">' + box("done", sl["done"])
|
|
||||||
+ box("pending", sl["pending"]) + "</div>")
|
|
||||||
|
|
||||||
else:
|
|
||||||
raise SystemExit(f"ERROR: 알 수 없는 슬라이드 종류: {kind}")
|
|
||||||
|
|
||||||
body.append(note(sl))
|
|
||||||
return (f'<section class="slide" id="s{idx}"><span class="num">{idx:02d}</span>'
|
|
||||||
+ "".join(body) + "</section>")
|
|
||||||
|
|
||||||
|
|
||||||
def render(doc: dict, version: str) -> str:
|
|
||||||
slides = doc["slides"]
|
|
||||||
dots = "".join(f'<a href="#s{i}" aria-label="슬라이드 {i}"></a>' for i in range(len(slides)))
|
|
||||||
body = "".join(render_slide(sl, i) for i, sl in enumerate(slides))
|
|
||||||
today = datetime.date.today().isoformat()
|
|
||||||
return (
|
|
||||||
'<!doctype html><html lang="ko"><head><meta charset="utf-8">'
|
|
||||||
'<meta name="viewport" content="width=device-width, initial-scale=1">'
|
|
||||||
f"<title>{esc(doc['title'])} — {esc(doc['audience'])}</title>"
|
|
||||||
f"<style>{CSS}</style></head><body>"
|
|
||||||
f'<nav class="rail"><span class="mark">{esc(doc["title"])}</span>'
|
|
||||||
f'<span class="dots">{dots}</span>'
|
|
||||||
f'<span class="mark">v{esc(version)} · {today}</span></nav>'
|
|
||||||
f'<main class="deck">{body}</main>'
|
|
||||||
'<div class="nav">'
|
|
||||||
'<button class="nav-prev" type="button" aria-label="이전 슬라이드">↑</button>'
|
|
||||||
'<button class="nav-next" type="button" aria-label="다음 슬라이드">↓</button>'
|
|
||||||
'</div>'
|
|
||||||
'<p class="help">← → 또는 버튼으로 이동 · F11 전체화면</p>'
|
|
||||||
f"<script>{JS}</script></body></html>")
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
ap = argparse.ArgumentParser()
|
|
||||||
ap.add_argument("--out", default=str(HERE / "경영진-보고.html"))
|
|
||||||
args = ap.parse_args()
|
|
||||||
|
|
||||||
vf = REPO / "VERSION"
|
|
||||||
if not vf.is_file():
|
|
||||||
raise SystemExit("ERROR: VERSION 파일이 없습니다. 버전 없는 보고 자료는 만들지 않습니다.")
|
|
||||||
version = vf.read_text(encoding="utf-8").strip()
|
|
||||||
|
|
||||||
doc = json.loads((HERE / "deck.json").read_text(encoding="utf-8"))
|
|
||||||
out = pathlib.Path(args.out)
|
|
||||||
out.write_text(render(doc, version), encoding="utf-8")
|
|
||||||
|
|
||||||
wanted, have = [], []
|
|
||||||
for sl in doc["slides"]:
|
|
||||||
for c in ([sl.get("clip")] if sl.get("clip") else []) + \
|
|
||||||
[sl[s].get("clip") for s in ("left", "right") if isinstance(sl.get(s), dict)]:
|
|
||||||
if c and c not in wanted:
|
|
||||||
wanted.append(c)
|
|
||||||
if has_clip(c):
|
|
||||||
have.append(c)
|
|
||||||
missing = [c for c in wanted if c not in have]
|
|
||||||
|
|
||||||
print(f"{out} ({out.stat().st_size/1024:.0f} KB, 슬라이드 {len(doc['slides'])}장)", file=sys.stderr)
|
|
||||||
if missing:
|
|
||||||
print(f"클립 대기 {len(missing)}/{len(wanted)} — 해당 자리는 스틸로 대체됨: "
|
|
||||||
+ ", ".join(missing), file=sys.stderr)
|
|
||||||
else:
|
|
||||||
print("클립 전량 배치됨. HTML 과 clips/ 폴더를 같이 옮기십시오.", file=sys.stderr)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
|
|
@ -1,203 +0,0 @@
|
||||||
{
|
|
||||||
"title": "작업장 안전 AI CCTV",
|
|
||||||
"subtitle": "쓰러진 사람을 3초 안에 알아채고, 경광등을 울리고, 증거를 남깁니다",
|
|
||||||
"audience": "경영진 보고",
|
|
||||||
"slides": [
|
|
||||||
{
|
|
||||||
"kind": "cover",
|
|
||||||
"image": "01-현장-설치.jpg",
|
|
||||||
"eyebrow": "제품 보고",
|
|
||||||
"title": "작업장 안전 AI CCTV",
|
|
||||||
"lead": "쓰러진 사람을 3초 안에 알아채고, 경광등을 울리고, 증거를 남깁니다.",
|
|
||||||
"foot": "감지 · 대응 · 증거 — 셋이 한 사슬로 묶인 시스템"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": "statement",
|
|
||||||
"eyebrow": "문제",
|
|
||||||
"title": "CCTV는 이미 있습니다.<br>그런데 왜 사고를 못 막습니까.",
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"head": "아무도 안 보고 있다",
|
|
||||||
"body": "카메라 수십 대를 사람이 실시간으로 볼 수는 없습니다. 화면은 사고가 난 뒤에 되감기 위한 것이 됩니다."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"head": "알아도 알릴 방법이 없다",
|
|
||||||
"body": "설령 감지해도 화면에만 뜨면 그것은 기록 장치입니다. 새벽 3시에 넘어진 사람은 아침까지 그대로입니다."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"head": "나중에 설명할 수 없다",
|
|
||||||
"body": "사고 조사에서 필요한 것은 영상이 아니라 시각·조치·통보의 기록인데, 그 기록이 남지 않습니다."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": "figure",
|
|
||||||
"eyebrow": "해결 구조",
|
|
||||||
"title": "하나의 사슬입니다",
|
|
||||||
"image": "05-사슬.png",
|
|
||||||
"note": "어느 한 칸이 비면 나머지가 무의미해집니다. 감지만 있으면 아무도 모르고, 대응만 있으면 나중에 설명할 수 없습니다."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": "clip",
|
|
||||||
"eyebrow": "핵심 시연",
|
|
||||||
"title": "쓰러지면, 경광등이 켜집니다",
|
|
||||||
"clip": "v01-넘어짐-경광등.mp4",
|
|
||||||
"poster": "02-경광등.jpg",
|
|
||||||
"caption": "쓰러진 자세가 3초 지속되면 확정. 감지한 노드가 자기 GPIO로 직접 울립니다.",
|
|
||||||
"points": [
|
|
||||||
"서버도 인터넷도 거치지 않습니다 — 회선이 끊겨도, 서버가 죽어도 울립니다",
|
|
||||||
"10초 넘게 움직임이 없으면 최상위 위급으로 스스로 올라갑니다",
|
|
||||||
"1분 안에 담당자에게, 5분 안에 확인되지 않으면 상위 담당자에게"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": "figure",
|
|
||||||
"eyebrow": "판정 근거",
|
|
||||||
"title": "언제 감지되는지 설명할 수 있습니다",
|
|
||||||
"image": "08-넘어짐-감지-시퀀스.jpg",
|
|
||||||
"note": "한 프레임 보고 놀라서 울리는 것이 아니라, 자세가 지속되는지를 초 단위로 확인하고 단계적으로 올립니다. 도중에 일어서면 알람 없이 니어미스로만 기록됩니다 — 사고 전 신호가 통계로 쌓입니다."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": "compare",
|
|
||||||
"eyebrow": "가장 많이 받는 질문",
|
|
||||||
"title": "앉으면 오탐이 나지 않습니까",
|
|
||||||
"left": {
|
|
||||||
"label": "울리지 않음",
|
|
||||||
"tone": "ok",
|
|
||||||
"clip": "v02-앉기-정상.mp4",
|
|
||||||
"poster": "09-앉기-vs-넘어짐.jpg",
|
|
||||||
"body": "쭈그려 앉거나 허리를 굽힌 자세는 세로가 여전히 깁니다."
|
|
||||||
},
|
|
||||||
"right": {
|
|
||||||
"label": "울림",
|
|
||||||
"tone": "alarm",
|
|
||||||
"clip": "v01-넘어짐-경광등.mp4",
|
|
||||||
"poster": "08-넘어짐-감지-시퀀스.jpg",
|
|
||||||
"body": "가로가 세로보다 넓어진 상태가 3초 이상 지속될 때만 확정합니다."
|
|
||||||
},
|
|
||||||
"note": "그래도 오탐은 납니다. 화면에서 오탐 표시 한 번이면 통계에서 빠지고, 감도 조정의 근거가 됩니다. 오탐율은 화면에 그대로 표시됩니다.",
|
|
||||||
"fallback": "09-앉기-vs-넘어짐.jpg"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": "clip",
|
|
||||||
"eyebrow": "구역 규칙",
|
|
||||||
"title": "위험구역은 화면 위에 직접 그립니다",
|
|
||||||
"clip": "v04-구역진입.mp4",
|
|
||||||
"poster": "10-위험구역-판정.jpg",
|
|
||||||
"caption": "판정 기준은 발 위치입니다. 구역 안에 0.75초 이상 머물면 이벤트가 남습니다.",
|
|
||||||
"points": [
|
|
||||||
"경고 · 제한 · 협착 3단계로 위험도를 나눠 지정합니다",
|
|
||||||
"2인 1조 구역에 혼자 남으면 30초 뒤 사건이 됩니다",
|
|
||||||
"정원 초과 · 장시간 체류도 같은 방식으로 규칙화됩니다"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": "clip",
|
|
||||||
"eyebrow": "차별점",
|
|
||||||
"title": "감시 장비 자신의 고장도 신고합니다",
|
|
||||||
"clip": "v05-카메라-건드림.mp4",
|
|
||||||
"poster": "01-현장-설치.jpg",
|
|
||||||
"caption": "청소하다 카메라를 스치면 위험구역이 엉뚱한 바닥을 가리킵니다. 그런데 프레임은 계속 들어오고 화면은 정상으로 보입니다.",
|
|
||||||
"points": [
|
|
||||||
"화각 변경 · 렌즈 가림 · 조도 저하를 스스로 감지합니다",
|
|
||||||
"노드가 응답을 멈추면 서버가 하트비트의 부재로 판정합니다",
|
|
||||||
"\"아무도 확인하지 않았다\"는 사실 자체도 사건으로 기록됩니다"
|
|
||||||
],
|
|
||||||
"kicker": "이것이 이 제품의 조용한 실패 방어선입니다. 대부분의 감시 시스템은 자기가 멈춘 것을 모릅니다."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": "figure",
|
|
||||||
"eyebrow": "기대효과",
|
|
||||||
"title": "달라지는 것은 발견까지 걸리는 시간입니다",
|
|
||||||
"image": "07-대응시간.png",
|
|
||||||
"note": "사고를 없애지는 못합니다. 발견과 대응이 빨라질 뿐입니다 — 그런데 쓰러진 사람에게는 그 차이가 결과를 바꿉니다."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": "metrics",
|
|
||||||
"eyebrow": "기대효과",
|
|
||||||
"title": "효과는 저희가 주장하지 않습니다.<br>시스템이 스스로 측정해 보여줍니다",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"head": "평균 대응시간",
|
|
||||||
"body": "감지에서 확인까지 걸린 시간. 개선 활동의 성패가 이 숫자 하나로 드러납니다."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"head": "니어미스 : 사고 비율",
|
|
||||||
"body": "넘어질 뻔한 횟수가 쌓입니다. 사고가 나기 전에 어디가 위험한지 보입니다."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"head": "조치 전후 감지 밀도",
|
|
||||||
"body": "안전 조치를 한 구역에서 이벤트가 실제로 줄었는지 히트맵으로 비교합니다."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"head": "오탐율",
|
|
||||||
"body": "숨기지 않고 화면에 표시합니다. 신뢰할 수 있는 시스템인지 스스로 증명하게 합니다."
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"note": "공급자의 ROI 추정이 아니라 고객 화면의 숫자입니다."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": "statement",
|
|
||||||
"eyebrow": "반드시 나올 질문",
|
|
||||||
"title": "\"근로자 감시 아닙니까\"",
|
|
||||||
"lead": "노조가 물을 것이고, 물어야 합니다. 답은 약속이 아니라 구조여야 합니다.",
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"head": "영상을 녹화하지 않습니다",
|
|
||||||
"body": "실시간으로 들여다보는 화면 자체가 제품에 없습니다. 위험 상황이 감지된 순간에만 사진 3장이 남고, 7일 뒤 자동 삭제됩니다."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"head": "누구인지 모릅니다",
|
|
||||||
"body": "이름도 사번도 얼굴 인식도 없습니다. 얼굴은 카메라 쪽에서, 전송 전에 가려집니다."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"head": "근무태도 기능이 없습니다",
|
|
||||||
"body": "감지 20종 전부가 위험 상태입니다. 자리 비움·작업 속도는 만들 수 있는 기능 자체가 없습니다."
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"note": "존재하지 않는 데이터는 열람될 수 없고, 유출될 수 없고, 목적 외로 쓰일 수 없습니다. 접근 통제는 되돌릴 수 있지만 미수집은 되돌릴 수 없습니다."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": "figure",
|
|
||||||
"eyebrow": "구성",
|
|
||||||
"title": "현장에 노드, 사무실에 대시보드",
|
|
||||||
"image": "06-구성도.png",
|
|
||||||
"note": "노드에서 서버로 가는 것은 스냅샷 3장과 메타데이터뿐입니다. 폐쇄망에서도 동작하며, 회선이 끊긴 동안의 이벤트는 노드에 쌓였다가 복구되면 올라갑니다."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": "status",
|
|
||||||
"eyebrow": "현재 상태",
|
|
||||||
"title": "지금 어디까지 와 있습니까",
|
|
||||||
"done": {
|
|
||||||
"head": "완료 — 1.2.0 납품 버전",
|
|
||||||
"items": [
|
|
||||||
"감지 20종(추락·동시 다발 포함) · 통보 · 에스컬레이션 · 사고조사 패키지",
|
|
||||||
"자동 테스트 548건 통과 (엣지 308 · 서버 240)",
|
|
||||||
"납품 점검 43건 + 재감사 12건 전량 해소 — 판정 적합",
|
|
||||||
"폐쇄망 설치 패키지 · 역할별 사용설명서"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"pending": {
|
|
||||||
"head": "남은 것 — 실장비 현장 검증",
|
|
||||||
"items": [
|
|
||||||
"경광등 실제 점등 · 카메라 감도 현장 튜닝",
|
|
||||||
"실제 영상으로 넘어짐 판정 타이밍 확인",
|
|
||||||
"보호구 미착용 · 중장비 근접 · 화재 감지는 모델 확보가 선행되어야 합니다"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"note": "감지율 100%는 없습니다. 초기에는 오탐이 반드시 납니다. 그 사실을 숨기지 않는 것이 이 시스템을 계속 쓰게 만드는 조건입니다."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": "closing",
|
|
||||||
"eyebrow": "요약",
|
|
||||||
"title": "이 시스템이 하는 일",
|
|
||||||
"lines": [
|
|
||||||
"쓰러진 자세가 <b>3초</b> 지속되면 확정하고,",
|
|
||||||
"감지한 노드가 <b>즉시</b> 경광등을 울리고,",
|
|
||||||
"<b>1분</b> 안에 담당자에게, <b>5분</b> 안에 확인되지 않으면 위로 올립니다.",
|
|
||||||
"그리고 무슨 일이 있었는지 <b>지울 수 없는 형태로</b> 남깁니다."
|
|
||||||
],
|
|
||||||
"foot": "감지 · 대응 · 증거"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,149 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""덱에 넣을 클립을 브라우저가 실제로 재생하는 형식으로 변환한다.
|
|
||||||
|
|
||||||
WHY THIS EXISTS
|
|
||||||
받은 클립은 HEVC(H.265) 였고, 그대로는 브라우저에서 디코딩되지 않았다. 같은 브라우저
|
|
||||||
같은 페이지에서 대조한 결과:
|
|
||||||
|
|
||||||
HEVC .mp4 -> readyState 0, videoWidth 0, duration NaN (error 이벤트조차 없음)
|
|
||||||
VP9 .webm -> readyState 1+, videoWidth 1600, duration 4.04
|
|
||||||
|
|
||||||
서버를 배제하고 blob 으로 통째로 올려도 HEVC 는 같았으므로 전송이 아니라 형식 문제다.
|
|
||||||
HEVC 는 재생 가능 여부가 OS·GPU·브라우저 조합을 타고(파이어폭스는 사실상 불가), 실패해도
|
|
||||||
error 없이 검은 상자로 남는다 — 리허설 없이는 모르고 회의실에서 처음 알게 되는 고장이라,
|
|
||||||
변환을 사람 손이 아니라 스크립트에 둔다.
|
|
||||||
|
|
||||||
확인하지 못한 것도 적어 둔다: **재생이 실제로 진행되는지**는 검증하지 못했다. 이 자동화
|
|
||||||
브라우저 컨텍스트에서는 디코딩까지만 되고 currentTime 이 0 에서 움직이지 않는다.
|
|
||||||
발표 노트북에서 한 번 눌러 보는 것으로 대체해야 한다. 재생이 안 되더라도 poster 가
|
|
||||||
남으므로 슬라이드가 비지는 않는다.
|
|
||||||
|
|
||||||
WHY VP9/WEBM AND NOT H.264
|
|
||||||
이 머신에는 ffmpeg 도, OpenCV 가 쓸 openh264 DLL 도 없어 H.264 인코딩이 불가능하다
|
|
||||||
(avc1/H264/X264 전부 VideoWriter 열기 실패). VP9/WebM 은 OpenCV 가 그대로 쓰고,
|
|
||||||
크로미움 계열과 파이어폭스가 코덱 확장 없이 재생한다.
|
|
||||||
|
|
||||||
H.264 가 필요해지면(구형 사파리 등) ffmpeg 를 설치하고 아래 CODEC 만 바꾸면 된다.
|
|
||||||
|
|
||||||
사용법:
|
|
||||||
python docs/deck/prepare_clips.py # image/ -> docs/deck/clips/
|
|
||||||
python docs/deck/prepare_clips.py --src <디렉터리>
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import pathlib
|
|
||||||
import sys
|
|
||||||
|
|
||||||
HERE = pathlib.Path(__file__).resolve().parent
|
|
||||||
REPO = HERE.parents[1]
|
|
||||||
CLIPS = HERE / "clips"
|
|
||||||
|
|
||||||
# 덱에서 클립이 실제로 그려지는 폭은 1000px 안팎이다. 1920 을 그대로 두면 파일만 커지고
|
|
||||||
# 화면에서 달라지는 것은 없다.
|
|
||||||
MAX_W = 1600
|
|
||||||
CODEC = "VP90"
|
|
||||||
SUFFIX = ".webm"
|
|
||||||
SOURCE_EXT = {".mp4", ".mov", ".m4v", ".mkv"}
|
|
||||||
|
|
||||||
|
|
||||||
def transcode(src: pathlib.Path, dst: pathlib.Path) -> dict:
|
|
||||||
import cv2
|
|
||||||
|
|
||||||
cap = cv2.VideoCapture(str(src))
|
|
||||||
if not cap.isOpened():
|
|
||||||
raise SystemExit(f"ERROR: 읽을 수 없습니다(코덱 미지원): {src}")
|
|
||||||
|
|
||||||
fps = cap.get(cv2.CAP_PROP_FPS) or 24.0
|
|
||||||
w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
|
||||||
h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
|
||||||
if w <= 0 or h <= 0:
|
|
||||||
raise SystemExit(f"ERROR: 해상도를 읽지 못했습니다: {src}")
|
|
||||||
|
|
||||||
ow = min(w, MAX_W)
|
|
||||||
oh = int(round(h * ow / w))
|
|
||||||
oh -= oh % 2 # 짝수 높이 — 홀수면 인코더가 거부하는 경우가 있다
|
|
||||||
|
|
||||||
writer = cv2.VideoWriter(str(dst), cv2.VideoWriter_fourcc(*CODEC), fps, (ow, oh))
|
|
||||||
if not writer.isOpened():
|
|
||||||
raise SystemExit(f"ERROR: {CODEC} 인코더를 열지 못했습니다. OpenCV 빌드를 확인하십시오.")
|
|
||||||
|
|
||||||
n = 0
|
|
||||||
while True:
|
|
||||||
ok, frame = cap.read()
|
|
||||||
if not ok:
|
|
||||||
break
|
|
||||||
if (ow, oh) != (w, h):
|
|
||||||
frame = cv2.resize(frame, (ow, oh), interpolation=cv2.INTER_AREA)
|
|
||||||
writer.write(frame)
|
|
||||||
n += 1
|
|
||||||
writer.release()
|
|
||||||
cap.release()
|
|
||||||
|
|
||||||
if n == 0:
|
|
||||||
dst.unlink(missing_ok=True)
|
|
||||||
raise SystemExit(f"ERROR: 프레임을 하나도 읽지 못했습니다: {src}")
|
|
||||||
|
|
||||||
# 클립 자신의 마지막 프레임을 포스터로 뽑는다.
|
|
||||||
#
|
|
||||||
# deck.json 에 적힌 포스터는 클립이 없을 때 쓰는 대체 도판이라, 클립이 생기면 오히려
|
|
||||||
# 어긋난다 — 비교 슬라이드에서는 좌우 비교 도판이 좌우 패널 각각에 들어가 비교가 두 겹이
|
|
||||||
# 됐다. 클립에서 뽑으면 그 영상이 무엇인지 정확히 보여주고, 새 클립이 와도 자동으로 맞는다.
|
|
||||||
# 마지막 프레임을 쓰는 이유: 이 클립들의 요지는 결과(쓰러짐·경광등 점등·정상 유지)다.
|
|
||||||
cap = cv2.VideoCapture(str(src))
|
|
||||||
last = None
|
|
||||||
while True:
|
|
||||||
ok, f = cap.read()
|
|
||||||
if not ok:
|
|
||||||
break
|
|
||||||
last = f
|
|
||||||
cap.release()
|
|
||||||
poster = dst.with_suffix(".poster.jpg")
|
|
||||||
if last is not None:
|
|
||||||
if (ow, oh) != (w, h):
|
|
||||||
last = cv2.resize(last, (ow, oh), interpolation=cv2.INTER_AREA)
|
|
||||||
# cv2.imwrite 는 Windows 에서 한글이 든 경로에 쓰지 못한다 — 예외도 없이 False 만
|
|
||||||
# 돌려주고 조용히 넘어간다(파일명이 전부 한글인 이 프로젝트에서는 전부 실패한다).
|
|
||||||
# 메모리로 인코딩한 뒤 파이썬이 쓰면 경로 인코딩과 무관해진다.
|
|
||||||
ok, buf = cv2.imencode(".jpg", last, [cv2.IMWRITE_JPEG_QUALITY, 88])
|
|
||||||
if not ok:
|
|
||||||
raise SystemExit(f"ERROR: 포스터 인코딩 실패: {src}")
|
|
||||||
poster.write_bytes(buf.tobytes())
|
|
||||||
|
|
||||||
return {"frames": n, "fps": fps, "size": f"{ow}x{oh}", "poster": poster.name,
|
|
||||||
"seconds": round(n / fps, 2), "bytes": dst.stat().st_size}
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
ap = argparse.ArgumentParser()
|
|
||||||
ap.add_argument("--src", default=str(REPO / "image"))
|
|
||||||
ap.add_argument("--force", action="store_true", help="이미 변환된 것도 다시 만든다")
|
|
||||||
args = ap.parse_args()
|
|
||||||
|
|
||||||
src_dir = pathlib.Path(args.src)
|
|
||||||
if not src_dir.is_dir():
|
|
||||||
raise SystemExit(f"ERROR: 원본 디렉터리가 없습니다: {src_dir}")
|
|
||||||
CLIPS.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
found = sorted(p for p in src_dir.iterdir()
|
|
||||||
if p.suffix.lower() in SOURCE_EXT and p.stem.startswith("v"))
|
|
||||||
if not found:
|
|
||||||
print(f"변환할 클립이 없습니다: {src_dir} (파일명은 v01-... 형식)", file=sys.stderr)
|
|
||||||
return
|
|
||||||
|
|
||||||
for src in found:
|
|
||||||
dst = CLIPS / (src.stem + SUFFIX)
|
|
||||||
if dst.exists() and not args.force and dst.stat().st_mtime >= src.stat().st_mtime:
|
|
||||||
print(f"{dst.name}: 최신 — 건너뜀", file=sys.stderr)
|
|
||||||
continue
|
|
||||||
info = transcode(src, dst)
|
|
||||||
print(f"{dst.name}: {info['size']} {info['seconds']}초 "
|
|
||||||
f"{info['frames']}프레임 {info['bytes']/1048576:.2f}MB", file=sys.stderr)
|
|
||||||
if info["seconds"] > 3.6:
|
|
||||||
# 브리프는 3초(±0.3)를 요구했다. 길다고 못 쓰는 것은 아니지만, 슬라이드당 3초를
|
|
||||||
# 전제로 짠 발표 흐름이므로 알고는 있어야 한다.
|
|
||||||
print(f" 주의: {info['seconds']}초 — 브리프 기준(3초±0.3)보다 깁니다.", file=sys.stderr)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
|
|
@ -1,12 +1,5 @@
|
||||||
# Delivery Audit — move-detect (납품 전 결함 목록 · 순차 처리)
|
# Delivery Audit — move-detect (납품 전 결함 목록 · 순차 처리)
|
||||||
|
|
||||||
> **판정 (2026-09-25, 1.3.0): 적합 — 오프하드웨어 항목 전량 해소.**
|
|
||||||
> 1차 43/43 + 2차 12/12 + 3차(1.1.0 장치 건전성) 14/14 +
|
|
||||||
> **4차 재감사(1.2.0 추락·동시 다발·프리셋 표면) 23/23** 해소.
|
|
||||||
> 2026-09-21 납품 정비로 Pi 번들 재빌드·MoveNet 원본·자격증명 회전 도구까지 닫혔다.
|
|
||||||
> **5차 이해관계자 페르소나 검증(2026-09-25) 4/4** — 열람 감사·크론 생존·증거 보존·증거 정책.
|
|
||||||
> 잔여는 실장비(OFF-HW) 검증과 **운영자가 직접 해야 하는 자격증명 교체** 뿐.
|
|
||||||
|
|
||||||
Source: 62-agent adversarial audit (2026-07-16) + skill `iot-airgap-delivery` §3 + manual
|
Source: 62-agent adversarial audit (2026-07-16) + skill `iot-airgap-delivery` §3 + manual
|
||||||
verification of every blocker against real code. Ordered for sequential fixing. Each item is
|
verification of every blocker against real code. Ordered for sequential fixing. Each item is
|
||||||
checked off only after a test locks it and verification passes.
|
checked off only after a test locks it and verification passes.
|
||||||
|
|
@ -102,23 +95,13 @@ misconfig/degradation fail LOUD.
|
||||||
normalize by elapsed time.
|
normalize by elapsed time.
|
||||||
DONE — track_displacement divides by dt (per-second speed); test_geometry.py locks
|
DONE — track_displacement divides by dt (per-second speed); test_geometry.py locks
|
||||||
FPS-invariance. NOTE: motion_thr is now per-second and needs on-device re-tuning.
|
FPS-invariance. NOTE: motion_thr is now per-second and needs on-device re-tuning.
|
||||||
- [x] **P2-6 [M]** `edge/tracking.py:50` — greedy IoU matching swaps IDs when workers cross,
|
- [ ] **P2-6 [M]** `edge/tracking.py:50` — greedy IoU matching swaps IDs when workers cross,
|
||||||
transferring FALLEN state + frozen baseline to the wrong person. **Fix:** gate matches;
|
transferring FALLEN state + frozen baseline to the wrong person. **Fix:** gate matches;
|
||||||
reset per-track safety state on a low-confidence match.
|
reset per-track safety state on a low-confidence match.
|
||||||
DONE — global greedy (all pairs sorted by IoU desc) replaces track-order greedy; any track
|
- [ ] **P2-7 [M]** `edge/detectors/fall.py:92` — a single prone-looking frame sets FALLING and
|
||||||
in a contended match (person↔2 tracks or track↔2 persons) is reported via
|
|
||||||
`tracker.ambiguous()` and run_once resets its engine + smoother state. A still-prone worker
|
|
||||||
re-confirms within prone_confirm_s. test_tracking.py + test_app.py lock both.
|
|
||||||
- [x] **P2-7 [M]** `edge/detectors/fall.py:92` — a single prone-looking frame sets FALLING and
|
|
||||||
arms a CRITICAL ghost if the person then leaves. **Fix:** require sustained prone.
|
arms a CRITICAL ghost if the person then leaves. **Fix:** require sustained prone.
|
||||||
DONE — STANDING→FALLING now requires sustained prone (`falling_confirm_s`, default 1.0s,
|
- [ ] **P2-8 [M]** `edge/app.py:30` — detection dropouts keep a track 'active' with a frozen
|
||||||
dataclass-only, no env knob). FALLEN/no_motion timing unchanged (keyed off prone_since).
|
|
||||||
test_fall.py locks it.
|
|
||||||
- [x] **P2-8 [M]** `edge/app.py:30` — detection dropouts keep a track 'active' with a frozen
|
|
||||||
frame; time-based timers accrue on stale data. **Fix:** treat a stale track as no-data.
|
frame; time-based timers accrue on stale data. **Fix:** treat a stale track as no-data.
|
||||||
DONE — run_once feeds the engine (and smoothers) only tracks re-detected THIS tick
|
|
||||||
(`latest.t == now`); a stale track is no-data until re-detected or lost. Re-detection still
|
|
||||||
confirms the fall (dropout pauses evidence, doesn't erase it). test_app.py locks both.
|
|
||||||
- [x] **P2-9 [M]** `edge/detectors/zone.py:45` — foot point averages both ankles with no
|
- [x] **P2-9 [M]** `edge/detectors/zone.py:45` — foot point averages both ankles with no
|
||||||
confidence check; a hallucinated ankle at (0,0) drags the foot into/out of a zone. **Fix:**
|
confidence check; a hallucinated ankle at (0,0) drags the foot into/out of a zone. **Fix:**
|
||||||
confidence-gate the ankles, fall back to bbox-bottom-center.
|
confidence-gate the ankles, fall back to bbox-bottom-center.
|
||||||
|
|
@ -165,11 +148,6 @@ misconfig/degradation fail LOUD.
|
||||||
— PINTO hosts models off-repo), so it was silently saving garbage. No known-good sha256 to
|
— PINTO hosts models off-repo), so it was silently saving garbage. No known-good sha256 to
|
||||||
pin until a valid MOVENET_ONNX_URL is set. NON-BLOCKING for the Hailo delivery path (MoveNet
|
pin until a valid MOVENET_ONNX_URL is set. NON-BLOCKING for the Hailo delivery path (MoveNet
|
||||||
is the optional CPU fallback); flag for whoever needs CPU MoveNet.
|
is the optional CPU fallback); flag for whoever needs CPU MoveNet.
|
||||||
CLOSED 2026-09-21 — 유효한 출처를 찾아 고정했다(tf2onnx 변환본, sha256+size). 고정 전에
|
|
||||||
어댑터 계약과 대조했다: 입력 `[1,192,192,3] int32` NHWC, 출력 `[1,1,17,3]`. 레이아웃이 다른
|
|
||||||
변환본은 **로드는 되고 좌표만 틀리는** 실패라, 그 계약을 테스트로 박았다
|
|
||||||
(`tests/hardware/test_movenet_smoke.py::test_model_signature_is_what_the_adapter_feeds_and_reads`).
|
|
||||||
실사진 2장으로 코<어깨<엉덩이<발목 세로 순서와 bbox 프레임 내 포함까지 확인.
|
|
||||||
|
|
||||||
## Phase 4 — Server hardening (shared hosting)
|
## Phase 4 — Server hardening (shared hosting)
|
||||||
|
|
||||||
|
|
@ -270,333 +248,6 @@ misconfig/degradation fail LOUD.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 6 — 재감사 2026-08-18 (안전 플랫폼 확장 표면)
|
|
||||||
|
|
||||||
커밋 `e00ad69` 로 감사 표면이 크게 늘었다: 신규 라우트 17개, DB 표 6종 + 컬럼 4종, CLI 도구
|
|
||||||
5개, 엣지 모듈 4개(GPIO 출력 포함), 통보 발송 경로(메일·웹훅) 신설, 감사로그 해시체인.
|
|
||||||
8개 관점(보안·데이터 정합·렌더·패키징·문서 정합·성능·UX·회귀)으로 재감사, 확정 12건 전량 해소.
|
|
||||||
|
|
||||||
### CRITICAL
|
|
||||||
|
|
||||||
- [x] **A1 [B]** `server/src/Ingest/EventValidator.php` — 신규 감지 유형(`camera_moved`,
|
|
||||||
`camera_obstructed`, `low_light`)이 ingest 허용 목록에 없어 **서버가 전부 400 으로 거부**했다.
|
|
||||||
`edge/queue.py` 는 20회 재시도 후 그 행을 버리므로, 카메라 무결성 감지는 구현·테스트·배포가
|
|
||||||
전부 끝난 상태로 end-to-end 무력화되어 있었다 — 로그를 뒤지지 않는 한 아무도 모른다.
|
|
||||||
DB CHECK 와 프런트 라벨은 갱신했는데 **가운데 검증 계층만** 빠진, 계층 간 목록 중복의 전형적 결함.
|
|
||||||
DONE — 허용 목록 갱신 + **언어 경계를 넘는 회귀 테스트** 신설
|
|
||||||
(`tests/test_event_type_parity.py`: `edge/events.py` ↔ EventValidator ↔ schema.sql ↔
|
|
||||||
프런트 라벨 5종 ↔ 유형 필터). 파이썬·PHP 어느 쪽 테스트도 잡을 수 없던 자리다.
|
|
||||||
- [x] **A2 [B]** `server/tools/seed_demo.php` — `--purge` 가 위험성평가를
|
|
||||||
`reviewed_by='안전관리자' AND site='factory-a'` 로 지웠다. '안전관리자'는 이 제품의 **기본
|
|
||||||
관리자 계정명**이고 'factory-a'는 흔한 현장명이라, 운영 DB 에서 한 번 실행하면 실제
|
|
||||||
위험성평가가 복구 불가능하게 사라진다.
|
|
||||||
DONE — 데모 행은 `DEMO_PREFIX`/`DEMO_ACTOR`/`DEMO_LABEL` 표식으로만 식별. 표식 없는 행은
|
|
||||||
어떤 경우에도 건드리지 않는다.
|
|
||||||
- [x] **A3 [B]** `server/tools/backup.php` — 웹 공개 경로 차단이 `str_contains($real, '/www/')`
|
|
||||||
라 **docroot 자체**(`/home/acct/www`)는 뒤에 슬래시가 없어 통과했다. 가장 흔한 오사용이
|
|
||||||
정확히 그 경우다. 전 현장의 사고 기록 + 작업자 이미지가 웹에서 내려받힌다 (파일명은
|
|
||||||
`move_detect-YYYYmmdd-HHMMSS.sql` 로 하루 범위만 알면 추측 가능).
|
|
||||||
DONE — 경로를 세그먼트로 쪼개 `www/public_html/htdocs/html/web/public` 중 하나라도 있으면 거부.
|
|
||||||
회귀 검증: docroot 자체·하위 경로 모두 차단, 비공개 경로는 통과.
|
|
||||||
|
|
||||||
### HIGH
|
|
||||||
|
|
||||||
- [x] **A4 [H]** 신규 유형이 `app.js`/`detail.js`/`report.js` 의 `TYPE_LABELS` 와 이벤트 유형
|
|
||||||
필터에 없어 화면에 `camera_moved` 같은 원시 코드가 그대로 표시됐다. 조사자가 그 유형만
|
|
||||||
걸러 볼 방법도 없었다. DONE — 3개 맵 + 필터 갱신, A1 의 파리티 테스트가 재발을 막는다.
|
|
||||||
- [x] **A5 [H]** 서버가 `date_default_timezone_set` 를 하지 않아 통보 메일의 시각이 PHP 기본
|
|
||||||
(공유호스팅은 흔히 UTC)으로 찍혔다. 대시보드는 브라우저 시각을 쓰므로, 콘솔이 13:01 로
|
|
||||||
보여준 넘어짐을 메일이 04:01 로 적는다 — 응급 대응에서 읽는 유일한 문장이 9시간 어긋난다.
|
|
||||||
DONE — `Config::timezone()` (기본 `Asia/Seoul`, 잘못된 값이면 폴백하되 발송은 막지 않음),
|
|
||||||
`Channels` 가 프로세스 전역 기본값을 건드리지 않고 해당 시간대로 렌더.
|
|
||||||
- [x] **A6 [H]** 마이그레이션의 `ALTER TABLE … DROP CHECK` 는 MySQL 8.0.16+ 전용 문법이다.
|
|
||||||
카페24 공유호스팅에 흔한 **MariaDB 는 `DROP CONSTRAINT` 를 쓰므로 마이그레이션이 통째로 실패**한다.
|
|
||||||
DONE — 해당 블록에 엔진별 분기(A: MySQL 8.0.16+ / B: MariaDB / C: 제약 없음) 를 명시하고,
|
|
||||||
이 CHECK 가 2차 방어선이며 1차는 `EventValidator` 임을 적어 실패해도 판단할 수 있게 함.
|
|
||||||
적용 확인용 probe INSERT/DELETE 도 함께 기재.
|
|
||||||
|
|
||||||
### MEDIUM
|
|
||||||
|
|
||||||
- [x] **A7 [M]** `AlertDispatcher::run` 이 매분 창 전체를 다시 훑으며 이미 보낸
|
|
||||||
(event, channel) 쌍마다 실패할 INSERT 를 한 번씩 재시도했다. 비용이 '새 이벤트'가 아니라
|
|
||||||
'창 길이'에 비례해, 크론이 불안정해 창을 넓힌 현장일수록 부하가 커진다.
|
|
||||||
DONE — `claimedSince()` 로 창의 원장을 한 번에 읽어 사전 필터. `claim()` 은 여전히 최종
|
|
||||||
권위(경합 시)다. 회귀 테스트가 2·3회차 실행에서 claim 시도가 0 임을 고정.
|
|
||||||
- [x] **A8 [M]** `move_alert_log` 에 보존 정책이 없어 유일하게 무한 증가하는 표였다
|
|
||||||
(이벤트 보존은 기본 비활성이라 CASCADE 도 일어나지 않는다).
|
|
||||||
DONE — `maintain.php` 가 해소된 전달(sent/suppressed)만 정리. **failed 는 남긴다** —
|
|
||||||
"아무에게도 닿지 않았다"는 사실이 조사에서 필요한 발견이라 청소가 지우면 안 된다.
|
|
||||||
- [x] **A9 [M]** 전역 관리자가 현장 채널을 수정하면 `site` 가 NULL 로 덮여 그 채널이 **모든
|
|
||||||
현장에 발화**하게 됐다. 폼 어디에도 그런 안내가 없다.
|
|
||||||
DONE — 수정 시 기존 site 보존, 신규 생성만 호출자 스코프. 양쪽 회귀 테스트 추가.
|
|
||||||
- [x] **A10 [M]** 일괄 확인이 대상마다 fetch 를 하나씩 띄웠다. 7건에서는 무해하지만 실제
|
|
||||||
백로그는 수백 건이고, HTTP/2 에서는 브라우저가 직렬화하지 않아 공유호스팅에 한꺼번에 떨어진다.
|
|
||||||
DONE — 동시 4개로 제한하고 진행 중에도 목록을 갱신(멈춘 것처럼 보이지 않게), 완료 토스트에
|
|
||||||
`성공/전체` 를 함께 표기.
|
|
||||||
|
|
||||||
### LOW
|
|
||||||
|
|
||||||
- [x] **A11 [L]** 패키지 테스트가 `tools/` 배치를 검사하지 않았다. 이 도구들은 메일을 보내고
|
|
||||||
DB 전체를 읽으므로 www/ 아래로 새면 인증 없는 표면이 된다.
|
|
||||||
DONE — ① 모든 CLI 도구가 `bootstrap.php`(SAPI 가드)를 require 하는지 ② 패키지에서 tools/ 가
|
|
||||||
private 폴더에만 있는지 ③ 웹 루트의 PHP 가 `index.php`/`_preflight.php` 뿐인지 검사.
|
|
||||||
- [x] **A12 [L]** 감사 리포트 판정 배너와 `CLAUDE.md` 의 "43/43" 표기가 stale.
|
|
||||||
DONE — 이 문서 상단 배너 + CLAUDE.md 갱신.
|
|
||||||
|
|
||||||
### 재감사에서 확인만 하고 수정하지 않은 항목 (판단 기록)
|
|
||||||
|
|
||||||
- **웹훅 SSRF (내부망 주소 지정 가능)** — 채널 등록은 admin 전용이고, 이 기능의 정당한
|
|
||||||
용도가 바로 **사내망** 그룹웨어·문자게이트웨이다. 사설 IP 대역을 막으면 기능 자체가
|
|
||||||
성립하지 않는다. http(s) 한정 + 리다이렉트 금지 + 토큰 헤더까지가 방어선. 수용.
|
|
||||||
- **서버가 엣지 GPIO 핀 번호를 지정** — 손상된 서버가 임의 핀을 구동할 수 있다. 다만 서버는
|
|
||||||
이미 존(감지 판정 자체)을 내려보내는 신뢰 주체이고, 핀 범위는 서버측에서 0~40 으로 검증된다.
|
|
||||||
이 신뢰 경계를 좁히려면 장치측 화이트리스트가 필요 — 실장비 커미셔닝 항목으로 이관.
|
|
||||||
- **`/setup` 4단계가 첫 카메라의 존만 확인** — 다른 카메라에 존이 있어도 "없음"으로 보일 수
|
|
||||||
있다. 상태 문구에 "첫 카메라에" 를 명시해 오해를 줄였고, 전 카메라 조회는 온보딩 화면이
|
|
||||||
N+1 요청을 내는 대가가 이득보다 크다고 판단. 수용.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3차 재감사 — 1.1.0 장치 건전성 표면 (2026-09-04)
|
|
||||||
|
|
||||||
범위: 1.0.0(`9596d1b`) 이후 변경된 서버 코드 전량 — `DeviceHealthWatch`(신규),
|
|
||||||
`dispatch_alerts.php` 1단계 배선, `Config.php` 설정 4종, `__device__` 센티넬 렌더 경로,
|
|
||||||
config 템플릿 2종. `edge/` 는 불변. 4개 관점 병렬 감사 후 각 발견을 반증 시도해 확정.
|
|
||||||
|
|
||||||
### CRITICAL
|
|
||||||
|
|
||||||
- [x] **R3-1 [B]** `dispatch_alerts.php` 1단계가 try 밖에 있어, 장치 건전성에서 난 예외가
|
|
||||||
**통보와 재시도까지 통째로 죽였다.** 이 단계만 `move_devices`·`move_device_metrics` 를
|
|
||||||
읽으므로(통보 경로는 두 표를 안 건드림), 마이그레이션 미적용·커넥션 한도·lock timeout 이
|
|
||||||
원래대로면 정상 발송됐을 실행을 매분 죽인다 — 새벽 3시 낙상이 통보되지 않고 콘솔은 멀쩡해
|
|
||||||
보인다. DONE — 1·2단계를 각각 격리하고 3단계의 `exit(1)` 을 제거해 4단계 재시도가 계속 돈다.
|
|
||||||
- [x] **R3-2 [B]** 간헐 고장에 감지기가 **양방향으로** 무너졌다. `edge/daemon.py:105` 는 성공
|
|
||||||
프레임 하나에 streak 를 지워 `cameras_erroring` 를 0 으로 되돌린다(리본 접촉 불량·USB
|
|
||||||
브라운아웃의 전형). 정상 표본 하나로 저하 구간을 끊는 구현은 (a) 두 시간 고장에도 지속
|
|
||||||
시간이 sustain 을 못 넘어 아무것도 못 올리고, (b) 끊긴 구간마다 새 앵커가 생겨 같은 고장을
|
|
||||||
수십 건으로 쏟아냈다(6시간 시뮬레이션 54건). DONE — 엣지 낙상 감지기의 `recover_grace_s`
|
|
||||||
와 같은 규율로 짧은 정상 표본을 흡수하고, uuid 에 재고지 버킷을 더해 주기당 1건으로 묶었다
|
|
||||||
(같은 입력 6시간 ≤7건). 기존 16개 테스트가 전부 단조 시퀀스라 이 결함이 CI를 통과했다 —
|
|
||||||
플래핑·폭주·회복·미래시각 테스트 5건 추가.
|
|
||||||
|
|
||||||
### HIGH
|
|
||||||
|
|
||||||
- [x] **R3-3 [H]** 센티넬 `__device__` 가 **메일·웹훅으로 회사 밖에 나갔다**
|
|
||||||
(`[경고] 장치 이상 — __device__`). 라벨을 `app.js` 에만 넣은 것이 원인.
|
|
||||||
DONE — `App\DeviceScope`(서버)·`assets/device-scope.js`(프런트) 단일 출처로 모으고
|
|
||||||
통보·상세·리포트·사고조사 패키지·설정 점검·컴플라이언스 전 경로에 적용.
|
|
||||||
- [x] **R3-4 [H]** 사건 상세의 "이 카메라 구역 설정" 링크가 `?camera=__device__` 를 넘겨,
|
|
||||||
**어떤 카메라와도 매칭되지 않는 구역**이 '활성'으로 저장될 수 있었다(아무것도 지키지 않음).
|
|
||||||
DONE — 링크를 감추고 `ZoneConfigController` 가 400 으로 거부.
|
|
||||||
- [x] **R3-5 [H]** 인제스트가 센티넬을 예약값으로 다루지 않아, 엣지가 그 `camera_id` 로 올린
|
|
||||||
**진짜 낙상**이 "장치 전체" 로 이름 붙고 카메라 목록·구역·통계에서 빠졌다 — 변경이 실제
|
|
||||||
사고를 화면에서 더 안 보이게 만들었다. DONE — `EventValidator` 가 `reserved:camera_id` 로 거부.
|
|
||||||
- [x] **R3-6 [H]** `BUILD-INFO.txt` 의 `git_commit` 이 릴리스 직전 커밋을 가리켰다(패키지는
|
|
||||||
1.1.0 내용, 스탬프는 1.0.0 트리). DONE — 릴리스 커밋 이후 재생성.
|
|
||||||
- [x] **R3-7 [H]** 실제 자격증명 파일(`PROVISION_SECRETS.md`)이 납품 폴더 `dist/` 안에 있었다.
|
|
||||||
zip 에는 안 들어갔지만 폴더째 압축하면 그대로 나간다. DONE — `secrets/` 로 이동(gitignore).
|
|
||||||
**잔여 조치: 해당 계정 비밀번호와 장치 HMAC 키 교체 권고** (아래 잔여 절).
|
|
||||||
|
|
||||||
### MEDIUM
|
|
||||||
|
|
||||||
- [x] **R3-8 [M]** 카메라별 집계 3곳에 센티넬이 섞였다 — "카메라별 위험 순위"에 유령 카메라가
|
|
||||||
오르고, 사이트별 "카메라 N대"가 한 대씩 부풀고("카메라 한 대로 지키는 현장"을 드러내려는
|
|
||||||
숫자라 목적 자체가 훼손), 오탐율 패널에도 나타났다. DONE — `NOT_DEVICE` 조각으로 제외.
|
|
||||||
- [x] **R3-9 [M]** 설정 점검(`/setup`)이 노드 장애 1건을 카메라로 세어 ③을 초록으로 통과시키고,
|
|
||||||
그 값으로 ④의 구역을 조회해 정상 현장에 "구역 없음"을 띄웠다. DONE.
|
|
||||||
- [x] **R3-10 [M]** 개선 조치를 센티넬에 기록할 수 있어, "조치 전후 감지 밀도"가 노드 장애
|
|
||||||
건수로 계산됐다. DONE — 폼을 감추고 `ActionsController` 가 거부.
|
|
||||||
- [x] **R3-11 [M]** 라벨 데이터셋(CSV)에 노드 사건이 `unreviewed` 로 영구히 쌓였다(스냅샷이
|
|
||||||
없어 프레임으로 라벨링 불가). DONE — 내보내기에서 제외.
|
|
||||||
- [x] **R3-12 [M]** 낡은 지표 행으로 "지금 저하 중"을 판정할 수 있었다. DONE — 최신 표본이
|
|
||||||
`device_stale_seconds` 보다 낡으면 저하 판정을 하지 않는다(죽은 노드는 device_fault 가 잡음).
|
|
||||||
- [x] **R3-13 [M]** 미래 시각 지표 표본 하나가 영원히 "최신"으로 고정돼 실제 저하를 가리거나
|
|
||||||
없던 장애를 sustain 없이 통과시켰다. DONE — `ts > now` 표본 제거.
|
|
||||||
- [x] **R3-14 [M]** 죽은 노드가 첫 시각 이후 영원히 조용했다(`device_fault` 는 warning 이라
|
|
||||||
에스컬레이션 대상도 아님) — 더 가벼운 `monitoring_degraded` 는 주기적으로 다시 올라오는데
|
|
||||||
방향이 반대였다. DONE — 재고지 버킷을 두 유형에 동일 적용.
|
|
||||||
|
|
||||||
### 반증되어 채택하지 않은 것 (REFUTED)
|
|
||||||
|
|
||||||
- 센티넬이 히트맵을 왜곡한다 → `spatial_x/y IS NOT NULL` 조건에 걸려 점이 하나도 안 들어간다.
|
|
||||||
- 센티넬이 에스컬레이션으로 번진다 → `EscalationWatch` 는 critical/high 만 올리고 장치 이벤트는
|
|
||||||
warning 이다. (심각도를 올리면 살아나므로 주석으로 표시.)
|
|
||||||
- 스트림 카메라 필터가 유령 카메라로 고정될 수 있다 → 필터는 레일 트리에서만 설정되고
|
|
||||||
센티넬은 트리에 들어가지 않는다.
|
|
||||||
- 설정 4종이 배선되지 않았다 → 키 이름까지 일치하며, 템플릿 누락은 기존 테스트가 자동으로 잡는다.
|
|
||||||
|
|
||||||
### 이번 재감사에서 확인한 잔여 (수정하지 않음, 사유 명시)
|
|
||||||
|
|
||||||
- **자격증명 교체** — `secrets/PROVISION_SECRETS.md` 의 대시보드 비밀번호와 장치 HMAC 키는
|
|
||||||
6주간 납품 폴더 안에 있었다. 유출 정황은 없으나(git 미추적·zip 미포함) 교체를 권고한다.
|
|
||||||
운영 계정 조작이라 이 저장소에서 수행하지 않는다.
|
|
||||||
2026-09-21 — 손으로 적던 절차를 도구로 바꿨다(`server/tools/rotate_credentials.php`).
|
|
||||||
**실행은 여전히 운영자 몫이다.**
|
|
||||||
- **Pi 번들 재빌드** — 1.1.0 은 서버 전용이라 `edge/` 가 불변이고 내용은 1.0.0 과 동일하지만,
|
|
||||||
파일명·MANIFEST 의 버전 표기가 1.0.0 이다. arm64 + 네트워크가 있는 빌드 머신에서만 재빌드
|
|
||||||
가능하므로 Pi 납품 시 그 머신에서 수행한다.
|
|
||||||
RESOLVED 2026-09-21 — `--cross` 모드로 해소했다. arm64 머신은 필요 없었다: pip 이 태그로
|
|
||||||
aarch64/cp311 휠을 받으므로 네트워크만 있으면 된다. 휠 11개 전부 aarch64.
|
|
||||||
- **매뉴얼에 신규 2종 설명 없음** — `manual.json` 에 `장치 이상`·`감시 성능 저하` 항목이 없다
|
|
||||||
(1.0.0 의 `미확인 경보`도 마찬가지라 이번 회귀는 아니다). 캡처는 실화면이고 UI 골격은 바뀌지
|
|
||||||
않았다. 다음 매뉴얼 개정에서 함께 넣는다.
|
|
||||||
- **크론 중복 실행 잠금 없음** — 재시도 패스에 claim 이 없어 1분을 넘기는 실행이 겹치면
|
|
||||||
중복 발송이 가능하다. 1.0.0 부터 있던 성질이고 이번 변경이 만든 것이 아니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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차 재감사의 잔여와 같은 항목. 다음 개정에서 일괄.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5차 — 이해관계자 페르소나 검증 (2026-09-25, 1.3.0)
|
|
||||||
|
|
||||||
앞선 네 번은 전부 "이 코드가 제대로 도는가"를 물었다. 이번에는 **"이 기능이 존재해야 할
|
|
||||||
이유가 실제로 닿는가"** 를 물었다 — 작업자·노조 대표·안전관리자·공장장·설치기사 5인의
|
|
||||||
관점에서 각각 3단논법을 세우고, 전제를 코드로 확인한 뒤 반문으로 깼다.
|
|
||||||
|
|
||||||
5인 중 2인의 논증이 완전히 무너졌고 2인이 부분적으로 무너졌다. 넷 다 같은 모양이었다:
|
|
||||||
구현도 단위테스트도 초록인데, **그 기능이 지켜야 할 것이 지켜지지 않는 상태.**
|
|
||||||
|
|
||||||
- [x] **S5-1 [B]** 스냅샷 열람·대량 반출이 감사기록에 남지 않았다 (`SnapshotProxy::serve`,
|
|
||||||
`ExportController::labels`). 쓰기는 임계값 변경까지 전부 해시 체인에 남는데 읽기는 하나도
|
|
||||||
남지 않았다 — 감시 시스템과 안전 시스템을 가르는 선이 정확히 거기고, 나머지 프라이버시
|
|
||||||
설계 전부가 이 한 줄이 없어서 검증 불가능한 주장에 머물러 있었다.
|
|
||||||
DONE — `snapshot_view`(세션당 사진 1회) · `label_export`(건수·기간). 거부된 열람은
|
|
||||||
기록하지 않는다(감사 로그가 "본 사람"이 아니라 "요청한 사람" 목록이 되면 쓸 수 없다).
|
|
||||||
기록 실패가 사진 열람을 막지 않되 세션 메모에 넣지 않아 다음 요청이 재시도한다.
|
|
||||||
테스트 `ReadAuditTest` 8건.
|
|
||||||
|
|
||||||
- [x] **S5-2 [B]** 통보 크론이 멈춘 것을 아무도 감지하지 못했다. 통보·5분 확대·`device_fault`
|
|
||||||
발행이 전부 `dispatch_alerts.php` 한 프로세스에 걸려 있는데, 그게 멈추면 화면은 조용해지고
|
|
||||||
그 침묵이 "사고 없음"과 구별되지 않는다. 관리자 화면의 조치는 산문 안내 한 줄이었다.
|
|
||||||
DONE — 크론이 `move_app_state` 에 실행 시각을 남기고 `/api/devices` 가 그 나이를 읽는다
|
|
||||||
(5분 기준). "한 번도 안 돎"과 "멈춤"을 구별한다 — 조치가 다르기 때문이다. 마이그레이션 전
|
|
||||||
배포에서 상태 패널 전체가 죽지 않도록 예외를 삼킨다. 테스트 `DispatcherLivenessTest`.
|
|
||||||
|
|
||||||
- [x] **S5-3 [H]** 스냅샷 삭제가 나이만 봤다(`purgeOlderThan`). 기본 7일인데 중대재해 조사는
|
|
||||||
몇 주~몇 달이라, 우리가 내건 목적("사고 후 원인 분석")이 8일째에 스스로 사라졌다.
|
|
||||||
DONE — `move_events.hold_until`. admin 만, 건별로, 최대 1년, 감사기록과 함께. 확인 처리로
|
|
||||||
자동 연장하지 않는다(자동이면 사실상 전량 보존이 되어 7일 약속이 무너진다). 고아 스냅샷은
|
|
||||||
LEFT JOIN 으로 여전히 삭제 대상 — INNER JOIN 이면 영원히 안 지워지는 PIPA 누출이 된다.
|
|
||||||
테스트 `EvidenceHoldTest` 8건.
|
|
||||||
|
|
||||||
- [x] **S5-4 [H]** 증거 프레임이 `ergo_report` 하나만 빼고 **모든** 이벤트에 붙었다. 체류 상한
|
|
||||||
초과(`overstay`)도 포함이라, 밀폐공간 프리셋 기본값(30분) 기준으로 정상 작업자가 규정대로
|
|
||||||
일하는 동안 주기적으로 본인 사진이 서버로 갔다 — 위험해서가 아니라 오래 있어서.
|
|
||||||
DONE — `edge/app.py` `NO_EVIDENCE_TYPES`. 기준은 "시간 경과 자체가 조건인 이벤트에는
|
|
||||||
사진을 붙이지 않는다". 규칙 위반(단독작업·정원 초과)은 사진이 곧 위반의 증명이라 유지.
|
|
||||||
`run_once` 를 실제로 태워 같은 배치에서 한쪽은 증거를 들고 다른 쪽은 안 드는지 본다.
|
|
||||||
|
|
||||||
### 이번 검증에서 확인한 잔여 (코드로 닫을 수 없음)
|
|
||||||
|
|
||||||
- **노조 대표 열람 계정** — 감사기록이 생겼으므로 이제 의미를 갖는다. 계정 발급은 사업주 결정.
|
|
||||||
- **보관기간 화면 표시** — 설정 파일 안이 아니라 눈에 보이는 곳에. 다음 UI 개정에서.
|
|
||||||
- **SHA256SUMS 미서명** — `SIGN_KEY` 미설정이라 손상은 잡고 위변조는 못 잡는다. 폐쇄망 USB
|
|
||||||
전달 경로의 위협 모델에서는 수용 가능하고 문서에 정직하게 적혀 있다. 결정으로 기록한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## OFF-HW 잔여 (실장비 커미셔닝에서만 검증 가능)
|
|
||||||
|
|
||||||
코드·문서로 닫을 수 없는 항목. 납품 판정을 막지 않되, 현장 설치 시 반드시 수행한다.
|
|
||||||
|
|
||||||
- [ ] **GPIO 경광등 실동작** — `edge/beacon.py` 의 핀 구동은 단위 테스트로 증명할 수 없다.
|
|
||||||
Pi + 릴레이 모듈로 latch/pulse, `active_high` 반전, hold 만료 해제를 육안 확인.
|
|
||||||
- [ ] **카메라 무결성 임계값** — `IntegrityConfig.moved_mad` / `obstructed_var` /
|
|
||||||
`low_light_mean` 은 실제 현장 조명·진동에서 튜닝해야 한다. 현재 값은 합성 프레임 기준.
|
|
||||||
- [ ] **P2-6/7/8** — 낙상 타이밍·트래킹 매칭 (1차 감사에서 이월). 블라인드 수정은 감지 **누락**을
|
|
||||||
만들 수 있어 실영상 확보 후 조정.
|
|
||||||
- [ ] **CSI 카메라 / Hailo HEF 추론** — `tests/hardware/` 스킵 항목.
|
|
||||||
- [ ] **통보 실발송** — 사내 SMTP 릴레이·웹훅 엔드포인트는 현장에만 있다.
|
|
||||||
`dispatch_alerts.php --dry-run` 으로 대상만 먼저 확인한 뒤 1회 실발송 검증.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Progress log
|
## Progress log
|
||||||
|
|
||||||
(updated as each item lands — newest first)
|
(updated as each item lands — newest first)
|
||||||
|
|
|
||||||
|
|
@ -1,217 +0,0 @@
|
||||||
{
|
|
||||||
"schema_version": 1,
|
|
||||||
"diagram_type": "lifecycle",
|
|
||||||
"meta": {
|
|
||||||
"title": "낙상 판정 상태기계 — 지속을 요구하는 이유",
|
|
||||||
"output": "move-detect-fall-lifecycle.html",
|
|
||||||
"quality_profile": "showcase",
|
|
||||||
"views": [
|
|
||||||
{
|
|
||||||
"id": "main-path",
|
|
||||||
"label": "확정까지",
|
|
||||||
"focus": [
|
|
||||||
"standing",
|
|
||||||
"falling",
|
|
||||||
"fallen",
|
|
||||||
"nomotion"
|
|
||||||
],
|
|
||||||
"note": "한 프레임으로는 아무것도 확정하지 않는다. 1초는 위험 자세, 3초라야 넘어짐."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "recovery",
|
|
||||||
"label": "회복 경로",
|
|
||||||
"focus": [
|
|
||||||
"falling",
|
|
||||||
"nearfall",
|
|
||||||
"fallen",
|
|
||||||
"recovered"
|
|
||||||
],
|
|
||||||
"note": "회복도 지속을 요구한다. 확정 전에 일어서면 니어미스로만 남는다."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "lost-track",
|
|
||||||
"label": "사라진 사람",
|
|
||||||
"focus": [
|
|
||||||
"fallen",
|
|
||||||
"ghost"
|
|
||||||
],
|
|
||||||
"note": "위험 상태에서 트랙이 사라지면 10초 기다린 뒤 별도 사건으로 올린다."
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"viewBox": [
|
|
||||||
1085,
|
|
||||||
620
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"lanes": [
|
|
||||||
{
|
|
||||||
"id": "main",
|
|
||||||
"label": "자세 상태"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "recovery",
|
|
||||||
"label": "회복"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "terminal",
|
|
||||||
"label": "종료"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"states": [
|
|
||||||
{
|
|
||||||
"id": "standing",
|
|
||||||
"type": "start",
|
|
||||||
"label": "정상",
|
|
||||||
"sublabel": "기립 · 보행",
|
|
||||||
"lane": "main",
|
|
||||||
"col": 0,
|
|
||||||
"step": "01"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "falling",
|
|
||||||
"type": "active",
|
|
||||||
"label": "위험 자세",
|
|
||||||
"sublabel": "쓰러진 자세 1초",
|
|
||||||
"lane": "main",
|
|
||||||
"col": 1,
|
|
||||||
"step": "02",
|
|
||||||
"tag": "알람 없음"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "fallen",
|
|
||||||
"type": "failure",
|
|
||||||
"label": "넘어짐 확정",
|
|
||||||
"sublabel": "3초 지속",
|
|
||||||
"lane": "main",
|
|
||||||
"col": 2,
|
|
||||||
"step": "03",
|
|
||||||
"tag": "경광등 + 통보"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "nomotion",
|
|
||||||
"type": "failure",
|
|
||||||
"label": "미동 없음",
|
|
||||||
"sublabel": "정지 10초",
|
|
||||||
"lane": "main",
|
|
||||||
"col": 3,
|
|
||||||
"step": "04",
|
|
||||||
"tag": "최상위 위급"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ghost",
|
|
||||||
"type": "failure",
|
|
||||||
"label": "위험 상태 소실",
|
|
||||||
"sublabel": "트랙이 사라짐",
|
|
||||||
"lane": "terminal",
|
|
||||||
"col": 2,
|
|
||||||
"tag": "10초 대기",
|
|
||||||
"yOffset": -16
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "nearfall",
|
|
||||||
"type": "neutral",
|
|
||||||
"label": "니어미스",
|
|
||||||
"sublabel": "확정 전 회복",
|
|
||||||
"lane": "recovery",
|
|
||||||
"col": 0,
|
|
||||||
"tag": "기록만"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "recovered",
|
|
||||||
"type": "success",
|
|
||||||
"label": "정상 복귀",
|
|
||||||
"sublabel": "정상 자세 1초",
|
|
||||||
"lane": "recovery",
|
|
||||||
"col": 1
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"transitions": [
|
|
||||||
{
|
|
||||||
"id": "to-falling",
|
|
||||||
"from": "standing",
|
|
||||||
"to": "falling",
|
|
||||||
"label": "쓰러진 자세 1초",
|
|
||||||
"labelDy": -46,
|
|
||||||
"route": "straight"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "to-fallen",
|
|
||||||
"from": "falling",
|
|
||||||
"to": "fallen",
|
|
||||||
"label": "3초 지속",
|
|
||||||
"variant": "emphasis",
|
|
||||||
"labelDy": -46,
|
|
||||||
"route": "straight"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "to-nomotion",
|
|
||||||
"from": "fallen",
|
|
||||||
"to": "nomotion",
|
|
||||||
"label": "정지 10초",
|
|
||||||
"variant": "emphasis",
|
|
||||||
"labelDy": -46,
|
|
||||||
"route": "straight"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "to-ghost",
|
|
||||||
"from": "nomotion",
|
|
||||||
"to": "ghost",
|
|
||||||
"label": "재출현 없음",
|
|
||||||
"variant": "security",
|
|
||||||
"route": "right-channel",
|
|
||||||
"channelX": 720,
|
|
||||||
"labelSegment": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "near-miss",
|
|
||||||
"from": "falling",
|
|
||||||
"to": "nearfall",
|
|
||||||
"label": "정상 1초",
|
|
||||||
"route": "drop"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "fallen-recover",
|
|
||||||
"from": "fallen",
|
|
||||||
"to": "recovered",
|
|
||||||
"label": "정상 1초",
|
|
||||||
"route": "drop"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "back-from-nearmiss",
|
|
||||||
"from": "nearfall",
|
|
||||||
"to": "standing",
|
|
||||||
"label": "복귀",
|
|
||||||
"variant": "dashed",
|
|
||||||
"route": "bottom-channel"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"cards": [
|
|
||||||
{
|
|
||||||
"dot": "emerald",
|
|
||||||
"title": "지속을 요구하는 이유",
|
|
||||||
"items": [
|
|
||||||
"한 프레임으로는 확정하지 않는다",
|
|
||||||
"1초는 위험 자세 — 알람 없음",
|
|
||||||
"앉는 동작과 가르는 조건이 이것이다"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"dot": "rose",
|
|
||||||
"title": "회복도 지속을 요구한다",
|
|
||||||
"items": [
|
|
||||||
"정상 자세 1초라야 회복",
|
|
||||||
"가려지면 카운트다운이 멈춘다",
|
|
||||||
"확정 전 회복은 니어미스로만"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"dot": "amber",
|
|
||||||
"title": "같은 3초가 두 번",
|
|
||||||
"items": [
|
|
||||||
"확정 창 3초 · 추락 연결 창 3초",
|
|
||||||
"겹쳐서 추락 승격이 미발화했다",
|
|
||||||
"시간을 그렸다면 보였을 충돌"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,258 +0,0 @@
|
||||||
{
|
|
||||||
"schema_version": 1,
|
|
||||||
"diagram_type": "sequence",
|
|
||||||
"meta": {
|
|
||||||
"title": "낙상 1건 — 감지에서 통보까지",
|
|
||||||
"output": "move-detect-fall-sequence.html",
|
|
||||||
"quality_profile": "showcase",
|
|
||||||
"column_fit": "spread",
|
|
||||||
"views": [
|
|
||||||
{
|
|
||||||
"id": "on-site",
|
|
||||||
"label": "현장에서 끝나는 3초",
|
|
||||||
"focus": [
|
|
||||||
"cam",
|
|
||||||
"edge",
|
|
||||||
"beacon"
|
|
||||||
],
|
|
||||||
"note": "확정부터 점등까지 서버를 거치지 않는다. 회선이 끊겨도 여기까지는 동작한다."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "what-leaves",
|
|
||||||
"label": "밖으로 나가는 것",
|
|
||||||
"focus": [
|
|
||||||
"edge",
|
|
||||||
"api",
|
|
||||||
"db"
|
|
||||||
],
|
|
||||||
"note": "영상이 아니라 얼굴 블러된 3프레임. 블러는 전송 전, 노드에서."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "notify-lag",
|
|
||||||
"label": "통보까지의 실제 지연",
|
|
||||||
"focus": [
|
|
||||||
"cron",
|
|
||||||
"db",
|
|
||||||
"staff"
|
|
||||||
],
|
|
||||||
"note": "경광등은 즉시, 메일은 크론 주기만큼 늦다. 그 간격이 이 그림의 요점이다."
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"viewBox": [
|
|
||||||
1060,
|
|
||||||
560
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"participants": [
|
|
||||||
{
|
|
||||||
"id": "cam",
|
|
||||||
"type": "external",
|
|
||||||
"label": "카메라",
|
|
||||||
"sublabel": "12fps"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "edge",
|
|
||||||
"type": "backend",
|
|
||||||
"label": "엣지 노드",
|
|
||||||
"sublabel": "Pi 5 + Hailo-8"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "beacon",
|
|
||||||
"type": "external",
|
|
||||||
"label": "경광등",
|
|
||||||
"sublabel": "GPIO"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "api",
|
|
||||||
"type": "security",
|
|
||||||
"label": "수집 API",
|
|
||||||
"sublabel": "HMAC 검증"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "db",
|
|
||||||
"type": "database",
|
|
||||||
"label": "MySQL",
|
|
||||||
"sublabel": "move_events"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "cron",
|
|
||||||
"type": "backend",
|
|
||||||
"label": "1분 크론",
|
|
||||||
"sublabel": "dispatch_alerts"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "staff",
|
|
||||||
"type": "external",
|
|
||||||
"label": "담당자",
|
|
||||||
"sublabel": "메일 · 웹훅"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"segments": [
|
|
||||||
{
|
|
||||||
"from": 152,
|
|
||||||
"to": 190,
|
|
||||||
"label": "감지 — 자세 3초"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"from": 198,
|
|
||||||
"to": 320,
|
|
||||||
"label": "현장 대응 · 기록"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"from": 330,
|
|
||||||
"to": 366,
|
|
||||||
"label": "미동 없음 13초"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"from": 374,
|
|
||||||
"to": 466,
|
|
||||||
"label": "통보 — 크론 주기 지연"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"id": "frames",
|
|
||||||
"from": "cam",
|
|
||||||
"to": "edge",
|
|
||||||
"y": 168,
|
|
||||||
"label": "프레임 · 12fps",
|
|
||||||
"variant": "default",
|
|
||||||
"note": "0.0초 쓰러짐 → 1초 위험 자세 → 3초 확정"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "gpio",
|
|
||||||
"from": "edge",
|
|
||||||
"to": "beacon",
|
|
||||||
"y": 206,
|
|
||||||
"label": "넘어짐 확정(3초) → GPIO 점등",
|
|
||||||
"variant": "emphasis",
|
|
||||||
"note": "3.0초 · 서버도 인터넷도 거치지 않는다"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "post",
|
|
||||||
"from": "edge",
|
|
||||||
"to": "api",
|
|
||||||
"y": 244,
|
|
||||||
"label": "HMAC POST · 3프레임",
|
|
||||||
"variant": "security",
|
|
||||||
"note": "얼굴 블러는 전송 전, 노드에서"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "insert",
|
|
||||||
"from": "api",
|
|
||||||
"to": "db",
|
|
||||||
"y": 276,
|
|
||||||
"label": "INSERT",
|
|
||||||
"variant": "default"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "accepted",
|
|
||||||
"from": "api",
|
|
||||||
"to": "edge",
|
|
||||||
"y": 306,
|
|
||||||
"label": "202 Accepted",
|
|
||||||
"variant": "return"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "nomotion",
|
|
||||||
"from": "edge",
|
|
||||||
"to": "api",
|
|
||||||
"y": 344,
|
|
||||||
"label": "미동 없음 · 10초",
|
|
||||||
"variant": "emphasis",
|
|
||||||
"note": "13.0초 — 최상위 위급"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "poll",
|
|
||||||
"from": "cron",
|
|
||||||
"to": "db",
|
|
||||||
"y": 384,
|
|
||||||
"label": "통보 대상 조회 · 최대 60초 뒤",
|
|
||||||
"variant": "default",
|
|
||||||
"note": "최대 60초 뒤 — 크론이 1분마다 돈다"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "send",
|
|
||||||
"from": "cron",
|
|
||||||
"to": "staff",
|
|
||||||
"y": 418,
|
|
||||||
"label": "메일 · 웹훅 (링크만)",
|
|
||||||
"variant": "emphasis"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "escalate",
|
|
||||||
"from": "cron",
|
|
||||||
"to": "db",
|
|
||||||
"y": 452,
|
|
||||||
"label": "5분 미확인 → 상위 확대",
|
|
||||||
"variant": "dashed"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"activations": [
|
|
||||||
{
|
|
||||||
"participant": "edge",
|
|
||||||
"from": 196,
|
|
||||||
"to": 352,
|
|
||||||
"type": "backend"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"participant": "beacon",
|
|
||||||
"from": 201,
|
|
||||||
"to": 232,
|
|
||||||
"type": "external"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"participant": "api",
|
|
||||||
"from": 239,
|
|
||||||
"to": 312,
|
|
||||||
"type": "security"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"participant": "db",
|
|
||||||
"from": 271,
|
|
||||||
"to": 300,
|
|
||||||
"type": "database"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"participant": "cron",
|
|
||||||
"from": 379,
|
|
||||||
"to": 458,
|
|
||||||
"type": "backend"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"participant": "staff",
|
|
||||||
"from": 413,
|
|
||||||
"to": 442,
|
|
||||||
"type": "external"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"cards": [
|
|
||||||
{
|
|
||||||
"dot": "cyan",
|
|
||||||
"title": "현장에서 끝나는 3초",
|
|
||||||
"items": [
|
|
||||||
"자세가 3초 지속돼야 확정한다",
|
|
||||||
"확정 즉시 GPIO 점등 — 서버 불필요",
|
|
||||||
"서버가 죽어도 여기까지는 동작"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"dot": "rose",
|
|
||||||
"title": "밖으로 나가는 것",
|
|
||||||
"items": [
|
|
||||||
"영상이 아니라 3프레임",
|
|
||||||
"얼굴 블러는 전송 전, 노드에서",
|
|
||||||
"블러 전 이미지는 서버에 없다"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"dot": "amber",
|
|
||||||
"title": "두 지연은 다르다",
|
|
||||||
"items": [
|
|
||||||
"경광등은 즉시",
|
|
||||||
"메일은 크론 주기만큼 늦다",
|
|
||||||
"\"1분 통보\"는 대기를 포함한 값"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -65,15 +65,8 @@ Measure frame processing under three operational modes:
|
||||||
| **T2: Detection** | Inference every 2–3 frames | Trade-off: skip frames to reduce Hailo load. | ≥ 8 fps | For additional context cameras; acceptable for non-critical zones. |
|
| **T2: Detection** | Inference every 2–3 frames | Trade-off: skip frames to reduce Hailo load. | ≥ 8 fps | For additional context cameras; acceptable for non-critical zones. |
|
||||||
| **T3: Motion** | Hailo only on motion keyframes | Inference triggered by motion (v4l2 MJPEG keyframe or frame diff). | ≥ 5 fps avg | For background monitoring; many cameras, low overhead. |
|
| **T3: Motion** | Hailo only on motion keyframes | Inference triggered by motion (v4l2 MJPEG keyframe or frame diff). | ≥ 5 fps avg | For background monitoring; many cameras, low overhead. |
|
||||||
|
|
||||||
1. Tier selection is `INFER_EVERY` in `/etc/move-detect/edge.env` (per device):
|
1. Configure edge detection core to support tier selection (via thresholds file or env var).
|
||||||
`1` = T1 (every tick, the default), `2`–`3` = T2. On a skipped tick the frame is still read
|
2. Run each tier with realistic camera setup (e.g., T1 on main fall cam, T2/T3 on perimeter).
|
||||||
and discarded — the camera buffer stays current — but no pose runs and nothing enters the
|
|
||||||
evidence ring, so no unblurred frame can reach a snapshot.
|
|
||||||
**T3 (motion-gated inference) is deliberately NOT implemented.** A motion gate that misjudges
|
|
||||||
would stop inferring on a *motionless fallen worker* — the exact case this product exists to
|
|
||||||
catch — and its threshold cannot be tuned without real footage. Decide it during bring-up
|
|
||||||
with measured video, not in advance.
|
|
||||||
2. Run each tier with realistic camera setup (e.g., T1 on main fall cam, T2 on perimeter).
|
|
||||||
3. Log FPS, E2E latency (frame capture → alert), CPU, Hailo temp for 30 min per tier.
|
3. Log FPS, E2E latency (frame capture → alert), CPU, Hailo temp for 30 min per tier.
|
||||||
4. Confirm T1 ≥ 12 fps for the target scene (well-lit, medium activity).
|
4. Confirm T1 ≥ 12 fps for the target scene (well-lit, medium activity).
|
||||||
|
|
||||||
|
|
@ -104,23 +97,14 @@ Validate sustained operation and resource cleanup:
|
||||||
|
|
||||||
After benchmark completion, update configuration:
|
After benchmark completion, update configuration:
|
||||||
|
|
||||||
Every knob below lives in `/etc/move-detect/edge.env` (read by `edge/config.py::load_config`),
|
1. **`edge/config.py` thresholds:**
|
||||||
**not** in a code file — no redeploy is needed to retune a site. All are validated at startup:
|
- Adjust `motion.cooldown_ms`, `motion.min_duration_ms` based on measured T2/T3 FPS.
|
||||||
a non-finite or non-positive value refuses to boot rather than silently disabling a detector.
|
- Tune `fall.confidence_threshold` if fall recall vs. precision trade-off differs on real video.
|
||||||
The full list is in `docs/pilot-runbook.md` §3.
|
|
||||||
|
|
||||||
1. **Detector thresholds:**
|
2. **`edge/config.py` camera roles:**
|
||||||
- `MOTION_THR` (default 0.25) — per-second stillness threshold shared by fall no-motion and
|
- Assign `role="fall_primary"` to high-fps T1 cam (≥ 12 fps).
|
||||||
immobility. This is the primary re-tuning knob on real video: it is frame-rate normalised,
|
- Assign `role="context"` to T2 cameras (8–10 fps).
|
||||||
so a device running at a different measured fps needs it re-checked.
|
- Assign `role="perimeter"` to T3 motion-only cameras.
|
||||||
- `FALL_PRONE_S` (3.0) / `FALL_NO_MOTION_S` (10.0) — how long prone before a fall confirms,
|
|
||||||
and before it escalates to the 위급 no-motion event. Raise if real footage over-triggers.
|
|
||||||
- `IMMOBILE_S` (120.0), `GHOST_REAPPEAR_S` (10.0), `ZONE_DEBOUNCE_S` (0.75), `COOLDOWN_S` (30.0).
|
|
||||||
|
|
||||||
2. **Per-camera load:**
|
|
||||||
- `INFER_EVERY` (1) — 1 for the fall-critical camera, 2–3 for context cameras that missed
|
|
||||||
the T1 target. Set per device; each camera on a device shares it today.
|
|
||||||
- `TARGET_FPS` (12) — loop pacing. It only slows the loop down; it cannot buy throughput.
|
|
||||||
|
|
||||||
3. **Benchmark report:**
|
3. **Benchmark report:**
|
||||||
- Document actual Pi hardware, kernel version, HailoRT version, Hailo FW version.
|
- Document actual Pi hardware, kernel version, HailoRT version, Hailo FW version.
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 290 KiB |
|
Before Width: | Height: | Size: 274 KiB |
|
Before Width: | Height: | Size: 314 KiB |
|
Before Width: | Height: | Size: 390 KiB |
|
Before Width: | Height: | Size: 370 KiB |
|
Before Width: | Height: | Size: 579 KiB |
|
Before Width: | Height: | Size: 224 KiB |
|
Before Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 252 KiB |
|
Before Width: | Height: | Size: 250 KiB |
|
Before Width: | Height: | Size: 248 KiB |
|
|
@ -1,19 +0,0 @@
|
||||||
# 소개서 이미지
|
|
||||||
|
|
||||||
`docs/소개서-이미지-브리프.md` 의 발주 명세대로 받은 이미지를 이 폴더에 넣습니다.
|
|
||||||
파일명은 브리프에 지정된 것을 **그대로** 씁니다 (배치 매니페스트가 파일명으로 찾습니다).
|
|
||||||
|
|
||||||
08-넘어짐-감지-시퀀스.png P0 §2-1 감지 ← 전체 최우선
|
|
||||||
09-앉기-vs-넘어짐.png P0 §2-1 감지
|
|
||||||
01-현장-설치.jpg P0 표지
|
|
||||||
02-경광등.jpg P0 §2-2 대응
|
|
||||||
05-사슬.png P0 §2 무엇을 하는가
|
|
||||||
06-구성도.png P0 §4 시스템 구성
|
|
||||||
10-위험구역-판정.png P1 §2-1 감지
|
|
||||||
03-엣지노드.jpg P1 §4-1 엣지 노드
|
|
||||||
07-대응시간.png P1 §3-1 즉시 달라지는 것
|
|
||||||
11-2인1조.png P2 §2-1 감지
|
|
||||||
04-위험구역.jpg P2 §2-1 감지
|
|
||||||
|
|
||||||
이미지가 들어오면 매니페스트를 만들어 소개서 HTML 을 재생성합니다.
|
|
||||||
제품 화면 캡처 8장은 이미 `server/deploy/manual/shots/` 에 있으므로 여기 넣지 않습니다.
|
|
||||||
|
|
@ -58,15 +58,15 @@ COCO-17 remap 로직은 그대로). 어댑터 remap은 `tests/test_movenet_pose.
|
||||||
로컬 MySQL 8(스키마 CHECK 제약은 8.0.16+)과 PHP 8.2+ 필요. 상세 배포·전제조건(gd/argon2/HTTPS 등)은
|
로컬 MySQL 8(스키마 CHECK 제약은 8.0.16+)과 PHP 8.2+ 필요. 상세 배포·전제조건(gd/argon2/HTTPS 등)은
|
||||||
[server/docs/server-deploy.md](../server/docs/server-deploy.md) 참조 — 여기선 로컬 최소 절차만.
|
[server/docs/server-deploy.md](../server/docs/server-deploy.md) 참조 — 여기선 로컬 최소 절차만.
|
||||||
|
|
||||||
1. DB 생성 후 스키마 임포트: `mysql <db> < server/schema.sql` (11개 테이블, 전부 `move_` 접두사).
|
1. DB 생성 후 스키마 임포트: `mysql <db> < server/schema.sql` (10개 테이블).
|
||||||
2. `server/config.php`를 `server/config.example.php`에서 복사, 로컬 DB DSN/계정 기입
|
2. `server/config.php`를 `server/config.example.php`에서 복사, 로컬 DB DSN/계정 기입
|
||||||
(`snapshot_dir`는 쓰기 가능 경로로).
|
(`snapshot_dir`는 쓰기 가능 경로로).
|
||||||
3. **관리자 계정 · 디바이스 시크릿 등록** — 앱에 셀프 가입 흐름이 없으므로 직접 넣는다.
|
3. **관리자 계정 · 디바이스 시크릿 등록** — 앱에 셀프 가입 흐름이 없으므로 직접 넣는다.
|
||||||
CLI 스니펫과 SQL은 server-deploy.md **§6(디바이스 시크릿)** · **§7(첫 관리자)** 그대로 사용:
|
CLI 스니펫과 SQL은 server-deploy.md **§6(디바이스 시크릿)** · **§7(첫 관리자)** 그대로 사용:
|
||||||
- 시크릿 생성: `php -r "echo bin2hex(random_bytes(32)), \"\n\";"`
|
- 시크릿 생성: `php -r "echo bin2hex(random_bytes(32)), \"\n\";"`
|
||||||
- `INSERT INTO move_devices (device_id, name, secret, config_version) VALUES ('pi-01','노트북','<시크릿>',1);`
|
- `INSERT INTO devices (device_id, name, secret, config_version) VALUES ('pi-01','노트북','<시크릿>',1);`
|
||||||
- 관리자 해시: `php -r "echo password_hash('바꿀-암구호', PASSWORD_ARGON2ID), \"\n\";"`
|
- 관리자 해시: `php -r "echo password_hash('바꿀-암구호', PASSWORD_ARGON2ID), \"\n\";"`
|
||||||
- `INSERT INTO move_users (username, password_hash, role, ...) VALUES ('admin','<해시>','admin',NULL,0,0);`
|
- `INSERT INTO users (username, password_hash, role, ...) VALUES ('admin','<해시>','admin',NULL,0,0);`
|
||||||
4. 기동: `php -S localhost:8000 -t server/public`
|
4. 기동: `php -S localhost:8000 -t server/public`
|
||||||
- 세션 쿠키가 `cookie_secure`라 순수 HTTP에선 로그인 쿠키가 안 붙을 수 있다. 로컬은 대개
|
- 세션 쿠키가 `cookie_secure`라 순수 HTTP에선 로그인 쿠키가 안 붙을 수 있다. 로컬은 대개
|
||||||
`localhost`가 secure-context로 취급돼 동작하지만, 안 되면 server-deploy.md §8(HTTPS) 참조.
|
`localhost`가 secure-context로 취급돼 동작하지만, 안 되면 server-deploy.md §8(HTTPS) 참조.
|
||||||
|
|
|
||||||
|
|
@ -1,280 +0,0 @@
|
||||||
# 신규 감지 설계 — 추락 · 동시 다발 쓰러짐 · 구역 프리셋
|
|
||||||
|
|
||||||
> 상태: **구현 완료 (1.2.0, 2026-09-04).** 이 문서는 판정 근거의 기록으로 남깁니다.
|
|
||||||
> 구현: `edge/detectors/height.py` · `edge/detectors/ghost.py`(무장 확장) ·
|
|
||||||
> `edge/engine.py`(유형 승격) · `server/src/Alert/MultiDownWatch.php` ·
|
|
||||||
> `server/src/Api/ZonePresetCatalog.php`
|
|
||||||
> 셋 다 **새 추론 모델이 필요 없습니다** — 지금 있는 자세추정·구역·트랙 이력만 씁니다.
|
|
||||||
> 근거 코드: `edge/detectors/{fall,ghost,zone,occupancy}.py` · `edge/tracking.py` ·
|
|
||||||
> `server/src/Alert/DeviceHealthWatch.php`(서버측 감시자 패턴)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. 왜 이 셋인가
|
|
||||||
|
|
||||||
| | 지금 상태 | 왜 필요한가 |
|
|
||||||
|---|---|---|
|
|
||||||
| **추락** | **없음** | 산업재해 사망 1위 유형. 지금 잡는 건 같은 높이에서의 *넘어짐*이고 둘은 다른 사건 |
|
|
||||||
| **동시 다발 쓰러짐** | 없음 | 밀폐공간 사고에서 구조하러 들어간 사람이 같이 죽는 것을 막는 유일한 신호 |
|
|
||||||
| **구역 프리셋** | 없음 | 밀폐공간·고소작업은 **이미 되는데** 아무도 그렇게 쓸 줄 모름 |
|
|
||||||
|
|
||||||
> 사망 유형 순위는 통념 기준입니다. 고객 대면 문서에 넣으려면 고용노동부 산업재해 통계
|
|
||||||
> 최신본으로 확인이 필요합니다 — 이 문서 안에서는 우선순위 근거로만 씁니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 추락 (`fall_from_height`)
|
|
||||||
|
|
||||||
### 1-1. 지금 왜 안 잡히는가
|
|
||||||
|
|
||||||
`GhostIncidentDetector`는 트랙이 **이미 위험 자세**일 때만 무장합니다:
|
|
||||||
|
|
||||||
```python
|
|
||||||
_DANGEROUS = {"FALLING", "FALLEN", "NO_MOTION"} # edge/detectors/ghost.py:5
|
|
||||||
```
|
|
||||||
|
|
||||||
발판 위에 **서 있다가** 아래로 떨어지는 사람은 소실 순간 `STANDING`입니다. 아무것도 안 걸립니다.
|
|
||||||
화각 밖으로 떨어지면 넘어진 자세를 볼 기회조차 없습니다.
|
|
||||||
|
|
||||||
### 1-2. 핵심 신호 — 중심이 아니라 **발 밑선(y2)**
|
|
||||||
|
|
||||||
이게 이 설계에서 가장 중요한 판단입니다.
|
|
||||||
|
|
||||||
| 동작 | bbox 중심(cy) | **bbox 하단(y2)** | 높이 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 쭈그려 앉기 | 내려감 | **거의 고정** | 줄어듦 |
|
|
||||||
| 계단 내려가기 | 천천히 내려감 | 천천히 내려감 | 유지 |
|
|
||||||
| 카메라 쪽으로 걸어옴 | 내려감 | 내려감 | **커짐** |
|
|
||||||
| **추락** | 빠르게 내려감 | **빠르게 내려감** | 유지~약간 |
|
|
||||||
|
|
||||||
**앉기와 추락을 가르는 건 y2입니다.** 앉을 때 발은 그 자리에 있습니다.
|
|
||||||
`cy`로 판정하면 쭈그려 앉는 작업자가 전부 추락이 됩니다.
|
|
||||||
|
|
||||||
원근 접근(카메라로 걸어옴)은 y2도 내려가므로 **높이 증가 가드**로 따로 막습니다.
|
|
||||||
|
|
||||||
### 1-3. 판정
|
|
||||||
|
|
||||||
**경로 A — 프레임 안에서 보이는 추락**
|
|
||||||
|
|
||||||
```
|
|
||||||
하강량 = (y2_현재 − y2_시작) / baseline_height
|
|
||||||
조건: 하강량 ≥ drop_ratio (기본 1.2)
|
|
||||||
구간 ≤ drop_window_s (기본 1.2초)
|
|
||||||
높이_현재 / 높이_시작 ≤ grow_guard (기본 1.35, 원근 접근 기각)
|
|
||||||
```
|
|
||||||
|
|
||||||
자유낙하 3m는 약 0.78초입니다. 12fps에서 9~10프레임이므로 1.2초 창이면 충분히 담깁니다.
|
|
||||||
`Track.frames`가 90프레임(7.5초) 이력을 들고 있어 별도 버퍼가 필요 없습니다.
|
|
||||||
|
|
||||||
하강 확정 후 `link_s`(기본 3초) 안에 `FallDetector`가 넘어짐을 확정하면
|
|
||||||
**이벤트 유형을 `fall`이 아니라 `fall_from_height`로 승격**합니다. 두 건이 아니라 한 건입니다.
|
|
||||||
|
|
||||||
**경로 B — 화각 밖으로 사라지는 추락**
|
|
||||||
|
|
||||||
`GhostIncidentDetector`의 무장 조건을 넓힙니다. 재출현 대기·방관자 판별 로직은 그대로 재사용합니다.
|
|
||||||
|
|
||||||
```
|
|
||||||
무장 조건 (기존) 위험 자세였다
|
|
||||||
(추가) 하강이 감지됐다 ← 경로 A 신호
|
|
||||||
(추가) 소실 지점이 프레임 하단 edge_margin 안 (기본 6%)
|
|
||||||
(추가) 소실 시점에 '고소' 구역 안에 있었다 ← 아래 1-4
|
|
||||||
```
|
|
||||||
|
|
||||||
방출 시 `last_state`가 아니라 무장 사유를 실어 `fall_from_height`로 발보합니다.
|
|
||||||
|
|
||||||
### 1-4. 구역으로 보강 — 스키마 변경 없음
|
|
||||||
|
|
||||||
구역 `rules_json`은 자유 형식이고 `edge/zones.py`가 파싱합니다. 새 키 하나만 씁니다.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "elevated": true }
|
|
||||||
```
|
|
||||||
|
|
||||||
운영자가 발판·개구부·사다리 상부를 구역으로 그려두면, 그 안에 있던 트랙의 소실·하강은
|
|
||||||
**훨씬 강한 신호**가 됩니다. 구역이 없어도 경로 A·B는 동작합니다 — 구역은 정밀도를 올릴 뿐
|
|
||||||
전제 조건이 아닙니다. (이 제품의 원칙: 위험은 운영자가 선언하되, 선언이 없어도 최소한은 본다.)
|
|
||||||
|
|
||||||
### 1-5. 파라미터
|
|
||||||
|
|
||||||
| 이름 | 기본값 | 근거 |
|
|
||||||
|---|---|---|
|
|
||||||
| `drop_ratio` | 1.2 | 사람 키의 1.2배 = 약 2m 하강. 계단 한 층은 못 미침 |
|
|
||||||
| `drop_window_s` | 1.2 | 3m 자유낙하 0.78초를 담되 걷기는 못 담는 길이 |
|
|
||||||
| `grow_guard` | 1.35 | 이 이상 커지면 원근 접근 |
|
|
||||||
| `edge_margin` | 0.06 | 프레임 하단 6% |
|
|
||||||
| `link_s` | 3.0 | 하강→넘어짐 확정 연결 창 |
|
|
||||||
|
|
||||||
### 1-6. 이벤트 계약
|
|
||||||
|
|
||||||
```
|
|
||||||
event_type : fall_from_height
|
|
||||||
severity : CRITICAL ← 넘어짐(HIGH)보다 한 단계 위
|
|
||||||
metrics : { drop_ratio, drop_s, exited_frame: bool, zone_id: str|null,
|
|
||||||
armed_by: "descent"|"zone"|"frame_edge" }
|
|
||||||
```
|
|
||||||
|
|
||||||
**CRITICAL인 이유**: 추락은 척추·두부 손상 가능성이 있어 **함부로 옮기면 안 됩니다.**
|
|
||||||
통보 문구에 그 주의가 들어가야 하고, 그건 심각도가 갈라져야 가능합니다.
|
|
||||||
|
|
||||||
### 1-7. 오탐 방어 요약
|
|
||||||
|
|
||||||
| 오탐원 | 방어 |
|
|
||||||
|---|---|
|
|
||||||
| 쭈그려 앉기·눕기 | y2 기준(발은 안 움직임) |
|
|
||||||
| 계단·경사로 | 하강량이 1.2배에 못 미침 |
|
|
||||||
| 카메라 쪽 접근 | 높이 증가 가드 |
|
|
||||||
| 장애물 뒤로 걸어감 | ghost의 재출현 창(10초) + 방관자 판별 재사용 |
|
|
||||||
| 트래커 ID 스위치 | 동상. 기존 로직 그대로 |
|
|
||||||
|
|
||||||
### 1-8. 테스트할 것
|
|
||||||
|
|
||||||
- 쭈그려 앉기 시퀀스가 **발보하지 않음** (y2 고정)
|
|
||||||
- 계단 하강이 발보하지 않음
|
|
||||||
- 원근 접근이 발보하지 않음 (높이 증가)
|
|
||||||
- 하강 후 prone → `fall`이 아니라 `fall_from_height` **1건**만
|
|
||||||
- 하강 후 화각 이탈 → 재출현 없으면 발보, 재출현하면 취소
|
|
||||||
- 고소 구역 안 소실 → 하강을 못 봤어도 발보
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 동시 다발 쓰러짐 (`multi_down`)
|
|
||||||
|
|
||||||
### 2-1. 왜 별도 이벤트인가
|
|
||||||
|
|
||||||
같은 자리에서 짧은 시간에 두 명이 쓰러진 것은 **넘어짐 두 건이 아닙니다.**
|
|
||||||
질식·가스 누출·폭발입니다. 그리고 이때 사람이 가장 많이 죽는 방식은
|
|
||||||
|
|
||||||
> **첫 번째 사람이 왜 쓰러졌는지 모르고 구조하러 들어간 사람이 같이 죽는 것**
|
|
||||||
|
|
||||||
입니다. 그래서 이 이벤트의 값어치는 감지가 아니라 **통보 문구**에 있습니다.
|
|
||||||
|
|
||||||
### 2-2. 어디에 두는가 — 서버
|
|
||||||
|
|
||||||
엣지가 아니라 **서버**에 둡니다. 이유:
|
|
||||||
|
|
||||||
- 가스는 카메라 한 대의 화각을 존중하지 않습니다. 노드가 다르면 엣지는 서로를 모릅니다.
|
|
||||||
- 현장 즉시 대응은 이미 확보돼 있습니다 — 첫 번째 쓰러짐에서 **경광등이 이미 울립니다.**
|
|
||||||
서버가 늦게 판단해도 잃는 것이 없습니다.
|
|
||||||
|
|
||||||
`DeviceHealthWatch`·`EscalationWatch`와 같은 자리(`dispatch_alerts.php` 크론)에 붙입니다.
|
|
||||||
|
|
||||||
### 2-3. 판정
|
|
||||||
|
|
||||||
```
|
|
||||||
후보 유형 : fall, fall_from_height, no_motion, ghost
|
|
||||||
사람 구분 : (device_id, camera_id, track_id) 조합이 서로 달라야 한다
|
|
||||||
— 한 사람이 넘어졌다 회복했다 다시 넘어진 것은 1명이다
|
|
||||||
창 : window_s (기본 120)
|
|
||||||
인원 : min_people (기본 2)
|
|
||||||
근접성 : 같은 zone_id 또는 spatial 거리 ≤ radius (기본 0.35, 프레임 정규화)
|
|
||||||
둘 다 없으면 같은 camera_id
|
|
||||||
```
|
|
||||||
|
|
||||||
**근접성 조건이 핵심 방어입니다.** 큰 공장에서 2분 안에 무관한 넘어짐 두 건은 일어납니다.
|
|
||||||
공간적으로 붙어 있어야 같은 원인입니다. `spatial_x/y`가 이미 이벤트에 실려 있으니 재료가 있습니다.
|
|
||||||
|
|
||||||
### 2-4. 멱등
|
|
||||||
|
|
||||||
`DeviceHealthWatch`와 같은 방식 — 결정적 uuid + UNIQUE 충돌.
|
|
||||||
|
|
||||||
```
|
|
||||||
uuid = hash("multi_down", scope_key, 최초_event_id, 인원수)
|
|
||||||
```
|
|
||||||
|
|
||||||
인원수를 넣는 이유: 3번째 사람이 쓰러지면 **새 사건으로 한 번 더 올라와야 합니다.**
|
|
||||||
인원은 현장 인원수로 자연히 한정되므로 폭주하지 않습니다.
|
|
||||||
|
|
||||||
### 2-5. 이벤트 계약
|
|
||||||
|
|
||||||
```
|
|
||||||
event_type : multi_down
|
|
||||||
severity : CRITICAL
|
|
||||||
metrics : { people: 3, origin_event_ids: [...], window_s, scope: "zone:z1"|"camera:cam-01",
|
|
||||||
types: ["fall","fall"], first_at, last_at }
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2-6. 통보 문구 — 이 이벤트의 본체
|
|
||||||
|
|
||||||
`Channels`에 유형별 예외를 둡니다. 이 한 문장이 사람을 살립니다.
|
|
||||||
|
|
||||||
```
|
|
||||||
[위급] 동시 다발 쓰러짐 — 3번 라인
|
|
||||||
|
|
||||||
같은 구역에서 2분 안에 2명이 쓰러졌습니다.
|
|
||||||
가스 누출·산소 결핍·폭발을 의심하십시오.
|
|
||||||
|
|
||||||
⚠ 공기호흡기 없이 진입하지 마십시오.
|
|
||||||
밀폐공간 사고 사망자의 상당수가 구조하러 들어간 사람입니다.
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2-7. 테스트할 것
|
|
||||||
|
|
||||||
- 같은 구역 2명 / 창 안 → 발보
|
|
||||||
- 같은 사람이 2번 넘어짐 → **발보하지 않음** (track_id 동일)
|
|
||||||
- 멀리 떨어진 2명 → 발보하지 않음 (근접성)
|
|
||||||
- 창 밖 2명 → 발보하지 않음
|
|
||||||
- 3번째 추가 → 한 번 더 발보, 4번째까지 총 3건 이하
|
|
||||||
- 크론 반복 실행 → 중복 없음
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 구역 프리셋
|
|
||||||
|
|
||||||
### 3-1. 문제
|
|
||||||
|
|
||||||
밀폐공간 작업을 감시하는 데 필요한 것은 이미 **전부 있습니다**:
|
|
||||||
|
|
||||||
> 제한구역 + 2인1조 + 체류 상한 + 정원
|
|
||||||
|
|
||||||
그런데 제품은 "폴리곤을 그리고 `min_workers`를 넣으세요"라고만 말합니다.
|
|
||||||
안전관리자는 `min_workers`를 생각하지 않습니다. **밀폐공간**을 생각합니다.
|
|
||||||
|
|
||||||
### 3-2. 설계 — 코드가 아니라 값
|
|
||||||
|
|
||||||
프리셋은 `(level, rules_json, 안내문)` 묶음입니다. **스키마 변경도 감지기 변경도 없습니다.**
|
|
||||||
구역 편집기에 선택 목록을 놓고, 고르면 레벨과 규칙이 채워집니다.
|
|
||||||
|
|
||||||
| 프리셋 | level | rules_json | 잡는 것 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| **밀폐공간** | restricted | `min_workers:2, max_dwell_s:1800` | 단독 진입 · 장시간 체류 |
|
|
||||||
| **고소작업 개구부** | restricted | `elevated:true, min_workers:2` | 추락(§1) · 단독 고소작업 |
|
|
||||||
| **프레스·협착** | pinch | `max_workers:1` | 협착 침입 · 2인 동시 조작 |
|
|
||||||
| **지게차 통로** | warning | `max_dwell_s:60` | 통로 내 보행·체류 |
|
|
||||||
| **화기작업** | restricted | `min_workers:2, max_dwell_s:3600` | 화기 감시자 부재 |
|
|
||||||
| **크레인 하부 · 붐 선회** | restricted | `max_workers:0` | **사람이 있으면 안 되는 곳** |
|
|
||||||
|
|
||||||
### 3-3. ⚠ 마지막 줄에 걸림돌이 있습니다
|
|
||||||
|
|
||||||
`max_workers: 0`이 **지금은 동작하지 않습니다.**
|
|
||||||
|
|
||||||
```python
|
|
||||||
max_workers = rules.get("max_workers")
|
|
||||||
if max_workers and n > max_workers: # edge/detectors/occupancy.py
|
|
||||||
```
|
|
||||||
|
|
||||||
`0`은 falsy라 규칙이 통째로 꺼졌습니다. **1.2.0 에서 수정** — 감지기·입력란·저장 직렬화·
|
|
||||||
목록 표시 네 곳 모두에서 막혀 있었습니다. 크레인 짐 아래·굴착기 선회 반경은 정확히 그 규칙이고, 깔림 사고의 전형입니다.
|
|
||||||
|
|
||||||
수정은 한 줄입니다 — `if max_workers is not None and n > max_workers`.
|
|
||||||
`min_workers`도 같은 형태이므로 함께 봐야 합니다.
|
|
||||||
|
|
||||||
### 3-4. 프리셋을 어디에 두는가
|
|
||||||
|
|
||||||
`server/src/Api/` 에 카탈로그로 두고 구역 편집기가 읽어갑니다 —
|
|
||||||
`ThresholdCatalog`가 튜너블에 대해 하는 것과 같은 구조(화이트리스트 + 한국어 설명)입니다.
|
|
||||||
프리셋을 고른 뒤 값을 손으로 바꾸는 것은 그대로 허용합니다. 프리셋은 **출발점이지 잠금이 아닙니다.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 구현 순서 제안
|
|
||||||
|
|
||||||
| | 무엇 | 규모 | 왜 이 순서 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 1 | `max_workers: 0` 수정 | 한 줄 + 테스트 | 프리셋의 전제. 지금 조용히 꺼져 있는 버그이기도 함 |
|
|
||||||
| 2 | 구역 프리셋 | 값 + 화면 | 코드 위험 0. 감지 범위 체감 확대가 가장 큼 |
|
|
||||||
| 3 | 추락 | 감지기 1개 + ghost 확장 | 사망 1위. 재료는 다 있음 |
|
|
||||||
| 4 | 동시 다발 | 서버 감시자 1개 | 추락이 들어간 뒤 후보 유형이 완성됨 |
|
|
||||||
|
|
||||||
1·2는 위험이 거의 없고, 3·4는 새 이벤트 유형이라 **엣지·서버·프런트 5곳 동기화**가 필요합니다
|
|
||||||
(`edge/events.py` · `EventValidator` · `schema.sql` CHECK · `TYPE_LABELS` · `events.php`).
|
|
||||||
`tests/test_event_type_parity.py`가 이미 그 다섯을 묶어 검사하므로, 빠뜨리면 CI가 잡습니다.
|
|
||||||
|
|
@ -1,425 +0,0 @@
|
||||||
# 프로젝트 소개서 — 이미지 브리프
|
|
||||||
|
|
||||||
대상 문서: `docs/프로젝트-소개서.md` (v1.1.0)
|
|
||||||
작성 목적: 소개서에 넣을 이미지를 **무엇을·왜·어떤 사양으로** 만들지 확정하기 위한 발주 명세.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. 먼저 읽을 것
|
|
||||||
|
|
||||||
### 0-1. 이미 확보된 자산 — 새로 만들 필요 없음
|
|
||||||
|
|
||||||
실제 제품 화면 캡처 8장이 이미 있습니다 (`server/deploy/manual/shots/`, 1400px 폭, 실데이터).
|
|
||||||
소개서에도 이것을 그대로 씁니다. **이 8장은 발주 대상이 아닙니다.**
|
|
||||||
|
|
||||||
| 파일 | 화면 |
|
|
||||||
|------|------|
|
|
||||||
| `events.jpg` | 이벤트 콘솔 (감지 스트림) |
|
|
||||||
| `event-detail.jpg` | 이벤트 상세 (증거 3프레임) |
|
|
||||||
| `package.jpg` | 사고조사 패키지 |
|
|
||||||
| `report.jpg` | 안전 리포트 |
|
|
||||||
| `compliance.jpg` | 안전 관리 |
|
|
||||||
| `admin.jpg` | 시스템 설정 |
|
|
||||||
| `setup.jpg` | 설치 점검 |
|
|
||||||
| `zones.jpg` | 구역 설정 |
|
|
||||||
|
|
||||||
### 0-2. 이 브리프가 요청하는 것
|
|
||||||
|
|
||||||
**A. 현장 사진 4장** — 제가 만들 수 없습니다. 촬영 또는 스톡 확보가 필요합니다.
|
|
||||||
**B. 감지 장면 4장** — **소개서에서 가장 중요한 그림**입니다. "언제 감지되는가"에 답합니다.
|
|
||||||
**C. 개념 다이어그램 3장** — 구성·흐름을 설명합니다.
|
|
||||||
|
|
||||||
총 **11장**이고, 그중 **P0 6장**이면 소개서가 성립합니다.
|
|
||||||
|
|
||||||
### 0-3. 전 이미지 공통 규칙
|
|
||||||
|
|
||||||
- **가로 방향**, 최소 폭 **1600px** (인쇄 배포 고려). 파일당 2MB 이하 권장.
|
|
||||||
- 형식: 사진 `.jpg`(품질 85 이상) · 다이어그램 `.png`(투명 배경 불필요, 흰 배경).
|
|
||||||
- **텍스트가 이미지 안에 들어가면 한국어로.** 영문 라벨 금지 (고객 대면 문서).
|
|
||||||
- 색: 문서 강조색은 **네이비 `#003478`**. 다이어그램의 주 강조는 이 색으로.
|
|
||||||
위험/경고 표현이 필요하면 빨강 `#dc2626`, 주의 황색 `#b7820a`.
|
|
||||||
|
|
||||||
### 0-4. ⚠ 개인정보 — 이 문서에서 가장 중요한 제약
|
|
||||||
|
|
||||||
이 제품의 핵심 주장은 **"작업자를 감시하지 않는다"** 입니다.
|
|
||||||
소개서에 **얼굴이 식별되는 실제 작업자 사진이 한 장이라도 들어가면 그 주장이 무너집니다.**
|
|
||||||
|
|
||||||
- 실제 인물 촬영 시 **서면 동의 필수**, 또는
|
|
||||||
- 얼굴이 식별되지 않는 각도(뒷모습·측면·헬멧 착용·원거리), 또는
|
|
||||||
- 모델을 쓴 연출 촬영, 또는 라이선스가 명확한 스톡.
|
|
||||||
|
|
||||||
이 규칙을 못 지키는 컷은 **아예 빼는 편이 낫습니다.**
|
|
||||||
|
|
||||||
### 0-5. 배치 방식
|
|
||||||
|
|
||||||
한글 헤딩은 파일명 자동 매칭이 불안정하므로 **매니페스트로 위치를 고정**합니다.
|
|
||||||
이미지를 받으면 제가 `docs/소개서-이미지.json` 을 만들어 HTML 생성기에 넘깁니다.
|
|
||||||
**따라서 파일명은 아래 지정한 이름 그대로** 주시면 됩니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## A. 현장 사진 (촬영·확보 필요)
|
|
||||||
|
|
||||||
### A-1. `01-현장-설치.jpg` — 표지 이미지 ★P0
|
|
||||||
|
|
||||||
| | |
|
|
||||||
|---|---|
|
|
||||||
| **위치** | 문서 최상단, 리드 문단 바로 아래 |
|
|
||||||
| **유형** | 현장 사진 |
|
|
||||||
| **비율** | 16:9 가로 (와이드), 최소 1600×900 |
|
|
||||||
|
|
||||||
**이 이미지가 없으면 전달 안 되는 것**
|
|
||||||
이 시스템이 **실제 공장 안에 설치되는 물건**이라는 사실. 소프트웨어 소개서로만 보이면
|
|
||||||
"화면만 있는 제품"으로 읽힙니다.
|
|
||||||
|
|
||||||
**담아야 할 것**
|
|
||||||
- 실내 공장 또는 물류창고의 실제 작업 공간 (기계·랙·통로가 보이는 곳)
|
|
||||||
- 천장이나 기둥에 설치된 **카메라 1대가 화면 안에 명확히** 보일 것
|
|
||||||
- 가능하면 그 옆에 **경광등**이 함께 보이면 최상 (이 제품의 차별점이라 표지에 값어치가 큼)
|
|
||||||
- 조명은 실제 공장 조도 그대로 — 스튜디오처럼 밝게 보정하지 말 것
|
|
||||||
|
|
||||||
**피할 것**
|
|
||||||
- 얼굴이 식별되는 작업자 (0-4 참조)
|
|
||||||
- 사무실·데이터센터·서버랙 이미지 (이 제품은 현장 물건입니다)
|
|
||||||
- 렌더링·3D 목업 티가 나는 이미지
|
|
||||||
- 카메라가 안 보이는 그냥 공장 전경 (그러면 아무 회사 소개서와 같아집니다)
|
|
||||||
|
|
||||||
**캡션 안**
|
|
||||||
> 기존 CCTV 화각에 엣지 AI 노드를 더해 설치합니다. 배선과 화각을 바꾸지 않습니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### A-2. `02-경광등.jpg` — 현장 경광등 ★P0
|
|
||||||
|
|
||||||
| | |
|
|
||||||
|---|---|
|
|
||||||
| **위치** | §2-2 대응 — 어떻게 사람에게 닿는가 |
|
|
||||||
| **유형** | 현장 사진 (근접) |
|
|
||||||
| **비율** | 4:3 또는 3:2 가로, 최소 1600px 폭 |
|
|
||||||
|
|
||||||
**이 이미지가 없으면 전달 안 되는 것**
|
|
||||||
"인터넷이 끊겨도 울린다"는 이 제품의 **가장 큰 차별점**이 글자로만 남습니다.
|
|
||||||
경광등은 눈에 보이는 물건이므로 사진 한 장이 문단 세 개보다 강합니다.
|
|
||||||
|
|
||||||
**담아야 할 것**
|
|
||||||
- 벽·기둥에 설치되어 **실제로 점등된** 경광등 (적색 권장)
|
|
||||||
- 주변 공장 환경이 배경으로 조금 보일 것 (맥락 없이 경광등만 있으면 부품 사진이 됩니다)
|
|
||||||
- 가능하면 근처에 Pi 노드 함체가 함께 보이면 좋음
|
|
||||||
|
|
||||||
**피할 것**
|
|
||||||
- 흰 배경 제품 컷 (쇼핑몰 사진처럼 보임)
|
|
||||||
- 소방·화재 경보 설비로 오인될 구성
|
|
||||||
|
|
||||||
**캡션 안**
|
|
||||||
> 감지한 노드가 자기 GPIO로 직접 울립니다. 서버도 인터넷도 거치지 않습니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### A-3. `03-엣지노드.jpg` — Pi + Hailo 노드 ★P1
|
|
||||||
|
|
||||||
| | |
|
|
||||||
|---|---|
|
|
||||||
| **위치** | §4-1 엣지 노드 |
|
|
||||||
| **유형** | 하드웨어 사진 |
|
|
||||||
| **비율** | 3:2 가로, 최소 1600px 폭 |
|
|
||||||
|
|
||||||
**목적** — "엣지에서 추론한다"의 실체를 보여줍니다. 클라우드 GPU를 안 쓴다는 주장의 근거.
|
|
||||||
|
|
||||||
**담아야 할 것**
|
|
||||||
- Raspberry Pi 5 + AI HAT+(Hailo-8)가 **조립된 상태**
|
|
||||||
- 실제 배치 형태 (DIN 레일 함체 안 또는 벽면 브래킷 장착)
|
|
||||||
- 카메라 케이블이 연결된 모습이면 더 좋음
|
|
||||||
|
|
||||||
**피할 것**
|
|
||||||
- 맨 기판만 책상 위에 올려둔 컷 (시제품처럼 보입니다)
|
|
||||||
- 과한 조명·보케 처리
|
|
||||||
|
|
||||||
**캡션 안**
|
|
||||||
> 추론은 이 장비에서 끝납니다. 영상은 이 상자 밖으로 나가지 않습니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### A-4. `04-위험구역.jpg` — 위험구역이 있는 작업 공간 ★P2
|
|
||||||
|
|
||||||
| | |
|
|
||||||
|---|---|
|
|
||||||
| **위치** | §2-1 감지 (구역 규칙 위반 표 위) |
|
|
||||||
| **유형** | 현장 사진 |
|
|
||||||
| **비율** | 16:9 가로 |
|
|
||||||
|
|
||||||
**목적** — "구역"이 추상 개념이 아니라 실제 공간임을 보여줍니다.
|
|
||||||
|
|
||||||
**담아야 할 것**
|
|
||||||
- 바닥에 **안전선(황색 테이프)** 이 그어진 구역, 또는 방호 울타리가 있는 기계 주변
|
|
||||||
- 프레스·컨베이어처럼 협착 위험이 명확한 설비가 보이면 최상
|
|
||||||
|
|
||||||
**피할 것** — 사람이 그 구역 안에 있는 컷 (규정 위반 장면을 홍보물에 넣는 셈이 됩니다)
|
|
||||||
|
|
||||||
**캡션 안**
|
|
||||||
> 관리자가 이 화면 위에 직접 구역을 그리고, 등급과 인원 규칙을 붙입니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## B. 감지 장면 — 무엇을 보고, 언제 울리는가 (제작 필요)
|
|
||||||
|
|
||||||
> **이 소개서에서 가장 중요한 그림들입니다.**
|
|
||||||
> 독자가 가장 먼저 묻는 질문은 "그래서 대체 언제 감지되는가"입니다. 지금 소개서는 그 답을
|
|
||||||
> 표와 문장으로만 하고 있어 읽히지 않습니다. 아래 4장이 그 답입니다.
|
|
||||||
>
|
|
||||||
> **사람은 일러스트(픽토그램·실루엣)로 그립니다.** 실사 사진이 아니므로 §0-4의 개인정보
|
|
||||||
> 제약과 충돌하지 않고, 오히려 안전 교육 자료의 표준 표현입니다.
|
|
||||||
>
|
|
||||||
> 아래 수치와 판정 조건은 **실제 제품 코드값**입니다
|
|
||||||
> (`edge/detectors/fall.py`, `zone.py`, `occupancy.py`). 임의로 바꾸면 소개서가 제품과 어긋납니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### B-1. `08-넘어짐-감지-시퀀스.png` — 넘어짐은 이렇게 감지된다 ★P0 (전체 최우선)
|
|
||||||
|
|
||||||
**이 그림이 말해야 하는 한 문장**
|
|
||||||
|
|
||||||
> 한 프레임 보고 놀라서 울리는 게 아니라, **자세가 지속되는지를 초 단위로 확인하고 단계적으로 올린다.**
|
|
||||||
|
|
||||||
**왜 필요한가** — 이 그림이 없으면 소개서에는 "AI가 알아서 감지한다"는 말만 남습니다.
|
|
||||||
그건 모든 경쟁 제품이 하는 말이고, 구매자는 그 말을 믿지 않습니다.
|
|
||||||
이 한 장이 **"판정 근거를 설명할 수 있는 제품"** 이라는 인상을 만듭니다.
|
|
||||||
|
|
||||||
**형식** — 가로 4컷 스트립 + 되돌아가는 분기 1개. 각 컷은 3층 구조로:
|
|
||||||
(위) 사람 자세 일러스트 · (가운데) 시스템이 보는 것 · (아래) 판정과 조치
|
|
||||||
|
|
||||||
| 컷 | 사람 자세 | 시스템이 보는 것 | 판정 | 조치 |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| ① | 서서 작업 | 평소 키높이를 기억해 둠 | 정상 | — |
|
|
||||||
| ② | 넘어지는 중 | 가로가 세로보다 넓어짐 · 키높이가 평소의 35% 아래로 붕괴 · 몸통이 70° 이상 기욺 · 머리가 엉덩이보다 낮음 | **1초 지속** → 위험 자세 | 아직 알람 없음 |
|
|
||||||
| ③ | 바닥에 쓰러짐 | 같은 자세가 계속됨 | **3초 지속** → 넘어짐 확정 | 경광등 + 통보 + 스냅샷 |
|
|
||||||
| ④ | 움직임 없음 | 관절 움직임이 임계 이하 | **10초 지속** → 미동 없음 | 최상위 위급으로 승격 |
|
|
||||||
| 분기 | ②에서 다시 일어섬 | 1초간 정상 자세 유지 | **니어미스** | 알람 없음 · **기록만** (히트맵·통계) |
|
|
||||||
|
|
||||||
**꼭 들어가야 하는 것**
|
|
||||||
|
|
||||||
- **1초 / 3초 / 10초** 숫자. 이 그림의 알맹이입니다.
|
|
||||||
- 컷 ②와 ③의 **색이 달라야** 합니다 (② 주의색, ③ 경보색). 단계가 있다는 게 눈에 보여야 합니다.
|
|
||||||
- **분기 화살표(② → 니어미스)**. "넘어질 뻔한 것도 남는다"는 이 제품의 예방 논리 전체가
|
|
||||||
여기 걸려 있는데, 지금 소개서에는 문장 한 줄뿐입니다.
|
|
||||||
- 여유가 되면 컷 ③ 옆에 작게 **"가려져도 판정 유지"**. 가로세로비·키높이 붕괴 신호는 골격이
|
|
||||||
안 보여도 동작하므로, 지게차나 적재물에 가린 상황에서도 사고가 취소되지 않습니다.
|
|
||||||
|
|
||||||
**피할 것**
|
|
||||||
|
|
||||||
- **골격(스켈레톤) 선을 사람 위에 겹치지 마세요.** §E에 적은 이유로 제외한 표현입니다.
|
|
||||||
"시스템이 보는 것"은 텍스트와 아이콘으로 표현합니다.
|
|
||||||
- 유혈·고통 표현. 픽토그램 수준의 중립적 실루엣으로 충분합니다.
|
|
||||||
- 실사 인물 사진 합성 금지.
|
|
||||||
|
|
||||||
**사양** — 가로형, 폭 2000px 이상 (컷이 많아 다른 그림보다 넓어야 합니다). 배경 흰색/투명.
|
|
||||||
|
|
||||||
**캡션안** — "넘어짐 감지 단계. 1초 위험 자세 → 3초 확정 → 10초 미동 없음. 도중에 일어서면 니어미스로만 기록됩니다."
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### B-2. `09-앉기-vs-넘어짐.png` — 오탐이 나지 않는 이유 ★P0
|
|
||||||
|
|
||||||
**한 문장**
|
|
||||||
|
|
||||||
> 앉거나 쭈그린 자세와 넘어진 자세는 **형태와 시간**으로 구분한다.
|
|
||||||
|
|
||||||
**왜 필요한가** — 도입 검토에서 **반드시** 나오는 첫 질문이 "앉으면 울리는 것 아니냐"입니다.
|
|
||||||
지금 소개서에는 이 답이 없습니다. 여기서 막히면 나머지 설명이 진행되지 않습니다.
|
|
||||||
|
|
||||||
**형식** — 좌우 2단 대비. 같은 크기, 같은 시점으로.
|
|
||||||
|
|
||||||
| | 왼쪽 — 울리지 않음 | 오른쪽 — 울림 |
|
|
||||||
|---|---|---|
|
|
||||||
| 자세 | 쭈그려 앉아 작업 / 허리 굽혀 물건 듦 | 바닥에 옆으로 누움 |
|
|
||||||
| 형태 | 세로가 여전히 김 | 가로가 세로보다 넓음 |
|
|
||||||
| 시간 | — | 3초 이상 그대로 |
|
|
||||||
| 결과 | 정상 (체크 표시) | 넘어짐 (경보색) |
|
|
||||||
|
|
||||||
**꼭** — 왼쪽에 **큼직한 체크 표시**를 넣어 주세요. 이 그림의 목적은 설명이 아니라 **안심**입니다.
|
|
||||||
|
|
||||||
**덧붙일 한 줄** (그림 하단 또는 캡션) — "그래도 오탐은 납니다. 화면에서 오탐 표시 한 번이면
|
|
||||||
통계에서 빠지고, 감도 조정의 근거가 됩니다." 소개서의 "약속하지 않는 것" 절과 톤을 맞춥니다.
|
|
||||||
|
|
||||||
**사양** — 가로형 1600px 이상. 배경 흰색/투명.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### B-3. `10-위험구역-판정.png` — 구역 침입은 이렇게 판정된다 ★P1
|
|
||||||
|
|
||||||
**한 문장**
|
|
||||||
|
|
||||||
> 사람의 **발이** 화면에 그려 둔 구역 안으로 들어오면, 그 순간이 기록된다.
|
|
||||||
|
|
||||||
**형식** — 작업장 사시도(또는 카메라 시점) 한 장에:
|
|
||||||
|
|
||||||
- 반투명 경보색 폴리곤 = 위험구역 (예: 프레스 앞, 지게차 통로)
|
|
||||||
- 사람 일러스트 2명 — 구역 밖 1명(정상), 구역 경계를 밟는 1명(감지)
|
|
||||||
- 감지된 사람의 **발 위치에 작은 표식** + 라벨 **"판정 기준 = 발 위치"**
|
|
||||||
- 그 사람 위 말풍선 **"0.75초 지속 시 이벤트"**
|
|
||||||
|
|
||||||
**꼭** — 판정 기준이 몸통 중심이 아니라 **발**이라는 점. 이걸 알아야 안전 담당자가
|
|
||||||
"프레스 앞 1m" 같은 구역을 실제로 제대로 그릴 수 있습니다.
|
|
||||||
|
|
||||||
**피할 것** — 구역을 화면 전체에 덮지 마세요. 구역은 **좁게 지정하는 것**이 운용 원칙이고,
|
|
||||||
넓게 그린 그림은 잘못된 사용법을 가르칩니다.
|
|
||||||
|
|
||||||
**사양** — 가로형 1600px 이상.
|
|
||||||
|
|
||||||
**캡션안** — "위험구역은 화면 위에 직접 그립니다. 발 위치가 구역 안에 0.75초 이상 머물면 이벤트가 남습니다."
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### B-4. `11-2인1조.png` — 혼자 있으면 안 되는 곳 ★P2
|
|
||||||
|
|
||||||
**한 문장**
|
|
||||||
|
|
||||||
> 자세뿐 아니라 **사람이 몇 명 있는지**도 안전 조건이다.
|
|
||||||
|
|
||||||
**형식** — 한 구역 안을 좌우로 대비.
|
|
||||||
왼쪽: 2명이 함께 작업(정상) / 오른쪽: 1명만 남음 → 라벨 **"2인 1조 위반 · 30초 지속"**
|
|
||||||
|
|
||||||
여유가 되면 같은 형식의 작은 칸으로 **"정원 초과 · 10초"**, **"장시간 체류"** 를 덧붙여도 좋습니다.
|
|
||||||
|
|
||||||
**사양** — 가로형 1400px 이상.
|
|
||||||
|
|
||||||
---
|
|
||||||
## C. 개념 다이어그램 (제작 필요)
|
|
||||||
|
|
||||||
> 아래 3장은 **개념도**입니다. §B가 "언제 울리는가"를 설명한다면, 여기는 "무엇으로 이뤄져 있는가"입니다. 제가 만든 SVG는 품질이 떨어지므로 스펙만 드립니다.
|
|
||||||
> 손으로 그리셔도 되고, 디자인 툴이나 이미지 생성 도구를 쓰셔도 됩니다.
|
|
||||||
> 각 항목의 **"이 그림이 말해야 하는 한 문장"** 이 전달되면 형태는 자유입니다.
|
|
||||||
|
|
||||||
### C-1. `05-사슬.png` — 감지 → 대응 → 증거 ★P0 (다이어그램 중 최우선)
|
|
||||||
|
|
||||||
| | |
|
|
||||||
|---|---|
|
|
||||||
| **위치** | §2 무엇을 하는가 (현재 ASCII 도식을 대체) |
|
|
||||||
| **비율** | 16:6 정도의 가로로 긴 형태, 최소 1800×700 |
|
|
||||||
|
|
||||||
**이 그림이 말해야 하는 한 문장**
|
|
||||||
> "감지만 있는 제품이 아니라, 사람에게 닿고 증거로 남는 데까지가 하나로 이어져 있다."
|
|
||||||
|
|
||||||
**구성** — 왼쪽에서 오른쪽으로 흐르는 3단 블록. 블록 사이에 굵은 화살표.
|
|
||||||
|
|
||||||
```
|
|
||||||
[1] 감지 [2] 대응 [3] 증거
|
|
||||||
───────────── ───────────────── ─────────────────
|
|
||||||
20종 이벤트 → 현장 경광등 (즉시) → 사고조사 패키지
|
|
||||||
엣지 AI 판정 담당자 통보 (1분) 증거 3프레임
|
|
||||||
얼굴 자동 블러 상위 확대 (5분) 통보·조치 이력
|
|
||||||
위·변조 탐지 기록
|
|
||||||
```
|
|
||||||
|
|
||||||
**반드시 들어갈 요소**
|
|
||||||
- 각 단계에 **시간 표기**: 감지 `3초` · 경광등 `즉시` · 통보 `1분` · 상위 확대 `5분`.
|
|
||||||
이 숫자들이 이 그림의 핵심입니다 — 속도가 곧 제품 가치이므로.
|
|
||||||
- 2단계 "현장 경광등" 옆에 **네트워크 불필요** 표시 (예: 와이파이 아이콘에 사선)
|
|
||||||
- 3단계는 문서 아이콘 계열로 — 1·2단계와 성격이 다름을 형태로 구분
|
|
||||||
|
|
||||||
**피할 것**
|
|
||||||
- 클라우드 아이콘 (이 제품은 클라우드를 쓰지 않습니다 — 정반대 메시지)
|
|
||||||
- 사람 얼굴 아이콘
|
|
||||||
- 3단계를 동등한 크기로만 나열하기 → 화살표가 굵어야 "이어져 있다"가 읽힙니다
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### C-2. `06-구성도.png` — 시스템 구성 ★P0
|
|
||||||
|
|
||||||
| | |
|
|
||||||
|---|---|
|
|
||||||
| **위치** | §4 시스템 구성 (현재 ASCII 도식을 대체) |
|
|
||||||
| **비율** | 4:3 또는 3:2 세로로 여유 있는 형태, 최소 1600px 폭 |
|
|
||||||
|
|
||||||
**이 그림이 말해야 하는 한 문장**
|
|
||||||
> "영상은 현장을 벗어나지 않는다. 밖으로 나가는 것은 스냅샷 3장과 메타데이터뿐이다."
|
|
||||||
|
|
||||||
**구성** — 위에서 아래로 3층.
|
|
||||||
|
|
||||||
| 층 | 내용 | 표기 |
|
|
||||||
|---|---|---|
|
|
||||||
| 1층 | 카메라 (CSI/USB) | "Pi 1대당 2~4대" |
|
|
||||||
| 2층 | Raspberry Pi 5 + Hailo-8 | 자세추정 · 이벤트 판정 · **얼굴 블러(저장 전)** · 오프라인 큐 · **경광등 GPIO** |
|
|
||||||
| 3층 | 웹 대시보드 (PHP + MySQL) | "일반 웹호스팅에서 동작" |
|
|
||||||
| 3층 우측 | 통보 | 메일 · 사내 웹훅/문자 |
|
|
||||||
|
|
||||||
**반드시 들어갈 요소**
|
|
||||||
- **2층과 3층 사이 화살표에 라벨**: `HMAC 서명 · 스냅샷 3장 + 메타데이터` 그리고
|
|
||||||
**`영상 없음`** — 이 라벨이 이 그림에서 가장 중요합니다.
|
|
||||||
- 2층을 **점선 박스로 감싸고 "현장(폐쇄망 가능)"** 이라고 표기 → 경계가 눈에 보이게
|
|
||||||
- 2층에서 경광등으로 빠지는 **짧은 화살표를 따로** (서버를 안 거친다는 것이 보여야 함)
|
|
||||||
|
|
||||||
**피할 것**
|
|
||||||
- 인터넷/클라우드 아이콘을 2층과 3층 사이에 크게 넣기 — 폐쇄망 구성이 가능하다는 점이 흐려짐
|
|
||||||
- 화살표를 양방향으로 (데이터는 위로 올라가고, 설정만 내려옵니다 — 굳이 그린다면 설정 하향 화살표는 가늘게)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### C-3. `07-대응시간.png` — 발견 시간 비교 ★P1
|
|
||||||
|
|
||||||
| | |
|
|
||||||
|---|---|
|
|
||||||
| **위치** | §3-1 즉시 달라지는 것 (표 위 또는 아래) |
|
|
||||||
| **비율** | 16:7 가로, 최소 1800px 폭 |
|
|
||||||
|
|
||||||
**이 그림이 말해야 하는 한 문장**
|
|
||||||
> "야간에 쓰러지면 지금은 다음 순찰까지 방치된다. 이 시스템은 그 간격을 분 단위로 줄인다."
|
|
||||||
|
|
||||||
**구성** — 위아래 두 개의 수평 타임라인.
|
|
||||||
|
|
||||||
```
|
|
||||||
[도입 전] 쓰러짐 ●─────────────────────────────────────● 다음 순찰/교대 때 발견
|
|
||||||
└───────── 수십 분 ~ 수 시간 ─────────┘
|
|
||||||
|
|
||||||
[도입 후] 쓰러짐 ●─┬──┬─────┬──────────────────────────
|
|
||||||
│ │ └ 5분: 미확인 시 상위 담당자
|
|
||||||
│ └ 1분 이내: 담당자 통보
|
|
||||||
└ 3초: 감지 · 현장 경광등
|
|
||||||
```
|
|
||||||
|
|
||||||
**반드시 들어갈 요소**
|
|
||||||
- 위쪽(도입 전) 막대는 **회색·길게**, 아래쪽(도입 후)은 **네이비·짧게** — 길이 대비가 메시지
|
|
||||||
- 아래쪽 타임라인의 3·1분·5분 지점에 마커와 라벨
|
|
||||||
- "수십 분 ~ 수 시간"은 **범위로만** 표기할 것 (구체 수치는 현장마다 달라 단정하면 반박당합니다)
|
|
||||||
|
|
||||||
**피할 것**
|
|
||||||
- 구체적인 생존율·부상률 수치 (근거 없는 주장이 됩니다)
|
|
||||||
- 응급/의료 이미지 (과장으로 읽힙니다)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## D. 정리 — 발주 목록
|
|
||||||
|
|
||||||
| 우선순위 | 파일명 | 유형 | 비고 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| **P0** | `08-넘어짐-감지-시퀀스.png` | 감지 장면 | **전체 최우선.** 1초/3초/10초 + 니어미스 분기 필수 |
|
|
||||||
| **P0** | `09-앉기-vs-넘어짐.png` | 감지 장면 | "앉으면 울리냐"에 대한 답 |
|
|
||||||
| **P0** | `01-현장-설치.jpg` | 사진 | 표지. 카메라(+경광등)가 보이는 실내 현장 |
|
|
||||||
| **P0** | `02-경광등.jpg` | 사진 | 점등 상태. 최대 차별점 |
|
|
||||||
| **P0** | `05-사슬.png` | 다이어그램 | 감지→대응→증거, 시간 표기 필수 |
|
|
||||||
| **P0** | `06-구성도.png` | 다이어그램 | "영상 없음" 라벨 필수 |
|
|
||||||
| P1 | `10-위험구역-판정.png` | 감지 장면 | 판정 기준 = 발 위치 |
|
|
||||||
| P1 | `03-엣지노드.jpg` | 사진 | Pi + Hailo 조립·설치 상태 |
|
|
||||||
| P1 | `07-대응시간.png` | 다이어그램 | 전/후 타임라인 대비 |
|
|
||||||
| P2 | `11-2인1조.png` | 감지 장면 | 인원 수 기반 규칙 |
|
|
||||||
| P2 | `04-위험구역.jpg` | 사진 | 안전선·방호 울타리 |
|
|
||||||
|
|
||||||
**P0 6장이면 소개서는 성립합니다.** P1·P2는 여유가 될 때 추가하면 됩니다.
|
|
||||||
|
|
||||||
**단 한 장만 만들 수 있다면 `08-넘어짐-감지-시퀀스.png`** 입니다. 소개서 전체에서
|
|
||||||
가장 답이 안 되어 있는 질문("언제 감지되는가")을 이 그림 하나가 해결합니다.
|
|
||||||
|
|
||||||
**전달 방법** — 위 파일명 그대로 `docs/images/` 에 넣어주시면, 제가 매니페스트를 만들어
|
|
||||||
HTML을 재생성하고 배치·캡션·인쇄 레이아웃까지 맞추겠습니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## E. 참고 — 넣지 않기로 한 것
|
|
||||||
|
|
||||||
검토했으나 의도적으로 제외한 이미지입니다. 나중에 "왜 없지?"가 나오지 않도록 남깁니다.
|
|
||||||
|
|
||||||
| 제외 항목 | 이유 |
|
|
||||||
|---|---|
|
|
||||||
| 넘어짐 감지 순간의 **실제 프레임(사진)** | 얼굴 블러를 해도 **사람이 쓰러진 사진**을 홍보물에 쓰는 것은 부적절. → 다만 **일러스트로 그린 사고 장면은 §B에 포함**했습니다. 실사 사진만 제외하는 것이고, 감지 시점 설명 자체는 소개서에 반드시 있어야 합니다 |
|
|
||||||
| 얼굴 블러 전/후 비교 | 비교하려면 **블러 전 원본**을 문서에 실어야 함 — 프라이버시 주장과 정면 충돌 |
|
|
||||||
| 골격(스켈레톤) 오버레이 시연 | 기술 데모로는 좋지만, 작업자를 추적한다는 인상을 강화해 도입 저항을 키움. §B에서도 골격선 대신 **판정 근거를 글자로** 적는 방식을 택함 |
|
|
||||||
| AI·뉴럴넷 추상 이미지 | 아무 정보도 전달하지 않음. 이 소개서의 문제는 "구체성 부족"이므로 정반대 방향 |
|
|
||||||
|
|
@ -1,157 +0,0 @@
|
||||||
# 이미지 브리프 2 — 감지 장면
|
|
||||||
|
|
||||||
> **1차 브리프(`소개서-이미지-브리프.md`)와 별개로, 새로 추가된 4장만 모은 문서입니다.**
|
|
||||||
> 1차에서 요청한 현장 사진 4장·개념 다이어그램 3장은 여기 없습니다.
|
|
||||||
|
|
||||||
## 왜 이 4장인가
|
|
||||||
|
|
||||||
1차 브리프에는 **"언제 감지되는가"에 답하는 그림이 하나도 없었습니다.**
|
|
||||||
쓰러진 사람 *사진* 을 홍보물에 쓰는 것이 부적절하다고 판단해 제외하면서,
|
|
||||||
**감지 시점을 설명하는 그림까지 함께 빠졌습니다.** 둘은 다릅니다 —
|
|
||||||
일러스트로 그린 사고 장면은 개인정보 제약과 충돌하지 않고, 안전 교육 자료에서는
|
|
||||||
오히려 표준 표현입니다.
|
|
||||||
|
|
||||||
소개서를 읽는 사람이 가장 먼저 묻는 질문이 "그래서 대체 언제 울리는가"입니다.
|
|
||||||
지금 소개서는 그 답을 표와 문장으로만 하고 있어 읽히지 않습니다. 아래 4장이 그 답입니다.
|
|
||||||
|
|
||||||
## 공통 규칙
|
|
||||||
|
|
||||||
- **사람은 일러스트(픽토그램·실루엣)로.** 실사 인물 사진 금지 — 이 제품의 핵심 주장이
|
|
||||||
"작업자를 감시하지 않는다"인데, 얼굴이 식별되는 사진이 한 장이라도 들어가면 그 주장이 무너집니다.
|
|
||||||
- **가로 방향.** 최소 폭 1600px (`08`은 2000px 이상). 배경 흰색 또는 투명. `.png`.
|
|
||||||
- **이미지 안 텍스트는 한국어로.** 영문 라벨 금지 (고객 대면 문서).
|
|
||||||
- 색: 강조 네이비 `#003478` · 경보 빨강 `#dc2626` · 주의 황색 `#b7820a`.
|
|
||||||
- **골격(스켈레톤) 선을 사람 위에 겹치지 마세요.** 작업자를 추적한다는 인상을 키워
|
|
||||||
도입 저항을 만듭니다. "시스템이 보는 것"은 글자와 아이콘으로 표현합니다.
|
|
||||||
|
|
||||||
## 수치는 임의로 바꾸지 마세요
|
|
||||||
|
|
||||||
아래 초 단위 값과 판정 조건은 **실제 제품 코드값**입니다
|
|
||||||
(`edge/detectors/fall.py`, `zone.py`, `occupancy.py`). 그림과 제품이 어긋나면
|
|
||||||
영업 문서에 거짓을 싣는 것이 됩니다.
|
|
||||||
|
|
||||||
## 발주 순서
|
|
||||||
|
|
||||||
| 우선순위 | 파일명 | 한 줄 |
|
|
||||||
|---|---|---|
|
|
||||||
| **P0 · 전체 최우선** | `08-넘어짐-감지-시퀀스.png` | 초 단위로 확인하고 단계적으로 올린다 |
|
|
||||||
| **P0** | `09-앉기-vs-넘어짐.png` | 앉은 자세와는 형태·시간으로 구분한다 |
|
|
||||||
| P1 | `10-위험구역-판정.png` | 판정 기준은 발 위치 |
|
|
||||||
| P2 | `11-2인1조.png` | 인원 수도 안전 조건이다 |
|
|
||||||
|
|
||||||
**단 한 장만 만들 수 있다면 `08`** 입니다. 소개서 전체에서 가장 답이 안 되어 있는
|
|
||||||
질문을 이 그림 하나가 해결합니다.
|
|
||||||
|
|
||||||
**전달** — 위 파일명 그대로 `docs/images/` 에 넣어주시면 소개서 HTML을 재생성합니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 1. `08-넘어짐-감지-시퀀스.png` — 넘어짐은 이렇게 감지된다 ★P0 (전체 최우선)
|
|
||||||
|
|
||||||
**이 그림이 말해야 하는 한 문장**
|
|
||||||
|
|
||||||
> 한 프레임 보고 놀라서 울리는 게 아니라, **자세가 지속되는지를 초 단위로 확인하고 단계적으로 올린다.**
|
|
||||||
|
|
||||||
**왜 필요한가** — 이 그림이 없으면 소개서에는 "AI가 알아서 감지한다"는 말만 남습니다.
|
|
||||||
그건 모든 경쟁 제품이 하는 말이고, 구매자는 그 말을 믿지 않습니다.
|
|
||||||
이 한 장이 **"판정 근거를 설명할 수 있는 제품"** 이라는 인상을 만듭니다.
|
|
||||||
|
|
||||||
**형식** — 가로 4컷 스트립 + 되돌아가는 분기 1개. 각 컷은 3층 구조로:
|
|
||||||
(위) 사람 자세 일러스트 · (가운데) 시스템이 보는 것 · (아래) 판정과 조치
|
|
||||||
|
|
||||||
| 컷 | 사람 자세 | 시스템이 보는 것 | 판정 | 조치 |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| ① | 서서 작업 | 평소 키높이를 기억해 둠 | 정상 | — |
|
|
||||||
| ② | 넘어지는 중 | 가로가 세로보다 넓어짐 · 키높이가 평소의 35% 아래로 붕괴 · 몸통이 70° 이상 기욺 · 머리가 엉덩이보다 낮음 | **1초 지속** → 위험 자세 | 아직 알람 없음 |
|
|
||||||
| ③ | 바닥에 쓰러짐 | 같은 자세가 계속됨 | **3초 지속** → 넘어짐 확정 | 경광등 + 통보 + 스냅샷 |
|
|
||||||
| ④ | 움직임 없음 | 관절 움직임이 임계 이하 | **10초 지속** → 미동 없음 | 최상위 위급으로 승격 |
|
|
||||||
| 분기 | ②에서 다시 일어섬 | 1초간 정상 자세 유지 | **니어미스** | 알람 없음 · **기록만** (히트맵·통계) |
|
|
||||||
|
|
||||||
**꼭 들어가야 하는 것**
|
|
||||||
|
|
||||||
- **1초 / 3초 / 10초** 숫자. 이 그림의 알맹이입니다.
|
|
||||||
- 컷 ②와 ③의 **색이 달라야** 합니다 (② 주의색, ③ 경보색). 단계가 있다는 게 눈에 보여야 합니다.
|
|
||||||
- **분기 화살표(② → 니어미스)**. "넘어질 뻔한 것도 남는다"는 이 제품의 예방 논리 전체가
|
|
||||||
여기 걸려 있는데, 지금 소개서에는 문장 한 줄뿐입니다.
|
|
||||||
- 여유가 되면 컷 ③ 옆에 작게 **"가려져도 판정 유지"**. 가로세로비·키높이 붕괴 신호는 골격이
|
|
||||||
안 보여도 동작하므로, 지게차나 적재물에 가린 상황에서도 사고가 취소되지 않습니다.
|
|
||||||
|
|
||||||
**피할 것**
|
|
||||||
|
|
||||||
- **골격(스켈레톤) 선을 사람 위에 겹치지 마세요.** 1차 브리프에서 제외한 표현입니다.
|
|
||||||
"시스템이 보는 것"은 텍스트와 아이콘으로 표현합니다.
|
|
||||||
- 유혈·고통 표현. 픽토그램 수준의 중립적 실루엣으로 충분합니다.
|
|
||||||
- 실사 인물 사진 합성 금지.
|
|
||||||
|
|
||||||
**사양** — 가로형, 폭 2000px 이상 (컷이 많아 다른 그림보다 넓어야 합니다). 배경 흰색/투명.
|
|
||||||
|
|
||||||
**캡션안** — "넘어짐 감지 단계. 1초 위험 자세 → 3초 확정 → 10초 미동 없음. 도중에 일어서면 니어미스로만 기록됩니다."
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. `09-앉기-vs-넘어짐.png` — 오탐이 나지 않는 이유 ★P0
|
|
||||||
|
|
||||||
**한 문장**
|
|
||||||
|
|
||||||
> 앉거나 쭈그린 자세와 넘어진 자세는 **형태와 시간**으로 구분한다.
|
|
||||||
|
|
||||||
**왜 필요한가** — 도입 검토에서 **반드시** 나오는 첫 질문이 "앉으면 울리는 것 아니냐"입니다.
|
|
||||||
지금 소개서에는 이 답이 없습니다. 여기서 막히면 나머지 설명이 진행되지 않습니다.
|
|
||||||
|
|
||||||
**형식** — 좌우 2단 대비. 같은 크기, 같은 시점으로.
|
|
||||||
|
|
||||||
| | 왼쪽 — 울리지 않음 | 오른쪽 — 울림 |
|
|
||||||
|---|---|---|
|
|
||||||
| 자세 | 쭈그려 앉아 작업 / 허리 굽혀 물건 듦 | 바닥에 옆으로 누움 |
|
|
||||||
| 형태 | 세로가 여전히 김 | 가로가 세로보다 넓음 |
|
|
||||||
| 시간 | — | 3초 이상 그대로 |
|
|
||||||
| 결과 | 정상 (체크 표시) | 넘어짐 (경보색) |
|
|
||||||
|
|
||||||
**꼭** — 왼쪽에 **큼직한 체크 표시**를 넣어 주세요. 이 그림의 목적은 설명이 아니라 **안심**입니다.
|
|
||||||
|
|
||||||
**덧붙일 한 줄** (그림 하단 또는 캡션) — "그래도 오탐은 납니다. 화면에서 오탐 표시 한 번이면
|
|
||||||
통계에서 빠지고, 감도 조정의 근거가 됩니다." 소개서의 "약속하지 않는 것" 절과 톤을 맞춥니다.
|
|
||||||
|
|
||||||
**사양** — 가로형 1600px 이상. 배경 흰색/투명.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. `10-위험구역-판정.png` — 구역 침입은 이렇게 판정된다 ★P1
|
|
||||||
|
|
||||||
**한 문장**
|
|
||||||
|
|
||||||
> 사람의 **발이** 화면에 그려 둔 구역 안으로 들어오면, 그 순간이 기록된다.
|
|
||||||
|
|
||||||
**형식** — 작업장 사시도(또는 카메라 시점) 한 장에:
|
|
||||||
|
|
||||||
- 반투명 경보색 폴리곤 = 위험구역 (예: 프레스 앞, 지게차 통로)
|
|
||||||
- 사람 일러스트 2명 — 구역 밖 1명(정상), 구역 경계를 밟는 1명(감지)
|
|
||||||
- 감지된 사람의 **발 위치에 작은 표식** + 라벨 **"판정 기준 = 발 위치"**
|
|
||||||
- 그 사람 위 말풍선 **"0.75초 지속 시 이벤트"**
|
|
||||||
|
|
||||||
**꼭** — 판정 기준이 몸통 중심이 아니라 **발**이라는 점. 이걸 알아야 안전 담당자가
|
|
||||||
"프레스 앞 1m" 같은 구역을 실제로 제대로 그릴 수 있습니다.
|
|
||||||
|
|
||||||
**피할 것** — 구역을 화면 전체에 덮지 마세요. 구역은 **좁게 지정하는 것**이 운용 원칙이고,
|
|
||||||
넓게 그린 그림은 잘못된 사용법을 가르칩니다.
|
|
||||||
|
|
||||||
**사양** — 가로형 1600px 이상.
|
|
||||||
|
|
||||||
**캡션안** — "위험구역은 화면 위에 직접 그립니다. 발 위치가 구역 안에 0.75초 이상 머물면 이벤트가 남습니다."
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. `11-2인1조.png` — 혼자 있으면 안 되는 곳 ★P2
|
|
||||||
|
|
||||||
**한 문장**
|
|
||||||
|
|
||||||
> 자세뿐 아니라 **사람이 몇 명 있는지**도 안전 조건이다.
|
|
||||||
|
|
||||||
**형식** — 한 구역 안을 좌우로 대비.
|
|
||||||
왼쪽: 2명이 함께 작업(정상) / 오른쪽: 1명만 남음 → 라벨 **"2인 1조 위반 · 30초 지속"**
|
|
||||||
|
|
||||||
여유가 되면 같은 형식의 작은 칸으로 **"정원 초과 · 10초"**, **"장시간 체류"** 를 덧붙여도 좋습니다.
|
|
||||||
|
|
||||||
**사양** — 가로형 1400px 이상.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
{
|
|
||||||
"images": [
|
|
||||||
{
|
|
||||||
"file": "01-현장-설치.jpg",
|
|
||||||
"heading": "## 1. 왜 만들었나",
|
|
||||||
"placement": "before-heading",
|
|
||||||
"caption": "작업장에 설치된 카메라와 현장 경광등."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "05-사슬.png",
|
|
||||||
"heading": "## 2. 무엇을 하는가",
|
|
||||||
"caption": "감지 → 대응 → 증거. 어느 한 칸이 비면 나머지가 무의미해집니다."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "08-넘어짐-감지-시퀀스.jpg",
|
|
||||||
"heading": "### 2-1. 감지 — 무엇을 알아채는가",
|
|
||||||
"caption": "넘어짐 감지 단계. 1초 위험 자세 → 3초 확정 → 10초 미동 없음. 도중에 일어서면 니어미스로만 기록됩니다."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "09-앉기-vs-넘어짐.jpg",
|
|
||||||
"heading": "### 2-1. 감지 — 무엇을 알아채는가",
|
|
||||||
"caption": "앉은 자세와 넘어진 자세는 형태와 지속 시간으로 구분합니다."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "10-위험구역-판정.jpg",
|
|
||||||
"heading": "### 2-1. 감지 — 무엇을 알아채는가",
|
|
||||||
"caption": "위험구역은 화면 위에 직접 그립니다. 판정 기준은 발 위치입니다."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "11-2인1조.jpg",
|
|
||||||
"heading": "### 2-1. 감지 — 무엇을 알아채는가",
|
|
||||||
"caption": "자세뿐 아니라 구역 안의 인원 수도 안전 조건입니다."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "02-경광등.jpg",
|
|
||||||
"heading": "### 2-2. 대응 — 어떻게 사람에게 닿는가",
|
|
||||||
"caption": "현장 경광등은 감지한 노드가 자기 GPIO로 직접 울립니다. 서버도 인터넷도 거치지 않습니다."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "07-대응시간.png",
|
|
||||||
"heading": "### 3-1. 즉시 달라지는 것",
|
|
||||||
"caption": "발견까지 걸리는 시간의 변화."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "06-구성도.png",
|
|
||||||
"heading": "## 4. 시스템 구성",
|
|
||||||
"caption": "카메라 → 엣지 노드 → 대시보드. 노드에서 서버로 가는 것은 스냅샷 3장과 메타데이터뿐입니다."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "03-엣지노드.jpg",
|
|
||||||
"heading": "### 4-1. 엣지 노드",
|
|
||||||
"caption": "Raspberry Pi 5 + Hailo-8 가속기."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "04-위험구역.jpg",
|
|
||||||
"heading": "## 6. 납품과 운영",
|
|
||||||
"caption": "설치 대상이 되는 전형적인 작업 공간."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
197
docs/영상-브리프.md
|
|
@ -1,197 +0,0 @@
|
||||||
# 경영진 보고용 3초 영상 브리프
|
|
||||||
|
|
||||||
> 대상: `docs/deck/` 경영진 보고 덱에 들어갈 짧은 클립.
|
|
||||||
> 이미 확보된 스틸 11장(`docs/images/`)은 그대로 씁니다 — 영상은 **스틸이 못 하는 것**만 발주합니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. 먼저 읽을 것
|
|
||||||
|
|
||||||
### 0-1. ⚠ 모든 클립은 "재현 영상"입니다
|
|
||||||
|
|
||||||
이 클립들은 실제 제품이 동작하는 화면 녹화가 아니라 **연출된 재현**입니다.
|
|
||||||
경영진 보고에서 이를 실제 동작 영상처럼 보여주면 그건 허위 시연입니다.
|
|
||||||
|
|
||||||
**덱 템플릿이 모든 클립에 `재현` 배지를 자동으로 붙이도록 만들었습니다.** 지워지지 않게
|
|
||||||
코드에 박아 뒀습니다 — 발표자가 깜빡할 수 있는 종류의 것이라서요.
|
|
||||||
|
|
||||||
실제 제품 화면이 필요한 자리에는 **실화면 캡처 8장**(`server/deploy/manual/shots/`)이 이미
|
|
||||||
있습니다. 그건 진짜입니다. 재현 클립과 실화면을 덱에서 시각적으로 구분해 두었습니다.
|
|
||||||
|
|
||||||
### 0-2. 스틸로 충분한 것은 발주하지 않았습니다
|
|
||||||
|
|
||||||
판정 조건·구성도·타임라인은 정지 이미지가 더 잘 전달합니다(읽을 시간이 필요하므로).
|
|
||||||
아래 6종은 **움직임이 있어야만 전달되는 것**만 골랐습니다.
|
|
||||||
|
|
||||||
### 0-3. 공통 사양
|
|
||||||
|
|
||||||
| 항목 | 값 |
|
|
||||||
|---|---|
|
|
||||||
| 길이 | **3초** (±0.3초). 받은 v01·v02 는 4.04초 — 쓸 수는 있으나 슬라이드당 3초 전제로 짠 흐름보다 깁니다 |
|
|
||||||
| 컷 | **단일 컷, 편집 없음.** 카메라 고정 — CCTV 시점이어야 설득력이 있습니다 |
|
|
||||||
| 화면비 | 16:9, **1920×1080 이상** |
|
|
||||||
| 형식 | **`.mp4` (H.264 + yuv420p)** — HEVC/H.265 로 뽑지 마십시오. 브라우저가 디코딩하지 못하면서 오류도 내지 않아, 회의실에서야 검은 화면을 보게 됩니다. (받은 v01·v02 가 HEVC 였고 `prepare_clips.py` 로 VP9/WebM 변환해 넣었습니다 — H.264 로 주시면 변환 없이 그대로 씁니다.) |
|
|
||||||
| 소리 | **없음.** 회의실에서 음소거로 재생합니다 |
|
|
||||||
| 재생 | 1회 재생 후 **마지막 프레임 정지**. 넘어지는 장면이 무한 반복되면 보기 불편합니다 |
|
|
||||||
| 얼굴 | **식별 불가**. 뒷모습·측면·헬멧·바닥을 향한 얼굴 |
|
|
||||||
| 조명 | 실제 공장 조도. 지나치게 영화적인 조명은 재현 티가 나서 오히려 신뢰를 깎습니다 |
|
|
||||||
| 색 | 스틸 11장과 같은 톤 — 네이비 작업복, 흰 헬멧, 회색 콘크리트 바닥, 노란 안전선 |
|
|
||||||
|
|
||||||
### 0-4. 카메라 시점을 고정하세요
|
|
||||||
|
|
||||||
**모든 클립이 같은 공장, 같은 높이(약 3~4m), 같은 앵글이면 가장 좋습니다.**
|
|
||||||
클립마다 장소가 바뀌면 "여러 곳에서 찍은 홍보 영상"처럼 보이고, 한 곳에서 이어지면
|
|
||||||
**"이 시스템이 이 공장을 보고 있다"** 로 읽힙니다. 후자가 훨씬 강합니다.
|
|
||||||
|
|
||||||
가능하면 `08-넘어짐-감지-시퀀스.png` 의 공장을 그대로 쓰세요.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## A. 발주 목록
|
|
||||||
|
|
||||||
### A-1. `v01-넘어짐-경광등.mp4` ★P0 — 이 덱에서 가장 중요한 3초
|
|
||||||
|
|
||||||
**이 클립이 말해야 하는 것**
|
|
||||||
|
|
||||||
> 사람이 쓰러지고, **경광등이 켜진다.** 그 사이에 아무도 없다.
|
|
||||||
|
|
||||||
**왜 필요한가** — 이 제품의 전부입니다. 감지가 대응으로 이어지는 순간을 정지 이미지로는
|
|
||||||
보여줄 수 없습니다. 경영진이 기억할 3초가 있다면 이것입니다.
|
|
||||||
|
|
||||||
**타임라인**
|
|
||||||
|
|
||||||
| 시각 | 화면 |
|
|
||||||
|---|---|
|
|
||||||
| 0.0s | 작업자가 기계 앞에 서 있음. 화면 한쪽 상단에 **꺼진 경광등**이 보임 |
|
|
||||||
| 0.5s | 무너지기 시작 |
|
|
||||||
| 1.2s | 바닥에 쓰러짐. 헬멧이 굴러감 |
|
|
||||||
| 2.0s | **경광등 점등** — 붉은 빛이 주변 바닥·기계에 반사 |
|
|
||||||
| 3.0s | 경광등 켜진 채 정지 |
|
|
||||||
|
|
||||||
**꼭** — **경광등이 처음부터 프레임 안에 있어야 합니다.** 꺼진 상태에서 켜지는 것이 보여야
|
|
||||||
하고, 컷을 나눠 경광등을 따로 보여주면 효과가 절반이 됩니다.
|
|
||||||
|
|
||||||
**피할 것** — 슬로모션, 극적인 음악용 편집, 카메라 흔들림(핸드헬드), 유혈.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### A-2. `v02-앉기-정상.mp4` ★P0 — 반드시 A-3과 한 쌍
|
|
||||||
|
|
||||||
**한 문장**
|
|
||||||
|
|
||||||
> 쭈그려 앉아 작업해도 **아무 일도 일어나지 않는다.**
|
|
||||||
|
|
||||||
**타임라인** — 작업자가 자연스럽게 쭈그려 앉아 바닥의 부품을 만짐. 3초 내내 경광등 꺼진 채.
|
|
||||||
|
|
||||||
**꼭** — **A-1과 완전히 같은 앵글·같은 장소.** 나란히 틀어서 "차이는 자세뿐"이 보여야 합니다.
|
|
||||||
경광등이 프레임 안에 있고 **끝까지 꺼져 있어야** 합니다.
|
|
||||||
|
|
||||||
**왜 필요한가** — "앉으면 오탐 나는 거 아니냐"가 도입 검토의 첫 질문입니다. 말로 답하는 것과
|
|
||||||
3초를 보여주는 것은 설득력이 다릅니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### A-3. `v03-허리굽힘-정상.mp4` ★P1
|
|
||||||
|
|
||||||
**한 문장**
|
|
||||||
|
|
||||||
> 허리를 굽혀 물건을 들어도 울리지 않는다.
|
|
||||||
|
|
||||||
A-2와 같은 목적, 다른 자세. 여유가 없으면 A-2만으로도 성립합니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### A-4. `v04-구역진입.mp4` ★P1
|
|
||||||
|
|
||||||
**한 문장**
|
|
||||||
|
|
||||||
> **발이 선을 넘는 순간** 판정된다.
|
|
||||||
|
|
||||||
**타임라인**
|
|
||||||
|
|
||||||
| 시각 | 화면 |
|
|
||||||
|---|---|
|
|
||||||
| 0.0s | 바닥에 노란 안전선. 작업자가 선 밖에서 걸어옴 |
|
|
||||||
| 1.5s | **발이 선을 넘음** |
|
|
||||||
| 2.5s | 경광등 점등 (또는 주의색) |
|
|
||||||
| 3.0s | 정지 |
|
|
||||||
|
|
||||||
**꼭** — **발과 선이 함께 프레임 안에** 있어야 합니다. 상반신만 잡으면 이 클립의 의미가 사라집니다.
|
|
||||||
카메라를 조금 낮추거나 각도를 눕혀 바닥이 넓게 보이게.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### A-5. `v05-카메라-건드림.mp4` ★P1 — 차별점
|
|
||||||
|
|
||||||
**한 문장**
|
|
||||||
|
|
||||||
> 카메라를 건드리면 **시스템이 그 사실을 스스로 신고한다.**
|
|
||||||
|
|
||||||
**타임라인**
|
|
||||||
|
|
||||||
| 시각 | 화면 |
|
|
||||||
|---|---|
|
|
||||||
| 0.0s | 정상적인 공장 화면 |
|
|
||||||
| 1.0s | 화면 전체가 **한 번 흔들리며 각도가 틀어짐** (누군가 청소하다 스친 것처럼) |
|
|
||||||
| 1.5s | 틀어진 채로 안정 — **화면은 여전히 멀쩡해 보임** |
|
|
||||||
| 3.0s | 정지 |
|
|
||||||
|
|
||||||
**꼭** — 카메라 **자체 시점**입니다(1인칭). 삼각대를 밖에서 찍는 게 아니라, 화면이 흔들려야 합니다.
|
|
||||||
틀어진 뒤에도 영상이 정상적으로 나오는 것이 핵심 — "고장난 게 아니라서 아무도 모른다"가
|
|
||||||
이 제품이 잡는 조용한 실패입니다.
|
|
||||||
|
|
||||||
**피할 것** — 화면이 까매지거나 노이즈가 끼면 안 됩니다. 그건 누구나 아는 고장입니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### A-6. `v06-2인1조.mp4` ★P2
|
|
||||||
|
|
||||||
**한 문장**
|
|
||||||
|
|
||||||
> 한 명이 나가면 **혼자 남은 것**이 판정된다.
|
|
||||||
|
|
||||||
**타임라인** — 0.0s 두 사람이 함께 작업 → 1.5s 한 명이 프레임 밖으로 걸어 나감 →
|
|
||||||
3.0s 남은 한 명 혼자. (실제로는 30초 지속 후 이벤트이므로 클립에 경광등은 켜지 **않습니다.**)
|
|
||||||
|
|
||||||
**꼭** — 경광등을 켜지 마세요. 30초 규칙이라 즉시 울리지 않습니다. 덱의 자막이 설명합니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## B. 우선순위
|
|
||||||
|
|
||||||
| | 파일 | 없으면 안 되는가 |
|
|
||||||
|---|---|---|
|
|
||||||
| **P0** | `v01-넘어짐-경광등.mp4` | **덱의 핵심.** 이것만 있어도 보고가 됩니다 |
|
|
||||||
| **P0** | `v02-앉기-정상.mp4` | v01과 쌍. 단독으로는 의미 없음 |
|
|
||||||
| P1 | `v04-구역진입.mp4` | 구역 기능 설명이 크게 살아납니다 |
|
|
||||||
| P1 | `v05-카메라-건드림.mp4` | 차별점 슬라이드용 |
|
|
||||||
| P1 | `v03-허리굽힘-정상.mp4` | v02의 보강 |
|
|
||||||
| P2 | `v06-2인1조.mp4` | 있으면 좋음 |
|
|
||||||
|
|
||||||
**P0 2개만 있어도 덱은 성립합니다.** 나머지 자리는 스틸 이미지로 자동 대체됩니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## C. 전달 방법
|
|
||||||
|
|
||||||
`image/` 또는 `docs/deck/clips/` 에 위 파일명 그대로 넣어주십시오.
|
|
||||||
|
|
||||||
```
|
|
||||||
python docs/deck/prepare_clips.py # 브라우저용 변환 + 포스터 추출
|
|
||||||
python docs/deck/build_deck.py # 덱 재생성
|
|
||||||
```
|
|
||||||
|
|
||||||
생성기가 **파일이 있으면 영상을, 없으면 해당 스틸 이미지를** 자동으로 넣습니다.
|
|
||||||
지금 P0 2개가 들어가 있고, 나머지는 하나씩 추가해도 위 두 줄만 다시 돌리면 반영됩니다.
|
|
||||||
포스터는 클립의 마지막 프레임에서 자동으로 뽑으므로 따로 주실 필요 없습니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## D. 넣지 않기로 한 것
|
|
||||||
|
|
||||||
| 제외 | 이유 |
|
|
||||||
|---|---|
|
|
||||||
| 대시보드 화면 녹화 | **실화면 캡처 8장이 이미 있고 그건 진짜입니다.** 재현 영상으로 대체하면 진짜를 가짜로 바꾸는 셈 |
|
|
||||||
| 골격(스켈레톤) 오버레이 애니메이션 | 작업자를 추적한다는 인상을 키움 — 스틸 브리프와 같은 이유 |
|
|
||||||
| 드론·타임랩스·기업 홍보 톤 | 이건 안전 설비 보고이지 브랜드 필름이 아닙니다. 화려할수록 "실제로 되는 건가"라는 의심을 삽니다 |
|
|
||||||
| 3초를 넘는 서사형 영상 | 경영진 보고에서 한 장면에 쓸 수 있는 시간은 3초입니다 |
|
|
||||||
|
|
@ -1,213 +0,0 @@
|
||||||
# 영상 보관 정책과 노조 대응 논리
|
|
||||||
|
|
||||||
> 작성 2026-08-24. **법률 자문이 아닙니다** — §5의 법령 해석은 실제 협의 전에
|
|
||||||
> 노무사·변호사 확인을 받으십시오. 이 문서는 제품이 무엇을 하는지(§1)와
|
|
||||||
> 그로부터 나오는 논리(§2~§4)를 정리한 것입니다.
|
|
||||||
|
|
||||||
## 1. 먼저, 이 제품은 영상을 녹화하지 않는다
|
|
||||||
|
|
||||||
질문의 전제("녹화된 영상을 어떻게 할까")가 이 제품에는 적용되지 않습니다.
|
|
||||||
코드 기준 실제 동작은 다음과 같습니다.
|
|
||||||
|
|
||||||
| 무엇 | 어디에 | 얼마나 | 근거 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 프레임 링버퍼 | **엣지 노드 RAM에만** | **90프레임 = 7.5초**(기본 12fps). 계속 덮어씀. **디스크에 쓰지 않음** | `edge/snapshot.py` `RingBuffer(maxlen=90)`, `TARGET_FPS=12` |
|
|
||||||
| 증거 스냅샷 | 서버 | **이벤트가 발생했을 때만 3장** — 이벤트 **2.5초 전·1초 전·그 순간**, 얼굴 블러 후 JPEG | `edge/app.py` `SNAPSHOT_OFFSETS_S = (2.5, 1.0, 0.0)` |
|
|
||||||
| 스냅샷 보관 | 서버 | **기본 7일 후 자동 삭제**. 예외는 아래 '보존 보류' 한 가지 | `Config::snapshotRetentionDays() = 7` |
|
|
||||||
| 스냅샷 **열람** | 서버 | **누가 언제 어느 사진을 열었는지 감사기록에 남음** | `SnapshotProxy::recordView()`, 감사 동작 `snapshot_view` |
|
|
||||||
| 기록 **반출** | 서버 | 대량 내보내기도 건수와 함께 기록됨 | `ExportController`, 감사 동작 `label_export` |
|
|
||||||
| 이벤트 기록(텍스트) | 서버 | 기본 영구 — **개인 식별 정보 없음** | `Config::eventRetentionDays() = 0` |
|
|
||||||
|
|
||||||
> **"7.5초를 보관하는 것 아니냐"** — 그 7.5초는 열람할 수 있는 화면도 API도 없고, 12분의 1초마다
|
|
||||||
> 덮어씁니다. 이벤트가 나야 3장이 뽑히고, 나지 않으면 사라집니다. 버퍼가 존재하는 이유는
|
|
||||||
> 보관이 아니라 **판정 지연의 보상**입니다: 넘어짐 확정에 3초가 걸리므로, 확정 시점에서
|
|
||||||
> 2.5초 전을 되짚으려면 그만큼은 손에 들고 있어야 합니다. 즉 **사고 직전 2.5초가 남는 전부**입니다.
|
|
||||||
|
|
||||||
**얼굴 블러는 엣지에서, 업로드 전에 겁니다.** 블러 안 된 이미지는 네트워크로 나가지
|
|
||||||
않고, 서버에 존재한 적이 없습니다. 과거 프레임은 *그 프레임 자신의* 머리 위치로
|
|
||||||
블러합니다(현재 위치로 블러하면 과거 얼굴이 그대로 남으므로).
|
|
||||||
|
|
||||||
또한 **라이브뷰가 없습니다.** 지금 누가 무엇을 하고 있는지 실시간으로 볼 수 있는
|
|
||||||
화면 자체가 제품에 없습니다.
|
|
||||||
|
|
||||||
### 그래서 질문에 대한 직접적인 답
|
|
||||||
|
|
||||||
> "감지가 없다면 폐기하는 쪽이 맞을까?"
|
|
||||||
|
|
||||||
**이미 그보다 강한 것을 하고 있습니다.** 감지가 없으면 폐기하는 게 아니라,
|
|
||||||
**애초에 저장하지 않습니다.** 폐기 정책은 "저장은 했다"를 전제하지만, 여기서는
|
|
||||||
데이터가 생성된 적이 없습니다. 노조 앞에서 이 차이는 매우 큽니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 두 선택지 비교 — (b)는 최악의 선택이다
|
|
||||||
|
|
||||||
| | (a) 감지 없으면 폐기 | (b) 용량껏 보관하되 아무도 못 봄 |
|
|
||||||
|---|---|---|
|
|
||||||
| 성격 | **구조적 보장** — 데이터가 없다 | **행동에 대한 약속** — 안 보겠다 |
|
|
||||||
| 검증 가능성 | 저장소를 열어 보이면 끝 | 검증 불가. "안 봤다"는 증명할 수 없다 |
|
|
||||||
| 뒤집히는 조건 | 제품을 다시 만들어야 함 | **관리자 설정 하나** |
|
|
||||||
| 유출 시 | 유출될 것이 없음 | 피해 그대로 |
|
|
||||||
| 수사기관 영장 | 내줄 것이 없음 | 전량 제출 |
|
|
||||||
|
|
||||||
**노조가 공격하는 지점이 정확히 (b)입니다.** 노조의 불신은 "지금 사측이 볼 것이다"가
|
|
||||||
아니라 **"언젠가, 다른 경영진이, 다른 목적으로 볼 수 있다"** 입니다. (b)는 그 불신에
|
|
||||||
대해 "우리를 믿어달라"고 답하는 것이고, 그건 협상에서 가장 약한 카드입니다.
|
|
||||||
|
|
||||||
> **원칙**: 존재하지 않는 데이터는 열람될 수 없고, 유출될 수 없고, 목적 외로 쓰일 수 없다.
|
|
||||||
> 접근 통제는 되돌릴 수 있지만 미수집은 되돌릴 수 없다.
|
|
||||||
|
|
||||||
**권고 — (a). 그리고 이미 (a)를 넘어선 상태이므로, 바꾸지 말고 그대로 설명하십시오.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. "하루치 보관해서 화재·붕괴 조짐을 보자"는 권하지 않습니다
|
|
||||||
|
|
||||||
취지는 이해합니다. 사고 원인 분석에 이벤트 전후만이 아니라 더 긴 맥락이 필요할 수
|
|
||||||
있습니다. 그러나 이 기능을 넣는 순간 잃는 것이 얻는 것보다 큽니다.
|
|
||||||
|
|
||||||
1. **노조 방어 논리 전체를 잃습니다.** §2의 표에서 (a)가 (b)로 바뀝니다.
|
|
||||||
"우리는 녹화하지 않습니다"는 한 문장이 사라지고, 그 뒤로는 기간·열람권 협상만 남습니다.
|
|
||||||
2. **법적 성격이 바뀝니다.** 상시 녹화는 고정형 영상정보처리기기 규율(§5)에 정면으로
|
|
||||||
들어가고, 근참법상 '근로자 감시 설비' 성격이 뚜렷해집니다.
|
|
||||||
3. **실익이 생각보다 작습니다.** 붕괴·화재의 *조짐* 은 영상으로 사후에 판독하기 어렵고,
|
|
||||||
그건 애초에 이 제품의 감지 대상이 아닙니다(자세·구역 기반).
|
|
||||||
4. **대부분의 현장에는 이미 일반 CCTV가 있습니다.** 상시 녹화는 그쪽의 몫입니다.
|
|
||||||
이 제품이 녹화를 시작하면 **기존 CCTV의 열등한 복제품**이 되면서 고유 가치를 잃습니다.
|
|
||||||
포지션은 "기존 CCTV를 대체하는 물건"이 아니라 **"기존 CCTV가 못 하는 즉시 대응을 붙이는 물건"** 입니다.
|
|
||||||
5. **하드웨어가 감당하기 어렵습니다.** 노드당 다중 카메라 24시간치를 로컬에 쓰면
|
|
||||||
SD/SSD 수명과 용량이 모두 문제가 되고, 폐쇄망에서는 그 관리 부담이 현장에 남습니다.
|
|
||||||
|
|
||||||
**사고 원인 분석은 이렇게 답하십시오** — 이 제품이 남기는 것은 영상이 아니라
|
|
||||||
**타임라인**입니다. 이벤트 시각·유형·위치, 전후 3프레임, 통보 이력, 조치 이력,
|
|
||||||
위·변조 탐지 감사기록. 원인 분석에 실제로 필요한 것은 대부분 이쪽이고,
|
|
||||||
영상이 꼭 필요하면 **현장의 기존 CCTV에서 해당 시각을 찾아보면 됩니다** —
|
|
||||||
이 제품이 그 "몇 시 몇 분"을 정확히 짚어 줍니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 진짜 쟁점은 기간이 아니라 접근 구조다
|
|
||||||
|
|
||||||
노조 대응에서 가장 흔한 실수가 **보관기간 숫자로 협상하는 것**입니다.
|
|
||||||
|
|
||||||
> 90일 보관 + 얼굴 블러 + 모든 열람 기록 + 개인 식별 불가
|
|
||||||
> **<<** 24시간 보관 + 관리자 무제한 열람 + 기록 없음
|
|
||||||
|
|
||||||
**후자가 훨씬 더 감시적입니다.** 기간을 줄이는 것은 노조에게 실질적 안전장치를
|
|
||||||
주지 못하면서 사고 조사 능력만 깎습니다. 협상 카드로 내밀 것은 기간이 아니라 아래입니다.
|
|
||||||
|
|
||||||
### 이 제품이 이미 가진 것 (그대로 설명하면 되는 것)
|
|
||||||
|
|
||||||
- **라이브뷰 없음** — 실시간 감시 화면이 존재하지 않음
|
|
||||||
- **영상 녹화·전송 없음** — 이벤트 스냅샷 3장이 전부
|
|
||||||
- **얼굴 블러** — 엣지에서, 업로드 전에
|
|
||||||
- **개인 식별자 없음** — 사람을 임시 추적번호로만 다루고, **누구인지 모릅니다.**
|
|
||||||
사번·이름·얼굴 인식 어느 것도 없습니다
|
|
||||||
- **근무태도 관련 이벤트 0종** — 감지 20종 전부가 *위험 상태* 이고,
|
|
||||||
자리 비움·작업 속도·휴게 시간 같은 항목은 **설계상 존재하지 않습니다**
|
|
||||||
- **위·변조 탐지 감사기록** — 설정 변경·확인·오탐 처리가 해시 체인으로 남아
|
|
||||||
지우거나 고치면 검증에서 드러납니다
|
|
||||||
- **스냅샷 7일 자동 삭제**, 이후에는 통계와 텍스트 기록만 남음
|
|
||||||
- **열람도 기록됩니다** — 누가 언제 어느 사진을 열었는지가 같은 해시 체인에 남습니다.
|
|
||||||
쓰기만 남기고 읽기는 안 남기면 "안 봤다"는 주장에 그치는데, 이제 기록으로 답할 수 있습니다
|
|
||||||
- **오래 머물렀다는 이유로는 사진이 남지 않습니다** — 체류 상한 초과는 시간 경과만으로
|
|
||||||
발화하므로 증거 프레임을 붙이지 않습니다. 사진이 남는 것은 사람이 쓰러졌거나 위험구역에
|
|
||||||
들어갔거나 2인1조가 깨졌을 때입니다 (`edge/app.py` `NO_EVIDENCE_TYPES`)
|
|
||||||
|
|
||||||
### 남은 것 — 운영으로 해야 하는 두 가지
|
|
||||||
|
|
||||||
열람 감사기록은 **구현되었습니다**(2026-09-25). 이 문서가 "가장 강한 카드가 비어 있다"고
|
|
||||||
지적했던 항목이고, 이제 누가 언제 어느 사진을 열었는지가 설정 변경과 같은 해시 체인에
|
|
||||||
남습니다. 대량 반출도 건수와 함께 기록됩니다.
|
|
||||||
|
|
||||||
코드로 닫을 수 없는 두 가지가 남습니다 — 둘 다 사업주의 결정입니다.
|
|
||||||
|
|
||||||
1. **노조 대표에게 열람 계정(viewer)과 감사기록 조회 권한을 준다** —
|
|
||||||
감시자를 감시할 수 있게 하는 것이 불신을 푸는 가장 빠른 길입니다.
|
|
||||||
기록이 생겼으므로 이제 이 권한이 실제로 의미를 가집니다.
|
|
||||||
2. **보관기간을 화면에 표시한다** — 설정 파일 안이 아니라 눈에 보이는 곳에.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 법적 근거 (참고 — 확인 필요)
|
|
||||||
|
|
||||||
### 5-1. 보관기간
|
|
||||||
|
|
||||||
**표준 개인정보 보호지침 제41조** — 개인영상정보는 보유 목적 달성을 위한 최소한의
|
|
||||||
기간으로 보유기간을 산정하되, **그 기간을 산정하기 곤란하면 수집 후 30일 이내**로
|
|
||||||
합니다. 30일이 상한이 아니라 *기간을 정하기 어려울 때의 기본값* 이라는 점이 중요합니다 —
|
|
||||||
목적이 명확하고 사유를 밝히면 다르게 정할 수 있습니다.
|
|
||||||
|
|
||||||
이 제품의 스냅샷 기본 **7일**은 그 기본값보다 짧고, "이벤트 확인·초기 대응"이라는
|
|
||||||
목적에 비추어 설명 가능한 값입니다. **7일을 늘릴 이유가 없습니다.**
|
|
||||||
|
|
||||||
### 5-2. 사고 기록 보존 의무 — 이미지와 분리해서 보십시오
|
|
||||||
|
|
||||||
**산업안전보건법 제164조**는 산업재해 발생 원인 등의 기록을 **3년** 보존하도록 합니다.
|
|
||||||
여기서 요구되는 것은 **기록**이지 영상이 아닙니다.
|
|
||||||
|
|
||||||
그래서 이 제품의 설계가 두 요구를 동시에 만족합니다.
|
|
||||||
|
|
||||||
- **개인 식별 이미지** → 7일 후 삭제 (개인정보 최소보유)
|
|
||||||
- **이벤트 기록·조치 이력·감사기록** → 장기 보존 (사고 조사 대응)
|
|
||||||
|
|
||||||
노조에게는 "사진은 일주일이면 사라지고, 남는 것은 몇 시에 무슨 일이 있었다는
|
|
||||||
기록뿐"이라고 설명할 수 있고, 사업주에게는 보존 의무를 충족한다고 설명할 수 있습니다.
|
|
||||||
|
|
||||||
#### 예외 하나 — 사고로 확정된 건의 증거 보존 (2026-09-25 추가)
|
|
||||||
|
|
||||||
중대재해 조사는 몇 주에서 몇 달입니다. 7일 규칙만 있으면 **조사가 시작되기도 전에 사진이
|
|
||||||
사라져**, 이 시스템을 도입한 목적("사고 후 원인 분석") 자체가 성립하지 않습니다.
|
|
||||||
|
|
||||||
그래서 **명시적 보존 보류**를 둡니다. 이것이 7일 원칙의 훼손이 아닌 이유:
|
|
||||||
|
|
||||||
| | 내용 |
|
|
||||||
|---|---|
|
|
||||||
| 걸 수 있는 사람 | **admin 만** (`manage_compliance`). 현장 운영자는 못 겁니다 |
|
|
||||||
| 대상 | 보류를 건 **그 사건 한 건**. 전체 보존으로 바뀌지 않습니다 |
|
|
||||||
| 기간 | 화면 기본 90일, **최대 1년**. 무기한 보류는 아예 불가능합니다 |
|
|
||||||
| 기록 | 누가 언제 얼마나 걸었는지 · 해제했는지가 감사 체인에 남습니다 |
|
|
||||||
| 만료 후 | 자동으로 다시 삭제 대상이 됩니다. 사람이 해제하지 않아도 됩니다 |
|
|
||||||
|
|
||||||
노조에는 이렇게 설명할 수 있습니다 — **"전부 지웁니다. 예외는 사고로 확정돼 조사가 걸린
|
|
||||||
건뿐이고, 그것도 최대 1년이며, 누가 걸었는지가 기록에 남습니다."** 확인 처리만으로 자동
|
|
||||||
연장되지 않는다는 점이 중요합니다: 자동이면 사실상 전량 보존이 되어 7일 약속이 무너집니다.
|
|
||||||
|
|
||||||
### 5-3. 노조 협의는 이기고 지는 문제가 아니라 절차 문제입니다
|
|
||||||
|
|
||||||
**근로자참여 및 협력증진에 관한 법률 제20조**는 **"사업장 내 근로자 감시 설비의 설치"**
|
|
||||||
를 노사협의회 **협의 사항**으로 정하고 있습니다. 설치의 주된 목적이 근로자 감시가
|
|
||||||
아니더라도, 실질적으로 감시 효과를 갖는 설비면 해당할 수 있다고 해석됩니다.
|
|
||||||
|
|
||||||
**따라서 "이건 감시 설비가 아니다"라고 다투는 것은 좋은 전략이 아닙니다.**
|
|
||||||
협의 대상이 아니라고 우기다가 절차 하자가 생기면, 제품의 기술적 장점과 무관하게
|
|
||||||
도입 자체가 무효화될 수 있습니다. 권고하는 순서는 다음과 같습니다.
|
|
||||||
|
|
||||||
1. **협의 대상임을 인정하고 먼저 노사협의회에 올린다.**
|
|
||||||
2. §4의 목록을 그대로 설명한다 — 특히 **근무태도 이벤트가 0종**이라는 점.
|
|
||||||
3. **목적 외 사용 금지를 문서로 합의한다** — 국가인권위원회는 안전·보안 목적으로
|
|
||||||
설치한 CCTV를 근무 감독에 활용한 사례를 인권침해로 판단한 바 있습니다(2017).
|
|
||||||
이 제품은 애초에 근무태도 데이터를 만들지 않으므로 **지키기 쉬운 약속**입니다.
|
|
||||||
4. **노조에 감사기록 조회권을 준다**(§4 권고 2). 합의를 검증 가능하게 만듭니다.
|
|
||||||
5. 합의 내용을 회의록으로 남긴다.
|
|
||||||
|
|
||||||
### 참고 자료
|
|
||||||
|
|
||||||
- [표준 개인정보 보호지침 — 국가법령정보센터](https://www.law.go.kr/admRulLsInfoP.do?admRulSeq=2100000234592)
|
|
||||||
- [표준 개인정보 보호지침 제41조 해설 — IT위키](https://itwiki.kr/w/%ED%91%9C%EC%A4%80_%EA%B0%9C%EC%9D%B8%EC%A0%95%EB%B3%B4_%EB%B3%B4%ED%98%B8%EC%A7%80%EC%B9%A8_%EC%A0%9C41%EC%A1%B0)
|
|
||||||
- [근로자참여 및 협력증진에 관한 법률 제20조 — CaseNote](https://casenote.kr/%EB%B2%95%EB%A0%B9/%EA%B7%BC%EB%A1%9C%EC%9E%90%EC%B0%B8%EC%97%AC_%EB%B0%8F_%ED%98%91%EB%A0%A5%EC%A6%9D%EC%A7%84%EC%97%90_%EA%B4%80%ED%95%9C_%EB%B2%95%EB%A5%A0/%EC%A0%9C20%EC%A1%B0)
|
|
||||||
- [산업안전보건법 제164조 — CaseNote](https://casenote.kr/%EB%B2%95%EB%A0%B9/%EC%82%B0%EC%97%85%EC%95%88%EC%A0%84%EB%B3%B4%EA%B1%B4%EB%B2%95/%EC%A0%9C164%EC%A1%B0)
|
|
||||||
- ["CCTV 목적 외 근무태도 감독 활용은 인권침해" — 국가인권위원회 보도자료](https://www.humanrights.go.kr/site/program/board/basicboard/view?boardtypeid=24&boardid=7601831&menuid=001004002001)
|
|
||||||
- [사업장 내 CCTV 등 감시 설비 설치에 따른 법적 문제 — 노동연구원 웹진](https://www.erc.re.kr/webzine/vol50/15.jsp)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 한 문단 요약 (노조 설명용)
|
|
||||||
|
|
||||||
> 이 시스템은 영상을 녹화하지 않습니다. 실시간으로 들여다보는 화면도 없습니다.
|
|
||||||
> 넘어짐 같은 위험 상황이 감지된 그 순간에만 사진 3장이 남고, 그 사진은 카메라 쪽에서
|
|
||||||
> 얼굴을 가린 뒤에 전송되며, 7일이 지나면 자동으로 지워집니다. 시스템은 그 사람이
|
|
||||||
> 누구인지 모릅니다 — 이름도 사번도 얼굴 인식도 없습니다. 감지하는 17가지는 전부
|
|
||||||
> 위험한 상태에 관한 것이고, 자리를 비웠는지 일을 얼마나 했는지는 **만들 수 있는
|
|
||||||
> 기능 자체가 없습니다.** 설정을 바꾸거나 기록을 열어 본 사람은 지울 수 없는 형태로
|
|
||||||
> 남고, 그 기록은 노조도 확인할 수 있게 하겠습니다.
|
|
||||||
345
docs/프로젝트-소개서.md
|
|
@ -1,345 +0,0 @@
|
||||||
# move_detect — 작업장 안전 AI CCTV
|
|
||||||
|
|
||||||
> **쓰러진 작업자를 3초면 알아채고, 즉시 현장에 울리고, 5분 안에 확인되지 않으면 위로 올립니다.**
|
|
||||||
>
|
|
||||||
> 실내 공장·창고의 안전사고를 엣지 AI가 실시간으로 감지하고, 감지에서 끝내지 않고 **현장 경광등과
|
|
||||||
> 담당자 통보까지 이어지도록** 만든 온프레미스 안전관리 시스템입니다. 영상은 외부로 나가지
|
|
||||||
> 않습니다 — 사고 순간의 스냅샷 3장(얼굴 자동 블러)만 전달합니다.
|
|
||||||
|
|
||||||
버전 1.3.0 · 2026-09-25
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 왜 만들었나
|
|
||||||
|
|
||||||
### 1-1. CCTV는 이미 있는데, 왜 사고를 못 막나
|
|
||||||
|
|
||||||
대부분의 작업장에는 이미 CCTV가 달려 있습니다. 그런데도 사고가 발생하면 다음 세 가지가
|
|
||||||
반복됩니다.
|
|
||||||
|
|
||||||
**첫째, 아무도 보고 있지 않습니다.** 카메라 수십 대를 실시간으로 지켜보는 인력은 어디에도
|
|
||||||
없습니다. CCTV는 사실상 **사후 확인용 녹화기**로만 쓰이고 있고, 그것은 사고를 막는 장치가
|
|
||||||
아니라 사고를 설명하는 장치입니다.
|
|
||||||
|
|
||||||
**둘째, 발견이 늦을수록 피해가 커집니다.** 넘어짐 자체보다 위험한 것은 넘어진 뒤 방치되는
|
|
||||||
시간입니다. 의식을 잃은 작업자에게 그 시간은 곧 후유장해와 사망 위험의 크기입니다.
|
|
||||||
야간·주말·단독 작업 시간대에는 이 공백이 몇 시간 단위로 벌어집니다.
|
|
||||||
|
|
||||||
**셋째, 사고 후에 증명할 자료가 없습니다.** 사고가 나면 사업주는 "무엇을 위험으로 보고
|
|
||||||
있었고, 어떤 통제를 걸어두었으며, 사건 당시 그것이 작동했는가"를 설명해야 합니다.
|
|
||||||
녹화 영상만으로는 그 질문에 답할 수 없습니다.
|
|
||||||
|
|
||||||
### 1-2. 이 세 가지를 각각 겨냥했습니다
|
|
||||||
|
|
||||||
| 문제 | move_detect의 답 |
|
|
||||||
|------|------------------|
|
|
||||||
| 아무도 안 보고 있다 | AI가 24시간 대신 봅니다. 사람은 **감지된 사건만** 봅니다 |
|
|
||||||
| 발견이 늦다 | 감지 즉시 **현장 경광등**이 울리고, 확인이 없으면 **상위 담당자로 자동 확대**됩니다 |
|
|
||||||
| 증명할 자료가 없다 | 사건마다 증거·통보·조치·위험요인이 묶인 **사고조사 패키지**가 한 번에 나옵니다 |
|
|
||||||
|
|
||||||
### 1-3. 설계를 결정한 세 가지 제약
|
|
||||||
|
|
||||||
이 제품의 형태는 다음 세 제약에서 나왔습니다. 기능보다 이 제약이 먼저입니다.
|
|
||||||
|
|
||||||
- **작업자를 감시하지 않는다.** 라이브뷰가 없습니다. 영상은 저장도 전송도 하지 않습니다.
|
|
||||||
사고 순간의 스냅샷만, 그것도 얼굴을 블러 처리한 뒤에 보냅니다. 근로자 동의를 받기 어려운
|
|
||||||
구조로는 현장에 들어갈 수 없습니다.
|
|
||||||
- **인터넷이 끊겨도 동작한다.** 폐쇄망 현장을 전제로 만들었습니다. 정전·회선 장애는 위험이
|
|
||||||
오히려 높아지는 시간대인데, 그때 시스템이 함께 죽으면 의미가 없습니다.
|
|
||||||
- **클라우드 GPU를 쓰지 않는다.** 추론은 현장 장비에서 끝납니다. 월 사용료가 없고, 영상이
|
|
||||||
외부로 나갈 이유 자체가 없어집니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 무엇을 하는가
|
|
||||||
|
|
||||||
기능을 나열하기 전에 구조를 먼저 봅니다. 이 제품은 **하나의 사슬**입니다.
|
|
||||||
|
|
||||||
```
|
|
||||||
감지 대응 증거
|
|
||||||
───────── ───────────────── ────────────────────
|
|
||||||
20종 이벤트 → 현장 경광등 (즉시) → 사고조사 패키지
|
|
||||||
엣지 AI 판정 담당자 통보 증거 3프레임
|
|
||||||
얼굴 블러 미확인 시 상위 확대 통보 이력 · 조치 이력
|
|
||||||
위·변조 탐지 감사기록
|
|
||||||
```
|
|
||||||
|
|
||||||
사슬의 어느 한 칸이 비면 나머지가 무의미해집니다. 감지만 있으면 아무도 모르고,
|
|
||||||
대응만 있으면 나중에 설명할 수 없습니다.
|
|
||||||
|
|
||||||
### 2-1. 감지 — 무엇을 알아채는가
|
|
||||||
|
|
||||||
엣지가 **16종**을 판정하고, 여기에 서버가 발행하는 4종(장치 이상 · 감시 성능 저하 · 미확인 경보 확대 · 동시 다발 쓰러짐)이 더해져 **모두 20종**입니다.
|
|
||||||
사람에 대한 것, 구역 규칙에 대한 것, 그리고 **감시 장비 자신**에 대한 것으로 나뉩니다.
|
|
||||||
|
|
||||||
**사람의 위험 상태**
|
|
||||||
|
|
||||||
| 이벤트 | 무엇을 잡는가 |
|
|
||||||
|--------|---------------|
|
|
||||||
| 넘어짐 | 기립→쓰러짐 전이를 자세로 판정. 쓰러진 자세가 **3초**(조정 가능) 지속되면 확정 — 앉는 동작과 구분 |
|
|
||||||
| **추락 (떨어짐)** | 발 밑선이 평소 키의 1.2배 이상 급강하하면 추락으로 판정 — 같은 높이의 넘어짐과 **다른 사건**으로 분류합니다. 산업재해 사망 1위 유형이고, 척추 손상 우려가 있어 통보에 이송 주의가 함께 나갑니다 |
|
|
||||||
| **동시 다발 쓰러짐** | 같은 구역에서 짧은 시간에 2명 이상이 쓰러지면 질식·가스·폭발로 봅니다. 통보에 **"공기호흡기 없이 진입하지 마십시오"** 가 실립니다 — 밀폐공간 사망자의 상당수가 구조하러 들어간 사람입니다 |
|
|
||||||
| **넘어짐 후 미동 없음** | 쓰러진 뒤 움직임이 없으면 **위급으로 승격** — 의식 소실 의심 |
|
|
||||||
| 장시간 부동 | 자세와 무관하게 오래 움직이지 않는 작업자 |
|
|
||||||
| 위험 상태 소실 | 위험한 상태였던 사람이 화면에서 사라짐 (가림·추락 등) |
|
|
||||||
| 넘어질 뻔함 (니어미스) | 사고 직전까지 갔다가 회복 — **사고 전 신호** |
|
|
||||||
| 작업자세 부담 | 허리·팔 각도 노출 시간 집계 (익명 통계) |
|
|
||||||
|
|
||||||
**구역 규칙 위반** — 관리자가 카메라 화면 위에 그린 구역에 대해 판정합니다.
|
|
||||||
|
|
||||||
| 이벤트 | 무엇을 잡는가 |
|
|
||||||
|--------|---------------|
|
|
||||||
| 경고구역 진입 / 제한구역 침입 / 협착구역 침입 | 등급별 구역 진입 |
|
|
||||||
| 단독 작업 | 2인 1조 구역에 혼자 있는 상태가 지속 |
|
|
||||||
| 정원 초과 / 체류 상한 초과 | 구역별 인원·체류시간 규칙 위반 |
|
|
||||||
|
|
||||||
**감시 자신의 건전성** — 이 항목들이 이 제품의 차별점입니다.
|
|
||||||
|
|
||||||
| 이벤트 | 왜 필요한가 |
|
|
||||||
|--------|-------------|
|
|
||||||
| **카메라 화각 변경** | 청소하다 삼각대를 스치면 위험구역이 엉뚱한 바닥을 가리킵니다. 그런데 프레임은 계속 들어오고 화면은 정상으로 보입니다 — 이 **조용한 실패**를 감지합니다 |
|
|
||||||
| 카메라 가림 / 조도 저하 | 렌즈가 덮이거나 조명이 죽어 검출력이 떨어진 상태 |
|
|
||||||
| **장치 이상** | 노드가 응답을 멈춘 상태. **서버가 하트비트의 부재로 판정합니다** — 전원이나 회선이 끊긴 노드는 자기가 죽었다고 신고할 수 없기 때문입니다 |
|
|
||||||
| **감시 성능 저하** | 노드는 살아 있으나 프레임률이 떨어지거나, 카메라 하나가 오류이거나, 발열로 성능이 깎인 상태. 한 번 튄 값이 아니라 **지속될 때만** 사건이 됩니다 |
|
|
||||||
| **미확인 경보 확대** | 위급 사건을 정해진 시간 동안 아무도 확인하지 않았다는 **사실 자체를 사건으로** 만듭니다 |
|
|
||||||
|
|
||||||
> 보호구 미착용 · 중장비 근접 · 화재/연기 3종은 감지기 배선과 이벤트 규격이 준비되어 있으나
|
|
||||||
> **추론 모델이 확보되지 않아 비활성** 상태입니다. 자세한 내용은 9장을 보십시오.
|
|
||||||
|
|
||||||
### 2-2. 대응 — 어떻게 사람에게 닿는가
|
|
||||||
|
|
||||||
감지가 화면에만 뜨면 그것은 기록 장치입니다. 이 제품은 세 갈래로 사람에게 닿습니다.
|
|
||||||
|
|
||||||
1. **현장 경광등·부저 (즉시, 네트워크 불필요)**
|
|
||||||
감지한 엣지 노드가 자기 GPIO로 직접 울립니다. 서버도 인터넷도 거치지 않으므로 **회선이
|
|
||||||
끊겨도, 서버가 죽어도 동작합니다.** 어떤 현장이든 이것부터 설정하도록 안내합니다.
|
|
||||||
|
|
||||||
2. **담당자 통보 (메일 · 사내 웹훅/문자 게이트웨이)**
|
|
||||||
심각도 기준을 채널마다 정할 수 있습니다. 통보에는 **현장 사진이 들어가지 않습니다** —
|
|
||||||
링크만 보내고, 사진은 로그인한 사람만 봅니다.
|
|
||||||
|
|
||||||
3. **미확인 시 자동 확대**
|
|
||||||
위급 사건이 정해진 시간(기본 5분) 동안 확인되지 않으면 상위 담당자에게 올라갑니다.
|
|
||||||
"감지했다"를 "누군가 보게 만들었다"로 바꾸는 장치입니다.
|
|
||||||
|
|
||||||
### 2-3. 증거 — 나중에 무엇으로 설명하는가
|
|
||||||
|
|
||||||
- **사고조사 패키지 (1클릭)** — 사건 개요와 **대응 소요 시간**, 증거 3프레임,
|
|
||||||
**같은 카메라의 전후 30분 타임라인**, 누구에게 언제 통보가 갔는지, 해당 위험요인과 통제
|
|
||||||
대책, 그 카메라의 개선 조치 이력, 서명란. 인쇄해 그대로 제출합니다.
|
|
||||||
- **위·변조 탐지 감사기록** — 확인·설정변경·구역편집이 모두 해시로 연결되어 기록됩니다.
|
|
||||||
중간을 고치거나 지우면 그 이후 전 구간이 어긋나 **탐지됩니다.**
|
|
||||||
- **위험성평가 연계** — 감지 유형별로 어떤 위험요인을 통제하고 있는지 기록하고, 사고조사
|
|
||||||
패키지에 함께 실립니다.
|
|
||||||
|
|
||||||
### 2-4. 운영 — 현장이 스스로 굴릴 수 있는가
|
|
||||||
|
|
||||||
납품 후 고객이 손댈 수 없는 시스템은 결국 꺼집니다.
|
|
||||||
|
|
||||||
- **감지 임계값 편집** — 허용 범위와 설명이 화면에 붙어 있어 잘못된 값이 저장되지 않습니다
|
|
||||||
- **정비 중 알람 억제** — 계획된 작업 동안 통보만 보류. 사유 필수, 최대 8시간, 자동 만료.
|
|
||||||
**감지와 기록은 계속되므로** 그 시간대도 사후에 조사할 수 있습니다
|
|
||||||
- **오탐 관리** — 카메라·유형별 오탐율을 보고 재조정 대상을 고릅니다
|
|
||||||
- **교대 인수인계** — 누가 어떤 미처리 건을 넘겨받았는지 기록
|
|
||||||
- **설비 성능 추이** — "지금 살아 있는가"가 아니라 "점점 나빠지고 있는가"
|
|
||||||
- **설치 점검 화면** — 각 단계가 실제로 동작하는지 조회해서 확인
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 기대효과
|
|
||||||
|
|
||||||
### 3-1. 즉시 달라지는 것
|
|
||||||
|
|
||||||
| 항목 | 도입 전 | 도입 후 |
|
|
||||||
|------|---------|---------|
|
|
||||||
| 야간·단독 작업 중 쓰러짐 | 다음 순찰·교대까지 방치 | 감지 즉시 경광등, 미확인 시 상위 확대 |
|
|
||||||
| 위험구역 관리 | 표지판과 교육에 의존 | 진입·단독작업·정원초과를 규칙으로 판정 |
|
|
||||||
| 사고 조사 | 녹화 뒤져 반나절 | 1클릭 패키지 (전후 맥락·통보·조치 포함) |
|
|
||||||
| CCTV 인력 | 봐야 보임 | 감지된 사건만 봄 |
|
|
||||||
| 안전 활동 증빙 | 문서로 주장 | 시스템 기록으로 제시 |
|
|
||||||
|
|
||||||
### 3-2. 시스템이 스스로 측정해 보여주는 효과
|
|
||||||
|
|
||||||
**이 제품은 자기 효과를 스스로 계측합니다.** 도입 효과를 공급자 주장이 아니라 고객 화면의
|
|
||||||
숫자로 확인할 수 있다는 뜻입니다. 안전 리포트에 다음이 나옵니다.
|
|
||||||
|
|
||||||
- **평균 대응 시간 (TTA)** — 감지에서 확인까지 걸린 시간. 이 숫자가 줄어드는 것이 곧
|
|
||||||
"발견이 빨라졌다"의 증거입니다
|
|
||||||
- **니어미스 : 사고 비율** — 사고 전 신호가 얼마나 먼저 잡히는지. 니어미스가 상대적으로
|
|
||||||
많다는 것은 예방이 앞단에서 작동하고 있다는 뜻입니다
|
|
||||||
- **개선 조치 전·후 감지 밀도** — 매트를 깔았더니 그 카메라의 넘어짐이 줄었는가.
|
|
||||||
**통계적으로 유의하지 않은 변화는 "판정 보류"로 표시**합니다 — 표본 6건에서 나온
|
|
||||||
"33% 개선"을 성과로 적으면 안전회의에서 한 번 반박당하고 그 자료 전체가 신뢰를 잃습니다
|
|
||||||
- **오탐율 추이** — 시스템이 현장에 맞게 조정되고 있는지
|
|
||||||
- **요일×시간 사고 패턴 · 전조 급증 경보** — 인력 배치와 사전 개입의 근거
|
|
||||||
|
|
||||||
### 3-3. 관리 부담이 줄어드는 지점
|
|
||||||
|
|
||||||
- 사고조사 자료를 사람이 모으지 않습니다 — 사고 다음 날 반나절을 쓸 수 있는 사람은 없습니다
|
|
||||||
- 위험성평가와 실제 감지가 한 곳에 연결되어 있어 갱신 근거가 자동으로 쌓입니다
|
|
||||||
- 교대 인수인계와 정비 억제가 기록으로 남아, "그때 왜 알람이 없었나"에 답할 수 있습니다
|
|
||||||
- 감사기록 위·변조 탐지로, 제출한 기록의 신뢰성을 별도로 주장할 필요가 없습니다
|
|
||||||
|
|
||||||
### 3-4. 약속하지 않는 것
|
|
||||||
|
|
||||||
과장은 도입 후 신뢰를 무너뜨리므로 미리 적습니다.
|
|
||||||
|
|
||||||
- **사고를 없애지 못합니다.** 이 시스템은 사고를 더 빨리 발견하고, 전조를 앞당겨 보여주고,
|
|
||||||
대응과 증거를 정리합니다. 사고 자체를 막는 것은 설비·공정·교육이며, 이 시스템은 그 개선의
|
|
||||||
**근거를 제공**하는 역할입니다.
|
|
||||||
- **감지율 100%는 없습니다.** 가려짐·역광·극단적 조도에서는 놓칩니다. 그래서 감시 자신의
|
|
||||||
건전성(화각 변경·가림·조도)을 별도 이벤트로 감지해 **못 보고 있다는 사실을 알립니다.**
|
|
||||||
- **오탐은 초기에 반드시 발생합니다.** 현장마다 조명·동선·작업 자세가 달라, 첫 2~4주는
|
|
||||||
임계값 조정 기간으로 잡아야 합니다. 그 조정을 고객이 직접 할 수 있도록 오탐율 통계와
|
|
||||||
임계값 편집 화면을 넣었습니다.
|
|
||||||
- **법적 판단을 대신하지 않습니다.** 리포트와 사고조사 패키지는 안전 활동을 뒷받침하는
|
|
||||||
보조 자료이며, 공식 재해 통계나 법정 서식을 대체하지 않습니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 시스템 구성
|
|
||||||
|
|
||||||
```
|
|
||||||
[카메라 CSI/USB · Pi당 2~4대]
|
|
||||||
│
|
|
||||||
┌──────────▼──────────────────────────────┐
|
|
||||||
│ Raspberry Pi 5 + Hailo-8 (26 TOPS) │ ← 엣지: 자세추정 · 이벤트 판정
|
|
||||||
│ · 얼굴 블러 (저장 전) │ · 경광등 GPIO 직접 구동
|
|
||||||
│ · 오프라인 큐 (회선 단절 대비) │ · 카메라 무결성 감시
|
|
||||||
└──────────┬──────────────────────────────┘
|
|
||||||
│ HMAC 서명 POST — 스냅샷 3장 + 메타데이터만 (영상 없음)
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────┐
|
|
||||||
│ 웹 대시보드 (PHP 8 + MySQL) │ ← 일반 웹호스팅에서 동작
|
|
||||||
│ 이벤트 콘솔 · 구역 편집 · 안전 리포트 │ 별도 서버 투자 불필요
|
|
||||||
│ 안전 관리 · 시스템 설정 · 사고조사 │
|
|
||||||
└──────────┬──────────────────────────────┘
|
|
||||||
│
|
|
||||||
메일 · 사내 웹훅/문자 게이트웨이
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4-1. 엣지 노드
|
|
||||||
|
|
||||||
- 하나의 공유 자세추정 모델로 모든 이벤트를 판정 — 카메라마다 모델을 더 올리지 않습니다
|
|
||||||
- CSI·USB 카메라 혼용, CPU 폴백 경로 내장
|
|
||||||
- 회선 단절 시 오프라인 큐에 보관 후 복구되면 재전송 (중복 전송은 서버가 제거)
|
|
||||||
- systemd 상주 + 워치독 + 하트비트 텔레메트리
|
|
||||||
- 감지 타이머는 단조 시계 기반이라 시스템 시각이 바뀌어도 오판하지 않습니다
|
|
||||||
|
|
||||||
### 4-2. 서버·대시보드
|
|
||||||
|
|
||||||
| 화면 | 누가 | 무엇을 |
|
|
||||||
|------|------|--------|
|
|
||||||
| 이벤트 | 운영자 | 실시간 스트림, 확인·오탐 판정, 날짜 조회, 일괄 처리 |
|
|
||||||
| 이벤트 상세 | 운영자 | 증거 3프레임, 판정, 조치 기록, 사고조사 패키지 진입 |
|
|
||||||
| 구역 설정 | 안전관리자 | 카메라 화면 위 폴리곤, 등급·점유 규칙, 위험 히트맵 |
|
|
||||||
| 안전 리포트 | 안전관리자 | 주간 회의용 지표 한 장 (인쇄) |
|
|
||||||
| 안전 관리 | 안전관리자 | 인수인계 · 정비 억제 · 위험성평가 · 설비 성능 |
|
|
||||||
| 시스템 설정 | 관리자 | 통보 경로 · 감지 임계값 |
|
|
||||||
|
|
||||||
이 외에 **설치 점검**(구성이 실제로 동작하는지 단계별 확인)과 **사고조사 패키지**(인쇄용
|
|
||||||
단일 문서) 화면이 있습니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 개인정보 보호와 보안
|
|
||||||
|
|
||||||
작업자 감시 도구로 비치는 순간 현장 도입이 막힙니다. 그래서 프라이버시는 설정이 아니라
|
|
||||||
**구조**로 넣었습니다.
|
|
||||||
|
|
||||||
| 영역 | 적용 |
|
|
||||||
|------|------|
|
|
||||||
| **영상** | 라이브뷰 없음, 영상 저장·전송 없음. 사건 스냅샷 3장만 |
|
|
||||||
| **얼굴** | 저장 전에 자동 블러. 원본은 어디에도 남지 않음 |
|
|
||||||
| **접근** | 스냅샷은 인증 프록시 경유. 보존기간 경과 시 자동 삭제 |
|
|
||||||
| **통보** | 알림에 이미지 미포함 — 링크만 전송 |
|
|
||||||
| 전송 보안 | HMAC 서명 + nonce로 위·변조·재전송 차단, 장치·IP별 rate limit |
|
|
||||||
| 인증 | Argon2id, 로그인 잠금(사용자명 열거 차단), 세션 고정 방지 |
|
|
||||||
| 권한 | 역할 기반 접근제어 + 현장 스코프, 전 요청 CSRF |
|
|
||||||
| 웹 | CSP nonce (unsafe-inline 없음), DOM 주입은 textContent만 |
|
|
||||||
| 기록 | 감사로그 해시체인 — 수정·삭제·우회삽입 탐지 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 납품과 운영
|
|
||||||
|
|
||||||
- **웹호스팅 업로드 패키지** — 서버 일체(마이그레이션 9종 포함)를 zip 하나로 업로드,
|
|
||||||
설정 파일 한 개만 수정
|
|
||||||
- **Pi 설치 번들** — 온라인 설치와 **폐쇄망 오프라인 번들** 모두 제공.
|
|
||||||
런타임 휠·모델·소스를 전부 동봉해 설치 중 네트워크가 필요 없습니다
|
|
||||||
- **동봉 문서** — 설치 안내서 · **역할별 사용 설명서**(실제 화면 캡처 포함 단일 HTML) ·
|
|
||||||
변경 내역 · 납품 점검 리포트
|
|
||||||
- **자동 실행 2종** — 통보 디스패처(1분) · 보존기간 정리(일 1회)
|
|
||||||
- **운영 도구** — 백업/복구, 감사기록 무결성 검증, 시연용 데모 데이터 생성
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 품질 근거
|
|
||||||
|
|
||||||
| 항목 | 현황 |
|
|
||||||
|------|------|
|
|
||||||
| 자동화 테스트 | **535건** (엣지 Python 311 · 서버 PHP 224), 스킵 0 (하드웨어 항목 제외) |
|
|
||||||
| 납품 감사 | 1차 43건 + 재감사 12건 = **55건 전량 해소**, 판정 **적합** |
|
|
||||||
| 상호운용 검증 | HMAC 골든 벡터로 엣지(Python) ↔ 서버(PHP) 대조 |
|
|
||||||
| 계층 정합 검증 | 감지 유형이 엣지·검증기·DB·화면 네 곳에서 일치하는지 자동 검사 |
|
|
||||||
| 납품물 검증 | 산출물↔소스 패리티, 패키지 내부 클래스 해석·문법 검사, 무결성 해시 |
|
|
||||||
| CI | 검증 명령 단일화, 스킵 발생 시 빌드 실패 |
|
|
||||||
|
|
||||||
> 감사에서 실제로 잡힌 대표 사례: 신규 감지 유형이 서버 검증기에만 누락되어 **감지기가
|
|
||||||
> 구현·배포되고도 아무 이벤트도 남기지 못하던 상태**를 발견해 수정하고, 재발 방지 테스트를
|
|
||||||
> 추가했습니다. 이런 "조용한 실패"를 찾는 것이 이 프로젝트 감사 사이클의 목적입니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 기술 스택
|
|
||||||
|
|
||||||
| 구분 | 기술 |
|
|
||||||
|------|------|
|
|
||||||
| 엣지 하드웨어 | Raspberry Pi 5 + AI HAT+ (Hailo-8, 26 TOPS) |
|
|
||||||
| 엣지 소프트웨어 | Python, Hailo HEF 자세추정 (CPU 폴백 내장) |
|
|
||||||
| 카메라 | CSI (picamera2) · USB (V4L2 MJPEG), Pi당 2~4대 |
|
|
||||||
| 서버 | PHP 8 + PDO + MySQL — 일반 웹호스팅에서 동작 |
|
|
||||||
| 프런트엔드 | 바닐라 JavaScript (빌드 도구 없음) |
|
|
||||||
| 배포 | 오프라인 번들 · systemd · zip 업로드 패키지 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 현재 한계와 다음 단계
|
|
||||||
|
|
||||||
### 9-1. 모델 확보가 선행되어야 하는 항목
|
|
||||||
|
|
||||||
보호구 미착용 · 중장비 근접 · 화재/연기 감지는 **감지기 배선·이벤트 규격·테스트까지 완성되어
|
|
||||||
있고 추론 모델만 비어 있습니다.** 라이선스 조건을 만족하는 가중치 확보와 엣지 컴파일이
|
|
||||||
선행 조건입니다.
|
|
||||||
|
|
||||||
이 상태를 "곧 됩니다"로 켜두지 않고 **명시적으로 비활성**으로 둔 것은 의도적입니다 —
|
|
||||||
켜져 있으면 현장은 감지되고 있다고 믿게 되고, 그 믿음이 가장 위험합니다.
|
|
||||||
|
|
||||||
### 9-2. 현장 설치 시 검증할 항목
|
|
||||||
|
|
||||||
코드로 닫을 수 없고 실장비에서만 확인 가능한 항목입니다. 설치 시 반드시 수행합니다.
|
|
||||||
|
|
||||||
- 경광등 실제 구동 (배선·극성·유지시간)
|
|
||||||
- 카메라 무결성 임계값을 현장 조명·진동에 맞게 조정
|
|
||||||
- 낙상 판정 타이밍을 실제 영상으로 조정 — 블라인드 조정은 감지 **누락**을 만들 수 있어
|
|
||||||
현장 확보 후로 미뤘습니다
|
|
||||||
- 통보 1회 실발송 (시험 발송으로 대상 확인 후)
|
|
||||||
|
|
||||||
### 9-3. 다음 단계
|
|
||||||
|
|
||||||
- 위 감지 3종 활성화 (모델 확보 시)
|
|
||||||
- 기계 가동 상태 연동 협착 판정 — 협착구역은 기계가 돌 때만 위험합니다
|
|
||||||
- 다현장 통합 운영 (본사 관점 롤업은 이미 제공)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 부록. 한눈에 보기
|
|
||||||
|
|
||||||
| | |
|
|
||||||
|---|---|
|
|
||||||
| **한 줄 정의** | 쓰러진 작업자를 3초면 알아채고, 현장에 즉시 울리고, 5분 안에 확인되지 않으면 위로 올리며, 그 전 과정을 증거로 남기는 시스템 |
|
|
||||||
| **핵심 차별점** | ① 감지에서 끝나지 않고 **현장 경광등·에스컬레이션까지** ② **감시 자신의 고장**을 감지 ③ **사고조사 패키지 1클릭** ④ 위·변조 탐지 감사기록 |
|
|
||||||
| **프라이버시** | 라이브뷰·영상 전송 없음 · 얼굴 저장 전 블러 · 알림에 이미지 미포함 |
|
|
||||||
| **운영 비용** | 클라우드 GPU 없음, 일반 웹호스팅에서 동작 |
|
|
||||||
| **감지 유형** | 20종 활성 (+ 모델 확보 대기 3종) |
|
|
||||||
| **검증** | 테스트 535건 · 납품 감사 55건 전량 해소 |
|
|
||||||
|
|
@ -4,17 +4,11 @@ import cv2
|
||||||
|
|
||||||
|
|
||||||
class UvcSource:
|
class UvcSource:
|
||||||
def __init__(self, dev, mjpeg: bool = True, *, reopen_after: int = 30,
|
def __init__(self, dev, mjpeg: bool = True, *, reopen_after: int = 30, _open=None):
|
||||||
size: tuple[int, int] = (640, 480), _open=None):
|
|
||||||
# _open lets tests inject a fake capture; production uses cv2.VideoCapture.
|
# _open lets tests inject a fake capture; production uses cv2.VideoCapture.
|
||||||
self._dev = dev
|
self._dev = dev
|
||||||
self._mjpeg = mjpeg
|
self._mjpeg = mjpeg
|
||||||
self._reopen_after = reopen_after
|
self._reopen_after = reopen_after
|
||||||
# Pin the capture size to the CSI default. Left unset, a UVC cam opens at ITS default —
|
|
||||||
# a 1080p webcam yields 6.2MB frames, and the 90-frame evidence ring then costs ~560MB
|
|
||||||
# per camera (vs ~80MB at 640x480), which is an OOM on a 4-camera Pi. Memory must be a
|
|
||||||
# property of OUR config, not of whichever webcam the site happened to plug in.
|
|
||||||
self._size = size
|
|
||||||
self._open = _open or (lambda d: cv2.VideoCapture(d))
|
self._open = _open or (lambda d: cv2.VideoCapture(d))
|
||||||
self._fail_streak = 0
|
self._fail_streak = 0
|
||||||
self.cap = self._open_cap()
|
self.cap = self._open_cap()
|
||||||
|
|
@ -28,14 +22,6 @@ class UvcSource:
|
||||||
raise RuntimeError(f"UVC device {self._dev!r} failed to open")
|
raise RuntimeError(f"UVC device {self._dev!r} failed to open")
|
||||||
if self._mjpeg:
|
if self._mjpeg:
|
||||||
cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*"MJPG"))
|
cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*"MJPG"))
|
||||||
w, h = self._size
|
|
||||||
cap.set(cv2.CAP_PROP_FRAME_WIDTH, w)
|
|
||||||
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, h)
|
|
||||||
# Ask v4l2 to keep the shallowest queue it will honour. When the loop runs slower than
|
|
||||||
# the camera's fps, a deep driver queue hands back frames from seconds ago — a fall
|
|
||||||
# would be detected long after it happened. Not all backends honour this; harmless if
|
|
||||||
# ignored (the drain-only tick in PipelineRuntime covers the rest).
|
|
||||||
cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
|
|
||||||
return cap
|
return cap
|
||||||
|
|
||||||
def read(self):
|
def read(self):
|
||||||
|
|
|
||||||
74
edge/app.py
|
|
@ -8,32 +8,6 @@ from edge.detectors.zone import ZoneIntrusionDetector
|
||||||
from edge.engine import EventEngine
|
from edge.engine import EventEngine
|
||||||
from edge.snapshot import RingBuffer, FaceBlurrer, Snapshotter
|
from edge.snapshot import RingBuffer, FaceBlurrer, Snapshotter
|
||||||
from edge.models import BBox
|
from edge.models import BBox
|
||||||
from edge.events import Event, EventType, severity_for
|
|
||||||
|
|
||||||
# Evidence timeline offsets (seconds before the event) — 3 frames tell how it developed.
|
|
||||||
# The server accepts at most 3 snapshots per event (EventValidator), so this is the cap.
|
|
||||||
SNAPSHOT_OFFSETS_S = (2.5, 1.0, 0.0)
|
|
||||||
|
|
||||||
# 사진을 붙이지 않는 유형.
|
|
||||||
#
|
|
||||||
# 기준은 "이 사진이 사건의 증거인가, 그 사람이 거기 있었다는 기록인가"이다. 넘어짐·추락·협착은
|
|
||||||
# 무슨 일이 일어났는지가 프레임 안에 있고, 단독작업·정원 초과는 규칙 위반 자체가 인원 수라서
|
|
||||||
# 사진이 그 수를 증명한다(관리자가 오탐인지 확인하는 수단이기도 하다). 아래 넷은 다르다:
|
|
||||||
#
|
|
||||||
# ERGO_REPORT — 카메라 단위 익명 통계. 프레임을 붙이면 통계가 감시 영상이 된다.
|
|
||||||
# OVERSTAY — 발화 조건이 시간 경과 하나뿐이다. "정상 작업을 오래 했다"는 사건이 아니고,
|
|
||||||
# 사진이 말해 주는 것은 그게 누구였고 무엇을 입었는지뿐이다. 그 사람이 실제로
|
|
||||||
# 쓰러지면 fall/no_motion 이 따로 발화하고 그쪽에는 증거가 붙는다.
|
|
||||||
# DEVICE_FAULT / MONITORING_DEGRADED — 장치 상태. 사람이 찍힐 이유가 없다.
|
|
||||||
#
|
|
||||||
# 이 목록이 짧게 유지되는 것이 중요하다. "정상 근무 중에는 아무것도 남지 않는다"는 우리가
|
|
||||||
# 작업자에게 한 약속인데, 시간이 조건인 유형에 사진을 붙이는 순간 그 약속이 거짓이 된다.
|
|
||||||
NO_EVIDENCE_TYPES = frozenset({
|
|
||||||
EventType.ERGO_REPORT,
|
|
||||||
EventType.OVERSTAY,
|
|
||||||
EventType.DEVICE_FAULT,
|
|
||||||
EventType.MONITORING_DEGRADED,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def _head_boxes(persons: list) -> list:
|
def _head_boxes(persons: list) -> list:
|
||||||
|
|
@ -55,8 +29,7 @@ def _head_boxes(persons: list) -> list:
|
||||||
return boxes
|
return boxes
|
||||||
|
|
||||||
|
|
||||||
def run_once(source, pose, tracker, pool, engine, ring, snapshotter, sink, now_fn, wall_fn=None,
|
def run_once(source, pose, tracker, pool, engine, ring, snapshotter, sink, now_fn, wall_fn=None):
|
||||||
integrity=None):
|
|
||||||
ok, frame = source.read()
|
ok, frame = source.read()
|
||||||
if not ok:
|
if not ok:
|
||||||
# No frame this tick. Return None (not []) so the daemon can tell "camera produced no
|
# No frame this tick. Return None (not []) so the daemon can tell "camera produced no
|
||||||
|
|
@ -68,56 +41,21 @@ def run_once(source, pose, tracker, pool, engine, ring, snapshotter, sink, now_f
|
||||||
persons = pose.infer(frame)
|
persons = pose.infer(frame)
|
||||||
tracks = tracker.update(persons, now) # RAW keypoints; smoothing happens per-track below
|
tracks = tracker.update(persons, now) # RAW keypoints; smoothing happens per-track below
|
||||||
lost = tracker.lost()
|
lost = tracker.lost()
|
||||||
# Head boxes are computed EVERY tick and stored with the frame (not only when an event
|
for tr in tracks:
|
||||||
# fires): a past frame pulled from the ring later must be blurred with the head positions
|
|
||||||
# of its own moment, not the current tick's.
|
|
||||||
head_boxes = _head_boxes(persons)
|
|
||||||
# A contended (crossing) match may be an id swap — FALLEN state, frozen baseline, or an
|
|
||||||
# immobility timer would ride along to the wrong person. Reset that track's safety state;
|
|
||||||
# a genuinely fallen worker still prone re-confirms within prone_confirm_s.
|
|
||||||
for tid in tracker.ambiguous():
|
|
||||||
engine.forget(tid)
|
|
||||||
pool.forget(tid)
|
|
||||||
# A track not re-detected this tick still sits in the tracker (< max_age) with a frozen
|
|
||||||
# latest frame. Feeding it downstream would re-smooth the same frame and let time-based
|
|
||||||
# detector timers (prone confirm / no-motion / immobility) accrue on stale data — treat
|
|
||||||
# it as no-data until it is re-detected or ages into lost().
|
|
||||||
fresh = [tr for tr in tracks if tr.latest.t == now]
|
|
||||||
for tr in fresh:
|
|
||||||
f = tr.latest
|
f = tr.latest
|
||||||
f.keypoints = pool.smoother_for(tr.track_id).smooth(f.keypoints, now)
|
f.keypoints = pool.smoother_for(tr.track_id).smooth(f.keypoints, now)
|
||||||
ring.push(now, frame, head_boxes)
|
ring.push(now, frame)
|
||||||
h, w = frame.shape[:2]
|
h, w = frame.shape[:2]
|
||||||
# `now` (monotonic) drives detector timers; wall time stamps the event (occurred_at). Off the
|
# `now` (monotonic) drives detector timers; wall time stamps the event (occurred_at). Off the
|
||||||
# daemon (direct callers / tests) wall_fn is None → engine falls back to now, as before.
|
# daemon (direct callers / tests) wall_fn is None → engine falls back to now, as before.
|
||||||
wall_now = wall_fn() if wall_fn is not None else None
|
wall_now = wall_fn() if wall_fn is not None else None
|
||||||
events = engine.process(fresh, lost, now, frame_wh=(w, h), wall_now=wall_now)
|
events = engine.process(tracks, lost, now, frame_wh=(w, h), wall_now=wall_now)
|
||||||
# Camera-integrity findings are about the FRAME, not about any track, so they are produced
|
|
||||||
# here (where the frame is) rather than inside the engine (which only sees tracks). They
|
|
||||||
# matter because a camera that has been knocked askew keeps producing perfectly healthy
|
|
||||||
# frames while every zone drawn on it now points somewhere else — the failure that used to
|
|
||||||
# be invisible until after an incident.
|
|
||||||
if integrity is not None:
|
|
||||||
stamp = wall_now if wall_now is not None else now
|
|
||||||
for etype, metrics in integrity.observe(frame, now):
|
|
||||||
et = EventType(etype)
|
|
||||||
events.append(Event(et, severity_for(et), engine.cfg.camera_id, None, None,
|
|
||||||
stamp, 0.0, metrics, None))
|
|
||||||
for tr in lost:
|
for tr in lost:
|
||||||
pool.forget(tr.track_id)
|
pool.forget(tr.track_id)
|
||||||
if events:
|
if events:
|
||||||
# 증거가 필요한 사건에만 프레임을 뜬다 — NO_EVIDENCE_TYPES 주석이 그 경계를 정한다.
|
snaps = snapshotter.capture([frame], [_head_boxes(persons)])
|
||||||
evidential = [e for e in events if e.event_type not in NO_EVIDENCE_TYPES]
|
|
||||||
snaps = []
|
|
||||||
if evidential:
|
|
||||||
# Evidence timeline, oldest→now: how the incident DEVELOPED, not just its end
|
|
||||||
# state (a fall's value is in the seconds before the person hit the floor).
|
|
||||||
# Right after startup the ring is short and fewer frames go out — never zero,
|
|
||||||
# since the current frame was pushed above. Each frame blurs with its own boxes.
|
|
||||||
frames, boxes = ring.timeline(now, SNAPSHOT_OFFSETS_S)
|
|
||||||
snaps = snapshotter.capture(frames, boxes)
|
|
||||||
for e in events:
|
for e in events:
|
||||||
sink.try_send(e, [] if e.event_type in NO_EVIDENCE_TYPES else snaps)
|
sink.try_send(e, snaps)
|
||||||
return events
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
176
edge/beacon.py
|
|
@ -1,176 +0,0 @@
|
||||||
"""현장 경광등·부저 — 엣지 노드가 스스로 울리는 유일한 통보 경로.
|
|
||||||
|
|
||||||
WHY LOCAL
|
|
||||||
폐쇄망 현장에서는 FCM 도 Slack 도 쓸 수 없고, 사내 SMTP·웹훅조차 없는 곳이 있다. 게다가
|
|
||||||
서버 경유 통보는 업링크가 살아 있을 때만 동작한다 — 정전이나 스위치 장애로 회선이 끊긴
|
|
||||||
순간은 사고 위험이 오히려 높아지는 때다. GPIO 출력은 그 모든 것과 무관하게 동작한다:
|
|
||||||
감지기가 이벤트를 만든 그 자리에서, 네트워크 없이, 수십 밀리초 안에.
|
|
||||||
|
|
||||||
그래서 이것이 어떤 사이트든 **가장 먼저 설정해야 할 통보 경로**다. 서버측 채널
|
|
||||||
(App\\Alert\\Channels)은 이 위에 얹는 보조 수단이다.
|
|
||||||
|
|
||||||
설정 전달
|
|
||||||
서버 /api/config 의 `beacons` 배열로 내려온다 (핀 번호, latch/pulse, 유지 시간,
|
|
||||||
active_high). 관리 화면에서 켜고 끄므로 현장에서 edge.env 를 고칠 필요가 없다.
|
|
||||||
|
|
||||||
하드웨어 부재
|
|
||||||
gpiozero / RPi.GPIO 가 없거나(개발 PC, CI) 핀 열기에 실패하면 NullBeacon 으로 조용히
|
|
||||||
떨어진다. 경광등이 없다고 감지가 멈추면 안 된다 — 통보는 감지의 부가 기능이지 전제가
|
|
||||||
아니다. 다만 그 사실은 로그로 남긴다: 아무도 모르게 경광등이 죽어 있는 것이 최악이다.
|
|
||||||
|
|
||||||
실장비 검증
|
|
||||||
핀이 실제로 떨어지는지는 이 파일의 단위 테스트로 증명할 수 없다. tests/hardware/ 의
|
|
||||||
OFF-HW 항목으로 남긴다 (docs/hardware-bringup.md).
|
|
||||||
"""
|
|
||||||
|
|
||||||
import time
|
|
||||||
|
|
||||||
_SEVERITY_RANK = {"info": 0, "warning": 1, "high": 2, "critical": 3}
|
|
||||||
|
|
||||||
|
|
||||||
class NullBeacon:
|
|
||||||
"""하드웨어가 없을 때의 무동작 구현. 인터페이스는 동일하다."""
|
|
||||||
|
|
||||||
available = False
|
|
||||||
|
|
||||||
def fire(self, severity: str, now: float) -> bool:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def tick(self, now: float) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def release(self) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class GpioBeacon:
|
|
||||||
"""핀 하나를 latch(고정) 또는 pulse(일정 시간 후 해제) 로 구동한다.
|
|
||||||
|
|
||||||
`latch` 는 사람이 확인할 때까지 계속 울리는 대신 hold_s 뒤 자동 해제한다. 무한 latch 는
|
|
||||||
현장에 사람이 갈 수 없는 시간대(야간·주말)에 아무도 끌 수 없는 소음이 되고, 그러면 다음
|
|
||||||
주에 배선이 뽑혀 있다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
available = True
|
|
||||||
|
|
||||||
def __init__(self, pin: int, *, mode: str = "latch", hold_s: float = 30.0,
|
|
||||||
active_high: bool = False, min_severity: str = "high",
|
|
||||||
backend=None, log=None):
|
|
||||||
self.pin = int(pin)
|
|
||||||
self.mode = mode if mode in ("latch", "pulse") else "latch"
|
|
||||||
self.hold_s = max(0.5, float(hold_s))
|
|
||||||
self.active_high = bool(active_high)
|
|
||||||
self.min_rank = _SEVERITY_RANK.get(min_severity, 2)
|
|
||||||
self._on_until: float | None = None
|
|
||||||
self._log = log or (lambda m: None)
|
|
||||||
self._backend = backend if backend is not None else _open_pin(self.pin, self.active_high)
|
|
||||||
|
|
||||||
def _write(self, on: bool) -> None:
|
|
||||||
# active_high=False 는 릴레이 모듈의 통상 배선(LOW 에서 도통)이다. 반대로 물린
|
|
||||||
# 현장에서 경광등이 상시 켜져 있는 것을 설정으로 뒤집을 수 있어야 한다.
|
|
||||||
self._backend.set(on if self.active_high else not on)
|
|
||||||
|
|
||||||
def fire(self, severity: str, now: float) -> bool:
|
|
||||||
if _SEVERITY_RANK.get(severity, 0) < self.min_rank:
|
|
||||||
return False
|
|
||||||
self._write(True)
|
|
||||||
# 이미 울리는 중에 새 이벤트가 오면 유지 시간을 연장한다 — 사고가 이어지는 동안
|
|
||||||
# 경광등이 중간에 꺼졌다 켜지면 사람이 "끝났다"로 읽는다.
|
|
||||||
self._on_until = now + self.hold_s
|
|
||||||
return True
|
|
||||||
|
|
||||||
def tick(self, now: float) -> None:
|
|
||||||
"""루프가 매 틱 호출한다. 유지 시간이 지나면 해제."""
|
|
||||||
if self._on_until is not None and now >= self._on_until:
|
|
||||||
self._write(False)
|
|
||||||
self._on_until = None
|
|
||||||
|
|
||||||
def release(self) -> None:
|
|
||||||
try:
|
|
||||||
self._write(False)
|
|
||||||
finally:
|
|
||||||
close = getattr(self._backend, "close", None)
|
|
||||||
if close:
|
|
||||||
close()
|
|
||||||
|
|
||||||
|
|
||||||
class _PinBackend:
|
|
||||||
"""gpiozero / RPi.GPIO 중 있는 쪽에 얇게 붙는다."""
|
|
||||||
|
|
||||||
def __init__(self, device=None, rpi=None, pin: int = 0):
|
|
||||||
self._device = device
|
|
||||||
self._rpi = rpi
|
|
||||||
self._pin = pin
|
|
||||||
|
|
||||||
def set(self, high: bool) -> None:
|
|
||||||
if self._device is not None:
|
|
||||||
if high:
|
|
||||||
self._device.on()
|
|
||||||
else:
|
|
||||||
self._device.off()
|
|
||||||
elif self._rpi is not None:
|
|
||||||
self._rpi.output(self._pin, self._rpi.HIGH if high else self._rpi.LOW)
|
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
if self._device is not None:
|
|
||||||
self._device.close()
|
|
||||||
elif self._rpi is not None:
|
|
||||||
self._rpi.cleanup(self._pin)
|
|
||||||
|
|
||||||
|
|
||||||
def _open_pin(pin: int, active_high: bool): # pragma: no cover (hardware)
|
|
||||||
try:
|
|
||||||
from gpiozero import DigitalOutputDevice
|
|
||||||
# initial_value=False 는 "비활성" 을 뜻한다. active_high 해석은 _write 가 담당하므로
|
|
||||||
# 여기서는 항상 raw 출력으로 열고, 부팅 순간 경광등이 번쩍이지 않도록 초기값을 준다.
|
|
||||||
dev = DigitalOutputDevice(pin, active_high=True, initial_value=not active_high)
|
|
||||||
return _PinBackend(device=dev, pin=pin)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
import RPi.GPIO as GPIO
|
|
||||||
GPIO.setmode(GPIO.BCM)
|
|
||||||
GPIO.setup(pin, GPIO.OUT, initial=GPIO.LOW if active_high else GPIO.HIGH)
|
|
||||||
return _PinBackend(rpi=GPIO, pin=pin)
|
|
||||||
except Exception:
|
|
||||||
raise RuntimeError("no usable GPIO backend")
|
|
||||||
|
|
||||||
|
|
||||||
def build_beacons(config: dict, log=None) -> list:
|
|
||||||
"""/api/config 의 `beacons` 배열 -> 구동 가능한 비콘 목록.
|
|
||||||
|
|
||||||
핀 하나가 열리지 않아도 나머지는 살린다. 전부 실패하면 빈 목록이고, 그때도 감지·전송은
|
|
||||||
그대로 돈다.
|
|
||||||
"""
|
|
||||||
log = log or (lambda m: None)
|
|
||||||
out = []
|
|
||||||
for b in (config or {}).get("beacons") or []:
|
|
||||||
if not isinstance(b, dict):
|
|
||||||
continue
|
|
||||||
pin = int(b.get("gpio_pin") or 0)
|
|
||||||
if pin <= 0:
|
|
||||||
continue # 0 = 미배선 (관리 화면에서 핀을 아직 안 정한 상태)
|
|
||||||
try:
|
|
||||||
out.append(GpioBeacon(
|
|
||||||
pin,
|
|
||||||
mode=str(b.get("mode") or "latch"),
|
|
||||||
hold_s=float(b.get("hold_s") or 30.0),
|
|
||||||
active_high=bool(b.get("active_high")),
|
|
||||||
min_severity=str(b.get("min_severity") or "high"),
|
|
||||||
log=log,
|
|
||||||
))
|
|
||||||
except Exception as e:
|
|
||||||
# 조용히 넘어가면 아무도 경광등이 죽은 줄 모른다.
|
|
||||||
log(f"beacon pin {pin} unavailable ({type(e).__name__}: {e}); alerts on this node "
|
|
||||||
f"will not sound locally")
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def fire_all(beacons, events, now: float | None = None) -> int:
|
|
||||||
"""이벤트 목록 중 가장 높은 심각도로 비콘을 울린다. @return 실제로 울린 비콘 수."""
|
|
||||||
if not beacons or not events:
|
|
||||||
return 0
|
|
||||||
now = time.monotonic() if now is None else now
|
|
||||||
worst = max((getattr(e.severity, "value", e.severity) for e in events),
|
|
||||||
key=lambda s: _SEVERITY_RANK.get(s, 0))
|
|
||||||
return sum(1 for b in beacons if b.fire(worst, now))
|
|
||||||
|
|
@ -19,32 +19,12 @@ class Config:
|
||||||
ghost: GhostConfig
|
ghost: GhostConfig
|
||||||
zone: ZoneConfig
|
zone: ZoneConfig
|
||||||
engine: EngineConfig
|
engine: EngineConfig
|
||||||
# Tier-2 degradation knob: run pose on every Nth tick (1 = every tick, the T1 target).
|
|
||||||
# Raise this when a device cannot sustain full-rate inference across all its cameras —
|
|
||||||
# it trades reaction granularity for keeping every camera online, which beats going blind
|
|
||||||
# on one. See docs/hardware-bringup.md.
|
|
||||||
infer_every: int = 1
|
|
||||||
|
|
||||||
|
|
||||||
def _f(env: dict, key: str, default: float) -> float:
|
def _f(env: dict, key: str, default: float) -> float:
|
||||||
return float(env[key]) if key in env else default
|
return float(env[key]) if key in env else default
|
||||||
|
|
||||||
|
|
||||||
def _positive(env: dict, key: str, default: float) -> float:
|
|
||||||
"""A timer/threshold knob that must be a finite positive number.
|
|
||||||
|
|
||||||
Every one of these feeds a `now - started >= limit` comparison. NaN makes that comparison
|
|
||||||
ALWAYS False and 0/negative makes it ALWAYS True, so a typo in edge.env does not crash —
|
|
||||||
it silently turns a detector off, or turns it into a permanent alarm. COOLDOWN_S=nan is the
|
|
||||||
worst case: dedup stops suppressing and every tick re-fires the same event. Reject at
|
|
||||||
startup, where the operator still sees the message, rather than in the field.
|
|
||||||
"""
|
|
||||||
val = _f(env, key, default)
|
|
||||||
if not math.isfinite(val) or val <= 0:
|
|
||||||
raise ValueError(f"{key} must be a finite positive number, got {val!r}")
|
|
||||||
return val
|
|
||||||
|
|
||||||
|
|
||||||
def frame_interval_from_env(env: dict) -> float:
|
def frame_interval_from_env(env: dict) -> float:
|
||||||
"""Seconds per frame from TARGET_FPS, validated. TARGET_FPS=0 (ZeroDivisionError) or a
|
"""Seconds per frame from TARGET_FPS, validated. TARGET_FPS=0 (ZeroDivisionError) or a
|
||||||
non-numeric value (ValueError) would otherwise crash at every startup into an infinite
|
non-numeric value (ValueError) would otherwise crash at every startup into an infinite
|
||||||
|
|
@ -118,20 +98,20 @@ def load_config(env: dict) -> Config:
|
||||||
# track_displacement is now frame-rate-normalized, so this must be tuned per device — expose
|
# track_displacement is now frame-rate-normalized, so this must be tuned per device — expose
|
||||||
# it via env instead of forcing a code edit + redeploy. Reject a non-finite or non-positive
|
# it via env instead of forcing a code edit + redeploy. Reject a non-finite or non-positive
|
||||||
# value at startup (a 0 or NaN would make everything read as "still" -> constant no_motion).
|
# value at startup (a 0 or NaN would make everything read as "still" -> constant no_motion).
|
||||||
motion_thr = _positive(env, "MOTION_THR", 0.25)
|
motion_thr = _f(env, "MOTION_THR", 0.25)
|
||||||
|
if not math.isfinite(motion_thr) or motion_thr <= 0:
|
||||||
|
raise ValueError(f"MOTION_THR must be a finite positive number, got {motion_thr!r}")
|
||||||
fall = FallConfig(
|
fall = FallConfig(
|
||||||
prone_confirm_s=_positive(env, "FALL_PRONE_S", 3.0),
|
prone_confirm_s=_f(env, "FALL_PRONE_S", 3.0),
|
||||||
no_motion_s=_positive(env, "FALL_NO_MOTION_S", 10.0),
|
no_motion_s=_f(env, "FALL_NO_MOTION_S", 10.0),
|
||||||
motion_thr=motion_thr,
|
motion_thr=motion_thr,
|
||||||
)
|
)
|
||||||
immobility = ImmobilityConfig(immobile_s=_positive(env, "IMMOBILE_S", 120.0),
|
immobility = ImmobilityConfig(immobile_s=_f(env, "IMMOBILE_S", 120.0),
|
||||||
motion_thr=motion_thr)
|
motion_thr=motion_thr)
|
||||||
ghost = GhostConfig(reappear_s=_positive(env, "GHOST_REAPPEAR_S", 10.0))
|
ghost = GhostConfig(reappear_s=_f(env, "GHOST_REAPPEAR_S", 10.0))
|
||||||
zone = ZoneConfig(debounce_s=_positive(env, "ZONE_DEBOUNCE_S", 0.75))
|
zone = ZoneConfig(debounce_s=_f(env, "ZONE_DEBOUNCE_S", 0.75))
|
||||||
engine = EngineConfig(camera_id=camera_id, cooldown_s=_positive(env, "COOLDOWN_S", 30.0))
|
engine = EngineConfig(camera_id=camera_id, cooldown_s=_f(env, "COOLDOWN_S", 30.0))
|
||||||
infer_every = int(_positive(env, "INFER_EVERY", 1.0))
|
|
||||||
return Config(
|
return Config(
|
||||||
infer_every=infer_every,
|
|
||||||
device_id=env.get("DEVICE_ID", "pi-unknown"),
|
device_id=env.get("DEVICE_ID", "pi-unknown"),
|
||||||
camera_id=camera_id,
|
camera_id=camera_id,
|
||||||
cafe24_url=env.get("CAFE24_URL", ""),
|
cafe24_url=env.get("CAFE24_URL", ""),
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,6 @@ import os
|
||||||
_LOG_EVERY = 300
|
_LOG_EVERY = 300
|
||||||
|
|
||||||
|
|
||||||
def _beacon_signature(beacons):
|
|
||||||
"""Identity of a beacon set, so a sync only re-opens GPIO when the wiring actually changed.
|
|
||||||
Rebuilding unconditionally would blink every strobe on every sync (every ~150 ticks)."""
|
|
||||||
return tuple(sorted((getattr(b, "pin", 0), getattr(b, "mode", ""), getattr(b, "hold_s", 0.0),
|
|
||||||
getattr(b, "active_high", False), getattr(b, "min_rank", 0))
|
|
||||||
for b in (beacons or [])))
|
|
||||||
|
|
||||||
|
|
||||||
def _default_log(msg, exc=None): # pragma: no cover (thin stderr wrapper)
|
def _default_log(msg, exc=None): # pragma: no cover (thin stderr wrapper)
|
||||||
import sys
|
import sys
|
||||||
import traceback
|
import traceback
|
||||||
|
|
@ -26,8 +18,7 @@ def _default_log(msg, exc=None): # pragma: no cover (thin stderr wrapper)
|
||||||
def run_loop(runtime, client, queue, *, clock, sleep, iterations=None,
|
def run_loop(runtime, client, queue, *, clock, sleep, iterations=None,
|
||||||
sync_every=150, heartbeat_every=900, flush_every=300,
|
sync_every=150, heartbeat_every=900, flush_every=300,
|
||||||
frame_interval=0.0, zones_for=None, log=None,
|
frame_interval=0.0, zones_for=None, log=None,
|
||||||
heartbeat_interval_s=None, health_fn=None, configured_cameras=None,
|
heartbeat_interval_s=None, health_fn=None, configured_cameras=None):
|
||||||
notify=None, beacons=None, build_beacons_fn=None):
|
|
||||||
# `runtime` may be a single PipelineRuntime or a list of them (multi-camera). All
|
# `runtime` may be a single PipelineRuntime or a list of them (multi-camera). All
|
||||||
# cameras share this loop, one shared clock read per tick, and the periodic
|
# cameras share this loop, one shared clock read per tick, and the periodic
|
||||||
# sync/heartbeat/flush. A per-camera step exception is counted and rate-limit-logged,
|
# sync/heartbeat/flush. A per-camera step exception is counted and rate-limit-logged,
|
||||||
|
|
@ -42,22 +33,7 @@ def run_loop(runtime, client, queue, *, clock, sleep, iterations=None,
|
||||||
if health_fn is None:
|
if health_fn is None:
|
||||||
from edge.health import read_health
|
from edge.health import read_health
|
||||||
health_fn = read_health
|
health_fn = read_health
|
||||||
if notify is None:
|
counters = {"frames": 0, "syncs": 0, "beats": 0, "flushes": 0, "errors": 0}
|
||||||
from edge.watchdog import ping as notify
|
|
||||||
# The ping happens at the TOP of each tick, before any camera work. That is the point: it
|
|
||||||
# proves the loop is turning. Pinging after the work would still ping while a camera step
|
|
||||||
# blocks forever, which is the exact failure the watchdog exists to catch.
|
|
||||||
# Local beacons (siren / strobe on the node's own GPIO). This is the ONE notification path
|
|
||||||
# that survives a dead uplink, so it fires here — the instant this loop sees the event —
|
|
||||||
# rather than waiting for the server-side dispatcher. Configured from /api/config, so the
|
|
||||||
# list is rebuilt on sync; None means "not built yet", [] means "built, nothing wired".
|
|
||||||
if beacons is None:
|
|
||||||
beacons = []
|
|
||||||
if build_beacons_fn is None:
|
|
||||||
from edge.beacon import build_beacons as build_beacons_fn
|
|
||||||
from edge.beacon import fire_all as _fire_beacons
|
|
||||||
counters = {"frames": 0, "syncs": 0, "beats": 0, "flushes": 0, "errors": 0,
|
|
||||||
"beacon_fires": 0, "tuned": 0}
|
|
||||||
streak = {} # id(rt)/"periodic" -> consecutive-error count, for rate-limited logging
|
streak = {} # id(rt)/"periodic" -> consecutive-error count, for rate-limited logging
|
||||||
|
|
||||||
def note(key, label, exc=None):
|
def note(key, label, exc=None):
|
||||||
|
|
@ -76,10 +52,6 @@ def run_loop(runtime, client, queue, *, clock, sleep, iterations=None,
|
||||||
i = 0
|
i = 0
|
||||||
while iterations is None or i < iterations:
|
while iterations is None or i < iterations:
|
||||||
i += 1
|
i += 1
|
||||||
try:
|
|
||||||
notify()
|
|
||||||
except Exception as e:
|
|
||||||
note("watchdog", "watchdog ping failed", e)
|
|
||||||
now = clock() # one timestamp per tick, shared across cameras
|
now = clock() # one timestamp per tick, shared across cameras
|
||||||
if beat_anchor is None:
|
if beat_anchor is None:
|
||||||
beat_anchor = (now, 0)
|
beat_anchor = (now, 0)
|
||||||
|
|
@ -103,48 +75,12 @@ def run_loop(runtime, client, queue, *, clock, sleep, iterations=None,
|
||||||
else:
|
else:
|
||||||
counters["frames"] += 1
|
counters["frames"] += 1
|
||||||
streak.pop(id(rt), None) # recovered — reset the streak
|
streak.pop(id(rt), None) # recovered — reset the streak
|
||||||
# Sound the local beacon on this tick's events. Wrapped: a wiring fault must
|
|
||||||
# never take down the detection loop — a node that stopped watching because
|
|
||||||
# its strobe failed would be the worst possible trade.
|
|
||||||
if produced and beacons:
|
|
||||||
try:
|
|
||||||
counters["beacon_fires"] += _fire_beacons(beacons, produced, now)
|
|
||||||
except Exception as e:
|
|
||||||
note("beacon", "beacon fire failed", e)
|
|
||||||
# Release latched beacons whose hold time has elapsed.
|
|
||||||
for b in beacons:
|
|
||||||
try:
|
|
||||||
b.tick(now)
|
|
||||||
except Exception as e:
|
|
||||||
note("beacon", "beacon tick failed", e)
|
|
||||||
try:
|
try:
|
||||||
if sync_every and i % sync_every == 0:
|
if sync_every and i % sync_every == 0:
|
||||||
cfg = client.fetch_config()
|
cfg = client.fetch_config()
|
||||||
if cfg is not None:
|
if cfg is not None:
|
||||||
for rt in runtimes:
|
for rt in runtimes:
|
||||||
rt.apply_zones(zones_for(cfg, rt))
|
rt.apply_zones(zones_for(cfg, rt))
|
||||||
# Thresholds too. The server has always SENT these; nothing ever applied
|
|
||||||
# them, so an administrator's retuning never reached the device.
|
|
||||||
thresholds = cfg.get("thresholds") if isinstance(cfg, dict) else None
|
|
||||||
if isinstance(thresholds, dict) and thresholds:
|
|
||||||
for rt in runtimes:
|
|
||||||
applier = getattr(rt, "apply_thresholds", None)
|
|
||||||
if applier is None:
|
|
||||||
continue
|
|
||||||
applied = applier(thresholds, log=lambda m: log(m, None))
|
|
||||||
counters["tuned"] += len(applied)
|
|
||||||
# Rebuild the local beacons from the published wiring. Doing it on sync
|
|
||||||
# (not only at boot) is what lets an administrator turn a site's strobe on
|
|
||||||
# without anyone walking to the node.
|
|
||||||
try:
|
|
||||||
rebuilt = build_beacons_fn(cfg if isinstance(cfg, dict) else {},
|
|
||||||
log=lambda m: log(m, None))
|
|
||||||
if _beacon_signature(rebuilt) != _beacon_signature(beacons):
|
|
||||||
for b in beacons:
|
|
||||||
b.release()
|
|
||||||
beacons = rebuilt
|
|
||||||
except Exception as e:
|
|
||||||
note("beacon", "beacon rebuild failed", e)
|
|
||||||
# Remember the config version so heartbeats can echo it back — the server's
|
# Remember the config version so heartbeats can echo it back — the server's
|
||||||
# config-drift indicator is meaningless until the device reports what it runs.
|
# config-drift indicator is meaningless until the device reports what it runs.
|
||||||
if isinstance(cfg, dict) and cfg.get("version") is not None:
|
if isinstance(cfg, dict) and cfg.get("version") is not None:
|
||||||
|
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
from edge.tracking import Track
|
|
||||||
from edge.geometry import torso_angle_deg, arm_elevation_deg, knee_flexion_deg, core_visible
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ErgoConfig:
|
|
||||||
"""자세 부담(인체공학) 노출 집계. REBA의 밴드 개념을 2D 단안 관절에 정직하게 축소한
|
|
||||||
것 — 정밀 REBA 점수가 아니라 '고위험 자세에 얼마나 오래 노출됐나'를 잰다."""
|
|
||||||
report_interval_s: float = 3600.0 # 카메라당 리포트 주기(노출이 있었던 경우만)
|
|
||||||
min_conf: float = 0.5
|
|
||||||
trunk_mod_deg: float = 20.0 # REBA trunk 밴드: 20~60° 중위험
|
|
||||||
trunk_high_deg: float = 60.0 # 60°+ 고위험
|
|
||||||
arm_high_deg: float = 90.0 # 어깨 위 거상
|
|
||||||
knee_flex_deg: float = 110.0 # 이보다 작은 내부각 = 깊은 웅크림
|
|
||||||
max_frame_gap_s: float = 2.0 # 검출 공백은 노출로 세지 않는다 (dt 상한)
|
|
||||||
|
|
||||||
|
|
||||||
class ErgoMonitor:
|
|
||||||
"""트랙별 프레임 dt로 자세 밴드 노출 시간을 누적하고, 주기마다 카메라 단위의 익명
|
|
||||||
합계를 리포트로 내보낸다. 개인 식별 없음: 트랙·좌표는 합산 과정에서 소거된다.
|
|
||||||
사고 상태(FALLING/FALLEN/NO_MOTION)의 트랙은 엔진이 걸러 넘긴다 — 쓰러진 사람의
|
|
||||||
90° 몸통은 인체공학 노출이 아니라 사고다."""
|
|
||||||
|
|
||||||
def __init__(self, cfg: ErgoConfig):
|
|
||||||
self.cfg = cfg
|
|
||||||
self._period_start: float | None = None
|
|
||||||
self._acc = self._zero()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _zero() -> dict:
|
|
||||||
return {"observed_s": 0.0, "trunk_mod_s": 0.0, "trunk_high_s": 0.0,
|
|
||||||
"arm_high_s": 0.0, "squat_s": 0.0}
|
|
||||||
|
|
||||||
def update(self, track: Track, now: float) -> None:
|
|
||||||
if self._period_start is None:
|
|
||||||
self._period_start = now
|
|
||||||
if len(track.frames) < 2:
|
|
||||||
return
|
|
||||||
dt = track.latest.t - track.frames[-2].t
|
|
||||||
if dt <= 0:
|
|
||||||
return
|
|
||||||
dt = min(dt, self.cfg.max_frame_gap_s)
|
|
||||||
kps = track.latest.keypoints
|
|
||||||
# 몸통 각도는 어깨·엉덩이 4점이 신뢰될 때만 — 저신뢰 프레임의 각도는 노이즈다.
|
|
||||||
if not core_visible(kps, self.cfg.min_conf):
|
|
||||||
return
|
|
||||||
self._acc["observed_s"] += dt
|
|
||||||
trunk = torso_angle_deg(kps)
|
|
||||||
if trunk >= self.cfg.trunk_high_deg:
|
|
||||||
self._acc["trunk_high_s"] += dt
|
|
||||||
elif trunk >= self.cfg.trunk_mod_deg:
|
|
||||||
self._acc["trunk_mod_s"] += dt
|
|
||||||
arm = arm_elevation_deg(kps, self.cfg.min_conf)
|
|
||||||
if arm is not None and arm >= self.cfg.arm_high_deg:
|
|
||||||
self._acc["arm_high_s"] += dt
|
|
||||||
knee = knee_flexion_deg(kps, self.cfg.min_conf)
|
|
||||||
if knee is not None and knee < self.cfg.knee_flex_deg:
|
|
||||||
self._acc["squat_s"] += dt
|
|
||||||
|
|
||||||
def report(self, now: float) -> dict | None:
|
|
||||||
"""주기가 찼고 실제 노출 관측이 있었으면 합계를 내보내고 다음 주기를 시작한다."""
|
|
||||||
if self._period_start is None or now - self._period_start < self.cfg.report_interval_s:
|
|
||||||
return None
|
|
||||||
acc, self._acc = self._acc, self._zero()
|
|
||||||
self._period_start = now
|
|
||||||
if acc["observed_s"] <= 0:
|
|
||||||
return None # 빈 주기: 사람이 없던 시간의 리포트는 소음이다
|
|
||||||
return {k: round(v, 1) for k, v in acc.items()}
|
|
||||||
|
|
@ -14,7 +14,6 @@ class FallConfig:
|
||||||
aspect_ratio: float = 1.25
|
aspect_ratio: float = 1.25
|
||||||
height_collapse: float = 0.35 # baseline 대비 남은 높이 비율(이 값 미만이면 급붕괴)
|
height_collapse: float = 0.35 # baseline 대비 남은 높이 비율(이 값 미만이면 급붕괴)
|
||||||
prone_confirm_s: float = 3.0
|
prone_confirm_s: float = 3.0
|
||||||
falling_confirm_s: float = 1.0 # STANDING→FALLING에 요구되는 지속 prone (P2-7)
|
|
||||||
no_motion_s: float = 10.0
|
no_motion_s: float = 10.0
|
||||||
motion_thr: float = 0.25 # per-second normalized joint speed (units/s); MOTION_THR env
|
motion_thr: float = 0.25 # per-second normalized joint speed (units/s); MOTION_THR env
|
||||||
min_conf: float = 0.5
|
min_conf: float = 0.5
|
||||||
|
|
@ -95,13 +94,7 @@ class FallDetector:
|
||||||
st.not_prone_since = now
|
st.not_prone_since = now
|
||||||
return None, {}
|
return None, {}
|
||||||
if now - st.not_prone_since >= self.cfg.recover_grace_s:
|
if now - st.not_prone_since >= self.cfg.recover_grace_s:
|
||||||
# FALLING에서 확정(FALLEN) 전에 회복 = 니어미스. 사고 알람은 아니지만
|
|
||||||
# 히트맵/통계의 원료라 신호로 구분해 올린다 (엔진이 INFO로 발보).
|
|
||||||
was_near_miss = not st.fall_emitted
|
|
||||||
prone_s = (st.not_prone_since - st.prone_since) if st.prone_since is not None else 0.0
|
|
||||||
self._states[track.track_id] = _State(baseline_height=bbox.height)
|
self._states[track.track_id] = _State(baseline_height=bbox.height)
|
||||||
if was_near_miss:
|
|
||||||
return "near_fall", {"prone_s": round(prone_s, 1)}
|
|
||||||
return "recover", {}
|
return "recover", {}
|
||||||
return None, {}
|
return None, {}
|
||||||
st.prone_since = None
|
st.prone_since = None
|
||||||
|
|
@ -111,9 +104,6 @@ class FallDetector:
|
||||||
st.not_prone_since = None
|
st.not_prone_since = None
|
||||||
if st.prone_since is None:
|
if st.prone_since is None:
|
||||||
st.prone_since = now
|
st.prone_since = now
|
||||||
# FALLING은 위험 상태다: 이 상태로 트랙이 사라지면 ghost가 CRITICAL을 무장한다.
|
|
||||||
# 단 한 프레임의 prone(굽힘·bbox 튐)으로 들어가지 않도록 지속 prone을 요구한다.
|
|
||||||
if st.name == "STANDING" and now - st.prone_since >= self.cfg.falling_confirm_s:
|
|
||||||
st.name = "FALLING"
|
st.name = "FALLING"
|
||||||
if now - st.prone_since >= self.cfg.prone_confirm_s and not st.fall_emitted:
|
if now - st.prone_since >= self.cfg.prone_confirm_s and not st.fall_emitted:
|
||||||
st.name = "FALLEN"
|
st.name = "FALLEN"
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,6 @@ _DANGEROUS = {"FALLING", "FALLEN", "NO_MOTION"}
|
||||||
@dataclass
|
@dataclass
|
||||||
class GhostConfig:
|
class GhostConfig:
|
||||||
reappear_s: float = 10.0
|
reappear_s: float = 10.0
|
||||||
# 높이 무장의 유효 기간. 무장에 시간 제한이 없으면 발판에 잠깐 올라갔던 사람이 몇 분 뒤
|
|
||||||
# 그냥 걸어 나가도 최상위 추락 경보가 된다.
|
|
||||||
height_arm_ttl_s: float = 20.0
|
|
||||||
reappear_dist: float = 1.0 # bbox 높이 배수
|
reappear_dist: float = 1.0 # bbox 높이 배수
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -18,63 +15,28 @@ class GhostIncidentDetector:
|
||||||
def __init__(self, cfg: GhostConfig):
|
def __init__(self, cfg: GhostConfig):
|
||||||
self.cfg = cfg
|
self.cfg = cfg
|
||||||
self._states: dict[int, str] = {}
|
self._states: dict[int, str] = {}
|
||||||
# 높이 기반 무장: 위험 자세가 아니어도 '떨어지는 중'이거나 '고소 구역 안'이면
|
|
||||||
# 소실 시 추락 후보로 잡는다. 발판에 서 있다가 화각 밖으로 떨어지는 사람은
|
|
||||||
# 소실 순간 STANDING 이라 _DANGEROUS 조건만으로는 영원히 안 걸린다.
|
|
||||||
self._height: dict[int, dict] = {}
|
|
||||||
# 후보: track_id -> (spatial(x,y), last_state, height, lost_at)
|
# 후보: track_id -> (spatial(x,y), last_state, height, lost_at)
|
||||||
self._candidates: dict[int, tuple] = {}
|
self._candidates: dict[int, tuple] = {}
|
||||||
|
|
||||||
def note_state(self, track_id: int, state: str) -> None:
|
def note_state(self, track_id: int, state: str) -> None:
|
||||||
self._states[track_id] = state
|
self._states[track_id] = state
|
||||||
|
|
||||||
def 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"
|
|
||||||
elif elevated and "armed_by" not in info:
|
|
||||||
info["armed_by"] = "zone"
|
|
||||||
if elevated:
|
|
||||||
info["elevated_zone"] = True
|
|
||||||
|
|
||||||
def on_lost(self, lost_tracks: list[Track], now: float) -> None:
|
def on_lost(self, lost_tracks: list[Track], now: float) -> None:
|
||||||
for tr in lost_tracks:
|
for tr in lost_tracks:
|
||||||
state = self._states.get(tr.track_id, "STANDING")
|
state = self._states.get(tr.track_id, "STANDING")
|
||||||
height = self._height.get(tr.track_id)
|
if state in _DANGEROUS:
|
||||||
# 오래된 무장은 무시한다 (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
|
b = tr.latest.bbox
|
||||||
# Snapshot the OTHER tracks active at loss time. Only a track that did NOT
|
# Snapshot the OTHER tracks active at loss time. Only a track that did NOT
|
||||||
# exist then can be the victim re-appearing (a re-acquired track gets a new
|
# exist then can be the victim re-appearing (a re-acquired track gets a new
|
||||||
# id); a track already present is a bystander/colleague and must not cancel
|
# id); a track already present is a bystander/colleague and must not cancel
|
||||||
# the incident by walking over the loss point.
|
# the incident by walking over the loss point.
|
||||||
bystanders = set(self._states) - {tr.track_id}
|
bystanders = set(self._states) - {tr.track_id}
|
||||||
self._candidates[tr.track_id] = ((b.cx, b.cy), state, b.height, now,
|
self._candidates[tr.track_id] = ((b.cx, b.cy), state, b.height, now, bystanders)
|
||||||
bystanders, height)
|
|
||||||
self._states.pop(tr.track_id, None)
|
self._states.pop(tr.track_id, None)
|
||||||
self._height.pop(tr.track_id, None)
|
|
||||||
|
|
||||||
def update(self, active_tracks: list[Track], now: float) -> list[dict]:
|
def update(self, active_tracks: list[Track], now: float) -> list[dict]:
|
||||||
# 인근 재출현이면 후보 취소 — 단, 손실 시점에 없던 새 트랙만 (진짜 재출현).
|
# 인근 재출현이면 후보 취소 — 단, 손실 시점에 없던 새 트랙만 (진짜 재출현).
|
||||||
for tid, (spatial, _state, height, _lost_at, bystanders, _h) in list(self._candidates.items()):
|
for tid, (spatial, _state, height, _lost_at, bystanders) in list(self._candidates.items()):
|
||||||
for tr in active_tracks:
|
for tr in active_tracks:
|
||||||
if tr.track_id in bystanders:
|
if tr.track_id in bystanders:
|
||||||
continue # a colleague who was already here — not the reappearing victim
|
continue # a colleague who was already here — not the reappearing victim
|
||||||
|
|
@ -85,14 +47,9 @@ class GhostIncidentDetector:
|
||||||
break
|
break
|
||||||
# 대기 초과 방출
|
# 대기 초과 방출
|
||||||
out = []
|
out = []
|
||||||
for tid, (spatial, state, _height, lost_at, _by, hinfo) in list(self._candidates.items()):
|
for tid, (spatial, state, _height, lost_at, _by) in list(self._candidates.items()):
|
||||||
if now - lost_at >= self.cfg.reappear_s:
|
if now - lost_at >= self.cfg.reappear_s:
|
||||||
rec = {"track_id": tid, "spatial": spatial, "last_state": state,
|
out.append({"track_id": tid, "spatial": spatial, "last_state": state,
|
||||||
"waited_s": round(now - lost_at, 1)}
|
"waited_s": round(now - lost_at, 1)})
|
||||||
if hinfo:
|
|
||||||
# 추락 후보였다 — 엔진이 ghost 가 아니라 fall_from_height 로 올린다.
|
|
||||||
rec["height_fall"] = True
|
|
||||||
rec.update(hinfo)
|
|
||||||
out.append(rec)
|
|
||||||
self._candidates.pop(tid, None)
|
self._candidates.pop(tid, None)
|
||||||
return out
|
return out
|
||||||
|
|
|
||||||
|
|
@ -1,127 +0,0 @@
|
||||||
"""추락(높이에서 떨어짐) 판정의 1차 신호 — 급격한 하강.
|
|
||||||
|
|
||||||
WHY THIS EXISTS
|
|
||||||
이 제품이 잡고 있던 것은 같은 높이에서의 **넘어짐**이다. 그런데 산업재해 사망 1위 유형은
|
|
||||||
**떨어짐**이고, 둘은 다른 사건이다. GhostIncidentDetector 는 트랙이 이미 위험 자세일 때만
|
|
||||||
무장하므로(_DANGEROUS), 발판 위에 서 있다가 떨어지는 사람은 소실 순간 STANDING 이라
|
|
||||||
아무것도 걸리지 않는다. 화각 밖으로 떨어지면 넘어진 자세를 볼 기회조차 없다.
|
|
||||||
|
|
||||||
WHY THE FOOT LINE (y2) AND NOT THE CENTRE
|
|
||||||
이 파일에서 가장 중요한 판단이다.
|
|
||||||
|
|
||||||
쭈그려 앉기 → 중심(cy)은 내려가지만 **발 밑선(y2)은 그 자리**다. 높이만 줄어든다.
|
|
||||||
계단 내려가기 → 둘 다 천천히 내려간다.
|
|
||||||
카메라 쪽 접근 → 둘 다 내려가지만 **사람이 커진다**.
|
|
||||||
추락 → 발 밑선이 통째로, 빠르게 내려간다.
|
|
||||||
|
|
||||||
중심으로 판정하면 쭈그려 앉아 작업하는 사람이 전부 추락이 된다. 원근 접근은 y2 도
|
|
||||||
내려가므로 높이 증가 가드로 따로 막는다.
|
|
||||||
|
|
||||||
WHAT THIS DOES NOT DO
|
|
||||||
이 감시자는 이벤트를 만들지 않는다. "이 트랙이 방금 떨어졌다"는 사실만 들고 있다가
|
|
||||||
EventEngine 이 물어보면 답한다. 실제 발보는 두 갈래로 갈린다:
|
|
||||||
· 프레임 안에서 넘어짐이 확정되면 -> 유형을 fall 대신 fall_from_height 로 승격
|
|
||||||
· 화각 밖으로 사라지면 -> GhostIncidentDetector 가 무장해 발보
|
|
||||||
한 번의 추락이 두 건으로 올라가지 않게 하려는 분리다.
|
|
||||||
|
|
||||||
Track.frames 가 90프레임(약 7.5초) 이력을 들고 있어 별도 버퍼가 필요 없다.
|
|
||||||
"""
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
from edge.tracking import Track
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class DescentConfig:
|
|
||||||
# 평소 키의 몇 배를 내려가야 추락으로 보는가. 1.2배 ≈ 2m — 계단 한 층은 못 미친다.
|
|
||||||
drop_ratio: float = 1.2
|
|
||||||
# 그 하강이 이 시간 안에 일어나야 한다. 3m 자유낙하가 약 0.78초이므로 1.2초면 담긴다.
|
|
||||||
drop_window_s: float = 1.2
|
|
||||||
# 구간 동안 사람이 이 배수 이상 커졌으면 카메라 쪽으로 걸어온 것이다.
|
|
||||||
grow_guard: float = 1.35
|
|
||||||
# 하강과 착지를 같은 사건으로 묶는 허용 오차.
|
|
||||||
#
|
|
||||||
# 이 값을 "하강 후 now 까지"로 재면 안 된다. 넘어짐은 prone 이 prone_confirm_s(기본 3초)
|
|
||||||
# 지속돼야 확정되므로, 확정 시점에는 하강으로부터 항상 3초 넘게 지나 있다 — 기본값끼리
|
|
||||||
# 부딪혀 승격이 영원히 일어나지 않았다. 착지(prone 시작) 시각과 비교해야 한다.
|
|
||||||
link_s: float = 3.0
|
|
||||||
# 무장 유효 기간. 하강 한 번이 트랙의 남은 평생을 무장 상태로 두면, 몇 분 뒤 그냥
|
|
||||||
# 걸어 나간 사람이 최상위 추락 경보가 된다.
|
|
||||||
arm_ttl_s: float = 20.0
|
|
||||||
|
|
||||||
|
|
||||||
class DescentWatch:
|
|
||||||
def __init__(self, cfg: DescentConfig):
|
|
||||||
self.cfg = cfg
|
|
||||||
self._seen: dict[int, tuple[float, dict]] = {} # track_id -> (감지 시각, 상세)
|
|
||||||
|
|
||||||
def forget(self, track_id: int) -> None:
|
|
||||||
self._seen.pop(track_id, None)
|
|
||||||
|
|
||||||
def armed_at(self, track_id: int) -> float | None:
|
|
||||||
"""이 트랙의 하강이 확정된 시각. 없으면 None."""
|
|
||||||
hit = self._seen.get(track_id)
|
|
||||||
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)
|
|
||||||
return dict(hit[1]) if hit is not None else None
|
|
||||||
|
|
||||||
def update(self, track: Track, now: float) -> dict | None:
|
|
||||||
"""이번 틱에 하강이 확정되면 상세를 돌려준다. 같은 하강은 한 번만 확정한다."""
|
|
||||||
frames = list(track.frames)
|
|
||||||
if len(frames) < 2:
|
|
||||||
return None
|
|
||||||
if self.armed(track.track_id, now):
|
|
||||||
return None # 이미 무장돼 있다 — 같은 추락을 매 틱 다시 확정하지 않는다
|
|
||||||
|
|
||||||
last = frames[-1]
|
|
||||||
# 창 안에서 가장 오래된 프레임을 기준으로 삼는다. 그보다 앞은 다른 사건이다.
|
|
||||||
start = None
|
|
||||||
for f in frames:
|
|
||||||
if last.t - f.t <= self.cfg.drop_window_s:
|
|
||||||
start = f
|
|
||||||
break
|
|
||||||
if start is None or start is last:
|
|
||||||
return None
|
|
||||||
|
|
||||||
baseline = start.bbox.height
|
|
||||||
if baseline <= 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
drop = (last.bbox.y2 - start.bbox.y2) / baseline
|
|
||||||
if drop < self.cfg.drop_ratio:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# 원근 접근 기각: 떨어지는 사람의 겉보기 크기는 그렇게 커지지 않는다.
|
|
||||||
if last.bbox.height > baseline * self.cfg.grow_guard:
|
|
||||||
return None
|
|
||||||
|
|
||||||
info = {
|
|
||||||
"drop_ratio": round(drop, 2),
|
|
||||||
"drop_s": round(last.t - start.t, 2),
|
|
||||||
"from_y2": round(start.bbox.y2, 3),
|
|
||||||
"to_y2": round(last.bbox.y2, 3),
|
|
||||||
}
|
|
||||||
self._seen[track.track_id] = (now, info)
|
|
||||||
return info
|
|
||||||
|
|
@ -1,151 +0,0 @@
|
||||||
"""카메라 무결성 감시 — 화각 이동 · 렌즈 가림 · 조도 저하.
|
|
||||||
|
|
||||||
WHY THIS EXISTS
|
|
||||||
존(위험구역) 판정은 "카메라가 어제와 같은 곳을 보고 있다"는 전제 위에 통째로 서 있다.
|
|
||||||
청소하다 삼각대를 스치거나, 지게차가 브래킷을 치거나, 누가 각도를 조금 돌리면 제한구역
|
|
||||||
폴리곤은 엉뚱한 바닥을 가리키게 된다. 그런데 시스템은 아무 오류도 내지 않는다 — 프레임은
|
|
||||||
계속 들어오고, 사람은 계속 검출되고, 대시보드는 초록불이다. 침입 감지가 죽었다는 사실을
|
|
||||||
누군가 사고가 난 뒤에야 알게 되는 것이 이 제품의 가장 조용한 실패 모드였다.
|
|
||||||
|
|
||||||
접근
|
|
||||||
무거운 특징점 매칭 대신 저해상도 그레이스케일 요약본을 기준으로 삼는다. Pi 에서 매 프레임
|
|
||||||
돌아야 하고, 우리가 감지하려는 것은 미세한 흔들림이 아니라 "다른 곳을 보고 있다" 수준의
|
|
||||||
변화이기 때문에 이 해상도로 충분하다. 세 신호를 각각 다른 물리량으로 잡는다:
|
|
||||||
|
|
||||||
moved — 기준 대비 평균 절대 차이(MAD)가 임계 초과. 각도가 바뀌면 화면 전체가 바뀐다.
|
|
||||||
obstructed — 프레임 자체의 분산 급락. 렌즈를 천으로 덮으면 평탄한 회색이 된다.
|
|
||||||
low_light — 평균 밝기 하강. 야간·조명 고장 시 검출력이 떨어졌음을 스스로 신고한다.
|
|
||||||
|
|
||||||
사람이 화면을 지나가도 MAD 는 오른다. 그래서 **지속 시간**을 요구한다: 순간 변화가 아니라
|
|
||||||
sustain_s 이상 유지될 때만 확정한다. 사람은 지나가고 화각 변화는 남는다는 성질을 쓴다.
|
|
||||||
|
|
||||||
기준 프레임
|
|
||||||
부팅 후 warmup_s 동안 조용히 관찰하다가 그 시점의 요약본을 기준으로 잡는다. 부팅 직후
|
|
||||||
한 장을 그냥 쓰면 마침 그때 사람이 서 있었을 경우 그 사람이 영원히 배경이 된다.
|
|
||||||
확정 이벤트를 낸 뒤에는 기준을 현재 화면으로 재설정한다 — 재설정하지 않으면 옮겨진
|
|
||||||
카메라가 cooldown 마다 같은 이벤트를 영원히 재발행한다.
|
|
||||||
|
|
||||||
의존성 없음: numpy 만 쓴다 (OpenCV 도 불필요). 폐쇄망 번들에 새 휠을 추가하지 않는다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class IntegrityConfig:
|
|
||||||
enabled: bool = True
|
|
||||||
# 기준 프레임을 잡기까지의 관찰 시간. 이 사이 화면에 사람이 있어도 평균이 희석된다.
|
|
||||||
warmup_s: float = 20.0
|
|
||||||
# 요약본 해상도. 32x24 면 사람 하나가 화면의 몇 퍼센트인지 구분하기엔 충분하고,
|
|
||||||
# 조명 깜빡임 같은 화소 단위 잡음은 자연히 뭉개진다.
|
|
||||||
grid_w: int = 32
|
|
||||||
grid_h: int = 24
|
|
||||||
# 0..255 스케일의 평균 절대 차이. 30 은 "화면 절반 이상이 다른 밝기" 수준으로,
|
|
||||||
# 사람 한 명이 지나가는 것(보통 5~15)과 명확히 갈린다.
|
|
||||||
moved_mad: float = 30.0
|
|
||||||
# 화각 변화로 확정하기까지 필요한 지속 시간. 지나가는 사람은 이 시간을 못 채운다.
|
|
||||||
sustain_s: float = 8.0
|
|
||||||
# 렌즈 가림: 프레임 분산이 이 밑이면 사실상 단색 화면이다.
|
|
||||||
obstructed_var: float = 25.0
|
|
||||||
obstructed_sustain_s: float = 5.0
|
|
||||||
# 조도: 평균 밝기가 이 밑이면 포즈 추정 신뢰도가 급격히 떨어진다.
|
|
||||||
low_light_mean: float = 40.0
|
|
||||||
low_light_sustain_s: float = 60.0
|
|
||||||
# 확정 후 같은 유형을 다시 낼 때까지의 최소 간격 (엔진 cooldown 과 별개의 2차 방어).
|
|
||||||
reemit_s: float = 900.0
|
|
||||||
|
|
||||||
|
|
||||||
class CameraIntegrityWatch:
|
|
||||||
"""프레임을 받아 (event_type, metrics) 목록을 돌려준다. 이벤트 생성은 엔진이 한다."""
|
|
||||||
|
|
||||||
def __init__(self, cfg: IntegrityConfig | None = None):
|
|
||||||
self.cfg = cfg or IntegrityConfig()
|
|
||||||
self._baseline: np.ndarray | None = None
|
|
||||||
self._first_seen: float | None = None
|
|
||||||
# 각 상태가 "연속으로 참이기 시작한 시각". None 이면 지금은 정상.
|
|
||||||
self._since: dict[str, float | None] = {"moved": None, "obstructed": None, "low_light": None}
|
|
||||||
self._last_emit: dict[str, float] = {}
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- helpers
|
|
||||||
def _summarize(self, frame) -> np.ndarray | None:
|
|
||||||
"""프레임 -> (grid_h, grid_w) float32 그레이스케일 요약본."""
|
|
||||||
if frame is None:
|
|
||||||
return None
|
|
||||||
a = np.asarray(frame)
|
|
||||||
if a.ndim == 3:
|
|
||||||
# BGR/RGB 어느 쪽이든 평균 그레이면 충분하다 (우리가 재는 것은 구조 변화이지 색이 아니다).
|
|
||||||
a = a.mean(axis=2)
|
|
||||||
elif a.ndim != 2:
|
|
||||||
return None
|
|
||||||
if a.size == 0:
|
|
||||||
return None
|
|
||||||
h, w = a.shape[:2]
|
|
||||||
gh, gw = self.cfg.grid_h, self.cfg.grid_w
|
|
||||||
if h < gh or w < gw:
|
|
||||||
return None
|
|
||||||
# 정수 인덱싱으로 다운샘플: 보간 라이브러리 없이 균등 격자를 뽑는다.
|
|
||||||
ys = (np.linspace(0, h - 1, gh)).astype(np.int32)
|
|
||||||
xs = (np.linspace(0, w - 1, gw)).astype(np.int32)
|
|
||||||
return a[np.ix_(ys, xs)].astype(np.float32)
|
|
||||||
|
|
||||||
def _hold(self, key: str, active: bool, now: float, need_s: float) -> bool:
|
|
||||||
"""상태가 need_s 이상 연속 유지되면 True 를 한 번 돌려준다."""
|
|
||||||
if not active:
|
|
||||||
self._since[key] = None
|
|
||||||
return False
|
|
||||||
if self._since[key] is None:
|
|
||||||
self._since[key] = now
|
|
||||||
return False
|
|
||||||
if now - self._since[key] < need_s:
|
|
||||||
return False
|
|
||||||
last = self._last_emit.get(key)
|
|
||||||
if last is not None and now - last < self.cfg.reemit_s:
|
|
||||||
return False
|
|
||||||
self._last_emit[key] = now
|
|
||||||
return True
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- main
|
|
||||||
def observe(self, frame, now: float) -> list[tuple[str, dict]]:
|
|
||||||
"""@return [(event_type_value, metrics), ...] — 보통 빈 리스트."""
|
|
||||||
if not self.cfg.enabled:
|
|
||||||
return []
|
|
||||||
summary = self._summarize(frame)
|
|
||||||
if summary is None:
|
|
||||||
return []
|
|
||||||
|
|
||||||
if self._first_seen is None:
|
|
||||||
self._first_seen = now
|
|
||||||
mean = float(summary.mean())
|
|
||||||
var = float(summary.var())
|
|
||||||
|
|
||||||
out: list[tuple[str, dict]] = []
|
|
||||||
|
|
||||||
# 조도와 가림은 기준 프레임이 없어도 판정할 수 있다 — 프레임 자체의 성질이다.
|
|
||||||
if self._hold("obstructed", var < self.cfg.obstructed_var, now, self.cfg.obstructed_sustain_s):
|
|
||||||
out.append(("camera_obstructed", {"variance": round(var, 2), "mean_luma": round(mean, 1)}))
|
|
||||||
if self._hold("low_light", mean < self.cfg.low_light_mean, now, self.cfg.low_light_sustain_s):
|
|
||||||
out.append(("low_light", {"mean_luma": round(mean, 1)}))
|
|
||||||
|
|
||||||
# 화각 이동은 기준이 있어야 한다.
|
|
||||||
if self._baseline is None:
|
|
||||||
if now - self._first_seen >= self.cfg.warmup_s:
|
|
||||||
self._baseline = summary
|
|
||||||
return out
|
|
||||||
|
|
||||||
mad = float(np.abs(summary - self._baseline).mean())
|
|
||||||
if self._hold("moved", mad > self.cfg.moved_mad, now, self.cfg.sustain_s):
|
|
||||||
out.append(("camera_moved", {"mad": round(mad, 1), "threshold": self.cfg.moved_mad}))
|
|
||||||
# 새 화각을 기준으로 승격한다. 하지 않으면 옮겨진 카메라가 영원히 같은 경보를 낸다.
|
|
||||||
# (존은 여전히 무효 상태다 — 그래서 이 이벤트가 HIGH 이고, 사람이 존을 다시 그려야 한다.)
|
|
||||||
self._baseline = summary
|
|
||||||
self._since["moved"] = None
|
|
||||||
|
|
||||||
return out
|
|
||||||
|
|
||||||
def reset_baseline(self) -> None:
|
|
||||||
"""존을 다시 그린 뒤 등, 현재 화각을 정상으로 재선언할 때."""
|
|
||||||
self._baseline = None
|
|
||||||
self._first_seen = None
|
|
||||||
self._since = {k: None for k in self._since}
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class OccupancyConfig:
|
|
||||||
lone_confirm_s: float = 30.0 # 단독 작업이 이 시간 지속돼야 발보 (동료의 잠깐 이탈 흡수)
|
|
||||||
over_confirm_s: float = 10.0 # 정원 초과 지속 요구 (경계 통과 순간의 겹침 흡수)
|
|
||||||
dwell_absence_grace_s: float = 5.0 # 이 시간 안의 미검출은 체류 연속으로 간주 (검출 드롭아웃 흡수)
|
|
||||||
|
|
||||||
|
|
||||||
class OccupancyWatchdog:
|
|
||||||
"""존 규칙(2인 1조 · 정원 · 체류 상한) 감시.
|
|
||||||
|
|
||||||
존 멤버십은 ZoneIntrusionDetector가 이미 계산한 것을 엔진이 존별 인원으로 뒤집어
|
|
||||||
넘겨준다 — 여기서는 기하 계산이 없다. 규칙은 Zone.rules(대시보드 존 편집기에서
|
|
||||||
설정, /api/config로 동기)에서 읽으므로 존 목록 교체(apply_zones)에 자동으로 따라간다.
|
|
||||||
|
|
||||||
발보는 조건이 해소될 때까지 존(또는 트랙·존)당 1회 — 재발보는 엔진 쿨다운이 아니라
|
|
||||||
조건 해소 후 재충족으로만 일어난다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, cfg: OccupancyConfig):
|
|
||||||
self.cfg = cfg
|
|
||||||
self._lone_since: dict[str, float] = {}
|
|
||||||
self._lone_fired: set[str] = set()
|
|
||||||
self._over_since: dict[str, float] = {}
|
|
||||||
self._over_fired: set[str] = set()
|
|
||||||
self._dwell: dict[tuple[int, str], list] = {} # (tid, zone_id) -> [since, last_seen]
|
|
||||||
self._dwell_fired: set[tuple[int, str]] = set()
|
|
||||||
|
|
||||||
def forget(self, track_id: int) -> None:
|
|
||||||
self._dwell = {k: v for k, v in self._dwell.items() if k[0] != track_id}
|
|
||||||
self._dwell_fired = {k for k in self._dwell_fired if k[0] != track_id}
|
|
||||||
|
|
||||||
def update(self, zones: list, zone_tracks: dict, now: float) -> list[dict]:
|
|
||||||
out: list[dict] = []
|
|
||||||
known: set[str] = set()
|
|
||||||
for zone in zones:
|
|
||||||
zid = zone.zone_id
|
|
||||||
known.add(zid)
|
|
||||||
rules = zone.rules or {}
|
|
||||||
tids = zone_tracks.get(zid, set())
|
|
||||||
n = len(tids)
|
|
||||||
|
|
||||||
# 2인 1조: 0 < 인원 < 최소 인원이 지속되면 발보. 빈 존은 위반이 아니다.
|
|
||||||
min_workers = rules.get("min_workers")
|
|
||||||
if min_workers and 0 < n < min_workers:
|
|
||||||
start = self._lone_since.setdefault(zid, now)
|
|
||||||
if now - start >= self.cfg.lone_confirm_s and zid not in self._lone_fired:
|
|
||||||
self._lone_fired.add(zid)
|
|
||||||
out.append({"kind": "lone_worker", "zone_id": zid,
|
|
||||||
"track_id": next(iter(tids)) if n == 1 else None,
|
|
||||||
"metrics": {"workers": n, "required": min_workers,
|
|
||||||
"alone_s": round(now - start, 1)}})
|
|
||||||
else:
|
|
||||||
self._lone_since.pop(zid, None)
|
|
||||||
self._lone_fired.discard(zid)
|
|
||||||
|
|
||||||
# 정원 초과. `is not None` 이어야 한다 — 0 은 falsy 라 `if max_workers` 로 쓰면
|
|
||||||
# "아무도 있으면 안 되는 구역"(크레인 짐 아래·굴착기 선회 반경 = 깔림 사고의 전형)이
|
|
||||||
# 화면에는 규칙으로 저장돼 있는데 감지기는 통째로 꺼진 상태가 된다.
|
|
||||||
# 키가 아예 없는 것(제한 없음)과 0(아무도 안 됨)은 다른 뜻이다.
|
|
||||||
max_workers = rules.get("max_workers")
|
|
||||||
if max_workers is not None and n > max_workers:
|
|
||||||
start = self._over_since.setdefault(zid, now)
|
|
||||||
if now - start >= self.cfg.over_confirm_s and zid not in self._over_fired:
|
|
||||||
self._over_fired.add(zid)
|
|
||||||
out.append({"kind": "overcapacity", "zone_id": zid, "track_id": None,
|
|
||||||
"metrics": {"workers": n, "limit": max_workers}})
|
|
||||||
else:
|
|
||||||
self._over_since.pop(zid, None)
|
|
||||||
self._over_fired.discard(zid)
|
|
||||||
|
|
||||||
# 체류 상한 (트랙별 연속 체류). 미검출 1~2틱(run_once의 stale 필터)으로 타이머가
|
|
||||||
# 리셋되면 10분 규칙이 사실상 무력화되므로, grace 안의 부재는 연속으로 간주한다.
|
|
||||||
max_dwell = rules.get("max_dwell_s")
|
|
||||||
if max_dwell:
|
|
||||||
for tid in tids:
|
|
||||||
rec = self._dwell.setdefault((tid, zid), [now, now])
|
|
||||||
rec[1] = now
|
|
||||||
if now - rec[0] >= max_dwell and (tid, zid) not in self._dwell_fired:
|
|
||||||
self._dwell_fired.add((tid, zid))
|
|
||||||
out.append({"kind": "overstay", "zone_id": zid, "track_id": tid,
|
|
||||||
"metrics": {"dwell_s": round(now - rec[0], 1),
|
|
||||||
"limit_s": max_dwell}})
|
|
||||||
|
|
||||||
# grace를 넘겨 존을 떠난(또는 트랙이 죽은) 체류 기록 정리 — 재진입은 새 체류다.
|
|
||||||
for key, rec in list(self._dwell.items()):
|
|
||||||
if now - rec[1] > self.cfg.dwell_absence_grace_s:
|
|
||||||
self._dwell.pop(key)
|
|
||||||
self._dwell_fired.discard(key)
|
|
||||||
# 삭제된 존의 잔여 상태 정리 (24/7 장치 메모리 누수 방지).
|
|
||||||
for d, fired in ((self._lone_since, self._lone_fired), (self._over_since, self._over_fired)):
|
|
||||||
for zid in list(d):
|
|
||||||
if zid not in known:
|
|
||||||
d.pop(zid, None)
|
|
||||||
fired.intersection_update(known)
|
|
||||||
return out
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
"""모델 대기 감지기 — 보호구 미착용 · 중장비 근접 · 화재/연기.
|
|
||||||
|
|
||||||
STATUS: 배선 완료, 모델 없음. 기본 비활성이며 이 상태에서는 어떤 이벤트도 발화하지 않는다.
|
|
||||||
|
|
||||||
왜 모델 없이 이 파일이 있는가
|
|
||||||
이 세 가지는 현장에서 가장 자주 요구되는 항목이고(특히 보호구), 나중에 급하게 끼워
|
|
||||||
넣으면 이벤트 계약·심각도·쿨다운·스냅샷 정책을 그때그때 즉흥적으로 정하게 된다. 계약을
|
|
||||||
지금 고정해 두면 모델이 확보되는 날 바꿀 것은 `Model` 구현 하나뿐이다.
|
|
||||||
|
|
||||||
무엇이 막고 있는가 (정직하게)
|
|
||||||
ultralytics(YOLO) 계열은 AGPL 이라 이 프로젝트에서 사용 금지다(CLAUDE.md). 따라서
|
|
||||||
- 보호구/중장비: AGPL 이 아닌 라이선스의 가중치 + Hailo HEF 컴파일이 선행되어야 한다.
|
|
||||||
- 화재/연기: 위와 같거나, 열화상 센서 같은 다른 감지 방식을 택해야 한다.
|
|
||||||
확보 전까지 `enabled=False` 이며, 켜더라도 `model=None` 이면 아무 일도 하지 않는다.
|
|
||||||
"곧 됩니다" 상태로 켜 두는 것이 가장 위험하다 — 현장은 감지되고 있다고 믿는다.
|
|
||||||
|
|
||||||
모델 인터페이스 (구현체가 만족해야 하는 계약)
|
|
||||||
detect(frame, persons) -> list[dict]
|
|
||||||
각 dict: {"kind": "ppe_missing"|"proximity"|"fire_smoke",
|
|
||||||
"confidence": float 0..1,
|
|
||||||
"track_id": int | None,
|
|
||||||
"spatial": (x, y) | None, # 0..1 정규화
|
|
||||||
"metrics": dict} # 유형별 부가 수치
|
|
||||||
프레임당 한 번 호출되며, 예외를 던지면 그 틱만 건너뛴다(파이프라인은 계속 돈다).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
|
|
||||||
|
|
||||||
_KINDS = ("ppe_missing", "proximity", "fire_smoke")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class PendingConfig:
|
|
||||||
# 기본 False. 모델이 없는 채로 켜면 "감지되고 있다"는 잘못된 믿음만 만든다.
|
|
||||||
enabled: bool = False
|
|
||||||
# 유형별 최소 신뢰도. 보호구는 오탐이 곧 현장 반발이라 높게 잡는다.
|
|
||||||
min_confidence: dict = field(default_factory=lambda: {
|
|
||||||
"ppe_missing": 0.80, "proximity": 0.70, "fire_smoke": 0.85,
|
|
||||||
})
|
|
||||||
# 유형별 재발행 억제(초). 엔진 쿨다운과 별개의 2차 방어 — 보호구 미착용은 한 사람이
|
|
||||||
# 한 시간을 그러고 있어도 사건 하나다.
|
|
||||||
reemit_s: dict = field(default_factory=lambda: {
|
|
||||||
"ppe_missing": 600.0, "proximity": 30.0, "fire_smoke": 60.0,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
class PendingDetectors:
|
|
||||||
"""모델이 주어지면 그 출력을 이벤트 후보로 정규화한다. 없으면 아무것도 하지 않는다."""
|
|
||||||
|
|
||||||
def __init__(self, cfg: PendingConfig | None = None, model=None, log=None):
|
|
||||||
self.cfg = cfg or PendingConfig()
|
|
||||||
self.model = model
|
|
||||||
self._log = log or (lambda m: None)
|
|
||||||
self._last: dict[tuple, float] = {}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def active(self) -> bool:
|
|
||||||
"""켜져 있고 실제로 판정할 수단이 있는가. 대시보드/기동 로그가 이것을 보고한다."""
|
|
||||||
return bool(self.cfg.enabled and self.model is not None)
|
|
||||||
|
|
||||||
def _allow(self, kind: str, track_id, now: float) -> bool:
|
|
||||||
key = (kind, track_id)
|
|
||||||
last = self._last.get(key)
|
|
||||||
if last is not None and now - last < float(self.cfg.reemit_s.get(kind, 60.0)):
|
|
||||||
return False
|
|
||||||
self._last[key] = now
|
|
||||||
return True
|
|
||||||
|
|
||||||
def forget(self, track_id: int) -> None:
|
|
||||||
self._last = {k: v for k, v in self._last.items() if k[1] != track_id}
|
|
||||||
|
|
||||||
def observe(self, frame, persons, now: float) -> list[tuple[str, dict, object, object]]:
|
|
||||||
"""@return [(event_type, metrics, track_id, spatial), ...] — 보통 빈 리스트.
|
|
||||||
|
|
||||||
모델이 없거나 비활성이면 항상 빈 리스트다. 그것이 정상 상태다.
|
|
||||||
"""
|
|
||||||
if not self.active:
|
|
||||||
return []
|
|
||||||
try:
|
|
||||||
raw = self.model.detect(frame, persons) or []
|
|
||||||
except Exception as e:
|
|
||||||
# 한 틱의 추론 실패로 넘어짐 감지까지 멈추면 안 된다.
|
|
||||||
self._log(f"pending detector model failed: {type(e).__name__}: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
out = []
|
|
||||||
for d in raw:
|
|
||||||
if not isinstance(d, dict):
|
|
||||||
continue
|
|
||||||
kind = d.get("kind")
|
|
||||||
if kind not in _KINDS:
|
|
||||||
continue
|
|
||||||
conf = float(d.get("confidence") or 0.0)
|
|
||||||
if conf < float(self.cfg.min_confidence.get(kind, 1.0)):
|
|
||||||
continue
|
|
||||||
track_id = d.get("track_id")
|
|
||||||
if not self._allow(kind, track_id, now):
|
|
||||||
continue
|
|
||||||
metrics = dict(d.get("metrics") or {})
|
|
||||||
metrics["confidence"] = round(conf, 3)
|
|
||||||
out.append((kind, metrics, track_id, d.get("spatial")))
|
|
||||||
return out
|
|
||||||
|
|
@ -25,9 +25,6 @@ class Zone:
|
||||||
zone_id: str
|
zone_id: str
|
||||||
level: str # "warning" | "restricted"
|
level: str # "warning" | "restricted"
|
||||||
polygon: list # list[tuple[float,float]]
|
polygon: list # list[tuple[float,float]]
|
||||||
# 선택적 존 규칙 (occupancy.py가 소비): {"min_workers": 2, "max_workers": 4,
|
|
||||||
# "max_dwell_s": 600} — 키가 없으면 그 규칙은 꺼진 것. None = 규칙 없음.
|
|
||||||
rules: dict | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -71,21 +68,6 @@ class ZoneIntrusionDetector:
|
||||||
fx, fy = fx / w, fy / h
|
fx, fy = fx / w, fy / h
|
||||||
return (fx, fy)
|
return (fx, fy)
|
||||||
|
|
||||||
def in_elevated_zone(self, track_id: int) -> bool:
|
|
||||||
"""이 트랙이 '고소' 표시된 구역 안에 있는가.
|
|
||||||
|
|
||||||
rules_json 의 `elevated: true` 한 키로 붙는다 — 스키마 변경이 없다. 운영자가 발판·
|
|
||||||
비계·개구부를 그려두면 추락 판정이 훨씬 정확해지지만, 구역이 없어도 하강 신호만으로
|
|
||||||
동작한다. 위험은 운영자가 선언하되, 선언이 없어도 최소한은 본다.
|
|
||||||
"""
|
|
||||||
active = self.active_zone_ids(track_id)
|
|
||||||
if not active:
|
|
||||||
return False
|
|
||||||
for z in self.zones:
|
|
||||||
if z.zone_id in active and (z.rules or {}).get("elevated"):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def active_zone_ids(self, track_id: int) -> set:
|
def active_zone_ids(self, track_id: int) -> set:
|
||||||
return {z for (tid, z) in self._inside_since if tid == track_id}
|
return {z for (tid, z) in self._inside_since if tid == track_id}
|
||||||
|
|
||||||
|
|
|
||||||
133
edge/engine.py
|
|
@ -6,9 +6,6 @@ from edge.detectors.fall import FallDetector
|
||||||
from edge.detectors.immobility import ImmobilityWatchdog
|
from edge.detectors.immobility import ImmobilityWatchdog
|
||||||
from edge.detectors.ghost import GhostIncidentDetector
|
from edge.detectors.ghost import GhostIncidentDetector
|
||||||
from edge.detectors.zone import ZoneIntrusionDetector
|
from edge.detectors.zone import ZoneIntrusionDetector
|
||||||
from edge.detectors.occupancy import OccupancyWatchdog, OccupancyConfig
|
|
||||||
from edge.detectors.ergonomics import ErgoMonitor, ErgoConfig
|
|
||||||
from edge.detectors.height import DescentWatch, DescentConfig
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -17,30 +14,16 @@ class EngineConfig:
|
||||||
cooldown_s: float = 30.0
|
cooldown_s: float = 30.0
|
||||||
|
|
||||||
|
|
||||||
_OCCUPANCY_TYPES = {"lone_worker": EventType.LONE_WORKER,
|
|
||||||
"overcapacity": EventType.OVERCAPACITY,
|
|
||||||
"overstay": EventType.OVERSTAY}
|
|
||||||
|
|
||||||
|
|
||||||
class EventEngine:
|
class EventEngine:
|
||||||
def __init__(self, fall: FallDetector, immobility: ImmobilityWatchdog,
|
def __init__(self, fall: FallDetector, immobility: ImmobilityWatchdog,
|
||||||
ghost: GhostIncidentDetector, zone: ZoneIntrusionDetector,
|
ghost: GhostIncidentDetector, zone: ZoneIntrusionDetector,
|
||||||
cfg: EngineConfig, occupancy: OccupancyWatchdog | None = None,
|
cfg: EngineConfig):
|
||||||
ergonomics: ErgoMonitor | None = None,
|
|
||||||
descent: DescentWatch | None = None):
|
|
||||||
self.fall = fall
|
self.fall = fall
|
||||||
self.immobility = immobility
|
self.immobility = immobility
|
||||||
self.ghost = ghost
|
self.ghost = ghost
|
||||||
self.zone = zone
|
self.zone = zone
|
||||||
# Keyword-with-default so the existing 5-arg constructors (runtime, tests) keep working.
|
|
||||||
self.occupancy = occupancy if occupancy is not None else OccupancyWatchdog(OccupancyConfig())
|
|
||||||
self.ergonomics = ergonomics if ergonomics is not None else ErgoMonitor(ErgoConfig())
|
|
||||||
self.descent = descent if descent is not None else DescentWatch(DescentConfig())
|
|
||||||
self.cfg = cfg
|
self.cfg = cfg
|
||||||
self._last_emit: dict[tuple, float] = {}
|
self._last_emit: dict[tuple, float] = {}
|
||||||
# 트랙별 마지막 구역. 소실 이벤트(ghost/추락)는 소실 후에 발보되므로
|
|
||||||
# 그때는 트랙이 없다 — 마지막으로 있던 구역을 들고 있어야 실을 수 있다.
|
|
||||||
self._last_zone: dict[int, str | None] = {}
|
|
||||||
|
|
||||||
def _allow(self, key: tuple, now: float) -> bool:
|
def _allow(self, key: tuple, now: float) -> bool:
|
||||||
last = self._last_emit.get(key)
|
last = self._last_emit.get(key)
|
||||||
|
|
@ -54,18 +37,13 @@ class EventEngine:
|
||||||
self.fall.forget(track_id)
|
self.fall.forget(track_id)
|
||||||
self.immobility.forget(track_id)
|
self.immobility.forget(track_id)
|
||||||
self.zone.forget(track_id)
|
self.zone.forget(track_id)
|
||||||
self.descent.forget(track_id)
|
|
||||||
self.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()
|
self._last_emit = {k: v for k, v in self._last_emit.items()
|
||||||
if not (len(k) >= 2 and k[1] == track_id)}
|
if not (len(k) >= 2 and k[1] == track_id)}
|
||||||
|
|
||||||
def _mk(self, etype: EventType, occurred_at: float, track_id=None, zone_id=None,
|
def _mk(self, etype: EventType, occurred_at: float, track_id=None, zone_id=None,
|
||||||
metrics=None, spatial=None) -> Event:
|
metrics=None, spatial=None) -> Event:
|
||||||
m = metrics or {}
|
m = metrics or {}
|
||||||
duration_s = m.get("immobility_s") or m.get("waited_s") or m.get("dwell_s") \
|
duration_s = m.get("immobility_s") or m.get("waited_s") or 0.0
|
||||||
or m.get("alone_s") or 0.0
|
|
||||||
return Event(etype, severity_for(etype), self.cfg.camera_id, track_id,
|
return Event(etype, severity_for(etype), self.cfg.camera_id, track_id,
|
||||||
zone_id, occurred_at, duration_s, m, spatial)
|
zone_id, occurred_at, duration_s, m, spatial)
|
||||||
|
|
||||||
|
|
@ -84,65 +62,19 @@ class EventEngine:
|
||||||
# frame_wh lets the zone detector normalize the foot to the dashboard's 0..1 zone
|
# 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.
|
# space (see ZoneIntrusionDetector._foot). run_once supplies the live frame size.
|
||||||
zone_hits = self.zone.update(tr, now, frame_wh)
|
zone_hits = self.zone.update(tr, now, frame_wh)
|
||||||
active_zones = self.zone.active_zone_ids(tr.track_id)
|
in_zone = bool(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)
|
|
||||||
|
|
||||||
# 하강 감시는 낙상 판정보다 먼저 — 같은 틱에 확정된 넘어짐을 승격할 수 있어야 한다.
|
|
||||||
descent = self.descent.update(tr, now)
|
|
||||||
elevated = self.zone.in_elevated_zone(tr.track_id)
|
|
||||||
if descent is not None or elevated:
|
|
||||||
# 위험 자세가 아니어도 ghost 를 무장시킨다. 발판에 서 있다가 화각 밖으로
|
|
||||||
# 떨어지는 사람은 소실 순간 STANDING 이라, 기존 무장 조건으로는 안 걸린다.
|
|
||||||
self.ghost.arm_height(tr.track_id, descent, elevated, now)
|
|
||||||
|
|
||||||
sig, meta = self.fall.update(tr, now)
|
sig, meta = self.fall.update(tr, now)
|
||||||
state = self.fall._states.get(tr.track_id)
|
state = self.fall._states.get(tr.track_id)
|
||||||
self.ghost.note_state(tr.track_id, state.name if state else "STANDING")
|
self.ghost.note_state(tr.track_id, state.name if state else "STANDING")
|
||||||
# 자세 부담 노출은 사고 상태가 아닌 트랙만 — 쓰러진 사람의 90° 몸통은
|
if sig == "fall" and self._allow((EventType.FALL, tr.track_id), now):
|
||||||
# 인체공학 노출이 아니라 이미 사고로 처리 중인 상태다.
|
events.append(self._mk(EventType.FALL, occurred_at, tr.track_id, metrics=meta))
|
||||||
if state is None or state.name == "STANDING":
|
|
||||||
self.ergonomics.update(tr, now)
|
|
||||||
if sig == "fall":
|
|
||||||
# 방금 떨어진 사람이 바닥에서 확정되면 그것은 넘어짐이 아니라 추락이다.
|
|
||||||
# 두 건으로 올리지 않고 유형만 올린다 — 한 번의 사고는 한 건이다.
|
|
||||||
# 착지(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)
|
|
||||||
d = self.descent.detail(tr.track_id)
|
|
||||||
if d:
|
|
||||||
m.update(d)
|
|
||||||
if elevated:
|
|
||||||
m["elevated_zone"] = True
|
|
||||||
events.append(self._mk(etype, occurred_at, tr.track_id,
|
|
||||||
metrics=m, spatial=foot, 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):
|
elif sig == "no_motion" and self._allow((EventType.NO_MOTION, tr.track_id), now):
|
||||||
events.append(self._mk(EventType.NO_MOTION, occurred_at, tr.track_id,
|
events.append(self._mk(EventType.NO_MOTION, occurred_at, tr.track_id, metrics=meta))
|
||||||
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,
|
|
||||||
metrics=meta, spatial=foot))
|
|
||||||
|
|
||||||
fired, imeta = self.immobility.update(tr, now, in_danger_zone=in_zone)
|
fired, imeta = self.immobility.update(tr, now, in_danger_zone=in_zone)
|
||||||
if fired and self._allow((EventType.IMMOBILITY, tr.track_id), now):
|
if fired and self._allow((EventType.IMMOBILITY, tr.track_id), now):
|
||||||
events.append(self._mk(EventType.IMMOBILITY, occurred_at, tr.track_id,
|
events.append(self._mk(EventType.IMMOBILITY, occurred_at, tr.track_id, metrics=imeta))
|
||||||
metrics=imeta, spatial=foot))
|
|
||||||
|
|
||||||
for hit in zone_hits:
|
for hit in zone_hits:
|
||||||
# 협착(pinch) is the highest-danger zone (machinery entrapment) and must emit
|
# 협착(pinch) is the highest-danger zone (machinery entrapment) and must emit
|
||||||
|
|
@ -152,30 +84,7 @@ class EventEngine:
|
||||||
"restricted": EventType.ZONE_RESTRICTED}.get(
|
"restricted": EventType.ZONE_RESTRICTED}.get(
|
||||||
hit["level"], EventType.ZONE_WARNING)
|
hit["level"], EventType.ZONE_WARNING)
|
||||||
if self._allow((etype, tr.track_id, hit["zone_id"]), now):
|
if self._allow((etype, tr.track_id, hit["zone_id"]), now):
|
||||||
events.append(self._mk(etype, occurred_at, tr.track_id,
|
events.append(self._mk(etype, occurred_at, tr.track_id, zone_id=hit["zone_id"]))
|
||||||
zone_id=hit["zone_id"], spatial=foot))
|
|
||||||
|
|
||||||
# 존 규칙(2인 1조 · 정원 · 체류): 위 per-track 루프에서 zone.update가 갱신한 멤버십을
|
|
||||||
# 존별 인원으로 뒤집어 감시자에 넘긴다. stale 트랙은 run_once가 이미 걸러냈으므로
|
|
||||||
# 여기 인원수는 이번 틱에 실제로 검출된 사람만 센다.
|
|
||||||
zone_tracks: dict[str, set[int]] = {}
|
|
||||||
for tr in active_tracks:
|
|
||||||
for zid in self.zone.active_zone_ids(tr.track_id):
|
|
||||||
zone_tracks.setdefault(zid, set()).add(tr.track_id)
|
|
||||||
for o in self.occupancy.update(self.zone.zones, zone_tracks, now):
|
|
||||||
etype = _OCCUPANCY_TYPES[o["kind"]]
|
|
||||||
# 쿨다운 키에 zone_id 포함: 존 A의 위반이 존 B의 발보를 막지 않는다. track_id가
|
|
||||||
# None인 존 단위 이벤트의 키는 forget()이 지우지 못하지만, 존 수에 비례하는
|
|
||||||
# 소량이라 누수로 보지 않는다 (ghost와 달리 키 공간이 유한).
|
|
||||||
if self._allow((etype, o["track_id"], o["zone_id"]), now):
|
|
||||||
events.append(self._mk(etype, occurred_at, track_id=o["track_id"],
|
|
||||||
zone_id=o["zone_id"], metrics=o["metrics"]))
|
|
||||||
|
|
||||||
# 자세 부담 주기 리포트 — 간격이 자체 게이트라 쿨다운(_allow)을 타지 않는다.
|
|
||||||
# 카메라 단위 익명 합계: track_id·spatial 없음 (개인 좌표를 남기지 않는다).
|
|
||||||
ergo = self.ergonomics.report(now)
|
|
||||||
if ergo is not None:
|
|
||||||
events.append(self._mk(EventType.ERGO_REPORT, occurred_at, metrics=ergo))
|
|
||||||
|
|
||||||
for g in self.ghost.update(active_tracks, now):
|
for g in self.ghost.update(active_tracks, now):
|
||||||
# No _allow cooldown for GHOST: ghost.update pops each incident from its candidates as it
|
# No _allow cooldown for GHOST: ghost.update pops each incident from its candidates as it
|
||||||
|
|
@ -183,28 +92,10 @@ class EventEngine:
|
||||||
# Routing it through _allow would only insert a permanent (GHOST, track_id) dedup key for
|
# Routing it through _allow would only insert a permanent (GHOST, track_id) dedup key for
|
||||||
# a track that was already forgotten reappear_s ago — a slow 24/7 memory leak that
|
# a track that was already forgotten reappear_s ago — a slow 24/7 memory leak that
|
||||||
# forget() can never reclaim (the track never appears in lost_tracks again).
|
# forget() can never reclaim (the track never appears in lost_tracks again).
|
||||||
# Ghost's loss point is camera-native pixels (bbox centre at loss time). Normalize to
|
events.append(self._mk(EventType.GHOST, occurred_at, track_id=g["track_id"],
|
||||||
# the same 0..1 space every other event's spatial uses so the heatmap can overlay them;
|
spatial=g["spatial"],
|
||||||
# frame_wh=None (unit tests) keeps raw coords, matching the zone detector's convention.
|
metrics={"last_state": g["last_state"],
|
||||||
gs = g["spatial"]
|
"waited_s": g["waited_s"]}))
|
||||||
if frame_wh and gs is not None:
|
|
||||||
w, h = frame_wh
|
|
||||||
if w > 0 and h > 0:
|
|
||||||
gs = (gs[0] / w, gs[1] / h)
|
|
||||||
gmeta = {"last_state": g["last_state"], "waited_s": g["waited_s"]}
|
|
||||||
# 추락 후보로 무장돼 있었으면 '위험 상태 소실'이 아니라 '추락'이다. 화각 밖으로
|
|
||||||
# 떨어진 사람에게는 이쪽이 유일한 발보 경로이므로 유형이 정확해야 한다.
|
|
||||||
if g.get("height_fall"):
|
|
||||||
for k in ("drop_ratio", "drop_s", "from_y2", "to_y2", "armed_by", "elevated_zone"):
|
|
||||||
if k in g:
|
|
||||||
gmeta[k] = g[k]
|
|
||||||
gmeta["exited_frame"] = True
|
|
||||||
etype = EventType.FALL_FROM_HEIGHT
|
|
||||||
else:
|
|
||||||
etype = EventType.GHOST
|
|
||||||
events.append(self._mk(etype, occurred_at, track_id=g["track_id"],
|
|
||||||
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.
|
# ghost.on_lost already consumed lost tracks' state above, so forgetting here is safe.
|
||||||
for tr in lost_tracks:
|
for tr in lost_tracks:
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ from enum import Enum
|
||||||
|
|
||||||
class EventType(str, Enum):
|
class EventType(str, Enum):
|
||||||
FALL = "fall"
|
FALL = "fall"
|
||||||
FALL_FROM_HEIGHT = "fall_from_height" # 높이에서 떨어짐 — 넘어짐과 다른 사건
|
|
||||||
NO_MOTION = "no_motion"
|
NO_MOTION = "no_motion"
|
||||||
IMMOBILITY = "immobility"
|
IMMOBILITY = "immobility"
|
||||||
GHOST = "ghost"
|
GHOST = "ghost"
|
||||||
|
|
@ -14,24 +13,6 @@ class EventType(str, Enum):
|
||||||
PINCH = "pinch"
|
PINCH = "pinch"
|
||||||
MONITORING_DEGRADED = "monitoring_degraded"
|
MONITORING_DEGRADED = "monitoring_degraded"
|
||||||
DEVICE_FAULT = "device_fault"
|
DEVICE_FAULT = "device_fault"
|
||||||
LONE_WORKER = "lone_worker" # 2인 1조 존에 단독 작업 지속
|
|
||||||
OVERCAPACITY = "overcapacity" # 존 정원 초과 지속
|
|
||||||
OVERSTAY = "overstay" # 존 체류 상한 초과 (트랙별)
|
|
||||||
NEAR_FALL = "near_fall" # 니어미스: FALLING까지 갔다가 확정 전 회복
|
|
||||||
ERGO_REPORT = "ergo_report" # 자세 부담 노출 주기 리포트 (카메라 단위, 익명)
|
|
||||||
# --- 감시 자체의 건전성 ---------------------------------------------------------
|
|
||||||
# 존 판정은 카메라 화각이 고정이라는 전제 위에 서 있다. 청소하다 삼각대를 건드리면
|
|
||||||
# 제한구역이 엉뚱한 곳을 가리키는데, 시스템은 계속 "정상"이라고 보고한다. 이 조용한
|
|
||||||
# 실패를 드러내는 것이 아래 세 유형이다.
|
|
||||||
CAMERA_MOVED = "camera_moved" # 배경 기준 프레임 대비 화각 이동
|
|
||||||
CAMERA_OBSTRUCTED = "camera_obstructed" # 렌즈 가림 (분산 급락)
|
|
||||||
LOW_LIGHT = "low_light" # 조도 저하로 검출력 하락
|
|
||||||
# --- 모델 확보 후 활성화되는 자리 -----------------------------------------------
|
|
||||||
# 감지기 배선과 이벤트 계약은 준비되어 있고, 추론 모델만 비어 있다. AGPL 아닌
|
|
||||||
# 가중치를 확보하기 전까지 엣지는 이 유형을 발화하지 않는다 (edge/detectors/pending.py).
|
|
||||||
PPE_MISSING = "ppe_missing" # 보호구(안전모·안전대) 미착용
|
|
||||||
PROXIMITY = "proximity" # 중장비-사람 근접
|
|
||||||
FIRE_SMOKE = "fire_smoke" # 화재·연기
|
|
||||||
|
|
||||||
|
|
||||||
class Severity(str, Enum):
|
class Severity(str, Enum):
|
||||||
|
|
@ -45,31 +26,12 @@ _SEVERITY = {
|
||||||
EventType.NO_MOTION: Severity.CRITICAL,
|
EventType.NO_MOTION: Severity.CRITICAL,
|
||||||
EventType.GHOST: Severity.CRITICAL,
|
EventType.GHOST: Severity.CRITICAL,
|
||||||
EventType.FALL: Severity.HIGH,
|
EventType.FALL: Severity.HIGH,
|
||||||
# 추락은 척추·두부 손상 가능성이 있어 함부로 옮기면 안 된다. 그 주의가 통보에
|
|
||||||
# 실리려면 넘어짐(HIGH)과 등급이 갈라져야 한다.
|
|
||||||
EventType.FALL_FROM_HEIGHT: Severity.CRITICAL,
|
|
||||||
EventType.ZONE_RESTRICTED: Severity.HIGH,
|
EventType.ZONE_RESTRICTED: Severity.HIGH,
|
||||||
EventType.PINCH: Severity.HIGH,
|
EventType.PINCH: Severity.HIGH,
|
||||||
EventType.IMMOBILITY: Severity.WARNING,
|
EventType.IMMOBILITY: Severity.WARNING,
|
||||||
EventType.ZONE_WARNING: Severity.WARNING,
|
EventType.ZONE_WARNING: Severity.WARNING,
|
||||||
EventType.MONITORING_DEGRADED: Severity.WARNING,
|
EventType.MONITORING_DEGRADED: Severity.WARNING,
|
||||||
EventType.DEVICE_FAULT: Severity.WARNING,
|
EventType.DEVICE_FAULT: Severity.WARNING,
|
||||||
# 단독 작업은 규정 위반이자 사고 시 구조 지연 요인 — 정원/체류보다 한 단계 높게.
|
|
||||||
EventType.LONE_WORKER: Severity.HIGH,
|
|
||||||
EventType.OVERCAPACITY: Severity.WARNING,
|
|
||||||
EventType.OVERSTAY: Severity.WARNING,
|
|
||||||
# 니어미스는 알람이 아니라 히트맵/통계 재료 — 소음이 되면 안 되므로 INFO.
|
|
||||||
EventType.NEAR_FALL: Severity.INFO,
|
|
||||||
EventType.ERGO_REPORT: Severity.INFO,
|
|
||||||
# 화각이 틀어지면 그 카메라의 존 판정 전체가 무효다. 사람이 다친 사건은 아니지만
|
|
||||||
# "지금부터 못 보고 있다"는 뜻이라 HIGH — 정비 대기열이 아니라 당일 처리 대상이다.
|
|
||||||
EventType.CAMERA_MOVED: Severity.HIGH,
|
|
||||||
EventType.CAMERA_OBSTRUCTED: Severity.HIGH,
|
|
||||||
# 조도 저하는 검출력이 떨어졌다는 신호이지 실패는 아니다 — monitoring_degraded 와 같은 급.
|
|
||||||
EventType.LOW_LIGHT: Severity.WARNING,
|
|
||||||
EventType.PPE_MISSING: Severity.WARNING,
|
|
||||||
EventType.PROXIMITY: Severity.HIGH,
|
|
||||||
EventType.FIRE_SMOKE: Severity.CRITICAL,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -94,7 +56,7 @@ class Event:
|
||||||
event_uuid: str = field(default_factory=lambda: _uuid.uuid4().hex)
|
event_uuid: str = field(default_factory=lambda: _uuid.uuid4().hex)
|
||||||
|
|
||||||
def to_payload(self, device_id: str) -> dict:
|
def to_payload(self, device_id: str) -> dict:
|
||||||
payload = {
|
return {
|
||||||
"device_id": device_id,
|
"device_id": device_id,
|
||||||
"event_uuid": self.event_uuid,
|
"event_uuid": self.event_uuid,
|
||||||
"camera_id": self.camera_id,
|
"camera_id": self.camera_id,
|
||||||
|
|
@ -106,8 +68,3 @@ class Event:
|
||||||
"duration_s": round(self.duration_s, 2),
|
"duration_s": round(self.duration_s, 2),
|
||||||
"metrics": self.metrics,
|
"metrics": self.metrics,
|
||||||
}
|
}
|
||||||
# 정규화 발/기준 좌표(0..1) — 서버 히트맵의 원료. None이면 키 자체를 생략해
|
|
||||||
# 구서버(스키마에 spatial 없음)와의 하위호환을 지킨다.
|
|
||||||
if self.spatial is not None:
|
|
||||||
payload["spatial"] = [round(float(self.spatial[0]), 4), round(float(self.spatial[1]), 4)]
|
|
||||||
return payload
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import math
|
import math
|
||||||
|
|
||||||
from edge.models import (
|
from edge.models import (
|
||||||
Keypoint, BBox, NOSE, L_SHOULDER, R_SHOULDER, L_ELBOW, R_ELBOW,
|
Keypoint, BBox, NOSE, L_SHOULDER, R_SHOULDER, L_HIP, R_HIP, CORE_JOINTS,
|
||||||
L_HIP, R_HIP, L_KNEE, R_KNEE, L_ANKLE, R_ANKLE, CORE_JOINTS,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -46,44 +45,6 @@ def head_below_hips(keypoints: list[Keypoint], nose_thr: float = 0.2) -> bool:
|
||||||
return nose.y > hy
|
return nose.y > hy
|
||||||
|
|
||||||
|
|
||||||
def arm_elevation_deg(keypoints: list[Keypoint], min_conf: float = 0.5) -> float | None:
|
|
||||||
"""어깨→팔꿈치 벡터가 '아래 수직'과 이루는 각의 좌우 최대값. 0=팔 내림, 90=수평 거상,
|
|
||||||
180=머리 위. 신뢰 가능한 쪽 팔만 사용; 양쪽 다 불가하면 None (측정 불가 ≠ 0°)."""
|
|
||||||
best = None
|
|
||||||
for s_i, e_i in ((L_SHOULDER, L_ELBOW), (R_SHOULDER, R_ELBOW)):
|
|
||||||
s, e = keypoints[s_i], keypoints[e_i]
|
|
||||||
if s.conf < min_conf or e.conf < min_conf:
|
|
||||||
continue
|
|
||||||
vx, vy = e.x - s.x, e.y - s.y
|
|
||||||
if not (vx or vy):
|
|
||||||
continue
|
|
||||||
# 이미지 y축은 아래로 증가: (0, +1)이 '팔 내림' 기준축.
|
|
||||||
ang = math.degrees(math.atan2(abs(vx), vy)) if vy >= 0 else \
|
|
||||||
180.0 - math.degrees(math.atan2(abs(vx), -vy))
|
|
||||||
best = ang if best is None else max(best, ang)
|
|
||||||
return best
|
|
||||||
|
|
||||||
|
|
||||||
def knee_flexion_deg(keypoints: list[Keypoint], min_conf: float = 0.5) -> float | None:
|
|
||||||
"""무릎 관절의 내부 각(엉덩이-무릎-발목)의 좌우 최소값. 180=곧게 섬, 작을수록 깊은
|
|
||||||
굴곡(웅크림). 신뢰 가능한 쪽 다리만 사용; 양쪽 다 불가하면 None."""
|
|
||||||
best = None
|
|
||||||
for h_i, k_i, a_i in ((L_HIP, L_KNEE, L_ANKLE), (R_HIP, R_KNEE, R_ANKLE)):
|
|
||||||
h, k, a = keypoints[h_i], keypoints[k_i], keypoints[a_i]
|
|
||||||
if h.conf < min_conf or k.conf < min_conf or a.conf < min_conf:
|
|
||||||
continue
|
|
||||||
v1 = (h.x - k.x, h.y - k.y)
|
|
||||||
v2 = (a.x - k.x, a.y - k.y)
|
|
||||||
n1 = math.hypot(*v1)
|
|
||||||
n2 = math.hypot(*v2)
|
|
||||||
if not n1 or not n2:
|
|
||||||
continue
|
|
||||||
cos = max(-1.0, min(1.0, (v1[0] * v2[0] + v1[1] * v2[1]) / (n1 * n2)))
|
|
||||||
ang = math.degrees(math.acos(cos))
|
|
||||||
best = ang if best is None else min(best, ang)
|
|
||||||
return best
|
|
||||||
|
|
||||||
|
|
||||||
def visible_count(keypoints: list[Keypoint], thr: float = 0.2) -> int:
|
def visible_count(keypoints: list[Keypoint], thr: float = 0.2) -> int:
|
||||||
return sum(1 for k in keypoints if k.conf >= thr)
|
return sum(1 for k in keypoints if k.conf >= thr)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,17 +8,10 @@ from edge.events import Event, EventType, Severity
|
||||||
|
|
||||||
class OfflineQueue:
|
class OfflineQueue:
|
||||||
def __init__(self, db_path: str, client, max_rows: int = 2000,
|
def __init__(self, db_path: str, client, max_rows: int = 2000,
|
||||||
max_send_attempts: int = 20, max_flush_rows: int = 20):
|
max_send_attempts: int = 20):
|
||||||
self.client = client
|
self.client = client
|
||||||
self.max_rows = max_rows # SD-card guard: evict oldest beyond this
|
self.max_rows = max_rows # SD-card guard: evict oldest beyond this
|
||||||
self.max_send_attempts = max_send_attempts # drop a poison event after this many tries
|
self.max_send_attempts = max_send_attempts # drop a poison event after this many tries
|
||||||
# Detection-gap guard. flush() runs INSIDE the single-threaded detection loop, so every
|
|
||||||
# row it sends is time the camera is not being processed. Draining a full max_rows queue
|
|
||||||
# in one call (2000 rows x up to 5s POST timeout) would stall fall detection for minutes
|
|
||||||
# right after an outage recovers — exactly when the backlog proves the site was
|
|
||||||
# unmonitored. Drain a slice per tick instead: the queue still empties, but the loop keeps
|
|
||||||
# detecting between slices.
|
|
||||||
self.max_flush_rows = max_flush_rows
|
|
||||||
self.db = sqlite3.connect(db_path)
|
self.db = sqlite3.connect(db_path)
|
||||||
self.db.execute(
|
self.db.execute(
|
||||||
"CREATE TABLE IF NOT EXISTS queue ("
|
"CREATE TABLE IF NOT EXISTS queue ("
|
||||||
|
|
@ -67,11 +60,8 @@ class OfflineQueue:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def flush(self) -> int:
|
def flush(self) -> int:
|
||||||
# LIMIT bounds BOTH the stall above and peak memory: each row carries up to 3 base64
|
|
||||||
# JPEGs, so fetching the whole queue would also spike RSS on the Pi.
|
|
||||||
rows = self.db.execute(
|
rows = self.db.execute(
|
||||||
"SELECT id, payload, snaps, attempts FROM queue ORDER BY id LIMIT ?",
|
"SELECT id, payload, snaps, attempts FROM queue ORDER BY id").fetchall()
|
||||||
(self.max_flush_rows,)).fetchall()
|
|
||||||
sent = 0
|
sent = 0
|
||||||
for rid, payload_json, snaps_json, attempts in rows:
|
for rid, payload_json, snaps_json, attempts in rows:
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,8 @@ from edge.detectors.fall import FallDetector
|
||||||
from edge.detectors.immobility import ImmobilityWatchdog
|
from edge.detectors.immobility import ImmobilityWatchdog
|
||||||
from edge.detectors.ghost import GhostIncidentDetector
|
from edge.detectors.ghost import GhostIncidentDetector
|
||||||
from edge.detectors.zone import ZoneIntrusionDetector
|
from edge.detectors.zone import ZoneIntrusionDetector
|
||||||
from edge.detectors.integrity import CameraIntegrityWatch, IntegrityConfig
|
|
||||||
from edge.engine import EventEngine
|
from edge.engine import EventEngine
|
||||||
from edge.snapshot import RingBuffer, FaceBlurrer, Snapshotter
|
from edge.snapshot import RingBuffer, FaceBlurrer, Snapshotter
|
||||||
from edge.tuning import apply_thresholds
|
|
||||||
|
|
||||||
|
|
||||||
class PipelineRuntime:
|
class PipelineRuntime:
|
||||||
|
|
@ -18,12 +16,6 @@ class PipelineRuntime:
|
||||||
self.source = source
|
self.source = source
|
||||||
self.pose = pose
|
self.pose = pose
|
||||||
self.sink = sink
|
self.sink = sink
|
||||||
# Tier-2 fallback (hardware-bringup.md): when the device cannot sustain full-rate pose
|
|
||||||
# for every camera, infer on every Nth tick instead of dropping cameras. Detector timers
|
|
||||||
# are wall/monotonic-based, not frame-count-based, so they stay correct at a lower
|
|
||||||
# sampling rate — only the reaction granularity coarsens.
|
|
||||||
self.infer_every = max(1, int(getattr(config, "infer_every", 1)))
|
|
||||||
self._tick = 0
|
|
||||||
# Wall clock for Event.occurred_at only; the loop clock (step's `now`) is monotonic. None
|
# Wall clock for Event.occurred_at only; the loop clock (step's `now`) is monotonic. None
|
||||||
# off-daemon -> run_once stamps occurred_at with the monotonic `now`, preserving old tests.
|
# off-daemon -> run_once stamps occurred_at with the monotonic `now`, preserving old tests.
|
||||||
self._wall = wall_clock
|
self._wall = wall_clock
|
||||||
|
|
@ -39,35 +31,11 @@ class PipelineRuntime:
|
||||||
)
|
)
|
||||||
self.ring = RingBuffer()
|
self.ring = RingBuffer()
|
||||||
self.snapshotter = Snapshotter(FaceBlurrer())
|
self.snapshotter = Snapshotter(FaceBlurrer())
|
||||||
# Watches the frame itself rather than the people in it: a knocked-askew or covered
|
|
||||||
# camera keeps delivering healthy frames while every zone drawn on it is now wrong.
|
|
||||||
self.integrity = CameraIntegrityWatch(
|
|
||||||
getattr(config, "integrity", None) or IntegrityConfig())
|
|
||||||
|
|
||||||
def step(self, now: float) -> list:
|
def step(self, now: float) -> list:
|
||||||
self._tick += 1
|
|
||||||
if self.infer_every > 1 and self._tick % self.infer_every != 0:
|
|
||||||
# Drain-only tick. Pull the frame so the v4l2/CSI buffer stays current — a backed-up
|
|
||||||
# buffer hands back seconds-old frames and delays every alert — then DISCARD it.
|
|
||||||
# It must NOT reach the ring buffer: head boxes come from inference, so an
|
|
||||||
# un-analyzed frame carries no blur regions and would ship an identifiable face in
|
|
||||||
# the evidence timeline (PIPA). Returning [] (not None) keeps this a healthy tick
|
|
||||||
# for the daemon's camera-health accounting; a dead camera still reports None below.
|
|
||||||
ok, _ = self.source.read()
|
|
||||||
return [] if ok else None
|
|
||||||
return run_once(self.source, self.pose, self.tracker, self.pool,
|
return run_once(self.source, self.pose, self.tracker, self.pool,
|
||||||
self.engine, self.ring, self.snapshotter, self.sink,
|
self.engine, self.ring, self.snapshotter, self.sink,
|
||||||
now_fn=lambda: now, wall_fn=self._wall,
|
now_fn=lambda: now, wall_fn=self._wall)
|
||||||
integrity=self.integrity)
|
|
||||||
|
|
||||||
def apply_zones(self, zones: list) -> None:
|
def apply_zones(self, zones: list) -> None:
|
||||||
self.zone_detector.zones = list(zones)
|
self.zone_detector.zones = list(zones)
|
||||||
|
|
||||||
def apply_thresholds(self, thresholds: dict, log=None) -> dict:
|
|
||||||
"""Push server-published tunables into the live detectors.
|
|
||||||
|
|
||||||
Until this existed the server stored thresholds, /api/config delivered them and the edge
|
|
||||||
parsed them into a dict it then threw away — the two ends of the wire were built and the
|
|
||||||
middle was missing, so nothing an administrator changed ever reached a device.
|
|
||||||
"""
|
|
||||||
return apply_thresholds(self, thresholds, log=log)
|
|
||||||
|
|
|
||||||
|
|
@ -7,34 +7,15 @@ from edge.models import BBox
|
||||||
|
|
||||||
class RingBuffer:
|
class RingBuffer:
|
||||||
def __init__(self, maxlen: int = 90):
|
def __init__(self, maxlen: int = 90):
|
||||||
self.frames: deque = deque(maxlen=maxlen) # (t, frame, head_boxes)
|
self.frames: deque = deque(maxlen=maxlen)
|
||||||
|
|
||||||
def push(self, t: float, frame, head_boxes: list | None = None) -> None:
|
def push(self, t: float, frame) -> None:
|
||||||
# head_boxes ride along per frame: a PAST frame must be blurred with the head
|
self.frames.append((t, frame))
|
||||||
# positions of ITS OWN moment — blurring history with the current tick's boxes
|
|
||||||
# would leave yesterday's face exactly where it actually was (PIPA).
|
|
||||||
self.frames.append((t, frame, head_boxes or []))
|
|
||||||
|
|
||||||
def window(self, pre: int, post: int, index: int) -> list:
|
def window(self, pre: int, post: int, index: int) -> list:
|
||||||
lo = max(0, index - pre)
|
lo = max(0, index - pre)
|
||||||
hi = min(len(self.frames), index + post + 1)
|
hi = min(len(self.frames), index + post + 1)
|
||||||
return [f for (_t, f, _b) in list(self.frames)[lo:hi]]
|
return [f for (_t, f) in list(self.frames)[lo:hi]]
|
||||||
|
|
||||||
def timeline(self, now: float, offsets_s: tuple) -> tuple[list, list]:
|
|
||||||
"""각 목표 시각(now - offset)에 가장 가까운 프레임을 골라 (frames, head_boxes)를
|
|
||||||
과거→현재 순으로 반환. 버퍼가 짧으면(기동 직후) 있는 만큼만 — 같은 프레임이 여러
|
|
||||||
목표에 걸리면 한 번만 들어간다."""
|
|
||||||
items = list(self.frames)
|
|
||||||
if not items:
|
|
||||||
return [], []
|
|
||||||
idxs: list[int] = []
|
|
||||||
for off in sorted(offsets_s, reverse=True):
|
|
||||||
target = now - off
|
|
||||||
i = min(range(len(items)), key=lambda k: abs(items[k][0] - target))
|
|
||||||
if i not in idxs:
|
|
||||||
idxs.append(i)
|
|
||||||
idxs.sort()
|
|
||||||
return [items[i][1] for i in idxs], [items[i][2] for i in idxs]
|
|
||||||
|
|
||||||
|
|
||||||
class FaceBlurrer:
|
class FaceBlurrer:
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,14 @@ to a temp file, validated, and only then moved into place — a truncated / HTML
|
||||||
tampered download must never be saved as the model (onnxruntime would then fail to load it, and
|
tampered download must never be saved as the model (onnxruntime would then fail to load it, and
|
||||||
the operator would see a cryptic runtime error instead of a clear fetch failure).
|
the operator would see a cryptic runtime error instead of a clear fetch failure).
|
||||||
|
|
||||||
SOURCE: the historical PINTO_model_zoo raw URL is DEAD (that repo hosts its models on external
|
STATUS: the historical PINTO_model_zoo raw URL below is DEAD (that repo hosts its models on
|
||||||
storage, not as repo blobs — the raw path returns a GitHub HTML 404). The pinned source below is
|
external storage, not as repo blobs — the raw path now returns a GitHub HTML 404). So there is
|
||||||
a tf2onnx export of Google's MoveNet SinglePose Lightning v4, checked to match what
|
no known-good sha256 to pin here yet. Until a working source is set:
|
||||||
edge/adapters/movenet_pose.py feeds and reads: input [1,192,192,3] int32 NHWC, output
|
* this tool FAILS LOUD if the download is not a real ONNX (rejects the HTML it currently gets),
|
||||||
[1,1,17,3] float (y, x, score). A conversion with a different layout would load fine and then
|
instead of silently saving garbage;
|
||||||
produce silently wrong keypoints, so the digest below is what makes this safe to fetch — not the
|
* set MOVENET_ONNX_URL to a valid source and pin PINNED_SHA256/PINNED_SIZE from its bytes.
|
||||||
hostname. For the Hailo delivery path this model is optional (POSE_BACKEND=hailo).
|
For the Hailo delivery path this model is optional (POSE_BACKEND=hailo), so a missing MoveNet is
|
||||||
|
non-blocking; the CPU fallback needs this resolved.
|
||||||
"""
|
"""
|
||||||
import hashlib
|
import hashlib
|
||||||
import os
|
import os
|
||||||
|
|
@ -21,16 +22,15 @@ import urllib.request
|
||||||
|
|
||||||
URL = os.environ.get(
|
URL = os.environ.get(
|
||||||
"MOVENET_ONNX_URL",
|
"MOVENET_ONNX_URL",
|
||||||
"https://huggingface.co/Xenova/movenet-singlepose-lightning/resolve/main/onnx/model.onnx",
|
"https://github.com/PINTO0309/PINTO_model_zoo/raw/main/115_MoveNet/resources/"
|
||||||
|
"movenet_singlepose_lightning_4.onnx",
|
||||||
)
|
)
|
||||||
DEST = os.path.join(os.path.dirname(__file__), "..", "..", "models", "movenet_lightning.onnx")
|
DEST = os.path.join(os.path.dirname(__file__), "..", "..", "models", "movenet_lightning.onnx")
|
||||||
|
|
||||||
# Pinned from the bytes of the source above. An override must bring its own digest — pointing
|
# Pin from the bytes of a known-good source once MOVENET_ONNX_URL points at one. Empty = unpinned
|
||||||
# MOVENET_ONNX_URL elsewhere while keeping this pin just makes the download fail, which is the
|
# (still validated as a real ONNX below, but not byte-identical-verified).
|
||||||
# intended direction of failure.
|
PINNED_SHA256 = os.environ.get("MOVENET_ONNX_SHA256", "")
|
||||||
PINNED_SHA256 = os.environ.get(
|
PINNED_SIZE = int(os.environ.get("MOVENET_ONNX_SIZE", "0") or "0")
|
||||||
"MOVENET_ONNX_SHA256", "1ad4f8d6c2f776a9967db3993c9ca740bc350104f9d37c151dc183fc29a464ad")
|
|
||||||
PINNED_SIZE = int(os.environ.get("MOVENET_ONNX_SIZE", "9413268") or "0")
|
|
||||||
|
|
||||||
|
|
||||||
def _looks_like_onnx(head: bytes) -> bool:
|
def _looks_like_onnx(head: bytes) -> bool:
|
||||||
|
|
|
||||||
|
|
@ -40,46 +40,24 @@ class IouTracker:
|
||||||
self._tracks: dict[int, Track] = {}
|
self._tracks: dict[int, Track] = {}
|
||||||
self._last_seen: dict[int, float] = {}
|
self._last_seen: dict[int, float] = {}
|
||||||
self._lost: list[Track] = []
|
self._lost: list[Track] = []
|
||||||
self._ambiguous: set[int] = set()
|
|
||||||
self._next_id = 1
|
self._next_id = 1
|
||||||
|
|
||||||
def update(self, persons: list[Person], t: float) -> list[Track]:
|
def update(self, persons: list[Person], t: float) -> list[Track]:
|
||||||
self._lost = []
|
self._lost = []
|
||||||
self._ambiguous = set()
|
unmatched = list(persons)
|
||||||
# 전역 그리디: 모든 (트랙, 사람) 쌍을 IoU 내림차순으로 배정. 트랙 순회 순서 우선의
|
# 기존 트랙과 그리디 IoU 매칭
|
||||||
# 로컬 그리디는 교차 시 먼저 온 트랙이 남의 박스를 가로채(0.48이 0.74를 이김) FALLEN
|
for tid, track in list(self._tracks.items()):
|
||||||
# 상태·frozen baseline이 다른 사람에게 넘어갔다.
|
best, best_iou = None, self.iou_thr
|
||||||
pairs = []
|
for p in unmatched:
|
||||||
track_cands: dict[int, int] = {}
|
|
||||||
person_cands: dict[int, int] = {}
|
|
||||||
for tid, track in self._tracks.items():
|
|
||||||
for i, p in enumerate(persons):
|
|
||||||
iou = _iou(track.latest.bbox, p.bbox)
|
iou = _iou(track.latest.bbox, p.bbox)
|
||||||
if iou >= self.iou_thr:
|
if iou >= best_iou:
|
||||||
pairs.append((iou, tid, i))
|
best, best_iou = p, iou
|
||||||
track_cands[tid] = track_cands.get(tid, 0) + 1
|
if best is not None:
|
||||||
person_cands[i] = person_cands.get(i, 0) + 1
|
unmatched.remove(best)
|
||||||
pairs.sort(key=lambda pr: pr[0], reverse=True)
|
track.frames.append(TrackFrame(t, best.bbox, best.keypoints))
|
||||||
used_tracks: set[int] = set()
|
|
||||||
used_persons: set[int] = set()
|
|
||||||
for _iou_v, tid, i in pairs:
|
|
||||||
if tid in used_tracks or i in used_persons:
|
|
||||||
continue
|
|
||||||
used_tracks.add(tid)
|
|
||||||
used_persons.add(i)
|
|
||||||
p = persons[i]
|
|
||||||
self._tracks[tid].frames.append(TrackFrame(t, p.bbox, p.keypoints))
|
|
||||||
self._last_seen[tid] = t
|
self._last_seen[tid] = t
|
||||||
# 경합 = 저신뢰 매칭. 한 사람이 두 트랙과, 또는 한 트랙이 두 사람과 임계 이상 겹치면
|
|
||||||
# (교차) 어느 배정이든 스왑일 수 있다 — 연루된 모든 트랙을 표시해 호출자가 트랙별
|
|
||||||
# 안전 상태(FALLEN·baseline·부동 타이머)를 리셋하게 한다.
|
|
||||||
for _iou_v, tid, i in pairs:
|
|
||||||
if track_cands[tid] > 1 or person_cands[i] > 1:
|
|
||||||
self._ambiguous.add(tid)
|
|
||||||
# 신규 트랙
|
# 신규 트랙
|
||||||
for i, p in enumerate(persons):
|
for p in unmatched:
|
||||||
if i in used_persons:
|
|
||||||
continue
|
|
||||||
tid = self._next_id
|
tid = self._next_id
|
||||||
self._next_id += 1
|
self._next_id += 1
|
||||||
self._tracks[tid] = Track(tid, deque([TrackFrame(t, p.bbox, p.keypoints)],
|
self._tracks[tid] = Track(tid, deque([TrackFrame(t, p.bbox, p.keypoints)],
|
||||||
|
|
@ -94,7 +72,3 @@ class IouTracker:
|
||||||
|
|
||||||
def lost(self) -> list[Track]:
|
def lost(self) -> list[Track]:
|
||||||
return list(self._lost)
|
return list(self._lost)
|
||||||
|
|
||||||
def ambiguous(self) -> set[int]:
|
|
||||||
"""직전 update에서 겹침 경합에 연루된(매칭이 스왑일 수 있는) 트랙 id."""
|
|
||||||
return set(self._ambiguous)
|
|
||||||
|
|
|
||||||
101
edge/tuning.py
|
|
@ -1,101 +0,0 @@
|
||||||
"""서버에서 내려온 임계값을 살아 있는 감지기에 적용한다.
|
|
||||||
|
|
||||||
WHAT WAS BROKEN
|
|
||||||
`edge/zones.py:thresholds_from_config()` 는 정의만 되어 있고 어디에서도 호출되지 않았다.
|
|
||||||
서버에는 `move_thresholds` 표가 있고, `GET /api/config` 가 그것을 성실히 실어 보내고,
|
|
||||||
엣지는 그 값을 파싱한 뒤 그대로 버렸다. 즉 배선의 양 끝이 다 있는데 가운데가 비어 있어서,
|
|
||||||
관리자가 무엇을 바꾸든 장비는 부팅 시 edge.env 값으로 영원히 돌았다. 이 파일이 그 가운데다.
|
|
||||||
|
|
||||||
WHY VALIDATE HERE TOO
|
|
||||||
서버의 ThresholdCatalog 가 이미 범위를 강제하지만, 그것은 *편집 화면* 의 방어선이다.
|
|
||||||
DB 를 직접 고친 값, 구버전 서버, 손상된 응답은 여전히 도달할 수 있고, `now - t >= limit`
|
|
||||||
비교에 NaN 이 들어가면 감지기가 조용히 꺼지고 0 이 들어가면 영구 알람이 된다
|
|
||||||
(edge/config.py `_positive` 의 주석과 같은 이유). 기동 시점의 검증은 여기서 반복된다.
|
|
||||||
|
|
||||||
WHY NOT CRASH
|
|
||||||
기동 검증은 잘못된 값에 예외를 던진다 — 그때는 운영자가 화면 앞에 있다. 여기는 24/7 로
|
|
||||||
도는 동기화 경로다. 서버가 보낸 한 값이 이상하다고 공장 안 장비를 재시작 루프에 빠뜨리면
|
|
||||||
그 카메라는 그 순간부터 아무것도 못 본다. 나쁜 값은 무시하고 로그로 남기고, 나머지는
|
|
||||||
적용한다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import math
|
|
||||||
|
|
||||||
# 서버 키 -> (대상 객체 경로, 속성명). 하나의 키가 두 곳에 적용되는 경우도 있다
|
|
||||||
# (motion_thr 은 fall 과 immobility 가 공유한다 — edge/config.py 가 같은 값을 둘에 넣는다).
|
|
||||||
_TARGETS: dict[str, tuple[tuple[str, str], ...]] = {
|
|
||||||
"fall_prone_s": (("fall", "prone_confirm_s"),),
|
|
||||||
"fall_no_motion_s": (("fall", "no_motion_s"),),
|
|
||||||
"immobile_s": (("immobility", "immobile_s"),),
|
|
||||||
"ghost_reappear_s": (("ghost", "reappear_s"),),
|
|
||||||
"zone_debounce_s": (("zone", "debounce_s"),),
|
|
||||||
"cooldown_s": (("engine", "cooldown_s"),),
|
|
||||||
"motion_thr": (("fall", "motion_thr"), ("immobility", "motion_thr")),
|
|
||||||
}
|
|
||||||
|
|
||||||
# 정수 노브는 별도 취급: 감지기 설정이 아니라 런타임 자체의 샘플링 주기다.
|
|
||||||
_INT_KNOBS = {"infer_every"}
|
|
||||||
|
|
||||||
|
|
||||||
def _clean(raw) -> float | None:
|
|
||||||
"""유한 양수만 통과. 그 외에는 None (호출부가 로그하고 무시)."""
|
|
||||||
try:
|
|
||||||
v = float(raw)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
if not math.isfinite(v) or v <= 0:
|
|
||||||
return None
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
def apply_thresholds(runtime, thresholds: dict, log=None) -> dict:
|
|
||||||
"""`runtime` 의 감지기 설정에 서버 임계값을 적용한다.
|
|
||||||
|
|
||||||
@return 실제로 적용된 {key: value} — 호출부가 로그/테스트에 쓴다.
|
|
||||||
"""
|
|
||||||
log = log or (lambda m: None)
|
|
||||||
if not isinstance(thresholds, dict) or not thresholds:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
engine = getattr(runtime, "engine", None)
|
|
||||||
if engine is None:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
# 감지기별 설정 객체. 이름이 다른 구현이 오더라도 없는 것은 조용히 건너뛴다.
|
|
||||||
holders = {
|
|
||||||
"fall": getattr(getattr(engine, "fall", None), "cfg", None),
|
|
||||||
"immobility": getattr(getattr(engine, "immobility", None), "cfg", None),
|
|
||||||
"ghost": getattr(getattr(engine, "ghost", None), "cfg", None),
|
|
||||||
"zone": getattr(getattr(engine, "zone", None), "cfg", None),
|
|
||||||
"engine": getattr(engine, "cfg", None),
|
|
||||||
}
|
|
||||||
|
|
||||||
applied: dict[str, float] = {}
|
|
||||||
for key, raw in thresholds.items():
|
|
||||||
if key in _INT_KNOBS:
|
|
||||||
v = _clean(raw)
|
|
||||||
if v is None or v != int(v):
|
|
||||||
log(f"threshold {key}={raw!r} ignored (must be a positive integer)")
|
|
||||||
continue
|
|
||||||
# infer_every 는 런타임의 샘플링 주기. 상한을 두는 이유: 서버가 실수로 1000 을
|
|
||||||
# 보내면 그 카메라는 사실상 감시를 멈춘다.
|
|
||||||
runtime.infer_every = max(1, min(10, int(v)))
|
|
||||||
applied[key] = runtime.infer_every
|
|
||||||
continue
|
|
||||||
|
|
||||||
targets = _TARGETS.get(key)
|
|
||||||
if targets is None:
|
|
||||||
# 알 수 없는 키는 무시한다. 서버가 신버전이고 엣지가 구버전인 정상 상황이다.
|
|
||||||
continue
|
|
||||||
v = _clean(raw)
|
|
||||||
if v is None:
|
|
||||||
log(f"threshold {key}={raw!r} ignored (must be a finite positive number)")
|
|
||||||
continue
|
|
||||||
for holder_name, attr in targets:
|
|
||||||
holder = holders.get(holder_name)
|
|
||||||
if holder is None or not hasattr(holder, attr):
|
|
||||||
continue
|
|
||||||
setattr(holder, attr, v)
|
|
||||||
applied[key] = v
|
|
||||||
|
|
||||||
return applied
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
"""systemd watchdog ping (sd_notify), stdlib-only.
|
|
||||||
|
|
||||||
Restart=always only catches a process that DIES. It cannot catch a process that is alive but
|
|
||||||
stuck — a v4l2 read blocking on a wedged USB controller, a HailoRT call that never returns, a
|
|
||||||
sqlite lock. In that state the daemon keeps its PID, stops detecting, and the only signal is the
|
|
||||||
dashboard turning the device offline 120s later: an unattended site then waits for a human to
|
|
||||||
notice. WatchdogSec + this ping turns that into an automatic restart.
|
|
||||||
|
|
||||||
No pysystemd dependency (the air-gapped bundle carries only wheels we vendor): the protocol is
|
|
||||||
one datagram to $NOTIFY_SOCKET.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import socket
|
|
||||||
|
|
||||||
|
|
||||||
def sd_notify(state: str) -> bool:
|
|
||||||
"""Send a status datagram to systemd. Returns False when not running under systemd
|
|
||||||
(NOTIFY_SOCKET unset) or on any socket error — never raises, because a telemetry failure
|
|
||||||
must not take down detection."""
|
|
||||||
addr = os.environ.get("NOTIFY_SOCKET")
|
|
||||||
if not addr:
|
|
||||||
return False
|
|
||||||
# AF_UNIX does not exist on Windows, where developers run the test suite. Guard rather than
|
|
||||||
# let an AttributeError escape a function whose whole contract is "never raises".
|
|
||||||
if not hasattr(socket, "AF_UNIX"):
|
|
||||||
return False
|
|
||||||
# Abstract namespace sockets start with '@' on Linux; the kernel expects a NUL byte.
|
|
||||||
if addr.startswith("@"):
|
|
||||||
addr = "\0" + addr[1:]
|
|
||||||
try:
|
|
||||||
with socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) as sock:
|
|
||||||
sock.connect(addr)
|
|
||||||
sock.sendall(state.encode())
|
|
||||||
return True
|
|
||||||
except OSError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def ping() -> bool:
|
|
||||||
"""One 'I am still looping' keepalive."""
|
|
||||||
return sd_notify("WATCHDOG=1")
|
|
||||||
|
|
||||||
|
|
||||||
def ready() -> bool:
|
|
||||||
"""Announce startup completion. Harmless under Type=exec; required if the unit ever
|
|
||||||
switches to Type=notify."""
|
|
||||||
return sd_notify("READY=1")
|
|
||||||
|
|
@ -17,44 +17,6 @@ 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)
|
return any(abs(x) > _NORMALIZED_MAX or abs(y) > _NORMALIZED_MAX for x, y in polygon)
|
||||||
|
|
||||||
|
|
||||||
_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:
|
|
||||||
"""Optional occupancy rules from the zone row's rules_json. A malformed rules blob must
|
|
||||||
not kill the zone's INTRUSION detection (the polygon is still valid) — raise here and the
|
|
||||||
caller keeps the zone but logs the rules loudly as inactive."""
|
|
||||||
raw = row.get("rules_json")
|
|
||||||
if raw in (None, "", "null"):
|
|
||||||
return None
|
|
||||||
data = json.loads(raw) if isinstance(raw, str) else raw
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
raise ValueError(f"rules_json is not an object: {data!r}")
|
|
||||||
rules = {}
|
|
||||||
for key in _RULE_INT_KEYS:
|
|
||||||
if data.get(key) is None:
|
|
||||||
continue
|
|
||||||
v = int(data[key])
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def zones_from_config(config: dict, camera_id: str | None = None) -> list[Zone]:
|
def zones_from_config(config: dict, camera_id: str | None = None) -> list[Zone]:
|
||||||
"""Parse server config zone rows into Zone objects. When camera_id is given, only
|
"""Parse server config zone rows into Zone objects. When camera_id is given, only
|
||||||
rows for that camera are kept (multi-camera routing); None keeps all rows."""
|
rows for that camera are kept (multi-camera routing); None keeps all rows."""
|
||||||
|
|
@ -78,16 +40,7 @@ def zones_from_config(config: dict, camera_id: str | None = None) -> list[Zone]:
|
||||||
"the dashboard so it is stored normalized (0..1).",
|
"the dashboard so it is stored normalized (0..1).",
|
||||||
row.get("zone_id"), row.get("camera_id"), polygon[:2])
|
row.get("zone_id"), row.get("camera_id"), polygon[:2])
|
||||||
continue
|
continue
|
||||||
try:
|
out.append(Zone(str(row["zone_id"]), str(row["level"]), polygon))
|
||||||
rules = _rules_of(row)
|
|
||||||
except (ValueError, TypeError, json.JSONDecodeError) as e:
|
|
||||||
# Same "never fail dark" principle: the polygon is fine, so intrusion detection
|
|
||||||
# stays live — but the occupancy rules the operator THINKS are active are not.
|
|
||||||
log.warning("zone %s on camera %s has malformed rules_json (%s: %s) — its "
|
|
||||||
"occupancy rules are INACTIVE until fixed in the dashboard.",
|
|
||||||
row.get("zone_id"), row.get("camera_id"), type(e).__name__, e)
|
|
||||||
rules = None
|
|
||||||
out.append(Zone(str(row["zone_id"]), str(row["level"]), polygon, rules))
|
|
||||||
except (KeyError, ValueError, TypeError, IndexError, AttributeError, json.JSONDecodeError) as e:
|
except (KeyError, ValueError, TypeError, IndexError, AttributeError, json.JSONDecodeError) as e:
|
||||||
# Same principle as the pixel-space branch above: never fail dark. A row missing a key
|
# Same principle as the pixel-space branch above: never fail dark. A row missing a key
|
||||||
# (level/zone_id), with unparseable polygon_json, or degenerate points would otherwise be
|
# (level/zone_id), with unparseable polygon_json, or degenerate points would otherwise be
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 4.5 MiB |
BIN
image/02-경광등.png
|
Before Width: | Height: | Size: 4.3 MiB |
|
Before Width: | Height: | Size: 4.3 MiB |
|
Before Width: | Height: | Size: 4.9 MiB |
BIN
image/05-사슬.png
|
Before Width: | Height: | Size: 2.1 MiB |
BIN
image/06-구성도.png
|
Before Width: | Height: | Size: 2 MiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 3.5 MiB |
|
Before Width: | Height: | Size: 3.5 MiB |
|
Before Width: | Height: | Size: 3.7 MiB |
|
Before Width: | Height: | Size: 3.3 MiB |
|
|
@ -13,28 +13,4 @@ return [
|
||||||
'ingest_ip_rate_limit_per_min' => 300,
|
'ingest_ip_rate_limit_per_min' => 300,
|
||||||
// PIPA retention: worker snapshots are purged after this many days (0 disables).
|
// PIPA retention: worker snapshots are purged after this many days (0 disables).
|
||||||
'snapshot_retention_days' => 7,
|
'snapshot_retention_days' => 7,
|
||||||
'event_retention_days' => 0, // 0 = 이벤트 기록 영구 보존 (안전사고 기록; 스냅샷만 만료)
|
|
||||||
|
|
||||||
// --- 통보 (tools/dispatch_alerts.php) --------------------------------------------
|
|
||||||
// 대시보드의 외부 접속 주소. 알림 메시지의 사건 링크에 쓰인다. 비어 있으면 디스패처가
|
|
||||||
// 실행을 거부한다 — 열리지 않는 링크가 담긴 알림을 보내는 것보다 낫다.
|
|
||||||
'public_base_url' => '', // 예: 'https://example.com/move_detect'
|
|
||||||
// 위급/높음 이벤트가 이 시간(초) 동안 미확인이면 unacked_escalation 이벤트를 발행한다.
|
|
||||||
'escalate_after_seconds' => 300,
|
|
||||||
// 장치 건전성 판정 (App\Alert\DeviceHealthWatch). 엣지는 이 두 이벤트를 스스로 못 낸다 —
|
|
||||||
// 죽은 노드는 자기가 죽었다고 신고할 수 없다. 하트비트의 부재로만 관측된다.
|
|
||||||
'device_stale_seconds' => 180, // 이만큼 무소식이면 device_fault
|
|
||||||
'degraded_sustain_seconds' => 300, // 성능 저하가 이만큼 지속되면 monitoring_degraded
|
|
||||||
'degraded_fps_ratio' => 0.6, // target_fps 의 이 비율 미만이면 저하
|
|
||||||
'target_fps' => 12.0, // 엣지 TARGET_FPS 와 맞출 것
|
|
||||||
// 통보 메시지의 시각 표기 기준. 대시보드는 브라우저 시각을 쓰지만 알림 메일은 PHP 가
|
|
||||||
// 만들고, 공유호스팅은 date.timezone 이 UTC 인 경우가 흔하다 — 콘솔이 13:01 로 보여준
|
|
||||||
// 넘어짐을 메일이 04:01 로 적으면 응급 대응에서 그대로 사고가 된다.
|
|
||||||
'timezone' => 'Asia/Seoul',
|
|
||||||
|
|
||||||
// --- 보존 / 용량 ------------------------------------------------------------------
|
|
||||||
// 하트비트 시계열 보존일. 장치당 하루 약 2,880행이 쌓인다 (tools/prune.php 가 정리).
|
|
||||||
'metric_retention_days' => 30,
|
|
||||||
// 스냅샷 볼륨 여유 공간 하한(MB). 밑돌면 device_fault 로 알린다. 0 = 검사 안 함.
|
|
||||||
'storage_min_free_mb' => 200,
|
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -70,43 +70,19 @@ DB 세 줄만 카페24 관리자 페이지의 MySQL 정보로 바꿉니다.
|
||||||
1. 카페24 관리자 → **phpMyAdmin** 열기
|
1. 카페24 관리자 → **phpMyAdmin** 열기
|
||||||
2. 이 앱용 DB 선택
|
2. 이 앱용 DB 선택
|
||||||
3. **Import(가져오기)** 탭 → `schema.sql` 선택 → 실행
|
3. **Import(가져오기)** 탭 → `schema.sql` 선택 → 실행
|
||||||
4. 표 11개가 생겼는지 확인
|
4. 표 10개가 생겼는지 확인
|
||||||
5. **서버에서 `schema.sql` 삭제** — 남겨둘 이유가 없습니다
|
5. **서버에서 `schema.sql` 삭제** — 남겨둘 이유가 없습니다
|
||||||
|
|
||||||
`schema.sql` 은 항상 최신 상태입니다. 처음 설치라면 아래 마이그레이션은 **실행하지 마세요**
|
|
||||||
(이미 반영되어 있어 충돌합니다).
|
|
||||||
|
|
||||||
### 이미 쓰던 DB 업그레이드(기존 데이터 있음)
|
### 이미 쓰던 DB 업그레이드(기존 데이터 있음)
|
||||||
|
|
||||||
> ⚠️ 기존 DB에 `schema.sql` 을 다시 Import 하면 `CREATE TABLE` 충돌로 실패합니다.
|
> ⚠️ 기존 DB에 `schema.sql` 을 다시 Import 하면 `CREATE TABLE` 충돌로 실패합니다.
|
||||||
> 대신 **마이그레이션**을 실행하세요.
|
> 대신 **마이그레이션**을 실행하세요.
|
||||||
|
|
||||||
**반드시 아래 순서대로, 아직 적용하지 않은 것부터 전부 실행합니다.** 하나라도 건너뛰면
|
|
||||||
그 컬럼·표를 쓰는 요청이 전부 500 이 됩니다. 순서가 중요한 이유는 뒤 파일이 앞 파일이 만든
|
|
||||||
표 이름(`move_` 접두사)을 전제하기 때문입니다.
|
|
||||||
|
|
||||||
| 순서 | 파일 | 무엇이 바뀌나 |
|
|
||||||
|---|---|---|
|
|
||||||
| 1 | `2026-07-17-p1-9-p3-2b.sql` | site / event_uuid 컬럼 + 인덱스, 기존 이벤트 site 백필 |
|
|
||||||
| 2 | `2026-07-20-move-table-prefix.sql` | 모든 표 이름에 `move_` 접두사 부여 (**이 단계를 건너뛰면 이후 전부 실패**) |
|
|
||||||
| 3 | `2026-08-06-zone-rules.sql` | 존 점유 규칙(`rules_json`) |
|
|
||||||
| 4 | `2026-08-06-heatmap-spatial.sql` | 히트맵 좌표(`spatial_x`, `spatial_y`) |
|
|
||||||
| 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`) 유형 허용 |
|
|
||||||
| 10 | `2026-09-25-evidence-hold-and-view-audit.sql` | 증거 보존 보류(`hold_until`) + 통보 크론 생존 기록(`move_app_state`) |
|
|
||||||
|
|
||||||
1. phpMyAdmin → 해당 DB → **SQL** 탭
|
1. phpMyAdmin → 해당 DB → **SQL** 탭
|
||||||
2. 위 표의 순서대로 파일 내용을 **하나씩** 붙여넣고 실행합니다
|
2. `migrations/2026-07-17-p1-9-p3-2b.sql` 내용을 붙여넣고 실행
|
||||||
(각 파일 첫머리에 적용 조건이 주석으로 적혀 있습니다)
|
(site / event_uuid 컬럼 + 인덱스 추가, 기존 이벤트의 site 백필 포함)
|
||||||
3. 각 파일 맨 아래 **검증 쿼리**로 컬럼·표가 생겼는지 확인
|
3. 파일 맨 아래 **검증 쿼리**로 컬럼·인덱스가 생겼는지 확인
|
||||||
4. 전부 끝난 뒤에 새 코드를 배포합니다
|
4. 그 다음에 새 코드를 배포합니다 — 컬럼이 없으면 모든 요청이 500 이 됩니다
|
||||||
|
|
||||||
> 이미 적용한 마이그레이션을 다시 실행하면 "Duplicate column" 같은 오류가 납니다 —
|
|
||||||
> 재실행은 안전하지 않으니, 어디까지 적용했는지 기록해 두세요. 어디까지 했는지 모르겠으면
|
|
||||||
> phpMyAdmin 의 **Structure(구조)** 탭에서 위 표의 컬럼·표가 있는지 하나씩 확인하면 됩니다.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -180,61 +156,10 @@ VALUES ('pi-01', '3라인 낙상 카메라', '여기에_비밀키_붙여넣기',
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. 알림 켜기 (자동 실행 등록) — **이걸 안 하면 알림이 안 갑니다**
|
## 8. 마지막 확인
|
||||||
|
|
||||||
여기까지만 하면 시스템은 **감지는 하지만 아무에게도 알리지 않습니다.** 화면을 보고 있는
|
|
||||||
사람에게만 보입니다. 새벽에 넘어진 작업자를 아무도 모른 채 아침을 맞게 됩니다.
|
|
||||||
|
|
||||||
카페24 관리자 페이지 → **자동실행관리(크론)** 에서 아래 두 개를 등록하세요.
|
|
||||||
|
|
||||||
| 주기 | 실행할 명령 | 하는 일 |
|
|
||||||
|------|-------------|---------|
|
|
||||||
| 1분마다 | `/usr/bin/php /home/<계정>/move_detect_app/tools/dispatch_alerts.php` | **장치 이상·감시 성능 저하 판정** · 알림 발송 · 미확인 경보 상위 통보 · 실패 재시도 |
|
|
||||||
| 하루 1회 (새벽) | `/usr/bin/php /home/<계정>/move_detect_app/tools/maintain.php` | 사진 보존기간 정리 · 기록 용량 관리 |
|
|
||||||
|
|
||||||
`<계정>` 자리에는 카페24 FTP 계정명을 넣습니다. 경로가 헷갈리면 FTP 로 접속했을 때
|
|
||||||
`move_detect_app` 폴더가 보이는 그 위치입니다.
|
|
||||||
|
|
||||||
### 처음 켤 때는 반드시 시험 발송부터
|
|
||||||
|
|
||||||
잘못된 수신자 목록으로 첫 발송을 하면 그 현장에서는 두 번 다시 이 기능을 켜지 않게 됩니다.
|
|
||||||
크론에 넣기 전에 아래를 한 번 실행해 **누구에게 무엇이 갈지만** 확인하세요 (아무것도 보내지
|
|
||||||
않습니다).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
php /home/<계정>/move_detect_app/tools/dispatch_alerts.php --dry-run
|
|
||||||
```
|
|
||||||
|
|
||||||
### 알림 경로 설정
|
|
||||||
|
|
||||||
로그인 후 상단 **[시스템 설정]** → **통보 경로** 에서 등록합니다.
|
|
||||||
|
|
||||||
- **현장 경광등**을 가장 먼저 설정하세요. 라즈베리파이가 직접 울리므로 **인터넷이 끊겨도
|
|
||||||
동작하는 유일한 경로**입니다. 정전·회선 장애 때 오히려 위험이 높아지는 시간대에 유일하게
|
|
||||||
살아 있는 알림입니다.
|
|
||||||
- 메일·웹훅은 그 위에 얹는 보조 수단이고, 위 크론이 돌아야 나갑니다.
|
|
||||||
- `config.php` 의 `public_base_url` 이 비어 있으면 디스패처는 실행을 거부합니다 — 열리지
|
|
||||||
않는 링크가 담긴 알림을 보내는 것보다 안 보내는 편이 낫기 때문입니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 설치 점검 화면
|
|
||||||
|
|
||||||
로그인 후 **`https://내도메인/move_detect/setup`** 을 엽니다.
|
|
||||||
|
|
||||||
각 단계는 실제 상태를 조회해서 표시하므로, 체크가 켜졌다면 그 단계는 **정말로 동작하고
|
|
||||||
있는 것**입니다. 장치 등록 · 장치 통신 · 카메라 감지 · 위험구역 · 통보 경로 순서대로
|
|
||||||
확인하세요.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. 마지막 확인
|
|
||||||
|
|
||||||
- [ ] `https://내도메인/move_detect/login` 이 열린다
|
- [ ] `https://내도메인/move_detect/login` 이 열린다
|
||||||
- [ ] admin 으로 로그인되고 대시보드가 뜬다
|
- [ ] admin 으로 로그인되고 대시보드가 뜬다
|
||||||
- [ ] `schema.sql` 을 서버에서 지웠다
|
- [ ] `schema.sql` 을 서버에서 지웠다
|
||||||
- [ ] `www/move_detect/_preflight.php` 를 서버에서 지웠다
|
- [ ] `www/move_detect/_preflight.php` 를 서버에서 지웠다
|
||||||
- [ ] `https://내도메인/move_detect_app/config.php` 가 안 열린다 (열리면 위치가 잘못된 것)
|
- [ ] `https://내도메인/move_detect_app/config.php` 가 안 열린다 (열리면 위치가 잘못된 것)
|
||||||
- [ ] 자동실행(크론) 2개가 등록되어 있다 — **없으면 알림이 나가지 않습니다** (8번)
|
|
||||||
- [ ] `[시스템 설정] → 통보 경로` 에 최소 1개가 '사용' 상태다 (현장 경광등 우선)
|
|
||||||
- [ ] `/setup` 화면의 모든 단계에 체크가 켜져 있다 (9번)
|
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,7 @@
|
||||||
# Run on the BUILD machine (needs composer + php — dev-only tools, never on the host):
|
# Run on the BUILD machine (needs composer + php — dev-only tools, never on the host):
|
||||||
# server/deploy/build_cafe24_package.sh
|
# server/deploy/build_cafe24_package.sh
|
||||||
#
|
#
|
||||||
# Output: dist/1_cafe24_upload/ and (MAKE_ZIP=1)
|
# Output: dist/1_cafe24_upload/ and dist/move-detect-cafe24.zip
|
||||||
# dist/move-detect-cafe24-<VERSION>-<YYYYMMDD>.zip
|
|
||||||
#
|
#
|
||||||
# dist/1_cafe24_upload/
|
# dist/1_cafe24_upload/
|
||||||
# ├── move_detect_app/ -> UPLOAD to the FTP root. Not web-reachable.
|
# ├── move_detect_app/ -> UPLOAD to the FTP root. Not web-reachable.
|
||||||
|
|
@ -46,21 +45,7 @@ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
SERVER="$REPO_ROOT/server"
|
SERVER="$REPO_ROOT/server"
|
||||||
OUT_DIR="${OUT_DIR:-$REPO_ROOT/dist}"
|
OUT_DIR="${OUT_DIR:-$REPO_ROOT/dist}"
|
||||||
STAGE="$OUT_DIR/1_cafe24_upload"
|
STAGE="$OUT_DIR/1_cafe24_upload"
|
||||||
|
ZIP="$OUT_DIR/move-detect-cafe24.zip"
|
||||||
# Release identity. VERSION is the single source (rules/release-versioning.md) and its ABSENCE
|
|
||||||
# is fatal, not a fallback: a package named "unknown" is one nobody can match to a commit, and
|
|
||||||
# the whole point of stamping provenance is that a stale bundle is self-consistent and therefore
|
|
||||||
# undetectable from the inside.
|
|
||||||
[ -f "$REPO_ROOT/VERSION" ] || {
|
|
||||||
echo "ERROR: $REPO_ROOT/VERSION is missing. Refusing to build an unversioned package." >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
RELEASE_VERSION="$(tr -d '[:space:]' < "$REPO_ROOT/VERSION")"
|
|
||||||
[ -n "$RELEASE_VERSION" ] || { echo "ERROR: VERSION is empty." >&2; exit 1; }
|
|
||||||
BUILD_DAY="$(date -u +%Y%m%d)"
|
|
||||||
# {제품}-{VERSION}-{YYYYMMDD}.zip — a date alone cannot separate two builds of the same day,
|
|
||||||
# and a version alone cannot say which day's rebuild a site is running.
|
|
||||||
ZIP="$OUT_DIR/move-detect-cafe24-${RELEASE_VERSION}-${BUILD_DAY}.zip"
|
|
||||||
|
|
||||||
command -v composer >/dev/null 2>&1 || { echo "ERROR: composer not found on the build machine." >&2; exit 1; }
|
command -v composer >/dev/null 2>&1 || { echo "ERROR: composer not found on the build machine." >&2; exit 1; }
|
||||||
command -v php >/dev/null 2>&1 || { echo "ERROR: php not found on the build machine." >&2; exit 1; }
|
command -v php >/dev/null 2>&1 || { echo "ERROR: php not found on the build machine." >&2; exit 1; }
|
||||||
|
|
@ -159,47 +144,13 @@ echo ">> [4/6] staging app source"
|
||||||
cp -r "$SERVER/src/." "$PRIV/src/"
|
cp -r "$SERVER/src/." "$PRIV/src/"
|
||||||
cp -r "$SERVER/public/." "$WEB/"
|
cp -r "$SERVER/public/." "$WEB/"
|
||||||
cp "$SERVER/schema.sql" "$PRIV/schema.sql"
|
cp "$SERVER/schema.sql" "$PRIV/schema.sql"
|
||||||
# migrations/ ships too: a host that already imported an older schema.sql upgrades by
|
|
||||||
# running these (phpMyAdmin, date order) instead of re-importing — schema.sql is for a
|
|
||||||
# FRESH database only. Like schema.sql, delete from the host after applying.
|
|
||||||
cp -r "$SERVER/migrations" "$PRIV/migrations"
|
|
||||||
# tools/ ships INSIDE the private folder, never under www/. These are CLI entry points the host
|
|
||||||
# runs from cron (notification dispatch, retention) or by hand (backup, audit verification), and
|
|
||||||
# bootstrap.php refuses to run under any SAPI but CLI — but the real guarantee is that they are
|
|
||||||
# not web-reachable at all, because dispatch_alerts.php sends mail and backup.php reads every
|
|
||||||
# incident record in the database.
|
|
||||||
cp -r "$SERVER/tools" "$PRIV/tools"
|
|
||||||
# The hardened DocumentRoot .htaccess replaces server/public/'s dev-local rewrite-only one.
|
# The hardened DocumentRoot .htaccess replaces server/public/'s dev-local rewrite-only one.
|
||||||
cp "$SERVER/deploy/htaccess.www" "$WEB/.htaccess"
|
cp "$SERVER/deploy/htaccess.www" "$WEB/.htaccess"
|
||||||
cp "$SERVER/deploy/config.template.php" "$PRIV/config.php"
|
cp "$SERVER/deploy/config.template.php" "$PRIV/config.php"
|
||||||
# Provenance stamp. A package's own contents are always self-consistent, so nothing inside it
|
|
||||||
# reveals that it was built before the last four feature commits — which is exactly how a stale
|
|
||||||
# package once shipped. Keep it OUT of the web root: it names the build, not a public asset.
|
|
||||||
{
|
|
||||||
echo "package: move-detect-cafe24"
|
|
||||||
echo "version: $RELEASE_VERSION"
|
|
||||||
echo "git_commit: $(cd "$REPO_ROOT" && git rev-parse --short HEAD 2>/dev/null || echo unknown)"
|
|
||||||
echo "built_at: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
||||||
} > "$PRIV/BUILD-INFO.txt"
|
|
||||||
cp "$SERVER/deploy/preflight.php" "$WEB/_preflight.php"
|
cp "$SERVER/deploy/preflight.php" "$WEB/_preflight.php"
|
||||||
# .user.ini: display_errors=Off backstop for pre-parse errors (Cafe24 often ships On).
|
# .user.ini: display_errors=Off backstop for pre-parse errors (Cafe24 often ships On).
|
||||||
cp "$SERVER/deploy/user.ini" "$WEB/.user.ini"
|
cp "$SERVER/deploy/user.ini" "$WEB/.user.ini"
|
||||||
cp "$SERVER/deploy/README-UPLOAD.md" "$STAGE/README-UPLOAD.md"
|
cp "$SERVER/deploy/README-UPLOAD.md" "$STAGE/README-UPLOAD.md"
|
||||||
# 납품 동봉 문서. 스테이지 루트(= FTP 최상위)에 두지만 www/ 밖이라 웹으로는 열리지 않는다.
|
|
||||||
# 감사 리포트를 함께 싣는 이유: "무엇을 점검했고 무엇이 잔여인가"를 패키지 자체가 답하지
|
|
||||||
# 못하면, 인수인계 몇 달 뒤에 그 답을 아는 사람이 아무도 남지 않는다.
|
|
||||||
cp "$REPO_ROOT/CHANGELOG.md" "$STAGE/CHANGELOG.md"
|
|
||||||
cp "$REPO_ROOT/docs/delivery-audit.md" "$STAGE/납품점검-리포트.md"
|
|
||||||
# 역할별 사용 설명서. 화면이 바뀌면 캡처와 함께 다시 생성해야 하므로, 여기서 빌드까지 돌려
|
|
||||||
# 소스(manual.json + shots/)와 산출물이 어긋난 채 납품되는 경우를 없앤다. 구 UI 캡처가 남은
|
|
||||||
# 매뉴얼은 납품물 자체의 결함이다.
|
|
||||||
if command -v python >/dev/null 2>&1 || command -v python3 >/dev/null 2>&1; then
|
|
||||||
PY_BIN="$(command -v python3 || command -v python)"
|
|
||||||
"$PY_BIN" "$SERVER/deploy/manual/build_manual.py" --out "$STAGE/사용설명서.html" || { echo "ERROR: 사용설명서 생성 실패 — 캡처(shots/)가 빠졌는지 확인하세요." >&2; exit 1; }
|
|
||||||
else
|
|
||||||
echo "ERROR: python not found; 사용설명서를 생성할 수 없습니다." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# `cp -r` above copies whatever is in server/src|public — defensively strip dev/tooling caches
|
# `cp -r` above copies whatever is in server/src|public — defensively strip dev/tooling caches
|
||||||
# so a stray one (e.g. a tool that ran in the tree) can never ship inside the web-served
|
# so a stray one (e.g. a tool that ran in the tree) can never ship inside the web-served
|
||||||
|
|
|
||||||
|
|
@ -28,39 +28,4 @@ return [
|
||||||
'rate_limit_per_min' => 60, // 장비 1대당 분당 이벤트 상한
|
'rate_limit_per_min' => 60, // 장비 1대당 분당 이벤트 상한
|
||||||
'ingest_ip_rate_limit_per_min' => 300, // IP 1개당 분당 이벤트 상한 (DoS 방어)
|
'ingest_ip_rate_limit_per_min' => 300, // IP 1개당 분당 이벤트 상한 (DoS 방어)
|
||||||
'snapshot_retention_days' => 7, // 작업자 스냅샷 보존일 (PIPA; 지나면 삭제, 0=비활성)
|
'snapshot_retention_days' => 7, // 작업자 스냅샷 보존일 (PIPA; 지나면 삭제, 0=비활성)
|
||||||
// 이벤트 '기록'(이미지 아님) 보존일. 0 = 영구 보존이 기본값입니다 — 산업안전 조사나
|
|
||||||
// 노사 분쟁에서 수년 뒤에도 필요할 수 있는 사고 기록이라, 시스템이 임의로 지우지 않습니다.
|
|
||||||
// DB 용량 제한이 있는 현장만 값을 넣으세요(예: 1095 = 3년). 스냅샷 보존일보다 훨씬 커야 합니다.
|
|
||||||
'event_retention_days' => 0,
|
|
||||||
|
|
||||||
// ---- 4. 통보 (알림을 쓰려면 채우세요) -------------------------------
|
|
||||||
// 대시보드 주소입니다. 알림 메일/웹훅에 들어가는 사건 링크가 이 주소로 만들어집니다.
|
|
||||||
// 비워두면 통보 디스패처(tools/dispatch_alerts.php)가 실행을 거부합니다 —
|
|
||||||
// 열리지 않는 링크가 담긴 알림을 보내는 것보다 안 보내는 편이 낫기 때문입니다.
|
|
||||||
'public_base_url' => '', // 예: 'https://example.com/move_detect'
|
|
||||||
// 위급/높음 이벤트를 이 시간(초) 동안 아무도 확인하지 않으면 '미확인 경보' 이벤트를
|
|
||||||
// 새로 만들어 상위 담당자에게 통보합니다. 300 = 5분.
|
|
||||||
'escalate_after_seconds' => 300,
|
|
||||||
// 노드가 이 시간(초) 동안 아무 소식이 없으면 '장치 이상' 이벤트를 만듭니다. 하트비트는
|
|
||||||
// 30초 간격이라 180 이면 몇 번 걸러 먹어도 견딥니다. 짧게 줄이면 잠깐의 네트워크
|
|
||||||
// 끊김마다 알람이 울려, 정작 노드가 정말 죽었을 때 아무도 쳐다보지 않게 됩니다.
|
|
||||||
'device_stale_seconds' => 180,
|
|
||||||
// 노드 성능 저하(프레임 저하·카메라 오류·발열 감속)가 이 시간(초) 이상 계속되면
|
|
||||||
// '감시 성능 저하' 이벤트를 만듭니다. 한 번 튄 값으로는 만들지 않습니다.
|
|
||||||
'degraded_sustain_seconds' => 300,
|
|
||||||
// 성능 저하 판정 기준: 실제 프레임률이 target_fps 의 이 비율 아래로 떨어지면 저하로 봅니다.
|
|
||||||
// 노드를 일부러 낮은 프레임으로 돌리는 현장은 target_fps 를 실제 값으로 맞추세요.
|
|
||||||
// 그러지 않으면 모든 노드가 계속 '감시 성능 저하' 로 보고됩니다.
|
|
||||||
'degraded_fps_ratio' => 0.6,
|
|
||||||
'target_fps' => 12.0,
|
|
||||||
// 알림 메일·웹훅에 찍히는 시각의 기준 시간대입니다. 대시보드 화면은 보는 사람의 PC
|
|
||||||
// 시각을 쓰지만 알림은 서버가 만들기 때문에, 이 값이 틀리면 화면과 메일의 시각이
|
|
||||||
// 서로 다르게 나옵니다. 한국 현장은 그대로 두세요.
|
|
||||||
'timezone' => 'Asia/Seoul',
|
|
||||||
|
|
||||||
// ---- 5. 용량 관리 (그대로 두세요) -----------------------------------
|
|
||||||
// 장비 성능 추이 기록 보존일. 장비 1대당 하루 약 2,880행이 쌓입니다.
|
|
||||||
'metric_retention_days' => 30,
|
|
||||||
// 스냅샷 저장 공간 여유 하한(MB). 밑돌면 대시보드에 경고가 뜹니다. 0 = 검사 안 함.
|
|
||||||
'storage_min_free_mb' => 200,
|
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1,227 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""역할별 사용자 설명서 생성기 — 캡처를 base64 로 박아 넣은 단일 HTML.
|
|
||||||
|
|
||||||
WHY A SINGLE FILE
|
|
||||||
납품처는 폐쇄망이고, 매뉴얼은 메일로 돌거나 USB 로 옮겨 다닌다. 이미지가 옆 폴더에
|
|
||||||
있으면 그 순간 도판 없는 문서가 된다. 전부 한 파일에 넣으면 어디로 옮겨도 온전하다.
|
|
||||||
|
|
||||||
WHY REGENERATE INSTEAD OF EDITING HTML
|
|
||||||
화면이 바뀌면 도판도 바뀌어야 한다. 구 UI 캡처가 남은 매뉴얼은 납품물 자체의 결함이다
|
|
||||||
(rules: delivery-audit '매뉴얼 캡처 실물 정합'). 내용은 manual.json, 도판은 shots/ 에
|
|
||||||
두고 이 스크립트로 다시 뽑는 구조라, 화면이 바뀌면 캡처만 갈아끼우고 재실행하면 된다.
|
|
||||||
|
|
||||||
사용법:
|
|
||||||
python server/deploy/manual/build_manual.py # -> 사용설명서.html
|
|
||||||
python server/deploy/manual/build_manual.py --out X # 출력 경로 지정
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import base64
|
|
||||||
import datetime
|
|
||||||
import html
|
|
||||||
import json
|
|
||||||
import pathlib
|
|
||||||
import sys
|
|
||||||
|
|
||||||
HERE = pathlib.Path(__file__).resolve().parent
|
|
||||||
REPO = HERE.parents[2]
|
|
||||||
|
|
||||||
CALLOUT_LABEL = {"info": "참고", "warn": "주의", "danger": "경고"}
|
|
||||||
|
|
||||||
|
|
||||||
def data_uri(path: pathlib.Path) -> str:
|
|
||||||
return "data:image/jpeg;base64," + base64.b64encode(path.read_bytes()).decode("ascii")
|
|
||||||
|
|
||||||
|
|
||||||
CSS = """
|
|
||||||
:root{
|
|
||||||
--bg:#eef1f5; --card:#fff; --line:#dde3ea; --line-2:#eef2f6;
|
|
||||||
--ink:#131824; --ink-2:#464e5c; --ink-3:#646c7a;
|
|
||||||
--accent:#0b6b7a; --accent-wash:#e2f0f3; --accent-line:#b9d9df;
|
|
||||||
--warn-bg:#fbf6e6; --warn-line:#efe0b4; --warn-ink:#7a5606;
|
|
||||||
--crit-bg:#fdf2f2; --crit-line:#f2cccc; --crit-ink:#a51d1d;
|
|
||||||
--font:system-ui,-apple-system,"Segoe UI","Malgun Gothic","Apple SD Gothic Neo",sans-serif;
|
|
||||||
--mono:ui-monospace,"Cascadia Mono",Consolas,"D2Coding",monospace;
|
|
||||||
}
|
|
||||||
*{box-sizing:border-box}
|
|
||||||
body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--font);
|
|
||||||
line-height:1.75;-webkit-font-smoothing:antialiased}
|
|
||||||
.wrap{max-width:880px;margin:0 auto;padding:0 1.2rem 5rem}
|
|
||||||
|
|
||||||
/* 표지 */
|
|
||||||
.cover{padding:3.5rem 0 2rem;border-bottom:3px solid var(--ink);margin-bottom:2rem}
|
|
||||||
.cover .kind{margin:0;font-size:.72rem;font-weight:700;letter-spacing:.14em;
|
|
||||||
text-transform:uppercase;color:var(--ink-3)}
|
|
||||||
.cover h1{margin:.5rem 0 .3rem;font-size:2.1rem;letter-spacing:-.02em}
|
|
||||||
.cover .sub{margin:0;color:var(--ink-2);font-size:1rem}
|
|
||||||
.cover .meta{margin:1.2rem 0 0;font-family:var(--mono);font-size:.76rem;color:var(--ink-3)}
|
|
||||||
|
|
||||||
/* 목차 */
|
|
||||||
.toc{background:var(--card);border:1px solid var(--line);border-radius:10px;
|
|
||||||
padding:1.1rem 1.3rem;margin-bottom:2.5rem}
|
|
||||||
.toc h2{margin:0 0 .6rem;font-size:.72rem;font-weight:700;letter-spacing:.08em;
|
|
||||||
text-transform:uppercase;color:var(--ink-3)}
|
|
||||||
.toc ul{margin:.2rem 0 0;padding-left:1.1rem;list-style:none}
|
|
||||||
.toc li{margin:.15rem 0}
|
|
||||||
.toc li::before{content:"·";color:var(--ink-3);margin-right:.5rem}
|
|
||||||
.toc a{color:var(--accent);text-decoration:none}
|
|
||||||
.toc a:hover{text-decoration:underline;text-underline-offset:3px}
|
|
||||||
.toc-role{margin:.9rem 0 0;font-weight:700;color:var(--ink);font-size:.9rem}
|
|
||||||
.toc-role:first-of-type{margin-top:0}
|
|
||||||
|
|
||||||
/* 역할 */
|
|
||||||
.role{margin:0 0 3rem}
|
|
||||||
.role-head{padding:1rem 1.2rem;background:var(--accent-wash);
|
|
||||||
border:1px solid var(--accent-line);border-radius:10px;margin-bottom:1.5rem}
|
|
||||||
.role-head h2{margin:0;font-size:1.25rem;color:var(--accent)}
|
|
||||||
.role-head p{margin:.35rem 0 0;font-size:.9rem;color:var(--ink-2)}
|
|
||||||
|
|
||||||
section.step{background:var(--card);border:1px solid var(--line);border-radius:10px;
|
|
||||||
padding:1.3rem 1.4rem 1.5rem;margin-bottom:1rem}
|
|
||||||
section.step h3{margin:0 0 .8rem;font-size:1.05rem;letter-spacing:-.01em}
|
|
||||||
section.step p{margin:0 0 .7rem;font-size:.94rem;color:var(--ink-2)}
|
|
||||||
section.step p:last-child{margin-bottom:0}
|
|
||||||
section.step strong{color:var(--ink)}
|
|
||||||
figure{margin:0 0 1.1rem}
|
|
||||||
figure img{width:100%;height:auto;display:block;border:1px solid var(--line);border-radius:8px}
|
|
||||||
figcaption{margin-top:.35rem;font-size:.74rem;color:var(--ink-3)}
|
|
||||||
|
|
||||||
.callout{margin:1rem 0 0;padding:.75rem .9rem;border-radius:8px;font-size:.9rem;
|
|
||||||
border:1px solid var(--line);background:var(--line-2);color:var(--ink-2)}
|
|
||||||
.callout .tag{display:inline-block;font-size:.68rem;font-weight:700;letter-spacing:.06em;
|
|
||||||
margin-right:.45rem;text-transform:uppercase}
|
|
||||||
.callout.warn{background:var(--warn-bg);border-color:var(--warn-line);color:var(--warn-ink)}
|
|
||||||
.callout.danger{background:var(--crit-bg);border-color:var(--crit-line);color:var(--crit-ink)}
|
|
||||||
.callout.info{background:var(--accent-wash);border-color:var(--accent-line);color:var(--accent)}
|
|
||||||
|
|
||||||
code{font-family:var(--mono);font-size:.86em;background:var(--line-2);
|
|
||||||
padding:.1em .35em;border-radius:4px}
|
|
||||||
kbd{font-family:var(--mono);font-size:.8em;background:var(--card);border:1px solid var(--line);
|
|
||||||
border-bottom-width:2px;border-radius:4px;padding:.05em .4em}
|
|
||||||
.chip{display:inline-block;font-size:.72em;font-weight:700;color:#fff;background:var(--crit-ink);
|
|
||||||
padding:.1em .5em;border-radius:999px}
|
|
||||||
|
|
||||||
/* FAQ */
|
|
||||||
.faq{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:1.3rem 1.4rem}
|
|
||||||
.faq h2{margin:0 0 1rem;font-size:1.15rem}
|
|
||||||
.faq dt{font-weight:700;margin-top:1rem;color:var(--ink)}
|
|
||||||
.faq dt:first-of-type{margin-top:0}
|
|
||||||
.faq dd{margin:.3rem 0 0;font-size:.93rem;color:var(--ink-2)}
|
|
||||||
|
|
||||||
footer.doc{margin-top:2.5rem;padding-top:1rem;border-top:1px solid var(--line);
|
|
||||||
font-size:.76rem;color:var(--ink-3);line-height:1.7}
|
|
||||||
|
|
||||||
@media print{
|
|
||||||
body{background:#fff}
|
|
||||||
.wrap{max-width:none;padding:0}
|
|
||||||
section.step,.toc,.faq,.role-head{break-inside:avoid;box-shadow:none}
|
|
||||||
.toc{display:none}
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def render(doc: dict, shots: pathlib.Path, version: str) -> str:
|
|
||||||
out: list[str] = []
|
|
||||||
a = out.append
|
|
||||||
today = datetime.date.today().isoformat()
|
|
||||||
|
|
||||||
a("<!doctype html>")
|
|
||||||
a('<html lang="ko"><head><meta charset="utf-8">')
|
|
||||||
a('<meta name="viewport" content="width=device-width, initial-scale=1">')
|
|
||||||
a(f"<title>{html.escape(doc['title'])}</title>")
|
|
||||||
a(f"<style>{CSS}</style>")
|
|
||||||
a("</head><body><div class=\"wrap\">")
|
|
||||||
|
|
||||||
# 표지
|
|
||||||
a('<header class="cover">')
|
|
||||||
a('<p class="kind">사용 설명서</p>')
|
|
||||||
a(f"<h1>{html.escape(doc['title'])}</h1>")
|
|
||||||
a(f"<p class=\"sub\">{html.escape(doc['subtitle'])}</p>")
|
|
||||||
a(f'<p class="meta">버전 {html.escape(version)} · {today} 기준 화면</p>')
|
|
||||||
a("</header>")
|
|
||||||
|
|
||||||
# 목차. 항목 번호는 제목이 이미 달고 있으므로(1., 2., …) 목록 자체는 번호를 매기지
|
|
||||||
# 않는다. 역할 이름을 <li> 로 끼워 넣고 list-style 로만 숨기면 그 줄이 카운터를 먹어
|
|
||||||
# 본문 번호와 목차 번호가 어긋난다(7 다음이 9로 뛴다).
|
|
||||||
a('<nav class="toc"><h2>목차</h2>')
|
|
||||||
n = 1
|
|
||||||
for role in doc["roles"]:
|
|
||||||
a(f'<p class="toc-role">{html.escape(role["name"])}</p><ul>')
|
|
||||||
for sec in role["sections"]:
|
|
||||||
anchor = f"sec-{n}"
|
|
||||||
n += 1
|
|
||||||
sec["_anchor"] = anchor
|
|
||||||
a(f'<li><a href="#{anchor}">{html.escape(sec["heading"])}</a></li>')
|
|
||||||
a("</ul>")
|
|
||||||
a('<p class="toc-role">자주 묻는 질문</p>')
|
|
||||||
a("</nav>")
|
|
||||||
|
|
||||||
# 본문
|
|
||||||
for role in doc["roles"]:
|
|
||||||
a('<div class="role">')
|
|
||||||
a('<div class="role-head">')
|
|
||||||
a(f"<h2>{html.escape(role['name'])}</h2>")
|
|
||||||
a(f"<p>{html.escape(role['summary'])}</p>")
|
|
||||||
a("</div>")
|
|
||||||
for sec in role["sections"]:
|
|
||||||
a(f'<section class="step" id="{sec["_anchor"]}">')
|
|
||||||
a(f"<h3>{html.escape(sec['heading'])}</h3>")
|
|
||||||
shot = sec.get("shot")
|
|
||||||
if shot:
|
|
||||||
p = shots / shot
|
|
||||||
if not p.is_file():
|
|
||||||
# 도판 누락을 조용히 넘기면 구멍 난 매뉴얼이 납품된다.
|
|
||||||
raise SystemExit(f"ERROR: 캡처가 없습니다: {p}")
|
|
||||||
a("<figure>")
|
|
||||||
a(f'<img alt="{html.escape(sec["heading"])} 화면" src="{data_uri(p)}">')
|
|
||||||
a(f"<figcaption>{html.escape(sec['heading'])} — 실제 화면</figcaption>")
|
|
||||||
a("</figure>")
|
|
||||||
for para in sec["body"]:
|
|
||||||
a(f"<p>{para}</p>") # 본문은 의도된 인라인 마크업을 허용한다
|
|
||||||
co = sec.get("callout")
|
|
||||||
if co:
|
|
||||||
label = CALLOUT_LABEL.get(co["kind"], "참고")
|
|
||||||
a(f'<div class="callout {co["kind"]}">'
|
|
||||||
f'<span class="tag">{label}</span>{co["text"]}</div>')
|
|
||||||
a("</section>")
|
|
||||||
a("</div>")
|
|
||||||
|
|
||||||
# FAQ
|
|
||||||
a('<div class="faq"><h2>자주 묻는 질문</h2><dl>')
|
|
||||||
for item in doc["faq"]:
|
|
||||||
a(f"<dt>{html.escape(item['q'])}</dt><dd>{item['a']}</dd>")
|
|
||||||
a("</dl></div>")
|
|
||||||
|
|
||||||
a('<footer class="doc">')
|
|
||||||
a(f"이 문서는 버전 {html.escape(version)} 화면을 기준으로 작성되었습니다. "
|
|
||||||
"화면이 바뀌면 캡처와 함께 다시 생성됩니다.<br>"
|
|
||||||
"설치·업그레이드 절차는 <code>README-UPLOAD.md</code>, "
|
|
||||||
"변경 내역은 <code>CHANGELOG.md</code>, "
|
|
||||||
"점검 결과는 <code>납품점검-리포트.md</code> 를 참조하세요.")
|
|
||||||
a("</footer>")
|
|
||||||
|
|
||||||
a("</div></body></html>")
|
|
||||||
return "\n".join(out)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
ap = argparse.ArgumentParser()
|
|
||||||
ap.add_argument("--out", default=str(HERE / "사용설명서.html"))
|
|
||||||
ap.add_argument("--shots", default=str(HERE / "shots"))
|
|
||||||
args = ap.parse_args()
|
|
||||||
|
|
||||||
version_file = REPO / "VERSION"
|
|
||||||
if not version_file.is_file():
|
|
||||||
raise SystemExit("ERROR: VERSION 파일이 없습니다. 버전 없는 매뉴얼은 만들지 않습니다.")
|
|
||||||
version = version_file.read_text(encoding="utf-8").strip()
|
|
||||||
|
|
||||||
doc = json.loads((HERE / "manual.json").read_text(encoding="utf-8"))
|
|
||||||
out = pathlib.Path(args.out)
|
|
||||||
out.write_text(render(doc, pathlib.Path(args.shots), version), encoding="utf-8")
|
|
||||||
size_kb = out.stat().st_size / 1024
|
|
||||||
print(f"{out} ({size_kb:.0f} KB, 단일 파일 · 이미지 포함)", file=sys.stderr)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
|
|
@ -1,202 +0,0 @@
|
||||||
{
|
|
||||||
"title": "AI SAFETY CCTV 사용 설명서",
|
|
||||||
"subtitle": "작업장 안전 관제 · 역할별 안내",
|
|
||||||
"roles": [
|
|
||||||
{
|
|
||||||
"id": "operator",
|
|
||||||
"name": "운영자 (관제 담당)",
|
|
||||||
"summary": "화면을 보며 감지된 사건을 확인하고 판정합니다. 하루 대부분의 시간을 [이벤트] 화면에서 보냅니다.",
|
|
||||||
"sections": [
|
|
||||||
{
|
|
||||||
"heading": "1. 감지 스트림 읽는 법",
|
|
||||||
"shot": "events.jpg",
|
|
||||||
"body": [
|
|
||||||
"목록은 <strong>항상 최근 순서</strong>입니다. 확인 여부와 무관하게 시간순으로만 정렬되므로, 사고가 났을 때 '그 시각 전후에 무슨 일이 있었나'를 그대로 따라 읽을 수 있습니다.",
|
|
||||||
"각 줄의 왼쪽은 <strong>증거 사진</strong>입니다. 사진을 눌러도, 사건 이름을 눌러도 상세 화면으로 갑니다.",
|
|
||||||
"<span class=\"chip\">확인 필요</span> 표시가 붙은 줄이 아직 아무도 처리하지 않은 사건입니다. 심각도가 높을수록 표시가 진합니다.",
|
|
||||||
"왼쪽 <strong>장치·카메라</strong> 목록은 '지금 무엇이 보고 있는가'를 말합니다. 빨갛게 바뀌면 그 장치가 신호를 보내지 않는 것이고, 그 카메라는 감시 공백입니다."
|
|
||||||
],
|
|
||||||
"callout": {
|
|
||||||
"kind": "warn",
|
|
||||||
"text": "장치가 <strong>오프라인</strong>이면 그 카메라는 아무것도 감지하지 못합니다. 화면이 조용한 것과 카메라가 죽은 것은 다릅니다 — 목록의 색을 먼저 보세요."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"heading": "2. 확인 · 오탐 판정",
|
|
||||||
"body": [
|
|
||||||
"<strong>확인</strong>은 '내가 봤고 처리했다'는 뜻입니다. 누르면 그 줄이 제자리에서 <em>확인됨(이름)</em>으로 바뀝니다. 목록에서 사라지지 않으므로, 방금 무엇을 처리했는지 눈으로 확인할 수 있습니다.",
|
|
||||||
"<strong>오탐</strong>은 '감지가 틀렸다'는 뜻입니다. 한 번 누르면 <em>오탐 확정?</em>으로 바뀌고, 4초 안에 한 번 더 눌러야 처리됩니다. 되돌릴 수 없고 카메라별 오탐율 통계에 반영되기 때문입니다.",
|
|
||||||
"확인과 오탐은 모두 <strong>기록에 남습니다</strong>(누가, 언제). 취소 기능은 일부러 넣지 않았습니다 — 안전 기록을 조용히 되돌릴 수 있으면 그 기록의 가치가 사라집니다."
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"heading": "3. 밀린 사건 처리 · 키보드 조작",
|
|
||||||
"body": [
|
|
||||||
"<strong>미확인만</strong> 탭으로 바꾸면 처리할 것만 남습니다. 순서는 그대로이므로 사건 사이의 관계가 흐트러지지 않습니다.",
|
|
||||||
"<strong>일괄 확인</strong> 버튼은 화면에 보이는 미확인 사건을 한 번에 처리합니다. 단 <strong>위급은 제외</strong>됩니다 — 넘어짐은 반드시 한 건씩 눈으로 봐야 하기 때문입니다.",
|
|
||||||
"키보드: <kbd>J</kbd>/<kbd>K</kbd> 위아래 이동 · <kbd>A</kbd> 확인 · <kbd>F</kbd> 오탐 · <kbd>Enter</kbd> 상세 열기."
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"heading": "4. 사건 상세 — 무슨 일이 있었는지 보기",
|
|
||||||
"shot": "event-detail.jpg",
|
|
||||||
"body": [
|
|
||||||
"증거 사진 3장이 <strong>이전 → 직전 → 발생 시점</strong> 순으로 놓입니다. 결과만이 아니라 사건이 어떻게 진행됐는지를 봅니다.",
|
|
||||||
"얼굴은 촬영 시점에 자동으로 흐리게 처리되어 저장됩니다. 원본 얼굴은 어디에도 남지 않습니다.",
|
|
||||||
"이 화면에서 바로 <strong>확인 처리</strong>·<strong>오탐</strong>을 누를 수 있습니다. 목록으로 돌아가 그 줄을 다시 찾을 필요가 없습니다.",
|
|
||||||
"오른쪽 <strong>개선 조치 기록</strong>에 조치를 남기면, 리포트가 조치 전후의 감지 밀도를 자동으로 비교해 줍니다."
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"heading": "5. 특정 날짜 찾아보기",
|
|
||||||
"body": [
|
|
||||||
"왼쪽 <strong>날짜 조회</strong>에 날짜를 넣고 [조회]를 누르면 그날 하루만 봅니다. 사고 조사에서 '지난주 화요일 오후'를 찾을 때 씁니다.",
|
|
||||||
"조회 중에도 실시간 감시는 계속 돌아갑니다. 화면 위 배너의 [실시간 스트림으로]를 누르면 돌아옵니다."
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"heading": "6. 경보음",
|
|
||||||
"body": [
|
|
||||||
"미확인 <strong>위급</strong> 사건이 있으면 경보음이 반복됩니다.",
|
|
||||||
"[경보음 끄기]로 잠시 끌 수 있지만, <strong>새 위급 사건이 오면 자동으로 다시 켜집니다.</strong> 밀린 사건 때문에 끈 것이 다음 사고를 놓치는 이유가 되지 않게 하기 위해서입니다.",
|
|
||||||
"음소거는 이 화면에서만, 이번 접속에만 적용됩니다. 새로고침하면 다시 켜집니다."
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "manager",
|
|
||||||
"name": "안전관리자",
|
|
||||||
"summary": "주간 안전회의 자료를 만들고, 사고가 나면 조사 자료를 제출하고, 위험구역과 위험성평가를 관리합니다.",
|
|
||||||
"sections": [
|
|
||||||
{
|
|
||||||
"heading": "7. 안전 리포트 — 주간 회의 자료",
|
|
||||||
"shot": "report.jpg",
|
|
||||||
"body": [
|
|
||||||
"상단 네 숫자가 그 기간의 요약입니다. 각 숫자 아래 <strong>이전 기간 대비</strong> 화살표가 붙어 추세를 함께 보여줍니다.",
|
|
||||||
"<strong>니어미스 : 사고</strong> 비율은 '사고 전 신호가 먼저 잡히고 있는가'를 봅니다. 니어미스가 많다는 것은 예방이 작동하고 있다는 뜻입니다.",
|
|
||||||
"[인쇄] 버튼을 누르면 화면 장식이 빠지고 A4 한 장으로 정리됩니다. 그대로 회의 자료로 씁니다.",
|
|
||||||
"<strong>표본이 적은 수치는 비율 대신 원시 숫자만</strong> 보여줍니다. 6건이 8건이 된 것을 '33% 악화'라고 적으면 회의에서 반박당하고, 그 순간 이 자료 전체가 신뢰를 잃기 때문입니다."
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"heading": "8. 사고조사 패키지",
|
|
||||||
"shot": "package.jpg",
|
|
||||||
"body": [
|
|
||||||
"사건 상세 화면의 [사고조사 패키지 →]를 누르면 만들어집니다.",
|
|
||||||
"한 장에 들어가는 것: 사건 개요와 <strong>대응 소요 시간</strong>, 증거 사진, <strong>같은 카메라의 전후 30분 타임라인</strong>, 누구에게 언제 알림이 갔는지, 해당 위험요인과 통제 대책, 그 카메라의 개선 조치 이력, 그리고 서명란.",
|
|
||||||
"[인쇄 · PDF 저장]으로 그대로 제출할 수 있습니다.",
|
|
||||||
"사고 다음 날 이 자료를 손으로 모을 시간이 있는 사람은 없습니다. 그래서 한 번에 나오게 만들었습니다."
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"heading": "9. 위험구역 그리기",
|
|
||||||
"shot": "zones.jpg",
|
|
||||||
"body": [
|
|
||||||
"① 장치와 카메라를 고릅니다. 고르면 그 카메라의 최근 화면과 저장된 구역이 자동으로 뜹니다.",
|
|
||||||
"② 등급을 정하고 화면을 클릭해 점을 3개 이상 찍습니다. <strong>경고</strong>는 진입 시 알림, <strong>제한</strong>은 진입 자체가 위반, <strong>협착</strong>은 끼임 위험 구역입니다.",
|
|
||||||
"③ 필요하면 점유 규칙을 넣습니다. 최소 인원(2인 1조 강제) · 최대 인원(정원 초과) · 체류 상한.",
|
|
||||||
"[이 구역 저장]을 누르면 다음 설정 동기화 때 장비로 내려갑니다."
|
|
||||||
],
|
|
||||||
"callout": {
|
|
||||||
"kind": "warn",
|
|
||||||
"text": "<strong>카메라를 건드리면 구역을 다시 그려야 합니다.</strong> 각도가 바뀌면 구역이 엉뚱한 바닥을 가리키게 됩니다. 시스템이 화각 변경을 감지하면 <em>카메라 화각 변경</em> 사건으로 알려주니, 그 사건이 뜨면 반드시 이 화면에서 확인하세요."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"heading": "10. 안전 관리 — 인수인계 · 정비 억제 · 위험성평가",
|
|
||||||
"shot": "compliance.jpg",
|
|
||||||
"body": [
|
|
||||||
"<strong>교대 인수인계</strong>: 교대 시작 때 [인수 확인]을 누르면 '누가 어떤 미처리 건을 넘겨받았는지'가 기록됩니다. 숫자는 시스템이 직접 세므로 실제보다 적게 적힐 수 없습니다.",
|
|
||||||
"<strong>정비 중 알람 억제</strong>: 정비·점검처럼 계획된 작업 때 알림만 잠시 멈춥니다. 사유는 필수이고 최대 8시간이며 시간이 지나면 자동 해제됩니다.",
|
|
||||||
"<strong>위험성평가 연계</strong>: 각 감지 유형이 어떤 위험요인을 대신 보고 있는지 기록합니다. 사고조사 패키지에 이 내용이 함께 실립니다.",
|
|
||||||
"<strong>감시 설비 성능 추이</strong>: 장치가 '지금 살아 있는가'가 아니라 '점점 나빠지고 있는가'를 봅니다. 열화로 죽는 장비는 멈추기 며칠 전부터 프레임률이 흘러내립니다."
|
|
||||||
],
|
|
||||||
"callout": {
|
|
||||||
"kind": "info",
|
|
||||||
"text": "억제 중에도 <strong>감지와 기록은 그대로 계속됩니다.</strong> 알림만 보류되고, 보류했다는 사실도 기록에 남습니다 — 나중에 그 시간대에 무슨 일이 있었는지 반드시 볼 수 있어야 하기 때문입니다."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "admin",
|
|
||||||
"name": "시스템 관리자",
|
|
||||||
"summary": "설치·설정·알림 경로를 담당합니다. 평소에는 볼 일이 없고, 설치할 때와 무언가 바꿀 때만 봅니다.",
|
|
||||||
"sections": [
|
|
||||||
{
|
|
||||||
"heading": "11. 설치 점검",
|
|
||||||
"shot": "setup.jpg",
|
|
||||||
"body": [
|
|
||||||
"[시스템 설정] 화면 상단의 <strong>설치 점검</strong> 링크, 또는 주소창에 <code>/setup</code>.",
|
|
||||||
"각 단계는 <strong>실제 상태를 조회해서</strong> 표시됩니다. 체크가 켜졌다면 그 단계는 정말로 동작하고 있는 것입니다.",
|
|
||||||
"설치 직후 반드시 한 번 열어보세요. 화면이 멀쩡히 뜨는데 아무것도 감시하지 않는 상태가 이 제품에서 가장 위험한 구간입니다."
|
|
||||||
],
|
|
||||||
"callout": {
|
|
||||||
"kind": "danger",
|
|
||||||
"text": "<strong>⑤ 통보 경로</strong>가 비어 있으면 이 시스템은 감지 기록 장치일 뿐입니다. 감지되어도 화면을 보고 있는 사람에게만 전달됩니다."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"heading": "12. 통보 경로 설정",
|
|
||||||
"shot": "admin.jpg",
|
|
||||||
"body": [
|
|
||||||
"<strong>현장 경광등을 가장 먼저 설정하세요.</strong> 라즈베리파이가 직접 울리므로 인터넷이나 서버가 끊겨도 동작하는 유일한 경로입니다. 정전·회선 장애는 오히려 위험이 높아지는 때입니다.",
|
|
||||||
"메일·웹훅은 그 위에 얹는 보조 수단이며, 서버의 자동 실행(크론)이 등록되어 있어야 나갑니다.",
|
|
||||||
"<strong>단계</strong>를 1 이상으로 두면 에스컬레이션 채널이 됩니다. 사건이 정해진 시간 동안 확인되지 않을 때만 울리므로, 상급자에게 가는 경로로 씁니다.",
|
|
||||||
"알림에는 현장 사진이 들어가지 않습니다. 링크만 보내고, 사진은 로그인한 사람만 볼 수 있습니다."
|
|
||||||
],
|
|
||||||
"callout": {
|
|
||||||
"kind": "warn",
|
|
||||||
"text": "채널을 처음 켤 때는 <strong>반드시 시험 발송(<code>--dry-run</code>)부터</strong> 하세요. 잘못된 수신자로 첫 발송을 하면 그 현장에서는 두 번 다시 이 기능을 켜지 않게 됩니다. 방법은 설치 안내서(README-UPLOAD.md) 8번에 있습니다."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"heading": "13. 감지 임계값 조정",
|
|
||||||
"body": [
|
|
||||||
"같은 [시스템 설정] 화면 아래쪽입니다. 장치를 고르면 조정 가능한 값들이 설명과 함께 나옵니다.",
|
|
||||||
"값을 바꾸면 그 장치의 설정 버전이 올라가고, 다음 동기화(최대 수 분) 때 적용됩니다. 적용됐는지는 [이벤트] 화면 왼쪽 장치 목록의 <strong>설정 지연</strong> 표시가 사라지는 것으로 확인합니다.",
|
|
||||||
"허용 범위를 벗어난 값은 저장되지 않습니다. 장비가 기동할 때 거부하는 값이라 미리 막습니다.",
|
|
||||||
"언제 조정하나: 특정 카메라의 오탐이 계속되면 [이벤트] 화면 왼쪽 아래 <strong>오탐율</strong> 패널을 먼저 보세요. 30% 이상인 카메라·유형이 재조정 후보입니다.",
|
|
||||||
"모든 변경은 누가 언제 바꿨는지 기록에 남습니다."
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"heading": "14. 정기 점검 항목",
|
|
||||||
"body": [
|
|
||||||
"<strong>매일</strong>: [이벤트] 화면 왼쪽 장치 목록이 전부 초록인지. 빨간 장치는 감시 공백입니다.",
|
|
||||||
"<strong>매주</strong>: [안전 리포트]를 인쇄해 회의 자료로. 오탐율이 오르고 있으면 임계값 조정을 검토합니다.",
|
|
||||||
"<strong>매월</strong>: 저장 공간 경고가 뜨지 않는지. 뜨면 사진 보존기간을 줄이거나 용량을 늘려야 합니다 — 그대로 두면 새 사건의 증거 사진이 저장되지 않습니다.",
|
|
||||||
"<strong>분기</strong>: 백업(<code>tools/backup.php</code>)을 받아 안전한 곳에 보관. 감사 기록 무결성 검증(<code>tools/verify_audit_chain.php</code>)도 함께 실행합니다."
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"faq": [
|
|
||||||
{
|
|
||||||
"q": "화면이 조용한데 정상인가요?",
|
|
||||||
"a": "왼쪽 장치 목록이 전부 초록이면 정상입니다. 빨간 장치가 있으면 그 카메라는 아무것도 보지 못하고 있는 것이므로, 조용한 것이 아니라 <strong>안 보이는 것</strong>입니다."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"q": "알림이 안 옵니다.",
|
|
||||||
"a": "① <code>/setup</code> 화면의 ⑤ 통보 경로에 체크가 켜져 있는지 ② 자동 실행(크론)이 등록되어 있는지 ③ 채널의 '최소 심각도'가 너무 높지 않은지 순서로 확인하세요. ②가 가장 흔한 원인입니다."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"q": "잘못 확인 처리했습니다. 되돌릴 수 있나요?",
|
|
||||||
"a": "화면에서는 되돌릴 수 없습니다. 안전 기록을 조용히 되돌릴 수 있으면 그 기록을 근거로 쓸 수 없기 때문입니다. 잘못 처리한 사건은 그대로 두고, 필요하면 개선 조치 기록에 경위를 남기세요."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"q": "오탐이 너무 많습니다.",
|
|
||||||
"a": "[이벤트] 화면 왼쪽 아래 <strong>오탐율</strong> 패널에서 어떤 카메라·유형이 문제인지 먼저 확인하세요. 표본이 적으면 비율 대신 원시 숫자만 표시됩니다. 30% 이상인 항목이 조정 대상이고, 조정은 [시스템 설정]의 감지 임계값에서 합니다."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"q": "작업자 얼굴이 찍히는 게 걱정됩니다.",
|
|
||||||
"a": "얼굴은 촬영하는 순간 흐리게 처리되어 저장됩니다. 원본은 어디에도 남지 않습니다. 사진은 로그인한 사람만 볼 수 있고, 정해진 보존기간이 지나면 자동으로 삭제됩니다. 알림 메일에는 사진이 들어가지 않습니다."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"q": "정비 중인데 알람이 계속 울립니다.",
|
|
||||||
"a": "[안전 관리] → 정비 중 알람 억제에서 대상과 기간, 사유를 넣으세요. 알림만 멈추고 감지·기록은 계속되므로 나중에 그 시간대를 조사할 수 있습니다."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
|
@ -139,14 +139,13 @@ or version control.
|
||||||
|
|
||||||
## 4. Import the schema
|
## 4. Import the schema
|
||||||
|
|
||||||
`server/schema.sql` contains the full MySQL 8.0+ DDL. All tables carry the `move_` prefix
|
`server/schema.sql` contains the full MySQL 8.0+ DDL (`users`, `devices`, `cameras`, `zones`,
|
||||||
(`move_users`, `move_devices`, `move_cameras`, `move_actions`, `move_zones`, `move_events`,
|
`events`, `event_snapshots`, `thresholds`, `nonces`, `rate_limits`, `audit_log`).
|
||||||
`move_event_snapshots`, `move_thresholds`, `move_nonces`, `move_rate_limits`, `move_audit_log`).
|
|
||||||
|
|
||||||
1. Open **phpMyAdmin** from the Cafe24 control panel, select the database created for this
|
1. Open **phpMyAdmin** from the Cafe24 control panel, select the database created for this
|
||||||
app.
|
app.
|
||||||
2. **Import** tab → choose file → upload `schema.sql` → Go.
|
2. **Import** tab → choose file → upload `schema.sql` → Go.
|
||||||
3. Confirm all 11 tables were created (phpMyAdmin's table list on the left, or
|
3. Confirm all 10 tables were created (phpMyAdmin's table list on the left, or
|
||||||
`SHOW TABLES;` in the SQL tab).
|
`SHOW TABLES;` in the SQL tab).
|
||||||
4. Delete `schema.sql` from the server once the import succeeds — it doesn't need to live
|
4. Delete `schema.sql` from the server once the import succeeds — it doesn't need to live
|
||||||
on the host afterwards, and leaving DDL lying around outside the web root is unnecessary
|
on the host afterwards, and leaving DDL lying around outside the web root is unnecessary
|
||||||
|
|
@ -280,45 +279,3 @@ client as a bare 500 with no body/trace, which is the intended behavior — turn
|
||||||
- [ ] `storage/snapshots/`, `src/`, `config.php`, and `schema.sql` (if not yet deleted per
|
- [ ] `storage/snapshots/`, `src/`, `config.php`, and `schema.sql` (if not yet deleted per
|
||||||
§4) are **not** reachable over HTTP — e.g. `curl -I https://<domain>/../config.php`
|
§4) are **not** reachable over HTTP — e.g. `curl -I https://<domain>/../config.php`
|
||||||
and `curl -I https://<domain>/storage/snapshots/` should not return the file/listing.
|
and `curl -I https://<domain>/storage/snapshots/` should not return the file/listing.
|
||||||
|
|
||||||
## 11. Scheduled jobs (cron)
|
|
||||||
|
|
||||||
Two jobs turn the installation from "records incidents" into "responds to incidents" and keep
|
|
||||||
the database inside a shared-hosting quota. Both live in `move_detect_app/tools/` — **inside the
|
|
||||||
private folder, never under `www/`** — and refuse to run under any SAPI but CLI.
|
|
||||||
|
|
||||||
```cron
|
|
||||||
# 통보 디스패처 — 통보 발송 + 미확인 에스컬레이션 + 실패 재시도.
|
|
||||||
# 이것이 돌지 않으면 감지는 되지만 아무에게도 전달되지 않는다 (콘솔을 보고 있는 사람 제외).
|
|
||||||
*/1 * * * * /usr/bin/php /home/<account>/move_detect_app/tools/dispatch_alerts.php >> /home/<account>/logs/alerts.log 2>&1
|
|
||||||
|
|
||||||
# 보존기간 정리 — 스냅샷(PIPA) · 성능 시계열 · nonce.
|
|
||||||
# 이벤트 기록은 event_retention_days 를 명시적으로 설정한 현장에서만 지워진다.
|
|
||||||
17 3 * * * /usr/bin/php /home/<account>/move_detect_app/tools/maintain.php >> /home/<account>/logs/maintain.log 2>&1
|
|
||||||
```
|
|
||||||
|
|
||||||
**처음 켤 때는 반드시 dry-run 부터.** 잘못된 수신자 목록으로 첫 발송을 하면 그 현장에서는
|
|
||||||
두 번 다시 이 기능을 켜지 않습니다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
php move_detect_app/tools/dispatch_alerts.php --dry-run
|
|
||||||
php move_detect_app/tools/maintain.php --dry-run
|
|
||||||
```
|
|
||||||
|
|
||||||
`public_base_url` 이 비어 있으면 디스패처는 실행을 거부합니다 — 열리지 않는 링크가 담긴
|
|
||||||
알림을 보내는 것보다 안 보내는 편이 낫기 때문입니다 (`config.php` §4).
|
|
||||||
|
|
||||||
### 그 외 도구 (수동 실행)
|
|
||||||
|
|
||||||
| 도구 | 용도 |
|
|
||||||
|------|------|
|
|
||||||
| `tools/backup.php --out=<디렉터리>` | DB 덤프 + 스냅샷 아카이브 + 복구 안내. 출력 경로가 `www/` 안이면 거부합니다. |
|
|
||||||
| `tools/verify_audit_chain.php` | 감사로그 해시체인 검증. 손상 시 종료 코드 1 과 함께 끊긴 지점을 출력합니다. |
|
|
||||||
| `tools/seed_demo.php --days=45` | 시연·QA용 데모 데이터. **운영 DB 에서는 절대 실행하지 마십시오** (`--purge` 로 데모 행만 제거). |
|
|
||||||
|
|
||||||
## 12. Post-deploy: 설치 점검 화면
|
|
||||||
|
|
||||||
로그인 후 `/setup` 을 엽니다. 각 단계는 실제 상태를 조회해 판정하므로, 체크가 켜졌다면 그
|
|
||||||
단계는 정말로 동작하는 것입니다. 특히 **⑤ 통보 경로** 가 비어 있으면 이 시스템은 감지 기록
|
|
||||||
장치일 뿐입니다 — 현장 경광등(엣지 GPIO)부터 설정하십시오. 인터넷이 끊겨도 동작하는 유일한
|
|
||||||
경로입니다.
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
-- Operator false-positive tag (오탐). Feeds per-camera/type FP-rate statistics only —
|
|
||||||
-- by product decision the tag never auto-tunes edge thresholds; a human reads the rate
|
|
||||||
-- and adjusts deliberately. Tagging also acknowledges the event (an FP is handled).
|
|
||||||
ALTER TABLE move_events
|
|
||||||
ADD COLUMN false_positive_by VARCHAR(190) NULL AFTER acknowledged_at,
|
|
||||||
ADD COLUMN false_positive_at BIGINT UNSIGNED NULL AFTER false_positive_by;
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
-- Heatmap raw material: normalized (0..1) spatial point per event, and the five new
|
|
||||||
-- event types (occupancy rules + near-miss + ergo report). The chk_events_type constraint
|
|
||||||
-- would REJECT the new types' inserts on MySQL 8.0.16+ without this. (Never applied to any
|
|
||||||
-- deployment before this revision, so the type list is edited in place.)
|
|
||||||
ALTER TABLE move_events
|
|
||||||
ADD COLUMN spatial_x DOUBLE NULL AFTER metrics_json,
|
|
||||||
ADD COLUMN spatial_y DOUBLE NULL AFTER spatial_x;
|
|
||||||
|
|
||||||
ALTER TABLE move_events DROP CHECK chk_events_type;
|
|
||||||
ALTER TABLE move_events ADD CONSTRAINT chk_events_type CHECK (event_type IN (
|
|
||||||
'fall','no_motion','immobility','ghost','zone_warning',
|
|
||||||
'zone_restricted','pinch','monitoring_degraded','device_fault',
|
|
||||||
'lone_worker','overcapacity','overstay','near_fall','ergo_report'
|
|
||||||
));
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
-- Zone occupancy rules (2인 1조 / 정원 / 체류 상한).
|
|
||||||
-- Nullable JSON blob validated app-side (ZoneConfigController::rulesJson); NULL = no rules.
|
|
||||||
-- Delivered to the edge unchanged via GET /api/config -> zones rows.
|
|
||||||
ALTER TABLE move_zones
|
|
||||||
ADD COLUMN rules_json JSON NULL AFTER polygon_json;
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
-- Improvement action log (개선 조치 기록): "미끄럼 방지 매트 설치" 같은 현장 조치를
|
|
||||||
-- 카메라에 기록해 두면, 리포트가 조치 전후의 검출 밀도를 자동 비교한다 —
|
|
||||||
-- 안전 투자의 효과가 데이터로 증명되는 경로.
|
|
||||||
CREATE TABLE move_actions (
|
|
||||||
action_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
||||||
camera_id VARCHAR(64) NOT NULL,
|
|
||||||
note VARCHAR(500) NOT NULL,
|
|
||||||
taken_at BIGINT UNSIGNED NOT NULL, -- 조치 시행 시각(epoch s)
|
|
||||||
created_by VARCHAR(190) NOT NULL,
|
|
||||||
created_at BIGINT UNSIGNED NOT NULL,
|
|
||||||
PRIMARY KEY (action_id),
|
|
||||||
KEY ix_actions_taken (taken_at)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
-- 개선 조치(move_actions)에 site 스코프 추가.
|
|
||||||
-- 이 표만 site 컬럼 없이 추가되어, 스코프 운영자가 타 현장의 조치 기록(카메라 ID·메모·
|
|
||||||
-- 작성자)을 목록에서 그대로 읽을 수 있었다. 다른 표(events/devices/zones)와 동일하게
|
|
||||||
-- site 를 비정규화해 SQL 단계에서 걸러낸다.
|
|
||||||
--
|
|
||||||
-- 적용 조건: 2026-08-07-improvement-actions.sql 이후.
|
|
||||||
|
|
||||||
ALTER TABLE move_actions
|
|
||||||
ADD COLUMN site VARCHAR(64) NULL AFTER camera_id,
|
|
||||||
ADD KEY ix_actions_site_taken (site, taken_at);
|
|
||||||
|
|
||||||
-- 기존 행 백필: 조치가 달린 카메라가 이벤트를 남긴 현장을 그 조치의 현장으로 본다.
|
|
||||||
-- (카메라는 별도 등록 표가 없고 events.site 가 유일한 소유 근거다.)
|
|
||||||
UPDATE move_actions a
|
|
||||||
SET a.site = (SELECT e.site FROM move_events e
|
|
||||||
WHERE e.camera_id = a.camera_id AND e.site IS NOT NULL
|
|
||||||
ORDER BY e.event_id DESC LIMIT 1)
|
|
||||||
WHERE a.site IS NULL;
|
|
||||||
|
|
||||||
-- 검증: site 가 채워졌는지 확인한다. NULL 로 남은 행은 해당 카메라의 이벤트가 없거나
|
|
||||||
-- 현장 미지정(devices.site IS NULL)인 경우로, 전역 관리자에게만 보인다.
|
|
||||||
-- SELECT site, COUNT(*) FROM move_actions GROUP BY site;
|
|
||||||
|
|
@ -1,218 +0,0 @@
|
||||||
-- 안전 플랫폼 확장 — 통보·억제·증거무결성·운영지표.
|
|
||||||
--
|
|
||||||
-- 이 마이그레이션 하나로 다음 기능의 저장소를 연다. 기능별로 파일을 쪼개지 않은 이유는
|
|
||||||
-- 서로를 참조하기 때문이다(억제 규칙은 통보를 막고, 통보 실패는 감사로그에 남고, 감사로그는
|
|
||||||
-- 해시체인으로 봉인된다). 부분 적용된 중간 상태가 더 위험하다.
|
|
||||||
--
|
|
||||||
-- 1. move_audit_log 해시체인 — 증거 무결성 (schema.sql 이 "P3 로 연기"라 적어둔 항목)
|
|
||||||
-- 2. move_suppressions — 정비/작업허가 중 알람 억제 (사유·기간·감사 필수)
|
|
||||||
-- 3. move_alert_channels/_log — 통보 경로와 전달 이력 (지금까지 통보 수단이 전무했다)
|
|
||||||
-- 4. move_shift_handovers — 교대 인수인계 기록
|
|
||||||
-- 5. move_risk_controls — 감지 유형 ↔ 위험요인·대책 (위험성평가 연계)
|
|
||||||
-- 6. move_device_metrics — 엣지 성능 시계열 (지금은 순간값만 있어 열화를 못 본다)
|
|
||||||
-- 7. move_thresholds 감사 컬럼 — 편집 UI 가 생기므로 누가 언제 바꿨는지 남긴다
|
|
||||||
-- 8. move_actions.event_id — 어떤 사건이 그 조치의 계기였는지
|
|
||||||
-- 9. move_events CHECK 확장 — 신규 감지 유형
|
|
||||||
--
|
|
||||||
-- 적용 조건: 2026-08-08-actions-site.sql 이후.
|
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------------
|
|
||||||
-- 1. 감사로그 해시체인
|
|
||||||
-- 각 행은 직전 행의 entry_hash 를 prev_hash 로 물고, 자신의 내용+prev_hash 를 sha256 한
|
|
||||||
-- 값을 entry_hash 로 갖는다. 중간 행을 지우거나 고치면 그 이후 전 구간의 사슬이 끊긴다.
|
|
||||||
-- (삭제·수정을 "막는" 것이 아니라 "숨길 수 없게" 만드는 것이 목적이다. append-only 권한을
|
|
||||||
-- DB 레벨에서 강제할 수 없는 공유호스팅에서는 이것이 실질적인 방어선이다.)
|
|
||||||
-- 기존 행은 NULL 로 남는다 — 체인 이전 구간임을 검증 도구가 그대로 보고한다.
|
|
||||||
ALTER TABLE move_audit_log
|
|
||||||
ADD COLUMN prev_hash CHAR(64) NULL AFTER ts,
|
|
||||||
ADD COLUMN entry_hash CHAR(64) NULL AFTER prev_hash,
|
|
||||||
ADD KEY ix_audit_log_entry_hash (entry_hash);
|
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------------
|
|
||||||
-- 2. 알람 억제 (정비 모드 / 작업허가)
|
|
||||||
-- 오탐의 최대 원인은 "계획된 작업"이다. 정비 중 제한구역 진입이 전부 알람이면 현장은
|
|
||||||
-- 시스템을 끄고, 그 다음 진짜 사고를 놓친다. 억제는 감지를 멈추지 않는다 — 이벤트는 그대로
|
|
||||||
-- 적재되고 통보만 막힌다. 사후 조사에서 "그때 무슨 일이 있었나"가 사라지면 안 되기 때문이다.
|
|
||||||
CREATE TABLE move_suppressions (
|
|
||||||
suppression_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
||||||
scope_type VARCHAR(16) NOT NULL, -- device | camera | zone
|
|
||||||
scope_id VARCHAR(64) NOT NULL, -- 해당 스코프의 식별자
|
|
||||||
site VARCHAR(64) NULL, -- 접근제어 스코프
|
|
||||||
reason VARCHAR(500) NOT NULL, -- 필수: 사유 없는 억제는 허용하지 않는다
|
|
||||||
starts_at BIGINT UNSIGNED NOT NULL,
|
|
||||||
ends_at BIGINT UNSIGNED NOT NULL, -- 무기한 억제 없음. 반드시 만료된다.
|
|
||||||
created_by VARCHAR(190) NOT NULL,
|
|
||||||
created_at BIGINT UNSIGNED NOT NULL,
|
|
||||||
revoked_by VARCHAR(190) NULL, -- 조기 해제
|
|
||||||
revoked_at BIGINT UNSIGNED NULL,
|
|
||||||
PRIMARY KEY (suppression_id),
|
|
||||||
KEY ix_suppressions_window (ends_at, starts_at),
|
|
||||||
KEY ix_suppressions_scope (scope_type, scope_id),
|
|
||||||
KEY ix_suppressions_site (site),
|
|
||||||
CONSTRAINT chk_suppressions_scope CHECK (scope_type IN ('device','camera','zone'))
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------------
|
|
||||||
-- 3. 통보 경로와 전달 이력
|
|
||||||
-- 폐쇄망이라 FCM/Slack 을 쓸 수 없다. 사내에서 실제로 닿는 경로만 둔다:
|
|
||||||
-- local — 엣지 노드의 경광등/부저 (GPIO). 인터넷이 필요 없는 유일한 경로이자 1순위.
|
|
||||||
-- email — 사내 SMTP 릴레이
|
|
||||||
-- webhook— 사내 시스템(그룹웨어·문자게이트웨이) HTTP 엔드포인트
|
|
||||||
-- config_json 은 경로별 설정(수신자, URL, GPIO 핀 등). 비밀번호류는 넣지 않는다 —
|
|
||||||
-- SMTP 인증정보는 config.php 에 두고 여기서는 참조만 한다.
|
|
||||||
CREATE TABLE move_alert_channels (
|
|
||||||
channel_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
||||||
site VARCHAR(64) NULL,
|
|
||||||
kind VARCHAR(16) NOT NULL, -- local | email | webhook
|
|
||||||
name VARCHAR(190) NOT NULL,
|
|
||||||
config_json JSON NOT NULL,
|
|
||||||
min_severity VARCHAR(16) NOT NULL DEFAULT 'high',
|
|
||||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
|
||||||
-- 에스컬레이션 단계. 0 = 즉시. 1 이상이면 escalate_after_s 만큼 미확인일 때만 발송한다.
|
|
||||||
step INT UNSIGNED NOT NULL DEFAULT 0,
|
|
||||||
escalate_after_s INT UNSIGNED NOT NULL DEFAULT 0,
|
|
||||||
created_at BIGINT UNSIGNED NOT NULL,
|
|
||||||
PRIMARY KEY (channel_id),
|
|
||||||
KEY ix_alert_channels_site (site, enabled),
|
|
||||||
CONSTRAINT chk_alert_channels_kind CHECK (kind IN ('local','email','webhook')),
|
|
||||||
CONSTRAINT chk_alert_channels_sev CHECK (min_severity IN ('critical','high','warning','info'))
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
||||||
|
|
||||||
-- 전달 이력. (event_id, channel_id) UNIQUE 가 중복 발송을 막는 유일한 방어선이다 —
|
|
||||||
-- 디스패처는 크론으로 돌고 겹쳐 실행될 수 있다.
|
|
||||||
CREATE TABLE move_alert_log (
|
|
||||||
alert_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
||||||
event_id INT UNSIGNED NOT NULL,
|
|
||||||
channel_id INT UNSIGNED NOT NULL,
|
|
||||||
status VARCHAR(16) NOT NULL, -- pending | sent | failed | suppressed
|
|
||||||
attempts INT UNSIGNED NOT NULL DEFAULT 0,
|
|
||||||
last_error VARCHAR(500) NULL,
|
|
||||||
queued_at BIGINT UNSIGNED NOT NULL,
|
|
||||||
sent_at BIGINT UNSIGNED NULL,
|
|
||||||
PRIMARY KEY (alert_id),
|
|
||||||
UNIQUE KEY uq_alert_log_event_channel (event_id, channel_id),
|
|
||||||
KEY ix_alert_log_status (status, queued_at),
|
|
||||||
CONSTRAINT chk_alert_log_status CHECK (status IN ('pending','sent','failed','suppressed')),
|
|
||||||
CONSTRAINT fk_alert_log_event FOREIGN KEY (event_id) REFERENCES move_events (event_id) ON DELETE CASCADE
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------------
|
|
||||||
-- 4. 교대 인수인계
|
|
||||||
-- 24시간 브리프는 있으나 교대 경계와 책임 이관이 없었다. 인수자가 "무엇을 넘겨받았는지"를
|
|
||||||
-- 명시적으로 확인한 기록이 남아야 미처리 이벤트가 교대 사이에서 증발하지 않는다.
|
|
||||||
CREATE TABLE move_shift_handovers (
|
|
||||||
handover_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
||||||
site VARCHAR(64) NULL,
|
|
||||||
from_user VARCHAR(190) NULL, -- 최초 교대는 인계자가 없다
|
|
||||||
to_user VARCHAR(190) NOT NULL,
|
|
||||||
period_start BIGINT UNSIGNED NOT NULL, -- 인계 대상 구간
|
|
||||||
period_end BIGINT UNSIGNED NOT NULL,
|
|
||||||
unacked_count INT UNSIGNED NOT NULL, -- 인계 시점 스냅샷 (사후 재계산과 대조 가능)
|
|
||||||
severe_count INT UNSIGNED NOT NULL,
|
|
||||||
note VARCHAR(1000) NULL,
|
|
||||||
created_at BIGINT UNSIGNED NOT NULL,
|
|
||||||
PRIMARY KEY (handover_id),
|
|
||||||
KEY ix_handovers_site_created (site, created_at)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------------
|
|
||||||
-- 5. 위험성평가 연계
|
|
||||||
-- 감지 유형 하나하나가 위험요인 하나에 대응한다. 이 표가 있어야 "이 시스템이 무엇을
|
|
||||||
-- 통제하고 있는가"를 서식으로 뽑을 수 있고, 개선조치가 어느 위험요인을 낮췄는지 이어진다.
|
|
||||||
CREATE TABLE move_risk_controls (
|
|
||||||
control_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
||||||
site VARCHAR(64) NULL,
|
|
||||||
event_type VARCHAR(32) NOT NULL, -- move_events.event_type 와 같은 코드
|
|
||||||
hazard VARCHAR(300) NOT NULL, -- 위험요인
|
|
||||||
control VARCHAR(500) NOT NULL, -- 현재 통제 대책
|
|
||||||
severity_rating TINYINT UNSIGNED NOT NULL, -- 1~5 (강도)
|
|
||||||
likelihood_rating TINYINT UNSIGNED NOT NULL, -- 1~5 (빈도)
|
|
||||||
reviewed_by VARCHAR(190) NULL,
|
|
||||||
reviewed_at BIGINT UNSIGNED NULL,
|
|
||||||
updated_at BIGINT UNSIGNED NOT NULL,
|
|
||||||
PRIMARY KEY (control_id),
|
|
||||||
UNIQUE KEY uq_risk_controls_site_type (site, event_type),
|
|
||||||
CONSTRAINT chk_risk_sev CHECK (severity_rating BETWEEN 1 AND 5),
|
|
||||||
CONSTRAINT chk_risk_like CHECK (likelihood_rating BETWEEN 1 AND 5)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------------
|
|
||||||
-- 6. 엣지 성능 시계열
|
|
||||||
-- move_devices 는 마지막 값만 덮어쓴다. "어제부터 추론 지연이 두 배가 됐다"를 볼 수 없어
|
|
||||||
-- 서서히 죽는 노드를 놓친다. 하트비트마다 한 행씩 append 하고 보존기간으로 잘라낸다.
|
|
||||||
CREATE TABLE move_device_metrics (
|
|
||||||
metric_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
||||||
device_id VARCHAR(64) NOT NULL,
|
|
||||||
ts BIGINT UNSIGNED NOT NULL,
|
|
||||||
fps DOUBLE NULL,
|
|
||||||
temp DOUBLE NULL,
|
|
||||||
throttle TINYINT(1) NULL,
|
|
||||||
queue_pending INT UNSIGNED NULL,
|
|
||||||
cameras_total INT UNSIGNED NULL,
|
|
||||||
cameras_erroring INT UNSIGNED NULL,
|
|
||||||
errors INT UNSIGNED NULL,
|
|
||||||
PRIMARY KEY (metric_id),
|
|
||||||
KEY ix_device_metrics_device_ts (device_id, ts)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------------
|
|
||||||
-- 7. 임계값 감사 컬럼 (편집 UI 도입)
|
|
||||||
-- 지금까지 이 표는 phpMyAdmin 으로 직접 고치는 것이 유일한 방법이었다(스키마 주석 참조).
|
|
||||||
-- 화면이 생기면 누가 언제 바꿨는지가 사고 조사에서 반드시 필요해진다.
|
|
||||||
ALTER TABLE move_thresholds
|
|
||||||
ADD COLUMN updated_by VARCHAR(190) NULL,
|
|
||||||
ADD COLUMN updated_at BIGINT UNSIGNED NULL;
|
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------------
|
|
||||||
-- 8. 조치 ↔ 계기 사건
|
|
||||||
ALTER TABLE move_actions
|
|
||||||
ADD COLUMN event_id INT UNSIGNED NULL AFTER site,
|
|
||||||
ADD KEY ix_actions_event (event_id);
|
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------------
|
|
||||||
-- 9. 신규 감지 유형 ★ 이 블록만 문법이 DB 엔진마다 다릅니다 — 아래 안내를 읽으세요 ★
|
|
||||||
--
|
|
||||||
-- camera_moved/obstructed 는 존 기반 판정의 조용한 실패를 드러내는 유형이고,
|
|
||||||
-- unacked_escalation 은 "아무도 확인하지 않았다"는 사실 자체를 사건으로 만든다.
|
|
||||||
-- ppe_missing/proximity/fire_smoke 는 모델 확보 후 활성화되는 자리다 (엣지에서 아직 발화하지 않음).
|
|
||||||
--
|
|
||||||
-- 이 CHECK 는 **2차 방어선**이지 검증 계층이 아닙니다. 1차 검증은 애플리케이션의
|
|
||||||
-- App\Ingest\EventValidator 이고(그쪽은 이미 신규 유형을 받습니다), tests/test_event_type_parity.py
|
|
||||||
-- 가 둘의 일치를 강제합니다. 따라서 이 블록이 실패하거나 건너뛰어져도 시스템은 정상 동작합니다.
|
|
||||||
-- 다만 그 경우 **신규 유형의 INSERT 가 제약 위반으로 거부**되므로, 아래 셋 중 맞는 것을 반드시
|
|
||||||
-- 하나 실행하십시오.
|
|
||||||
--
|
|
||||||
-- (A) MySQL 8.0.16 이상 — `DROP CHECK` 지원:
|
|
||||||
-- ALTER TABLE move_events DROP CHECK chk_events_type;
|
|
||||||
--
|
|
||||||
-- (B) MariaDB (10.2.22+) — `DROP CHECK` 없음, `DROP CONSTRAINT` 사용:
|
|
||||||
-- ALTER TABLE move_events DROP CONSTRAINT chk_events_type;
|
|
||||||
--
|
|
||||||
-- (C) 제약이 애초에 없는 환경 (MySQL 5.7 은 CHECK 를 파싱만 하고 무시했습니다):
|
|
||||||
-- DROP 을 건너뛰고 아래 ADD 만 실행하십시오. ADD 가 "이미 존재" 오류를 내면
|
|
||||||
-- 그 환경에는 제약이 살아 있는 것이므로 (A) 또는 (B) 로 돌아가십시오.
|
|
||||||
--
|
|
||||||
-- 어느 것을 실행해야 할지 모르겠다면 먼저 확인하십시오:
|
|
||||||
-- SELECT VERSION();
|
|
||||||
--
|
|
||||||
-- 아래 DROP 은 (A) 기준으로 적어 두었습니다. MariaDB 라면 이 한 줄을 (B) 로 바꾸세요.
|
|
||||||
ALTER TABLE move_events
|
|
||||||
DROP CHECK chk_events_type;
|
|
||||||
|
|
||||||
ALTER TABLE move_events
|
|
||||||
ADD CONSTRAINT chk_events_type CHECK (event_type IN (
|
|
||||||
'fall','no_motion','immobility','ghost','zone_warning',
|
|
||||||
'zone_restricted','pinch','monitoring_degraded','device_fault',
|
|
||||||
'lone_worker','overcapacity','overstay','near_fall','ergo_report',
|
|
||||||
'camera_moved','camera_obstructed','low_light','unacked_escalation',
|
|
||||||
'ppe_missing','proximity','fire_smoke'
|
|
||||||
));
|
|
||||||
|
|
||||||
-- 검증:
|
|
||||||
-- SELECT COUNT(*) FROM move_audit_log WHERE entry_hash IS NULL; -- 체인 이전 구간
|
|
||||||
-- php tools/verify_audit_chain.php -- 사슬 무결성
|
|
||||||
-- SHOW COLUMNS FROM move_thresholds LIKE 'updated_%';
|
|
||||||
-- -- 신규 유형이 실제로 들어가는지 (넣고 바로 지웁니다):
|
|
||||||
-- INSERT INTO move_events (device_id,camera_id,event_type,severity,occurred_at,received_at)
|
|
||||||
-- VALUES ('_migration_probe','_probe','camera_moved','high',0,0);
|
|
||||||
-- DELETE FROM move_events WHERE device_id = '_migration_probe';
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
-- 1.2.0 — 추락(fall_from_height) 유형 추가
|
|
||||||
--
|
|
||||||
-- 이 제품이 잡던 것은 같은 높이에서의 '넘어짐'(fall)이었고, 산업재해 사망 1위 유형인
|
|
||||||
-- '떨어짐'은 잡지 못했다. 둘은 다른 사건이라 유형을 나눈다:
|
|
||||||
-- fall — 같은 높이에서 쓰러짐. 심각도 HIGH.
|
|
||||||
-- fall_from_height — 높이에서 떨어짐. 심각도 CRITICAL.
|
|
||||||
-- 척추·두부 손상 가능성이 있어 함부로 옮기면 안 되고, 그 주의가 통보에 실려야 한다.
|
|
||||||
--
|
|
||||||
-- 적용 조건: 2026-08-09-safety-platform.sql 이후.
|
|
||||||
--
|
|
||||||
-- ★ 이 블록은 문법이 DB 엔진마다 다릅니다 — 2026-08-09 마이그레이션 §9 의 안내와 동일합니다.
|
|
||||||
--
|
|
||||||
-- 이 CHECK 는 **2차 방어선**이지 검증 계층이 아닙니다. 1차 검증은 App\Ingest\EventValidator
|
|
||||||
-- 이고(그쪽은 이미 신규 유형을 받습니다), tests/test_event_type_parity.py 가 둘의 일치를
|
|
||||||
-- 강제합니다. 따라서 이 블록을 건너뛰어도 시스템은 동작하지만, **신규 유형의 INSERT 가
|
|
||||||
-- 제약 위반으로 거부**되므로 아래 셋 중 맞는 것을 반드시 하나 실행하십시오.
|
|
||||||
--
|
|
||||||
-- (A) MySQL 8.0.16 이상:
|
|
||||||
-- ALTER TABLE move_events DROP CHECK chk_events_type;
|
|
||||||
--
|
|
||||||
-- (B) MariaDB (10.2.22+):
|
|
||||||
-- ALTER TABLE move_events DROP CONSTRAINT chk_events_type;
|
|
||||||
--
|
|
||||||
-- (C) 제약이 애초에 없는 환경 (MySQL 5.7):
|
|
||||||
-- DROP 을 건너뛰고 아래 ADD 만 실행하십시오.
|
|
||||||
--
|
|
||||||
-- 어느 것인지 모르겠다면: SELECT VERSION();
|
|
||||||
|
|
||||||
ALTER TABLE move_events DROP CHECK chk_events_type;
|
|
||||||
|
|
||||||
ALTER TABLE move_events
|
|
||||||
ADD CONSTRAINT chk_events_type CHECK (event_type IN (
|
|
||||||
'fall','fall_from_height','no_motion','immobility','ghost','zone_warning',
|
|
||||||
'zone_restricted','pinch','monitoring_degraded','device_fault',
|
|
||||||
'lone_worker','overcapacity','overstay','near_fall','ergo_report',
|
|
||||||
'camera_moved','camera_obstructed','low_light',
|
|
||||||
'unacked_escalation','multi_down',
|
|
||||||
'ppe_missing','proximity','fire_smoke'
|
|
||||||
));
|
|
||||||