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:
3
behavision/gallery/__init__.py
Normal file
3
behavision/gallery/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .service import Gallery, Resolution # noqa: F401
|
||||
from .store import IdentityStore # noqa: F401
|
||||
from .index import VectorIndex # noqa: F401
|
||||
83
behavision/gallery/index.py
Normal file
83
behavision/gallery/index.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Cosine-similarity vector index.
|
||||
|
||||
FAISS `IndexFlatIP` wrapped in `IndexIDMap2` when faiss is installed, plain
|
||||
numpy otherwise — same interface, same results. Choices that fix the old
|
||||
codebase's failure modes:
|
||||
|
||||
- Exact inner-product search (vectors are unit-norm, so IP == cosine).
|
||||
No IVF: nothing to train, no wrong-metric trap, and exact search is
|
||||
microseconds up to hundreds of thousands of vectors.
|
||||
- `-1` ids from an empty index are filtered, never used as list indices.
|
||||
- The index is rebuilt from SQLite at startup (SQLite is the source of
|
||||
truth), so index and metadata can never drift apart.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import numpy as np
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
import faiss # type: ignore
|
||||
_HAVE_FAISS = True
|
||||
except ImportError: # pragma: no cover - environment dependent
|
||||
faiss = None
|
||||
_HAVE_FAISS = False
|
||||
|
||||
|
||||
class VectorIndex:
|
||||
def __init__(self, dim: int):
|
||||
self.dim = dim
|
||||
if _HAVE_FAISS:
|
||||
self._index = faiss.IndexIDMap2(faiss.IndexFlatIP(dim))
|
||||
self._ids = None
|
||||
self._vecs = None
|
||||
else:
|
||||
log.warning("faiss not installed - using exact numpy search "
|
||||
"(identical results, slower at large scale)")
|
||||
self._index = None
|
||||
self._ids = np.empty((0,), dtype=np.int64)
|
||||
self._vecs = np.empty((0, dim), dtype=np.float32)
|
||||
|
||||
def __len__(self) -> int:
|
||||
if self._index is not None:
|
||||
return self._index.ntotal
|
||||
return len(self._ids)
|
||||
|
||||
def add(self, ids: "list[int]", vectors: np.ndarray) -> None:
|
||||
if len(ids) == 0:
|
||||
return
|
||||
vectors = np.ascontiguousarray(vectors, dtype=np.float32).reshape(len(ids), self.dim)
|
||||
id_arr = np.asarray(ids, dtype=np.int64)
|
||||
if self._index is not None:
|
||||
self._index.add_with_ids(vectors, id_arr)
|
||||
else:
|
||||
self._ids = np.concatenate([self._ids, id_arr])
|
||||
self._vecs = np.vstack([self._vecs, vectors])
|
||||
|
||||
def remove(self, ids: "list[int]") -> None:
|
||||
if len(ids) == 0:
|
||||
return
|
||||
id_arr = np.asarray(ids, dtype=np.int64)
|
||||
if self._index is not None:
|
||||
self._index.remove_ids(id_arr)
|
||||
else:
|
||||
keep = ~np.isin(self._ids, id_arr)
|
||||
self._ids = self._ids[keep]
|
||||
self._vecs = self._vecs[keep]
|
||||
|
||||
def search(self, vector: np.ndarray, k: int = 1) -> "list[tuple[int, float]]":
|
||||
"""Top-k (embedding_id, cosine_similarity), best first."""
|
||||
if len(self) == 0:
|
||||
return []
|
||||
q = np.ascontiguousarray(vector, dtype=np.float32).reshape(1, self.dim)
|
||||
k = min(k, len(self))
|
||||
if self._index is not None:
|
||||
scores, ids = self._index.search(q, k)
|
||||
return [(int(i), float(s))
|
||||
for i, s in zip(ids[0], scores[0]) if i != -1]
|
||||
sims = self._vecs @ q[0]
|
||||
order = np.argsort(-sims)[:k]
|
||||
return [(int(self._ids[i]), float(sims[i])) for i in order]
|
||||
327
behavision/gallery/service.py
Normal file
327
behavision/gallery/service.py
Normal file
@@ -0,0 +1,327 @@
|
||||
"""Identity resolution: match, reinforce, or auto-enroll — with hysteresis.
|
||||
|
||||
Three-zone decision instead of one threshold:
|
||||
similarity >= match_threshold -> same person
|
||||
similarity < enroll_threshold -> genuinely new person
|
||||
in between -> ambiguous: do NOTHING
|
||||
The ambiguous zone is what prevents both duplicate identities and wrong
|
||||
merges — the two failure modes the previous system had simultaneously.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ..config import RecognitionSection
|
||||
from .index import VectorIndex
|
||||
from .store import IdentityStore
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Resolution:
|
||||
kind: str # known | new | ambiguous | skipped
|
||||
identity_id: Optional[int] = None
|
||||
label: Optional[str] = None
|
||||
similarity: float = 0.0
|
||||
new_sighting: bool = False
|
||||
|
||||
|
||||
class Gallery:
|
||||
"""One gallery shared by every camera.
|
||||
|
||||
`cfg` here is the global recognition section — the default. Callers that
|
||||
belong to a camera pass that camera's merged section as `rcfg`, because
|
||||
the gates describe a view and cameras do not share one.
|
||||
"""
|
||||
|
||||
def __init__(self, store: IdentityStore, index: VectorIndex,
|
||||
cfg: RecognitionSection, model_name: str = "default"):
|
||||
self.store = store
|
||||
self.index = index
|
||||
self.cfg = cfg
|
||||
self.model_name = model_name
|
||||
self._lock = threading.Lock()
|
||||
self._last_sighting: dict[tuple[int, str], float] = {}
|
||||
# Only embeddings produced by the active encoder enter the index;
|
||||
# vectors from a different model are numerically incompatible.
|
||||
ids, vecs = store.all_embeddings(index.dim, model=model_name)
|
||||
index.add(ids, vecs)
|
||||
log.info("gallery ready: %d embeddings (model '%s') across %d "
|
||||
"identities", len(ids), model_name,
|
||||
store.stats()["identities"])
|
||||
|
||||
def resolve(self, embedding: np.ndarray, quality: float, camera_id: str,
|
||||
ts: "float | None" = None,
|
||||
attributes: "dict | None" = None,
|
||||
rcfg: "RecognitionSection | None" = None) -> Resolution:
|
||||
"""`rcfg` is the calling camera's merged thresholds; the gallery is
|
||||
shared across cameras but the gates that decide a view are not."""
|
||||
ts = ts or time.time()
|
||||
cfg = rcfg or self.cfg
|
||||
with self._lock:
|
||||
matches = self.index.search(embedding, k=1)
|
||||
top_id, top_sim = matches[0] if matches else (None, -1.0)
|
||||
|
||||
if top_id is not None and top_sim >= cfg.match_threshold:
|
||||
ident = self.store.identity_for_embedding(top_id)
|
||||
if ident is None: # index/store race — treat as ambiguous
|
||||
return Resolution(kind="ambiguous", similarity=top_sim)
|
||||
self._maybe_reinforce(ident["id"], embedding, quality,
|
||||
top_sim, cfg)
|
||||
fresh = self._record_sighting(
|
||||
ident["id"], camera_id, ts, top_sim, quality, attributes)
|
||||
return Resolution(kind="known", identity_id=ident["id"],
|
||||
label=ident["label"], similarity=top_sim,
|
||||
new_sighting=fresh)
|
||||
|
||||
if top_id is None or top_sim < cfg.enroll_threshold:
|
||||
if not cfg.auto_enroll:
|
||||
return Resolution(kind="skipped", similarity=top_sim)
|
||||
if quality < cfg.min_enroll_quality:
|
||||
# Not confident enough in this face to mint an identity.
|
||||
return Resolution(kind="skipped", similarity=top_sim)
|
||||
identity_id, label = self.store.create_auto_identity()
|
||||
emb_id = self.store.add_embedding(identity_id, embedding,
|
||||
quality, self.model_name)
|
||||
self.index.add([emb_id], embedding.reshape(1, -1))
|
||||
self._record_sighting(identity_id, camera_id, ts, 1.0, quality,
|
||||
attributes)
|
||||
log.info("auto-enrolled %s (quality %.2f)", label, quality)
|
||||
return Resolution(kind="new", identity_id=identity_id,
|
||||
label=label, similarity=top_sim,
|
||||
new_sighting=True)
|
||||
|
||||
return Resolution(kind="ambiguous", similarity=top_sim)
|
||||
|
||||
def enroll(self, label: str, embeddings: "list[np.ndarray]",
|
||||
quality: float = 1.0) -> int:
|
||||
"""Explicit enrollment (CLI / API) with a known name."""
|
||||
with self._lock:
|
||||
identity_id = self.store.create_identity(label, kind="enrolled")
|
||||
for emb in embeddings[: self.cfg.max_embeddings_per_identity]:
|
||||
emb_id = self.store.add_embedding(identity_id, emb, quality,
|
||||
self.model_name)
|
||||
self.index.add([emb_id], emb.reshape(1, -1))
|
||||
return identity_id
|
||||
|
||||
def reinforce_identity(self, identity_id: int, embedding: np.ndarray,
|
||||
quality: float,
|
||||
rcfg: "RecognitionSection | None" = None) -> bool:
|
||||
"""Add another view of an ALREADY-identified person.
|
||||
|
||||
A track is resolved once and then stops contributing, so an identity
|
||||
was born holding a single embedding from the first second of a visit —
|
||||
and the next encounter at a different angle had one reference vector to
|
||||
beat. This lets the rest of the visit fill the gallery out.
|
||||
|
||||
Guarded three ways: the view must still map to *this* identity (a
|
||||
track that drifted onto another face must not poison the gallery), it
|
||||
must be similar enough that we actually believe it is this person
|
||||
(>= enroll_threshold), and different enough to be worth storing
|
||||
(< reinforce_threshold).
|
||||
"""
|
||||
cfg = rcfg or self.cfg
|
||||
with self._lock:
|
||||
if quality < cfg.min_enroll_quality:
|
||||
return False
|
||||
if (self.store.embedding_count(identity_id)
|
||||
>= cfg.max_embeddings_per_identity):
|
||||
return False
|
||||
matches = self.index.search(embedding, k=1)
|
||||
if not matches:
|
||||
return False
|
||||
top_id, top_sim = matches[0]
|
||||
ident = self.store.identity_for_embedding(top_id)
|
||||
if ident is None or ident["id"] != identity_id:
|
||||
return False # looks more like someone else - do not store
|
||||
if top_sim < cfg.enroll_threshold:
|
||||
# Nearest neighbour is this identity, but only barely. Below
|
||||
# enroll_threshold resolve() would call this a DIFFERENT
|
||||
# person, so gluing it on here would contradict the decision
|
||||
# the same numbers drive everywhere else. Measured on the
|
||||
# overhead camera, unfloored reinforcement gave one identity
|
||||
# two vectors 0.195 apart. The risk is asymmetric: a wrong
|
||||
# face welded into an identity is unrecoverable, a missed
|
||||
# hard angle is not.
|
||||
return False
|
||||
if top_sim >= cfg.reinforce_threshold:
|
||||
return False # near-duplicate of what we already have
|
||||
emb_id = self.store.add_embedding(identity_id, embedding, quality,
|
||||
self.model_name)
|
||||
self.index.add([emb_id], embedding.reshape(1, -1))
|
||||
log.debug("reinforced identity %d (sim %.3f, quality %.2f)",
|
||||
identity_id, top_sim, quality)
|
||||
return True
|
||||
|
||||
def merge_identities(self, source_id: int, target_id: int,
|
||||
force: bool = False) -> "dict":
|
||||
"""Fold one identity into another — the repair for a person who was
|
||||
enrolled twice.
|
||||
|
||||
Duplicates are not a hypothetical: two views of one face can score
|
||||
below `match_threshold`, and when they do the system mints a second
|
||||
identity and there is no way back. Deleting one loses that person's
|
||||
history; leaving both means the same customer is greeted as new.
|
||||
|
||||
Merging is destructive and, unlike a duplicate, *unrecoverable* — two
|
||||
different people welded together cannot be separated afterwards,
|
||||
because nothing records which embedding came from whom. So the two
|
||||
identities must look at least plausibly alike: below
|
||||
`enroll_threshold` resolve() positively asserts they are different
|
||||
people, and overriding that assertion requires `force`.
|
||||
|
||||
Returns a dict with `ok`; on refusal `reason` says why, so the UI can
|
||||
offer the override instead of failing silently.
|
||||
"""
|
||||
cfg = self.cfg
|
||||
with self._lock:
|
||||
if source_id == target_id:
|
||||
return {"ok": False, "reason": "cannot merge an identity "
|
||||
"into itself"}
|
||||
if self.store.get_identity(source_id) is None:
|
||||
return {"ok": False, "reason": f"identity {source_id} not found"}
|
||||
if self.store.get_identity(target_id) is None:
|
||||
return {"ok": False, "reason": f"identity {target_id} not found"}
|
||||
|
||||
sim, checkable = self._identity_similarity(source_id, target_id)
|
||||
if not force:
|
||||
if not checkable:
|
||||
return {"ok": False, "similarity": None,
|
||||
"reason": "no comparable embeddings (different "
|
||||
"encoder model) - cannot verify these "
|
||||
"are the same person"}
|
||||
if sim < cfg.enroll_threshold:
|
||||
return {"ok": False, "similarity": round(sim, 3),
|
||||
"threshold": cfg.enroll_threshold,
|
||||
"reason": "these look like different people "
|
||||
f"(best similarity {sim:.3f} < "
|
||||
f"{cfg.enroll_threshold})"}
|
||||
|
||||
result = self.store.merge_identities(
|
||||
source_id, target_id, cfg.max_embeddings_per_identity)
|
||||
if result is None:
|
||||
return {"ok": False, "reason": "identity not found"}
|
||||
# Trimmed vectors must leave the index or it keeps answering with
|
||||
# embedding ids that no longer exist in SQLite.
|
||||
self.index.remove(result["dropped_embeddings"])
|
||||
# The per-camera sighting cooldown is keyed by identity; the
|
||||
# source's keys now point at an identity that is gone.
|
||||
for key in [k for k in self._last_sighting if k[0] == source_id]:
|
||||
self._last_sighting.pop(key, None)
|
||||
log.warning("merged identity %d into %d (%s): %d embeddings, "
|
||||
"%d sightings, similarity %s%s", source_id, target_id,
|
||||
result["label"], result["embeddings_moved"],
|
||||
result["sightings_moved"],
|
||||
f"{sim:.3f}" if checkable else "n/a",
|
||||
" [FORCED]" if force else "")
|
||||
result.update(ok=True, forced=force,
|
||||
similarity=round(sim, 3) if checkable else None)
|
||||
return result
|
||||
|
||||
def duplicate_candidates(self, limit: int = 20, k: int = 6
|
||||
) -> "list[dict]":
|
||||
"""Identity pairs that look like the same person.
|
||||
|
||||
Found through the index rather than an all-pairs comparison: every
|
||||
stored vector asks for its `k` nearest neighbours and any that belong
|
||||
to a *different* identity is evidence those two are one person. That
|
||||
is O(n*k) and needs no big matrix — an all-pairs float32 matrix over
|
||||
10k embeddings is 400 MB, and this runs on a box that already OOMs on
|
||||
a 250 MB model.
|
||||
|
||||
Only pairs at or above `enroll_threshold` are reported: below it the
|
||||
gallery's own numbers say these are different people, and offering
|
||||
that as a suggestion would invite exactly the merge that cannot be
|
||||
undone.
|
||||
"""
|
||||
with self._lock:
|
||||
owners = self.store.embedding_owners(self.model_name)
|
||||
if not owners:
|
||||
return []
|
||||
ids, vecs = self.store.all_embeddings(self.index.dim,
|
||||
model=self.model_name)
|
||||
best: dict[tuple[int, int], float] = {}
|
||||
for emb_id, vec in zip(ids, vecs):
|
||||
mine = owners.get(emb_id)
|
||||
if mine is None:
|
||||
continue
|
||||
for other_id, sim in self.index.search(vec, k=k):
|
||||
theirs = owners.get(other_id)
|
||||
if theirs is None or theirs == mine:
|
||||
continue
|
||||
if sim < self.cfg.enroll_threshold:
|
||||
continue
|
||||
pair = (min(mine, theirs), max(mine, theirs))
|
||||
if sim > best.get(pair, -1.0):
|
||||
best[pair] = float(sim)
|
||||
out = []
|
||||
for (a, b), sim in sorted(best.items(), key=lambda kv: -kv[1])[:limit]:
|
||||
ia, ib = self.store.get_identity(a), self.store.get_identity(b)
|
||||
if ia is None or ib is None:
|
||||
continue
|
||||
out.append({
|
||||
"a": {"id": a, "label": ia["label"], "kind": ia["kind"],
|
||||
"sighting_count": ia["sighting_count"]},
|
||||
"b": {"id": b, "label": ib["label"], "kind": ib["kind"],
|
||||
"sighting_count": ib["sighting_count"]},
|
||||
"similarity": round(sim, 3),
|
||||
"confident": sim >= self.cfg.match_threshold})
|
||||
return out
|
||||
|
||||
def _identity_similarity(self, a: int, b: int) -> "tuple[float, bool]":
|
||||
"""Best cosine similarity between any view of `a` and any view of `b`.
|
||||
|
||||
Best, not mean: two identities of one person exist precisely because
|
||||
their *typical* views disagree. If any pair of views agrees, that is
|
||||
the evidence they are the same person.
|
||||
"""
|
||||
_, va = self.store.identity_embeddings(a, self.index.dim,
|
||||
self.model_name)
|
||||
_, vb = self.store.identity_embeddings(b, self.index.dim,
|
||||
self.model_name)
|
||||
if len(va) == 0 or len(vb) == 0:
|
||||
return 0.0, False
|
||||
return float((va @ vb.T).max()), True
|
||||
|
||||
def delete_identity(self, identity_id: int) -> bool:
|
||||
with self._lock:
|
||||
removed = self.store.delete_identity(identity_id)
|
||||
self.index.remove(removed)
|
||||
return bool(removed)
|
||||
|
||||
# -- internals ------------------------------------------------------
|
||||
def _maybe_reinforce(self, identity_id: int, embedding: np.ndarray,
|
||||
quality: float, similarity: float,
|
||||
cfg: RecognitionSection) -> None:
|
||||
"""Add an extra embedding for a known person when this view is
|
||||
confidently theirs but usefully different (pose/lighting), improving
|
||||
recall over time without letting the identity drift."""
|
||||
if similarity >= cfg.reinforce_threshold:
|
||||
return # too similar to what we already have — adds nothing
|
||||
if quality < cfg.min_enroll_quality:
|
||||
return
|
||||
if (self.store.embedding_count(identity_id)
|
||||
>= cfg.max_embeddings_per_identity):
|
||||
return
|
||||
emb_id = self.store.add_embedding(identity_id, embedding, quality,
|
||||
self.model_name)
|
||||
self.index.add([emb_id], embedding.reshape(1, -1))
|
||||
|
||||
def _record_sighting(self, identity_id: int, camera_id: str, ts: float,
|
||||
similarity: float, quality: float,
|
||||
attributes: "dict | None" = None) -> bool:
|
||||
key = (identity_id, camera_id)
|
||||
last = self._last_sighting.get(key, 0.0)
|
||||
if ts - last < self.cfg.sighting_cooldown_seconds:
|
||||
return False
|
||||
self._last_sighting[key] = ts
|
||||
self.store.record_sighting(identity_id, camera_id, ts, similarity,
|
||||
quality, attributes)
|
||||
return True
|
||||
338
behavision/gallery/store.py
Normal file
338
behavision/gallery/store.py
Normal file
@@ -0,0 +1,338 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user