Tell the shader which colour space it is encoding for
The generated shader ended with `encode_srgb` and a clamp, so every photograph leaving DarkRoom had been through sRGB's gamut whatever the settings page said. Export refused the other three spaces rather than tag clipped pixels with a gamut they did not contain — correct, and not something an encoder could fix. So the output space becomes a parameter of composition. `compose_for` emits a constant primaries matrix after the camera matrix and before the clip, and generates the transfer function to match: the sRGB curve for sRGB and Display P3, a pure 2.199 gamma for Adobe RGB, 1.8 with a linear toe for ProPhoto. The ordering the camera matrix depends on is untouched — operations still run in camera space — and sRGB emits no conversion at all, so the shader compiled on nearly every frame is byte-for-byte what it was. The numbers live in dr-types, derived from four chromaticity pairs per space rather than tabulated. That is not tidiness: the shader encodes the pixels and the ICC profile describes them, and a file whose profile disagrees with its own contents is worse than one with no profile. One derivation makes them agree by construction, and can be checked against the values the specifications publish. Profiles are generated here too — minimal v2 matrix/TRC, about 2 KB, pure Rust, no lcms to satisfy under the NDK. A JPEG carries it in APP2, a PNG in iCCP, a TIFF in tag 34675. sRGB gets one as well, because untagged does not mean sRGB, it means guess. The refusal survives in a sharper form. A `Frame` now carries the space it was rendered in, and export refuses to label it anything else. The develop session still composes for sRGB, so a P3 export from the interface fails with an accurate error instead of producing a file that lies — the frontend half is a separate change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1260,6 +1260,84 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_output_colour_space_renders_what_the_colorimetry_predicts() {
|
||||
// TRACES: FR-EXP-2
|
||||
// The shader carries constants generated from `dr_types::colour`; this
|
||||
// recomputes the same conversion on the CPU and demands the GPU agree.
|
||||
// A transposed matrix, a transfer function applied before the
|
||||
// primaries, or a clip in the wrong place all compile perfectly and
|
||||
// simply produce the wrong colour — none of which a "did it compile"
|
||||
// test would notice.
|
||||
//
|
||||
// A saturated patch, deliberately: every one of these spaces maps a
|
||||
// neutral to itself, so a grey would agree with all four.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
let source = [200u8, 90, 40];
|
||||
let img = jpeg_image(&ctx, source);
|
||||
|
||||
// The JPEG path linearises with the sRGB curve, so this is the value
|
||||
// reaching the output stage.
|
||||
let linear: Vec<f32> = source
|
||||
.iter()
|
||||
.map(|&v| dr_types::Transfer::Srgb.decode(f32::from(v) / 255.0))
|
||||
.collect();
|
||||
|
||||
for space in dr_types::ColourSpace::ALL {
|
||||
let shader = EditGraph::default_chain().compose_for(space);
|
||||
let t = pass.render(&img, &shader, 16, 16).expect("render");
|
||||
let got = read_centre(&ctx, t);
|
||||
|
||||
let m = space.from_linear_srgb();
|
||||
for channel in 0..3 {
|
||||
let converted = m[channel * 3] * linear[0]
|
||||
+ m[channel * 3 + 1] * linear[1]
|
||||
+ m[channel * 3 + 2] * linear[2];
|
||||
let want = space.transfer().encode(converted.clamp(0.0, 1.0)) * 255.0;
|
||||
let delta = (f32::from(got[channel]) - want).abs();
|
||||
// Two levels: the pipeline stores its intermediate in f16 and
|
||||
// the source itself came from an 8-bit texel, so exactness is
|
||||
// not on offer. A wrong matrix is out by tens.
|
||||
assert!(
|
||||
delta <= 2.0,
|
||||
"{space:?} channel {channel}: rendered {} against a predicted {want:.1} \
|
||||
(whole pixel {got:?})",
|
||||
got[channel]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wide_gamut_render_differs_from_an_srgb_one() {
|
||||
// The companion to the test above, and the one that would fail if the
|
||||
// output space were accepted and then ignored: predicted values that
|
||||
// happened to match sRGB's would prove nothing. A saturated red is
|
||||
// several tens of levels apart in P3.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
let img = jpeg_image(&ctx, [230, 30, 20]);
|
||||
|
||||
let srgb = {
|
||||
let shader = EditGraph::default_chain().compose_for(dr_types::ColourSpace::Srgb);
|
||||
let t = pass.render(&img, &shader, 16, 16).expect("render");
|
||||
read_centre(&ctx, t)
|
||||
};
|
||||
let p3 = {
|
||||
let shader = EditGraph::default_chain().compose_for(dr_types::ColourSpace::DisplayP3);
|
||||
let t = pass.render(&img, &shader, 16, 16).expect("render");
|
||||
read_centre(&ctx, t)
|
||||
};
|
||||
|
||||
// Less red and more green: the same colour expressed against wider
|
||||
// primaries needs smaller numbers to reach it.
|
||||
assert!(
|
||||
p3[0] < srgb[0] && p3[1] > srgb[1],
|
||||
"sRGB rendered {srgb:?} and Display P3 {p3:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_jpeg_and_sensor_data_agree_on_the_same_scene_value() {
|
||||
// The two producers must be interchangeable. A mid-grey that is
|
||||
|
||||
Reference in New Issue
Block a user