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:
131
web/src/views/Live.jsx
Normal file
131
web/src/views/Live.jsx
Normal 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>
|
||||
}
|
||||
Reference in New Issue
Block a user