/** * Tiling utilities — dividing images into overlapping tiles and * reassembling them with seam-free stitching. */ /** * Compute the interior region a tile should contribute, trimming overlap * margins at seams. Returns a canvas-space rect { x, y, w, h }. * * Each side touching another tile is trimmed by half the overlap; * edges against the canvas boundary keep their full extent. */ export function overlapCrop(destX, destY, tileW, tileH, canvasW, canvasH, overlap) { const cropL = destX > 0 ? (overlap / 2) | 0 : 0; const cropT = destY > 0 ? (overlap / 2) | 0 : 0; const cropR = (destX + tileW) < canvasW ? (overlap / 2) | 0 : 0; const cropB = (destY + tileH) < canvasH ? (overlap / 2) | 0 : 0; return { x: destX + cropL, y: destY + cropT, w: tileW - cropL - cropR, h: tileH - cropT - cropB, }; } /** * Build the grid of overlapping source tiles that cover the image. * Returns an array of { x, y, w, h } in source-pixel coordinates. */ export function buildTileGrid(srcW, srcH, tileSize, overlap) { const noTiling = tileSize <= 0; const size = noTiling ? Math.max(srcW, srcH) : tileSize; const step = noTiling ? size : size - overlap; const tiles = []; for (let ty = 0; ty < srcH; ty += step) { for (let tx = 0; tx < srcW; tx += step) { tiles.push({ x: tx, y: ty, w: Math.min(size, srcW - tx), h: Math.min(size, srcH - ty), }); } } return tiles; } /** Write an ImageData to the canvas, trimming overlap margins at tile seams. */ export function pasteTileCropped(ctx, imgData, dx, dy, canvasW, canvasH, overlap) { const crop = overlapCrop(dx, dy, imgData.width, imgData.height, canvasW, canvasH, overlap); if (crop.w <= 0 || crop.h <= 0) return; ctx.putImageData(imgData, dx, dy, crop.x - dx, crop.y - dy, crop.w, crop.h); } // ─── Gaussian tile blending ──────────────────────────────────────────── // For diffusion-style refiners (e.g. TinySR) the tile-edge artifacts are // strong enough that the half-overlap hard crop above shows visible seams. // `makeGaussianWeights2D` produces a per-pixel weight kernel matching // TinySR's pipeline.js: variance=0.01 scaled by tile^2 so the shape is // scale-invariant. Engines that opt in maintain a float32 accumulator and // a contribution buffer, then divide once at the end. /** * 2D Gaussian weight kernel for tile blending. Returns a Float32Array of * length tileH*tileW with pixel-space weights (single channel — apply the * same weight to all 3 colour channels at accumulation time). * * Matches the formula in tinysr/tools/web/pipeline.js:makeGaussianWeights * so the visual behaviour ports over directly. */ export function makeGaussianWeights2D(tileH, tileW) { const variance = 0.01; const midX = (tileW - 1) / 2; const midY = tileH / 2; // intentional asymmetry — matches the upstream const denomX = tileW * tileW * 2 * variance; const denomY = tileH * tileH * 2 * variance; const norm = 1 / Math.sqrt(2 * Math.PI * variance); const xs = new Float32Array(tileW); const ys = new Float32Array(tileH); for (let i = 0; i < tileW; i++) xs[i] = norm * Math.exp(-((i - midX) ** 2) / denomX); for (let i = 0; i < tileH; i++) ys[i] = norm * Math.exp(-((i - midY) ** 2) / denomY); const out = new Float32Array(tileH * tileW); for (let y = 0; y < tileH; y++) { const yw = ys[y]; const rowOff = y * tileW; for (let x = 0; x < tileW; x++) out[rowOff + x] = yw * xs[x]; } return out; } /** * Accumulate one model-output tile into the canvas-sized float32 buffers * `accumRGB` (3*outW*outH, RGB-planar) and `accumW` (outW*outH), weighted * by `weights`. `srcRGB` is in [0, modelValueRange]; `layout` is 'chw' for * RGB-planar input or 'hwc' for RGB-interleaved. Crops to the top-left * (tileW × tileH) region if the model output was padded. */ export function accumulateGaussianTile( accumRGB, accumW, outW, outH, srcRGB, srcStrideW, srcStrideH, tileW, tileH, destX, destY, weights, valueScale, layout, ) { const outPlane = outW * outH; const isCHW = layout === 'chw'; const chanStride = isCHW ? srcStrideW * srcStrideH : 1; const colStride = isCHW ? 1 : 3; const rowStride = isCHW ? srcStrideW : srcStrideW * 3; for (let y = 0; y < tileH; y++) { const dy = destY + y; if (dy < 0 || dy >= outH) continue; const wRow = y * tileW; const sRow = y * rowStride; const dRow = dy * outW; for (let x = 0; x < tileW; x++) { const dx = destX + x; if (dx < 0 || dx >= outW) continue; const w = weights[wRow + x]; const srcIdx = sRow + x * colStride; const dstIdx = dRow + dx; accumRGB[dstIdx] += srcRGB[srcIdx] * valueScale * w; accumRGB[outPlane + dstIdx] += srcRGB[srcIdx + chanStride] * valueScale * w; accumRGB[2 * outPlane + dstIdx] += srcRGB[srcIdx + 2 * chanStride] * valueScale * w; accumW[dstIdx] += w; } } } /** * Divide accumulated RGB by weights inside a rectangular region of the * output canvas, clamp to [0,255], and write via putImageData. Called * after each tile accumulates so the user sees progressive preview; the * last tile to touch any given pixel ends up writing the final value * (which is the same value a single full-canvas finalize would produce). * * Clips the region to the canvas bounds, so callers can pass tile-sized * rects without worrying about edge tiles. */ export function finalizeGaussianRegion(ctx, outX, outY, regionW, regionH, outW, outH, accumRGB, accumW) { const x0 = Math.max(0, outX | 0); const y0 = Math.max(0, outY | 0); const x1 = Math.min(outW, (outX + regionW) | 0); const y1 = Math.min(outH, (outY + regionH) | 0); const w = x1 - x0; const h = y1 - y0; if (w <= 0 || h <= 0) return; const imgData = ctx.createImageData(w, h); const px = imgData.data; const plane = outW * outH; for (let y = 0; y < h; y++) { const srcRow = (y0 + y) * outW; const dstRow = y * w; for (let x = 0; x < w; x++) { // accumW is zero only if no tile covered this pixel — shouldn't // happen for pixels reached by this call, but guard against NaN. const srcIdx = srcRow + x0 + x; const wAcc = accumW[srcIdx] || 1; const r = accumRGB[srcIdx] / wAcc; const g = accumRGB[plane + srcIdx] / wAcc; const b = accumRGB[2 * plane + srcIdx] / wAcc; const o = (dstRow + x) * 4; px[o] = r < 0 ? 0 : r > 255 ? 255 : (r + 0.5) | 0; px[o + 1] = g < 0 ? 0 : g > 255 ? 255 : (g + 0.5) | 0; px[o + 2] = b < 0 ? 0 : b > 255 ? 255 : (b + 0.5) | 0; px[o + 3] = 255; } } ctx.putImageData(imgData, x0, y0); }