Files
DarkRoom/core/dr-pipeline/src/neutral.rs
T
dtourolle 4576499c3b Refuse a clipped highlight as a neutral
Sampling the overcast sky on a Canon 6D frame set tint to -100 and
temperature to -15 for a patch the canvas showed as pure white. A clipped
photosite is sensor white, not a colour: every channel stopped counting,
so what the tap hands back is the as-shot multipliers themselves, which
are strongly magenta, and the solver dutifully drove green to its stop.
The display shader already fades such a pixel to a neutral of the same
brightness before any operation runs, so the picker was balancing against
something the photographer could not see.

The probe now refuses a sample with any channel at or above the onset the
shader fades from, the way the solver already refuses black. The threshold
is one constant, CLIP_ONSET, formatted into the shader and read by the
probe, so the two cannot drift apart.
2026-09-20 13:41:17 +02:00

398 lines
15 KiB
Rust

//! TRACES: FR-DEV-3 | FR-DEV-3a
//! Turning a colour that ought to be grey into white balance parameters.
//!
//! # Why this is in the core and not in the interface
//!
//! FR-DEV-3 asks for a white balance picker: point at something neutral and
//! the correction follows. The pointing is the interface's — it owns the
//! canvas, the pixel and the modality — but the *arithmetic* is not, and the
//! reason is ARCH §4.3a rather than tidiness. How far a hundred units of
//! temperature move red against blue is declared in
//! `core/dr-pipeline/ops/white_balance.yaml`, in one expression, and an
//! interface that inverted it would be holding a second copy of a number it
//! is not allowed to know. The copy would then be wrong the first time
//! somebody adjusted the range, silently, in the direction of "the picker is
//! slightly off".
//!
//! So the interface hands over a colour and this hands back a moved graph.
//!
//! # What it is allowed to assume, which is a widget kind and not an operation
//!
//! Nothing here names white balance. It looks for the operation that asks for
//! [`WidgetKind::WhitePoint`], and everything else is that widget kind's own
//! contract — the same kind of contract [`WidgetKind::ToneCurve`] carries when
//! it says its parameters are point coordinates interleaved:
//!
//! * The **first two parameters** the presentation names are the axes. The
//! first trades red against blue, the second green against magenta.
//! * The operation publishes **exactly three uniforms: the linear per-channel
//! gains, in red, green, blue order.** That is what an eyedropper needs in
//! order to be invertible at all, and it is the honest way to say so —
//! a node whose effect on a grey is not three gains is not a node an
//! eyedropper can drive, and declaring the widget on one is the mistake
//! this refuses rather than approximates.
//!
//! Checked rather than trusted: a node that declares the widget and publishes
//! four uniforms gets no picker, and its sliders go on working.
//!
//! # Why a search rather than an inversion
//!
//! The declared expression is arbitrary — `exp2` today, a table lookup or a
//! polynomial tomorrow — and only its *monotonicity* is part of the bargain:
//! warmer is warmer all the way along, or the slider is not a slider. A
//! bisection needs exactly that and nothing more, so it stays correct across
//! every expression the declaration language can grow, where a closed-form
//! inverse would be a second definition of the node maintained here.
//!
//! It is also free. Twenty halvings of two ranges, three times over, is a few
//! hundred evaluations of two small arithmetic expressions — on a click, not
//! on a frame.
use crate::descriptor::{OpId, ParamId, ParamKind, WidgetKind};
use crate::graph::{EditGraph, OpCapability};
/// Halvings per axis.
///
/// Twenty resolves a ±100 control to about a five-thousandth of a unit, which
/// is far under the precision any of them is displayed at. There is no reason
/// to stop earlier: each step is two multiplications.
const STEPS: u32 = 20;
/// How many times the two axes are solved in turn.
///
/// One pass suffices for a node whose axes are independent, which is what
/// "trades red against blue" and "trades green against magenta" describe. The
/// repeats are for a node whose are not — a green correction that also lifts
/// red would leave the first axis a little out — and they cost nothing worth
/// counting.
const ROUNDS: u32 = 3;
/// Below this a channel carries no ratio worth balancing.
///
/// A sample in the deep shadows has no white balance in it: the logarithms
/// below would run away and the picker would slam a slider to its stop.
/// Refusing is the honest answer, and the caller reports that the point was
/// not usable rather than moving the photograph. The other end — a blown
/// highlight, where every channel has stopped counting — is refused by the
/// caller before the sample is taken, because only the caller can see the
/// sensor value; see [`crate::operation::CLIP_ONSET`].
const FLOOR: f32 = 1e-4;
/// TRACES: FR-DEV-3
/// Move the graph so that `sample` renders neutral.
///
/// `sample` is the linear triple the operation's own gains multiply — camera
/// RGB with the camera's as-shot balance on, *before* the body's base curve
/// and matrix, and with the sampling operation at its defaults. Not the
/// pixel on the screen: the matrix mixes the channels on the way there, so
/// a colour read after it does not answer to these gains, and a solve over
/// one lands somewhere no sample asked for. Returns whether the graph was
/// moved: `false` where the chain offers no white point widget, or where the
/// colour has no balance in it to correct.
///
/// **Absolute, not relative.** The values written depend on the colour and not
/// on where the sliders happened to be, so sampling the same wall twice lands
/// in the same place — and sampling a second, better neutral corrects the
/// photograph rather than correcting the correction.
pub fn neutralise(graph: &mut EditGraph, sample: [f32; 3]) -> bool {
if sample.iter().any(|c| !c.is_finite() || *c < FLOOR) {
return false;
}
let Some(axes) = sampler(graph) else {
return false;
};
// The uniform contract, checked before anything moves. A node that
// declares the widget and publishes something other than three gains
// cannot be driven from a pixel, and half-solving it would leave the
// photograph somewhere nobody asked for.
if gains(graph, axes.op).is_none() {
return false;
}
for _ in 0..ROUNDS {
solve(
graph,
axes.op,
axes.warm,
axes.warm_axis,
sample,
warm_error,
);
solve(
graph,
axes.op,
axes.green,
axes.green_axis,
sample,
green_error,
);
}
true
}
/// The operation an eyedropper writes to, and the two axes it moves.
struct Axes {
op: OpId,
/// Red against blue, with its declared travel and display precision.
warm: ParamId,
warm_axis: (f32, f32, u8),
/// Green against magenta, on the same terms.
green: ParamId,
green_axis: (f32, f32, u8),
}
/// Find the operation that asked to be driven from a pixel.
///
/// The *first* one, if a chain ever carries two. Two white points in one chain
/// is a chain that has already decided something odd, and picking the first is
/// at least the same answer every time.
fn sampler(graph: &EditGraph) -> Option<Axes> {
graph.capabilities().into_iter().find_map(|cap| {
let presentation = cap.presentation.as_ref()?;
if !presentation.widgets.contains(&WidgetKind::WhitePoint) {
return None;
}
let mut owned = presentation.params.iter().copied();
let warm = owned.next()?;
let green = owned.next()?;
Some(Axes {
op: cap.id,
warm,
warm_axis: axis(&cap, warm)?,
green,
green_axis: axis(&cap, green)?,
})
})
}
/// How far a parameter may be moved and how finely, from its own declaration.
///
/// A non-scalar axis is refused rather than coerced: a bisection over a list
/// of named alternatives is meaningless, and an operation offering one has not
/// declared what this widget kind needs.
fn axis(cap: &OpCapability, param: ParamId) -> Option<(f32, f32, u8)> {
cap.params
.iter()
.find(|p| p.id == param)
.and_then(|p| match &p.kind {
ParamKind::Scalar {
min,
max,
precision,
..
} => Some((*min, *max, *precision)),
_ => None,
})
}
/// Round to the precision the parameter is displayed at.
///
/// **The search has to stop somewhere, and this is the honest place.** Twenty
/// halvings land on something like -63.0117, which the panel would draw as
/// -63 and a photographer could never reproduce by hand — so the two would
/// disagree about what the control says, and a slider nudged one unit either
/// way would silently discard a fraction nobody could see.
///
/// It also fixes the case that matters most: sampling something that is
/// *already* neutral. Unrounded, that lands a ten-thousandth off zero, which
/// is a photograph marked modified and a step on the undo stack for a
/// correction of nothing.
fn snap(value: f32, precision: u8) -> f32 {
let scale = 10f32.powi(i32::from(precision));
(value * scale).round() / scale
}
/// The three linear gains this operation currently applies, in RGB order.
///
/// `None` unless there are exactly three, which is the widget kind's contract
/// stated as a check. See the module note for why it is a contract and not a
/// guess.
fn gains(graph: &EditGraph, op: OpId) -> Option<[f32; 3]> {
let uniforms = graph.uniforms_of(op)?;
match uniforms.as_slice() {
[r, g, b] => Some([r.value, g.value, b.value]),
_ => None,
}
}
/// How far red sits from blue in the corrected sample, in stops.
///
/// Logarithms because the correction is multiplicative and the controls are
/// symmetric: warming by n and cooling by n are exact reciprocals, so an error
/// measured as a ratio is linear in the thing being searched and a difference
/// of products is not.
fn warm_error(gains: [f32; 3], sample: [f32; 3]) -> f32 {
(gains[0] * sample[0]).ln() - (gains[2] * sample[2]).ln()
}
/// How far green sits from the red-blue midpoint, in stops.
///
/// Against the midpoint rather than against red alone, so that the two axes do
/// not fight: with red and blue already balanced the two are the same number,
/// and while they are not, green is being pulled toward where the other axis
/// is heading rather than toward one end of it.
fn green_error(gains: [f32; 3], sample: [f32; 3]) -> f32 {
let r = (gains[0] * sample[0]).ln();
let b = (gains[2] * sample[2]).ln();
(gains[1] * sample[1]).ln() - 0.5 * (r + b)
}
/// Drive one axis until `error` crosses zero, and leave it there.
///
/// The direction is read off the ends rather than assumed: which way "warmer"
/// runs is the node's business, and a picker that had guessed would move the
/// slider the wrong way on the first node that disagreed with it.
fn solve(
graph: &mut EditGraph,
op: OpId,
param: ParamId,
(min, max, precision): (f32, f32, u8),
sample: [f32; 3],
error: fn([f32; 3], [f32; 3]) -> f32,
) {
let at = |graph: &mut EditGraph, value: f32| -> f32 {
graph.set_param(op, param, value);
gains(graph, op).map_or(0.0, |g| error(g, sample))
};
let mut low = min;
let mut high = max;
let low_error = at(graph, low);
let high_error = at(graph, high);
// No crossing inside the declared travel: the correction this colour needs
// is more than the control can give. Taken as far as it goes, which is
// what a photographer would do by hand — refusing would leave a badly cast
// frame uncorrected because it could not be corrected *entirely*.
if low_error.is_sign_positive() == high_error.is_sign_positive() {
let end = if low_error.abs() <= high_error.abs() {
low
} else {
high
};
graph.set_param(op, param, snap(end, precision));
return;
}
for _ in 0..STEPS {
let middle = 0.5 * (low + high);
if at(graph, middle).is_sign_positive() == low_error.is_sign_positive() {
low = middle;
} else {
high = middle;
}
}
graph.set_param(op, param, snap(0.5 * (low + high), precision));
}
#[cfg(test)]
mod tests {
use super::*;
/// The spread of a triple, relative to its middle channel.
fn cast(rendered: [f32; 3]) -> f32 {
let high = rendered.iter().copied().fold(f32::MIN, f32::max);
let low = rendered.iter().copied().fold(f32::MAX, f32::min);
(high - low) / rendered[1].max(f32::EPSILON)
}
/// What the sampled colour becomes once the graph has been moved.
fn corrected(graph: &EditGraph, sample: [f32; 3]) -> [f32; 3] {
let axes = sampler(graph).expect("the default chain declares a white point");
let g = gains(graph, axes.op).expect("three gains");
[g[0] * sample[0], g[1] * sample[1], g[2] * sample[2]]
}
/// TRACES: FR-DEV-3
/// The whole point: a warm grey comes back grey.
#[test]
fn sampling_a_warm_grey_makes_it_grey() {
let mut graph = EditGraph::default_chain();
let sample = [0.62, 0.50, 0.40];
assert!(cast(sample) > 0.3, "the premise: this is a strong cast");
assert!(neutralise(&mut graph, sample));
assert!(
cast(corrected(&graph, sample)) < 0.02,
"the sample should render neutral: {:?}",
corrected(&graph, sample)
);
}
/// TRACES: FR-DEV-3
/// And so does a cold one, which is the other half of the same claim: the
/// search finds its own direction rather than being told one.
#[test]
fn sampling_a_cold_grey_makes_it_grey_too() {
let mut graph = EditGraph::default_chain();
let sample = [0.40, 0.50, 0.62];
assert!(neutralise(&mut graph, sample));
assert!(
cast(corrected(&graph, sample)) < 0.02,
"{:?}",
corrected(&graph, sample)
);
}
/// TRACES: FR-DEV-3
/// Sampling is absolute: the second sample corrects the photograph, not
/// the previous correction.
///
/// The failure this guards is the one every relative picker has — sample a
/// wall, decide a cloud was better, sample the cloud, and land somewhere
/// neither of them describes.
#[test]
fn a_second_sample_replaces_the_first_rather_than_compounding_it() {
let mut graph = EditGraph::default_chain();
assert!(neutralise(&mut graph, [0.62, 0.50, 0.40]));
assert!(neutralise(&mut graph, [0.44, 0.50, 0.56]));
let second = [0.44, 0.50, 0.56];
assert!(
cast(corrected(&graph, second)) < 0.02,
"{:?}",
corrected(&graph, second)
);
}
/// TRACES: FR-DEV-3
/// A sample with nothing in it is refused rather than acted on.
///
/// Black has no white balance. A picker that treated it as one would take
/// the ratio of two numbers that are both noise and slam the controls to
/// their stops, which reads as the feature being broken rather than as the
/// point having been a poor choice.
#[test]
fn a_sample_with_no_balance_in_it_moves_nothing() {
let mut graph = EditGraph::default_chain();
let before = crate::Preset::capture(&graph);
assert!(!neutralise(&mut graph, [0.0, 0.0, 0.0]));
assert!(!neutralise(&mut graph, [f32::NAN, 0.5, 0.5]));
assert_eq!(crate::Preset::capture(&graph), before);
}
/// TRACES: FR-DEV-3a
/// The picker is found through the widget kind, not through a name.
///
/// If this ever fails because the declaration moved, the right fix is in
/// the declaration: nothing here is entitled to know which node it is.
#[test]
fn the_chain_offers_exactly_one_white_point() {
let graph = EditGraph::default_chain();
let found = graph
.capabilities()
.into_iter()
.filter(|c| {
c.presentation
.as_ref()
.is_some_and(|p| p.widgets.contains(&WidgetKind::WhitePoint))
})
.count();
assert_eq!(found, 1, "one operation should ask to be driven by a pixel");
}
}