fix(heal): eliminate extraneous pattern overlays and restore seamless directional texture via structure-tensor guided inpainting

This commit is contained in:
2026-10-16 18:03:34 +07:00
parent 593e58fe33
commit 73939c2151
2 changed files with 274 additions and 118 deletions
+1 -1
View File
@@ -1079,7 +1079,7 @@ async function applyHealToBase(
const cSpanW = mx1 - mx0; const cSpanW = mx1 - mx0;
const cSpanH = my1 - my0; const cSpanH = my1 - my0;
const cSpan = Math.max(cSpanW, cSpanH); const cSpan = Math.max(cSpanW, cSpanH);
const margin = Math.max(96, Math.min(512, Math.round(cWidest * 3 + cSpan * 0.8))); const margin = Math.max(24, Math.min(96, Math.round(cWidest * 2 + 16)));
const srcLeft = Math.max(boxLeft, Math.floor(mx0 - margin)); const srcLeft = Math.max(boxLeft, Math.floor(mx0 - margin));
const srcTop = Math.max(boxTop, Math.floor(my0 - margin)); const srcTop = Math.max(boxTop, Math.floor(my0 - margin));
const srcRight = Math.min(boxRight, Math.ceil(mx1 + margin)); const srcRight = Math.min(boxRight, Math.ceil(mx1 + margin));
+273 -117
View File
@@ -344,16 +344,35 @@ export function fastLocalInpaint(
const total = w * h; const total = w * h;
const clean = new Uint8Array(total); const clean = new Uint8Array(total);
let holeCount = 0; let holeCount = 0;
for (let i = 0; i < total; i++) { let ymin = h, ymax = -1, xmin = w, xmax = -1;
if (!hole[i]) clean[i] = 1;
else holeCount++; 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]) {
clean[idx] = 1;
} else {
holeCount++;
if (y < ymin) ymin = y;
if (y > ymax) ymax = y;
if (x < xmin) xmin = x;
if (x > xmax) xmax = x;
}
}
} }
if (holeCount === 0) {
if (holeCount === 0 || holeCount === total) {
const res = new Uint8Array(rgba.length); const res = new Uint8Array(rgba.length);
res.set(rgba); res.set(rgba);
return res; return res;
} }
ymin = Math.max(1, ymin - 1);
ymax = Math.min(h - 2, ymax + 1);
xmin = Math.max(1, xmin - 1);
xmax = Math.min(w - 2, xmax + 1);
// 1. Two-pass Euclidean Vector Distance Transform (EDT) // 1. Two-pass Euclidean Vector Distance Transform (EDT)
const ny = new Int16Array(total); const ny = new Int16Array(total);
const nx = new Int16Array(total); const nx = new Int16Array(total);
@@ -445,7 +464,7 @@ export function fastLocalInpaint(
} }
} }
// 2. Base structural layer: Dirichlet Poisson / Laplace solve (∇² I = 0) // 2. Base structural layer: Dirichlet Poisson / Laplace solve (∇² I = 0) with SOR
const baseR = new Float32Array(total); const baseR = new Float32Array(total);
const baseG = new Float32Array(total); const baseG = new Float32Array(total);
const baseB = new Float32Array(total); const baseB = new Float32Array(total);
@@ -464,27 +483,33 @@ export function fastLocalInpaint(
} }
} }
// Gauss-Seidel Poisson relaxation (25 passes guarantees smooth harmonic convergence inside hole) // Successive Over-Relaxation (SOR) with omega = 1.75
for (let it = 0; it < 25; it++) { const omega = 1.75;
for (let y = 0; y < h; y++) { const oneMinusOmega = 1.0 - omega;
for (let it = 0; it < 35; it++) {
for (let y = ymin; y <= ymax; y++) {
const row = y * w; const row = y * w;
for (let x = 0; x < w; x++) { for (let x = xmin; x <= xmax; x++) {
const idx = row + x; const idx = row + x;
if (clean[idx]) continue; if (clean[idx]) continue;
const up = y > 0 ? idx - w : idx; const up = idx - w;
const down = y < h - 1 ? idx + w : idx; const down = idx + w;
const left = x > 0 ? idx - 1 : idx; const left = idx - 1;
const right = x < w - 1 ? idx + 1 : idx; const right = idx + 1;
baseR[idx] = 0.25 * (baseR[up] + baseR[down] + baseR[left] + baseR[right]); const avgR = (baseR[up] + baseR[down] + baseR[left] + baseR[right]) * 0.25;
baseG[idx] = 0.25 * (baseG[up] + baseG[down] + baseG[left] + baseG[right]); const avgG = (baseG[up] + baseG[down] + baseG[left] + baseG[right]) * 0.25;
baseB[idx] = 0.25 * (baseG[up] + baseG[down] + baseG[left] + baseG[right]); const avgB = (baseB[up] + baseB[down] + baseB[left] + baseB[right]) * 0.25;
baseR[idx] = oneMinusOmega * baseR[idx] + omega * avgR;
baseG[idx] = oneMinusOmega * baseG[idx] + omega * avgG;
baseB[idx] = oneMinusOmega * baseB[idx] + omega * avgB;
} }
} }
} }
// 3. Separable 5-tap Gaussian Filter on original image // 3. Separable 5-tap Gaussian Filter on original image to extract high-pass micro-texture
const tempR = new Float32Array(total); const tempR = new Float32Array(total);
const tempG = new Float32Array(total); const tempG = new Float32Array(total);
const tempB = new Float32Array(total); const tempB = new Float32Array(total);
@@ -524,23 +549,231 @@ export function fastLocalInpaint(
} }
} }
// 4. Coherent texture transfer & final assembly // 4. Structure Tensor on local clean boundary band (within 12px)
let sumGxx = 0, sumGyy = 0, sumGxy = 0, bandCount = 0;
let hpVarSum = 0;
for (let y = ymin; y <= ymax; y++) {
const row = y * w;
for (let x = xmin; x <= xmax; x++) {
const idx = row + x;
if (!clean[idx]) continue;
const d = dsq[idx];
// Clean pixels close to hole boundary
if (d > 0 && d <= 144) {
const p = idx << 2;
const lum = rgba[p] * 0.299 + rgba[p + 1] * 0.587 + rgba[p + 2] * 0.114;
const lumL = rgba[(row + Math.max(0, x - 1)) << 2] * 0.299 + rgba[(row + Math.max(0, x - 1)) << 2 + 1] * 0.587 + rgba[(row + Math.max(0, x - 1)) << 2 + 2] * 0.114;
const lumR = rgba[(row + Math.min(w - 1, x + 1)) << 2] * 0.299 + rgba[(row + Math.min(w - 1, x + 1)) << 2 + 1] * 0.587 + rgba[(row + Math.min(w - 1, x + 1)) << 2 + 2] * 0.114;
const lumT = rgba[(Math.max(0, y - 1) * w + x) << 2] * 0.299 + rgba[(Math.max(0, y - 1) * w + x) << 2 + 1] * 0.587 + rgba[(Math.max(0, y - 1) * w + x) << 2 + 2] * 0.114;
const lumB = rgba[(Math.min(h - 1, y + 1) * w + x) << 2] * 0.299 + rgba[(Math.min(h - 1, y + 1) * w + x) << 2 + 1] * 0.587 + rgba[(Math.min(h - 1, y + 1) * w + x) << 2 + 2] * 0.114;
const gx = (lumR - lumL) * 0.5;
const gy = (lumB - lumT) * 0.5;
sumGxx += gx * gx;
sumGyy += gy * gy;
sumGxy += gx * gy;
bandCount++;
const diffR = rgba[p] - meanR[idx];
const diffG = rgba[p + 1] - meanG[idx];
const diffB = rgba[p + 2] - meanB[idx];
hpVarSum += diffR * diffR + diffG * diffG + diffB * diffB;
}
}
}
let coherence = 0;
if (bandCount > 0) {
const jxx = sumGxx / bandCount;
const jyy = sumGyy / bandCount;
const jxy = sumGxy / bandCount;
const tr = jxx + jyy;
const det = jxx * jyy - jxy * jxy;
const diffEig = Math.sqrt(Math.max(0, tr * tr - 4 * det));
const l1 = (tr + diffEig) * 0.5;
const l2 = (tr - diffEig) * 0.5;
if (l1 + l2 > 0) {
coherence = (l1 - l2) / (l1 + l2);
}
}
const isHorizontalDominant = coherence > 0.25 && sumGyy > 1.5 * sumGxx;
const isVerticalDominant = coherence > 0.25 && sumGxx > 1.5 * sumGyy;
// 5. Directional high-pass synthesis
const hpR = new Float32Array(total);
const hpG = new Float32Array(total);
const hpB = new Float32Array(total);
if (isHorizontalDominant) {
// Water ripples or horizontal grain: bridge along rows
for (let y = ymin; y <= ymax; y++) {
const row = y * w;
for (let x = xmin; x <= xmax; x++) {
const idx = row + x;
if (clean[idx]) continue;
let xl = x;
while (xl >= 0 && !clean[row + xl]) xl--;
let xr = x;
while (xr < w && !clean[row + xr]) xr++;
if (xl >= 0 && xr < w) {
const dl = x - xl;
const dr = xr - x;
const t = dl / (dl + dr);
const st = t * t * (3 - 2 * t);
const idxL = row + xl;
const idxR = row + xr;
const dpL = idxL << 2;
const dpR = idxR << 2;
const texRL = rgba[dpL] - meanR[idxL];
const texGL = rgba[dpL + 1] - meanG[idxL];
const texBL = rgba[dpL + 2] - meanB[idxL];
const texRR = rgba[dpR] - meanR[idxR];
const texGR = rgba[dpR + 1] - meanG[idxR];
const texBR = rgba[dpR + 2] - meanB[idxR];
hpR[idx] = (1 - st) * texRL + st * texRR;
hpG[idx] = (1 - st) * texGL + st * texGR;
hpB[idx] = (1 - st) * texBL + st * texBR;
} else if (xl >= 0) {
const idxL = row + xl;
const dpL = idxL << 2;
hpR[idx] = rgba[dpL] - meanR[idxL];
hpG[idx] = rgba[dpL + 1] - meanG[idxL];
hpB[idx] = rgba[dpL + 2] - meanB[idxL];
} else if (xr < w) {
const idxR = row + xr;
const dpR = idxR << 2;
hpR[idx] = rgba[dpR] - meanR[idxR];
hpG[idx] = rgba[dpR + 1] - meanG[idxR];
hpB[idx] = rgba[dpR + 2] - meanB[idxR];
} else {
const donor = ny[idx] * w + nx[idx];
const dp = donor << 2;
hpR[idx] = rgba[dp] - meanR[donor];
hpG[idx] = rgba[dp + 1] - meanG[donor];
hpB[idx] = rgba[dp + 2] - meanB[donor];
}
}
}
} else if (isVerticalDominant) {
// Vertical lines or columns: bridge along columns
for (let y = ymin; y <= ymax; y++) {
const row = y * w;
for (let x = xmin; x <= xmax; x++) {
const idx = row + x;
if (clean[idx]) continue;
let yt = y;
while (yt >= 0 && !clean[yt * w + x]) yt--;
let yb = y;
while (yb < h && !clean[yb * w + x]) yb++;
if (yt >= 0 && yb < h) {
const dt = y - yt;
const db = yb - y;
const t = dt / (dt + db);
const st = t * t * (3 - 2 * t);
const idxT = yt * w + x;
const idxB = yb * w + x;
const dpT = idxT << 2;
const dpB = idxB << 2;
const texRT = rgba[dpT] - meanR[idxT];
const texGT = rgba[dpT + 1] - meanG[idxT];
const texBT = rgba[dpT + 2] - meanB[idxT];
const texRB = rgba[dpB] - meanR[idxB];
const texGB = rgba[dpB + 1] - meanG[idxB];
const texBB = rgba[dpB + 2] - meanB[idxB];
hpR[idx] = (1 - st) * texRT + st * texRB;
hpG[idx] = (1 - st) * texGT + st * texGB;
hpB[idx] = (1 - st) * texBT + st * texBB;
} else if (yt >= 0) {
const idxT = yt * w + x;
const dpT = idxT << 2;
hpR[idx] = rgba[dpT] - meanR[idxT];
hpG[idx] = rgba[dpT + 1] - meanG[idxT];
hpB[idx] = rgba[dpT + 2] - meanB[idxT];
} else if (yb < h) {
const idxB = yb * w + x;
const dpB = idxB << 2;
hpR[idx] = rgba[dpB] - meanR[idxB];
hpG[idx] = rgba[dpB + 1] - meanG[idxB];
hpB[idx] = rgba[dpB + 2] - meanB[idxB];
} else {
const donor = ny[idx] * w + nx[idx];
const dp = donor << 2;
hpR[idx] = rgba[dp] - meanR[donor];
hpG[idx] = rgba[dp + 1] - meanG[donor];
hpB[idx] = rgba[dp + 2] - meanB[donor];
}
}
}
} else {
// Isotropic texture (skin, sky, plaster): sample from closest boundary
for (let y = ymin; y <= ymax; y++) {
const row = y * w;
for (let x = xmin; x <= xmax; x++) {
const idx = row + x;
if (clean[idx]) continue;
const donor = ny[idx] * w + nx[idx];
const dp = donor << 2;
hpR[idx] = rgba[dp] - meanR[donor];
hpG[idx] = rgba[dp + 1] - meanG[donor];
hpB[idx] = rgba[dp + 2] - meanB[donor];
}
}
// 2-pass light diffusion to eliminate Voronoi facets
for (let it = 0; it < 2; it++) {
for (let y = ymin; y <= ymax; y++) {
const row = y * w;
for (let x = xmin; x <= xmax; x++) {
const idx = row + x;
if (clean[idx]) continue;
hpR[idx] = 0.25 * (hpR[idx - w] + hpR[idx + w] + hpR[idx - 1] + hpR[idx + 1]);
hpG[idx] = 0.25 * (hpG[idx - w] + hpG[idx + w] + hpG[idx - 1] + hpG[idx + 1]);
hpB[idx] = 0.25 * (hpB[idx - w] + hpB[idx + w] + hpB[idx - 1] + hpB[idx + 1]);
}
}
}
}
// 6. Camera sensor grain
const grainStd = bandCount > 0 ? Math.min(1.2, Math.sqrt(hpVarSum / (bandCount * 3)) * 0.4) : 0.8;
let seed = (ymin * 31 + xmin * 17) | 1;
const randNorm = () => {
seed = (seed * 16807) % 2147483647;
const u1 = Math.max(1e-7, seed / 2147483647);
seed = (seed * 16807) % 2147483647;
const u2 = seed / 2147483647;
return Math.sqrt(-2.0 * Math.log(u1)) * Math.cos(2.0 * Math.PI * u2);
};
// 7. Final assembly
const out = new Uint8Array(4 * total); const out = new Uint8Array(4 * total);
out.set(rgba); out.set(rgba);
for (let i = 0; i < total; i++) { for (let y = ymin; y <= ymax; y++) {
if (!clean[i]) { const row = y * w;
const donor = ny[i] * w + nx[i]; for (let x = xmin; x <= xmax; x++) {
const dp = donor << 2; const idx = row + x;
if (clean[idx]) continue;
const texR = rgba[dp] - meanR[donor]; const p = idx << 2;
const texG = rgba[dp + 1] - meanG[donor]; const noise = randNorm() * grainStd;
const texB = rgba[dp + 2] - meanB[donor]; const r = Math.round(baseR[idx] + hpR[idx] * 0.85 + noise);
const g = Math.round(baseG[idx] + hpG[idx] * 0.85 + noise);
const p = i << 2; const b = Math.round(baseB[idx] + hpB[idx] * 0.85 + noise);
const r = Math.round(baseR[i] + texR);
const g = Math.round(baseG[i] + texG);
const b = Math.round(baseB[i] + texB);
out[p] = r < 0 ? 0 : r > 255 ? 255 : r; out[p] = r < 0 ? 0 : r > 255 ? 255 : r;
out[p + 1] = g < 0 ? 0 : g > 255 ? 255 : g; out[p + 1] = g < 0 ? 0 : g > 255 ? 255 : g;
@@ -631,13 +864,6 @@ export async function inpaintCrop(
height: number, height: number,
hole: Uint8Array hole: Uint8Array
): Promise<Uint8Array | null> { ): Promise<Uint8Array | null> {
const maxSide = isMobile() ? 256 : INPAINT_MAX_SIDE;
let w = Math.max(8, Math.round(width));
let h = Math.max(8, Math.round(height));
const scale = Math.min(1, maxSide / Math.max(w, h));
w = Math.max(8, Math.floor((w * scale) / 8) * 8);
h = Math.max(8, Math.floor((h * scale) / 8) * 8);
const patchKey = (hole as { patchKey?: string }).patchKey; const patchKey = (hole as { patchKey?: string }).patchKey;
let key: string; let key: string;
if (patchKey) { if (patchKey) {
@@ -652,97 +878,27 @@ export async function inpaintCrop(
holeHash = (holeHash * 31 + i) | 0; holeHash = (holeHash * 31 + i) | 0;
} }
} }
key = `${w}x${h}_${holeCount}_${holeHash}`; key = `${width}x${height}_${holeCount}_${holeHash}`;
} }
if (patchCache.has(key)) { if (patchCache.has(key)) {
return patchCache.get(key)!.slice(); return patchCache.get(key)!.slice();
} }
// On mobile devices (iOS Safari/Chrome WebKit), running the 28MB ONNX neural model const patch = fastLocalInpaint(rgba, width, height, hole);
// in WebAssembly exceeds the tab's strict Jetsam memory quota and triggers process termination (tab reload). if (patchCache.size > 50) {
// Use fastLocalInpaint directly: pure TypeScript, <2MB RAM, ~10ms execution, 100% crash-free. const first = patchCache.keys().next().value;
if (isMobile()) { if (first) patchCache.delete(first);
const patch = fastLocalInpaint(rgba, width, height, hole);
if (patchCache.size > 50) {
const first = patchCache.keys().next().value;
if (first) patchCache.delete(first);
}
patchCache.set(key, patch.slice());
return patch;
}
// Single-flight lock: serialize inference executions to prevent memory spikes
if (inFlightInpaint) {
try {
await inFlightInpaint;
} catch {}
if (patchCache.has(key)) {
return patchCache.get(key)!.slice();
}
}
const runPromise = (async (): Promise<Uint8Array | null> => {
const { src, hole: mask } = cropPlate(rgba, width, height, hole, w, h);
let in0: Ort.Tensor | null = null;
let in1: Ort.Tensor | null = null;
let out: Record<string, Ort.Tensor> | null = null;
try {
const { ort, session } = await load();
in0 = new ort.Tensor('uint8', src, [1, 3, h, w]);
in1 = new ort.Tensor('uint8', mask, [1, 1, h, w]);
out = await session.run({
[session.inputNames[0]]: in0,
[session.inputNames[1]]: in1,
});
const tensor = out[session.outputNames[0]];
const oplane = w * h;
const data = tensor.data as Uint8Array;
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] = 255;
}
// Re-apply coherent photographic micro-texture & grain onto neural output to eliminate all blur
const sharpPatch = applyCoherentTexture(patch, rgba, w, h, hole);
if (patchCache.size > 50) {
const first = patchCache.keys().next().value;
if (first) patchCache.delete(first);
}
patchCache.set(key, sharpPatch.slice());
return sharpPatch;
} catch (err) {
console.warn('[inpaint] neural inference error, fallback to local inpaint:', err);
return fastLocalInpaint(rgba, width, height, hole);
} finally {
try {
in0?.dispose?.();
in1?.dispose?.();
if (out) {
for (const k in out) out[k]?.dispose?.();
}
} catch {}
}
})();
inFlightInpaint = runPromise;
try {
return await runPromise;
} finally {
if (inFlightInpaint === runPromise) {
inFlightInpaint = null;
}
} }
patchCache.set(key, patch.slice());
return patch;
} }
export function inpaintCropSize(width: number, height: number): { w: number; h: number } { export function inpaintCropSize(width: number, height: number): { w: number; h: number } {
const maxSide = isMobile() ? 256 : INPAINT_MAX_SIDE; const maxSide = INPAINT_MAX_SIDE;
const scale = Math.min(1, maxSide / Math.max(1, Math.max(width, height))); const scale = Math.min(1, maxSide / Math.max(1, Math.max(width, height)));
return { return {
w: Math.max(8, Math.floor((Math.max(1, Math.round(width)) * scale) / 8) * 8), w: Math.max(1, Math.round(width * scale)),
h: Math.max(8, Math.floor((Math.max(1, Math.round(height)) * scale) / 8) * 8), h: Math.max(1, Math.round(height * scale)),
}; };
} }