Add detection zone configuration with polygon editor

New /config/zone page lets users draw a polygon over a camera frame
(including live RTSP snapshot) to define the area where vehicle detection
applies. Zone is stored in /data/zone.json and applied in two ways:
- Motion scoring: inter-frame diff is masked outside the zone so garden
  movement doesn't inflate frame scores
- YOLO plate detection: detections whose center falls outside the zone
  are filtered out

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
perco
2026-06-03 17:11:08 +02:00
co-authored by Claude Sonnet 4.6
parent d6f95131bc
commit 0fc2be31aa
4 changed files with 416 additions and 1 deletions
+41 -1
View File
@@ -35,7 +35,28 @@ _last_event_time = 0.0
_analyzer: PlateAnalyzer | None = None
import re as _re
import json as _json
_FR_PLATE_RE = _re.compile(r'^([A-Z]{2})(\d{3})([A-Z]{2})$')
_ZONE_PATH = "/data/zone.json"
def _read_zone_points() -> list | None:
try:
with open(_ZONE_PATH) as f:
pts = _json.load(f).get("points", [])
if len(pts) >= 3:
return pts
except Exception:
pass
return None
def _zone_mask(h: int, w: int, pts: list) -> np.ndarray:
poly = np.array([[int(x * w), int(y * h)] for x, y in pts], dtype=np.int32)
mask = np.zeros((h, w), dtype=np.uint8)
cv2.fillPoly(mask, [poly], 255)
return mask
def _normalize_plate(raw: str) -> str:
"""Validate and format a French plate (6-8 alphanumeric chars).
@@ -189,6 +210,9 @@ def _process_clip(event_id: str, event_dir: str, clip_path: str, camera_name: st
loaded: list[tuple[np.ndarray, str, float, float]] = [] # frame, path, sharpness, motion
prev_small: np.ndarray | None = None
zone_pts = _read_zone_points()
zone_mask_small: np.ndarray | None = None # computed lazily on first frame
for path in frame_files:
frame = cv2.imread(path)
if frame is None:
@@ -197,7 +221,16 @@ def _process_clip(event_id: str, event_dir: str, clip_path: str, camera_name: st
lap = cv2.Laplacian(gray, cv2.CV_64F).var()
h, w = gray.shape
small = cv2.resize(gray, (_MOTION_W, _MOTION_W * h // w))
motion = float(np.mean(np.abs(small.astype(np.float32) - prev_small.astype(np.float32)))) if prev_small is not None else 0.0
if zone_mask_small is None and zone_pts:
sh, sw = small.shape
zone_mask_small = cv2.resize(_zone_mask(h, w, zone_pts), (sw, sh))
if prev_small is not None:
diff = np.abs(small.astype(np.float32) - prev_small.astype(np.float32))
if zone_mask_small is not None:
diff = diff * (zone_mask_small.astype(np.float32) / 255.0)
motion = float(np.mean(diff))
else:
motion = 0.0
prev_small = small
loaded.append((frame, path, lap, motion))
@@ -221,6 +254,13 @@ def _process_clip(event_id: str, event_dir: str, clip_path: str, camera_name: st
scored: list[tuple[float, np.ndarray, str]] = []
for _, frame, path in top15:
plates = _analyzer.detect_plates(frame) if _analyzer else []
if plates and zone_pts:
fh, fw = frame.shape[:2]
poly = np.array([[int(x * fw), int(y * fh)] for x, y in zone_pts], dtype=np.int32)
plates = [p for p in plates
if cv2.pointPolygonTest(poly.reshape(-1, 1, 2),
((p[0] + p[2]) / 2, (p[1] + p[3]) / 2),
False) >= 0]
score = _frame_score(frame, plates)
scored.append((score, frame, path))
scored.sort(key=lambda x: -x[0])