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.
398 lines
15 KiB
Rust
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");
|
|
}
|
|
}
|