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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)}`}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user