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
80 lines
2.3 KiB
Python
80 lines
2.3 KiB
Python
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
|