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.
→ 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 실렌더(편집기 저장·목록·콘솔 라벨).

View file

@ -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/ (납품)

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

View file

@ -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.

View file

@ -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,
}

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)
_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

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;
// 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.

View file

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

View file

@ -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) {

View file

@ -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),

View file

@ -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

View file

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

View file

@ -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

View file

@ -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>

View file

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

View file

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

View file

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

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
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
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)
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)