kagabo's picture
Remove second model and emphasize visible settings
f8669ab verified
Raw
History Blame Contribute Delete
25.8 kB
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();