feat(zones): occupancy rule engine - lone-worker / capacity / dwell limits
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).
This commit is contained in:
parent
01d36d0a1c
commit
a203c42bf3
22 changed files with 508 additions and 20 deletions
|
|
@ -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 실렌더(편집기 저장·목록·콘솔 라벨).
|
||||
|
|
|
|||
|
|
@ -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/ (납품)
|
||||
|
|
|
|||
95
edge/detectors/occupancy.py
Normal file
95
edge/detectors/occupancy.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
5
server/migrations/2026-08-06-zone-rules.sql
Normal file
5
server/migrations/2026-08-06-zone-rules.sql
Normal file
|
|
@ -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;
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -21,6 +21,14 @@ render_header('구역 설정');
|
|||
<button type="button" id="zone-clear-btn">현재 폴리곤 지우기</button>
|
||||
<button type="button" id="zone-save-btn">저장</button>
|
||||
</div>
|
||||
<div class="zone-controls">
|
||||
<label for="rule-min-workers">최소 인원 (2인 1조)</label>
|
||||
<input type="number" id="rule-min-workers" min="2" max="50" placeholder="없음">
|
||||
<label for="rule-max-workers">최대 인원</label>
|
||||
<input type="number" id="rule-max-workers" min="1" max="50" placeholder="없음">
|
||||
<label for="rule-max-dwell">체류 상한 (분)</label>
|
||||
<input type="number" id="rule-max-dwell" min="1" max="1440" placeholder="없음">
|
||||
</div>
|
||||
<canvas id="zone-canvas" width="960" height="540" tabindex="0" aria-label="구역 편집 캔버스: 클릭하여 폴리곤 점을 추가합니다"></canvas>
|
||||
<ul id="zone-list" aria-live="polite"></ul>
|
||||
<p id="zone-status" role="status"></p>
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
114
tests/test_occupancy.py
Normal file
114
tests/test_occupancy.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue