Files
DarkRoom/core/dr-pipeline/src/ops/capture_sharpen.rs
T
dtourolleandClaude Opus 5 0c3b8cb1c4 Hand out descriptors a declaration could produce
`Operation::descriptor()` returned `&'static OpDescriptor`, and that lifetime
is the whole reason a build-time node is free and a run-time node is
impossible: only a compile-time literal can satisfy it, so no amount of
reading `ops/*.yaml` at startup could ever produce a descriptor the rest of
the application would accept. FR-PLG-2 says a bundled operation and a
third-party plugin are the same kind of thing, differing only in where the
file was found — and a lifetime outsiders cannot meet is exactly the second,
weaker format that requirement forbids.

So a descriptor is now owned and handed out as `Arc<OpDescriptor>`, with `Vec`
where it held `&'static` slices. `Arc` rather than a `&self`-borrowed
reference because the callers want to *keep* it: the develop panel collects
descriptors and then mutates the graph, and a borrow would tie the
descriptor's lifetime to a borrow of the operation it came from, which is the
one thing `&'static` was doing right.

The identifier newtypes deliberately did not follow. `ParamId` is `Copy`, is
compared in `match` arms against generated constants, is a map key in the
sidecar and history, and reaches Slint model rows; an `Arc<str>` there would
cost a refcount on every one of those and would take `match id { EXPOSURE =>
.. }` away from the generated code. They gain an interner instead, which is
honest about its lifetime rather than pretending to one — the set of ids is
bounded by deduplication and is process-lifetime by construction, because the
sidecar on disk names its parameters and an id has to stay resolvable for as
long as any edit naming it can be opened.

No behaviour changes. Every descriptor that was a `static` is a `LazyLock`
initialiser now, `Operation::helpers` borrows from `self` instead of being
`'static` so a future run-time node can own its list, and `Warp` and `Framing`
follow `Operation` so there is one shape rather than two.

The one place a descriptor is read per frame is `compose_full`, which takes
`descriptor().id` to prefix each active operation's uniforms, and `dr-ui`
composes on every frame it draws. That is a dozen atomic increments beside a
composition that is already building several kilobytes of WGSL on the same
call; it is noted at the trait method rather than left for a profiler to find.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 19:02:25 +02:00

909 lines
41 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 std::sync::{Arc, LazyLock};
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: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
Arc::new(OpDescriptor {
id: ID,
label: LocalizedKey("op.capture_sharpen"),
params: vec![
// 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: vec![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) -> Arc<OpDescriptor> {
DESCRIPTOR.clone()
}
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,
// A convolution, not a list: nothing to bind at binding 3.
storage: Vec::new(),
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,
// A convolution, not a list: nothing to bind at binding 3.
storage: Vec::new(),
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);
// `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
// 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(sharpened, 0.0) / max(centre, 1e-5));
c = select(c, scaled, centre > 1e-5);"#;
#[cfg(test)]
mod tests {
use super::*;
use crate::EditGraph;
use dr_types::ColourSpace;
/// 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(graph: &EditGraph, scale: RenderScale) -> crate::detail::ComposedDetail {
// The scale is what these tests vary, so it is rebuilt into the two
// sizes it stands for rather than handed over: a render of the full
// frame at `render_size`, from a source of `full_size`.
graph.compose_detail_for(scale.full_size(), scale.render_size(), 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(&EditGraph::default_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. It is the test the brief asked for: the
// same edit at more than one render resolution has to sharpen the same
// photograph.
//
// 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(&graph, scale).radius() as f32 / scale.ratio()
};
let export = in_source_pixels(4000);
let half = in_source_pixels(2000);
let fit = in_source_pixels(1600);
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 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!(
(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(&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]
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 graph = sharpening(100.0, 1.0);
let proxy = RenderScale::new((1000, 1000), (4000, 4000));
assert!(!proxy.resolves(1.0));
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.
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(&graph, 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 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);
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 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 kernel 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 block the composer wraps
// the body in. Its preamble declares `var output` and its tail
// writes through it, and neither is this test's business; scanning
// the whole file would fail on generated code nobody here can fix.
let block = pass
.source
.rsplit_once(" {\n")
.expect("the operation's block opens")
.1;
let body = block
.split_once("\n }\n")
.map_or(block, |(inside, _)| inside);
// Comments are not declarations, and the kernel deliberately names
// `target` in one to say why it does not use it. Stripping them
// keeps this on the code, so that editing the prose can never fail
// a test about what compiles.
let code: Vec<&str> = body
.lines()
.filter(|l| !l.trim_start().starts_with("//"))
.collect();
let code = code.join("\n");
for keyword in RESERVED {
for form in [format!("let {keyword} "), format!("var {keyword} ")] {
assert!(
!code.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
// 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);
}
}