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

339 lines
15 KiB
Python

"""SQLite persistence for identities, embeddings and sightings.
Single writer class with an internal lock; WAL mode so the API can read
while the pipeline writes. Embeddings are stored as float32 BLOBs — SQLite
is the source of truth and the vector index is rebuilt from here at boot.
"""
from __future__ import annotations
import json
import sqlite3
import threading
import time
from pathlib import Path
import numpy as np
_SCHEMA = """
CREATE TABLE IF NOT EXISTS identities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
label TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'auto',
created_at REAL NOT NULL,
last_seen_at REAL,
sighting_count INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS embeddings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
identity_id INTEGER NOT NULL REFERENCES identities(id) ON DELETE CASCADE,
vector BLOB NOT NULL,
model TEXT NOT NULL DEFAULT '',
quality REAL NOT NULL DEFAULT 0,
created_at REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_embeddings_identity ON embeddings(identity_id);
CREATE TABLE IF NOT EXISTS sightings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
identity_id INTEGER NOT NULL REFERENCES identities(id) ON DELETE CASCADE,
camera_id TEXT NOT NULL,
ts REAL NOT NULL,
similarity REAL NOT NULL DEFAULT 0,
quality REAL NOT NULL DEFAULT 0,
attributes TEXT
);
CREATE INDEX IF NOT EXISTS idx_sightings_identity ON sightings(identity_id);
CREATE INDEX IF NOT EXISTS idx_sightings_ts ON sightings(ts);
"""
class IdentityStore:
def __init__(self, db_path: "Path | str"):
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
self._lock = threading.Lock()
self._db = sqlite3.connect(str(db_path), check_same_thread=False)
self._db.row_factory = sqlite3.Row
with self._lock:
self._db.execute("PRAGMA journal_mode=WAL")
self._db.execute("PRAGMA foreign_keys=ON")
self._db.executescript(_SCHEMA)
self._db.commit()
# -- identities -----------------------------------------------------
def create_identity(self, label: str, kind: str = "auto") -> int:
with self._lock:
cur = self._db.execute(
"INSERT INTO identities(label, kind, created_at) VALUES(?,?,?)",
(label, kind, time.time()))
self._db.commit()
return int(cur.lastrowid)
def create_auto_identity(self) -> "tuple[int, str]":
"""Create an auto-enrolled identity labelled 'Visitor <id>' in one
transaction; returns (id, label)."""
with self._lock:
cur = self._db.execute(
"INSERT INTO identities(label, kind, created_at) VALUES(?,?,?)",
("pending", "auto", time.time()))
identity_id = int(cur.lastrowid)
label = f"Visitor {identity_id}"
self._db.execute(
"UPDATE identities SET label=? WHERE id=?", (label, identity_id))
self._db.commit()
return identity_id, label
def rename_identity(self, identity_id: int, label: str) -> bool:
with self._lock:
cur = self._db.execute(
"UPDATE identities SET label=?, kind='enrolled' WHERE id=?",
(label, identity_id))
self._db.commit()
return cur.rowcount > 0
def delete_identity(self, identity_id: int) -> "list[int]":
"""Delete an identity; returns removed embedding ids (for the index)."""
with self._lock:
rows = self._db.execute(
"SELECT id FROM embeddings WHERE identity_id=?",
(identity_id,)).fetchall()
self._db.execute("DELETE FROM identities WHERE id=?", (identity_id,))
self._db.commit()
return [int(r["id"]) for r in rows]
def merge_identities(self, source_id: int, target_id: int,
max_embeddings: int = 5) -> "dict | None":
"""Fold `source_id` into `target_id`; returns a summary, or None if
either identity is missing.
Embeddings and sightings are re-pointed rather than copied, which is
what keeps this cheap AND keeps the vector index valid: the index maps
*embedding* id to vector, and those ids do not change here, so a merge
needs no reindex. Only trimmed embeddings have to be dropped from it,
which is why they are returned.
Everything happens in one transaction. A half-merge — sightings moved,
embeddings not — would leave two identities each holding part of one
person, which is strictly worse than the duplicate we started with.
"""
with self._lock:
src = self._db.execute("SELECT * FROM identities WHERE id=?",
(source_id,)).fetchone()
dst = self._db.execute("SELECT * FROM identities WHERE id=?",
(target_id,)).fetchone()
if src is None or dst is None or source_id == target_id:
return None
try:
emb = self._db.execute(
"UPDATE embeddings SET identity_id=? WHERE identity_id=?",
(target_id, source_id)).rowcount
sig = self._db.execute(
"UPDATE sightings SET identity_id=? WHERE identity_id=?",
(target_id, source_id)).rowcount
# A human-assigned name outranks an auto "Visitor N" whichever
# direction the operator merged in — silently turning "Alice"
# back into "Visitor 3" would be a data-loss bug, not a policy.
label, kind = dst["label"], dst["kind"]
if dst["kind"] == "auto" and src["kind"] != "auto":
label, kind = src["label"], src["kind"]
# The merged identity's history starts at the earlier of the
# two first-sightings; it is one person and always was.
created = min(float(src["created_at"]), float(dst["created_at"]))
# Trim to the highest-quality views. Merging two identities
# that each held the cap would otherwise leave one holding
# double, quietly overweighting that person in every search.
dropped = [int(r["id"]) for r in self._db.execute(
"SELECT id FROM embeddings WHERE identity_id=? "
"ORDER BY quality DESC, id ASC LIMIT -1 OFFSET ?",
(target_id, max_embeddings)).fetchall()]
if dropped:
self._db.execute(
"DELETE FROM embeddings WHERE id IN (%s)"
% ",".join("?" * len(dropped)), dropped)
# Recomputed, never summed: sighting_count on the source may
# itself be stale, and COUNT(*) is the only figure that cannot
# drift away from the rows actually present.
agg = self._db.execute(
"SELECT COUNT(*) AS n, MAX(ts) AS last FROM sightings "
"WHERE identity_id=?", (target_id,)).fetchone()
self._db.execute(
"UPDATE identities SET label=?, kind=?, created_at=?, "
"sighting_count=?, last_seen_at=? WHERE id=?",
(label, kind, created, int(agg["n"]), agg["last"],
target_id))
self._db.execute("DELETE FROM identities WHERE id=?",
(source_id,))
self._db.commit()
except Exception:
self._db.rollback()
raise
return {"source": source_id, "target": target_id, "label": label,
"embeddings_moved": int(emb), "sightings_moved": int(sig),
"dropped_embeddings": dropped,
"sighting_count": int(agg["n"])}
def identity_embeddings(self, identity_id: int, dim: int,
model: "str | None" = None
) -> "tuple[list[int], np.ndarray]":
"""One identity's stored vectors, for comparing two identities to each
other. Model-filtered for the same reason the index is."""
with self._lock:
if model is None:
rows = self._db.execute(
"SELECT id, vector FROM embeddings WHERE identity_id=? "
"ORDER BY id", (identity_id,)).fetchall()
else:
rows = self._db.execute(
"SELECT id, vector FROM embeddings WHERE identity_id=? "
"AND model=? ORDER BY id",
(identity_id, model)).fetchall()
ids = [int(r["id"]) for r in rows]
if not ids:
return [], np.empty((0, dim), dtype=np.float32)
return ids, np.vstack([
np.frombuffer(r["vector"], dtype=np.float32) for r in rows])
def get_identity(self, identity_id: int) -> "dict | None":
with self._lock:
row = self._db.execute(
"SELECT * FROM identities WHERE id=?", (identity_id,)).fetchone()
return dict(row) if row else None
def list_identities(self, limit: int = 200) -> "list[dict]":
with self._lock:
rows = self._db.execute(
"SELECT i.*, COUNT(e.id) AS embedding_count FROM identities i "
"LEFT JOIN embeddings e ON e.identity_id = i.id "
"GROUP BY i.id ORDER BY i.last_seen_at DESC LIMIT ?",
(limit,)).fetchall()
return [dict(r) for r in rows]
# -- embeddings -----------------------------------------------------
def add_embedding(self, identity_id: int, vector: np.ndarray,
quality: float, model: str = "") -> int:
blob = np.asarray(vector, dtype=np.float32).tobytes()
with self._lock:
cur = self._db.execute(
"INSERT INTO embeddings(identity_id, vector, model, quality,"
" created_at) VALUES(?,?,?,?,?)",
(identity_id, blob, model, quality, time.time()))
self._db.commit()
return int(cur.lastrowid)
def embedding_count(self, identity_id: int) -> int:
with self._lock:
row = self._db.execute(
"SELECT COUNT(*) AS n FROM embeddings WHERE identity_id=?",
(identity_id,)).fetchone()
return int(row["n"])
def identity_for_embedding(self, embedding_id: int) -> "dict | None":
with self._lock:
row = self._db.execute(
"SELECT i.* FROM identities i JOIN embeddings e "
"ON e.identity_id = i.id WHERE e.id=?",
(embedding_id,)).fetchone()
return dict(row) if row else None
def all_embeddings(self, dim: int, model: "str | None" = None
) -> "tuple[list[int], np.ndarray]":
"""Embeddings for the vector index. Filtering by `model` is what
keeps vectors from different encoders out of the same search space —
they are numerically incompatible."""
with self._lock:
if model is None:
rows = self._db.execute(
"SELECT id, vector FROM embeddings ORDER BY id").fetchall()
else:
rows = self._db.execute(
"SELECT id, vector FROM embeddings WHERE model=? "
"ORDER BY id", (model,)).fetchall()
ids = [int(r["id"]) for r in rows]
if not ids:
return [], np.empty((0, dim), dtype=np.float32)
vecs = np.vstack([
np.frombuffer(r["vector"], dtype=np.float32) for r in rows])
return ids, vecs
def best_embedding(self, identity_id: int, model: "str | None" = None
) -> "tuple[np.ndarray, float] | None":
"""The highest-quality stored view of one identity.
For handing an identity to the server: sending the best view rather
than the mean because a mean of two disagreeing views is a vector that
matches neither, which is precisely how one person becomes two
identities.
"""
with self._lock:
if model is None:
row = self._db.execute(
"SELECT vector, quality FROM embeddings WHERE identity_id=? "
"ORDER BY quality DESC, id ASC LIMIT 1",
(identity_id,)).fetchone()
else:
row = self._db.execute(
"SELECT vector, quality FROM embeddings WHERE identity_id=? "
"AND model=? ORDER BY quality DESC, id ASC LIMIT 1",
(identity_id, model)).fetchone()
if row is None:
return None
return np.frombuffer(row["vector"], dtype=np.float32), float(row["quality"])
def embedding_owners(self, model: "str | None" = None) -> "dict[int, int]":
"""embedding_id -> identity_id, for turning index hits into identity
pairs without a round trip to SQLite per hit."""
with self._lock:
if model is None:
rows = self._db.execute(
"SELECT id, identity_id FROM embeddings").fetchall()
else:
rows = self._db.execute(
"SELECT id, identity_id FROM embeddings WHERE model=?",
(model,)).fetchall()
return {int(r["id"]): int(r["identity_id"]) for r in rows}
# -- sightings ------------------------------------------------------
def record_sighting(self, identity_id: int, camera_id: str, ts: float,
similarity: float, quality: float,
attributes: "dict | None" = None) -> None:
with self._lock:
self._db.execute(
"INSERT INTO sightings(identity_id, camera_id, ts, similarity,"
" quality, attributes) VALUES(?,?,?,?,?,?)",
(identity_id, camera_id, ts, similarity, quality,
json.dumps(attributes) if attributes else None))
self._db.execute(
"UPDATE identities SET last_seen_at=?, "
"sighting_count=sighting_count+1 WHERE id=?", (ts, identity_id))
self._db.commit()
def recent_sightings(self, limit: int = 100) -> "list[dict]":
with self._lock:
rows = self._db.execute(
"SELECT s.*, i.label FROM sightings s JOIN identities i "
"ON i.id = s.identity_id ORDER BY s.ts DESC LIMIT ?",
(limit,)).fetchall()
out = []
for r in rows:
d = dict(r)
if d.get("attributes"):
d["attributes"] = json.loads(d["attributes"])
out.append(d)
return out
def stats(self) -> dict:
with self._lock:
n_id = self._db.execute(
"SELECT COUNT(*) AS n FROM identities").fetchone()["n"]
n_emb = self._db.execute(
"SELECT COUNT(*) AS n FROM embeddings").fetchone()["n"]
n_sight = self._db.execute(
"SELECT COUNT(*) AS n FROM sightings").fetchone()["n"]
return {"identities": n_id, "embeddings": n_emb, "sightings": n_sight}
def close(self) -> None:
with self._lock:
self._db.close()