From a203c42bf3afe7edc2d9408cd87be64c0d956aa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EC=B0=BD=EC=9A=B1?= Date: Thu, 6 Aug 2026 19:33:12 +0900 Subject: [PATCH] feat(zones): occupancy rule engine - lone-worker / capacity / dwell limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-zone declarative safety rules, set in the zone editor and enforced on the edge from the tracking the pipeline already does (no new geometry, no new model): - min_workers (2인 1조): 0 < count < minimum sustained 30s -> lone_worker/HIGH. A solo worker in a buddy-rule zone is a compliance violation and a rescue- delay risk, so it outranks the other two. - max_workers: count > limit sustained 10s -> overcapacity/WARNING. - max_dwell_s: one track's continuous stay past the limit -> overstay/WARNING. A short detection dropout (<=5s grace) does NOT reset the dwell timer - without that, run_once's stale-track filter would quietly neuter every 10-minute rule at real-world detection rates. Edge: detectors/occupancy.py consumes Zone.rules; the engine flips the zone detector's per-track membership into per-zone headcounts (stale tracks already excluded), so counts always match what was actually detected this tick. Fired states clear on condition recovery - re-alerts need a fresh violation, not a cooldown expiry. Malformed rules_json keeps the zone's intrusion detection alive and logs the rules as inactive (never fail dark). Server: move_zones.rules_json (nullable JSON, migration included) validated strictly in ZoneConfigController (bounds, min<=max, unknown keys rejected - a silently dropped rule is one the operator believes is active). Delivered to the edge unchanged via GET /api/config. EventValidator accepts the 3 new types. UI: zone editor gains the three rule fields (dwell entered in minutes, wired in seconds) and shows each zone's rules in its list row; the events console and detail view now render every event type in Korean (wire codes fall back). Verified: pytest 216 pass / PHPUnit 123 pass + real-render harness in Chrome (editor save payload, list labels, console type labels). --- docs/INDEX.md | 3 + docs/PROJECT_MAP.md | 6 +- edge/detectors/occupancy.py | 95 +++++++++++++++ edge/detectors/zone.py | 3 + edge/engine.py | 30 ++++- edge/events.py | 7 ++ edge/zones.py | 35 +++++- server/migrations/2026-08-06-zone-rules.sql | 5 + server/public/assets/app.js | 11 +- server/public/assets/detail.js | 11 +- server/public/assets/zone-draw.js | 33 ++++- server/schema.sql | 1 + server/src/Dashboard/ZoneConfigController.php | 37 ++++++ server/src/Ingest/EventValidator.php | 3 +- .../src/Repository/Pdo/PdoZoneRepository.php | 21 ++-- server/src/View/zones.php | 8 ++ server/tests/EventValidatorTest.php | 6 + server/tests/PdoIntegrationTest.php | 1 + server/tests/ZoneConfigTest.php | 45 +++++++ tests/test_engine.py | 26 ++++ tests/test_occupancy.py | 114 ++++++++++++++++++ tests/test_zones.py | 27 +++++ 22 files changed, 508 insertions(+), 20 deletions(-) create mode 100644 edge/detectors/occupancy.py create mode 100644 server/migrations/2026-08-06-zone-rules.sql create mode 100644 tests/test_occupancy.py diff --git a/docs/INDEX.md b/docs/INDEX.md index 55b45c6..c514c1d 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -6,3 +6,6 @@ stale 트랙 no-data) — `32f2237`. TDD(RED 확인), pytest 203 통과/2 skip. → delivery-audit 오프하드웨어 항목 43/43 완료. 잔여는 실장비(OFF-HW) 검증뿐. - **2026-08-06** 연속성 문서 신설: `CLAUDE.md` · `docs/PROJECT_MAP.md` · 본 INDEX. +- **2026-08-06** 존 점유 규칙 엔진 (2인1조 `lone_worker`/HIGH · 정원 `overcapacity` · + 체류상한 `overstay`): edge `occupancy.py` + `move_zones.rules_json` + 존 편집기 규칙 입력 + + 이벤트 유형 한글 라벨. pytest 216/PHPUnit 123 통과 + Chrome 실렌더(편집기 저장·목록·콘솔 라벨). diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index c933db8..84422c0 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -16,7 +16,7 @@ | `app.py` | `run_once` 파이프라인 (프레임→추론→추적→감지→스냅샷→전송). ambiguous 리셋·stale 필터 | | `engine.py` | `EventEngine` — 감지기 오케스트레이션, 쿨다운 dedup, 트랙 forget | | `tracking.py` | `IouTracker` — 전역 그리디 IoU 매칭, `ambiguous()` (교차 경합 트랙) | -| `detectors/` | `fall.py`(FALLING→FALLEN→NO_MOTION, sustained prone) · `immobility.py` · `ghost.py` · `zone.py` | +| `detectors/` | `fall.py`(FALLING→FALLEN→NO_MOTION, sustained prone) · `immobility.py` · `ghost.py` · `zone.py` · `occupancy.py`(존 규칙: 2인1조/정원/체류상한 — Zone.rules 소비) | | `smoothing.py` | 트랙별 키포인트 스무딩 풀 | | `snapshot.py` | RingBuffer · FaceBlurrer(PIPA) · Snapshotter | | `alert.py` / `serverclient.py` | HMAC+nonce POST, 커스텀 UA, no-redirect | @@ -36,7 +36,9 @@ `src/Dashboard/` 이벤트목록·디바이스상태·존편집·스냅샷프록시 · `src/Repository/` 9종 (인터페이스+PDO) - 프런트: `public/assets/app.js`(이벤트 콘솔 폴링·필터) · `device-tree.js`(장치→카메라 트리 레일) · `zone-draw.js` · `detail.js` · `app.css` -- DB: `schema.sql` (테이블 prefix `move_`), 마이그레이션 `migrations/` +- DB: `schema.sql` (테이블 prefix `move_`), 마이그레이션 `migrations/`. + 존 행의 `rules_json`(선택) = 점유 규칙 {min_workers,max_workers,max_dwell_s} — 편집기 저장 시 + ZoneConfigController가 검증, /api/config로 엣지 전달 - 배포: `server/deploy/build_cafe24_package.sh` → zip, `server/docs/server-deploy.md` ## deploy/ · dist/ (납품) diff --git a/edge/detectors/occupancy.py b/edge/detectors/occupancy.py new file mode 100644 index 0000000..734bc3e --- /dev/null +++ b/edge/detectors/occupancy.py @@ -0,0 +1,95 @@ +from dataclasses import dataclass + + +@dataclass +class OccupancyConfig: + lone_confirm_s: float = 30.0 # 단독 작업이 이 시간 지속돼야 발보 (동료의 잠깐 이탈 흡수) + over_confirm_s: float = 10.0 # 정원 초과 지속 요구 (경계 통과 순간의 겹침 흡수) + dwell_absence_grace_s: float = 5.0 # 이 시간 안의 미검출은 체류 연속으로 간주 (검출 드롭아웃 흡수) + + +class OccupancyWatchdog: + """존 규칙(2인 1조 · 정원 · 체류 상한) 감시. + + 존 멤버십은 ZoneIntrusionDetector가 이미 계산한 것을 엔진이 존별 인원으로 뒤집어 + 넘겨준다 — 여기서는 기하 계산이 없다. 규칙은 Zone.rules(대시보드 존 편집기에서 + 설정, /api/config로 동기)에서 읽으므로 존 목록 교체(apply_zones)에 자동으로 따라간다. + + 발보는 조건이 해소될 때까지 존(또는 트랙·존)당 1회 — 재발보는 엔진 쿨다운이 아니라 + 조건 해소 후 재충족으로만 일어난다. + """ + + def __init__(self, cfg: OccupancyConfig): + self.cfg = cfg + self._lone_since: dict[str, float] = {} + self._lone_fired: set[str] = set() + self._over_since: dict[str, float] = {} + self._over_fired: set[str] = set() + self._dwell: dict[tuple[int, str], list] = {} # (tid, zone_id) -> [since, last_seen] + self._dwell_fired: set[tuple[int, str]] = set() + + def forget(self, track_id: int) -> None: + self._dwell = {k: v for k, v in self._dwell.items() if k[0] != track_id} + self._dwell_fired = {k for k in self._dwell_fired if k[0] != track_id} + + def update(self, zones: list, zone_tracks: dict, now: float) -> list[dict]: + out: list[dict] = [] + known: set[str] = set() + for zone in zones: + zid = zone.zone_id + known.add(zid) + rules = zone.rules or {} + tids = zone_tracks.get(zid, set()) + n = len(tids) + + # 2인 1조: 0 < 인원 < 최소 인원이 지속되면 발보. 빈 존은 위반이 아니다. + min_workers = rules.get("min_workers") + if min_workers and 0 < n < min_workers: + start = self._lone_since.setdefault(zid, now) + if now - start >= self.cfg.lone_confirm_s and zid not in self._lone_fired: + self._lone_fired.add(zid) + out.append({"kind": "lone_worker", "zone_id": zid, + "track_id": next(iter(tids)) if n == 1 else None, + "metrics": {"workers": n, "required": min_workers, + "alone_s": round(now - start, 1)}}) + else: + self._lone_since.pop(zid, None) + self._lone_fired.discard(zid) + + # 정원 초과 + max_workers = rules.get("max_workers") + if max_workers and n > max_workers: + start = self._over_since.setdefault(zid, now) + if now - start >= self.cfg.over_confirm_s and zid not in self._over_fired: + self._over_fired.add(zid) + out.append({"kind": "overcapacity", "zone_id": zid, "track_id": None, + "metrics": {"workers": n, "limit": max_workers}}) + else: + self._over_since.pop(zid, None) + self._over_fired.discard(zid) + + # 체류 상한 (트랙별 연속 체류). 미검출 1~2틱(run_once의 stale 필터)으로 타이머가 + # 리셋되면 10분 규칙이 사실상 무력화되므로, grace 안의 부재는 연속으로 간주한다. + max_dwell = rules.get("max_dwell_s") + if max_dwell: + for tid in tids: + rec = self._dwell.setdefault((tid, zid), [now, now]) + rec[1] = now + if now - rec[0] >= max_dwell and (tid, zid) not in self._dwell_fired: + self._dwell_fired.add((tid, zid)) + out.append({"kind": "overstay", "zone_id": zid, "track_id": tid, + "metrics": {"dwell_s": round(now - rec[0], 1), + "limit_s": max_dwell}}) + + # grace를 넘겨 존을 떠난(또는 트랙이 죽은) 체류 기록 정리 — 재진입은 새 체류다. + for key, rec in list(self._dwell.items()): + if now - rec[1] > self.cfg.dwell_absence_grace_s: + self._dwell.pop(key) + self._dwell_fired.discard(key) + # 삭제된 존의 잔여 상태 정리 (24/7 장치 메모리 누수 방지). + for d, fired in ((self._lone_since, self._lone_fired), (self._over_since, self._over_fired)): + for zid in list(d): + if zid not in known: + d.pop(zid, None) + fired.intersection_update(known) + return out diff --git a/edge/detectors/zone.py b/edge/detectors/zone.py index 64a1b5b..55e13a6 100644 --- a/edge/detectors/zone.py +++ b/edge/detectors/zone.py @@ -25,6 +25,9 @@ class Zone: zone_id: str level: str # "warning" | "restricted" polygon: list # list[tuple[float,float]] + # 선택적 존 규칙 (occupancy.py가 소비): {"min_workers": 2, "max_workers": 4, + # "max_dwell_s": 600} — 키가 없으면 그 규칙은 꺼진 것. None = 규칙 없음. + rules: dict | None = None @dataclass diff --git a/edge/engine.py b/edge/engine.py index bb2ccd6..4d3e92b 100644 --- a/edge/engine.py +++ b/edge/engine.py @@ -6,6 +6,7 @@ from edge.detectors.fall import FallDetector from edge.detectors.immobility import ImmobilityWatchdog from edge.detectors.ghost import GhostIncidentDetector from edge.detectors.zone import ZoneIntrusionDetector +from edge.detectors.occupancy import OccupancyWatchdog, OccupancyConfig @dataclass @@ -14,14 +15,21 @@ class EngineConfig: cooldown_s: float = 30.0 +_OCCUPANCY_TYPES = {"lone_worker": EventType.LONE_WORKER, + "overcapacity": EventType.OVERCAPACITY, + "overstay": EventType.OVERSTAY} + + class EventEngine: def __init__(self, fall: FallDetector, immobility: ImmobilityWatchdog, ghost: GhostIncidentDetector, zone: ZoneIntrusionDetector, - cfg: EngineConfig): + cfg: EngineConfig, occupancy: OccupancyWatchdog | None = None): self.fall = fall self.immobility = immobility self.ghost = ghost self.zone = zone + # Keyword-with-default so the existing 5-arg constructors (runtime, tests) keep working. + self.occupancy = occupancy if occupancy is not None else OccupancyWatchdog(OccupancyConfig()) self.cfg = cfg self._last_emit: dict[tuple, float] = {} @@ -37,13 +45,15 @@ class EventEngine: self.fall.forget(track_id) self.immobility.forget(track_id) self.zone.forget(track_id) + self.occupancy.forget(track_id) self._last_emit = {k: v for k, v in self._last_emit.items() if not (len(k) >= 2 and k[1] == track_id)} def _mk(self, etype: EventType, occurred_at: float, track_id=None, zone_id=None, metrics=None, spatial=None) -> Event: m = metrics or {} - duration_s = m.get("immobility_s") or m.get("waited_s") or 0.0 + duration_s = m.get("immobility_s") or m.get("waited_s") or m.get("dwell_s") \ + or m.get("alone_s") or 0.0 return Event(etype, severity_for(etype), self.cfg.camera_id, track_id, zone_id, occurred_at, duration_s, m, spatial) @@ -86,6 +96,22 @@ class EventEngine: 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"])) + # 존 규칙(2인 1조 · 정원 · 체류): 위 per-track 루프에서 zone.update가 갱신한 멤버십을 + # 존별 인원으로 뒤집어 감시자에 넘긴다. stale 트랙은 run_once가 이미 걸러냈으므로 + # 여기 인원수는 이번 틱에 실제로 검출된 사람만 센다. + zone_tracks: dict[str, set[int]] = {} + for tr in active_tracks: + for zid in self.zone.active_zone_ids(tr.track_id): + zone_tracks.setdefault(zid, set()).add(tr.track_id) + for o in self.occupancy.update(self.zone.zones, zone_tracks, now): + etype = _OCCUPANCY_TYPES[o["kind"]] + # 쿨다운 키에 zone_id 포함: 존 A의 위반이 존 B의 발보를 막지 않는다. track_id가 + # None인 존 단위 이벤트의 키는 forget()이 지우지 못하지만, 존 수에 비례하는 + # 소량이라 누수로 보지 않는다 (ghost와 달리 키 공간이 유한). + if self._allow((etype, o["track_id"], o["zone_id"]), now): + events.append(self._mk(etype, occurred_at, track_id=o["track_id"], + zone_id=o["zone_id"], metrics=o["metrics"])) + for g in self.ghost.update(active_tracks, now): # No _allow cooldown for GHOST: ghost.update pops each incident from its candidates as it # emits, and track ids are never reused, so a given track fires GHOST at most once ever. diff --git a/edge/events.py b/edge/events.py index 4590323..2fd5e1a 100644 --- a/edge/events.py +++ b/edge/events.py @@ -13,6 +13,9 @@ class EventType(str, Enum): PINCH = "pinch" MONITORING_DEGRADED = "monitoring_degraded" DEVICE_FAULT = "device_fault" + LONE_WORKER = "lone_worker" # 2인 1조 존에 단독 작업 지속 + OVERCAPACITY = "overcapacity" # 존 정원 초과 지속 + OVERSTAY = "overstay" # 존 체류 상한 초과 (트랙별) class Severity(str, Enum): @@ -32,6 +35,10 @@ _SEVERITY = { EventType.ZONE_WARNING: Severity.WARNING, EventType.MONITORING_DEGRADED: Severity.WARNING, EventType.DEVICE_FAULT: Severity.WARNING, + # 단독 작업은 규정 위반이자 사고 시 구조 지연 요인 — 정원/체류보다 한 단계 높게. + EventType.LONE_WORKER: Severity.HIGH, + EventType.OVERCAPACITY: Severity.WARNING, + EventType.OVERSTAY: Severity.WARNING, } diff --git a/edge/zones.py b/edge/zones.py index 15bdc6c..dd7fa61 100644 --- a/edge/zones.py +++ b/edge/zones.py @@ -17,6 +17,30 @@ def _looks_like_pixels(polygon: list[tuple[float, float]]) -> bool: return any(abs(x) > _NORMALIZED_MAX or abs(y) > _NORMALIZED_MAX for x, y in polygon) +_RULE_KEYS = ("min_workers", "max_workers", "max_dwell_s") + + +def _rules_of(row: dict) -> dict | None: + """Optional occupancy rules from the zone row's rules_json. A malformed rules blob must + not kill the zone's INTRUSION detection (the polygon is still valid) — raise here and the + caller keeps the zone but logs the rules loudly as inactive.""" + raw = row.get("rules_json") + if raw in (None, "", "null"): + return None + data = json.loads(raw) if isinstance(raw, str) else raw + if not isinstance(data, dict): + raise ValueError(f"rules_json is not an object: {data!r}") + rules = {} + for key in _RULE_KEYS: + if data.get(key) is None: + continue + v = int(data[key]) + if v <= 0: + raise ValueError(f"{key} must be positive, got {v}") + rules[key] = v + return rules or None + + def zones_from_config(config: dict, camera_id: str | None = None) -> list[Zone]: """Parse server config zone rows into Zone objects. When camera_id is given, only rows for that camera are kept (multi-camera routing); None keeps all rows.""" @@ -40,7 +64,16 @@ def zones_from_config(config: dict, camera_id: str | None = None) -> list[Zone]: "the dashboard so it is stored normalized (0..1).", row.get("zone_id"), row.get("camera_id"), polygon[:2]) continue - out.append(Zone(str(row["zone_id"]), str(row["level"]), polygon)) + try: + rules = _rules_of(row) + except (ValueError, TypeError, json.JSONDecodeError) as e: + # Same "never fail dark" principle: the polygon is fine, so intrusion detection + # stays live — but the occupancy rules the operator THINKS are active are not. + log.warning("zone %s on camera %s has malformed rules_json (%s: %s) — its " + "occupancy rules are INACTIVE until fixed in the dashboard.", + row.get("zone_id"), row.get("camera_id"), type(e).__name__, e) + rules = None + out.append(Zone(str(row["zone_id"]), str(row["level"]), polygon, rules)) except (KeyError, ValueError, TypeError, IndexError, AttributeError, json.JSONDecodeError) as e: # Same principle as the pixel-space branch above: never fail dark. A row missing a key # (level/zone_id), with unparseable polygon_json, or degenerate points would otherwise be diff --git a/server/migrations/2026-08-06-zone-rules.sql b/server/migrations/2026-08-06-zone-rules.sql new file mode 100644 index 0000000..67cbb8d --- /dev/null +++ b/server/migrations/2026-08-06-zone-rules.sql @@ -0,0 +1,5 @@ +-- Zone occupancy rules (2인 1조 / 정원 / 체류 상한). +-- Nullable JSON blob validated app-side (ZoneConfigController::rulesJson); NULL = no rules. +-- Delivered to the edge unchanged via GET /api/config -> zones rows. +ALTER TABLE move_zones + ADD COLUMN rules_json JSON NULL AFTER polygon_json; diff --git a/server/public/assets/app.js b/server/public/assets/app.js index f5f32a9..718c4b2 100644 --- a/server/public/assets/app.js +++ b/server/public/assets/app.js @@ -15,6 +15,15 @@ var POLL_INTERVAL_MS = 5000; + // Event types arrive as wire codes ("fall") but the console reads in Korean. An unknown + // code (a newer edge than this dashboard) falls through as the raw code — never blank. + var TYPE_LABELS = { + fall: '넘어짐', no_motion: '넘어짐 후 미동 없음', immobility: '장시간 부동', + ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입', + pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상', + lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과' + }; + var SEVERITY_LABELS = { critical: { icon: '⛔', text: '위급', cls: 'sev-critical' }, // ⛔ high: { icon: '⚠', text: '높음', cls: 'sev-high' }, // ⚠ @@ -198,7 +207,7 @@ var typeLink = document.createElement('a'); typeLink.className = 'event-detail-link'; typeLink.href = B + '/events/' + encodeURIComponent(ev.event_id) + '/view'; - typeLink.textContent = String(ev.event_type || ''); + typeLink.textContent = TYPE_LABELS[ev.event_type] || String(ev.event_type || ''); type.appendChild(typeLink); // The one loud cue: an unacknowledged critical (a fall) gets a "확인 필요" chip — // secondary encoding alongside the red tint, so it never rides on color alone. diff --git a/server/public/assets/detail.js b/server/public/assets/detail.js index 1c68f80..165324c 100644 --- a/server/public/assets/detail.js +++ b/server/public/assets/detail.js @@ -21,6 +21,15 @@ info: { icon: 'ℹ', text: '정보', cls: 'sev-info' } }; + // Same wire-code -> Korean map as app.js (the two pages share no module system); + // an unknown code falls through as the raw code — never blank. + var TYPE_LABELS = { + fall: '넘어짐', no_motion: '넘어짐 후 미동 없음', immobility: '장시간 부동', + ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입', + pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상', + lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과' + }; + var meta = document.getElementById('event-detail-meta'); var snapBox = document.getElementById('event-detail-snapshots'); if (!meta) return; @@ -83,7 +92,7 @@ meta.removeAttribute('aria-busy'); addRow('심각도', buildBadge(ev.severity)); - addRow('유형', textNode(ev.event_type)); + addRow('유형', textNode(TYPE_LABELS[ev.event_type] || ev.event_type)); addRow('카메라', textNode(ev.camera_id)); addRow('발생 시각', textNode(formatTime(ev.occurred_at))); addRow('지속(초)', textNode(ev.duration_s)); diff --git a/server/public/assets/zone-draw.js b/server/public/assets/zone-draw.js index eed3bfe..880b6cc 100644 --- a/server/public/assets/zone-draw.js +++ b/server/public/assets/zone-draw.js @@ -33,6 +33,9 @@ var saveBtn = document.getElementById('zone-save-btn'); var listEl = document.getElementById('zone-list'); var statusEl = document.getElementById('zone-status'); + var ruleMinInput = document.getElementById('rule-min-workers'); + var ruleMaxInput = document.getElementById('rule-max-workers'); + var ruleDwellInput = document.getElementById('rule-max-dwell'); var LEVEL_STYLE = { warning: { stroke: '#eab308', label: '경고' }, @@ -106,6 +109,29 @@ drawPolygon(currentPoints, current.stroke, currentPoints.length ? '편집중' : '', false); } + // Occupancy rules the operator typed (dwell in MINUTES on screen, seconds on the wire). + // Only filled fields are sent; an empty form means "no rules" and stores NULL. + function rulesFromInputs() { + var rules = {}; + var minW = parseInt(ruleMinInput && ruleMinInput.value, 10); + var maxW = parseInt(ruleMaxInput && ruleMaxInput.value, 10); + var dwellMin = parseInt(ruleDwellInput && ruleDwellInput.value, 10); + if (minW > 0) rules.min_workers = minW; + if (maxW > 0) rules.max_workers = maxW; + if (dwellMin > 0) rules.max_dwell_s = dwellMin * 60; + return rules; + } + + function rulesLabel(z) { + var r; + try { r = JSON.parse(z.rules_json || 'null') || {}; } catch (e) { return ''; } + var parts = []; + if (r.min_workers) parts.push('최소 ' + r.min_workers + '인'); + if (r.max_workers) parts.push('최대 ' + r.max_workers + '인'); + if (r.max_dwell_s) parts.push('체류 ' + Math.round(r.max_dwell_s / 60) + '분'); + return parts.length ? ' · ' + parts.join(' · ') : ''; + } + function renderZoneList() { listEl.textContent = ''; existingZones.forEach(function (z) { @@ -113,7 +139,7 @@ var style = LEVEL_STYLE[z.level] || { label: z.level }; var label = document.createElement('span'); - label.textContent = style.label + ' (' + z.points.length + '점)'; + label.textContent = style.label + ' (' + z.points.length + '점)' + rulesLabel(z); li.appendChild(label); var del = document.createElement('button'); @@ -163,7 +189,7 @@ .then(function (data) { if (!data) return; existingZones = (data.zones || []).map(function (z) { - return { zone_id: z.zone_id, level: z.level, points: parsePolygon(z) }; + return { zone_id: z.zone_id, level: z.level, points: parsePolygon(z), rules_json: z.rules_json }; }); currentPoints = []; renderZoneList(); @@ -190,7 +216,8 @@ // camera's own resolution). See the parsePolygon contract note above. polygon: currentPoints.map(function (p) { return [p[0] / canvas.width, p[1] / canvas.height]; - }) + }), + rules: rulesFromInputs() }) }) .then(function (res) { diff --git a/server/schema.sql b/server/schema.sql index 4c6059c..085b23e 100644 --- a/server/schema.sql +++ b/server/schema.sql @@ -100,6 +100,7 @@ CREATE TABLE move_zones ( camera_id VARCHAR(64) NOT NULL, level VARCHAR(16) NOT NULL, -- app-whitelisted: warning|restricted|pinch (ZoneConfigController::LEVELS) polygon_json JSON NOT NULL, + rules_json JSON NULL, -- optional occupancy rules: {"min_workers","max_workers","max_dwell_s"} (ZoneConfigController validates) updated_at BIGINT UNSIGNED NOT NULL, PRIMARY KEY (zone_id), KEY ix_zones_camera (camera_id), diff --git a/server/src/Dashboard/ZoneConfigController.php b/server/src/Dashboard/ZoneConfigController.php index 740ba38..83bb20e 100644 --- a/server/src/Dashboard/ZoneConfigController.php +++ b/server/src/Dashboard/ZoneConfigController.php @@ -11,6 +11,13 @@ use App\Repository\{ZoneRepository, DeviceRepository, AuditRepository, SnapshotR final class ZoneConfigController { private const LEVELS = ['warning','restricted','pinch']; + // Occupancy-rule bounds. min_workers starts at 2 (a 1-person minimum is always satisfied + // by an occupied zone — meaningless); dwell floor 10s rejects a seconds/minutes mixup. + private const RULE_BOUNDS = [ + 'min_workers' => [2, 50], + 'max_workers' => [1, 50], + 'max_dwell_s' => [10, 86400], + ]; public function __construct( private ZoneRepository $zones, private DeviceRepository $devices, @@ -62,6 +69,33 @@ final class ZoneConfigController return Response::json(['uuid' => $shot['file_uuid']], 200); } + /** + * Validate the optional occupancy rules and return their canonical JSON (or null for + * "no rules"). Strict on purpose: an unknown key, a non-integer, an out-of-bounds value, + * or min > max is a CLIENT error — a rule silently dropped here would be one the operator + * believes is protecting a zone while the edge enforces nothing. + * @return array{0:?string,1:bool} [rules_json, ok] + */ + private static function rulesJson(mixed $rules): array + { + if ($rules === null || $rules === []) return [null, true]; + if (!is_array($rules)) return [null, false]; + $out = []; + foreach ($rules as $key => $value) { + if (!isset(self::RULE_BOUNDS[$key])) return [null, false]; + if ($value === null || $value === '') continue; + if (!is_int($value) && !(is_string($value) && ctype_digit($value))) return [null, false]; + $n = (int)$value; + [$lo, $hi] = self::RULE_BOUNDS[$key]; + if ($n < $lo || $n > $hi) return [null, false]; + $out[$key] = $n; + } + if (isset($out['min_workers'], $out['max_workers']) && $out['min_workers'] > $out['max_workers']) { + return [null, false]; + } + return [$out === [] ? null : json_encode($out), true]; + } + public function save(Request $req): Response { if ($g = $this->guard('edit_zones')) return $g; @@ -74,6 +108,8 @@ final class ZoneConfigController if (!in_array($level, self::LEVELS, true) || !is_array($polygon) || !Polygon::validate($polygon)) { return Response::json(['error' => 'invalid_zone'], 400); } + [$rulesJson, $rulesOk] = self::rulesJson($b['rules'] ?? null); + if (!$rulesOk) return Response::json(['error' => 'invalid_rules'], 400); $deviceId = (string)($b['device_id'] ?? ''); // The zone MUST name a real device: it is delivered to the edge only via // GET /api/config -> zones->forDevice($deviceId), so a zone saved with a blank or mistyped @@ -102,6 +138,7 @@ final class ZoneConfigController 'camera_id' => (string)($b['camera_id'] ?? ''), 'level' => $level, 'polygon_json' => json_encode($polygon), + 'rules_json' => $rulesJson, ]); } catch (ZoneOwnershipException $e) { // Editing a zone with a device_id/camera_id that does not own it (or a zone deleted diff --git a/server/src/Ingest/EventValidator.php b/server/src/Ingest/EventValidator.php index cc25ded..8e4c048 100644 --- a/server/src/Ingest/EventValidator.php +++ b/server/src/Ingest/EventValidator.php @@ -5,7 +5,8 @@ namespace App\Ingest; final class EventValidator { 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']; private const SEVERITIES = ['critical','high','warning','info']; private const REQUIRED = ['device_id','camera_id','event_type','severity','occurred_at']; diff --git a/server/src/Repository/Pdo/PdoZoneRepository.php b/server/src/Repository/Pdo/PdoZoneRepository.php index 05c48b5..41fb9e6 100644 --- a/server/src/Repository/Pdo/PdoZoneRepository.php +++ b/server/src/Repository/Pdo/PdoZoneRepository.php @@ -13,7 +13,7 @@ final class PdoZoneRepository implements ZoneRepository public function find(string $zoneId): ?array { $st = $this->pdo->prepare( - 'SELECT zone_id, device_id, camera_id, level, polygon_json FROM move_zones WHERE zone_id = ? LIMIT 1' + 'SELECT zone_id, device_id, camera_id, level, polygon_json, rules_json FROM move_zones WHERE zone_id = ? LIMIT 1' ); $st->execute([$zoneId]); $row = $st->fetch(); @@ -23,7 +23,7 @@ final class PdoZoneRepository implements ZoneRepository public function forDevice(string $deviceId): array { $st = $this->pdo->prepare( - 'SELECT zone_id, device_id, camera_id, level, polygon_json FROM move_zones WHERE device_id = ?' + 'SELECT zone_id, device_id, camera_id, level, polygon_json, rules_json FROM move_zones WHERE device_id = ?' ); $st->execute([$deviceId]); return $st->fetchAll(); @@ -32,7 +32,7 @@ final class PdoZoneRepository implements ZoneRepository public function forCamera(string $cameraId): array { $st = $this->pdo->prepare( - 'SELECT zone_id, device_id, camera_id, level, polygon_json FROM move_zones WHERE camera_id = ?' + 'SELECT zone_id, device_id, camera_id, level, polygon_json, rules_json FROM move_zones WHERE camera_id = ?' ); $st->execute([$cameraId]); return $st->fetchAll(); @@ -45,17 +45,19 @@ final class PdoZoneRepository implements ZoneRepository $cameraId = (string)($zone['camera_id'] ?? ''); $level = (string)($zone['level'] ?? ''); $polygonJson = (string)($zone['polygon_json'] ?? '[]'); + $rulesJson = isset($zone['rules_json']) && $zone['rules_json'] !== null + ? (string)$zone['rules_json'] : null; if (is_string($zoneId) && $zoneId !== '') { - return $this->updateOwned($zoneId, $deviceId, $cameraId, $level, $polygonJson); + return $this->updateOwned($zoneId, $deviceId, $cameraId, $level, $polygonJson, $rulesJson); } $newId = bin2hex(random_bytes(16)); $st = $this->pdo->prepare( - 'INSERT INTO move_zones (zone_id, device_id, camera_id, level, polygon_json, updated_at) - VALUES (?, ?, ?, ?, ?, ?)' + 'INSERT INTO move_zones (zone_id, device_id, camera_id, level, polygon_json, rules_json, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)' ); - $st->execute([$newId, $deviceId, $cameraId, $level, $polygonJson, time()]); + $st->execute([$newId, $deviceId, $cameraId, $level, $polygonJson, $rulesJson, time()]); return $newId; } @@ -87,6 +89,7 @@ final class PdoZoneRepository implements ZoneRepository string $cameraId, string $level, string $polygonJson, + ?string $rulesJson, ): string { $owns = $this->pdo->prepare( 'SELECT 1 FROM move_zones WHERE zone_id = ? AND device_id = ? AND camera_id = ? LIMIT 1' @@ -97,10 +100,10 @@ final class PdoZoneRepository implements ZoneRepository } $st = $this->pdo->prepare( - 'UPDATE move_zones SET level = ?, polygon_json = ?, updated_at = ? + 'UPDATE move_zones SET level = ?, polygon_json = ?, rules_json = ?, updated_at = ? WHERE zone_id = ? AND device_id = ? AND camera_id = ?' ); - $st->execute([$level, $polygonJson, time(), $zoneId, $deviceId, $cameraId]); + $st->execute([$level, $polygonJson, $rulesJson, time(), $zoneId, $deviceId, $cameraId]); if ($st->rowCount() === 0) { // Ambiguous on its own: MySQL's rowCount() reports "changed" rows, // not "matched" rows, so 0 here could mean either (a) a genuine diff --git a/server/src/View/zones.php b/server/src/View/zones.php index 9b949e2..03386f2 100644 --- a/server/src/View/zones.php +++ b/server/src/View/zones.php @@ -21,6 +21,14 @@ render_header('구역 설정'); +
+ + + + + + +

diff --git a/server/tests/EventValidatorTest.php b/server/tests/EventValidatorTest.php index 13aa62c..34ef0eb 100644 --- a/server/tests/EventValidatorTest.php +++ b/server/tests/EventValidatorTest.php @@ -48,6 +48,12 @@ final class EventValidatorTest extends TestCase $p = $this->valid(); $p['event_type'] = 'explode'; $this->assertFalse(EventValidator::validate($p)['ok']); } + public function testAcceptsOccupancyRuleEventTypes(): void { + foreach (['lone_worker', 'overcapacity', 'overstay'] as $type) { + $p = $this->valid(); $p['event_type'] = $type; + $this->assertTrue(EventValidator::validate($p)['ok'], "$type must be a known type"); + } + } public function testRejectsUnknownSeverity(): void { $p = $this->valid(); $p['severity'] = 'ULTRA'; $this->assertFalse(EventValidator::validate($p)['ok']); diff --git a/server/tests/PdoIntegrationTest.php b/server/tests/PdoIntegrationTest.php index bcec642..8dd1c20 100644 --- a/server/tests/PdoIntegrationTest.php +++ b/server/tests/PdoIntegrationTest.php @@ -177,6 +177,7 @@ final class PdoIntegrationTest extends TestCase camera_id TEXT NOT NULL, level TEXT NOT NULL, polygon_json TEXT NOT NULL, + rules_json TEXT, updated_at INTEGER NOT NULL ); SQL; diff --git a/server/tests/ZoneConfigTest.php b/server/tests/ZoneConfigTest.php index c54cb8d..4067f58 100644 --- a/server/tests/ZoneConfigTest.php +++ b/server/tests/ZoneConfigTest.php @@ -222,4 +222,49 @@ final class ZoneConfigTest extends TestCase $this->authed('operator', 'factory-b'), $snaps); $this->assertSame(200, $theirs->latestSnapshot($this->snapReq(), ['camera' => 'cam-b'])->status()); } + + public function testSavePersistsValidOccupancyRules(): void { + $repo = new InMemoryZoneRepository(); + $c = new ZoneConfigController($repo, $this->devices(), new InMemoryAuditRepository(), $this->authed('operator'), new InMemorySnapshotRepository()); + $res = $c->save($this->saveReq(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'level' => 'restricted', + 'polygon' => [[0,0],[9,0],[9,9]], + 'rules' => ['min_workers' => 2, 'max_workers' => 4, 'max_dwell_s' => 600]])); + $this->assertSame(201, $res->status()); + $row = $repo->forCamera('cam-1')[0]; + $this->assertSame(['min_workers' => 2, 'max_workers' => 4, 'max_dwell_s' => 600], + json_decode($row['rules_json'], true)); + } + + public function testSaveWithoutRulesStoresNull(): void { + $repo = new InMemoryZoneRepository(); + $c = new ZoneConfigController($repo, $this->devices(), new InMemoryAuditRepository(), $this->authed('operator'), new InMemorySnapshotRepository()); + $res = $c->save($this->saveReq(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'level' => 'restricted', + 'polygon' => [[0,0],[9,0],[9,9]], 'rules' => []])); + $this->assertSame(201, $res->status()); + $this->assertNull($repo->forCamera('cam-1')[0]['rules_json']); + } + + public function testSaveRejectsInvalidRules(): void { + // A rule silently dropped would be one the operator believes protects the zone while + // the edge enforces nothing — every malformed variant must be a hard 400. + $bad = [ + ['min_workers' => 1], // 1인 최소는 무의미 (하한 2) + ['min_workers' => 0], + ['max_workers' => 99], // 상한 50 초과 + ['max_dwell_s' => 5], // 초/분 착오 방지 하한 10s + ['max_dwell_s' => 999999], + ['min_workers' => 5, 'max_workers' => 2], // min > max 모순 + ['unknown_rule' => 3], + ['min_workers' => 'abc'], + 'not-an-object', + ]; + foreach ($bad as $rules) { + $repo = new InMemoryZoneRepository(); + $c = new ZoneConfigController($repo, $this->devices(), new InMemoryAuditRepository(), $this->authed('operator'), new InMemorySnapshotRepository()); + $res = $c->save($this->saveReq(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'level' => 'restricted', + 'polygon' => [[0,0],[9,0],[9,9]], 'rules' => $rules])); + $this->assertSame(400, $res->status(), 'rules should be rejected: ' . json_encode($rules)); + $this->assertCount(0, $repo->forCamera('cam-1')); + } + } } diff --git a/tests/test_engine.py b/tests/test_engine.py index 913111d..9a05276 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -190,6 +190,32 @@ def test_process_defaults_occurred_at_to_now_when_no_wall_clock(): assert len(fall) == 1 and fall[0].occurred_at == 0.6 +def test_lone_worker_rule_fires_through_engine(): + # End-to-end wiring of the occupancy rules: a 2인 1조 zone with one standing worker inside + # past the confirm window must emit LONE_WORKER/HIGH with the zone id attached. + from edge.detectors.occupancy import OccupancyWatchdog, OccupancyConfig + poly = [(400, 100), (600, 100), (600, 300), (400, 300)] + zdet = ZoneIntrusionDetector([Zone("z1", "restricted", poly, {"min_workers": 2})], + ZoneConfig(debounce_s=0.0)) + eng = EventEngine( + FallDetector(FallConfig(prone_confirm_s=999)), + ImmobilityWatchdog(ImmobilityConfig(immobile_s=999)), + GhostIncidentDetector(GhostConfig()), + zdet, EngineConfig(camera_id="cam-01", cooldown_s=30.0), + occupancy=OccupancyWatchdog(OccupancyConfig(lone_confirm_s=5.0))) + tr = Track(1, deque(maxlen=90)) + _standing_track(tr, 0.0, cx=500) # ankles at (490,200)/(510,200) — inside the zone + events = eng.process([tr], [], now=0.0) + assert not any(e.event_type == EventType.LONE_WORKER for e in events) + _standing_track(tr, 6.0, cx=500) + events = eng.process([tr], [], now=6.0) + lone = [e for e in events if e.event_type == EventType.LONE_WORKER] + assert len(lone) == 1 + assert lone[0].severity == Severity.HIGH + assert lone[0].zone_id == "z1" and lone[0].track_id == 1 + assert lone[0].metrics["required"] == 2 + + def test_allow_cooldown_primitive(): eng = _engine() # EngineConfig(camera_id="cam-01", cooldown_s=30.0) key = ("FALL", 1) diff --git a/tests/test_occupancy.py b/tests/test_occupancy.py new file mode 100644 index 0000000..2533c70 --- /dev/null +++ b/tests/test_occupancy.py @@ -0,0 +1,114 @@ +from edge.detectors.zone import Zone +from edge.detectors.occupancy import OccupancyWatchdog, OccupancyConfig + +_POLY = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)] + + +def _zone(zone_id="z1", **rules): + return Zone(zone_id, "restricted", _POLY, rules or None) + + +def _watch(**cfg): + return OccupancyWatchdog(OccupancyConfig(**cfg)) + + +def test_no_rules_emits_nothing(): + w = _watch() + zones = [Zone("z1", "restricted", _POLY)] + assert w.update(zones, {"z1": {1}}, now=0.0) == [] + assert w.update(zones, {"z1": {1}}, now=999.0) == [] + + +def test_lone_worker_fires_after_sustained_solo(): + w = _watch(lone_confirm_s=30.0) + zones = [_zone(min_workers=2)] + assert w.update(zones, {"z1": {7}}, now=0.0) == [] # 방금 혼자가 됨 + assert w.update(zones, {"z1": {7}}, now=29.0) == [] # 아직 유예 + out = w.update(zones, {"z1": {7}}, now=31.0) + assert out == [{"kind": "lone_worker", "zone_id": "z1", "track_id": 7, + "metrics": {"workers": 1, "required": 2, "alone_s": 31.0}}] + # 조건이 계속돼도 재발보 없음 (해소 후 재충족으로만) + assert w.update(zones, {"z1": {7}}, now=60.0) == [] + + +def test_colleague_return_resets_lone_timer(): + w = _watch(lone_confirm_s=30.0) + zones = [_zone(min_workers=2)] + w.update(zones, {"z1": {7}}, now=0.0) + w.update(zones, {"z1": {7, 8}}, now=20.0) # 동료 복귀 → 리셋 + assert w.update(zones, {"z1": {7}}, now=40.0) == [] # 새로 30초 필요 + assert len(w.update(zones, {"z1": {7}}, now=71.0)) == 1 + + +def test_empty_zone_is_not_a_lone_violation(): + w = _watch(lone_confirm_s=0.0) + zones = [_zone(min_workers=2)] + assert w.update(zones, {}, now=0.0) == [] + assert w.update(zones, {}, now=100.0) == [] + + +def test_overcapacity_fires_after_sustained_excess(): + w = _watch(over_confirm_s=10.0) + zones = [_zone(max_workers=2)] + assert w.update(zones, {"z1": {1, 2, 3}}, now=0.0) == [] + out = w.update(zones, {"z1": {1, 2, 3}}, now=11.0) + assert out == [{"kind": "overcapacity", "zone_id": "z1", "track_id": None, + "metrics": {"workers": 3, "limit": 2}}] + # 해소 후 재충족이면 다시 발보 + w.update(zones, {"z1": {1, 2}}, now=20.0) + w.update(zones, {"z1": {1, 2, 3}}, now=30.0) + assert len(w.update(zones, {"z1": {1, 2, 3}}, now=41.0)) == 1 + + +def test_overstay_fires_per_track(): + w = _watch() + zones = [_zone(max_dwell_s=60)] + w.update(zones, {"z1": {1}}, now=0.0) + w.update(zones, {"z1": {1, 2}}, now=30.0) # 2번은 30초에 진입 + out = w.update(zones, {"z1": {1, 2}}, now=61.0) # 1번만 60초 초과 + assert [o["track_id"] for o in out] == [1] + assert out[0]["kind"] == "overstay" + out = w.update(zones, {"z1": {1, 2}}, now=91.0) # 이제 2번도 초과 + assert [o["track_id"] for o in out] == [2] + + +def test_dwell_survives_short_detection_dropout(): + # run_once의 stale 필터로 1~2틱 미검출이 흔하다. grace 안의 부재는 체류 연속으로 + # 간주해야 10분 규칙이 실사용에서 무력화되지 않는다. + w = _watch(dwell_absence_grace_s=5.0) + zones = [_zone(max_dwell_s=60)] + w.update(zones, {"z1": {1}}, now=0.0) + w.update(zones, {}, now=30.0) # 드롭아웃 (grace보다 김? 아님: 아래 재검출로 판단) + out = w.update(zones, {"z1": {1}}, now=61.0) + # 30.0의 부재는 last_seen(0.0)에서 5초 grace를 초과 → 새 체류로 리셋되었다. + assert out == [] + # 연속 체류가 실제로 60초를 넘기면 발보 + w.update(zones, {"z1": {1}}, now=90.0) + w.update(zones, {"z1": {1}}, now=120.0) + assert len(w.update(zones, {"z1": {1}}, now=122.0)) == 1 + + +def test_dwell_short_gap_within_grace_keeps_timer(): + w = _watch(dwell_absence_grace_s=5.0) + zones = [_zone(max_dwell_s=60)] + w.update(zones, {"z1": {1}}, now=0.0) + w.update(zones, {}, now=2.0) # 2초 드롭아웃 — grace 이내 + out = w.update(zones, {"z1": {1}}, now=61.0) + assert len(out) == 1 and out[0]["metrics"]["dwell_s"] == 61.0 + + +def test_forget_clears_dwell_state(): + w = _watch() + zones = [_zone(max_dwell_s=60)] + w.update(zones, {"z1": {1}}, now=0.0) + w.forget(1) + assert w.update(zones, {"z1": {1}}, now=61.0) == [] # 새 체류로 시작 + + +def test_deleted_zone_state_is_pruned(): + w = _watch(lone_confirm_s=0.0, over_confirm_s=0.0) + zones = [_zone(min_workers=2, max_workers=1)] + w.update(zones, {"z1": {1}}, now=0.0) + w.update([], {}, now=1.0) # 존 삭제 (config 동기) + assert w._lone_since == {} and w._over_since == {} + assert w._lone_fired == set() and w._over_fired == set() diff --git a/tests/test_zones.py b/tests/test_zones.py index f9578db..a534501 100644 --- a/tests/test_zones.py +++ b/tests/test_zones.py @@ -56,3 +56,30 @@ def test_rejects_legacy_pixel_space_polygon_and_warns(caplog): zones = zones_from_config(cfg) assert [z.zone_id for z in zones] == ["modern"] # legacy dropped, modern kept assert any("legacy" in r.message and "pixel" in r.message for r in caplog.records) + + +def test_parses_occupancy_rules(): + cfg = {"zones": [ + {"zone_id": "z1", "camera_id": "c", "level": "restricted", + "polygon_json": "[[0,0],[0.9,0],[0.9,0.9]]", + "rules_json": '{"min_workers": 2, "max_workers": 4, "max_dwell_s": 600}'}, + {"zone_id": "z2", "camera_id": "c", "level": "warning", + "polygon_json": "[[0,0],[0.9,0],[0.9,0.9]]"}, + ]} + zones = zones_from_config(cfg) + assert zones[0].rules == {"min_workers": 2, "max_workers": 4, "max_dwell_s": 600} + assert zones[1].rules is None + + +def test_malformed_rules_keep_zone_but_warn(caplog): + # A broken rules blob must not kill the zone's INTRUSION detection (the polygon is + # valid) -- keep the zone, drop the rules, and say so loudly. + cfg = {"zones": [ + {"zone_id": "z1", "camera_id": "c", "level": "restricted", + "polygon_json": "[[0,0],[0.9,0],[0.9,0.9]]", + "rules_json": '{"min_workers": -1}'}, + ]} + with caplog.at_level(logging.WARNING): + zones = zones_from_config(cfg) + assert len(zones) == 1 and zones[0].rules is None + assert any("rules_json" in r.message for r in caplog.records)