dbbb051ebd
Build & push images / images (push) Successful in 4m22s
The wash stream is small; the entry camera photographs every car in exactly the view the classifier is trained on. The booth can now queue entry vehicle reads as pure training material — crop + the camera's class, no order, no operator, no category. - Core announces every vehicle read (deviceEvents.emitVehicleRead from snapshot.ts); the Car Wash module listens, samples entry reads in-process (sampleEntry: exactly one in N) and queues them (enqueueEntry). CARWASH_REVIEW_ENTRY_SAMPLE=N; 1 = every entry (storage and bandwidth are not the limit — user); 0/unset = off. Forwarded by compose. - Packages carry kind: "wash" | "entry". Collector: kind column, entry meta validated without the operator fields, review screen shows an entry sample as such, export has a kind column, operator agreement computed from wash items only. Setup line shows "1 in N entries sampled"; status carries entrySample. - komodo: park-2's four review lines enabled (collector URL by Netbird DNS name, booth-2, the shared per-booth secret, every entry sampled) — the collector is up on the overlay. - Tests on both sides. Wiki: vision-review-outbox (entry stream + the internet-feed assessment), log. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
121 lines
6.7 KiB
TypeScript
121 lines
6.7 KiB
TypeScript
import { VEHICLE_CLASSES } from "@parking/shared";
|
||
|
||
// The reviewer's screen: one pending crop at a time, the operator's pick and the camera's
|
||
// pick beside it, one button per vocabulary class + "unusable". Served by the collector
|
||
// itself (no build step, no framework) — this is deliberately the whole UI.
|
||
|
||
export function reviewPage(): string {
|
||
const classes = JSON.stringify(VEHICLE_CLASSES);
|
||
return `<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Wash review</title>
|
||
<style>
|
||
:root { --bg:#111; --panel:#1b1b1b; --text:#e8e8e8; --muted:#9a9a9a; --amber:#e0a030; --green:#4caf50; --red:#e05050; }
|
||
body { margin:0; background:var(--bg); color:var(--text); font:14px/1.4 system-ui, sans-serif; }
|
||
header { display:flex; justify-content:space-between; align-items:center; padding:.6rem 1rem; border-bottom:1px solid #333; }
|
||
header b { letter-spacing:.08em; text-transform:uppercase; color:var(--amber); font-size:.75rem; }
|
||
main { max-width:960px; margin:0 auto; padding:1rem; display:grid; gap:1rem; }
|
||
.card { background:var(--panel); border:1px solid #333; border-radius:6px; padding:1rem; }
|
||
img { max-width:100%; max-height:60vh; display:block; margin:0 auto; background:#000; border-radius:4px; }
|
||
dl { display:grid; grid-template-columns:max-content 1fr; gap:.2rem .8rem; margin:0; font-variant-numeric:tabular-nums; }
|
||
dt { color:var(--muted); }
|
||
.buttons { display:flex; flex-wrap:wrap; gap:.4rem; }
|
||
button { background:#2a2a2a; color:var(--text); border:1px solid #444; border-radius:4px; padding:.5rem .8rem; font:inherit; cursor:pointer; }
|
||
button:hover { border-color:var(--amber); }
|
||
button.mono { font-family:ui-monospace, monospace; }
|
||
button.hint { border-color:var(--amber); }
|
||
button.unusable { color:var(--red); }
|
||
button.skip { color:var(--muted); }
|
||
.muted { color:var(--muted); }
|
||
.warn { color:var(--amber); }
|
||
table { border-collapse:collapse; width:100%; font-variant-numeric:tabular-nums; }
|
||
td, th { text-align:left; padding:.2rem .5rem; border-bottom:1px solid #2a2a2a; }
|
||
th { color:var(--muted); font-weight:normal; font-size:.75rem; text-transform:uppercase; letter-spacing:.06em; }
|
||
kbd { background:#2a2a2a; border:1px solid #444; border-radius:3px; padding:0 .3rem; font-size:.75rem; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header><b>Wash review</b><span id="counts" class="muted"></span></header>
|
||
<main>
|
||
<section class="card" id="item">
|
||
<p class="muted">Loading…</p>
|
||
</section>
|
||
<section class="card">
|
||
<table id="stats"><thead><tr><th>booth</th><th>operator</th><th>reviewed</th><th>agree</th><th>disagree</th><th>unusable</th></tr></thead><tbody></tbody></table>
|
||
</section>
|
||
<p class="muted">Keys: <kbd>1</kbd>–<kbd>9</kbd>, <kbd>0</kbd> pick a class in order · <kbd>u</kbd> unusable · <kbd>s</kbd> skip. Skipped items come back after a reload. Your verdict is the training label; the operator's pick is only compared against it.</p>
|
||
</main>
|
||
<script>
|
||
const CLASSES = ${classes};
|
||
const skipped = new Set();
|
||
let current = null;
|
||
|
||
async function api(path, init) {
|
||
const r = await fetch(path, init);
|
||
if (!r.ok) throw new Error(path + ' → HTTP ' + r.status);
|
||
return r.json();
|
||
}
|
||
|
||
function esc(s) { return String(s).replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c])); }
|
||
|
||
async function loadStats() {
|
||
const s = await api('/api/stats');
|
||
const pending = s.booths.reduce((n, b) => n + b.pending, 0);
|
||
const reviewed = s.booths.reduce((n, b) => n + b.reviewed, 0);
|
||
document.getElementById('counts').textContent = pending + ' waiting · ' + reviewed + ' reviewed';
|
||
const tb = document.querySelector('#stats tbody');
|
||
tb.innerHTML = s.operators.map(o => '<tr><td>' + esc(o.booth) + '</td><td class="mono">' + esc(o.operatorRef) + '</td><td>' + o.reviewed + '</td><td>' + o.agree + '</td><td' + (o.disagree ? ' class="warn"' : '') + '>' + o.disagree + '</td><td>' + o.unusable + '</td></tr>').join('') || '<tr><td colspan="6" class="muted">nothing reviewed yet</td></tr>';
|
||
}
|
||
|
||
async function next() {
|
||
const { items } = await api('/api/items?status=pending&limit=25');
|
||
current = items.find(i => !skipped.has(i.id)) || null;
|
||
const el = document.getElementById('item');
|
||
if (!current) { el.innerHTML = '<p class="muted">Nothing waiting for review.</p>'; return; }
|
||
const it = current;
|
||
const opClasses = JSON.parse(it.operatorClasses || '[]');
|
||
el.innerHTML =
|
||
'<img src="/api/items/' + encodeURIComponent(it.id) + '/image" alt="">' +
|
||
'<dl style="margin-top:.8rem">' +
|
||
(it.kind === 'entry'
|
||
? '<dt>sample</dt><dd><span class="muted">entry stream — no wash, no operator decision; label the vehicle</span></dd>'
|
||
: '<dt>operator chose</dt><dd><b>' + esc(it.operatorCategoryName) + '</b> <span class="muted">(' + esc(opClasses.join(', ') || 'no classes mapped') + ')</span></dd>') +
|
||
'<dt>camera saw</dt><dd class="mono">' + esc(it.visionClass) + ' <span class="muted">' + Math.round(it.visionConfidence * 100) + '%</span>' + (it.downgraded ? ' <span class="warn">flagged downgrade at the booth</span>' : '') + '</dd>' +
|
||
(it.kind === 'entry' ? '<dt>booth</dt><dd class="mono">' + esc(it.booth) + '</dd>' :
|
||
'<dt>service</dt><dd>' + esc(it.service) + '</dd>' +
|
||
'<dt>booth · operator</dt><dd class="mono">' + esc(it.booth) + ' · ' + esc(it.operatorRef) + '</dd>') +
|
||
'<dt>at</dt><dd>' + esc(it.at) + '</dd>' +
|
||
'</dl>' +
|
||
'<div class="buttons" style="margin-top:.8rem">' +
|
||
CLASSES.map((c, i) => '<button class="mono' + (c === it.visionClass ? ' hint' : '') + '" data-label="' + c + '" title="key ' + ((i + 1) % 10) + '">' + c + '</button>').join('') +
|
||
'<button class="unusable" data-label="unusable">unusable</button>' +
|
||
'<button class="skip" data-skip="1">skip</button>' +
|
||
'</div>';
|
||
el.querySelectorAll('button[data-label]').forEach(b => b.addEventListener('click', () => verdict(b.dataset.label)));
|
||
el.querySelector('button[data-skip]').addEventListener('click', skip);
|
||
}
|
||
|
||
async function verdict(label) {
|
||
if (!current) return;
|
||
await api('/api/items/' + encodeURIComponent(current.id) + '/review', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ label }) });
|
||
await Promise.all([next(), loadStats()]);
|
||
}
|
||
function skip() { if (current) { skipped.add(current.id); next(); } }
|
||
|
||
document.addEventListener('keydown', e => {
|
||
if (e.target.tagName === 'INPUT') return;
|
||
if (e.key === 'u') verdict('unusable');
|
||
else if (e.key === 's') skip();
|
||
else if (/^[0-9]$/.test(e.key)) { const i = e.key === '0' ? 9 : Number(e.key) - 1; if (CLASSES[i]) verdict(CLASSES[i]); }
|
||
});
|
||
|
||
next().catch(e => { document.getElementById('item').innerHTML = '<p class="warn">' + esc(e.message) + '</p>'; });
|
||
loadStats().catch(() => {});
|
||
</script>
|
||
</body>
|
||
</html>`;
|
||
}
|