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:
97
web/src/App.jsx
Normal file
97
web/src/App.jsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api, signedIn, clearTokens } from './api.js'
|
||||
import Login from './views/Login.jsx'
|
||||
import Sites from './views/Sites.jsx'
|
||||
import Live from './views/Live.jsx'
|
||||
import CamerasView from './views/Cameras.jsx'
|
||||
import Assistant from './views/Assistant.jsx'
|
||||
import Clients from './views/Clients.jsx'
|
||||
|
||||
// A platform admin has no client of their own, so the tenant screens have
|
||||
// nothing to show them. Rather than render empty pages, they get the one screen
|
||||
// that is theirs.
|
||||
//
|
||||
// Customers and Reports were removed from the navigation - not deleted, the
|
||||
// views and their API routes are untouched - the same trim the desktop app
|
||||
// already made and for the same reason: this is the screen somebody opens to
|
||||
// find out whether their shops are WORKING. A customer search and a
|
||||
// month-on-month chart are a different job, and putting them in the same nav
|
||||
// implies the estate is healthy enough to be worth reporting on before anyone
|
||||
// has checked. They come back when there is something to say with them.
|
||||
const TENANT_VIEWS = [
|
||||
{ id: 'sites', label: 'Shops', View: Sites },
|
||||
{ id: 'live', label: 'Live', View: Live },
|
||||
{ id: 'cameras', label: 'Cameras', View: CamerasView },
|
||||
]
|
||||
const ADMIN_VIEWS = [
|
||||
{ id: 'clients', label: 'Companies', View: Clients },
|
||||
]
|
||||
|
||||
export default function App() {
|
||||
const [user, setUser] = useState(null)
|
||||
const [booting, setBooting] = useState(true)
|
||||
const [view, setView] = useState('sites')
|
||||
const [asking, setAsking] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (signedIn()) {
|
||||
try { setUser(await api.me()) } catch { clearTokens() }
|
||||
}
|
||||
setBooting(false)
|
||||
})()
|
||||
}, [])
|
||||
|
||||
if (booting) {
|
||||
return <div className="boot"><span className="spinner" aria-hidden="true" />Loading…</div>
|
||||
}
|
||||
if (!user) return <Login onSignedIn={setUser} />
|
||||
|
||||
const isAdmin = user.role === 'admin' && !user.client_id
|
||||
const views = isAdmin ? ADMIN_VIEWS : TENANT_VIEWS
|
||||
const current = views.find(v => v.id === view) || views[0]
|
||||
const Current = current.View
|
||||
|
||||
const signOut = async () => { await api.logout(); setUser(null) }
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="topbar">
|
||||
<div className="brand">
|
||||
<span className="mark" aria-hidden="true" />
|
||||
<div>
|
||||
<strong>Behavision</strong>
|
||||
<span className="org">{user.client_name || 'Loyaly platform'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="tabs">
|
||||
{views.map(v => (
|
||||
<button
|
||||
key={v.id}
|
||||
onClick={() => setView(v.id)}
|
||||
aria-current={current.id === v.id ? 'page' : undefined}
|
||||
>{v.label}</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="who">
|
||||
<button className={'ask-btn' + (asking ? ' on' : '')}
|
||||
onClick={() => setAsking(a => !a)}>
|
||||
<span aria-hidden="true">✳</span> Ask
|
||||
</button>
|
||||
<span className="name">{user.full_name || user.email}</span>
|
||||
<span className="role">{isAdmin ? 'platform admin' : user.role}</span>
|
||||
<button className="ghost" onClick={signOut}>Sign out</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className={'with-assistant' + (asking ? ' open' : '')}>
|
||||
<main className="page">
|
||||
<Current user={user} />
|
||||
</main>
|
||||
<Assistant open={asking} onClose={() => setAsking(false)} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user