// 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');