diff --git a/docker/frontend/scripts/denoise-check.mjs b/docker/frontend/scripts/denoise-check.mjs new file mode 100644 index 0000000..9197a0c --- /dev/null +++ b/docker/frontend/scripts/denoise-check.mjs @@ -0,0 +1,122 @@ +// 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'); diff --git a/docker/frontend/shared/utils/toneShader.ts b/docker/frontend/shared/utils/toneShader.ts index e68bcba..3ee9156 100644 --- a/docker/frontend/shared/utils/toneShader.ts +++ b/docker/frontend/shared/utils/toneShader.ts @@ -745,6 +745,48 @@ vec4 main(vec2 xy) { // of the reference (0..1, the same units MASK's CLARITY uses on it). export const CLARITY_GAIN = 3.2; +// NOISE REDUCTION — thay_doi_thong_so_giong_lightroom.md §4.1, the colour half. +// The eye is sensitive to a change in brightness and nearly blind to one in hue +// at the same scale, so the knob is spent where it costs no detail: the CHROMA +// comes from a blurred copy of the frame and the LUMA from the frame itself, and +// a strand of hair comes back exactly where it was. The split is this file's own +// lightness/chroma one, `rgb - luma`, the same the tone ramp's header describes. +// +// Until now the whole frame was blurred instead — `MakeBlur` on the draw, one +// sigma over all three channels — which is the doc's own §4 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: the knob could not take a colour speckle out without taking the +// picture's edges with it. +// +// `amount` is the share of the blurred chroma: 0 leaves the pixel exactly as it +// was and 1 hands it the neighbourhood's hue with its own brightness still on +// it, so the two ends of the knob are the identity and the blur and nothing in +// between moves a pixel's luma at all. +// +// The LUMA half of §4.1 (its bilateral filter) is deliberately not here: it is +// the half that costs detail, and no frame has yet shown grain the chroma half +// left behind. ponytail: add it as a second child of this same pass if one does. +export const NR_SKSL = ` +uniform shader sharp; +uniform shader blurred; +uniform float amount; +const float3 NR_LUM = vec3(0.2126, 0.7152, 0.0722); +vec4 main(vec2 xy) { + vec3 s = clamp(sharp.eval(xy).rgb, 0.0, 1.0); + vec3 b = clamp(blurred.eval(xy).rgb, 0.0, 1.0); + float ys = dot(s, NR_LUM); + float yb = dot(b, NR_LUM); + return vec4(clamp(vec3(ys) + mix(s - vec3(ys), b - vec3(yb), amount), 0.0, 1.0), 1.0); +} +`; + +// How far the chroma filter reaches, as a fraction of the frame's width — the +// doc's 3..5 pixels of a full-resolution frame, which is 0.4% of it, so a +// preview and a file average the same share of the picture. The knob's own blur +// was 0.6 of a pixel at NOISE REDUCTION 100, which is under the doc's patch and +// under a colour speckle as well. +export const NR_CHROMA_SPAN = 0.004; + // DEHAZE — raw_parameter_processing_gradient_mask_algorithms.md, section 3.2. // Haze is scattered light: it lifts the DARKEST channel of every patch, which is // the Dark Channel Prior. The dark channel is the MINIMUM of min(r,g,b)/A over diff --git a/docker/frontend/src/engine/exportEngine.ts b/docker/frontend/src/engine/exportEngine.ts index 364e9e3..2d7d8c6 100644 --- a/docker/frontend/src/engine/exportEngine.ts +++ b/docker/frontend/src/engine/exportEngine.ts @@ -14,7 +14,8 @@ // - The negative-side blurs stay on `ImageFilter.MakeBlur`, but a blur cannot // be composited over the un-blurred content, so those passes snapshot, clear // and redraw. Order matches RN's nested filter chain: denoise → clarity → -// mist → soften. +// mist → soften. NOISE REDUCTION's chroma reference is one of those blurs, +// and only its chroma (NR_SKSL) — the knob used to smear all three channels. // - EXIF rewrite (`writeJpegExif`) is not ported yet; `patchJpegDpi(300)` // alone keeps the print size metadata. // ponytail: EXIF says "W4 if the web export needs the camera/lens tags". @@ -30,6 +31,8 @@ import { CLARITY_SKSL, CLARITY_BLUR_SKSL, CLARITY_BLEND_SKSL, + NR_SKSL, + NR_CHROMA_SPAN, DEHAZE_SKSL, dehazeUniformArray, getToneUniforms, @@ -166,6 +169,7 @@ let halationEffect: any = null; let curveEffect: any = null; let clarityBlurEffect: any = null; let clarityBlendEffect: any = null; +let nrEffect: any = null; let dehazeEffect: any = null; function effects() { @@ -178,6 +182,7 @@ function effects() { if (!curveEffect) curveEffect = Skia.RuntimeEffect.Make(CURVE_SKSL); if (!clarityBlurEffect) clarityBlurEffect = Skia.RuntimeEffect.Make(CLARITY_BLUR_SKSL); if (!clarityBlendEffect) clarityBlendEffect = Skia.RuntimeEffect.Make(CLARITY_BLEND_SKSL); + if (!nrEffect) nrEffect = Skia.RuntimeEffect.Make(NR_SKSL); if (!dehazeEffect) dehazeEffect = Skia.RuntimeEffect.Make(DEHAZE_SKSL); return { sharpenEffect, @@ -189,6 +194,7 @@ function effects() { curveEffect, clarityBlurEffect, clarityBlendEffect, + nrEffect, dehazeEffect, }; } @@ -344,6 +350,34 @@ function blurredBase( } } +// A blurred copy of what the surface holds right now, as an image a pass can +// take as a child. Same `MakeBlur` as the ramp's base and the negative blurs, +// but built from a SNAPSHOT rather than from a shader the caller still holds — +// so the snapshot's own shader is freed here, and NOISE REDUCTION can read the +// graded pixels of the frame without re-deriving the draw that made them. +function blurredFrame(surface: any, w: number, h: number, sigma: number): any | null { + flush(surface); + const snap = surface.makeImageSnapshot(); + if (!snap) return null; + const surf = createSurface(w, h); + const child = surf ? imageShaderChild(snap) : null; + try { + if (!surf || !child) return null; + const filter = Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null); + const paint = Skia.Paint(); + paint.setImageFilter(filter); + paint.setShader(child); + surf.getCanvas().drawRect(Skia.XYWHRect(0, 0, w, h), paint); + flush(surf); + disposeAll([paint, filter]); + return surf.makeImageSnapshot() ?? null; + } finally { + if (child) child.dispose(); + surf?.dispose(); + snap.dispose(); + } +} + // --- spatial passes: CLARITY and DEHAZE ------------------------------------- // // Both of the scratchpad docs' algorithms (raw_parameter_processing... §3 and @@ -943,12 +977,6 @@ export async function renderPhoto(input: RenderInput): Promise 0) { - const sigma = (adjustments.denoise / 10) * 0.6; - paint.setImageFilter(own(Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null))); - } - // 5. Draw the graded photo. if (paintShader) { canvas.drawRect(Skia.XYWHRect(0, 0, width, height), paint); @@ -956,6 +984,33 @@ export async function renderPhoto(input: RenderInput): Promise 0) { + const { nrEffect: denoiseEffect } = effects(); + const sigma = (denoiseKnob / 10) * NR_CHROMA_SPAN * width; + // The blur is built before the redraw because the redraw overwrites the + // frame the reference is read out of (the same reason DEHAZE reads its dark + // channel and its light first). + const blurred = denoiseEffect ? own(blurredFrame(surface, width, height, sigma)) : null; + if (denoiseEffect && blurred) { + replaceThrough(canvas, surface, width, height, (snap) => { + const shader = denoiseEffect.makeShaderWithChildren( + [Math.min(1, denoiseKnob / 10)], + [own(imageShaderChild(snap)), own(imageShaderChild(blurred))] + ); + return shader ? own(shader) : null; + }); + } + } + // 4b. DEHAZE and CLARITY — the frame-wide half of the two docs // (raw_parameter_processing... §3, ki_n_tr_c_multiple_passes... in full). Both // compare a pixel against a blurred reference of its own surroundings, so the