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:
2026-09-04 11:14:18 +05:30
commit dad04e8cda
216 changed files with 40473 additions and 0 deletions

View File

@@ -0,0 +1,182 @@
import { useState } from 'react'
import { api, message } from '../bridge.js'
import CustomerPhoto, { useCustomerPhoto } from './CustomerPhoto.jsx'
import VisitHistory from './VisitHistory.jsx'
import EraseCustomer from './EraseCustomer.jsx'
// The in-store form. Two jobs in one sheet: capture who this person is, and
// record what they bought — because staff have the customer in front of them
// once, and asking them to open a second screen means the sale never gets
// recorded.
export default function CustomerForm({ customer, session, onClose, onSaved }) {
const [f, setF] = useState({
full_name: customer.full_name ?? '',
phone: customer.phone ?? '',
email: customer.email ?? '',
gender: '',
date_of_birth: '',
notes: '',
consent: customer.has_consent ?? false,
})
const [purchase, setPurchase] = useState({ amount: '', items: '', notes: '' })
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const [erasing, setErasing] = useState(false)
const [photo, setPhoto] = useState(null)
const fetched = useCustomerPhoto(customer.id)
const shown = photo ?? fetched
// Erasure destroys a record permanently, so it is a manager's decision. The
// server enforces this too — this only avoids offering a button that would
// come back 403.
const role = session?.user?.role
const canErase = ['admin', 'owner', 'manager'].includes(role)
const set = k => e => setF({ ...f, [k]: e.target.value })
async function save(e) {
e.preventDefault()
setBusy(true); setError(null)
try {
await api.saveProfile({ visitor_id: customer.id, ...f })
const amount = parseFloat(purchase.amount)
if (!isNaN(amount) && amount > 0) {
const items = purchase.items.split(',').map(s => s.trim()).filter(Boolean)
await api.recordPurchase(customer.id, amount, items, purchase.notes)
}
onSaved()
} catch (err) {
setError(message(err))
} finally {
setBusy(false)
}
}
return (
<div className="drawer" onMouseDown={e => e.target === e.currentTarget && onClose()}>
<div className="panel">
<div className="who">
<CustomerPhoto photo={shown} name={customer.full_name || customer.label}
onBroken={() => setPhoto({ available: false,
reason: 'The photo could not be loaded.' })} />
<div className="grow">
<h3>{customer.full_name || customer.label}</h3>
<p className="note">
{customer.visit_count} visit{customer.visit_count === 1 ? '' : 's'}
{customer.last_seen_at && ` · last seen ${new Date(customer.last_seen_at).toLocaleDateString()}`}
</p>
{shown && !shown.available && shown.reason &&
<p className="note">{shown.reason}</p>}
</div>
<button type="button" className="btn sm" onClick={onClose}>Close</button>
</div>
<form onSubmit={save}>
{error && <div className="err">{error}</div>}
<div className="card" style={{ marginBottom: 14 }}>
<h3>Customer details</h3>
<label className="field">
<span>Full name</span>
<input value={f.full_name} onChange={set('full_name')} autoFocus />
</label>
<div className="fieldrow">
<label className="field">
<span>Phone</span>
<input value={f.phone} onChange={set('phone')} inputMode="tel" />
</label>
<label className="field">
<span>Email</span>
<input value={f.email} onChange={set('email')} type="email" />
</label>
</div>
<div className="fieldrow">
<label className="field">
<span>Gender</span>
<select value={f.gender} onChange={set('gender')}>
<option value="">Not recorded</option>
<option>Female</option><option>Male</option><option>Other</option>
</select>
</label>
<label className="field">
<span>Date of birth</span>
<input type="date" value={f.date_of_birth} onChange={set('date_of_birth')} />
</label>
</div>
<label className="field">
<span>Notes</span>
<textarea value={f.notes} onChange={set('notes')}
placeholder="Preferences, sizes, anything worth remembering" />
</label>
</div>
<div className="card" style={{ marginBottom: 14 }}>
<h3>Purchase (optional)</h3>
<div className="fieldrow">
<label className="field">
<span>Amount</span>
<input value={purchase.amount} inputMode="decimal" placeholder="0.00"
onChange={e => setPurchase({ ...purchase, amount: e.target.value })} />
</label>
<label className="field">
<span>Items</span>
<input value={purchase.items} placeholder="shirt, belt"
onChange={e => setPurchase({ ...purchase, items: e.target.value })} />
</label>
</div>
<p className="note">Leave the amount blank if they did not buy anything —
a visit without a sale is still worth recording.</p>
</div>
<div className="card" style={{ marginBottom: 16 }}>
<h3>Consent</h3>
<label style={{ display: 'flex', gap: 10, alignItems: 'flex-start',
fontSize: 13, cursor: 'pointer' }}>
<input type="checkbox" checked={f.consent} style={{ marginTop: 3 }}
onChange={e => setF({ ...f, consent: e.target.checked })} />
<span>This customer agreed to us keeping their details and
recognising them on future visits.</span>
</label>
<p className="note" style={{ marginTop: 10 }}>
Recorded with the date and who collected it. They can withdraw it
at any time, which erases their face data.
</p>
</div>
<div className="card" style={{ marginBottom: 14 }}>
<h3>Visits</h3>
<VisitHistory customer={customer} />
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn primary" disabled={busy}>
{busy ? 'Saving…' : 'Save'}
</button>
<button type="button" className="btn" onClick={onClose}>Cancel</button>
</div>
{canErase && (
<div className="card danger-zone">
<h3>At the customer's request</h3>
{erasing
? <EraseCustomer customer={customer}
onCancel={() => setErasing(false)}
onDone={onSaved} />
: <>
<p className="note">
Erase this person's face data, photo and details. Their
past visits stay in your footfall figures, without their
name.
</p>
<button type="button" className="btn danger"
onClick={() => setErasing(true)}>
Erase this customer…
</button>
</>}
</div>
)}
</form>
</div>
</div>
)
}