fix(heal): eliminate border seams with smoothstep feather, 95% solid mask, and matched photo grain texture

This commit is contained in:
2026-10-14 16:45:15 +07:00
parent f6acf3ee6c
commit 8df0a03414
3 changed files with 41 additions and 32 deletions
+9 -17
View File
@@ -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);
}
`;
+31 -14
View File
@@ -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;
}
}