Behavision: face recognition for retail, edge to head office
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
This commit is contained in:
65
behavision/detection.py
Normal file
65
behavision/detection.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user