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:
194
behavision/cameras.py
Normal file
194
behavision/cameras.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""Writable camera list — the store behind "user connects their camera".
|
||||
|
||||
Cameras used to live in `config/default.yaml` with credentials in `.env`, which
|
||||
means adding one is an edit-and-restart. A product needs them added at runtime
|
||||
from a UI, so they move here: a small JSON file the API can rewrite safely
|
||||
while the engine is running.
|
||||
|
||||
Deliberately NOT stored in `behavision.db`. That file is a biometric database
|
||||
with its own handling and erasure obligations; folding user-editable config
|
||||
into it makes both harder to reason about, to back up, and to hand to support.
|
||||
|
||||
Passwords are protected at rest with Windows DPAPI. This is not theatre: the
|
||||
file sits on the same disk as the face gallery, and an RTSP credential is a
|
||||
live path into the camera itself.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .config import CameraConfig
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_PLAIN = "plain:"
|
||||
_DPAPI = "dpapi:"
|
||||
# Machine scope, not user scope. The service (LocalSystem) and an admin running
|
||||
# the CLI are different accounts, and a user-scoped blob written by one cannot
|
||||
# be read by the other — a failure that only shows up after install, on the
|
||||
# customer's machine. Machine scope still defends the actual threat here:
|
||||
# someone copying cameras.json off the box.
|
||||
_CRYPTPROTECT_LOCAL_MACHINE = 0x04
|
||||
|
||||
|
||||
def _win32crypt():
|
||||
try:
|
||||
import win32crypt # type: ignore
|
||||
return win32crypt
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
|
||||
def protect(value: str) -> str:
|
||||
"""Encrypt a secret for storage. Tagged so the format can change later."""
|
||||
if not value:
|
||||
return ""
|
||||
crypt = _win32crypt()
|
||||
if crypt is None:
|
||||
return _PLAIN + value
|
||||
try:
|
||||
blob = crypt.CryptProtectData(value.encode("utf-8"), "behavision",
|
||||
None, None, None,
|
||||
_CRYPTPROTECT_LOCAL_MACHINE)
|
||||
return _DPAPI + base64.b64encode(blob).decode("ascii")
|
||||
except Exception:
|
||||
log.warning("DPAPI unavailable - storing camera password unencrypted",
|
||||
exc_info=True)
|
||||
return _PLAIN + value
|
||||
|
||||
|
||||
def unprotect(stored: str) -> str:
|
||||
"""Inverse of `protect`. Never raises: a credential that cannot be read is
|
||||
an empty credential, so one unreadable camera does not stop the engine."""
|
||||
if not stored:
|
||||
return ""
|
||||
if stored.startswith(_PLAIN):
|
||||
return stored[len(_PLAIN):]
|
||||
if stored.startswith(_DPAPI):
|
||||
crypt = _win32crypt()
|
||||
if crypt is None:
|
||||
log.error("camera password is DPAPI-encrypted but win32crypt is "
|
||||
"unavailable - re-enter it on this machine")
|
||||
return ""
|
||||
try:
|
||||
return crypt.CryptUnprotectData(
|
||||
base64.b64decode(stored[len(_DPAPI):]),
|
||||
None, None, None, 0)[1].decode("utf-8")
|
||||
except Exception:
|
||||
log.error("camera password could not be decrypted (config copied "
|
||||
"from another machine?) - re-enter it", exc_info=True)
|
||||
return ""
|
||||
return stored # pre-tag file written before this module existed
|
||||
|
||||
|
||||
class CameraStore:
|
||||
"""Cameras as JSON, safe to rewrite while the engine is running."""
|
||||
|
||||
def __init__(self, path: "Path | str"):
|
||||
self.path = Path(path)
|
||||
self._lock = threading.RLock()
|
||||
self._cameras: "dict[str, CameraConfig]" = {}
|
||||
self._load()
|
||||
|
||||
# -- persistence ----------------------------------------------------
|
||||
def _load(self) -> None:
|
||||
if not self.path.exists():
|
||||
return
|
||||
try:
|
||||
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
log.exception("%s is unreadable - starting with no cameras "
|
||||
"(the file is left in place, not overwritten)",
|
||||
self.path)
|
||||
return
|
||||
for entry in raw.get("cameras", []):
|
||||
try:
|
||||
entry = dict(entry)
|
||||
entry["password"] = unprotect(entry.get("password", ""))
|
||||
cam = CameraConfig.model_validate(entry)
|
||||
except Exception:
|
||||
log.exception("skipping malformed camera entry %r", entry)
|
||||
continue
|
||||
self._cameras[cam.id] = cam
|
||||
|
||||
def _save(self) -> None:
|
||||
"""Atomic: a crash mid-write must not leave a truncated camera list."""
|
||||
payload = {"version": 1, "cameras": []}
|
||||
for cam in self._cameras.values():
|
||||
entry = cam.model_dump(mode="json")
|
||||
entry["password"] = protect(cam.password)
|
||||
payload["cameras"].append(entry)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = self.path.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
||||
try:
|
||||
os.chmod(tmp, 0o600)
|
||||
except OSError: # best effort (Windows)
|
||||
pass
|
||||
os.replace(tmp, self.path) # atomic on POSIX and NTFS
|
||||
|
||||
# -- CRUD -----------------------------------------------------------
|
||||
def list(self) -> "list[CameraConfig]":
|
||||
with self._lock:
|
||||
return list(self._cameras.values())
|
||||
|
||||
def get(self, camera_id: str) -> Optional[CameraConfig]:
|
||||
with self._lock:
|
||||
return self._cameras.get(camera_id)
|
||||
|
||||
def add(self, camera: CameraConfig) -> CameraConfig:
|
||||
with self._lock:
|
||||
if camera.id in self._cameras:
|
||||
raise ValueError(f"camera '{camera.id}' already exists")
|
||||
camera.source() # validate now, not at connect time
|
||||
self._cameras[camera.id] = camera
|
||||
self._save()
|
||||
return camera
|
||||
|
||||
def update(self, camera_id: str, fields: dict) -> Optional[CameraConfig]:
|
||||
with self._lock:
|
||||
existing = self._cameras.get(camera_id)
|
||||
if existing is None:
|
||||
return None
|
||||
# id is the engine's key for the worker; renaming would orphan it
|
||||
fields = {k: v for k, v in fields.items()
|
||||
if k != "id" and v is not None}
|
||||
# Re-validated rather than model_copy(update=...): copy does not
|
||||
# coerce, so a nested `tuning` arriving as a plain dict from JSON
|
||||
# would be stored as a dict and blow up the first time a camera
|
||||
# asked it for its thresholds.
|
||||
updated = CameraConfig.model_validate(
|
||||
{**existing.model_dump(), **fields})
|
||||
updated.source()
|
||||
self._cameras[camera_id] = updated
|
||||
self._save()
|
||||
return updated
|
||||
|
||||
def delete(self, camera_id: str) -> bool:
|
||||
with self._lock:
|
||||
if self._cameras.pop(camera_id, None) is None:
|
||||
return False
|
||||
self._save()
|
||||
return True
|
||||
|
||||
def seed(self, cameras: "list[CameraConfig]") -> bool:
|
||||
"""Import YAML-declared cameras on first run only.
|
||||
|
||||
After that the store is authoritative — otherwise a camera the user
|
||||
deleted in the UI would reappear on every restart.
|
||||
"""
|
||||
with self._lock:
|
||||
if self.path.exists() or not cameras:
|
||||
return False
|
||||
for cam in cameras:
|
||||
self._cameras[cam.id] = cam
|
||||
self._save()
|
||||
log.info("seeded %d camera(s) from YAML into %s",
|
||||
len(cameras), self.path)
|
||||
return True
|
||||
Reference in New Issue
Block a user