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:
2026-09-04 11:14:18 +05:30
commit dad04e8cda
216 changed files with 40473 additions and 0 deletions

117
agent/cmd/e2e/main.go Normal file
View File

@@ -0,0 +1,117 @@
// End-to-end probe: publish visits through the real agent path.
//
// SIM controls the similarity of the second visit to the first, which is what
// exercises the reinforcement branch: identical vectors teach the gallery
// nothing and must be refused, a genuinely different view of the same person
// must be kept.
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"math"
"math/rand"
"os"
"strconv"
"time"
"github.com/loyaly/behavision-agent/pkg/mqtt"
"github.com/loyaly/behavision-agent/pkg/spool"
)
func unit(seed int64) []float32 {
rng := rand.New(rand.NewSource(seed))
v := make([]float32, 512)
var n float64
for i := range v {
v[i] = float32(rng.NormFloat64())
n += float64(v[i]) * float64(v[i])
}
n = math.Sqrt(n)
for i := range v {
v[i] /= float32(n)
}
return v
}
// atSimilarity builds a unit vector exactly `target` from base.
func atSimilarity(base []float32, target float64, seed int64) []float32 {
other := unit(seed)
var dot float64
for i := range base {
dot += float64(base[i]) * float64(other[i])
}
var n float64
for i := range other {
other[i] -= float32(dot) * base[i]
n += float64(other[i]) * float64(other[i])
}
n = math.Sqrt(n)
out := make([]float32, len(base))
k := math.Sqrt(1 - target*target)
for i := range base {
out[i] = float32(target)*base[i] + float32(k)*(other[i]/float32(n))
}
return out
}
func main() {
broker, user := os.Getenv("BROKER"), os.Getenv("MQTT_USER")
logger := log.New(os.Stdout, " ", 0)
q, err := spool.Open(os.Getenv("SPOOL_DIR"), 100)
if err != nil {
log.Fatal(err)
}
sim, _ := strconv.ParseFloat(os.Getenv("SIM"), 64)
emb := unit(42)
if sim > 0 {
emb = atSimilarity(unit(42), sim, 7)
}
quality, _ := strconv.ParseFloat(os.Getenv("QUALITY"), 64)
if quality == 0 {
quality = 0.74
}
visit := map[string]any{
"event_id": os.Getenv("EVENT_ID"),
"occurred_at": time.Now().UTC().Format(time.RFC3339Nano),
"camera_id": "entrance",
"is_new": sim == 0,
"quality": quality,
"model": "w600k_r50.onnx",
"embedding": emb,
"attributes": map[string]any{"gender": "Male", "age": 34},
}
if err := q.Append(fmt.Sprintf("bv/%s/visit", user), visit); err != nil {
log.Fatal(err)
}
client, err := mqtt.NewClient(mqtt.ClientOptions{
BrokerURL: broker, ClientID: "e2e-probe-" + os.Getenv("EVENT_ID"),
Username: user, Password: os.Getenv("MQTT_PASS"),
CAFile: os.Getenv("CA_FILE"), Log: logger,
})
if err != nil {
log.Fatalf("connect: %v", err)
}
defer client.Close()
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
go (&mqtt.Pump{Queue: q, Publisher: client, Log: logger}).Run(ctx)
deadline := time.Now().Add(15 * time.Second)
for time.Now().Before(deadline) {
if q.Len() == 0 {
b, _ := json.Marshal(map[string]any{"sim": sim, "quality": quality})
logger.Printf("published %s", b)
return
}
time.Sleep(200 * time.Millisecond)
}
log.Fatalf("spool did not drain: %d left", q.Len())
}