WIP: capture sharpening

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.
This commit is contained in:
2026-08-22 19:01:18 +02:00
parent c963dafd09
commit 8ea427de3c
7 changed files with 1410 additions and 2 deletions
+503
View File
@@ -0,0 +1,503 @@
//! Capture sharpening, end to end on a real device.
//!
//! `dr-pipeline`'s own tests assert what the composer *generates* — the kernel
//! extent, the uniforms, which pass encodes. None of them can tell whether the
//! generated WGSL compiles, whether the second pass is handed what the first
//! one wrote, or whether the result is sharpening rather than a shader that
//! silently produced the input again. Those are questions only a GPU answers.
//!
//! # Reading the expected values
//!
//! The source is uploaded through `DemosaicedImage::from_rgba8`, which flags it
//! non-linear, so the generated shader decodes sRGB before any operation runs
//! and a black/white step reaches the detail stage as linear 0.0 and 1.0
//! exactly. The last detail pass re-encodes. So a byte read back here is
//! `srgb_encode(whatever the kernel produced in linear light)`, and an
//! overshoot — the bright fringe an unsharp mask puts on the light side of an
//! edge — cannot show above 255 on the white side of a full-scale step, and the
//! undershoot on the dark side of one clips to black long before the halo has
//! been drawn. The tests therefore use a **grey** step, from byte 90 to byte
//! 150, which at 100% amount leaves the whole halo inside the representable
//! range at both ends. Every expected value below is arithmetic on that step,
//! not a number read off a previous run.
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext};
use dr_pipeline::descriptor::ParamId;
use dr_pipeline::ops::capture_sharpen::{AMOUNT, ID, RADIUS, THRESHOLD};
use dr_pipeline::{Affects, EditGraph};
use dr_types::ColourSpace;
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
}
}
}
/// A vertical step from `low` to `high`, changing at the middle column.
///
/// The one image whose sharpening is worth checking by hand: an unsharp mask
/// must darken the last few columns before the step and brighten the first few
/// after it, and leave everything further away exactly where it was. A gradient
/// would blur to itself and hide a kernel that does nothing at all.
fn step_edge(ctx: &GpuContext, size: u32, low: u8, high: u8) -> DemosaicedImage {
let data: Vec<u8> = (0..size * size)
.flat_map(|i| {
let v = if (i % size) < size / 2 { low } else { high };
[v, v, v, 255]
})
.collect();
DemosaicedImage::from_rgba8(ctx, &data, size, size).expect("upload")
}
/// A flat field of one value.
fn flat(ctx: &GpuContext, size: u32, value: u8) -> DemosaicedImage {
let data: Vec<u8> = (0..size * size)
.flat_map(|_| [value, value, value, 255])
.collect();
DemosaicedImage::from_rgba8(ctx, &data, size, size).expect("upload")
}
/// One row of the rendered image, red channel, as bytes.
fn row(pixels: &[u8], width: u32, y: u32) -> Vec<u8> {
(0..width)
.map(|x| pixels[((y * width + x) * 4) as usize])
.collect()
}
/// The develop chain with capture sharpening set.
fn sharpened(amount: f32, radius: f32, threshold: f32) -> EditGraph {
let mut graph = EditGraph::default_chain();
graph.set_param(ID, AMOUNT, amount);
graph.set_param(ID, RADIUS, radius);
graph.set_param(ID, THRESHOLD, threshold);
graph
}
/// Render one graph, with its detail stage, and read the pixels back.
///
/// The whole calling convention a frontend adopts, in five lines: compose both
/// halves from one graph at one output space, ask the graph for the scale, and
/// pass the invalidation key through.
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
}
#[test]
fn an_unsharp_mask_puts_a_halo_on_the_edge_and_leaves_the_rest_alone() {
// What sharpening *is*, asserted as pixels rather than as "something
// changed": an undershoot immediately before the transition, an overshoot
// immediately after it, the step itself steeper than it was, and the flat
// ground at either end untouched. A shader that ran the blur and forgot to
// add the difference back would pass a "the image changed" test and fail
// every one of these.
let Some(ctx) = ctx() else { return };
const SIZE: u32 = 64;
let source = step_edge(&ctx, SIZE, 90, 150);
let plain = render(
&mut AdjustPass::new(&ctx),
&EditGraph::default_chain(),
&source,
SIZE,
);
let sharp = render(
&mut AdjustPass::new(&ctx),
&sharpened(100.0, 2.0, 0.0),
&source,
SIZE,
);
let before = row(&plain, SIZE, SIZE / 2);
let after = row(&sharp, SIZE, SIZE / 2);
let edge = (SIZE / 2) as usize;
// The dark side of the transition is driven darker and the light side
// lighter — the halo. Two pixels in, where a two-pixel-sigma kernel has
// most of its response.
assert!(
after[edge - 2] < before[edge - 2],
"the dark side of the edge should be pushed down: {} -> {}",
before[edge - 2],
after[edge - 2]
);
assert!(
after[edge + 1] > before[edge + 1],
"the light side of the edge should be pushed up: {} -> {}",
before[edge + 1],
after[edge + 1]
);
// And the transition really is steeper across the same two columns.
let slope = |r: &[u8]| r[edge] as i32 - r[edge - 1] as i32;
assert!(
slope(&after) > slope(&before),
"sharpening must steepen the edge: {} -> {}",
slope(&before),
slope(&after)
);
// Far from the edge there is nothing to sharpen, so nothing may move. This
// is the property a kernel that forgot to normalise its weights breaks,
// and it breaks it as a brightness shift over the whole photograph.
for x in [0usize, 4, 8, SIZE as usize - 1] {
assert!(
after[x].abs_diff(before[x]) <= 1,
"column {x} is flat ground and moved: {} -> {}",
before[x],
after[x]
);
}
}
#[test]
fn a_flat_field_survives_any_amount_of_sharpening() {
// The kernel sums to one — `(1 + a)` of the pixel minus `a` of its blur —
// so a sky must come through bit for bit however far the slider is pushed.
// The border is the part that is easy to get wrong: `tap` clamps, and a
// kernel that normalised by an analytic integral instead of by the weights
// it actually summed would draw a band around the whole frame.
let Some(ctx) = ctx() else { return };
const SIZE: u32 = 48;
let source = flat(&ctx, SIZE, 128);
let plain = render(
&mut AdjustPass::new(&ctx),
&EditGraph::default_chain(),
&source,
SIZE,
);
let sharp = render(
&mut AdjustPass::new(&ctx),
&sharpened(100.0, 3.0, 0.0),
&source,
SIZE,
);
for (i, (a, b)) in sharp.iter().zip(&plain).enumerate() {
assert!(
a.abs_diff(*b) <= 1,
"pixel {} of a flat field moved: {b} -> {a}",
i / 4
);
}
}
#[test]
fn a_proxy_and_an_export_sharpen_the_same_photograph() {
// TRACES: FR-DSP-1 — the decision this operation is most likely to get
// wrong, and the one that is invisible until an export comes back wrong.
//
// The same edit, rendered at two resolutions of one source. The radius is
// in source pixels, so the halo must cover the same *proportion of the
// picture* at both: a fringe four source pixels wide is four source pixels
// wide whether it was drawn on a half-size proxy or at full size.
//
// Read the radius as render pixels instead and the proxy's halo would be
// twice as wide relative to the frame and roughly twice as strong, so what
// was tuned on screen would not be what landed in the file. That is the
// failure this catches, and it is a large one: the widths would differ by a
// factor of two, not by a rounding.
let Some(ctx) = ctx() else { return };
const SOURCE: u32 = 128;
let source = step_edge(&ctx, SOURCE, 90, 150);
// Four source pixels, so that even the half-size proxy has a two-pixel
// sigma and resolves the radius — the honest cut-off is tested in
// `dr-pipeline`, and this test is about the case where both renders draw.
let graph = sharpened(100.0, 4.0, 0.0);
// The halo, measured against the same edit with no sharpening at the same
// size: how far from the transition the picture is still disturbed, as a
// fraction of the frame, and how much deviation the halo carries in total.
let measure = |out: u32| -> (f32, f32) {
let plain = render(
&mut AdjustPass::new(&ctx),
&EditGraph::default_chain(),
&source,
out,
);
let sharp = render(&mut AdjustPass::new(&ctx), &graph, &source, out);
let (a, b) = (row(&plain, out, out / 2), row(&sharp, out, out / 2));
let disturbed: Vec<usize> = (0..out as usize)
.filter(|&x| b[x].abs_diff(a[x]) > 3)
.collect();
let first = *disturbed.first().expect("a halo");
let last = *disturbed.last().expect("a halo");
// The halo's strength as an *area* — the sum of the deviations, scaled
// by the width of a render pixel — rather than as its peak. A peak is
// one sample of a smooth curve, and the two renders do not sample it at
// the same place: the pixel next to the transition sits half a render
// pixel from it, which is half a source pixel at export and a whole one
// on the proxy, so their peaks would legitimately differ by more than
// the property under test. An integral over the same curve does not
// care where the samples fell.
let area: f32 = (0..out as usize)
.map(|x| b[x].abs_diff(a[x]) as f32)
.sum::<f32>()
/ out as f32;
((last - first) as f32 / out as f32, area)
};
let (proxy_width, proxy_area) = measure(SOURCE / 2);
let (export_width, export_area) = measure(SOURCE);
assert!(
(proxy_width - export_width).abs() < 0.06,
"the halo covers {proxy_width:.3} of the proxy and {export_width:.3} \
of the export; a radius tuned on screen must land in the file"
);
// The strength has to agree too. A viewport-scaled kernel would not only
// be wider on the proxy, it would push the fringe further, because a wider
// blur takes more away for the high-pass to add back — so the areas would
// differ by considerably more than the sampling slack allowed here.
let ratio = proxy_area / export_area;
assert!(
(0.75..1.35).contains(&ratio),
"the halo carries {proxy_area:.2} on the proxy and {export_area:.2} at \
export, a ratio of {ratio:.2}"
);
// And both are a real halo rather than two flat images agreeing.
assert!(
proxy_width > 0.05 && export_width > 0.05,
"{proxy_width:.3} / {export_width:.3}"
);
assert!(proxy_area > 1.0 && export_area > 1.0, "{proxy_area} / {export_area}");
}
#[test]
fn the_threshold_leaves_shallow_modulation_where_it_found_it() {
// What the threshold is for: sensor noise is shallow, and sharpening it is
// the fastest way to make a clean frame look worse. Two images, one with a
// strong edge and one with a shallow ripple, through the same gate — the
// edge must still sharpen and the ripple must not.
let Some(ctx) = ctx() else { return };
const SIZE: u32 = 64;
// A four-code ripple: about 4% local contrast at this level, which is the
// order of magnitude read noise reaches on a well-exposed frame — and well
// under the 12.5% at which the gate below starts letting detail through.
let ripple: Vec<u8> = (0..SIZE * SIZE)
.flat_map(|i| {
let v = if (i % SIZE) % 2 == 0 { 128u8 } else { 132 };
[v, v, v, 255]
})
.collect();
let ripple = DemosaicedImage::from_rgba8(&ctx, &ripple, SIZE, SIZE).expect("upload");
let edge = step_edge(&ctx, SIZE, 90, 150);
let gated = sharpened(100.0, 1.0, 1.0);
let ungated = sharpened(100.0, 1.0, 0.0);
let spread = |graph: &EditGraph, source: &DemosaicedImage| -> u8 {
let pixels = render(&mut AdjustPass::new(&ctx), graph, source, SIZE);
let line = row(&pixels, SIZE, SIZE / 2);
// Peak-to-peak over the middle of the row, away from the border.
let window = &line[8..24];
window.iter().max().unwrap() - window.iter().min().unwrap()
};
let ripple_open = spread(&ungated, &ripple);
let ripple_gated = spread(&gated, &ripple);
assert!(
ripple_gated < ripple_open,
"the gate must hold shallow modulation back: {ripple_open} -> \
{ripple_gated}"
);
// The edge is deep modulation and must come through the same gate
// sharpened — a threshold that flattens everything is not a threshold.
let plain_edge = {
let pixels = render(
&mut AdjustPass::new(&ctx),
&EditGraph::default_chain(),
&edge,
SIZE,
);
row(&pixels, SIZE, SIZE / 2)
};
let gated_edge = {
let pixels = render(&mut AdjustPass::new(&ctx), &gated, &edge, SIZE);
row(&pixels, SIZE, SIZE / 2)
};
let mid = (SIZE / 2) as usize;
assert!(
gated_edge[mid - 1] < plain_edge[mid - 1],
"a real edge must still sharpen through the gate: {} -> {}",
plain_edge[mid - 1],
gated_edge[mid - 1]
);
}
#[test]
fn sharpening_an_edge_does_not_change_its_colour() {
// The reason the high-pass is applied as a gain on the three channels
// rather than as an offset. An offset moves a saturated colour towards
// grey as it brightens it, so a sharpened red roof gets a pink fringe —
// which reads as chromatic aberration and gets blamed on the lens.
let Some(ctx) = ctx() else { return };
const SIZE: u32 = 64;
// A step between two saturated reds of different brightness: the ratios
// between the channels are the colour, and they must survive the halo.
let data: Vec<u8> = (0..SIZE * SIZE)
.flat_map(|i| {
if (i % SIZE) < SIZE / 2 {
[80u8, 30, 30, 255]
} else {
[200, 75, 75, 255]
}
})
.collect();
let source = DemosaicedImage::from_rgba8(&ctx, &data, SIZE, SIZE).expect("upload");
let pixels = render(
&mut AdjustPass::new(&ctx),
&sharpened(60.0, 2.0, 0.0),
&source,
SIZE,
);
// Sampled inside the halo, where an additive sharpener would have washed
// the colour out most.
let y = SIZE / 2;
for x in [SIZE / 2 - 2, SIZE / 2 + 1] {
let i = ((y * SIZE + x) * 4) as usize;
let (r, g, b) = (pixels[i] as f32, pixels[i + 1] as f32, pixels[i + 2] as f32);
assert!(r > g && r > b, "the fringe lost its hue at column {x}");
// Green and blue started equal and must stay equal: an offset would
// keep them equal too, but the *ratio* to red is what moves, and this
// is the assertion that it did not.
let saturation = (r - g) / r;
assert!(
saturation > 0.55,
"column {x} washed out: rgb {r} {g} {b}, saturation {saturation:.3}"
);
}
}
#[test]
fn dragging_the_amount_recompiles_nothing_and_reallocates_nothing() {
// The two costs that are ruinous per frame and invisible in the output.
// A sharpening slider is dragged continuously, so this is the difference
// between a control that tracks the mouse and one that stutters.
let Some(ctx) = ctx() else { return };
const SIZE: u32 = 48;
let source = step_edge(&ctx, SIZE, 90, 150);
let mut pass = AdjustPass::new(&ctx);
let mut graph = sharpened(40.0, 1.0, 0.0);
render(&mut pass, &graph, &source, SIZE);
let pipelines = pass.cached_detail_pipelines();
let allocations = pass.detail_allocations();
assert_eq!(pipelines, 2, "one per axis of the separable mask");
assert_eq!(allocations, 2, "the colour result, and one hand-off");
assert_eq!(pass.detail_dispatches(), 2);
assert_eq!(pass.colour_dispatches(), 1);
for amount in [50.0, 60.0, 70.0, 80.0] {
graph.set_param(ID, AMOUNT, amount);
render(&mut pass, &graph, &source, SIZE);
}
assert_eq!(
pass.cached_detail_pipelines(),
pipelines,
"an amount is a uniform, not a shader"
);
assert_eq!(
pass.detail_allocations(),
allocations,
"a steady viewport must allocate nothing"
);
// TRACES: FR-DEV-3d — and the operational point of `Affects::Detail`:
// sharpening is downstream of every fused operation, so dragging it must
// not re-run them.
assert_eq!(
pass.colour_dispatches(),
1,
"the fused colour pass re-ran for a change it does not depend on"
);
// The radius is also only a uniform, even though it changes the kernel
// extent — the loop bound is read from the uniform block rather than
// baked into the source, which is what keeps a drag off the compiler.
graph.set_param(ID, RADIUS, 2.5);
render(&mut pass, &graph, &source, SIZE);
assert_eq!(pass.cached_detail_pipelines(), pipelines);
graph.set_param(ID, THRESHOLD, 0.3);
render(&mut pass, &graph, &source, SIZE);
assert_eq!(pass.cached_detail_pipelines(), pipelines);
}
#[test]
fn a_render_too_coarse_for_the_radius_still_reaches_the_screen() {
// The failure mode that the pass-through exists to prevent, proved on a
// device rather than argued about. With the radius finer than a render
// pixel the operation declines to sharpen — but it is still active, so the
// fused pass has already been composed to hand on unclipped linear values,
// and something must still perform the output transform. An empty chain
// here would not be a soft preview: it would be a hard error out of
// `render_detailed`, on the most ordinary develop view there is.
let Some(ctx) = ctx() else { return };
const SOURCE: u32 = 128;
const RENDER: u32 = 32; // a quarter scale, as a fit view of a large frame
let source = step_edge(&ctx, SOURCE, 90, 150);
let graph = sharpened(100.0, 1.0, 0.0);
let scale = graph.render_scale((SOURCE, SOURCE), (RENDER, RENDER));
assert!(!scale.resolves(1.0), "the premise of this test");
let mut pass = AdjustPass::new(&ctx);
let sharp = render(&mut pass, &graph, &source, RENDER);
assert_eq!(pass.detail_dispatches(), 1, "one pass, and it only encodes");
// And what reaches the screen is the unsharpened picture, not a black
// frame, a linear one, or a guess.
let plain = render(
&mut AdjustPass::new(&ctx),
&EditGraph::default_chain(),
&source,
RENDER,
);
for (i, (a, b)) in sharp.iter().zip(&plain).enumerate() {
assert!(
a.abs_diff(*b) <= 1,
"pixel {} differs from the unsharpened render: {b} -> {a}",
i / 4
);
}
}
#[test]
fn the_operation_is_reachable_by_the_ids_a_frontend_will_use() {
// FR-DEV-3c: adding an operation needs no UI change, which is only true if
// the panel can find it through the capability list. A typo between the
// declaration's `id:` and the descriptor's would place it in the chain
// under one name and address it under another.
let graph = EditGraph::default_chain();
let cap = graph
.capabilities()
.into_iter()
.find(|c| c.id == ID)
.expect("capture sharpening is in the default chain");
let names: Vec<ParamId> = cap.params.iter().map(|p| p.id).collect();
assert_eq!(names, vec![AMOUNT, RADIUS, THRESHOLD]);
assert!(!cap.active, "a fresh chain is not sharpening anything");
}
+6 -1
View File
@@ -211,7 +211,9 @@ half in Rust would be worse than either alone.
Currently hand-written: `tone_curve` (a curve widget over five interpolated
points), `colour_mixer` (thirty-six faceted parameters from twelve computed hue
bands). `vignetting` is hand-written too but is not in the develop chain — it
bands), `capture_sharpen` (a separable convolution, which is the other reason a
node is Rust — see the next section). `vignetting` is hand-written too but is
not in the develop chain — it
carries lens-profile coefficients that are not parameters. `distortion` and
`aberration` are `Warp`s rather than operations: they rewrite coordinates
before sampling rather than transforming a colour after it.
@@ -239,6 +241,9 @@ rather than a convenience. A node of this kind:
each with a WGSL body, its uniforms, and **its kernel radius in render
pixels**, which the tile scheduler needs and nothing can infer.
`capture_sharpen` is the worked example: two passes, one per axis, and a radius
converted from source pixels once per render.
The `order:` still belongs here, and still orders the node — among the other
detail nodes. Detail runs as a group after every point operation, so an `order:`
that interleaves one with exposure would be a lie the chain cannot tell.
+33
View File
@@ -0,0 +1,33 @@
# A hand-written node, and a neighbourhood one: it reads the pixels around the
# one it is writing, so it runs in the detail stage rather than as a fragment
# in the fused pass. See `../src/detail.rs` for why that stage exists and
# `README.md`'s "Nodes that read their neighbours" for the contract.
#
# As with every `rust:` node, its descriptor, parameters and behaviour come
# from the type; this file exists so that `ops/` remains the one place the
# pipeline's order is written down.
id: capture_sharpen
order: 110
attributes: [detail]
rust: CaptureSharpen
why_rust: |
A convolution, not a point function. The schema in `README.md` describes an
operation handed a colour with no way back to a coordinate, which is exactly
what a kernel cannot work with — and stretching it to cover taps, kernel
extents and a per-render conversion from source pixels to render pixels
would produce a worse language than Rust aimed at one caller.
placement: |
First among the detail nodes, because capture sharpening is a correction to
the capture: it recovers the acutance the anti-aliasing filter, the lens's
circle of confusion and the demosaic interpolation each took out, and it is
meaningful before any effect built on top of it. The compositional detail
controls — texture, clarity — reasonably follow it, since they are about the
picture rather than about the sensor.
Being in the detail group at all is what places it after every tonal and
chromatic operation: an amount tuned before a tone curve is amplified by
whatever slope that curve happens to have, so the amount that looked right
stops looking right the moment the curve moves.
+44
View File
@@ -934,9 +934,19 @@ impl MaskLayer {
/// the output's dimensions, so it is a property of the photograph and not
/// of a region within it. There is no such thing as cropping part of an
/// image.
///
/// The neighbourhood operations are absent for the same reason, and this
/// filter is the visible half of the one [`Self::active_ops`] already
/// applies. A layer's chain is fused into the point-operation pass; the
/// detail stage runs once, afterwards, over the whole frame, so there is
/// no seam through which a mask could reach it (see [`crate::detail`]).
/// Offering the controls anyway would put a sharpening slider on a mask
/// that moves and does nothing — which is worse than the control being
/// absent, because absence is legible and a dead slider is not.
pub fn capabilities(&self) -> Vec<crate::graph::OpCapability> {
self.ops
.iter()
.filter(|op| op.detail().is_none())
.map(|op| {
let desc = op.descriptor();
crate::graph::OpCapability {
@@ -1625,4 +1635,38 @@ mod tests {
let order: Vec<&str> = stack.layers().iter().map(|l| l.id.as_str()).collect();
assert_eq!(order, ["m3", "m1", "m2"]);
}
#[test]
fn a_layer_offers_no_control_it_cannot_honour() {
// A layer's chain is fused into the point-operation pass, and the
// detail stage runs once afterwards over the whole frame — so a
// neighbourhood operation inside a mask has nowhere to run.
// `active_ops` has always dropped them; this is the other half, which
// stops the panel drawing a sharpening slider on a mask that would
// move and change nothing.
let layer = lit_layer("m1", 1.0);
let global: Vec<&str> = crate::EditGraph::default_chain()
.capabilities()
.iter()
.map(|c| c.id.0)
.collect();
let scoped: Vec<&str> = layer.capabilities().iter().map(|c| c.id.0).collect();
let detail: Vec<&str> = crate::ops::chain()
.iter()
.filter(|o| o.detail().is_some())
.map(|o| o.descriptor().id.0)
.collect();
assert!(
!detail.is_empty(),
"the chain has neighbourhood operations, or this proves nothing"
);
for id in detail {
assert!(global.contains(&id), "{id} is missing from the chain");
assert!(
!scoped.contains(&id),
"{id} cannot run inside a mask and must not be offered there"
);
}
}
}
+794
View File
@@ -0,0 +1,794 @@
//! TRACES: FR-DEV-3 | FR-DSP-1
//! Capture sharpening — an unsharp mask against the sensor.
//!
//! Every raw file arrives softer than the scene was. The anti-aliasing filter
//! spreads a point over more than one photosite on purpose, the lens's circle
//! of confusion spreads it further, and the demosaic interpolates two of every
//! three colour samples at each site from its neighbours. None of that is a
//! mistake to be corrected in the developed *picture*; it is a property of how
//! the frame was recorded, and capture sharpening is the step that undoes as
//! much of it as the data supports before anything else is built on top.
//!
//! That distinction is the whole reason this operation exists separately from
//! the output sharpening in `dr-export` (FR-EXP-4). Output sharpening is aimed
//! at a size and a medium — a 900-pixel web image and a matte A2 print want
//! different treatment of the same edit. Capture sharpening is aimed at the
//! sensor, and its answer does not change because the file is going somewhere
//! else.
//!
//! # Unsharp mask, and why the plainest one
//!
//! Blur a copy, subtract it from the original, and add back some multiple of
//! the difference. The difference is everything the blur threw away — the
//! high-frequency content — so adding it back steepens exactly the transitions
//! that the capture chain flattened, and leaves flat areas alone because the
//! blur of a flat area is the area itself.
//!
//! Deconvolution would in principle do better, since the thing being undone
//! really is a convolution with a roughly known kernel. It is also iterative,
//! needs a per-body point-spread estimate this codebase does not have, and
//! amplifies noise in a way that needs its own regularisation. An unsharp mask
//! is the baseline every editor ships and the one a photographer's hands
//! already know; a deconvolution mode can be added later behind the same three
//! parameters without changing what those parameters mean.
//!
//! # Why two passes and not one kernel
//!
//! A Gaussian is separable: blurring along x and then along y gives exactly
//! the same result as a single two-dimensional kernel, at 2(2R+1) taps per
//! pixel instead of (2R+1)². At the radii this operation reaches when zoomed
//! in — a 3-source-pixel radius at 400% is a kernel extent of 36 render pixels
//! — that is 146 taps against 5 329, and it is the difference between a
//! sharpening slider that tracks the mouse and one that does not.
//!
//! [`crate::detail::DetailStage::passes`] returns a *list* precisely so this
//! is expressible: the stage ping-pongs between intermediates, so the second
//! pass is handed what the first one wrote with no plumbing here.
//!
//! ## What the chain can and cannot do, exactly
//!
//! A detail pass reads exactly one texture — whatever ran before it. So the
//! textbook arrangement, "blur in two passes and then subtract the result from
//! the original", is not available: by the time the blur is finished the
//! original is two dispatches behind and nothing is holding it. That is not a
//! gap to be worked around with a third pass either, and it is worth writing
//! down why, because it looks like it should be.
//!
//! Write `Gx`, `Gy` for the two one-dimensional blurs and `Hx = I - Gx`,
//! `Hy = I - Gy` for the high-passes they define. A second pass can form only
//! `α·t + β·Gy(t)` from what the first pass left it in `t`. The result wanted
//! is `(1+a)c - a·GyGx(c)`, whose only occurrence of `c` is under two blurs;
//! matching the `GyGx` term needs `β ≠ 0`, and then the stray `Gy(c)` term
//! that comes with it can only be cancelled by `α·t` if `t` contains `c`
//! unblurred, which the `Gx` in the same expression rules out. No number of
//! extra passes changes this: each one only adds another blur in front.
//!
//! So the two passes each apply a *one-dimensional* unsharp mask, and the
//! composite is the product of the two one-dimensional kernels:
//!
//! ```text
//! (I + a·Hy)(I + a·Hx) = I + a·(Hx + Hy) + a²·HxHy
//! true unsharp = I + a·(Hx + Hy) - a ·HxHy
//! ```
//!
//! They differ in one term, and that term is worth understanding rather than
//! apologising for. `HxHy` responds only to structure that curves in both
//! directions at once: on any locally one-dimensional feature — which is what
//! an edge is — one of the two factors is zero and **the two agree exactly**.
//! Run this on a vertical edge and it produces the textbook unsharp mask to
//! the last bit. They part company only at corners and at fine two-dimensional
//! texture, where this arrangement sharpens slightly harder, by `a(1+a)` times
//! a quantity that is itself second-order small.
//!
//! Both preserve a flat field exactly: each one-dimensional kernel sums to
//! `(1+a) - a = 1`, so their product does too, and no amount of sharpening
//! shifts the brightness of a sky.
//!
//! # The radius is in source pixels, and that is the decision to check
//!
//! [`crate::detail::RenderScale`] offers two units and the choice between them
//! is the one thing about a neighbourhood operation that is easy to get wrong
//! and invisible when it is. `frame_fraction` is for lengths that are a
//! property of the *composition* — clarity, texture, dehaze, a mask feather —
//! where "one percent of the frame" is what the photographer meant. This
//! radius is not one of those. It stands for the spread of a point across
//! *photosites*, and a body with a stronger anti-aliasing filter needs a
//! larger one at the same framing, so it is stated in source pixels and
//! converted with [`RenderScale::source_pixels`] once per render.
//!
//! Read as render pixels instead, the slider would mean a different photograph
//! at every size: the develop view renders at whatever the viewport needs
//! (FR-DSP-1), so a 60 MP frame in a 2 000 px panel would be sharpened with a
//! kernel nine times too wide relative to the picture, and the export — the
//! only render that is ever kept — would be the one that looked nothing like
//! what was tuned. `the_radius_is_a_sensor_length_not_a_viewport_one` below
//! and `a_proxy_and_an_export_sharpen_the_same_photograph` in `dr-gpu` are the
//! two halves of the proof that it does not.
//!
//! # Where the honest answer is "not at this size"
//!
//! Converting into render pixels does not conjure detail back. On a proxy at
//! one-third scale a one-source-pixel radius is a third of a render pixel, and
//! the frequencies it would act on were destroyed by the downscale before this
//! stage ran. [`RenderScale::resolves`] is the predicate for that condition and
//! this operation obeys it: below one render pixel it stops, rather than
//! drawing a plausible-looking sharpening that the exported file will not
//! contain. That is why every editor tells the photographer to judge
//! sharpening at 1:1 — and zooming to 1:1 is enough, because the framing's
//! view rect shrinks while the render target keeps its size and the ratio
//! climbs back to one.
//!
//! A softer roll-off, fading the amount out as the kernel approaches a pixel
//! rather than stopping at it, would look better while zooming. It is not done
//! because it needs a second threshold that no requirement supplies and that
//! would be a guess dressed as a number; `resolves` is the line the stage
//! already draws, and drawing it in two places differently is worse than a
//! visible step.
use crate::descriptor::{
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit,
};
use crate::detail::{DetailPass, DetailStage, RenderScale};
use crate::operation::{Affects, Helper, Operation, Uniform};
use crate::ops::helpers;
pub const ID: OpId = OpId("capture_sharpen");
pub const AMOUNT: ParamId = ParamId("amount");
pub const RADIUS: ParamId = ParamId("radius");
pub const THRESHOLD: ParamId = ParamId("threshold");
/// How far out the Gaussian is walked, in standard deviations.
///
/// Three: beyond that a Gaussian carries under 1.2% of its weight, and the
/// taps cost more than they change. The kernel is normalised by the weights
/// actually summed rather than by an analytic integral, so truncating here
/// costs a slightly narrower effective blur and *not* a brightness shift.
const KERNEL_SIGMAS: f32 = 3.0;
/// The largest kernel extent, in render pixels, that will be dispatched.
///
/// Only reachable by zooming past about 16:1, where the ratio climbs above one
/// and a source-pixel radius becomes many render pixels. The cap exists so
/// that a magnification nobody judges sharpening at cannot quietly turn a
/// slider drag into a 200-tap convolution per pass; the price is a Gaussian
/// truncated inside three sigma at those magnifications, which is a slightly
/// tighter blur and nothing else.
const MAX_KERNEL: f32 = 48.0;
/// The default radius, in source pixels.
///
/// One photosite. It is what an anti-aliasing filter and a demosaic between
/// them spread a point over on a conventional Bayer sensor, and it is where
/// every editor's capture sharpening starts.
const DEFAULT_RADIUS: f32 = 1.0;
static DESCRIPTOR: OpDescriptor = OpDescriptor {
id: ID,
label: LocalizedKey("op.capture_sharpen"),
params: &[
// Amount carries the neutral, which is why it is first: the operation
// is off when this is zero regardless of the other two, so a reset is
// one control and the panel's ordering matches the way it is used.
ParamDescriptor::amount("amount", "param.amount"),
// In **source pixels** — see the module documentation. Half a photosite
// is the smallest radius that means anything on a Bayer sensor, and
// three is already past the point where an unsharp mask is sharpening
// rather than adding local contrast; a photographer wanting the latter
// wants clarity, which is a different operation with a different unit.
ParamDescriptor::scalar(
"radius",
"param.radius",
0.5,
3.0,
DEFAULT_RADIUS,
Unit::None,
Scale::Linear,
2,
),
// A fraction, but declared as a scalar rather than through
// `ParamDescriptor::fraction` for its precision alone: four decimal
// places on a control whose whole useful travel is a dozen steps
// reads as noise, and invites fiddling with digits that do nothing.
ParamDescriptor::scalar(
"threshold",
"param.threshold",
0.0,
1.0,
0.0,
Unit::None,
Scale::Linear,
2,
),
],
attributes: &[Attribute::Detail],
};
/// TRACES: FR-DEV-3
/// Capture sharpening: a separable unsharp mask with a contrast threshold.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CaptureSharpen {
/// −100…100. Negative softens, which is a real request: a lens that
/// out-resolves the sensor, or a frame with moiré, is better served by
/// backing off the capture chain's acutance than by sharpening it.
amount: f32,
/// The Gaussian's standard deviation, **in source pixels**.
radius: f32,
/// Local contrast below which detail is left alone, 0…1.
threshold: f32,
}
impl Default for CaptureSharpen {
/// Neutral, and a radius already set to something usable.
///
/// The radius does not start at its minimum, and this is not the usual
/// "neutral means every parameter at zero" rule being broken: neutrality
/// here is `amount == 0`, and a radius has no neutral value at all — a
/// blur of zero width is not the identity, it is a kernel that does not
/// exist. Starting it at one photosite means dragging the amount up gives
/// a sensible result immediately rather than whatever the low end of the
/// slider happens to be.
fn default() -> Self {
Self {
amount: 0.0,
radius: DEFAULT_RADIUS,
threshold: 0.0,
}
}
}
impl CaptureSharpen {
pub fn new() -> Self {
Self::default()
}
/// The Gaussian's standard deviation at `scale`, in **render** pixels.
///
/// The single place the unit conversion happens, and the reason it is a
/// method rather than a line inside [`Self::passes`]: the tests assert
/// against it, and a test that recomputed the conversion would agree with
/// a bug in it.
pub fn sigma(&self, scale: RenderScale) -> f32 {
scale.source_pixels(self.radius)
}
/// The kernel extent at `scale`, in render pixels — the halo each pass
/// reads, and what [`DetailPass::radius`] has to state.
///
/// At least one whenever the pass runs at all: a kernel of extent zero
/// reads one tap, its "blur" is the pixel itself, and the high-pass it
/// produces is identically zero. That would be a dispatch that copies the
/// image, which is not what "sharpen a little" should mean.
pub fn kernel(&self, scale: RenderScale) -> u32 {
let extent = (self.sigma(scale) * KERNEL_SIGMAS).ceil();
extent.clamp(1.0, MAX_KERNEL) as u32
}
/// Whether this render is fine enough to show the radius that was chosen.
///
/// Delegates to [`RenderScale::resolves`] rather than restating the
/// comparison, so that the line between "sharpened" and "not at this size"
/// is drawn in exactly one place in the codebase.
pub fn resolves(&self, scale: RenderScale) -> bool {
scale.resolves(self.radius)
}
}
impl Operation for CaptureSharpen {
fn descriptor(&self) -> &'static OpDescriptor {
&DESCRIPTOR
}
fn set_param(&mut self, id: ParamId, value: f32) {
if id == AMOUNT {
self.amount = value;
} else if id == RADIUS {
self.radius = value;
} else if id == THRESHOLD {
self.threshold = value;
} else {
log::warn!("capture_sharpen: unknown parameter {id}");
}
}
fn param(&self, id: ParamId) -> f32 {
if id == AMOUNT {
self.amount
} else if id == RADIUS {
self.radius
} else if id == THRESHOLD {
self.threshold
} else {
0.0
}
}
/// Neutral is `amount == 0`, not "every parameter at its default".
///
/// A radius and a threshold describe *how* to sharpen and say nothing
/// about whether to; moving either one with the amount at zero must leave
/// the photograph untouched and must not make the edit non-neutral, or an
/// unedited file would open reporting itself modified as soon as anyone
/// brushed the radius slider.
fn is_active(&self) -> bool {
self.amount != 0.0
}
/// Never called: a detail operation contributes no fused fragment, and
/// [`crate::operation::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)
}
/// The shared Rec. 709 luminance, which in this stage is not the
/// approximation its own documentation warns about: `_helpers.yaml` notes
/// that the weights are only approximate on camera-space values, and the
/// detail stage runs *after* the camera matrix, in linear sRGB, where they
/// are the definition.
fn helpers(&self) -> &'static [Helper] {
&[helpers::LUMINANCE]
}
}
impl DetailStage for CaptureSharpen {
fn passes(&self, scale: RenderScale) -> Vec<DetailPass> {
if !self.resolves(scale) {
return vec![nothing_to_sharpen()];
}
let extent = self.kernel(scale);
// Both halves are the same body and the same uniforms, differing only
// in the axis they walk — and the axis is read from the pass index the
// composer already writes into the base uniform block, so there is one
// kernel here rather than two that can drift apart.
["horizontal", "vertical"]
.into_iter()
.map(|label| DetailPass {
label,
radius: extent,
uniforms: vec![
Uniform {
// −100…100 as a gain around zero. A hundred percent is
// a strong capture sharpen and not the ceiling of what
// is useful, which is why the control is the familiar
// photographic amount rather than a 0…1 fraction.
name: "amount",
value: self.amount / 100.0,
},
Uniform {
name: "sigma",
value: self.sigma(scale),
},
Uniform {
name: "taps",
value: extent as f32,
},
Uniform {
// The threshold as a local-contrast fraction. A quarter
// at the top of the slider: past about 25% modulation
// the gate has stopped rejecting noise and started
// rejecting the edges the operation exists to sharpen.
name: "gate",
value: self.threshold * 0.25,
},
],
wgsl: BODY.to_string(),
})
.collect()
}
}
/// The pass emitted when the radius is finer than a render pixel.
///
/// One dispatch that changes nothing, rather than an empty chain, and the
/// difference is not stylistic. [`crate::operation::compose_full`] decides
/// from the *operations* — before any resolution is known — that an active
/// detail operation means the fused pass hands on unclipped linear values
/// instead of encoding its own output. If this returned no passes at all,
/// that decision would still stand and nothing downstream would ever perform
/// the output transform: `dr-gpu` would be handed a linear-working shader
/// with an empty chain and refuse it.
///
/// So the honest "nothing survives at this scale" still has to carry the
/// encode, and one pass that does only that is exactly the resolve step the
/// stage would otherwise need. It costs a single copy of a proxy-sized
/// texture, which is a rounding error against the dispatches around it.
fn nothing_to_sharpen() -> DetailPass {
DetailPass {
label: "unresolved",
// Reads only the pixel it writes, so a tile needs no halo at all.
radius: 0,
uniforms: Vec::new(),
wgsl: "// The chosen radius is finer than one pixel of this render, so the detail
// it would act on is not in this texture — it was lost to the downscale
// before this stage ran (FR-DSP-1). Guessing at it would put sharpening on
// screen that the exported file will not contain, so this pass passes the
// colour through unchanged and the interface is free to say `zoom to 1:1`.
//
// `c` already holds this pixel; leaving it alone is the whole body."
.to_string(),
}
}
/// One axis of the separable unsharp mask.
///
/// Emitted verbatim for both passes — see [`DetailStage::passes`] for why the
/// axis is read from a uniform the composer already writes rather than from a
/// second copy of this kernel.
const BODY: &str = r#"// One axis of a separable unsharp mask, applied to luminance.
//
// The composer writes this pass's index into the base uniform block's fourth
// lane precisely so that a two-pass operation need not carry a uniform of its
// own to say which half it is in. Pass 0 walks x, pass 1 walks y.
let axis = select(vec2<i32>(0, 1), vec2<i32>(1, 0), u.detail_base.w < 0.5);
// The Gaussian is evaluated here rather than uploaded as a weight table. The
// kernel changes size with the zoom — the radius is in source pixels and the
// ratio is not fixed — so a table would have to be a fixed-length array padded
// to the widest kernel the slider can reach, uploaded per frame, to save an
// `exp` that the hardware does in one instruction.
let extent = i32(taps);
let falloff = 1.0 / (2.0 * sigma * sigma);
// Sharpening acts on luminance alone. Adding the high-pass to the three
// channels independently sharpens chroma noise into coloured speckle at every
// edge, which is the classic way an unsharp mask ruins a high-ISO frame; and
// the demosaic's interpolation error — the thing being corrected — is a
// luminance error, because that is the channel the CFA samples most densely.
let centre = luminance(c);
var weighted = 0.0;
var total = 0.0;
for (var i = -extent; i <= extent; i = i + 1) {
let d = f32(i);
let w = exp(-d * d * falloff);
weighted = weighted + w * luminance(tap(coord, axis * i));
total = total + w;
}
// Normalised by the weights actually summed, never by an analytic integral.
// The kernel is truncated at three sigma and `tap` clamps at the border, so
// the two disagree — by a fraction of a percent in the middle of the frame and
// by far more along its edge. Dividing by the wrong one would put a bright or
// dark band around the whole photograph, which is invisible on a test pattern
// and perfectly visible on a sky.
let blurred = weighted / total;
// Everything this axis's blur threw away. Zero on a flat field, so a sky comes
// through untouched at any amount, and the kernel as a whole still sums to one.
let high = centre - blurred;
// The threshold, as *local contrast* rather than as an absolute difference.
//
// A photographer setting this is saying "modulation this shallow is noise, not
// detail", and that judgement is about the ratio between the detail and the
// tone it sits on: the same sensor noise is a hundred times smaller in linear
// units in a shadow than in a highlight, so an absolute gate calibrated on a
// midtone would leave shadow noise fully sharpened and flatten highlight
// texture. A ratio also makes the control survive the exposure slider, which
// an absolute one would not.
//
// The floor keeps the ratio finite as the local level approaches black. Below
// roughly nine stops down there is nothing but read noise anyway, and without
// it a noise-sized difference divided by a noise-sized level would read as a
// hard edge and be sharpened hardest exactly where it is least wanted.
let level = max(blurred, 0.005);
let contrast = abs(high) / level;
// A soft knee rather than a step: gating on a comparison would sharpen one
// pixel fully and its neighbour not at all, and the boundary between them is
// itself an edge — visible as a crawling outline around every gently graded
// region. Full suppression below half the gate, full effect above it.
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;
// 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
// are the hue and the saturation, and multiplying leaves them exactly where
// they were. Negative luminance is not a colour, so undershoot stops at black
// — the only clamp in this stage, and it is on the scalar, not on the channels,
// which stay unclipped above one for the output transform to deal with.
//
// 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));
c = select(c, scaled, centre > 1e-5);"#;
#[cfg(test)]
mod tests {
use super::*;
use crate::detail::compose_detail;
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
}
fn chain_at(ops: &[Box<dyn Operation>], scale: RenderScale) -> crate::detail::ComposedDetail {
compose_detail(ops, scale, ColourSpace::Srgb)
}
#[test]
fn a_radius_alone_is_not_an_edit() {
// The neutral rule this operation states differently from most: two of
// its three parameters describe *how* to sharpen, and moving them with
// the amount at zero must leave the file unmodified. Otherwise opening
// an image and brushing the radius slider would mark it edited and
// write a sidecar for a photograph nobody changed.
let mut op = CaptureSharpen::new();
assert!(!op.is_active());
op.set_param(RADIUS, 3.0);
op.set_param(THRESHOLD, 1.0);
assert!(!op.is_active(), "a radius is not a decision to sharpen");
op.set_param(AMOUNT, 25.0);
assert!(op.is_active());
}
#[test]
fn a_neutral_sharpener_composes_no_passes_at_all() {
// 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)));
assert!(composed.is_empty());
assert_eq!(composed.radius(), 0);
}
#[test]
fn sharpening_is_two_passes_and_only_the_last_one_encodes() {
// Separability, as it reaches the GPU. The first pass writes a linear
// intermediate and the second writes the display texture, so the
// output transform happens exactly once (FR-DEV-2) at the end of the
// chain rather than in the middle of a convolution.
let composed = chain_at(&sharpening(50.0, 1.0), RenderScale::full((512, 512)));
assert_eq!(composed.len(), 2);
let (first, last) = (&composed.passes[0], &composed.passes[1]);
assert_eq!(first.label, "capture_sharpen/horizontal");
assert_eq!(last.label, "capture_sharpen/vertical");
assert!(!first.writes_output);
assert!(first.source.contains("texture_storage_2d<rgba16float"));
assert!(!first.source.contains("fn encode_output"));
assert!(last.writes_output);
assert!(last.source.contains("texture_storage_2d<rgba8unorm"));
assert!(last.source.contains("fn encode_output"));
// Two shaders, so two pipeline-cache entries. Sharing one would run
// the horizontal pass's uniforms through the vertical pass's slots.
assert_ne!(first.structure_hash, last.structure_hash);
}
#[test]
fn both_passes_are_one_kernel_told_apart_by_the_index_the_composer_writes() {
// The reason there is a single `BODY` constant. If the axis came from
// a uniform of this operation's own, there would be two bodies to keep
// in step; instead each pass reads the index the composer already puts
// in the base block, and the only difference between the two generated
// shaders is the uniform prefix.
let composed = chain_at(&sharpening(50.0, 1.0), RenderScale::full((256, 256)));
for pass in &composed.passes {
assert!(pass.source.contains("u.detail_base.w < 0.5"));
}
assert!(composed.passes[0]
.source
.contains("capture_sharpen_0_amount: f32,"));
assert!(composed.passes[1]
.source
.contains("capture_sharpen_1_amount: f32,"));
// And the body addresses them by the bare names it declared, with the
// composer doing the prefixing — so two detail operations may both
// call a uniform `amount` and neither has to know.
assert!(composed.passes[0]
.source
.contains("let falloff = 1.0 / (2.0 * u.capture_sharpen_0_sigma"));
}
#[test]
fn the_declared_halo_is_the_kernel_the_body_actually_walks() {
// ARCH §5.3 grows a tile by the declared radius before computing it.
// Understate it and every tile boundary shows a seam that looks like a
// driver bug; nothing can infer it from the WGSL, because the loop
// bound is a uniform.
let scale = RenderScale::full((1000, 1000));
let op = {
let mut op = CaptureSharpen::new();
op.set_param(AMOUNT, 60.0);
op.set_param(RADIUS, 2.0);
op
};
let expected = op.kernel(scale);
assert_eq!(expected, 6, "three sigma of a two-pixel radius at 1:1");
let composed = chain_at(&sharpening(60.0, 2.0), scale);
assert_eq!(composed.radius(), expected);
assert!(composed.passes.iter().all(|p| p.radius == expected));
// And the loop really is walked over that many taps each way.
for pass in &composed.passes {
assert!(pass.uniforms.contains(&(expected as f32)));
}
}
#[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.
//
// 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);
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()
};
// 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);
assert!((export - 12.0).abs() < 0.01, "three sigma of four 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.
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"
);
// 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);
}
#[test]
fn a_render_too_coarse_for_the_radius_stops_rather_than_guesses() {
// A one-pixel radius on a quarter-scale proxy is a quarter of a render
// pixel, and no kernel represents that — the frequencies it would act
// 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 proxy = RenderScale::new((1000, 1000), (4000, 4000));
assert!(!proxy.resolves(1.0));
let composed = chain_at(&ops, 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.
assert_eq!(composed.len(), 1);
assert_eq!(composed.radius(), 0, "it reads no neighbours");
let pass = &composed.passes[0];
assert_eq!(pass.label, "capture_sharpen/unresolved");
assert!(pass.writes_output);
assert!(pass.source.contains("fn encode_output"));
assert!(
!pass.source.contains("for (var i ="),
"the pass-through must not walk a kernel it has decided not to run"
);
// Zooming to 1:1 is what brings it back — the view rect shrinks while
// 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);
}
#[test]
fn the_amount_reaches_the_shader_as_a_gain_and_keeps_its_sign() {
// Negative is not a mistake to be clamped away: a lens that
// out-resolves the sensor, or a frame with moiré, wants the capture
// chain's acutance backed off rather than lifted, and the same kernel
// with a negative gain is exactly that.
let amount_of = |value: f32| -> f32 {
let composed = chain_at(&sharpening(value, 1.0), RenderScale::full((512, 512)));
// Base block first and fixed, then this pass's own, in the order
// `passes` declared them.
composed.passes[0].uniforms[crate::detail::DETAIL_BASE_UNIFORM_FIELDS]
};
assert!((amount_of(100.0) - 1.0).abs() < 1e-6);
assert!((amount_of(50.0) - 0.5).abs() < 1e-6);
assert!((amount_of(-40.0) + 0.4).abs() < 1e-6);
}
#[test]
fn the_threshold_is_off_when_it_is_at_zero() {
// The gate is a `smoothstep`, and a `smoothstep` whose two edges meet
// is undefined. The body guards it with a `select` on this uniform
// 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)));
*composed.passes[0].uniforms.last().expect("a gate")
};
assert_eq!(gate_of(0.0), 0.0);
assert!((gate_of(1.0) - 0.25).abs() < 1e-6);
assert!(chain_at(&sharpening(50.0, 1.0), RenderScale::full((512, 512))).passes[0]
.source
.contains("gate > 0.0"));
}
#[test]
fn every_pass_declares_a_uniform_block_the_gpu_will_accept() {
// A uniform struct whose size is not a multiple of sixteen is rejected
// outright by the WGSL uniform address space rules, and the failure
// arrives as a compilation error against source nobody wrote. Both
// shapes this operation emits have to satisfy it — the sharpening pair
// and the pass-through, which carries no uniforms of its own at all.
let scales = [
RenderScale::full((512, 512)),
RenderScale::new((256, 256), (4096, 4096)),
];
for scale in scales {
for pass in chain_at(&sharpening(75.0, 1.0), scale).passes {
assert_eq!(pass.uniforms.len() % 4, 0, "{}", pass.label);
assert!(pass.uniforms.iter().all(|v| v.is_finite()), "{}", 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
// source pixels becomes many render pixels. The cap keeps the cost of
// a magnification nobody judges sharpening at from growing without
// limit; what it costs is a Gaussian truncated inside three sigma,
// which is a slightly tighter blur and no other artefact.
let mut op = CaptureSharpen::new();
op.set_param(AMOUNT, 100.0);
op.set_param(RADIUS, 3.0);
let deep = RenderScale::new((2000, 2000), (25, 25));
assert!(deep.ratio() > 16.0);
assert_eq!(op.kernel(deep), MAX_KERNEL as u32);
}
}
+13 -1
View File
@@ -22,10 +22,20 @@
//! reason rather than for want of migrating. The tone curve interpolates
//! between five points and its neutral is a *relationship* between them; the
//! colour mixer generates thirty-six faceted parameters from twelve computed
//! hue bands; [`vignetting`] carries lens-profile coefficients that are not
//! hue bands; [`capture_sharpen`] is a convolution, and the schema describes a
//! fragment handed a colour with no way back to a coordinate;
//! [`vignetting`] carries lens-profile coefficients that are not
//! parameters at all. A schema stretched to cover those would be a worse
//! language than Rust, aimed at one caller each.
//!
//! # The neighbourhood nodes
//!
//! [`capture_sharpen`] reads the pixels around the one it writes, so it runs
//! in [`crate::detail`]'s stage after the fused pass rather than as a fragment
//! within it. It is an ordinary [`Operation`](crate::Operation) in every other
//! respect — descriptor, parameters, sidecar, history — which is what lets the
//! panel, the presets and the undo stack carry it with no special case.
//!
//! Both publish the same [`crate::descriptor::OpDescriptor`], so nothing
//! downstream can tell them apart. A hand-written node still declares its
//! place in the chain in `ops/<id>.yaml` with `rust:`, so the directory
@@ -41,12 +51,14 @@
// Hand-written nodes. Each is listed in `ops/` with `rust:`, which is what
// places it in the chain; these are the implementations that entry points at.
pub mod aberration;
pub mod capture_sharpen;
pub mod colour_mixer;
pub mod curve;
pub mod distortion;
pub mod vignetting;
pub use aberration::Aberration;
pub use capture_sharpen::CaptureSharpen;
pub use colour_mixer::ColourMixer;
pub use curve::ToneCurve;
pub use distortion::Distortion;