diff --git a/docker/frontend/src/engine/exportEngine.ts b/docker/frontend/src/engine/exportEngine.ts index 16fed42..19c953b 100644 --- a/docker/frontend/src/engine/exportEngine.ts +++ b/docker/frontend/src/engine/exportEngine.ts @@ -1079,7 +1079,7 @@ async function applyHealToBase( const cSpanW = mx1 - mx0; const cSpanH = my1 - my0; 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 srcTop = Math.max(boxTop, Math.floor(my0 - margin)); const srcRight = Math.min(boxRight, Math.ceil(mx1 + margin)); diff --git a/docker/frontend/src/engine/inpaint.ts b/docker/frontend/src/engine/inpaint.ts index 0243baf..8be30e0 100644 --- a/docker/frontend/src/engine/inpaint.ts +++ b/docker/frontend/src/engine/inpaint.ts @@ -344,16 +344,35 @@ export function fastLocalInpaint( const total = w * h; const clean = new Uint8Array(total); let holeCount = 0; - for (let i = 0; i < total; i++) { - if (!hole[i]) clean[i] = 1; - else holeCount++; + let ymin = h, ymax = -1, xmin = w, xmax = -1; + + 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); res.set(rgba); 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) const ny = 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 baseG = 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) - for (let it = 0; it < 25; it++) { - for (let y = 0; y < h; y++) { + // Successive Over-Relaxation (SOR) with omega = 1.75 + const omega = 1.75; + const oneMinusOmega = 1.0 - omega; + for (let it = 0; it < 35; it++) { + for (let y = ymin; y <= ymax; y++) { const row = y * w; - for (let x = 0; x < w; x++) { + for (let x = xmin; x <= xmax; x++) { const idx = row + x; if (clean[idx]) continue; - const up = y > 0 ? idx - w : idx; - const down = y < h - 1 ? idx + w : idx; - const left = x > 0 ? idx - 1 : idx; - const right = x < w - 1 ? idx + 1 : idx; + const up = idx - w; + const down = idx + w; + const left = idx - 1; + const right = idx + 1; - baseR[idx] = 0.25 * (baseR[up] + baseR[down] + baseR[left] + baseR[right]); - baseG[idx] = 0.25 * (baseG[up] + baseG[down] + baseG[left] + baseG[right]); - baseB[idx] = 0.25 * (baseG[up] + baseG[down] + baseG[left] + baseG[right]); + const avgR = (baseR[up] + baseR[down] + baseR[left] + baseR[right]) * 0.25; + const avgG = (baseG[up] + baseG[down] + baseG[left] + baseG[right]) * 0.25; + 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 tempG = 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); out.set(rgba); - for (let i = 0; i < total; i++) { - if (!clean[i]) { - const donor = ny[i] * w + nx[i]; - const dp = donor << 2; + 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 texR = rgba[dp] - meanR[donor]; - const texG = rgba[dp + 1] - meanG[donor]; - const texB = rgba[dp + 2] - meanB[donor]; - - const p = i << 2; - const r = Math.round(baseR[i] + texR); - const g = Math.round(baseG[i] + texG); - const b = Math.round(baseB[i] + texB); + const p = idx << 2; + const noise = randNorm() * grainStd; + const r = Math.round(baseR[idx] + hpR[idx] * 0.85 + noise); + const g = Math.round(baseG[idx] + hpG[idx] * 0.85 + noise); + const b = Math.round(baseB[idx] + hpB[idx] * 0.85 + noise); out[p] = r < 0 ? 0 : r > 255 ? 255 : r; out[p + 1] = g < 0 ? 0 : g > 255 ? 255 : g; @@ -631,13 +864,6 @@ export async function inpaintCrop( height: number, hole: Uint8Array ): Promise { - 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; let key: string; if (patchKey) { @@ -652,97 +878,27 @@ export async function inpaintCrop( holeHash = (holeHash * 31 + i) | 0; } } - key = `${w}x${h}_${holeCount}_${holeHash}`; + key = `${width}x${height}_${holeCount}_${holeHash}`; } if (patchCache.has(key)) { return patchCache.get(key)!.slice(); } - // On mobile devices (iOS Safari/Chrome WebKit), running the 28MB ONNX neural model - // in WebAssembly exceeds the tab's strict Jetsam memory quota and triggers process termination (tab reload). - // Use fastLocalInpaint directly: pure TypeScript, <2MB RAM, ~10ms execution, 100% crash-free. - if (isMobile()) { - 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 => { - 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 | 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; - } + 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; } 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))); return { - w: Math.max(8, Math.floor((Math.max(1, Math.round(width)) * scale) / 8) * 8), - h: Math.max(8, Math.floor((Math.max(1, Math.round(height)) * scale) / 8) * 8), + w: Math.max(1, Math.round(width * scale)), + h: Math.max(1, Math.round(height * scale)), }; }