Compare commits
13 commits
b654468fbb
...
cfdbcef640
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfdbcef640 | ||
|
|
43a3d069a3 | ||
|
|
bebf426c2e | ||
|
|
2345de6919 | ||
|
|
dea12a2931 | ||
|
|
fdf3d8b72a | ||
|
|
9ee3dab378 | ||
|
|
643b428d0a | ||
|
|
9c0cfd2fea | ||
|
|
46d45c9a6e | ||
|
|
bb5710ac52 | ||
|
|
107f7d9f8a | ||
|
|
2fc8178a5c |
11
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
51
CHANGELOG.md
|
|
@ -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 은 **"감지에 대응하는 시스템"** 입니다.
|
||||
|
|
|
|||
2
VERSION
|
|
@ -1 +1 @@
|
|||
1.0.0
|
||||
1.1.0
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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="이전 슬라이드">↑</button>'
|
||||
'<button class="nav-next" type="button" aria-label="다음 슬라이드">↓</button>'
|
||||
'</div>'
|
||||
'<p class="help">← → 또는 버튼으로 이동 · F11 전체화면</p>'
|
||||
f"<script>{JS}</script></body></html>")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--out", default=str(HERE / "경영진-보고.html"))
|
||||
args = ap.parse_args()
|
||||
|
||||
vf = REPO / "VERSION"
|
||||
if not vf.is_file():
|
||||
raise SystemExit("ERROR: VERSION 파일이 없습니다. 버전 없는 보고 자료는 만들지 않습니다.")
|
||||
version = vf.read_text(encoding="utf-8").strip()
|
||||
|
||||
doc = json.loads((HERE / "deck.json").read_text(encoding="utf-8"))
|
||||
out = pathlib.Path(args.out)
|
||||
out.write_text(render(doc, version), encoding="utf-8")
|
||||
|
||||
wanted, have = [], []
|
||||
for sl in doc["slides"]:
|
||||
for c in ([sl.get("clip")] if sl.get("clip") else []) + \
|
||||
[sl[s].get("clip") for s in ("left", "right") if isinstance(sl.get(s), dict)]:
|
||||
if c and c not in wanted:
|
||||
wanted.append(c)
|
||||
if has_clip(c):
|
||||
have.append(c)
|
||||
missing = [c for c in wanted if c not in have]
|
||||
|
||||
print(f"{out} ({out.stat().st_size/1024:.0f} KB, 슬라이드 {len(doc['slides'])}장)", file=sys.stderr)
|
||||
if missing:
|
||||
print(f"클립 대기 {len(missing)}/{len(wanted)} — 해당 자리는 스틸로 대체됨: "
|
||||
+ ", ".join(missing), file=sys.stderr)
|
||||
else:
|
||||
print("클립 전량 배치됨. HTML 과 clips/ 폴더를 같이 옮기십시오.", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
0
docs/deck/clips/.gitkeep
Normal file
203
docs/deck/deck.json
Normal 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
|
|
@ -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()
|
||||
|
|
@ -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 잔여 (실장비 커미셔닝에서만 검증 가능)
|
||||
|
||||
코드·문서로 닫을 수 없는 항목. 납품 판정을 막지 않되, 현장 설치 시 반드시 수행한다.
|
||||
|
|
|
|||
BIN
docs/images/01-현장-설치.jpg
Normal file
|
After Width: | Height: | Size: 290 KiB |
BIN
docs/images/02-경광등.jpg
Normal file
|
After Width: | Height: | Size: 274 KiB |
BIN
docs/images/03-엣지노드.jpg
Normal file
|
After Width: | Height: | Size: 314 KiB |
BIN
docs/images/04-위험구역.jpg
Normal file
|
After Width: | Height: | Size: 390 KiB |
BIN
docs/images/05-사슬.png
Normal file
|
After Width: | Height: | Size: 370 KiB |
BIN
docs/images/06-구성도.png
Normal file
|
After Width: | Height: | Size: 579 KiB |
BIN
docs/images/07-대응시간.png
Normal file
|
After Width: | Height: | Size: 224 KiB |
BIN
docs/images/08-넘어짐-감지-시퀀스.jpg
Normal file
|
After Width: | Height: | Size: 226 KiB |
BIN
docs/images/09-앉기-vs-넘어짐.jpg
Normal file
|
After Width: | Height: | Size: 252 KiB |
BIN
docs/images/10-위험구역-판정.jpg
Normal file
|
After Width: | Height: | Size: 250 KiB |
BIN
docs/images/11-2인1조.jpg
Normal file
|
After Width: | Height: | Size: 248 KiB |
19
docs/images/README.md
Normal 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/` 에 있으므로 여기 넣지 않습니다.
|
||||
425
docs/소개서-이미지-브리프.md
Normal 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·뉴럴넷 추상 이미지 | 아무 정보도 전달하지 않음. 이 소개서의 문제는 "구체성 부족"이므로 정반대 방향 |
|
||||
157
docs/소개서-이미지-브리프2.md
Normal 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 이상.
|
||||
|
||||
---
|
||||
60
docs/소개서-이미지.json
Normal 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
|
|
@ -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초입니다 |
|
||||
190
docs/영상보관-노조대응.md
Normal 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가지는 전부
|
||||
> 위험한 상태에 관한 것이고, 자리를 비웠는지 일을 얼마나 했는지는 **만들 수 있는
|
||||
> 기능 자체가 없습니다.** 설정을 바꾸거나 기록을 열어 본 사람은 지울 수 없는 형태로
|
||||
> 남고, 그 기록은 노조도 확인할 수 있게 하겠습니다.
|
||||
708
docs/프로젝트-소개서.html
Normal file
343
docs/프로젝트-소개서.md
Normal 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
|
After Width: | Height: | Size: 4.5 MiB |
BIN
image/02-경광등.png
Normal file
|
After Width: | Height: | Size: 4.3 MiB |
BIN
image/03-엣지노드.png
Normal file
|
After Width: | Height: | Size: 4.3 MiB |
BIN
image/04-위험구역.png
Normal file
|
After Width: | Height: | Size: 4.9 MiB |
BIN
image/05-사슬.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
image/06-구성도.png
Normal file
|
After Width: | Height: | Size: 2 MiB |
BIN
image/07-대응시간.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
image/08-넘어짐-감지-시퀀스.png
Normal file
|
After Width: | Height: | Size: 3.5 MiB |
BIN
image/09-앉기-vs-넘어짐.png
Normal file
|
After Width: | Height: | Size: 3.5 MiB |
BIN
image/10-위험구역-판정.png
Normal file
|
After Width: | Height: | Size: 3.7 MiB |
BIN
image/11-2인1조.png
Normal file
|
After Width: | Height: | Size: 3.3 MiB |
BIN
image/v01-넘어짐-경광등.mp4
Normal file
BIN
image/v02-앉기-정상.mp4
Normal 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 로 적으면 응급 대응에서 그대로 사고가 된다.
|
||||
|
|
|
|||
|
|
@ -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 로 접속했을 때
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// 시각을 쓰지만 알림은 서버가 만들기 때문에, 이 값이 틀리면 화면과 메일의 시각이
|
||||
// 서로 다르게 나옵니다. 한국 현장은 그대로 두세요.
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
36
server/public/assets/device-scope.js
Normal 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);
|
||||
|
|
@ -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) + '초');
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
248
server/src/Alert/DeviceHealthWatch.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
50
server/src/DeviceScope.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)];
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
429
server/tests/DeviceHealthWatchTest.php
Normal 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));
|
||||
}
|
||||
}
|
||||
73
server/tests/DeviceScopeTest.php
Normal 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'] ?? []));
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||