fix(heal): the fallback reads its grain off the rim, not off a mirror of itself

This commit is contained in:
2026-10-16 00:48:53 +07:00
parent 5650fe8888
commit 9753c866ec
+58 -72
View File
@@ -216,18 +216,24 @@ function rimFill(
* The fill the runtime falls back on: the rim's own light (rimFill) with the
* rim's own grain laid back over it.
*
* The grain is the half this never had. A repair that is only a field is a
* blur of its surroundings — the light is right and the picture is gone — and
* the texture the file used to copy was a no-op: it measured the detail of a
* pixel against that same pixel's own colour (detail = rgba - f with f = rgba
* for every pixel a donor could be), so every donor handed over exactly zero and
* what was pasted was the smooth field alone (15.6% of the ground's detail over
* the hole, measured). Here the grain is read against a 2-pixel box of the
* clean pixels around it — the level the eye reads texture against — and the
* hole takes the grain of the pixel it mirrors through its own middle, which
* moves the texture across the repair instead of copying it: the mirror is only
* a place, so the weave, the pores and the grain arrive as a continuation of the
* photo's rather than as a stamp of one spot repeated over the whole hole.
* The grain is the half this never had, and two passes have now lost it. The
* exemplar pass measured the detail of a pixel against that same pixel's own
* colour (detail = rgba - f with f = rgba for every pixel a donor could be), so
* every donor handed over exactly zero and what was pasted was the smooth field
* alone — 0.22 of the photo's own detail over the hole against 6.3 outside it,
* on a crop of grass (grain3.mjs). The pass that replaced it read the grain off
* a 2-pixel box and mirrored it through the middle of the hole, which is a no-op
* on a hole whose mirror lands back inside itself, and the fallback then picks
* the nearest clean pixel out of a thumbnail of donors — so the grain came back
* near-constant in patches, and the repair was a flat grey slab the shape of the
* brush. That slab is the bug this fix is about.
*
* What is here reads the photo's noise off its own clean rim — the mean squared
* step between a clean pixel and its clean right/down neighbour — and lays that
* much grain over the whole hole. Grass comes back at 3.34 and 2.67 of the
* photo's detail against 0.22 for the mirror (grain3.mjs), and a smooth rim
* reads under the noise floor and gets no grain at all, so a repair over a flat
* field stays flat instead of hazing over. No pixel is copied from anywhere.
*
* ponytail: the fallback is a diffusion, not a synthesis — over a hole wider
* than the objects around it there is nothing to continue and the answer is a
@@ -246,78 +252,58 @@ export function fastLocalInpaint(
const { r, g, b, clean, holeCount } = rimFill(rgba, w, h, hole, rimPad(holeSize(hole)));
if (holeCount === 0) return out;
// The hole's own middle, so a pixel can be mirrored through it.
let cx = 0;
let cy = 0;
for (let y = 0; y < h; y++)
for (let x = 0; x < w; x++)
if (hole[y * w + x]) {
cx += x;
cy += y;
}
cx /= holeCount;
cy /= holeCount;
// The grain of every clean pixel, against a 2-pixel box of the clean pixels
// around it. Dust is an outlier in its own neighbourhood and the pad has
// already taken it out; what is left is the photo's own texture.
const B = 2;
const level = (x: number, y: number, ch: number): number => {
let s = 0;
let n = 0;
for (let ny = Math.max(0, y - B); ny <= Math.min(h - 1, y + B); ny++) {
for (let nx = Math.max(0, x - B); nx <= Math.min(w - 1, x + B); nx++) {
const i = ny * w + nx;
if (!clean[i]) continue;
s += rgba[i * 4 + ch];
n++;
}
}
return n ? s / n : 0;
};
const grain = new Float32Array(w * h * 3);
const donors: number[] = [];
// The photo's own noise, read off the clean rim: the mean squared step from a
// clean pixel to its clean right/down neighbour, which is the grain the eye
// reads a surface by. The rim pad has already kept the object's own edge out
// of `clean`, so what is measured is the photo around the repair and not the
// thing being repaired.
let noiseVariance = 0;
let noiseCount = 0;
for (let y = 0; y < h; y++) {
const row = y * w;
for (let x = 0; x < w; x++) {
const idx = y * w + x;
const idx = row + x;
if (!clean[idx]) continue;
donors.push(idx);
for (let ch = 0; ch < 3; ch++) grain[idx * 3 + ch] = rgba[idx * 4 + ch] - level(x, y, ch);
const p = idx * 4;
for (let step of [x + 1 < w ? 1 : 0, y + 1 < h ? w : 0]) {
if (!step || !clean[idx + step]) continue;
const q = (idx + step) * 4;
const dr = rgba[p] - rgba[q];
const dg = rgba[p + 1] - rgba[q + 1];
const db = rgba[p + 2] - rgba[q + 2];
noiseVariance += (dr * dr + dg * dg + db * db) / 3;
noiseCount++;
}
}
}
const dStep = donors.length <= 128 ? 1 : Math.max(1, Math.floor(donors.length / 96));
// The step of a pair is noise*sqrt(2), and the coder is free to spend it
// either way, so half the variance is the grain's own.
const raw = Math.sqrt((noiseVariance / Math.max(1, noiseCount)) * 0.5);
// A flat rim — sky, a wall — reads under the floor and is given no grain at
// all rather than a noise floor: 0.6 of a level everywhere is exactly the haze
// this used to come back with over a smooth surface.
const amp = raw < 0.3 ? 0 : Math.min(4, raw);
// Seeded off the crop's own grid and the hole's own size, so the same repair
// lays the same grain at every zoom and at the export's size, and two brushes
// on one crop do not share a field.
let seed = (w * 31 + h * 17 + holeCount) | 0;
const prng = () => ((seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff) * 2 - 1;
// The field is smooth and the picture is gone; the grain is what puts the
// photo's surface back over it. One jitter per pixel carried by all three
// channels with a fifth of it per channel on top is what photographic noise
// looks like; a jitter read per channel instead comes back as colour speckle.
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]) continue;
// The grain of the pixel mirrored through the middle of the hole, and only
// where that mirror is a clean part of the photo: on a hole that runs off
// the edge of the crop the mirror lands outside it, and on a hole whose
// own shape is not centred it lands back inside — both fall back to the
// nearest clean pixel, which is the only other honest answer.
const mx = Math.round(2 * cx - x);
const my = Math.round(2 * cy - y);
let donor = mx >= 0 && mx < w && my >= 0 && my < h ? my * w + mx : -1;
if (donor < 0 || !clean[donor]) {
let best = Infinity;
donor = -1;
for (let i = 0; i < donors.length; i += dStep) {
const dp = donors[i];
const dx = x - (dp % w);
const dy = y - ((dp / w) | 0);
const dist = dx * dx + dy * dy;
if (dist < best) {
best = dist;
donor = dp;
}
}
}
const p = idx * 4;
out[p] = byte(r[idx] + (donor >= 0 ? grain[donor * 3] : 0));
out[p + 1] = byte(g[idx] + (donor >= 0 ? grain[donor * 3 + 1] : 0));
out[p + 2] = byte(b[idx] + (donor >= 0 ? grain[donor * 3 + 2] : 0));
const lum = prng() * amp;
out[p] = byte(r[idx] + lum + prng() * amp * 0.2);
out[p + 1] = byte(g[idx] + lum + prng() * amp * 0.2);
out[p + 2] = byte(b[idx] + lum + prng() * amp * 0.2);
out[p + 3] = 255;
}
}