fix(heal): eliminate boundary seams by windowing inpaint clusters to 1:1 scale and smoothing feather transition

This commit is contained in:
2026-10-14 17:35:27 +07:00
parent 8df0a03414
commit 4b5e55bc50
4 changed files with 35 additions and 11 deletions
+3 -3
View File
@@ -25,9 +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 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;
// out to 75% the mask is solid to completely eliminate defects, and the outer 25%
// is a smooth Hermite fade (smoothstep) to blend invisibly into the surrounding photo.
export const HEAL_FEATHER = 0.75;
// 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
+28 -5
View File
@@ -1326,20 +1326,43 @@ function clusterHealSpots(
}
}
}
// Maximum dimension of a cluster's bounding box so that the inpaint crop
// runs at ~1:1 native resolution (avoiding heavy downscale/upscale blur & edge seams)
const isMob = typeof navigator !== 'undefined' && /iPad|iPhone|iPod|Android|Mobile/i.test(navigator.userAgent || '');
const maxSpan = isMob ? 240 : 380;
const visited = new Uint8Array(n);
const clusters: { x: number; y: number; r: number }[][] = [];
for (let i = 0; i < n; i++) {
if (visited[i]) continue;
visited[i] = 1;
const cluster: { x: number; y: number; r: number }[] = [spots[i]];
let minX = spots[i].x * width;
let maxX = minX;
let minY = spots[i].y * height;
let maxY = minY;
const queue = [i];
while (queue.length > 0) {
const curr = queue.pop()!;
const curr = queue.shift()!;
for (const neighbor of adj[curr]) {
if (!visited[neighbor]) {
visited[neighbor] = 1;
cluster.push(spots[neighbor]);
queue.push(neighbor);
const nx = spots[neighbor].x * width;
const ny = spots[neighbor].y * height;
const newMinX = Math.min(minX, nx);
const newMaxX = Math.max(maxX, nx);
const newMinY = Math.min(minY, ny);
const newMaxY = Math.max(maxY, ny);
if (newMaxX - newMinX <= maxSpan && newMaxY - newMinY <= maxSpan) {
visited[neighbor] = 1;
cluster.push(spots[neighbor]);
minX = newMinX;
maxX = newMaxX;
minY = newMinY;
maxY = newMaxY;
queue.push(neighbor);
}
}
}
}
@@ -1503,7 +1526,7 @@ function clusterHealSpots(
const my1 = Math.max(...cYs) + cWidest;
const margin = Math.max(
16,
Math.min(64, Math.round(Math.max(cWidest * 1.5, 0.25 * Math.max(mx1 - mx0, my1 - my0))))
Math.min(64, Math.round(cWidest * 1.5))
);
const srcLeft = Math.max(boxLeft, Math.floor(mx0 - margin));
const srcTop = Math.max(boxTop, Math.floor(my0 - margin));
+3 -2
View File
@@ -253,7 +253,7 @@ export function fastLocalInpaint(
const fG = new Float32Array(w * h);
const fB = new Float32Array(w * h);
const bStep = Math.max(1, Math.floor(bLen / 32));
const bStep = bLen <= 96 ? 1 : Math.max(1, Math.floor(bLen / 64));
for (let y = 0; y < h; y++) {
const row = y * w;
@@ -329,7 +329,8 @@ export function fastLocalInpaint(
}
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)));
const rawAmp = Math.sqrt(noiseVariance * 0.5);
const noiseAmp = rawAmp < 0.3 ? 0 : Math.min(4, rawAmp);
let seed = (w * 31 + h * 17 + holeCount) | 0;
function prng(): number {
+1 -1
View File
@@ -1 +1 @@
11bf4442bb348f4ac3ed55da345805502351bcadddbe11a1edb2b819a864a611 recipescam-images.tar.gz
ca55c46c0374f76a007f5309759bf25e07fcee625483a87fe96cce5c34a93645 recipescam-images.tar.gz