web: upscale on a thread pool instead of one core

The super-resolution export ran single-threaded because the site was not
cross-origin isolated and the runtime had no SharedArrayBuffer to spread a tile
over. nginx now sends COOP and COEP — on the document, and on the script
responses a nested worker fetches, which Chromium checks the same way and blocks
as `coep-frame-resource-needs-coep-header` without them — and the loader asks
for `min(8, hardwareConcurrency)` threads whenever the page is isolated, falling
back to one if the headers ever go missing. A worker script is also why the
landing's QR image needed `crossOrigin`: COEP refuses a cross-origin image that
did not opt in with CORS.

The unpack was the other half. Each tile was clamped a channel at a time and
painted whole, padded ring and all; it now writes straight into the
Uint8ClampedArray, which clamps and rounds on assignment, and skips the ring
rather than drawing it and clipping it away.

640x480 to 4096: 39.0s to 16.2s. 1000x750 to 4096: 89.6s to 30.9s. One 256px
tile through the model: 6.8s to 1.8s. Measured on the wasm path — the test
browser has no GPU adapter — so a WebGPU export, still per-tile inference, keeps
its own times.
This commit is contained in:
2026-09-22 20:30:02 +07:00
parent 3d273a3188
commit 1d4c6b1d66
3 changed files with 51 additions and 23 deletions
+14
View File
@@ -8,6 +8,12 @@ server {
# Static only, plus the API proxy below; a stricter CSP needs a per-app tune
# (canvas wasm, the QR image host) and belongs here once it is done.
# Cross-origin isolation: super-resolution runs onnxruntime's threaded wasm,
# which needs a SharedArrayBuffer, which the browser only hands over when the
# page is isolated. Both headers are required, and both must also sit on the
# script responses a nested worker fetches — see the two locations below.
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
@@ -21,10 +27,16 @@ server {
# the server hands over as octet-stream: onnxruntime's glue is one of those.
# First match wins among regex locations, so this one stands before the
# /wasm/ block that would otherwise swallow the file.
# (Both locations below restate the isolation headers from above: an add_header
# in a location drops every add_header inherited, and a worker script is fetched
# under the embedder's COEP — Chromium blocks it as
# `coep-frame-resource-needs-coep-header` without them.)
location ~* \.mjs$ {
types { application/javascript mjs; }
expires 30d;
add_header Cache-Control "public, immutable";
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;
try_files $uri =404;
}
@@ -34,6 +46,8 @@ server {
location ~* ^/(wasm|models|assets)/ {
expires 30d;
add_header Cache-Control "public, immutable";
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;
try_files $uri =404;
}
+34 -23
View File
@@ -42,10 +42,14 @@ function load(): Promise<Loaded> {
loaded = (async () => {
const ort = await import('onnxruntime-web');
ort.env.wasm.wasmPaths = WASM_DIR;
// No cross-origin isolation on this site, so the threaded path has no
// SharedArrayBuffer to use; saying so keeps the runtime from fetching a
// proxy worker it cannot run.
ort.env.wasm.numThreads = 1;
// nginx sends COOP+COEP, so the page is cross-origin isolated and there is
// a SharedArrayBuffer for the threaded build to spread a tile over. The
// check is for the case the headers ever go missing: asking for more than
// one thread without isolation makes the runtime throw instead of falling
// back, and a slow export beats a broken one.
ort.env.wasm.numThreads = self.crossOriginIsolated
? Math.min(8, navigator.hardwareConcurrency || 1)
: 1;
const session = await ort.InferenceSession.create(MODEL_URL, { executionProviders: ['webgpu'] }).catch(() =>
ort.InferenceSession.create(MODEL_URL, { executionProviders: ['wasm'] })
);
@@ -125,19 +129,31 @@ export async function upscaleJpeg(
const oh = dims[2];
const opts = tensor.data as Float32Array;
// Paint the tile's own pixels into a canvas of their own, then draw only
// the unpadded middle of it into the output, at the output's scale.
const tile = new OffscreenCanvas(ow, oh);
// Paint the tile's own pixels into a canvas of their own — the padded ring
// the model needed to see is skipped here instead of drawn and clipped —
// then draw that canvas into the output at the output's scale.
const cw = tw * MODEL_SCALE;
const ch = th * MODEL_SCALE;
const tile = new OffscreenCanvas(cw, ch);
const tileCtx = tile.getContext('2d');
if (!tileCtx) return bytes;
const img = tileCtx.createImageData(ow, oh);
const img = tileCtx.createImageData(cw, ch);
const oplane = ow * oh;
for (let i = 0; i < oplane; i++) {
const d = i * 4;
img.data[d] = clamp8(opts[i]);
img.data[d + 1] = clamp8(opts[oplane + i]);
img.data[d + 2] = clamp8(opts[2 * oplane + i]);
img.data[d + 3] = 255;
const ox = left * MODEL_SCALE;
const oy = top * MODEL_SCALE;
for (let y = 0; y < ch; y++) {
const srcRow = (oy + y) * ow + ox;
let d = y * cw * 4;
for (let x = 0; x < cw; x++) {
const s = srcRow + x;
// A Uint8ClampedArray clamps and rounds on assignment; a clamp call
// per channel was most of this loop's cost.
img.data[d] = opts[s] * 255;
img.data[d + 1] = opts[oplane + s] * 255;
img.data[d + 2] = opts[2 * oplane + s] * 255;
img.data[d + 3] = 255;
d += 4;
}
}
tileCtx.putImageData(img, 0, 0);
// Destination edges snapped to whole pixels: neighbouring tiles then share
@@ -148,10 +164,10 @@ export async function upscaleJpeg(
const dy0 = Math.round(y0 * scale);
dstCtx.drawImage(
tile,
left * MODEL_SCALE,
top * MODEL_SCALE,
tw * MODEL_SCALE,
th * MODEL_SCALE,
0,
0,
cw,
ch,
dx0,
dy0,
Math.round((x0 + tw) * scale) - dx0,
@@ -169,8 +185,3 @@ export async function upscaleJpeg(
bitmap.close();
}
}
function clamp8(v: number): number {
const n = v * 255;
return n <= 0 ? 0 : n >= 255 ? 255 : Math.round(n);
}
+3
View File
@@ -8,6 +8,8 @@ import { useState } from 'react';
export function QrCode({ url, size = 180, className }: { url: string; size?: number; className?: string }) {
const [ok, setOk] = useState(true);
if (!ok) return null;
// `crossOrigin`: the page is cross-origin isolated (COEP), which rejects a
// cross-origin image unless it opted in with CORS — qrserver answers `*`.
return (
<img
className={className}
@@ -16,6 +18,7 @@ export function QrCode({ url, size = 180, className }: { url: string; size?: num
alt=""
loading="lazy"
referrerPolicy="no-referrer"
crossOrigin="anonymous"
onError={() => setOk(false)}
src={`https://api.qrserver.com/v1/create-qr-code/?size=${size}x${size}&data=${encodeURIComponent(url)}`}
/>