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:
parent
a203c42bf3
commit
9e4225a338
23 changed files with 433 additions and 17 deletions
|
|
@ -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 실렌더(편집기 저장·목록·콘솔 라벨).
|
||||||
|
|
|
||||||
|
|
@ -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`(장치→카메라 트리 레일) ·
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"]}))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
13
server/migrations/2026-08-06-heatmap-spatial.sql
Normal file
13
server/migrations/2026-08-06-heatmap-spatial.sql
Normal 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'
|
||||||
|
));
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
43
server/src/Dashboard/HeatmapController.php
Normal file
43
server/src/Dashboard/HeatmapController.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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']);
|
||||||
|
|
|
||||||
85
server/tests/HeatmapTest.php
Normal file
85
server/tests/HeatmapTest.php
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 = [];
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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자리 반올림
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue