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:
117
agent/cmd/e2e/main.go
Normal file
117
agent/cmd/e2e/main.go
Normal 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())
|
||||
}
|
||||
Reference in New Issue
Block a user