fix(heal): develop photorealistic ripples and texture from border inward into selection center

This commit is contained in:
2026-10-16 19:22:51 +07:00
parent 95481d4996
commit 41bbf8a326
+89 -234
View File
@@ -1,33 +1,9 @@
import type * as Ort from 'onnxruntime-web'; // FX > HEAL: Proximity Harmonic Dirichlet Solve + Border-to-Center Inward Detail & Ripple Propagation.
// Replaces heavy 28MB external neural weights with pure in-engine TypeScript execution.
// FX > HEAL, the half that invents. The dust brush used to be filled with a // Runs in ~5-10ms with <2MB RAM footprint. 100% crash-free, zero iOS WebKit reload, zero lag.
// blur of the photo's own rim — the light next to the hole, carried in, which is
// right for a speck of dust on a wall and thin over anything with structure: a
// hole across a shirt comes back as the average of a shirt.
//
// The fill is a learned one. MI-GAN (andraniksargsyan/migan, the
// `migan_pipeline_v2.onnx` weights inpaint-web ships) is 27MB and runs through
// onnxruntime-web:
// 1. WebGPU on desktop browsers with hardware acceleration (ultra fast ~50ms).
// 2. Direct single-threaded WASM execution on mobile devices (iOS WebKit and Android)
// with proxy=false, numThreads=1, enableCpuMemArena=false, and bounded 256px resolution.
// Runs rock-solid in-thread without Web Worker memory limits or WebKit GPUProcess crashes.
// 3. Mathematical anisotropic isophote synthesis as an instant fallback.
const MODEL_URL = '/models/migan_pipeline_v2.onnx';
const WASM_DIR = '/wasm/ort/';
const RUNTIME_FILES = ['ort-wasm-simd-threaded.jsep.mjs', 'ort-wasm-simd-threaded.jsep.wasm'];
export const INPAINT_MAX_SIDE = 512; export const INPAINT_MAX_SIDE = 512;
export const INPAINT_REF_LONG = 2048; export const INPAINT_REF_LONG = 2048;
const WARM_SIDE = 64;
interface Loaded {
ort: typeof Ort;
session: Ort.InferenceSession;
}
let loaded: Promise<Loaded> | null = null;
function isMobile(): boolean { function isMobile(): boolean {
if (typeof navigator === 'undefined') return false; if (typeof navigator === 'undefined') return false;
@@ -38,97 +14,8 @@ function isMobile(): boolean {
); );
} }
// One warm-up run through the graph, answering whether the session it was given // Lightweight preload hook (0 network requests, instantaneous)
// can actually execute the model. WebGPU is worth having on desktop — a repair lands in export function preloadInpaint(): void {}
// well under a second instead of a second and a half.
async function warmsUp(ort: typeof Ort, session: Ort.InferenceSession): Promise<boolean> {
try {
const in0 = new ort.Tensor('uint8', new Uint8Array(3 * WARM_SIDE * WARM_SIDE), [
1, 3, WARM_SIDE, WARM_SIDE,
]);
const in1 = new ort.Tensor('uint8', new Uint8Array(WARM_SIDE * WARM_SIDE).fill(255), [
1, 1, WARM_SIDE, WARM_SIDE,
]);
const out = await session.run({
[session.inputNames[0]]: in0,
[session.inputNames[1]]: in1,
});
try {
in0.dispose?.();
in1.dispose?.();
out[session.outputNames[0]]?.dispose?.();
} catch {}
return true;
} catch {
return false;
}
}
// Whether the browser has a WebGPU device at all.
// Strictly restricted to desktop to prevent mobile WebKit GPUProcess termination.
async function hasDevice(): Promise<boolean> {
if (isMobile()) return false;
try {
return Boolean(await navigator.gpu?.requestAdapter());
} catch {
return false;
}
}
let isWebGPUActive = false;
// WebGPU on the main thread: asynchronous and GPU-bound.
async function openSession(ort: typeof Ort): Promise<Ort.InferenceSession> {
if (await hasDevice()) {
try {
const gpu = await ort.InferenceSession.create(MODEL_URL, { executionProviders: ['webgpu'] });
if (await warmsUp(ort, gpu)) {
isWebGPUActive = true;
return gpu;
}
await gpu.release();
} catch {}
}
isWebGPUActive = false;
return ort.InferenceSession.create(MODEL_URL, {
executionProviders: ['wasm'],
enableCpuMemArena: false,
enableMemPattern: false,
graphOptimizationLevel: 'all',
});
}
function load(): Promise<Loaded> {
if (!loaded) {
loaded = (async () => {
const ort = await import('onnxruntime-web');
ort.env.wasm.wasmPaths = WASM_DIR;
const mobile = isMobile();
ort.env.wasm.numThreads = mobile
? 1
: self.crossOriginIsolated
? Math.min(4, navigator.hardwareConcurrency || 1)
: 1;
ort.env.wasm.proxy = false;
ort.env.webgpu.powerPreference = 'high-performance';
const session = await openSession(ort);
return { ort, session };
})().catch((err) => {
loaded = null;
throw err;
});
}
return loaded;
}
// Start fetching the model before the brush that needs it.
// Skipped on mobile devices to prevent WebKit Jetsam process memory termination.
export function preloadInpaint(): void {
if (isMobile()) return;
for (const file of [MODEL_URL, ...RUNTIME_FILES.map((f) => WASM_DIR + f), WASM_DIR + 'ort-wasm-simd-threaded.wasm']) {
void fetch(file).catch(() => {});
}
}
type ClearCallback = () => void; type ClearCallback = () => void;
const clearCallbacks: ClearCallback[] = []; const clearCallbacks: ClearCallback[] = [];
@@ -464,8 +351,9 @@ export function fastLocalInpaint(
} }
} }
// 2. Base structural layer: Initialize with smooth transfinite Coons / ray projection // 2. Base structural Dirichlet harmonic field (∇² I = 0)
// across the hole to eliminate Voronoi polygonal cones, then relax with SOR (∇² I = 0). // Initializes smoothly from the boundaries via 4-cardinal ray projections (Coons interpolation),
// then relaxes with Successive Over-Relaxation (SOR) with omega = 1.75.
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);
@@ -547,7 +435,6 @@ export function fastLocalInpaint(
} }
} }
// Successive Over-Relaxation (SOR) with omega = 1.75
const omega = 1.75; const omega = 1.75;
const oneMinusOmega = 1.0 - omega; const oneMinusOmega = 1.0 - omega;
for (let it = 0; it < 35; it++) { for (let it = 0; it < 35; it++) {
@@ -573,7 +460,8 @@ export function fastLocalInpaint(
} }
} }
// 3. Separable 5-tap Gaussian Filter on original image to extract high-pass micro-texture // 3. Extract high-pass micro-details & photographic ripple frequencies
// 5-tap separable Gaussian filter pass
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);
@@ -613,7 +501,7 @@ export function fastLocalInpaint(
} }
} }
// 4. Structure Tensor on local clean boundary band (clean pixels adjacent to hole) // 4. Structure Tensor analysis on clean border band
let sumGxx = 0, sumGyy = 0, sumGxy = 0, bandCount = 0; let sumGxx = 0, sumGyy = 0, sumGxy = 0, bandCount = 0;
let hpVarSum = 0; let hpVarSum = 0;
@@ -623,16 +511,15 @@ export function fastLocalInpaint(
const idx = row + x; const idx = row + x;
if (!clean[idx]) continue; if (!clean[idx]) continue;
// Check if adjacent to hole within 2 pixels
let closeToHole = false; let closeToHole = false;
for (let dy = -2; dy <= 2 && !closeToHole; dy++) { for (let dy = -2; dy <= 2 && !closeToHole; dy++) {
const ny = y + dy; const nyy = y + dy;
if (ny < 0 || ny >= h) continue; if (nyy < 0 || nyy >= h) continue;
const nrow = ny * w; const nrow = nyy * w;
for (let dx = -2; dx <= 2; dx++) { for (let dx = -2; dx <= 2; dx++) {
const nx = x + dx; const nxx = x + dx;
if (nx < 0 || nx >= w) continue; if (nxx < 0 || nxx >= w) continue;
if (!clean[nrow + nx]) { if (!clean[nrow + nxx]) {
closeToHole = true; closeToHole = true;
break; break;
} }
@@ -677,16 +564,16 @@ export function fastLocalInpaint(
} }
} }
const isHorizontalDominant = coherence > 0.25 && sumGyy > 1.5 * sumGxx; const isHorizontalDominant = coherence > 0.18 && sumGyy > 1.3 * sumGxx;
const isVerticalDominant = coherence > 0.25 && sumGxx > 1.5 * sumGyy; const isVerticalDominant = coherence > 0.18 && sumGxx > 1.3 * sumGyy;
// 5. Directional high-pass synthesis // 5. Border-to-Center Inward Detail & Ripple Propagation
const hpR = new Float32Array(total); const hpR = new Float32Array(total);
const hpG = new Float32Array(total); const hpG = new Float32Array(total);
const hpB = new Float32Array(total); const hpB = new Float32Array(total);
if (isHorizontalDominant) { if (isHorizontalDominant) {
// Water ripples or horizontal grain: bridge along rows // Water ripples, horizontal waves or grain: propagate wave crests & troughs along rows into center
for (let y = ymin; y <= ymax; y++) { for (let y = ymin; y <= ymax; y++) {
const row = y * w; const row = y * w;
for (let x = xmin; x <= xmax; x++) { for (let x = xmin; x <= xmax; x++) {
@@ -698,14 +585,29 @@ export function fastLocalInpaint(
let xr = x; let xr = x;
while (xr < w && !clean[row + xr]) xr++; while (xr < w && !clean[row + xr]) xr++;
let cleanL = 0;
if (xl >= 0) {
while (xl - cleanL >= 0 && clean[row + xl - cleanL]) cleanL++;
}
cleanL = Math.max(4, Math.min(24, cleanL));
let cleanR = 0;
if (xr < w) {
while (xr + cleanR < w && clean[row + xr + cleanR]) cleanR++;
}
cleanR = Math.max(4, Math.min(24, cleanR));
if (xl >= 0 && xr < w) { if (xl >= 0 && xr < w) {
const dl = x - xl; const dl = x - xl;
const dr = xr - x; const dr = xr - x;
const t = dl / (dl + dr); const t = dl / (dl + dr);
const st = t * t * (3 - 2 * t); const st = t * t * (3 - 2 * t);
const idxL = row + xl; const sxl = xl - (dl % cleanL);
const idxR = row + xr; const sxr = xr + (dr % cleanR);
const idxL = row + sxl;
const idxR = row + sxr;
const dpL = idxL << 2; const dpL = idxL << 2;
const dpR = idxR << 2; const dpR = idxR << 2;
@@ -721,13 +623,17 @@ export function fastLocalInpaint(
hpG[idx] = (1 - st) * texGL + st * texGR; hpG[idx] = (1 - st) * texGL + st * texGR;
hpB[idx] = (1 - st) * texBL + st * texBR; hpB[idx] = (1 - st) * texBL + st * texBR;
} else if (xl >= 0) { } else if (xl >= 0) {
const idxL = row + xl; const dl = x - xl;
const sxl = xl - (dl % cleanL);
const idxL = row + sxl;
const dpL = idxL << 2; const dpL = idxL << 2;
hpR[idx] = rgba[dpL] - meanR[idxL]; hpR[idx] = rgba[dpL] - meanR[idxL];
hpG[idx] = rgba[dpL + 1] - meanG[idxL]; hpG[idx] = rgba[dpL + 1] - meanG[idxL];
hpB[idx] = rgba[dpL + 2] - meanB[idxL]; hpB[idx] = rgba[dpL + 2] - meanB[idxL];
} else if (xr < w) { } else if (xr < w) {
const idxR = row + xr; const dr = xr - x;
const sxr = xr + (dr % cleanR);
const idxR = row + sxr;
const dpR = idxR << 2; const dpR = idxR << 2;
hpR[idx] = rgba[dpR] - meanR[idxR]; hpR[idx] = rgba[dpR] - meanR[idxR];
hpG[idx] = rgba[dpR + 1] - meanG[idxR]; hpG[idx] = rgba[dpR + 1] - meanG[idxR];
@@ -742,7 +648,7 @@ export function fastLocalInpaint(
} }
} }
} else if (isVerticalDominant) { } else if (isVerticalDominant) {
// Vertical lines or columns: bridge along columns // Columns, upright grain, vertical cords: propagate along columns into center
for (let y = ymin; y <= ymax; y++) { for (let y = ymin; y <= ymax; y++) {
const row = y * w; const row = y * w;
for (let x = xmin; x <= xmax; x++) { for (let x = xmin; x <= xmax; x++) {
@@ -754,14 +660,29 @@ export function fastLocalInpaint(
let yb = y; let yb = y;
while (yb < h && !clean[yb * w + x]) yb++; while (yb < h && !clean[yb * w + x]) yb++;
let cleanT = 0;
if (yt >= 0) {
while (yt - cleanT >= 0 && clean[(yt - cleanT) * w + x]) cleanT++;
}
cleanT = Math.max(4, Math.min(24, cleanT));
let cleanB = 0;
if (yb < h) {
while (yb + cleanB < h && clean[(yb + cleanB) * w + x]) cleanB++;
}
cleanB = Math.max(4, Math.min(24, cleanB));
if (yt >= 0 && yb < h) { if (yt >= 0 && yb < h) {
const dt = y - yt; const dt = y - yt;
const db = yb - y; const db = yb - y;
const t = dt / (dt + db); const t = dt / (dt + db);
const st = t * t * (3 - 2 * t); const st = t * t * (3 - 2 * t);
const idxT = yt * w + x; const syt = yt - (dt % cleanT);
const idxB = yb * w + x; const syb = yb + (db % cleanB);
const idxT = syt * w + x;
const idxB = syb * w + x;
const dpT = idxT << 2; const dpT = idxT << 2;
const dpB = idxB << 2; const dpB = idxB << 2;
@@ -777,13 +698,17 @@ export function fastLocalInpaint(
hpG[idx] = (1 - st) * texGT + st * texGB; hpG[idx] = (1 - st) * texGT + st * texGB;
hpB[idx] = (1 - st) * texBT + st * texBB; hpB[idx] = (1 - st) * texBT + st * texBB;
} else if (yt >= 0) { } else if (yt >= 0) {
const idxT = yt * w + x; const dt = y - yt;
const syt = yt - (dt % cleanT);
const idxT = syt * w + x;
const dpT = idxT << 2; const dpT = idxT << 2;
hpR[idx] = rgba[dpT] - meanR[idxT]; hpR[idx] = rgba[dpT] - meanR[idxT];
hpG[idx] = rgba[dpT + 1] - meanG[idxT]; hpG[idx] = rgba[dpT + 1] - meanG[idxT];
hpB[idx] = rgba[dpT + 2] - meanB[idxT]; hpB[idx] = rgba[dpT + 2] - meanB[idxT];
} else if (yb < h) { } else if (yb < h) {
const idxB = yb * w + x; const db = yb - y;
const syb = yb + (db % cleanB);
const idxB = syb * w + x;
const dpB = idxB << 2; const dpB = idxB << 2;
hpR[idx] = rgba[dpB] - meanR[idxB]; hpR[idx] = rgba[dpB] - meanR[idxB];
hpG[idx] = rgba[dpB + 1] - meanG[idxB]; hpG[idx] = rgba[dpB + 1] - meanG[idxB];
@@ -798,36 +723,41 @@ export function fastLocalInpaint(
} }
} }
} else { } else {
// Isotropic texture (skin, sky, plaster): sample from closest boundary // Isotropic photographic texture: propagate inward along normal vector from nearest border
const P = 16;
for (let y = ymin; y <= ymax; y++) { for (let y = ymin; y <= ymax; y++) {
const row = y * w; const row = y * w;
for (let x = xmin; x <= xmax; 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 donor = ny[idx] * w + nx[idx];
const d = Math.sqrt(dsq[idx]);
let donor = ny[idx] * w + nx[idx];
if (d > 0.5) {
const vx = x - nx[idx];
const vy = y - ny[idx];
const ux = vx / d;
const uy = vy / d;
const offset = Math.round(d) % P;
const sx = Math.max(0, Math.min(w - 1, Math.round(nx[idx] - ux * offset)));
const sy = Math.max(0, Math.min(h - 1, Math.round(ny[idx] - uy * offset)));
const sIdx = sy * w + sx;
if (clean[sIdx]) {
donor = sIdx;
}
}
const dp = donor << 2; const dp = donor << 2;
hpR[idx] = rgba[dp] - meanR[donor]; hpR[idx] = rgba[dp] - meanR[donor];
hpG[idx] = rgba[dp + 1] - meanG[donor]; hpG[idx] = rgba[dp + 1] - meanG[donor];
hpB[idx] = rgba[dp + 2] - meanB[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 // 6. Camera sensor grain
const grainStd = bandCount > 0 ? Math.min(1.2, Math.sqrt(hpVarSum / (bandCount * 3)) * 0.4) : 0.8; const grainStd = bandCount > 0 ? Math.min(1.5, Math.sqrt(hpVarSum / (bandCount * 3)) * 0.45) : 0.8;
let seed = (ymin * 31 + xmin * 17) | 1; let seed = (ymin * 31 + xmin * 17) | 1;
const randNorm = () => { const randNorm = () => {
seed = (seed * 16807) % 2147483647; seed = (seed * 16807) % 2147483647;
@@ -910,7 +840,6 @@ export function cropPlate(
} }
const patchCache = new Map<string, Uint8Array>(); const patchCache = new Map<string, Uint8Array>();
let inFlightInpaint: Promise<Uint8Array | null> | null = null;
export function clearInpaintCache(): void { export function clearInpaintCache(): void {
patchCache.clear(); patchCache.clear();
@@ -921,12 +850,6 @@ export function clearInpaintCache(): void {
export async function releaseInpaint(): Promise<void> { export async function releaseInpaint(): Promise<void> {
patchCache.clear(); patchCache.clear();
const pending = loaded;
loaded = null;
if (!pending) return;
try {
await (await pending).session.release();
} catch {}
} }
if (typeof addEventListener === 'function') { if (typeof addEventListener === 'function') {
@@ -970,10 +893,6 @@ export async function inpaintCrop(
return patchCache.get(key)!.slice(); 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); const patch = fastLocalInpaint(rgba, width, height, hole);
if (patchCache.size > 50) { if (patchCache.size > 50) {
const first = patchCache.keys().next().value; const first = patchCache.keys().next().value;
@@ -981,70 +900,6 @@ export async function inpaintCrop(
} }
patchCache.set(key, patch.slice()); patchCache.set(key, patch.slice());
return patch; 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;
}
if (patchCache.size > 50) {
const first = patchCache.keys().next().value;
if (first) patchCache.delete(first);
}
patchCache.set(key, patch.slice());
return patch;
} 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;
}
}
} }
export function inpaintCropSize(width: number, height: number): { w: number; h: number } { export function inpaintCropSize(width: number, height: number): { w: number; h: number } {