Files
Behavision/tests/test_engine_cameras.py
Suriyakumarvijayanayagam dad04e8cda 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
2026-09-04 11:14:18 +05:30

164 lines
5.5 KiB
Python

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