Compare commits
3 commits
5db0a4a403
...
faa893199e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
faa893199e | ||
|
|
6b6f29c447 | ||
|
|
34506e150d |
15 changed files with 1213 additions and 22 deletions
10
.gitignore
vendored
10
.gitignore
vendored
|
|
@ -11,7 +11,11 @@ dist/
|
||||||
|
|
||||||
# Edge runtime artifacts
|
# Edge runtime artifacts
|
||||||
*.db
|
*.db
|
||||||
|
# 모델 가중치는 커밋하지 않는다. 둘 다 고정된 fetch 스크립트로 받는다
|
||||||
|
# (edge/tools/fetch_hailo_hef.py · fetch_movenet.py) — sha256 가 저장소보다 확실한 보증이고,
|
||||||
|
# 9~10MB 바이너리를 히스토리에 쌓을 이유가 없다.
|
||||||
*.hef
|
*.hef
|
||||||
|
*.onnx
|
||||||
# Root-anchored on purpose. A bare `snapshots/` matches at EVERY depth, which swallowed
|
# Root-anchored on purpose. A bare `snapshots/` matches at EVERY depth, which swallowed
|
||||||
# cloud/storage/snapshots/ — and since git cannot re-include a file whose parent directory
|
# cloud/storage/snapshots/ — and since git cannot re-include a file whose parent directory
|
||||||
# is excluded, it silently killed cloud/.gitignore's `!/storage/snapshots/.gitkeep`. The
|
# is excluded, it silently killed cloud/.gitignore's `!/storage/snapshots/.gitkeep`. The
|
||||||
|
|
@ -49,3 +53,9 @@ docs/deck/clips/*
|
||||||
# 프로비저닝 자격증명 — 납품 폴더(dist/) 밖에 둔다.
|
# 프로비저닝 자격증명 — 납품 폴더(dist/) 밖에 둔다.
|
||||||
# dist/ 안에 있으면 폴더째 압축할 때 같이 나간다.
|
# dist/ 안에 있으면 폴더째 압축할 때 같이 나간다.
|
||||||
/secrets/
|
/secrets/
|
||||||
|
|
||||||
|
# archify 생성물. 소스는 docs/*.archify.json 이고 `archify deliver` 로 언제든 다시 만든다.
|
||||||
|
# 사용설명서와 같은 이유로 커밋하지 않는다 — 소스가 바뀐 뒤에도 낡은 HTML 이 최신처럼 보인다.
|
||||||
|
# visual-check 는 검증 증거(PNG·리포트)라 저장소가 아니라 그때의 판정 기록으로만 쓴다.
|
||||||
|
docs/move-detect-*.html
|
||||||
|
docs/move-detect-*.visual-check.*
|
||||||
|
|
|
||||||
32
CHANGELOG.md
32
CHANGELOG.md
|
|
@ -6,6 +6,38 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 1.2.1 — 2026-09-21 · 납품 정비 (기능 변경 없음)
|
||||||
|
|
||||||
|
감지 동작은 1.2.0 과 같습니다. 납품·운영에서 손으로 하던 일 세 가지를 닫았습니다.
|
||||||
|
|
||||||
|
### 추가 — 자격증명 회전 도구
|
||||||
|
|
||||||
|
`tools/rotate_credentials.php`. 장치 HMAC 키와 관리자 비밀번호 교체를 명령 하나로 만듭니다.
|
||||||
|
DB 에 접속하지 않고 phpMyAdmin 에 붙여넣을 SQL 만 출력하므로, 접속 정보 없이 어느 머신에서나
|
||||||
|
실행할 수 있습니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
php tools/rotate_credentials.php --device=pi-01 # 장치 HMAC 키
|
||||||
|
php tools/rotate_credentials.php --admin=admin # 대시보드 비밀번호
|
||||||
|
```
|
||||||
|
|
||||||
|
장치 키는 **서버와 Pi 를 같이** 바꿔야 합니다. 한쪽만 바꾸면 그 장치의 이벤트가 전부 거부되고,
|
||||||
|
화면에는 `device_fault` 하나만 뜹니다. 출력에 양쪽 절차가 함께 나옵니다.
|
||||||
|
비밀번호는 STDIN 으로만 받습니다 — 명령행에 적으면 `ps` 와 셸 히스토리에 남기 때문입니다.
|
||||||
|
|
||||||
|
### 복구 — MoveNet CPU 폴백
|
||||||
|
|
||||||
|
Hailo 없이 돌리는 CPU 폴백 모델의 배포 원본이 사라져 있어(원 저장소가 모델을 외부 저장소로
|
||||||
|
옮김) 번들에 넣을 수 없었습니다. 유효한 출처를 찾아 sha256 으로 고정했습니다. Hailo 납품에는
|
||||||
|
영향이 없습니다(폴백은 선택).
|
||||||
|
|
||||||
|
### Pi 번들
|
||||||
|
|
||||||
|
1.2.0 에서 `edge/` 가 바뀌었는데 번들이 재빌드되지 않은 상태였습니다. 재빌드했습니다 —
|
||||||
|
`move-detect-pi-1.2.1-<날짜>-aarch64-cp311-hailo8.tar.gz`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 1.2.0 — 2026-09-05 · 추락 · 동시 다발 쓰러짐 · 구역 프리셋
|
## 1.2.0 — 2026-09-05 · 추락 · 동시 다발 쓰러짐 · 구역 프리셋
|
||||||
|
|
||||||
AI CCTV 의 목적을 원론부터 다시 점검한 결과입니다. 셋 다 **새 추론 모델 없이** 지금 있는
|
AI CCTV 의 목적을 원론부터 다시 점검한 결과입니다. 셋 다 **새 추론 모델 없이** 지금 있는
|
||||||
|
|
|
||||||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
1.2.0
|
1.2.1
|
||||||
|
|
|
||||||
|
|
@ -168,3 +168,29 @@
|
||||||
- 검증: pytest 322 passed(5 skip: 환경·Pi번들 미빌드) · phpunit 263 passed
|
- 검증: pytest 322 passed(5 skip: 환경·Pi번들 미빌드) · phpunit 263 passed
|
||||||
|
|
||||||
- 2026-09-05 — 1.2.0 4차 재감사: 확정 23건 전량 해소, 반증 5건 기록. CRITICAL 4건은 전부 "구현·단위테스트는 초록인데 납품 경로에서 무동작"이었다 — 추락 승격이 기본 설정에서 미발화(테스트가 prone_confirm_s=0.5 사용), max_workers:0·elevated 가 상류 허용 목록 두 곳에서 차단, 동시 다발이 트래커 재획득으로 한 사람을 두 명으로 셈(구조 지연). 재발 방지 테스트 추가: BUILD-INFO↔HEAD, README↔마이그레이션, 시더 심각도↔엣지, 프리셋 왕복. 문서 "17종"→실제 20종 정정. 산출물 `move-detect-cafe24-1.2.0-20260905.zip`(BUILD-INFO=93f896a). 검증 pytest 338 / phpunit 269. `93f896a`
|
- 2026-09-05 — 1.2.0 4차 재감사: 확정 23건 전량 해소, 반증 5건 기록. CRITICAL 4건은 전부 "구현·단위테스트는 초록인데 납품 경로에서 무동작"이었다 — 추락 승격이 기본 설정에서 미발화(테스트가 prone_confirm_s=0.5 사용), max_workers:0·elevated 가 상류 허용 목록 두 곳에서 차단, 동시 다발이 트래커 재획득으로 한 사람을 두 명으로 셈(구조 지연). 재발 방지 테스트 추가: BUILD-INFO↔HEAD, README↔마이그레이션, 시더 심각도↔엣지, 프리셋 왕복. 문서 "17종"→실제 20종 정정. 산출물 `move-detect-cafe24-1.2.0-20260905.zip`(BUILD-INFO=93f896a). 검증 pytest 338 / phpunit 269. `93f896a`
|
||||||
|
|
||||||
|
- 2026-09-18 — 설계 도해 3종 작성(archify, showcase 9/9 · 오류 0 · 경고 0, 1440~2048 4해상도 양 테마 브라우저 검증):
|
||||||
|
시스템 구성 · 낙상 시퀀스 · 낙상 상태기계. 시퀀스를 그리다 확인한 사실 — **경광등(3초)과 메일(최대 63초)은
|
||||||
|
같은 "통보"가 아니다.** 상태기계 쪽은 확정 창 3초와 추락 연결 창 3초가 겹쳐 있음을 한 장으로 보여준다
|
||||||
|
(4차 감사의 CRITICAL, 시간을 그렸다면 코드보다 먼저 보였을 충돌). HTML 은 gitignore, 소스 JSON 만 커밋.
|
||||||
|
|
||||||
|
## 2026-09-21 — 납품 정비 (잔여 3건 해소)
|
||||||
|
|
||||||
|
- **Pi 번들 재빌드** `move-detect-pi-1.2.0-20260921-aarch64-cp311-hailo8.tar.gz` (82MB).
|
||||||
|
arm64 빌드 머신이 필요하다고 봤던 것은 과한 가정이었다 — `--cross` 로 pip 이 태그를 보고
|
||||||
|
aarch64/cp311 휠을 받으므로 네트워크만 있으면 된다. 휠 11개 전부 aarch64. 이걸로
|
||||||
|
"Pi 번들 미빌드" 로 스킵되던 테스트가 실제로 돌아 스킵이 5 → 2로 줄었다.
|
||||||
|
- **MoveNet CPU 폴백 복구** — P3-3 의 잔여였던 죽은 원본을 유효한 tf2onnx 변환본으로 교체·고정
|
||||||
|
(sha256 + size). 고정 **전에** 어댑터 계약과 대조했다: 입력 `[1,192,192,3] int32` NHWC,
|
||||||
|
출력 `[1,1,17,3]`. 레이아웃이 다른 변환본은 로드는 되고 좌표만 틀리므로 그 계약을 테스트로
|
||||||
|
박았다. 실사진 2장으로 코<어깨<엉덩이<발목 순서까지 확인. `edge/tools/fetch_movenet.py`
|
||||||
|
- **자격증명 회전 도구** `server/tools/rotate_credentials.php` — 손으로 적던 SQL 절차를 명령
|
||||||
|
하나로. DB 에 접속하지 않고 붙여넣을 SQL 만 낸다(운영자의 창구가 phpMyAdmin 이므로).
|
||||||
|
비밀번호는 STDIN 으로만 받는다 — 명령행은 `ps` 와 셸 히스토리에 남는다. 테스트가 잠그는
|
||||||
|
것은 "서버 SQL 의 키와 Pi 에 넣으라는 키가 같은가"와 "앱이 그 해시를 검증하는가" 두 가지다.
|
||||||
|
**교체 실행 자체는 운영 계정 조작이라 사용자 몫으로 남긴다.**
|
||||||
|
- 검증: pytest 스킵 2(shellcheck 미설치·Windows AF_UNIX) · phpunit 276 passed, 스킵 0
|
||||||
|
- **1.2.1 릴리스** — 감지 동작은 1.2.0 과 동일(기능 변경 없음). 납품 패키지 내용이 바뀌었으므로
|
||||||
|
(회전 도구 추가) 버전을 올렸다. 산출물 `move-detect-cafe24-1.2.1-20260921.zip` ·
|
||||||
|
`move-detect-pi-1.2.1-20260921-aarch64-cp311-hailo8.tar.gz`(82MB, MoveNet 폴백 포함)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,8 @@
|
||||||
- `src/Api/ThresholdCatalog.php`: 편집 가능한 튜너블의 화이트리스트 + 범위 + 한국어 설명.
|
- `src/Api/ThresholdCatalog.php`: 편집 가능한 튜너블의 화이트리스트 + 범위 + 한국어 설명.
|
||||||
목록에 없는 키는 API 로 저장 불가 (장비가 조용히 무시하는 값을 "설정됨"으로 보이지 않게)
|
목록에 없는 키는 API 로 저장 불가 (장비가 조용히 무시하는 값을 "설정됨"으로 보이지 않게)
|
||||||
- `tools/`: `dispatch_alerts.php`(크론 1분) · `maintain.php`(크론 일 1회, 보존기간) ·
|
- `tools/`: `dispatch_alerts.php`(크론 1분) · `maintain.php`(크론 일 1회, 보존기간) ·
|
||||||
`backup.php`(DB 덤프+스냅샷) · `verify_audit_chain.php`(감사 무결성) · `seed_demo.php`(시연·QA).
|
`backup.php`(DB 덤프+스냅샷) · `verify_audit_chain.php`(감사 무결성) · `seed_demo.php`(시연·QA) ·
|
||||||
|
`rotate_credentials.php`(장치 HMAC 키·관리자 비번 회전 — DB 미접속, 붙여넣을 SQL 만 출력).
|
||||||
전부 CLI 전용(`bootstrap.php` 가 SAPI 검사), 납품 시 `move_detect_app/tools/` 로 www 밖에 배치
|
전부 CLI 전용(`bootstrap.php` 가 SAPI 검사), 납품 시 `move_detect_app/tools/` 로 www 밖에 배치
|
||||||
- 라우트: `POST /api/events`(ingest, HMAC) · `POST /api/heartbeat` · `GET /api/config` ·
|
- 라우트: `POST /api/events`(ingest, HMAC) · `POST /api/heartbeat` · `GET /api/config` ·
|
||||||
`GET /api/events` · `GET /api/devices` · `GET /api/heatmap/{camera}`(spatial 집계) ·
|
`GET /api/events` · `GET /api/devices` · `GET /api/heatmap/{camera}`(spatial 집계) ·
|
||||||
|
|
@ -98,5 +99,12 @@
|
||||||
|
|
||||||
## docs/
|
## docs/
|
||||||
|
|
||||||
`delivery-audit.md`(감사 43건, 전부 완료 — OFF-HW 실장비 검증만 잔여) · `pilot-runbook.md` ·
|
`delivery-audit.md`(감사 43건 + 재감사 4회, 전부 완료 — OFF-HW 실장비 검증만 잔여) · `pilot-runbook.md` ·
|
||||||
`hardware-bringup.md` · `INDEX.md`(작업 기록)
|
`hardware-bringup.md` · `INDEX.md`(작업 기록)
|
||||||
|
|
||||||
|
도해 3종. 소스는 `*.archify.json` 이고 HTML 은 생성물이라 커밋하지 않는다
|
||||||
|
(`node ~/.claude/skills/archify/bin/archify.mjs deliver <type> <소스> <출력> --quality showcase`):
|
||||||
|
|
||||||
|
- `architecture.archify.json` — 시스템 구성(현장 ↔ Cafe24). 무엇이 경계를 넘는가.
|
||||||
|
- `fall-sequence.archify.json` — 낙상 1건의 시간축. 경광등 즉시 vs 통보 최대 60초의 간격.
|
||||||
|
- `fall-lifecycle.archify.json` — 낙상 판정 상태기계. 확정·회복 양쪽이 지속을 요구하는 구조.
|
||||||
|
|
|
||||||
343
docs/architecture.archify.json
Normal file
343
docs/architecture.archify.json
Normal file
|
|
@ -0,0 +1,343 @@
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"diagram_type": "architecture",
|
||||||
|
"meta": {
|
||||||
|
"title": "작업장 안전 AI CCTV — 시스템 구성",
|
||||||
|
"output": "move-detect-architecture.html",
|
||||||
|
"quality_profile": "showcase",
|
||||||
|
"views": [
|
||||||
|
{
|
||||||
|
"id": "detection-chain",
|
||||||
|
"label": "감지 → 대응",
|
||||||
|
"focus": [
|
||||||
|
"cameras",
|
||||||
|
"edge",
|
||||||
|
"beacon"
|
||||||
|
],
|
||||||
|
"note": "카메라에서 경광등까지. 서버도 인터넷도 거치지 않는 즉시 대응 경로."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "privacy-path",
|
||||||
|
"label": "프라이버시 경로",
|
||||||
|
"focus": [
|
||||||
|
"edge",
|
||||||
|
"blur",
|
||||||
|
"ingest",
|
||||||
|
"db"
|
||||||
|
],
|
||||||
|
"note": "영상은 나가지 않는다. 얼굴 블러 후 스냅샷 3장만, 7일 뒤 삭제."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "server-issued",
|
||||||
|
"label": "서버가 발행하는 사건",
|
||||||
|
"focus": [
|
||||||
|
"cron",
|
||||||
|
"db",
|
||||||
|
"notify"
|
||||||
|
],
|
||||||
|
"note": "죽은 노드는 자기가 죽었다고 신고할 수 없다 — 하트비트의 부재로 판정한다."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"id": "cameras",
|
||||||
|
"type": "external",
|
||||||
|
"label": "카메라",
|
||||||
|
"sublabel": "CSI · USB",
|
||||||
|
"tag": "노드당 2~4대",
|
||||||
|
"pos": [
|
||||||
|
40,
|
||||||
|
340
|
||||||
|
],
|
||||||
|
"size": [
|
||||||
|
130,
|
||||||
|
64
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "edge",
|
||||||
|
"type": "backend",
|
||||||
|
"label": "엣지 노드",
|
||||||
|
"sublabel": "Pi 5 + Hailo-8",
|
||||||
|
"tag": "판정 16종",
|
||||||
|
"pos": [
|
||||||
|
230,
|
||||||
|
332
|
||||||
|
],
|
||||||
|
"size": [
|
||||||
|
165,
|
||||||
|
80
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "beacon",
|
||||||
|
"type": "external",
|
||||||
|
"label": "현장 경광등",
|
||||||
|
"sublabel": "GPIO 직결",
|
||||||
|
"tag": "네트워크 불필요",
|
||||||
|
"pos": [
|
||||||
|
230,
|
||||||
|
180
|
||||||
|
],
|
||||||
|
"size": [
|
||||||
|
165,
|
||||||
|
64
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "queue",
|
||||||
|
"type": "messagebus",
|
||||||
|
"label": "오프라인 큐",
|
||||||
|
"sublabel": "단절 시 보관",
|
||||||
|
"pos": [
|
||||||
|
230,
|
||||||
|
500
|
||||||
|
],
|
||||||
|
"size": [
|
||||||
|
165,
|
||||||
|
64
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "blur",
|
||||||
|
"type": "security",
|
||||||
|
"label": "얼굴 블러",
|
||||||
|
"sublabel": "전송 전",
|
||||||
|
"tag": "영상 녹화 없음",
|
||||||
|
"pos": [
|
||||||
|
465,
|
||||||
|
336
|
||||||
|
],
|
||||||
|
"size": [
|
||||||
|
160,
|
||||||
|
72
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ingest",
|
||||||
|
"type": "security",
|
||||||
|
"label": "수집 API",
|
||||||
|
"sublabel": "HMAC + nonce",
|
||||||
|
"tag": "재전송 차단",
|
||||||
|
"pos": [
|
||||||
|
715,
|
||||||
|
332
|
||||||
|
],
|
||||||
|
"size": [
|
||||||
|
155,
|
||||||
|
80
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "app",
|
||||||
|
"type": "backend",
|
||||||
|
"label": "대시보드 서버",
|
||||||
|
"sublabel": "PHP 8",
|
||||||
|
"pos": [
|
||||||
|
960,
|
||||||
|
332
|
||||||
|
],
|
||||||
|
"size": [
|
||||||
|
160,
|
||||||
|
80
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "db",
|
||||||
|
"type": "database",
|
||||||
|
"label": "MySQL",
|
||||||
|
"sublabel": "move_* · 7일",
|
||||||
|
"pos": [
|
||||||
|
1150,
|
||||||
|
340
|
||||||
|
],
|
||||||
|
"size": [
|
||||||
|
160,
|
||||||
|
64
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cron",
|
||||||
|
"type": "backend",
|
||||||
|
"label": "1분 크론",
|
||||||
|
"sublabel": "이벤트 발행",
|
||||||
|
"tag": "서버 발행 4종",
|
||||||
|
"pos": [
|
||||||
|
1150,
|
||||||
|
480
|
||||||
|
],
|
||||||
|
"size": [
|
||||||
|
160,
|
||||||
|
76
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "console",
|
||||||
|
"type": "frontend",
|
||||||
|
"label": "운영 콘솔",
|
||||||
|
"sublabel": "바닐라 JS",
|
||||||
|
"tag": "라이브뷰 없음",
|
||||||
|
"pos": [
|
||||||
|
960,
|
||||||
|
180
|
||||||
|
],
|
||||||
|
"size": [
|
||||||
|
160,
|
||||||
|
76
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "notify",
|
||||||
|
"type": "external",
|
||||||
|
"label": "메일 · 사내 웹훅",
|
||||||
|
"sublabel": "링크만 전송",
|
||||||
|
"pos": [
|
||||||
|
1150,
|
||||||
|
630
|
||||||
|
],
|
||||||
|
"size": [
|
||||||
|
160,
|
||||||
|
72
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"boundaries": [
|
||||||
|
{
|
||||||
|
"kind": "region",
|
||||||
|
"label": "현장 (폐쇄망 가능)",
|
||||||
|
"wraps": [
|
||||||
|
"cameras",
|
||||||
|
"edge",
|
||||||
|
"beacon",
|
||||||
|
"queue",
|
||||||
|
"blur"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "region",
|
||||||
|
"label": "Cafe24 공유호스팅",
|
||||||
|
"wraps": [
|
||||||
|
"ingest",
|
||||||
|
"app",
|
||||||
|
"db",
|
||||||
|
"cron"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": [
|
||||||
|
{
|
||||||
|
"id": "capture",
|
||||||
|
"from": "cameras",
|
||||||
|
"to": "edge",
|
||||||
|
"label": "프레임"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "local-beacon",
|
||||||
|
"from": "edge",
|
||||||
|
"to": "beacon",
|
||||||
|
"label": "즉시 점등",
|
||||||
|
"variant": "emphasis",
|
||||||
|
"fromSide": "top",
|
||||||
|
"toSide": "bottom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "evidence",
|
||||||
|
"from": "edge",
|
||||||
|
"to": "blur",
|
||||||
|
"label": "이벤트 시"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "spool",
|
||||||
|
"from": "edge",
|
||||||
|
"to": "queue",
|
||||||
|
"label": "회선 단절 시",
|
||||||
|
"variant": "dashed",
|
||||||
|
"fromSide": "bottom",
|
||||||
|
"toSide": "top",
|
||||||
|
"labelDy": 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "upload",
|
||||||
|
"from": "blur",
|
||||||
|
"to": "ingest",
|
||||||
|
"label": "HMAC POST",
|
||||||
|
"variant": "emphasis"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "resend",
|
||||||
|
"from": "queue",
|
||||||
|
"to": "ingest",
|
||||||
|
"label": "복구 후 재전송",
|
||||||
|
"variant": "dashed",
|
||||||
|
"fromSide": "right",
|
||||||
|
"toSide": "bottom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "accept",
|
||||||
|
"from": "ingest",
|
||||||
|
"to": "app",
|
||||||
|
"label": "검증 통과"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "persist",
|
||||||
|
"from": "app",
|
||||||
|
"to": "db",
|
||||||
|
"label": "SQL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cron-db",
|
||||||
|
"from": "cron",
|
||||||
|
"to": "db",
|
||||||
|
"label": "하트비트 부재로 판정",
|
||||||
|
"fromSide": "top",
|
||||||
|
"toSide": "bottom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dispatch",
|
||||||
|
"from": "cron",
|
||||||
|
"to": "notify",
|
||||||
|
"label": "1분 주기 통보",
|
||||||
|
"fromSide": "bottom",
|
||||||
|
"toSide": "top",
|
||||||
|
"labelDy": 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "serve",
|
||||||
|
"from": "app",
|
||||||
|
"to": "console",
|
||||||
|
"label": "HTTPS · 역할 기반 접근",
|
||||||
|
"fromSide": "top",
|
||||||
|
"toSide": "bottom"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cards": [
|
||||||
|
{
|
||||||
|
"dot": "cyan",
|
||||||
|
"title": "현장에서 끝나는 대응",
|
||||||
|
"items": [
|
||||||
|
"판정은 엣지에서 완결 — 서버 왕복 없음",
|
||||||
|
"경광등은 GPIO 직결이라 회선·서버가 죽어도 울린다",
|
||||||
|
"끊긴 동안의 이벤트는 큐에 쌓였다가 복구 시 재전송"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dot": "rose",
|
||||||
|
"title": "영상을 남기지 않는다",
|
||||||
|
"items": [
|
||||||
|
"링버퍼는 RAM 7.5초 — 디스크에 쓰지 않는다",
|
||||||
|
"이벤트 순간에만 3프레임, 얼굴 블러는 전송 전 노드에서",
|
||||||
|
"스냅샷 7일 후 자동 삭제 · 라이브뷰 화면 자체가 없음"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dot": "emerald",
|
||||||
|
"title": "서버가 보는 것",
|
||||||
|
"items": [
|
||||||
|
"HMAC + nonce 로 재전송 공격 차단",
|
||||||
|
"죽은 노드는 자기가 죽었다고 신고할 수 없다 — 크론이 하트비트의 부재로 판정",
|
||||||
|
"통보는 링크만 — 현장 이미지는 메일로 나가지 않는다"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
# Delivery Audit — move-detect (납품 전 결함 목록 · 순차 처리)
|
# Delivery Audit — move-detect (납품 전 결함 목록 · 순차 처리)
|
||||||
|
|
||||||
> **판정 (2026-09-05, 1.2.0): 적합 — 오프하드웨어 항목 전량 해소.**
|
> **판정 (2026-09-21, 1.2.1): 적합 — 오프하드웨어 항목 전량 해소.**
|
||||||
> 1차 43/43 + 2차 12/12 + 3차(1.1.0 장치 건전성) 14/14 +
|
> 1차 43/43 + 2차 12/12 + 3차(1.1.0 장치 건전성) 14/14 +
|
||||||
> **4차 재감사(1.2.0 추락·동시 다발·프리셋 표면) 23/23** 해소.
|
> **4차 재감사(1.2.0 추락·동시 다발·프리셋 표면) 23/23** 해소.
|
||||||
> 잔여는 실장비(OFF-HW) 검증뿐 — 아래 "OFF-HW 잔여" 절 참조.
|
> 2026-09-21 납품 정비로 Pi 번들 재빌드·MoveNet 원본·자격증명 회전 도구까지 닫혔다.
|
||||||
|
> 잔여는 실장비(OFF-HW) 검증과 **운영자가 직접 해야 하는 자격증명 교체** 뿐.
|
||||||
|
|
||||||
Source: 62-agent adversarial audit (2026-07-16) + skill `iot-airgap-delivery` §3 + manual
|
Source: 62-agent adversarial audit (2026-07-16) + skill `iot-airgap-delivery` §3 + manual
|
||||||
verification of every blocker against real code. Ordered for sequential fixing. Each item is
|
verification of every blocker against real code. Ordered for sequential fixing. Each item is
|
||||||
|
|
@ -163,6 +164,11 @@ misconfig/degradation fail LOUD.
|
||||||
— PINTO hosts models off-repo), so it was silently saving garbage. No known-good sha256 to
|
— PINTO hosts models off-repo), so it was silently saving garbage. No known-good sha256 to
|
||||||
pin until a valid MOVENET_ONNX_URL is set. NON-BLOCKING for the Hailo delivery path (MoveNet
|
pin until a valid MOVENET_ONNX_URL is set. NON-BLOCKING for the Hailo delivery path (MoveNet
|
||||||
is the optional CPU fallback); flag for whoever needs CPU MoveNet.
|
is the optional CPU fallback); flag for whoever needs CPU MoveNet.
|
||||||
|
CLOSED 2026-09-21 — 유효한 출처를 찾아 고정했다(tf2onnx 변환본, sha256+size). 고정 전에
|
||||||
|
어댑터 계약과 대조했다: 입력 `[1,192,192,3] int32` NHWC, 출력 `[1,1,17,3]`. 레이아웃이 다른
|
||||||
|
변환본은 **로드는 되고 좌표만 틀리는** 실패라, 그 계약을 테스트로 박았다
|
||||||
|
(`tests/hardware/test_movenet_smoke.py::test_model_signature_is_what_the_adapter_feeds_and_reads`).
|
||||||
|
실사진 2장으로 코<어깨<엉덩이<발목 세로 순서와 bbox 프레임 내 포함까지 확인.
|
||||||
|
|
||||||
## Phase 4 — Server hardening (shared hosting)
|
## Phase 4 — Server hardening (shared hosting)
|
||||||
|
|
||||||
|
|
@ -423,9 +429,13 @@ config 템플릿 2종. `edge/` 는 불변. 4개 관점 병렬 감사 후 각 발
|
||||||
- **자격증명 교체** — `secrets/PROVISION_SECRETS.md` 의 대시보드 비밀번호와 장치 HMAC 키는
|
- **자격증명 교체** — `secrets/PROVISION_SECRETS.md` 의 대시보드 비밀번호와 장치 HMAC 키는
|
||||||
6주간 납품 폴더 안에 있었다. 유출 정황은 없으나(git 미추적·zip 미포함) 교체를 권고한다.
|
6주간 납품 폴더 안에 있었다. 유출 정황은 없으나(git 미추적·zip 미포함) 교체를 권고한다.
|
||||||
운영 계정 조작이라 이 저장소에서 수행하지 않는다.
|
운영 계정 조작이라 이 저장소에서 수행하지 않는다.
|
||||||
|
2026-09-21 — 손으로 적던 절차를 도구로 바꿨다(`server/tools/rotate_credentials.php`).
|
||||||
|
**실행은 여전히 운영자 몫이다.**
|
||||||
- **Pi 번들 재빌드** — 1.1.0 은 서버 전용이라 `edge/` 가 불변이고 내용은 1.0.0 과 동일하지만,
|
- **Pi 번들 재빌드** — 1.1.0 은 서버 전용이라 `edge/` 가 불변이고 내용은 1.0.0 과 동일하지만,
|
||||||
파일명·MANIFEST 의 버전 표기가 1.0.0 이다. arm64 + 네트워크가 있는 빌드 머신에서만 재빌드
|
파일명·MANIFEST 의 버전 표기가 1.0.0 이다. arm64 + 네트워크가 있는 빌드 머신에서만 재빌드
|
||||||
가능하므로 Pi 납품 시 그 머신에서 수행한다.
|
가능하므로 Pi 납품 시 그 머신에서 수행한다.
|
||||||
|
RESOLVED 2026-09-21 — `--cross` 모드로 해소했다. arm64 머신은 필요 없었다: pip 이 태그로
|
||||||
|
aarch64/cp311 휠을 받으므로 네트워크만 있으면 된다. 휠 11개 전부 aarch64.
|
||||||
- **매뉴얼에 신규 2종 설명 없음** — `manual.json` 에 `장치 이상`·`감시 성능 저하` 항목이 없다
|
- **매뉴얼에 신규 2종 설명 없음** — `manual.json` 에 `장치 이상`·`감시 성능 저하` 항목이 없다
|
||||||
(1.0.0 의 `미확인 경보`도 마찬가지라 이번 회귀는 아니다). 캡처는 실화면이고 UI 골격은 바뀌지
|
(1.0.0 의 `미확인 경보`도 마찬가지라 이번 회귀는 아니다). 캡처는 실화면이고 UI 골격은 바뀌지
|
||||||
않았다. 다음 매뉴얼 개정에서 함께 넣는다.
|
않았다. 다음 매뉴얼 개정에서 함께 넣는다.
|
||||||
|
|
|
||||||
217
docs/fall-lifecycle.archify.json
Normal file
217
docs/fall-lifecycle.archify.json
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"diagram_type": "lifecycle",
|
||||||
|
"meta": {
|
||||||
|
"title": "낙상 판정 상태기계 — 지속을 요구하는 이유",
|
||||||
|
"output": "move-detect-fall-lifecycle.html",
|
||||||
|
"quality_profile": "showcase",
|
||||||
|
"views": [
|
||||||
|
{
|
||||||
|
"id": "main-path",
|
||||||
|
"label": "확정까지",
|
||||||
|
"focus": [
|
||||||
|
"standing",
|
||||||
|
"falling",
|
||||||
|
"fallen",
|
||||||
|
"nomotion"
|
||||||
|
],
|
||||||
|
"note": "한 프레임으로는 아무것도 확정하지 않는다. 1초는 위험 자세, 3초라야 넘어짐."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "recovery",
|
||||||
|
"label": "회복 경로",
|
||||||
|
"focus": [
|
||||||
|
"falling",
|
||||||
|
"nearfall",
|
||||||
|
"fallen",
|
||||||
|
"recovered"
|
||||||
|
],
|
||||||
|
"note": "회복도 지속을 요구한다. 확정 전에 일어서면 니어미스로만 남는다."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "lost-track",
|
||||||
|
"label": "사라진 사람",
|
||||||
|
"focus": [
|
||||||
|
"fallen",
|
||||||
|
"ghost"
|
||||||
|
],
|
||||||
|
"note": "위험 상태에서 트랙이 사라지면 10초 기다린 뒤 별도 사건으로 올린다."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"viewBox": [
|
||||||
|
1085,
|
||||||
|
620
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"lanes": [
|
||||||
|
{
|
||||||
|
"id": "main",
|
||||||
|
"label": "자세 상태"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "recovery",
|
||||||
|
"label": "회복"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "terminal",
|
||||||
|
"label": "종료"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"states": [
|
||||||
|
{
|
||||||
|
"id": "standing",
|
||||||
|
"type": "start",
|
||||||
|
"label": "정상",
|
||||||
|
"sublabel": "기립 · 보행",
|
||||||
|
"lane": "main",
|
||||||
|
"col": 0,
|
||||||
|
"step": "01"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "falling",
|
||||||
|
"type": "active",
|
||||||
|
"label": "위험 자세",
|
||||||
|
"sublabel": "쓰러진 자세 1초",
|
||||||
|
"lane": "main",
|
||||||
|
"col": 1,
|
||||||
|
"step": "02",
|
||||||
|
"tag": "알람 없음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fallen",
|
||||||
|
"type": "failure",
|
||||||
|
"label": "넘어짐 확정",
|
||||||
|
"sublabel": "3초 지속",
|
||||||
|
"lane": "main",
|
||||||
|
"col": 2,
|
||||||
|
"step": "03",
|
||||||
|
"tag": "경광등 + 통보"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "nomotion",
|
||||||
|
"type": "failure",
|
||||||
|
"label": "미동 없음",
|
||||||
|
"sublabel": "정지 10초",
|
||||||
|
"lane": "main",
|
||||||
|
"col": 3,
|
||||||
|
"step": "04",
|
||||||
|
"tag": "최상위 위급"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ghost",
|
||||||
|
"type": "failure",
|
||||||
|
"label": "위험 상태 소실",
|
||||||
|
"sublabel": "트랙이 사라짐",
|
||||||
|
"lane": "terminal",
|
||||||
|
"col": 2,
|
||||||
|
"tag": "10초 대기",
|
||||||
|
"yOffset": -16
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "nearfall",
|
||||||
|
"type": "neutral",
|
||||||
|
"label": "니어미스",
|
||||||
|
"sublabel": "확정 전 회복",
|
||||||
|
"lane": "recovery",
|
||||||
|
"col": 0,
|
||||||
|
"tag": "기록만"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "recovered",
|
||||||
|
"type": "success",
|
||||||
|
"label": "정상 복귀",
|
||||||
|
"sublabel": "정상 자세 1초",
|
||||||
|
"lane": "recovery",
|
||||||
|
"col": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"transitions": [
|
||||||
|
{
|
||||||
|
"id": "to-falling",
|
||||||
|
"from": "standing",
|
||||||
|
"to": "falling",
|
||||||
|
"label": "쓰러진 자세 1초",
|
||||||
|
"labelDy": -46,
|
||||||
|
"route": "straight"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "to-fallen",
|
||||||
|
"from": "falling",
|
||||||
|
"to": "fallen",
|
||||||
|
"label": "3초 지속",
|
||||||
|
"variant": "emphasis",
|
||||||
|
"labelDy": -46,
|
||||||
|
"route": "straight"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "to-nomotion",
|
||||||
|
"from": "fallen",
|
||||||
|
"to": "nomotion",
|
||||||
|
"label": "정지 10초",
|
||||||
|
"variant": "emphasis",
|
||||||
|
"labelDy": -46,
|
||||||
|
"route": "straight"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "to-ghost",
|
||||||
|
"from": "nomotion",
|
||||||
|
"to": "ghost",
|
||||||
|
"label": "재출현 없음",
|
||||||
|
"variant": "security",
|
||||||
|
"route": "right-channel",
|
||||||
|
"channelX": 720,
|
||||||
|
"labelSegment": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "near-miss",
|
||||||
|
"from": "falling",
|
||||||
|
"to": "nearfall",
|
||||||
|
"label": "정상 1초",
|
||||||
|
"route": "drop"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fallen-recover",
|
||||||
|
"from": "fallen",
|
||||||
|
"to": "recovered",
|
||||||
|
"label": "정상 1초",
|
||||||
|
"route": "drop"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "back-from-nearmiss",
|
||||||
|
"from": "nearfall",
|
||||||
|
"to": "standing",
|
||||||
|
"label": "복귀",
|
||||||
|
"variant": "dashed",
|
||||||
|
"route": "bottom-channel"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cards": [
|
||||||
|
{
|
||||||
|
"dot": "emerald",
|
||||||
|
"title": "지속을 요구하는 이유",
|
||||||
|
"items": [
|
||||||
|
"한 프레임으로는 확정하지 않는다",
|
||||||
|
"1초는 위험 자세 — 알람 없음",
|
||||||
|
"앉는 동작과 가르는 조건이 이것이다"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dot": "rose",
|
||||||
|
"title": "회복도 지속을 요구한다",
|
||||||
|
"items": [
|
||||||
|
"정상 자세 1초라야 회복",
|
||||||
|
"가려지면 카운트다운이 멈춘다",
|
||||||
|
"확정 전 회복은 니어미스로만"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dot": "amber",
|
||||||
|
"title": "같은 3초가 두 번",
|
||||||
|
"items": [
|
||||||
|
"확정 창 3초 · 추락 연결 창 3초",
|
||||||
|
"겹쳐서 추락 승격이 미발화했다",
|
||||||
|
"시간을 그렸다면 보였을 충돌"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
258
docs/fall-sequence.archify.json
Normal file
258
docs/fall-sequence.archify.json
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"diagram_type": "sequence",
|
||||||
|
"meta": {
|
||||||
|
"title": "낙상 1건 — 감지에서 통보까지",
|
||||||
|
"output": "move-detect-fall-sequence.html",
|
||||||
|
"quality_profile": "showcase",
|
||||||
|
"column_fit": "spread",
|
||||||
|
"views": [
|
||||||
|
{
|
||||||
|
"id": "on-site",
|
||||||
|
"label": "현장에서 끝나는 3초",
|
||||||
|
"focus": [
|
||||||
|
"cam",
|
||||||
|
"edge",
|
||||||
|
"beacon"
|
||||||
|
],
|
||||||
|
"note": "확정부터 점등까지 서버를 거치지 않는다. 회선이 끊겨도 여기까지는 동작한다."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "what-leaves",
|
||||||
|
"label": "밖으로 나가는 것",
|
||||||
|
"focus": [
|
||||||
|
"edge",
|
||||||
|
"api",
|
||||||
|
"db"
|
||||||
|
],
|
||||||
|
"note": "영상이 아니라 얼굴 블러된 3프레임. 블러는 전송 전, 노드에서."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "notify-lag",
|
||||||
|
"label": "통보까지의 실제 지연",
|
||||||
|
"focus": [
|
||||||
|
"cron",
|
||||||
|
"db",
|
||||||
|
"staff"
|
||||||
|
],
|
||||||
|
"note": "경광등은 즉시, 메일은 크론 주기만큼 늦다. 그 간격이 이 그림의 요점이다."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"viewBox": [
|
||||||
|
1060,
|
||||||
|
560
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"participants": [
|
||||||
|
{
|
||||||
|
"id": "cam",
|
||||||
|
"type": "external",
|
||||||
|
"label": "카메라",
|
||||||
|
"sublabel": "12fps"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "edge",
|
||||||
|
"type": "backend",
|
||||||
|
"label": "엣지 노드",
|
||||||
|
"sublabel": "Pi 5 + Hailo-8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "beacon",
|
||||||
|
"type": "external",
|
||||||
|
"label": "경광등",
|
||||||
|
"sublabel": "GPIO"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api",
|
||||||
|
"type": "security",
|
||||||
|
"label": "수집 API",
|
||||||
|
"sublabel": "HMAC 검증"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "db",
|
||||||
|
"type": "database",
|
||||||
|
"label": "MySQL",
|
||||||
|
"sublabel": "move_events"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cron",
|
||||||
|
"type": "backend",
|
||||||
|
"label": "1분 크론",
|
||||||
|
"sublabel": "dispatch_alerts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "staff",
|
||||||
|
"type": "external",
|
||||||
|
"label": "담당자",
|
||||||
|
"sublabel": "메일 · 웹훅"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"segments": [
|
||||||
|
{
|
||||||
|
"from": 152,
|
||||||
|
"to": 190,
|
||||||
|
"label": "감지 — 자세 3초"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": 198,
|
||||||
|
"to": 320,
|
||||||
|
"label": "현장 대응 · 기록"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": 330,
|
||||||
|
"to": 366,
|
||||||
|
"label": "미동 없음 13초"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": 374,
|
||||||
|
"to": 466,
|
||||||
|
"label": "통보 — 크론 주기 지연"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"id": "frames",
|
||||||
|
"from": "cam",
|
||||||
|
"to": "edge",
|
||||||
|
"y": 168,
|
||||||
|
"label": "프레임 · 12fps",
|
||||||
|
"variant": "default",
|
||||||
|
"note": "0.0초 쓰러짐 → 1초 위험 자세 → 3초 확정"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "gpio",
|
||||||
|
"from": "edge",
|
||||||
|
"to": "beacon",
|
||||||
|
"y": 206,
|
||||||
|
"label": "넘어짐 확정(3초) → GPIO 점등",
|
||||||
|
"variant": "emphasis",
|
||||||
|
"note": "3.0초 · 서버도 인터넷도 거치지 않는다"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "post",
|
||||||
|
"from": "edge",
|
||||||
|
"to": "api",
|
||||||
|
"y": 244,
|
||||||
|
"label": "HMAC POST · 3프레임",
|
||||||
|
"variant": "security",
|
||||||
|
"note": "얼굴 블러는 전송 전, 노드에서"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "insert",
|
||||||
|
"from": "api",
|
||||||
|
"to": "db",
|
||||||
|
"y": 276,
|
||||||
|
"label": "INSERT",
|
||||||
|
"variant": "default"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "accepted",
|
||||||
|
"from": "api",
|
||||||
|
"to": "edge",
|
||||||
|
"y": 306,
|
||||||
|
"label": "202 Accepted",
|
||||||
|
"variant": "return"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "nomotion",
|
||||||
|
"from": "edge",
|
||||||
|
"to": "api",
|
||||||
|
"y": 344,
|
||||||
|
"label": "미동 없음 · 10초",
|
||||||
|
"variant": "emphasis",
|
||||||
|
"note": "13.0초 — 최상위 위급"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "poll",
|
||||||
|
"from": "cron",
|
||||||
|
"to": "db",
|
||||||
|
"y": 384,
|
||||||
|
"label": "통보 대상 조회 · 최대 60초 뒤",
|
||||||
|
"variant": "default",
|
||||||
|
"note": "최대 60초 뒤 — 크론이 1분마다 돈다"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "send",
|
||||||
|
"from": "cron",
|
||||||
|
"to": "staff",
|
||||||
|
"y": 418,
|
||||||
|
"label": "메일 · 웹훅 (링크만)",
|
||||||
|
"variant": "emphasis"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "escalate",
|
||||||
|
"from": "cron",
|
||||||
|
"to": "db",
|
||||||
|
"y": 452,
|
||||||
|
"label": "5분 미확인 → 상위 확대",
|
||||||
|
"variant": "dashed"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"activations": [
|
||||||
|
{
|
||||||
|
"participant": "edge",
|
||||||
|
"from": 196,
|
||||||
|
"to": 352,
|
||||||
|
"type": "backend"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"participant": "beacon",
|
||||||
|
"from": 201,
|
||||||
|
"to": 232,
|
||||||
|
"type": "external"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"participant": "api",
|
||||||
|
"from": 239,
|
||||||
|
"to": 312,
|
||||||
|
"type": "security"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"participant": "db",
|
||||||
|
"from": 271,
|
||||||
|
"to": 300,
|
||||||
|
"type": "database"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"participant": "cron",
|
||||||
|
"from": 379,
|
||||||
|
"to": 458,
|
||||||
|
"type": "backend"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"participant": "staff",
|
||||||
|
"from": 413,
|
||||||
|
"to": 442,
|
||||||
|
"type": "external"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cards": [
|
||||||
|
{
|
||||||
|
"dot": "cyan",
|
||||||
|
"title": "현장에서 끝나는 3초",
|
||||||
|
"items": [
|
||||||
|
"자세가 3초 지속돼야 확정한다",
|
||||||
|
"확정 즉시 GPIO 점등 — 서버 불필요",
|
||||||
|
"서버가 죽어도 여기까지는 동작"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dot": "rose",
|
||||||
|
"title": "밖으로 나가는 것",
|
||||||
|
"items": [
|
||||||
|
"영상이 아니라 3프레임",
|
||||||
|
"얼굴 블러는 전송 전, 노드에서",
|
||||||
|
"블러 전 이미지는 서버에 없다"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dot": "amber",
|
||||||
|
"title": "두 지연은 다르다",
|
||||||
|
"items": [
|
||||||
|
"경광등은 즉시",
|
||||||
|
"메일은 크론 주기만큼 늦다",
|
||||||
|
"\"1분 통보\"는 대기를 포함한 값"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -6,7 +6,7 @@
|
||||||
> 담당자 통보까지 이어지도록** 만든 온프레미스 안전관리 시스템입니다. 영상은 외부로 나가지
|
> 담당자 통보까지 이어지도록** 만든 온프레미스 안전관리 시스템입니다. 영상은 외부로 나가지
|
||||||
> 않습니다 — 사고 순간의 스냅샷 3장(얼굴 자동 블러)만 전달합니다.
|
> 않습니다 — 사고 순간의 스냅샷 3장(얼굴 자동 블러)만 전달합니다.
|
||||||
|
|
||||||
버전 1.2.0 · 2026-09-05
|
버전 1.2.1 · 2026-09-21
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,13 @@ to a temp file, validated, and only then moved into place — a truncated / HTML
|
||||||
tampered download must never be saved as the model (onnxruntime would then fail to load it, and
|
tampered download must never be saved as the model (onnxruntime would then fail to load it, and
|
||||||
the operator would see a cryptic runtime error instead of a clear fetch failure).
|
the operator would see a cryptic runtime error instead of a clear fetch failure).
|
||||||
|
|
||||||
STATUS: the historical PINTO_model_zoo raw URL below is DEAD (that repo hosts its models on
|
SOURCE: the historical PINTO_model_zoo raw URL is DEAD (that repo hosts its models on external
|
||||||
external storage, not as repo blobs — the raw path now returns a GitHub HTML 404). So there is
|
storage, not as repo blobs — the raw path returns a GitHub HTML 404). The pinned source below is
|
||||||
no known-good sha256 to pin here yet. Until a working source is set:
|
a tf2onnx export of Google's MoveNet SinglePose Lightning v4, checked to match what
|
||||||
* this tool FAILS LOUD if the download is not a real ONNX (rejects the HTML it currently gets),
|
edge/adapters/movenet_pose.py feeds and reads: input [1,192,192,3] int32 NHWC, output
|
||||||
instead of silently saving garbage;
|
[1,1,17,3] float (y, x, score). A conversion with a different layout would load fine and then
|
||||||
* set MOVENET_ONNX_URL to a valid source and pin PINNED_SHA256/PINNED_SIZE from its bytes.
|
produce silently wrong keypoints, so the digest below is what makes this safe to fetch — not the
|
||||||
For the Hailo delivery path this model is optional (POSE_BACKEND=hailo), so a missing MoveNet is
|
hostname. For the Hailo delivery path this model is optional (POSE_BACKEND=hailo).
|
||||||
non-blocking; the CPU fallback needs this resolved.
|
|
||||||
"""
|
"""
|
||||||
import hashlib
|
import hashlib
|
||||||
import os
|
import os
|
||||||
|
|
@ -22,15 +21,16 @@ import urllib.request
|
||||||
|
|
||||||
URL = os.environ.get(
|
URL = os.environ.get(
|
||||||
"MOVENET_ONNX_URL",
|
"MOVENET_ONNX_URL",
|
||||||
"https://github.com/PINTO0309/PINTO_model_zoo/raw/main/115_MoveNet/resources/"
|
"https://huggingface.co/Xenova/movenet-singlepose-lightning/resolve/main/onnx/model.onnx",
|
||||||
"movenet_singlepose_lightning_4.onnx",
|
|
||||||
)
|
)
|
||||||
DEST = os.path.join(os.path.dirname(__file__), "..", "..", "models", "movenet_lightning.onnx")
|
DEST = os.path.join(os.path.dirname(__file__), "..", "..", "models", "movenet_lightning.onnx")
|
||||||
|
|
||||||
# Pin from the bytes of a known-good source once MOVENET_ONNX_URL points at one. Empty = unpinned
|
# Pinned from the bytes of the source above. An override must bring its own digest — pointing
|
||||||
# (still validated as a real ONNX below, but not byte-identical-verified).
|
# MOVENET_ONNX_URL elsewhere while keeping this pin just makes the download fail, which is the
|
||||||
PINNED_SHA256 = os.environ.get("MOVENET_ONNX_SHA256", "")
|
# intended direction of failure.
|
||||||
PINNED_SIZE = int(os.environ.get("MOVENET_ONNX_SIZE", "0") or "0")
|
PINNED_SHA256 = os.environ.get(
|
||||||
|
"MOVENET_ONNX_SHA256", "1ad4f8d6c2f776a9967db3993c9ca740bc350104f9d37c151dc183fc29a464ad")
|
||||||
|
PINNED_SIZE = int(os.environ.get("MOVENET_ONNX_SIZE", "9413268") or "0")
|
||||||
|
|
||||||
|
|
||||||
def _looks_like_onnx(head: bytes) -> bool:
|
def _looks_like_onnx(head: bytes) -> bool:
|
||||||
|
|
|
||||||
111
server/tests/RotateCredentialsTest.php
Normal file
111
server/tests/RotateCredentialsTest.php
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
namespace App\Tests;
|
||||||
|
|
||||||
|
use App\Security\Password;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자격증명 회전 도구.
|
||||||
|
*
|
||||||
|
* 이 도구의 실패는 조용하다. 회전은 사고 없이 몇 달에 한 번 하는 일이라, 출력이 틀려도
|
||||||
|
* 알아차릴 기회가 없다. 특히 두 가지가 치명적이다:
|
||||||
|
*
|
||||||
|
* 서버 SQL 의 키와 Pi 에 넣으라는 키가 다르면 — 그 장치의 이벤트가 전부 거부된다.
|
||||||
|
* 사람이 쓰러져도 서버까지 오지 않는데, 대시보드는 device_fault 하나만 보여준다.
|
||||||
|
* 해시가 앱의 검증과 안 맞으면 — 운영자가 대시보드에서 잠긴다.
|
||||||
|
*
|
||||||
|
* 둘 다 "실행했고 SQL 도 돌았는데 안 된다" 로 나타나므로, 출력 그대로를 검사한다.
|
||||||
|
*/
|
||||||
|
final class RotateCredentialsTest extends TestCase
|
||||||
|
{
|
||||||
|
private const TOOL = __DIR__ . '/../tools/rotate_credentials.php';
|
||||||
|
|
||||||
|
/** @return array{0:int,1:string,2:string} [exit, stdout, stderr] */
|
||||||
|
private function tool(array $args, string $stdin = ''): array
|
||||||
|
{
|
||||||
|
$cmd = array_merge([PHP_BINARY, self::TOOL], $args);
|
||||||
|
$spec = [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
|
||||||
|
$p = proc_open($cmd, $spec, $pipes);
|
||||||
|
$this->assertIsResource($p, 'rotate_credentials.php 를 실행하지 못했습니다');
|
||||||
|
fwrite($pipes[0], $stdin);
|
||||||
|
fclose($pipes[0]);
|
||||||
|
$out = (string)stream_get_contents($pipes[1]);
|
||||||
|
$err = (string)stream_get_contents($pipes[2]);
|
||||||
|
fclose($pipes[1]);
|
||||||
|
fclose($pipes[2]);
|
||||||
|
return [proc_close($p), $out, $err];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_device_rotation_puts_the_same_key_on_both_sides(): void
|
||||||
|
{
|
||||||
|
[$code, $out] = $this->tool(['--device=pi-01']);
|
||||||
|
$this->assertSame(0, $code);
|
||||||
|
|
||||||
|
$this->assertSame(1, preg_match(
|
||||||
|
"/UPDATE move_devices SET secret = '([0-9a-f]{64})' WHERE device_id = 'pi-01';/", $out, $sql));
|
||||||
|
$this->assertSame(1, preg_match('/DEVICE_SECRET=([0-9a-f]{64})\|/', $out, $env));
|
||||||
|
|
||||||
|
$this->assertSame($sql[1], $env[1],
|
||||||
|
'서버에 넣는 키와 Pi 에 넣는 키가 다릅니다 — 그 장치의 이벤트는 전부 거부됩니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_each_run_mints_a_fresh_key(): void
|
||||||
|
{
|
||||||
|
[, $a] = $this->tool(['--device=pi-01']);
|
||||||
|
[, $b] = $this->tool(['--device=pi-01']);
|
||||||
|
$this->assertNotSame($a, $b, '회전인데 같은 값이 나오면 회전이 아닙니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_device_id_must_be_an_identifier(): void
|
||||||
|
{
|
||||||
|
// device_id 는 운영자가 타이핑하는 값이고 SQL 문자열로 들어간다.
|
||||||
|
foreach (["pi-01'; DROP TABLE move_devices; --", "pi 01", str_repeat('a', 65), ""] as $bad) {
|
||||||
|
[$code, $out] = $this->tool(["--device={$bad}"]);
|
||||||
|
$this->assertNotSame(0, $code, "거부해야 할 device_id 를 통과시켰습니다: {$bad}");
|
||||||
|
$this->assertStringNotContainsString('UPDATE', $out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_hash_is_what_the_app_will_verify(): void
|
||||||
|
{
|
||||||
|
$plain = 'correct-horse-battery-staple';
|
||||||
|
[$code, $out] = $this->tool(['--admin=admin'], "{$plain}\n{$plain}\n");
|
||||||
|
$this->assertSame(0, $code);
|
||||||
|
|
||||||
|
$this->assertSame(1, preg_match(
|
||||||
|
"/SET password_hash = '([^']+)', failed_attempts = 0, locked_until = 0/", $out, $m));
|
||||||
|
$this->assertTrue(Password::verify($plain, $m[1]),
|
||||||
|
'앱이 검증하지 못하는 해시입니다 — 운영자가 대시보드에서 잠깁니다.');
|
||||||
|
|
||||||
|
// 잠금 해제까지 같이 하지 않으면, 비번을 잊어 잠긴 계정은 새 비번으로도 못 들어온다.
|
||||||
|
$this->assertStringContainsString('failed_attempts = 0', $out);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_the_plaintext_password_is_never_printed(): void
|
||||||
|
{
|
||||||
|
$plain = 'correct-horse-battery-staple';
|
||||||
|
[, $out, $err] = $this->tool(['--admin=admin'], "{$plain}\n{$plain}\n");
|
||||||
|
$this->assertStringNotContainsString($plain, $out);
|
||||||
|
$this->assertStringNotContainsString($plain, $err);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_mismatch_and_short_passwords_are_refused(): void
|
||||||
|
{
|
||||||
|
foreach ([["aaaaaaaaaaaa\nbbbbbbbbbbbb\n", '다릅니다'], ["short\nshort\n", '미만']] as [$in, $why]) {
|
||||||
|
[$code, $out, $err] = $this->tool(['--admin=admin'], $in);
|
||||||
|
$this->assertNotSame(0, $code, "거부해야 할 입력을 통과시켰습니다: {$why}");
|
||||||
|
$this->assertStringNotContainsString('UPDATE', $out);
|
||||||
|
$this->assertStringContainsString($why, $err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_refuses_to_guess_which_credential_to_rotate(): void
|
||||||
|
{
|
||||||
|
foreach ([[], ['--device=pi-01', '--admin=admin']] as $args) {
|
||||||
|
[$code, $out] = $this->tool($args);
|
||||||
|
$this->assertNotSame(0, $code);
|
||||||
|
$this->assertStringNotContainsString('UPDATE', $out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
157
server/tools/rotate_credentials.php
Normal file
157
server/tools/rotate_credentials.php
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 자격증명 회전 — 붙여넣을 SQL 을 만들어 준다. DB 에 직접 쓰지 않는다.
|
||||||
|
*
|
||||||
|
* php tools/rotate_credentials.php --device=pi-01
|
||||||
|
* php tools/rotate_credentials.php --admin=admin
|
||||||
|
*
|
||||||
|
* DB 에 연결하지 않는 이유: 회전이 필요한 순간은 카페24 공유호스팅이고, 운영자가 실제로 쓰는
|
||||||
|
* 창구는 phpMyAdmin 이다. 접속 정보 없이 어느 머신에서나 돌려서 SQL 만 받아 붙여넣는 편이
|
||||||
|
* 폐쇄망·공유호스팅 양쪽에서 확실하다. 손으로 적던 절차(secrets/PROVISION_SECRETS.md "회전
|
||||||
|
* 방법")를 명령 하나로 바꾸는 것이 이 도구의 전부다.
|
||||||
|
*
|
||||||
|
* 비밀번호는 argv 로 받지 않는다 — 명령행은 `ps` 와 셸 히스토리에 남는다. STDIN 으로만 받고,
|
||||||
|
* 받은 평문은 해시 외에 어디에도 출력하지 않는다.
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require __DIR__ . '/bootstrap.php';
|
||||||
|
|
||||||
|
use App\Security\Password;
|
||||||
|
|
||||||
|
const MIN_PASSWORD_LEN = 12; // 대시보드는 인터넷에 노출된다. 짧은 비번은 회전의 의미가 없다.
|
||||||
|
const DEVICE_SECRET_BYTES = 32;
|
||||||
|
|
||||||
|
$args = tool_args($argv);
|
||||||
|
$device = $args['device'] ?? null;
|
||||||
|
$admin = $args['admin'] ?? null;
|
||||||
|
|
||||||
|
if (($device === null) === ($admin === null)) {
|
||||||
|
fwrite(STDERR, <<<TXT
|
||||||
|
사용법: --device=<device_id> | --admin=<username> (둘 중 하나)
|
||||||
|
|
||||||
|
--device=pi-01 장치 HMAC 키를 새로 만들고, 서버 UPDATE 문과 Pi 쪽 절차를 출력
|
||||||
|
--admin=admin 새 비밀번호를 STDIN 으로 받아 Argon2id 해시와 UPDATE 문을 출력
|
||||||
|
|
||||||
|
출력된 SQL 을 phpMyAdmin 에서 실행하십시오. 이 도구는 DB 를 건드리지 않습니다.
|
||||||
|
|
||||||
|
TXT);
|
||||||
|
exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MySQL 문자열 리터럴. 여기서 만드는 값은 hex/해시뿐이지만, 식별자는 운영자 입력이다. */
|
||||||
|
function sql_quote(string $s): string
|
||||||
|
{
|
||||||
|
return "'" . str_replace(["\\", "'"], ["\\\\", "''"], $s) . "'";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($device !== null) {
|
||||||
|
if (!is_string($device) || !preg_match('/^[A-Za-z0-9._-]{1,64}$/', $device)) {
|
||||||
|
fwrite(STDERR, "device_id 형식이 올바르지 않습니다(영숫자 . _ - , 64자 이내): {$device}\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
$secret = bin2hex(random_bytes(DEVICE_SECRET_BYTES));
|
||||||
|
$secret_q = sql_quote($secret);
|
||||||
|
$q = sql_quote($device);
|
||||||
|
|
||||||
|
echo <<<TXT
|
||||||
|
=== 1) 서버 (phpMyAdmin 에서 실행) ===
|
||||||
|
|
||||||
|
UPDATE move_devices SET secret = {$secret_q} WHERE device_id = {$q};
|
||||||
|
|
||||||
|
=== 2) Pi (SSH) ===
|
||||||
|
|
||||||
|
sudo sed -i 's|^DEVICE_SECRET=.*|DEVICE_SECRET={$secret}|' /etc/move-detect/edge.env
|
||||||
|
sudo systemctl restart move-detect-edge
|
||||||
|
|
||||||
|
=== 3) 확인 ===
|
||||||
|
|
||||||
|
sudo journalctl -u move-detect-edge -n 30 --no-pager
|
||||||
|
401/403 이 없고 하트비트가 올라오면 성공. 서버만 바꾸고 Pi 를 안 바꾸면 그 장치의
|
||||||
|
이벤트는 전부 거부되고, 대시보드에는 device_fault 로 나타난다 — 둘을 같이 하십시오.
|
||||||
|
|
||||||
|
TXT;
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- admin 비밀번호 ---
|
||||||
|
// 해시는 App\Security\Password 를 그대로 쓴다 — 여기서 password_hash 를 다시 부르면 앱이
|
||||||
|
// 알고리즘·옵션을 바꾼 날 조용히 갈라진다. config.php 는 필요 없으므로(DB 미접속) tool_boot
|
||||||
|
// 대신 오토로더만 올린다. tools/ 는 저장소와 납품 패키지 양쪽에서 vendor/ 와 형제다.
|
||||||
|
$autoload = dirname(__DIR__) . '/vendor/autoload.php';
|
||||||
|
if (!is_file($autoload)) {
|
||||||
|
fwrite(STDERR, "vendor/autoload.php 를 찾지 못했습니다: {$autoload}\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
require_once $autoload;
|
||||||
|
|
||||||
|
if (!defined('PASSWORD_ARGON2ID') || !in_array('argon2id', password_algos(), true)) {
|
||||||
|
fwrite(STDERR, "이 PHP 에는 Argon2id 가 없습니다. 납품 대상과 같은 PHP 에서 실행하십시오"
|
||||||
|
. " (server/deploy/preflight.php 가 같은 것을 검사합니다).\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
if (!is_string($admin) || $admin === '' || strlen($admin) > 190) {
|
||||||
|
fwrite(STDERR, "username 이 비었거나 너무 깁니다.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite(STDERR, "새 비밀번호 (화면에 표시되지 않음, " . MIN_PASSWORD_LEN . "자 이상): ");
|
||||||
|
$plain = read_secret_line();
|
||||||
|
fwrite(STDERR, "\n한 번 더: ");
|
||||||
|
$again = read_secret_line();
|
||||||
|
fwrite(STDERR, "\n");
|
||||||
|
|
||||||
|
if ($plain !== $again) {
|
||||||
|
fwrite(STDERR, "두 입력이 다릅니다. 중단합니다.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
if (strlen($plain) < MIN_PASSWORD_LEN) {
|
||||||
|
fwrite(STDERR, "비밀번호가 " . MIN_PASSWORD_LEN . "자 미만입니다. 중단합니다.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$hash = Password::hash($plain);
|
||||||
|
$plain = $again = ''; // 이 프로세스가 더 들고 있을 이유가 없다
|
||||||
|
$q = sql_quote($admin);
|
||||||
|
$h = sql_quote($hash);
|
||||||
|
|
||||||
|
echo <<<TXT
|
||||||
|
=== phpMyAdmin 에서 실행 ===
|
||||||
|
|
||||||
|
UPDATE move_users SET password_hash = {$h}, failed_attempts = 0, locked_until = 0
|
||||||
|
WHERE username = {$q};
|
||||||
|
|
||||||
|
실행 후 "영향받은 행: 1" 인지 확인하십시오. 0 이면 username 이 틀린 것이고, 비밀번호는
|
||||||
|
바뀌지 않은 채 바뀐 줄 알게 됩니다. 바로 로그아웃 후 새 비밀번호로 로그인해 확인하십시오.
|
||||||
|
|
||||||
|
TXT;
|
||||||
|
exit(0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 한 줄 읽는다. 터미널이면 에코를 꺼서 화면에 안 남긴다.
|
||||||
|
*
|
||||||
|
* 파이프로 넣은 경우(테스트·스크립트)는 애초에 보여줄 화면이 없으므로 stty 를 부르지 않는다 —
|
||||||
|
* Windows 에서 stty 를 부르면 셸이 못 찾았다는 잡음만 STDOUT 으로 새어 나온다. 터미널인데도
|
||||||
|
* 에코를 못 끈 경우에만 경고한다(숨김 실패 ≠ 입력 실패).
|
||||||
|
*/
|
||||||
|
function read_secret_line(): string
|
||||||
|
{
|
||||||
|
$tty = function_exists('stream_isatty') && @stream_isatty(STDIN)
|
||||||
|
&& strncasecmp(PHP_OS_FAMILY, 'Windows', 7) !== 0;
|
||||||
|
$hidden = false;
|
||||||
|
if ($tty) {
|
||||||
|
$hidden = trim((string)@shell_exec('stty -echo 2>/dev/null && echo ok')) === 'ok';
|
||||||
|
if (!$hidden) {
|
||||||
|
fwrite(STDERR, "\n (경고: 이 터미널에서는 입력을 숨길 수 없었습니다. 화면과 히스토리를 확인하십시오)\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$line = fgets(STDIN);
|
||||||
|
if ($hidden) {
|
||||||
|
@shell_exec('stty echo 2>/dev/null');
|
||||||
|
}
|
||||||
|
if ($line === false) {
|
||||||
|
fwrite(STDERR, "입력을 읽지 못했습니다.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
return rtrim($line, "\r\n");
|
||||||
|
}
|
||||||
|
|
@ -26,3 +26,22 @@ def test_real_movenet_runs():
|
||||||
assert isinstance(persons, list)
|
assert isinstance(persons, list)
|
||||||
for person in persons:
|
for person in persons:
|
||||||
assert len(person.keypoints) == 17
|
assert len(person.keypoints) == 17
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not os.path.exists(MODEL), reason="MoveNet model not bundled")
|
||||||
|
def test_model_signature_is_what_the_adapter_feeds_and_reads():
|
||||||
|
"""모델을 갈아끼웠을 때 조용히 틀리는 경우를 막는다.
|
||||||
|
|
||||||
|
레이아웃이 다른 변환본(NCHW·float32·[1,17,3])도 onnxruntime 에는 그냥 로드된다. 그러면
|
||||||
|
어댑터는 예외 없이 돌면서 엉뚱한 좌표를 내놓고, 낙상 판정은 "동작하는데 안 맞는" 상태가
|
||||||
|
된다. 파일이 바뀌었는지가 아니라 **계약이 맞는지**를 본다.
|
||||||
|
"""
|
||||||
|
ort = pytest.importorskip("onnxruntime")
|
||||||
|
session = ort.InferenceSession(MODEL, providers=["CPUExecutionProvider"])
|
||||||
|
|
||||||
|
inp = session.get_inputs()[0]
|
||||||
|
assert inp.shape == [1, 192, 192, 3], f"어댑터는 192x192 NHWC 를 넣는다: {inp.shape}"
|
||||||
|
assert inp.type == "tensor(int32)", f"어댑터는 int32 로 넣는다: {inp.type}"
|
||||||
|
|
||||||
|
out = session.get_outputs()[0]
|
||||||
|
assert out.shape == [1, 1, 17, 3], f"어댑터는 [1,1,17,3] (y,x,score) 를 읽는다: {out.shape}"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue