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:
39
.gitignore
vendored
Normal file
39
.gitignore
vendored
Normal 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]
|
||||||
127
README.md
Normal file
127
README.md
Normal 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
217
RUN.md
Normal 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
44
agent/README.md
Normal 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
117
agent/cmd/e2e/main.go
Normal 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
10
agent/go.mod
Normal 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
8
agent/go.sum
Normal 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
311
agent/main.go
Normal 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
279
agent/pkg/bridge/bridge.go
Normal 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...)
|
||||||
|
}
|
||||||
|
}
|
||||||
288
agent/pkg/bridge/bridge_test.go
Normal file
288
agent/pkg/bridge/bridge_test.go
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
56
agent/pkg/bridge/engine_client.go
Normal file
56
agent/pkg/bridge/engine_client.go
Normal 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
205
agent/pkg/bridge/images.go
Normal 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
|
||||||
|
}
|
||||||
202
agent/pkg/bridge/images_test.go
Normal file
202
agent/pkg/bridge/images_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
291
agent/pkg/cameras/cameras.go
Normal file
291
agent/pkg/cameras/cameras.go
Normal 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...)
|
||||||
|
}
|
||||||
|
}
|
||||||
241
agent/pkg/cameras/cameras_test.go
Normal file
241
agent/pkg/cameras/cameras_test.go
Normal 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
247
agent/pkg/cameras/checks.go
Normal 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."}
|
||||||
|
}
|
||||||
247
agent/pkg/cameras/checks_test.go
Normal file
247
agent/pkg/cameras/checks_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
263
agent/pkg/cameras/clients.go
Normal file
263
agent/pkg/cameras/clients.go
Normal 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)
|
||||||
|
}
|
||||||
29
agent/pkg/cameras/wiring_test.go
Normal file
29
agent/pkg/cameras/wiring_test.go
Normal 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
201
agent/pkg/config/config.go
Normal 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
|
||||||
|
}
|
||||||
179
agent/pkg/config/config_test.go
Normal file
179
agent/pkg/config/config_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
62
agent/pkg/config/credentials.go
Normal file
62
agent/pkg/config/credentials.go
Normal 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
|
||||||
|
}
|
||||||
58
agent/pkg/config/credentials_test.go
Normal file
58
agent/pkg/config/credentials_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
13
agent/pkg/config/protect.go
Normal file
13
agent/pkg/config/protect.go
Normal 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 }
|
||||||
68
agent/pkg/config/protect_windows.go
Normal file
68
agent/pkg/config/protect_windows.go
Normal 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 }
|
||||||
44
agent/pkg/engine/health_test.go
Normal file
44
agent/pkg/engine/health_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
355
agent/pkg/engine/supervisor.go
Normal file
355
agent/pkg/engine/supervisor.go
Normal 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)
|
||||||
|
}
|
||||||
240
agent/pkg/engine/supervisor_test.go
Normal file
240
agent/pkg/engine/supervisor_test.go
Normal 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
217
agent/pkg/mqtt/client.go
Normal 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
|
||||||
104
agent/pkg/mqtt/client_test.go
Normal file
104
agent/pkg/mqtt/client_test.go
Normal 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
218
agent/pkg/mqtt/pump.go
Normal 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
288
agent/pkg/mqtt/pump_test.go
Normal 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
79
agent/pkg/paths/paths.go
Normal 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
|
||||||
|
}
|
||||||
43
agent/pkg/paths/paths_test.go
Normal file
43
agent/pkg/paths/paths_test.go
Normal 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
102
behavision.spec
Normal 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
7
behavision/__init__.py
Normal 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
261
behavision/__main__.py
Normal 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
341
behavision/api.py
Normal 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
220
behavision/attributes.py
Normal 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
535
behavision/calibrate.py
Normal 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
194
behavision/cameras.py
Normal 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
237
behavision/capture.py
Normal 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
242
behavision/commission.py
Normal 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
316
behavision/config.py
Normal 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
65
behavision/detection.py
Normal 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
601
behavision/engine.py
Normal 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
122
behavision/events.py
Normal 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
142
behavision/faces.py
Normal 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)
|
||||||
3
behavision/gallery/__init__.py
Normal file
3
behavision/gallery/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .service import Gallery, Resolution # noqa: F401
|
||||||
|
from .store import IdentityStore # noqa: F401
|
||||||
|
from .index import VectorIndex # noqa: F401
|
||||||
83
behavision/gallery/index.py
Normal file
83
behavision/gallery/index.py
Normal 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]
|
||||||
327
behavision/gallery/service.py
Normal file
327
behavision/gallery/service.py
Normal 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
338
behavision/gallery/store.py
Normal 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
81
behavision/geometry.py
Normal 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
28
behavision/log.py
Normal 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
119
behavision/model_assets.py
Normal 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
146
behavision/paths.py
Normal 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
167
behavision/recognition.py
Normal 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)))
|
||||||
552
behavision/static/dashboard.html
Normal file
552
behavision/static/dashboard.html
Normal 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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">→</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
119
behavision/tracking.py
Normal 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
102
config/default.yaml
Normal 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
48
desktop/README.md
Normal 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
687
desktop/app.go
Normal 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
5
desktop/env.go
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "os"
|
||||||
|
|
||||||
|
func osGetenv(k string) string { return os.Getenv(k) }
|
||||||
1
desktop/frontend/dist/assets/index-XjqO50wd.css
vendored
Normal file
1
desktop/frontend/dist/assets/index-XjqO50wd.css
vendored
Normal file
File diff suppressed because one or more lines are too long
40
desktop/frontend/dist/assets/index-whFsTNQf.js
vendored
Normal file
40
desktop/frontend/dist/assets/index-whFsTNQf.js
vendored
Normal file
File diff suppressed because one or more lines are too long
13
desktop/frontend/dist/index.html
vendored
Normal file
13
desktop/frontend/dist/index.html
vendored
Normal 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>
|
||||||
12
desktop/frontend/index.html
Normal file
12
desktop/frontend/index.html
Normal 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
1738
desktop/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
desktop/frontend/package.json
Normal file
18
desktop/frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
161
desktop/frontend/src/App.jsx
Normal file
161
desktop/frontend/src/App.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
64
desktop/frontend/src/bridge.js
Normal file
64
desktop/frontend/src/bridge.js
Normal 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)
|
||||||
|
}
|
||||||
65
desktop/frontend/src/hooks.js
Normal file
65
desktop/frontend/src/hooks.js
Normal 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) }
|
||||||
8
desktop/frontend/src/main.jsx
Normal file
8
desktop/frontend/src/main.jsx
Normal 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>
|
||||||
|
)
|
||||||
252
desktop/frontend/src/styles.css
Normal file
252
desktop/frontend/src/styles.css
Normal 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; }
|
||||||
280
desktop/frontend/src/views/Cameras.jsx
Normal file
280
desktop/frontend/src/views/Cameras.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
182
desktop/frontend/src/views/CustomerForm.jsx
Normal file
182
desktop/frontend/src/views/CustomerForm.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
45
desktop/frontend/src/views/CustomerPhoto.jsx
Normal file
45
desktop/frontend/src/views/CustomerPhoto.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
92
desktop/frontend/src/views/Customers.jsx
Normal file
92
desktop/frontend/src/views/Customers.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
89
desktop/frontend/src/views/EraseCustomer.jsx
Normal file
89
desktop/frontend/src/views/EraseCustomer.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
162
desktop/frontend/src/views/Footfall.jsx
Normal file
162
desktop/frontend/src/views/Footfall.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
192
desktop/frontend/src/views/Live.jsx
Normal file
192
desktop/frontend/src/views/Live.jsx
Normal 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
|
||||||
|
}
|
||||||
52
desktop/frontend/src/views/Login.jsx
Normal file
52
desktop/frontend/src/views/Login.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
88
desktop/frontend/src/views/Sales.jsx
Normal file
88
desktop/frontend/src/views/Sales.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
103
desktop/frontend/src/views/Setup.jsx
Normal file
103
desktop/frontend/src/views/Setup.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
51
desktop/frontend/src/views/VisitHistory.jsx
Normal file
51
desktop/frontend/src/views/VisitHistory.jsx
Normal 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',
|
||||||
|
})
|
||||||
|
}
|
||||||
14
desktop/frontend/vite.config.js
Normal file
14
desktop/frontend/vite.config.js
Normal 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
29
desktop/go.mod
Normal 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
94
desktop/go.sum
Normal 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
130
desktop/icons.go
Normal 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
93
desktop/icons_test.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"image/png"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The tray icon shipped as a PNG for a while. systray hands the bytes to
|
||||||
|
// LoadImageW, which decodes .ico only, so Windows logged one line and drew
|
||||||
|
// nothing — and nothing on a Mac could notice. These tests are the substitute
|
||||||
|
// for the Windows box we do not have.
|
||||||
|
func TestEncodeICOIsAValidIconFile(t *testing.T) {
|
||||||
|
b := encodeICO(circle(colorFor("ok")))
|
||||||
|
if len(b) < 22 {
|
||||||
|
t.Fatalf("far too short: %d bytes", len(b))
|
||||||
|
}
|
||||||
|
if got := b[:6]; !bytes.Equal(got, []byte{0, 0, 1, 0, 1, 0}) {
|
||||||
|
t.Errorf("ICONDIR = % x, want 00 00 01 00 01 00", got)
|
||||||
|
}
|
||||||
|
if b[6] != iconSize || b[7] != iconSize {
|
||||||
|
t.Errorf("entry is %dx%d, want %dx%d", b[6], b[7], iconSize, iconSize)
|
||||||
|
}
|
||||||
|
if bpp := binary.LittleEndian.Uint16(b[12:14]); bpp != 32 {
|
||||||
|
t.Errorf("bits per pixel = %d, want 32", bpp)
|
||||||
|
}
|
||||||
|
// A wrong length here loads as a truncated or garbage icon rather than
|
||||||
|
// failing outright, which is the harder version of this bug to spot.
|
||||||
|
size := binary.LittleEndian.Uint32(b[14:18])
|
||||||
|
off := binary.LittleEndian.Uint32(b[18:22])
|
||||||
|
if off != 22 {
|
||||||
|
t.Errorf("image offset = %d, want 22", off)
|
||||||
|
}
|
||||||
|
if int(off)+int(size) != len(b) {
|
||||||
|
t.Errorf("entry claims %d bytes at %d, file is %d", size, off, len(b))
|
||||||
|
}
|
||||||
|
if h := binary.LittleEndian.Uint32(b[22:26]); h != 40 {
|
||||||
|
t.Errorf("BITMAPINFOHEADER size = %d, want 40", h)
|
||||||
|
}
|
||||||
|
// Height must be doubled for the implied AND mask or Windows draws the
|
||||||
|
// bottom half of the icon stretched over the whole square.
|
||||||
|
if hh := int32(binary.LittleEndian.Uint32(b[30:34])); hh != int32(iconSize*2) {
|
||||||
|
t.Errorf("biHeight = %d, want %d", hh, iconSize*2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncodeICOPixelsAreBGRABottomUp(t *testing.T) {
|
||||||
|
want := colorFor("error") // red: distinguishable from B and G if swapped
|
||||||
|
img := circle(want)
|
||||||
|
b := encodeICO(img)
|
||||||
|
// Centre of the circle, which is solid fill. Bottom-up means image row
|
||||||
|
// iconSize/2 lands at DIB row iconSize/2-1 counting from the start.
|
||||||
|
row := iconSize - 1 - iconSize/2
|
||||||
|
i := 22 + 40 + (row*iconSize+iconSize/2)*4
|
||||||
|
got := b[i : i+4]
|
||||||
|
if !bytes.Equal(got, []byte{want.B, want.G, want.R, 0xFF}) {
|
||||||
|
t.Errorf("centre pixel = % x, want % x (BGRA)",
|
||||||
|
got, []byte{want.B, want.G, want.R, 0xFF})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIconForMatchesThePlatformDecoder(t *testing.T) {
|
||||||
|
b := iconFor("ok")
|
||||||
|
pngMagic := []byte{0x89, 'P', 'N', 'G'}
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
if bytes.HasPrefix(b, pngMagic) {
|
||||||
|
t.Fatal("Windows tray icon is a PNG; LoadImageW will refuse it")
|
||||||
|
}
|
||||||
|
if !bytes.HasPrefix(b, []byte{0, 0, 1, 0}) {
|
||||||
|
t.Fatalf("Windows tray icon is not an ICO: % x", b[:4])
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := png.Decode(bytes.NewReader(b)); err != nil {
|
||||||
|
t.Fatalf("non-Windows tray icon is not a decodable PNG: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The four states exist to be told apart at a glance; identical bytes would
|
||||||
|
// mean the icon never changes and the amber "running but blind" state — the
|
||||||
|
// one that otherwise goes unnoticed for weeks — looks exactly like healthy.
|
||||||
|
func TestEveryStateLooksDifferent(t *testing.T) {
|
||||||
|
seen := map[string]string{}
|
||||||
|
for _, s := range []string{"ok", "warn", "error", "stopped"} {
|
||||||
|
k := string(iconFor(s))
|
||||||
|
if prev, dup := seen[k]; dup {
|
||||||
|
t.Errorf("%q and %q render identically", prev, s)
|
||||||
|
}
|
||||||
|
seen[k] = s
|
||||||
|
}
|
||||||
|
}
|
||||||
504
desktop/internal/cloud/client.go
Normal file
504
desktop/internal/cloud/client.go
Normal 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)
|
||||||
|
}
|
||||||
139
desktop/internal/cloud/client_test.go
Normal file
139
desktop/internal/cloud/client_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
134
desktop/internal/local/client.go
Normal file
134
desktop/internal/local/client.go
Normal 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
66
desktop/main.go
Normal 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
174
desktop/tray.go
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fyne.io/systray"
|
||||||
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tray is the always-present control surface. Wails v2 has no systray of its
|
||||||
|
// own, so this drives fyne.io/systray alongside the window.
|
||||||
|
//
|
||||||
|
// It is a CLIENT of the app, not a second copy of it: everything it shows
|
||||||
|
// comes from EngineStatus(), so the tray and the dashboard can never disagree
|
||||||
|
// about whether recognition is running.
|
||||||
|
type tray struct {
|
||||||
|
app *App
|
||||||
|
once sync.Once
|
||||||
|
quit chan struct{}
|
||||||
|
|
||||||
|
mStatus *systray.MenuItem
|
||||||
|
mOpen *systray.MenuItem
|
||||||
|
mStart *systray.MenuItem
|
||||||
|
mStop *systray.MenuItem
|
||||||
|
mLogs *systray.MenuItem
|
||||||
|
mQuit *systray.MenuItem
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTray(a *App) *tray { return &tray{app: a, quit: make(chan struct{})} }
|
||||||
|
|
||||||
|
func (t *tray) start(ctx context.Context) {
|
||||||
|
t.once.Do(func() {
|
||||||
|
go systray.Run(func() { t.onReady(ctx) }, func() {})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tray) stop() {
|
||||||
|
select {
|
||||||
|
case <-t.quit:
|
||||||
|
default:
|
||||||
|
close(t.quit)
|
||||||
|
}
|
||||||
|
systray.Quit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tray) onReady(ctx context.Context) {
|
||||||
|
systray.SetTitle("Behavision")
|
||||||
|
systray.SetTooltip("Behavision — starting")
|
||||||
|
systray.SetIcon(iconFor("stopped"))
|
||||||
|
|
||||||
|
t.mStatus = systray.AddMenuItem("Starting…", "")
|
||||||
|
t.mStatus.Disable()
|
||||||
|
systray.AddSeparator()
|
||||||
|
t.mOpen = systray.AddMenuItem("Open dashboard", "Show the Behavision window")
|
||||||
|
systray.AddSeparator()
|
||||||
|
t.mStart = systray.AddMenuItem("Start recognition", "Start the engine")
|
||||||
|
t.mStop = systray.AddMenuItem("Stop recognition", "Stop the engine")
|
||||||
|
t.mLogs = systray.AddMenuItem("Open logs folder", "")
|
||||||
|
systray.AddSeparator()
|
||||||
|
t.mQuit = systray.AddMenuItem("Quit Behavision", "Stops recognition")
|
||||||
|
|
||||||
|
go t.poll(ctx)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-t.quit:
|
||||||
|
return
|
||||||
|
case <-t.mOpen.ClickedCh:
|
||||||
|
runtime.Show(ctx)
|
||||||
|
case <-t.mStart.ClickedCh:
|
||||||
|
t.app.StartEngine()
|
||||||
|
case <-t.mStop.ClickedCh:
|
||||||
|
t.app.StopEngine()
|
||||||
|
case <-t.mLogs.ClickedCh:
|
||||||
|
runtime.BrowserOpenURL(ctx, "file://"+logsDir())
|
||||||
|
case <-t.mQuit.ClickedCh:
|
||||||
|
// Quitting the tray stops recognition. Leaving the engine running
|
||||||
|
// with no visible control is worse than stopping it: nobody would
|
||||||
|
// know it was still watching.
|
||||||
|
t.app.StopEngine()
|
||||||
|
runtime.Quit(ctx)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// poll keeps the icon honest. The colour answers the only question a shop
|
||||||
|
// manager glancing at the taskbar has: is it working right now.
|
||||||
|
func (t *tray) poll(ctx context.Context) {
|
||||||
|
tick := time.NewTicker(5 * time.Second)
|
||||||
|
defer tick.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-t.quit:
|
||||||
|
return
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-tick.C:
|
||||||
|
s := t.app.EngineStatus()
|
||||||
|
state, label := describe(s)
|
||||||
|
systray.SetIcon(iconFor(state))
|
||||||
|
systray.SetTooltip("Behavision — " + label)
|
||||||
|
if t.mStatus != nil {
|
||||||
|
t.mStatus.SetTitle(label)
|
||||||
|
}
|
||||||
|
running := s.State == "running"
|
||||||
|
if t.mStart != nil && t.mStop != nil {
|
||||||
|
if running {
|
||||||
|
t.mStart.Disable()
|
||||||
|
t.mStop.Enable()
|
||||||
|
} else {
|
||||||
|
t.mStart.Enable()
|
||||||
|
t.mStop.Disable()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// describe collapses engine state into the three things worth showing.
|
||||||
|
//
|
||||||
|
// "Running but no camera connected" is deliberately amber, not green: the
|
||||||
|
// process is fine and the product is not working, and that is exactly the
|
||||||
|
// state that otherwise goes unnoticed for weeks.
|
||||||
|
func describe(s EngineStatus) (state, label string) {
|
||||||
|
switch {
|
||||||
|
case s.State == "stopped":
|
||||||
|
return "stopped", "Stopped"
|
||||||
|
case s.State == "failed":
|
||||||
|
return "error", "Failed — " + firstLine(s.Error)
|
||||||
|
case s.State == "backoff":
|
||||||
|
return "error", fmt.Sprintf("Restarting (%d attempts)", s.Restarts)
|
||||||
|
case !s.Reachable:
|
||||||
|
return "warn", "Starting…"
|
||||||
|
case len(s.Cameras) == 0:
|
||||||
|
return "warn", "Running — no cameras configured"
|
||||||
|
default:
|
||||||
|
up := 0
|
||||||
|
for _, ok := range s.Cameras {
|
||||||
|
if ok {
|
||||||
|
up++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if up == 0 {
|
||||||
|
return "error", fmt.Sprintf("No camera connected (0 of %d)", len(s.Cameras))
|
||||||
|
}
|
||||||
|
if up < len(s.Cameras) {
|
||||||
|
return "warn", fmt.Sprintf("%d of %d cameras live", up, len(s.Cameras))
|
||||||
|
}
|
||||||
|
return "ok", fmt.Sprintf("Watching %d camera%s", up, plural(up))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func plural(n int) string {
|
||||||
|
if n == 1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "s"
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstLine(s string) string {
|
||||||
|
for i, r := range s {
|
||||||
|
if r == '\n' {
|
||||||
|
return s[:i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(s) > 60 {
|
||||||
|
return s[:60] + "…"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
17
desktop/wails.json
Normal file
17
desktop/wails.json
Normal 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
162
installer/behavision.iss
Normal 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
134
installer/build.ps1
Normal 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
26
pyproject.toml
Normal 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
Reference in New Issue
Block a user