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

151
web/src/views/Cameras.jsx Normal file
View File

@@ -0,0 +1,151 @@
import { useState } from 'react'
import { api } from '../api.js'
import { usePolled } from '../hooks.js'
import { ago, Loading, Problem } from './Sites.jsx'
import CameraSetup from './CameraSetup.jsx'
// Cameras, onboarded from head office.
//
// The shop PC is still the thing that CONNECTS — it is the only machine on the
// camera's network — so everything here is desired state that its agent pulls
// and applies. That is why a new camera reads "waiting for the shop PC" rather
// than "connected": saying it is set up would be claiming something nobody has
// verified yet.
// verification is the second claim a camera card makes, and the one that
// matters: connected says the stream opens, verified says a person walking past
// can actually be recognised. Office1 was the first for weeks without ever
// being the second.
function verification(cam) {
const c = cam.check || {}
if (c.state === 'requested' || c.state === 'running') {
return { tone: 'idle', mark: '…', words: 'Checking now' }
}
if (c.state !== 'done') {
return { tone: 'idle', mark: '?', words: 'Not checked yet' }
}
if (c.kind === 'placement') {
return c.ok
? { tone: 'ok', mark: '✓', words: 'Recognises faces here' }
: { tone: 'bad', mark: '✕', words: c.headline || 'Cannot recognise faces here' }
}
return c.ok
? { tone: 'warn', mark: '!', words: 'Stream works — faces not checked yet' }
: { tone: 'bad', mark: '✕', words: c.headline || 'Could not reach the camera' }
}
export default function Cameras({ user }) {
const { data: cams, error, loading, reload } =
usePolled(() => api.cameras(), 20000, [])
const { data: sites } = usePolled(() => api.sites(), 0, [])
const [editing, setEditing] = useState(null)
const canEdit = ['admin', 'owner', 'manager'].includes(user.role)
const list = cams || []
const up = list.filter(c => c.connected).length
const down = list.filter(c => c.connected === false).length
const waiting = list.filter(c => c.connected == null).length
return (
<>
<header className="head">
<h1>Cameras</h1>
<p className="sub">
{list.length} {list.length === 1 ? 'camera' : 'cameras'}
{up > 0 && <> · <b className="ok">{up} connected</b></>}
{down > 0 && <> · <b className="bad">{down} down</b></>}
{waiting > 0 && <> · {waiting} waiting for the shop PC</>}
</p>
{canEdit && (
<button className="primary" onClick={() => setEditing({})}>
Set up a camera
</button>
)}
</header>
{loading && !cams ? <Loading /> : error ? <Problem error={error} /> : (
list.length === 0 ? (
<div className="state">
<h2>No cameras yet</h2>
<p className="sub">
Add one here and the shop’s PC will pick it up within a couple of
minutes. Cameras already set up on a shop PC appear here on their own.
</p>
</div>
) : (
<div className="grid cams">
{list.map(c => (
<CameraCard key={c.id} cam={c} canEdit={canEdit}
onEdit={() => setEditing(c)} />
))}
</div>
)
)}
{editing && (
<CameraSetup
existing={editing.id ? editing : null}
sites={sites || []}
onClose={() => { setEditing(null); reload() }}
onSaved={(_, opts) => { if (!opts?.keepOpen) setEditing(null); reload() }}
/>
)}
</>
)
}
function CameraCard({ cam, canEdit, onEdit }) {
// Three states, not two. A camera nobody has tried yet is not a camera that
// is down, and telling an operator to check the cabling on a camera the shop
// PC has not even seen sends them to the wrong building.
const state = cam.connected == null ? 'idle'
: cam.connected ? 'ok' : 'bad'
const words = cam.connected == null ? 'Waiting for the shop PC'
: cam.connected ? 'Connected' : 'Not connecting'
const verified = verification(cam)
return (
// The picture IS the card. A camera is a thing you look at, and the
// previous layout made it a black rectangle sitting above a table of
// connection settings — which is the view a developer wants, not a shop
// owner. The technical detail moves behind Edit, where it is needed only
// when something is being changed.
<article className={'card cam state-' + state} onClick={canEdit ? onEdit : undefined}
role={canEdit ? 'button' : undefined} tabIndex={canEdit ? 0 : undefined}
onKeyDown={e => canEdit && e.key === 'Enter' && onEdit()}>
<div className="shot">
{cam.snapshot?.available
? <img src={cam.snapshot.url} alt={`View from ${cam.label}`} loading="lazy" />
: <div className="noshot">
<span className="lens" aria-hidden="true" />
{cam.snapshot?.reason || 'No picture yet.'}
</div>}
{/* Over the picture, where a camera label belongs, rather than in a
caption underneath it. */}
<div className="shot-over">
<div className="shot-name">
<b>{cam.label || cam.camera_id}</b>
<span>{cam.site}</span>
</div>
<span className={'status ' + state}>
<i aria-hidden="true" />{words}
</span>
</div>
{cam.snapshot_at && (
<span className="shot-age">{ago(cam.snapshot_at)}</span>
)}
</div>
{/* Connected and verified are different claims, and the gap between them
is where a site gets signed off broken: a camera can be streaming
perfectly and still produce views nothing can recognise. This is the
one fact worth carrying on the front of the card. */}
<div className={'verdict ' + verified.tone}>
<span className="mark" aria-hidden="true">{verified.mark}</span>
<span className="words">{verified.words}</span>
{canEdit && <span className="go" aria-hidden="true">→</span>}
</div>
</article>
)
}