| const MODEL_PATH = "https://huggingface.co/fique5/watermeter/resolve/main/best.onnx";; |
| const INPUT_SIZE = 640; |
| const CLASS_NAMES = [ |
| "meter", |
| "window", |
| "0", |
| "1", |
| "2", |
| "3", |
| "4", |
| "5", |
| "6", |
| "7", |
| "8", |
| "9", |
| "u" |
| ]; |
| const SCORE_THRESHOLD = 0.25; |
| const IOU_THRESHOLD = 0.45; |
| const MAX_BOXES = 120; |
|
|
| let session = null; |
| let selectedImage = null; |
|
|
| const canvas = document.getElementById("canvas"); |
| const ctx = canvas.getContext("2d"); |
| const imageInput = document.getElementById("imageInput"); |
| const browseBtn = document.getElementById("browseBtn"); |
| const dropZone = document.getElementById("dropZone"); |
| const predictBtn = document.getElementById("predictBtn"); |
| const modelStatus = document.getElementById("modelStatus"); |
| const meterReading = document.getElementById("meterReading"); |
| const confidence = document.getElementById("confidence"); |
| const detectionsList = document.getElementById("detectionsList"); |
| const imageInfo = document.getElementById("imageInfo"); |
|
|
| async function loadModel() { |
| modelStatus.textContent = "Loading model..."; |
| modelStatus.className = "badge loading"; |
|
|
| try { |
| session = await ort.InferenceSession.create(MODEL_PATH, { |
| executionProviders: ["wasm"] |
| }); |
|
|
| modelStatus.textContent = "Model ready"; |
| modelStatus.className = "badge ready"; |
| } catch (error) { |
| console.error(error); |
| modelStatus.textContent = "Model failed"; |
| modelStatus.className = "badge error"; |
| detectionsList.innerHTML = |
| "<p class=\"small-text\">Unable to load the ONNX model. Make sure best.onnx is present in the repository root.</p>"; |
| } |
| } |
|
|
| function setDropZoneState(active) { |
| if (active) { |
| dropZone.classList.add("drag-over"); |
| } else { |
| dropZone.classList.remove("drag-over"); |
| } |
| } |
|
|
| browseBtn.addEventListener("click", () => imageInput.click()); |
| imageInput.addEventListener("change", (event) => { |
| const file = event.target.files[0]; |
| if (file) { |
| loadImage(file); |
| } |
| }); |
|
|
| dropZone.addEventListener("dragover", (event) => { |
| event.preventDefault(); |
| setDropZoneState(true); |
| }); |
|
|
| dropZone.addEventListener("dragleave", () => setDropZoneState(false)); |
|
|
| dropZone.addEventListener("drop", (event) => { |
| event.preventDefault(); |
| setDropZoneState(false); |
| const file = event.dataTransfer.files[0]; |
| if (file) { |
| loadImage(file); |
| } |
| }); |
|
|
| window.addEventListener("dragover", (event) => { |
| event.preventDefault(); |
| }); |
|
|
| window.addEventListener("drop", (event) => { |
| event.preventDefault(); |
| }); |
|
|
| function loadImage(file) { |
| const img = new Image(); |
| img.onload = () => { |
| selectedImage = img; |
| canvas.width = img.width; |
| canvas.height = img.height; |
| ctx.clearRect(0, 0, canvas.width, canvas.height); |
| ctx.drawImage(img, 0, 0); |
| imageInfo.textContent = `${img.width}px × ${img.height}px`; |
| meterReading.textContent = "--"; |
| confidence.textContent = "--"; |
| detectionsList.innerHTML = |
| "<p class=\"small-text\">Ready to analyze. Press Analyze Image.</p>"; |
| URL.revokeObjectURL(img.src); |
| }; |
| img.src = URL.createObjectURL(file); |
| } |
|
|
| predictBtn.addEventListener("click", runPrediction); |
|
|
| async function runPrediction() { |
| if (!selectedImage) { |
| alert("Please upload an image first."); |
| return; |
| } |
|
|
| if (!session) { |
| alert("Model is not ready yet. Wait until the model finishes loading."); |
| return; |
| } |
|
|
| modelStatus.textContent = "Running inference..."; |
| modelStatus.className = "badge loading"; |
|
|
| try { |
| const prediction = await predictImage(selectedImage); |
| drawDetectionResults(prediction.detections); |
| updatePredictionUI(prediction); |
| modelStatus.textContent = "Ready"; |
| modelStatus.className = "badge ready"; |
| } catch (error) { |
| console.error(error); |
| modelStatus.textContent = "Inference failed"; |
| modelStatus.className = "badge error"; |
| detectionsList.innerHTML = |
| "<p class=\"small-text\">Inference failed. Check your model path and image format.</p>"; |
| } |
| } |
|
|
| async function predictImage(image) { |
| const {tensor, ratio, pad, originalWidth, originalHeight} = prepareInput(image); |
| const inputName = session.inputNames[0]; |
| const feeds = {}; |
| feeds[inputName] = new ort.Tensor("float32", [1, 3, INPUT_SIZE, INPUT_SIZE], tensor); |
|
|
| const results = await session.run(feeds); |
| const outputName = session.outputNames[0]; |
| const rawOutput = results[outputName]; |
| const detections = decodeOutput( |
| rawOutput.data, |
| rawOutput.dims, |
| ratio, |
| pad, |
| originalWidth, |
| originalHeight |
| ); |
|
|
| return { |
| detections, |
| reading: extractMeterReading(detections), |
| averageConfidence: computeAverageConfidence(detections) |
| }; |
| } |
|
|
| function prepareInput(image) { |
| const letterbox = letterboxImage(image, INPUT_SIZE); |
| const imageData = letterbox.imageData; |
| const floatArray = new Float32Array(1 * 3 * INPUT_SIZE * INPUT_SIZE); |
|
|
| for (let y = 0; y < INPUT_SIZE; y++) { |
| for (let x = 0; x < INPUT_SIZE; x++) { |
| const idx = (y * INPUT_SIZE + x) * 4; |
| const r = imageData.data[idx] / 255; |
| const g = imageData.data[idx + 1] / 255; |
| const b = imageData.data[idx + 2] / 255; |
| const pos = y * INPUT_SIZE + x; |
| floatArray[pos] = r; |
| floatArray[INPUT_SIZE * INPUT_SIZE + pos] = g; |
| floatArray[2 * INPUT_SIZE * INPUT_SIZE + pos] = b; |
| } |
| } |
|
|
| return { |
| tensor: floatArray, |
| ratio: letterbox.ratio, |
| pad: letterbox.pad, |
| originalWidth: image.width, |
| originalHeight: image.height |
| }; |
| } |
|
|
| function letterboxImage(image, size) { |
| const offscreen = document.createElement("canvas"); |
| offscreen.width = size; |
| offscreen.height = size; |
| const ctxOff = offscreen.getContext("2d"); |
| ctxOff.fillStyle = "#000"; |
| ctxOff.fillRect(0, 0, size, size); |
|
|
| const ratio = Math.min(size / image.width, size / image.height); |
| const newWidth = Math.round(image.width * ratio); |
| const newHeight = Math.round(image.height * ratio); |
| const padX = Math.round((size - newWidth) / 2); |
| const padY = Math.round((size - newHeight) / 2); |
|
|
| ctxOff.drawImage(image, 0, 0, image.width, image.height, padX, padY, newWidth, newHeight); |
|
|
| return { |
| imageData: ctxOff.getImageData(0, 0, size, size), |
| ratio, |
| pad: { x: padX, y: padY } |
| }; |
| } |
|
|
| function decodeOutput(data, dims, ratio, pad, originalWidth, originalHeight) { |
| const [batch, numBoxes, attributes] = dims; |
| const detections = []; |
|
|
| for (let i = 0; i < numBoxes; i++) { |
| const offset = i * attributes; |
| const x = data[offset]; |
| const y = data[offset + 1]; |
| const w = data[offset + 2]; |
| const h = data[offset + 3]; |
| const objectness = data[offset + 4]; |
|
|
| let bestClass = -1; |
| let bestScore = 0; |
|
|
| for (let c = 0; c < CLASS_NAMES.length; c++) { |
| const classScore = data[offset + 5 + c]; |
| const score = objectness * classScore; |
| if (score > bestScore) { |
| bestScore = score; |
| bestClass = c; |
| } |
| } |
|
|
| if (bestScore < SCORE_THRESHOLD) { |
| continue; |
| } |
|
|
| const x1 = (x - w / 2 - pad.x) / ratio; |
| const y1 = (y - h / 2 - pad.y) / ratio; |
| const x2 = (x + w / 2 - pad.x) / ratio; |
| const y2 = (y + h / 2 - pad.y) / ratio; |
|
|
| detections.push({ |
| classIndex: bestClass, |
| label: CLASS_NAMES[bestClass], |
| score: bestScore, |
| x1: clamp(x1, 0, originalWidth), |
| y1: clamp(y1, 0, originalHeight), |
| x2: clamp(x2, 0, originalWidth), |
| y2: clamp(y2, 0, originalHeight) |
| }); |
| } |
|
|
| return nonMaxSuppression(detections, IOU_THRESHOLD, MAX_BOXES); |
| } |
|
|
| function nonMaxSuppression(detections, iouThreshold, maxBoxes) { |
| const results = []; |
| const sorted = detections.sort((a, b) => b.score - a.score); |
|
|
| while (sorted.length && results.length < maxBoxes) { |
| const current = sorted.shift(); |
| results.push(current); |
|
|
| for (let i = sorted.length - 1; i >= 0; i--) { |
| if (current.classIndex !== sorted[i].classIndex) { |
| continue; |
| } |
| if (intersectionOverUnion(current, sorted[i]) > iouThreshold) { |
| sorted.splice(i, 1); |
| } |
| } |
| } |
|
|
| return results; |
| } |
|
|
| 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 width = Math.max(0, x2 - x1); |
| const height = Math.max(0, y2 - y1); |
| const intersection = width * height; |
| const union = |
| (a.x2 - a.x1) * (a.y2 - a.y1) + |
| (b.x2 - b.x1) * (b.y2 - b.y1) - |
| intersection; |
|
|
| return union === 0 ? 0 : intersection / union; |
| } |
|
|
| function drawDetectionResults(detections) { |
| if (!selectedImage) { |
| return; |
| } |
|
|
| canvas.width = selectedImage.width; |
| canvas.height = selectedImage.height; |
| ctx.clearRect(0, 0, canvas.width, canvas.height); |
| ctx.drawImage(selectedImage, 0, 0); |
|
|
| detections.forEach((detection) => { |
| const width = detection.x2 - detection.x1; |
| const height = detection.y2 - detection.y1; |
| ctx.strokeStyle = detection.classIndex === 0 ? "#00d4ff" : "#ffb703"; |
| ctx.lineWidth = Math.max(2, Math.round(canvas.width / 360)); |
| ctx.strokeRect(detection.x1, detection.y1, width, height); |
|
|
| const label = `${detection.label} ${(detection.score * 100).toFixed(1)}%`; |
| ctx.font = `${Math.max(12, Math.round(canvas.width / 60))}px Inter`; |
| ctx.textBaseline = "top"; |
| ctx.fillStyle = "rgba(0, 0, 0, 0.65)"; |
| const textWidth = ctx.measureText(label).width + 16; |
| const textHeight = parseInt(ctx.font, 10) + 10; |
|
|
| const textX = detection.x1; |
| const textY = Math.max(0, detection.y1 - textHeight - 4); |
|
|
| ctx.fillRect(textX, textY, textWidth, textHeight); |
| ctx.fillStyle = "#ffffff"; |
| ctx.fillText(label, textX + 8, textY + 5); |
| }); |
| } |
|
|
| function extractMeterReading(detections) { |
| const digits = detections.filter( |
| (item) => item.classIndex >= 2 && item.classIndex <= 11 |
| ); |
| const unknown = detections.some((item) => item.classIndex === 12); |
|
|
| if (!digits.length) { |
| if (unknown) { |
| return "Unreadable"; |
| } |
| return "No digits detected"; |
| } |
|
|
| const ordered = digits.sort((a, b) => a.x1 - b.x1); |
| return ordered.map((item) => item.label).join(""); |
| } |
|
|
| function computeAverageConfidence(detections) { |
| const digits = detections.filter( |
| (item) => item.classIndex >= 2 && item.classIndex <= 11 |
| ); |
|
|
| if (!digits.length) { |
| return 0; |
| } |
|
|
| const sum = digits.reduce((acc, item) => acc + item.score, 0); |
| return sum / digits.length; |
| } |
|
|
| function updatePredictionUI(prediction) { |
| const { detections, reading, averageConfidence } = prediction; |
| meterReading.textContent = reading; |
| confidence.textContent = averageConfidence |
| ? `${(averageConfidence * 100).toFixed(1)}%` |
| : "--"; |
|
|
| if (!detections.length) { |
| detectionsList.innerHTML = |
| "<p class=\"small-text\">No objects detected in this image.</p>"; |
| return; |
| } |
|
|
| detectionsList.innerHTML = detections |
| .slice(0, 20) |
| .map( |
| (item) => |
| `<div class="detection-card"><strong>${item.label}</strong><span>Score: ${(item.score * 100).toFixed(1)}%</span></div>` |
| ) |
| .join(""); |
| } |
|
|
| function clamp(value, min, max) { |
| return Math.max(min, Math.min(value, max)); |
| } |
|
|
| loadModel(); |
|
|