// SHARPENING is an unsharp mask MASKED BY AN EDGE DETECTOR and CORED — // thay_doi_thong_so_giong_lightroom.md §4.2, "Chỉ có viền mắt, lông mi, sợi vải, // kẽ lá nhận Mask = 1; bầu trời phẳng và làn da mịn màng nhận Mask = 0". // // The knob used to be the same 3x3 kernel with Mask = 1 everywhere and no // coring, so a flat field of grain took the same gain an eyelash did and a // rising SHARPENING raised the frame's noise with it. This check pins the three // things the doc asks for and that the old kernel could not do: // // 1. a flat field comes back untouched (no gain on noise), // 2. a soft step below the edge threshold comes back untouched (Mask = 0), // 3. a hard step gains contrast on both sides of it (Mask = 1). // // node scripts/sharpen-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(), 'sharpen-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 { SHARPEN_SKSL, SHARPEN_CORE, SHARPEN_MASK_LO, SHARPEN_MASK_HI } = await import( pathToFileURL(join(dir, 'toneShader.mjs')).href ); const engine = readFileSync(new URL('../src/engine/exportEngine.ts', import.meta.url), 'utf8'); assert.match(engine, /Skia\.RuntimeEffect\.Make\(SHARPEN_SKSL\)/, 'SHARPEN_SKSL is not compiled any more'); assert.doesNotMatch(engine, /CLARITY_SKSL/, 'the unmasked kernel is still wired in somewhere'); assert.ok( SHARPEN_MASK_LO > 0 && SHARPEN_MASK_HI > SHARPEN_MASK_LO, `the doc's smoothstep(T, T + 0.1, G) is not a ramp (${SHARPEN_MASK_LO}..${SHARPEN_MASK_HI})`, ); assert.ok(SHARPEN_CORE > 0, 'Detail coring is off, so a speckle is amplified again'); const CanvasKit = await CanvasKitInit({ locateFile: () => fileURLToPath(new URL('../node_modules/canvaskit-wasm/bin/full/canvaskit.wasm', import.meta.url)), }); const W = 16; const H = 16; const AMOUNT = 0.8; // the engine's own gain at SHARPENING 100: (100 / 10) * 0.8 const near = (a, b, why) => assert.ok(Math.abs(a - b) <= 2 / 255, `${why} (${a} vs ${b})`); // One 8-bit RGBA image from a per-pixel grey level. function imaged(grey) { const px = new Uint8Array(W * H * 4); for (let y = 0; y < H; y++) { for (let x = 0; x < W; x++) { const v = Math.round(Math.max(0, Math.min(1, grey(x, y))) * 255); const i = (y * W + x) * 4; px[i] = px[i + 1] = px[i + 2] = v; px[i + 3] = 255; } } return CanvasKit.MakeImage( { width: W, height: H, colorType: CanvasKit.ColorType.RGBA_8888, alphaType: CanvasKit.AlphaType.Unpremul, colorSpace: CanvasKit.ColorSpace.SRGB, }, px, W * 4, ); } function render(grey) { const effect = CanvasKit.RuntimeEffect.Make(SHARPEN_SKSL); assert.ok(effect, 'SHARPEN_SKSL does not compile'); const src = imaged(grey); const child = src.makeShaderOptions( CanvasKit.TileMode.Clamp, CanvasKit.TileMode.Clamp, CanvasKit.FilterMode.Linear, CanvasKit.MipmapMode.None, ); const shader = effect.makeShaderWithChildren([AMOUNT, 1, 1], [child]); // px = 1,1: 1:1 export canvas assert.ok(shader, 'the pass did not take the frame as its child'); const out = CanvasKit.MakeSurface(W, H); const paint = new CanvasKit.Paint(); paint.setShader(shader); out.getCanvas().drawPaint(paint); const bytes = out.makeImageSnapshot().readPixels(0, 0, { width: W, height: H, colorType: CanvasKit.ColorType.RGBA_8888, alphaType: CanvasKit.AlphaType.Unpremul, colorSpace: CanvasKit.ColorSpace.SRGB, }); const at = (x, y) => bytes[(y * W + x) * 4] / 255; const values = []; for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) values.push(at(x, y)); const mean = values.reduce((a, b) => a + b, 0) / values.length; const std = Math.sqrt(values.reduce((a, b) => a + (b - mean) ** 2, 0) / values.length); return { at, mean, std }; } // 1. A flat field: the Sobel magnitude is 0 and the high-pass is 0, so the whole // Mask is 0 and the frame is handed back exactly. The old kernel gained here. const flat = render(() => 0.5); near(flat.mean, 0.5, 'a flat field moved — Mask = 0 is not being honoured'); assert.ok(flat.std <= 2 / 255, `a flat field gained texture (std ${flat.std})`); // 2. The same picture with grain on it: the noise is under the edge threshold, so // SHARPENING must not raise the frame's noise — the doc's §1 "sạn hạt cát". let seed = 12345; const rnd = () => ((seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff - 0.5); const grain = new Float64Array(W * H); for (let i = 0; i < grain.length; i++) grain[i] = 0.5 + rnd() * 0.04; const grainy = render((x, y) => grain[y * W + x]); const noiseIn = Math.sqrt(grain.reduce((a, b) => a + (b - 0.5) ** 2, 0) / grain.length); assert.ok( grainy.std <= noiseIn + 2 / 255, `grain was amplified by SHARPENING (std ${grainy.std} for a noise of ${noiseIn})`, ); // 3. A soft step, well under the edge threshold: Mask = 0, so it is not touched. const soft = render((x) => (x < W / 2 ? 0.5 : 0.52)); near(soft.at(6, 8), 0.5, 'a sub-threshold step was sharpened on its dark side'); near(soft.at(9, 8), 0.52, 'a sub-threshold step was sharpened on its light side'); // 4. A hard step: Sobel saturates the mask, the high-pass is coring-open, and // both sides move apart. A pass that changed nothing would fail here. const hard = render((x) => (x < W / 2 ? 0.25 : 0.75)); assert.ok(hard.at(7, 8) < 0.25 - 2 / 255, `the dark side of an edge did not move (${hard.at(7, 8)})`); assert.ok(hard.at(8, 8) > 0.75 + 2 / 255, `the light side of an edge did not move (${hard.at(8, 8)})`); // ...and only the two columns beside the edge: the flat halves keep Mask = 0. near(hard.at(1, 8), 0.25, 'a flat half of the frame was sharpened'); near(hard.at(14, 8), 0.75, 'a flat half of the frame was sharpened'); console.log('sharpen ok: edges move, flat fields and grain do not');