Compare commits

..

13 commits

Author SHA1 Message Date
유창욱
cfdbcef640 docs: 1.1.0 릴리스 기록 + 산출물 날짜 통일
Some checks are pending
verify / edge (push) Waiting to run
verify / server (push) Waiting to run
CHANGELOG·소개서·감사 리포트의 릴리스 날짜를 빌드 스탬프(UTC 2026-09-04)에
맞췄다. 같은 버전의 고객 대면 산출물에 두 날짜가 찍혀 있었다.

INDEX 에 1.1.0 항목 추가 — 산출물 파일명, BUILD-INFO 커밋, 동봉 문서, 잔여
조치(자격증명 교체·Pi 번들 재빌드)를 기록. 그 의무가 지금까지 커밋 메시지
안에만 있었다.

검증: pytest 308 passed · phpunit 250 passed · dist 파린티 회복.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBjuR7DW6PNNMows4KnbYU
2026-09-05 00:08:17 +09:00
유창욱
43a3d069a3 fix(audit): 1.1.0 3차 재감사 — 확정 결함 14건 전량 해소
4개 관점 병렬 감사 후 각 발견을 반증 시도해 확정. 반증된 4건은 감사 문서에
REFUTED 로 남겼다(히트맵 왜곡·에스컬레이션 전파·스트림 필터 고정·설정 미배선).

CRITICAL 2건 — 둘 다 이번 기능이 만든 회귀다:

R3-1 dispatch_alerts.php 의 장치 건전성 단계가 try 밖에 있었다. 이 단계만
move_devices·move_device_metrics 를 읽는데 통보 경로는 그 두 표를 건드리지
않는다 — 즉 마이그레이션 미적용·커넥션 한도·lock timeout 이 원래대로면 정상
발송됐을 실행을 통째로 죽인다. 원인이 지속형이라 매분 반복되고 콘솔은 멀쩡해
보인다. 1·2단계를 각각 격리했고, 3단계의 exit(1) 도 없앴다 — 거기 도달하는
예외는 저장소 장애이고 4단계 재시도가 바로 그것에서 회복하려고 존재한다.

R3-2 간헐 고장에 감지기가 양방향으로 무너졌다. edge/daemon.py:105 는 성공
프레임 하나에 streak 를 지워 cameras_erroring 를 0 으로 되돌린다. 정상 표본
하나로 저하 구간을 끊으니 (a) 두 시간 고장에도 sustain 을 못 넘어 아무것도
못 올리고 (b) 끊긴 구간마다 새 앵커가 생겨 6시간에 54건을 쏟아냈다. 엣지
낙상 감지기의 recover_grace_s 와 같은 규율로 짧은 정상을 흡수하고, uuid 에
재고지 버킷을 더해 주기당 1건으로 묶었다(같은 입력 ≤7건). 기존 16개 테스트가
전부 단조 시퀀스라 이 결함이 CI를 통과했다 — 그래서 플래핑·폭주·회복·미래시각
테스트를 추가했다.

HIGH 5건: 센티넬이 메일·웹훅으로 회사 밖에 나갔고(라벨을 app.js 에만 넣은 것이
원인), 사건 상세 링크로 존재하지 않는 카메라에 구역을 저장할 수 있었고, 인제스트가
센티넬을 예약값으로 안 막아 엣지가 그 camera_id 로 올린 진짜 낙상이 화면에서
더 안 보이게 됐고, BUILD-INFO 가 릴리스 이전 커밋을 가리켰고, 실제 자격증명
파일이 납품 폴더 안에 있었다.

값이 흩어진 것이 센티넬 결함의 근본 원인이라 단일 출처를 만들었다:
App\DeviceScope(서버) + assets/device-scope.js(프런트) + PdoEventRepository 의
NOT_DEVICE SQL 조각. 셋이 어긋나면 DeviceScopeTest 가 잡는다.

MEDIUM 7건: 카메라별 집계 3곳 제외, 설정 점검 오판정, 개선조치 대상 거부,
라벨 CSV 제외, 낡은 지표로 판정 금지, 미래 시각 표본 제거, 죽은 노드 재고지.

검증: phpunit 250 passed (신규 10) · pytest 는 dist 재생성 후 확인.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBjuR7DW6PNNMows4KnbYU
2026-09-05 00:06:35 +09:00
유창욱
bebf426c2e release: 1.1.0 — 서버 전용 릴리스 (감시 자신의 고장 신고)
1.0.0 이후 edge/ 는 한 줄도 바뀌지 않았다(git diff 확인). 바뀐 것은 서버뿐이라
서버 전용 릴리스로 낸다.

왜 버전을 올리는가: dist/ 재생성으로 DeviceHealthWatch 가 이미 패키지에 들어가
있는데 VERSION 은 1.0.0 이었다. 그대로 두면 "1.0.0" 이름표를 단 서로 다른
패키지가 두 종류 생긴다. 기능 추가이므로 1.1.0.

- VERSION 1.0.0 -> 1.1.0
- CHANGELOG "미출시 (main)" 절을 1.1.0 으로 확정
- dist 재생성 + 구버전 ZIP 제거(같은 폴더에 두 버전이 있으면 잘못 집는다)
  -> move-detect-cafe24-1.1.0-20260904.zip
- 사용설명서·경영진 덱·소개서 재생성 (전부 VERSION 을 표기에 쓴다)

Pi 번들은 이번 릴리스에서 갱신하지 않았다. edge/ 가 불변이라 내용은 1.0.0 과
동일하고, 재빌드는 arm64 + 네트워크(pip download, fetch_hailo_hef)가 있는 빌드
머신에서만 가능하다. dist/ 에는 1.0.0 타르볼이 그대로 남아 있으므로, Pi 를
납품할 때 그 머신에서 재빌드해 파일명 버전을 맞춰야 한다.

검증: pytest 308 passed / phpunit 240 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBjuR7DW6PNNMows4KnbYU
2026-09-04 23:45:40 +09:00
유창욱
2345de6919 docs: 영상 브리프 전달절차 정리 + INDEX 기록 (dea12a2) 2026-08-30 22:52:37 +09:00
유창욱
dea12a2931 feat(deck): P0 클립 2종 배치 — 브라우저 재생 형식으로 변환 + 포스터 자동 추출
받은 v01(넘어짐·경광등)·v02(앉기·정상)는 내용상 브리프대로였다. 컨택트
시트로 확인: v01 0.79초 쓰러짐 → 1.58초 경광등 점등 → 유지, v02 4초 내내
경광등 꺼짐. 둘 다 CCTV 앵글, 얼굴 식별 불가.

다만 HEVC(H.265)로 인코딩돼 있어 브라우저가 디코딩하지 못했다. 같은 페이지
같은 브라우저에서 대조:

    HEVC  .mp4  -> readyState 0, videoWidth 0, duration NaN, error 이벤트 없음
    VP9   .webm -> readyState 1+, videoWidth 1600, duration 4.04

서버(Range 미지원 가능성)를 배제하려 blob 으로 통째로 올려도 HEVC 는 같았다.
오류를 내지 않는 실패라 리허설 없이는 회의실에서야 알게 되는 종류다.

docs/deck/prepare_clips.py 신설 — 원본을 VP9/WebM 으로 변환(ffmpeg·openh264
없어 H.264 인코딩 불가, avc1/H264/X264 전부 VideoWriter 열기 실패)하고 클립
자신의 마지막 프레임을 포스터로 뽑는다. 원본 mp4 도 clips/ 에 남겨 두고
build_deck 이 <source> 를 webm→mp4 순으로 내보내 환경에 맞는 쪽이 재생된다.

포스터를 클립에서 뽑는 이유: deck.json 의 포스터는 클립이 없을 때 쓰는 대체
도판이라 클립이 생기면 어긋난다 — 비교 슬라이드에서 좌우 비교 도판이 좌우
패널 각각에 들어가 비교가 두 겹이 됐다.

cv2.imwrite 는 Windows 에서 한글 경로에 쓰지 못한다(예외 없이 False). 이
프로젝트는 파일명이 전부 한글이라 포스터가 조용히 하나도 안 만들어졌다.
imencode + write_bytes 로 우회.

확인하지 못한 것: 재생 진행 여부. 자동화 브라우저 컨텍스트가 디코딩까지만
허용하고 currentTime 이 0 에서 움직이지 않는다(자기가 MediaRecorder 로 만든
WebM 도 동일). 발표 노트북에서 한 번 눌러 확인해야 한다. 재생이 안 되더라도
poster 가 남아 슬라이드가 비지는 않는다.

영상 브리프에 형식 요구(H.264, HEVC 금지)와 변환 절차를 반영. 남은 P1 2종
(v04 구역진입·v05 카메라 건드림) 자리는 계속 스틸로 대체된다.
2026-08-30 22:52:23 +09:00
유창욱
fdf3d8b72a docs(index): 덱·영상 브리프 기록 (9ee3dab) 2026-08-25 11:04:02 +09:00
유창욱
9ee3dab378 docs(deck): 경영진 보고 덱 + 영상 브리프, 소개서에 도판 11장 배치
받은 이미지 11장을 docs/images/ 로 최적화 배치(37.6MB -> 3.3MB: 사진은
JPEG q88, 평면 다이어그램은 PNG-8, 최대폭 1600). 원본은 image/ 에 그대로 둔다.

소개서: 매니페스트(docs/소개서-이미지.json)로 11장을 절에 배치해 HTML 재생성
(4.6MB 단일 파일). 표지 컷은 1장 앞에 before-heading 으로.

경영진 보고 덱(docs/deck/): 14장. 매뉴얼 생성기와 같은 구조 —
deck.json(내용) + build_deck.py(생성기) + clips/(3초 클립). 손으로 쓴 HTML 은
두 번째 수정부터 낡고, 낡은 덱은 회의실에서 틀린 숫자를 띄운다.

클립이 없으면 포스터 스틸로 자동 대체되므로 지금 상태로도 완성본이다.
비교 슬라이드는 클립이 하나도 없으면 이미 좌우 비교인 09 스틸 1장으로
바뀐다 — 좌우 패널에 쪼개 넣으면 같은 비교가 두 겹이 된다.

모든 클립·스틸에 '재현' 배지를 템플릿에 박았다. 이 영상들은 제품 동작
녹화가 아니라 연출된 재현이고, 실제 동작처럼 보여주면 허위 시연이 된다.
배지 문구는 매체를 따른다(영상/이미지) — 스틸에 "재현 영상"은 그 자체가
작은 거짓말이다. 실화면 캡처 8장에는 배지가 붙지 않는다.

실렌더로 확인하며 잡은 결함:
- 마지막 슬라이드가 백지였다. :nth-child(even) 이 section.closing 보다
  명시도가 높아 네이비 배경을 덮고, 흰 글자가 흰 배경에 얹혔다(대비 1:1).
- 한국어 제목이 어절 중간에서 끊겼다("설명할 수 있습/니다") — CJK 기본
  줄바꿈이 글자 단위다. word-break:keep-all.
- 슬라이드 이동이 전혀 동작하지 않았다. scroll-snap-type:y mandatory 인
  컨테이너에서 behavior:'smooth' 가 취소되고 원위치로 스냅된다
  (측정: smooth->0, auto->905). 즉시 전환으로 변경 — 덱에서는 컷 전환이
  오히려 슬라이드답다. 키보드가 항상 잡히는 것은 아니라 이전/다음 버튼도 추가.
- 도판이 커서 하단 주석이 화면 밖으로 밀렸다(max-height 하향).
- 표지 베일이 무거워 경광등 빨강이 죽었다 — 이 제품의 상징색이라 살렸다.
- 4번과 5번이 같은 도판을 썼다(포스터 교체).

접근성 게이트(측정): 대비 실패 0건. figcaption 4.35:1 미달로 --ink-3 을
#5C6474 로, 네이비 위 슬라이드 번호 2.0:1 을 #8FB2E2 로 교정. 레일 점은
7x7 이라 발표 중 못 맞춘다 — 점은 그대로 두고 히트영역 24x24(WCAG 2.5.8),
이전/다음 버튼 46x46.

영상 브리프(docs/영상-브리프.md): 3초 클립 6종. 스틸이 못 하는 것만 발주
했고, 클립마다 0.0~3.0초 타임라인·프레임 안에 반드시 있어야 할 것·피할 것을
적었다. 대시보드 녹화는 제외 — 실화면 캡처가 이미 있고 그건 진짜다.

생성물(경영진-보고.html)과 clips/ 는 gitignore — 사용설명서.html 과 같은 이유.
2026-08-25 11:03:50 +09:00
유창욱
643b428d0a docs(index): DeviceHealthWatch 구현 기록 (9c0cfd2) 2026-08-25 10:36:59 +09:00
유창욱
9c0cfd2fea feat(alert): 아무도 발행하지 않던 device_fault·monitoring_degraded 구현
두 유형은 타입 정의(edge/events.py)·서버 검증(EventValidator)·콘솔 필터·
통보 라벨까지 전 구간에 있었으나 생성하는 코드가 어디에도 없었다. 실제로
값을 넣는 것은 seed_demo.php 뿐이었고, 소개서가 내세운 "17종"은 15종이었다.

앞서 고친 EventValidator 누락과 정확히 반대 방향의 같은 병이다. 그때는
감지기가 있는데 서버가 막았고, 이번엔 서버·UI가 열려 있는데 감지기가 없다.
어느 쪽이든 화면은 멀쩡해 보인다.

엣지가 낼 수 없는 유형이라 서버에 둔다 — 전원·회선이 끊긴 노드는 자기가
죽었다고 신고할 수 없다. 하트비트의 부재로만 관측된다.

App\Alert\DeviceHealthWatch:
- dispatch_alerts.php 1분 크론의 1단계. 에스컬레이션·통보보다 앞에 두어
  같은 실행 안에서 발행된 장치 이벤트가 곧바로 통보 대상이 되게 했다.
- 멱등은 EscalationWatch와 같은 방식(event_uuid UNIQUE 충돌)이되, 장치
  건전성은 연속 상태라 uuid 앵커를 '장애 구간의 시작'으로 잡았다.
  device_fault는 last_heartbeat_at(장애 내내 불변) → 장애당 정확히 1건.
  monitoring_degraded는 창 안의 마지막 정상 하트비트 ts. 창 전체가 저하면
  시작을 볼 수 없으므로 창 버킷으로 폴백해 시간당 1회 재고지한다 —
  의도된 리마인더이고, 매 틱 재발행하는 앵커 드리프트가 아니다.
- 성능 저하는 지속을 요구(기본 5분). 한 번 튄 값에 울리면 운영자가 이
  유형을 무시하게 되고, 그러면 감시가 멈춘 날에도 아무도 보지 않는다.
  엣지 감지기들이 쓰는 규율과 같다.
- 하트비트를 한 번도 보낸 적 없는 장치는 장애가 아니다(미설치 노드).
  새 현장 첫날이 빨간 벽이 되면 그 사이트는 이 유형을 영영 신뢰하지 않는다.
- 죽은 노드에 degraded를 겹쳐 내지 않는다 — 한 고장에 두 알람은 소음이다.
- fps NULL을 0으로 강압하지 않는다(측정 실패 ≠ 정지). PdoDeviceMetric
  Repository가 NULL을 지키는 이유와 같다.
- 억제(정비 모드)는 여기서 보지 않는다. 기록은 남기고 통보 여부는
  AlertDispatcher가 정한다 — 정비 창은 전화를 끄는 것이지 노드가 죽었던
  기록을 지우는 것이 아니다.

camera_id는 NOT NULL인데 장치 단위 이벤트에는 카메라가 없다. '__device__'
센티넬을 쓰고, 콘솔은 "장치 전체"로 표시, 구역 편집기 카메라 목록에서는
제외한다(카메라 목록은 보고된 이벤트에서 파생되므로, 두면 존재하지 않는
카메라에 구역을 그리려는 일이 생긴다).

설정 4종 추가(device_stale_seconds 180 · degraded_sustain_seconds 300 ·
degraded_fps_ratio 0.6 · target_fps 12) — 두 config 템플릿에 한국어 설명과
함께. 낮은 프레임으로 운용하는 현장이 target_fps를 안 맞추면 전 노드가
영구 저하로 보고되므로 그 경고를 템플릿에 적었다.

검증: pytest 308 passed(3 skip: 모델 미동봉·shellcheck 미설치·AF_UNIX,
기존과 동일) · phpunit 240 passed(신규 16). dist 재생성으로 패리티 회복.
2026-08-25 10:36:48 +09:00
유창욱
46d45c9a6e docs: 이미지 브리프2(감지 장면 분리) + 영상 보관정책·노조대응 논리
브리프2: 새로 추가한 감지 장면 4장만 독립 문서로. 1차 브리프에서 §B를
떼어내되 공통 규칙·수치 주의·발주 순서를 다시 실어 단독으로 성립하게 했다.

영상 보관정책: 노조의 "감시체제 아니냐" 공격에 대한 대응 논리.
핵심은 질문의 전제가 이 제품에 적용되지 않는다는 것 — 영상을 녹화하지
않는다. 링버퍼는 RAM 90프레임이고 디스크에 쓰지 않으며, 이벤트 때만
얼굴 블러된 3장이 서버로 가고 7일 후 삭제된다(코드 근거 표로 정리).

'보관하되 아무도 못 본다'는 최악의 선택이라고 결론. 그것은 행동에 대한
약속이라 검증 불가능하고 관리자 설정 하나로 뒤집힌다 — 노조의 불신은
정확히 그 지점이다. 미수집은 되돌릴 수 없다는 것이 유일하게 강한 카드다.

'하루치 보관해 조짐 관찰'은 권하지 않음(5개 이유). 상시 녹화를 시작하는
순간 기존 CCTV의 열등한 복제품이 되고 고유 포지션을 잃는다.

진짜 쟁점은 기간이 아니라 접근 구조라는 점을 명시. 그 과정에서 제품
공백 하나 확인: 스냅샷 열람이 감사기록에 남지 않는다(현재 audit->append
호출부는 쓰기 동작뿐). 노조 협의에서 가장 강한 카드가 될 항목이라 보완
권고로 남겼다.

법령 근거(표준지침 41조 30일 기본값, 산안법 164조 3년, 근참법 20조
감시설비 협의사항, 인권위 2017)는 출처 링크와 함께 참고로만 적고,
노무사·변호사 확인 필요를 문서 상단에 명시했다.
2026-08-24 19:31:28 +09:00
유창욱
bb5710ac52 docs(index): 이미지 브리프 §B 추가 기록 (107f7d9) 2026-08-24 19:25:25 +09:00
유창욱
107f7d9f8a docs(brief): 감지 시점 설명 이미지 4종 추가 (§B 신설)
브리프에 "언제 감지되는가"에 답하는 이미지가 하나도 없었다. 쓰러진 사람
'사진'을 부적절하다고 제외하면서 '감지 시점을 설명하는 그림'까지 함께
빠졌다. 둘은 다르다 — 일러스트로 그린 사고 장면은 프라이버시 제약과
충돌하지 않고, 안전 자료에서는 표준 표현이다.

신설 §B (감지 장면) 4종:
- 08-넘어짐-감지-시퀀스: 상태기계를 그림으로. 1초(위험 자세) → 3초(확정)
  → 10초(미동 없음), ②에서 회복 시 니어미스 분기. 전체 최우선.
- 09-앉기-vs-넘어짐: 도입 검토 첫 질문("앉으면 울리냐")에 대한 답
- 10-위험구역-판정: 판정 기준이 몸통이 아니라 발 위치라는 점 + 0.75초
- 11-2인1조: 인원 수 기반 규칙 (30초)

수치·판정 조건은 edge/detectors/{fall,zone,occupancy}.py 실코드값으로
확인해 적었다. 그림이 제품과 어긋나면 영업 문서에 거짓을 싣는 것이 된다.

기존 B(다이어그램)→C, C(정리)→D, D(제외 목록)→E 로 재번호.
E의 '실제 프레임 제외' 항목에는 일러스트는 포함했다는 단서를 남겨,
나중에 판단이 뒤집힌 것으로 읽히지 않게 했다.
2026-08-24 19:25:15 +09:00
유창욱
2fc8178a5c docs: 프로젝트 소개서 전면 재작성 + 이미지 브리프
기존 소개서의 문제는 문장이 아니라 구조와 최신성이었다.

- 기대효과 섹션이 아예 없었다. 기능 목록만 있고 "그래서 뭐가 달라지나"가 없었다.
- 왜 만들었나가 4문장이었다. 이미 CCTV 가 있는 현장이 왜 또 사는지에 답하지 못했다.
- 내용이 0.9.0 시점이었다. 1.0.0 의 핵심인 통보·에스컬레이션이 로드맵에 "P3 예정"으로
  적혀 있었고 테스트 320건·감사 43항목도 옛 수치였다 — 완성된 기능을 미완성으로 소개하는
  문서가 고객에게 나갈 뻔했다.

재작성
- 기능 나열을 버리고 감지 → 대응 → 증거의 사슬로 재구성. 어느 한 칸이 비면 나머지가
  무의미해진다는 것이 이 제품의 논지이므로 문서 구조도 그래야 한다.
- 왜 만들었나를 "CCTV 는 이미 있는데 왜 사고를 못 막나" 세 가지(아무도 안 본다 / 발견이
  늦다 / 증명할 자료가 없다)로 세우고, 각각에 대응하는 답을 표로 연결.
- 기대효과 신설. 지어낸 ROI 대신 **제품이 스스로 계측해 보여주는 지표**(TTA, 니어미스:사고,
  조치 전후 밀도, 오탐율)로 썼다 — 공급자 주장이 아니라 고객 화면의 숫자다.
  '약속하지 않는 것' 절을 함께 넣었다. 사고를 없애지 못하고, 감지율 100%는 없고, 오탐은
  초기에 반드시 난다. 과장은 도입 후에 반드시 되돌아온다.
- 설계 제약(감시하지 않는다 / 인터넷 없이 동작한다 / 클라우드를 안 쓴다)을 기능보다 앞에
  두었다. 이 제품의 형태는 거기서 나왔다.

수치는 전부 코드와 대조해 교정
- 테스트 535건(pytest 311 + PHPUnit 224), 감사 55건, 감지 17종 + 서버 발행 1종
- "3초 안에 찾아낸다"는 과장이었다. 실제로는 쓰러진 자세가 3초 지속되면 확정이므로
  "3초면 알아채고"로 고치고, 근거가 되는 3초를 감지 표에 함께 적었다.

이미지 브리프 신설 (docs/소개서-이미지-브리프.md)
- 현장 사진 4장 + 다이어그램 3장의 발주 명세. 각 항목에 "이 이미지가 없으면 전달 안 되는
  것", 담을 것, 피할 것, 사양, 캡션안, 우선순위.
- 제품 화면 캡처 8장은 이미 확보되어 있어 발주 대상에서 제외.
- 개인정보 제약을 최상단에 명시. 얼굴이 식별되는 작업자 사진이 한 장이라도 들어가면
  "감시하지 않는다"는 이 제품의 핵심 주장이 무너진다.
- 검토했으나 뺀 이미지(쓰러진 순간 프레임, 블러 전/후 비교, 스켈레톤 오버레이)와 그 이유도
  기록. 나중에 "왜 없지"가 반복되지 않도록.

HTML 은 md-to-html-report 로 재생성(목차·표·콜아웃·인쇄 스타일). 실렌더 확인.
2026-08-24 19:06:24 +09:00
73 changed files with 4154 additions and 36 deletions

11
.gitignore vendored
View file

@ -38,3 +38,14 @@ deploy/device.env
# 납품 패키저가 빌드할 때마다 다시 만든다. 커밋된 사본을 두면 화면이 바뀐 뒤에도 그것이
# 최신처럼 보이는데, 구 UI 캡처가 남은 매뉴얼은 납품물 자체의 결함이다.
server/deploy/manual/사용설명서.html
# 경영진 보고 덱 — 생성물. 매뉴얼(사용설명서.html)과 같은 이유로 커밋하지 않는다:
# 낡은 사본이 최신인 척하는 것이 가장 위험하다. build_deck.py 로 다시 뽑을 것.
docs/deck/경영진-보고.html
# 3초 클립은 바이너리다. 저장소가 아니라 발표 노트북에 둔다.
docs/deck/clips/*
!docs/deck/clips/.gitkeep
# 프로비저닝 자격증명 — 납품 폴더(dist/) 밖에 둔다.
# dist/ 안에 있으면 폴더째 압축할 때 같이 나간다.
/secrets/

View file

@ -6,6 +6,57 @@
---
## 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 은 **"감지에 대응하는 시스템"** 입니다.

View file

@ -1 +1 @@
1.0.0
1.1.0

View file

@ -140,3 +140,17 @@
- **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)

View file

@ -36,7 +36,14 @@
뷰: `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 로 멱등)
`unacked_escalation` 이벤트 발행, event_uuid 로 멱등) ·
`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/ThresholdCatalog.php`: 편집 가능한 튜너블의 화이트리스트 + 범위 + 한국어 설명.
목록에 없는 키는 API 로 저장 불가 (장비가 조용히 무시하는 값을 "설정됨"으로 보이지 않게)
- `tools/`: `dispatch_alerts.php`(크론 1분) · `maintain.php`(크론 일 1회, 보존기간) ·

509
docs/deck/build_deck.py Normal file
View file

@ -0,0 +1,509 @@
#!/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()

0
docs/deck/clips/.gitkeep Normal file
View file

203
docs/deck/deck.json Normal file
View file

@ -0,0 +1,203 @@
{
"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": "감지 17종 전부가 위험 상태입니다. 자리 비움·작업 속도는 만들 수 있는 기능 자체가 없습니다."
}
],
"note": "존재하지 않는 데이터는 열람될 수 없고, 유출될 수 없고, 목적 외로 쓰일 수 없습니다. 접근 통제는 되돌릴 수 있지만 미수집은 되돌릴 수 없습니다."
},
{
"kind": "figure",
"eyebrow": "구성",
"title": "현장에 노드, 사무실에 대시보드",
"image": "06-구성도.png",
"note": "노드에서 서버로 가는 것은 스냅샷 3장과 메타데이터뿐입니다. 폐쇄망에서도 동작하며, 회선이 끊긴 동안의 이벤트는 노드에 쌓였다가 복구되면 올라갑니다."
},
{
"kind": "status",
"eyebrow": "현재 상태",
"title": "지금 어디까지 와 있습니까",
"done": {
"head": "완료 — 1.1.0 납품 버전",
"items": [
"감지 17종 · 통보 · 에스컬레이션 · 사고조사 패키지",
"자동 테스트 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": "감지 · 대응 · 증거"
}
]
}

149
docs/deck/prepare_clips.py Normal file
View file

@ -0,0 +1,149 @@
#!/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,7 +1,7 @@
# Delivery Audit — move-detect (납품 전 결함 목록 · 순차 처리)
> **판정 (2026-08-18): 적합 — 오프하드웨어 항목 전량 해소.**
> 1차 감사 43/43 + 2차 재감사(안전 플랫폼 확장 표면) 12/12 해소.
> **판정 (2026-09-04, 1.1.0): 적합 — 오프하드웨어 항목 전량 해소.**
> 1차 43/43 + 2차 재감사 12/12 + **3차 재감사(1.1.0 장치 건전성 표면) 14/14** 해소.
> 잔여는 실장비(OFF-HW) 검증뿐 — 아래 "OFF-HW 잔여" 절 참조.
Source: 62-agent adversarial audit (2026-07-16) + skill `iot-airgap-delivery` §3 + manual
@ -349,6 +349,90 @@ misconfig/degradation fail LOUD.
---
## 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 미포함) 교체를 권고한다.
운영 계정 조작이라 이 저장소에서 수행하지 않는다.
- **Pi 번들 재빌드** — 1.1.0 은 서버 전용이라 `edge/` 가 불변이고 내용은 1.0.0 과 동일하지만,
파일명·MANIFEST 의 버전 표기가 1.0.0 이다. arm64 + 네트워크가 있는 빌드 머신에서만 재빌드
가능하므로 Pi 납품 시 그 머신에서 수행한다.
- **매뉴얼에 신규 2종 설명 없음** — `manual.json` 에 `장치 이상`·`감시 성능 저하` 항목이 없다
(1.0.0 의 `미확인 경보`도 마찬가지라 이번 회귀는 아니다). 캡처는 실화면이고 UI 골격은 바뀌지
않았다. 다음 매뉴얼 개정에서 함께 넣는다.
- **크론 중복 실행 잠금 없음** — 재시도 패스에 claim 이 없어 1분을 넘기는 실행이 겹치면
중복 발송이 가능하다. 1.0.0 부터 있던 성질이고 이번 변경이 만든 것이 아니다.
---
## OFF-HW 잔여 (실장비 커미셔닝에서만 검증 가능)
코드·문서로 닫을 수 없는 항목. 납품 판정을 막지 않되, 현장 설치 시 반드시 수행한다.

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

BIN
docs/images/05-사슬.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 579 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

BIN
docs/images/11-2인1조.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

19
docs/images/README.md Normal file
View file

@ -0,0 +1,19 @@
# 소개서 이미지
`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

@ -0,0 +1,425 @@
# 프로젝트 소개서 — 이미지 브리프
대상 문서: `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] 증거
───────────── ───────────────── ─────────────────
17종 이벤트 → 현장 경광등 (즉시) → 사고조사 패키지
엣지 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

@ -0,0 +1,157 @@
# 이미지 브리프 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

@ -0,0 +1,60 @@
{
"images": [
{
"file": "01-현장-설치.jpg",
"heading": "## 1. 왜 만들었나",
"placement": "before-heading",
"caption": "작업장에 설치된 카메라와 현장 경광등."
},
{
"file": "05-사슬.png",
"heading": "## 2. 무엇을 하는가",
"caption": "감지 → 대응 → 증거. 어느 한 칸이 비면 나머지가 무의미해집니다."
},
{
"file": "08-넘어짐-감지-시퀀스.jpg",
"heading": "### 2-1. 감지 — 무엇을 알아채는가",
"caption": "넘어짐 감지 단계. 1초 위험 자세 → 3초 확정 → 10초 미동 없음. 도중에 일어서면 니어미스로만 기록됩니다."
},
{
"file": "09-앉기-vs-넘어짐.jpg",
"heading": "### 2-1. 감지 — 무엇을 알아채는가",
"caption": "앉은 자세와 넘어진 자세는 형태와 지속 시간으로 구분합니다."
},
{
"file": "10-위험구역-판정.jpg",
"heading": "### 2-1. 감지 — 무엇을 알아채는가",
"caption": "위험구역은 화면 위에 직접 그립니다. 판정 기준은 발 위치입니다."
},
{
"file": "11-2인1조.jpg",
"heading": "### 2-1. 감지 — 무엇을 알아채는가",
"caption": "자세뿐 아니라 구역 안의 인원 수도 안전 조건입니다."
},
{
"file": "02-경광등.jpg",
"heading": "### 2-2. 대응 — 어떻게 사람에게 닿는가",
"caption": "현장 경광등은 감지한 노드가 자기 GPIO로 직접 울립니다. 서버도 인터넷도 거치지 않습니다."
},
{
"file": "07-대응시간.png",
"heading": "### 3-1. 즉시 달라지는 것",
"caption": "발견까지 걸리는 시간의 변화."
},
{
"file": "06-구성도.png",
"heading": "## 4. 시스템 구성",
"caption": "카메라 → 엣지 노드 → 대시보드. 노드에서 서버로 가는 것은 스냅샷 3장과 메타데이터뿐입니다."
},
{
"file": "03-엣지노드.jpg",
"heading": "### 4-1. 엣지 노드",
"caption": "Raspberry Pi 5 + Hailo-8 가속기."
},
{
"file": "04-위험구역.jpg",
"heading": "## 6. 납품과 운영",
"caption": "설치 대상이 되는 전형적인 작업 공간."
}
]
}

197
docs/영상-브리프.md Normal file
View file

@ -0,0 +1,197 @@
# 경영진 보고용 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

@ -0,0 +1,190 @@
# 영상 보관 정책과 노조 대응 논리
> 작성 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` |
| 이벤트 기록(텍스트) | 서버 | 기본 영구 — **개인 식별 정보 없음** | `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종** — 감지 17종 전부가 *위험 상태* 이고,
자리 비움·작업 속도·휴게 시간 같은 항목은 **설계상 존재하지 않습니다**
- **위·변조 탐지 감사기록** — 설정 변경·확인·오탐 처리가 해시 체인으로 남아
지우거나 고치면 검증에서 드러납니다
- **스냅샷 7일 자동 삭제**, 이후에는 통계와 텍스트 기록만 남음
### 아직 없는 것 — 보완을 권고합니다
**스냅샷을 누가 열람했는지가 감사기록에 남지 않습니다.** 현재 감사기록은 쓰기
동작(확인·오탐·구역 변경·설정 변경·로그인)만 기록합니다(`server/src/**` 의
`audit->append` 호출부 기준).
이것은 노조 협의에서 **가장 강한 카드가 될 수 있는 항목이 비어 있는 것**입니다.
"아무도 함부로 보지 않는다"를 약속이 아니라 **기록으로** 만들 수 있습니다.
권고 3가지:
1. **스냅샷 열람을 감사기록에 남긴다** — 누가, 언제, 어느 이벤트의 사진을 열었는지.
2. **노조 대표에게 열람 계정(viewer)과 감사기록 조회 권한을 준다** —
감시자를 감시할 수 있게 하는 것이 불신을 푸는 가장 빠른 길입니다.
3. **보관기간을 화면에 표시한다** — 설정 파일 안이 아니라 눈에 보이는 곳에.
---
## 5. 법적 근거 (참고 — 확인 필요)
### 5-1. 보관기간
**표준 개인정보 보호지침 제41조** — 개인영상정보는 보유 목적 달성을 위한 최소한의
기간으로 보유기간을 산정하되, **그 기간을 산정하기 곤란하면 수집 후 30일 이내**로
합니다. 30일이 상한이 아니라 *기간을 정하기 어려울 때의 기본값* 이라는 점이 중요합니다 —
목적이 명확하고 사유를 밝히면 다르게 정할 수 있습니다.
이 제품의 스냅샷 기본 **7일**은 그 기본값보다 짧고, "이벤트 확인·초기 대응"이라는
목적에 비추어 설명 가능한 값입니다. **7일을 늘릴 이유가 없습니다.**
### 5-2. 사고 기록 보존 의무 — 이미지와 분리해서 보십시오
**산업안전보건법 제164조**는 산업재해 발생 원인 등의 기록을 **3년** 보존하도록 합니다.
여기서 요구되는 것은 **기록**이지 영상이 아닙니다.
그래서 이 제품의 설계가 두 요구를 동시에 만족합니다.
- **개인 식별 이미지** → 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

@ -0,0 +1,343 @@
# move_detect — 작업장 안전 AI CCTV
> **쓰러진 작업자를 3초면 알아채고, 즉시 현장에 울리고, 5분 안에 확인되지 않으면 위로 올립니다.**
>
> 실내 공장·창고의 안전사고를 엣지 AI가 실시간으로 감지하고, 감지에서 끝내지 않고 **현장 경광등과
> 담당자 통보까지 이어지도록** 만든 온프레미스 안전관리 시스템입니다. 영상은 외부로 나가지
> 않습니다 — 사고 순간의 스냅샷 3장(얼굴 자동 블러)만 전달합니다.
버전 1.1.0 · 2026-09-04
---
## 1. 왜 만들었나
### 1-1. CCTV는 이미 있는데, 왜 사고를 못 막나
대부분의 작업장에는 이미 CCTV가 달려 있습니다. 그런데도 사고가 발생하면 다음 세 가지가
반복됩니다.
**첫째, 아무도 보고 있지 않습니다.** 카메라 수십 대를 실시간으로 지켜보는 인력은 어디에도
없습니다. CCTV는 사실상 **사후 확인용 녹화기**로만 쓰이고 있고, 그것은 사고를 막는 장치가
아니라 사고를 설명하는 장치입니다.
**둘째, 발견이 늦을수록 피해가 커집니다.** 넘어짐 자체보다 위험한 것은 넘어진 뒤 방치되는
시간입니다. 의식을 잃은 작업자에게 그 시간은 곧 후유장해와 사망 위험의 크기입니다.
야간·주말·단독 작업 시간대에는 이 공백이 몇 시간 단위로 벌어집니다.
**셋째, 사고 후에 증명할 자료가 없습니다.** 사고가 나면 사업주는 "무엇을 위험으로 보고
있었고, 어떤 통제를 걸어두었으며, 사건 당시 그것이 작동했는가"를 설명해야 합니다.
녹화 영상만으로는 그 질문에 답할 수 없습니다.
### 1-2. 이 세 가지를 각각 겨냥했습니다
| 문제 | move_detect의 답 |
|------|------------------|
| 아무도 안 보고 있다 | AI가 24시간 대신 봅니다. 사람은 **감지된 사건만** 봅니다 |
| 발견이 늦다 | 감지 즉시 **현장 경광등**이 울리고, 확인이 없으면 **상위 담당자로 자동 확대**됩니다 |
| 증명할 자료가 없다 | 사건마다 증거·통보·조치·위험요인이 묶인 **사고조사 패키지**가 한 번에 나옵니다 |
### 1-3. 설계를 결정한 세 가지 제약
이 제품의 형태는 다음 세 제약에서 나왔습니다. 기능보다 이 제약이 먼저입니다.
- **작업자를 감시하지 않는다.** 라이브뷰가 없습니다. 영상은 저장도 전송도 하지 않습니다.
사고 순간의 스냅샷만, 그것도 얼굴을 블러 처리한 뒤에 보냅니다. 근로자 동의를 받기 어려운
구조로는 현장에 들어갈 수 없습니다.
- **인터넷이 끊겨도 동작한다.** 폐쇄망 현장을 전제로 만들었습니다. 정전·회선 장애는 위험이
오히려 높아지는 시간대인데, 그때 시스템이 함께 죽으면 의미가 없습니다.
- **클라우드 GPU를 쓰지 않는다.** 추론은 현장 장비에서 끝납니다. 월 사용료가 없고, 영상이
외부로 나갈 이유 자체가 없어집니다.
---
## 2. 무엇을 하는가
기능을 나열하기 전에 구조를 먼저 봅니다. 이 제품은 **하나의 사슬**입니다.
```
감지 대응 증거
───────── ───────────────── ────────────────────
17종 이벤트 → 현장 경광등 (즉시) → 사고조사 패키지
엣지 AI 판정 담당자 통보 증거 3프레임
얼굴 블러 미확인 시 상위 확대 통보 이력 · 조치 이력
위·변조 탐지 감사기록
```
사슬의 어느 한 칸이 비면 나머지가 무의미해집니다. 감지만 있으면 아무도 모르고,
대응만 있으면 나중에 설명할 수 없습니다.
### 2-1. 감지 — 무엇을 알아채는가
엣지가 **17종**을 판정하고, 여기에 서버가 발행하는 '미확인 경보 확대' 1종이 더해집니다.
사람에 대한 것, 구역 규칙에 대한 것, 그리고 **감시 장비 자신**에 대한 것으로 나뉩니다.
**사람의 위험 상태**
| 이벤트 | 무엇을 잡는가 |
|--------|---------------|
| 넘어짐 | 기립→쓰러짐 전이를 자세로 판정. 쓰러진 자세가 **3초**(조정 가능) 지속되면 확정 — 앉는 동작과 구분 |
| **넘어짐 후 미동 없음** | 쓰러진 뒤 움직임이 없으면 **위급으로 승격** — 의식 소실 의심 |
| 장시간 부동 | 자세와 무관하게 오래 움직이지 않는 작업자 |
| 위험 상태 소실 | 위험한 상태였던 사람이 화면에서 사라짐 (가림·추락 등) |
| 넘어질 뻔함 (니어미스) | 사고 직전까지 갔다가 회복 — **사고 전 신호** |
| 작업자세 부담 | 허리·팔 각도 노출 시간 집계 (익명 통계) |
**구역 규칙 위반** — 관리자가 카메라 화면 위에 그린 구역에 대해 판정합니다.
| 이벤트 | 무엇을 잡는가 |
|--------|---------------|
| 경고구역 진입 / 제한구역 침입 / 협착구역 침입 | 등급별 구역 진입 |
| 단독 작업 | 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. 납품과 운영
- **웹호스팅 업로드 패키지** — 서버 일체(마이그레이션 8종 포함)를 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 없음, 일반 웹호스팅에서 동작 |
| **감지 유형** | 17종 활성 (+ 모델 확보 대기 3종) |
| **검증** | 테스트 535건 · 납품 감사 55건 전량 해소 |

BIN
image/01-현장-설치.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 MiB

BIN
image/02-경광등.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

BIN
image/03-엣지노드.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

BIN
image/04-위험구역.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 MiB

BIN
image/05-사슬.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

BIN
image/06-구성도.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 MiB

BIN
image/07-대응시간.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

BIN
image/11-2인1조.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

BIN
image/v02-앉기-정상.mp4 Normal file

Binary file not shown.

View file

@ -21,6 +21,12 @@ return [
'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 로 적으면 응급 대응에서 그대로 사고가 된다.

View file

@ -186,7 +186,7 @@ VALUES ('pi-01', '3라인 낙상 카메라', '여기에_비밀키_붙여넣기',
| 주기 | 실행할 명령 | 하는 일 |
|------|-------------|---------|
| 1분마다 | `/usr/bin/php /home/<계정>/move_detect_app/tools/dispatch_alerts.php` | 알림 발송 · 미확인 경보 상위 통보 · 실패 재시도 |
| 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 로 접속했을 때

View file

@ -41,6 +41,18 @@ return [
// 위급/높음 이벤트를 이 시간(초) 동안 아무도 확인하지 않으면 '미확인 경보' 이벤트를
// 새로 만들어 상위 담당자에게 통보합니다. 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
// 시각을 쓰지만 알림은 서버가 만들기 때문에, 이 값이 틀리면 화면과 메일의 시각이
// 서로 다르게 나옵니다. 한국 현장은 그대로 두세요.

View file

@ -571,6 +571,9 @@ a.event-thumb-slot:hover { border-color:var(--accent); box-shadow:0 0 0 3px var(
padding:.9rem 1rem 1rem; background:var(--card);
border:1px solid var(--line); border-radius:10px; box-shadow:var(--shadow-sm);
}
/* 이 파일의 다른 [hidden] 규칙과 같은 이유로 명시한다 — 카드에 display 를 주는 규칙이
나중에 붙으면 UA 기본값을 이겨 감춰야 할 카드가 그대로 보인다. */
.detail-action-card[hidden] { display:none; }
.detail-card-title { margin:0 0 .75rem; font-size:.72rem; font-weight:700; letter-spacing:.06em; text-transform:uppercase; color:var(--ink-3); }
.detail-empty { margin:0; font-size:.82rem; color:var(--ink-3); }
.detail-foot { margin:.8rem 0 0; font-size:.72rem; color:var(--ink-3); }

View file

@ -129,9 +129,16 @@
toastTimer = setTimeout(function () { toastEl.classList.remove('is-on'); }, 4000);
}
// Device-scope events (device_fault / monitoring_degraded) carry this sentinel: the server
// files them about the NODE, and move_events.camera_id is NOT NULL. Rendering it raw would put
// "__device__" in front of an operator, and letting it into a camera list would offer a camera
// that does not exist to a person trying to draw zones on it.
function isDeviceScope(ev) { return DeviceScope.is(ev); }
function cameraLabel(id) { return DeviceScope.label(id); }
function eventSummary(ev) {
var what = TYPE_LABELS[ev.event_type] || String(ev.event_type || '');
return what + (ev.camera_id ? ' · ' + ev.camera_id : '');
return what + (ev.camera_id ? ' · ' + cameraLabel(ev.camera_id) : '');
}
function buildBadge(sev) {
@ -327,7 +334,7 @@
sub.className = 'event-time';
var src = deviceLabel(ev.device_id);
if (src) sub.appendChild(spanWith('ev-src', src));
if (ev.camera_id) sub.appendChild(spanWith('ev-cam', String(ev.camera_id)));
if (ev.camera_id) sub.appendChild(spanWith('ev-cam', cameraLabel(ev.camera_id)));
var time = formatTime(ev.occurred_at);
if (time) sub.appendChild(spanWith('ev-at', time));
meta.appendChild(sub);
@ -924,7 +931,7 @@
function cameraStats() {
var byKey = {}, out = [];
combinedEvents().forEach(function (ev) {
if (!ev.camera_id) return;
if (!ev.camera_id || isDeviceScope(ev)) return;
var key = cameraKey(ev.device_id, ev.camera_id);
var cam = byKey[key];
if (!cam) {
@ -936,7 +943,7 @@
if (at > cam.last_at) cam.last_at = at;
});
liveEvents.forEach(function (ev) {
if (!ev.camera_id || isAcked(ev)) return;
if (!ev.camera_id || isDeviceScope(ev) || isAcked(ev)) return;
var cam = byKey[cameraKey(ev.device_id, ev.camera_id)];
if (!cam) return;
cam.unacked += 1;

View file

@ -397,7 +397,7 @@
var seen = {};
cameraRows = [];
((res[1] && res[1].events) || []).forEach(function (e) {
if (!e.camera_id || seen[e.camera_id]) return;
if (!e.camera_id || DeviceScope.is(e.camera_id) || seen[e.camera_id]) return;
seen[e.camera_id] = true;
cameraRows.push({ id: e.camera_id, label: e.camera_id });
});

View file

@ -250,7 +250,7 @@
addRow('심각도', buildBadge(ev.severity));
addRow('유형', textNode(TYPE_LABELS[ev.event_type] || ev.event_type));
addRow('장치', textNode(deviceNames[ev.device_id] || ev.device_id));
addRow('카메라', textNode(ev.camera_id, 'mono'));
addRow('카메라', textNode(DeviceScope.label(ev.camera_id), 'mono'));
addRow('발생 시각', textNode(formatTime(ev.occurred_at), 'mono'));
addRow('지속', textNode(fmtDuration(ev.duration_s)));
if (ev.track_id !== null && ev.track_id !== undefined) addRow('추적 ID', textNode(ev.track_id, 'mono'));
@ -315,6 +315,10 @@
// --- outbound links + improvement action ---------------------------------------------
function renderLinks() {
var ev = currentEvent || {};
var actionCard = document.getElementById('detail-action-card')
|| document.getElementById('detail-action-form');
if (actionCard) actionCard.hidden = !isCameraEvent(ev);
var pkg = document.getElementById('detail-package');
if (pkg) pkg.href = B + '/events/' + encodeURIComponent(id) + '/package/view';
var zones = document.getElementById('detail-zones');
@ -324,10 +328,18 @@
// and asks you anyway" problem the editor's own picker exists to remove.
zones.href = B + '/zones?device=' + encodeURIComponent(ev.device_id || '')
+ '&camera=' + encodeURIComponent(ev.camera_id || '');
zones.hidden = !ev.camera_id;
// 노드 단위 사건(장치 이상·감시 성능 저하)에는 카메라가 없다. 링크를 남겨 두면
// ?camera=__device__ 로 편집기가 열리고, 그 값이 그대로 저장되면 어떤 카메라와도
// 매칭되지 않는 구역이 '활성'으로 남는다. 서버도 막지만 여기서도 내보내지 않는다.
zones.hidden = !ev.camera_id || DeviceScope.is(ev.camera_id);
}
}
/** 이 사건이 카메라에 대한 것인가. 개선 조치는 카메라 단위 개념이라 노드 사건에는 없다. */
function isCameraEvent(ev) {
return !!(ev && ev.camera_id) && !DeviceScope.is(ev.camera_id);
}
function wireActionForm() {
var form = document.getElementById('detail-action-form');
var note = document.getElementById('detail-action-note');

View file

@ -0,0 +1,36 @@
/**
* 장치 단위 이벤트의 camera_id 센티넬 — 프런트 공용.
*
* device_fault / monitoring_degraded 는 카메라가 아니라 노드에 대한 사건인데
* move_events.camera_id 가 NOT NULL 이라 서버가 '__device__' 를 넣는다(App\DeviceScope).
*
* WHY A SHARED FILE
* 처음에는 이 값을 app.js 와 zone-draw.js 에만 적어 두었다. 그 결과 이벤트 스트림에서만
* "장치 전체" 로 보이고 상세 화면·리포트·사고조사 패키지·설정 점검·컴플라이언스에는
* `__device__` 가 그대로 노출됐다. 값이 흩어진 것이 원인이었으므로 한 파일로 모은다.
* 서버측 단일 출처는 App\DeviceScope 이고, 두 값이 어긋나면 테스트가 잡는다.
*/
(function (global) {
'use strict';
var CAMERA = '__device__';
var LABEL = '장치 전체';
global.DeviceScope = {
CAMERA: CAMERA,
LABEL: LABEL,
/** 이 이벤트(또는 camera_id)가 노드 단위인가. */
is: function (cameraIdOrEvent) {
if (cameraIdOrEvent && typeof cameraIdOrEvent === 'object') {
return String(cameraIdOrEvent.camera_id) === CAMERA;
}
return String(cameraIdOrEvent) === CAMERA;
},
/** 사람에게 보여줄 이름. 표시하는 모든 자리에서 이 함수를 통과시킨다. */
label: function (cameraId) {
return String(cameraId) === CAMERA ? LABEL : String(cameraId == null ? '' : cameraId);
}
};
})(window);

View file

@ -75,7 +75,7 @@
head.appendChild(el('p', 'pkg-doc-kind', '작업장 안전 사고조사 패키지'));
head.appendChild(el('h1', 'pkg-title', TYPE_LABELS[ev.event_type] || ev.event_type));
var sub = el('p', 'pkg-sub');
sub.textContent = [SEV_LABELS[ev.severity] || ev.severity, ev.camera_id, stamp(ev.occurred_at)]
sub.textContent = [SEV_LABELS[ev.severity] || ev.severity, DeviceScope.label(ev.camera_id), stamp(ev.occurred_at)]
.filter(Boolean).join(' · ');
head.appendChild(sub);
root.appendChild(head);
@ -87,7 +87,7 @@
kv(dl, '유형', TYPE_LABELS[ev.event_type] || ev.event_type);
kv(dl, '심각도', SEV_LABELS[ev.severity] || ev.severity);
kv(dl, '장치', ev.device_id);
kv(dl, '카메라', ev.camera_id);
kv(dl, '카메라', DeviceScope.label(ev.camera_id));
kv(dl, '현장', ev.site);
kv(dl, '발생 시각', stamp(ev.occurred_at));
kv(dl, '지속', (ev.duration_s || 0) + '초');

View file

@ -208,7 +208,7 @@
var maxSevere = r.by_camera.reduce(function (m, c) { return Math.max(m, c.critical_high); }, 0);
var hotFloor = Math.max(1, Math.ceil(maxSevere * 0.6));
r.by_camera.forEach(function (c) {
camerasEl.appendChild(barRow(c.camera_id, c.count, maxCam,
camerasEl.appendChild(barRow(DeviceScope.label(c.camera_id), c.count, maxCam,
c.count + '건 · 중대 ' + c.critical_high, maxSevere > 0 && c.critical_high >= hotFloor));
});
if (maxSevere > 0) {
@ -349,7 +349,7 @@
var row = el('div', 'action-row');
var what = el('div', 'action-what');
what.appendChild(el('span', 'action-date', fmtDate(a.taken_at)));
what.appendChild(el('span', 'action-cam', a.camera_id));
what.appendChild(el('span', 'action-cam', DeviceScope.label(a.camera_id)));
what.appendChild(el('span', 'action-note', a.note));
row.appendChild(what);
row.appendChild(actionEffect(a));

View file

@ -73,7 +73,10 @@
var online = devices.filter(function (d) { return d.online; });
var events = eventsRes.events || [];
var cameras = {};
events.forEach(function (e) { if (e.camera_id) cameras[e.camera_id] = true; });
events.forEach(function (e) {
if (!e.camera_id || DeviceScope.is(e.camera_id)) return; // 노드 단위 사건은 카메라가 아니다
cameras[e.camera_id] = true;
});
var cameraCount = Object.keys(cameras).length;
var steps = [];

View file

@ -353,7 +353,9 @@
var devs = (res[0] && res[0].devices) || [];
var evs = (res[1] && res[1].events) || [];
evs.forEach(function (ev) {
if (!ev.camera_id) return;
// '__device__' is the sentinel on node-scope events (device_fault / monitoring_degraded),
// not a camera. Offering it here would let an operator draw zones onto nothing.
if (!ev.camera_id || DeviceScope.is(ev.camera_id)) return;
var d = String(ev.device_id || '');
if (!camerasByDevice[d]) camerasByDevice[d] = [];
if (camerasByDevice[d].indexOf(ev.camera_id) === -1) camerasByDevice[d].push(ev.camera_id);

View file

@ -2,6 +2,8 @@
declare(strict_types=1);
namespace App\Alert;
use App\DeviceScope;
/**
* The senders. Two of the three channel kinds are delivered from here:
*
@ -82,7 +84,7 @@ final class Channels
{
$sev = self::SEVERITY_LABELS[$event['severity']] ?? (string)$event['severity'];
$type = self::TYPE_LABELS[$event['event_type']] ?? (string)$event['event_type'];
return "[{$sev}] {$type} — " . (string)($event['camera_id'] ?? '');
return "[{$sev}] {$type} — " . DeviceScope::label($event['camera_id'] ?? '');
}
public function body(array $event): string
@ -97,7 +99,7 @@ final class Channels
"유형 : {$type}",
"심각도 : {$sev}",
"장치 : " . (string)($event['device_id'] ?? '-'),
"카메라 : " . (string)($event['camera_id'] ?? '-'),
"카메라 : " . ($event['camera_id'] ?? null ? DeviceScope::label($event['camera_id']) : '-'),
"발생 시각: {$when}",
"지속 : " . (string)($event['duration_s'] ?? 0) . "초",
"",
@ -152,6 +154,7 @@ final class Channels
'severity' => $event['severity'],
'device_id' => $event['device_id'] ?? null,
'camera_id' => $event['camera_id'] ?? null,
'camera_label' => DeviceScope::label($event['camera_id'] ?? ''),
'site' => $event['site'] ?? null,
'occurred_at' => (int)$event['occurred_at'],
'duration_s' => (float)($event['duration_s'] ?? 0),

View file

@ -0,0 +1,248 @@
<?php
declare(strict_types=1);
namespace App\Alert;
use App\Repository\DeviceMetricRepository;
use App\Repository\DeviceRepository;
use App\DeviceScope;
use App\Repository\EventRepository;
/**
* Makes the monitoring system report its OWN failures as events.
*
* `monitoring_degraded` and `device_fault` existed everywhere except where it mattered: the event
* type was declared in edge/events.py, accepted by EventValidator, filterable in the console and
* labelled in the notification channels — and nothing on either side ever produced one. The edge
* cannot: a node that has crashed, lost power or lost its network is precisely the node that
* cannot file a report about itself. So the observation has to happen here, from the ABSENCE of a
* heartbeat rather than the presence of one.
*
* That asymmetry is the whole point. Every other event in this product means "something happened
* in front of a camera". These two mean "we may not be watching at all" — the failure mode the
* operator cannot see for themselves, because the console looks calm either way.
*
* IDEMPOTENCY, as in EscalationWatch, comes from a deterministic event_uuid colliding on the
* UNIQUE key rather than from a read-then-write race. 다만 장치 건전성은 **연속 상태**라
* uuid 를 두 조각으로 만든다: 장애 구간의 시작(불변) + now 를 재고지 주기로 내림한 버킷.
*
* device_fault -> (last_heartbeat_at, 시간 버킷). 마지막 하트비트는 장애 내내
* 불변이라 한 장애가 한 사건으로 묶이고, 버킷이 넘어갈 때마다 한 번씩
* 다시 올라온다 — 한 달째 죽은 노드가 첫 시각 이후 영원히 조용해지지
* 않게. 복구 후 재발은 last_heartbeat_at 이 달라 별건이 된다.
* monitoring_degraded -> (저하 시작 ts, 시간 버킷). 시작은 짧은 정상 표본을 흡수해
* 구하므로(degradation() 주석 참조) 간헐 고장에도 흔들리지 않는다.
*
* 이 조합이 두 실패를 동시에 막는다: 한 주기 안에서는 몇 번을 평가하든 uuid 가 하나라 폭주가
* 없고, 주기가 지나면 uuid 가 바뀌어 미해결 장애가 다시 눈에 띈다.
*
* Suppression (planned maintenance) is NOT consulted here. These are recorded either way and
* AlertDispatcher decides whether anyone is woken — the same layering every other event type uses.
* A maintenance window should silence the phone, not erase the record that a node was down.
*/
final class DeviceHealthWatch
{
/**
* move_events.camera_id is NOT NULL, but these two events are about the NODE, not any one
* camera. 값과 표시 라벨은 App\DeviceScope 가 단일 출처다 — 여러 곳에 문자열로 흩어 두었더니
* 콘솔 스트림에서만 라벨이 붙고 메일·리포트·CSV 로는 원문이 그대로 나갔다.
*/
public const DEVICE_CAMERA = DeviceScope::CAMERA;
public function __construct(
private EventRepository $events,
private DeviceRepository $devices,
private DeviceMetricRepository $metrics,
) {}
/**
* Deterministic 32-hex uuid for one fault run. Prefixed so it is recognisable in the DB.
*
* $anchorTs 는 장애 구간의 시작(고장이 계속되는 동안 불변)이고, $reminderBucket 은
* now 를 재고지 주기로 내림한 값이다. 둘을 함께 쓰면:
* · 같은 장애가 한 주기 안에서 몇 번 평가되든 uuid 가 하나 -> 폭주 없음
* · 주기가 넘어가면 uuid 가 바뀌어 한 번 더 올라옴 -> 죽은 채로 영원히 조용해지지 않음
* · 서로 다른 장애는 $anchorTs 가 달라 구분됨
*/
public static function uuidFor(string $kind, string $deviceId, int $anchorTs,
int $reminderBucket = 0): string
{
$seed = "$kind:$deviceId:$anchorTs:$reminderBucket";
return substr('d0c70401' . substr(hash('sha256', $seed), 0, 24), 0, 32);
}
/**
* @param array{stale_after_s?:int,sustain_s?:int,fps_ratio?:float,target_fps?:float,
* repeat_after_s?:int,limit?:int} $opts
* @return array<int,array{device_id:string,event_type:string,event_id:int}> what was filed
*/
public function run(int $now, array $opts = []): array
{
$staleAfter = max(60, (int) ($opts['stale_after_s'] ?? 180));
$sustain = max(0, (int) ($opts['sustain_s'] ?? 300));
$fpsRatio = max(0.0, (float)($opts['fps_ratio'] ?? 0.6));
$targetFps = max(0.0, (float)($opts['target_fps'] ?? 12.0));
$repeatAfter = max(300, (int) ($opts['repeat_after_s'] ?? 3600));
$limit = max(1, (int) ($opts['limit'] ?? 2000));
// 회복으로 인정할 최소 정상 지속. 하트비트 30초 기준 120초면 짧은 깜빡임은 흡수하고
// 진짜 회복은 4비트 안에 인정한다.
$recoverGrace = max(0, (int)($opts['recover_grace_s'] ?? 120));
// 같은 장애를 이 주기당 한 번만 다시 올린다. 죽은 노드가 영원히 조용해지지도,
// 매분 쏟아지지도 않는 지점.
$bucket = intdiv($now, $repeatAfter);
$filed = [];
foreach ($this->devices->listStatus(null) as $dev) {
$deviceId = (string)($dev['device_id'] ?? '');
if ($deviceId === '') continue;
$last = (int)($dev['last_heartbeat_at'] ?? 0);
// A device row that has NEVER reported is a provisioned-but-not-yet-installed node,
// not a fault. Alarming on it would make every new site's first day a wall of red and
// teach the operator to ignore this event type before it ever means anything.
if ($last <= 0) continue;
$silent = $now - $last;
if ($silent >= $staleAfter) {
// A node that is not reporting cannot also be meaningfully "degraded" — it is
// simply gone. Filing both would double-alarm a single failure.
$id = $this->file($dev, 'device_fault',
self::uuidFor('device_fault', $deviceId, $last, $bucket),
(float)$now, (float)$silent,
['last_heartbeat_at' => $last, 'silent_s' => $silent, 'stale_after_s' => $staleAfter]);
if ($id !== null) {
$filed[] = ['device_id' => $deviceId, 'event_type' => 'device_fault', 'event_id' => $id];
}
continue;
}
$verdict = $this->degradation($deviceId, $now, $repeatAfter, $limit,
$fpsRatio, $targetFps, $recoverGrace, $staleAfter);
if ($verdict === null) continue;
[$onset, $degradedFor, $metrics] = $verdict;
if ($degradedFor < $sustain) continue;
$id = $this->file($dev, 'monitoring_degraded',
self::uuidFor('monitoring_degraded', $deviceId, $onset, $bucket),
(float)$now, (float)$degradedFor,
$metrics + ['degraded_s' => $degradedFor, 'sustain_s' => $sustain]);
if ($id !== null) {
$filed[] = ['device_id' => $deviceId, 'event_type' => 'monitoring_degraded', 'event_id' => $id];
}
}
return $filed;
}
/**
* Is this node degraded right now, and since when?
*
* 회복은 **지속된** 정상이어야 인정한다. 이 규율이 없으면 간헐 고장에 감지기가 양쪽으로
* 무너진다: `edge/daemon.py` 는 성공 프레임 하나만 들어와도 streak 를 지워
* `cameras_erroring` 를 0 으로 되돌린다(리본 접촉 불량·USB 브라운아웃의 전형적 양상).
* 정상 표본 하나로 런을 끊으면
* (a) 지속 시간이 영원히 sustain 을 못 넘어 두 시간을 고장 나 있어도 아무것도 안 올라오고,
* (b) 끊긴 런마다 새 앵커가 생겨 같은 고장이 수십 건으로 쏟아진다.
* 엣지 낙상 감지기의 recover_grace_s 와 같은 판단이다.
*
* @return array{0:int,1:int,2:array}|null [anchorTs, degradedForSeconds, metrics], or null.
* anchorTs 는 저하가 시작된 시각이되, **그 시작이 창 안에서 실제로 관측될 때만** 그렇다.
* 창 전체가 저하면 시작이 시야 밖이므로 0(=미상)을 돌려준다 — 그때 시작 시각을 앵커로
* 쓰면 오래된 표본이 창 밖으로 밀릴 때마다 값이 흔들려 같은 장애가 여러 건으로 갈라진다.
*/
private function degradation(string $deviceId, int $now, int $window, int $limit,
float $fpsRatio, float $targetFps, int $recoverGrace,
int $staleAfter): ?array
{
$series = $this->metrics->series($deviceId, $now - $window, $limit);
// 미래 시각 표본은 버린다. NTP 보정·수동 백필로 들어오면 그 하나가 영원히 "최신"으로
// 고정돼, 실제로 멈춘 노드가 정상으로 보이거나 없던 장애가 sustain 을 건너뛴다.
$series = array_values(array_filter($series, static fn (array $p): bool => (int)$p['ts'] <= $now));
if ($series === []) return null;
$latest = $series[count($series) - 1];
// 최신 표본이 낡았으면 저하를 판정하지 않는다. 낡은 행으로 "지금 저하 중"이라고
// 말하는 것은 사실이 아니고, 진짜로 멈춘 노드는 device_fault 가 이미 잡는다.
if ($now - (int)$latest['ts'] > $staleAfter) return null;
$reasons = $this->reasons($latest, $fpsRatio, $targetFps);
if ($reasons === []) return null; // healthy right now — nothing to file
$onset = (int)$latest['ts'];
$onsetSeen = false; // 창 안에서 시작을 실제로 봤는가
$healthyRunEnd = null; // 연속 정상 구간의 최신쪽 ts
for ($i = count($series) - 2; $i >= 0; $i--) {
$ts = (int)$series[$i]['ts'];
if ($this->reasons($series[$i], $fpsRatio, $targetFps) === []) {
if ($healthyRunEnd === null) $healthyRunEnd = $ts;
// 정상 구간이 유예를 넘겼으면 그때 진짜 회복한 것 — 현재 장애는 그 뒤에 시작됐다.
if ($healthyRunEnd - $ts >= $recoverGrace) { $onsetSeen = true; break; }
} else {
$healthyRunEnd = null; // 유예 안쪽의 짧은 정상은 흡수한다
$onset = $ts;
}
}
return [$onsetSeen ? $onset : 0, max(0, (int)$latest['ts'] - $onset), [
'reasons' => $reasons,
'fps' => $latest['fps'] ?? null,
'target_fps' => $targetFps,
'cameras_total' => $latest['cameras_total'] ?? null,
'cameras_erroring' => $latest['cameras_erroring'] ?? null,
'throttle' => isset($latest['throttle']) ? (bool)$latest['throttle'] : null,
]];
}
/** Why this heartbeat counts as degraded. Empty = healthy. */
private function reasons(array $point, float $fpsRatio, float $targetFps): array
{
$out = [];
// A camera erroring out is the most direct form of "not watching": the node is alive and
// reporting, and one of its eyes is shut.
if (isset($point['cameras_erroring']) && (int)$point['cameras_erroring'] > 0) {
$out[] = 'cameras_erroring';
}
if (!empty($point['throttle'])) {
$out[] = 'thermal_throttle';
}
// Frame rate is the detector's sampling rate. Detector timers are clock-based, so a slow
// node still judges correctly — it just reacts later, which is exactly what is worth
// reporting before someone relies on a 3-second confirmation that now takes eight.
if (isset($point['fps']) && $point['fps'] !== null && $targetFps > 0.0
&& (float)$point['fps'] < $targetFps * $fpsRatio) {
$out[] = 'low_fps';
}
return $out;
}
/** Insert, tolerating the UNIQUE collision that means a concurrent run already filed it. */
private function file(array $dev, string $type, string $uuid, float $at,
float $duration, array $metrics): ?int
{
if ($this->events->idByUuid($uuid) !== null) return null;
try {
return $this->events->insert([
'event_uuid' => $uuid,
'device_id' => (string)$dev['device_id'],
'site' => $dev['site'] ?? null,
'camera_id' => DeviceScope::CAMERA,
'event_type' => $type,
// Matches edge/events.py _SEVERITY for these two types. Kept in step deliberately:
// one event type arriving from two producers must not carry two severities.
'severity' => 'warning',
'track_id' => null,
'zone_id' => null,
'occurred_at' => $at,
'duration_s' => $duration,
'metrics' => $metrics,
'spatial_x' => null,
'spatial_y' => null,
]);
} catch (\Throwable $e) {
$m = $e->getMessage();
if (!str_contains($m, 'uq_events_uuid') && !str_contains($m, 'UNIQUE')
&& !str_contains($m, 'Duplicate')) {
throw $e;
}
return null;
}
}
}

View file

@ -68,6 +68,29 @@ final class Config
*/
public function metricRetentionDays(): int { return (int)($this->a['metric_retention_days'] ?? 30); }
/**
* How long a node may stay silent before App\Alert\DeviceHealthWatch files a `device_fault`.
* The heartbeat is 30s, so 180 tolerates several consecutive misses (a brief network blip,
* a cron overrun) before declaring a node down — a false "장치 이상" at every hiccup would
* train the operator to ignore the one event that means nobody is watching.
*/
public function deviceStaleSeconds(): int { return (int)($this->a['device_stale_seconds'] ?? 180); }
/**
* How long a node must stay degraded (low fps / erroring camera / thermal throttle) before it
* files `monitoring_degraded`. Same discipline the edge detectors use: a sustained state, not
* a single bad sample. Five minutes.
*/
public function degradedSustainSeconds(): int { return (int)($this->a['degraded_sustain_seconds'] ?? 300); }
/**
* Fraction of TARGET_FPS below which a node counts as degraded, and the target it is measured
* against. The edge's own default is 12; a site that runs its nodes slower must set this or
* every node reports itself degraded forever.
*/
public function degradedFpsRatio(): float { return (float)($this->a['degraded_fps_ratio'] ?? 0.6); }
public function targetFps(): float { return (float)($this->a['target_fps'] ?? 12.0); }
/**
* Free-space floor for the snapshot volume, in megabytes. Below it the system raises a
* `device_fault` notice instead of discovering the problem as silently-failing image writes.

View file

@ -51,6 +51,12 @@ final class ActionsController
if (!$this->events->cameraExists($camera)) {
return Response::json(['error' => 'unknown_camera'], 400);
}
// 센티넬은 카메라가 아니다(App\DeviceScope). cameraExists 는 이벤트 존재로 판단하므로
// 장치 장애가 한 건이라도 있으면 통과해 버린다. 그대로 두면 "조치 전후 감지 밀도"가
// 노드 장애 건수로 계산되어, 카메라 안전 투자 효과라는 이름의 엉뚱한 숫자가 나온다.
if ($camera === \App\DeviceScope::CAMERA) {
return Response::json(['error' => 'unknown_camera'], 400);
}
// Scope BEFORE the write: a scoped operator must not record an action against another
// site's camera (it would pollute that site's report). 404 hides existence, as everywhere.
$site = $this->events->siteForCamera($camera);

View file

@ -47,6 +47,9 @@ final class ExportController
$lines = [];
foreach ($rows as $r) {
if (($r['event_type'] ?? '') === 'ergo_report') continue; // 통계 행: 라벨 대상 아님
// 장치 단위 이벤트(노드 고장·성능 저하)도 뺀다. 카메라 검출이 아니고 스냅샷이
// 없어 프레임으로 라벨링할 수 없다 — 데이터셋에 unreviewed 로 영원히 남는다.
if (\App\DeviceScope::isDevice($r['camera_id'] ?? null)) continue;
$isFp = ($r['false_positive_by'] ?? null) !== null;
$isAcked = ($r['acknowledged_by'] ?? null) !== null;
$lines[] = json_encode([

View file

@ -124,6 +124,13 @@ final class ZoneConfigController
// site's device. For an update, the EXISTING zone's device must also be in scope, so a
// caller cannot re-home a cross-site zone under one of their own device ids.
if (!$this->inScope($deviceId)) return Response::json(['error' => 'not_found'], 404);
// 장치 단위 이벤트의 camera_id 센티넬은 카메라가 아니다(App\DeviceScope). 사건 상세
// 화면의 "이 카메라 구역 설정" 링크가 그 값을 그대로 넘길 수 있는데, 저장되면 어떤
// 카메라와도 매칭되지 않는 구역이 목록에 '활성'으로 남아 아무것도 지키지 않는다 —
// 이 컨트롤러가 device_id 에 대해 막고 있는 것과 같은 종류의 조용한 안전 구멍이다.
if (($b['camera_id'] ?? '') === \App\DeviceScope::CAMERA) {
return Response::json(['error' => 'reserved_camera'], 400);
}
$zoneId = $b['zone_id'] ?? null;
if (is_string($zoneId) && $zoneId !== '') {
$existing = $this->zones->find($zoneId);

View file

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App;
/**
* 장치 단위 이벤트의 camera_id 센티넬.
*
* `device_fault` / `monitoring_degraded` 는 카메라가 아니라 **노드**에 대한 사건인데
* `move_events.camera_id` 는 NOT NULL 이다. 그래서 서버가 이 센티넬을 넣는다.
*
* WHY THIS IS A CLASS AND NOT THREE STRING LITERALS
* 처음에는 값을 DeviceHealthWatch 와 두 개의 JS 파일에 각각 적어 두었다. 그 결과
* 콘솔 스트림에서만 "장치 전체" 로 보이고, 상세 화면·리포트·사고조사 패키지·CSV·
* 그리고 **메일과 웹훅** 에는 `__device__` 가 그대로 나갔다. 통보는 회사 밖으로
* 나가는 유일한 경로다 — 담당자 휴대폰에 `[경고] 장치 이상 — __device__` 가 찍힌다.
* 라벨과 판별을 한곳에 모아, 표시하는 쪽이 늘어나도 같은 실수를 반복하지 않게 한다.
*
* WHY INGEST MUST REJECT IT
* 이 값은 **서버 소유**다. 엣지가 이 camera_id 로 `fall` 을 올리면 콘솔은 그것을
* "장치 전체" 로 이름 붙이고 카메라 목록·구역·통계에서 제외한다 — 즉 진짜 낙상이
* 화면에서 더 찾기 어려워진다. 경계에서 막는다 (App\Ingest\EventValidator).
*/
final class DeviceScope
{
/** 노드 단위 이벤트의 camera_id. 실제 카메라 id 와 겹치지 않도록 밑줄 2개로 감쌌다. */
public const CAMERA = '__device__';
/** 사람에게 보여줄 이름. 화면·메일·리포트·CSV 어디서나 이 함수를 통과시킨다. */
public const LABEL = '장치 전체';
public static function isDevice(mixed $cameraId): bool
{
return is_string($cameraId) && $cameraId === self::CAMERA;
}
/** 표시용 이름. 센티넬이면 한국어 라벨, 아니면 원래 camera_id. */
public static function label(mixed $cameraId): string
{
return self::isDevice($cameraId) ? self::LABEL : (string)($cameraId ?? '');
}
/** 이벤트 배열의 camera_id 를 표시용으로 바꾼 사본. 원본은 건드리지 않는다. */
public static function labelled(array $event): array
{
if (isset($event['camera_id'])) {
$event['camera_id'] = self::label($event['camera_id']);
}
return $event;
}
}

View file

@ -46,6 +46,12 @@ final class EventValidator
// Type guards for required fields
if (isset($p['device_id']) && !is_string($p['device_id'])) $errors[] = 'bad:device_id';
if (isset($p['camera_id']) && !is_string($p['camera_id'])) $errors[] = 'bad:camera_id';
// camera_id 센티넬은 서버 소유다(App\DeviceScope). 엣지가 이 값으로 fall 을 올리면
// 콘솔이 그것을 "장치 전체" 로 이름 붙이고 카메라 목록·구역·통계에서 빼버린다 —
// 진짜 낙상이 화면에서 더 찾기 어려워지므로 경계에서 막는다.
if (isset($p['camera_id']) && $p['camera_id'] === \App\DeviceScope::CAMERA) {
$errors[] = 'reserved:camera_id';
}
if (isset($p['occurred_at']) && !is_numeric($p['occurred_at'])) $errors[] = 'bad:occurred_at';
// Type guards for optional fields

View file

@ -7,6 +7,17 @@ use PDO;
final class PdoEventRepository implements EventRepository
{
/**
* 카메라별 집계에서 장치 단위 이벤트를 빼는 조건.
*
* device_fault / monitoring_degraded 는 카메라가 아니라 노드에 대한 사건인데 camera_id 가
* NOT NULL 이라 센티넬이 들어간다(App\DeviceScope). 그대로 두면 "카메라별 위험 순위"에
* 존재하지 않는 카메라가 오르고, "카메라 N대"가 한 대씩 부풀고, 오탐율 패널에도 나타난다.
* SQL 리터럴에는 PHP 상수를 넣을 수 없어 값이 여기 한 번 더 적히므로,
* PdoEventRepositoryTest 가 이 조각과 DeviceScope::CAMERA 가 일치하는지 검사한다.
*/
private const NOT_DEVICE = "camera_id <> '__device__'";
private const SELECT_COLUMNS =
'event_id, event_uuid, device_id, site, camera_id, event_type, severity, track_id, zone_id,
occurred_at, duration_s, metrics_json, spatial_x, spatial_y, acknowledged_by, acknowledged_at,
@ -188,7 +199,7 @@ final class PdoEventRepository implements EventRepository
$sql = 'SELECT camera_id, event_type, COUNT(*) AS total,
SUM(false_positive_by IS NOT NULL) AS fp
FROM move_events
WHERE occurred_at >= ?';
WHERE occurred_at >= ? AND ' . self::NOT_DEVICE;
$binds = [[sprintf('%.6f', $sinceOccurredAt), PDO::PARAM_STR]];
if ($site !== null) {
$sql .= ' AND site = ?';
@ -230,7 +241,7 @@ final class PdoEventRepository implements EventRepository
$byCamera = $this->pdo->prepare(
"SELECT camera_id, COUNT(*) AS count,
SUM(severity IN ('critical','high')) AS critical_high
FROM move_events WHERE occurred_at >= ?$until$scope
FROM move_events WHERE occurred_at >= ?$until$scope AND " . self::NOT_DEVICE . "
GROUP BY camera_id ORDER BY critical_high DESC, count DESC LIMIT ?"
);
$pos = 1;
@ -344,7 +355,7 @@ final class PdoEventRepository implements EventRepository
COUNT(*) AS count,
SUM(CASE WHEN severity IN ('critical','high') THEN 1 ELSE 0 END) AS critical_high,
SUM(CASE WHEN acknowledged_by IS NULL THEN 1 ELSE 0 END) AS unacked,
COUNT(DISTINCT camera_id) AS cameras
COUNT(DISTINCT CASE WHEN " . self::NOT_DEVICE . " THEN camera_id END) AS cameras
FROM move_events
WHERE occurred_at >= ? AND event_type <> 'ergo_report'";
$binds = [sprintf('%.6f', $sinceOccurredAt)];

View file

@ -102,6 +102,7 @@ render_header('안전 관리');
</section>
</div>
</div>
<script src="<?= view_url('/assets/device-scope.js') ?>"></script>
<script src="<?= view_url('/assets/compliance.js') ?>"></script>
<?php
render_footer();

View file

@ -34,7 +34,7 @@ render_header('이벤트 상세');
</div>
<!-- 사건에서 조치로 바로 이어지게 한다. 조치를 리포트 화면에서만 기록할 수 있으면
"무엇이 계기였나"가 끊기고, 나중에 그 대책이 어느 사고에서 나왔는지 알 수 없다. -->
<div class="detail-card detail-action-card">
<div class="detail-card detail-action-card" id="detail-action-card">
<h2 class="detail-card-title">개선 조치 기록</h2>
<p class="detail-card-hint">이 사건을 계기로 취한 조치를 남기면 전후 감지 밀도를 자동 비교합니다.</p>
<form id="detail-action-form">
@ -48,6 +48,7 @@ render_header('이벤트 상세');
</aside>
</div>
</div>
<script src="<?= view_url('/assets/device-scope.js') ?>"></script>
<script src="<?= view_url('/assets/detail.js') ?>"></script>
<?php
render_footer();

View file

@ -115,6 +115,7 @@ render_header('이벤트');
<div class="list-more"><button type="button" id="load-more" hidden>더 보기</button></div>
</section>
</div>
<script src="<?= view_url('/assets/device-scope.js') ?>"></script>
<script src="<?= view_url('/assets/device-tree.js') ?>"></script>
<script src="<?= view_url('/assets/app.js') ?>"></script>
<?php

View file

@ -14,6 +14,7 @@ render_header('사고조사 패키지');
<p class="package-loading">불러오는 중…</p>
</article>
</div>
<script src="<?= view_url('/assets/device-scope.js') ?>"></script>
<script src="<?= view_url('/assets/package.js') ?>"></script>
<?php
render_footer();

View file

@ -66,6 +66,7 @@ render_header('안전 리포트');
<p class="report-foot"><a id="export-labels" href="#">라벨 데이터셋 내려받기 (JSONL, 관리자)</a>
— 확인/오탐 판정이 붙은 감지 이력. 평가셋·임계 튜닝의 원료입니다.</p>
</div>
<script src="<?= view_url('/assets/device-scope.js') ?>"></script>
<script src="<?= view_url('/assets/report.js') ?>"></script>
<?php
render_footer();

View file

@ -20,6 +20,7 @@ render_header('설치 점검');
문제가 있는 단계만 남기려면 <a href="<?= view_url('/setup') ?>">새로고침</a>하세요.
</p>
</div>
<script src="<?= view_url('/assets/device-scope.js') ?>"></script>
<script src="<?= view_url('/assets/setup.js') ?>"></script>
<?php
render_footer();

View file

@ -88,6 +88,7 @@ render_header('구역 설정');
</section>
</div>
</div>
<script src="<?= view_url('/assets/device-scope.js') ?>"></script>
<script src="<?= view_url('/assets/zone-draw.js') ?>"></script>
<?php
render_footer();

View file

@ -0,0 +1,429 @@
<?php
declare(strict_types=1);
namespace App\Tests;
use App\Alert\DeviceHealthWatch;
use App\Tests\Support\{InMemoryEventRepository, InMemoryDeviceRepository, InMemoryDeviceMetricRepository};
use PHPUnit\Framework\TestCase;
/**
* The two events nobody produced.
*
* These tests pin the behaviour that makes them trustworthy rather than noisy: a fault must be
* filed ONCE per outage (not once per cron tick), a slow node must be degraded for a sustained
* period before it counts, and a node that has never been installed must stay silent.
*/
final class DeviceHealthWatchTest extends TestCase
{
private const NOW = 1_700_000_000;
private function devices(array $rows): InMemoryDeviceRepository
{
return new InMemoryDeviceRepository([], [], [], $rows);
}
private function device(string $id, ?int $heartbeat, ?string $site = 'site-a'): array
{
return ['device_id' => $id, 'name' => $id, 'site' => $site,
'last_fps' => null, 'last_temp' => null, 'last_throttle' => null,
'config_version' => 1, 'reported_config_version' => 1,
'last_heartbeat_at' => $heartbeat];
}
/** @param array<int,array> $points [tsOffsetFromNow => metric fields] */
private function metrics(string $deviceId, array $points): InMemoryDeviceMetricRepository
{
$repo = new InMemoryDeviceMetricRepository();
foreach ($points as $offset => $fields) {
$repo->append($deviceId, $fields, self::NOW - $offset);
}
return $repo;
}
// --- device_fault ---------------------------------------------------------------------
public function testSilentDeviceFilesDeviceFault(): void
{
$events = new InMemoryEventRepository();
$watch = new DeviceHealthWatch(
$events,
$this->devices([$this->device('pi-01', self::NOW - 600)]),
new InMemoryDeviceMetricRepository()
);
$filed = $watch->run(self::NOW);
self::assertCount(1, $filed);
self::assertSame('device_fault', $filed[0]['event_type']);
$row = $events->find($filed[0]['event_id']);
self::assertSame('warning', $row['severity']);
self::assertSame(DeviceHealthWatch::DEVICE_CAMERA, $row['camera_id']);
self::assertSame('site-a', $row['site']);
self::assertSame(600, $row['metrics']['silent_s']);
}
public function testHealthyHeartbeatFilesNothing(): void
{
$events = new InMemoryEventRepository();
$watch = new DeviceHealthWatch(
$events,
$this->devices([$this->device('pi-01', self::NOW - 20)]),
$this->metrics('pi-01', [20 => ['fps' => 12.0, 'cameras_erroring' => 0]])
);
self::assertSame([], $watch->run(self::NOW));
}
public function testOutageFilesOnceAcrossRepeatedRuns(): void
{
// The cron runs every minute. An hour-long outage must leave ONE event, not sixty.
$events = new InMemoryEventRepository();
$devices = $this->devices([$this->device('pi-01', self::NOW - 600)]);
$watch = new DeviceHealthWatch($events, $devices, new InMemoryDeviceMetricRepository());
$first = $watch->run(self::NOW);
$second = $watch->run(self::NOW + 60);
$third = $watch->run(self::NOW + 120);
self::assertCount(1, $first);
self::assertSame([], $second);
self::assertSame([], $third);
}
public function testRecoveredThenFailedAgainIsASecondEvent(): void
{
$events = new InMemoryEventRepository();
$devices = $this->devices([$this->device('pi-01', self::NOW - 600)]);
$watch = new DeviceHealthWatch($events, $devices, new InMemoryDeviceMetricRepository());
self::assertCount(1, $watch->run(self::NOW));
// The node came back (new heartbeat) and then went silent again.
$devices->statusRows = [$this->device('pi-01', self::NOW + 1000)];
self::assertCount(1, $watch->run(self::NOW + 2000));
}
public function testNeverInstalledDeviceIsNotAFault(): void
{
// A provisioned row that has never reported is an uninstalled node. Alarming here would
// make every new site's first day a wall of red.
$events = new InMemoryEventRepository();
$watch = new DeviceHealthWatch(
$events,
$this->devices([$this->device('pi-new', null), $this->device('pi-zero', 0)]),
new InMemoryDeviceMetricRepository()
);
self::assertSame([], $watch->run(self::NOW));
}
public function testSilentDeviceDoesNotAlsoFileDegraded(): void
{
// A node that is gone must not be reported twice for one failure.
$events = new InMemoryEventRepository();
$watch = new DeviceHealthWatch(
$events,
$this->devices([$this->device('pi-01', self::NOW - 600)]),
$this->metrics('pi-01', [900 => ['fps' => 1.0], 700 => ['fps' => 1.0]])
);
$filed = $watch->run(self::NOW);
self::assertCount(1, $filed);
self::assertSame('device_fault', $filed[0]['event_type']);
}
// --- monitoring_degraded --------------------------------------------------------------
public function testSustainedLowFpsFilesDegraded(): void
{
$events = new InMemoryEventRepository();
$watch = new DeviceHealthWatch(
$events,
$this->devices([$this->device('pi-01', self::NOW - 10)]),
$this->metrics('pi-01', [
800 => ['fps' => 12.0], // healthy anchor
600 => ['fps' => 3.0],
300 => ['fps' => 3.0],
10 => ['fps' => 3.0],
])
);
$filed = $watch->run(self::NOW);
self::assertCount(1, $filed);
self::assertSame('monitoring_degraded', $filed[0]['event_type']);
$row = $events->find($filed[0]['event_id']);
self::assertSame(['low_fps'], $row['metrics']['reasons']);
self::assertSame(590, $row['metrics']['degraded_s']);
}
public function testBriefDipDoesNotFileDegraded(): void
{
// One slow heartbeat is a hiccup. Requiring a sustained run is what separates this event
// from noise the operator learns to ignore.
$events = new InMemoryEventRepository();
$watch = new DeviceHealthWatch(
$events,
$this->devices([$this->device('pi-01', self::NOW - 10)]),
$this->metrics('pi-01', [
200 => ['fps' => 12.0],
100 => ['fps' => 12.0],
10 => ['fps' => 3.0],
])
);
self::assertSame([], $watch->run(self::NOW));
}
public function testRecoveredFpsFilesNothingEvenAfterALongBadRun(): void
{
// The latest heartbeat is what decides. A node that already recovered must not alarm.
$events = new InMemoryEventRepository();
$watch = new DeviceHealthWatch(
$events,
$this->devices([$this->device('pi-01', self::NOW - 10)]),
$this->metrics('pi-01', [
900 => ['fps' => 2.0],
600 => ['fps' => 2.0],
10 => ['fps' => 12.0],
])
);
self::assertSame([], $watch->run(self::NOW));
}
public function testErroringCameraIsDegradedEvenAtFullFps(): void
{
$events = new InMemoryEventRepository();
$watch = new DeviceHealthWatch(
$events,
$this->devices([$this->device('pi-01', self::NOW - 10)]),
$this->metrics('pi-01', [
800 => ['fps' => 12.0, 'cameras_total' => 2, 'cameras_erroring' => 0],
600 => ['fps' => 12.0, 'cameras_total' => 2, 'cameras_erroring' => 1],
10 => ['fps' => 12.0, 'cameras_total' => 2, 'cameras_erroring' => 1],
])
);
$filed = $watch->run(self::NOW);
self::assertCount(1, $filed);
$row = $events->find($filed[0]['event_id']);
self::assertSame(['cameras_erroring'], $row['metrics']['reasons']);
self::assertSame(1, $row['metrics']['cameras_erroring']);
}
public function testThermalThrottleCountsAsDegraded(): void
{
$events = new InMemoryEventRepository();
$watch = new DeviceHealthWatch(
$events,
$this->devices([$this->device('pi-01', self::NOW - 10)]),
$this->metrics('pi-01', [
800 => ['fps' => 12.0, 'throttle' => 0],
600 => ['fps' => 12.0, 'throttle' => 1],
10 => ['fps' => 12.0, 'throttle' => 1],
])
);
$filed = $watch->run(self::NOW);
self::assertCount(1, $filed);
self::assertSame(['thermal_throttle'],
$events->find($filed[0]['event_id'])['metrics']['reasons']);
}
public function testOngoingDegradationFilesOncePerRunNotPerTick(): void
{
$events = new InMemoryEventRepository();
$watch = new DeviceHealthWatch(
$events,
$this->devices([$this->device('pi-01', self::NOW - 10)]),
$this->metrics('pi-01', [
800 => ['fps' => 12.0],
600 => ['fps' => 3.0],
10 => ['fps' => 3.0],
])
);
self::assertCount(1, $watch->run(self::NOW));
self::assertSame([], $watch->run(self::NOW + 60));
self::assertSame([], $watch->run(self::NOW + 120));
}
public function testMissingFpsIsNotTreatedAsZero(): void
{
// A node that cannot read a metric reports NULL. Coercing that to 0 would invent an
// outage out of a missing reading.
$events = new InMemoryEventRepository();
$watch = new DeviceHealthWatch(
$events,
$this->devices([$this->device('pi-01', self::NOW - 10)]),
$this->metrics('pi-01', [
600 => ['fps' => null, 'cameras_erroring' => 0],
10 => ['fps' => null, 'cameras_erroring' => 0],
])
);
self::assertSame([], $watch->run(self::NOW));
}
public function testNoHeartbeatSeriesFilesNothingWhileDeviceIsFresh(): void
{
// Fresh heartbeat but no metric rows yet (just installed): nothing to judge.
$events = new InMemoryEventRepository();
$watch = new DeviceHealthWatch(
$events,
$this->devices([$this->device('pi-01', self::NOW - 5)]),
new InMemoryDeviceMetricRepository()
);
self::assertSame([], $watch->run(self::NOW));
}
public function testEachDeviceIsJudgedIndependently(): void
{
$events = new InMemoryEventRepository();
$metrics = new InMemoryDeviceMetricRepository();
$metrics->append('pi-ok', ['fps' => 12.0], self::NOW - 10);
$metrics->append('pi-slow', ['fps' => 12.0], self::NOW - 800);
$metrics->append('pi-slow', ['fps' => 2.0], self::NOW - 600);
$metrics->append('pi-slow', ['fps' => 2.0], self::NOW - 10);
$watch = new DeviceHealthWatch($events, $this->devices([
$this->device('pi-ok', self::NOW - 10),
$this->device('pi-slow', self::NOW - 10),
$this->device('pi-dead', self::NOW - 3600),
]), $metrics);
$filed = $watch->run(self::NOW);
$byDevice = [];
foreach ($filed as $f) $byDevice[$f['device_id']] = $f['event_type'];
self::assertSame(['pi-slow' => 'monitoring_degraded', 'pi-dead' => 'device_fault'], $byDevice);
}
// --- 간헐 고장(플래핑) -------------------------------------------------------------
//
// 아래 네 건이 이 클래스에서 가장 중요한 테스트다. edge/daemon.py 는 성공 프레임 하나만
// 들어와도 streak 를 지워 cameras_erroring 를 0 으로 되돌린다 — 리본 접촉 불량이나 USB
// 브라운아웃이면 30초 하트비트마다 1↔0 이 오간다. 정상 표본 하나로 저하 구간을 끊는
// 구현은 이 입력에서 (a) 두 시간을 고장 나 있어도 아무것도 못 올리고 (b) 동시에 같은
// 고장을 수십 건으로 쏟아낸다. 기존 테스트가 전부 단조 시퀀스라 그 버그가 CI를 통과했다.
/** @param array<int,bool> $pattern [초 전 => 저하 여부] */
private function flapping(string $deviceId, array $pattern): InMemoryDeviceMetricRepository
{
$repo = new InMemoryDeviceMetricRepository();
foreach ($pattern as $offset => $bad) {
$repo->append($deviceId, ['fps' => $bad ? 2.0 : 12.0], self::NOW - $offset);
}
return $repo;
}
public function testFlappingCameraStillFilesDegraded(): void
{
// 30초 간격 40분: 대체로 저하인데 중간중간 정상 프레임이 한 번씩 낀다.
$pattern = [];
for ($off = 2400; $off >= 0; $off -= 30) {
$pattern[$off] = !(($off / 30) % 4 === 0); // 4비트에 한 번 정상
}
$pattern[0] = true; // 지금은 저하
$events = new InMemoryEventRepository();
$watch = new DeviceHealthWatch(
$events,
$this->devices([$this->device('pi-01', self::NOW - 10)]),
$this->flapping('pi-01', $pattern)
);
$filed = $watch->run(self::NOW);
self::assertCount(1, $filed, '깜빡이는 정상 표본에 저하 구간이 끊겨 아무것도 올라오지 않았다');
self::assertGreaterThanOrEqual(
300, $events->find($filed[0]['event_id'])['metrics']['degraded_s']);
}
public function testFlappingDoesNotStorm(): void
{
// 6시간 내내 간헐 고장이 이어지고 하트비트도 계속 들어오는 상황을 그대로 재현한다.
// 고정 표본으로 now 만 밀면 낡은 행으로 판정하게 되어 실제와 다른 것을 재는 셈이다.
$events = new InMemoryEventRepository();
$devices = $this->devices([$this->device('pi-01', self::NOW)]);
$repo = new InMemoryDeviceMetricRepository();
$total = 0;
for ($t = 0; $t <= 6 * 3600; $t += 30) {
$now = self::NOW + $t;
// 4비트에 한 번 정상이 끼는 저하 — edge/daemon.py 의 streak 리셋과 같은 양상
$repo->append('pi-01', ['fps' => (($t / 30) % 4 === 0) ? 12.0 : 2.0], $now);
$devices->statusRows = [$this->device('pi-01', $now)];
if ($t % 60 === 0) { // 크론은 1분 주기
$total += count((new DeviceHealthWatch($events, $devices, $repo))->run($now));
}
}
self::assertGreaterThanOrEqual(1, $total, '6시간 내내 고장인데 한 건도 올라오지 않았다');
// 재고지 주기가 1시간이므로 6~7건이 상한이다. 수정 전 구현은 같은 입력에서 수십 건을 냈다.
self::assertLessThanOrEqual(7, $total, "6시간 동안 $total 건 — 폭주");
}
public function testSustainedRecoveryStartsANewRun(): void
{
// 긴 정상 구간(유예 초과)은 진짜 회복이다 — 그 이전의 저하는 현재 구간에 포함되지 않는다.
$events = new InMemoryEventRepository();
$watch = new DeviceHealthWatch(
$events,
$this->devices([$this->device('pi-01', self::NOW - 10)]),
$this->metrics('pi-01', [
3000 => ['fps' => 2.0], // 옛 저하
2700 => ['fps' => 2.0],
2400 => ['fps' => 12.0], // 정상이 600초 지속 = 회복
2100 => ['fps' => 12.0],
1800 => ['fps' => 12.0],
120 => ['fps' => 2.0], // 새 저하 — 아직 120초뿐
0 => ['fps' => 2.0],
])
);
// 새 구간은 sustain(300)에 못 미치므로 아무것도 올라오지 않아야 한다.
self::assertSame([], $watch->run(self::NOW));
}
public function testDeadNodeIsRaisedAgainInTheNextWindow(): void
{
// 한 달째 죽은 노드가 첫 시각 이후 영원히 조용해지면 안 된다.
$events = new InMemoryEventRepository();
$devices = $this->devices([$this->device('pi-01', self::NOW - 600)]);
$watch = new DeviceHealthWatch($events, $devices, new InMemoryDeviceMetricRepository());
self::assertCount(1, $watch->run(self::NOW));
self::assertSame([], $watch->run(self::NOW + 600)); // 같은 주기 — 조용
self::assertCount(1, $watch->run(self::NOW + 3600)); // 다음 주기 — 한 번 더
}
public function testFutureTimestampedMetricIsIgnored(): void
{
// NTP 보정이나 수동 백필로 들어온 미래 표본 하나가 "최신"으로 고정되면, 실제로 멈춘
// 노드가 정상으로 보이거나 없던 장애가 sustain 을 건너뛴다.
$events = new InMemoryEventRepository();
$repo = new InMemoryDeviceMetricRepository();
$repo->append('pi-01', ['fps' => 12.0], self::NOW + 86400); // 미래·정상
foreach ([900, 600, 300, 0] as $off) {
$repo->append('pi-01', ['fps' => 1.0], self::NOW - $off); // 실제로는 계속 저하
}
$watch = new DeviceHealthWatch(
$events, $this->devices([$this->device('pi-01', self::NOW - 10)]), $repo);
$filed = $watch->run(self::NOW);
self::assertCount(1, $filed, '미래 표본이 최신으로 잡혀 실제 저하를 가렸다');
self::assertSame('monitoring_degraded', $filed[0]['event_type']);
}
public function testUuidIsStableAndDistinctPerKindAndDevice(): void
{
$a = DeviceHealthWatch::uuidFor('device_fault', 'pi-01', 100);
self::assertSame($a, DeviceHealthWatch::uuidFor('device_fault', 'pi-01', 100));
self::assertSame(32, strlen($a));
self::assertNotSame($a, DeviceHealthWatch::uuidFor('monitoring_degraded', 'pi-01', 100));
self::assertNotSame($a, DeviceHealthWatch::uuidFor('device_fault', 'pi-02', 100));
self::assertNotSame($a, DeviceHealthWatch::uuidFor('device_fault', 'pi-01', 101));
}
}

View file

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Tests;
use App\DeviceScope;
use App\Ingest\EventValidator;
use PHPUnit\Framework\TestCase;
/**
* 센티넬 값이 흩어지는 것을 막는 테스트.
*
* 이 프로젝트에서 실제로 난 사고: 값을 DeviceHealthWatch 와 app.js 두 곳에만 적어 두었더니
* 콘솔 스트림에서만 "장치 전체" 로 보이고 상세·리포트·사고조사 패키지·CSV, 그리고 **메일과
* 웹훅** 에는 `__device__` 가 그대로 나갔다. 값은 PHP 상수 하나, JS 파일 하나, 그리고 SQL
* 리터럴 하나(상수를 넣을 수 없으므로) — 이 셋이 어긋나면 여기서 잡는다.
*/
final class DeviceScopeTest extends TestCase
{
public function testLabelIsUsedForTheSentinelAndPassesEverythingElseThrough(): void
{
self::assertSame('장치 전체', DeviceScope::label(DeviceScope::CAMERA));
self::assertSame('cam-01', DeviceScope::label('cam-01'));
self::assertSame('', DeviceScope::label(null));
self::assertTrue(DeviceScope::isDevice(DeviceScope::CAMERA));
self::assertFalse(DeviceScope::isDevice('cam-01'));
self::assertFalse(DeviceScope::isDevice(null));
}
public function testFrontEndSharedScriptCarriesTheSameValue(): void
{
$js = file_get_contents(__DIR__ . '/../public/assets/device-scope.js');
self::assertNotFalse($js);
self::assertStringContainsString("var CAMERA = '" . DeviceScope::CAMERA . "';", $js);
self::assertStringContainsString("var LABEL = '" . DeviceScope::LABEL . "';", $js);
}
public function testSqlExclusionFragmentCarriesTheSameValue(): void
{
// PdoEventRepository 는 카메라별 집계에서 센티넬을 빼는데, SQL 리터럴에는 PHP 상수를
// 넣을 수 없어 값이 한 번 더 적힌다. 그 사본이 진짜와 같은지 본다.
$php = file_get_contents(__DIR__ . '/../src/Repository/Pdo/PdoEventRepository.php');
self::assertNotFalse($php);
self::assertStringContainsString(
'private const NOT_DEVICE = "camera_id <> ' . "'" . DeviceScope::CAMERA . "'" . '";', $php);
}
public function testIngestRejectsTheReservedCameraId(): void
{
// 센티넬은 서버 소유다. 엣지가 이 값으로 낙상을 올리면 콘솔이 그것을 "장치 전체" 로
// 이름 붙이고 카메라 목록·구역·통계에서 빼버려, 진짜 사고가 화면에서 더 안 보이게 된다.
$res = (new EventValidator())->validate([
'device_id' => 'pi-01',
'camera_id' => DeviceScope::CAMERA,
'event_type' => 'fall',
'severity' => 'high',
'occurred_at' => 1_700_000_000,
]);
self::assertFalse($res['ok']);
self::assertContains('reserved:camera_id', $res['errors']);
}
public function testIngestStillAcceptsAnOrdinaryCameraId(): void
{
$res = (new EventValidator())->validate([
'device_id' => 'pi-01',
'camera_id' => 'cam-01',
'event_type' => 'fall',
'severity' => 'high',
'occurred_at' => 1_700_000_000,
]);
self::assertTrue($res['ok'], implode(',', $res['errors'] ?? []));
}
}

View file

@ -9,10 +9,15 @@
*
* 정확한 크론 표기는 server/docs/server-deploy.md §11 을 보십시오.
*
* 하는 일 3가지:
* 1. 미확인 채로 임계 시간을 넘긴 위급/높음 이벤트에 `unacked_escalation` 이벤트를 발행한다.
* 2. 최근 창의 이벤트를 채널 규칙에 따라 통보한다 (즉시 채널 + 지연 에스컬레이션 채널).
* 3. 실패한 전달을 재시도한다.
* 하는 일 4가지:
* 1. 하트비트가 끊긴 노드에 `device_fault`, 성능이 지속 저하된 노드에 `monitoring_degraded`
* 이벤트를 발행한다. 엣지는 이걸 스스로 못 한다 — 죽은 노드는 자기가 죽었다고 신고할 수 없다.
* 2. 미확인 채로 임계 시간을 넘긴 위급/높음 이벤트에 `unacked_escalation` 이벤트를 발행한다.
* 3. 최근 창의 이벤트를 채널 규칙에 따라 통보한다 (즉시 채널 + 지연 에스컬레이션 채널).
* 4. 실패한 전달을 재시도한다.
*
* 1번이 2·3번보다 먼저 오는 것은 의도적이다: 같은 실행 안에서 발행된 장치 이벤트가 곧바로
* 통보 대상이 되어야 한다. 뒤에 두면 노드가 죽은 사실이 다음 크론까지 1분 늦게 전달된다.
*
* 이 파일이 없으면 이 제품은 "사고를 기록하는 시스템"이지 "사고에 대응하는 시스템"이 아니다.
* 새벽에 넘어진 작업자를 아무도 모른 채 아침을 맞는 상황이 정확히 이 구멍이었다.
@ -25,8 +30,9 @@ declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
use App\Alert\{AlertDispatcher, Channels, EscalationWatch};
use App\Repository\Pdo\{PdoEventRepository, PdoAlertRepository, PdoSuppressionRepository};
use App\Alert\{AlertDispatcher, Channels, DeviceHealthWatch, EscalationWatch};
use App\Repository\Pdo\{PdoEventRepository, PdoAlertRepository, PdoSuppressionRepository,
PdoDeviceRepository, PdoDeviceMetricRepository};
[$cfg, $pdo] = tool_boot();
$args = tool_args($argv);
@ -51,14 +57,52 @@ if ($baseUrl === '') {
}
$channels = new Channels($baseUrl, 8, $cfg->timezone());
// --- 1. 에스컬레이션 발행 -------------------------------------------------------------
// --- 1. 장치 건전성 발행 --------------------------------------------------------------
// 이 두 유형은 서버만 발행할 수 있다. 노드가 응답하지 않는다는 사실은 응답하지 않는 노드가
// 아니라 그것을 기다리는 쪽에서만 관측된다.
//
// 이 단계는 반드시 격리한다. 장치 건전성은 move_devices·move_device_metrics 를 읽는데, 통보
// 경로는 그 두 표를 건드리지 않는다 — 즉 여기서 나는 예외(마이그레이션 미적용, 공유호스팅
// 커넥션 한도, lock timeout)는 원래대로면 정상 발송됐을 실행을 통째로 죽인다. 그러면 새벽 3시
// 넘어짐이 통보되지 않고, 원인이 지속형이라 매분 반복되면서 콘솔은 멀쩡해 보인다.
// 장치 건전성은 부가 기능이고 통보는 이 제품의 존재 이유다 — 실패하면 로그만 남기고 계속 간다.
$healthFiled = [];
if (!$dryRun) {
try {
$health = new DeviceHealthWatch(
$events, new PdoDeviceRepository($pdo), new PdoDeviceMetricRepository($pdo));
$healthFiled = $health->run($now, [
'stale_after_s' => $cfg->deviceStaleSeconds(),
'sustain_s' => $cfg->degradedSustainSeconds(),
'fps_ratio' => $cfg->degradedFpsRatio(),
'target_fps' => $cfg->targetFps(),
]);
} catch (Throwable $e) {
fwrite(STDERR, date('c') . ' device-health error (통보는 계속 진행): '
. $e->getMessage() . PHP_EOL);
}
}
foreach ($healthFiled as $f) {
fwrite(STDOUT, sprintf('%s %s: %s (event=%d)' . PHP_EOL,
date('c'), $f['event_type'], $f['device_id'], $f['event_id']));
}
// --- 2. 에스컬레이션 발행 -------------------------------------------------------------
$watch = new EscalationWatch($events);
$escalated = $dryRun ? [] : $watch->run($now, $escalateAfter, $window, $limit);
$escalated = [];
if (!$dryRun) {
try {
$escalated = $watch->run($now, $escalateAfter, $window, $limit);
} catch (Throwable $e) {
fwrite(STDERR, date('c') . ' escalation error (통보는 계속 진행): '
. $e->getMessage() . PHP_EOL);
}
}
if ($escalated !== []) {
fwrite(STDOUT, date('c') . " escalated: " . implode(',', $escalated) . "\n");
}
// --- 2. 통보 --------------------------------------------------------------------------
// --- 3. 통보 --------------------------------------------------------------------------
$dispatcher = new AlertDispatcher($events, $alerts, $suppressions);
// local 채널은 디스패처가 계획 단계에서 제외한다 (엣지가 자체 GPIO 로 즉시 울린다 —
@ -90,11 +134,11 @@ try {
$stats = $dispatcher->run($now, $window, $limit, $send);
}
} catch (Throwable $e) {
fwrite(STDERR, date('c') . ' dispatcher error: ' . $e->getMessage() . "\n");
exit(1);
fwrite(STDERR, date('c') . ' dispatcher error (재시도는 계속 진행): ' . $e->getMessage() . PHP_EOL);
// exit 하지 않는다: 여기 도달하는 예외는 저장소·DB 장애이고, 4단계 재시도 패스가 바로 그 전이 장애에서 회복하려고 존재한다.
}
// --- 3. 실패 재시도 -------------------------------------------------------------------
// --- 4. 실패 재시도 -------------------------------------------------------------------
$retried = 0;
if (!$dryRun) {
foreach ($alerts->retryable($maxAttempts, 100) as $row) {