# P1-A 엣지 감지 코어 (Edge Detection Core) Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 작업장 안전 AI CCTV의 "감지 브레인"을 순수 Python으로 구현 — 자세추정 결과에서 넘어짐·자세무관 부동·유령사고(트랙소실)·위험구역 침입을 판정하고, 스냅샷과 함께 Cafe24로 POST(오프라인 큐 포함). Hailo 추론·카메라는 어댑터 인터페이스 뒤에 두어 하드웨어 없이 전 로직을 TDD로 완결한다. **Architecture:** 프레임 → PosePort(어댑터) → 스무딩 → TrackerPort → EventEngine(FallDetector·ImmobilityWatchdog·GhostIncidentDetector·ZoneIntrusionDetector) → Snapshotter(+FaceBlur) → PostClient/OfflineQueue. 모든 판정 로직은 주입된 타임스탬프(`now: float`)로 동작(결정론적 테스트). 임계값은 Config(env)에서 per-camera/zone. **Tech Stack:** Python 3.11, pytest, numpy, opencv-python(cv2), stdlib(sqlite3·hmac·hashlib·json·collections·dataclasses). POST는 requests. **런타임 ultralytics(AGPL) 금지.** 점-다각형·One-Euro·IoU 추적은 경량 자체 구현(어댑터로 ByteTrack 교체 가능). ## Global Constraints - Python **3.11** 고정. 타입힌트 필수. - **폐쇄망(air-gap):** 런타임 인터넷은 Cafe24 POST뿐. 의존성은 벤더링/핀 고정(오프라인 설치). `.hef`·휠 사전 번들. - **런타임 `ultralytics` 금지** (AGPL). pose는 Hailo HEF 어댑터로만. - **불변 지향:** 데이터 모델은 `@dataclass(frozen=True)`. 새 객체 생성, 제자리 변경 지양(가변 상태는 detector 내부 dict에 격리). - **타임스탬프 주입:** 판정 로직 내부에서 `time.*` 호출 금지. `now: float`(초, monotonic) 인자로 받는다. (테스트 결정론) - **픽셀 임계는 bbox 높이로 정규화.** 원시 픽셀 임계 금지. - **단일 공유 pose 모델** 가정: 어댑터는 프레임당 `list[Person]`(박스+관절17) 반환. - **스냅샷만**: 영상/클립 없음. 이벤트당 JPEG 1~3장. - **POST 서명:** `HMAC-SHA256(nonce + timestamp + body, per_device_secret)` + nonce/timestamp(replay 방지). - **임계값 하드코딩 금지:** Config(env)에서 로드, per-(device,camera,zone) 오버라이드. - 파일은 작고 단일책임(200~400줄). 함수 <50줄. 커밋 자주. - COCO-17 관절 인덱스 표준. 이미지 좌표계 y는 아래로 증가. --- ## File Structure ``` edge/ __init__.py models.py # Keypoint, BBox, Person, COCO 인덱스 상수 geometry.py # com, torso_angle_deg, head_below_hips, visible_count, core_visible, normalize, height_of smoothing.py # OneEuroFilter, KeypointSmoother ports.py # PosePort, SourcePort (Protocol) — 하드웨어 경계 tracking.py # TrackFrame, Track, TrackerPort, IouTracker(경량) events.py # EventType, Severity, Event, severity_for detectors/ __init__.py fall.py # FallDetector (STANDING→FALLING→FALLEN→NO_MOTION, OR로직, baseline freeze) immobility.py # ImmobilityWatchdog (자세무관 부동) ghost.py # GhostIncidentDetector (트랙소실 공간사고) zone.py # point_in_polygon, ZoneIntrusionDetector (발끝/디바운스) engine.py # EventEngine (detector 오케스트레이션, dedup, cooldown) snapshot.py # RingBuffer, FaceBlurrer, Snapshotter alert.py # PostClient (HMAC+nonce) queue.py # OfflineQueue (sqlite) config.py # Config, load_config (env) app.py # 메인 루프 배선 (source→pose→track→engine→snapshot→alert/queue) tests/ test_geometry.py test_smoothing.py test_tracking.py test_fall.py test_immobility.py test_ghost.py test_zone.py test_events.py test_engine.py test_snapshot.py test_alert.py test_queue.py test_config.py test_app.py hardware/ # 실장비 전용(여기서 미실행) pyproject.toml ``` --- ## Task 1: 프로젝트 스캐폴드 + 데이터 모델 **Files:** - Create: `pyproject.toml`, `edge/__init__.py`, `edge/models.py`, `tests/test_models.py` **Interfaces:** - Consumes: (없음) - Produces: `Keypoint(x,y,conf)`, `BBox(x1,y1,x2,y2)` (props: `width,height,aspect,cx,cy`), `Person(bbox, keypoints: list[Keypoint])`; 상수 `NOSE=0, L_SHOULDER=5, R_SHOULDER=6, L_HIP=11, R_HIP=12, L_ANKLE=15, R_ANKLE=16, CORE_JOINTS=(5,6,11,12)`. - [ ] **Step 1: pyproject.toml 작성** ```toml [project] name = "move-detect-edge" version = "0.1.0" requires-python = ">=3.11" dependencies = ["numpy", "opencv-python", "requests"] [project.optional-dependencies] dev = ["pytest"] [tool.pytest.ini_options] testpaths = ["tests"] addopts = "-q" ``` - [ ] **Step 2: 실패 테스트 작성** — `tests/test_models.py` ```python from edge.models import Keypoint, BBox, Person, L_SHOULDER, R_HIP, CORE_JOINTS def test_bbox_derived_properties(): b = BBox(10, 20, 30, 80) assert b.width == 20 assert b.height == 60 assert b.aspect == 20 / 60 assert b.cx == 20 and b.cy == 50 def test_bbox_zero_height_aspect_is_zero(): assert BBox(0, 0, 10, 0).aspect == 0.0 def test_person_holds_17_keypoints(): kps = [Keypoint(0, 0, 0.9) for _ in range(17)] p = Person(BBox(0, 0, 1, 1), kps) assert len(p.keypoints) == 17 assert p.keypoints[L_SHOULDER].conf == 0.9 def test_coco_index_constants(): assert CORE_JOINTS == (5, 6, 11, 12) assert R_HIP == 12 ``` - [ ] **Step 3: 실패 확인** Run: `pytest tests/test_models.py -v` Expected: FAIL — `ModuleNotFoundError: No module named 'edge.models'` - [ ] **Step 4: 구현** — `edge/__init__.py`(빈 파일), `edge/models.py` ```python from dataclasses import dataclass # COCO-17 keypoint indices NOSE = 0 L_EYE, R_EYE, L_EAR, R_EAR = 1, 2, 3, 4 L_SHOULDER, R_SHOULDER = 5, 6 L_ELBOW, R_ELBOW, L_WRIST, R_WRIST = 7, 8, 9, 10 L_HIP, R_HIP = 11, 12 L_KNEE, R_KNEE, L_ANKLE, R_ANKLE = 13, 14, 15, 16 CORE_JOINTS = (L_SHOULDER, R_SHOULDER, L_HIP, R_HIP) @dataclass(frozen=True) class Keypoint: x: float y: float conf: float @dataclass(frozen=True) class BBox: x1: float y1: float x2: float y2: float @property def width(self) -> float: return self.x2 - self.x1 @property def height(self) -> float: return self.y2 - self.y1 @property def aspect(self) -> float: return self.width / self.height if self.height else 0.0 @property def cx(self) -> float: return (self.x1 + self.x2) / 2 @property def cy(self) -> float: return (self.y1 + self.y2) / 2 @dataclass(frozen=True) class Person: bbox: BBox keypoints: list # list[Keypoint], length 17 ``` - [ ] **Step 5: 통과 확인 + 커밋** Run: `pytest tests/test_models.py -v` → Expected: PASS ```bash git add pyproject.toml edge/__init__.py edge/models.py tests/test_models.py git commit -m "feat(edge): data models (Keypoint, BBox, Person) + COCO indices" ``` --- ## Task 2: 기하 유틸 (geometry) **Files:** - Create: `edge/geometry.py`, `tests/test_geometry.py` **Interfaces:** - Consumes: `Keypoint, BBox, CORE_JOINTS, NOSE, L_SHOULDER, R_SHOULDER, L_HIP, R_HIP` (Task 1) - Produces: - `com(keypoints) -> tuple[float, float]` — 어깨2+엉덩이2 평균 - `torso_angle_deg(keypoints) -> float` — 몸통벡터의 수직 대비 각(0=수직/서있음, 90=수평/누움) - `head_below_hips(keypoints) -> bool` - `visible_count(keypoints, thr=0.2) -> int` - `core_visible(keypoints, thr=0.5) -> bool` - `height_of(bbox) -> float` - `normalize(value, bbox_height) -> float` - [ ] **Step 1: 실패 테스트** — `tests/test_geometry.py` ```python import math from edge.models import Keypoint, BBox from edge.geometry import ( com, torso_angle_deg, head_below_hips, visible_count, core_visible, normalize, ) def _kps(overrides): kps = [Keypoint(0, 0, 0.0) for _ in range(17)] for i, (x, y, c) in overrides.items(): kps[i] = Keypoint(x, y, c) return kps def test_com_is_mean_of_shoulders_and_hips(): kps = _kps({5: (0, 0, 1), 6: (10, 0, 1), 11: (0, 10, 1), 12: (10, 10, 1)}) assert com(kps) == (5.0, 5.0) def test_torso_angle_upright_near_zero(): # shoulders above hips (smaller y), vertical torso kps = _kps({5: (0, 0, 1), 6: (10, 0, 1), 11: (0, 100, 1), 12: (10, 100, 1)}) assert torso_angle_deg(kps) < 10 def test_torso_angle_lying_near_ninety(): # shoulders and hips at same height, horizontal torso kps = _kps({5: (0, 50, 1), 6: (0, 55, 1), 11: (100, 50, 1), 12: (100, 55, 1)}) assert torso_angle_deg(kps) > 80 def test_head_below_hips_true_when_nose_lower_on_screen(): kps = _kps({0: (5, 120, 1), 11: (0, 100, 1), 12: (10, 100, 1)}) assert head_below_hips(kps) is True def test_visible_count_and_core_visible(): kps = _kps({5: (0, 0, 0.9), 6: (1, 0, 0.9), 11: (0, 1, 0.9), 12: (1, 1, 0.9)}) assert visible_count(kps, thr=0.2) == 4 assert core_visible(kps, thr=0.5) is True def test_normalize_divides_by_bbox_height(): assert normalize(30, 60) == 0.5 assert normalize(30, 0) == 0.0 ``` - [ ] **Step 2: 실패 확인** Run: `pytest tests/test_geometry.py -v` → Expected: FAIL — `ModuleNotFoundError: edge.geometry` - [ ] **Step 3: 구현** — `edge/geometry.py` ```python import math from edge.models import ( Keypoint, BBox, NOSE, L_SHOULDER, R_SHOULDER, L_HIP, R_HIP, CORE_JOINTS, ) def _mean(points: list[tuple[float, float]]) -> tuple[float, float]: n = len(points) return (sum(p[0] for p in points) / n, sum(p[1] for p in points) / n) def com(keypoints: list[Keypoint]) -> tuple[float, float]: pts = [(keypoints[i].x, keypoints[i].y) for i in CORE_JOINTS] return _mean(pts) def _shoulder_center(kps: list[Keypoint]) -> tuple[float, float]: return _mean([(kps[L_SHOULDER].x, kps[L_SHOULDER].y), (kps[R_SHOULDER].x, kps[R_SHOULDER].y)]) def _hip_center(kps: list[Keypoint]) -> tuple[float, float]: return _mean([(kps[L_HIP].x, kps[L_HIP].y), (kps[R_HIP].x, kps[R_HIP].y)]) def torso_angle_deg(keypoints: list[Keypoint]) -> float: """몸통(어깨중심→엉덩이중심) 벡터가 수직축과 이루는 각. 0=수직, 90=수평.""" sx, sy = _shoulder_center(keypoints) hx, hy = _hip_center(keypoints) vx, vy = hx - sx, hy - sy # 수직축(0,1) 대비 편차. 위/아래 무관하게 |성분| 사용. return math.degrees(math.atan2(abs(vx), abs(vy))) if (vx or vy) else 0.0 def head_below_hips(keypoints: list[Keypoint]) -> bool: _, hy = _hip_center(keypoints) return keypoints[NOSE].y > hy def visible_count(keypoints: list[Keypoint], thr: float = 0.2) -> int: return sum(1 for k in keypoints if k.conf >= thr) def core_visible(keypoints: list[Keypoint], thr: float = 0.5) -> bool: return all(keypoints[i].conf >= thr for i in CORE_JOINTS) def height_of(bbox: BBox) -> float: return bbox.height def normalize(value: float, bbox_height: float) -> float: return value / bbox_height if bbox_height else 0.0 ``` - [ ] **Step 4: 통과 확인** Run: `pytest tests/test_geometry.py -v` → Expected: PASS - [ ] **Step 5: 커밋** ```bash git add edge/geometry.py tests/test_geometry.py git commit -m "feat(edge): pose geometry utils (torso angle, com, normalization)" ``` --- ## Task 3: 키포인트 스무딩 (One-Euro) **Files:** - Create: `edge/smoothing.py`, `tests/test_smoothing.py` **Interfaces:** - Consumes: `Keypoint` (Task 1) - Produces: - `OneEuroFilter(min_cutoff=1.0, beta=0.0)` with `filter(value: float, t: float) -> float` - `KeypointSmoother(min_cutoff=1.0, beta=0.007)` with `smooth(keypoints: list[Keypoint], t: float) -> list[Keypoint]` (관절별 x,y 스무딩, conf 통과) - [ ] **Step 1: 실패 테스트** — `tests/test_smoothing.py` ```python from edge.models import Keypoint from edge.smoothing import OneEuroFilter, KeypointSmoother def test_one_euro_first_sample_returns_input(): f = OneEuroFilter(min_cutoff=1.0, beta=0.0) assert f.filter(5.0, t=0.0) == 5.0 def test_one_euro_attenuates_jitter(): f = OneEuroFilter(min_cutoff=0.5, beta=0.0) f.filter(0.0, t=0.0) out = f.filter(10.0, t=0.1) # big jump at low cutoff -> damped below input assert 0.0 < out < 10.0 def test_keypoint_smoother_preserves_conf_and_length(): s = KeypointSmoother() kps = [Keypoint(float(i), float(i), 0.8) for i in range(17)] out = s.smooth(kps, t=0.0) assert len(out) == 17 assert out[3].conf == 0.8 ``` - [ ] **Step 2: 실패 확인** Run: `pytest tests/test_smoothing.py -v` → Expected: FAIL — `ModuleNotFoundError: edge.smoothing` - [ ] **Step 3: 구현** — `edge/smoothing.py` ```python import math from edge.models import Keypoint class OneEuroFilter: """1€ filter (Casiez et al.). 저지연 지터 감쇠.""" def __init__(self, min_cutoff: float = 1.0, beta: float = 0.0, d_cutoff: float = 1.0): self.min_cutoff = min_cutoff self.beta = beta self.d_cutoff = d_cutoff self._x_prev: float | None = None self._dx_prev: float = 0.0 self._t_prev: float | None = None @staticmethod def _alpha(cutoff: float, dt: float) -> float: tau = 1.0 / (2 * math.pi * cutoff) return 1.0 / (1.0 + tau / dt) def filter(self, value: float, t: float) -> float: if self._x_prev is None or self._t_prev is None or t <= self._t_prev: self._x_prev, self._t_prev, self._dx_prev = value, t, 0.0 return value dt = t - self._t_prev dx = (value - self._x_prev) / dt a_d = self._alpha(self.d_cutoff, dt) dx_hat = a_d * dx + (1 - a_d) * self._dx_prev cutoff = self.min_cutoff + self.beta * abs(dx_hat) a = self._alpha(cutoff, dt) x_hat = a * value + (1 - a) * self._x_prev self._x_prev, self._dx_prev, self._t_prev = x_hat, dx_hat, t return x_hat class KeypointSmoother: """관절 17개 각각 x,y에 독립 1€ 필터.""" def __init__(self, min_cutoff: float = 1.0, beta: float = 0.007): self._fx = [OneEuroFilter(min_cutoff, beta) for _ in range(17)] self._fy = [OneEuroFilter(min_cutoff, beta) for _ in range(17)] def smooth(self, keypoints: list[Keypoint], t: float) -> list[Keypoint]: out = [] for i, k in enumerate(keypoints): out.append(Keypoint(self._fx[i].filter(k.x, t), self._fy[i].filter(k.y, t), k.conf)) return out ``` - [ ] **Step 4: 통과 확인** Run: `pytest tests/test_smoothing.py -v` → Expected: PASS - [ ] **Step 5: 커밋** ```bash git add edge/smoothing.py tests/test_smoothing.py git commit -m "feat(edge): One-Euro keypoint smoothing (INT8 jitter)" ``` --- ## Task 4: 추적 (Track 모델 + 경량 IoU 추적기 + 공간 바인딩) **Files:** - Create: `edge/tracking.py`, `edge/ports.py`, `tests/test_tracking.py` **Interfaces:** - Consumes: `Person, BBox, Keypoint` (Task 1) - Produces: - `ports.PosePort` (Protocol): `infer(frame) -> list[Person]` - `ports.SourcePort` (Protocol): `read() -> tuple[bool, object]` (ok, frame) - `TrackFrame(t: float, bbox: BBox, keypoints: list[Keypoint])` - `Track(track_id: int, frames: deque[TrackFrame])` — `latest` 프로퍼티 - `TrackerPort` (Protocol): `update(persons: list[Person], t: float) -> list[Track]` - `IouTracker(iou_thr=0.3, max_age=1.0, history=90)` implements TrackerPort; **소실 트랙은 `lost()` 로 노출**(공간 바인딩·유령사고용): `lost() -> list[Track]` - [ ] **Step 1: 실패 테스트** — `tests/test_tracking.py` ```python from edge.models import Person, BBox, Keypoint from edge.tracking import IouTracker def _person(x1, y1, x2, y2): kps = [Keypoint((x1 + x2) / 2, (y1 + y2) / 2, 0.9) for _ in range(17)] return Person(BBox(x1, y1, x2, y2), kps) def test_new_person_gets_track_id(): tr = IouTracker() tracks = tr.update([_person(0, 0, 10, 20)], t=0.0) assert len(tracks) == 1 assert tracks[0].track_id == 1 def test_overlapping_person_keeps_same_id(): tr = IouTracker(iou_thr=0.3) tr.update([_person(0, 0, 10, 20)], t=0.0) tracks = tr.update([_person(1, 1, 11, 21)], t=0.1) # high overlap assert tracks[0].track_id == 1 def test_disappeared_track_is_reported_lost(): tr = IouTracker(max_age=0.2) tr.update([_person(0, 0, 10, 20)], t=0.0) tr.update([], t=0.5) # nothing detected, past max_age lost = tr.lost() assert len(lost) == 1 assert lost[0].track_id == 1 def test_history_accumulates_frames(): tr = IouTracker() tr.update([_person(0, 0, 10, 20)], t=0.0) tracks = tr.update([_person(0, 0, 10, 20)], t=0.1) assert len(tracks[0].frames) == 2 assert tracks[0].latest.t == 0.1 ``` - [ ] **Step 2: 실패 확인** Run: `pytest tests/test_tracking.py -v` → Expected: FAIL — `ModuleNotFoundError: edge.tracking` - [ ] **Step 3: 구현** — `edge/ports.py` ```python from typing import Protocol class SourcePort(Protocol): def read(self) -> tuple[bool, object]: """(ok, frame). ok=False면 프레임 없음.""" ... class PosePort(Protocol): def infer(self, frame) -> list: # list[Person] ... ``` 그리고 `edge/tracking.py`: ```python from collections import deque from dataclasses import dataclass from edge.models import Person, BBox, Keypoint @dataclass class TrackFrame: t: float bbox: BBox keypoints: list # list[Keypoint] @dataclass class Track: track_id: int frames: deque @property def latest(self) -> TrackFrame: return self.frames[-1] def _iou(a: BBox, b: BBox) -> float: ix1, iy1 = max(a.x1, b.x1), max(a.y1, b.y1) ix2, iy2 = min(a.x2, b.x2), min(a.y2, b.y2) iw, ih = max(0.0, ix2 - ix1), max(0.0, iy2 - iy1) inter = iw * ih union = a.width * a.height + b.width * b.height - inter return inter / union if union else 0.0 class IouTracker: """경량 IoU 그리디 매칭 추적기. 실장비는 ByteTrack 어댑터로 교체 가능(동일 TrackerPort).""" def __init__(self, iou_thr: float = 0.3, max_age: float = 1.0, history: int = 90): self.iou_thr = iou_thr self.max_age = max_age self.history = history self._tracks: dict[int, Track] = {} self._last_seen: dict[int, float] = {} self._lost: list[Track] = [] self._next_id = 1 def update(self, persons: list[Person], t: float) -> list[Track]: self._lost = [] unmatched = list(persons) # 기존 트랙과 그리디 IoU 매칭 for tid, track in list(self._tracks.items()): best, best_iou = None, self.iou_thr for p in unmatched: iou = _iou(track.latest.bbox, p.bbox) if iou >= best_iou: best, best_iou = p, iou if best is not None: unmatched.remove(best) track.frames.append(TrackFrame(t, best.bbox, best.keypoints)) self._last_seen[tid] = t # 신규 트랙 for p in unmatched: tid = self._next_id self._next_id += 1 self._tracks[tid] = Track(tid, deque([TrackFrame(t, p.bbox, p.keypoints)], maxlen=self.history)) self._last_seen[tid] = t # 소실 판정 for tid in list(self._tracks.keys()): if t - self._last_seen[tid] > self.max_age: self._lost.append(self._tracks.pop(tid)) self._last_seen.pop(tid, None) return list(self._tracks.values()) def lost(self) -> list[Track]: return list(self._lost) ``` - [ ] **Step 4: 통과 확인** Run: `pytest tests/test_tracking.py -v` → Expected: PASS - [ ] **Step 5: 커밋** ```bash git add edge/ports.py edge/tracking.py tests/test_tracking.py git commit -m "feat(edge): Track model + IoU tracker with lost-track reporting" ``` --- ## Task 5: 이벤트 모델 (Event, 심각도) **Files:** - Create: `edge/events.py`, `tests/test_events.py` **Interfaces:** - Consumes: (없음) - Produces: - `EventType` (str enum): `FALL, NO_MOTION, IMMOBILITY, GHOST, ZONE_WARNING, ZONE_RESTRICTED, PINCH, MONITORING_DEGRADED, DEVICE_FAULT` - `Severity` (str enum): `CRITICAL, HIGH, WARNING, INFO` - `Event(event_type, severity, camera_id, track_id, zone_id, occurred_at, duration_s, metrics: dict, spatial: tuple|None)` — `@dataclass(frozen=True)`, `to_payload(device_id) -> dict` (§7.3 계약 형태, snapshots 제외) - `severity_for(event_type: EventType) -> Severity` - [ ] **Step 1: 실패 테스트** — `tests/test_events.py` ```python from edge.events import EventType, Severity, Event, severity_for def test_severity_mapping(): assert severity_for(EventType.NO_MOTION) == Severity.CRITICAL assert severity_for(EventType.ZONE_RESTRICTED) == Severity.HIGH assert severity_for(EventType.FALL) == Severity.HIGH assert severity_for(EventType.IMMOBILITY) == Severity.WARNING assert severity_for(EventType.ZONE_WARNING) == Severity.WARNING def test_event_to_payload_shape(): e = Event(EventType.FALL, Severity.HIGH, camera_id="cam-01", track_id=7, zone_id=None, occurred_at=1000.0, duration_s=3.2, metrics={"torso_angle": 78.0}, spatial=None) p = e.to_payload(device_id="pi-01") assert p["device_id"] == "pi-01" assert p["event_type"] == "fall" assert p["severity"] == "high" assert p["track_id"] == 7 assert p["metrics"]["torso_angle"] == 78.0 assert "snapshots" not in p # snapshots는 Alerter가 붙임 ``` - [ ] **Step 2: 실패 확인** Run: `pytest tests/test_events.py -v` → Expected: FAIL — `ModuleNotFoundError: edge.events` - [ ] **Step 3: 구현** — `edge/events.py` ```python from dataclasses import dataclass from enum import Enum class EventType(str, Enum): FALL = "fall" NO_MOTION = "no_motion" IMMOBILITY = "immobility" GHOST = "ghost" ZONE_WARNING = "zone_warning" ZONE_RESTRICTED = "zone_restricted" PINCH = "pinch" MONITORING_DEGRADED = "monitoring_degraded" DEVICE_FAULT = "device_fault" class Severity(str, Enum): CRITICAL = "critical" HIGH = "high" WARNING = "warning" INFO = "info" _SEVERITY = { EventType.NO_MOTION: Severity.CRITICAL, EventType.GHOST: Severity.CRITICAL, EventType.FALL: Severity.HIGH, EventType.ZONE_RESTRICTED: Severity.HIGH, EventType.PINCH: Severity.HIGH, EventType.IMMOBILITY: Severity.WARNING, EventType.ZONE_WARNING: Severity.WARNING, EventType.MONITORING_DEGRADED: Severity.WARNING, EventType.DEVICE_FAULT: Severity.WARNING, } def severity_for(event_type: EventType) -> Severity: return _SEVERITY.get(event_type, Severity.INFO) @dataclass(frozen=True) class Event: event_type: EventType severity: Severity camera_id: str track_id: int | None zone_id: str | None occurred_at: float duration_s: float metrics: dict spatial: tuple | None # (x, y) 유령사고 등 공간 기준 def to_payload(self, device_id: str) -> dict: return { "device_id": device_id, "camera_id": self.camera_id, "event_type": self.event_type.value, "severity": self.severity.value, "track_id": self.track_id, "zone_id": self.zone_id, "occurred_at": self.occurred_at, "duration_s": round(self.duration_s, 2), "metrics": self.metrics, } ``` - [ ] **Step 4: 통과 확인** Run: `pytest tests/test_events.py -v` → Expected: PASS - [ ] **Step 5: 커밋** ```bash git add edge/events.py tests/test_events.py git commit -m "feat(edge): Event model + severity mapping (§7.3 payload)" ``` --- ## Task 6: FallDetector (상태머신 + OR로직 + baseline freeze + 낙상후 미동) **Files:** - Create: `edge/detectors/__init__.py`(빈 파일), `edge/detectors/fall.py`, `tests/test_fall.py` **Interfaces:** - Consumes: `Track, TrackFrame` (Task 4); `torso_angle_deg, head_below_hips, core_visible, visible_count, normalize, com` (Task 2) - Produces: - `FallConfig(falling_angle=45, fallen_angle=70, aspect_ratio=1.25, height_collapse=0.35, prone_confirm_s=3.0, no_motion_s=10.0, motion_thr=0.02, min_conf=0.5, min_visible=10)` - `FallDetector(cfg: FallConfig)` with `update(track: Track, now: float) -> tuple[str|None, dict]` - 반환: `("fall", metrics)` (FALLEN 확정), `("no_motion", metrics)` (낙상후 미동 ≥ no_motion_s), `("recover", {})` (자세 복원), 아니면 `(None, {})` - 내부 상태: `STANDING/FALLING/FALLEN/NO_MOTION` (track_id별 `_State` dict, `_State.name` 노출), incident 활성 중 baseline(정상 키높이) 동결 - [ ] **Step 1: 실패 테스트** — `tests/test_fall.py` ```python from collections import deque from edge.models import Keypoint, BBox from edge.tracking import Track, TrackFrame from edge.detectors.fall import FallDetector, FallConfig def _standing_kps(): kps = [Keypoint(50, 150, 0.9) for _ in range(17)] kps[5] = Keypoint(40, 100, 0.9); kps[6] = Keypoint(60, 100, 0.9) kps[11] = Keypoint(40, 200, 0.9); kps[12] = Keypoint(60, 200, 0.9) kps[0] = Keypoint(50, 60, 0.9) # nose above hips return kps def _lying_kps(): 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) # nose below hips return kps def _track(): return Track(1, deque(maxlen=90)) def _feed(det, track, kps, bbox, t): track.frames.append(TrackFrame(t, bbox, kps)) return det.update(track, now=t) def test_standing_produces_no_event(): det = FallDetector(FallConfig()) tr = _track() sig, _ = _feed(det, tr, _standing_kps(), BBox(40, 60, 60, 210), t=0.0) assert sig is None def test_lying_confirmed_after_prone_seconds_emits_fall(): det = FallDetector(FallConfig(prone_confirm_s=1.0)) tr = _track() lying_box = BBox(90, 290, 210, 320) # 넓고 낮음 (aspect>1.25) _feed(det, tr, _lying_kps(), lying_box, t=0.0) sig, meta = _feed(det, tr, _lying_kps(), lying_box, t=1.5) # prone 1.5s > 1.0 assert sig == "fall" assert meta["torso_angle"] > 80 def test_front_fall_height_collapse_triggers_without_angle(): # 정면 낙상: 각도/종횡비 안 변해도 bbox 높이 급붕괴 det = FallDetector(FallConfig(prone_confirm_s=0.5)) tr = _track() tall = BBox(40, 60, 60, 210) # height 150 _feed(det, tr, _standing_kps(), tall, t=0.0) short = BBox(40, 180, 60, 210) # height 30 (붕괴 0.8) _feed(det, tr, _standing_kps(), short, t=0.2) sig, _ = _feed(det, tr, _standing_kps(), short, t=0.9) assert sig == "fall" def test_no_motion_after_fall_escalates(): det = FallDetector(FallConfig(prone_confirm_s=0.5, no_motion_s=1.0, motion_thr=0.05)) tr = _track() box = BBox(90, 290, 210, 320) _feed(det, tr, _lying_kps(), box, t=0.0) _feed(det, tr, _lying_kps(), box, t=0.6) # -> fall _feed(det, tr, _lying_kps(), box, t=1.0) # 미동 지속 sig, _ = _feed(det, tr, _lying_kps(), box, t=2.0) # 부동 >1.0s assert sig == "no_motion" def test_recovery_clears_state(): det = FallDetector(FallConfig(prone_confirm_s=0.5)) tr = _track() box = BBox(90, 290, 210, 320) _feed(det, tr, _lying_kps(), box, t=0.0) _feed(det, tr, _lying_kps(), box, t=0.6) # fall sig, _ = _feed(det, tr, _standing_kps(), BBox(40, 60, 60, 210), t=1.0) assert sig == "recover" def test_low_confidence_is_ignored(): det = FallDetector(FallConfig()) tr = _track() kps = [Keypoint(0, 0, 0.1) for _ in range(17)] # 전부 저신뢰 sig, _ = _feed(det, tr, kps, BBox(0, 0, 100, 30), t=0.0) assert sig is None ``` - [ ] **Step 2: 실패 확인** Run: `pytest tests/test_fall.py -v` → Expected: FAIL — `ModuleNotFoundError: edge.detectors.fall` - [ ] **Step 3: 구현** — `edge/detectors/fall.py` ```python from dataclasses import dataclass from edge.tracking import Track from edge.geometry import ( torso_angle_deg, head_below_hips, core_visible, visible_count, normalize, ) @dataclass class FallConfig: falling_angle: float = 45.0 fallen_angle: float = 70.0 aspect_ratio: float = 1.25 height_collapse: float = 0.35 # baseline 대비 남은 높이 비율(이 값 미만이면 급붕괴) prone_confirm_s: float = 3.0 no_motion_s: float = 10.0 motion_thr: float = 0.02 # 정규화 관절변위 임계 min_conf: float = 0.5 min_visible: int = 10 @dataclass class _State: name: str = "STANDING" baseline_height: float | None = None prone_since: float | None = None still_since: float | None = None fall_emitted: bool = False no_motion_emitted: bool = False class FallDetector: def __init__(self, cfg: FallConfig): self.cfg = cfg self._states: dict[int, _State] = {} def _prone(self, kps, bbox, st: _State) -> tuple[bool, dict]: angle = torso_angle_deg(kps) ar = bbox.aspect remaining = 1.0 if st.baseline_height: remaining = bbox.height / st.baseline_height prone = ( angle > self.cfg.fallen_angle or ar > self.cfg.aspect_ratio or remaining < self.cfg.height_collapse # 높이 급붕괴(정면/후면 낙상) or head_below_hips(kps) ) return prone, {"torso_angle": round(angle, 1), "aspect_ratio": round(ar, 2), "height_remaining": round(remaining, 2)} def _displacement(self, track: Track) -> float: if len(track.frames) < 2: return 1.0 a, b = track.frames[-2], track.frames[-1] h = b.bbox.height or 1.0 d = sum(((p.x - q.x) ** 2 + (p.y - q.y) ** 2) ** 0.5 for p, q in zip(a.keypoints, b.keypoints)) / len(b.keypoints) return normalize(d, h) def update(self, track: Track, now: float) -> tuple[str | None, dict]: st = self._states.setdefault(track.track_id, _State()) f = track.latest kps, bbox = f.keypoints, f.bbox # 신뢰도 게이트 if not core_visible(kps, self.cfg.min_conf) or visible_count(kps) < self.cfg.min_visible: return None, {} prone, meta = self._prone(kps, bbox, st) # incident 비활성일 때만 baseline(정상 키높이) 갱신 = incident 중 freeze if st.name == "STANDING" and not prone: st.baseline_height = bbox.height if not prone: if st.name in ("FALLING", "FALLEN", "NO_MOTION"): self._states[track.track_id] = _State(baseline_height=bbox.height) return "recover", {} st.prone_since = None return None, {} # prone 상태 if st.prone_since is None: st.prone_since = now st.name = "FALLING" if now - st.prone_since >= self.cfg.prone_confirm_s and not st.fall_emitted: st.name = "FALLEN" st.fall_emitted = True st.still_since = now return "fall", meta # FALLEN 이후 미동 감시 if st.fall_emitted: if self._displacement(track) < self.cfg.motion_thr: if st.still_since is None: st.still_since = now elif now - st.still_since >= self.cfg.no_motion_s and not st.no_motion_emitted: st.name = "NO_MOTION" st.no_motion_emitted = True return "no_motion", {**meta, "immobility_s": round(now - st.still_since, 1)} else: st.still_since = None return None, {} ``` - [ ] **Step 4: 통과 확인** Run: `pytest tests/test_fall.py -v` → Expected: PASS - [ ] **Step 5: 커밋** ```bash git add edge/detectors/__init__.py edge/detectors/fall.py tests/test_fall.py git commit -m "feat(edge): FallDetector state machine (OR-logic, baseline freeze, post-fall no-motion)" ``` --- ## Task 7: ImmobilityWatchdog (자세무관 부동) **Files:** - Create: `edge/detectors/immobility.py`, `tests/test_immobility.py` **Interfaces:** - Consumes: `Track` (Task 4); `normalize` (Task 2) - Produces: - `ImmobilityConfig(motion_thr=0.02, immobile_s=120.0, zone_immobile_s=30.0)` - `ImmobilityWatchdog(cfg)` with `update(track: Track, now: float, in_danger_zone: bool = False) -> tuple[bool, dict]` — `(True, meta)` 최초 부동 임계 초과 시 1회, 이후 `(False, {})`; 움직이면 리셋 - [ ] **Step 1: 실패 테스트** — `tests/test_immobility.py` ```python from collections import deque from edge.models import Keypoint, BBox from edge.tracking import Track, TrackFrame from edge.detectors.immobility import ImmobilityWatchdog, ImmobilityConfig def _kps(x, y): return [Keypoint(x, y, 0.9) for _ in range(17)] def _push(track, x, y, t): track.frames.append(TrackFrame(t, BBox(x, y, x + 20, y + 60), _kps(x, y))) def test_still_person_triggers_after_threshold(): w = ImmobilityWatchdog(ImmobilityConfig(motion_thr=0.05, immobile_s=1.0)) tr = Track(1, deque(maxlen=90)) _push(tr, 100, 100, 0.0) assert w.update(tr, now=0.0)[0] is False _push(tr, 100, 100, 0.5) assert w.update(tr, now=0.5)[0] is False _push(tr, 100, 100, 1.5) # 1.5s 정지 fired, meta = w.update(tr, now=1.5) assert fired is True assert meta["immobility_s"] >= 1.0 def test_fires_only_once_until_movement(): w = ImmobilityWatchdog(ImmobilityConfig(motion_thr=0.05, immobile_s=1.0)) tr = Track(1, deque(maxlen=90)) _push(tr, 100, 100, 0.0) _push(tr, 100, 100, 1.5); w.update(tr, now=1.5) _push(tr, 100, 100, 2.0) assert w.update(tr, now=2.0)[0] is False # 재발화 안 함 def test_movement_resets_timer(): w = ImmobilityWatchdog(ImmobilityConfig(motion_thr=0.05, immobile_s=1.0)) tr = Track(1, deque(maxlen=90)) _push(tr, 100, 100, 0.0) _push(tr, 100, 100, 0.9); w.update(tr, now=0.9) _push(tr, 300, 300, 1.0) # 크게 이동 fired, _ = w.update(tr, now=1.0) assert fired is False def test_danger_zone_uses_shorter_window(): w = ImmobilityWatchdog(ImmobilityConfig(motion_thr=0.05, immobile_s=100.0, zone_immobile_s=1.0)) tr = Track(1, deque(maxlen=90)) _push(tr, 100, 100, 0.0) _push(tr, 100, 100, 1.5) fired, _ = w.update(tr, now=1.5, in_danger_zone=True) assert fired is True ``` - [ ] **Step 2: 실패 확인** Run: `pytest tests/test_immobility.py -v` → Expected: FAIL — `ModuleNotFoundError` - [ ] **Step 3: 구현** — `edge/detectors/immobility.py` ```python from dataclasses import dataclass from edge.tracking import Track from edge.geometry import normalize @dataclass class ImmobilityConfig: motion_thr: float = 0.02 immobile_s: float = 120.0 zone_immobile_s: float = 30.0 class ImmobilityWatchdog: def __init__(self, cfg: ImmobilityConfig): self.cfg = cfg self._still_since: dict[int, float] = {} self._fired: set[int] = set() def _displacement(self, track: Track) -> float: if len(track.frames) < 2: return 1.0 a, b = track.frames[-2], track.frames[-1] h = b.bbox.height or 1.0 d = sum(((p.x - q.x) ** 2 + (p.y - q.y) ** 2) ** 0.5 for p, q in zip(a.keypoints, b.keypoints)) / len(b.keypoints) return normalize(d, h) def update(self, track: Track, now: float, in_danger_zone: bool = False) -> tuple[bool, dict]: tid = track.track_id window = self.cfg.zone_immobile_s if in_danger_zone else self.cfg.immobile_s if self._displacement(track) >= self.cfg.motion_thr: self._still_since.pop(tid, None) self._fired.discard(tid) return False, {} start = self._still_since.setdefault(tid, now) if now - start >= window and tid not in self._fired: self._fired.add(tid) return True, {"immobility_s": round(now - start, 1)} return False, {} ``` - [ ] **Step 4: 통과 확인** Run: `pytest tests/test_immobility.py -v` → Expected: PASS - [ ] **Step 5: 커밋** ```bash git add edge/detectors/immobility.py tests/test_immobility.py git commit -m "feat(edge): ImmobilityWatchdog (posture-independent prolonged immobility)" ``` --- ## Task 8: GhostIncidentDetector (트랙 소실 공간사고) **Files:** - Create: `edge/detectors/ghost.py`, `tests/test_ghost.py` **Interfaces:** - Consumes: `Track` (Task 4) - Produces: - `GhostConfig(reappear_s=10.0, reappear_dist=1.0)` (dist는 bbox 높이 배수) - `GhostIncidentDetector(cfg)`: - `note_state(track_id: int, state: str)` — FallDetector 상태를 공유(위험상태 트랙만 유령 후보) - `on_lost(lost_tracks: list[Track], now: float)` — 위험상태(FALLING/FALLEN/NO_MOTION)로 사라진 트랙을 후보 등록 - `update(active_tracks: list[Track], now: float) -> list[dict]` — 재출현 없이 reappear_s 경과한 후보를 유령사고로 방출(`{"spatial": (x,y), "last_state": ..., "waited_s": ...}`) - [ ] **Step 1: 실패 테스트** — `tests/test_ghost.py` ```python from collections import deque from edge.models import Keypoint, BBox from edge.tracking import Track, TrackFrame from edge.detectors.ghost import GhostIncidentDetector, GhostConfig def _track(tid, x, y, t): fr = TrackFrame(t, BBox(x, y, x + 20, y + 60), [Keypoint(x, y, 0.9) for _ in range(17)]) return Track(tid, deque([fr], maxlen=90)) def test_lost_dangerous_track_becomes_ghost_after_wait(): d = GhostIncidentDetector(GhostConfig(reappear_s=1.0)) d.note_state(1, "FALLEN") d.on_lost([_track(1, 100, 100, 0.0)], now=0.0) assert d.update([], now=0.5) == [] # 아직 대기 ghosts = d.update([], now=1.5) # 재출현 없음 > 1.0s assert len(ghosts) == 1 assert ghosts[0]["last_state"] == "FALLEN" assert ghosts[0]["spatial"] == (110.0, 130.0) # bbox 중심 def test_non_dangerous_lost_track_is_ignored(): d = GhostIncidentDetector(GhostConfig(reappear_s=1.0)) d.note_state(1, "STANDING") d.on_lost([_track(1, 100, 100, 0.0)], now=0.0) assert d.update([], now=2.0) == [] def test_reappearance_near_location_cancels_ghost(): d = GhostIncidentDetector(GhostConfig(reappear_s=1.0, reappear_dist=2.0)) d.note_state(1, "FALLEN") d.on_lost([_track(1, 100, 100, 0.0)], now=0.0) # 인근에 새 트랙 재출현 assert d.update([_track(2, 105, 105, 0.5)], now=0.5) == [] assert d.update([], now=2.0) == [] # 취소되어 방출 없음 ``` - [ ] **Step 2: 실패 확인** Run: `pytest tests/test_ghost.py -v` → Expected: FAIL — `ModuleNotFoundError` - [ ] **Step 3: 구현** — `edge/detectors/ghost.py` ```python from dataclasses import dataclass from edge.tracking import Track _DANGEROUS = {"FALLING", "FALLEN", "NO_MOTION"} @dataclass class GhostConfig: reappear_s: float = 10.0 reappear_dist: float = 1.0 # bbox 높이 배수 class GhostIncidentDetector: def __init__(self, cfg: GhostConfig): self.cfg = cfg self._states: dict[int, str] = {} # 후보: track_id -> (spatial(x,y), last_state, height, lost_at) self._candidates: dict[int, tuple] = {} def note_state(self, track_id: int, state: str) -> None: self._states[track_id] = state def on_lost(self, lost_tracks: list[Track], now: float) -> None: for tr in lost_tracks: state = self._states.get(tr.track_id, "STANDING") if state in _DANGEROUS: b = tr.latest.bbox self._candidates[tr.track_id] = ((b.cx, b.cy), state, b.height, now) self._states.pop(tr.track_id, None) def update(self, active_tracks: list[Track], now: float) -> list[dict]: # 인근 재출현이면 후보 취소 for tid, (spatial, _state, height, _lost_at) in list(self._candidates.items()): for tr in active_tracks: b = tr.latest.bbox dist = ((b.cx - spatial[0]) ** 2 + (b.cy - spatial[1]) ** 2) ** 0.5 if dist <= self.cfg.reappear_dist * (height or 1.0): self._candidates.pop(tid, None) break # 대기 초과 방출 out = [] for tid, (spatial, state, _height, lost_at) in list(self._candidates.items()): if now - lost_at >= self.cfg.reappear_s: out.append({"spatial": spatial, "last_state": state, "waited_s": round(now - lost_at, 1)}) self._candidates.pop(tid, None) return out ``` - [ ] **Step 4: 통과 확인** Run: `pytest tests/test_ghost.py -v` → Expected: PASS - [ ] **Step 5: 커밋** ```bash git add edge/detectors/ghost.py tests/test_ghost.py git commit -m "feat(edge): GhostIncidentDetector (collapse-then-occluded spatial incident)" ``` --- ## Task 9: ZoneIntrusionDetector (점-다각형 + 디바운스) **Files:** - Create: `edge/detectors/zone.py`, `tests/test_zone.py` **Interfaces:** - Consumes: `Track` (Task 4); `L_ANKLE, R_ANKLE` (Task 1) - Produces: - `point_in_polygon(x: float, y: float, poly: list[tuple[float,float]]) -> bool` (ray-casting) - `Zone(zone_id: str, level: str, polygon: list[tuple[float,float]])` — level ∈ {"warning","restricted"} - `ZoneConfig(debounce_s=0.75)` - `ZoneIntrusionDetector(zones: list[Zone], cfg)` with `update(track: Track, now: float) -> list[dict]` — 발목점이 폴리곤 안에 debounce_s 이상 지속 시 `{"zone_id","level"}` 1회 방출; 이탈 시 리셋. `active_zone_ids(track_id) -> set[str]` (부동 워치독 연동용) - [ ] **Step 1: 실패 테스트** — `tests/test_zone.py` ```python from collections import deque from edge.models import Keypoint, BBox from edge.tracking import Track, TrackFrame from edge.detectors.zone import point_in_polygon, Zone, ZoneIntrusionDetector, ZoneConfig SQUARE = [(0, 0), (100, 0), (100, 100), (0, 100)] def test_point_in_polygon_basic(): assert point_in_polygon(50, 50, SQUARE) is True assert point_in_polygon(150, 50, SQUARE) is False def _track_with_ankles(ax, ay, t): kps = [Keypoint(ax, ay, 0.9) for _ in range(17)] kps[15] = Keypoint(ax, ay, 0.9) # L_ANKLE kps[16] = Keypoint(ax, ay, 0.9) # R_ANKLE return Track(1, deque([TrackFrame(t, BBox(ax - 10, ay - 60, ax + 10, ay), kps)], maxlen=90)) def test_intrusion_fires_after_debounce(): z = Zone("z1", "restricted", SQUARE) det = ZoneIntrusionDetector([z], ZoneConfig(debounce_s=0.5)) tr = _track_with_ankles(50, 50, 0.0) assert det.update(tr, now=0.0) == [] tr = _track_with_ankles(50, 50, 0.6) out = det.update(tr, now=0.6) assert out == [{"zone_id": "z1", "level": "restricted"}] def test_leaving_zone_resets(): z = Zone("z1", "warning", SQUARE) det = ZoneIntrusionDetector([z], ZoneConfig(debounce_s=0.5)) det.update(_track_with_ankles(50, 50, 0.0), now=0.0) det.update(_track_with_ankles(200, 200, 0.6), now=0.6) # 이탈 out = det.update(_track_with_ankles(50, 50, 1.0), now=1.0) # 재진입, 디바운스 재시작 assert out == [] ``` - [ ] **Step 2: 실패 확인** Run: `pytest tests/test_zone.py -v` → Expected: FAIL — `ModuleNotFoundError` - [ ] **Step 3: 구현** — `edge/detectors/zone.py` ```python from dataclasses import dataclass from edge.tracking import Track from edge.models import L_ANKLE, R_ANKLE def point_in_polygon(x: float, y: float, poly: list[tuple[float, float]]) -> bool: inside = False n = len(poly) j = n - 1 for i in range(n): xi, yi = poly[i] xj, yj = poly[j] if ((yi > y) != (yj > y)) and (x < (xj - xi) * (y - yi) / ((yj - yi) or 1e-9) + xi): inside = not inside j = i return inside @dataclass(frozen=True) class Zone: zone_id: str level: str # "warning" | "restricted" polygon: list # list[tuple[float,float]] @dataclass class ZoneConfig: debounce_s: float = 0.75 class ZoneIntrusionDetector: def __init__(self, zones: list[Zone], cfg: ZoneConfig): self.zones = zones self.cfg = cfg self._inside_since: dict[tuple[int, str], float] = {} self._fired: set[tuple[int, str]] = set() def _foot(self, track: Track) -> tuple[float, float]: kps = track.latest.keypoints a, b = kps[L_ANKLE], kps[R_ANKLE] return ((a.x + b.x) / 2, (a.y + b.y) / 2) def active_zone_ids(self, track_id: int) -> set: return {z for (tid, z) in self._inside_since if tid == track_id} def update(self, track: Track, now: float) -> list[dict]: fx, fy = self._foot(track) out = [] for zone in self.zones: key = (track.track_id, zone.zone_id) if point_in_polygon(fx, fy, zone.polygon): start = self._inside_since.setdefault(key, now) if now - start >= self.cfg.debounce_s and key not in self._fired: self._fired.add(key) out.append({"zone_id": zone.zone_id, "level": zone.level}) else: self._inside_since.pop(key, None) self._fired.discard(key) return out ``` - [ ] **Step 4: 통과 확인** Run: `pytest tests/test_zone.py -v` → Expected: PASS - [ ] **Step 5: 커밋** ```bash git add edge/detectors/zone.py tests/test_zone.py git commit -m "feat(edge): ZoneIntrusionDetector (foot-anchor point-in-polygon, debounce)" ``` --- ## Task 10: EventEngine (오케스트레이션 + dedup + cooldown) **Files:** - Create: `edge/engine.py`, `tests/test_engine.py` **Interfaces:** - Consumes: `Track` (Task 4); `FallDetector` (Task 6); `ImmobilityWatchdog` (Task 7); `GhostIncidentDetector` (Task 8); `ZoneIntrusionDetector` (Task 9); `Event, EventType, severity_for` (Task 5) - Produces: - `EngineConfig(camera_id="cam", cooldown_s=30.0)` - `EventEngine(fall, immobility, ghost, zone, cfg)` with `process(active_tracks: list[Track], lost_tracks: list[Track], now: float) -> list[Event]` - [ ] **Step 1: 실패 테스트** — `tests/test_engine.py` ```python from collections import deque from edge.models import Keypoint, BBox from edge.tracking import Track, TrackFrame from edge.events import EventType from edge.detectors.fall import FallDetector, FallConfig from edge.detectors.immobility import ImmobilityWatchdog, ImmobilityConfig from edge.detectors.ghost import GhostIncidentDetector, GhostConfig from edge.detectors.zone import ZoneIntrusionDetector, ZoneConfig from edge.engine import EventEngine, EngineConfig def _lying_kps(): 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 kps def _lying_track(track, t): track.frames.append(TrackFrame(t, BBox(90, 290, 210, 320), _lying_kps())) return track def _engine(): return EventEngine( FallDetector(FallConfig(prone_confirm_s=0.5)), ImmobilityWatchdog(ImmobilityConfig(immobile_s=999)), GhostIncidentDetector(GhostConfig()), ZoneIntrusionDetector([], ZoneConfig()), EngineConfig(camera_id="cam-01", cooldown_s=30.0), ) def test_fall_produces_fall_event(): eng = _engine() tr = Track(1, deque(maxlen=90)) _lying_track(tr, 0.0) eng.process([tr], [], now=0.0) _lying_track(tr, 0.6) events = eng.process([tr], [], now=0.6) types = [e.event_type for e in events] assert EventType.FALL in types assert events[0].camera_id == "cam-01" def test_cooldown_suppresses_duplicate(): eng = _engine() tr = Track(1, deque(maxlen=90)) _lying_track(tr, 0.0); eng.process([tr], [], now=0.0) _lying_track(tr, 0.6); eng.process([tr], [], now=0.6) # FALL 1회 _lying_track(tr, 0.7) again = eng.process([tr], [], now=0.7) assert all(e.event_type != EventType.FALL for e in again) ``` - [ ] **Step 2: 실패 확인** Run: `pytest tests/test_engine.py -v` → Expected: FAIL — `ModuleNotFoundError: edge.engine` - [ ] **Step 3: 구현** — `edge/engine.py` ```python from dataclasses import dataclass from edge.tracking import Track from edge.events import Event, EventType, severity_for from edge.detectors.fall import FallDetector from edge.detectors.immobility import ImmobilityWatchdog from edge.detectors.ghost import GhostIncidentDetector from edge.detectors.zone import ZoneIntrusionDetector @dataclass class EngineConfig: camera_id: str = "cam" cooldown_s: float = 30.0 class EventEngine: def __init__(self, fall: FallDetector, immobility: ImmobilityWatchdog, ghost: GhostIncidentDetector, zone: ZoneIntrusionDetector, cfg: EngineConfig): self.fall = fall self.immobility = immobility self.ghost = ghost self.zone = zone self.cfg = cfg self._last_emit: dict[tuple, float] = {} def _allow(self, key: tuple, now: float) -> bool: last = self._last_emit.get(key) if last is not None and now - last < self.cfg.cooldown_s: return False self._last_emit[key] = now return True def _mk(self, etype: EventType, now: float, track_id=None, zone_id=None, metrics=None, spatial=None) -> Event: return Event(etype, severity_for(etype), self.cfg.camera_id, track_id, zone_id, now, 0.0, metrics or {}, spatial) def process(self, active_tracks: list[Track], lost_tracks: list[Track], now: float) -> list[Event]: events: list[Event] = [] self.ghost.on_lost(lost_tracks, now) for tr in active_tracks: zone_hits = self.zone.update(tr, now) in_zone = bool(self.zone.active_zone_ids(tr.track_id)) sig, meta = self.fall.update(tr, now) state = self.fall._states.get(tr.track_id) self.ghost.note_state(tr.track_id, state.name if state else "STANDING") if sig == "fall" and self._allow((EventType.FALL, tr.track_id), now): events.append(self._mk(EventType.FALL, now, tr.track_id, metrics=meta)) elif sig == "no_motion" and self._allow((EventType.NO_MOTION, tr.track_id), now): events.append(self._mk(EventType.NO_MOTION, now, tr.track_id, metrics=meta)) fired, imeta = self.immobility.update(tr, now, in_danger_zone=in_zone) if fired and self._allow((EventType.IMMOBILITY, tr.track_id), now): events.append(self._mk(EventType.IMMOBILITY, now, tr.track_id, metrics=imeta)) for hit in zone_hits: etype = (EventType.ZONE_RESTRICTED if hit["level"] == "restricted" else EventType.ZONE_WARNING) if self._allow((etype, tr.track_id, hit["zone_id"]), now): events.append(self._mk(etype, now, tr.track_id, zone_id=hit["zone_id"])) for g in self.ghost.update(active_tracks, now): key = (EventType.GHOST, round(g["spatial"][0]), round(g["spatial"][1])) if self._allow(key, now): events.append(self._mk(EventType.GHOST, now, spatial=g["spatial"], metrics={"last_state": g["last_state"], "waited_s": g["waited_s"]})) return events ``` - [ ] **Step 4: 통과 확인** Run: `pytest tests/test_engine.py -v` → Expected: PASS - [ ] **Step 5: 커밋** ```bash git add edge/engine.py tests/test_engine.py git commit -m "feat(edge): EventEngine orchestration with dedup + cooldown" ``` --- ## Task 11: RingBuffer + FaceBlurrer + Snapshotter **Files:** - Create: `edge/snapshot.py`, `tests/test_snapshot.py` **Interfaces:** - Consumes: `BBox` (Task 1) — 얼굴 블러 대상 박스 - Produces: - `RingBuffer(maxlen=90)` with `push(t: float, frame)` and `window(pre: int, post: int, index: int)` - `FaceBlurrer()` with `blur(frame, head_boxes: list[BBox]) -> frame` (cv2 GaussianBlur) - `Snapshotter(blurrer, quality=80)` with `capture(frames: list, head_boxes_per_frame: list) -> list[bytes]` — JPEG 최대 3장 인코딩 - [ ] **Step 1: 실패 테스트** — `tests/test_snapshot.py` ```python import numpy as np from edge.models import BBox from edge.snapshot import RingBuffer, FaceBlurrer, Snapshotter def _frame(val=128): return np.full((64, 64, 3), val, dtype=np.uint8) def test_ringbuffer_keeps_recent(): rb = RingBuffer(maxlen=3) for i in range(5): rb.push(float(i), _frame(i)) assert len(rb.frames) == 3 assert rb.frames[-1][0] == 4.0 def test_face_blur_changes_region(): fb = FaceBlurrer() f = _frame(0) f[0:10, 0:10] = 255 out = fb.blur(f.copy(), [BBox(0, 0, 10, 10)]) assert out is not None assert out.shape == f.shape def test_snapshotter_returns_jpeg_bytes(): snap = Snapshotter(FaceBlurrer()) imgs = snap.capture([_frame(100), _frame(120)], [[], []]) assert 1 <= len(imgs) <= 3 assert imgs[0][:2] == b"\xff\xd8" # JPEG magic ``` - [ ] **Step 2: 실패 확인** Run: `pytest tests/test_snapshot.py -v` → Expected: FAIL — `ModuleNotFoundError` - [ ] **Step 3: 구현** — `edge/snapshot.py` ```python from collections import deque import cv2 from edge.models import BBox class RingBuffer: def __init__(self, maxlen: int = 90): self.frames: deque = deque(maxlen=maxlen) def push(self, t: float, frame) -> None: self.frames.append((t, frame)) def window(self, pre: int, post: int, index: int) -> list: lo = max(0, index - pre) hi = min(len(self.frames), index + post + 1) return [f for (_t, f) in list(self.frames)[lo:hi]] class FaceBlurrer: def blur(self, frame, head_boxes: list[BBox]): for b in head_boxes: x1, y1 = int(max(0, b.x1)), int(max(0, b.y1)) x2, y2 = int(min(frame.shape[1], b.x2)), int(min(frame.shape[0], b.y2)) if x2 > x1 and y2 > y1: roi = frame[y1:y2, x1:x2] frame[y1:y2, x1:x2] = cv2.GaussianBlur(roi, (0, 0), sigmaX=12) return frame class Snapshotter: def __init__(self, blurrer: FaceBlurrer, quality: int = 80): self.blurrer = blurrer self.quality = quality def capture(self, frames: list, head_boxes_per_frame: list) -> list[bytes]: out: list[bytes] = [] for frame, heads in list(zip(frames, head_boxes_per_frame))[:3]: blurred = self.blurrer.blur(frame.copy(), heads) ok, buf = cv2.imencode(".jpg", blurred, [cv2.IMWRITE_JPEG_QUALITY, self.quality]) if ok: out.append(buf.tobytes()) return out ``` - [ ] **Step 4: 통과 확인** Run: `pytest tests/test_snapshot.py -v` → Expected: PASS - [ ] **Step 5: 커밋** ```bash git add edge/snapshot.py tests/test_snapshot.py git commit -m "feat(edge): RingBuffer + face blur + JPEG snapshotter" ``` --- ## Task 12: PostClient (HMAC + nonce 서명) **Files:** - Create: `edge/alert.py`, `tests/test_alert.py` **Interfaces:** - Consumes: `Event` (Task 5) - Produces: - `sign(secret: str, nonce: str, ts: str, body: bytes) -> str` (HMAC-SHA256 hex) - `PostClient(url, device_id, secret, nonce_fn, now_fn, http_post=None, timeout=5.0)` with `send(event: Event, snapshots: list[bytes]) -> bool` - [ ] **Step 1: 실패 테스트** — `tests/test_alert.py` ```python import json from edge.events import Event, EventType, Severity from edge.alert import sign, PostClient def _event(): return Event(EventType.FALL, Severity.HIGH, "cam-01", 7, None, 1000.0, 3.0, {"torso_angle": 78.0}, None) class _FakeResp: def __init__(self, code): self.status_code = code def test_sign_is_deterministic(): s1 = sign("secret", "n1", "1000", b"body") s2 = sign("secret", "n1", "1000", b"body") assert s1 == s2 and len(s1) == 64 def test_send_posts_signed_payload(): captured = {} def fake_post(url, data=None, headers=None, timeout=None): captured["url"] = url captured["headers"] = headers captured["body"] = data return _FakeResp(200) client = PostClient("https://x/api/events", "pi-01", "secret", nonce_fn=lambda: "n1", now_fn=lambda: 1000.0, http_post=fake_post) ok = client.send(_event(), [b"\xff\xd8jpeg"]) assert ok is True assert captured["headers"]["X-Nonce"] == "n1" assert captured["headers"]["X-Timestamp"] == "1000.0" payload = json.loads(captured["body"]) assert payload["device_id"] == "pi-01" assert payload["event_type"] == "fall" assert len(payload["snapshots"]) == 1 expected = sign("secret", "n1", "1000.0", captured["body"].encode()) assert captured["headers"]["X-Signature"] == expected def test_send_returns_false_on_error_code(): client = PostClient("https://x", "pi-01", "s", lambda: "n", lambda: 1.0, http_post=lambda *a, **k: _FakeResp(500)) assert client.send(_event(), []) is False ``` - [ ] **Step 2: 실패 확인** Run: `pytest tests/test_alert.py -v` → Expected: FAIL — `ModuleNotFoundError` - [ ] **Step 3: 구현** — `edge/alert.py` ```python import base64 import hashlib import hmac import json from edge.events import Event def sign(secret: str, nonce: str, ts: str, body: bytes) -> str: msg = nonce.encode() + ts.encode() + body return hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest() class PostClient: def __init__(self, url: str, device_id: str, secret: str, nonce_fn, now_fn, http_post=None, timeout: float = 5.0): self.url = url self.device_id = device_id self.secret = secret self.nonce_fn = nonce_fn self.now_fn = now_fn self.timeout = timeout if http_post is None: import requests http_post = requests.post self._post = http_post def send(self, event: Event, snapshots: list[bytes]) -> bool: payload = event.to_payload(self.device_id) payload["snapshots"] = [base64.b64encode(s).decode() for s in snapshots] body = json.dumps(payload, sort_keys=True) nonce = self.nonce_fn() ts = str(self.now_fn()) headers = { "Content-Type": "application/json", "X-Nonce": nonce, "X-Timestamp": ts, "X-Signature": sign(self.secret, nonce, ts, body.encode()), } try: resp = self._post(self.url, data=body, headers=headers, timeout=self.timeout) except Exception: return False return 200 <= resp.status_code < 300 ``` - [ ] **Step 4: 통과 확인** Run: `pytest tests/test_alert.py -v` → Expected: PASS - [ ] **Step 5: 커밋** ```bash git add edge/alert.py tests/test_alert.py git commit -m "feat(edge): PostClient with HMAC+nonce signed event POST" ``` --- ## Task 13: OfflineQueue (SQLite 지속 + 재전송) **Files:** - Create: `edge/queue.py`, `tests/test_queue.py` **Interfaces:** - Consumes: `PostClient` (Task 12); `Event, EventType, Severity` (Task 5) - Produces: - `OfflineQueue(db_path, client)`: - `enqueue(event: Event, snapshots: list[bytes]) -> None` - `try_send(event: Event, snapshots: list[bytes]) -> bool` — 즉시 전송, 실패 시 enqueue - `flush() -> int` — 큐 재전송, 성공 개수 반환(실패 시 순서 보존 중단) - `pending() -> int` - [ ] **Step 1: 실패 테스트** — `tests/test_queue.py` ```python from edge.events import Event, EventType, Severity from edge.queue import OfflineQueue def _event(n=1): return Event(EventType.FALL, Severity.HIGH, "cam-01", n, None, float(n), 3.0, {}, None) class _Client: def __init__(self, fail=False): self.fail = fail self.sent = [] def send(self, event, snapshots): if self.fail: return False self.sent.append(event.track_id) return True def test_try_send_success_not_queued(tmp_path): q = OfflineQueue(str(tmp_path / "q.db"), _Client()) assert q.try_send(_event(1), [b"x"]) is True assert q.pending() == 0 def test_failure_is_queued(tmp_path): q = OfflineQueue(str(tmp_path / "q.db"), _Client(fail=True)) assert q.try_send(_event(1), [b"x"]) is False assert q.pending() == 1 def test_flush_resends_when_recovered(tmp_path): client = _Client(fail=True) q = OfflineQueue(str(tmp_path / "q.db"), client) q.try_send(_event(1), [b"x"]) q.try_send(_event(2), [b"y"]) assert q.pending() == 2 client.fail = False assert q.flush() == 2 assert q.pending() == 0 assert client.sent == [1, 2] ``` - [ ] **Step 2: 실패 확인** Run: `pytest tests/test_queue.py -v` → Expected: FAIL — `ModuleNotFoundError` - [ ] **Step 3: 구현** — `edge/queue.py` ```python import base64 import json import sqlite3 from edge.events import Event, EventType, Severity class OfflineQueue: def __init__(self, db_path: str, client): self.client = client self.db = sqlite3.connect(db_path) self.db.execute( "CREATE TABLE IF NOT EXISTS queue (" "id INTEGER PRIMARY KEY AUTOINCREMENT, payload TEXT, snaps TEXT)" ) self.db.commit() def _rehydrate(self, payload: dict, snaps: list[bytes]) -> tuple[Event, list[bytes]]: e = Event(EventType(payload["event_type"]), Severity(payload["severity"]), payload["camera_id"], payload["track_id"], payload["zone_id"], payload["occurred_at"], payload["duration_s"], payload["metrics"], tuple(payload["spatial"]) if payload.get("spatial") else None) return e, snaps def enqueue(self, event: Event, snapshots: list[bytes]) -> None: payload = event.to_payload("") # device_id는 client가 다시 채움 payload["event_type"] = event.event_type.value payload["severity"] = event.severity.value payload["spatial"] = list(event.spatial) if event.spatial else None snaps = [base64.b64encode(s).decode() for s in snapshots] self.db.execute("INSERT INTO queue (payload, snaps) VALUES (?, ?)", (json.dumps(payload), json.dumps(snaps))) self.db.commit() def try_send(self, event: Event, snapshots: list[bytes]) -> bool: if self.client.send(event, snapshots): return True self.enqueue(event, snapshots) return False def flush(self) -> int: rows = self.db.execute("SELECT id, payload, snaps FROM queue ORDER BY id").fetchall() sent = 0 for rid, payload_json, snaps_json in rows: payload = json.loads(payload_json) snaps = [base64.b64decode(s) for s in json.loads(snaps_json)] event, snapshots = self._rehydrate(payload, snaps) if self.client.send(event, snapshots): self.db.execute("DELETE FROM queue WHERE id = ?", (rid,)) self.db.commit() sent += 1 else: break # 여전히 오프라인 — 순서 보존 return sent def pending(self) -> int: return self.db.execute("SELECT COUNT(*) FROM queue").fetchone()[0] ``` - [ ] **Step 4: 통과 확인** Run: `pytest tests/test_queue.py -v` → Expected: PASS - [ ] **Step 5: 커밋** ```bash git add edge/queue.py tests/test_queue.py git commit -m "feat(edge): OfflineQueue (SQLite persist + ordered resend)" ``` --- ## Task 14: Config (env 로드) **Files:** - Create: `edge/config.py`, `tests/test_config.py` **Interfaces:** - Consumes: `FallConfig` (Task 6), `ImmobilityConfig` (Task 7), `GhostConfig` (Task 8), `ZoneConfig` (Task 9), `EngineConfig` (Task 10) - Produces: - `Config(device_id, camera_id, cafe24_url, secret, fall, immobility, ghost, zone, engine)` - `load_config(env: dict) -> Config` - env 키: `DEVICE_ID, CAMERA_ID, CAFE24_URL, DEVICE_SECRET, FALL_PRONE_S, FALL_NO_MOTION_S, IMMOBILE_S, GHOST_REAPPEAR_S, ZONE_DEBOUNCE_S, COOLDOWN_S` - [ ] **Step 1: 실패 테스트** — `tests/test_config.py` ```python from edge.config import load_config def test_defaults_when_env_empty(): cfg = load_config({}) assert cfg.device_id == "pi-unknown" assert cfg.fall.prone_confirm_s == 3.0 assert cfg.fall.no_motion_s == 10.0 def test_env_overrides(): env = { "DEVICE_ID": "pi-w1", "CAMERA_ID": "cam-9", "CAFE24_URL": "https://x/api/events", "DEVICE_SECRET": "s", "FALL_PRONE_S": "2.0", "FALL_NO_MOTION_S": "8", "IMMOBILE_S": "90", "COOLDOWN_S": "45", } cfg = load_config(env) assert cfg.device_id == "pi-w1" assert cfg.camera_id == "cam-9" assert cfg.fall.prone_confirm_s == 2.0 assert cfg.fall.no_motion_s == 8.0 assert cfg.immobility.immobile_s == 90.0 assert cfg.engine.cooldown_s == 45.0 assert cfg.engine.camera_id == "cam-9" ``` - [ ] **Step 2: 실패 확인** Run: `pytest tests/test_config.py -v` → Expected: FAIL — `ModuleNotFoundError` - [ ] **Step 3: 구현** — `edge/config.py` ```python from dataclasses import dataclass from edge.detectors.fall import FallConfig from edge.detectors.immobility import ImmobilityConfig from edge.detectors.ghost import GhostConfig from edge.detectors.zone import ZoneConfig from edge.engine import EngineConfig @dataclass class Config: device_id: str camera_id: str cafe24_url: str secret: str fall: FallConfig immobility: ImmobilityConfig ghost: GhostConfig zone: ZoneConfig engine: EngineConfig def _f(env: dict, key: str, default: float) -> float: return float(env[key]) if key in env else default def load_config(env: dict) -> Config: camera_id = env.get("CAMERA_ID", "cam-01") fall = FallConfig( prone_confirm_s=_f(env, "FALL_PRONE_S", 3.0), no_motion_s=_f(env, "FALL_NO_MOTION_S", 10.0), ) immobility = ImmobilityConfig(immobile_s=_f(env, "IMMOBILE_S", 120.0)) ghost = GhostConfig(reappear_s=_f(env, "GHOST_REAPPEAR_S", 10.0)) zone = ZoneConfig(debounce_s=_f(env, "ZONE_DEBOUNCE_S", 0.75)) engine = EngineConfig(camera_id=camera_id, cooldown_s=_f(env, "COOLDOWN_S", 30.0)) return Config( device_id=env.get("DEVICE_ID", "pi-unknown"), camera_id=camera_id, cafe24_url=env.get("CAFE24_URL", ""), secret=env.get("DEVICE_SECRET", ""), fall=fall, immobility=immobility, ghost=ghost, zone=zone, engine=engine, ) ``` - [ ] **Step 4: 통과 확인** Run: `pytest tests/test_config.py -v` → Expected: PASS - [ ] **Step 5: 커밋** ```bash git add edge/config.py tests/test_config.py git commit -m "feat(edge): env-based Config for detectors and transport" ``` --- ## Task 15: 메인 루프 배선 (app) + 통합 테스트 **Files:** - Create: `edge/app.py`, `tests/test_app.py` **Interfaces:** - Consumes: 전 태스크. `SourcePort, PosePort` (Task 4); `Person` (Task 1) - Produces: - `run_once(source, pose, tracker, smoother, engine, ring, snapshotter, sink, now_fn) -> list[Event]` — 한 프레임 처리 - `build_pipeline(config: Config, source, pose) -> dict` — 실제 배선 팩토리 - [ ] **Step 1: 실패 테스트** — `tests/test_app.py` ```python import numpy as np from edge.models import Person, BBox, Keypoint from edge.tracking import IouTracker from edge.smoothing import KeypointSmoother from edge.detectors.fall import FallDetector, FallConfig from edge.detectors.immobility import ImmobilityWatchdog, ImmobilityConfig from edge.detectors.ghost import GhostIncidentDetector, GhostConfig from edge.detectors.zone import ZoneIntrusionDetector, ZoneConfig from edge.engine import EventEngine, EngineConfig from edge.snapshot import RingBuffer, FaceBlurrer, Snapshotter from edge.events import EventType from edge.app import run_once 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, event, snapshots): self.events.append(event); return True def test_run_once_pipeline_emits_fall_after_prone(): src, pose = _Source(), _Pose() tracker = IouTracker() smoother = KeypointSmoother() engine = EventEngine( FallDetector(FallConfig(prone_confirm_s=0.5)), ImmobilityWatchdog(ImmobilityConfig(immobile_s=999)), GhostIncidentDetector(GhostConfig()), ZoneIntrusionDetector([], ZoneConfig()), EngineConfig(camera_id="cam-01"), ) ring = RingBuffer(); snap = Snapshotter(FaceBlurrer()); sink = _Sink() times = iter([0.0, 0.6]) def now_fn(): return next(times) run_once(src, pose, tracker, smoother, engine, ring, snap, sink, now_fn) run_once(src, pose, tracker, smoother, engine, ring, snap, sink, now_fn) assert any(e.event_type == EventType.FALL for e in sink.events) ``` - [ ] **Step 2: 실패 확인** Run: `pytest tests/test_app.py -v` → Expected: FAIL — `ModuleNotFoundError: edge.app` - [ ] **Step 3: 구현** — `edge/app.py` ```python from edge.config import Config from edge.tracking import IouTracker from edge.smoothing import KeypointSmoother 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 from edge.models import Person, BBox def _head_boxes(persons: list) -> list: # 배경인물 머리영역 근사: 코 주변 작은 박스 (MVP; 실제는 얼굴 검출 포트로 교체) boxes = [] for p in persons: nose = p.keypoints[0] boxes.append(BBox(nose.x - 20, nose.y - 25, nose.x + 20, nose.y + 15)) return boxes def run_once(source, pose, tracker, smoother, engine, ring, snapshotter, sink, now_fn): ok, frame = source.read() if not ok: return [] now = now_fn() persons = pose.infer(frame) smoothed = [Person(p.bbox, smoother.smooth(p.keypoints, now)) for p in persons] tracks = tracker.update(smoothed, now) lost = tracker.lost() ring.push(now, frame) events = engine.process(tracks, lost, now) if events: snaps = snapshotter.capture([frame], [_head_boxes(persons)]) for e in events: sink.try_send(e, snaps) return events def build_pipeline(config: Config, source, pose) -> dict: return { "source": source, "pose": pose, "tracker": IouTracker(), "smoother": KeypointSmoother(), "engine": EventEngine( FallDetector(config.fall), ImmobilityWatchdog(config.immobility), GhostIncidentDetector(config.ghost), ZoneIntrusionDetector([], config.zone), config.engine, ), "ring": RingBuffer(), "snapshotter": Snapshotter(FaceBlurrer()), } ``` - [ ] **Step 4: 통과 확인** Run: `pytest tests/test_app.py -v` → Expected: PASS - [ ] **Step 5: 전체 스위트 + 커밋** ```bash pytest -q git add edge/app.py tests/test_app.py git commit -m "feat(edge): main-loop wiring (run_once) + pipeline factory" ``` --- ## Task 16: 하드웨어 어댑터 스텁 + 브링업 문서 (실장비 게이트) > ⚠️ **이 태스크는 실장비(Pi5+Hailo)에서 실행.** 개발 PC(Windows)에선 파일 생성·문서화까지만. fps 벤치·카메라 캡처는 Pi에서. **Files:** - Create: `edge/adapters/__init__.py`(빈 파일), `edge/adapters/hailo_pose.py`, `edge/adapters/picamera_source.py`, `edge/adapters/uvc_source.py`, `docs/hardware-bringup.md` **Interfaces:** - Consumes: `PosePort, SourcePort` (Task 4); `Person, BBox, Keypoint` (Task 1) - Produces: `HailoPoseAdapter(hef_path)` implements PosePort; `PiCameraSource(index)`, `UvcSource(dev, mjpeg=True)` implement SourcePort. - [ ] **Step 1: 어댑터 스텁 작성** — `edge/adapters/hailo_pose.py` ```python """Hailo yolov8s_pose HEF 어댑터 (실장비). PosePort 구현. 개발 PC에선 인스턴스화 불가(hailort 부재). Pi에서 HailoRT로 실제 추론 배선.""" from edge.models import Person, BBox, Keypoint class HailoPoseAdapter: def __init__(self, hef_path: str): self.hef_path = hef_path # 실장비 배선 지점: HailoRT VDevice/InferModel 로드, yolov8s_pose 후처리. # 참고: hailo-rpi5-examples pose 파이프라인 (MIT). raise NotImplementedError("실장비 전용: HailoRT 배선 필요") def infer(self, frame) -> list: # frame(640x640 RGB) -> yolov8s_pose -> list[Person(bbox, 17 keypoints)] raise NotImplementedError ``` - [ ] **Step 2: 소스 스텁 작성** — `edge/adapters/picamera_source.py`, `edge/adapters/uvc_source.py` ```python # picamera_source.py """CSI 카메라 소스 (실장비). 카메라당 별 프로세스로 picamera2 구동.""" class PiCameraSource: def __init__(self, index: int = 0): self.index = index raise NotImplementedError("실장비 전용: picamera2 배선 필요") def read(self): raise NotImplementedError ``` ```python # uvc_source.py """USB UVC 소스 (실장비). v4l2 MJPEG. cv2.VideoCapture, 2개 USB3 컨트롤러 분산.""" import cv2 class UvcSource: def __init__(self, dev, mjpeg: bool = True): self.cap = cv2.VideoCapture(dev) if mjpeg: self.cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*"MJPG")) def read(self): return self.cap.read() ``` - [ ] **Step 3: 브링업 검증 문서** — `docs/hardware-bringup.md` 다음 실장비 검증 절차 명시(스펙 §14): 1. PCIe Gen3 활성: `/boot/firmware/config.txt`에 `dtparam=pciex1_gen=3`, 재부팅 후 `lspci -vv | grep -i speed` 확인. 2. `yolov8s_pose` HEF 벤치: 단일·2·4 스트림 라운드로빈 fps·CPU·온도 측정. 목표 낙상 중요캠 ≥12~15fps. 3. fps 3티어(full/detection/motion) 각각 측정 → 캠 수·BOM 확정. 4. CSI 카메라당 별 프로세스 + USB MJPEG 2컨트롤러 분산, 수 시간 동시 캡처 안정성. 5. 실측 결과를 `thresholds`·카메라 `role`에 반영. - [ ] **Step 4: 개발 PC에서 UVC 어댑터 import 확인** Run: `python -c "import edge.adapters.uvc_source; print('ok')"` Expected: `ok` (cv2만 필요; Hailo/picamera 스텁은 인스턴스화 시에만 NotImplementedError). - [ ] **Step 5: 커밋** ```bash git add edge/adapters docs/hardware-bringup.md git commit -m "feat(edge): hardware adapter stubs + Pi bring-up/benchmark doc" ``` --- ## Self-Review **1. Spec coverage (P1 범위 대비):** - F1 넘어짐 → Task 6 ✓ · F2 낙상후 미동 위급 → Task 6(no_motion) ✓ · F3 자세복원 하향 → Task 6(recover) ✓ - F3b 자세무관 부동 → Task 7 ✓ · F3c 유령사고 → Task 8 ✓ · F4 위험구역 2단계 → Task 9 ✓ - F5 dedup+cooldown → Task 10 ✓ · F6 스냅샷 1~3장 → Task 11 ✓ · F8 오프라인 큐 → Task 13 ✓ - 시점강건 낙상(OR) → Task 6 ✓ · 지터스무딩 → Task 3 ✓ · baseline freeze → Task 6 ✓ - NF5 HMAC+nonce → Task 12 ✓ · 얼굴블러(F14 일부) → Task 11 ✓ · env 임계 → Task 14 ✓ - fps 3티어 검증(§10.2) → Task 16 문서(실장비) ✓ - **P1-A 범위 밖(다른 서브플랜):** 인증/RBAC·ingest 서버(P1-B), 라벨 평가셋·드릴게이트(P1-C), 컴플라이언스 화면(P1-B), 신뢰시간 RTC(P2). - **알려진 갭(의도적):** F15 "현재프레임 캡처"는 카메라 소스 어댑터 후속(P1-B 구역설정과 함께). fps 3티어(T2/T3) 실제 배선은 Task 16 실장비 단계에서(코어 로직은 모델 무관). **2. Placeholder scan:** 코드 스텝 전부 실제 코드. Task 16만 실장비 스텁(의도적 하드웨어 게이트, 문서로 절차 명시). **3. Type consistency:** `Track.latest`, `TrackFrame(t,bbox,keypoints)`, `_State.name`(FallDetector↔EventEngine 참조), `Event.to_payload`, `PostClient.send(event,snapshots)`, `OfflineQueue.try_send`, detector `update(...)->tuple` 시그니처 Task 간 일치. `BBox` import는 app.py에 포함 ✓. `height_remaining` 메트릭 키 fall.py 내부 일관. --- ## Execution Handoff **Plan complete. 저장: `docs/superpowers/plans/2026-07-11-p1a-edge-detection-core.md`.** 두 실행 옵션: 1. **Subagent-Driven (추천)** — 태스크마다 새 subagent 디스패치, 태스크 간 리뷰, 빠른 반복. `superpowers:subagent-driven-development`. 2. **Inline Execution** — 이 세션에서 태스크 순차 실행, 체크포인트 리뷰. `superpowers:executing-plans`. 어느 방식으로?