Download webgpu-e2e.html from ameraino11/baberu-ocr-webgpu: direct link, hf CLI and curl.
- Browser
- Download file 15.9 kB
-
https://huggingface.co/ameraino11/baberu-ocr-webgpu/resolve/main/webgpu-e2e.html
- Command line
-
hf download hf://ameraino11/baberu-ocr-webgpu/webgpu-e2e.html
-
curl -L -o webgpu-e2e.html https://huggingface.co/ameraino11/baberu-ocr-webgpu/resolve/main/webgpu-e2e.html
15.9 kB
| <meta charset="utf-8" /> | |
| <title>Baberu end-to-end WebGPU OCR</title> | |
| <style> | |
| body { background: #111; color: #eee; font: 14px/1.45 ui-monospace, monospace; margin: 24px; } | |
| .controls { display: flex; gap: 12px; align-items: center; margin-bottom: 16px; } | |
| button, input { font: inherit; } | |
| canvas { border: 1px solid #555; height: 224px; width: 224px; } | |
| pre { white-space: pre-wrap; } | |
| </style> | |
| <h1>Baberu end-to-end WebGPU OCR</h1> | |
| <div class="controls"> | |
| <input id="file" type="file" accept="image/*" /> | |
| <button id="run" type="button">Run selected crop</button> | |
| </div> | |
| <canvas id="preview" width="224" height="224"></canvas> | |
| <pre id="log">Starting…</pre> | |
| <script type="module"> | |
| import * as ort from "./.cache/ort/ort.webgpu.min.mjs"; | |
| const VOCAB_SIZE = 14630; | |
| const BOS = 1; | |
| const EOS = 2; | |
| const MEAN = [0.485, 0.456, 0.406]; | |
| const STD = [0.229, 0.224, 0.225]; | |
| const cacheNames = [ | |
| ...Array.from({ length: 6 }, (_, index) => `present_k${index}`), | |
| ...Array.from({ length: 6 }, (_, index) => `present_v${index}`), | |
| ]; | |
| const output = document.querySelector("#log"); | |
| const preview = document.querySelector("#preview"); | |
| const context = preview.getContext("2d", { willReadFrequently: true }); | |
| const lines = []; | |
| const log = (message) => { | |
| lines.push(`${new Date().toISOString()} ${message}`); | |
| output.textContent = lines.join("\n"); | |
| console.log(message); | |
| }; | |
| const timed = async (label, operation) => { | |
| const started = performance.now(); | |
| const result = await operation(); | |
| log(`PASS ${label} (${(performance.now() - started).toFixed(1)} ms)`); | |
| return result; | |
| }; | |
| window.addEventListener("error", (event) => log(`FAIL ${event.error?.stack ?? event.message}`)); | |
| window.addEventListener("unhandledrejection", (event) => log(`FAIL ${event.reason?.stack ?? event.reason}`)); | |
| ort.env.wasm.numThreads = 1; | |
| ort.env.wasm.wasmPaths = "/.cache/ort/"; | |
| if (!navigator.gpu) throw new Error("WebGPU is unavailable"); | |
| const adapter = await navigator.gpu.requestAdapter({ powerPreference: "high-performance" }); | |
| if (!adapter) throw new Error("No WebGPU adapter is available"); | |
| const gpuDevice = await adapter.requestDevice(); | |
| ort.env.webgpu.device = gpuDevice; | |
| log(`navigator.gpu=true secure=${window.isSecureContext}`); | |
| const createSession = async (path, preferredOutputLocation) => { | |
| const response = await fetch(path); | |
| if (!response.ok) throw new Error(`${path}: HTTP ${response.status}`); | |
| const bytes = new Uint8Array(await response.arrayBuffer()); | |
| return ort.InferenceSession.create(bytes, { | |
| executionProviders: ["webgpu"], | |
| preferredOutputLocation, | |
| logSeverityLevel: 2, | |
| }); | |
| }; | |
| const parameters = new URLSearchParams(location.search); | |
| const bundle = parameters.get("bundle") ?? "lossless"; | |
| const compactVision = bundle.startsWith("compact-"); | |
| const compact = bundle === "compact-qdq"; | |
| const compactFp16 = bundle === "compact-fp16"; | |
| const gatherTokens = bundle === "compact-unified-gather" || bundle === "balanced-unified-gather"; | |
| const unifiedQdq = bundle === "compact-unified-qdq" || bundle === "balanced-unified-qdq" || gatherTokens; | |
| const qdq = compact || bundle === "balanced-qdq"; | |
| const publishedLayout = parameters.get("layout") === "hf"; | |
| const publishedRoot = compactVision ? "./variants/webgpu-121" : "./variants/webgpu-242"; | |
| const visionPath = publishedLayout | |
| ? `${publishedRoot}/${compactVision ? "vision_int4.onnx" : "vision_fp16.onnx"}` | |
| : compactVision | |
| ? "./model/onnx/vision_int4.onnx" | |
| : parameters.get("vision") === "fp32" | |
| ? "./output/vision_fp32.onnx" | |
| : "./model/onnx/vision_fp16.onnx"; | |
| const prefillPath = publishedLayout | |
| ? `${publishedRoot}/decoder_prefill_qdq_int8.onnx` | |
| : compactFp16 | |
| ? "./output/decoder_prefill_fp16.onnx" | |
| : qdq | |
| ? "./output/decoder_prefill_qdq_int8.onnx" | |
| : "./output/decoder_prefill_fp32.onnx"; | |
| const stepPath = publishedLayout | |
| ? `${publishedRoot}/decoder_step_qdq_int8.onnx` | |
| : compactFp16 | |
| ? "./output/decoder_step_fp16.onnx" | |
| : qdq | |
| ? "./output/decoder_step_qdq_int8.onnx" | |
| : "./output/decoder_step_fp32.onnx"; | |
| const maxTokens = Number(parameters.get("tokens") ?? 128); | |
| const vision = await timed("vision session creation", () => | |
| createSession(visionPath, { vision_embeds: "gpu-buffer" }) | |
| ); | |
| const decoderOutputs = Object.fromEntries([ | |
| ["logits", "cpu"], | |
| ...cacheNames.map((name) => [name, "gpu-buffer"]), | |
| ]); | |
| const unifiedFile = gatherTokens | |
| ? "decoder_unified_gather_qdq_int8.onnx" | |
| : "decoder_unified_qdq_int8.onnx"; | |
| const unifiedPath = publishedLayout | |
| ? `${publishedRoot}/${unifiedFile}` | |
| : `./output/${unifiedFile}`; | |
| const activePrefillPath = unifiedQdq ? unifiedPath : prefillPath; | |
| const activeStepPath = unifiedQdq ? unifiedPath : stepPath; | |
| const prefill = await timed("prefill session creation", () => | |
| createSession(activePrefillPath, decoderOutputs) | |
| ); | |
| const step = unifiedQdq ? prefill : await timed("step session creation", () => | |
| createSession(stepPath, decoderOutputs) | |
| ); | |
| let sessionsReleased = false; | |
| const releaseSessions = async () => { | |
| if (sessionsReleased) return; | |
| sessionsReleased = true; | |
| const sessions = step === prefill ? [vision, prefill] : [vision, prefill, step]; | |
| const results = await Promise.allSettled(sessions.map((session) => session.release())); | |
| gpuDevice.destroy(); | |
| const failed = results.filter((result) => result.status === "rejected"); | |
| if (failed.length) { | |
| throw new Error(`Failed to release ${failed.length}/${sessions.length} model sessions`); | |
| } | |
| document.querySelector("#run").disabled = true; | |
| log(`PASS released ${sessions.length} model sessions`); | |
| }; | |
| window.releaseSessions = releaseSessions; | |
| window.addEventListener("pagehide", () => void releaseSessions(), { once: true }); | |
| const vocabPath = publishedLayout ? "./tokenizer/vocab.json" : "./model/tokenizer/vocab.json"; | |
| const charset = await (await fetch(vocabPath)).json(); | |
| const idToCharacter = ["", "", "", "", ...charset]; | |
| const contentIds = new Set(); | |
| for (let index = 0; index < charset.length; index += 1) { | |
| const character = charset[index]; | |
| if (character.length === 1 && !"ーー〜~".includes(character) && /[\p{Letter}\p{Number}]/u.test(character)) { | |
| contentIds.add(index + 4); | |
| } | |
| } | |
| log(`models ready bundle=${bundle} vision=${visionPath} prefill=${activePrefillPath} step=${activeStepPath} sharedDecoder=${unifiedQdq} gatherTokens=${gatherTokens} vocab=${idToCharacter.length}`); | |
| const loadImage = (source) => new Promise((resolve, reject) => { | |
| const image = new Image(); | |
| image.onload = () => resolve(image); | |
| image.onerror = reject; | |
| image.src = source; | |
| }); | |
| const parseCrop = (image) => { | |
| const raw = parameters.get("crop"); | |
| if (!raw) return [0, 0, image.naturalWidth, image.naturalHeight]; | |
| const values = raw.split(",").map(Number); | |
| if (values.length !== 4 || values.some((value) => !Number.isFinite(value))) { | |
| throw new Error("crop must be x,y,width,height"); | |
| } | |
| return values; | |
| }; | |
| const preprocess = (image) => { | |
| const crop = parseCrop(image); | |
| context.imageSmoothingEnabled = true; | |
| context.imageSmoothingQuality = "high"; | |
| context.clearRect(0, 0, 224, 224); | |
| context.drawImage(image, ...crop, 0, 0, 224, 224); | |
| const rgba = context.getImageData(0, 0, 224, 224).data; | |
| const values = new Float32Array(3 * 224 * 224); | |
| for (let pixel = 0; pixel < 224 * 224; pixel += 1) { | |
| for (let channel = 0; channel < 3; channel += 1) { | |
| values[channel * 224 * 224 + pixel] = | |
| (rgba[pixel * 4 + channel] / 255 - MEAN[channel]) / STD[channel]; | |
| } | |
| } | |
| log(`crop=${crop.join(",")} source=${image.naturalWidth}x${image.naturalHeight}`); | |
| return new ort.Tensor("float32", values, [1, 3, 224, 224]); | |
| }; | |
| const oneHot = (token) => { | |
| const values = new Float32Array(VOCAB_SIZE); | |
| values[token] = 1; | |
| return new ort.Tensor("float32", values, [1, 1, VOCAB_SIZE]); | |
| }; | |
| const tokenId = (token) => new ort.Tensor("int32", Int32Array.of(token), [1, 1]); | |
| const emptyVision = () => new ort.Tensor("float32", new Float32Array(0), [1, 0, 512]); | |
| const emptyCache = () => new ort.Tensor("float32", new Float32Array(0), [1, 2, 0, 64]); | |
| const prefillPositions = () => new ort.Tensor( | |
| "int32", | |
| Int32Array.from({ length: 257 }, (_, index) => index), | |
| [1, 257], | |
| ); | |
| const shouldBlockToken = (tokens) => { | |
| if (!tokens.length || !contentIds.has(tokens.at(-1))) return false; | |
| const last = tokens.at(-1); | |
| let run = 0; | |
| for (let index = tokens.length - 1; index >= 0 && tokens[index] === last; index -= 1) run += 1; | |
| return run >= 12; | |
| }; | |
| const chooseToken = (source, sequence, tokens) => { | |
| const seen = new Set(sequence); | |
| const blocked = shouldBlockToken(tokens) ? tokens.at(-1) : -1; | |
| const adjusted = (index) => { | |
| if (index === blocked) return Number.NEGATIVE_INFINITY; | |
| const value = source[index]; | |
| if (!seen.has(index)) return value; | |
| return value < 0 ? value * 1.2 : value / 1.2; | |
| }; | |
| let result = 0; | |
| let best = adjusted(0); | |
| for (let index = 1; index < source.length; index += 1) { | |
| const value = adjusted(index); | |
| if (value > best) { | |
| best = value; | |
| result = index; | |
| } | |
| } | |
| return result; | |
| }; | |
| const disposeResult = (result) => { | |
| for (const name of cacheNames) result[name].dispose(); | |
| result.logits.dispose(); | |
| }; | |
| const recognize = async (image) => { | |
| const pixels = preprocess(image); | |
| const totalStarted = performance.now(); | |
| const visionStarted = performance.now(); | |
| const visionResult = await timed("vision execution", () => vision.run({ pixel_values: pixels })); | |
| const visionMs = performance.now() - visionStarted; | |
| log(`vision output=${visionResult.vision_embeds.location} ${JSON.stringify(visionResult.vision_embeds.dims)}`); | |
| const prefillStarted = performance.now(); | |
| const prefillFeeds = unifiedQdq | |
| ? { | |
| vision_embeds: visionResult.vision_embeds, | |
| [gatherTokens ? "token_ids" : "token_one_hot"]: gatherTokens ? tokenId(BOS) : oneHot(BOS), | |
| position_ids: prefillPositions(), | |
| ...Object.fromEntries(cacheNames.map((name) => [name.replace("present_", "past_"), emptyCache()])), | |
| } | |
| : { vision_embeds: visionResult.vision_embeds }; | |
| let cacheResult = await timed("decoder prefill", () => prefill.run(prefillFeeds)); | |
| if (unifiedQdq) { | |
| prefillFeeds[gatherTokens ? "token_ids" : "token_one_hot"].dispose(); | |
| prefillFeeds.position_ids.dispose(); | |
| for (const name of cacheNames) prefillFeeds[name.replace("present_", "past_")].dispose(); | |
| } | |
| const prefillMs = performance.now() - prefillStarted; | |
| log(`KV output=${cacheResult.present_k0.location} ${JSON.stringify(cacheResult.present_k0.dims)}`); | |
| visionResult.vision_embeds.dispose(); | |
| pixels.dispose(); | |
| const sequence = [BOS]; | |
| const tokens = []; | |
| const decodeStarted = performance.now(); | |
| for (let iteration = 0; iteration < maxTokens; iteration += 1) { | |
| const next = chooseToken(cacheResult.logits.data, sequence, tokens); | |
| if (next === EOS) break; | |
| tokens.push(next); | |
| sequence.push(next); | |
| if (tokens.length >= maxTokens) break; | |
| const feeds = { | |
| [gatherTokens ? "token_ids" : "token_one_hot"]: gatherTokens ? tokenId(next) : oneHot(next), | |
| position_ids: new ort.Tensor("int32", new Int32Array([257 + iteration]), [1, 1]), | |
| }; | |
| if (unifiedQdq) feeds.vision_embeds = emptyVision(); | |
| for (let layer = 0; layer < 6; layer += 1) { | |
| feeds[`past_k${layer}`] = cacheResult[`present_k${layer}`]; | |
| feeds[`past_v${layer}`] = cacheResult[`present_v${layer}`]; | |
| } | |
| const nextResult = await step.run(feeds); | |
| disposeResult(cacheResult); | |
| feeds[gatherTokens ? "token_ids" : "token_one_hot"].dispose(); | |
| feeds.position_ids.dispose(); | |
| if (unifiedQdq) feeds.vision_embeds.dispose(); | |
| cacheResult = nextResult; | |
| } | |
| const decodeMs = performance.now() - decodeStarted; | |
| const text = tokens.map((token) => idToCharacter[token] ?? "").join(""); | |
| log(`PASS decode ${tokens.length} tokens (${decodeMs.toFixed(1)} ms)`); | |
| log(`TEXT ${text}`); | |
| disposeResult(cacheResult); | |
| return { | |
| text, | |
| tokens: tokens.length, | |
| visionMs, | |
| prefillMs, | |
| decodeMs, | |
| totalMs: performance.now() - totalStarted, | |
| }; | |
| }; | |
| const normalizeText = (text) => text.normalize("NFKC").replace(/\s+/g, ""); | |
| const editDistance = (left, right) => { | |
| let previous = Array.from({ length: right.length + 1 }, (_, index) => index); | |
| for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) { | |
| const current = [leftIndex]; | |
| for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) { | |
| current.push(Math.min( | |
| current[rightIndex - 1] + 1, | |
| previous[rightIndex] + 1, | |
| previous[rightIndex - 1] + (left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1), | |
| )); | |
| } | |
| previous = current; | |
| } | |
| return previous.at(-1); | |
| }; | |
| const hayai13 = [ | |
| ["01", "知らない世界で見つけたイメージを"], | |
| ["02", "カナデトモスソラ(Kanadetomosusora)"], | |
| ["03", "建設会社社員行方"], | |
| ["04", "だとしてもこのレベルがウロつくなんて...おそらく2級の呪い"], | |
| ["05", "パチパチパチパチ"], | |
| ["06", "バビュン"], | |
| ["07", "僕の過去とか未来とか"], | |
| ["08", "くらべられっ子"], | |
| ["09", "そうだクラス分けがあるんだった!!"], | |
| ["10", "脇役よ、主役を超えよ!"], | |
| ["11", "Eh~Idon'treallywantto~"], | |
| ["12", "「Sorryforthewait~!Didyouwaitlong?」"], | |
| ["13", "YamateAreaNewresidentialdistrictforforeigners"], | |
| ]; | |
| const runHayai13 = async () => { | |
| const results = []; | |
| for (const [id, expected] of hayai13) { | |
| log(`CASE ${id}/13`); | |
| const result = await recognize(await loadImage(`./.cache/hayai13/${id}.png`)); | |
| const normalizedExpected = normalizeText(expected); | |
| const normalizedActual = normalizeText(result.text); | |
| results.push({ | |
| id, | |
| expected, | |
| actual: result.text, | |
| distance: editDistance(normalizedExpected, normalizedActual), | |
| characters: normalizedExpected.length, | |
| exact: normalizedExpected === normalizedActual, | |
| ...result, | |
| }); | |
| } | |
| const distance = results.reduce((sum, result) => sum + result.distance, 0); | |
| const characters = results.reduce((sum, result) => sum + result.characters, 0); | |
| const exact = results.filter((result) => result.exact).length; | |
| const sortedTimes = results.map((result) => result.totalMs).sort((a, b) => a - b); | |
| const summary = { | |
| bundle, | |
| cases: results.length, | |
| nCER: distance / characters, | |
| distance, | |
| characters, | |
| exact, | |
| medianMs: sortedTimes[Math.floor(sortedTimes.length / 2)], | |
| peakCaseMs: sortedTimes.at(-1), | |
| results, | |
| }; | |
| log(`SUITE_RESULT ${JSON.stringify(summary)}`); | |
| window.__suiteResult = summary; | |
| await fetch("/benchmark-result", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify(summary), | |
| }); | |
| log(`PASS persisted benchmark result for ${bundle}`); | |
| }; | |
| window.runHayai13 = runHayai13; | |
| const fileInput = document.querySelector("#file"); | |
| document.querySelector("#run").addEventListener("click", async () => { | |
| const [file] = fileInput.files; | |
| if (!file) throw new Error("Choose a speech-bubble crop first"); | |
| try { | |
| await recognize(await loadImage(URL.createObjectURL(file))); | |
| } finally { | |
| await releaseSessions(); | |
| } | |
| }); | |
| const suite = parameters.get("suite"); | |
| const imagePath = parameters.get("image"); | |
| if (suite === "hayai13" || imagePath) { | |
| try { | |
| if (suite === "hayai13") { | |
| await runHayai13(); | |
| } else { | |
| const image = await loadImage(imagePath); | |
| const runs = Number(parameters.get("runs") ?? 1); | |
| for (let run = 1; run <= runs; run += 1) { | |
| log(`RUN ${run}/${runs}`); | |
| await recognize(image); | |
| } | |
| } | |
| } finally { | |
| await releaseSessions(); | |
| } | |
| } | |
| </script> | |