watermeter / script.js
snakail's picture
Fix YOLOv8 output attribute count and decoding
a4799f0
Raw
History Blame Contribute Delete
12.4 kB
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.16;
const IOU_THRESHOLD = 0.45;
const MAX_BOXES = 200;
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("Model load 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 public and accessible.</p>";
}
}
function setDropZoneState(active) {
dropZone.classList.toggle("drag-over", active);
}
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);
if (!prediction.detections.length) {
detectionsList.innerHTML =
"<p class=\"small-text\">No detections found. Try a different photo or use a clearer meter image.</p>";
}
modelStatus.textContent = "Ready";
modelStatus.className = "badge ready";
} catch (error) {
console.error("Inference error:", error);
modelStatus.textContent = "Inference failed";
modelStatus.className = "badge error";
detectionsList.innerHTML =
`<p class="small-text">Inference failed: ${error.message}</p>`;
}
}
async function predictImage(image) {
const {tensor, ratio, pad, originalWidth, originalHeight} = prepareInput(image);
const inputName = session.inputNames[0];
const feeds = {
[inputName]: new ort.Tensor("float32", tensor, [1, 3, INPUT_SIZE, INPUT_SIZE])
};
const results = await session.run(feeds);
const outputName = session.outputNames[0];
const rawOutput = results[outputName];
if (!rawOutput) {
throw new Error("Model did not return an output tensor.");
}
console.log("Model output names:", session.outputNames);
console.log("Raw model dims:", rawOutput.dims);
const normalized = normalizeOutput(rawOutput);
const detections = decodeOutput(normalized, ratio, pad, originalWidth, originalHeight);
return {
detections,
reading: extractMeterReading(detections),
averageConfidence: computeAverageConfidence(detections)
};
}
function normalizeOutput(output) {
const expectedAttrs = 4 + CLASS_NAMES.length;
let dims = Array.from(output.dims);
while (dims.length > 3 && dims.some((d) => d === 1)) {
const idx = dims.findIndex((d) => d === 1);
dims.splice(idx, 1);
}
if (dims.length === 2) {
dims = [1, dims[0], dims[1]];
}
if (dims.length !== 3) {
throw new Error(`Unsupported output tensor shape: ${output.dims.join("x")}`);
}
if (dims[0] === 1 && dims[1] === expectedAttrs) {
return {data: output.data, dims, layout: "chw", boxes: dims[2]};
}
if (dims[0] === 1 && dims[2] === expectedAttrs) {
return {data: output.data, dims: [dims[0], dims[2], dims[1]], layout: "hwc", boxes: dims[1]};
}
throw new Error(`Unsupported ONNX output layout. Expected attrs=${expectedAttrs}, got: ${dims.join("x")}`);
}
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;
floatArray[y * INPUT_SIZE + x] = imageData.data[idx] / 255;
floatArray[INPUT_SIZE * INPUT_SIZE + y * INPUT_SIZE + x] = imageData.data[idx + 1] / 255;
floatArray[2 * INPUT_SIZE * INPUT_SIZE + y * INPUT_SIZE + x] = imageData.data[idx + 2] / 255;
}
}
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(normalized, ratio, pad, originalWidth, originalHeight) {
const {data, dims, layout, boxes} = normalized;
const attributes = dims[1];
const classCount = attributes - 4;
const detections = [];
for (let i = 0; i < boxes; i++) {
const x = layout === "chw" ? data[0 * boxes + i] : data[i * attributes + 0];
const y = layout === "chw" ? data[1 * boxes + i] : data[i * attributes + 1];
const w = layout === "chw" ? data[2 * boxes + i] : data[i * attributes + 2];
const h = layout === "chw" ? data[3 * boxes + i] : data[i * attributes + 3];
let bestClass = -1;
let bestScore = 0;
for (let c = 0; c < classCount; c++) {
const classScore = layout === "chw" ? data[(4 + c) * boxes + i] : data[i * attributes + 4 + c];
if (classScore > bestScore) {
bestScore = classScore;
bestClass = c;
}
}
if (bestScore < SCORE_THRESHOLD || bestClass < 0) {
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;
const label = bestClass < CLASS_NAMES.length ? CLASS_NAMES[bestClass] : `class_${bestClass}`;
detections.push({
classIndex: bestClass,
label,
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();