Files
DarkRoom/ui/dr-ui/src/develop/white_balance.rs
T
dtourolle f6a3f3f4e2 Read DCP camera profiles: embedded in a DNG, or a .dcp beside the app
The first half of D20. dr-decode now finds a camera profile's HueSatMap
and LookTable in the order camera-profiles.md §4 gives: the profile a
DNG embeds, then a .dcp in the profiles directory whose
UniqueCameraModel names the body, then none. A .dcp brings its own
matrices, since its tables were measured against its forward matrix.

The HueSatMap is blended for the frame's colour temperature with the
same mired weight the matrices use, once per decode, and the result
rides on RawImage as profile_tables beside color_matrix, so every path
that renders a decoded file gets the same profile without a setter to
forget. Nothing applies the tables yet.

A profile whose embed policy allows copying can be written back out as
a .dcp (rawler's TIFF writer with the RC magic patched in), which is how
the library's 6D CR2s will get the Adobe Standard their DNGs carry. The
table type lives in dr-types because decode, pipeline and GPU all need
its layout. Tests read the library's 6D DNG when it is present.
2026-10-02 22:38:06 -04:00

384 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Setting the white balance from a point on the photograph.
#[cfg(test)]
use dr_decode::RawImage;
use dr_pipeline::Edit;
use crate::labels;
use super::session::DevelopSession;
impl DevelopSession {
/// TRACES: FR-DEV-3 | FR-DEV-5
/// Set the white balance from a point on the photograph.
///
/// `x` and `y` are fractions of the *visible* image — the coordinates a
/// click on the canvas arrives in — so a photographer inspecting a
/// highlight at 4× samples the pixel they are actually looking at.
///
/// **Nothing here knows it is white balance.** The colour goes to
/// [`dr_pipeline::neutral`], which finds the operation that asked to be
/// driven by a pixel and inverts its declared response; this side supplies
/// the pixel and the undo step and nothing else. That is FR-DEV-3a's line
/// in its awkward case: a picker genuinely needs to know how far a hundred
/// units of temperature move red against blue, and that number is declared
/// in the node's own file, so the interface must not be the thing that
/// holds a second copy of it.
///
/// **One step per sample**, and no step at all for a sample that could not
/// be used — a point in the deep shadows has no balance in it to correct.
/// `Edit::Action` never coalesces, so two clicks are two decisions
/// however quickly they follow each other, which is what a photographer
/// trying a wall and then a cloud expects to be able to undo one at a
/// time.
///
/// Returns whether the photograph moved.
pub fn sample_neutral(&mut self, x: f32, y: f32) -> bool {
let Some(sample) = self.sample_as_shot(x, y) else {
return false;
};
if !dr_pipeline::neutral::neutralise(&mut self.graph, sample) {
return false;
}
self.history
.record(&self.graph, Edit::Action(labels::step::SAMPLED_NEUTRAL));
true
}
/// 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 where the operation acts, not where the photographer
/// looks.** The white balance node runs first in the chain, on camera
/// RGB, before the camera matrix and the view transform; 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.
///
/// 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.
///
/// **A patch, not a point.** The shader fetches the source at one
/// position per output pixel — nearest, or four photosites blended — so
/// a probe of the whole visible region rendered at 192px was not
/// "averaging a neighbourhood into each pixel" as its comment claimed;
/// it was one point sample of a noisy sensor, and two painted-white air
/// conditioners on the same wall answered +37 and −50. Every eyedropper
/// averages for exactly this reason: the photographer is pointing at a
/// grey card, not at a photosite. So the tap is narrowed to the
/// [`PATCH`] of the canvas around the click — a couple of percent of
/// its width, square on screen — and rendered at [`PROBE_PX`] square
/// with interpolation on, which puts a sample on every sensor pixel
/// under the patch at any ordinary zoom. Those are averaged; a sample
/// the tap marked void (outside the frame after the lens correction) or
/// clipped is left out rather than allowed to pull the mean, and if
/// fewer than half the patch survives there was nothing there to
/// balance against. One small dispatch and a 64 KB readback on a click.
pub(super) fn sample_as_shot(&mut self, x: f32, y: f32) -> Option<[f32; 3]> {
/// Width of the patch as a fraction of what is on the canvas.
const PATCH: f32 = 0.015;
/// Side of the probe render, in pixels.
const PROBE_PX: u32 = 64;
// Square on screen: the height fraction follows the aspect of the
// visible region, which is the crop's shape times the view's.
let (sw, sh) = self.demosaiced.size();
let (cw, ch) = self.graph.output_size(sw, sh);
let view = self.graph.framing().view();
let aspect = (cw as f32 * view.width) / (ch as f32 * view.height).max(f32::EPSILON);
let (pw, ph) = (PATCH, PATCH * aspect);
let patch = dr_pipeline::CropRect {
x: x.clamp(0.0, 1.0) - pw * 0.5,
y: y.clamp(0.0, 1.0) - ph * 0.5,
width: pw,
height: ph,
};
let shader = self.graph.compose_camera_probe(patch);
let rendered = self
.adjust
.render_camera_linear(&self.demosaiced, &shader, PROBE_PX, PROBE_PX)
.map(|_| ());
let (rgba, _, _) = rendered
.and_then(|()| self.adjust.read_camera_linear())
.inspect_err(|e| log::warn!("could not read a neutral off the frame: {e}"))
.ok()?;
let mut sum = [0.0f32; 3];
let mut kept = 0usize;
let mut seen = 0usize;
for pixel in rgba.chunks_exact(4) {
seen += 1;
// 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 {
continue;
}
// Nor in a clipped one. A blown sky reads as sensor white, and
// sensor white with the as-shot balance on is strongly magenta —
// a solve over it drives tint to its stop for a pixel that, on
// the canvas, the shader has already desaturated to neutral. The
// same threshold the shader fades from, so what is refused here
// is what it would have hidden there.
if pixel[..3].iter().any(|c| *c >= dr_pipeline::CLIP_ONSET) {
continue;
}
for (acc, c) in sum.iter_mut().zip(pixel) {
*acc += c;
}
kept += 1;
}
if kept == 0 || kept * 2 < seen {
return None;
}
// 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();
let n = kept as f32;
Some([sum[0] / n * wb[0], sum[1] / n * wb[1], sum[2] / n * wb[2]])
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::develop::test_support::*;
/// TRACES: FR-DEV-3 | FR-DEV-5
/// Sampling something that is already neutral corrects nothing, and says
/// so by leaving the stack alone.
///
/// The failure this guards is a picker that lands a ten-thousandth off
/// zero: the photograph would come back marked modified, an undo step
/// would appear for a correction of nothing, and the sidecar would gain a
/// temperature the photographer never chose. The solve rounds to the
/// precision the control is drawn at, which is what makes "no correction"
/// representable at all.
#[test]
fn sampling_a_grey_that_is_already_grey_leaves_the_photograph_alone() {
let Some(ctx) = headless() else { return };
let (mut session, _) = grey_session(&ctx);
let steps = session.history_rows().len();
assert!(
session.sample_neutral(0.5, 0.5),
"a flat grey frame is a usable sample"
);
assert!(
session.is_neutral(),
"there was nothing to correct, so nothing was corrected"
);
assert_eq!(
session.history_rows().len(),
steps,
"and a correction of nothing is not a step"
);
}
/// 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),
samples_per_pixel: 3,
profile: None,
profile_tables: 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-3
/// The picker reads a patch, not a photosite.
///
/// A frame whose pixels alternate warm and cool grey, averaging to a
/// neutral: a point sample lands on one or the other and swings the
/// controls hard one way, which is what two white boxes on the same wall
/// answering +37 and −50 looked like. Averaged, there is nothing to
/// correct, and the graph says so.
#[test]
fn sampling_averages_a_patch_rather_than_reading_one_photosite() {
let Some(ctx) = headless() else { return };
// Large enough that the patch — a couple of percent of the frame —
// holds many sensor pixels; on a 64px frame it would hold one, and
// the test would be asserting about interpolation instead.
let size = 1536u32;
let warm = [0.30f32, 0.25, 0.20];
let cool = [0.20f32, 0.25, 0.30];
let mut data = Vec::with_capacity((size * size * 3) as usize);
for i in 0..(size * size) as usize {
let p = if i % 2 == 0 { warm } else { cool };
data.extend(p.iter().map(|c| (c * 65535.0).round() as u16));
}
let raw = RawImage {
width: size,
height: size,
data,
cfa_pattern: dr_decode::CfaPattern::Rggb,
black_level: [0; 4],
white_level: 65535,
wb_coeffs: [1.0, 1.0, 1.0, 0.0],
color_matrix: None,
samples_per_pixel: 3,
profile: None,
profile_tables: 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");
assert!(
session.sample_neutral(0.5, 0.5),
"a mid-grey patch is usable"
);
let moved: Vec<_> = session
.rows()
.iter()
.filter(|r| r.value != r.default_value)
.map(|r| (r.param_label.to_string(), r.value))
.collect();
assert!(
moved.iter().all(|(_, v)| v.abs() <= 2.0),
"the patch averages neutral, so nothing should move far: {moved:?}"
);
}
/// TRACES: FR-DEV-3
/// A blown highlight is refused, the way black is.
///
/// Sensor white is not a colour: every channel stopped counting, so the
/// ratio between them is the as-shot multipliers and nothing about the
/// scene. Sampling the overcast sky on a Canon 6D frame drove tint to
/// -100 and temperature to -15 for a patch the canvas showed as pure
/// white, which is the picker being wrong rather than the point being a
/// poor choice. Refused, nothing moves and no step is taken.
#[test]
fn sampling_a_blown_highlight_moves_nothing() {
let Some(ctx) = headless() else { return };
let size = 16u32;
let raw = RawImage {
width: size,
height: size,
data: vec![65535; (size * size * 3) as usize],
cfa_pattern: dr_decode::CfaPattern::Rggb,
black_level: [0; 4],
white_level: 65535,
wb_coeffs: [1.9, 1.0, 1.7, 0.0],
color_matrix: None,
samples_per_pixel: 3,
profile: None,
profile_tables: 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 steps = session.history_rows().len();
assert!(
!session.sample_neutral(0.5, 0.5),
"a clipped photosite has no balance in it"
);
assert!(session.is_neutral(), "and so nothing was corrected");
assert_eq!(session.history_rows().len(), steps);
}
}