move_detect/docs/superpowers/plans/2026-07-12-p1-pilot-integration.md
유창욱 e1f9f91110 docs: P1 pilot integration plan (wire edge daemon + CPU pose + detail view)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne4rU4FToTLHcBUMUvUnT1
2026-07-12 10:35:21 +09:00

772 lines
34 KiB
Markdown

# P1 파일럿 통합 (Pilot Integration) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 이미 구현·병합된 P1-A(엣지 감지 라이브러리) + P1-B(클라우드) 사이의 **끊긴 배선을 연결**해, 노트북 웹캠으로 실행되는 **엔드투엔드 파일럿**을 만든다: 실제 프레임 → CPU pose → 감지 → 서명 POST → 클라우드 → 관리자가 **사진 보고 알림 받고 확인**. 역루프(대시보드 구역 → 엣지 적용)와 하트비트도 살린다. (실장비 Hailo·폐쇄망 번들·드릴 게이트는 별도 후속.)
**Architecture:** 순수 배선. 기존 부품(`run_once`, EventEngine, PostClient, OfflineQueue, ZoneIntrusionDetector, 클라우드 ingest/detail/snapshot)은 그대로 두고 (1) 엣지 데몬(main 루프)이 부품을 조립·구동, (2) CloudClient가 config 폴링·하트비트, (3) MoveNet ONNX 어댑터가 PosePort 구현, (4) 클라우드 detail 뷰·Web Audio가 응답루프 완성. 모든 데몬/클라이언트 로직은 주입(fake source/pose/clock/http)으로 TDD; 실 pose는 정적이미지로 게이트 테스트.
**Tech Stack:** Python 3.11+(dev 3.13), pytest, numpy, opencv, requests, **onnxruntime**(CPU) + **MoveNet SinglePose Lightning ONNX**(COCO-17, Apache-2). 클라우드는 기존 바닐라 PHP + JS(Web Audio, 의존성 0). 런타임 ultralytics(AGPL) 금지 유지.
## Global Constraints
- Python **3.11+**, 타입힌트, `edge/` 기존 패턴 준수. PHP 8.5 `declare(strict_types=1)`, 클라우드 스위트 **0 warnings/0 deprecations** 유지.
- **판정/루프 로직은 주입 타임스탬프·주입 의존성으로 TDD** — 데몬 루프 내부에서 `time.sleep`/`time.time` 직접 호출 금지(주입된 clock/sleeper 사용). 실 pose 추론만 게이트(모델/onnxruntime 없으면 `skip`).
- **폐쇄망 지속:** 새 의존성(onnxruntime)·모델(.onnx)은 **빌드머신서 받아 번들**할 것 — 후속 패키징 태스크 대상. 런타임 인터넷은 Cafe24 POST + config 폴링뿐.
- **HMAC 계약 유지:** config 폴링·하트비트도 P1-A `sign(nonce+ts+body)` 동일 서명(클라우드 T14가 이미 검증). PostClient 서명 재사용.
- **키포인트 계약:** PosePort는 `list[Person]` (COCO-17 `Keypoint`) 반환 — MoveNet 17관절을 그 순서로 매핑.
- YAGNI: 파일럿에 필요한 배선만. 멀티캠·감도·처리결과 등 P2/P3는 손대지 않음.
## File Structure
```
edge/
cloudclient.py # CloudClient: fetch_config(HMAC GET), send_heartbeat(HMAC POST)
zones.py # zone rows(dict) -> Zone objects; thresholds
runtime.py # PipelineRuntime: assemble source/pose/tracker/smoother/engine/ring/snapshotter/sink; step(); apply_zones()
daemon.py # main loop: step every frame + periodic sync/heartbeat/queue-flush; signals; main()
__main__.py # `python -m edge` -> daemon.main()
adapters/
movenet_pose.py # MoveNetPoseAdapter(PosePort) via onnxruntime + COCO-17 remap
tools/
fetch_movenet.py # build-machine: download MoveNet Lightning ONNX to models/
cloud/
src/View/event_detail.php # (replace stub) HTML detail shell + script
public/assets/detail.js # fetch GET /events/{id} -> render meta + <img src=/snap/{uuid}>
public/assets/app.js # (edit) link rows to /events/{id}/view, desk Web Audio on new critical
public/index.php # (edit) add GET /events/{id}/view HTML route
tests/ (edge) + cloud/tests/
docs/pilot-runbook.md
```
---
## Task 1: CloudClient — config 폴링 + 하트비트 (HMAC 서명)
**Files:** Create `edge/cloudclient.py`, `tests/test_cloudclient.py`
**Interfaces:**
- Consumes: `sign` (edge/alert.py), injected `http_get`/`http_post`, `nonce_fn`, `now_fn`.
- Produces:
- `CloudClient(base_url, device_id, secret, nonce_fn, now_fn, http_get=None, http_post=None, timeout=5.0)`
- `fetch_config() -> dict | None` — GET `{base}/api/config?device_id=`, headers X-Nonce/X-Timestamp/X-Signature (sign over empty body), returns parsed `{version, zones, thresholds}` on 200 else None.
- `send_heartbeat(metrics: dict) -> bool` — POST `{base}/api/heartbeat` body `{device_id, **metrics}` signed, True on 2xx.
- [ ] **Step 1: 실패 테스트** — `tests/test_cloudclient.py`
```python
import json
from edge.cloudclient import CloudClient
from edge.alert import sign
class _Resp:
def __init__(self, code, body=""): self.status_code = code; self.text = body
def json(self): return json.loads(self.text)
def test_fetch_config_signs_and_parses():
captured = {}
def fake_get(url, headers=None, timeout=None):
captured["url"] = url; captured["headers"] = headers
return _Resp(200, json.dumps({"version": 3, "zones": [], "thresholds": {}}))
c = CloudClient("https://x", "pi-1", "sek", nonce_fn=lambda: "n1", now_fn=lambda: 1000.0, http_get=fake_get)
cfg = c.fetch_config()
assert cfg["version"] == 3
assert "device_id=pi-1" in captured["url"]
assert captured["headers"]["X-Signature"] == sign("sek", "n1", "1000.0", "")
def test_fetch_config_none_on_error():
c = CloudClient("https://x", "pi-1", "sek", lambda: "n", lambda: 1.0, http_get=lambda *a, **k: _Resp(401))
assert c.fetch_config() is None
def test_send_heartbeat_signs_body():
captured = {}
def fake_post(url, data=None, headers=None, timeout=None):
captured["data"] = data; captured["headers"] = headers; return _Resp(200)
c = CloudClient("https://x", "pi-1", "sek", lambda: "n1", lambda: 1000.0, http_post=fake_post)
assert c.send_heartbeat({"fps": 12.0}) is True
body = captured["data"]
assert json.loads(body)["device_id"] == "pi-1"
assert captured["headers"]["X-Signature"] == sign("sek", "n1", "1000.0", body)
```
- [ ] **Step 2: 실패 확인** — `pytest tests/test_cloudclient.py -v` → FAIL (ModuleNotFoundError)
- [ ] **Step 3: 구현** — `edge/cloudclient.py`
```python
import json
from edge.alert import sign
class CloudClient:
def __init__(self, base_url: str, device_id: str, secret: str,
nonce_fn, now_fn, http_get=None, http_post=None, timeout: float = 5.0):
self.base_url = base_url.rstrip("/")
self.device_id = device_id
self.secret = secret
self.nonce_fn = nonce_fn
self.now_fn = now_fn
self.timeout = timeout
if http_get is None or http_post is None:
import requests
http_get = http_get or requests.get
http_post = http_post or requests.post
self._get = http_get
self._post = http_post
def _headers(self, body: str) -> dict:
nonce = self.nonce_fn()
ts = str(self.now_fn())
return {"X-Nonce": nonce, "X-Timestamp": ts,
"X-Signature": sign(self.secret, nonce, ts, body)}
def fetch_config(self) -> dict | None:
url = f"{self.base_url}/api/config?device_id={self.device_id}"
try:
resp = self._get(url, headers=self._headers(""), timeout=self.timeout)
except Exception:
return None
if 200 <= resp.status_code < 300:
try:
return resp.json()
except Exception:
return None
return None
def send_heartbeat(self, metrics: dict) -> bool:
payload = {"device_id": self.device_id, **metrics}
body = json.dumps(payload, sort_keys=True)
headers = {"Content-Type": "application/json", **self._headers(body)}
try:
resp = self._post(f"{self.base_url}/api/heartbeat", data=body,
headers=headers, timeout=self.timeout)
except Exception:
return False
return 200 <= resp.status_code < 300
```
- [ ] **Step 4: 통과 + 커밋** — `pytest tests/test_cloudclient.py -v` → PASS
```bash
git add edge/cloudclient.py tests/test_cloudclient.py
git commit -m "feat(edge): CloudClient for config poll + heartbeat (HMAC-signed)"
```
---
## Task 2: 클라우드 zone 행 → Zone 객체 로딩
**Files:** Create `edge/zones.py`, `tests/test_zones.py`
**Interfaces:**
- Consumes: `Zone` (edge/detectors/zone.py)
- Produces:
- `zones_from_config(config: dict) -> list[Zone]` — `config["zones"]` 각 행(`{zone_id, camera_id, level, polygon_json}`)을 `Zone(zone_id, level, polygon)`으로. `polygon_json`은 JSON 문자열(또는 이미 list) → `[(x,y),...]`. 파싱 실패/3점미만 행은 건너뜀.
- `thresholds_from_config(config: dict) -> dict` — `config["thresholds"]` 그대로 반환({}).
- [ ] **Step 1: 실패 테스트** — `tests/test_zones.py`
```python
from edge.zones import zones_from_config, thresholds_from_config
def test_parses_zone_rows_json_string_polygon():
cfg = {"zones": [
{"zone_id": "z1", "camera_id": "cam-1", "level": "restricted",
"polygon_json": "[[0,0],[9,0],[9,9]]"},
], "thresholds": {"fall_prone_s": 3.0}}
zones = zones_from_config(cfg)
assert len(zones) == 1
assert zones[0].zone_id == "z1"
assert zones[0].level == "restricted"
assert zones[0].polygon == [(0.0, 0.0), (9.0, 0.0), (9.0, 9.0)]
assert thresholds_from_config(cfg)["fall_prone_s"] == 3.0
def test_skips_malformed_row():
cfg = {"zones": [{"zone_id": "bad"}], "thresholds": {}}
assert zones_from_config(cfg) == []
def test_empty_config():
assert zones_from_config({}) == []
assert thresholds_from_config({}) == {}
```
- [ ] **Step 2: 실패 확인** → FAIL
- [ ] **Step 3: 구현** — `edge/zones.py`
```python
import json
from edge.detectors.zone import Zone
def zones_from_config(config: dict) -> list[Zone]:
out: list[Zone] = []
for row in config.get("zones", []) or []:
try:
raw = row["polygon_json"]
pts = json.loads(raw) if isinstance(raw, str) else raw
polygon = [(float(p[0]), float(p[1])) for p in pts]
if len(polygon) < 3:
continue
out.append(Zone(str(row["zone_id"]), str(row["level"]), polygon))
except (KeyError, ValueError, TypeError, json.JSONDecodeError):
continue
return out
def thresholds_from_config(config: dict) -> dict:
t = config.get("thresholds")
return t if isinstance(t, dict) else {}
```
- [ ] **Step 4: 통과 + 커밋**
```bash
git add edge/zones.py tests/test_zones.py
git commit -m "feat(edge): parse cloud config zones/thresholds into Zone objects"
```
---
## Task 3: PipelineRuntime — 부품 조립 + zone 핫스왑
**Files:** Create `edge/runtime.py`, `tests/test_runtime.py`
**Interfaces:**
- Consumes: `run_once` (edge/app.py), `IouTracker`, `TrackSmootherPool`, `EventEngine`+detectors, `RingBuffer`/`Snapshotter`/`FaceBlurrer`, `Config` (edge/config.py), `Zone`.
- Produces:
- `PipelineRuntime(config, source, pose, sink)` — 내부에 tracker/pool/engine/ring/snapshotter 구성(ZoneIntrusionDetector는 `zone_detector` 속성으로 보관).
- `step(now: float) -> list` — 한 프레임 처리(`run_once` 호출), 이벤트 반환.
- `apply_zones(zones: list[Zone]) -> None` — 러닝 중 ZoneIntrusionDetector의 zone 목록 교체(역루프).
- [ ] **Step 1: 실패 테스트** — `tests/test_runtime.py`
```python
import numpy as np
from edge.config import load_config
from edge.models import Person, BBox, Keypoint
from edge.detectors.zone import Zone
from edge.runtime import PipelineRuntime
def _lying_person():
kps = [Keypoint(150, 300, 0.9) for _ in range(17)]
kps[5] = Keypoint(100, 300, 0.9); kps[6] = Keypoint(100, 310, 0.9)
kps[11] = Keypoint(200, 300, 0.9); kps[12] = Keypoint(200, 310, 0.9)
kps[0] = Keypoint(90, 320, 0.9)
return Person(BBox(90, 290, 210, 320), kps)
class _Source:
def read(self): return True, np.full((360, 640, 3), 128, np.uint8)
class _Pose:
def infer(self, frame): return [_lying_person()]
class _Sink:
def __init__(self): self.events = []
def try_send(self, e, snaps): self.events.append(e); return True
def test_step_runs_pipeline_and_emits_fall():
cfg = load_config({"FALL_PRONE_S": "0.5"})
sink = _Sink()
rt = PipelineRuntime(cfg, _Source(), _Pose(), sink)
rt.step(now=0.0)
rt.step(now=0.6)
from edge.events import EventType
assert any(e.event_type == EventType.FALL for e in sink.events)
def test_apply_zones_hotswaps():
cfg = load_config({})
rt = PipelineRuntime(cfg, _Source(), _Pose(), _Sink())
rt.apply_zones([Zone("z1", "restricted", [(0, 0), (9, 0), (9, 9)])])
assert rt.zone_detector.zones[0].zone_id == "z1"
```
- [ ] **Step 2: 실패 확인** → FAIL
- [ ] **Step 3: 구현** — `edge/runtime.py`
```python
from edge.app import run_once
from edge.tracking import IouTracker
from edge.smoothing import TrackSmootherPool
from edge.detectors.fall import FallDetector
from edge.detectors.immobility import ImmobilityWatchdog
from edge.detectors.ghost import GhostIncidentDetector
from edge.detectors.zone import ZoneIntrusionDetector
from edge.engine import EventEngine
from edge.snapshot import RingBuffer, FaceBlurrer, Snapshotter
class PipelineRuntime:
def __init__(self, config, source, pose, sink):
self.config = config
self.source = source
self.pose = pose
self.sink = sink
self.tracker = IouTracker()
self.pool = TrackSmootherPool()
self.zone_detector = ZoneIntrusionDetector([], config.zone)
self.engine = EventEngine(
FallDetector(config.fall),
ImmobilityWatchdog(config.immobility),
GhostIncidentDetector(config.ghost),
self.zone_detector,
config.engine,
)
self.ring = RingBuffer()
self.snapshotter = Snapshotter(FaceBlurrer())
def step(self, now: float) -> list:
return run_once(self.source, self.pose, self.tracker, self.pool,
self.engine, self.ring, self.snapshotter, self.sink,
now_fn=lambda: now)
def apply_zones(self, zones: list) -> None:
self.zone_detector.zones = list(zones)
```
> **NOTE (implementer):** `ZoneIntrusionDetector.zones` is a public list (see edge/detectors/zone.py) — reassigning it hot-swaps the active zones without rebuilding the engine. Confirm the attribute name is `zones`. Also confirm `run_once`'s parameter order is `(source, pose, tracker, pool, engine, ring, snapshotter, sink, now_fn)` (P1-A final form after the per-track-smoother fix).
- [ ] **Step 4: 통과 + 커밋**
```bash
git add edge/runtime.py tests/test_runtime.py
git commit -m "feat(edge): PipelineRuntime assembles components + hot-swaps zones"
```
---
## Task 4: 데몬 — main 루프 + 주기 동기/하트비트/큐flush
**Files:** Create `edge/daemon.py`, `edge/__main__.py`, `tests/test_daemon.py`; Modify `pyproject.toml`
**Interfaces:**
- Consumes: `PipelineRuntime` (T3), `CloudClient` (T1), `zones_from_config` (T2), `OfflineQueue` (edge/queue.py), `PostClient` (edge/alert.py).
- Produces:
- `run_loop(runtime, client, queue, *, clock, sleep, iterations=None, sync_every=150, heartbeat_every=900, flush_every=300, frame_interval=0.0) -> dict` — `iterations` 프레임 루프(None이면 무한, 테스트는 유한). 매 프레임 `runtime.step(clock())`(예외 삼켜 카운트); sync_every 프레임마다 `client.fetch_config()`→`runtime.apply_zones(zones_from_config(...))`; heartbeat_every마다 `send_heartbeat`; flush_every마다 `queue.flush()`. 반환 카운터 dict.
- `main()` — env로 구성 후 무한 루프. `edge/__main__.py`가 호출. (`# pragma: no cover` — 실장비 엔트리)
- [ ] **Step 1: 실패 테스트** — `tests/test_daemon.py`
```python
from edge.daemon import run_loop
class _Runtime:
def __init__(self): self.steps = 0; self.applied = None
def step(self, now): self.steps += 1; return []
def apply_zones(self, zones): self.applied = zones
class _Client:
def __init__(self): self.configs = 0; self.beats = 0
def fetch_config(self): self.configs += 1; return {"zones": [], "thresholds": {}}
def send_heartbeat(self, m): self.beats += 1; return True
class _Queue:
def __init__(self): self.flushes = 0
def flush(self): self.flushes += 1; return 0
def test_loop_steps_and_periodics():
rt, cl, q = _Runtime(), _Client(), _Queue()
ticks = iter(range(1000))
counters = run_loop(rt, cl, q, clock=lambda: float(next(ticks)),
sleep=lambda s: None, iterations=10,
sync_every=5, heartbeat_every=5, flush_every=5)
assert rt.steps == 10
assert cl.configs == 2 and cl.beats == 2 and q.flushes == 2 # at frames 5 and 10
assert rt.applied == [] # zones applied from fetched config
def test_loop_survives_step_exception():
class Boom(_Runtime):
def step(self, now):
self.steps += 1
if self.steps == 2: raise RuntimeError("frame glitch")
return []
rt = Boom()
counters = run_loop(rt, _Client(), _Queue(), clock=lambda: 0.0,
sleep=lambda s: None, iterations=3,
sync_every=99, heartbeat_every=99, flush_every=99)
assert rt.steps == 3 # kept going past the exception
assert counters["errors"] == 1
```
- [ ] **Step 2: 실패 확인** → FAIL
- [ ] **Step 3: 구현** — `edge/daemon.py`
```python
import os
def run_loop(runtime, client, queue, *, clock, sleep, iterations=None,
sync_every=150, heartbeat_every=900, flush_every=300,
frame_interval=0.0):
counters = {"frames": 0, "syncs": 0, "beats": 0, "flushes": 0, "errors": 0}
i = 0
while iterations is None or i < iterations:
i += 1
try:
runtime.step(clock())
counters["frames"] += 1
except Exception:
counters["errors"] += 1
if sync_every and i % sync_every == 0:
cfg = client.fetch_config()
if cfg is not None:
from edge.zones import zones_from_config
runtime.apply_zones(zones_from_config(cfg))
counters["syncs"] += 1
if heartbeat_every and i % heartbeat_every == 0:
if client.send_heartbeat({"fps": 0.0, "frames": counters["frames"]}):
counters["beats"] += 1
if flush_every and i % flush_every == 0:
queue.flush()
counters["flushes"] += 1
if frame_interval:
sleep(frame_interval)
return counters
def main() -> None: # pragma: no cover (integration entrypoint, exercised on device)
import time
from edge.config import load_config
from edge.cloudclient import CloudClient
from edge.alert import PostClient
from edge.queue import OfflineQueue
from edge.runtime import PipelineRuntime
from edge.adapters.uvc_source import UvcSource
from edge.adapters.movenet_pose import MoveNetPoseAdapter
cfg = load_config(dict(os.environ))
nonce = lambda: os.urandom(8).hex()
now = time.time
base = cfg.cafe24_url.rstrip("/")
client = CloudClient(base, cfg.device_id, cfg.secret, nonce, now)
post = PostClient(base + "/api/events", cfg.device_id, cfg.secret, nonce, now)
queue = OfflineQueue(os.environ.get("QUEUE_DB", "queue.db"), post)
source = UvcSource(int(os.environ.get("UVC_DEVICE", "0")))
pose = MoveNetPoseAdapter(os.environ["MOVENET_ONNX"])
runtime = PipelineRuntime(cfg, source, pose, queue)
run_loop(runtime, client, queue, clock=now, sleep=time.sleep, iterations=None,
frame_interval=1.0 / float(os.environ.get("TARGET_FPS", "12")))
```
`edge/__main__.py`:
```python
from edge.daemon import main
if __name__ == "__main__":
main()
```
- [ ] **Step 4: pyproject.toml에 스크립트 엔트리 추가**
```toml
[project.scripts]
move-detect-edge = "edge.daemon:main"
```
- [ ] **Step 5: 통과 + 커밋** — `pytest tests/test_daemon.py -v` → PASS; 전체 `pytest -q`
```bash
git add edge/daemon.py edge/__main__.py tests/test_daemon.py pyproject.toml
git commit -m "feat(edge): runnable daemon loop (step + periodic sync/heartbeat/flush)"
```
---
## Task 5: MoveNet CPU pose 어댑터 (실 pose 경로)
**Files:** Create `edge/adapters/movenet_pose.py`, `edge/tools/fetch_movenet.py`, `tests/test_movenet_pose.py`
**Interfaces:**
- Consumes: `Person, BBox, Keypoint` (edge/models.py), PosePort 계약.
- Produces: `MoveNetPoseAdapter(onnx_path, input_size=192, session=None, min_person_score=0.2)` implements PosePort `infer(frame) -> list[Person]`. MoveNet SinglePose Lightning: 출력 `[1,1,17,3]` = (y,x,score) 정규화(0..1). 프레임 크기로 역정규화 → COCO-17 순서 Keypoint(x,y,conf). bbox = 가시 관절(conf≥min_person_score) min/max; 가시 관절 0이면 빈 리스트.
- [ ] **Step 1: 실패 테스트** — `tests/test_movenet_pose.py` (session 주입 — onnxruntime 불필요)
```python
import numpy as np
from edge.adapters.movenet_pose import MoveNetPoseAdapter
class _FakeSession:
"""MoveNet 출력 형태 [1,1,17,3] (y,x,score) 정규화."""
def get_inputs(self):
class I: name = "input"
return [I()]
def run(self, _out, feed):
kp = np.zeros((1, 1, 17, 3), dtype=np.float32)
for j in range(17):
kp[0, 0, j] = [0.5, 0.25, 0.9] # y=0.5, x=0.25, score=0.9
return [kp]
def test_infer_maps_movenet_to_coco17_persons():
frame = np.zeros((400, 800, 3), dtype=np.uint8)
a = MoveNetPoseAdapter("unused.onnx", session=_FakeSession())
persons = a.infer(frame)
assert len(persons) == 1
p = persons[0]
assert len(p.keypoints) == 17
# x = 0.25 * 800 = 200, y = 0.5 * 400 = 200
assert abs(p.keypoints[0].x - 200) < 1 and abs(p.keypoints[0].y - 200) < 1
assert p.keypoints[0].conf == 0.9
assert p.bbox.x1 <= 200 <= p.bbox.x2
def test_low_score_person_dropped():
class Low(_FakeSession):
def run(self, _o, feed):
return [np.zeros((1, 1, 17, 3), dtype=np.float32)] # all score 0
a = MoveNetPoseAdapter("unused.onnx", session=Low())
assert a.infer(np.zeros((10, 10, 3), np.uint8)) == []
```
- [ ] **Step 2: 실패 확인** → FAIL
- [ ] **Step 3: 구현** — `edge/adapters/movenet_pose.py`
```python
import cv2
import numpy as np
from edge.models import Person, BBox, Keypoint
class MoveNetPoseAdapter:
"""MoveNet SinglePose Lightning (COCO-17) via onnxruntime. CPU dev/pilot PosePort.
출력 [1,1,17,3] = (y, x, score) normalized 0..1."""
def __init__(self, onnx_path: str, input_size: int = 192, session=None,
min_person_score: float = 0.2):
self.input_size = input_size
self.min_person_score = min_person_score
if session is None:
import onnxruntime # gated: bundled on build machine
session = onnxruntime.InferenceSession(onnx_path, providers=["CPUExecutionProvider"])
self.session = session
self._input_name = session.get_inputs()[0].name
def infer(self, frame) -> list:
h, w = frame.shape[:2]
img = cv2.resize(frame, (self.input_size, self.input_size))
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB).astype(np.int32)[None, ...]
out = self.session.run(None, {self._input_name: img})[0] # [1,1,17,3]
kp = out[0, 0] # [17,3] (y,x,score)
keypoints = [Keypoint(float(kp[j, 1] * w), float(kp[j, 0] * h), float(kp[j, 2]))
for j in range(17)]
vis = [k for k in keypoints if k.conf >= self.min_person_score]
if not vis:
return []
xs = [k.x for k in vis]; ys = [k.y for k in vis]
bbox = BBox(min(xs), min(ys), max(xs), max(ys))
return [Person(bbox, keypoints)]
```
`edge/tools/fetch_movenet.py`:
```python
"""빌드머신 전용: MoveNet SinglePose Lightning ONNX 다운로드(폐쇄망 번들). Apache-2.0."""
import os
import urllib.request
# MoveNet Lightning ONNX (예시 소스; 조직 미러로 교체 가능)
URL = "https://github.com/PINTO0309/PINTO_model_zoo/raw/main/115_MoveNet/resources/movenet_singlepose_lightning_4.onnx"
DEST = os.path.join(os.path.dirname(__file__), "..", "..", "models", "movenet_lightning.onnx")
def main():
os.makedirs(os.path.dirname(DEST), exist_ok=True)
print(f"downloading MoveNet ONNX -> {DEST}")
urllib.request.urlretrieve(URL, DEST)
print("done")
if __name__ == "__main__":
main()
```
> **NOTE (implementer):** the `fetch_movenet.py` URL is a build-machine convenience; verify the model's input size/dtype and output layout `[1,1,17,3]` against the actual downloaded file before relying on it on hardware — the fake-session unit test locks the ADAPTER's remap logic, not the specific model file. If the real model expects a different input size/dtype, adjust `input_size`/dtype accordingly; the COCO-17 remap stays.
- [ ] **Step 4: 통과 + 커밋** — `pytest tests/test_movenet_pose.py -v` → PASS
```bash
git add edge/adapters/movenet_pose.py edge/tools/fetch_movenet.py tests/test_movenet_pose.py
git commit -m "feat(edge): MoveNet ONNX CPU pose adapter (COCO-17 remap)"
```
---
## Task 6: 클라우드 사고 상세 뷰 — 스냅샷 표시
**Files:** Modify `cloud/src/View/event_detail.php`; Create `cloud/public/assets/detail.js`; Modify `cloud/public/index.php`; Modify `cloud/tests/WiringSmokeTest.php`
**Interfaces:**
- Consumes: existing `GET /events/{id}` (JSON: event+snapshots) + `GET /snap/{uuid}` (auth image).
- Produces: `GET /events/{id}/view` → renders `event_detail.php` (HTML shell + `<script src="/assets/detail.js">`). `detail.js` parses the id from the URL, fetches `GET /events/{id}`, renders meta + each snapshot as `<img src="/snap/{uuid}">` (createElement, XSS-safe).
- [ ] **Step 1: 스모크 테스트 갱신** — `cloud/tests/WiringSmokeTest.php`의 paths 배열에 `['GET','/events/{id}/view']` 추가. `cd cloud && ./vendor/bin/phpunit tests/WiringSmokeTest.php` — (index 라우트 추가 전이라 실제 앱엔 없지만 스모크는 자체 라우터에 등록하므로) 통과 유지 확인. (스모크는 라우터 매칭만 검증.)
- [ ] **Step 2: event_detail.php (스텁 교체)**
```php
<?php /* rendered at GET /events/{id}/view; data loaded by detail.js */ ?>
<h1>사고 상세</h1>
<div id="detail-meta" role="status">불러오는 중…</div>
<div id="detail-snapshots"></div>
<p><a href="/">← 목록</a></p>
<script src="/assets/detail.js" defer></script>
```
- [ ] **Step 3: detail.js** — `cloud/public/assets/detail.js`
```javascript
(function () {
var m = location.pathname.match(/\/events\/(\d+)\/view/);
if (!m) return;
var id = m[1];
fetch('/events/' + id, { credentials: 'same-origin' })
.then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); })
.then(function (d) {
var ev = d.event || {};
var meta = document.getElementById('detail-meta');
meta.textContent = '';
[['유형', ev.event_type], ['심각도', ev.severity], ['카메라', ev.camera_id],
['시각', ev.occurred_at], ['지속(초)', ev.duration_s], ['확인', ev.acknowledged_by || '미확인']]
.forEach(function (row) {
var p = document.createElement('div');
p.textContent = row[0] + ': ' + (row[1] == null ? '-' : row[1]);
meta.appendChild(p);
});
var box = document.getElementById('detail-snapshots');
(d.snapshots || []).forEach(function (s) {
var img = document.createElement('img');
img.src = '/snap/' + encodeURIComponent(s.file_uuid);
img.alt = '사고 스냅샷';
img.style.maxWidth = '100%';
box.appendChild(img);
});
})
.catch(function () {
document.getElementById('detail-meta').textContent = '불러오기 실패';
});
})();
```
- [ ] **Step 4: index.php 라우트** — 뷰 라우트 섹션에 추가:
```php
$r->add('GET', '/events/{id}/view', fn() => $render('event_detail'));
```
- [ ] **Step 5: 통과 + 커밋** — 전체 `cd cloud && ./vendor/bin/phpunit --display-warnings --display-deprecations` green; `php -l cloud/public/index.php`.
```bash
git add cloud/src/View/event_detail.php cloud/public/assets/detail.js cloud/public/index.php cloud/tests/WiringSmokeTest.php
git commit -m "feat(cloud): incident detail view renders snapshot images"
```
---
## Task 7: 응답 루프 — 목록 링크 + 데스크 Web Audio
**Files:** Modify `cloud/public/assets/app.js`
**Interfaces:**
- Consumes: `GET /api/events` (기존 폴링).
- Produces: 각 목록 행이 `/events/{id}/view`로 링크; **새 미확인 critical 이벤트 감지 시 데스크 Web Audio 반복 비프**(미확인 critical 존재 동안), 첫 클릭으로 audio 언락. 색맹안전 배지 유지. 기존 buildRow/폴링/ack 동작 보존.
- [ ] **Step 1: app.js 편집** — 기존 구조에 아래를 통합(구현자는 실제 함수명에 맞춤):
```javascript
// --- desk audio alert (no binary asset; WebAudio beep) ---
var audioCtx = null;
document.addEventListener('click', function unlock() {
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
document.removeEventListener('click', unlock);
});
var alertedCritical = {}; // event_id -> true (already beeped once)
var alarmTimer = null;
function beep() {
if (!audioCtx) return;
var o = audioCtx.createOscillator(), g = audioCtx.createGain();
o.frequency.value = 880; o.connect(g); g.connect(audioCtx.destination);
g.gain.value = 0.2; o.start(); o.stop(audioCtx.currentTime + 0.4);
}
function updateAlarm(events) {
var unackedCritical = false;
events.forEach(function (e) {
if (e.severity === 'critical' && !e.acknowledged_by) {
unackedCritical = true;
if (!alertedCritical[e.event_id]) { alertedCritical[e.event_id] = true; beep(); }
}
});
if (unackedCritical && !alarmTimer) alarmTimer = setInterval(beep, 3000);
if (!unackedCritical && alarmTimer) { clearInterval(alarmTimer); alarmTimer = null; }
}
```
그리고: (a) 행 빌더에서 각 행을 `/events/{event_id}/view` 링크로 감싸기 — `var a = document.createElement('a'); a.href = '/events/' + e.event_id + '/view'; a.appendChild(rowEl);` 후 컨테이너에 append. (b) 폴링 성공 콜백에서 `renderList(events)` 뒤에 `updateAlarm(events)` 호출.
- [ ] **Step 2: 통과 + 커밋** — `node --check cloud/public/assets/app.js`(node 있으면), 전체 phpunit green(JS만 변경).
```bash
git add cloud/public/assets/app.js
git commit -m "feat(cloud): dashboard rows link to detail + desk Web Audio on critical"
```
---
## Task 8: 파일럿 런북 + 게이트 pose 스모크
**Files:** Create `docs/pilot-runbook.md`, `tests/hardware/test_movenet_smoke.py`
- [ ] **Step 1: 게이트 스모크** — `tests/hardware/test_movenet_smoke.py`
```python
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
a = MoveNetPoseAdapter(MODEL)
persons = a.infer(np.full((480, 640, 3), 128, np.uint8))
assert isinstance(persons, list) # runs end-to-end; each Person has 17 keypoints
```
- [ ] **Step 2: 파일럿 런북** — `docs/pilot-runbook.md`, 노트북 파일럿 절차:
1. **빌드:** `pip install onnxruntime`; `python -m edge.tools.fetch_movenet` (모델 → `models/`).
2. **클라우드:** `php -S localhost:8000 -t cloud/public` (config.php에 로컬 DB) 또는 Cafe24. admin 계정 생성 + device secret을 `devices` 테이블에 등록.
3. **env:** `DEVICE_ID`, `DEVICE_SECRET`(클라우드와 일치), `CAFE24_URL`(예 http://localhost:8000), `MOVENET_ONNX=models/movenet_lightning.onnx`, `UVC_DEVICE=0`, `TARGET_FPS=12`.
4. **실행:** `move-detect-edge` (또는 `python -m edge`).
5. **데모:** 웹캠 앞 눕기 → 3초 후 fall, 10초 후 no_motion(위급, 데스크 비프) → 목록행 클릭 → 상세서 스냅샷 확인 → 확인처리(비프 멈춤). 대시보드서 구역 그리기 → 발끝 진입 → 침입 이벤트(역루프). 네트워크 끊고 이벤트 → 복구 후 큐 flush.
6. **한계(명시):** 단일캠·CPU pose(저fps)·SMS 미연동(Web Audio만)·前中後 아닌 단일프레임. 실장비 Hailo·폐쇄망 번들·드릴 게이트·F14 컴플라이언스는 후속.
- [ ] **Step 3: 통과 + 커밋** — `pytest -q`(hardware skip) green.
```bash
git add docs/pilot-runbook.md tests/hardware/test_movenet_smoke.py
git commit -m "docs: pilot runbook + gated MoveNet smoke test"
```
---
## Self-Review
**1. 최소경로 커버리지 (기능검토 1~7):**
- 엣지 데몬(1) → T4 ✓ · CPU pose(2) → T5 ✓ · edge→클라우드 흐름(3) → T4 데몬+기존 PostClient ✓
- 사고 가시화(4) → T6 ✓ · 응답 1채널(5) → T7 Web Audio ✓
- config-sync 역루프(6) → T1+T2+T3(apply_zones)+T4(주기 sync) ✓ · 하트비트(7) → T1+T4 ✓ · 큐 flush(rank7) → T4 ✓
- **범위 밖(명시):** 실 Hailo(하드웨어), 폐쇄망 전체 번들(T5/T8은 fetch+게이트만; 번들·차단설치는 후속 패키징), 드릴 게이트(P1-C), F14 컴플라이언스(별도), 前中後 링버퍼(rank11 — 파일럿 단일프레임 유지).
- **의도적 미포함:** critical SMS/webhook 실연동(leak_sms 게이트 정보 필요) — T7 Web Audio로 "사람 알림" 증명, 실연동 후속.
**2. Placeholder scan:** 데몬/클라이언트/런타임/어댑터/zone은 실코드+TDD. T5 실모델 스모크·T8은 게이트(모델 없으면 skip) — 의도적. fetch URL은 빌드 편의(구현자 노트로 검증 지시).
**3. Type consistency:** `CloudClient.sign` = PostClient/클라우드 T14 동일(`nonce+ts+body`). `PipelineRuntime.step`가 `run_once(source,pose,tracker,pool,engine,ring,snapshotter,sink,now_fn)` — P1-A 최종형(per-track smoother 수정 후) 일치. `apply_zones`가 `ZoneIntrusionDetector.zones` 재할당. MoveNet [1,1,17,3](y,x,score)→COCO-17 Keypoint(x,y,conf) 일관.
---
## Execution Handoff
**Plan complete. 저장: `docs/superpowers/plans/2026-07-12-p1-pilot-integration.md`.**
실행: **Subagent-Driven (추천)** 또는 Inline. 어느 방식?