Files
RecipesCam/docker/frontend/scripts/denoise-check.mjs
T
3dtours 90a7ec9e46 web: NOISE REDUCTION takes the colour speckle out of the frame and leaves every strand of it where it was
The knob was a `MakeBlur` image filter on the draw of the graded photo — one
sigma over all three channels — so at NOISE REDUCTION 100 a 1024-pixel preview
lost every edge finer than 0.6 of a pixel of its own and nothing brought the
luminance detail back. That is thay_doi_thong_so_giong_lightroom.md §4's own
warning ("Noise Reduction sẽ làm nhòe toàn bộ chi tiết sợi tóc và vân da") written
into the engine, and the filter had a second cost: a paint filter is handed the
shader's INPUT, so the pass could never read the graded pixels it was supposed to
correct.

It is a two-child pass now, NR_SKSL, run after the draw: the frame, and a blurred
copy of it (blurredFrame — the snapshot read back through the same MakeBlur the
ramp's base and the negative sharpening use). The output takes its CHROMA from
the blurred child and its LUMA from the frame, the split the tone ramp's header
already describes (`rgb - luma`), so the output's brightness is the input's at
every amount by construction — the eye is nearly blind to a hue change at that
scale, which is the whole reason the colour half is free. The reference reaches
NR_CHROMA_SPAN = 0.4% of the frame's width, the doc's own 3..5 pixels of a
full-resolution frame, where the knob's blur was 0.6 of a pixel.

The luminance half of §4.1 (its bilateral filter) is deliberately not here: it is
the half that costs detail and no frame has shown grain the colour half left
behind. ponytail: add it as a second child of this same pass when one does.

Checked: `tsc --noEmit` clean; `denoise-check.mjs` (new) compiles NR_SKSL on
CanvasKit, asserts the engine still wires both children and no longer blurs the
draw, and renders the pass at five amounts against a flat pair — amount 0 is the
pixel exactly, amount 1 carries the neighbourhood's colour difference, and the
luma never moves at any of them; `highlight-knee-check.mjs`, `tone-base-check.mjs`
and `mask-wb-check.mjs` still green.
2026-10-02 08:14:25 +07:00

123 lines
5.7 KiB
JavaScript

// NOISE REDUCTION blurs the CHROMA of the frame and leaves its LUMA alone —
// thay_doi_thong_so_giong_lightroom.md §4.1A, "chi tiết ảnh không bị mất đi 1 sợi
// tóc nào".
//
// The knob used to be a `MakeBlur` image filter on the draw, i.e. one sigma over
// all three channels, which is the doc's own §4 warning about losing hair and
// skin written into the engine — and a filter on the draw can only be handed the
// shader's INPUT, so the pass could never read the graded pixels it was meant to
// correct either. It is a two-child pass now (NR_SKSL: the frame, and a blurred
// copy of it), and this check pins both halves of that: the shape of the wiring
// in exportEngine, and the one property that makes the knob safe — the luma of
// the output is the luma of the input, at every amount.
//
// node scripts/denoise-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(), 'denoise-check-'));
writeFileSync(join(dir, 'colorUtils.mjs'), transpile('../shared/utils/colorUtils.ts'));
writeFileSync(
join(dir, 'toneShader.mjs'),
transpile('../shared/utils/toneShader.ts').replace(
/^import .*from ['"]\.\/colorUtils['"];$/m,
'import { HSL_BANDS, hslBandGaps, isMonochromeBase } from "./colorUtils.mjs";',
),
);
const { NR_SKSL, NR_CHROMA_SPAN } = await import(pathToFileURL(join(dir, 'toneShader.mjs')).href);
const engine = readFileSync(new URL('../src/engine/exportEngine.ts', import.meta.url), 'utf8');
// The engine still builds this shader, still hands it the frame and its blur,
// and no longer blurs the whole draw.
assert.match(engine, /Skia\.RuntimeEffect\.Make\(NR_SKSL\)/, 'NR_SKSL is not compiled any more');
assert.match(
engine,
/denoiseEffect\.makeShaderWithChildren\(\s*\[Math\.min\(1, denoiseKnob \/ 10\)\],\s*\[\s*own\(imageShaderChild\(snap\)\), own\(imageShaderChild\(blurred\)\)\s*\]\s*\)/,
'the denoise pass no longer takes the frame and its blurred copy as two children'
);
assert.doesNotMatch(
engine,
/adjustments\.denoise > 0[\s\S]{0,200}?ImageFilter\.MakeBlur/,
'the whole frame is blurred on the draw again — that is the detail the knob used to take'
);
assert.ok(
NR_CHROMA_SPAN >= 0.003 && NR_CHROMA_SPAN <= 0.005,
`NR_CHROMA_SPAN ${NR_CHROMA_SPAN} is not the doc's 3..5 pixels of a frame`
);
const CanvasKit = await CanvasKitInit({
locateFile: () => fileURLToPath(new URL('../node_modules/canvaskit-wasm/bin/full/canvaskit.wasm', import.meta.url)),
});
const SIZE = 4;
const NR_LUM = [0.2126, 0.7152, 0.0722];
const luma = (c) => c[0] * NR_LUM[0] + c[1] * NR_LUM[1] + c[2] * NR_LUM[2];
const near = (a, b, why) => assert.ok(Math.abs(a - b) <= 2 / 255, `${why} (${a} vs ${b})`);
function flat(rgb) {
const surf = CanvasKit.MakeSurface(SIZE, SIZE);
const paint = new CanvasKit.Paint();
// Color4f, not Color: CanvasKit's Color takes 0..255 and would truncate a
// channel of 0.8 to zero (the rest of the checks in here pass ints for that
// reason, these colours are fractions).
paint.setColor(CanvasKit.Color4f(rgb[0], rgb[1], rgb[2], 1));
surf.getCanvas().drawPaint(paint);
return surf.makeImageSnapshot();
}
const asChild = (image) =>
image.makeShaderOptions(
CanvasKit.TileMode.Clamp, CanvasKit.TileMode.Clamp, CanvasKit.FilterMode.Linear, CanvasKit.MipmapMode.None,
);
// A warm pixel over a neutral neighbourhood: the two ends of the knob are the
// pixel itself and the neighbourhood's hue on the pixel's own brightness.
const sharp = [0.8, 0.2, 0.35];
const blurred = [0.4, 0.45, 0.5];
function render(amount) {
const effect = CanvasKit.RuntimeEffect.Make(NR_SKSL);
assert.ok(effect, 'NR_SKSL does not compile');
const src = flat(sharp);
const ref = flat(blurred);
const shader = effect.makeShaderWithChildren([amount], [asChild(src), asChild(ref)]);
assert.ok(shader, 'the pass did not take two children — the blur is not wired in');
const out = CanvasKit.MakeSurface(SIZE, SIZE);
const paint = new CanvasKit.Paint();
paint.setShader(shader);
out.getCanvas().drawPaint(paint);
const px = out.makeImageSnapshot().readPixels(0, 0, {
width: SIZE, height: SIZE, colorType: CanvasKit.ColorType.RGBA_8888,
alphaType: CanvasKit.AlphaType.Unpremul, colorSpace: CanvasKit.ColorSpace.SRGB,
});
return [px[0] / 255, px[1] / 255, px[2] / 255];
}
const at0 = render(0);
near(at0[0], sharp[0], 'amount 0 is not the pixel itself (red)');
near(at0[1], sharp[1], 'amount 0 is not the pixel itself (green)');
near(at0[2], sharp[2], 'amount 0 is not the pixel itself (blue)');
// The colour difference is the neighbourhood's at amount 1 — and that is a real
// move: a pixel that came back unchanged would mean the pass did nothing.
const at1 = render(1);
const chroma = (c) => c.map((v) => v - luma(c));
near(chroma(at1)[0], chroma(blurred)[0], 'amount 1 does not carry the blurred red difference');
near(chroma(at1)[2], chroma(blurred)[2], 'amount 1 does not carry the blurred blue difference');
// The property the doc's §4.1A is about, and the reason the knob costs no
// detail: whatever the amount, the output's brightness is the pixel's own.
for (const amount of [0, 0.25, 0.5, 0.75, 1]) {
near(luma(render(amount)), luma(sharp), `amount ${amount} moved the pixel's luma`);
}
console.log('denoise ok: the chroma of the frame is blurred, its luma never is');