feat(heatmap): near-miss events + spatial ingestion + camera risk-heatmap overlay

Heinrich's-law play: the signals we already compute but used to throw away
become a spatial risk map of the workplace, drawn over the camera's own frame.

Edge:
- near_fall (INFO): a track that reached FALLING (sustained prone) but recovered
  before the FALLEN confirm. Treated as a full event per product decision -
  same ingest, snapshots, dedup, ack flow as incidents. Fix en route: prone_s
  used a truthiness check that turned prone_since=0.0 into "no timer".
- Every per-track event (fall/no_motion/near_fall/immobility/zone hits) now
  carries the foot point in the zones' normalized 0..1 space (reusing the zone
  detector's own coordinate math); ghost's pixel-space loss point is normalized
  at emit. to_payload omits the key when absent, so an old server ignores it.

Server:
- move_events.spatial_x/spatial_y (nullable DOUBLE) + validator: optional
  [x, y] finite-number pair; range deliberately NOT enforced (an off-by-a-hair
  foot must never reject a FALL alert - renderers clamp).
- MIGRATION FIX: chk_events_type would have REJECTED the previous commit's
  lone_worker/overcapacity/overstay inserts on MySQL 8.0.16+ - the constraint
  is rebuilt with all 13 types (incl. near_fall).
- GET /api/heatmap/{camera}?days=N (1..90, default 30, 5000-point cap):
  spatial points with the same fail-closed site scoping as the event list.

UI (zone editor): "위험 히트맵" toggle + day window. Additive radial blots in
an offscreen layer under the zone polygons; severity weighs the alpha so a
cluster of near-misses reads lighter than one confirmed fall at the same spot.
Events console/detail label near_fall in Korean.

Verified: pytest 222 pass / PHPUnit 130 pass + Chrome real-render (blue
near-miss cluster + red incident hotspot inside the restricted zone, amber
cluster in the warning zone, zones drawn on top; no console errors).
This commit is contained in:
유창욱 2026-08-06 20:19:09 +09:00
parent a203c42bf3
commit 9e4225a338
23 changed files with 433 additions and 17 deletions

View file

@ -6,6 +6,10 @@
stale 트랙 no-data) — `32f2237`. TDD(RED 확인), pytest 203 통과/2 skip. stale 트랙 no-data) — `32f2237`. TDD(RED 확인), pytest 203 통과/2 skip.
→ delivery-audit 오프하드웨어 항목 43/43 완료. 잔여는 실장비(OFF-HW) 검증뿐. → delivery-audit 오프하드웨어 항목 43/43 완료. 잔여는 실장비(OFF-HW) 검증뿐.
- **2026-08-06** 연속성 문서 신설: `CLAUDE.md` · `docs/PROJECT_MAP.md` · 본 INDEX. - **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` · - **2026-08-06** 존 점유 규칙 엔진 (2인1조 `lone_worker`/HIGH · 정원 `overcapacity` ·
체류상한 `overstay`): edge `occupancy.py` + `move_zones.rules_json` + 존 편집기 규칙 입력 + 체류상한 `overstay`): edge `occupancy.py` + `move_zones.rules_json` + 존 편집기 규칙 입력 +
이벤트 유형 한글 라벨. pytest 216/PHPUnit 123 통과 + Chrome 실렌더(편집기 저장·목록·콘솔 라벨). 이벤트 유형 한글 라벨. pytest 216/PHPUnit 123 통과 + Chrome 실렌더(편집기 저장·목록·콘솔 라벨).

View file

@ -30,8 +30,9 @@
- 진입: `public/index.php` (라우트 전부 여기), 뷰: `src/View/{layout,events,login,zones,event_detail}.php` - 진입: `public/index.php` (라우트 전부 여기), 뷰: `src/View/{layout,events,login,zones,event_detail}.php`
- 라우트: `POST /api/events`(ingest, HMAC) · `POST /api/heartbeat` · `GET /api/config` · - 라우트: `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 /api/events` · `GET /api/devices` · `GET /api/heatmap/{camera}`(spatial 집계) ·
`GET /snap/{uuid}` · `POST /events/{id}/ack` · 페이지 `/`, `/login`, `/zones`, `/events/{id}/view` `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/Ingest/` HMAC+nonce+rate-limit+replay 방지 · `src/Auth/` argon2id/세션/RBAC/CSRF ·
`src/Dashboard/` 이벤트목록·디바이스상태·존편집·스냅샷프록시 · `src/Repository/` 9종 (인터페이스+PDO) `src/Dashboard/` 이벤트목록·디바이스상태·존편집·스냅샷프록시 · `src/Repository/` 9종 (인터페이스+PDO)
- 프런트: `public/assets/app.js`(이벤트 콘솔 폴링·필터) · `device-tree.js`(장치→카메라 트리 레일) · - 프런트: `public/assets/app.js`(이벤트 콘솔 폴링·필터) · `device-tree.js`(장치→카메라 트리 레일) ·

View file

@ -95,7 +95,13 @@ class FallDetector:
st.not_prone_since = now st.not_prone_since = now
return None, {} return None, {}
if now - st.not_prone_since >= self.cfg.recover_grace_s: 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) 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 "recover", {}
return None, {} return None, {}
st.prone_since = None st.prone_since = None

View file

@ -73,18 +73,28 @@ class EventEngine:
# space (see ZoneIntrusionDetector._foot). run_once supplies the live frame size. # space (see ZoneIntrusionDetector._foot). run_once supplies the live frame size.
zone_hits = self.zone.update(tr, now, frame_wh) zone_hits = self.zone.update(tr, now, frame_wh)
in_zone = bool(self.zone.active_zone_ids(tr.track_id)) 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) sig, meta = self.fall.update(tr, now)
state = self.fall._states.get(tr.track_id) state = self.fall._states.get(tr.track_id)
self.ghost.note_state(tr.track_id, state.name if state else "STANDING") 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): 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): 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) fired, imeta = self.immobility.update(tr, now, in_danger_zone=in_zone)
if fired and self._allow((EventType.IMMOBILITY, tr.track_id), now): 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: for hit in zone_hits:
# 협착(pinch) is the highest-danger zone (machinery entrapment) and must emit # 협착(pinch) is the highest-danger zone (machinery entrapment) and must emit
@ -94,7 +104,8 @@ class EventEngine:
"restricted": EventType.ZONE_RESTRICTED}.get( "restricted": EventType.ZONE_RESTRICTED}.get(
hit["level"], EventType.ZONE_WARNING) hit["level"], EventType.ZONE_WARNING)
if self._allow((etype, tr.track_id, hit["zone_id"]), now): 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가 갱신한 멤버십을 # 존 규칙(2인 1조 · 정원 · 체류): 위 per-track 루프에서 zone.update가 갱신한 멤버십을
# 존별 인원으로 뒤집어 감시자에 넘긴다. stale 트랙은 run_once가 이미 걸러냈으므로 # 존별 인원으로 뒤집어 감시자에 넘긴다. stale 트랙은 run_once가 이미 걸러냈으므로
@ -118,8 +129,16 @@ class EventEngine:
# Routing it through _allow would only insert a permanent (GHOST, track_id) dedup key for # 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 # 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). # 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"], events.append(self._mk(EventType.GHOST, occurred_at, track_id=g["track_id"],
spatial=g["spatial"], spatial=gs,
metrics={"last_state": g["last_state"], metrics={"last_state": g["last_state"],
"waited_s": g["waited_s"]})) "waited_s": g["waited_s"]}))

View file

@ -16,6 +16,7 @@ class EventType(str, Enum):
LONE_WORKER = "lone_worker" # 2인 1조 존에 단독 작업 지속 LONE_WORKER = "lone_worker" # 2인 1조 존에 단독 작업 지속
OVERCAPACITY = "overcapacity" # 존 정원 초과 지속 OVERCAPACITY = "overcapacity" # 존 정원 초과 지속
OVERSTAY = "overstay" # 존 체류 상한 초과 (트랙별) OVERSTAY = "overstay" # 존 체류 상한 초과 (트랙별)
NEAR_FALL = "near_fall" # 니어미스: FALLING까지 갔다가 확정 전 회복
class Severity(str, Enum): class Severity(str, Enum):
@ -39,6 +40,8 @@ _SEVERITY = {
EventType.LONE_WORKER: Severity.HIGH, EventType.LONE_WORKER: Severity.HIGH,
EventType.OVERCAPACITY: Severity.WARNING, EventType.OVERCAPACITY: Severity.WARNING,
EventType.OVERSTAY: 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) event_uuid: str = field(default_factory=lambda: _uuid.uuid4().hex)
def to_payload(self, device_id: str) -> dict: def to_payload(self, device_id: str) -> dict:
return { payload = {
"device_id": device_id, "device_id": device_id,
"event_uuid": self.event_uuid, "event_uuid": self.event_uuid,
"camera_id": self.camera_id, "camera_id": self.camera_id,
@ -75,3 +78,8 @@ class Event:
"duration_s": round(self.duration_s, 2), "duration_s": round(self.duration_s, 2),
"metrics": self.metrics, "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

View file

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

View file

@ -21,7 +21,8 @@
fall: '넘어짐', no_motion: '넘어짐 후 미동 없음', immobility: '장시간 부동', fall: '넘어짐', no_motion: '넘어짐 후 미동 없음', immobility: '장시간 부동',
ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입', ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입',
pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상', pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상',
lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과' lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과',
near_fall: '넘어질 뻔함 (니어미스)'
}; };
var SEVERITY_LABELS = { var SEVERITY_LABELS = {

View file

@ -27,7 +27,8 @@
fall: '넘어짐', no_motion: '넘어짐 후 미동 없음', immobility: '장시간 부동', fall: '넘어짐', no_motion: '넘어짐 후 미동 없음', immobility: '장시간 부동',
ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입', ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입',
pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상', pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상',
lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과' lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과',
near_fall: '넘어질 뻔함 (니어미스)'
}; };
var meta = document.getElementById('event-detail-meta'); var meta = document.getElementById('event-detail-meta');

View file

@ -36,6 +36,8 @@
var ruleMinInput = document.getElementById('rule-min-workers'); var ruleMinInput = document.getElementById('rule-min-workers');
var ruleMaxInput = document.getElementById('rule-max-workers'); var ruleMaxInput = document.getElementById('rule-max-workers');
var ruleDwellInput = document.getElementById('rule-max-dwell'); var ruleDwellInput = document.getElementById('rule-max-dwell');
var heatmapBtn = document.getElementById('heatmap-btn');
var heatmapDaysInput = document.getElementById('heatmap-days');
var LEVEL_STYLE = { var LEVEL_STYLE = {
warning: { stroke: '#eab308', label: '경고' }, warning: { stroke: '#eab308', label: '경고' },
@ -47,6 +49,9 @@
var existingZones = []; // [{zone_id, level, points: [[x,y],...]}] var existingZones = []; // [{zone_id, level, points: [[x,y],...]}]
var currentPoints = []; // in-progress polygon being drawn var currentPoints = []; // in-progress polygon being drawn
var csrfToken = null; 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) { function setStatus(msg) {
statusEl.textContent = 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() { function redraw() {
ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.clearRect(0, 0, canvas.width, canvas.height);
if (backgroundImage) { if (backgroundImage) {
ctx.drawImage(backgroundImage, 0, 0, canvas.width, canvas.height); ctx.drawImage(backgroundImage, 0, 0, canvas.width, canvas.height);
} }
if (heatmapOn && heatmapLayer) {
ctx.drawImage(heatmapLayer, 0, 0);
}
existingZones.forEach(function (z) { existingZones.forEach(function (z) {
var style = LEVEL_STYLE[z.level] || { stroke: '#9ca3af', label: z.level }; var style = LEVEL_STYLE[z.level] || { stroke: '#9ca3af', label: z.level };
drawPolygon(z.points, style.stroke, style.label, true); drawPolygon(z.points, style.stroke, style.label, true);
@ -260,6 +329,8 @@
levelSelect.addEventListener('change', redraw); levelSelect.addEventListener('change', redraw);
loadBtn.addEventListener('click', loadZones); loadBtn.addEventListener('click', loadZones);
if (frameBtn) frameBtn.addEventListener('click', loadLatestFrame); 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('현재 폴리곤을 지웠습니다.'); }); clearBtn.addEventListener('click', function () { currentPoints = []; redraw(); setStatus('현재 폴리곤을 지웠습니다.'); });
saveBtn.addEventListener('click', saveZone); saveBtn.addEventListener('click', saveZone);

View file

@ -46,7 +46,7 @@ use App\Ingest\IngestController;
use App\Api\{ConfigController, HeartbeatController}; use App\Api\{ConfigController, HeartbeatController};
use App\Auth\{AuthController, CsrfController}; use App\Auth\{AuthController, CsrfController};
use App\Dashboard\{EventListController, EventDetailController, SnapshotProxy, ZoneConfigController, 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 // 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 // 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); $proxy = new SnapshotProxy($snaps, $events, $sess);
$zoneCtl = new ZoneConfigController($zones, $devices, $audit, $sess, $snaps); $zoneCtl = new ZoneConfigController($zones, $devices, $audit, $sess, $snaps);
$deviceStatus = new DeviceStatusController($devices, $sess); $deviceStatus = new DeviceStatusController($devices, $sess);
$heatmap = new HeatmapController($events, $sess);
$render = function (string $view): Response { $render = function (string $view): Response {
ob_start(); require APP_ROOT . '/src/View/' . $view . '.php'; 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('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('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/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. // 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('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)); $r->add('POST', '/api/zones', fn(Request $q) => $zoneCtl->save($q));

View file

@ -126,6 +126,8 @@ CREATE TABLE move_events (
occurred_at DOUBLE NOT NULL, -- device-reported epoch seconds (float) occurred_at DOUBLE NOT NULL, -- device-reported epoch seconds (float)
duration_s DOUBLE NOT NULL DEFAULT 0, duration_s DOUBLE NOT NULL DEFAULT 0,
metrics_json JSON NULL, 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_by VARCHAR(190) NULL,
acknowledged_at BIGINT UNSIGNED NULL, acknowledged_at BIGINT UNSIGNED NULL,
received_at BIGINT UNSIGNED NOT NULL, -- server receipt time (PHP time()), for clock-skew flagging (design doc §8) 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 KEY ix_events_camera (camera_id, event_id), -- zone editor: newest snapshot per camera
CONSTRAINT chk_events_type CHECK (event_type IN ( CONSTRAINT chk_events_type CHECK (event_type IN (
'fall','no_motion','immobility','ghost','zone_warning', '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')) CONSTRAINT chk_events_severity CHECK (severity IN ('critical','high','warning','info'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View file

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Dashboard;
use App\Http\{Request, Response};
use App\Auth\SessionStore;
use App\Repository\EventRepository;
/**
* GET /api/heatmap/{camera}?days=N -> {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));
}
}

View file

@ -6,7 +6,7 @@ final class EventValidator
{ {
private const TYPES = ['fall','no_motion','immobility','ghost','zone_warning', private const TYPES = ['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']; 'lone_worker','overcapacity','overstay','near_fall'];
private const SEVERITIES = ['critical','high','warning','info']; private const SEVERITIES = ['critical','high','warning','info'];
private const REQUIRED = ['device_id','camera_id','event_type','severity','occurred_at']; 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['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['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'; 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 // 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 // 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. // 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'], 'occurred_at' => (float)$p['occurred_at'],
'duration_s' => (float)($p['duration_s'] ?? 0.0), 'duration_s' => (float)($p['duration_s'] ?? 0.0),
'metrics' => is_array($p['metrics'] ?? null) ? $p['metrics'] : [], '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]; return ['ok' => true, 'errors' => [], 'clean' => $clean];
} }

View file

@ -20,4 +20,10 @@ interface EventRepository
public function page(int $limit, ?string $severity, ?string $beforeTs, ?int $beforeId, ?string $site = null): array; public function page(int $limit, ?string $severity, ?string $beforeTs, ?int $beforeId, ?string $site = null): array;
public function find(int $id): ?array; public function find(int $id): ?array;
public function acknowledge(int $id, string $user): bool; 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;
} }

View file

@ -9,7 +9,7 @@ final class PdoEventRepository implements EventRepository
{ {
private const SELECT_COLUMNS = private const SELECT_COLUMNS =
'event_id, event_uuid, device_id, site, camera_id, event_type, severity, track_id, zone_id, '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) {} public function __construct(private PDO $pdo) {}
@ -18,8 +18,8 @@ final class PdoEventRepository implements EventRepository
$st = $this->pdo->prepare( $st = $this->pdo->prepare(
'INSERT INTO move_events 'INSERT INTO move_events
(event_uuid, device_id, site, camera_id, event_type, severity, track_id, zone_id, (event_uuid, device_id, site, camera_id, event_type, severity, track_id, zone_id,
occurred_at, duration_s, metrics_json, received_at) occurred_at, duration_s, metrics_json, spatial_x, spatial_y, received_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
); );
$st->execute([ $st->execute([
$event['event_uuid'] ?? null, // NULL when the edge sent no idempotency key (legacy) $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['occurred_at'],
(float)($event['duration_s'] ?? 0.0), (float)($event['duration_s'] ?? 0.0),
json_encode($event['metrics'] ?? [], JSON_THROW_ON_ERROR), json_encode($event['metrics'] ?? [], JSON_THROW_ON_ERROR),
$event['spatial_x'] ?? null,
$event['spatial_y'] ?? null,
time(), time(),
]); ]);
return (int)$this->pdo->lastInsertId(); return (int)$this->pdo->lastInsertId();
@ -131,6 +133,33 @@ final class PdoEventRepository implements EventRepository
return true; 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 private static function mapRow(array $row): array
{ {
return [ return [
@ -146,6 +175,8 @@ final class PdoEventRepository implements EventRepository
'occurred_at' => (float)$row['occurred_at'], 'occurred_at' => (float)$row['occurred_at'],
'duration_s' => (float)$row['duration_s'], 'duration_s' => (float)$row['duration_s'],
'metrics' => json_decode((string)($row['metrics_json'] ?? '[]'), true) ?? [], '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_by' => $row['acknowledged_by'],
'acknowledged_at' => $row['acknowledged_at'] !== null ? (int)$row['acknowledged_at'] : null, 'acknowledged_at' => $row['acknowledged_at'] !== null ? (int)$row['acknowledged_at'] : null,
]; ];

View file

@ -10,6 +10,9 @@ render_header('구역 설정');
<input type="text" id="camera-id" placeholder="cam-01"> <input type="text" id="camera-id" placeholder="cam-01">
<button type="button" id="zone-load-btn">불러오기</button> <button type="button" id="zone-load-btn">불러오기</button>
<button type="button" id="zone-frame-btn">현재 화면 불러오기</button> <button type="button" id="zone-frame-btn">현재 화면 불러오기</button>
<button type="button" id="heatmap-btn" aria-pressed="false">위험 히트맵</button>
<label for="heatmap-days">기간(일)</label>
<input type="number" id="heatmap-days" min="1" max="90" value="30">
</div> </div>
<div class="zone-controls"> <div class="zone-controls">
<label for="zone-level">등급</label> <label for="zone-level">등급</label>

View file

@ -49,11 +49,25 @@ final class EventValidatorTest extends TestCase
$this->assertFalse(EventValidator::validate($p)['ok']); $this->assertFalse(EventValidator::validate($p)['ok']);
} }
public function testAcceptsOccupancyRuleEventTypes(): void { 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; $p = $this->valid(); $p['event_type'] = $type;
$this->assertTrue(EventValidator::validate($p)['ok'], "$type must be a known 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 { public function testRejectsUnknownSeverity(): void {
$p = $this->valid(); $p['severity'] = 'ULTRA'; $p = $this->valid(); $p['severity'] = 'ULTRA';
$this->assertFalse(EventValidator::validate($p)['ok']); $this->assertFalse(EventValidator::validate($p)['ok']);

View file

@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Tests;
use App\Http\Request;
use App\Auth\ArraySessionStore;
use App\Dashboard\HeatmapController;
use App\Tests\Support\InMemoryEventRepository;
use PHPUnit\Framework\TestCase;
final class HeatmapTest extends TestCase
{
private function sess(?string $site = null): ArraySessionStore
{
$s = new ArraySessionStore();
$s->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());
}
}

View file

@ -166,6 +166,8 @@ final class PdoIntegrationTest extends TestCase
occurred_at REAL NOT NULL, occurred_at REAL NOT NULL,
duration_s REAL NOT NULL DEFAULT 0, duration_s REAL NOT NULL DEFAULT 0,
metrics_json TEXT, metrics_json TEXT,
spatial_x REAL,
spatial_y REAL,
acknowledged_by TEXT, acknowledged_by TEXT,
acknowledged_at INTEGER, acknowledged_at INTEGER,
received_at INTEGER NOT NULL received_at INTEGER NOT NULL

View file

@ -61,6 +61,16 @@ final class InMemoryEventRepository implements EventRepository {
} }
public function find(int $id): ?array { return $this->rows[$id] ?? null; } 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 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 { final class InMemorySnapshotRepository implements SnapshotRepository {
public array $rows = []; public array $paths = []; public array $created = []; public array $rows = []; public array $paths = []; public array $created = [];

View file

@ -216,6 +216,51 @@ def test_lone_worker_rule_fires_through_engine():
assert lone[0].metrics["required"] == 2 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(): def test_allow_cooldown_primitive():
eng = _engine() # EngineConfig(camera_id="cam-01", cooldown_s=30.0) eng = _engine() # EngineConfig(camera_id="cam-01", cooldown_s=30.0)
key = ("FALL", 1) key = ("FALL", 1)

View file

@ -20,3 +20,12 @@ def test_event_to_payload_shape():
assert p["track_id"] == 7 assert p["track_id"] == 7
assert p["metrics"]["torso_angle"] == 78.0 assert p["metrics"]["torso_angle"] == 78.0
assert "snapshots" not in p # snapshots는 Alerter가 붙임 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자리 반올림

View file

@ -132,6 +132,33 @@ def test_single_prone_frame_does_not_enter_falling():
assert det._states[1].name == "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(): def test_low_confidence_is_ignored():
det = FallDetector(FallConfig()) det = FallDetector(FallConfig())
tr = _track() tr = _track()