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:
2026-09-04 11:14:18 +05:30
commit dad04e8cda
216 changed files with 40473 additions and 0 deletions

39
.gitignore vendored Normal file
View File

@@ -0,0 +1,39 @@
__pycache__/
*.pyc
.venv/
venv/
.env
data/
models/*.onnx
models/*.caffemodel
models/*.prototxt
*.log
.pytest_cache/
# run-local.sh's working directory: the built binary, the encryption key and
# the broker's password file. Nothing here belongs in a repository.
.local/
# The agent's local state when BEHAVISION_DATA_DIR points at a checkout.
# agent.json holds this PC's broker password and its API token.
agent.json
spool/
# Build output. The Windows package is ~400 MB unpacked and is rebuilt from
# source by installer/build.ps1; the WebView2 bootstrapper is Microsoft's
# redistributable, fetched at build time rather than vendored into history.
/dist/
/build/
desktop/build/bin/
installer/vendor/
node_modules/
# NOT ignored, deliberately: server/internal/web/dist and
# desktop/frontend/dist. Both are `go:embed`ed at COMPILE time, so without
# them in the tree `go build ./...` fails on a fresh checkout - on a machine
# that may have no npm at all. They are ~200 KB and regenerating them is one
# command; a repository that does not compile is the more expensive problem.
# macOS finder metadata and rotated engine logs.
.DS_Store
*.log.[0-9]

1986
CLAUDE.md Normal file

File diff suppressed because it is too large Load Diff

127
README.md Normal file
View File

@@ -0,0 +1,127 @@
# Behavision
Production face recognition over RTSP. Watches camera streams, detects and
tracks faces, recognizes known people, auto-enrolls new visitors, records
visit history, and serves a live dashboard + JSON API.
Clean-room rewrite of the previous `Camera/` and `pattern_reg/` projects:
same core ideas, correct engineering.
## Quick start (Windows)
```powershell
cd D:\NEARLE\Behavision
python -m venv .venv
.venv\Scripts\activate
pip install -r requirements.txt
python -m behavision setup-models # downloads YuNet, copies ArcFace etc. from the old project
python -m behavision run # dashboard at http://localhost:8010
```
Camera credentials live in `.env` (gitignored) — never in code or YAML.
So do the dashboard credentials: set `BEHAVISION_API_USER` and
`BEHAVISION_API_PASSWORD`, or let the server generate one into
`data/api_credentials.txt` on first boot. A routable `api.host` is never
served without HTTP Basic auth; `127.0.0.1` is left open.
To test without a camera, set `webcam: 0` on a camera in
`config/default.yaml`.
Enroll a person by name from photos:
```powershell
python -m behavision enroll --name "Alice" --images C:\photos\alice\
```
## Architecture
```
behavision/
├── config.py typed config: YAML + ${ENV} expansion, validated (pydantic)
├── capture.py RTSP/webcam reader thread: latest-frame slot, TCP transport,
│ exponential-backoff reconnect, percent-encoded credentials
├── detection.py YuNet face detector (OpenCV) → boxes + 5 landmarks, clipped
├── recognition.py ArcFace ONNX encoder (correct (x-127.5)/127.5 RGB preprocessing,
│ unit-norm output) + clamped face-quality scoring
├── tracking.py IoU tracker: identity decided once per TRACK, not per frame
├── gallery/
│ ├── index.py FAISS IndexFlatIP (exact cosine) with identical numpy fallback
│ ├── store.py SQLite (WAL): identities, embeddings, sightings — source of truth
│ └── service.py three-zone matching: match / ambiguous(do nothing) / enroll
├── attributes.py optional age, gender, emotion on the aligned chip
├── events.py async event bus → log / webhook / rate-limited email sinks
├── engine.py one worker thread per camera, shared models + gallery
├── api.py FastAPI: dashboard, MJPEG stream, identities, events, stats
└── __main__.py CLI: run | enroll | setup-models
```
### Pipeline
```
RTSP ──► capture ──► detect (YuNet) ──► track (IoU)
│ once per track, quality-gated
▼
align (Umeyama 5-pt) ──► ArcFace ──► cosine search
│
┌───────────────────────────┼──────────────────────────┐
sim ≥ 0.42 0.32 ≤ sim < 0.42 sim < 0.32
known person ambiguous → retry new visitor
sighting + event on a better frame auto-enroll + event
```
### Design decisions (and the failure they prevent)
| Decision | Prevents |
|---|---|
| Per-camera `FaceDetector`, shared thread-safe encoder | cv2 input-size race between camera workers |
| HTTP Basic on every route, escaped dashboard output | open biometric API on the LAN; stored XSS via identity labels |
| Percent-encoded credentials, URL built from parts | `@` in password silently breaking the stream (old bug) |
| Track-level identity, sighting cooldown | one user registered per frame (old bug) |
| Exact `IndexFlatIP` on unit vectors, `-1` guarded | inverted L2 threshold + wrong-person `metadata[-1]` (old bugs) |
| SQLite as source of truth, index rebuilt at boot | index/metadata drift, untrained-IVF crash (old bugs) |
| Three-zone thresholds with ambiguous no-op | duplicate identities *and* wrong merges |
| One color conversion, ArcFace-native normalization | off-distribution embeddings making thresholds meaningless (old bug) |
| All quality terms clamped to [0,1] | unreachable registration threshold (old bug) |
| Readiness-guarded API, sinks off the hot path | startup crashes, notification stalls |
## API
| Method | Path | Purpose |
|---|---|---|
| GET | `/` | live dashboard |
| GET | `/api/health`, `/api/stats` | liveness / metrics |
| GET | `/api/cameras/{id}/stream.mjpeg` | annotated live stream |
| GET | `/api/cameras/{id}/frame.jpg` | latest annotated frame |
| GET | `/api/identities`, `/api/sightings`, `/api/events` | data |
| PATCH | `/api/identities/{id}` | rename a visitor (`{"label": "Alice"}`) |
| DELETE | `/api/identities/{id}` | forget a person (embeddings removed) |
## Tests
```powershell
pip install pytest
pytest tests -q
```
## Configuration
Everything lives in `config/default.yaml`; `${VAR}` placeholders resolve
from the environment (`.env` is loaded first). Thresholds:
- `recognition.match_threshold` (default 0.42): raise for fewer false
matches, lower for fewer duplicates.
- `recognition.min_enroll_quality` (0.65): how good a face must look
(sharpness, size, lighting, frontality) before a new identity is minted.
- `tracking.min_hits_for_id` (4): frames a face must persist before we
spend an embedding on it — filters passers-by and phantom detections.
- `cameras[].max_width` (1280): frames are downscaled at ingest — full
3MP streams waste memory and detector time.
## Recognition models
The encoder picks the first usable model in `models/`:
`arcface_int8.onnx` → `w600k_mbf.onnx` (MobileFaceNet, 13 MB, downloaded
automatically) → `arcface.onnx` (r100, 260 MB, copied from the old project;
needs ~1.5 GB free RAM to load). Pin one with `recognition.model_file`.
Every stored embedding is tagged with the model that produced it, and only
embeddings from the active model are searched — different encoders'
vectors are numerically incompatible and never mix.

217
RUN.md Normal file
View File

@@ -0,0 +1,217 @@
# Running the platform locally
Three processes and two containers. Nothing here touches production.
## The short way
```bash
./run-local.sh # Postgres + Mosquitto + build + accounts + run
```
Idempotent — run it again after a reset and it rebuilds the same state. It
prints the two logins and serves <http://127.0.0.1:8088>.
**Port 8088, not 8080.** Docker Desktop listens on 127.0.0.1:8080 itself, and
an earlier run of this ended up talking to Docker's own listener and reading
its 401 as a Behavision one.
Everything it writes lives in `.local/` — the binary, the encryption key, the
broker's password file. Keep `.local/env.sh`: without that key every sealed
camera and broker password is unrecoverable.
The rest of this file is the same thing done by hand.
## 1. Database
```bash
docker run -d --name bv-pg -p 55432:5432 \
-e POSTGRES_PASSWORD=test -e POSTGRES_DB=behavision \
pgvector/pgvector:pg16 # pgvector, NOT plain postgres — 001 needs it
for f in server/migrations/*.sql; do
docker exec -i bv-pg psql -U postgres -d behavision -v ON_ERROR_STOP=1 -q < "$f"
done
```
## 2. Build
The web app builds INTO the Go module, so the server must be built after it.
```bash
cd web && npm install && npm run build # -> server/internal/web/dist
cd ../server && go build -o bv-server ./cmd/behavision-server
```
## 3. First accounts
The first platform admin comes from the CLI, because creating it cannot require
being signed in as one.
```bash
export DATABASE_URL='postgres://postgres:test@127.0.0.1:55432/behavision'
# -raw prints the key alone. Without it the command prints JSON, for pasting
# into an env file - and `$(... | tail -1)` then captures the whole object and
# hands the server a key it cannot parse.
export BEHAVISION_SECRET_KEY="$(./bv-server provision key -raw)"
./bv-server provision user -email root@loyaly.ai -role admin \
-name "Loyaly Platform" -password 'loyaly-root-2026'
```
Every company after that is created from the web UI: sign in as the admin,
**Companies → New company**. That is the only place tenants are made — there is
no public registration.
## 4. Run
```bash
LISTEN_ADDR=127.0.0.1:8080 ./bv-server
```
Open <http://127.0.0.1:8080>. The API and the web app are the same binary on the
same port; in production Traefik puts `platform.loyaly.ai` in front of it.
## Accounts in the local demo
| who | sign in | sees |
|---|---|---|
| Platform admin | `admin@loyaly.ai` / `loyaly-platform-2026` | Companies only |
| TeNext owner | `suriya@tenext.in` / `tenext-2026` | Shops, Live, Cameras, Customers, Reports |
One address is one account across the whole platform, so the same email cannot
be both a platform admin and a tenant user. See CLAUDE.md.
## Optional: the broker, for live arrivals
Only needed to watch visits arrive in real time. Reports and Customers work
without it.
```bash
docker run -d --name bv-mqtt -p 51883:1883 \
-v "$PWD/mosquitto:/mosquitto/config" eclipse-mosquitto:2
```
The config needs a `passwd` file containing the server's own broker user and one
user per site, and an `acl` granting the server `read bv/#` and each site
`write bv/<client>.<site>/#`. `provision site` prints the site's password once.
Then run the server with `MQTT_URL`, `MQTT_USERNAME` and `MQTT_PASSWORD` set.
## Onboarding a shop, the way a customer is onboarded
1. **Company** — sign in as the platform admin, **Companies → New company**.
The owner's password is shown once.
2. **Shop** — `provision site -client <slug> -site <slug> ...`, then add the
broker user it prints to Mosquitto. Still a command; see the note in
CLAUDE.md about what would have to change for this to be self-service.
3. **Installation code** — sign in as the owner, **Shops → the shop →
Set up a shop PC → Create an installation code**. Shown once, works once.
4. **The shop PC** — open Behavision on it and type the code into **Set up this
PC**. It comes back with the broker credentials, its own API token and its
topic prefix, and starts publishing immediately.
5. **Cameras** — **Cameras → Set up a camera** at head office. The shop PC picks
it up within about two minutes.
6. **Prove it** — **Shops → the shop → Run the check**.
## Running a real shop PC on this machine
The engine and the agent both honour `BEHAVISION_DATA_DIR`, so a checkout can
act as a shop PC:
```bash
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
cd agent && CGO_ENABLED=0 go build -o ../.local/behavision-agent . && cd ..
# agent.json: written by the desktop app's "Set up this PC" screen in real life.
# Locally, redeem a code by hand and write client_slug / site_slug / broker
# credentials / agent_token into $BEHAVISION_DATA_DIR/agent.json.
BEHAVISION_DATA_DIR=$PWD ./.local/behavision-agent run
```
The agent starts the engine itself — do not also run `python -m behavision run`,
or two processes fight over one camera and one SQLite WAL. It passes the
bridge's URL to the engine through `BEHAVISION_WEBHOOK_URL`, and reads the
engine's generated Basic credential from `data/api_credentials.txt`.
`./.local/behavision-agent status` prints what the agent can see of the engine —
the fastest way to tell "the engine is down" from "the agent cannot talk to it".
## Cameras
Onboard one from **Cameras → Set up a camera** on the platform. The shop PC picks it
up within about two minutes and it turns from *Waiting for the shop PC* to
*Connected* once the agent has actually reached it.
Cameras already configured on a shop PC appear here on their own — the agent
offers them up on its first sync, so switching this on does not disturb a site
that is already running.
The picture on each card is the engine's **latest frame**, uploaded about once a
minute, not live video: the camera stream lives on the shop PC's loopback behind
a router, and putting live video in a browser at head office needs a relay this
deployment does not have.
Camera passwords are encrypted with `BEHAVISION_SECRET_KEY`. Without that key
set, a camera can be saved but not with a password, and the API says so rather
than storing it blank.
## Proving a camera works
**Cameras → Set up a camera** walks it in four steps: name and make, connection
details, a connection test, then a walk-past check. Picking the make fills in
the RTSP path, which is the field nobody can find.
A camera is only "verified" once someone has walked past it. Until then the card
says *Not checked yet* even when the stream is connected — those are different
claims, and a camera can stream perfectly while producing views nothing can
recognise.
**Cameras → Is this shop working?** runs the end-to-end check: PC online,
recognition running, cameras connected, faces recognisable, visits arriving. It
stops at the first failure rather than guessing past it.
Checks are jobs the shop PC picks up on its next sync, so allow a couple of
minutes — or restart the agent to make it sync now.
## The assistant
Set `ANTHROPIC_API_KEY` before starting the server and the **Ask** button
appears in the top bar. Without it the server logs that the assistant is off,
the panel says so, and nothing else changes.
It answers from the same business questions the screens ask — shops, cameras,
footfall, sales, customers — and can request a camera check. Every tool runs as
the signed-in user, so it can only see what that person could already see, and
staff are refused camera checks the same way the UI refuses them.
Uses `claude-sonnet-5`; set `BEHAVISION_ASSISTANT_MODEL` to change it without a
rebuild. A conversation is capped at 8 tool round trips.
If your key is **identity-linked** (issued against a user rather than an org),
it also needs `ANTHROPIC_WORKSPACE_ID` — a `wrkspc_...` id from
console.anthropic.com → Settings → Workspaces. Without it every Anthropic
endpoint returns 400, and the server says so by name rather than reporting a
generic fault. A classic API key needs nothing extra.
## Tests
```bash
cd server && go test ./... # no database needed
cd server && TEST_DATABASE_URL="$DATABASE_URL" go test ./... # adds the SQL tests
cd agent && go test ./...
```
The live database tests skip themselves when `TEST_DATABASE_URL` is unset, so
the suite stays runnable with no services — the same rule the bucket tests and
the Python tests follow.
## Windows binaries, from a Mac
```bash
cd desktop/frontend && npm run build # go:embed needs frontend/dist
cd .. && GOOS=windows CGO_ENABLED=0 go build -o behavision-desktop.exe .
cd ../agent && GOOS=windows CGO_ENABLED=0 go build -o behavision-agent.exe .
```
These compile. They have never been RUN on Windows — no `wails build`, no
installer, no code signing.

44
agent/README.md Normal file
View File

@@ -0,0 +1,44 @@
# Behavision agent (Go)
The half of the edge install that touches the network. The Python engine keeps
the cameras and the models; this keeps the tray icon, the UI shell, the MQTT
connection and the offline queue.
┌─ agent (Go) ───────────────────┐ ┌─ engine (Python) ────────┐
│ tray icon + WebView2 window │ │ RTSP capture │
│ supervises the engine process │───────▶│ YuNet / ArcFace / FAISS │
│ MQTT publish + offline spool │◀───────│ SQLite (biometric) │
│ S3 handoff, tenant config │ local │ localhost API + events │
└────────────────────────────────┘ HTTP └──────────────────────────┘
## Why the split
Go cannot run ONNX, OpenCV or FAISS, so the engine stays Python and ships
frozen. Go is here for what it is actually better at: a durable queue that
survives a store's internet dropping, a supervised child process, and one
language shared with the server so the MQTT contract has a single definition.
## Why not a Windows service
A service runs in session 0 and **cannot draw a tray icon** — that is Windows
session isolation, not a library limitation. Since the product is "user starts
and stops it from the tray", the agent is a normal user-session process that
spawns the engine as a child. That also means it never needs elevation at
runtime: starting a child process does not, controlling a service does.
`internal/engine` is written so a service wrapper can be added later without
touching the supervision logic.
## Layout
main.go entry point, mode dispatch
internal/engine start/stop/supervise the Python engine, health polling
internal/spool durable event queue (survives restart and outage)
internal/mqtt broker client, publishes from the spool
internal/config tenant identity, broker settings, credentials
frontend/ React UI served into the WebView
## Build
go build ./... # agent alone
wails build # agent + frontend, once the UI is added

117
agent/cmd/e2e/main.go Normal file
View File

@@ -0,0 +1,117 @@
// End-to-end probe: publish visits through the real agent path.
//
// SIM controls the similarity of the second visit to the first, which is what
// exercises the reinforcement branch: identical vectors teach the gallery
// nothing and must be refused, a genuinely different view of the same person
// must be kept.
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"math"
"math/rand"
"os"
"strconv"
"time"
"github.com/loyaly/behavision-agent/pkg/mqtt"
"github.com/loyaly/behavision-agent/pkg/spool"
)
func unit(seed int64) []float32 {
rng := rand.New(rand.NewSource(seed))
v := make([]float32, 512)
var n float64
for i := range v {
v[i] = float32(rng.NormFloat64())
n += float64(v[i]) * float64(v[i])
}
n = math.Sqrt(n)
for i := range v {
v[i] /= float32(n)
}
return v
}
// atSimilarity builds a unit vector exactly `target` from base.
func atSimilarity(base []float32, target float64, seed int64) []float32 {
other := unit(seed)
var dot float64
for i := range base {
dot += float64(base[i]) * float64(other[i])
}
var n float64
for i := range other {
other[i] -= float32(dot) * base[i]
n += float64(other[i]) * float64(other[i])
}
n = math.Sqrt(n)
out := make([]float32, len(base))
k := math.Sqrt(1 - target*target)
for i := range base {
out[i] = float32(target)*base[i] + float32(k)*(other[i]/float32(n))
}
return out
}
func main() {
broker, user := os.Getenv("BROKER"), os.Getenv("MQTT_USER")
logger := log.New(os.Stdout, " ", 0)
q, err := spool.Open(os.Getenv("SPOOL_DIR"), 100)
if err != nil {
log.Fatal(err)
}
sim, _ := strconv.ParseFloat(os.Getenv("SIM"), 64)
emb := unit(42)
if sim > 0 {
emb = atSimilarity(unit(42), sim, 7)
}
quality, _ := strconv.ParseFloat(os.Getenv("QUALITY"), 64)
if quality == 0 {
quality = 0.74
}
visit := map[string]any{
"event_id": os.Getenv("EVENT_ID"),
"occurred_at": time.Now().UTC().Format(time.RFC3339Nano),
"camera_id": "entrance",
"is_new": sim == 0,
"quality": quality,
"model": "w600k_r50.onnx",
"embedding": emb,
"attributes": map[string]any{"gender": "Male", "age": 34},
}
if err := q.Append(fmt.Sprintf("bv/%s/visit", user), visit); err != nil {
log.Fatal(err)
}
client, err := mqtt.NewClient(mqtt.ClientOptions{
BrokerURL: broker, ClientID: "e2e-probe-" + os.Getenv("EVENT_ID"),
Username: user, Password: os.Getenv("MQTT_PASS"),
CAFile: os.Getenv("CA_FILE"), Log: logger,
})
if err != nil {
log.Fatalf("connect: %v", err)
}
defer client.Close()
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
go (&mqtt.Pump{Queue: q, Publisher: client, Log: logger}).Run(ctx)
deadline := time.Now().Add(15 * time.Second)
for time.Now().Before(deadline) {
if q.Len() == 0 {
b, _ := json.Marshal(map[string]any{"sim": sim, "quality": quality})
logger.Printf("published %s", b)
return
}
time.Sleep(200 * time.Millisecond)
}
log.Fatalf("spool did not drain: %d left", q.Len())
}

10
agent/go.mod Normal file
View File

@@ -0,0 +1,10 @@
module github.com/loyaly/behavision-agent
go 1.22
require (
github.com/eclipse/paho.mqtt.golang v1.4.3 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
golang.org/x/net v0.8.0 // indirect
golang.org/x/sync v0.1.0 // indirect
)

8
agent/go.sum Normal file
View File

@@ -0,0 +1,8 @@
github.com/eclipse/paho.mqtt.golang v1.4.3 h1:2kwcUGn8seMUfWndX0hGbvH8r7crgcJguQNCyp70xik=
github.com/eclipse/paho.mqtt.golang v1.4.3/go.mod h1:CSYvoAlsMkhYOXh/oKyxa8EcBci6dVkLCbo5tTC1RIE=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=

311
agent/main.go Normal file
View File

@@ -0,0 +1,311 @@
// Command behavision-agent is the Go half of the edge install: it supervises
// the Python recognition engine and moves its events to the server.
//
// Modes:
//
// run supervise the engine and drain the spool (what the tray runs)
// status one-shot health report, for support and for the installer
// paths where this agent thinks state lives
//
// The tray and the Wails UI wrap this; none of the logic below assumes a
// window exists, so `run` works headless over SSH or from a scheduled task.
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/loyaly/behavision-agent/pkg/bridge"
"github.com/loyaly/behavision-agent/pkg/cameras"
"github.com/loyaly/behavision-agent/pkg/config"
"github.com/loyaly/behavision-agent/pkg/engine"
"github.com/loyaly/behavision-agent/pkg/mqtt"
"github.com/loyaly/behavision-agent/pkg/paths"
"github.com/loyaly/behavision-agent/pkg/spool"
)
var version = "dev"
func main() {
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "behavision-agent %s\n\nusage: %s <run|status|paths>\n",
version, filepath.Base(os.Args[0]))
}
flag.Parse()
mode := "run"
if flag.NArg() > 0 {
mode = flag.Arg(0)
}
var err error
switch mode {
case "run":
err = cmdRun()
case "status":
err = cmdStatus()
case "paths":
err = cmdPaths()
default:
flag.Usage()
os.Exit(2)
}
if err != nil {
log.Fatalf("behavision-agent: %v", err)
}
}
func cmdPaths() error {
return json.NewEncoder(os.Stdout).Encode(map[string]string{
"version": version,
"install_root": paths.InstallRoot(),
"state_root": paths.StateRoot(),
"agent_config": paths.AgentConfig(),
"spool": paths.SpoolDir(),
"engine_log": paths.EngineLog(),
})
}
func cmdStatus() error {
cfg, err := config.Load(paths.AgentConfig())
if err != nil {
return err
}
// The engine invents its own Basic credential when none is configured,
// which is the default. Reading it here is what stops every call the agent
// makes to the engine coming back 401 on a stock install.
cfg = cfg.WithEngineCredentials(paths.APICredentials())
q, err := spool.Open(paths.SpoolDir(), cfg.SpoolMax)
if err != nil {
return err
}
sup := engine.New(engine.Options{
Command: func(context.Context) *exec.Cmd { return nil },
HealthURL: strings.TrimRight(cfg.APIBase, "/") + "/api/health",
StatsURL: strings.TrimRight(cfg.APIBase, "/") + "/api/stats",
User: cfg.APIUser, Password: cfg.APIPassword,
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
out := map[string]any{
"version": version,
"configured": cfg.Configured(),
"secrets_protected": config.SecretsProtected(),
"queued": q.Len(),
"dropped": q.Dropped(),
}
if h, err := sup.Health(ctx); err != nil {
out["engine"] = map[string]any{"reachable": false, "error": err.Error()}
} else {
out["engine"] = h
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(out)
}
func cmdRun() error {
logger := log.New(os.Stdout, "", log.LstdFlags|log.LUTC)
if err := paths.EnsureState(); err != nil {
return err
}
cfg, err := config.Load(paths.AgentConfig())
if err != nil {
return err
}
// The engine invents its own Basic credential when none is configured,
// which is the default. Reading it here is what stops every call the agent
// makes to the engine coming back 401 on a stock install.
cfg = cfg.WithEngineCredentials(paths.APICredentials())
// Opened before the engine starts: detections arriving in the first second
// must have somewhere to land.
q, err := spool.Open(paths.SpoolDir(), cfg.SpoolMax)
if err != nil {
return fmt.Errorf("spool: %w", err)
}
logFile, err := engine.LogFile(paths.EngineLog())
if err != nil {
return err
}
defer logFile.Close()
exe := cfg.EngineExe
if !filepath.IsAbs(exe) {
// Resolved against the install root, not the working directory: a
// service or a shortcut can start us anywhere.
exe = filepath.Join(paths.InstallRoot(), exe)
}
// hookURL is read when the engine is LAUNCHED, not when the supervisor is
// built, because the bridge has not picked its port yet and because a
// restarted engine has to be told again.
var hookURL string
sup := engine.New(engine.Options{
Command: func(ctx context.Context) *exec.Cmd {
cmd := exec.CommandContext(ctx, exe, cfg.EngineArgs...)
// How the engine learns where to send detections. The engine's
// config already reads `events.webhook_url: ${BEHAVISION_WEBHOOK_URL}`
// and python-dotenv does not override a variable the process
// already has, so this needs no new endpoint and no fixed port.
//
// Without it the engine recognised people and the bridge received
// nothing - the URL was returned, logged and even exposed on the
// desktop's status object, and never actually given to the engine.
// A claimed shop PC published heartbeats and zero visits.
cmd.Env = append(os.Environ(), "BEHAVISION_WEBHOOK_URL="+hookURL)
return cmd
},
LogWriter: logFile,
HealthURL: strings.TrimRight(cfg.APIBase, "/") + "/api/health",
StatsURL: strings.TrimRight(cfg.APIBase, "/") + "/api/stats",
User: cfg.APIUser, Password: cfg.APIPassword,
})
ctx, stop := signal.NotifyContext(context.Background(),
os.Interrupt, syscall.SIGTERM)
defer stop()
// The bridge always runs, claimed or not: a PC that is set up before its
// tenant credentials arrive must still record the footfall it sees, and
// the spool is what holds it until the broker is configured.
// Created before the bridge and handed over unconditionally. On a PC that
// is not claimed yet there is no pump reading it, which costs nothing: the
// waker's single slot fills once and every later ring is dropped.
waker := mqtt.NewWaker()
br := &bridge.Bridge{
Queue: q,
Wake: waker.Wake,
Embeddings: bridge.NewEngineEmbeddings(cfg.APIBase, cfg.APIUser, cfg.APIPassword),
TopicPrefix: topicPrefix(cfg),
Log: logger,
// Uploads face images through a URL the server mints, so this PC never
// holds bucket credentials. Harmless when the engine writes no images
// or the PC is not claimed: Upload reports "images off" and the visit
// queues without a photo.
Uploader: &bridge.SpacesUploader{
BaseURL: cfg.CloudBase, Token: cfg.AgentToken,
},
}
// Cameras, kept in step with head office. Runs whether or not this PC is
// claimed: unclaimed it simply logs that it has no credentials yet, and the
// engine carries on with the cameras already in its own store.
uploader := &bridge.SpacesUploader{BaseURL: cfg.CloudBase, Token: cfg.AgentToken}
cloud := cameras.NewCloudClient(cfg.CloudBase, cfg.AgentToken)
cloud.Upload = uploader.UploadBytes
eng := cameras.NewEngineClient(cfg.APIBase, cfg.APIUser, cfg.APIPassword)
go cameras.New(eng, cloud, logger).Run(ctx)
// Before the engine starts, so the engine can be launched already knowing
// where to post its detections.
//
// A PC set up to run on its own is the one case where it should not: it
// has nothing to report to and, unlike an unclaimed one, never will, so
// queuing would write up to SpoolMax visits - each carrying a face
// template, which is biometric personal data - into a queue nothing is
// going to drain. Recognition and the cameras are unaffected; they belong
// to the engine, not the pump.
if cfg.Standalone && !cfg.Configured() {
logger.Print("standalone: recognition runs locally, nothing is reported")
} else {
url, stopBridge, err := br.Listen(ctx)
if err != nil {
return fmt.Errorf("event bridge: %w", err)
}
defer stopBridge()
hookURL = url
logger.Printf("event bridge listening on %s", hookURL)
}
logger.Printf("starting engine: %s %s", exe, strings.Join(cfg.EngineArgs, " "))
sup.Start()
if cfg.Configured() {
logger.Printf("tenant %s / site %s; broker %s",
cfg.ClientID, cfg.SiteID, cfg.BrokerURL)
client, err := mqtt.NewClient(mqtt.ClientOptions{
BrokerURL: cfg.BrokerURL,
ClientID: "behavision-" + cfg.ClientID + "-" + cfg.SiteID,
Username: cfg.BrokerUsername, Password: cfg.BrokerPassword,
CAFile: cfg.BrokerCAFile, Log: logger,
})
if err != nil {
// Not fatal. Events keep accumulating on disk and go out when the
// link returns - which is the entire point of the spool.
logger.Printf("broker unavailable, queuing locally: %v", err)
} else {
defer client.Close()
pump := &mqtt.Pump{
Queue: q, Publisher: client, Log: logger,
Wake: waker.C(),
HeartbeatTopic: topicPrefix(cfg) + "/heartbeat",
HeartbeatPayload: func() []byte {
return heartbeat(q, sup)
},
}
go pump.Run(ctx)
logger.Print("broker pump running")
}
} else {
logger.Print("not claimed by a tenant yet - recording locally only")
}
<-ctx.Done()
logger.Print("stopping engine")
sup.Stop()
return nil
}
// topicPrefix is the site's MQTT namespace. The broker enforces
// `pattern write bv/%u/...`, so this must equal the credential's username or
// every publish is refused.
func topicPrefix(cfg config.Config) string {
if cfg.ClientID == "" || cfg.SiteID == "" {
return ""
}
return "bv/" + cfg.ClientID + "." + cfg.SiteID
}
// heartbeat says the site is alive and what shape it is in.
//
// `dropped` matters most: non-zero means this site's queue overflowed and it
// genuinely lost footfall the customer paid for. Reporting it is the only way
// that becomes visible rather than being inferred from a dip in a graph.
func heartbeat(q *spool.Spool, sup *engine.Supervisor) []byte {
hb := map[string]any{
"sent_at": time.Now().UTC().Format(time.RFC3339),
"agent_version": version,
"queued": q.Len(),
"dropped": q.Dropped(),
}
if sup != nil {
state, _ := sup.State()
hb["engine_state"] = string(state)
hctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
defer cancel()
if h, err := sup.Health(hctx); err == nil {
hb["recognition_model"] = h.RecognitionModel
hb["cameras"] = h.Cameras
}
// The share of faces this site's cameras saw and discarded before they
// could become visits. It is the difference between "a quiet week" and
// "the camera is pointed at the ceiling", which are the same row of
// numbers on a footfall report without it. Measured on the Office1
// camera it was 0.727.
if st, err := sup.Stats(hctx); err == nil {
if worst, ok := st.WorstBelowGate(); ok {
hb["fraction_below_gate"] = worst
}
}
}
b, _ := json.Marshal(hb)
return b
}

279
agent/pkg/bridge/bridge.go Normal file
View File

@@ -0,0 +1,279 @@
// Package bridge turns engine detections into queued MQTT messages.
//
// This is the link that was missing: the engine detects a person and fires an
// event onto its own bus; nothing turned that into something the server would
// ever see. The engine already has a webhook sink, so the agent listens on
// loopback and points `events.webhook_url` at itself.
//
// A webhook rather than the agent polling the engine: polling would either miss
// events between polls or need cursor state the engine does not keep, and the
// sink already exists and already runs off the hot path.
package bridge
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"strings"
"sync"
"time"
)
// Queue is the durable spool, reduced to what the bridge needs.
type Queue interface {
Append(topic string, payload any) error
}
// Embeddings fetches an identity's template from the engine.
//
// The event bus deliberately does not carry embeddings — a 512-float template
// on the bus would reach the log sink and the email sink too — so the bridge
// asks for it separately, once per identity.
type Embeddings interface {
Embedding(ctx context.Context, identityID int64) (vector []float32, model string, err error)
}
// Event is the engine's wire shape (behavision/events.py).
type Event struct {
Type string `json:"type"`
CameraID string `json:"camera_id"`
TS float64 `json:"ts"`
Data map[string]any `json:"data"`
}
type Bridge struct {
Queue Queue
Embeddings Embeddings
// TopicPrefix is "bv/<client>.<site>". The broker enforces that a site can
// only publish under its own, so an empty one means this PC is not claimed
// yet and events stay on disk rather than being addressed to nowhere.
TopicPrefix string
Log *log.Logger
// Uploader sends face images to object storage. Nil when the engine is not
// writing them, which is the default.
Uploader Uploader
// Wake, when set, is rung after a visit reaches the queue so the pump
// drains it now instead of on its next idle tick. That tick is two seconds,
// and it sits squarely on the path between a person walking in and their
// face appearing on a screen - the one delay in this chain that costs
// nothing to remove.
//
// Must not block: it runs on the engine's webhook request, so a slow pump
// would apply backpressure all the way into the recognition loop.
Wake func()
mu sync.Mutex
// Templates are fetched once per identity, not once per sighting. A
// returning customer seen forty times a day would otherwise pull the same
// 512 floats out of SQLite forty times.
seen map[int64]cached
Accepted uint64
Skipped uint64
Failed uint64
}
type cached struct {
vector []float32
model string
at time.Time
}
const cacheTTL = 30 * time.Minute
// Handler is the HTTP endpoint the engine posts to.
func (b *Bridge) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "post only", http.StatusMethodNotAllowed)
return
}
var ev Event
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&ev); err != nil {
// 400, not 500: the engine must not retry a payload that will
// never parse, and its sink logs failures without blocking.
http.Error(w, "bad json", http.StatusBadRequest)
return
}
if err := b.Handle(r.Context(), ev); err != nil {
b.logf("event %s: %v", ev.Type, err)
http.Error(w, "queue failed", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
})
return mux
}
// Handle converts one engine event and queues it.
func (b *Bridge) Handle(ctx context.Context, ev Event) error {
switch ev.Type {
case "person.new", "person.seen":
default:
// camera.up/down and person.missed are local diagnostics. They belong
// in the heartbeat, not in the footfall stream, where they would be
// counted as visits.
b.bump(&b.Skipped)
return nil
}
if b.TopicPrefix == "" {
b.bump(&b.Skipped)
return nil
}
identityID := asInt(ev.Data["identity_id"])
visit := map[string]any{
// Deterministic from what identifies the sighting, so the SAME event
// redelivered after a crash carries the SAME id and the server's
// idempotency check catches it. A random uuid here would defeat the
// entire at-least-once design.
"event_id": eventID(b.TopicPrefix, ev.CameraID, identityID, ev.TS),
"occurred_at": time.Unix(0, int64(ev.TS*float64(time.Second))).UTC(),
"camera_id": ev.CameraID,
"is_new": ev.Type == "person.new",
"similarity": asFloat(ev.Data["similarity"]),
"quality": asFloat(ev.Data["quality"]),
"local_visitor_id": identityID,
"attributes": attributes(ev.Data),
}
if identityID > 0 && b.Embeddings != nil {
vec, model, err := b.embedding(ctx, identityID)
if err != nil {
// Queue the visit anyway. A footfall count without a template is
// still a real visit; dropping it would lose the one number the
// customer is paying for over an optional field.
b.logf("no embedding for identity %d, sending counts only: %v",
identityID, err)
} else {
visit["embedding"] = vec
visit["model"] = model
}
}
// After the embedding, before the queue: the key has to be on the event
// that gets queued, and the local file is removed either way so a failed
// upload cannot leave a picture of a customer on a shop PC forever.
if path, _ := ev.Data["image_path"].(string); path != "" {
b.attachImage(ctx, visit, path)
}
if err := b.Queue.Append(b.TopicPrefix+"/visit", visit); err != nil {
b.bump(&b.Failed)
return fmt.Errorf("queue visit: %w", err)
}
b.bump(&b.Accepted)
// After the append, never before: waking a pump for an event that is not
// on disk yet is a drain that finds nothing and an event that then waits
// out the full idle interval anyway.
if b.Wake != nil {
b.Wake()
}
return nil
}
func (b *Bridge) embedding(ctx context.Context, id int64) ([]float32, string, error) {
b.mu.Lock()
if c, ok := b.seen[id]; ok && time.Since(c.at) < cacheTTL {
b.mu.Unlock()
return c.vector, c.model, nil
}
b.mu.Unlock()
vec, model, err := b.Embeddings.Embedding(ctx, id)
if err != nil {
return nil, "", err
}
b.mu.Lock()
if b.seen == nil {
b.seen = map[int64]cached{}
}
b.seen[id] = cached{vector: vec, model: model, at: time.Now()}
b.mu.Unlock()
return vec, model, nil
}
// Listen serves the webhook on loopback and returns the URL to configure in
// the engine. Port 0 so two instances on one machine cannot collide.
func (b *Bridge) Listen(ctx context.Context) (url string, stop func(), err error) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return "", nil, err
}
srv := &http.Server{
Handler: b.Handler(),
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
b.logf("bridge server stopped: %v", err)
}
}()
return fmt.Sprintf("http://%s/events", ln.Addr().String()), func() {
c, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_ = srv.Shutdown(c)
}, nil
}
// eventID is stable for one sighting: same camera, same identity, same second.
//
// The engine's sighting cooldown is 30 s, so two genuinely different visits by
// one person at one camera cannot share a second. Truncating to the second
// rather than using the raw float also survives the engine re-sending after a
// restart with a marginally different timestamp.
func eventID(prefix, camera string, identity int64, ts float64) string {
return fmt.Sprintf("%s|%s|%d|%d",
strings.TrimPrefix(prefix, "bv/"), camera, identity, int64(ts))
}
// attributes keeps the estimator output and drops the bookkeeping fields the
// server already has as columns.
func attributes(data map[string]any) map[string]any {
out := map[string]any{}
for k, v := range data {
switch k {
case "identity_id", "label", "similarity", "quality", "frame_quality":
continue
}
out[k] = v
}
return out
}
func asInt(v any) int64 {
switch n := v.(type) {
case float64:
return int64(n)
case int64:
return n
case int:
return int64(n)
}
return 0
}
func asFloat(v any) float64 {
if f, ok := v.(float64); ok {
return f
}
return 0
}
func (b *Bridge) bump(p *uint64) {
b.mu.Lock()
*p++
b.mu.Unlock()
}
func (b *Bridge) logf(format string, args ...any) {
if b.Log != nil {
b.Log.Printf(format, args...)
}
}

View File

@@ -0,0 +1,288 @@
package bridge
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
type fakeQueue struct {
topics []string
payloads []map[string]any
err error
}
func (q *fakeQueue) Append(topic string, payload any) error {
if q.err != nil {
return q.err
}
b, _ := json.Marshal(payload)
var m map[string]any
json.Unmarshal(b, &m)
q.topics = append(q.topics, topic)
q.payloads = append(q.payloads, m)
return nil
}
type fakeEmb struct {
calls int
err error
}
func (f *fakeEmb) Embedding(_ context.Context, id int64) ([]float32, string, error) {
f.calls++
if f.err != nil {
return nil, "", f.err
}
return make([]float32, 512), "w600k_r50.onnx", nil
}
func newBridge() (*Bridge, *fakeQueue, *fakeEmb) {
q, e := &fakeQueue{}, &fakeEmb{}
return &Bridge{Queue: q, Embeddings: e, TopicPrefix: "bv/acme.store1"}, q, e
}
func seen(id int64, ts float64) Event {
return Event{Type: "person.seen", CameraID: "entrance", TS: ts,
Data: map[string]any{"identity_id": float64(id), "similarity": 0.58,
"quality": 0.74, "gender": "Male", "age": float64(34)}}
}
func TestADetectionIsQueuedForTheRightTopic(t *testing.T) {
b, q, _ := newBridge()
if err := b.Handle(context.Background(), seen(7, 1787996491)); err != nil {
t.Fatal(err)
}
if len(q.topics) != 1 || q.topics[0] != "bv/acme.store1/visit" {
t.Fatalf("topics=%v", q.topics)
}
p := q.payloads[0]
if p["camera_id"] != "entrance" || p["is_new"] != false {
t.Fatalf("%+v", p)
}
if p["quality"] != 0.74 || p["similarity"] != 0.58 {
t.Fatalf("measurements lost: %+v", p)
}
}
func TestTheEventIDIsStableForTheSameSighting(t *testing.T) {
// This is what makes at-least-once delivery safe. A random id here would
// defeat the server's idempotency check and double the store's footfall
// after every reconnect.
b, q, _ := newBridge()
ctx := context.Background()
b.Handle(ctx, seen(7, 1787996491.20))
b.Handle(ctx, seen(7, 1787996491.86)) // same second, redelivered
if q.payloads[0]["event_id"] != q.payloads[1]["event_id"] {
t.Fatalf("ids differ: %v vs %v",
q.payloads[0]["event_id"], q.payloads[1]["event_id"])
}
}
func TestDifferentPeopleAndCamerasGetDifferentIDs(t *testing.T) {
b, q, _ := newBridge()
ctx := context.Background()
b.Handle(ctx, seen(7, 1787996491))
b.Handle(ctx, seen(8, 1787996491)) // different person, same instant
other := seen(7, 1787996491)
other.CameraID = "till"
b.Handle(ctx, other) // same person, different camera
ids := map[any]bool{}
for _, p := range q.payloads {
ids[p["event_id"]] = true
}
if len(ids) != 3 {
t.Fatalf("collided: %d distinct ids from 3 sightings", len(ids))
}
}
func TestLocalDiagnosticsAreNotCountedAsVisits(t *testing.T) {
// person.missed and camera.up are real events, but sending them down the
// footfall stream would inflate the headcount with things that are not
// people.
b, q, _ := newBridge()
for _, typ := range []string{"person.missed", "camera.up", "camera.down",
"identity.merged"} {
b.Handle(context.Background(), Event{Type: typ, CameraID: "entrance"})
}
if len(q.payloads) != 0 {
t.Fatalf("queued %d non-visits", len(q.payloads))
}
if b.Skipped != 4 {
t.Fatalf("skipped=%d", b.Skipped)
}
}
func TestAnUnclaimedPCQueuesNothing(t *testing.T) {
// Without a tenant prefix an event would be addressed to nowhere, and the
// broker would refuse it anyway.
b, q, _ := newBridge()
b.TopicPrefix = ""
b.Handle(context.Background(), seen(7, 1))
if len(q.payloads) != 0 {
t.Fatal("queued an event with no tenant")
}
}
func TestTheTemplateIsFetchedOncePerIdentity(t *testing.T) {
// A returning customer seen forty times a day would otherwise pull the
// same 512 floats out of SQLite forty times.
b, _, e := newBridge()
ctx := context.Background()
for i := 0; i < 5; i++ {
b.Handle(ctx, seen(7, float64(1787996491+i*60)))
}
if e.calls != 1 {
t.Fatalf("fetched the template %d times", e.calls)
}
}
func TestAMissingTemplateStillQueuesTheVisit(t *testing.T) {
// A footfall count without a template is still a real visit. Dropping it
// would lose the number the customer is paying for over an optional field.
b, q, e := newBridge()
e.err = errors.New("no embedding")
if err := b.Handle(context.Background(), seen(7, 1)); err != nil {
t.Fatal(err)
}
if len(q.payloads) != 1 {
t.Fatal("visit dropped because the template was missing")
}
if _, has := q.payloads[0]["embedding"]; has {
t.Fatal("queued an embedding key with no embedding")
}
}
func TestAttributesSurviveButBookkeepingIsDropped(t *testing.T) {
b, q, _ := newBridge()
b.Handle(context.Background(), seen(7, 1))
attrs := q.payloads[0]["attributes"].(map[string]any)
if attrs["gender"] != "Male" || attrs["age"] != float64(34) {
t.Fatalf("attributes lost: %+v", attrs)
}
for _, k := range []string{"identity_id", "similarity", "quality"} {
if _, dup := attrs[k]; dup {
t.Errorf("%q duplicated into attributes; it is already a column", k)
}
}
}
func TestMalformedJSONIsRejectedNotRetried(t *testing.T) {
b, _, _ := newBridge()
srv := httptest.NewServer(b.Handler())
defer srv.Close()
resp, err := http.Post(srv.URL+"/events", "application/json",
strings.NewReader("{ truncated"))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status %d — the engine would retry this forever", resp.StatusCode)
}
}
func TestTheWebhookQueuesARealPost(t *testing.T) {
b, q, _ := newBridge()
srv := httptest.NewServer(b.Handler())
defer srv.Close()
body, _ := json.Marshal(seen(7, 1787996491))
resp, err := http.Post(srv.URL+"/events", "application/json",
strings.NewReader(string(body)))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("status %d", resp.StatusCode)
}
if len(q.payloads) != 1 {
t.Fatal("nothing queued")
}
}
func TestListenBindsLoopbackOnly(t *testing.T) {
// The webhook accepts unauthenticated posts that become footfall rows;
// it must not be reachable from the network.
b, _, _ := newBridge()
url, stop, err := b.Listen(context.Background())
if err != nil {
t.Fatal(err)
}
defer stop()
if !strings.HasPrefix(url, "http://127.0.0.1:") {
t.Fatalf("bridge listening on %s", url)
}
}
// ---------------------------------------------------------------- waking
// Waking BEFORE the append would send the pump to look at a queue the event
// has not reached yet: it finds nothing, goes back to sleep, and the visit then
// waits out the full idle interval anyway - the exact delay the wake exists to
// remove, with an extra wasted drain on top.
func TestTheQueueIsWokenOnlyAfterTheVisitIsOnDisk(t *testing.T) {
b, q, _ := newBridge()
var depthWhenWoken = -1
b.Wake = func() { depthWhenWoken = len(q.payloads) }
if err := b.Handle(context.Background(), seen(7, 1_700_000_000)); err != nil {
t.Fatal(err)
}
if depthWhenWoken != 1 {
t.Fatalf("woken with %d events queued, want 1 - the event must be durable first",
depthWhenWoken)
}
}
// A visit that never reached the queue must not wake anything: there is nothing
// to drain, and the pump would spin on an empty spool.
func TestAFailedAppendDoesNotWakeThePump(t *testing.T) {
b, q, _ := newBridge()
q.err = errors.New("disk full")
woken := 0
b.Wake = func() { woken++ }
if err := b.Handle(context.Background(), seen(7, 1_700_000_000)); err == nil {
t.Fatal("a failed append should surface as an error")
}
if woken != 0 {
t.Fatalf("woke the pump %d times for an event that was never queued", woken)
}
}
// Diagnostics are not visits. They are skipped before the queue, so they must
// not wake a pump either.
func TestASkippedEventDoesNotWakeThePump(t *testing.T) {
b, _, _ := newBridge()
woken := 0
b.Wake = func() { woken++ }
ev := seen(7, 1_700_000_000)
ev.Type = "person.missed"
if err := b.Handle(context.Background(), ev); err != nil {
t.Fatal(err)
}
if woken != 0 {
t.Fatalf("a diagnostic event woke the pump %d times", woken)
}
}
// The bridge must run unchanged with no waker wired, which is what an agent
// built before this existed looks like.
func TestNoWakerIsFine(t *testing.T) {
b, q, _ := newBridge()
b.Wake = nil
if err := b.Handle(context.Background(), seen(7, 1_700_000_000)); err != nil {
t.Fatal(err)
}
if len(q.payloads) != 1 {
t.Fatalf("queued %d visits, want 1", len(q.payloads))
}
}

View File

@@ -0,0 +1,56 @@
package bridge
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// EngineEmbeddings reads templates from the local engine's API.
type EngineEmbeddings struct {
Base string
User string
Password string
Client *http.Client
}
func NewEngineEmbeddings(base, user, password string) *EngineEmbeddings {
return &EngineEmbeddings{
Base: strings.TrimRight(base, "/"), User: user, Password: password,
Client: &http.Client{Timeout: 10 * time.Second},
}
}
func (e *EngineEmbeddings) Embedding(ctx context.Context, id int64) ([]float32, string, error) {
url := fmt.Sprintf("%s/api/identities/%d/embedding", e.Base, id)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, "", err
}
if e.User != "" {
req.SetBasicAuth(e.User, e.Password)
}
resp, err := e.Client.Do(req)
if err != nil {
return nil, "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, "", fmt.Errorf("engine returned %s", resp.Status)
}
var body struct {
Model string `json:"model"`
Embedding []float32 `json:"embedding"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); err != nil {
return nil, "", err
}
if len(body.Embedding) == 0 {
return nil, "", fmt.Errorf("engine returned an empty embedding")
}
return body.Embedding, body.Model, nil
}

205
agent/pkg/bridge/images.go Normal file
View File

@@ -0,0 +1,205 @@
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
}

View File

@@ -0,0 +1,202 @@
package bridge
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func writeImage(t *testing.T, dir, name string, body []byte) string {
t.Helper()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, body, 0o600); err != nil {
t.Fatal(err)
}
return path
}
// fakeServer plays both halves: the API that mints an upload URL and the
// bucket that receives the PUT.
func fakeServer(t *testing.T, uploaded *[]byte, sentACL *string) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
mux.HandleFunc("/api/agent/upload-url", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer agent-token" {
w.WriteHeader(http.StatusUnauthorized)
return
}
json.NewEncoder(w).Encode(uploadTarget{ //nolint:errcheck
Key: "behavision/acme/store1/2026/08/31/abc.jpg",
URL: srv.URL + "/bucket/abc.jpg",
Headers: map[string]string{
"x-amz-acl": "private", "content-type": "image/jpeg",
},
ExpiresIn: 600,
})
})
mux.HandleFunc("/bucket/", func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
*uploaded = body
*sentACL = r.Header.Get("x-amz-acl")
w.WriteHeader(http.StatusOK)
})
return srv
}
func TestUploadSendsTheFileAndTheSignedACL(t *testing.T) {
var got []byte
var acl string
srv := fakeServer(t, &got, &acl)
dir := t.TempDir()
path := writeImage(t, dir, "face.jpg", []byte("jpeg bytes"))
u := &SpacesUploader{BaseURL: srv.URL, Token: "agent-token"}
key, err := u.Upload(context.Background(), path)
if err != nil {
t.Fatal(err)
}
if key != "behavision/acme/store1/2026/08/31/abc.jpg" {
t.Fatalf("key = %q", key)
}
if string(got) != "jpeg bytes" {
t.Fatalf("uploaded %q", got)
}
// The ACL is inside the server's signature. Sending it exactly as handed
// back is what keeps the shop PC from deciding to publish the image.
if acl != "private" {
t.Fatalf("x-amz-acl = %q, want private", acl)
}
}
func TestUnclaimedPCReportsImagesOffRatherThanFailing(t *testing.T) {
u := &SpacesUploader{} // no base url, no token: not enrolled yet
_, err := u.Upload(context.Background(), "/nonexistent")
if !errors.Is(err, ErrImagesOff) {
t.Fatalf("got %v, want ErrImagesOff", err)
}
}
func TestServerWithoutABucketIsNotARetryableFailure(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotImplemented)
}))
defer srv.Close()
dir := t.TempDir()
path := writeImage(t, dir, "face.jpg", []byte("x"))
u := &SpacesUploader{BaseURL: srv.URL, Token: "agent-token"}
// 501 means "this deployment stores no images". The agent must stop trying
// rather than retry every visitor forever.
if _, err := u.Upload(context.Background(), path); !errors.Is(err, ErrImagesOff) {
t.Fatalf("got %v, want ErrImagesOff", err)
}
}
func TestOversizedFilesAreRefusedBeforeTheUplink(t *testing.T) {
dir := t.TempDir()
path := writeImage(t, dir, "huge.jpg", make([]byte, maxImageBytes+1))
u := &SpacesUploader{BaseURL: "http://example.invalid", Token: "t"}
// A shop uplink should not spend minutes discovering that something other
// than a face crop landed in the outbox.
if _, err := u.Upload(context.Background(), path); err == nil ||
!strings.Contains(err.Error(), "limit") {
t.Fatalf("got %v", err)
}
}
// -- the bridge's use of it -------------------------------------------------
type stubUploader struct {
key string
err error
sent []string
}
func (s *stubUploader) Upload(_ context.Context, path string) (string, error) {
s.sent = append(s.sent, path)
return s.key, s.err
}
func TestVisitCarriesTheImageKeyAndTheLocalFileIsRemoved(t *testing.T) {
q := &fakeQueue{}
up := &stubUploader{key: "behavision/acme/store1/2026/08/31/abc.jpg"}
b := &Bridge{Queue: q, TopicPrefix: "bv/acme.store1", Uploader: up}
path := writeImage(t, t.TempDir(), "face.jpg", []byte("jpeg"))
err := b.Handle(context.Background(), Event{
Type: "person.new", CameraID: "entrance", TS: 1756_000_000,
Data: map[string]any{"identity_id": float64(7), "image_path": path},
})
if err != nil {
t.Fatal(err)
}
if len(q.payloads) != 1 {
t.Fatalf("expected one queued visit, got %d", len(q.payloads))
}
visit := q.payloads[0]
if visit["image_key"] != up.key {
t.Fatalf("image_key = %v", visit["image_key"])
}
// The outbox is transient. Leaving files behind means a shop PC slowly
// filling with pictures of its customers.
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatal("the local image was not removed after upload")
}
}
// A footfall count without a photo is a real visit and the number the customer
// pays for. Losing it over an optional field would be the wrong trade - the
// same rule the bridge already follows for a missing embedding.
func TestAFailedUploadStillQueuesTheVisit(t *testing.T) {
q := &fakeQueue{}
up := &stubUploader{err: errors.New("bucket unreachable")}
b := &Bridge{Queue: q, TopicPrefix: "bv/acme.store1", Uploader: up}
path := writeImage(t, t.TempDir(), "face.jpg", []byte("jpeg"))
if err := b.Handle(context.Background(), Event{
Type: "person.seen", CameraID: "entrance", TS: 1756_000_001,
Data: map[string]any{"identity_id": float64(7), "image_path": path},
}); err != nil {
t.Fatal(err)
}
if len(q.payloads) != 1 {
t.Fatalf("the visit was dropped because its photo failed")
}
if _, ok := q.payloads[0]["image_key"]; ok {
t.Fatal("a key was attached despite the upload failing")
}
// Removed anyway: keeping it for a retry means an outbox that grows for as
// long as the failure lasts.
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatal("the local image survived a failed upload")
}
}
func TestNoUploaderMeansNoImageAndNoLeftovers(t *testing.T) {
q := &fakeQueue{}
b := &Bridge{Queue: q, TopicPrefix: "bv/acme.store1"} // images off
path := writeImage(t, t.TempDir(), "face.jpg", []byte("jpeg"))
if err := b.Handle(context.Background(), Event{
Type: "person.new", CameraID: "entrance", TS: 1756_000_002,
Data: map[string]any{"identity_id": float64(7), "image_path": path},
}); err != nil {
t.Fatal(err)
}
if _, ok := q.payloads[0]["image_key"]; ok {
t.Fatal("an image key appeared with no uploader configured")
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatal("the local image was left on disk")
}
}

View File

@@ -0,0 +1,291 @@
// Package cameras keeps a shop PC's cameras in step with head office.
//
// The split is forced by the network, not by taste: only this PC is on the
// camera's LAN, so only this PC can connect to it — but the person onboarding a
// camera is often in an office somewhere else. So head office holds the DESIRED
// configuration and the agent pulls it.
//
// Pull, never push. A shop PC sits behind a router with no inbound route, so it
// has to ask; and asking makes the whole thing idempotent — a sync that fails
// halfway is fixed by the next one rather than leaving two systems disagreeing.
package cameras
import (
"context"
"log"
"time"
)
// Engine is the local recognition engine's camera API.
type Engine interface {
List(ctx context.Context) ([]Local, error)
Add(ctx context.Context, cam Local) error
Update(ctx context.Context, id string, cam Local) error
Remove(ctx context.Context, id string) error
Snapshot(ctx context.Context, id string) ([]byte, error)
}
// Cloud is head office.
type Cloud interface {
Desired(ctx context.Context) ([]Desired, error)
Report(ctx context.Context, rep Report) error
UploadSnapshot(ctx context.Context, jpeg []byte) (key string, err error)
}
// Local is a camera as the engine holds it.
type Local struct {
ID string `json:"id"`
Label string `json:"label,omitempty"`
Host string `json:"host,omitempty"`
Port int `json:"port,omitempty"`
Path string `json:"path,omitempty"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
MaxWidth int `json:"max_width,omitempty"`
Tuning map[string]any `json:"tuning,omitempty"`
Connected bool `json:"connected"`
}
// Desired is a camera as head office holds it.
type Desired struct {
CameraID string `json:"camera_id"`
Label string `json:"label"`
Host string `json:"host"`
Port int `json:"port"`
Path string `json:"path"`
Username string `json:"username"`
Password string `json:"password"`
MaxWidth int `json:"max_width"`
Tuning map[string]any `json:"tuning"`
Enabled bool `json:"enabled"`
Revision int64 `json:"revision"`
Deleted bool `json:"deleted"`
}
type State struct {
CameraID string `json:"camera_id"`
Connected bool `json:"connected"`
SnapshotKey string `json:"snapshot_key,omitempty"`
}
type Report struct {
State []State `json:"state,omitempty"`
Adopt []Desired `json:"adopt,omitempty"`
}
// Syncer reconciles the two, on a timer.
type Syncer struct {
Engine Engine
Cloud Cloud
Log *log.Logger
// Every how often to reconcile configuration. Cameras change rarely, and
// each sync is a database read on the server for every site in the estate,
// so this is minutes rather than seconds.
Interval time.Duration
// How often to send a fresh picture of each camera. A shop floor does not
// change much, and each frame is a few tens of kilobytes uploaded over the
// same connection the visits have to travel on.
SnapshotEvery time.Duration
// Checks and Prober are the "prove this camera works" half. Both nil on a
// PC that has never been claimed, and the syncer simply skips that work
// rather than treating it as a failure.
Checks Checks
Prober Prober
// applied remembers the revision last pushed into the engine, so an
// unchanged site costs one request and no engine calls at all.
applied map[string]int64
}
const (
DefaultInterval = 2 * time.Minute
DefaultSnapshotEvery = 60 * time.Second
)
// New builds a fully wired Syncer from the two clients every caller already
// has.
//
// It exists because the four fields were assembled by hand at each call site
// and both of them - the headless agent and the desktop app - set Engine and
// Cloud and forgot Checks and Prober. runChecks returns silently when either
// is nil (correct: an unclaimed PC has neither), so pressing "Test connection"
// at head office left the camera saying "checking..." until the five-minute
// stale release, and then said nothing at all. No error, on either side.
//
// The same two objects satisfy all four interfaces, so there was never a
// reason for a caller to choose.
func New(eng *EngineClient, cloud *CloudClient, log *log.Logger) *Syncer {
return &Syncer{Engine: eng, Cloud: cloud, Checks: cloud, Prober: eng, Log: log}
}
// Run reconciles until ctx is cancelled.
func (s *Syncer) Run(ctx context.Context) {
interval, snapEvery := s.Interval, s.SnapshotEvery
if interval <= 0 {
interval = DefaultInterval
}
if snapEvery <= 0 {
snapEvery = DefaultSnapshotEvery
}
// Immediately on start, so a PC that has just been claimed picks up its
// cameras now rather than in two minutes.
s.Once(ctx)
config := time.NewTicker(interval)
defer config.Stop()
snaps := time.NewTicker(snapEvery)
defer snaps.Stop()
for {
select {
case <-ctx.Done():
return
case <-config.C:
s.Once(ctx)
case <-snaps.C:
s.report(ctx)
}
}
}
// Once performs one full reconcile: pull desired, apply, then report back.
func (s *Syncer) Once(ctx context.Context) {
if s.applied == nil {
s.applied = map[string]int64{}
}
desired, err := s.Cloud.Desired(ctx)
if err != nil {
// Not fatal and not even unusual: an unclaimed PC has no credentials
// and a disconnected one has no network. The engine keeps running the
// cameras it already has, which is the whole point of the local store.
s.logf("camera sync: %v", err)
return
}
local, err := s.Engine.List(ctx)
if err != nil {
s.logf("camera sync: engine unavailable: %v", err)
return
}
have := map[string]Local{}
for _, c := range local {
have[c.ID] = c
}
known := map[string]bool{}
for _, d := range desired {
known[d.CameraID] = true
_, exists := have[d.CameraID]
switch {
case d.Deleted || !d.Enabled:
if exists {
if err := s.Engine.Remove(ctx, d.CameraID); err != nil {
s.logf("camera %s: remove failed: %v", d.CameraID, err)
continue
}
s.logf("camera %s removed (head office)", d.CameraID)
}
delete(s.applied, d.CameraID)
case !exists:
if err := s.Engine.Add(ctx, toLocal(d)); err != nil {
s.logf("camera %s: add failed: %v", d.CameraID, err)
continue
}
s.applied[d.CameraID] = d.Revision
s.logf("camera %s added from head office", d.CameraID)
case s.applied[d.CameraID] != d.Revision:
// The revision is what keeps this cheap. Without it every sync
// would PATCH every camera, and a PATCH restarts the connection —
// so a healthy site would drop its own video every two minutes.
if err := s.Engine.Update(ctx, d.CameraID, toLocal(d)); err != nil {
s.logf("camera %s: update failed: %v", d.CameraID, err)
continue
}
s.applied[d.CameraID] = d.Revision
s.logf("camera %s updated to revision %d", d.CameraID, d.Revision)
}
}
// Anything running here that head office has never heard of gets offered
// up. Without this, switching the feature on would delete every camera an
// existing site is already running — including the one it was commissioned
// with. The server refuses to overwrite its own config with these, and
// keeps tombstones, so a deleted camera is not resurrected.
var adopt []Desired
for id, c := range have {
if known[id] {
continue
}
adopt = append(adopt, Desired{
CameraID: id, Label: orElse(c.Label, id), Host: c.Host,
Port: c.Port, Path: c.Path, Username: c.Username,
Password: c.Password, MaxWidth: c.MaxWidth, Tuning: c.Tuning,
Enabled: true,
})
}
s.reportWith(ctx, adopt)
// Last, so a check requested against a camera added in the same breath
// finds it already applied. Inside Once() rather than beside it in the
// loop, so it also runs on startup and cannot be called twice a tick.
s.runChecks(ctx, desired)
}
func (s *Syncer) report(ctx context.Context) { s.reportWith(ctx, nil) }
// reportWith sends observed state, and a fresh picture from each camera.
func (s *Syncer) reportWith(ctx context.Context, adopt []Desired) {
local, err := s.Engine.List(ctx)
if err != nil {
s.logf("camera report: engine unavailable: %v", err)
return
}
rep := Report{Adopt: adopt}
for _, c := range local {
st := State{CameraID: c.ID, Connected: c.Connected}
if c.Connected {
// A snapshot failure never blocks the state report. Knowing a
// camera is down matters far more than having a picture of it,
// and the picture is the part most likely to fail.
if jpeg, err := s.Engine.Snapshot(ctx, c.ID); err == nil && len(jpeg) > 0 {
if key, err := s.Cloud.UploadSnapshot(ctx, jpeg); err == nil {
st.SnapshotKey = key
} else {
s.logf("camera %s: snapshot upload failed: %v", c.ID, err)
}
}
}
rep.State = append(rep.State, st)
}
if len(rep.State) == 0 && len(rep.Adopt) == 0 {
return
}
if err := s.Cloud.Report(ctx, rep); err != nil {
s.logf("camera report: %v", err)
}
}
func toLocal(d Desired) Local {
return Local{
ID: d.CameraID, Label: d.Label, Host: d.Host, Port: d.Port,
Path: d.Path, Username: d.Username, Password: d.Password,
MaxWidth: d.MaxWidth, Tuning: d.Tuning,
}
}
func orElse(s, fallback string) string {
if s == "" {
return fallback
}
return s
}
func (s *Syncer) logf(format string, args ...any) {
if s.Log != nil {
s.Log.Printf(format, args...)
}
}

View File

@@ -0,0 +1,241 @@
package cameras
import (
"context"
"errors"
"testing"
)
type fakeEngine struct {
cams map[string]Local
added []string
updated []string
removed []string
listErr error
snapshot []byte
}
func newEngine(cams ...Local) *fakeEngine {
m := map[string]Local{}
for _, c := range cams {
m[c.ID] = c
}
return &fakeEngine{cams: m, snapshot: []byte("\xff\xd8jpeg")}
}
func (f *fakeEngine) List(context.Context) ([]Local, error) {
if f.listErr != nil {
return nil, f.listErr
}
var out []Local
for _, c := range f.cams {
out = append(out, c)
}
return out, nil
}
func (f *fakeEngine) Add(_ context.Context, c Local) error {
f.cams[c.ID] = c
f.added = append(f.added, c.ID)
return nil
}
func (f *fakeEngine) Update(_ context.Context, id string, c Local) error {
c.ID = id
f.cams[id] = c
f.updated = append(f.updated, id)
return nil
}
func (f *fakeEngine) Remove(_ context.Context, id string) error {
delete(f.cams, id)
f.removed = append(f.removed, id)
return nil
}
func (f *fakeEngine) Snapshot(context.Context, string) ([]byte, error) {
return f.snapshot, nil
}
type fakeCloud struct {
desired []Desired
desiredErr error
reports []Report
uploads int
uploadErr error
}
func (f *fakeCloud) Desired(context.Context) ([]Desired, error) {
return f.desired, f.desiredErr
}
func (f *fakeCloud) Report(_ context.Context, r Report) error {
f.reports = append(f.reports, r)
return nil
}
func (f *fakeCloud) UploadSnapshot(context.Context, []byte) (string, error) {
if f.uploadErr != nil {
return "", f.uploadErr
}
f.uploads++
return "snap/key.jpg", nil
}
func syncer(e *fakeEngine, c *fakeCloud) *Syncer {
return &Syncer{Engine: e, Cloud: c}
}
func TestACameraAddedAtHeadOfficeAppearsOnTheShopPC(t *testing.T) {
e, c := newEngine(), &fakeCloud{desired: []Desired{{
CameraID: "entrance", Label: "Entrance", Host: "192.168.0.138",
Port: 554, Path: "/ch0_0.264", Username: "admin", Password: "s3cret",
MaxWidth: 1280, Enabled: true, Revision: 1,
}}}
syncer(e, c).Once(context.Background())
got, ok := e.cams["entrance"]
if !ok {
t.Fatal("the camera was never created on the PC")
}
if got.Host != "192.168.0.138" || got.Password != "s3cret" {
t.Fatalf("connection details did not travel: %+v", got)
}
}
// The whole reason adoption exists. Every existing site is already running
// cameras configured locally - including the office camera this was tested with
// - and a reconcile that only pushed downwards would delete all of them the
// first time it ran.
func TestACameraAlreadyRunningLocallyIsOfferedToHeadOfficeNotDeleted(t *testing.T) {
e := newEngine(Local{ID: "office", Label: "Office", Host: "192.168.0.138",
Port: 554, Username: "admin", Password: "s3cret", Connected: true})
c := &fakeCloud{}
syncer(e, c).Once(context.Background())
if _, ok := e.cams["office"]; !ok {
t.Fatal("an existing camera was deleted by the first sync")
}
if len(c.reports) == 0 || len(c.reports[0].Adopt) != 1 {
t.Fatalf("the camera was not offered for adoption: %+v", c.reports)
}
if got := c.reports[0].Adopt[0]; got.CameraID != "office" || got.Password != "s3cret" {
t.Fatalf("adoption dropped details the camera needs: %+v", got)
}
}
// A tombstone must win over adoption, or a deleted camera comes straight back
// on the next sync and the operator cannot work out why.
func TestADeletedCameraIsRemovedAndNotReadopted(t *testing.T) {
e := newEngine(Local{ID: "entrance", Host: "10.0.0.5", Connected: true})
c := &fakeCloud{desired: []Desired{
{CameraID: "entrance", Enabled: true, Revision: 3, Deleted: true},
}}
s := syncer(e, c)
s.Once(context.Background())
if _, ok := e.cams["entrance"]; ok {
t.Fatal("a camera deleted at head office is still running")
}
for _, r := range c.reports {
for _, a := range r.Adopt {
if a.CameraID == "entrance" {
t.Fatal("the deleted camera was offered back for adoption")
}
}
}
}
// A PATCH restarts the camera connection, so re-applying unchanged config every
// two minutes would make a healthy site drop its own video permanently.
func TestUnchangedConfigurationTouchesTheEngineOnce(t *testing.T) {
e := newEngine()
c := &fakeCloud{desired: []Desired{{CameraID: "entrance", Host: "10.0.0.5",
Enabled: true, Revision: 7}}}
s := syncer(e, c)
s.Once(context.Background())
s.Once(context.Background())
s.Once(context.Background())
if len(e.added) != 1 {
t.Fatalf("added %d times, want 1", len(e.added))
}
if len(e.updated) != 0 {
t.Fatalf("updated %d times with no change - every one restarts the stream", len(e.updated))
}
}
func TestANewRevisionIsApplied(t *testing.T) {
e := newEngine()
c := &fakeCloud{desired: []Desired{{CameraID: "entrance", Host: "10.0.0.5",
Enabled: true, Revision: 1}}}
s := syncer(e, c)
s.Once(context.Background())
c.desired[0].Host = "10.0.0.9"
c.desired[0].Revision = 2
s.Once(context.Background())
if len(e.updated) != 1 {
t.Fatalf("updated %d times, want 1", len(e.updated))
}
if e.cams["entrance"].Host != "10.0.0.9" {
t.Fatalf("the new address was not applied: %+v", e.cams["entrance"])
}
}
// An unclaimed PC, or one with no internet, must keep running the cameras it
// already has. Wiping local config because head office is unreachable would
// stop a shop recognising anybody for the duration of an outage.
func TestAnUnreachableHeadOfficeChangesNothingLocally(t *testing.T) {
e := newEngine(Local{ID: "entrance", Host: "10.0.0.5", Connected: true})
c := &fakeCloud{desiredErr: errors.New("this PC is not claimed by a company yet")}
syncer(e, c).Once(context.Background())
if _, ok := e.cams["entrance"]; !ok {
t.Fatal("local cameras were removed because the cloud was unreachable")
}
if len(e.removed) != 0 {
t.Fatalf("removed %v", e.removed)
}
}
// Knowing a camera is down matters far more than having a picture of it, and
// the picture is the part most likely to fail.
func TestAFailedSnapshotStillReportsWhetherTheCameraIsUp(t *testing.T) {
e := newEngine(Local{ID: "entrance", Connected: true})
c := &fakeCloud{uploadErr: errors.New("bucket unreachable")}
syncer(e, c).Once(context.Background())
if len(c.reports) == 0 || len(c.reports[0].State) != 1 {
t.Fatalf("no state was reported: %+v", c.reports)
}
st := c.reports[0].State[0]
if !st.Connected {
t.Error("connected state was lost with the snapshot")
}
if st.SnapshotKey != "" {
t.Error("a failed upload reported a key anyway")
}
}
// No point photographing a camera that is not producing frames, and the attempt
// costs a request per sync per dead camera.
func TestADisconnectedCameraIsNotPhotographed(t *testing.T) {
e := newEngine(Local{ID: "entrance", Connected: false})
c := &fakeCloud{}
syncer(e, c).Once(context.Background())
if c.uploads != 0 {
t.Fatalf("uploaded %d snapshots of a disconnected camera", c.uploads)
}
if c.reports[0].State[0].Connected {
t.Error("a disconnected camera was reported as up")
}
}
func TestAnEngineThatIsNotRunningIsNotAnError(t *testing.T) {
e := newEngine()
e.listErr = errors.New("connection refused")
c := &fakeCloud{desired: []Desired{{CameraID: "entrance", Enabled: true, Revision: 1}}}
syncer(e, c).Once(context.Background()) // must not panic
if len(c.reports) != 0 {
t.Fatal("reported state it could not have observed")
}
}

247
agent/pkg/cameras/checks.go Normal file
View File

@@ -0,0 +1,247 @@
package cameras
import (
"context"
"encoding/base64"
"fmt"
"strings"
"time"
)
// Running the checks head office asks for.
//
// Both answers come from the engine, which already knows how to give them and
// already phrases them for whoever is standing next to the camera. Nothing here
// re-words a verdict; it carries one.
// Job is one check the shop PC has been asked to run.
type Job struct {
CameraID string `json:"camera_id"`
Kind string `json:"kind"`
Seconds int `json:"seconds"`
}
// Result is what it found.
type Result struct {
CameraID string `json:"camera_id"`
OK bool `json:"ok"`
Verdict string `json:"verdict,omitempty"`
Headline string `json:"headline,omitempty"`
Advice []string `json:"advice,omitempty"`
Detail map[string]any `json:"detail,omitempty"`
ImageKey string `json:"image_key,omitempty"`
}
// Prober is the half of the engine that answers "does this camera work".
type Prober interface {
// Test opens the stream once and lets go, returning a frame. The engine's
// probe checks TCP reachability first, so a wrong address answers in
// milliseconds instead of the ~75 s an FFmpeg connect would take.
Test(ctx context.Context, cam Local) (TestResult, error)
// Placement watches for `seconds` and judges whether a person walking past
// produced a view worth enrolling.
Placement(ctx context.Context, cameraID string, seconds int) (map[string]any, error)
}
type TestResult struct {
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
// Snapshot is base64 JPEG, the operator's proof that the camera is
// pointing where they think it is.
Snapshot string `json:"snapshot_jpeg_b64,omitempty"`
}
// Checks is the client for the job queue.
type Checks interface {
Pending(ctx context.Context) ([]Job, error)
Submit(ctx context.Context, res Result) error
}
// runChecks picks up whatever head office has asked for and answers it.
//
// Called from the same sync loop as configuration, so a check requested at head
// office is picked up on the next tick. Deliberately not its own faster poll: a
// placement check needs a human to walk about anyway, so shaving a minute off
// the request buys nothing an operator would notice.
func (s *Syncer) runChecks(ctx context.Context, desired []Desired) {
if s.Checks == nil || s.Prober == nil {
return
}
jobs, err := s.Checks.Pending(ctx)
if err != nil {
s.logf("camera checks: %v", err)
return
}
for _, job := range jobs {
res := s.runOne(ctx, job, desired)
if err := s.Checks.Submit(ctx, res); err != nil {
// Nothing to retry against: the server released the claim on a
// timeout, so the operator's next press starts a fresh one. Losing
// a result is better than a queue of stale verdicts.
s.logf("camera %s: could not report the check: %v", job.CameraID, err)
}
}
}
func (s *Syncer) runOne(ctx context.Context, job Job, desired []Desired) Result {
res := Result{CameraID: job.CameraID}
local, err := s.Engine.List(ctx)
if err != nil {
res.Headline = "the recognition software on this PC is not responding"
res.Advice = []string{"Open Behavision on the shop's PC and make sure it is started."}
return res
}
var cam Local
var found bool
for _, c := range local {
if c.ID == job.CameraID {
cam, found = c, true
break
}
}
if !found {
// The camera exists at head office but the PC has not applied it yet.
// Honest, and it tells the operator to wait rather than to go and look
// at the cabling.
res.Headline = "this PC has not set up that camera yet"
res.Advice = []string{"It is applied within a couple of minutes of being added. Try again shortly."}
return res
}
// The engine never returns a camera password - by design, it reports
// `has_password` and nothing else - so probing with what it hands back
// dials the camera with an empty credential. That failed, and reported
// "could not open stream - check the host, port, path and credentials"
// about a camera the same PC had been streaming for an hour, with advice
// sending the installer to check the very credential that was never sent.
//
// Head office has the real one, and this sync already fetched it.
if cam.Password == "" {
for _, d := range desired {
if d.CameraID == job.CameraID {
cam.Password = d.Password
break
}
}
}
switch job.Kind {
case "placement":
return s.runPlacement(ctx, job, cam)
default:
return s.runConnection(ctx, job, cam)
}
}
func (s *Syncer) runConnection(ctx context.Context, job Job, cam Local) Result {
res := Result{CameraID: job.CameraID}
out, err := s.Prober.Test(ctx, cam)
if err != nil {
res.Headline = "could not test the camera: " + err.Error()
return res
}
if !out.OK {
res.Verdict = "unreachable"
// The engine's own sentence. It distinguishes a refused connection from
// a wrong path from a stream that opens and never sends a frame, and
// those need three different things done about them.
res.Headline = out.Error
res.Advice = adviceFor(out.Error)
return res
}
res.OK = true
res.Verdict = "reachable"
res.Headline = fmt.Sprintf("connected — %d×%d", out.Width, out.Height)
res.Detail = map[string]any{"width": out.Width, "height": out.Height}
res.Advice = []string{
"Check the picture below is the view you expect.",
"Then run a walk-past check to prove faces here can actually be recognised.",
}
if out.Snapshot != "" {
if jpeg, err := base64.StdEncoding.DecodeString(out.Snapshot); err == nil {
if key, err := s.Cloud.UploadSnapshot(ctx, jpeg); err == nil {
res.ImageKey = key
} else {
// A missing picture does not invalidate the result: the camera
// still connected, which is what was asked.
s.logf("camera %s: check snapshot upload failed: %v", job.CameraID, err)
}
}
}
return res
}
func (s *Syncer) runPlacement(ctx context.Context, job Job, cam Local) Result {
res := Result{CameraID: job.CameraID}
seconds := job.Seconds
if seconds <= 0 {
seconds = 25
}
// Room for the watch itself plus the engine's own overhead. Without the
// margin the context dies at the exact moment the verdict is computed.
ctx, cancel := context.WithTimeout(ctx, time.Duration(seconds+30)*time.Second)
defer cancel()
report, err := s.Prober.Placement(ctx, job.CameraID, seconds)
if err != nil {
res.Headline = "the walk-past check could not be run: " + err.Error()
return res
}
res.Detail = report
res.Verdict, _ = report["verdict"].(string)
res.Headline, _ = report["headline"].(string)
if adv, ok := report["advice"].([]any); ok {
for _, a := range adv {
if str, ok := a.(string); ok {
res.Advice = append(res.Advice, str)
}
}
}
// Only `good` is a pass. `marginal` means half the visitors are silently
// discarded, which is not a working camera - calling it one is how a site
// gets signed off and discovered three weeks later from a footfall report
// that was always zero.
res.OK = res.Verdict == "good"
return res
}
// adviceFor turns the engine's diagnosis into the next thing to do.
//
// Matched on the engine's own wording rather than an error code, because the
// engine returns prose - and prose that is already correct. This adds the
// action, it does not restate the problem.
func adviceFor(engineError string) []string {
msg := strings.ToLower(engineError)
switch {
case strings.Contains(msg, "refused"):
return []string{
"Something answered at that address but refused the connection.",
"The port is usually 554 for an RTSP camera. Check the port first.",
}
case strings.Contains(msg, "unreachable"), strings.Contains(msg, "timed out"),
strings.Contains(msg, "no route"):
return []string{
"Nothing answered at that address from the shop's PC.",
"Check the camera is powered on and plugged into the same network as the PC.",
"Confirm the address in the camera's own app or on its label.",
}
case strings.Contains(msg, "could not open"):
return []string{
"The address is reachable but the stream would not open.",
"This is usually the stream path or the camera's username and password.",
"Pick your camera's make above to fill in the usual path for it.",
}
case strings.Contains(msg, "no frame"):
return []string{
"The camera accepted the connection but sent no picture.",
"Some cameras only allow one viewer at a time — close any app watching it.",
}
}
return []string{"Check the address, port, stream path, username and password."}
}

View 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)
}
}

View File

@@ -0,0 +1,263 @@
package cameras
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// EngineClient talks to the recognition engine on this PC's loopback.
type EngineClient struct {
Base string
User string
Password string
Client *http.Client
}
func NewEngineClient(base, user, password string) *EngineClient {
return &EngineClient{
Base: strings.TrimRight(base, "/"), User: user, Password: password,
// Generous, because adding a camera makes the engine dial it, and a
// wrong address takes the full RTSP timeout to fail. Shorter than that
// and every genuinely-bad camera looks like an engine fault instead.
Client: &http.Client{Timeout: 30 * time.Second},
}
}
func (e *EngineClient) do(ctx context.Context, method, path string, body, out any) error {
var rdr io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return err
}
rdr = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, e.Base+path, rdr)
if err != nil {
return err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if e.User != "" {
req.SetBasicAuth(e.User, e.Password)
}
resp, err := e.Client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
// The engine's message, not just a status. "camera stored but failed to
// start: connection refused" is something an operator can act on;
// "500" is not, and this string ends up in the agent log a support
// engineer reads.
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return fmt.Errorf("engine %s: %s", resp.Status, strings.TrimSpace(string(msg)))
}
if out == nil {
return nil
}
return json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(out)
}
func (e *EngineClient) List(ctx context.Context) ([]Local, error) {
var out []Local
return out, e.do(ctx, http.MethodGet, "/api/cameras", nil, &out)
}
func (e *EngineClient) Add(ctx context.Context, cam Local) error {
return e.do(ctx, http.MethodPost, "/api/cameras", cam, nil)
}
func (e *EngineClient) Update(ctx context.Context, id string, cam Local) error {
// The engine takes the id from the path on PATCH and refuses it in the
// body, so it is cleared here rather than at the call site.
cam.ID = ""
return e.do(ctx, http.MethodPatch, "/api/cameras/"+url.PathEscape(id), cam, nil)
}
func (e *EngineClient) Remove(ctx context.Context, id string) error {
return e.do(ctx, http.MethodDelete, "/api/cameras/"+url.PathEscape(id), nil, nil)
}
// Snapshot fetches the most recent frame the engine holds.
//
// Not a fresh capture: the engine already keeps the latest frame in memory for
// its own MJPEG stream, so this costs a memory copy rather than a camera round
// trip. A camera that has not produced a frame yet answers 503, which is a
// normal state on a just-added camera and not an error worth logging loudly.
func (e *EngineClient) Snapshot(ctx context.Context, id string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
e.Base+"/api/cameras/"+url.PathEscape(id)+"/frame.jpg", nil)
if err != nil {
return nil, err
}
if e.User != "" {
req.SetBasicAuth(e.User, e.Password)
}
resp, err := e.Client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("engine %s", resp.Status)
}
// Bounded. A frame is tens of kilobytes; anything near this cap means
// something other than a JPEG is on the other end.
return io.ReadAll(io.LimitReader(resp.Body, 8<<20))
}
// CloudClient talks to head office with this agent's own token.
type CloudClient struct {
Base string
Token string
Client *http.Client
// Upload is the existing image path: the server mints a presigned URL and
// the agent PUTs to it. Reused rather than reimplemented, so a shop PC
// still never holds bucket credentials — the reason that path exists.
Upload func(ctx context.Context, jpeg []byte) (string, error)
}
func NewCloudClient(base, token string) *CloudClient {
return &CloudClient{
Base: strings.TrimRight(base, "/"), Token: token,
Client: &http.Client{Timeout: 20 * time.Second},
}
}
func (c *CloudClient) do(ctx context.Context, method, path string, body, out any) error {
if c.Token == "" {
// An unclaimed PC. Said plainly, because this is the normal state
// between installing the software and typing an enrolment code, and it
// must not read as a fault in the log.
return fmt.Errorf("this PC is not claimed by a company yet")
}
var rdr io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return err
}
rdr = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, c.Base+path, rdr)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.Token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.Client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("head office: %s", resp.Status)
}
if out == nil || resp.StatusCode == http.StatusNoContent {
return nil
}
return json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(out)
}
func (c *CloudClient) Desired(ctx context.Context) ([]Desired, error) {
var body struct {
Cameras []Desired `json:"cameras"`
}
if err := c.do(ctx, http.MethodGet, "/api/agent/cameras", nil, &body); err != nil {
return nil, err
}
return body.Cameras, nil
}
func (c *CloudClient) Report(ctx context.Context, rep Report) error {
return c.do(ctx, http.MethodPost, "/api/agent/cameras", rep, nil)
}
func (c *CloudClient) UploadSnapshot(ctx context.Context, jpeg []byte) (string, error) {
if c.Upload == nil {
return "", fmt.Errorf("images are not enabled for this deployment")
}
return c.Upload(ctx, jpeg)
}
// ---------------------------------------------------------------- probing
// Test opens the candidate stream once, without saving it.
//
// The engine does this as a sync handler in its threadpool because
// cv2.VideoCapture blocks hard, and it checks TCP reachability first - so a
// wrong address, which is the single most likely thing anybody types, answers
// in milliseconds rather than the ~75 s an FFmpeg connect takes to give up.
func (e *EngineClient) Test(ctx context.Context, cam Local) (TestResult, error) {
var out TestResult
// A generous ceiling: the engine's own deadline is 12 s for the frame plus
// 3 s to connect, and cutting it off earlier would report a timeout of our
// own making as if it were the camera's.
ctx, cancel := context.WithTimeout(ctx, 45*time.Second)
defer cancel()
return out, e.do(ctx, http.MethodPost, "/api/cameras/test", cam, &out)
}
// Placement runs the engine's commissioning watch and returns its report whole.
//
// Polled rather than awaited: the engine starts the watch and answers
// immediately, so the run survives this request being retried, and the report
// arrives with `running: true` until it does not.
func (e *EngineClient) Placement(ctx context.Context, cameraID string, seconds int) (
map[string]any, error) {
path := "/api/cameras/" + url.PathEscape(cameraID) + "/commission"
var report map[string]any
if err := e.do(ctx, http.MethodPost, path,
map[string]any{"seconds": seconds}, &report); err != nil {
return nil, err
}
deadline := time.Now().Add(time.Duration(seconds+20) * time.Second)
for time.Now().Before(deadline) {
select {
case <-ctx.Done():
return report, ctx.Err()
case <-time.After(2 * time.Second):
}
var latest map[string]any
if err := e.do(ctx, http.MethodGet, path, nil, &latest); err != nil {
// Keep the last good report rather than losing the whole run to
// one failed poll - the engine may simply have been busy.
continue
}
report = latest
if running, _ := latest["running"].(bool); !running {
return report, nil
}
}
return report, nil
}
// ---------------------------------------------------------------- check jobs
func (c *CloudClient) Pending(ctx context.Context) ([]Job, error) {
var body struct {
Checks []Job `json:"checks"`
}
if err := c.do(ctx, http.MethodGet, "/api/agent/checks", nil, &body); err != nil {
return nil, err
}
return body.Checks, nil
}
func (c *CloudClient) Submit(ctx context.Context, res Result) error {
return c.do(ctx, http.MethodPost, "/api/agent/checks", res, nil)
}

View File

@@ -0,0 +1,29 @@
package cameras
import (
"log"
"testing"
)
// The bug this guards: both callers built the Syncer as a struct literal, set
// Engine and Cloud, and left Checks and Prober nil. runChecks returns silently
// when either is nil - correct for an unclaimed PC - so a camera check
// requested at head office was claimed by nobody and sat at "checking..." until
// the server released it five minutes later. Nothing logged, on either side.
func TestNewWiresEveryHalfOfTheSyncer(t *testing.T) {
eng := NewEngineClient("http://127.0.0.1:8010", "u", "p")
cloud := NewCloudClient("https://example.invalid", "token")
s := New(eng, cloud, log.Default())
if s.Engine == nil || s.Cloud == nil {
t.Fatal("configuration half not wired")
}
// The half that proves a camera works. A Syncer without these is not a
// broken Syncer, which is exactly why the omission was invisible.
if s.Checks == nil {
t.Error("Checks is nil: head office's camera checks would never be claimed")
}
if s.Prober == nil {
t.Error("Prober is nil: a claimed check could never be answered")
}
}

201
agent/pkg/config/config.go Normal file
View File

@@ -0,0 +1,201 @@
// Package config holds the agent's own settings: which tenant and site this
// install belongs to, how to reach the broker, and how to launch the engine.
//
// Kept separate from the engine's YAML on purpose. That file describes
// recognition — thresholds, cameras, gates — and is edited by whoever tunes a
// site. This one describes identity and connectivity, is written by the
// installer and the login flow, and holds a secret.
package config
import (
"encoding/base64"
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
)
// protectedPrefix marks a value that went through DPAPI, so a config written
// on Windows is never mistaken for a plaintext dev one and vice versa.
const protectedPrefix = "dpapi:"
// Config is the agent's on-disk settings.
type Config struct {
// Tenant identity. The server keys everything on these.
ClientID string `json:"client_id"`
SiteID string `json:"site_id"`
SiteName string `json:"site_name"`
// Broker.
BrokerURL string `json:"broker_url"`
BrokerUsername string `json:"broker_username"`
BrokerPassword string `json:"broker_password"` // protected at rest
// Pins the broker's issuer. Empty uses the system roots, which is what a
// Let's Encrypt certificate needs; a private CA is pinned by path.
BrokerCAFile string `json:"broker_ca_file"`
// Engine process.
EngineExe string `json:"engine_exe"`
EngineArgs []string `json:"engine_args"`
APIBase string `json:"api_base"`
APIUser string `json:"api_user"`
APIPassword string `json:"api_password"` // protected at rest
// Session, so a shop PC that reboots overnight is not a login every
// morning. Protected at rest like every other secret here.
SessionToken string `json:"session_token"`
SessionRefresh string `json:"session_refresh"`
SessionEmail string `json:"session_email"`
// CloudBase is the server this site reports to; AgentToken is this PC's
// own credential there, issued once at enrolment.
//
// Deliberately not the same secret as BrokerPassword: they authenticate
// different things - one says this site may publish events, the other that
// it may ask the API for something - so rotating either must not break the
// other. Protected at rest like every other secret here.
CloudBase string `json:"cloud_base"`
AgentToken string `json:"agent_token"`
// Standalone marks a PC deliberately run on its own: cameras, recognition
// and the local gallery, with nothing reported to head office.
//
// It exists so that "not linked yet" and "not going to be linked" are
// different states. Without it every install was blocked on an enrolment
// code, so a shop with one PC and no head office could not add a camera at
// all - the software refused to do the thing it is for until a server it
// does not need had issued it a credential.
Standalone bool `json:"standalone"`
// Queue.
SpoolMax int `json:"spool_max"`
path string
}
// Defaults returns a config that runs a locally installed engine.
//
// EngineExe is relative to the install root - the directory holding this
// executable - and names the installed layout: the engine is a PyInstaller
// one-FOLDER build, so it brings its own DLLs and cannot simply sit beside the
// app. Windows filenames are case-insensitive too, so `Behavision.exe` (the
// app) and `behavision.exe` (the engine) could not share a directory even if
// it were tidy to.
func Defaults() Config {
exe := filepath.Join("engine", "behavision")
if runtime.GOOS == "windows" {
exe += ".exe"
}
return Config{
EngineExe: exe,
EngineArgs: []string{"run"},
APIBase: "http://127.0.0.1:8010",
SpoolMax: 50000,
}
}
// Load reads the config, decrypting secrets. A missing file is not an error:
// a fresh install has none until the operator logs in, and failing to start
// because of that would leave them with no UI to log in from.
func Load(path string) (Config, error) {
cfg := Defaults()
cfg.path = path
blob, err := os.ReadFile(path)
if os.IsNotExist(err) {
return cfg, nil
}
if err != nil {
return cfg, err
}
if err := json.Unmarshal(blob, &cfg); err != nil {
return cfg, fmt.Errorf("config %s: %w", path, err)
}
cfg.path = path
for _, field := range []*string{&cfg.BrokerPassword, &cfg.APIPassword,
&cfg.SessionToken, &cfg.SessionRefresh, &cfg.AgentToken} {
plain, err := reveal(*field)
if err != nil {
// A secret that cannot be decrypted usually means the config was
// copied from another machine - DPAPI is machine-scoped. Blank it
// rather than failing: the operator can log in again, but they
// cannot fix a process that will not start.
*field = ""
continue
}
*field = plain
}
return cfg, nil
}
// Save writes the config atomically, protecting secrets on the way out.
func (c Config) Save(path string) error {
if path == "" {
path = c.path
}
if path == "" {
return fmt.Errorf("config: no path to save to")
}
out := c
out.path = ""
for _, field := range []*string{&out.BrokerPassword, &out.APIPassword,
&out.SessionToken, &out.SessionRefresh, &out.AgentToken} {
hidden, err := conceal(*field)
if err != nil {
return err
}
*field = hidden
}
blob, err := json.MarshalIndent(out, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
// Temp-then-rename: a crash mid-write must not leave a config that parses
// as valid but is half old and half new.
tmp := path + ".tmp"
if err := os.WriteFile(tmp, blob, 0o600); err != nil {
return err
}
return os.Rename(tmp, path)
}
// Configured reports whether this install has been claimed by a tenant yet.
// The UI shows a login screen until it has.
func (c Config) Configured() bool {
return c.ClientID != "" && c.SiteID != "" && c.BrokerURL != ""
}
// SecretsProtected is false on a dev machine, where secrets are stored as-is.
// Surfaced rather than hidden so nobody ships a build believing otherwise.
func SecretsProtected() bool { return protectionAvailable() }
func conceal(plain string) (string, error) {
if plain == "" || !protectionAvailable() {
return plain, nil
}
blob, err := protect([]byte(plain))
if err != nil {
return "", err
}
return protectedPrefix + base64.StdEncoding.EncodeToString(blob), nil
}
func reveal(stored string) (string, error) {
if !strings.HasPrefix(stored, protectedPrefix) {
return stored, nil
}
blob, err := base64.StdEncoding.DecodeString(
strings.TrimPrefix(stored, protectedPrefix))
if err != nil {
return "", err
}
plain, err := unprotect(blob)
if err != nil {
return "", err
}
return string(plain), nil
}

View File

@@ -0,0 +1,179 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestAFreshInstallLoadsDefaultsInsteadOfFailing(t *testing.T) {
// There is no config until the operator logs in, and refusing to start
// would leave them with no UI to log in from.
cfg, err := Load(filepath.Join(t.TempDir(), "nope.json"))
if err != nil {
t.Fatalf("missing config treated as an error: %v", err)
}
if cfg.Configured() {
t.Fatal("a blank install reported itself as configured")
}
if cfg.APIBase == "" || cfg.EngineExe == "" {
t.Fatal("defaults were not applied")
}
}
func TestRoundTrip(t *testing.T) {
path := filepath.Join(t.TempDir(), "agent.json")
cfg := Defaults()
cfg.ClientID, cfg.SiteID, cfg.BrokerURL = "acme", "store-1", "tls://b:8883"
cfg.BrokerPassword, cfg.APIPassword = "broker-secret", "api-secret"
if err := cfg.Save(path); err != nil {
t.Fatal(err)
}
back, err := Load(path)
if err != nil {
t.Fatal(err)
}
if back.BrokerPassword != "broker-secret" || back.APIPassword != "api-secret" {
t.Fatalf("secrets did not survive the round trip: %+v", back)
}
if !back.Configured() {
t.Fatal("a claimed install reported itself unconfigured")
}
}
func TestSaveIsAtomic(t *testing.T) {
// A crash mid-write must not leave a config that parses but is half old
// and half new.
dir := t.TempDir()
path := filepath.Join(dir, "agent.json")
cfg := Defaults()
cfg.ClientID = "acme"
if err := cfg.Save(path); err != nil {
t.Fatal(err)
}
entries, _ := os.ReadDir(dir)
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".tmp") {
t.Fatalf("temp file left behind: %s", e.Name())
}
}
}
func TestAnUndecryptableSecretBlanksRatherThanBlocksStartup(t *testing.T) {
// DPAPI is machine-scoped, so a config copied between PCs cannot be read.
// Refusing to start would be unrecoverable without a UI; blanking it means
// the operator just logs in again.
path := filepath.Join(t.TempDir(), "agent.json")
os.WriteFile(path, []byte(`{"client_id":"acme","site_id":"s1",
"broker_url":"tls://b","broker_password":"dpapi:!!!not-base64!!!"}`), 0o600)
cfg, err := Load(path)
if err != nil {
t.Fatalf("unreadable secret blocked startup: %v", err)
}
if cfg.BrokerPassword != "" {
t.Fatal("a secret that could not be decrypted was kept")
}
if cfg.ClientID != "acme" {
t.Fatal("the rest of the config was discarded too")
}
}
func TestPlaintextSecretsAreMarkedDifferentlyFromProtectedOnes(t *testing.T) {
// So a dev config is never mistaken for a protected one on inspection.
stored, err := conceal("secret")
if err != nil {
t.Fatal(err)
}
if SecretsProtected() && !strings.HasPrefix(stored, protectedPrefix) {
t.Fatal("protected value is not marked")
}
if !SecretsProtected() && strings.HasPrefix(stored, protectedPrefix) {
t.Fatal("plaintext value claims to be protected")
}
}
func TestSaveDoesNotLeakThePathFieldIntoJSON(t *testing.T) {
path := filepath.Join(t.TempDir(), "agent.json")
Defaults().Save(path)
blob, _ := os.ReadFile(path)
if strings.Contains(string(blob), t.TempDir()) {
t.Fatal("internal path field was serialised")
}
}
func TestTheSessionSurvivesARestart(t *testing.T) {
// A shop PC reboots overnight. Without this someone logs in every morning
// before the store can record anything.
path := filepath.Join(t.TempDir(), "agent.json")
cfg := Defaults()
cfg.SessionToken, cfg.SessionRefresh = "access-tok", "refresh-tok"
cfg.SessionEmail = "manager@acme.test"
if err := cfg.Save(path); err != nil {
t.Fatal(err)
}
back, err := Load(path)
if err != nil {
t.Fatal(err)
}
if back.SessionToken != "access-tok" || back.SessionRefresh != "refresh-tok" {
t.Fatalf("session lost: %+v", back)
}
if back.SessionEmail != "manager@acme.test" {
t.Fatal("email not kept")
}
}
func TestSessionTokensAreProtectedLikeOtherSecrets(t *testing.T) {
// A bearer token in plaintext on disk is a credential anyone with the file
// can replay.
path := filepath.Join(t.TempDir(), "agent.json")
cfg := Defaults()
cfg.SessionToken = "super-secret-jwt"
cfg.Save(path)
raw, _ := os.ReadFile(path)
if SecretsProtected() && strings.Contains(string(raw), "super-secret-jwt") {
t.Fatal("session token written in plaintext")
}
}
// Standalone has to survive a restart. It is a setup choice made once at a
// counter, and a flag that only lives in memory would put the enrolment-code
// screen back in front of a shop that already answered "we have no head
// office" - which reads as the app forgetting the setup step was ever done.
func TestStandaloneSurvivesSaveAndLoad(t *testing.T) {
path := filepath.Join(t.TempDir(), "agent.json")
cfg := Defaults()
cfg.Standalone = true
if err := cfg.Save(path); err != nil {
t.Fatalf("save: %v", err)
}
back, err := Load(path)
if err != nil {
t.Fatalf("load: %v", err)
}
if !back.Standalone {
t.Fatal("standalone was not persisted")
}
// Independent of being claimed: a standalone PC has no tenant, and a
// claimed one is not standalone even if the flag was once set.
if back.Configured() {
t.Fatal("a standalone config must not report itself as claimed")
}
}
// The engine is a PyInstaller one-FOLDER build living in its own subdirectory,
// and on Windows `Behavision.exe` (the app) could not share a directory with
// `behavision.exe` (the engine) anyway. Asserted here because the installer
// lays the tree out to match, and a rename would otherwise fail only inside
// the package - the one place nothing is tested.
func TestDefaultEngineExeIsInTheEngineFolder(t *testing.T) {
got := Defaults().EngineExe
if dir := filepath.Dir(got); dir != "engine" {
t.Fatalf("engine exe %q is not under engine/, got dir %q", got, dir)
}
if filepath.IsAbs(got) {
t.Fatalf("engine exe %q must be relative to the install root", got)
}
}

View File

@@ -0,0 +1,62 @@
package config
import (
"bufio"
"os"
"strings"
)
// EngineCredentials reads the Basic credentials the engine generated for
// itself, from the file it writes them to.
//
// The engine only invents a credential when none is configured and its API
// listens on a routable address - which is the DEFAULT configuration, so this
// is the ordinary case and not an edge one. `paths.APICredentials` has existed
// since the agent was written, with a comment saying the agent reads the file
// "rather than storing a second copy, so a regenerated credential does not
// silently break the tray". Nothing read it. On a stock install the agent's
// api_user was therefore empty and every call it makes to the engine - health,
// stats, camera sync, embeddings for a visit - came back 401: the tray red, the
// cameras never reconciled, and no error anywhere saying why.
//
// A missing or unreadable file is not an error. A PC where the operator set
// BEHAVISION_API_USER has no such file and needs none.
func EngineCredentials(path string) (user, password string) {
f, err := os.Open(path)
if err != nil {
return "", ""
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
// `key=value`, and `key: value` too: the file is also read by people,
// and which separator the engine used is not worth a support call.
line := strings.TrimSpace(sc.Text())
k, v, ok := strings.Cut(line, "=")
if !ok {
k, v, ok = strings.Cut(line, ":")
}
if !ok {
continue
}
switch strings.TrimSpace(k) {
case "username":
user = strings.TrimSpace(v)
case "password":
password = strings.TrimSpace(v)
}
}
return user, password
}
// WithEngineCredentials fills in the engine's Basic credentials from the file
// when the config carries none. Configured values always win: an operator who
// set BEHAVISION_API_USER means it.
func (c Config) WithEngineCredentials(path string) Config {
if c.APIUser != "" || c.APIPassword != "" {
return c
}
c.APIUser, c.APIPassword = EngineCredentials(path)
return c
}

View File

@@ -0,0 +1,58 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func writeCreds(t *testing.T, body string) string {
t.Helper()
p := filepath.Join(t.TempDir(), "api_credentials.txt")
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
return p
}
// The shape the engine actually writes. This is the whole point of the file:
// on a stock install it is the ONLY place the credential exists.
func TestItReadsWhatTheEngineWrites(t *testing.T) {
p := writeCreds(t, "username=behavision\npassword=qQTGFpetJ5Py613XwcbARQ\n")
u, pw := EngineCredentials(p)
if u != "behavision" || pw != "qQTGFpetJ5Py613XwcbARQ" {
t.Fatalf("got %q / %q", u, pw)
}
}
func TestColonSeparatedIsReadToo(t *testing.T) {
p := writeCreds(t, " username: behavision\n password: hunter2\n")
if u, pw := EngineCredentials(p); u != "behavision" || pw != "hunter2" {
t.Fatalf("got %q / %q", u, pw)
}
}
// A missing file is normal - an operator who set BEHAVISION_API_USER has none.
func TestAMissingFileIsNotAnError(t *testing.T) {
if u, pw := EngineCredentials("/nope/nothing.txt"); u != "" || pw != "" {
t.Fatalf("got %q / %q", u, pw)
}
}
// Configured values win. Reading the file over an operator's own credential
// would silently ignore what they set.
func TestAConfiguredCredentialIsNotOverwritten(t *testing.T) {
p := writeCreds(t, "username=generated\npassword=generated\n")
c := Config{APIUser: "mine", APIPassword: "secret"}.WithEngineCredentials(p)
if c.APIUser != "mine" || c.APIPassword != "secret" {
t.Fatalf("configured credential was replaced: %q / %q", c.APIUser, c.APIPassword)
}
}
func TestAnEmptyCredentialIsFilledIn(t *testing.T) {
p := writeCreds(t, "username=behavision\npassword=abc\n")
c := Config{}.WithEngineCredentials(p)
if c.APIUser != "behavision" || c.APIPassword != "abc" {
t.Fatalf("not filled in: %q / %q", c.APIUser, c.APIPassword)
}
}

View File

@@ -0,0 +1,13 @@
//go:build !windows
package config
// On non-Windows hosts secrets are stored as-is. This exists so the rest of
// the agent compiles and tests on a developer machine; the shipping platform
// is Windows, where protect.go's DPAPI implementation is used instead.
//
// It is a passthrough, NOT encryption, and Save() marks such values plainly so
// nobody can mistake a dev config for a protected one.
func protect(plain []byte) ([]byte, error) { return plain, nil }
func unprotect(blob []byte) ([]byte, error) { return blob, nil }
func protectionAvailable() bool { return false }

View File

@@ -0,0 +1,68 @@
//go:build windows
package config
import (
"fmt"
"syscall"
"unsafe"
)
// Windows DPAPI, reached through crypt32.dll directly rather than pulling in
// golang.org/x/sys. Machine scope, matching how the Python side already
// protects camera passwords: the agent and the engine may run as different
// users on the same PC, and a user-scoped blob written by one cannot be read
// by the other.
var (
crypt32 = syscall.NewLazyDLL("crypt32.dll")
kernel32 = syscall.NewLazyDLL("kernel32.dll")
procProtectData = crypt32.NewProc("CryptProtectData")
procUnprotectData = crypt32.NewProc("CryptUnprotectData")
procLocalFree = kernel32.NewProc("LocalFree")
)
const cryptprotectLocalMachine = 0x4
type dataBlob struct {
cbData uint32
pbData *byte
}
func newBlob(d []byte) dataBlob {
if len(d) == 0 {
return dataBlob{}
}
return dataBlob{cbData: uint32(len(d)), pbData: &d[0]}
}
func (b *dataBlob) bytes() []byte {
out := make([]byte, b.cbData)
copy(out, unsafe.Slice(b.pbData, b.cbData))
return out
}
func protect(plain []byte) ([]byte, error) {
in, out := newBlob(plain), dataBlob{}
r, _, err := procProtectData.Call(
uintptr(unsafe.Pointer(&in)), 0, 0, 0, 0,
cryptprotectLocalMachine, uintptr(unsafe.Pointer(&out)))
if r == 0 {
return nil, fmt.Errorf("CryptProtectData: %w", err)
}
defer procLocalFree.Call(uintptr(unsafe.Pointer(out.pbData)))
return out.bytes(), nil
}
func unprotect(blob []byte) ([]byte, error) {
in, out := newBlob(blob), dataBlob{}
r, _, err := procUnprotectData.Call(
uintptr(unsafe.Pointer(&in)), 0, 0, 0, 0,
cryptprotectLocalMachine, uintptr(unsafe.Pointer(&out)))
if r == 0 {
return nil, fmt.Errorf("CryptUnprotectData: %w", err)
}
defer procLocalFree.Call(uintptr(unsafe.Pointer(out.pbData)))
return out.bytes(), nil
}
func protectionAvailable() bool { return true }

View File

@@ -0,0 +1,44 @@
package engine
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
// The engine's REAL reply, copied from a running instance. The point of this
// test is the `"frozen": false` inside `paths`: Health.Paths was
// map[string]string, so decoding failed on that one bool, and because a failed
// decode fails the whole document a working engine was reported unreachable -
// red tray, and a heartbeat carrying neither the model nor the cameras, so head
// office showed "0 of 0 cameras" for a site that was watching one.
const realHealthBody = `{"status":"ok","recognition_model":"w600k_r50",
"paths":{"frozen":false,"install_root":"/opt/behavision","state_root":"/var/behavision",
"config":"/var/behavision/config/default.yaml","data_dir":"/var/behavision/data",
"models_dir":"/var/behavision/models"},"cameras":{"cam1":true},"uptime_seconds":42.5}`
func TestHealthDecodesWhatTheEngineActuallySends(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(realHealthBody))
}))
defer srv.Close()
s := New(Options{HealthURL: srv.URL})
h, err := s.Health(context.Background())
if err != nil {
t.Fatalf("the engine's own reply did not decode: %v", err)
}
if h.RecognitionModel != "w600k_r50" {
t.Errorf("model = %q", h.RecognitionModel)
}
// The two fields the heartbeat carries. Losing these is what made a
// working site look empty at head office.
if up, ok := h.Cameras["cam1"]; !ok || !up {
t.Errorf("cameras = %v, want cam1 connected", h.Cameras)
}
if h.Paths.StateRoot != "/var/behavision" || h.Paths.Frozen {
t.Errorf("paths = %+v", h.Paths)
}
}

View File

@@ -0,0 +1,355 @@
// Package engine starts, watches and stops the Python recognition engine.
//
// Go cannot run ONNX, OpenCV or FAISS, so the engine stays Python and ships
// frozen. What Go owns is its lifecycle: start it, keep it up, capture its
// output, and stop it when the user asks — which is what the tray's start/stop
// buttons actually drive.
//
// Deliberately not a Windows service. A service runs in session 0 and cannot
// draw a tray icon, and spawning a child process needs no elevation while
// controlling a service does. A service wrapper can be layered on later
// without touching anything here.
package engine
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"sync"
"time"
)
// State is what the tray icon colours itself from.
type State string
const (
Stopped State = "stopped" // not running, and not meant to be
Starting State = "starting" // process spawned, not yet answering
Running State = "running" // answering /api/health
Backoff State = "backoff" // crashed, waiting to retry
Failed State = "failed" // gave up
)
const (
minBackoff = 1 * time.Second
maxBackoff = 30 * time.Second
// A run that lasted this long counts as healthy, so the next crash starts
// its backoff from the bottom again. Without this a process that runs fine
// for hours and then crashes once waits the full 30s to come back.
stableRun = 60 * time.Second
// How long a stopping process gets to exit on its own before it is killed.
stopGrace = 10 * time.Second
)
// Options configures a Supervisor.
type Options struct {
// Command builds the process to run. Injected rather than hardcoded so
// tests can supervise /bin/sh instead of a 200 MB frozen engine.
Command func(ctx context.Context) *exec.Cmd
// LogWriter receives the engine's stdout and stderr. A crashed engine with
// no captured output is undiagnosable, which on a customer site means a
// site visit.
LogWriter io.Writer
// HealthURL, StatsURL, User, Password address the engine's own API.
HealthURL string
StatsURL string
User string
Password string
// MaxRestarts of 0 means never give up. Non-zero is for tests.
MaxRestarts int
now func() time.Time
}
// Supervisor keeps one engine process running. Safe for concurrent use.
type Supervisor struct {
opts Options
mu sync.Mutex
state State
lastErr error
restarts int
cancel context.CancelFunc
done chan struct{}
}
func New(opts Options) *Supervisor {
if opts.LogWriter == nil {
opts.LogWriter = io.Discard
}
if opts.now == nil {
opts.now = time.Now
}
return &Supervisor{opts: opts, state: Stopped}
}
// Start launches the engine and keeps it running until Stop. Calling it while
// already running is a no-op rather than a second process — two engines on one
// SQLite WAL and one camera is exactly the failure this package exists to
// avoid.
func (s *Supervisor) Start() {
s.mu.Lock()
if s.cancel != nil {
s.mu.Unlock()
return
}
ctx, cancel := context.WithCancel(context.Background())
s.cancel = cancel
s.done = make(chan struct{})
s.state = Starting
s.restarts = 0
done := s.done
s.mu.Unlock()
go s.supervise(ctx, done)
}
// Stop asks the engine to exit and waits for it.
func (s *Supervisor) Stop() {
s.mu.Lock()
cancel, done := s.cancel, s.done
s.cancel = nil
s.mu.Unlock()
if cancel == nil {
return
}
cancel()
if done != nil {
<-done
}
s.setState(Stopped, nil)
}
// State reports what the supervisor is doing, plus the last error if any.
func (s *Supervisor) State() (State, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.state, s.lastErr
}
// Restarts counts crash-restarts since Start.
func (s *Supervisor) Restarts() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.restarts
}
// -- the loop --------------------------------------------------------------
func (s *Supervisor) supervise(ctx context.Context, done chan struct{}) {
defer close(done)
backoff := minBackoff
for {
if ctx.Err() != nil {
return
}
s.setState(Starting, nil)
started := s.opts.now()
err := s.runOnce(ctx)
ran := s.opts.now().Sub(started)
// A cancelled context means the user pressed Stop. Exiting then is
// success, not a crash, and restarting would be the single most
// annoying bug a tray app can have.
if ctx.Err() != nil {
return
}
s.mu.Lock()
s.restarts++
restarts := s.restarts
s.mu.Unlock()
if s.opts.MaxRestarts > 0 && restarts >= s.opts.MaxRestarts {
s.setState(Failed, err)
return
}
if ran >= stableRun {
backoff = minBackoff
}
s.setState(Backoff, err)
select {
case <-ctx.Done():
return
case <-time.After(backoff):
}
if backoff < maxBackoff {
backoff *= 2
if backoff > maxBackoff {
backoff = maxBackoff
}
}
}
}
func (s *Supervisor) runOnce(ctx context.Context) error {
cmd := s.opts.Command(ctx)
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
cmd.Stderr = cmd.Stdout
if err := cmd.Start(); err != nil {
return fmt.Errorf("engine failed to start: %w", err)
}
pumped := make(chan struct{})
go func() {
defer close(pumped)
sc := bufio.NewScanner(stdout)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for sc.Scan() {
fmt.Fprintln(s.opts.LogWriter, sc.Text())
}
}()
s.setState(Running, nil)
waitErr := cmd.Wait()
<-pumped
// A context cancel terminates the child through exec's own handling; the
// resulting error is expected, not a fault.
if ctx.Err() != nil {
return nil
}
if waitErr != nil {
return fmt.Errorf("engine exited: %w", waitErr)
}
return errors.New("engine exited unexpectedly with status 0")
}
func (s *Supervisor) setState(st State, err error) {
s.mu.Lock()
s.state = st
if err != nil {
s.lastErr = err
}
s.mu.Unlock()
}
// -- health ----------------------------------------------------------------
// Health is the subset of /api/health the tray and the server care about.
type Health struct {
Status string `json:"status"`
RecognitionModel string `json:"recognition_model"`
Cameras map[string]bool `json:"cameras"`
// Paths is a STRUCT, not map[string]string, because `frozen` is a bool.
// It was a map of strings, so decoding the engine's real reply failed with
// "cannot unmarshal bool into Go struct field Health.paths" - and because
// one bad field fails the whole document, a perfectly healthy engine was
// reported unreachable: red tray, and a heartbeat carrying neither the
// model nor the camera list, so head office showed 0 of 0 cameras for a
// site that was watching one.
Paths EnginePaths `json:"paths"`
}
// EnginePaths mirrors what `behavision paths` and /api/health report. Unknown
// fields are ignored by encoding/json, so the engine can add to it freely.
type EnginePaths struct {
Frozen bool `json:"frozen"`
InstallRoot string `json:"install_root"`
StateRoot string `json:"state_root"`
Config string `json:"config"`
DataDir string `json:"data_dir"`
ModelsDir string `json:"models_dir"`
}
// Health polls the engine's own API. A running process is not the same as a
// working engine: the model can fail to load and the process stays up.
func (s *Supervisor) Health(ctx context.Context) (*Health, error) {
if s.opts.HealthURL == "" {
return nil, errors.New("no health url configured")
}
var h Health
if err := s.getJSON(ctx, s.opts.HealthURL, &h); err != nil {
return nil, err
}
return &h, nil
}
// Stats is the slice of /api/stats the heartbeat carries.
//
// Only fraction_below_gate, because that is the number that decides whether a
// site's footfall can be believed at all - the share of faces its cameras saw
// and discarded before they ever became a visit. Everything else in /api/stats
// is a local diagnostic and belongs on the local dashboard, not on the wire
// every thirty seconds.
type Stats struct {
Cameras []struct {
CameraID string `json:"camera_id"`
Pipeline struct {
BestQuality struct {
N int `json:"n"`
FractionBelowGate float64 `json:"fraction_below_gate"`
} `json:"best_quality"`
} `json:"pipeline"`
} `json:"cameras"`
}
// WorstBelowGate returns the worst camera's figure, and whether any camera has
// measured enough faces to have an opinion.
//
// Worst rather than average: one badly placed camera is a hole in the report,
// and averaging it against three good ones hides the only camera anyone needs
// to move. The sample floor is there because three faces is an anecdote -
// reporting 1.00 from a single below-gate track would raise an alarm about a
// camera nobody has walked past yet.
func (s *Stats) WorstBelowGate() (float64, bool) {
const minSamples = 10
worst, found := 0.0, false
for _, c := range s.Cameras {
if c.Pipeline.BestQuality.N < minSamples {
continue
}
if !found || c.Pipeline.BestQuality.FractionBelowGate > worst {
worst, found = c.Pipeline.BestQuality.FractionBelowGate, true
}
}
return worst, found
}
// Stats polls the engine's pipeline counters.
func (s *Supervisor) Stats(ctx context.Context) (*Stats, error) {
if s.opts.StatsURL == "" {
return nil, errors.New("no stats url configured")
}
var out Stats
if err := s.getJSON(ctx, s.opts.StatsURL, &out); err != nil {
return nil, err
}
return &out, nil
}
func (s *Supervisor) getJSON(ctx context.Context, url string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
if s.opts.User != "" {
req.SetBasicAuth(s.opts.User, s.opts.Password)
}
resp, err := (&http.Client{Timeout: 5 * time.Second}).Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("%s returned %s", url, resp.Status)
}
return json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(out)
}
// LogFile opens the engine log, rotating aside anything already there so one
// run's output cannot be mistaken for another's.
func LogFile(path string) (*os.File, error) {
if _, err := os.Stat(path); err == nil {
os.Rename(path, path+".1")
}
return os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
}

View File

@@ -0,0 +1,240 @@
package engine
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"os/exec"
"sync"
"testing"
"time"
)
// sh supervises /bin/sh instead of a 200 MB frozen engine. The Command hook
// exists for exactly this.
func sh(script string) func(context.Context) *exec.Cmd {
return func(ctx context.Context) *exec.Cmd {
return exec.CommandContext(ctx, "/bin/sh", "-c", script)
}
}
func waitFor(t *testing.T, s *Supervisor, want State, within time.Duration) {
t.Helper()
deadline := time.Now().Add(within)
for time.Now().Before(deadline) {
if got, _ := s.State(); got == want {
return
}
time.Sleep(5 * time.Millisecond)
}
got, err := s.State()
t.Fatalf("state %q (err %v), want %q within %s", got, err, want, within)
}
func TestItRunsAndReportsRunning(t *testing.T) {
s := New(Options{Command: sh("sleep 5")})
s.Start()
defer s.Stop()
waitFor(t, s, Running, 2*time.Second)
}
func TestStopDoesNotTriggerARestart(t *testing.T) {
// The classic supervisor bug: the user presses Stop, the child exits, the
// loop reads that as a crash and starts it again.
s := New(Options{Command: sh("sleep 30")})
s.Start()
waitFor(t, s, Running, 2*time.Second)
s.Stop()
if got, _ := s.State(); got != Stopped {
t.Fatalf("state after Stop is %q", got)
}
if n := s.Restarts(); n != 0 {
t.Fatalf("Stop counted as %d crash-restarts", n)
}
time.Sleep(200 * time.Millisecond)
if got, _ := s.State(); got != Stopped {
t.Fatalf("it restarted itself after Stop: %q", got)
}
}
func TestStopIsSynchronous(t *testing.T) {
// Stop must not return while the child still holds the SQLite WAL, or the
// next Start races the previous process.
s := New(Options{Command: sh("sleep 30")})
s.Start()
waitFor(t, s, Running, 2*time.Second)
done := make(chan struct{})
go func() { s.Stop(); close(done) }()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("Stop did not return")
}
}
func TestACrashIsRestarted(t *testing.T) {
s := New(Options{Command: sh("exit 1")})
s.Start()
defer s.Stop()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if s.Restarts() >= 2 {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("only %d restarts - is it backing off correctly?", s.Restarts())
}
func TestItDoesNotSpinOnAProcessThatCannotStart(t *testing.T) {
// A tight restart loop on a broken install pins a core and fills the disk
// with log lines. Backoff must space the attempts out.
s := New(Options{Command: sh("exit 1")})
s.Start()
defer s.Stop()
time.Sleep(1500 * time.Millisecond)
// 1s + 2s backoff means at most ~2 attempts in 1.5s; a spin would be
// thousands.
if n := s.Restarts(); n > 4 {
t.Fatalf("%d restarts in 1.5s - not backing off", n)
}
}
func TestItGivesUpAfterMaxRestarts(t *testing.T) {
s := New(Options{Command: sh("exit 1"), MaxRestarts: 2})
s.Start()
defer s.Stop()
waitFor(t, s, Failed, 5*time.Second)
if _, err := s.State(); err == nil {
t.Fatal("Failed state carries no reason")
}
}
func TestEngineOutputIsCaptured(t *testing.T) {
// A crashed engine with no captured output means a site visit to diagnose.
var mu sync.Mutex
buf := &lockedBuf{mu: &mu}
s := New(Options{Command: sh("echo model-load-failed; exit 1"),
LogWriter: buf, MaxRestarts: 1})
s.Start()
defer s.Stop()
waitFor(t, s, Failed, 5*time.Second)
if got := buf.String(); !bytes.Contains([]byte(got), []byte("model-load-failed")) {
t.Fatalf("engine output not captured, got %q", got)
}
}
func TestStartTwiceDoesNotRunTwoEngines(t *testing.T) {
// Two engines on one SQLite WAL and one camera is the failure this whole
// package exists to prevent.
s := New(Options{Command: sh("sleep 5")})
s.Start()
s.Start()
defer s.Stop()
waitFor(t, s, Running, 2*time.Second)
if n := s.Restarts(); n != 0 {
t.Fatalf("second Start disturbed the first: %d restarts", n)
}
}
func TestHealthReportsTheModelThatActuallyLoaded(t *testing.T) {
// A running process is not a working engine: on a memory-starved box the
// big model loses the fallback chain and the process stays up regardless.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth()
if !ok || user != "u" || pass != "p" {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.Write([]byte(`{"status":"ok","recognition_model":"w600k_mbf.onnx",
"cameras":{"entrance":true}}`))
}))
defer srv.Close()
s := New(Options{Command: sh("sleep 1"), HealthURL: srv.URL,
User: "u", Password: "p"})
h, err := s.Health(context.Background())
if err != nil {
t.Fatal(err)
}
if h.RecognitionModel != "w600k_mbf.onnx" || !h.Cameras["entrance"] {
t.Fatalf("bad health: %+v", h)
}
}
func TestHealthFailsClosedOnBadCredentials(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer srv.Close()
s := New(Options{Command: sh("true"), HealthURL: srv.URL, User: "u", Password: "wrong"})
if _, err := s.Health(context.Background()); err == nil {
t.Fatal("401 reported as healthy")
}
}
type lockedBuf struct {
mu *sync.Mutex
buf bytes.Buffer
}
func (l *lockedBuf) Write(p []byte) (int, error) {
l.mu.Lock()
defer l.mu.Unlock()
return l.buf.Write(p)
}
func (l *lockedBuf) String() string {
l.mu.Lock()
defer l.mu.Unlock()
return l.buf.String()
}
// The engine has to be TOLD where to post detections, and the only place that
// can happen is when the child is launched: the bridge picks a random loopback
// port after the supervisor is built, and a restarted engine has to be told
// again. This pins that the Command hook is consulted per launch rather than
// captured once - the wiring that was missing while the bridge's own doc
// comment claimed it existed.
func TestTheChildIsBuiltFreshOnEveryLaunch(t *testing.T) {
var mu sync.Mutex
url := "http://127.0.0.1:1111/e"
var seen []string
s := New(Options{Command: func(ctx context.Context) *exec.Cmd {
mu.Lock()
seen = append(seen, url)
mu.Unlock()
return exec.CommandContext(ctx, "/bin/sh", "-c", "exit 1")
}})
s.Start()
waitFor(t, s, Backoff, 2*time.Second)
// The port changes, exactly as it does when the bridge restarts.
mu.Lock()
url = "http://127.0.0.1:2222/e"
mu.Unlock()
// One backoff (1s) plus room for the relaunch.
deadline := time.Now().Add(4 * time.Second)
for time.Now().Before(deadline) {
mu.Lock()
n := len(seen)
mu.Unlock()
if n >= 2 {
break
}
time.Sleep(20 * time.Millisecond)
}
s.Stop()
mu.Lock()
defer mu.Unlock()
if len(seen) < 2 {
t.Fatalf("the command hook ran %d times, so a restart could not be told a new URL", len(seen))
}
if seen[len(seen)-1] != "http://127.0.0.1:2222/e" {
t.Fatalf("the last launch used %q - the hook captured a stale value", seen[len(seen)-1])
}
}

217
agent/pkg/mqtt/client.go Normal file
View File

@@ -0,0 +1,217 @@
// Broker client: the thin adapter behind the Publisher interface.
//
// Everything that decides *what to send and when* is in pump.go and is tested
// without a broker. This file only knows how to put bytes on a topic, which is
// why it is the one part that needs a real connection to exercise.
//
// Targets Mosquitto. No clustering, no shared subscriptions, no broker-side
// rules — a store publishes its own events under its own prefix and that is
// the whole interaction.
package mqtt
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"log"
neturl "net/url"
"os"
"strings"
"time"
paho "github.com/eclipse/paho.mqtt.golang"
)
// ClientOptions configures a broker connection.
type ClientOptions struct {
// BrokerURL is tls://host:8883 in production, tcp://host:1883 for local
// testing only. Credentials and footfall must never cross the internet in
// the clear, so Connect refuses tcp:// to a non-loopback host.
BrokerURL string
ClientID string
Username string
Password string
// CAFile pins a private CA. Empty uses the system roots, which is what a
// Let's Encrypt certificate on the broker needs.
CAFile string
// InsecureSkipVerify disables certificate checking. Only ever for a
// self-signed staging box, and it is logged loudly when set, because a
// forgotten one silently removes the protection TLS was added for.
InsecureSkipVerify bool
// PublishTimeout bounds a single publish. Without it a half-open
// connection blocks the pump indefinitely and the queue grows behind it.
PublishTimeout time.Duration
Log *log.Logger
}
// Client implements Publisher.
type Client struct {
opts ClientOptions
client paho.Client
}
// NewClient dials the broker. It returns as soon as the connection is
// established; reconnection afterwards is automatic and the pump reads
// Connected() to decide whether to try.
func NewClient(opts ClientOptions) (*Client, error) {
if opts.BrokerURL == "" {
return nil, errors.New("mqtt: no broker url")
}
if opts.PublishTimeout <= 0 {
opts.PublishTimeout = 10 * time.Second
}
if err := checkTransport(opts.BrokerURL); err != nil {
return nil, err
}
po := paho.NewClientOptions().
AddBroker(opts.BrokerURL).
SetClientID(opts.ClientID).
SetUsername(opts.Username).
SetPassword(opts.Password).
// The broker holds no state for us: every event is already durable on
// our own disk, so a clean session avoids the broker queueing a
// second copy we would then have to de-duplicate.
SetCleanSession(true).
SetAutoReconnect(true).
SetConnectRetry(true).
SetConnectRetryInterval(5 * time.Second).
SetMaxReconnectInterval(2 * time.Minute).
SetKeepAlive(30 * time.Second).
SetConnectTimeout(15 * time.Second).
// Publishes must fail fast rather than pile up in memory while the
// link is down; the spool is what holds them, not the client.
SetMessageChannelDepth(1).
SetOrderMatters(true)
if strings.HasPrefix(opts.BrokerURL, "tls://") ||
strings.HasPrefix(opts.BrokerURL, "ssl://") {
cfg, err := tlsConfig(opts)
if err != nil {
return nil, err
}
po.SetTLSConfig(cfg)
}
c := &Client{opts: opts}
po.OnConnect = func(paho.Client) { c.logf("broker connected: %s", opts.BrokerURL) }
po.OnConnectionLost = func(_ paho.Client, err error) {
c.logf("broker connection lost: %v", err)
}
c.client = paho.NewClient(po)
tok := c.client.Connect()
if !tok.WaitTimeout(20 * time.Second) {
return c, fmt.Errorf("mqtt: connect to %s timed out", opts.BrokerURL)
}
if err := tok.Error(); err != nil {
return c, fmt.Errorf("mqtt: connect to %s: %w", opts.BrokerURL, err)
}
return c, nil
}
// Publish sends one message at QoS 1 and waits for the broker's PUBACK.
//
// QoS 1, not 0 or 2. At QoS 0 the broker never confirms, so the pump would ack
// and delete an event that was dropped on the wire. QoS 2 costs two extra
// round trips to remove a duplicate the server can drop itself from the event
// id — at-least-once with idempotent consumers is the cheaper contract.
func (c *Client) Publish(ctx context.Context, topic string, payload []byte) error {
if c.client == nil {
return errors.New("mqtt: no client")
}
if !c.client.IsConnected() {
return errors.New("mqtt: not connected")
}
tok := c.client.Publish(topic, 1, false, payload)
// Honour both the caller's context and a hard timeout: a half-open TCP
// connection can leave a token that never completes, which would stall the
// pump forever with the queue growing behind it.
done := make(chan struct{})
go func() { tok.Wait(); close(done) }()
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(c.opts.PublishTimeout):
return fmt.Errorf("mqtt: publish to %s timed out", topic)
case <-done:
return tok.Error()
}
}
// Connected reports whether the broker link is up.
func (c *Client) Connected() bool {
return c.client != nil && c.client.IsConnected()
}
// Close disconnects cleanly, giving in-flight publishes a moment to land.
func (c *Client) Close() {
if c.client != nil && c.client.IsConnected() {
c.client.Disconnect(1000)
}
}
func (c *Client) logf(format string, args ...any) {
if c.opts.Log != nil {
c.opts.Log.Printf(format, args...)
}
}
// checkTransport refuses plaintext MQTT to anywhere but the local machine.
//
// The payloads carry customer visit records and the connection carries the
// tenant's broker password. A tcp:// URL to a public host is not a
// configuration choice, it is a mistake, and it is one that works — which is
// exactly why it has to be rejected here rather than noticed later.
func checkTransport(raw string) error {
if !strings.HasPrefix(raw, "tcp://") && !strings.HasPrefix(raw, "mqtt://") {
return nil
}
if os.Getenv("BEHAVISION_ALLOW_PLAINTEXT_MQTT") == "1" {
return nil
}
// url.Parse, not hand-rolled splitting: an IPv6 literal is bracketed and
// full of colons, so scanning for the first ":" turns "[::1]:1883" into
// "[" and refuses a perfectly good loopback address.
u, err := neturl.Parse(raw)
if err != nil {
return fmt.Errorf("mqtt: cannot parse broker url %q: %w", raw, err)
}
switch u.Hostname() {
case "localhost", "127.0.0.1", "::1", "":
return nil
}
return fmt.Errorf("mqtt: refusing plaintext connection to %q - use tls:// "+
"(set BEHAVISION_ALLOW_PLAINTEXT_MQTT=1 only for local testing)",
u.Hostname())
}
func tlsConfig(opts ClientOptions) (*tls.Config, error) {
cfg := &tls.Config{MinVersion: tls.VersionTLS12}
if opts.InsecureSkipVerify {
cfg.InsecureSkipVerify = true
if opts.Log != nil {
opts.Log.Print("WARNING: MQTT certificate verification is DISABLED")
}
return cfg, nil
}
if opts.CAFile == "" {
return cfg, nil // system roots
}
pem, err := os.ReadFile(opts.CAFile)
if err != nil {
return nil, fmt.Errorf("mqtt: ca file: %w", err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return nil, fmt.Errorf("mqtt: no certificates found in %s", opts.CAFile)
}
cfg.RootCAs = pool
return cfg, nil
}
// osWriteFile is indirected so tests can build without importing os twice.
var osWriteFile = os.WriteFile

View File

@@ -0,0 +1,104 @@
package mqtt
import (
"strings"
"testing"
)
func TestPlaintextToAPublicHostIsRefused(t *testing.T) {
// The payloads carry customer visit records and the connection carries the
// tenant's broker password. A tcp:// URL to a public host is not a config
// choice, it is a mistake — and one that WORKS, which is exactly why it
// has to fail here rather than be noticed after a year of traffic.
for _, url := range []string{
"tcp://broker.example.com:1883",
"mqtt://66.116.226.234:1883",
"tcp://10.0.0.5:1883",
"tcp://[2001:db8::1]:1883",
} {
if _, err := NewClient(ClientOptions{BrokerURL: url}); err == nil ||
!strings.Contains(err.Error(), "refusing plaintext") {
t.Errorf("%s was not refused (err=%v)", url, err)
}
}
}
func TestPlaintextToLocalhostIsAllowed(t *testing.T) {
// Local testing against a Mosquitto on the same box crosses no network.
// Checked at the transport gate rather than through NewClient: dialling a
// port nothing is listening on burns the full 20s connect timeout, and a
// slow test is a test people start skipping.
for _, url := range []string{"tcp://127.0.0.1:1883", "tcp://localhost:1883",
"mqtt://[::1]:1883"} {
if err := checkTransport(url); err != nil {
t.Errorf("loopback %s was refused: %v", url, err)
}
}
}
func TestPlaintextEscapeHatchIsExplicit(t *testing.T) {
// An override must exist for a lab, but it has to be a deliberate act,
// not a config field someone leaves set.
t.Setenv("BEHAVISION_ALLOW_PLAINTEXT_MQTT", "1")
if err := checkTransport("tcp://broker.example.com:1883"); err != nil {
t.Fatalf("escape hatch did not apply: %v", err)
}
}
func TestTLSUrlsSkipTheTransportCheck(t *testing.T) {
for _, url := range []string{"tls://b:8883", "ssl://b:8883", "wss://b:443"} {
if err := checkTransport(url); err != nil {
t.Errorf("%s rejected: %v", url, err)
}
}
}
func TestAnEmptyBrokerUrlIsAnError(t *testing.T) {
if _, err := NewClient(ClientOptions{}); err == nil {
t.Fatal("empty broker url accepted")
}
}
func TestTLSConfigRejectsAnUnreadableCA(t *testing.T) {
// Silently falling back to system roots when a pinned CA is missing would
// quietly undo the pinning.
if _, err := tlsConfig(ClientOptions{CAFile: "/nonexistent/ca.pem"}); err == nil {
t.Fatal("missing CA file accepted")
}
}
func TestTLSConfigRejectsAFileWithNoCertificates(t *testing.T) {
f := t.TempDir() + "/not-a-cert.pem"
if err := writeFile(f, "hello"); err != nil {
t.Fatal(err)
}
if _, err := tlsConfig(ClientOptions{CAFile: f}); err == nil {
t.Fatal("a file with no PEM certificates was accepted as a CA")
}
}
func TestTLSFloorIsTLS12(t *testing.T) {
cfg, err := tlsConfig(ClientOptions{})
if err != nil {
t.Fatal(err)
}
if cfg.MinVersion < 0x0303 {
t.Fatalf("MinVersion %#x allows TLS below 1.2", cfg.MinVersion)
}
}
func TestPublishOnADeadClientErrorsRatherThanPanics(t *testing.T) {
// The pump calls this on every tick; a nil-client panic would take the
// whole agent down instead of backing off.
c := &Client{}
if err := c.Publish(nil, "t", []byte("{}")); err == nil { //nolint:staticcheck
t.Fatal("publish on an unconnected client reported success")
}
if c.Connected() {
t.Fatal("an unconnected client reported Connected")
}
}
func writeFile(path, content string) error {
return osWriteFile(path, []byte(content), 0o600)
}

218
agent/pkg/mqtt/pump.go Normal file
View File

@@ -0,0 +1,218 @@
// Package mqtt moves queued events to the broker.
//
// Split from the broker client on purpose: everything that decides *what to
// send and when* lives here and is testable without a broker, while the paho
// binding is a thin adapter that only knows how to put bytes on a topic.
package mqtt
import (
"context"
"errors"
"log"
"time"
"github.com/loyaly/behavision-agent/pkg/spool"
)
// Publisher is the broker, reduced to what the pump needs.
type Publisher interface {
// Publish must return nil only once the broker has confirmed receipt.
// Returning early would let the pump ack an event that never arrived.
Publish(ctx context.Context, topic string, payload []byte) error
Connected() bool
}
// Queue is the durable side, reduced likewise.
type Queue interface {
Peek(n int) ([]spool.Entry, error)
Ack(seqs ...uint64) error
Len() int
Dropped() uint64
}
const (
batchSize = 32
idleInterval = 2 * time.Second
minRetry = 1 * time.Second
maxRetry = 30 * time.Second
defaultHeartbe = 60 * time.Second
)
// Pump drains the queue into the broker and emits a heartbeat.
type Pump struct {
Queue Queue
Publisher Publisher
// Heartbeat topic. Without it "the site is offline" and "nobody visited"
// are indistinguishable on the server, which for a footfall product is a
// silent hole in the customer's report.
HeartbeatTopic string
HeartbeatPayload func() []byte
HeartbeatInterval time.Duration
Log *log.Logger
// Wake, when set, makes the pump drain immediately instead of waiting out
// idleInterval. Without it a visit that lands one millisecond after a drain
// sits on disk for two seconds before anyone is told - and that delay is on
// the path a shop screen or a mobile app sees as "how long after someone
// walks in does their face appear".
//
// A doorbell, not a queue: it carries nothing, because the pump re-reads
// the spool either way. Buffered by one and written non-blockingly, so a
// burst of arrivals cannot stall the recognition pipeline behind a pump
// that is mid-publish.
Wake <-chan struct{}
}
// Waker is the writing end of the Wake channel, held by whatever appends to the
// queue. NewWaker returns both halves so a caller cannot accidentally build one
// that blocks its own producer.
type Waker struct{ ch chan struct{} }
func NewWaker() *Waker { return &Waker{ch: make(chan struct{}, 1)} }
// Wake rings the pump. Never blocks: a full slot already means "there is work",
// which is the entire message, so a second ring adds nothing.
func (w *Waker) Wake() {
if w == nil {
return
}
select {
case w.ch <- struct{}{}:
default:
}
}
// C is the channel to hand the pump.
func (w *Waker) C() <-chan struct{} {
if w == nil {
return nil
}
return w.ch
}
// Run drains until ctx is cancelled.
func (p *Pump) Run(ctx context.Context) {
interval := p.HeartbeatInterval
if interval <= 0 {
interval = defaultHeartbe
}
beat := time.NewTicker(interval)
defer beat.Stop()
retry := minRetry
for {
if ctx.Err() != nil {
return
}
// Non-blocking, for the case where there is a backlog and the loop
// never reaches the waiting select below.
select {
case <-beat.C:
p.heartbeat(ctx)
default:
}
sent, err := p.drainOnce(ctx)
if ctx.Err() != nil {
return
}
var wait time.Duration
switch {
case err != nil:
// The broker is down or refusing. Back off rather than spinning:
// a store with no internet would otherwise burn a core all night.
p.logf("publish failed, retrying in %s: %v", retry, err)
wait = retry
if retry < maxRetry {
retry *= 2
if retry > maxRetry {
retry = maxRetry
}
}
case sent == 0:
retry = minRetry
wait = idleInterval
default:
// Something went through; there may be more waiting, so loop
// immediately rather than sleeping through a backlog.
retry = minRetry
}
if wait == 0 {
continue
}
// The heartbeat must be able to interrupt this wait. Sleeping through
// it would delay every beat by the idle interval, and on a quiet site
// the pump is idle essentially always.
// A nil Wake channel blocks forever in a select, which is exactly the
// right behaviour: an agent with no waker falls back to the timer.
select {
case <-ctx.Done():
return
case <-beat.C:
p.heartbeat(ctx)
case <-p.Wake:
// Something was queued. Loop straight round and drain it rather
// than sleeping out the rest of the idle interval.
case <-time.After(wait):
}
}
}
// drainOnce sends at most one batch and returns how many were acked.
func (p *Pump) drainOnce(ctx context.Context) (int, error) {
if !p.Publisher.Connected() {
return 0, errors.New("broker not connected")
}
entries, err := p.Queue.Peek(batchSize)
if err != nil || len(entries) == 0 {
return 0, err
}
sent := 0
for _, e := range entries {
if err := p.Publisher.Publish(ctx, e.Topic, e.Payload); err != nil {
// Stop at the first failure instead of skipping past it. Events
// are a per-visitor timeline and the server reads them in order;
// publishing around a stuck one would reorder a customer's visits.
return sent, err
}
// Acked one at a time, immediately after its own confirmation. A batch
// ack would re-send everything before a mid-batch failure on restart.
if err := p.Queue.Ack(e.Seq); err != nil {
return sent, err
}
sent++
}
return sent, nil
}
func (p *Pump) heartbeat(ctx context.Context) {
if p.HeartbeatTopic == "" || p.HeartbeatPayload == nil {
return
}
if !p.Publisher.Connected() {
return
}
// Not queued: a heartbeat is only meaningful now. Spooling them would
// replay a week of "I am alive" the moment a site reconnects.
if err := p.Publisher.Publish(ctx, p.HeartbeatTopic, p.HeartbeatPayload()); err != nil {
p.logf("heartbeat failed: %v", err)
}
}
func (p *Pump) logf(format string, args ...any) {
if p.Log != nil {
p.Log.Printf(format, args...)
}
}
func sleep(ctx context.Context, d time.Duration) bool {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return false
case <-t.C:
return true
}
}

288
agent/pkg/mqtt/pump_test.go Normal file
View File

@@ -0,0 +1,288 @@
package mqtt
import (
"context"
"errors"
"sync"
"testing"
"time"
"github.com/loyaly/behavision-agent/pkg/spool"
)
type fakeBroker struct {
mu sync.Mutex
connected bool
sent []string
failAfter int // fail every publish once this many have succeeded
err error
}
func (f *fakeBroker) Publish(ctx context.Context, topic string, payload []byte) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.failAfter > 0 && len(f.sent) >= f.failAfter {
if f.err != nil {
return f.err
}
return errors.New("broker refused")
}
f.sent = append(f.sent, string(payload))
return nil
}
func (f *fakeBroker) Connected() bool {
f.mu.Lock()
defer f.mu.Unlock()
return f.connected
}
func (f *fakeBroker) delivered() []string {
f.mu.Lock()
defer f.mu.Unlock()
return append([]string(nil), f.sent...)
}
func queue(t *testing.T, payloads ...string) *spool.Spool {
t.Helper()
s, err := spool.Open(t.TempDir(), 100)
if err != nil {
t.Fatal(err)
}
for _, p := range payloads {
if err := s.Append("visit", p); err != nil {
t.Fatal(err)
}
}
return s
}
func TestItDrainsInOrderAndAcks(t *testing.T) {
q := queue(t, "a", "b", "c")
b := &fakeBroker{connected: true}
p := &Pump{Queue: q, Publisher: b}
sent, err := p.drainOnce(context.Background())
if err != nil {
t.Fatal(err)
}
if sent != 3 || q.Len() != 0 {
t.Fatalf("sent %d, %d left in queue", sent, q.Len())
}
got := b.delivered()
if len(got) != 3 || got[0] != `"a"` || got[2] != `"c"` {
t.Fatalf("wrong order: %v", got)
}
}
func TestNothingIsAckedWhileTheBrokerIsDown(t *testing.T) {
// Acking an event the broker never took is how footfall disappears.
q := queue(t, "a", "b")
b := &fakeBroker{connected: false}
p := &Pump{Queue: q, Publisher: b}
if _, err := p.drainOnce(context.Background()); err == nil {
t.Fatal("a disconnected broker was treated as success")
}
if q.Len() != 2 {
t.Fatalf("events were dropped while offline: %d left", q.Len())
}
}
func TestAFailureStopsTheBatchInsteadOfSkippingPast(t *testing.T) {
// Events are a per-visitor timeline read in order; publishing around a
// stuck one would reorder a customer's visits on the server.
q := queue(t, "a", "b", "c")
b := &fakeBroker{connected: true, failAfter: 1}
p := &Pump{Queue: q, Publisher: b}
sent, err := p.drainOnce(context.Background())
if err == nil {
t.Fatal("failure not reported")
}
if sent != 1 {
t.Fatalf("sent %d, want 1 before stopping", sent)
}
if q.Len() != 2 {
t.Fatalf("%d left in queue, want the 2 unsent", q.Len())
}
// And the survivors are the RIGHT two, still in order.
rest, _ := q.Peek(10)
if string(rest[0].Payload) != `"b"` {
t.Fatalf("queue head is %s, want b", rest[0].Payload)
}
}
func TestConfirmedEventsSurviveAMidBatchFailure(t *testing.T) {
// Acking per-event rather than per-batch: a batch ack would re-send
// everything before the failure after a restart, duplicating footfall.
q := queue(t, "a", "b", "c")
b := &fakeBroker{connected: true, failAfter: 2}
p := &Pump{Queue: q, Publisher: b}
p.drainOnce(context.Background())
if q.Len() != 1 {
t.Fatalf("%d left, want only the unsent one", q.Len())
}
b.failAfter = 0
sent, err := p.drainOnce(context.Background())
if err != nil || sent != 1 {
t.Fatalf("recovery sent %d (%v)", sent, err)
}
got := b.delivered()
if len(got) != 3 {
t.Fatalf("delivered %v - duplicates or losses", got)
}
}
func TestRunRecoversWhenTheBrokerComesBack(t *testing.T) {
q := queue(t, "a")
b := &fakeBroker{connected: false}
p := &Pump{Queue: q, Publisher: b}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go p.Run(ctx)
time.Sleep(100 * time.Millisecond)
if len(b.delivered()) != 0 {
t.Fatal("published while disconnected")
}
b.mu.Lock()
b.connected = true
b.mu.Unlock()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if len(b.delivered()) == 1 {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("queue never drained after the broker returned")
}
func TestHeartbeatIsSentSeparatelyFromTheQueue(t *testing.T) {
// "Site offline" and "nobody visited" must be distinguishable on the
// server. And a heartbeat is only meaningful now, so it is never spooled -
// otherwise a reconnecting site replays a week of "I am alive".
q := queue(t)
b := &fakeBroker{connected: true}
p := &Pump{Queue: q, Publisher: b,
HeartbeatTopic: "site/alive",
HeartbeatPayload: func() []byte { return []byte(`{"up":true}`) },
HeartbeatInterval: 20 * time.Millisecond}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go p.Run(ctx)
time.Sleep(200 * time.Millisecond)
if len(b.delivered()) == 0 {
t.Fatal("no heartbeat was sent")
}
if q.Len() != 0 {
t.Fatal("heartbeats were written to the durable queue")
}
}
func TestRunStopsPromptlyOnCancel(t *testing.T) {
q := queue(t)
b := &fakeBroker{connected: true}
p := &Pump{Queue: q, Publisher: b}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { p.Run(ctx); close(done) }()
cancel()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("Run ignored cancellation")
}
}
// ---------------------------------------------------------------- waking
// The delay this removes is on the path between a person walking in and their
// face reaching a screen, so the test asserts a real wall-clock bound rather
// than that a channel was read.
func TestAWakeDrainsWithoutWaitingOutTheIdleInterval(t *testing.T) {
q := queue(t)
pub := &fakeBroker{connected: true}
waker := NewWaker()
p := &Pump{Queue: q, Publisher: pub, Wake: waker.C(),
HeartbeatInterval: time.Hour}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go p.Run(ctx)
// Let it reach the idle wait with an empty queue first, so what follows is
// genuinely the wake path and not the drain it does on startup.
waitUntil(t, func() bool { return len(pub.delivered()) == 0 }, time.Second)
time.Sleep(50 * time.Millisecond)
start := time.Now()
if err := q.Append("visit", "e1"); err != nil {
t.Fatal(err)
}
waker.Wake()
waitUntil(t, func() bool { return len(pub.delivered()) == 1 }, 2*time.Second)
if took := time.Since(start); took >= idleInterval {
t.Fatalf("took %s - the wake did not beat the %s idle tick", took, idleInterval)
}
}
// A pump with no waker must behave exactly as it did before: a nil channel
// blocks forever in a select, which is the correct fallback, not a hang.
func TestAPumpWithNoWakerStillDrainsOnItsTimer(t *testing.T) {
q := queue(t)
pub := &fakeBroker{connected: true}
p := &Pump{Queue: q, Publisher: pub, HeartbeatInterval: time.Hour}
if err := q.Append("visit", "e1"); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go p.Run(ctx)
waitUntil(t, func() bool { return len(pub.delivered()) == 1 }, 3*time.Second)
}
// The waker runs on the engine's webhook request. If it could ever block, a
// burst of arrivals would apply backpressure into the recognition loop.
func TestWakingNeverBlocksEvenWithNobodyListening(t *testing.T) {
waker := NewWaker()
done := make(chan struct{})
go func() {
defer close(done)
for i := 0; i < 10000; i++ {
waker.Wake()
}
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("Wake blocked with no pump reading - this would stall recognition")
}
}
func TestANilWakerIsSafe(t *testing.T) {
var w *Waker
w.Wake() // an agent assembled without one must still run
if w.C() != nil {
t.Fatal("a nil waker handed out a channel")
}
}
func waitUntil(t *testing.T, cond func() bool, within time.Duration) {
t.Helper()
deadline := time.Now().Add(within)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(2 * time.Millisecond)
}
t.Fatalf("condition not met within %s", within)
}

79
agent/pkg/paths/paths.go Normal file
View File

@@ -0,0 +1,79 @@
// Package paths mirrors behavision/paths.py.
//
// The two processes must agree on where state lives or they will quietly use
// different databases: the engine would write footfall into one file while the
// agent reads another and reports an empty store. The rule is the same on both
// sides — BEHAVISION_DATA_DIR wins, then %PROGRAMDATA%\Behavision on Windows —
// and `behavision paths` prints the engine's answer so the two can be compared
// on a real machine rather than assumed equal.
package paths
import (
"os"
"path/filepath"
"runtime"
)
const AppName = "Behavision"
// StateRoot is the writable root: database, logs, spool, agent config.
func StateRoot() string {
if v := os.Getenv("BEHAVISION_DATA_DIR"); v != "" {
if abs, err := filepath.Abs(v); err == nil {
return abs
}
return v
}
if runtime.GOOS == "windows" {
base := os.Getenv("PROGRAMDATA")
if base == "" {
base = `C:\ProgramData`
}
return filepath.Join(base, AppName)
}
home, err := os.UserHomeDir()
if err != nil {
return "."
}
if runtime.GOOS == "darwin" {
return filepath.Join(home, "Library", "Application Support", AppName)
}
if v := os.Getenv("XDG_DATA_HOME"); v != "" {
return filepath.Join(v, "behavision")
}
return filepath.Join(home, ".local", "share", "behavision")
}
// InstallRoot is the directory holding this executable.
func InstallRoot() string {
exe, err := os.Executable()
if err != nil {
return "."
}
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
exe = resolved
}
return filepath.Dir(exe)
}
func AgentConfig() string { return filepath.Join(StateRoot(), "agent.json") }
func SpoolDir() string { return filepath.Join(StateRoot(), "spool") }
func EngineLog() string { return filepath.Join(StateRoot(), "engine.log") }
// APICredentials is the file the engine writes when it generates its own
// Basic credentials. The agent reads it rather than storing a second copy,
// so a regenerated credential does not silently break the tray.
func APICredentials() string {
return filepath.Join(StateRoot(), "data", "api_credentials.txt")
}
// EnsureState creates the writable tree. Called before anything opens a file
// under it, so a first run on a fresh machine does not fail on a missing dir.
func EnsureState() error {
for _, d := range []string{StateRoot(), SpoolDir()} {
if err := os.MkdirAll(d, 0o700); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,43 @@
package paths
import (
"path/filepath"
"strings"
"testing"
)
func TestDataDirEnvWins(t *testing.T) {
// The override is what lets one machine run two instances, and what makes
// the installed layout testable from a checkout - on both sides.
dir := t.TempDir()
t.Setenv("BEHAVISION_DATA_DIR", dir)
if got := StateRoot(); got != dir {
t.Fatalf("StateRoot() = %q, want %q", got, dir)
}
}
func TestEverythingLivesUnderTheStateRoot(t *testing.T) {
dir := t.TempDir()
t.Setenv("BEHAVISION_DATA_DIR", dir)
for name, got := range map[string]string{
"agent config": AgentConfig(),
"spool": SpoolDir(),
"engine log": EngineLog(),
"credentials": APICredentials(),
} {
if !strings.HasPrefix(got, dir) {
t.Errorf("%s resolved outside the state root: %s", name, got)
}
}
}
func TestEnsureStateIsIdempotent(t *testing.T) {
dir := filepath.Join(t.TempDir(), "fresh")
t.Setenv("BEHAVISION_DATA_DIR", dir)
if err := EnsureState(); err != nil {
t.Fatal(err)
}
if err := EnsureState(); err != nil {
t.Fatalf("second call failed: %v", err)
}
}

102
behavision.spec Normal file
View File

@@ -0,0 +1,102 @@
# -*- mode: python ; coding: utf-8 -*-
"""PyInstaller spec for the Behavision engine.
One-folder, not one-file. A onefile build of this is ~200 MB and extracts the
whole thing to a temp directory on **every** start, which on a store PC means a
multi-second delay and an antivirus scan each time the service restarts.
Models are NOT bundled. They are ~200 MB on their own and `setup-models`
already downloads them with a resumable `.part`-then-rename; bundling them
would triple the installer and force a re-sign for a model change. They land in
the writable state root (see behavision/paths.py), not next to the code.
Build:
pyinstaller behavision.spec --noconfirm
Output:
dist/behavision/behavision.exe
"""
import sys
from PyInstaller.utils.hooks import collect_dynamic_libs, collect_submodules
block_cipher = None
# The dashboard and the default config are read from disk at runtime, so they
# have to travel with the code. They go to the *install* root; anything the app
# writes goes to the state root instead.
datas = [
("behavision/static", "behavision/static"),
("config/default.yaml", "config"),
]
# onnxruntime and cv2 load native libraries that PyInstaller's static analysis
# cannot see through. Missing these is the classic "works in the venv, dies in
# the bundle" failure.
binaries = []
for pkg in ("onnxruntime", "cv2"):
try:
binaries += collect_dynamic_libs(pkg)
except Exception:
pass
hiddenimports = [
# Imported lazily inside functions, so the graph never sees them.
"dotenv",
"uvicorn.logging",
"uvicorn.loops.auto",
"uvicorn.protocols.http.auto",
"uvicorn.protocols.websockets.auto",
"uvicorn.lifespan.on",
]
for pkg in ("onnxruntime", "faiss"):
try:
hiddenimports += collect_submodules(pkg)
except Exception:
# faiss is optional - the numpy fallback is exact and identical, just
# slower, so its absence must not fail the build.
pass
a = Analysis(
["behavision/__main__.py"],
pathex=[],
binaries=binaries,
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
runtime_hooks=[],
# Nothing here draws a window; matplotlib/tkinter would add ~40 MB of
# payload that no code path can reach.
excludes=["tkinter", "matplotlib", "PyQt5", "PySide2", "IPython",
"notebook", "pytest"],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name="behavision",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=False, # UPX-packed binaries are a common AV false positive
console=True, # the tray app is the GUI; this is the engine
disable_windowed_traceback=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
coll = COLLECT(
exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=False,
name="behavision",
)

7
behavision/__init__.py Normal file
View File

@@ -0,0 +1,7 @@
"""Behavision — production face recognition over RTSP.
Pipeline: capture -> detect (YuNet) -> track (IoU) -> align + encode
(ArcFace ONNX) -> match / auto-enroll (FAISS + SQLite) -> events + API.
"""
__version__ = "1.0.0"

261
behavision/__main__.py Normal file
View File

@@ -0,0 +1,261 @@
"""CLI: python -m behavision {run | enroll | setup-models}"""
from __future__ import annotations
import argparse
import logging
import sys
from pathlib import Path
from .config import ensure_api_credentials, load_config
from .log import setup_logging
log = logging.getLogger("behavision")
def cmd_run(args: argparse.Namespace) -> int:
import uvicorn
from .api import create_app
from .engine import Engine
from .model_assets import setup_models
cfg = load_config(args.config)
setup_logging(cfg.app.log_level, cfg.app.data_dir)
missing = setup_models(cfg.app.models_dir)
if missing:
log.error("required models missing: %s", ", ".join(missing))
return 1
auth_on, generated = ensure_api_credentials(cfg)
if generated:
log.warning(
"no API credentials configured - generated one for %s:%s\n"
" username: %s\n password: %s\n"
" (saved to %s; set BEHAVISION_API_USER / "
"BEHAVISION_API_PASSWORD in .env to choose your own)",
cfg.api.host, cfg.api.port, cfg.api.username, cfg.api.password,
cfg.app.data_dir / "api_credentials.txt")
elif not auth_on:
log.info("API bound to %s - serving without authentication",
cfg.api.host)
engine = Engine(cfg)
if not engine.workers:
# A fresh install legitimately has no cameras - the user adds them
# from the dashboard. Refusing to boot here would mean they could
# never reach the UI that adds the first one.
log.info("no cameras yet - add one at http://%s:%s",
"localhost" if cfg.api.is_loopback else cfg.api.host,
cfg.api.port)
engine.start()
try:
uvicorn.run(create_app(engine), host=cfg.api.host, port=cfg.api.port,
log_level="warning")
finally:
engine.stop()
return 0
def cmd_enroll(args: argparse.Namespace) -> int:
import cv2
from .detection import FaceDetector
from .gallery import Gallery, IdentityStore, VectorIndex
from .recognition import EMBEDDING_DIM, ArcFaceEncoder, face_quality
cfg = load_config(args.config)
setup_logging(cfg.app.log_level)
detector = FaceDetector(cfg.app.models_dir,
cfg.detection.score_threshold,
cfg.detection.nms_threshold)
encoder = ArcFaceEncoder(cfg.app.models_dir, cfg.recognition.model_file)
store = IdentityStore(cfg.app.data_dir / "behavision.db")
gallery = Gallery(store, VectorIndex(EMBEDDING_DIM), cfg.recognition,
encoder.model_name)
paths: list[Path] = []
for p in args.images:
p = Path(p)
if p.is_dir():
paths += [f for f in sorted(p.iterdir())
if f.suffix.lower() in (".jpg", ".jpeg", ".png", ".bmp")]
else:
paths.append(p)
embeddings = []
for path in paths:
image = cv2.imread(str(path))
if image is None:
log.warning("unreadable image skipped: %s", path)
continue
detections = detector.detect(image)
if not detections:
log.warning("no face found in %s", path)
continue
best = max(detections, key=lambda d: (d.box[2] - d.box[0])
* (d.box[3] - d.box[1]))
emb = encoder.encode(image, best.kps)
if emb is None:
log.warning("could not embed face in %s", path)
continue
q = face_quality(image, best.box, best.kps)
embeddings.append(emb)
log.info("embedded %s (quality %.2f)", path.name, q)
if not embeddings:
log.error("no usable faces - nothing enrolled")
return 1
identity_id = gallery.enroll(args.name, embeddings)
log.info("enrolled '%s' as identity %d with %d embedding(s)",
args.name, identity_id, len(embeddings))
store.close()
return 0
def cmd_calibrate(args: argparse.Namespace) -> int:
"""Measure the similarity distributions this camera+encoder actually
produce, then report thresholds that separate them."""
from .calibrate import CalibrationStore, capture, format_report
from .detection import FaceDetector
from .recognition import MODEL_CANDIDATES, ArcFaceEncoder
cfg = load_config(args.config)
setup_logging(cfg.app.log_level)
store = CalibrationStore(cfg.app.data_dir / "calibration.npz")
if args.report:
if not store.models():
log.error("no samples yet - run: python -m behavision calibrate "
"--person NAME")
return 1
print(format_report(store, cfg))
return 0
if not args.person:
log.error("give --person NAME to capture, or --report to analyse")
return 1
# Every model that is present gets embedded from the SAME frames, so an
# A/B between encoders is a fair comparison rather than two sessions.
names = [args.model] if args.model else MODEL_CANDIDATES
encoders = {}
for name in names:
if not (cfg.app.models_dir / name).exists():
continue
try:
enc = ArcFaceEncoder(cfg.app.models_dir, name,
cfg.recognition.color_order)
encoders[enc.model_name] = enc
except Exception:
log.warning("%s did not load - skipping", name)
if not encoders:
log.error("no recognition model loaded from %s", cfg.app.models_dir)
return 1
log.info("calibrating with: %s", ", ".join(encoders))
detector = FaceDetector(cfg.app.models_dir, cfg.detection.score_threshold,
cfg.detection.nms_threshold, cfg.detection.max_faces,
cfg.detection.min_face_px)
source = args.source
if source is None:
cam = cfg.cameras[0] if cfg.cameras else None
if cam is None:
log.error("no cameras configured - pass --source")
return 1
source = cam.source()
log.info("capturing '%s' for %.0fs - vary pose, distance and expression",
args.person, args.seconds)
try:
# Deliberately ungated: the enrollment gate is one of the things being
# calibrated, and filtering by it here would make it unmeasurable.
samples, qualities = capture(source, args.person, args.seconds, cfg,
detector, encoders)
except RuntimeError:
log.exception("capture failed")
return 1
kept = 0
for model, embeddings in samples.items():
if len(embeddings):
kept = store.add(model, args.person, embeddings, qualities)
if not kept:
log.error("no usable faces captured for '%s' - nothing stored "
"(nobody in frame, two faces at once, or too far away?)",
args.person)
return 1
store.save()
log.info("stored %d embeddings for '%s' (total per model). Capture more "
"people, then: python -m behavision calibrate --report",
kept, args.person)
return 0
def cmd_setup_models(args: argparse.Namespace) -> int:
from .model_assets import setup_models
cfg = load_config(args.config)
setup_logging(cfg.app.log_level)
missing = setup_models(cfg.app.models_dir)
if missing:
log.error("still missing (place them in %s manually): %s",
cfg.app.models_dir, ", ".join(missing))
return 1
log.info("all required models present in %s", cfg.app.models_dir)
return 0
def cmd_paths(args) -> int:
"""Where everything lives. An installer and a support call both need this,
and installed it is not next to the code."""
from .paths import describe
# load_config first: it seeds the editable copy, and describing the config
# path before that would name the bundled file rather than the one the next
# run actually loads.
cfg = load_config(args.config)
info = describe()
info["data_dir"] = str(cfg.app.data_dir)
info["models_dir"] = str(cfg.app.models_dir)
width = max(len(k) for k in info)
for key, value in info.items():
print(f"{key.rjust(width)} : {value}")
return 0
def main() -> int:
parser = argparse.ArgumentParser(
prog="behavision", description="Face recognition over RTSP")
parser.add_argument("--config", default=None,
help="path to YAML config (default: config/default.yaml)")
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("run", help="start the pipeline + API server")
enroll = sub.add_parser("enroll", help="enroll a person from images")
enroll.add_argument("--name", required=True)
enroll.add_argument("--images", nargs="+", required=True,
help="image files and/or directories")
sub.add_parser("setup-models", help="download/copy model files")
sub.add_parser("paths", help="show where config, data and models live")
cal = sub.add_parser(
"calibrate",
help="measure similarity distributions and recommend thresholds")
cal.add_argument("--person", help="label for this capture session")
cal.add_argument("--seconds", type=float, default=20.0)
cal.add_argument("--source", default=None,
help="capture source (default: first configured camera)")
cal.add_argument("--model", default=None,
help="only this model file (default: all present)")
cal.add_argument("--report", action="store_true",
help="analyse stored samples instead of capturing")
args = parser.parse_args()
handlers = {"run": cmd_run, "enroll": cmd_enroll,
"setup-models": cmd_setup_models, "calibrate": cmd_calibrate,
"paths": cmd_paths}
return handlers[args.command](args)
if __name__ == "__main__":
sys.exit(main())

341
behavision/api.py Normal file
View File

@@ -0,0 +1,341 @@
"""HTTP API + minimal live dashboard (FastAPI).
Every endpoint is guarded by engine readiness; the server can start before
models finish loading without a single unguarded None dereference.
"""
from __future__ import annotations
import asyncio
import logging
import secrets
from pathlib import Path
from typing import Optional
from fastapi import Depends, FastAPI, HTTPException
from fastapi.responses import HTMLResponse, Response, StreamingResponse
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from pydantic import BaseModel, ValidationError
from .config import ApiSection, CameraConfig, CameraTuning
from .commission import CommissionRun
from .events import Event
from .engine import Engine
log = logging.getLogger(__name__)
_STATIC = Path(__file__).parent / "static"
class RenamePayload(BaseModel):
label: str
class CommissionPayload(BaseModel):
seconds: float = 25.0
class MergePayload(BaseModel):
"""`into` is the identity that survives. `force` overrides the
similarity guard and is never the default: a wrong merge cannot be
undone, because nothing records which embedding came from whom."""
into: int
force: bool = False
class CameraPayload(BaseModel):
"""Camera as the UI submits it. Mirrors CameraConfig but every field is
optional so PATCH can send a subset.
Optional[...] rather than `X | None`: pydantic evaluates field annotations
at runtime, and CameraConfig already uses this form.
"""
id: Optional[str] = None
url: Optional[str] = None
host: Optional[str] = None
port: Optional[int] = None
path: Optional[str] = None
username: Optional[str] = None
password: Optional[str] = None
webcam: Optional[int] = None
max_width: Optional[int] = None
# Per-camera gate overrides. Without this the store could hold them but
# nothing could set them, so the commissioning advice ("loosen this
# camera's quality gate") had no way to be acted on.
tuning: Optional[CameraTuning] = None
def camera_public(cam: CameraConfig, worker=None) -> dict:
"""Camera as the API returns it.
The password is NEVER included — not masked, not empty-string-if-set,
absent. `safe_url()` already exists for exactly this and masks credentials
inside the URL form too.
"""
out = {
"id": cam.id, "host": cam.host, "port": cam.port, "path": cam.path,
"username": cam.username, "webcam": cam.webcam,
"max_width": cam.max_width, "has_password": bool(cam.password),
"url": cam.safe_url(),
"tuning": cam.tuning.model_dump(),
}
if worker is not None:
out.update(worker.stats())
out["url"] = cam.safe_url() # worker.stats() also carries a url key
return out
def _auth_dependencies(api_cfg: ApiSection) -> list:
"""HTTP Basic over every route when credentials are configured.
Applied at app level rather than per-route so a future endpoint cannot be
added unprotected by omission. Basic (not a token) because the dashboard
is a browser page: the browser prompts once and then attaches the header
to the MJPEG <img> subresource too, which a bearer token cannot do.
"""
if not api_cfg.auth_enabled:
return []
scheme = HTTPBasic()
def check(credentials: HTTPBasicCredentials = Depends(scheme)) -> None:
# compare_digest on both halves: no early exit, no timing signal.
ok_user = secrets.compare_digest(
credentials.username.encode("utf-8"),
api_cfg.username.encode("utf-8"))
ok_pass = secrets.compare_digest(
credentials.password.encode("utf-8"),
api_cfg.password.encode("utf-8"))
if not (ok_user and ok_pass):
raise HTTPException(401, "invalid credentials",
headers={"WWW-Authenticate": "Basic"})
return [Depends(check)]
def create_app(engine: Engine) -> FastAPI:
app = FastAPI(title="Behavision", version="1.0.0",
dependencies=_auth_dependencies(engine.cfg.api))
def worker_or_404(camera_id: str):
worker = engine.workers.get(camera_id)
if worker is None:
raise HTTPException(404, f"unknown camera '{camera_id}'")
return worker
@app.get("/", response_class=HTMLResponse)
def dashboard() -> str:
return (_STATIC / "dashboard.html").read_text(encoding="utf-8")
@app.get("/api/health")
def health() -> dict:
from .paths import describe
return {"status": "ok" if engine.started_at else "starting",
"recognition_model": engine.encoder.model_name,
# "where is my database" must be answerable from the API: the
# tray, the installer and support all need it, and installed
# it is not next to the code.
"paths": {**describe(), "data_dir": str(engine.cfg.app.data_dir),
"models_dir": str(engine.cfg.app.models_dir)},
"cameras": {cid: w.source.connected
for cid, w in engine.workers.items()}}
@app.get("/api/stats")
def stats() -> dict:
return engine.stats()
@app.get("/api/events")
def events(limit: int = 50) -> list:
return list(engine.bus.recent)[:limit]
@app.get("/api/identities")
def identities(limit: int = 200) -> list:
return engine.store.list_identities(limit)
@app.get("/api/sightings")
def sightings(limit: int = 100) -> list:
return engine.store.recent_sightings(limit)
@app.patch("/api/identities/{identity_id}")
def rename_identity(identity_id: int, payload: RenamePayload) -> dict:
if not engine.store.rename_identity(identity_id, payload.label.strip()):
raise HTTPException(404, "identity not found")
return engine.store.get_identity(identity_id)
@app.get("/api/identities/{identity_id}/embedding")
def identity_embedding(identity_id: int) -> dict:
"""One identity's best stored vector, for forwarding to the server.
This returns biometric personal data. It is on the authenticated local
API and bound to loopback in the product, and it exists because the
event bus deliberately does not carry embeddings — putting a 512-float
template on the bus would send it to the log sink and the email sink
too.
"""
best = engine.store.best_embedding(identity_id, engine.gallery.model_name)
if best is None:
raise HTTPException(404, "no embedding for this identity "
"(or it was made by a different model)")
vector, quality = best
return {"identity_id": identity_id,
"model": engine.gallery.model_name,
"quality": round(quality, 3),
"embedding": [round(float(x), 6) for x in vector]}
@app.get("/api/identities/duplicates")
def duplicate_identities(limit: int = 20) -> list:
"""Identity pairs that look like one person enrolled twice."""
return engine.gallery.duplicate_candidates(limit)
@app.post("/api/identities/{identity_id}/merge")
def merge_identity(identity_id: int, payload: MergePayload) -> dict:
result = engine.gallery.merge_identities(
identity_id, payload.into, force=payload.force)
if not result.get("ok"):
reason = result.get("reason", "merge refused")
# 409, not 400: the request is well formed, it conflicts with what
# the gallery believes. The body carries the measured similarity so
# the UI can show the operator what it is asking them to override.
status = 404 if "not found" in reason else 409
raise HTTPException(status, detail=result)
engine.bus.publish(Event(
type="identity.merged", camera_id="",
data={k: result[k] for k in
("source", "target", "label", "similarity", "forced",
"embeddings_moved", "sightings_moved")}))
return result
@app.delete("/api/identities/{identity_id}")
def delete_identity(identity_id: int) -> dict:
if not engine.gallery.delete_identity(identity_id):
raise HTTPException(404, "identity not found")
return {"deleted": identity_id}
@app.get("/api/cameras")
def cameras() -> list:
out = []
for cam in engine.camera_store.list():
out.append(camera_public(cam, engine.workers.get(cam.id)))
return out
@app.post("/api/cameras", status_code=201)
def add_camera(payload: CameraPayload) -> dict:
data = payload.model_dump(exclude_none=True)
if not data.get("id"):
raise HTTPException(400, "id is required")
try:
cam = CameraConfig.model_validate(data)
cam.source() # reject "no url, no host, no webcam" before storing
# Resolve the per-camera gates here too. Without this an inverted
# enroll/match pair was only caught when the worker was built,
# which surfaced as a 500 "stored but failed to start" instead of
# telling the user what was wrong with what they typed.
engine.cfg.recognition.merged(cam.tuning)
except (ValidationError, ValueError) as exc:
raise HTTPException(400, str(exc))
try:
engine.camera_store.add(cam)
except ValueError as exc:
raise HTTPException(409, str(exc))
try:
engine.add_camera(cam)
except Exception as exc:
# Never leave the store describing a camera the engine refused —
# the two would disagree until the next restart.
engine.camera_store.delete(cam.id)
raise HTTPException(500, f"camera stored but failed to start: {exc}")
return camera_public(cam, engine.workers.get(cam.id))
@app.patch("/api/cameras/{camera_id}")
def edit_camera(camera_id: str, payload: CameraPayload) -> dict:
fields = payload.model_dump(exclude_none=True)
fields.pop("id", None)
try:
if "tuning" in fields:
engine.cfg.recognition.merged(
CameraTuning.model_validate(fields["tuning"]))
cam = engine.camera_store.update(camera_id, fields)
except (ValidationError, ValueError) as exc:
raise HTTPException(400, str(exc))
if cam is None:
raise HTTPException(404, f"unknown camera '{camera_id}'")
engine.restart_camera(cam) # a changed URL needs a fresh connection
return camera_public(cam, engine.workers.get(cam.id))
@app.delete("/api/cameras/{camera_id}")
def delete_camera(camera_id: str) -> dict:
if not engine.camera_store.delete(camera_id):
raise HTTPException(404, f"unknown camera '{camera_id}'")
engine.remove_camera(camera_id)
return {"deleted": camera_id}
@app.post("/api/cameras/{camera_id}/commission")
def start_commission(camera_id: str,
payload: CommissionPayload) -> dict:
"""Begin a placement check: watch this camera for N seconds and judge
whether faces here are good enough to enrol."""
worker = worker_or_404(camera_id)
# The camera's own gate, not the global one - the whole point is to
# judge this view against the threshold it will actually run under.
worker.commission = CommissionRun(
camera_id, worker.rcfg.min_enroll_quality, payload.seconds)
return worker.commission.report()
@app.get("/api/cameras/{camera_id}/commission")
def commission_result(camera_id: str) -> dict:
worker = worker_or_404(camera_id)
if worker.commission is None:
raise HTTPException(404, "no placement check has been run")
return worker.commission.report()
@app.delete("/api/cameras/{camera_id}/commission")
def cancel_commission(camera_id: str) -> dict:
worker = worker_or_404(camera_id)
if worker.commission is not None:
worker.commission.cancel()
return {"cancelled": camera_id}
@app.post("/api/cameras/test")
def test_camera(payload: CameraPayload) -> dict:
"""Try a camera WITHOUT saving it - the UI's Test button.
Deliberately a sync def so FastAPI runs it in the threadpool:
cv2.VideoCapture blocks hard and a wrong host can hang for the full
FFmpeg timeout, which would stall the whole event loop.
"""
from .capture import probe_source
data = payload.model_dump(exclude_none=True)
data.setdefault("id", "__test__")
try:
cam = CameraConfig.model_validate(data)
source = cam.source()
except (ValidationError, ValueError) as exc:
return {"ok": False, "error": str(exc)}
return probe_source(source, cam.max_width)
@app.get("/api/cameras/{camera_id}/frame.jpg")
def frame(camera_id: str) -> Response:
jpeg = worker_or_404(camera_id).latest_jpeg()
if jpeg is None:
raise HTTPException(503, "no frame yet")
return Response(jpeg, media_type="image/jpeg")
@app.get("/api/cameras/{camera_id}/stream.mjpeg")
async def stream(camera_id: str) -> StreamingResponse:
worker = worker_or_404(camera_id)
async def generate():
boundary = b"--frame\r\nContent-Type: image/jpeg\r\n\r\n"
# Stop when the camera is deleted or its worker dies - otherwise a
# removed camera leaves this generator running for the life of the
# process, holding a reference to a worker nothing else can see.
while engine.workers.get(camera_id) is worker and worker.is_alive():
jpeg = worker.latest_jpeg()
if jpeg is not None:
yield boundary + jpeg + b"\r\n"
await asyncio.sleep(0.1) # ~10 fps to the browser
return StreamingResponse(
generate(),
media_type="multipart/x-mixed-replace; boundary=frame")
return app

220
behavision/attributes.py Normal file
View File

@@ -0,0 +1,220 @@
"""Optional age / gender / emotion estimation.
Primary gender+age model: InsightFace `genderage.onnx` (2021, CNN trained
jointly with the face-recognition stack; outputs age in YEARS). Fallback:
the 2015 Levi-Hassner Caffe nets. Emotion: FER+ ONNX.
Crop discipline — the part that made the old results absurd: gender/age
models are trained on LOOSE head crops (hair, chin, head shape included),
so they receive a 1.5x-expanded box from the full frame, never the tight
112x112 recognition chip. Only FER+ gets the aligned chip.
Everything is best-effort: any net missing or failing (e.g. out of memory)
is skipped or disabled without touching the recognition pipeline.
"""
from __future__ import annotations
import logging
import threading
from pathlib import Path
import cv2
import numpy as np
log = logging.getLogger(__name__)
AGE_BUCKETS = ["0-2", "4-6", "8-12", "15-20", "25-32", "38-43", "48-53", "60+"]
GENDERS = ["Male", "Female"]
EMOTIONS = ["neutral", "happiness", "surprise", "sadness",
"anger", "disgust", "fear", "contempt"]
_CAFFE_MEAN = (78.4263377603, 87.7689143744, 114.895847746)
def _loose_head_crop(frame: np.ndarray, box, scale: float = 1.5) -> np.ndarray:
"""Square crop centered on the face box, expanded to include the whole
head; replicate-padded when it runs off-frame so aspect stays 1:1."""
x1, y1, x2, y2 = box
cx, cy = (x1 + x2) / 2.0, (y1 + y2) / 2.0
half = max(x2 - x1, y2 - y1) * scale / 2.0
fh, fw = frame.shape[:2]
gx1, gy1 = int(round(cx - half)), int(round(cy - half))
gx2, gy2 = int(round(cx + half)), int(round(cy + half))
pad_l, pad_t = max(0, -gx1), max(0, -gy1)
pad_r, pad_b = max(0, gx2 - fw), max(0, gy2 - fh)
crop = frame[max(0, gy1):min(fh, gy2), max(0, gx1):min(fw, gx2)]
if crop.size == 0:
return crop
if pad_l or pad_t or pad_r or pad_b:
crop = cv2.copyMakeBorder(crop, pad_t, pad_b, pad_l, pad_r,
cv2.BORDER_REPLICATE)
return crop
def aggregate(samples: "list[dict]") -> dict:
"""Combine per-frame estimates into one verdict for a track.
Age comes from a tiny CNN reading a single frame, so consecutive frames of
the same face can differ by a decade. Median over several frames (not mean)
keeps one wild frame from dragging the answer, and costs nothing but the
inferences already being run.
"""
samples = [s for s in samples if s]
if not samples:
return {}
out: dict = {}
ages = [s["age"] for s in samples if isinstance(s.get("age"), (int, float))]
if ages:
out["age"] = int(round(float(np.median(ages))))
out["age_spread"] = int(max(ages) - min(ages)) # honest uncertainty
for field, conf_field in (("gender", "gender_confidence"),
("emotion", "emotion_confidence"),
("age_range", None)):
votes: dict = {}
for s in samples:
v = s.get(field)
if v is None:
continue
votes.setdefault(v, []).append(s.get(conf_field, 1.0) if conf_field else 1.0)
if not votes:
continue
# most frames win; ties broken by mean confidence
best = max(votes, key=lambda k: (len(votes[k]), float(np.mean(votes[k]))))
out[field] = best
if conf_field:
out[conf_field] = round(float(np.mean(votes[best])), 3)
return out
class AttributeEstimator:
def __init__(self, models_dir: Path):
models_dir = Path(models_dir)
# cv2.dnn.Net (emotion + the Caffe fallbacks) is stateful across
# setInput/forward, so concurrent camera workers must not enter
# together. Attributes run once per TRACK, not per frame, so the
# contention this costs is negligible.
self._lock = threading.Lock()
self._genderage = None
self._ga_input = None
ga_path = models_dir / "genderage.onnx"
if ga_path.exists():
try:
import onnxruntime as ort
self._genderage = ort.InferenceSession(
str(ga_path), providers=["CPUExecutionProvider"])
inp = self._genderage.get_inputs()[0]
self._ga_input = inp.name
self._ga_size = (inp.shape[-1]
if isinstance(inp.shape[-1], int) else 96)
except Exception:
log.exception("genderage model failed to load")
self._genderage = None
# Legacy Caffe fallbacks, used only when genderage is unavailable.
self._gender = None
self._age = None
if self._genderage is None:
self._gender = self._load_caffe(models_dir, "gender")
self._age = self._load_caffe(models_dir, "age")
self._emotion = None
emo = models_dir / "emotion-ferplus-8.onnx"
if emo.exists():
try:
self._emotion = cv2.dnn.readNetFromONNX(str(emo))
except cv2.error:
log.exception("emotion model failed to load")
log.info("attributes: genderage=%s caffe(gender=%s age=%s) emotion=%s",
bool(self._genderage), bool(self._gender), bool(self._age),
bool(self._emotion))
@staticmethod
def _load_caffe(models_dir: Path, name: str):
proto = models_dir / f"{name}_deploy.prototxt"
weights = models_dir / f"{name}_net.caffemodel"
if not (proto.exists() and weights.exists()):
return None
try:
return cv2.dnn.readNetFromCaffe(str(proto), str(weights))
except cv2.error:
log.exception("%s model failed to load", name)
return None
@property
def has_genderage(self) -> bool:
return self._genderage is not None
@property
def any_loaded(self) -> bool:
return any([self._genderage, self._gender, self._age, self._emotion])
def estimate(self, frame_bgr: np.ndarray, box,
chip_bgr: np.ndarray) -> dict:
"""`frame_bgr` + `box` feed the gender/age nets (loose head crop);
`chip_bgr` (aligned 112x112) feeds FER+ emotion."""
out: dict = {}
head = _loose_head_crop(frame_bgr, box)
with self._lock:
if head.size:
if self._genderage is not None:
self._estimate_genderage(head, out)
elif self._gender is not None or self._age is not None:
self._estimate_caffe(head, out)
if (self._emotion is not None and chip_bgr is not None
and chip_bgr.size):
self._estimate_emotion(chip_bgr, out)
return out
# -- backends -------------------------------------------------------
def _estimate_genderage(self, head: np.ndarray, out: dict) -> None:
try:
size = self._ga_size
rgb = cv2.cvtColor(cv2.resize(head, (size, size)),
cv2.COLOR_BGR2RGB).astype(np.float32)
blob = rgb.transpose(2, 0, 1)[None]
pred = self._genderage.run(None, {self._ga_input: blob})[0][0]
# pred = [female_logit, male_logit, age/100]
g = np.array(pred[:2], dtype=np.float64)
probs = np.exp(g - g.max())
probs /= probs.sum()
out["gender"] = "Male" if pred[1] > pred[0] else "Female"
out["gender_confidence"] = round(float(probs.max()), 3)
out["age"] = int(round(float(pred[2]) * 100))
except Exception:
log.warning("genderage failed at inference - disabled")
self._genderage = None
def _estimate_caffe(self, head: np.ndarray, out: dict) -> None:
blob = cv2.dnn.blobFromImage(
cv2.resize(head, (227, 227)), 1.0, (227, 227),
_CAFFE_MEAN, swapRB=False)
if self._gender is not None:
try:
self._gender.setInput(blob)
probs = self._gender.forward().ravel()
out["gender"] = GENDERS[int(np.argmax(probs))]
out["gender_confidence"] = round(float(probs.max()), 3)
except cv2.error:
log.warning("gender net failed at inference - disabled")
self._gender = None
if self._age is not None:
try:
self._age.setInput(blob)
probs = self._age.forward().ravel()
out["age_range"] = AGE_BUCKETS[int(np.argmax(probs))]
except cv2.error:
log.warning("age net failed at inference - disabled")
self._age = None
def _estimate_emotion(self, chip_bgr: np.ndarray, out: dict) -> None:
try:
gray = cv2.cvtColor(chip_bgr, cv2.COLOR_BGR2GRAY)
blob = cv2.resize(gray, (64, 64)).astype(np.float32)[None, None]
self._emotion.setInput(blob)
logits = self._emotion.forward().ravel()
exp = np.exp(logits - logits.max())
probs = exp / exp.sum()
out["emotion"] = EMOTIONS[int(np.argmax(probs))]
out["emotion_confidence"] = round(float(probs.max()), 3)
except cv2.error:
log.warning("emotion net failed at inference - disabled")
self._emotion = None

535
behavision/calibrate.py Normal file
View File

@@ -0,0 +1,535 @@
"""Threshold calibration: derive match/enroll thresholds from measured data.
The three recognition thresholds are not universal constants — they describe a
particular *encoder* on a particular *camera*. Change either and the numbers
that were measured for the old pair silently stop describing the new one: the
gallery starts splitting one person into several (enroll_threshold too high) or
merging different people (match_threshold too low).
This module measures the two distributions that actually decide those numbers:
same-person similarity - how alike two views of ONE person look
cross-person similarity - how alike views of DIFFERENT people look
and reports thresholds that separate them, per model, so a model swap is a
measurement rather than a guess.
Two details make the measurement match runtime instead of merely resembling it:
- Identity is decided from the *mean* of `min_embeddings_for_id` embeddings,
never a single frame (see engine._identify). So samples are grouped and
averaged the same way before any similarity is computed. Measuring
single-frame similarity would report a much wider spread than the running
system ever sees.
- Only frames that pass the live quality gate are collected, because those are
the only frames the running system ever embeds.
Privacy: no images are written. Chips are embedded in memory and only the
resulting vectors are stored, matching the guarantee the rest of the system
makes.
"""
from __future__ import annotations
import logging
import time
from pathlib import Path
from typing import Optional
import numpy as np
log = logging.getLogger(__name__)
# Below this, a "recommendation" would be fitting noise.
MIN_GROUPS_PER_PERSON = 2
MIN_SAMPLES_PER_PERSON = 6
class CalibrationStore:
"""Embeddings per (model, person), persisted as a single .npz.
Keyed by model so one capture session can be replayed against several
encoders — that is what makes an A/B of two models fair: identical faces,
identical frames, only the encoder differs.
"""
def __init__(self, path: "Path | str"):
self.path = Path(path)
self.data: dict[str, np.ndarray] = {}
if self.path.exists():
with np.load(self.path) as npz:
self.data = {k: npz[k] for k in npz.files}
# Quality is stored under a parallel key rather than a second file, so a
# capture session stays one artefact. Suffixed (not prefixed) so the
# model/person parsing below keeps working on old archives.
_Q = "||__quality"
@staticmethod
def _key(model: str, person: str) -> str:
return f"{model}||{person}"
def add(self, model: str, person: str, embeddings: np.ndarray,
qualities: "np.ndarray | None" = None) -> int:
key = self._key(model, person)
if key in self.data and len(self.data[key]):
embeddings = np.vstack([self.data[key], embeddings])
self.data[key] = np.asarray(embeddings, dtype=np.float32)
if qualities is not None:
qkey = key + self._Q
q = np.asarray(qualities, dtype=np.float32).reshape(-1)
if qkey in self.data and len(self.data[qkey]):
q = np.concatenate([self.data[qkey], q])
self.data[qkey] = q
return len(self.data[key])
def models(self) -> "list[str]":
return sorted({k.split("||", 1)[0] for k in self.data
if not k.endswith(self._Q)})
def people(self, model: str) -> "list[str]":
return sorted(k.split("||", 1)[1] for k in self.data
if k.startswith(f"{model}||") and not k.endswith(self._Q))
def get(self, model: str, person: str) -> np.ndarray:
return self.data.get(self._key(model, person), np.empty((0, 512), np.float32))
def qualities(self, model: str, person: str) -> np.ndarray:
"""Per-embedding quality, or empty for an archive captured before
quality was recorded. Empty means 'unknown', never 'zero'."""
q = self.data.get(self._key(model, person) + self._Q,
np.empty(0, np.float32))
emb = self.get(model, person)
# A partially-upgraded archive would silently misalign the two arrays.
return q if len(q) == len(emb) else np.empty(0, np.float32)
def save(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
np.savez_compressed(self.path, **self.data)
def _mean_unit(vectors: np.ndarray) -> Optional[np.ndarray]:
"""Normalised mean — the exact quantity the engine matches on."""
mean = vectors.mean(axis=0)
norm = float(np.linalg.norm(mean))
if norm < 1e-6:
return None
return (mean / norm).astype(np.float32)
def group_means(embeddings: np.ndarray, group_size: int) -> np.ndarray:
"""Chunk into groups of `group_size` and average each, mirroring the
multi-frame averaging in engine._identify. A trailing partial group is
kept only if it holds at least half a group, so one stray frame cannot
contribute a noisy 'identity' to the statistics."""
out = []
for start in range(0, len(embeddings), group_size):
chunk = embeddings[start:start + group_size]
if len(chunk) < max(2, (group_size + 1) // 2):
break
mean = _mean_unit(chunk)
if mean is not None:
out.append(mean)
return np.vstack(out) if out else np.empty((0, embeddings.shape[1]), np.float32)
def capture(source, label: str, seconds: float, cfg, detector, encoders: dict,
min_quality: Optional[float] = None
) -> "tuple[dict[str, np.ndarray], np.ndarray]":
"""Collect faces from `source`, embed with every encoder, keep the quality.
`min_quality` defaults to 0.0 — everything the detector finds is recorded,
with its score. It used to default to the live enrollment gate, which made
the gate impossible to calibrate: you cannot measure whether a threshold is
set correctly using only the data that threshold already admitted. The
filter now happens at analysis time (`distributions`), where it can be
varied, which keeps the runtime-matching property without the circularity.
`source` is anything cv2.VideoCapture accepts (webcam index, RTSP URL,
video file). Returns ({model_name: embeddings}, qualities) with the
quality array aligned to every model's rows. Raises if the source will not
open, since a silent empty capture is worse than a loud failure.
"""
import cv2
from .geometry import align_face
from .recognition import face_quality
if min_quality is None:
min_quality = 0.0
cap = cv2.VideoCapture(source)
if not cap.isOpened():
cap.release()
raise RuntimeError(f"cannot open capture source {source!r}")
per_model: dict[str, list] = {name: [] for name in encoders}
qualities: list = []
max_width = cfg.cameras[0].max_width if cfg.cameras else 1280
deadline = time.time() + seconds
seen = rejected = 0
try:
while time.time() < deadline:
ok, frame = cap.read()
if not ok or frame is None:
break
if max_width and frame.shape[1] > max_width:
scale = max_width / frame.shape[1]
frame = cv2.resize(frame, (max_width, int(frame.shape[0] * scale)),
interpolation=cv2.INTER_AREA)
detections = detector.detect(frame)
if len(detections) > 1:
# Two faces in frame makes the 'which person is this' label
# ambiguous, and a mislabelled sample poisons both curves.
rejected += 1
continue
for det in detections:
seen += 1
quality = face_quality(frame, det.box, det.kps)
if quality < min_quality:
rejected += 1
continue
# Commit a frame only if EVERY encoder embedded it. A partial
# row would desynchronise the models from each other and from
# the quality array, quietly breaking both the A/B comparison
# and the quality analysis.
row = {}
for name, enc in encoders.items():
chip = align_face(frame, det.kps, size=enc.size)
emb = enc.encode_chip(chip)
if emb is None:
break
row[name] = emb
if len(row) != len(encoders):
rejected += 1
continue
for name, emb in row.items():
per_model[name].append(emb)
qualities.append(quality)
finally:
cap.release()
kept = len(qualities)
log.info("[%s] %d faces seen, %d rejected (ambiguous/unencodable), %d kept "
"(quality p05 %.2f - p95 %.2f)", label, seen, rejected, kept,
float(np.percentile(qualities, 5)) if qualities else 0.0,
float(np.percentile(qualities, 95)) if qualities else 0.0)
return ({name: (np.vstack(v) if v else np.empty((0, 512), np.float32))
for name, v in per_model.items()},
np.asarray(qualities, dtype=np.float32))
def distributions(store: CalibrationStore, model: str, group_size: int,
min_quality: Optional[float] = None
) -> "tuple[np.ndarray, np.ndarray, dict]":
"""Same-person and cross-person similarity samples for one model.
`min_quality` filters to the frames the running system would actually
embed. Applied here rather than at capture time so the same archive can be
re-analysed against a different gate — that is what makes the gate itself
measurable instead of assumed.
"""
grouped, skipped = {}, {}
ungated, gated_out = [], {}
for person in store.people(model):
raw = store.get(model, person)
if min_quality is not None:
q = store.qualities(model, person)
if len(q):
kept = raw[q >= min_quality]
if len(kept) < len(raw):
gated_out[person] = (len(raw) - len(kept), len(raw))
raw = kept
else:
ungated.append(person)
means = group_means(raw, group_size)
if len(means) < MIN_GROUPS_PER_PERSON or len(raw) < MIN_SAMPLES_PER_PERSON:
skipped[person] = len(raw)
continue
grouped[person] = means
same, cross = [], []
people = sorted(grouped)
for i, person in enumerate(people):
m = grouped[person]
for a in range(len(m)):
for b in range(a + 1, len(m)):
same.append(float(m[a] @ m[b]))
for other in people[i + 1:]:
for va in m:
for vb in grouped[other]:
cross.append(float(va @ vb))
meta = {"people": people, "skipped": skipped,
"groups": {p: len(m) for p, m in grouped.items()}}
if ungated:
meta["ungated"] = ungated # captured before quality was recorded
if gated_out:
meta["gated_out"] = gated_out
return np.array(same), np.array(cross), meta
# -- quality gate -------------------------------------------------------
# Wide enough that a bucket holds real evidence, narrow enough to locate a
# knee; below this a bucket's median is one or two frames talking.
QUALITY_BUCKET = 0.05
MIN_BUCKET_SAMPLES = 5
# A bucket counts as "as good as this camera gets" within this fraction of the
# best bucket. Not an absolute target: what matters is whether a frame is
# materially worse than what this camera can produce, not how it compares to a
# number measured somewhere else.
KNEE_FRACTION = 0.90
def _self_similarity(embeddings: np.ndarray) -> np.ndarray:
"""Each embedding's similarity to its own person's mean, computed
leave-one-out so a sample is not compared against a mean it helped make."""
n = len(embeddings)
if n < 2:
return np.empty(0, np.float32)
total = embeddings.sum(axis=0)
others = (total - embeddings) / (n - 1)
norms = np.linalg.norm(others, axis=1, keepdims=True)
norms[norms < 1e-6] = 1.0
return np.einsum("ij,ij->i", embeddings, others / norms).astype(np.float32)
def quality_curve(store: CalibrationStore, model: str) -> dict:
"""Does face quality actually predict a usable embedding on this camera?
Pairs every captured frame's quality score with how much that frame looks
like its own person, then reports the relationship. This is the evidence
`min_enroll_quality` should be set from; it was previously the one
threshold in the system still chosen by hand.
"""
quals, sims = [], []
for person in store.people(model):
emb = store.get(model, person)
q = store.qualities(model, person)
if not len(q) or len(emb) < 2:
continue
sim = _self_similarity(emb)
if len(sim):
quals.append(q)
sims.append(sim)
if not quals:
return {"n": 0, "error": (
"no per-frame quality recorded - this archive predates quality "
"capture. Re-capture to calibrate the quality gate.")}
q = np.concatenate(quals)
sim = np.concatenate(sims)
out: dict = {"n": int(len(q)),
"quality": {"p05": round(float(np.percentile(q, 5)), 3),
"p50": round(float(np.percentile(q, 50)), 3),
"p95": round(float(np.percentile(q, 95)), 3)}}
# Whether the score means anything here at all. Undefined if every frame
# scored the same, which is itself the signature of a static artefact.
if q.std() > 1e-6 and sim.std() > 1e-6:
out["correlation"] = round(float(np.corrcoef(q, sim)[0, 1]), 3)
# Bin by integer index rather than by accumulating a float edge. Stepping
# `edge += 0.05` from 0.30 reaches 0.5000000000000001, so a quality of
# exactly 0.50 tests as *below* its own bucket and lands one step down —
# which shifts the recommended gate a whole bucket, and that number is
# copied straight into a config file.
idx = np.floor(q / QUALITY_BUCKET + 1e-9).astype(int)
buckets = []
for b in range(int(idx.min()), int(idx.max()) + 1):
sel = idx == b
if sel.sum() >= MIN_BUCKET_SAMPLES:
buckets.append({"lo": round(b * QUALITY_BUCKET, 2),
"hi": round((b + 1) * QUALITY_BUCKET, 2),
"n": int(sel.sum()),
"median_sim": round(float(np.median(sim[sel])), 3)})
out["buckets"] = buckets
if not buckets:
out["note"] = (f"fewer than {MIN_BUCKET_SAMPLES} frames in every "
"quality bucket - capture longer")
return out
best = max(b["median_sim"] for b in buckets)
target = best * KNEE_FRACTION
# Walk down from the top and stop at the first bucket that falls off, so a
# single noisy low bucket cannot drag the recommendation down with it.
gate = buckets[-1]["lo"]
for bucket in reversed(buckets):
if bucket["median_sim"] < target:
break
gate = bucket["lo"]
out["best_median_sim"] = round(float(best), 3)
out["min_enroll_quality"] = round(float(gate), 2)
out["retained_fraction"] = round(float((q >= gate).mean()), 3)
if gate <= buckets[0]["lo"]:
out["note"] = ("quality does not predict embedding stability on this "
"camera - every bucket is about as good as the best. "
"The gate is discarding frames for no measured benefit; "
"the limit here is the view, not the threshold.")
return out
def recommend(same: np.ndarray, cross: np.ndarray,
current_match: Optional[float] = None) -> dict:
"""Turn the two distributions into thresholds.
match_threshold - above the bulk of cross-person similarity, so a stranger
is not merged into an existing identity.
enroll_threshold - below the bulk of same-person similarity, so a returning
person is not minted as a duplicate.
Both are set from percentiles rather than raw min/max: one freak frame
should not move a production threshold. When the two curves overlap, no
pair of thresholds can separate them and that is reported as such rather
than papered over with a midpoint.
"""
out: dict = {"n_same": int(len(same)), "n_cross": int(len(cross))}
if len(same):
out["same"] = {"min": float(same.min()), "p01": float(np.percentile(same, 1)),
"p05": float(np.percentile(same, 5)),
"mean": float(same.mean()), "max": float(same.max())}
if len(cross):
out["cross"] = {"min": float(cross.min()), "mean": float(cross.mean()),
"p95": float(np.percentile(cross, 95)),
"p99": float(np.percentile(cross, 99)),
"max": float(cross.max())}
if not len(same):
out["error"] = ("no same-person pairs - capture more frames per person "
f"(need >={MIN_SAMPLES_PER_PERSON})")
return out
same_low = float(np.percentile(same, 5))
if len(cross):
cross_high = float(np.percentile(cross, 99))
out["separation"] = round(same_low - cross_high, 3)
if same_low <= cross_high:
out["error"] = (
"same-person and cross-person similarity OVERLAP - no threshold "
"pair separates them. Improve capture (pose, lighting, distance) "
"or use a stronger encoder before trusting any threshold.")
out["match_threshold"] = round(cross_high + 0.02, 2)
out["enroll_threshold"] = round(max(0.05, same_low - 0.02), 2)
return out
# BOTH thresholds are placed inside the gap between the curves, which
# keeps enroll < match however wide the separation turns out to be.
# Anchoring them to the distribution ends instead (same_p05 - margin)
# inverts the pair on well-separated data. match sits high in the gap
# because a false merge is unrecoverable — two people permanently share
# one identity — while a false split is a duplicate you can merge later.
gap = same_low - cross_high
out["match_threshold"] = round(cross_high + 0.55 * gap, 2)
out["enroll_threshold"] = round(cross_high + 0.15 * gap, 2)
if out["enroll_threshold"] >= out["match_threshold"]: # after rounding
out["enroll_threshold"] = round(out["match_threshold"] - 0.01, 2)
return out
out["note"] = ("only one person captured - cross-person similarity is "
"unmeasured, so match_threshold cannot be recommended. "
"Capture 2+ people to calibrate it.")
out["match_threshold"] = None
enroll = max(0.05, same_low - 0.05)
if current_match is not None and enroll > current_match - 0.01:
# config.py enforces enroll < match; never emit a value that would be
# rejected at load time against the match threshold still in force.
# Flag it, because a clamped value is the ceiling talking, not the
# data — without this, every model reports the same number and it
# reads like a measurement.
enroll = current_match - 0.01
out["clamped"] = (
f"same-person p05 is {same_low:.3f}, so the data supports an enroll "
f"threshold far above the current match threshold "
f"({current_match}). Clamped to sit just under it - calibrate "
f"match_threshold with 2+ people to lift both.")
out["enroll_threshold"] = round(enroll, 2)
return out
def format_report(store: CalibrationStore, cfg) -> str:
"""Human-readable report for every model in the store."""
group_size = cfg.tracking.min_embeddings_for_id
lines = [f"Calibration report ({store.path})",
f"grouping: mean of {group_size} embeddings (matches runtime)", ""]
for model in store.models():
same, cross, meta = distributions(store, model, group_size,
cfg.recognition.min_enroll_quality)
rec = recommend(same, cross, cfg.recognition.match_threshold)
qual = quality_curve(store, model)
lines.append(f"── {model} " + "─" * max(0, 56 - len(model)))
lines.append(f" people: {', '.join(meta['people']) or 'none'}")
if meta["skipped"]:
lines.append(" skipped (too few samples): " + ", ".join(
f"{p} ({n})" for p, n in meta["skipped"].items()))
if "same" in rec:
s = rec["same"]
lines.append(f" same-person n={rec['n_same']:<5} "
f"min {s['min']:.3f} p05 {s['p05']:.3f} mean {s['mean']:.3f}")
if "cross" in rec:
c = rec["cross"]
lines.append(f" cross-person n={rec['n_cross']:<5} "
f"mean {c['mean']:.3f} p99 {c['p99']:.3f} max {c['max']:.3f}")
if "separation" in rec:
lines.append(f" separation (same_p05 - cross_p99): {rec['separation']:+.3f}")
if "error" in rec:
lines.append(f" !! {rec['error']}")
if meta.get("gated_out"):
worst = ", ".join(f"{p} ({out}/{tot})"
for p, (out, tot) in meta["gated_out"].items())
lines.append(f" dropped by min_enroll_quality="
f"{cfg.recognition.min_enroll_quality}: {worst}")
if not meta["people"]:
# Otherwise the error above reads 'capture more frames' when
# plenty were captured and the gate discarded all of them —
# sending the operator to re-shoot instead of to the gate.
lines.append(" ^ every sample was captured, then filtered "
"out by the quality gate. The capture is fine; "
"the gate does not fit this camera.")
if "note" in rec:
lines.append(f" note: {rec['note']}")
if "clamped" in rec:
lines.append(f" clamped: {rec['clamped']}")
if meta.get("ungated"):
lines.append(" note: no per-frame quality for "
+ ", ".join(meta["ungated"])
+ " - analysed unfiltered (older capture)")
# -- quality gate ------------------------------------------------
if qual.get("error"):
lines.append(f" quality gate: {qual['error']}")
elif qual.get("buckets"):
q = qual["quality"]
lines.append("")
lines.append(f" face quality n={qual['n']:<5} "
f"p05 {q['p05']:.3f} p50 {q['p50']:.3f} "
f"p95 {q['p95']:.3f}")
if "correlation" in qual:
lines.append(" quality vs same-person similarity: "
f"r={qual['correlation']:+.3f}")
for b in qual["buckets"]:
bar = "#" * int(round(b["median_sim"] * 40))
lines.append(f" {b['lo']:.2f}-{b['hi']:.2f} "
f"n={b['n']:<4} med {b['median_sim']:.3f} {bar}")
if qual.get("note"):
lines.append(f" !! {qual['note']}")
elif qual.get("note"):
lines.append(f" quality gate: {qual['note']}")
if rec.get("match_threshold") is not None:
lines.append("")
lines.append(" recommended config/default.yaml:")
lines.append(" recognition:")
lines.append(f" match_threshold: {rec['match_threshold']}")
lines.append(f" enroll_threshold: {rec['enroll_threshold']}")
if qual.get("min_enroll_quality") is not None:
lines.append(f" min_enroll_quality: "
f"{qual['min_enroll_quality']}"
f" # keeps {qual['retained_fraction']:.0%} of faces")
elif rec.get("enroll_threshold") is not None:
lines.append("")
lines.append(" recommended (enroll only, match needs 2+ people):")
lines.append(f" enroll_threshold: {rec['enroll_threshold']}")
if qual.get("min_enroll_quality") is not None:
lines.append(f" min_enroll_quality: "
f"{qual['min_enroll_quality']}"
f" # keeps {qual['retained_fraction']:.0%} of faces")
lines.append("")
lines.append(f"current: match={cfg.recognition.match_threshold} "
f"enroll={cfg.recognition.enroll_threshold} "
f"min_enroll_quality={cfg.recognition.min_enroll_quality}")
return "\n".join(lines)

194
behavision/cameras.py Normal file
View File

@@ -0,0 +1,194 @@
"""Writable camera list — the store behind "user connects their camera".
Cameras used to live in `config/default.yaml` with credentials in `.env`, which
means adding one is an edit-and-restart. A product needs them added at runtime
from a UI, so they move here: a small JSON file the API can rewrite safely
while the engine is running.
Deliberately NOT stored in `behavision.db`. That file is a biometric database
with its own handling and erasure obligations; folding user-editable config
into it makes both harder to reason about, to back up, and to hand to support.
Passwords are protected at rest with Windows DPAPI. This is not theatre: the
file sits on the same disk as the face gallery, and an RTSP credential is a
live path into the camera itself.
"""
from __future__ import annotations
import base64
import json
import logging
import os
import threading
from pathlib import Path
from typing import Optional
from .config import CameraConfig
log = logging.getLogger(__name__)
_PLAIN = "plain:"
_DPAPI = "dpapi:"
# Machine scope, not user scope. The service (LocalSystem) and an admin running
# the CLI are different accounts, and a user-scoped blob written by one cannot
# be read by the other — a failure that only shows up after install, on the
# customer's machine. Machine scope still defends the actual threat here:
# someone copying cameras.json off the box.
_CRYPTPROTECT_LOCAL_MACHINE = 0x04
def _win32crypt():
try:
import win32crypt # type: ignore
return win32crypt
except ImportError:
return None
def protect(value: str) -> str:
"""Encrypt a secret for storage. Tagged so the format can change later."""
if not value:
return ""
crypt = _win32crypt()
if crypt is None:
return _PLAIN + value
try:
blob = crypt.CryptProtectData(value.encode("utf-8"), "behavision",
None, None, None,
_CRYPTPROTECT_LOCAL_MACHINE)
return _DPAPI + base64.b64encode(blob).decode("ascii")
except Exception:
log.warning("DPAPI unavailable - storing camera password unencrypted",
exc_info=True)
return _PLAIN + value
def unprotect(stored: str) -> str:
"""Inverse of `protect`. Never raises: a credential that cannot be read is
an empty credential, so one unreadable camera does not stop the engine."""
if not stored:
return ""
if stored.startswith(_PLAIN):
return stored[len(_PLAIN):]
if stored.startswith(_DPAPI):
crypt = _win32crypt()
if crypt is None:
log.error("camera password is DPAPI-encrypted but win32crypt is "
"unavailable - re-enter it on this machine")
return ""
try:
return crypt.CryptUnprotectData(
base64.b64decode(stored[len(_DPAPI):]),
None, None, None, 0)[1].decode("utf-8")
except Exception:
log.error("camera password could not be decrypted (config copied "
"from another machine?) - re-enter it", exc_info=True)
return ""
return stored # pre-tag file written before this module existed
class CameraStore:
"""Cameras as JSON, safe to rewrite while the engine is running."""
def __init__(self, path: "Path | str"):
self.path = Path(path)
self._lock = threading.RLock()
self._cameras: "dict[str, CameraConfig]" = {}
self._load()
# -- persistence ----------------------------------------------------
def _load(self) -> None:
if not self.path.exists():
return
try:
raw = json.loads(self.path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
log.exception("%s is unreadable - starting with no cameras "
"(the file is left in place, not overwritten)",
self.path)
return
for entry in raw.get("cameras", []):
try:
entry = dict(entry)
entry["password"] = unprotect(entry.get("password", ""))
cam = CameraConfig.model_validate(entry)
except Exception:
log.exception("skipping malformed camera entry %r", entry)
continue
self._cameras[cam.id] = cam
def _save(self) -> None:
"""Atomic: a crash mid-write must not leave a truncated camera list."""
payload = {"version": 1, "cameras": []}
for cam in self._cameras.values():
entry = cam.model_dump(mode="json")
entry["password"] = protect(cam.password)
payload["cameras"].append(entry)
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp = self.path.with_suffix(".json.tmp")
tmp.write_text(json.dumps(payload, indent=2), encoding="utf-8")
try:
os.chmod(tmp, 0o600)
except OSError: # best effort (Windows)
pass
os.replace(tmp, self.path) # atomic on POSIX and NTFS
# -- CRUD -----------------------------------------------------------
def list(self) -> "list[CameraConfig]":
with self._lock:
return list(self._cameras.values())
def get(self, camera_id: str) -> Optional[CameraConfig]:
with self._lock:
return self._cameras.get(camera_id)
def add(self, camera: CameraConfig) -> CameraConfig:
with self._lock:
if camera.id in self._cameras:
raise ValueError(f"camera '{camera.id}' already exists")
camera.source() # validate now, not at connect time
self._cameras[camera.id] = camera
self._save()
return camera
def update(self, camera_id: str, fields: dict) -> Optional[CameraConfig]:
with self._lock:
existing = self._cameras.get(camera_id)
if existing is None:
return None
# id is the engine's key for the worker; renaming would orphan it
fields = {k: v for k, v in fields.items()
if k != "id" and v is not None}
# Re-validated rather than model_copy(update=...): copy does not
# coerce, so a nested `tuning` arriving as a plain dict from JSON
# would be stored as a dict and blow up the first time a camera
# asked it for its thresholds.
updated = CameraConfig.model_validate(
{**existing.model_dump(), **fields})
updated.source()
self._cameras[camera_id] = updated
self._save()
return updated
def delete(self, camera_id: str) -> bool:
with self._lock:
if self._cameras.pop(camera_id, None) is None:
return False
self._save()
return True
def seed(self, cameras: "list[CameraConfig]") -> bool:
"""Import YAML-declared cameras on first run only.
After that the store is authoritative — otherwise a camera the user
deleted in the UI would reappear on every restart.
"""
with self._lock:
if self.path.exists() or not cameras:
return False
for cam in cameras:
self._cameras[cam.id] = cam
self._save()
log.info("seeded %d camera(s) from YAML into %s",
len(cameras), self.path)
return True

237
behavision/capture.py Normal file
View File

@@ -0,0 +1,237 @@
"""Resilient video capture: RTSP (or webcam) reader thread with reconnect.
Design: one daemon thread per source holds the newest frame in a single
slot. Consumers always get the latest frame (never a backlog), and a lost
camera reconnects with exponential backoff instead of killing the pipeline.
"""
from __future__ import annotations
import logging
import os
import threading
import time
from typing import Optional
import cv2
import numpy as np
log = logging.getLogger(__name__)
# Force TCP transport and a 5s socket timeout for RTSP before OpenCV loads
# ffmpeg. UDP is the default and silently drops frames on lossy Wi-Fi.
os.environ.setdefault(
"OPENCV_FFMPEG_CAPTURE_OPTIONS", "rtsp_transport;tcp|stimeout;5000000"
)
def _tcp_reachable(source: "str | int", timeout: float
) -> "tuple[bool, str]":
"""Cheap pre-flight for an rtsp:// URL. Non-URL sources pass through."""
import socket
from urllib.parse import urlparse
if isinstance(source, int):
return True, ""
parsed = urlparse(source)
if not parsed.hostname:
return True, "" # not a form we can pre-check; let OpenCV try
port = parsed.port or (554 if parsed.scheme == "rtsp" else 80)
try:
with socket.create_connection((parsed.hostname, port), timeout):
return True, ""
except socket.timeout:
return False, (f"no response from {parsed.hostname}:{port} within "
f"{timeout:.0f}s - check the IP address and that the "
f"camera is on the same network")
except OSError as exc:
return False, f"cannot reach {parsed.hostname}:{port} - {exc.strerror or exc}"
def probe_source(source: "str | int", max_width: int = 1280,
timeout: float = 12.0, connect_timeout: float = 3.0) -> dict:
"""Open a candidate camera, grab one frame, and let go.
Backs the UI's Test button, so it must answer for a *wrong* URL as
reliably as a right one: no retries, no reconnect loop, and a hard deadline
because a bad host makes cv2.VideoCapture block until FFmpeg gives up.
Returns a JPEG snapshot so the user can confirm the camera is pointing
where they think it is.
"""
import base64
# cv2.VideoCapture blocks inside the constructor while FFmpeg completes a
# TCP connect, and against an unroutable host that is the OS connect
# timeout (~75s), not our deadline. A wrong IP or port is the single most
# likely thing a user types, so check reachability first — it turns the
# common failure into a sub-second answer instead of a frozen UI.
reachable, why = _tcp_reachable(source, connect_timeout)
if not reachable:
return {"ok": False, "error": why}
cap = None
try:
cap = (cv2.VideoCapture(source) if isinstance(source, int)
else cv2.VideoCapture(source, cv2.CAP_FFMPEG))
cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
if not cap.isOpened():
return {"ok": False, "error": "could not open stream - check the "
"host, port, path and credentials"}
deadline = time.time() + timeout
frame = None
while time.time() < deadline:
ok, candidate = cap.read()
if ok and candidate is not None and candidate.size:
frame = candidate
break
if frame is None:
return {"ok": False, "error": "connected but no frame arrived "
f"within {timeout:.0f}s"}
height, width = frame.shape[:2]
preview = frame
if max_width and width > max_width:
scale = max_width / width
preview = cv2.resize(frame, (max_width, int(height * scale)),
interpolation=cv2.INTER_AREA)
ok, buf = cv2.imencode(".jpg", preview,
[int(cv2.IMWRITE_JPEG_QUALITY), 70])
return {
"ok": True, "width": int(width), "height": int(height),
"downscaled_to": int(preview.shape[1]) if preview is not frame else None,
"snapshot": (base64.b64encode(buf.tobytes()).decode("ascii")
if ok else None),
}
except (cv2.error, MemoryError, OSError) as exc:
return {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
finally:
if cap is not None:
cap.release()
class VideoSource(threading.Thread):
def __init__(self, camera_id: str, source: "str | int", display_url: str = "",
max_width: int = 1280):
super().__init__(daemon=True, name=f"capture-{camera_id}")
self.camera_id = camera_id
self._source = source
self._display_url = display_url or str(source)
# Downscale at ingest: 3MP+ streams waste memory and detector time,
# and on tight machines a full-res frame copy alone can OOM.
self.max_width = max_width
self._lock = threading.Lock()
self._frame: Optional[np.ndarray] = None
self._frame_ts: float = 0.0
# _stopping, NOT _stop. threading.Thread has its own private _stop(),
# and join() calls it: shadowing the name with an Event made every
# join() on a started worker raise "'Event' object is not callable".
# It only surfaces when a camera is removed or edited at runtime, so
# the engine answered 500 to every camera edit from head office while
# every test using a stubbed worker passed.
self._stopping = threading.Event()
self.connected = False
self.frames_total = 0
self.reconnects = 0
self._ever_connected = False
# -- public ---------------------------------------------------------
def latest(self) -> "tuple[Optional[np.ndarray], float]":
with self._lock:
if self._frame is None:
return None, 0.0
try:
return self._frame.copy(), self._frame_ts
except MemoryError:
return None, 0.0
def latest_since(self, known_ts: float) -> "tuple[Optional[np.ndarray], float]":
"""Latest frame, but only if it is newer than `known_ts`.
The staleness check happens under the lock so no frame is copied just
to be discarded — the worker polls far faster than the stream
delivers, and a discarded full-frame copy per poll is exactly the
allocation pattern that used to exhaust memory on small machines.
"""
with self._lock:
if self._frame is None or self._frame_ts == known_ts:
return None, self._frame_ts
try:
return self._frame.copy(), self._frame_ts
except MemoryError:
return None, 0.0
def stop(self) -> None:
self._stopping.set()
def stats(self) -> dict:
return {
"camera_id": self.camera_id,
"url": self._display_url,
"connected": self.connected,
"frames_total": self.frames_total,
"reconnects": self.reconnects,
"last_frame_age_s": round(time.time() - self._frame_ts, 1)
if self._frame_ts else None,
}
# -- thread ---------------------------------------------------------
def run(self) -> None:
backoff = 1.0
while not self._stopping.is_set():
cap = self._open()
if cap is None:
self.connected = False
log.warning("[%s] connect failed, retrying in %.0fs (%s)",
self.camera_id, backoff, self._display_url)
if self._stopping.wait(backoff):
break
backoff = min(backoff * 2, 30.0)
continue
self.connected = True
if self._ever_connected: # the first connect is not a reconnect
self.reconnects += 1
self._ever_connected = True
backoff = 1.0
log.info("[%s] connected (%s)", self.camera_id, self._display_url)
while not self._stopping.is_set():
try:
ok, frame = cap.read()
except (cv2.error, SystemError, MemoryError):
log.warning("[%s] read failed (low memory?), reconnecting",
self.camera_id)
break
if not ok or frame is None:
log.warning("[%s] stream dropped, reconnecting", self.camera_id)
break
try:
if self.max_width and frame.shape[1] > self.max_width:
scale = self.max_width / frame.shape[1]
frame = cv2.resize(
frame,
(self.max_width, int(frame.shape[0] * scale)),
interpolation=cv2.INTER_AREA)
except (cv2.error, MemoryError):
time.sleep(0.1) # transient allocation failure: drop frame
continue
with self._lock:
self._frame = frame
self._frame_ts = time.time()
self.frames_total += 1
cap.release()
self.connected = False
log.info("[%s] capture stopped", self.camera_id)
def _open(self) -> Optional[cv2.VideoCapture]:
try:
if isinstance(self._source, int):
cap = cv2.VideoCapture(self._source)
else:
cap = cv2.VideoCapture(self._source, cv2.CAP_FFMPEG)
cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
if not cap.isOpened():
cap.release()
return None
return cap
except cv2.error:
log.exception("[%s] VideoCapture error", self.camera_id)
return None

242
behavision/commission.py Normal file
View File

@@ -0,0 +1,242 @@
"""Camera commissioning: is this camera placed well enough to recognise faces?
The Office1 camera was installed, ran for weeks, and recognised almost nobody.
Nothing was broken — the overhead angle tilted every face down and the frosted
glass backlit them, so ArcFace never received a view it could embed stably. It
took reading vectors out of SQLite by hand to find that out.
This turns that diagnosis into an install step. The person installing walks
past a few times and gets one of two answers: "this camera is good" or "move it
to head height facing the approach direction". A site cannot be signed off
broken and then discovered three weeks later from a footfall report that was
always zero.
It measures the *live pipeline*, not a separate probe: every finished track
reports the best face quality it managed. That is the right question — not
"were the frames sharp" but "did a person walking past produce at least one
view worth enrolling" — and it is the same number `fraction_below_gate` on the
dashboard is built from, so the wizard and the running system cannot disagree.
"""
from __future__ import annotations
import threading
import time
from typing import Optional
# Verdict boundaries, from measured data on real cameras (see CLAUDE.md):
# frontal faces at head height score 0.70-0.82, the overhead corridor scores
# 0.32-0.45 against a 0.65 gate. The fractions below are of faces that fall
# under whatever gate that camera is configured with.
GOOD_BELOW_GATE = 0.20
POOR_BELOW_GATE = 0.50
# A real walk-past varies; a static artifact does not. Frosted-glass tracks
# measured a flat 0.37 on every frame, and a constant score across many
# detections is the signature of a thing, not a person.
FLAT_SPREAD = 0.03
FLAT_MIN_SAMPLES = 6
# Below this many faces the numbers are anecdote, not measurement.
MIN_SAMPLES = 5
DEFAULT_SECONDS = 25.0
def quantile(ordered: "list[float]", frac: float) -> float:
if not ordered:
return 0.0
idx = min(len(ordered) - 1, int(round(frac * (len(ordered) - 1))))
return ordered[idx]
class CommissionRun:
"""One timed placement check on one camera.
Written by the worker thread as tracks end, read by API threads polling
for the result, hence the lock.
"""
def __init__(self, camera_id: str, gate: float,
seconds: float = DEFAULT_SECONDS,
now: "float | None" = None):
self.camera_id = camera_id
self.gate = gate
self.seconds = max(5.0, float(seconds))
self.started_at = now if now is not None else time.time()
self._lock = threading.Lock()
self._qualities: "list[float]" = []
# Frames on which at least one face was being tracked. A face in view
# and a face that completed a pass are different observations, and
# only the second one produces a quality sample.
self._live_frames = 0
self._cancelled = False
# -- written by the worker thread -----------------------------------
def record(self, best_quality: float, now: "float | None" = None) -> None:
"""One finished track's best view. Tracks that never held a face at
all are not evidence about placement — they are evidence about
detection — so they are dropped."""
if best_quality <= 0:
return
if not self.running(now):
return
with self._lock:
self._qualities.append(float(best_quality))
def observe(self, live_faces: int, now: "float | None" = None) -> None:
"""One frame's worth of live tracking, whether or not anything ended.
Without this the check cannot tell "the camera sees nobody" from
"somebody is standing in front of it right now", because both produce
zero finished tracks — and those two states need opposite advice.
"""
if live_faces <= 0 or not self.running(now):
return
with self._lock:
self._live_frames += 1
# -- read by API threads --------------------------------------------
def running(self, now: "float | None" = None) -> bool:
if self._cancelled:
return False
now = now if now is not None else time.time()
return now - self.started_at < self.seconds
def cancel(self) -> None:
self._cancelled = True
def report(self, now: "float | None" = None) -> dict:
now = now if now is not None else time.time()
with self._lock:
ordered = sorted(self._qualities)
live = self._live_frames
running = self.running(now)
out = {
"camera_id": self.camera_id,
"gate": round(self.gate, 3),
"seconds": self.seconds,
"elapsed": round(min(now - self.started_at, self.seconds), 1),
"running": running,
"cancelled": self._cancelled,
"faces": len(ordered),
"frames_with_a_face": live,
"quality": _spread(ordered, self.gate),
}
out.update(self._verdict(ordered, running, live))
return out
# -- internals ------------------------------------------------------
def _verdict(self, ordered: "list[float]", running: bool,
live: int = 0) -> dict:
n = len(ordered)
if running:
done = f"{n} pass{'' if n == 1 else 'es'} completed"
# Saying "0 faces" while a face is plainly on screen reads as a
# broken check, so report what is actually happening.
seen = " · face in view" if live else ""
return {"verdict": "running",
"headline": f"watching… {done}{seen}",
"advice": ["Walk past the camera the way a customer "
"would, and out of the frame."]}
if n == 0 and live:
# A face was tracked the whole time and never left. The camera is
# aimed correctly and the old advice ("check it is pointing at the
# walkway") would send an installer to move a camera looking
# straight at them — which is how a good camera gets made bad.
return {"verdict": "no_completed_passes",
"headline": "a face was in view, but nobody walked past",
"advice": [
"The camera is detecting a face, so it is pointed "
"correctly — but no one completed a pass.",
"This check scores the best view of each person as "
"they leave the frame, which is what recognition "
"actually uses, so standing still measures nothing.",
"Walk through the frame and out of it, a few times, "
"then run the check again."]}
if n == 0:
# Streaming but nothing detected. Distinguishing this from "placed
# badly" matters: the fix is completely different.
return {"verdict": "no_faces",
"headline": "no faces detected",
"advice": [
"The camera is streaming but saw no face at all.",
"Check it is pointing at the walkway, not the ceiling "
"or floor, and that someone walked through the frame.",
"If people did walk past, the view is too far, too "
"dark, or too steep for the detector."]}
below = sum(1 for q in ordered if q < self.gate) / n
p50 = quantile(ordered, 0.50)
spread = quantile(ordered, 0.95) - quantile(ordered, 0.05)
if n >= FLAT_MIN_SAMPLES and spread < FLAT_SPREAD:
# Every detection scoring the same is not a camera problem to
# solve by moving it - it is not seeing people at all.
return {"verdict": "artifact",
"headline": f"every detection scored {p50:.2f} — this is "
"probably not a face",
"advice": [
"A constant score across every detection is the "
"signature of a static object, not a person.",
"Glass, a poster, a reflection or a mannequin in view "
"will do this.",
"Point the camera away from it, or raise "
"detection.score_threshold for this camera."]}
if n < MIN_SAMPLES:
return {"verdict": "inconclusive",
"headline": f"only {n} face{'' if n == 1 else 's'} seen — "
"not enough to judge",
"advice": [
f"Median quality was {p50:.2f}, but {n} "
f"sample{'' if n == 1 else 's'} is anecdote, not "
"measurement.",
"Run the check again and walk past several times, "
"ideally with more than one person."]}
if below <= GOOD_BELOW_GATE:
return {"verdict": "good",
"headline": f"good placement — median quality {p50:.2f}",
"advice": [
f"{below:.0%} of faces fell below the {self.gate:.2f} "
"enrollment gate. This camera can enrol and recognise "
"people reliably."]}
if below <= POOR_BELOW_GATE:
return {"verdict": "marginal",
"headline": f"usable but weak — {below:.0%} of faces are "
"below the gate",
"advice": [
f"Median quality {p50:.2f} against a "
f"{self.gate:.2f} gate: roughly {below:.0%} of "
"visitors will be seen and then discarded.",
"Angling it to face the approach direction, or "
"lowering it toward head height, usually fixes this.",
"If the position cannot change, lower this camera's "
"min_enroll_quality — but only this camera's."]}
return {"verdict": "poor",
"headline": f"poor placement — {below:.0%} of faces are below "
"the gate",
"advice": [
f"Median quality {p50:.2f} against a {self.gate:.2f} "
"gate. Most people who walk past will not be enrolled or "
"recognised, and nothing will look broken.",
"Move the camera to roughly head height, facing the "
"direction people approach from.",
"An overhead camera tilts every face downward, which is "
"the single most common cause of this result.",
"Backlighting — a window or lit glass behind the "
"subject — is the second most common.",
"Re-run this check after moving it. Do not lower the "
"quality gate to make this message go away: it converts a "
"visible miss into an invisible wrong match."]}
def _spread(ordered: "list[float]", gate: float) -> dict:
out = {"n": len(ordered)}
if not ordered:
return out
out["p05"] = round(quantile(ordered, 0.05), 3)
out["p50"] = round(quantile(ordered, 0.50), 3)
out["p95"] = round(quantile(ordered, 0.95), 3)
out["fraction_below_gate"] = round(
sum(1 for v in ordered if v < gate) / len(ordered), 3)
return out

316
behavision/config.py Normal file
View File

@@ -0,0 +1,316 @@
"""Typed configuration loaded from YAML with ${ENV} expansion.
Secrets never live in YAML: the YAML references environment variables
(populated from `.env`), so the config file is safe to commit.
"""
from __future__ import annotations
import os
import re
from pathlib import Path
from typing import Optional
from urllib.parse import quote
import yaml
from pydantic import BaseModel, Field, model_validator
_ENV_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
def _expand_env(text: str) -> str:
return _ENV_RE.sub(lambda m: os.environ.get(m.group(1), ""), text)
class CameraTuning(BaseModel):
"""Per-camera overrides for the recognition gates. None = use the global.
These are per-camera because they describe a *view*, not a preference: a
gate measured on an entrance camera at head height does not describe an
overhead corridor camera, and a real deployment has both at one site.
Measured on Office1: genuine faces score 0.32-0.45 there against a global
gate of 0.65, so every visitor was discarded — while the same gate is
correct for a frontal camera where real faces score 0.70-0.82.
Note the asymmetry before overriding the similarity thresholds. Quality is
purely local — it only asks whether THIS view is good enough to store.
match/enroll are not: every camera writes into one shared gallery, so a
camera set loose can merge two people into an identity that a stricter
camera then trusts. Loosen quality per camera freely; loosen match only
with measured cross-person data from that camera.
"""
min_enroll_quality: Optional[float] = None
match_threshold: Optional[float] = None
enroll_threshold: Optional[float] = None
class CameraConfig(BaseModel):
id: str
url: str = ""
host: str = ""
port: int = 554
path: str = "/"
username: str = ""
password: str = ""
webcam: Optional[int] = None
max_width: int = 1280 # frames wider than this are downscaled at ingest
tuning: CameraTuning = CameraTuning()
def source(self) -> "str | int":
"""Resolved capture source: webcam index, explicit URL, or a URL
built from parts with percent-encoded credentials."""
if self.webcam is not None:
return self.webcam
if self.url:
return self.url
if not self.host:
raise ValueError(f"camera '{self.id}': set url, host or webcam")
auth = ""
if self.username:
auth = quote(self.username, safe="")
if self.password:
auth += ":" + quote(self.password, safe="")
auth += "@"
path = self.path if self.path.startswith("/") else "/" + self.path
return f"rtsp://{auth}{self.host}:{self.port}{path}"
def safe_url(self) -> str:
"""Loggable form with the password masked."""
src = self.source()
if isinstance(src, int):
return f"webcam:{src}"
return re.sub(r"(rtsp://[^:/@]+:)[^@]*@", r"\1*****@", src)
class AppSection(BaseModel):
data_dir: Path = Path("data")
models_dir: Path = Path("models")
log_level: str = "INFO"
# Save every aligned chip the encoder sees to data/debug/ — diagnostic
# only, off in normal operation (it writes face images to disk).
debug_faces: bool = False
# Write one face image per resolved visit to data/outbox/ for the agent to
# upload. OFF by default, and that default is the product's privacy
# position rather than an oversight: with it off this machine holds
# templates and timestamps and nothing resembling a photograph. Turning it
# on changes what the system is under GDPR and India's DPDP, so it has to
# be a decision somebody makes rather than one they inherit.
store_faces: bool = False
class ApiSection(BaseModel):
host: str = "0.0.0.0"
port: int = 8010
# HTTP Basic credentials. Blank + loopback host = open (unreachable from
# off-box anyway); blank + routable host = generated, see
# ensure_api_credentials(). Never hardcode these — they come from .env.
username: str = ""
password: str = ""
@model_validator(mode="before")
@classmethod
def _normalize_blanks(cls, values):
# Unset ${ENV} placeholders parse as YAML null — treat as "".
if isinstance(values, dict):
values = {k: ("" if v is None else v) for k, v in values.items()}
if values.get("port") == "":
values["port"] = 8010
return values
@property
def auth_enabled(self) -> bool:
return bool(self.username and self.password)
@property
def is_loopback(self) -> bool:
return self.host in ("127.0.0.1", "::1", "localhost", "")
class DetectionSection(BaseModel):
# Measured on the deployment site: frosted-glass false positives pass
# 0.75, real faces score higher. Keep in step with config/default.yaml.
score_threshold: float = 0.82
nms_threshold: float = 0.3
min_face_px: int = 48
max_faces: int = 20
class RecognitionSection(BaseModel):
model_file: str = "" # pin a specific model filename; empty = auto
# Override the channel order the encoder feeds the model. Empty =
# inferred from the model family (ArcFace RGB, AdaFace BGR).
color_order: str = ""
match_threshold: float = 0.42
enroll_threshold: float = 0.32
reinforce_threshold: float = 0.55
max_embeddings_per_identity: int = 5
auto_enroll: bool = True
min_enroll_quality: float = 0.65 # real frontal faces 0.70-0.82, glass blurs <=0.54
sighting_cooldown_seconds: float = 30.0
@model_validator(mode="after")
def _sane(self) -> "RecognitionSection":
if not (0 < self.enroll_threshold < self.match_threshold < 1):
raise ValueError("need 0 < enroll_threshold < match_threshold < 1")
return self
def merged(self, tuning: "CameraTuning | None") -> "RecognitionSection":
"""This section with one camera's overrides applied.
Returns a validated copy, so a per-camera pair that inverts
enroll/match is rejected here rather than silently driving decisions
that contradict each other.
"""
if tuning is None:
return self
overrides = {k: v for k, v in tuning.model_dump().items()
if v is not None}
if not overrides:
return self
return RecognitionSection.model_validate(
{**self.model_dump(), **overrides})
class TrackingSection(BaseModel):
iou_threshold: float = 0.3
max_misses: int = 25
min_hits_for_id: int = 4
min_embeddings_for_id: int = 3
min_quality_to_encode: float = 0.35
max_id_attempts: int = 8
# Ambiguous tracks keep accumulating embeddings every frame but
# only re-decide this often, so max_id_attempts spans seconds of
# genuinely different frames rather than one burst.
id_retry_interval_seconds: float = 0.5
# A resolved track keeps contributing views for the rest of the
# visit, so an identity does not stay stuck on the single embedding
# it was born with. Sampled this often; each view is still subject
# to the reinforce/quality/cap gates in Gallery.
reinforce_during_track: bool = True
reinforce_interval_seconds: float = 1.0
class AttributesSection(BaseModel):
enabled: bool = True
# Gate for collecting a per-frame age/gender/emotion sample. Deliberately
# NOT recognition.min_enroll_quality, which it used to borrow: that gate
# is 0.65 and guards minting a permanent identity, while genuine faces on
# an overhead camera measure 0.32-0.45. Sharing it meant no track ever
# collected the multiple samples the median is computed from, so the
# aggregate silently collapsed to a single frame — the exact instability
# the median was added to remove.
min_quality: float = 0.35
class EmailSection(BaseModel):
smtp_host: str = ""
smtp_port: int = 587
username: str = ""
password: str = ""
to: str = ""
min_interval_seconds: float = 300.0
@model_validator(mode="before")
@classmethod
def _normalize_blanks(cls, values):
# Unset ${ENV} placeholders parse as YAML null — treat as "".
if isinstance(values, dict):
values = {k: ("" if v is None else v) for k, v in values.items()}
if values.get("smtp_port") == "":
values["smtp_port"] = 587
return values
@property
def enabled(self) -> bool:
return bool(self.smtp_host and self.username and self.to)
class EventsSection(BaseModel):
webhook_url: str = ""
email: EmailSection = Field(default_factory=EmailSection)
@model_validator(mode="before")
@classmethod
def _normalize_blanks(cls, values):
if isinstance(values, dict) and values.get("webhook_url") is None:
values["webhook_url"] = ""
return values
class Config(BaseModel):
app: AppSection = Field(default_factory=AppSection)
api: ApiSection = Field(default_factory=ApiSection)
cameras: list[CameraConfig] = Field(default_factory=list)
detection: DetectionSection = Field(default_factory=DetectionSection)
recognition: RecognitionSection = Field(default_factory=RecognitionSection)
tracking: TrackingSection = Field(default_factory=TrackingSection)
attributes: AttributesSection = Field(default_factory=AttributesSection)
events: EventsSection = Field(default_factory=EventsSection)
def ensure_api_credentials(cfg: "Config") -> "tuple[bool, bool]":
"""Make sure a routable API is never served unauthenticated.
A live face feed plus a biometric gallery must not be readable by anyone
who can reach the port. But failing to boot mid-deployment is its own
outage, so instead of refusing to start we mint a credential, persist it
0600 under data/, and log it. Returns (auth_enabled, was_generated).
"""
import os
import secrets
if cfg.api.auth_enabled:
return True, False
if cfg.api.is_loopback:
return False, False # not reachable off-box; leave it open
cred_file = cfg.app.data_dir / "api_credentials.txt"
if cred_file.exists():
parsed = dict(
line.split("=", 1) for line in
cred_file.read_text(encoding="utf-8").splitlines() if "=" in line)
cfg.api.username = parsed.get("username", "").strip()
cfg.api.password = parsed.get("password", "").strip()
if cfg.api.auth_enabled:
return True, False
cfg.api.username = "behavision"
cfg.api.password = secrets.token_urlsafe(16)
cred_file.write_text(
f"username={cfg.api.username}\npassword={cfg.api.password}\n",
encoding="utf-8")
try:
os.chmod(cred_file, 0o600)
except OSError: # best effort (Windows)
pass
return True, True
def load_config(path: "Path | str | None" = None) -> Config:
"""Load .env, then YAML with ${ENV} expansion, into a validated Config.
Relative `data_dir` / `models_dir` resolve against the **state root**, not
the code: installed, the code lives under `Program Files` where nothing may
write, and the database, logs and downloaded models still have to go
somewhere that survives an upgrade. In a checkout the two are the same
directory, so development is unaffected.
"""
from dotenv import load_dotenv
from .paths import ensure_config, env_file, state_root
env = env_file()
if env is not None:
load_dotenv(env)
cfg_path = Path(path) if path else ensure_config()
raw = yaml.safe_load(_expand_env(cfg_path.read_text(encoding="utf-8"))) or {}
cfg = Config.model_validate(raw)
root = state_root()
for key in ("data_dir", "models_dir"):
p = getattr(cfg.app, key)
if not p.is_absolute():
setattr(cfg.app, key, root / p)
cfg.app.data_dir.mkdir(parents=True, exist_ok=True)
cfg.app.models_dir.mkdir(parents=True, exist_ok=True)
return cfg

65
behavision/detection.py Normal file
View File

@@ -0,0 +1,65 @@
"""Face detection with YuNet (OpenCV FaceDetectorYN).
Why YuNet: modern CNN detector with 5-point landmarks built into OpenCV —
no compilation, no extra runtime, works on Windows out of the box, and its
landmarks feed ArcFace alignment directly. Accuracy on frontal surveillance
footage is on par with SCRFD-500M at a fraction of the operational cost.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from pathlib import Path
import cv2
import numpy as np
from .geometry import clip_box
log = logging.getLogger(__name__)
YUNET_FILENAME = "face_detection_yunet_2023mar.onnx"
@dataclass
class Detection:
box: tuple # x1, y1, x2, y2 (int, clipped to frame)
kps: np.ndarray # (5, 2) float32, full-frame coordinates
score: float
quality: float = 0.0
attributes: dict = field(default_factory=dict)
class FaceDetector:
def __init__(self, models_dir: Path, score_threshold: float = 0.75,
nms_threshold: float = 0.3, max_faces: int = 20,
min_face_px: int = 48):
model_path = Path(models_dir) / YUNET_FILENAME
if not model_path.exists():
raise FileNotFoundError(
f"{model_path} missing - run: python -m behavision setup-models")
self._det = cv2.FaceDetectorYN_create(
str(model_path), "", (320, 320), score_threshold, nms_threshold,
max_faces)
self._input_size: "tuple[int, int] | None" = None
self.min_face_px = min_face_px
def detect(self, frame: np.ndarray) -> "list[Detection]":
h, w = frame.shape[:2]
if self._input_size != (w, h):
self._det.setInputSize((w, h))
self._input_size = (w, h)
_, faces = self._det.detect(frame)
if faces is None:
return []
out: list[Detection] = []
for f in faces:
x, y, bw, bh = f[:4]
if min(bw, bh) < self.min_face_px:
continue
box = clip_box((x, y, x + bw, y + bh), w, h)
if box is None:
continue
kps = f[4:14].reshape(5, 2).astype(np.float32)
out.append(Detection(box=box, kps=kps, score=float(f[14])))
return out

601
behavision/engine.py Normal file
View File

@@ -0,0 +1,601 @@
"""Pipeline engine: one worker thread per camera, shared models and gallery.
Per frame: detect -> score quality -> update tracker. Identity is resolved
per TRACK (once a track has enough hits and a good-enough frame), never per
frame. Ambiguous matches retry on later, better frames up to a bounded
number of attempts.
"""
from __future__ import annotations
import collections
import logging
import threading
import time
from typing import Optional
import cv2
import numpy as np
from .attributes import AttributeEstimator, aggregate as aggregate_attrs
from .cameras import CameraStore
from .capture import VideoSource
from .faces import FaceOutbox
from .commission import CommissionRun
from .config import CameraConfig, Config
from .detection import FaceDetector
from .events import EmailSink, Event, EventBus, LogSink, WebhookSink
from .gallery import Gallery, IdentityStore, VectorIndex
from .geometry import align_face
from .recognition import EMBEDDING_DIM, ArcFaceEncoder, face_quality
from .tracking import IouTracker, Track
log = logging.getLogger(__name__)
_COLORS = {"known": (80, 200, 80), "new": (60, 160, 255),
"pending": (160, 160, 160), "ambiguous": (60, 120, 200)}
# Terminal outcomes that mean "a person was on camera and we failed to place
# them", as opposed to "a person walked through too fast to try".
_LOST_OUTCOMES = ("rejected_quality", "gave_up_ambiguous", "ended_ambiguous")
def _track_outcome(track: Track) -> str:
"""Classify a finished track. Exactly one label per track, decided once.
Order matters: a track that exhausted its attempts because every one was
refused for quality is a *quality* failure, and reporting it as "ambiguous"
would send anyone tuning the site to the match threshold instead of to the
camera mount.
"""
if track.state == "resolved":
return "enrolled" if track.is_new else "recognized"
if track.emb_count == 0:
return "no_embedding" # never held a frame worth encoding
if track.id_attempts == 0:
return "too_brief" # left before enough evidence accumulated
if track.quality_skips:
return "rejected_quality" # face seen, too poor to mint an identity
if track.state == "gave_up":
return "gave_up_ambiguous"
return "ended_ambiguous"
def _quantile(ordered: "list[float]", frac: float) -> float:
if not ordered:
return 0.0
idx = min(len(ordered) - 1, int(round(frac * (len(ordered) - 1))))
return ordered[idx]
def _spread(values: "list[float]", gate: "float | None" = None) -> dict:
ordered = sorted(values)
out = {"n": len(ordered)}
if not ordered:
return out
out["p05"] = round(_quantile(ordered, 0.05), 3)
out["p50"] = round(_quantile(ordered, 0.50), 3)
out["p95"] = round(_quantile(ordered, 0.95), 3)
if gate is not None:
out["fraction_below_gate"] = round(
sum(1 for v in ordered if v < gate) / len(ordered), 3)
return out
class PipelineStats:
"""Per-camera tally of what became of each track.
The pipeline used to be unfalsifiable from outside: the only numbers were
frames and faces, so "nobody visited" and "every visitor was refused by the
quality gate" produced identical output, and every diagnosis meant reading
SQLite by hand. Deciding whether a site's camera placement works needs the
rejection reasons and the quality spread, not the frame count.
Written by the worker thread, read by API threads, hence the lock. The
distribution windows are bounded so a camera running for weeks cannot grow
this without limit.
"""
WINDOW = 500
def __init__(self) -> None:
self._lock = threading.Lock()
self._outcomes: "collections.Counter[str]" = collections.Counter()
self._qualities: "collections.deque[float]" = collections.deque(
maxlen=self.WINDOW)
self._similarities: "collections.deque[float]" = collections.deque(
maxlen=self.WINDOW)
self.tracks_ended = 0
def record(self, track: Track, outcome: str) -> None:
with self._lock:
self.tracks_ended += 1
self._outcomes[outcome] += 1
if track.best_quality > 0:
self._qualities.append(track.best_quality)
# Only tracks that actually reached resolve() have a similarity;
# zero from the others would drag every percentile down.
if track.id_attempts:
self._similarities.append(track.similarity)
def snapshot(self, enroll_gate: "float | None" = None) -> dict:
with self._lock:
outcomes = dict(self._outcomes)
qualities = list(self._qualities)
similarities = list(self._similarities)
ended = self.tracks_ended
return {
"tracks_ended": ended,
"outcomes": outcomes,
# fraction_below_gate is the number that says whether the
# enrollment gate is set wrong for this camera.
"best_quality": _spread(qualities, enroll_gate),
"similarity": _spread(similarities),
}
class CameraWorker(threading.Thread):
def __init__(self, cam_cfg: CameraConfig, cfg: Config, detector: FaceDetector,
encoder: ArcFaceEncoder, gallery: Gallery, bus: EventBus,
attrs: Optional[AttributeEstimator]):
super().__init__(daemon=True, name=f"worker-{cam_cfg.id}")
self.cam_cfg = cam_cfg
self.cfg = cfg
self.detector = detector
self.encoder = encoder
self.gallery = gallery
self.bus = bus
self.attrs = attrs
# Gates describe a view, so they are resolved per camera: an overhead
# corridor and an entrance camera at head height cannot share a
# quality gate, and a site has both.
self.rcfg = cfg.recognition.merged(cam_cfg.tuning)
self.source = VideoSource(cam_cfg.id, cam_cfg.source(),
cam_cfg.safe_url(), cam_cfg.max_width)
self.tracker = IouTracker(cfg.tracking.iou_threshold,
cfg.tracking.max_misses)
# _stopping, NOT _stop. threading.Thread has its own private _stop(),
# and join() calls it: shadowing the name with an Event made every
# join() on a started worker raise "'Event' object is not callable".
# It only surfaces when a camera is removed or edited at runtime, so
# the engine answered 500 to every camera edit from head office while
# every test using a stubbed worker passed.
self._stopping = threading.Event()
self._lock = threading.Lock()
self._annotated_jpeg: Optional[bytes] = None
self._last_frame_ts = 0.0
self._was_connected = False
self.frames_processed = 0
self.faces_seen = 0
self.pipeline = PipelineStats()
# One outbox per worker, all writing into the same directory. Files are
# uuid-named so two cameras resolving a visit in the same millisecond
# cannot collide.
self.faces = FaceOutbox(cfg.app.data_dir, cfg.app.store_faces)
# Set while a placement check is running. The check reads the live
# pipeline rather than a probe of its own, so what it measures is
# exactly what production will see.
self.commission: Optional[CommissionRun] = None
# -- public ---------------------------------------------------------
def start(self) -> None:
self.source.start()
super().start()
def stop(self) -> None:
self._stopping.set()
self.source.stop()
def latest_jpeg(self) -> Optional[bytes]:
with self._lock:
return self._annotated_jpeg
def stats(self) -> dict:
return {
**self.source.stats(),
"frames_processed": self.frames_processed,
"faces_seen": self.faces_seen,
"active_tracks": len(self.tracker.tracks),
"pipeline": self.pipeline.snapshot(self.rcfg.min_enroll_quality),
"gates": {"min_enroll_quality": self.rcfg.min_enroll_quality,
"match_threshold": self.rcfg.match_threshold,
"enroll_threshold": self.rcfg.enroll_threshold},
}
# -- thread ---------------------------------------------------------
def run(self) -> None:
tcfg = self.cfg.tracking
while not self._stopping.is_set():
try:
self._emit_connection_events()
frame, ts = self.source.latest_since(self._last_frame_ts)
if frame is None:
time.sleep(0.02)
continue
self._last_frame_ts = ts
detections = self.detector.detect(frame)
for det in detections:
det.quality = face_quality(frame, det.box, det.kps)
active, ended = self.tracker.update(detections, ts)
self.faces_seen += sum(1 for t in active if t.hits == 1)
# A placement check needs to know a face is in view even while
# its track is still open: someone standing in front of their
# own camera to test it produces no finished tracks at all.
run = self.commission
if run is not None:
run.observe(len(active), ts)
for track in active:
if self._should_identify(track, tcfg, ts):
self._identify(track, frame, ts)
# Every track ends exactly once, so this is the one place a
# per-visit outcome can be tallied without double counting.
for track in ended:
self._finish_track(track, ts)
self._publish_annotated(frame, active)
self.frames_processed += 1
except Exception:
log.exception("[%s] frame processing failed", self.cam_cfg.id)
time.sleep(0.5)
log.info("[%s] worker stopped", self.cam_cfg.id)
# -- internals ------------------------------------------------------
def _emit_connection_events(self) -> None:
connected = self.source.connected
if connected != self._was_connected:
self._was_connected = connected
self.bus.publish(Event(
type="camera.up" if connected else "camera.down",
camera_id=self.cam_cfg.id))
def _finish_track(self, track: Track, ts: float) -> None:
"""Record what became of a track, once, as it ends.
Tracks that never reached an identity previously vanished without a
trace. For a footfall product that is a headcount which is wrong in a
way nobody can detect, and it is why a mis-set quality gate was
indistinguishable from an empty corridor.
"""
outcome = _track_outcome(track)
self.pipeline.record(track, outcome)
run = self.commission
if run is not None:
run.record(track.best_quality, ts)
if outcome not in _LOST_OUTCOMES:
return
# Only worth an event once the track held enough evidence to have been
# a real decision; a face glimpsed for two frames is noise, not a loss.
if track.emb_count < self.cfg.tracking.min_embeddings_for_id:
return
self.bus.publish(Event(
type="person.missed", camera_id=self.cam_cfg.id, ts=ts,
data={"reason": outcome,
"quality": round(track.best_quality, 3),
"similarity": round(track.similarity, 3),
"attempts": track.id_attempts,
"embeddings": track.emb_count}))
def _should_identify(self, track: Track, tcfg, ts: float) -> bool:
if track.state == "resolved":
# Known person, still on screen: keep sampling their other angles
# so the identity does not stay frozen on the one embedding it was
# created with. Gated hard on quality — a blurred frame teaches
# the gallery nothing useful.
if not tcfg.reinforce_during_track:
return False
if track.identity_id is None:
return False
if track.quality < tcfg.min_quality_to_encode:
return False
return ts - track.last_reinforce_ts >= tcfg.reinforce_interval_seconds
if track.state not in ("pending", "ambiguous"):
return False
if track.id_attempts >= tcfg.max_id_attempts:
track.state = "gave_up"
return False
# Wait for a frame worth encoding, but don't wait forever: after
# twice the warmup period, take whatever the track has.
if (track.quality < tcfg.min_quality_to_encode
and track.hits < tcfg.min_hits_for_id * 2):
return False
return True
def _identify(self, track: Track, frame: np.ndarray, ts: float) -> None:
"""Accumulate an embedding for this frame; decide identity only from
the mean of several frames. Single-frame ArcFace embeddings under
steep camera angles / motion blur differ so much that one walk-by
can look like several people — the average is stable."""
if track.state == "resolved":
self._reinforce(track, frame, ts)
return
chip = align_face(frame, track.kps, size=self.encoder.size)
# Keep the best-looking view for the customer record. Quality is
# already computed for the enrolment gate, so choosing on it costs
# nothing and picks the frame a person would have picked.
if self.faces.enabled and track.quality > track.best_face_quality:
crop = self.faces.crop(frame, track.box)
if crop is not None:
track.best_face, track.best_face_quality = crop, track.quality
if self.cfg.app.debug_faces:
debug_dir = self.cfg.app.data_dir / "debug"
debug_dir.mkdir(parents=True, exist_ok=True)
cv2.imwrite(str(debug_dir / (
f"{ts:.1f}_track{track.id}_q{track.quality:.2f}.jpg")), chip)
embedding = self.encoder.encode_chip(chip)
if embedding is None:
return
if track.emb_sum is None:
track.emb_sum = embedding.copy()
else:
track.emb_sum += embedding
track.emb_count += 1
# One more attribute sample per accumulated frame, capped. A single
# frame's age estimate swings by a decade; a few frames median out.
tcfg = self.cfg.tracking
if (self.attrs is not None
and len(track.attr_samples) < tcfg.min_embeddings_for_id
and track.quality >= self.cfg.attributes.min_quality):
track.attr_samples.append(
self.attrs.estimate(frame, track.box, chip))
if (track.emb_count < tcfg.min_embeddings_for_id
or track.hits < tcfg.min_hits_for_id):
return # keep collecting evidence
# An already-ambiguous track keeps accumulating above (that is what
# improves the mean) but only re-decides after a real interval —
# otherwise max_id_attempts is spent on consecutive frames of the
# same instant instead of on the "later, better frame" it promises.
if (track.state == "ambiguous"
and ts - track.last_attempt_ts < tcfg.id_retry_interval_seconds):
return
mean = track.emb_sum / track.emb_count
norm = float(np.linalg.norm(mean))
if norm < 1e-6:
return
mean = (mean / norm).astype(np.float32)
# Aggregated before resolve() so the sighting row carries the settled
# verdict, not whichever frame happened to be first.
if self.attrs is not None and not track.attributes:
if not track.attr_samples:
track.attr_samples.append(
self.attrs.estimate(frame, track.box, chip))
track.attributes = aggregate_attrs(track.attr_samples)
track.id_attempts += 1
track.last_attempt_ts = ts
res = self.gallery.resolve(mean, track.best_quality,
self.cam_cfg.id, ts,
attributes=track.attributes or None,
rcfg=self.rcfg)
track.similarity = res.similarity
if res.kind in ("known", "new"):
track.state = "resolved"
track.is_new = res.kind == "new"
track.identity_id = res.identity_id
track.label = res.label
if res.new_sighting:
# Written once, at the moment the visit becomes real. Writing
# per frame would fill the outbox with images of visits that
# never resolved into anything.
image_path = self.faces.save(track.best_face)
track.best_face = None # let the array go; the file has it now
self.bus.publish(Event(
type="person.new" if res.kind == "new" else "person.seen",
camera_id=self.cam_cfg.id, ts=ts,
data={"identity_id": res.identity_id, "label": res.label,
"similarity": round(res.similarity, 3),
# A local file for the agent to upload and delete.
# The engine does not upload: a shop PC must never
# hold object-storage credentials.
**({"image_path": image_path} if image_path else {}),
# The gate ran on best_quality; reporting this
# frame's quality made events look like they had
# passed a threshold they were below.
"quality": round(track.best_quality, 3),
"frame_quality": round(track.quality, 3),
**track.attributes}))
elif res.kind == "ambiguous":
track.state = "ambiguous" # retried on a later, better frame
elif res.kind == "skipped":
# resolve() declined to mint an identity — in practice always
# because best_quality is under min_enroll_quality. This branch
# did not exist: the verdict fell through, the track stayed
# "pending", and the visitor was dropped with no event, no counter
# and no log line. Marking it ambiguous also buys the retry
# throttle, so the remaining attempts are spent on genuinely later
# frames instead of being burnt in one burst on the same instant.
track.quality_skips += 1
track.state = "ambiguous"
def _reinforce(self, track: Track, frame: np.ndarray, ts: float) -> None:
"""Feed one more view of an already-identified person to the gallery."""
track.last_reinforce_ts = ts
chip = align_face(frame, track.kps, size=self.encoder.size)
embedding = self.encoder.encode_chip(chip)
if embedding is None:
return
if self.gallery.reinforce_identity(track.identity_id, embedding,
track.quality, rcfg=self.rcfg):
track.reinforcements += 1
def _publish_annotated(self, frame: np.ndarray, tracks: "list[Track]") -> None:
canvas = frame.copy()
for t in tracks:
if t.misses > 0:
continue # only draw tracks matched in this frame
x1, y1, x2, y2 = t.box
if t.state == "resolved":
color = _COLORS["known"] if t.label and not str(t.label).startswith(
"Visitor") else _COLORS["new"]
text = f"{t.label} ({t.similarity:.2f})"
elif t.state == "ambiguous":
color, text = _COLORS["ambiguous"], "?"
else:
color, text = _COLORS["pending"], ""
cv2.rectangle(canvas, (x1, y1), (x2, y2), color, 2)
if text:
cv2.putText(canvas, text, (x1, max(20, y1 - 8)),
cv2.FONT_HERSHEY_SIMPLEX, 0.55, color, 2)
ok, buf = cv2.imencode(".jpg", canvas,
[int(cv2.IMWRITE_JPEG_QUALITY), 80])
if ok:
with self._lock:
self._annotated_jpeg = buf.tobytes()
class Engine:
"""Owns all shared components and one CameraWorker per camera."""
def __init__(self, cfg: Config):
self.cfg = cfg
self.bus = EventBus()
self.bus.add_sink(LogSink())
if cfg.events.webhook_url:
self.bus.add_sink(WebhookSink(cfg.events.webhook_url))
email = cfg.events.email
if email.enabled:
self.bus.add_sink(EmailSink(
email.smtp_host, email.smtp_port, email.username,
email.password, email.to, email.min_interval_seconds))
# One detector PER CAMERA. cv2.FaceDetectorYN carries mutable state
# (setInputSize + the cached input size) and is not thread-safe, so a
# shared instance races as soon as a second camera worker runs — and
# corrupts inference outright if the two streams differ in resolution.
# The YuNet model is ~230 KB, so per-worker copies are essentially free
# and avoid serialising the hottest per-frame call behind a lock.
self.detectors: "dict[str, FaceDetector]" = {}
# Shared deliberately: onnxruntime InferenceSession.run is thread-safe
# and the encoder weights are worth sharing (13-260 MB).
self.encoder = ArcFaceEncoder(cfg.app.models_dir,
cfg.recognition.model_file,
cfg.recognition.color_order)
self.store = IdentityStore(cfg.app.data_dir / "behavision.db")
self.gallery = Gallery(self.store, VectorIndex(EMBEDDING_DIM),
cfg.recognition, self.encoder.model_name)
self.attributes = None
if cfg.attributes.enabled:
est = AttributeEstimator(cfg.app.models_dir)
self.attributes = est if est.any_loaded else None
# Cameras are added and removed at runtime from the API, so this dict
# is mutated by request threads while the worker loop and stats() read
# it. RLock because add_camera/remove_camera call each other via
# restart_camera.
self._lock = threading.RLock()
self.workers: "dict[str, CameraWorker]" = {}
self.started_at: Optional[float] = None
self._running = False
# YAML seeds the store on first run; after that the store is
# authoritative, or a camera deleted in the UI would come back on the
# next restart.
self.camera_store = CameraStore(cfg.app.data_dir / "cameras.json")
self.camera_store.seed(cfg.cameras)
for cam in self.camera_store.list():
self._build_worker(cam)
# -- camera lifecycle -----------------------------------------------
def _build_worker(self, cam_cfg: CameraConfig) -> "CameraWorker":
"""Construct (but do not start) a worker and its own detector."""
det = self.cfg.detection
detector = FaceDetector(self.cfg.app.models_dir, det.score_threshold,
det.nms_threshold, det.max_faces,
det.min_face_px)
worker = CameraWorker(cam_cfg, self.cfg, detector, self.encoder,
self.gallery, self.bus, self.attributes)
self.detectors[cam_cfg.id] = detector
self.workers[cam_cfg.id] = worker
return worker
def add_camera(self, cam_cfg: CameraConfig) -> "CameraWorker":
"""Attach a camera to a live engine. Raises if the id is taken."""
with self._lock:
if cam_cfg.id in self.workers:
raise ValueError(f"camera '{cam_cfg.id}' is already running")
worker = self._build_worker(cam_cfg)
if self._running:
worker.start()
log.info("camera '%s' added (%s)", cam_cfg.id, cam_cfg.safe_url())
return worker
def remove_camera(self, camera_id: str) -> bool:
with self._lock:
worker = self.workers.pop(camera_id, None)
self.detectors.pop(camera_id, None)
if worker is None:
return False
# Outside the lock: join() can take seconds and must not block the
# frame loop's stats() calls or another camera being added.
worker.stop()
if worker.is_alive():
worker.join(timeout=5)
log.info("camera '%s' removed", camera_id)
return True
def restart_camera(self, cam_cfg: CameraConfig) -> "CameraWorker":
"""Apply an edited URL/credential. CameraWorker is a Thread, and a
stopped Thread cannot be restarted, so this must build a new one."""
with self._lock:
self.remove_camera(cam_cfg.id)
return self.add_camera(cam_cfg)
# -- lifecycle ------------------------------------------------------
def start(self) -> None:
self.bus.start()
with self._lock:
self._running = True
workers = list(self.workers.values())
for worker in workers:
worker.start()
self.started_at = time.time()
log.info("engine started with %d camera(s)", len(workers))
def stop(self) -> None:
with self._lock:
self._running = False
workers = list(self.workers.values())
for worker in workers:
worker.stop()
for worker in workers:
if worker.is_alive():
worker.join(timeout=5)
self.bus.stop()
self.store.close()
log.info("engine stopped")
def stats(self) -> dict:
return {
"uptime_s": round(time.time() - self.started_at, 1)
if self.started_at else 0,
# Which encoder actually won the fallback chain. On a
# memory-constrained box the big model can silently lose to the
# 13 MB one, and every stored embedding is tagged with whichever
# loaded — so this is the first thing to check after a deploy.
"recognition": {
"model": self.encoder.model_name,
"color_order": self.encoder.color_order,
"input_size": self.encoder.size,
},
"attributes": {
"enabled": self.attributes is not None,
"age_model": ("genderage" if self.attributes is not None
and self.attributes.has_genderage else "caffe/none"),
},
"gallery": self.store.stats(),
"cameras": [w.stats() for w in self.snapshot_workers()],
}
def snapshot_workers(self) -> "list[CameraWorker]":
"""Point-in-time copy — callers must never iterate self.workers
directly now that cameras come and go from request threads."""
with self._lock:
return list(self.workers.values())

122
behavision/events.py Normal file
View File

@@ -0,0 +1,122 @@
"""Async event bus with pluggable sinks (log, webhook, email).
Events are published from the pipeline thread and delivered on a dedicated
worker thread, so a slow webhook or SMTP server can never stall frame
processing. Sink failures are logged, never raised.
"""
from __future__ import annotations
import logging
import queue
import smtplib
import threading
import time
from collections import deque
from dataclasses import asdict, dataclass, field
from email.mime.text import MIMEText
log = logging.getLogger(__name__)
@dataclass
class Event:
type: str # person.new | person.seen | camera.up | camera.down | ...
camera_id: str
ts: float = field(default_factory=time.time)
data: dict = field(default_factory=dict)
def to_dict(self) -> dict:
return asdict(self)
class EventBus:
def __init__(self) -> None:
self._queue: "queue.Queue[Event | None]" = queue.Queue(maxsize=1000)
self._sinks: list = []
self.recent: deque = deque(maxlen=300)
self._worker = threading.Thread(
target=self._run, daemon=True, name="event-bus")
self._started = False
def add_sink(self, sink) -> None:
self._sinks.append(sink)
def start(self) -> None:
if not self._started:
self._started = True
self._worker.start()
def stop(self) -> None:
if self._started:
self._queue.put(None)
self._worker.join(timeout=5)
def publish(self, event: Event) -> None:
self.recent.appendleft(event.to_dict())
try:
self._queue.put_nowait(event)
except queue.Full:
log.warning("event queue full, dropping %s", event.type)
def _run(self) -> None:
while True:
event = self._queue.get()
if event is None:
return
for sink in self._sinks:
try:
sink.handle(event)
except Exception:
log.exception("sink %s failed for %s",
type(sink).__name__, event.type)
class LogSink:
def handle(self, event: Event) -> None:
log.info("event %s [%s] %s", event.type, event.camera_id, event.data)
class WebhookSink:
def __init__(self, url: str, timeout: float = 5.0):
self.url = url
self.timeout = timeout
def handle(self, event: Event) -> None:
import requests
requests.post(self.url, json=event.to_dict(), timeout=self.timeout)
class EmailSink:
"""Rate-limited email notifications for person events only."""
NOTIFY_TYPES = {"person.new", "person.seen"}
def __init__(self, smtp_host: str, smtp_port: int, username: str,
password: str, to: str, min_interval: float = 300.0):
self.smtp_host = smtp_host
self.smtp_port = smtp_port
self.username = username
self.password = password
self.to = to
self.min_interval = min_interval
self._last_sent = 0.0
def handle(self, event: Event) -> None:
if event.type not in self.NOTIFY_TYPES:
return
now = time.time()
if now - self._last_sent < self.min_interval:
return
self._last_sent = now
label = event.data.get("label", "someone")
body = (f"Behavision: {label} detected on camera {event.camera_id}\n"
f"Event: {event.type}\nDetails: {event.data}")
msg = MIMEText(body)
msg["Subject"] = f"Behavision: {label} on {event.camera_id}"
msg["From"] = self.username
msg["To"] = self.to
with smtplib.SMTP(self.smtp_host, self.smtp_port, timeout=10) as smtp:
smtp.starttls()
smtp.login(self.username, self.password)
smtp.send_message(msg)

142
behavision/faces.py Normal file
View File

@@ -0,0 +1,142 @@
"""Saving a face image for one visit — the outbox the agent uploads from.
This is the one place the engine writes a picture of a person to disk, and it
is off unless `app.store_faces` is set. That default is the product's original
privacy position, not an oversight: with images off, `data/behavision.db` holds
templates and timestamps and nothing that looks like a photograph. Turning them
on changes what the system is under GDPR and India's DPDP, so it is a decision
someone has to make on purpose.
The engine does NOT upload. It writes a file and names it on the event; the
agent uploads through a short-lived URL the server mints. A shop PC therefore
never holds object-storage credentials — the bucket is shared and a counter-top
machine is the least trustworthy thing in the estate.
"""
from __future__ import annotations
import logging
import time
import uuid
from pathlib import Path
import cv2
import numpy as np
log = logging.getLogger(__name__)
# A loose crop, not the aligned 112x112 chip.
#
# The chip is built for ArcFace: tight, warped to canonical landmarks, and
# nearly useless to a human trying to recognise a customer. This is the frame a
# person looks at, so it gets the same 1.5x head crop the attribute models use.
CROP_SCALE = 1.5
# Enough to see a face on a dashboard, small enough that a shop on ADSL can
# upload one per visitor without the queue backing up. ~15-25 KB at q80.
MAX_EDGE = 320
JPEG_QUALITY = 80
def _loose_crop(frame: np.ndarray, box, scale: float = CROP_SCALE) -> np.ndarray:
"""Square crop around the head, replicate-padded when it runs off-frame."""
x1, y1, x2, y2 = (float(v) for v in box)
cx, cy = (x1 + x2) / 2.0, (y1 + y2) / 2.0
half = max(x2 - x1, y2 - y1) * scale / 2.0
left, top = int(round(cx - half)), int(round(cy - half))
right, bottom = int(round(cx + half)), int(round(cy + half))
h, w = frame.shape[:2]
pad_l, pad_t = max(0, -left), max(0, -top)
pad_r, pad_b = max(0, right - w), max(0, bottom - h)
crop = frame[max(0, top):min(h, bottom), max(0, left):min(w, right)]
if crop.size == 0:
return frame
if pad_l or pad_t or pad_r or pad_b:
crop = cv2.copyMakeBorder(crop, pad_t, pad_b, pad_l, pad_r,
cv2.BORDER_REPLICATE)
return crop
class FaceOutbox:
"""Writes one JPEG per resolved visit for the agent to collect.
Files land in `data_dir/outbox`, which is deliberately NOT inside the
database directory: it is transient, the agent deletes each file after a
successful upload, and a backup of the database must not quietly start
including face images.
"""
def __init__(self, data_dir: Path, enabled: bool,
max_files: int = 500) -> None:
self.enabled = enabled
self.dir = Path(data_dir) / "outbox"
# Bounded. If the agent stops collecting — not running, no credentials,
# server unreachable for a week — this must not fill a shop's disk with
# pictures of its customers. Dropping the oldest is right: a stale
# photo of a visit already reported is the least valuable thing here.
self.max_files = max_files
if enabled:
self.dir.mkdir(parents=True, exist_ok=True)
log.warning(
"app.store_faces is ON: face images are being written to %s. "
"This changes what this machine holds under GDPR/DPDP.",
self.dir)
def crop(self, frame: np.ndarray, box) -> "np.ndarray | None":
"""The candidate image for one frame, downscaled and nothing else.
Kept as an array rather than encoded here because this runs on every
frame of every track: JPEG encoding per frame is milliseconds spent to
throw away all but the last one. At 320 px a crop is ~300 KB, so one
per live track is affordable even on the 16 GB box that already OOMs on
a 250 MB model — holding whole 1280x720 frames instead would not be.
"""
if not self.enabled:
return None
try:
crop = _loose_crop(frame, box)
h, w = crop.shape[:2]
if min(h, w) <= 0:
return None
if max(h, w) > MAX_EDGE:
s = MAX_EDGE / float(max(h, w))
crop = cv2.resize(crop, (max(1, int(w * s)), max(1, int(h * s))),
interpolation=cv2.INTER_AREA)
# A copy, because the slice from _loose_crop can be a view onto the
# capture buffer, which the capture thread overwrites in place.
return np.ascontiguousarray(crop)
except Exception:
log.exception("could not build a face crop")
return None
def save(self, crop: "np.ndarray | None") -> "str | None":
"""Write the crop and return its path, or None if images are off."""
if not self.enabled or crop is None:
return None
try:
path = self.dir / f"{time.time():.3f}_{uuid.uuid4().hex}.jpg"
ok, buf = cv2.imencode(".jpg", crop,
[int(cv2.IMWRITE_JPEG_QUALITY), JPEG_QUALITY])
if not ok:
return None
# Write-then-rename. The agent watches this directory, and a
# partially written JPEG that it picks up mid-write is an upload of
# a corrupt file that nothing will ever correct.
tmp = path.with_suffix(".part")
tmp.write_bytes(buf.tobytes())
tmp.replace(path)
self._trim()
return str(path)
except Exception:
# Never take the recognition loop down over a photo. A missing
# image is a cosmetic loss; a stalled worker is the product.
log.exception("could not write a face image")
return None
def _trim(self) -> None:
try:
files = sorted(self.dir.glob("*.jpg"), key=lambda p: p.stat().st_mtime)
for stale in files[:-self.max_files]:
stale.unlink(missing_ok=True)
except Exception:
log.debug("outbox trim failed", exc_info=True)

View File

@@ -0,0 +1,3 @@
from .service import Gallery, Resolution # noqa: F401
from .store import IdentityStore # noqa: F401
from .index import VectorIndex # noqa: F401

View File

@@ -0,0 +1,83 @@
"""Cosine-similarity vector index.
FAISS `IndexFlatIP` wrapped in `IndexIDMap2` when faiss is installed, plain
numpy otherwise — same interface, same results. Choices that fix the old
codebase's failure modes:
- Exact inner-product search (vectors are unit-norm, so IP == cosine).
No IVF: nothing to train, no wrong-metric trap, and exact search is
microseconds up to hundreds of thousands of vectors.
- `-1` ids from an empty index are filtered, never used as list indices.
- The index is rebuilt from SQLite at startup (SQLite is the source of
truth), so index and metadata can never drift apart.
"""
from __future__ import annotations
import logging
import numpy as np
log = logging.getLogger(__name__)
try:
import faiss # type: ignore
_HAVE_FAISS = True
except ImportError: # pragma: no cover - environment dependent
faiss = None
_HAVE_FAISS = False
class VectorIndex:
def __init__(self, dim: int):
self.dim = dim
if _HAVE_FAISS:
self._index = faiss.IndexIDMap2(faiss.IndexFlatIP(dim))
self._ids = None
self._vecs = None
else:
log.warning("faiss not installed - using exact numpy search "
"(identical results, slower at large scale)")
self._index = None
self._ids = np.empty((0,), dtype=np.int64)
self._vecs = np.empty((0, dim), dtype=np.float32)
def __len__(self) -> int:
if self._index is not None:
return self._index.ntotal
return len(self._ids)
def add(self, ids: "list[int]", vectors: np.ndarray) -> None:
if len(ids) == 0:
return
vectors = np.ascontiguousarray(vectors, dtype=np.float32).reshape(len(ids), self.dim)
id_arr = np.asarray(ids, dtype=np.int64)
if self._index is not None:
self._index.add_with_ids(vectors, id_arr)
else:
self._ids = np.concatenate([self._ids, id_arr])
self._vecs = np.vstack([self._vecs, vectors])
def remove(self, ids: "list[int]") -> None:
if len(ids) == 0:
return
id_arr = np.asarray(ids, dtype=np.int64)
if self._index is not None:
self._index.remove_ids(id_arr)
else:
keep = ~np.isin(self._ids, id_arr)
self._ids = self._ids[keep]
self._vecs = self._vecs[keep]
def search(self, vector: np.ndarray, k: int = 1) -> "list[tuple[int, float]]":
"""Top-k (embedding_id, cosine_similarity), best first."""
if len(self) == 0:
return []
q = np.ascontiguousarray(vector, dtype=np.float32).reshape(1, self.dim)
k = min(k, len(self))
if self._index is not None:
scores, ids = self._index.search(q, k)
return [(int(i), float(s))
for i, s in zip(ids[0], scores[0]) if i != -1]
sims = self._vecs @ q[0]
order = np.argsort(-sims)[:k]
return [(int(self._ids[i]), float(sims[i])) for i in order]

View File

@@ -0,0 +1,327 @@
"""Identity resolution: match, reinforce, or auto-enroll — with hysteresis.
Three-zone decision instead of one threshold:
similarity >= match_threshold -> same person
similarity < enroll_threshold -> genuinely new person
in between -> ambiguous: do NOTHING
The ambiguous zone is what prevents both duplicate identities and wrong
merges — the two failure modes the previous system had simultaneously.
"""
from __future__ import annotations
import logging
import threading
import time
from dataclasses import dataclass
from typing import Optional
import numpy as np
from ..config import RecognitionSection
from .index import VectorIndex
from .store import IdentityStore
log = logging.getLogger(__name__)
@dataclass
class Resolution:
kind: str # known | new | ambiguous | skipped
identity_id: Optional[int] = None
label: Optional[str] = None
similarity: float = 0.0
new_sighting: bool = False
class Gallery:
"""One gallery shared by every camera.
`cfg` here is the global recognition section — the default. Callers that
belong to a camera pass that camera's merged section as `rcfg`, because
the gates describe a view and cameras do not share one.
"""
def __init__(self, store: IdentityStore, index: VectorIndex,
cfg: RecognitionSection, model_name: str = "default"):
self.store = store
self.index = index
self.cfg = cfg
self.model_name = model_name
self._lock = threading.Lock()
self._last_sighting: dict[tuple[int, str], float] = {}
# Only embeddings produced by the active encoder enter the index;
# vectors from a different model are numerically incompatible.
ids, vecs = store.all_embeddings(index.dim, model=model_name)
index.add(ids, vecs)
log.info("gallery ready: %d embeddings (model '%s') across %d "
"identities", len(ids), model_name,
store.stats()["identities"])
def resolve(self, embedding: np.ndarray, quality: float, camera_id: str,
ts: "float | None" = None,
attributes: "dict | None" = None,
rcfg: "RecognitionSection | None" = None) -> Resolution:
"""`rcfg` is the calling camera's merged thresholds; the gallery is
shared across cameras but the gates that decide a view are not."""
ts = ts or time.time()
cfg = rcfg or self.cfg
with self._lock:
matches = self.index.search(embedding, k=1)
top_id, top_sim = matches[0] if matches else (None, -1.0)
if top_id is not None and top_sim >= cfg.match_threshold:
ident = self.store.identity_for_embedding(top_id)
if ident is None: # index/store race — treat as ambiguous
return Resolution(kind="ambiguous", similarity=top_sim)
self._maybe_reinforce(ident["id"], embedding, quality,
top_sim, cfg)
fresh = self._record_sighting(
ident["id"], camera_id, ts, top_sim, quality, attributes)
return Resolution(kind="known", identity_id=ident["id"],
label=ident["label"], similarity=top_sim,
new_sighting=fresh)
if top_id is None or top_sim < cfg.enroll_threshold:
if not cfg.auto_enroll:
return Resolution(kind="skipped", similarity=top_sim)
if quality < cfg.min_enroll_quality:
# Not confident enough in this face to mint an identity.
return Resolution(kind="skipped", similarity=top_sim)
identity_id, label = self.store.create_auto_identity()
emb_id = self.store.add_embedding(identity_id, embedding,
quality, self.model_name)
self.index.add([emb_id], embedding.reshape(1, -1))
self._record_sighting(identity_id, camera_id, ts, 1.0, quality,
attributes)
log.info("auto-enrolled %s (quality %.2f)", label, quality)
return Resolution(kind="new", identity_id=identity_id,
label=label, similarity=top_sim,
new_sighting=True)
return Resolution(kind="ambiguous", similarity=top_sim)
def enroll(self, label: str, embeddings: "list[np.ndarray]",
quality: float = 1.0) -> int:
"""Explicit enrollment (CLI / API) with a known name."""
with self._lock:
identity_id = self.store.create_identity(label, kind="enrolled")
for emb in embeddings[: self.cfg.max_embeddings_per_identity]:
emb_id = self.store.add_embedding(identity_id, emb, quality,
self.model_name)
self.index.add([emb_id], emb.reshape(1, -1))
return identity_id
def reinforce_identity(self, identity_id: int, embedding: np.ndarray,
quality: float,
rcfg: "RecognitionSection | None" = None) -> bool:
"""Add another view of an ALREADY-identified person.
A track is resolved once and then stops contributing, so an identity
was born holding a single embedding from the first second of a visit —
and the next encounter at a different angle had one reference vector to
beat. This lets the rest of the visit fill the gallery out.
Guarded three ways: the view must still map to *this* identity (a
track that drifted onto another face must not poison the gallery), it
must be similar enough that we actually believe it is this person
(>= enroll_threshold), and different enough to be worth storing
(< reinforce_threshold).
"""
cfg = rcfg or self.cfg
with self._lock:
if quality < cfg.min_enroll_quality:
return False
if (self.store.embedding_count(identity_id)
>= cfg.max_embeddings_per_identity):
return False
matches = self.index.search(embedding, k=1)
if not matches:
return False
top_id, top_sim = matches[0]
ident = self.store.identity_for_embedding(top_id)
if ident is None or ident["id"] != identity_id:
return False # looks more like someone else - do not store
if top_sim < cfg.enroll_threshold:
# Nearest neighbour is this identity, but only barely. Below
# enroll_threshold resolve() would call this a DIFFERENT
# person, so gluing it on here would contradict the decision
# the same numbers drive everywhere else. Measured on the
# overhead camera, unfloored reinforcement gave one identity
# two vectors 0.195 apart. The risk is asymmetric: a wrong
# face welded into an identity is unrecoverable, a missed
# hard angle is not.
return False
if top_sim >= cfg.reinforce_threshold:
return False # near-duplicate of what we already have
emb_id = self.store.add_embedding(identity_id, embedding, quality,
self.model_name)
self.index.add([emb_id], embedding.reshape(1, -1))
log.debug("reinforced identity %d (sim %.3f, quality %.2f)",
identity_id, top_sim, quality)
return True
def merge_identities(self, source_id: int, target_id: int,
force: bool = False) -> "dict":
"""Fold one identity into another — the repair for a person who was
enrolled twice.
Duplicates are not a hypothetical: two views of one face can score
below `match_threshold`, and when they do the system mints a second
identity and there is no way back. Deleting one loses that person's
history; leaving both means the same customer is greeted as new.
Merging is destructive and, unlike a duplicate, *unrecoverable* — two
different people welded together cannot be separated afterwards,
because nothing records which embedding came from whom. So the two
identities must look at least plausibly alike: below
`enroll_threshold` resolve() positively asserts they are different
people, and overriding that assertion requires `force`.
Returns a dict with `ok`; on refusal `reason` says why, so the UI can
offer the override instead of failing silently.
"""
cfg = self.cfg
with self._lock:
if source_id == target_id:
return {"ok": False, "reason": "cannot merge an identity "
"into itself"}
if self.store.get_identity(source_id) is None:
return {"ok": False, "reason": f"identity {source_id} not found"}
if self.store.get_identity(target_id) is None:
return {"ok": False, "reason": f"identity {target_id} not found"}
sim, checkable = self._identity_similarity(source_id, target_id)
if not force:
if not checkable:
return {"ok": False, "similarity": None,
"reason": "no comparable embeddings (different "
"encoder model) - cannot verify these "
"are the same person"}
if sim < cfg.enroll_threshold:
return {"ok": False, "similarity": round(sim, 3),
"threshold": cfg.enroll_threshold,
"reason": "these look like different people "
f"(best similarity {sim:.3f} < "
f"{cfg.enroll_threshold})"}
result = self.store.merge_identities(
source_id, target_id, cfg.max_embeddings_per_identity)
if result is None:
return {"ok": False, "reason": "identity not found"}
# Trimmed vectors must leave the index or it keeps answering with
# embedding ids that no longer exist in SQLite.
self.index.remove(result["dropped_embeddings"])
# The per-camera sighting cooldown is keyed by identity; the
# source's keys now point at an identity that is gone.
for key in [k for k in self._last_sighting if k[0] == source_id]:
self._last_sighting.pop(key, None)
log.warning("merged identity %d into %d (%s): %d embeddings, "
"%d sightings, similarity %s%s", source_id, target_id,
result["label"], result["embeddings_moved"],
result["sightings_moved"],
f"{sim:.3f}" if checkable else "n/a",
" [FORCED]" if force else "")
result.update(ok=True, forced=force,
similarity=round(sim, 3) if checkable else None)
return result
def duplicate_candidates(self, limit: int = 20, k: int = 6
) -> "list[dict]":
"""Identity pairs that look like the same person.
Found through the index rather than an all-pairs comparison: every
stored vector asks for its `k` nearest neighbours and any that belong
to a *different* identity is evidence those two are one person. That
is O(n*k) and needs no big matrix — an all-pairs float32 matrix over
10k embeddings is 400 MB, and this runs on a box that already OOMs on
a 250 MB model.
Only pairs at or above `enroll_threshold` are reported: below it the
gallery's own numbers say these are different people, and offering
that as a suggestion would invite exactly the merge that cannot be
undone.
"""
with self._lock:
owners = self.store.embedding_owners(self.model_name)
if not owners:
return []
ids, vecs = self.store.all_embeddings(self.index.dim,
model=self.model_name)
best: dict[tuple[int, int], float] = {}
for emb_id, vec in zip(ids, vecs):
mine = owners.get(emb_id)
if mine is None:
continue
for other_id, sim in self.index.search(vec, k=k):
theirs = owners.get(other_id)
if theirs is None or theirs == mine:
continue
if sim < self.cfg.enroll_threshold:
continue
pair = (min(mine, theirs), max(mine, theirs))
if sim > best.get(pair, -1.0):
best[pair] = float(sim)
out = []
for (a, b), sim in sorted(best.items(), key=lambda kv: -kv[1])[:limit]:
ia, ib = self.store.get_identity(a), self.store.get_identity(b)
if ia is None or ib is None:
continue
out.append({
"a": {"id": a, "label": ia["label"], "kind": ia["kind"],
"sighting_count": ia["sighting_count"]},
"b": {"id": b, "label": ib["label"], "kind": ib["kind"],
"sighting_count": ib["sighting_count"]},
"similarity": round(sim, 3),
"confident": sim >= self.cfg.match_threshold})
return out
def _identity_similarity(self, a: int, b: int) -> "tuple[float, bool]":
"""Best cosine similarity between any view of `a` and any view of `b`.
Best, not mean: two identities of one person exist precisely because
their *typical* views disagree. If any pair of views agrees, that is
the evidence they are the same person.
"""
_, va = self.store.identity_embeddings(a, self.index.dim,
self.model_name)
_, vb = self.store.identity_embeddings(b, self.index.dim,
self.model_name)
if len(va) == 0 or len(vb) == 0:
return 0.0, False
return float((va @ vb.T).max()), True
def delete_identity(self, identity_id: int) -> bool:
with self._lock:
removed = self.store.delete_identity(identity_id)
self.index.remove(removed)
return bool(removed)
# -- internals ------------------------------------------------------
def _maybe_reinforce(self, identity_id: int, embedding: np.ndarray,
quality: float, similarity: float,
cfg: RecognitionSection) -> None:
"""Add an extra embedding for a known person when this view is
confidently theirs but usefully different (pose/lighting), improving
recall over time without letting the identity drift."""
if similarity >= cfg.reinforce_threshold:
return # too similar to what we already have — adds nothing
if quality < cfg.min_enroll_quality:
return
if (self.store.embedding_count(identity_id)
>= cfg.max_embeddings_per_identity):
return
emb_id = self.store.add_embedding(identity_id, embedding, quality,
self.model_name)
self.index.add([emb_id], embedding.reshape(1, -1))
def _record_sighting(self, identity_id: int, camera_id: str, ts: float,
similarity: float, quality: float,
attributes: "dict | None" = None) -> bool:
key = (identity_id, camera_id)
last = self._last_sighting.get(key, 0.0)
if ts - last < self.cfg.sighting_cooldown_seconds:
return False
self._last_sighting[key] = ts
self.store.record_sighting(identity_id, camera_id, ts, similarity,
quality, attributes)
return True

338
behavision/gallery/store.py Normal file
View File

@@ -0,0 +1,338 @@
"""SQLite persistence for identities, embeddings and sightings.
Single writer class with an internal lock; WAL mode so the API can read
while the pipeline writes. Embeddings are stored as float32 BLOBs — SQLite
is the source of truth and the vector index is rebuilt from here at boot.
"""
from __future__ import annotations
import json
import sqlite3
import threading
import time
from pathlib import Path
import numpy as np
_SCHEMA = """
CREATE TABLE IF NOT EXISTS identities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
label TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'auto',
created_at REAL NOT NULL,
last_seen_at REAL,
sighting_count INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS embeddings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
identity_id INTEGER NOT NULL REFERENCES identities(id) ON DELETE CASCADE,
vector BLOB NOT NULL,
model TEXT NOT NULL DEFAULT '',
quality REAL NOT NULL DEFAULT 0,
created_at REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_embeddings_identity ON embeddings(identity_id);
CREATE TABLE IF NOT EXISTS sightings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
identity_id INTEGER NOT NULL REFERENCES identities(id) ON DELETE CASCADE,
camera_id TEXT NOT NULL,
ts REAL NOT NULL,
similarity REAL NOT NULL DEFAULT 0,
quality REAL NOT NULL DEFAULT 0,
attributes TEXT
);
CREATE INDEX IF NOT EXISTS idx_sightings_identity ON sightings(identity_id);
CREATE INDEX IF NOT EXISTS idx_sightings_ts ON sightings(ts);
"""
class IdentityStore:
def __init__(self, db_path: "Path | str"):
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
self._lock = threading.Lock()
self._db = sqlite3.connect(str(db_path), check_same_thread=False)
self._db.row_factory = sqlite3.Row
with self._lock:
self._db.execute("PRAGMA journal_mode=WAL")
self._db.execute("PRAGMA foreign_keys=ON")
self._db.executescript(_SCHEMA)
self._db.commit()
# -- identities -----------------------------------------------------
def create_identity(self, label: str, kind: str = "auto") -> int:
with self._lock:
cur = self._db.execute(
"INSERT INTO identities(label, kind, created_at) VALUES(?,?,?)",
(label, kind, time.time()))
self._db.commit()
return int(cur.lastrowid)
def create_auto_identity(self) -> "tuple[int, str]":
"""Create an auto-enrolled identity labelled 'Visitor <id>' in one
transaction; returns (id, label)."""
with self._lock:
cur = self._db.execute(
"INSERT INTO identities(label, kind, created_at) VALUES(?,?,?)",
("pending", "auto", time.time()))
identity_id = int(cur.lastrowid)
label = f"Visitor {identity_id}"
self._db.execute(
"UPDATE identities SET label=? WHERE id=?", (label, identity_id))
self._db.commit()
return identity_id, label
def rename_identity(self, identity_id: int, label: str) -> bool:
with self._lock:
cur = self._db.execute(
"UPDATE identities SET label=?, kind='enrolled' WHERE id=?",
(label, identity_id))
self._db.commit()
return cur.rowcount > 0
def delete_identity(self, identity_id: int) -> "list[int]":
"""Delete an identity; returns removed embedding ids (for the index)."""
with self._lock:
rows = self._db.execute(
"SELECT id FROM embeddings WHERE identity_id=?",
(identity_id,)).fetchall()
self._db.execute("DELETE FROM identities WHERE id=?", (identity_id,))
self._db.commit()
return [int(r["id"]) for r in rows]
def merge_identities(self, source_id: int, target_id: int,
max_embeddings: int = 5) -> "dict | None":
"""Fold `source_id` into `target_id`; returns a summary, or None if
either identity is missing.
Embeddings and sightings are re-pointed rather than copied, which is
what keeps this cheap AND keeps the vector index valid: the index maps
*embedding* id to vector, and those ids do not change here, so a merge
needs no reindex. Only trimmed embeddings have to be dropped from it,
which is why they are returned.
Everything happens in one transaction. A half-merge — sightings moved,
embeddings not — would leave two identities each holding part of one
person, which is strictly worse than the duplicate we started with.
"""
with self._lock:
src = self._db.execute("SELECT * FROM identities WHERE id=?",
(source_id,)).fetchone()
dst = self._db.execute("SELECT * FROM identities WHERE id=?",
(target_id,)).fetchone()
if src is None or dst is None or source_id == target_id:
return None
try:
emb = self._db.execute(
"UPDATE embeddings SET identity_id=? WHERE identity_id=?",
(target_id, source_id)).rowcount
sig = self._db.execute(
"UPDATE sightings SET identity_id=? WHERE identity_id=?",
(target_id, source_id)).rowcount
# A human-assigned name outranks an auto "Visitor N" whichever
# direction the operator merged in — silently turning "Alice"
# back into "Visitor 3" would be a data-loss bug, not a policy.
label, kind = dst["label"], dst["kind"]
if dst["kind"] == "auto" and src["kind"] != "auto":
label, kind = src["label"], src["kind"]
# The merged identity's history starts at the earlier of the
# two first-sightings; it is one person and always was.
created = min(float(src["created_at"]), float(dst["created_at"]))
# Trim to the highest-quality views. Merging two identities
# that each held the cap would otherwise leave one holding
# double, quietly overweighting that person in every search.
dropped = [int(r["id"]) for r in self._db.execute(
"SELECT id FROM embeddings WHERE identity_id=? "
"ORDER BY quality DESC, id ASC LIMIT -1 OFFSET ?",
(target_id, max_embeddings)).fetchall()]
if dropped:
self._db.execute(
"DELETE FROM embeddings WHERE id IN (%s)"
% ",".join("?" * len(dropped)), dropped)
# Recomputed, never summed: sighting_count on the source may
# itself be stale, and COUNT(*) is the only figure that cannot
# drift away from the rows actually present.
agg = self._db.execute(
"SELECT COUNT(*) AS n, MAX(ts) AS last FROM sightings "
"WHERE identity_id=?", (target_id,)).fetchone()
self._db.execute(
"UPDATE identities SET label=?, kind=?, created_at=?, "
"sighting_count=?, last_seen_at=? WHERE id=?",
(label, kind, created, int(agg["n"]), agg["last"],
target_id))
self._db.execute("DELETE FROM identities WHERE id=?",
(source_id,))
self._db.commit()
except Exception:
self._db.rollback()
raise
return {"source": source_id, "target": target_id, "label": label,
"embeddings_moved": int(emb), "sightings_moved": int(sig),
"dropped_embeddings": dropped,
"sighting_count": int(agg["n"])}
def identity_embeddings(self, identity_id: int, dim: int,
model: "str | None" = None
) -> "tuple[list[int], np.ndarray]":
"""One identity's stored vectors, for comparing two identities to each
other. Model-filtered for the same reason the index is."""
with self._lock:
if model is None:
rows = self._db.execute(
"SELECT id, vector FROM embeddings WHERE identity_id=? "
"ORDER BY id", (identity_id,)).fetchall()
else:
rows = self._db.execute(
"SELECT id, vector FROM embeddings WHERE identity_id=? "
"AND model=? ORDER BY id",
(identity_id, model)).fetchall()
ids = [int(r["id"]) for r in rows]
if not ids:
return [], np.empty((0, dim), dtype=np.float32)
return ids, np.vstack([
np.frombuffer(r["vector"], dtype=np.float32) for r in rows])
def get_identity(self, identity_id: int) -> "dict | None":
with self._lock:
row = self._db.execute(
"SELECT * FROM identities WHERE id=?", (identity_id,)).fetchone()
return dict(row) if row else None
def list_identities(self, limit: int = 200) -> "list[dict]":
with self._lock:
rows = self._db.execute(
"SELECT i.*, COUNT(e.id) AS embedding_count FROM identities i "
"LEFT JOIN embeddings e ON e.identity_id = i.id "
"GROUP BY i.id ORDER BY i.last_seen_at DESC LIMIT ?",
(limit,)).fetchall()
return [dict(r) for r in rows]
# -- embeddings -----------------------------------------------------
def add_embedding(self, identity_id: int, vector: np.ndarray,
quality: float, model: str = "") -> int:
blob = np.asarray(vector, dtype=np.float32).tobytes()
with self._lock:
cur = self._db.execute(
"INSERT INTO embeddings(identity_id, vector, model, quality,"
" created_at) VALUES(?,?,?,?,?)",
(identity_id, blob, model, quality, time.time()))
self._db.commit()
return int(cur.lastrowid)
def embedding_count(self, identity_id: int) -> int:
with self._lock:
row = self._db.execute(
"SELECT COUNT(*) AS n FROM embeddings WHERE identity_id=?",
(identity_id,)).fetchone()
return int(row["n"])
def identity_for_embedding(self, embedding_id: int) -> "dict | None":
with self._lock:
row = self._db.execute(
"SELECT i.* FROM identities i JOIN embeddings e "
"ON e.identity_id = i.id WHERE e.id=?",
(embedding_id,)).fetchone()
return dict(row) if row else None
def all_embeddings(self, dim: int, model: "str | None" = None
) -> "tuple[list[int], np.ndarray]":
"""Embeddings for the vector index. Filtering by `model` is what
keeps vectors from different encoders out of the same search space —
they are numerically incompatible."""
with self._lock:
if model is None:
rows = self._db.execute(
"SELECT id, vector FROM embeddings ORDER BY id").fetchall()
else:
rows = self._db.execute(
"SELECT id, vector FROM embeddings WHERE model=? "
"ORDER BY id", (model,)).fetchall()
ids = [int(r["id"]) for r in rows]
if not ids:
return [], np.empty((0, dim), dtype=np.float32)
vecs = np.vstack([
np.frombuffer(r["vector"], dtype=np.float32) for r in rows])
return ids, vecs
def best_embedding(self, identity_id: int, model: "str | None" = None
) -> "tuple[np.ndarray, float] | None":
"""The highest-quality stored view of one identity.
For handing an identity to the server: sending the best view rather
than the mean because a mean of two disagreeing views is a vector that
matches neither, which is precisely how one person becomes two
identities.
"""
with self._lock:
if model is None:
row = self._db.execute(
"SELECT vector, quality FROM embeddings WHERE identity_id=? "
"ORDER BY quality DESC, id ASC LIMIT 1",
(identity_id,)).fetchone()
else:
row = self._db.execute(
"SELECT vector, quality FROM embeddings WHERE identity_id=? "
"AND model=? ORDER BY quality DESC, id ASC LIMIT 1",
(identity_id, model)).fetchone()
if row is None:
return None
return np.frombuffer(row["vector"], dtype=np.float32), float(row["quality"])
def embedding_owners(self, model: "str | None" = None) -> "dict[int, int]":
"""embedding_id -> identity_id, for turning index hits into identity
pairs without a round trip to SQLite per hit."""
with self._lock:
if model is None:
rows = self._db.execute(
"SELECT id, identity_id FROM embeddings").fetchall()
else:
rows = self._db.execute(
"SELECT id, identity_id FROM embeddings WHERE model=?",
(model,)).fetchall()
return {int(r["id"]): int(r["identity_id"]) for r in rows}
# -- sightings ------------------------------------------------------
def record_sighting(self, identity_id: int, camera_id: str, ts: float,
similarity: float, quality: float,
attributes: "dict | None" = None) -> None:
with self._lock:
self._db.execute(
"INSERT INTO sightings(identity_id, camera_id, ts, similarity,"
" quality, attributes) VALUES(?,?,?,?,?,?)",
(identity_id, camera_id, ts, similarity, quality,
json.dumps(attributes) if attributes else None))
self._db.execute(
"UPDATE identities SET last_seen_at=?, "
"sighting_count=sighting_count+1 WHERE id=?", (ts, identity_id))
self._db.commit()
def recent_sightings(self, limit: int = 100) -> "list[dict]":
with self._lock:
rows = self._db.execute(
"SELECT s.*, i.label FROM sightings s JOIN identities i "
"ON i.id = s.identity_id ORDER BY s.ts DESC LIMIT ?",
(limit,)).fetchall()
out = []
for r in rows:
d = dict(r)
if d.get("attributes"):
d["attributes"] = json.loads(d["attributes"])
out.append(d)
return out
def stats(self) -> dict:
with self._lock:
n_id = self._db.execute(
"SELECT COUNT(*) AS n FROM identities").fetchone()["n"]
n_emb = self._db.execute(
"SELECT COUNT(*) AS n FROM embeddings").fetchone()["n"]
n_sight = self._db.execute(
"SELECT COUNT(*) AS n FROM sightings").fetchone()["n"]
return {"identities": n_id, "embeddings": n_emb, "sightings": n_sight}
def close(self) -> None:
with self._lock:
self._db.close()

81
behavision/geometry.py Normal file
View File

@@ -0,0 +1,81 @@
"""Box math and ArcFace 5-point alignment (Umeyama similarity transform)."""
from __future__ import annotations
import cv2
import numpy as np
# Canonical 5-point landmark template for a 112x112 ArcFace crop:
# left eye, right eye, nose tip, left mouth corner, right mouth corner.
ARCFACE_TEMPLATE = np.array(
[
[38.2946, 51.6963],
[73.5318, 51.5014],
[56.0252, 71.7366],
[41.5493, 92.3655],
[70.7299, 92.2041],
],
dtype=np.float32,
)
def clip_box(box, width: int, height: int):
"""Clamp an (x1, y1, x2, y2) box to image bounds.
Returns int coords, or None when nothing of the box remains inside the
frame. This is what prevents negative indices from silently wrapping
around in numpy slicing.
"""
x1, y1, x2, y2 = box
x1 = int(max(0, min(x1, width)))
y1 = int(max(0, min(y1, height)))
x2 = int(max(0, min(x2, width)))
y2 = int(max(0, min(y2, height)))
if x2 - x1 < 2 or y2 - y1 < 2:
return None
return x1, y1, x2, y2
def iou(a, b) -> float:
ax1, ay1, ax2, ay2 = a
bx1, by1, bx2, by2 = b
ix1, iy1 = max(ax1, bx1), max(ay1, by1)
ix2, iy2 = min(ax2, bx2), min(ay2, by2)
iw, ih = max(0.0, ix2 - ix1), max(0.0, iy2 - iy1)
inter = iw * ih
if inter <= 0:
return 0.0
union = (ax2 - ax1) * (ay2 - ay1) + (bx2 - bx1) * (by2 - by1) - inter
return float(inter / union) if union > 0 else 0.0
def umeyama(src: np.ndarray, dst: np.ndarray) -> np.ndarray:
"""Least-squares similarity transform (Umeyama 1991) mapping src -> dst.
Deterministic (no RANSAC), which keeps embeddings reproducible for the
same input frame. Returns a 2x3 affine matrix for cv2.warpAffine.
"""
src = np.asarray(src, dtype=np.float64)
dst = np.asarray(dst, dtype=np.float64)
n = src.shape[0]
src_mean, dst_mean = src.mean(0), dst.mean(0)
src_c, dst_c = src - src_mean, dst - dst_mean
cov = dst_c.T @ src_c / n
u, s, vt = np.linalg.svd(cov)
d = np.ones(2)
if np.linalg.det(u) * np.linalg.det(vt) < 0:
d[1] = -1.0
rot = u @ np.diag(d) @ vt
var_src = (src_c ** 2).sum() / n
scale = (s * d).sum() / var_src if var_src > 1e-12 else 1.0
t = dst_mean - scale * rot @ src_mean
return np.hstack([scale * rot, t.reshape(2, 1)]).astype(np.float32)
def align_face(image: np.ndarray, kps: np.ndarray, size: int = 112) -> np.ndarray:
"""Warp a full frame to a canonical `size`x`size` face chip using the
5 detected landmarks (full-frame coordinates — the whole point is that
landmarks and image are in the SAME coordinate space)."""
template = ARCFACE_TEMPLATE * (size / 112.0)
m = umeyama(np.asarray(kps, dtype=np.float32), template)
return cv2.warpAffine(image, m, (size, size), borderValue=0)

28
behavision/log.py Normal file
View File

@@ -0,0 +1,28 @@
"""Central logging setup: console + rotating file, no print() anywhere."""
from __future__ import annotations
import logging
import logging.handlers
from pathlib import Path
_FORMAT = "%(asctime)s %(levelname)-7s %(name)s: %(message)s"
def setup_logging(level: str = "INFO", data_dir: "Path | None" = None) -> None:
root = logging.getLogger()
if root.handlers: # already configured (tests, reload)
return
root.setLevel(getattr(logging, level.upper(), logging.INFO))
console = logging.StreamHandler()
console.setFormatter(logging.Formatter(_FORMAT))
root.addHandler(console)
if data_dir is not None:
log_dir = Path(data_dir) / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
fileh = logging.handlers.RotatingFileHandler(
log_dir / "behavision.log", maxBytes=5_000_000, backupCount=3,
encoding="utf-8")
fileh.setFormatter(logging.Formatter(_FORMAT))
root.addHandler(fileh)

119
behavision/model_assets.py Normal file
View File

@@ -0,0 +1,119 @@
"""Model acquisition: download YuNet, copy reusable models from the old
projects on this machine when present. Idempotent — safe to re-run."""
from __future__ import annotations
import logging
import shutil
import urllib.request
from pathlib import Path
log = logging.getLogger(__name__)
YUNET_URL = ("https://github.com/opencv/opencv_zoo/raw/main/models/"
"face_detection_yunet/face_detection_yunet_2023mar.onnx")
BUFFALO_SC_URL = ("https://github.com/deepinsight/insightface/releases/"
"download/v0.7/buffalo_sc.zip")
RECOGNIZERS = ["adaface_ir101.onnx", "adaface_ir50.onnx", "w600k_r50.onnx",
"arcface_int8.onnx", "w600k_mbf.onnx", "arcface.onnx"]
# Known locations of reusable models from the previous projects.
_LEGACY_MODEL_DIRS = [
Path(r"D:\NEARLE\WOrking now\RTSP_16072025\pattern_reg\models"),
]
BUFFALO_L_URL = ("https://github.com/deepinsight/insightface/releases/"
"download/v0.7/buffalo_l.zip")
# target filename -> legacy filename
_COPY_MAP = {
"arcface.onnx": "arcface.onnx",
"age_deploy.prototxt": "age_deploy.prototxt",
"age_net.caffemodel": "age_net.caffemodel",
"gender_deploy.prototxt": "gender_deploy.prototxt",
"gender_net.caffemodel": "gender_net.caffemodel",
"emotion-ferplus-8.onnx": "emotion-ferplus-8.onnx",
}
def setup_models(models_dir: Path) -> "list[str]":
"""Ensure all model files exist in models_dir. Returns missing ones."""
models_dir = Path(models_dir)
models_dir.mkdir(parents=True, exist_ok=True)
yunet = models_dir / "face_detection_yunet_2023mar.onnx"
if not yunet.exists():
log.info("downloading YuNet face detector (~230 KB)...")
tmp = yunet.with_suffix(".part")
urllib.request.urlretrieve(YUNET_URL, tmp)
tmp.rename(yunet)
log.info("YuNet saved to %s", yunet)
for target_name, legacy_name in _COPY_MAP.items():
target = models_dir / target_name
if target.exists():
continue
for legacy_dir in _LEGACY_MODEL_DIRS:
src = legacy_dir / legacy_name
if src.exists():
log.info("copying %s from %s ...", legacy_name, legacy_dir)
shutil.copy2(src, target)
break
# Any one recognizer is enough; get the lightweight MobileFaceNet if
# none is present (13 MB, loads reliably on low-memory machines).
if not any((models_dir / n).exists() for n in RECOGNIZERS):
log.info("downloading MobileFaceNet recognizer (buffalo_sc, ~15 MB)...")
import io
import zipfile
with urllib.request.urlopen(BUFFALO_SC_URL) as resp:
payload = io.BytesIO(resp.read())
with zipfile.ZipFile(payload) as zf, \
zf.open("w600k_mbf.onnx") as src, \
open(models_dir / "w600k_mbf.onnx", "wb") as dst:
shutil.copyfileobj(src, dst)
log.info("w600k_mbf.onnx saved")
# buffalo_l carries both the modern gender+age net (1.3 MB) and the
# ResNet50 recognizer (~166 MB, IJB-C 97.25 vs MobileFaceNet's 95.02).
# One 275 MB download serves both, so fetch it once and take what is
# missing. Optional: failure here must never block the pipeline.
wanted = {name: models_dir / name
for name in ("genderage.onnx", "w600k_r50.onnx")
if not (models_dir / name).exists()}
if wanted:
try:
log.info("downloading %s from the buffalo_l bundle (~275 MB "
"one-time download)...", ", ".join(wanted))
import zipfile
tmp = models_dir / "buffalo_l.zip.part"
urllib.request.urlretrieve(BUFFALO_L_URL, tmp)
with zipfile.ZipFile(tmp) as zf:
for name, target in wanted.items():
member = next((n for n in zf.namelist()
if n.endswith(name)), None)
if member is None:
log.warning("%s not found in bundle", name)
continue
part = target.with_suffix(".part")
with zf.open(member) as src, open(part, "wb") as dst:
shutil.copyfileobj(src, dst)
part.rename(target) # never leave a half-written model
log.info("%s saved", name)
tmp.unlink()
except Exception:
log.warning("buffalo_l download failed - falling back to the "
"models already present", exc_info=True)
missing = []
if not (models_dir / "face_detection_yunet_2023mar.onnx").exists():
missing.append("face_detection_yunet_2023mar.onnx")
if not any((models_dir / n).exists() for n in RECOGNIZERS):
missing.append("a recognition model (any of: %s)" % ", ".join(RECOGNIZERS))
optional_missing = [n for n in _COPY_MAP
if not (models_dir / n).exists() and n not in missing]
if optional_missing:
log.warning("optional attribute models missing (age/gender/emotion "
"will be skipped): %s", ", ".join(optional_missing))
return missing

146
behavision/paths.py Normal file
View File

@@ -0,0 +1,146 @@
"""Where the code lives versus where the code may write.
In a checkout these are the same directory, which is why everything resolved
against the repo root until now. Installed, they are not: the code sits under
`Program Files`, which is read-only for a normal user and for a service running
as LocalSystem, while the database, logs, camera list and downloaded models all
have to be written somewhere that survives an upgrade.
Three roots, resolved in one place so nothing else has to know it is frozen:
- `install_root()` — the code and the bundled default config. Read-only.
- `state_root()` — everything we write. `%PROGRAMDATA%\\Behavision` when
frozen on Windows.
- `config_path()` — the YAML actually loaded.
Models live under `state_root()`, not next to the code: they are ~200 MB and
are downloaded on first run rather than bundled (a 300 MB installer that has to
be re-signed for a model change is a bad trade), so they must land somewhere
writable.
`BEHAVISION_DATA_DIR` and `BEHAVISION_CONFIG` override everything, which is
what makes the installed layout testable from a checkout and lets one machine
run two instances.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
APP_NAME = "Behavision"
def is_frozen() -> bool:
"""True inside a PyInstaller bundle."""
return bool(getattr(sys, "frozen", False))
def install_root() -> Path:
"""Directory holding the code and bundled data files.
Frozen, that is the folder containing the .exe — PyInstaller's one-folder
layout — not `_MEIPASS`, which for onefile is a temp dir that vanishes.
"""
if is_frozen():
return Path(sys.executable).resolve().parent
return Path(__file__).resolve().parent.parent
def _os_family() -> str:
"""Which install layout applies.
A seam, not decoration: a test cannot monkeypatch `os.name` to exercise the
Windows layout on another host, because `pathlib` dispatches on it and
every `Path()` in the process starts raising.
"""
if os.name == "nt":
return "windows"
if sys.platform == "darwin":
return "macos"
return "linux"
def state_root() -> Path:
"""Directory we may write to. Created by the caller, not here."""
override = os.environ.get("BEHAVISION_DATA_DIR", "").strip()
if override:
return Path(override).expanduser().resolve()
if not is_frozen():
# A checkout keeps everything together; that is the whole convenience
# of developing from one.
return install_root()
family = _os_family()
if family == "windows":
base = os.environ.get("PROGRAMDATA") or r"C:\ProgramData"
return Path(base) / APP_NAME
if family == "macos":
return Path.home() / "Library" / "Application Support" / APP_NAME
return Path(
os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share"
) / APP_NAME.lower()
def config_path() -> Path:
"""The YAML to load.
An installed system must be configurable without editing anything under
`Program Files`, so a copy in the state root wins over the bundled one.
`ensure_config()` puts it there on first run.
"""
override = os.environ.get("BEHAVISION_CONFIG", "").strip()
if override:
return Path(override).expanduser().resolve()
local = state_root() / "config" / "default.yaml"
if local.is_file():
return local
return install_root() / "config" / "default.yaml"
def env_file() -> "Path | None":
"""`.env`, preferring the writable copy. Returns None when there is none —
it is optional, and an installed system keeps its secrets in the config
and the camera store instead."""
for candidate in (state_root() / ".env", install_root() / ".env"):
if candidate.is_file():
return candidate
return None
def ensure_config() -> Path:
"""Seed an editable config in the state root on first run, and return the
path that will be loaded.
Copied, never symlinked, and never overwritten: an upgrade must not
silently revert an operator's thresholds.
"""
override = os.environ.get("BEHAVISION_CONFIG", "").strip()
if override:
return Path(override).expanduser().resolve()
local = state_root() / "config" / "default.yaml"
if local.is_file():
return local
bundled = install_root() / "config" / "default.yaml"
if not bundled.is_file():
# Nothing to seed. Return the bundled path so the caller's "no such
# file" names the place the file was supposed to be.
return bundled
if local.parent.exists() and local.resolve() == bundled.resolve():
# A checkout: install root and state root are the same directory, so
# the "copy" would be a file onto itself.
return bundled
local.parent.mkdir(parents=True, exist_ok=True)
local.write_text(bundled.read_text(encoding="utf-8"), encoding="utf-8")
return local
def describe() -> dict:
"""For /api/health and the tray - "where is my database" must be
answerable without reading the source."""
return {
"frozen": is_frozen(),
"install_root": str(install_root()),
"state_root": str(state_root()),
"config": str(config_path()),
}

167
behavision/recognition.py Normal file
View File

@@ -0,0 +1,167 @@
"""ArcFace embedding + face quality assessment.
Preprocessing contract (this is where the old codebase broke recognition):
aligned 112x112 BGR chip -> [RGB if the model wants it] ->
(x - 127.5) / 127.5 -> NCHW float32.
Exactly one colour conversion, the normalisation ArcFace was trained with,
and L2-normalised output so cosine similarity is a plain dot product.
Channel order is per-model (see color_order_for): ArcFace/InsightFace want
RGB, AdaFace wants BGR. Same scaling, opposite channel order, and no error
if you get it wrong — hence the explicit table.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Optional
import cv2
import numpy as np
from .geometry import align_face
log = logging.getLogger(__name__)
EMBEDDING_DIM = 512
# Tried in order; first one that exists AND loads wins. The lightweight
# MobileFaceNet (13 MB, same WebFace600K training data) sits before the
# 260 MB r100 export because a model that loads on every boot beats a
# marginally more accurate one that fails under memory pressure — and
# embeddings from different models are incompatible, so boot-to-boot
# consistency matters. Pin one explicitly via recognition config if needed.
MODEL_CANDIDATES = [
"adaface_ir101.onnx", # best, ~250 MB - only loads on a roomy machine
"adaface_ir50.onnx", # ~170 MB, quality-adaptive: best for blur/low light
"w600k_r50.onnx", # ~166 MB, IJB-C 97.25 vs mbf's 95.02
"arcface_int8.onnx",
"w600k_mbf.onnx", # 13 MB, always loads
"arcface.onnx", # r100, 249 MB
]
# Channel order each family was trained on. InsightFace/ArcFace exports expect
# RGB; AdaFace expects BGR (mean=0.5/std=0.5, which is the same (x-127.5)/127.5
# scaling — ONLY the channel order differs). Getting it wrong raises nothing.
# Measured on this camera with w600k_r50: the same face chip encoded RGB vs
# BGR cross-matches at 0.945, so it is a mild perturbation rather than a
# catastrophe (faces are low-saturation, so R and B correlate). Still declared
# per model: it costs one lookup, it is the documented contract each model was
# trained under, and it removes a needless source of drift near the 0.42
# decision boundary.
BGR_MODELS = ("adaface",)
DEFAULT_COLOR_ORDER = "RGB"
def color_order_for(model_name: str) -> str:
name = model_name.lower()
return "BGR" if any(tag in name for tag in BGR_MODELS) else DEFAULT_COLOR_ORDER
class ArcFaceEncoder:
def __init__(self, models_dir: Path, model_file: str = "",
color_order: str = ""):
import onnxruntime as ort
candidates = [model_file] if model_file else MODEL_CANDIDATES
providers = ort.get_available_providers()
self.session = None
for name in candidates:
model_path = Path(models_dir) / name
if not model_path.exists():
continue
try:
self.session = ort.InferenceSession(str(model_path),
providers=providers)
except Exception:
# Graph optimization of a large model needs a big transient
# allocation; retry unoptimized before giving up on it.
log.warning("%s: optimized load failed, retrying without "
"graph optimization (low memory?)", name)
try:
so = ort.SessionOptions()
so.graph_optimization_level = (
ort.GraphOptimizationLevel.ORT_DISABLE_ALL)
so.enable_mem_pattern = False
self.session = ort.InferenceSession(
str(model_path), sess_options=so, providers=providers)
except Exception:
log.warning("%s: unusable on this machine, trying next "
"candidate", name)
continue
self.model_name = model_path.stem
break
if self.session is None:
raise FileNotFoundError(
f"no usable recognition model in {models_dir} "
f"(tried {', '.join(candidates)}) - "
"run: python -m behavision setup-models")
# Explicit config wins; otherwise infer from the model family.
self.color_order = (color_order or color_order_for(self.model_name)).upper()
if self.color_order not in ("RGB", "BGR"):
raise ValueError(f"color_order must be RGB or BGR, got {color_order!r}")
inp = self.session.get_inputs()[0]
self.input_name = inp.name
# Introspect instead of assuming: works for 112x112 r50/r100/mbf exports.
self.size = inp.shape[-1] if isinstance(inp.shape[-1], int) else 112
self.output_name = self.session.get_outputs()[0].name
log.info("recognition model '%s' loaded (input %sx%s, %s, providers=%s)",
self.model_name, self.size, self.size, self.color_order,
providers)
def encode_chip(self, chip_bgr: np.ndarray) -> Optional[np.ndarray]:
"""Embed an already-aligned BGR chip. Returns unit-norm float32[512]."""
if chip_bgr is None or chip_bgr.size == 0:
return None
if chip_bgr.shape[:2] != (self.size, self.size):
chip_bgr = cv2.resize(chip_bgr, (self.size, self.size))
# Exactly one colour conversion, and only when the model wants RGB.
chip = (cv2.cvtColor(chip_bgr, cv2.COLOR_BGR2RGB)
if self.color_order == "RGB" else chip_bgr)
blob = ((chip.astype(np.float32) - 127.5) / 127.5).transpose(2, 0, 1)[None]
emb = self.session.run([self.output_name], {self.input_name: blob})[0][0]
emb = np.asarray(emb, dtype=np.float32).ravel()
norm = float(np.linalg.norm(emb))
if norm < 1e-6: # degenerate output — never store or match this
return None
return emb / norm
def encode(self, frame_bgr: np.ndarray, kps: np.ndarray) -> Optional[np.ndarray]:
"""Align (full-frame landmarks) then embed."""
chip = align_face(frame_bgr, kps, size=self.size)
return self.encode_chip(chip)
def face_quality(frame: np.ndarray, box, kps: np.ndarray) -> float:
"""0..1 quality score used to gate enrollment. Every term is clamped so
the weighted sum stays interpretable (the old code's size term made its
own threshold unreachable)."""
x1, y1, x2, y2 = box
crop = frame[y1:y2, x1:x2]
if crop.size == 0:
return 0.0
gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY)
sharpness = min(1.0, cv2.Laplacian(gray, cv2.CV_64F).var() / 250.0)
size_score = min(1.0, min(x2 - x1, y2 - y1) / 112.0)
mean_b = float(gray.mean())
if 60.0 <= mean_b <= 190.0:
brightness = 1.0
elif mean_b < 60.0:
brightness = max(0.0, mean_b / 60.0)
else:
brightness = max(0.0, (255.0 - mean_b) / 65.0)
# Frontality: nose tip should sit near the horizontal midpoint of the
# eyes; offset is normalised by inter-eye distance.
eye_l, eye_r, nose = kps[0], kps[1], kps[2]
eye_dist = float(np.linalg.norm(eye_r - eye_l))
if eye_dist < 1.0:
frontality = 0.0
else:
mid_x = (eye_l[0] + eye_r[0]) / 2.0
frontality = max(0.0, 1.0 - 2.0 * abs(nose[0] - mid_x) / eye_dist)
score = (0.35 * sharpness + 0.25 * size_score
+ 0.15 * brightness + 0.25 * frontality)
return float(max(0.0, min(1.0, score)))

View File

@@ -0,0 +1,552 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Behavision</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; margin: 0; }
body { font: 14px/1.5 system-ui, sans-serif; background: #101418;
color: #dde3ea; padding: 1.25rem; }
h1 { font-size: 1.15rem; margin-bottom: 1rem; letter-spacing: .02em; }
h1 small { color: #7b8794; font-weight: 400; margin-left: .5rem; }
.grid { display: grid; grid-template-columns: 2fr 1fr; gap: 1rem; }
@media (max-width: 900px) { .grid { grid-template-columns: 1fr; } }
.card { background: #171d24; border: 1px solid #232c36;
border-radius: 10px; padding: 1rem; }
.card h2 { font-size: .8rem; text-transform: uppercase; color: #7b8794;
letter-spacing: .08em; margin-bottom: .75rem; }
img.feed { width: 100%; border-radius: 6px; background: #000;
min-height: 240px; }
table { width: 100%; border-collapse: collapse; }
td, th { padding: .35rem .5rem; text-align: left;
border-bottom: 1px solid #232c36; }
th { color: #7b8794; font-weight: 500; font-size: .78rem; }
.muted { color: #7b8794; }
ul#events { list-style: none; max-height: 380px; overflow-y: auto; }
ul#events li { padding: .4rem 0; border-bottom: 1px solid #232c36; }
.tag { display: inline-block; padding: .05rem .45rem; border-radius: 99px;
font-size: .72rem; margin-right: .4rem; }
.tag.new { background: #2b4e77; } .tag.seen { background: #2e5c3a; }
.tag.cam { background: #5a4a2a; }
.tag.miss { background: #6b3030; }
.tag.merge { background: #4a3a6b; }
.bar { display: flex; height: 8px; border-radius: 4px; overflow: hidden;
margin: .35rem 0 .5rem; background: #222; }
.bar i { display: block; }
.warn { color: #e0a33a; }
.pl-row { margin-bottom: .9rem; }
.pl-row b { font-weight: 600; }
.legend { font-size: .72rem; }
.legend span { margin-right: .7rem; white-space: nowrap; }
.btn { background: #2a3446; color: #cfd8e3; border: 1px solid #3a4658;
border-radius: 4px; padding: .25rem .6rem; cursor: pointer;
font: inherit; font-size: .78rem; }
.btn:hover { background: #35415a; }
.btn[disabled] { opacity: .5; cursor: default; }
.btn.primary { background: #2b4e77; border-color: #3a6291; }
.btn.danger { background: #4a2626; border-color: #6b3030; }
.card h2 .btn { float: right; margin-top: -.15rem; text-transform: none;
letter-spacing: 0; }
.cam-row { display: flex; align-items: center; gap: .5rem;
padding: .4rem 0; border-bottom: 1px solid #1e2430; }
.cam-row:last-child { border-bottom: 0; }
.cam-row .grow { flex: 1; min-width: 0; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; }
.pill { font-size: .7rem; padding: .05rem .45rem; border-radius: 99px; }
.pill.up { background: #2e5c3a; } .pill.down { background: #6b3030; }
.fields { display: grid; grid-template-columns: 1fr 1fr; gap: .5rem .75rem;
margin-bottom: .6rem; }
.fields label { display: block; font-size: .72rem; color: #7b8794;
margin-bottom: .15rem; }
.fields .wide { grid-column: 1 / -1; }
.fields input { width: 100%; background: #101418; color: #dde3ea;
border: 1px solid #2b3543; border-radius: 4px;
padding: .3rem .45rem; font: inherit; font-size: .82rem; }
.fields input:disabled { color: #7b8794; }
#cam-form { border-top: 1px solid #232c36; margin-top: .6rem;
padding-top: .75rem; }
#wizard { position: fixed; inset: 0; background: #000a;
display: flex; align-items: center; justify-content: center;
padding: 1rem; z-index: 20; }
/* An author `display` beats the UA stylesheet's `[hidden] { display: none }`,
so without this the placement wizard sits open over the dashboard on every
load - the modal is toggled by the `hidden` property, not by a class. */
#wizard[hidden] { display: none; }
#wizard .panel { background: #171d24; border: 1px solid #2b3543;
border-radius: 10px; padding: 1.25rem; max-width: 460px;
width: 100%; }
#wizard h3 { font-size: .95rem; margin-bottom: .5rem; }
#wizard .advice { margin: .6rem 0 .9rem; padding-left: 1.1rem;
font-size: .84rem; color: #b8c2ce; }
#wizard .advice li { margin-bottom: .3rem; }
.verdict { font-size: .95rem; font-weight: 600; margin: .4rem 0; }
.v-good { color: #4caf7d; } .v-marginal { color: #e0a33a; }
.v-poor, .v-artifact { color: #e05c5c; }
.v-no_faces, .v-inconclusive { color: #e0a33a; }
.progress { height: 6px; border-radius: 3px; background: #222;
overflow: hidden; margin: .6rem 0; }
.progress i { display: block; height: 100%; background: #3a6291; }
#cam-test { margin-top: .6rem; font-size: .82rem; }
#cam-test img { width: 100%; border-radius: 6px; margin-top: .4rem; }
.ok { color: #4caf7d; }
.err { color: #e05c5c; }
.dup { display: flex; align-items: center; gap: .5rem;
padding: .35rem 0; border-bottom: 1px solid #1e2430; }
.dup:last-child { border-bottom: 0; }
.dup .grow { flex: 1; min-width: 0; }
.dup button { background: #2a3446; color: #cfd8e3; border: 1px solid #3a4658;
border-radius: 4px; padding: .2rem .55rem; cursor: pointer;
font: inherit; font-size: .78rem; }
.dup button:hover { background: #35415a; }
.dup button[disabled] { opacity: .5; cursor: default; }
.sim { font-variant-numeric: tabular-nums; }
.dot { display: inline-block; width: .55rem; height: .55rem;
border-radius: 50%; margin-right: .25rem; vertical-align: middle; }
</style>
</head>
<body>
<h1>Behavision <small id="status">connecting…</small></h1>
<div class="grid">
<div>
<div class="card"><h2>Live</h2><div id="feeds"></div></div>
<div class="card" style="margin-top:1rem">
<h2>Cameras <button class="btn" id="cam-new">+ Add camera</button></h2>
<div id="cam-list" class="muted">none configured</div>
<div id="cam-form" hidden>
<div class="fields">
<div><label for="f-id">Camera id</label>
<input id="f-id" placeholder="entrance"></div>
<div><label for="f-host">Host / IP</label>
<input id="f-host" placeholder="192.168.0.138"></div>
<div><label for="f-port">Port</label>
<input id="f-port" type="number" value="554"></div>
<div><label for="f-path">Stream path</label>
<input id="f-path" placeholder="/ch0_0.264"></div>
<div><label for="f-username">Username</label>
<input id="f-username" placeholder="admin"></div>
<div><label for="f-password">Password</label>
<input id="f-password" type="password" autocomplete="new-password"></div>
<div><label for="f-max_width">Max width (px)</label>
<input id="f-max_width" type="number" value="1280"></div>
<div><label for="f-webcam">Webcam index (instead of RTSP)</label>
<input id="f-webcam" type="number" placeholder="0"></div>
<div class="wide"><label for="f-url">Full URL (overrides host/port/path)</label>
<input id="f-url" placeholder="rtsp://user:pass@host:554/stream"></div>
</div>
<button class="btn" id="cam-test-btn">Test connection</button>
<button class="btn primary" id="cam-save">Save</button>
<button class="btn" id="cam-cancel">Cancel</button>
<div id="cam-test"></div>
</div>
</div>
</div>
<div>
<div class="card"><h2>Recent events</h2><ul id="events"></ul></div>
<div class="card" style="margin-top:1rem"><h2>Recognition health</h2>
<div id="pipeline" class="muted">no tracks yet</div></div>
<div class="card" style="margin-top:1rem"><h2>Possible duplicates</h2>
<div id="dupes" class="muted">none found</div></div>
<div class="card" style="margin-top:1rem"><h2>People</h2>
<table><thead><tr><th>Label</th><th>Seen</th><th>Last</th></tr></thead>
<tbody id="people"></tbody></table>
</div>
</div>
</div>
<div id="wizard" hidden><div class="panel">
<h3 id="wz-title">Camera placement check</h3>
<div id="wz-body"></div>
<button class="btn" id="wz-close">Close</button>
<button class="btn primary" id="wz-again" hidden>Run again</button>
<button class="btn" id="wz-loosen" hidden>Use this camera's own gate</button>
</div></div>
<script>
const feeds = document.getElementById('feeds');
const fmtTime = ts => new Date(ts * 1000).toLocaleTimeString();
// Identity labels are user-supplied (PATCH /api/identities/{id}) and camera
// ids come from config, so every value interpolated into innerHTML below is
// escaped first. Without this a label like <img onerror=...> is stored XSS.
const esc = v => String(v ?? '').replace(/[&<>"']/g,
c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
// `camera_id` only exists on cameras whose worker is running - it comes from
// worker.stats(). A stored camera that failed to start has only `id`, and
// using the wrong one put the string "undefined" in the stream URL.
let feedKey = null;
function renderFeeds(cams) {
const key = cams.map(c => c.id).join('|');
// Never re-create a live <img>: assigning src restarts the MJPEG stream, so
// rebuilding on every 3s refresh would make every feed flicker forever.
if (key === feedKey) return;
feedKey = key;
feeds.innerHTML = cams.map(cam => `<figure><img class="feed"
src="/api/cameras/${encodeURIComponent(cam.id)}/stream.mjpeg"
alt="${esc(cam.id)}"><figcaption class="muted">${esc(cam.id)}</figcaption>
</figure>`).join('') || '<span class="muted">no cameras configured</span>';
}
async function boot() {
refresh();
setInterval(refresh, 3000);
}
// Outcome of every finished track. This panel exists because the pipeline
// was previously unfalsifiable from the UI: a camera rejecting every visitor
// on quality looked exactly like a camera nobody walked past.
const OUTCOMES = [
['recognized', '#2e5c3a', 'returning'],
['enrolled', '#2b4e77', 'new'],
['rejected_quality', '#8a3b3b', 'face too poor to enroll'],
['gave_up_ambiguous','#8a6a2a', 'never settled'],
['ended_ambiguous', '#6a5a3a', 'left while unsure'],
['too_brief', '#444', 'gone too fast'],
['no_embedding', '#333', 'never encodable'],
];
function renderPipeline(c) {
const p = c.pipeline;
if (!p || !p.tracks_ended) {
return `<div class="pl-row"><b>${esc(c.camera_id)}</b>
<span class="muted"> — no finished tracks yet</span></div>`;
}
const total = p.tracks_ended;
const bar = OUTCOMES.map(([key, color]) => {
const n = p.outcomes[key] || 0;
return n ? `<i style="width:${(n / total * 100).toFixed(1)}%;
background:${color}" title="${key}: ${n}"></i>` : '';
}).join('');
const legend = OUTCOMES.filter(([k]) => p.outcomes[k]).map(([k, color, human]) =>
`<span><i class="dot" style="background:${color}"></i>${esc(human)}
${esc(p.outcomes[k])}</span>`).join('');
const q = p.best_quality || {};
// The number that says the enrollment gate is wrong for this camera, as
// opposed to the camera being pointed somewhere nobody walks.
const below = q.fraction_below_gate;
const gateWarn = below >= 0.5
? `<div class="warn">⚠ ${(below * 100).toFixed(0)}% of faces are below the
enrollment quality gate — these visitors are seen and discarded.
Fix camera placement before touching thresholds.</div>` : '';
const spread = q.n
? `<div class="muted legend">face quality p05 ${esc(q.p05)} ·
median ${esc(q.p50)} · p95 ${esc(q.p95)}${
below != null ? ` · ${(below * 100).toFixed(0)}% under gate` : ''}</div>`
: '';
return `<div class="pl-row"><b>${esc(c.camera_id)}</b>
<span class="muted"> — ${esc(total)} finished tracks</span>
<div class="bar">${bar}</div>
<div class="muted legend">${legend}</div>${spread}${gateWarn}</div>`;
}
// One person enrolled twice. Merging is irreversible - nothing records which
// embedding came from which identity - so this only ever *suggests*, and the
// operator confirms. Pairs below enroll_threshold are not offered at all.
function renderDupes(pairs) {
if (!pairs.length) return '<span class="muted">none found</span>';
return pairs.map(p => {
// Merge the sparser record into the richer one, and a "Visitor N" into a
// named person, so the surviving identity is the one with more history.
const named = x => x.kind !== 'auto';
let [from, into] = named(p.a) && !named(p.b) ? [p.b, p.a]
: named(p.b) && !named(p.a) ? [p.a, p.b]
: p.a.sighting_count <= p.b.sighting_count ? [p.a, p.b] : [p.b, p.a];
return `<div class="dup">
<span class="grow">${esc(from.label)} <span class="muted">&rarr;</span>
${esc(into.label)}</span>
<span class="muted sim">${esc(p.similarity)}${p.confident ? '' : ' ?'}</span>
<button data-from="${esc(from.id)}" data-into="${esc(into.id)}"
data-desc="${esc(from.label)} into ${esc(into.label)}">Merge</button>
</div>`;
}).join('');
}
document.getElementById('dupes').addEventListener('click', async ev => {
const btn = ev.target.closest('button[data-from]');
if (!btn) return;
const {from, into, desc} = btn.dataset;
if (!confirm(`Merge ${desc}?\n\nThis cannot be undone.`)) return;
btn.disabled = true;
const send = force => fetch(`/api/identities/${encodeURIComponent(from)}/merge`,
{method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({into: Number(into), force})});
let r = await send(false);
if (r.status === 409) {
// The gallery's own numbers say these are different people. Show the
// measured similarity rather than a generic failure - overriding it is a
// decision, and the operator needs the number to make it.
const d = (await r.json()).detail || {};
if (!confirm(`${d.reason || 'Refused.'}\n\nMerge anyway?`)) {
btn.disabled = false; return;
}
r = await send(true);
}
if (!r.ok) alert('Merge failed.');
btn.disabled = false;
refresh();
});
// -- camera settings ------------------------------------------------------
// The API never returns a camera password - not masked, not empty-string-if-
// set, absent. So an edit sends `password` only when the user actually typed
// one; leaving it blank keeps whatever is stored.
const F = ['id', 'host', 'port', 'path', 'username', 'password', 'max_width',
'webcam', 'url'];
const fld = n => document.getElementById('f-' + n);
let editing = null; // camera id being edited, or null when adding
function renderCameras(cams) {
const el = document.getElementById('cam-list');
if (!cams.length) {
el.innerHTML = '<span class="muted">none configured</span>';
return;
}
el.innerHTML = cams.map(c => {
const live = c.connected === undefined ? null : !!c.connected;
const pill = live === null ? '<span class="pill muted">stopped</span>'
: `<span class="pill ${live ? 'up' : 'down'}">${live ? 'live' : 'offline'}</span>`;
return `<div class="cam-row">
<span class="grow"><b>${esc(c.id)}</b>
<span class="muted"> ${esc(c.url)}</span></span>
${pill}
<button class="btn" data-check="${esc(c.id)}">Check placement</button>
<button class="btn" data-edit="${esc(c.id)}">Edit</button>
<button class="btn danger" data-del="${esc(c.id)}">Delete</button>
</div>`;
}).join('');
}
function openForm(cam) {
editing = cam ? cam.id : null;
for (const n of F) fld(n).value = '';
fld('port').value = 554;
fld('max_width').value = 1280;
if (cam) {
for (const n of F) if (cam[n] !== null && cam[n] !== undefined) fld(n).value = cam[n];
fld('password').value = '';
fld('password').placeholder = cam.has_password ? '(unchanged)' : '';
} else {
fld('password').placeholder = '';
}
// The id is the store key and PATCH ignores it; showing it editable would
// imply a rename that silently does nothing.
fld('id').disabled = !!cam;
document.getElementById('cam-test').innerHTML = '';
document.getElementById('cam-form').hidden = false;
}
function closeForm() {
document.getElementById('cam-form').hidden = true;
editing = null;
}
function formBody() {
const body = {};
for (const n of F) {
const v = fld(n).value.trim();
if (v === '') continue; // blank = "leave alone", never "clear"
body[n] = (n === 'port' || n === 'max_width' || n === 'webcam')
? Number(v) : v;
}
return body;
}
async function testCamera() {
const btn = document.getElementById('cam-test-btn');
const out = document.getElementById('cam-test');
btn.disabled = true;
out.innerHTML = '<span class="muted">connecting…</span>';
try {
const r = await fetch('/api/cameras/test', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify(formBody())});
const d = await r.json();
if (!d.ok) {
out.innerHTML = `<span class="err">${esc(d.error || 'failed')}</span>`;
} else {
const scaled = d.downscaled_to
? ` <span class="muted">(downscaled to ${esc(d.downscaled_to)}px)</span>` : '';
out.innerHTML = `<span class="ok">connected — ${esc(d.width)}×${esc(d.height)}</span>${scaled}`
+ (d.snapshot ? `<img src="data:image/jpeg;base64,${esc(d.snapshot)}" alt="snapshot">` : '');
}
} catch (e) {
out.innerHTML = '<span class="err">test request failed</span>';
}
btn.disabled = false;
}
async function saveCamera() {
const btn = document.getElementById('cam-save');
const out = document.getElementById('cam-test');
const body = formBody();
if (!editing && !body.id) {
out.innerHTML = '<span class="err">camera id is required</span>';
return;
}
btn.disabled = true;
const r = editing
? await fetch(`/api/cameras/${encodeURIComponent(editing)}`, {
method: 'PATCH', headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body)})
: await fetch('/api/cameras', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body)});
btn.disabled = false;
if (!r.ok) {
let msg = `save failed (${r.status})`;
try { const d = await r.json(); msg = typeof d.detail === 'string' ? d.detail : msg; } catch (e) {}
out.innerHTML = `<span class="err">${esc(msg)}</span>`;
return;
}
closeForm();
feedKey = null; // a new or edited camera needs its feed rebuilt
refresh();
}
document.getElementById('cam-new').onclick = () => openForm(null);
document.getElementById('cam-cancel').onclick = closeForm;
document.getElementById('cam-test-btn').onclick = testCamera;
document.getElementById('cam-save').onclick = saveCamera;
document.getElementById('cam-list').addEventListener('click', async ev => {
const check = ev.target.closest('button[data-check]');
if (check) { wzStart(check.dataset.check); return; }
const edit = ev.target.closest('button[data-edit]');
if (edit) {
const cams = await (await fetch('/api/cameras')).json();
const cam = cams.find(c => c.id === edit.dataset.edit);
if (cam) openForm(cam);
return;
}
const del = ev.target.closest('button[data-del]');
if (!del) return;
const id = del.dataset.del;
if (!confirm(`Delete camera "${id}"? Recognition from it stops immediately.`)) return;
del.disabled = true;
await fetch(`/api/cameras/${encodeURIComponent(id)}`, {method: 'DELETE'});
if (editing === id) closeForm();
feedKey = null;
refresh();
});
// -- placement wizard -----------------------------------------------------
// The Office1 camera ran for weeks recognising almost nobody, and nothing
// looked broken. This makes that discoverable at install time instead of from
// a footfall report that was always zero.
const wz = document.getElementById('wizard');
let wzCamera = null, wzTimer = null, wzLast = null;
function wzShow(html) { document.getElementById('wz-body').innerHTML = html; }
function wzRender(d) {
wzLast = d;
const pct = Math.round(100 * (d.elapsed / d.seconds));
const q = d.quality || {};
const bar = `<div class="progress"><i style="width:${d.running ? pct : 100}%"></i></div>`;
const advice = (d.advice || []).map(a => `<li>${esc(a)}</li>`).join('');
const numbers = q.n ? `<div class="muted legend">faces ${esc(q.n)} ·
quality p05 ${esc(q.p05)} · median ${esc(q.p50)} · p95 ${esc(q.p95)} ·
gate ${esc(d.gate)} · below gate ${Math.round(100 * q.fraction_below_gate)}%</div>` : '';
wzShow(`<div class="verdict v-${esc(d.verdict)}">${esc(d.headline)}</div>
${bar}<ul class="advice">${advice}</ul>${numbers}`);
document.getElementById('wz-again').hidden = d.running;
// Loosening the gate is only ever offered for a marginal camera. For a poor
// one the answer is to move the camera: dropping the gate there converts a
// visible miss into an invisible wrong match, which is strictly worse.
document.getElementById('wz-loosen').hidden =
!(d.verdict === 'marginal' && q.p05 !== undefined);
}
async function wzPoll() {
const r = await fetch(`/api/cameras/${encodeURIComponent(wzCamera)}/commission`);
if (!r.ok) return;
const d = await r.json();
wzRender(d);
if (!d.running) { clearInterval(wzTimer); wzTimer = null; }
}
async function wzStart(id) {
wzCamera = id;
wz.hidden = false;
document.getElementById('wz-title').textContent = `Placement check — ${id}`;
wzShow('<span class="muted">starting…</span>');
const r = await fetch(`/api/cameras/${encodeURIComponent(id)}/commission`,
{method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({seconds: 25})});
if (!r.ok) { wzShow('<span class="err">could not start — is the camera running?</span>'); return; }
wzRender(await r.json());
if (wzTimer) clearInterval(wzTimer);
wzTimer = setInterval(wzPoll, 1000);
}
document.getElementById('wz-close').onclick = async () => {
if (wzTimer) { clearInterval(wzTimer); wzTimer = null; }
// Cancel rather than leave it running: a check still collecting after the
// installer walked away would keep changing the answer they just read.
if (wzCamera) await fetch(`/api/cameras/${encodeURIComponent(wzCamera)}/commission`,
{method: 'DELETE'});
wz.hidden = true; wzCamera = null;
};
document.getElementById('wz-again').onclick = () => wzStart(wzCamera);
document.getElementById('wz-loosen').onclick = async () => {
// Set this camera's gate just under the faces it actually sees. Per camera
// only - quality describes a view, and every camera writes into one gallery.
const gate = Math.max(0.1, Math.round((wzLast.quality.p05 - 0.02) * 100) / 100);
if (!confirm(`Set ${wzCamera} min_enroll_quality to ${gate}?\n\n` +
`Only this camera is affected.`)) return;
await fetch(`/api/cameras/${encodeURIComponent(wzCamera)}`, {
method: 'PATCH', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({tuning: {min_enroll_quality: gate}})});
wzStart(wzCamera);
};
async function refresh() {
try {
const [stats, events, people, dupes, camList] = await Promise.all([
(await fetch('/api/stats')).json(),
(await fetch('/api/events?limit=30')).json(),
(await fetch('/api/identities?limit=30')).json(),
(await fetch('/api/identities/duplicates?limit=10')).json(),
(await fetch('/api/cameras')).json(),
]);
renderFeeds(camList);
renderCameras(camList);
const cams = stats.cameras.map(c =>
`${c.camera_id}: ${c.connected ? 'live' : 'offline'}`).join(' · ');
// textContent, not innerHTML — no escaping needed here.
document.getElementById('status').textContent =
`${cams} · ${stats.gallery.identities} people · ${stats.gallery.sightings} sightings`;
document.getElementById('events').innerHTML = events.map(e => {
const cls = e.type === 'person.new' ? 'new'
: e.type === 'person.seen' ? 'seen'
: e.type === 'person.missed' ? 'miss'
: e.type === 'identity.merged' ? 'merge' : 'cam';
const who = e.data.label ? ` ${esc(e.data.label)}` : '';
// genderage.onnx reports an integer `age`; the Caffe fallback reports
// a bucketed `age_range`. Show whichever this backend produced.
const age = e.data.age ?? e.data.age_range;
const extra = e.data.gender ? ` · ${esc(e.data.gender)}${age != null ? ', ' + esc(age) : ''}${e.data.emotion ? ', ' + esc(e.data.emotion) : ''}` : '';
return `<li><span class="tag ${cls}">${esc(e.type)}</span>${who}
<span class="muted">${extra} · ${esc(e.camera_id)} · ${fmtTime(e.ts)}</span></li>`;
}).join('');
document.getElementById('pipeline').innerHTML =
stats.cameras.map(renderPipeline).join('');
document.getElementById('dupes').innerHTML = renderDupes(dupes);
document.getElementById('people').innerHTML = people.map(p =>
`<tr><td>${esc(p.label)}</td><td>${esc(p.sighting_count)}</td>
<td class="muted">${p.last_seen_at ? fmtTime(p.last_seen_at) : '—'}</td></tr>`
).join('');
} catch (err) {
document.getElementById('status').textContent = 'api unreachable';
}
}
boot();
</script>
</body>
</html>

119
behavision/tracking.py Normal file
View File

@@ -0,0 +1,119 @@
"""IoU-based multi-face tracker.
Purpose: turn per-frame detections into per-person *tracks* so identity is
decided once per visit, not once per frame (the old backend registered a
new user for every frame). Greedy IoU association is deliberate: faces move
slowly relative to frame rate, and determinism beats a heavier Kalman/
ByteTrack stack for this workload.
"""
from __future__ import annotations
import itertools
import time
from dataclasses import dataclass, field
from typing import Optional
import numpy as np
from .detection import Detection
from .geometry import iou
@dataclass
class Track:
id: int
box: tuple
kps: np.ndarray
score: float
quality: float = 0.0
best_quality: float = 0.0
hits: int = 1
misses: int = 0
created_at: float = field(default_factory=time.time)
updated_at: float = field(default_factory=time.time)
# identity resolution state
state: str = "pending" # pending | resolved | ambiguous | gave_up
# Embeddings are accumulated over multiple frames and averaged before
# any identity decision: single-frame embeddings under extreme pose /
# motion blur are unstable, the mean is not.
emb_sum: Optional[np.ndarray] = None
emb_count: int = 0
id_attempts: int = 0
# Set when THIS track minted the identity, so a terminal tally can tell
# a first-time visitor from a returning one without re-querying the store.
is_new: bool = False
# resolve() refused to enroll this face (quality below the gate). Counted
# rather than ignored: a mis-set gate and an empty room used to look the
# same from outside.
quality_skips: int = 0
last_attempt_ts: float = 0.0
last_reinforce_ts: float = 0.0
reinforcements: int = 0
identity_id: Optional[int] = None
label: Optional[str] = None
similarity: float = 0.0
attributes: dict = field(default_factory=dict)
# The best-quality face crop seen on this track, kept only when
# app.store_faces is on. One small array per live track, replaced rather
# than accumulated; None when images are off, which is the default.
best_face: Optional[np.ndarray] = None
best_face_quality: float = 0.0
attr_samples: list = field(default_factory=list)
class IouTracker:
def __init__(self, iou_threshold: float = 0.3, max_misses: int = 15):
self.iou_threshold = iou_threshold
self.max_misses = max_misses
self.tracks: list[Track] = []
self._ids = itertools.count(1)
def update(self, detections: "list[Detection]", now: "float | None" = None
) -> "tuple[list[Track], list[Track]]":
"""Associate detections to tracks. Returns (active, ended)."""
now = now or time.time()
# Greedy matching on IoU, best pairs first.
pairs = []
for ti, track in enumerate(self.tracks):
for di, det in enumerate(detections):
overlap = iou(track.box, det.box)
if overlap >= self.iou_threshold:
pairs.append((overlap, ti, di))
pairs.sort(reverse=True)
matched_tracks: set[int] = set()
matched_dets: set[int] = set()
for overlap, ti, di in pairs:
if ti in matched_tracks or di in matched_dets:
continue
matched_tracks.add(ti)
matched_dets.add(di)
track, det = self.tracks[ti], detections[di]
track.box = det.box
track.kps = det.kps
track.score = det.score
track.quality = det.quality
track.best_quality = max(track.best_quality, det.quality)
track.hits += 1
track.misses = 0
track.updated_at = now
new_tracks = [
Track(id=next(self._ids), box=det.box, kps=det.kps,
score=det.score, quality=det.quality,
best_quality=det.quality, created_at=now, updated_at=now)
for di, det in enumerate(detections) if di not in matched_dets
]
ended: list[Track] = []
alive: list[Track] = []
for ti, track in enumerate(self.tracks):
if ti not in matched_tracks:
track.misses += 1
if track.misses > self.max_misses:
ended.append(track)
else:
alive.append(track)
self.tracks = alive + new_tracks
return self.tracks, ended

102
config/default.yaml Normal file
View File

@@ -0,0 +1,102 @@
# Behavision configuration.
# ${VAR} placeholders are resolved from the environment (.env is loaded first).
app:
data_dir: data
models_dir: models
log_level: INFO
debug_faces: false # dump aligned chips to data/debug (diagnosis only)
# Write one face image per visit to data/outbox for the agent to upload.
# Off by default on purpose: with this off the machine holds no photographs,
# which is a data-protection position, not a missing feature.
store_faces: false
api:
host: 0.0.0.0
port: 8010
# HTTP Basic credentials for the dashboard and the whole JSON API.
# Leave blank and a credential is generated into
# data/api_credentials.txt on first boot (and logged) — a routable
# host is never served unauthenticated. Blank + host 127.0.0.1 is
# open, since it is unreachable from off-box.
username: ${BEHAVISION_API_USER}
password: ${BEHAVISION_API_PASSWORD}
cameras:
- id: cam1
# Either give a full `url` (must be percent-encoded yourself), or give
# parts below and the URL is built with proper encoding ('@' in the
# password is handled correctly).
url: ""
host: ${BEHAVISION_CAM1_HOST}
port: 554
path: /ch0_0.264
username: ${BEHAVISION_CAM1_USERNAME}
password: ${BEHAVISION_CAM1_PASSWORD}
# For quick testing without a camera, set `webcam: 0` to use a local
# webcam instead of RTSP.
webcam: null
# Per-camera overrides for the recognition gates. Anything left out uses
# the global `recognition:` block below. The gates describe a *view*, so
# an overhead corridor camera and an entrance camera at head height need
# different numbers — measure each with:
# python -m behavision calibrate --person NAME --seconds 25
# python -m behavision calibrate --report
# Quality is safe to loosen per camera (it only judges this view).
# match/enroll are not: every camera writes into one shared gallery, so a
# loose camera can merge two people into an identity a strict one trusts.
tuning:
min_enroll_quality: null
match_threshold: null
enroll_threshold: null
detection:
score_threshold: 0.82 # measured: frosted-glass false positives pass 0.75
nms_threshold: 0.3
min_face_px: 48 # ignore faces smaller than this (short side, px)
max_faces: 20
recognition:
# Cosine similarity on L2-normalised ArcFace embeddings.
match_threshold: 0.42 # >= this -> same person (higher = stricter)
enroll_threshold: 0.32 # < this -> safe to treat as a brand-new person
reinforce_threshold: 0.55
max_embeddings_per_identity: 5
auto_enroll: true
# Measured on this camera: real frontal faces score 0.70-0.82, glass
# blurs/silhouettes peak at 0.54 — 0.65 separates them cleanly.
min_enroll_quality: 0.65
sighting_cooldown_seconds: 30
tracking:
iou_threshold: 0.3
max_misses: 25 # frames a track survives without a detection
min_hits_for_id: 4 # frames before a track can be identified
min_embeddings_for_id: 3 # embeddings averaged before deciding identity
min_quality_to_encode: 0.35
max_id_attempts: 8
# Bounds how often an ambiguous track re-decides, not how often it
# encodes: embeddings still accumulate every frame, so the 8 attempts
# above span ~4s of genuinely different frames instead of ~0.3s.
id_retry_interval_seconds: 0.5
# Keep learning a person's other angles for the rest of their visit
# instead of freezing the identity on its first embedding.
reinforce_during_track: true
reinforce_interval_seconds: 1.0
attributes:
enabled: true # age / gender / emotion (needs optional models)
# Gate for collecting one age/gender/emotion sample. Separate from
# recognition.min_enroll_quality on purpose: that gate guards creating a
# permanent identity, this one only guards a measurement, and sharing it
# meant no track ever gathered the several samples the median needs.
min_quality: 0.35
events:
webhook_url: ${BEHAVISION_WEBHOOK_URL}
email:
smtp_host: ${BEHAVISION_SMTP_HOST}
smtp_port: ${BEHAVISION_SMTP_PORT}
username: ${BEHAVISION_SMTP_USER}
password: ${BEHAVISION_SMTP_PASSWORD}
to: ${BEHAVISION_SMTP_TO}
min_interval_seconds: 300

48
desktop/README.md Normal file
View File

@@ -0,0 +1,48 @@
# Behavision desktop
The store-facing app: a tray icon, a window, and the supervisor for the Python
recognition engine.
## Why one process, not three
The tray, the window and the supervisor all need the same state, and a user who
quits the tray expects recognition to stop. Splitting them means two things can
disagree about whether the engine is running.
It is deliberately **not** a Windows service. A service runs in session 0 and
cannot draw a tray icon — that is Windows session isolation, not a library
limitation. Spawning a child process also needs no elevation, while controlling
a service does, so this design never triggers UAC at runtime.
## Layout
main.go wails.Run, window options
app.go the methods bound to the frontend
tray.go fyne.io/systray — wails v2 has no tray of its own
icons.go tray icons generated at run time, not embedded
internal/local client for the engine on 127.0.0.1:8010
internal/cloud client for https://mcp.loyaly.ai
frontend/ React + Vite
The supervisor, durable spool, broker client and path resolution come from
`../agent/pkg/*` — the same tested code the headless agent runs, imported
rather than copied.
## Build
cd frontend && npm install && npm run build # then, from this directory:
wails build -platform windows/amd64
`wails build` needs the Wails CLI:
go install github.com/wailsapp/wails/v2/cmd/wails@v2.9.2
Without it, `go build` still type-checks everything **provided
`frontend/dist` exists** — the embed directive requires it.
## What the frontend talks to
Nothing is imported from generated bindings. `src/bridge.js` calls
`window.go.main.App.*` directly, so `npm run build` works without running
`wails generate`, and there is one place that handles "the engine is not
running yet" — the state every screen has to survive on a fresh install.

687
desktop/app.go Normal file
View File

@@ -0,0 +1,687 @@
// The methods bound to the frontend.
//
// Every one is a thin adapter: it talks to the local engine, the cloud, or the
// supervisor, and returns something JSON-shaped. No recognition logic lives
// here - the engine owns that, and duplicating any of it would give the UI a
// second opinion about who someone is.
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
agentbridge "github.com/loyaly/behavision-agent/pkg/bridge"
agentcameras "github.com/loyaly/behavision-agent/pkg/cameras"
agentcfg "github.com/loyaly/behavision-agent/pkg/config"
agentengine "github.com/loyaly/behavision-agent/pkg/engine"
agentmqtt "github.com/loyaly/behavision-agent/pkg/mqtt"
agentpaths "github.com/loyaly/behavision-agent/pkg/paths"
agentspool "github.com/loyaly/behavision-agent/pkg/spool"
"github.com/loyaly/behavision-desktop/internal/cloud"
"github.com/loyaly/behavision-desktop/internal/local"
)
type App struct {
ctx context.Context
mu sync.RWMutex
cfg agentcfg.Config
cloud *cloud.Client
local *local.Client
sup *agentengine.Supervisor
spool *agentspool.Spool
bridge *agentbridge.Bridge
broker *agentmqtt.Client
stopBridge func()
hookURL string
// Set once the operator logs in. Until then the UI shows the login sheet
// and nothing else is reachable.
onSessionChange func(bool)
}
func NewApp() *App {
cfg, _ := agentcfg.Load(agentpaths.AgentConfig())
// The engine invents its own Basic credential when none is configured,
// which is the default. Without this every call this app makes to the
// engine - health, cameras, the live feed - comes back 401, and the tray
// shows a healthy process the UI cannot talk to.
cfg = cfg.WithEngineCredentials(agentpaths.APICredentials())
base := cfg.APIBase
if base == "" {
base = "http://127.0.0.1:8010"
}
return &App{
cfg: cfg,
cloud: cloud.New(envOr("BEHAVISION_CLOUD", "https://mcp.loyaly.ai")),
local: local.New(base, cfg.APIUser, cfg.APIPassword),
}
}
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
_ = agentpaths.EnsureState()
// A saved session means a shop PC that rebooted overnight comes back
// working instead of waiting for someone to log in.
if a.cfg.SessionToken != "" {
a.cloud.SetSession(cloud.Session{
Token: a.cfg.SessionToken, RefreshToken: a.cfg.SessionRefresh,
User: cloud.User{Email: a.cfg.SessionEmail},
})
}
// The server rotates the refresh token every time it is used, so a PC that
// refreshes and then reboots would come back holding one the server has
// already invalidated - it would look exactly like a normal expiry, twelve
// hours after anyone last touched the machine.
a.cloud.OnRefresh(func(s cloud.Session) { a.persistSession(s) })
exe := a.cfg.EngineExe
if exe != "" && !filepath.IsAbs(exe) {
exe = filepath.Join(agentpaths.InstallRoot(), exe)
}
logFile, _ := agentengine.LogFile(agentpaths.EngineLog())
a.sup = agentengine.New(agentengine.Options{
Command: func(c context.Context) *exec.Cmd {
cmd := exec.CommandContext(c, exe, a.cfg.EngineArgs...)
// How the engine learns where to post its detections. Its config
// already reads `events.webhook_url: ${BEHAVISION_WEBHOOK_URL}`,
// and python-dotenv does not override a variable the process
// already has, so this needs no new endpoint and no fixed port.
//
// Read here rather than captured, because the bridge picks its
// port after this closure is built and a restarted engine has to
// be told again. Without it the engine recognised people and the
// bridge received nothing: a claimed shop PC published heartbeats
// and zero visits.
cmd.Env = append(os.Environ(), "BEHAVISION_WEBHOOK_URL="+a.webhookURL())
return cmd
},
LogWriter: logFile,
HealthURL: strings.TrimRight(a.cfg.APIBase, "/") + "/api/health",
StatsURL: strings.TrimRight(a.cfg.APIBase, "/") + "/api/stats",
User: a.cfg.APIUser, Password: a.cfg.APIPassword,
})
a.startPipeline(ctx)
}
// webhookURL is the loopback address the bridge is listening on, or empty
// before it has started.
func (a *App) webhookURL() string {
a.mu.RLock()
defer a.mu.RUnlock()
return a.hookURL
}
// startPipeline connects detections to the server: the engine posts events to
// a loopback webhook, the bridge queues them durably, and the pump drains the
// queue to the broker. Without it the engine recognises people and nothing
// ever leaves the PC.
func (a *App) startPipeline(ctx context.Context) {
logger := log.New(os.Stdout, "", log.LstdFlags)
// A PC set up on its own has nothing to report to, and unlike an unclaimed
// one it never will. Queuing anyway would write up to SpoolMax visits to
// disk - each carrying a face template, which is biometric personal data -
// into a queue nothing is ever going to drain. Recognition, the gallery
// and the cameras are unaffected: they are the engine's, not the pump's.
//
// Deliberately distinct from the unclaimed case below, where the queue is
// exactly right: that PC is waiting for credentials, and its footfall from
// the day it was installed should survive until they arrive.
if a.cfg.Standalone && !a.cfg.Configured() {
logger.Print("standalone: recognition runs locally, nothing is reported")
go a.startLocalCameras(ctx, logger)
return
}
q, err := agentspool.Open(agentpaths.SpoolDir(), a.cfg.SpoolMax)
if err != nil {
logger.Printf("spool unavailable, detections will not be recorded: %v", err)
return
}
a.spool = q
// Created before the bridge and handed over unconditionally: an unclaimed
// PC has no pump reading it, which is harmless - the single slot fills
// once and later rings are dropped.
waker := agentmqtt.NewWaker()
a.bridge = &agentbridge.Bridge{
Queue: q,
Wake: waker.Wake,
Embeddings: agentbridge.NewEngineEmbeddings(
a.cfg.APIBase, a.cfg.APIUser, a.cfg.APIPassword),
TopicPrefix: topicPrefix(a.cfg),
Log: logger,
// Uploads face images through a URL the server mints, so this PC never
// holds bucket credentials. Harmless when the engine writes no images
// or the PC is not claimed: Upload reports "images off" and the visit
// queues without a photo.
Uploader: &agentbridge.SpacesUploader{
BaseURL: a.cfg.CloudBase, Token: a.cfg.AgentToken,
},
}
// Cameras, kept in step with head office. Started before the broker check
// because it does not need one: an unclaimed PC still reconciles (to
// nothing) and still keeps running its local cameras.
go a.startLocalCameras(ctx, logger)
url, stop, err := a.bridge.Listen(ctx)
if err != nil {
logger.Printf("event bridge failed to start: %v", err)
return
}
// Under the lock: the engine supervisor reads this from whichever goroutine
// launches the child, and Claim can run startPipeline again at any time.
a.mu.Lock()
a.stopBridge = stop
a.hookURL = url
a.mu.Unlock()
logger.Printf("event bridge on %s", url)
if !a.cfg.Configured() {
// Not claimed yet. The bridge still runs, so footfall from today is on
// disk waiting for the credentials rather than lost.
return
}
client, err := agentmqtt.NewClient(agentmqtt.ClientOptions{
BrokerURL: a.cfg.BrokerURL,
ClientID: "behavision-" + a.cfg.ClientID + "-" + a.cfg.SiteID,
Username: a.cfg.BrokerUsername, Password: a.cfg.BrokerPassword,
CAFile: a.cfg.BrokerCAFile, Log: logger,
})
if err != nil {
logger.Printf("broker unavailable, queuing locally: %v", err)
return
}
a.broker = client
go (&agentmqtt.Pump{
Queue: q, Publisher: client, Log: logger,
Wake: waker.C(),
HeartbeatTopic: topicPrefix(a.cfg) + "/heartbeat",
HeartbeatPayload: a.heartbeat,
}).Run(ctx)
logger.Print("broker pump running")
}
// startLocalCameras runs the reconciler that keeps this PC's cameras in step
// with head office. It is deliberately not conditional on being claimed: with
// no server to ask it reconciles against nothing and the locally configured
// cameras keep running, which is the whole of standalone operation.
func (a *App) startLocalCameras(ctx context.Context, logger *log.Logger) {
camUploader := &agentbridge.SpacesUploader{
BaseURL: a.cfg.CloudBase, Token: a.cfg.AgentToken,
}
camCloud := agentcameras.NewCloudClient(a.cfg.CloudBase, a.cfg.AgentToken)
camCloud.Upload = camUploader.UploadBytes
camEngine := agentcameras.NewEngineClient(
a.cfg.APIBase, a.cfg.APIUser, a.cfg.APIPassword)
// New(), not a struct literal: assembling the Syncer by hand here is how
// this app - and the headless agent - both ended up wiring configuration
// and forgetting the check runner, so "Test connection" at head office
// never completed on any shop PC.
agentcameras.New(camEngine, camCloud, logger).Run(ctx)
}
// topicPrefix must equal the MQTT username: the broker enforces
// `pattern write bv/%u/...`, so any other prefix is refused.
func topicPrefix(cfg agentcfg.Config) string {
if cfg.ClientID == "" || cfg.SiteID == "" {
return ""
}
return "bv/" + cfg.ClientID + "." + cfg.SiteID
}
func (a *App) heartbeat() []byte {
hb := map[string]any{"sent_at": time.Now().UTC().Format(time.RFC3339)}
if a.spool != nil {
hb["queued"] = a.spool.Len()
// Non-zero means this site's queue overflowed and it genuinely lost
// footfall. Reported rather than inferred from a dip in a graph.
hb["dropped"] = a.spool.Dropped()
}
s := a.EngineStatus()
hb["engine_state"] = s.State
if s.Model != "" {
hb["recognition_model"] = s.Model
}
if s.Cameras != nil {
hb["cameras"] = s.Cameras
}
b, _ := json.Marshal(hb)
return b
}
// PipelineStatus is what the UI shows about the link to head office.
type PipelineStatus struct {
WebhookURL string `json:"webhook_url"`
Queued int `json:"queued"`
Dropped uint64 `json:"dropped"`
Claimed bool `json:"claimed"`
// Standalone separates "nothing is being sent because this PC is set up on
// its own" from "nothing is being sent and something is wrong". They look
// identical from the counters alone, and only one of them is a fault.
Standalone bool `json:"standalone"`
BrokerUp bool `json:"broker_up"`
Accepted uint64 `json:"accepted"`
}
func (a *App) PipelineStatus() PipelineStatus {
out := PipelineStatus{
WebhookURL: a.hookURL,
Claimed: a.cfg.Configured(),
Standalone: a.cfg.Standalone && !a.cfg.Configured(),
}
if a.spool != nil {
out.Queued, out.Dropped = a.spool.Len(), a.spool.Dropped()
}
if a.bridge != nil {
out.Accepted = a.bridge.Accepted
}
if a.broker != nil {
out.BrokerUp = a.broker.Connected()
}
return out
}
// ---------------------------------------------------------------- session --
type SessionInfo struct {
LoggedIn bool `json:"logged_in"`
User cloud.User `json:"user"`
SiteName string `json:"site_name"`
Claimed bool `json:"claimed"`
// Standalone is a PC deliberately run on its own. The UI then shows only
// the screens that work without head office - the cameras and what this
// PC is seeing - rather than a sign-in form for an account that does not
// exist.
Standalone bool `json:"standalone"`
}
func (a *App) Session() SessionInfo {
a.mu.RLock()
defer a.mu.RUnlock()
return SessionInfo{
LoggedIn: a.cloud.LoggedIn(),
User: a.cloud.User(),
SiteName: a.cfg.SiteName,
Claimed: a.cfg.Configured(),
Standalone: a.cfg.Standalone && !a.cfg.Configured(),
}
}
// RunStandalone sets this PC up on its own, with no head office.
//
// Recognition, the cameras and the local gallery all work without a server -
// they always did - so refusing to open the app until somebody issues an
// enrolment code held the product hostage to a component it does not need. The
// choice is persisted because it has to survive a reboot, and it is reversible:
// Claim still works afterwards and clears the flag.
func (a *App) RunStandalone() (SessionInfo, error) {
a.mu.Lock()
a.cfg.Standalone = true
err := a.cfg.Save(agentpaths.AgentConfig())
a.mu.Unlock()
if err != nil {
// A choice that is not on disk works until the next restart and then
// silently is not made any more, which looks exactly like the app
// forgetting the setup step was ever done.
return SessionInfo{}, fmt.Errorf("could not save this choice: %w", err)
}
return a.Session(), nil
}
func (a *App) Login(email, password string) (SessionInfo, error) {
ctx, cancel := context.WithTimeout(a.ctx, 30*time.Second)
defer cancel()
sess, err := a.cloud.Login(ctx, email, password)
if err != nil {
return SessionInfo{}, err
}
a.persistSession(sess)
if a.onSessionChange != nil {
a.onSessionChange(true)
}
return a.Session(), nil
}
// persistSession writes the tokens to the DPAPI-protected config. Called on
// sign-in and on every silent refresh, so the two can never diverge.
func (a *App) persistSession(s cloud.Session) {
a.mu.Lock()
defer a.mu.Unlock()
a.cfg.SessionToken = s.Token
a.cfg.SessionRefresh = s.RefreshToken
if s.User.Email != "" {
a.cfg.SessionEmail = s.User.Email
}
_ = a.cfg.Save(agentpaths.AgentConfig())
}
// Claim links this PC to a shop, using the one-shot code an operator is given.
//
// This is the half of onboarding that had no way to happen. The server has had
// POST /api/agent/enrol since enrolment was built and `cloud.Client.Bootstrap`
// has existed to call it - and nothing called it, so a freshly installed PC
// displayed "Not linked to head office" and offered no way to link it. The
// only route was hand-editing a JSON file on a shop counter.
//
// Deliberately NOT session-authenticated, mirroring the endpoint: the person
// standing at a new shop PC has no account on it yet, and requiring a login
// first would mean shipping a password to every shop that installs the
// software.
func (a *App) Claim(code string) (SessionInfo, error) {
code = strings.TrimSpace(code)
if code == "" {
return SessionInfo{}, errors.New("type the installation code you were given")
}
ctx, cancel := context.WithTimeout(a.ctx, 30*time.Second)
defer cancel()
b, err := a.cloud.Bootstrap(ctx, code)
if err != nil {
return SessionInfo{}, err
}
a.mu.Lock()
// The slugs, not the uuids: the topic prefix is <client>.<site> and the
// broker's ACL is written against exactly that username.
a.cfg.ClientID = b.ClientSlug
a.cfg.SiteID = b.SiteSlug
a.cfg.SiteName = b.SiteName
a.cfg.BrokerURL = b.MQTTURL
a.cfg.BrokerUsername = b.MQTTUser
a.cfg.BrokerPassword = b.MQTTPass
a.cfg.AgentToken = b.AgentToken
a.cfg.CloudBase = a.cloud.Base
// A PC that was running on its own and has now been linked is no longer
// standalone. Leaving the flag set would keep the head-office screens
// hidden on the one machine that just earned them.
a.cfg.Standalone = false
err = a.cfg.Save(agentpaths.AgentConfig())
a.mu.Unlock()
if err != nil {
// Reported, not swallowed. A claim that is not on disk works until the
// next restart and then silently is not claimed any more, which looks
// like the code was wrong when it was not.
return SessionInfo{}, fmt.Errorf("could not save the settings: %w", err)
}
// The pipeline was started unclaimed: no broker, no pump. Restart it so
// this PC begins publishing now rather than at the next launch - an
// installer who has to reboot to finish setting up will assume it failed.
a.restartPipeline()
return a.Session(), nil
}
// restartPipeline tears the bridge and broker down and builds them again from
// the current config. Only Claim needs it today; it exists as its own method
// because "stop everything that reads the config, then start it" is the part
// that is easy to get half right.
func (a *App) restartPipeline() {
if a.stopBridge != nil {
a.stopBridge()
a.stopBridge = nil
}
if a.broker != nil {
a.broker.Close()
a.broker = nil
}
a.startPipeline(a.ctx)
}
func (a *App) Logout() SessionInfo {
// Revoke server-side too. Clearing only the local copy leaves a live token
// on a machine somebody is about to hand back or resell.
ctx, cancel := context.WithTimeout(a.ctx, 10*time.Second)
defer cancel()
_ = a.cloud.Logout(ctx)
a.mu.Lock()
a.cfg.SessionToken, a.cfg.SessionRefresh, a.cfg.SessionEmail = "", "", ""
_ = a.cfg.Save(agentpaths.AgentConfig())
a.mu.Unlock()
if a.onSessionChange != nil {
a.onSessionChange(false)
}
return a.Session()
}
// ---------------------------------------------------------------- engine ---
type EngineStatus struct {
State string `json:"state"`
Error string `json:"error,omitempty"`
Restarts int `json:"restarts"`
Reachable bool `json:"reachable"`
Model string `json:"recognition_model,omitempty"`
Cameras map[string]bool `json:"cameras,omitempty"`
}
func (a *App) EngineStatus() EngineStatus {
out := EngineStatus{State: "stopped"}
if a.sup == nil {
return out
}
st, err := a.sup.State()
out.State = string(st)
out.Restarts = a.sup.Restarts()
if err != nil {
out.Error = err.Error()
}
ctx, cancel := context.WithTimeout(a.ctx, 4*time.Second)
defer cancel()
// A running process is not a working engine: on a memory-starved box the
// large model loses the fallback chain and the process stays up regardless,
// so the UI reports which encoder actually loaded.
if h, herr := a.sup.Health(ctx); herr == nil {
out.Reachable = true
out.Model = h.RecognitionModel
out.Cameras = h.Cameras
}
return out
}
func (a *App) StartEngine() EngineStatus {
if a.sup != nil {
a.sup.Start()
}
return a.EngineStatus()
}
func (a *App) StopEngine() EngineStatus {
if a.sup != nil {
a.sup.Stop()
}
return a.EngineStatus()
}
// ---------------------------------------------------------------- cameras --
func (a *App) Cameras() ([]map[string]any, error) {
ctx, cancel := context.WithTimeout(a.ctx, 15*time.Second)
defer cancel()
return a.local.Cameras(ctx)
}
func (a *App) TestCamera(cam map[string]any) (map[string]any, error) {
ctx, cancel := context.WithTimeout(a.ctx, 60*time.Second)
defer cancel()
return a.local.TestCamera(ctx, cam)
}
func (a *App) SaveCamera(id string, cam map[string]any) (map[string]any, error) {
ctx, cancel := context.WithTimeout(a.ctx, 60*time.Second)
defer cancel()
if id == "" {
return a.local.AddCamera(ctx, cam)
}
return a.local.UpdateCamera(ctx, id, cam)
}
func (a *App) DeleteCamera(id string) error {
ctx, cancel := context.WithTimeout(a.ctx, 20*time.Second)
defer cancel()
return a.local.DeleteCamera(ctx, id)
}
// StartPlacementCheck begins the guided commissioning walk. This is the step
// that stops a site being signed off with a camera that recognises nobody.
func (a *App) StartPlacementCheck(id string, seconds float64) (map[string]any, error) {
ctx, cancel := context.WithTimeout(a.ctx, 15*time.Second)
defer cancel()
return a.local.StartPlacementCheck(ctx, id, seconds)
}
func (a *App) PlacementResult(id string) (map[string]any, error) {
ctx, cancel := context.WithTimeout(a.ctx, 15*time.Second)
defer cancel()
return a.local.PlacementResult(ctx, id)
}
// StreamURL is the MJPEG endpoint for a camera, with credentials inline so an
// <img> tag can load it. Loopback only - it never leaves this machine.
func (a *App) StreamURL(cameraID string) string {
base := strings.TrimPrefix(strings.TrimPrefix(a.local.Base, "http://"), "https://")
if a.local.User == "" {
return fmt.Sprintf("http://%s/api/cameras/%s/stream.mjpeg", base, cameraID)
}
return fmt.Sprintf("http://%s:%s@%s/api/cameras/%s/stream.mjpeg",
a.local.User, a.local.Password, base, cameraID)
}
// ------------------------------------------------------------------- live --
type LiveSnapshot struct {
Stats map[string]any `json:"stats"`
Events []map[string]any `json:"events"`
}
func (a *App) Live() (LiveSnapshot, error) {
ctx, cancel := context.WithTimeout(a.ctx, 15*time.Second)
defer cancel()
stats, err := a.local.Stats(ctx)
if err != nil {
return LiveSnapshot{}, err
}
events, err := a.local.Events(ctx, 40)
if err != nil {
return LiveSnapshot{}, err
}
return LiveSnapshot{Stats: stats, Events: events}, nil
}
// ---------------------------------------------------------------- reports --
func (a *App) Footfall(from, to, bucket string) (cloud.FootfallReport, error) {
ctx, cancel := context.WithTimeout(a.ctx, 20*time.Second)
defer cancel()
return a.cloud.Footfall(ctx, from, to, bucket)
}
func (a *App) Sales(from, to string) (cloud.SalesReport, error) {
ctx, cancel := context.WithTimeout(a.ctx, 20*time.Second)
defer cancel()
return a.cloud.Sales(ctx, from, to)
}
func (a *App) Customers(query string, limit int) ([]cloud.Customer, error) {
ctx, cancel := context.WithTimeout(a.ctx, 20*time.Second)
defer cancel()
if limit <= 0 {
limit = 100
}
return a.cloud.Customers(ctx, query, limit)
}
// Sites is the health of every store this account can see. It is what makes
// "no customers today" distinguishable from "that shop's PC has been unplugged
// for a week" - two identical rows of zeroes with completely different answers.
func (a *App) Sites() ([]cloud.SiteHealth, error) {
ctx, cancel := context.WithTimeout(a.ctx, 20*time.Second)
defer cancel()
return a.cloud.Sites(ctx)
}
// VisitorHistory is one customer's timeline, for the customer record screen.
func (a *App) VisitorHistory(id string, limit int) ([]cloud.Visit, error) {
if limit <= 0 {
limit = 100
}
ctx, cancel := context.WithTimeout(a.ctx, 20*time.Second)
defer cancel()
return a.cloud.VisitorHistory(ctx, id, limit)
}
// VisitorPhoto returns a link to this customer's face image, valid for a few
// minutes. "There is no photo" comes back as a Photo with Available false and
// a sentence explaining why, not as an error - see cloud.Photo.
func (a *App) VisitorPhoto(id string) (cloud.Photo, error) {
ctx, cancel := context.WithTimeout(a.ctx, 20*time.Second)
defer cancel()
return a.cloud.VisitorImage(ctx, id)
}
// ForgetCustomer erases a person at the request of that person.
//
// Bound as its own method rather than folded into SaveProfile because it is
// not an edit: it destroys the face template, the photo and the profile, and
// cannot be undone.
func (a *App) ForgetCustomer(id string) error {
// Longer than the usual 20s: the server deletes every stored image from
// object storage before it touches the database, and refuses the whole
// request if any one of them fails.
ctx, cancel := context.WithTimeout(a.ctx, 60*time.Second)
defer cancel()
return a.cloud.ForgetVisitor(ctx, id)
}
func (a *App) SaveProfile(p cloud.Profile) error {
ctx, cancel := context.WithTimeout(a.ctx, 20*time.Second)
defer cancel()
return a.cloud.SaveProfile(ctx, p)
}
func (a *App) RecordPurchase(visitorID string, amount float64,
items []string, notes string) error {
ctx, cancel := context.WithTimeout(a.ctx, 20*time.Second)
defer cancel()
return a.cloud.RecordPurchase(ctx, visitorID, amount, items, notes)
}
// --------------------------------------------------------------- identity --
// LocalIdentities reads the engine's own gallery. Shown alongside the cloud
// customer list because they answer different questions: this is who this PC
// can recognise right now, that is who the business knows.
func (a *App) LocalIdentities(limit int) ([]map[string]any, error) {
ctx, cancel := context.WithTimeout(a.ctx, 15*time.Second)
defer cancel()
if limit <= 0 {
limit = 50
}
return a.local.Identities(ctx, limit)
}
func (a *App) LocalSightings(limit int) ([]map[string]any, error) {
ctx, cancel := context.WithTimeout(a.ctx, 15*time.Second)
defer cancel()
if limit <= 0 {
limit = 50
}
return a.local.Sightings(ctx, limit)
}
func envOr(key, def string) string {
if v := osGetenv(key); v != "" {
return v
}
return def
}

5
desktop/env.go Normal file
View File

@@ -0,0 +1,5 @@
package main
import "os"
func osGetenv(k string) string { return os.Getenv(k) }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

13
desktop/frontend/dist/index.html vendored Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Behavision</title>
<script type="module" crossorigin src="./assets/index-whFsTNQf.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-XjqO50wd.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Behavision</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

1738
desktop/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
{
"name": "behavision-frontend",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
"vite": "^5.4.8"
}
}

View File

@@ -0,0 +1,161 @@
import { useCallback, useEffect, useState } from 'react'
import { api, isDesktop, message } from './bridge.js'
import { usePolled } from './hooks.js'
import Login from './views/Login.jsx'
import Setup from './views/Setup.jsx'
import Live from './views/Live.jsx'
import Customers from './views/Customers.jsx'
import Cameras from './views/Cameras.jsx'
// Three screens, and the trim is by AUDIENCE rather than by taste.
//
// This window runs on a PC behind a counter, and the person in front of it can
// act on exactly three things: is it working, who is this customer, and is the
// camera set up. Footfall and Sales answer a different person's questions - an
// owner comparing shops, who is not standing in one - and they now live on the
// head-office platform where a comparison across sites is even possible. A
// month-on-month chart on a shop PC was a report nobody there could act on,
// competing for the attention of somebody with a customer waiting.
//
// `cloud` marks a screen that cannot work without head office. A PC set up on
// its own hides those rather than showing a screen that can only ever fail:
// the customer record lives on the server, the cameras and what this PC is
// seeing do not.
const VIEWS = [
{ id: 'live', label: 'Live', glyph: '◉', View: Live },
{ id: 'customers', label: 'Customers', glyph: '☺', View: Customers, cloud: true },
{ id: 'cameras', label: 'Cameras', glyph: '▢', View: Cameras },
]
export default function App() {
const [session, setSession] = useState(null)
const [view, setView] = useState('live')
const [booting, setBooting] = useState(true)
// A standalone PC can join head office later. That is the same Setup screen,
// reached deliberately rather than because the app will not open otherwise.
const [linking, setLinking] = useState(false)
useEffect(() => {
(async () => {
try { setSession(await api.session()) } catch { setSession(null) }
setBooting(false)
})()
}, [])
if (!isDesktop()) {
// The frontend can be served by `npm run dev` for styling work, where the
// Go bindings do not exist. Saying so beats a blank screen and a console
// error nobody will read.
return (
<div className="login"><div className="box">
<h1>Behavision</h1>
<p className="lead">
This is the Behavision window running outside the app, so it has no
connection to the recognition engine. Launch the Behavision
application instead.
</p>
</div></div>
)
}
if (booting) return <div className="login"><p className="note">Starting…</p></div>
// Which shop this PC IS comes before who is standing at it. An installer on a
// brand new counter has a code and often no account yet, and every screen
// behind here is about a shop this PC does not have one of. Unless there is
// no head office at all, which is the other supported answer.
if (!session?.claimed && !session?.standalone) return <Setup onDone={setSession} />
if (!session?.standalone && !session?.logged_in) return <Login onDone={setSession} />
const views = VIEWS.filter(v => !v.cloud || !session.standalone)
const Current = views.find(v => v.id === view)?.View ?? Live
if (linking) {
return <Setup onDone={s => { setLinking(false); setSession(s) }}
onCancel={() => setLinking(false)} />
}
return (
<div className="shell">
<aside className="side">
<div className="brand">
<h1>Behavision</h1>
<p>{session.site_name || session.user?.client_name || 'Store'}</p>
</div>
<nav className="nav">
{views.map(v => (
<button key={v.id} onClick={() => setView(v.id)}
aria-current={v.id === view ? 'page' : undefined}>
<span className="glyph">{v.glyph}</span>{v.label}
</button>
))}
</nav>
<EngineBox />
<div style={{ padding: '10px 12px 14px', borderTop: '1px solid var(--line-soft)' }}>
{session.standalone
? <>
<div className="note" style={{ marginBottom: 8 }}>
Running on its own
</div>
<button className="btn sm" style={{ width: '100%' }}
onClick={() => setLinking(true)}>
Link to head office
</button>
</>
: <>
<div className="note" style={{ marginBottom: 8 }}>
{session.user?.email}
</div>
<button className="btn sm" style={{ width: '100%' }}
onClick={async () => setSession(await api.logout())}>
Sign out
</button>
</>}
</div>
</aside>
<main className="main"><Current session={session} /></main>
</div>
)
}
// Always visible, because "is recognition actually running" is the question
// behind every other screen — an empty Live page means something completely
// different depending on the answer.
function EngineBox() {
const { data, reload } = usePolled(() => api.engineStatus(), 5000)
const [busy, setBusy] = useState(false)
const s = data ?? { state: 'stopped' }
const act = useCallback(async fn => {
setBusy(true)
try { await fn() } catch (e) { alert(message(e)) }
finally { setBusy(false); reload() }
}, [reload])
const running = s.state === 'running'
const cams = Object.values(s.cameras ?? {})
const up = cams.filter(Boolean).length
let tone = 'idle', text = 'Stopped'
if (s.state === 'failed' || s.state === 'backoff') { tone = 'bad'; text = 'Not running' }
else if (running && !s.reachable) { tone = 'warn'; text = 'Starting…' }
else if (running && cams.length === 0) { tone = 'warn'; text = 'No cameras' }
else if (running && up === 0) { tone = 'bad'; text = 'No camera connected' }
else if (running && up < cams.length) { tone = 'warn'; text = `${up} of ${cams.length} cameras` }
else if (running) { tone = 'ok'; text = `Watching ${up} camera${up === 1 ? '' : 's'}` }
return (
<div className="enginebox">
<div className="row"><i className={`dot ${tone}`} /><strong>{text}</strong></div>
{s.recognition_model && (
<span className="label">Model: {s.recognition_model}</span>
)}
{s.error && <span className="label" style={{ color: 'var(--bad)' }}>{s.error}</span>}
<div className="actions">
<button className="btn sm" disabled={busy || running}
onClick={() => act(api.startEngine)}>Start</button>
<button className="btn sm" disabled={busy || !running}
onClick={() => act(api.stopEngine)}>Stop</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,64 @@
// The single seam between React and Go.
//
// Wails injects bound methods at window.go.main.App.*. Calling them through
// here rather than importing generated bindings means `npm run build` works
// without running `wails generate`, and it gives one place to handle the
// "engine not running yet" case that every screen has to survive.
const app = () => window?.go?.main?.App
export const isDesktop = () => Boolean(app())
async function call(name, ...args) {
const a = app()
if (!a || typeof a[name] !== 'function') {
throw new Error(`${name} is unavailable — run this inside the Behavision app`)
}
return a[name](...args)
}
// Every binding the UI uses, named as the UI thinks of them.
export const api = {
session: () => call('Session'),
login: (email, password) => call('Login', email, password),
logout: () => call('Logout'),
// The one-shot installation code that links this PC to a shop.
claim: (code) => call('Claim', code),
// Set this PC up on its own, with no head office at all.
runStandalone: () => call('RunStandalone'),
engineStatus: () => call('EngineStatus'),
startEngine: () => call('StartEngine'),
stopEngine: () => call('StopEngine'),
cameras: () => call('Cameras'),
testCamera: (cam) => call('TestCamera', cam),
saveCamera: (id, cam) => call('SaveCamera', id, cam),
deleteCamera: (id) => call('DeleteCamera', id),
startPlacement: (id, seconds) => call('StartPlacementCheck', id, seconds),
placementResult: (id) => call('PlacementResult', id),
streamURL: (id) => call('StreamURL', id),
live: () => call('Live'),
pipelineStatus: () => call('PipelineStatus'),
localIdentities: (n) => call('LocalIdentities', n),
localSightings: (n) => call('LocalSightings', n),
footfall: (from, to, bucket) => call('Footfall', from, to, bucket),
sites: () => call('Sites'),
visitorHistory: (id, limit) => call('VisitorHistory', id, limit),
visitorPhoto: (id) => call('VisitorPhoto', id),
forgetCustomer: (id) => call('ForgetCustomer', id),
sales: (from, to) => call('Sales', from, to),
customers: (q, limit) => call('Customers', q, limit),
saveProfile: (p) => call('SaveProfile', p),
recordPurchase: (id, amount, items, notes) =>
call('RecordPurchase', id, amount, items, notes),
}
// Errors from Go arrive as strings or Error objects depending on the path.
// Normalising here keeps every catch block in the UI to one line.
export function message(err) {
if (!err) return 'Something went wrong.'
if (typeof err === 'string') return err
return err.message || String(err)
}

View File

@@ -0,0 +1,65 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { message } from './bridge.js'
// One hook for every screen that loads and refreshes.
//
// It exists because the naive version has two bugs every screen would repeat:
// a slow response arriving after the user navigated away sets state on an
// unmounted component, and a poll that fires while the previous request is
// still running stacks up requests against an engine that is already slow.
export function usePolled(fn, intervalMs, deps = []) {
const [data, setData] = useState(null)
const [error, setError] = useState(null)
const [loading, setLoading] = useState(true)
const alive = useRef(true)
const busy = useRef(false)
const run = useCallback(async () => {
if (busy.current) return
busy.current = true
try {
const result = await fn()
if (!alive.current) return
setData(result)
setError(null)
} catch (err) {
if (alive.current) setError(message(err))
} finally {
busy.current = false
if (alive.current) setLoading(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps)
useEffect(() => {
alive.current = true
run()
if (!intervalMs) return () => { alive.current = false }
const id = setInterval(run, intervalMs)
return () => { alive.current = false; clearInterval(id) }
}, [run, intervalMs])
return { data, error, loading, reload: run }
}
export function fmtTime(ts) {
if (!ts) return '—'
const d = typeof ts === 'number' ? new Date(ts * 1000) : new Date(ts)
if (isNaN(d)) return '—'
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
export function fmtDate(ts) {
if (!ts) return '—'
const d = typeof ts === 'number' ? new Date(ts * 1000) : new Date(ts)
if (isNaN(d)) return '—'
return d.toLocaleDateString([], { day: 'numeric', month: 'short' })
}
export function daysAgo(n) {
const d = new Date()
d.setDate(d.getDate() - n)
return d.toISOString().slice(0, 10)
}
export function today() { return new Date().toISOString().slice(0, 10) }

View File

@@ -0,0 +1,8 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
import './styles.css'
createRoot(document.getElementById('root')).render(
<React.StrictMode><App /></React.StrictMode>
)

View File

@@ -0,0 +1,252 @@
/* Behavision desktop — an instrument panel, not a website.
A shop PC runs this all day on a cheap monitor, so: high contrast, dense
but not cramped, and state readable at a glance from across a counter. */
:root {
--ground: #0E1317;
--surface: #161D23;
--surface-2: #1D262D;
--line: #27333B;
--line-soft: #1F2A31;
--ink: #E7EEF3;
--ink-2: #B4C2CC;
--muted: #7C8B97;
--accent: #45B0C7;
--accent-dim:#123039;
--ok: #4FB37B;
--warn: #E0A33A;
--bad: #E0655A;
--radius: 8px;
--mono: "SFMono-Regular", ui-monospace, Menlo, Consolas, monospace;
}
* { box-sizing: border-box; margin: 0; }
html, body, #root { height: 100%; }
body {
background: var(--ground);
color: var(--ink);
font: 14px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif;
-webkit-font-smoothing: antialiased;
overflow: hidden;
user-select: none;
}
button, input, select, textarea { font: inherit; color: inherit; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* ---------------------------------------------------------------- shell -- */
.shell { display: grid; grid-template-columns: 216px 1fr; height: 100%; }
.side {
background: var(--surface); border-right: 1px solid var(--line);
display: flex; flex-direction: column; min-height: 0;
}
.side .brand {
padding: 18px 18px 14px; border-bottom: 1px solid var(--line-soft);
}
.side .brand h1 { font-size: 15px; font-weight: 650; letter-spacing: -.01em; }
.side .brand p { font-size: 11.5px; color: var(--muted); margin-top: 3px; }
.nav { padding: 10px 10px; display: flex; flex-direction: column; gap: 2px; flex: 1; }
.nav button {
display: flex; align-items: center; gap: 10px; width: 100%;
background: none; border: 0; border-radius: 6px; padding: 8px 10px;
color: var(--ink-2); cursor: pointer; text-align: left; font-size: 13.5px;
}
.nav button:hover { background: var(--surface-2); color: var(--ink); }
.nav button[aria-current="page"] { background: var(--accent-dim); color: var(--accent); font-weight: 550; }
.nav .glyph { width: 16px; text-align: center; opacity: .85; font-size: 13px; }
.enginebox { padding: 12px; border-top: 1px solid var(--line-soft); }
.enginebox .row { display: flex; align-items: center; gap: 8px; font-size: 12px; }
.enginebox .label { color: var(--muted); font-size: 11px; margin-top: 2px;
display: block; line-height: 1.4; }
.enginebox .actions { display: flex; gap: 6px; margin-top: 10px; }
.main { min-width: 0; min-height: 0; overflow-y: auto; }
.page { padding: 22px 26px 40px; max-width: 1180px; }
.page > header { margin-bottom: 18px; }
.page h2 { font-size: 19px; font-weight: 620; letter-spacing: -.01em; }
.page header p { color: var(--muted); font-size: 13px; margin-top: 3px; }
/* --------------------------------------------------------------- pieces -- */
.card {
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); padding: 16px;
}
.card h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .07em;
color: var(--muted); font-weight: 600; margin-bottom: 12px; }
.grid { display: grid; gap: 14px; }
.cols-4 { grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); }
.cols-2 { grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); }
.stat .value { font-size: 30px; font-weight: 620; letter-spacing: -.02em;
font-variant-numeric: tabular-nums; line-height: 1.1; }
.stat .unit { font-size: 15px; color: var(--muted); margin-left: 3px; }
.stat .sub { color: var(--muted); font-size: 12px; margin-top: 5px; }
.dot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
.dot.ok { background: var(--ok); }
.dot.warn { background: var(--warn); }
.dot.bad { background: var(--bad); }
.dot.idle { background: var(--muted); }
.pill { display: inline-flex; align-items: center; gap: 5px; font-size: 11px;
padding: 3px 8px; border-radius: 99px; border: 1px solid var(--line);
color: var(--muted); white-space: nowrap; }
.pill.ok { color: var(--ok); border-color: #2b5c42; background: #12251b; }
.pill.warn { color: var(--warn); border-color: #5c4a22; background: #241d0f; }
.pill.bad { color: var(--bad); border-color: #5c2e2a; background: #241312; }
.btn {
background: var(--surface-2); border: 1px solid var(--line);
border-radius: 6px; padding: 7px 13px; cursor: pointer; font-size: 13px;
color: var(--ink); white-space: nowrap;
}
.btn:hover:not(:disabled) { background: #26323a; }
.btn:disabled { opacity: .45; cursor: default; }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #06222a;
font-weight: 600; }
.btn.primary:hover:not(:disabled) { background: #5ac0d6; }
.btn.danger { color: var(--bad); border-color: #4a2823; }
.btn.sm { padding: 4px 9px; font-size: 12px; }
.field { display: block; margin-bottom: 12px; }
.field span { display: block; font-size: 11.5px; color: var(--muted);
margin-bottom: 4px; letter-spacing: .01em; }
.field input, .field select, .field textarea {
width: 100%; background: var(--ground); border: 1px solid var(--line);
border-radius: 6px; padding: 8px 10px; font-size: 13.5px;
user-select: text;
}
.field input:focus, .field select:focus, .field textarea:focus {
border-color: var(--accent); outline: none;
}
.field textarea { resize: vertical; min-height: 66px; }
.fieldrow { display: grid; gap: 0 12px; grid-template-columns: 1fr 1fr; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th { text-align: left; font-size: 10.5px; text-transform: uppercase;
letter-spacing: .08em; color: var(--muted); font-weight: 600;
padding: 8px 10px; border-bottom: 1px solid var(--line); }
td { padding: 9px 10px; border-bottom: 1px solid var(--line-soft); vertical-align: middle; }
tr:last-child td { border-bottom: 0; }
tbody tr.click { cursor: pointer; }
tbody tr.click:hover { background: var(--surface-2); }
td.num { font-variant-numeric: tabular-nums; text-align: right; }
.tablewrap { overflow-x: auto; }
.empty { color: var(--muted); font-size: 13px; padding: 26px 4px; text-align: center; }
.err {
border: 1px solid #5c2e2a; background: #241312; color: #f0b3ad;
border-radius: 6px; padding: 10px 12px; font-size: 13px; margin-bottom: 14px;
}
.note { color: var(--muted); font-size: 12.5px; }
.mono { font-family: var(--mono); font-size: 12px; }
/* --------------------------------------------------------------- login --- */
.login { height: 100%; display: grid; place-items: center; padding: 24px; }
.login .box { width: 100%; max-width: 380px; }
.login h1 { font-size: 21px; font-weight: 650; letter-spacing: -.015em; }
.login .lead { color: var(--muted); font-size: 13px; margin: 6px 0 22px; }
.login form { background: var(--surface); border: 1px solid var(--line);
border-radius: 10px; padding: 20px; }
.login .btn { width: 100%; margin-top: 6px; }
.login .foot { color: var(--muted); font-size: 11.5px; margin-top: 14px;
text-align: center; line-height: 1.5; }
/* The second way out of the setup screen: a shop with no head office. Styled
quieter than the form above it because linking is still the common case,
but present, because for a single-till shop it is the only one that works. */
.login .alt { margin-top: 18px; padding-top: 16px; text-align: center;
border-top: 1px solid var(--line-soft); }
.login .alt .note { line-height: 1.55; margin-bottom: 12px; text-align: left; }
.login .alt .btn { margin-top: 0; }
.linkbtn { background: none; border: 0; padding: 0; cursor: pointer;
font: inherit; font-size: 12.5px; color: var(--accent);
text-decoration: underline; text-underline-offset: 3px; }
.linkbtn:hover { color: var(--ink); }
/* ---------------------------------------------------------------- live --- */
.feeds { display: grid; gap: 14px; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); }
.feed { background: #000; border: 1px solid var(--line); border-radius: var(--radius);
overflow: hidden; }
.feed img { width: 100%; display: block; aspect-ratio: 16/9; object-fit: cover; background: #000; }
.feed .cap { display: flex; justify-content: space-between; align-items: center;
padding: 8px 11px; background: var(--surface); font-size: 12.5px; }
.events { list-style: none; max-height: 420px; overflow-y: auto; }
.events li { display: flex; gap: 9px; align-items: baseline;
padding: 7px 2px; border-bottom: 1px solid var(--line-soft); font-size: 12.5px; }
.events li:last-child { border-bottom: 0; }
.events .when { color: var(--muted); font-family: var(--mono); font-size: 11px;
flex: none; }
.tag { font-size: 10px; padding: 2px 6px; border-radius: 4px; flex: none;
background: var(--surface-2); color: var(--muted); }
.tag.new { background: #17364f; color: #86c2ec; }
.tag.seen { background: #14301f; color: #7fcb9c; }
.tag.miss { background: #3a1c1a; color: #eb9a92; }
/* -------------------------------------------------------------- charts --- */
.bars { display: flex; align-items: flex-end; gap: 3px; height: 150px; margin-top: 4px; }
.bars .col { flex: 1; display: flex; flex-direction: column; justify-content: flex-end;
gap: 2px; min-width: 0; }
.bars .seg { border-radius: 2px 2px 0 0; }
.bars .seg.ret { background: var(--accent); }
.bars .seg.new { background: #2f6f81; }
.axis { display: flex; justify-content: space-between; color: var(--muted);
font-size: 10.5px; margin-top: 6px; font-family: var(--mono); }
.key { display: flex; gap: 14px; font-size: 11.5px; color: var(--muted); margin-top: 10px; }
.key i { display: inline-block; width: 9px; height: 9px; border-radius: 2px;
margin-right: 5px; vertical-align: -1px; }
/* --------------------------------------------------------------- drawer -- */
.drawer { position: fixed; inset: 0; background: rgba(4,8,10,.6);
display: flex; justify-content: flex-end; z-index: 30; }
.drawer .panel { width: min(480px, 100%); height: 100%; background: var(--surface);
border-left: 1px solid var(--line); overflow-y: auto; padding: 20px 22px 40px; }
.drawer h3 { font-size: 16px; font-weight: 620; text-transform: none;
letter-spacing: -.01em; color: var(--ink); margin-bottom: 2px; }
/* Close lives in the sticky header (.who) now. Positioned against the fixed
overlay it stayed put while the sheet scrolled underneath it, printing the
button on top of whatever happened to be at the top of the viewport. */
/* -- customer record ---------------------------------------------------- */
/* Full-bleed sticky header: a customer record is long enough to scroll, and
both the name and the way out have to stay reachable. The negative margins
cancel the panel's padding so the background covers the full width. */
.who { position: sticky; top: -20px; z-index: 1; display: flex; gap: 14px;
align-items: flex-start; background: var(--surface);
margin: -20px -22px 18px; padding: 20px 22px 14px;
border-bottom: 1px solid var(--line-soft); }
.who .grow { flex: 1; min-width: 0; }
.who h3 { margin-bottom: 2px; }
.avatar { width: 64px; height: 64px; border-radius: 10px; flex: none;
object-fit: cover; background: var(--ground);
border: 1px solid var(--line); }
.avatar.none { display: grid; place-items: center; color: var(--muted);
font-size: 20px; font-weight: 600; letter-spacing: .02em; }
.timeline { list-style: none; max-height: 220px; overflow-y: auto; }
.timeline li { display: flex; gap: 10px; align-items: baseline; padding: 6px 0;
border-bottom: 1px solid var(--line-soft); font-size: 12.5px; }
.timeline li:last-child { border-bottom: 0; }
.timeline .when { font-family: var(--mono); font-size: 11px; color: var(--muted);
flex: none; min-width: 108px; }
.timeline .where { flex: 1; min-width: 0; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; }
/* Visually separated from Save: this is the one control in the sheet that
cannot be undone, and it must not read as just another button in a row. */
.danger-zone { margin-top: 22px; border-color: #4a2823; }
.danger-zone > h3 { color: var(--bad); }
.danger-zone .note { margin-bottom: 10px; }
.confirm h4 { font-size: 13.5px; font-weight: 620; margin-bottom: 10px; }
.confirm .cols { display: grid; grid-template-columns: 1fr 1fr; gap: 14px;
margin-bottom: 12px; }
@media (max-width: 560px) { .confirm .cols { grid-template-columns: 1fr; } }
.confirm .lbl { font-size: 11px; text-transform: uppercase; letter-spacing: .07em;
color: var(--muted); margin-bottom: 5px; }
.confirm .lbl.bad { color: var(--bad); }
.confirm ul { list-style: none; font-size: 12.5px; }
.confirm li { padding: 3px 0 3px 12px; position: relative; color: var(--ink); }
.confirm li::before { content: '·'; position: absolute; left: 2px;
color: var(--muted); }
.confirm .row { display: flex; gap: 8px; }

View File

@@ -0,0 +1,280 @@
import { useEffect, useRef, useState } from 'react'
import { api, message } from '../bridge.js'
import { usePolled } from '../hooks.js'
import { MAKES, makeById } from '../../../../shared/cameraMakes.js'
const BLANK = { id: '', host: '', port: 554, path: '', username: '', password: '',
max_width: 1280 }
export default function Cameras() {
const { data, error, reload } = usePolled(() => api.cameras(), 8000)
const [editing, setEditing] = useState(null)
const [check, setCheck] = useState(null)
const cams = data ?? []
async function remove(id) {
if (!confirm(`Remove camera "${id}"? Recognition from it stops immediately.`)) return
try { await api.deleteCamera(id); reload() } catch (e) { alert(message(e)) }
}
return (
<div className="page">
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end' }}>
<div>
<h2>Cameras</h2>
<p>Add a camera, check it can see faces properly, then it starts working.</p>
</div>
<button className="btn primary" onClick={() => setEditing({ ...BLANK })}>
Add camera
</button>
</header>
{error && <div className="err">{error}</div>}
<div className="card">
{cams.length === 0
? <div className="empty">No cameras yet.</div>
: <div className="tablewrap">
<table>
<thead><tr><th>Name</th><th>Address</th><th>Status</th><th></th></tr></thead>
<tbody>
{cams.map(c => (
<tr key={c.id}>
<td>{c.id}</td>
<td className="mono">{c.url}</td>
<td>
{c.connected === undefined
? <span className="pill"><i className="dot idle" />stopped</span>
: c.connected
? <span className="pill ok"><i className="dot ok" />live</span>
: <span className="pill bad"><i className="dot bad" />offline</span>}
</td>
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<button className="btn sm" onClick={() => setCheck(c.id)}>
Check placement
</button>{' '}
<button className="btn sm" onClick={() => setEditing(c)}>Edit</button>{' '}
<button className="btn sm danger" onClick={() => remove(c.id)}>
Remove
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>}
</div>
{editing && <CameraSheet cam={editing} onClose={() => setEditing(null)}
onSaved={() => { setEditing(null); reload() }} />}
{check && <PlacementSheet id={check} onClose={() => setCheck(null)} />}
</div>
)
}
function CameraSheet({ cam, onClose, onSaved }) {
const isNew = !cam.id
const [f, setF] = useState({ ...BLANK, ...cam, password: '',
path: cam.path || (isNew ? MAKES[0].path : '') })
const [make, setMake] = useState(isNew ? MAKES[0].id : 'manual')
const [test, setTest] = useState(null)
const [busy, setBusy] = useState(null)
const [error, setError] = useState(null)
const set = k => e => setF({ ...f, [k]: e.target.value })
// Only overwrite the path when the preset has one, so choosing "I know the
// path" does not wipe what the installer already typed.
function chooseMake(e) {
const m = makeById(e.target.value)
setMake(m.id)
setF(prev => ({ ...prev, path: m.path || prev.path }))
}
// Blank means "leave alone", never "clear". The engine never returns a
// stored password, so sending an empty one would wipe it on every edit.
function payload() {
const out = {}
for (const [k, v] of Object.entries(f)) {
if (v === '' || v === null || v === undefined) continue
out[k] = (k === 'port' || k === 'max_width') ? Number(v) : v
}
return out
}
async function runTest() {
setBusy('test'); setError(null); setTest(null)
try { setTest(await api.testCamera(payload())) }
catch (e) { setError(message(e)) } finally { setBusy(null) }
}
async function save(e) {
e.preventDefault()
setBusy('save'); setError(null)
try { await api.saveCamera(isNew ? '' : cam.id, payload()); onSaved() }
catch (e) { setError(message(e)) } finally { setBusy(null) }
}
return (
<div className="drawer" onMouseDown={e => e.target === e.currentTarget && onClose()}>
<div className="panel">
<button className="btn sm close" onClick={onClose}>Close</button>
<h3>{isNew ? 'Add camera' : cam.id}</h3>
<p className="note" style={{ marginBottom: 18 }}>
Test the connection before saving — a wrong address is the most common mistake.
</p>
<form onSubmit={save}>
{error && <div className="err">{error}</div>}
<label className="field">
<span>Name</span>
<input value={f.id} onChange={set('id')} disabled={!isNew}
placeholder="entrance" required autoComplete="off" />
</label>
{/* The highest-value field on this form. The address and the
password are on a label or in the installer's notes; the RTSP
path is not written anywhere a shop owner would look, and getting
it wrong produces "could not open stream", which reads like a
password problem and is not. */}
<label className="field"><span>Make of camera</span>
<select value={make} onChange={chooseMake}>
{MAKES.map(m => <option key={m.id} value={m.id}>{m.label}</option>)}
</select>
</label>
{makeById(make).note && (
<p className="note" style={{ marginTop: -8, marginBottom: 12 }}>
{makeById(make).note}
</p>
)}
<div className="fieldrow">
<label className="field"><span>Camera address</span>
<input value={f.host} onChange={set('host')} placeholder="192.168.0.138"
autoComplete="off" />
</label>
<label className="field"><span>Port</span>
<input value={f.port} onChange={set('port')} inputMode="numeric"
autoComplete="off" />
</label>
</div>
<label className="field"><span>Stream path</span>
<input value={f.path} onChange={set('path')} placeholder="/ch0_0.264"
autoComplete="off" />
</label>
<div className="fieldrow">
{/* A text input next to a password input is a sign-in form as far
as the webview is concerned, so without this the browser offers
the operator's own Behavision email as the camera's username -
which fails with a message about credentials that points at the
camera. "off" alone is frequently ignored; a non-login name and
new-password on the secret are what actually work. */}
<label className="field"><span>Username</span>
<input value={f.username} onChange={set('username')}
name="camera-account" autoComplete="off" />
</label>
<label className="field"><span>Password</span>
<input type="password" value={f.password} onChange={set('password')}
name="camera-secret" autoComplete="new-password"
placeholder={cam.has_password ? '(unchanged)' : ''} />
</label>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
<button type="button" className="btn" onClick={runTest} disabled={!!busy}>
{busy === 'test' ? 'Connecting…' : 'Test connection'}
</button>
<button className="btn primary" disabled={!!busy || !f.id}>
{busy === 'save' ? 'Saving…' : 'Save'}
</button>
</div>
{test && (
<div style={{ marginTop: 14 }}>
{test.ok
? <>
<p style={{ color: 'var(--ok)', fontSize: 13 }}>
Connected — {test.width}×{test.height}
</p>
{test.snapshot && (
<img alt="Camera preview" style={{ width: '100%', marginTop: 8,
borderRadius: 6, border: '1px solid var(--line)' }}
src={`data:image/jpeg;base64,${test.snapshot}`} />
)}
</>
: <div className="err">{test.error}</div>}
</div>
)}
</form>
</div>
</div>
)
}
// The commissioning wizard. This is what stops a site being signed off with a
// camera that recognises nobody — the failure that otherwise shows up weeks
// later as a footfall report that was always zero.
function PlacementSheet({ id, onClose }) {
const [state, setState] = useState({ verdict: 'starting', advice: [] })
const [error, setError] = useState(null)
const timer = useRef(null)
useEffect(() => {
let alive = true
;(async () => {
try {
setState(await api.startPlacement(id, 25))
timer.current = setInterval(async () => {
try {
const r = await api.placementResult(id)
if (!alive) return
setState(r)
if (!r.running) clearInterval(timer.current)
} catch (e) { if (alive) setError(message(e)) }
}, 1000)
} catch (e) { if (alive) setError(message(e)) }
})()
return () => { alive = false; clearInterval(timer.current) }
}, [id])
const tone = { good: 'ok', marginal: 'warn', poor: 'bad', artifact: 'bad',
no_faces: 'warn', inconclusive: 'warn' }[state.verdict]
const pct = state.seconds ? Math.min(100, (state.elapsed / state.seconds) * 100) : 0
return (
<div className="drawer" onMouseDown={e => e.target === e.currentTarget && onClose()}>
<div className="panel">
<button className="btn sm close" onClick={onClose}>Close</button>
<h3>Placement check — {id}</h3>
<p className="note" style={{ marginBottom: 18 }}>
Walk past the camera the way a customer would, a few times.
</p>
{error && <div className="err">{error}</div>}
<div className="card">
<div style={{ fontSize: 15, fontWeight: 600,
color: tone ? `var(--${tone})` : 'var(--ink)' }}>
{state.headline || 'Starting…'}
</div>
{state.running && (
<div style={{ height: 5, background: 'var(--surface-2)', borderRadius: 3,
overflow: 'hidden', margin: '12px 0' }}>
<div style={{ height: '100%', width: `${pct}%`, background: 'var(--accent)',
transition: 'width .4s linear' }} />
</div>
)}
{state.advice?.length > 0 && (
<ul style={{ margin: '12px 0 0 18px', fontSize: 13, color: 'var(--ink-2)' }}>
{state.advice.map((a, i) => <li key={i} style={{ marginBottom: 5 }}>{a}</li>)}
</ul>
)}
{state.quality?.n > 0 && (
<p className="note" style={{ marginTop: 12 }}>
{state.quality.n} face{state.quality.n === 1 ? '' : 's'} seen ·
median quality {state.quality.p50} ·
gate {state.gate} ·
{' '}{Math.round((state.quality.fraction_below_gate ?? 0) * 100)}% below it
</p>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,182 @@
import { useState } from 'react'
import { api, message } from '../bridge.js'
import CustomerPhoto, { useCustomerPhoto } from './CustomerPhoto.jsx'
import VisitHistory from './VisitHistory.jsx'
import EraseCustomer from './EraseCustomer.jsx'
// The in-store form. Two jobs in one sheet: capture who this person is, and
// record what they bought — because staff have the customer in front of them
// once, and asking them to open a second screen means the sale never gets
// recorded.
export default function CustomerForm({ customer, session, onClose, onSaved }) {
const [f, setF] = useState({
full_name: customer.full_name ?? '',
phone: customer.phone ?? '',
email: customer.email ?? '',
gender: '',
date_of_birth: '',
notes: '',
consent: customer.has_consent ?? false,
})
const [purchase, setPurchase] = useState({ amount: '', items: '', notes: '' })
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const [erasing, setErasing] = useState(false)
const [photo, setPhoto] = useState(null)
const fetched = useCustomerPhoto(customer.id)
const shown = photo ?? fetched
// Erasure destroys a record permanently, so it is a manager's decision. The
// server enforces this too — this only avoids offering a button that would
// come back 403.
const role = session?.user?.role
const canErase = ['admin', 'owner', 'manager'].includes(role)
const set = k => e => setF({ ...f, [k]: e.target.value })
async function save(e) {
e.preventDefault()
setBusy(true); setError(null)
try {
await api.saveProfile({ visitor_id: customer.id, ...f })
const amount = parseFloat(purchase.amount)
if (!isNaN(amount) && amount > 0) {
const items = purchase.items.split(',').map(s => s.trim()).filter(Boolean)
await api.recordPurchase(customer.id, amount, items, purchase.notes)
}
onSaved()
} catch (err) {
setError(message(err))
} finally {
setBusy(false)
}
}
return (
<div className="drawer" onMouseDown={e => e.target === e.currentTarget && onClose()}>
<div className="panel">
<div className="who">
<CustomerPhoto photo={shown} name={customer.full_name || customer.label}
onBroken={() => setPhoto({ available: false,
reason: 'The photo could not be loaded.' })} />
<div className="grow">
<h3>{customer.full_name || customer.label}</h3>
<p className="note">
{customer.visit_count} visit{customer.visit_count === 1 ? '' : 's'}
{customer.last_seen_at && ` · last seen ${new Date(customer.last_seen_at).toLocaleDateString()}`}
</p>
{shown && !shown.available && shown.reason &&
<p className="note">{shown.reason}</p>}
</div>
<button type="button" className="btn sm" onClick={onClose}>Close</button>
</div>
<form onSubmit={save}>
{error && <div className="err">{error}</div>}
<div className="card" style={{ marginBottom: 14 }}>
<h3>Customer details</h3>
<label className="field">
<span>Full name</span>
<input value={f.full_name} onChange={set('full_name')} autoFocus />
</label>
<div className="fieldrow">
<label className="field">
<span>Phone</span>
<input value={f.phone} onChange={set('phone')} inputMode="tel" />
</label>
<label className="field">
<span>Email</span>
<input value={f.email} onChange={set('email')} type="email" />
</label>
</div>
<div className="fieldrow">
<label className="field">
<span>Gender</span>
<select value={f.gender} onChange={set('gender')}>
<option value="">Not recorded</option>
<option>Female</option><option>Male</option><option>Other</option>
</select>
</label>
<label className="field">
<span>Date of birth</span>
<input type="date" value={f.date_of_birth} onChange={set('date_of_birth')} />
</label>
</div>
<label className="field">
<span>Notes</span>
<textarea value={f.notes} onChange={set('notes')}
placeholder="Preferences, sizes, anything worth remembering" />
</label>
</div>
<div className="card" style={{ marginBottom: 14 }}>
<h3>Purchase (optional)</h3>
<div className="fieldrow">
<label className="field">
<span>Amount</span>
<input value={purchase.amount} inputMode="decimal" placeholder="0.00"
onChange={e => setPurchase({ ...purchase, amount: e.target.value })} />
</label>
<label className="field">
<span>Items</span>
<input value={purchase.items} placeholder="shirt, belt"
onChange={e => setPurchase({ ...purchase, items: e.target.value })} />
</label>
</div>
<p className="note">Leave the amount blank if they did not buy anything —
a visit without a sale is still worth recording.</p>
</div>
<div className="card" style={{ marginBottom: 16 }}>
<h3>Consent</h3>
<label style={{ display: 'flex', gap: 10, alignItems: 'flex-start',
fontSize: 13, cursor: 'pointer' }}>
<input type="checkbox" checked={f.consent} style={{ marginTop: 3 }}
onChange={e => setF({ ...f, consent: e.target.checked })} />
<span>This customer agreed to us keeping their details and
recognising them on future visits.</span>
</label>
<p className="note" style={{ marginTop: 10 }}>
Recorded with the date and who collected it. They can withdraw it
at any time, which erases their face data.
</p>
</div>
<div className="card" style={{ marginBottom: 14 }}>
<h3>Visits</h3>
<VisitHistory customer={customer} />
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn primary" disabled={busy}>
{busy ? 'Saving…' : 'Save'}
</button>
<button type="button" className="btn" onClick={onClose}>Cancel</button>
</div>
{canErase && (
<div className="card danger-zone">
<h3>At the customer's request</h3>
{erasing
? <EraseCustomer customer={customer}
onCancel={() => setErasing(false)}
onDone={onSaved} />
: <>
<p className="note">
Erase this person's face data, photo and details. Their
past visits stay in your footfall figures, without their
name.
</p>
<button type="button" className="btn danger"
onClick={() => setErasing(true)}>
Erase this customer…
</button>
</>}
</div>
)}
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,45 @@
import { useEffect, useState } from 'react'
import { api } from '../bridge.js'
// The customer's face, when there is one.
//
// Fetched when the sheet opens rather than stored with the customer row: the
// server hands out a signed link that expires in minutes, deliberately, so
// that "delete my data" can actually make a picture stop loading. A link kept
// in a list rendered an hour ago is a broken image.
//
// The fetch lives in a hook and happens ONCE per sheet, because the server
// writes an audit_log row for every read of a face image — "who looked at my
// customers" has to be answerable — and a component that fetched its own copy
// for the picture and again for the caption would put two rows in that log for
// one glance at one person.
export function useCustomerPhoto(id) {
const [photo, setPhoto] = useState(null)
useEffect(() => {
let alive = true
setPhoto(null)
api.visitorPhoto(id)
.then(p => { if (alive) setPhoto(p) })
// A failure to load a photo must never take the customer record with
// it: the name and phone number are what staff opened this for.
.catch(() => { if (alive) setPhoto({ available: false, reason: '' }) })
return () => { alive = false }
}, [id])
return photo
}
// No photo is the normal case — images are off by default — so this renders
// initials, not an error.
export default function CustomerPhoto({ photo, name, onBroken }) {
if (photo?.available) {
return <img className="avatar" src={photo.url} alt={`Photo of ${name}`}
onError={onBroken} />
}
const initials = String(name || '').split(/\s+/).filter(Boolean).slice(0, 2)
.map(w => w[0].toUpperCase()).join('') || '?'
return (
<div className="avatar none" role="img" aria-label={`No photo of ${name}`}>
<span>{initials}</span>
</div>
)
}

View File

@@ -0,0 +1,92 @@
import { useState } from 'react'
import { api } from '../bridge.js'
import { usePolled, fmtDate } from '../hooks.js'
import CustomerForm from './CustomerForm.jsx'
// The customer database, and the form staff fill in when someone walks in.
export default function Customers({ session }) {
const [query, setQuery] = useState('')
const [selected, setSelected] = useState(null)
const { data, error, reload } = usePolled(
() => api.customers(query, 200), 30000, [query])
const rows = data ?? []
const named = rows.filter(r => r.has_profile).length
return (
<div className="page">
<header>
<h2>Customers</h2>
<p>Everyone this business has recognised. Fill in details once and they
are known at every store.</p>
</header>
{error && <div className="err">{error}</div>}
<div className="grid cols-4" style={{ marginBottom: 16 }}>
<Stat label="Known people" value={rows.length || '—'} />
<Stat label="With details" value={named || '—'}
sub={rows.length ? `${Math.round(100 * named / rows.length)}% captured` : null} />
<Stat label="Returning" value={rows.filter(r => r.visit_count > 1).length || '—'} />
<Stat label="With consent" value={rows.filter(r => r.has_consent).length || '—'} />
</div>
<div className="card">
<div style={{ display: 'flex', gap: 10, marginBottom: 12 }}>
<input className="field" style={{ flex: 1, margin: 0, background: 'var(--ground)',
border: '1px solid var(--line)', borderRadius: 6, padding: '8px 10px' }}
placeholder="Search by name or phone"
value={query} onChange={e => setQuery(e.target.value)} />
<button className="btn" onClick={reload}>Refresh</button>
</div>
{rows.length === 0
? <div className="empty">
No customers yet. They appear here the first time a camera sees them.
</div>
: <div className="tablewrap">
<table>
<thead>
<tr>
<th>Customer</th><th>Phone</th>
<th className="num">Visits</th>
<th>First seen</th><th>Last seen</th><th>Details</th>
</tr>
</thead>
<tbody>
{rows.map(c => (
<tr key={c.id} className="click" onClick={() => setSelected(c)}>
<td>{c.full_name || <span className="note">{c.label}</span>}</td>
<td className="mono">{c.phone || '—'}</td>
<td className="num">{c.visit_count}</td>
<td>{fmtDate(c.first_seen_at)}</td>
<td>{fmtDate(c.last_seen_at)}</td>
<td>
{c.has_profile
? <span className="pill ok"><i className="dot ok" />captured</span>
: <span className="pill warn"><i className="dot warn" />needed</span>}
</td>
</tr>
))}
</tbody>
</table>
</div>}
</div>
{selected && (
<CustomerForm customer={selected} session={session}
onClose={() => setSelected(null)}
onSaved={() => { setSelected(null); reload() }} />
)}
</div>
)
}
function Stat({ label, value, sub }) {
return (
<div className="card stat">
<h3>{label}</h3><div className="value">{value}</div>
{sub && <div className="sub">{sub}</div>}
</div>
)
}

View File

@@ -0,0 +1,89 @@
import { useState } from 'react'
import { api, message } from '../bridge.js'
// Erasing a customer, at that customer's request.
//
// This is a legal obligation with a destructive implementation, so the screen
// does two things a plain "are you sure" cannot. It says exactly what is
// destroyed and exactly what is kept — staff are asked "will you delete my
// data?" by a real person standing in front of them and have to be able to
// answer truthfully — and it requires the customer's name to be typed, because
// this sits next to Save in a sheet used all day and a misclick is
// unrecoverable.
//
// What is kept is not an oversight. Visits stay (unlinked): they are the
// shop's own footfall history, and silently rewriting last quarter's numbers
// because one customer exercised a right is both wrong and detectable. Consent
// stays, revoked: deleting it destroys the proof of what we were permitted to
// do and when, which is the first thing an auditor asks for.
export default function EraseCustomer({ customer, onCancel, onDone }) {
const name = customer.full_name || customer.label
const [typed, setTyped] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const confirmed = typed.trim().toLowerCase() === name.trim().toLowerCase()
async function erase() {
setBusy(true); setError(null)
try {
await api.forgetCustomer(customer.id)
onDone()
} catch (err) {
// The server deletes stored photos before it touches the database and
// refuses the whole request if one fails, so a failure here means
// nothing was erased. Say so — the alternative is a shop believing a
// request was honoured when it was not.
setError(message(err))
setBusy(false)
}
}
return (
<div className="confirm">
<h4>Erase {name}?</h4>
<div className="cols">
<div>
<p className="lbl bad">Deleted for good</p>
<ul>
<li>Their face data — they will not be recognised again</li>
<li>Their photo</li>
<li>Their name, phone, email and notes</li>
</ul>
</div>
<div>
<p className="lbl">Kept</p>
<ul>
<li>Past visits, with their name removed — your footfall figures
do not change</li>
<li>The consent record, marked withdrawn, as proof of what was
agreed</li>
</ul>
</div>
</div>
<p className="note">This cannot be undone. If they come back they will be
recorded as a new customer.</p>
{error && <div className="err">{error}</div>}
<label className="field">
<span>Type <b>{name}</b> to confirm</span>
<input value={typed} onChange={e => setTyped(e.target.value)}
autoFocus autoComplete="off" spellCheck="false"
aria-label={`Type ${name} to confirm erasure`} />
</label>
<div className="row">
<button type="button" className="btn danger"
disabled={!confirmed || busy} onClick={erase}>
{busy ? 'Erasing…' : 'Erase permanently'}
</button>
<button type="button" className="btn" onClick={onCancel} disabled={busy}>
Cancel
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,162 @@
import { useState } from 'react'
import { api } from '../bridge.js'
import { usePolled, daysAgo, today } from '../hooks.js'
const RANGES = [
{ label: '7 days', days: 7, bucket: 'day' },
{ label: '30 days', days: 30, bucket: 'day' },
{ label: '90 days', days: 90, bucket: 'week' },
]
export default function Footfall() {
const [range, setRange] = useState(RANGES[0])
const { data, error, loading } = usePolled(
() => api.footfall(daysAgo(range.days), today(), range.bucket),
60000, [range.days, range.bucket])
const sites = usePolled(() => api.sites(), 60000, [])
const points = data?.points ?? []
const peak = Math.max(1, ...points.map(p => p.visitors))
// Both numbers come from the server, and neither is the sum of the chart.
//
// A customer who came on Monday and Thursday is ONE person and TWO
// bucket-visitors, so adding the bars up gives a headcount that is silently
// too high. Summing new + returning is wrong a second way: a site sending
// counts without face templates produces visits that are real footfall but
// an unknown person, and those are counted in neither.
const unique = data?.total ?? 0
const visits = data?.visits ?? 0
const totalNew = points.reduce((n, p) => n + (p.new ?? 0), 0)
const totalRet = points.reduce((n, p) => n + (p.returning ?? 0), 0)
const identified = totalNew + totalRet
const gate = data?.fraction_below_gate ?? 0
return (
<div className="page">
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end' }}>
<div>
<h2>Footfall</h2>
<p>Visitors over time, split by whether we had seen them before.</p>
</div>
<div style={{ display: 'flex', gap: 6 }}>
{RANGES.map(r => (
<button key={r.label} className="btn sm"
aria-pressed={r.days === range.days}
style={r.days === range.days
? { background: 'var(--accent-dim)', color: 'var(--accent)',
borderColor: 'var(--accent)' } : undefined}
onClick={() => setRange(r)}>{r.label}</button>
))}
</div>
</header>
{error && <div className="err">{error}</div>}
<div className="grid cols-4" style={{ marginBottom: 16 }}>
<Stat label="People" value={unique || '—'}
sub={visits ? `${visits} visit${visits === 1 ? '' : 's'} in total` : null} />
<Stat label="New" value={totalNew || '—'} />
<Stat label="Returning" value={totalRet || '—'}
sub={identified ? `${Math.round(100 * totalRet / identified)}% of recognised visits` : null} />
<Stat label="Below quality gate"
value={data ? `${Math.round(gate * 100)}%` : '—'}
tone={gate > 0.5 ? 'bad' : gate > 0.2 ? 'warn' : undefined}
sub={data?.worst_site ? `worst: ${data.worst_site}` : 'Faces seen but too poor to count'} />
</div>
<div className="card">
<h3>Visitors per {range.bucket}</h3>
{loading && !points.length ? <div className="empty">Loading…</div>
: points.length === 0 ? <div className="empty">No visits recorded in this period.</div>
: <>
<div className="bars">
{points.map((p, i) => (
<div className="col" key={i}
title={`${bucketLabel(p.bucket, range.bucket)}: ${p.visitors} visitor${p.visitors === 1 ? '' : 's'}`}>
<div className="seg new" style={{ height: `${(p.new / peak) * 100}%` }} />
<div className="seg ret" style={{ height: `${(p.returning / peak) * 100}%` }} />
</div>
))}
</div>
<div className="axis">
<span>{bucketLabel(points[0]?.bucket, range.bucket)}</span>
<span>{bucketLabel(points[points.length - 1]?.bucket, range.bucket)}</span>
</div>
<div className="key">
<span><i style={{ background: 'var(--accent)' }} />Returning</span>
<span><i style={{ background: '#2f6f81' }} />New</span>
{data?.timezone && <span style={{ marginLeft: 'auto', opacity: 0.6 }}>
times in {data.timezone}</span>}
</div>
</>}
</div>
<Sites sites={sites.data} error={sites.error} />
{gate > 0.5 && (
<div className="err" style={{ marginTop: 16 }}>
More than half the faces {data.worst_site ? `at ${data.worst_site}` : 'this site'} saw
were too poor to count, so this chart understates real footfall. Run a
placement check on that camera before trusting these numbers.
</div>
)}
</div>
)
}
// A site that has stopped reporting looks exactly like a site with no
// customers - the same row of zeroes - and only one of them is something to
// act on. This is the difference, shown next to the chart it explains.
function Sites({ sites, error }) {
if (error || !sites || sites.length === 0) return null
return (
<div className="card" style={{ marginTop: 16 }}>
<h3>Sites</h3>
<table>
<tbody>
{sites.map(s => (
<tr key={s.site_id}>
<td><b>{s.name}</b></td>
<td>
{/* .dot is sized, so it needs a box: a bare span is inline
and would collapse to nothing inside a table cell. */}
<span className={`dot ${s.online ? 'ok' : 'bad'}`}
style={{ display: 'inline-block', marginRight: 6 }} />
{s.online ? 'Reporting' : 'Not reporting'}
</td>
<td>{s.cameras_total
? `${s.cameras_up}/${s.cameras_total} camera${s.cameras_total === 1 ? '' : 's'} connected`
: 'no cameras'}</td>
{/* Dropped events are footfall this site permanently lost, so it
has to be visible rather than inferred from a dip in a graph. */}
<td>{s.dropped > 0
? <span style={{ color: 'var(--bad)' }}>{s.dropped} events lost</span>
: s.queued > 0 ? `${s.queued} queued` : ''}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
// Buckets come back as local wall time with no offset, labelled by the
// timezone in the report. Parsing them as a Date would re-interpret them in
// the viewer's zone and shift every label by hours.
function bucketLabel(bucket, size) {
if (!bucket) return ''
const [date, time] = bucket.split('T')
if (size === 'hour') return `${date.slice(5)} ${(time || '').slice(0, 5)}`
return date
}
function Stat({ label, value, sub, tone }) {
return (
<div className="card stat">
<h3>{label}</h3>
<div className="value" style={tone ? { color: `var(--${tone})` } : undefined}>{value}</div>
{sub && <div className="sub">{sub}</div>}
</div>
)
}

View File

@@ -0,0 +1,192 @@
import { useEffect, useState } from 'react'
import { api } from '../bridge.js'
import { usePolled, fmtTime } from '../hooks.js'
// What is happening right now. The first screen a shop manager opens, so it
// answers "is it working" before it answers anything else.
export default function Live() {
const { data, error } = usePolled(() => api.live(), 3000)
const { data: pipe } = usePolled(() => api.pipelineStatus(), 5000)
const cams = useCameraFeeds()
const cameras = data?.stats?.cameras ?? []
const gallery = data?.stats?.gallery ?? {}
const events = data?.events ?? []
// fraction_below_gate is the number that decides a site: what share of the
// faces this camera saw were too poor to enrol. Surfaced here rather than
// buried, because a high value looks exactly like "a quiet day".
const worst = cameras.reduce((acc, c) => {
const f = c?.pipeline?.best_quality?.fraction_below_gate
return typeof f === 'number' && f > acc ? f : acc
}, 0)
return (
<div className="page">
<header>
<h2>Live</h2>
<p>Cameras, recent detections, and whether this site is recognising people.</p>
</header>
{error && <div className="err">{error}</div>}
<div className="grid cols-4" style={{ marginBottom: 16 }}>
<Stat label="People known" value={gallery.identities ?? '—'} />
<Stat label="Sightings" value={gallery.sightings ?? '—'} />
<Stat label="Cameras live"
value={`${cameras.filter(c => c.connected).length}/${cameras.length || 0}`} />
<Stat label="Below quality gate"
value={cameras.length ? `${Math.round(worst * 100)}%` : '—'}
tone={worst > 0.5 ? 'bad' : worst > 0.2 ? 'warn' : 'ok'}
sub={worst > 0.5 ? 'Most visitors are being missed — check camera placement'
: 'Share of faces too poor to enrol'} />
</div>
<Pipeline pipe={pipe} />
<div className="grid cols-2">
<div>
<div className="card">
<h3>Cameras</h3>
{cameras.length === 0
? <div className="empty">No cameras yet. Add one in Cameras.</div>
: <div className="feeds">
{cameras.map(c => (
<div className="feed" key={c.camera_id}>
{cams[c.camera_id]
? <img src={cams[c.camera_id]} alt={c.camera_id} />
: <div style={{ aspectRatio: '16/9' }} />}
<div className="cap">
<span>{c.camera_id}</span>
<span className={`pill ${c.connected ? 'ok' : 'bad'}`}>
<i className={`dot ${c.connected ? 'ok' : 'bad'}`} />
{c.connected ? 'live' : 'offline'}
</span>
</div>
</div>
))}
</div>}
</div>
</div>
<div className="card">
<h3>Recent detections</h3>
{events.length === 0
? <div className="empty">Nothing detected yet.</div>
: <ul className="events">
{events.map((e, i) => <EventRow key={i} e={e} />)}
</ul>}
</div>
</div>
</div>
)
}
// Whether anything is actually reaching head office. Without this the app can
// look perfectly healthy while every detection piles up on disk unsent — which
// is exactly what it did before the bridge existed.
function Pipeline({ pipe }) {
if (!pipe) return null
// A PC set up on its own is not "not linked yet" — nothing is coming, and
// saying so with an idle dot beside a count of zero reads as a fault.
if (pipe.standalone) {
return (
<div className="card" style={{ marginBottom: 16, display: 'flex',
gap: 10, alignItems: 'center' }}>
<i className="dot ok" />
<strong style={{ fontSize: 13 }}>Running on this PC only</strong>
<span className="note">Recognition and customers stay here.</span>
</div>
)
}
const stuck = pipe.claimed && !pipe.broker_up
return (
<div className="card" style={{ marginBottom: 16, display: 'flex',
gap: 22, alignItems: 'center', flexWrap: 'wrap' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<i className={`dot ${!pipe.claimed ? 'idle' : pipe.broker_up ? 'ok' : 'bad'}`} />
<strong style={{ fontSize: 13 }}>
{!pipe.claimed ? 'Not linked to head office'
: pipe.broker_up ? 'Sending to head office' : 'Offline — saving locally'}
</strong>
</span>
<span className="note">{pipe.accepted} recorded today</span>
{pipe.queued > 0 && (
<span className="note" style={stuck ? { color: 'var(--warn)' } : undefined}>
{pipe.queued} waiting to send
</span>
)}
{pipe.dropped > 0 && (
<span className="note" style={{ color: 'var(--bad)' }}>
{pipe.dropped} lost — this PC was offline too long
</span>
)}
</div>
)
}
function EventRow({ e }) {
const cls = e.type === 'person.new' ? 'new'
: e.type === 'person.seen' ? 'seen'
: e.type === 'person.missed' ? 'miss' : ''
const age = e.data?.age ?? e.data?.age_range
const extra = [e.data?.gender, age, e.data?.emotion].filter(Boolean).join(', ')
return (
<li>
<span className="when">{fmtTime(e.ts)}</span>
<span className={`tag ${cls}`}>{label(e.type)}</span>
<span style={{ flex: 1, minWidth: 0 }}>
{e.data?.label || e.camera_id}
{extra && <span className="note"> · {extra}</span>}
</span>
</li>
)
}
// The event names are internal; a shop manager should not have to learn them.
function label(type) {
return {
'person.new': 'new',
'person.seen': 'returning',
'person.missed': 'missed',
'camera.up': 'camera up',
'camera.down': 'camera down',
'identity.merged': 'merged',
}[type] ?? type
}
function Stat({ label, value, sub, tone }) {
return (
<div className="card stat">
<h3>{label}</h3>
<div className="value" style={tone ? { color: `var(--${tone})` } : undefined}>
{value}
</div>
{sub && <div className="sub">{sub}</div>}
</div>
)
}
// Stream URLs are fetched once per camera and then left alone: reassigning an
// MJPEG <img> src restarts the stream, so rebuilding them on every poll would
// make every feed flicker permanently.
function useCameraFeeds() {
const [urls, setUrls] = useState({})
const { data } = usePolled(() => api.cameras(), 10000)
useEffect(() => {
let cancelled = false
;(async () => {
const next = {}
for (const cam of data ?? []) {
if (urls[cam.id]) { next[cam.id] = urls[cam.id]; continue }
try { next[cam.id] = await api.streamURL(cam.id) } catch { /* engine down */ }
}
const changed = Object.keys(next).length !== Object.keys(urls).length ||
Object.keys(next).some(k => next[k] !== urls[k])
if (!cancelled && changed) setUrls(next)
})()
return () => { cancelled = true }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data])
return urls
}

View File

@@ -0,0 +1,52 @@
import { useState } from 'react'
import { api, message } from '../bridge.js'
// The gate. Nothing else in the app is reachable until this succeeds, because
// the broker credentials and the customer database both live behind it.
export default function Login({ onDone }) {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
async function submit(e) {
e.preventDefault()
setBusy(true); setError(null)
try {
onDone(await api.login(email.trim(), password))
} catch (err) {
setError(message(err))
} finally {
setBusy(false)
}
}
return (
<div className="login">
<div className="box">
<h1>Behavision</h1>
<p className="lead">Sign in to connect this PC to your store.</p>
<form onSubmit={submit}>
{error && <div className="err">{error}</div>}
<label className="field">
<span>Email</span>
<input type="email" value={email} autoComplete="username" required
autoFocus onChange={e => setEmail(e.target.value)} />
</label>
<label className="field">
<span>Password</span>
<input type="password" value={password} autoComplete="current-password"
required onChange={e => setPassword(e.target.value)} />
</label>
<button className="btn primary" disabled={busy || !email || !password}>
{busy ? 'Signing in…' : 'Sign in'}
</button>
</form>
<p className="foot">
Signing in downloads this store's recognition models and connects it
to your account. Nothing is sent until a camera is set up.
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,88 @@
import { useState } from 'react'
import { api } from '../bridge.js'
import { usePolled, daysAgo, today } from '../hooks.js'
const RANGES = [{ label: '7 days', days: 7 }, { label: '30 days', days: 30 },
{ label: '90 days', days: 90 }]
// Footfall against sales. The number the business actually judges the product
// by: how many of the people who walked in bought something.
export default function Sales() {
const [range, setRange] = useState(RANGES[1])
const { data, error } = usePolled(
() => api.sales(daysAgo(range.days), today()), 60000, [range.days])
const cur = data?.currency || 'INR'
const money = n => typeof n === 'number'
? new Intl.NumberFormat(undefined, { style: 'currency', currency: cur,
maximumFractionDigits: 0 }).format(n)
: '—'
const conv = data?.conversion ?? 0
return (
<div className="page">
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end' }}>
<div>
<h2>Sales summary</h2>
<p>How many of the people who came in actually bought something.</p>
</div>
<div style={{ display: 'flex', gap: 6 }}>
{RANGES.map(r => (
<button key={r.label} className="btn sm"
style={r.days === range.days
? { background: 'var(--accent-dim)', color: 'var(--accent)',
borderColor: 'var(--accent)' } : undefined}
onClick={() => setRange(r)}>{r.label}</button>
))}
</div>
</header>
{error && <div className="err">{error}</div>}
<div className="grid cols-4" style={{ marginBottom: 16 }}>
<Stat label="Visitors" value={data?.visitors ?? '—'} />
<Stat label="Bought something" value={data?.purchasers ?? '—'} />
<Stat label="Conversion" value={data ? `${Math.round(conv * 100)}%` : '—'}
tone={conv >= 0.3 ? 'ok' : conv > 0 ? 'warn' : undefined} />
<Stat label="Revenue" value={money(data?.revenue)}
sub={data ? `${money(data.average_basket)} average basket` : null} />
</div>
<div className="card">
<h3>Visitors who bought</h3>
{!data ? <div className="empty">Loading…</div>
: data.visitors === 0
? <div className="empty">No visits recorded in this period.</div>
: <>
<div style={{ display: 'flex', height: 34, borderRadius: 6,
overflow: 'hidden', border: '1px solid var(--line)' }}>
<div style={{ width: `${conv * 100}%`, background: 'var(--accent)' }} />
<div style={{ flex: 1, background: 'var(--surface-2)' }} />
</div>
<div className="key">
<span><i style={{ background: 'var(--accent)' }} />
Bought — {data.purchasers}</span>
<span><i style={{ background: 'var(--surface-2)' }} />
Left without buying — {data.visitors - data.purchasers}</span>
</div>
</>}
</div>
<p className="note" style={{ marginTop: 14 }}>
Sales come from what staff enter on the customer form. A visit with no
amount counts as a visit that did not convert, which is what makes this
figure meaningful.
</p>
</div>
)
}
function Stat({ label, value, sub, tone }) {
return (
<div className="card stat">
<h3>{label}</h3>
<div className="value" style={tone ? { color: `var(--${tone})` } : undefined}>{value}</div>
{sub && <div className="sub">{sub}</div>}
</div>
)
}

View File

@@ -0,0 +1,103 @@
import { useState } from 'react'
import { api, message } from '../bridge.js'
// Linking this PC to a shop — the first thing that happens on a new install,
// and until now the one thing the app could not do.
//
// It comes BEFORE sign-in on purpose. The installer standing at a new counter
// has an installation code and, quite often, no account of their own yet; the
// PC's identity is not a person's identity. The endpoint behind this is
// deliberately unauthenticated for the same reason — requiring a login first
// would mean shipping a password to every shop that installs the software.
export default function Setup({ onDone, onCancel }) {
const [code, setCode] = useState('')
const [busy, setBusy] = useState(null)
const [error, setError] = useState(null)
const [alone, setAlone] = useState(false)
async function submit(e) {
e.preventDefault()
setBusy('claim'); setError(null)
try {
onDone(await api.claim(code))
} catch (err) {
setError(message(err))
} finally {
setBusy(null)
}
}
async function standalone() {
setBusy('alone'); setError(null)
try {
onDone(await api.runStandalone())
} catch (err) {
setError(message(err))
} finally {
setBusy(null)
}
}
return (
<div className="login">
<div className="box">
<h1>{onCancel ? 'Link to head office' : 'Set up this PC'}</h1>
<p className="lead">
Type the installation code for this shop. You only do this once.
</p>
<form onSubmit={submit}>
{error && <div className="err">{error}</div>}
<label className="field">
<span>Installation code</span>
{/* Uppercase and letter-spaced because the code arrives read aloud
down a phone or photographed off a screen. Spaces, dashes and
case are stripped on the server, so what is typed here can be
as untidy as it needs to be. */}
<input value={code} autoFocus required
placeholder="ABCDEF-123456-GHIJKL-789012"
autoComplete="off" spellCheck="false"
style={{ textTransform: 'uppercase', letterSpacing: '.06em' }}
onChange={e => setCode(e.target.value)} />
</label>
<button className="btn primary" disabled={!!busy || code.trim().length < 6}>
{busy === 'claim' ? 'Linking…' : 'Link this PC'}
</button>
</form>
<p className="foot">
The code works once. Ask whoever manages your shops for it — they can
create one from the Behavision platform, under the shop.
</p>
{/* The second way out of this screen, and the reason it exists.
Recognition, the cameras and this shop's own gallery all run on
this PC and need no server, so a shop with one till and no head
office was being blocked from adding a camera until somebody
issued it a code — the software refusing to do the thing it is
for. Linking later is still one click away, and it keeps the
visits already recorded here. */}
<div className="alt">
{onCancel
? <button type="button" className="linkbtn" onClick={onCancel}>
Not now — go back
</button>
: !alone
? <button type="button" className="linkbtn" onClick={() => setAlone(true)}>
No head office — set this PC up on its own
</button>
: <>
<p className="note">
This PC will watch its cameras and recognise returning
customers on its own. Nothing is sent anywhere. You can link
it to head office later without losing anything recorded
here.
</p>
<button type="button" className="btn" disabled={!!busy}
onClick={standalone}>
{busy === 'alone' ? 'Setting up…' : 'Use this PC on its own'}
</button>
</>}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,51 @@
import { api } from '../bridge.js'
import { usePolled } from '../hooks.js'
// One customer's timeline.
//
// The endpoint and the binding for this both already existed and nothing
// called them, which meant the product could recognise a returning customer
// and then had no screen able to say when they had been before — the single
// question staff ask about a regular.
//
// Not polled: a record sheet open on a counter should not re-query every few
// seconds, and the visit that matters is happening at the counter, not in the
// list.
export default function VisitHistory({ customer }) {
const { data, error, loading } = usePolled(
() => api.visitorHistory(customer.id, 50), 0, [customer.id])
if (loading) return <p className="note">Loading visits…</p>
if (error) return <p className="note">Could not load visits: {error}</p>
const visits = data ?? []
if (visits.length === 0) {
return <p className="note">No recorded visits yet.</p>
}
return (
<ul className="timeline">
{visits.map(v => (
<li key={v.id}>
<span className="when">{whenLabel(v.occurred_at)}</span>
<span className="where">
{v.site || 'this store'}
{v.camera_id ? <span className="note"> · {v.camera_id}</span> : null}
</span>
{v.is_new_visitor && <span className="tag new">first visit</span>}
</li>
))}
</ul>
)
}
// The server returns visit times as an instant; showing the date and the time
// of day matters more than precision here — "Tuesday afternoon" is how staff
// remember a customer.
function whenLabel(iso) {
const d = new Date(iso)
if (isNaN(d)) return '—'
return d.toLocaleString([], {
day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit',
})
}

View File

@@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
build: {
outDir: 'dist',
// Wails serves these from an embedded FS at the root, so relative asset
// paths are required - absolute ones 404 inside the webview.
assetsDir: 'assets',
emptyOutDir: true,
},
base: './',
})

29
desktop/go.mod Normal file
View File

@@ -0,0 +1,29 @@
module github.com/loyaly/behavision-desktop
go 1.22
// The desktop app is the agent with a face on it: same supervisor, same
// durable spool, same broker client, all already tested. A replace rather
// than a copy so there is exactly one implementation of each.
replace github.com/loyaly/behavision-agent => ../agent
require (
fyne.io/systray v1.12.2
github.com/loyaly/behavision-agent v0.0.0
github.com/wailsapp/wails/v2 v2.9.2
)
require (
github.com/eclipse/paho.mqtt.golang v1.4.3 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/leaanthony/go-ansi-parser v1.6.0 // indirect
github.com/leaanthony/slicer v1.6.0 // indirect
github.com/leaanthony/u v1.1.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.4 // indirect
github.com/wailsapp/go-webview2 v1.0.16 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/sys v0.20.0 // indirect
)

94
desktop/go.sum Normal file
View File

@@ -0,0 +1,94 @@
fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA=
fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/eclipse/paho.mqtt.golang v1.4.3 h1:2kwcUGn8seMUfWndX0hGbvH8r7crgcJguQNCyp70xik=
github.com/eclipse/paho.mqtt.golang v1.4.3/go.mod h1:CSYvoAlsMkhYOXh/oKyxa8EcBci6dVkLCbo5tTC1RIE=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/labstack/echo/v4 v4.10.2 h1:n1jAhnq/elIFTHr1EYpiYtyKgx4RW9ccVgkqByZaN2M=
github.com/labstack/echo/v4 v4.10.2/go.mod h1:OEyqf2//K1DFdE57vw2DRgWY0M7s65IVQO2FzvI4J5k=
github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8=
github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM=
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
github.com/leaanthony/go-ansi-parser v1.6.0 h1:T8TuMhFB6TUMIUm0oRrSbgJudTFw9csT3ZK09w0t4Pg=
github.com/leaanthony/go-ansi-parser v1.6.0/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
github.com/leaanthony/gosod v1.0.3 h1:Fnt+/B6NjQOVuCWOKYRREZnjGyvg+mEhd1nkkA04aTQ=
github.com/leaanthony/gosod v1.0.3/go.mod h1:BJ2J+oHsQIyIQpnLPjnqFGTMnOZXDbvWtRCSG7jGxs4=
github.com/leaanthony/slicer v1.5.0/go.mod h1:FwrApmf8gOrpzEWM2J/9Lh79tyq8KTX5AzRtwV7m4AY=
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
github.com/leaanthony/u v1.1.0 h1:2n0d2BwPVXSUq5yhe8lJPHdxevE2qK5G99PMStMZMaI=
github.com/leaanthony/u v1.1.0/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM=
github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/tkrajina/go-reflector v0.5.6 h1:hKQ0gyocG7vgMD2M3dRlYN6WBBOmdoOzJ6njQSepKdE=
github.com/tkrajina/go-reflector v0.5.6/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/wailsapp/go-webview2 v1.0.16 h1:wffnvnkkLvhRex/aOrA3R7FP7rkvOqL/bir1br7BekU=
github.com/wailsapp/go-webview2 v1.0.16/go.mod h1:Uk2BePfCRzttBBjFrBmqKGJd41P6QIHeV9kTgIeOZNo=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.9.2 h1:Xb5YRTos1w5N7DTMyYegWaGukCP2fIaX9WF21kPPF2k=
github.com/wailsapp/wails/v2 v2.9.2/go.mod h1:uehvlCwJSFcBq7rMCGfk4rxca67QQGsbg5Nm4m9UnBs=
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 h1:k/i9J1pBpvlfR+9QsetwPyERsqu1GIbi967PQMq3Ivc=
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

130
desktop/icons.go Normal file
View File

@@ -0,0 +1,130 @@
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() }

93
desktop/icons_test.go Normal file
View 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
}
}

View File

@@ -0,0 +1,504 @@
// Package cloud talks to the Behavision server at mcp.loyaly.ai.
//
// Everything a store PC sends to head office goes over MQTT; this is the
// request/response half — logging in, reading reports, saving the customer
// form. A store PC never holds database credentials, so every one of these is
// a call the server authorises against the session token.
package cloud
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
// ErrUnauthorized means the session is gone. The UI shows the login sheet
// again rather than an error dialog - an expired token is an ordinary event,
// not a fault.
var ErrUnauthorized = errors.New("session expired")
type Client struct {
Base string
http *http.Client
mu sync.RWMutex
token string
refresh string
user User
// Held across a whole refresh so concurrent screens cannot each spend the
// single-use refresh token.
refreshMu sync.Mutex
onRefresh func(Session)
}
type User struct {
ID string `json:"id"`
Email string `json:"email"`
FullName string `json:"full_name"`
Role string `json:"role"`
ClientID string `json:"client_id"`
Client string `json:"client_name"`
}
type Session struct {
Token string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
User User `json:"user"`
}
func New(base string) *Client {
return &Client{
Base: strings.TrimRight(base, "/"),
http: &http.Client{Timeout: 30 * time.Second},
}
}
func (c *Client) SetSession(s Session) {
c.mu.Lock()
defer c.mu.Unlock()
c.token, c.refresh, c.user = s.Token, s.RefreshToken, s.User
}
func (c *Client) Clear() {
c.mu.Lock()
defer c.mu.Unlock()
c.token, c.refresh, c.user = "", "", User{}
}
func (c *Client) User() User {
c.mu.RLock()
defer c.mu.RUnlock()
return c.user
}
func (c *Client) LoggedIn() bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.token != ""
}
// do sends a request, refreshing the session once if the access token has
// expired.
//
// The body is marshalled up front and kept, because a retry has to send it
// again and an io.Reader is spent after the first attempt - a bug that only
// shows up twelve hours after a shop PC was last touched, which is the worst
// possible time to find it.
func (c *Client) do(ctx context.Context, method, path string, body, out any) error {
var raw []byte
if body != nil {
var err error
if raw, err = json.Marshal(body); err != nil {
return err
}
}
err := c.send(ctx, method, path, raw, out)
if !errors.Is(err, errTokenExpired) {
return err
}
if rerr := c.Refresh(ctx); rerr != nil {
// The refresh token is gone too, so this really is a sign-in, not a
// transient failure. Report it as such so the UI shows the login sheet
// rather than an error dialog.
return ErrUnauthorized
}
return c.send(ctx, method, path, raw, out)
}
// APIError carries the server's machine-readable code alongside the prose.
//
// Some codes are not failures at all: a customer with no photo is the default
// configuration of this product, not a fault, and a caller cannot tell that
// from the message text. Error() still returns the server's own words, so
// anything that only prints the error is unaffected.
type APIError struct {
Status int
Code string
Message string
}
func (e *APIError) Error() string { return e.Message }
// codeOf reports the server's error code, or "" for anything else.
func codeOf(err error) string {
var ae *APIError
if errors.As(err, &ae) {
return ae.Code
}
return ""
}
// errTokenExpired is internal: callers see either success or ErrUnauthorized.
// An expiring access token is an ordinary event that the client handles on its
// own, not something every screen should have to know about.
var errTokenExpired = errors.New("access token expired")
func (c *Client) send(ctx context.Context, method, path string, raw []byte, out any) error {
var rdr io.Reader
if raw != nil {
rdr = bytes.NewReader(raw)
}
req, err := http.NewRequestWithContext(ctx, method, c.Base+path, rdr)
if err != nil {
return err
}
if raw != nil {
req.Header.Set("Content-Type", "application/json")
}
c.mu.RLock()
tok := c.token
c.mu.RUnlock()
if tok != "" {
req.Header.Set("Authorization", "Bearer "+tok)
}
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("cannot reach %s: %w", c.Base, err)
}
defer resp.Body.Close()
// The server returns {error, message, detail}; showing `message` puts the
// server's own words in front of the user instead of a status code.
var e struct {
Message string `json:"message"`
Error string `json:"error"`
}
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
_ = json.Unmarshal(body, &e)
}
switch {
case resp.StatusCode == http.StatusUnauthorized && e.Error == "token_expired":
return errTokenExpired
case resp.StatusCode == http.StatusUnauthorized:
return ErrUnauthorized
case resp.StatusCode >= 400:
msg := e.Message
if msg == "" {
msg = fmt.Sprintf("%s %s: %s", method, path, resp.Status)
}
return &APIError{Status: resp.StatusCode, Code: e.Error, Message: msg}
}
if out == nil {
return nil
}
return json.NewDecoder(io.LimitReader(resp.Body, 8<<20)).Decode(out)
}
// Refresh swaps the refresh token for a new pair.
//
// Serialised behind refreshMu so a screen that fires four polls at once does
// not spend the refresh token four times - the server rotates it on use, so
// three of those four would race and lose, logging the shop out at random.
func (c *Client) Refresh(ctx context.Context) error {
c.refreshMu.Lock()
defer c.refreshMu.Unlock()
c.mu.RLock()
before, refresh := c.token, c.refresh
c.mu.RUnlock()
if refresh == "" {
return ErrUnauthorized
}
var s Session
if err := c.send(ctx, http.MethodPost, "/api/auth/refresh",
mustJSON(map[string]string{"refresh_token": refresh}), &s); err != nil {
return err
}
c.mu.Lock()
// Another goroutine may have refreshed while this one waited on the lock;
// its tokens are the live ones and must not be overwritten by ours.
if c.token == before {
c.token, c.refresh = s.Token, s.RefreshToken
if s.User.Email != "" {
c.user = s.User
}
}
c.mu.Unlock()
if c.onRefresh != nil {
// So the caller can persist the rotated tokens. Without this a PC that
// refreshes and then reboots comes back holding a refresh token the
// server already invalidated.
c.onRefresh(s)
}
return nil
}
// OnRefresh registers a callback fired whenever the session rotates.
func (c *Client) OnRefresh(fn func(Session)) { c.onRefresh = fn }
func mustJSON(v any) []byte {
b, err := json.Marshal(v)
if err != nil {
panic(err) // a map of strings cannot fail to marshal
}
return b
}
func (c *Client) Login(ctx context.Context, email, password string) (Session, error) {
var s Session
err := c.do(ctx, http.MethodPost, "/api/auth/login",
map[string]string{"email": email, "password": password}, &s)
if err != nil {
return Session{}, err
}
c.SetSession(s)
return s, nil
}
func (c *Client) Me(ctx context.Context) (User, error) {
var u User
err := c.do(ctx, http.MethodGet, "/api/auth/me", nil, &u)
if err == nil {
c.mu.Lock()
c.user = u
c.mu.Unlock()
}
return u, err
}
// Logout revokes the session server-side as well as forgetting it here.
// Clearing only the local copy leaves a live token on a machine somebody is
// about to hand back.
func (c *Client) Logout(ctx context.Context) error {
err := c.do(ctx, http.MethodPost, "/api/auth/logout", nil, nil)
c.Clear()
return err
}
// Session returns the current tokens so the caller can persist them.
func (c *Client) Session() Session {
c.mu.RLock()
defer c.mu.RUnlock()
return Session{Token: c.token, RefreshToken: c.refresh, User: c.user}
}
// Bootstrap is what a freshly installed PC asks for after the operator logs
// in: which models to fetch, and the broker credentials for this site. The
// installer ships none of this, so a leaked build hands out nothing.
type Bootstrap struct {
SiteID string `json:"site_id"`
SiteName string `json:"site_name"`
SiteSlug string `json:"site_slug"`
// ClientSlug and SiteSlug are what the agent's topic prefix is built from,
// and the server derives ClientSlug from the broker username so the two
// cannot disagree with the broker's ACL.
ClientSlug string `json:"client_slug"`
MQTTURL string `json:"mqtt_url"`
MQTTUser string `json:"mqtt_username"`
MQTTPass string `json:"mqtt_password"`
// AgentToken is this PC's own credential for the HTTPS API - asking for an
// image upload URL, pulling its camera list. Not the broker password: they
// authenticate different things, so rotating one must not break the other.
AgentToken string `json:"agent_token"`
CACert string `json:"ca_cert"`
Models []Model `json:"models"`
}
type Model struct {
Name string `json:"name"`
URL string `json:"url"`
SHA256 string `json:"sha256"`
Bytes int64 `json:"bytes"`
}
func (c *Client) Bootstrap(ctx context.Context, siteToken string) (Bootstrap, error) {
var b Bootstrap
return b, c.do(ctx, http.MethodPost, "/api/agent/enrol",
map[string]string{"site_token": siteToken}, &b)
}
type FootfallPoint struct {
Bucket string `json:"bucket"`
Visitors int `json:"visitors"`
New int `json:"new"`
Returning int `json:"returning"`
}
type FootfallReport struct {
From string `json:"from"`
To string `json:"to"`
Bucket string `json:"bucket"`
TZ string `json:"timezone"`
Points []FootfallPoint `json:"points"`
// Total is unique people over the whole window; Visits counts every
// appearance. Summing Points gives neither - a customer who came on Monday
// and Thursday is one Total and two bucket-visitors - so both ship rather
// than letting a screen add up the chart and call it a headcount.
Total int `json:"total"`
Visits int `json:"visits"`
// Share of faces the cameras saw that fell below the enrolment gate. A
// footfall figure from a badly placed camera is wrong in a way nobody can
// see, so the number ships with its own confidence.
FractionBelowGate float64 `json:"fraction_below_gate"`
WorstSite string `json:"worst_site,omitempty"`
}
// SiteHealth distinguishes "no customers" from "this shop's PC has been
// unplugged for a week" - two identical rows of zeroes with completely
// different responses.
type SiteHealth struct {
SiteID string `json:"site_id"`
Slug string `json:"slug"`
Name string `json:"name"`
Timezone string `json:"timezone"`
Online bool `json:"online"`
LastHeartbeatAt string `json:"last_heartbeat_at"`
LastEventAt string `json:"last_event_at"`
RecognitionModel string `json:"recognition_model"`
AgentVersion string `json:"agent_version"`
CamerasUp int `json:"cameras_up"`
CamerasTotal int `json:"cameras_total"`
FractionBelowGate float64 `json:"fraction_below_gate"`
Queued int `json:"queued"`
Dropped int64 `json:"dropped"`
}
func (c *Client) Sites(ctx context.Context) ([]SiteHealth, error) {
var out []SiteHealth
return out, c.do(ctx, http.MethodGet, "/api/sites", nil, &out)
}
// Visit is one appearance in a customer's timeline.
type Visit struct {
ID string `json:"id"`
OccurredAt string `json:"occurred_at"`
Site string `json:"site"`
CameraID string `json:"camera_id"`
IsNew bool `json:"is_new_visitor"`
Similarity float64 `json:"similarity"`
Quality float64 `json:"quality"`
Attributes map[string]any `json:"attributes"`
}
// Photo is a customer's face image, or a plain statement that there isn't one.
//
// Absence is modelled as data rather than as an error because it is the
// ordinary case: images are off by default, so most deployments answer
// "no photo" for every customer forever. Returning an error there would put a
// red failure box on screen for a system working exactly as configured, and a
// UI that cries wolf is a UI whose real errors get ignored.
type Photo struct {
URL string `json:"url"`
ExpiresIn int `json:"expires_in"`
Available bool `json:"available"`
Reason string `json:"reason"`
}
// VisitorImage fetches a short-lived signed link to this customer's photo.
//
// The link expires (the server decides how soon, and says so), so it is
// fetched when a screen opens rather than cached alongside the customer.
func (c *Client) VisitorImage(ctx context.Context, id string) (Photo, error) {
var out Photo
err := c.do(ctx, http.MethodGet,
"/api/visitors/"+url.PathEscape(id)+"/image", nil, &out)
if err != nil {
switch codeOf(err) {
case "no_image":
return Photo{Reason: "No photo of this customer has been captured."}, nil
case "images_disabled":
return Photo{Reason: "This system is not storing customer photos."}, nil
}
return Photo{}, err
}
out.Available = out.URL != ""
return out, nil
}
// ForgetVisitor erases a customer: face template, photo and profile.
//
// Irreversible by design — a soft-deleted face template is a retained
// photograph by another name, because template inversion reconstructs a
// recognisable face from it. The server refuses the whole request rather than
// report a partial erasure, so an error here means nothing was deleted.
func (c *Client) ForgetVisitor(ctx context.Context, id string) error {
return c.do(ctx, http.MethodDelete,
"/api/visitors/"+url.PathEscape(id), nil, nil)
}
func (c *Client) VisitorHistory(ctx context.Context, id string, limit int) ([]Visit, error) {
var out []Visit
return out, c.do(ctx, http.MethodGet,
fmt.Sprintf("/api/visitors/%s/history?limit=%d", url.PathEscape(id), limit),
nil, &out)
}
func (c *Client) Footfall(ctx context.Context, from, to, bucket string) (FootfallReport, error) {
var r FootfallReport
return r, c.do(ctx, http.MethodGet,
fmt.Sprintf("/api/reports/footfall?from=%s&to=%s&bucket=%s", from, to, bucket),
nil, &r)
}
type SalesReport struct {
Visitors int `json:"visitors"`
Purchasers int `json:"purchasers"`
Conversion float64 `json:"conversion"`
Revenue float64 `json:"revenue"`
AvgBasket float64 `json:"average_basket"`
Currency string `json:"currency"`
}
func (c *Client) Sales(ctx context.Context, from, to string) (SalesReport, error) {
var r SalesReport
return r, c.do(ctx, http.MethodGet,
fmt.Sprintf("/api/reports/conversion?from=%s&to=%s", from, to), nil, &r)
}
type Customer struct {
ID string `json:"id"`
Label string `json:"label"`
FullName string `json:"full_name"`
Phone string `json:"phone"`
Email string `json:"email"`
VisitCount int `json:"visit_count"`
FirstSeenAt string `json:"first_seen_at"`
LastSeenAt string `json:"last_seen_at"`
HasProfile bool `json:"has_profile"`
HasConsent bool `json:"has_consent"`
}
func (c *Client) Customers(ctx context.Context, query string, limit int) ([]Customer, error) {
var out []Customer
return out, c.do(ctx, http.MethodGet,
fmt.Sprintf("/api/visitors?q=%s&limit=%d",
url.QueryEscape(query), limit), nil, &out)
}
// Profile is the in-store form. PUT rather than POST: a staff member
// resubmitting on a bad connection must not create a second record for the
// same person.
type Profile struct {
VisitorID string `json:"visitor_id"`
FullName string `json:"full_name"`
Phone string `json:"phone"`
Email string `json:"email"`
Gender string `json:"gender"`
DateOfBirth string `json:"date_of_birth"`
Notes string `json:"notes"`
Consent bool `json:"consent"`
}
func (c *Client) SaveProfile(ctx context.Context, p Profile) error {
return c.do(ctx, http.MethodPut,
"/api/visitors/"+url.PathEscape(p.VisitorID)+"/profile", p, nil)
}
func (c *Client) RecordPurchase(ctx context.Context, visitorID string,
amount float64, items []string, notes string) error {
return c.do(ctx, http.MethodPost, "/api/purchases", map[string]any{
"visitor_id": visitorID, "amount": amount,
"items": items, "source": "manual", "notes": notes,
}, nil)
}

View File

@@ -0,0 +1,139 @@
package cloud
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
)
func serve(t *testing.T, h http.HandlerFunc) *Client {
t.Helper()
srv := httptest.NewServer(h)
t.Cleanup(srv.Close)
c := New(srv.URL)
c.SetSession(Session{Token: "test-token"})
return c
}
func fail(w http.ResponseWriter, status int, code, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": code, "message": msg})
}
// A customer with no photo is the DEFAULT configuration of this product, not a
// fault. If it surfaced as an error the record sheet would show a red failure
// box for every customer in every shop that has not turned images on.
func TestNoPhotoIsNotAnError(t *testing.T) {
for _, tc := range []struct{ code, want string }{
{"no_image", "No photo"},
{"images_disabled", "not storing"},
} {
t.Run(tc.code, func(t *testing.T) {
c := serve(t, func(w http.ResponseWriter, r *http.Request) {
fail(w, http.StatusNotFound, tc.code, "server prose")
})
p, err := c.VisitorImage(context.Background(), "abc")
if err != nil {
t.Fatalf("returned an error for a normal state: %v", err)
}
if p.Available {
t.Error("Available should be false when there is no photo")
}
if p.Reason == "" {
t.Error("a missing photo must come with an explanation")
}
})
}
}
func TestPhotoReturnsTheSignedLink(t *testing.T) {
c := serve(t, func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer test-token" {
t.Errorf("Authorization = %q", got)
}
json.NewEncoder(w).Encode(map[string]any{
"url": "https://example.test/signed", "expires_in": 900})
})
p, err := c.VisitorImage(context.Background(), "abc")
if err != nil {
t.Fatal(err)
}
if !p.Available || p.URL != "https://example.test/signed" || p.ExpiresIn != 900 {
t.Fatalf("got %+v", p)
}
}
// A real failure must still be a failure: silently rendering initials would
// hide a broken server behind a design that looks intentional.
func TestPhotoServerErrorIsAnError(t *testing.T) {
c := serve(t, func(w http.ResponseWriter, r *http.Request) {
fail(w, http.StatusInternalServerError, "server_error", "boom")
})
if _, err := c.VisitorImage(context.Background(), "abc"); err == nil {
t.Fatal("a 500 must not be reported as 'no photo'")
}
}
// The server deletes stored images before it touches the database and refuses
// the whole request if one fails, so an error here means NOTHING was erased.
// Swallowing it would tell a shop a legal request had been honoured when it
// had not.
func TestForgetVisitorSurfacesFailure(t *testing.T) {
c := serve(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
t.Errorf("method = %s, want DELETE", r.Method)
}
fail(w, http.StatusBadGateway, "storage_error",
"The photo could not be deleted, so nothing was erased.")
})
err := c.ForgetVisitor(context.Background(), "abc")
if err == nil {
t.Fatal("a refused erasure must not look like success")
}
if err.Error() != "The photo could not be deleted, so nothing was erased." {
t.Errorf("lost the server's own words: %q", err)
}
}
func TestForgetVisitorSucceedsOn204(t *testing.T) {
c := serve(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
})
if err := c.ForgetVisitor(context.Background(), "abc"); err != nil {
t.Fatal(err)
}
}
// APIError carries the code without changing what anything that prints the
// error sees — every existing screen relies on that text.
func TestAPIErrorKeepsServerMessage(t *testing.T) {
c := serve(t, func(w http.ResponseWriter, r *http.Request) {
fail(w, http.StatusForbidden, "forbidden",
"Your account cannot delete customer records.")
})
err := c.ForgetVisitor(context.Background(), "abc")
if err.Error() != "Your account cannot delete customer records." {
t.Errorf("message = %q", err)
}
var ae *APIError
if !errors.As(err, &ae) || ae.Code != "forbidden" || ae.Status != 403 {
t.Errorf("code not preserved: %+v", ae)
}
}
// An id with a slash or a space must not silently address a different route.
func TestVisitorIDIsPathEscaped(t *testing.T) {
var got string
c := serve(t, func(w http.ResponseWriter, r *http.Request) {
got = r.URL.EscapedPath()
w.WriteHeader(http.StatusNoContent)
})
c.ForgetVisitor(context.Background(), "a b/c") //nolint:errcheck
if got != "/api/visitors/a%20b%2Fc" {
t.Errorf("path = %q", got)
}
}

View File

@@ -0,0 +1,134 @@
// Package local talks to the Python recognition engine running on this PC.
//
// The desktop app is a CLIENT of the engine and never imports it. The engine
// owns the cameras, the models and the SQLite gallery; two processes touching
// one webcam or one WAL is the failure this separation exists to prevent.
package local
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
type Client struct {
Base string
User string
Password string
http *http.Client
}
func New(base, user, password string) *Client {
return &Client{
Base: strings.TrimRight(base, "/"), User: user, Password: password,
// Generous: a camera Test opens an RTSP stream and can legitimately
// take ten seconds against a slow NVR.
http: &http.Client{Timeout: 45 * time.Second},
}
}
func (c *Client) do(ctx context.Context, method, path string, body, out any) error {
var rdr io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return err
}
rdr = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, c.Base+path, rdr)
if err != nil {
return err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.User != "" {
req.SetBasicAuth(c.User, c.Password)
}
resp, err := c.http.Do(req)
if err != nil {
// The single most common state on a fresh install: the engine has not
// been started yet. Say that, rather than surfacing a dial error the
// user cannot act on.
return fmt.Errorf("engine not reachable at %s (is it running?): %w", c.Base, err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return fmt.Errorf("engine %s %s: %s: %s", method, path, resp.Status,
strings.TrimSpace(string(msg)))
}
if out == nil {
return nil
}
return json.NewDecoder(io.LimitReader(resp.Body, 8<<20)).Decode(out)
}
func (c *Client) Health(ctx context.Context) (map[string]any, error) {
var out map[string]any
return out, c.do(ctx, http.MethodGet, "/api/health", nil, &out)
}
func (c *Client) Stats(ctx context.Context) (map[string]any, error) {
var out map[string]any
return out, c.do(ctx, http.MethodGet, "/api/stats", nil, &out)
}
func (c *Client) Events(ctx context.Context, limit int) ([]map[string]any, error) {
var out []map[string]any
return out, c.do(ctx, http.MethodGet,
fmt.Sprintf("/api/events?limit=%d", limit), nil, &out)
}
func (c *Client) Cameras(ctx context.Context) ([]map[string]any, error) {
var out []map[string]any
return out, c.do(ctx, http.MethodGet, "/api/cameras", nil, &out)
}
func (c *Client) AddCamera(ctx context.Context, cam map[string]any) (map[string]any, error) {
var out map[string]any
return out, c.do(ctx, http.MethodPost, "/api/cameras", cam, &out)
}
func (c *Client) UpdateCamera(ctx context.Context, id string, cam map[string]any) (map[string]any, error) {
var out map[string]any
return out, c.do(ctx, http.MethodPatch, "/api/cameras/"+id, cam, &out)
}
func (c *Client) DeleteCamera(ctx context.Context, id string) error {
return c.do(ctx, http.MethodDelete, "/api/cameras/"+id, nil, nil)
}
func (c *Client) TestCamera(ctx context.Context, cam map[string]any) (map[string]any, error) {
var out map[string]any
return out, c.do(ctx, http.MethodPost, "/api/cameras/test", cam, &out)
}
func (c *Client) StartPlacementCheck(ctx context.Context, id string, seconds float64) (map[string]any, error) {
var out map[string]any
return out, c.do(ctx, http.MethodPost, "/api/cameras/"+id+"/commission",
map[string]any{"seconds": seconds}, &out)
}
func (c *Client) PlacementResult(ctx context.Context, id string) (map[string]any, error) {
var out map[string]any
return out, c.do(ctx, http.MethodGet, "/api/cameras/"+id+"/commission", nil, &out)
}
func (c *Client) Identities(ctx context.Context, limit int) ([]map[string]any, error) {
var out []map[string]any
return out, c.do(ctx, http.MethodGet,
fmt.Sprintf("/api/identities?limit=%d", limit), nil, &out)
}
func (c *Client) Sightings(ctx context.Context, limit int) ([]map[string]any, error) {
var out []map[string]any
return out, c.do(ctx, http.MethodGet,
fmt.Sprintf("/api/sightings?limit=%d", limit), nil, &out)
}

66
desktop/main.go Normal file
View File

@@ -0,0 +1,66 @@
// Command behavision-desktop is the store-facing application: a tray icon, a
// window, and the supervisor for the recognition engine.
//
// It is one process rather than three because the tray, the window and the
// supervisor all need the same state, and because a user who quits the tray
// expects recognition to stop. It is deliberately NOT a Windows service: a
// service runs in session 0 and cannot draw a tray icon, and spawning a child
// process needs no elevation while controlling a service does.
package main
import (
"context"
"embed"
"log"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
"github.com/wailsapp/wails/v2/pkg/options/windows"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
//go:embed all:frontend/dist
var assets embed.FS
func main() {
app := NewApp()
tray := newTray(app)
err := wails.Run(&options.App{
Title: "Behavision",
Width: 1280,
Height: 820,
// Small enough to still be usable on a cramped shop-counter monitor.
MinWidth: 1024,
MinHeight: 640,
AssetServer: &assetserver.Options{Assets: assets},
// Closing the window hides it rather than quitting: the engine must
// keep recognising after a shop assistant clicks the X, and the tray
// is where they get the window back.
HideWindowOnClose: true,
OnStartup: func(ctx context.Context) {
app.startup(ctx)
tray.start(ctx)
},
OnBeforeClose: func(ctx context.Context) bool {
runtime.Hide(ctx)
return true // prevent the close
},
OnShutdown: func(ctx context.Context) {
tray.stop()
app.StopEngine()
},
Bind: []any{app},
Windows: &windows.Options{
WebviewIsTransparent: false,
WindowIsTranslucent: false,
// A shop PC is not a developer machine; a stray right-click that
// opens devtools looks like the software is broken.
DisableWindowIcon: false,
},
})
if err != nil {
log.Fatalf("behavision-desktop: %v", err)
}
}

174
desktop/tray.go Normal file
View 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
}

17
desktop/wails.json Normal file
View File

@@ -0,0 +1,17 @@
{
"$schema": "https://wails.io/schemas/config.v2.json",
"name": "Behavision",
"outputfilename": "Behavision",
"frontend:install": "npm install",
"frontend:build": "npm run build",
"frontend:dev:watcher": "npm run dev",
"frontend:dev:serverUrl": "auto",
"author": { "name": "Loyaly" },
"info": {
"companyName": "Loyaly",
"productName": "Behavision",
"productVersion": "0.1.0",
"copyright": "© Loyaly",
"comments": "Footfall and customer recognition for retail"
}
}

162
installer/behavision.iss Normal file
View File

@@ -0,0 +1,162 @@
; Behavision installer (Inno Setup 6).
;
; Built by installer\build.ps1, which stages dist\Behavision first. Compile it
; by hand with:
; ISCC.exe /DMyAppVersion=0.1.0 installer\behavision.iss
;
; Three decisions worth reading before changing anything here:
;
; 1. ADMIN AT INSTALL TIME, NEVER AT RUN TIME. The files go under Program
; Files, so installing needs elevation. Running does not: the app is a
; normal user-session process that spawns the engine as a child, which is
; also why it can draw a tray icon at all - a Windows service runs in
; session 0 and cannot.
;
; 2. NO MODELS IN THE PACKAGE. They are ~200 MB and `setup-models` downloads
; them resumably into the state root on first run. Bundling them would
; quadruple this file and force a re-sign for a model change.
;
; 3. NOTHING WRITABLE UNDER Program Files. The database, logs, camera list,
; downloaded models and agent config all live in %PROGRAMDATA%\Behavision,
; which is what makes an upgrade a file copy rather than a migration. That
; split is behavision/paths.py and agent/pkg/paths, and this file must not
; contradict it.
#define MyAppName "Behavision"
#ifndef MyAppVersion
#define MyAppVersion "0.1.0"
#endif
#define MyAppPublisher "Loyaly"
#define MyAppURL "https://platform.loyaly.ai"
#define MyAppExeName "Behavision.exe"
[Setup]
AppId={{7C4B9E2A-3F51-4C86-9D0A-B1E7A2F65D11}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
AppPublisher={#MyAppPublisher}
AppPublisherURL={#MyAppURL}
DefaultDirName={autopf}\{#MyAppName}
DefaultGroupName={#MyAppName}
DisableProgramGroupPage=yes
OutputDir=..\dist
OutputBaseFilename=Behavision-Setup-{#MyAppVersion}
Compression=lzma2/max
SolidCompression=yes
WizardStyle=modern
; Admin, because Program Files is. See decision 1 above.
PrivilegesRequired=admin
ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible
UninstallDisplayIcon={app}\{#MyAppExeName}
; The engine folder alone is ~400 MB unpacked; saying so up front beats a
; wizard that stops halfway on a small shop PC.
ExtraDiskSpaceRequired=450000000
; Ask Windows' Restart Manager to close a running copy instead of writing DLLs
; underneath it. The engine holds the SQLite WAL and the camera, so a half
; replaced install fails on the NEXT start - long after anyone would connect
; the two events. RestartApplications=no because the [Run] section starts the
; app again itself, and twice is one process too many for one webcam.
CloseApplications=yes
RestartApplications=no
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopicon"; Description: "Create a &desktop shortcut"; GroupDescription: "Shortcuts:"
; Ticked by default: the product is meaningless if it is not watching. A shop
; PC reboots after a power cut at 3am with nobody there to open anything.
Name: "startup"; Description: "Start Behavision when this PC starts"; GroupDescription: "Startup:"
[Files]
Source: "..\dist\Behavision\Behavision.exe"; DestDir: "{app}"; Flags: ignoreversion
Source: "..\dist\Behavision\behavision-agent.exe"; DestDir: "{app}"; Flags: ignoreversion
Source: "..\dist\Behavision\engine\*"; DestDir: "{app}\engine"; Flags: ignoreversion recursesubdirs createallsubdirs
; ~2 MB bootstrapper, downloaded at build time by build.ps1. The app is a
; WebView2 window; without the runtime it opens BLANK - not an error, just an
; empty white rectangle, which is the single worst failure mode to hand a shop.
; Present on Windows 11 and recent Windows 10, absent on plenty of older
; machines, and a shop PC is exactly where an older machine lives.
Source: "vendor\MicrosoftEdgeWebview2Setup.exe"; DestDir: "{tmp}"; \
Flags: deleteafterinstall; Check: WebView2Missing
[Icons]
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
; Per-user, not HKLM\Run: the app draws a tray icon and a window, so it has to
; start in an interactive session. A machine-wide entry would try before anyone
; has logged in.
Name: "{userstartup}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: startup
; Named in the installer's own text when somebody skips the download, so it has
; to exist. Console window on purpose: it is a 200 MB download with a progress
; line, and a silent one looks like nothing happened.
Name: "{group}\Download models"; Filename: "{app}\engine\behavision.exe"; \
Parameters: "setup-models"; Comment: "Download the face recognition models"
Name: "{group}\Where is my data"; Filename: "{app}\engine\behavision.exe"; \
Parameters: "paths"; Comment: "Print the installed layout"
[Dirs]
; Created here so a first run does not have to, and ACLed to Administrators
; plus the installing user rather than Everyone: it holds face templates, which
; are biometric personal data, and the engine's generated API password.
Name: "{commonappdata}\Behavision"; Permissions: admins-full users-modify
[Run]
Filename: "{tmp}\MicrosoftEdgeWebview2Setup.exe"; Parameters: "/silent /install"; \
StatusMsg: "Installing Microsoft Edge WebView2 (required to show the app)..."; \
Flags: waituntilterminated; Check: WebView2Missing
; ~200 MB over the network, so it is offered rather than forced, and it is
; resumable: a killed download leaves a .part file and the next run continues.
Filename: "{app}\engine\behavision.exe"; Parameters: "setup-models"; \
StatusMsg: "Downloading face recognition models (about 200 MB)..."; \
Flags: runhidden waituntilterminated; Check: WantModels
Filename: "{app}\{#MyAppExeName}"; Description: "Start {#MyAppName} now"; \
Flags: nowait postinstall skipifsilent
[UninstallDelete]
; The unpacked engine writes nothing here, but PyInstaller leaves stray
; __pycache__ directories that would keep {app} alive after an uninstall.
Type: filesandordirs; Name: "{app}\engine"
[Code]
var
ModelsPage: TInputOptionWizardPage;
procedure InitializeWizard;
begin
ModelsPage := CreateInputOptionPage(wpSelectTasks,
'Face recognition models',
'Behavision needs about 200 MB of model files to recognise faces.',
'These are downloaded once. If this PC has no internet connection now, ' +
'skip this and run "Download models" from the Start menu later - the app ' +
'will not recognise anyone until they are present.',
True, False);
ModelsPage.Add('Download the models now (recommended)');
ModelsPage.Values[0] := True;
end;
function WantModels: Boolean;
begin
Result := ModelsPage.Values[0];
end;
// The WebView2 runtime registers itself under EdgeUpdate with a non-empty
// version. Checked in three places because the runtime can be installed
// per-machine (both registry views on 64-bit) or per-user.
function WebView2Missing: Boolean;
const
CLIENT = '{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}';
var
pv: string;
begin
Result := True;
if RegQueryStringValue(HKLM, 'SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\' + CLIENT, 'pv', pv) and (pv <> '') then
Result := False
else if RegQueryStringValue(HKLM, 'SOFTWARE\Microsoft\EdgeUpdate\Clients\' + CLIENT, 'pv', pv) and (pv <> '') then
Result := False
else if RegQueryStringValue(HKCU, 'SOFTWARE\Microsoft\EdgeUpdate\Clients\' + CLIENT, 'pv', pv) and (pv <> '') then
Result := False;
end;

134
installer/build.ps1 Normal file
View File

@@ -0,0 +1,134 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Builds the Behavision Windows package: engine, app, agent, installer.
.DESCRIPTION
This script must run ON WINDOWS. Everything else in this repository
cross-compiles from a Mac - the Go binaries with GOOS=windows, the web bundle
with npm - but the ENGINE cannot. PyInstaller freezes the interpreter and the
native wheels (onnxruntime, OpenCV) of the machine it runs on; there is no
cross-target flag, and there never has been. So the engine .exe is built here
or it is not built at all.
Output: dist\Behavision-Setup-<version>.exe, plus dist\Behavision\ which is
the unpacked tree the installer copies (useful for testing without
installing).
.PARAMETER Version
Stamped into the installer and shown in Add/Remove Programs.
.PARAMETER SkipInstaller
Build the payload but not the setup .exe. Use when Inno Setup is absent.
#>
param(
[string]$Version = "0.1.0",
[switch]$SkipInstaller
)
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $PSScriptRoot
$dist = Join-Path $root "dist"
$stage = Join-Path $dist "Behavision"
function Step($msg) { Write-Host "`n=== $msg ===" -ForegroundColor Cyan }
function Need($exe, $hint) {
if (-not (Get-Command $exe -ErrorAction SilentlyContinue)) {
throw "$exe not found on PATH. $hint"
}
}
Need python "Install Python 3.11+ and tick 'Add to PATH'."
Need go "Install Go 1.21+ from https://go.dev/dl/."
Need npm "Install Node.js LTS from https://nodejs.org/."
Step "Python environment"
Push-Location $root
if (-not (Test-Path ".venv")) { python -m venv .venv }
& .\.venv\Scripts\python -m pip install --upgrade pip | Out-Null
& .\.venv\Scripts\python -m pip install -r requirements.txt pyinstaller | Out-Null
Step "Engine tests"
# The package is not worth building if the engine is broken, and finding that
# out after the installer is signed is the expensive order to do it in.
& .\.venv\Scripts\python -m pytest tests -q
if ($LASTEXITCODE -ne 0) { throw "engine tests failed" }
Step "Engine (PyInstaller, one-folder)"
if (Test-Path (Join-Path $root "build")) { Remove-Item -Recurse -Force (Join-Path $root "build") }
& .\.venv\Scripts\pyinstaller behavision.spec --noconfirm --distpath (Join-Path $dist "engine-build")
if ($LASTEXITCODE -ne 0) { throw "pyinstaller failed" }
Step "Desktop app (Wails)"
Push-Location (Join-Path $root "desktop\frontend")
npm ci
npm run build
Pop-Location
Push-Location (Join-Path $root "desktop")
# Wails v2 talks to WebView2 through pure-Go bindings, so no cgo and no
# toolchain beyond Go itself. Verified by cross-compiling the same package from
# a Mac with CGO_ENABLED=0.
$env:CGO_ENABLED = "0"
if (Get-Command wails -ErrorAction SilentlyContinue) {
wails build -platform windows/amd64 -clean -ldflags "-X main.version=$Version"
} else {
Write-Warning "wails CLI not found - falling back to a plain go build (no icon, no manifest)."
go build -ldflags "-H windowsgui -X main.version=$Version" -o (Join-Path $root "desktop\build\bin\Behavision.exe") .
}
Pop-Location
Step "Headless agent"
Push-Location (Join-Path $root "agent")
$env:CGO_ENABLED = "0" # the cgo resolver forces external linking
go build -o (Join-Path $root "dist\behavision-agent.exe") .
Pop-Location
Step "WebView2 bootstrapper"
# Bundled rather than downloaded at install time: a shop PC being set up often
# has no working internet yet, and the app is a blank white window without it.
$vendor = Join-Path $root "installer\vendor"
New-Item -ItemType Directory -Force -Path $vendor | Out-Null
$wv2 = Join-Path $vendor "MicrosoftEdgeWebview2Setup.exe"
if (-not (Test-Path $wv2)) {
Invoke-WebRequest -Uri "https://go.microsoft.com/fwlink/p/?LinkId=2124703" -OutFile $wv2
}
Step "Staging"
if (Test-Path $stage) { Remove-Item -Recurse -Force $stage }
New-Item -ItemType Directory -Force -Path $stage | Out-Null
# The engine keeps its own folder: it is a one-folder PyInstaller build with
# ~150 native DLLs beside it, and `behavision.exe` would otherwise collide with
# `Behavision.exe` on a case-insensitive filesystem.
Copy-Item -Recurse (Join-Path $dist "engine-build\behavision") (Join-Path $stage "engine")
Copy-Item (Join-Path $root "desktop\build\bin\Behavision.exe") $stage
Copy-Item (Join-Path $dist "behavision-agent.exe") $stage
Copy-Item (Join-Path $root "LICENSE") $stage -ErrorAction SilentlyContinue
$engineExe = Join-Path $stage "engine\behavision.exe"
if (-not (Test-Path $engineExe)) { throw "engine exe missing at $engineExe" }
& $engineExe paths
if ($LASTEXITCODE -ne 0) { throw "the frozen engine cannot start - `paths` failed" }
Pop-Location
if ($SkipInstaller) {
Step "Done (payload only)"
Write-Host "Unpacked tree: $stage"
exit 0
}
Step "Installer (Inno Setup)"
$iscc = @(
"$env:ProgramFiles\Inno Setup 6\ISCC.exe",
"${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe"
) | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $iscc) {
throw "Inno Setup 6 not found. Install it from https://jrsoftware.org/isdl.php, or re-run with -SkipInstaller."
}
& $iscc "/DMyAppVersion=$Version" (Join-Path $root "installer\behavision.iss")
if ($LASTEXITCODE -ne 0) { throw "ISCC failed" }
Step "Done"
Get-ChildItem (Join-Path $dist "Behavision-Setup-*.exe") | ForEach-Object {
Write-Host ("{0} ({1:N1} MB)" -f $_.FullName, ($_.Length / 1MB))
}

26
pyproject.toml Normal file
View File

@@ -0,0 +1,26 @@
[project]
name = "behavision"
version = "1.0.0"
description = "Production face recognition over RTSP"
requires-python = ">=3.10"
dependencies = [
"numpy>=1.26,<2.0",
"opencv-python>=4.8.1",
"onnxruntime>=1.16",
"fastapi>=0.110",
"uvicorn>=0.29",
"pydantic>=2.6",
"PyYAML>=6.0",
"python-dotenv>=1.0",
"faiss-cpu>=1.7.4",
"requests>=2.31",
]
[project.optional-dependencies]
dev = ["pytest>=8.0"]
[tool.setuptools.packages.find]
include = ["behavision*"]
[tool.pytest.ini_options]
testpaths = ["tests"]

Some files were not shown because too many files have changed in this diff Show More