move_detect/edge/zones.py
유창욱 a203c42bf3 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).
2026-08-06 19:33:12 +09:00

91 lines
4.4 KiB
Python

import json
import logging
from edge.detectors.zone import Zone
log = logging.getLogger(__name__)
# Zones are stored NORMALIZED (0..1 of the image) by the current dashboard. Polygons drawn by the
# pre-normalization dashboard were saved in canvas PIXELS (values in the hundreds); after the edge
# normalizes the foot point, a pixel polygon can never match — silently killing that zone's
# intrusion detection with no error. Flag such rows loudly so the operator redraws them, instead
# of failing dark. A normalized coordinate may round slightly past 1.0, so allow a small margin.
_NORMALIZED_MAX = 1.5
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."""
out: list[Zone] = []
rows = config.get("zones", []) or []
if not isinstance(rows, list):
return []
for row in rows:
try:
if camera_id is not None and str(row["camera_id"]) != str(camera_id):
continue
raw = row["polygon_json"]
pts = json.loads(raw) if isinstance(raw, str) else raw
polygon = [(float(p[0]), float(p[1])) for p in pts]
if len(polygon) < 3:
continue
if _looks_like_pixels(polygon):
log.warning(
"zone %s on camera %s has un-normalized (pixel-space) coordinates %r — it "
"would match nothing after foot normalization. Skipping; redraw this zone in "
"the dashboard so it is stored normalized (0..1).",
row.get("zone_id"), row.get("camera_id"), polygon[:2])
continue
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
# dropped silently — intrusion detection for that zone dead with no log, no dashboard
# signal. Flag it so it can be fixed instead of discovered after a worker enters the zone.
zid = row.get("zone_id") if isinstance(row, dict) else None
log.warning("skipping malformed zone row %r (%s: %s) — its intrusion detection is "
"inactive until the row is fixed in the dashboard.", zid, type(e).__name__, e)
continue
return out
def thresholds_from_config(config: dict) -> dict:
t = config.get("thresholds")
return t if isinstance(t, dict) else {}