Dehaze cost 22.9 ms of a 2560x1600 frame on the reference laptop RTX 3050, and 54.1 ms at 3840x2160, with the memory clock held at 810 MHz by the power cap (graphics 1762 MHz). It ran five passes: a run and a span erosion along x, the same along y, and the recovery. At those clocks a detail pass costs what it reads and writes, not what it taps: a pass with an empty body - one render-sized rgba16float read and write - measured 4.0 ms, and each dehaze pass 4.4-4.6 ms, so the taps were about 2 ms of the 22 and the four hand-offs between passes were the rest. Each axis is now one pass that takes the minimum over the whole window directly, and the recovery rides in the y pass, which already holds the veil and the pixel's own colour. That is 36 texture reads per pixel at 2560x1600 in place of 12, nearly all of them cache hits, and two passes in place of five. The picture is the same bits. A minimum is exact in any order, and the window is the one Split always covered, the surplus pixel on the far side included (Split::first and Split::width). The veil crossing the removed hand-offs was already exactly representable in rgba16float - a minimum of channels read from rgba16float, floored at zero - so storing it between passes never rounded anything that the fused form now keeps unrounded. Measured with a scratch probe that renders the synthetic 60 MP frame from examples/frame_budget.rs, only a detail parameter moving so the fused pass is reused, 30 frames per scene after six of warm-up, five runs of each binary alternated, median of the per-run p50: scene before after dehaze 2560 fit 22.88 ms 9.06 ms dehaze 2560 1:1 23.41 ms 9.52 ms dehaze 3840 fit 54.09 ms 28.12 ms all detail 2560 fit 53.11 ms 39.97 ms (NR, sharpen, clarity, all detail 2560 1:1 67.48 ms 56.42 ms texture, dehaze) every op 2560 fit 57.59 ms 44.19 ms (with film) every op 2560 1:1 71.83 ms 57.93 ms controls without dehaze (NR, sharpen, clarity, texture): within +-2% The rgba8 output hashed identically before and after for every scene - dehaze alone, all five detail operations, every operation with film, and each other detail operation alone - at fit and 1:1, at 2560x1600, 3840x2160, 1917x1203 and 333x211: 64 of 64.
717 lines
32 KiB
Rust
717 lines
32 KiB
Rust
//! 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/dev/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.
|
||
//!
|
||
//! # Why it is now two passes and not five
|
||
//!
|
||
//! The decomposition was run as four erosion passes — run and span along x,
|
||
//! then along y — and a fifth for the recovery. On the reference laptop the
|
||
//! taps turned out not to be what a pass costs: with the memory clock held at
|
||
//! 810 MHz by the power cap, a pass that only reads the render-sized
|
||
//! `rgba16float` intermediate and writes the other one costs about 4 ms at
|
||
//! 2560 x 1600, and dehaze's five came to 22 ms, of which the taps were about
|
||
//! 2. So each axis is now one pass that takes the minimum over the whole
|
||
//! window directly — 36 texture reads per pixel at that size instead of 12,
|
||
//! nearly all of them served by the cache — and the recovery rides in the y
|
||
//! pass, which already has the veil and this pixel's colour in hand. Two
|
||
//! passes: 9.2 ms.
|
||
//!
|
||
//! It is the same picture, bit for bit. A minimum is exact in any order, so
|
||
//! the minimum over the window's pixels is one value however it is grouped,
|
||
//! and the window is the one [`Split`] always covered, surplus pixel
|
||
//! included. The veil reaching the recovery was always exactly representable
|
||
//! in the `rgba16float` lane it crossed — a minimum of channel values that
|
||
//! were themselves read from `rgba16float` — so no rounding was lost by not
|
||
//! storing it between passes.
|
||
//!
|
||
//! 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. Two full-resolution passes cost less than
|
||
//! that coupling.
|
||
//!
|
||
//! # 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. The passes no longer run the two stages apart (see "Why it is
|
||
/// now two passes"), but the window they read is still the one this split
|
||
/// covers — [`Self::first`] and [`Self::width`] — surplus pixel included,
|
||
/// because that is the window every edit made so far was tuned against.
|
||
#[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,
|
||
/// How far before the pixel being written the window starts.
|
||
///
|
||
/// 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 have needed 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 window's first offset from the pixel being written: `-shift`.
|
||
pub fn first(&self) -> i32 {
|
||
-self.shift
|
||
}
|
||
|
||
/// How many pixels the window covers, `run * span` — the patch asked for
|
||
/// and the one or two surplus pixels on the far side the split leaves.
|
||
pub fn width(&self) -> u32 {
|
||
self.run * self.span
|
||
}
|
||
|
||
/// The furthest the window reads from the pixel being written, in pixels.
|
||
///
|
||
/// Stated rather than assumed symmetric: the 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 extent(&self) -> u32 {
|
||
let far = self.width() as i32 - 1 - 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));
|
||
|
||
// Both axes erode over the same window; only the offset expression
|
||
// differs, which is what `erode` takes as an argument — one filter
|
||
// written once, so the two axes cannot drift into being different
|
||
// filters.
|
||
let window = vec![
|
||
Uniform {
|
||
name: "first",
|
||
value: split.first() as f32,
|
||
},
|
||
Uniform {
|
||
name: "width",
|
||
value: split.width() as f32,
|
||
},
|
||
];
|
||
let mut recovery = window.clone();
|
||
recovery.extend([
|
||
Uniform {
|
||
name: "omega",
|
||
value: self.omega(),
|
||
},
|
||
Uniform {
|
||
name: "min_transmission",
|
||
value: MIN_TRANSMISSION,
|
||
},
|
||
]);
|
||
|
||
vec![
|
||
DetailPass {
|
||
output_scale: 1,
|
||
label: "veil-x",
|
||
radius: split.extent(),
|
||
storage: Vec::new(),
|
||
uniforms: window,
|
||
wgsl: erode(Axis::X),
|
||
},
|
||
DetailPass {
|
||
output_scale: 1,
|
||
label: "veil-y-clear",
|
||
radius: split.extent(),
|
||
storage: Vec::new(),
|
||
uniforms: recovery,
|
||
// The erosion in a block of its own, so its locals do not
|
||
// collide with the recovery's.
|
||
wgsl: format!("{{\n{}\n}}\n\n{CLEAR}", erode(Axis::Y)),
|
||
},
|
||
]
|
||
}
|
||
}
|
||
|
||
/// 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 minimum over the window along one axis.
|
||
///
|
||
/// Along x it reads the colour and reduces it to the dark channel; along y the
|
||
/// dark channel's x minimum 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.
|
||
///
|
||
/// The x pass does not touch `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(axis: Axis) -> String {
|
||
let source = match axis {
|
||
Axis::X => "dark_channel(tap(coord, OFFSET))",
|
||
Axis::Y => "tap_aux(coord, OFFSET)",
|
||
};
|
||
let head = source.replace("OFFSET", &axis.offset("o"));
|
||
let rest = source.replace("OFFSET", &axis.offset("o + i"));
|
||
|
||
format!(
|
||
"\
|
||
// The minimum over every pixel of the window along this axis, from `first`
|
||
// for `width` pixels. A minimum is exact in any order, so this is the same
|
||
// value, bit for bit, as chaining a run and a span over the same pixels.
|
||
let o = i32(first);
|
||
let n = i32(width);
|
||
var veil = {head};
|
||
for (var i = 1; i < n; i = i + 1) {{
|
||
veil = min(veil, {rest});
|
||
}}
|
||
aux = veil;"
|
||
)
|
||
}
|
||
|
||
/// The recovery: invert the scattering model with the transmission the erosion
|
||
/// implies.
|
||
const CLEAR: &str = "\
|
||
// The veil the two erosions 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 two 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(), 2);
|
||
}
|
||
|
||
#[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.first(), split.width()), (-31, 64));
|
||
assert_eq!(split.extent(), 32);
|
||
}
|
||
|
||
#[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);
|
||
// [-1, 2]: the surplus pixel is on the far side.
|
||
assert_eq!((split.first(), split.width()), (-1, 4));
|
||
assert_eq!(split.extent(), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn the_chain_is_an_erosion_per_axis_the_second_carrying_the_recovery() {
|
||
// The shape of the operation, asserted where it is cheap to assert.
|
||
// The x erosion leaves the colour alone and hands the veil forward in
|
||
// the scratch lane; only the y pass touches `c`, which is what makes an
|
||
// unsharp-mask-shaped operation expressible in a chain that hands each
|
||
// pass exactly one texture. Two passes and not five: each extra pass
|
||
// is a render-sized read and write, which is what a pass costs (see
|
||
// "Why it is now two passes").
|
||
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-x", "dehaze/veil-y-clear"]);
|
||
|
||
// Both declare the whole window they read.
|
||
let split = Split::of(Dehaze::with_amount(60.0).patch(RenderScale::full((2000, 1500))));
|
||
assert!(composed.passes.iter().all(|p| p.radius == split.extent()));
|
||
|
||
// Only the last writes the display texture, so the output transform
|
||
// happens exactly once (FR-DEV-2).
|
||
assert!(!composed.passes[0].writes_output);
|
||
assert!(composed.passes[1].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[1].source.contains("dark_channel(tap(coord"));
|
||
assert!(composed.passes[1].source.contains("tap_aux(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(")));
|
||
}
|
||
}
|