diff --git a/docs/INDEX.md b/docs/INDEX.md index c514c1d..a6822f5 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -6,6 +6,10 @@ stale 트랙 no-data) — `32f2237`. TDD(RED 확인), pytest 203 통과/2 skip. → delivery-audit 오프하드웨어 항목 43/43 완료. 잔여는 실장비(OFF-HW) 검증뿐. - **2026-08-06** 연속성 문서 신설: `CLAUDE.md` · `docs/PROJECT_MAP.md` · 본 INDEX. +- **2026-08-06** 니어미스 히트맵: `near_fall`(FALLING→확정 전 회복, INFO) + 전 트랙 이벤트에 + 정규화 발좌표(spatial) 부착 → `move_events.spatial_x/y` 적재 → `GET /api/heatmap/{camera}` → + 존 편집기 '위험 히트맵' 오버레이. **직전 커밋의 chk_events_type 제약 누락(신규 타입 INSERT + 거부) 마이그레이션으로 수정.** pytest 222/PHPUnit 130 + Chrome 실렌더. - **2026-08-06** 존 점유 규칙 엔진 (2인1조 `lone_worker`/HIGH · 정원 `overcapacity` · 체류상한 `overstay`): edge `occupancy.py` + `move_zones.rules_json` + 존 편집기 규칙 입력 + 이벤트 유형 한글 라벨. pytest 216/PHPUnit 123 통과 + Chrome 실렌더(편집기 저장·목록·콘솔 라벨). diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index 84422c0..8e25336 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -30,8 +30,9 @@ - 진입: `public/index.php` (라우트 전부 여기), 뷰: `src/View/{layout,events,login,zones,event_detail}.php` - 라우트: `POST /api/events`(ingest, HMAC) · `POST /api/heartbeat` · `GET /api/config` · - `GET /api/events` · `GET /api/devices` · `GET/POST /api/zones*` · `POST /login|/logout` · - `GET /snap/{uuid}` · `POST /events/{id}/ack` · 페이지 `/`, `/login`, `/zones`, `/events/{id}/view` + `GET /api/events` · `GET /api/devices` · `GET /api/heatmap/{camera}`(spatial 집계) · + `GET/POST /api/zones*` · `POST /login|/logout` · `GET /snap/{uuid}` · + `POST /events/{id}/ack` · 페이지 `/`, `/login`, `/zones`, `/events/{id}/view` - `src/Ingest/` HMAC+nonce+rate-limit+replay 방지 · `src/Auth/` argon2id/세션/RBAC/CSRF · `src/Dashboard/` 이벤트목록·디바이스상태·존편집·스냅샷프록시 · `src/Repository/` 9종 (인터페이스+PDO) - 프런트: `public/assets/app.js`(이벤트 콘솔 폴링·필터) · `device-tree.js`(장치→카메라 트리 레일) · diff --git a/edge/detectors/fall.py b/edge/detectors/fall.py index 463c1f5..9fdef8d 100644 --- a/edge/detectors/fall.py +++ b/edge/detectors/fall.py @@ -95,7 +95,13 @@ class FallDetector: st.not_prone_since = now return None, {} if now - st.not_prone_since >= self.cfg.recover_grace_s: + # FALLING에서 확정(FALLEN) 전에 회복 = 니어미스. 사고 알람은 아니지만 + # 히트맵/통계의 원료라 신호로 구분해 올린다 (엔진이 INFO로 발보). + was_near_miss = not st.fall_emitted + prone_s = (st.not_prone_since - st.prone_since) if st.prone_since is not None else 0.0 self._states[track.track_id] = _State(baseline_height=bbox.height) + if was_near_miss: + return "near_fall", {"prone_s": round(prone_s, 1)} return "recover", {} return None, {} st.prone_since = None diff --git a/edge/engine.py b/edge/engine.py index 4d3e92b..cb08d3b 100644 --- a/edge/engine.py +++ b/edge/engine.py @@ -73,18 +73,28 @@ class EventEngine: # space (see ZoneIntrusionDetector._foot). run_once supplies the live frame size. zone_hits = self.zone.update(tr, now, frame_wh) in_zone = bool(self.zone.active_zone_ids(tr.track_id)) + # 발 좌표(0..1)를 트랙 이벤트 전부에 부착 — 서버 히트맵의 원료. 존 감지기가 이미 + # 계산하는 좌표계를 그대로 재사용해 존 폴리곤과 같은 공간에 놓인다. + foot = self.zone._foot(tr, frame_wh) 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, occurred_at, tr.track_id, metrics=meta)) + events.append(self._mk(EventType.FALL, occurred_at, tr.track_id, + metrics=meta, spatial=foot)) elif sig == "no_motion" and self._allow((EventType.NO_MOTION, tr.track_id), now): - events.append(self._mk(EventType.NO_MOTION, occurred_at, tr.track_id, metrics=meta)) + events.append(self._mk(EventType.NO_MOTION, occurred_at, tr.track_id, + metrics=meta, spatial=foot)) + elif sig == "near_fall" and self._allow((EventType.NEAR_FALL, tr.track_id), now): + # 니어미스(FALLING까지 갔다 확정 전 회복): 알람용이 아니라 히트맵/통계 재료. + events.append(self._mk(EventType.NEAR_FALL, occurred_at, tr.track_id, + metrics=meta, spatial=foot)) 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, occurred_at, tr.track_id, metrics=imeta)) + events.append(self._mk(EventType.IMMOBILITY, occurred_at, tr.track_id, + metrics=imeta, spatial=foot)) for hit in zone_hits: # 협착(pinch) is the highest-danger zone (machinery entrapment) and must emit @@ -94,7 +104,8 @@ class EventEngine: "restricted": EventType.ZONE_RESTRICTED}.get( hit["level"], EventType.ZONE_WARNING) if self._allow((etype, tr.track_id, hit["zone_id"]), now): - events.append(self._mk(etype, occurred_at, tr.track_id, zone_id=hit["zone_id"])) + events.append(self._mk(etype, occurred_at, tr.track_id, + zone_id=hit["zone_id"], spatial=foot)) # 존 규칙(2인 1조 · 정원 · 체류): 위 per-track 루프에서 zone.update가 갱신한 멤버십을 # 존별 인원으로 뒤집어 감시자에 넘긴다. stale 트랙은 run_once가 이미 걸러냈으므로 @@ -118,8 +129,16 @@ class EventEngine: # Routing it through _allow would only insert a permanent (GHOST, track_id) dedup key for # a track that was already forgotten reappear_s ago — a slow 24/7 memory leak that # forget() can never reclaim (the track never appears in lost_tracks again). + # Ghost's loss point is camera-native pixels (bbox centre at loss time). Normalize to + # the same 0..1 space every other event's spatial uses so the heatmap can overlay them; + # frame_wh=None (unit tests) keeps raw coords, matching the zone detector's convention. + gs = g["spatial"] + if frame_wh and gs is not None: + w, h = frame_wh + if w > 0 and h > 0: + gs = (gs[0] / w, gs[1] / h) events.append(self._mk(EventType.GHOST, occurred_at, track_id=g["track_id"], - spatial=g["spatial"], + spatial=gs, metrics={"last_state": g["last_state"], "waited_s": g["waited_s"]})) diff --git a/edge/events.py b/edge/events.py index 2fd5e1a..c58f9bb 100644 --- a/edge/events.py +++ b/edge/events.py @@ -16,6 +16,7 @@ class EventType(str, Enum): LONE_WORKER = "lone_worker" # 2인 1조 존에 단독 작업 지속 OVERCAPACITY = "overcapacity" # 존 정원 초과 지속 OVERSTAY = "overstay" # 존 체류 상한 초과 (트랙별) + NEAR_FALL = "near_fall" # 니어미스: FALLING까지 갔다가 확정 전 회복 class Severity(str, Enum): @@ -39,6 +40,8 @@ _SEVERITY = { EventType.LONE_WORKER: Severity.HIGH, EventType.OVERCAPACITY: Severity.WARNING, EventType.OVERSTAY: Severity.WARNING, + # 니어미스는 알람이 아니라 히트맵/통계 재료 — 소음이 되면 안 되므로 INFO. + EventType.NEAR_FALL: Severity.INFO, } @@ -63,7 +66,7 @@ class Event: event_uuid: str = field(default_factory=lambda: _uuid.uuid4().hex) def to_payload(self, device_id: str) -> dict: - return { + payload = { "device_id": device_id, "event_uuid": self.event_uuid, "camera_id": self.camera_id, @@ -75,3 +78,8 @@ class Event: "duration_s": round(self.duration_s, 2), "metrics": self.metrics, } + # 정규화 발/기준 좌표(0..1) — 서버 히트맵의 원료. None이면 키 자체를 생략해 + # 구서버(스키마에 spatial 없음)와의 하위호환을 지킨다. + if self.spatial is not None: + payload["spatial"] = [round(float(self.spatial[0]), 4), round(float(self.spatial[1]), 4)] + return payload diff --git a/server/migrations/2026-08-06-heatmap-spatial.sql b/server/migrations/2026-08-06-heatmap-spatial.sql new file mode 100644 index 0000000..3323b2e --- /dev/null +++ b/server/migrations/2026-08-06-heatmap-spatial.sql @@ -0,0 +1,13 @@ +-- Heatmap raw material: normalized (0..1) spatial point per event, and the four new +-- event types (occupancy rules + near-miss). The chk_events_type constraint would REJECT +-- lone_worker/overcapacity/overstay/near_fall inserts on MySQL 8.0.16+ without this. +ALTER TABLE move_events + ADD COLUMN spatial_x DOUBLE NULL AFTER metrics_json, + ADD COLUMN spatial_y DOUBLE NULL AFTER spatial_x; + +ALTER TABLE move_events DROP CHECK chk_events_type; +ALTER TABLE move_events ADD CONSTRAINT chk_events_type CHECK (event_type IN ( + 'fall','no_motion','immobility','ghost','zone_warning', + 'zone_restricted','pinch','monitoring_degraded','device_fault', + 'lone_worker','overcapacity','overstay','near_fall' +)); diff --git a/server/public/assets/app.js b/server/public/assets/app.js index 718c4b2..71fe8f8 100644 --- a/server/public/assets/app.js +++ b/server/public/assets/app.js @@ -21,7 +21,8 @@ fall: '넘어짐', no_motion: '넘어짐 후 미동 없음', immobility: '장시간 부동', ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입', pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상', - lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과' + lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과', + near_fall: '넘어질 뻔함 (니어미스)' }; var SEVERITY_LABELS = { diff --git a/server/public/assets/detail.js b/server/public/assets/detail.js index 165324c..b8e37f6 100644 --- a/server/public/assets/detail.js +++ b/server/public/assets/detail.js @@ -27,7 +27,8 @@ fall: '넘어짐', no_motion: '넘어짐 후 미동 없음', immobility: '장시간 부동', ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입', pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상', - lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과' + lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과', + near_fall: '넘어질 뻔함 (니어미스)' }; var meta = document.getElementById('event-detail-meta'); diff --git a/server/public/assets/zone-draw.js b/server/public/assets/zone-draw.js index 880b6cc..850924b 100644 --- a/server/public/assets/zone-draw.js +++ b/server/public/assets/zone-draw.js @@ -36,6 +36,8 @@ var ruleMinInput = document.getElementById('rule-min-workers'); var ruleMaxInput = document.getElementById('rule-max-workers'); var ruleDwellInput = document.getElementById('rule-max-dwell'); + var heatmapBtn = document.getElementById('heatmap-btn'); + var heatmapDaysInput = document.getElementById('heatmap-days'); var LEVEL_STYLE = { warning: { stroke: '#eab308', label: '경고' }, @@ -47,6 +49,9 @@ var existingZones = []; // [{zone_id, level, points: [[x,y],...]}] var currentPoints = []; // in-progress polygon being drawn var csrfToken = null; + var heatmapOn = false; + var heatmapPoints = []; // [{x, y, severity}] normalized 0..1 (same space as zones) + var heatmapLayer = null; // offscreen canvas, rebuilt when points change function setStatus(msg) { statusEl.textContent = msg; @@ -96,11 +101,75 @@ } } + // ---- 위험 히트맵 오버레이 ------------------------------------------------------- + // Density of everything this camera detected (incidents AND near-misses) over the window, + // drawn as additive radial blots UNDER the zone polygons. Severity weighs the alpha: + // a cluster of near-misses reads lighter than one confirmed fall at the same spot. + var HEAT_STYLE = { + critical: { color: '220,38,38', alpha: 0.34 }, + high: { color: '220,38,38', alpha: 0.26 }, + warning: { color: '217,119,6', alpha: 0.18 }, + info: { color: '37,99,235', alpha: 0.11 } + }; + + function buildHeatmapLayer() { + var layer = document.createElement('canvas'); + layer.width = canvas.width; + layer.height = canvas.height; + var lctx = layer.getContext('2d'); + var radius = Math.round(canvas.width * 0.03); // ~29px at 960: a footprint, not a pin + heatmapPoints.forEach(function (p) { + var s = HEAT_STYLE[p.severity] || HEAT_STYLE.info; + var x = Math.min(1, Math.max(0, p.x)) * layer.width; + var y = Math.min(1, Math.max(0, p.y)) * layer.height; + var g = lctx.createRadialGradient(x, y, 0, x, y, radius); + g.addColorStop(0, 'rgba(' + s.color + ',' + s.alpha + ')'); + g.addColorStop(1, 'rgba(' + s.color + ',0)'); + lctx.fillStyle = g; + lctx.fillRect(x - radius, y - radius, radius * 2, radius * 2); + }); + return layer; + } + + function loadHeatmap() { + var camera = cameraInput.value.trim(); + if (!camera) { setStatus('카메라 ID를 입력하세요.'); return; } + var days = Math.max(1, Math.min(90, parseInt(heatmapDaysInput && heatmapDaysInput.value, 10) || 30)); + setStatus('히트맵을 불러오는 중...'); + fetch(B + '/api/heatmap/' + encodeURIComponent(camera) + '?days=' + days, + { headers: { Accept: 'application/json' } }) + .then(function (res) { + if (res.status === 401) { window.location.href = B + '/login'; return null; } + if (!res.ok) throw new Error('heatmap_failed:' + res.status); + return res.json(); + }) + .then(function (data) { + if (!data) return; + heatmapPoints = data.points || []; + heatmapLayer = buildHeatmapLayer(); + redraw(); + setStatus(heatmapPoints.length + ? '최근 ' + data.days + '일 감지 ' + heatmapPoints.length + '건을 표시합니다.' + : '최근 ' + data.days + '일 동안 좌표가 기록된 감지가 없습니다.'); + }) + .catch(function () { setStatus('히트맵을 불러오지 못했습니다.'); }); + } + + function toggleHeatmap() { + heatmapOn = !heatmapOn; + if (heatmapBtn) heatmapBtn.setAttribute('aria-pressed', heatmapOn ? 'true' : 'false'); + if (heatmapOn) loadHeatmap(); + else { heatmapLayer = null; heatmapPoints = []; redraw(); setStatus('히트맵을 껐습니다.'); } + } + function redraw() { ctx.clearRect(0, 0, canvas.width, canvas.height); if (backgroundImage) { ctx.drawImage(backgroundImage, 0, 0, canvas.width, canvas.height); } + if (heatmapOn && heatmapLayer) { + ctx.drawImage(heatmapLayer, 0, 0); + } existingZones.forEach(function (z) { var style = LEVEL_STYLE[z.level] || { stroke: '#9ca3af', label: z.level }; drawPolygon(z.points, style.stroke, style.label, true); @@ -260,6 +329,8 @@ levelSelect.addEventListener('change', redraw); loadBtn.addEventListener('click', loadZones); if (frameBtn) frameBtn.addEventListener('click', loadLatestFrame); + if (heatmapBtn) heatmapBtn.addEventListener('click', toggleHeatmap); + if (heatmapDaysInput) heatmapDaysInput.addEventListener('change', function () { if (heatmapOn) loadHeatmap(); }); clearBtn.addEventListener('click', function () { currentPoints = []; redraw(); setStatus('현재 폴리곤을 지웠습니다.'); }); saveBtn.addEventListener('click', saveZone); diff --git a/server/public/index.php b/server/public/index.php index 8bf75c8..a479ec1 100644 --- a/server/public/index.php +++ b/server/public/index.php @@ -46,7 +46,7 @@ use App\Ingest\IngestController; use App\Api\{ConfigController, HeartbeatController}; use App\Auth\{AuthController, CsrfController}; use App\Dashboard\{EventListController, EventDetailController, SnapshotProxy, ZoneConfigController, - DeviceStatusController}; + DeviceStatusController, HeatmapController}; // Never leak internals to the client. Cafe24 shared hosting often ships display_errors=On, so // an uncaught PDOException would otherwise render the DSN + db_user in a raw 500. Turn it off @@ -95,6 +95,7 @@ $detail = new EventDetailController($events, $snaps, $audit, $sess); $proxy = new SnapshotProxy($snaps, $events, $sess); $zoneCtl = new ZoneConfigController($zones, $devices, $audit, $sess, $snaps); $deviceStatus = new DeviceStatusController($devices, $sess); +$heatmap = new HeatmapController($events, $sess); $render = function (string $view): Response { ob_start(); require APP_ROOT . '/src/View/' . $view . '.php'; @@ -113,6 +114,7 @@ $r->add('GET', '/api/devices', fn(Request $q) => $deviceStatus->json($q, $now)); $r->add('GET', '/events/{id}', fn(Request $q, array $p) => $detail->show($q, $p)); $r->add('POST', '/events/{id}/ack', fn(Request $q, array $p) => $detail->acknowledge($q, $p)); $r->add('GET', '/api/zones/{camera}', fn(Request $q, array $p) => $zoneCtl->list($q, $p)); +$r->add('GET', '/api/heatmap/{camera}', fn(Request $q, array $p) => $heatmap->json($q, $p)); // Background frame for the zone editor: the newest snapshot this camera produced. $r->add('GET', '/api/cameras/{camera}/latest-snapshot', fn(Request $q, array $p) => $zoneCtl->latestSnapshot($q, $p)); $r->add('POST', '/api/zones', fn(Request $q) => $zoneCtl->save($q)); diff --git a/server/schema.sql b/server/schema.sql index 085b23e..5d15919 100644 --- a/server/schema.sql +++ b/server/schema.sql @@ -126,6 +126,8 @@ CREATE TABLE move_events ( occurred_at DOUBLE NOT NULL, -- device-reported epoch seconds (float) duration_s DOUBLE NOT NULL DEFAULT 0, metrics_json JSON NULL, + spatial_x DOUBLE NULL, -- normalized foot/reference point (0..1 of the frame), + spatial_y DOUBLE NULL, -- heatmap raw material; NULL for legacy/deviceless events acknowledged_by VARCHAR(190) NULL, acknowledged_at BIGINT UNSIGNED NULL, received_at BIGINT UNSIGNED NOT NULL, -- server receipt time (PHP time()), for clock-skew flagging (design doc §8) @@ -141,7 +143,8 @@ CREATE TABLE move_events ( KEY ix_events_camera (camera_id, event_id), -- zone editor: newest snapshot per camera CONSTRAINT chk_events_type CHECK (event_type IN ( 'fall','no_motion','immobility','ghost','zone_warning', - 'zone_restricted','pinch','monitoring_degraded','device_fault' + 'zone_restricted','pinch','monitoring_degraded','device_fault', + 'lone_worker','overcapacity','overstay','near_fall' )), CONSTRAINT chk_events_severity CHECK (severity IN ('critical','high','warning','info')) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/server/src/Dashboard/HeatmapController.php b/server/src/Dashboard/HeatmapController.php new file mode 100644 index 0000000..661b18c --- /dev/null +++ b/server/src/Dashboard/HeatmapController.php @@ -0,0 +1,43 @@ + {points: [{x,y,severity,event_type,occurred_at}], days} + * + * Spatial density of everything the camera detected (incidents AND near-misses) in the + * window — the raw material the zone editor overlays on the camera frame. Points are + * normalized 0..1 of the image, the same space the zone polygons live in. + */ +final class HeatmapController +{ + private const DEFAULT_DAYS = 30; + private const MAX_DAYS = 90; + private const MAX_POINTS = 5000; + + public function __construct(private EventRepository $events, private SessionStore $sess) {} + + public function json(Request $req, array $params): Response + { + if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401); + $camera = (string)($params['camera'] ?? ''); + if ($camera === '') return Response::json(['error' => 'missing_camera'], 400); + $days = self::clampDays($req->query('days')); + // Same fail-closed site scoping as the event list: a scoped operator sees only their + // site's points; only a genuinely null scope is global. + $scope = $this->sess->get('site_scope'); + $site = is_string($scope) ? $scope : null; + $points = $this->events->heatmapPoints($camera, time() - $days * 86400, $site, self::MAX_POINTS); + return Response::json(['points' => $points, 'days' => $days], 200); + } + + private static function clampDays(mixed $raw): int + { + if ($raw === null || $raw === '' || !is_numeric($raw)) return self::DEFAULT_DAYS; + return max(1, min(self::MAX_DAYS, (int)$raw)); + } +} diff --git a/server/src/Ingest/EventValidator.php b/server/src/Ingest/EventValidator.php index 8e4c048..a25a6fa 100644 --- a/server/src/Ingest/EventValidator.php +++ b/server/src/Ingest/EventValidator.php @@ -6,7 +6,7 @@ final class EventValidator { private const TYPES = ['fall','no_motion','immobility','ghost','zone_warning', 'zone_restricted','pinch','monitoring_degraded','device_fault', - 'lone_worker','overcapacity','overstay']; + 'lone_worker','overcapacity','overstay','near_fall']; private const SEVERITIES = ['critical','high','warning','info']; private const REQUIRED = ['device_id','camera_id','event_type','severity','occurred_at']; @@ -32,6 +32,16 @@ final class EventValidator if (isset($p['track_id']) && !is_numeric($p['track_id'])) $errors[] = 'bad:track_id'; if (isset($p['zone_id']) && !is_string($p['zone_id'])) $errors[] = 'bad:zone_id'; if (isset($p['duration_s']) && !is_numeric($p['duration_s'])) $errors[] = 'bad:duration_s'; + // Optional normalized point (heatmap raw material): [x, y], both finite numbers. Range is + // NOT enforced — a foot a hair outside 0..1 must not reject a FALL alert; renderers clamp. + if (isset($p['spatial'])) { + $sp = $p['spatial']; + if (!is_array($sp) || count($sp) !== 2 + || !is_numeric($sp[0] ?? null) || !is_numeric($sp[1] ?? null) + || !is_finite((float)$sp[0]) || !is_finite((float)$sp[1])) { + $errors[] = 'bad:spatial'; + } + } // Optional idempotency key: the edge generates a stable 32-hex uuid per event and re-sends // it on retry so the server can dedup. Enforce the exact shape (matches CHAR(32) + the // snapshot-uuid convention); a malformed key is a bug, not a silent pass. @@ -59,6 +69,8 @@ final class EventValidator 'occurred_at' => (float)$p['occurred_at'], 'duration_s' => (float)($p['duration_s'] ?? 0.0), 'metrics' => is_array($p['metrics'] ?? null) ? $p['metrics'] : [], + 'spatial_x' => isset($p['spatial']) ? (float)$p['spatial'][0] : null, + 'spatial_y' => isset($p['spatial']) ? (float)$p['spatial'][1] : null, ]; return ['ok' => true, 'errors' => [], 'clean' => $clean]; } diff --git a/server/src/Repository/EventRepository.php b/server/src/Repository/EventRepository.php index 4cd8a7f..20846f4 100644 --- a/server/src/Repository/EventRepository.php +++ b/server/src/Repository/EventRepository.php @@ -20,4 +20,10 @@ interface EventRepository public function page(int $limit, ?string $severity, ?string $beforeTs, ?int $beforeId, ?string $site = null): array; public function find(int $id): ?array; public function acknowledge(int $id, string $user): bool; + /** + * Spatial points for the heatmap: events of this camera since $sinceOccurredAt that carry + * a spatial point, newest first, capped at $limit. $site (non-null) applies the same + * site-scope filter as page(). Rows: {x, y, severity, event_type, occurred_at}. + */ + public function heatmapPoints(string $cameraId, float $sinceOccurredAt, ?string $site, int $limit): array; } diff --git a/server/src/Repository/Pdo/PdoEventRepository.php b/server/src/Repository/Pdo/PdoEventRepository.php index b878705..3b3cd53 100644 --- a/server/src/Repository/Pdo/PdoEventRepository.php +++ b/server/src/Repository/Pdo/PdoEventRepository.php @@ -9,7 +9,7 @@ final class PdoEventRepository implements EventRepository { private const SELECT_COLUMNS = 'event_id, event_uuid, device_id, site, camera_id, event_type, severity, track_id, zone_id, - occurred_at, duration_s, metrics_json, acknowledged_by, acknowledged_at'; + occurred_at, duration_s, metrics_json, spatial_x, spatial_y, acknowledged_by, acknowledged_at'; public function __construct(private PDO $pdo) {} @@ -18,8 +18,8 @@ final class PdoEventRepository implements EventRepository $st = $this->pdo->prepare( 'INSERT INTO move_events (event_uuid, device_id, site, camera_id, event_type, severity, track_id, zone_id, - occurred_at, duration_s, metrics_json, received_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' + occurred_at, duration_s, metrics_json, spatial_x, spatial_y, received_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' ); $st->execute([ $event['event_uuid'] ?? null, // NULL when the edge sent no idempotency key (legacy) @@ -33,6 +33,8 @@ final class PdoEventRepository implements EventRepository (float)$event['occurred_at'], (float)($event['duration_s'] ?? 0.0), json_encode($event['metrics'] ?? [], JSON_THROW_ON_ERROR), + $event['spatial_x'] ?? null, + $event['spatial_y'] ?? null, time(), ]); return (int)$this->pdo->lastInsertId(); @@ -131,6 +133,33 @@ final class PdoEventRepository implements EventRepository return true; } + public function heatmapPoints(string $cameraId, float $sinceOccurredAt, ?string $site, int $limit): array + { + $sql = 'SELECT spatial_x, spatial_y, severity, event_type, occurred_at + FROM move_events + WHERE camera_id = ? AND occurred_at >= ? + AND spatial_x IS NOT NULL AND spatial_y IS NOT NULL'; + $binds = [[$cameraId, PDO::PARAM_STR], [sprintf('%.6f', $sinceOccurredAt), PDO::PARAM_STR]]; + if ($site !== null) { + // Same fail-closed site scoping as page(): an empty-string scope matches nothing. + $sql .= ' AND site = ?'; + $binds[] = [$site, PDO::PARAM_STR]; + } + $sql .= ' ORDER BY occurred_at DESC LIMIT ?'; + $binds[] = [$limit, PDO::PARAM_INT]; + $st = $this->pdo->prepare($sql); + $pos = 1; + foreach ($binds as [$val, $type]) $st->bindValue($pos++, $val, $type); + $st->execute(); + return array_map(static fn ($r) => [ + 'x' => (float)$r['spatial_x'], + 'y' => (float)$r['spatial_y'], + 'severity' => $r['severity'], + 'event_type' => $r['event_type'], + 'occurred_at' => (float)$r['occurred_at'], + ], $st->fetchAll()); + } + private static function mapRow(array $row): array { return [ @@ -146,6 +175,8 @@ final class PdoEventRepository implements EventRepository 'occurred_at' => (float)$row['occurred_at'], 'duration_s' => (float)$row['duration_s'], 'metrics' => json_decode((string)($row['metrics_json'] ?? '[]'), true) ?? [], + 'spatial_x' => $row['spatial_x'] !== null ? (float)$row['spatial_x'] : null, + 'spatial_y' => $row['spatial_y'] !== null ? (float)$row['spatial_y'] : null, 'acknowledged_by' => $row['acknowledged_by'], 'acknowledged_at' => $row['acknowledged_at'] !== null ? (int)$row['acknowledged_at'] : null, ]; diff --git a/server/src/View/zones.php b/server/src/View/zones.php index 03386f2..b357358 100644 --- a/server/src/View/zones.php +++ b/server/src/View/zones.php @@ -10,6 +10,9 @@ render_header('구역 설정'); + + +
diff --git a/server/tests/EventValidatorTest.php b/server/tests/EventValidatorTest.php index 34ef0eb..02303ff 100644 --- a/server/tests/EventValidatorTest.php +++ b/server/tests/EventValidatorTest.php @@ -49,11 +49,25 @@ final class EventValidatorTest extends TestCase $this->assertFalse(EventValidator::validate($p)['ok']); } public function testAcceptsOccupancyRuleEventTypes(): void { - foreach (['lone_worker', 'overcapacity', 'overstay'] as $type) { + foreach (['lone_worker', 'overcapacity', 'overstay', 'near_fall'] as $type) { $p = $this->valid(); $p['event_type'] = $type; $this->assertTrue(EventValidator::validate($p)['ok'], "$type must be a known type"); } } + public function testSpatialIsOptionalAndCanonicalized(): void { + $p = $this->valid(); + $this->assertNull(EventValidator::validate($p)['clean']['spatial_x']); + $p['spatial'] = [0.42, '0.87']; + $clean = EventValidator::validate($p)['clean']; + $this->assertSame(0.42, $clean['spatial_x']); + $this->assertSame(0.87, $clean['spatial_y']); + } + public function testRejectsMalformedSpatial(): void { + foreach ([[0.5], [0.5, 'x'], 'mid', [0.5, NAN], [1, 2, 3]] as $i => $bad) { + $p = $this->valid(); $p['spatial'] = $bad; + $this->assertFalse(EventValidator::validate($p)['ok'], "spatial case #$i must be rejected"); + } + } public function testRejectsUnknownSeverity(): void { $p = $this->valid(); $p['severity'] = 'ULTRA'; $this->assertFalse(EventValidator::validate($p)['ok']); diff --git a/server/tests/HeatmapTest.php b/server/tests/HeatmapTest.php new file mode 100644 index 0000000..e237ea4 --- /dev/null +++ b/server/tests/HeatmapTest.php @@ -0,0 +1,85 @@ +set('user_id', 1); + if ($site !== null) $s->set('site_scope', $site); + return $s; + } + + private function repo(): InMemoryEventRepository + { + $r = new InMemoryEventRepository(); + $base = ['device_id' => 'pi-1', 'event_type' => 'near_fall', 'severity' => 'info', + 'metrics' => [], 'occurred_at' => (float)time()]; + // array_merge: the per-row override must WIN over $base (the `+` operator keeps the left + // side's keys, which silently discarded the old-row occurred_at override). + $r->insert(array_merge($base, ['camera_id' => 'cam-1', 'site' => 'a', 'spatial_x' => 0.4, 'spatial_y' => 0.6])); + $r->insert(array_merge($base, ['camera_id' => 'cam-1', 'site' => 'b', 'spatial_x' => 0.1, 'spatial_y' => 0.1])); + $r->insert(array_merge($base, ['camera_id' => 'cam-2', 'site' => 'a', 'spatial_x' => 0.9, 'spatial_y' => 0.9])); + $r->insert(array_merge($base, ['camera_id' => 'cam-1', 'site' => 'a'])); // no spatial + $r->insert(array_merge($base, ['camera_id' => 'cam-1', 'site' => 'a', 'spatial_x' => 0.5, + 'spatial_y' => 0.5, 'occurred_at' => (float)(time() - 400 * 86400)])); // too old + return $r; + } + + private function req(string $days = ''): Request + { + $q = $days === '' ? [] : ['days' => $days]; + return new Request('GET', '/api/heatmap/cam-1', [], '', $q, []); + } + + public function testRequiresLogin(): void + { + $c = new HeatmapController($this->repo(), new ArraySessionStore()); + $this->assertSame(401, $c->json($this->req(), ['camera' => 'cam-1'])->status()); + } + + public function testReturnsCameraPointsWithinWindow(): void + { + $c = new HeatmapController($this->repo(), $this->sess()); + $res = $c->json($this->req(), ['camera' => 'cam-1']); + $this->assertSame(200, $res->status()); + $data = json_decode($res->body(), true); + $this->assertSame(30, $data['days']); + // cam-2's point, the spatial-less row, and the 400-day-old row are all excluded. + $this->assertCount(2, $data['points']); + foreach ($data['points'] as $p) { + $this->assertArrayHasKey('x', $p); + $this->assertArrayHasKey('y', $p); + $this->assertSame('near_fall', $p['event_type']); + } + } + + public function testSiteScopeFiltersPoints(): void + { + $c = new HeatmapController($this->repo(), $this->sess('a')); + $data = json_decode($c->json($this->req(), ['camera' => 'cam-1'])->body(), true); + $this->assertCount(1, $data['points']); // site-b point excluded + $this->assertSame(0.4, $data['points'][0]['x']); + } + + public function testDaysClamped(): void + { + $c = new HeatmapController($this->repo(), $this->sess()); + $this->assertSame(90, json_decode($c->json($this->req('9999'), ['camera' => 'cam-1'])->body(), true)['days']); + $this->assertSame(30, json_decode($c->json($this->req('abc'), ['camera' => 'cam-1'])->body(), true)['days']); + } + + public function testMissingCameraIs400(): void + { + $c = new HeatmapController($this->repo(), $this->sess()); + $this->assertSame(400, $c->json($this->req(), [])->status()); + } +} diff --git a/server/tests/PdoIntegrationTest.php b/server/tests/PdoIntegrationTest.php index 8dd1c20..826965b 100644 --- a/server/tests/PdoIntegrationTest.php +++ b/server/tests/PdoIntegrationTest.php @@ -166,6 +166,8 @@ final class PdoIntegrationTest extends TestCase occurred_at REAL NOT NULL, duration_s REAL NOT NULL DEFAULT 0, metrics_json TEXT, + spatial_x REAL, + spatial_y REAL, acknowledged_by TEXT, acknowledged_at INTEGER, received_at INTEGER NOT NULL diff --git a/server/tests/Support/InMemoryRepositories.php b/server/tests/Support/InMemoryRepositories.php index eb70c5f..5b0f7f6 100644 --- a/server/tests/Support/InMemoryRepositories.php +++ b/server/tests/Support/InMemoryRepositories.php @@ -61,6 +61,16 @@ final class InMemoryEventRepository implements EventRepository { } public function find(int $id): ?array { return $this->rows[$id] ?? null; } public function acknowledge(int $id, string $u): bool { if (!isset($this->rows[$id])) return false; $this->rows[$id]['acknowledged_by'] = $u; return true; } + public function heatmapPoints(string $cameraId, float $since, ?string $site, int $limit): array { + $r = array_filter($this->rows, fn($x) => ($x['camera_id'] ?? '') === $cameraId + && ($x['occurred_at'] ?? 0) >= $since + && ($x['spatial_x'] ?? null) !== null && ($x['spatial_y'] ?? null) !== null + && ($site === null || ($x['site'] ?? null) === $site)); + usort($r, fn($a, $b) => ($b['occurred_at'] ?? 0) <=> ($a['occurred_at'] ?? 0)); + return array_map(fn($x) => ['x' => (float)$x['spatial_x'], 'y' => (float)$x['spatial_y'], + 'severity' => $x['severity'], 'event_type' => $x['event_type'], + 'occurred_at' => (float)($x['occurred_at'] ?? 0)], array_slice(array_values($r), 0, $limit)); + } } final class InMemorySnapshotRepository implements SnapshotRepository { public array $rows = []; public array $paths = []; public array $created = []; diff --git a/tests/test_engine.py b/tests/test_engine.py index 9a05276..266af6b 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -216,6 +216,51 @@ def test_lone_worker_rule_fires_through_engine(): assert lone[0].metrics["required"] == 2 +def test_fall_event_carries_normalized_foot_spatial(): + # Every per-track event now carries the foot point in the zones' 0..1 space — the raw + # material of the server-side heatmap. frame_wh drives the normalization. + eng = _engine() # prone_confirm_s=0.5 + tr = Track(1, deque(maxlen=90)) + _lying_track(tr, 0.0); eng.process([tr], [], now=0.0, frame_wh=(640, 360)) + _lying_track(tr, 0.6) + events = eng.process([tr], [], now=0.6, frame_wh=(640, 360)) + fall = [e for e in events if e.event_type == EventType.FALL][0] + assert fall.spatial is not None + x, y = fall.spatial + assert 0.0 <= x <= 1.0 and 0.0 <= y <= 1.0 + + +def test_near_fall_emits_info_event(): + eng = EventEngine( + FallDetector(FallConfig(prone_confirm_s=5.0, falling_confirm_s=0.5, recover_grace_s=1.0)), + ImmobilityWatchdog(ImmobilityConfig(immobile_s=999)), + GhostIncidentDetector(GhostConfig()), + ZoneIntrusionDetector([], ZoneConfig()), + EngineConfig(camera_id="cam-01", cooldown_s=30.0)) + tr = Track(1, deque(maxlen=90)) + _lying_track(tr, 0.0); eng.process([tr], [], now=0.0) + _lying_track(tr, 1.0); eng.process([tr], [], now=1.0) # FALLING + _standing_track(tr, 2.0); eng.process([tr], [], now=2.0) # 회복 시작 + _standing_track(tr, 3.5) + events = eng.process([tr], [], now=3.5) # grace 경과 → near_fall + near = [e for e in events if e.event_type == EventType.NEAR_FALL] + assert len(near) == 1 + assert near[0].severity == Severity.INFO + assert near[0].metrics["prone_s"] == 2.0 + + +def test_ghost_spatial_is_normalized_with_frame_size(): + eng = _engine() + tr = Track(1, deque(maxlen=90)) + _lying_track(tr, 0.0); eng.process([tr], [], now=0.0, frame_wh=(640, 360)) + _lying_track(tr, 0.6); eng.process([tr], [], now=0.6, frame_wh=(640, 360)) # FALLEN + eng.process([], [tr], now=1.0, frame_wh=(640, 360)) # lost while dangerous + events = eng.process([], [], now=12.0, frame_wh=(640, 360)) + ghost = [e for e in events if e.event_type == EventType.GHOST][0] + x, y = ghost.spatial + assert 0.0 <= x <= 1.0 and 0.0 <= y <= 1.0 # not raw pixels (150, 305) + + def test_allow_cooldown_primitive(): eng = _engine() # EngineConfig(camera_id="cam-01", cooldown_s=30.0) key = ("FALL", 1) diff --git a/tests/test_events.py b/tests/test_events.py index 8250b54..0e2299f 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -20,3 +20,12 @@ def test_event_to_payload_shape(): assert p["track_id"] == 7 assert p["metrics"]["torso_angle"] == 78.0 assert "snapshots" not in p # snapshots는 Alerter가 붙임 + assert "spatial" not in p # None이면 키 생략 (구서버 하위호환) + + +def test_event_to_payload_includes_spatial_when_present(): + 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={}, spatial=(0.51234, 0.87)) + p = e.to_payload(device_id="pi-01") + assert p["spatial"] == [0.5123, 0.87] # 0..1 정규화 좌표, 4자리 반올림 diff --git a/tests/test_fall.py b/tests/test_fall.py index 8660910..da80e88 100644 --- a/tests/test_fall.py +++ b/tests/test_fall.py @@ -132,6 +132,33 @@ def test_single_prone_frame_does_not_enter_falling(): assert det._states[1].name == "FALLING" +def test_recovery_before_confirm_signals_near_fall(): + # FALLING(지속 prone)까지 갔다가 FALLEN 확정 전에 회복 = 니어미스. 사고 알람이 아니라 + # 히트맵/통계 재료로 구분된 신호("near_fall")여야 한다. + det = FallDetector(FallConfig(prone_confirm_s=5.0, falling_confirm_s=0.5, recover_grace_s=1.0)) + tr = _track() + box = BBox(90, 290, 210, 320) + _feed(det, tr, _lying_kps(), box, t=0.0) + _feed(det, tr, _lying_kps(), box, t=1.0) # FALLING (확정 전) + standing_box = BBox(40, 60, 60, 210) + _feed(det, tr, _standing_kps(), standing_box, t=2.0) # 회복 시작 + sig, meta = _feed(det, tr, _standing_kps(), standing_box, t=3.5) # grace 경과 + assert sig == "near_fall" + assert meta["prone_s"] == 2.0 # prone 0.0 → 비-prone 2.0 + + +def test_recovery_after_confirmed_fall_is_recover_not_near_fall(): + det = FallDetector(FallConfig(prone_confirm_s=0.5, recover_grace_s=1.0)) + 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 확정 + standing_box = BBox(40, 60, 60, 210) + _feed(det, tr, _standing_kps(), standing_box, t=1.0) + sig, _ = _feed(det, tr, _standing_kps(), standing_box, t=2.5) + assert sig == "recover" + + def test_low_confidence_is_ignored(): det = FallDetector(FallConfig()) tr = _track()