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