From 76d84503c97b7133be659518d572376fa2597187 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Fri, 2 Oct 2026 08:55:53 +0700 Subject: [PATCH] web: SHARPENING lifts only the edge it was pointed at, so a flat half of the frame keeps the grain it came with MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SHARPENING was the doc's §4.2 kernel with the two parts of §4.2 missing from it. `CLARITY_SKSL` evaluated the 3x3 unsharp mask with Mask = 1 on every pixel and no coring at all, so a flat sky, a cheek and a noise speckle all took the gain an eyelash took. That is `thay_doi_thong_so_giong_lightroom.md` §1 written out as a bug: "khi Sharpen, ảnh nổi đầy sạn hạt cát" — the knob could not raise the contrast of an edge without raising the noise of everything beside it, and on a grainy frame the second effect won. `SHARPEN_SKSL` is the doc's own line, `Image + Amount x HighPass x Mask`, with the two terms it names: - EDGE DETECTION: the Sobel magnitude G = sqrt(Gx^2 + Gy^2) on luminance, put through the doc's soft threshold smoothstep(T, T + 0.1, G). Flat fields read G = 0 and get Mask = 0 — the pixel is handed back untouched. - DETAIL (halo coring): a high-pass under SHARPEN_CORE is a speckle, not a detail, and is suppressed. The coring is soft (a ramp across the threshold, not a cliff) so a detail sitting on it is not switched on and off from one pixel to the next. The HIGH-PASS is the doc's Radius, held at one image pixel — 0.7-0.9px on a Retina panel — and it is a LUMINANCE high-pass carried by all three channels. A per-channel kernel sharpens a red edge against a green one and draws a colour fringe down every contour; the file's own §3 rule is to keep R/L, G/L and B/L where they were. `scripts/sharpen-check.mjs` pins the three properties the old kernel could not have: a flat field and a field of grain come back unchanged, a step below the threshold comes back unchanged, and a hard step moves apart on both sides while the flat halves beside it stay put. `CLARITY_SKSL` and `clarityUniforms` are gone with it, and the header note that said CanvasKit had two convolution steps to replace now says the one it has. Checked: node scripts/sharpen-check.mjs; node scripts/denoise-check.mjs; node scripts/tone-base-check.mjs; node scripts/highlight-knee-check.mjs; node scripts/auto-tone-check.mjs; node scripts/half-check.mjs; node scripts/mask-wb-check.mjs; node scripts/preview-match-check.mjs; node scripts/raw-develop-check.mjs; node scripts/white-level-check.mjs; node scripts/wb-table-check.mjs; npx tsc --noEmit. --- docker/frontend/scripts/sharpen-check.mjs | 139 +++++++++++++++++++++ docker/frontend/shared/utils/toneShader.ts | 77 +++++++++--- docker/frontend/src/engine/exportEngine.ts | 17 +-- 3 files changed, 211 insertions(+), 22 deletions(-) create mode 100644 docker/frontend/scripts/sharpen-check.mjs diff --git a/docker/frontend/scripts/sharpen-check.mjs b/docker/frontend/scripts/sharpen-check.mjs new file mode 100644 index 0000000..6c7f7ff --- /dev/null +++ b/docker/frontend/scripts/sharpen-check.mjs @@ -0,0 +1,139 @@ +// 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'); diff --git a/docker/frontend/shared/utils/toneShader.ts b/docker/frontend/shared/utils/toneShader.ts index 3ee9156..aeef70d 100644 --- a/docker/frontend/shared/utils/toneShader.ts +++ b/docker/frontend/shared/utils/toneShader.ts @@ -607,27 +607,76 @@ export function glowUniformArray(): number[] { // NAME (a flat array is only valid for the JS makeShaderWithChildren API). export const GLOW_UNIFORMS = { t0: GLOW_T0, t1: GLOW_T1 }; -// CLARITY (positive): unsharp 3x3 with epsilon 0 — the kernel export pass 4 -// builds with MakeMatrixConvolution, re-expressed as a plain shader because RN -// Skia 2.6 exposes no convolution image filter to the declarative JSX writer. -// `px` is one ORIGINAL image pixel expressed in the caller's canvas units, so -// the preview, the camera worklet and the file all sharpen at the same radius. -export const CLARITY_SKSL = ` +// SHARPENING — thay_doi_thong_so_giong_lightroom.md §4.2, all four of its +// parameters in the one knob the panel has. The doc's SHARPENING is not an +// unsharp mask: it is an unsharp mask MASKED BY AN EDGE DETECTOR, which is what +// keeps a flat sky and a cheek from gaining grain, and CORED, which is what +// keeps a noise speckle from being amplified into a white dot. +// +// Image_sharp = Image + Amount x HighPass x Mask +// +// EDGE DETECTION is the doc's Sobel magnitude G = sqrt(Gx² + Gy²) on luminance, +// put through its soft threshold Mask = smoothstep(T, T + 0.1, G). This pass was +// the same 3x3 kernel with Mask = 1 everywhere and no coring, so every +// low-contrast pixel — every pixel of a flat sky, every pore of a face — took +// the same gain an eyelash did, and a rising SHARPENING raised the frame's noise +// with it. That is the doc's §1 complaint word for word ("khi Sharpen, ảnh nổi +// đầy sạn hạt cát"). +// +// The HIGH-PASS is the doc's Radius, held at one image pixel (its 0.7-0.9px for +// a Retina panel), and it is a LUMINANCE high-pass carried by all three channels +// rather than a per-channel one: a per-channel kernel sharpens a red edge +// against a green one and draws a colour fringe down every contour. +// +// SHARPEN_CORE is the doc's Detail. Coring is SOFT (a ramp over the threshold, +// not a cliff), so a detail crossing it is not switched on and off from one +// pixel to the next. +// +// `amount` is signed the way the knob is: the negative side is the caller's +// blur and never reaches this shader. `px` is one ORIGINAL image pixel in the +// caller's canvas units, so the preview, the camera worklet and the file all +// sharpen at the same radius. The three constants are the doc's own shape; the +// doc leaves their value to the panel, and this panel has one SHARPENING knob, +// so they are fixed here. No frame has yet asked for a second one. +export const SHARPEN_CORE = 0.02; +export const SHARPEN_MASK_LO = 0.1; +export const SHARPEN_MASK_HI = SHARPEN_MASK_LO + 0.1; + +export const SHARPEN_SKSL = ` uniform shader src; uniform float a; uniform float2 px; +const float3 SHARPEN_LUM = vec3(0.2126, 0.7152, 0.0722); +const float SHARPEN_CORE = ${SHARPEN_CORE}; +const float SHARPEN_MASK_LO = ${SHARPEN_MASK_LO}; +const float SHARPEN_MASK_HI = ${SHARPEN_MASK_HI}; +float sharpenLum(vec2 p) { + return dot(clamp(src.eval(p).rgb, 0.0, 1.0), SHARPEN_LUM); +} vec4 main(vec2 xy) { - vec4 c = src.eval(xy); - vec4 s = src.eval(xy + float2(0.0, -px.y)) - + src.eval(xy + float2(0.0, px.y)) - + src.eval(xy + float2(-px.x, 0.0)) - + src.eval(xy + float2( px.x, 0.0)); - return vec4(clamp(c.rgb * (1.0 + 4.0 * a) - a * s.rgb, 0.0, 1.0), c.a); + vec2 dx = float2(px.x, 0.0); + vec2 dy = float2(0.0, px.y); + float yc = sharpenLum(xy); + float yl = sharpenLum(xy - dx); + float yr = sharpenLum(xy + dx); + float yt = sharpenLum(xy - dy); + float yb = sharpenLum(xy + dy); + float ytl = sharpenLum(xy - dx - dy); + float ytr = sharpenLum(xy + dx - dy); + float ybl = sharpenLum(xy - dx + dy); + float ybr = sharpenLum(xy + dx + dy); + float gx = (ytl + 2.0 * yl + ybl) - (ytr + 2.0 * yr + ybr); + float gy = (ytl + 2.0 * yt + ytr) - (ybl + 2.0 * yb + ybr); + float mask = smoothstep(SHARPEN_MASK_LO, SHARPEN_MASK_HI, sqrt(gx * gx + gy * gy)); + float hp = yc - 0.25 * (yl + yr + yt + yb); + hp *= smoothstep(SHARPEN_CORE, 2.0 * SHARPEN_CORE, abs(hp)); + vec4 c = clamp(src.eval(xy), 0.0, 1.0); + return vec4(clamp(c.rgb + a * hp * mask, 0.0, 1.0), c.a); } `; -// Named uniforms for , same names as CLARITY_SKSL declares. -export function clarityUniforms(a: number, pxX: number, pxY: number) { +// Named uniforms for , same names as SHARPEN_SKSL declares. +export function sharpenUniforms(a: number, pxX: number, pxY: number) { return { a, px: [pxX, pxY] }; } diff --git a/docker/frontend/src/engine/exportEngine.ts b/docker/frontend/src/engine/exportEngine.ts index 2d7d8c6..2c09053 100644 --- a/docker/frontend/src/engine/exportEngine.ts +++ b/docker/frontend/src/engine/exportEngine.ts @@ -6,11 +6,12 @@ // - `Skia.Data.fromURI` + expo file APIs → the caller hands in the JPEG bytes // and gets JPEG bytes back; the file save / gallery / MediaLibrary tail is // gone (the browser downloads a Blob instead). -// - `MakeMatrixConvolution` does not exist in CanvasKit. The two convolution -// steps (CLARITY, output sharpen) run through `CLARITY_SKSL` from -// `toneShader.ts` — the same kernel `[0,-a,0,-a,1+4a,-a,0,-a,0]`, evaluated -// as a shader pass. `convolve` replaces the pixels outright (the shader's -// output is opaque and covers the same rect), so no clearing pass is needed. +// - `MakeMatrixConvolution` does not exist in CanvasKit. The output sharpen +// (and CLARITY's own kernel) run through shader passes instead — +// `SHARPEN_SKSL` for the knob, the same kernel with the doc's Sobel edge +// mask and detail coring on it. `convolve` replaces the pixels outright (the +// shader's output is opaque and covers the same rect), so no clearing pass +// is needed. // - 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 → @@ -28,7 +29,7 @@ import { TONE_SKSL, EXPOSURE_SKSL, GLOW_SKSL, - CLARITY_SKSL, + SHARPEN_SKSL, CLARITY_BLUR_SKSL, CLARITY_BLEND_SKSL, NR_SKSL, @@ -173,7 +174,7 @@ let nrEffect: any = null; let dehazeEffect: any = null; function effects() { - if (!sharpenEffect) sharpenEffect = Skia.RuntimeEffect.Make(CLARITY_SKSL); + if (!sharpenEffect) sharpenEffect = Skia.RuntimeEffect.Make(SHARPEN_SKSL); if (!toneEffect) toneEffect = Skia.RuntimeEffect.Make(TONE_SKSL); if (!exposureEffect) exposureEffect = Skia.RuntimeEffect.Make(EXPOSURE_SKSL); if (!cinemaEffect) cinemaEffect = Skia.RuntimeEffect.Make(CINEMA_SKSL); @@ -242,7 +243,7 @@ function maskEffectFor(count: number, spatial: boolean): any { return effect; } -// CLARITY_SKSL uniforms are (a, px.x, px.y); px = one source pixel = 1 unit on +// SHARPEN_SKSL uniforms are (a, px.x, px.y); px = one source pixel = 1 unit on // a 1:1 export canvas, so the radius matches what the preview tuned. function convolvePaint(srcImage: any, amount: number): any { const { sharpenEffect: effect } = effects();