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:
311
agent/main.go
Normal file
311
agent/main.go
Normal file
@@ -0,0 +1,311 @@
|
||||
// Command behavision-agent is the Go half of the edge install: it supervises
|
||||
// the Python recognition engine and moves its events to the server.
|
||||
//
|
||||
// Modes:
|
||||
//
|
||||
// run supervise the engine and drain the spool (what the tray runs)
|
||||
// status one-shot health report, for support and for the installer
|
||||
// paths where this agent thinks state lives
|
||||
//
|
||||
// The tray and the Wails UI wrap this; none of the logic below assumes a
|
||||
// window exists, so `run` works headless over SSH or from a scheduled task.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/loyaly/behavision-agent/pkg/bridge"
|
||||
"github.com/loyaly/behavision-agent/pkg/cameras"
|
||||
"github.com/loyaly/behavision-agent/pkg/config"
|
||||
"github.com/loyaly/behavision-agent/pkg/engine"
|
||||
"github.com/loyaly/behavision-agent/pkg/mqtt"
|
||||
"github.com/loyaly/behavision-agent/pkg/paths"
|
||||
"github.com/loyaly/behavision-agent/pkg/spool"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
flag.Usage = func() {
|
||||
fmt.Fprintf(os.Stderr, "behavision-agent %s\n\nusage: %s <run|status|paths>\n",
|
||||
version, filepath.Base(os.Args[0]))
|
||||
}
|
||||
flag.Parse()
|
||||
|
||||
mode := "run"
|
||||
if flag.NArg() > 0 {
|
||||
mode = flag.Arg(0)
|
||||
}
|
||||
var err error
|
||||
switch mode {
|
||||
case "run":
|
||||
err = cmdRun()
|
||||
case "status":
|
||||
err = cmdStatus()
|
||||
case "paths":
|
||||
err = cmdPaths()
|
||||
default:
|
||||
flag.Usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
if err != nil {
|
||||
log.Fatalf("behavision-agent: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func cmdPaths() error {
|
||||
return json.NewEncoder(os.Stdout).Encode(map[string]string{
|
||||
"version": version,
|
||||
"install_root": paths.InstallRoot(),
|
||||
"state_root": paths.StateRoot(),
|
||||
"agent_config": paths.AgentConfig(),
|
||||
"spool": paths.SpoolDir(),
|
||||
"engine_log": paths.EngineLog(),
|
||||
})
|
||||
}
|
||||
|
||||
func cmdStatus() error {
|
||||
cfg, err := config.Load(paths.AgentConfig())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The engine invents its own Basic credential when none is configured,
|
||||
// which is the default. Reading it here is what stops every call the agent
|
||||
// makes to the engine coming back 401 on a stock install.
|
||||
cfg = cfg.WithEngineCredentials(paths.APICredentials())
|
||||
q, err := spool.Open(paths.SpoolDir(), cfg.SpoolMax)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sup := engine.New(engine.Options{
|
||||
Command: func(context.Context) *exec.Cmd { return nil },
|
||||
HealthURL: strings.TrimRight(cfg.APIBase, "/") + "/api/health",
|
||||
StatsURL: strings.TrimRight(cfg.APIBase, "/") + "/api/stats",
|
||||
User: cfg.APIUser, Password: cfg.APIPassword,
|
||||
})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
out := map[string]any{
|
||||
"version": version,
|
||||
"configured": cfg.Configured(),
|
||||
"secrets_protected": config.SecretsProtected(),
|
||||
"queued": q.Len(),
|
||||
"dropped": q.Dropped(),
|
||||
}
|
||||
if h, err := sup.Health(ctx); err != nil {
|
||||
out["engine"] = map[string]any{"reachable": false, "error": err.Error()}
|
||||
} else {
|
||||
out["engine"] = h
|
||||
}
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(out)
|
||||
}
|
||||
|
||||
func cmdRun() error {
|
||||
logger := log.New(os.Stdout, "", log.LstdFlags|log.LUTC)
|
||||
if err := paths.EnsureState(); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := config.Load(paths.AgentConfig())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The engine invents its own Basic credential when none is configured,
|
||||
// which is the default. Reading it here is what stops every call the agent
|
||||
// makes to the engine coming back 401 on a stock install.
|
||||
cfg = cfg.WithEngineCredentials(paths.APICredentials())
|
||||
// Opened before the engine starts: detections arriving in the first second
|
||||
// must have somewhere to land.
|
||||
q, err := spool.Open(paths.SpoolDir(), cfg.SpoolMax)
|
||||
if err != nil {
|
||||
return fmt.Errorf("spool: %w", err)
|
||||
}
|
||||
logFile, err := engine.LogFile(paths.EngineLog())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer logFile.Close()
|
||||
|
||||
exe := cfg.EngineExe
|
||||
if !filepath.IsAbs(exe) {
|
||||
// Resolved against the install root, not the working directory: a
|
||||
// service or a shortcut can start us anywhere.
|
||||
exe = filepath.Join(paths.InstallRoot(), exe)
|
||||
}
|
||||
// hookURL is read when the engine is LAUNCHED, not when the supervisor is
|
||||
// built, because the bridge has not picked its port yet and because a
|
||||
// restarted engine has to be told again.
|
||||
var hookURL string
|
||||
sup := engine.New(engine.Options{
|
||||
Command: func(ctx context.Context) *exec.Cmd {
|
||||
cmd := exec.CommandContext(ctx, exe, cfg.EngineArgs...)
|
||||
// How the engine learns where to send detections. The engine's
|
||||
// config already reads `events.webhook_url: ${BEHAVISION_WEBHOOK_URL}`
|
||||
// and python-dotenv does not override a variable the process
|
||||
// already has, so this needs no new endpoint and no fixed port.
|
||||
//
|
||||
// Without it the engine recognised people and the bridge received
|
||||
// nothing - the URL was returned, logged and even exposed on the
|
||||
// desktop's status object, and never actually given to the engine.
|
||||
// A claimed shop PC published heartbeats and zero visits.
|
||||
cmd.Env = append(os.Environ(), "BEHAVISION_WEBHOOK_URL="+hookURL)
|
||||
return cmd
|
||||
},
|
||||
LogWriter: logFile,
|
||||
HealthURL: strings.TrimRight(cfg.APIBase, "/") + "/api/health",
|
||||
StatsURL: strings.TrimRight(cfg.APIBase, "/") + "/api/stats",
|
||||
User: cfg.APIUser, Password: cfg.APIPassword,
|
||||
})
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(),
|
||||
os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
// The bridge always runs, claimed or not: a PC that is set up before its
|
||||
// tenant credentials arrive must still record the footfall it sees, and
|
||||
// the spool is what holds it until the broker is configured.
|
||||
// Created before the bridge and handed over unconditionally. On a PC that
|
||||
// is not claimed yet there is no pump reading it, which costs nothing: the
|
||||
// waker's single slot fills once and every later ring is dropped.
|
||||
waker := mqtt.NewWaker()
|
||||
br := &bridge.Bridge{
|
||||
Queue: q,
|
||||
Wake: waker.Wake,
|
||||
Embeddings: bridge.NewEngineEmbeddings(cfg.APIBase, cfg.APIUser, cfg.APIPassword),
|
||||
TopicPrefix: topicPrefix(cfg),
|
||||
Log: logger,
|
||||
// Uploads face images through a URL the server mints, so this PC never
|
||||
// holds bucket credentials. Harmless when the engine writes no images
|
||||
// or the PC is not claimed: Upload reports "images off" and the visit
|
||||
// queues without a photo.
|
||||
Uploader: &bridge.SpacesUploader{
|
||||
BaseURL: cfg.CloudBase, Token: cfg.AgentToken,
|
||||
},
|
||||
}
|
||||
// Cameras, kept in step with head office. Runs whether or not this PC is
|
||||
// claimed: unclaimed it simply logs that it has no credentials yet, and the
|
||||
// engine carries on with the cameras already in its own store.
|
||||
uploader := &bridge.SpacesUploader{BaseURL: cfg.CloudBase, Token: cfg.AgentToken}
|
||||
cloud := cameras.NewCloudClient(cfg.CloudBase, cfg.AgentToken)
|
||||
cloud.Upload = uploader.UploadBytes
|
||||
eng := cameras.NewEngineClient(cfg.APIBase, cfg.APIUser, cfg.APIPassword)
|
||||
go cameras.New(eng, cloud, logger).Run(ctx)
|
||||
|
||||
// Before the engine starts, so the engine can be launched already knowing
|
||||
// where to post its detections.
|
||||
//
|
||||
// A PC set up to run on its own is the one case where it should not: it
|
||||
// has nothing to report to and, unlike an unclaimed one, never will, so
|
||||
// queuing would write up to SpoolMax visits - each carrying a face
|
||||
// template, which is biometric personal data - into a queue nothing is
|
||||
// going to drain. Recognition and the cameras are unaffected; they belong
|
||||
// to the engine, not the pump.
|
||||
if cfg.Standalone && !cfg.Configured() {
|
||||
logger.Print("standalone: recognition runs locally, nothing is reported")
|
||||
} else {
|
||||
url, stopBridge, err := br.Listen(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event bridge: %w", err)
|
||||
}
|
||||
defer stopBridge()
|
||||
hookURL = url
|
||||
logger.Printf("event bridge listening on %s", hookURL)
|
||||
}
|
||||
|
||||
logger.Printf("starting engine: %s %s", exe, strings.Join(cfg.EngineArgs, " "))
|
||||
sup.Start()
|
||||
|
||||
if cfg.Configured() {
|
||||
logger.Printf("tenant %s / site %s; broker %s",
|
||||
cfg.ClientID, cfg.SiteID, cfg.BrokerURL)
|
||||
client, err := mqtt.NewClient(mqtt.ClientOptions{
|
||||
BrokerURL: cfg.BrokerURL,
|
||||
ClientID: "behavision-" + cfg.ClientID + "-" + cfg.SiteID,
|
||||
Username: cfg.BrokerUsername, Password: cfg.BrokerPassword,
|
||||
CAFile: cfg.BrokerCAFile, Log: logger,
|
||||
})
|
||||
if err != nil {
|
||||
// Not fatal. Events keep accumulating on disk and go out when the
|
||||
// link returns - which is the entire point of the spool.
|
||||
logger.Printf("broker unavailable, queuing locally: %v", err)
|
||||
} else {
|
||||
defer client.Close()
|
||||
pump := &mqtt.Pump{
|
||||
Queue: q, Publisher: client, Log: logger,
|
||||
Wake: waker.C(),
|
||||
HeartbeatTopic: topicPrefix(cfg) + "/heartbeat",
|
||||
HeartbeatPayload: func() []byte {
|
||||
return heartbeat(q, sup)
|
||||
},
|
||||
}
|
||||
go pump.Run(ctx)
|
||||
logger.Print("broker pump running")
|
||||
}
|
||||
} else {
|
||||
logger.Print("not claimed by a tenant yet - recording locally only")
|
||||
}
|
||||
|
||||
<-ctx.Done()
|
||||
logger.Print("stopping engine")
|
||||
sup.Stop()
|
||||
return nil
|
||||
}
|
||||
|
||||
// topicPrefix is the site's MQTT namespace. The broker enforces
|
||||
// `pattern write bv/%u/...`, so this must equal the credential's username or
|
||||
// every publish is refused.
|
||||
func topicPrefix(cfg config.Config) string {
|
||||
if cfg.ClientID == "" || cfg.SiteID == "" {
|
||||
return ""
|
||||
}
|
||||
return "bv/" + cfg.ClientID + "." + cfg.SiteID
|
||||
}
|
||||
|
||||
// heartbeat says the site is alive and what shape it is in.
|
||||
//
|
||||
// `dropped` matters most: non-zero means this site's queue overflowed and it
|
||||
// genuinely lost footfall the customer paid for. Reporting it is the only way
|
||||
// that becomes visible rather than being inferred from a dip in a graph.
|
||||
func heartbeat(q *spool.Spool, sup *engine.Supervisor) []byte {
|
||||
hb := map[string]any{
|
||||
"sent_at": time.Now().UTC().Format(time.RFC3339),
|
||||
"agent_version": version,
|
||||
"queued": q.Len(),
|
||||
"dropped": q.Dropped(),
|
||||
}
|
||||
if sup != nil {
|
||||
state, _ := sup.State()
|
||||
hb["engine_state"] = string(state)
|
||||
hctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
||||
defer cancel()
|
||||
if h, err := sup.Health(hctx); err == nil {
|
||||
hb["recognition_model"] = h.RecognitionModel
|
||||
hb["cameras"] = h.Cameras
|
||||
}
|
||||
// The share of faces this site's cameras saw and discarded before they
|
||||
// could become visits. It is the difference between "a quiet week" and
|
||||
// "the camera is pointed at the ceiling", which are the same row of
|
||||
// numbers on a footfall report without it. Measured on the Office1
|
||||
// camera it was 0.727.
|
||||
if st, err := sup.Stats(hctx); err == nil {
|
||||
if worst, ok := st.WorstBelowGate(); ok {
|
||||
hb["fraction_below_gate"] = worst
|
||||
}
|
||||
}
|
||||
}
|
||||
b, _ := json.Marshal(hb)
|
||||
return b
|
||||
}
|
||||
Reference in New Issue
Block a user