fix(heal): restore sharp photorealistic textures and structural lines via 1:1 scale exemplar synthesis

This commit is contained in:
2026-10-16 16:10:27 +07:00
parent 4c249fc173
commit 105fc67c9b
2 changed files with 132 additions and 205 deletions
+1 -4
View File
@@ -1088,10 +1088,7 @@ async function applyHealToBase(
const srcH = srcBottom - srcTop;
if (srcW >= 8 && srcH >= 8) {
const { w: mw, h: mh } = inpaintCropSize(
(srcW / width) * INPAINT_REF_LONG,
(srcH / height) * INPAINT_REF_LONG
);
const { w: mw, h: mh } = inpaintCropSize(srcW, srcH);
const cropSurface = tempOwn(createSurface(mw, mh));
if (cropSurface) {
cropSurface.getCanvas().drawImageRect(
+131 -201
View File
@@ -155,39 +155,56 @@ export function fastLocalInpaint(
const out = new Uint8Array(rgba.length);
out.set(rgba);
const mask = new Uint8Array(total);
const clean = new Uint8Array(total);
let holeCount = 0;
for (let i = 0; i < total; i++) {
if (hole[i]) {
mask[i] = 1;
holeCount++;
}
if (!hole[i]) clean[i] = 1;
else holeCount++;
}
if (holeCount === 0) return out;
// 1. Distance transform from clean boundary into the hole
const dist = new Float32Array(total).fill(1e6);
const queue: number[] = [];
const boundaryDonors: number[] = [];
// 1. Measure vertical structure periodicity (e.g. brick courses, horizontal joints, slats)
const rowLum = new Float32Array(h);
for (let y = 0; y < h; y++) {
let sum = 0;
const row = y * w * 4;
for (let x = 0; x < w; x++) {
const p = row + (x << 2);
sum += (rgba[p] * 77 + rgba[p + 1] * 150 + rgba[p + 2] * 29) >> 8;
}
rowLum[y] = sum / w;
}
let lumMean = 0;
for (let y = 0; y < h; y++) lumMean += rowLum[y];
lumMean /= h;
for (let y = 0; y < h; y++) rowLum[y] -= lumMean;
let period = 0;
let bestCorr = 0;
const maxLag = Math.min(60, Math.floor(h / 2));
for (let lag = 12; lag < maxLag; lag++) {
let sum = 0;
for (let y = 0; y < h - lag; y++) sum += rowLum[y] * rowLum[y + lag];
if (sum > bestCorr) {
bestCorr = sum;
period = lag;
}
}
// 2. Distance transform for onion-peel layer-by-layer inward synthesis
const dist = new Int16Array(total).fill(30000);
const queue: number[] = [];
for (let y = 0; y < h; y++) {
const row = y * w;
for (let x = 0; x < w; x++) {
const idx = row + x;
if (!mask[idx]) {
if (clean[idx]) {
dist[idx] = 0;
const up = y > 0 && mask[idx - w];
const down = y < h - 1 && mask[idx + w];
const left = x > 0 && mask[idx - 1];
const right = x < w - 1 && mask[idx + 1];
if (up || down || left || right) {
boundaryDonors.push(idx);
}
} else {
const up = y > 0 && !mask[idx - w];
const down = y < h - 1 && !mask[idx + w];
const left = x > 0 && !mask[idx - 1];
const right = x < w - 1 && !mask[idx + 1];
const up = y > 0 && clean[idx - w];
const down = y < h - 1 && clean[idx + w];
const left = x > 0 && clean[idx - 1];
const right = x < w - 1 && clean[idx + 1];
if (up || down || left || right) {
dist[idx] = 1;
queue.push(idx);
@@ -196,211 +213,124 @@ export function fastLocalInpaint(
}
}
if (boundaryDonors.length === 0) return out;
let head = 0;
while (head < queue.length) {
const idx = queue[head++];
const dCur = dist[idx];
const cx = idx % w;
const cy = (idx / w) | 0;
const dCur = dist[idx];
const neighbors = [
cy > 0 ? idx - w : -1,
cy < h - 1 ? idx + w : -1,
cx > 0 ? idx - 1 : -1,
cx < w - 1 ? idx + 1 : -1,
];
const n1 = cy > 0 ? idx - w : -1;
const n2 = cy < h - 1 ? idx + w : -1;
const n3 = cx > 0 ? idx - 1 : -1;
const n4 = cx < w - 1 ? idx + 1 : -1;
for (let n = 0; n < 4; n++) {
const nIdx = neighbors[n];
if (nIdx !== -1 && mask[nIdx] && dist[nIdx] > dCur + 1) {
dist[nIdx] = dCur + 1;
queue.push(nIdx);
if (n1 !== -1 && dist[n1] > dCur + 1) { dist[n1] = dCur + 1; queue.push(n1); }
if (n2 !== -1 && dist[n2] > dCur + 1) { dist[n2] = dCur + 1; queue.push(n2); }
if (n3 !== -1 && dist[n3] > dCur + 1) { dist[n3] = dCur + 1; queue.push(n3); }
if (n4 !== -1 && dist[n4] > dCur + 1) { dist[n4] = dCur + 1; queue.push(n4); }
}
// 3. Candidate donor relative shifts (dy, dx)
const shiftsY: number[] = [];
const shiftsX: number[] = [];
// Shifts based on periodic structure if detected
if (period >= 12) {
for (const mult of [-2, -1, 1, 2]) {
const dy = mult * period;
for (let dx = -40; dx <= 40; dx += 4) {
shiftsY.push(dy);
shiftsX.push(dx);
}
}
}
// Sort queue by distance: outer boundary to inner core
queue.sort((a, b) => dist[a] - dist[b]);
let sumHx = 0, sumHy = 0;
for (let i = 0; i < queue.length; i++) {
const idx = queue[i];
sumHx += idx % w;
sumHy += (idx / w) | 0;
// Local horizontal and diagonal shifts
for (let dy = -6; dy <= 6; dy += 2) {
for (const dx of [-36, -28, -20, -14, -8, 8, 14, 20, 28, 36]) {
shiftsY.push(dy);
shiftsX.push(dx);
}
}
// Vertical shifts
for (const dy of [-28, -20, -14, -7, 7, 14, 20, 28]) {
for (let dx = -10; dx <= 10; dx += 5) {
shiftsY.push(dy);
shiftsX.push(dx);
}
}
const holeCx = sumHx / queue.length;
const holeCy = sumHy / queue.length;
// 2. Anisotropic gradient propagation along isophotes
const R = 7;
const shiftCount = shiftsY.length;
const filled = new Uint8Array(clean);
// 4. Inward synthesis: process pixels from boundary inward (queue order)
const P = 3; // 7x7 patch
for (let i = 0; i < queue.length; i++) {
const idx = queue[i];
const px = idx % w;
const py = (idx / w) | 0;
const cx = idx % w;
const cy = (idx / w) | 0;
let sumR = 0, sumG = 0, sumB = 0, sumW = 0;
let bestCost = 1e9;
let bestDonor = idx;
// Normal vector pointing inward
let nx = 0, ny = 0;
if (px > 0 && px < w - 1) nx = dist[idx + 1] - dist[idx - 1];
if (py > 0 && py < h - 1) ny = dist[idx + w] - dist[idx - w];
const nLen = Math.hypot(nx, ny);
if (nLen > 1e-4) { nx /= nLen; ny /= nLen; }
for (let s = 0; s < shiftCount; s++) {
const dy = shiftsY[s];
const dx = shiftsX[s];
const sy = cy + dy;
const sx = cx + dx;
for (let dy = -R; dy <= R; dy++) {
const sy = py + dy;
if (sy < 0 || sy >= h) continue;
const srow = sy * w;
for (let dx = -R; dx <= R; dx++) {
const sx = px + dx;
if (sx < 0 || sx >= w) continue;
const d2 = dx * dx + dy * dy;
if (d2 === 0 || d2 > R * R) continue;
if (sy < P || sy >= h - P || sx < P || sx >= w - P) continue;
const donorIdx = sy * w + sx;
if (!clean[donorIdx]) continue;
const sIdx = srow + sx;
if (!mask[sIdx] || dist[sIdx] < dist[idx]) {
const dDist = Math.sqrt(d2);
const dir = Math.abs((dx * (-ny) + dy * nx) / dDist) * 0.7 + 0.3;
const wSample = dir * Math.exp(-dDist / 3.0) / (1 + dist[sIdx] * 0.5);
let sumDiff = 0;
let count = 0;
const sp = sIdx * 4;
let gx = 0, gy = 0;
if (sx > 0 && sx < w - 1) gx = (out[sp + 4] - out[sp - 4]) * 0.5;
if (sy > 0 && sy < h - 1) gy = (out[(srow + w + sx) * 4] - out[(srow - w + sx) * 4]) * 0.5;
for (let py = -P; py <= P; py++) {
const ty = cy + py;
const s_y = sy + py;
if (ty < 0 || ty >= h || s_y < 0 || s_y >= h) continue;
const rowT = ty * w;
const rowS = s_y * w;
const rExt = out[sp] + gx * (-dx) * 0.3;
const gExt = out[sp + 1] + gy * (-dy) * 0.3;
const bExt = out[sp + 2] + (gx + gy) * 0.5 * (-dx - dy) * 0.15;
for (let px = -P; px <= P; px++) {
const tx = cx + px;
const s_x = sx + px;
if (tx < 0 || tx >= w || s_x < 0 || s_x >= w) continue;
sumR += Math.max(0, Math.min(255, rExt)) * wSample;
sumG += Math.max(0, Math.min(255, gExt)) * wSample;
sumB += Math.max(0, Math.min(255, bExt)) * wSample;
sumW += wSample;
const pTarget = rowT + tx;
if (filled[pTarget]) {
const pDonor = rowS + s_x;
const tIdx = pTarget << 2;
const dIdx = pDonor << 2;
const dr = out[tIdx] - rgba[dIdx];
const dg = out[tIdx + 1] - rgba[dIdx + 1];
const db = out[tIdx + 2] - rgba[dIdx + 2];
sumDiff += dr * dr + dg * dg + db * db;
count++;
}
}
}
if (count > 0) {
const cost = (sumDiff / count) + Math.abs(dy) * 0.4 + Math.abs(dx) * 0.08;
if (cost < bestCost) {
bestCost = cost;
bestDonor = donorIdx;
if (cost < 20.0) break; // early exit on near-perfect match
}
}
}
const p = idx * 4;
if (sumW > 0) {
out[p] = Math.round(sumR / sumW);
out[p + 1] = Math.round(sumG / sumW);
out[p + 2] = Math.round(sumB / sumW);
} else {
let bestD2 = 1e9;
let bestP = boundaryDonors[0] * 4;
const bStep = Math.max(1, Math.floor(boundaryDonors.length / 32));
for (let k = 0; k < boundaryDonors.length; k += bStep) {
const bIdx = boundaryDonors[k];
const bx = bIdx % w;
const by = (bIdx / w) | 0;
const bd2 = (px - bx) ** 2 + (py - by) ** 2;
if (bd2 < bestD2) {
bestD2 = bd2;
bestP = bIdx * 4;
}
}
out[p] = out[bestP];
out[p + 1] = out[bestP + 1];
out[p + 2] = out[bestP + 2];
}
}
// 3. Multi-scale Poisson relaxation
for (let pass = 0; pass < 4; pass++) {
for (let i = 0; i < queue.length; i++) {
const idx = queue[i];
const px = idx % w;
const py = (idx / w) | 0;
if (px === 0 || px === w - 1 || py === 0 || py === h - 1) continue;
const p = idx * 4;
const l = (idx - 1) * 4;
const r = (idx + 1) * 4;
const u = (idx - w) * 4;
const d = (idx + w) * 4;
for (let c = 0; c < 3; c++) {
const avg = (out[l + c] + out[r + c] + out[u + c] + out[d + c]) * 0.25;
out[p + c] = Math.round(out[p + c] * 0.35 + avg * 0.65);
}
}
}
// 4. Authentic Exemplar Texture & Grain Transfer
const bCount = boundaryDonors.length;
const bStep = Math.max(1, Math.floor(bCount / 64));
let noiseVarSum = 0;
let noiseVarCount = 0;
for (let i = 0; i < bCount; i += bStep) {
const bIdx = boundaryDonors[i];
const bx = bIdx % w;
const by = (bIdx / w) | 0;
if (bx > 0 && bx < w - 1 && by > 0 && by < h - 1) {
const bp = bIdx * 4;
const avgSurrR = (rgba[bp - 4] + rgba[bp + 4] + rgba[(by - 1) * w * 4 + bx * 4] + rgba[(by + 1) * w * 4 + bx * 4]) * 0.25;
const avgSurrG = (rgba[bp - 3] + rgba[bp + 5] + rgba[(by - 1) * w * 4 + bx * 4 + 1] + rgba[(by + 1) * w * 4 + bx * 4 + 1]) * 0.25;
const avgSurrB = (rgba[bp - 2] + rgba[bp + 6] + rgba[(by - 1) * w * 4 + bx * 4 + 2] + rgba[(by + 1) * w * 4 + bx * 4 + 2]) * 0.25;
const diffR = rgba[bp] - avgSurrR;
const diffG = rgba[bp + 1] - avgSurrG;
const diffB = rgba[bp + 2] - avgSurrB;
noiseVarSum += Math.abs(diffR) + Math.abs(diffG) + Math.abs(diffB);
noiseVarCount++;
}
}
const avgTextureEnergy = noiseVarCount > 0 ? (noiseVarSum / noiseVarCount) / 3 : 1.5;
let seed = 1234567;
const rand = () => {
seed = (seed * 16807) % 2147483647;
return (seed / 2147483647) - 0.5;
};
for (let i = 0; i < queue.length; i++) {
const idx = queue[i];
const px = idx % w;
const py = (idx / w) | 0;
const mx = Math.max(0, Math.min(w - 1, Math.round(2 * holeCx - px)));
const my = Math.max(0, Math.min(h - 1, Math.round(2 * holeCy - py)));
const mIdx = my * w + mx;
let donorIdx = -1;
if (!mask[mIdx]) {
donorIdx = mIdx;
} else {
let bestDist = 1e9;
for (let k = 0; k < bCount; k += bStep) {
const bp = boundaryDonors[k];
const bbx = bp % w;
const bby = (bp / w) | 0;
const d = (px - bbx) ** 2 + (py - bby) ** 2;
if (d < bestDist) {
bestDist = d;
donorIdx = bp;
}
}
}
const p = idx * 4;
if (donorIdx >= 0) {
const dp = donorIdx * 4;
const dx = donorIdx % w;
const dy = (donorIdx / w) | 0;
let highR = 0, highG = 0, highB = 0;
if (dx > 0 && dx < w - 1 && dy > 0 && dy < h - 1) {
highR = rgba[dp] - (rgba[dp - 4] + rgba[dp + 4] + rgba[(dy - 1) * w * 4 + dx * 4] + rgba[(dy + 1) * w * 4 + dx * 4]) * 0.25;
highG = rgba[dp + 1] - (rgba[dp - 3] + rgba[dp + 5] + rgba[(dy - 1) * w * 4 + dx * 4 + 1] + rgba[(dy + 1) * w * 4 + dx * 4 + 1]) * 0.25;
highB = rgba[dp + 2] - (rgba[dp - 2] + rgba[dp + 6] + rgba[(dy - 1) * w * 4 + dx * 4 + 2] + rgba[(dy + 1) * w * 4 + dx * 4 + 2]) * 0.25;
}
const grain = rand() * Math.min(3.5, avgTextureEnergy * 0.5);
out[p] = Math.max(0, Math.min(255, Math.round(out[p] + highR * 0.75 + grain)));
out[p + 1] = Math.max(0, Math.min(255, Math.round(out[p + 1] + highG * 0.75 + grain)));
out[p + 2] = Math.max(0, Math.min(255, Math.round(out[p + 2] + highB * 0.75 + grain)));
}
out[p + 3] = 255;
const tIdx = idx << 2;
const dIdx = bestDonor << 2;
out[tIdx] = rgba[dIdx];
out[tIdx + 1] = rgba[dIdx + 1];
out[tIdx + 2] = rgba[dIdx + 2];
out[tIdx + 3] = 255;
filled[idx] = 1;
}
return out;