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.
211 lines
8.6 KiB
Rust
211 lines
8.6 KiB
Rust
//! The develop operations.
|
|
//!
|
|
//! # Adding one
|
|
//!
|
|
//! Write `ops/<id>.yaml` and rebuild. That is the whole procedure: the node
|
|
//! appears in the chain at its declared `order`, the develop panel grows the
|
|
//! controls its parameters describe (FR-DEV-3c), the sidecar persists them
|
|
//! because they are ordinary parameters, and its declared tests run with
|
|
//! everything else.
|
|
//!
|
|
//! There is no list to extend here, no shader to edit, and no UI change.
|
|
//! `build.rs` compiles each declaration into a module implementing
|
|
//! [`crate::operation::Operation`], and [`chain`] is generated from the
|
|
//! `order:` each node carries.
|
|
//!
|
|
//! # The two kinds of node
|
|
//!
|
|
//! **Declared** nodes are the majority: parameters, uniform expressions over
|
|
//! those parameters, and a WGSL fragment. Nothing about them is Rust.
|
|
//!
|
|
//! **Hand-written** nodes are the exceptions, and they are exceptions for a
|
|
//! reason rather than for want of migrating. The tone curve interpolates
|
|
//! between five points and its neutral is a *relationship* between them; the
|
|
//! colour mixer generates thirty-six faceted parameters from twelve computed
|
|
//! hue bands; [`capture_sharpen`] is a convolution, and the schema describes a
|
|
//! fragment handed a colour with no way back to a coordinate;
|
|
//! [`vignetting`] carries lens-profile coefficients that are not
|
|
//! parameters at all. A schema stretched to cover those would be a worse
|
|
//! language than Rust, aimed at one caller each.
|
|
//!
|
|
//! # The neighbourhood nodes
|
|
//!
|
|
//! [`capture_sharpen`] and [`noise_reduction`] read the pixels around the one
|
|
//! they write, so they run in [`crate::detail`]'s stage after the fused pass
|
|
//! rather than as fragments within it. They are ordinary
|
|
//! [`Operation`](crate::Operation)s in every other respect — descriptor,
|
|
//! parameters, sidecar, history — which is what lets the panel, the presets
|
|
//! and the undo stack carry them with no special case.
|
|
//!
|
|
//! [`noise_reduction`] shows why the declarative schema cannot express one at
|
|
//! all: a declared node's `wgsl:` is handed a colour with no way back to a
|
|
//! coordinate. A kernel decides at each render how many dispatches to emit,
|
|
//! and converts a radius stated in sensor pixels into the render pixels this
|
|
//! frame is actually being drawn at.
|
|
//!
|
|
//! Both publish the same [`crate::descriptor::OpDescriptor`], so nothing
|
|
//! downstream can tell them apart. A hand-written node still declares its
|
|
//! place in the chain in `ops/<id>.yaml` with `rust:`, so the directory
|
|
//! remains the one place the pipeline's order is written down.
|
|
//!
|
|
//! # The optical corrections
|
|
//!
|
|
//! [`distortion`] and [`aberration`] implement [`crate::lens::Warp`] rather
|
|
//! than `Operation`, because they rewrite *coordinates* before the source is
|
|
//! sampled rather than transforming a colour after it. They are not part of
|
|
//! the develop chain and do not appear in `ops/`.
|
|
//!
|
|
//! [`vignetting`] is the exception, and the reason the split is drawn at
|
|
//! coordinates rather than at "lens correction": it applies a gain to the
|
|
//! pixel already fetched, so it is an ordinary node in `ops/` like any other.
|
|
//! What it needs that a colour fragment is not otherwise given is the pixel's
|
|
//! distance from the optical axis, which the sampler publishes as `radius`
|
|
//! — corner-normalised there, because the profile coefficients are fitted
|
|
//! against a corner radius of 1 and the prologue's `p` is not.
|
|
|
|
// Hand-written nodes. Each is listed in `ops/` with `rust:`, which is what
|
|
// places it in the chain; these are the implementations that entry points at.
|
|
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;
|
|
pub mod noise_reduction;
|
|
pub mod vignetting;
|
|
|
|
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
|
|
// documentation for why that is two nodes and not one.
|
|
pub use local_contrast::{Clarity, Texture};
|
|
pub use noise_reduction::NoiseReduction;
|
|
pub use vignetting::Vignetting;
|
|
|
|
// The declared nodes, plus `helpers` and `chain`. Generated into OUT_DIR by
|
|
// `build.rs` from `ops/*.yaml` — see that file for why it does not land here
|
|
// beside the sources it looks exactly like.
|
|
include!(concat!(env!("OUT_DIR"), "/nodes.rs"));
|
|
|
|
// Re-exported so a caller writes `ops::Exposure` as it did when these were
|
|
// hand-written files, and so the chain reads the same either way.
|
|
pub use blacks_whites::BlacksWhites;
|
|
pub use brilliance::Brilliance;
|
|
pub use contrast::Contrast;
|
|
pub use exposure::Exposure;
|
|
pub use highlights_shadows::HighlightsShadows;
|
|
pub use saturation::Saturation;
|
|
pub use vibrance::Vibrance;
|
|
pub use white_balance::WhiteBalance;
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::collections::BTreeSet;
|
|
|
|
#[test]
|
|
fn the_chain_is_what_the_declarations_say_it_is() {
|
|
// The only check available on a `rust:` node: `build.rs` cannot read
|
|
// the Rust type's descriptor, so it emits the declared id and this
|
|
// asserts the type agrees. A `rust:` entry whose id drifts from its
|
|
// implementation would otherwise reorder the pipeline silently.
|
|
let built: Vec<&str> = chain().iter().map(|o| o.descriptor().id.0).collect();
|
|
assert_eq!(built, DECLARED_IDS);
|
|
}
|
|
|
|
#[test]
|
|
fn every_helper_defines_the_function_it_names() {
|
|
// A mismatch between the dedup key and the function actually emitted
|
|
// would produce either a duplicate definition or a missing one.
|
|
// `build.rs` rejects this at the declaration; this asserts the
|
|
// generated registry kept the property.
|
|
for h in helpers::ALL {
|
|
assert!(
|
|
h.source.contains(&format!("fn {}(", h.name)),
|
|
"helper {} does not define fn {}",
|
|
h.name,
|
|
h.name
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn no_two_helpers_share_a_name() {
|
|
// The drift the single-source-of-truth rule exists to prevent: same
|
|
// name, different source, and the composer silently picks one.
|
|
let mut names = BTreeSet::new();
|
|
for h in helpers::ALL {
|
|
assert!(
|
|
names.insert(h.name),
|
|
"two helpers are both called {}",
|
|
h.name
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_node_only_requests_helpers_that_exist() {
|
|
// Follows from the build-time check, but asserted end to end: a
|
|
// fragment calling a function no helper defines compiles here and
|
|
// fails in the shader, which is the expensive place to find it.
|
|
let known: BTreeSet<&str> = helpers::ALL.iter().map(|h| h.name).collect();
|
|
for op in chain() {
|
|
for h in op.helpers() {
|
|
assert!(
|
|
known.contains(h.name) || h.source.contains(&format!("fn {}(", h.name)),
|
|
"{} requests helper {}, which is neither shared nor \
|
|
defined by the node",
|
|
op.descriptor().id,
|
|
h.name
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn every_node_starts_neutral() {
|
|
// An unedited image must be the image. A node whose defaults are not
|
|
// its neutral would apply itself to every photograph on open.
|
|
for op in chain() {
|
|
assert!(
|
|
!op.is_active(),
|
|
"{} is active at its defaults",
|
|
op.descriptor().id
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn every_declared_parameter_round_trips() {
|
|
// The generated `set_param`/`param` pair is mechanical, which is
|
|
// exactly why it is worth checking: a wrong field in one arm reads
|
|
// perfectly and silently breaks the sidecar.
|
|
for mut op in chain() {
|
|
let descriptor = op.descriptor();
|
|
for p in &descriptor.params {
|
|
let crate::descriptor::ParamKind::Scalar { min, max, .. } = p.kind else {
|
|
continue;
|
|
};
|
|
// A value inside the range and away from the default, so a
|
|
// stuck field cannot pass by returning the default.
|
|
let target = (p.default + (max - p.default) * 0.5).clamp(min, max);
|
|
op.set_param(p.id, target);
|
|
assert_eq!(
|
|
op.param(p.id),
|
|
target,
|
|
"{}.{} did not round-trip",
|
|
descriptor.id,
|
|
p.id
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|