Files
Behavision/behavision/capture.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

238 lines
9.7 KiB
Python

"""Resilient video capture: RTSP (or webcam) reader thread with reconnect.
Design: one daemon thread per source holds the newest frame in a single
slot. Consumers always get the latest frame (never a backlog), and a lost
camera reconnects with exponential backoff instead of killing the pipeline.
"""
from __future__ import annotations
import logging
import os
import threading
import time
from typing import Optional
import cv2
import numpy as np
log = logging.getLogger(__name__)
# Force TCP transport and a 5s socket timeout for RTSP before OpenCV loads
# ffmpeg. UDP is the default and silently drops frames on lossy Wi-Fi.
os.environ.setdefault(
"OPENCV_FFMPEG_CAPTURE_OPTIONS", "rtsp_transport;tcp|stimeout;5000000"
)
def _tcp_reachable(source: "str | int", timeout: float
) -> "tuple[bool, str]":
"""Cheap pre-flight for an rtsp:// URL. Non-URL sources pass through."""
import socket
from urllib.parse import urlparse
if isinstance(source, int):
return True, ""
parsed = urlparse(source)
if not parsed.hostname:
return True, "" # not a form we can pre-check; let OpenCV try
port = parsed.port or (554 if parsed.scheme == "rtsp" else 80)
try:
with socket.create_connection((parsed.hostname, port), timeout):
return True, ""
except socket.timeout:
return False, (f"no response from {parsed.hostname}:{port} within "
f"{timeout:.0f}s - check the IP address and that the "
f"camera is on the same network")
except OSError as exc:
return False, f"cannot reach {parsed.hostname}:{port} - {exc.strerror or exc}"
def probe_source(source: "str | int", max_width: int = 1280,
timeout: float = 12.0, connect_timeout: float = 3.0) -> dict:
"""Open a candidate camera, grab one frame, and let go.
Backs the UI's Test button, so it must answer for a *wrong* URL as
reliably as a right one: no retries, no reconnect loop, and a hard deadline
because a bad host makes cv2.VideoCapture block until FFmpeg gives up.
Returns a JPEG snapshot so the user can confirm the camera is pointing
where they think it is.
"""
import base64
# cv2.VideoCapture blocks inside the constructor while FFmpeg completes a
# TCP connect, and against an unroutable host that is the OS connect
# timeout (~75s), not our deadline. A wrong IP or port is the single most
# likely thing a user types, so check reachability first — it turns the
# common failure into a sub-second answer instead of a frozen UI.
reachable, why = _tcp_reachable(source, connect_timeout)
if not reachable:
return {"ok": False, "error": why}
cap = None
try:
cap = (cv2.VideoCapture(source) if isinstance(source, int)
else cv2.VideoCapture(source, cv2.CAP_FFMPEG))
cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
if not cap.isOpened():
return {"ok": False, "error": "could not open stream - check the "
"host, port, path and credentials"}
deadline = time.time() + timeout
frame = None
while time.time() < deadline:
ok, candidate = cap.read()
if ok and candidate is not None and candidate.size:
frame = candidate
break
if frame is None:
return {"ok": False, "error": "connected but no frame arrived "
f"within {timeout:.0f}s"}
height, width = frame.shape[:2]
preview = frame
if max_width and width > max_width:
scale = max_width / width
preview = cv2.resize(frame, (max_width, int(height * scale)),
interpolation=cv2.INTER_AREA)
ok, buf = cv2.imencode(".jpg", preview,
[int(cv2.IMWRITE_JPEG_QUALITY), 70])
return {
"ok": True, "width": int(width), "height": int(height),
"downscaled_to": int(preview.shape[1]) if preview is not frame else None,
"snapshot": (base64.b64encode(buf.tobytes()).decode("ascii")
if ok else None),
}
except (cv2.error, MemoryError, OSError) as exc:
return {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
finally:
if cap is not None:
cap.release()
class VideoSource(threading.Thread):
def __init__(self, camera_id: str, source: "str | int", display_url: str = "",
max_width: int = 1280):
super().__init__(daemon=True, name=f"capture-{camera_id}")
self.camera_id = camera_id
self._source = source
self._display_url = display_url or str(source)
# Downscale at ingest: 3MP+ streams waste memory and detector time,
# and on tight machines a full-res frame copy alone can OOM.
self.max_width = max_width
self._lock = threading.Lock()
self._frame: Optional[np.ndarray] = None
self._frame_ts: float = 0.0
# _stopping, NOT _stop. threading.Thread has its own private _stop(),
# and join() calls it: shadowing the name with an Event made every
# join() on a started worker raise "'Event' object is not callable".
# It only surfaces when a camera is removed or edited at runtime, so
# the engine answered 500 to every camera edit from head office while
# every test using a stubbed worker passed.
self._stopping = threading.Event()
self.connected = False
self.frames_total = 0
self.reconnects = 0
self._ever_connected = False
# -- public ---------------------------------------------------------
def latest(self) -> "tuple[Optional[np.ndarray], float]":
with self._lock:
if self._frame is None:
return None, 0.0
try:
return self._frame.copy(), self._frame_ts
except MemoryError:
return None, 0.0
def latest_since(self, known_ts: float) -> "tuple[Optional[np.ndarray], float]":
"""Latest frame, but only if it is newer than `known_ts`.
The staleness check happens under the lock so no frame is copied just
to be discarded — the worker polls far faster than the stream
delivers, and a discarded full-frame copy per poll is exactly the
allocation pattern that used to exhaust memory on small machines.
"""
with self._lock:
if self._frame is None or self._frame_ts == known_ts:
return None, self._frame_ts
try:
return self._frame.copy(), self._frame_ts
except MemoryError:
return None, 0.0
def stop(self) -> None:
self._stopping.set()
def stats(self) -> dict:
return {
"camera_id": self.camera_id,
"url": self._display_url,
"connected": self.connected,
"frames_total": self.frames_total,
"reconnects": self.reconnects,
"last_frame_age_s": round(time.time() - self._frame_ts, 1)
if self._frame_ts else None,
}
# -- thread ---------------------------------------------------------
def run(self) -> None:
backoff = 1.0
while not self._stopping.is_set():
cap = self._open()
if cap is None:
self.connected = False
log.warning("[%s] connect failed, retrying in %.0fs (%s)",
self.camera_id, backoff, self._display_url)
if self._stopping.wait(backoff):
break
backoff = min(backoff * 2, 30.0)
continue
self.connected = True
if self._ever_connected: # the first connect is not a reconnect
self.reconnects += 1
self._ever_connected = True
backoff = 1.0
log.info("[%s] connected (%s)", self.camera_id, self._display_url)
while not self._stopping.is_set():
try:
ok, frame = cap.read()
except (cv2.error, SystemError, MemoryError):
log.warning("[%s] read failed (low memory?), reconnecting",
self.camera_id)
break
if not ok or frame is None:
log.warning("[%s] stream dropped, reconnecting", self.camera_id)
break
try:
if self.max_width and frame.shape[1] > self.max_width:
scale = self.max_width / frame.shape[1]
frame = cv2.resize(
frame,
(self.max_width, int(frame.shape[0] * scale)),
interpolation=cv2.INTER_AREA)
except (cv2.error, MemoryError):
time.sleep(0.1) # transient allocation failure: drop frame
continue
with self._lock:
self._frame = frame
self._frame_ts = time.time()
self.frames_total += 1
cap.release()
self.connected = False
log.info("[%s] capture stopped", self.camera_id)
def _open(self) -> Optional[cv2.VideoCapture]:
try:
if isinstance(self._source, int):
cap = cv2.VideoCapture(self._source)
else:
cap = cv2.VideoCapture(self._source, cv2.CAP_FFMPEG)
cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
if not cap.isOpened():
cap.release()
return None
return cap
except cv2.error:
log.exception("[%s] VideoCapture error", self.camera_id)
return None