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)}%`; }); }