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
553 lines
25 KiB
HTML
553 lines
25 KiB
HTML
<!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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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">→</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>
|