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:
161
desktop/frontend/src/App.jsx
Normal file
161
desktop/frontend/src/App.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user