Compare commits

..

No commits in common. "delivery-hardening" and "main" have entirely different histories.

234 changed files with 580 additions and 24687 deletions

View file

@ -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
View file

@ -11,11 +11,7 @@ dist/
# Edge runtime artifacts
*.db
# 모델 가중치는 커밋하지 않는다. 둘 다 고정된 fetch 스크립트로 받는다
# (edge/tools/fetch_hailo_hef.py · fetch_movenet.py) — sha256 가 저장소보다 확실한 보증이고,
# 9~10MB 바이너리를 히스토리에 쌓을 이유가 없다.
*.hef
*.onnx
# 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
# 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
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.*

View file

@ -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 스탬프.

View file

@ -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 금지).

View file

@ -1 +0,0 @@
1.3.0

View file

@ -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
being offline (events buffer in the local sqlite queue and flush on reconnect).
> Scope: the delivered bundle is **Hailo-8 + `yolov8s_pose.hef`**, which
> `build_offline_bundle.sh` fetches (sha256-pinned) and stages into `models/`.
> The CPU MoveNet fallback is **not** bundled: its upstream ONNX URL 404s, so
> `install_offline.sh` configures the unit for whatever backend is actually
> present and refuses to install if none is. Camera side: USB (UVC) and CSI are
> both wired; CSI needs `hailo-all`/`picamera2` from the Pi OS image.
> Device validation for both is `docs/hardware-bringup.md` (still OFF-HW).
> Scope: this covers the **CPU MoveNet + USB (UVC) webcam** path — the one the
> daemon wires today (`edge/daemon.py:main`) and the one verified end-to-end.
> Hailo-8 acceleration (`edge/adapters/hailo_pose.py`) and the CSI camera
> (`edge/adapters/picamera_source.py`) are separate device-validated paths — see
> `docs/hardware-bringup.md`.
## 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
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
`dist/move-detect-pi-<VERSION>-<YYYYMMDD>-aarch64-cp311-hailo8.tar.gz` — version and
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.
Set `MAKE_TARBALL=1` if you would rather transfer one
`dist/2_pi_install-aarch64-cp311-hailo8.tar.gz` file.
## 2. Transfer + install (on the air-gapped Pi)
Copy `dist/2_pi_install/` across the gap (USB, one-way transfer, etc.), then:
```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
sudo ./install_offline.sh
```
@ -109,18 +105,17 @@ sudo systemctl enable --now move-detect-edge
journalctl -u move-detect-edge -f
```
`DEVICE_ID`/`DEVICE_SECRET` must match a `move_devices` row in the server (see
`server/docs/server-deploy.md` §6). `HAILO_HEF` points at the bundled model
(`/opt/move-detect/models/yolov8s_pose.hef`); the installer sets it for you and
only falls back to `MOVENET_ONNX` if a MoveNet bundle was built.
`DEVICE_ID`/`DEVICE_SECRET` must match a `devices` row in the server (see
`server/docs/server-deploy.md` §6). `MOVENET_ONNX` should point at the bundled
model (`/opt/move-detect/models/movenet_lightning.onnx`).
## 4. Verify the air gap (do this before trusting a build)
- [ ] On a host with **networking disabled**, `install_offline.sh` completes —
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.
- [ ] Step [4/6] of the installer prints `edge import OK` (cv2 + numpy + requests
import from the venv; onnxruntime only if a MoveNet bundle was built).
- [ ] Step [4/6] of the installer prints `edge import OK` (onnxruntime + cv2 +
numpy + requests all import from the venv).
- [ ] `systemctl status move-detect-edge` is `active (running)`; the log shows
frames processed and (if the server is reachable) heartbeats.
- [ ] 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)
- **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
system packages, installed out-of-band (above). Note the HEF itself **is** bundled
(`models/yolov8s_pose.hef`); only the driver is not.
- **`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.
- **HailoRT + `yolov8s_pose.hef` + picamera2** — Hailo's runtime/driver, the compiled
HEF, and the CSI camera stack are Pi system packages installed out-of-band (above);
the default CPU MoveNet + USB path needs none of them.

View file

@ -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.
HAS_ONNX=no; [ -f "$STAGE/models/movenet_lightning.onnx" ] && HAS_ONNX=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 "version: $BUNDLE_VERSION"
echo "git_commit: ${GIT_REV}${GIT_DIRTY}"
echo "built_at: $BUILD_DATE"
echo "built_mode: $MODE"
echo "python: $PYVER ($PYABI)"
echo "target_arch: $TARGET_ARCH"
@ -215,7 +197,7 @@ if [ "${MAKE_TARBALL:-0}" != "1" ]; then
fi
# 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.
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"
echo ">> DONE: $TARBALL"
echo ">> transfer this single file across the air gap, then run install_offline.sh on the Pi."

View file

@ -19,15 +19,12 @@ CAFE24_URL=https://your-domain.example/move_detect
CAMERA_ID=cam-01
# --- pose backend / capture ---
# POSE_BACKEND: "hailo" (Pi 5 + AI HAT+, needs HAILO_HEF) or "movenet" (CPU, needs MOVENET_ONNX).
# The delivered bundle carries the HEF only — the MoveNet ONNX has no reachable upstream, so a
# movenet unit would point at a file that is not there and fail to start. install_offline.sh
# rewrites these to match whatever the bundle actually contains; edit them only if you know the
# file exists on this device.
POSE_BACKEND=hailo
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
# POSE_BACKEND: "movenet" (CPU, needs MOVENET_ONNX) or "hailo" (needs HAILO_HEF).
POSE_BACKEND=movenet
# MOVENET_ONNX: required when POSE_BACKEND=movenet.
MOVENET_ONNX=/opt/move-detect/models/movenet_lightning.onnx
# HAILO_HEF: required when POSE_BACKEND=hailo (uncomment and set).
#HAILO_HEF=/opt/move-detect/models/yolov8s_pose.hef
# CAMERA_BACKEND: "uvc" (USB webcam, uses UVC_DEVICE) or "csi" (Pi CSI camera, uses CSI_DEVICE).
CAMERA_BACKEND=uvc
# UVC_DEVICE: OpenCV VideoCapture index for the USB (UVC) webcam.

View file

@ -43,21 +43,6 @@ ExecStart=/opt/move-detect/venv/bin/python -m edge
Restart=always
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).
# systemd creates/owns /var/lib/move-detect for the service user.
StateDirectory=move-detect

View file

@ -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`

View file

@ -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` — 낙상 판정 상태기계. 확정·회복 양쪽이 지속을 요구하는 구조.

View file

@ -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 로 재전송 공격 차단",
"죽은 노드는 자기가 죽었다고 신고할 수 없다 — 크론이 하트비트의 부재로 판정",
"통보는 링크만 — 현장 이미지는 메일로 나가지 않는다"
]
}
]
}

View file

@ -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="이전 슬라이드">&#8593;</button>'
'<button class="nav-next" type="button" aria-label="다음 슬라이드">&#8595;</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()

View file

@ -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": "감지 · 대응 · 증거"
}
]
}

View file

@ -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()

View file

@ -1,12 +1,5 @@
# 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
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.
@ -102,23 +95,13 @@ misconfig/degradation fail LOUD.
normalize by elapsed time.
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.
- [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;
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
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
- [ ] **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.
DONE — STANDING→FALLING now requires sustained prone (`falling_confirm_s`, default 1.0s,
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
- [ ] **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.
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
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.
@ -165,11 +148,6 @@ misconfig/degradation fail LOUD.
— 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
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)
@ -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
(updated as each item lands — newest first)

View file

@ -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초",
"겹쳐서 추락 승격이 미발화했다",
"시간을 그렸다면 보였을 충돌"
]
}
]
}

View file

@ -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분 통보\"는 대기를 포함한 값"
]
}
]
}

View file

@ -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. |
| **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` = T1 (every tick, the default), `2`–`3` = T2. On a skipped tick the frame is still read
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).
1. Configure edge detection core to support tier selection (via thresholds file or env var).
2. Run each tier with realistic camera setup (e.g., T1 on main fall cam, T2/T3 on perimeter).
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).
@ -104,23 +97,14 @@ Validate sustained operation and resource cleanup:
After benchmark completion, update configuration:
Every knob below lives in `/etc/move-detect/edge.env` (read by `edge/config.py::load_config`),
**not** in a code file — no redeploy is needed to retune a site. All are validated at startup:
a non-finite or non-positive value refuses to boot rather than silently disabling a detector.
The full list is in `docs/pilot-runbook.md` §3.
1. **`edge/config.py` thresholds:**
- Adjust `motion.cooldown_ms`, `motion.min_duration_ms` based on measured T2/T3 FPS.
- Tune `fall.confidence_threshold` if fall recall vs. precision trade-off differs on real video.
1. **Detector thresholds:**
- `MOTION_THR` (default 0.25) — per-second stillness threshold shared by fall no-motion and
immobility. This is the primary re-tuning knob on real video: it is frame-rate normalised,
so a device running at a different measured fps needs it re-checked.
- `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.
2. **`edge/config.py` camera roles:**
- Assign `role="fall_primary"` to high-fps T1 cam (≥ 12 fps).
- Assign `role="context"` to T2 cameras (8–10 fps).
- Assign `role="perimeter"` to T3 motion-only cameras.
3. **Benchmark report:**
- Document actual Pi hardware, kernel version, HailoRT version, Hailo FW version.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 579 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

View file

@ -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/` 에 있으므로 여기 넣지 않습니다.

View file

@ -58,15 +58,15 @@ COCO-17 remap 로직은 그대로). 어댑터 remap은 `tests/test_movenet_pose.
로컬 MySQL 8(스키마 CHECK 제약은 8.0.16+)과 PHP 8.2+ 필요. 상세 배포·전제조건(gd/argon2/HTTPS 등)은
[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/계정 기입
(`snapshot_dir`는 쓰기 가능 경로로).
3. **관리자 계정 · 디바이스 시크릿 등록** — 앱에 셀프 가입 흐름이 없으므로 직접 넣는다.
CLI 스니펫과 SQL은 server-deploy.md **§6(디바이스 시크릿)** · **§7(첫 관리자)** 그대로 사용:
- 시크릿 생성: `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\";"`
- `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`
- 세션 쿠키가 `cookie_secure`라 순수 HTTP에선 로그인 쿠키가 안 붙을 수 있다. 로컬은 대개
`localhost`가 secure-context로 취급돼 동작하지만, 안 되면 server-deploy.md §8(HTTPS) 참조.

View file

@ -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가 잡습니다.

View file

@ -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·뉴럴넷 추상 이미지 | 아무 정보도 전달하지 않음. 이 소개서의 문제는 "구체성 부족"이므로 정반대 방향 |

View file

@ -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 이상.
---

View file

@ -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": "설치 대상이 되는 전형적인 작업 공간."
}
]
}

View file

@ -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초입니다 |

View file

@ -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가지는 전부
> 위험한 상태에 관한 것이고, 자리를 비웠는지 일을 얼마나 했는지는 **만들 수 있는
> 기능 자체가 없습니다.** 설정을 바꾸거나 기록을 열어 본 사람은 지울 수 없는 형태로
> 남고, 그 기록은 노조도 확인할 수 있게 하겠습니다.

File diff suppressed because one or more lines are too long

View file

@ -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건 전량 해소 |

View file

@ -4,17 +4,11 @@ import cv2
class UvcSource:
def __init__(self, dev, mjpeg: bool = True, *, reopen_after: int = 30,
size: tuple[int, int] = (640, 480), _open=None):
def __init__(self, dev, mjpeg: bool = True, *, reopen_after: int = 30, _open=None):
# _open lets tests inject a fake capture; production uses cv2.VideoCapture.
self._dev = dev
self._mjpeg = mjpeg
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._fail_streak = 0
self.cap = self._open_cap()
@ -28,14 +22,6 @@ class UvcSource:
raise RuntimeError(f"UVC device {self._dev!r} failed to open")
if self._mjpeg:
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
def read(self):

View file

@ -8,32 +8,6 @@ from edge.detectors.zone import ZoneIntrusionDetector
from edge.engine import EventEngine
from edge.snapshot import RingBuffer, FaceBlurrer, Snapshotter
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:
@ -55,8 +29,7 @@ def _head_boxes(persons: list) -> list:
return boxes
def run_once(source, pose, tracker, pool, engine, ring, snapshotter, sink, now_fn, wall_fn=None,
integrity=None):
def run_once(source, pose, tracker, pool, engine, ring, snapshotter, sink, now_fn, wall_fn=None):
ok, frame = source.read()
if not ok:
# 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)
tracks = tracker.update(persons, now) # RAW keypoints; smoothing happens per-track below
lost = tracker.lost()
# Head boxes are computed EVERY tick and stored with the frame (not only when an event
# 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:
for tr in tracks:
f = tr.latest
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]
# `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.
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)
# 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))
events = engine.process(tracks, lost, now, frame_wh=(w, h), wall_now=wall_now)
for tr in lost:
pool.forget(tr.track_id)
if events:
# 증거가 필요한 사건에만 프레임을 뜬다 — NO_EVIDENCE_TYPES 주석이 그 경계를 정한다.
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)
snaps = snapshotter.capture([frame], [_head_boxes(persons)])
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

View file

@ -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))

View file

@ -19,32 +19,12 @@ class Config:
ghost: GhostConfig
zone: ZoneConfig
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:
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:
"""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
@ -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
# 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).
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(
prone_confirm_s=_positive(env, "FALL_PRONE_S", 3.0),
no_motion_s=_positive(env, "FALL_NO_MOTION_S", 10.0),
prone_confirm_s=_f(env, "FALL_PRONE_S", 3.0),
no_motion_s=_f(env, "FALL_NO_MOTION_S", 10.0),
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)
ghost = GhostConfig(reappear_s=_positive(env, "GHOST_REAPPEAR_S", 10.0))
zone = ZoneConfig(debounce_s=_positive(env, "ZONE_DEBOUNCE_S", 0.75))
engine = EngineConfig(camera_id=camera_id, cooldown_s=_positive(env, "COOLDOWN_S", 30.0))
infer_every = int(_positive(env, "INFER_EVERY", 1.0))
ghost = GhostConfig(reappear_s=_f(env, "GHOST_REAPPEAR_S", 10.0))
zone = ZoneConfig(debounce_s=_f(env, "ZONE_DEBOUNCE_S", 0.75))
engine = EngineConfig(camera_id=camera_id, cooldown_s=_f(env, "COOLDOWN_S", 30.0))
return Config(
infer_every=infer_every,
device_id=env.get("DEVICE_ID", "pi-unknown"),
camera_id=camera_id,
cafe24_url=env.get("CAFE24_URL", ""),

View file

@ -7,14 +7,6 @@ import os
_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)
import sys
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,
sync_every=150, heartbeat_every=900, flush_every=300,
frame_interval=0.0, zones_for=None, log=None,
heartbeat_interval_s=None, health_fn=None, configured_cameras=None,
notify=None, beacons=None, build_beacons_fn=None):
heartbeat_interval_s=None, health_fn=None, configured_cameras=None):
# `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
# 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:
from edge.health import read_health
health_fn = read_health
if notify is None:
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}
counters = {"frames": 0, "syncs": 0, "beats": 0, "flushes": 0, "errors": 0}
streak = {} # id(rt)/"periodic" -> consecutive-error count, for rate-limited logging
def note(key, label, exc=None):
@ -76,10 +52,6 @@ def run_loop(runtime, client, queue, *, clock, sleep, iterations=None,
i = 0
while iterations is None or i < iterations:
i += 1
try:
notify()
except Exception as e:
note("watchdog", "watchdog ping failed", e)
now = clock() # one timestamp per tick, shared across cameras
if beat_anchor is None:
beat_anchor = (now, 0)
@ -103,48 +75,12 @@ def run_loop(runtime, client, queue, *, clock, sleep, iterations=None,
else:
counters["frames"] += 1
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:
if sync_every and i % sync_every == 0:
cfg = client.fetch_config()
if cfg is not None:
for rt in runtimes:
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
# config-drift indicator is meaningless until the device reports what it runs.
if isinstance(cfg, dict) and cfg.get("version") is not None:

View file

@ -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()}

View file

@ -14,7 +14,6 @@ class FallConfig:
aspect_ratio: float = 1.25
height_collapse: float = 0.35 # baseline 대비 남은 높이 비율(이 값 미만이면 급붕괴)
prone_confirm_s: float = 3.0
falling_confirm_s: float = 1.0 # STANDING→FALLING에 요구되는 지속 prone (P2-7)
no_motion_s: float = 10.0
motion_thr: float = 0.25 # per-second normalized joint speed (units/s); MOTION_THR env
min_conf: float = 0.5
@ -95,13 +94,7 @@ class FallDetector:
st.not_prone_since = now
return None, {}
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)
if was_near_miss:
return "near_fall", {"prone_s": round(prone_s, 1)}
return "recover", {}
return None, {}
st.prone_since = None
@ -111,9 +104,6 @@ class FallDetector:
st.not_prone_since = None
if st.prone_since is None:
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"
if now - st.prone_since >= self.cfg.prone_confirm_s and not st.fall_emitted:
st.name = "FALLEN"

View file

@ -8,9 +8,6 @@ _DANGEROUS = {"FALLING", "FALLEN", "NO_MOTION"}
@dataclass
class GhostConfig:
reappear_s: float = 10.0
# 높이 무장의 유효 기간. 무장에 시간 제한이 없으면 발판에 잠깐 올라갔던 사람이 몇 분 뒤
# 그냥 걸어 나가도 최상위 추락 경보가 된다.
height_arm_ttl_s: float = 20.0
reappear_dist: float = 1.0 # bbox 높이 배수
@ -18,63 +15,28 @@ class GhostIncidentDetector:
def __init__(self, cfg: GhostConfig):
self.cfg = cfg
self._states: dict[int, str] = {}
# 높이 기반 무장: 위험 자세가 아니어도 '떨어지는 중'이거나 '고소 구역 안'이면
# 소실 시 추락 후보로 잡는다. 발판에 서 있다가 화각 밖으로 떨어지는 사람은
# 소실 순간 STANDING 이라 _DANGEROUS 조건만으로는 영원히 안 걸린다.
self._height: dict[int, dict] = {}
# 후보: track_id -> (spatial(x,y), last_state, height, lost_at)
self._candidates: dict[int, tuple] = {}
def note_state(self, track_id: int, state: str) -> None:
self._states[track_id] = state
def 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:
for tr in lost_tracks:
state = self._states.get(tr.track_id, "STANDING")
height = self._height.get(tr.track_id)
# 오래된 무장은 무시한다 (H2). 고소 구역 안에 있는 동안은 매 틱 갱신되므로
# 구역 기반 무장은 이 검사에 걸리지 않는다.
if height is not None and now - float(height.get("armed_at", now)) > self.cfg.height_arm_ttl_s:
height = None
self._height.pop(tr.track_id, None)
if state in _DANGEROUS or height:
if state in _DANGEROUS:
b = tr.latest.bbox
# Snapshot the OTHER tracks active at loss time. Only a track that did NOT
# exist then can be the victim re-appearing (a re-acquired track gets a new
# id); a track already present is a bystander/colleague and must not cancel
# the incident by walking over the loss point.
bystanders = set(self._states) - {tr.track_id}
self._candidates[tr.track_id] = ((b.cx, b.cy), state, b.height, now,
bystanders, height)
self._candidates[tr.track_id] = ((b.cx, b.cy), state, b.height, now, bystanders)
self._states.pop(tr.track_id, None)
self._height.pop(tr.track_id, None)
def update(self, active_tracks: list[Track], now: float) -> list[dict]:
# 인근 재출현이면 후보 취소 — 단, 손실 시점에 없던 새 트랙만 (진짜 재출현).
for tid, (spatial, _state, height, _lost_at, bystanders, _h) in list(self._candidates.items()):
for tid, (spatial, _state, height, _lost_at, bystanders) in list(self._candidates.items()):
for tr in active_tracks:
if tr.track_id in bystanders:
continue # a colleague who was already here — not the reappearing victim
@ -85,14 +47,9 @@ class GhostIncidentDetector:
break
# 대기 초과 방출
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:
rec = {"track_id": tid, "spatial": spatial, "last_state": state,
"waited_s": round(now - lost_at, 1)}
if hinfo:
# 추락 후보였다 — 엔진이 ghost 가 아니라 fall_from_height 로 올린다.
rec["height_fall"] = True
rec.update(hinfo)
out.append(rec)
out.append({"track_id": tid, "spatial": spatial, "last_state": state,
"waited_s": round(now - lost_at, 1)})
self._candidates.pop(tid, None)
return out

View file

@ -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

View file

@ -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}

View file

@ -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

View file

@ -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

View file

@ -25,9 +25,6 @@ class Zone:
zone_id: str
level: str # "warning" | "restricted"
polygon: list # list[tuple[float,float]]
# 선택적 존 규칙 (occupancy.py가 소비): {"min_workers": 2, "max_workers": 4,
# "max_dwell_s": 600} — 키가 없으면 그 규칙은 꺼진 것. None = 규칙 없음.
rules: dict | None = None
@dataclass
@ -71,21 +68,6 @@ class ZoneIntrusionDetector:
fx, fy = fx / w, fy / h
return (fx, fy)
def in_elevated_zone(self, track_id: int) -> bool:
"""이 트랙이 '고소' 표시된 구역 안에 있는가.
rules_json 의 `elevated: true` 한 키로 붙는다 — 스키마 변경이 없다. 운영자가 발판·
비계·개구부를 그려두면 추락 판정이 훨씬 정확해지지만, 구역이 없어도 하강 신호만으로
동작한다. 위험은 운영자가 선언하되, 선언이 없어도 최소한은 본다.
"""
active = self.active_zone_ids(track_id)
if not active:
return False
for z in self.zones:
if z.zone_id in active and (z.rules or {}).get("elevated"):
return True
return False
def active_zone_ids(self, track_id: int) -> set:
return {z for (tid, z) in self._inside_since if tid == track_id}

View file

@ -6,9 +6,6 @@ from edge.detectors.fall import FallDetector
from edge.detectors.immobility import ImmobilityWatchdog
from edge.detectors.ghost import GhostIncidentDetector
from edge.detectors.zone import ZoneIntrusionDetector
from edge.detectors.occupancy import OccupancyWatchdog, OccupancyConfig
from edge.detectors.ergonomics import ErgoMonitor, ErgoConfig
from edge.detectors.height import DescentWatch, DescentConfig
@dataclass
@ -17,30 +14,16 @@ class EngineConfig:
cooldown_s: float = 30.0
_OCCUPANCY_TYPES = {"lone_worker": EventType.LONE_WORKER,
"overcapacity": EventType.OVERCAPACITY,
"overstay": EventType.OVERSTAY}
class EventEngine:
def __init__(self, fall: FallDetector, immobility: ImmobilityWatchdog,
ghost: GhostIncidentDetector, zone: ZoneIntrusionDetector,
cfg: EngineConfig, occupancy: OccupancyWatchdog | None = None,
ergonomics: ErgoMonitor | None = None,
descent: DescentWatch | None = None):
cfg: EngineConfig):
self.fall = fall
self.immobility = immobility
self.ghost = ghost
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._last_emit: dict[tuple, float] = {}
# 트랙별 마지막 구역. 소실 이벤트(ghost/추락)는 소실 후에 발보되므로
# 그때는 트랙이 없다 — 마지막으로 있던 구역을 들고 있어야 실을 수 있다.
self._last_zone: dict[int, str | None] = {}
def _allow(self, key: tuple, now: float) -> bool:
last = self._last_emit.get(key)
@ -54,18 +37,13 @@ class EventEngine:
self.fall.forget(track_id)
self.immobility.forget(track_id)
self.zone.forget(track_id)
self.descent.forget(track_id)
self.ghost.forget(track_id)
self._last_zone.pop(track_id, None)
self.occupancy.forget(track_id)
self._last_emit = {k: v for k, v in self._last_emit.items()
if not (len(k) >= 2 and k[1] == track_id)}
def _mk(self, etype: EventType, occurred_at: float, track_id=None, zone_id=None,
metrics=None, spatial=None) -> Event:
m = metrics or {}
duration_s = m.get("immobility_s") or m.get("waited_s") or m.get("dwell_s") \
or m.get("alone_s") or 0.0
duration_s = m.get("immobility_s") or m.get("waited_s") or 0.0
return Event(etype, severity_for(etype), self.cfg.camera_id, track_id,
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
# space (see ZoneIntrusionDetector._foot). run_once supplies the live frame size.
zone_hits = self.zone.update(tr, now, frame_wh)
active_zones = self.zone.active_zone_ids(tr.track_id)
in_zone = bool(active_zones)
# 사람 상태 이벤트(넘어짐·미동·소실)에도 구역을 싣는다. 서버의 동시 다발 판정이
# "같은 구역"으로 묶으려면 이 값이 있어야 하고, 없으면 카메라가 다른 두 희생자가
# 절대 묶이지 않는다 — 밀폐공간이 정확히 그 상황이다.
# 여러 구역이 겹치면 정렬해 첫 번째를 쓴다(같은 입력에 같은 답이 나오도록).
here = sorted(active_zones)[0] if active_zones else None
self._last_zone[tr.track_id] = here
# 발 좌표(0..1)를 트랙 이벤트 전부에 부착 — 서버 히트맵의 원료. 존 감지기가 이미
# 계산하는 좌표계를 그대로 재사용해 존 폴리곤과 같은 공간에 놓인다.
foot = self.zone._foot(tr, frame_wh)
# 하강 감시는 낙상 판정보다 먼저 — 같은 틱에 확정된 넘어짐을 승격할 수 있어야 한다.
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)
in_zone = bool(self.zone.active_zone_ids(tr.track_id))
sig, meta = self.fall.update(tr, now)
state = self.fall._states.get(tr.track_id)
self.ghost.note_state(tr.track_id, state.name if state else "STANDING")
# 자세 부담 노출은 사고 상태가 아닌 트랙만 — 쓰러진 사람의 90° 몸통은
# 인체공학 노출이 아니라 이미 사고로 처리 중인 상태다.
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)
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))
elif sig == "no_motion" and self._allow((EventType.NO_MOTION, tr.track_id), now):
events.append(self._mk(EventType.NO_MOTION, occurred_at, tr.track_id,
metrics=meta, spatial=foot, 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))
events.append(self._mk(EventType.NO_MOTION, occurred_at, tr.track_id, metrics=meta))
fired, imeta = self.immobility.update(tr, now, in_danger_zone=in_zone)
if fired and self._allow((EventType.IMMOBILITY, tr.track_id), now):
events.append(self._mk(EventType.IMMOBILITY, occurred_at, tr.track_id,
metrics=imeta, spatial=foot))
events.append(self._mk(EventType.IMMOBILITY, occurred_at, tr.track_id, metrics=imeta))
for hit in zone_hits:
# 협착(pinch) is the highest-danger zone (machinery entrapment) and must emit
@ -152,30 +84,7 @@ class EventEngine:
"restricted": EventType.ZONE_RESTRICTED}.get(
hit["level"], EventType.ZONE_WARNING)
if self._allow((etype, tr.track_id, hit["zone_id"]), now):
events.append(self._mk(etype, occurred_at, tr.track_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))
events.append(self._mk(etype, occurred_at, tr.track_id, zone_id=hit["zone_id"]))
for g in self.ghost.update(active_tracks, now):
# 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
# 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).
# Ghost's loss point is camera-native pixels (bbox centre at loss time). Normalize to
# the same 0..1 space every other event's spatial uses so the heatmap can overlay them;
# frame_wh=None (unit tests) keeps raw coords, matching the zone detector's convention.
gs = g["spatial"]
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))
events.append(self._mk(EventType.GHOST, occurred_at, track_id=g["track_id"],
spatial=g["spatial"],
metrics={"last_state": g["last_state"],
"waited_s": g["waited_s"]}))
# ghost.on_lost already consumed lost tracks' state above, so forgetting here is safe.
for tr in lost_tracks:

View file

@ -5,7 +5,6 @@ from enum import Enum
class EventType(str, Enum):
FALL = "fall"
FALL_FROM_HEIGHT = "fall_from_height" # 높이에서 떨어짐 — 넘어짐과 다른 사건
NO_MOTION = "no_motion"
IMMOBILITY = "immobility"
GHOST = "ghost"
@ -14,24 +13,6 @@ class EventType(str, Enum):
PINCH = "pinch"
MONITORING_DEGRADED = "monitoring_degraded"
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):
@ -45,31 +26,12 @@ _SEVERITY = {
EventType.NO_MOTION: Severity.CRITICAL,
EventType.GHOST: Severity.CRITICAL,
EventType.FALL: Severity.HIGH,
# 추락은 척추·두부 손상 가능성이 있어 함부로 옮기면 안 된다. 그 주의가 통보에
# 실리려면 넘어짐(HIGH)과 등급이 갈라져야 한다.
EventType.FALL_FROM_HEIGHT: Severity.CRITICAL,
EventType.ZONE_RESTRICTED: Severity.HIGH,
EventType.PINCH: Severity.HIGH,
EventType.IMMOBILITY: Severity.WARNING,
EventType.ZONE_WARNING: Severity.WARNING,
EventType.MONITORING_DEGRADED: 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)
def to_payload(self, device_id: str) -> dict:
payload = {
return {
"device_id": device_id,
"event_uuid": self.event_uuid,
"camera_id": self.camera_id,
@ -106,8 +68,3 @@ class Event:
"duration_s": round(self.duration_s, 2),
"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

View file

@ -1,8 +1,7 @@
import math
from edge.models import (
Keypoint, BBox, NOSE, L_SHOULDER, R_SHOULDER, L_ELBOW, R_ELBOW,
L_HIP, R_HIP, L_KNEE, R_KNEE, L_ANKLE, R_ANKLE, CORE_JOINTS,
Keypoint, BBox, NOSE, L_SHOULDER, R_SHOULDER, L_HIP, R_HIP, CORE_JOINTS,
)
@ -46,44 +45,6 @@ def head_below_hips(keypoints: list[Keypoint], nose_thr: float = 0.2) -> bool:
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:
return sum(1 for k in keypoints if k.conf >= thr)

View file

@ -8,17 +8,10 @@ from edge.events import Event, EventType, Severity
class OfflineQueue:
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.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
# 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.execute(
"CREATE TABLE IF NOT EXISTS queue ("
@ -67,11 +60,8 @@ class OfflineQueue:
return False
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(
"SELECT id, payload, snaps, attempts FROM queue ORDER BY id LIMIT ?",
(self.max_flush_rows,)).fetchall()
"SELECT id, payload, snaps, attempts FROM queue ORDER BY id").fetchall()
sent = 0
for rid, payload_json, snaps_json, attempts in rows:
try:

View file

@ -5,10 +5,8 @@ from edge.detectors.fall import FallDetector
from edge.detectors.immobility import ImmobilityWatchdog
from edge.detectors.ghost import GhostIncidentDetector
from edge.detectors.zone import ZoneIntrusionDetector
from edge.detectors.integrity import CameraIntegrityWatch, IntegrityConfig
from edge.engine import EventEngine
from edge.snapshot import RingBuffer, FaceBlurrer, Snapshotter
from edge.tuning import apply_thresholds
class PipelineRuntime:
@ -18,12 +16,6 @@ class PipelineRuntime:
self.source = source
self.pose = pose
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
# off-daemon -> run_once stamps occurred_at with the monotonic `now`, preserving old tests.
self._wall = wall_clock
@ -39,35 +31,11 @@ class PipelineRuntime:
)
self.ring = RingBuffer()
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:
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,
self.engine, self.ring, self.snapshotter, self.sink,
now_fn=lambda: now, wall_fn=self._wall,
integrity=self.integrity)
now_fn=lambda: now, wall_fn=self._wall)
def apply_zones(self, zones: list) -> None:
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)

View file

@ -7,34 +7,15 @@ from edge.models import BBox
class RingBuffer:
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:
# head_boxes ride along per frame: a PAST frame must be blurred with the head
# 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 push(self, t: float, frame) -> None:
self.frames.append((t, frame))
def window(self, pre: int, post: int, index: int) -> list:
lo = max(0, index - pre)
hi = min(len(self.frames), index + post + 1)
return [f for (_t, f, _b) 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]
return [f for (_t, f) in list(self.frames)[lo:hi]]
class FaceBlurrer:

View file

@ -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
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
storage, not as repo blobs — the raw path returns a GitHub HTML 404). The pinned source below is
a tf2onnx export of Google's MoveNet SinglePose Lightning v4, checked to match what
edge/adapters/movenet_pose.py feeds and reads: input [1,192,192,3] int32 NHWC, output
[1,1,17,3] float (y, x, score). A conversion with a different layout would load fine and then
produce silently wrong keypoints, so the digest below is what makes this safe to fetch — not the
hostname. For the Hailo delivery path this model is optional (POSE_BACKEND=hailo).
STATUS: the historical PINTO_model_zoo raw URL below is DEAD (that repo hosts its models on
external storage, not as repo blobs — the raw path now returns a GitHub HTML 404). So there is
no known-good sha256 to pin here yet. Until a working source is set:
* this tool FAILS LOUD if the download is not a real ONNX (rejects the HTML it currently gets),
instead of silently saving garbage;
* set MOVENET_ONNX_URL to a valid source and pin PINNED_SHA256/PINNED_SIZE from its bytes.
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 os
@ -21,16 +22,15 @@ import urllib.request
URL = os.environ.get(
"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")
# Pinned from the bytes of the source above. An override must bring its own digest — pointing
# MOVENET_ONNX_URL elsewhere while keeping this pin just makes the download fail, which is the
# intended direction of failure.
PINNED_SHA256 = os.environ.get(
"MOVENET_ONNX_SHA256", "1ad4f8d6c2f776a9967db3993c9ca740bc350104f9d37c151dc183fc29a464ad")
PINNED_SIZE = int(os.environ.get("MOVENET_ONNX_SIZE", "9413268") or "0")
# Pin from the bytes of a known-good source once MOVENET_ONNX_URL points at one. Empty = unpinned
# (still validated as a real ONNX below, but not byte-identical-verified).
PINNED_SHA256 = os.environ.get("MOVENET_ONNX_SHA256", "")
PINNED_SIZE = int(os.environ.get("MOVENET_ONNX_SIZE", "0") or "0")
def _looks_like_onnx(head: bytes) -> bool:

View file

@ -40,46 +40,24 @@ class IouTracker:
self._tracks: dict[int, Track] = {}
self._last_seen: dict[int, float] = {}
self._lost: list[Track] = []
self._ambiguous: set[int] = set()
self._next_id = 1
def update(self, persons: list[Person], t: float) -> list[Track]:
self._lost = []
self._ambiguous = set()
# 전역 그리디: 모든 (트랙, 사람) 쌍을 IoU 내림차순으로 배정. 트랙 순회 순서 우선의
# 로컬 그리디는 교차 시 먼저 온 트랙이 남의 박스를 가로채(0.48이 0.74를 이김) FALLEN
# 상태·frozen baseline이 다른 사람에게 넘어갔다.
pairs = []
track_cands: dict[int, int] = {}
person_cands: dict[int, int] = {}
for tid, track in self._tracks.items():
for i, p in enumerate(persons):
unmatched = list(persons)
# 기존 트랙과 그리디 IoU 매칭
for tid, track in list(self._tracks.items()):
best, best_iou = None, self.iou_thr
for p in unmatched:
iou = _iou(track.latest.bbox, p.bbox)
if iou >= self.iou_thr:
pairs.append((iou, tid, i))
track_cands[tid] = track_cands.get(tid, 0) + 1
person_cands[i] = person_cands.get(i, 0) + 1
pairs.sort(key=lambda pr: pr[0], reverse=True)
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))
if iou >= best_iou:
best, best_iou = p, iou
if best is not None:
unmatched.remove(best)
track.frames.append(TrackFrame(t, best.bbox, best.keypoints))
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):
if i in used_persons:
continue
for p in unmatched:
tid = self._next_id
self._next_id += 1
self._tracks[tid] = Track(tid, deque([TrackFrame(t, p.bbox, p.keypoints)],
@ -94,7 +72,3 @@ class IouTracker:
def lost(self) -> list[Track]:
return list(self._lost)
def ambiguous(self) -> set[int]:
"""직전 update에서 겹침 경합에 연루된(매칭이 스왑일 수 있는) 트랙 id."""
return set(self._ambiguous)

View file

@ -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

View file

@ -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")

View file

@ -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)
_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]:
"""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."""
@ -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).",
row.get("zone_id"), row.get("camera_id"), polygon[:2])
continue
try:
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))
out.append(Zone(str(row["zone_id"]), str(row["level"]), polygon))
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
# (level/zone_id), with unparseable polygon_json, or degenerate points would otherwise be

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

Binary file not shown.

View file

@ -13,28 +13,4 @@ return [
'ingest_ip_rate_limit_per_min' => 300,
// PIPA retention: worker snapshots are purged after this many days (0 disables).
'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,
];

View file

@ -70,43 +70,19 @@ DB 세 줄만 카페24 관리자 페이지의 MySQL 정보로 바꿉니다.
1. 카페24 관리자 → **phpMyAdmin** 열기
2. 이 앱용 DB 선택
3. **Import(가져오기)** 탭 → `schema.sql` 선택 → 실행
4. 표 11개가 생겼는지 확인
4. 표 10개가 생겼는지 확인
5. **서버에서 `schema.sql` 삭제** — 남겨둘 이유가 없습니다
`schema.sql` 은 항상 최신 상태입니다. 처음 설치라면 아래 마이그레이션은 **실행하지 마세요**
(이미 반영되어 있어 충돌합니다).
### 이미 쓰던 DB 업그레이드(기존 데이터 있음)
> ⚠️ 기존 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** 탭
2. 위 표의 순서대로 파일 내용을 **하나씩** 붙여넣고 실행합니다
(각 파일 첫머리에 적용 조건이 주석으로 적혀 있습니다)
3. 각 파일 맨 아래 **검증 쿼리**로 컬럼·표가 생겼는지 확인
4. 전부 끝난 뒤에 새 코드를 배포합니다
> 이미 적용한 마이그레이션을 다시 실행하면 "Duplicate column" 같은 오류가 납니다 —
> 재실행은 안전하지 않으니, 어디까지 적용했는지 기록해 두세요. 어디까지 했는지 모르겠으면
> phpMyAdmin 의 **Structure(구조)** 탭에서 위 표의 컬럼·표가 있는지 하나씩 확인하면 됩니다.
2. `migrations/2026-07-17-p1-9-p3-2b.sql` 내용을 붙여넣고 실행
(site / event_uuid 컬럼 + 인덱스 추가, 기존 이벤트의 site 백필 포함)
3. 파일 맨 아래 **검증 쿼리**로 컬럼·인덱스가 생겼는지 확인
4. 그 다음에 새 코드를 배포합니다 — 컬럼이 없으면 모든 요청이 500 이 됩니다
---
@ -180,61 +156,10 @@ VALUES ('pi-01', '3라인 낙상 카메라', '여기에_비밀키_붙여넣기',
---
## 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. 마지막 확인
## 8. 마지막 확인
- [ ] `https://내도메인/move_detect/login` 이 열린다
- [ ] admin 으로 로그인되고 대시보드가 뜬다
- [ ] `schema.sql` 을 서버에서 지웠다
- [ ] `www/move_detect/_preflight.php` 를 서버에서 지웠다
- [ ] `https://내도메인/move_detect_app/config.php` 가 안 열린다 (열리면 위치가 잘못된 것)
- [ ] 자동실행(크론) 2개가 등록되어 있다 — **없으면 알림이 나가지 않습니다** (8번)
- [ ] `[시스템 설정] → 통보 경로` 에 최소 1개가 '사용' 상태다 (현장 경광등 우선)
- [ ] `/setup` 화면의 모든 단계에 체크가 켜져 있다 (9번)

View file

@ -11,8 +11,7 @@
# Run on the BUILD machine (needs composer + php — dev-only tools, never on the host):
# server/deploy/build_cafe24_package.sh
#
# Output: dist/1_cafe24_upload/ and (MAKE_ZIP=1)
# dist/move-detect-cafe24-<VERSION>-<YYYYMMDD>.zip
# Output: dist/1_cafe24_upload/ and dist/move-detect-cafe24.zip
#
# dist/1_cafe24_upload/
# ├── 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"
OUT_DIR="${OUT_DIR:-$REPO_ROOT/dist}"
STAGE="$OUT_DIR/1_cafe24_upload"
# 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"
ZIP="$OUT_DIR/move-detect-cafe24.zip"
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; }
@ -159,47 +144,13 @@ echo ">> [4/6] staging app source"
cp -r "$SERVER/src/." "$PRIV/src/"
cp -r "$SERVER/public/." "$WEB/"
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.
cp "$SERVER/deploy/htaccess.www" "$WEB/.htaccess"
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"
# .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/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
# so a stray one (e.g. a tool that ran in the tree) can never ship inside the web-served

View file

@ -28,39 +28,4 @@ return [
'rate_limit_per_min' => 60, // 장비 1대당 분당 이벤트 상한
'ingest_ip_rate_limit_per_min' => 300, // IP 1개당 분당 이벤트 상한 (DoS 방어)
'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,
];

View file

@ -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()

View file

@ -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": "[안전 관리] → 정비 중 알람 억제에서 대상과 기간, 사유를 넣으세요. 알림만 멈추고 감지·기록은 계속되므로 나중에 그 시간대를 조사할 수 있습니다."
}
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

View file

@ -139,14 +139,13 @@ or version control.
## 4. Import the schema
`server/schema.sql` contains the full MySQL 8.0+ DDL. All tables carry the `move_` prefix
(`move_users`, `move_devices`, `move_cameras`, `move_actions`, `move_zones`, `move_events`,
`move_event_snapshots`, `move_thresholds`, `move_nonces`, `move_rate_limits`, `move_audit_log`).
`server/schema.sql` contains the full MySQL 8.0+ DDL (`users`, `devices`, `cameras`, `zones`,
`events`, `event_snapshots`, `thresholds`, `nonces`, `rate_limits`, `audit_log`).
1. Open **phpMyAdmin** from the Cafe24 control panel, select the database created for this
app.
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).
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
@ -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
§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.
## 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)부터 설정하십시오. 인터넷이 끊겨도 동작하는 유일한
경로입니다.

View file

@ -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;

View file

@ -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'
));

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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';

View file

@ -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'
));

Some files were not shown because too many files have changed in this diff Show more