Behavision: face recognition for retail, edge to head office

Five components that ship as one product:

- behavision/  the recognition engine. RTSP ingest, YuNet detection, IoU
               tracking, ArcFace embeddings, a FAISS/SQLite gallery, and a
               FastAPI dashboard. Identity is decided once per TRACK from an
               average of at least three embeddings, never per frame.
- agent/       the Go edge agent: supervises the engine, holds a durable
               spool, and drains it to MQTT. Nothing is acked before the
               broker confirms.
- desktop/     the shop PC application (Wails + React + tray).
- server/      the cloud API, MQTT consumer, reports and assistant.
- web/         platform.loyaly.ai, the head-office app, embedded in the
               server binary.

The gallery stores 512-float embeddings and timestamps - no images unless
`app.store_faces` is switched on. Those embeddings are biometric personal
data under GDPR and India's DPDP: template inversion reconstructs a
recognisable face from an ArcFace vector, so data/behavision.db is treated
as a biometric database and DELETE /api/visitors/{id} is a real erasure.

CLAUDE.md carries the reasoning behind every non-obvious decision here,
including the ones that were measured and the ones that were wrong first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
This commit is contained in:
2026-09-04 11:14:18 +05:30
commit dad04e8cda
216 changed files with 40473 additions and 0 deletions

View File

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