baberu-ocr-webgpu / mangaocr-webgpu-benchmark.html
ameraino11's picture
Add complete 121 MB and 242 MB WebGPU variants, port source, and benchmarks
520f98d verified
Raw
History Blame Contribute Delete
9.92 kB
<!doctype html>
<meta charset="utf-8" />
<title>MangaOCR WebGPU comparison harness</title>
<style>
body { background: #111; color: #eee; font: 14px/1.45 ui-monospace, monospace; margin: 24px; }
pre { white-space: pre-wrap; }
</style>
<h1>MangaOCR WebGPU comparison harness</h1>
<pre id="log">Starting…</pre>
<script type="module">
import * as ort from "./.cache/ort/ort.webgpu.min.mjs";
const parameters = new URLSearchParams(location.search);
const output = document.querySelector("#log");
const lines = [];
const log = (message) => {
lines.push(`${new Date().toISOString()} ${message}`);
output.textContent = lines.join("\n");
console.log(message);
};
const timed = async (operation) => {
const started = performance.now();
const value = await operation();
return [value, performance.now() - started];
};
window.addEventListener("error", (event) => log(`FAIL ${event.error?.stack ?? event.message}`));
window.addEventListener("unhandledrejection", (event) => log(`FAIL ${event.reason?.stack ?? event.reason}`));
if (!navigator.gpu) throw new Error("WebGPU is unavailable");
log(`navigator.gpu=true secure=${window.isSecureContext}`);
const session = async (path) => {
const bytes = new Uint8Array(await (await fetch(path)).arrayBuffer());
return ort.InferenceSession.create(bytes, {
executionProviders: ["webgpu"],
logSeverityLevel: 3,
});
};
let value;
let elapsed;
[value, elapsed] = await timed(() => session("./.cache/mangaocr/encoder_model.onnx"));
const encoder = value;
log(`PASS encoder session creation (${elapsed.toFixed(1)} ms)`);
[value, elapsed] = await timed(() => session("./.cache/mangaocr/decoder_model.onnx"));
const decoder = value;
log(`PASS decoder session creation (${elapsed.toFixed(1)} ms)`);
const vocab = (await (await fetch("./.cache/mangaocr/vocab.txt")).text()).split(/\r?\n/g);
log(`models ready vocab=${vocab.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];
return raw.split(",").map(Number);
};
const cropImageData = (image) => {
const [x, y, width, height] = parseCrop(image);
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d", { willReadFrequently: true });
context.drawImage(image, x, y, width, height, 0, 0, width, height);
return context.getImageData(0, 0, width, height);
};
const clampByte = (number) => Math.max(0, Math.min(255, number));
const grayscale = (image) => {
const result = new Uint8ClampedArray(image.width * image.height);
for (let source = 0, pixel = 0; source < image.data.length; source += 4, pixel += 1) {
const r = image.data[source] ?? 0;
const g = image.data[source + 1] ?? 0;
const b = image.data[source + 2] ?? 0;
result[pixel] = ((b * 19595 + g * 38470 + r * 7471 + 0x8000) >> 16) & 0xff;
}
return result;
};
const coefficients = (inputSize, outputSize) => {
const scale = inputSize / outputSize;
const support = scale >= 1 ? scale : 1;
const maximum = Math.ceil(support) * 2 + 1;
const result = [];
for (let outputIndex = 0; outputIndex < outputSize; outputIndex += 1) {
const center = (outputIndex + 0.5) * scale;
const inverse = scale >= 1 ? 1 / scale : 1;
const start = Math.max(0, Math.min(inputSize, Math.trunc(center - support + 0.5)));
const end = Math.min(Math.trunc(center + support + 0.5), inputSize);
const size = Math.max(0, Math.min(maximum, end - start));
const weights = [];
let total = 0;
for (let index = 0; index < size; index += 1) {
const weight = Math.max(0, 1 - Math.abs((index + start - center + 0.5) * inverse));
weights.push(weight);
total += weight;
}
result.push({
start,
size,
weights: weights.map((weight) => Math.trunc(0.5 + weight / total * 2 ** 22)),
});
}
return result;
};
const preprocess = (image) => {
const source = grayscale(image);
const horizontal = coefficients(image.width, 224);
const vertical = coefficients(image.height, 224);
const temporary = new Uint8ClampedArray(image.height * 224);
for (let y = 0; y < image.height; y += 1) {
for (let x = 0; x < 224; x += 1) {
const item = horizontal[x];
let sum = 1 << 21;
for (let index = 0; index < item.size; index += 1) {
sum += (source[y * image.width + item.start + index] ?? 0) * (item.weights[index] ?? 0);
}
temporary[y * 224 + x] = clampByte(Math.trunc(sum / 2 ** 22));
}
}
const resized = new Uint8ClampedArray(224 * 224);
for (let y = 0; y < 224; y += 1) {
const item = vertical[y];
for (let x = 0; x < 224; x += 1) {
let sum = 1 << 21;
for (let index = 0; index < item.size; index += 1) {
sum += (temporary[(item.start + index) * 224 + x] ?? 0) * (item.weights[index] ?? 0);
}
resized[y * 224 + x] = clampByte(Math.trunc(sum / 2 ** 22));
}
}
const plane = 224 * 224;
const result = new Float32Array(3 * plane);
for (let pixel = 0; pixel < plane; pixel += 1) {
const normalized = Math.fround(Math.fround(Math.fround(resized[pixel]) / Math.fround(255)) * Math.fround(2) - Math.fround(1));
result[pixel] = normalized;
result[plane + pixel] = normalized;
result[2 * plane + pixel] = normalized;
}
return result;
};
const logSoftmax = (values) => {
let maximum = Number.NEGATIVE_INFINITY;
for (const item of values) maximum = Math.max(maximum, item);
let sum = 0;
for (const item of values) sum += Math.exp(item - maximum);
const logZ = Math.log(sum) + maximum;
return Float32Array.from(values, (item) => item - logZ);
};
const noRepeat = (ids, scores) => {
if (ids.length < 2) return;
const prefix = ids.slice(-2);
for (let index = 0; index <= ids.length - 3; index += 1) {
if (prefix.every((id, offset) => id === ids[index + offset])) scores[ids[index + 2]] = Number.NEGATIVE_INFINITY;
}
};
const top = (items, item, count) => {
if (items.length < count || item.score > (items.at(-1)?.score ?? Number.NEGATIVE_INFINITY)) {
items.push(item);
items.sort((a, b) => b.score - a.score);
if (items.length > count) items.pop();
}
};
const normalizedScore = (beam) => beam.score / Math.pow(Math.max(1, beam.ended ? beam.ids.length - 1 : beam.ids.length), 2);
const decodeText = (ids) => ids.filter((id) => id >= 15).map((id) => vocab[id] ?? "").map((token) => token.replace(/^##/, "")).join("").replace(/\s+/g, "").replaceAll("…", "...").replace(/[・.]{2,}/g, (match) => ".".repeat(match.length)).replace(/[\u0021-\u007e]/g, (character) => String.fromCharCode(character.charCodeAt(0) + 0xfee0));
const recognize = async (image) => {
const pixels = preprocess(cropImageData(image));
let result;
let encoderMs;
[result, encoderMs] = await timed(() => encoder.run({ pixel_values: new ort.Tensor("float32", pixels, [1, 3, 224, 224]) }));
const hiddenTensor = result.last_hidden_state ?? Object.values(result)[0];
log(`encoder output=${hiddenTensor.location} ${JSON.stringify(hiddenTensor.dims)}`);
const hidden = hiddenTensor.data;
const [, seqLen, hiddenDim] = hiddenTensor.dims;
let active = [{ ids: [2], score: 0, ended: false }];
const completed = [];
const decoderStarted = performance.now();
for (let step = 1; step < 96; step += 1) {
if (!active.length || completed.length >= 4) break;
const tokenLength = active[0].ids.length;
const inputIds = new BigInt64Array(active.length * tokenLength);
const repeatedHidden = new Float32Array(active.length * seqLen * hiddenDim);
active.forEach((beam, beamIndex) => {
beam.ids.forEach((id, idIndex) => inputIds[beamIndex * tokenLength + idIndex] = BigInt(id));
repeatedHidden.set(hidden, beamIndex * seqLen * hiddenDim);
});
const decoderResult = await decoder.run({
input_ids: new ort.Tensor("int64", inputIds, [active.length, tokenLength]),
encoder_hidden_states: new ort.Tensor("float32", repeatedHidden, [active.length, seqLen, hiddenDim]),
});
const tensor = decoderResult.logits ?? Object.values(decoderResult)[0];
const candidates = [];
active.forEach((beam, beamIndex) => {
const rowOffset = beamIndex * tokenLength * 6144 + (tokenLength - 1) * 6144;
const scores = logSoftmax(tensor.data.subarray(rowOffset, rowOffset + 6144));
noRepeat(beam.ids, scores);
for (let token = 0; token < scores.length; token += 1) top(candidates, { beam, token, score: beam.score + scores[token] }, 8);
});
const next = [];
for (let rank = 0; rank < candidates.length && next.length < 4; rank += 1) {
const candidate = candidates[rank];
if (candidate.token === 3) {
if (rank < 4) completed.push({ ids: [...candidate.beam.ids, 3], score: candidate.score, ended: true });
} else next.push({ ids: [...candidate.beam.ids, candidate.token], score: candidate.score, ended: false });
}
active = next;
}
const decoderMs = performance.now() - decoderStarted;
const candidates = (completed.length >= 4 ? completed : [...completed, ...active]).sort((a, b) => normalizedScore(b) - normalizedScore(a));
const text = decodeText(candidates[0]?.ids.filter((id) => id !== 2 && id !== 3) ?? []);
hiddenTensor.dispose();
return { encoderMs, decoderMs, totalMs: encoderMs + decoderMs, text };
};
const image = await loadImage(parameters.get("image") ?? "/.cache/demo/daf0244b038a-20260706.png");
const runs = Number(parameters.get("runs") ?? 4);
for (let run = 1; run <= runs; run += 1) {
const result = await recognize(image);
log(`RUN ${run}/${runs} encoder=${result.encoderMs.toFixed(1)} decoder=${result.decoderMs.toFixed(1)} total=${result.totalMs.toFixed(1)} ms`);
log(`TEXT ${result.text}`);
}
</script>