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
131 lines
4.6 KiB
Go
131 lines
4.6 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/binary"
|
|
"image"
|
|
"image/color"
|
|
"image/png"
|
|
"runtime"
|
|
|
|
agentpaths "github.com/loyaly/behavision-agent/pkg/paths"
|
|
)
|
|
|
|
// iconFor renders the tray icon at run time rather than embedding four PNGs.
|
|
//
|
|
// A 16x16 filled circle is all the taskbar shows at this size, and generating
|
|
// it means the four states cannot drift apart visually or have one file go
|
|
// missing from a build.
|
|
//
|
|
// The encoding is per-platform and is NOT cosmetic. systray writes these bytes
|
|
// to a temp file and, on Windows, hands the path to LoadImageW with
|
|
// IMAGE_ICON|LR_LOADFROMFILE — which decodes .ico and nothing else. A PNG
|
|
// there returns 0, systray logs "unable to set icon", and the product ships
|
|
// with no tray icon at all: the one control surface a shop manager has.
|
|
func iconFor(state string) []byte {
|
|
img := circle(colorFor(state))
|
|
if runtime.GOOS == "windows" {
|
|
return encodeICO(img)
|
|
}
|
|
var buf bytes.Buffer
|
|
_ = png.Encode(&buf, img)
|
|
return buf.Bytes()
|
|
}
|
|
|
|
// colorFor maps engine state to the only thing the taskbar conveys at 16px.
|
|
func colorFor(state string) color.RGBA {
|
|
switch state {
|
|
case "ok":
|
|
return color.RGBA{R: 0x2E, G: 0x9E, B: 0x60, A: 0xFF} // green
|
|
case "warn":
|
|
return color.RGBA{R: 0xE0, G: 0xA3, B: 0x3A, A: 0xFF} // amber
|
|
case "error":
|
|
return color.RGBA{R: 0xD1, G: 0x4B, B: 0x3F, A: 0xFF} // red
|
|
default:
|
|
return color.RGBA{R: 0x86, G: 0x93, B: 0x9E, A: 0xFF} // grey
|
|
}
|
|
}
|
|
|
|
const iconSize = 16
|
|
|
|
func circle(c color.RGBA) *image.RGBA {
|
|
img := image.NewRGBA(image.Rect(0, 0, iconSize, iconSize))
|
|
const r = 6.5
|
|
cx, cy := float64(iconSize)/2-0.5, float64(iconSize)/2-0.5
|
|
for y := 0; y < iconSize; y++ {
|
|
for x := 0; x < iconSize; x++ {
|
|
dx, dy := float64(x)-cx, float64(y)-cy
|
|
d := dx*dx + dy*dy
|
|
switch {
|
|
case d <= (r-1)*(r-1):
|
|
img.SetRGBA(x, y, c)
|
|
case d <= r*r:
|
|
// One-pixel feathered edge; a hard-aliased circle looks broken
|
|
// next to every other icon in the tray.
|
|
a := uint8(float64(c.A) * (r*r - d) / (r*r - (r-1)*(r-1)))
|
|
img.SetRGBA(x, y, color.RGBA{R: c.R, G: c.G, B: c.B, A: a})
|
|
}
|
|
}
|
|
}
|
|
return img
|
|
}
|
|
|
|
// encodeICO writes a single-image .ico holding an uncompressed 32-bit DIB.
|
|
//
|
|
// Vista and later also accept a PNG stored inside the .ico container, which
|
|
// would be a dozen lines instead of forty. It is not worth the risk: which
|
|
// Windows builds accept PNG-in-ICO through LoadImage rather than through the
|
|
// newer imaging APIs is genuinely murky, the failure is silent, and it would
|
|
// only ever be discovered on a customer's counter. A DIB is what every version
|
|
// of Windows has always loaded.
|
|
func encodeICO(img *image.RGBA) []byte {
|
|
w, h := img.Bounds().Dx(), img.Bounds().Dy()
|
|
// 1bpp AND mask, each row padded to a 4-byte boundary. Unused with a 32-bit
|
|
// alpha channel, but the format requires it to be present and sized.
|
|
maskRow := ((w + 31) / 32) * 4
|
|
xor := w * h * 4
|
|
dib := 40 + xor + maskRow*h
|
|
|
|
var b bytes.Buffer
|
|
// ICONDIR
|
|
binary.Write(&b, binary.LittleEndian, uint16(0)) // reserved
|
|
binary.Write(&b, binary.LittleEndian, uint16(1)) // type: icon
|
|
binary.Write(&b, binary.LittleEndian, uint16(1)) // image count
|
|
// ICONDIRENTRY. 256 is encoded as 0 in these byte fields; at 16px it is moot.
|
|
b.WriteByte(byte(w))
|
|
b.WriteByte(byte(h))
|
|
b.WriteByte(0) // palette size: none
|
|
b.WriteByte(0) // reserved
|
|
binary.Write(&b, binary.LittleEndian, uint16(1)) // colour planes
|
|
binary.Write(&b, binary.LittleEndian, uint16(32)) // bits per pixel
|
|
binary.Write(&b, binary.LittleEndian, uint32(dib)) // bytes in resource
|
|
binary.Write(&b, binary.LittleEndian, uint32(6+16)) // offset to that data
|
|
|
|
// BITMAPINFOHEADER. Height is doubled because the DIB nominally stacks the
|
|
// colour image and the AND mask; omitting the doubling renders half an icon
|
|
// stretched over the whole square.
|
|
binary.Write(&b, binary.LittleEndian, uint32(40))
|
|
binary.Write(&b, binary.LittleEndian, int32(w))
|
|
binary.Write(&b, binary.LittleEndian, int32(h*2))
|
|
binary.Write(&b, binary.LittleEndian, uint16(1))
|
|
binary.Write(&b, binary.LittleEndian, uint16(32))
|
|
binary.Write(&b, binary.LittleEndian, uint32(0)) // BI_RGB, uncompressed
|
|
binary.Write(&b, binary.LittleEndian, uint32(xor+maskRow*h))
|
|
for i := 0; i < 4; i++ { // resolution and palette counts, all unused
|
|
binary.Write(&b, binary.LittleEndian, uint32(0))
|
|
}
|
|
|
|
// Pixels: BGRA, bottom-up. Go's image is top-down and RGBA, so both the row
|
|
// order and the channel order invert here.
|
|
for y := h - 1; y >= 0; y-- {
|
|
for x := 0; x < w; x++ {
|
|
c := img.RGBAAt(x, y)
|
|
b.Write([]byte{c.B, c.G, c.R, c.A})
|
|
}
|
|
}
|
|
b.Write(make([]byte, maskRow*h)) // all-zero: every pixel opaque per the mask
|
|
return b.Bytes()
|
|
}
|
|
|
|
func logsDir() string { return agentpaths.StateRoot() }
|