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

131
web/src/views/Live.jsx Normal file
View File

@@ -0,0 +1,131 @@
import { useEffect, useRef, useState } from 'react'
import { api, streamArrivals } from '../api.js'
import { ago, Loading, Problem } from './Sites.jsx'
// Who just walked in, pushed as it happens.
//
// The list is capped rather than unbounded: this screen is left open on a wall
// display for days, and an ever-growing array of arrivals — each holding an
// image URL — is a tab that eventually falls over. Older arrivals are in the
// reports; this view is about the last few minutes.
const KEEP = 60
export default function Live() {
const [arrivals, setArrivals] = useState([])
const [status, setStatus] = useState('connecting')
const [error, setError] = useState(null)
const seen = useRef(new Set())
useEffect(() => {
const ac = new AbortController()
let stop = () => {}
;(async () => {
try {
// One poll for the recent window, so the screen is never blank while
// waiting for the next person to walk through a door.
const page = await api.arrivals({ limit: 30 })
const rows = page.arrivals || []
rows.forEach(a => seen.current.add(a.visit_id))
setArrivals(rows.slice().reverse())
setStatus('live')
stop = streamArrivals({
cursor: page.cursor,
signal: ac.signal,
onPage: (p) => {
const fresh = (p.arrivals || []).filter(a => !seen.current.has(a.visit_id))
if (!fresh.length) return
fresh.forEach(a => seen.current.add(a.visit_id))
setStatus('live')
// Newest first on screen; the feed delivers oldest first.
setArrivals(prev => [...fresh.reverse(), ...prev].slice(0, KEEP))
},
onError: () => setStatus('reconnecting'),
})
} catch (err) {
setError(err)
}
})()
return () => { ac.abort(); stop() }
}, [])
if (error) return <Problem error={error} />
if (status === 'connecting' && !arrivals.length) return <Loading />
return (
<>
<header className="head">
<h1>Live</h1>
<p className="sub">
<span className={'dot ' + (status === 'live' ? 'ok' : 'warn')} aria-hidden="true" />
{status === 'live' ? 'Connected' : 'Reconnecting…'}
{' · '}last {KEEP} arrivals
</p>
</header>
{arrivals.length === 0 ? (
<div className="state">
<h2>Nobody yet</h2>
<p className="sub">
Arrivals appear here the moment a camera recognises someone.
</p>
</div>
) : (
<ul className="arrivals">
{arrivals.map(a => <Arrival key={a.visit_id} a={a} />)}
</ul>
)}
</>
)
}
function Arrival({ a }) {
const name = a.name || a.label || 'Unidentified'
return (
<li className="card arrival">
<Face image={a.image} name={name} />
<div className="who-col">
<strong>{name}</strong>
<span className="sub">
{a.site}{a.camera_id ? ` · ${a.camera_id}` : ''} · {ago(a.occurred_at)}
</span>
{a.attributes && <Attributes attrs={a.attributes} />}
</div>
{a.is_new_visitor
? <span className="pill new">New</span>
: <span className="pill">Returning</span>}
</li>
)
}
// Photos are off unless a shop turns them on, so "no photo" is the ordinary
// case. Initials, never an error state — a screen full of red for a system
// working exactly as configured teaches people to ignore it.
function Face({ image, name }) {
if (image?.available) {
return <img className="face" src={image.url} alt="" loading="lazy" />
}
return (
<span className="face initials" title={image?.reason || ''} aria-hidden="true">
{initials(name)}
</span>
)
}
function initials(name) {
const parts = String(name).trim().split(/\s+/).filter(Boolean)
if (!parts.length) return '?'
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase()
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
}
function Attributes({ attrs }) {
const bits = []
if (attrs.gender) bits.push(attrs.gender)
if (attrs.age) bits.push(`~${Math.round(attrs.age)}`)
if (attrs.emotion) bits.push(attrs.emotion)
if (!bits.length) return null
return <span className="attrs">{bits.join(' · ')}</span>
}