@@ -0,0 +1,742 @@
//! TRACES: FR-DEV-18 | FR-DSP-1
//! Dehaze — measuring the veil distance puts over a subject, and dividing it
//! back out.
//!
//! Haze is not a tone problem wearing a spatial disguise, which is why none of
//! the controls already in the chain can remove it. Atmospheric scattering
//! composites an *airlight* over the scene in proportion to how far away each
//! part of it is:
//!
//! ```text
//! I = J·t + A·(1 − t), t = e^(−β·d)
//! ```
//!
//! `J` is the scene, `A` the airlight, `t` the transmission and `d` the
//! distance. The two things it does — lifting the black point towards `A` and
//! compressing contrast towards it — are both *per-pixel*, because `d` is. A
//! black point that clears the mountains crushes the foreground; a contrast
//! curve that clears the mountains does the same. So the operation has to
//! estimate `t` at every pixel, and estimating it is the whole of the work.
//!
//! # How the transmission is estimated
//!
//! The dark-channel prior: in a haze-free patch of an ordinary photograph, at
//! least one of the three channels is close to zero *somewhere* — a shadow, a
//! dark surface, a saturated colour whose complementary channels are empty.
//! Wherever that local minimum sits well above zero instead, something
//! additive has lifted it, and the amount it has been lifted by is `A·(1 − t)`.
//! So the veil is a local minimum over the channels and over a patch, and the
//! transmission follows from it.
//!
//! The prior fails on a genuinely bright, genuinely haze-free subject — snow,
//! a white wall filling the patch — where it reads the brightness as veil and
//! this operation darkens it. That is the known failure of the prior and it is
//! why the amount is a slider a photographer sets by eye rather than an
//! automatic correction applied on open.
//!
//! # Why the airlight is taken as neutral, and as one
//!
//! The literature estimates `A` from the brightest few pixels of the dark
//! channel — a *whole-frame* reduction, which this stage cannot perform. The
//! detail chain hands each pass the pass before it at a fixed fraction of the
//! render size; there is no reduction to a single value in it, and adding one
//! would be a second chain of `log(n)` dispatches whose shape changes with
//! every viewport.
//!
//! It is not needed. Airlight is the illuminant scattered towards the camera,
//! and white balance is the first node in the chain — by the time the detail
//! stage runs, the illuminant has already been driven to neutral, so `A` is
//! grey and only its *magnitude* is unknown. Taking that magnitude as one — as
//! bright as diffuse white — makes the estimated veil a fixed multiple of the
//! true one, and a fixed multiple of the veil is exactly what the amount
//! slider already scales. The unknown lands on a control the photographer is
//! setting by eye anyway, rather than on a reduction the architecture would
//! have to grow to compute.
//!
//! # In linear light, deliberately unlike clarity
//!
//! [`crate::ops::local_contrast`] works in stops, because a perceptual local
//! contrast control has to mean the same thing in a highlight and in a shadow.
//! This operation is the opposite case: it inverts a physical model stated in
//! linear radiance, where the veil is an *additive* term. Taking logarithms
//! first would turn the subtraction into something that is not the inverse of
//! anything, and the correction would stop being a correction. The intermediate
//! this stage reads is linear and scene-referred, so the model applies to it as
//! written.
//!
//! # The radius is a fraction of the frame
//!
//! [`RenderScale`] names two units and choosing the wrong one is the mistake a
//! neighbourhood operation makes silently. The patch is compositional, so it
//! takes [`RenderScale::frame_fraction`] — the unit clarity, texture and a mask
//! feather already use — and never [`RenderScale::source_pixels`].
//!
//! The test is whose property the length is. Capture sharpening's radius
//! belongs to the *sensor*: it stands for the spread of a point across
//! photosites and does not change when the frame is cropped. This one belongs
//! to the *picture*: the patch has to be large enough to contain something
//! dark and small enough that the veil it measures is still local, and both of
//! those are statements about how much of the composition it covers. Crop into
//! a quarter of the frame and the depth structure now fills it, so the patch
//! that measures it really has grown — which `frame_fraction` gives, because
//! [`crate::EditGraph::render_scale`] folds the crop in before this code runs.
//!
//! Stated in raw pixels it would be a different photograph on screen and in
//! the file: the develop view renders at whatever the viewport needs
//! (FR-DSP-1), so a patch tuned at one-third scale would be three times too
//! narrow relative to the picture in the export — and the export is the only
//! render anybody keeps.
//!
//! Unlike texture, the pass is **not** dropped when the patch rounds small.
//! Texture's absence on a thumbnail is the honest answer, because a two-pixel
//! surface structure is not present in a 300-pixel rendering of the frame at
//! all. Dehaze changes the overall tone and colour of the picture, and a
//! thumbnail disagreeing with the develop view about *that* reads as a bug
//! rather than as a scale. So the patch is floored at one pixel instead.
//!
//! # What it costs, and the identity that makes it affordable
//!
//! A minimum over a patch is separable, as a Gaussian is: minimum along x,
//! then along y. That alone is not enough. The patch is 1% of the shorter edge
//! — 61 taps across at 4K — and two passes of 61 taps is the arithmetic that
//! measured 34 ms for clarity and became `docs/technical-debt.md` TD-4.
//!
//! A minimum has a property a Gaussian does not: **erosions compose by adding
//! their structuring elements**. The minimum over a contiguous run of `d`
//! pixels, followed by the minimum over `k` pixels spaced `d` apart, is the
//! minimum over the whole `k·d`-wide window, because `{0…d− 1} ⊕ {0, d, …} =
//! {0…kd− 1}`. With `d ≈ √W` the window costs `d + k ≈ 2√W` taps instead of
//! `W` — 16 rather than 61 at 4K — and it is the **same filter**, not an
//! approximation of one.
//!
//! That distinction is the whole reason this is allowed here while the strided
//! kernel `local_contrast` refuses is not. A stride samples an image that is
//! not band-limited and aliases: high-frequency content folds down into the
//! base, the base is subtracted, and the aliasing arrives as low-frequency
//! mottling across smooth gradients. This decomposition samples nothing — it
//! evaluates the exact minimum over every pixel of the window, in two steps.
//!
//! It is also why this operation does not use the reduced chain that TD-4 gave
//! clarity. The runner holds one reduced buffer, so every scaled pass in a
//! chain must declare the same `output_scale`; clarity's steps down with the
//! viewport, so a second operation choosing its own would disagree with it at
//! some window sizes and not others. Four cheap full-resolution passes cost
//! less than that coupling, and the decomposition is what makes them cheap.
//!
//! # The artefact this does not fix
//!
//! An erosion by a wide element carries a dark object's value out to the
//! patch radius around it, so the transmission map says "no haze here" in a
//! band around every dark foreground shape and the correction falls off
//! inside it. That band is the classic dark-channel halo, and the published
//! answer to it is a guided filter or a matting Laplacian, which refines the
//! transmission against the picture's own edges.
//!
//! Neither is done. A guided filter is a second chain carrying two more
//! moments per pixel, and this stage hands each pass exactly one scalar lane
//! (see [`DetailPass::wgsl`]); its regularisation parameter is a number no
//! requirement supplies and would be a guess dressed up as a constant. The
//! erosion of a continuous image is continuous, so what is left is a gradient
//! rather than an edge — an under-correction near dark objects, not a ring
//! around them.
use std ::sync ::{ Arc , LazyLock } ;
use crate ::descriptor ::{ Attribute , LocalizedKey , OpDescriptor , OpId , ParamDescriptor , ParamId } ;
use crate ::detail ::{ DetailPass , DetailStage , RenderScale } ;
use crate ::operation ::{ Affects , Helper , Operation , Uniform } ;
pub const ID : OpId = OpId ( " dehaze " ) ;
/// The one parameter, so the sidecar key reads `dehaze.amount`.
pub const AMOUNT : ParamId = ParamId ( " amount " ) ;
/// The patch the veil is measured over, as a fraction of the frame's shorter
/// edge — a **half**-width, so the window is twice this plus one.
///
/// 1% — 30 px either side on a 4000 x 3000 frame. The two ends of the range
/// are set by opposite failures of the prior. Narrower, and an ordinary patch
/// of sky or skin contains nothing dark, so the minimum reads brightness as
/// veil and the operation darkens the subject. Wider, and the minimum stops
/// being local: it starts reporting the darkest thing in a large region of the
/// frame, so a single shadow suppresses the correction across a quarter of the
/// picture.
///
/// It is within a factor of two of the 15 x 15 patch the dark-channel
/// literature uses on the ~600 px images it is demonstrated at, which is the
/// same fraction of a frame stated the other way round.
const PATCH : f32 = 0.01 ;
/// The fraction of the estimated veil that full slider travel removes.
///
/// Not one, and this is a decision about the photograph rather than a safety
/// margin. Aerial perspective is how a picture says "far away"; removing all
/// of it flattens a landscape into a cut-out, which is the look that makes
/// heavy dehaze recognisable as an effect. Keeping a twentieth of the veil
/// leaves distance reading as distance at the top of the slider.
const MAX_OMEGA : f32 = 0.95 ;
/// The smallest transmission the recovery will divide by.
///
/// Where the veil is nearly total there is nothing left to recover — the
/// signal that survived is a few percent of the airlight, and dividing it back
/// up amplifies whatever noise came with it by the same factor. A tenth is the
/// floor the dark-channel literature uses and it means the same thing here: at
/// worst this operation multiplies by ten, and a region that hits the floor
/// keeps a trace of haze rather than becoming a picture of its own noise.
const MIN_TRANSMISSION : f32 = 0.1 ;
static DESCRIPTOR : LazyLock < Arc < OpDescriptor > > = LazyLock ::new ( | | {
Arc ::new ( OpDescriptor {
id : ID ,
label : LocalizedKey ( " op.dehaze " ) ,
params : vec ! [ ParamDescriptor ::amount ( " amount " , " param.dehaze.amount " ) ] ,
attributes : vec ! [ Attribute ::Detail ] ,
} )
} ) ;
/// The darkest channel at a pixel, floored at zero.
///
/// Declared here rather than in `_helpers.yaml` because it is not a shared
/// idea: it is the dark-channel prior's own statistic and means nothing
/// outside this file.
const DARK_CHANNEL : Helper = Helper {
name : " dark_channel " ,
source : " \
// The smallest of the three channels — the dark-channel prior's statistic,
// before the minimum over a patch is taken.
//
// Floored at zero because the intermediate is unclipped and scene-referred, so
// an out-of-gamut colour arrives with a negative channel. A negative veil
// would come back through the recovery as extra contrast in exactly the
// pixels the working space could not represent, which is a bright fringe
// arriving from a control the photographer reads as haze removal.
fn dark_channel(c: vec3<f32>) -> f32 {
return max(min(min(c.r, c.g), c.b), 0.0);
} " ,
} ;
static HELPERS : & [ Helper ] = & [ DARK_CHANNEL ] ;
/// TRACES: FR-DEV-18
/// Dehaze: estimate the atmospheric veil from the picture and divide it out.
///
/// `Default` is derived rather than written out: the neutral of this operation
/// is an amount of zero and nothing else, so there is no second fact for a
/// hand-written impl to state. Capture sharpening needs one because its radius
/// has no neutral value — a blur of zero width is not the identity, it is a
/// kernel that does not exist — and the patch here is a constant rather than a
/// parameter, so that problem does not arise.
#[ derive(Debug, Clone, Copy, PartialEq, Default) ]
pub struct Dehaze {
/// − 100…100, exactly as the slider reports it. Negative *adds* haze — see
/// [`Self::omega`].
amount : f32 ,
}
impl Dehaze {
pub fn new ( ) -> Self {
Self ::default ( )
}
/// Start from a slider position, for tests and presets.
pub fn with_amount ( amount : f32 ) -> Self {
Self { amount }
}
/// The patch's half-width at this render, in **render pixels**.
///
/// The one unit conversion this operation performs, and a method rather
/// than a line inside [`Self::passes`] so that a test can state what it
/// expects without repeating the rounding rule — a test that recomputed it
/// would agree with a bug in it.
///
/// Floored at one pixel rather than allowed to reach zero: see the module
/// documentation for why this operation does not drop its pass on a
/// thumbnail the way texture does.
pub fn patch ( & self , scale : RenderScale ) -> u32 {
scale . frame_fraction ( PATCH ) . round ( ) . max ( 1.0 ) as u32
}
/// How much of the estimated veil this setting removes.
///
/// Negative at a negative amount, and the same formula then *adds* haze:
/// the recovery becomes a composite of airlight over the picture in
/// proportion to the veil already measured. So negative dehaze deepens the
/// aerial perspective a scene already has rather than fogging it evenly,
/// which is what a photographer asking for atmosphere means — and a scene
/// with no haze in it stays clear, because there is no veil to scale.
fn omega ( & self ) -> f32 {
self . amount / 100.0 * MAX_OMEGA
}
}
/// A minimum filter of half-width `radius`, split into two exact stages.
///
/// See the module documentation: eroding by a contiguous run and then by a set
/// of points spaced one run apart erodes by the sum of the two, which is the
/// whole window. This is the arithmetic of that split, in one place, because
/// both axes need it and a second copy is a second chance to get the centring
/// wrong.
#[ derive(Debug, Clone, Copy, PartialEq) ]
pub struct Split {
/// Length of the contiguous run the first pass takes the minimum over.
pub run : u32 ,
/// How many runs the second pass chains together, spaced `run` apart.
pub span : u32 ,
/// What the second pass subtracts from its offsets to centre the window.
///
/// The composite covers `run * span` pixels, which is at least the window
/// asked for and can be one or two more; the surplus falls on the far side
/// rather than being trimmed, because trimming it would need a third pass
/// and a patch a pixel wider on one side is not a visible difference in a
/// field this smooth.
pub shift : i32 ,
}
impl Split {
/// Split a window of half-width `radius`.
///
/// `run` is the square root of the window rather than any other divisor
/// because `d + ceil(W/d)` is smallest there — the two stages cost the
/// same, which is what minimises their sum for a fixed product.
pub fn of ( radius : u32 ) -> Self {
let width = 2 * radius + 1 ;
let run = ( width as f32 ) . sqrt ( ) . ceil ( ) . max ( 1.0 ) as u32 ;
let span = width . div_ceil ( run ) ;
Self {
run ,
span ,
shift : ( ( run * span - 1 ) / 2 ) as i32 ,
}
}
/// The furthest the second pass reads, in pixels.
///
/// Stated rather than assumed symmetric: the composite window is centred
/// to within a pixel and not exactly, so the two directions can differ by
/// one. An understated radius is a seam at every tile boundary (ARCH
/// §5.3), which is the kind of artefact that looks like a driver bug.
pub fn reach ( & self ) -> u32 {
let far = ( self . span . saturating_sub ( 1 ) * self . run ) as i32 - self . shift ;
self . shift . max ( far ) . max ( 0 ) as u32
}
}
impl Operation for Dehaze {
fn descriptor ( & self ) -> Arc < OpDescriptor > {
Arc ::clone ( & DESCRIPTOR )
}
fn set_param ( & mut self , _id : ParamId , value : f32 ) {
self . amount = value ;
}
fn param ( & self , _id : ParamId ) -> f32 {
self . amount
}
fn is_active ( & self ) -> bool {
self . amount ! = 0.0
}
/// Never called: a neighbourhood operation contributes no fused fragment,
/// and `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 )
}
fn helpers ( & self ) -> & 'static [ Helper ] {
HELPERS
}
}
impl DetailStage for Dehaze {
fn passes ( & self , scale : RenderScale ) -> Vec < DetailPass > {
let split = Split ::of ( self . patch ( scale ) ) ;
// The run stage's uniforms are the same on both axes, and so are the
// span stage's. Only the offset expression differs, which is what
// `erode_run` and `erode_span` take as an argument — each filter
// written once, so the two axes cannot drift into being different
// filters.
let run = vec! [ Uniform {
name : " run " ,
value : split . run as f32 ,
} ] ;
let span = vec! [
Uniform {
name : " span " ,
value : split . span as f32 ,
} ,
Uniform {
name : " stride " ,
value : split . run as f32 ,
} ,
Uniform {
name : " shift " ,
value : split . shift as f32 ,
} ,
] ;
vec! [
DetailPass {
output_scale : 1 ,
label : " veil-run-x " ,
// The run starts at this pixel and walks forward, so it reads
// `run - 1` beyond itself and nothing behind.
radius : split . run . saturating_sub ( 1 ) ,
storage : Vec ::new ( ) ,
uniforms : run . clone ( ) ,
wgsl : erode_run ( Axis ::X ) ,
} ,
DetailPass {
output_scale : 1 ,
label : " veil-span-x " ,
radius : split . reach ( ) ,
storage : Vec ::new ( ) ,
uniforms : span . clone ( ) ,
wgsl : erode_span ( Axis ::X ) ,
} ,
DetailPass {
output_scale : 1 ,
label : " veil-run-y " ,
radius : split . run . saturating_sub ( 1 ) ,
storage : Vec ::new ( ) ,
uniforms : run ,
wgsl : erode_run ( Axis ::Y ) ,
} ,
DetailPass {
output_scale : 1 ,
label : " veil-span-y " ,
radius : split . reach ( ) ,
storage : Vec ::new ( ) ,
uniforms : span ,
wgsl : erode_span ( Axis ::Y ) ,
} ,
DetailPass {
output_scale : 1 ,
label : " clear " ,
// Reads only the pixel it writes: the veil arrived in the
// scratch lane four passes ago.
radius : 0 ,
storage : Vec ::new ( ) ,
uniforms : vec ! [
Uniform {
name : " omega " ,
value : self . omega ( ) ,
} ,
Uniform {
name : " min_transmission " ,
value : MIN_TRANSMISSION ,
} ,
] ,
wgsl : CLEAR . to_string ( ) ,
} ,
]
}
}
/// Which way a separable half runs.
#[ derive(Clone, Copy) ]
enum Axis {
X ,
Y ,
}
impl Axis {
/// The offset expression for a scalar step `i` along this axis.
fn offset ( & self , step : & str ) -> String {
match self {
Axis ::X = > format! ( " vec2<i32>( {step} , 0) " ) ,
Axis ::Y = > format! ( " vec2<i32>(0, {step} ) " ) ,
}
}
}
/// The first stage: the minimum over a contiguous run.
///
/// Along x it reads the colour and reduces it to the dark channel; along y the
/// dark channel is already in the scratch lane, so it reads that instead.
/// Doing the channel minimum again on the second axis would be reducing a
/// scalar and would quietly discard the x erosion.
///
/// Neither stage touches `c`. The recovery needs the original colour *and* the
/// veil in the same place at the same time, and the ping-pong hands each pass
/// only what the pass before it wrote — so the veil travels in `aux` and the
/// colour rides through untouched. See [`DetailPass::wgsl`].
fn erode_run ( axis : Axis ) -> String {
let source = match axis {
Axis ::X = > " dark_channel(tap(coord, OFFSET)) " ,
Axis ::Y = > " tap_aux(coord, OFFSET) " ,
} ;
let first = source . replace ( " OFFSET " , & axis . offset ( " 0 " ) ) ;
let rest = source . replace ( " OFFSET " , & axis . offset ( " i " ) ) ;
format! (
" \
// Half of the erosion's first stage: the minimum over `run` contiguous pixels,
// walking forward from this one. The second stage chains these together, and
// the two structuring elements add up to the whole patch — which is why this
// one is not centred and does not need to be.
let n = i32(run);
var veil = {first} ;
for (var i = 1; i < n; i = i + 1) {{
veil = min(veil, {rest} );
}}
aux = veil; "
)
}
/// The second stage: the minimum over `span` points spaced `stride` apart.
///
/// Each of those points already holds the minimum over the run that starts
/// there, so this reads the whole window while touching `span` pixels of it.
/// `shift` is what centres the composite on the pixel being written; without
/// it the veil would be measured from a patch lying entirely to one side, and
/// the correction would appear to lag the picture by half a patch.
fn erode_span ( axis : Axis ) -> String {
let offset = axis . offset ( " j * s - o " ) ;
let first = axis . offset ( " -o " ) ;
format! (
" \
// The erosion's second stage. `span` taps, spaced a whole run apart, each
// standing for the run that begins at it — so the minimum over the patch costs
// `run + span` taps rather than the `run * span` pixels it covers, and it is
// the exact minimum over all of them rather than a sample of them.
let n = i32(span);
let s = i32(stride);
let o = i32(shift);
var veil = tap_aux(coord, {first} );
for (var j = 1; j < n; j = j + 1) {{
veil = min(veil, tap_aux(coord, {offset} ));
}}
aux = veil; "
)
}
/// The recovery: invert the scattering model with the transmission the erosion
/// implies.
const CLEAR : & str = " \
// The veil the four erosion passes measured: the smallest channel anywhere in
// the patch around this pixel, which the dark-channel prior reads as the
// airlight that has been composited over the scene here.
//
// Capped at one because the airlight is taken as diffuse white (see the module
// documentation) and the intermediate is unclipped: a specular highlight
// arrives at three, and three units of `veil` would drive the transmission
// negative and turn the recovery inside out. A value above one is a highlight,
// not more haze. The lower bound is already guaranteed by `dark_channel`.
let veil = min(aux, 1.0);
// `omega * veil` is the part of the veil this setting removes — negative at a
// negative amount, where the same expression composites airlight back on and
// deepens the aerial perspective instead.
let lifted = omega * veil;
// The transmission implied by that veil, floored. Where the veil is nearly
// total the surviving signal is a few percent of the airlight, and dividing it
// back up amplifies its noise by the same factor; the floor is what makes the
// worst case a trace of remaining haze rather than a picture of the noise.
// Adding haze cannot reach it — `lifted` is negative there, so the
// transmission is above one and the max never bites.
let t = max(1.0 - lifted, min_transmission);
// The scattering model, inverted: I = J·t + A·(1 − t) with A taken as one, so
// J = (I − A·(1 − t)) / t. Applied per channel rather than as a gain on
// luminance, and that is the difference from every other control in this
// stage: the veil is grey, so subtracting it *is* a chromaticity change, and
// it is the right one — a haze-veiled distance is desaturated because the
// airlight diluted it, and removing the airlight is what gives the colour
// back.
c = (c - lifted) / t; " ;
#[ cfg(test) ]
mod tests {
use super ::* ;
use crate ::detail ::compose_detail ;
use dr_types ::ColourSpace ;
fn ops ( amount : f32 ) -> Vec < Box < dyn Operation > > {
vec! [ Box ::new ( Dehaze ::with_amount ( amount ) ) ]
}
fn composed ( amount : f32 , scale : RenderScale ) -> crate ::ComposedDetail {
compose_detail ( & ops ( amount ) , scale , ColourSpace ::Srgb )
}
#[ test ]
fn dehaze_starts_neutral_and_costs_nothing ( ) {
// The rule the whole pipeline rests on. An unedited photograph must not
// pay for a slider nobody has touched — and this one is five dispatches
// when it is on, so "nothing" here is a worthwhile amount of nothing.
assert! ( ! Dehaze ::new ( ) . is_active ( ) ) ;
assert! ( composed ( 0.0 , RenderScale ::full ( ( 2000 , 1500 ) ) ) . is_empty ( ) ) ;
}
#[ test ]
fn the_patch_is_a_fraction_of_the_frame_and_not_a_count_of_source_pixels ( ) {
// TRACES: FR-DSP-1 — the decision the units of this file rest on.
//
// The patch has to contain something dark and has to stay local, and
// both are statements about how much of the *composition* it covers. So
// it must be the same proportion of the picture on a proxy as in the
// export, which `frame_fraction` gives and `source_pixels` would not:
// at one-third scale a source-pixel patch would be three times too
// narrow relative to the frame, and the export — the only render
// anybody keeps — would be the one that looked nothing like what was
// tuned.
//
// frame_fraction takes the *shorter* edge, and PATCH is 0.01:
// 300 → 0.01 × 300 = 3
// 3000 → 0.01 × 3000 = 30
// 4500 → 0.01 × 4500 = 45
for ( w , h , expected ) in [ ( 400 u32 , 300 u32 , 3 u32 ) , ( 4000 , 3000 , 30 ) , ( 6000 , 4500 , 45 ) ] {
let patch = Dehaze ::with_amount ( 50.0 ) . patch ( RenderScale ::full ( ( w , h ) ) ) ;
assert_eq! ( patch , expected , " {w}x{h} " ) ;
assert! (
( patch as f32 / w . min ( h ) as f32 - PATCH ) . abs ( ) < 0.002 ,
" the patch drifted from its declared fraction at {w}x{h} "
) ;
}
}
#[ test ]
fn a_thumbnail_still_gets_the_correction ( ) {
// Deliberately unlike texture, which contributes no pass once its
// kernel rounds to nothing. Texture's absence at that size is honest —
// a two-pixel surface structure is not in the picture. Dehaze changes
// the overall tone and colour, so a thumbnail that disagreed with the
// develop view about it would read as a bug.
let tiny = RenderScale ::full ( ( 48 , 32 ) ) ;
assert_eq! ( Dehaze ::with_amount ( 50.0 ) . patch ( tiny ) , 1 ) ;
assert_eq! ( composed ( 50.0 , tiny ) . len ( ) , 5 ) ;
}
#[ test ]
fn the_split_erosion_covers_the_whole_patch_and_costs_its_square_root ( ) {
// The identity the affordability of this operation rests on: eroding by
// a run of `d` and then by `k` points spaced `d` apart erodes by the
// whole `k·d` window, because structuring elements add. If the product
// ever falls short the patch is silently narrower than the one this
// file documents, and nothing else would say so.
//
// 30 px either side → a window of 61
// run = ceil(sqrt(61)) = 8
// span = ceil(61 / 8) = 8 → covers 64 >= 61
let split = Split ::of ( 30 ) ;
assert_eq! ( split . run , 8 ) ;
assert_eq! ( split . span , 8 ) ;
assert! ( split . run * split . span > = 61 , " the window is not covered " ) ;
assert! (
split . run + split . span < 61 ,
" the split costs more taps than the window it replaces "
) ;
// Centred to within the pixel the odd surplus leaves over: the window
// covers [-31, 32] around the pixel being written.
assert_eq! ( split . shift , 31 ) ;
assert_eq! ( split . reach ( ) , 31 ) ;
}
#[ test ]
fn a_patch_of_one_pixel_still_splits_into_something_that_runs ( ) {
// The degenerate end, which a thumbnail reaches. A window of three
// splits into two runs of two, covering four — one pixel more than
// asked for, on the far side, which is the surplus `Split::shift`
// documents rather than an error to correct with a third pass.
let split = Split ::of ( 1 ) ;
assert_eq! ( split . run , 2 ) ;
assert_eq! ( split . span , 2 ) ;
assert! ( split . run * split . span > = 3 , " the window is not covered " ) ;
assert_eq! ( split . shift , 1 ) ;
assert_eq! ( split . reach ( ) , 1 ) ;
}
#[ test ]
fn the_chain_is_four_erosions_and_a_recovery ( ) {
// The shape of the operation, asserted where it is cheap to assert.
// The erosions leave the colour alone and hand the veil forward in the
// scratch lane; only the last pass touches `c`, which is what makes an
// unsharp-mask-shaped operation expressible in a chain that hands each
// pass exactly one texture.
let composed = composed ( 60.0 , RenderScale ::full ( ( 2000 , 1500 ) ) ) ;
let labels : Vec < & str > = composed . passes . iter ( ) . map ( | p | p . label . as_str ( ) ) . collect ( ) ;
assert_eq! (
labels ,
[
" dehaze/veil-run-x " ,
" dehaze/veil-span-x " ,
" dehaze/veil-run-y " ,
" dehaze/veil-span-y " ,
" dehaze/clear " ,
]
) ;
// Only the last writes the display texture, so the output transform
// happens exactly once (FR-DEV-2).
assert! ( composed . passes [ .. 4 ] . iter ( ) . all ( | p | ! p . writes_output ) ) ;
assert! ( composed . passes [ 4 ] . writes_output ) ;
// Nothing here uses the reduced chain — see the module documentation
// for why a second operation cannot pick its own `output_scale` while
// the runner holds one reduced buffer.
assert! ( composed . passes . iter ( ) . all ( | p | p . output_scale = = 1 ) ) ;
// Every pass declares a uniform block the GPU will accept: a struct
// whose size is not a multiple of sixteen bytes is rejected outright by
// the WGSL uniform address space rules.
assert! ( composed . passes . iter ( ) . all ( | p | p . uniforms . len ( ) % 4 = = 0 ) ) ;
assert! ( composed
. passes
. iter ( )
. all ( | p | p . uniforms . iter ( ) . all ( | v | v . is_finite ( ) ) ) ) ;
}
#[ test ]
fn adding_haze_is_the_same_expression_with_the_sign_turned_round ( ) {
// Negative dehaze is the forward model rather than a second operation:
// it composites airlight back on in proportion to the veil already
// measured, so it deepens the aerial perspective a scene has instead of
// fogging it evenly — and a scene with no haze in it stays clear.
assert! ( Dehaze ::with_amount ( - 100.0 ) . is_active ( ) ) ;
assert! ( Dehaze ::with_amount ( - 40.0 ) . omega ( ) < 0.0 ) ;
let symmetric = Dehaze ::with_amount ( - 40.0 ) . omega ( ) + Dehaze ::with_amount ( 40.0 ) . omega ( ) ;
assert! ( symmetric . abs ( ) < 1e-6 , " the two directions disagree " ) ;
// And at the top of the range some veil is deliberately left behind, so
// a landscape keeps its distance rather than becoming a cut-out.
assert! ( Dehaze ::with_amount ( 100.0 ) . omega ( ) < 1.0 ) ;
}
#[ test ]
fn the_veil_is_measured_from_the_colour_once_and_then_from_the_lane ( ) {
// The one asymmetry between the two axes, and the one that would be
// invisible if it were wrong: x reduces the colour to its dark channel,
// y erodes the scalar x left behind. Taking the channel minimum again
// on the second axis would reduce a scalar and quietly discard the
// whole x erosion — a patch half the width this file documents, with
// nothing to say so.
let composed = composed ( 60.0 , RenderScale ::full ( ( 2000 , 1500 ) ) ) ;
assert! ( composed . passes [ 0 ] . source . contains ( " dark_channel(tap(coord " ) ) ;
assert! ( ! composed . passes [ 2 ] . source . contains ( " dark_channel(tap(coord " ) ) ;
// The helper is still emitted for every pass of the operation, and it
// must define the function it is named for or the shader fails to
// compile a long way from here.
assert! ( composed
. passes
. iter ( )
. all ( | p | p . source . contains ( " fn dark_channel( " ) ) ) ;
}
}