@@ -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 = ( 4000 u32 , 4000 u32 ) ;
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 ) ;
}
}