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