From 90a7ec9e46339cb06667026be91047396684c1fd Mon Sep 17 00:00:00 2001 From: 3dtours Date: Fri, 2 Oct 2026 08:14:25 +0700 Subject: [PATCH] web: NOISE REDUCTION takes the colour speckle out of the frame and leaves every strand of it where it was MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docker/frontend/scripts/denoise-check.mjs | 122 +++++++++++++++++++++ docker/frontend/shared/utils/toneShader.ts | 42 +++++++ docker/frontend/src/engine/exportEngine.ts | 69 ++++++++++-- 3 files changed, 226 insertions(+), 7 deletions(-) create mode 100644 docker/frontend/scripts/denoise-check.mjs 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