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:
247
agent/pkg/cameras/checks_test.go
Normal file
247
agent/pkg/cameras/checks_test.go
Normal file
@@ -0,0 +1,247 @@
|
||||
package cameras
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fakeProber struct {
|
||||
test TestResult
|
||||
testErr error
|
||||
placement map[string]any
|
||||
placeErr error
|
||||
placedFor int
|
||||
// testedWith records the camera the probe was actually handed, which is
|
||||
// where the credential either arrives or does not.
|
||||
testedWith Local
|
||||
}
|
||||
|
||||
func (f *fakeProber) Test(_ context.Context, cam Local) (TestResult, error) {
|
||||
f.testedWith = cam
|
||||
return f.test, f.testErr
|
||||
}
|
||||
func (f *fakeProber) Placement(_ context.Context, _ string, seconds int) (map[string]any, error) {
|
||||
f.placedFor = seconds
|
||||
return f.placement, f.placeErr
|
||||
}
|
||||
|
||||
type fakeChecks struct {
|
||||
jobs []Job
|
||||
submitted []Result
|
||||
pendErr error
|
||||
}
|
||||
|
||||
func (f *fakeChecks) Pending(context.Context) ([]Job, error) { return f.jobs, f.pendErr }
|
||||
func (f *fakeChecks) Submit(_ context.Context, r Result) error {
|
||||
f.submitted = append(f.submitted, r)
|
||||
return nil
|
||||
}
|
||||
|
||||
func checker(e *fakeEngine, p *fakeProber, c *fakeChecks) *Syncer {
|
||||
return &Syncer{Engine: e, Cloud: &fakeCloud{}, Prober: p, Checks: c}
|
||||
}
|
||||
|
||||
func TestAReachableCameraReportsItsResolutionAndAPicture(t *testing.T) {
|
||||
e := newEngine(Local{ID: "entrance", Host: "192.168.0.138", Connected: true})
|
||||
p := &fakeProber{test: TestResult{OK: true, Width: 1920, Height: 1080,
|
||||
Snapshot: "/9j/4AAQSkZJRg=="}}
|
||||
c := &fakeChecks{jobs: []Job{{CameraID: "entrance", Kind: "connection"}}}
|
||||
|
||||
checker(e, p, c).runChecks(context.Background(), nil)
|
||||
|
||||
if len(c.submitted) != 1 {
|
||||
t.Fatalf("submitted %d results", len(c.submitted))
|
||||
}
|
||||
got := c.submitted[0]
|
||||
if !got.OK {
|
||||
t.Fatalf("a working camera reported as failing: %+v", got)
|
||||
}
|
||||
if !strings.Contains(got.Headline, "1920") {
|
||||
t.Errorf("headline does not say what was found: %q", got.Headline)
|
||||
}
|
||||
if got.ImageKey == "" {
|
||||
t.Error("no picture uploaded, so the operator cannot see what it is pointing at")
|
||||
}
|
||||
}
|
||||
|
||||
// The engine already tells three different failures apart, and each needs a
|
||||
// different thing done about it. Carrying its sentence and adding the action is
|
||||
// the whole design; re-wording it here would be a fourth description of the
|
||||
// same fault.
|
||||
func TestEachConnectionFailureGetsItsOwnAdvice(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"connection refused": "port",
|
||||
"host unreachable": "powered on",
|
||||
"could not open stream - check the host, port, path and credentials": "stream path",
|
||||
"connected but no frame arrived within 12s": "one viewer at a time",
|
||||
}
|
||||
for engineErr, want := range cases {
|
||||
e := newEngine(Local{ID: "entrance", Connected: true})
|
||||
p := &fakeProber{test: TestResult{OK: false, Error: engineErr}}
|
||||
c := &fakeChecks{jobs: []Job{{CameraID: "entrance", Kind: "connection"}}}
|
||||
|
||||
checker(e, p, c).runChecks(context.Background(), nil)
|
||||
|
||||
got := c.submitted[0]
|
||||
if got.OK {
|
||||
t.Errorf("%q reported as a pass", engineErr)
|
||||
}
|
||||
// The engine's own sentence must survive intact.
|
||||
if got.Headline != engineErr {
|
||||
t.Errorf("headline %q, want the engine's own words %q", got.Headline, engineErr)
|
||||
}
|
||||
joined := strings.ToLower(strings.Join(got.Advice, " "))
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Errorf("%q -> advice %q, expected it to mention %q", engineErr, joined, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only `good` is a pass. `marginal` means half the visitors are silently
|
||||
// discarded, and signing that off as working is exactly how a site runs for
|
||||
// weeks recognising almost nobody.
|
||||
func TestOnlyAGoodPlacementCounts(t *testing.T) {
|
||||
for verdict, wantOK := range map[string]bool{
|
||||
"good": true, "marginal": false, "poor": false,
|
||||
"no_faces": false, "artifact": false, "inconclusive": false,
|
||||
"no_completed_passes": false,
|
||||
} {
|
||||
e := newEngine(Local{ID: "entrance", Connected: true})
|
||||
p := &fakeProber{placement: map[string]any{
|
||||
"verdict": verdict, "headline": "h",
|
||||
"advice": []any{"do the thing"},
|
||||
}}
|
||||
c := &fakeChecks{jobs: []Job{{CameraID: "entrance", Kind: "placement", Seconds: 25}}}
|
||||
|
||||
checker(e, p, c).runChecks(context.Background(), nil)
|
||||
|
||||
if got := c.submitted[0].OK; got != wantOK {
|
||||
t.Errorf("verdict %q -> ok=%v, want %v", verdict, got, wantOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The engine's advice is written for the person standing next to the camera.
|
||||
// It must reach them.
|
||||
func TestThePlacementAdviceIsCarriedThroughVerbatim(t *testing.T) {
|
||||
e := newEngine(Local{ID: "entrance", Connected: true})
|
||||
p := &fakeProber{placement: map[string]any{
|
||||
"verdict": "poor",
|
||||
"headline": "most visitors here cannot be recognised",
|
||||
"advice": []any{
|
||||
"Face the camera the way people walk in, at about head height.",
|
||||
"Re-run this check after moving it.",
|
||||
},
|
||||
"faces": float64(11),
|
||||
}}
|
||||
c := &fakeChecks{jobs: []Job{{CameraID: "entrance", Kind: "placement", Seconds: 25}}}
|
||||
|
||||
checker(e, p, c).runChecks(context.Background(), nil)
|
||||
|
||||
got := c.submitted[0]
|
||||
if got.Headline != "most visitors here cannot be recognised" {
|
||||
t.Errorf("headline changed: %q", got.Headline)
|
||||
}
|
||||
if len(got.Advice) != 2 || !strings.Contains(got.Advice[0], "head height") {
|
||||
t.Errorf("advice did not survive: %+v", got.Advice)
|
||||
}
|
||||
// Everything else the engine said travels too, so a new field reaches the
|
||||
// UI without a schema change on the way.
|
||||
if got.Detail["faces"] != float64(11) {
|
||||
t.Errorf("detail was dropped: %+v", got.Detail)
|
||||
}
|
||||
if p.placedFor != 25 {
|
||||
t.Errorf("watched for %ds, want 25", p.placedFor)
|
||||
}
|
||||
}
|
||||
|
||||
// A camera added at head office 30 seconds ago has not reached the PC yet.
|
||||
// Telling the operator to check the cabling would send them to the wrong place.
|
||||
func TestACameraTheShopPCHasNotAppliedYetSaysSo(t *testing.T) {
|
||||
e := newEngine() // engine knows nothing about it
|
||||
p := &fakeProber{}
|
||||
c := &fakeChecks{jobs: []Job{{CameraID: "entrance", Kind: "connection"}}}
|
||||
|
||||
checker(e, p, c).runChecks(context.Background(), nil)
|
||||
|
||||
got := c.submitted[0]
|
||||
if got.OK {
|
||||
t.Fatal("reported a pass for a camera that does not exist here")
|
||||
}
|
||||
if !strings.Contains(got.Headline, "not set up that camera yet") {
|
||||
t.Errorf("headline blames the wrong thing: %q", got.Headline)
|
||||
}
|
||||
if !strings.Contains(strings.Join(got.Advice, " "), "Try again shortly") {
|
||||
t.Errorf("advice does not tell them to wait: %+v", got.Advice)
|
||||
}
|
||||
}
|
||||
|
||||
// "The engine is not running" and "the camera is broken" need opposite actions.
|
||||
func TestAStoppedEngineIsNotReportedAsABrokenCamera(t *testing.T) {
|
||||
e := newEngine()
|
||||
e.listErr = errors.New("connection refused")
|
||||
p := &fakeProber{}
|
||||
c := &fakeChecks{jobs: []Job{{CameraID: "entrance", Kind: "connection"}}}
|
||||
|
||||
checker(e, p, c).runChecks(context.Background(), nil)
|
||||
|
||||
got := c.submitted[0]
|
||||
if !strings.Contains(got.Headline, "not responding") {
|
||||
t.Fatalf("headline blames the camera: %q", got.Headline)
|
||||
}
|
||||
if !strings.Contains(strings.Join(got.Advice, " "), "make sure it is started") {
|
||||
t.Errorf("advice: %+v", got.Advice)
|
||||
}
|
||||
}
|
||||
|
||||
// An unclaimed PC has neither, and must not treat that as a fault.
|
||||
func TestASyncerWithNoCheckSupportSkipsQuietly(t *testing.T) {
|
||||
s := &Syncer{Engine: newEngine(), Cloud: &fakeCloud{}}
|
||||
s.runChecks(context.Background(), nil) // must not panic
|
||||
}
|
||||
|
||||
func TestNoPendingChecksSubmitsNothing(t *testing.T) {
|
||||
c := &fakeChecks{}
|
||||
checker(newEngine(), &fakeProber{}, c).runChecks(context.Background(), nil)
|
||||
if len(c.submitted) != 0 {
|
||||
t.Fatalf("submitted %d results with no jobs", len(c.submitted))
|
||||
}
|
||||
}
|
||||
|
||||
// The engine deliberately never returns a camera password - it reports
|
||||
// has_password and nothing else - so probing with what the engine hands back
|
||||
// dials the camera with an empty credential. That reported "could not open
|
||||
// stream - check the host, port, path and credentials" about a camera the very
|
||||
// same PC had been streaming for an hour, and sent the installer to check the
|
||||
// one thing that had never been sent.
|
||||
func TestTheProbeIsGivenThePasswordHeadOfficeHolds(t *testing.T) {
|
||||
e := newEngine(Local{ID: "entrance", Host: "192.168.0.138", Username: "admin",
|
||||
Connected: true}) // no Password: the engine does not return one
|
||||
p := &fakeProber{test: TestResult{OK: true, Width: 1920, Height: 1080}}
|
||||
c := &fakeChecks{jobs: []Job{{CameraID: "entrance", Kind: "connection"}}}
|
||||
desired := []Desired{{CameraID: "entrance", Username: "admin", Password: "hunter2"}}
|
||||
|
||||
checker(e, p, c).runChecks(context.Background(), desired)
|
||||
|
||||
if p.testedWith.Password != "hunter2" {
|
||||
t.Fatalf("the probe was handed password %q - a working camera would be "+
|
||||
"reported unreachable", p.testedWith.Password)
|
||||
}
|
||||
}
|
||||
|
||||
// A password the engine DOES have is not overwritten by head office's copy:
|
||||
// the local one is what the camera is actually being streamed with.
|
||||
func TestALocalPasswordWins(t *testing.T) {
|
||||
e := newEngine(Local{ID: "entrance", Password: "local", Connected: true})
|
||||
p := &fakeProber{test: TestResult{OK: true}}
|
||||
c := &fakeChecks{jobs: []Job{{CameraID: "entrance", Kind: "connection"}}}
|
||||
|
||||
checker(e, p, c).runChecks(context.Background(),
|
||||
[]Desired{{CameraID: "entrance", Password: "remote"}})
|
||||
|
||||
if p.testedWith.Password != "local" {
|
||||
t.Fatalf("probe used %q, want the engine's own", p.testedWith.Password)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user