From 6778b3bd487df1d1b36a1c4d906ec769e76a757f Mon Sep 17 00:00:00 2001 From: Rodrigo Date: Mon, 5 Oct 2026 04:55:26 +0200 Subject: [PATCH 1/4] Reduce a RAW photo's noise on its Bayer mosaic before demosaicing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds noise reduction to RAW photos with a 2 × 2 mosaic, an amount from 0 to 100 on the image layer. The GPU fits the photo's Poisson-Gaussian noise per color filter position, stabilizes it with the generalized Anscombe transform, and filters a six-level pyramid with two-step sliding DCT denoising before the unbiased inverse returns samples. The source keeps the reduced samples, so fading and later edits only blend decoded and reduced samples and develop again. --- API.md | 7 +- ARCHITECTURE.md | 3 +- CONTEXT.md | 1 + src/app/commands.ts | 11 + src/app/controls.ts | 3 + src/app/editor/adjust/index.tsx | 25 +- src/app/editor/renderer.ts | 9 +- src/app/scene-file.ts | 2 + src/core/document/scene.ts | 5 + src/core/image/decode/raw-blend.wgsl | 39 +++ src/core/image/decode/raw.ts | 173 +++++++++++- src/core/image/index.ts | 27 +- src/core/renderer/index.ts | 57 +++- src/features/adjustments/controls.tsx | 5 +- src/features/noise-reduction/algorithm.md | 21 ++ src/features/noise-reduction/controls.tsx | 86 ++++++ src/features/noise-reduction/denoise.ts | 296 ++++++++++++++++++++ src/features/noise-reduction/down.wgsl | 26 ++ src/features/noise-reduction/edits.ts | 22 ++ src/features/noise-reduction/finish.wgsl | 14 + src/features/noise-reduction/fuse.wgsl | 71 +++++ src/features/noise-reduction/model.ts | 4 + src/features/noise-reduction/noise.ts | 119 ++++++++ src/features/noise-reduction/noise.wgsl | 63 +++++ src/features/noise-reduction/prepare.wgsl | 58 ++++ src/features/noise-reduction/shrink.wgsl | 149 ++++++++++ src/features/noise-reduction/spread.wgsl | 36 +++ src/features/noise-reduction/stabilize.wgsl | 65 +++++ src/features/noise-reduction/texels.wgsl | 8 + tests/fixtures/raw/README.md | 2 + tests/fixtures/raw/noisy-bayer.dng | Bin 0 -> 25040 bytes tests/noise-reduction.e2e.ts | 57 ++++ tests/noise-reduction.test.ts | 30 ++ 33 files changed, 1471 insertions(+), 23 deletions(-) create mode 100644 src/core/image/decode/raw-blend.wgsl create mode 100644 src/features/noise-reduction/algorithm.md create mode 100644 src/features/noise-reduction/controls.tsx create mode 100644 src/features/noise-reduction/denoise.ts create mode 100644 src/features/noise-reduction/down.wgsl create mode 100644 src/features/noise-reduction/edits.ts create mode 100644 src/features/noise-reduction/finish.wgsl create mode 100644 src/features/noise-reduction/fuse.wgsl create mode 100644 src/features/noise-reduction/model.ts create mode 100644 src/features/noise-reduction/noise.ts create mode 100644 src/features/noise-reduction/noise.wgsl create mode 100644 src/features/noise-reduction/prepare.wgsl create mode 100644 src/features/noise-reduction/shrink.wgsl create mode 100644 src/features/noise-reduction/spread.wgsl create mode 100644 src/features/noise-reduction/stabilize.wgsl create mode 100644 src/features/noise-reduction/texels.wgsl create mode 100644 tests/fixtures/raw/noisy-bayer.dng create mode 100644 tests/noise-reduction.e2e.ts create mode 100644 tests/noise-reduction.test.ts diff --git a/API.md b/API.md index 7542027e..3ed945b8 100644 --- a/API.md +++ b/API.md @@ -72,6 +72,8 @@ Create an empty Paint layer with `addLayer("paint")`. `addPaintStroke(id, stroke For RAW sources, `setWhiteBalance({ temperature, tint })` sets absolute Kelvin and DNG tint, preserving unspecified values. Temperature accepts 2000–25000 K and tint accepts -150–150, extending either range to include the file's As Shot value. `setWhiteBalance()` restores that value (the decoder's daylight fallback if camera multipliers are unavailable). Non-RAW sources and invalid values throw. Incremental temperature/tint remain separate RGB adjustments. +For RAW sources with a 2 × 2 Bayer mosaic, `setNoiseReduction(amount)` reduces noise on the mosaic before demosaicing, from 0, the samples as decoded, to 100, fully reduced. A photo's first reduction runs once on the GPU and takes a moment, while the editor keeps showing the photo as it was; later amounts, white balance, and every other edit reuse the reduced samples, so they apply at once. The original comparison shows the samples as decoded. Other sources and invalid values throw. + `autoWhiteBalance()` neutralizes the photo's color cast as one edit and resolves once it is made. The GPU measures the cast of the source before any edit, as its edges' average color, so calling it again gives the same balance. A RAW photo's white balance moves from As Shot; any other photo's incremental temperature and tint are replaced. When another photo opens first, it resolves without an edit. Edits update the scene and history synchronously. Rendering may finish later, particularly RAW development. Tests should wait for visible results; `exportImage()` renders and waits for its captured scene independently of the preview. @@ -185,7 +187,7 @@ The image renders at the document dimensions and downsamples to `longEdge` with `scene` is the `getState()` scene; each image layer's `source` names an entry in `sources`, and each Remove patch's `field` an entry in `fields`: where its texels sit, or `{}` for a field not yet synthesized. Version 1 files, which kept each Remove field inside its patch, open as version 2. Opening the file with `loadScene`, `openFile`, a drop, or the file picker decodes the stored source again and restores the frame and every layer as a new document with empty history. Preview settings and history are not saved. -Opening validates every value as the matching command does, and a file that fails leaves the workspace in its error state with a message naming the first invalid field. Fields OpenLight does not know are dropped. A parameter missing from `adjustments`, `details`, `vignette`, `grain`, `fill`, or `colorMixer` takes its default, so older files still open when a group gains a parameter; a RAW image without a white balance uses its As Shot value, and a frame without `perspective` has none. `version` increases only when older files can no longer open as written, or an older OpenLight would drop what a file holds: a scene that corrects perspective is version 3, which OpenLight before perspective correction rejects, and every other scene stays version 2. A newer version is rejected. +Opening validates every value as the matching command does, and a file that fails leaves the workspace in its error state with a message naming the first invalid field. Fields OpenLight does not know are dropped. A parameter missing from `adjustments`, `details`, `vignette`, `grain`, `fill`, or `colorMixer` takes its default, so older files still open when a group gains a parameter; a RAW image without a white balance uses its As Shot value, an image without `noiseReduction` has none, and a frame without `perspective` has none. `version` increases only when older files can no longer open as written, or an older OpenLight would drop what a file holds: a scene that corrects perspective is version 3, which OpenLight before perspective correction rejects, and every other scene stays version 2. A newer version is rejected. ## Drafts @@ -218,6 +220,7 @@ editor.run({ | `set-adjustments` | adjustments, `layerId?` | Like `setAdjustments`. | | `set-tone-curve` | `points?`, `layerId?` | Like `setToneCurve`. | | `set-white-balance` | `temperature?`, `tint?` | Like `setWhiteBalance` with a change. | +| `set-noise-reduction` | `amount` | Like `setNoiseReduction`. | | `set-color-mixer` | `color`, `hue?`, `saturation?`, `luminance?`, `layerId?` | Like `setColorMixer`. | | `set-details` | `clarity?`, `sharpening?`, `sharpenRadius?`, `layerId?` | Like `setDetails`. | | `set-vignette` | `intensity?`, `softness?`, `layerId?` | Like `setVignette`. | @@ -225,7 +228,7 @@ editor.run({ | `add-mask` | `mask`, `adjustments?` | Adds a mask layer with those adjustments on top of the stack as one edit. | | `delete-layer` | `layerId` | Like `deleteLayer`. | | `set-crop` | `aspectRatio?`, `straighten?` | Replaces the frame with the largest centered crop of the source at `aspectRatio`, width over height, straightened by −45 to 45 degrees, keeping the frame's perspective. Without either, it removes the crop. | -| `reset` | none | Removes every layer and returns adjustments, the tone curve, white balance, and the frame, perspective included, to how the photo opened, as one edit. | +| `reset` | none | Removes every layer and returns adjustments, the tone curve, white balance, noise reduction, and the frame, perspective included, to how the photo opened, as one edit. | | `undo`, `redo` | none | Like `undo` and `redo`. | | `set-preview` | `comparison` | Shows `"edited"`, `"original"`, or `"split"`. | diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 03fe88e3..e43acf33 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -94,4 +94,5 @@ Dependencies point downward. Features do not import each other; `app/` connects - A mask without child effects renders its adjustments, curve, and mix in one pass over the image below, so no full-size texture holds its edited image; while it is inspected, its curve input reads the adjusted image, so the three render separately. A mask that only gradients shape needs no texture: the mix pass computes it, children included. A mask with a painted brush or a range combines in the render graph: each child's coverage, a brush's cached raster, a gradient drawn by a node, or a range read from the image below the mask, folds into the group's in stored order through one combine pass, and the mix pass shares its add, subtract, and intersect. The group takes the image's resolution when a range takes part, since a range follows the photo's edges, and the brushes' otherwise. The renderer gives it out, with each range's own coverage, for the overlay and thumbnails. While a color is picked, renders also keep the image below the mask group, which the picker reads through one GPU pass and 16 bytes of readback per pick. - Renders reduce the source by the largest whole factor that still gives every device pixel a texel, a proxy, in gestures or not, so the photo's own size renders once the display shows more than half a device pixel per source pixel; the main canvas writes that density into the preview state, and the provider renders again when it changes. A proxy that lacks a Remove patch's field synthesizes it apart, outside a gesture: the layers below the patch render in full for the field alone, and the proxy composes again with it, so the display never shows a full render for it. The export view renders its own full snapshot, which its preview and the saved file both encode. Every render image carries `scale`, its source pixels per texel; shaders that take document coordinates or radii apply it. - The scene's frame maps output to source in one homogeneous transform, `frameTransform` in `core/image/frame.ts`: crop, rotation, flips, and scale, then the perspective correction about the source's center. Layers, paint, masks, and patches stay in source pixels, so correcting perspective leaves them in place; `transformImage` resamples the composite once, the display draws the comparison and the mask overlay from the source through the same transform, and a frame that changes nothing adds no pass. The proxy follows the frame's strongest magnification, perspective's included. `lib/projective.ts` holds the plane projective geometry the frame, the Crop tool's coverage, and the canvas mapping share. -- TIFF and camera RAW decode through `raw-webgpu`; OpenLight adapts its resources to document ownership. A RAW source keeps its as-shot development, which a renderer shows as long as that balance is selected; another balance develops into the renderer's own texture. +- TIFF and camera RAW decode through `raw-webgpu`; OpenLight adapts its resources to document ownership. A RAW source keeps its as-shot development, which a renderer shows as long as that balance is selected and noise reduction is off, and the original comparison always shows; another balance, or noise reduction, develops into the renderer's own texture. +- Noise reduction works on a RAW photo's 2 × 2 mosaic before demosaicing, as the image layer's amount. `features/noise-reduction` reduces the mosaic's noise on the GPU, once per source, the first time a renderer or control asks; the source keeps the reduced samples until it closes and shares them with every renderer. The package develops only its own sensor texture, so `core/image/decode/raw.ts` moves the decoded samples to a copy the first time and, right before each development, writes the blend of decoded and reduced samples by the amount into that texture, in the same task, so no other renderer's write falls between. Changing the amount develops again, as a white balance does, without reducing noise again; a newer request need not wait for a first reduction. `app/editor/renderer.ts` gives the renderer the feature's reduction, so the core imports no feature. See [the algorithm](src/features/noise-reduction/algorithm.md). diff --git a/CONTEXT.md b/CONTEXT.md index 9822c021..46b26812 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -10,6 +10,7 @@ OpenLight edits documents containing image content and adjustments. | **Stroke** | One brush drag: paint or erase, size, feather, flow, and points with pressure in source pixels; a Paint layer's strokes also carry a color. Strokes are content; the renderer rasterizes them into a cached texture at half the source's resolution, coverage for a mask and premultiplied color for a Paint layer, and the latest stroke into a shared stroke buffer until the next one starts. A Paint layer's or brush mask's strokes settle into pixels every 100 strokes, which it then paints over; undo still reaches every stroke. A Healing patch stores ordered hard paint/erase strokes, rasterized over only the tiles its paint reaches, and feathers its accumulated shape separately. | | **Range** | A mask that selects pixels of the image below its mask group by lightness, a luminance range, or by color, a color range, rather than by position. A new color range has no coverage until it takes a color from a click on the photo. | | **Healing patch** | One painted, non-destructive repair in a Healing layer. Its Heal mode copies a nearby donor, found automatically or set by hand, with a bounded boundary color correction. Clone mode copies the donor without color correction. Remove mode synthesizes many local donor patches from surrounding texture and has no donor offset; it keeps where each pixel copies from as its field, so the layers below change only the copied colors, and a stroke added or erased synthesizes its whole new shape. Patches replay in order and each sees the result of the preceding patches. Each gesture keeps its recorded size and separate path; Shift adds and Alt/Option subtracts on the selected patch. Feather and opacity remain editable on its accumulated shape. | +| **Noise reduction** | A RAW photo's setting, from 0 to 100, that removes noise from its color filter mosaic before demosaicing. The photo's first reduction takes a moment; fading it or editing anything else afterwards applies at once, and the original comparison shows the photo as decoded. Only photos with a 2 × 2 Bayer mosaic have it. | | **Perspective correction** | Part of the frame: Vertical and Horizontal keystone that makes lines converging in the photo, such as a building's edges, parallel, before the crop, rotation, and flips. Layers keep their source pixels and follow it; tools draw and place them as it maps them. | | **Image source** | An image used as input to the document's composition. It is content within a document. | | **LUT** | A 3D color lookup table, such as a creative grade, read from a `.cube` file. A LUT layer holds one and applies it to the image below, at the layer's opacity. | diff --git a/src/app/commands.ts b/src/app/commands.ts index a70bd0ba..0aad7415 100644 --- a/src/app/commands.ts +++ b/src/app/commands.ts @@ -16,6 +16,8 @@ import { setGrain } from "@/features/grain/edits"; import { grainSchema } from "@/features/grain/model"; import { addLayer, deleteLayer } from "@/features/layers/edits"; import { maskSchema } from "@/features/layers/model"; +import { setNoiseReduction } from "@/features/noise-reduction/edits"; +import { noiseReductionSchema } from "@/features/noise-reduction/model"; import { curveSchema, defaultCurve } from "@/features/tone-curves/curve"; import { setToneCurve } from "@/features/tone-curves/edits"; import { setVignette } from "@/features/vignette/edits"; @@ -65,6 +67,7 @@ function reset(document: EditorDocument) { adjustments: { ...defaultAdjustments }, toneCurve: defaultCurve, whiteBalance: source.raw?.asShot, + noiseReduction: undefined, }, ], }); @@ -104,6 +107,14 @@ export const commands = { return { layerId: image(document).id }; }, ), + "set-noise-reduction": command( + "Reduces a RAW photo's noise before demosaicing: amount from 0, as decoded, to 100, fully reduced. The first reduction of a photo takes a moment; later amounts apply at once. Photos without a Bayer mosaic, such as JPEGs, have no noise reduction.", + z.strictObject({ amount: noiseReductionSchema }), + (document, { amount }) => { + setNoiseReduction(document, amount); + return { layerId: image(document).id }; + }, + ), "set-color-mixer": command( "Shifts one color range's hue, saturation, or luminance from -100 to 100, keeping other colors and omitted values. Neutral grays are unaffected. Without layerId it edits the first Color Mixer layer, creating one when there is none.", z.extend(mixerChange, { color: mixerColor, layerId }), diff --git a/src/app/controls.ts b/src/app/controls.ts index ced6aa91..ca3b1654 100644 --- a/src/app/controls.ts +++ b/src/app/controls.ts @@ -62,6 +62,7 @@ import { setMaskOperation, } from "@/features/layers/edits"; import { defaultGradient } from "@/features/layers/gradient"; +import { setNoiseReduction } from "@/features/noise-reduction/edits"; import { addPaintStroke, setPaintBlend } from "@/features/paint/edits"; import { defaultCurve } from "@/features/tone-curves/curve"; import { setToneCurve } from "@/features/tone-curves/edits"; @@ -123,6 +124,8 @@ export function createControls( setWhiteBalance: (change?: Partial) => setWhiteBalance(workspace.getDocument(), change), autoWhiteBalance: () => autoWhiteBalance(workspace.getDocument(), gpu), + setNoiseReduction: (amount: number) => + setNoiseReduction(workspace.getDocument(), amount), setToneCurve: (curve?: ToneCurve, id?: string) => setToneCurve(workspace.getDocument(), curve, id), setColorMixer(color: MixerColor, change: MixerChange, id?: string) { diff --git a/src/app/editor/adjust/index.tsx b/src/app/editor/adjust/index.tsx index 84de6ff0..ee1362a0 100644 --- a/src/app/editor/adjust/index.tsx +++ b/src/app/editor/adjust/index.tsx @@ -1,5 +1,6 @@ import { Button } from "@roprgm/ui/button"; import { Slider } from "@roprgm/ui/slider"; +import { Spinner } from "@roprgm/ui/spinner"; import { type ReactNode, useCallback, useState } from "react"; import { useStore } from "zustand"; import { effectKinds } from "@/app/editor/layers"; @@ -41,6 +42,10 @@ import { Histogram } from "@/features/histogram"; import { setLayer } from "@/features/layers/edits"; import { OverlayToggle } from "@/features/layers/overlay-toggle"; import { LutCurves } from "@/features/lut/curves"; +import { + NoiseReductionControls, + useNoiseReduction, +} from "@/features/noise-reduction/controls"; import { PaintControls } from "@/features/paint/controls"; import { defaultCurve } from "@/features/tone-curves/curve"; import { setToneCurve } from "@/features/tone-curves/edits"; @@ -148,7 +153,9 @@ function SelectedControls({ layer }: { layer: Layer }) { id={layer.id} adjustments={layer.adjustments} whiteBalance={} - /> + > + {layer.kind === "image" && } +
@@ -240,6 +247,7 @@ function AdjustmentDials({ }) { const document = useDocument(); const whiteBalance = useWhiteBalance(layer); + const noiseReduction = useNoiseReduction(layer); const header = ( ); } + // A RAW photo's noise reduction follows its tone, with a spinner while the first reduction runs. return ( + {noiseReduction?.reducing && ( + + )} + {action} + + } + parameters={[ + ...adjustments(tone), + ...(noiseReduction ? [noiseReduction.parameter] : []), + ]} /> ); } diff --git a/src/app/editor/renderer.ts b/src/app/editor/renderer.ts index bf6ccdbc..a91e29b8 100644 --- a/src/app/editor/renderer.ts +++ b/src/app/editor/renderer.ts @@ -24,6 +24,7 @@ import { grain } from "@/features/grain/pass"; import { heal, retainHealPatches } from "@/features/heal/pass"; import { histogramGrid } from "@/features/histogram/histogram"; import { lut } from "@/features/lut/pass"; +import { denoiseMosaic } from "@/features/noise-reduction/denoise"; import { paint } from "@/features/paint/pass"; import { toneCurves } from "@/features/tone-curves/pass"; import { vignette } from "@/features/vignette/pass"; @@ -181,7 +182,8 @@ function composeLayers( /** * Pure layer composition shares the same graph for preview, crop, and export. - * The image layer develops the photo first; layers above it process that floating-point result. + * The image layer develops the photo first, a RAW one from its noise-reduced mosaic as it asks; layers + * above it process that floating-point result. * Display and export encode only after composition, so later layers can recover HDR headroom. */ export function createEditorRenderer( @@ -220,6 +222,9 @@ export function createEditorRenderer( rangeSource: children.rangeSource ?? composite.rangeSource, }; }, - options, + { + ...options, + denoise: (mosaic, signal) => denoiseMosaic(gpu, mosaic, signal), + }, ); } diff --git a/src/app/scene-file.ts b/src/app/scene-file.ts index 9fa17273..6513ec20 100644 --- a/src/app/scene-file.ts +++ b/src/app/scene-file.ts @@ -32,6 +32,7 @@ import { maskSchema, } from "@/features/layers/model"; import { lutSchema } from "@/features/lut/model"; +import { noiseReductionSchema } from "@/features/noise-reduction/model"; import { paintShape } from "@/features/paint/model"; import { curveSchema } from "@/features/tone-curves/curve"; import { defaultVignette, vignetteSchema } from "@/features/vignette/model"; @@ -225,6 +226,7 @@ const imageLayer = z.object({ whiteBalance: z.optional( z.object({ temperature: z.number(), tint: z.number() }), ), + noiseReduction: z.optional(noiseReductionSchema), adjustments, toneCurve: curveSchema, children: empty("The image layer cannot contain layers"), diff --git a/src/core/document/scene.ts b/src/core/document/scene.ts index f3eec98f..bce902f9 100644 --- a/src/core/document/scene.ts +++ b/src/core/document/scene.ts @@ -72,6 +72,11 @@ export type ImageLayer = { readonly name: string; readonly source: string; readonly whiteBalance?: Readonly; + /** + * How far a RAW photo's mosaic moves toward its noise-reduced samples before demosaicing, 0 to 100; + * none without it. + */ + readonly noiseReduction?: number; readonly adjustments: Readonly; readonly toneCurve: ToneCurve; readonly children: readonly ProcessingLayer[]; diff --git a/src/core/image/decode/raw-blend.wgsl b/src/core/image/decode/raw-blend.wgsl new file mode 100644 index 00000000..a6f81fde --- /dev/null +++ b/src/core/image/decode/raw-blend.wgsl @@ -0,0 +1,39 @@ +// Writes sensor samples for the package to develop: the decoded ones blended toward replacement +// samples, each 2 × 2 cell's four by position above black in one texel, and dithered so smooth +// replacements don't band when rounded back to whole sensor units. +struct Blend { + amount: f32, + // Each 2 × 2 position's black level. + black: vec4f, +} + +@group(0) @binding(0) var decoded: texture_2d; +@group(0) @binding(1) var replacement: texture_2d; +@group(0) @binding(2) var blend: Blend; + +// One triangle covering the whole destination. +@vertex fn vs_main(@builtin(vertex_index) index: u32) -> @builtin(position) vec4f { + let points = array(vec2f(-1, -1), vec2f(3, -1), vec2f(-1, 3)); + return vec4f(points[index], 0, 1); +} + +// Uniform in [-0.5, 0.5), from a hash of the sample's position (PCG, Jarzynski and Olano, 2020). +fn dither(p: vec2u) -> f32 { + var state = p.x * 747796405u + p.y * 2891336453u + 2891336453u; + state = ((state >> ((state >> 28u) + 4u)) ^ state) * 277803737u; + return f32((state >> 22u) ^ state) / 4294967296.0 - 0.5; +} + +@fragment fn fs_main(@builtin(position) position: vec4f) -> @location(0) vec4u { + let p = vec2u(position.xy); + let original = f32(textureLoad(decoded, p, 0).r); + let cell = p / 2u; + // A last odd row or column has no cell and keeps its samples. + if (any(cell >= textureDimensions(replacement))) { + return vec4u(u32(original)); + } + let site = (p.y & 1u) * 2u + (p.x & 1u); + let value = textureLoad(replacement, cell, 0)[site] + blend.black[site]; + let blended = mix(original, value, blend.amount) + dither(p); + return vec4u(u32(clamp(round(blended), 0.0, 65535.0))); +} diff --git a/src/core/image/decode/raw.ts b/src/core/image/decode/raw.ts index 6348be43..528bf08f 100644 --- a/src/core/image/decode/raw.ts +++ b/src/core/image/decode/raw.ts @@ -1,11 +1,161 @@ -import { createRawDecoder } from "raw-webgpu"; -import { type Gpu, type Target, target } from "vgpu"; -import { createImageSource, type RawDevelopment } from "@/core/image"; +import { createRawDecoder, type RawSource } from "raw-webgpu"; +import { type Gpu, type Target, type Texture, target } from "vgpu"; +import { + createImageSource, + type Mosaic, + type RawDevelopment, + type SampleBlend, +} from "@/core/image"; import { weakMemo } from "@/lib/weak-memo"; +import blendShader from "./raw-blend.wgsl"; /** One decoder per GPU, released with its device. */ const decoder = weakMemo((gpu: Gpu) => createRawDecoder(gpu.gpu)); +const blendPipeline = weakMemo((device: GPUDevice) => { + const module = device.createShaderModule({ code: blendShader.wgsl }); + return device.createRenderPipeline({ + layout: "auto", + vertex: { module, entryPoint: "vs_main" }, + fragment: { + module, + entryPoint: "fs_main", + targets: [{ format: "r16uint" }], + }, + }); +}); + +/** + * The samples the package develops, which live in its own `sensor` texture. Passes take turns: each + * writes the samples it develops just before developing, in the same task, so no other pass's write + * falls between. The decoded samples move to a copy the first time a blend replaces them. + */ +function createSampleWriter( + gpu: Gpu, + sensor: GPUTexture, + blacks: readonly number[], +) { + const device = gpu.gpu; + // The blend's amount, then each 2 × 2 position's black level. + const blendValues = new Float32Array(8); + blendValues.set(blacks, 4); + const uniforms = gpu.device.createBuffer({ + size: blendValues.byteLength, + usage: ["uniform", "copy_dst"], + }); + let decoded: Texture | undefined; + let held: SampleBlend | undefined; + return { + /** The decoded samples, wherever they are. */ + decoded: () => decoded?.gpu ?? sensor, + write(blend?: SampleBlend) { + if (blend?.samples === held?.samples && blend?.amount === held?.amount) { + return; + } + const encoder = device.createCommandEncoder(); + const size: [number, number] = [sensor.width, sensor.height]; + if (!decoded) { + decoded = gpu.device.createTexture({ + size, + format: sensor.format, + usage: ["texture_binding", "copy_src", "copy_dst"], + }); + encoder.copyTextureToTexture( + { texture: sensor }, + { texture: decoded.gpu }, + size, + ); + } + if (blend) { + blendValues[0] = blend.amount; + uniforms.write(blendValues); + const pipeline = blendPipeline(device); + const pass = encoder.beginRenderPass({ + colorAttachments: [ + { view: sensor.createView(), loadOp: "clear", storeOp: "store" }, + ], + }); + pass.setPipeline(pipeline); + pass.setBindGroup( + 0, + device.createBindGroup({ + layout: pipeline.getBindGroupLayout(0), + entries: [ + { binding: 0, resource: decoded.gpu.createView() }, + { binding: 1, resource: blend.samples.color.gpu.createView() }, + { binding: 2, resource: { buffer: uniforms.gpu } }, + ], + }), + ); + pass.draw(3); + pass.end(); + } else { + encoder.copyTextureToTexture( + { texture: decoded.gpu }, + { texture: sensor }, + size, + ); + } + device.queue.submit([encoder.finish()]); + held = blend; + }, + dispose() { + uniforms.dispose(); + decoded?.dispose(); + }, + }; +} + +/** + * The mosaic of a source the GPU demosaics from a 2 × 2 color filter of integer samples, and what + * writes the samples its passes develop. + */ +function mosaicOf(gpu: Gpu, source: RawSource) { + const { cfa, cfaSize = 2, demosaic, black, white } = source.metadata; + if ( + !cfa || + cfaSize !== 2 || + demosaic !== "gpu" || + source.texture.format !== "r16uint" + ) { + return undefined; + } + const writer = createSampleWriter( + gpu, + source.texture, + cfa.map((color) => black[color]), + ); + const closed = new AbortController(); + let denoised: Promise | undefined; + const mosaic: Mosaic = { + get samples() { + return writer.decoded(); + }, + pattern: cfa, + black, + white, + denoised(reduce) { + denoised ??= reduce(mosaic, closed.signal).catch((error) => { + denoised = undefined; + throw error; + }); + return denoised; + }, + }; + return { + mosaic, + write: writer.write, + dispose() { + closed.abort(Error("RAW source is closed.")); + void denoised?.then( + (samples) => samples.color.dispose(), + () => {}, + ); + writer.dispose(); + }, + }; +} + /** Adapts package-owned sensor sources to OpenLight's document and renderer lifetimes. */ export async function decodeRaw(gpu: Gpu, file: File) { const source = await decoder(gpu).load(file); @@ -23,26 +173,31 @@ export async function decodeRaw(gpu: Gpu, file: File) { } const { asShot } = source; const developed = original; + const mosaic = mosaicOf(gpu, source); const raw: RawDevelopment = { asShot, + mosaic: mosaic?.mosaic, createPass() { - // The as-shot development is the image the source keeps; another balance gets its own. + // The as-shot development of the decoded samples is the image the source keeps; another + // balance, or a blend, gets its own. let output: Target | undefined; const pass = source.createDevelopPass(); let calibration = source.calibration; + let blend: SampleBlend | undefined; let dirty = true; return { - async prepare(balance) { + async prepare(balance, samples) { const shot = balance.temperature === asShot.temperature && balance.tint === asShot.tint; calibration = shot ? source.calibration : await source.calibrate(balance); + blend = samples?.amount ? samples : undefined; dirty = true; }, render() { - if (calibration === source.calibration) { + if (calibration === source.calibration && !blend) { output?.color.dispose(); output = undefined; return developed; @@ -52,6 +207,7 @@ export async function decodeRaw(gpu: Gpu, file: File) { format: "rgba16float", }); if (dirty) { + mosaic?.write(blend); pass.render({ destination: output.color.gpu, calibration }); dirty = false; } @@ -63,7 +219,10 @@ export async function decodeRaw(gpu: Gpu, file: File) { }, }; }, - dispose: source.dispose, + dispose() { + mosaic?.dispose(); + source.dispose(); + }, }; return createImageSource(original, { raw }); } catch (error) { diff --git a/src/core/image/index.ts b/src/core/image/index.ts index 02026edb..6f0ecbb1 100644 --- a/src/core/image/index.ts +++ b/src/core/image/index.ts @@ -1,10 +1,35 @@ import type { Target } from "vgpu"; export type WhiteBalance = { temperature: number; tint: number }; +/** A RAW photo's 2 × 2 color filter mosaic before the GPU demosaics it. */ +export type Mosaic = { + /** One `r16uint` sample per sensor pixel, unrotated, as decoded. */ + readonly samples: GPUTexture; + /** Each 2 × 2 cell position's color, row-major: 0 red, 1 or 3 green, 2 blue. */ + pattern: readonly number[]; + /** Black level per color, in sensor units. */ + black: readonly number[]; + /** The level where samples clip, in sensor units. */ + white: number; + /** + * The samples with their noise reduced, which `reduce` makes the first time a renderer or control + * asks, kept until the source closes; a failure lets the next ask try again. + */ + denoised( + reduce: (mosaic: Mosaic, signal: AbortSignal) => Promise, + ): Promise; +}; +/** + * Samples a RAW pass develops in place of the decoded ones, blended toward them by `amount`, 0 to 1: + * a half-size `rgba16float` image holding each 2 × 2 cell's four by position, above their black level. + */ +export type SampleBlend = { samples: Target; amount: number }; export type RawDevelopment = { asShot: WhiteBalance; + /** The mosaic, when the GPU demosaics a 2 × 2 one, for processing before demosaicing. */ + mosaic?: Mosaic; createPass(): { - prepare(balance: WhiteBalance): Promise; + prepare(balance: WhiteBalance, blend?: SampleBlend): Promise; render(): Target; dispose(): void; }; diff --git a/src/core/renderer/index.ts b/src/core/renderer/index.ts index db3722ff..b0d80817 100644 --- a/src/core/renderer/index.ts +++ b/src/core/renderer/index.ts @@ -11,7 +11,7 @@ import { type Scene, walkLayers, } from "@/core/document"; -import type { ImageSource, WhiteBalance } from "@/core/image"; +import type { ImageSource, Mosaic, WhiteBalance } from "@/core/image"; import type { Point } from "@/core/image/frame"; import { createFieldStore, type FieldLattice } from "./fields"; import { createRenderGraph } from "./graph"; @@ -142,6 +142,8 @@ export type RendererOptions = { field?: (id: string) => RemoveField | undefined; /** Receives each Remove field the renderer synthesizes, read back; without it nothing is read back. */ saveField?: (id: string, field: RemoveField) => void; + /** Reduces a RAW mosaic's noise, once per source, for the image layer's noise reduction. */ + denoise?: (mosaic: Mosaic, signal: AbortSignal) => Promise; }; /** @@ -159,6 +161,9 @@ export function createRenderer( }, field: savedField = () => undefined, saveField, + denoise = () => { + throw Error("This renderer reduces no noise."); + }, }: RendererOptions = {}, ) { const source = resource.image; @@ -181,7 +186,9 @@ export function createRenderer( /** Mask coverage the graph rendered for the overlay and thumbnails, by layer ID. */ let shown = new Map(); let balance = resource.raw?.asShot; - /** Counts developments, so the proxy follows white-balance changes. */ + /** How far the development's samples moved toward their noise-reduced ones, 0 to 1. */ + let reduction = 0; + /** Counts developments, so the proxy follows white-balance and noise-reduction changes. */ let version = 0; let last: RenderRequest | undefined; /** Whether the last render, a gesture's proxy, showed a Remove patch unfilled. */ @@ -195,6 +202,8 @@ export function createRenderer( let reductions: number[] = []; let next: RenderRequest | undefined; let pending: Promise | undefined; + /** Ends a wait for noise reduction once a newer request or closing makes it moot. */ + let interrupt: (() => void) | undefined; /** A raster being read back, a painting to settle or a Remove field to save; nothing renders meanwhile. */ let settling: Promise | undefined; /** Readbacks of synthesized fields in flight, by ID, which finish even once the renderer closes. */ @@ -414,9 +423,19 @@ export function createRenderer( } return stale; } + /** Resolves with `work`, or with nothing once a newer request or closing interrupts the wait. */ + function interruptible(work: Promise) { + const interrupted = new Promise((resolve) => { + interrupt = () => resolve(undefined); + }); + return Promise.race([work, interrupted]).finally(() => { + interrupt = undefined; + }); + } /** * Renders the latest request once what it waits for is ready: a settle, a RAW development, whose - * calibration alone crosses the worker, settled paint, or Remove fields to load. + * calibration alone crosses the worker and whose noise reduction runs once per source, which a + * newer request need not wait for, settled paint, or Remove fields to load. */ async function develop() { while (next && !disposed) { @@ -428,13 +447,34 @@ export function createRenderer( } if (disposed) return; if (next) continue; - const selected = scene.layers[0].whiteBalance ?? resource.raw?.asShot; - if (raw && selected && !sameBalance(balance, selected)) { - await raw.prepare(selected); + const [image] = scene.layers; + const selected = image.whiteBalance ?? resource.raw?.asShot; + const mosaic = resource.raw?.mosaic; + const amount = mosaic ? (image.noiseReduction ?? 0) / 100 : 0; + let failure: { error: unknown } | undefined; + if ( + raw && + selected && + (!sameBalance(balance, selected) || amount !== reduction) + ) { + let samples: Target | undefined; + if (amount && mosaic) { + try { + samples = await interruptible(mosaic.denoised(denoise)); + } catch (error) { + // The photo develops as decoded rather than not at all; the error shows once, and the + // reduction runs again when the amount changes. + failure = { error }; + } + } + if (disposed) return; + if (next) continue; + await raw.prepare(selected, samples && { samples, amount }); if (disposed) { return; } balance = selected; + reduction = amount; version++; } for (const { id, raster, rasters } of stalePaint(scene)) { @@ -452,6 +492,9 @@ export function createRenderer( if (!next) { render(request); } + if (failure) { + throw failure.error; + } } } /** @@ -489,6 +532,7 @@ export function createRenderer( return; } next = request; + interrupt?.(); pending ??= develop() .catch((error) => { if (!next) { @@ -632,6 +676,7 @@ export function createRenderer( return; } disposed = true; + interrupt?.(); listeners.clear(); // Banded transfers keep their targets until their last GPU operation finishes. const finishing = pending ?? settling; diff --git a/src/features/adjustments/controls.tsx b/src/features/adjustments/controls.tsx index 07ba1432..d1bb99e1 100644 --- a/src/features/adjustments/controls.tsx +++ b/src/features/adjustments/controls.tsx @@ -66,21 +66,24 @@ function AdjustmentSliders({ ); } -/** Tone, then the white balance's controls, then color. */ +/** Tone, then the white balance's controls, then color, then any controls that follow. */ export function AdjustmentControls({ id, adjustments, whiteBalance, + children, }: { id: string; adjustments: Readonly; whiteBalance: ReactNode; + children?: ReactNode; }) { return (
{whiteBalance} + {children}
); } diff --git a/src/features/noise-reduction/algorithm.md b/src/features/noise-reduction/algorithm.md new file mode 100644 index 00000000..ed5bbb09 --- /dev/null +++ b/src/features/noise-reduction/algorithm.md @@ -0,0 +1,21 @@ +# RAW noise reduction + +`denoiseMosaic(gpu, mosaic, signal)` reduces a RAW photo's noise on its Bayer mosaic, before the GPU demosaics it, and resolves to a half-size `rgba16float` target holding each 2 × 2 cell's four samples by position, in sensor units. It runs without React, model weights, or workers, entirely in WGSL but for two small fits in TypeScript. The RAW source keeps the result until it closes; a pass blends the decoded samples toward it by the image layer's amount and demosaics the blend, so fading or editing the layers above never reduces noise again. + +1. **Noise model.** Every 32 × 32 block of sensor pixels gives each cell position its mean and the variance of its diagonal Haar detail, which flat blocks owe to noise alone. Over 40 equal-count bins along the signal, the quietest quarter of each bin gives a point, and a weighted line through them is the position's Poisson-Gaussian model, variance = gain · signal + floor. Positions fit apart: some cameras scale a channel digitally, which scales its noise too. A block with a clipped sample is skipped. +2. **Spectrum.** The generalized Anscombe transform, measured from its value at black so half floats stay precise, makes each position's noise unit Gaussian, signal-dependent noise included. An orthonormal transform then turns red, the two greens, and blue into light, green against magenta, red against blue, and the greens' difference, keeping that noise unit. A sample far above its same-color neighbors and every adjacent one is a hot pixel, since light reaches neighbors through the lens; it takes its brightest same-color neighbor's value. Samples below the transform's lowest value, such as dead pixels, are clamped there. +3. **Pyramid.** Averaging 2 × 2 texels builds up to six levels, down to 32 texels. Each level measures its own noise per component from its flattest blocks rather than assuming it halves: lossy RAW compression, for one, leaves noise that is quieter at fine scales and louder at coarse ones, which an assumed halving turns into blotches. +4. **Filter.** From the coarsest level up, sliding 8 × 8 DCT denoising runs in two steps: hard thresholding at 3 deviations, 3.5 for the greens' difference, then Wiener shrinkage by the first estimate, with color shrinking twice and the greens' difference eight times as hard as light. Every patch position counts, each output texel gathers the 64 patches over it in workgroup memory, weighted by how little of each survived and by a Kaiser window. A patch's DC stays whole, so every level stays unbiased, except the greens' difference: both greens see the same light, so its mean is noise or imbalance, which demosaicing would draw as a maze. After each step, the upsampled difference between the coarser estimate and the step's own 2 × 2 averages, blurred, replaces the step's low frequencies: broad noise the 8 × 8 patches cannot tell from the image goes, the coarser level's finest frequencies, where its patches ring at edges, stay out, and where the two levels agree nothing changes. +5. **Inverse.** The orthonormal transform back, and the closed-form inverse that is unbiased for Poisson-Gaussian noise (Mäkitalo and Foi), return sensor samples, so shadows keep their level after a strong exposure push. + +Patches are filtered alone. Grouping similar ones, as BM3D does, ranks candidates by a distance that noise dominates in dark areas; along edges it groups aligned patches whose average keeps a profile of noise, which showed as streaks. Alone, a patch cannot mistake aligned noise for likeness. + +## Limits + +Detail with less signal than noise is gone for any filter: in the darkest areas of a high-ISO photo the result is smooth rather than textured. Very fine repeated texture near the noise level softens. The model assumes Poisson-Gaussian noise; row or column banding, which some sensors add, passes through. Only 2 × 2 color filter mosaics that the GPU demosaics take part; X-Trans and linear DNG files have no noise reduction. + +## Verification + +`tests/noise-reduction.test.ts` checks the noise fit against blocks of known noise, a third of them textured. `tests/noise-reduction.e2e.ts` opens a RAW fixture with seeded Poisson-Gaussian noise, reduces it, and checks that its deviation falls well below the noise while its mean holds, and that fading back to 0 develops the decoded samples again. + +References: [Yu and Sapiro, DCT image denoising](https://www.ipol.im/pub/art/2011/ys-dct/), [Dabov et al., BM3D](https://webpages.tuni.fi/foi/GCF-BM3D/), [Pierazzo, Morel, and Facciolo, multiscale DCT denoising](https://www.ipol.im/pub/art/2017/201/), [Foi et al., single-image Poisson-Gaussian noise fitting](https://webpages.tuni.fi/foi/papers/Foi-PoissonianGaussianClippedRaw-2007-IEEE_TIP.pdf), [Mäkitalo and Foi, the unbiased inverse](https://webpages.tuni.fi/foi/papers/OptGenAnscombeInverse-doublecolumn-preprint.pdf). diff --git a/src/features/noise-reduction/controls.tsx b/src/features/noise-reduction/controls.tsx new file mode 100644 index 00000000..2597c638 --- /dev/null +++ b/src/features/noise-reduction/controls.tsx @@ -0,0 +1,86 @@ +import { Slider } from "@roprgm/ui/slider"; +import { Spinner } from "@roprgm/ui/spinner"; +import { useEffect, useState } from "react"; +import { useGpu } from "vgpu-react"; +import type { Parameter } from "@/components/editor/parameter"; +import { useDocument } from "@/components/editor/session"; +import type { ImageLayer, Layer } from "@/core/document"; +import type { Mosaic } from "@/core/image"; +import { denoiseMosaic } from "./denoise"; +import { setNoiseReduction } from "./edits"; + +/** + * Whether the mosaic's noise-reduced samples are still being made for an amount above zero: the + * first time is slow, and the renderer shows the photo as it was until they are ready. + */ +function useReducing(mosaic: Mosaic | undefined, amount: number) { + const gpu = useGpu(); + const [settled, setSettled] = useState(); + const wanted = amount > 0; + useEffect(() => { + if (!mosaic || !wanted) { + return; + } + let active = true; + const settle = () => active && setSettled(mosaic); + mosaic + .denoised((mosaic, signal) => denoiseMosaic(gpu, mosaic, signal)) + .then(settle, settle); + return () => { + active = false; + }; + }, [gpu, mosaic, wanted]); + return wanted && settled !== mosaic; +} + +/** + * The image layer's noise reduction for a RAW photo whose mosaic the GPU demosaics, and whether its + * first reduction is running; nothing for other layers and photos. + */ +export function useNoiseReduction( + layer: Layer, +): { parameter: Parameter; reducing: boolean } | undefined { + const document = useDocument(); + const image = layer.kind === "image" ? layer : undefined; + const mosaic = image && document.resources.get(image.source).raw?.mosaic; + const value = image?.noiseReduction ?? 0; + const reducing = useReducing(mosaic, value); + if (!mosaic) { + return undefined; + } + return { + parameter: { + id: "noiseReduction", + label: "Noise reduction", + value, + min: 0, + max: 100, + defaultValue: 0, + onChange: (amount) => setNoiseReduction(document, amount), + }, + reducing, + }; +} + +/** The noise reduction slider, with a spinner beside its label while the first reduction runs. */ +export function NoiseReductionControls({ layer }: { layer: ImageLayer }) { + const noiseReduction = useNoiseReduction(layer); + if (!noiseReduction) { + return null; + } + const { id, ...parameter } = noiseReduction.parameter; + return ( +
+ + {noiseReduction.reducing && ( + // The slider's label row again, its text hidden, so the spinner follows the label. +
+ + {parameter.label} + + +
+ )} +
+ ); +} diff --git a/src/features/noise-reduction/denoise.ts b/src/features/noise-reduction/denoise.ts new file mode 100644 index 00000000..cbdcdac0 --- /dev/null +++ b/src/features/noise-reduction/denoise.ts @@ -0,0 +1,296 @@ +import { + type Buffer, + compute, + effect, + frame, + type Gpu, + type Target, + target, +} from "vgpu"; +import type { Mosaic } from "@/core/image"; +import { weakMemo } from "@/lib/weak-memo"; +import downShader from "./down.wgsl"; +import finishShader from "./finish.wgsl"; +import fuseShader from "./fuse.wgsl"; +import { measureNoise, type NoiseModel } from "./noise"; +import prepareShader from "./prepare.wgsl"; +import shrinkShader from "./shrink.wgsl"; +import spreadShader from "./spread.wgsl"; + +type Size = readonly [number, number]; +/** One level of the spectrum: four half floats per texel, row after row. */ +type Spectrum = { buffer: Buffer; size: Size }; + +const passes = weakMemo((gpu: Gpu) => ({ + prepare: compute(gpu, prepareShader), + down: compute(gpu, downShader), + spread: compute(gpu, spreadShader), + shrink: compute(gpu, shrinkShader), + blurRows: compute(gpu, fuseShader, { entry: "blurRows" }), + blurColumns: compute(gpu, fuseShader, { entry: "blurColumns" }), + finish: effect(gpu, finishShader), +})); + +/** Levels of the pyramid at most, and the shortest side a level may have. */ +const levels = 6; +const smallest = 32; +/** Spectrum texels along the side of the tile each workgroup of the filter takes. */ +const tile = 32; +/** Spectrum texels along the side of each block that measures a level's noise. */ +const block = 16; +/** + * The transform's largest value, in noise deviations: half floats keep it, and the filter's + * fixed-point sums of its patches stay in range. + */ +const largest = 1024; +/** The flattest quarter of blocks gives the noise; their chi-squared estimates sit at 0.885 of it. */ +const quartileBias = 0.885; + +function spectrum(gpu: Gpu, size: Size): Spectrum { + const buffer = gpu.device.createBuffer({ + size: size[0] * size[1] * 8, + usage: ["storage"], + }); + return { buffer, size }; +} + +function groups([width, height]: Size, side = 8) { + return [Math.ceil(width / side), Math.ceil(height / side)] as const; +} + +/** + * The least floor that keeps the transform of a sample `range` above black within `largest`: solving + * 2r / (√(a·r + c) + √c) = L for √c, with c = 3/8 · a² + b. A photo with little noise, which would + * stretch the transform further, is filtered as if it had that much. + */ +function leastFloor(gain: number, range: number) { + const root = range / largest - (gain * largest) / 4; + return root > 0 ? root * root - 0.375 * gain * gain : 0; +} + +/** The filter's noise model per 2 × 2 position, and the positions of red, the two greens, and blue. */ +function noiseParams( + { pattern, black, white }: Mosaic, + { gain, floor }: NoiseModel, +) { + const greens = [0, 1, 2, 3].filter((p) => pattern[p] % 2 === 1); + const blacks = pattern.map((color) => black[color]); + return { + order: [pattern.indexOf(0), greens[0], greens[1], pattern.indexOf(2)], + black: blacks, + gain, + floor: floor.map((b, p) => + Math.max(b, leastFloor(gain[p], white - blacks[p])), + ), + }; +} + +/** + * Each component's noise deviation in one level of the spectrum, from its flattest blocks: unit at + * the finest level when the model fits, and halving with each level only where the noise is white. + * Blocks without any detail, such as clipped highlights, hold no noise to measure; without others, + * the level takes `expected`, the deviation white noise would have. + */ +async function measureSpread( + gpu: Gpu, + { buffer, size }: Spectrum, + expected: number, +) { + const columns = Math.floor(size[0] / block); + const rows = Math.floor(size[1] / block); + const blocks = gpu.device.createBuffer({ + size: columns * rows * 16, + usage: ["storage", "copy_src"], + }); + try { + passes(gpu) + .spread.set({ size, spectrum: buffer, blocks }) + .dispatch(columns, rows); + const values = new Float32Array(await blocks.read(columns * rows * 16)); + return [0, 1, 2, 3].map((component) => { + const variances = values + .filter((variance, i) => i % 4 === component && variance > 0) + .sort((a, b) => a - b); + const quartile = variances[Math.floor(variances.length / 4)]; + return quartile ? Math.sqrt(quartile / quartileBias) : expected; + }); + } finally { + blocks.dispose(); + } +} + +/** One step of the patch filter over `noisy` into `output`, shrinking by `pilot`'s Wiener weights if given. */ +function shrink( + gpu: Gpu, + noisy: Spectrum, + output: Spectrum, + sigma: number[], + pilot?: Spectrum, +) { + const { size } = noisy; + passes(gpu) + .shrink.set({ + params: { size, sigma, wiener: Number(Boolean(pilot)) }, + noisy: noisy.buffer, + pilot: (pilot ?? noisy).buffer, + output: output.buffer, + }) + .dispatch(...groups(size, tile)); +} + +/** Replaces `estimate`'s low frequencies with those of `coarse`, the estimate of the level below, into `output`. */ +function fuse( + gpu: Gpu, + estimate: Spectrum, + coarse: Spectrum, + output: Spectrum, +) { + const { down, blurRows, blurColumns } = passes(gpu); + const average = spectrum(gpu, coarse.size); + const across = spectrum(gpu, estimate.size); + const sizes = { fine: estimate.size, coarse: coarse.size }; + try { + down + .set({ + sizes: { source: estimate.size, output: coarse.size }, + source: estimate.buffer, + output: average.buffer, + }) + .dispatch(...groups(coarse.size)); + blurRows + .set({ + sizes, + average: average.buffer, + coarse: coarse.buffer, + across: across.buffer, + }) + .dispatch(...groups(estimate.size)); + blurColumns + .set({ + sizes, + fine: estimate.buffer, + across: across.buffer, + output: output.buffer, + }) + .dispatch(...groups(estimate.size)); + } finally { + average.buffer.dispose(); + across.buffer.dispose(); + } +} + +/** + * Denoises one level of the pyramid, taking its low frequencies from the estimate of the level below + * when there is one: thresholding first, then Wiener shrinkage guided by that first estimate. + * `expected` is the level's noise deviation were the noise white. + */ +async function denoiseLevel( + gpu: Gpu, + noisy: Spectrum, + coarse: Spectrum | undefined, + expected: number, + signal?: AbortSignal, +) { + const sigma = await measureSpread(gpu, noisy, expected); + signal?.throwIfAborted(); + const first = spectrum(gpu, noisy.size); + try { + const second = spectrum(gpu, noisy.size); + try { + shrink(gpu, noisy, first, sigma); + if (!coarse) { + shrink(gpu, noisy, second, sigma, first); + return second; + } + fuse(gpu, first, coarse, second); + shrink(gpu, noisy, first, sigma, second); + fuse(gpu, first, coarse, second); + return second; + } catch (error) { + second.buffer.dispose(); + throw error; + } + } finally { + first.buffer.dispose(); + } +} + +/** + * Denoises `pyramid`, finest level first, from its coarsest level up: each level takes its low + * frequencies from the estimate of the one below it. `expected` is the finest level's noise + * deviation were the noise white, and halves with each level. + */ +async function denoisePyramid( + gpu: Gpu, + [level, ...coarser]: readonly Spectrum[], + signal?: AbortSignal, + expected = 1, +): Promise { + const coarse = coarser.length + ? await denoisePyramid(gpu, coarser, signal, expected / 2) + : undefined; + try { + return await denoiseLevel(gpu, level, coarse, expected, signal); + } finally { + coarse?.buffer.dispose(); + } +} + +/** + * Removes the mosaic's noise before demosaicing: its own noise model makes the noise unit Gaussian, + * a pyramid of patch filtering removes it from fine detail to broad color blotches, and the result + * returns to sensor samples: a half-size `rgba16float` target holding each 2 × 2 cell's four samples + * by position, above their black level. Stops at the next readback once `signal` aborts. + */ +export async function denoiseMosaic( + gpu: Gpu, + mosaic: Mosaic, + signal?: AbortSignal, +): Promise { + const { prepare, down, finish } = passes(gpu); + const size: Size = [ + Math.floor(mosaic.samples.width / 2), + Math.floor(mosaic.samples.height / 2), + ]; + if (Math.min(...size) < block) { + throw Error("The photo is too small to reduce its noise."); + } + const noise = noiseParams(mosaic, await measureNoise(gpu, mosaic)); + signal?.throwIfAborted(); + const pyramid = [spectrum(gpu, size)]; + try { + prepare + .set({ samples: mosaic.samples, noise, size, output: pyramid[0].buffer }) + .dispatch(...groups(size)); + while (pyramid.length < levels) { + const finer = pyramid[pyramid.length - 1]; + const next: Size = [ + Math.ceil(finer.size[0] / 2), + Math.ceil(finer.size[1] / 2), + ]; + if (Math.min(...next) < smallest) break; + const level = spectrum(gpu, next); + down + .set({ + sizes: { source: finer.size, output: next }, + source: finer.buffer, + output: level.buffer, + }) + .dispatch(...groups(next)); + pyramid.push(level); + } + const estimate = await denoisePyramid(gpu, pyramid, signal); + try { + const output = target(gpu, { size, format: "rgba16float" }); + const filtered = estimate.buffer; + frame(gpu, (f) => + f.pass(output, finish.set({ noise, size, spectrum: filtered })), + ); + return output; + } finally { + estimate.buffer.dispose(); + } + } finally { + for (const level of pyramid) level.buffer.dispose(); + } +} diff --git a/src/features/noise-reduction/down.wgsl b/src/features/noise-reduction/down.wgsl new file mode 100644 index 00000000..8413fb98 --- /dev/null +++ b/src/features/noise-reduction/down.wgsl @@ -0,0 +1,26 @@ +// Halves the spectrum by averaging each 2 × 2 block, which halves its noise; past an odd edge the last +// texel repeats. +import { pack, unpack } from "./texels.wgsl"; + +struct Sizes { + source: vec2u, + output: vec2u, +} + +@group(0) @binding(0) var sizes: Sizes; +@group(0) @binding(1) var source: array; +@group(0) @binding(2) var output: array; + +fn load(p: vec2u) -> vec4f { + let q = min(p, sizes.source - 1u); + return unpack(source[q.y * sizes.source.x + q.x]); +} + +@compute @workgroup_size(8, 8) fn down(@builtin(global_invocation_id) id: vec3u) { + if (any(id.xy >= sizes.output)) { + return; + } + let p = id.xy * 2u; + let sum = load(p) + load(p + vec2u(1u, 0u)) + load(p + vec2u(0u, 1u)) + load(p + 1u); + output[id.y * sizes.output.x + id.x] = pack(sum * 0.25); +} diff --git a/src/features/noise-reduction/edits.ts b/src/features/noise-reduction/edits.ts new file mode 100644 index 00000000..793ac1fd --- /dev/null +++ b/src/features/noise-reduction/edits.ts @@ -0,0 +1,22 @@ +import { type EditorDocument, editLayer } from "@/core/document"; +import { parse } from "@/lib/parse"; +import { noiseReductionSchema } from "./model"; + +/** Sets the image layer's noise reduction, 0 to 100, for a RAW photo the GPU demosaics. */ +export function setNoiseReduction(document: EditorDocument, amount: number) { + const [image] = document.scene.getState().layers; + if (!document.resources.get(image.source).raw?.mosaic) { + throw Error("Only RAW photos with a Bayer mosaic support noise reduction."); + } + const noiseReduction = parse( + noiseReductionSchema, + amount, + "Invalid noise reduction", + ); + editLayer(document, image.id, (layer) => { + if (layer.kind !== "image") { + throw Error("Select the image layer."); + } + return { ...layer, noiseReduction }; + }); +} diff --git a/src/features/noise-reduction/finish.wgsl b/src/features/noise-reduction/finish.wgsl new file mode 100644 index 00000000..2c0ff098 --- /dev/null +++ b/src/features/noise-reduction/finish.wgsl @@ -0,0 +1,14 @@ +// The filtered spectrum back to sensor samples above black, each texel one 2 × 2 cell's four by +// position. +import { fromSpectrum, Noise, unstabilize } from "./stabilize.wgsl"; +import { unpack } from "./texels.wgsl"; + +@group(0) @binding(0) var noise: Noise; +@group(0) @binding(1) var size: vec2u; +@group(0) @binding(2) var spectrum: array; + +@fragment fn fs_main(@builtin(position) position: vec4f) -> @location(0) vec4f { + let p = vec2u(position.xy); + let z = fromSpectrum(unpack(spectrum[p.y * size.x + p.x]), noise); + return unstabilize(z, noise); +} diff --git a/src/features/noise-reduction/fuse.wgsl b/src/features/noise-reduction/fuse.wgsl new file mode 100644 index 00000000..091bf839 --- /dev/null +++ b/src/features/noise-reduction/fuse.wgsl @@ -0,0 +1,71 @@ +// Replaces an estimate's low frequencies with those of the half-size spectrum's estimate, whose +// patches were large enough to tell broad noise from the image: the estimate plus the upsampled +// difference between that coarser estimate and the estimate's own 2 × 2 averages (after Pierazzo, +// Morel, and Facciolo, 2017), blurred so the coarser estimate's finest frequencies, where its patches +// ring at edges, stay out. Where the two agree, the estimate stays whole. Separable, across rows into +// `across`, then down columns into `output`. +import { pack, unpack } from "./texels.wgsl"; + +struct Sizes { + fine: vec2u, + coarse: vec2u, +} + +// A Gaussian of 1.25 texels, normalized over its nine taps. +const taps = array(0.3192, 0.2318, 0.0888, 0.0179, 0.0019); + +@group(0) @binding(0) var sizes: Sizes; +@group(0) @binding(1) var fine: array; +// The fine estimate's 2 × 2 averages, and the coarser scale's estimate. +@group(0) @binding(2) var average: array; +@group(0) @binding(3) var coarse: array; +@group(0) @binding(4) var across: array; +@group(0) @binding(5) var output: array; + +fn coarseAt(p: vec2i) -> vec4f { + let q = vec2u(clamp(p, vec2i(0), vec2i(sizes.coarse) - 1)); + let index = q.y * sizes.coarse.x + q.x; + return unpack(coarse[index]) - unpack(average[index]); +} + +// The coarser estimate less the fine one's averages, upsampled to `p`; bilinear, each coarse texel +// centered on the 2 × 2 block it averages. +fn difference(p: vec2i) -> vec4f { + let q = clamp(p, vec2i(0), vec2i(sizes.fine) - 1); + let position = vec2f(q) * 0.5 - 0.25; + let base = vec2i(floor(position)); + let f = position - floor(position); + let top = mix(coarseAt(base), coarseAt(base + vec2i(1, 0)), f.x); + let bottom = mix(coarseAt(base + vec2i(0, 1)), coarseAt(base + 1), f.x); + return mix(top, bottom, f.y); +} + +@compute @workgroup_size(8, 8) fn blurRows(@builtin(global_invocation_id) id: vec3u) { + if (any(id.xy >= sizes.fine)) { + return; + } + let p = vec2i(id.xy); + var sum = taps[0] * difference(p); + for (var k = 1; k < 5; k++) { + sum += taps[k] * (difference(p - vec2i(k, 0)) + difference(p + vec2i(k, 0))); + } + across[id.y * sizes.fine.x + id.x] = pack(sum); +} + +fn acrossAt(p: vec2i) -> vec4f { + let q = vec2u(clamp(p, vec2i(0), vec2i(sizes.fine) - 1)); + return unpack(across[q.y * sizes.fine.x + q.x]); +} + +@compute @workgroup_size(8, 8) fn blurColumns(@builtin(global_invocation_id) id: vec3u) { + if (any(id.xy >= sizes.fine)) { + return; + } + let p = vec2i(id.xy); + var sum = taps[0] * acrossAt(p); + for (var k = 1; k < 5; k++) { + sum += taps[k] * (acrossAt(p - vec2i(0, k)) + acrossAt(p + vec2i(0, k))); + } + let index = id.y * sizes.fine.x + id.x; + output[index] = pack(unpack(fine[index]) + sum); +} diff --git a/src/features/noise-reduction/model.ts b/src/features/noise-reduction/model.ts new file mode 100644 index 00000000..5a0ecf77 --- /dev/null +++ b/src/features/noise-reduction/model.ts @@ -0,0 +1,4 @@ +import { range } from "@/lib/parse"; + +/** How far a RAW photo develops toward its noise-reduced samples, in UI units; 0 develops them as decoded. */ +export const noiseReductionSchema = range(0, 100); diff --git a/src/features/noise-reduction/noise.ts b/src/features/noise-reduction/noise.ts new file mode 100644 index 00000000..d728397f --- /dev/null +++ b/src/features/noise-reduction/noise.ts @@ -0,0 +1,119 @@ +import { compute, type Gpu } from "vgpu"; +import type { Mosaic } from "@/core/image"; +import { weakMemo } from "@/lib/weak-memo"; +import shader from "./noise.wgsl"; + +type Vec4 = [number, number, number, number]; + +/** + * Sensor noise per 2 × 2 cell position: the variance of a sample `x` above black is + * `gain · x + floor`, photon noise growing with the signal over the read noise. + */ +export type NoiseModel = { gain: Vec4; floor: Vec4 }; + +const measure = weakMemo((gpu: Gpu) => + compute(gpu, shader, { entry: "measure" }), +); + +/** Sensor pixels along each block's side. */ +const block = 32; +/** Equal-count bins of blocks along the signal, each of which yields one point to fit. */ +const bins = 40; +/** + * The variance of the quietest quarter of a bin's blocks comes from its flattest ones; a block's + * estimate over 64 details is chi-squared, whose first quartile lies at 0.885 of its mean. + */ +const quartile = 0.25; +const quartileBias = 0.885; + +function fitLine(points: readonly [number, number][]) { + // Weighted by the inverse variance squared, so dark and bright bins count by relative error. + let sw = 0; + let sx = 0; + let sy = 0; + let sxx = 0; + let sxy = 0; + for (const [x, y] of points) { + const w = 1 / (y * y); + sw += w; + sx += w * x; + sy += w * y; + sxx += w * x * x; + sxy += w * x * y; + } + const gain = (sw * sxy - sx * sy) / (sw * sxx - sx * sx); + const floor = (sy - gain * sx) / sw; + if (!(gain > 0)) return { gain: 0, floor: sy / sw }; + if (!(floor > 0)) return { gain: sxy / sxx, floor: 0 }; + return { gain, floor }; +} + +/** + * Fits one position's model to its blocks' means and variances, skipping clipped blocks; without + * any, the photo shows no noise. + */ +export function fitNoise( + means: readonly number[], + variances: readonly number[], +) { + const usable = means + .map((mean, i) => [mean, variances[i]] as [number, number]) + .filter(([, variance]) => variance > 0) + .sort(([a], [b]) => a - b); + if (!usable.length) { + return { gain: 0, floor: 0 }; + } + // A small photo fits fewer points, down to one, which leaves read noise alone. + const count = Math.min(bins, Math.ceil(usable.length / 16)); + const points: [number, number][] = []; + for (let i = 0; i < count; i++) { + const bin = usable.slice( + Math.floor((i * usable.length) / count), + Math.floor(((i + 1) * usable.length) / count), + ); + const variances = bin.map(([, variance]) => variance).sort((a, b) => a - b); + const mean = bin[Math.floor(bin.length / 2)][0]; + const variance = variances[Math.floor(bin.length * quartile)]; + points.push([mean, variance / quartileBias]); + } + return fitLine(points); +} + +/** Measures the mosaic's noise on the GPU from its flat blocks, then fits each position's model. */ +export async function measureNoise( + gpu: Gpu, + { samples, pattern, black, white }: Mosaic, +): Promise { + const columns = Math.floor(samples.width / block); + const rows = Math.floor(samples.height / block); + const stats = gpu.device.createBuffer({ + size: columns * rows * 32, + usage: ["storage", "copy_src"], + }); + try { + measure(gpu) + .set({ + samples, + params: { black: pattern.map((color) => black[color]), white }, + blocks: stats, + }) + .dispatch(columns, rows); + const values = new Float32Array(await stats.read(columns * rows * 32)); + const gain: Vec4 = [0, 0, 0, 0]; + const floor: Vec4 = [0, 0, 0, 0]; + for (let position = 0; position < 4; position++) { + const means: number[] = []; + const variances: number[] = []; + for (let i = 0; i < values.length; i += 8) { + means.push(values[i + position]); + variances.push(values[i + 4 + position]); + } + const fit = fitNoise(means, variances); + gain[position] = fit.gain; + floor[position] = fit.floor; + } + return { gain, floor }; + } finally { + stats.dispose(); + } +} diff --git a/src/features/noise-reduction/noise.wgsl b/src/features/noise-reduction/noise.wgsl new file mode 100644 index 00000000..8813a42a --- /dev/null +++ b/src/features/noise-reduction/noise.wgsl @@ -0,0 +1,63 @@ +// Noise statistics of a Bayer mosaic: for each block of 32 × 32 sensor pixels, each 2 × 2 cell +// position's mean above black and the variance of its diagonal Haar detail, which flat blocks owe to +// noise alone. A position with a clipped sample in the block reports a negative variance, which the +// fit skips. +const side = 8u; + +struct Params { + black: vec4f, + white: f32, +} + +@group(0) @binding(0) var samples: texture_2d; +@group(0) @binding(1) var params: Params; +// Per block: the four positions' means, then their variances. +@group(0) @binding(2) var blocks: array; + +var means: array; +var details: array; +var clipped: array; + +// The 2 × 2 cell at `cell`, one sample per position, above black. +fn cell(cell: vec2u) -> vec4f { + let p = vec2i(cell * 2u); + return vec4f( + f32(textureLoad(samples, p, 0).r), + f32(textureLoad(samples, p + vec2i(1, 0), 0).r), + f32(textureLoad(samples, p + vec2i(0, 1), 0).r), + f32(textureLoad(samples, p + vec2i(1, 1), 0).r), + ) - params.black; +} + +@compute @workgroup_size(side, side) fn measure( + @builtin(workgroup_id) block: vec3u, + @builtin(local_invocation_id) local: vec3u, + @builtin(local_invocation_index) thread: u32, + @builtin(num_workgroups) blocks_: vec3u, +) { + // Each thread takes 2 × 2 cells: their mean and their diagonal detail, (a - b - c + d) / 2. + let origin = (block.xy * side + local.xy) * 2u; + let a = cell(origin); + let b = cell(origin + vec2u(1u, 0u)); + let c = cell(origin + vec2u(0u, 1u)); + let d = cell(origin + vec2u(1u, 1u)); + let detail = (a - b - c + d) * 0.5; + means[thread] = (a + b + c + d) * 0.25; + details[thread] = detail * detail; + let top = max(max(a, b), max(c, d)) + params.black; + clipped[thread] = vec4u(top >= vec4f(params.white)); + workgroupBarrier(); + for (var stride = 32u; stride > 0u; stride /= 2u) { + if (thread < stride) { + means[thread] += means[thread + stride]; + details[thread] += details[thread + stride]; + clipped[thread] = max(clipped[thread], clipped[thread + stride]); + } + workgroupBarrier(); + } + if (thread == 0u) { + let index = (block.y * blocks_.x + block.x) * 2u; + blocks[index] = means[0] / 64.0; + blocks[index + 1u] = select(details[0] / 64.0, vec4f(-1.0), clipped[0] != vec4u(0u)); + } +} diff --git a/src/features/noise-reduction/prepare.wgsl b/src/features/noise-reduction/prepare.wgsl new file mode 100644 index 00000000..ee46bf45 --- /dev/null +++ b/src/features/noise-reduction/prepare.wgsl @@ -0,0 +1,58 @@ +// Packs each 2 × 2 cell of the mosaic into one texel of the filter's spectrum, where noise is unit +// Gaussian everywhere. A sample that stands far above both its same-color neighbors and every +// adjacent one is a hot pixel rather than light, which would also reach its neighbors through the +// lens, and takes its brightest same-color neighbor's value. +import { Noise, stabilize, toSpectrum } from "./stabilize.wgsl"; +import { pack } from "./texels.wgsl"; + +// How many noise deviations a hot pixel stands out by. +const outlier = 5.0; + +@group(0) @binding(0) var samples: texture_2d; +@group(0) @binding(1) var noise: Noise; +@group(0) @binding(2) var size: vec2u; +@group(0) @binding(3) var output: array; + +// The 6 × 6 samples around the cell, stabilized, row by row. +var window: array; + +fn at(x: i32, y: i32) -> f32 { + return window[y * 6 + x]; +} + +@compute @workgroup_size(8, 8) fn prepare(@builtin(global_invocation_id) id: vec3u) { + if (any(id.xy >= size)) { + return; + } + for (var j = 0; j < 9; j++) { + let offset = vec2i(j % 3, j / 3); + let cell = clamp(vec2i(id.xy) + offset - 1, vec2i(0), vec2i(size) - 1); + let p = cell * 2; + let z = stabilize(vec4f( + f32(textureLoad(samples, p, 0).r), + f32(textureLoad(samples, p + vec2i(1, 0), 0).r), + f32(textureLoad(samples, p + vec2i(0, 1), 0).r), + f32(textureLoad(samples, p + vec2i(1, 1), 0).r), + ) - noise.black, noise); + for (var k = 0; k < 4; k++) { + window[(offset.y * 2 + k / 2) * 6 + offset.x * 2 + k % 2] = z[k]; + } + } + var z: vec4f; + for (var k = 0; k < 4; k++) { + let x = 2 + k % 2; + let y = 2 + k / 2; + let same = max(max(at(x - 2, y), at(x + 2, y)), max(at(x, y - 2), at(x, y + 2))); + var adjacent = -3.4e38; + for (var dy = -1; dy <= 1; dy++) { + for (var dx = -1; dx <= 1; dx++) { + if (dx != 0 || dy != 0) { + adjacent = max(adjacent, at(x + dx, y + dy)); + } + } + } + let value = at(x, y); + z[k] = select(value, same, value > same + outlier && value > adjacent + outlier); + } + output[id.y * size.x + id.x] = pack(toSpectrum(z, noise)); +} diff --git a/src/features/noise-reduction/shrink.wgsl b/src/features/noise-reduction/shrink.wgsl new file mode 100644 index 00000000..61b29fc1 --- /dev/null +++ b/src/features/noise-reduction/shrink.wgsl @@ -0,0 +1,149 @@ +// One step of sliding DCT denoising (Yu and Sapiro, 2011), the patch filter BM3D builds on (Dabov, +// Foi, Katkovnik, and Egiazarian, 2007), over a tile of the spectrum per workgroup: every 8 × 8 patch +// that reaches the tile is transformed, shrunk, and added back to the texels it covers, weighted by +// how little of it survived. The first step keeps the coefficients that stand out of the noise; the +// second shrinks each by the Wiener weight the first step's estimate gives it. Patches are filtered +// alone rather than grouped with similar ones, which would mistake aligned noise for likeness. +import { pack, unpack } from "./texels.wgsl"; + +const side = 8u; +const tile = 32u; +// Patches whose texels reach the tile, along each side. +const span = 39u; +const threads = 256u; +// Coefficients below this many noise deviations are noise, in the first step; the greens' difference +// holds less detail than light and color. +const lambda = vec4f(3.0, 3.0, 3.0, 3.5); +// How much more each component's noise counts in the Wiener weights: color, and more so the greens' +// difference, shrink harder than light. +const caution = vec4f(1.0, 2.0, 2.0, 8.0); +// Fixed-point scales of the sums. Light, the largest component, reaches twice the transform's largest +// value, 1024, and ringing at most doubles it; the 64 patches over a texel weigh 36 at most, in their +// Kaiser windows. Both sums stay in range. +const valueScale = 8192.0; +const weightScale = 16777216.0; +const kaiser = array(0.438676, 0.681324, 0.87684, 0.985823, 0.985823, 0.87684, 0.681324, 0.438676); + +struct Params { + size: vec2u, + // Each component's noise deviation. + sigma: vec4f, + // 0 thresholds; 1 shrinks by the Wiener weights of `pilot`, the first step's estimate. + wiener: u32, +} + +@group(0) @binding(0) var params: Params; +@group(0) @binding(1) var noisy: array; +@group(0) @binding(2) var pilot: array; +@group(0) @binding(3) var output: array; + +// The 8-point DCT basis, [frequency][position]. +var basis: array; +var sums: array, 1024>; +var weights: array, 1024>; + +// The 2D DCT of a block, or its inverse, separably along rows and then columns. +fn transform(block: ptr>, inverse: bool) { + var line: array; + for (var step = 0u; step < 2u; step++) { + for (var a = 0u; a < 8u; a++) { + for (var k = 0u; k < 8u; k++) { + var sum = 0.0; + for (var i = 0u; i < 8u; i++) { + let b = select(basis[k * 8u + i], basis[i * 8u + k], inverse); + sum += b * (*block)[select(a * 8u + i, i * 8u + a, step == 1u)]; + } + line[k] = sum; + } + for (var k = 0u; k < 8u; k++) { + (*block)[select(a * 8u + k, k * 8u + a, step == 1u)] = line[k]; + } + } + } +} + +@compute @workgroup_size(16, 16) fn shrink( + @builtin(workgroup_id) cell: vec3u, + @builtin(local_invocation_index) t: u32, +) { + let frequency = (t / 8u) % 8u; + let position = t % 8u; + if (t < 64u) { + basis[t] = select(0.5, 0.35355339, frequency == 0u) * cos(f32((2u * position + 1u) * frequency) * 0.19634954); + } + let origin = vec2i(cell.xy * tile); + let last = vec2i(params.size) - i32(side); + var results: array; + for (var channel = 0u; channel < 4u; channel++) { + for (var i = t; i < tile * tile; i += threads) { + atomicStore(&sums[i], 0); + atomicStore(&weights[i], 0u); + } + workgroupBarrier(); + let sigma = params.sigma[channel]; + for (var i = t; i < span * span; i += threads) { + let corner = origin - i32(side - 1u) + vec2i(vec2u(i % span, i / span)); + if (any(corner < vec2i(0)) || any(corner > last)) { + continue; + } + var values: array; + var guide: array; + for (var j = 0u; j < 64u; j++) { + let p = vec2u(corner) + vec2u(j % 8u, j / 8u); + let index = p.y * params.size.x + p.x; + values[j] = unpack(noisy[index])[channel]; + if (params.wiener == 1u) { + guide[j] = unpack(pilot[index])[channel]; + } + } + transform(&values, false); + if (params.wiener == 1u) { + transform(&guide, false); + } + // A patch's mean, its DC, stays whole: the inverse transform expects every level unbiased. Not + // the greens' difference: both greens see the same light, so its mean is noise or imbalance, + // which demosaicing would draw as a maze. + var kept = 0.0; + for (var j = 0u; j < 64u; j++) { + if (j == 0u && channel != 3u) { + kept += 1.0; + } else if (params.wiener == 1u) { + let g = guide[j] * guide[j]; + let w = g / (g + caution[channel] * sigma * sigma); + values[j] *= w; + kept += w * w; + } else if (abs(values[j]) > lambda[channel] * sigma) { + kept += 1.0; + } else { + values[j] = 0.0; + } + } + transform(&values, true); + let weight = 1.0 / max(kept, 1.0); + for (var j = 0u; j < 64u; j++) { + let p = corner + vec2i(vec2u(j % 8u, j / 8u)) - origin; + if (all(p >= vec2i(0)) && all(p < vec2i(i32(tile)))) { + let w = weight * kaiser[j % 8u] * kaiser[j / 8u]; + let index = u32(p.y) * tile + u32(p.x); + atomicAdd(&sums[index], i32(round(values[j] * w * valueScale))); + atomicAdd(&weights[index], u32(round(w * weightScale))); + } + } + } + workgroupBarrier(); + for (var k = 0u; k < 4u; k++) { + let i = t + k * threads; + let value = f32(atomicLoad(&sums[i])) / valueScale; + let weight = f32(atomicLoad(&weights[i])) / weightScale; + results[k][channel] = value / weight; + } + workgroupBarrier(); + } + for (var k = 0u; k < 4u; k++) { + let i = t + k * threads; + let p = vec2u(origin) + vec2u(i % tile, i / tile); + if (all(p < params.size)) { + output[p.y * params.size.x + p.x] = pack(results[k]); + } + } +} diff --git a/src/features/noise-reduction/spread.wgsl b/src/features/noise-reduction/spread.wgsl new file mode 100644 index 00000000..80ee2ccc --- /dev/null +++ b/src/features/noise-reduction/spread.wgsl @@ -0,0 +1,36 @@ +// The noise left in one level of the spectrum: for each block of 16 × 16 texels, the variance of each +// component's diagonal Haar detail, which flat blocks owe to noise alone. +import { unpack } from "./texels.wgsl"; + +const side = 8u; + +@group(0) @binding(0) var size: vec2u; +@group(0) @binding(1) var spectrum: array; +@group(0) @binding(2) var blocks: array; + +var details: array; + +fn load(p: vec2u) -> vec4f { + return unpack(spectrum[p.y * size.x + p.x]); +} + +@compute @workgroup_size(side, side) fn measure( + @builtin(workgroup_id) block: vec3u, + @builtin(local_invocation_id) local: vec3u, + @builtin(local_invocation_index) thread: u32, + @builtin(num_workgroups) count: vec3u, +) { + let p = (block.xy * side + local.xy) * 2u; + let detail = (load(p) - load(p + vec2u(1u, 0u)) - load(p + vec2u(0u, 1u)) + load(p + 1u)) * 0.5; + details[thread] = detail * detail; + workgroupBarrier(); + for (var stride = 32u; stride > 0u; stride /= 2u) { + if (thread < stride) { + details[thread] += details[thread + stride]; + } + workgroupBarrier(); + } + if (thread == 0u) { + blocks[block.y * count.x + block.x] = details[0] / 64.0; + } +} diff --git a/src/features/noise-reduction/stabilize.wgsl b/src/features/noise-reduction/stabilize.wgsl new file mode 100644 index 00000000..4464ca1c --- /dev/null +++ b/src/features/noise-reduction/stabilize.wgsl @@ -0,0 +1,65 @@ +// The mosaic's noise model per 2 × 2 cell position, and the transforms between sensor samples and the +// filter's spectrum, where noise is unit Gaussian everywhere. + +export struct Noise { + // The positions of red, the two greens, and blue. + order: vec4u, + black: vec4f, + // A sample x above black varies by gain · x + floor. + gain: vec4f, + floor: vec4f, +} + +// Orthonormal, so unit noise stays unit noise: light, then green against magenta, red against blue, +// and the two greens' difference, which holds little more than noise. +const spectrum = mat4x4f( + 0.5, 0.5, 0.70710678, 0.0, + 0.5, -0.5, 0.0, 0.70710678, + 0.5, -0.5, 0.0, -0.70710678, + 0.5, 0.5, -0.70710678, 0.0, +); + +// The lowest value the transform takes, at x = −c/a, below which the model allows no sample: dead +// pixels lie there. Unbounded without photon noise. +fn lowest(noise: Noise) -> vec4f { + let root = sqrt(0.375 * noise.gain * noise.gain + noise.floor); + return select(vec4f(-3.4e38), -2.0 * root / noise.gain, noise.gain > vec4f(0.0)); +} + +// The generalized Anscombe transform 2/a · (√(a·x + c) − √c), with c = 3/8 · a² + b, of samples +// above black, written so it keeps its precision as the gain goes to zero. Zero at black, which +// keeps half floats precise. +export fn stabilize(x: vec4f, noise: Noise) -> vec4f { + let c = 0.375 * noise.gain * noise.gain + noise.floor; + return max(2.0 * x / (sqrt(max(noise.gain * x + c, vec4f(0.0))) + sqrt(c)), lowest(noise)); +} + +// The inverse that is unbiased for Poisson-Gaussian noise, in the closed form of Mäkitalo and Foi +// (2013): the algebraic inverse plus a correction in powers of 1 / D, where D is the transform +// measured from its own zero. +export fn unstabilize(value: vec4f, noise: Noise) -> vec4f { + let a = noise.gain; + let root = sqrt(0.375 * a * a + noise.floor); + let z = max(value, lowest(noise)); + let u = clamp(a / (a * z + 2.0 * root), vec4f(0.0), vec4f(1.0)); + let correction = 0.25 + u * (0.30618622 + u * (-1.375 + u * 0.76546554)); + return root * z + 0.25 * a * z * z + a * correction; +} + +// Stabilized samples by position into the spectrum. +export fn toSpectrum(z: vec4f, noise: Noise) -> vec4f { + let o = noise.order; + return spectrum * vec4f(z[o.x], z[o.y], z[o.z], z[o.w]); +} + +// The spectrum back to stabilized samples by position. +export fn fromSpectrum(value: vec4f, noise: Noise) -> vec4f { + let colors = value * spectrum; + let o = noise.order; + var z: vec4f; + z[o.x] = colors.x; + z[o.y] = colors.y; + z[o.z] = colors.z; + z[o.w] = colors.w; + return z; +} diff --git a/src/features/noise-reduction/texels.wgsl b/src/features/noise-reduction/texels.wgsl new file mode 100644 index 00000000..db7454fd --- /dev/null +++ b/src/features/noise-reduction/texels.wgsl @@ -0,0 +1,8 @@ +// The spectrum lives in storage buffers: four half floats per texel, row after row. +export fn unpack(texel: vec2u) -> vec4f { + return vec4f(unpack2x16float(texel.x), unpack2x16float(texel.y)); +} + +export fn pack(value: vec4f) -> vec2u { + return vec2u(pack2x16float(value.xy), pack2x16float(value.zw)); +} diff --git a/tests/fixtures/raw/README.md b/tests/fixtures/raw/README.md index be95c2d4..d39443b6 100644 --- a/tests/fixtures/raw/README.md +++ b/tests/fixtures/raw/README.md @@ -5,3 +5,5 @@ These two synthetic MIT-licensed fixtures are copied unchanged from [raw-webgpu] `bayer.dng` stores RGGB samples; `linear-jxl.dng` stores equivalent linear RGB with JPEG XL compression. Both use an sRGB-like camera profile, samples `[32768, 16384, 8192]`, white level 65535 and orientation 6. Expected output is 96×128 with sRGB pixels approximately `[188, 137, 99]`. OpenLight tests loading, white-balance edits, preview/export agreement, history and resource lifetime with these files. + +`noisy-bayer.dng` is `bayer.dng` with seeded Poisson-Gaussian noise in its samples, at 64 sensor units per electron over a read noise of 300, for noise reduction. diff --git a/tests/fixtures/raw/noisy-bayer.dng b/tests/fixtures/raw/noisy-bayer.dng new file mode 100644 index 0000000000000000000000000000000000000000..0d751341ee7189799b0c02e9acd9b65a7dc3323b GIT binary patch literal 25040 zcmZs?b$HuKw>_LPw<#r!ZK0i%xlL@*%t$h%c#5aY+?3LQS>|AwnVFd(?I|-ew<*&p z^H2A^?;rPjo=<-4+0u+;%X>7l_gZU`lj}s4j*5z^5%oK&cvKP9`p;J6e{G+-Q{=yP zAJr~eq}YGjM5}hO|Jwg^PRak;dS6sj<^Q%nuU1s~@9~mqo9h3kea!#4X6%3Ms6J6q znj$6sbNvSDx!V8QiK<=e|D4;nNYSYB>h%Qm__#o^sG_P}RDF#P6jAR$-HH?`TIJ~f z+C@qgiK_a4>}p4gMrqY1E!1#Y|CH+x{-65fze`5_PyJ+1>Hn1c-TVKkzuj)p zfA;^U?*H%opC9+1kLSvFc73Ec%nZHZcX}Ezzg&%^PyRY+uI~p}6x=4f@()GbBWciQ z-vABnxjd0w#=OQ|3>9X_awf-8bFBZhSUEfuRg1XIjU!cIi-1=Mxqq2vd+STutjA)q z|0~bB>Y=(}rkQYu7M+bWt2RWXDHSQrO(H+aiIPS0=JiXv3Un{GQP09;)hlkx;xFp|m@mHvh*fQK!tg5VRx*_0UFWQd_i^eYF`gus{YVK`j zaz;I&yZgKRDzE`obwX~h=RI3KESY@a3+xZi2%gG)&0O>*Ntc7E+{xe-erV!YjD;%+ z3{}P7fh6)9XcRYu8(FV0KZV+Ayo9}wcH^BzGu@Cg6FuQUY%O~qdkwl1P58#tMCpq@ z+EP#V0DL6w361H$P%mt>aGCJ2`9v)Ei`Z@6g6%|h{3idANP~y)s+Je@J}^?dgX>Md z;%4AW&`JFP;}ra$aG&Y`B>h&dIldX)pgQtB=}hwua-R7zKF@5$k8>D44?tZFOUSSm z6xUfz*RkeYY2t*GOfR-9#q-fr-BUD$=pfv{=1O6_qwN!IL`4nP*f3p%UxgKb1K2<8 zS50L>LwW&D=)f4mc66QEg?{N>S&XHUH^w|91chSGVSID2u%!*X1?&&`jN7H_!^ zqtY$}6a7p0iuNj?Z6KMs628bh@^s+ugkHh@x%=c#t`;pfyRS1bUaRQv%>ou>0y-GN zg=c|drWf8{Ee?79ZMNQ1OTH7l%1?0IG}ly`N{a%`O}U|`TuLZLNbt@T4*IsEefCfC zmz)6n?J2aj3bz-pg>yl6T7AA_RujuL&lEl(>;i>Kj@g^P3RYH@g5KeCt+x9Xv%|a= zCc}@U_Q9g!7Jp-~(@8b&?Bn@m;d0&-`~u$| z=6G^ly$&uV4uYe)Sj&>QnZkSQg)o;s#$BXb0z>o%M&c?$^nS35*lH?Lvo`puPm!PE z&tVBXPFg{GZ3pmHd~0&AT!wfHMr*IhQFvpPrL|x`R+3qi@I#zKSLdhF1YE3Bgksbl zp%S%&KY*{52U9UzDq)w}k~_?$^&k12|=0`r>!u{7cH((8nQ0G8+A}Vp|tRN zSR(x{#3GmAJbwf(b%YlMS7r;Zsg+^J8 zrQx0>yy7e+C5?@OJ0?l;$Gq;~mQs=F;nJBag*$^Q4o2whEY0TyBcN|i8Ca;S1s5Eb zg|Ch_l2e{QKH=}^0Yqe$Wub04>PWnUBeYkN*4JnV^0A)AesOVPJF+VHQsWTN#Xse$ zW8H-n)KsAYW&tb7K42MHA1*Sav#WGb(rJ=48^~3xqOS~VVp~u@-6=FSu9PrPn)J6V@YBN7_;QH` z`W@eb1c1}cWfu`%u^6!xH6msujVA9)^K_e;ZbWCkVSG)gCE1kQg?l+lw-N^R?}d-p zc-S1*C0`?di(%F0-SI{IE$l3Jmc9mBQXRSPcoMfm7Z>;{4)SZIj#-Illy^Vu zZ~gmBi&GbYSL&EPIHWNj^NlmVR)%t{uNz88TP=(Yd6_vmr=?H9Mz%(dd;ICbnhdwm3Pk5#|NHwm6yD zornHBd6n|XHr&OC-FzK^7%t28vdImpBcPES8{Ji{r_SmNi6IVLkEQJeYiCwBs>gg>DMl9@9uUx<@97m|^-% zFXZ}BWi2B#=^VmoF1yx|W^snD=5@M`P$DmIdeX{IBImR1$%*^~JjkymyMxh0q1Z;d zsrgj=f@On#xcms;jh@q!g%Xrmd_&Ys-h<5(YwIgo!*~(y6aET~BufkbQVpds^tEJ# zoG%o_j^%-l7S>XSQII+;xoI1?iPwjtu}Azme3N|9a8(*l+?NXI&S~wrS;`o;hdTdH za_Zb`xItQbi%Noj7~!7(`{AlnqK3blX)P zWdsKEYjgRemi8fVqIa4(*30m_l?m{H{RH36`w#!#{e~M5=wx{mDFyzdO%Rm8A2h;y z3%m?gWOK94Nr#jH&7Wp-CcV49v7zss=}vxYkg1I0tGYRHpVCV{X76J?9IS5{7O0PO z*^2pg25ZuXmodfj?{F_ukp+Ygpq#Ad*5SdEs9xRycqd$29P8{0ERkc<@Ql_X?@JT* zxZT`x&mA*#JY-Gw(jX-?3~u+@U=OQVTQwKA7%ZpL;XM5S zzL$Qo{0O^c4(X@JK;K{15FMFh;y}y(xPuMRovfSi?4~%r0wm}raj|qVuu{KJ7jnG# z3SY*oQpa)$Ya>L(UFC}D9q5yG1=GRsL%15(MNFoum|p5`h<&wL!gr#h+z0Ciw&CNY z`|&rDdGf})AbVnzK0_=&ufl%#Kus>U@Ib`tZ<(};ib zKXV_#MfgH{*QAAk4@`5rOYD>RN51PSYIfUa$>Wr@N!^_D8P;P>j?J&jUCO#9S;j4v zkSAaI;y%Kpxu&4CIiFBl$a^NH0M-YOigDlcl^;jrA8j zgaJ8Y0iMwfx_r}xQ|WPHrW%8;bml;3I64`PX(QG3%Hnx{Q2gW{#{3hUE++a2(#4#Ip>!pRa4U$j_x88n;s`r0oPFMo5jZvu?70h}vL|wqbIr?g2NIwu^T1IQWQd z6?Rbt+!TFRNunmH<3v46E&L|8|z$C{AwVW3lOQraplo$>H*5jn%Zbwv1jF>`A*58Z!*OLYSt;nEzrWEKk(> zX2fH33m+;rA`VN#uym`1xFY?gzgS+7zlD|939-EXxDZ8uX2;Rb`0u*T@H3SRYt_t2 z{;Au@BKop7&9c~ zB6r{>mlo=kcd{55g|2$uGD&$=<ODT?C zk7+Csc?nzz?=bkUuc@#>nQb2LOO!8V|@Cmt;DM_cqcFHt-H3)3oIufJ&uP@VZ|`drgf zd_TL9dWlvUjx+o59?~wl4z!c^#rgDgIGq}U+UZjOfenyuYcs@VG!2eo?Zm&xF49_U ze`81bkDO_!&D|wRo7WL_k`~d&K{5I<>>Sqv6%vKkt@_47JHt$|qy7uuim0A^ova{4 zkqN9HpDK)|W#+ZoPiE-4n@(y*^7HYUAdx14g$hfPbbZVkazC4=9|P*po0y`+JvcY6 zbMiE7l=V0^08Ah+wCbwE;1jH=ERnrIN&OMnPydwtgFmp0BUi{xNQNIl?2(pJ8zGLR z%W7b5Sx?PLtfY5wf9c~CJ(y=tl-8sTkc&BI8U5akX394f>6M0Zr$7_5+WC|Zq?w_R zH#hNvdmS2_odu@4wty|6RCLAn16Fa&X5B{#-#u4iwYhcK z1A!~eD*G9(ujes*lf4j@@}FTx`xb%RNOyA)S4Hz6`wqTA@R6Jz$}>N5E#@Y;8%w8^ zO!Ug_0JB`R`z&pGT+K&#V}@!59_UU3bOrSn> zm(dap5;u}Y;Z(v*cE7fo__ux+^A%f+>~tntL{;L3kzZwuy2Ab;ZR}xuv|J0{%=wA3 z;vCA>x~*=oyiS`9YLf3ze>@emu3c4XOzgGZ!&~rv`Y2pXpOuE_Do7WJGscF*A)u|f zMhek(DcUd@oX4z!nP@FtC1ZpQ_>?q*v_r{<9TmOU-CdsTmGzL%_QVM8a0VI|JkNIy zz2t5y2I0164Y=>>CY*D<2hYN<(D~48?u;S|Wjx1B{hfa-9ldkJ#jaSPrmHgYxaLW7 zl-k@-{}HKED31SWKgoadwURhjd%+#3CjSV(G1pV(f#tqq;vf42zIg6e$&%TG`xKfe zZ3$h1DavJ2F?-O=x=sjOxH2db90?fZige12iM+F{;8H4Cx}~)h&F-(#D|cJiFXFRa z&8lqr>8!*y3|>h*6u4{I?awk*3nv3$zsrqQSXt{IDDNFJl7E%^h^gkk%-#)8Ly7h{ zb3;atiDDJ(JDRLbVdIGnXg)rOsYPbxf2DCn16L zu|1={nj2_FgZsoA{g?Xd4)Aq>HGMB2 z8T!RGcIODkLz_@B&q}#b#3K##4`e5LwJ6s!oG+D;3SN3fh!cIagxru-oRz%FO4(`YMWC`u?Q81fof$me)%omB?AVq5vZxf$|K8AxJTg7Vj z=;xS^;s+%&(ZJf1q@WhR!LOzh%t_d01T^WOn$9OTsEJ7r=qu1kC7b_H<3YC80ICuL z!C?&XWvCRnI(Nry;UI)yv z*T6>I9l22V1;xeJvNXjfGNXw|vRD60>`jGGBCZW=2Cm2haZ@fP&vsVhMtOE|y#u|V z5Ngh?an}?Uc;CPYIU(+jG6bHpuS`7ZZ!UBU+rZ-7NAh{sOq(?aGu`u+V@~*Hp)$_v zd`TzGzROhr>1u8%bdM3bN6w&r!8BNTtl4}+$pNi#|S&*ECn8y5!4L((S_6xrVD|_%ma6iqz$7^n9c^zLrvBe znfDFklU#@3Rs|QH2aNoQte1@4^FuhA<%F*S7i|q9jik}8y-kaaQpDPkf55ox&2X8s z(6l*_FWd_RlWPl^bVP`uH=$gjtDKMbhkub8^Hu5*O4FYcieMvAPaU+h!3@HOgw4Vx zvIaOnRIr>TWWEUYN=U-@o0Ih?Ih-KD0elA_RNTc&%wum6F;YPL7Od1HNhNWE*^IA1 zM-vjQrLn5KLA%GKX#Zj;eRs=mEZWwIEH0goPm4Ep3E+C zg>?^Muw0Gp10ItpEJF-S8c6p|E@LPqt;Pe;PhN(J`UiY0Ro|bAj`~{)%>sqCUEYo$ zAy`pL@oW&If{E-OWv|pT_pzyOBuDP;>w|83Q-uQeeRwCQ7TBbGgCJBE{0hyHiz)Rj zRXxMRiGgMErR->VqI){i!58Gl`CmwWWuEv&X=R)iTEM@{7!H006w5t#H{r9VB0TJH ziF1@4@*Gzcw8$T_ObPTbnfwZvrrZ|V`TM|<8FyiCS3haEG6WO}b%3>Vhgd6mTUzdf zSHcaxTS5z;!qkZ{U`A$Xes?%o@OzfQuaO`ZJ0Xf2;`t4#xw6F$>TeqD+$h-H6}h4* z6HL`I>VhSq8$ynIvGgcZo@u0FH|@|{-Cf};mC0X@e<3v{7Z^kOWBgfN6lg>)gio}1 zatrmDqVajsKXIo`owbYL2;v;O4Ic@25q;$Ix)k(NR~lT<)#8U!y%LKPBqH?FU`Mh6 zLz5%KpO}xEr>z1nlhXx>2n)xl9n3poxP<5ne1d+j)Ks@w#fJ@+J_H3elbhjw{1Uee zA7?aCUgImWD*CDV{~T3aY)#(;4X{yatY6yJka)+oqfJ5#_Jw;zCBban3NAuUm!@eN zhz4DAZmV__dk||2dTMuauk<^m>UbqNOm7t)5JSv&$tv(T9WwX8?lOOLmm=NxDCHPe z-P=ui9sZp3$DV9jol%ab+=ImN?ho8DXQO6wUFU51+2zq2cN`k)dCTpKM9b4dQ`q6* z@#v3z1b@IEEsl1#6KdPD!Cv>Lr1!2@;?)2p$L8m9>pagvyuHBubL<~;Y)*Ul(zghC z!!L!!t}5{6xY8)aHy8eNZ9>litx-v(GDn3zpsl`ge7)?&lHlJ8Hv8AW(!Sq}!?}r@ z=bgcAO}_}pNO$I^ccG+smy36U_24>J6m#6s%p9Gw144Jt7E{9~;4qg(VEm_q7s10q zz|)#v;muNW*%IRN^b+O@zStHQBkTACm)k~JI%@Wq{k2}hx>QH80bX5PL3`Le_$cXx zZYuO(x_@Tu3CtuH=1-lQ@QY>JRfVw3WGzFBdjx zciT3QC*XN)6Eu*xAuLS5L>oC<92P%>J3&Svmio>$r-vnvB6HX`294;&e}XYYQ)wIC z$JmjW!lqIah1c;tIFjgR&LiFm0ounjAw@BoavNF07gUy7#f+hE30uiiD2{%X7_WOJ z^`H-;4Y(sYj!qCRQs4Qr_&zqBn%ZJH)dcAA@5XFHCf6EYDZV3?i0Qf!!aRK|p)^$o zW$NeWo!}0PyT^H^?|?G`r=&UV!76T2k*gLdDK+yp74&`$DDcddhTDsB&WvHwv&a{; z(Iud4zdw0F0Eh(+EokCy#eMeGmYYXPq4mLfQj)v6ir2;ub`Ct?YuleFCZkNK1% zN_d#r7wz|Tw4Abcg6owm{-FN=H`T{&2)FAj0zYKSfPthIfGP_yD z3r)Ixz^vksn`x_%Nfby2sQG9#Iac^vn`-Grjkhk+zcUS|r-7ElW?>cOfbXbE(l7E@ z^J<2n42EljC_Sl60IdUi#Wj96sN*RVy!H^AXfKY6hql{Vg`$xseJ9%MxGj6LXR*KB zV{E^jGvpm(3P4`S0GdVOtP=xYjk$p}Di$W_~fZY<{`KFU;w8VYVv@`3jY4+Gd z{A5=vlP#yf#ATP1?kKfvBfTZ0vaUqtY{n*;&O0o1RW4X}yA)^*jxyhb6Y#3SRcac@ z*WWd-BnJpj@wAq^wDXN6YM7*Gx{1n}Ju?uchp9|kEu>AlJ^i*&`_rn(R zluzl(+WO)d#t1%7TuxLlmQEn0iS!PpBi(~JYzU({*eiy>qM=XM$cU(VLOPkx&rtK$ zV#ExwF=>>~;00o_n%$unxBHrb`JOB}(K8oa4QtS-tkTft0}ld3?@;0I%m!T3+`Dj}e~I<7XD#PR8)#;HCs@+= z5S{VhyfsL1`Mw$OcwS4?LK)3GwV!~8LO(=I$u*@0Pb7IV>q3_|Mm9xuB=&L^MZ?^S zg-PyzO+n8Fu4kyNsg?bNP%cmayM-$OLw0VGGB$(%J6zH_)32B2W_}i{MNDAin0v-< zj$amj!ZJhzUYepaY9!@3KXI*<4Yo7NYoV8SIhU1P8kBeSg}oHOUG{Df*M*i#wOcfZ zCE*7u8agqP+*fl;IQb32#7yBR{#XbTkC;x_cetAvC}tQ=qaN|I z!6L(QsSN#<(Gy&&KJjl+xj4W4P+I`1;7ep@4Zt?SXNrY{!dZxB+;1v|mxu+zzoZ7` z5Syg#Y7Swe9`bJNyQvF3&lb@pnit|8v>jV7#ZzsK49PG9sZ-2n;y0f{9cH?d9XNx^ zJ;S(Spp)(ptco9k2lWkPJ-$$kqbA#S#61>Hs5!=J+6_;WFQtLhIPo{OMeK(4;EIzs zxyQs<@tUp%$is@lzp1DEMQWYOadh_j!~?+;SRr(aeeT%`%K8$xyzy^PlRz`^mH(X- z<=dN-=Sbu>M>58wK+}c0eg!>WP=jzIi z@Y#}1I_7{@%J+7OtkY6Gg8+GYQO|AZC@ zE&NBg{honhjH|M+%XvnqGPbsOJENa$^$q3+d3))-eGkQDt zNNqG8B?dRkr|&aXq9Nadw6e2xJ}^G+9oq>%1C#MrCKF*5^3=FtiuRqj5Pt|gnw{cL zm1n4irNG_#c;T5QTjds}%bj&I**H9!e~4L|a`-pI(oH}^`Ut;A|6c7!KZVZN5dIM{ zlz)r4q+`?sFjd!7DodSAE>3hdO(Rp`b&|17(U(F05+C3O?1PH=-4uSs1AeA1iwl!~ z!H!f7Go@R{;qjliZ}?6)k_w{IH`JM}v?BnIh;V@@bN}yuC)2OqHw{X7U;9bbccJb#}I*+?0 z9r0Xc)+j8e3zU(PLf849N)O?*zlK=CZseOQM*do45O+(7Mymrk;NH0DV&zB&Yklu| zOP%0mAcYrm^<6>fgJ&yx?rS93ob}|toV~cauKVKSFlj30x(P-(ccCRguP`CBo!K6~ z4`|0(wr3~_72%H%EzJV?kr_=bFn=QdW$TkENUQH?$tG#;eSEAuLKmxIc-Q%n)F<{H zl?*g0UHDD=1lEZwZ4K&9NLKnEAxU3aDoggXtkE?BCFz;MeDW+i0F#j&3!vU~P0)~{ zrK|c`!YGoJ4;gCkHatzPL<|FGiOni*x1V1^MWfM_UVNZ05H4u;ST^cDNslQnFKO$r z4Tx&Im#i*Lpex9!3HOth)AN~3qAJ&0JD1;wDI~>Wz;7Rb=`^vN` zT#4%){%qQ!6j+}ITbr*bo!G8n47Lkb6)&e2GX4C8MvZSAyTCpp>1^8 z1}X=)fb*_F{Ko(%>N88ok3DC(s@_c0$v2RpegB|ZYK}0%*+?EXl1O^wS0vhffWH?^ zW%qjssOJul54n$qLwptDiui#ZCZ$m$;bNSGcQpv~#NTm?;xmlH z)MvV~CV|)TG zb@6kAqr@Wd09|OEPwf>xQs3qCcpbJ3Q9LPys>-Js?uZhG3j?VFxL4EEr+~YTW^5~E z08e|zL*R6CWqgCh=#15U+TO zAtq}vs1qC~>Fvuv57$ogKJN?a;bLKM{8?d!eVB>%#;BT$M#kapZ{ULS0DsKgpSck_ z&o1_G*217-`R;JSyWV$bf-{SMk@bP^>b;Jx=A?-&vw+aou~B&LJSWU?_F_8+pL2ga zjJdVT3V!=0O920tC&vPnTq-k(r#i~ z(#IO*MTQIt_HL(Di#k@gU8SY7<(X07P6%v36$ z=kOoks{W+(f{KM3=tQ`lv~woI80!}zmMG9XhW{yzSGm$@>FqdDsifnfq2BOqA#N6^b$}Bx0v08nJr81w3&5txaLHT%OliK zHt=;Lr^PRs=iyt=Pi9qs0UL8kc*eI-x{|p=x*m$>dMZ6FslhngEYDLg%6*C1>aGX+ zWjeT3zTc=pWIlwBdK?)zk6wA!T9$h<#X7D`RNc2pn2`P$9t#kb^b8ljBX=SD&UF#K zR*cxW%Wc`(uab7TruEAs2x8nt2ejO-A_E?FOLdcGhjuZiurk~!=r(7LX+Ix zNsRIvObPU7VnW4`ls}D6&a7nFoVJ*~i z!y5Ec-H*f~_K$#w)RwzC+sd8YP2fy73&!|fnI>hwgeP5kbl0;FR`CvGF1ZWY*P*@O zr?;2<+;andP!z%E5t)tS`iRy521cfUnUR6X zymuRjMEZ%gP$^U=Z!`BU91S{TH-x)fgDi8MN7(lMA=cS$QZAu9;r_`OCY*LZ=TCTN z3nP?i*5ZM`V1-CAu-`jJxZ|D8_jS(~hlg!kpDe$uL{gHM2XD#^LP0($_mhrzWCFYQMxU#2dR@s()Fzuu`bMP-2tu=;gDj9-snBi$Wobl2a6CB#rpd4O-=ek z#!tF)BChIwW@`KL{S7wrM%`mBi)bR$C0mInHNEAj^lM8|oMdk4D9h{`-MJ-XW8o)R zj+;xSNE7uXQFPpC=>fLWWFxP^Y-|D=7{8wzsadPWYaZ~BXv?Q*MzWLRQ;-cS537-r z`PI67Gs5kbvAPAoO#!{X23M+U%{ro%a@7UWch+OP_+v~ zfkquKrc_YLl0bQ5M8w>gdq z1*(2)UuH+p)_I!qxg=|8Us(wc)|PK5hb(Vg27oIWXhZgJTS6en-t@OOozCxI>FG$4 zt~#qC(s2aL4HQVvlse)G59FFFDNI$B53;8fZTZpBO&xc3a%Sh>%ysX2BzQ2m&)Ltm zA@o7+6Zn_i@9!vNcw6(;0&`Hjs`vQgD}@dO2f;4h#RAOkjv5C_v4pRv9Asx1KC{Jj zLDN@#IhoPjV2|Lu^ogbvx;PGn5)|^y@|gChm$Lq1K3O0lL#_tx;=u8_zsp5yX9DVgEW$y zW7c8Ygss{Zd=AzbUeGRVF_-$pWEc)fFUYdIA7_D`9BLk{%||;m#rX~iWsIkY1kQr* z5#qJo#nJR~D@m1Qo>5hV@wz(fGEFP4kDAxT(w~ie<2#`<8i}#c70}fLWV}P(vK*o} zwW+R;h5d*xtd-8^lJUuGw4s)`j69s4BA&?kEZq-1L@hlQakIOUknGbVYb3*T+K()6 zy&C?ZClyx6ZipN%4TIc&Ku$(iFe5lyO!Tji&p2X|b^hAi*xU)?((EMuFL!BEuWSTj zoin)fNHJ3&rzfbayl-AB@CT0YuNT%T?YU|0u1Otz@$wc``xnZ)!A}Tp6&#*4OUuAG z<1}9bzGfsKoQtgF4|q(LHKBa9|JlsXa%;k!s{Zeo>nq^g575hOEnnB$)pRN?89wmG zftxB(R40;P&WXHcR))U88<9np8le~PWuPK(2iuxvWTl}AjtB5}u(qj+zdV~7F3(iX z+z1PkdJz@S-uN~$(rqw)CyHCT$L&EYh_7NWzJoPL#;F=$2E`fX!P&$Xu^QfkbCTEP zlG;PeQmPpHDbN{DmlDV=f{p5}@)cW}YpMDsSwB;FN?a0;;tAGq)O&Uid5tYa)=>2f!^LV; z3R8l(&BxU2&+f;8sxN8Z;xIi4_SDt1UBwjilbGbz^LKq`_{E+yIM_YGJjJgSvP11n zlLC{tytIGN-Ygkx@&7HKbl>8Sx}L)AnLC90N_}HF?`}bffJ69h2B3wc+DaVCpK&UC~>DtOy_s^00<#Z66*h`4pylwa# zrA|@{|7>}Ur=MxD(1eKMM#T3=Q8Xr=q+6JJqELo^Ahph*e~P{sXpCY9z`8M+tk=R4qq-8Cw7QN!o#;9Hp;v4Jb5y<1MQ%e z@)fZQ(m-q%JWIJz6{=9`YuG1Trq{?n$wFv}x3NdbXkkr!t{6k5NFTK(p(c6J+?IL* zW*9QtY}aYnW-5QMk6aATlBK~3@~SbJ^rMNouB{TtgMy+xWVuhiX7h-_5YzXO&LnJN z7SI;3triYaF?A*+4!~RS^Er|(Fq?JJ%mXqkztuH_ukbtEXsoyLmvOoGlh`3Ahu`U~ z$#nPMoSdK+DXN}BHW>R0+%J_9@B)$mcY#RYjKGHn z#etfV++BaAto`dj`|FV=3G{97EH?TeHs?aN^v^XdjFv+3n{EDoOa;$O~Jd1RNg`wKa zrHmAyjgXd;&ad42&~~A6WRb{3N(mj)yC!~|+Md1cFagoo!Q40SgCChbO*j`q(t1x( zur~Mzbn}maWgP>#EB@I+j7LU)!d*cn-%fUtf4OklQxeA7=QmFbx|l!dYdATl7~CE( zim5(Ay5%@8jrMnC^L+!^h(f_7842h$IDy+xgsdbgu@@^UpsKcO!n9?&M3hEi*#rsOiV8QqsNQPofq_9XcU`3J-iqtSSJr}Qa)FcXz< zob61k;?9yGxJUB>WEoDtX~Ym)3SmbT=;7!ze%6#g?`)Z+t1QjZ?c|EmEzxhPByi$i zZ1XjLge%ktYpgG*?)4?_YMi{Cjpm@&#b$v%Z_~J=s z_IZmYEsETfpM;$tHS!67KtD0Z-9QAYuhVjWTaVahNpYc>e9+sD!*aWcETK8D%y^JV9#T_q?szu*H7P< ztAt7XDBVoUCaQ6>dPE6ZBkTw}gF1q?Q!CMK!z=IxkDwEVaT3xuFlQKMumf;`-$nDV zF8;SVUwmb2=|4&15{iknbj4In_eph4)@QI8f6wNUehCp>&{nK<^Vf8is8B1#Gla_= zkMCi#XodeuZey>K$52zep+J!}nREKnrl$BX?g8EmOeQ)BlgI{WJJDApYL*s~G?my4 zT*njCFSZiV4DiGqej~9^D2x9q#bAfojzpj2+A0P$%8<+b#744u`eK`?gbaQzRUdlj zk<2NgtlSgtWFH~hbIuv(dJb_-LZ#)&?hpLx$Yj&q?6TI5LEcit<3+&HCb6q8haHm< zf_;Nk`Gv|mp_}tMjB(wEL3c5EXI=volT${L+)*me(*QLOd(=7og>WLQ&POIoZj>D383-5p?y)6uj>CHHzLs=P zwCzLIPT{wU7rXdk8QI%i{E$-!t2on5kH?HjiuOFPH43go(;dUbiDO zGG6Ky{7JWC59tbokwjS4WRHVWYi*Ka$gV0D^jMll_hU*?m{0>l@FIR*)#yA@%RZ2( zxr%p@#kqQf&UR8e#Z-}6$F9bVpgR2+6ruX6JOU3t8s?erlP>PGW{BmcHbq*k+hV?j z&*h$K{^C1oPYW}3lT=QxkhNn#NFZ*ROVTGyHR$m$72m?vCGPWX+94iD@Q5buEb&%s z3NT{B(O&YLJe?XUtkKj8Hsx=-7K$??1S8}HnfFZ zZZuVO4g-gssm$i|v8Ex)`J~&<=E6ef8tYMCSGI3tUh7$|k76_5M=&`whAo$6m5*g_ zx2PJ|24NtZ=(`zt0z1+y(z96LmPuHLdTU_*6%yO1k< znrr534C;E{u|r%BBOsQ`MFF>x!d2$Ixa)&xEIXz&&R*rl0pX zALX6{0>OoRnx|Zg8A=q$4tcpj_PJaQ&j7SD?G~uvr=^&23G!fnl4WmBZFrR_psu6x zc&S!Xb$6KBx@Gc5!*5e3{T0-NtZq)z4w8P5&!jA>7idf6F)PTO%o0Pi1=ly{j^IL0Gi;kE6QFR(mRo~vg zZ0NP-r>QPwG6P~8lZ;nq$C1y>h{{LXX_I_d+mWA4PZPX`SZk+*;#^QakC}?+a;o1z z4mkvUCa$r=DHb-w4;x2PKJFRa1R1D$>?u;q#n2~(?N}U4pxa7!^jBr0J{QfTuZy*` zOVD*)JTWvRUilVdZ*KTjy}o zY~L@=rObk})peG!3y^6yPW#3R5 z@rI$md=I~F*^ZqRNL>x{aI%$jpI*Q|0x#6{Yj0^m zs*6tp%T#XZAU(-Eizehv*k8y^EeAu0Gu$n_YUqqGC^IPC8SPi|yJrAp)f4i4E~d7V zL+jLaAAR!ni9-V(u2JB+xF`LaR6jDC8|j_OP6+l>eLCCNC99acIil28sc==PSaw+SWGPH>htF8jyAugMhX1B~2jhJ!x%R=< zpkDeEi#_M1wV|`GurRzy%yt#$+XY_PMk}K%aNJz}u=6dXd~eXbkP~bRRYr#3CGLoG z9l9C#mn$FH#OC_Gf`j>gOK052*fGxI!t}hu=$@~Ze8#!I*?r$Am=t<1IfE*V?b$C* z&3vF|bq*f}Uu?y|vQ%p8(O=u?igDg61Ek@^}N`_9{ zTOu~OrHTdn=q2cHEhbNi`NfGuu5}+ZAvv2^X??4!W1lBpb$<}%DH}{=hF?let0#VT z4M(@Uec003X{J$`jo~PFQK*Dj_MGQ6IuZEFNBsNv!qEXs*W6m*n(Eh2o?vqS$PO_k z@>-}9`oNcTEHI)l$Mp*=l3RJpNdK>bvtWq0HdTJ&!fjR1H-$|vL>xov9IZLdb{hOW|JR&aipTgO4C-c~l-HyWm zWsql}dOBDvmcczQzt^Uo@F(e`{1ZS6pQ7Fi)w6E!S5_;#yl9krs8r8k<}YMyha(<@+ZIA3`hC>-h_9{hVunB~sN|E^tM%yW3j^id9&TI>H#MbYt(quGA~3 zEVG)Ar-JB;VU?v!!y=^$-8^}V>7D2yeiZZBmeNuBB3x~7s4a~dw(hKx&*pA|W^@?k zacd+O)x`FOo~>*&Z3a>1usDhB%tts~TTgnebD8-vOSKV;h&~{j8iad)>Ab->8-(aV zNNB{tImQ+E>@JeG(Sr36w_byUf?4qP0yK^mYj7KI)=)0F8u`YS$8}5|gBiajR36GT z*}-6Pm=>beL!PUp_{m|?u(&PiMP{JjjV(fL8txbGu+uFh`&hg}R7BHaX=N0#SDq77 zqAljC!N-QD{;uj$Pd%Y+-a0g`pfS8rn63O>7$pQBv$(<>maJJ+Z2ReyAxyQ)OeUH`F z(ThoEoKIz=zlE}5>IJ!(&!A8d64*Q&fZlO9W`anx`8o)h&5Zwwrl{(=2=bx!J(f&Xab(-%5>gg$~CwSKgr81YvRCKgH z#SKvPkO7!-uc4~W|Ab`!PC1m<7+nj|VnT3*UOW6zf9ZLwCQq#ngX$vEEDz-BNZ;_A z`n{n&I7Z`%HDQLCr44c(Z9~=RZXG7jdq5&rRjxqKP-if4LIQW$x;AE#)Hco~RiItc zc;YL1P8vXjjS)5SiZq4G7t3Ilc(ti2ct;naOk;^9mVA~pjqIhIX?KC^4-wuY zKV=`v<}yIk!sQ~6N77Fn;jd|lBCzbH3w zS5=n<{siBJ3N3S;Io9EUtdvbTwY1(jjm5OAi?ZUIpw{-+5jQxdSr6s3QdUJqNlU`b zU|w*K$oq#wtAD7FFoi}te1D1G{H@ViN1~FPyF!l3I;q5D<>D-$mweuz!P|lcerDbU zFx^=;`3LWEFx$P%@+J72(l!`x?d>VXxp8MP)md5|?M;;jOsS$A&Fd_rM0I6|@{xX{ zP;ooZgxD467~#?x!wvpF(^mDf>4|cHnIm2&TDB|CxRsGO%X?;Mfq%Q}c@MWvd`TVA zQ}F($DKjhS1!dRRSRY!(e3LGc-{Fq0sOA`Nr%a1$p(hxcS(YvvjE2HSM`dcO_=dt}^huGh(RoOI!Fg};Jap`)u z*ltoa#sZeGal&Al6c%$|B`392e#*|4x0@7nnyIeS>^}2g%!}NncENv%aaPDYkiHns zC{3y6yh>T+xx{7dD5>dRsPC}L;E9h$0%oUfXV-)KgNH0h!Bn&$`_4jaFm>8-rrUU|f2R|dMcGVUxJ2{P( zdYYyyJ?XsU^>IV+EZqrp=N3!TnDts4(^B+^nFj{YtJGem4ax&LSNg)HqVsWM^hD!K zB-CGGJxTWinOuxg&v+W0yPKOKb5i6IPk={3TFgy!We3@`4l%Td9Cq?st^<5 zWu^^0-e8J8lU|?>Bu;|PT#~5Mo2AQ?U+G5IQfiW$AZtuUFPL<(4bdHh*k7d$RCgsr zO7LjxWwZuoe-}768b^DW9=3)5b;cigRD0-Wls2eyPxqq*VPaHjXA^4i@>dy@GE zRB;T{jzpeFz}He;7Fpb`xu=%xgKG(zI{kn=F-*Wi*^N_ydyI0&H`#i@y#$ueFVRHD zzwnN03|QdaBOlD_j54zSLX+}32-h48{2WP;EBNYbMZRjktq0!)5?n3K~ z&>Ip!nycac#l;qIm8 zId6b}!|jzh*tM}A?|ZAayUI-xcQfhIKC%S;K)bX=;~LnXyeM|2@?eA>3>G!)4VTlp zo=*L3duO=EzvS8rcmALEaC^i?w2o(pKCm<=+w+(oBoomR)L~>{54BI2PR)dU$Ti?T zcbaF6zgsB0yY0+5J1vMSBZ%}mP;4}!>xOUAf`%vcM@%r8BC1EF>}awsfhnn|+3)4qiLm%p8`5-g4N zYR0%%ay8=u*-Z_Qv&g=b9_#4i17T%}+&ZD6RP)Zg{Ydeu2at`+z( z`Q7A(wuhcQ|L1_^SN^;3R-sSeJSk{hW;Of0z(@&U&hwFbo47i#JW29t(nv=K;0x7+ ziD41^96GD|-0P(aUP`Xx-2ltFO=xrArp&twQGV2=HI5!thDRhlCluvhhnmRGvzrOc zT%G>k&yrh3g79I!&%VZ83V71@iRC?)^f}o!ImugD=o~mCttwb%SKL*UqR_uW6=wr! z)Z`&h3e1z4yq@TEsE)DC%Vo-`{ zq>x69m&%dV_=BWF@zC!e%?wj~*st_0HU%AIqJou4k?s&{gj?9VamVx;td2P>E#k8E zZE;m`9$W-m)GVAMctJDvnC&vv7?s9M5J@eUM^U5od&UatQo1o}O4NYG)Mr^T3^cdp zDk-1Hy14sQ1LczK(E@6@R6?%QrsB891=CH7n$kUka$*E)tSWIq1WBw*@OD@jJ`bv9^}_$t|D+q9KIq((kFa_0p(ccm@{B`P-n*Y!-nf6ZJ@a>g z!+n#rzdTLF+QH_io~NZ4hyY0sjJ68l79tb5C;d9{C(Al_J`ckq!8`X(*vChSY0=Z_ zhUoY3lB*Az6s`y>N1jU+yj9f^feU~OXlR7{wfHpagi;c&hnBheT1N(#!0ox|_$T%b z9`-YKQ(hh9_th5;IJbd)fnp>$uZbhP4U~&{DbS%?9K55 zzrVRkg}dq@*D|G2=$hO|zQJ5k7RL8ddy!Q^PviG+h%ptlF#aQyCUWGXWP7-Qttf{u z2Re}XB2d)ML+_QWt-^6aOcQFr@I`mYjA4Q?H%n)>$T#A-aH~Fs2MdbpS z-L5+W(a@L^0FZU9X>l>~cCK{tE8|RY7+0Wq*=^ty5kM7~Rk$B;MsQPmm1Q_H-V&Dt zCR5d6PdXj{Z5QT?=EEW6N_$7{D*u>>7k3$VXzi&?xsmCRm}Pj5^UHfcB}CMZSPH*V zh;PcA!t78KeC2A(kH~+7bT(0*VLX{s$cEIf#&rEX=~4YuY1|K46B;LP33ky>I_s(z zJr(uLs8#Omc_y8UR@OH8H;7cuGBB!uM9%OB`IfJxy)V9ZFFNnS$==_=U*S;-Q7CEy zgCoo_VMZ#I^UORWR15WWl~<>^-KfadQfv|!s9tlP6(!$hz3Swhxc4~(%%0XtOAIfS zUWEm1Qtoca?{zDGIooS*+?RxZ{NIu{xf&|VJ^xA>o;CWLz-loXEF~9s*R>fNzQ?!r zaQq?W|$iMOUiSJ6ucrC(v8A`)zQ7 zM~AmvYd~6-N4@8C$+sL$_3NH(>hRKLDlJK-XCNP3F<)wPK^iby%MpN&W0f7E&L z7FU3J5MwZNTMWh$pHOw<9?TIu118gNxFcXpuE97!19qlzhPf>Kf#0Axv=Pl`+6fn|KZeRs}eezPZ_)%H%eh8=sPzXP(}5KULA z2KQKQW&CMt8mI#rc@9XXXlMD!w3*^kk00y_tX3O`Q^i~1MX-&#m=8`KjJePE@`B)E zP%h-fyTW+n^L9laf(Bc9eqF6yt_ALM2H1$@{roE1CQo4njt8w|u zw4%@%aK^b4_6(j@S_a?4wm9=&F-t`8zTMK|EDyTtye|;`(rB(zK*iDJQup9Q$mRBu zX1ab+{&Y{Y_0JirNO)(E7(JkPyg#Fa$UgHa=@f;XeN-(xtJM;!(U_N|i_QB@G4@xc zdU6#mU7AWRmP_bYa$(%`l$n$ts)ljaVcb{5vjBV|uZk9e1e-WkNn|{r1NDQ_klrXU z^mVNzn+%7M{klvlYZT+*sQW#AAHkq)AQLqbQP)D_5f3!Z!M?TFKZ&`6=!S z`b>=yY8j6xUAc8&B-23YVJa)lr}3+qY+#QwCg?M{npz{GKwQGUmB!Lzq<-vic$A?W zgHa=2Cw_UZl`qaK63ciND&F9GFeI2|S(C9;Np+Q1hIkeUBO~*{Ki<};z>8Zd*FHj4&qqym;98M4sJ5?ra5Gdu}OX zvKnZe!t+3plhmKPTktEr`=lL#IO*p=Z7{%-Dh_k5HkX1LGaCh|UAUuZ5&N>4(l6{Q zxPpE!ucs!Vn{;_pjX>HMdZ7HA{8Q>pEEk%Y-sq)`TeVuuU&(ikYmn8LFO@SsgPXa{ z!V}{{-p@UBd0$l7c_@`xZ5>OshDTTn@DdxWs;?$>l+)d-P^$l!`F&&|JRCW%9rXVpC*(|r zRo&e|v9FB$hYzC9fg;PR%o9@MoNm0=@m|aDjDOLV z+PczrbSYtip)%&WgCLeID=<`AN=K>!=JVd8M{)Ip<;F6oItR2-%uw`-EwAjNvcw9p zt%cSNeENIi-*%VrDx5&4NV{>@VP@63pD{@cZeQKX5w#5f|c2L z7+?-+knAC+kVj>LeQ$n7_tb{aE!DmBN8M)XBkmwV{97gm@2@@y)!78~GqVWih!mK_ z48hF9G`%{0M=oPg;UQSsztPs;v)x)hH%5Q#SuOk;{uSpoN#=ijhXg)*Dc?OfLA&g( zj6IwEt*x9*l<&NILDS%03VvGRoFxJ=S`NFoPYI(vJ%v-@e&&+EOg+Ilhac~nisriK z=q1iN^4IJq!YEHS`G{|r{ENF-tmRvT-+h&UJMd6kns*p4W!k{!;lqlK`GJkt6Ekv3 zXa2hLjM5?e5S9rJS5E|tx*TnRnmV0QhWDmaIndIa=1@V3yMD^uz$1Bkc%9fjTvKd^ z@0d%W57IBLV(pywXY&PDiM@ZcqVP8Vh_u|l2K?zcrtJ0Az}(3u^}Y9_^g>1gOqF2eXU^ai-RT-$FgmSFw+UU+FXI4eV+Cj`>wT#O;7r*p8SZN`+nNcw3Nb zrN+ihLBoxHe!MZ+HjC^64a_|454Nhc41Kxn6S@_OP{+lg%wD`hzJc8kKWMkfjY$$y ztk)y#=EFn`cuPEkec5w@iEAL|GjpXf#u9Le9;m)0Hw&Yg2y`09f*15~^qDYY9%-*U zmV#1qV~R2pyD9_pUR!7Sv$Y;G$2Y?^)a4fb!ycv!kqjw0WKM8kFc}vt((&(+cC3Y}&2&ZZFyO48H%jBk1aTDWKug(jN`IW&?_gg5NcF+~ z#)c`0)HtarlWM(+v+5h<@A4kzs$wubfx8-XfOD8CsG%{5f5xp&T12^oP86pQu};{A z1gI&6$$io-3?(;6!*RZ-QZ->loZHfvGeyE`k*ljJhI^rK$gRX-o~I<*A9ZmzRr-3i z@;!VDF@wDm-3hjrGJ?fw8~;$%5-w45{bkh^URLj)U(0R^N5LS>`+lFvg5u1j(4Ko- z9pag-Xa#olocEZJnVV^ref1Q3sEX1H`(cZmyY$Ohf6HB?B4j+X;hMZO^x+dymT{)Mj_+^Yf1jJ=2q66g88 zqDJx`R2MLZd!!Z6q$Qb?VO4Ullx^&#{7ron!`vs?W3)>e+e|A@Zx9DDujD)IabX3M z0b3F#`CEhAwlvIW?lc_L$8(QRP17cMNkbKEHdI%>G4lkTyN>Vpj`DJj2VI!%N_R4( zq|y)N*Th6IjX5BHBqGYM^k`{LTs&Wyn2ky?y>PZ{)lGCYy+$0Zn3>C31?-F3VVJ02 z|9$}G$Q|+`=5Ha7D29uun+g-V!Frdj1ybWXVP{#Y79z3njH{>7+!vfHZ%lqo3euvu z1JX!iNBEvuqMRXT2(b+c%*&}g3F`j*>nzp%+w8mLna!gjuE!ta@Xp(ePK zmm2J)#{?Q$Gn|LTO3r5DUguTR(d|@s1O@46?kWCm#0Vz2K4Gs~p;ZYTm4IisG%zp& zJd1SJzVmN{r#yS)18zd->R6>7^1YMZ1xKQlfxXst?qzBZe-lg3V76cmUxn_-OYn<3 z0p7&(M|<2ksE4~|SNyw`>n>4hlRX<0``?Pm5e@w0>7+05e6!Tbn+QvC2eca+ko86G z=eV|FR^E9I=zlven(qf=)MJ7B`V@z(*+Sb;sqi1lA;$&2t)q-qI#dHJaIF+~d&Y`4 zJ+c&a?-Of?2k0L94yui%I|VGgaAs?2NGcncdg2JWv{nnx1Bt{~;XUz(I+`_@e_>0o zi+R6LfjMQHWZDjWY-gpFi345ee!>dcB~Qm}#%j)hrV>}dCFU-=#O#7o@%?g-D{T9J z_p^&EM2}2MAx$8#mv(@3s;jt;I1m5eJ}C#t%eDaB6n@X_RP&ivij95`56~-ZJGiT` zv1t};&fb-NCV#SiCYvY{^+N2#grw`n0epRCAMSG0!JPmT>=pkMyInLb!&FBe!F|!! z7#pFcWEtfiKC{Mh`{hEz6KM{0Qns*VL25%;TO4=Q^8vV~rt|wEBl*$Zo8phYf%1y* z6fiXJ6d>F-)Y!MrS{uKiM>z_mkD0^xAEK|-yList9`0;?5cwW<&aNXT_*aM~Pl;BN z^GQp0e75fNPmoS{@7PU__bM6r&Zas}V0X}J;e6-?-#WAyW=~lqFAIIueo;uk>F?^k#ERx0@c>@#-n)OQqYgS}lxz>i^4%yo3MRtq-L{Jv_+1=k?3 zHRr0jc|tqh;5NcR%Grb|Dvi(M3@!(W%xnz$6%KyNc2WPh|$%w|x+ zlvNSV&crU0g-WR4z|wK~{z%Fxz~cwd*J8S^9J4f%1s>ADVcH2mk;8 literal 0 HcmV?d00001 diff --git a/tests/noise-reduction.e2e.ts b/tests/noise-reduction.e2e.ts new file mode 100644 index 00000000..d63673a7 --- /dev/null +++ b/tests/noise-reduction.e2e.ts @@ -0,0 +1,57 @@ +import { expect, test } from "./fixtures"; +import { readImage } from "./images"; + +/** Points across the fixture, flat but for its noise: a 96 × 128 image once oriented. */ +const points = Array.from( + { length: 48 }, + (_, i) => [12 + (i % 6) * 14, 12 + Math.floor(i / 6) * 14] as const, +); + +/** Each channel's mean and deviation over the sampled points. */ +function measure(samples: number[][]) { + return [0, 1, 2].map((channel) => { + const values = samples.map((sample) => sample[channel]); + const mean = values.reduce((sum, value) => sum + value, 0) / values.length; + const variance = + values.reduce((sum, value) => sum + (value - mean) ** 2, 0) / + values.length; + return { mean, deviation: Math.sqrt(variance) }; + }); +} + +test("Noise reduction cleans a noisy RAW before demosaicing and fades back", async ({ + page, +}) => { + await page.goto("/"); + await page.waitForFunction(() => window.openlight); + await page.evaluate( + (url) => window.openlight.loadUrl(url), + "/tests/fixtures/raw/noisy-bayer.dng", + ); + await expect( + page.getByRole("slider", { name: "Noise reduction" }), + ).toBeVisible(); + // Off its As Shot balance, a RAW develops into its own texture, from samples it writes back. + await page.evaluate(() => + window.openlight.setWhiteBalance({ temperature: 4000, tint: 20 }), + ); + const noisy = await readImage(page, undefined, points); + await page.evaluate(() => window.openlight.setNoiseReduction(100)); + const reduced = await readImage(page, undefined, points); + const before = measure(noisy.samples ?? []); + const after = measure(reduced.samples ?? []); + for (const [channel, { mean, deviation }] of after.entries()) { + expect(deviation, `Channel ${channel}`).toBeLessThan( + before[channel].deviation / 3, + ); + expect( + Math.abs(mean - before[channel].mean), + `Channel ${channel}`, + ).toBeLessThan(4); + } + // The reduced samples stay, so fading needs no second reduction, and 0 develops the decoded ones. + await page.evaluate(() => window.openlight.setNoiseReduction(0)); + expect((await readImage(page, undefined, points)).samples).toEqual( + noisy.samples, + ); +}); diff --git a/tests/noise-reduction.test.ts b/tests/noise-reduction.test.ts new file mode 100644 index 00000000..afe9c59b --- /dev/null +++ b/tests/noise-reduction.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from "bun:test"; +import { fitNoise } from "@/features/noise-reduction/noise"; + +test("noise fits photon and read noise from the flattest blocks, past texture", () => { + // A seeded generator, so the blocks are the same every run. + let seed = 7; + const random = () => { + seed = (seed * 1664525 + 1013904223) >>> 0; + return seed / 2 ** 32; + }; + const normal = () => + Math.sqrt(-2 * Math.log(1 - random())) * Math.cos(2 * Math.PI * random()); + const means: number[] = []; + const variances: number[] = []; + for (let i = 0; i < 20000; i++) { + const mean = random() ** 2 * 4000; + // Each block averages 64 squared details: chi-squared with 64 degrees of freedom around the noise. + let sum = 0; + for (let k = 0; k < 64; k++) sum += normal() ** 2; + const noise = (32 * mean + 1500) * (sum / 64); + // A third of the blocks hold texture as well as noise. + const texture = i % 3 === 0 ? (1 + 10 * random()) * noise : 0; + means.push(mean); + variances.push(noise + texture); + } + // Textured blocks lift each bin's quietest quarter a little, never to their own level. + const { gain, floor } = fitNoise(means, variances); + expect(Math.abs(gain / 32 - 1)).toBeLessThan(0.1); + expect(Math.abs(floor / 1500 - 1)).toBeLessThan(0.1); +}); From 09701ec399fb5eaa1c01c01110d1882c4f6f0e5c Mon Sep 17 00:00:00 2001 From: Rodrigo Date: Mon, 5 Oct 2026 14:41:24 +0200 Subject: [PATCH 2/4] Reduce noise by luminance and color strength, and in non-RAW images Noise reduction now takes luminance and color strengths instead of an opacity. A photo's reduction runs once at three strengths, and each renderer composes them at the chosen ones in one pass. Non-RAW images reduce in their encoded color. The noise fit follows the quietest blocks and leaves out texture, hot pixels are left alone, and patches mirror past the edges, so detailed photos keep their detail. --- API.md | 4 +- ARCHITECTURE.md | 2 +- CONTEXT.md | 2 +- src/app/commands.ts | 8 +- src/app/controls.ts | 6 +- src/app/editor/adjust/index.tsx | 5 +- src/app/editor/renderer.ts | 8 +- src/core/document/scene.ts | 9 +- src/core/image/decode/raw-blend.wgsl | 23 +- src/core/image/decode/raw.ts | 60 +--- src/core/image/index.ts | 54 +++- src/core/renderer/index.ts | 94 ++++-- src/features/noise-reduction/algorithm.md | 23 +- .../noise-reduction/compose-image.wgsl | 31 ++ src/features/noise-reduction/compose.wgsl | 38 +++ src/features/noise-reduction/controls.tsx | 75 +++-- src/features/noise-reduction/denoise.ts | 296 ------------------ src/features/noise-reduction/edits.ts | 32 +- .../noise-reduction/finish-color.wgsl | 17 + .../noise-reduction/finish-light.wgsl | 10 + src/features/noise-reduction/finish.wgsl | 18 +- src/features/noise-reduction/fuse.wgsl | 74 +++-- src/features/noise-reduction/image.ts | 139 ++++++++ src/features/noise-reduction/model.ts | 57 +++- src/features/noise-reduction/mosaic.ts | 152 +++++++++ src/features/noise-reduction/noise.ts | 29 +- src/features/noise-reduction/noise.wgsl | 21 +- src/features/noise-reduction/opponent.wgsl | 28 ++ .../noise-reduction/prepare-image.wgsl | 15 + src/features/noise-reduction/prepare.wgsl | 53 +--- src/features/noise-reduction/pyramid.ts | 262 ++++++++++++++++ src/features/noise-reduction/reduce.ts | 23 ++ src/features/noise-reduction/shrink.wgsl | 18 +- tests/fixtures/noisy.png | Bin 0 -> 13577 bytes tests/noise-reduction.e2e.ts | 101 ++++-- tests/noise-reduction.test.ts | 30 +- 36 files changed, 1204 insertions(+), 613 deletions(-) create mode 100644 src/features/noise-reduction/compose-image.wgsl create mode 100644 src/features/noise-reduction/compose.wgsl delete mode 100644 src/features/noise-reduction/denoise.ts create mode 100644 src/features/noise-reduction/finish-color.wgsl create mode 100644 src/features/noise-reduction/finish-light.wgsl create mode 100644 src/features/noise-reduction/image.ts create mode 100644 src/features/noise-reduction/mosaic.ts create mode 100644 src/features/noise-reduction/opponent.wgsl create mode 100644 src/features/noise-reduction/prepare-image.wgsl create mode 100644 src/features/noise-reduction/pyramid.ts create mode 100644 src/features/noise-reduction/reduce.ts create mode 100644 tests/fixtures/noisy.png diff --git a/API.md b/API.md index 3ed945b8..110904ad 100644 --- a/API.md +++ b/API.md @@ -72,7 +72,7 @@ Create an empty Paint layer with `addLayer("paint")`. `addPaintStroke(id, stroke For RAW sources, `setWhiteBalance({ temperature, tint })` sets absolute Kelvin and DNG tint, preserving unspecified values. Temperature accepts 2000–25000 K and tint accepts -150–150, extending either range to include the file's As Shot value. `setWhiteBalance()` restores that value (the decoder's daylight fallback if camera multipliers are unavailable). Non-RAW sources and invalid values throw. Incremental temperature/tint remain separate RGB adjustments. -For RAW sources with a 2 × 2 Bayer mosaic, `setNoiseReduction(amount)` reduces noise on the mosaic before demosaicing, from 0, the samples as decoded, to 100, fully reduced. A photo's first reduction runs once on the GPU and takes a moment, while the editor keeps showing the photo as it was; later amounts, white balance, and every other edit reuse the reduced samples, so they apply at once. The original comparison shows the samples as decoded. Other sources and invalid values throw. +`setNoiseReduction({ luminance, color })` reduces the photo's noise before its adjustments, keeping the strength it omits: a RAW photo's on its Bayer mosaic before demosaicing, any other image's in its encoded color. Each strength goes from 0, none, to 100; near 50 the result comes closest to the clean photo, and higher smooths further. Luminance reduces noise in light, color in color, so color alone leaves a monochrome grain. A photo's first reduction runs once on the GPU, at three strengths, and takes a moment while the editor keeps showing the photo as it was; later strengths, white balance, and every other edit reuse it, so they apply at once. The original comparison shows the photo as decoded. RAW files without a 2 × 2 mosaic, such as Fujifilm X-Trans, and invalid values throw. `autoWhiteBalance()` neutralizes the photo's color cast as one edit and resolves once it is made. The GPU measures the cast of the source before any edit, as its edges' average color, so calling it again gives the same balance. A RAW photo's white balance moves from As Shot; any other photo's incremental temperature and tint are replaced. When another photo opens first, it resolves without an edit. @@ -220,7 +220,7 @@ editor.run({ | `set-adjustments` | adjustments, `layerId?` | Like `setAdjustments`. | | `set-tone-curve` | `points?`, `layerId?` | Like `setToneCurve`. | | `set-white-balance` | `temperature?`, `tint?` | Like `setWhiteBalance` with a change. | -| `set-noise-reduction` | `amount` | Like `setNoiseReduction`. | +| `set-noise-reduction` | `luminance?`, `color?` | Like `setNoiseReduction`. | | `set-color-mixer` | `color`, `hue?`, `saturation?`, `luminance?`, `layerId?` | Like `setColorMixer`. | | `set-details` | `clarity?`, `sharpening?`, `sharpenRadius?`, `layerId?` | Like `setDetails`. | | `set-vignette` | `intensity?`, `softness?`, `layerId?` | Like `setVignette`. | diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e43acf33..b7f54f15 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -95,4 +95,4 @@ Dependencies point downward. Features do not import each other; `app/` connects - Renders reduce the source by the largest whole factor that still gives every device pixel a texel, a proxy, in gestures or not, so the photo's own size renders once the display shows more than half a device pixel per source pixel; the main canvas writes that density into the preview state, and the provider renders again when it changes. A proxy that lacks a Remove patch's field synthesizes it apart, outside a gesture: the layers below the patch render in full for the field alone, and the proxy composes again with it, so the display never shows a full render for it. The export view renders its own full snapshot, which its preview and the saved file both encode. Every render image carries `scale`, its source pixels per texel; shaders that take document coordinates or radii apply it. - The scene's frame maps output to source in one homogeneous transform, `frameTransform` in `core/image/frame.ts`: crop, rotation, flips, and scale, then the perspective correction about the source's center. Layers, paint, masks, and patches stay in source pixels, so correcting perspective leaves them in place; `transformImage` resamples the composite once, the display draws the comparison and the mask overlay from the source through the same transform, and a frame that changes nothing adds no pass. The proxy follows the frame's strongest magnification, perspective's included. `lib/projective.ts` holds the plane projective geometry the frame, the Crop tool's coverage, and the canvas mapping share. - TIFF and camera RAW decode through `raw-webgpu`; OpenLight adapts its resources to document ownership. A RAW source keeps its as-shot development, which a renderer shows as long as that balance is selected and noise reduction is off, and the original comparison always shows; another balance, or noise reduction, develops into the renderer's own texture. -- Noise reduction works on a RAW photo's 2 × 2 mosaic before demosaicing, as the image layer's amount. `features/noise-reduction` reduces the mosaic's noise on the GPU, once per source, the first time a renderer or control asks; the source keeps the reduced samples until it closes and shares them with every renderer. The package develops only its own sensor texture, so `core/image/decode/raw.ts` moves the decoded samples to a copy the first time and, right before each development, writes the blend of decoded and reduced samples by the amount into that texture, in the same task, so no other renderer's write falls between. Changing the amount develops again, as a white balance does, without reducing noise again; a newer request need not wait for a first reduction. `app/editor/renderer.ts` gives the renderer the feature's reduction, so the core imports no feature. See [the algorithm](src/features/noise-reduction/algorithm.md). +- Noise reduction works before the image layer's adjustments, on a RAW photo's 2 × 2 mosaic before demosaicing or on any other image's encoded color, at the layer's luminance and color strengths. `features/noise-reduction` reduces the noise on the GPU at three strengths, once per source, the first time a renderer or control asks; the image source keeps the reduction until it closes and shares it with every renderer, and each renderer composes it at the layer's strengths in one pass into a texture of its own. A RAW photo develops the composed samples: the package develops only its own sensor texture, so `core/image/decode/raw.ts` moves the decoded samples to a copy the first time and, right before each development, writes the samples that development takes into that texture, in the same task, so no other renderer's write falls between. Any other image's composition takes the composed image in place of the source. Changing a strength composes and develops again, as a white balance does, without reducing noise again; a newer request need not wait for a first reduction. `app/editor/renderer.ts` gives the renderer the feature's reduction, so the core imports no feature. See [the algorithm](src/features/noise-reduction/algorithm.md). diff --git a/CONTEXT.md b/CONTEXT.md index 46b26812..71fabad8 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -10,7 +10,7 @@ OpenLight edits documents containing image content and adjustments. | **Stroke** | One brush drag: paint or erase, size, feather, flow, and points with pressure in source pixels; a Paint layer's strokes also carry a color. Strokes are content; the renderer rasterizes them into a cached texture at half the source's resolution, coverage for a mask and premultiplied color for a Paint layer, and the latest stroke into a shared stroke buffer until the next one starts. A Paint layer's or brush mask's strokes settle into pixels every 100 strokes, which it then paints over; undo still reaches every stroke. A Healing patch stores ordered hard paint/erase strokes, rasterized over only the tiles its paint reaches, and feathers its accumulated shape separately. | | **Range** | A mask that selects pixels of the image below its mask group by lightness, a luminance range, or by color, a color range, rather than by position. A new color range has no coverage until it takes a color from a click on the photo. | | **Healing patch** | One painted, non-destructive repair in a Healing layer. Its Heal mode copies a nearby donor, found automatically or set by hand, with a bounded boundary color correction. Clone mode copies the donor without color correction. Remove mode synthesizes many local donor patches from surrounding texture and has no donor offset; it keeps where each pixel copies from as its field, so the layers below change only the copied colors, and a stroke added or erased synthesizes its whole new shape. Patches replay in order and each sees the result of the preceding patches. Each gesture keeps its recorded size and separate path; Shift adds and Alt/Option subtracts on the selected patch. Feather and opacity remain editable on its accumulated shape. | -| **Noise reduction** | A RAW photo's setting, from 0 to 100, that removes noise from its color filter mosaic before demosaicing. The photo's first reduction takes a moment; fading it or editing anything else afterwards applies at once, and the original comparison shows the photo as decoded. Only photos with a 2 × 2 Bayer mosaic have it. | +| **Noise reduction** | The image layer's luminance and color strengths, each 0 to 100, that remove noise before its adjustments: from a RAW photo's color filter mosaic before demosaicing, from any other image's color. The photo's first reduction takes a moment; changing a strength or editing anything else afterwards applies at once, and the original comparison shows the photo as decoded. RAW photos without a 2 × 2 Bayer mosaic have none. | | **Perspective correction** | Part of the frame: Vertical and Horizontal keystone that makes lines converging in the photo, such as a building's edges, parallel, before the crop, rotation, and flips. Layers keep their source pixels and follow it; tools draw and place them as it maps them. | | **Image source** | An image used as input to the document's composition. It is content within a document. | | **LUT** | A 3D color lookup table, such as a creative grade, read from a `.cube` file. A LUT layer holds one and applies it to the image below, at the layer's opacity. | diff --git a/src/app/commands.ts b/src/app/commands.ts index 0aad7415..d9232426 100644 --- a/src/app/commands.ts +++ b/src/app/commands.ts @@ -108,10 +108,10 @@ export const commands = { }, ), "set-noise-reduction": command( - "Reduces a RAW photo's noise before demosaicing: amount from 0, as decoded, to 100, fully reduced. The first reduction of a photo takes a moment; later amounts apply at once. Photos without a Bayer mosaic, such as JPEGs, have no noise reduction.", - z.strictObject({ amount: noiseReductionSchema }), - (document, { amount }) => { - setNoiseReduction(document, amount); + "Reduces the photo's noise before its adjustments, a RAW photo's before demosaicing: luminance and color from 0, none, to 100, keeping omitted values. 50 removes the noise the photo measures; more smooths further. The first reduction of a photo takes a moment; later strengths apply at once. RAW files without a 2 × 2 mosaic, such as Fujifilm X-Trans, have none.", + change(noiseReductionSchema), + (document, change) => { + setNoiseReduction(document, change); return { layerId: image(document).id }; }, ), diff --git a/src/app/controls.ts b/src/app/controls.ts index ca3b1654..0897d3c0 100644 --- a/src/app/controls.ts +++ b/src/app/controls.ts @@ -28,7 +28,7 @@ import type { ToneCurve, Vignette, } from "@/core/document"; -import type { WhiteBalance } from "@/core/image"; +import type { NoiseReduction, WhiteBalance } from "@/core/image"; import { decode } from "@/core/image/decode"; import type { ImageFrame, Point } from "@/core/image/frame"; import { setAdjustments, setExposure } from "@/features/adjustments/edits"; @@ -124,8 +124,8 @@ export function createControls( setWhiteBalance: (change?: Partial) => setWhiteBalance(workspace.getDocument(), change), autoWhiteBalance: () => autoWhiteBalance(workspace.getDocument(), gpu), - setNoiseReduction: (amount: number) => - setNoiseReduction(workspace.getDocument(), amount), + setNoiseReduction: (change: Partial) => + setNoiseReduction(workspace.getDocument(), change), setToneCurve: (curve?: ToneCurve, id?: string) => setToneCurve(workspace.getDocument(), curve, id), setColorMixer(color: MixerColor, change: MixerChange, id?: string) { diff --git a/src/app/editor/adjust/index.tsx b/src/app/editor/adjust/index.tsx index ee1362a0..2698f022 100644 --- a/src/app/editor/adjust/index.tsx +++ b/src/app/editor/adjust/index.tsx @@ -318,10 +318,7 @@ function AdjustmentDials({ {action} } - parameters={[ - ...adjustments(tone), - ...(noiseReduction ? [noiseReduction.parameter] : []), - ]} + parameters={[...adjustments(tone), ...(noiseReduction?.parameters ?? [])]} /> ); } diff --git a/src/app/editor/renderer.ts b/src/app/editor/renderer.ts index a91e29b8..8dd43cca 100644 --- a/src/app/editor/renderer.ts +++ b/src/app/editor/renderer.ts @@ -24,7 +24,7 @@ import { grain } from "@/features/grain/pass"; import { heal, retainHealPatches } from "@/features/heal/pass"; import { histogramGrid } from "@/features/histogram/histogram"; import { lut } from "@/features/lut/pass"; -import { denoiseMosaic } from "@/features/noise-reduction/denoise"; +import { reduceNoise } from "@/features/noise-reduction/reduce"; import { paint } from "@/features/paint/pass"; import { toneCurves } from "@/features/tone-curves/pass"; import { vignette } from "@/features/vignette/pass"; @@ -182,8 +182,8 @@ function composeLayers( /** * Pure layer composition shares the same graph for preview, crop, and export. - * The image layer develops the photo first, a RAW one from its noise-reduced mosaic as it asks; layers - * above it process that floating-point result. + * The image layer develops the photo first, after its noise reduction; layers above it process that + * floating-point result. * Display and export encode only after composition, so later layers can recover HDR headroom. */ export function createEditorRenderer( @@ -224,7 +224,7 @@ export function createEditorRenderer( }, { ...options, - denoise: (mosaic, signal) => denoiseMosaic(gpu, mosaic, signal), + reduceNoise: (source, signal) => reduceNoise(gpu, source, signal), }, ); } diff --git a/src/core/document/scene.ts b/src/core/document/scene.ts index bce902f9..9fdeaf59 100644 --- a/src/core/document/scene.ts +++ b/src/core/document/scene.ts @@ -1,4 +1,4 @@ -import type { WhiteBalance } from "@/core/image"; +import type { NoiseReduction, WhiteBalance } from "@/core/image"; import type { ImageFrame, Point } from "@/core/image/frame"; export type Adjustments = { @@ -72,11 +72,8 @@ export type ImageLayer = { readonly name: string; readonly source: string; readonly whiteBalance?: Readonly; - /** - * How far a RAW photo's mosaic moves toward its noise-reduced samples before demosaicing, 0 to 100; - * none without it. - */ - readonly noiseReduction?: number; + /** Noise reduction on the photo before any adjustment, a RAW one's before demosaicing; none without it. */ + readonly noiseReduction?: Readonly; readonly adjustments: Readonly; readonly toneCurve: ToneCurve; readonly children: readonly ProcessingLayer[]; diff --git a/src/core/image/decode/raw-blend.wgsl b/src/core/image/decode/raw-blend.wgsl index a6f81fde..8e45f929 100644 --- a/src/core/image/decode/raw-blend.wgsl +++ b/src/core/image/decode/raw-blend.wgsl @@ -1,15 +1,10 @@ -// Writes sensor samples for the package to develop: the decoded ones blended toward replacement -// samples, each 2 × 2 cell's four by position above black in one texel, and dithered so smooth -// replacements don't band when rounded back to whole sensor units. -struct Blend { - amount: f32, - // Each 2 × 2 position's black level. - black: vec4f, -} - +// Writes sensor samples for the package to develop from replacement samples, each 2 × 2 cell's four by +// position above black in one texel, dithered so smooth replacements don't band when rounded back to +// whole sensor units. @group(0) @binding(0) var decoded: texture_2d; @group(0) @binding(1) var replacement: texture_2d; -@group(0) @binding(2) var blend: Blend; +// Each 2 × 2 position's black level. +@group(0) @binding(2) var black: vec4f; // One triangle covering the whole destination. @vertex fn vs_main(@builtin(vertex_index) index: u32) -> @builtin(position) vec4f { @@ -26,14 +21,12 @@ fn dither(p: vec2u) -> f32 { @fragment fn fs_main(@builtin(position) position: vec4f) -> @location(0) vec4u { let p = vec2u(position.xy); - let original = f32(textureLoad(decoded, p, 0).r); let cell = p / 2u; // A last odd row or column has no cell and keeps its samples. if (any(cell >= textureDimensions(replacement))) { - return vec4u(u32(original)); + return textureLoad(decoded, p, 0); } let site = (p.y & 1u) * 2u + (p.x & 1u); - let value = textureLoad(replacement, cell, 0)[site] + blend.black[site]; - let blended = mix(original, value, blend.amount) + dither(p); - return vec4u(u32(clamp(round(blended), 0.0, 65535.0))); + let value = textureLoad(replacement, cell, 0)[site] + black[site] + dither(p); + return vec4u(u32(clamp(round(value), 0.0, 65535.0))); } diff --git a/src/core/image/decode/raw.ts b/src/core/image/decode/raw.ts index 528bf08f..70a21372 100644 --- a/src/core/image/decode/raw.ts +++ b/src/core/image/decode/raw.ts @@ -4,7 +4,6 @@ import { createImageSource, type Mosaic, type RawDevelopment, - type SampleBlend, } from "@/core/image"; import { weakMemo } from "@/lib/weak-memo"; import blendShader from "./raw-blend.wgsl"; @@ -28,7 +27,7 @@ const blendPipeline = weakMemo((device: GPUDevice) => { /** * The samples the package develops, which live in its own `sensor` texture. Passes take turns: each * writes the samples it develops just before developing, in the same task, so no other pass's write - * falls between. The decoded samples move to a copy the first time a blend replaces them. + * falls between. The decoded samples move to a copy the first time others replace them. */ function createSampleWriter( gpu: Gpu, @@ -36,20 +35,18 @@ function createSampleWriter( blacks: readonly number[], ) { const device = gpu.gpu; - // The blend's amount, then each 2 × 2 position's black level. - const blendValues = new Float32Array(8); - blendValues.set(blacks, 4); - const uniforms = gpu.device.createBuffer({ - size: blendValues.byteLength, + const black = gpu.device.createBuffer({ + size: 16, usage: ["uniform", "copy_dst"], }); + black.write(new Float32Array(blacks)); let decoded: Texture | undefined; - let held: SampleBlend | undefined; return { /** The decoded samples, wherever they are. */ decoded: () => decoded?.gpu ?? sensor, - write(blend?: SampleBlend) { - if (blend?.samples === held?.samples && blend?.amount === held?.amount) { + /** Writes `samples` in place of the decoded ones, or the decoded ones back. */ + write(samples?: Target) { + if (!samples && !decoded) { return; } const encoder = device.createCommandEncoder(); @@ -66,9 +63,7 @@ function createSampleWriter( size, ); } - if (blend) { - blendValues[0] = blend.amount; - uniforms.write(blendValues); + if (samples) { const pipeline = blendPipeline(device); const pass = encoder.beginRenderPass({ colorAttachments: [ @@ -82,8 +77,8 @@ function createSampleWriter( layout: pipeline.getBindGroupLayout(0), entries: [ { binding: 0, resource: decoded.gpu.createView() }, - { binding: 1, resource: blend.samples.color.gpu.createView() }, - { binding: 2, resource: { buffer: uniforms.gpu } }, + { binding: 1, resource: samples.color.gpu.createView() }, + { binding: 2, resource: { buffer: black.gpu } }, ], }), ); @@ -97,10 +92,9 @@ function createSampleWriter( ); } device.queue.submit([encoder.finish()]); - held = blend; }, dispose() { - uniforms.dispose(); + black.dispose(); decoded?.dispose(); }, }; @@ -125,8 +119,6 @@ function mosaicOf(gpu: Gpu, source: RawSource) { source.texture, cfa.map((color) => black[color]), ); - const closed = new AbortController(); - let denoised: Promise | undefined; const mosaic: Mosaic = { get samples() { return writer.decoded(); @@ -134,26 +126,8 @@ function mosaicOf(gpu: Gpu, source: RawSource) { pattern: cfa, black, white, - denoised(reduce) { - denoised ??= reduce(mosaic, closed.signal).catch((error) => { - denoised = undefined; - throw error; - }); - return denoised; - }, - }; - return { - mosaic, - write: writer.write, - dispose() { - closed.abort(Error("RAW source is closed.")); - void denoised?.then( - (samples) => samples.color.dispose(), - () => {}, - ); - writer.dispose(); - }, }; + return { mosaic, write: writer.write, dispose: writer.dispose }; } /** Adapts package-owned sensor sources to OpenLight's document and renderer lifetimes. */ @@ -179,11 +153,11 @@ export async function decodeRaw(gpu: Gpu, file: File) { mosaic: mosaic?.mosaic, createPass() { // The as-shot development of the decoded samples is the image the source keeps; another - // balance, or a blend, gets its own. + // balance, or other samples, get their own. let output: Target | undefined; const pass = source.createDevelopPass(); let calibration = source.calibration; - let blend: SampleBlend | undefined; + let replaced: Target | undefined; let dirty = true; return { async prepare(balance, samples) { @@ -193,11 +167,11 @@ export async function decodeRaw(gpu: Gpu, file: File) { calibration = shot ? source.calibration : await source.calibrate(balance); - blend = samples?.amount ? samples : undefined; + replaced = samples; dirty = true; }, render() { - if (calibration === source.calibration && !blend) { + if (calibration === source.calibration && !replaced) { output?.color.dispose(); output = undefined; return developed; @@ -207,7 +181,7 @@ export async function decodeRaw(gpu: Gpu, file: File) { format: "rgba16float", }); if (dirty) { - mosaic?.write(blend); + mosaic?.write(replaced); pass.render({ destination: output.color.gpu, calibration }); dirty = false; } diff --git a/src/core/image/index.ts b/src/core/image/index.ts index 6f0ecbb1..70a47098 100644 --- a/src/core/image/index.ts +++ b/src/core/image/index.ts @@ -1,6 +1,24 @@ import type { Target } from "vgpu"; export type WhiteBalance = { temperature: number; tint: number }; +/** How much noise reduction removes from light and from color, each 0 to 100; 0 keeps it. */ +export type NoiseReduction = { luminance: number; color: number }; +/** + * A source's noise, reduced once at a few strengths, which each renderer composes at any strengths + * at once. + */ +export type Reduction = { + /** A composer with its own output, for one renderer. */ + compose(): { + /** + * The source reduced by `strengths`: for a RAW photo, samples to develop in place of the decoded + * ones; for any other image, the image itself. + */ + render(strengths: NoiseReduction): Target; + dispose(): void; + }; + dispose(): void; +}; /** A RAW photo's 2 × 2 color filter mosaic before the GPU demosaics it. */ export type Mosaic = { /** One `r16uint` sample per sensor pixel, unrotated, as decoded. */ @@ -11,25 +29,17 @@ export type Mosaic = { black: readonly number[]; /** The level where samples clip, in sensor units. */ white: number; - /** - * The samples with their noise reduced, which `reduce` makes the first time a renderer or control - * asks, kept until the source closes; a failure lets the next ask try again. - */ - denoised( - reduce: (mosaic: Mosaic, signal: AbortSignal) => Promise, - ): Promise; }; -/** - * Samples a RAW pass develops in place of the decoded ones, blended toward them by `amount`, 0 to 1: - * a half-size `rgba16float` image holding each 2 × 2 cell's four by position, above their black level. - */ -export type SampleBlend = { samples: Target; amount: number }; export type RawDevelopment = { asShot: WhiteBalance; /** The mosaic, when the GPU demosaics a 2 × 2 one, for processing before demosaicing. */ mosaic?: Mosaic; createPass(): { - prepare(balance: WhiteBalance, blend?: SampleBlend): Promise; + /** + * Develops with `balance`, from the decoded samples or from `samples`: a half-size + * `rgba16float` image holding each 2 × 2 cell's four by position, above their black level. + */ + prepare(balance: WhiteBalance, samples?: Target): Promise; render(): Target; dispose(): void; }; @@ -64,6 +74,8 @@ export function createImageSource( { raw, primaries = "rec2020" }: SourceOptions = {}, ) { let references = 1; + const closed = new AbortController(); + let reduction: Promise | undefined; function release() { let active = true; return () => { @@ -72,6 +84,11 @@ export function createImageSource( } active = false; if (--references === 0) { + closed.abort(Error("Image source is closed.")); + void reduction?.then( + (reduced) => reduced.dispose(), + () => {}, + ); image.color.dispose(); raw?.dispose(); } @@ -81,6 +98,17 @@ export function createImageSource( image, raw, primaries, + /** + * The source's noise reduction, which `reduce` makes the first time a renderer or control asks, + * kept until the source closes; a failure lets the next ask try again. + */ + reduced(reduce: (signal: AbortSignal) => Promise) { + reduction ??= reduce(closed.signal).catch((error) => { + reduction = undefined; + throw error; + }); + return reduction; + }, retain() { if (!references) { throw Error("Image source is closed."); diff --git a/src/core/renderer/index.ts b/src/core/renderer/index.ts index b0d80817..4496d1d1 100644 --- a/src/core/renderer/index.ts +++ b/src/core/renderer/index.ts @@ -11,7 +11,12 @@ import { type Scene, walkLayers, } from "@/core/document"; -import type { ImageSource, Mosaic, WhiteBalance } from "@/core/image"; +import type { + ImageSource, + NoiseReduction, + Reduction, + WhiteBalance, +} from "@/core/image"; import type { Point } from "@/core/image/frame"; import { createFieldStore, type FieldLattice } from "./fields"; import { createRenderGraph } from "./graph"; @@ -129,6 +134,12 @@ function reductionFor(density: number) { /** How many reductions keep their proxy and intermediates, each a quarter of the photo at most. */ const keptReductions = 4; +const unreduced: NoiseReduction = { luminance: 0, color: 0 }; + +function sameReduction(a: NoiseReduction, b: NoiseReduction) { + return a.luminance === b.luminance && a.color === b.color; +} + function sameBalance(a: WhiteBalance | undefined, b: WhiteBalance | undefined) { return a?.temperature === b?.temperature && a?.tint === b?.tint; } @@ -142,8 +153,11 @@ export type RendererOptions = { field?: (id: string) => RemoveField | undefined; /** Receives each Remove field the renderer synthesizes, read back; without it nothing is read back. */ saveField?: (id: string, field: RemoveField) => void; - /** Reduces a RAW mosaic's noise, once per source, for the image layer's noise reduction. */ - denoise?: (mosaic: Mosaic, signal: AbortSignal) => Promise; + /** Reduces a source's noise once at a few strengths, for the image layer's noise reduction. */ + reduceNoise?: ( + source: ImageSource, + signal: AbortSignal, + ) => Promise; }; /** @@ -161,7 +175,7 @@ export function createRenderer( }, field: savedField = () => undefined, saveField, - denoise = () => { + reduceNoise = () => { throw Error("This renderer reduces no noise."); }, }: RendererOptions = {}, @@ -186,8 +200,16 @@ export function createRenderer( /** Mask coverage the graph rendered for the overlay and thumbnails, by layer ID. */ let shown = new Map(); let balance = resource.raw?.asShot; - /** How far the development's samples moved toward their noise-reduced ones, 0 to 1. */ - let reduction = 0; + /** A RAW photo reduces noise on its 2 × 2 mosaic, and any other image on itself. */ + const reducible = !resource.raw || Boolean(resource.raw.mosaic); + /** The noise reduction the development applies. */ + let applied = unreduced; + /** Renders the reduction at the applied strengths, for the reduction it composes. */ + let composer: + | (ReturnType & { of: Reduction }) + | undefined; + /** An image's reduced self, which compositions take in place of the source. */ + let reduced: Target | undefined; /** Counts developments, so the proxy follows white-balance and noise-reduction changes. */ let version = 0; let last: RenderRequest | undefined; @@ -279,7 +301,7 @@ export function createRenderer( if (unchanged && !(lacked && !request.interactive)) { return; } - const developed = raw?.render() ?? source; + const developed = raw?.render() ?? reduced ?? source; // Every brush and paint layer updates once, bypassed or not, so hidden layers keep their rasters. for (const { layer } of walkLayers(scene.layers)) { if (layer.kind === "mask" && layer.mask.kind === "brush") { @@ -423,6 +445,20 @@ export function createRenderer( } return stale; } + /** The noise reduction a scene asks of the image layer, none where the source takes none. */ + function wantedReduction(scene: Scene) { + return reducible + ? (scene.layers[0].noiseReduction ?? unreduced) + : unreduced; + } + /** The source reduced at `strengths`, through this renderer's composer for `reduction`. */ + function composeReduction(reduction: Reduction, strengths: NoiseReduction) { + if (composer?.of !== reduction) { + composer?.dispose(); + composer = { ...reduction.compose(), of: reduction }; + } + return composer.render(strengths); + } /** Resolves with `work`, or with nothing once a newer request or closing interrupts the wait. */ function interruptible(work: Promise) { const interrupted = new Promise((resolve) => { @@ -447,34 +483,41 @@ export function createRenderer( } if (disposed) return; if (next) continue; - const [image] = scene.layers; - const selected = image.whiteBalance ?? resource.raw?.asShot; - const mosaic = resource.raw?.mosaic; - const amount = mosaic ? (image.noiseReduction ?? 0) / 100 : 0; + const selected = scene.layers[0].whiteBalance ?? resource.raw?.asShot; + const wanted = wantedReduction(scene); let failure: { error: unknown } | undefined; if ( - raw && - selected && - (!sameBalance(balance, selected) || amount !== reduction) + (raw && !sameBalance(balance, selected)) || + !sameReduction(wanted, applied) ) { - let samples: Target | undefined; - if (amount && mosaic) { + let output: Target | undefined; + if (wanted.luminance || wanted.color) { try { - samples = await interruptible(mosaic.denoised(denoise)); + const reduction = await interruptible( + resource.reduced((signal) => reduceNoise(resource, signal)), + ); + output = reduction && composeReduction(reduction, wanted); } catch (error) { // The photo develops as decoded rather than not at all; the error shows once, and the - // reduction runs again when the amount changes. + // reduction runs again when its strengths change. failure = { error }; } + } else { + composer?.dispose(); + composer = undefined; } if (disposed) return; if (next) continue; - await raw.prepare(selected, samples && { samples, amount }); - if (disposed) { - return; + if (raw && selected) { + await raw.prepare(selected, output); + if (disposed) { + return; + } + balance = selected; + } else { + reduced = output; } - balance = selected; - reduction = amount; + applied = wanted; version++; } for (const { id, raster, rasters } of stalePaint(scene)) { @@ -520,11 +563,13 @@ export function createRenderer( factor: reductionFor(density), interactive, }; - // Renders wait, in order, for a settle, a RAW development, or settled paint and fields to load. + // Renders wait, in order, for a settle, a RAW development or noise reduction, or settled paint and + // fields to load. if ( !raw && !pending && !settling && + sameReduction(wantedReduction(scene), applied) && !stalePaint(scene).length && !staleFields(scene).length ) { @@ -573,6 +618,7 @@ export function createRenderer( strokes.dispose(); proxy.dispose(); raw?.dispose(); + composer?.dispose(); release(); } diff --git a/src/features/noise-reduction/algorithm.md b/src/features/noise-reduction/algorithm.md index ed5bbb09..7b4c9bc2 100644 --- a/src/features/noise-reduction/algorithm.md +++ b/src/features/noise-reduction/algorithm.md @@ -1,21 +1,24 @@ -# RAW noise reduction +# Noise reduction -`denoiseMosaic(gpu, mosaic, signal)` reduces a RAW photo's noise on its Bayer mosaic, before the GPU demosaics it, and resolves to a half-size `rgba16float` target holding each 2 × 2 cell's four samples by position, in sensor units. It runs without React, model weights, or workers, entirely in WGSL but for two small fits in TypeScript. The RAW source keeps the result until it closes; a pass blends the decoded samples toward it by the image layer's amount and demosaics the blend, so fading or editing the layers above never reduces noise again. +`reduceNoise(gpu, source, signal)` reduces a photo's noise once, at three strengths, before its adjustments: a RAW photo on its Bayer mosaic before the GPU demosaics it, any other image in its encoded color. It runs without React, model weights, or workers, entirely in WGSL but for small fits in TypeScript. The source keeps the result until it closes, and each renderer composes it at the image layer's luminance and color strengths in one pass, so moving either slider, or editing any layer above, never reduces noise again. -1. **Noise model.** Every 32 × 32 block of sensor pixels gives each cell position its mean and the variance of its diagonal Haar detail, which flat blocks owe to noise alone. Over 40 equal-count bins along the signal, the quietest quarter of each bin gives a point, and a weighted line through them is the position's Poisson-Gaussian model, variance = gain · signal + floor. Positions fit apart: some cameras scale a channel digitally, which scales its noise too. A block with a clipped sample is skipped. -2. **Spectrum.** The generalized Anscombe transform, measured from its value at black so half floats stay precise, makes each position's noise unit Gaussian, signal-dependent noise included. An orthonormal transform then turns red, the two greens, and blue into light, green against magenta, red against blue, and the greens' difference, keeping that noise unit. A sample far above its same-color neighbors and every adjacent one is a hot pixel, since light reaches neighbors through the lens; it takes its brightest same-color neighbor's value. Samples below the transform's lowest value, such as dead pixels, are clamped there. -3. **Pyramid.** Averaging 2 × 2 texels builds up to six levels, down to 32 texels. Each level measures its own noise per component from its flattest blocks rather than assuming it halves: lossy RAW compression, for one, leaves noise that is quieter at fine scales and louder at coarse ones, which an assumed halving turns into blotches. -4. **Filter.** From the coarsest level up, sliding 8 × 8 DCT denoising runs in two steps: hard thresholding at 3 deviations, 3.5 for the greens' difference, then Wiener shrinkage by the first estimate, with color shrinking twice and the greens' difference eight times as hard as light. Every patch position counts, each output texel gathers the 64 patches over it in workgroup memory, weighted by how little of each survived and by a Kaiser window. A patch's DC stays whole, so every level stays unbiased, except the greens' difference: both greens see the same light, so its mean is noise or imbalance, which demosaicing would draw as a maze. After each step, the upsampled difference between the coarser estimate and the step's own 2 × 2 averages, blurred, replaces the step's low frequencies: broad noise the 8 × 8 patches cannot tell from the image goes, the coarser level's finest frequencies, where its patches ring at edges, stay out, and where the two levels agree nothing changes. -5. **Inverse.** The orthonormal transform back, and the closed-form inverse that is unbiased for Poisson-Gaussian noise (Mäkitalo and Foi), return sensor samples, so shadows keep their level after a strong exposure push. +1. **Noise model, RAW.** Every 16 × 16 block of sensor pixels gives each cell position its mean and the variance of its diagonal Haar detail, which flat blocks owe to noise alone. Over 40 equal-count bins along the signal, the quietest tenth of each bin gives a point, and a weighted line through them is the position's Poisson-Gaussian model, variance = gain · signal + floor. Texture only adds variance, so the fit runs again without points more than 30 % above the line, which follows the noise where a detailed photo has few flat blocks. Positions fit apart: some cameras scale a channel digitally, which scales its noise too. +2. **Spectrum.** For a RAW photo, the generalized Anscombe transform, measured from its value at black so half floats stay precise, makes each position's noise unit Gaussian, and an orthonormal transform turns red, the two greens, and blue into light, green against magenta, red against blue, and the greens' difference. Samples below the transform's lowest value, such as dead pixels, are clamped there. Any other image is filtered in the sRGB transfer's encoding, where noise varies far less with light than in linear, as light and two color differences. +3. **Pyramid.** Averaging 2 × 2 texels builds up to six levels, down to 32 texels. Each level measures each component's noise from its flattest blocks. For a RAW photo, the finest level's noise is the model's, which a lower measure corrects; for an image, its measure, never below 8-bit rounding. A coarser level takes its own measure only up to 15 % over what white noise would leave there: lossy compression correlates noise and raises it at coarse levels, while texture, which a coarse level's flattest blocks still hold, raises a measure far more. +4. **Filter.** From the coarsest level up, sliding 8 × 8 DCT denoising runs in two steps: hard thresholding at 3 deviations, 3.5 for the greens' difference, then Wiener shrinkage by the first estimate, with color shrinking twice and the greens' difference eight times as hard as light. Every patch position counts, mirrored past the image's edges so edges and corners get as many as the rest; each output texel gathers the 64 patches over it in workgroup memory, weighted by how little of each survived and by a Kaiser window. A patch's DC stays whole, so every level stays unbiased, except the greens' difference: both greens see the same light, so its mean is noise or imbalance, which demosaicing would draw as a maze. After each step, the upsampled difference between the coarser estimate and the step's own 2 × 2 averages, blurred at the coarse size, replaces the step's low frequencies: broad noise the 8 × 8 patches cannot tell from the image goes, the coarser level's finest frequencies, where its patches ring at edges, stay out, and where the two levels agree nothing changes. +5. **Anchors.** The filter runs at three strengths, 0.35, 0.7, and 1.4 times the measured noise, sharing the pyramid and its measures. For a RAW photo each keeps, through the closed-form inverse that is unbiased for Poisson-Gaussian noise (Mäkitalo and Foi), its change to the decoded samples; for an image, its light at full size and its color at half, which a reduced image's color needs no more of. +6. **Composing.** An amount from 0 to 100 scales the filtered noise linearly: 25, 50, and 100 reach the anchors, and an amount between two blends them, or the weakest with the photo as decoded. Luminance weighs the light component, color the two color differences, and the stronger of the two the greens' difference. For a RAW photo, each anchor's change splits into those components along the transform's slope where the strongest anchor puts each sample, so the split follows the filter's, and the weighted parts add to the decoded samples: an anchor at both strengths gives that anchor exactly, and 0 the samples as decoded. -Patches are filtered alone. Grouping similar ones, as BM3D does, ranks candidates by a distance that noise dominates in dark areas; along edges it groups aligned patches whose average keeps a profile of noise, which showed as streaks. Alone, a patch cannot mistake aligned noise for likeness. +Patches are filtered alone. Grouping similar ones, as BM3D does, ranks candidates by a distance that noise dominates in dark areas; along edges it groups aligned patches whose average keeps a profile of noise, which showed as streaks. Hot pixels are left alone: telling one from a fine highlight by its neighbors removed far more detail from sharp photos than it saved. + +On synthetic RAW mosaics made from a sharp photo, at three noise levels, an amount near 50 comes closest to the clean photo, 2 to 3.5 dB nearer than the noisy one; 100 smooths further, which very noisy photos may want. An image with added sRGB noise gains 5 to 7 dB near 50. ## Limits -Detail with less signal than noise is gone for any filter: in the darkest areas of a high-ISO photo the result is smooth rather than textured. Very fine repeated texture near the noise level softens. The model assumes Poisson-Gaussian noise; row or column banding, which some sensors add, passes through. Only 2 × 2 color filter mosaics that the GPU demosaics take part; X-Trans and linear DNG files have no noise reduction. +Detail with less signal than noise is gone for any filter: in the darkest areas of a high-ISO photo, strong amounts leave smooth rather than textured surfaces. Very fine repeated texture near the noise level softens. A detailed photo with little noise can still measure more noise than it has, which a lower amount corrects. The RAW model assumes Poisson-Gaussian noise; row or column banding, which some sensors add, passes through. An image's noise has no model, and JPEG block edges are not noise. X-Trans and linear DNG files have no noise reduction. An image reduces at full size in three components, so it costs about four times a RAW photo of as many pixels. ## Verification -`tests/noise-reduction.test.ts` checks the noise fit against blocks of known noise, a third of them textured. `tests/noise-reduction.e2e.ts` opens a RAW fixture with seeded Poisson-Gaussian noise, reduces it, and checks that its deviation falls well below the noise while its mean holds, and that fading back to 0 develops the decoded samples again. +`tests/noise-reduction.test.ts` checks the noise fit against blocks of known noise, a third of them textured, and that amounts scale the filtered noise linearly. `tests/noise-reduction.e2e.ts` opens a RAW fixture with seeded Poisson-Gaussian noise and a noisy PNG, checks that their noise falls well below the original while their mean holds, that color reduces apart from light, and that 0 gives back the decoded pixels. References: [Yu and Sapiro, DCT image denoising](https://www.ipol.im/pub/art/2011/ys-dct/), [Dabov et al., BM3D](https://webpages.tuni.fi/foi/GCF-BM3D/), [Pierazzo, Morel, and Facciolo, multiscale DCT denoising](https://www.ipol.im/pub/art/2017/201/), [Foi et al., single-image Poisson-Gaussian noise fitting](https://webpages.tuni.fi/foi/papers/Foi-PoissonianGaussianClippedRaw-2007-IEEE_TIP.pdf), [Mäkitalo and Foi, the unbiased inverse](https://webpages.tuni.fi/foi/papers/OptGenAnscombeInverse-doublecolumn-preprint.pdf). diff --git a/src/features/noise-reduction/compose-image.wgsl b/src/features/noise-reduction/compose-image.wgsl new file mode 100644 index 00000000..cc1c8bb4 --- /dev/null +++ b/src/features/noise-reduction/compose-image.wgsl @@ -0,0 +1,31 @@ +// An image at chosen strengths: its encoded light and color differences, each moved toward every +// anchor's by that anchor's share, then decoded. Shares that are all zero give the image exactly. +import { decode, encode, fromOpponent, toOpponent } from "./opponent.wgsl"; + +// Per anchor, weakest first: its share of light, then of color. +@group(0) @binding(0) var shares: array; +@group(0) @binding(1) var source: texture_2d; +@group(0) @binding(2) var linearSampler: sampler; +@group(0) @binding(3) var weakLight: texture_2d; +@group(0) @binding(4) var measuredLight: texture_2d; +@group(0) @binding(5) var strongLight: texture_2d; +// Color at half size, sampled between its texels. +@group(0) @binding(6) var weakColor: texture_2d; +@group(0) @binding(7) var measuredColor: texture_2d; +@group(0) @binding(8) var strongColor: texture_2d; + +fn toward(value: vec3f, light: f32, color: vec2f, share: vec4f) -> vec3f { + return vec3f(share.x * (light - value.x), share.y * (color - value.yz)); +} + +@fragment fn fs_main(@builtin(position) position: vec4f) -> @location(0) vec4f { + let p = vec2i(position.xy); + let texel = textureLoad(source, p, 0); + let value = toOpponent(encode(texel.rgb)); + let uv = position.xy / vec2f(textureDimensions(source)); + var result = value; + result += toward(value, textureLoad(weakLight, p, 0).x, textureSampleLevel(weakColor, linearSampler, uv, 0.0).xy, shares[0]); + result += toward(value, textureLoad(measuredLight, p, 0).x, textureSampleLevel(measuredColor, linearSampler, uv, 0.0).xy, shares[1]); + result += toward(value, textureLoad(strongLight, p, 0).x, textureSampleLevel(strongColor, linearSampler, uv, 0.0).xy, shares[2]); + return vec4f(decode(fromOpponent(result)), texel.a); +} diff --git a/src/features/noise-reduction/compose.wgsl b/src/features/noise-reduction/compose.wgsl new file mode 100644 index 00000000..c18d397b --- /dev/null +++ b/src/features/noise-reduction/compose.wgsl @@ -0,0 +1,38 @@ +// Replacement samples for a mosaic at chosen strengths: the decoded samples plus each anchor's +// change, split into the spectrum's components and weighed by that anchor's share of each. The +// split follows the transform's slope where the strongest anchor puts each sample, so changes divide +// as the filter saw them, and shares that are all one or all zero give an anchor or the decoded +// samples exactly. +import { fromSpectrum, Noise, toSpectrum } from "./stabilize.wgsl"; + +@group(0) @binding(0) var noise: Noise; +// Per anchor, weakest first: its share of light, the two color differences, and the greens' one. +@group(0) @binding(1) var shares: array; +@group(0) @binding(2) var samples: texture_2d; +@group(0) @binding(3) var weak: texture_2d; +@group(0) @binding(4) var measured: texture_2d; +@group(0) @binding(5) var strong: texture_2d; + +@fragment fn fs_main(@builtin(position) position: vec4f) -> @location(0) vec4f { + let cell = vec2i(position.xy); + let p = cell * 2; + let decoded = vec4f( + f32(textureLoad(samples, p, 0).r), + f32(textureLoad(samples, p + vec2i(1, 0), 0).r), + f32(textureLoad(samples, p + vec2i(0, 1), 0).r), + f32(textureLoad(samples, p + vec2i(1, 1), 0).r), + ) - noise.black; + let changes = array( + textureLoad(weak, cell, 0), + textureLoad(measured, cell, 0), + textureLoad(strong, cell, 0), + ); + let a = noise.gain; + let settled = max(decoded + changes[2], vec4f(0.0)); + let slope = sqrt(a * settled + 0.375 * a * a + noise.floor); + var result = decoded; + for (var k = 0; k < 3; k++) { + result += slope * fromSpectrum(shares[k] * toSpectrum(changes[k] / slope, noise), noise); + } + return result; +} diff --git a/src/features/noise-reduction/controls.tsx b/src/features/noise-reduction/controls.tsx index 2597c638..143c90bb 100644 --- a/src/features/noise-reduction/controls.tsx +++ b/src/features/noise-reduction/controls.tsx @@ -4,75 +4,82 @@ import { useEffect, useState } from "react"; import { useGpu } from "vgpu-react"; import type { Parameter } from "@/components/editor/parameter"; import { useDocument } from "@/components/editor/session"; -import type { ImageLayer, Layer } from "@/core/document"; -import type { Mosaic } from "@/core/image"; -import { denoiseMosaic } from "./denoise"; +import type { Layer } from "@/core/document"; +import type { ImageSource } from "@/core/image"; import { setNoiseReduction } from "./edits"; +import { defaultNoiseReduction, reducible } from "./model"; +import { reduceNoise } from "./reduce"; /** - * Whether the mosaic's noise-reduced samples are still being made for an amount above zero: the - * first time is slow, and the renderer shows the photo as it was until they are ready. + * Whether the source's first reduction is running while some is asked for: it takes a moment, and + * the renderer shows the photo as it was until it is ready. */ -function useReducing(mosaic: Mosaic | undefined, amount: number) { +function useReducing(source: ImageSource | undefined, wanted: boolean) { const gpu = useGpu(); - const [settled, setSettled] = useState(); - const wanted = amount > 0; + const [settled, setSettled] = useState(); useEffect(() => { - if (!mosaic || !wanted) { + if (!source || !wanted) { return; } let active = true; - const settle = () => active && setSettled(mosaic); - mosaic - .denoised((mosaic, signal) => denoiseMosaic(gpu, mosaic, signal)) + const settle = () => active && setSettled(source); + source + .reduced((signal) => reduceNoise(gpu, source, signal)) .then(settle, settle); return () => { active = false; }; - }, [gpu, mosaic, wanted]); - return wanted && settled !== mosaic; + }, [gpu, source, wanted]); + return wanted && settled !== source; } +const controls = [ + ["luminance", "Luminance noise"], + ["color", "Color noise"], +] as const; + /** - * The image layer's noise reduction for a RAW photo whose mosaic the GPU demosaics, and whether its - * first reduction is running; nothing for other layers and photos. + * The image layer's noise reduction, light and color, and whether its first reduction is running; + * nothing for other layers, or a RAW photo without a 2 × 2 mosaic. */ export function useNoiseReduction( layer: Layer, -): { parameter: Parameter; reducing: boolean } | undefined { +): { parameters: Parameter[]; reducing: boolean } | undefined { const document = useDocument(); const image = layer.kind === "image" ? layer : undefined; - const mosaic = image && document.resources.get(image.source).raw?.mosaic; - const value = image?.noiseReduction ?? 0; - const reducing = useReducing(mosaic, value); - if (!mosaic) { + const source = image && document.resources.get(image.source); + const strengths = image?.noiseReduction ?? defaultNoiseReduction; + const reducing = useReducing( + source, + strengths.luminance > 0 || strengths.color > 0, + ); + if (!source || !reducible(source)) { return undefined; } return { - parameter: { - id: "noiseReduction", - label: "Noise reduction", - value, + parameters: controls.map(([name, label]) => ({ + id: name, + label, + value: strengths[name], min: 0, max: 100, defaultValue: 0, - onChange: (amount) => setNoiseReduction(document, amount), - }, + onChange: (value) => setNoiseReduction(document, { [name]: value }), + })), reducing, }; } -/** The noise reduction slider, with a spinner beside its label while the first reduction runs. */ -export function NoiseReductionControls({ layer }: { layer: ImageLayer }) { +/** The noise reduction sliders, with a spinner beside the first's label while the first reduction runs. */ +export function NoiseReductionControls({ layer }: { layer: Layer }) { const noiseReduction = useNoiseReduction(layer); if (!noiseReduction) { return null; } - const { id, ...parameter } = noiseReduction.parameter; - return ( -
+ return noiseReduction.parameters.map(({ id, ...parameter }, i) => ( +
- {noiseReduction.reducing && ( + {i === 0 && noiseReduction.reducing && ( // The slider's label row again, its text hidden, so the spinner follows the label.
@@ -82,5 +89,5 @@ export function NoiseReductionControls({ layer }: { layer: ImageLayer }) {
)}
- ); + )); } diff --git a/src/features/noise-reduction/denoise.ts b/src/features/noise-reduction/denoise.ts deleted file mode 100644 index cbdcdac0..00000000 --- a/src/features/noise-reduction/denoise.ts +++ /dev/null @@ -1,296 +0,0 @@ -import { - type Buffer, - compute, - effect, - frame, - type Gpu, - type Target, - target, -} from "vgpu"; -import type { Mosaic } from "@/core/image"; -import { weakMemo } from "@/lib/weak-memo"; -import downShader from "./down.wgsl"; -import finishShader from "./finish.wgsl"; -import fuseShader from "./fuse.wgsl"; -import { measureNoise, type NoiseModel } from "./noise"; -import prepareShader from "./prepare.wgsl"; -import shrinkShader from "./shrink.wgsl"; -import spreadShader from "./spread.wgsl"; - -type Size = readonly [number, number]; -/** One level of the spectrum: four half floats per texel, row after row. */ -type Spectrum = { buffer: Buffer; size: Size }; - -const passes = weakMemo((gpu: Gpu) => ({ - prepare: compute(gpu, prepareShader), - down: compute(gpu, downShader), - spread: compute(gpu, spreadShader), - shrink: compute(gpu, shrinkShader), - blurRows: compute(gpu, fuseShader, { entry: "blurRows" }), - blurColumns: compute(gpu, fuseShader, { entry: "blurColumns" }), - finish: effect(gpu, finishShader), -})); - -/** Levels of the pyramid at most, and the shortest side a level may have. */ -const levels = 6; -const smallest = 32; -/** Spectrum texels along the side of the tile each workgroup of the filter takes. */ -const tile = 32; -/** Spectrum texels along the side of each block that measures a level's noise. */ -const block = 16; -/** - * The transform's largest value, in noise deviations: half floats keep it, and the filter's - * fixed-point sums of its patches stay in range. - */ -const largest = 1024; -/** The flattest quarter of blocks gives the noise; their chi-squared estimates sit at 0.885 of it. */ -const quartileBias = 0.885; - -function spectrum(gpu: Gpu, size: Size): Spectrum { - const buffer = gpu.device.createBuffer({ - size: size[0] * size[1] * 8, - usage: ["storage"], - }); - return { buffer, size }; -} - -function groups([width, height]: Size, side = 8) { - return [Math.ceil(width / side), Math.ceil(height / side)] as const; -} - -/** - * The least floor that keeps the transform of a sample `range` above black within `largest`: solving - * 2r / (√(a·r + c) + √c) = L for √c, with c = 3/8 · a² + b. A photo with little noise, which would - * stretch the transform further, is filtered as if it had that much. - */ -function leastFloor(gain: number, range: number) { - const root = range / largest - (gain * largest) / 4; - return root > 0 ? root * root - 0.375 * gain * gain : 0; -} - -/** The filter's noise model per 2 × 2 position, and the positions of red, the two greens, and blue. */ -function noiseParams( - { pattern, black, white }: Mosaic, - { gain, floor }: NoiseModel, -) { - const greens = [0, 1, 2, 3].filter((p) => pattern[p] % 2 === 1); - const blacks = pattern.map((color) => black[color]); - return { - order: [pattern.indexOf(0), greens[0], greens[1], pattern.indexOf(2)], - black: blacks, - gain, - floor: floor.map((b, p) => - Math.max(b, leastFloor(gain[p], white - blacks[p])), - ), - }; -} - -/** - * Each component's noise deviation in one level of the spectrum, from its flattest blocks: unit at - * the finest level when the model fits, and halving with each level only where the noise is white. - * Blocks without any detail, such as clipped highlights, hold no noise to measure; without others, - * the level takes `expected`, the deviation white noise would have. - */ -async function measureSpread( - gpu: Gpu, - { buffer, size }: Spectrum, - expected: number, -) { - const columns = Math.floor(size[0] / block); - const rows = Math.floor(size[1] / block); - const blocks = gpu.device.createBuffer({ - size: columns * rows * 16, - usage: ["storage", "copy_src"], - }); - try { - passes(gpu) - .spread.set({ size, spectrum: buffer, blocks }) - .dispatch(columns, rows); - const values = new Float32Array(await blocks.read(columns * rows * 16)); - return [0, 1, 2, 3].map((component) => { - const variances = values - .filter((variance, i) => i % 4 === component && variance > 0) - .sort((a, b) => a - b); - const quartile = variances[Math.floor(variances.length / 4)]; - return quartile ? Math.sqrt(quartile / quartileBias) : expected; - }); - } finally { - blocks.dispose(); - } -} - -/** One step of the patch filter over `noisy` into `output`, shrinking by `pilot`'s Wiener weights if given. */ -function shrink( - gpu: Gpu, - noisy: Spectrum, - output: Spectrum, - sigma: number[], - pilot?: Spectrum, -) { - const { size } = noisy; - passes(gpu) - .shrink.set({ - params: { size, sigma, wiener: Number(Boolean(pilot)) }, - noisy: noisy.buffer, - pilot: (pilot ?? noisy).buffer, - output: output.buffer, - }) - .dispatch(...groups(size, tile)); -} - -/** Replaces `estimate`'s low frequencies with those of `coarse`, the estimate of the level below, into `output`. */ -function fuse( - gpu: Gpu, - estimate: Spectrum, - coarse: Spectrum, - output: Spectrum, -) { - const { down, blurRows, blurColumns } = passes(gpu); - const average = spectrum(gpu, coarse.size); - const across = spectrum(gpu, estimate.size); - const sizes = { fine: estimate.size, coarse: coarse.size }; - try { - down - .set({ - sizes: { source: estimate.size, output: coarse.size }, - source: estimate.buffer, - output: average.buffer, - }) - .dispatch(...groups(coarse.size)); - blurRows - .set({ - sizes, - average: average.buffer, - coarse: coarse.buffer, - across: across.buffer, - }) - .dispatch(...groups(estimate.size)); - blurColumns - .set({ - sizes, - fine: estimate.buffer, - across: across.buffer, - output: output.buffer, - }) - .dispatch(...groups(estimate.size)); - } finally { - average.buffer.dispose(); - across.buffer.dispose(); - } -} - -/** - * Denoises one level of the pyramid, taking its low frequencies from the estimate of the level below - * when there is one: thresholding first, then Wiener shrinkage guided by that first estimate. - * `expected` is the level's noise deviation were the noise white. - */ -async function denoiseLevel( - gpu: Gpu, - noisy: Spectrum, - coarse: Spectrum | undefined, - expected: number, - signal?: AbortSignal, -) { - const sigma = await measureSpread(gpu, noisy, expected); - signal?.throwIfAborted(); - const first = spectrum(gpu, noisy.size); - try { - const second = spectrum(gpu, noisy.size); - try { - shrink(gpu, noisy, first, sigma); - if (!coarse) { - shrink(gpu, noisy, second, sigma, first); - return second; - } - fuse(gpu, first, coarse, second); - shrink(gpu, noisy, first, sigma, second); - fuse(gpu, first, coarse, second); - return second; - } catch (error) { - second.buffer.dispose(); - throw error; - } - } finally { - first.buffer.dispose(); - } -} - -/** - * Denoises `pyramid`, finest level first, from its coarsest level up: each level takes its low - * frequencies from the estimate of the one below it. `expected` is the finest level's noise - * deviation were the noise white, and halves with each level. - */ -async function denoisePyramid( - gpu: Gpu, - [level, ...coarser]: readonly Spectrum[], - signal?: AbortSignal, - expected = 1, -): Promise { - const coarse = coarser.length - ? await denoisePyramid(gpu, coarser, signal, expected / 2) - : undefined; - try { - return await denoiseLevel(gpu, level, coarse, expected, signal); - } finally { - coarse?.buffer.dispose(); - } -} - -/** - * Removes the mosaic's noise before demosaicing: its own noise model makes the noise unit Gaussian, - * a pyramid of patch filtering removes it from fine detail to broad color blotches, and the result - * returns to sensor samples: a half-size `rgba16float` target holding each 2 × 2 cell's four samples - * by position, above their black level. Stops at the next readback once `signal` aborts. - */ -export async function denoiseMosaic( - gpu: Gpu, - mosaic: Mosaic, - signal?: AbortSignal, -): Promise { - const { prepare, down, finish } = passes(gpu); - const size: Size = [ - Math.floor(mosaic.samples.width / 2), - Math.floor(mosaic.samples.height / 2), - ]; - if (Math.min(...size) < block) { - throw Error("The photo is too small to reduce its noise."); - } - const noise = noiseParams(mosaic, await measureNoise(gpu, mosaic)); - signal?.throwIfAborted(); - const pyramid = [spectrum(gpu, size)]; - try { - prepare - .set({ samples: mosaic.samples, noise, size, output: pyramid[0].buffer }) - .dispatch(...groups(size)); - while (pyramid.length < levels) { - const finer = pyramid[pyramid.length - 1]; - const next: Size = [ - Math.ceil(finer.size[0] / 2), - Math.ceil(finer.size[1] / 2), - ]; - if (Math.min(...next) < smallest) break; - const level = spectrum(gpu, next); - down - .set({ - sizes: { source: finer.size, output: next }, - source: finer.buffer, - output: level.buffer, - }) - .dispatch(...groups(next)); - pyramid.push(level); - } - const estimate = await denoisePyramid(gpu, pyramid, signal); - try { - const output = target(gpu, { size, format: "rgba16float" }); - const filtered = estimate.buffer; - frame(gpu, (f) => - f.pass(output, finish.set({ noise, size, spectrum: filtered })), - ); - return output; - } finally { - estimate.buffer.dispose(); - } - } finally { - for (const level of pyramid) level.buffer.dispose(); - } -} diff --git a/src/features/noise-reduction/edits.ts b/src/features/noise-reduction/edits.ts index 793ac1fd..d6437023 100644 --- a/src/features/noise-reduction/edits.ts +++ b/src/features/noise-reduction/edits.ts @@ -1,22 +1,36 @@ import { type EditorDocument, editLayer } from "@/core/document"; -import { parse } from "@/lib/parse"; -import { noiseReductionSchema } from "./model"; +import type { NoiseReduction } from "@/core/image"; +import { change, parse } from "@/lib/parse"; +import { + defaultNoiseReduction, + noiseReductionSchema, + reducible, +} from "./model"; -/** Sets the image layer's noise reduction, 0 to 100, for a RAW photo the GPU demosaics. */ -export function setNoiseReduction(document: EditorDocument, amount: number) { +const noiseReductionChange = change(noiseReductionSchema); + +/** Sets the image layer's noise reduction, keeping the strengths `values` omits. */ +export function setNoiseReduction( + document: EditorDocument, + values: Partial, +) { const [image] = document.scene.getState().layers; - if (!document.resources.get(image.source).raw?.mosaic) { - throw Error("Only RAW photos with a Bayer mosaic support noise reduction."); + if (!reducible(document.resources.get(image.source))) { + throw Error("Only RAW photos with a 2 × 2 mosaic take noise reduction."); } - const noiseReduction = parse( - noiseReductionSchema, - amount, + const strengths = parse( + noiseReductionChange, + values, "Invalid noise reduction", ); editLayer(document, image.id, (layer) => { if (layer.kind !== "image") { throw Error("Select the image layer."); } + const noiseReduction = { + ...(layer.noiseReduction ?? defaultNoiseReduction), + ...strengths, + }; return { ...layer, noiseReduction }; }); } diff --git a/src/features/noise-reduction/finish-color.wgsl b/src/features/noise-reduction/finish-color.wgsl new file mode 100644 index 00000000..68e33313 --- /dev/null +++ b/src/features/noise-reduction/finish-color.wgsl @@ -0,0 +1,17 @@ +// A filtered image spectrum's two color differences, averaged over 2 × 2 pixels: a reduced image's +// color holds no detail at that scale, and half its size takes a quarter of the memory. +import { unpack } from "./texels.wgsl"; + +@group(0) @binding(0) var size: vec2u; +@group(0) @binding(1) var spectrum: array; + +fn colorAt(p: vec2u) -> vec2f { + let q = min(p, size - 1u); + return unpack(spectrum[q.y * size.x + q.x]).yz; +} + +@fragment fn fs_main(@builtin(position) position: vec4f) -> @location(0) vec4f { + let p = vec2u(position.xy) * 2u; + let sum = colorAt(p) + colorAt(p + vec2u(1u, 0u)) + colorAt(p + vec2u(0u, 1u)) + colorAt(p + 1u); + return vec4f(sum * 0.25, 0.0, 1.0); +} diff --git a/src/features/noise-reduction/finish-light.wgsl b/src/features/noise-reduction/finish-light.wgsl new file mode 100644 index 00000000..54517ae4 --- /dev/null +++ b/src/features/noise-reduction/finish-light.wgsl @@ -0,0 +1,10 @@ +// A filtered image spectrum's light, at full size. +import { unpack } from "./texels.wgsl"; + +@group(0) @binding(0) var size: vec2u; +@group(0) @binding(1) var spectrum: array; + +@fragment fn fs_main(@builtin(position) position: vec4f) -> @location(0) vec4f { + let p = vec2u(position.xy); + return vec4f(unpack(spectrum[p.y * size.x + p.x]).x, 0.0, 0.0, 1.0); +} diff --git a/src/features/noise-reduction/finish.wgsl b/src/features/noise-reduction/finish.wgsl index 2c0ff098..85b70e10 100644 --- a/src/features/noise-reduction/finish.wgsl +++ b/src/features/noise-reduction/finish.wgsl @@ -1,14 +1,22 @@ -// The filtered spectrum back to sensor samples above black, each texel one 2 × 2 cell's four by -// position. +// How a filtered spectrum changes the mosaic: each 2 × 2 cell's four samples by position, as the +// unbiased inverse returns them, less the decoded ones. import { fromSpectrum, Noise, unstabilize } from "./stabilize.wgsl"; import { unpack } from "./texels.wgsl"; @group(0) @binding(0) var noise: Noise; @group(0) @binding(1) var size: vec2u; @group(0) @binding(2) var spectrum: array; +@group(0) @binding(3) var samples: texture_2d; @fragment fn fs_main(@builtin(position) position: vec4f) -> @location(0) vec4f { - let p = vec2u(position.xy); - let z = fromSpectrum(unpack(spectrum[p.y * size.x + p.x]), noise); - return unstabilize(z, noise); + let cell = vec2u(position.xy); + let p = vec2i(cell * 2u); + let decoded = vec4f( + f32(textureLoad(samples, p, 0).r), + f32(textureLoad(samples, p + vec2i(1, 0), 0).r), + f32(textureLoad(samples, p + vec2i(0, 1), 0).r), + f32(textureLoad(samples, p + vec2i(1, 1), 0).r), + ) - noise.black; + let z = fromSpectrum(unpack(spectrum[cell.y * size.x + cell.x]), noise); + return unstabilize(z, noise) - decoded; } diff --git a/src/features/noise-reduction/fuse.wgsl b/src/features/noise-reduction/fuse.wgsl index 091bf839..8fa4c72e 100644 --- a/src/features/noise-reduction/fuse.wgsl +++ b/src/features/noise-reduction/fuse.wgsl @@ -1,9 +1,8 @@ // Replaces an estimate's low frequencies with those of the half-size spectrum's estimate, whose -// patches were large enough to tell broad noise from the image: the estimate plus the upsampled -// difference between that coarser estimate and the estimate's own 2 × 2 averages (after Pierazzo, -// Morel, and Facciolo, 2017), blurred so the coarser estimate's finest frequencies, where its patches -// ring at edges, stay out. Where the two agree, the estimate stays whole. Separable, across rows into -// `across`, then down columns into `output`. +// patches were large enough to tell broad noise from the image (after Pierazzo, Morel, and Facciolo, +// 2017): the difference between that coarser estimate and the estimate's own 2 × 2 averages, +// blurred at the coarse size so the coarser patches' finest frequencies, which ring at edges, stay +// out, then upsampled and added. Where the two levels agree, the estimate stays whole. import { pack, unpack } from "./texels.wgsl"; struct Sizes { @@ -11,61 +10,68 @@ struct Sizes { coarse: vec2u, } -// A Gaussian of 1.25 texels, normalized over its nine taps. -const taps = array(0.3192, 0.2318, 0.0888, 0.0179, 0.0019); +// A Gaussian of 0.625 coarse texels, 1.25 fine ones, normalized over its five taps. +const taps = array(0.6377, 0.1773, 0.0038); @group(0) @binding(0) var sizes: Sizes; @group(0) @binding(1) var fine: array; -// The fine estimate's 2 × 2 averages, and the coarser scale's estimate. +// The fine estimate's 2 × 2 averages, and the coarser level's estimate. @group(0) @binding(2) var average: array; @group(0) @binding(3) var coarse: array; +// The difference blurred across rows, then down columns, at the coarse size. @group(0) @binding(4) var across: array; -@group(0) @binding(5) var output: array; +@group(0) @binding(5) var blurred: array; +@group(0) @binding(6) var output: array; -fn coarseAt(p: vec2i) -> vec4f { +fn coarseIndex(p: vec2i) -> u32 { let q = vec2u(clamp(p, vec2i(0), vec2i(sizes.coarse) - 1)); - let index = q.y * sizes.coarse.x + q.x; - return unpack(coarse[index]) - unpack(average[index]); + return q.y * sizes.coarse.x + q.x; } -// The coarser estimate less the fine one's averages, upsampled to `p`; bilinear, each coarse texel -// centered on the 2 × 2 block it averages. fn difference(p: vec2i) -> vec4f { - let q = clamp(p, vec2i(0), vec2i(sizes.fine) - 1); - let position = vec2f(q) * 0.5 - 0.25; - let base = vec2i(floor(position)); - let f = position - floor(position); - let top = mix(coarseAt(base), coarseAt(base + vec2i(1, 0)), f.x); - let bottom = mix(coarseAt(base + vec2i(0, 1)), coarseAt(base + 1), f.x); - return mix(top, bottom, f.y); + let index = coarseIndex(p); + return unpack(coarse[index]) - unpack(average[index]); } @compute @workgroup_size(8, 8) fn blurRows(@builtin(global_invocation_id) id: vec3u) { - if (any(id.xy >= sizes.fine)) { + if (any(id.xy >= sizes.coarse)) { return; } let p = vec2i(id.xy); var sum = taps[0] * difference(p); - for (var k = 1; k < 5; k++) { + for (var k = 1; k < 3; k++) { sum += taps[k] * (difference(p - vec2i(k, 0)) + difference(p + vec2i(k, 0))); } - across[id.y * sizes.fine.x + id.x] = pack(sum); -} - -fn acrossAt(p: vec2i) -> vec4f { - let q = vec2u(clamp(p, vec2i(0), vec2i(sizes.fine) - 1)); - return unpack(across[q.y * sizes.fine.x + q.x]); + across[coarseIndex(p)] = pack(sum); } @compute @workgroup_size(8, 8) fn blurColumns(@builtin(global_invocation_id) id: vec3u) { - if (any(id.xy >= sizes.fine)) { + if (any(id.xy >= sizes.coarse)) { return; } let p = vec2i(id.xy); - var sum = taps[0] * acrossAt(p); - for (var k = 1; k < 5; k++) { - sum += taps[k] * (acrossAt(p - vec2i(0, k)) + acrossAt(p + vec2i(0, k))); + var sum = taps[0] * unpack(across[coarseIndex(p)]); + for (var k = 1; k < 3; k++) { + sum += taps[k] * (unpack(across[coarseIndex(p - vec2i(0, k))]) + unpack(across[coarseIndex(p + vec2i(0, k))])); } + blurred[coarseIndex(p)] = pack(sum); +} + +fn blurredAt(p: vec2i) -> vec4f { + return unpack(blurred[coarseIndex(p)]); +} + +// The estimate plus the blurred difference, bilinear, each coarse texel centered on the 2 × 2 block +// it averages. +@compute @workgroup_size(8, 8) fn add(@builtin(global_invocation_id) id: vec3u) { + if (any(id.xy >= sizes.fine)) { + return; + } + let position = vec2f(id.xy) * 0.5 - 0.25; + let base = vec2i(floor(position)); + let f = position - floor(position); + let top = mix(blurredAt(base), blurredAt(base + vec2i(1, 0)), f.x); + let bottom = mix(blurredAt(base + vec2i(0, 1)), blurredAt(base + 1), f.x); let index = id.y * sizes.fine.x + id.x; - output[index] = pack(unpack(fine[index]) + sum); + output[index] = pack(unpack(fine[index]) + mix(top, bottom, f.y)); } diff --git a/src/features/noise-reduction/image.ts b/src/features/noise-reduction/image.ts new file mode 100644 index 00000000..3f34ace4 --- /dev/null +++ b/src/features/noise-reduction/image.ts @@ -0,0 +1,139 @@ +import { + compute, + effect, + frame, + type Gpu, + sampler, + type Target, + target, +} from "vgpu"; +import type { EncodedImage, NoiseReduction, Reduction } from "@/core/image"; +import { weakMemo } from "@/lib/weak-memo"; +import composeShader from "./compose-image.wgsl"; +import finishColorShader from "./finish-color.wgsl"; +import finishLightShader from "./finish-light.wgsl"; +import { anchors, componentShares } from "./model"; +import prepareShader from "./prepare-image.wgsl"; +import { + block, + buildPyramid, + filterPyramid, + groups, + measureLevels, + type Size, + spectrum, +} from "./pyramid"; + +const passes = weakMemo((gpu: Gpu) => ({ + prepare: compute(gpu, prepareShader), + light: effect(gpu, finishLightShader), + color: effect(gpu, finishColorShader), + compose: effect(gpu, composeShader), +})); + +/** The noise 8-bit samples hold at least, their rounding, in the encoding's units. */ +const rounding = 1 / 255 / Math.sqrt(12); + +/** An anchor's light at full size and color at half. */ +type Anchor = { light: Target; color: Target }; + +function createComposer( + gpu: Gpu, + { image }: EncodedImage, + [weak, measured, strong]: readonly Anchor[], +) { + const output = target(gpu, { size: image.size, format: "rgba16float" }); + return { + render(strengths: NoiseReduction) { + const pass = passes(gpu).compose.set({ + shares: componentShares(strengths), + source: image.color, + linearSampler: sampler(gpu, { + minFilter: "linear", + magFilter: "linear", + }), + weakLight: weak.light.color, + measuredLight: measured.light.color, + strongLight: strong.light.color, + weakColor: weak.color.color, + measuredColor: measured.color.color, + strongColor: strong.color.color, + }); + frame(gpu, (f) => f.pass(output, pass)); + return output; + }, + dispose: () => output.color.dispose(), + }; +} + +function disposeAnchors(anchors: readonly Anchor[]) { + for (const { light, color } of anchors) { + light.color.dispose(); + color.color.dispose(); + } +} + +/** + * Reduces an image's noise, once at each anchor strength, in its encoded light and color, with no + * noise model: each level's noise comes from its flattest blocks. A composer then gives the image + * at any strengths at once, linear in the image's primaries as an `rgba16float` target. Stops + * between levels once `signal` aborts. + */ +export async function reduceImage( + gpu: Gpu, + encoded: EncodedImage, + signal: AbortSignal, +): Promise { + const { prepare, light, color } = passes(gpu); + const size: Size = encoded.image.size; + if (Math.min(...size) < block) { + throw Error("The image is too small to reduce its noise."); + } + const finest = spectrum(gpu, size); + prepare + .set({ source: encoded.image.color, size, output: finest.buffer }) + .dispatch(...groups(size)); + const pyramid = buildPyramid(gpu, finest); + const reduced: Anchor[] = []; + try { + const levels = await measureLevels(gpu, pyramid, (measured) => + Math.max(measured, rounding), + ); + for (const strength of anchors) { + const estimate = await filterPyramid( + gpu, + pyramid, + levels, + strength, + 3, + signal, + ); + try { + const anchor = { + light: target(gpu, { size, format: "r16float" }), + color: target(gpu, { + size: [Math.ceil(size[0] / 2), Math.ceil(size[1] / 2)], + format: "rg16float", + }), + }; + reduced.push(anchor); + const params = { size, spectrum: estimate.buffer }; + frame(gpu, (f) => { + f.pass(anchor.light, light.set(params)); + f.pass(anchor.color, color.set(params)); + }); + } finally { + estimate.buffer.dispose(); + } + } + } catch (error) { + disposeAnchors(reduced); + throw error; + } finally { + for (const level of pyramid) level.buffer.dispose(); + } + return { + compose: () => createComposer(gpu, encoded, reduced), + dispose: () => disposeAnchors(reduced), + }; +} diff --git a/src/features/noise-reduction/model.ts b/src/features/noise-reduction/model.ts index 5a0ecf77..c00372f7 100644 --- a/src/features/noise-reduction/model.ts +++ b/src/features/noise-reduction/model.ts @@ -1,4 +1,57 @@ +import { z } from "zod/mini"; +import type { ImageSource, NoiseReduction } from "@/core/image"; import { range } from "@/lib/parse"; -/** How far a RAW photo develops toward its noise-reduced samples, in UI units; 0 develops them as decoded. */ -export const noiseReductionSchema = range(0, 100); +export const defaultNoiseReduction: NoiseReduction = { luminance: 0, color: 0 }; + +/** Whether a source takes noise reduction: a RAW photo through its 2 × 2 mosaic, any other image itself. */ +export function reducible(source: ImageSource) { + return !source.raw || Boolean(source.raw.mosaic); +} + +/** How much noise reduction removes from light and from color, each 0 to 100; 0 keeps it. */ +export const noiseReductionSchema = z.object({ + luminance: range(0, 100), + color: range(0, 100), +}) satisfies z.ZodMiniType; + +/** + * The strengths a photo's reduction is made at once, as multiples of the noise it measures, and the + * amounts that reach each, so an amount scales the noise filtered linearly: near 50 a reduced test + * photo comes closest to its clean original, and 100 smooths twice as hard. An amount between two + * blends their results, and one below the weakest blends it with the photo as decoded. + */ +export const anchors = [0.35, 0.7, 1.4]; +const amounts = [25, 50, 100]; + +/** Each anchor's share of the result at `amount`, 0 to 100; what remains is the photo as decoded. */ +export function anchorShares(amount: number) { + const shares = anchors.map(() => 0); + const next = amounts.findIndex((at) => amount <= at); + if (amount <= 0) { + return shares; + } + if (next === -1) { + shares[shares.length - 1] = 1; + return shares; + } + const from = next ? amounts[next - 1] : 0; + const t = (amount - from) / (amounts[next] - from); + shares[next] = t; + if (next) { + shares[next - 1] = 1 - t; + } + return shares; +} + +/** + * Each anchor's share per component of the spectrum: light follows luminance, the two color + * differences follow color, and the greens' difference, which demosaicing draws as a maze, the + * stronger of the two. + */ +export function componentShares({ luminance, color }: NoiseReduction) { + const light = anchorShares(luminance); + const tint = anchorShares(color); + const split = anchorShares(Math.max(luminance, color)); + return anchors.map((_, k) => [light[k], tint[k], tint[k], split[k]]); +} diff --git a/src/features/noise-reduction/mosaic.ts b/src/features/noise-reduction/mosaic.ts new file mode 100644 index 00000000..0a1b242f --- /dev/null +++ b/src/features/noise-reduction/mosaic.ts @@ -0,0 +1,152 @@ +import { compute, effect, frame, type Gpu, type Target, target } from "vgpu"; +import type { Mosaic, NoiseReduction, Reduction } from "@/core/image"; +import { weakMemo } from "@/lib/weak-memo"; +import composeShader from "./compose.wgsl"; +import finishShader from "./finish.wgsl"; +import { anchors, componentShares } from "./model"; +import { measureNoise, type NoiseModel } from "./noise"; +import prepareShader from "./prepare.wgsl"; +import { + block, + buildPyramid, + filterPyramid, + groups, + measureLevels, + modeled, + type Size, + spectrum, +} from "./pyramid"; + +const passes = weakMemo((gpu: Gpu) => ({ + prepare: compute(gpu, prepareShader), + finish: effect(gpu, finishShader), + compose: effect(gpu, composeShader), +})); + +/** + * The transform's largest value, in noise deviations: half floats keep it, and the filter's + * fixed-point sums of its patches stay in range. + */ +const largest = 1024; + +/** + * The least floor that keeps the transform of a sample `range` above black within `largest`: solving + * 2r / (√(a·r + c) + √c) = L for √c, with c = 3/8 · a² + b. A photo with little noise, which would + * stretch the transform further, is filtered as if it had that much. + */ +function leastFloor(gain: number, range: number) { + const root = range / largest - (gain * largest) / 4; + return root > 0 ? root * root - 0.375 * gain * gain : 0; +} + +/** The filter's noise model per 2 × 2 position, and the positions of red, the two greens, and blue. */ +function noiseParams( + { pattern, black, white }: Mosaic, + { gain, floor }: NoiseModel, +) { + const greens = [0, 1, 2, 3].filter((p) => pattern[p] % 2 === 1); + const blacks = pattern.map((color) => black[color]); + return { + order: [pattern.indexOf(0), greens[0], greens[1], pattern.indexOf(2)], + black: blacks, + gain, + floor: floor.map((b, p) => + Math.max(b, leastFloor(gain[p], white - blacks[p])), + ), + }; +} + +type NoiseParams = ReturnType; + +/** Each anchor's change to the samples, a half-size target of each 2 × 2 cell's four by position. */ +function createComposer( + gpu: Gpu, + mosaic: Mosaic, + noise: NoiseParams, + [weak, measured, strong]: readonly Target[], + size: Size, +) { + const output = target(gpu, { size, format: "rgba16float" }); + return { + render(strengths: NoiseReduction) { + const pass = passes(gpu).compose.set({ + noise, + shares: componentShares(strengths), + samples: mosaic.samples, + weak: weak.color, + measured: measured.color, + strong: strong.color, + }); + frame(gpu, (f) => f.pass(output, pass)); + return output; + }, + dispose: () => output.color.dispose(), + }; +} + +/** + * Reduces a mosaic's noise before demosaicing, once at each anchor strength: its own noise model + * makes the noise unit Gaussian, a pyramid of patch filtering removes it from fine detail to broad + * color blotches, and the unbiased inverse returns samples. A composer then gives replacement + * samples at any strengths at once: a half-size `rgba16float` target holding each 2 × 2 cell's four + * samples by position, above black. Stops between levels once `signal` aborts. + */ +export async function reduceMosaic( + gpu: Gpu, + mosaic: Mosaic, + signal: AbortSignal, +): Promise { + const { prepare, finish } = passes(gpu); + const size: Size = [ + Math.floor(mosaic.samples.width / 2), + Math.floor(mosaic.samples.height / 2), + ]; + if (Math.min(...size) < block) { + throw Error("The photo is too small to reduce its noise."); + } + const noise = noiseParams(mosaic, await measureNoise(gpu, mosaic)); + signal.throwIfAborted(); + const finest = spectrum(gpu, size); + prepare + .set({ samples: mosaic.samples, noise, size, output: finest.buffer }) + .dispatch(...groups(size)); + const pyramid = buildPyramid(gpu, finest); + const changes: Target[] = []; + try { + const levels = await measureLevels(gpu, pyramid, modeled); + for (const strength of anchors) { + const estimate = await filterPyramid( + gpu, + pyramid, + levels, + strength, + 4, + signal, + ); + try { + const change = target(gpu, { size, format: "rgba16float" }); + changes.push(change); + const pass = finish.set({ + noise, + size, + spectrum: estimate.buffer, + samples: mosaic.samples, + }); + frame(gpu, (f) => f.pass(change, pass)); + } finally { + estimate.buffer.dispose(); + } + } + } catch (error) { + for (const change of changes) change.color.dispose(); + throw error; + } finally { + for (const level of pyramid) level.buffer.dispose(); + } + return { + compose: () => createComposer(gpu, mosaic, noise, changes, size), + dispose() { + for (const change of changes) change.color.dispose(); + }, + }; +} diff --git a/src/features/noise-reduction/noise.ts b/src/features/noise-reduction/noise.ts index d728397f..649d64ef 100644 --- a/src/features/noise-reduction/noise.ts +++ b/src/features/noise-reduction/noise.ts @@ -16,15 +16,21 @@ const measure = weakMemo((gpu: Gpu) => ); /** Sensor pixels along each block's side. */ -const block = 32; +const block = 16; /** Equal-count bins of blocks along the signal, each of which yields one point to fit. */ const bins = 40; /** - * The variance of the quietest quarter of a bin's blocks comes from its flattest ones; a block's - * estimate over 64 details is chi-squared, whose first quartile lies at 0.885 of its mean. + * The variance of the quietest tenth of a bin's blocks comes from its flattest ones, which texture + * reaches least; a block's estimate over 16 details is chi-squared, whose tenth percentile lies at + * 0.582 of its mean. */ -const quartile = 0.25; -const quartileBias = 0.885; +const quiet = 0.1; +const quietBias = 0.582; +/** + * Texture only adds variance, so a bin this far above the fitted line holds texture rather than + * noise; fitting again without such bins follows the noise where a detailed photo has few flat blocks. + */ +const envelope = 1.3; function fitLine(points: readonly [number, number][]) { // Weighted by the inverse variance squared, so dark and bright bins count by relative error. @@ -73,10 +79,17 @@ export function fitNoise( ); const variances = bin.map(([, variance]) => variance).sort((a, b) => a - b); const mean = bin[Math.floor(bin.length / 2)][0]; - const variance = variances[Math.floor(bin.length * quartile)]; - points.push([mean, variance / quartileBias]); + const variance = variances[Math.floor(bin.length * quiet)]; + points.push([mean, variance / quietBias]); + } + let fit = fitLine(points); + for (let pass = 0; pass < 5; pass++) { + const { gain, floor } = fit; + const below = points.filter(([x, y]) => y <= envelope * (gain * x + floor)); + if (below.length < 3) break; + fit = fitLine(below); } - return fitLine(points); + return fit; } /** Measures the mosaic's noise on the GPU from its flat blocks, then fits each position's model. */ diff --git a/src/features/noise-reduction/noise.wgsl b/src/features/noise-reduction/noise.wgsl index 8813a42a..14a3a4f4 100644 --- a/src/features/noise-reduction/noise.wgsl +++ b/src/features/noise-reduction/noise.wgsl @@ -1,8 +1,9 @@ -// Noise statistics of a Bayer mosaic: for each block of 32 × 32 sensor pixels, each 2 × 2 cell +// Noise statistics of a Bayer mosaic: for each block of 16 × 16 sensor pixels, each 2 × 2 cell // position's mean above black and the variance of its diagonal Haar detail, which flat blocks owe to -// noise alone. A position with a clipped sample in the block reports a negative variance, which the -// fit skips. -const side = 8u; +// noise alone; small blocks leave more of them flat in a detailed photo. A position with a clipped +// sample in the block reports a negative variance, which the fit skips. +const side = 4u; +const threads = side * side; struct Params { black: vec4f, @@ -14,9 +15,9 @@ struct Params { // Per block: the four positions' means, then their variances. @group(0) @binding(2) var blocks: array; -var means: array; -var details: array; -var clipped: array; +var means: array; +var details: array; +var clipped: array; // The 2 × 2 cell at `cell`, one sample per position, above black. fn cell(cell: vec2u) -> vec4f { @@ -47,7 +48,7 @@ fn cell(cell: vec2u) -> vec4f { let top = max(max(a, b), max(c, d)) + params.black; clipped[thread] = vec4u(top >= vec4f(params.white)); workgroupBarrier(); - for (var stride = 32u; stride > 0u; stride /= 2u) { + for (var stride = threads / 2u; stride > 0u; stride /= 2u) { if (thread < stride) { means[thread] += means[thread + stride]; details[thread] += details[thread + stride]; @@ -57,7 +58,7 @@ fn cell(cell: vec2u) -> vec4f { } if (thread == 0u) { let index = (block.y * blocks_.x + block.x) * 2u; - blocks[index] = means[0] / 64.0; - blocks[index + 1u] = select(details[0] / 64.0, vec4f(-1.0), clipped[0] != vec4u(0u)); + blocks[index] = means[0] / f32(threads); + blocks[index + 1u] = select(details[0] / f32(threads), vec4f(-1.0), clipped[0] != vec4u(0u)); } } diff --git a/src/features/noise-reduction/opponent.wgsl b/src/features/noise-reduction/opponent.wgsl new file mode 100644 index 00000000..00244d60 --- /dev/null +++ b/src/features/noise-reduction/opponent.wgsl @@ -0,0 +1,28 @@ +// An image is filtered in the sRGB transfer's encoding, where noise varies far less with light than +// in linear, extended past 0 and 1 by symmetry and its own curve; and in an orthonormal basis of +// light and two color differences, so noise keeps its deviation in each. + +export fn encode(x: vec3f) -> vec3f { + let a = abs(x); + return sign(x) * select(1.055 * pow(a, vec3f(1.0 / 2.4)) - 0.055, 12.92 * a, a <= vec3f(0.0031308)); +} + +export fn decode(e: vec3f) -> vec3f { + let a = abs(e); + return sign(e) * select(pow((a + 0.055) / 1.055, vec3f(2.4)), a / 12.92, a <= vec3f(0.04045)); +} + +// Light, red against blue, and green against magenta. +const opponent = mat3x3f( + 0.57735027, 0.70710678, 0.40824829, + 0.57735027, 0.0, -0.81649658, + 0.57735027, -0.70710678, 0.40824829, +); + +export fn toOpponent(rgb: vec3f) -> vec3f { + return opponent * rgb; +} + +export fn fromOpponent(value: vec3f) -> vec3f { + return value * opponent; +} diff --git a/src/features/noise-reduction/prepare-image.wgsl b/src/features/noise-reduction/prepare-image.wgsl new file mode 100644 index 00000000..ba8eadbd --- /dev/null +++ b/src/features/noise-reduction/prepare-image.wgsl @@ -0,0 +1,15 @@ +// An image's pixels as the filter's spectrum: light and two color differences of the encoded color. +import { encode, toOpponent } from "./opponent.wgsl"; +import { pack } from "./texels.wgsl"; + +@group(0) @binding(0) var source: texture_2d; +@group(0) @binding(1) var size: vec2u; +@group(0) @binding(2) var output: array; + +@compute @workgroup_size(8, 8) fn prepare(@builtin(global_invocation_id) id: vec3u) { + if (any(id.xy >= size)) { + return; + } + let rgb = textureLoad(source, id.xy, 0).rgb; + output[id.y * size.x + id.x] = pack(vec4f(toOpponent(encode(rgb)), 0.0)); +} diff --git a/src/features/noise-reduction/prepare.wgsl b/src/features/noise-reduction/prepare.wgsl index ee46bf45..bff73ba1 100644 --- a/src/features/noise-reduction/prepare.wgsl +++ b/src/features/noise-reduction/prepare.wgsl @@ -1,58 +1,23 @@ // Packs each 2 × 2 cell of the mosaic into one texel of the filter's spectrum, where noise is unit -// Gaussian everywhere. A sample that stands far above both its same-color neighbors and every -// adjacent one is a hot pixel rather than light, which would also reach its neighbors through the -// lens, and takes its brightest same-color neighbor's value. +// Gaussian everywhere. import { Noise, stabilize, toSpectrum } from "./stabilize.wgsl"; import { pack } from "./texels.wgsl"; -// How many noise deviations a hot pixel stands out by. -const outlier = 5.0; - @group(0) @binding(0) var samples: texture_2d; @group(0) @binding(1) var noise: Noise; @group(0) @binding(2) var size: vec2u; @group(0) @binding(3) var output: array; -// The 6 × 6 samples around the cell, stabilized, row by row. -var window: array; - -fn at(x: i32, y: i32) -> f32 { - return window[y * 6 + x]; -} - @compute @workgroup_size(8, 8) fn prepare(@builtin(global_invocation_id) id: vec3u) { if (any(id.xy >= size)) { return; } - for (var j = 0; j < 9; j++) { - let offset = vec2i(j % 3, j / 3); - let cell = clamp(vec2i(id.xy) + offset - 1, vec2i(0), vec2i(size) - 1); - let p = cell * 2; - let z = stabilize(vec4f( - f32(textureLoad(samples, p, 0).r), - f32(textureLoad(samples, p + vec2i(1, 0), 0).r), - f32(textureLoad(samples, p + vec2i(0, 1), 0).r), - f32(textureLoad(samples, p + vec2i(1, 1), 0).r), - ) - noise.black, noise); - for (var k = 0; k < 4; k++) { - window[(offset.y * 2 + k / 2) * 6 + offset.x * 2 + k % 2] = z[k]; - } - } - var z: vec4f; - for (var k = 0; k < 4; k++) { - let x = 2 + k % 2; - let y = 2 + k / 2; - let same = max(max(at(x - 2, y), at(x + 2, y)), max(at(x, y - 2), at(x, y + 2))); - var adjacent = -3.4e38; - for (var dy = -1; dy <= 1; dy++) { - for (var dx = -1; dx <= 1; dx++) { - if (dx != 0 || dy != 0) { - adjacent = max(adjacent, at(x + dx, y + dy)); - } - } - } - let value = at(x, y); - z[k] = select(value, same, value > same + outlier && value > adjacent + outlier); - } - output[id.y * size.x + id.x] = pack(toSpectrum(z, noise)); + let p = vec2i(id.xy * 2u); + let cell = vec4f( + f32(textureLoad(samples, p, 0).r), + f32(textureLoad(samples, p + vec2i(1, 0), 0).r), + f32(textureLoad(samples, p + vec2i(0, 1), 0).r), + f32(textureLoad(samples, p + vec2i(1, 1), 0).r), + ); + output[id.y * size.x + id.x] = pack(toSpectrum(stabilize(cell - noise.black, noise), noise)); } diff --git a/src/features/noise-reduction/pyramid.ts b/src/features/noise-reduction/pyramid.ts new file mode 100644 index 00000000..cbe581c1 --- /dev/null +++ b/src/features/noise-reduction/pyramid.ts @@ -0,0 +1,262 @@ +import { type Buffer, compute, type Gpu } from "vgpu"; +import { weakMemo } from "@/lib/weak-memo"; +import downShader from "./down.wgsl"; +import fuseShader from "./fuse.wgsl"; +import shrinkShader from "./shrink.wgsl"; +import spreadShader from "./spread.wgsl"; + +export type Size = readonly [number, number]; +/** One level of the spectrum: four half floats per texel, row after row. */ +export type Spectrum = { buffer: Buffer; size: Size }; + +const passes = weakMemo((gpu: Gpu) => ({ + down: compute(gpu, downShader), + spread: compute(gpu, spreadShader), + shrink: compute(gpu, shrinkShader), + blurRows: compute(gpu, fuseShader, { entry: "blurRows" }), + blurColumns: compute(gpu, fuseShader, { entry: "blurColumns" }), + add: compute(gpu, fuseShader, { entry: "add" }), +})); + +/** Levels of the pyramid at most, and the shortest side a level may have. */ +const levels = 6; +const smallest = 32; +/** Spectrum texels along the side of the tile each workgroup of the filter takes. */ +const tile = 32; +/** Spectrum texels along the side of each block that measures a level's noise. */ +export const block = 16; +/** The flattest quarter of blocks gives the noise; their chi-squared estimates sit at 0.876 of it. */ +const quartileBias = 0.876; +/** + * How far a level's noise may exceed what white noise would leave there: lossy compression + * correlates noise, raising it at coarse levels; texture raises a measure far more, and is not noise. + */ +const margin = 1.15; + +export function spectrum(gpu: Gpu, size: Size): Spectrum { + const buffer = gpu.device.createBuffer({ + size: size[0] * size[1] * 8, + usage: ["storage"], + }); + return { buffer, size }; +} + +export function groups([width, height]: Size, side = 8) { + return [Math.ceil(width / side), Math.ceil(height / side)] as const; +} + +function half([width, height]: Size): Size { + return [Math.ceil(width / 2), Math.ceil(height / 2)]; +} + +/** Averages 2 × 2 texels of `source` into `output`, half its size. */ +function down(gpu: Gpu, source: Spectrum, output: Spectrum) { + passes(gpu) + .down.set({ + sizes: { source: source.size, output: output.size }, + source: source.buffer, + output: output.buffer, + }) + .dispatch(...groups(output.size)); +} + +/** `finest` and its averages, each level half the one before, down to `smallest` texels. */ +export function buildPyramid(gpu: Gpu, finest: Spectrum) { + const pyramid = [finest]; + while (pyramid.length < levels) { + const finer = pyramid[pyramid.length - 1]; + const size = half(finer.size); + if (Math.min(...size) < smallest) break; + const level = spectrum(gpu, size); + down(gpu, finer, level); + pyramid.push(level); + } + return pyramid; +} + +/** + * Each component's noise deviation in one level, from its flattest blocks, which texture can only + * raise. Blocks without any detail, such as clipped highlights, hold no noise to measure; without + * others, a component measures none. + */ +async function measureSpread(gpu: Gpu, { buffer, size }: Spectrum) { + const columns = Math.floor(size[0] / block); + const rows = Math.floor(size[1] / block); + const blocks = gpu.device.createBuffer({ + size: columns * rows * 16, + usage: ["storage", "copy_src"], + }); + try { + passes(gpu) + .spread.set({ size, spectrum: buffer, blocks }) + .dispatch(columns, rows); + const values = new Float32Array(await blocks.read(columns * rows * 16)); + return [0, 1, 2, 3].map((component) => { + const variances = values + .filter((variance, i) => i % 4 === component && variance > 0) + .sort((a, b) => a - b); + const quartile = variances[Math.floor(variances.length / 4)]; + return quartile ? Math.sqrt(quartile / quartileBias) : 0; + }); + } finally { + blocks.dispose(); + } +} + +/** + * Each level's noise deviation per component, finest first. `finest` turns the finest level's + * measure into its noise. A coarser level's flattest blocks give its noise, up to a margin over what + * white noise would leave there, since they still hold texture. + */ +export async function measureLevels( + gpu: Gpu, + pyramid: readonly Spectrum[], + finest: (measured: number) => number, +) { + const measured = []; + for (const level of pyramid) measured.push(await measureSpread(gpu, level)); + const base = measured[0].map(finest); + return measured.map((level, l) => + level.map((value, c) => + l ? Math.min(value || Infinity, margin * base[c] * 2 ** -l) : base[c], + ), + ); +} + +/** The finest level's noise for a noise model that predicts unit noise, which a lower measure corrects. */ +export function modeled(measured: number) { + return Math.min(measured || 1, margin); +} + +/** One step of the patch filter over `noisy` into `output`, shrinking by `pilot`'s Wiener weights if given. */ +function shrink( + gpu: Gpu, + noisy: Spectrum, + output: Spectrum, + sigma: readonly number[], + channels: number, + pilot?: Spectrum, +) { + const { size } = noisy; + passes(gpu) + .shrink.set({ + params: { size, channels, sigma, wiener: Number(Boolean(pilot)) }, + noisy: noisy.buffer, + pilot: (pilot ?? noisy).buffer, + output: output.buffer, + }) + .dispatch(...groups(size, tile)); +} + +/** Replaces `estimate`'s low frequencies with those of `coarse`, the estimate of the level below, into `output`. */ +function fuse( + gpu: Gpu, + estimate: Spectrum, + coarse: Spectrum, + output: Spectrum, +) { + const { blurRows, blurColumns, add } = passes(gpu); + const average = spectrum(gpu, coarse.size); + const across = spectrum(gpu, coarse.size); + const blurred = spectrum(gpu, coarse.size); + const sizes = { fine: estimate.size, coarse: coarse.size }; + try { + down(gpu, estimate, average); + blurRows + .set({ + sizes, + average: average.buffer, + coarse: coarse.buffer, + across: across.buffer, + }) + .dispatch(...groups(coarse.size)); + blurColumns + .set({ sizes, across: across.buffer, blurred: blurred.buffer }) + .dispatch(...groups(coarse.size)); + add + .set({ + sizes, + fine: estimate.buffer, + blurred: blurred.buffer, + output: output.buffer, + }) + .dispatch(...groups(estimate.size)); + } finally { + average.buffer.dispose(); + across.buffer.dispose(); + blurred.buffer.dispose(); + } +} + +/** + * Denoises one level, taking its low frequencies from the estimate of the level below when there is + * one: thresholding first, then Wiener shrinkage guided by that first estimate. + */ +function filterLevel( + gpu: Gpu, + noisy: Spectrum, + coarse: Spectrum | undefined, + sigma: readonly number[], + channels: number, +) { + const first = spectrum(gpu, noisy.size); + try { + const second = spectrum(gpu, noisy.size); + try { + shrink(gpu, noisy, first, sigma, channels); + if (!coarse) { + shrink(gpu, noisy, second, sigma, channels, first); + return second; + } + fuse(gpu, first, coarse, second); + shrink(gpu, noisy, first, sigma, channels, second); + fuse(gpu, first, coarse, second); + return second; + } catch (error) { + second.buffer.dispose(); + throw error; + } + } finally { + first.buffer.dispose(); + } +} + +/** + * Denoises `pyramid`, finest level first, from its coarsest level up, as if its noise were + * `strength` times what each level measured: each level takes its low frequencies from the + * estimate of the one below it. Resolves to the finest estimate; stops between levels once + * `signal` aborts. + */ +export async function filterPyramid( + gpu: Gpu, + pyramid: readonly Spectrum[], + noise: readonly (readonly number[])[], + strength: number, + channels: number, + signal?: AbortSignal, +): Promise { + const sigma = (l: number) => noise[l].map((value) => value * strength); + const coarsest = pyramid.length - 1; + let estimate = filterLevel( + gpu, + pyramid[coarsest], + undefined, + sigma(coarsest), + channels, + ); + for (let l = coarsest - 1; l >= 0; l--) { + const coarse = estimate; + try { + estimate = filterLevel(gpu, pyramid[l], coarse, sigma(l), channels); + } finally { + coarse.buffer.dispose(); + } + // Each level submits before the next, so other GPU work, such as the display, runs between. + await gpu.gpu.queue.onSubmittedWorkDone(); + if (signal?.aborted) { + estimate.buffer.dispose(); + signal.throwIfAborted(); + } + } + return estimate; +} diff --git a/src/features/noise-reduction/reduce.ts b/src/features/noise-reduction/reduce.ts new file mode 100644 index 00000000..b79e42bd --- /dev/null +++ b/src/features/noise-reduction/reduce.ts @@ -0,0 +1,23 @@ +import type { Gpu } from "vgpu"; +import type { ImageSource, Reduction } from "@/core/image"; +import { reduceImage } from "./image"; +import { reduceMosaic } from "./mosaic"; + +/** + * Reduces a source's noise once at each anchor strength: a RAW photo on its mosaic before + * demosaicing, any other image in its encoded color. + */ +export async function reduceNoise( + gpu: Gpu, + source: ImageSource, + signal: AbortSignal, +): Promise { + const mosaic = source.raw?.mosaic; + if (mosaic) { + return reduceMosaic(gpu, mosaic, signal); + } + if (source.raw) { + throw Error("Only RAW photos with a 2 × 2 mosaic take noise reduction."); + } + return reduceImage(gpu, source, signal); +} diff --git a/src/features/noise-reduction/shrink.wgsl b/src/features/noise-reduction/shrink.wgsl index 61b29fc1..eeff38a6 100644 --- a/src/features/noise-reduction/shrink.wgsl +++ b/src/features/noise-reduction/shrink.wgsl @@ -26,6 +26,8 @@ const kaiser = array(0.438676, 0.681324, 0.87684, 0.985823, 0.985823, 0. struct Params { size: vec2u, + // Components the spectrum holds: four for a mosaic, three for an image. + channels: u32, // Each component's noise deviation. sigma: vec4f, // 0 thresholds; 1 shrinks by the Wiener weights of `pilot`, the first step's estimate. @@ -42,6 +44,13 @@ var basis: array; var sums: array, 1024>; var weights: array, 1024>; +// A texel past the edge reads the image mirrored there, so patches cover the edge as fully as the rest. +fn mirrored(p: vec2i) -> u32 { + let last = vec2i(params.size) - 1; + let q = min(abs(p), 2 * last - p); + return u32(q.y) * params.size.x + u32(q.x); +} + // The 2D DCT of a block, or its inverse, separably along rows and then columns. fn transform(block: ptr>, inverse: bool) { var line: array; @@ -72,9 +81,8 @@ fn transform(block: ptr>, inverse: bool) { basis[t] = select(0.5, 0.35355339, frequency == 0u) * cos(f32((2u * position + 1u) * frequency) * 0.19634954); } let origin = vec2i(cell.xy * tile); - let last = vec2i(params.size) - i32(side); var results: array; - for (var channel = 0u; channel < 4u; channel++) { + for (var channel = 0u; channel < params.channels; channel++) { for (var i = t; i < tile * tile; i += threads) { atomicStore(&sums[i], 0); atomicStore(&weights[i], 0u); @@ -83,14 +91,10 @@ fn transform(block: ptr>, inverse: bool) { let sigma = params.sigma[channel]; for (var i = t; i < span * span; i += threads) { let corner = origin - i32(side - 1u) + vec2i(vec2u(i % span, i / span)); - if (any(corner < vec2i(0)) || any(corner > last)) { - continue; - } var values: array; var guide: array; for (var j = 0u; j < 64u; j++) { - let p = vec2u(corner) + vec2u(j % 8u, j / 8u); - let index = p.y * params.size.x + p.x; + let index = mirrored(corner + vec2i(vec2u(j % 8u, j / 8u))); values[j] = unpack(noisy[index])[channel]; if (params.wiener == 1u) { guide[j] = unpack(pilot[index])[channel]; diff --git a/tests/fixtures/noisy.png b/tests/fixtures/noisy.png new file mode 100644 index 0000000000000000000000000000000000000000..ab3c4d2133cf33c3fc45fdf658e6ae91e635a09e GIT binary patch literal 13577 zcmV+kHTKGhP)lvD1y`|4|PUGV?@fBxUcL%mydKo(yGDi<_Xu<$c&?bC1Wx`za{;$t*#{Pf(( z&HFM?g=$ft=^G505o&yQX#U=;*JQ^{Tb>q*(yFrp7T4@ZBXPznQryd(UF=l9uikBJ zCbH~V$4a<)vWaXrCf(jx`A!E~T=VI(b^9$pOV0$){Aoz00Uea|;vwW{QP)p6IvaV% zlKRLn@-h2|{jvRsKypofyyy+cRtgg!2LE6`oqf`$a$uisG;VLoK4PmHcAh?bq^PTq(?Oq_(;3Cvx7AOq6UP_rCoM0d}@J*I@TTz)P`sCqUgtqZ@L;zxNmaCMJu@ z2KMW9y+3#wy?5@&;7e&JB+6`Jium%hQqDgKg$jksWh$4~T0d&c2F>p#bO9Q+xex-u z8>V4l@E=~O%^1NLQ=eJgN7niq&}hcDl|tvLSa2ej(dU8o4@C4w)_Bgn)daoN@(l<& z!qvDLe@rtC)t>cM)1|SasyuP)pfS)vLB2F4Fox;u&{8GQBB0EqZ0^vpBKj|JCJk*U|v8QV1wrw zJb<`&oHvPrd4?#)qU+OT;uy?zR{_oY5c7=XpS&}VX~I6lbZ&7Oz$gab-;}hs)qNJJ zH8w*M!4L0riMB23O3c?W(|cztU|FyUoo;=jN}ewTNg9ge<13&Z!;$5xZ&J+dx2M3;PN8CSYjyW;KS&IN|PLHTm|K;OLGr0L3u z!)pQX7r*?ur0(vPkGCq8E`-8ZdZ4!t4y+k2?-sw=+%*3zV@I2uIPdKCJZ~h@Y#1qH zj;TB)n8k!p8gon6J9(tUCX-UZk=fkwWsq2ZG_WaVvQ&4d7aV;v;ZB5sK){3q{<<Scw4Fs(jhxs{#;(Mh(#wamYFTznGLtT(YmpWfDm2r3a` zS)simJcordT+aIbn`S8|{b>L$r*>`iiHhAQOb@GV#nFPXzoByh^D2~24DDCACZ$J< zzh6yPe3u{bkN)~EY$y`pHqnW>jcQUp1mnw1dpY%@NK|KG1DEK_xb8` zL3cXpl&!)^GiobfI5R-WI3Tlq@=&f#-Ky<0S`ab&>m**P+eC}Wp2n%c2R)jkOO{(^UgY@(_5?} z>xlI>jjW8pX8FbfKLXU1(+Qs}*-{vyUs>WuwhByYUT~*he0zX_q|LRV1;rPE4pr|Q zt*PN%6fg~_q~1f79Q835X@Zx0T~TPk)B7CT?CF+;{O`h*QW}~nPA9DXk^;LDm!c4r9b}2&*XTDNVlINY ze)Gop^Z+UqL98LaZ@!D%*0j^SdIh;$y+;f0V;aE|cr#dyyV(vsDA$EfiC1oLsVxtw zo7*eG1y6j~@9^*a!52a*G)wlzxIr4yv>@H&Tzg_Jc4ps`new%#*q>ATjLRy!mK#W? zrw}TQmc4ib$TPDQz+O{%uS0`Hzxs@FI|=rrw`4yZ(rf_@MPbw#(GvJ;PRY7Byu>w= zhkR87=PX_c$$2D}0Z)62C|TUdnw?Nyd0L&V!Sbe_k(ES~fQ`kK3upX`pZ~&KS;7+q z*zizq-;Wx!%Y9^8l8(?GKSj3O7P-hQ7IA{Xc<7>|qiGPfejQoP6bFLiaTZxgF97j+ z?d!Y}?W-3zg%^8&_f}!|^Ohk}?6?=H05 z%3-jx*HKQOMhil_s}Ob5CSkFP%9l04KmD`c;Rdiz;&1^(kIjfCH6|^5n)yK5 zdJYUrBj{@HyE_d}QBbxzy(>sKmNPdw;M)yc$-Jrzv@b8y-9D)xgF6zBfA+I~+f(V+ zbujZ@>Bv|QrG_uv!sFd5fF4ztI=gj7P1(Mz-d`O^RMIAZOJR^tk*SUJ)$(jJX2NPx z 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 P00000NkvXXu0mjfTF;R& literal 0 HcmV?d00001 diff --git a/tests/noise-reduction.e2e.ts b/tests/noise-reduction.e2e.ts index d63673a7..8004a646 100644 --- a/tests/noise-reduction.e2e.ts +++ b/tests/noise-reduction.e2e.ts @@ -1,25 +1,35 @@ import { expect, test } from "./fixtures"; import { readImage } from "./images"; -/** Points across the fixture, flat but for its noise: a 96 × 128 image once oriented. */ -const points = Array.from( - { length: 48 }, - (_, i) => [12 + (i % 6) * 14, 12 + Math.floor(i / 6) * 14] as const, -); +/** A grid of points inside a `width` × `height` image, clear of its edges. */ +function grid(width: number, height: number) { + return Array.from( + { length: 48 }, + (_, i) => + [ + Math.round(((i % 6) + 1) * (width / 7)), + Math.round((Math.floor(i / 6) + 1) * (height / 9)), + ] as const, + ); +} -/** Each channel's mean and deviation over the sampled points. */ +const mean = (values: number[]) => + values.reduce((sum, value) => sum + value, 0) / values.length; +const deviation = (values: number[]) => + Math.sqrt(mean(values.map((value) => (value - mean(values)) ** 2))); + +/** Light, the channels' mean, and color, red less blue, across samples of a flat photo. */ function measure(samples: number[][]) { - return [0, 1, 2].map((channel) => { - const values = samples.map((sample) => sample[channel]); - const mean = values.reduce((sum, value) => sum + value, 0) / values.length; - const variance = - values.reduce((sum, value) => sum + (value - mean) ** 2, 0) / - values.length; - return { mean, deviation: Math.sqrt(variance) }; - }); + const light = samples.map(([r, g, b]) => (r + g + b) / 3); + const color = samples.map(([r, , b]) => r - b); + return { + light: mean(light), + lightNoise: deviation(light), + colorNoise: deviation(color), + }; } -test("Noise reduction cleans a noisy RAW before demosaicing and fades back", async ({ +test("Noise reduction cleans a noisy RAW before demosaicing and returns it as decoded at 0", async ({ page, }) => { await page.goto("/"); @@ -29,28 +39,63 @@ test("Noise reduction cleans a noisy RAW before demosaicing and fades back", asy "/tests/fixtures/raw/noisy-bayer.dng", ); await expect( - page.getByRole("slider", { name: "Noise reduction" }), + page.getByRole("slider", { name: "Luminance noise" }), ).toBeVisible(); // Off its As Shot balance, a RAW develops into its own texture, from samples it writes back. await page.evaluate(() => window.openlight.setWhiteBalance({ temperature: 4000, tint: 20 }), ); + const points = grid(96, 128); const noisy = await readImage(page, undefined, points); - await page.evaluate(() => window.openlight.setNoiseReduction(100)); + await page.evaluate(() => + window.openlight.setNoiseReduction({ luminance: 75, color: 75 }), + ); const reduced = await readImage(page, undefined, points); const before = measure(noisy.samples ?? []); const after = measure(reduced.samples ?? []); - for (const [channel, { mean, deviation }] of after.entries()) { - expect(deviation, `Channel ${channel}`).toBeLessThan( - before[channel].deviation / 3, - ); - expect( - Math.abs(mean - before[channel].mean), - `Channel ${channel}`, - ).toBeLessThan(4); - } - // The reduced samples stay, so fading needs no second reduction, and 0 develops the decoded ones. - await page.evaluate(() => window.openlight.setNoiseReduction(0)); + expect(after.lightNoise).toBeLessThan(before.lightNoise / 3); + expect(after.colorNoise).toBeLessThan(before.colorNoise / 3); + expect(Math.abs(after.light - before.light)).toBeLessThan(2); + // The reductions stay, so changing strengths needs no other, and 0 develops the decoded samples. + await page.evaluate(() => + window.openlight.setNoiseReduction({ luminance: 0, color: 0 }), + ); + expect((await readImage(page, undefined, points)).samples).toEqual( + noisy.samples, + ); +}); + +test("Noise reduction cleans an image's color and light apart", async ({ + page, +}) => { + await page.goto("/"); + await page.waitForFunction(() => window.openlight); + await page.evaluate( + (url) => window.openlight.loadUrl(url), + "/tests/fixtures/noisy.png", + ); + const points = grid(64, 96); + const noisy = await readImage(page, undefined, points); + const before = measure(noisy.samples ?? []); + await page.evaluate(() => + window.openlight.setNoiseReduction({ luminance: 0, color: 75 }), + ); + const colorOnly = measure( + (await readImage(page, undefined, points)).samples ?? [], + ); + expect(colorOnly.colorNoise).toBeLessThan(before.colorNoise / 2); + expect(colorOnly.lightNoise).toBeGreaterThan(before.lightNoise * 0.6); + await page.evaluate(() => + window.openlight.setNoiseReduction({ luminance: 75 }), + ); + const both = measure( + (await readImage(page, undefined, points)).samples ?? [], + ); + expect(both.lightNoise).toBeLessThan(before.lightNoise / 2); + expect(Math.abs(both.light - before.light)).toBeLessThan(2); + await page.evaluate(() => + window.openlight.setNoiseReduction({ luminance: 0, color: 0 }), + ); expect((await readImage(page, undefined, points)).samples).toEqual( noisy.samples, ); diff --git a/tests/noise-reduction.test.ts b/tests/noise-reduction.test.ts index afe9c59b..7fddbd40 100644 --- a/tests/noise-reduction.test.ts +++ b/tests/noise-reduction.test.ts @@ -1,4 +1,5 @@ import { expect, test } from "bun:test"; +import { anchorShares, anchors } from "@/features/noise-reduction/model"; import { fitNoise } from "@/features/noise-reduction/noise"; test("noise fits photon and read noise from the flattest blocks, past texture", () => { @@ -14,17 +15,34 @@ test("noise fits photon and read noise from the flattest blocks, past texture", const variances: number[] = []; for (let i = 0; i < 20000; i++) { const mean = random() ** 2 * 4000; - // Each block averages 64 squared details: chi-squared with 64 degrees of freedom around the noise. + // Each block averages 16 squared details: chi-squared with 16 degrees of freedom around the noise. let sum = 0; - for (let k = 0; k < 64; k++) sum += normal() ** 2; - const noise = (32 * mean + 1500) * (sum / 64); + for (let k = 0; k < 16; k++) sum += normal() ** 2; + const noise = (32 * mean + 1500) * (sum / 16); // A third of the blocks hold texture as well as noise. const texture = i % 3 === 0 ? (1 + 10 * random()) * noise : 0; means.push(mean); variances.push(noise + texture); } - // Textured blocks lift each bin's quietest quarter a little, never to their own level. + // Texture in every bin lifts its quietest tenth a little, never to the textured blocks' level. const { gain, floor } = fitNoise(means, variances); - expect(Math.abs(gain / 32 - 1)).toBeLessThan(0.1); - expect(Math.abs(floor / 1500 - 1)).toBeLessThan(0.1); + expect(Math.abs(gain / 32 - 1)).toBeLessThan(0.15); + expect(Math.abs(floor / 1500 - 1)).toBeLessThan(0.15); +}); + +test("noise reduction amounts scale the noise filtered between the anchors", () => { + expect(anchorShares(0)).toEqual([0, 0, 0]); + expect(anchorShares(10)).toEqual([0.4, 0, 0]); + expect(anchorShares(25)).toEqual([1, 0, 0]); + expect(anchorShares(37.5)).toEqual([0.5, 0.5, 0]); + expect(anchorShares(75)).toEqual([0, 0.5, 0.5]); + expect(anchorShares(100)).toEqual([0, 0, 1]); + // Shares blend anchors linearly, so the noise filtered follows the amount on one line. + for (const amount of [10, 25, 40, 60, 90]) { + const strength = anchorShares(amount).reduce( + (sum, share, k) => sum + share * anchors[k], + 0, + ); + expect(strength).toBeCloseTo((amount / 100) * anchors[2], 10); + } }); From b9ca34800bfb6125dc5fc449dd9d4cc22b61bc24 Mon Sep 17 00:00:00 2001 From: Rodrigo Date: Mon, 5 Oct 2026 15:12:24 +0200 Subject: [PATCH 3/4] Keep a RAW photo's modeled noise at its finest level, and sample half-size color by its own texels Defines once in the image module which sources take noise reduction and what none is, gives a coarse level without flat blocks white noise's deviation, and aligns half-size color with odd-sized images. --- src/app/editor/adjust/index.tsx | 2 +- src/core/image/index.ts | 6 +++++ src/core/renderer/index.ts | 24 +++++++++---------- .../noise-reduction/compose-image.wgsl | 3 ++- src/features/noise-reduction/controls.tsx | 14 +++++------ src/features/noise-reduction/edits.ts | 10 +++----- src/features/noise-reduction/image.ts | 6 ++--- src/features/noise-reduction/model.ts | 9 +------ src/features/noise-reduction/mosaic.ts | 7 +++--- src/features/noise-reduction/pyramid.ts | 12 ++++++---- 10 files changed, 43 insertions(+), 50 deletions(-) diff --git a/src/app/editor/adjust/index.tsx b/src/app/editor/adjust/index.tsx index 2698f022..3a97fa12 100644 --- a/src/app/editor/adjust/index.tsx +++ b/src/app/editor/adjust/index.tsx @@ -306,7 +306,7 @@ function AdjustmentDials({ /> ); } - // A RAW photo's noise reduction follows its tone, with a spinner while the first reduction runs. + // Noise reduction follows the tone, with a spinner while the first reduction runs. return ( ; + +/** Whether a source takes noise reduction: a RAW photo through its 2 × 2 mosaic, any other image itself. */ +export function reducible(source: ImageSource) { + return !source.raw || Boolean(source.raw.mosaic); +} diff --git a/src/core/renderer/index.ts b/src/core/renderer/index.ts index 4496d1d1..0565067e 100644 --- a/src/core/renderer/index.ts +++ b/src/core/renderer/index.ts @@ -11,11 +11,13 @@ import { type Scene, walkLayers, } from "@/core/document"; -import type { - ImageSource, - NoiseReduction, - Reduction, - WhiteBalance, +import { + type ImageSource, + type NoiseReduction, + noNoiseReduction, + type Reduction, + reducible, + type WhiteBalance, } from "@/core/image"; import type { Point } from "@/core/image/frame"; import { createFieldStore, type FieldLattice } from "./fields"; @@ -134,8 +136,6 @@ function reductionFor(density: number) { /** How many reductions keep their proxy and intermediates, each a quarter of the photo at most. */ const keptReductions = 4; -const unreduced: NoiseReduction = { luminance: 0, color: 0 }; - function sameReduction(a: NoiseReduction, b: NoiseReduction) { return a.luminance === b.luminance && a.color === b.color; } @@ -200,10 +200,8 @@ export function createRenderer( /** Mask coverage the graph rendered for the overlay and thumbnails, by layer ID. */ let shown = new Map(); let balance = resource.raw?.asShot; - /** A RAW photo reduces noise on its 2 × 2 mosaic, and any other image on itself. */ - const reducible = !resource.raw || Boolean(resource.raw.mosaic); /** The noise reduction the development applies. */ - let applied = unreduced; + let applied = noNoiseReduction; /** Renders the reduction at the applied strengths, for the reduction it composes. */ let composer: | (ReturnType & { of: Reduction }) @@ -447,9 +445,9 @@ export function createRenderer( } /** The noise reduction a scene asks of the image layer, none where the source takes none. */ function wantedReduction(scene: Scene) { - return reducible - ? (scene.layers[0].noiseReduction ?? unreduced) - : unreduced; + return reducible(resource) + ? (scene.layers[0].noiseReduction ?? noNoiseReduction) + : noNoiseReduction; } /** The source reduced at `strengths`, through this renderer's composer for `reduction`. */ function composeReduction(reduction: Reduction, strengths: NoiseReduction) { diff --git a/src/features/noise-reduction/compose-image.wgsl b/src/features/noise-reduction/compose-image.wgsl index cc1c8bb4..32580881 100644 --- a/src/features/noise-reduction/compose-image.wgsl +++ b/src/features/noise-reduction/compose-image.wgsl @@ -22,7 +22,8 @@ fn toward(value: vec3f, light: f32, color: vec2f, share: vec4f) -> vec3f { let p = vec2i(position.xy); let texel = textureLoad(source, p, 0); let value = toOpponent(encode(texel.rgb)); - let uv = position.xy / vec2f(textureDimensions(source)); + // Each half-size texel averages 2 × 2 pixels, whatever the image's parity. + let uv = position.xy * 0.5 / vec2f(textureDimensions(weakColor)); var result = value; result += toward(value, textureLoad(weakLight, p, 0).x, textureSampleLevel(weakColor, linearSampler, uv, 0.0).xy, shares[0]); result += toward(value, textureLoad(measuredLight, p, 0).x, textureSampleLevel(measuredColor, linearSampler, uv, 0.0).xy, shares[1]); diff --git a/src/features/noise-reduction/controls.tsx b/src/features/noise-reduction/controls.tsx index 143c90bb..3b70d6b3 100644 --- a/src/features/noise-reduction/controls.tsx +++ b/src/features/noise-reduction/controls.tsx @@ -4,10 +4,9 @@ import { useEffect, useState } from "react"; import { useGpu } from "vgpu-react"; import type { Parameter } from "@/components/editor/parameter"; import { useDocument } from "@/components/editor/session"; -import type { Layer } from "@/core/document"; -import type { ImageSource } from "@/core/image"; +import type { ImageLayer, Layer } from "@/core/document"; +import { type ImageSource, noNoiseReduction, reducible } from "@/core/image"; import { setNoiseReduction } from "./edits"; -import { defaultNoiseReduction, reducible } from "./model"; import { reduceNoise } from "./reduce"; /** @@ -47,13 +46,14 @@ export function useNoiseReduction( ): { parameters: Parameter[]; reducing: boolean } | undefined { const document = useDocument(); const image = layer.kind === "image" ? layer : undefined; - const source = image && document.resources.get(image.source); - const strengths = image?.noiseReduction ?? defaultNoiseReduction; + const resource = image && document.resources.get(image.source); + const source = resource && reducible(resource) ? resource : undefined; + const strengths = image?.noiseReduction ?? noNoiseReduction; const reducing = useReducing( source, strengths.luminance > 0 || strengths.color > 0, ); - if (!source || !reducible(source)) { + if (!source) { return undefined; } return { @@ -71,7 +71,7 @@ export function useNoiseReduction( } /** The noise reduction sliders, with a spinner beside the first's label while the first reduction runs. */ -export function NoiseReductionControls({ layer }: { layer: Layer }) { +export function NoiseReductionControls({ layer }: { layer: ImageLayer }) { const noiseReduction = useNoiseReduction(layer); if (!noiseReduction) { return null; diff --git a/src/features/noise-reduction/edits.ts b/src/features/noise-reduction/edits.ts index d6437023..0ea75b10 100644 --- a/src/features/noise-reduction/edits.ts +++ b/src/features/noise-reduction/edits.ts @@ -1,11 +1,7 @@ import { type EditorDocument, editLayer } from "@/core/document"; -import type { NoiseReduction } from "@/core/image"; +import { type NoiseReduction, noNoiseReduction, reducible } from "@/core/image"; import { change, parse } from "@/lib/parse"; -import { - defaultNoiseReduction, - noiseReductionSchema, - reducible, -} from "./model"; +import { noiseReductionSchema } from "./model"; const noiseReductionChange = change(noiseReductionSchema); @@ -28,7 +24,7 @@ export function setNoiseReduction( throw Error("Select the image layer."); } const noiseReduction = { - ...(layer.noiseReduction ?? defaultNoiseReduction), + ...(layer.noiseReduction ?? noNoiseReduction), ...strengths, }; return { ...layer, noiseReduction }; diff --git a/src/features/noise-reduction/image.ts b/src/features/noise-reduction/image.ts index 3f34ace4..8b250bfa 100644 --- a/src/features/noise-reduction/image.ts +++ b/src/features/noise-reduction/image.ts @@ -19,6 +19,7 @@ import { buildPyramid, filterPyramid, groups, + half, measureLevels, type Size, spectrum, @@ -111,10 +112,7 @@ export async function reduceImage( try { const anchor = { light: target(gpu, { size, format: "r16float" }), - color: target(gpu, { - size: [Math.ceil(size[0] / 2), Math.ceil(size[1] / 2)], - format: "rg16float", - }), + color: target(gpu, { size: half(size), format: "rg16float" }), }; reduced.push(anchor); const params = { size, spectrum: estimate.buffer }; diff --git a/src/features/noise-reduction/model.ts b/src/features/noise-reduction/model.ts index c00372f7..76d64373 100644 --- a/src/features/noise-reduction/model.ts +++ b/src/features/noise-reduction/model.ts @@ -1,14 +1,7 @@ import { z } from "zod/mini"; -import type { ImageSource, NoiseReduction } from "@/core/image"; +import type { NoiseReduction } from "@/core/image"; import { range } from "@/lib/parse"; -export const defaultNoiseReduction: NoiseReduction = { luminance: 0, color: 0 }; - -/** Whether a source takes noise reduction: a RAW photo through its 2 × 2 mosaic, any other image itself. */ -export function reducible(source: ImageSource) { - return !source.raw || Boolean(source.raw.mosaic); -} - /** How much noise reduction removes from light and from color, each 0 to 100; 0 keeps it. */ export const noiseReductionSchema = z.object({ luminance: range(0, 100), diff --git a/src/features/noise-reduction/mosaic.ts b/src/features/noise-reduction/mosaic.ts index 0a1b242f..3c0374ef 100644 --- a/src/features/noise-reduction/mosaic.ts +++ b/src/features/noise-reduction/mosaic.ts @@ -58,15 +58,14 @@ function noiseParams( type NoiseParams = ReturnType; -/** Each anchor's change to the samples, a half-size target of each 2 × 2 cell's four by position. */ +/** Composes replacement samples at any strengths from each anchor's change, into a target of its own. */ function createComposer( gpu: Gpu, mosaic: Mosaic, noise: NoiseParams, [weak, measured, strong]: readonly Target[], - size: Size, ) { - const output = target(gpu, { size, format: "rgba16float" }); + const output = target(gpu, { size: weak.size, format: "rgba16float" }); return { render(strengths: NoiseReduction) { const pass = passes(gpu).compose.set({ @@ -144,7 +143,7 @@ export async function reduceMosaic( for (const level of pyramid) level.buffer.dispose(); } return { - compose: () => createComposer(gpu, mosaic, noise, changes, size), + compose: () => createComposer(gpu, mosaic, noise, changes), dispose() { for (const change of changes) change.color.dispose(); }, diff --git a/src/features/noise-reduction/pyramid.ts b/src/features/noise-reduction/pyramid.ts index cbe581c1..c3ba86b2 100644 --- a/src/features/noise-reduction/pyramid.ts +++ b/src/features/noise-reduction/pyramid.ts @@ -45,7 +45,7 @@ export function groups([width, height]: Size, side = 8) { return [Math.ceil(width / side), Math.ceil(height / side)] as const; } -function half([width, height]: Size): Size { +export function half([width, height]: Size): Size { return [Math.ceil(width / 2), Math.ceil(height / 2)]; } @@ -117,15 +117,17 @@ export async function measureLevels( for (const level of pyramid) measured.push(await measureSpread(gpu, level)); const base = measured[0].map(finest); return measured.map((level, l) => - level.map((value, c) => - l ? Math.min(value || Infinity, margin * base[c] * 2 ** -l) : base[c], - ), + level.map((value, c) => { + const white = base[c] * 2 ** -l; + if (!l) return base[c]; + return value ? Math.min(value, margin * white) : white; + }), ); } /** The finest level's noise for a noise model that predicts unit noise, which a lower measure corrects. */ export function modeled(measured: number) { - return Math.min(measured || 1, margin); + return Math.min(measured || 1, 1); } /** One step of the patch filter over `noisy` into `output`, shrinking by `pilot`'s Wiener weights if given. */ From 94a4b645f0874b34a1f6192669f649cc485dbae4 Mon Sep 17 00:00:00 2001 From: Rodrigo Date: Tue, 6 Oct 2026 02:25:03 +0200 Subject: [PATCH 4/4] Reduce noise in one run, and keep an image's spectra at half size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A photo's reduction now runs once, at the noise it measures, with patches on every other texel, instead of three times, so it takes far less memory and time. Strengths remove the difference between the photo and its reduction by each 3 × 3 neighborhood, in noise deviations: noise goes evenly and detail stays. Other images filter light through 2 × 2 Haar components and color apart, each at half size, so large photos fit the GPU's storage binding instead of failing and turning gray, and they stay in their own format. --- API.md | 2 +- ARCHITECTURE.md | 2 +- src/core/image/index.ts | 5 +- src/core/renderer/index.ts | 2 +- src/features/noise-reduction/algorithm.md | 22 +- .../noise-reduction/compose-image.wgsl | 76 ++++--- src/features/noise-reduction/compose.wgsl | 43 ++-- .../noise-reduction/finish-color.wgsl | 14 +- .../noise-reduction/finish-light.wgsl | 7 +- src/features/noise-reduction/image.ts | 195 +++++++++++------- src/features/noise-reduction/limit.wgsl | 20 ++ src/features/noise-reduction/model.ts | 48 +---- src/features/noise-reduction/mosaic.ts | 111 +++++----- src/features/noise-reduction/opponent.wgsl | 9 + .../noise-reduction/prepare-image.wgsl | 32 ++- src/features/noise-reduction/pyramid.ts | 131 +++++++----- src/features/noise-reduction/reduce.ts | 4 +- src/features/noise-reduction/shrink.wgsl | 44 ++-- tests/noise-reduction.e2e.ts | 2 + tests/noise-reduction.test.ts | 18 -- 20 files changed, 448 insertions(+), 339 deletions(-) create mode 100644 src/features/noise-reduction/limit.wgsl diff --git a/API.md b/API.md index 110904ad..006329f1 100644 --- a/API.md +++ b/API.md @@ -72,7 +72,7 @@ Create an empty Paint layer with `addLayer("paint")`. `addPaintStroke(id, stroke For RAW sources, `setWhiteBalance({ temperature, tint })` sets absolute Kelvin and DNG tint, preserving unspecified values. Temperature accepts 2000–25000 K and tint accepts -150–150, extending either range to include the file's As Shot value. `setWhiteBalance()` restores that value (the decoder's daylight fallback if camera multipliers are unavailable). Non-RAW sources and invalid values throw. Incremental temperature/tint remain separate RGB adjustments. -`setNoiseReduction({ luminance, color })` reduces the photo's noise before its adjustments, keeping the strength it omits: a RAW photo's on its Bayer mosaic before demosaicing, any other image's in its encoded color. Each strength goes from 0, none, to 100; near 50 the result comes closest to the clean photo, and higher smooths further. Luminance reduces noise in light, color in color, so color alone leaves a monochrome grain. A photo's first reduction runs once on the GPU, at three strengths, and takes a moment while the editor keeps showing the photo as it was; later strengths, white balance, and every other edit reuse it, so they apply at once. The original comparison shows the photo as decoded. RAW files without a 2 × 2 mosaic, such as Fujifilm X-Trans, and invalid values throw. +`setNoiseReduction({ luminance, color })` reduces the photo's noise before its adjustments, keeping the strength it omits: a RAW photo's on its Bayer mosaic before demosaicing, any other image's in its encoded color. Each strength goes from 0, none, to 100; near 50 the result comes closest to the clean photo, and higher smooths further. Luminance reduces noise in light, color in color, so color alone leaves a monochrome grain. A photo's first reduction runs once on the GPU and takes a moment while the editor keeps showing the photo as it was; later strengths, white balance, and every other edit reuse it, so they apply at once. The original comparison shows the photo as decoded. RAW files without a 2 × 2 mosaic, such as Fujifilm X-Trans, and invalid values throw. `autoWhiteBalance()` neutralizes the photo's color cast as one edit and resolves once it is made. The GPU measures the cast of the source before any edit, as its edges' average color, so calling it again gives the same balance. A RAW photo's white balance moves from As Shot; any other photo's incremental temperature and tint are replaced. When another photo opens first, it resolves without an edit. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b7f54f15..68d9c6c5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -95,4 +95,4 @@ Dependencies point downward. Features do not import each other; `app/` connects - Renders reduce the source by the largest whole factor that still gives every device pixel a texel, a proxy, in gestures or not, so the photo's own size renders once the display shows more than half a device pixel per source pixel; the main canvas writes that density into the preview state, and the provider renders again when it changes. A proxy that lacks a Remove patch's field synthesizes it apart, outside a gesture: the layers below the patch render in full for the field alone, and the proxy composes again with it, so the display never shows a full render for it. The export view renders its own full snapshot, which its preview and the saved file both encode. Every render image carries `scale`, its source pixels per texel; shaders that take document coordinates or radii apply it. - The scene's frame maps output to source in one homogeneous transform, `frameTransform` in `core/image/frame.ts`: crop, rotation, flips, and scale, then the perspective correction about the source's center. Layers, paint, masks, and patches stay in source pixels, so correcting perspective leaves them in place; `transformImage` resamples the composite once, the display draws the comparison and the mask overlay from the source through the same transform, and a frame that changes nothing adds no pass. The proxy follows the frame's strongest magnification, perspective's included. `lib/projective.ts` holds the plane projective geometry the frame, the Crop tool's coverage, and the canvas mapping share. - TIFF and camera RAW decode through `raw-webgpu`; OpenLight adapts its resources to document ownership. A RAW source keeps its as-shot development, which a renderer shows as long as that balance is selected and noise reduction is off, and the original comparison always shows; another balance, or noise reduction, develops into the renderer's own texture. -- Noise reduction works before the image layer's adjustments, on a RAW photo's 2 × 2 mosaic before demosaicing or on any other image's encoded color, at the layer's luminance and color strengths. `features/noise-reduction` reduces the noise on the GPU at three strengths, once per source, the first time a renderer or control asks; the image source keeps the reduction until it closes and shares it with every renderer, and each renderer composes it at the layer's strengths in one pass into a texture of its own. A RAW photo develops the composed samples: the package develops only its own sensor texture, so `core/image/decode/raw.ts` moves the decoded samples to a copy the first time and, right before each development, writes the samples that development takes into that texture, in the same task, so no other renderer's write falls between. Any other image's composition takes the composed image in place of the source. Changing a strength composes and develops again, as a white balance does, without reducing noise again; a newer request need not wait for a first reduction. `app/editor/renderer.ts` gives the renderer the feature's reduction, so the core imports no feature. See [the algorithm](src/features/noise-reduction/algorithm.md). +- Noise reduction works before the image layer's adjustments, on a RAW photo's 2 × 2 mosaic before demosaicing or on any other image's encoded color, at the layer's luminance and color strengths. `features/noise-reduction` reduces the noise on the GPU once per source, the first time a renderer or control asks; the image source keeps the reduction until it closes and shares it with every renderer, and each renderer composes it at the layer's strengths in one pass into a texture of its own. A RAW photo develops the composed samples: the package develops only its own sensor texture, so `core/image/decode/raw.ts` moves the decoded samples to a copy the first time and, right before each development, writes the samples that development takes into that texture, in the same task, so no other renderer's write falls between. Any other image's composition takes the composed image in place of the source. Changing a strength composes and develops again, as a white balance does, without reducing noise again; a newer request need not wait for a first reduction. `app/editor/renderer.ts` gives the renderer the feature's reduction, so the core imports no feature. See [the algorithm](src/features/noise-reduction/algorithm.md). diff --git a/src/core/image/index.ts b/src/core/image/index.ts index 198d18c4..59a8987a 100644 --- a/src/core/image/index.ts +++ b/src/core/image/index.ts @@ -4,10 +4,7 @@ export type WhiteBalance = { temperature: number; tint: number }; /** How much noise reduction removes from light and from color, each 0 to 100; 0 keeps it. */ export type NoiseReduction = { luminance: number; color: number }; export const noNoiseReduction: NoiseReduction = { luminance: 0, color: 0 }; -/** - * A source's noise, reduced once at a few strengths, which each renderer composes at any strengths - * at once. - */ +/** A source's noise, reduced once, which each renderer composes at any strengths at once. */ export type Reduction = { /** A composer with its own output, for one renderer. */ compose(): { diff --git a/src/core/renderer/index.ts b/src/core/renderer/index.ts index 0565067e..df657223 100644 --- a/src/core/renderer/index.ts +++ b/src/core/renderer/index.ts @@ -153,7 +153,7 @@ export type RendererOptions = { field?: (id: string) => RemoveField | undefined; /** Receives each Remove field the renderer synthesizes, read back; without it nothing is read back. */ saveField?: (id: string, field: RemoveField) => void; - /** Reduces a source's noise once at a few strengths, for the image layer's noise reduction. */ + /** Reduces a source's noise once, for the image layer's noise reduction. */ reduceNoise?: ( source: ImageSource, signal: AbortSignal, diff --git a/src/features/noise-reduction/algorithm.md b/src/features/noise-reduction/algorithm.md index 7b4c9bc2..80abc07e 100644 --- a/src/features/noise-reduction/algorithm.md +++ b/src/features/noise-reduction/algorithm.md @@ -1,24 +1,28 @@ # Noise reduction -`reduceNoise(gpu, source, signal)` reduces a photo's noise once, at three strengths, before its adjustments: a RAW photo on its Bayer mosaic before the GPU demosaics it, any other image in its encoded color. It runs without React, model weights, or workers, entirely in WGSL but for small fits in TypeScript. The source keeps the result until it closes, and each renderer composes it at the image layer's luminance and color strengths in one pass, so moving either slider, or editing any layer above, never reduces noise again. +`reduceNoise(gpu, source, signal)` reduces a photo's noise once, before its adjustments: a RAW photo on its Bayer mosaic before the GPU demosaics it, any other image in its encoded color. It runs without React, model weights, or workers, entirely in WGSL but for small fits in TypeScript. The source keeps the result until it closes, and each renderer composes it at the image layer's luminance and color strengths in one pass, so moving either slider, or editing any layer above, never reduces noise again. 1. **Noise model, RAW.** Every 16 × 16 block of sensor pixels gives each cell position its mean and the variance of its diagonal Haar detail, which flat blocks owe to noise alone. Over 40 equal-count bins along the signal, the quietest tenth of each bin gives a point, and a weighted line through them is the position's Poisson-Gaussian model, variance = gain · signal + floor. Texture only adds variance, so the fit runs again without points more than 30 % above the line, which follows the noise where a detailed photo has few flat blocks. Positions fit apart: some cameras scale a channel digitally, which scales its noise too. -2. **Spectrum.** For a RAW photo, the generalized Anscombe transform, measured from its value at black so half floats stay precise, makes each position's noise unit Gaussian, and an orthonormal transform turns red, the two greens, and blue into light, green against magenta, red against blue, and the greens' difference. Samples below the transform's lowest value, such as dead pixels, are clamped there. Any other image is filtered in the sRGB transfer's encoding, where noise varies far less with light than in linear, as light and two color differences. -3. **Pyramid.** Averaging 2 × 2 texels builds up to six levels, down to 32 texels. Each level measures each component's noise from its flattest blocks. For a RAW photo, the finest level's noise is the model's, which a lower measure corrects; for an image, its measure, never below 8-bit rounding. A coarser level takes its own measure only up to 15 % over what white noise would leave there: lossy compression correlates noise and raises it at coarse levels, while texture, which a coarse level's flattest blocks still hold, raises a measure far more. -4. **Filter.** From the coarsest level up, sliding 8 × 8 DCT denoising runs in two steps: hard thresholding at 3 deviations, 3.5 for the greens' difference, then Wiener shrinkage by the first estimate, with color shrinking twice and the greens' difference eight times as hard as light. Every patch position counts, mirrored past the image's edges so edges and corners get as many as the rest; each output texel gathers the 64 patches over it in workgroup memory, weighted by how little of each survived and by a Kaiser window. A patch's DC stays whole, so every level stays unbiased, except the greens' difference: both greens see the same light, so its mean is noise or imbalance, which demosaicing would draw as a maze. After each step, the upsampled difference between the coarser estimate and the step's own 2 × 2 averages, blurred at the coarse size, replaces the step's low frequencies: broad noise the 8 × 8 patches cannot tell from the image goes, the coarser level's finest frequencies, where its patches ring at edges, stay out, and where the two levels agree nothing changes. -5. **Anchors.** The filter runs at three strengths, 0.35, 0.7, and 1.4 times the measured noise, sharing the pyramid and its measures. For a RAW photo each keeps, through the closed-form inverse that is unbiased for Poisson-Gaussian noise (Mäkitalo and Foi), its change to the decoded samples; for an image, its light at full size and its color at half, which a reduced image's color needs no more of. -6. **Composing.** An amount from 0 to 100 scales the filtered noise linearly: 25, 50, and 100 reach the anchors, and an amount between two blends them, or the weakest with the photo as decoded. Luminance weighs the light component, color the two color differences, and the stronger of the two the greens' difference. For a RAW photo, each anchor's change splits into those components along the transform's slope where the strongest anchor puts each sample, so the split follows the filter's, and the weighted parts add to the decoded samples: an anchor at both strengths gives that anchor exactly, and 0 the samples as decoded. +2. **Spectrum.** For a RAW photo, the generalized Anscombe transform, measured from its value at black so half floats stay precise, makes each position's noise unit Gaussian, and an orthonormal transform turns red, the two greens, and blue into light, green against magenta, red against blue, and the greens' difference. Samples below the transform's lowest value, such as dead pixels, are clamped there. Any other image is filtered in the sRGB transfer's encoding, where noise varies far less with light than in linear, as light and two color differences, each at half size and apart: light as each 2 × 2 cell's Haar transform, its mean and three differences, and color as each cell's sum. Every spectrum then takes 8 bytes per 2 × 2 pixels, so the largest texture a device takes fits one storage binding. +3. **Pyramid.** Averaging 2 × 2 texels builds up to six levels, down to 32 texels. Each level measures each component's noise from its flattest blocks. For a RAW photo, the finest level's noise is the model's, which a lower measure corrects. For an image, never below 8-bit rounding, light takes the least of its four measures that find any noise, since white noise holds the same deviation in each and texture raises a coarse cell's mean most, and each color difference its own. A coarser level takes its own measure only up to 15 % over what white noise would leave there: lossy compression correlates noise and raises it at coarse levels, while texture, which a coarse level's flattest blocks still hold, raises a measure far more. +4. **Filter.** From the coarsest level up, sliding 8 × 8 DCT denoising runs in two steps: hard thresholding at 3 deviations, 3.5 for the greens' difference, then Wiener shrinkage by the first estimate, with color shrinking twice and the greens' difference eight times as hard as light. Patches start on every other texel, mirrored past the image's edges so edges and corners get as many as the rest; each output texel gathers the 16 patches over it in workgroup memory, weighted by how little of each survived and by a Kaiser window. That is a quarter of the patches of every texel's, within 0.1 dB of them on test photos. A patch's DC stays whole where a component holds a level, so every level stays unbiased, but not for the greens' difference: both greens see the same light, so its mean is noise or imbalance, which demosaicing would draw as a maze. After each step, the upsampled difference between the coarser estimate and the step's own 2 × 2 averages, blurred at the coarse size, replaces the step's low frequencies: broad noise the 8 × 8 patches cannot tell from the image goes, the coarser level's finest frequencies, where its patches ring at edges, stay out, and where the two levels agree nothing changes. +5. **Reduction.** The filter runs once, at the measured noise. For a RAW photo it keeps, through the closed-form inverse that is unbiased for Poisson-Gaussian noise (Mäkitalo and Foi), its change to the decoded samples; for an image, its light at full size and its color at half, which a reduced image's color needs no more of. +6. **Composing.** An amount from 0 to 100 sets how much of each component's difference between the photo and its reduction goes, in noise deviations: none at 0, all at 100. Between, a component loses nearly all of a difference whose 3 × 3 neighborhood varies by less than a limit, and about the limit's worth of one that varies more: noise, which varies by one deviation, goes evenly, while detail, which varies more, mostly stays. Weighing neighborhoods rather than single values keeps the few noise values that stand out from staying as dots. At an amount a, the limit is a / (100 − a) times its value at 50: one deviation for light, three for color, which holds little detail, and 0.7 for the greens' difference, which the filter shrinks hardest. Luminance sets light, color the two color differences, and the stronger of the two the greens' difference. For a RAW photo, differences count along the transform's slope where the reduction puts each sample, as the filter saw them. Patches are filtered alone. Grouping similar ones, as BM3D does, ranks candidates by a distance that noise dominates in dark areas; along edges it groups aligned patches whose average keeps a profile of noise, which showed as streaks. Hot pixels are left alone: telling one from a fine highlight by its neighbors removed far more detail from sharp photos than it saved. -On synthetic RAW mosaics made from a sharp photo, at three noise levels, an amount near 50 comes closest to the clean photo, 2 to 3.5 dB nearer than the noisy one; 100 smooths further, which very noisy photos may want. An image with added sRGB noise gains 5 to 7 dB near 50. +On synthetic RAW mosaics made from a sharp photo, at three noise levels, an amount near 50 comes closest to the clean photo, 2 to 3 dB nearer than the noisy one, within 0.3 dB of the best a single strength reaches; 100, the whole reduction, smooths further, which very noisy photos may want. An image with added sRGB noise gains 5 to 6 dB near 50. + +## Cost + +Reducing runs the pyramid once. At its peak, the finest level's noisy spectrum, its two step estimates, the coarser estimate, and the blur between them take about 8 bytes per pixel; coarser levels free as soon as they are filtered, and an image's color waits through light's at 1 byte per pixel. Afterwards a RAW photo keeps 2 bytes per pixel for its change and 2 for its decoded samples, and each renderer 2 for its composed samples, besides the development it already makes for a white balance. An image keeps 3 bytes per pixel for its light and color, and each renderer an image in the source's own format: 4 bytes per pixel for 8-bit sources, dithered so smooth reductions don't band. ## Limits -Detail with less signal than noise is gone for any filter: in the darkest areas of a high-ISO photo, strong amounts leave smooth rather than textured surfaces. Very fine repeated texture near the noise level softens. A detailed photo with little noise can still measure more noise than it has, which a lower amount corrects. The RAW model assumes Poisson-Gaussian noise; row or column banding, which some sensors add, passes through. An image's noise has no model, and JPEG block edges are not noise. X-Trans and linear DNG files have no noise reduction. An image reduces at full size in three components, so it costs about four times a RAW photo of as many pixels. +Detail with less signal than noise is gone for any filter: in the darkest areas of a high-ISO photo, strong amounts leave smooth rather than textured surfaces. Very fine repeated texture near the noise level softens. A detailed photo with little noise can still measure more noise than it has, which a lower amount corrects. The RAW model assumes Poisson-Gaussian noise; row or column banding, which some sensors add, passes through. An image's noise has no model, and JPEG block edges are not noise. Filtering an image's light through 2 × 2 Haar components, which keeps it at 2 bytes per pixel, comes about 0.6 dB short of filtering it at full size. X-Trans and linear DNG files have no noise reduction. ## Verification -`tests/noise-reduction.test.ts` checks the noise fit against blocks of known noise, a third of them textured, and that amounts scale the filtered noise linearly. `tests/noise-reduction.e2e.ts` opens a RAW fixture with seeded Poisson-Gaussian noise and a noisy PNG, checks that their noise falls well below the original while their mean holds, that color reduces apart from light, and that 0 gives back the decoded pixels. +`tests/noise-reduction.test.ts` checks the noise fit against blocks of known noise, a third of them textured. `tests/noise-reduction.e2e.ts` opens a RAW fixture with seeded Poisson-Gaussian noise and a noisy PNG, checks that their noise falls well below the original while their mean holds, that color reduces apart from light and keeps the image's color, and that 0 gives back the decoded pixels. References: [Yu and Sapiro, DCT image denoising](https://www.ipol.im/pub/art/2011/ys-dct/), [Dabov et al., BM3D](https://webpages.tuni.fi/foi/GCF-BM3D/), [Pierazzo, Morel, and Facciolo, multiscale DCT denoising](https://www.ipol.im/pub/art/2017/201/), [Foi et al., single-image Poisson-Gaussian noise fitting](https://webpages.tuni.fi/foi/papers/Foi-PoissonianGaussianClippedRaw-2007-IEEE_TIP.pdf), [Mäkitalo and Foi, the unbiased inverse](https://webpages.tuni.fi/foi/papers/OptGenAnscombeInverse-doublecolumn-preprint.pdf). diff --git a/src/features/noise-reduction/compose-image.wgsl b/src/features/noise-reduction/compose-image.wgsl index 32580881..3d417fb1 100644 --- a/src/features/noise-reduction/compose-image.wgsl +++ b/src/features/noise-reduction/compose-image.wgsl @@ -1,32 +1,60 @@ -// An image at chosen strengths: its encoded light and color differences, each moved toward every -// anchor's by that anchor's share, then decoded. Shares that are all zero give the image exactly. +// An image at chosen strengths: its encoded light and color differences less part of their difference +// from the reduction's, in noise deviations, which the limits of light and color and the difference +// over the 3 × 3 pixels around set; then decoded, and dithered when the output keeps 8 bits, so +// smooth reductions don't band. +import { Limits, removed } from "./limit.wgsl"; import { decode, encode, fromOpponent, toOpponent } from "./opponent.wgsl"; -// Per anchor, weakest first: its share of light, then of color. -@group(0) @binding(0) var shares: array; -@group(0) @binding(1) var source: texture_2d; -@group(0) @binding(2) var linearSampler: sampler; -@group(0) @binding(3) var weakLight: texture_2d; -@group(0) @binding(4) var measuredLight: texture_2d; -@group(0) @binding(5) var strongLight: texture_2d; +struct Params { + // The noise deviation of each pixel's light and two color differences. + noise: vec4f, + // The output's step in the encoding, or 0 when it keeps floats. + quantum: f32, +} + +// Of light and the two color differences. +@group(0) @binding(0) var limits: Limits; +@group(0) @binding(1) var params: Params; +@group(0) @binding(2) var source: texture_2d; +@group(0) @binding(3) var linearSampler: sampler; +@group(0) @binding(4) var light: texture_2d; // Color at half size, sampled between its texels. -@group(0) @binding(6) var weakColor: texture_2d; -@group(0) @binding(7) var measuredColor: texture_2d; -@group(0) @binding(8) var strongColor: texture_2d; +@group(0) @binding(5) var color: texture_2d; -fn toward(value: vec3f, light: f32, color: vec2f, share: vec4f) -> vec3f { - return vec3f(share.x * (light - value.x), share.y * (color - value.yz)); +// Uniform in [-0.5, 0.5), from a hash of the pixel's position (PCG, Jarzynski and Olano, 2020). +fn dither(p: vec2u) -> f32 { + var state = p.x * 747796405u + p.y * 2891336453u + 2891336453u; + state = ((state >> ((state >> 28u) + 4u)) ^ state) * 277803737u; + return f32((state >> 22u) ^ state) / 4294967296.0 - 0.5; } -@fragment fn fs_main(@builtin(position) position: vec4f) -> @location(0) vec4f { - let p = vec2i(position.xy); - let texel = textureLoad(source, p, 0); - let value = toOpponent(encode(texel.rgb)); +struct Pixel { + value: vec3f, + // The difference from the reduction, in noise deviations. + difference: vec3f, +} + +fn pixelAt(at: vec2i) -> Pixel { + let p = clamp(at, vec2i(0), vec2i(textureDimensions(source)) - 1); + let value = toOpponent(encode(textureLoad(source, p, 0).rgb)); // Each half-size texel averages 2 × 2 pixels, whatever the image's parity. - let uv = position.xy * 0.5 / vec2f(textureDimensions(weakColor)); - var result = value; - result += toward(value, textureLoad(weakLight, p, 0).x, textureSampleLevel(weakColor, linearSampler, uv, 0.0).xy, shares[0]); - result += toward(value, textureLoad(measuredLight, p, 0).x, textureSampleLevel(measuredColor, linearSampler, uv, 0.0).xy, shares[1]); - result += toward(value, textureLoad(strongLight, p, 0).x, textureSampleLevel(strongColor, linearSampler, uv, 0.0).xy, shares[2]); - return vec4f(decode(fromOpponent(result)), texel.a); + let uv = (vec2f(p) + 0.5) * 0.5 / vec2f(textureDimensions(color)); + let reduced = vec3f(textureLoad(light, p, 0).x, textureSampleLevel(color, linearSampler, uv, 0.0).xy); + return Pixel(value, (value - reduced) / params.noise.xyz); +} + +@fragment fn fs_main(@builtin(position) position: vec4f) -> @location(0) vec4f { + let center = vec2i(position.xy); + let pixel = pixelAt(center); + var energy = pixel.difference * pixel.difference; + for (var i = 0; i < 9; i++) { + let offset = vec2i(i % 3, i / 3) - 1; + if (any(offset != vec2i(0))) { + let difference = pixelAt(center + offset).difference; + energy += difference * difference; + } + } + let part = removed(vec4f(pixel.difference, 0.0), vec4f(energy / 9.0, 0.0), limits).xyz; + let result = fromOpponent(pixel.value - params.noise.xyz * part); + return vec4f(decode(result + dither(vec2u(center)) * params.quantum), textureLoad(source, center, 0).a); } diff --git a/src/features/noise-reduction/compose.wgsl b/src/features/noise-reduction/compose.wgsl index c18d397b..71d1e081 100644 --- a/src/features/noise-reduction/compose.wgsl +++ b/src/features/noise-reduction/compose.wgsl @@ -1,17 +1,16 @@ -// Replacement samples for a mosaic at chosen strengths: the decoded samples plus each anchor's -// change, split into the spectrum's components and weighed by that anchor's share of each. The -// split follows the transform's slope where the strongest anchor puts each sample, so changes divide -// as the filter saw them, and shares that are all one or all zero give an anchor or the decoded -// samples exactly. +// Replacement samples for a mosaic at chosen strengths: the decoded samples less part of their +// difference from the reduced ones, in noise deviations per spectrum component, which the +// component's limits and the difference over the 3 × 3 cells around set. Shares of one give the +// reduction, and of zero the decoded samples, exactly. +import { Limits, removed } from "./limit.wgsl"; import { fromSpectrum, Noise, toSpectrum } from "./stabilize.wgsl"; @group(0) @binding(0) var noise: Noise; -// Per anchor, weakest first: its share of light, the two color differences, and the greens' one. -@group(0) @binding(1) var shares: array; +// Of light, the two color differences, and the greens' difference. +@group(0) @binding(1) var limits: Limits; @group(0) @binding(2) var samples: texture_2d; -@group(0) @binding(3) var weak: texture_2d; -@group(0) @binding(4) var measured: texture_2d; -@group(0) @binding(5) var strong: texture_2d; +// The reduction's change to each 2 × 2 cell's samples, by position. +@group(0) @binding(3) var reduction: texture_2d; @fragment fn fs_main(@builtin(position) position: vec4f) -> @location(0) vec4f { let cell = vec2i(position.xy); @@ -22,17 +21,19 @@ import { fromSpectrum, Noise, toSpectrum } from "./stabilize.wgsl"; f32(textureLoad(samples, p + vec2i(0, 1), 0).r), f32(textureLoad(samples, p + vec2i(1, 1), 0).r), ) - noise.black; - let changes = array( - textureLoad(weak, cell, 0), - textureLoad(measured, cell, 0), - textureLoad(strong, cell, 0), - ); + let change = textureLoad(reduction, cell, 0); + // The transform's slope where the reduction puts each sample, so differences count as the filter + // saw them. Neighbors share it: light varies little between them except at edges, which the + // difference already marks. let a = noise.gain; - let settled = max(decoded + changes[2], vec4f(0.0)); - let slope = sqrt(a * settled + 0.375 * a * a + noise.floor); - var result = decoded; - for (var k = 0; k < 3; k++) { - result += slope * fromSpectrum(shares[k] * toSpectrum(changes[k] / slope, noise), noise); + let slope = sqrt(a * max(decoded + change, vec4f(0.0)) + 0.375 * a * a + noise.floor); + let last = vec2i(textureDimensions(reduction)) - 1; + var energy = vec4f(0.0); + for (var i = 0; i < 9; i++) { + let neighbor = clamp(cell + vec2i(i % 3, i / 3) - 1, vec2i(0), last); + let difference = toSpectrum(textureLoad(reduction, neighbor, 0) / slope, noise); + energy += difference * difference; } - return result; + let part = removed(toSpectrum(-change / slope, noise), energy / 9.0, limits); + return decoded - slope * fromSpectrum(part, noise); } diff --git a/src/features/noise-reduction/finish-color.wgsl b/src/features/noise-reduction/finish-color.wgsl index 68e33313..4e5f5675 100644 --- a/src/features/noise-reduction/finish-color.wgsl +++ b/src/features/noise-reduction/finish-color.wgsl @@ -1,17 +1,11 @@ -// A filtered image spectrum's two color differences, averaged over 2 × 2 pixels: a reduced image's -// color holds no detail at that scale, and half its size takes a quarter of the memory. +// A filtered color spectrum's two differences, as each 2 × 2 cell's mean: a reduced image's color +// holds no detail at that scale, and half its size takes a quarter of the memory. import { unpack } from "./texels.wgsl"; @group(0) @binding(0) var size: vec2u; @group(0) @binding(1) var spectrum: array; -fn colorAt(p: vec2u) -> vec2f { - let q = min(p, size - 1u); - return unpack(spectrum[q.y * size.x + q.x]).yz; -} - @fragment fn fs_main(@builtin(position) position: vec4f) -> @location(0) vec4f { - let p = vec2u(position.xy) * 2u; - let sum = colorAt(p) + colorAt(p + vec2u(1u, 0u)) + colorAt(p + vec2u(0u, 1u)) + colorAt(p + 1u); - return vec4f(sum * 0.25, 0.0, 1.0); + let cell = vec2u(position.xy); + return vec4f(unpack(spectrum[cell.y * size.x + cell.x]).xy * 0.5, 0.0, 1.0); } diff --git a/src/features/noise-reduction/finish-light.wgsl b/src/features/noise-reduction/finish-light.wgsl index 54517ae4..00307891 100644 --- a/src/features/noise-reduction/finish-light.wgsl +++ b/src/features/noise-reduction/finish-light.wgsl @@ -1,4 +1,5 @@ -// A filtered image spectrum's light, at full size. +// A filtered light spectrum back to full size: each 2 × 2 cell's pixels from its Haar transform. +import { haar } from "./opponent.wgsl"; import { unpack } from "./texels.wgsl"; @group(0) @binding(0) var size: vec2u; @@ -6,5 +7,7 @@ import { unpack } from "./texels.wgsl"; @fragment fn fs_main(@builtin(position) position: vec4f) -> @location(0) vec4f { let p = vec2u(position.xy); - return vec4f(unpack(spectrum[p.y * size.x + p.x]).x, 0.0, 0.0, 1.0); + let cell = p / 2u; + let pixels = haar * unpack(spectrum[cell.y * size.x + cell.x]); + return vec4f(pixels[(p.y & 1u) * 2u + (p.x & 1u)], 0.0, 0.0, 1.0); } diff --git a/src/features/noise-reduction/image.ts b/src/features/noise-reduction/image.ts index 8b250bfa..14b7deaf 100644 --- a/src/features/noise-reduction/image.ts +++ b/src/features/noise-reduction/image.ts @@ -1,5 +1,7 @@ import { + type Compute, compute, + type Effect, effect, frame, type Gpu, @@ -12,53 +14,98 @@ import { weakMemo } from "@/lib/weak-memo"; import composeShader from "./compose-image.wgsl"; import finishColorShader from "./finish-color.wgsl"; import finishLightShader from "./finish-light.wgsl"; -import { anchors, componentShares } from "./model"; +import { componentLimits } from "./model"; import prepareShader from "./prepare-image.wgsl"; import { block, - buildPyramid, - filterPyramid, + type Component, groups, half, - measureLevels, + reduceSpectrum, type Size, - spectrum, } from "./pyramid"; -const passes = weakMemo((gpu: Gpu) => ({ - prepare: compute(gpu, prepareShader), - light: effect(gpu, finishLightShader), - color: effect(gpu, finishColorShader), - compose: effect(gpu, composeShader), -})); - /** The noise 8-bit samples hold at least, their rounding, in the encoding's units. */ const rounding = 1 / 255 / Math.sqrt(12); -/** An anchor's light at full size and color at half. */ -type Anchor = { light: Target; color: Target }; +/** + * One of the spectra an image reduces apart: how it is made and returns, at what size and format, how + * its components filter, and their noise from the finest level's measures. + */ +type Part = { + prepare: Compute; + finish: Effect; + size: (image: Size) => Size; + format: GPUTextureFormat; + components: readonly Component[]; + noise: (measured: readonly number[]) => readonly number[]; +}; + +/** + * Light returns at full size from its Haar components, which filter alike; white noise holds the + * same deviation in each, so the least measure, which texture raises least, gives all of theirs. + * Color returns at half size, which a reduced image's color needs no more of, and its two + * differences shrink twice as hard as light. + */ +const passes = weakMemo((gpu: Gpu) => ({ + light: { + prepare: compute(gpu, prepareShader, { entry: "light" }), + finish: effect(gpu, finishLightShader), + size: (image: Size) => image, + format: "r16float", + components: Array(4).fill({ + threshold: 3, + caution: 1, + mean: true, + }), + noise: (measured: readonly number[]) => { + // A component without detail anywhere, such as an enlarged image's differences, measures none. + const measures = measured.filter((value) => value > 0); + const least = measures.length ? Math.min(...measures) : 0; + return measured.map(() => Math.max(least, rounding)); + }, + } satisfies Part, + color: { + prepare: compute(gpu, prepareShader, { entry: "color" }), + finish: effect(gpu, finishColorShader), + size: half, + format: "rg16float", + components: Array(2).fill({ + threshold: 3, + caution: 2, + mean: true, + }), + noise: (measured: readonly number[]) => + measured.map((value) => Math.max(value, rounding)), + } satisfies Part, + compose: effect(gpu, composeShader), +})); +/** + * Composes the image at any strengths from its reduced light and color, into a target of its own + * in the image's format: 8-bit images stay 8-bit, dithered. + */ function createComposer( gpu: Gpu, { image }: EncodedImage, - [weak, measured, strong]: readonly Anchor[], + light: Target, + color: Target, + noise: readonly number[], ) { - const output = target(gpu, { size: image.size, format: "rgba16float" }); + const output = target(gpu, { size: image.size, format: image.format }); + const quantum = image.format === "rgba8unorm-srgb" ? 1 / 255 : 0; return { render(strengths: NoiseReduction) { const pass = passes(gpu).compose.set({ - shares: componentShares(strengths), + limits: componentLimits(strengths), + params: { noise, quantum }, source: image.color, linearSampler: sampler(gpu, { minFilter: "linear", magFilter: "linear", }), - weakLight: weak.light.color, - measuredLight: measured.light.color, - strongLight: strong.light.color, - weakColor: weak.color.color, - measuredColor: measured.color.color, - strongColor: strong.color.color, + light: light.color, + color: color.color, }); frame(gpu, (f) => f.pass(output, pass)); return output; @@ -67,17 +114,47 @@ function createComposer( }; } -function disposeAnchors(anchors: readonly Anchor[]) { - for (const { light, color } of anchors) { - light.color.dispose(); - color.color.dispose(); +/** Reduces one of an image's spectra into a target of its own, with the noise it measured per component. */ +async function reducePart( + gpu: Gpu, + image: Target, + part: Part, + signal: AbortSignal, +) { + const size = half(image.size); + const { estimate, noise } = await reduceSpectrum( + gpu, + size, + (finest) => + part.prepare + .set({ source: image.color, size, output: finest.buffer }) + .dispatch(...groups(size)), + part.noise, + part.components, + signal, + ); + try { + const pass = part.finish.set({ size, spectrum: estimate.buffer }); + const output = target(gpu, { + size: part.size(image.size), + format: part.format, + }); + try { + frame(gpu, (f) => f.pass(output, pass)); + } catch (error) { + output.color.dispose(); + throw error; + } + return { output, noise }; + } finally { + estimate.buffer.dispose(); } } /** - * Reduces an image's noise, once at each anchor strength, in its encoded light and color, with no - * noise model: each level's noise comes from its flattest blocks. A composer then gives the image - * at any strengths at once, linear in the image's primaries as an `rgba16float` target. Stops + * Reduces an image's noise in its encoded light and color, each at half size and apart, with no + * noise model: each level's noise comes from its flattest blocks. A composer then gives the image at + * any strengths at once. Color reduces first, so only its smaller result waits through light's. Stops * between levels once `signal` aborts. */ export async function reduceImage( @@ -85,53 +162,25 @@ export async function reduceImage( encoded: EncodedImage, signal: AbortSignal, ): Promise { - const { prepare, light, color } = passes(gpu); - const size: Size = encoded.image.size; - if (Math.min(...size) < block) { + const { image } = encoded; + if (Math.min(...half(image.size)) < block) { throw Error("The image is too small to reduce its noise."); } - const finest = spectrum(gpu, size); - prepare - .set({ source: encoded.image.color, size, output: finest.buffer }) - .dispatch(...groups(size)); - const pyramid = buildPyramid(gpu, finest); - const reduced: Anchor[] = []; + const parts = passes(gpu); + const color = await reducePart(gpu, image, parts.color, signal); try { - const levels = await measureLevels(gpu, pyramid, (measured) => - Math.max(measured, rounding), - ); - for (const strength of anchors) { - const estimate = await filterPyramid( - gpu, - pyramid, - levels, - strength, - 3, - signal, - ); - try { - const anchor = { - light: target(gpu, { size, format: "r16float" }), - color: target(gpu, { size: half(size), format: "rg16float" }), - }; - reduced.push(anchor); - const params = { size, spectrum: estimate.buffer }; - frame(gpu, (f) => { - f.pass(anchor.light, light.set(params)); - f.pass(anchor.color, color.set(params)); - }); - } finally { - estimate.buffer.dispose(); - } - } + const light = await reducePart(gpu, image, parts.light, signal); + const noise = [light.noise[0], color.noise[0], color.noise[1], 0]; + return { + compose: () => + createComposer(gpu, encoded, light.output, color.output, noise), + dispose() { + light.output.color.dispose(); + color.output.color.dispose(); + }, + }; } catch (error) { - disposeAnchors(reduced); + color.output.color.dispose(); throw error; - } finally { - for (const level of pyramid) level.buffer.dispose(); } - return { - compose: () => createComposer(gpu, encoded, reduced), - dispose: () => disposeAnchors(reduced), - }; } diff --git a/src/features/noise-reduction/limit.wgsl b/src/features/noise-reduction/limit.wgsl new file mode 100644 index 00000000..e8c870cb --- /dev/null +++ b/src/features/noise-reduction/limit.wgsl @@ -0,0 +1,20 @@ +// How much of the difference between an image and its reduction, in noise deviations, to remove: +// none at a share of 0 and all of it at 1. Between, a component loses nearly all of a difference +// whose neighborhood varies by less than a limit, mostly noise, and of one that varies more, mostly +// detail, about the limit's worth; the limit grows with the share, through its value at one half, +// and without bound toward 1. Weighing neighborhoods rather than single values keeps noise from +// standing out where it varies most. + +// Each component's share, and its limit at a share of one half. +export struct Limits { + shares: vec4f, + halves: vec4f, +} + +// `energy` is the difference's mean square around it. +export fn removed(difference: vec4f, energy: vec4f, limits: Limits) -> vec4f { + let limit = limits.halves * limits.shares; + let kept = 1.0 - limits.shares; + let part = difference * limit * inverseSqrt(limit * limit + kept * kept * energy); + return select(vec4f(0.0), part, limits.shares > vec4f(0.0)); +} diff --git a/src/features/noise-reduction/model.ts b/src/features/noise-reduction/model.ts index 76d64373..9372b03f 100644 --- a/src/features/noise-reduction/model.ts +++ b/src/features/noise-reduction/model.ts @@ -9,42 +9,16 @@ export const noiseReductionSchema = z.object({ }) satisfies z.ZodMiniType; /** - * The strengths a photo's reduction is made at once, as multiples of the noise it measures, and the - * amounts that reach each, so an amount scales the noise filtered linearly: near 50 a reduced test - * photo comes closest to its clean original, and 100 smooths twice as hard. An amount between two - * blends their results, and one below the weakest blends it with the photo as decoded. + * Per spectrum component, its share of the reduction, 0 to 1, and its limit at a share of one half, + * in noise deviations: less for detail the filter removes more of, more for color, which holds + * little. Light follows luminance, the two color differences color, and the greens' difference, which + * demosaicing draws as a maze, the stronger of the two. */ -export const anchors = [0.35, 0.7, 1.4]; -const amounts = [25, 50, 100]; - -/** Each anchor's share of the result at `amount`, 0 to 100; what remains is the photo as decoded. */ -export function anchorShares(amount: number) { - const shares = anchors.map(() => 0); - const next = amounts.findIndex((at) => amount <= at); - if (amount <= 0) { - return shares; - } - if (next === -1) { - shares[shares.length - 1] = 1; - return shares; - } - const from = next ? amounts[next - 1] : 0; - const t = (amount - from) / (amounts[next] - from); - shares[next] = t; - if (next) { - shares[next - 1] = 1 - t; - } - return shares; -} - -/** - * Each anchor's share per component of the spectrum: light follows luminance, the two color - * differences follow color, and the greens' difference, which demosaicing draws as a maze, the - * stronger of the two. - */ -export function componentShares({ luminance, color }: NoiseReduction) { - const light = anchorShares(luminance); - const tint = anchorShares(color); - const split = anchorShares(Math.max(luminance, color)); - return anchors.map((_, k) => [light[k], tint[k], tint[k], split[k]]); +export function componentLimits({ luminance, color }: NoiseReduction) { + return { + shares: [luminance, color, color, Math.max(luminance, color)].map( + (amount) => amount / 100, + ), + halves: [1, 3, 3, 0.7], + }; } diff --git a/src/features/noise-reduction/mosaic.ts b/src/features/noise-reduction/mosaic.ts index 3c0374ef..476ff2d2 100644 --- a/src/features/noise-reduction/mosaic.ts +++ b/src/features/noise-reduction/mosaic.ts @@ -3,18 +3,16 @@ import type { Mosaic, NoiseReduction, Reduction } from "@/core/image"; import { weakMemo } from "@/lib/weak-memo"; import composeShader from "./compose.wgsl"; import finishShader from "./finish.wgsl"; -import { anchors, componentShares } from "./model"; +import { componentLimits } from "./model"; import { measureNoise, type NoiseModel } from "./noise"; import prepareShader from "./prepare.wgsl"; import { block, - buildPyramid, - filterPyramid, + type Component, groups, - measureLevels, modeled, + reduceSpectrum, type Size, - spectrum, } from "./pyramid"; const passes = weakMemo((gpu: Gpu) => ({ @@ -23,6 +21,19 @@ const passes = weakMemo((gpu: Gpu) => ({ compose: effect(gpu, composeShader), })); +/** + * How the filter treats light, the two color differences, and the greens' difference: color shrinks + * twice as hard as light. The greens' difference holds less detail, so a coefficient needs more to + * stand out and shrinks eight times as hard; both greens see the same light, so its patches' means + * are noise or imbalance, which demosaicing would draw as a maze. + */ +const components: Component[] = [ + { threshold: 3, caution: 1, mean: true }, + { threshold: 3, caution: 2, mean: true }, + { threshold: 3, caution: 2, mean: true }, + { threshold: 3.5, caution: 8, mean: false }, +]; + /** * The transform's largest value, in noise deviations: half floats keep it, and the filter's * fixed-point sums of its patches stay in range. @@ -58,23 +69,21 @@ function noiseParams( type NoiseParams = ReturnType; -/** Composes replacement samples at any strengths from each anchor's change, into a target of its own. */ +/** Composes replacement samples at any strengths from the reduction's change, into a target of its own. */ function createComposer( gpu: Gpu, mosaic: Mosaic, noise: NoiseParams, - [weak, measured, strong]: readonly Target[], + reduction: Target, ) { - const output = target(gpu, { size: weak.size, format: "rgba16float" }); + const output = target(gpu, { size: reduction.size, format: "rgba16float" }); return { render(strengths: NoiseReduction) { const pass = passes(gpu).compose.set({ noise, - shares: componentShares(strengths), + limits: componentLimits(strengths), samples: mosaic.samples, - weak: weak.color, - measured: measured.color, - strong: strong.color, + reduction: reduction.color, }); frame(gpu, (f) => f.pass(output, pass)); return output; @@ -84,11 +93,11 @@ function createComposer( } /** - * Reduces a mosaic's noise before demosaicing, once at each anchor strength: its own noise model - * makes the noise unit Gaussian, a pyramid of patch filtering removes it from fine detail to broad - * color blotches, and the unbiased inverse returns samples. A composer then gives replacement - * samples at any strengths at once: a half-size `rgba16float` target holding each 2 × 2 cell's four - * samples by position, above black. Stops between levels once `signal` aborts. + * Reduces a mosaic's noise before demosaicing: its own noise model makes the noise unit Gaussian, a + * pyramid of patch filtering removes it from fine detail to broad color blotches, and the unbiased + * inverse returns samples, kept as their change to the decoded ones. A composer then gives + * replacement samples at any strengths at once: a half-size `rgba16float` target holding each 2 × 2 + * cell's four samples by position, above black. Stops between levels once `signal` aborts. */ export async function reduceMosaic( gpu: Gpu, @@ -104,48 +113,36 @@ export async function reduceMosaic( throw Error("The photo is too small to reduce its noise."); } const noise = noiseParams(mosaic, await measureNoise(gpu, mosaic)); - signal.throwIfAborted(); - const finest = spectrum(gpu, size); - prepare - .set({ samples: mosaic.samples, noise, size, output: finest.buffer }) - .dispatch(...groups(size)); - const pyramid = buildPyramid(gpu, finest); - const changes: Target[] = []; + const { estimate } = await reduceSpectrum( + gpu, + size, + (finest) => + prepare + .set({ samples: mosaic.samples, noise, size, output: finest.buffer }) + .dispatch(...groups(size)), + modeled, + components, + signal, + ); try { - const levels = await measureLevels(gpu, pyramid, modeled); - for (const strength of anchors) { - const estimate = await filterPyramid( - gpu, - pyramid, - levels, - strength, - 4, - signal, - ); - try { - const change = target(gpu, { size, format: "rgba16float" }); - changes.push(change); - const pass = finish.set({ - noise, - size, - spectrum: estimate.buffer, - samples: mosaic.samples, - }); - frame(gpu, (f) => f.pass(change, pass)); - } finally { - estimate.buffer.dispose(); - } + const pass = finish.set({ + noise, + size, + spectrum: estimate.buffer, + samples: mosaic.samples, + }); + const change = target(gpu, { size, format: "rgba16float" }); + try { + frame(gpu, (f) => f.pass(change, pass)); + } catch (error) { + change.color.dispose(); + throw error; } - } catch (error) { - for (const change of changes) change.color.dispose(); - throw error; + return { + compose: () => createComposer(gpu, mosaic, noise, change), + dispose: () => change.color.dispose(), + }; } finally { - for (const level of pyramid) level.buffer.dispose(); + estimate.buffer.dispose(); } - return { - compose: () => createComposer(gpu, mosaic, noise, changes), - dispose() { - for (const change of changes) change.color.dispose(); - }, - }; } diff --git a/src/features/noise-reduction/opponent.wgsl b/src/features/noise-reduction/opponent.wgsl index 00244d60..8e3b7c29 100644 --- a/src/features/noise-reduction/opponent.wgsl +++ b/src/features/noise-reduction/opponent.wgsl @@ -26,3 +26,12 @@ export fn toOpponent(rgb: vec3f) -> vec3f { export fn fromOpponent(value: vec3f) -> vec3f { return value * opponent; } + +// Each 2 × 2 cell's four values, row-major, and its mean, horizontal, vertical, and diagonal +// differences, one to the other: orthonormal, so noise keeps its deviation, and its own inverse. +export const haar = mat4x4f( + 0.5, 0.5, 0.5, 0.5, + 0.5, -0.5, 0.5, -0.5, + 0.5, 0.5, -0.5, -0.5, + 0.5, -0.5, -0.5, 0.5, +); diff --git a/src/features/noise-reduction/prepare-image.wgsl b/src/features/noise-reduction/prepare-image.wgsl index ba8eadbd..4f3232fe 100644 --- a/src/features/noise-reduction/prepare-image.wgsl +++ b/src/features/noise-reduction/prepare-image.wgsl @@ -1,15 +1,37 @@ -// An image's pixels as the filter's spectrum: light and two color differences of the encoded color. -import { encode, toOpponent } from "./opponent.wgsl"; +// An image's pixels as the filter's spectra at half size, from the light and two color differences of +// its encoded color: each 2 × 2 cell's light by its Haar transform, or its color differences, summed +// orthonormally so noise keeps its deviation. A last odd row or column repeats the one before. +import { encode, haar, toOpponent } from "./opponent.wgsl"; import { pack } from "./texels.wgsl"; @group(0) @binding(0) var source: texture_2d; @group(0) @binding(1) var size: vec2u; @group(0) @binding(2) var output: array; -@compute @workgroup_size(8, 8) fn prepare(@builtin(global_invocation_id) id: vec3u) { +fn opponentAt(p: vec2u) -> vec3f { + let q = min(p, textureDimensions(source) - 1u); + return toOpponent(encode(textureLoad(source, q, 0).rgb)); +} + +@compute @workgroup_size(8, 8) fn light(@builtin(global_invocation_id) id: vec3u) { + if (any(id.xy >= size)) { + return; + } + let p = id.xy * 2u; + let cell = vec4f( + opponentAt(p).x, + opponentAt(p + vec2u(1u, 0u)).x, + opponentAt(p + vec2u(0u, 1u)).x, + opponentAt(p + 1u).x, + ); + output[id.y * size.x + id.x] = pack(haar * cell); +} + +@compute @workgroup_size(8, 8) fn color(@builtin(global_invocation_id) id: vec3u) { if (any(id.xy >= size)) { return; } - let rgb = textureLoad(source, id.xy, 0).rgb; - output[id.y * size.x + id.x] = pack(vec4f(toOpponent(encode(rgb)), 0.0)); + let p = id.xy * 2u; + let sum = opponentAt(p).yz + opponentAt(p + vec2u(1u, 0u)).yz + opponentAt(p + vec2u(0u, 1u)).yz + opponentAt(p + 1u).yz; + output[id.y * size.x + id.x] = pack(vec4f(sum * 0.5, 0.0, 0.0)); } diff --git a/src/features/noise-reduction/pyramid.ts b/src/features/noise-reduction/pyramid.ts index c3ba86b2..0f454900 100644 --- a/src/features/noise-reduction/pyramid.ts +++ b/src/features/noise-reduction/pyramid.ts @@ -8,6 +8,15 @@ import spreadShader from "./spread.wgsl"; export type Size = readonly [number, number]; /** One level of the spectrum: four half floats per texel, row after row. */ export type Spectrum = { buffer: Buffer; size: Size }; +/** How the filter treats one of a spectrum's components. */ +export type Component = { + /** Noise deviations past which the first step keeps a coefficient. */ + threshold: number; + /** How much more its noise counts in the second step's Wiener weights. */ + caution: number; + /** Whether patches keep their means whole, as a component that holds a level must to stay unbiased. */ + mean: boolean; +}; const passes = weakMemo((gpu: Gpu) => ({ down: compute(gpu, downShader), @@ -33,7 +42,7 @@ const quartileBias = 0.876; */ const margin = 1.15; -export function spectrum(gpu: Gpu, size: Size): Spectrum { +function spectrum(gpu: Gpu, size: Size): Spectrum { const buffer = gpu.device.createBuffer({ size: size[0] * size[1] * 8, usage: ["storage"], @@ -60,18 +69,16 @@ function down(gpu: Gpu, source: Spectrum, output: Spectrum) { .dispatch(...groups(output.size)); } -/** `finest` and its averages, each level half the one before, down to `smallest` texels. */ -export function buildPyramid(gpu: Gpu, finest: Spectrum) { - const pyramid = [finest]; +/** Adds averages of `pyramid`'s last level, each half the one before, down to `smallest` texels. */ +function extendPyramid(gpu: Gpu, pyramid: Spectrum[]) { while (pyramid.length < levels) { const finer = pyramid[pyramid.length - 1]; const size = half(finer.size); - if (Math.min(...size) < smallest) break; + if (Math.min(...size) < smallest) return; const level = spectrum(gpu, size); - down(gpu, finer, level); pyramid.push(level); + down(gpu, finer, level); } - return pyramid; } /** @@ -105,17 +112,17 @@ async function measureSpread(gpu: Gpu, { buffer, size }: Spectrum) { /** * Each level's noise deviation per component, finest first. `finest` turns the finest level's - * measure into its noise. A coarser level's flattest blocks give its noise, up to a margin over what + * measures into its noise. A coarser level's flattest blocks give its noise, up to a margin over what * white noise would leave there, since they still hold texture. */ -export async function measureLevels( +async function measureLevels( gpu: Gpu, pyramid: readonly Spectrum[], - finest: (measured: number) => number, + finest: (measured: readonly number[]) => readonly number[], ) { const measured = []; for (const level of pyramid) measured.push(await measureSpread(gpu, level)); - const base = measured[0].map(finest); + const base = finest(measured[0]); return measured.map((level, l) => level.map((value, c) => { const white = base[c] * 2 ** -l; @@ -125,24 +132,36 @@ export async function measureLevels( ); } -/** The finest level's noise for a noise model that predicts unit noise, which a lower measure corrects. */ -export function modeled(measured: number) { - return Math.min(measured || 1, 1); +/** The finest level's noise for a noise model that predicts unit noise, which lower measures correct. */ +export function modeled(measured: readonly number[]) { + return measured.map((value) => Math.min(value || 1, 1)); } +/** Each of four components' value, 0 past the components a spectrum holds. */ +const padded = (values: readonly number[]) => + [0, 1, 2, 3].map((c) => values[c] ?? 0); + /** One step of the patch filter over `noisy` into `output`, shrinking by `pilot`'s Wiener weights if given. */ function shrink( gpu: Gpu, noisy: Spectrum, output: Spectrum, sigma: readonly number[], - channels: number, + components: readonly Component[], pilot?: Spectrum, ) { const { size } = noisy; passes(gpu) .shrink.set({ - params: { size, channels, sigma, wiener: Number(Boolean(pilot)) }, + params: { + size, + channels: components.length, + wiener: Number(Boolean(pilot)), + sigma, + threshold: padded(components.map((c) => c.threshold)), + caution: padded(components.map((c) => c.caution)), + mean: padded(components.map((c) => Number(c.mean))), + }, noisy: noisy.buffer, pilot: (pilot ?? noisy).buffer, output: output.buffer, @@ -199,19 +218,19 @@ function filterLevel( noisy: Spectrum, coarse: Spectrum | undefined, sigma: readonly number[], - channels: number, + components: readonly Component[], ) { const first = spectrum(gpu, noisy.size); try { const second = spectrum(gpu, noisy.size); try { - shrink(gpu, noisy, first, sigma, channels); + shrink(gpu, noisy, first, sigma, components); if (!coarse) { - shrink(gpu, noisy, second, sigma, channels, first); + shrink(gpu, noisy, second, sigma, components, first); return second; } fuse(gpu, first, coarse, second); - shrink(gpu, noisy, first, sigma, channels, second); + shrink(gpu, noisy, first, sigma, components, second); fuse(gpu, first, coarse, second); return second; } catch (error) { @@ -224,41 +243,49 @@ function filterLevel( } /** - * Denoises `pyramid`, finest level first, from its coarsest level up, as if its noise were - * `strength` times what each level measured: each level takes its low frequencies from the - * estimate of the one below it. Resolves to the finest estimate; stops between levels once - * `signal` aborts. + * Denoises a spectrum of `size`, which `prepare` fills, through a pyramid of its averages from the + * coarsest level up: each level's `components` are filtered at the noise they measure, which + * `noiseOf` turns into the finest level's, and each level takes its low frequencies from the + * estimate of the level below. Resolves to the finest estimate and the finest level's noise per + * component; stops between steps once `signal` aborts. */ -export async function filterPyramid( +export async function reduceSpectrum( gpu: Gpu, - pyramid: readonly Spectrum[], - noise: readonly (readonly number[])[], - strength: number, - channels: number, - signal?: AbortSignal, -): Promise { - const sigma = (l: number) => noise[l].map((value) => value * strength); - const coarsest = pyramid.length - 1; - let estimate = filterLevel( - gpu, - pyramid[coarsest], - undefined, - sigma(coarsest), - channels, - ); - for (let l = coarsest - 1; l >= 0; l--) { - const coarse = estimate; - try { - estimate = filterLevel(gpu, pyramid[l], coarse, sigma(l), channels); - } finally { - coarse.buffer.dispose(); - } - // Each level submits before the next, so other GPU work, such as the display, runs between. - await gpu.gpu.queue.onSubmittedWorkDone(); - if (signal?.aborted) { - estimate.buffer.dispose(); + size: Size, + prepare: (finest: Spectrum) => void, + noiseOf: (measured: readonly number[]) => readonly number[], + components: readonly Component[], + signal: AbortSignal, +) { + signal.throwIfAborted(); + const pyramid = [spectrum(gpu, size)]; + let estimate: Spectrum | undefined; + try { + prepare(pyramid[0]); + extendPyramid(gpu, pyramid); + const noise = await measureLevels(gpu, pyramid, noiseOf); + signal.throwIfAborted(); + for (let l = pyramid.length - 1; ; l--) { + const coarse = estimate; + const filtered = filterLevel( + gpu, + pyramid[l], + coarse, + noise[l], + components, + ); + estimate = filtered; + coarse?.buffer.dispose(); + pyramid[l].buffer.dispose(); + // Each level submits before the next, so other GPU work, such as the display, runs between. + await gpu.gpu.queue.onSubmittedWorkDone(); signal.throwIfAborted(); + if (!l) return { estimate: filtered, noise: noise[0] }; } + } catch (error) { + estimate?.buffer.dispose(); + throw error; + } finally { + for (const level of pyramid) level.buffer.dispose(); } - return estimate; } diff --git a/src/features/noise-reduction/reduce.ts b/src/features/noise-reduction/reduce.ts index b79e42bd..60651f67 100644 --- a/src/features/noise-reduction/reduce.ts +++ b/src/features/noise-reduction/reduce.ts @@ -4,8 +4,8 @@ import { reduceImage } from "./image"; import { reduceMosaic } from "./mosaic"; /** - * Reduces a source's noise once at each anchor strength: a RAW photo on its mosaic before - * demosaicing, any other image in its encoded color. + * Reduces a source's noise once: a RAW photo on its mosaic before demosaicing, any other image in + * its encoded color. */ export async function reduceNoise( gpu: Gpu, diff --git a/src/features/noise-reduction/shrink.wgsl b/src/features/noise-reduction/shrink.wgsl index eeff38a6..417d27f5 100644 --- a/src/features/noise-reduction/shrink.wgsl +++ b/src/features/noise-reduction/shrink.wgsl @@ -1,24 +1,20 @@ // One step of sliding DCT denoising (Yu and Sapiro, 2011), the patch filter BM3D builds on (Dabov, // Foi, Katkovnik, and Egiazarian, 2007), over a tile of the spectrum per workgroup: every 8 × 8 patch -// that reaches the tile is transformed, shrunk, and added back to the texels it covers, weighted by -// how little of it survived. The first step keeps the coefficients that stand out of the noise; the -// second shrinks each by the Wiener weight the first step's estimate gives it. Patches are filtered -// alone rather than grouped with similar ones, which would mistake aligned noise for likeness. +// that starts on every other texel and reaches the tile is transformed, shrunk, and added back to the +// 64 texels it covers, weighted by how little of it survived, so 16 patches cover each texel. The +// first step keeps the coefficients that stand out of the noise; the second shrinks each by the +// Wiener weight the first step's estimate gives it. Patches are filtered alone rather than grouped +// with similar ones, which would mistake aligned noise for likeness. import { pack, unpack } from "./texels.wgsl"; const side = 8u; const tile = 32u; +const stride = 2u; // Patches whose texels reach the tile, along each side. -const span = 39u; +const span = 19u; const threads = 256u; -// Coefficients below this many noise deviations are noise, in the first step; the greens' difference -// holds less detail than light and color. -const lambda = vec4f(3.0, 3.0, 3.0, 3.5); -// How much more each component's noise counts in the Wiener weights: color, and more so the greens' -// difference, shrink harder than light. -const caution = vec4f(1.0, 2.0, 2.0, 8.0); // Fixed-point scales of the sums. Light, the largest component, reaches twice the transform's largest -// value, 1024, and ringing at most doubles it; the 64 patches over a texel weigh 36 at most, in their +// value, 1024, and ringing at most doubles it; the 16 patches over a texel weigh 9 at most, in their // Kaiser windows. Both sums stay in range. const valueScale = 8192.0; const weightScale = 16777216.0; @@ -26,12 +22,17 @@ const kaiser = array(0.438676, 0.681324, 0.87684, 0.985823, 0.985823, 0. struct Params { size: vec2u, - // Components the spectrum holds: four for a mosaic, three for an image. + // Components the spectrum holds, up to four. channels: u32, - // Each component's noise deviation. - sigma: vec4f, // 0 thresholds; 1 shrinks by the Wiener weights of `pilot`, the first step's estimate. wiener: u32, + // Each component's noise deviation. + sigma: vec4f, + // Per component, the noise deviations past which the first step keeps a coefficient, how much more + // its noise counts in the second step's Wiener weights, and 1 where patches keep their means whole. + threshold: vec4f, + caution: vec4f, + mean: vec4u, } @group(0) @binding(0) var params: Params; @@ -90,7 +91,7 @@ fn transform(block: ptr>, inverse: bool) { workgroupBarrier(); let sigma = params.sigma[channel]; for (var i = t; i < span * span; i += threads) { - let corner = origin - i32(side - 1u) + vec2i(vec2u(i % span, i / span)); + let corner = origin - i32(side - stride) + i32(stride) * vec2i(vec2u(i % span, i / span)); var values: array; var guide: array; for (var j = 0u; j < 64u; j++) { @@ -104,19 +105,18 @@ fn transform(block: ptr>, inverse: bool) { if (params.wiener == 1u) { transform(&guide, false); } - // A patch's mean, its DC, stays whole: the inverse transform expects every level unbiased. Not - // the greens' difference: both greens see the same light, so its mean is noise or imbalance, - // which demosaicing would draw as a maze. + // A patch's mean, its DC, stays whole where a component holds a level: the inverse transform + // expects every level unbiased. var kept = 0.0; for (var j = 0u; j < 64u; j++) { - if (j == 0u && channel != 3u) { + if (j == 0u && params.mean[channel] == 1u) { kept += 1.0; } else if (params.wiener == 1u) { let g = guide[j] * guide[j]; - let w = g / (g + caution[channel] * sigma * sigma); + let w = g / (g + params.caution[channel] * sigma * sigma); values[j] *= w; kept += w * w; - } else if (abs(values[j]) > lambda[channel] * sigma) { + } else if (abs(values[j]) > params.threshold[channel] * sigma) { kept += 1.0; } else { values[j] = 0.0; diff --git a/tests/noise-reduction.e2e.ts b/tests/noise-reduction.e2e.ts index 8004a646..d1fb5f33 100644 --- a/tests/noise-reduction.e2e.ts +++ b/tests/noise-reduction.e2e.ts @@ -24,6 +24,7 @@ function measure(samples: number[][]) { const color = samples.map(([r, , b]) => r - b); return { light: mean(light), + color: mean(color), lightNoise: deviation(light), colorNoise: deviation(color), }; @@ -85,6 +86,7 @@ test("Noise reduction cleans an image's color and light apart", async ({ ); expect(colorOnly.colorNoise).toBeLessThan(before.colorNoise / 2); expect(colorOnly.lightNoise).toBeGreaterThan(before.lightNoise * 0.6); + expect(Math.abs(colorOnly.color - before.color)).toBeLessThan(3); await page.evaluate(() => window.openlight.setNoiseReduction({ luminance: 75 }), ); diff --git a/tests/noise-reduction.test.ts b/tests/noise-reduction.test.ts index 7fddbd40..a9c9dbe7 100644 --- a/tests/noise-reduction.test.ts +++ b/tests/noise-reduction.test.ts @@ -1,5 +1,4 @@ import { expect, test } from "bun:test"; -import { anchorShares, anchors } from "@/features/noise-reduction/model"; import { fitNoise } from "@/features/noise-reduction/noise"; test("noise fits photon and read noise from the flattest blocks, past texture", () => { @@ -29,20 +28,3 @@ test("noise fits photon and read noise from the flattest blocks, past texture", expect(Math.abs(gain / 32 - 1)).toBeLessThan(0.15); expect(Math.abs(floor / 1500 - 1)).toBeLessThan(0.15); }); - -test("noise reduction amounts scale the noise filtered between the anchors", () => { - expect(anchorShares(0)).toEqual([0, 0, 0]); - expect(anchorShares(10)).toEqual([0.4, 0, 0]); - expect(anchorShares(25)).toEqual([1, 0, 0]); - expect(anchorShares(37.5)).toEqual([0.5, 0.5, 0]); - expect(anchorShares(75)).toEqual([0, 0.5, 0.5]); - expect(anchorShares(100)).toEqual([0, 0, 1]); - // Shares blend anchors linearly, so the noise filtered follows the amount on one line. - for (const amount of [10, 25, 40, 60, 90]) { - const strength = anchorShares(amount).reduce( - (sum, share, k) => sum + share * anchors[k], - 0, - ); - expect(strength).toBeCloseTo((amount / 100) * anchors[2], 10); - } -});