"""What happens to a track that never becomes an identity. These cover the failure this pipeline was blind to: a visitor detected, tracked and embedded, then dropped because their face was under the enrollment gate — with no event, no counter and no log line, so a mis-tuned gate was indistinguishable from an empty room. """ import threading import numpy as np import pytest from behavision.config import Config from behavision.engine import (CameraWorker, PipelineStats, _track_outcome, _spread) from behavision.events import Event from behavision.gallery.service import Resolution from behavision.faces import FaceOutbox from behavision.tracking import Track class RecordingBus: def __init__(self): self.events = [] def publish(self, event: Event): self.events.append(event) class StubGallery: """Returns one canned verdict, whatever it is handed.""" def __init__(self, resolution): self.resolution = resolution self.calls = 0 def resolve(self, *a, **kw): self.calls += 1 return self.resolution class StubEncoder: size = 112 def encode_chip(self, chip): v = np.ones(512, dtype=np.float32) return v / np.linalg.norm(v) def make_worker(resolution, monkeypatch, cfg=None): """A CameraWorker with no camera, no detector and no models.""" monkeypatch.setattr("behavision.engine.align_face", lambda frame, kps, size: np.zeros((size, size, 3), dtype=np.uint8)) w = object.__new__(CameraWorker) w.cfg = cfg or Config() w.rcfg = w.cfg.recognition.merged(None) w.commission = None # no placement check running w.cam_cfg = w.cfg.cameras[0] if w.cfg.cameras else None w.encoder = StubEncoder() w.gallery = StubGallery(resolution) w.bus = RecordingBus() w.attrs = None w.pipeline = PipelineStats() w._lock = threading.Lock() # Images are off, the product default. A real FaceOutbox rather than a # mock, so a worker built this way runs the same disabled path # production does when store_faces is unset. w.faces = FaceOutbox(w.cfg.app.data_dir, enabled=False) class _Cam: id = "cam1" w.cam_cfg = _Cam() return w def ready_track(**kw): """A track that has already met every precondition for a decision.""" t = Track(id=1, box=(0, 0, 50, 50), kps=np.zeros((5, 2), dtype=np.float32), score=0.9) t.quality = t.best_quality = kw.pop("quality", 0.45) t.hits = 10 t.emb_sum = np.ones(512, dtype=np.float32) * 3 t.emb_count = 3 for k, v in kw.items(): setattr(t, k, v) return t # -- the bug ------------------------------------------------------------ def test_skipped_verdict_is_recorded_not_swallowed(monkeypatch): """resolve() refusing on quality must leave evidence behind.""" w = make_worker(Resolution(kind="skipped", similarity=0.1), monkeypatch) track = ready_track() w._identify(track, np.zeros((100, 100, 3), np.uint8), 100.0) assert w.gallery.calls == 1 assert track.quality_skips == 1, "the refusal left no trace on the track" assert track.state == "ambiguous", "a skipped track must not stay pending" def test_skipped_track_ends_as_a_quality_rejection(monkeypatch): w = make_worker(Resolution(kind="skipped", similarity=0.1), monkeypatch) track = ready_track() w._identify(track, np.zeros((100, 100, 3), np.uint8), 100.0) w._finish_track(track, 101.0) assert w.pipeline.snapshot()["outcomes"] == {"rejected_quality": 1} assert [e.type for e in w.bus.events] == ["person.missed"] assert w.bus.events[0].data["reason"] == "rejected_quality" def test_skipped_retries_are_throttled_not_burnt_in_one_burst(monkeypatch): """Marking it ambiguous buys the retry interval; without that the eight attempts are spent on eight consecutive frames of the same instant.""" w = make_worker(Resolution(kind="skipped", similarity=0.1), monkeypatch) track = ready_track() for ts in (100.0, 100.03, 100.06): # three frames, ~30 ms apart w._identify(track, np.zeros((100, 100, 3), np.uint8), ts) assert w.gallery.calls == 1 assert track.id_attempts == 1 # -- outcome classification -------------------------------------------- def test_recognized_and_enrolled_are_distinguished(): assert _track_outcome(ready_track(state="resolved", is_new=True)) == "enrolled" assert _track_outcome(ready_track(state="resolved")) == "recognized" def test_quality_rejection_outranks_gave_up(): """A track that exhausted its attempts on quality refusals is a quality failure; calling it ambiguous sends whoever tunes the site to the match threshold instead of to the camera mount.""" t = ready_track(state="gave_up", id_attempts=8, quality_skips=8) assert _track_outcome(t) == "rejected_quality" def test_track_that_never_encoded_is_not_a_recognition_failure(): t = ready_track(emb_sum=None, emb_count=0) assert _track_outcome(t) == "no_embedding" def test_track_that_left_before_deciding_is_too_brief(): assert _track_outcome(ready_track(id_attempts=0)) == "too_brief" def test_brief_losses_do_not_raise_events(monkeypatch): """A face glimpsed for two frames is noise, not a lost visitor.""" w = make_worker(Resolution(kind="skipped"), monkeypatch) t = ready_track(state="ambiguous", id_attempts=1, quality_skips=1, emb_count=1) w._finish_track(t, 100.0) assert w.pipeline.snapshot()["outcomes"] == {"rejected_quality": 1} assert w.bus.events == [] # -- distributions ------------------------------------------------------ def test_fraction_below_gate_names_the_real_problem(): """The number that says the enrollment gate is wrong for this camera.""" stats = PipelineStats() for q in (0.32, 0.38, 0.41, 0.45, 0.72): # measured overhead spread stats.record(ready_track(quality=q, id_attempts=1), "rejected_quality") snap = stats.snapshot(enroll_gate=0.65) assert snap["best_quality"]["n"] == 5 assert snap["best_quality"]["fraction_below_gate"] == 0.8 assert snap["tracks_ended"] == 5 def test_similarity_only_counts_tracks_that_reached_a_decision(): """Tracks that never called resolve() have similarity 0.0, and averaging those in would drag every percentile toward zero.""" stats = PipelineStats() stats.record(ready_track(id_attempts=1, similarity=0.5), "recognized") stats.record(ready_track(id_attempts=0, similarity=0.0), "too_brief") assert stats.snapshot()["similarity"]["n"] == 1 def test_spread_of_nothing_is_empty_not_zero(): assert _spread([]) == {"n": 0} def test_distribution_window_is_bounded(): """A camera running for weeks must not grow this without limit.""" stats = PipelineStats() for _ in range(PipelineStats.WINDOW + 50): stats.record(ready_track(id_attempts=1), "recognized") snap = stats.snapshot() assert snap["best_quality"]["n"] == PipelineStats.WINDOW assert snap["tracks_ended"] == PipelineStats.WINDOW + 50