const video = document.getElementById("video");
const canvas = document.getElementById("canvas");
const captured = document.getElementById("captured");
const cameraError = document.getElementById("cameraError");
const fileInput = document.getElementById("fileInput");
const pickBtn = document.getElementById("pickBtn");
const scanBtn = document.getElementById("scanBtn");
const flipBtn = document.getElementById("flipBtn");
const flashBtn = document.getElementById("flashBtn");
const scanStatusText = document.getElementById("scanStatusText");
const camDetectBadge = document.getElementById("camDetectBadge");
const scannerModal = document.getElementById("scannerModal");
const openScannerBtn = document.getElementById("openScannerBtn");
const scanAnotherBtn = document.getElementById("scanAnotherBtn");
const closeScannerBtn = document.getElementById("closeScannerBtn");
const menuScanLink = document.getElementById("menuScanLink");
const menuBtn = document.getElementById("menuBtn");
const navMenu = document.getElementById("navMenu");
const resultOverlay = document.getElementById("resultOverlay");
const resultCard = document.getElementById("resultCard");
const resultIcon = document.getElementById("resultIcon");
const resultBinIcon = document.getElementById("resultBinIcon");
const resultBinName = document.getElementById("resultBinName");
const resultBinDesc = document.getElementById("resultBinDesc");
const resultLabel = document.getElementById("resultLabel");
const resultConfidence = document.getElementById("resultConfidence");
const resultConfidenceFill = document.getElementById("resultConfidenceFill");
const resultTopWrap = document.getElementById("resultTopWrap");
const resultTopList = document.getElementById("resultTopList");
const closeResultBtn = document.getElementById("closeResultBtn");
const cameraWrap = document.getElementById("cameraWrap");
const toast = document.getElementById("toast");
const analysisToggle = document.getElementById("analysisToggle");
const analysisPanel = document.getElementById("analysisPanel");
const analysisDist = document.getElementById("analysisDist");
const analysisModel = document.getElementById("analysisModel");
const mEntropy = document.getElementById("mEntropy");
const mEntropyNote = document.getElementById("mEntropyNote");
const mMargin = document.getElementById("mMargin");
const mLatency = document.getElementById("mLatency");
const mDevice = document.getElementById("mDevice");
const mLogit = document.getElementById("mLogit");
const explainBtn = document.getElementById("explainBtn");
const explainResult = document.getElementById("explainResult");
const explainImg = document.getElementById("explainImg");
const explainMeta = document.getElementById("explainMeta");
const CHECK_ICON =
'';
const BIN_ICON = {
green: '',
blue: '',
red: '',
};
let currentBlob = null;
let lastScanBlob = null;
let stream = null;
let facingMode = "environment";
let torchOn = false;
function showToast(message) {
toast.textContent = message;
toast.hidden = false;
setTimeout(() => (toast.hidden = true), 3000);
}
function setStatus(text) {
scanStatusText.textContent = text;
}
async function startCamera() {
stopCamera();
try {
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode },
audio: false,
});
video.srcObject = stream;
cameraError.hidden = true;
torchOn = false;
flashBtn.classList.remove("is-on");
const capabilities = stream.getVideoTracks()[0]?.getCapabilities?.() || {};
flashBtn.hidden = !capabilities.torch;
} catch (err) {
cameraError.hidden = false;
flashBtn.hidden = true;
console.warn("Camera unavailable:", err);
}
}
function stopCamera() {
if (stream) {
stream.getTracks().forEach((t) => t.stop());
stream = null;
}
}
function showCapturedFrame(blob) {
currentBlob = blob;
captured.src = URL.createObjectURL(blob);
captured.hidden = false;
video.hidden = true;
}
function resetToLive() {
currentBlob = null;
captured.hidden = true;
video.hidden = false;
fileInput.value = "";
setStatus("Point at a waste item");
}
async function captureFrame() {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext("2d").drawImage(video, 0, 0);
return new Promise((resolve) => canvas.toBlob(resolve, "image/jpeg", 0.9));
}
scanBtn.addEventListener("click", async () => {
if (!currentBlob) {
if (!video.srcObject) {
showToast("Camera unavailable. Upload a photo instead.");
return;
}
currentBlob = await captureFrame();
showCapturedFrame(currentBlob);
}
await runScan(currentBlob);
});
pickBtn.addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", async () => {
const file = fileInput.files[0];
if (!file) return;
showCapturedFrame(file);
await runScan(file);
});
flipBtn.addEventListener("click", async () => {
facingMode = facingMode === "environment" ? "user" : "environment";
resetToLive();
await startCamera();
});
flashBtn.addEventListener("click", async () => {
const track = stream?.getVideoTracks()[0];
if (!track) return;
torchOn = !torchOn;
try {
await track.applyConstraints({ advanced: [{ torch: torchOn }] });
flashBtn.classList.toggle("is-on", torchOn);
} catch {
torchOn = false;
showToast("Flash isn't available on this camera.");
}
});
closeResultBtn.addEventListener("click", () => {
resultOverlay.hidden = true;
resetToLive();
});
function openScanner() {
navMenu.hidden = true;
menuBtn.setAttribute("aria-expanded", "false");
scannerModal.hidden = false;
resetToLive();
startCamera();
}
function closeScanner() {
scannerModal.hidden = true;
stopCamera();
resetToLive();
}
openScannerBtn.addEventListener("click", openScanner);
scanAnotherBtn.addEventListener("click", openScanner);
closeScannerBtn.addEventListener("click", closeScanner);
menuScanLink.addEventListener("click", (e) => {
e.preventDefault();
openScanner();
});
menuBtn.addEventListener("click", () => {
const isOpen = !navMenu.hidden;
navMenu.hidden = isOpen;
menuBtn.setAttribute("aria-expanded", String(!isOpen));
});
// The model answers in ~250ms, which is too fast to read. Hold the sweep long
// enough for the scan to be legible, then pop the check before the result.
const SWEEP_MS = 1800;
const DETECT_MS = 700;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function classify(blob) {
const formData = new FormData();
formData.append("file", blob, "capture.jpg");
const res = await fetch("/api/classify", { method: "POST", body: formData });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || `Request failed (${res.status})`);
}
return res.json();
}
async function runScan(blob) {
scanBtn.disabled = true;
lastScanBlob = blob;
cameraWrap.classList.add("is-scanning");
setStatus("Scanning...");
// Request and sweep run together, so the wait is whichever takes longer.
const [outcome] = await Promise.all([
classify(blob).then(
(data) => ({ data }),
(error) => ({ error })
),
sleep(SWEEP_MS),
]);
cameraWrap.classList.remove("is-scanning");
scanBtn.disabled = false;
if (outcome.error) {
showToast(outcome.error.message || "Scan failed. Try again.");
resetToLive();
return;
}
setStatus("Detecting...");
camDetectBadge.classList.add("is-visible");
await sleep(DETECT_MS);
setStatus("Detected");
camDetectBadge.classList.remove("is-visible");
showResult(outcome.data);
}
function formatParams(n) {
return n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n.toLocaleString();
}
function certaintyNote(normalized) {
if (normalized < 0.15) return "very certain";
if (normalized < 0.4) return "confident";
if (normalized < 0.7) return "ambiguous";
return "highly uncertain";
}
function renderAnalysis(analysis) {
analysisToggle.hidden = !analysis;
if (!analysis) return;
mEntropy.textContent = `${analysis.entropy_bits.toFixed(3)} bits`;
mEntropyNote.textContent = certaintyNote(analysis.entropy_normalized);
mMargin.textContent = `${(analysis.margin * 100).toFixed(1)}%`;
mLatency.textContent = `${analysis.latency_ms} ms`;
mDevice.textContent = analysis.device;
mLogit.textContent = analysis.logit_top.toFixed(2);
analysisDist.replaceChildren(
...analysis.distribution.map((item, i) => {
const li = document.createElement("li");
if (i === 0) li.className = "is-top";
const name = document.createElement("span");
name.className = "dist-name";
name.textContent = item.label;
const track = document.createElement("span");
track.className = "dist-track";
const fill = document.createElement("span");
fill.className = "dist-fill";
fill.style.width = `${Math.max(item.confidence * 100, 0.6)}%`;
track.appendChild(fill);
const pct = document.createElement("span");
pct.className = "dist-pct";
const p = item.confidence * 100;
pct.textContent = p >= 0.1 ? `${p.toFixed(1)}%` : `${p.toExponential(1)}%`;
li.append(name, track, pct);
return li;
})
);
const m = analysis.model;
const rows = [
["Model", m.id],
["Architecture", m.architecture],
["Parameters", formatParams(m.parameters)],
["Input", `${m.input_size[0]}×${m.input_size[1]} · patch ${m.patch_size}`],
["Transformer", `${m.num_layers} layers · ${m.num_heads} heads · d=${m.hidden_size}`],
["Classes", m.num_classes],
["Source image", `${m.original_image_size[0]}×${m.original_image_size[1]}`],
["Normalize", `mean ${m.normalize_mean.join(", ")} · std ${m.normalize_std.join(", ")}`],
];
analysisModel.replaceChildren(
...rows.flatMap(([k, v]) => {
const dt = document.createElement("dt");
dt.textContent = k;
const dd = document.createElement("dd");
dd.textContent = v;
return [dt, dd];
})
);
}
function collapseAnalysis() {
analysisPanel.hidden = true;
analysisToggle.setAttribute("aria-expanded", "false");
analysisToggle.classList.remove("is-open");
explainResult.hidden = true;
explainImg.removeAttribute("src");
explainBtn.hidden = false;
explainBtn.disabled = false;
explainBtn.textContent = "Generate attention map";
}
explainBtn.addEventListener("click", async () => {
if (!lastScanBlob) {
showToast("Scan an image first.");
return;
}
explainBtn.disabled = true;
explainBtn.textContent = "Computing attention...";
try {
const formData = new FormData();
formData.append("file", lastScanBlob, "capture.jpg");
const res = await fetch("/api/explain", { method: "POST", body: formData });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || `Request failed (${res.status})`);
}
const data = await res.json();
explainImg.src = data.heatmap;
explainMeta.textContent = `${data.grid[0]}×${data.grid[1]} patch grid · ${data.patches} patches · ${data.layers} layers rolled out · ${data.latency_ms} ms`;
explainResult.hidden = false;
explainBtn.hidden = true;
} catch (err) {
showToast(err.message || "Could not generate the attention map.");
explainBtn.disabled = false;
explainBtn.textContent = "Generate attention map";
}
});
analysisToggle.addEventListener("click", () => {
const open = analysisPanel.hidden;
analysisPanel.hidden = !open;
analysisToggle.setAttribute("aria-expanded", String(open));
analysisToggle.classList.toggle("is-open", open);
});
function showResult(data) {
resultCard.classList.remove("green", "blue", "red");
resultCard.classList.add(data.bin_color);
collapseAnalysis();
renderAnalysis(data.analysis);
resultIcon.innerHTML = CHECK_ICON;
resultBinIcon.innerHTML = BIN_ICON[data.bin_color] || "";
resultLabel.textContent = data.label;
resultBinName.textContent = data.bin_name;
resultBinDesc.textContent = data.bin_desc;
const pct = data.confidence * 100;
resultConfidence.textContent = `${pct.toFixed(1)}%`;
const top = Array.isArray(data.top) ? data.top : [];
resultTopWrap.hidden = top.length === 0;
resultTopList.replaceChildren(
...top.map((item) => {
const li = document.createElement("li");
const name = document.createElement("span");
name.className = "pred-name";
name.textContent = item.label;
const track = document.createElement("span");
track.className = "pred-bar-track";
const fill = document.createElement("span");
fill.className = "pred-bar-fill";
fill.style.width = `${(item.confidence * 100).toFixed(1)}%`;
track.appendChild(fill);
const pctEl = document.createElement("span");
pctEl.className = "pred-pct";
pctEl.textContent = `${Math.round(item.confidence * 100)}%`;
li.append(name, track, pctEl);
return li;
})
);
resultOverlay.hidden = false;
// Let the overlay paint at 0 before growing the bar, so it animates.
resultConfidenceFill.style.width = "0%";
requestAnimationFrame(() => {
resultConfidenceFill.style.width = `${pct.toFixed(1)}%`;
});
}