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:
119
tests/test_camera_tuning.py
Normal file
119
tests/test_camera_tuning.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""Per-camera recognition gates.
|
||||
|
||||
The gates describe a *view*, not a preference. An overhead corridor camera
|
||||
where genuine faces measure 0.32-0.45 and an entrance camera at head height
|
||||
where they measure 0.70-0.82 cannot share one enrollment gate, and a real
|
||||
site has both — so one global number is guaranteed wrong somewhere.
|
||||
"""
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from behavision.config import (CameraConfig, CameraTuning, Config,
|
||||
RecognitionSection)
|
||||
from behavision.gallery import Gallery, IdentityStore, VectorIndex
|
||||
|
||||
DIM = 16
|
||||
|
||||
|
||||
def _unit(seed):
|
||||
rng = np.random.default_rng(seed)
|
||||
v = rng.normal(size=DIM).astype(np.float32)
|
||||
return v / np.linalg.norm(v)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gallery(tmp_path):
|
||||
store = IdentityStore(tmp_path / "t.db")
|
||||
gal = Gallery(store, VectorIndex(DIM),
|
||||
RecognitionSection(sighting_cooldown_seconds=0.0))
|
||||
yield gal
|
||||
store.close()
|
||||
|
||||
|
||||
# -- merging ------------------------------------------------------------
|
||||
def test_merged_overrides_only_what_is_set():
|
||||
base = RecognitionSection()
|
||||
merged = base.merged(CameraTuning(min_enroll_quality=0.40))
|
||||
assert merged.min_enroll_quality == 0.40
|
||||
assert merged.match_threshold == base.match_threshold
|
||||
|
||||
|
||||
def test_merging_does_not_mutate_the_global_section():
|
||||
"""Every camera merges off the same object; an in-place update would let
|
||||
one camera's tuning leak into every other camera."""
|
||||
base = RecognitionSection()
|
||||
base.merged(CameraTuning(min_enroll_quality=0.40))
|
||||
assert base.min_enroll_quality == 0.65
|
||||
|
||||
|
||||
def test_empty_tuning_returns_the_global_section_itself():
|
||||
base = RecognitionSection()
|
||||
assert base.merged(CameraTuning()) is base
|
||||
assert base.merged(None) is base
|
||||
|
||||
|
||||
def test_a_camera_cannot_invert_enroll_and_match():
|
||||
"""config.py's invariant has to hold per camera too, or one camera makes
|
||||
decisions that contradict the numbers driving every other one."""
|
||||
with pytest.raises(ValueError):
|
||||
RecognitionSection().merged(CameraTuning(match_threshold=0.10))
|
||||
|
||||
|
||||
def test_tuning_survives_the_camera_json_round_trip():
|
||||
cam = CameraConfig(id="door", host="10.0.0.5",
|
||||
tuning=CameraTuning(min_enroll_quality=0.40))
|
||||
revived = CameraConfig.model_validate(cam.model_dump(mode="json"))
|
||||
assert revived.tuning.min_enroll_quality == 0.40
|
||||
|
||||
|
||||
def test_a_camera_without_tuning_still_loads():
|
||||
cam = CameraConfig(id="plain", host="10.0.0.6")
|
||||
assert cam.tuning.min_enroll_quality is None
|
||||
assert RecognitionSection().merged(cam.tuning).min_enroll_quality == 0.65
|
||||
|
||||
|
||||
# -- effect on the shared gallery ---------------------------------------
|
||||
def test_a_loose_camera_enrolls_a_face_the_global_gate_refuses(gallery):
|
||||
"""The measured Office1 case: real faces at 0.45 against a 0.65 gate."""
|
||||
emb = _unit(1)
|
||||
assert gallery.resolve(emb, quality=0.45, camera_id="hall").kind == "skipped"
|
||||
|
||||
overhead = RecognitionSection().merged(CameraTuning(min_enroll_quality=0.40))
|
||||
res = gallery.resolve(emb, quality=0.45, camera_id="hall", rcfg=overhead)
|
||||
assert res.kind == "new"
|
||||
|
||||
|
||||
def test_one_cameras_override_does_not_leak_to_another(gallery):
|
||||
loose = RecognitionSection().merged(CameraTuning(min_enroll_quality=0.40))
|
||||
gallery.resolve(_unit(1), quality=0.45, camera_id="overhead", rcfg=loose)
|
||||
# A different, unmodified camera must still apply the global gate. Seed 4
|
||||
# sits at 0.033 to seed 1 — a genuinely different person, so the refusal
|
||||
# can only come from the quality gate. (These are 16-d fixtures; random
|
||||
# vectors that small are far less orthogonal than the real 512-d ones,
|
||||
# so the seed has to be picked, not assumed.)
|
||||
assert gallery.resolve(_unit(4), quality=0.45,
|
||||
camera_id="door").kind == "skipped"
|
||||
|
||||
|
||||
def test_reinforcement_honours_the_calling_cameras_gate(gallery):
|
||||
loose = RecognitionSection().merged(CameraTuning(min_enroll_quality=0.40))
|
||||
new = gallery.resolve(_unit(1), quality=0.9, camera_id="overhead",
|
||||
rcfg=loose)
|
||||
# Reinforcement only stores a view that is confidently this person
|
||||
# (>= enroll 0.32) yet not a near-duplicate (< reinforce 0.55). This
|
||||
# mixture measures 0.451 against the stored vector — inside that window.
|
||||
view = _unit(1) * 0.3 + _unit(3) * 0.7
|
||||
view = (view / np.linalg.norm(view)).astype(np.float32)
|
||||
|
||||
# 0.45 is under the global gate but over this camera's.
|
||||
assert not gallery.reinforce_identity(new.identity_id, view, 0.45)
|
||||
assert gallery.reinforce_identity(new.identity_id, view, 0.45, rcfg=loose)
|
||||
|
||||
|
||||
def test_worker_resolves_its_own_gates_at_construction():
|
||||
cfg = Config()
|
||||
cam = CameraConfig(id="overhead", host="10.0.0.7",
|
||||
tuning=CameraTuning(min_enroll_quality=0.40))
|
||||
merged = cfg.recognition.merged(cam.tuning)
|
||||
assert merged.min_enroll_quality == 0.40
|
||||
assert cfg.recognition.min_enroll_quality == 0.65
|
||||
Reference in New Issue
Block a user