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:
226
tests/test_api_cameras.py
Normal file
226
tests/test_api_cameras.py
Normal 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
176
tests/test_api_merge.py
Normal 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
91
tests/test_attributes.py
Normal 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
122
tests/test_calibrate.py
Normal 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
119
tests/test_camera_tuning.py
Normal 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
110
tests/test_cameras.py
Normal 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
79
tests/test_capture.py
Normal 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
186
tests/test_commission.py
Normal 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
125
tests/test_config.py
Normal 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
138
tests/test_dashboard.py
Normal 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; }}`."
|
||||
)
|
||||
55
tests/test_detector_concurrency.py
Normal file
55
tests/test_detector_concurrency.py
Normal 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()) == []
|
||||
163
tests/test_engine_cameras.py
Normal file
163
tests/test_engine_cameras.py
Normal 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
112
tests/test_faces.py
Normal 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
168
tests/test_gallery.py
Normal 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
35
tests/test_geometry.py
Normal 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
44
tests/test_index.py
Normal 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
83
tests/test_installer.py
Normal 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
282
tests/test_merge.py
Normal 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
171
tests/test_paths.py
Normal 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}"
|
||||
189
tests/test_pipeline_stats.py
Normal file
189
tests/test_pipeline_stats.py
Normal 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
161
tests/test_quality_gate.py
Normal 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
45
tests/test_tracker.py
Normal 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
|
||||
Reference in New Issue
Block a user