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:
유창욱 2026-08-06 19:33:12 +09:00
parent 01d36d0a1c
commit a203c42bf3
22 changed files with 508 additions and 20 deletions

View file

@ -6,3 +6,6 @@
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** 존 점유 규칙 엔진 (2인1조 `lone_worker`/HIGH · 정원 `overcapacity` ·
체류상한 `overstay`): edge `occupancy.py` + `move_zones.rules_json` + 존 편집기 규칙 입력 +
이벤트 유형 한글 라벨. pytest 216/PHPUnit 123 통과 + Chrome 실렌더(편집기 저장·목록·콘솔 라벨).

View file

@ -16,7 +16,7 @@
| `app.py` | `run_once` 파이프라인 (프레임→추론→추적→감지→스냅샷→전송). ambiguous 리셋·stale 필터 | | `app.py` | `run_once` 파이프라인 (프레임→추론→추적→감지→스냅샷→전송). ambiguous 리셋·stale 필터 |
| `engine.py` | `EventEngine` — 감지기 오케스트레이션, 쿨다운 dedup, 트랙 forget | | `engine.py` | `EventEngine` — 감지기 오케스트레이션, 쿨다운 dedup, 트랙 forget |
| `tracking.py` | `IouTracker` — 전역 그리디 IoU 매칭, `ambiguous()` (교차 경합 트랙) | | `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` | 트랙별 키포인트 스무딩 풀 | | `smoothing.py` | 트랙별 키포인트 스무딩 풀 |
| `snapshot.py` | RingBuffer · FaceBlurrer(PIPA) · Snapshotter | | `snapshot.py` | RingBuffer · FaceBlurrer(PIPA) · Snapshotter |
| `alert.py` / `serverclient.py` | HMAC+nonce POST, 커스텀 UA, no-redirect | | `alert.py` / `serverclient.py` | HMAC+nonce POST, 커스텀 UA, no-redirect |
@ -36,7 +36,9 @@
`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`(장치→카메라 트리 레일) ·
`zone-draw.js` · `detail.js` · `app.css` `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` - 배포: `server/deploy/build_cafe24_package.sh` → zip, `server/docs/server-deploy.md`
## deploy/ · dist/ (납품) ## deploy/ · dist/ (납품)

View 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

View file

@ -25,6 +25,9 @@ class Zone:
zone_id: str zone_id: str
level: str # "warning" | "restricted" level: str # "warning" | "restricted"
polygon: list # list[tuple[float,float]] polygon: list # list[tuple[float,float]]
# 선택적 존 규칙 (occupancy.py가 소비): {"min_workers": 2, "max_workers": 4,
# "max_dwell_s": 600} — 키가 없으면 그 규칙은 꺼진 것. None = 규칙 없음.
rules: dict | None = None
@dataclass @dataclass

View file

@ -6,6 +6,7 @@ from edge.detectors.fall import FallDetector
from edge.detectors.immobility import ImmobilityWatchdog from edge.detectors.immobility import ImmobilityWatchdog
from edge.detectors.ghost import GhostIncidentDetector from edge.detectors.ghost import GhostIncidentDetector
from edge.detectors.zone import ZoneIntrusionDetector from edge.detectors.zone import ZoneIntrusionDetector
from edge.detectors.occupancy import OccupancyWatchdog, OccupancyConfig
@dataclass @dataclass
@ -14,14 +15,21 @@ class EngineConfig:
cooldown_s: float = 30.0 cooldown_s: float = 30.0
_OCCUPANCY_TYPES = {"lone_worker": EventType.LONE_WORKER,
"overcapacity": EventType.OVERCAPACITY,
"overstay": EventType.OVERSTAY}
class EventEngine: class EventEngine:
def __init__(self, fall: FallDetector, immobility: ImmobilityWatchdog, def __init__(self, fall: FallDetector, immobility: ImmobilityWatchdog,
ghost: GhostIncidentDetector, zone: ZoneIntrusionDetector, ghost: GhostIncidentDetector, zone: ZoneIntrusionDetector,
cfg: EngineConfig): cfg: EngineConfig, occupancy: OccupancyWatchdog | None = None):
self.fall = fall self.fall = fall
self.immobility = immobility self.immobility = immobility
self.ghost = ghost self.ghost = ghost
self.zone = zone 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.cfg = cfg
self._last_emit: dict[tuple, float] = {} self._last_emit: dict[tuple, float] = {}
@ -37,13 +45,15 @@ class EventEngine:
self.fall.forget(track_id) self.fall.forget(track_id)
self.immobility.forget(track_id) self.immobility.forget(track_id)
self.zone.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() self._last_emit = {k: v for k, v in self._last_emit.items()
if not (len(k) >= 2 and k[1] == track_id)} if not (len(k) >= 2 and k[1] == track_id)}
def _mk(self, etype: EventType, occurred_at: float, track_id=None, zone_id=None, def _mk(self, etype: EventType, occurred_at: float, track_id=None, zone_id=None,
metrics=None, spatial=None) -> Event: metrics=None, spatial=None) -> Event:
m = metrics or {} 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, return Event(etype, severity_for(etype), self.cfg.camera_id, track_id,
zone_id, occurred_at, duration_s, m, spatial) 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): 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"]))
# 존 규칙(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): for g in self.ghost.update(active_tracks, now):
# No _allow cooldown for GHOST: ghost.update pops each incident from its candidates as it # 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. # emits, and track ids are never reused, so a given track fires GHOST at most once ever.

View file

@ -13,6 +13,9 @@ class EventType(str, Enum):
PINCH = "pinch" PINCH = "pinch"
MONITORING_DEGRADED = "monitoring_degraded" MONITORING_DEGRADED = "monitoring_degraded"
DEVICE_FAULT = "device_fault" DEVICE_FAULT = "device_fault"
LONE_WORKER = "lone_worker" # 2인 1조 존에 단독 작업 지속
OVERCAPACITY = "overcapacity" # 존 정원 초과 지속
OVERSTAY = "overstay" # 존 체류 상한 초과 (트랙별)
class Severity(str, Enum): class Severity(str, Enum):
@ -32,6 +35,10 @@ _SEVERITY = {
EventType.ZONE_WARNING: Severity.WARNING, EventType.ZONE_WARNING: Severity.WARNING,
EventType.MONITORING_DEGRADED: Severity.WARNING, EventType.MONITORING_DEGRADED: Severity.WARNING,
EventType.DEVICE_FAULT: Severity.WARNING, EventType.DEVICE_FAULT: Severity.WARNING,
# 단독 작업은 규정 위반이자 사고 시 구조 지연 요인 — 정원/체류보다 한 단계 높게.
EventType.LONE_WORKER: Severity.HIGH,
EventType.OVERCAPACITY: Severity.WARNING,
EventType.OVERSTAY: Severity.WARNING,
} }

View file

@ -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) 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]: 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 """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.""" 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).", "the dashboard so it is stored normalized (0..1).",
row.get("zone_id"), row.get("camera_id"), polygon[:2]) row.get("zone_id"), row.get("camera_id"), polygon[:2])
continue 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: 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 # 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 # (level/zone_id), with unparseable polygon_json, or degenerate points would otherwise be

View 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;

View file

@ -15,6 +15,15 @@
var POLL_INTERVAL_MS = 5000; 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 = { var SEVERITY_LABELS = {
critical: { icon: '⛔', text: '위급', cls: 'sev-critical' }, // ⛔ critical: { icon: '⛔', text: '위급', cls: 'sev-critical' }, // ⛔
high: { icon: '⚠', text: '높음', cls: 'sev-high' }, // ⚠ high: { icon: '⚠', text: '높음', cls: 'sev-high' }, // ⚠
@ -198,7 +207,7 @@
var typeLink = document.createElement('a'); var typeLink = document.createElement('a');
typeLink.className = 'event-detail-link'; typeLink.className = 'event-detail-link';
typeLink.href = B + '/events/' + encodeURIComponent(ev.event_id) + '/view'; 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); type.appendChild(typeLink);
// The one loud cue: an unacknowledged critical (a fall) gets a "확인 필요" chip — // 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. // secondary encoding alongside the red tint, so it never rides on color alone.

View file

@ -21,6 +21,15 @@
info: { icon: 'ℹ', text: '정보', cls: 'sev-info' } 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 meta = document.getElementById('event-detail-meta');
var snapBox = document.getElementById('event-detail-snapshots'); var snapBox = document.getElementById('event-detail-snapshots');
if (!meta) return; if (!meta) return;
@ -83,7 +92,7 @@
meta.removeAttribute('aria-busy'); meta.removeAttribute('aria-busy');
addRow('심각도', buildBadge(ev.severity)); 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(ev.camera_id));
addRow('발생 시각', textNode(formatTime(ev.occurred_at))); addRow('발생 시각', textNode(formatTime(ev.occurred_at)));
addRow('지속(초)', textNode(ev.duration_s)); addRow('지속(초)', textNode(ev.duration_s));

View file

@ -33,6 +33,9 @@
var saveBtn = document.getElementById('zone-save-btn'); var saveBtn = document.getElementById('zone-save-btn');
var listEl = document.getElementById('zone-list'); var listEl = document.getElementById('zone-list');
var statusEl = document.getElementById('zone-status'); 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 = { var LEVEL_STYLE = {
warning: { stroke: '#eab308', label: '경고' }, warning: { stroke: '#eab308', label: '경고' },
@ -106,6 +109,29 @@
drawPolygon(currentPoints, current.stroke, currentPoints.length ? '편집중' : '', false); 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() { function renderZoneList() {
listEl.textContent = ''; listEl.textContent = '';
existingZones.forEach(function (z) { existingZones.forEach(function (z) {
@ -113,7 +139,7 @@
var style = LEVEL_STYLE[z.level] || { label: z.level }; var style = LEVEL_STYLE[z.level] || { label: z.level };
var label = document.createElement('span'); var label = document.createElement('span');
label.textContent = style.label + ' (' + z.points.length + '점)'; label.textContent = style.label + ' (' + z.points.length + '점)' + rulesLabel(z);
li.appendChild(label); li.appendChild(label);
var del = document.createElement('button'); var del = document.createElement('button');
@ -163,7 +189,7 @@
.then(function (data) { .then(function (data) {
if (!data) return; if (!data) return;
existingZones = (data.zones || []).map(function (z) { 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 = []; currentPoints = [];
renderZoneList(); renderZoneList();
@ -190,7 +216,8 @@
// camera's own resolution). See the parsePolygon contract note above. // camera's own resolution). See the parsePolygon contract note above.
polygon: currentPoints.map(function (p) { polygon: currentPoints.map(function (p) {
return [p[0] / canvas.width, p[1] / canvas.height]; return [p[0] / canvas.width, p[1] / canvas.height];
}) }),
rules: rulesFromInputs()
}) })
}) })
.then(function (res) { .then(function (res) {

View file

@ -100,6 +100,7 @@ CREATE TABLE move_zones (
camera_id VARCHAR(64) NOT NULL, camera_id VARCHAR(64) NOT NULL,
level VARCHAR(16) NOT NULL, -- app-whitelisted: warning|restricted|pinch (ZoneConfigController::LEVELS) level VARCHAR(16) NOT NULL, -- app-whitelisted: warning|restricted|pinch (ZoneConfigController::LEVELS)
polygon_json JSON NOT NULL, 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, updated_at BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (zone_id), PRIMARY KEY (zone_id),
KEY ix_zones_camera (camera_id), KEY ix_zones_camera (camera_id),

View file

@ -11,6 +11,13 @@ use App\Repository\{ZoneRepository, DeviceRepository, AuditRepository, SnapshotR
final class ZoneConfigController final class ZoneConfigController
{ {
private const LEVELS = ['warning','restricted','pinch']; 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( public function __construct(
private ZoneRepository $zones, private DeviceRepository $devices, private ZoneRepository $zones, private DeviceRepository $devices,
@ -62,6 +69,33 @@ final class ZoneConfigController
return Response::json(['uuid' => $shot['file_uuid']], 200); 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 public function save(Request $req): Response
{ {
if ($g = $this->guard('edit_zones')) return $g; 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)) { if (!in_array($level, self::LEVELS, true) || !is_array($polygon) || !Polygon::validate($polygon)) {
return Response::json(['error' => 'invalid_zone'], 400); 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'] ?? ''); $deviceId = (string)($b['device_id'] ?? '');
// The zone MUST name a real device: it is delivered to the edge only via // 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 // 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'] ?? ''), 'camera_id' => (string)($b['camera_id'] ?? ''),
'level' => $level, 'level' => $level,
'polygon_json' => json_encode($polygon), 'polygon_json' => json_encode($polygon),
'rules_json' => $rulesJson,
]); ]);
} catch (ZoneOwnershipException $e) { } catch (ZoneOwnershipException $e) {
// Editing a zone with a device_id/camera_id that does not own it (or a zone deleted // Editing a zone with a device_id/camera_id that does not own it (or a zone deleted

View file

@ -5,7 +5,8 @@ namespace App\Ingest;
final class EventValidator 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'];
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'];

View file

@ -13,7 +13,7 @@ final class PdoZoneRepository implements ZoneRepository
public function find(string $zoneId): ?array public function find(string $zoneId): ?array
{ {
$st = $this->pdo->prepare( $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]); $st->execute([$zoneId]);
$row = $st->fetch(); $row = $st->fetch();
@ -23,7 +23,7 @@ final class PdoZoneRepository implements ZoneRepository
public function forDevice(string $deviceId): array public function forDevice(string $deviceId): array
{ {
$st = $this->pdo->prepare( $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]); $st->execute([$deviceId]);
return $st->fetchAll(); return $st->fetchAll();
@ -32,7 +32,7 @@ final class PdoZoneRepository implements ZoneRepository
public function forCamera(string $cameraId): array public function forCamera(string $cameraId): array
{ {
$st = $this->pdo->prepare( $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]); $st->execute([$cameraId]);
return $st->fetchAll(); return $st->fetchAll();
@ -45,17 +45,19 @@ final class PdoZoneRepository implements ZoneRepository
$cameraId = (string)($zone['camera_id'] ?? ''); $cameraId = (string)($zone['camera_id'] ?? '');
$level = (string)($zone['level'] ?? ''); $level = (string)($zone['level'] ?? '');
$polygonJson = (string)($zone['polygon_json'] ?? '[]'); $polygonJson = (string)($zone['polygon_json'] ?? '[]');
$rulesJson = isset($zone['rules_json']) && $zone['rules_json'] !== null
? (string)$zone['rules_json'] : null;
if (is_string($zoneId) && $zoneId !== '') { 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)); $newId = bin2hex(random_bytes(16));
$st = $this->pdo->prepare( $st = $this->pdo->prepare(
'INSERT INTO move_zones (zone_id, device_id, camera_id, level, polygon_json, updated_at) 'INSERT INTO move_zones (zone_id, device_id, camera_id, level, polygon_json, rules_json, updated_at)
VALUES (?, ?, ?, ?, ?, ?)' VALUES (?, ?, ?, ?, ?, ?, ?)'
); );
$st->execute([$newId, $deviceId, $cameraId, $level, $polygonJson, time()]); $st->execute([$newId, $deviceId, $cameraId, $level, $polygonJson, $rulesJson, time()]);
return $newId; return $newId;
} }
@ -87,6 +89,7 @@ final class PdoZoneRepository implements ZoneRepository
string $cameraId, string $cameraId,
string $level, string $level,
string $polygonJson, string $polygonJson,
?string $rulesJson,
): string { ): string {
$owns = $this->pdo->prepare( $owns = $this->pdo->prepare(
'SELECT 1 FROM move_zones WHERE zone_id = ? AND device_id = ? AND camera_id = ? LIMIT 1' '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( $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 = ?' 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) { if ($st->rowCount() === 0) {
// Ambiguous on its own: MySQL's rowCount() reports "changed" rows, // Ambiguous on its own: MySQL's rowCount() reports "changed" rows,
// not "matched" rows, so 0 here could mean either (a) a genuine // not "matched" rows, so 0 here could mean either (a) a genuine

View file

@ -21,6 +21,14 @@ render_header('구역 설정');
<button type="button" id="zone-clear-btn">현재 폴리곤 지우기</button> <button type="button" id="zone-clear-btn">현재 폴리곤 지우기</button>
<button type="button" id="zone-save-btn">저장</button> <button type="button" id="zone-save-btn">저장</button>
</div> </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> <canvas id="zone-canvas" width="960" height="540" tabindex="0" aria-label="구역 편집 캔버스: 클릭하여 폴리곤 점을 추가합니다"></canvas>
<ul id="zone-list" aria-live="polite"></ul> <ul id="zone-list" aria-live="polite"></ul>
<p id="zone-status" role="status"></p> <p id="zone-status" role="status"></p>

View file

@ -48,6 +48,12 @@ final class EventValidatorTest extends TestCase
$p = $this->valid(); $p['event_type'] = 'explode'; $p = $this->valid(); $p['event_type'] = 'explode';
$this->assertFalse(EventValidator::validate($p)['ok']); $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 { public function testRejectsUnknownSeverity(): void {
$p = $this->valid(); $p['severity'] = 'ULTRA'; $p = $this->valid(); $p['severity'] = 'ULTRA';
$this->assertFalse(EventValidator::validate($p)['ok']); $this->assertFalse(EventValidator::validate($p)['ok']);

View file

@ -177,6 +177,7 @@ final class PdoIntegrationTest extends TestCase
camera_id TEXT NOT NULL, camera_id TEXT NOT NULL,
level TEXT NOT NULL, level TEXT NOT NULL,
polygon_json TEXT NOT NULL, polygon_json TEXT NOT NULL,
rules_json TEXT,
updated_at INTEGER NOT NULL updated_at INTEGER NOT NULL
); );
SQL; SQL;

View file

@ -222,4 +222,49 @@ final class ZoneConfigTest extends TestCase
$this->authed('operator', 'factory-b'), $snaps); $this->authed('operator', 'factory-b'), $snaps);
$this->assertSame(200, $theirs->latestSnapshot($this->snapReq(), ['camera' => 'cam-b'])->status()); $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'));
}
}
} }

View file

@ -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 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(): 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)

114
tests/test_occupancy.py Normal file
View 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()

View file

@ -56,3 +56,30 @@ def test_rejects_legacy_pixel_space_polygon_and_warns(caplog):
zones = zones_from_config(cfg) zones = zones_from_config(cfg)
assert [z.zone_id for z in zones] == ["modern"] # legacy dropped, modern kept 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) 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)