Measure the white balance probe in camera RGB, where the gains multiply

Pressing "pick" and clicking a near-neutral wall on a Canon 6D frame set
tint to -77 and turned the whole photograph green. The white balance
operation runs first in the chain, on camera RGB, before the body's base
curve and colour matrix; the probe was read off a display render after
all three, and the solve treated that sRGB triple as if the gains
multiplied it directly. On a JPEG the two spaces coincide, which is why
the existing tests passed while the picker was broken on every raw file.

The probe now reads the camera-space tap a merge stitches from, composed
under the edit's own framing so a fraction of the canvas is a fraction of
the probe, and puts the as-shot balance on itself - exactly the value the
operation's gains are about to multiply. No operations run in the tap, so
nothing has to be stripped and restored, and the display target is left
alone, so a sample that found nothing usable no longer needs a redraw.

A raw-frame test with the 6D's matrix and a typical as-shot balance
samples a warm grey and asserts the rendered pixel comes back neutral; it
fails on the previous probe.
This commit is contained in:
2026-09-20 13:41:11 +02:00
parent 764ad55ead
commit 2f47087223
6 changed files with 203 additions and 96 deletions
+11
View File
@@ -857,6 +857,17 @@ impl EditGraph {
crate::operation::compose_camera_linear(&self.warps, self.framing.baseline(), view)
}
/// TRACES: FR-DEV-3
/// The camera-space tap under this edit's own framing — crop, view,
/// rotation and all — so a fraction of what is on the canvas is a
/// fraction of what this renders. Nothing else of the edit: no
/// operation, no mask, no repair. See
/// [`crate::operation::compose_camera_probe`] for why the white balance
/// picker reads from here and not from the display.
pub fn compose_camera_probe(&self) -> ComposedShader {
crate::operation::compose_camera_probe(&self.warps, &self.framing)
}
/// TRACES: FR-DEV-19c
/// [`Self::compose_for`], with one layer's mask drawn over the picture.
///
+8 -4
View File
@@ -79,10 +79,14 @@ const FLOOR: f32 = 1e-4;
/// TRACES: FR-DEV-3
/// Move the graph so that `sample` renders neutral.
///
/// `sample` is linear RGB, as the operation's own gains multiply it — that is,
/// measured with the sampling operation at its defaults. 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.
/// `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
+24 -5
View File
@@ -431,9 +431,11 @@ pub enum OutputMode {
/// no operations, and the caller fills the reserved uniforms neutral —
/// unit white balance, identity matrix, base curve off — so what is
/// stored is the sensor's own numbers, demosaiced and undistorted. Only
/// [`compose_camera_linear`] produces it, and only
/// `AdjustPass::render_camera_linear` accepts it, so the neutral
/// uniforms cannot be forgotten by a caller that composed it by mistake.
/// [`compose_camera_probe`] produces it — for a merge through
/// [`compose_camera_linear`], and for the white balance picker under the
/// edit's own framing — and only `AdjustPass::render_camera_linear`
/// accepts it, so the neutral uniforms cannot be forgotten by a caller
/// that composed it by mistake.
///
/// Thirty-two bits rather than sixteen because the composite is written
/// back as a RAW at the sensor's scale (FR-MRG-3): a 14-bit sensor has
@@ -614,9 +616,26 @@ pub fn compose_camera_linear(
// upright too, and `view` is a fraction of the upright frame.
framing.set_baseline(baseline);
framing.set_view(view);
compose_camera_probe(warps, &framing)
}
/// TRACES: FR-DEV-3
/// The same tap under a framing the caller chose: the white balance probe.
///
/// A neutral picked off the canvas has to be measured in the space the
/// white balance gains multiply, and that is camera RGB — the operation
/// runs before the body's matrix, and a probe read after the matrix would
/// be solving the wrong equation on any body whose matrix mixes the
/// channels, which is every body. It also has to be measured at the pixel
/// the canvas is showing, which is why this takes the edit's own framing
/// where a merge passes the file's orientation and a tile.
pub fn compose_camera_probe(
warps: &[Box<dyn crate::lens::Warp>],
framing: &Framing,
) -> ComposedShader {
compose_inner(
&[],
&framing,
framing,
ColourSpace::Srgb,
&MaskStack::new(),
&crate::spot::SpotSet::new(),
@@ -668,7 +687,7 @@ fn compose_inner(
// twice and be bound to a texture of the wrong format.
//
// `forced` is the one exception, and it is not a caller flag in the
// sense above: `compose_camera_linear` is the only function that passes
// sense above: `compose_camera_probe` is the only function that passes
// it, with an empty operation list, and the mode it forces has its own
// storage format and its own render entry on the GPU side.
let output_mode = forced.unwrap_or(
+31 -31
View File
File diff suppressed because one or more lines are too long
+126 -51
View File
@@ -4067,9 +4067,9 @@ impl DevelopSession {
let space = self.display_space;
// TRACES: FR-DEV-19c
// **The one composition that may show a mask.** Every other caller of
// the graph — `render_the_file`, the thumbnail, `sample_as_shot` —
// goes through `compose_for`, which cannot ask for a reveal, so no
// exported file can carry one.
// the graph — `render_the_file`, the thumbnail — goes through
// `compose_for`, which cannot ask for a reveal, so no exported file
// can carry one; `sample_as_shot` composes no operations at all.
let shader = self.graph.compose_revealing(space, self.reveal().as_ref());
// Rasterise the masks first: the shader addresses array slices by
@@ -4442,18 +4442,28 @@ impl DevelopSession {
true
}
/// The colour at a point with every adjustment taken off, in linear RGB.
/// The colour at a point in the space the white balance gains multiply:
/// camera RGB with the camera's own balance on, linear, nothing else.
///
/// **Measured before the chain rather than off the screen**, and that is
/// the difference between a picker that converges and one that chases
/// itself. The frame on the canvas has already been through the white
/// balance being solved for, the tone curve, the contrast and whatever
/// else is on; neutralising *that* pixel would be correcting a correction,
/// and the second sample of the same wall would land somewhere else. With
/// the adjustments stripped the value read is the colour as the file has
/// it, which is the domain `neutralise` is defined over.
/// **Measured where the operation acts, not where the photographer
/// looks.** The white balance node runs first in the chain, on camera
/// RGB, before the body's base curve and its matrix; the canvas shows
/// the pixel after all three. The probe used to be read off a display
/// render with the adjustments stripped, and the solve then treated an
/// sRGB triple as if the gains multiplied it directly. On a JPEG the two
/// spaces coincide, so it worked; on a raw file from any real body the
/// matrix mixes the channels, and a slightly blue wall on a Canon 6D
/// came back tint −77 with the whole frame green. This reads the
/// camera-space tap a merge stitches from — the sensor's numbers after
/// the lens warp — and puts the as-shot balance on itself, which is
/// exactly the value the operation's gains are about to multiply.
///
/// The framing stays on, exactly as it does for [`Self::render_original`]
/// That also means nothing has to be stripped and restored: the tap
/// runs no operations at all, and the display target is untouched, so
/// a sample that found nothing usable leaves the canvas exactly as it
/// was.
///
/// The framing is the edit's own, exactly as for [`Self::render_original`]
/// and for the same reason: `x` and `y` are fractions of what is on
/// screen, and a probe rendered without the crop and the zoom would be
/// answering about a different part of the photograph.
@@ -4462,41 +4472,23 @@ impl DevelopSession {
/// averages a small neighbourhood into each of its pixels, which is what
/// every eyedropper does deliberately: a single photosite off a noisy
/// shadow is a worse answer than the patch around it, and the photographer
/// is pointing at a grey card rather than at a pixel. It is also two
/// dispatches' worth of work on a click.
///
/// This overwrites the frame the adjust pass is holding, so the caller
/// must redraw — which the callback that samples does anyway, since the
/// picture has just changed.
/// is pointing at a grey card rather than at a pixel. It is also one
/// dispatch's worth of work on a click.
fn sample_as_shot(&mut self, x: f32, y: f32) -> Option<[f32; 3]> {
/// Long edge of the probe render. See the note above on why it is
/// small rather than large.
const PROBE_EDGE: u32 = 192;
// sRGB regardless of the display: this is a measurement, not something
// anybody looks at, and decoding it needs a transfer function known
// here. Composing for a wide-gamut panel would put the reading in a
// space the arithmetic below does not undo.
let space = dr_types::ColourSpace::Srgb;
let saved = self.graph.state();
self.strip_adjustments();
let (sw, sh) = self.demosaiced.size();
let (fw, fh) = self.graph.output_size(sw, sh);
let (w, h) = fit(fw, fh, PROBE_EDGE, PROBE_EDGE);
let shader = self.graph.compose_for(space);
let probe = self
.render_with_masks(&shader, w, h, space)
.and_then(|()| self.adjust.export_pixels().map_err(|e| e.to_string()));
// Restored whatever happened, for the reason every other suspension
// here restores: leaving the graph stripped after a failed probe would
// discard the edit silently.
let debt = self.graph.set_state(&saved);
self.pay_film_debt(&debt);
let (rgba, pw, ph) = probe
let shader = self.graph.compose_camera_probe();
let rendered = self
.adjust
.render_camera_linear(&self.demosaiced, &shader, w, h)
.map(|_| ());
let (rgba, pw, ph) = rendered
.and_then(|()| self.adjust.read_camera_linear())
.inspect_err(|e| log::warn!("could not read a neutral off the frame: {e}"))
.ok()?;
@@ -4504,18 +4496,18 @@ impl DevelopSession {
let px = ((x.clamp(0.0, 1.0) * pw as f32) as usize).min(pw.saturating_sub(1));
let py = ((y.clamp(0.0, 1.0) * ph as f32) as usize).min(ph.saturating_sub(1));
let at = (py * pw + px) * 4;
let pixel = rgba.get(at..at + 3)?;
let pixel = rgba.get(at..at + 4)?;
// The tap marks a pixel the lens correction pulled in from outside
// the frame with alpha 0. There is nothing there to balance against.
if pixel[3] < 0.5 {
return None;
}
// The probe was encoded for the screen; the solve is multiplicative
// and only means anything in linear light (ARCH §5.2). Undone with
// the space's own transfer function rather than a second copy of the
// curve written out here.
let transfer = space.transfer();
Some([
transfer.decode(f32::from(pixel[0]) / 255.0),
transfer.decode(f32::from(pixel[1]) / 255.0),
transfer.decode(f32::from(pixel[2]) / 255.0),
])
// The tap is the sensor's numbers with the profile filled neutral;
// the operation multiplies them *after* the camera's own balance, so
// that goes on here and the solve sees what the gains will see.
let wb = self.demosaiced.as_shot_wb();
Some([pixel[0] * wb[0], pixel[1] * wb[1], pixel[2] * wb[2]])
}
/// TRACES: FR-PLAT-AND-5 | NFR-RES-1
@@ -6670,6 +6662,89 @@ mod tests {
);
}
/// TRACES: FR-DEV-3
/// The whole point of the picker, measured where the photographer sees
/// it: a cast grey on a *raw* frame, sampled, renders grey.
///
/// On a raw frame and not a JPEG, because that is where it was wrong. The
/// white balance gains multiply camera RGB, before the body's matrix
/// turns it into sRGB; the probe was read *after* the matrix, and the
/// solve treated the two as the same space. On a body whose matrix mixes
/// the channels as much as a Canon's does, a slightly blue wall came back
/// tint −77 and the whole frame went green. A JPEG carries an identity
/// matrix, so the same test on one passed while the picker was broken.
#[test]
fn sampling_a_cast_grey_on_a_raw_frame_renders_it_grey() {
let Some(ctx) = headless() else { return };
// A Canon EOS 6D's D65 matrix (rows summing to one, as
// `neutral_stays_neutral_through_the_colour_matrix` requires) and a
// typical as-shot balance for it.
let cam_to_srgb = [
1.9125, -1.0587, 0.1461, //
-0.2249, 1.6466, -0.4217, //
0.0099, -0.5093, 1.4994,
];
let as_shot = [1.9, 1.0, 1.7];
// What the wall should look like once the camera's own balance is on:
// a warm cast, a little over half a stop between red and blue.
let balanced = [0.30f32, 0.25, 0.20];
let sensor: Vec<u16> = (0..3)
.map(|c| (balanced[c] / as_shot[c] * 65535.0).round() as u16)
.collect();
let size = 64u32;
let raw = RawImage {
width: size,
height: size,
data: sensor.repeat((size * size) as usize),
cfa_pattern: dr_decode::CfaPattern::Rggb,
black_level: [0; 4],
white_level: 65535,
wb_coeffs: [as_shot[0], as_shot[1], as_shot[2], 0.0],
color_matrix: Some(cam_to_srgb),
base_curve: dr_decode::BaseCurve::IDENTITY,
samples_per_pixel: 3,
profile: None,
make: String::new(),
model: String::new(),
crop: dr_decode::CropRect {
x: 0,
y: 0,
width: size,
height: size,
},
};
let mut session =
DevelopSession::open(&ctx, &raw, dr_types::Orientation::NORMAL).expect("session");
let at = ((size / 2) * size + size / 2) as usize * 4;
let before = read_back(&ctx, &session.render(size, size).expect("render"));
let cast = |px: &[u8]| px.iter().max().unwrap() - px.iter().min().unwrap();
assert!(
cast(&before[at..at + 3]) > 20,
"the premise: the wall renders with a cast, {:?}",
&before[at..at + 3]
);
assert!(
session.sample_neutral(0.5, 0.5),
"a mid-grey is a usable sample"
);
let after = read_back(&ctx, &session.render(size, size).expect("render"));
let px = &after[at..at + 3];
assert!(
cast(px) <= 3,
"the sampled point should render neutral, got {px:?} with {:?}",
session
.rows()
.iter()
.filter(|r| r.value != r.default_value)
.map(|r| (r.param_label.to_string(), r.value))
.collect::<Vec<_>>()
);
}
/// TRACES: FR-DEV-7 | FR-DEV-5
/// A held comparison hands the edit straight back.
///
+3 -5
View File
@@ -3127,14 +3127,12 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
.borrow_mut()
.as_mut()
.is_some_and(|s| s.sample_neutral(x, y));
// Nothing to redraw otherwise: the probe renders to its own
// target and a sample that found nothing usable moved nothing.
if sampled {
sync_rows(&w, &rows, &session);
redraw(&w);
}
// Redrawn either way. A sample that found nothing usable still
// overwrote the frame the adjust pass was holding, and leaving the
// canvas showing a probe of the unedited image would look like the
// edit had been thrown away.
redraw(&w);
});
}