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,552 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Behavision</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; margin: 0; }
body { font: 14px/1.5 system-ui, sans-serif; background: #101418;
color: #dde3ea; padding: 1.25rem; }
h1 { font-size: 1.15rem; margin-bottom: 1rem; letter-spacing: .02em; }
h1 small { color: #7b8794; font-weight: 400; margin-left: .5rem; }
.grid { display: grid; grid-template-columns: 2fr 1fr; gap: 1rem; }
@media (max-width: 900px) { .grid { grid-template-columns: 1fr; } }
.card { background: #171d24; border: 1px solid #232c36;
border-radius: 10px; padding: 1rem; }
.card h2 { font-size: .8rem; text-transform: uppercase; color: #7b8794;
letter-spacing: .08em; margin-bottom: .75rem; }
img.feed { width: 100%; border-radius: 6px; background: #000;
min-height: 240px; }
table { width: 100%; border-collapse: collapse; }
td, th { padding: .35rem .5rem; text-align: left;
border-bottom: 1px solid #232c36; }
th { color: #7b8794; font-weight: 500; font-size: .78rem; }
.muted { color: #7b8794; }
ul#events { list-style: none; max-height: 380px; overflow-y: auto; }
ul#events li { padding: .4rem 0; border-bottom: 1px solid #232c36; }
.tag { display: inline-block; padding: .05rem .45rem; border-radius: 99px;
font-size: .72rem; margin-right: .4rem; }
.tag.new { background: #2b4e77; } .tag.seen { background: #2e5c3a; }
.tag.cam { background: #5a4a2a; }
.tag.miss { background: #6b3030; }
.tag.merge { background: #4a3a6b; }
.bar { display: flex; height: 8px; border-radius: 4px; overflow: hidden;
margin: .35rem 0 .5rem; background: #222; }
.bar i { display: block; }
.warn { color: #e0a33a; }
.pl-row { margin-bottom: .9rem; }
.pl-row b { font-weight: 600; }
.legend { font-size: .72rem; }
.legend span { margin-right: .7rem; white-space: nowrap; }
.btn { background: #2a3446; color: #cfd8e3; border: 1px solid #3a4658;
border-radius: 4px; padding: .25rem .6rem; cursor: pointer;
font: inherit; font-size: .78rem; }
.btn:hover { background: #35415a; }
.btn[disabled] { opacity: .5; cursor: default; }
.btn.primary { background: #2b4e77; border-color: #3a6291; }
.btn.danger { background: #4a2626; border-color: #6b3030; }
.card h2 .btn { float: right; margin-top: -.15rem; text-transform: none;
letter-spacing: 0; }
.cam-row { display: flex; align-items: center; gap: .5rem;
padding: .4rem 0; border-bottom: 1px solid #1e2430; }
.cam-row:last-child { border-bottom: 0; }
.cam-row .grow { flex: 1; min-width: 0; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; }
.pill { font-size: .7rem; padding: .05rem .45rem; border-radius: 99px; }
.pill.up { background: #2e5c3a; } .pill.down { background: #6b3030; }
.fields { display: grid; grid-template-columns: 1fr 1fr; gap: .5rem .75rem;
margin-bottom: .6rem; }
.fields label { display: block; font-size: .72rem; color: #7b8794;
margin-bottom: .15rem; }
.fields .wide { grid-column: 1 / -1; }
.fields input { width: 100%; background: #101418; color: #dde3ea;
border: 1px solid #2b3543; border-radius: 4px;
padding: .3rem .45rem; font: inherit; font-size: .82rem; }
.fields input:disabled { color: #7b8794; }
#cam-form { border-top: 1px solid #232c36; margin-top: .6rem;
padding-top: .75rem; }
#wizard { position: fixed; inset: 0; background: #000a;
display: flex; align-items: center; justify-content: center;
padding: 1rem; z-index: 20; }
/* An author `display` beats the UA stylesheet's `[hidden] { display: none }`,
so without this the placement wizard sits open over the dashboard on every
load - the modal is toggled by the `hidden` property, not by a class. */
#wizard[hidden] { display: none; }
#wizard .panel { background: #171d24; border: 1px solid #2b3543;
border-radius: 10px; padding: 1.25rem; max-width: 460px;
width: 100%; }
#wizard h3 { font-size: .95rem; margin-bottom: .5rem; }
#wizard .advice { margin: .6rem 0 .9rem; padding-left: 1.1rem;
font-size: .84rem; color: #b8c2ce; }
#wizard .advice li { margin-bottom: .3rem; }
.verdict { font-size: .95rem; font-weight: 600; margin: .4rem 0; }
.v-good { color: #4caf7d; } .v-marginal { color: #e0a33a; }
.v-poor, .v-artifact { color: #e05c5c; }
.v-no_faces, .v-inconclusive { color: #e0a33a; }
.progress { height: 6px; border-radius: 3px; background: #222;
overflow: hidden; margin: .6rem 0; }
.progress i { display: block; height: 100%; background: #3a6291; }
#cam-test { margin-top: .6rem; font-size: .82rem; }
#cam-test img { width: 100%; border-radius: 6px; margin-top: .4rem; }
.ok { color: #4caf7d; }
.err { color: #e05c5c; }
.dup { display: flex; align-items: center; gap: .5rem;
padding: .35rem 0; border-bottom: 1px solid #1e2430; }
.dup:last-child { border-bottom: 0; }
.dup .grow { flex: 1; min-width: 0; }
.dup button { background: #2a3446; color: #cfd8e3; border: 1px solid #3a4658;
border-radius: 4px; padding: .2rem .55rem; cursor: pointer;
font: inherit; font-size: .78rem; }
.dup button:hover { background: #35415a; }
.dup button[disabled] { opacity: .5; cursor: default; }
.sim { font-variant-numeric: tabular-nums; }
.dot { display: inline-block; width: .55rem; height: .55rem;
border-radius: 50%; margin-right: .25rem; vertical-align: middle; }
</style>
</head>
<body>
<h1>Behavision <small id="status">connecting…</small></h1>
<div class="grid">
<div>
<div class="card"><h2>Live</h2><div id="feeds"></div></div>
<div class="card" style="margin-top:1rem">
<h2>Cameras <button class="btn" id="cam-new">+ Add camera</button></h2>
<div id="cam-list" class="muted">none configured</div>
<div id="cam-form" hidden>
<div class="fields">
<div><label for="f-id">Camera id</label>
<input id="f-id" placeholder="entrance"></div>
<div><label for="f-host">Host / IP</label>
<input id="f-host" placeholder="192.168.0.138"></div>
<div><label for="f-port">Port</label>
<input id="f-port" type="number" value="554"></div>
<div><label for="f-path">Stream path</label>
<input id="f-path" placeholder="/ch0_0.264"></div>
<div><label for="f-username">Username</label>
<input id="f-username" placeholder="admin"></div>
<div><label for="f-password">Password</label>
<input id="f-password" type="password" autocomplete="new-password"></div>
<div><label for="f-max_width">Max width (px)</label>
<input id="f-max_width" type="number" value="1280"></div>
<div><label for="f-webcam">Webcam index (instead of RTSP)</label>
<input id="f-webcam" type="number" placeholder="0"></div>
<div class="wide"><label for="f-url">Full URL (overrides host/port/path)</label>
<input id="f-url" placeholder="rtsp://user:pass@host:554/stream"></div>
</div>
<button class="btn" id="cam-test-btn">Test connection</button>
<button class="btn primary" id="cam-save">Save</button>
<button class="btn" id="cam-cancel">Cancel</button>
<div id="cam-test"></div>
</div>
</div>
</div>
<div>
<div class="card"><h2>Recent events</h2><ul id="events"></ul></div>
<div class="card" style="margin-top:1rem"><h2>Recognition health</h2>
<div id="pipeline" class="muted">no tracks yet</div></div>
<div class="card" style="margin-top:1rem"><h2>Possible duplicates</h2>
<div id="dupes" class="muted">none found</div></div>
<div class="card" style="margin-top:1rem"><h2>People</h2>
<table><thead><tr><th>Label</th><th>Seen</th><th>Last</th></tr></thead>
<tbody id="people"></tbody></table>
</div>
</div>
</div>
<div id="wizard" hidden><div class="panel">
<h3 id="wz-title">Camera placement check</h3>
<div id="wz-body"></div>
<button class="btn" id="wz-close">Close</button>
<button class="btn primary" id="wz-again" hidden>Run again</button>
<button class="btn" id="wz-loosen" hidden>Use this camera's own gate</button>
</div></div>
<script>
const feeds = document.getElementById('feeds');
const fmtTime = ts => new Date(ts * 1000).toLocaleTimeString();
// Identity labels are user-supplied (PATCH /api/identities/{id}) and camera
// ids come from config, so every value interpolated into innerHTML below is
// escaped first. Without this a label like <img onerror=...> is stored XSS.
const esc = v => String(v ?? '').replace(/[&<>"']/g,
c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
// `camera_id` only exists on cameras whose worker is running - it comes from
// worker.stats(). A stored camera that failed to start has only `id`, and
// using the wrong one put the string "undefined" in the stream URL.
let feedKey = null;
function renderFeeds(cams) {
const key = cams.map(c => c.id).join('|');
// Never re-create a live <img>: assigning src restarts the MJPEG stream, so
// rebuilding on every 3s refresh would make every feed flicker forever.
if (key === feedKey) return;
feedKey = key;
feeds.innerHTML = cams.map(cam => `<figure><img class="feed"
src="/api/cameras/${encodeURIComponent(cam.id)}/stream.mjpeg"
alt="${esc(cam.id)}"><figcaption class="muted">${esc(cam.id)}</figcaption>
</figure>`).join('') || '<span class="muted">no cameras configured</span>';
}
async function boot() {
refresh();
setInterval(refresh, 3000);
}
// Outcome of every finished track. This panel exists because the pipeline
// was previously unfalsifiable from the UI: a camera rejecting every visitor
// on quality looked exactly like a camera nobody walked past.
const OUTCOMES = [
['recognized', '#2e5c3a', 'returning'],
['enrolled', '#2b4e77', 'new'],
['rejected_quality', '#8a3b3b', 'face too poor to enroll'],
['gave_up_ambiguous','#8a6a2a', 'never settled'],
['ended_ambiguous', '#6a5a3a', 'left while unsure'],
['too_brief', '#444', 'gone too fast'],
['no_embedding', '#333', 'never encodable'],
];
function renderPipeline(c) {
const p = c.pipeline;
if (!p || !p.tracks_ended) {
return `<div class="pl-row"><b>${esc(c.camera_id)}</b>
<span class="muted"> — no finished tracks yet</span></div>`;
}
const total = p.tracks_ended;
const bar = OUTCOMES.map(([key, color]) => {
const n = p.outcomes[key] || 0;
return n ? `<i style="width:${(n / total * 100).toFixed(1)}%;
background:${color}" title="${key}: ${n}"></i>` : '';
}).join('');
const legend = OUTCOMES.filter(([k]) => p.outcomes[k]).map(([k, color, human]) =>
`<span><i class="dot" style="background:${color}"></i>${esc(human)}
${esc(p.outcomes[k])}</span>`).join('');
const q = p.best_quality || {};
// The number that says the enrollment gate is wrong for this camera, as
// opposed to the camera being pointed somewhere nobody walks.
const below = q.fraction_below_gate;
const gateWarn = below >= 0.5
? `<div class="warn">⚠ ${(below * 100).toFixed(0)}% of faces are below the
enrollment quality gate — these visitors are seen and discarded.
Fix camera placement before touching thresholds.</div>` : '';
const spread = q.n
? `<div class="muted legend">face quality p05 ${esc(q.p05)} ·
median ${esc(q.p50)} · p95 ${esc(q.p95)}${
below != null ? ` · ${(below * 100).toFixed(0)}% under gate` : ''}</div>`
: '';
return `<div class="pl-row"><b>${esc(c.camera_id)}</b>
<span class="muted"> — ${esc(total)} finished tracks</span>
<div class="bar">${bar}</div>
<div class="muted legend">${legend}</div>${spread}${gateWarn}</div>`;
}
// One person enrolled twice. Merging is irreversible - nothing records which
// embedding came from which identity - so this only ever *suggests*, and the
// operator confirms. Pairs below enroll_threshold are not offered at all.
function renderDupes(pairs) {
if (!pairs.length) return '<span class="muted">none found</span>';
return pairs.map(p => {
// Merge the sparser record into the richer one, and a "Visitor N" into a
// named person, so the surviving identity is the one with more history.
const named = x => x.kind !== 'auto';
let [from, into] = named(p.a) && !named(p.b) ? [p.b, p.a]
: named(p.b) && !named(p.a) ? [p.a, p.b]
: p.a.sighting_count <= p.b.sighting_count ? [p.a, p.b] : [p.b, p.a];
return `<div class="dup">
<span class="grow">${esc(from.label)} <span class="muted">&rarr;</span>
${esc(into.label)}</span>
<span class="muted sim">${esc(p.similarity)}${p.confident ? '' : ' ?'}</span>
<button data-from="${esc(from.id)}" data-into="${esc(into.id)}"
data-desc="${esc(from.label)} into ${esc(into.label)}">Merge</button>
</div>`;
}).join('');
}
document.getElementById('dupes').addEventListener('click', async ev => {
const btn = ev.target.closest('button[data-from]');
if (!btn) return;
const {from, into, desc} = btn.dataset;
if (!confirm(`Merge ${desc}?\n\nThis cannot be undone.`)) return;
btn.disabled = true;
const send = force => fetch(`/api/identities/${encodeURIComponent(from)}/merge`,
{method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({into: Number(into), force})});
let r = await send(false);
if (r.status === 409) {
// The gallery's own numbers say these are different people. Show the
// measured similarity rather than a generic failure - overriding it is a
// decision, and the operator needs the number to make it.
const d = (await r.json()).detail || {};
if (!confirm(`${d.reason || 'Refused.'}\n\nMerge anyway?`)) {
btn.disabled = false; return;
}
r = await send(true);
}
if (!r.ok) alert('Merge failed.');
btn.disabled = false;
refresh();
});
// -- camera settings ------------------------------------------------------
// The API never returns a camera password - not masked, not empty-string-if-
// set, absent. So an edit sends `password` only when the user actually typed
// one; leaving it blank keeps whatever is stored.
const F = ['id', 'host', 'port', 'path', 'username', 'password', 'max_width',
'webcam', 'url'];
const fld = n => document.getElementById('f-' + n);
let editing = null; // camera id being edited, or null when adding
function renderCameras(cams) {
const el = document.getElementById('cam-list');
if (!cams.length) {
el.innerHTML = '<span class="muted">none configured</span>';
return;
}
el.innerHTML = cams.map(c => {
const live = c.connected === undefined ? null : !!c.connected;
const pill = live === null ? '<span class="pill muted">stopped</span>'
: `<span class="pill ${live ? 'up' : 'down'}">${live ? 'live' : 'offline'}</span>`;
return `<div class="cam-row">
<span class="grow"><b>${esc(c.id)}</b>
<span class="muted"> ${esc(c.url)}</span></span>
${pill}
<button class="btn" data-check="${esc(c.id)}">Check placement</button>
<button class="btn" data-edit="${esc(c.id)}">Edit</button>
<button class="btn danger" data-del="${esc(c.id)}">Delete</button>
</div>`;
}).join('');
}
function openForm(cam) {
editing = cam ? cam.id : null;
for (const n of F) fld(n).value = '';
fld('port').value = 554;
fld('max_width').value = 1280;
if (cam) {
for (const n of F) if (cam[n] !== null && cam[n] !== undefined) fld(n).value = cam[n];
fld('password').value = '';
fld('password').placeholder = cam.has_password ? '(unchanged)' : '';
} else {
fld('password').placeholder = '';
}
// The id is the store key and PATCH ignores it; showing it editable would
// imply a rename that silently does nothing.
fld('id').disabled = !!cam;
document.getElementById('cam-test').innerHTML = '';
document.getElementById('cam-form').hidden = false;
}
function closeForm() {
document.getElementById('cam-form').hidden = true;
editing = null;
}
function formBody() {
const body = {};
for (const n of F) {
const v = fld(n).value.trim();
if (v === '') continue; // blank = "leave alone", never "clear"
body[n] = (n === 'port' || n === 'max_width' || n === 'webcam')
? Number(v) : v;
}
return body;
}
async function testCamera() {
const btn = document.getElementById('cam-test-btn');
const out = document.getElementById('cam-test');
btn.disabled = true;
out.innerHTML = '<span class="muted">connecting…</span>';
try {
const r = await fetch('/api/cameras/test', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify(formBody())});
const d = await r.json();
if (!d.ok) {
out.innerHTML = `<span class="err">${esc(d.error || 'failed')}</span>`;
} else {
const scaled = d.downscaled_to
? ` <span class="muted">(downscaled to ${esc(d.downscaled_to)}px)</span>` : '';
out.innerHTML = `<span class="ok">connected — ${esc(d.width)}×${esc(d.height)}</span>${scaled}`
+ (d.snapshot ? `<img src="data:image/jpeg;base64,${esc(d.snapshot)}" alt="snapshot">` : '');
}
} catch (e) {
out.innerHTML = '<span class="err">test request failed</span>';
}
btn.disabled = false;
}
async function saveCamera() {
const btn = document.getElementById('cam-save');
const out = document.getElementById('cam-test');
const body = formBody();
if (!editing && !body.id) {
out.innerHTML = '<span class="err">camera id is required</span>';
return;
}
btn.disabled = true;
const r = editing
? await fetch(`/api/cameras/${encodeURIComponent(editing)}`, {
method: 'PATCH', headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body)})
: await fetch('/api/cameras', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body)});
btn.disabled = false;
if (!r.ok) {
let msg = `save failed (${r.status})`;
try { const d = await r.json(); msg = typeof d.detail === 'string' ? d.detail : msg; } catch (e) {}
out.innerHTML = `<span class="err">${esc(msg)}</span>`;
return;
}
closeForm();
feedKey = null; // a new or edited camera needs its feed rebuilt
refresh();
}
document.getElementById('cam-new').onclick = () => openForm(null);
document.getElementById('cam-cancel').onclick = closeForm;
document.getElementById('cam-test-btn').onclick = testCamera;
document.getElementById('cam-save').onclick = saveCamera;
document.getElementById('cam-list').addEventListener('click', async ev => {
const check = ev.target.closest('button[data-check]');
if (check) { wzStart(check.dataset.check); return; }
const edit = ev.target.closest('button[data-edit]');
if (edit) {
const cams = await (await fetch('/api/cameras')).json();
const cam = cams.find(c => c.id === edit.dataset.edit);
if (cam) openForm(cam);
return;
}
const del = ev.target.closest('button[data-del]');
if (!del) return;
const id = del.dataset.del;
if (!confirm(`Delete camera "${id}"? Recognition from it stops immediately.`)) return;
del.disabled = true;
await fetch(`/api/cameras/${encodeURIComponent(id)}`, {method: 'DELETE'});
if (editing === id) closeForm();
feedKey = null;
refresh();
});
// -- placement wizard -----------------------------------------------------
// The Office1 camera ran for weeks recognising almost nobody, and nothing
// looked broken. This makes that discoverable at install time instead of from
// a footfall report that was always zero.
const wz = document.getElementById('wizard');
let wzCamera = null, wzTimer = null, wzLast = null;
function wzShow(html) { document.getElementById('wz-body').innerHTML = html; }
function wzRender(d) {
wzLast = d;
const pct = Math.round(100 * (d.elapsed / d.seconds));
const q = d.quality || {};
const bar = `<div class="progress"><i style="width:${d.running ? pct : 100}%"></i></div>`;
const advice = (d.advice || []).map(a => `<li>${esc(a)}</li>`).join('');
const numbers = q.n ? `<div class="muted legend">faces ${esc(q.n)} ·
quality p05 ${esc(q.p05)} · median ${esc(q.p50)} · p95 ${esc(q.p95)} ·
gate ${esc(d.gate)} · below gate ${Math.round(100 * q.fraction_below_gate)}%</div>` : '';
wzShow(`<div class="verdict v-${esc(d.verdict)}">${esc(d.headline)}</div>
${bar}<ul class="advice">${advice}</ul>${numbers}`);
document.getElementById('wz-again').hidden = d.running;
// Loosening the gate is only ever offered for a marginal camera. For a poor
// one the answer is to move the camera: dropping the gate there converts a
// visible miss into an invisible wrong match, which is strictly worse.
document.getElementById('wz-loosen').hidden =
!(d.verdict === 'marginal' && q.p05 !== undefined);
}
async function wzPoll() {
const r = await fetch(`/api/cameras/${encodeURIComponent(wzCamera)}/commission`);
if (!r.ok) return;
const d = await r.json();
wzRender(d);
if (!d.running) { clearInterval(wzTimer); wzTimer = null; }
}
async function wzStart(id) {
wzCamera = id;
wz.hidden = false;
document.getElementById('wz-title').textContent = `Placement check — ${id}`;
wzShow('<span class="muted">starting…</span>');
const r = await fetch(`/api/cameras/${encodeURIComponent(id)}/commission`,
{method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({seconds: 25})});
if (!r.ok) { wzShow('<span class="err">could not start — is the camera running?</span>'); return; }
wzRender(await r.json());
if (wzTimer) clearInterval(wzTimer);
wzTimer = setInterval(wzPoll, 1000);
}
document.getElementById('wz-close').onclick = async () => {
if (wzTimer) { clearInterval(wzTimer); wzTimer = null; }
// Cancel rather than leave it running: a check still collecting after the
// installer walked away would keep changing the answer they just read.
if (wzCamera) await fetch(`/api/cameras/${encodeURIComponent(wzCamera)}/commission`,
{method: 'DELETE'});
wz.hidden = true; wzCamera = null;
};
document.getElementById('wz-again').onclick = () => wzStart(wzCamera);
document.getElementById('wz-loosen').onclick = async () => {
// Set this camera's gate just under the faces it actually sees. Per camera
// only - quality describes a view, and every camera writes into one gallery.
const gate = Math.max(0.1, Math.round((wzLast.quality.p05 - 0.02) * 100) / 100);
if (!confirm(`Set ${wzCamera} min_enroll_quality to ${gate}?\n\n` +
`Only this camera is affected.`)) return;
await fetch(`/api/cameras/${encodeURIComponent(wzCamera)}`, {
method: 'PATCH', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({tuning: {min_enroll_quality: gate}})});
wzStart(wzCamera);
};
async function refresh() {
try {
const [stats, events, people, dupes, camList] = await Promise.all([
(await fetch('/api/stats')).json(),
(await fetch('/api/events?limit=30')).json(),
(await fetch('/api/identities?limit=30')).json(),
(await fetch('/api/identities/duplicates?limit=10')).json(),
(await fetch('/api/cameras')).json(),
]);
renderFeeds(camList);
renderCameras(camList);
const cams = stats.cameras.map(c =>
`${c.camera_id}: ${c.connected ? 'live' : 'offline'}`).join(' · ');
// textContent, not innerHTML — no escaping needed here.
document.getElementById('status').textContent =
`${cams} · ${stats.gallery.identities} people · ${stats.gallery.sightings} sightings`;
document.getElementById('events').innerHTML = events.map(e => {
const cls = e.type === 'person.new' ? 'new'
: e.type === 'person.seen' ? 'seen'
: e.type === 'person.missed' ? 'miss'
: e.type === 'identity.merged' ? 'merge' : 'cam';
const who = e.data.label ? ` ${esc(e.data.label)}` : '';
// genderage.onnx reports an integer `age`; the Caffe fallback reports
// a bucketed `age_range`. Show whichever this backend produced.
const age = e.data.age ?? e.data.age_range;
const extra = e.data.gender ? ` · ${esc(e.data.gender)}${age != null ? ', ' + esc(age) : ''}${e.data.emotion ? ', ' + esc(e.data.emotion) : ''}` : '';
return `<li><span class="tag ${cls}">${esc(e.type)}</span>${who}
<span class="muted">${extra} · ${esc(e.camera_id)} · ${fmtTime(e.ts)}</span></li>`;
}).join('');
document.getElementById('pipeline').innerHTML =
stats.cameras.map(renderPipeline).join('');
document.getElementById('dupes').innerHTML = renderDupes(dupes);
document.getElementById('people').innerHTML = people.map(p =>
`<tr><td>${esc(p.label)}</td><td>${esc(p.sighting_count)}</td>
<td class="muted">${p.last_seen_at ? fmtTime(p.last_seen_at) : '—'}</td></tr>`
).join('');
} catch (err) {
document.getElementById('status').textContent = 'api unreachable';
}
}
boot();
</script>
</body>
</html>