updraft / features /upscaler /engine /upscaler-engine.js
Nicholas Celestin
Build update — 2026-05-22T18:34:00.912Z
3f22414
Raw
History Blame Contribute Delete
35.7 kB
/**
* UpscalerEngine — tiled ONNX super-resolution inference.
* Downloads a model, creates a session, runs tiled inference on images.
* Uses Canvas 2D for pixel I/O in the WASM/WebGL path; GPU paths avoid readback.
*/
import { fetchWithProgress } from 'lib/fetch-progress';
import { GpuTileRenderer, GpuOutputTooLargeError } from './gpu-tile-renderer.js';
import { GpuFrameExtractor } from './gpu-frame-extractor.js';
import {
buildTileGrid,
pasteTileCropped,
overlapCrop,
makeGaussianWeights2D,
accumulateGaussianTile,
finalizeGaussianRegion,
} from './tiling.js';
import { readMetaEntry, isFp16InputType } from 'lib/onnx-meta';
import { dispatchBackendEvent } from 'lib/backend-events';
import { loadSession } from 'lib/backend';
const DEFAULT_SCALE = 4;
const DEFAULT_OVERLAP = 16;
function clampByte(v) {
return v < 0 ? 0 : v > 255 ? 255 : (v + 0.5) | 0;
}
// Normalize the various aliases the caller might pass for backend intent.
// New code should pass 'gpu' or 'cpu' directly; the legacy ORT-Web strings
// 'webgpu' and 'wasm' are still accepted so a half-migrated UI keeps working.
function normalizeIntent(value) {
if (value === 'webgpu' || value === 'gpu') return 'gpu';
if (value === 'wasm' || value === 'cpu') return 'cpu';
return 'cpu';
}
function yieldToEventLoop() {
return new Promise(resolve => {
const ch = new MessageChannel();
ch.port1.onmessage = resolve;
ch.port2.postMessage(undefined);
});
}
function clampCoord(v, max) {
if (v < 0) return 0;
if (v > max) return max;
return v;
}
/**
* Extract a tile from ImageData as Float32 in CHW layout
* (channels-first: [R plane, G plane, B plane]), with edge replication padding.
*
* @param {number} valueScale - multiply each pixel byte by this (e.g. 1/255 for [0,1] output)
*/
function extractTileNCHW(imageData, tx, ty, tw, th, padW, padH, valueScale) {
const { data, width } = imageData;
const out = new Float32Array(3 * padH * padW);
const planeSize = padH * padW;
const maxX = tx + tw - 1;
const maxY = ty + th - 1;
for (let row = 0; row < padH; row++) {
for (let col = 0; col < padW; col++) {
const srcX = clampCoord(tx + col, maxX);
const srcY = clampCoord(ty + row, maxY);
const srcIdx = (srcY * width + srcX) * 4;
const dstIdx = row * padW + col;
out[dstIdx] = data[srcIdx] * valueScale;
out[planeSize + dstIdx] = data[srcIdx + 1] * valueScale;
out[2 * planeSize + dstIdx] = data[srcIdx + 2] * valueScale;
}
}
return out;
}
/**
* Extract a tile from ImageData as Float32 in HWC layout
* (channels-last: [R,G,B, R,G,B, ...]), with edge replication padding.
*/
function extractTileNHWC(imageData, tx, ty, tw, th, padW, padH, valueScale) {
const { data, width } = imageData;
const out = new Float32Array(padH * padW * 3);
const maxX = tx + tw - 1;
const maxY = ty + th - 1;
for (let row = 0; row < padH; row++) {
for (let col = 0; col < padW; col++) {
const srcX = clampCoord(tx + col, maxX);
const srcY = clampCoord(ty + row, maxY);
const srcIdx = (srcY * width + srcX) * 4;
const dstIdx = (row * padW + col) * 3;
out[dstIdx] = data[srcIdx] * valueScale;
out[dstIdx + 1] = data[srcIdx + 1] * valueScale;
out[dstIdx + 2] = data[srcIdx + 2] * valueScale;
}
}
return out;
}
/**
* Convert CHW float32 data (channels-first: [R plane, G plane, B plane])
* back into an RGBA ImageData. Inverse of extractTileCHW.
*
* @param {number} valueScale - multiply each CHW value by this to get [0,255] bytes
*/
function chwToImageData(chwData, width, height, valueScale) {
const imgData = new ImageData(width, height);
const px = imgData.data;
const planeSize = width * height;
for (let row = 0; row < height; row++) {
for (let col = 0; col < width; col++) {
const srcIdx = row * width + col;
const dstIdx = srcIdx * 4;
px[dstIdx] = clampByte(chwData[srcIdx] * valueScale);
px[dstIdx + 1] = clampByte(chwData[planeSize + srcIdx] * valueScale);
px[dstIdx + 2] = clampByte(chwData[2 * planeSize + srcIdx] * valueScale);
px[dstIdx + 3] = 255;
}
}
return imgData;
}
/**
* Convert HWC float32 data (channels-last: [R,G,B, R,G,B, …] per pixel)
* into an RGBA ImageData. Used when the WebGPU EP returns NHWC-ordered output.
*/
function hwcToImageData(hwcData, width, height, valueScale) {
const imgData = new ImageData(width, height);
const px = imgData.data;
for (let row = 0; row < height; row++) {
for (let col = 0; col < width; col++) {
const srcIdx = (row * width + col) * 3;
const dstIdx = (row * width + col) * 4;
px[dstIdx] = clampByte(hwcData[srcIdx] * valueScale);
px[dstIdx + 1] = clampByte(hwcData[srcIdx + 1] * valueScale);
px[dstIdx + 2] = clampByte(hwcData[srcIdx + 2] * valueScale);
px[dstIdx + 3] = 255;
}
}
return imgData;
}
// ---------------------------------------------------------------------------
// fp16 packing — ORT-Web fp16 tensors take/return a Uint16Array of IEEE-754
// binary16 bit patterns. We use the platform's Float16Array when available
// (Chromium 122+, Safari 18.2+) and fall back to a manual packer otherwise.
// fp16 only kicks in when the model is declared fp16; everything else stays
// fp32 with no extra work.
// ---------------------------------------------------------------------------
const HAS_NATIVE_FLOAT16 = typeof globalThis.Float16Array === 'function';
function packFloat32ToFloat16Bits(f32) {
if (HAS_NATIVE_FLOAT16) {
const f16 = new globalThis.Float16Array(f32);
return new Uint16Array(f16.buffer, f16.byteOffset, f16.length);
}
const out = new Uint16Array(f32.length);
// f32arr is a Float32Array; reinterpret as Uint32 to read bit fields.
const u32 = new Uint32Array(f32.buffer, f32.byteOffset, f32.length);
for (let i = 0; i < f32.length; i++) {
const x = u32[i];
const sign = (x >>> 16) & 0x8000;
const expRaw = (x >>> 23) & 0xff;
const mantissa = x & 0x7fffff;
let exp = expRaw - 127 + 15;
if (expRaw === 0xff) {
out[i] = sign | 0x7c00 | (mantissa ? 0x200 : 0);
} else if (exp >= 31) {
out[i] = sign | 0x7c00;
} else if (exp <= 0) {
if (exp < -10) {
out[i] = sign;
} else {
const m = mantissa | 0x800000;
out[i] = sign | (m >>> (14 - exp));
}
} else {
out[i] = sign | (exp << 10) | (mantissa >>> 13);
}
}
return out;
}
function unpackFloat16BitsToFloat32(u16) {
if (HAS_NATIVE_FLOAT16) {
const f16 = new globalThis.Float16Array(u16.buffer, u16.byteOffset, u16.length);
return new Float32Array(f16);
}
const out = new Float32Array(u16.length);
const u32 = new Uint32Array(out.buffer);
for (let i = 0; i < u16.length; i++) {
const h = u16[i];
const sign = (h & 0x8000) << 16;
const exp = (h >> 10) & 0x1f;
const mantissa = h & 0x3ff;
if (exp === 0) {
if (mantissa === 0) {
u32[i] = sign;
} else {
let e = -14;
let m = mantissa;
while (!(m & 0x400)) { m <<= 1; e--; }
m &= 0x3ff;
u32[i] = sign | ((e + 127) << 23) | (m << 13);
}
} else if (exp === 0x1f) {
u32[i] = sign | 0x7f800000 | (mantissa << 13);
} else {
u32[i] = sign | ((exp - 15 + 127) << 23) | (mantissa << 13);
}
}
return out;
}
// TODO(ort-web): remove this whole block when ORT-Web fixes
// program-manager.ts normalizeDispatchGroupSize, which today does a lossy
// rewrite of dispatch shape (X, 1, 1) → (sqrt(X), sqrt(X), 1) and breaks
// the (X=col, Y=row) contract Conv2DMatMul and MatMul shaders expect.
//
// What goes wrong: ORT reshuffles whenever X > maxComputeWorkgroupsPerDimension
// (65535). Conv2DMatMul/MatMul then treat the synthesised Y as a row-tile
// index, the row-bounds guard rejects ~99% of writes, and the output
// buffer is left mostly uninitialised — visible as scrambled output for
// any model whose post-PixelShuffle activation has H*W > ~2.1M pixels.
//
// Workaround: monkey-patch device.createShaderModule to recover the
// effective column from both workgroup ids when dim_a_outer is small
// enough that the original dispatch Y was 1.
const WGPU_DISPATCH_FIX_INSTALLED = Symbol.for('updraft.wgslDispatchOverflowFix');
const CONV2D_MM_FIND =
'let globalRowStart = i32(workgroupId.y) * 32;\n' +
' let globalColStart = i32(workgroupId.x) * 32;';
const CONV2D_MM_REPLACE =
'let p_isSmallA = uniforms.dim_a_outer <= 32;\n' +
' let p_totalCols = (u32(uniforms.dim_b_outer) + 31u) / 32u;\n' +
' let p_dispatchX = u32(ceil(sqrt(f32(p_totalCols))));\n' +
' let p_effectiveCol = workgroupId.x + workgroupId.y * p_dispatchX;\n' +
' let globalRowStart = select(i32(workgroupId.y) * 32, 0, p_isSmallA);\n' +
' let globalColStart = select(i32(workgroupId.x) * 32, i32(p_effectiveCol) * 32, p_isSmallA);';
const MATMUL_FIND =
'let globalRow =i32(globalId.y) * rowPerThread;\n' +
' let globalCol = i32(globalId.x);';
const MATMUL_REPLACE =
'let p_isSmallA = uniforms.dim_a_outer <= 8;\n' +
' let p_totalVecCols = (u32(uniforms.dim_b_outer) + 31u) / 32u;\n' +
' let p_dispatchX = u32(ceil(sqrt(f32(p_totalVecCols))));\n' +
' let p_effectiveWgX = workgroupId.x + workgroupId.y * p_dispatchX;\n' +
' let globalRow = select(i32(globalId.y) * rowPerThread, i32(localId.y) * rowPerThread, p_isSmallA);\n' +
' let globalCol = select(i32(globalId.x), i32(p_effectiveWgX) * 8 + i32(localId.x), p_isSmallA);';
function patchWGSLForDispatchOverflow(code, label) {
if (label === 'Conv2DMatMul' && code.includes(CONV2D_MM_FIND)) {
return code.split(CONV2D_MM_FIND).join(CONV2D_MM_REPLACE);
}
if (label === 'MatMul' && code.includes(MATMUL_FIND)) {
return code.split(MATMUL_FIND).join(MATMUL_REPLACE);
}
return code;
}
function installWebGPUDispatchFix(device) {
if (!device || device[WGPU_DISPATCH_FIX_INSTALLED]) return false;
const origCreate = device.createShaderModule.bind(device);
device.createShaderModule = (descriptor) => {
const patched = patchWGSLForDispatchOverflow(descriptor.code, descriptor.label || '');
if (patched === descriptor.code) return origCreate(descriptor);
return origCreate({ ...descriptor, code: patched });
};
device[WGPU_DISPATCH_FIX_INSTALLED] = true;
return true;
}
export class UpscalerEngine {
#session = null;
#modelBuffer = null;
#modelUrl;
#scale;
#overlap;
#modelValueRange;
#modelLayout;
#modelInputMultiple;
#modelPrecision;
#upscaleBefore;
#tileBlend;
#profiling = false;
// What the user actually got: a label like 'web-webgpu', 'web-wasm',
// 'native-coreml/MLProgram', 'native-cpu'. Set by loadSession on every
// successful load AND kept current by #backendListener for the rest of
// the session — without that, runtime EP fallbacks (e.g. native worker
// drops from CoreML to CPU mid-tile) would leave it stale and the
// loadModel early-return path would mis-announce on the next run.
#realizedBackend = null;
#backendListener = null;
// What the caller asked for ('gpu' | 'cpu'). The loadModel short-circuit
// keys off this so the engine doesn't pointlessly reload when the user
// re-runs with the same intent.
#intent = null;
#device = null;
#gpuRenderer = null;
#gpuExtractor = null;
constructor({
modelUrl,
scale = DEFAULT_SCALE,
overlap = DEFAULT_OVERLAP,
modelValueRange = 1,
modelLayout = 'nchw',
modelInputMultiple = 1,
modelPrecision = 'fp32',
// upscaleBefore=true: the model operates in HR pixel space (e.g. a
// refiner that takes a pre-upsampled LR image and returns an HR image
// at the SAME resolution). Tile coordinates and modelInputMultiple
// stay in LR-pixel units (consistent with regular SR models advertised
// with scale > 1); the engine bicubic-upsamples LR->HR before tile
// extraction and multiplies extraction coords by `scale` so the
// backend sees HR tensors. All GPU fast paths remain viable — they're
// coordinate-agnostic.
upscaleBefore = false,
// tileBlend='gaussian' replaces the default half-overlap hard crop
// with float32 Gaussian-weighted accumulation. Use for diffusion-
// style models with visible tile seams. Costs ~16 bytes/HR-pixel
// working memory and forces the CPU readback path (the GPU output
// renderer writes directly to the bgra8unorm canvas surface, which
// can't host the float32 accumulator).
tileBlend = 'overlapCrop',
profile = false,
}) {
this.#modelUrl = modelUrl;
this.#scale = scale;
this.#overlap = overlap;
this.#modelValueRange = modelValueRange;
this.#modelLayout = modelLayout === 'nhwc' ? 'nhwc' : 'nchw';
this.#modelInputMultiple = Number.isFinite(modelInputMultiple) ? Math.max(1, Math.floor(modelInputMultiple)) : 1;
this.#modelPrecision = modelPrecision === 'fp16' ? 'fp16' : 'fp32';
this.#upscaleBefore = !!upscaleBefore;
this.#tileBlend = tileBlend === 'gaussian' ? 'gaussian' : 'overlapCrop';
this.#profiling = profile;
}
get scale() { return this.#scale; }
// The realized backend label (e.g. 'web-webgpu', 'native-coreml/MLProgram').
// For UI display via friendlyBackend; not used for identity checks.
get realizedBackend() { return this.#realizedBackend; }
// The user's load intent ('gpu' | 'cpu'). EnginePool and loadModel both
// key off this for "do we already have the right session?" decisions.
get intent() { return this.#intent; }
get isLoaded() { return this.#session !== null; }
get profiling() { return this.#profiling; }
set profiling(v) { this.#profiling = !!v; }
get modelPrecision() { return this.#modelPrecision; }
async loadModel(intent = 'cpu', onProgress) {
if (onProgress != null && typeof onProgress !== 'function') {
console.warn('[UpscalerEngine] Ignoring non-function onProgress callback.', {
type: typeof onProgress,
value: onProgress,
intent,
});
}
intent = normalizeIntent(intent);
const report = typeof onProgress === 'function' ? onProgress : null;
if (this.#session && this.#intent === intent) {
// Reusing the existing session; re-announce so per-run backend
// trackers (status bar's "Done via X" line) record a success this run.
if (this.#realizedBackend) {
dispatchBackendEvent({ kind: 'success', backend: this.#realizedBackend });
}
return;
}
this.#releaseSession();
if (!this.#modelBuffer) {
this.#modelBuffer = await fetchWithProgress(this.#modelUrl, report);
}
report?.(1, 'Loading model into runtime\u2026');
// The GPU fast paths (zero-copy input extract via GpuFrameExtractor and
// zero-readback output render via GpuTileRenderer) both assume fp32
// storage buffers in their WGSL shaders. fp16 models go through the
// standard CPU readback path: ONNX still runs on the GPU, but the tile
// tensors round-trip through the CPU as Uint16 bit patterns. We make the
// initial decision from the configured precision, then re-validate after
// the session is created (the model's declared input dtype is the source
// of truth — see comment below).
let canUseGpuFastPath =
this.#modelPrecision !== 'fp16' &&
this.#modelLayout === 'nchw' &&
this.#modelInputMultiple === 1;
const sessionLoadOpts = { profile: this.#profiling };
if (intent === 'gpu' && canUseGpuFastPath) {
sessionLoadOpts.preferredOutputLocation = 'gpu-buffer';
}
// loadSession picks between native (desktop bridge) and web (ort-web)
// based on whether __nativeOrt is exposed; it dispatches its own
// attempt/fallback/success backend-events so we don't need to here.
const { session, realizedBackend } = await loadSession(this.#modelBuffer, intent, sessionLoadOpts);
this.#session = session;
this.#intent = intent;
this.#realizedBackend = realizedBackend;
this.#trackRealizedBackend();
// Self-correct modelPrecision from the model's declared input dtype.
// Stale custom-model records (e.g. uploaded before fp16 support existed,
// or before the inspector started reading the right metadata field)
// can carry the wrong precision; the model graph itself doesn't lie.
// Without this, the engine would build fp32 tensors for an fp16 model
// and ORT would throw "Unexpected input data type" at the first run.
const sessionInputName = this.#session.inputNames?.[0];
const sessionInMeta = readMetaEntry(this.#session.inputMetadata, sessionInputName, 0);
const declaredInputType = sessionInMeta?.type;
const detectedPrecision = isFp16InputType(declaredInputType)
? 'fp16'
: 'fp32';
if (detectedPrecision !== this.#modelPrecision) {
console.warn(
`[UpscalerEngine] Configured precision (${this.#modelPrecision}) disagrees with the model's declared input dtype (${declaredInputType}); using ${detectedPrecision}. ` +
`If this is a saved custom model, edit it and set Precision = ${detectedPrecision} to make this explicit.`,
);
this.#modelPrecision = detectedPrecision;
canUseGpuFastPath =
this.#modelPrecision !== 'fp16' &&
this.#modelLayout === 'nchw' &&
this.#modelInputMultiple === 1;
}
if (this.#realizedBackend === 'web-webgpu') {
const ort = globalThis.ort;
try {
this.#device = await ort.env.webgpu.device;
// installWebGPUDispatchFix is idempotent across model loads. If we
// install it here for the first time, the just-created session's
// shader modules were compiled by ORT-Web BEFORE the wrapper
// existed — release and recreate so all shaders go through it now.
if (installWebGPUDispatchFix(this.#device)) {
await this.#session.release();
const reloaded = await loadSession(this.#modelBuffer, intent, sessionLoadOpts);
this.#session = reloaded.session;
this.#realizedBackend = reloaded.realizedBackend;
}
if (canUseGpuFastPath) {
this.#gpuRenderer = new GpuTileRenderer(this.#device);
}
if (canUseGpuFastPath && typeof ort.Tensor.fromGpuBuffer === 'function') {
this.#gpuExtractor = new GpuFrameExtractor(this.#device);
}
} catch (err) {
console.warn('[UpscalerEngine] GPU pipeline init failed, using CPU fallback:', err);
this.#device = null;
this.#gpuRenderer = null;
this.#gpuExtractor = null;
}
}
this.#modelBuffer = null;
report?.(1, 'Model loaded.');
}
async upscale(img, tileSize, { onTile, signal } = {}) {
if (!this.#session) throw new Error('Model not loaded — call loadModel() first');
const perf = {
setup: 0,
extract: 0,
inference: 0,
inferenceEstimated: 0,
readback: 0,
gpuRender: 0,
writeTile: 0,
dispose: 0,
total: 0,
};
const tTotal = performance.now();
const scale = this.#scale;
const overlap = this.#overlap;
const srcW = img.videoWidth ?? img.width;
const srcH = img.videoHeight ?? img.height;
const outW = srcW * scale;
const outH = srcH * scale;
const gaussianBlend = this.#tileBlend === 'gaussian';
// The GPU output renderer writes via fragment shader directly to the
// canvas's bgra8unorm surface, so it can't host the float32
// accumulator Gaussian blending needs. Force the CPU readback path.
let useGpu = this.#gpuRenderer !== null && !gaussianBlend;
const useGpuInput = this.#gpuExtractor !== null;
// In upscaleBefore mode the model takes HR-sized tiles, so we
// multiply all input-side tile coords/dims by `scale`. The output
// side already uses HR coords (tx*scale, outTW=tw*scale, …) for
// every model, so the GPU output renderer needs no changes.
const pixelScale = this.#upscaleBefore ? scale : 1;
// Gaussian accumulator buffers + a per-tile-size weight cache. The
// accumRGB stores values in [0, 255] before clamping (matching what
// the existing chwToImageData decoder produces), so finalize can do
// a single divide+clamp+pack pass.
const accumRGB = gaussianBlend ? new Float32Array(3 * outW * outH) : null;
const accumW = gaussianBlend ? new Float32Array(outW * outH) : null;
const gaussWeightCache = gaussianBlend ? new Map() : null;
// The WebGPU canvas surface and the renderer's persistent output texture
// are both bounded by maxTextureDimension2D (commonly 8192, sometimes
// 16384). Exceeding this cap doesn't throw — WebGPU pushes a validation
// error and the canvas stays black — so we proactively fall back to the
// CPU readback path whenever the destination is too big. ONNX inference
// continues to run on WebGPU; only the output rendering path changes.
if (useGpu) {
const maxDim = this.#device?.limits?.maxTextureDimension2D ?? 8192;
if (outW > maxDim || outH > maxDim) {
console.info(
`[UpscalerEngine] Output ${outW}\u00d7${outH} exceeds GPU max texture dimension ${maxDim}; using CPU readback path for this image.`,
);
useGpu = false;
}
}
// For upscaleBefore models, pre-rasterize an HR bicubic upsample on
// a 2D canvas and use that as the source for tile extraction. The
// GPU extractor and CPU getImageData paths both accept any canvas;
// they just see a larger texture/ImageData with HR coordinates.
let extractImg = img;
let extractW = srcW;
let extractH = srcH;
if (this.#upscaleBefore) {
const hrCanvas = document.createElement('canvas');
hrCanvas.width = outW;
hrCanvas.height = outH;
const hrCtx = hrCanvas.getContext('2d');
hrCtx.imageSmoothingEnabled = true;
hrCtx.imageSmoothingQuality = 'high';
hrCtx.drawImage(img, 0, 0, outW, outH);
extractImg = hrCanvas;
extractW = outW;
extractH = outH;
}
const srcData = this.#prepareSource(extractImg, extractW, extractH, useGpuInput, perf);
const outCanvas = document.createElement('canvas');
outCanvas.width = outW;
outCanvas.height = outH;
let outCtx = null;
if (useGpu) {
try {
this.#gpuRenderer.configure(outCanvas, outW, outH);
} catch (err) {
if (err instanceof GpuOutputTooLargeError) {
console.info(
`[UpscalerEngine] ${err.message} Using CPU readback path for this image.`,
);
} else {
console.warn(
'[UpscalerEngine] GPU canvas configure failed, falling back to CPU readback:',
err,
);
}
useGpu = false;
}
}
if (!useGpu) {
outCtx = outCanvas.getContext('2d');
}
const tiles = buildTileGrid(srcW, srcH, tileSize, overlap);
const inputName = this.#session.inputNames[0];
const outputName = this.#session.outputNames[0];
if (this.#profiling) try { this.#session.startProfiling(); } catch {}
let firstInferAt = 0;
let callbackMs = 0;
let yieldMs = 0;
for (let i = 0; i < tiles.length; i++) {
if (signal?.aborted) throw new DOMException('Upscale cancelled', 'AbortError');
const rendererLost = useGpu && this.#gpuRenderer?.lost;
const extractorLost = useGpuInput && this.#gpuExtractor?.lost;
if (rendererLost || extractorLost) {
throw new Error('GPU device was lost (browser or OS interrupted). Please retry or switch to the WASM backend.');
}
const { x: tx, y: ty, w: tw, h: th } = tiles[i];
const paddedTW = this.#alignToMultiple(tw);
const paddedTH = this.#alignToMultiple(th);
const tExtract = performance.now();
// In upscaleBefore mode the source canvas is HR-sized, so extraction
// coords/dims scale up. Equality checks against the LR-side padded
// values are unaffected (both sides scale by the same factor).
const tensor = this.#createTileTensor(
srcData,
tx * pixelScale,
ty * pixelScale,
tw * pixelScale,
th * pixelScale,
paddedTW * pixelScale,
paddedTH * pixelScale,
useGpuInput && paddedTW === tw && paddedTH === th,
);
const extractMs = performance.now() - tExtract;
perf.extract += extractMs;
const tInfer = performance.now();
if (!firstInferAt) firstInferAt = tInfer;
const results = await this.#session.run({ [inputName]: tensor });
const inferenceMs = performance.now() - tInfer;
perf.inference += inferenceMs;
const outTW = tw * scale;
const outTH = th * scale;
const outPaddedTW = paddedTW * scale;
const outPaddedTH = paddedTH * scale;
let renderMs = 0, readbackMs = 0;
if (useGpu && outPaddedTW === outTW && outPaddedTH === outTH) {
const tGpu = performance.now();
this.#gpuRenderer.renderTile(
results[outputName].gpuBuffer, outTW, outTH,
tx * scale, ty * scale, overlap * scale, 1 / this.#modelValueRange,
);
this.#gpuRenderer.presentToCanvas();
renderMs = performance.now() - tGpu;
perf.gpuRender += renderMs;
} else {
const tReadback = performance.now();
const outTensor = results[outputName];
// When the session was opened with preferredOutputLocation:'gpu-buffer'
// (gpu fast path enabled at load time) but we're falling back per-image
// because the destination canvas would exceed maxTextureDimension2D,
// the tensor lives on the GPU and `.data` is empty. getData(true)
// downloads the data and releases the GPU buffer in one step.
const rawOutData = outTensor.location === 'gpu-buffer'
? await outTensor.getData(true)
: outTensor.data;
// fp16 output tensors expose a Uint16Array of bit patterns; unpack
// to Float32 once so the existing CHW/HWC decoders can stay fp32.
const outData = outTensor.type === 'float16'
? unpackFloat16BitsToFloat32(rawOutData)
: rawOutData;
readbackMs = performance.now() - tReadback;
perf.readback += readbackMs;
const tWrite = performance.now();
const dims = outTensor.dims;
const isNHWC = dims.length === 4 && dims[3] === 3 && dims[1] !== 3;
if (gaussianBlend) {
// Look up / build the weight kernel for this tile's HR-side
// content dimensions (outTH, outTW). Edge tiles can be smaller
// than interior tiles; cache by key.
const key = `${outTH}x${outTW}`;
let weights = gaussWeightCache.get(key);
if (!weights) {
weights = makeGaussianWeights2D(outTH, outTW);
gaussWeightCache.set(key, weights);
}
const valueScale = 255 / this.#modelValueRange;
accumulateGaussianTile(
accumRGB, accumW, outW, outH,
outData, outPaddedTW, outPaddedTH,
outTW, outTH, tx * scale, ty * scale,
weights, valueScale, isNHWC ? 'hwc' : 'chw',
);
// Finalize just the rectangle this tile touched so the user
// sees progressive preview. Overlapping tiles will rewrite
// their shared region as they accumulate; the last tile to
// touch a pixel writes the same value a final full-canvas
// finalize would.
finalizeGaussianRegion(
outCtx, tx * scale, ty * scale, outTW, outTH,
outW, outH, accumRGB, accumW,
);
} else {
const decode = isNHWC ? hwcToImageData : chwToImageData;
const paddedImgData = decode(outData, outPaddedTW, outPaddedTH, 255 / this.#modelValueRange);
const imgData = outPaddedTW === outTW && outPaddedTH === outTH
? paddedImgData
: this.#cropImageData(paddedImgData, outTW, outTH);
pasteTileCropped(outCtx, imgData, tx * scale, ty * scale, outW, outH, overlap * scale);
}
renderMs = performance.now() - tWrite;
perf.writeTile += renderMs;
}
const tDispose = performance.now();
tensor.dispose();
results[outputName].dispose();
const disposeMs = performance.now() - tDispose;
perf.dispose += disposeMs;
const crop = overlapCrop(tx * scale, ty * scale, outTW, outTH, outW, outH, overlap * scale);
const tCallback = performance.now();
onTile?.({
index: i, total: tiles.length, tileMs: inferenceMs, tilePixels: tw * th,
canvas: outCanvas, outX: tx * scale, outY: ty * scale, outW: outTW, outH: outTH,
crop,
perf: { extractMs, inferenceMs, readbackMs, renderMs, disposeMs },
});
callbackMs += performance.now() - tCallback;
const tYield = performance.now();
await yieldToEventLoop();
yieldMs += performance.now() - tYield;
}
if (useGpu) {
this.#gpuRenderer.presentToCanvas();
await this.#waitForGpuWork();
}
const tDone = performance.now();
perf.total = tDone - tTotal;
if (useGpu && firstInferAt) {
const gpuSpanMs = tDone - firstInferAt;
const otherTrackedMs =
perf.extract +
perf.gpuRender +
perf.readback +
perf.writeTile +
perf.dispose +
callbackMs +
yieldMs;
perf.inferenceEstimated = Math.max(0, gpuSpanMs - otherTrackedMs);
}
let ortProfile = null;
if (this.#profiling) {
ortProfile = this.#collectOrtProfile();
}
// 'gpu-gpu' = GPU input extract + GPU output render (zero readback).
// 'gpu' = ONNX runs on GPU but at least one of input/output uses CPU
// (e.g., per-image fallback when output exceeds maxTexDim).
// 'cpu' = WASM/CPU end-to-end.
const pipeline = useGpu && useGpuInput
? 'gpu-gpu'
: (useGpu || useGpuInput) ? 'gpu' : 'cpu';
return {
canvas: outCanvas,
perf: { ...perf, tiles: tiles.length, tileSize, srcW, srcH, outW, outH, pipeline },
ortProfile,
};
}
destroy() {
this.#releaseSession();
this.#modelBuffer = null;
}
#releaseSession() {
this.#untrackRealizedBackend();
this.#gpuRenderer?.destroy();
this.#gpuRenderer = null;
this.#gpuExtractor?.destroy();
this.#gpuExtractor = null;
this.#device = null;
try { this.#session?.release(); } catch {}
this.#session = null;
this.#realizedBackend = null;
this.#intent = null;
}
// While a session is alive, follow runtime backend changes via the
// backend-event channel — the native worker can fall back from CoreML to
// CPU between tiles, and that's the only signal we get. Without this the
// next loadModel-early-return announces a stale realizedBackend.
#trackRealizedBackend() {
if (this.#backendListener) return;
this.#backendListener = (e) => {
const d = e?.detail;
if (d && d.kind === 'success' && typeof d.backend === 'string') {
this.#realizedBackend = d.backend;
}
};
document.addEventListener('aitools:backend-event', this.#backendListener);
}
#untrackRealizedBackend() {
if (!this.#backendListener) return;
document.removeEventListener('aitools:backend-event', this.#backendListener);
this.#backendListener = null;
}
#prepareSource(img, srcW, srcH, useGpuInput, perf) {
const tSetup = performance.now();
let srcData = null;
if (useGpuInput) {
this.#gpuExtractor.uploadFrame(img, srcW, srcH);
} else {
const tmpC = document.createElement('canvas');
tmpC.width = srcW;
tmpC.height = srcH;
const tmpCtx = tmpC.getContext('2d');
tmpCtx.drawImage(img, 0, 0);
srcData = tmpCtx.getImageData(0, 0, srcW, srcH);
tmpC.width = 0;
tmpC.height = 0;
}
perf.setup = performance.now() - tSetup;
return srcData;
}
#alignToMultiple(value) {
const m = this.#modelInputMultiple;
if (!Number.isFinite(m) || m <= 1) return value;
return Math.ceil(value / m) * m;
}
#cropImageData(imgData, width, height) {
if (imgData.width === width && imgData.height === height) return imgData;
const out = new ImageData(width, height);
const src = imgData.data;
const dst = out.data;
const srcStride = imgData.width * 4;
const dstStride = width * 4;
for (let row = 0; row < height; row++) {
const srcStart = row * srcStride;
const dstStart = row * dstStride;
dst.set(src.subarray(srcStart, srcStart + dstStride), dstStart);
}
return out;
}
#createTileTensor(srcData, tx, ty, tw, th, paddedTW, paddedTH, useGpuInput) {
const ort = globalThis.ort;
if (useGpuInput) {
// GPU input fast path is gated to fp32 in loadModel(), so we only
// reach here when the model is fp32.
const gpuBuf = this.#gpuExtractor.extractTile(tx, ty, tw, th, this.#modelValueRange);
return ort.Tensor.fromGpuBuffer(gpuBuf, {
dataType: 'float32',
dims: [1, 3, th, tw],
dispose: () => {},
});
}
const isNHWC = this.#modelLayout === 'nhwc';
const extract = isNHWC ? extractTileNHWC : extractTileNCHW;
const dims = isNHWC ? [1, paddedTH, paddedTW, 3] : [1, 3, paddedTH, paddedTW];
const f32 = extract(
srcData,
tx,
ty,
tw,
th,
paddedTW,
paddedTH,
this.#modelValueRange / 255,
);
if (this.#modelPrecision === 'fp16') {
const u16 = packFloat32ToFloat16Bits(f32);
return new ort.Tensor('float16', u16, dims);
}
return new ort.Tensor('float32', f32, dims);
}
async #waitForGpuWork() {
try {
if (this.#device?.queue?.onSubmittedWorkDone) {
await this.#device.queue.onSubmittedWorkDone();
}
} catch {
// Ignore sync failures and let the caller continue.
}
}
/**
* Capture ORT's profiling output (logged to console by endProfiling)
* and return it as structured data instead of formatted strings.
*/
#collectOrtProfile() {
const captured = [];
const origLog = console.log;
const origWarn = console.warn;
const intercept = (...args) => captured.push(args.join(' '));
console.log = intercept;
console.warn = intercept;
try { this.#session.endProfiling(); } catch {}
console.log = origLog;
console.warn = origWarn;
if (!captured.length) return null;
let events;
try {
const raw = captured.join('\n');
events = JSON.parse(raw.substring(raw.indexOf('['), raw.lastIndexOf(']') + 1));
} catch { return null; }
const nodes = events.filter(e => e.cat === 'Node');
const runs = events.filter(e => e.name === 'model_run');
if (!nodes.length) return null;
const gpuOps = {}, cpuOps = {};
let toHostUs = 0, toHostN = 0, fromHostUs = 0, fromHostN = 0;
for (const n of nodes) {
const op = n.args?.op_name ?? n.name;
const us = n.dur ?? 0;
if (op === 'MemcpyToHost') { toHostUs += us; toHostN++; continue; }
if (op === 'MemcpyFromHost') { fromHostUs += us; fromHostN++; continue; }
const bucket = n.args?.provider === 'CPUExecutionProvider' ? cpuOps : gpuOps;
bucket[op] ??= { us: 0, n: 0 };
bucket[op].us += us;
bucket[op].n++;
}
return {
runs: runs.length,
modelRunUs: runs.reduce((s, r) => s + (r.dur ?? 0), 0),
gpuOps,
cpuOps,
memcpy: {
toHost: { us: toHostUs, n: toHostN },
fromHost: { us: fromHostUs, n: fromHostN },
},
};
}
}