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
+34 -4
View File
@@ -1055,11 +1055,20 @@ impl DevelopSession {
/// The mask array is rasterised in source space at proxy size and sampled
/// through the framing map, so one array is correct at every output size:
/// a 256px thumbnail and a 24 MP export bind the same texture.
///
/// `space` is the output space `shader` was composed for, and it has to be
/// passed rather than assumed because the **detail stage** is composed
/// here too and the two halves must agree. When an edit has an active
/// neighbourhood operation the fused pass stops at unclipped linear
/// working values and the last detail pass performs the output transform;
/// composing the fused half for Display P3 and the detail half for sRGB
/// would encode the export in the wrong space, with nothing to notice it.
fn render_with_masks(
&mut self,
shader: &dr_pipeline::operation::ComposedShader,
w: u32,
h: u32,
space: dr_types::ColourSpace,
) -> Result<(), String> {
let ctx = self.ctx.clone();
self.ensure_subject_fields(&ctx);
@@ -1069,8 +1078,29 @@ impl DevelopSession {
.then(|| self.masks.as_ref().and_then(|p| p.array()))
.flatten();
// The neighbourhood stage, composed at the size actually being drawn.
//
// It has to be composed *per render* rather than cached with the edit,
// because a kernel is the one thing in this pipeline that is not
// scale-free: a sharpening radius is stated in source pixels and the
// develop view renders at whatever the viewport needs (FR-DSP-1), so
// the conversion is different for the canvas, the thumbnail and the
// export. `render_scale` works the ratio out from the framing, which
// is also what makes zooming to 1:1 restore an exact preview with no
// second render path to maintain.
//
// Empty for every edit with no active neighbourhood operation — which
// is almost all of them — and `render_detailed` then falls straight
// through to the single masked dispatch this used to call.
let scale = self.graph.render_scale(self.demosaiced.size(), (w, h));
let detail = self.graph.compose_detail_for(scale, space);
let colour_key = self
.graph
.invalidation()
.through(dr_pipeline::Affects::Colour);
self.adjust
.render_masked(&self.demosaiced, shader, w, h, masks)
.render_detailed(&self.demosaiced, shader, w, h, masks, &detail, colour_key)
.map(|_| ())
.map_err(|e| e.to_string())
}
@@ -1769,7 +1799,7 @@ impl DevelopSession {
// Rasterise the masks first: the shader addresses array slices by
// index, so the array has to describe *this* stack before it is bound.
self.render_with_masks(&shader, w, h)?;
self.render_with_masks(&shader, w, h, dr_types::ColourSpace::Srgb)?;
let texture = self.adjust.output().ok_or("nothing was rendered")?;
// The import is fallible on format and usage only, and both are fixed
@@ -1893,7 +1923,7 @@ impl DevelopSession {
let (w, h) = self.graph.output_size(sw, sh);
let shader = self.graph.compose_for(space);
self.render_with_masks(&shader, w, h)?;
self.render_with_masks(&shader, w, h, space)?;
let (pixels, rw, rh) = self.adjust.export_pixels().map_err(|e| e.to_string())?;
dr_export::Frame::in_space(rw, rh, pixels, space).map_err(|e| e.to_string())
@@ -1920,7 +1950,7 @@ impl DevelopSession {
let (w, h) = fit(fw, fh, edge.max(1), edge.max(1));
let shader = self.graph.compose_for(dr_types::ColourSpace::Srgb);
self.render_with_masks(&shader, w, h)?;
self.render_with_masks(&shader, w, h, dr_types::ColourSpace::Srgb)?;
let (pixels, rw, rh) = self.adjust.export_pixels().map_err(|e| e.to_string())?;
Ok((rw, rh, pixels))