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:
241
agent/pkg/cameras/cameras_test.go
Normal file
241
agent/pkg/cameras/cameras_test.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package cameras
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fakeEngine struct {
|
||||
cams map[string]Local
|
||||
added []string
|
||||
updated []string
|
||||
removed []string
|
||||
listErr error
|
||||
snapshot []byte
|
||||
}
|
||||
|
||||
func newEngine(cams ...Local) *fakeEngine {
|
||||
m := map[string]Local{}
|
||||
for _, c := range cams {
|
||||
m[c.ID] = c
|
||||
}
|
||||
return &fakeEngine{cams: m, snapshot: []byte("\xff\xd8jpeg")}
|
||||
}
|
||||
|
||||
func (f *fakeEngine) List(context.Context) ([]Local, error) {
|
||||
if f.listErr != nil {
|
||||
return nil, f.listErr
|
||||
}
|
||||
var out []Local
|
||||
for _, c := range f.cams {
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (f *fakeEngine) Add(_ context.Context, c Local) error {
|
||||
f.cams[c.ID] = c
|
||||
f.added = append(f.added, c.ID)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeEngine) Update(_ context.Context, id string, c Local) error {
|
||||
c.ID = id
|
||||
f.cams[id] = c
|
||||
f.updated = append(f.updated, id)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeEngine) Remove(_ context.Context, id string) error {
|
||||
delete(f.cams, id)
|
||||
f.removed = append(f.removed, id)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeEngine) Snapshot(context.Context, string) ([]byte, error) {
|
||||
return f.snapshot, nil
|
||||
}
|
||||
|
||||
type fakeCloud struct {
|
||||
desired []Desired
|
||||
desiredErr error
|
||||
reports []Report
|
||||
uploads int
|
||||
uploadErr error
|
||||
}
|
||||
|
||||
func (f *fakeCloud) Desired(context.Context) ([]Desired, error) {
|
||||
return f.desired, f.desiredErr
|
||||
}
|
||||
func (f *fakeCloud) Report(_ context.Context, r Report) error {
|
||||
f.reports = append(f.reports, r)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeCloud) UploadSnapshot(context.Context, []byte) (string, error) {
|
||||
if f.uploadErr != nil {
|
||||
return "", f.uploadErr
|
||||
}
|
||||
f.uploads++
|
||||
return "snap/key.jpg", nil
|
||||
}
|
||||
|
||||
func syncer(e *fakeEngine, c *fakeCloud) *Syncer {
|
||||
return &Syncer{Engine: e, Cloud: c}
|
||||
}
|
||||
|
||||
func TestACameraAddedAtHeadOfficeAppearsOnTheShopPC(t *testing.T) {
|
||||
e, c := newEngine(), &fakeCloud{desired: []Desired{{
|
||||
CameraID: "entrance", Label: "Entrance", Host: "192.168.0.138",
|
||||
Port: 554, Path: "/ch0_0.264", Username: "admin", Password: "s3cret",
|
||||
MaxWidth: 1280, Enabled: true, Revision: 1,
|
||||
}}}
|
||||
syncer(e, c).Once(context.Background())
|
||||
|
||||
got, ok := e.cams["entrance"]
|
||||
if !ok {
|
||||
t.Fatal("the camera was never created on the PC")
|
||||
}
|
||||
if got.Host != "192.168.0.138" || got.Password != "s3cret" {
|
||||
t.Fatalf("connection details did not travel: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The whole reason adoption exists. Every existing site is already running
|
||||
// cameras configured locally - including the office camera this was tested with
|
||||
// - and a reconcile that only pushed downwards would delete all of them the
|
||||
// first time it ran.
|
||||
func TestACameraAlreadyRunningLocallyIsOfferedToHeadOfficeNotDeleted(t *testing.T) {
|
||||
e := newEngine(Local{ID: "office", Label: "Office", Host: "192.168.0.138",
|
||||
Port: 554, Username: "admin", Password: "s3cret", Connected: true})
|
||||
c := &fakeCloud{}
|
||||
syncer(e, c).Once(context.Background())
|
||||
|
||||
if _, ok := e.cams["office"]; !ok {
|
||||
t.Fatal("an existing camera was deleted by the first sync")
|
||||
}
|
||||
if len(c.reports) == 0 || len(c.reports[0].Adopt) != 1 {
|
||||
t.Fatalf("the camera was not offered for adoption: %+v", c.reports)
|
||||
}
|
||||
if got := c.reports[0].Adopt[0]; got.CameraID != "office" || got.Password != "s3cret" {
|
||||
t.Fatalf("adoption dropped details the camera needs: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A tombstone must win over adoption, or a deleted camera comes straight back
|
||||
// on the next sync and the operator cannot work out why.
|
||||
func TestADeletedCameraIsRemovedAndNotReadopted(t *testing.T) {
|
||||
e := newEngine(Local{ID: "entrance", Host: "10.0.0.5", Connected: true})
|
||||
c := &fakeCloud{desired: []Desired{
|
||||
{CameraID: "entrance", Enabled: true, Revision: 3, Deleted: true},
|
||||
}}
|
||||
s := syncer(e, c)
|
||||
s.Once(context.Background())
|
||||
|
||||
if _, ok := e.cams["entrance"]; ok {
|
||||
t.Fatal("a camera deleted at head office is still running")
|
||||
}
|
||||
for _, r := range c.reports {
|
||||
for _, a := range r.Adopt {
|
||||
if a.CameraID == "entrance" {
|
||||
t.Fatal("the deleted camera was offered back for adoption")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A PATCH restarts the camera connection, so re-applying unchanged config every
|
||||
// two minutes would make a healthy site drop its own video permanently.
|
||||
func TestUnchangedConfigurationTouchesTheEngineOnce(t *testing.T) {
|
||||
e := newEngine()
|
||||
c := &fakeCloud{desired: []Desired{{CameraID: "entrance", Host: "10.0.0.5",
|
||||
Enabled: true, Revision: 7}}}
|
||||
s := syncer(e, c)
|
||||
|
||||
s.Once(context.Background())
|
||||
s.Once(context.Background())
|
||||
s.Once(context.Background())
|
||||
|
||||
if len(e.added) != 1 {
|
||||
t.Fatalf("added %d times, want 1", len(e.added))
|
||||
}
|
||||
if len(e.updated) != 0 {
|
||||
t.Fatalf("updated %d times with no change - every one restarts the stream", len(e.updated))
|
||||
}
|
||||
}
|
||||
|
||||
func TestANewRevisionIsApplied(t *testing.T) {
|
||||
e := newEngine()
|
||||
c := &fakeCloud{desired: []Desired{{CameraID: "entrance", Host: "10.0.0.5",
|
||||
Enabled: true, Revision: 1}}}
|
||||
s := syncer(e, c)
|
||||
s.Once(context.Background())
|
||||
|
||||
c.desired[0].Host = "10.0.0.9"
|
||||
c.desired[0].Revision = 2
|
||||
s.Once(context.Background())
|
||||
|
||||
if len(e.updated) != 1 {
|
||||
t.Fatalf("updated %d times, want 1", len(e.updated))
|
||||
}
|
||||
if e.cams["entrance"].Host != "10.0.0.9" {
|
||||
t.Fatalf("the new address was not applied: %+v", e.cams["entrance"])
|
||||
}
|
||||
}
|
||||
|
||||
// An unclaimed PC, or one with no internet, must keep running the cameras it
|
||||
// already has. Wiping local config because head office is unreachable would
|
||||
// stop a shop recognising anybody for the duration of an outage.
|
||||
func TestAnUnreachableHeadOfficeChangesNothingLocally(t *testing.T) {
|
||||
e := newEngine(Local{ID: "entrance", Host: "10.0.0.5", Connected: true})
|
||||
c := &fakeCloud{desiredErr: errors.New("this PC is not claimed by a company yet")}
|
||||
syncer(e, c).Once(context.Background())
|
||||
|
||||
if _, ok := e.cams["entrance"]; !ok {
|
||||
t.Fatal("local cameras were removed because the cloud was unreachable")
|
||||
}
|
||||
if len(e.removed) != 0 {
|
||||
t.Fatalf("removed %v", e.removed)
|
||||
}
|
||||
}
|
||||
|
||||
// Knowing a camera is down matters far more than having a picture of it, and
|
||||
// the picture is the part most likely to fail.
|
||||
func TestAFailedSnapshotStillReportsWhetherTheCameraIsUp(t *testing.T) {
|
||||
e := newEngine(Local{ID: "entrance", Connected: true})
|
||||
c := &fakeCloud{uploadErr: errors.New("bucket unreachable")}
|
||||
syncer(e, c).Once(context.Background())
|
||||
|
||||
if len(c.reports) == 0 || len(c.reports[0].State) != 1 {
|
||||
t.Fatalf("no state was reported: %+v", c.reports)
|
||||
}
|
||||
st := c.reports[0].State[0]
|
||||
if !st.Connected {
|
||||
t.Error("connected state was lost with the snapshot")
|
||||
}
|
||||
if st.SnapshotKey != "" {
|
||||
t.Error("a failed upload reported a key anyway")
|
||||
}
|
||||
}
|
||||
|
||||
// No point photographing a camera that is not producing frames, and the attempt
|
||||
// costs a request per sync per dead camera.
|
||||
func TestADisconnectedCameraIsNotPhotographed(t *testing.T) {
|
||||
e := newEngine(Local{ID: "entrance", Connected: false})
|
||||
c := &fakeCloud{}
|
||||
syncer(e, c).Once(context.Background())
|
||||
|
||||
if c.uploads != 0 {
|
||||
t.Fatalf("uploaded %d snapshots of a disconnected camera", c.uploads)
|
||||
}
|
||||
if c.reports[0].State[0].Connected {
|
||||
t.Error("a disconnected camera was reported as up")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEngineThatIsNotRunningIsNotAnError(t *testing.T) {
|
||||
e := newEngine()
|
||||
e.listErr = errors.New("connection refused")
|
||||
c := &fakeCloud{desired: []Desired{{CameraID: "entrance", Enabled: true, Revision: 1}}}
|
||||
syncer(e, c).Once(context.Background()) // must not panic
|
||||
|
||||
if len(c.reports) != 0 {
|
||||
t.Fatal("reported state it could not have observed")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user