const MODEL_SIZE = 960; const MODEL_URL = "models/new_clean_yolo12n_raw_pascal_best.onnx"; const RECOVERY_CONFIDENCE_FLOOR = 0.001; const CLASS_NAMES = ["meter", "window", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]; const COLORS = ["#51a7ff", "#39e6c6", "#ffc66d", "#ffc66d", "#ffc66d", "#ffc66d", "#ffc66d", "#ffc66d", "#ffc66d", "#ffc66d", "#ffc66d", "#ffc66d"]; const HISTORY_KEY = "aquavision-reading-history-v1"; const elements = { fileInput: document.querySelector("#fileInput"), dropZone: document.querySelector("#dropZone"), clearButton: document.querySelector("#clearButton"), runButton: document.querySelector("#runButton"), runLabel: document.querySelector("#runLabel"), modelStatus: document.querySelector("#modelStatus"), headerStatus: document.querySelector("#headerStatus"), headerStatusDot: document.querySelector("#headerStatusDot"), canvasWrap: document.querySelector("#canvasWrap"), canvas: document.querySelector("#resultCanvas"), imageBadge: document.querySelector("#imageBadge"), readingOutput: document.querySelector("#readingOutput"), rawReadingOutput: document.querySelector("#rawReadingOutput"), unknownCount: document.querySelector("#unknownCount"), readingState: document.querySelector("#readingState"), confidenceOutput: document.querySelector("#confidenceOutput"), digitCount: document.querySelector("#digitCount"), timing: document.querySelector("#timing"), detectionCount: document.querySelector("#detectionCount"), averageConfidence: document.querySelector("#averageConfidence"), windowStatus: document.querySelector("#windowStatus"), qualityBar: document.querySelector("#qualityBar"), qualityLabel: document.querySelector("#qualityLabel"), copyButton: document.querySelector("#copyButton"), downloadButton: document.querySelector("#downloadButton"), detectedViewButton: document.querySelector("#detectedViewButton"), originalViewButton: document.querySelector("#originalViewButton"), confidenceSlider: document.querySelector("#confidenceSlider"), confidenceValue: document.querySelector("#confidenceValue"), iouSlider: document.querySelector("#iouSlider"), iouValue: document.querySelector("#iouValue"), unknownSlider: document.querySelector("#unknownSlider"), unknownValue: document.querySelector("#unknownValue"), historyList: document.querySelector("#historyList"), clearHistoryButton: document.querySelector("#clearHistoryButton"), applyRecommendedButton: document.querySelector("#applyRecommendedButton"), tabButtons: [...document.querySelectorAll("[data-tab]")], tabPanels: [...document.querySelectorAll("[data-tab-panel]")], toast: document.querySelector("#toast"), }; const context = elements.canvas.getContext("2d"); let session = null; let selectedImage = null; let selectedObjectUrl = null; let running = false; let lastDetections = []; let lastReading = ""; let activeView = "detected"; let toastTimer = null; function confidenceThreshold() { return Number(elements.confidenceSlider.value) / 100; } function iouThreshold() { return Number(elements.iouSlider.value) / 100; } function unknownThreshold() { return Number(elements.unknownSlider.value) / 100; } function showToast(message) { elements.toast.textContent = message; elements.toast.classList.add("show"); clearTimeout(toastTimer); toastTimer = setTimeout(() => elements.toast.classList.remove("show"), 2200); } function activateTab(tabName, updateUrl = true) { const validTab = elements.tabPanels.some((panel) => panel.dataset.tabPanel === tabName) ? tabName : "reader"; elements.tabButtons.forEach((button) => { const active = button.dataset.tab === validTab; button.classList.toggle("active", active); button.setAttribute("aria-selected", String(active)); }); elements.tabPanels.forEach((panel) => panel.classList.toggle("active", panel.dataset.tabPanel === validTab)); if (updateUrl) history.replaceState(null, "", validTab === "reader" ? location.pathname : `#${validTab}`); window.scrollTo({ top: 0, behavior: "smooth" }); } function applyRecommendedSettings() { elements.confidenceSlider.value = "10"; elements.iouSlider.value = "45"; elements.unknownSlider.value = "40"; updateRange(elements.confidenceSlider, elements.confidenceValue); updateRange(elements.iouSlider, elements.iouValue); updateRange(elements.unknownSlider, elements.unknownValue); if (lastDetections.length) { const { safeReading, rawReading, digits, bestWindow } = reconstructReading(lastDetections); displayReading(safeReading, rawReading, digits, bestWindow); if (activeView === "detected") drawDetections(); } showToast("Recommended settings applied: 10% / 45% / 40%"); } function setSystemStatus(message, state = "ready") { elements.headerStatus.textContent = message; elements.headerStatusDot.className = state; const className = state === "loading" ? "loading" : state === "error" ? "error" : ""; elements.modelStatus.innerHTML = `${message}`; } function updateRunButton() { elements.runButton.disabled = !session || !selectedImage || running; if (running) elements.runLabel.textContent = "Analyzing meter"; else if (!session) elements.runLabel.textContent = "Preparing AI model"; else elements.runLabel.textContent = "Analyze meter"; } async function loadModel() { try { setSystemStatus("Loading AquaVision YOLO12n", "loading"); ort.env.wasm.wasmPaths = "https://cdn.jsdelivr.net/npm/onnxruntime-web@1.23.2/dist/"; ort.env.wasm.numThreads = 1; session = await ort.InferenceSession.create(MODEL_URL, { executionProviders: ["wasm"], graphOptimizationLevel: "all", }); setSystemStatus("AquaVision YOLO12n ready"); } catch (error) { console.error(error); setSystemStatus("Model failed to load", "error"); showToast("Could not load the AI model. Refresh the page."); } finally { updateRunButton(); } } function resetResults() { lastDetections = []; lastReading = ""; elements.readingOutput.textContent = "------"; elements.rawReadingOutput.textContent = "------"; elements.unknownCount.textContent = "0 unknown"; elements.unknownCount.className = ""; elements.readingState.textContent = selectedImage ? "READY" : "WAITING"; elements.readingState.className = ""; elements.confidenceOutput.innerHTML = '

Run an analysis to inspect every detected digit.

'; elements.digitCount.textContent = "0 DIGITS"; elements.timing.textContent = "--"; elements.detectionCount.textContent = "--"; elements.averageConfidence.textContent = "--"; elements.windowStatus.textContent = "--"; elements.qualityBar.style.width = "0"; elements.qualityLabel.textContent = "Not analyzed"; elements.copyButton.disabled = true; elements.downloadButton.disabled = true; setActiveView("original"); } function loadFile(file) { if (!file || !file.type.startsWith("image/")) { showToast("Please select a JPG, PNG, or WebP image."); return; } if (file.size > 20 * 1024 * 1024) { showToast("The selected image is larger than 20 MB."); return; } if (selectedObjectUrl) URL.revokeObjectURL(selectedObjectUrl); selectedObjectUrl = URL.createObjectURL(file); const image = new Image(); image.onload = () => { selectedImage = image; elements.canvas.width = image.naturalWidth; elements.canvas.height = image.naturalHeight; context.drawImage(image, 0, 0); elements.canvasWrap.classList.remove("empty"); elements.clearButton.disabled = false; resetResults(); updateRunButton(); showToast("Image ready for analysis"); }; image.onerror = () => showToast("The selected image could not be opened."); image.src = selectedObjectUrl; } function clearImage() { selectedImage = null; elements.fileInput.value = ""; if (selectedObjectUrl) URL.revokeObjectURL(selectedObjectUrl); selectedObjectUrl = null; elements.canvas.width = 0; elements.canvas.height = 0; elements.canvasWrap.classList.add("empty"); elements.clearButton.disabled = true; resetResults(); updateRunButton(); } function prepareInput(image) { const modelSize = MODEL_SIZE; const workCanvas = document.createElement("canvas"); workCanvas.width = modelSize; workCanvas.height = modelSize; const workContext = workCanvas.getContext("2d", { willReadFrequently: true }); const scale = Math.min(modelSize / image.naturalWidth, modelSize / image.naturalHeight); const width = Math.round(image.naturalWidth * scale); const height = Math.round(image.naturalHeight * scale); const padX = Math.floor((modelSize - width) / 2); const padY = Math.floor((modelSize - height) / 2); workContext.fillStyle = "rgb(114, 114, 114)"; workContext.fillRect(0, 0, modelSize, modelSize); workContext.drawImage(image, padX, padY, width, height); const pixels = workContext.getImageData(0, 0, modelSize, modelSize).data; const planeSize = modelSize * modelSize; const input = new Float32Array(3 * planeSize); for (let pixel = 0, offset = 0; pixel < planeSize; pixel += 1, offset += 4) { input[pixel] = pixels[offset] / 255; input[planeSize + pixel] = pixels[offset + 1] / 255; input[2 * planeSize + pixel] = pixels[offset + 2] / 255; } return { tensor: new ort.Tensor("float32", input, [1, 3, modelSize, modelSize]), scale, padX, padY, }; } function outputValue(output, channel, prediction, channels, count) { if (output.dims[1] === channels) return output.data[channel * count + prediction]; return output.data[prediction * channels + channel]; } function decodeOutput(output, transform, image) { const channelsFirst = output.dims[1] < output.dims[2]; const channels = channelsFirst ? output.dims[1] : output.dims[2]; const count = channelsFirst ? output.dims[2] : output.dims[1]; const candidates = []; for (let prediction = 0; prediction < count; prediction += 1) { let classId = 0; let confidence = -Infinity; for (let channel = 4; channel < channels; channel += 1) { const score = outputValue(output, channel, prediction, channels, count); if (score > confidence) { confidence = score; classId = channel - 4; } } if (confidence < RECOVERY_CONFIDENCE_FLOOR || classId >= CLASS_NAMES.length) continue; const cx = outputValue(output, 0, prediction, channels, count); const cy = outputValue(output, 1, prediction, channels, count); const width = outputValue(output, 2, prediction, channels, count); const height = outputValue(output, 3, prediction, channels, count); const x1 = Math.max(0, (cx - width / 2 - transform.padX) / transform.scale); const y1 = Math.max(0, (cy - height / 2 - transform.padY) / transform.scale); const x2 = Math.min(image.naturalWidth, (cx + width / 2 - transform.padX) / transform.scale); const y2 = Math.min(image.naturalHeight, (cy + height / 2 - transform.padY) / transform.scale); if (x2 <= x1 || y2 <= y1) continue; candidates.push({ classId, confidence, x1, y1, x2, y2 }); } candidates.sort((a, b) => b.confidence - a.confidence); const allDetections = nonMaxSuppression(candidates.slice(0, 1000)); const regularDetections = allDetections.filter((item) => item.confidence >= confidenceThreshold()); return recoverTrailingDigit(allDetections, regularDetections); } function intersectionOverUnion(a, b) { const x1 = Math.max(a.x1, b.x1); const y1 = Math.max(a.y1, b.y1); const x2 = Math.min(a.x2, b.x2); const y2 = Math.min(a.y2, b.y2); const intersection = Math.max(0, x2 - x1) * Math.max(0, y2 - y1); const areaA = (a.x2 - a.x1) * (a.y2 - a.y1); const areaB = (b.x2 - b.x1) * (b.y2 - b.y1); return intersection / (areaA + areaB - intersection + 1e-7); } function nonMaxSuppression(candidates) { const kept = []; for (const candidate of candidates) { const suppressed = kept.some( (existing) => existing.classId === candidate.classId && intersectionOverUnion(existing, candidate) > iouThreshold(), ); if (!suppressed) kept.push(candidate); if (kept.length >= 300) break; } return kept; } function centerInside(box, container) { const centerX = (box.x1 + box.x2) / 2; const centerY = (box.y1 + box.y2) / 2; return centerX >= container.x1 && centerX <= container.x2 && centerY >= container.y1 && centerY <= container.y2; } function median(values) { if (!values.length) return 0; const sorted = [...values].sort((a, b) => a - b); const middle = Math.floor(sorted.length / 2); return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2; } function recoverTrailingDigit(allDetections, regularDetections) { const bestWindow = regularDetections .filter((item) => item.classId === 1) .sort((a, b) => b.confidence - a.confidence)[0]; if (!bestWindow) return regularDetections; const trustedDigits = regularDetections .filter((item) => item.classId >= 2 && item.classId <= 11 && centerInside(item, bestWindow)) .sort((a, b) => (a.x1 + a.x2) / 2 - (b.x1 + b.x2) / 2); if (trustedDigits.length < 4) return regularDetections; const centers = trustedDigits.map((item) => (item.x1 + item.x2) / 2); const pitch = median(centers.slice(1).map((center, index) => center - centers[index])); if (pitch <= 0) return regularDetections; const lastCenter = centers.at(-1); const expectedCenter = lastCenter + pitch; const medianY = median(trustedDigits.map((item) => (item.y1 + item.y2) / 2)); const medianHeight = median(trustedDigits.map((item) => item.y2 - item.y1)); const candidates = allDetections .filter((item) => ( item.classId >= 2 && item.classId <= 11 && item.confidence < confidenceThreshold() && centerInside(item, bestWindow) )) .map((item) => { const centerX = (item.x1 + item.x2) / 2; const centerY = (item.y1 + item.y2) / 2; return { item, centerX, positionError: Math.abs(centerX - expectedCenter), verticalError: Math.abs(centerY - medianY), }; }) .filter(({ centerX, positionError, verticalError }) => ( centerX > lastCenter + pitch * 0.45 && centerX < lastCenter + pitch * 1.6 && positionError <= pitch * 0.55 && verticalError <= Math.max(4, medianHeight * 0.7) )) .sort((a, b) => ( (a.positionError / pitch) - (b.positionError / pitch) || b.item.confidence - a.item.confidence )); if (!candidates.length) return regularDetections; return [...regularDetections, { ...candidates[0].item, recovered: true }]; } function reconstructReading(detections) { const windows = detections.filter((item) => item.classId === 1); let digits = detections.filter((item) => item.classId >= 2 && item.classId <= 11); const bestWindow = windows.sort((a, b) => b.confidence - a.confidence)[0]; if (bestWindow) digits = digits.filter((digit) => centerInside(digit, bestWindow)); digits.sort((a, b) => (a.x1 + a.x2) / 2 - (b.x1 + b.x2) / 2); const rawReading = digits.map((digit) => String(digit.classId - 2)).join(""); const safeReading = digits.map((digit) => ( digit.confidence < unknownThreshold() ? "?" : String(digit.classId - 2) )).join(""); return { rawReading, safeReading, digits, bestWindow, }; } function drawOriginal() { if (!selectedImage) return; elements.canvas.width = selectedImage.naturalWidth; elements.canvas.height = selectedImage.naturalHeight; context.drawImage(selectedImage, 0, 0); } function drawDetections() { if (!selectedImage) return; drawOriginal(); const lineWidth = Math.max(2, Math.round(Math.min(elements.canvas.width, elements.canvas.height) / 320)); const fontSize = Math.max(13, Math.round(Math.min(elements.canvas.width, elements.canvas.height) / 42)); context.lineWidth = lineWidth; context.font = `700 ${fontSize}px ui-monospace, monospace`; context.textBaseline = "top"; for (const detection of lastDetections) { const isUnknownDigit = detection.classId >= 2 && detection.classId <= 11 && detection.confidence < unknownThreshold(); const color = isUnknownDigit ? "#ff7083" : COLORS[detection.classId]; const classLabel = isUnknownDigit ? `? raw:${CLASS_NAMES[detection.classId]}` : CLASS_NAMES[detection.classId]; const label = `${classLabel} ${(detection.confidence * 100).toFixed(0)}%`; context.strokeStyle = color; context.strokeRect(detection.x1, detection.y1, detection.x2 - detection.x1, detection.y2 - detection.y1); const textWidth = context.measureText(label).width; const labelY = Math.max(0, detection.y1 - fontSize - 8); context.fillStyle = color; context.fillRect(detection.x1, labelY, textWidth + 10, fontSize + 8); context.fillStyle = "#041018"; context.fillText(label, detection.x1 + 5, labelY + 4); } } function setActiveView(view) { activeView = view; elements.detectedViewButton.classList.toggle("active", view === "detected"); elements.originalViewButton.classList.toggle("active", view === "original"); elements.imageBadge.textContent = view === "detected" ? "AI DETECTION OVERLAY" : "ORIGINAL PREVIEW"; if (!selectedImage) return; if (view === "detected" && lastDetections.length) drawDetections(); else drawOriginal(); } function displayReading(safeReading, rawReading, digits, bestWindow) { lastReading = safeReading; const average = digits.length ? digits.reduce((sum, digit) => sum + digit.confidence, 0) / digits.length : 0; const percent = Math.round(average * 100); const unknownDigits = digits.filter((digit) => digit.confidence < unknownThreshold()); elements.digitCount.textContent = `${digits.length} ${digits.length === 1 ? "DIGIT" : "DIGITS"}`; elements.rawReadingOutput.textContent = rawReading || "------"; elements.unknownCount.textContent = `${unknownDigits.length} unknown`; elements.unknownCount.className = unknownDigits.length ? "has-unknown" : ""; elements.averageConfidence.textContent = digits.length ? `${percent}%` : "--"; elements.windowStatus.textContent = bestWindow ? `${Math.round(bestWindow.confidence * 100)}% FOUND` : "NOT FOUND"; elements.qualityBar.style.width = `${percent}%`; elements.qualityLabel.textContent = percent >= 85 ? "Excellent" : percent >= 65 ? "Good" : percent ? "Review" : "Not detected"; if (safeReading) { elements.readingOutput.textContent = safeReading; elements.readingState.textContent = unknownDigits.length ? "NEEDS REVIEW" : "DETECTED"; elements.readingState.className = unknownDigits.length ? "warning" : "success"; elements.copyButton.disabled = false; elements.confidenceOutput.innerHTML = digits.map((digit) => { const isUnknown = digit.confidence < unknownThreshold(); const shownDigit = isUnknown ? "?" : digit.classId - 2; const rawNote = isUnknown ? `RAW ${digit.classId - 2}` : "CONFIDENCE"; return `
${shownDigit}${rawNote}${(digit.confidence * 100).toFixed(1)}%
`; }).join(""); } else { elements.readingOutput.textContent = "NOT FOUND"; elements.readingState.textContent = "RETRY IMAGE"; elements.readingState.className = "warning"; elements.copyButton.disabled = true; elements.confidenceOutput.innerHTML = '

No complete reading detected. Try a clearer, straighter image.

'; } } function historyItems() { try { return JSON.parse(localStorage.getItem(HISTORY_KEY) || "[]"); } catch { return []; } } function saveHistory(reading, confidence, duration) { if (!reading) return; const items = historyItems(); items.unshift({ reading, confidence, duration, timestamp: Date.now() }); localStorage.setItem(HISTORY_KEY, JSON.stringify(items.slice(0, 8))); renderHistory(); } function renderHistory() { const items = historyItems(); elements.clearHistoryButton.disabled = items.length === 0; if (!items.length) { elements.historyList.innerHTML = '
Completed readings will be saved here on this device only.
'; return; } elements.historyList.innerHTML = items.slice(0, 4).map((item) => { const date = new Date(item.timestamp); const time = date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); return `
${item.reading}${date.toLocaleDateString()} ยท ${time}
${item.confidence}%
`; }).join(""); } async function runInference() { if (!session || !selectedImage || running) return; running = true; updateRunButton(); setSystemStatus("AI analysis running", "loading"); elements.canvasWrap.classList.add("scanning"); const startedAt = performance.now(); try { const transform = prepareInput(selectedImage); const feeds = { [session.inputNames[0]]: transform.tensor }; const outputs = await session.run(feeds); const output = outputs[session.outputNames[0]]; lastDetections = decodeOutput(output, transform, selectedImage); const { safeReading, rawReading, digits, bestWindow } = reconstructReading(lastDetections); const duration = (performance.now() - startedAt) / 1000; const average = digits.length ? Math.round(digits.reduce((sum, digit) => sum + digit.confidence, 0) / digits.length * 100) : 0; setActiveView("detected"); displayReading(safeReading, rawReading, digits, bestWindow); elements.timing.textContent = `${duration.toFixed(1)} s`; elements.detectionCount.textContent = String(lastDetections.length); elements.downloadButton.disabled = false; saveHistory(safeReading, average, duration.toFixed(1)); setSystemStatus("Analysis complete"); const hasUnknown = safeReading.includes("?"); showToast(safeReading ? (hasUnknown ? `Reading needs review: ${safeReading}` : `Meter reading detected: ${safeReading}`) : "No complete reading found"); } catch (error) { console.error(error); setSystemStatus("Analysis failed", "error"); elements.readingOutput.textContent = "ERROR"; elements.readingState.textContent = "FAILED"; elements.readingState.className = "warning"; showToast("Inference failed on this device. Please refresh and retry."); } finally { running = false; elements.canvasWrap.classList.remove("scanning"); updateRunButton(); } } async function copyReading() { if (!lastReading) return; try { await navigator.clipboard.writeText(lastReading); showToast("Reading copied to clipboard"); } catch { const textArea = document.createElement("textarea"); textArea.value = lastReading; document.body.appendChild(textArea); textArea.select(); document.execCommand("copy"); textArea.remove(); showToast("Reading copied to clipboard"); } } function downloadResult() { if (!selectedImage || !lastDetections.length) return; const previousView = activeView; drawDetections(); const link = document.createElement("a"); link.download = `water-meter-${lastReading || "detection"}.png`; link.href = elements.canvas.toDataURL("image/png"); link.click(); setActiveView(previousView); showToast("Annotated result exported"); } function updateRange(slider, output) { const minimum = Number(slider.min); const maximum = Number(slider.max); const value = Number(slider.value); slider.style.setProperty("--range-progress", `${((value - minimum) / (maximum - minimum)) * 100}%`); output.textContent = `${value}%`; } elements.fileInput.addEventListener("change", () => loadFile(elements.fileInput.files[0])); elements.clearButton.addEventListener("click", clearImage); elements.runButton.addEventListener("click", runInference); elements.copyButton.addEventListener("click", copyReading); elements.downloadButton.addEventListener("click", downloadResult); elements.detectedViewButton.addEventListener("click", () => setActiveView("detected")); elements.originalViewButton.addEventListener("click", () => setActiveView("original")); elements.confidenceSlider.addEventListener("input", () => updateRange(elements.confidenceSlider, elements.confidenceValue)); elements.iouSlider.addEventListener("input", () => updateRange(elements.iouSlider, elements.iouValue)); elements.unknownSlider.addEventListener("input", () => { updateRange(elements.unknownSlider, elements.unknownValue); if (lastDetections.length) { const { safeReading, rawReading, digits, bestWindow } = reconstructReading(lastDetections); displayReading(safeReading, rawReading, digits, bestWindow); if (activeView === "detected") drawDetections(); } }); elements.tabButtons.forEach((button) => button.addEventListener("click", () => activateTab(button.dataset.tab))); elements.applyRecommendedButton.addEventListener("click", applyRecommendedSettings); elements.clearHistoryButton.addEventListener("click", () => { localStorage.removeItem(HISTORY_KEY); renderHistory(); showToast("Local reading history cleared"); }); for (const eventName of ["dragenter", "dragover"]) { elements.dropZone.addEventListener(eventName, (event) => { event.preventDefault(); elements.dropZone.classList.add("dragging"); }); } for (const eventName of ["dragleave", "drop"]) { elements.dropZone.addEventListener(eventName, (event) => { event.preventDefault(); elements.dropZone.classList.remove("dragging"); }); } elements.dropZone.addEventListener("drop", (event) => loadFile(event.dataTransfer.files[0])); updateRange(elements.confidenceSlider, elements.confidenceValue); updateRange(elements.iouSlider, elements.iouValue); updateRange(elements.unknownSlider, elements.unknownValue); activateTab(location.hash.replace("#", "") || "reader", false); renderHistory(); loadModel();