Measure the haze from the picture, and divide it back out
Four files named dehaze as a member of the compositional detail family — `detail.rs` twice, `dr-gpu`'s detail module, `ops/README.md` and `capture_sharpen.rs` — and no such node existed. Every one of them was describing the family by listing clarity, texture and a control the photographer could not reach. Haze is the one degradation the controls already in the chain cannot remove, and the reason is spatial rather than tonal. Scattering composites an airlight over the scene in proportion to distance, so the lift is per-pixel: a black point that clears the mountains crushes the foreground, and a contrast curve that clears the mountains does the same. So the node has to estimate the transmission at every pixel, which is the dark-channel prior — the local minimum over the channels and over a patch is the airlight that has been added there — and then invert the scattering model with it. The airlight is taken as neutral and as unit, which removes the one part of the published method this stage cannot perform. Estimating it properly is a whole-frame reduction, and the detail chain has none: it hands each pass the pass before it. It is also unnecessary, because white balance is the first node in the chain and has already driven the illuminant to grey, so only the magnitude is unknown — and an unknown magnitude on the veil is a scale factor on the amount slider, which the photographer is setting by eye regardless. The patch is a fraction of the frame's shorter edge, through `RenderScale::frame_fraction`, and never a count of pixels. It has to be wide enough to contain something dark and narrow enough that what it measures is still local, and both of those are statements about how much of the composition it covers — so it must cover the same proportion of the picture on a proxy as in the export, or the file is sharpened for a patch three times narrower than the one that was tuned on screen. Affording it needs an identity a Gaussian does not have. Erosions compose by adding their structuring elements, so the minimum over a run of d followed by the minimum over k points spaced d apart is the exact minimum over the whole kd window. At the square root that is 16 taps rather than 61 at 4K, and it is the same filter rather than an approximation of one — which is the difference from the strided kernel `local_contrast` refuses, where sampling an image that is not band-limited aliases into the base and comes back as mottling. It runs first among the compositional detail nodes, at order 125: after noise reduction, because dividing by a transmission below one amplifies the noise in the veiled distance by exactly the factor it recovers the contrast by, and before clarity and texture, coarse before fine, so that their base is computed on the picture the veil has left rather than on a modelling about to be divided out. What it cannot honour is the placement dehaze most wants. It shifts colour — it subtracts a grey term and rescales, so saturation changes wherever the veil is thick — and the colour work would ideally be correcting the picture that leaves here. The detail stage runs as a group after every point operation, because a neighbourhood pass is a separate dispatch over a texture the fused pass has finished writing, so an order placing this node ahead of `vibrance` would be a lie the chain cannot tell. Interleaving would mean splitting the fused pass in half around it, at the cost of a second full-frame dispatch and intermediate for every edit in the catalogue whether it dehazes or not. The declaration records that rather than leaving it to be rediscovered. FR-DEV-18 is added to the requirements register alongside it. The tag had nowhere to point, and an orphan tag fails the traceability gate rather than quietly counting for nothing.
This commit is contained in:
@@ -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 [(400u32, 300u32, 3u32), (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(")));
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,7 @@ pub mod aberration;
|
||||
pub mod capture_sharpen;
|
||||
pub mod colour_mixer;
|
||||
pub mod curve;
|
||||
pub mod dehaze;
|
||||
pub mod distortion;
|
||||
pub mod film_sim;
|
||||
pub mod local_contrast;
|
||||
@@ -79,6 +80,7 @@ pub use aberration::Aberration;
|
||||
pub use capture_sharpen::CaptureSharpen;
|
||||
pub use colour_mixer::ColourMixer;
|
||||
pub use curve::ToneCurve;
|
||||
pub use dehaze::Dehaze;
|
||||
pub use distortion::Distortion;
|
||||
pub use film_sim::{FilmSim, FilmTables};
|
||||
// Clarity and texture are one implementation at two scales; see the module's
|
||||
|
||||
Reference in New Issue
Block a user