Spaces:
Running
Running
| /** | |
| * 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 }, | |
| }, | |
| }; | |
| } | |
| } | |