Files
RecipesCam/docker/frontend/scripts/heal-check.mjs
T

430 lines
20 KiB
JavaScript

// HEAL is a mask and a fill now, not a patch pasted under each spot. Four
// things can go wrong and none of them throws: the circles of one stroke can
// stay separate shapes (which is what a row of round grey blobs on the photo
// is), the fill can fail to carry the light of the photo around it (a repair
// that reads as a mark of its own), the search can still borrow a patch from
// somewhere (the card three radii away, which is the brush the user complained
// about), or the composite can touch pixels it was never given (a smear across
// the whole frame). So the mask, the fill and the untouched ground are all
// measured here, on synthetic photos whose answer is known, with CanvasKit
// running the same two passes exportEngine runs.
//
// node scripts/heal-check.mjs
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import ts from 'typescript';
import CanvasKitInit from 'canvaskit-wasm/bin/full/canvaskit.js';
const transpile = (path) =>
ts.transpileModule(readFileSync(new URL(path, import.meta.url), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
}).outputText;
const dir = mkdtempSync(join(tmpdir(), 'heal-check-'));
writeFileSync(join(dir, 'heal.mjs'), transpile('../shared/utils/heal.ts'));
const { readHeal, HEAL_MASK_STOPS, HEAL_FILL_BLUR, HEAL_FILL_MAX_LEVELS, HEAL_CUT_SKSL, HEAL_FILL_SKSL } = await import(
pathToFileURL(join(dir, 'heal.mjs')).href
);
// The other half of the check: the passes below are these two shaders, so the
// engine has to be compiling them — and it must not still be building the
// per-spot shader this replaced.
const engine = readFileSync(new URL('../src/engine/exportEngine.ts', import.meta.url), 'utf8');
assert.match(engine, /healCutEffect = Skia\.RuntimeEffect\.Make\(HEAL_CUT_SKSL\)/, 'exportEngine does not compile HEAL_CUT_SKSL');
assert.match(engine, /healFillEffect = Skia\.RuntimeEffect\.Make\(HEAL_FILL_SKSL\)/, 'exportEngine does not compile HEAL_FILL_SKSL');
assert.doesNotMatch(engine, /healSkSL\(/, 'the per-spot heal shader is still in exportEngine');
assert.doesNotMatch(engine, /healUniforms\(/, 'the per-spot uniform block is still in exportEngine');
// ...and the fill's level count is the one this check builds, not a constant.
assert.match(engine, /HEAL_FILL_MAX_LEVELS/, 'exportEngine does not cap the fill levels at HEAL_FILL_MAX_LEVELS');
assert.match(engine, /1 \+ Math\.ceil\(Math\.log2\(2 \+ span \/ 2 \/ widest\)\)/, 'exportEngine no longer scales the fill levels with the depth of the hole');
// ...and the fill is built on the brush's box, read back at the frame's origin.
assert.match(engine, /imageShaderChildAt\(fillImage, boxLeft, boxTop\)/, 'the fill child is read at its own origin instead of the frame\'s');
// ...and the box the fill is built on is drawn at the surface's own corner, which
// is where that child reads it. Drawn at the frame's coordinates instead, the two
// disagree by the box's corner, and the repair comes back a patch of the frame
// 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');
// --- the stored spots --------------------------------------------------------
// A recipe written by the older brush carries sx/sy. It still loads: those two
// numbers are no longer read, and nothing else about the spot moves.
assert.deepEqual(readHeal([{ x: 0.5, y: 0.5, r: 0.02, sx: 0.9, sy: 0.1 }]), [{ x: 0.5, y: 0.5, r: 0.02 }]);
// Out of frame, non-numeric, or a radius of nothing: a spot the brush and the
// renderer would disagree about is dropped either way.
assert.deepEqual(readHeal([{ x: 2, y: -1, r: 0.05 }, { x: 'a', y: 0, r: 0.05 }, { x: 0.5, y: 0.5, r: 0 }]), [
{ x: 1, y: 0, r: 0.05 },
{ x: 0, y: 0, r: 0.05 },
]);
const CanvasKit = await CanvasKitInit({
locateFile: () => fileURLToPath(new URL('../node_modules/canvaskit-wasm/bin/full/canvaskit.wasm', import.meta.url)),
});
// The frame every pass below is built on. One size for most of the check; the
// last case needs a bigger one, which is the whole point of it (see there), so
// these two are the frame and the functions here read them.
let W = 160;
let H = 160;
const GREY = 118;
const DUST = 38;
const R = 0.05; // the brush: an 8px radius on this frame
// A stroke of nine spots, one every 0.6 of a radius, running down the frame.
const spots = Array.from({ length: 9 }, (_, i) => ({ x: 0.5, y: (48 + i * R * 0.6 * W) / H, r: R }));
const cx = 0.5 * W;
const cy = 48 + 4 * R * 0.6 * W;
// Three radii to the right of the stroke: where the old search looked.
const CARD_X = Math.round(cx + 3.2 * R * W);
const shadeFlat = (x, y) => {
if (Math.abs(x - CARD_X) < 6 && Math.abs(y - cy) < 6) return [220, 30, 30];
if (Math.hypot(x - cx, y - cy) < 6) return [DUST, DUST, DUST];
return [GREY, GREY, GREY];
};
const shadeRamp = (x) => {
const v = 40 + (120 * x) / (W - 1);
return [v, v, v];
};
function bytesOf(shade) {
const rgba = new Uint8Array(W * H * 4);
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const [r, g, b] = shade(x, y);
const i = (y * W + x) * 4;
rgba[i] = r;
rgba[i + 1] = g;
rgba[i + 2] = b;
rgba[i + 3] = 255;
}
}
return rgba;
}
const imageOf = (rgba) =>
CanvasKit.MakeImage(
{
width: W,
height: H,
colorType: CanvasKit.ColorType.RGBA_8888,
alphaType: CanvasKit.AlphaType.Unpremul,
colorSpace: CanvasKit.ColorSpace.SRGB,
},
rgba,
W * 4
);
const surface = (w, h) => {
const s = CanvasKit.MakeSurface(w, h);
s.getCanvas().clear(CanvasKit.TRANSPARENT);
return s;
};
const pixelsOf = (image) => {
const s = surface(image.width(), image.height());
s.getCanvas().drawImage(image, 0, 0);
return s.getCanvas().readPixels(0, 0, {
width: image.width(),
height: image.height(),
colorType: CanvasKit.ColorType.RGBA_8888,
alphaType: CanvasKit.AlphaType.Unpremul,
colorSpace: CanvasKit.ColorSpace.SRGB,
});
};
const asChild = (image) =>
image.makeShaderOptions(CanvasKit.TileMode.Clamp, CanvasKit.TileMode.Clamp, CanvasKit.FilterMode.Linear, CanvasKit.MipmapMode.None);
// The fill's own child, read in the frame's coordinates: the fill is built on
// the brush's box (below), and a whole-pixel shift is what the engine does.
const asChildAt = (image, x, y) =>
image.makeShaderOptions(
CanvasKit.TileMode.Clamp,
CanvasKit.TileMode.Clamp,
CanvasKit.FilterMode.Linear,
CanvasKit.MipmapMode.None,
[1, 0, x, 0, 1, y, 0, 0, 1]
);
// The brush's circles as ONE mask: a radial gradient per spot, solid out to
// HEAL_FEATHER of its radius and clear at its edge, each laid over the last.
function maskOf(spotsIn) {
const surf = surface(W, H);
const canvas = surf.getCanvas();
for (const s of spotsIn) {
const rad = s.r * W;
const shader = CanvasKit.Shader.MakeRadialGradient(
[s.x * W, s.y * H],
rad,
new Float32Array([1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0]),
HEAL_MASK_STOPS,
CanvasKit.TileMode.Clamp
);
const paint = new CanvasKit.Paint();
paint.setAntiAlias(true);
paint.setShader(shader);
canvas.drawCircle(s.x * W, s.y * H, rad, paint);
}
return surf.makeImageSnapshot();
}
// exportEngine's 6e, pass for pass: cut, the blurred fill, and the fill laid
// back under the brush.
function healPass(rgba, spotsIn) {
const src = imageOf(rgba);
const mask = maskOf(spotsIn);
const cutEffect = CanvasKit.RuntimeEffect.Make(HEAL_CUT_SKSL);
assert.ok(cutEffect, 'HEAL_CUT_SKSL does not compile');
const cut = surface(W, H);
const cutPaint = new CanvasKit.Paint();
cutPaint.setShader(cutEffect.makeShaderWithChildren([], [asChild(src), asChild(mask)]));
cut.getCanvas().drawRect(CanvasKit.XYWHRect(0, 0, W, H), cutPaint);
// The fill: the cut frame blurred at a few widths, widest first, each finer
// one drawn over it, the untouched cut on top. A blur is a weighted mean of
// the premultiplied pixels around each pixel, and the hole weighs nothing, so
// the hole comes back as the light of its rim. Widest first means the widest
// blur only has a say where every finer one has nothing — the middle of a hole
// deeper than a brush — and the untouched cut wins wherever the photo is still
// there, so the rim is the rim exactly.
//
// Blurs and not the pyramid of halvings the engine used to build: this Skia
// gets the premultiply wrong on a snapshot drawn with a SCALE — its rgb comes
// back with the alpha multiplied in a second time — and a pyramid is made of
// exactly those halvings, so a partly transparent rim came back a wash of
// grey. Every draw here is 1:1, which filters correctly.
const cutImage = cut.makeImageSnapshot();
// The count and the widths are exportEngine's own, to the letter: a check
// that picks its own levels is checking a renderer nobody ships. So is the
// surface the fill is built on — the brush's own box, padded by three sigma of
// the widest level, rather than the frame.
const widest = Math.max(...spotsIn.map((s) => s.r * W));
const xs = spotsIn.map((s) => s.x * W);
const ys = spotsIn.map((s) => s.y * H);
const span = Math.hypot(Math.max(...xs) - Math.min(...xs), Math.max(...ys) - Math.min(...ys));
const levelCount = Math.min(
HEAL_FILL_MAX_LEVELS,
Math.max(2, 1 + Math.ceil(Math.log2(2 + span / 2 / widest)))
);
const pad = 3 * HEAL_FILL_BLUR * widest * 2 ** (levelCount - 1);
const boxLeft = Math.max(0, Math.floor(Math.min(...xs) - widest - pad));
const boxTop = Math.max(0, Math.floor(Math.min(...ys) - widest - pad));
const boxRight = Math.min(W, Math.ceil(Math.max(...xs) + widest + pad));
const boxBottom = Math.min(H, Math.ceil(Math.max(...ys) + widest + pad));
const box = CanvasKit.XYWHRect(boxLeft, boxTop, boxRight - boxLeft, boxBottom - boxTop);
// ...and the box's region is drawn at the surface's OWN corner, which is what
// the child below reads it back with. Both rects the `box` — the frame's own
// coordinates on a surface the size of the box — is the draw that leaves the
// surface's left and top strips empty, clips its far side, and hands the
// composite a patch of the frame from boxLeft, boxTop up and to the left of the
// spot: a repair the wrong shade, on a ramp a far-off one. `box, onto`, and the
// case at the foot of this file is what keeps it that way.
const onto = CanvasKit.XYWHRect(0, 0, boxRight - boxLeft, boxBottom - boxTop);
const fillBox = surface(boxRight - boxLeft, boxBottom - boxTop);
for (let k = levelCount; k >= 1; k--) {
const sigma = HEAL_FILL_BLUR * widest * 2 ** (k - 1);
const blurPaint = new CanvasKit.Paint();
blurPaint.setImageFilter(CanvasKit.ImageFilter.MakeBlur(sigma, sigma, CanvasKit.TileMode.Clamp, null));
fillBox.getCanvas().drawImageRect(cutImage, box, onto, blurPaint);
}
fillBox.getCanvas().drawImageRect(cutImage, box, onto, new CanvasKit.Paint());
const fillImage = fillBox.makeImageSnapshot();
const fillEffect = CanvasKit.RuntimeEffect.Make(HEAL_FILL_SKSL);
assert.ok(fillEffect, 'HEAL_FILL_SKSL does not compile');
const out = surface(W, H);
const paint = new CanvasKit.Paint();
paint.setShader(fillEffect.makeShaderWithChildren([], [asChild(src), asChildAt(fillImage, boxLeft, boxTop), asChild(mask)]));
out.getCanvas().drawRect(CanvasKit.XYWHRect(0, 0, W, H), paint);
return { pixels: pixelsOf(out.makeImageSnapshot()), mask: pixelsOf(mask), boxLeft, boxTop };
}
const flat = bytesOf(shadeFlat);
const made = healPass(flat, spots);
const alphaAt = (x, y) => made.mask[(y * W + x) * 4 + 3];
const inMask = (x, y) => alphaAt(x, y) > 200;
const rgbAt = (x, y) => {
const i = (y * W + x) * 4;
return [made.pixels[i], made.pixels[i + 1], made.pixels[i + 2]];
};
// 1. The mask is one shape. Painting a stroke as nine overlapping spots has to
// leave one painted region, not nine: every painted pixel is reachable from
// the first spot's centre without ever stepping off the mask. This is what
// "mask liền mạch" is, and it is the thing the row of circles failed.
const painted = [];
for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) if (inMask(x, y)) painted.push(y * W + x);
const seen = new Set();
const queue = [painted[0]];
seen.add(painted[0]);
while (queue.length) {
const p = queue.pop();
const x = p % W;
const y = (p - x) / W;
for (const [dx, dy] of [
[1, 0],
[-1, 0],
[0, 1],
[0, -1],
]) {
const nx = x + dx;
const ny = y + dy;
if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
const q = ny * W + nx;
if (seen.has(q) || !inMask(nx, ny)) continue;
seen.add(q);
queue.push(q);
}
}
assert.equal(seen.size, painted.length, `${painted.length - seen.size} painted pixels are not reachable from the rest of the stroke`);
// ...and the mask is solid in the middle and clear well outside it.
assert.equal(alphaAt(Math.round(cx), Math.round(cy)), 255, 'the middle of the stroke is not fully painted');
assert.equal(alphaAt(2, 2), 0, 'the mask leaked into the corner of the frame');
// 2. The dust is gone and nothing was borrowed to do it: every painted pixel
// ends on the ground's own value, 3.2 radii from a red card that a patch
// search would have pasted in.
let worstIn = 0;
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));
}
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
// its own would step at every seam.
let worstStep = 0;
for (let y = 0; y + 1 < H; y++) {
for (let x = 0; x + 1 < W; x++) {
if (!inMask(x, y) || !inMask(x + 1, y)) continue;
worstStep = Math.max(worstStep, Math.abs(rgbAt(x, y)[0] - rgbAt(x + 1, y)[0]));
}
}
assert.ok(worstStep <= 4, `the fill steps by ${worstStep} levels between neighbours inside the mask`);
// 4. The repair carries the LIGHT of where it landed. On a photo running from
// dark to light across the frame, the painted pixels come back on that ramp
// rather than at one flat value borrowed from somewhere else. The bound is
// the ramp's own steepness, not zero: the fill reads the rim of the hole, and
// where the hole takes weight off one side of a pixel its average slides that
// way by about a pixel's worth of the ramp — 0.75 levels a pixel across this
// frame. On the flat ground of 2 and 3 it is nothing at all.
const ramped = healPass(bytesOf(shadeRamp), spots);
let worstRamp = 0;
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
if (ramped.mask[(y * W + x) * 4 + 3] < 250) continue;
worstRamp = Math.max(worstRamp, Math.abs(ramped.pixels[(y * W + x) * 4] - shadeRamp(x)[0]));
}
}
assert.ok(worstRamp <= 6, `the fill is off the ramp it sits in by ${worstRamp} levels`);
// 5. Outside the mask, nothing moved. The pass is asked for the painted pixels
// and no others — and "painted" is the whole brush, feather included: the last
// 15% of a spot is where the repair is mixed back in, so it is part of the
// repair. Past the brush's own edge the photo is the photo.
let outWorst = 0;
let outAt = 0;
for (let p = 0; p < W * H; p++) {
const x = p % W;
const y = (p - x) / W;
if (alphaAt(x, y) > 0) continue;
for (let c = 0; c < 3; c++) {
const off = Math.abs(made.pixels[p * 4 + c] - flat[p * 4 + c]);
if (off > outWorst) {
outWorst = off;
outAt = x;
}
}
}
assert.equal(outWorst, 0, `a pixel outside the mask (x=${outAt}) changed by ${outWorst} levels`);
// 6. The same photo and the same spots give the same photo, byte for byte.
assert.deepEqual(
Buffer.from(healPass(flat, spots).pixels),
Buffer.from(made.pixels),
'the pass is not deterministic'
);
// 7. A mask with no rim to read from — one spot over the whole frame — leaves
// the photo alone. Nothing is painted black, because nothing was found to
// paint it with, and a frame is still a frame.
const all = healPass(flat, [{ x: 0.5, y: 0.5, r: 2 }]);
let worstAll = 0;
for (let p = 0; p < W * H; p++) {
for (let c = 0; c < 3; c++) worstAll = Math.max(worstAll, Math.abs(all.pixels[p * 4 + c] - flat[p * 4 + c]));
}
assert.equal(worstAll, 0, `a frame-wide mask changed the photo by ${worstAll} levels`);
// 8. The shape the brush is actually used in: one long thin run right across the
// frame, on a photo that runs dark to light along it. Its rim is the pixels
// just above and below it wherever it passes, so the repair comes back on the
// ramp there — not at the frame's average colour. A hole this long needs the
// level count to grow with it: too few levels and the middle of the run is
// farther from any rim than the widest blur reaches, so the fill comes back
// the average of a blur too wide to have read the rim — one flat mark.
const band = [];
for (let i = 0; i <= 60; i++) band.push({ x: 0.12 + (0.76 * i) / 60, y: 0.5, r: 0.02 });
const across = healPass(bytesOf(shadeRamp), band);
let worstBand = 0;
let bandAt = 0;
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
if (across.mask[(y * W + x) * 4 + 3] < 250) continue;
const off = Math.abs(across.pixels[(y * W + x) * 4] - shadeRamp(x)[0]);
if (off > worstBand) {
worstBand = off;
bandAt = x;
}
}
}
assert.ok(worstBand <= 8, `a painted pixel at x=${bandAt} is ${worstBand} levels off the rim it sits in`);
// 9. The one case every size above hides: a frame big enough that the fill's box
// sits INSIDE it. On a 160px frame with a 8px brush the padding is wider than
// the frame, so the box is the frame and its corner is (0,0) — a fill drawn at
// the box's coordinates and one drawn at the surface's own are then the same
// draw, and a repair off by the box's corner is off by nothing. On a real photo
// it is not nothing: a brush at the sensor-dust end of the scale, a frame a few
// thousand pixels across, and the corner is hundreds of pixels up and to the
// left of the spot. That is the shape the reader heals with, and on a ramp the
// patch comes back at the ramp's value hundreds of pixels away — the healed
// area is the wrong shade, which is what this case is here to catch.
{
const keptW = W;
const keptH = H;
// Tall enough that the box clears BOTH edges: the padding is three blur
// radii of the widest brush, which on this frame is 192px, so a shorter
// frame would push boxTop to 16 and the corner would nearly be the edge.
W = 640;
H = 720;
const shadeWide = (x) => {
const v = 40 + (120 * x) / (W - 1);
return [v, v, v];
};
const spot = [{ x: 0.5, y: 0.5, r: 0.05 }]; // a 32px brush on this frame
const wide = healPass(bytesOf(shadeWide), spot);
assert.ok(
wide.boxLeft > 16 && wide.boxTop > 16,
`the fill's box is not inset on this frame (boxLeft=${wide.boxLeft}, boxTop=${wide.boxTop}) — this case is no longer testing the bug it exists for`
);
let worstWide = 0;
let wideAt = 0;
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
if (wide.mask[(y * W + x) * 4 + 3] < 250) continue;
const off = Math.abs(wide.pixels[(y * W + x) * 4] - shadeWide(x)[0]);
if (off > worstWide) {
worstWide = off;
wideAt = x;
}
}
}
assert.ok(
worstWide <= 4,
`a spot healed at the middle of a ${W}x${H} frame came back ${worstWide} levels off at x=${wideAt} — the fill is read at the box's corner, not at the spot (box is at ${wide.boxLeft},${wide.boxTop})`
);
W = keptW;
H = keptH;
}
console.log('ok the stroke is one mask, the fill carries the light, and nothing outside it moved');