Merge branch 'worktree-agent-acd27f9b2974c67eb' into integration

# Conflicts:
#	core/dr-gpu/src/adjust.rs
#	core/dr-pipeline/src/detail.rs
#	core/dr-pipeline/src/ops/mod.rs
This commit is contained in:
2026-08-22 19:31:15 +02:00
7 changed files with 1661 additions and 7 deletions
+27 -6
View File
@@ -891,6 +891,11 @@ mod tests {
use dr_decode::{BaseCurve, CfaPattern, CropRect, RawImage};
use dr_pipeline::ops::{colour_mixer, exposure, saturation};
use dr_pipeline::EditGraph;
// For `Operation::detail`, which is how `the_whole_chain_at_once_compiles`
// asks the chain which of its operations are neighbourhood operations
// rather than being told a list. Imported anonymously: nothing here names
// the trait, only calls through it.
use dr_pipeline::Operation as _;
use crate::Demosaicer;
@@ -1454,8 +1459,20 @@ mod tests {
// allocation would show up.
let (w, h) = g.output_size(32, 32);
let shader = g.compose();
let scale = g.render_scale(img.size(), (w, h));
let detail = g.compose_detail(scale);
// At 512 rather than the 32 this test used before the detail stage
// existed, and the size is load-bearing twice over. A compositional
// radius is a fraction of the frame, so on a 32-pixel target every
// detail kernel rounds to nothing: the chain would compose no passes,
// leaving half the shader uncompiled, and the exclusive-or below would
// find those operations in neither stage and fail for a reason that is
// not a defect. Shadows the smaller size deliberately.
let (w, h) = g.output_size(512, 512);
let scale = g.render_scale((512, 512), (w, h));
let detail = g.compose_detail_for(scale, dr_types::ColourSpace::Srgb);
assert!(
!detail.is_empty(),
"the detail half composed nothing, so nothing of it was compiled"
);
// Every operation has to reach the pipeline, but they do not all reach
// the same half of it, and which half is not this test's business to
@@ -1494,10 +1511,14 @@ mod tests {
"with every operation active the detail stage must run"
);
// Both halves, from the one graph: with a detail stage present the
// fused pass stops at linear working values and the last detail pass
// performs the output transform, so rendering only the first half is
// not a smaller test — it is a texture format the driver rejects.
// The other half of the same edit, and it belongs in this test for the
// reason the test exists: the detail passes are generated WGSL too,
// they carry their own uniform blocks, and "everything at once" is
// exactly where a collision between them would show. Rendering the
// fused half alone is no longer even legal — with a neighbourhood
// operation active the fused pass stops at linear working values and
// the last detail pass performs the output transform, which is the
// mismatch `render_detailed` exists to reject.
let key = g.invalidation().through(dr_pipeline::Affects::Colour);
pass.render_detailed(&img, &shader, w, h, None, &detail, key)
.expect("the full chain must compile");
+607
View File
@@ -0,0 +1,607 @@
//! 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<GpuContext> {
// 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<u8> = (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<u8> {
(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<u8> {
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();
// Where the two comparisons below sit, derived rather than observed:
//
// srgb_decode(175) = 0.4287, srgb_decode(90) = 0.1022
// the step is log2(0.4287 / 0.1022) = 2.069 stops
// an unlimited mask peaks at half of it = 1.034 stops
// the soft limit saturates at = 0.350 stops (`bound`)
// the midtone taper then takes about 13% off at 175, so the peak this
// test should actually see is near = 0.30 stops
//
// So 0.30 has to clear the 0.1 floor with room, and fall well under both
// 0.35 + slack and 0.6 × 1.034 = 0.62. Every one of those is a bound with
// a reason, not a tolerance widened until the test passed.
//
// 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<f32> = (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);
// Asserted on the composed chain rather than on a dispatch counter,
// because texture *alone* at this size is a configuration the stage as a
// whole cannot currently render, and that is a gap in the seam rather than
// in this operation.
//
// `compose_full` decides whether the fused pass should hand on linear
// working values from `is_active()`, which has no `RenderScale` to consult;
// `compose_detail` decides what to dispatch from the kernel it can actually
// draw at this scale. Almost always the two agree. They disagree exactly
// when a detail operation is active and its kernel rounds away, and then
// `render_detailed` finds an empty chain, falls through to `render_masked`,
// and is rejected for handing a linear-working shader to the plain path.
//
// Pre-existing, and not something clarity and texture introduce:
// `DetailStage::passes` documents the empty return as *the honest answer
// for an acutance operation on a heavy proxy*, so capture sharpening and
// noise reduction reach it by the same road. Fixing it means composing
// both halves of a render together, so the fused half can know whether a
// detail half survived the scale — a change at the composition boundary,
// not in this file.
let scale = graph.render_scale(source.size(), (128, 128));
assert!(
graph.compose_detail_for(scale, ColourSpace::Srgb).is_empty(),
"texture claimed a kernel it cannot draw"
);
// With clarity on as well the edit is renderable again, and the dispatch
// count says what the assertion above says: two passes, not four. Texture
// is active, and contributes nothing.
graph.set_param(CLARITY, AMOUNT, 100.0);
let mut pass = AdjustPass::new(&ctx);
render(&mut pass, &graph, &source, 128);
assert_eq!(
pass.detail_dispatches(),
2,
"clarity survives a thumbnail, and texture added nothing beside it"
);
// 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);
}
+36
View File
@@ -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.
+23
View File
@@ -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.
+84 -1
View File
@@ -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
@@ -586,7 +617,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<f32>(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<f32>(c, aux));"
.to_string(),
)
};
@@ -632,6 +667,12 @@ fn tap(coord: vec2<i32>, offset: vec2<i32>) -> vec3<f32> {{
return textureLoad(source, clamp(coord + offset, vec2<i32>(0), last), 0).rgb;
}}
// The same neighbour's scratch lane — see `aux` in the body below.
fn tap_aux(coord: vec2<i32>, offset: vec2<i32>) -> f32 {{
let last = vec2<i32>(textureDimensions(source)) - vec2<i32>(1);
return textureLoad(source, clamp(coord + offset, vec2<i32>(0), last), 0).a;
}}
{helper_src}{encode_fn}
@compute @workgroup_size(8, 8, 1)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
@@ -646,6 +687,10 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
let render_scale = u.detail_base.z;
var c = tap(coord, vec2<i32>(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<i32>(0));
{{
{indented}
@@ -973,6 +1018,44 @@ mod tests {
assert_eq!(fused(&ops).output_mode, OutputMode::LinearWorking);
}
#[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<f32>(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<f32>(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
+880
View File
@@ -0,0 +1,880 @@
//! 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>) -> 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<B: Band> {
/// −100…100, exactly as the slider reports it.
amount: f32,
band: PhantomData<B>,
}
/// Clarity: local contrast at roughly a hundredth of the frame.
pub type Clarity = LocalContrast<Coarse>;
/// Texture: local contrast a decade finer than clarity.
pub type Texture = LocalContrast<Fine>;
impl<B: Band> Default for LocalContrast<B> {
fn default() -> Self {
Self {
amount: 0.0,
band: PhantomData,
}
}
}
impl<B: Band> LocalContrast<B> {
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<B: Band> Operation for LocalContrast<B> {
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<Uniform> {
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<B: Band> DetailStage for LocalContrast<B> {
fn passes(&self, scale: RenderScale) -> Vec<DetailPass> {
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<i32>(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<i32>(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<Box<dyn Operation>> {
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);
// Both derived rather than observed, because a number copied out of a
// test run agrees with whatever the code did on the day.
//
// `frame_fraction` takes the *shorter* edge: min(4000, 3000) = 3000.
// clarity σ = 0.012 × 3000 = 36.0 → round(36.0 × 2) = 72
// texture σ = 0.0012 × 3000 = 3.6 → round( 3.6 × 2) = 7
//
// (7.2 rounds down, which is why texture is 7 and not 8 — the
// truncation is two sigmas, and two sigmas of 3.6 px is 7.2 px.)
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.
// The declared proportion is σ × 2 = 0.012 × 2 = 0.024 of the shorter
// edge, and the tolerance is what rounding to a whole pixel costs:
//
// 300 → round(0.024 × 300) = 7 → 7/300 = 0.02333 (−0.00067)
// 1500 → round(0.024 × 1500) = 36 → 36/1500 = 0.02400 ( 0.00000)
// 4500 → round(0.024 × 4500) = 108 → 108/4500 = 0.02400 ( 0.00000)
//
// Half a pixel over the smallest frame here is 0.5/300 = 0.0017, so
// 0.002 is the tolerance a rounded kernel can actually hold — and it
// is a bound, not a fitted number.
let sizes = [(400u32, 300u32), (2000, 1500), (6000, 4500)];
let proportions: Vec<f32> = 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.
//
// ratio = (2000/6000 + 1500/4500) / 2 = 1/3
// frame_fraction(0.012) → 0.012 × 1500 = 18 px, the same 18 px the
// export of that framing gets, because the
// unit is a proportion of what is rendered;
// source_pixels(96) → 96 × 1/3 = 32 px, a third of the reach
// the same edit had at full size.
//
// 96 is clarity's kernel at 4000 px of shorter edge, so the second line
// is what this control would have done had it been written in the
// acutance family's unit.
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.
//
// shorter edge = 120
// texture σ = 0.0012 × 120 = 0.144 → round(0.288) = 0 — no pass
// clarity σ = 0.012 × 120 = 1.44 → round(2.88) = 3 — two passes
//
// Texture's kernel crosses back above zero at round(0.0024 × e) ≥ 1,
// i.e. a shorter edge of about 209 px, which is a little larger than a
// contact sheet thumbnail and a great deal smaller than any view a
// photographer judges surface detail in.
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.
//
// shorter edge = 1500
// clarity → round(0.024 × 1500) = 36 px
// texture → round(0.0024 × 1500) = 4 px
//
// so the halo the four passes together need is clarity's 36, taken as
// the maximum rather than the sum: the passes are separate dispatches,
// and a tile is grown for whichever of them reaches furthest.
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.
//
// clarity gain = 100/100 × 1.00 = 1.00 ; × 0.35 = 0.35 stops (26%)
// texture gain = 100/100 × 1.25 = 1.25 ; × 1.00 = 1.25 stops
//
// Both at full travel, which is what makes them a bound and not a
// measurement: no picture, and no edge in any picture, can produce more.
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);
}
}
+4
View File
@@ -62,6 +62,7 @@ pub mod capture_sharpen;
pub mod colour_mixer;
pub mod curve;
pub mod distortion;
pub mod local_contrast;
pub mod noise_reduction;
pub mod vignetting;
@@ -70,6 +71,9 @@ pub use capture_sharpen::CaptureSharpen;
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 noise_reduction::NoiseReduction;
pub use vignetting::Vignetting;