Sharpen the capture with a separable unsharp mask

Capture sharpening as a two-pass unsharp mask in the detail stage: blur
along x, then along y, each pass applying a one-dimensional high-pass to
luminance so the composite preserves a flat field exactly and matches the
textbook kernel on any locally one-dimensional edge.

The radius is stated in source pixels and converted once per render, so a
radius tuned on a fit view is the radius the exported file gets. Below one
render pixel the operation declines to draw rather than showing sharpening
the file will not contain, and emits a single pass-through that still
carries the output transform.

The develop session now renders through render_detailed, which is what
lets an active neighbourhood operation reach the screen at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-22 19:13:00 +02:00
co-authored by Claude Opus 5
parent f78c8b6959
commit b321556dbe
3 changed files with 145 additions and 57 deletions
+106 -50
View File
@@ -494,7 +494,13 @@ let contrast = abs(high) / level;
let knee = max(gate, 1e-5);
let keep = select(1.0, smoothstep(knee * 0.5, knee, contrast), gate > 0.0);
let target = centre + amount * high * keep;
// `sharpened` rather than the obvious `target`: `target` is a WGSL reserved
// keyword, and a fragment that declares one fails to compile against
// *generated* source, so the error names a file nobody wrote. The pipeline
// has been bitten by exactly this once already — see
// `no_fragment_declares_a_wgsl_reserved_keyword` in `lib.rs`, which was added
// the day `let target` broke the contrast fragment.
let sharpened = centre + amount * high * keep;
// Applied as a gain on all three channels rather than as an offset, so that
// steepening an edge does not drag its colour towards grey: the channel ratios
@@ -506,29 +512,32 @@ let target = centre + amount * high * keep;
// Below a nearly-black luminance the ratio stops carrying information — the
// three channels are all noise there and the divisor is meaningless — so the
// pixel is handed on untouched rather than multiplied by whatever fell out.
let scaled = c * (max(target, 0.0) / max(centre, 1e-5));
let scaled = c * (max(sharpened, 0.0) / max(centre, 1e-5));
c = select(c, scaled, centre > 1e-5);"#;
#[cfg(test)]
mod tests {
use super::*;
use crate::detail::compose_detail;
use crate::EditGraph;
use dr_types::ColourSpace;
/// The chain with the sharpener turned up, as the composer sees it.
fn sharpening(amount: f32, radius: f32) -> Vec<Box<dyn Operation>> {
let mut ops = crate::ops::chain();
for op in &mut ops {
if op.descriptor().id == ID {
op.set_param(AMOUNT, amount);
op.set_param(RADIUS, radius);
}
}
ops
/// The develop chain with the sharpener turned up.
///
/// Built through [`EditGraph`] rather than by reaching into `ops::chain()`
/// directly, so that every value these tests use passes the same clamp a
/// slider's would. A test asserting an exact kernel for a radius the graph
/// would have clipped on the way in is a test of a configuration the
/// photographer cannot reach — the mistake `build.rs` refuses outright for
/// declared nodes, and which nothing catches for a hand-written one.
fn sharpening(amount: f32, radius: f32) -> EditGraph {
let mut graph = EditGraph::default_chain();
graph.set_param(ID, AMOUNT, amount);
graph.set_param(ID, RADIUS, radius);
graph
}
fn chain_at(ops: &[Box<dyn Operation>], scale: RenderScale) -> crate::detail::ComposedDetail {
compose_detail(ops, scale, ColourSpace::Srgb)
fn chain_at(graph: &EditGraph, scale: RenderScale) -> crate::detail::ComposedDetail {
graph.compose_detail_for(scale, ColourSpace::Srgb)
}
#[test]
@@ -552,7 +561,7 @@ mod tests {
// The rule the whole pipeline rests on: an operation at its defaults
// costs nothing. Almost every photograph in a library is unsharpened,
// and none of them should pay a dispatch for it.
let composed = chain_at(&crate::ops::chain(), RenderScale::full((512, 512)));
let composed = chain_at(&EditGraph::default_chain(), RenderScale::full((512, 512)));
assert!(composed.is_empty());
assert_eq!(composed.radius(), 0);
}
@@ -636,53 +645,59 @@ mod tests {
#[test]
fn the_radius_is_a_sensor_length_not_a_viewport_one() {
// TRACES: FR-DSP-1 — the decision this operation is most likely to get
// wrong, asserted directly.
// wrong, asserted directly. It is the test the brief asked for: the
// same edit at more than one render resolution has to sharpen the same
// photograph.
//
// The same edit, composed at three resolutions of one 4000-pixel
// frame. A radius in source pixels must come out as the same number of
// *source* pixels every time, which means a different number of render
// pixels every time. Read the stored radius as render pixels instead
// and the third assertion below is the one that fails: the kernel
// would be four sigma wide at every size, so the proxy on screen would
// be sharpened four times as hard, relative to the picture, as the file
// that gets exported.
let ops = sharpening(80.0, 4.0);
// Three renders of one 4000-pixel frame: an export, a half-size proxy,
// and a 40% fit view. A radius in source pixels must come out as the
// same number of *source* pixels every time, which means a different
// number of render pixels every time. Read the stored radius as render
// pixels instead and the last three assertions are the ones that fail:
// the kernel would be three sigma wide at every size, so the proxy on
// screen would be sharpened two and a half times as hard, relative to
// the picture, as the file that gets exported.
//
// The radius is the slider's maximum rather than a round number past
// it, because `EditGraph::set_param` clamps and a test asserting on a
// radius the graph would have clipped would be asserting about a
// photograph nobody can produce.
let graph = sharpening(80.0, 3.0);
let full = (4000u32, 4000u32);
let in_source_pixels = |render: u32| -> f32 {
let scale = RenderScale::new((render, render), full);
chain_at(&ops, scale).radius() as f32 / scale.ratio()
chain_at(&graph, scale).radius() as f32 / scale.ratio()
};
// Export, half-size proxy, quarter-size proxy.
let export = in_source_pixels(4000);
let half = in_source_pixels(2000);
let quarter = in_source_pixels(1000);
let fit = in_source_pixels(1600);
assert!((export - 12.0).abs() < 0.01, "three sigma of four pixels");
assert!((export - 9.0).abs() < 0.01, "three sigma of three pixels");
// Within the rounding of one render pixel back through the ratio,
// which is the whole of the permitted error: the kernel is an integer
// count of render pixels and 12 does not divide evenly by four.
// count of render pixels, and one of those is two source pixels on the
// half proxy and two and a half on the fit view.
assert!(
(half - export).abs() <= 2.0,
"the same edit covers {half} source pixels on a half proxy and \
{export} at export"
);
assert!(
(quarter - export).abs() <= 4.0,
"the same edit covers {quarter} source pixels on a quarter proxy \
and {export} at export"
(fit - export).abs() <= 2.5,
"the same edit covers {fit} source pixels on a 40% view and \
{export} at export"
);
// The other half of the statement, and the one that fails if the unit
// is misread: the kernel in *render* pixels must shrink with the
// render, because that is what keeps it the same size on the picture.
let render_pixels = |render: u32| {
chain_at(&ops, RenderScale::new((render, render), full)).radius()
};
assert_eq!(render_pixels(4000), 12);
assert_eq!(render_pixels(2000), 6);
assert_eq!(render_pixels(1000), 3);
let render_pixels =
|render: u32| chain_at(&graph, RenderScale::new((render, render), full)).radius();
assert_eq!(render_pixels(4000), 9);
assert_eq!(render_pixels(2000), 5, "ceil(3 sigma of 1.5)");
assert_eq!(render_pixels(1600), 4, "ceil(3 sigma of 1.2)");
}
#[test]
@@ -692,11 +707,11 @@ mod tests {
// on went out with the downscale. `RenderScale::resolves` reports the
// condition and this obeys it, because a preview that shows sharpening
// the exported file will not contain is worse than one that shows none.
let ops = sharpening(100.0, 1.0);
let graph = sharpening(100.0, 1.0);
let proxy = RenderScale::new((1000, 1000), (4000, 4000));
assert!(!proxy.resolves(1.0));
let composed = chain_at(&ops, proxy);
let composed = chain_at(&graph, proxy);
// Not empty, though. See `nothing_to_sharpen`: the fused pass has
// already been composed to hand on linear values, so *something* must
// still perform the output transform.
@@ -715,7 +730,7 @@ mod tests {
// the render target keeps its size — so there is no separate
// full-resolution preview path for a photographer to wait on.
let one_to_one = RenderScale::new((1000, 1000), (1000, 1000));
assert_eq!(chain_at(&ops, one_to_one).len(), 2);
assert_eq!(chain_at(&graph, one_to_one).len(), 2);
}
#[test]
@@ -742,13 +757,9 @@ mod tests {
// being positive, so a photographer who never touches the threshold
// gets the plain unsharp mask and not a NaN.
let gate_of = |threshold: f32| -> f32 {
let mut ops = sharpening(50.0, 1.0);
for op in &mut ops {
if op.descriptor().id == ID {
op.set_param(THRESHOLD, threshold);
}
}
let composed = chain_at(&ops, RenderScale::full((512, 512)));
let mut graph = sharpening(50.0, 1.0);
graph.set_param(ID, THRESHOLD, threshold);
let composed = chain_at(&graph, RenderScale::full((512, 512)));
*composed.passes[0].uniforms.last().expect("a gate")
};
assert_eq!(gate_of(0.0), 0.0);
@@ -777,6 +788,51 @@ mod tests {
}
}
#[test]
fn the_kernel_declares_no_wgsl_reserved_keyword() {
// `lib.rs` runs this check over the fused fragments and cannot reach
// here: a detail pass is a separate shader, composed at a resolution
// that `compose()` never sees. It is worth repeating rather than
// skipping, because the failure it catches is the least legible one in
// this crate — `let target = ...` in the contrast fragment once failed
// with "name `target` is a reserved keyword", pointing at generated
// source rather than at the operation that wrote it, and this body
// reaches for exactly that word.
//
// Not the full reserved list; the words a convolution would plausibly
// pick for a local.
// The same list `no_fragment_declares_a_wgsl_reserved_keyword` uses,
// kept identical on purpose: two lists that drift apart would let a
// word be safe in one stage and not in the other, which is the sort of
// difference nobody discovers until a shader fails to compile.
const RESERVED: &[&str] = &[
"target", "sample", "filter", "texture", "buffer", "binding", "const", "enum", "mat",
"vec", "ptr", "ref", "shared", "static", "typedef", "union", "unless", "handle",
"layout", "packed", "premerge", "regardless", "active", "do", "input", "output",
"private", "resource", "restrict", "self", "std", "where",
];
for pass in chain_at(&sharpening(100.0, 2.0), RenderScale::full((512, 512))).passes {
// Only what this operation wrote. The composer's own preamble
// declares `var output` and `let coord`, which naga accepts and
// which are not this test's business.
let body = pass
.source
.rsplit_once(" {\n")
.expect("the operation's block")
.1;
for keyword in RESERVED {
for form in [format!("let {keyword} "), format!("var {keyword} ")] {
assert!(
!body.contains(&form),
"{} declares `{keyword}`, which is a WGSL reserved keyword",
pass.label
);
}
}
}
}
#[test]
fn a_deep_zoom_cannot_turn_a_slider_into_an_unbounded_convolution() {
// Zooming past about 16:1 pushes the ratio above one, and a radius in