diff --git a/cloud/public/assets/app.css b/cloud/public/assets/app.css index 733ec6d..1ed9ca5 100644 --- a/cloud/public/assets/app.css +++ b/cloud/public/assets/app.css @@ -84,3 +84,14 @@ button:hover { background: #f3f4f6; } .ack-btn { margin-left: auto; } .ack-btn:disabled { opacity: 0.6; cursor: default; } + +/* Dashboard row -> incident detail link (keyboard-focusable affordance). */ +.event-detail-link { color: inherit; text-decoration: none; } +.event-detail-link:hover, +.event-detail-link:focus-visible { text-decoration: underline; } + +/* Incident detail view (event_detail.php + detail.js). */ +.detail-row { margin: 0.25rem 0; } +.detail-row .detail-label { color: #4b5563; font-weight: 600; } +#event-detail-snapshots { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-top: 1rem; } +.detail-snapshot { max-width: 100%; height: auto; border: 1px solid #d1d5db; border-radius: 4px; } diff --git a/cloud/public/assets/app.js b/cloud/public/assets/app.js index f787fa9..ade6666 100644 --- a/cloud/public/assets/app.js +++ b/cloud/public/assets/app.js @@ -55,6 +55,55 @@ return badge; } + // --- desk audio alert: repeating beep while any unacknowledged critical exists --- + // No binary asset (air-gapped friendly): a WebAudio oscillator tone. The browser + // blocks audio until a user gesture, so the context is created on first click. + var audioCtx = null; + var alarmTimer = null; + var beepedCritical = {}; // event_id -> true; each new critical beeps once immediately + + function unlockAudio() { + if (!audioCtx) { + var AC = window.AudioContext || window.webkitAudioContext; + if (AC) audioCtx = new AC(); + } + document.removeEventListener('click', unlockAudio); + } + + function beep() { + if (!audioCtx) return; + try { + var osc = audioCtx.createOscillator(); + var gain = audioCtx.createGain(); + osc.type = 'sine'; + osc.frequency.value = 880; + gain.gain.value = 0.2; + osc.connect(gain); + gain.connect(audioCtx.destination); + osc.start(); + osc.stop(audioCtx.currentTime + 0.4); + } catch (e) { + // audio unavailable; the visual list still surfaces the critical event + } + } + + function updateAlarm(events) { + var hasUnackedCritical = false; + events.forEach(function (ev) { + var acked = ev.acknowledged_by !== null && ev.acknowledged_by !== undefined && ev.acknowledged_by !== ''; + if (ev.severity === 'critical' && !acked) { + hasUnackedCritical = true; + if (!beepedCritical[ev.event_id]) { beepedCritical[ev.event_id] = true; beep(); } + } + }); + if (hasUnackedCritical && !alarmTimer) { + alarmTimer = setInterval(beep, 3000); + } else if (!hasUnackedCritical && alarmTimer) { + clearInterval(alarmTimer); + alarmTimer = null; + } + } + function buildRow(ev) { var isAcked = ev.acknowledged_by !== null && ev.acknowledged_by !== undefined && ev.acknowledged_by !== ''; @@ -68,7 +117,11 @@ var type = document.createElement('div'); type.className = 'event-type'; - type.textContent = String(ev.event_type || ''); + var typeLink = document.createElement('a'); + typeLink.className = 'event-detail-link'; + typeLink.href = '/events/' + encodeURIComponent(ev.event_id) + '/view'; + typeLink.textContent = String(ev.event_type || ''); + type.appendChild(typeLink); meta.appendChild(type); var sub = document.createElement('div'); @@ -118,6 +171,8 @@ return aAcked ? 1 : -1; // unacknowledged first }); + updateAlarm(sorted); // before any early return, so an emptied list also silences the alarm + root.textContent = ''; root.removeAttribute('aria-busy'); if (sorted.length === 0) { @@ -154,6 +209,8 @@ }); } + document.addEventListener('click', unlockAudio); + loadCsrfToken(); loadEvents(); setInterval(loadEvents, POLL_INTERVAL_MS); diff --git a/cloud/public/assets/detail.js b/cloud/public/assets/detail.js new file mode 100644 index 0000000..58f5145 --- /dev/null +++ b/cloud/public/assets/detail.js @@ -0,0 +1,119 @@ +/** + * Incident detail view: parses the event id from /events/{id}/view, fetches + * GET /events/{id} (JSON {event, snapshots}), and renders the event metadata + * plus each snapshot as an . + * + * XSS-safe: every dynamic value is written via textContent or a validated URL + * attribute, never innerHTML. Severity is shown as icon + text (never color + * alone, per NF9). On 401 the browser is redirected to /login, mirroring app.js. + */ +(function () { + 'use strict'; + + var SEVERITY_LABELS = { + critical: { icon: '⛔', text: '위급', cls: 'sev-critical' }, + high: { icon: '⚠', text: '높음', cls: 'sev-high' }, + warning: { icon: '△', text: '경고', cls: 'sev-warning' }, + info: { icon: 'ℹ', text: '정보', cls: 'sev-info' } + }; + + var meta = document.getElementById('event-detail-meta'); + var snapBox = document.getElementById('event-detail-snapshots'); + if (!meta) return; + + var m = location.pathname.match(/\/events\/(\d+)\/view/); + if (!m) { + meta.textContent = '잘못된 주소입니다.'; + meta.removeAttribute('aria-busy'); + return; + } + var id = m[1]; + + function severityInfo(sev) { + return SEVERITY_LABELS[sev] || { icon: 'ℹ', text: sev || '알수없음', cls: 'sev-info' }; + } + + function formatTime(occurredAt) { + if (!occurredAt && occurredAt !== 0) return '-'; + var d = new Date(Number(occurredAt) * 1000); + if (isNaN(d.getTime())) return '-'; + return d.toLocaleString('ko-KR'); + } + + function buildBadge(sev) { + var info = severityInfo(sev); + var badge = document.createElement('span'); + badge.className = 'severity-badge ' + info.cls; + var icon = document.createElement('span'); + icon.className = 'icon'; + icon.setAttribute('aria-hidden', 'true'); + icon.textContent = info.icon; + var label = document.createElement('span'); + label.className = 'label'; + label.textContent = info.text; + badge.appendChild(icon); + badge.appendChild(label); + return badge; + } + + function textNode(value) { + var span = document.createElement('span'); + span.textContent = (value === null || value === undefined || value === '') ? '-' : String(value); + return span; + } + + function addRow(label, valueNode) { + var row = document.createElement('div'); + row.className = 'detail-row'; + var k = document.createElement('span'); + k.className = 'detail-label'; + k.textContent = label + ': '; + row.appendChild(k); + row.appendChild(valueNode); + meta.appendChild(row); + } + + function render(data) { + var ev = data.event || {}; + meta.textContent = ''; + meta.removeAttribute('aria-busy'); + + addRow('심각도', buildBadge(ev.severity)); + addRow('유형', textNode(ev.event_type)); + addRow('카메라', textNode(ev.camera_id)); + addRow('발생 시각', textNode(formatTime(ev.occurred_at))); + addRow('지속(초)', textNode(ev.duration_s)); + addRow('확인', textNode(ev.acknowledged_by || '미확인')); + + if (!snapBox) return; + snapBox.textContent = ''; + var snaps = data.snapshots || []; + if (snaps.length === 0) { + var none = document.createElement('p'); + none.textContent = '스냅샷이 없습니다.'; + snapBox.appendChild(none); + return; + } + snaps.forEach(function (s) { + if (!s || !s.file_uuid) return; + var img = document.createElement('img'); + img.src = '/snap/' + encodeURIComponent(s.file_uuid); + img.alt = '사고 스냅샷'; + img.className = 'detail-snapshot'; + img.loading = 'lazy'; + snapBox.appendChild(img); + }); + } + + fetch('/events/' + encodeURIComponent(id), { headers: { Accept: 'application/json' } }) + .then(function (res) { + if (res.status === 401) { window.location.href = '/login'; return null; } + if (!res.ok) throw new Error('detail_failed:' + res.status); + return res.json(); + }) + .then(function (data) { if (data) render(data); }) + .catch(function () { + meta.textContent = '불러오기 실패'; + meta.removeAttribute('aria-busy'); + }); +})(); diff --git a/cloud/public/index.php b/cloud/public/index.php index bf653ac..6e4157e 100644 --- a/cloud/public/index.php +++ b/cloud/public/index.php @@ -61,5 +61,6 @@ $r->add('GET', '/snap/{uuid}', fn(Request $q, array $p) => $proxy->serve($q, $p) $r->add('GET', '/', fn() => $render('events')); $r->add('GET', '/login', fn() => $render('login')); $r->add('GET', '/zones', fn() => $render('zones')); +$r->add('GET', '/events/{id}/view', fn(Request $q, array $p) => $render('event_detail')); $r->dispatch(Request::fromGlobals())->send(); diff --git a/cloud/src/View/event_detail.php b/cloud/src/View/event_detail.php index 952d4aa..864f5bb 100644 --- a/cloud/src/View/event_detail.php +++ b/cloud/src/View/event_detail.php @@ -3,6 +3,9 @@ require __DIR__ . '/layout.php'; render_header('이벤트 상세'); ?>

이벤트 상세

-
불러오는 중...
+
불러오는 중...
+
+

← 목록으로

+ assertSame(200, $r->dispatch(new Request('GET', '/events/9', [], '', [], []))->status()); $this->assertSame(200, $r->dispatch(new Request('GET', '/snap/abc', [], '', [], []))->status()); $this->assertSame(404, $r->dispatch(new Request('DELETE', '/api/events', [], '', [], []))->status()); + + // The 3-segment view route must resolve to itself, NOT be swallowed by the + // 2-segment /events/{id} JSON route (they are registered in that order). + $twoSeg = $r->dispatch(new Request('GET', '/events/9', [], '', [], [])); + $this->assertSame('/events/{id}', json_decode($twoSeg->body(), true)['route']); + $viewSeg = $r->dispatch(new Request('GET', '/events/9/view', [], '', [], [])); + $this->assertSame(200, $viewSeg->status()); + $this->assertSame('/events/{id}/view', json_decode($viewSeg->body(), true)['route']); } } 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/edge/__main__.py b/edge/__main__.py new file mode 100644 index 0000000..8744750 --- /dev/null +++ b/edge/__main__.py @@ -0,0 +1,4 @@ +from edge.daemon import main + +if __name__ == "__main__": + main() diff --git a/edge/adapters/movenet_pose.py b/edge/adapters/movenet_pose.py new file mode 100644 index 0000000..5e50f1e --- /dev/null +++ b/edge/adapters/movenet_pose.py @@ -0,0 +1,34 @@ +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)] diff --git a/edge/alert.py b/edge/alert.py index b2edb37..388f54d 100644 --- a/edge/alert.py +++ b/edge/alert.py @@ -6,7 +6,9 @@ import json from edge.events import Event -def sign(secret: str, nonce: str, ts: str, body: bytes) -> str: +def sign(secret: str, nonce: str, ts: str, body: str | bytes) -> str: + if isinstance(body, str): + body = body.encode() msg = nonce.encode() + ts.encode() + body return hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest() diff --git a/edge/cloudclient.py b/edge/cloudclient.py new file mode 100644 index 0000000..f982afa --- /dev/null +++ b/edge/cloudclient.py @@ -0,0 +1,50 @@ +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 diff --git a/edge/daemon.py b/edge/daemon.py new file mode 100644 index 0000000..7bd04a3 --- /dev/null +++ b/edge/daemon.py @@ -0,0 +1,57 @@ +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 + try: + 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 + except Exception: + counters["errors"] += 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"))) diff --git a/edge/runtime.py b/edge/runtime.py new file mode 100644 index 0000000..7b76a59 --- /dev/null +++ b/edge/runtime.py @@ -0,0 +1,37 @@ +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) diff --git a/edge/tools/__init__.py b/edge/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/edge/tools/fetch_movenet.py b/edge/tools/fetch_movenet.py new file mode 100644 index 0000000..4b9c6e3 --- /dev/null +++ b/edge/tools/fetch_movenet.py @@ -0,0 +1,16 @@ +"""빌드머신 전용: 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() diff --git a/edge/zones.py b/edge/zones.py new file mode 100644 index 0000000..23718ff --- /dev/null +++ b/edge/zones.py @@ -0,0 +1,26 @@ +import json + +from edge.detectors.zone import Zone + + +def zones_from_config(config: dict) -> list[Zone]: + out: list[Zone] = [] + rows = config.get("zones", []) or [] + if not isinstance(rows, list): + return [] + for row in rows: + 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, IndexError, json.JSONDecodeError): + continue + return out + + +def thresholds_from_config(config: dict) -> dict: + t = config.get("thresholds") + return t if isinstance(t, dict) else {} diff --git a/pyproject.toml b/pyproject.toml index a75cd4d..510cccc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,9 @@ dependencies = ["numpy", "opencv-python", "requests"] [project.optional-dependencies] dev = ["pytest"] +[project.scripts] +move-detect-edge = "edge.daemon:main" + [tool.pytest.ini_options] testpaths = ["tests"] addopts = "-q" 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 diff --git a/tests/test_cloudclient.py b/tests/test_cloudclient.py new file mode 100644 index 0000000..d05b075 --- /dev/null +++ b/tests/test_cloudclient.py @@ -0,0 +1,32 @@ +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) diff --git a/tests/test_daemon.py b/tests/test_daemon.py new file mode 100644 index 0000000..2d01880 --- /dev/null +++ b/tests/test_daemon.py @@ -0,0 +1,56 @@ +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 + + +def test_loop_survives_flush_exception(): + class BoomQueue(_Queue): + def flush(self): + self.flushes += 1 + raise RuntimeError("db locked") + rt = _Runtime() + counters = run_loop(rt, _Client(), BoomQueue(), clock=lambda: 0.0, + sleep=lambda s: None, iterations=5, + sync_every=99, heartbeat_every=99, flush_every=2) + assert rt.steps == 5 # loop kept running past the flush exceptions + assert counters["errors"] >= 1 diff --git a/tests/test_movenet_pose.py b/tests/test_movenet_pose.py new file mode 100644 index 0000000..0db0a8a --- /dev/null +++ b/tests/test_movenet_pose.py @@ -0,0 +1,32 @@ +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 abs(p.keypoints[0].conf - 0.9) < 1e-6 + 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)) == [] diff --git a/tests/test_runtime.py b/tests/test_runtime.py new file mode 100644 index 0000000..85bd4b4 --- /dev/null +++ b/tests/test_runtime.py @@ -0,0 +1,43 @@ +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" diff --git a/tests/test_zones.py b/tests/test_zones.py new file mode 100644 index 0000000..1fcb342 --- /dev/null +++ b/tests/test_zones.py @@ -0,0 +1,33 @@ +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({}) == {} + +def test_skips_short_point_without_crashing(): + cfg = {"zones": [ + {"zone_id": "bad", "camera_id": "c", "level": "restricted", "polygon_json": "[[0,0],[9],[9,9]]"}, + {"zone_id": "ok", "camera_id": "c", "level": "restricted", "polygon_json": "[[0,0],[9,0],[9,9]]"}, + ]} + zones = zones_from_config(cfg) + # bad row skipped, good row survives -- function did not crash + assert [z.zone_id for z in zones] == ["ok"] + +def test_non_iterable_zones_returns_empty(): + assert zones_from_config({"zones": 5}) == []