| 'use strict'; |
|
|
| const APP = Object.freeze({ |
| modelUrl: './ms_se_efficientnet_b0.onnx?v=ac619ed4', |
| ortVersion: '1.27.0', |
| imageSize: 224, |
| mean: [0.485, 0.456, 0.406], |
| std: [0.229, 0.224, 0.225], |
| labels: [ |
| 'battery', 'biological', 'cardboard', 'clothes', 'glass', |
| 'metal', 'paper', 'plastic', 'shoes', 'trash', |
| ], |
| metadata: { |
| input: { width: 224, height: 224, channels: 3 }, |
| complexity: { parameters: 4234159 }, |
| }, |
| }); |
|
|
| const state = { |
| session: null, |
| labels: APP.labels, |
| metadata: APP.metadata, |
| bitmap: null, |
| objectUrl: null, |
| file: null, |
| modelReady: false, |
| predicting: false, |
| }; |
|
|
| const els = { |
| runtimePill: document.querySelector('#runtimePill'), |
| runtimeText: document.querySelector('#runtimeText'), |
| statusDot: document.querySelector('#statusDot'), |
| statusCard: document.querySelector('.status-card'), |
| progressBar: document.querySelector('#progressBar'), |
| loadPercent: document.querySelector('#loadPercent'), |
| statusDetail: document.querySelector('#statusDetail'), |
| fileInput: document.querySelector('#fileInput'), |
| dropZone: document.querySelector('#dropZone'), |
| clearButton: document.querySelector('#clearButton'), |
| previewWrap: document.querySelector('#previewWrap'), |
| imagePreview: document.querySelector('#imagePreview'), |
| fileName: document.querySelector('#fileName'), |
| fileDetails: document.querySelector('#fileDetails'), |
| predictButton: document.querySelector('#predictButton'), |
| predictButtonText: document.querySelector('#predictButtonText'), |
| buttonSpinner: document.querySelector('#buttonSpinner'), |
| inputMessage: document.querySelector('#inputMessage'), |
| emptyResult: document.querySelector('#emptyResult'), |
| results: document.querySelector('#results'), |
| predictedClass: document.querySelector('#predictedClass'), |
| confidenceRing: document.querySelector('#confidenceRing'), |
| confidenceValue: document.querySelector('#confidenceValue'), |
| topThree: document.querySelector('#topThree'), |
| probabilityList: document.querySelector('#probabilityList'), |
| preprocessTime: document.querySelector('#preprocessTime'), |
| inferenceTime: document.querySelector('#inferenceTime'), |
| totalTime: document.querySelector('#totalTime'), |
| providerBadge: document.querySelector('#providerBadge'), |
| classChips: document.querySelector('#classChips'), |
| modelFacts: document.querySelector('#modelFacts'), |
| canvas: document.querySelector('#preprocessCanvas'), |
| }; |
|
|
| function setProgress(percent, detail) { |
| const safePercent = Math.max(0, Math.min(100, Math.round(percent))); |
| els.progressBar.style.width = `${safePercent}%`; |
| els.loadPercent.textContent = `${safePercent}%`; |
| if (detail) els.statusDetail.textContent = detail; |
| } |
|
|
| function setRuntimeState(kind, text) { |
| els.runtimePill.classList.remove('ready', 'error'); |
| if (kind) els.runtimePill.classList.add(kind); |
| els.runtimeText.textContent = text; |
| } |
|
|
| function setError(message, error) { |
| console.error(message, error || ''); |
| state.modelReady = false; |
| els.statusCard.classList.add('error'); |
| setRuntimeState('error', 'Runtime unavailable'); |
| setProgress(100, message); |
| els.inputMessage.textContent = 'The model could not be initialized. Refresh the page or try another modern browser.'; |
| els.inputMessage.classList.add('error'); |
| updatePredictButton(); |
| } |
|
|
| function titleCase(value) { |
| return String(value) |
| .replace(/[_-]+/g, ' ') |
| .replace(/\b\w/g, character => character.toUpperCase()); |
| } |
|
|
| function formatBytes(bytes) { |
| if (!Number.isFinite(bytes) || bytes <= 0) return ''; |
| const units = ['B', 'KB', 'MB', 'GB']; |
| const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); |
| return `${(bytes / (1024 ** index)).toFixed(index >= 2 ? 1 : 0)} ${units[index]}`; |
| } |
|
|
| function formatMilliseconds(value) { |
| if (!Number.isFinite(value)) return '—'; |
| if (value < 10) return `${value.toFixed(2)} ms`; |
| if (value < 100) return `${value.toFixed(1)} ms`; |
| return `${Math.round(value)} ms`; |
| } |
|
|
| function updatePredictButton() { |
| const enabled = state.modelReady && Boolean(state.bitmap) && !state.predicting; |
| els.predictButton.disabled = !enabled; |
| els.clearButton.disabled = !state.bitmap && !state.file; |
| } |
|
|
| async function fetchBinaryWithProgress(url, onProgress) { |
| const response = await fetch(url, { cache: 'force-cache' }); |
| if (!response.ok) throw new Error(`Model download failed: HTTP ${response.status}`); |
|
|
| const total = Number(response.headers.get('content-length')) || 0; |
| if (!response.body || !total) { |
| const buffer = await response.arrayBuffer(); |
| onProgress(1, buffer.byteLength); |
| return new Uint8Array(buffer); |
| } |
|
|
| const reader = response.body.getReader(); |
| const chunks = []; |
| let received = 0; |
|
|
| while (true) { |
| const { done, value } = await reader.read(); |
| if (done) break; |
| chunks.push(value); |
| received += value.length; |
| onProgress(received / total, total); |
| } |
|
|
| const bytes = new Uint8Array(received); |
| let offset = 0; |
| for (const chunk of chunks) { |
| bytes.set(chunk, offset); |
| offset += chunk.length; |
| } |
| return bytes; |
| } |
|
|
| function renderClassChips() { |
| els.classChips.replaceChildren(); |
| for (const label of state.labels) { |
| const chip = document.createElement('span'); |
| chip.className = 'class-chip'; |
| chip.textContent = label; |
| els.classChips.appendChild(chip); |
| } |
| } |
|
|
| function renderModelFacts() { |
| if (!state.metadata) return; |
| const facts = [ |
| ['Input', `${state.metadata.input.width} × ${state.metadata.input.height} RGB`], |
| ['Classes', String(state.labels.length)], |
| ['Parameters', `${(state.metadata.complexity.parameters / 1e6).toFixed(3)}M`], |
| ['Runtime', 'WebAssembly'], |
| ]; |
| els.modelFacts.replaceChildren(); |
| for (const [term, value] of facts) { |
| const wrapper = document.createElement('div'); |
| const dt = document.createElement('dt'); |
| const dd = document.createElement('dd'); |
| dt.textContent = term; |
| dd.textContent = value; |
| wrapper.append(dt, dd); |
| els.modelFacts.appendChild(wrapper); |
| } |
| } |
|
|
| async function initializeModel() { |
| try { |
| if (!window.ort) throw new Error('ONNX Runtime Web did not load from the pinned CDN.'); |
|
|
| setRuntimeState('', 'Preparing model'); |
| setProgress(3, 'Preparing class labels and model information…'); |
| renderClassChips(); |
| renderModelFacts(); |
|
|
| ort.env.logLevel = 'error'; |
| ort.env.wasm.numThreads = 1; |
| ort.env.wasm.proxy = false; |
| ort.env.wasm.wasmPaths = `https://cdn.jsdelivr.net/npm/onnxruntime-web@${APP.ortVersion}/dist/`; |
|
|
| setRuntimeState('', 'Downloading model'); |
| const modelBytes = await fetchBinaryWithProgress(APP.modelUrl, (fraction, totalBytes) => { |
| const percent = 5 + (fraction * 66); |
| setProgress(percent, `Downloading ${formatBytes(totalBytes) || 'ONNX model'}…`); |
| }); |
|
|
| setProgress(75, 'Creating optimized WebAssembly inference session…'); |
| setRuntimeState('', 'Creating session'); |
| state.session = await ort.InferenceSession.create(modelBytes, { |
| executionProviders: ['wasm'], |
| graphOptimizationLevel: 'all', |
| }); |
|
|
| const inputNames = Array.isArray(state.session.inputNames) ? state.session.inputNames : []; |
| const outputNames = Array.isArray(state.session.outputNames) ? state.session.outputNames : []; |
| if (!inputNames.includes('input') || !outputNames.includes('logits')) { |
| throw new Error(`Unexpected ONNX interface: inputs ${inputNames.join(', ') || 'none'}, outputs ${outputNames.join(', ') || 'none'}`); |
| } |
|
|
| |
| |
| setProgress(92, 'Verifying model execution and output shape…'); |
| const testData = new Float32Array(3 * APP.imageSize * APP.imageSize); |
| const testTensor = new ort.Tensor('float32', testData, [1, 3, APP.imageSize, APP.imageSize]); |
| const testOutputs = await state.session.run({ input: testTensor }); |
| const testLogits = testOutputs.logits && testOutputs.logits.data; |
| if (!testLogits || testLogits.length !== state.labels.length) { |
| const actualLength = testLogits ? testLogits.length : 0; |
| throw new Error(`ONNX smoke test failed: expected ${state.labels.length} logits, received ${actualLength}.`); |
| } |
| if (!Array.from(testLogits).every(Number.isFinite)) { |
| throw new Error('ONNX smoke test failed: output contains non-finite values.'); |
| } |
|
|
| state.modelReady = true; |
| els.statusCard.classList.remove('error'); |
| setProgress(100, 'Ready. The ONNX model has loaded successfully.'); |
| setRuntimeState('ready', 'Model ready'); |
| els.providerBadge.textContent = 'WASM'; |
| updatePredictButton(); |
| } catch (error) { |
| setError(error.message || 'Model initialization failed.', error); |
| } |
| } |
|
|
| async function decodeImage(file) { |
| if ('createImageBitmap' in window) { |
| try { |
| return await createImageBitmap(file, { imageOrientation: 'from-image' }); |
| } catch (_) { |
| return createImageBitmap(file); |
| } |
| } |
|
|
| return new Promise((resolve, reject) => { |
| const url = URL.createObjectURL(file); |
| const image = new Image(); |
| image.onload = () => { |
| URL.revokeObjectURL(url); |
| resolve(image); |
| }; |
| image.onerror = () => { |
| URL.revokeObjectURL(url); |
| reject(new Error('The selected file could not be decoded as an image.')); |
| }; |
| image.src = url; |
| }); |
| } |
|
|
| function resetResults() { |
| els.results.classList.add('hidden'); |
| els.emptyResult.classList.remove('hidden'); |
| els.topThree.replaceChildren(); |
| els.probabilityList.replaceChildren(); |
| } |
|
|
| function clearImage() { |
| if (state.bitmap && typeof state.bitmap.close === 'function') state.bitmap.close(); |
| if (state.objectUrl) URL.revokeObjectURL(state.objectUrl); |
| state.bitmap = null; |
| state.objectUrl = null; |
| state.file = null; |
| els.fileInput.value = ''; |
| els.imagePreview.removeAttribute('src'); |
| els.previewWrap.classList.add('hidden'); |
| els.dropZone.classList.remove('hidden'); |
| els.fileName.textContent = ''; |
| els.fileDetails.textContent = ''; |
| els.inputMessage.textContent = ''; |
| els.inputMessage.classList.remove('error'); |
| resetResults(); |
| updatePredictButton(); |
| } |
|
|
| async function handleFile(file) { |
| if (!file) return; |
| if (!file.type.startsWith('image/')) { |
| els.inputMessage.textContent = 'Select a valid image file.'; |
| els.inputMessage.classList.add('error'); |
| return; |
| } |
|
|
| try { |
| els.inputMessage.textContent = 'Decoding image…'; |
| els.inputMessage.classList.remove('error'); |
|
|
| if (state.bitmap && typeof state.bitmap.close === 'function') state.bitmap.close(); |
| if (state.objectUrl) URL.revokeObjectURL(state.objectUrl); |
|
|
| state.bitmap = await decodeImage(file); |
| state.file = file; |
| state.objectUrl = URL.createObjectURL(file); |
|
|
| els.imagePreview.src = state.objectUrl; |
| els.fileName.textContent = file.name; |
| els.fileDetails.textContent = `${state.bitmap.width} × ${state.bitmap.height} · ${formatBytes(file.size)}`; |
| els.dropZone.classList.add('hidden'); |
| els.previewWrap.classList.remove('hidden'); |
| els.inputMessage.textContent = state.modelReady ? 'Image ready for classification.' : 'Image ready. Waiting for the model to finish loading.'; |
| resetResults(); |
| updatePredictButton(); |
| } catch (error) { |
| els.inputMessage.textContent = error.message || 'Could not read this image.'; |
| els.inputMessage.classList.add('error'); |
| clearImage(); |
| } |
| } |
|
|
| function preprocessImage(bitmap) { |
| const canvas = els.canvas; |
| const context = canvas.getContext('2d', { willReadFrequently: true }); |
| canvas.width = APP.imageSize; |
| canvas.height = APP.imageSize; |
| context.clearRect(0, 0, APP.imageSize, APP.imageSize); |
| context.imageSmoothingEnabled = true; |
| context.imageSmoothingQuality = 'high'; |
| context.drawImage(bitmap, 0, 0, APP.imageSize, APP.imageSize); |
|
|
| const rgba = context.getImageData(0, 0, APP.imageSize, APP.imageSize).data; |
| const planeSize = APP.imageSize * APP.imageSize; |
| const nchw = new Float32Array(3 * planeSize); |
|
|
| for (let pixel = 0; pixel < planeSize; pixel += 1) { |
| const source = pixel * 4; |
| nchw[pixel] = ((rgba[source] / 255) - APP.mean[0]) / APP.std[0]; |
| nchw[planeSize + pixel] = ((rgba[source + 1] / 255) - APP.mean[1]) / APP.std[1]; |
| nchw[(2 * planeSize) + pixel] = ((rgba[source + 2] / 255) - APP.mean[2]) / APP.std[2]; |
| } |
|
|
| return new ort.Tensor('float32', nchw, [1, 3, APP.imageSize, APP.imageSize]); |
| } |
|
|
| function softmax(logits) { |
| const maximum = Math.max(...logits); |
| const exponentials = logits.map(value => Math.exp(value - maximum)); |
| const denominator = exponentials.reduce((sum, value) => sum + value, 0); |
| return exponentials.map(value => value / denominator); |
| } |
|
|
| function renderResults(probabilities, timings) { |
| const ranked = probabilities |
| .map((probability, index) => ({ label: state.labels[index], probability })) |
| .sort((a, b) => b.probability - a.probability); |
|
|
| const winner = ranked[0]; |
| const confidencePercent = winner.probability * 100; |
|
|
| els.predictedClass.textContent = titleCase(winner.label); |
| els.confidenceValue.textContent = `${confidencePercent.toFixed(1)}%`; |
| els.confidenceRing.style.setProperty('--confidence', `${winner.probability * 360}deg`); |
| els.confidenceRing.setAttribute('aria-label', `${confidencePercent.toFixed(1)} percent confidence`); |
|
|
| els.topThree.replaceChildren(); |
| for (const item of ranked.slice(0, 3)) { |
| const row = document.createElement('li'); |
| const name = document.createElement('span'); |
| const value = document.createElement('span'); |
| name.className = 'class-name'; |
| value.className = 'class-prob'; |
| name.textContent = item.label; |
| value.textContent = `${(item.probability * 100).toFixed(2)}%`; |
| row.append(name, value); |
| els.topThree.appendChild(row); |
| } |
|
|
| els.probabilityList.replaceChildren(); |
| for (const item of ranked) { |
| const row = document.createElement('div'); |
| row.className = 'probability-row'; |
| const name = document.createElement('span'); |
| name.className = 'name'; |
| name.textContent = item.label; |
| const track = document.createElement('div'); |
| track.className = 'bar-track'; |
| const fill = document.createElement('div'); |
| fill.className = 'bar-fill'; |
| fill.style.width = `${Math.max(item.probability * 100, 0.3)}%`; |
| track.appendChild(fill); |
| const value = document.createElement('span'); |
| value.className = 'value'; |
| value.textContent = `${(item.probability * 100).toFixed(2)}%`; |
| row.append(name, track, value); |
| els.probabilityList.appendChild(row); |
| } |
|
|
| els.preprocessTime.textContent = formatMilliseconds(timings.preprocess); |
| els.inferenceTime.textContent = formatMilliseconds(timings.inference); |
| els.totalTime.textContent = formatMilliseconds(timings.total); |
| els.emptyResult.classList.add('hidden'); |
| els.results.classList.remove('hidden'); |
| } |
|
|
| async function predict() { |
| if (!state.modelReady || !state.bitmap || state.predicting) return; |
|
|
| state.predicting = true; |
| updatePredictButton(); |
| els.predictButtonText.textContent = 'Classifying…'; |
| els.buttonSpinner.classList.remove('hidden'); |
| els.inputMessage.textContent = 'Running browser-side inference…'; |
| els.inputMessage.classList.remove('error'); |
|
|
| try { |
| const totalStart = performance.now(); |
| const preprocessStart = performance.now(); |
| const inputTensor = preprocessImage(state.bitmap); |
| const preprocessEnd = performance.now(); |
|
|
| const inferenceStart = performance.now(); |
| const outputs = await state.session.run({ input: inputTensor }); |
| const inferenceEnd = performance.now(); |
|
|
| const outputTensor = outputs.logits; |
| if (!outputTensor || !outputTensor.data) { |
| throw new Error('The ONNX runtime did not return the expected logits output.'); |
| } |
| const logits = Array.from(outputTensor.data); |
| if (logits.length !== state.labels.length) { |
| throw new Error(`Expected ${state.labels.length} logits but received ${logits.length}.`); |
| } |
|
|
| const probabilities = softmax(logits); |
| const totalEnd = performance.now(); |
| renderResults(probabilities, { |
| preprocess: preprocessEnd - preprocessStart, |
| inference: inferenceEnd - inferenceStart, |
| total: totalEnd - totalStart, |
| }); |
| els.inputMessage.textContent = 'Prediction completed locally in this browser.'; |
| } catch (error) { |
| console.error(error); |
| els.inputMessage.textContent = error.message || 'Inference failed.'; |
| els.inputMessage.classList.add('error'); |
| } finally { |
| state.predicting = false; |
| els.predictButtonText.textContent = 'Classify image'; |
| els.buttonSpinner.classList.add('hidden'); |
| updatePredictButton(); |
| } |
| } |
|
|
| function preventDefaults(event) { |
| event.preventDefault(); |
| event.stopPropagation(); |
| } |
|
|
| ['dragenter', 'dragover'].forEach(eventName => { |
| els.dropZone.addEventListener(eventName, event => { |
| preventDefaults(event); |
| els.dropZone.classList.add('dragging'); |
| }); |
| }); |
|
|
| ['dragleave', 'drop'].forEach(eventName => { |
| els.dropZone.addEventListener(eventName, event => { |
| preventDefaults(event); |
| els.dropZone.classList.remove('dragging'); |
| }); |
| }); |
|
|
| els.dropZone.addEventListener('drop', event => handleFile(event.dataTransfer.files[0])); |
| els.dropZone.addEventListener('keydown', event => { |
| if (event.key === 'Enter' || event.key === ' ') { |
| event.preventDefault(); |
| els.fileInput.click(); |
| } |
| }); |
| els.fileInput.addEventListener('change', event => handleFile(event.target.files[0])); |
| els.clearButton.addEventListener('click', clearImage); |
| els.predictButton.addEventListener('click', predict); |
| window.addEventListener('beforeunload', () => { |
| if (state.bitmap && typeof state.bitmap.close === 'function') state.bitmap.close(); |
| if (state.objectUrl) URL.revokeObjectURL(state.objectUrl); |
| }); |
|
|
| initializeModel(); |
|
|