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
120 lines
4.8 KiB
Python
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
|