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:
commit
d270baa450
23 changed files with 768 additions and 4 deletions
|
|
@ -84,3 +84,14 @@ button:hover { background: #f3f4f6; }
|
||||||
|
|
||||||
.ack-btn { margin-left: auto; }
|
.ack-btn { margin-left: auto; }
|
||||||
.ack-btn:disabled { opacity: 0.6; cursor: default; }
|
.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; }
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,55 @@
|
||||||
return badge;
|
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) {
|
function buildRow(ev) {
|
||||||
var isAcked = ev.acknowledged_by !== null && ev.acknowledged_by !== undefined && ev.acknowledged_by !== '';
|
var isAcked = ev.acknowledged_by !== null && ev.acknowledged_by !== undefined && ev.acknowledged_by !== '';
|
||||||
|
|
||||||
|
|
@ -68,7 +117,11 @@
|
||||||
|
|
||||||
var type = document.createElement('div');
|
var type = document.createElement('div');
|
||||||
type.className = 'event-type';
|
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);
|
meta.appendChild(type);
|
||||||
|
|
||||||
var sub = document.createElement('div');
|
var sub = document.createElement('div');
|
||||||
|
|
@ -118,6 +171,8 @@
|
||||||
return aAcked ? 1 : -1; // unacknowledged first
|
return aAcked ? 1 : -1; // unacknowledged first
|
||||||
});
|
});
|
||||||
|
|
||||||
|
updateAlarm(sorted); // before any early return, so an emptied list also silences the alarm
|
||||||
|
|
||||||
root.textContent = '';
|
root.textContent = '';
|
||||||
root.removeAttribute('aria-busy');
|
root.removeAttribute('aria-busy');
|
||||||
if (sorted.length === 0) {
|
if (sorted.length === 0) {
|
||||||
|
|
@ -154,6 +209,8 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', unlockAudio);
|
||||||
|
|
||||||
loadCsrfToken();
|
loadCsrfToken();
|
||||||
loadEvents();
|
loadEvents();
|
||||||
setInterval(loadEvents, POLL_INTERVAL_MS);
|
setInterval(loadEvents, POLL_INTERVAL_MS);
|
||||||
|
|
|
||||||
119
cloud/public/assets/detail.js
Normal file
119
cloud/public/assets/detail.js
Normal 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');
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
@ -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', '/', fn() => $render('events'));
|
||||||
$r->add('GET', '/login', fn() => $render('login'));
|
$r->add('GET', '/login', fn() => $render('login'));
|
||||||
$r->add('GET', '/zones', fn() => $render('zones'));
|
$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();
|
$r->dispatch(Request::fromGlobals())->send();
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@ require __DIR__ . '/layout.php';
|
||||||
render_header('이벤트 상세');
|
render_header('이벤트 상세');
|
||||||
?>
|
?>
|
||||||
<h1>이벤트 상세</h1>
|
<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
|
<?php
|
||||||
render_footer();
|
render_footer();
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ final class WiringSmokeTest extends TestCase
|
||||||
$paths = [
|
$paths = [
|
||||||
['POST', '/api/events'], ['POST', '/api/heartbeat'], ['GET', '/api/config'],
|
['POST', '/api/events'], ['POST', '/api/heartbeat'], ['GET', '/api/config'],
|
||||||
['POST', '/login'], ['POST', '/logout'], ['GET', '/api/events'],
|
['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'],
|
['GET', '/api/zones/{camera}'], ['POST', '/api/zones'],
|
||||||
['POST', '/api/zones/{id}/delete'], ['GET', '/snap/{uuid}'],
|
['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', '/events/9', [], '', [], []))->status());
|
||||||
$this->assertSame(200, $r->dispatch(new Request('GET', '/snap/abc', [], '', [], []))->status());
|
$this->assertSame(200, $r->dispatch(new Request('GET', '/snap/abc', [], '', [], []))->status());
|
||||||
$this->assertSame(404, $r->dispatch(new Request('DELETE', '/api/events', [], '', [], []))->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
112
docs/pilot-runbook.md
Normal 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
4
edge/__main__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
from edge.daemon import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
34
edge/adapters/movenet_pose.py
Normal file
34
edge/adapters/movenet_pose.py
Normal 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)]
|
||||||
|
|
@ -6,7 +6,9 @@ import json
|
||||||
from edge.events import Event
|
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
|
msg = nonce.encode() + ts.encode() + body
|
||||||
return hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()
|
return hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
|
|
||||||
50
edge/cloudclient.py
Normal file
50
edge/cloudclient.py
Normal 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
57
edge/daemon.py
Normal 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
37
edge/runtime.py
Normal 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
0
edge/tools/__init__.py
Normal file
16
edge/tools/fetch_movenet.py
Normal file
16
edge/tools/fetch_movenet.py
Normal 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
26
edge/zones.py
Normal 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 {}
|
||||||
|
|
@ -7,6 +7,9 @@ dependencies = ["numpy", "opencv-python", "requests"]
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = ["pytest"]
|
dev = ["pytest"]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
move-detect-edge = "edge.daemon:main"
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
addopts = "-q"
|
addopts = "-q"
|
||||||
|
|
|
||||||
28
tests/hardware/test_movenet_smoke.py
Normal file
28
tests/hardware/test_movenet_smoke.py
Normal 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
32
tests/test_cloudclient.py
Normal 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
56
tests/test_daemon.py
Normal 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
|
||||||
32
tests/test_movenet_pose.py
Normal file
32
tests/test_movenet_pose.py
Normal 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
43
tests/test_runtime.py
Normal 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
33
tests/test_zones.py
Normal 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}) == []
|
||||||
Loading…
Reference in a new issue