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
164 lines
5.5 KiB
Python
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()
|