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

123 lines
3.7 KiB
Python

"""Async event bus with pluggable sinks (log, webhook, email).
Events are published from the pipeline thread and delivered on a dedicated
worker thread, so a slow webhook or SMTP server can never stall frame
processing. Sink failures are logged, never raised.
"""
from __future__ import annotations
import logging
import queue
import smtplib
import threading
import time
from collections import deque
from dataclasses import asdict, dataclass, field
from email.mime.text import MIMEText
log = logging.getLogger(__name__)
@dataclass
class Event:
type: str # person.new | person.seen | camera.up | camera.down | ...
camera_id: str
ts: float = field(default_factory=time.time)
data: dict = field(default_factory=dict)
def to_dict(self) -> dict:
return asdict(self)
class EventBus:
def __init__(self) -> None:
self._queue: "queue.Queue[Event | None]" = queue.Queue(maxsize=1000)
self._sinks: list = []
self.recent: deque = deque(maxlen=300)
self._worker = threading.Thread(
target=self._run, daemon=True, name="event-bus")
self._started = False
def add_sink(self, sink) -> None:
self._sinks.append(sink)
def start(self) -> None:
if not self._started:
self._started = True
self._worker.start()
def stop(self) -> None:
if self._started:
self._queue.put(None)
self._worker.join(timeout=5)
def publish(self, event: Event) -> None:
self.recent.appendleft(event.to_dict())
try:
self._queue.put_nowait(event)
except queue.Full:
log.warning("event queue full, dropping %s", event.type)
def _run(self) -> None:
while True:
event = self._queue.get()
if event is None:
return
for sink in self._sinks:
try:
sink.handle(event)
except Exception:
log.exception("sink %s failed for %s",
type(sink).__name__, event.type)
class LogSink:
def handle(self, event: Event) -> None:
log.info("event %s [%s] %s", event.type, event.camera_id, event.data)
class WebhookSink:
def __init__(self, url: str, timeout: float = 5.0):
self.url = url
self.timeout = timeout
def handle(self, event: Event) -> None:
import requests
requests.post(self.url, json=event.to_dict(), timeout=self.timeout)
class EmailSink:
"""Rate-limited email notifications for person events only."""
NOTIFY_TYPES = {"person.new", "person.seen"}
def __init__(self, smtp_host: str, smtp_port: int, username: str,
password: str, to: str, min_interval: float = 300.0):
self.smtp_host = smtp_host
self.smtp_port = smtp_port
self.username = username
self.password = password
self.to = to
self.min_interval = min_interval
self._last_sent = 0.0
def handle(self, event: Event) -> None:
if event.type not in self.NOTIFY_TYPES:
return
now = time.time()
if now - self._last_sent < self.min_interval:
return
self._last_sent = now
label = event.data.get("label", "someone")
body = (f"Behavision: {label} detected on camera {event.camera_id}\n"
f"Event: {event.type}\nDetails: {event.data}")
msg = MIMEText(body)
msg["Subject"] = f"Behavision: {label} on {event.camera_id}"
msg["From"] = self.username
msg["To"] = self.to
with smtplib.SMTP(self.smtp_host, self.smtp_port, timeout=10) as smtp:
smtp.starttls()
smtp.login(self.username, self.password)
smtp.send_message(msg)