| "use strict"; |
|
|
| (() => { |
| const MODEL_SHA256 = "8ddbe216c1cd0416a1f6528fc07169a989e5f8f39569ceab1afbc6d9b2e8e839"; |
| const OUTPUTS = ["group_logits", "machine_logits", "exact_logits"]; |
| const HEADS = ["group", "machine", "exact"]; |
| const COUNTS = { group: 1225, machine: 1544, exact: 1499 }; |
| const IMAGE_SIZE = 256; |
| const MEAN = [0.485, 0.456, 0.406]; |
| const STD = [0.229, 0.224, 0.225]; |
|
|
| const fileInput = document.querySelector("#image-file"); |
| const topKInput = document.querySelector("#top-k"); |
| const runButton = document.querySelector("#run"); |
| const preview = document.querySelector("#preview"); |
| const status = document.querySelector("#status"); |
| const errorBox = document.querySelector("#error"); |
| const resultsSection = document.querySelector("#results"); |
| const resultBodies = Object.fromEntries(HEADS.map((head) => [head, document.querySelector(`#${head}-results`)])); |
|
|
| let sessionPromise; |
| let metadata; |
| let previewUrl; |
|
|
| if (typeof ort === "undefined") { |
| status.textContent = "Provider: unavailable"; |
| errorBox.textContent = "ONNX Runtime Web failed to load"; |
| errorBox.style.display = "block"; |
| runButton.disabled = true; |
| return; |
| } |
| ort.env.wasm.wasmPaths = "https://cdn.jsdelivr.net/npm/onnxruntime-web@1.22.0/dist/"; |
|
|
| function clearResults() { |
| for (const body of Object.values(resultBodies)) body.replaceChildren(); |
| resultsSection.style.display = "none"; |
| } |
|
|
| function clearError() { |
| errorBox.textContent = ""; |
| errorBox.style.display = "none"; |
| } |
|
|
| function showError(error) { |
| clearResults(); |
| const message = error instanceof Error ? error.message : String(error); |
| errorBox.textContent = message || "Unknown browser inference error"; |
| errorBox.style.display = "block"; |
| } |
|
|
| function assert(condition, message) { |
| if (!condition) throw new Error(message); |
| } |
|
|
| function sameOriginUrl(relativePath) { |
| const url = new URL(relativePath, window.location.href); |
| assert(url.origin === window.location.origin, `Refusing cross-origin asset: ${url.href}`); |
| return url; |
| } |
|
|
| async function fetchAsset(relativePath, responseType) { |
| const url = sameOriginUrl(relativePath); |
| const response = await fetch(url, { credentials: "same-origin" }); |
| assert(response.ok, `Failed to load ${url.pathname}: HTTP ${response.status}`); |
| assert(new URL(response.url).origin === window.location.origin, `Asset redirected off origin: ${url.pathname}`); |
| return responseType === "json" ? response.json() : response.arrayBuffer(); |
| } |
|
|
| function arraysEqual(actual, expected) { |
| return Array.isArray(actual) && actual.length === expected.length && actual.every((value, index) => value === expected[index]); |
| } |
|
|
| function validateMetadata(value) { |
| assert(value && typeof value === "object", "Metadata must be an object"); |
| assert(value.format_version === 3, "Unsupported metadata format"); |
| assert(value.label_schema_version === 2, "Unsupported label schema"); |
| assert(value.label_migration?.canonical_vocabulary_sha256 === "7f0de4a0cc94845d5e6f429ca9c6eac81dbef4e7cdfe5f008267ce19d78c1cc1", "Unexpected canonical vocabulary digest"); |
| assert(value.browser_runtime?.batch_size === 1, "Metadata must specify browser batch size 1"); |
| assert(value.browser_runtime?.wasm_fallback_required === true, "Metadata must require WASM fallback"); |
| assert(arraysEqual(value.browser_runtime?.preferred_execution_providers, ["webgpu", "wasm"]), "Unexpected provider order"); |
| assert(value.onnx?.file === "model.fp16.onnx", "Unexpected model filename"); |
| assert(value.onnx?.sha256 === MODEL_SHA256, "Unexpected metadata model digest"); |
| assert(value.onnx?.fixed_batch_size === 1, "Model must use fixed batch size 1"); |
| assert(value.input?.name === "images", "Unexpected input name"); |
| assert(value.input?.dtype === "float32", "Unexpected input type"); |
| assert(arraysEqual(value.input?.shape, [1, 3, IMAGE_SIZE, IMAGE_SIZE]), "Unexpected input shape"); |
| assert(value.input?.resize_short_edge === IMAGE_SIZE && value.input?.center_crop === IMAGE_SIZE, "Unexpected image sizing contract"); |
| assert(value.input?.color === "RGB" && value.input?.interpolation === "bicubic", "Unexpected image preprocessing contract"); |
| assert(arraysEqual(value.input?.mean, MEAN) && arraysEqual(value.input?.std, STD), "Unexpected normalization contract"); |
| assert(arraysEqual(value.outputs, OUTPUTS), "Unexpected output order"); |
| assert(value.precision?.input === "float32" && value.precision?.outputs === "float32", "Unexpected tensor precision contract"); |
| for (const head of HEADS) { |
| const vocabulary = value.vocabularies?.[head]; |
| assert(Array.isArray(vocabulary) && vocabulary.length === COUNTS[head], `Unexpected ${head} vocabulary`); |
| assert(vocabulary.every((id) => typeof id === "string"), `Invalid ${head} vocabulary entry`); |
| } |
| } |
|
|
| async function sha256Hex(bytes) { |
| const digest = await crypto.subtle.digest("SHA-256", bytes); |
| return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); |
| } |
|
|
| async function initializeSession() { |
| status.textContent = "Provider: loading model and metadata…"; |
| const [loadedMetadata, modelBytes] = await Promise.all([ |
| fetchAsset("./onnx-metadata.json", "json"), |
| fetchAsset("./model.fp16.onnx", "arrayBuffer") |
| ]); |
| validateMetadata(loadedMetadata); |
| const digest = await sha256Hex(modelBytes); |
| assert(digest === MODEL_SHA256, `Model SHA-256 mismatch: expected ${MODEL_SHA256}, got ${digest}`); |
|
|
| let session; |
| let provider; |
| try { |
| if (new URLSearchParams(window.location.search).get("forceWebgpuFailure") === "1") { |
| throw new Error("WebGPU creation force-failed by query parameter"); |
| } |
| session = await ort.InferenceSession.create(modelBytes, { executionProviders: ["webgpu"] }); |
| provider = "webgpu"; |
| } catch (webgpuError) { |
| status.textContent = "Provider: WebGPU unavailable; initializing WASM…"; |
| try { |
| session = await ort.InferenceSession.create(modelBytes, { executionProviders: ["wasm"] }); |
| provider = "wasm"; |
| } catch (wasmError) { |
| throw new Error(`Unable to initialize WebGPU or WASM. WebGPU: ${webgpuError.message}; WASM: ${wasmError.message}`); |
| } |
| } |
| metadata = loadedMetadata; |
| status.textContent = `Provider: ${provider}`; |
| return session; |
| } |
|
|
| function getSession() { |
| if (!sessionPromise) { |
| sessionPromise = initializeSession().catch((error) => { |
| sessionPromise = undefined; |
| metadata = undefined; |
| status.textContent = "Provider: unavailable"; |
| throw error; |
| }); |
| } |
| return sessionPromise; |
| } |
|
|
| async function preprocessImage(file) { |
| let bitmap; |
| try { |
| bitmap = await createImageBitmap(file); |
| } catch (error) { |
| throw new Error(`Unable to decode the selected image: ${error.message}`); |
| } |
| try { |
| assert(bitmap.width > 0 && bitmap.height > 0, "Decoded image has invalid dimensions"); |
| let resizedWidth; |
| let resizedHeight; |
| if (bitmap.width < bitmap.height) { |
| resizedWidth = IMAGE_SIZE; |
| resizedHeight = Math.trunc(bitmap.height * IMAGE_SIZE / bitmap.width); |
| } else { |
| resizedHeight = IMAGE_SIZE; |
| resizedWidth = Math.trunc(bitmap.width * IMAGE_SIZE / bitmap.height); |
| } |
| const cropX = Math.round((resizedWidth - IMAGE_SIZE) / 2); |
| const cropY = Math.round((resizedHeight - IMAGE_SIZE) / 2); |
| const canvas = document.createElement("canvas"); |
| canvas.width = IMAGE_SIZE; |
| canvas.height = IMAGE_SIZE; |
| const context = canvas.getContext("2d", { willReadFrequently: true }); |
| assert(context, "Canvas 2D is unavailable"); |
| context.imageSmoothingEnabled = true; |
| context.imageSmoothingQuality = "high"; |
| context.drawImage(bitmap, -cropX, -cropY, resizedWidth, resizedHeight); |
| const rgba = context.getImageData(0, 0, IMAGE_SIZE, IMAGE_SIZE).data; |
| const plane = IMAGE_SIZE * IMAGE_SIZE; |
| const chw = new Float32Array(3 * plane); |
| for (let pixel = 0; pixel < plane; pixel += 1) { |
| const rgbaOffset = pixel * 4; |
| chw[pixel] = (rgba[rgbaOffset] / 255 - MEAN[0]) / STD[0]; |
| chw[plane + pixel] = (rgba[rgbaOffset + 1] / 255 - MEAN[1]) / STD[1]; |
| chw[2 * plane + pixel] = (rgba[rgbaOffset + 2] / 255 - MEAN[2]) / STD[2]; |
| } |
| return new ort.Tensor("float32", chw, [1, 3, IMAGE_SIZE, IMAGE_SIZE]); |
| } finally { |
| bitmap.close(); |
| } |
| } |
|
|
| function rankOutput(tensor, head, topK) { |
| assert(tensor && tensor.type === "float32", `${head} output must be float32`); |
| assert(arraysEqual(tensor.dims, [1, COUNTS[head]]) || arraysEqual(tensor.dims, [COUNTS[head]]), `${head} output has unexpected dimensions`); |
| assert(tensor.data.length === COUNTS[head], `${head} output has unexpected length`); |
| let maximum = -Infinity; |
| for (const value of tensor.data) { |
| assert(Number.isFinite(value), `${head} output contains a non-finite logit`); |
| if (value > maximum) maximum = value; |
| } |
| const probabilities = new Float64Array(tensor.data.length); |
| let denominator = 0; |
| for (let index = 0; index < tensor.data.length; index += 1) { |
| const probability = Math.exp(tensor.data[index] - maximum); |
| probabilities[index] = probability; |
| denominator += probability; |
| } |
| assert(Number.isFinite(denominator) && denominator > 0, `${head} softmax failed`); |
| return Array.from(probabilities, (probability, index) => ({ index, confidence: probability / denominator })) |
| .sort((left, right) => right.confidence - left.confidence || left.index - right.index) |
| .slice(0, topK) |
| .map(({ index, confidence }) => ({ id: metadata.vocabularies[head][index], confidence })); |
| } |
|
|
| function render(head, rows) { |
| const fragment = document.createDocumentFragment(); |
| for (const row of rows) { |
| const tr = document.createElement("tr"); |
| const id = document.createElement("td"); |
| const confidence = document.createElement("td"); |
| id.textContent = row.id; |
| confidence.textContent = row.confidence.toFixed(6); |
| tr.append(id, confidence); |
| fragment.append(tr); |
| } |
| resultBodies[head].replaceChildren(fragment); |
| } |
|
|
| fileInput.addEventListener("change", () => { |
| clearError(); |
| clearResults(); |
| if (previewUrl) URL.revokeObjectURL(previewUrl); |
| const file = fileInput.files?.[0]; |
| if (!file) { |
| preview.removeAttribute("src"); |
| preview.style.display = "none"; |
| previewUrl = undefined; |
| return; |
| } |
| previewUrl = URL.createObjectURL(file); |
| preview.src = previewUrl; |
| preview.style.display = "block"; |
| }); |
|
|
| runButton.addEventListener("click", async () => { |
| clearError(); |
| clearResults(); |
| const file = fileInput.files?.[0]; |
| const topK = Number(topKInput.value); |
| try { |
| assert(file, "Select an image before running inference"); |
| assert(Number.isInteger(topK) && topK >= 1 && topK <= 20, "Results per head must be an integer between 1 and 20"); |
| runButton.disabled = true; |
| const [session, tensor] = await Promise.all([getSession(), preprocessImage(file)]); |
| status.textContent = `${status.textContent}; running inference…`; |
| const outputs = await session.run({ images: tensor }); |
| for (let index = 0; index < HEADS.length; index += 1) { |
| const head = HEADS[index]; |
| const outputName = OUTPUTS[index]; |
| assert(Object.prototype.hasOwnProperty.call(outputs, outputName), `Missing output ${outputName}`); |
| render(head, rankOutput(outputs[outputName], head, topK)); |
| } |
| resultsSection.style.display = "grid"; |
| status.textContent = status.textContent.replace("; running inference…", ""); |
| } catch (error) { |
| status.textContent = status.textContent.replace("; running inference…", ""); |
| showError(error); |
| } finally { |
| runButton.disabled = false; |
| } |
| }); |
| })(); |
|
|