docs/ had 26 developer documents flat beside the manual, and the two audiences are very differently sized: most readers want the manual and the gesture reference, a few want the register, the designs and the measurements. The manual and gestures.md stay at the top; everything for someone changing the code moves to docs/dev/, and the two documents that name their own successors — the v0.1 milestone and the UI-refinement plan — go to docs/dev/archive/ rather than being deleted, since both are still cited. docs/README.md is the index, users first. Every reference follows: code comments, Cargo manifests, the workflows, the pre-commit hook, the bench and traceability tools (which locate the repo root by docs/dev/requirements.md now), packaging, the Docker READMEs, CLAUDE.md, CONTRIBUTING.md and the README. The matrix links one level deeper and is regenerated. Links out of the moved documents into the tree gain a level; a link checker over every Markdown file finds none broken.
743 lines
32 KiB
Rust
743 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.
|
||
//!
|
||
//! 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(")));
|
||
}
|
||
}
|