From 2dac868274e7fbd90185f8cde89b6bf066ebaa70 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Fri, 16 Oct 2026 07:58:38 +0700 Subject: [PATCH] fix(heal): feed crisp photo context to inpaint model and cache patch across zoom levels --- docker/frontend/src/App.tsx | 1 + docker/frontend/src/engine/exportEngine.ts | 27 ++++++-------- docker/frontend/src/engine/inpaint.ts | 42 +++++++++++----------- 3 files changed, 32 insertions(+), 38 deletions(-) diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index a7411fa..7205aec 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -1054,6 +1054,7 @@ export function Workspace() { // compare copy is never a stale render of the last one. useEffect(() => { setComparing(false); + clearInpaintCache(); }, [source]); // The geometry and the stamps a render carries, apart from its recipe: the diff --git a/docker/frontend/src/engine/exportEngine.ts b/docker/frontend/src/engine/exportEngine.ts index fcd5d01..93b62a3 100644 --- a/docker/frontend/src/engine/exportEngine.ts +++ b/docker/frontend/src/engine/exportEngine.ts @@ -1664,22 +1664,14 @@ function clusterHealSpots( ); const cropSurface = own(createSurface(mw, mh)); if (cropSurface) { - // Progressive inpainting: read the crop from fillSurface, which already - // carries the patches of the clusters this pass solved before it, over a - // frame that carries every drag repaired before this one. When repairing - // adjacent objects (e.g. parallel wires), the second wire sees the first - // wire ALREADY cleanly healed, so it never samples the first wire's dirty - // texture or overwrites the first wire's repair. - flush(fillSurface); - const fillSnap = own(fillSurface.makeImageSnapshot()); - if (fillSnap) { - cropSurface.getCanvas().drawImageRect( - fillSnap, - Skia.XYWHRect(srcLeft - boxLeft, srcTop - boxTop, srcW, srcH), - Skia.XYWHRect(0, 0, mw, mh), - own(Skia.Paint()) - ); - } + // Neural inpainting (inpaint-web): read the clean, unblurred original pixels + // from snap so the model is fed crisp scene context without Gaussian pre-blurring. + cropSurface.getCanvas().drawImageRect( + snap, + Skia.XYWHRect(srcLeft, srcTop, srcW, srcH), + Skia.XYWHRect(0, 0, mw, mh), + own(Skia.Paint()) + ); flush(cropSurface); const cropPixels = cropSurface.getCanvas().readPixels(0, 0, { width: mw, @@ -1709,6 +1701,9 @@ function clusterHealSpots( } } } + // Compute normalized patchKey so preview zoom in/out hits cache with 0ms overhead + const patchKey = `${mw}x${mh}_` + cluster.map((s) => `${s.x.toFixed(4)},${s.y.toFixed(4)},${s.r.toFixed(4)}`).join(';'); + (hole as { patchKey?: string }).patchKey = patchKey; const patch = await inpaintCrop(cropPixels, mw, mh, hole).catch(() => null); if (patch) { const patchSurface = own(createSurface(mw, mh)); diff --git a/docker/frontend/src/engine/inpaint.ts b/docker/frontend/src/engine/inpaint.ts index 3bd2230..80ff5d1 100644 --- a/docker/frontend/src/engine/inpaint.ts +++ b/docker/frontend/src/engine/inpaint.ts @@ -505,18 +505,13 @@ export function cropPlate( const plane = w * h; const src = new Uint8Array(3 * plane); const mask = new Uint8Array(plane).fill(255); - const field = rimFill(small, w, h, smallHole, 0); for (let d = 0; d < plane; d++) { const s = d * 4; + src[d] = small[s]; + src[plane + d] = small[s + 1]; + src[2 * plane + d] = small[s + 2]; if (smallHole[d]) { - src[d] = byte(field.r[d]); - src[plane + d] = byte(field.g[d]); - src[2 * plane + d] = byte(field.b[d]); mask[d] = 0; - } else { - src[d] = small[s]; - src[plane + d] = small[s + 1]; - src[2 * plane + d] = small[s + 2]; } } return { src, hole: mask }; @@ -571,22 +566,25 @@ export async function inpaintCrop( w = Math.max(8, Math.floor((w * scale) / 8) * 8); h = Math.max(8, Math.floor((h * scale) / 8) * 8); - // Quick signature to avoid redundant re-inferences on subsequent renders - let holeCount = 0; - let holeHash = 0; - const step = Math.max(1, Math.floor(hole.length / 64)); - for (let i = 0; i < hole.length; i += step) { - if (hole[i]) { - holeCount++; - holeHash = (holeHash * 31 + i) | 0; + // Quick signature to avoid redundant re-inferences on subsequent renders. + // When patchKey is supplied via (hole as any).patchKey or parameter, it is based on + // normalized coordinates and is invariant to zoom in/out preview resamplings. + const patchKey = (hole as { patchKey?: string }).patchKey; + let key: string; + if (patchKey) { + key = patchKey; + } else { + let holeCount = 0; + let holeHash = 0; + const step = Math.max(1, Math.floor(hole.length / 64)); + for (let i = 0; i < hole.length; i += step) { + if (hole[i]) { + holeCount++; + holeHash = (holeHash * 31 + i) | 0; + } } + key = `${w}x${h}_${holeCount}_${holeHash}`; } - let pixHash = 0; - const pixStep = Math.max(4, Math.floor(rgba.length / 64) * 4); - for (let i = 0; i < rgba.length; i += pixStep) { - pixHash = (pixHash * 33 + rgba[i] + rgba[i + 1]) | 0; - } - const key = `${w}x${h}_${holeCount}_${holeHash}_${pixHash}`; if (patchCache.has(key)) { return patchCache.get(key)!.slice();