Merge feat/p1-pilot-integration: P1 pilot end-to-end wiring (T1-T8)

Wire the already-merged P1-A (edge detection) and P1-B (cloud) together into a
laptop-webcam end-to-end pilot: real frames -> CPU MoveNet pose -> detection ->
HMAC-signed POST -> cloud -> admin sees snapshot, hears alert, acknowledges.
Adds the edge daemon loop, CloudClient config-poll/heartbeat, zone reverse-loop,
MoveNet ONNX adapter, cloud incident detail view, and desk Web Audio alerting.
This commit is contained in:
유창욱 2026-07-12 11:59:22 +09:00
commit d270baa450
23 changed files with 768 additions and 4 deletions

View file

@ -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; }

View file

@ -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);

View file

@ -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 <img src="/snap/{file_uuid}">.
*
* 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');
});
})();

View file

@ -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();

View file

@ -3,6 +3,9 @@ require __DIR__ . '/layout.php';
render_header('이벤트 상세');
?>
<h1>이벤트 상세</h1>
<div id="event-detail-root" aria-live="polite" aria-busy="true">불러오는 중...</div>
<div id="event-detail-meta" role="status" aria-live="polite" aria-busy="true">불러오는 중...</div>
<div id="event-detail-snapshots"></div>
<p><a href="/">← 목록으로</a></p>
<script src="/assets/detail.js"></script>
<?php
render_footer();

View file

@ -11,7 +11,7 @@ final class WiringSmokeTest extends TestCase
$paths = [
['POST', '/api/events'], ['POST', '/api/heartbeat'], ['GET', '/api/config'],
['POST', '/login'], ['POST', '/logout'], ['GET', '/api/events'],
['GET', '/events/{id}'], ['POST', '/events/{id}/ack'],
['GET', '/events/{id}'], ['POST', '/events/{id}/ack'], ['GET', '/events/{id}/view'],
['GET', '/api/zones/{camera}'], ['POST', '/api/zones'],
['POST', '/api/zones/{id}/delete'], ['GET', '/snap/{uuid}'],
];
@ -19,5 +19,13 @@ final class WiringSmokeTest extends TestCase
$this->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']);
}
}

112
docs/pilot-runbook.md Normal file
View file

@ -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 <db> < 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).

4
edge/__main__.py Normal file
View file

@ -0,0 +1,4 @@
from edge.daemon import main
if __name__ == "__main__":
main()

View file

@ -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)]

View file

@ -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()

50
edge/cloudclient.py Normal file
View file

@ -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

57
edge/daemon.py Normal file
View file

@ -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")))

37
edge/runtime.py Normal file
View file

@ -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)

0
edge/tools/__init__.py Normal file
View file

View file

@ -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()

26
edge/zones.py Normal file
View file

@ -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 {}

View file

@ -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"

View file

@ -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

32
tests/test_cloudclient.py Normal file
View file

@ -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)

56
tests/test_daemon.py Normal file
View file

@ -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

View file

@ -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)) == []

43
tests/test_runtime.py Normal file
View file

@ -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"

33
tests/test_zones.py Normal file
View file

@ -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}) == []