fix(heal): restore inpaint-web MI-GAN neural model to resolve grey blob bug

This commit is contained in:
2026-10-16 07:36:25 +07:00
parent 5650fe8888
commit 64e6ff2608
6 changed files with 463 additions and 100 deletions
+22 -21
View File
@@ -9,10 +9,12 @@
// measured here, on synthetic photos whose answer is known, with CanvasKit
// running the same two passes exportEngine runs.
//
// The blur is the fill's only author. MI-GAN used to be a second one, over the
// mask, and it was measured (heal-repro2.mjs) as both the slower half and the
// worse one on every case it was given, so it is off the render path and out of
// the tree; what is asserted about it here is that it stays gone.
// The fill now has a second, better author where one can be had — MI-GAN, over
// the mask (engine/inpaint.ts) — and this check deliberately measures the other
// one: the blur that stands in when the model cannot be had, which is the case
// on a machine without the runtime and the case this file can run offline. What
// is asserted about the model is only that the pass is still wired in, and with
// which polarity.
//
// node scripts/heal-check.mjs
import assert from 'node:assert/strict';
@@ -53,13 +55,21 @@ assert.match(engine, /imageShaderChildAt\(fillImage, boxLeft, boxTop\)/, 'the fi
// from up and to the left of the spot (the case at the foot of this file).
assert.match(engine, /drawImageRect\(cutImage, box, onto, /, 'the fill is drawn at the box\'s frame coordinates, not at the surface\'s own corner');
// And the model stays off the pass: an `await` here is a drag that stalls for
// seconds on every move of the brush, and the crop it is asked for is a ride out
// to the CPU and back that the numbers below never needed.
assert.doesNotMatch(engine, /inpaintCrop\(/, 'exportEngine is asking the model for the crop again');
// The repair is the diffusion fallback itself, called in-line: no model, no
// promise, no cache to fill.
assert.match(engine, /fastLocalInpaint\(cropPixels, mw, mh, hole\)/, 'the repair is the local diffusion, not a model round-trip');
// The model half is wired into the same pass: the masked crop is asked for, the
// answer is written into the fill surface over the box, and the mask it is
// handed is built from the brush's own circles. What it is NOT is measured
// here — a working model is invisible to the numbers below, which are the
// fallback's. The polarity is MI-GAN's, 255 KEEP and 0 HOLE, and it is the one
// thing that, got backwards, repaints the frame and leaves the hole (measured in
// engine/inpaint.ts; the probe is gone).
const inpaint = readFileSync(new URL('../src/engine/inpaint.ts', import.meta.url), 'utf8');
assert.match(engine, /await inpaintCrop\(cropPixels, mw, mh, hole\)/, 'exportEngine does not ask the model for the masked crop');
assert.match(engine, /patchSurface\.getCanvas\(\)\.writePixels\(patch, mw, mh, 0, 0\)/, 'the model answer is never written into the fill surface');
assert.match(engine, /new Uint8Array\(mw \* mh\)/, 'the mask handed to the model is not built at the crop\'s own resolution');
assert.match(engine, /hole\[row \+ x\] = 1/, 'the mask handed to the model is not the brush\'s own circles');
assert.match(inpaint, /fill\(255\)/, 'the model mask no longer starts at KEEP');
assert.match(inpaint, /INPAINT_MAX_SIDE/, 'the crop handed to the model is no longer bounded in size');
assert.match(inpaint, /inpaintCropSize/, 'nothing tells the renderer the resolution the model will run at');
// --- the stored spots --------------------------------------------------------
// A recipe written by the older brush carries sx/sy. It still loads: those two
@@ -299,16 +309,7 @@ for (const p of painted) {
const i = p * 4;
for (let c = 0; c < 3; c++) worstIn = Math.max(worstIn, Math.abs(made.pixels[i + c] - GREY));
}
// The bound is 6 and not 4 because the mask now owns the WHOLE spot (heal.ts,
// HEAL_FEATHER): the pixels it used to fade over — the outer quarter of a radius
// — are painted at full weight now, and this is the one place the fill's own
// maths shows. A blur of the cut reads a neighbourhood, so a rim pixel next to
// the card (3.2 radii out, and the trap this assertion exists for) comes back
// with a share of it: 5 levels of GREY 118. Before the mask reached the rim the
// same pixels measured 4, and the same card, on the pixels the fade had already
// left out. Either number catches what this is for — a borrowed patch is off by
// a hundred, and a spot left unhealed by the dust's own 80.
assert.ok(worstIn <= 6, `a painted pixel is off the ground it sits in by ${worstIn} levels (dust left, or a patch borrowed)`);
assert.ok(worstIn <= 4, `a painted pixel is off the ground it sits in by ${worstIn} levels (dust left, or a patch borrowed)`);
// 3. Nothing survives INSIDE the stroke either: neighbouring painted pixels
// differ by a level or two, the way the ground does, where a spot solved on