Spaces:
Running
Running
| 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 = `<i class="${className}"></i>${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 = '<p class="empty-copy">Run an analysis to inspect every detected digit.</p>'; | |
| 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 `<div class="digit-chip ${isUnknown ? "unknown" : ""}"><b>${shownDigit}</b><span>${rawNote}<strong>${(digit.confidence * 100).toFixed(1)}%</strong></span></div>`; | |
| }).join(""); | |
| } else { | |
| elements.readingOutput.textContent = "NOT FOUND"; | |
| elements.readingState.textContent = "RETRY IMAGE"; | |
| elements.readingState.className = "warning"; | |
| elements.copyButton.disabled = true; | |
| elements.confidenceOutput.innerHTML = '<p class="empty-copy">No complete reading detected. Try a clearer, straighter image.</p>'; | |
| } | |
| } | |
| 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 = '<div class="history-empty">Completed readings will be saved here on this device only.</div>'; | |
| 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 `<article class="history-card"><div><strong>${item.reading}</strong><small>${date.toLocaleDateString()} · ${time}</small></div><span>${item.confidence}%</span></article>`; | |
| }).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(); | |