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:
174
desktop/tray.go
Normal file
174
desktop/tray.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"fyne.io/systray"
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
// tray is the always-present control surface. Wails v2 has no systray of its
|
||||
// own, so this drives fyne.io/systray alongside the window.
|
||||
//
|
||||
// It is a CLIENT of the app, not a second copy of it: everything it shows
|
||||
// comes from EngineStatus(), so the tray and the dashboard can never disagree
|
||||
// about whether recognition is running.
|
||||
type tray struct {
|
||||
app *App
|
||||
once sync.Once
|
||||
quit chan struct{}
|
||||
|
||||
mStatus *systray.MenuItem
|
||||
mOpen *systray.MenuItem
|
||||
mStart *systray.MenuItem
|
||||
mStop *systray.MenuItem
|
||||
mLogs *systray.MenuItem
|
||||
mQuit *systray.MenuItem
|
||||
}
|
||||
|
||||
func newTray(a *App) *tray { return &tray{app: a, quit: make(chan struct{})} }
|
||||
|
||||
func (t *tray) start(ctx context.Context) {
|
||||
t.once.Do(func() {
|
||||
go systray.Run(func() { t.onReady(ctx) }, func() {})
|
||||
})
|
||||
}
|
||||
|
||||
func (t *tray) stop() {
|
||||
select {
|
||||
case <-t.quit:
|
||||
default:
|
||||
close(t.quit)
|
||||
}
|
||||
systray.Quit()
|
||||
}
|
||||
|
||||
func (t *tray) onReady(ctx context.Context) {
|
||||
systray.SetTitle("Behavision")
|
||||
systray.SetTooltip("Behavision — starting")
|
||||
systray.SetIcon(iconFor("stopped"))
|
||||
|
||||
t.mStatus = systray.AddMenuItem("Starting…", "")
|
||||
t.mStatus.Disable()
|
||||
systray.AddSeparator()
|
||||
t.mOpen = systray.AddMenuItem("Open dashboard", "Show the Behavision window")
|
||||
systray.AddSeparator()
|
||||
t.mStart = systray.AddMenuItem("Start recognition", "Start the engine")
|
||||
t.mStop = systray.AddMenuItem("Stop recognition", "Stop the engine")
|
||||
t.mLogs = systray.AddMenuItem("Open logs folder", "")
|
||||
systray.AddSeparator()
|
||||
t.mQuit = systray.AddMenuItem("Quit Behavision", "Stops recognition")
|
||||
|
||||
go t.poll(ctx)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-t.quit:
|
||||
return
|
||||
case <-t.mOpen.ClickedCh:
|
||||
runtime.Show(ctx)
|
||||
case <-t.mStart.ClickedCh:
|
||||
t.app.StartEngine()
|
||||
case <-t.mStop.ClickedCh:
|
||||
t.app.StopEngine()
|
||||
case <-t.mLogs.ClickedCh:
|
||||
runtime.BrowserOpenURL(ctx, "file://"+logsDir())
|
||||
case <-t.mQuit.ClickedCh:
|
||||
// Quitting the tray stops recognition. Leaving the engine running
|
||||
// with no visible control is worse than stopping it: nobody would
|
||||
// know it was still watching.
|
||||
t.app.StopEngine()
|
||||
runtime.Quit(ctx)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// poll keeps the icon honest. The colour answers the only question a shop
|
||||
// manager glancing at the taskbar has: is it working right now.
|
||||
func (t *tray) poll(ctx context.Context) {
|
||||
tick := time.NewTicker(5 * time.Second)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-t.quit:
|
||||
return
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-tick.C:
|
||||
s := t.app.EngineStatus()
|
||||
state, label := describe(s)
|
||||
systray.SetIcon(iconFor(state))
|
||||
systray.SetTooltip("Behavision — " + label)
|
||||
if t.mStatus != nil {
|
||||
t.mStatus.SetTitle(label)
|
||||
}
|
||||
running := s.State == "running"
|
||||
if t.mStart != nil && t.mStop != nil {
|
||||
if running {
|
||||
t.mStart.Disable()
|
||||
t.mStop.Enable()
|
||||
} else {
|
||||
t.mStart.Enable()
|
||||
t.mStop.Disable()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// describe collapses engine state into the three things worth showing.
|
||||
//
|
||||
// "Running but no camera connected" is deliberately amber, not green: the
|
||||
// process is fine and the product is not working, and that is exactly the
|
||||
// state that otherwise goes unnoticed for weeks.
|
||||
func describe(s EngineStatus) (state, label string) {
|
||||
switch {
|
||||
case s.State == "stopped":
|
||||
return "stopped", "Stopped"
|
||||
case s.State == "failed":
|
||||
return "error", "Failed — " + firstLine(s.Error)
|
||||
case s.State == "backoff":
|
||||
return "error", fmt.Sprintf("Restarting (%d attempts)", s.Restarts)
|
||||
case !s.Reachable:
|
||||
return "warn", "Starting…"
|
||||
case len(s.Cameras) == 0:
|
||||
return "warn", "Running — no cameras configured"
|
||||
default:
|
||||
up := 0
|
||||
for _, ok := range s.Cameras {
|
||||
if ok {
|
||||
up++
|
||||
}
|
||||
}
|
||||
if up == 0 {
|
||||
return "error", fmt.Sprintf("No camera connected (0 of %d)", len(s.Cameras))
|
||||
}
|
||||
if up < len(s.Cameras) {
|
||||
return "warn", fmt.Sprintf("%d of %d cameras live", up, len(s.Cameras))
|
||||
}
|
||||
return "ok", fmt.Sprintf("Watching %d camera%s", up, plural(up))
|
||||
}
|
||||
}
|
||||
|
||||
func plural(n int) string {
|
||||
if n == 1 {
|
||||
return ""
|
||||
}
|
||||
return "s"
|
||||
}
|
||||
|
||||
func firstLine(s string) string {
|
||||
for i, r := range s {
|
||||
if r == '\n' {
|
||||
return s[:i]
|
||||
}
|
||||
}
|
||||
if len(s) > 60 {
|
||||
return s[:60] + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
Reference in New Issue
Block a user