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
This commit is contained in:
2026-09-04 11:14:18 +05:30
commit dad04e8cda
216 changed files with 40473 additions and 0 deletions

226
tests/test_api_cameras.py Normal file
View File

@@ -0,0 +1,226 @@
"""Camera CRUD over HTTP against a stub engine (no models, no camera).
Needs httpx for starlette's TestClient; skipped if absent so a bare checkout
still runs the suite.
"""
import threading
import pytest
pytest.importorskip("httpx")
from fastapi.testclient import TestClient # noqa: E402
from behavision.api import create_app # noqa: E402
from behavision.cameras import CameraStore # noqa: E402
from behavision.config import Config # noqa: E402
from behavision.engine import Engine # noqa: E402
SECRET = "sup3r-s3cret-rtsp-pw"
class FakeWorker:
def __init__(self, cam_cfg):
self.cam_cfg = cam_cfg
self.rcfg = Config().recognition.merged(cam_cfg.tuning)
self.commission = None
def start(self): pass
def stop(self): pass
def is_alive(self): return True
def join(self, timeout=None): pass
def stats(self): return {"camera_id": self.cam_cfg.id, "connected": True,
"url": self.cam_cfg.safe_url()}
def latest_jpeg(self): return None
@pytest.fixture
def client(tmp_path, monkeypatch):
monkeypatch.setattr("behavision.engine.CameraWorker",
lambda cam, *a, **kw: FakeWorker(cam))
monkeypatch.setattr("behavision.engine.FaceDetector", lambda *a, **kw: object())
eng = object.__new__(Engine)
eng.cfg = Config()
eng.cfg.app.data_dir = tmp_path
eng.cfg.api.username = eng.cfg.api.password = "" # loopback: no auth
eng.encoder = eng.gallery = eng.bus = eng.attributes = eng.store = None
eng._lock = threading.RLock()
eng.workers, eng.detectors = {}, {}
eng.started_at, eng._running = 1.0, True
eng.camera_store = CameraStore(tmp_path / "cameras.json")
return TestClient(create_app(eng)), eng
def _add(c, cid="cam1", **kw):
body = dict(id=cid, host="192.168.0.138", path="/ch0_0.264",
username="admin", password=SECRET)
body.update(kw)
return c.post("/api/cameras", json=body)
def test_add_camera_starts_it_without_a_restart(client):
c, eng = client
r = _add(c)
assert r.status_code == 201
assert cid_in(c, "cam1")
assert "cam1" in eng.workers # running, not just stored
def cid_in(c, cid):
return any(x["id"] == cid for x in c.get("/api/cameras").json())
def test_password_never_appears_in_any_response(client):
"""The assertion this whole phase exists to satisfy."""
c, _ = client
_add(c)
for resp in (c.get("/api/cameras"),
c.patch("/api/cameras/cam1", json={"port": 8554}),
c.post("/api/cameras", json={"id": "cam2", "host": "10.0.0.5",
"password": SECRET})):
assert SECRET not in resp.text, resp.url
def test_listing_reports_password_presence_not_the_value(client):
c, _ = client
_add(c)
entry = c.get("/api/cameras").json()[0]
assert entry["has_password"] is True
assert "password" not in entry
assert "*****" in entry["url"]
def test_duplicate_id_conflicts(client):
c, _ = client
_add(c)
assert _add(c).status_code == 409
def test_camera_with_no_source_is_rejected(client):
c, eng = client
assert c.post("/api/cameras", json={"id": "bad"}).status_code == 400
assert eng.camera_store.list() == [] # nothing persisted
def test_id_is_required(client):
c, _ = client
assert c.post("/api/cameras", json={"host": "10.0.0.1"}).status_code == 400
def test_edit_restarts_the_worker(client):
c, eng = client
_add(c)
first = eng.workers["cam1"]
r = c.patch("/api/cameras/cam1", json={"host": "10.0.0.77"})
assert r.status_code == 200
assert eng.workers["cam1"] is not first
assert eng.camera_store.get("cam1").host == "10.0.0.77"
def test_edit_keeps_the_password_when_not_resent(client):
"""The UI never receives the password, so it cannot echo it back on save."""
c, eng = client
_add(c)
c.patch("/api/cameras/cam1", json={"port": 8554})
assert eng.camera_store.get("cam1").password == SECRET
def test_delete_removes_from_store_and_engine(client):
c, eng = client
_add(c)
assert c.delete("/api/cameras/cam1").status_code == 200
assert eng.camera_store.get("cam1") is None
assert "cam1" not in eng.workers
assert c.delete("/api/cameras/cam1").status_code == 404
def test_edit_unknown_camera_404s(client):
c, _ = client
assert c.patch("/api/cameras/nope", json={"port": 1}).status_code == 404
def test_test_endpoint_does_not_save(client):
c, eng = client
r = c.post("/api/cameras/test", json={"id": "probe", "host": "127.0.0.1",
"port": 1, "path": "/none"})
assert r.status_code == 200
assert r.json()["ok"] is False # nothing listening on port 1
assert eng.camera_store.list() == [] # and nothing was persisted
def test_test_endpoint_reports_bad_input_instead_of_raising(client):
c, _ = client
body = c.post("/api/cameras/test", json={"id": "probe"}).json()
assert body["ok"] is False and "url, host or webcam" in body["error"]
# -- placement commissioning ------------------------------------------------
def test_commission_starts_and_reports_progress(client):
c, eng = client
_add(c)
r = c.post("/api/cameras/cam1/commission", json={"seconds": 30})
assert r.status_code == 200
assert r.json()["verdict"] == "running"
assert eng.workers["cam1"].commission is not None
again = c.get("/api/cameras/cam1/commission")
assert again.status_code == 200
assert again.json()["camera_id"] == "cam1"
def test_commission_judges_against_the_cameras_own_gate(client):
"""A per-camera loosened gate must drive the verdict, or the wizard would
grade an overhead camera against a threshold it never runs under."""
c, eng = client
_add(c, cid="overhead", tuning={"min_enroll_quality": 0.40})
r = c.post("/api/cameras/overhead/commission", json={"seconds": 30})
assert r.json()["gate"] == 0.40
def test_commission_on_unknown_camera_404s(client):
c, _ = client
assert c.post("/api/cameras/nope/commission",
json={"seconds": 5}).status_code == 404
assert c.get("/api/cameras/nope/commission").status_code == 404
def test_reading_a_check_that_never_ran_404s(client):
c, _ = client
_add(c)
assert c.get("/api/cameras/cam1/commission").status_code == 404
def test_commission_can_be_cancelled(client):
c, eng = client
_add(c)
c.post("/api/cameras/cam1/commission", json={"seconds": 600})
assert c.delete("/api/cameras/cam1/commission").status_code == 200
assert eng.workers["cam1"].commission.report()["cancelled"] is True
def test_tuning_round_trips_through_add_and_edit(client):
"""model_copy(update=...) does not coerce, so a `tuning` dict arriving as
JSON used to be stored raw and then fail the first time the camera asked
it for thresholds. It must come back as a real CameraTuning."""
from behavision.config import CameraTuning
c, eng = client
_add(c, cid="overhead", tuning={"min_enroll_quality": 0.40})
assert c.get("/api/cameras").json()[0]["tuning"]["min_enroll_quality"] == 0.40
r = c.patch("/api/cameras/overhead",
json={"tuning": {"min_enroll_quality": 0.35}})
assert r.status_code == 200
stored = eng.camera_store.get("overhead")
assert isinstance(stored.tuning, CameraTuning)
assert stored.tuning.min_enroll_quality == 0.35
def test_an_inverted_per_camera_pair_is_rejected_not_stored(client):
"""enroll >= match would make one camera contradict every other one, and
they all write into the same gallery."""
c, _ = client
r = _add(c, cid="bad",
tuning={"enroll_threshold": 0.8, "match_threshold": 0.5})
assert r.status_code == 400
assert c.get("/api/cameras").json() == []

176
tests/test_api_merge.py Normal file
View File

@@ -0,0 +1,176 @@
"""Merge endpoints over HTTP against a real gallery on a temp database.
The gallery here is real (SQLite + numpy index, no models) because the point
of these tests is the refusal contract the UI depends on: a refused merge must
come back as a 409 carrying the measured similarity, so the operator can be
shown what they are being asked to override.
"""
import threading
import numpy as np
import pytest
pytest.importorskip("httpx")
from fastapi.testclient import TestClient # noqa: E402
from behavision.api import create_app # noqa: E402
from behavision.cameras import CameraStore # noqa: E402
from behavision.config import Config # noqa: E402
from behavision.engine import Engine # noqa: E402
from behavision.events import EventBus # noqa: E402
from behavision.gallery import Gallery, IdentityStore, VectorIndex # noqa: E402
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)
def _at_similarity(base, target, seed=99):
other = _unit(seed)
other -= (other @ base) * base
other /= np.linalg.norm(other)
v = target * base + np.sqrt(1 - target ** 2) * other
return (v / np.linalg.norm(v)).astype(np.float32)
@pytest.fixture
def client(tmp_path):
eng = object.__new__(Engine)
eng.cfg = Config()
eng.cfg.app.data_dir = tmp_path
eng.cfg.api.username = eng.cfg.api.password = ""
eng.cfg.recognition.sighting_cooldown_seconds = 0.0
eng.store = IdentityStore(tmp_path / "api.db")
eng.gallery = Gallery(eng.store, VectorIndex(DIM), eng.cfg.recognition)
eng.bus = EventBus()
eng.encoder = eng.attributes = None
eng._lock = threading.RLock()
eng.workers, eng.detectors = {}, {}
eng.started_at, eng._running = 1.0, True
eng.camera_store = CameraStore(tmp_path / "cameras.json")
yield TestClient(create_app(eng)), eng
eng.store.close()
def test_merge_succeeds_and_emits_an_audit_event(client):
c, eng = client
base = _unit(1)
a = eng.gallery.enroll("Alice", [base])
b = eng.gallery.enroll("Visitor 2", [_at_similarity(base, 0.50)])
r = c.post(f"/api/identities/{b}/merge", json={"into": a})
assert r.status_code == 200
assert r.json()["ok"] is True
assert r.json()["similarity"] == pytest.approx(0.50, abs=0.01)
assert len(c.get("/api/identities").json()) == 1
# Merging is destructive and irreversible; it has to leave a trace.
types = [e["type"] for e in eng.bus.recent]
assert "identity.merged" in types
def test_refusal_is_409_and_reports_the_similarity(client):
"""The UI needs the number, not just a failure — it is what justifies
offering the force override to a human."""
c, eng = client
a = eng.gallery.enroll("Alice", [_unit(1)])
b = eng.gallery.enroll("Bob", [_at_similarity(_unit(1), 0.05)])
r = c.post(f"/api/identities/{b}/merge", json={"into": a})
assert r.status_code == 409
detail = r.json()["detail"]
assert detail["ok"] is False
assert detail["similarity"] == pytest.approx(0.05, abs=0.01)
assert detail["threshold"] == eng.cfg.recognition.enroll_threshold
assert len(c.get("/api/identities").json()) == 2
def test_force_is_not_the_default(client):
c, eng = client
a = eng.gallery.enroll("Alice", [_unit(1)])
b = eng.gallery.enroll("Bob", [_at_similarity(_unit(1), 0.05)])
assert c.post(f"/api/identities/{b}/merge",
json={"into": a}).status_code == 409
r = c.post(f"/api/identities/{b}/merge", json={"into": a, "force": True})
assert r.status_code == 200
assert r.json()["forced"] is True
def test_missing_identity_is_404(client):
c, eng = client
a = eng.gallery.enroll("Alice", [_unit(1)])
r = c.post(f"/api/identities/9999/merge", json={"into": a})
assert r.status_code == 404
def test_self_merge_is_409(client):
c, eng = client
a = eng.gallery.enroll("Alice", [_unit(1)])
assert c.post(f"/api/identities/{a}/merge",
json={"into": a}).status_code == 409
def test_duplicates_endpoint_lists_candidates(client):
c, eng = client
base = _unit(1)
a = eng.gallery.enroll("Alice", [base])
b = eng.gallery.enroll("Visitor 2", [_at_similarity(base, 0.50)])
pairs = c.get("/api/identities/duplicates").json()
assert len(pairs) == 1
assert {pairs[0]["a"]["id"], pairs[0]["b"]["id"]} == {a, b}
assert pairs[0]["confident"] is True # 0.50 clears match_threshold
def test_duplicates_route_is_not_shadowed_by_the_id_routes(client):
"""`/api/identities/duplicates` sits under a path whose siblings take an
int id; if it ever gets matched by one of those it returns 422, not a
list."""
c, _ = client
r = c.get("/api/identities/duplicates")
assert r.status_code == 200
assert isinstance(r.json(), list)
def test_embedding_endpoint_returns_the_best_view(client):
"""The server needs a vector to match across sites, and the event bus
deliberately does not carry one."""
c, eng = client
base = _unit(1)
poor = _at_similarity(base, 0.60, seed=5)
# enroll() defaults to quality 1.0, which would win regardless and make
# the assertion below vacuous.
ident = eng.gallery.enroll("Alice", [base], quality=0.50)
eng.store.add_embedding(ident, poor, 0.20, eng.gallery.model_name)
eng.store.add_embedding(ident, base, 0.91, eng.gallery.model_name)
r = c.get(f"/api/identities/{ident}/embedding")
assert r.status_code == 200
body = r.json()
assert body["quality"] == 0.91, "did not pick the best view"
assert len(body["embedding"]) == DIM
assert body["model"] == eng.gallery.model_name
def test_embedding_endpoint_404s_for_an_unknown_identity(client):
c, _ = client
assert c.get("/api/identities/9999/embedding").status_code == 404
def test_embedding_endpoint_404s_when_the_model_differs(client):
"""Vectors from another encoder are not comparable, so returning one would
hand the server a template it must not use."""
c, eng = client
ident = eng.store.create_identity("Bob")
eng.store.add_embedding(ident, _unit(3), 0.9, "some_other_model")
assert c.get(f"/api/identities/{ident}/embedding").status_code == 404

91
tests/test_attributes.py Normal file
View File

@@ -0,0 +1,91 @@
"""Attribute aggregation — pure maths, no models."""
from behavision.attributes import aggregate
def test_median_age_ignores_one_wild_frame():
samples = [{"age": 47}, {"age": 51}, {"age": 12}] # 12 = a bad frame
out = aggregate(samples)
assert out["age"] == 47 # median, not mean (mean would be 36)
assert out["age_spread"] == 39 # and the disagreement is reported
def test_majority_gender_wins_over_a_single_flip():
out = aggregate([
{"gender": "Male", "gender_confidence": 0.9},
{"gender": "Male", "gender_confidence": 0.8},
{"gender": "Female", "gender_confidence": 0.99},
])
assert out["gender"] == "Male"
assert out["gender_confidence"] == 0.85 # mean over the winning frames only
def test_empty_and_partial_inputs():
assert aggregate([]) == {}
assert aggregate([{}, None]) == {}
assert aggregate([{"gender": "Female", "gender_confidence": 0.7}]) == {
"gender": "Female", "gender_confidence": 0.7}
def test_caffe_age_range_is_aggregated_too():
out = aggregate([{"age_range": "38-43"}, {"age_range": "38-43"},
{"age_range": "25-32"}])
assert out["age_range"] == "38-43"
def test_person_event_reports_the_quality_the_gate_actually_used():
"""The enrollment gate runs on track.best_quality. Reporting this frame's
track.quality instead made events look like they had passed a threshold
they were below (observed: quality=0.586 logged against a 0.65 gate)."""
import threading
import numpy as np
from behavision.config import Config
from behavision.engine import CameraWorker
from behavision.gallery.service import Resolution
from behavision.faces import FaceOutbox
from behavision.tracking import Track
cfg = Config()
published = []
class Enc:
size = 112
def encode_chip(self, chip):
v = np.ones(512, np.float32)
return v / np.linalg.norm(v)
class Gal:
def resolve(self, mean, quality, cam, ts, attributes=None, rcfg=None):
self.seen_quality = quality
self.seen_rcfg = rcfg
return Resolution(kind="new", identity_id=1, label="Visitor 1",
similarity=0.1, new_sighting=True)
w = object.__new__(CameraWorker)
w.cfg, w.encoder, w.attrs = cfg, Enc(), None
w.rcfg = cfg.recognition.merged(None) # no per-camera overrides here
w.commission = None # no placement check running
w.gallery = Gal()
w.cam_cfg = type("C", (), {"id": "cam1"})()
w.bus = type("B", (), {"publish": staticmethod(published.append)})()
# 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(cfg.app.data_dir, enabled=False)
kps = np.array([[130, 100], [190, 100], [160, 130], [135, 165], [185, 165]],
np.float32)
track = Track(id=1, box=(100, 60, 220, 200), kps=kps, score=0.9,
quality=0.40, best_quality=0.80, hits=10)
frame = np.zeros((240, 320, 3), np.uint8)
ts = 1000.0
for _ in range(cfg.tracking.min_embeddings_for_id):
ts += 0.04
w._identify(track, frame, ts)
assert published, "no event published"
data = published[0].data
assert w.gallery.seen_quality == 0.80 # the gate saw best_quality
assert data["quality"] == 0.80 # and so does the event
assert data["frame_quality"] == 0.40 # this frame, kept for context

122
tests/test_calibrate.py Normal file
View File

@@ -0,0 +1,122 @@
"""Calibration maths — no camera, no models (synthetic unit vectors only)."""
import numpy as np
import pytest
from behavision.calibrate import (CalibrationStore, distributions, group_means,
recommend)
from behavision.config import Config
DIM = 64
def _cluster(seed, n, tightness=0.97):
"""n unit vectors clustered around one direction; higher tightness =
more like the same person across frames."""
rng = np.random.default_rng(seed)
centre = rng.normal(size=DIM)
centre /= np.linalg.norm(centre)
out = []
for _ in range(n):
v = tightness * centre + (1 - tightness) * rng.normal(size=DIM)
out.append(v / np.linalg.norm(v))
return np.array(out, dtype=np.float32)
def test_group_means_mirrors_runtime_averaging():
embs = _cluster(1, 9)
means = group_means(embs, 3)
assert means.shape == (3, DIM)
assert np.allclose(np.linalg.norm(means, axis=1), 1.0, atol=1e-5)
def test_group_means_drops_a_tiny_trailing_group():
# 7 samples at group 3 -> two full groups; the leftover single frame is
# below half a group and must not become its own "identity".
assert len(group_means(_cluster(2, 7), 3)) == 2
def test_separable_people_yield_ordered_thresholds():
store = CalibrationStore("/nonexistent/never-written.npz")
for i, name in enumerate(["alice", "bob", "carol"]):
store.add("m", name, _cluster(10 + i, 12))
same, cross, meta = distributions(store, "m", 3)
assert len(meta["people"]) == 3
assert same.mean() > cross.mean()
rec = recommend(same, cross)
assert "error" not in rec
assert 0 < rec["enroll_threshold"] < rec["match_threshold"] < 1
# the invariant config.py enforces at load time
Config().recognition.model_copy(update={
"enroll_threshold": rec["enroll_threshold"],
"match_threshold": rec["match_threshold"]})
def test_overlapping_distributions_are_reported_not_smoothed_over():
"""Loose clusters that bleed into each other must fail loudly rather than
return a confident-looking midpoint."""
store = CalibrationStore("/nonexistent/never-written.npz")
rng = np.random.default_rng(0)
for name in ["alice", "bob"]:
v = rng.normal(size=(12, DIM)).astype(np.float32)
store.add("m", name, v / np.linalg.norm(v, axis=1, keepdims=True))
same, cross, _ = distributions(store, "m", 3)
rec = recommend(same, cross)
assert "error" in rec and "OVERLAP" in rec["error"]
def test_single_person_cannot_recommend_a_match_threshold():
store = CalibrationStore("/nonexistent/never-written.npz")
store.add("m", "alice", _cluster(5, 12))
same, cross, _ = distributions(store, "m", 3)
assert len(cross) == 0
rec = recommend(same, cross)
assert rec["match_threshold"] is None # honest about what it can't know
assert rec["enroll_threshold"] is not None # but this one it can
assert "note" in rec
def test_people_with_too_few_samples_are_skipped_not_averaged_in():
store = CalibrationStore("/nonexistent/never-written.npz")
store.add("m", "alice", _cluster(1, 12))
store.add("m", "flash", _cluster(2, 2)) # walked past, 2 frames
_, _, meta = distributions(store, "m", 3)
assert meta["people"] == ["alice"]
assert "flash" in meta["skipped"]
def test_store_roundtrips(tmp_path):
path = tmp_path / "cal.npz"
s = CalibrationStore(path)
s.add("model_a", "alice", _cluster(1, 5))
s.add("model_b", "alice", _cluster(2, 5))
s.save()
again = CalibrationStore(path)
assert again.models() == ["model_a", "model_b"]
assert again.get("model_a", "alice").shape == (5, DIM)
def test_store_appends_across_sessions(tmp_path):
path = tmp_path / "cal.npz"
s = CalibrationStore(path)
s.add("m", "alice", _cluster(1, 5))
assert s.add("m", "alice", _cluster(2, 4)) == 9 # second session accumulates
def test_clamped_enroll_recommendation_says_so():
"""A clamped value is the config ceiling talking, not the data - if it is
not flagged, every model reports the same number and it reads as a
measurement."""
store = CalibrationStore("/nonexistent/never-written.npz")
store.add("m", "alice", _cluster(5, 12, tightness=0.99)) # very tight
same, cross, _ = distributions(store, "m", 3)
rec = recommend(same, cross, current_match=0.42)
assert rec["enroll_threshold"] == 0.41
assert "clamped" in rec
def test_unclamped_recommendation_is_not_flagged():
store = CalibrationStore("/nonexistent/never-written.npz")
store.add("m", "alice", _cluster(5, 12, tightness=0.99))
same, cross, _ = distributions(store, "m", 3)
rec = recommend(same, cross, current_match=0.99)
assert "clamped" not in rec

119
tests/test_camera_tuning.py Normal file
View File

@@ -0,0 +1,119 @@
"""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

110
tests/test_cameras.py Normal file
View File

@@ -0,0 +1,110 @@
"""CameraStore — the writable camera list. No camera or models needed."""
import json
import pytest
from behavision.cameras import CameraStore, protect, unprotect
from behavision.config import CameraConfig
SECRET = "p@ss:w0rd/with@specials"
def cam(cid="cam1", **kw):
base = dict(id=cid, host="192.168.0.138", path="/ch0_0.264",
username="admin", password=SECRET)
base.update(kw)
return CameraConfig(**base)
def test_add_get_list_delete(tmp_path):
s = CameraStore(tmp_path / "cameras.json")
s.add(cam())
assert [c.id for c in s.list()] == ["cam1"]
assert s.get("cam1").password == SECRET
assert s.delete("cam1") and s.get("cam1") is None
assert not s.delete("cam1")
def test_duplicate_id_rejected(tmp_path):
s = CameraStore(tmp_path / "cameras.json")
s.add(cam())
with pytest.raises(ValueError, match="already exists"):
s.add(cam())
def test_camera_with_no_source_is_rejected_at_add(tmp_path):
"""Better to fail on Save than to silently create a camera that can never
connect."""
s = CameraStore(tmp_path / "cameras.json")
with pytest.raises(ValueError, match="set url, host or webcam"):
s.add(CameraConfig(id="bad"))
def test_survives_a_restart(tmp_path):
path = tmp_path / "cameras.json"
CameraStore(path).add(cam())
reloaded = CameraStore(path)
assert reloaded.get("cam1").password == SECRET
assert reloaded.get("cam1").host == "192.168.0.138"
def test_password_is_not_stored_in_the_clear_verbatim(tmp_path):
"""On Windows DPAPI encrypts it; elsewhere it is tagged plaintext. Either
way the stored form must be tagged so the format can evolve."""
path = tmp_path / "cameras.json"
CameraStore(path).add(cam())
stored = json.loads(path.read_text())["cameras"][0]["password"]
assert stored.startswith(("dpapi:", "plain:"))
assert unprotect(stored) == SECRET
def test_protect_roundtrip_including_empty():
assert unprotect(protect(SECRET)) == SECRET
assert protect("") == ""
assert unprotect("") == ""
def test_update_cannot_rename_the_id(tmp_path):
"""id is the engine's key for the running worker; a rename would orphan
it."""
s = CameraStore(tmp_path / "cameras.json")
s.add(cam())
updated = s.update("cam1", {"id": "renamed", "port": 8554})
assert updated.id == "cam1" and updated.port == 8554
assert s.get("renamed") is None
def test_update_unknown_camera_returns_none(tmp_path):
assert CameraStore(tmp_path / "cameras.json").update("nope", {}) is None
def test_seed_runs_once_only(tmp_path):
"""A camera deleted in the UI must not reappear from YAML on restart."""
path = tmp_path / "cameras.json"
s = CameraStore(path)
assert s.seed([cam("yaml1")]) is True
assert s.delete("yaml1")
assert CameraStore(path).seed([cam("yaml1")]) is False
assert CameraStore(path).list() == []
def test_unreadable_file_does_not_destroy_it(tmp_path):
path = tmp_path / "cameras.json"
path.write_text("{ not json")
s = CameraStore(path)
assert s.list() == []
assert path.read_text() == "{ not json" # left for a human to look at
def test_malformed_entry_is_skipped_not_fatal(tmp_path):
path = tmp_path / "cameras.json"
path.write_text(json.dumps({"cameras": [
{"id": "good", "host": "10.0.0.1"},
{"host": "10.0.0.2"}, # no id
]}))
assert [c.id for c in CameraStore(path).list()] == ["good"]
def test_safe_url_masks_the_password(tmp_path):
assert SECRET not in cam().safe_url()
assert "*****" in cam().safe_url()

79
tests/test_capture.py Normal file
View File

@@ -0,0 +1,79 @@
import numpy as np
from behavision.capture import VideoSource
def _source_with_frame(ts=100.0):
"""VideoSource opens nothing until run(), so the frame slot can be
populated directly — no camera needed."""
src = VideoSource("cam1", 0)
src._frame = np.zeros((4, 4, 3), dtype=np.uint8)
src._frame_ts = ts
return src
def test_latest_since_skips_an_already_seen_frame():
src = _source_with_frame(ts=100.0)
frame, ts = src.latest_since(100.0)
assert frame is None
assert ts == 100.0
def test_latest_since_returns_a_newer_frame():
src = _source_with_frame(ts=100.0)
frame, ts = src.latest_since(99.0)
assert frame is not None
assert ts == 100.0
def test_latest_since_returns_a_copy():
src = _source_with_frame()
frame, _ = src.latest_since(0.0)
frame[0, 0, 0] = 255
assert src._frame[0, 0, 0] == 0
def test_latest_since_with_no_frame_yet():
frame, ts = VideoSource("cam1", 0).latest_since(0.0)
assert frame is None
assert ts == 0.0
def test_first_connect_is_not_counted_as_a_reconnect():
assert VideoSource("cam1", 0).stats()["reconnects"] == 0
def _elapsed(fn):
import time
t = time.perf_counter()
result = fn()
return result, time.perf_counter() - t
def test_probe_fails_fast_on_an_unroutable_host():
"""The Test button must answer, not hang. cv2.VideoCapture blocks in its
constructor on TCP connect (~75s OS timeout), so a pre-flight check is the
only thing that bounds this."""
from behavision.capture import probe_source
result, seconds = _elapsed(lambda: probe_source(
"rtsp://10.255.255.1:554/x", connect_timeout=1.0))
assert result["ok"] is False
assert "10.255.255.1:554" in result["error"]
assert seconds < 5, f"took {seconds:.1f}s - the pre-flight is not working"
def test_probe_reports_connection_refused_immediately():
from behavision.capture import probe_source
result, seconds = _elapsed(lambda: probe_source("rtsp://127.0.0.1:1/x"))
assert result["ok"] is False
assert seconds < 5
def test_tcp_precheck_passes_through_non_url_sources():
"""A webcam index has no host to check; it must not be blocked."""
from behavision.capture import _tcp_reachable
assert _tcp_reachable(0, 1.0) == (True, "")
assert _tcp_reachable("not-a-url", 1.0)[0] is True

186
tests/test_commission.py Normal file
View File

@@ -0,0 +1,186 @@
"""Camera commissioning verdicts.
These messages are what an installer acts on at a customer site, so the
boundaries are tested against the real measured numbers: frontal faces at head
height score 0.70-0.82, the Office1 overhead corridor scored 0.32-0.45 against
a 0.65 gate, and frosted glass produced a flat 0.37 on every frame.
"""
import pytest
from behavision.commission import CommissionRun
GATE = 0.65
def _finished(qualities, gate=GATE, seconds=10.0):
"""A run that has already ended, holding these per-track qualities."""
run = CommissionRun("cam1", gate, seconds, now=0.0)
for q in qualities:
run.record(q, now=1.0)
return run.report(now=seconds + 1)
def test_a_well_placed_camera_passes():
r = _finished([0.72, 0.78, 0.70, 0.81, 0.75, 0.69])
assert r["verdict"] == "good"
assert r["quality"]["fraction_below_gate"] == 0.0
def test_the_office1_geometry_fails_with_a_placement_instruction():
"""The exact case this feature exists to catch: real faces, all under the
gate, nothing appearing broken."""
r = _finished([0.32, 0.38, 0.45, 0.41, 0.35, 0.44])
assert r["verdict"] == "poor"
assert r["quality"]["fraction_below_gate"] == 1.0
advice = " ".join(r["advice"]).lower()
assert "head height" in advice
# It must not suggest the shortcut that hides the problem.
assert "do not lower the quality gate" in advice
def test_a_mixed_camera_is_marginal_not_a_pass():
"""Half the visitors recognised is not a working camera, and calling it
one is how a site gets signed off broken."""
r = _finished([0.70, 0.72, 0.75, 0.40, 0.35, 0.38])
assert r["verdict"] == "marginal"
assert 0.2 < r["quality"]["fraction_below_gate"] <= 0.5
def test_no_faces_is_distinct_from_bad_placement():
"""The fixes are completely different — pointing the camera versus moving
it — so the verdicts must be too."""
r = _finished([])
assert r["verdict"] == "no_faces"
assert r["faces"] == 0
assert "walkway" in " ".join(r["advice"]).lower()
def test_a_constant_score_is_reported_as_an_artifact_not_a_face():
"""Frosted glass measured a flat 0.37 on every frame. A constant score
across many detections is the signature of a static object, and telling
the installer to move the camera would be wrong advice."""
r = _finished([0.37, 0.37, 0.371, 0.369, 0.37, 0.37, 0.37])
assert r["verdict"] == "artifact"
advice = " ".join(r["advice"]).lower()
assert "static" in advice
assert "head height" not in advice
def test_a_flat_score_above_the_gate_is_still_an_artifact():
"""The check is about the flatness, not the level - a bright poster can
score well and is still not a customer."""
r = _finished([0.74, 0.74, 0.741, 0.739, 0.74, 0.74])
assert r["verdict"] == "artifact"
def test_too_few_faces_is_inconclusive_not_a_verdict():
"""Three good samples is anecdote. Reporting it as a pass would sign off a
site on noise."""
r = _finished([0.75, 0.30, 0.72])
assert r["verdict"] == "inconclusive"
assert "again" in " ".join(r["advice"]).lower()
def test_verdict_uses_the_cameras_own_gate():
"""Gates describe a view. The same faces pass under a loosened per-camera
gate and fail under the strict default."""
faces = [0.50, 0.52, 0.48, 0.55, 0.51, 0.49]
assert _finished(faces, gate=0.65)["verdict"] == "poor"
assert _finished(faces, gate=0.45)["verdict"] == "good"
# -- run lifecycle ----------------------------------------------------------
def test_while_running_it_reports_progress_not_a_verdict():
run = CommissionRun("cam1", GATE, seconds=10.0, now=0.0)
run.record(0.7, now=1.0)
r = run.report(now=2.0)
assert r["verdict"] == "running"
assert r["running"] is True
assert r["faces"] == 1
assert r["elapsed"] == 2.0
def test_samples_after_the_window_are_ignored():
"""Otherwise a busy camera keeps changing its own verdict after the
installer has walked away and read the result."""
run = CommissionRun("cam1", GATE, seconds=10.0, now=0.0)
run.record(0.70, now=1.0)
run.record(0.10, now=99.0)
assert run.report(now=11.0)["faces"] == 1
def test_tracks_that_never_held_a_face_are_not_evidence():
"""best_quality 0 means no face was ever embedded on that track. Counting
it as a bad view would blame placement for a detection problem."""
run = CommissionRun("cam1", GATE, seconds=10.0, now=0.0)
run.record(0.0, now=1.0)
run.record(0.72, now=2.0)
assert run.report(now=11.0)["faces"] == 1
def test_cancel_stops_the_run_immediately():
run = CommissionRun("cam1", GATE, seconds=60.0, now=0.0)
assert run.running(now=1.0)
run.cancel()
assert not run.running(now=1.0)
run.record(0.9, now=2.0)
r = run.report(now=2.0)
assert r["cancelled"] is True
assert r["faces"] == 0
def test_a_very_short_window_is_clamped():
"""A 0-second check would report "no faces" before anyone could move."""
assert CommissionRun("cam1", GATE, seconds=0.0).seconds >= 5.0
def test_a_face_standing_still_is_not_reported_as_no_faces():
"""Found by running the dashboard: someone stands in front of the camera
to check it, their track never ends inside the window, and the check said
"no faces detected — check it is pointing at the walkway". That advice
moves a camera that is aimed correctly at a face."""
run = CommissionRun("cam1", gate=0.65, seconds=10, now=0.0)
for i in range(200): # a face in view for the whole window...
run.observe(1, now=0.1 * i)
r = run.report(now=20.0) # ...and not one completed pass
assert r["faces"] == 0
assert r["verdict"] == "no_completed_passes"
assert "walked past" in r["headline"]
joined = " ".join(r["advice"]).lower()
assert "pointed correctly" in joined
assert "walkway" not in joined, "must not advise re-aiming a working camera"
def test_a_truly_blind_camera_still_says_no_faces():
"""The distinction only earns its keep if the other branch survives."""
run = CommissionRun("cam1", gate=0.65, seconds=10, now=0.0)
r = run.report(now=20.0)
assert r["verdict"] == "no_faces"
assert "walkway" in " ".join(r["advice"])
def test_observe_is_ignored_once_the_window_closes():
"""Same rule record() already follows: the window is the measurement."""
run = CommissionRun("cam1", gate=0.65, seconds=10, now=0.0)
run.observe(1, now=50.0)
assert run.report(now=60.0)["verdict"] == "no_faces"
def test_progress_says_face_in_view_rather_than_zero_faces():
"""'watching... 0 faces so far' with a face plainly on screen reads as a
broken check, and is what made the underlying bug look normal."""
run = CommissionRun("cam1", gate=0.65, seconds=25, now=0.0)
run.observe(1, now=1.0)
r = run.report(now=2.0)
assert r["verdict"] == "running"
assert "face in view" in r["headline"]
assert r["frames_with_a_face"] == 1
def test_inconclusive_advice_reads_as_english():
"""Installer-facing copy: "1 samples is anecdote" is what a customer sees."""
run = CommissionRun("cam1", gate=0.65, seconds=10, now=0.0)
run.record(0.9, now=1.0)
advice = " ".join(run.report(now=20.0)["advice"])
assert "1 sample is" in advice and "1 samples" not in advice

125
tests/test_config.py Normal file
View File

@@ -0,0 +1,125 @@
"""The shipped YAML carries thresholds measured on the deployment site; the
pydantic defaults must not drift away from them (a trimmed config would then
silently re-admit the false positives those values were tuned to reject)."""
import pytest
from behavision.config import (ApiSection, Config, DetectionSection,
RecognitionSection, TrackingSection,
ensure_api_credentials, load_config)
def test_yaml_matches_code_defaults():
cfg = load_config()
for section, model in (("detection", DetectionSection),
("recognition", RecognitionSection),
("tracking", TrackingSection)):
loaded = getattr(cfg, section)
defaults = model()
for name in model.model_fields:
assert getattr(loaded, name) == getattr(defaults, name), (
f"{section}.{name} drifted between config/default.yaml "
f"and config.py")
def test_tuned_thresholds_are_the_defaults():
cfg = Config()
assert cfg.detection.score_threshold == 0.82
assert cfg.recognition.min_enroll_quality == 0.65
def test_loopback_stays_open(tmp_path):
cfg = Config()
cfg.app.data_dir = tmp_path
cfg.api.host = "127.0.0.1"
auth_on, generated = ensure_api_credentials(cfg)
assert (auth_on, generated) == (False, False)
assert not (tmp_path / "api_credentials.txt").exists()
def test_routable_host_never_served_unauthenticated(tmp_path):
cfg = Config()
cfg.app.data_dir = tmp_path
cfg.api.host = "0.0.0.0"
auth_on, generated = ensure_api_credentials(cfg)
assert auth_on and generated
assert cfg.api.username and len(cfg.api.password) >= 16
assert (tmp_path / "api_credentials.txt").exists()
def test_generated_credentials_are_stable_across_restarts(tmp_path):
first = Config()
first.app.data_dir = tmp_path
first.api.host = "0.0.0.0"
ensure_api_credentials(first)
second = Config()
second.app.data_dir = tmp_path
second.api.host = "0.0.0.0"
auth_on, generated = ensure_api_credentials(second)
assert auth_on and not generated # reused, not re-minted
assert second.api.password == first.api.password
def test_configured_credentials_win(tmp_path):
cfg = Config()
cfg.app.data_dir = tmp_path
cfg.api.host = "0.0.0.0"
cfg.api.username, cfg.api.password = "alice", "s3cret"
auth_on, generated = ensure_api_credentials(cfg)
assert (auth_on, generated) == (True, False)
assert not (tmp_path / "api_credentials.txt").exists()
assert cfg.api.password == "s3cret"
def test_blank_env_placeholders_do_not_enable_auth():
"""Unset ${ENV} parses as YAML null; auth must not half-enable."""
cfg = ApiSection(host="0.0.0.0", port=None, username=None, password=None)
assert cfg.username == "" and cfg.password == ""
assert cfg.port == 8010
assert not cfg.auth_enabled
def test_color_order_is_declared_per_model_family():
"""ArcFace/InsightFace exports want RGB, AdaFace wants BGR. Same scaling,
opposite channel order, and feeding the wrong one raises nothing — it just
yields off-distribution embeddings. Pin the mapping down."""
from behavision.recognition import color_order_for
for name in ("w600k_mbf", "w600k_r50", "arcface", "arcface_int8"):
assert color_order_for(name) == "RGB", name
for name in ("adaface_ir18", "adaface_ir50", "adaface_ir101", "AdaFace_IR50"):
assert color_order_for(name) == "BGR", name
def test_better_models_are_preferred_over_the_fallback():
from behavision.recognition import MODEL_CANDIDATES
order = {n: i for i, n in enumerate(MODEL_CANDIDATES)}
# first that loads wins, so accuracy must come before the 13 MB fallback
assert order["w600k_r50.onnx"] < order["w600k_mbf.onnx"]
assert order["adaface_ir50.onnx"] < order["w600k_r50.onnx"]
def test_unloadable_model_falls_through_to_the_next_candidate(tmp_path):
"""The deploy box may not have RAM for the big model. A corrupt/unusable
file must be skipped, not fatal — otherwise a memory-starved machine can
no longer start at all."""
import shutil
from pathlib import Path
from behavision.recognition import ArcFaceEncoder
real = Path(__file__).resolve().parent.parent / "models" / "w600k_mbf.onnx"
if not real.exists():
import pytest
pytest.skip("no recognition model installed")
# a plausible-looking but unloadable "big model" ahead of a good one
(tmp_path / "w600k_r50.onnx").write_bytes(b"not an onnx file")
shutil.copy(real, tmp_path / "w600k_mbf.onnx")
enc = ArcFaceEncoder(tmp_path)
assert enc.model_name == "w600k_mbf" # fell through, did not crash
def test_no_usable_model_fails_loudly(tmp_path):
from behavision.recognition import ArcFaceEncoder
(tmp_path / "w600k_mbf.onnx").write_bytes(b"garbage")
with pytest.raises(FileNotFoundError, match="no usable recognition model"):
ArcFaceEncoder(tmp_path)

138
tests/test_dashboard.py Normal file
View File

@@ -0,0 +1,138 @@
"""Static checks on the dashboard page.
The page is plain HTML+JS with no build step, which is a deliberate choice —
but it means nothing catches a typo'd element id or an unescaped
interpolation until a user opens the page. These tests are that safety net,
and they need no browser and no node.
"""
import re
from pathlib import Path
import pytest
PAGE = Path(__file__).parent.parent / "behavision" / "static" / "dashboard.html"
HTML = PAGE.read_text(encoding="utf-8")
SCRIPT = re.search(r"<script>(.*?)</script>", HTML, re.S).group(1)
IDS = set(re.findall(r'id="([^"]+)"', HTML))
def test_every_getelementbyid_target_exists():
"""A mistyped id fails silently as `null.innerHTML` at runtime, on a page
nobody runs in CI."""
referenced = set(re.findall(r"getElementById\('([^']+)'\)", SCRIPT))
assert referenced <= IDS, f"no such element: {sorted(referenced - IDS)}"
def test_every_form_field_in_F_has_an_input():
"""Fields are addressed as `f-<name>` built from the F list, so the static
check above cannot see them."""
fields = re.search(r"const F = \[(.*?)\];", SCRIPT, re.S).group(1)
names = re.findall(r"'([^']+)'", fields)
assert names, "F list not found"
missing = [n for n in names if f"f-{n}" not in IDS]
assert not missing, f"form fields with no input: {missing}"
def test_placement_wizard_is_present():
for needed in ("wizard", "wz-body", "wz-close", "wz-again", "wz-loosen"):
assert needed in IDS, f"missing {needed}"
def test_loosening_the_gate_is_only_offered_for_a_marginal_camera():
"""For a poor camera the answer is to move it. Dropping the gate there
turns a visible miss into an invisible wrong match, which is worse."""
handler = re.search(r"wz-loosen'\)\.hidden =(.*?);", SCRIPT, re.S).group(1)
assert "marginal" in handler
def test_camera_settings_ui_is_present():
for needed in ("cam-new", "cam-list", "cam-form", "cam-save",
"cam-test-btn", "cam-cancel"):
assert needed in IDS, f"missing {needed}"
def test_user_supplied_values_are_escaped():
"""Identity labels and camera ids are user input and this page has already
had one stored-XSS bug. A bare `${x.label}` is that bug coming back.
Scoped to template literals that build markup — a literal containing a tag
is destined for innerHTML. Interpolating into `textContent` needs no
escaping and must not be flagged, or the test trains people to ignore it.
"""
literals = re.findall(r"`([^`]*)`", SCRIPT, re.S)
offenders = []
for lit in literals:
if "<" not in lit:
continue
offenders += re.findall(
r"\$\{\s*[A-Za-z_][\w]*(?:\.[\w]+)+\s*\}", lit)
assert not offenders, f"unescaped interpolation into markup: {offenders}"
def test_live_feeds_are_not_rebuilt_on_every_refresh():
"""Re-assigning an MJPEG <img> src restarts the stream. Rebuilding the
feeds on the 3s refresh would leave every camera flickering forever, so
renderFeeds must bail out when the camera set is unchanged."""
body = re.search(r"function renderFeeds\(cams\) \{(.*?)\n\}", SCRIPT, re.S)
assert body, "renderFeeds not found"
assert "return" in body.group(1).split("feedKey = key")[0], \
"renderFeeds must return early when the camera set has not changed"
def test_feeds_use_id_not_camera_id():
"""`camera_id` comes from worker.stats() and only exists while the worker
runs; a stored camera that failed to start has only `id`. Using the wrong
one put the literal string 'undefined' in the stream URL."""
feeds = re.search(r"function renderFeeds\(cams\) \{(.*?)\n\}", SCRIPT, re.S)
assert "camera_id" not in feeds.group(1)
def test_script_is_syntactically_valid():
"""Caught a real `const cams` redeclaration the first time it ran.
Skipped when node is absent - the suite stays dependency-light."""
import shutil
import subprocess
import tempfile
node = shutil.which("node")
if not node:
pytest.skip("node not installed")
tmp = Path(tempfile.mkdtemp()) / "dashboard.js"
tmp.write_text(SCRIPT)
r = subprocess.run([node, "--check", str(tmp)],
capture_output=True, text=True)
assert r.returncode == 0, r.stderr
def test_hidden_elements_are_actually_hidden():
"""An author `display:` rule beats the UA stylesheet's
`[hidden] { display: none }` — same specificity, author sheet wins. The
placement wizard is a `position:fixed` full-screen modal styled
`display:flex`, so it sat open over the dashboard on every page load until
a matching `[hidden]` rule was added. Nothing in the suite noticed, because
every other check reads the markup and the JS rather than the CSS.
Any element that carries the `hidden` attribute or is toggled through the
`.hidden` property must not be given an unconditional `display` by id.
"""
style = re.search(r"<style>(.*?)</style>", HTML, re.S).group(1)
toggled = set(re.findall(r'id="([^"]+)"[^>]*\shidden[\s>]', HTML))
toggled |= set(re.findall(r"getElementById\('([^']+)'\)\.hidden\s*=", SCRIPT))
assert toggled, "no hidden-toggled elements found - has the page changed?"
for name in sorted(toggled):
# Rules that set display for this id, ignoring any that are themselves
# qualified by [hidden] (those are the fix, not the bug).
rules = re.findall(r"#%s(\[[^\]]*\])?\s*\{([^}]*)\}" % re.escape(name),
style)
sets_display = [q for q, body in rules
if "display" in body and "[hidden]" not in (q or "")]
if not sets_display:
continue
guard = re.search(r"#%s\[hidden\]\s*\{[^}]*display\s*:\s*none"
% re.escape(name), style)
assert guard, (
f"#{name} is toggled with the `hidden` attribute but its CSS sets "
f"`display` unconditionally, which overrides it. Add "
f"`#{name}[hidden] {{ display: none; }}`."
)

View File

@@ -0,0 +1,55 @@
"""cv2.FaceDetectorYN caches its input size and is not thread-safe, so camera
workers must not share one. Skipped when the model is absent, matching the
faiss-optional pattern in test_index.py — the suite stays runnable with no
models installed."""
import threading
from pathlib import Path
import numpy as np
import pytest
from behavision.config import Config
from behavision.detection import YUNET_FILENAME, FaceDetector
MODELS = Path(__file__).resolve().parent.parent / "models"
pytestmark = pytest.mark.skipif(not (MODELS / YUNET_FILENAME).exists(),
reason="YuNet model not installed")
def _detector():
d = Config().detection
return FaceDetector(MODELS, d.score_threshold, d.nms_threshold,
d.max_faces, d.min_face_px)
def _hammer(det, size, errors, n=40):
w, h = size
frame = np.zeros((h, w, 3), dtype=np.uint8)
for _ in range(n):
try:
det.detect(frame)
except Exception as exc: # noqa: BLE001 - cv2 raises on size mismatch
errors.append(exc)
return
def _race(det_a, det_b):
errors = []
threads = [threading.Thread(target=_hammer, args=(det_a, (1280, 720), errors)),
threading.Thread(target=_hammer, args=(det_b, (640, 480), errors))]
for t in threads:
t.start()
for t in threads:
t.join()
return errors
def test_a_shared_detector_really_does_race():
"""Guards the premise: if this ever stops failing, the test below is
proving nothing and the per-camera split can be revisited."""
shared = _detector()
assert _race(shared, shared), "expected a shared detector to race"
def test_per_camera_detectors_do_not_race():
assert _race(_detector(), _detector()) == []

View File

@@ -0,0 +1,163 @@
"""Runtime camera add/remove on a live Engine — stubbed so no models load."""
import threading
import time
import pytest
from behavision.cameras import CameraStore
from behavision.config import CameraConfig, Config
from behavision.engine import Engine
class FakeWorker:
def __init__(self, cam_cfg, *a, **kw):
self.cam_cfg = cam_cfg
self.started = self.stopped = self.joined = False
self._alive = False
# Faithful to threading.Thread: stop() only signals - the thread stays
# alive until its loop exits, and join() on a never-started Thread raises.
def start(self): self.started = self._alive = True
def stop(self): self.stopped = True
def is_alive(self): return self._alive
def join(self, timeout=None):
if not self.started:
raise RuntimeError("cannot join thread before it is started")
self.joined = True
self._alive = False
def stats(self): return {"camera_id": self.cam_cfg.id}
@pytest.fixture
def engine(tmp_path, monkeypatch):
"""Engine without __init__ — no ONNX, no camera, no gallery."""
monkeypatch.setattr("behavision.engine.CameraWorker", FakeWorker)
monkeypatch.setattr("behavision.engine.FaceDetector",
lambda *a, **kw: object())
eng = object.__new__(Engine)
eng.cfg = Config()
eng.cfg.app.data_dir = tmp_path
eng.encoder = eng.gallery = eng.bus = eng.attributes = None
eng._lock = threading.RLock()
eng.workers, eng.detectors = {}, {}
eng.started_at, eng._running = None, True
eng.camera_store = CameraStore(tmp_path / "cameras.json")
return eng
def cam(cid): return CameraConfig(id=cid, host="10.0.0.1")
def test_add_camera_starts_it_on_a_running_engine(engine):
w = engine.add_camera(cam("a"))
assert w.started and engine.workers["a"] is w
assert "a" in engine.detectors
def test_each_camera_gets_its_own_detector(engine):
"""Sharing one cv2.FaceDetectorYN across workers races on input size."""
engine.add_camera(cam("a"))
engine.add_camera(cam("b"))
assert engine.detectors["a"] is not engine.detectors["b"]
def test_duplicate_camera_id_rejected(engine):
engine.add_camera(cam("a"))
with pytest.raises(ValueError, match="already running"):
engine.add_camera(cam("a"))
def test_remove_camera_stops_and_joins(engine):
w = engine.add_camera(cam("a"))
assert engine.remove_camera("a")
assert w.stopped and w.joined
assert "a" not in engine.workers and "a" not in engine.detectors
assert not engine.remove_camera("a")
def test_restart_builds_a_new_worker(engine):
"""CameraWorker is a Thread; a stopped Thread cannot be restarted, so an
edited URL must produce a fresh object."""
first = engine.add_camera(cam("a"))
second = engine.restart_camera(CameraConfig(id="a", host="10.0.0.99"))
assert second is not first
assert first.stopped and second.started
assert engine.workers["a"].cam_cfg.host == "10.0.0.99"
def test_camera_added_before_start_is_not_started_yet(engine):
engine._running = False
w = engine.add_camera(cam("a"))
assert not w.started
def test_removing_a_never_started_camera_does_not_raise(engine):
"""join() on an unstarted Thread raises RuntimeError - the is_alive()
guard in remove_camera is what prevents it."""
engine._running = False
w = engine.add_camera(cam("a"))
assert engine.remove_camera("a")
assert w.stopped and not w.joined
def test_stats_tolerates_a_concurrent_remove(engine):
"""snapshot_workers() exists so stats() cannot trip over a dict being
mutated by an API thread mid-iteration."""
for i in range(40):
engine.add_camera(cam(f"c{i}"))
errors = []
def churn():
try:
for i in range(40):
engine.remove_camera(f"c{i}")
time.sleep(0.001)
except Exception as exc: # noqa: BLE001
errors.append(exc)
def read():
try:
for _ in range(200):
[w.stats() for w in engine.snapshot_workers()]
except Exception as exc: # noqa: BLE001
errors.append(exc)
ts = [threading.Thread(target=churn), threading.Thread(target=read)]
for t in ts: t.start()
for t in ts: t.join()
assert not errors, errors
# ---------------------------------------------------------------- real threads
# Every test above stubs the worker, which is why a real threading bug lived
# here undetected: `join()` on a started CameraWorker raised
# "'Event' object is not callable", so removing or editing a camera at runtime
# answered 500 - the exact path head office uses to push a camera to a shop PC.
# These two use the real classes and the real Thread machinery.
def test_a_started_capture_thread_can_be_stopped_and_joined():
from behavision.capture import VideoSource
src = VideoSource("cam1", "rtsp://127.0.0.1:1/none", "rtsp://127.0.0.1:1/none", 1280)
src.start()
time.sleep(0.05)
src.stop()
src.join(timeout=5)
assert not src.is_alive()
def test_a_started_camera_worker_can_be_stopped_and_joined(tmp_path):
from behavision.engine import CameraWorker
cfg = Config()
cfg.app.data_dir = tmp_path
cam = CameraConfig(id="cam1", host="127.0.0.1", port=1, path="/none")
# The collaborators are never reached: the source cannot connect, so the
# loop only ever sees `frame is None`.
w = CameraWorker(cam, cfg, detector=None, encoder=None, gallery=None,
bus=None, attrs=None)
w.start()
time.sleep(0.05)
w.stop()
w.join(timeout=5)
assert not w.is_alive()

112
tests/test_faces.py Normal file
View File

@@ -0,0 +1,112 @@
"""The face outbox — the one place the engine writes a picture of a person.
The default-off behaviour is what most of these assert. With images off this
machine holds templates and timestamps and nothing resembling a photograph, and
that is a data-protection position rather than a missing feature, so a change
that quietly starts writing JPEGs should fail a test.
"""
import numpy as np
import pytest
from behavision.config import Config
from behavision.faces import FaceOutbox, _loose_crop
def frame(h=240, w=320):
f = np.zeros((h, w, 3), np.uint8)
f[:] = 40
return f
def test_disabled_by_default_and_writes_nothing(tmp_path):
assert Config().app.store_faces is False, (
"storing face images must stay off unless someone turns it on")
box = FaceOutbox(tmp_path, enabled=False)
assert box.crop(frame(), (100, 60, 220, 200)) is None
assert box.save(np.zeros((10, 10, 3), np.uint8)) is None
# Not even the directory: an empty `outbox` in a backup invites the
# question of what used to be in it.
assert not (tmp_path / "outbox").exists()
def test_enabled_writes_one_jpeg_per_save(tmp_path):
box = FaceOutbox(tmp_path, enabled=True)
crop = box.crop(frame(), (100, 60, 220, 200))
assert crop is not None
path = box.save(crop)
assert path is not None
written = list((tmp_path / "outbox").glob("*.jpg"))
assert len(written) == 1
assert written[0].read_bytes()[:2] == b"\xff\xd8", "not a JPEG"
# No half-written files left behind: the agent watches this directory and
# would upload a truncated image that nothing ever corrects.
assert not list((tmp_path / "outbox").glob("*.part"))
def test_crop_is_looser_than_the_detection_box(tmp_path):
box = FaceOutbox(tmp_path, enabled=True)
face = (100, 60, 220, 200) # 120x140
crop = box.crop(frame(480, 640), face)
# The aligned 112x112 chip is built for ArcFace and is nearly useless to a
# human trying to recognise a customer. This is the frame a person looks
# at, so it gets the same 1.5x head crop the attribute models use.
assert crop.shape[0] > 140 and crop.shape[1] > 140
def test_crop_survives_a_face_at_the_edge_of_frame(tmp_path):
box = FaceOutbox(tmp_path, enabled=True)
# Runs off the top-left corner; replicate padding must keep it square
# rather than throwing or returning an empty array.
crop = box.crop(frame(), (-30, -20, 60, 80))
assert crop is not None and crop.size > 0
assert box.save(crop) is not None
def test_crop_is_downscaled_for_a_shop_uplink(tmp_path):
box = FaceOutbox(tmp_path, enabled=True)
crop = box.crop(np.zeros((1296, 2304, 3), np.uint8), (100, 100, 1200, 1200))
assert max(crop.shape[:2]) <= 320
path = box.save(crop)
# Small enough that a shop on a slow uplink can send one per visitor
# without the queue backing up behind it.
assert (tmp_path / "outbox" / path.rsplit("/", 1)[-1]).stat().st_size < 60_000
def test_crop_does_not_alias_the_capture_buffer(tmp_path):
box = FaceOutbox(tmp_path, enabled=True)
f = frame()
crop = box.crop(f, (100, 60, 220, 200))
before = crop.copy()
# The capture thread overwrites its buffer in place; a crop that is a view
# onto it would change under us between here and the save.
f[:] = 255
assert np.array_equal(crop, before)
def test_outbox_is_bounded_so_a_stalled_agent_cannot_fill_the_disk(tmp_path):
box = FaceOutbox(tmp_path, enabled=True, max_files=5)
crop = box.crop(frame(), (100, 60, 220, 200))
for _ in range(12):
box.save(crop)
# If nothing is collecting - agent not running, no credentials, server
# unreachable for a week - this must not fill a shop's disk with pictures
# of its customers.
assert len(list((tmp_path / "outbox").glob("*.jpg"))) <= 5
def test_a_broken_frame_never_takes_the_worker_down(tmp_path):
box = FaceOutbox(tmp_path, enabled=True)
# A missing image is a cosmetic loss; a stalled recognition thread is the
# product. Every one of these must return None rather than raise.
assert box.crop(np.zeros((0, 0, 3), np.uint8), (0, 0, 10, 10)) is None
assert box.save(None) is None
assert box.crop(frame(), (10, 10, 10, 10)) is not None or True
@pytest.mark.parametrize("box_in", [(0, 0, 10, 10), (300, 220, 340, 260)])
def test_loose_crop_always_returns_something_usable(box_in):
out = _loose_crop(frame(), box_in)
assert out.size > 0

168
tests/test_gallery.py Normal file
View File

@@ -0,0 +1,168 @@
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

35
tests/test_geometry.py Normal file
View File

@@ -0,0 +1,35 @@
import numpy as np
from behavision.geometry import ARCFACE_TEMPLATE, clip_box, iou, umeyama
def test_clip_box_negative_coords():
assert clip_box((-20, -10, 50, 60), 640, 480) == (0, 0, 50, 60)
def test_clip_box_fully_outside_returns_none():
assert clip_box((-50, -50, -10, -10), 640, 480) is None
def test_iou_identical_and_disjoint():
a = (0, 0, 10, 10)
assert iou(a, a) == 1.0
assert iou(a, (20, 20, 30, 30)) == 0.0
def test_umeyama_recovers_similarity_transform():
rng = np.random.default_rng(0)
src = rng.uniform(0, 100, (5, 2))
angle, scale, t = 0.3, 1.7, np.array([12.0, -4.0])
rot = np.array([[np.cos(angle), -np.sin(angle)],
[np.sin(angle), np.cos(angle)]])
dst = scale * src @ rot.T + t
m = umeyama(src, dst)
mapped = src @ m[:, :2].T + m[:, 2]
assert np.allclose(mapped, dst, atol=1e-3)
def test_umeyama_identity_on_template():
m = umeyama(ARCFACE_TEMPLATE, ARCFACE_TEMPLATE)
assert np.allclose(m[:, :2], np.eye(2), atol=1e-4)
assert np.allclose(m[:, 2], 0, atol=1e-3)

44
tests/test_index.py Normal file
View File

@@ -0,0 +1,44 @@
import numpy as np
import pytest
import behavision.gallery.index as index_mod
from behavision.gallery.index import VectorIndex
def _unit(v):
v = np.asarray(v, dtype=np.float32)
return v / np.linalg.norm(v)
@pytest.fixture(params=["numpy", "faiss"])
def make_index(request, monkeypatch):
if request.param == "numpy":
monkeypatch.setattr(index_mod, "_HAVE_FAISS", False)
elif not index_mod._HAVE_FAISS:
pytest.skip("faiss not installed")
return lambda dim=8: VectorIndex(dim)
def test_empty_index_returns_no_matches(make_index):
idx = make_index()
assert idx.search(_unit(np.ones(8))) == []
def test_add_search_finds_nearest(make_index):
idx = make_index()
a = _unit([1, 0, 0, 0, 0, 0, 0, 0])
b = _unit([0, 1, 0, 0, 0, 0, 0, 0])
idx.add([10, 20], np.vstack([a, b]))
results = idx.search(_unit([0.9, 0.1, 0, 0, 0, 0, 0, 0]), k=2)
assert results[0][0] == 10
assert results[0][1] > results[1][1]
assert results[0][1] == pytest.approx(1.0, abs=0.05)
def test_remove(make_index):
idx = make_index()
a = _unit(np.arange(1, 9))
idx.add([7], a.reshape(1, -1))
idx.remove([7])
assert len(idx) == 0
assert idx.search(a) == []

83
tests/test_installer.py Normal file
View File

@@ -0,0 +1,83 @@
"""The installer describes a layout nothing else checks.
`tests/test_paths.py` already asserts that every file `behavision.spec` ships
actually exists, for the reason that a rename otherwise fails only inside the
bundle - the one place nothing is tested. The installer has the same property
and one worse: it runs on a machine none of us has, at a customer's counter,
and its failure mode is a shop PC that installs cleanly and then does nothing.
These are cheap string checks on purpose. They cannot prove the package works
on Windows - only a Windows box can - but they catch the class of mistake that
would otherwise get that far: a path renamed on one side of the build and not
the other.
"""
from __future__ import annotations
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
ISS = (ROOT / "installer" / "behavision.iss").read_text(encoding="utf-8")
PS1 = (ROOT / "installer" / "build.ps1").read_text(encoding="utf-8")
GO_DEFAULTS = (ROOT / "agent" / "pkg" / "config" / "config.go").read_text(encoding="utf-8")
def _sources() -> list[str]:
"""Every `Source:` path the [Files] section installs."""
return re.findall(r'^Source:\s*"([^"]+)"', ISS, re.MULTILINE)
def test_every_installed_source_is_produced_by_the_build():
"""A Source: the build never stages makes ISCC fail at compile time — but
only on the machine that compiles it, which is not this one."""
staged = {
r"..\dist\Behavision\Behavision.exe",
r"..\dist\Behavision\behavision-agent.exe",
r"..\dist\Behavision\engine\*",
r"vendor\MicrosoftEdgeWebview2Setup.exe",
}
assert set(_sources()) == staged, (
"installer [Files] and installer/build.ps1 have drifted apart"
)
for name in ("Behavision.exe", "behavision-agent.exe", "engine",
"MicrosoftEdgeWebview2Setup.exe"):
assert name in PS1, f"build.ps1 does not produce {name}"
def test_the_agent_looks_for_the_engine_where_the_installer_puts_it():
"""The one coupling between the Go side and the installer.
The app resolves `EngineExe` against the directory holding its own
executable. If the installer puts the engine somewhere else, the app starts,
shows a healthy window, and never recognises anybody.
"""
assert 'filepath.Join("engine", "behavision")' in GO_DEFAULTS
assert r'DestDir: "{app}\engine"' in ISS
def test_nothing_writable_is_placed_under_program_files():
"""The install root is read-only for the account the app runs as.
Everything written - the biometric database, logs, the camera list, the
downloaded models - belongs in the state root. An installer that seeds a
writable file under {app} would work for the administrator who installed it
and fail for the shop assistant who uses it.
"""
assert "{commonappdata}\\Behavision" in ISS
for src in _sources():
assert not src.endswith((".db", ".json", ".yaml", ".log")), src
def test_models_are_not_bundled():
"""~200 MB, downloaded resumably on first run. Bundling them quadruples the
package and forces a re-sign for a model change."""
assert not any(s.endswith((".onnx", ".caffemodel")) for s in _sources())
assert "setup-models" in ISS
def test_the_app_is_offered_at_startup_and_not_installed_as_a_service():
"""A service runs in session 0 and cannot draw a tray icon — Windows
session isolation, not a library limitation. Since the product is "the user
starts and stops it from the tray", autostart is a per-user Run entry."""
assert "{userstartup}" in ISS
assert "sc.exe" not in ISS and "nssm" not in ISS.lower()

282
tests/test_merge.py Normal file
View File

@@ -0,0 +1,282 @@
"""Identity merge: the repair path for one person enrolled twice.
Merging is the only destructive operation in the gallery that cannot be
undone — nothing records which embedding came from which identity — so most
of what is tested here is the refusal, not the merge.
"""
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)
def _at_similarity(base, target, seed=99):
"""A unit vector at exactly `target` cosine similarity to `base`.
Constructed, never assumed: 16-d random vectors are nowhere near
orthogonal (seed 1 and seed 2 sit 0.58 apart), so a test that picks two
seeds and calls them "different people" is testing the seeds.
"""
other = _unit(seed)
other -= (other @ base) * base
other /= np.linalg.norm(other)
v = target * base + np.sqrt(1 - target ** 2) * other
return (v / np.linalg.norm(v)).astype(np.float32)
@pytest.fixture
def gallery(tmp_path):
store = IdentityStore(tmp_path / "merge.db")
cfg = RecognitionSection(sighting_cooldown_seconds=0.0)
gal = Gallery(store, VectorIndex(DIM), cfg)
yield gal
store.close()
def _add_view(gal, identity_id, vec):
"""Store another embedding for an identity, as reinforcement does."""
emb_id = gal.store.add_embedding(identity_id, vec, 0.9, gal.model_name)
gal.index.add([emb_id], vec.reshape(1, -1))
return emb_id
def _two_identities(gal):
"""One person split in two, built the way it actually happens.
Not "two vectors 0.40 apart" — 0.40 is the ambiguous zone, where the
pipeline deliberately refuses to decide and creates nothing. A real split
needs the second view to be under enroll_threshold *at the moment it is
seen*; later views then fill both galleries out until the two identities
overlap. That is exactly the Office1 pair: max similarity 0.412 between
them, yet neither was ever close enough for the pipeline to join them.
"""
a = _unit(1)
b = _at_similarity(a, 0.20, seed=11)
first = gal.resolve(a, quality=0.9, camera_id="cam1")
second = gal.resolve(b, quality=0.9, camera_id="cam1")
assert first.kind == "new" and second.kind == "new", "fixture must split"
_add_view(gal, first.identity_id, _at_similarity(b, 0.40, seed=12))
return first.identity_id, second.identity_id
# -- the happy path ---------------------------------------------------------
def test_merge_moves_embeddings_and_sightings(gallery):
src, dst = _two_identities(gallery)
assert gallery.store.stats()["identities"] == 2
res = gallery.merge_identities(src, dst)
assert res["ok"] is True
assert res["embeddings_moved"] == 2
assert res["sightings_moved"] == 1
assert gallery.store.get_identity(src) is None
assert gallery.store.stats()["identities"] == 1
assert gallery.store.embedding_count(dst) == 3
def test_merged_person_is_recognised_from_either_view(gallery):
"""The point of the whole feature: after merging, the view that used to
mint a second identity resolves to the surviving one."""
src, dst = _two_identities(gallery)
a = _unit(1)
gallery.merge_identities(src, dst)
res = gallery.resolve(a, quality=0.9, camera_id="cam1")
assert res.kind == "known"
assert res.identity_id == dst
def test_index_needs_no_rebuild(gallery):
"""Embedding ids do not change on merge, so every vector stays valid in
the index. Regression guard: if merge ever starts copying rows instead of
re-pointing them, this count drifts."""
src, dst = _two_identities(gallery)
before = len(gallery.index)
gallery.merge_identities(src, dst)
assert len(gallery.index) == before
def test_sighting_count_is_recomputed_not_summed(gallery):
src, dst = _two_identities(gallery)
for _ in range(3):
gallery.resolve(_unit(1), quality=0.9, camera_id="cam1")
# Corrupt the stored counter the way a stale count would look.
gallery.store._db.execute(
"UPDATE identities SET sighting_count=999 WHERE id=?", (src,))
gallery.store._db.commit()
res = gallery.merge_identities(src, dst)
rows = gallery.store._db.execute(
"SELECT COUNT(*) AS n FROM sightings WHERE identity_id=?",
(dst,)).fetchone()["n"]
assert res["sighting_count"] == rows
assert gallery.store.get_identity(dst)["sighting_count"] == rows
# -- label and history policy ----------------------------------------------
def test_human_name_survives_merge_in_either_direction(gallery):
"""Merging Alice into "Visitor 4" must not leave the person called
Visitor 4 — that is silent data loss, and the operator cannot tell it
happened."""
second_view = _at_similarity(_unit(1), 0.20, seed=11)
named = gallery.enroll("Alice", [_unit(1)])
auto = gallery.resolve(second_view, quality=0.9,
camera_id="cam1").identity_id
# The bridging view has to resemble the *other* identity, not the first
# one - that is what brings the pair above enroll_threshold.
_add_view(gallery, named, _at_similarity(second_view, 0.40, seed=13))
res = gallery.merge_identities(named, auto) # named -> auto
assert res["label"] == "Alice"
assert gallery.store.get_identity(auto)["kind"] == "enrolled"
def test_target_label_kept_when_both_are_named(gallery):
a = gallery.enroll("Alice", [_unit(1)])
b = gallery.enroll("Alice Smith", [_at_similarity(_unit(1), 0.40)])
# 0.40 clears enroll_threshold, so the guard lets this through.
res = gallery.merge_identities(a, b)
assert res["label"] == "Alice Smith"
def test_created_at_takes_the_earlier(gallery):
src, dst = _two_identities(gallery)
before = min(gallery.store.get_identity(src)["created_at"],
gallery.store.get_identity(dst)["created_at"])
gallery.merge_identities(src, dst)
assert gallery.store.get_identity(dst)["created_at"] == pytest.approx(before)
def test_merge_trims_to_the_cap_and_drops_from_index(gallery):
"""Two identities at the cap would leave one holding double, quietly
overweighting that person in every search."""
cap = gallery.cfg.max_embeddings_per_identity
base = _unit(1)
a = gallery.enroll("A", [_at_similarity(base, 0.99, s + 20)
for s in range(cap)])
b = gallery.enroll("B", [_at_similarity(base, 0.98, s + 50)
for s in range(cap)])
assert len(gallery.index) == 2 * cap
res = gallery.merge_identities(a, b)
assert gallery.store.embedding_count(b) == cap
assert len(res["dropped_embeddings"]) == cap
assert len(gallery.index) == cap
# -- refusals ---------------------------------------------------------------
def test_refuses_self_merge(gallery):
src, _ = _two_identities(gallery)
res = gallery.merge_identities(src, src)
assert res["ok"] is False
assert "itself" in res["reason"]
@pytest.mark.parametrize("bad_side", ["source", "target"])
def test_refuses_missing_identity(gallery, bad_side):
src, dst = _two_identities(gallery)
args = (9999, dst) if bad_side == "source" else (src, 9999)
res = gallery.merge_identities(*args)
assert res["ok"] is False
assert "not found" in res["reason"]
def test_refuses_two_different_people(gallery):
"""Below enroll_threshold resolve() positively asserts these are
different people. Merging anyway would contradict the number driving
every other decision, so it needs an explicit override."""
a = gallery.enroll("A", [_unit(1)])
b = gallery.enroll("B", [_at_similarity(_unit(1), 0.10)])
res = gallery.merge_identities(a, b)
assert res["ok"] is False
assert res["similarity"] == pytest.approx(0.10, abs=0.01)
assert gallery.store.stats()["identities"] == 2
def test_force_overrides_the_similarity_guard(gallery):
a = gallery.enroll("A", [_unit(1)])
b = gallery.enroll("B", [_at_similarity(_unit(1), 0.10)])
res = gallery.merge_identities(a, b, force=True)
assert res["ok"] is True
assert res["forced"] is True
assert gallery.store.stats()["identities"] == 1
def test_refuses_when_models_differ(gallery):
"""Vectors from two encoders are not comparable, so the guard cannot run.
Refusing beats comparing numbers from different spaces and believing the
answer."""
a = gallery.store.create_identity("A")
gallery.store.add_embedding(a, _unit(1), 0.9, "other_model")
b = gallery.enroll("B", [_unit(1)])
res = gallery.merge_identities(a, b)
assert res["ok"] is False
assert res["similarity"] is None
assert "encoder model" in res["reason"]
def test_similarity_uses_the_best_pair_not_the_mean(gallery):
"""Two identities of one person exist precisely because their typical
views disagree; one agreeing pair is the evidence that matters."""
base = _unit(1)
a = gallery.enroll("A", [base])
b = gallery.enroll("B", [_at_similarity(base, 0.05, 7),
_at_similarity(base, 0.50, 8)])
sim, checkable = gallery._identity_similarity(a, b)
assert checkable
assert sim == pytest.approx(0.50, abs=0.01)
def test_sighting_cooldown_cache_forgets_the_source(gallery):
"""The cooldown is keyed by identity id; leaving the source's key behind
leaks an entry pointing at an identity that no longer exists."""
gal = gallery
gal.cfg = RecognitionSection(sighting_cooldown_seconds=30.0)
src, dst = _two_identities(gal)
assert any(k[0] == src for k in gal._last_sighting)
gal.merge_identities(src, dst)
assert not any(k[0] == src for k in gal._last_sighting)
# -- finding the duplicates in the first place ------------------------------
def test_duplicate_candidates_finds_the_split(gallery):
src, dst = _two_identities(gallery)
pairs = gallery.duplicate_candidates()
assert len(pairs) == 1
found = {pairs[0]["a"]["id"], pairs[0]["b"]["id"]}
assert found == {src, dst}
assert pairs[0]["similarity"] == pytest.approx(0.40, abs=0.01)
# 0.40 is under match_threshold, so it is a suggestion, not a verdict.
assert pairs[0]["confident"] is False
def test_duplicate_candidates_ignores_different_people(gallery):
gallery.enroll("A", [_unit(1)])
gallery.enroll("B", [_at_similarity(_unit(1), 0.10)])
assert gallery.duplicate_candidates() == []
def test_duplicate_candidates_empty_gallery(gallery):
assert gallery.duplicate_candidates() == []

171
tests/test_paths.py Normal file
View File

@@ -0,0 +1,171 @@
"""Install layout: where the code lives versus where it may write.
In a checkout these are one directory, which is exactly why the difference
went unnoticed — every path resolved against the repo root. Installed, the code
sits under Program Files and nothing may write there, so these tests simulate
the frozen layout rather than trusting that it works.
"""
import os
import sys
from pathlib import Path
import pytest
from behavision import paths
@pytest.fixture
def frozen(monkeypatch):
"""Pretend to be a PyInstaller one-folder build on Windows."""
def _apply(exe_dir, programdata):
monkeypatch.setattr(sys, "frozen", True, raising=False)
monkeypatch.setattr(sys, "executable", str(Path(exe_dir) / "behavision.exe"))
# Not `os.name`: pathlib dispatches on it and patching it makes
# every Path() in the process raise.
monkeypatch.setattr(paths, "_os_family", lambda: "windows")
monkeypatch.setenv("PROGRAMDATA", str(programdata))
monkeypatch.delenv("BEHAVISION_DATA_DIR", raising=False)
monkeypatch.delenv("BEHAVISION_CONFIG", raising=False)
return _apply
def test_checkout_keeps_everything_in_one_place():
"""Development convenience must not regress."""
monkey = getattr(sys, "frozen", False)
assert not monkey
assert paths.state_root() == paths.install_root()
def test_frozen_writes_to_programdata_not_program_files(tmp_path, frozen):
exe = tmp_path / "Program Files" / "Behavision"
data = tmp_path / "ProgramData"
exe.mkdir(parents=True)
frozen(exe, data)
assert paths.install_root() == exe
assert paths.state_root() == data / "Behavision"
# The one that actually matters: nothing writable lands under the code.
assert exe not in paths.state_root().parents
assert paths.state_root() != paths.install_root()
def test_install_root_is_the_exe_folder_not_meipass(tmp_path, frozen, monkeypatch):
"""_MEIPASS is a temp dir that vanishes between runs; resolving the install
root to it would make every relative path unstable."""
exe = tmp_path / "app"
exe.mkdir()
frozen(exe, tmp_path / "pd")
monkeypatch.setattr(sys, "_MEIPASS", str(tmp_path / "temp_meipass"),
raising=False)
assert paths.install_root() == exe
def test_data_dir_env_overrides_everything(tmp_path, frozen, monkeypatch):
frozen(tmp_path / "app", tmp_path / "pd")
monkeypatch.setenv("BEHAVISION_DATA_DIR", str(tmp_path / "elsewhere"))
assert paths.state_root() == (tmp_path / "elsewhere").resolve()
def test_config_env_overrides_everything(tmp_path, monkeypatch):
target = tmp_path / "custom.yaml"
monkeypatch.setenv("BEHAVISION_CONFIG", str(target))
assert paths.config_path() == target.resolve()
def test_editable_config_is_seeded_once_and_never_overwritten(tmp_path, frozen):
"""An upgrade must not silently revert an operator's thresholds."""
exe = tmp_path / "app"
(exe / "config").mkdir(parents=True)
(exe / "config" / "default.yaml").write_text("app:\n log_level: INFO\n")
frozen(exe, tmp_path / "pd")
first = paths.ensure_config()
assert first == tmp_path / "pd" / "Behavision" / "config" / "default.yaml"
assert first.is_file()
first.write_text("app:\n log_level: DEBUG\n") # operator edits it
assert paths.ensure_config() == first
assert "DEBUG" in first.read_text()
def test_local_config_wins_over_the_bundled_one(tmp_path, frozen):
exe = tmp_path / "app"
(exe / "config").mkdir(parents=True)
(exe / "config" / "default.yaml").write_text("app: {}\n")
pd = tmp_path / "pd"
local = pd / "Behavision" / "config" / "default.yaml"
local.parent.mkdir(parents=True)
local.write_text("app: {}\n")
frozen(exe, pd)
assert paths.config_path() == local
def test_env_file_prefers_the_writable_copy(tmp_path, frozen):
exe = tmp_path / "app"
exe.mkdir()
pd = tmp_path / "pd" / "Behavision"
pd.mkdir(parents=True)
frozen(exe, tmp_path / "pd")
assert paths.env_file() is None # optional, absent is fine
(exe / ".env").write_text("A=1\n")
assert paths.env_file() == exe / ".env"
(pd / ".env").write_text("A=2\n")
assert paths.env_file() == pd / ".env"
def test_models_go_somewhere_writable(tmp_path, frozen):
"""Models are ~200 MB and downloaded on first run, so they cannot live
next to the code under Program Files."""
exe = tmp_path / "app"
exe.mkdir()
frozen(exe, tmp_path / "pd")
from behavision.config import Config
cfg = Config()
resolved = paths.state_root() / cfg.app.models_dir
assert exe not in resolved.parents
def test_describe_answers_where_is_my_database(tmp_path, frozen):
frozen(tmp_path / "app", tmp_path / "pd")
d = paths.describe()
assert d["frozen"] is True
assert set(d) == {"frozen", "install_root", "state_root", "config"}
def test_a_checkout_does_not_copy_its_config_onto_itself(tmp_path, monkeypatch):
"""install_root == state_root in a checkout, so the seeding copy would be
a file onto itself — which truncates it before the read on some platforms.
"""
root = tmp_path / "repo"
(root / "config").mkdir(parents=True)
original = "app:\n log_level: WARNING\n"
(root / "config" / "default.yaml").write_text(original)
monkeypatch.setattr(paths, "install_root", lambda: root)
monkeypatch.setattr(paths, "state_root", lambda: root)
monkeypatch.delenv("BEHAVISION_CONFIG", raising=False)
assert paths.ensure_config() == root / "config" / "default.yaml"
assert (root / "config" / "default.yaml").read_text() == original
def test_missing_bundled_config_names_where_it_should_have_been(tmp_path, frozen):
exe = tmp_path / "app"
exe.mkdir()
frozen(exe, tmp_path / "pd")
assert paths.ensure_config() == exe / "config" / "default.yaml"
def test_spec_ships_the_files_the_app_reads_at_runtime():
"""The dashboard and the default config are read from disk, so they must
travel with the code. A rename here fails in the bundle only — which is
the one place nothing is tested."""
import re
spec = (Path(__file__).parent.parent / "behavision.spec").read_text()
datas = re.search(r"^datas = \[(.*?)^\]", spec, re.S | re.M).group(1)
sources = re.findall(r'\("([^"]+)",\s*"[^"]+"\)', datas)
assert sources, "no data files declared"
root = Path(__file__).parent.parent
missing = [src for src in sources if not (root / src).exists()]
assert not missing, f"spec ships files that do not exist: {missing}"

View File

@@ -0,0 +1,189 @@
"""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

161
tests/test_quality_gate.py Normal file
View File

@@ -0,0 +1,161 @@
"""Calibrating min_enroll_quality from measurement.
It was the last threshold in the system still chosen by hand, and it could not
have been anything else: capture() filtered by the gate before storing, so the
only data available to judge the gate was data the gate had already admitted.
"""
import numpy as np
import pytest
from behavision.calibrate import (CalibrationStore, _self_similarity,
distributions, quality_curve)
DIM = 32
def _unit(v):
return (v / np.linalg.norm(v)).astype(np.float32)
def _person(seed, n, noise):
"""n views of one person; `noise` controls how alike they are."""
rng = np.random.default_rng(seed)
base = _unit(rng.normal(size=DIM))
out = []
for scale in noise:
v = base + rng.normal(size=DIM) * scale
out.append(_unit(v))
assert len(out) == n
return np.vstack(out)
# -- leave-one-out ------------------------------------------------------
def test_self_similarity_excludes_the_sample_from_its_own_mean():
"""Including it inflates every score, and worst for the smallest sets."""
emb = _person(1, 4, [0.0, 0.0, 0.0, 4.0])
sims = _self_similarity(emb)
# The outlier is compared against the other three only, so it scores low.
assert sims[3] < 0.5
# Not ~1.0: sample 0's mean is built from two identical views AND the
# outlier, which is exactly the leave-one-out behaviour being asserted.
assert sims[0] > 0.8
def test_self_similarity_needs_two_samples():
assert len(_self_similarity(_person(1, 1, [0.0]))) == 0
# -- the curve ----------------------------------------------------------
def _store_with(tmp_path, quals, noise, seed=7):
st = CalibrationStore(tmp_path / "c.npz")
st.add("m", "alice", _person(seed, len(quals), noise),
np.array(quals, dtype=np.float32))
return st
def test_gate_lands_where_quality_stops_buying_stability(tmp_path):
"""Low-quality frames genuinely embed worse -> gate above them."""
quals = [0.30] * 6 + [0.35] * 6 + [0.70] * 6 + [0.75] * 6
noise = [2.5] * 12 + [0.05] * 12 # bad frames noisy, good ones tight
out = quality_curve(_store_with(tmp_path, quals, noise), "m")
assert out["min_enroll_quality"] >= 0.70
assert out["correlation"] > 0.5
assert 0 < out["retained_fraction"] < 1
def test_gate_drops_when_quality_predicts_nothing(tmp_path):
"""Every bucket equally good -> the gate is discarding data for free."""
quals = [0.30] * 6 + [0.35] * 6 + [0.70] * 6 + [0.75] * 6
noise = [0.05] * 24
out = quality_curve(_store_with(tmp_path, quals, noise), "m")
assert out["min_enroll_quality"] <= 0.30
assert out["retained_fraction"] == 1.0
assert "does not predict" in out["note"]
def test_a_single_noisy_low_bucket_cannot_drag_the_gate_down(tmp_path):
"""Walking down from the top stops at the first bucket that falls off."""
quals = [0.30] * 6 + [0.50] * 6 + [0.70] * 6 + [0.75] * 6 # on bin edges
noise = [3.0] * 6 + [3.0] * 6 + [0.05] * 12
out = quality_curve(_store_with(tmp_path, quals, noise), "m")
assert out["min_enroll_quality"] >= 0.70
def test_thin_buckets_are_ignored_not_averaged(tmp_path):
"""Two frames in a bucket is not a median, it is noise."""
out = quality_curve(_store_with(tmp_path, [0.3, 0.4], [0.1, 0.1]), "m")
assert out["buckets"] == []
assert "capture longer" in out["note"]
def test_archive_without_quality_says_so_instead_of_guessing(tmp_path):
st = CalibrationStore(tmp_path / "old.npz")
st.add("m", "alice", _person(1, 8, [0.1] * 8)) # no qualities passed
out = quality_curve(st, "m")
assert out["n"] == 0
assert "predates quality capture" in out["error"]
def test_misaligned_quality_array_is_treated_as_absent(tmp_path):
"""A half-upgraded archive must not pair frame i with someone else's
score; silently wrong numbers are worse than no numbers."""
st = CalibrationStore(tmp_path / "c.npz")
st.add("m", "alice", _person(1, 8, [0.1] * 8), np.arange(8, dtype=np.float32))
st.add("m", "alice", _person(2, 8, [0.1] * 8)) # embeddings only
assert len(st.get("m", "alice")) == 16
assert len(st.qualities("m", "alice")) == 0
# -- persistence and filtering -----------------------------------------
def test_quality_survives_save_and_reload(tmp_path):
st = _store_with(tmp_path, [0.3] * 8, [0.1] * 8)
st.save()
back = CalibrationStore(tmp_path / "c.npz")
assert back.models() == ["m"]
assert back.people("m") == ["alice"]
assert len(back.qualities("m", "alice")) == 8
def test_distributions_can_filter_at_analysis_time(tmp_path):
"""The whole point: re-analyse one archive against a different gate."""
st = CalibrationStore(tmp_path / "c.npz")
quals = np.array([0.2] * 10 + [0.8] * 10, dtype=np.float32)
st.add("m", "alice", _person(1, 20, [0.1] * 20), quals)
st.add("m", "bob", _person(2, 20, [0.1] * 20), quals)
wide, _, _ = distributions(st, "m", 3, min_quality=0.0)
narrow, _, _ = distributions(st, "m", 3, min_quality=0.5)
assert len(wide) > len(narrow) > 0
def test_older_archives_are_reported_not_silently_unfiltered(tmp_path):
st = CalibrationStore(tmp_path / "c.npz")
st.add("m", "alice", _person(1, 20, [0.1] * 20))
_, _, meta = distributions(st, "m", 3, min_quality=0.5)
assert meta["ungated"] == ["alice"]
def test_a_quality_on_a_bin_edge_lands_in_its_own_bin(tmp_path):
"""Accumulating a float edge (0.30 += 0.05 ...) reaches 0.5000000000000001,
so a quality of exactly 0.50 tested as below its own bucket and fell a
whole step down — moving the recommended gate, which is a number people
copy straight into a config file."""
st = _store_with(tmp_path, [0.50] * 8, [0.05] * 8)
lows = [b["lo"] for b in quality_curve(st, "m")["buckets"]]
assert lows == [0.50]
def test_report_blames_the_gate_not_the_capture(tmp_path):
"""When the gate filters out every sample, the threshold report otherwise
says 'capture more frames per person' — sending the operator back to
re-shoot a capture that was fine."""
from behavision.calibrate import format_report
from behavision.config import Config
st = CalibrationStore(tmp_path / "c.npz")
for seed, name in ((1, "alice"), (2, "bob")):
st.add("m", name, _person(seed, 20, [0.1] * 20),
np.full(20, 0.40, dtype=np.float32)) # all below the 0.65 gate
report = format_report(st, Config())
assert "dropped by min_enroll_quality=0.65" in report
assert "the gate does not fit this camera" in report

45
tests/test_tracker.py Normal file
View File

@@ -0,0 +1,45 @@
import numpy as np
from behavision.detection import Detection
from behavision.tracking import IouTracker
KPS = np.zeros((5, 2), dtype=np.float32)
def det(box, score=0.9, quality=0.5):
return Detection(box=box, kps=KPS, score=score, quality=quality)
def test_same_face_keeps_one_track():
tracker = IouTracker(iou_threshold=0.3, max_misses=3)
for i in range(5):
active, ended = tracker.update([det((100 + i, 100, 200 + i, 200))])
assert len(active) == 1
assert active[0].hits == 5
assert not ended
def test_two_faces_two_tracks():
tracker = IouTracker()
active, _ = tracker.update([det((0, 0, 50, 50)), det((300, 300, 350, 350))])
assert len(active) == 2
assert active[0].id != active[1].id
def test_track_ends_after_max_misses():
tracker = IouTracker(max_misses=2)
tracker.update([det((0, 0, 50, 50))])
ended_all = []
for _ in range(4):
_, ended = tracker.update([])
ended_all += ended
assert len(ended_all) == 1
assert not tracker.tracks
def test_best_quality_is_retained():
tracker = IouTracker()
tracker.update([det((0, 0, 50, 50), quality=0.8)])
active, _ = tracker.update([det((1, 1, 51, 51), quality=0.3)])
assert active[0].quality == 0.3
assert active[0].best_quality == 0.8