import numpy as np import pytest from behavision.config import 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 / "test.db") cfg = RecognitionSection(sighting_cooldown_seconds=0.0) gal = Gallery(store, VectorIndex(DIM), cfg) yield gal store.close() def test_auto_enroll_then_recognize(gallery): emb = _unit(1) first = gallery.resolve(emb, quality=0.9, camera_id="cam1") assert first.kind == "new" assert first.label.startswith("Visitor") again = gallery.resolve(emb, quality=0.9, camera_id="cam1") assert again.kind == "known" assert again.identity_id == first.identity_id assert again.similarity == pytest.approx(1.0, abs=1e-5) def test_low_quality_face_is_not_enrolled(gallery): res = gallery.resolve(_unit(2), quality=0.1, camera_id="cam1") assert res.kind == "skipped" assert gallery.store.stats()["identities"] == 0 def test_ambiguous_zone_creates_nothing(gallery): base = _unit(3) gallery.resolve(base, quality=0.9, camera_id="cam1") # Build a vector with similarity between enroll(0.32) and match(0.42). other = _unit(4) other -= (other @ base) * base other /= np.linalg.norm(other) mid = 0.37 * base + np.sqrt(1 - 0.37 ** 2) * other res = gallery.resolve(mid.astype(np.float32), quality=0.9, camera_id="cam1") assert res.kind == "ambiguous" assert gallery.store.stats()["identities"] == 1 def test_explicit_enroll_and_delete(gallery): identity_id = gallery.enroll("Alice", [_unit(5), _unit(6)]) res = gallery.resolve(_unit(5), quality=0.9, camera_id="cam1") assert res.kind == "known" assert res.label == "Alice" assert gallery.delete_identity(identity_id) res2 = gallery.resolve(_unit(5), quality=0.9, camera_id="cam1") assert res2.kind == "new" # gone from index too def test_sighting_cooldown(tmp_path): store = IdentityStore(tmp_path / "cd.db") cfg = RecognitionSection(sighting_cooldown_seconds=9999) gal = Gallery(store, VectorIndex(DIM), cfg) emb = _unit(7) gal.resolve(emb, quality=0.9, camera_id="cam1") res = gal.resolve(emb, quality=0.9, camera_id="cam1") assert res.kind == "known" assert res.new_sighting is False store.close() def test_attributes_are_persisted_on_both_paths(gallery): emb = _unit(8) first = gallery.resolve(emb, quality=0.9, camera_id="cam1", attributes={"gender": "Male", "age": 41}) assert first.kind == "new" row = gallery.store.recent_sightings(1)[0] assert row["attributes"] == {"gender": "Male", "age": 41} again = gallery.resolve(emb, quality=0.9, camera_id="cam1", attributes={"gender": "Male", "emotion": "neutral"}) assert again.kind == "known" row = gallery.store.recent_sightings(1)[0] assert row["attributes"] == {"gender": "Male", "emotion": "neutral"} def test_sighting_without_attributes_stays_null(gallery): gallery.resolve(_unit(9), quality=0.9, camera_id="cam1") assert gallery.store.recent_sightings(1)[0]["attributes"] is None def test_reinforce_fills_out_an_identity_born_with_one_embedding(gallery): """The cold-gallery fix: an identity created from a single view must be able to accumulate other views during the same visit.""" base = _unit(20) res = gallery.resolve(base, quality=0.9, camera_id="cam1") assert gallery.store.embedding_count(res.identity_id) == 1 # a genuinely different view of the same person (sim below reinforce_threshold) other = _unit(21) other -= (other @ base) * base other /= np.linalg.norm(other) view2 = (0.5 * base + np.sqrt(1 - 0.25) * other).astype(np.float32) assert gallery.reinforce_identity(res.identity_id, view2, quality=0.9) assert gallery.store.embedding_count(res.identity_id) == 2 def test_reinforce_refuses_a_near_duplicate(gallery): res = gallery.resolve(_unit(22), quality=0.9, camera_id="cam1") # identical view adds nothing (sim 1.0 >= reinforce_threshold) assert not gallery.reinforce_identity(res.identity_id, _unit(22), quality=0.9) assert gallery.store.embedding_count(res.identity_id) == 1 def test_reinforce_refuses_low_quality_and_respects_the_cap(gallery): res = gallery.resolve(_unit(23), quality=0.9, camera_id="cam1") assert not gallery.reinforce_identity(res.identity_id, _unit(24), quality=0.1) for i in range(10): gallery.reinforce_identity(res.identity_id, _unit(30 + i), quality=0.9) assert (gallery.store.embedding_count(res.identity_id) <= gallery.cfg.max_embeddings_per_identity) def test_reinforce_will_not_attach_another_persons_face(gallery): """A track that drifts onto a different face must not poison the gallery.""" a = gallery.resolve(_unit(40), quality=0.9, camera_id="cam1") b = gallery.resolve(_unit(41), quality=0.9, camera_id="cam1") assert a.identity_id != b.identity_id # b's own vector offered as if it were a: top match is b, so refuse assert not gallery.reinforce_identity(a.identity_id, _unit(41), quality=0.9) assert gallery.store.embedding_count(a.identity_id) == 1 def _view(base, sim, seed): """A unit vector at a chosen cosine similarity to `base`.""" other = _unit(seed) other -= (other @ base) * base other /= np.linalg.norm(other) return (sim * base + np.sqrt(1 - sim ** 2) * other).astype(np.float32) def test_reinforce_refuses_a_view_it_would_call_a_different_person(gallery): """Below enroll_threshold, resolve() would mint a NEW identity - so attaching the same vector to an existing one contradicts it. Measured on the overhead camera: without this floor one identity held two vectors 0.195 apart.""" base = _unit(50) res = gallery.resolve(base, quality=0.9, camera_id="cam1") weak = _view(base, 0.20, 51) # below enroll_threshold 0.32 assert not gallery.reinforce_identity(res.identity_id, weak, quality=0.9) assert gallery.store.embedding_count(res.identity_id) == 1 def test_reinforce_accepts_the_useful_band(gallery): """Between enroll_threshold and reinforce_threshold is exactly the view worth learning: plausibly this person, usefully different.""" base = _unit(52) res = gallery.resolve(base, quality=0.9, camera_id="cam1") for sim, seed in ((0.35, 53), (0.50, 54)): assert gallery.reinforce_identity( res.identity_id, _view(base, sim, seed), quality=0.9), sim assert gallery.store.embedding_count(res.identity_id) == 3