Five components that ship as one product:
- behavision/ the recognition engine. RTSP ingest, YuNet detection, IoU
tracking, ArcFace embeddings, a FAISS/SQLite gallery, and a
FastAPI dashboard. Identity is decided once per TRACK from an
average of at least three embeddings, never per frame.
- agent/ the Go edge agent: supervises the engine, holds a durable
spool, and drains it to MQTT. Nothing is acked before the
broker confirms.
- desktop/ the shop PC application (Wails + React + tray).
- server/ the cloud API, MQTT consumer, reports and assistant.
- web/ platform.loyaly.ai, the head-office app, embedded in the
server binary.
The gallery stores 512-float embeddings and timestamps - no images unless
`app.store_faces` is switched on. Those embeddings are biometric personal
data under GDPR and India's DPDP: template inversion reconstructs a
recognisable face from an ArcFace vector, so data/behavision.db is treated
as a biometric database and DELETE /api/visitors/{id} is a real erasure.
CLAUDE.md carries the reasoning behind every non-obvious decision here,
including the ones that were measured and the ones that were wrong first.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
66 lines
2.2 KiB
Python
66 lines
2.2 KiB
Python
"""Face detection with YuNet (OpenCV FaceDetectorYN).
|
|
|
|
Why YuNet: modern CNN detector with 5-point landmarks built into OpenCV —
|
|
no compilation, no extra runtime, works on Windows out of the box, and its
|
|
landmarks feed ArcFace alignment directly. Accuracy on frontal surveillance
|
|
footage is on par with SCRFD-500M at a fraction of the operational cost.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
import cv2
|
|
import numpy as np
|
|
|
|
from .geometry import clip_box
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
YUNET_FILENAME = "face_detection_yunet_2023mar.onnx"
|
|
|
|
|
|
@dataclass
|
|
class Detection:
|
|
box: tuple # x1, y1, x2, y2 (int, clipped to frame)
|
|
kps: np.ndarray # (5, 2) float32, full-frame coordinates
|
|
score: float
|
|
quality: float = 0.0
|
|
attributes: dict = field(default_factory=dict)
|
|
|
|
|
|
class FaceDetector:
|
|
def __init__(self, models_dir: Path, score_threshold: float = 0.75,
|
|
nms_threshold: float = 0.3, max_faces: int = 20,
|
|
min_face_px: int = 48):
|
|
model_path = Path(models_dir) / YUNET_FILENAME
|
|
if not model_path.exists():
|
|
raise FileNotFoundError(
|
|
f"{model_path} missing - run: python -m behavision setup-models")
|
|
self._det = cv2.FaceDetectorYN_create(
|
|
str(model_path), "", (320, 320), score_threshold, nms_threshold,
|
|
max_faces)
|
|
self._input_size: "tuple[int, int] | None" = None
|
|
self.min_face_px = min_face_px
|
|
|
|
def detect(self, frame: np.ndarray) -> "list[Detection]":
|
|
h, w = frame.shape[:2]
|
|
if self._input_size != (w, h):
|
|
self._det.setInputSize((w, h))
|
|
self._input_size = (w, h)
|
|
_, faces = self._det.detect(frame)
|
|
if faces is None:
|
|
return []
|
|
out: list[Detection] = []
|
|
for f in faces:
|
|
x, y, bw, bh = f[:4]
|
|
if min(bw, bh) < self.min_face_px:
|
|
continue
|
|
box = clip_box((x, y, x + bw, y + bh), w, h)
|
|
if box is None:
|
|
continue
|
|
kps = f[4:14].reshape(5, 2).astype(np.float32)
|
|
out.append(Detection(box=box, kps=kps, score=float(f[14])))
|
|
return out
|