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:
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()
|
||||
Reference in New Issue
Block a user