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:
93
desktop/icons_test.go
Normal file
93
desktop/icons_test.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"image/png"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The tray icon shipped as a PNG for a while. systray hands the bytes to
|
||||
// LoadImageW, which decodes .ico only, so Windows logged one line and drew
|
||||
// nothing — and nothing on a Mac could notice. These tests are the substitute
|
||||
// for the Windows box we do not have.
|
||||
func TestEncodeICOIsAValidIconFile(t *testing.T) {
|
||||
b := encodeICO(circle(colorFor("ok")))
|
||||
if len(b) < 22 {
|
||||
t.Fatalf("far too short: %d bytes", len(b))
|
||||
}
|
||||
if got := b[:6]; !bytes.Equal(got, []byte{0, 0, 1, 0, 1, 0}) {
|
||||
t.Errorf("ICONDIR = % x, want 00 00 01 00 01 00", got)
|
||||
}
|
||||
if b[6] != iconSize || b[7] != iconSize {
|
||||
t.Errorf("entry is %dx%d, want %dx%d", b[6], b[7], iconSize, iconSize)
|
||||
}
|
||||
if bpp := binary.LittleEndian.Uint16(b[12:14]); bpp != 32 {
|
||||
t.Errorf("bits per pixel = %d, want 32", bpp)
|
||||
}
|
||||
// A wrong length here loads as a truncated or garbage icon rather than
|
||||
// failing outright, which is the harder version of this bug to spot.
|
||||
size := binary.LittleEndian.Uint32(b[14:18])
|
||||
off := binary.LittleEndian.Uint32(b[18:22])
|
||||
if off != 22 {
|
||||
t.Errorf("image offset = %d, want 22", off)
|
||||
}
|
||||
if int(off)+int(size) != len(b) {
|
||||
t.Errorf("entry claims %d bytes at %d, file is %d", size, off, len(b))
|
||||
}
|
||||
if h := binary.LittleEndian.Uint32(b[22:26]); h != 40 {
|
||||
t.Errorf("BITMAPINFOHEADER size = %d, want 40", h)
|
||||
}
|
||||
// Height must be doubled for the implied AND mask or Windows draws the
|
||||
// bottom half of the icon stretched over the whole square.
|
||||
if hh := int32(binary.LittleEndian.Uint32(b[30:34])); hh != int32(iconSize*2) {
|
||||
t.Errorf("biHeight = %d, want %d", hh, iconSize*2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeICOPixelsAreBGRABottomUp(t *testing.T) {
|
||||
want := colorFor("error") // red: distinguishable from B and G if swapped
|
||||
img := circle(want)
|
||||
b := encodeICO(img)
|
||||
// Centre of the circle, which is solid fill. Bottom-up means image row
|
||||
// iconSize/2 lands at DIB row iconSize/2-1 counting from the start.
|
||||
row := iconSize - 1 - iconSize/2
|
||||
i := 22 + 40 + (row*iconSize+iconSize/2)*4
|
||||
got := b[i : i+4]
|
||||
if !bytes.Equal(got, []byte{want.B, want.G, want.R, 0xFF}) {
|
||||
t.Errorf("centre pixel = % x, want % x (BGRA)",
|
||||
got, []byte{want.B, want.G, want.R, 0xFF})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIconForMatchesThePlatformDecoder(t *testing.T) {
|
||||
b := iconFor("ok")
|
||||
pngMagic := []byte{0x89, 'P', 'N', 'G'}
|
||||
if runtime.GOOS == "windows" {
|
||||
if bytes.HasPrefix(b, pngMagic) {
|
||||
t.Fatal("Windows tray icon is a PNG; LoadImageW will refuse it")
|
||||
}
|
||||
if !bytes.HasPrefix(b, []byte{0, 0, 1, 0}) {
|
||||
t.Fatalf("Windows tray icon is not an ICO: % x", b[:4])
|
||||
}
|
||||
return
|
||||
}
|
||||
if _, err := png.Decode(bytes.NewReader(b)); err != nil {
|
||||
t.Fatalf("non-Windows tray icon is not a decodable PNG: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The four states exist to be told apart at a glance; identical bytes would
|
||||
// mean the icon never changes and the amber "running but blind" state — the
|
||||
// one that otherwise goes unnoticed for weeks — looks exactly like healthy.
|
||||
func TestEveryStateLooksDifferent(t *testing.T) {
|
||||
seen := map[string]string{}
|
||||
for _, s := range []string{"ok", "warn", "error", "stopped"} {
|
||||
k := string(iconFor(s))
|
||||
if prev, dup := seen[k]; dup {
|
||||
t.Errorf("%q and %q render identically", prev, s)
|
||||
}
|
||||
seen[k] = s
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user