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

342 lines
14 KiB
Python

"""HTTP API + minimal live dashboard (FastAPI).
Every endpoint is guarded by engine readiness; the server can start before
models finish loading without a single unguarded None dereference.
"""
from __future__ import annotations
import asyncio
import logging
import secrets
from pathlib import Path
from typing import Optional
from fastapi import Depends, FastAPI, HTTPException
from fastapi.responses import HTMLResponse, Response, StreamingResponse
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from pydantic import BaseModel, ValidationError
from .config import ApiSection, CameraConfig, CameraTuning
from .commission import CommissionRun
from .events import Event
from .engine import Engine
log = logging.getLogger(__name__)
_STATIC = Path(__file__).parent / "static"
class RenamePayload(BaseModel):
label: str
class CommissionPayload(BaseModel):
seconds: float = 25.0
class MergePayload(BaseModel):
"""`into` is the identity that survives. `force` overrides the
similarity guard and is never the default: a wrong merge cannot be
undone, because nothing records which embedding came from whom."""
into: int
force: bool = False
class CameraPayload(BaseModel):
"""Camera as the UI submits it. Mirrors CameraConfig but every field is
optional so PATCH can send a subset.
Optional[...] rather than `X | None`: pydantic evaluates field annotations
at runtime, and CameraConfig already uses this form.
"""
id: Optional[str] = None
url: Optional[str] = None
host: Optional[str] = None
port: Optional[int] = None
path: Optional[str] = None
username: Optional[str] = None
password: Optional[str] = None
webcam: Optional[int] = None
max_width: Optional[int] = None
# Per-camera gate overrides. Without this the store could hold them but
# nothing could set them, so the commissioning advice ("loosen this
# camera's quality gate") had no way to be acted on.
tuning: Optional[CameraTuning] = None
def camera_public(cam: CameraConfig, worker=None) -> dict:
"""Camera as the API returns it.
The password is NEVER included — not masked, not empty-string-if-set,
absent. `safe_url()` already exists for exactly this and masks credentials
inside the URL form too.
"""
out = {
"id": cam.id, "host": cam.host, "port": cam.port, "path": cam.path,
"username": cam.username, "webcam": cam.webcam,
"max_width": cam.max_width, "has_password": bool(cam.password),
"url": cam.safe_url(),
"tuning": cam.tuning.model_dump(),
}
if worker is not None:
out.update(worker.stats())
out["url"] = cam.safe_url() # worker.stats() also carries a url key
return out
def _auth_dependencies(api_cfg: ApiSection) -> list:
"""HTTP Basic over every route when credentials are configured.
Applied at app level rather than per-route so a future endpoint cannot be
added unprotected by omission. Basic (not a token) because the dashboard
is a browser page: the browser prompts once and then attaches the header
to the MJPEG <img> subresource too, which a bearer token cannot do.
"""
if not api_cfg.auth_enabled:
return []
scheme = HTTPBasic()
def check(credentials: HTTPBasicCredentials = Depends(scheme)) -> None:
# compare_digest on both halves: no early exit, no timing signal.
ok_user = secrets.compare_digest(
credentials.username.encode("utf-8"),
api_cfg.username.encode("utf-8"))
ok_pass = secrets.compare_digest(
credentials.password.encode("utf-8"),
api_cfg.password.encode("utf-8"))
if not (ok_user and ok_pass):
raise HTTPException(401, "invalid credentials",
headers={"WWW-Authenticate": "Basic"})
return [Depends(check)]
def create_app(engine: Engine) -> FastAPI:
app = FastAPI(title="Behavision", version="1.0.0",
dependencies=_auth_dependencies(engine.cfg.api))
def worker_or_404(camera_id: str):
worker = engine.workers.get(camera_id)
if worker is None:
raise HTTPException(404, f"unknown camera '{camera_id}'")
return worker
@app.get("/", response_class=HTMLResponse)
def dashboard() -> str:
return (_STATIC / "dashboard.html").read_text(encoding="utf-8")
@app.get("/api/health")
def health() -> dict:
from .paths import describe
return {"status": "ok" if engine.started_at else "starting",
"recognition_model": engine.encoder.model_name,
# "where is my database" must be answerable from the API: the
# tray, the installer and support all need it, and installed
# it is not next to the code.
"paths": {**describe(), "data_dir": str(engine.cfg.app.data_dir),
"models_dir": str(engine.cfg.app.models_dir)},
"cameras": {cid: w.source.connected
for cid, w in engine.workers.items()}}
@app.get("/api/stats")
def stats() -> dict:
return engine.stats()
@app.get("/api/events")
def events(limit: int = 50) -> list:
return list(engine.bus.recent)[:limit]
@app.get("/api/identities")
def identities(limit: int = 200) -> list:
return engine.store.list_identities(limit)
@app.get("/api/sightings")
def sightings(limit: int = 100) -> list:
return engine.store.recent_sightings(limit)
@app.patch("/api/identities/{identity_id}")
def rename_identity(identity_id: int, payload: RenamePayload) -> dict:
if not engine.store.rename_identity(identity_id, payload.label.strip()):
raise HTTPException(404, "identity not found")
return engine.store.get_identity(identity_id)
@app.get("/api/identities/{identity_id}/embedding")
def identity_embedding(identity_id: int) -> dict:
"""One identity's best stored vector, for forwarding to the server.
This returns biometric personal data. It is on the authenticated local
API and bound to loopback in the product, and it exists because the
event bus deliberately does not carry embeddings — putting a 512-float
template on the bus would send it to the log sink and the email sink
too.
"""
best = engine.store.best_embedding(identity_id, engine.gallery.model_name)
if best is None:
raise HTTPException(404, "no embedding for this identity "
"(or it was made by a different model)")
vector, quality = best
return {"identity_id": identity_id,
"model": engine.gallery.model_name,
"quality": round(quality, 3),
"embedding": [round(float(x), 6) for x in vector]}
@app.get("/api/identities/duplicates")
def duplicate_identities(limit: int = 20) -> list:
"""Identity pairs that look like one person enrolled twice."""
return engine.gallery.duplicate_candidates(limit)
@app.post("/api/identities/{identity_id}/merge")
def merge_identity(identity_id: int, payload: MergePayload) -> dict:
result = engine.gallery.merge_identities(
identity_id, payload.into, force=payload.force)
if not result.get("ok"):
reason = result.get("reason", "merge refused")
# 409, not 400: the request is well formed, it conflicts with what
# the gallery believes. The body carries the measured similarity so
# the UI can show the operator what it is asking them to override.
status = 404 if "not found" in reason else 409
raise HTTPException(status, detail=result)
engine.bus.publish(Event(
type="identity.merged", camera_id="",
data={k: result[k] for k in
("source", "target", "label", "similarity", "forced",
"embeddings_moved", "sightings_moved")}))
return result
@app.delete("/api/identities/{identity_id}")
def delete_identity(identity_id: int) -> dict:
if not engine.gallery.delete_identity(identity_id):
raise HTTPException(404, "identity not found")
return {"deleted": identity_id}
@app.get("/api/cameras")
def cameras() -> list:
out = []
for cam in engine.camera_store.list():
out.append(camera_public(cam, engine.workers.get(cam.id)))
return out
@app.post("/api/cameras", status_code=201)
def add_camera(payload: CameraPayload) -> dict:
data = payload.model_dump(exclude_none=True)
if not data.get("id"):
raise HTTPException(400, "id is required")
try:
cam = CameraConfig.model_validate(data)
cam.source() # reject "no url, no host, no webcam" before storing
# Resolve the per-camera gates here too. Without this an inverted
# enroll/match pair was only caught when the worker was built,
# which surfaced as a 500 "stored but failed to start" instead of
# telling the user what was wrong with what they typed.
engine.cfg.recognition.merged(cam.tuning)
except (ValidationError, ValueError) as exc:
raise HTTPException(400, str(exc))
try:
engine.camera_store.add(cam)
except ValueError as exc:
raise HTTPException(409, str(exc))
try:
engine.add_camera(cam)
except Exception as exc:
# Never leave the store describing a camera the engine refused —
# the two would disagree until the next restart.
engine.camera_store.delete(cam.id)
raise HTTPException(500, f"camera stored but failed to start: {exc}")
return camera_public(cam, engine.workers.get(cam.id))
@app.patch("/api/cameras/{camera_id}")
def edit_camera(camera_id: str, payload: CameraPayload) -> dict:
fields = payload.model_dump(exclude_none=True)
fields.pop("id", None)
try:
if "tuning" in fields:
engine.cfg.recognition.merged(
CameraTuning.model_validate(fields["tuning"]))
cam = engine.camera_store.update(camera_id, fields)
except (ValidationError, ValueError) as exc:
raise HTTPException(400, str(exc))
if cam is None:
raise HTTPException(404, f"unknown camera '{camera_id}'")
engine.restart_camera(cam) # a changed URL needs a fresh connection
return camera_public(cam, engine.workers.get(cam.id))
@app.delete("/api/cameras/{camera_id}")
def delete_camera(camera_id: str) -> dict:
if not engine.camera_store.delete(camera_id):
raise HTTPException(404, f"unknown camera '{camera_id}'")
engine.remove_camera(camera_id)
return {"deleted": camera_id}
@app.post("/api/cameras/{camera_id}/commission")
def start_commission(camera_id: str,
payload: CommissionPayload) -> dict:
"""Begin a placement check: watch this camera for N seconds and judge
whether faces here are good enough to enrol."""
worker = worker_or_404(camera_id)
# The camera's own gate, not the global one - the whole point is to
# judge this view against the threshold it will actually run under.
worker.commission = CommissionRun(
camera_id, worker.rcfg.min_enroll_quality, payload.seconds)
return worker.commission.report()
@app.get("/api/cameras/{camera_id}/commission")
def commission_result(camera_id: str) -> dict:
worker = worker_or_404(camera_id)
if worker.commission is None:
raise HTTPException(404, "no placement check has been run")
return worker.commission.report()
@app.delete("/api/cameras/{camera_id}/commission")
def cancel_commission(camera_id: str) -> dict:
worker = worker_or_404(camera_id)
if worker.commission is not None:
worker.commission.cancel()
return {"cancelled": camera_id}
@app.post("/api/cameras/test")
def test_camera(payload: CameraPayload) -> dict:
"""Try a camera WITHOUT saving it - the UI's Test button.
Deliberately a sync def so FastAPI runs it in the threadpool:
cv2.VideoCapture blocks hard and a wrong host can hang for the full
FFmpeg timeout, which would stall the whole event loop.
"""
from .capture import probe_source
data = payload.model_dump(exclude_none=True)
data.setdefault("id", "__test__")
try:
cam = CameraConfig.model_validate(data)
source = cam.source()
except (ValidationError, ValueError) as exc:
return {"ok": False, "error": str(exc)}
return probe_source(source, cam.max_width)
@app.get("/api/cameras/{camera_id}/frame.jpg")
def frame(camera_id: str) -> Response:
jpeg = worker_or_404(camera_id).latest_jpeg()
if jpeg is None:
raise HTTPException(503, "no frame yet")
return Response(jpeg, media_type="image/jpeg")
@app.get("/api/cameras/{camera_id}/stream.mjpeg")
async def stream(camera_id: str) -> StreamingResponse:
worker = worker_or_404(camera_id)
async def generate():
boundary = b"--frame\r\nContent-Type: image/jpeg\r\n\r\n"
# Stop when the camera is deleted or its worker dies - otherwise a
# removed camera leaves this generator running for the life of the
# process, holding a reference to a worker nothing else can see.
while engine.workers.get(camera_id) is worker and worker.is_alive():
jpeg = worker.latest_jpeg()
if jpeg is not None:
yield boundary + jpeg + b"\r\n"
await asyncio.sleep(0.1) # ~10 fps to the browser
return StreamingResponse(
generate(),
media_type="multipart/x-mixed-replace; boundary=frame")
return app