Files
Behavision/tests/test_camera_tuning.py
Suriyakumarvijayanayagam dad04e8cda 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
2026-09-04 11:14:18 +05:30

120 lines
4.8 KiB
Python

"""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