From 97d4bd9061e1da8a72a8014a36fd56bc4a3e728c Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 22 Aug 2026 19:01:18 +0200 Subject: [PATCH] WIP: clarity and texture Checkpoint committed by the coordinator, not by the authoring agent: the session hit its API limit mid-task and left this work uncommitted. Committed so it survives, NOT because it is finished - expect failing tests and half-applied changes. The agent resumes from here. --- core/dr-gpu/tests/local_contrast.rs | 567 ++++++++++++++ core/dr-pipeline/ops/clarity.yaml | 36 + core/dr-pipeline/ops/texture.yaml | 23 + core/dr-pipeline/src/detail.rs | 85 ++- core/dr-pipeline/src/ops/local_contrast.rs | 828 +++++++++++++++++++++ core/dr-pipeline/src/ops/mod.rs | 4 + 6 files changed, 1542 insertions(+), 1 deletion(-) create mode 100644 core/dr-gpu/tests/local_contrast.rs create mode 100644 core/dr-pipeline/ops/clarity.yaml create mode 100644 core/dr-pipeline/ops/texture.yaml create mode 100644 core/dr-pipeline/src/ops/local_contrast.rs diff --git a/core/dr-gpu/tests/local_contrast.rs b/core/dr-gpu/tests/local_contrast.rs new file mode 100644 index 0000000..f93e328 --- /dev/null +++ b/core/dr-gpu/tests/local_contrast.rs @@ -0,0 +1,567 @@ +//! Clarity and texture, end to end on a real device. +//! +//! `dr-pipeline`'s tests assert what the composer *generates* — the kernel +//! width, the uniforms, which lines of WGSL each node emits. None of that can +//! tell whether the two passes compose into an unsharp mask, whether the +//! original colour really survives the hand-off from the blur pass to the +//! combining one, or whether the halo the soft limit is supposed to bound is +//! actually bounded in pixels. Those are questions only a GPU answers. +//! +//! # Why every measurement is in stops +//! +//! The controls work on log luminance, and their guarantees are stated in +//! stops: an overshoot of at most `gain * threshold`, an effect that is +//! symmetric about neutral, a strength that does not depend on how bright the +//! subject is. Asserting on 8-bit code values would restate all of that in a +//! unit where none of it is true, and would need a fresh magic number for +//! every brightness tested. So the pixels are decoded back to linear and +//! compared as ratios. +//! +//! # The test image +//! +//! A vertical step between two **midtones** rather than between black and +//! white. Clarity is tapered to nothing at both ends of the range on purpose +//! (see `midtone_weight`), so a 0–255 step is the one edge in the world it is +//! designed to leave alone, and a test built on it would measure the taper +//! working and call it the feature not working. + +use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext}; +use dr_pipeline::descriptor::{OpId, ParamId}; +use dr_pipeline::ops::local_contrast::{Clarity, Texture}; +use dr_pipeline::{Affects, EditGraph, OutputMode}; +use dr_types::ColourSpace; + +const CLARITY: OpId = OpId("clarity"); +const TEXTURE: OpId = OpId("texture"); +const AMOUNT: ParamId = ParamId("amount"); + +/// Large enough that texture's kernel — a tenth of clarity's — is still more +/// than one pixel wide. At 1024 its sigma is 1.2 px; at 256 it would round to +/// a delta and the control would honestly do nothing, which is the behaviour +/// `texture_stops_rather_than_lying_when_the_render_is_too_small` covers and +/// not the behaviour under test here. +const SIZE: u32 = 1024; + +/// The two sides of the step, as sRGB code values. +/// +/// Both well inside the range, and roughly two stops apart — a real edge, of +/// the kind that produces the halo this file exists to bound. +const DARK: u8 = 90; +const BRIGHT: u8 = 175; + +fn ctx() -> Option { + // CI runners and headless machines may have no usable adapter. Skip rather + // than fail, exactly as the rest of this crate's device tests do. + match pollster::block_on(GpuContext::new_headless()) { + Ok(c) => Some(c), + Err(e) => { + eprintln!("skipping: no GPU adapter ({e})"); + None + } + } +} + +fn srgb_decode(v: u8) -> f32 { + let e = v as f32 / 255.0; + if e <= 0.040_45 { + e / 12.92 + } else { + ((e + 0.055) / 1.055).powf(2.4) + } +} + +/// A vertical step from `DARK` to `BRIGHT` at the half-way column. +fn step_edge(ctx: &GpuContext, size: u32, tint: [f32; 3]) -> DemosaicedImage { + let data: Vec = (0..size * size) + .flat_map(|i| { + let x = i % size; + let v = if x < size / 2 { DARK } else { BRIGHT } as f32; + [ + (v * tint[0]).round() as u8, + (v * tint[1]).round() as u8, + (v * tint[2]).round() as u8, + 255, + ] + }) + .collect(); + DemosaicedImage::from_rgba8(ctx, &data, size, size).expect("upload") +} + +/// One row of the rendered image, as linear luminance-ish red values. +fn row(pixels: &[u8], size: u32, y: u32) -> Vec { + (0..size) + .map(|x| pixels[((y * size + x) * 4) as usize]) + .collect() +} + +/// One row as full RGB triples. +fn row_rgb(pixels: &[u8], size: u32, y: u32) -> Vec<[u8; 3]> { + (0..size) + .map(|x| { + let i = ((y * size + x) * 4) as usize; + [pixels[i], pixels[i + 1], pixels[i + 2]] + }) + .collect() +} + +/// Render one graph with its detail stage and read the pixels back. +fn render(pass: &mut AdjustPass, graph: &EditGraph, source: &DemosaicedImage, out: u32) -> Vec { + let shader = graph.compose_for(ColourSpace::Srgb); + let scale = graph.render_scale(source.size(), (out, out)); + let detail = graph.compose_detail_for(scale, ColourSpace::Srgb); + let key = graph.invalidation().through(Affects::Colour); + pass.render_detailed(source, &shader, out, out, None, &detail, key) + .expect("render"); + pass.export_pixels().expect("readback").0 +} + +/// A graph with one of the two controls set and everything else neutral. +fn graph_with(op: OpId, amount: f32) -> EditGraph { + let mut g = EditGraph::default_chain(); + g.set_param(op, AMOUNT, amount); + g +} + +/// How far a pixel moved, in stops, against the same pixel unedited. +fn stops(edited: u8, plain: u8) -> f32 { + (srgb_decode(edited).max(1e-6) / srgb_decode(plain).max(1e-6)).log2() +} + +#[test] +fn clarity_lifts_local_contrast_and_leaves_the_flat_regions_alone() { + // The definition of a local contrast control, as pixels: it must do + // something at the edge and *nothing* a long way from it. An operation + // that brightened the whole bright plateau would be an exposure slider + // with extra steps, and it is the failure a sign error in the base + // produces. + let Some(ctx) = ctx() else { return }; + let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]); + + let mut plain_pass = AdjustPass::new(&ctx); + let plain = row( + &render(&mut plain_pass, &EditGraph::default_chain(), &source, SIZE), + SIZE, + SIZE / 2, + ); + + let mut pass = AdjustPass::new(&ctx); + let edited = row( + &render(&mut pass, &graph_with(CLARITY, 100.0), &source, SIZE), + SIZE, + SIZE / 2, + ); + + let edge = (SIZE / 2) as usize; + let reach = Clarity::with_amount(100.0) + .kernel(EditGraph::default_chain().render_scale((SIZE, SIZE), (SIZE, SIZE))) + as usize; + + // Far outside the kernel's reach the base equals the pixel, the detail + // signal is zero, and the output must be the input to the last code value. + for x in [0, reach / 2, SIZE as usize - 1 - reach / 2, SIZE as usize - 1] { + assert!( + edited[x].abs_diff(plain[x]) <= 1, + "column {x} moved by {} away from any edge", + edited[x].abs_diff(plain[x]) + ); + } + + // And at the edge it must do the thing it is for: the bright side lifts, + // the dark side drops, which is what "more local contrast" means. + assert!( + edited[edge] > plain[edge] + 4, + "the bright side of the edge did not lift: {} vs {}", + edited[edge], + plain[edge] + ); + assert!( + edited[edge - 1] + 4 < plain[edge - 1], + "the dark side of the edge did not drop: {} vs {}", + edited[edge - 1], + plain[edge - 1] + ); +} + +#[test] +fn the_soft_limit_bounds_the_halo_at_a_hard_edge() { + // The single most common way clarity is got wrong, held to a number. + // + // `t * tanh(d / t)` saturates at `t`, so no pixel may move further than + // `gain * threshold` stops however violent the edge — a bound that holds + // by construction rather than by tuning, and one this test takes from the + // operation itself rather than restating. + // + // The comparison that gives it meaning is the second assertion: an + // unlimited unsharp mask over this edge would move the bright side by + // about half the step, which is more than twice as far. That is the + // difference between a control and a white glow along the skyline. + let Some(ctx) = ctx() else { return }; + let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]); + + let mut plain_pass = AdjustPass::new(&ctx); + let plain = row( + &render(&mut plain_pass, &EditGraph::default_chain(), &source, SIZE), + SIZE, + SIZE / 2, + ); + let mut pass = AdjustPass::new(&ctx); + let edited = row( + &render(&mut pass, &graph_with(CLARITY, 100.0), &source, SIZE), + SIZE, + SIZE / 2, + ); + + let worst = (0..SIZE as usize) + .map(|x| stops(edited[x], plain[x]).abs()) + .fold(0.0f32, f32::max); + let bound = Clarity::with_amount(100.0).overshoot_bound(); + + // A code value's worth of slack: the readback is 8-bit, and a pixel + // sitting exactly on the bound quantises either side of it. + assert!( + worst <= bound + 0.02, + "a pixel moved {worst:.3} stops, past the {bound:.3} the soft limit \ + promises" + ); + + // Half the step is what an unlimited mask would have produced at the very + // edge, since the base there is the mean of the two plateaus. + let unlimited = (srgb_decode(BRIGHT) / srgb_decode(DARK)).log2() / 2.0; + assert!( + worst < unlimited * 0.6, + "the limit is not biting: {worst:.3} stops against the {unlimited:.3} \ + an unlimited unsharp mask would give" + ); + // But it is still a real effect, not a control that does nothing. + assert!(worst > 0.1, "clarity moved almost nothing: {worst:.3} stops"); +} + +#[test] +fn a_proxy_and_an_export_agree_about_the_effect() { + // TRACES: FR-DSP-1 — the decision the radius unit rests on, proved in + // pixels rather than in kernel widths. + // + // Clarity's radius is a fraction of the frame because the control is + // compositional: "separate the subject from its background" is a statement + // about how much of the picture the subject occupies. If that is right, + // the *same edit* rendered at two resolutions must produce an effect of + // the same strength covering the same proportion of the frame — which is + // exactly what a photographer tuning on screen and exporting at full size + // is relying on. + // + // Had the radius been stated in source pixels, the proxy here would show + // half the reach and the export would be a different photograph. + let Some(ctx) = ctx() else { return }; + let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]); + + // Peak excursion in stops, and how far the effect reaches, as a fraction + // of the frame. + let measure = |out: u32| -> (f32, f32) { + let mut plain_pass = AdjustPass::new(&ctx); + let plain = row( + &render(&mut plain_pass, &EditGraph::default_chain(), &source, out), + out, + out / 2, + ); + let mut pass = AdjustPass::new(&ctx); + let edited = row( + &render(&mut pass, &graph_with(CLARITY, 100.0), &source, out), + out, + out / 2, + ); + + let moved: Vec = (0..out as usize) + .map(|x| stops(edited[x], plain[x]).abs()) + .collect(); + let peak = moved.iter().cloned().fold(0.0f32, f32::max); + // The width of the band that moved by more than a tenth of the peak — + // a threshold relative to the effect, so it means the same thing at + // both sizes. + let touched = moved.iter().filter(|m| **m > peak * 0.1).count(); + (peak, touched as f32 / out as f32) + }; + + let (proxy_peak, proxy_reach) = measure(SIZE / 2); + let (export_peak, export_reach) = measure(SIZE); + + assert!( + (proxy_peak - export_peak).abs() < 0.03, + "the same edit is {proxy_peak:.3} stops on the proxy and \ + {export_peak:.3} in the export" + ); + assert!( + (proxy_reach - export_reach).abs() < 0.02, + "the effect covers {proxy_reach:.3} of the proxy and {export_reach:.3} \ + of the export; a radius tuned on screen must land in the file" + ); + // And it is a real effect at both sizes, not two flat images agreeing. + assert!( + proxy_peak > 0.1 && proxy_reach > 0.02, + "{proxy_peak:.3} stops over {proxy_reach:.3} of the proxy" + ); +} + +#[test] +fn texture_acts_at_a_finer_scale_than_clarity() { + // The whole reason there are two nodes. If the two controls ever reach the + // same distance from an edge, the second slider has become a duplicate of + // the first and a photographer setting both is setting one thing twice. + let Some(ctx) = ctx() else { return }; + let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]); + + let mut plain_pass = AdjustPass::new(&ctx); + let plain = row( + &render(&mut plain_pass, &EditGraph::default_chain(), &source, SIZE), + SIZE, + SIZE / 2, + ); + + let reach = |op: OpId| -> usize { + let mut pass = AdjustPass::new(&ctx); + let edited = row( + &render(&mut pass, &graph_with(op, 100.0), &source, SIZE), + SIZE, + SIZE / 2, + ); + // How many columns moved by more than a code value — the honest + // measure of "how far from the edge does this control reach". + (0..SIZE as usize) + .filter(|&x| edited[x].abs_diff(plain[x]) > 1) + .count() + }; + + let coarse = reach(CLARITY); + let fine = reach(TEXTURE); + assert!(fine > 0, "texture did nothing at all"); + assert!( + coarse > fine * 4, + "clarity reaches {coarse} columns and texture {fine}; these are not \ + separable scales" + ); +} + +#[test] +fn clarity_moves_luminance_without_moving_hue() { + // The third halo decision, in pixels. The gain is applied as a scale on + // the whole triple, so chromaticity is untouched; boosting the channels + // independently would put a *coloured* fringe along every edge, arriving + // from a control the photographer reads as contrast. + let Some(ctx) = ctx() else { return }; + // A strongly tinted step, so a per-channel mask would show plainly. + let source = step_edge(&ctx, SIZE, [1.0, 0.55, 0.25]); + + let mut plain_pass = AdjustPass::new(&ctx); + let plain = row_rgb( + &render(&mut plain_pass, &EditGraph::default_chain(), &source, SIZE), + SIZE, + SIZE / 2, + ); + let mut pass = AdjustPass::new(&ctx); + let edited = row_rgb( + &render(&mut pass, &graph_with(CLARITY, 100.0), &source, SIZE), + SIZE, + SIZE / 2, + ); + + // Compare in linear light, where a scale is a scale. The two channel + // ratios together fix the chromaticity, so holding both fixes the colour. + let edge = (SIZE / 2) as usize; + for x in [edge, edge + 1, edge + 4, edge - 1, edge - 4] { + let ratio = |p: [u8; 3], i: usize| srgb_decode(p[i]) / srgb_decode(p[0]).max(1e-6); + for channel in [1, 2] { + let before = ratio(plain[x], channel); + let after = ratio(edited[x], channel); + assert!( + (after - before).abs() < 0.02, + "column {x} channel {channel}: chromaticity moved from \ + {before:.4} to {after:.4} — that is a coloured fringe" + ); + } + } + // And the effect was actually applied here, or the assertion above is + // vacuous. + assert!(edited[edge][0].abs_diff(plain[edge][0]) > 3); +} + +#[test] +fn negative_clarity_softens_the_surface_without_dissolving_the_edge() { + // The soft limit earns its keep in both directions. An unlimited mask at + // −100 subtracts the whole detail signal and turns every edge to mud; + // limited, it removes at most the threshold, so modelling softens and real + // edges stand. + let Some(ctx) = ctx() else { return }; + let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]); + + let mut plain_pass = AdjustPass::new(&ctx); + let plain = row( + &render(&mut plain_pass, &EditGraph::default_chain(), &source, SIZE), + SIZE, + SIZE / 2, + ); + let mut pass = AdjustPass::new(&ctx); + let softened = row( + &render(&mut pass, &graph_with(CLARITY, -100.0), &source, SIZE), + SIZE, + SIZE / 2, + ); + + let edge = (SIZE / 2) as usize; + // The sign is the other way round from the positive case: the bright side + // of the edge comes down and the dark side comes up. + assert!( + softened[edge] + 3 < plain[edge], + "negative clarity did not soften: {} vs {}", + softened[edge], + plain[edge] + ); + + // But the step itself survives. Measured in stops across the edge, so the + // claim is about contrast and not about code values. + let step_of = |r: &[u8]| (srgb_decode(r[edge]) / srgb_decode(r[edge - 1])).log2(); + let before = step_of(&plain); + let after = step_of(&softened); + assert!( + after > before * 0.55, + "the edge dissolved: {after:.3} stops left of {before:.3}" + ); +} + +#[test] +fn neutral_controls_cost_the_edit_nothing() { + // Both nodes are in the default chain, and both are the widest kernels in + // the pipeline. An unedited photograph must render through the single + // fused dispatch it always did — no detail pass, no intermediate texture, + // and byte-identical pixels. + let Some(ctx) = ctx() else { return }; + let source = step_edge(&ctx, 128, [1.0, 1.0, 1.0]); + let graph = EditGraph::default_chain(); + + assert_eq!( + graph.compose_for(ColourSpace::Srgb).output_mode, + OutputMode::Encoded, + "a neutral detail operation must not change how the fused pass ends" + ); + + let mut pass = AdjustPass::new(&ctx); + render(&mut pass, &graph, &source, 128); + assert_eq!(pass.colour_dispatches(), 1); + assert_eq!(pass.detail_dispatches(), 0); + assert_eq!(pass.detail_allocations(), 0, "nothing was allocated"); +} + +#[test] +fn dragging_the_slider_re_runs_the_detail_stage_and_nothing_else() { + // TRACES: FR-DEV-3d. Clarity is `Affects::Detail`, so the fused colour + // pass's result is still valid while the slider moves — which for a + // hundred-tap kernel is the difference between an interactive control and + // a slideshow. Invisible in the output by construction, so a dispatch + // counter is the only thing that can see it. + let Some(ctx) = ctx() else { return }; + let source = step_edge(&ctx, 256, [1.0, 1.0, 1.0]); + let mut pass = AdjustPass::new(&ctx); + + let mut graph = graph_with(CLARITY, 40.0); + render(&mut pass, &graph, &source, 256); + assert_eq!(pass.colour_dispatches(), 1); + assert_eq!(pass.detail_dispatches(), 2, "a separable mask is two passes"); + let pipelines = pass.cached_detail_pipelines(); + + for amount in [50.0, 60.0, 70.0] { + graph.set_param(CLARITY, AMOUNT, amount); + render(&mut pass, &graph, &source, 256); + } + assert_eq!( + pass.colour_dispatches(), + 1, + "the fused colour pass re-ran for a change it does not depend on" + ); + assert_eq!(pass.detail_dispatches(), 8); + assert_eq!( + pass.cached_detail_pipelines(), + pipelines, + "an amount is a uniform, not a shader" + ); + + // Turning on the other control adds its own pair, and only its own pair. + graph.set_param(TEXTURE, AMOUNT, 40.0); + render(&mut pass, &graph, &source, 256); + assert_eq!(pass.detail_dispatches(), 12); + assert_eq!(pass.colour_dispatches(), 1); +} + +#[test] +fn the_two_controls_stack_without_overwriting_each_other() { + // Four passes through one ping-pong, with the scratch lane changing hands + // half way. If clarity's combining pass left the colour where its blur + // pass had put it — or if texture's blur overwrote the colour rather than + // the lane — the result would be a blurred image rather than a sharpened + // one, which is loud rather than subtle. + let Some(ctx) = ctx() else { return }; + let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]); + + let mut plain_pass = AdjustPass::new(&ctx); + let plain = row( + &render(&mut plain_pass, &EditGraph::default_chain(), &source, SIZE), + SIZE, + SIZE / 2, + ); + + let mut graph = graph_with(CLARITY, 80.0); + graph.set_param(TEXTURE, AMOUNT, 80.0); + let mut pass = AdjustPass::new(&ctx); + let both = row(&render(&mut pass, &graph, &source, SIZE), SIZE, SIZE / 2); + assert_eq!(pass.detail_dispatches(), 4); + + let edge = (SIZE / 2) as usize; + // Both sides of the edge move the way local contrast moves them... + assert!(both[edge] > plain[edge] + 4); + assert!(both[edge - 1] + 4 < plain[edge - 1]); + // ...and the plateaus are untouched, which a stray blur would not leave. + assert!(both[0].abs_diff(plain[0]) <= 1); + assert!(both[SIZE as usize - 1].abs_diff(plain[SIZE as usize - 1]) <= 1); + + // Stacked, they must reach further than either alone — the coarse control + // still working at its own scale rather than being overwritten by the fine + // one running after it. + let mut clarity_only = AdjustPass::new(&ctx); + let coarse = row( + &render(&mut clarity_only, &graph_with(CLARITY, 80.0), &source, SIZE), + SIZE, + SIZE / 2, + ); + assert!( + both[edge] >= coarse[edge], + "adding texture undid clarity: {} against {}", + both[edge], + coarse[edge] + ); +} + +#[test] +fn texture_contributes_nothing_where_its_scale_does_not_exist() { + // Unlike the acutance family this is not an approximation being hidden. A + // two-pixel surface structure is not present in a 128-pixel rendering of + // the frame, so the honest answer is no pass at all — and clarity, a + // hundred times wider, still runs, which is what a thumbnail should show. + let Some(ctx) = ctx() else { return }; + let source = step_edge(&ctx, 512, [1.0, 1.0, 1.0]); + + let mut graph = graph_with(TEXTURE, 100.0); + let mut pass = AdjustPass::new(&ctx); + render(&mut pass, &graph, &source, 128); + assert_eq!( + pass.detail_dispatches(), + 0, + "texture claimed a kernel it cannot draw" + ); + + graph.set_param(CLARITY, AMOUNT, 100.0); + render(&mut pass, &graph, &source, 128); + assert_eq!(pass.detail_dispatches(), 2, "clarity survives a thumbnail"); + + // And texture comes back, exactly, as soon as the view is large enough to + // hold it — no separate path, no fade, just the kernel resolving again. + let mut zoomed = AdjustPass::new(&ctx); + render(&mut zoomed, &graph_with(TEXTURE, 100.0), &source, 1024); + assert_eq!(zoomed.detail_dispatches(), 2); +} diff --git a/core/dr-pipeline/ops/clarity.yaml b/core/dr-pipeline/ops/clarity.yaml new file mode 100644 index 0000000..ea16cd2 --- /dev/null +++ b/core/dr-pipeline/ops/clarity.yaml @@ -0,0 +1,36 @@ +# A hand-written node. `rust:` names the type in `crate::ops` that implements +# `Operation`; its descriptor, its parameters and its passes come from that +# type rather than from this file. It appears here anyway because `ops/` is +# where the pipeline's order is written down, and an order kept half in YAML +# and half in Rust would be worse than either alone. +id: clarity +order: 120 + +attributes: [detail] +rust: Clarity + +why_rust: | + A neighbourhood operation. Clarity is defined by what the pixels around a + pixel are doing, and the schema above describes a function of one colour — + `wgsl:` is handed `c` and no coordinate, which is the wall the detail stage + exists on the other side of. It declares `Affects::Detail` and returns two + `DetailPass`es: a Gaussian of log luminance along each axis, the second of + which also applies the mask. + + A kernel is also not four facts. Stretching this schema to express a + truncation rule, a soft limit and a midtone taper would produce a worse + language than Rust, aimed at one caller. + +placement: | + In the detail group, after noise reduction and before sharpening. + + Order inside the group is not arbitrary. Clarity multiplies local contrast, + so it multiplies noise with it — running it before noise reduction would ask + the denoiser to remove grain that clarity had already amplified into + structure. And capture sharpening belongs last, on the picture as it will + finally be, so that the acutance a photographer judges at 1:1 is the acutance + in the file. + + Before texture, which is a decade finer: coarse before fine, so the fine + control's base is computed on the modelling the coarse one has already + settled. diff --git a/core/dr-pipeline/ops/texture.yaml b/core/dr-pipeline/ops/texture.yaml new file mode 100644 index 0000000..6db3402 --- /dev/null +++ b/core/dr-pipeline/ops/texture.yaml @@ -0,0 +1,23 @@ +# A hand-written node — see `clarity.yaml`, whose implementation this shares. +id: texture +order: 130 + +attributes: [detail] +rust: Texture + +why_rust: | + The same neighbourhood operation as clarity, at a tenth of the scale: one + implementation in `src/ops/local_contrast.rs`, parameterised by the band it + acts on. Two nodes rather than one node with two sliders because the radius + is the *definition* of each control rather than a setting of it, and because + a texture at zero must then cost nothing at all — which `is_active()` gives + for free and a merged node would have had to hand-write. + +placement: | + Immediately after clarity, and for the same reasons: after noise reduction, + which must not be handed amplified grain, and before capture sharpening, + which belongs last. + + After clarity specifically, so that the fine base is computed on the + modelling the coarse control has already settled rather than the other way + round. diff --git a/core/dr-pipeline/src/detail.rs b/core/dr-pipeline/src/detail.rs index cccc909..6943d1b 100644 --- a/core/dr-pipeline/src/detail.rs +++ b/core/dr-pipeline/src/detail.rs @@ -315,6 +315,37 @@ pub struct DetailPass { /// them in the shader. Prefer computing lengths on the CPU in /// [`DetailStage::passes`], where the units are named methods rather /// than an untyped float. + /// - `aux: f32` and `tap_aux(coord, offset) -> f32` — **one scalar per + /// pixel that survives to the next pass**, pre-loaded with what the + /// previous pass left there and written back out unless the body + /// assigns it. + /// + /// # Why `aux` exists + /// + /// The ping-pong hands each pass exactly one texture: what the pass before + /// it wrote. That is enough for a chain of filters — a separable blur is + /// two of them — and it is *not* enough for an unsharp mask, which is the + /// shape of sharpening, clarity, texture and dehaze alike. An unsharp mask + /// needs the blur **and** the original in the same place at the same time, + /// and once the first pass has written its blur the original is gone. + /// + /// Three channels cannot carry both. Even restricted to the case where the + /// operation only moves luminance — so the colour is a luminance and two + /// chromaticity degrees of freedom — the combining pass needs four + /// numbers: the original luminance, two of chromaticity, and the blurred + /// luminance. Four does not fit in three, and no encoding makes it fit. + /// + /// The intermediate is `rgba16float` and its alpha was being written as a + /// constant `1.0` and read by nobody, so the fourth number goes there. A + /// blur pass leaves `c` alone and puts its result in `aux`; the pass after + /// it therefore receives the untouched original *and* the blur, and can + /// subtract one from the other. An operation with no use for the lane says + /// nothing and hands on what it was given. + /// + /// The last pass in the chain writes the display texture, whose alpha is + /// opacity rather than scratch space, so `aux` is readable there and not + /// written. That is exactly the right way round: the combining pass is the + /// one that reads it. /// /// Uniforms are addressed by the bare names declared in [`Self::uniforms`], /// exactly as a fused fragment addresses its own; the composer rewrites @@ -536,7 +567,11 @@ fn compose_one( "rgba16float", " // Another linear intermediate: no clip and no encode, because\n\ \x20 // the pass after this one still has to read real values.\n\ - \x20 textureStore(output, coord, vec4(c, 1.0));" + \x20 //\n\ + \x20 // `aux` rides in alpha. A pass that never touches it hands on\n\ + \x20 // whatever it was given, so the lane costs an operation that\n\ + \x20 // does not want it exactly one copy of a value it already read.\n\ + \x20 textureStore(output, coord, vec4(c, aux));" .to_string(), ) }; @@ -582,6 +617,12 @@ fn tap(coord: vec2, offset: vec2) -> vec3 {{ return textureLoad(source, clamp(coord + offset, vec2(0), last), 0).rgb; }} +// The same neighbour's scratch lane — see `aux` in the body below. +fn tap_aux(coord: vec2, offset: vec2) -> f32 {{ + let last = vec2(textureDimensions(source)) - vec2(1); + return textureLoad(source, clamp(coord + offset, vec2(0), last), 0).a; +}} + {helper_src}{encode_fn} @compute @workgroup_size(8, 8, 1) fn main(@builtin(global_invocation_id) gid: vec3) {{ @@ -596,6 +637,10 @@ fn main(@builtin(global_invocation_id) gid: vec3) {{ let render_scale = u.detail_base.z; var c = tap(coord, vec2(0)); + // One scalar per pixel that survives the hand-off from one pass to the + // next, alongside the colour. See `DetailPass::wgsl` for what it is for + // and why three channels were not enough. + var aux = tap_aux(coord, vec2(0)); {{ {indented} @@ -880,6 +925,44 @@ mod tests { } } + #[test] + fn a_pass_can_hand_a_scalar_to_the_next_one_alongside_the_colour() { + // What makes an unsharp mask — sharpening, clarity, texture, dehaze — + // expressible at all in a chain that hands each pass exactly one + // texture. The blur goes in `aux` and the colour rides through + // untouched, so the pass that combines them receives both; without the + // lane, four numbers would have to fit in three channels and the + // operation could only ever be a blur. + // + // A pass that says nothing about `aux` hands on what it was given, + // which is why the box blur below needs no knowledge of it. + let ops = with_blur(0.05); + let composed = compose_detail( + &ops, + RenderScale::full((512, 512)), + dr_types::ColourSpace::Srgb, + ); + + for pass in &composed.passes { + assert!( + pass.source.contains("fn tap_aux(") && pass.source.contains("var aux = tap_aux("), + "{} cannot read the scratch lane", + pass.label + ); + } + assert!( + composed.passes[0] + .source + .contains("textureStore(output, coord, vec4(c, aux));"), + "an intermediate must carry the lane to the pass after it" + ); + // The last pass writes the display texture, whose alpha is opacity and + // not scratch space. Readable there, not written — which is the right + // way round, because the combining pass is the one that reads it. + assert!(composed.passes[1].writes_output); + assert!(!composed.passes[1].source.contains("vec4(c, aux)")); + } + #[test] fn an_edit_with_no_detail_operation_composes_no_passes() { // The property that keeps the cost of this stage at zero for the diff --git a/core/dr-pipeline/src/ops/local_contrast.rs b/core/dr-pipeline/src/ops/local_contrast.rs new file mode 100644 index 0000000..5724027 --- /dev/null +++ b/core/dr-pipeline/src/ops/local_contrast.rs @@ -0,0 +1,828 @@ +//! TRACES: FR-DEV-3 | FR-DSP-1 +//! Clarity and texture — local contrast at two scales. +//! +//! Both are unsharp masks. Both build a blurred *base*, subtract it from the +//! pixel to get a local contrast signal, and add a multiple of that signal +//! back. The only thing that separates them is the width of the blur, and +//! that single difference is the whole of what a photographer means by the two +//! words: +//! +//! - **Clarity** works at roughly a hundredth of the frame. At that scale the +//! base is a picture of where the *subject* is, so the difference is the +//! subject's modelling — the sense of a face standing away from its +//! background, of cloud having volume. It is the "punch" control, and it is +//! also the one that produces visible halos when it is got wrong, because a +//! forty-pixel overshoot along a skyline is not a subtlety. +//! +//! - **Texture** works a decade finer, at a few pixels. At that scale the base +//! is a picture of the *surface*, so the difference is skin, fabric, bark, +//! foliage. Its overshoot is a band two or three pixels wide, which the eye +//! reads as acutance rather than as a halo — which is exactly why it can be +//! pushed much harder than clarity without looking artificial. +//! +//! # Why two nodes and not one node with two parameters +//! +//! The tempting shape is a single `local_contrast` node with a `clarity` and a +//! `texture` slider, since they share every line of machinery. It is the wrong +//! one, for four reasons that all point the same way. +//! +//! **The scale is not a parameter, it is the definition.** Neither control +//! exposes a radius, and neither should: a texture slider with a large radius +//! *is* clarity, and offering the photographer that knob would ask them to +//! re-derive the distinction the two names already make. So the radius is a +//! constant of the node — and a node whose defining constant differs is a +//! different node, not a different setting. +//! +//! **Neutrality would have to be re-implemented by hand.** The rule the whole +//! pipeline rests on is that an operation at its defaults contributes nothing: +//! no code, no uniform, no dispatch. `is_active()` gives each of these that for +//! free. Merged, the node would be active whenever *either* slider had moved, +//! and would then need an internal guard per half to avoid dispatching a +//! forty-pixel blur for a control sitting at zero — hand-writing, in one +//! place, the thing the pipeline already does everywhere. +//! +//! **There is no dispatch to save.** The usual reason to merge two operations +//! is to fuse their work. Here there is nothing to fuse: the two blurs are +//! different blurs, by definition, so a merged node costs the same four passes +//! that two nodes cost, and costs them in the same order. +//! +//! **The sidecar, the history and the reset all read better.** `clarity.amount` +//! and `texture.amount` say what they are; `local_contrast.clarity` names a +//! concept no photographer asked for in order to reach one that they did. +//! Undo says "clarity", and double-tapping clarity to reset it leaves texture +//! alone — which is what a photographer who has just tuned texture expects. +//! +//! Against all that, the cost of two nodes is one shared implementation +//! parameterised by a [`Band`], below. The `attributes:` grouping is +//! `[detail]` either way, so it offers no argument in either direction. +//! +//! # Halos, and what is done about them +//! +//! A naive unsharp mask — `c + amount * (c - blur(c))` in linear light — is +//! the single most common way this feature is got wrong, and it fails in four +//! separate ways at once. Each is addressed by a specific decision here. +//! +//! **1. Work in stops, not in levels.** The base is a Gaussian mean of *log* +//! luminance, so the detail signal is a ratio: "this pixel is 0.4 stops +//! brighter than its surroundings". In linear light the same edge produces an +//! overshoot proportional to absolute brightness, so an edge against a bright +//! sky blows out while the identical edge in shadow does nothing — and the +//! amount that looked right stops looking right the moment exposure moves. +//! Stops also make the negative direction symmetric: −50 removes exactly the +//! proportion of local contrast that +50 adds. +//! +//! **2. Soft-limit the detail signal — this is the main halo control.** The +//! signal is passed through `t * tanh(d / t)` before it is used. Below the +//! threshold the function is the identity to within a percent, so structure +//! and surface detail pass through at full strength; far above it the output +//! saturates at `t` whatever the input, so a four-stop skyline transition +//! contributes no more overshoot than a one-stop one. That is the distinction +//! between *structure* and an *edge*, drawn on amplitude rather than by an +//! edge detector — a guided or bilateral base would draw it more precisely and +//! would cost several more full-frame passes to do it. `tanh` costs one +//! instruction and has no threshold artefact, because it is smooth everywhere; +//! a hard clamp would put a visible contour along the locus where the detail +//! signal crosses `t`. +//! +//! It is also the right thing on the negative side. At amount −100 an +//! unlimited unsharp mask subtracts the whole detail signal and dissolves +//! edges into mud; limited, it removes at most `t` stops, so negative clarity +//! softens surface and modelling while leaving real edges standing. +//! +//! **3. Move luminance only, and scale the triple.** The gain is applied as +//! `c * 2^stops`, which leaves chromaticity exactly where it was. Boosting the +//! three channels independently shifts hue and saturation wherever the detail +//! signal is large — that is a *coloured* fringe along every edge, arriving +//! from a control the photographer thinks of as contrast, and it is the hardest +//! kind of halo to attribute to its cause. `apply_tone_gain` already takes this +//! position for the tonal controls, for the same reason. +//! +//! **4. Taper clarity to nothing at both ends of the range.** Clarity is +//! midtone structure by definition, and its two worst halos are at the +//! extremes: a bright sky beside a dark subject blooms, and deep shadow goes +//! to mud. A weight of `1 - (2p - 1)^2` over the perceptual tone position +//! removes exactly those, and stops a *contrast* slider from creating a blown +//! highlight by pushing a recovered value back over one. +//! +//! Texture deliberately does **not** get this taper. Skin in a highlight and +//! fabric in a shadow are precisely what the control is for, and a fine-scale +//! overshoot at either end is a two-pixel band, not a bloom. +//! +//! # Why the radius is a fraction of the frame +//! +//! [`RenderScale`] names two units, and picking the wrong one produces an +//! effect that is a different photograph on screen and in the file. These two +//! controls take [`RenderScale::frame_fraction`] — the unit a mask feather is +//! already stored in — and not [`RenderScale::source_pixels`]. +//! +//! The test is whose property the length is. Capture sharpening's radius +//! belongs to the *sensor*: it is about the lens's circle of confusion and the +//! demosaic's interpolation, both of which are facts about the file and +//! neither of which changes if the photograph is cropped. Clarity's radius +//! belongs to the *composition*: "separate the subject from its background" is +//! a statement about how much of the frame the subject occupies, and it stays +//! true when the same frame is printed large or viewed small. Crop into a +//! quarter of the frame and the subject now fills it, so the scale that models +//! it really has grown — which `frame_fraction` gives, because +//! [`crate::EditGraph::render_scale`] folds the crop in before this code runs. +//! +//! The practical consequence is that these two controls preview honestly at +//! every zoom level, which the acutance family cannot. There is no +//! [`RenderScale::resolves`] check here and no reason for one: at a small +//! render the kernel shrinks with the frame and keeps its proportions, and the +//! effect is the effect. +//! +//! Texture does eventually round to a zero-pixel kernel on a thumbnail, and +//! then contributes no pass at all. That is not the acutance family's problem +//! restated — it is the honest answer. A two-pixel surface structure is not +//! present in a 300-pixel rendering of the frame in the first place, and it +//! reappears, exactly, as soon as the view is zoomed. +//! +//! # What this costs +//! +//! Clarity's kernel is large — of the order of a hundred taps per pass at +//! preview resolution — and the two passes are the honest, exact separable +//! Gaussian rather than a sparse approximation of one. A strided kernel would +//! be several times cheaper and is deliberately not taken: undersampling an +//! image that is not band-limited aliases high-frequency content down into the +//! base, the base is then subtracted, and the aliasing arrives in the output as +//! low-frequency mottling across smooth gradients. Mottled skies are precisely +//! the artefact this control must not have. The right optimisation is a base +//! computed at reduced resolution, which needs a detail stage that can write a +//! smaller target than it reads; that is a change to [`crate::detail`], not to +//! this file. + +use std::marker::PhantomData; + +use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId}; +use crate::detail::{DetailPass, DetailStage, RenderScale}; +use crate::operation::{Affects, Helper, Operation, Uniform}; +use crate::ops::helpers; + +pub const CLARITY: OpId = OpId("clarity"); +pub const TEXTURE: OpId = OpId("texture"); + +/// The one parameter each control has. Both are called `amount`, so the +/// sidecar keys read `clarity.amount` and `texture.amount`. +pub const AMOUNT: ParamId = ParamId("amount"); + +/// How far the kernel runs, in standard deviations. +/// +/// Two, not three. A Gaussian truncated at 2σ and renormalised keeps 95.4% of +/// its mass and is still a perfectly monotone low-pass; the missing tail +/// changes the base by less than the difference between two adjacent settings +/// of the slider, and it halves the tap count of the widest pass in the +/// pipeline. +const TRUNCATION: f32 = 2.0; + +/// Everything that makes one of these two controls the control it is. +/// +/// A struct rather than four associated constants so that the differences +/// between clarity and texture can be read side by side, which is the one +/// thing a reader comes to this file to do. +pub struct Recipe { + descriptor: &'static OpDescriptor, + helpers: &'static [Helper], + /// The Gaussian's σ, as a fraction of the frame's shorter edge. + sigma: f32, + /// Where the soft limit starts to bite, in stops. See the module + /// documentation, halo control (2). + threshold: f32, + /// Stops of local contrast added at full slider travel. + gain: f32, + /// Whether the effect is tapered away from the midtones. See halo control + /// (4) — true for clarity, false for texture, and that asymmetry is + /// deliberate. + midtone_taper: bool, +} + +/// The band of spatial frequencies a control acts on. +/// +/// The type parameter of [`LocalContrast`], because the scale is the *only* +/// thing that differs between clarity and texture and it differs at compile +/// time. One implementation, two nodes, and no branch anywhere that could +/// drift. +pub trait Band: Send + Sync + 'static { + const RECIPE: Recipe; +} + +/// Clarity's band: roughly a hundredth of the frame. +pub struct Coarse; + +/// Texture's band: a decade finer, a few pixels at any size. +pub struct Fine; + +impl Band for Coarse { + const RECIPE: Recipe = Recipe { + descriptor: &CLARITY_DESCRIPTOR, + helpers: CLARITY_HELPERS, + // 1.2% of the shorter edge — about 48 px on a 4000 px frame. Wide + // enough that the base is the subject rather than the surface, narrow + // enough that the result is still local contrast and not a second + // exposure slider. + sigma: 0.012, + // A third of a stop. Clarity's whole difficulty is that a wide kernel + // sees an enormous detail signal at every real edge, so the limit has + // to bite early: at full travel the largest overshoot any edge can + // produce is a third of a stop, about 26%, before the midtone taper + // reduces it further. + threshold: 0.35, + gain: 1.0, + midtone_taper: true, + }; +} + +impl Band for Fine { + const RECIPE: Recipe = Recipe { + descriptor: &TEXTURE_DESCRIPTOR, + helpers: TEXTURE_HELPERS, + // Exactly a decade below clarity, which is what makes the two controls + // separable in use: at a ten-to-one ratio of scales, neither can + // substantially do the other's job, so a photographer setting both is + // setting two things and not the same thing twice. + sigma: 0.0012, + // Three times clarity's, because at this scale the overshoot is a band + // two or three pixels wide and the eye reads that as acutance. Limiting + // it as hard as clarity would take the crispness out of the one control + // that exists to provide it. + threshold: 1.0, + // A narrow overshoot carries less visual weight than a wide one, so + // equal numbers on the two sliders should land at comparable strength. + gain: 1.25, + midtone_taper: false, + }; +} + +static CLARITY_DESCRIPTOR: OpDescriptor = OpDescriptor { + id: CLARITY, + label: LocalizedKey("op.clarity"), + params: &[ParamDescriptor::amount("amount", "param.clarity.amount")], + attributes: &[Attribute::Detail], +}; + +static TEXTURE_DESCRIPTOR: OpDescriptor = OpDescriptor { + id: TEXTURE, + label: LocalizedKey("op.texture"), + params: &[ParamDescriptor::amount("amount", "param.texture.amount")], + attributes: &[Attribute::Detail], +}; + +/// Luminance as a position on a logarithmic scale, floored. +/// +/// Declared here rather than in `_helpers.yaml` because it is not a shared +/// idea: it exists so that the base can be a mean of *log* luminance, which is +/// the first of this file's four halo decisions and means nothing outside it. +const LOG_LUMA: Helper = Helper { + name: "log_luma", + source: "\ +// Luminance in stops, floored fourteen stops below white. +// +// The floor is what makes the logarithm safe on the values this stage +// actually receives: the intermediate is unclipped and scene-referred, so a +// pixel can be exactly zero and an out-of-gamut colour can be negative. It +// sits far enough down that no real signal is affected — a fourteen-stop +// range is more than any sensor delivers — and it turns both of those into a +// very dark pixel rather than an infinity that would propagate through the +// blur into every pixel within the kernel's reach. +fn log_luma(c: vec3) -> f32 { + return log2(max(luminance(c), 0.00006103515625)); +}", +}; + +/// How much of clarity applies at a given luminance. +const MIDTONE_WEIGHT: Helper = Helper { + name: "midtone_weight", + source: "\ +// A parabola over the perceptual tone position: one in the midtones, zero at +// both black and white. +// +// Clarity is midtone structure by definition, and this is also where two of +// its three worst halos live — a bright sky beside a dark subject blooms, and +// deep shadow turns to mud. Tapering to nothing at both ends removes them, and +// stops a control the photographer reads as `contrast` from pushing a +// recovered highlight back over one and clipping it. +// +// `tone_position` rather than the raw value, so the taper is even to the eye +// rather than crowded into the bottom of the range the way a linear weight +// would be. +fn midtone_weight(luma: f32) -> f32 { + let p = 2.0 * tone_position(luma) - 1.0; + return 1.0 - p * p; +}", +}; + +static CLARITY_HELPERS: &[Helper] = &[ + helpers::LUMINANCE, + LOG_LUMA, + helpers::TONE_POSITION, + MIDTONE_WEIGHT, +]; + +static TEXTURE_HELPERS: &[Helper] = &[helpers::LUMINANCE, LOG_LUMA]; + +/// An unsharp mask at one fixed scale. +/// +/// See the module documentation for why the scale is a type parameter rather +/// than a parameter, and why there are two nodes rather than one. +pub struct LocalContrast { + /// −100…100, exactly as the slider reports it. + amount: f32, + band: PhantomData, +} + +/// Clarity: local contrast at roughly a hundredth of the frame. +pub type Clarity = LocalContrast; + +/// Texture: local contrast a decade finer than clarity. +pub type Texture = LocalContrast; + +impl Default for LocalContrast { + fn default() -> Self { + Self { + amount: 0.0, + band: PhantomData, + } + } +} + +impl LocalContrast { + pub fn new() -> Self { + Self::default() + } + + /// Start from a slider position, for tests and presets. + pub fn with_amount(amount: f32) -> Self { + Self { + amount, + band: PhantomData, + } + } + + /// The Gaussian's σ at this render, in **render pixels**. + /// + /// The one conversion this operation performs, and the reason it happens + /// here rather than in WGSL: `frame_fraction` is named after its unit, + /// where a bare `f32` in a shader would not be. + pub fn sigma(&self, scale: RenderScale) -> f32 { + scale.frame_fraction(B::RECIPE.sigma) + } + + /// The kernel radius at this render, in render pixels. + /// + /// Exposed so a test can state what it expects without repeating the + /// rounding rule — a test that recomputed it would agree with a bug. + pub fn kernel(&self, scale: RenderScale) -> u32 { + (self.sigma(scale) * TRUNCATION).round().max(0.0) as u32 + } + + /// Stops of local contrast at this slider position. + fn gain(&self) -> f32 { + self.amount / 100.0 * B::RECIPE.gain + } + + /// The largest excursion this control can produce at its current setting, + /// in stops — the bound the soft limit guarantees. + /// + /// `gain * threshold`, because `t * tanh(d / t)` saturates at `t` however + /// violent the edge. Public because it is the one number a test can hold + /// the halo to without re-deriving the shader: whatever the picture, no + /// pixel may move further than this. See the module documentation, halo + /// control (2). + pub fn overshoot_bound(&self) -> f32 { + self.gain().abs() * B::RECIPE.threshold + } +} + +impl Operation for LocalContrast { + fn descriptor(&self) -> &'static OpDescriptor { + B::RECIPE.descriptor + } + + fn set_param(&mut self, _id: ParamId, value: f32) { + self.amount = value; + } + + fn param(&self, _id: ParamId) -> f32 { + self.amount + } + + fn is_active(&self) -> bool { + self.amount != 0.0 + } + + /// Never called: a neighbourhood operation contributes no fused fragment, + /// and `compose_full` filters it out before asking. + fn wgsl_body(&self) -> String { + String::new() + } + + fn uniforms(&self) -> Vec { + Vec::new() + } + + fn affects(&self) -> Affects { + Affects::Detail + } + + fn detail(&self) -> Option<&dyn DetailStage> { + Some(self) + } + + fn helpers(&self) -> &'static [Helper] { + B::RECIPE.helpers + } +} + +impl DetailStage for LocalContrast { + fn passes(&self, scale: RenderScale) -> Vec { + let sigma = self.sigma(scale); + let radius = self.kernel(scale); + + // A kernel that rounded to nothing is not "blur by zero" — it is a + // scale this render is too small to show. Texture reaches this on a + // thumbnail and the honest answer is to contribute no pass, which is + // also what stops a degenerate one-tap Gaussian from burning two + // dispatches to copy the image. + if radius == 0 { + return Vec::new(); + } + + // 1/σ², so the shader's inner loop is a multiply rather than a + // division per tap. + let inv_variance = 1.0 / (sigma * sigma); + let shape = vec![ + Uniform { + name: "radius", + value: radius as f32, + }, + Uniform { + name: "inv_variance", + value: inv_variance, + }, + ]; + + let mut combine = shape.clone(); + combine.push(Uniform { + name: "threshold", + value: B::RECIPE.threshold, + }); + combine.push(Uniform { + name: "gain", + value: self.gain(), + }); + + vec![ + DetailPass { + label: "base", + radius, + uniforms: shape, + wgsl: BASE_X.to_string(), + }, + DetailPass { + label: "combine", + radius, + uniforms: combine, + wgsl: combine_body(B::RECIPE.midtone_taper), + }, + ] + } +} + +/// Half of the base, along x. +/// +/// Deliberately does not touch `c`: the pass after this one needs the +/// *original* colour as well as the blur, which is what an unsharp mask is and +/// why the `aux` lane exists at all. +const BASE_X: &str = "\ +// Half of a separable Gaussian, over log luminance, along x. +// +// The colour is left exactly as it arrived. An unsharp mask needs the blur and +// the original in the same place at the same time, and the ping-pong hands +// each pass only what the pass before it wrote — so the blur travels in `aux` +// and the colour rides through untouched. See `DetailPass::wgsl`. +// +// Weights are evaluated rather than tabulated: a table would need a uniform +// array sized for the largest kernel any resolution could ask for, and `exp` +// is cheaper than the bandwidth that array would cost. +let r = i32(radius); +var sum = 0.0; +var weight = 0.0; +for (var i = -r; i <= r; i = i + 1) { + let f = f32(i); + let w = exp(-0.5 * f * f * inv_variance); + sum = sum + w * log_luma(tap(coord, vec2(i, 0))); + weight = weight + w; +} +// Normalised by the weights actually summed, not by an analytic constant, so +// truncating the Gaussian at 2σ leaves a true mean rather than a slightly dark +// one — and so a kernel clamped at the image border averages the pixels that +// exist. +aux = sum / weight;"; + +/// The second pass: finish the base along y, then apply the mask. +/// +/// Generated rather than constant because the midtone taper is present for +/// clarity and absent for texture. Emitting the line only where it applies +/// keeps texture's shader honest about not having one, and saves it a uniform +/// and two helper functions it would never call. +fn combine_body(midtone_taper: bool) -> String { + let weight = if midtone_taper { + "\n\ + // Clarity only: tapered to nothing at both ends of the range. See\n\ + // `midtone_weight` for what that is worth against a halo.\n\ + let stops = gain * shaped * midtone_weight(luminance(c));" + } else { + "\n\ + // Texture is deliberately *not* tapered towards black and white.\n\ + // Skin in a highlight and fabric in a shadow are what the control is\n\ + // for, and at this scale an overshoot is two pixels wide — acutance,\n\ + // not a bloom.\n\ + let stops = gain * shaped;" + }; + + format!( + "\ +// The other half of the base, then the unsharp mask itself. +// +// `tap_aux` reads the previous pass's log-luminance blur, while `c` is still +// the colour the colour pass produced — which is the arrangement that makes an +// unsharp mask expressible in a chain that hands on one texture per pass. +let r = i32(radius); +var sum = 0.0; +var weight = 0.0; +for (var i = -r; i <= r; i = i + 1) {{ + let f = f32(i); + let w = exp(-0.5 * f * f * inv_variance); + sum = sum + w * tap_aux(coord, vec2(0, i)); + weight = weight + w; +}} +let base = sum / weight; + +// Local contrast, in **stops**. Both terms are logarithms, so this is a ratio: +// `detail` says how much brighter this pixel is than its surroundings, and +// says it in a unit that means the same thing in a highlight and in a shadow. +// The linear-light difference an unsharp mask usually takes does not, which is +// why it blows out bright edges and does nothing to dark ones. +let detail = log_luma(c) - base; + +// The halo control. Below the threshold `tanh` is the identity to within a +// percent, so structure and surface pass through at full strength; far above +// it the output saturates at the threshold, so a four-stop edge contributes no +// more overshoot than a one-stop one. Smooth everywhere, so unlike a clamp it +// leaves no contour along the locus where the detail signal crosses it. +let shaped = threshold * tanh(detail / threshold); +{weight} + +// Applied as a scale on the whole triple, which leaves chromaticity exactly +// where it was. Boosting the channels independently would put a *coloured* +// fringe along every edge, arriving from a control the photographer reads as +// contrast — the hardest kind of halo to attribute to its cause. +c = c * exp2(stops);" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::detail::compose_detail; + use dr_types::ColourSpace; + + /// The two controls, as the graph would hold them. + fn ops(clarity: f32, texture: f32) -> Vec> { + vec![ + Box::new(Clarity::with_amount(clarity)), + Box::new(Texture::with_amount(texture)), + ] + } + + fn composed(clarity: f32, texture: f32, scale: RenderScale) -> crate::ComposedDetail { + compose_detail(&ops(clarity, texture), scale, ColourSpace::Srgb) + } + + #[test] + fn both_controls_start_neutral_and_cost_nothing() { + // The rule the whole pipeline rests on. An unedited photograph must + // not pay for a clarity slider nobody has touched — and, because these + // are the widest kernels in the pipeline, "nothing" here is a large + // amount of nothing. + assert!(!Clarity::new().is_active()); + assert!(!Texture::new().is_active()); + assert!(composed(0.0, 0.0, RenderScale::full((2000, 1500))).is_empty()); + } + + #[test] + fn one_control_moving_does_not_dispatch_the_other() { + // The concrete reason these are two nodes rather than one with two + // sliders. Merged, the node would be active whenever either had moved + // and would need a hand-written guard per half to avoid running a + // forty-pixel blur for a control sitting at zero. + let scale = RenderScale::full((2000, 1500)); + let only_clarity = composed(50.0, 0.0, scale); + assert_eq!(only_clarity.len(), 2, "one operation, two passes"); + assert!(only_clarity.passes.iter().all(|p| p.label.starts_with("clarity/"))); + + let both = composed(50.0, 50.0, scale); + assert_eq!(both.len(), 4); + } + + #[test] + fn the_two_controls_differ_by_a_decade_of_scale() { + // The whole point of there being two of them. If these ever converge, + // one of the controls has stopped doing its job and the second slider + // has become a duplicate of the first. + let scale = RenderScale::full((4000, 3000)); + let clarity = Clarity::with_amount(100.0).kernel(scale); + let texture = Texture::with_amount(100.0).kernel(scale); + // 1.2% of the *shorter* edge — 3000, not 4000 — and two sigmas wide. + assert_eq!(clarity, 72); + assert_eq!(texture, 7, "a decade finer"); + assert!( + clarity >= texture * 8, + "clarity {clarity} and texture {texture} are not separable scales" + ); + } + + #[test] + fn a_radius_is_a_fraction_of_the_frame_and_not_a_count_of_source_pixels() { + // TRACES: FR-DSP-1 — the decision this whole file's units rest on. + // + // Clarity is compositional: "separate the subject from its background" + // is a statement about how much of the frame the subject occupies, and + // it stays true at every size the frame is rendered at. So the kernel + // must cover the same *proportion* of the picture on a proxy as in the + // export, which is what `frame_fraction` gives and what + // `source_pixels` would not. + let sizes = [(400u32, 300u32), (2000, 1500), (6000, 4500)]; + let proportions: Vec = sizes + .iter() + .map(|&(w, h)| { + let scale = RenderScale::full((w, h)); + Clarity::with_amount(60.0).kernel(scale) as f32 / w.min(h) as f32 + }) + .collect(); + for p in &proportions { + assert!( + (p - 0.024).abs() < 0.002, + "the kernel drifted from its declared fraction: {proportions:?}" + ); + } + + // And the contrast with the other unit, stated rather than implied: on + // a one-third proxy a source-pixel radius *shrinks* to a third of the + // proportion it had, which is the bug this choice avoids. + let proxy = RenderScale::new((2000, 1500), (6000, 4500)); + let clarity = Clarity::with_amount(60.0); + assert_eq!(clarity.kernel(proxy), clarity.kernel(RenderScale::full((2000, 1500)))); + assert!( + proxy.source_pixels(96.0) < 40.0, + "the same length in the other unit would have collapsed" + ); + } + + #[test] + fn texture_stops_rather_than_lying_when_the_render_is_too_small() { + // A two-pixel surface structure is not present in a 300-pixel + // rendering of the frame, so the honest thing is to contribute no + // pass. Unlike the acutance family this is not an approximation being + // hidden: zoom in and the kernel comes back, exactly. + let thumbnail = RenderScale::full((160, 120)); + assert_eq!(Texture::with_amount(100.0).kernel(thumbnail), 0); + assert!(Texture::with_amount(100.0).passes(thumbnail).is_empty()); + + // Clarity is a hundred times wider and survives, which is what a + // thumbnail should show: the modelling, not the surface. + assert!(Clarity::with_amount(100.0).kernel(thumbnail) > 0); + assert_eq!(Clarity::with_amount(100.0).passes(thumbnail).len(), 2); + } + + #[test] + fn the_first_pass_hands_the_original_colour_to_the_second() { + // The property that makes an unsharp mask expressible in a chain that + // passes on one texture per pass. If the blur pass ever writes `c`, + // the combining pass has nothing to subtract the base *from* and the + // operation silently becomes a blur. + let passes = Clarity::with_amount(50.0).passes(RenderScale::full((2000, 1500))); + let base = &passes[0]; + let combine = &passes[1]; + + assert!(base.wgsl.contains("aux = sum / weight;")); + assert!( + !base.wgsl.contains("c = "), + "the blur pass must leave the colour alone: {}", + base.wgsl + ); + assert!( + combine.wgsl.contains("tap_aux("), + "the combining pass must read the blur out of the scratch lane" + ); + assert!(combine.wgsl.contains("log_luma(c) - base")); + } + + #[test] + fn the_declared_radius_is_the_halo_a_tile_would_need() { + // ARCH §5.3 grows a tile by the widest reach of the pass computing it, + // and nothing can infer that from the WGSL because the offsets come + // from a uniform. An understated radius shows as a seam at every tile + // boundary — an artefact that reads as a driver bug. + let scale = RenderScale::full((2000, 1500)); + let composed = composed(50.0, 50.0, scale); + let clarity = Clarity::with_amount(50.0).kernel(scale); + assert_eq!(composed.radius(), clarity, "the widest pass sets the halo"); + for pass in &composed.passes { + assert!(pass.radius > 0, "{} declared no reach", pass.label); + } + } + + #[test] + fn the_halo_limit_is_in_the_shader_and_bounds_the_overshoot() { + // The single most common way this feature is got wrong. `tanh` + // saturates at the threshold, so the largest overshoot a full-travel + // slider can produce is `gain * threshold` stops however violent the + // edge — a bound that holds by construction rather than by tuning. + let combine = &Clarity::with_amount(100.0).passes(RenderScale::full((2000, 1500)))[1]; + assert!(combine.wgsl.contains("threshold * tanh(detail / threshold)")); + + let bound = |u: &[Uniform]| { + let get = |n| u.iter().find(|x| x.name == n).unwrap().value; + get("gain").abs() * get("threshold") + }; + // A third of a stop for clarity, before the midtone taper takes more + // off; a little over a stop for texture, whose overshoot is two pixels + // wide and reads as acutance. + assert!((bound(&combine.uniforms) - 0.35).abs() < 1e-6); + let texture = &Texture::with_amount(100.0).passes(RenderScale::full((2000, 1500)))[1]; + assert!((bound(&texture.uniforms) - 1.25).abs() < 1e-6); + } + + #[test] + fn only_clarity_tapers_towards_black_and_white() { + // The asymmetry is the deliberate part: texture must work on skin in a + // highlight and fabric in a shadow, which is exactly where clarity + // must not. + let scale = RenderScale::full((2000, 1500)); + let clarity = &Clarity::with_amount(50.0).passes(scale)[1]; + let texture = &Texture::with_amount(50.0).passes(scale)[1]; + assert!(clarity.wgsl.contains("midtone_weight(luminance(c))")); + assert!(!texture.wgsl.contains("midtone_weight")); + + // And texture does not carry the helpers it would need for one, so its + // shader says what it does rather than merely not calling it. + assert!(Texture::new().helpers().iter().any(|h| h.name == "log_luma")); + assert!(!Texture::new() + .helpers() + .iter() + .any(|h| h.name == "midtone_weight")); + assert!(Clarity::new() + .helpers() + .iter() + .any(|h| h.name == "midtone_weight")); + } + + #[test] + fn the_amount_is_symmetric_about_neutral() { + // Working in stops is what buys this: −50 removes exactly the + // proportion of local contrast that +50 adds, at every brightness. + // In linear light it would not, and the pair of settings that looked + // balanced would depend on exposure. + let scale = RenderScale::full((2000, 1500)); + let up = Clarity::with_amount(50.0).passes(scale); + let down = Clarity::with_amount(-50.0).passes(scale); + let gain = |p: &[DetailPass]| { + p[1].uniforms + .iter() + .find(|u| u.name == "gain") + .unwrap() + .value + }; + assert!((gain(&up) + gain(&down)).abs() < 1e-6); + // Same kernel either way — the direction is a sign, not a scale. + assert_eq!(up[0].radius, down[0].radius); + } + + #[test] + fn every_pass_declares_a_uniform_block_the_gpu_will_accept() { + // A uniform struct whose size is not a multiple of 16 is rejected + // outright by the WGSL uniform address space rules, and arrives as a + // compilation failure against generated source. + for pass in composed(70.0, -40.0, RenderScale::full((1600, 1200))).passes { + assert_eq!(pass.uniforms.len() % 4, 0, "{}", pass.label); + assert!( + pass.uniforms.iter().all(|v| v.is_finite()), + "{} uploaded a non-finite uniform", + pass.label + ); + } + } + + #[test] + fn the_parameter_round_trips_under_its_own_id() { + // Mechanical, and exactly why it is worth checking: a `set_param` that + // read the wrong field would look perfect and silently break the + // sidecar. + let mut op = Clarity::new(); + op.set_param(AMOUNT, -37.0); + assert_eq!(op.param(AMOUNT), -37.0); + assert_eq!(op.descriptor().id, CLARITY); + assert_eq!(Texture::new().descriptor().id, TEXTURE); + } +} diff --git a/core/dr-pipeline/src/ops/mod.rs b/core/dr-pipeline/src/ops/mod.rs index 34362cb..5c4bd5a 100644 --- a/core/dr-pipeline/src/ops/mod.rs +++ b/core/dr-pipeline/src/ops/mod.rs @@ -44,12 +44,16 @@ pub mod aberration; pub mod colour_mixer; pub mod curve; pub mod distortion; +pub mod local_contrast; pub mod vignetting; pub use aberration::Aberration; pub use colour_mixer::ColourMixer; pub use curve::ToneCurve; pub use distortion::Distortion; +// Clarity and texture are one implementation at two scales; see the module's +// documentation for why that is two nodes and not one. +pub use local_contrast::{Clarity, Texture}; pub use vignetting::Vignetting; // The declared nodes, plus `helpers` and `chain`. Generated into OUT_DIR by