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:
76
server/migrations/005_cameras.sql
Normal file
76
server/migrations/005_cameras.sql
Normal file
@@ -0,0 +1,76 @@
|
||||
BEGIN;
|
||||
|
||||
-- Cameras, owned by head office rather than by the PC they run on.
|
||||
--
|
||||
-- Until now a camera existed only in `cameras.json` on one shop's disk, added
|
||||
-- through the desktop app by somebody standing in that shop. That is fine for
|
||||
-- the shop and impossible for the tenant: an owner onboarding a new store, or
|
||||
-- fixing a camera in a branch they are not standing in, had no way to do it.
|
||||
--
|
||||
-- The shop PC stays the thing that CONNECTS to the camera - it is on the same
|
||||
-- LAN, and nothing else can be - so this table is desired state that the agent
|
||||
-- pulls and applies. The engine's own store remains the running config; these
|
||||
-- two are reconciled, not merged.
|
||||
CREATE TABLE site_cameras (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
client_id uuid NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
|
||||
site_id uuid NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
|
||||
|
||||
-- The id the ENGINE knows this camera by, and the one that lands in
|
||||
-- visits.camera_id. Stable for the life of the camera: renaming it would
|
||||
-- orphan every visit already recorded against the old name.
|
||||
camera_id text NOT NULL,
|
||||
label text NOT NULL DEFAULT '',
|
||||
|
||||
-- Connection, split into parts rather than stored as one URL. The engine
|
||||
-- builds the RTSP URL itself with percent-encoded credentials, because a
|
||||
-- password containing '@' in a hand-assembled URL is the exact bug the
|
||||
-- original project shipped.
|
||||
host text NOT NULL DEFAULT '',
|
||||
port integer NOT NULL DEFAULT 554,
|
||||
path text NOT NULL DEFAULT '/',
|
||||
username text NOT NULL DEFAULT '',
|
||||
-- Encrypted, never hashed: the agent has to be able to USE it. Same
|
||||
-- treatment as a site's broker password, and for the same reason.
|
||||
--
|
||||
-- This is a real widening of what the server holds. An RTSP credential is
|
||||
-- a live path into the camera itself, and until now it lived only on the
|
||||
-- shop PC under DPAPI. Putting it here is the price of onboarding a camera
|
||||
-- from head office, and it is sealed with the site id as additional data so
|
||||
-- a row copied between sites will not decrypt.
|
||||
password_enc bytea,
|
||||
|
||||
max_width integer NOT NULL DEFAULT 1280,
|
||||
tuning jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
|
||||
-- Bumped on every edit. The agent compares it to what it last applied, so
|
||||
-- a reconcile is cheap when nothing has changed - which is almost always.
|
||||
revision bigint NOT NULL DEFAULT 1,
|
||||
|
||||
-- Observed state, written by the agent. Kept beside desired state on
|
||||
-- purpose: "this camera is configured" and "this camera is working" are
|
||||
-- the two halves of the only question anyone asks about a camera, and
|
||||
-- splitting them across tables makes the screen that answers it a join
|
||||
-- nobody remembers to write.
|
||||
connected boolean,
|
||||
last_seen_at timestamptz,
|
||||
-- The most recent frame, as an object key. NOT a URL, for the same reason
|
||||
-- face images are keys: a stored URL is permanent and unrevocable.
|
||||
snapshot_key text NOT NULL DEFAULT '',
|
||||
snapshot_at timestamptz,
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
-- A tombstone, not a delete. The agent ADOPTS cameras it finds configured
|
||||
-- locally, so a hard delete would be undone on the next sync by the very
|
||||
-- camera the operator just removed.
|
||||
deleted_at timestamptz,
|
||||
|
||||
UNIQUE (site_id, camera_id)
|
||||
);
|
||||
|
||||
CREATE INDEX site_cameras_site_idx ON site_cameras (site_id) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX site_cameras_client_idx ON site_cameras (client_id) WHERE deleted_at IS NULL;
|
||||
|
||||
COMMIT;
|
||||
Reference in New Issue
Block a user