Files
Behavision/agent/pkg/bridge/images.go
Suriyakumarvijayanayagam dad04e8cda 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
2026-09-04 11:14:18 +05:30

206 lines
6.5 KiB
Go

package bridge
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
// Uploader sends one face image to object storage.
//
// It is an interface because the bridge must work identically when images are
// off, when the PC is not enrolled yet, and when the server has no bucket
// configured - three states that are normal rather than exceptional.
type Uploader interface {
// Upload returns the object key the server assigned.
Upload(ctx context.Context, path string) (key string, err error)
}
// SpacesUploader uploads through a URL the server mints.
//
// The shop PC holds no bucket credentials, only its own agent token. That is
// the point: the bucket is shared with other applications and a counter-top PC
// is the least trustworthy machine in the estate, so a stolen one gives up a
// few minutes of write access to one key rather than a bucket password.
type SpacesUploader struct {
// BaseURL is the server, e.g. https://mcp.loyaly.ai
BaseURL string
// Token is the agent's own API credential, issued at enrolment. Separate
// from the broker password so rotating either does not break the other.
Token string
Client *http.Client
}
// ErrImagesOff means the server stores no images. Distinct from a failure: the
// agent should stop trying and carry on sending visits, not retry forever.
var ErrImagesOff = errors.New("server does not store images")
// maxImageBytes bounds what will be read off disk and sent. The engine writes
// ~20 KB crops; anything near this is a bug or a different file that landed in
// the outbox, and a shop uplink should not spend minutes discovering that.
const maxImageBytes = 2 << 20
func (u *SpacesUploader) httpClient() *http.Client {
if u.Client != nil {
return u.Client
}
// Long enough for a slow shop uplink, bounded so a half-open connection
// cannot stall the queue behind it.
return &http.Client{Timeout: 60 * time.Second}
}
type uploadTarget struct {
Key string `json:"key"`
URL string `json:"url"`
Headers map[string]string `json:"headers"`
ExpiresIn int `json:"expires_in"`
}
func (u *SpacesUploader) Upload(ctx context.Context, path string) (string, error) {
if u.BaseURL == "" || u.Token == "" {
// Not claimed yet. The visit still queues; it simply has no photo.
return "", ErrImagesOff
}
info, err := os.Stat(path)
if err != nil {
return "", err
}
if info.Size() == 0 {
return "", errors.New("image file is empty")
}
if info.Size() > maxImageBytes {
return "", fmt.Errorf("image is %d bytes, over the %d limit",
info.Size(), maxImageBytes)
}
body, err := os.ReadFile(path)
if err != nil {
return "", err
}
return u.UploadBytes(ctx, body)
}
// UploadBytes puts an image already in memory.
//
// Split out for camera snapshots, which the engine hands over as bytes. The
// alternative - writing each frame to a temp file so Upload could read it back
// - would put a picture of a shop floor on disk once a minute per camera, on
// the one machine in the estate least worth trusting with it.
func (u *SpacesUploader) UploadBytes(ctx context.Context, body []byte) (string, error) {
if u.BaseURL == "" || u.Token == "" {
return "", ErrImagesOff
}
if len(body) == 0 {
return "", errors.New("image is empty")
}
if int64(len(body)) > maxImageBytes {
return "", fmt.Errorf("image is %d bytes, over the %d limit",
len(body), maxImageBytes)
}
target, err := u.target(ctx)
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPut, target.URL,
bytes.NewReader(body))
if err != nil {
return "", err
}
// Sent exactly as handed back. The ACL is inside the server's signature, so
// changing or dropping it does not publish the image - it fails the upload,
// which is the safe direction.
for k, v := range target.Headers {
req.Header.Set(k, v)
}
req.ContentLength = int64(len(body))
resp, err := u.httpClient().Do(req)
if err != nil {
return "", fmt.Errorf("upload: %w", err)
}
defer resp.Body.Close()
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return "", fmt.Errorf("upload returned %s: %s",
resp.Status, strings.TrimSpace(string(msg)))
}
return target.Key, nil
}
func (u *SpacesUploader) target(ctx context.Context) (uploadTarget, error) {
var out uploadTarget
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
strings.TrimRight(u.BaseURL, "/")+"/api/agent/upload-url", nil)
if err != nil {
return out, err
}
req.Header.Set("Authorization", "Bearer "+u.Token)
resp, err := u.httpClient().Do(req)
if err != nil {
return out, fmt.Errorf("ask for an upload url: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotImplemented {
return out, ErrImagesOff
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
return out, fmt.Errorf("upload url returned %s: %s",
resp.Status, strings.TrimSpace(string(body)))
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(&out); err != nil {
return out, err
}
if out.URL == "" || out.Key == "" {
return out, errors.New("server returned an incomplete upload target")
}
return out, nil
}
// attachImage uploads the engine's face image and returns the object key.
//
// Every failure is non-fatal and the local file is removed regardless. A visit
// without a photo is a real visit and the number the customer pays for; a
// visit stuck behind a failed upload is lost footfall. Keeping the file for a
// retry would also mean an outbox that grows for as long as the failure lasts,
// full of pictures of customers.
func (b *Bridge) attachImage(ctx context.Context, visit map[string]any, path string) {
if path == "" {
return
}
defer func() {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
b.logf("could not remove %s after upload: %v", filepath.Base(path), err)
}
}()
if b.Uploader == nil {
return
}
// Bounded separately from the caller: an upload that hangs must not hold
// up the visit it belongs to.
uctx, cancel := context.WithTimeout(ctx, 90*time.Second)
defer cancel()
key, err := b.Uploader.Upload(uctx, path)
if err != nil {
if errors.Is(err, ErrImagesOff) {
// Normal for a deployment that stores no images, and for a PC that
// has not been claimed yet. Not worth a line per visitor.
return
}
b.logf("image upload failed for %s, sending the visit without it: %v",
filepath.Base(path), err)
return
}
visit["image_key"] = key
}