diff --git a/docs/pilot-runbook.md b/docs/pilot-runbook.md new file mode 100644 index 0000000..b6c21cc --- /dev/null +++ b/docs/pilot-runbook.md @@ -0,0 +1,112 @@ +# P1 파일럿 런북 (노트북 웹캠 엔드투엔드) + +이 문서는 노트북 웹캠 하나로 **엣지 → 클라우드 → 관리자 응답**의 최소 경로를 돌리는 절차다. +실장비(Hailo·Pi5)·폐쇄망 전체 번들·드릴 게이트·SMS 연동은 후속(범위 밖, §6). + +- 엣지: `python -m edge` (또는 `move-detect-edge`) — CPU MoveNet pose → 감지 → HMAC 서명 POST. +- 클라우드: 기존 `cloud/` PHP 앱 — ingest + 대시보드 + 사고 상세 + 데스크 Web Audio 알림. + +--- + +## 1. 빌드 머신 준비 (모델 · onnxruntime) + +> **폐쇄망 원칙:** onnxruntime 휠과 `.onnx` 모델은 **빌드 머신에서 받아 번들**한다. 타깃(폐쇄망)에서 +> 인터넷을 받지 않는다. 노트북 파일럿은 빌드=타깃이 같은 머신이라 아래를 그대로 실행하면 된다. + +```bash +pip install onnxruntime # CPU 추론 런타임 (에어갭 타깃엔 오프라인 휠로 번들) +python -m edge.tools.fetch_movenet # MoveNet SinglePose Lightning ONNX -> models/movenet_lightning.onnx +``` + +`fetch_movenet.py`의 URL은 빌드 편의용이다. 실제 내려받은 모델의 입력 크기/자료형과 출력 레이아웃 +`[1,1,17,3]`(y,x,score)를 어댑터 기대값과 대조하라(다르면 `MoveNetPoseAdapter(input_size=...)` 조정; +COCO-17 remap 로직은 그대로). 어댑터 remap은 `tests/test_movenet_pose.py`(fake session)로 고정돼 있고, +실모델 경로는 `tests/hardware/test_movenet_smoke.py`가 모델이 번들됐을 때만 게이트로 검증한다. + +--- + +## 2. 클라우드 로컬 기동 + +로컬 MySQL 8(스키마 CHECK 제약은 8.0.16+)과 PHP 8.2+ 필요. 상세 배포·전제조건(gd/argon2/HTTPS 등)은 +[cloud/docs/cloud-deploy.md](../cloud/docs/cloud-deploy.md) 참조 — 여기선 로컬 최소 절차만. + +1. DB 생성 후 스키마 임포트: `mysql < cloud/schema.sql` (10개 테이블). +2. `cloud/config.php`를 `cloud/config.example.php`에서 복사, 로컬 DB DSN/계정 기입 + (`snapshot_dir`는 쓰기 가능 경로로). +3. **관리자 계정 · 디바이스 시크릿 등록** — 앱에 셀프 가입 흐름이 없으므로 직접 넣는다. + CLI 스니펫과 SQL은 cloud-deploy.md **§6(디바이스 시크릿)** · **§7(첫 관리자)** 그대로 사용: + - 시크릿 생성: `php -r "echo bin2hex(random_bytes(32)), \"\n\";"` + - `INSERT INTO devices (device_id, name, secret, config_version) VALUES ('pi-01','노트북','<시크릿>',1);` + - 관리자 해시: `php -r "echo password_hash('바꿀-암구호', PASSWORD_ARGON2ID), \"\n\";"` + - `INSERT INTO users (username, password_hash, role, ...) VALUES ('admin','<해시>','admin',NULL,0,0);` +4. 기동: `php -S localhost:8000 -t cloud/public` + - 세션 쿠키가 `cookie_secure`라 순수 HTTP에선 로그인 쿠키가 안 붙을 수 있다. 로컬은 대개 + `localhost`가 secure-context로 취급돼 동작하지만, 안 되면 cloud-deploy.md §8(HTTPS) 참조. + +--- + +## 3. 엣지 환경변수 + +`edge/config.py`(`load_config`) + `edge/daemon.py`(`main`)가 읽는 실제 키: + +| env | 의미 | 예시 | +|-----|------|------| +| `DEVICE_ID` | `devices.device_id`와 일치 | `pi-01` | +| `DEVICE_SECRET` | `devices.secret`와 일치(HMAC) | `<§2에서 생성한 값>` | +| `CAFE24_URL` | 클라우드 베이스 URL | `http://localhost:8000` | +| `CAMERA_ID` | 이벤트/구역의 camera_id | `cam-01` | +| `MOVENET_ONNX` | 모델 경로 **(필수, 없으면 즉시 KeyError)** | `models/movenet_lightning.onnx` | +| `UVC_DEVICE` | OpenCV 캡처 인덱스 | `0` | +| `TARGET_FPS` | 프레임 간격 = 1/fps | `12` | +| `QUEUE_DB` | 오프라인 큐 sqlite 경로 | `queue.db` | + +감지 임계값(선택, 데모 단축용): `FALL_PRONE_S`(기본 3.0), `FALL_NO_MOTION_S`(10.0), +`IMMOBILE_S`(120.0), `GHOST_REAPPEAR_S`(10.0), `ZONE_DEBOUNCE_S`(0.75), `COOLDOWN_S`(30.0). + +```bash +export DEVICE_ID=pi-01 DEVICE_SECRET='<시크릿>' CAFE24_URL=http://localhost:8000 \ + CAMERA_ID=cam-01 MOVENET_ONNX=models/movenet_lightning.onnx UVC_DEVICE=0 TARGET_FPS=12 +python -m edge # 또는 move-detect-edge +``` + +--- + +## 4. 데모 시나리오 + +1. **낙상:** 웹캠 앞에서 눕는다 → `FALL_PRONE_S`(기본 3초) 후 `fall`(high) 이벤트. +2. **위급 상승:** 계속 안 움직이면 `FALL_NO_MOTION_S`(기본 10초) 후 `no_motion`(critical) + → 대시보드에서 **데스크 비프 반복**(첫 클릭으로 오디오 언락 필요). +3. **가시화:** 목록의 이벤트 유형(링크) 클릭 → `/events/{id}/view` 상세 → **스냅샷 이미지** 확인. +4. **응답:** 상세/목록에서 **확인** → 미확인 critical이 사라지면 비프 정지. +5. **역루프(구역):** 대시보드 `구역 설정`에서 폴리곤 저장 → 발끝 진입 → `zone_*` 이벤트. + 구역 변경은 엣지의 **다음 config 폴링** 때 적용된다 — 기본 cadence `sync_every=150` 프레임 + ≈ 12fps에서 약 12초. 저장 후 ~15초 기다렸다가 진입할 것. +6. **오프라인 큐:** 네트워크(또는 `php -S`) 중단 상태에서 이벤트 발생 → 큐에 적재 → + 복구 후 `flush_every=300` 프레임(≈25초)마다 flush로 밀린 이벤트 전송. +7. **하트비트:** `heartbeat_every=900` 프레임(≈75초)마다 `POST /api/heartbeat`(서명). + +> cadence(`sync_every`/`heartbeat_every`/`flush_every`)는 `daemon.main()` 기본값이며 env로 +> 노출돼 있지 않다. 데모에서 더 빠른 주기가 필요하면 `run_loop(...)` 호출 인자를 임시 조정한다. + +--- + +## 5. 검증(테스트) + +```bash +pytest -q # 엣지 유닛(하드웨어 스모크는 모델 없으면 skip) +cd cloud && ./vendor/bin/phpunit --display-warnings --display-deprecations # 클라우드(0 warnings) +``` + +모델을 번들한 뒤 실추론 경로까지 확인하려면 `models/movenet_lightning.onnx` 존재 상태로 `pytest -q` — +`tests/hardware/test_movenet_smoke.py`가 skip 해제돼 실행된다. + +--- + +## 6. 파일럿 한계 (명시) + +- **단일 카메라 · CPU pose(저 fps)** — 실장비 Hailo 가속은 후속. +- **단일 프레임 스냅샷** — 前中後 링버퍼 다중 프레임은 P2. +- **알림 1채널(데스크 Web Audio)** — critical SMS/webhook 실연동은 후속(게이트 정보 필요). +- **폐쇄망 전체 번들 아님** — onnxruntime/모델 fetch만 제공. 오프라인 휠·차단 설치·`docker save` + 패키징은 별도 후속 태스크. +- **드릴 게이트 · F14 컴플라이언스 · 감사 해시체인** — 범위 밖(P1-C/P3). diff --git a/tests/hardware/test_movenet_smoke.py b/tests/hardware/test_movenet_smoke.py new file mode 100644 index 0000000..f7ec467 --- /dev/null +++ b/tests/hardware/test_movenet_smoke.py @@ -0,0 +1,28 @@ +"""Gated end-to-end smoke test for the real MoveNet ONNX model. + +Skipped unless the model is bundled AND onnxruntime is installed, so the default +CI/dev suite (no model, no onnxruntime) stays green. On a build/pilot machine that +ran ``python -m edge.tools.fetch_movenet``, this exercises the full adapter path: +frame -> onnxruntime inference -> COCO-17 remap -> list[Person]. +""" +import os + +import numpy as np +import pytest + +MODEL = os.path.join(os.path.dirname(__file__), "..", "..", "models", "movenet_lightning.onnx") + + +@pytest.mark.skipif(not os.path.exists(MODEL), reason="MoveNet model not bundled") +def test_real_movenet_runs(): + pytest.importorskip("onnxruntime") + from edge.adapters.movenet_pose import MoveNetPoseAdapter + + adapter = MoveNetPoseAdapter(MODEL) + persons = adapter.infer(np.full((480, 640, 3), 128, np.uint8)) + + # Runs end-to-end; a flat gray frame may yield 0 persons — the contract is + # only that it returns a list and, when non-empty, each Person has 17 keypoints. + assert isinstance(persons, list) + for person in persons: + assert len(person.keypoints) == 17