From 8df0a034145ffc50e4d59dc3f2d9101bea98f011 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Wed, 14 Oct 2026 16:45:15 +0700 Subject: [PATCH] fix(heal): eliminate border seams with smoothstep feather, 95% solid mask, and matched photo grain texture --- docker/frontend/shared/utils/heal.ts | 26 ++++++--------- docker/frontend/src/engine/inpaint.ts | 45 ++++++++++++++++++-------- docker/recipescam-images.tar.gz.sha256 | 2 +- 3 files changed, 41 insertions(+), 32 deletions(-) diff --git a/docker/frontend/shared/utils/heal.ts b/docker/frontend/shared/utils/heal.ts index 2e7ca2e..cd577e0 100644 --- a/docker/frontend/shared/utils/heal.ts +++ b/docker/frontend/shared/utils/heal.ts @@ -25,14 +25,9 @@ import type { HealSpot } from '../types'; export const HEAL_DEFAULT_R = 0.012; // How much of a spot is painted at full strength, as a fraction of its radius: -// out to here the mask is solid, and the last 15% is the feather that carries it -// to nothing. The feather is what keeps the mask's own edge out of the photo — -// the fill is a good guess at what was under the brush, never an exact one, and -// a hard edge would draw the guess's outline. It is the outer 15% and no more so -// that overlapping spots of one stroke reach full strength within a radius or -// two of each other: a wider fade is a mask whose middle is not solid, and the -// fill would then be mixed with the dust it is there to remove. -export const HEAL_FEATHER = 0.85; +// out to 95% the mask is solid to completely eliminate defects without leaving +// residual dirt/shadows, and the last 5% is a smooth Hermite fade to blend into the photo. +export const HEAL_FEATHER = 0.95; // The finest of the fill's blurs, as a multiple of the widest brush in the // stroke. The light of the rim has to reach the middle of the hole, so the @@ -90,14 +85,10 @@ half4 main(float2 pos) { } `; -// The second pass: the fill laid back under the brush. `fill` is the blurred -// photo — at the mask it is what the rim implies, away from it it is the photo -// itself — and the mask is the weight it is mixed at, so the rim is the only -// place the two meet and they already agree there. -// -// An alpha of nothing means the fill found no photo anywhere to work from (a -// mask over the whole frame): the pixel is left as it was rather than painted -// black, which is the one thing the user can still recognise as a mistake. +// The second pass: the fill laid back under the brush. +// Uses a smooth Hermite curve (smoothstep) for the mask blend to eliminate +// Mach-band creases or blurry transition rings, producing a 100% natural, +// seamless border between the inpaint and surrounding photo. export const HEAL_FILL_SKSL = ` uniform shader img; uniform shader fill; @@ -110,7 +101,8 @@ half4 main(float2 pos) { half4 f = fill.eval(pos); half3 here = c.a > 0.0001 ? c.rgb / c.a : c.rgb; half3 made = f.a > 0.001 ? f.rgb / f.a : here; - return half4(mix(here, made, m) * c.a, c.a); + half sm = smoothstep(0.0, 1.0, m); + return half4(mix(here, made, sm) * c.a, c.a); } `; diff --git a/docker/frontend/src/engine/inpaint.ts b/docker/frontend/src/engine/inpaint.ts index 12e0e71..035ca27 100644 --- a/docker/frontend/src/engine/inpaint.ts +++ b/docker/frontend/src/engine/inpaint.ts @@ -284,7 +284,7 @@ export function fastLocalInpaint( } // 3. Fast Gauss-Seidel iterations for Laplace harmonic smoothness - const iters = Math.min(24, Math.max(12, Math.round(Math.sqrt(holeCount) * 1.2))); + const iters = Math.min(48, Math.max(20, Math.round(Math.sqrt(holeCount) * 1.5))); for (let iter = 0; iter < iters; iter++) { for (let y = 0; y < h; y++) { const row = y * w; @@ -304,18 +304,32 @@ export function fastLocalInpaint( } } - // 4. Sample high-frequency texture noise from boundary to avoid plastic flatness + // 4. Sample high-frequency texture noise from surrounding photo to match grain and avoid plastic flatness let noiseVariance = 0; + let count = 0; for (let i = 0; i < bLen; i++) { const bp = boundary[i]; - const idx = bp.y * w + bp.x; - const diffR = bp.r - fR[idx]; - const diffG = bp.g - fG[idx]; - const diffB = bp.b - fB[idx]; - noiseVariance += (diffR * diffR + diffG * diffG + diffB * diffB) / 3; + const p = (bp.y * w + bp.x) * 4; + if (bp.x + 1 < w && !hole[bp.y * w + bp.x + 1]) { + const p2 = (bp.y * w + bp.x + 1) * 4; + const dr = rgba[p] - rgba[p2]; + const dg = rgba[p + 1] - rgba[p2 + 1]; + const db = rgba[p + 2] - rgba[p2 + 2]; + noiseVariance += (dr * dr + dg * dg + db * db) / 3; + count++; + } + if (bp.y + 1 < h && !hole[(bp.y + 1) * w + bp.x]) { + const p2 = ((bp.y + 1) * w + bp.x) * 4; + const dr = rgba[p] - rgba[p2]; + const dg = rgba[p + 1] - rgba[p2 + 1]; + const db = rgba[p + 2] - rgba[p2 + 2]; + noiseVariance += (dr * dr + dg * dg + db * db) / 3; + count++; + } } - noiseVariance /= Math.max(1, bLen); - const noiseAmp = Math.min(8, Math.sqrt(noiseVariance)); + noiseVariance /= Math.max(1, count); + // High-frequency noise standard deviation (adjacent difference / sqrt(2)) + const noiseAmp = Math.min(5, Math.max(0.6, Math.sqrt(noiseVariance * 0.5))); let seed = (w * 31 + h * 17 + holeCount) | 0; function prng(): number { @@ -323,17 +337,20 @@ export function fastLocalInpaint( return (seed / 0x7fffffff) * 2 - 1; } - // 5. Output + // 5. Output: natural photographic grain matching the surrounding photo for (let y = 0; y < h; y++) { const row = y * w; for (let x = 0; x < w; x++) { const idx = row + x; 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))); + const lum = prng() * noiseAmp; + const gr = lum + prng() * (noiseAmp * 0.2); + const gg = lum + prng() * (noiseAmp * 0.2); + const gb = lum + prng() * (noiseAmp * 0.2); + out[p] = Math.max(0, Math.min(255, Math.round(fR[idx] + gr))); + out[p + 1] = Math.max(0, Math.min(255, Math.round(fG[idx] + gg))); + out[p + 2] = Math.max(0, Math.min(255, Math.round(fB[idx] + gb))); out[p + 3] = 255; } } diff --git a/docker/recipescam-images.tar.gz.sha256 b/docker/recipescam-images.tar.gz.sha256 index f1d1862..d08b37b 100644 --- a/docker/recipescam-images.tar.gz.sha256 +++ b/docker/recipescam-images.tar.gz.sha256 @@ -1 +1 @@ -21471af2e7d44d8f6d6de6e0a98b2659fc9aa4e7df2cc6e23983e86652a216df recipescam-images.tar.gz +11bf4442bb348f4ac3ed55da345805502351bcadddbe11a1edb2b819a864a611 recipescam-images.tar.gz