A spot set now composes detail passes of its own, one per round, and they go ahead of every operation's kernel. That placement is the decision worth recording: a sharpening pass reads a neighbourhood, so sharpening a dust mark before removing it smears its edge into pixels the repair's disc does not cover, and what survives is a faint over-sharpened ring around an otherwise perfect patch. It also disagrees with ARCH §5.2, which draws spot removal after clarity — docs/spot-removal.md §5.1 is where that is argued out. Every length reaching the shader is in render pixels, converted here where the framing is in scope. Both the centre and the source go through `Framing::output_at` — the same map the fused pass applies to every pixel — so a rotated photograph rotates the offset with no trigonometry, and the radius is found by mapping a point one radius above the centre and measuring, rather than by multiplying by a ratio this function has no business knowing about. The tests turn and crop the frame and expect the mark to stay gone, which is the property that arrangement buys. compose_full now takes the spot set, because a photograph with a repair and no sharpening still has a detail stage: a fused pass that encoded its own output there would quantise twice and bind to a texture of the wrong format. compose_detail_for takes the source size for the same kind of reason — a RenderScale describes the region on screen, and a spot is stored against the photograph. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
906 lines
41 KiB
Rust
906 lines
41 KiB
Rust
//! 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,
|
||
// 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);
|
||
}
|
||
}
|