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