From f6acf3ee6ccaf1069598afbc4cbcd23733083f5a Mon Sep 17 00:00:00 2001 From: 3dtours Date: Wed, 14 Oct 2026 16:29:17 +0700 Subject: [PATCH] fix(heal): eliminate grey wash artifacts by restoring opaque patches and progressive fillSurface cropping --- docker/frontend/src/engine/exportEngine.ts | 68 ++++------------------ docker/frontend/src/engine/inpaint.ts | 14 ++--- docker/recipescam-images.tar.gz.sha256 | 2 +- 3 files changed, 17 insertions(+), 67 deletions(-) diff --git a/docker/frontend/src/engine/exportEngine.ts b/docker/frontend/src/engine/exportEngine.ts index 45f3d6c..dffeb76 100644 --- a/docker/frontend/src/engine/exportEngine.ts +++ b/docker/frontend/src/engine/exportEngine.ts @@ -1318,11 +1318,7 @@ function clusterHealSpots( const xj = sj.x * width; const yj = sj.y * height; const rj = sj.r * width; - // Connect spots that are close enough that their inpainting crops interact - // (e.g. parallel wires, nearby scratches or dust within margin reach). - // Clustering nearby objects together ensures they are inpainted in one unified - // pass where neither object is sampled as "clean background texture" for the other. - const maxDist = Math.max((ri + rj) * 2.5, ri + rj + 48); + const maxDist = (ri + rj) * 1.5; const distSq = (xi - xj) ** 2 + (yi - yj) ** 2; if (distSq <= maxDist * maxDist) { adj[i].push(j); @@ -1496,18 +1492,6 @@ function clusterHealSpots( // Strokes are clustered so that separate brush repairs (e.g. Brush 1 and Brush 2) // are solved independently at their own high resolution rather than lumped into // one downscaled bounding box that would degrade previous repairs. - // Progressive working surface for inpainting: - // When multiple strokes/clusters exist (such as parallel wires or adjacent spots), - // each cluster reads from the surface that already has previous clusters repaired, - // and writes ONLY to its own hole (alpha 0 outside the hole). - // This prevents earlier repaired wires from reappearing and prevents dirty neighboring - // textures from contaminating subsequent inpainting. - const workSurface = own(createSurface(width, height)); - const workCanvas = workSurface.getCanvas(); - if (snap) { - workCanvas.drawImage(snap, 0, 0, own(Skia.Paint())); - } - const clusters = clusterHealSpots(heal, width, height); for (const cluster of clusters) { const cWidest = Math.max(...cluster.map((s) => s.r * width)); @@ -1532,16 +1516,20 @@ function clusterHealSpots( const { w: mw, h: mh } = inpaintCropSize(srcW, srcH); const cropSurface = own(createSurface(mw, mh)); if (cropSurface) { - flush(workSurface); - const workSnap = workSurface.makeImageSnapshot(); - if (workSnap) { + // Progressive inpainting: read the crop from fillSurface, which already + // carries any previous clusters' repaired patches. 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( - workSnap, - Skia.XYWHRect(srcLeft, srcTop, srcW, srcH), + fillSnap, + Skia.XYWHRect(srcLeft - boxLeft, srcTop - boxTop, srcW, srcH), Skia.XYWHRect(0, 0, mw, mh), own(Skia.Paint()) ); - try { workSnap.dispose(); } catch {} } flush(cropSurface); const cropPixels = cropSurface.getCanvas().readPixels(0, 0, { @@ -1555,7 +1543,6 @@ function clusterHealSpots( const kx = mw / srcW; const ky = mh / srcH; const hole = new Uint8Array(mw * mh); - // Mark all spots from the current cluster for (const s of cluster) { const cx = (s.x * width - srcLeft) * kx; const cy = (s.y * height - srcTop) * ky; @@ -1573,35 +1560,8 @@ function clusterHealSpots( } } } - // Also mark any other heal spots falling into this crop box as holes, - // so they can never be sampled as "clean background texture" - for (const s of heal) { - const cx = (s.x * width - srcLeft) * kx; - const cy = (s.y * height - srcTop) * ky; - const rad = s.r * width * kx; - if (cx + rad < 0 || cx - rad >= mw || cy + rad < 0 || cy - rad >= mh) continue; - const x0 = Math.max(0, Math.floor(cx - rad)); - const x1 = Math.min(mw - 1, Math.ceil(cx + rad)); - const y0 = Math.max(0, Math.floor(cy - rad)); - const y1 = Math.min(mh - 1, Math.ceil(cy + rad)); - for (let y = y0; y <= y1; y++) { - const dy = y - cy; - const row = y * mw; - for (let x = x0; x <= x1; x++) { - const dx = x - cx; - if (dx * dx + dy * dy <= rad * rad) hole[row + x] = 1; - } - } - } const patch = await inpaintCrop(cropPixels, mw, mh, hole).catch(() => null); if (patch) { - // Ensure pixels outside hole are 100% transparent so drawing never - // clobbers neighboring clusters or clean background - for (let i = 0; i < mw * mh; i++) { - if (!hole[i]) { - patch[i * 4 + 3] = 0; - } - } const patchSurface = own(createSurface(mw, mh)); if (patchSurface && patchSurface.getCanvas().writePixels(patch, mw, mh, 0, 0)) { flush(patchSurface); @@ -1615,12 +1575,6 @@ function clusterHealSpots( Skia.XYWHRect(srcLeft - boxLeft, srcTop - boxTop, srcW, srcH), patchPaint ); - workCanvas.drawImageRect( - patchImage, - Skia.XYWHRect(0, 0, mw, mh), - Skia.XYWHRect(srcLeft, srcTop, srcW, srcH), - patchPaint - ); } } } diff --git a/docker/frontend/src/engine/inpaint.ts b/docker/frontend/src/engine/inpaint.ts index 7d13acb..12e0e71 100644 --- a/docker/frontend/src/engine/inpaint.ts +++ b/docker/frontend/src/engine/inpaint.ts @@ -238,8 +238,6 @@ export function fastLocalInpaint( out[p + 1] = cg; out[p + 2] = cb; out[p + 3] = 255; - } else { - out[i * 4 + 3] = 0; } } return out; @@ -325,20 +323,18 @@ export function fastLocalInpaint( return (seed / 0x7fffffff) * 2 - 1; } - // 5. Output: only hole pixels are opaque; outside hole is transparent (alpha = 0) + // 5. Output for (let y = 0; y < h; y++) { const row = y * w; for (let x = 0; x < w; x++) { const idx = row + x; - const p = idx * 4; if (hole[idx]) { + const p = idx * 4; const grain = noiseAmp > 0.5 ? prng() * noiseAmp : 0; out[p] = Math.max(0, Math.min(255, Math.round(fR[idx] + grain))); out[p + 1] = Math.max(0, Math.min(255, Math.round(fG[idx] + grain))); out[p + 2] = Math.max(0, Math.min(255, Math.round(fB[idx] + grain))); out[p + 3] = 255; - } else { - out[p + 3] = 0; } } } @@ -475,14 +471,14 @@ export async function inpaintCrop( const tensor = out[session.outputNames[0]]; const oplane = w * h; const data = tensor.data as Uint8Array; - // Back to RGBA: only pixels inside the hole (mask[i] === 0) are opaque; - // pixels outside the hole are transparent so drawing the patch never overwrites adjacent clusters. + // Back to RGBA, opaque: the crop covers the frame, which has no transparency + // of its own to carry. const patch = new Uint8Array(4 * w * h); for (let i = 0; i < oplane; i++) { patch[i * 4] = data[i]; patch[i * 4 + 1] = data[oplane + i]; patch[i * 4 + 2] = data[2 * oplane + i]; - patch[i * 4 + 3] = mask[i] === 0 ? 255 : 0; + patch[i * 4 + 3] = 255; } try { in0.dispose?.(); diff --git a/docker/recipescam-images.tar.gz.sha256 b/docker/recipescam-images.tar.gz.sha256 index 9209e72..f1d1862 100644 --- a/docker/recipescam-images.tar.gz.sha256 +++ b/docker/recipescam-images.tar.gz.sha256 @@ -1 +1 @@ -1591f02043e723d73336f51f03f96ca086bed72ed41ef4176c9191af0356920a recipescam-images.tar.gz +21471af2e7d44d8f6d6de6e0a98b2659fc9aa4e7df2cc6e23983e86652a216df recipescam-images.tar.gz