WIP: capture sharpening

Checkpoint committed by the coordinator, not by the authoring agent: the
session hit its API limit mid-task and left this work uncommitted. Committed
so it survives, NOT because it is finished - expect failing tests and
half-applied changes. The agent resumes from here.
This commit is contained in:
2026-08-22 19:01:18 +02:00
parent c963dafd09
commit 8ea427de3c
7 changed files with 1410 additions and 2 deletions
+44
View File
@@ -934,9 +934,19 @@ impl MaskLayer {
/// the output's dimensions, so it is a property of the photograph and not
/// of a region within it. There is no such thing as cropping part of an
/// image.
///
/// The neighbourhood operations are absent for the same reason, and this
/// filter is the visible half of the one [`Self::active_ops`] already
/// applies. A layer's chain is fused into the point-operation pass; the
/// detail stage runs once, afterwards, over the whole frame, so there is
/// no seam through which a mask could reach it (see [`crate::detail`]).
/// Offering the controls anyway would put a sharpening slider on a mask
/// that moves and does nothing — which is worse than the control being
/// absent, because absence is legible and a dead slider is not.
pub fn capabilities(&self) -> Vec<crate::graph::OpCapability> {
self.ops
.iter()
.filter(|op| op.detail().is_none())
.map(|op| {
let desc = op.descriptor();
crate::graph::OpCapability {
@@ -1625,4 +1635,38 @@ mod tests {
let order: Vec<&str> = stack.layers().iter().map(|l| l.id.as_str()).collect();
assert_eq!(order, ["m3", "m1", "m2"]);
}
#[test]
fn a_layer_offers_no_control_it_cannot_honour() {
// A layer's chain is fused into the point-operation pass, and the
// detail stage runs once afterwards over the whole frame — so a
// neighbourhood operation inside a mask has nowhere to run.
// `active_ops` has always dropped them; this is the other half, which
// stops the panel drawing a sharpening slider on a mask that would
// move and change nothing.
let layer = lit_layer("m1", 1.0);
let global: Vec<&str> = crate::EditGraph::default_chain()
.capabilities()
.iter()
.map(|c| c.id.0)
.collect();
let scoped: Vec<&str> = layer.capabilities().iter().map(|c| c.id.0).collect();
let detail: Vec<&str> = crate::ops::chain()
.iter()
.filter(|o| o.detail().is_some())
.map(|o| o.descriptor().id.0)
.collect();
assert!(
!detail.is_empty(),
"the chain has neighbourhood operations, or this proves nothing"
);
for id in detail {
assert!(global.contains(&id), "{id} is missing from the chain");
assert!(
!scoped.contains(&id),
"{id} cannot run inside a mask and must not be offered there"
);
}
}
}
+794
View File
@@ -0,0 +1,794 @@
//! TRACES: FR-DEV-3 | FR-DSP-1
//! Capture sharpening — an unsharp mask against the sensor.
//!
//! Every raw file arrives softer than the scene was. The anti-aliasing filter
//! spreads a point over more than one photosite on purpose, the lens's circle
//! of confusion spreads it further, and the demosaic interpolates two of every
//! three colour samples at each site from its neighbours. None of that is a
//! mistake to be corrected in the developed *picture*; it is a property of how
//! the frame was recorded, and capture sharpening is the step that undoes as
//! much of it as the data supports before anything else is built on top.
//!
//! That distinction is the whole reason this operation exists separately from
//! the output sharpening in `dr-export` (FR-EXP-4). Output sharpening is aimed
//! at a size and a medium — a 900-pixel web image and a matte A2 print want
//! different treatment of the same edit. Capture sharpening is aimed at the
//! sensor, and its answer does not change because the file is going somewhere
//! else.
//!
//! # Unsharp mask, and why the plainest one
//!
//! Blur a copy, subtract it from the original, and add back some multiple of
//! the difference. The difference is everything the blur threw away — the
//! high-frequency content — so adding it back steepens exactly the transitions
//! that the capture chain flattened, and leaves flat areas alone because the
//! blur of a flat area is the area itself.
//!
//! Deconvolution would in principle do better, since the thing being undone
//! really is a convolution with a roughly known kernel. It is also iterative,
//! needs a per-body point-spread estimate this codebase does not have, and
//! amplifies noise in a way that needs its own regularisation. An unsharp mask
//! is the baseline every editor ships and the one a photographer's hands
//! already know; a deconvolution mode can be added later behind the same three
//! parameters without changing what those parameters mean.
//!
//! # Why two passes and not one kernel
//!
//! A Gaussian is separable: blurring along x and then along y gives exactly
//! the same result as a single two-dimensional kernel, at 2(2R+1) taps per
//! pixel instead of (2R+1)². At the radii this operation reaches when zoomed
//! in — a 3-source-pixel radius at 400% is a kernel extent of 36 render pixels
//! — that is 146 taps against 5 329, and it is the difference between a
//! sharpening slider that tracks the mouse and one that does not.
//!
//! [`crate::detail::DetailStage::passes`] returns a *list* precisely so this
//! is expressible: the stage ping-pongs between intermediates, so the second
//! pass is handed what the first one wrote with no plumbing here.
//!
//! ## What the chain can and cannot do, exactly
//!
//! A detail pass reads exactly one texture — whatever ran before it. So the
//! textbook arrangement, "blur in two passes and then subtract the result from
//! the original", is not available: by the time the blur is finished the
//! original is two dispatches behind and nothing is holding it. That is not a
//! gap to be worked around with a third pass either, and it is worth writing
//! down why, because it looks like it should be.
//!
//! Write `Gx`, `Gy` for the two one-dimensional blurs and `Hx = I - Gx`,
//! `Hy = I - Gy` for the high-passes they define. A second pass can form only
//! `α·t + β·Gy(t)` from what the first pass left it in `t`. The result wanted
//! is `(1+a)c - a·GyGx(c)`, whose only occurrence of `c` is under two blurs;
//! matching the `GyGx` term needs `β ≠ 0`, and then the stray `Gy(c)` term
//! that comes with it can only be cancelled by `α·t` if `t` contains `c`
//! unblurred, which the `Gx` in the same expression rules out. No number of
//! extra passes changes this: each one only adds another blur in front.
//!
//! So the two passes each apply a *one-dimensional* unsharp mask, and the
//! composite is the product of the two one-dimensional kernels:
//!
//! ```text
//! (I + a·Hy)(I + a·Hx) = I + a·(Hx + Hy) + a²·HxHy
//! true unsharp = I + a·(Hx + Hy) - a ·HxHy
//! ```
//!
//! They differ in one term, and that term is worth understanding rather than
//! apologising for. `HxHy` responds only to structure that curves in both
//! directions at once: on any locally one-dimensional feature — which is what
//! an edge is — one of the two factors is zero and **the two agree exactly**.
//! Run this on a vertical edge and it produces the textbook unsharp mask to
//! the last bit. They part company only at corners and at fine two-dimensional
//! texture, where this arrangement sharpens slightly harder, by `a(1+a)` times
//! a quantity that is itself second-order small.
//!
//! Both preserve a flat field exactly: each one-dimensional kernel sums to
//! `(1+a) - a = 1`, so their product does too, and no amount of sharpening
//! shifts the brightness of a sky.
//!
//! # The radius is in source pixels, and that is the decision to check
//!
//! [`crate::detail::RenderScale`] offers two units and the choice between them
//! is the one thing about a neighbourhood operation that is easy to get wrong
//! and invisible when it is. `frame_fraction` is for lengths that are a
//! property of the *composition* — clarity, texture, dehaze, a mask feather —
//! where "one percent of the frame" is what the photographer meant. This
//! radius is not one of those. It stands for the spread of a point across
//! *photosites*, and a body with a stronger anti-aliasing filter needs a
//! larger one at the same framing, so it is stated in source pixels and
//! converted with [`RenderScale::source_pixels`] once per render.
//!
//! Read as render pixels instead, the slider would mean a different photograph
//! at every size: the develop view renders at whatever the viewport needs
//! (FR-DSP-1), so a 60 MP frame in a 2 000 px panel would be sharpened with a
//! kernel nine times too wide relative to the picture, and the export — the
//! only render that is ever kept — would be the one that looked nothing like
//! what was tuned. `the_radius_is_a_sensor_length_not_a_viewport_one` below
//! and `a_proxy_and_an_export_sharpen_the_same_photograph` in `dr-gpu` are the
//! two halves of the proof that it does not.
//!
//! # Where the honest answer is "not at this size"
//!
//! Converting into render pixels does not conjure detail back. On a proxy at
//! one-third scale a one-source-pixel radius is a third of a render pixel, and
//! the frequencies it would act on were destroyed by the downscale before this
//! stage ran. [`RenderScale::resolves`] is the predicate for that condition and
//! this operation obeys it: below one render pixel it stops, rather than
//! drawing a plausible-looking sharpening that the exported file will not
//! contain. That is why every editor tells the photographer to judge
//! sharpening at 1:1 — and zooming to 1:1 is enough, because the framing's
//! view rect shrinks while the render target keeps its size and the ratio
//! climbs back to one.
//!
//! A softer roll-off, fading the amount out as the kernel approaches a pixel
//! rather than stopping at it, would look better while zooming. It is not done
//! because it needs a second threshold that no requirement supplies and that
//! would be a guess dressed as a number; `resolves` is the line the stage
//! already draws, and drawing it in two places differently is worse than a
//! visible step.
use crate::descriptor::{
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit,
};
use crate::detail::{DetailPass, DetailStage, RenderScale};
use crate::operation::{Affects, Helper, Operation, Uniform};
use crate::ops::helpers;
pub const ID: OpId = OpId("capture_sharpen");
pub const AMOUNT: ParamId = ParamId("amount");
pub const RADIUS: ParamId = ParamId("radius");
pub const THRESHOLD: ParamId = ParamId("threshold");
/// How far out the Gaussian is walked, in standard deviations.
///
/// Three: beyond that a Gaussian carries under 1.2% of its weight, and the
/// taps cost more than they change. The kernel is normalised by the weights
/// actually summed rather than by an analytic integral, so truncating here
/// costs a slightly narrower effective blur and *not* a brightness shift.
const KERNEL_SIGMAS: f32 = 3.0;
/// The largest kernel extent, in render pixels, that will be dispatched.
///
/// Only reachable by zooming past about 16:1, where the ratio climbs above one
/// and a source-pixel radius becomes many render pixels. The cap exists so
/// that a magnification nobody judges sharpening at cannot quietly turn a
/// slider drag into a 200-tap convolution per pass; the price is a Gaussian
/// truncated inside three sigma at those magnifications, which is a slightly
/// tighter blur and nothing else.
const MAX_KERNEL: f32 = 48.0;
/// The default radius, in source pixels.
///
/// One photosite. It is what an anti-aliasing filter and a demosaic between
/// them spread a point over on a conventional Bayer sensor, and it is where
/// every editor's capture sharpening starts.
const DEFAULT_RADIUS: f32 = 1.0;
static DESCRIPTOR: OpDescriptor = OpDescriptor {
id: ID,
label: LocalizedKey("op.capture_sharpen"),
params: &[
// Amount carries the neutral, which is why it is first: the operation
// is off when this is zero regardless of the other two, so a reset is
// one control and the panel's ordering matches the way it is used.
ParamDescriptor::amount("amount", "param.amount"),
// In **source pixels** — see the module documentation. Half a photosite
// is the smallest radius that means anything on a Bayer sensor, and
// three is already past the point where an unsharp mask is sharpening
// rather than adding local contrast; a photographer wanting the latter
// wants clarity, which is a different operation with a different unit.
ParamDescriptor::scalar(
"radius",
"param.radius",
0.5,
3.0,
DEFAULT_RADIUS,
Unit::None,
Scale::Linear,
2,
),
// A fraction, but declared as a scalar rather than through
// `ParamDescriptor::fraction` for its precision alone: four decimal
// places on a control whose whole useful travel is a dozen steps
// reads as noise, and invites fiddling with digits that do nothing.
ParamDescriptor::scalar(
"threshold",
"param.threshold",
0.0,
1.0,
0.0,
Unit::None,
Scale::Linear,
2,
),
],
attributes: &[Attribute::Detail],
};
/// TRACES: FR-DEV-3
/// Capture sharpening: a separable unsharp mask with a contrast threshold.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CaptureSharpen {
/// −100…100. Negative softens, which is a real request: a lens that
/// out-resolves the sensor, or a frame with moiré, is better served by
/// backing off the capture chain's acutance than by sharpening it.
amount: f32,
/// The Gaussian's standard deviation, **in source pixels**.
radius: f32,
/// Local contrast below which detail is left alone, 0…1.
threshold: f32,
}
impl Default for CaptureSharpen {
/// Neutral, and a radius already set to something usable.
///
/// The radius does not start at its minimum, and this is not the usual
/// "neutral means every parameter at zero" rule being broken: neutrality
/// here is `amount == 0`, and a radius has no neutral value at all — a
/// blur of zero width is not the identity, it is a kernel that does not
/// exist. Starting it at one photosite means dragging the amount up gives
/// a sensible result immediately rather than whatever the low end of the
/// slider happens to be.
fn default() -> Self {
Self {
amount: 0.0,
radius: DEFAULT_RADIUS,
threshold: 0.0,
}
}
}
impl CaptureSharpen {
pub fn new() -> Self {
Self::default()
}
/// The Gaussian's standard deviation at `scale`, in **render** pixels.
///
/// The single place the unit conversion happens, and the reason it is a
/// method rather than a line inside [`Self::passes`]: the tests assert
/// against it, and a test that recomputed the conversion would agree with
/// a bug in it.
pub fn sigma(&self, scale: RenderScale) -> f32 {
scale.source_pixels(self.radius)
}
/// The kernel extent at `scale`, in render pixels — the halo each pass
/// reads, and what [`DetailPass::radius`] has to state.
///
/// At least one whenever the pass runs at all: a kernel of extent zero
/// reads one tap, its "blur" is the pixel itself, and the high-pass it
/// produces is identically zero. That would be a dispatch that copies the
/// image, which is not what "sharpen a little" should mean.
pub fn kernel(&self, scale: RenderScale) -> u32 {
let extent = (self.sigma(scale) * KERNEL_SIGMAS).ceil();
extent.clamp(1.0, MAX_KERNEL) as u32
}
/// Whether this render is fine enough to show the radius that was chosen.
///
/// Delegates to [`RenderScale::resolves`] rather than restating the
/// comparison, so that the line between "sharpened" and "not at this size"
/// is drawn in exactly one place in the codebase.
pub fn resolves(&self, scale: RenderScale) -> bool {
scale.resolves(self.radius)
}
}
impl Operation for CaptureSharpen {
fn descriptor(&self) -> &'static OpDescriptor {
&DESCRIPTOR
}
fn set_param(&mut self, id: ParamId, value: f32) {
if id == AMOUNT {
self.amount = value;
} else if id == RADIUS {
self.radius = value;
} else if id == THRESHOLD {
self.threshold = value;
} else {
log::warn!("capture_sharpen: unknown parameter {id}");
}
}
fn param(&self, id: ParamId) -> f32 {
if id == AMOUNT {
self.amount
} else if id == RADIUS {
self.radius
} else if id == THRESHOLD {
self.threshold
} else {
0.0
}
}
/// Neutral is `amount == 0`, not "every parameter at its default".
///
/// A radius and a threshold describe *how* to sharpen and say nothing
/// about whether to; moving either one with the amount at zero must leave
/// the photograph untouched and must not make the edit non-neutral, or an
/// unedited file would open reporting itself modified as soon as anyone
/// brushed the radius slider.
fn is_active(&self) -> bool {
self.amount != 0.0
}
/// Never called: a detail operation contributes no fused fragment, and
/// [`crate::operation::compose_full`] filters it out before asking.
fn wgsl_body(&self) -> String {
String::new()
}
fn uniforms(&self) -> Vec<Uniform> {
Vec::new()
}
fn affects(&self) -> Affects {
Affects::Detail
}
fn detail(&self) -> Option<&dyn DetailStage> {
Some(self)
}
/// The shared Rec. 709 luminance, which in this stage is not the
/// approximation its own documentation warns about: `_helpers.yaml` notes
/// that the weights are only approximate on camera-space values, and the
/// detail stage runs *after* the camera matrix, in linear sRGB, where they
/// are the definition.
fn helpers(&self) -> &'static [Helper] {
&[helpers::LUMINANCE]
}
}
impl DetailStage for CaptureSharpen {
fn passes(&self, scale: RenderScale) -> Vec<DetailPass> {
if !self.resolves(scale) {
return vec![nothing_to_sharpen()];
}
let extent = self.kernel(scale);
// Both halves are the same body and the same uniforms, differing only
// in the axis they walk — and the axis is read from the pass index the
// composer already writes into the base uniform block, so there is one
// kernel here rather than two that can drift apart.
["horizontal", "vertical"]
.into_iter()
.map(|label| DetailPass {
label,
radius: extent,
uniforms: vec![
Uniform {
// −100…100 as a gain around zero. A hundred percent is
// a strong capture sharpen and not the ceiling of what
// is useful, which is why the control is the familiar
// photographic amount rather than a 0…1 fraction.
name: "amount",
value: self.amount / 100.0,
},
Uniform {
name: "sigma",
value: self.sigma(scale),
},
Uniform {
name: "taps",
value: extent as f32,
},
Uniform {
// The threshold as a local-contrast fraction. A quarter
// at the top of the slider: past about 25% modulation
// the gate has stopped rejecting noise and started
// rejecting the edges the operation exists to sharpen.
name: "gate",
value: self.threshold * 0.25,
},
],
wgsl: BODY.to_string(),
})
.collect()
}
}
/// The pass emitted when the radius is finer than a render pixel.
///
/// One dispatch that changes nothing, rather than an empty chain, and the
/// difference is not stylistic. [`crate::operation::compose_full`] decides
/// from the *operations* — before any resolution is known — that an active
/// detail operation means the fused pass hands on unclipped linear values
/// instead of encoding its own output. If this returned no passes at all,
/// that decision would still stand and nothing downstream would ever perform
/// the output transform: `dr-gpu` would be handed a linear-working shader
/// with an empty chain and refuse it.
///
/// So the honest "nothing survives at this scale" still has to carry the
/// encode, and one pass that does only that is exactly the resolve step the
/// stage would otherwise need. It costs a single copy of a proxy-sized
/// texture, which is a rounding error against the dispatches around it.
fn nothing_to_sharpen() -> DetailPass {
DetailPass {
label: "unresolved",
// Reads only the pixel it writes, so a tile needs no halo at all.
radius: 0,
uniforms: Vec::new(),
wgsl: "// The chosen radius is finer than one pixel of this render, so the detail
// it would act on is not in this texture — it was lost to the downscale
// before this stage ran (FR-DSP-1). Guessing at it would put sharpening on
// screen that the exported file will not contain, so this pass passes the
// colour through unchanged and the interface is free to say `zoom to 1:1`.
//
// `c` already holds this pixel; leaving it alone is the whole body."
.to_string(),
}
}
/// One axis of the separable unsharp mask.
///
/// Emitted verbatim for both passes — see [`DetailStage::passes`] for why the
/// axis is read from a uniform the composer already writes rather than from a
/// second copy of this kernel.
const BODY: &str = r#"// One axis of a separable unsharp mask, applied to luminance.
//
// The composer writes this pass's index into the base uniform block's fourth
// lane precisely so that a two-pass operation need not carry a uniform of its
// own to say which half it is in. Pass 0 walks x, pass 1 walks y.
let axis = select(vec2<i32>(0, 1), vec2<i32>(1, 0), u.detail_base.w < 0.5);
// The Gaussian is evaluated here rather than uploaded as a weight table. The
// kernel changes size with the zoom — the radius is in source pixels and the
// ratio is not fixed — so a table would have to be a fixed-length array padded
// to the widest kernel the slider can reach, uploaded per frame, to save an
// `exp` that the hardware does in one instruction.
let extent = i32(taps);
let falloff = 1.0 / (2.0 * sigma * sigma);
// Sharpening acts on luminance alone. Adding the high-pass to the three
// channels independently sharpens chroma noise into coloured speckle at every
// edge, which is the classic way an unsharp mask ruins a high-ISO frame; and
// the demosaic's interpolation error — the thing being corrected — is a
// luminance error, because that is the channel the CFA samples most densely.
let centre = luminance(c);
var weighted = 0.0;
var total = 0.0;
for (var i = -extent; i <= extent; i = i + 1) {
let d = f32(i);
let w = exp(-d * d * falloff);
weighted = weighted + w * luminance(tap(coord, axis * i));
total = total + w;
}
// Normalised by the weights actually summed, never by an analytic integral.
// The kernel is truncated at three sigma and `tap` clamps at the border, so
// the two disagree — by a fraction of a percent in the middle of the frame and
// by far more along its edge. Dividing by the wrong one would put a bright or
// dark band around the whole photograph, which is invisible on a test pattern
// and perfectly visible on a sky.
let blurred = weighted / total;
// Everything this axis's blur threw away. Zero on a flat field, so a sky comes
// through untouched at any amount, and the kernel as a whole still sums to one.
let high = centre - blurred;
// The threshold, as *local contrast* rather than as an absolute difference.
//
// A photographer setting this is saying "modulation this shallow is noise, not
// detail", and that judgement is about the ratio between the detail and the
// tone it sits on: the same sensor noise is a hundred times smaller in linear
// units in a shadow than in a highlight, so an absolute gate calibrated on a
// midtone would leave shadow noise fully sharpened and flatten highlight
// texture. A ratio also makes the control survive the exposure slider, which
// an absolute one would not.
//
// The floor keeps the ratio finite as the local level approaches black. Below
// roughly nine stops down there is nothing but read noise anyway, and without
// it a noise-sized difference divided by a noise-sized level would read as a
// hard edge and be sharpened hardest exactly where it is least wanted.
let level = max(blurred, 0.005);
let contrast = abs(high) / level;
// A soft knee rather than a step: gating on a comparison would sharpen one
// pixel fully and its neighbour not at all, and the boundary between them is
// itself an edge — visible as a crawling outline around every gently graded
// region. Full suppression below half the gate, full effect above it.
let knee = max(gate, 1e-5);
let keep = select(1.0, smoothstep(knee * 0.5, knee, contrast), gate > 0.0);
let target = centre + amount * high * keep;
// Applied as a gain on all three channels rather than as an offset, so that
// steepening an edge does not drag its colour towards grey: the channel ratios
// are the hue and the saturation, and multiplying leaves them exactly where
// they were. Negative luminance is not a colour, so undershoot stops at black
// — the only clamp in this stage, and it is on the scalar, not on the channels,
// which stay unclipped above one for the output transform to deal with.
//
// Below a nearly-black luminance the ratio stops carrying information — the
// three channels are all noise there and the divisor is meaningless — so the
// pixel is handed on untouched rather than multiplied by whatever fell out.
let scaled = c * (max(target, 0.0) / max(centre, 1e-5));
c = select(c, scaled, centre > 1e-5);"#;
#[cfg(test)]
mod tests {
use super::*;
use crate::detail::compose_detail;
use dr_types::ColourSpace;
/// The chain with the sharpener turned up, as the composer sees it.
fn sharpening(amount: f32, radius: f32) -> Vec<Box<dyn Operation>> {
let mut ops = crate::ops::chain();
for op in &mut ops {
if op.descriptor().id == ID {
op.set_param(AMOUNT, amount);
op.set_param(RADIUS, radius);
}
}
ops
}
fn chain_at(ops: &[Box<dyn Operation>], scale: RenderScale) -> crate::detail::ComposedDetail {
compose_detail(ops, scale, ColourSpace::Srgb)
}
#[test]
fn a_radius_alone_is_not_an_edit() {
// The neutral rule this operation states differently from most: two of
// its three parameters describe *how* to sharpen, and moving them with
// the amount at zero must leave the file unmodified. Otherwise opening
// an image and brushing the radius slider would mark it edited and
// write a sidecar for a photograph nobody changed.
let mut op = CaptureSharpen::new();
assert!(!op.is_active());
op.set_param(RADIUS, 3.0);
op.set_param(THRESHOLD, 1.0);
assert!(!op.is_active(), "a radius is not a decision to sharpen");
op.set_param(AMOUNT, 25.0);
assert!(op.is_active());
}
#[test]
fn a_neutral_sharpener_composes_no_passes_at_all() {
// The rule the whole pipeline rests on: an operation at its defaults
// costs nothing. Almost every photograph in a library is unsharpened,
// and none of them should pay a dispatch for it.
let composed = chain_at(&crate::ops::chain(), RenderScale::full((512, 512)));
assert!(composed.is_empty());
assert_eq!(composed.radius(), 0);
}
#[test]
fn sharpening_is_two_passes_and_only_the_last_one_encodes() {
// Separability, as it reaches the GPU. The first pass writes a linear
// intermediate and the second writes the display texture, so the
// output transform happens exactly once (FR-DEV-2) at the end of the
// chain rather than in the middle of a convolution.
let composed = chain_at(&sharpening(50.0, 1.0), RenderScale::full((512, 512)));
assert_eq!(composed.len(), 2);
let (first, last) = (&composed.passes[0], &composed.passes[1]);
assert_eq!(first.label, "capture_sharpen/horizontal");
assert_eq!(last.label, "capture_sharpen/vertical");
assert!(!first.writes_output);
assert!(first.source.contains("texture_storage_2d<rgba16float"));
assert!(!first.source.contains("fn encode_output"));
assert!(last.writes_output);
assert!(last.source.contains("texture_storage_2d<rgba8unorm"));
assert!(last.source.contains("fn encode_output"));
// Two shaders, so two pipeline-cache entries. Sharing one would run
// the horizontal pass's uniforms through the vertical pass's slots.
assert_ne!(first.structure_hash, last.structure_hash);
}
#[test]
fn both_passes_are_one_kernel_told_apart_by_the_index_the_composer_writes() {
// The reason there is a single `BODY` constant. If the axis came from
// a uniform of this operation's own, there would be two bodies to keep
// in step; instead each pass reads the index the composer already puts
// in the base block, and the only difference between the two generated
// shaders is the uniform prefix.
let composed = chain_at(&sharpening(50.0, 1.0), RenderScale::full((256, 256)));
for pass in &composed.passes {
assert!(pass.source.contains("u.detail_base.w < 0.5"));
}
assert!(composed.passes[0]
.source
.contains("capture_sharpen_0_amount: f32,"));
assert!(composed.passes[1]
.source
.contains("capture_sharpen_1_amount: f32,"));
// And the body addresses them by the bare names it declared, with the
// composer doing the prefixing — so two detail operations may both
// call a uniform `amount` and neither has to know.
assert!(composed.passes[0]
.source
.contains("let falloff = 1.0 / (2.0 * u.capture_sharpen_0_sigma"));
}
#[test]
fn the_declared_halo_is_the_kernel_the_body_actually_walks() {
// ARCH §5.3 grows a tile by the declared radius before computing it.
// Understate it and every tile boundary shows a seam that looks like a
// driver bug; nothing can infer it from the WGSL, because the loop
// bound is a uniform.
let scale = RenderScale::full((1000, 1000));
let op = {
let mut op = CaptureSharpen::new();
op.set_param(AMOUNT, 60.0);
op.set_param(RADIUS, 2.0);
op
};
let expected = op.kernel(scale);
assert_eq!(expected, 6, "three sigma of a two-pixel radius at 1:1");
let composed = chain_at(&sharpening(60.0, 2.0), scale);
assert_eq!(composed.radius(), expected);
assert!(composed.passes.iter().all(|p| p.radius == expected));
// And the loop really is walked over that many taps each way.
for pass in &composed.passes {
assert!(pass.uniforms.contains(&(expected as f32)));
}
}
#[test]
fn the_radius_is_a_sensor_length_not_a_viewport_one() {
// TRACES: FR-DSP-1 — the decision this operation is most likely to get
// wrong, asserted directly.
//
// The same edit, composed at three resolutions of one 4000-pixel
// frame. A radius in source pixels must come out as the same number of
// *source* pixels every time, which means a different number of render
// pixels every time. Read the stored radius as render pixels instead
// and the third assertion below is the one that fails: the kernel
// would be four sigma wide at every size, so the proxy on screen would
// be sharpened four times as hard, relative to the picture, as the file
// that gets exported.
let ops = sharpening(80.0, 4.0);
let full = (4000u32, 4000u32);
let in_source_pixels = |render: u32| -> f32 {
let scale = RenderScale::new((render, render), full);
chain_at(&ops, scale).radius() as f32 / scale.ratio()
};
// Export, half-size proxy, quarter-size proxy.
let export = in_source_pixels(4000);
let half = in_source_pixels(2000);
let quarter = in_source_pixels(1000);
assert!((export - 12.0).abs() < 0.01, "three sigma of four pixels");
// Within the rounding of one render pixel back through the ratio,
// which is the whole of the permitted error: the kernel is an integer
// count of render pixels and 12 does not divide evenly by four.
assert!(
(half - export).abs() <= 2.0,
"the same edit covers {half} source pixels on a half proxy and \
{export} at export"
);
assert!(
(quarter - export).abs() <= 4.0,
"the same edit covers {quarter} source pixels on a quarter proxy \
and {export} at export"
);
// The other half of the statement, and the one that fails if the unit
// is misread: the kernel in *render* pixels must shrink with the
// render, because that is what keeps it the same size on the picture.
let render_pixels = |render: u32| {
chain_at(&ops, RenderScale::new((render, render), full)).radius()
};
assert_eq!(render_pixels(4000), 12);
assert_eq!(render_pixels(2000), 6);
assert_eq!(render_pixels(1000), 3);
}
#[test]
fn a_render_too_coarse_for_the_radius_stops_rather_than_guesses() {
// A one-pixel radius on a quarter-scale proxy is a quarter of a render
// pixel, and no kernel represents that — the frequencies it would act
// on went out with the downscale. `RenderScale::resolves` reports the
// condition and this obeys it, because a preview that shows sharpening
// the exported file will not contain is worse than one that shows none.
let ops = sharpening(100.0, 1.0);
let proxy = RenderScale::new((1000, 1000), (4000, 4000));
assert!(!proxy.resolves(1.0));
let composed = chain_at(&ops, proxy);
// Not empty, though. See `nothing_to_sharpen`: the fused pass has
// already been composed to hand on linear values, so *something* must
// still perform the output transform.
assert_eq!(composed.len(), 1);
assert_eq!(composed.radius(), 0, "it reads no neighbours");
let pass = &composed.passes[0];
assert_eq!(pass.label, "capture_sharpen/unresolved");
assert!(pass.writes_output);
assert!(pass.source.contains("fn encode_output"));
assert!(
!pass.source.contains("for (var i ="),
"the pass-through must not walk a kernel it has decided not to run"
);
// Zooming to 1:1 is what brings it back — the view rect shrinks while
// the render target keeps its size — so there is no separate
// full-resolution preview path for a photographer to wait on.
let one_to_one = RenderScale::new((1000, 1000), (1000, 1000));
assert_eq!(chain_at(&ops, one_to_one).len(), 2);
}
#[test]
fn the_amount_reaches_the_shader_as_a_gain_and_keeps_its_sign() {
// Negative is not a mistake to be clamped away: a lens that
// out-resolves the sensor, or a frame with moiré, wants the capture
// chain's acutance backed off rather than lifted, and the same kernel
// with a negative gain is exactly that.
let amount_of = |value: f32| -> f32 {
let composed = chain_at(&sharpening(value, 1.0), RenderScale::full((512, 512)));
// Base block first and fixed, then this pass's own, in the order
// `passes` declared them.
composed.passes[0].uniforms[crate::detail::DETAIL_BASE_UNIFORM_FIELDS]
};
assert!((amount_of(100.0) - 1.0).abs() < 1e-6);
assert!((amount_of(50.0) - 0.5).abs() < 1e-6);
assert!((amount_of(-40.0) + 0.4).abs() < 1e-6);
}
#[test]
fn the_threshold_is_off_when_it_is_at_zero() {
// The gate is a `smoothstep`, and a `smoothstep` whose two edges meet
// is undefined. The body guards it with a `select` on this uniform
// being positive, so a photographer who never touches the threshold
// gets the plain unsharp mask and not a NaN.
let gate_of = |threshold: f32| -> f32 {
let mut ops = sharpening(50.0, 1.0);
for op in &mut ops {
if op.descriptor().id == ID {
op.set_param(THRESHOLD, threshold);
}
}
let composed = chain_at(&ops, RenderScale::full((512, 512)));
*composed.passes[0].uniforms.last().expect("a gate")
};
assert_eq!(gate_of(0.0), 0.0);
assert!((gate_of(1.0) - 0.25).abs() < 1e-6);
assert!(chain_at(&sharpening(50.0, 1.0), RenderScale::full((512, 512))).passes[0]
.source
.contains("gate > 0.0"));
}
#[test]
fn every_pass_declares_a_uniform_block_the_gpu_will_accept() {
// A uniform struct whose size is not a multiple of sixteen is rejected
// outright by the WGSL uniform address space rules, and the failure
// arrives as a compilation error against source nobody wrote. Both
// shapes this operation emits have to satisfy it — the sharpening pair
// and the pass-through, which carries no uniforms of its own at all.
let scales = [
RenderScale::full((512, 512)),
RenderScale::new((256, 256), (4096, 4096)),
];
for scale in scales {
for pass in chain_at(&sharpening(75.0, 1.0), scale).passes {
assert_eq!(pass.uniforms.len() % 4, 0, "{}", pass.label);
assert!(pass.uniforms.iter().all(|v| v.is_finite()), "{}", pass.label);
}
}
}
#[test]
fn a_deep_zoom_cannot_turn_a_slider_into_an_unbounded_convolution() {
// Zooming past about 16:1 pushes the ratio above one, and a radius in
// source pixels becomes many render pixels. The cap keeps the cost of
// a magnification nobody judges sharpening at from growing without
// limit; what it costs is a Gaussian truncated inside three sigma,
// which is a slightly tighter blur and no other artefact.
let mut op = CaptureSharpen::new();
op.set_param(AMOUNT, 100.0);
op.set_param(RADIUS, 3.0);
let deep = RenderScale::new((2000, 2000), (25, 25));
assert!(deep.ratio() > 16.0);
assert_eq!(op.kernel(deep), MAX_KERNEL as u32);
}
}
+13 -1
View File
@@ -22,10 +22,20 @@
//! reason rather than for want of migrating. The tone curve interpolates
//! between five points and its neutral is a *relationship* between them; the
//! colour mixer generates thirty-six faceted parameters from twelve computed
//! hue bands; [`vignetting`] carries lens-profile coefficients that are not
//! hue bands; [`capture_sharpen`] is a convolution, and the schema describes a
//! fragment handed a colour with no way back to a coordinate;
//! [`vignetting`] carries lens-profile coefficients that are not
//! parameters at all. A schema stretched to cover those would be a worse
//! language than Rust, aimed at one caller each.
//!
//! # The neighbourhood nodes
//!
//! [`capture_sharpen`] reads the pixels around the one it writes, so it runs
//! in [`crate::detail`]'s stage after the fused pass rather than as a fragment
//! within it. It is an ordinary [`Operation`](crate::Operation) in every other
//! respect — descriptor, parameters, sidecar, history — which is what lets the
//! panel, the presets and the undo stack carry it with no special case.
//!
//! Both publish the same [`crate::descriptor::OpDescriptor`], so nothing
//! downstream can tell them apart. A hand-written node still declares its
//! place in the chain in `ops/<id>.yaml` with `rust:`, so the directory
@@ -41,12 +51,14 @@
// Hand-written nodes. Each is listed in `ops/` with `rust:`, which is what
// places it in the chain; these are the implementations that entry points at.
pub mod aberration;
pub mod capture_sharpen;
pub mod colour_mixer;
pub mod curve;
pub mod distortion;
pub mod vignetting;
pub use aberration::Aberration;
pub use capture_sharpen::CaptureSharpen;
pub use colour_mixer::ColourMixer;
pub use curve::ToneCurve;
pub use distortion::Distortion;