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:
260
web/src/views/Sites.jsx
Normal file
260
web/src/views/Sites.jsx
Normal file
@@ -0,0 +1,260 @@
|
||||
import { useState } from 'react'
|
||||
import { api } from '../api.js'
|
||||
import { usePolled } from '../hooks.js'
|
||||
import SiteCheck from './SiteCheck.jsx'
|
||||
|
||||
// The estate at a glance.
|
||||
//
|
||||
// The question this answers is not "how busy were we" — it is "is any of this
|
||||
// actually working". A shop whose PC has been unplugged for a week and a shop
|
||||
// with no customers produce the same row of zeroes everywhere else in the
|
||||
// product, and only one of them is something to act on.
|
||||
//
|
||||
// Laid out like the camera screen, and for the same reason: the first version
|
||||
// was a definition list of cameras_up, fraction_below_gate and last_heartbeat,
|
||||
// which is the view a developer wants. An owner opening this wants to SEE their
|
||||
// shops. So the shop's own camera view is the card, the numbers sit under it,
|
||||
// and one line says what to do — with the technical detail one click away
|
||||
// rather than on the surface.
|
||||
export default function Sites() {
|
||||
const { data, error, loading } = usePolled(() => api.sites(), 20000, [])
|
||||
// Cameras come from a second call and are joined here rather than server-side:
|
||||
// the picture is decoration on this screen, so it must never be able to make
|
||||
// the health list fail. If this errors the cards simply have no photograph.
|
||||
const { data: cams } = usePolled(() => api.cameras(), 60000, [])
|
||||
const [checking, setChecking] = useState(null)
|
||||
const sites = data || []
|
||||
|
||||
if (loading && !data) return <Loading />
|
||||
if (error) return <Problem error={error} />
|
||||
if (!sites.length) return <Empty />
|
||||
|
||||
// Counted from the same verdicts the cards show. Summarising with a second,
|
||||
// simpler rule up here is how a header ends up reading "all working" over a
|
||||
// grid containing a red card.
|
||||
const rows = sites.map(s => {
|
||||
const own = (cams || []).filter(c => c.site_id === s.site_id)
|
||||
return { site: s, cams: own, verdict: verdictFor(s, own) }
|
||||
})
|
||||
const tally = t => rows.filter(r => r.verdict.tone === t).length
|
||||
const broken = tally('bad'), watch = tally('warn'), fresh = tally('idle')
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="head">
|
||||
<h1>Shops</h1>
|
||||
<p className="sub">
|
||||
{sites.length} {sites.length === 1 ? 'shop' : 'shops'}
|
||||
{broken > 0 && <> · <b className="bad">{broken} not working</b></>}
|
||||
{watch > 0 && <> · <b className="warn">{watch} needing attention</b></>}
|
||||
{fresh > 0 && <> · {fresh} not set up yet</>}
|
||||
{!broken && !watch && !fresh && <> · <b className="ok">all working</b></>}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="grid sites">
|
||||
{rows.map(r => (
|
||||
<SiteCard key={r.site.site_id} site={r.site} cams={r.cams}
|
||||
verdict={r.verdict} onCheck={() => setChecking(r.site)} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Reachable per shop, at last. The smoke test used to hang off a single
|
||||
button on the camera screen that always checked sites[0], so with two
|
||||
shops the second could not be checked at all. */}
|
||||
{checking && <SiteCheck site={checking} onClose={() => setChecking(null)} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// The one line the card leads with, in severity order. Only the first is shown:
|
||||
// a shop that is offline AND has a bad camera needs its PC turned on first, and
|
||||
// listing both invites someone to start with the wrong one.
|
||||
//
|
||||
// This is also the ONLY place a shop's health is decided. The pill over the
|
||||
// picture, the stripe down the edge and this line all read from it, because the
|
||||
// first version computed the pill separately from online + gate and a shop with
|
||||
// two dead cameras came out labelled "Working" directly above the words "2 of 3
|
||||
// cameras not connecting". Two surfaces disagreeing about one fact is worse
|
||||
// than either being wrong on its own.
|
||||
function verdictFor(site, cams) {
|
||||
const gate = site.fraction_below_gate
|
||||
const total = site.cameras_total || cams.length
|
||||
|
||||
if (!site.online) {
|
||||
return { tone: 'bad', mark: '✕', headline: 'Offline',
|
||||
words: `Offline — last heard from ${ago(site.last_heartbeat_at)}` }
|
||||
}
|
||||
// Footfall this shop saw and can never report. Stored with GREATEST()
|
||||
// server-side so a restarted agent cannot make it quietly disappear.
|
||||
if (site.dropped > 0) {
|
||||
return { tone: 'bad', mark: '✕',
|
||||
words: `${site.dropped} visits lost and unrecoverable` }
|
||||
}
|
||||
if (total === 0) {
|
||||
return { tone: 'idle', mark: '+', headline: 'Not set up',
|
||||
words: 'No cameras set up yet' }
|
||||
}
|
||||
if (site.cameras_up === 0) {
|
||||
return { tone: 'bad', mark: '✕', words: 'No cameras connected' }
|
||||
}
|
||||
if (site.cameras_up < total) {
|
||||
return { tone: 'warn', mark: '!',
|
||||
words: `${total - site.cameras_up} of ${total} cameras not connecting` }
|
||||
}
|
||||
// The Office1 case: online, connected, and recognising almost nobody. Over
|
||||
// half is a failure and not a warning — those visitors are gone.
|
||||
if (gate > 0.5) {
|
||||
return { tone: 'bad', mark: '✕',
|
||||
words: `${pct(gate)} of faces too poor to recognise` }
|
||||
}
|
||||
if (gate > 0.2) {
|
||||
return { tone: 'warn', mark: '!',
|
||||
words: `${pct(gate)} of faces too poor to recognise` }
|
||||
}
|
||||
if (site.queued > 0) {
|
||||
return { tone: 'warn', mark: '!', words: `${site.queued} visits waiting to upload` }
|
||||
}
|
||||
return { tone: 'ok', mark: '✓',
|
||||
words: `Working — ${total} ${total === 1 ? 'camera' : 'cameras'} connected` }
|
||||
}
|
||||
|
||||
const HEADLINES = { ok: 'Working', warn: 'Needs attention',
|
||||
bad: 'Not working', idle: 'Not set up' }
|
||||
|
||||
function SiteCard({ site, cams, verdict, onCheck }) {
|
||||
const gate = site.fraction_below_gate
|
||||
const health = verdict.tone
|
||||
const headline = verdict.headline || HEADLINES[health]
|
||||
const view = bestView(cams)
|
||||
const total = site.cameras_total || cams.length
|
||||
|
||||
return (
|
||||
<article className={'card site state-' + health} onClick={onCheck} role="button"
|
||||
tabIndex={0} onKeyDown={e => e.key === 'Enter' && onCheck()}>
|
||||
<div className="shot">
|
||||
{view.url
|
||||
? <img src={view.url} alt={`View inside ${site.name}`} loading="lazy" />
|
||||
: <div className="noshot">
|
||||
<ShopMark />
|
||||
{view.reason && <span>{view.reason}</span>}
|
||||
</div>}
|
||||
|
||||
<div className="shot-over">
|
||||
<div className="shot-name">
|
||||
<b>{site.name}</b>
|
||||
<span>{total > 0
|
||||
? `${total} ${total === 1 ? 'camera' : 'cameras'}`
|
||||
: 'No cameras yet'}</span>
|
||||
</div>
|
||||
<span className={'status ' + health}>
|
||||
<i aria-hidden="true" />{headline}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{view.at && <span className="shot-age">{ago(view.at)}</span>}
|
||||
</div>
|
||||
|
||||
{/* Three numbers, and each one is a different question: is the hardware
|
||||
up, can it see faces well enough to recognise them, and is anyone
|
||||
home. The rest moved behind the check. */}
|
||||
<div className="metrics">
|
||||
<Metric label="Cameras"
|
||||
value={total ? `${site.cameras_up}/${total}` : '—'}
|
||||
tone={!total ? 'idle' : site.cameras_up < total ? 'bad' : 'ok'} />
|
||||
<Metric label="Faces usable"
|
||||
value={gate > 0 ? pct(1 - gate) : '—'}
|
||||
tone={!gate ? 'idle' : gate > 0.5 ? 'bad' : gate > 0.2 ? 'warn' : 'ok'} />
|
||||
<Metric label="Last seen" value={ago(site.last_heartbeat_at)}
|
||||
tone={site.online ? 'ok' : 'bad'} />
|
||||
</div>
|
||||
|
||||
<div className={'verdict ' + verdict.tone}>
|
||||
<span className="mark" aria-hidden="true">{verdict.mark}</span>
|
||||
<span className="words">{verdict.words}</span>
|
||||
<span className="go" aria-hidden="true">→</span>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function Metric({ label, value, tone }) {
|
||||
return (
|
||||
<div className="metric">
|
||||
<b className={tone}>{value}</b>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// The freshest picture any of this shop's cameras has sent.
|
||||
//
|
||||
// A missing picture is a normal state, not an error — images are off by default
|
||||
// across the product — so the empty tile explains itself rather than showing a
|
||||
// black hole with an apology in it. The three absences need different words:
|
||||
// "nobody has set a camera up", "the PC has not reported yet", and "this system
|
||||
// stores no photographs" are three different next actions.
|
||||
function bestView(cams) {
|
||||
// Nothing written here for a shop with no cameras: the verdict line already
|
||||
// says exactly that, and three phrasings of one fact on one card reads as a
|
||||
// fault rather than a state.
|
||||
if (!cams.length) return {}
|
||||
let best = null
|
||||
for (const c of cams) {
|
||||
if (!c.snapshot?.available || !c.snapshot.url) continue
|
||||
if (!best || (c.snapshot_at || '') > (best.snapshot_at || '')) best = c
|
||||
}
|
||||
if (best) return { url: best.snapshot.url, at: best.snapshot_at }
|
||||
const reason = cams.map(c => c.snapshot?.reason).find(Boolean)
|
||||
return { reason: reason || 'No picture from this shop yet.' }
|
||||
}
|
||||
|
||||
// Drawn, not an emoji or an icon font: an empty tile that still reads as a
|
||||
// shop, in the same spirit as the camera screen's lens.
|
||||
function ShopMark() {
|
||||
return (
|
||||
<svg className="shopmark" viewBox="0 0 40 32" aria-hidden="true">
|
||||
<path d="M4 12h32v18H4z" />
|
||||
<path d="M2 12l4-8h28l4 8" />
|
||||
<path d="M15 30v-9h10v9" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function pct(f) { return `${Math.round(f * 100)}%` }
|
||||
|
||||
export function ago(iso) {
|
||||
if (!iso) return 'never'
|
||||
const then = new Date(iso).getTime()
|
||||
if (Number.isNaN(then)) return '—'
|
||||
const secs = Math.max(0, (Date.now() - then) / 1000)
|
||||
if (secs < 90) return 'just now'
|
||||
const mins = Math.round(secs / 60)
|
||||
if (mins < 60) return `${mins} min ago`
|
||||
const hrs = Math.round(mins / 60)
|
||||
if (hrs < 48) return `${hrs} h ago`
|
||||
return `${Math.round(hrs / 24)} days ago`
|
||||
}
|
||||
|
||||
export function Loading() {
|
||||
return <div className="state"><span className="spinner" aria-hidden="true" />Loading…</div>
|
||||
}
|
||||
|
||||
export function Problem({ error }) {
|
||||
return (
|
||||
<div className="state">
|
||||
<p className="error" role="alert">{error.message}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Empty() {
|
||||
return (
|
||||
<div className="state">
|
||||
<h2>No shops yet</h2>
|
||||
<p className="sub">
|
||||
A shop appears here once its PC has been claimed with an enrolment code.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user