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.
This commit is contained in:
2026-09-20 13:41:17 +02:00
parent 2f47087223
commit 4576499c3b
5 changed files with 106 additions and 32 deletions
+2 -1
View File
@@ -65,7 +65,8 @@ pub use history::{Edit, Entry as HistoryEntry, History, Step};
pub use lens::{compose_warps, ComposedWarp, LensProfile, Tca, Warp}; pub use lens::{compose_warps, ComposedWarp, LensProfile, Tca, Warp};
pub use operation::{ pub use operation::{
compose, compose_with_framing, Affects, ComposedShader, Helper, Invalidation, Operation, compose, compose_with_framing, Affects, ComposedShader, Helper, Invalidation, Operation,
OutputMode, Uniform, BASE_CURVE_POINTS, BASE_CURVE_UNIFORM_OFFSET, RESERVED_UNIFORM_FIELDS, OutputMode, Uniform, BASE_CURVE_POINTS, BASE_CURVE_UNIFORM_OFFSET, CLIP_ONSET,
RESERVED_UNIFORM_FIELDS,
}; };
pub use preset::{LibraryParseError, NameError, Preset, PresetLibrary, Scope}; pub use preset::{LibraryParseError, NameError, Preset, PresetLibrary, Scope};
pub use sidecar::{Sidecar, Version}; pub use sidecar::{Sidecar, Version};
+7 -5
View File
@@ -69,11 +69,13 @@ const ROUNDS: u32 = 3;
/// Below this a channel carries no ratio worth balancing. /// Below this a channel carries no ratio worth balancing.
/// ///
/// A sample in the deep shadows, or one taken on a blown highlight where a /// A sample in the deep shadows has no white balance in it: the logarithms
/// channel has already clipped to nothing, has no white balance in it: the /// below would run away and the picker would slam a slider to its stop.
/// logarithms below would run away and the picker would slam a slider to its /// Refusing is the honest answer, and the caller reports that the point was
/// stop. Refusing is the honest answer, and the caller reports that the point /// not usable rather than moving the photograph. The other end — a blown
/// was not usable rather than moving the photograph. /// 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; const FLOOR: f32 = 1e-4;
/// TRACES: FR-DEV-3 /// TRACES: FR-DEV-3
+17 -1
View File
@@ -492,6 +492,19 @@ pub const BASE_CURVE_UNIFORM_OFFSET: usize = 16;
/// selection in [`compose_full`]. /// selection in [`compose_full`].
pub const BASE_CURVE_POINTS: usize = 5; pub const BASE_CURVE_POINTS: usize = 5;
/// Where the highlight desaturation begins: the fraction of the white level
/// above which a photosite is treated as clipped.
///
/// A photosite this close to saturation has stopped counting, so its ratio
/// to its neighbours is not a colour. The generated prologue fades a pixel
/// above this toward a neutral of the same brightness before any operation
/// runs, and the white balance probe refuses to sample one: a blown sky is
/// sensor white, which the as-shot multipliers make magenta, and a solve
/// over that slams tint to its stop. One number, so the two cannot drift
/// apart — a probe that accepted what the shader had already desaturated
/// would be balancing against a pixel the photographer cannot see.
pub const CLIP_ONSET: f32 = 0.985;
/// Where an operation's own uniforms begin in the generated block. /// Where an operation's own uniforms begin in the generated block.
/// ///
/// The base fields, then framing's. Exported because `dr-gpu` writes the /// The base fields, then framing's. Exported because `dr-gpu` writes the
@@ -1016,6 +1029,9 @@ fn compose_inner(
.to_string() .to_string()
}; };
// Formatted with Rust's `Display` so the shader reads the same threshold
// the probe checks against; see `CLIP_ONSET`.
let clip_onset = CLIP_ONSET;
let source = format!( let source = format!(
"// GENERATED — do not edit. "// GENERATED — do not edit.
// //
@@ -1086,7 +1102,7 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
// A photosite at its white level carries no colour information — every // A photosite at its white level carries no colour information — every
// channel simply stopped counting — so the balance below must not be // channel simply stopped counting — so the balance below must not be
// allowed to tint it. // allowed to tint it.
let clipped = smoothstep(0.985, 1.0, max(c.r, max(c.g, c.b))); let clipped = smoothstep({clip_onset}, 1.0, max(c.r, max(c.g, c.b)));
c = c * u.as_shot_wb.rgb; c = c * u.as_shot_wb.rgb;
+25 -25
View File
File diff suppressed because one or more lines are too long
+55
View File
@@ -4502,6 +4502,15 @@ impl DevelopSession {
if pixel[3] < 0.5 { if pixel[3] < 0.5 {
return None; return None;
} }
// 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) {
return None;
}
// The tap is the sensor's numbers with the profile filled neutral; // The tap is the sensor's numbers with the profile filled neutral;
// the operation multiplies them *after* the camera's own balance, so // the operation multiplies them *after* the camera's own balance, so
@@ -6745,6 +6754,52 @@ mod tests {
); );
} }
/// 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,
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 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);
}
/// TRACES: FR-DEV-7 | FR-DEV-5 /// TRACES: FR-DEV-7 | FR-DEV-5
/// A held comparison hands the edit straight back. /// A held comparison hands the edit straight back.
/// ///