| |
| |
| |
| |
| |
|
|
| 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; |
| } |
|
|
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| 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); |
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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; |
| |
| |
| |
| |
| |
| |
| #realizedBackend = null; |
| #backendListener = null; |
| |
| |
| |
| #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; } |
| |
| |
| get realizedBackend() { return this.#realizedBackend; } |
| |
| |
| 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) { |
| |
| |
| 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'); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| let canUseGpuFastPath = |
| this.#modelPrecision !== 'fp16' && |
| this.#modelLayout === 'nchw' && |
| this.#modelInputMultiple === 1; |
| const sessionLoadOpts = { profile: this.#profiling }; |
| if (intent === 'gpu' && canUseGpuFastPath) { |
| sessionLoadOpts.preferredOutputLocation = 'gpu-buffer'; |
| } |
|
|
| |
| |
| |
| const { session, realizedBackend } = await loadSession(this.#modelBuffer, intent, sessionLoadOpts); |
| this.#session = session; |
| this.#intent = intent; |
| this.#realizedBackend = realizedBackend; |
| this.#trackRealizedBackend(); |
|
|
| |
| |
| |
| |
| |
| |
| 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; |
| |
| |
| |
| |
| 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'; |
| |
| |
| |
| let useGpu = this.#gpuRenderer !== null && !gaussianBlend; |
| const useGpuInput = this.#gpuExtractor !== null; |
|
|
| |
| |
| |
| |
| const pixelScale = this.#upscaleBefore ? scale : 1; |
|
|
| |
| |
| |
| |
| const accumRGB = gaussianBlend ? new Float32Array(3 * outW * outH) : null; |
| const accumW = gaussianBlend ? new Float32Array(outW * outH) : null; |
| const gaussWeightCache = gaussianBlend ? new Map() : null; |
|
|
| |
| |
| |
| |
| |
| |
| 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; |
| } |
| } |
|
|
| |
| |
| |
| |
| 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(); |
| |
| |
| |
| 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]; |
| |
| |
| |
| |
| |
| const rawOutData = outTensor.location === 'gpu-buffer' |
| ? await outTensor.getData(true) |
| : outTensor.data; |
| |
| |
| 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) { |
| |
| |
| |
| 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', |
| ); |
| |
| |
| |
| |
| |
| 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(); |
| } |
|
|
| |
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| #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) { |
| |
| |
| 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 { |
| |
| } |
| } |
|
|
| |
| |
| |
| |
| #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 }, |
| }, |
| }; |
| } |
| } |
|
|