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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user