diff --git a/core/dr-export/examples/export.rs b/core/dr-export/examples/export.rs index b2d415d..9612aa6 100644 --- a/core/dr-export/examples/export.rs +++ b/core/dr-export/examples/export.rs @@ -13,7 +13,7 @@ use std::path::PathBuf; use dr_export::{export, Frame, NameContext}; use dr_gpu::{AdjustPass, DemosaicedImage, Demosaicer, GpuContext}; use dr_pipeline::EditGraph; -use dr_types::{ExportFormat, ExportSettings, OutputSharpening, SizingMode}; +use dr_types::{ColourSpace, ExportFormat, ExportSettings, OutputSharpening, SizingMode}; fn main() { env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info,wgpu=warn")) @@ -72,17 +72,24 @@ fn main() { let (fw, fh) = graph.output_size(sw, sh); println!("source {sw}×{sh}, framed {fw}×{fh}"); + // The output space is chosen *here*, before the render, because that is + // where it takes effect: the primaries conversion and the encode are the + // last two lines of the generated shader (FR-EXP-2). Asking for it at the + // encoder would be too late — the pixels would already be clipped. + let space = ColourSpace::DisplayP3; + let mut adjust = AdjustPass::new(&ctx); - let shader = graph.compose(); + let shader = graph.compose_for(space); let t = std::time::Instant::now(); adjust.render(&source, &shader, fw, fh).expect("render"); let (pixels, w, h) = adjust.export_pixels().expect("read back"); println!( - "rendered {w}×{h} in {:.0} ms", - t.elapsed().as_secs_f32() * 1000.0 + "rendered {w}×{h} in {:.0} ms as {}", + t.elapsed().as_secs_f32() * 1000.0, + space.label() ); - let frame = Frame::new(w, h, pixels).expect("well-formed frame"); + let frame = Frame::in_space(w, h, pixels, space).expect("well-formed frame"); let stem = input .file_stem() @@ -121,6 +128,7 @@ fn main() { format, sizing, sharpening, + colour_space: space, filename_template: "{name}-{dimensions}".into(), ..Default::default() }; diff --git a/core/dr-export/src/encode.rs b/core/dr-export/src/encode.rs index c022c69..6495a33 100644 --- a/core/dr-export/src/encode.rs +++ b/core/dr-export/src/encode.rs @@ -1,4 +1,4 @@ -//! TRACES: FR-EXP-1 | FR-EXP-8 +//! TRACES: FR-EXP-1 | FR-EXP-2 | FR-EXP-8 //! The encoders. //! //! All four write RGB, not RGBA. The pipeline produces an opaque frame — no @@ -7,6 +7,18 @@ //! the same value in every pixel, and a PNG that some tools then treat as //! having meaningful transparency. //! +//! # The colour profile +//! +//! All four embed one, in the place their container puts it: a JPEG APP2 +//! segment, a PNG `iCCP` chunk, TIFF tag 34675. Encoding correctly and +//! labelling correctly are separate jobs and both are required — pixels in +//! Display P3 with no profile are read as sRGB and come out desaturated, +//! which is a worse outcome than not offering the space at all. +//! +//! sRGB gets one too, rather than relying on it being everyone's default. +//! Untagged is not the same as tagged sRGB: it means "guess", and the guess +//! differs between a browser, a phone gallery and a print shop. +//! //! # Metadata //! //! Nothing is written. `strip_location` defaults to on (FR-EXP-8) and this @@ -22,20 +34,25 @@ use dr_types::{ExportFormat, ExportSettings}; -use crate::ExportError; +use crate::{icc, ExportError}; /// Encode a resized, sharpened RGBA buffer to the requested format. +/// +/// The buffer is already encoded into `settings.colour_space` — that happened +/// in the shader, at the only point where the unclipped colour still existed. +/// All that is left here is to say so. pub fn encode( rgba: &[u8], width: u32, height: u32, settings: &ExportSettings, ) -> Result, ExportError> { + let profile = icc::profile(settings.colour_space); match settings.format { - ExportFormat::Jpeg => jpeg(rgba, width, height, settings.quality), - ExportFormat::Png => png(rgba, width, height), - ExportFormat::Tiff8 => tiff8(rgba, width, height), - ExportFormat::Tiff16 => tiff16(rgba, width, height), + ExportFormat::Jpeg => jpeg(rgba, width, height, settings.quality, &profile), + ExportFormat::Png => png(rgba, width, height, &profile), + ExportFormat::Tiff8 => tiff8(rgba, width, height, &profile), + ExportFormat::Tiff16 => tiff16(rgba, width, height, &profile), other => Err(ExportError::FormatUnsupported(other)), } } @@ -49,9 +66,20 @@ fn rgb(rgba: &[u8]) -> Vec { out } -fn jpeg(rgba: &[u8], width: u32, height: u32, quality: u8) -> Result, ExportError> { +fn jpeg( + rgba: &[u8], + width: u32, + height: u32, + quality: u8, + profile: &[u8], +) -> Result, ExportError> { let mut bytes = Vec::new(); - let encoder = jpeg_encoder::Encoder::new(&mut bytes, quality); + let mut encoder = jpeg_encoder::Encoder::new(&mut bytes, quality); + // Splits across APP2 segments itself if it has to. The profiles this crate + // generates fit in one, but the branch is the encoder's rather than ours. + encoder + .add_icc_profile(profile) + .map_err(|e| ExportError::Encode(e.to_string()))?; encoder .encode( &rgb(rgba), @@ -63,12 +91,21 @@ fn jpeg(rgba: &[u8], width: u32, height: u32, quality: u8) -> Result, Ex Ok(bytes) } -fn png(rgba: &[u8], width: u32, height: u32) -> Result, ExportError> { +fn png(rgba: &[u8], width: u32, height: u32, profile: &[u8]) -> Result, ExportError> { let mut bytes = Vec::new(); { - let mut encoder = png::Encoder::new(&mut bytes, width, height); - encoder.set_color(png::ColorType::Rgb); - encoder.set_depth(png::BitDepth::Eight); + // Built through `Info` rather than the setters, because the profile is + // the one field `png::Encoder` has no setter for. The `sRGB` chunk is + // deliberately left unset: the crate writes `iCCP` only in its + // absence, and an sRGB chunk beside a P3 profile is a contradiction a + // reader has to pick a side of. + let mut info = png::Info::with_size(width, height); + info.color_type = png::ColorType::Rgb; + info.bit_depth = png::BitDepth::Eight; + info.icc_profile = Some(std::borrow::Cow::Borrowed(profile)); + + let encoder = png::Encoder::with_info(&mut bytes, info) + .map_err(|e| ExportError::Encode(e.to_string()))?; let mut writer = encoder .write_header() .map_err(|e| ExportError::Encode(e.to_string()))?; @@ -82,18 +119,63 @@ fn png(rgba: &[u8], width: u32, height: u32) -> Result, ExportError> { Ok(bytes) } -fn tiff8(rgba: &[u8], width: u32, height: u32) -> Result, ExportError> { +/// The ICC profile as a TIFF tag value. +/// +/// A newtype only because the tag's field type is `UNDEFINED` (7) and the +/// `tiff` crate maps a plain `&[u8]` to `BYTE` (1). Both are single bytes and +/// most readers do not look, but libtiff declares `TIFFTAG_ICCPROFILE` as +/// undefined and a strict reader is entitled to agree with it. +struct IccTag<'a>(&'a [u8]); + +impl tiff::encoder::TiffValue for IccTag<'_> { + const BYTE_LEN: u8 = 1; + const FIELD_TYPE: tiff::tags::Type = tiff::tags::Type::UNDEFINED; + + fn count(&self) -> usize { + self.0.len() + } + + fn data(&self) -> std::borrow::Cow<'_, [u8]> { + std::borrow::Cow::Borrowed(self.0) + } +} + +/// Tag 34675, `InterColourProfile`. Not in the `tiff` crate's `Tag` enum. +const TAG_ICC_PROFILE: u16 = 34675; + +fn tiff8(rgba: &[u8], width: u32, height: u32, profile: &[u8]) -> Result, ExportError> { use tiff::encoder::{colortype, TiffEncoder}; let mut bytes = std::io::Cursor::new(Vec::new()); let mut encoder = TiffEncoder::new(&mut bytes).map_err(|e| ExportError::Encode(e.to_string()))?; - encoder - .write_image::(width, height, &rgb(rgba)) + let mut image = encoder + .new_image::(width, height) + .map_err(|e| ExportError::Encode(e.to_string()))?; + tag_profile(image.encoder(), profile)?; + image + .write_data(&rgb(rgba)) .map_err(|e| ExportError::Encode(e.to_string()))?; Ok(bytes.into_inner()) } +/// Add the profile tag to a directory being built. +/// +/// Shared by both TIFF widths, and separate from them because `write_image` +/// cannot be used once there is a tag to add — the directory has to be opened, +/// written into, and closed by hand. +fn tag_profile( + dir: &mut tiff::encoder::DirectoryEncoder<'_, W, K>, + profile: &[u8], +) -> Result<(), ExportError> +where + W: std::io::Write + std::io::Seek, + K: tiff::encoder::TiffKind, +{ + dir.write_tag(tiff::tags::Tag::Unknown(TAG_ICC_PROFILE), IccTag(profile)) + .map_err(|e| ExportError::Encode(e.to_string())) +} + /// 16-bit TIFF, for work continuing in another editor. /// /// **Honest about what it carries.** The adjust pass renders to an 8-bit @@ -108,7 +190,7 @@ fn tiff8(rgba: &[u8], width: u32, height: u32) -> Result, ExportError> { /// one: the composer has to be told what format to write, and export has to /// ask for the wide one (FR-EXP-9). Until then this is a container promotion, /// which is still the right thing to hand an editor that works in 16-bit. -fn tiff16(rgba: &[u8], width: u32, height: u32) -> Result, ExportError> { +fn tiff16(rgba: &[u8], width: u32, height: u32, profile: &[u8]) -> Result, ExportError> { use tiff::encoder::{colortype, TiffEncoder}; // `x * 257` rather than `x << 8`: it maps 255 to 65535 exactly, where the @@ -118,8 +200,12 @@ fn tiff16(rgba: &[u8], width: u32, height: u32) -> Result, ExportError> let mut bytes = std::io::Cursor::new(Vec::new()); let mut encoder = TiffEncoder::new(&mut bytes).map_err(|e| ExportError::Encode(e.to_string()))?; - encoder - .write_image::(width, height, &wide) + let mut image = encoder + .new_image::(width, height) + .map_err(|e| ExportError::Encode(e.to_string()))?; + tag_profile(image.encoder(), profile)?; + image + .write_data(&wide) .map_err(|e| ExportError::Encode(e.to_string()))?; Ok(bytes.into_inner()) } @@ -127,6 +213,7 @@ fn tiff16(rgba: &[u8], width: u32, height: u32) -> Result, ExportError> #[cfg(test)] mod tests { use super::*; + use dr_types::ColourSpace; #[test] fn alpha_is_dropped_before_encoding() { @@ -155,7 +242,7 @@ mod tests { 0, 0, 255, 255, // blue 10, 20, 30, 255, ]; - let bytes = png(&rgba, 2, 2).unwrap(); + let bytes = png(&rgba, 2, 2, &icc::profile(ColourSpace::Srgb)).unwrap(); let decoder = png::Decoder::new(std::io::Cursor::new(&bytes)); let mut reader = decoder.read_info().unwrap(); @@ -166,4 +253,123 @@ mod tests { assert_eq!(info.color_type, png::ColorType::Rgb); assert_eq!(&out[..12], &[255, 0, 0, 0, 255, 0, 0, 0, 255, 10, 20, 30]); } + + /// A flat frame, for the tests that care only about what surrounds the + /// pixels. + fn flat(w: u32, h: u32) -> Vec { + vec![128; (w * h * 4) as usize] + } + + #[test] + fn a_png_carries_a_profile_a_decoder_gets_back_intact() { + // Read out through the PNG decoder, so this exercises the iCCP + // chunk's deflate round-trip rather than asserting the encoder was + // called. A truncated or mis-deflated profile is one a reader + // discards silently, leaving the file to be guessed at as sRGB. + for space in ColourSpace::ALL { + let want = icc::profile(space); + let bytes = png(&flat(4, 4), 4, 4, &want).unwrap(); + + let decoder = png::Decoder::new(std::io::Cursor::new(&bytes)); + let reader = decoder.read_info().unwrap(); + let got = reader + .info() + .icc_profile + .as_ref() + .unwrap_or_else(|| panic!("{space:?} PNG carries no profile")); + assert_eq!(got.as_ref(), want.as_slice(), "{space:?}"); + } + } + + #[test] + fn a_jpeg_carries_its_profile_in_a_well_formed_app2_segment() { + // The APP2 form is exacting: the marker, a length, the string + // "ICC_PROFILE\0", then a chunk index and count before the payload. + // A reader that does not find that header ignores the segment, so + // walking it here is the only way to know the file is really tagged. + for space in ColourSpace::ALL { + let want = icc::profile(space); + let bytes = jpeg(&flat(4, 4), 4, 4, 90, &want).unwrap(); + let got = jpeg_icc(&bytes) + .unwrap_or_else(|| panic!("{space:?} JPEG has no ICC_PROFILE segment")); + assert_eq!(got, want, "{space:?}"); + } + } + + /// Walk a JPEG's marker segments and reassemble the ICC profile. + /// + /// Hand-rolled because `zune-jpeg` decodes pixels and this is about what + /// travels beside them. + fn jpeg_icc(bytes: &[u8]) -> Option> { + const TAG: &[u8] = b"ICC_PROFILE\0"; + let mut out = Vec::new(); + let mut i = 2; // past the SOI + while i + 4 <= bytes.len() { + if bytes[i] != 0xFF { + return None; + } + let marker = bytes[i + 1]; + // Start of scan: entropy-coded data follows and there are no more + // parseable segments. + if marker == 0xDA { + break; + } + let len = usize::from(u16::from_be_bytes([bytes[i + 2], bytes[i + 3]])); + let payload = &bytes[i + 4..i + 2 + len]; + if marker == 0xE2 && payload.starts_with(TAG) { + // Two bytes of chunk index and count follow the tag. + out.extend_from_slice(&payload[TAG.len() + 2..]); + } + i += 2 + len; + } + (!out.is_empty()).then_some(out) + } + + #[test] + fn both_tiff_widths_carry_the_profile_in_tag_34675() { + // Read back through the `tiff` decoder's own tag lookup. Writing the + // tag with the wrong field type or a stale offset produces a file that + // still opens — with no profile, and therefore the wrong colours. + use tiff::decoder::Decoder; + use tiff::tags::Tag; + + for space in ColourSpace::ALL { + let want = icc::profile(space); + for (label, bytes) in [ + ("8-bit", tiff8(&flat(4, 4), 4, 4, &want).unwrap()), + ("16-bit", tiff16(&flat(4, 4), 4, 4, &want).unwrap()), + ] { + let mut d = Decoder::new(std::io::Cursor::new(&bytes)).expect("decode"); + let got = d + .get_tag_u8_vec(Tag::Unknown(TAG_ICC_PROFILE)) + .unwrap_or_else(|e| panic!("{space:?} {label} TIFF: {e}")); + assert_eq!(got, want, "{space:?} {label}"); + } + } + } + + #[test] + fn a_tiff_still_decodes_to_its_pixels_with_the_extra_tag_present() { + // Adding a tag means opening the directory by hand instead of using + // `write_image`, which is the sort of change that produces a valid + // header over unreadable strips. + use tiff::decoder::{Decoder, DecodingResult}; + + let rgba: Vec = vec![ + 255, 0, 0, 255, // red + 0, 255, 0, 255, // green + 0, 0, 255, 255, // blue + 10, 20, 30, 255, + ]; + let bytes = tiff8(&rgba, 2, 2, &icc::profile(ColourSpace::Srgb)).unwrap(); + let mut d = Decoder::new(std::io::Cursor::new(&bytes)).expect("decode"); + assert_eq!(d.dimensions().expect("dimensions"), (2, 2)); + let DecodingResult::U8(pixels) = d.read_image().expect("read") else { + panic!("expected 8-bit samples"); + }; + assert_eq!( + &pixels[..12], + &[255, 0, 0, 0, 255, 0, 0, 0, 255, 10, 20, 30] + ); + } } diff --git a/core/dr-export/src/error.rs b/core/dr-export/src/error.rs index c56e10c..ed6a2dc 100644 --- a/core/dr-export/src/error.rs +++ b/core/dr-export/src/error.rs @@ -23,12 +23,23 @@ pub enum ExportError { #[error("{} export is not supported yet", .0.label())] FormatUnsupported(ExportFormat), - /// Asked for a colour space the pipeline does not render to. + /// TRACES: FR-EXP-2 + /// The frame was rendered into one colour space and asked to be labelled + /// another. /// - /// See the note in `export`: the shader clips to sRGB before this crate - /// sees a pixel, so a wider space could only be a mislabelling. - #[error("{} export needs a pipeline that renders to it", .0.label())] - ColourSpaceUnsupported(ColourSpace), + /// Not a limitation of the encoders — all four spaces embed a correct + /// profile. It is that the conversion happens in the shader, before the + /// clip to 0..1, so a frame is in exactly one space by the time it gets + /// here. The caller composes with `EditGraph::compose_for` to change which. + #[error( + "the frame was rendered in {} but a {} file was asked for", + .rendered.label(), + .requested.label() + )] + ColourSpaceMismatch { + rendered: ColourSpace, + requested: ColourSpace, + }, #[error("encoding failed: {0}")] Encode(String), diff --git a/core/dr-export/src/icc.rs b/core/dr-export/src/icc.rs new file mode 100644 index 0000000..a3fb706 --- /dev/null +++ b/core/dr-export/src/icc.rs @@ -0,0 +1,483 @@ +//! TRACES: FR-EXP-2 +//! Minimal ICC v2 matrix/TRC profiles, generated. +//! +//! # Why generated rather than shipped +//! +//! A profile is a description of what the pixels in a file mean, and the +//! pixels here were produced by [`dr_types::colour`]'s matrices. Embedding a +//! profile downloaded from elsewhere would mean two independent statements +//! about the same space, agreeing until one of them was revised. Deriving both +//! from the same primaries makes agreement structural. +//! +//! It is also the only pure-Rust route. Little-CMS is the obvious library and +//! it is C, which the whole workspace avoids so it cross-compiles under the +//! Android NDK — the same reasoning behind rustls, bundled SQLite and the +//! Lensfun port. +//! +//! # What "minimal" leaves out +//! +//! A matrix/TRC display profile and nothing else: three colorants, three tone +//! curves, a white point and the chromatic adaptation that got it there. No +//! A2B/B2A lookup tables, no gamut tag, no named colours. That is the whole of +//! what an RGB working space *is*, and it is what every reader — a browser, an +//! operating system compositor, Photoshop — takes from a profile like this +//! one. The tags omitted describe device behaviour these spaces do not have. +//! +//! Profiles come out around 2 KB, which matters more than it sounds: a JPEG +//! carries the profile in APP2 segments capped at 64 KB each, and one that fits +//! in a single segment avoids the chunked form that some older readers +//! mishandle. + +use dr_types::{ColourSpace, Transfer}; + +/// The ICC profile describing `space`, ready to embed. +/// +/// Deterministic — the same space always produces the same bytes. Two exports +/// of the same frame must be byte-identical files, which a creation timestamp +/// read from the clock would quietly break, along with any deduplication +/// downstream of it. +pub fn profile(space: ColourSpace) -> Vec { + let colorants = space.to_pcs_xyz(); + let trc = trc_curve(space.transfer()); + + // Sorted by signature, as the specification asks a tag table to be. Some + // readers binary-search it. + let mut tags: Vec<(&[u8; 4], Vec)> = vec![ + (b"bTRC", trc.clone()), + // Columns, not rows: a colorant tag is where one primary lands in XYZ. + (b"bXYZ", xyz_type(colorants[2], colorants[5], colorants[8])), + (b"cprt", text_type(COPYRIGHT)), + (b"desc", description_type(&description(space))), + (b"gTRC", trc.clone()), + (b"gXYZ", xyz_type(colorants[1], colorants[4], colorants[7])), + (b"rTRC", trc), + (b"rXYZ", xyz_type(colorants[0], colorants[3], colorants[6])), + // The PCS illuminant itself, not the space's own white. The space's + // white is recoverable from this and `chad`, and a profile that put + // its native white here would have every reader adapt it twice. + (b"wtpt", xyz_type(PCS_D50[0], PCS_D50[1], PCS_D50[2])), + ]; + + // Only where there is an adaptation to declare. ProPhoto is a D50 space + // already, and an identity `chad` is a tag saying nothing. + let adaptation = space.adaptation_to_pcs(); + if !is_identity(&adaptation) { + tags.push((b"chad", sf32_type(&adaptation))); + } + tags.sort_by_key(|(sig, _)| **sig); + + assemble(&tags) +} + +/// What a colour-management dialogue will show this profile as. +/// +/// Deliberately not the canonical names. "sRGB IEC61966-2.1" is the reference +/// profile, and this is not it — it is a profile derived from the same +/// primaries, which is a different and weaker claim. "Adobe RGB (1998)" is +/// additionally a name belonging to someone else. A distinct name also tells a +/// user opening the file where the profile came from, which is the question +/// they are asking when they look. +fn description(space: ColourSpace) -> String { + format!("DarkRoom {}", space.label()) +} + +/// The copyright tag, which ICC requires a profile to carry. +/// +/// A set of chromaticity coordinates from a published specification is not +/// something to claim rights over, and a profile nobody may redistribute would +/// make the files carrying it awkward to share — which is the whole purpose of +/// an export. +const COPYRIGHT: &str = "Generated by DarkRoom. No rights reserved."; + +/// The profile connection space illuminant, as s15Fixed16 exactly. +const PCS_D50: [f32; 3] = [0.9642, 1.0, 0.8249]; + +/// Samples in a tabulated tone curve. +/// +/// 1024 is what the reference sRGB profiles use. The curve is interpolated +/// linearly between samples, so this is far finer than the 8-bit values it +/// describes; halving it would still be adequate and would save a kilobyte +/// nobody is counting. +const TRC_SAMPLES: usize = 1024; + +/// A tone reproduction curve for the space's transfer function. +/// +/// ICC curves run *towards* the connection space — device value to linear — +/// which is the opposite direction from the shader's final encode. Getting it +/// backwards produces a file that looks washed out or crushed by exactly the +/// amount the curve bends. +fn trc_curve(transfer: Transfer) -> Vec { + // A pure power curve has an exact representation: a single u8Fixed8 + // gamma. Adobe RGB's 563/256 lands on it precisely, where a 1024-entry + // table would be an approximation of a number the format can hold. + if let Transfer::Gamma(g) = transfer { + let mut out = tag_header(b"curv"); + out.extend_from_slice(&1u32.to_be_bytes()); + out.extend_from_slice(&((g * 256.0).round() as u16).to_be_bytes()); + return out; + } + + let mut out = tag_header(b"curv"); + out.extend_from_slice(&(TRC_SAMPLES as u32).to_be_bytes()); + for i in 0..TRC_SAMPLES { + let device = i as f32 / (TRC_SAMPLES - 1) as f32; + let linear = transfer.decode(device); + out.extend_from_slice(&((linear * 65535.0).round() as u16).to_be_bytes()); + } + out +} + +/// An `XYZType` tag: one colour in the connection space. +fn xyz_type(x: f32, y: f32, z: f32) -> Vec { + let mut out = tag_header(b"XYZ "); + for v in [x, y, z] { + out.extend_from_slice(&s15_fixed16(v).to_be_bytes()); + } + out +} + +/// An `s15Fixed16ArrayType` tag, which is how `chad` is stored. +fn sf32_type(m: &[f32; 9]) -> Vec { + let mut out = tag_header(b"sf32"); + for v in m { + out.extend_from_slice(&s15_fixed16(*v).to_be_bytes()); + } + out +} + +/// A `textType` tag: ASCII with a terminating NUL. +fn text_type(s: &str) -> Vec { + let mut out = tag_header(b"text"); + out.extend_from_slice(s.as_bytes()); + out.push(0); + out +} + +/// A `textDescriptionType` tag — the v2 profile's name field. +/// +/// Baroque, and not optional: v2 has no plain `mluc`, and the ASCII string is +/// followed by empty Unicode and ScriptCode blocks that a reader will walk +/// whether or not they hold anything. The 67-byte Macintosh field is fixed +/// width by specification, so it is written out zeroed rather than omitted. +fn description_type(s: &str) -> Vec { + let ascii = s.as_bytes(); + let mut out = tag_header(b"desc"); + out.extend_from_slice(&(ascii.len() as u32 + 1).to_be_bytes()); + out.extend_from_slice(ascii); + out.push(0); + // Unicode language code, then Unicode character count: none of either. + out.extend_from_slice(&[0; 8]); + // ScriptCode code (u16), length (u8), and the fixed 67-byte field. + out.extend_from_slice(&[0; 3]); + out.extend_from_slice(&[0; 67]); + out +} + +/// Every tag element opens with its type signature and four reserved bytes. +fn tag_header(sig: &[u8; 4]) -> Vec { + let mut out = Vec::from(*sig); + out.extend_from_slice(&[0; 4]); + out +} + +/// ICC's fixed-point number: 16 integer bits, 16 fractional. +fn s15_fixed16(v: f32) -> i32 { + (f64::from(v) * 65536.0).round() as i32 +} + +fn is_identity(m: &[f32; 9]) -> bool { + const IDENTITY: [f32; 9] = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]; + // One step of s15Fixed16, the format the matrix would be stored in. Below + // that it *is* the identity — ProPhoto's own white and the PCS illuminant + // differ in the sixth decimal place, and a `chad` recording that would be + // nine copies of 1.0000 and 0.0000 dressed up as information. + const STEP: f32 = 1.0 / 65536.0; + m.iter().zip(IDENTITY).all(|(a, b)| (a - b).abs() < STEP) +} + +/// Header, tag table, and the tag data, with the size written back in. +fn assemble(tags: &[(&[u8; 4], Vec)]) -> Vec { + let mut out = header(); + + out.extend_from_slice(&(tags.len() as u32).to_be_bytes()); + let table_at = out.len(); + out.resize(table_at + tags.len() * 12, 0); + + for (i, (sig, data)) in tags.iter().enumerate() { + // Identical elements share one copy, which the specification allows + // explicitly. The three tone curves of a grey-balanced space are the + // same 2 KB table, so this is two thirds of the profile. + let offset = find(&out, data).unwrap_or_else(|| { + let at = out.len(); + out.extend_from_slice(data); + // Every element starts on a four-byte boundary. + while !out.len().is_multiple_of(4) { + out.push(0); + } + at + }); + + let entry = table_at + i * 12; + out[entry..entry + 4].copy_from_slice(*sig); + out[entry + 4..entry + 8].copy_from_slice(&(offset as u32).to_be_bytes()); + out[entry + 8..entry + 12].copy_from_slice(&(data.len() as u32).to_be_bytes()); + } + + let size = out.len() as u32; + out[0..4].copy_from_slice(&size.to_be_bytes()); + out +} + +/// Where `needle` already sits in `haystack`, if it does. +/// +/// Only ever called with tag elements, which begin on four-byte boundaries and +/// start with a type signature — so a match cannot be a coincidental overlap +/// of two other tags' bytes. +fn find(haystack: &[u8], needle: &[u8]) -> Option { + haystack + .windows(needle.len()) + .position(|w| w == needle) + .filter(|at| at.is_multiple_of(4)) +} + +/// The fixed 128-byte profile header. +fn header() -> Vec { + let mut h = Vec::with_capacity(128); + // Size, filled in once the profile is complete. + h.extend_from_slice(&[0; 4]); + // Preferred CMM: no preference. + h.extend_from_slice(&[0; 4]); + // Version 2.1.0. v2 rather than v4 because it is what every reader + // handles, and because nothing here needs a v4 tag type. + h.extend_from_slice(&[0x02, 0x10, 0x00, 0x00]); + h.extend_from_slice(b"mntr"); + h.extend_from_slice(b"RGB "); + h.extend_from_slice(b"XYZ "); + // Creation date. Fixed, for the determinism the module docs describe. + for field in [2025u16, 1, 1, 0, 0, 0] { + h.extend_from_slice(&field.to_be_bytes()); + } + h.extend_from_slice(b"acsp"); + // Primary platform, flags, manufacturer, model, attributes: unspecified. + h.extend_from_slice(&[0; 24]); + // Rendering intent: perceptual, as the reference RGB working-space + // profiles declare. For a matrix/TRC profile the field is advisory — + // there is only one transform in here to apply. + h.extend_from_slice(&[0; 4]); + for v in PCS_D50 { + h.extend_from_slice(&s15_fixed16(v).to_be_bytes()); + } + // Creator, profile ID, and the reserved tail. + h.extend_from_slice(&[0; 4]); + h.extend_from_slice(&[0; 16]); + h.extend_from_slice(&[0; 28]); + debug_assert_eq!(h.len(), 128); + h +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A tag's element data, located through the profile's own tag table — + /// so these tests read the profile the way a colour engine would rather + /// than the way it was written. + fn tag<'a>(profile: &'a [u8], want: &[u8; 4]) -> Option<&'a [u8]> { + let count = u32::from_be_bytes(profile[128..132].try_into().unwrap()) as usize; + for i in 0..count { + let at = 132 + i * 12; + if &profile[at..at + 4] == want { + let off = u32::from_be_bytes(profile[at + 4..at + 8].try_into().unwrap()) as usize; + let len = u32::from_be_bytes(profile[at + 8..at + 12].try_into().unwrap()) as usize; + return Some(&profile[off..off + len]); + } + } + None + } + + fn xyz(data: &[u8]) -> [f32; 3] { + let read = + |at: usize| i32::from_be_bytes(data[at..at + 4].try_into().unwrap()) as f32 / 65536.0; + [read(8), read(12), read(16)] + } + + #[test] + fn a_profile_declares_its_own_length() { + // The first field a reader trusts. A profile whose header says it is + // longer than the buffer is one a strict parser rejects outright and a + // lax one reads past the end of. + for space in ColourSpace::ALL { + let p = profile(space); + let declared = u32::from_be_bytes(p[0..4].try_into().unwrap()) as usize; + assert_eq!(declared, p.len(), "{space:?}"); + } + } + + #[test] + fn a_profile_carries_the_signature_that_identifies_it_as_one() { + // `acsp` at offset 36 is how every reader recognises an ICC profile. + for space in ColourSpace::ALL { + assert_eq!(&profile(space)[36..40], b"acsp", "{space:?}"); + } + } + + #[test] + fn every_tag_lies_inside_the_profile_and_on_a_boundary() { + // A tag table is offsets and lengths, and nothing checks them for us. + // An off-by-four here produces a profile that parses as far as the + // tag a reader happens to want. + for space in ColourSpace::ALL { + let p = profile(space); + let count = u32::from_be_bytes(p[128..132].try_into().unwrap()) as usize; + for i in 0..count { + let at = 132 + i * 12; + let off = u32::from_be_bytes(p[at + 4..at + 8].try_into().unwrap()) as usize; + let len = u32::from_be_bytes(p[at + 8..at + 12].try_into().unwrap()) as usize; + assert!(off.is_multiple_of(4), "{space:?} tag {i} starts at {off}"); + assert!(off + len <= p.len(), "{space:?} tag {i} runs off the end"); + } + } + } + + #[test] + fn every_profile_carries_the_tags_a_matrix_trc_profile_requires() { + // The ICC v2 required set for a display profile. A reader missing any + // one of these falls back to assuming sRGB, which is the silent + // failure this whole feature exists to prevent. + for space in ColourSpace::ALL { + let p = profile(space); + for required in [ + b"desc", b"cprt", b"wtpt", b"rXYZ", b"gXYZ", b"bXYZ", b"rTRC", b"gTRC", b"bTRC", + ] { + assert!(tag(&p, required).is_some(), "{space:?} has no {required:?}"); + } + } + } + + #[test] + fn the_colorants_are_the_ones_the_shader_encoded_with() { + // The property the file's honesty rests on. The composer converts the + // pixels with `to_pcs_xyz`'s primaries; if the profile described any + // others the file would be a precise, confident lie. + for space in ColourSpace::ALL { + let p = profile(space); + let want = space.to_pcs_xyz(); + for (i, sig) in [b"rXYZ", b"gXYZ", b"bXYZ"].into_iter().enumerate() { + let got = xyz(tag(&p, sig).expect("colorant")); + for (row, g) in got.iter().enumerate() { + let expected = want[row * 3 + i]; + assert!( + (g - expected).abs() < 1e-4, + "{space:?} {sig:?} row {row}: profile says {g}, shader used {expected}" + ); + } + } + } + } + + #[test] + fn the_white_point_is_the_connection_space_illuminant() { + // Not the space's own white. ProPhoto's is D50 anyway, but P3's is + // D65, and a profile advertising D65 as its media white would have + // every neutral adapted a second time. + for space in ColourSpace::ALL { + let got = xyz(tag(&profile(space), b"wtpt").expect("wtpt")); + for (i, want) in PCS_D50.iter().enumerate() { + assert!((got[i] - want).abs() < 1e-4, "{space:?} white {i}: {got:?}"); + } + } + } + + #[test] + fn a_tabulated_curve_reproduces_the_transfer_function_it_came_from() { + // Read back out of the profile and compared against the function the + // shader encodes with. The curve runs device-to-linear, and writing it + // the other way round would still produce a monotonic curve of the + // right length — this is what catches the direction. + for space in [ColourSpace::Srgb, ColourSpace::ProPhoto] { + let p = profile(space); + let curve = tag(&p, b"rTRC").expect("rTRC"); + let count = u32::from_be_bytes(curve[8..12].try_into().unwrap()) as usize; + assert_eq!(count, TRC_SAMPLES, "{space:?}"); + + let transfer = space.transfer(); + for i in [0, 1, count / 4, count / 2, count - 1] { + let at = 12 + i * 2; + let got = + f32::from(u16::from_be_bytes(curve[at..at + 2].try_into().unwrap())) / 65535.0; + let want = transfer.decode(i as f32 / (count - 1) as f32); + assert!( + (got - want).abs() < 1e-4, + "{space:?} sample {i}: profile {got}, transfer {want}" + ); + } + } + } + + #[test] + fn adobe_rgb_stores_its_gamma_exactly_rather_than_sampling_it() { + // 563/256 is representable in a u8Fixed8, so the curve is one number. + // A 1024-entry table would approximate a value the format can hold + // exactly, and would round-trip through other software as 2.2. + let p = profile(ColourSpace::AdobeRgb); + let curve = tag(&p, b"rTRC").expect("rTRC"); + assert_eq!(u32::from_be_bytes(curve[8..12].try_into().unwrap()), 1); + assert_eq!(u16::from_be_bytes(curve[12..14].try_into().unwrap()), 563); + } + + #[test] + fn the_three_tone_curves_share_one_copy() { + // Not a size optimisation for its own sake: it keeps the profile under + // the 64 KB a single JPEG APP2 segment holds, so the chunked form that + // older readers mishandle is never needed. + let p = profile(ColourSpace::Srgb); + let count = u32::from_be_bytes(p[128..132].try_into().unwrap()) as usize; + let offsets: Vec = ["rTRC", "gTRC", "bTRC"] + .iter() + .map(|sig| { + (0..count) + .map(|i| 132 + i * 12) + .find(|at| &p[*at..at + 4] == sig.as_bytes()) + .map(|at| u32::from_be_bytes(p[at + 4..at + 8].try_into().unwrap())) + .expect("curve present") + }) + .collect(); + assert_eq!(offsets[0], offsets[1]); + assert_eq!(offsets[1], offsets[2]); + assert!(p.len() < 8 * 1024, "{} bytes is too large", p.len()); + } + + #[test] + fn a_d65_space_declares_its_adaptation_and_a_d50_space_does_not() { + // `chad` is what lets a reader recover the space's native white from + // colorants that have already been adapted. Without it, D65 primaries + // adapted to D50 and genuine D50 primaries are the same nine numbers. + assert!(tag(&profile(ColourSpace::DisplayP3), b"chad").is_some()); + assert!( + tag(&profile(ColourSpace::ProPhoto), b"chad").is_none(), + "ProPhoto is a D50 space; an identity chad says nothing" + ); + } + + #[test] + fn the_same_space_always_produces_the_same_bytes() { + // Two exports of one frame must be identical files. A creation + // timestamp from the clock is the obvious way to lose that. + for space in ColourSpace::ALL { + assert_eq!(profile(space), profile(space), "{space:?}"); + } + } + + #[test] + fn each_space_is_described_by_its_own_name() { + // A file whose profile says "sRGB" while carrying P3 pixels is exactly + // as misleading as no profile at all, and harder to notice. + for space in ColourSpace::ALL { + let p = profile(space); + let desc = tag(&p, b"desc").expect("desc"); + let len = u32::from_be_bytes(desc[8..12].try_into().unwrap()) as usize; + let name = std::str::from_utf8(&desc[12..12 + len - 1]).expect("ascii"); + assert_eq!(name, format!("DarkRoom {}", space.label())); + } + } +} diff --git a/core/dr-export/src/lib.rs b/core/dr-export/src/lib.rs index f228562..3f9b8b2 100644 --- a/core/dr-export/src/lib.rs +++ b/core/dr-export/src/lib.rs @@ -1,4 +1,4 @@ -//! TRACES: FR-EXP-1 | FR-EXP-3 | FR-EXP-4 | FR-EXP-6 | FR-EXP-9 +//! TRACES: FR-EXP-1 | FR-EXP-2 | FR-EXP-3 | FR-EXP-4 | FR-EXP-6 | FR-EXP-9 //! Turning a rendered frame into a file's worth of bytes. //! //! # What this crate is, and is not @@ -26,6 +26,7 @@ use dr_types::{ColourSpace, ExportFormat, ExportSettings}; mod encode; mod error; +pub mod icc; mod name; mod sharpen; mod size; @@ -36,7 +37,7 @@ pub use size::target_size; /// A rendered frame, as the adjust pass produced it. /// -/// 8-bit RGBA, display-encoded sRGB — the format +/// 8-bit RGBA, display-encoded in [`Self::space`] — the format /// [`dr_gpu::AdjustPass`](../dr_gpu/struct.AdjustPass.html) writes. Alpha is /// carried but never meaningful: the pipeline writes 1.0 everywhere, and no /// operation produces transparency. @@ -46,10 +47,42 @@ pub struct Frame { pub height: u32, /// Tightly packed RGBA8, `width * height * 4` bytes. pub rgba: Vec, + /// TRACES: FR-EXP-2 + /// The space the shader encoded these pixels into. + /// + /// Travels with the pixels rather than being asserted at the point of + /// encoding, because it is a fact about them and not a preference. The + /// conversion happened in the generated shader, before the clip to 0..1, + /// and nothing downstream can undo or redo it — a frame clipped to sRGB + /// has already lost whatever a wider space would have carried. + /// + /// Making it a field is what lets [`export`] refuse to label a frame as + /// something it is not, rather than trusting a caller to have rendered + /// what it asked for. + pub space: ColourSpace, } impl Frame { + /// A frame the pipeline rendered in sRGB — what + /// [`EditGraph::compose`](../dr_pipeline/struct.EditGraph.html#method.compose) + /// produces, and so what the display path hands over. + /// + /// An export in a wider space must render its own frame with + /// `compose_for` and declare it through [`Self::in_space`]. Defaulting + /// here rather than demanding the space at every call site keeps the + /// common case honest by construction: a caller that has not thought + /// about colour is describing sRGB, and sRGB is what it rendered. pub fn new(width: u32, height: u32, rgba: Vec) -> Result { + Self::in_space(width, height, rgba, ColourSpace::Srgb) + } + + /// A frame rendered into a stated colour space. + pub fn in_space( + width: u32, + height: u32, + rgba: Vec, + space: ColourSpace, + ) -> Result { let expected = width as usize * height as usize * 4; if rgba.len() != expected { return Err(ExportError::FrameSize { @@ -64,6 +97,7 @@ impl Frame { width, height, rgba, + space, }) } } @@ -100,17 +134,22 @@ pub fn export( settings: &ExportSettings, name: String, ) -> Result { - // Refused rather than mislabelled. The pipeline's final stage encodes to - // sRGB and clamps to its gamut (see `encode_srgb` in the generated - // shader), so the pixels arriving here have already lost anything a wider - // space could have carried. Tagging them Display P3 would produce a file - // that claims a gamut it does not contain — worse than not offering it, - // because the claim survives into everything downstream. + // TRACES: FR-EXP-2 + // Refused rather than mislabelled. Every space the settings page offers + // now works, but only if the *frame* was rendered into it: the conversion + // and the clip both happen in the generated shader, so pixels that arrive + // clipped to sRGB have already lost whatever a wider space would have + // carried, and no amount of profile-writing here brings it back. // - // Honouring the other spaces is a pipeline change, not an encoder one: - // the shader has to be told what to encode to (FR-EXP-2). - if settings.colour_space != ColourSpace::Srgb { - return Err(ExportError::ColourSpaceUnsupported(settings.colour_space)); + // The caller's fix is to compose with `EditGraph::compose_for(space)` + // before rendering. Until it does, this is an accurate error where the + // alternative would be a file that claims a gamut it does not contain — + // and that claim survives into everything downstream. + if frame.space != settings.colour_space { + return Err(ExportError::ColourSpaceMismatch { + rendered: frame.space, + requested: settings.colour_space, + }); } if matches!(settings.format, ExportFormat::Avif | ExportFormat::JpegXl) { @@ -247,17 +286,41 @@ mod tests { } #[test] - fn a_colour_space_the_pipeline_cannot_produce_is_refused_not_mislabelled() { + fn a_frame_rendered_in_one_space_is_not_labelled_another() { // A file tagged Display P3 carrying sRGB-clipped pixels is a lie that - // survives into everything downstream. Better to fail loudly. + // survives into everything downstream. The frame carries the space it + // was rendered in precisely so this cannot be waved through. let mut s = settings(ExportFormat::Jpeg); s.colour_space = ColourSpace::DisplayP3; assert!(matches!( export(&frame(8, 8), &s, "a".into()), - Err(ExportError::ColourSpaceUnsupported(_)) + Err(ExportError::ColourSpaceMismatch { .. }) )); } + #[test] + fn every_colour_space_exports_when_the_frame_was_rendered_in_it() { + // The other side of the refusal above, and what FR-EXP-2 actually + // asks for: a frame the pipeline encoded into a wide space reaches a + // file, in every format that has an encoder. + for space in ColourSpace::ALL { + for format in [ + ExportFormat::Jpeg, + ExportFormat::Png, + ExportFormat::Tiff8, + ExportFormat::Tiff16, + ] { + let mut s = settings(format); + s.colour_space = space; + let mut f = frame(8, 8); + f.space = space; + let out = export(&f, &s, "a".into()) + .unwrap_or_else(|e| panic!("{space:?} as {format:?}: {e}")); + assert!(!out.bytes.is_empty()); + } + } + } + #[test] fn the_formats_without_an_encoder_say_so() { for format in [ExportFormat::Avif, ExportFormat::JpegXl] { diff --git a/core/dr-gpu/src/adjust.rs b/core/dr-gpu/src/adjust.rs index 39c3c22..9783d7c 100644 --- a/core/dr-gpu/src/adjust.rs +++ b/core/dr-gpu/src/adjust.rs @@ -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 = 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 diff --git a/core/dr-pipeline/src/graph.rs b/core/dr-pipeline/src/graph.rs index 89f0ca9..b4b68fa 100644 --- a/core/dr-pipeline/src/graph.rs +++ b/core/dr-pipeline/src/graph.rs @@ -285,9 +285,24 @@ impl EditGraph { !self.ops.iter().any(|o| o.is_active()) && !self.framing.edits_image() } - /// Generate the fused shader for the current state. + /// Generate the fused shader for the current state, encoded to sRGB. + /// + /// What the display path wants. An export that has been asked for a wider + /// space wants [`Self::compose_for`] instead, and must say so: the space + /// is baked into the shader, so a frame rendered by this one is sRGB and + /// nothing downstream can make it anything else. pub fn compose(&self) -> ComposedShader { - compose_with_framing(&self.ops, &self.framing) + self.compose_for(dr_types::ColourSpace::Srgb) + } + + /// TRACES: FR-EXP-2 + /// Generate the fused shader, encoded to a chosen output space. + /// + /// Not stored on the graph, because it is not part of the edit: the same + /// graph renders to the screen and to a file in the same breath, and the + /// two want different answers. + pub fn compose_for(&self, output: dr_types::ColourSpace) -> ComposedShader { + compose_with_framing(&self.ops, &self.framing, output) } } diff --git a/core/dr-pipeline/src/operation.rs b/core/dr-pipeline/src/operation.rs index b2ee778..9ec1711 100644 --- a/core/dr-pipeline/src/operation.rs +++ b/core/dr-pipeline/src/operation.rs @@ -22,6 +22,8 @@ use std::fmt::Write as _; +use dr_types::{ColourSpace, Transfer}; + use crate::descriptor::{OpDescriptor, ParamId, Presentation}; use crate::framing::{Framing, FRAMING_UNIFORM_FIELDS}; @@ -152,23 +154,41 @@ const BASE_UNIFORM_FIELDS: usize = 16; /// operation's uniforms the moment either block changes size. pub const RESERVED_UNIFORM_FIELDS: usize = BASE_UNIFORM_FIELDS + FRAMING_UNIFORM_FIELDS; -/// Compose enabled operations into a single compute shader. +/// Compose enabled operations into a single compute shader, for the display. /// /// Inactive operations are skipped entirely — they contribute no code, no /// uniforms, and nothing to the structure hash. /// -/// Equivalent to [`compose_with_framing`] with neutral framing. +/// Equivalent to [`compose_with_framing`] with neutral framing and an sRGB +/// output. pub fn compose(ops: &[Box]) -> ComposedShader { - compose_with_framing(ops, &Framing::new()) + compose_with_framing(ops, &Framing::new(), ColourSpace::Srgb) } +/// TRACES: FR-EXP-2 | FR-DSP-6 /// Compose operations and framing into a single compute shader. /// /// Framing generates the shader's **prologue** — the map from an output pixel /// back to a source position — where [`compose`] would emit a fixed identity /// scale. The fused-dispatch property is unaffected: a cropped, straightened /// edit with three adjustments is still one dispatch, one read, one write. -pub fn compose_with_framing(ops: &[Box], framing: &Framing) -> ComposedShader { +/// +/// # The output space is a parameter, not a constant +/// +/// `output` decides the primaries and transfer function the last two lines of +/// the shader encode into. It is passed per composition rather than held +/// anywhere because it is a property of *this render*: the same edit goes to +/// the screen in the display's space and to a file in whatever the export asks +/// for, and neither is more authoritative than the other. +/// +/// It also enters the structure hash, so the two do not collide in the +/// pipeline cache — a screen render and a Display P3 export are different +/// shaders, however identical their sliders. +pub fn compose_with_framing( + ops: &[Box], + framing: &Framing, + output: ColourSpace, +) -> ComposedShader { let active: Vec<&dyn Operation> = ops .iter() .map(|o| o.as_ref()) @@ -271,6 +291,9 @@ pub fn compose_with_framing(ops: &[Box], framing: &Framing) -> Co "" }; + let to_output = primaries_conversion(output); + let encode_output = encode_output_fn(output); + let source = format!( "// GENERATED — do not edit. // @@ -286,17 +309,8 @@ struct Params {{ @group(0) @binding(1) var u: Params; @group(0) @binding(2) var output: texture_storage_2d; -{sampler_helper}{helper_src}// Linear sRGB to the display transfer function. -// -// The one place quantisation happens: everything above runs in linear f16, -// and this is the final encode (ARCH §5.2). -fn encode_srgb(c: vec3) -> vec3 {{ - let lo = c * 12.92; - let hi = 1.055 * pow(max(c, vec3(0.0031308)), vec3(1.0 / 2.4)) - 0.055; - return select(hi, lo, c <= vec3(0.0031308)); -}} - -// The inverse, for sources that arrive already display-encoded. +{sampler_helper}{helper_src}{encode_output} +// Display-encoded sRGB back to linear, for sources that arrive that way. // // A JPEG is uploaded with its bytes untouched, so its values are gamma-encoded // where the demosaicer's are linear. Every operation below assumes linear @@ -345,10 +359,12 @@ fn main(@builtin(global_invocation_id) gid: vec3) {{ dot(u.cam_to_srgb_1.rgb, c), dot(u.cam_to_srgb_2.rgb, c), ); - - // Clip to the display gamut and encode. +{to_output} + // Clip to the output gamut and encode. The clip is last for the reason the + // matrix above is: a colour outside sRGB is still inside a wider space, and + // clipping before the conversion would throw it away for no one's benefit. c = clamp(c, vec3(0.0), vec3(1.0)); - textureStore(output, vec2(gid.xy), vec4(encode_srgb(c), 1.0)); + textureStore(output, vec2(gid.xy), vec4(encode_output(c), 1.0)); }} ", active.len() @@ -357,7 +373,15 @@ fn main(@builtin(global_invocation_id) gid: vec3) {{ // Framing enters the hash by structure only — which branches its prologue // generated, never how far a slider moved. Dragging the crop handles must // reuse the compiled pipeline and re-upload uniforms. - let structure_hash = mix(hash_structure(&active), framing.structure_key()); + // + // The output space enters it too, and must: it changes the source, so two + // spaces sharing a hash would have the second silently rendered with the + // first's shader — a Display P3 export that came out sRGB and said + // otherwise. + let structure_hash = mix( + mix(hash_structure(&active), framing.structure_key()), + output as u64, + ); ComposedShader { source, @@ -366,6 +390,87 @@ fn main(@builtin(global_invocation_id) gid: vec3) {{ } } +/// The WGSL converting linear sRGB into the output space's primaries. +/// +/// A constant matrix rather than a uniform: the space is chosen when the +/// shader is composed, so the numbers are known at generation time and the +/// driver can fold them into the surrounding arithmetic. +/// +/// Empty for sRGB, which is the space the pipeline already works in — the +/// camera matrix converts into it, which is what `cam_to_srgb` is named for. +/// Emitting an identity there would put nine constants and three dot products +/// into the display path's shader, the one compiled most often, to compute the +/// value it already had. The identity is detected rather than special-cased by +/// name, so a space that happens to share sRGB's primaries would be spared +/// too. +fn primaries_conversion(output: ColourSpace) -> String { + let m = output.from_linear_srgb(); + const IDENTITY: [f32; 9] = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]; + // A tolerance rather than equality: the matrix is an inverse multiplied by + // a product, so sRGB's own comes back a few ULP off the identity. A + // millionth of a channel is four decimal places below an 8-bit step. + if m.iter().zip(IDENTITY).all(|(a, b)| (a - b).abs() < 1e-6) { + return String::new(); + } + + let mut out = format!( + "\n // Linear sRGB -> linear {}. The last colour transform before the\n\ + \x20 // encode, and the reason a colour sRGB could not hold survives\n\ + \x20 // this far: it is still inside this gamut.\n\ + \x20 c = vec3(\n", + output.label() + ); + // Entries below the printed precision are zero as far as the shader is + // concerned, and a shared primary produces one every time. Snapping them + // avoids emitting `-0.000000`, which reads as a sign error to whoever is + // debugging a shader at the time. + let show = |v: f32| if v.abs() < 5e-7 { 0.0 } else { v }; + for row in 0..3 { + let _ = writeln!( + out, + " dot(vec3({:.6}, {:.6}, {:.6}), c),", + show(m[row * 3]), + show(m[row * 3 + 1]), + show(m[row * 3 + 2]) + ); + } + out.push_str(" );\n"); + out +} + +/// The WGSL of the output space's transfer function. +/// +/// Named `encode_output` whatever the space, so the call site at the end of +/// `main` does not have to know which one it got. +fn encode_output_fn(output: ColourSpace) -> String { + let body = match output.transfer() { + Transfer::Srgb => " let lo = c * 12.92; + let hi = 1.055 * pow(max(c, vec3(0.0031308)), vec3(1.0 / 2.4)) - 0.055; + return select(hi, lo, c <= vec3(0.0031308));" + .to_string(), + // No linear segment at all, so no `select`: Adobe RGB (1998) is a + // pure power curve, and inventing a toe for it would be a different + // space wearing its name. + Transfer::Gamma(g) => format!(" return pow(c, vec3(1.0 / {g:.8}));"), + Transfer::Prophoto => " let lo = c * 16.0; + let hi = pow(max(c, vec3(0.001953125)), vec3(1.0 / 1.8)); + return select(hi, lo, c < vec3(0.001953125));" + .to_string(), + }; + + format!( + "// Linear {} to its transfer function. +// +// The one place quantisation happens: everything above runs in linear f16, +// and this is the final encode (ARCH §5.2). +fn encode_output(c: vec3) -> vec3 {{ +{body} +}} +", + output.label() + ) +} + /// The WGSL turning the framed source position `p` into the colour `c`. /// /// Split out because it is the join between the coordinate stage and the @@ -766,6 +871,90 @@ mod tests { assert!(op < matrix, "the camera matrix must come after operations"); } + /// Compose with neutral framing into a chosen output space. + fn compose_to(ops: &[Box], output: ColourSpace) -> ComposedShader { + compose_with_framing(ops, &Framing::new(), output) + } + + #[test] + fn an_srgb_render_is_byte_for_byte_what_it_was_before_output_spaces_existed() { + // The display path is the shader compiled on nearly every frame, and + // it must not pick up an identity matrix multiply for the sake of + // generality. Asserted against the source rather than against timing, + // which would not fail reliably. + let ops = vec![fake(&DESC_A, 1.0, false)]; + let srgb = compose_to(&ops, ColourSpace::Srgb).source; + assert!( + !srgb.contains("Linear sRGB -> linear sRGB"), + "sRGB in, sRGB out must emit no conversion:\n{srgb}" + ); + assert_eq!(srgb, compose(&ops).source); + } + + #[test] + fn a_wide_output_space_converts_after_the_camera_matrix_and_before_the_clip() { + // The whole point of the ordering. The camera matrix lands the colour + // in linear sRGB, the primaries conversion carries it into the wider + // space, and only then is it clipped — clipping first would discard + // exactly the colours the wider space was chosen to keep. + let source = compose_to(&[fake(&DESC_A, 1.0, false)], ColourSpace::DisplayP3).source; + let camera = source.find("u.cam_to_srgb_0").expect("camera matrix"); + let convert = source + .find("Linear sRGB -> linear Display P3") + .expect("primaries conversion"); + let clip = source.find("c = clamp(c,").expect("clip"); + assert!(camera < convert, "the camera matrix must come first"); + assert!(convert < clip, "the clip must come after the conversion"); + } + + #[test] + fn the_generated_matrix_is_the_one_the_profile_writer_will_use() { + // The shader encodes the pixels and `dr-export` describes them, from + // the same table in `dr-types`. If the composer ever grew its own copy + // of these numbers the file would be labelled with primaries it does + // not contain, which is the failure the whole feature exists to avoid. + let m = ColourSpace::DisplayP3.from_linear_srgb(); + let first_row = format!("dot(vec3({:.6}, {:.6}, {:.6}), c)", m[0], m[1], m[2]); + let source = compose_to(&[], ColourSpace::DisplayP3).source; + assert!( + source.contains(&first_row), + "expected {first_row} in:\n{source}" + ); + } + + #[test] + fn every_output_space_encodes_with_its_own_transfer_function() { + // Adobe RGB's pure 2.199 gamma and ProPhoto's 1.8-with-a-toe are not + // the sRGB curve, and a file encoded with the wrong one is wrong in a + // way no amount of correct primaries repairs. + let marks = [ + (ColourSpace::Srgb, "1.0 / 2.4"), + (ColourSpace::DisplayP3, "1.0 / 2.4"), + (ColourSpace::AdobeRgb, "1.0 / 2.19921875"), + (ColourSpace::ProPhoto, "1.0 / 1.8"), + ]; + for (space, mark) in marks { + let source = compose_to(&[], space).source; + assert!( + source.contains(mark), + "{space:?} should encode with {mark}:\n{source}" + ); + } + } + + #[test] + fn the_output_space_changes_the_structure_hash() { + // The pipeline cache is keyed on this hash. Two spaces sharing one + // would have the second rendered with the first's compiled shader — + // an export that came out sRGB and claimed to be Display P3. + let mut seen: Vec = Vec::new(); + for space in ColourSpace::ALL { + let h = compose_to(&[fake(&DESC_A, 1.0, false)], space).structure_hash; + assert!(!seen.contains(&h), "{space:?} collides with another space"); + seen.push(h); + } + } + #[test] fn generated_source_carries_a_do_not_edit_banner() { // Someone will eventually find this in a debugger and try to fix it diff --git a/core/dr-types/src/colour.rs b/core/dr-types/src/colour.rs new file mode 100644 index 0000000..a38639c --- /dev/null +++ b/core/dr-types/src/colour.rs @@ -0,0 +1,545 @@ +//! TRACES: FR-EXP-2 | FR-DSP-6 +//! The numbers behind an output colour space. +//! +//! # Why this lives in the types crate +//! +//! Two crates need these numbers and they must never disagree. `dr-pipeline` +//! bakes the primaries into the generated shader, so the pixels are *encoded* +//! into the space; `dr-export` writes the same primaries into an ICC profile, +//! so the file is *described* as being in it. A drift between the two produces +//! a file whose profile lies about its own contents — precisely the failure the +//! export path refused to risk before any of this existed. Neither crate +//! depends on the other, so one shared home is the only way to make that +//! disagreement impossible rather than merely unlikely. +//! +//! # Derived, not tabulated +//! +//! Everything here comes from four chromaticity pairs per space. A table of +//! nine pre-computed matrix entries is a set of numbers nobody can check, and a +//! transposed row in one looks exactly like a correct matrix. A derivation can +//! be tested against the values the specifications publish, which is what the +//! tests at the bottom do. +//! +//! The arithmetic is `f64` throughout and only narrows on the way out. Two of +//! the three steps are matrix inversions, and an inverse amplifies whatever +//! rounding it was handed. + +use crate::settings::ColourSpace; + +/// A colour space's primaries and white point, as CIE xy chromaticities. +/// +/// This is the whole definition of a space's *gamut*; the transfer function +/// (see [`Transfer`]) is the other, independent half. Display P3 and sRGB +/// differ only in the first, ProPhoto and Adobe RGB in both. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Chromaticities { + pub red: [f64; 2], + pub green: [f64; 2], + pub blue: [f64; 2], + pub white: [f64; 2], +} + +/// How a space maps linear light onto the numbers stored in a file. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Transfer { + /// The sRGB curve: linear at slope 12.92 below 0.0031308, then gamma 2.4 + /// offset to meet it. Display P3 uses this same curve — it departs from + /// sRGB in its primaries alone. + Srgb, + /// A pure power curve with no linear segment. Adobe RGB (1998)'s. + Gamma(f32), + /// ROMM RGB's curve: linear at slope 16 below 1/512, then gamma 1.8. + Prophoto, +} + +impl Transfer { + /// Linear light to a stored value. What the final stage of a render does. + pub fn encode(self, v: f32) -> f32 { + // `powf` of a negative is NaN, and a negative arrives whenever a + // colour falls outside the destination gamut. Callers clamp, but a + // NaN escaping here would reach a file as a black pixel with no + // indication of where it came from. + let v = v.max(0.0); + match self { + Self::Srgb => { + if v <= 0.003_130_8 { + v * 12.92 + } else { + 1.055 * v.powf(1.0 / 2.4) - 0.055 + } + } + Self::Gamma(g) => v.powf(1.0 / g), + Self::Prophoto => { + if v < 1.0 / 512.0 { + v * 16.0 + } else { + v.powf(1.0 / 1.8) + } + } + } + } + + /// A stored value back to linear light. + /// + /// The direction an ICC tone reproduction curve is defined in — a `curv` + /// tag maps device values towards the profile connection space — which is + /// why this exists alongside the encoder the renderer wants. + pub fn decode(self, v: f32) -> f32 { + let v = v.max(0.0); + match self { + Self::Srgb => { + if v <= 0.040_45 { + v / 12.92 + } else { + ((v + 0.055) / 1.055).powf(2.4) + } + } + Self::Gamma(g) => v.powf(g), + Self::Prophoto => { + if v < 16.0 / 512.0 { + v / 16.0 + } else { + v.powf(1.8) + } + } + } + } +} + +impl ColourSpace { + /// The primaries and white point that define this space's gamut. + pub fn chromaticities(self) -> Chromaticities { + match self { + Self::Srgb => Chromaticities { + red: [0.6400, 0.3300], + green: [0.3000, 0.6000], + blue: [0.1500, 0.0600], + white: D65, + }, + // Same blue as sRGB, and a far redder red: P3's gamut is the + // cinema projector primaries on a D65 white. + Self::DisplayP3 => Chromaticities { + red: [0.6800, 0.3200], + green: [0.2650, 0.6900], + blue: [0.1500, 0.0600], + white: D65, + }, + // Red and blue are sRGB's exactly. Adobe RGB (1998) widens the + // green corner alone, which is why the conversion out of sRGB + // leaves the blue channel almost untouched. + Self::AdobeRgb => Chromaticities { + red: [0.6400, 0.3300], + green: [0.2100, 0.7100], + blue: [0.1500, 0.0600], + white: D65, + }, + // ROMM RGB. Two of its three primaries are imaginary — outside the + // spectral locus — which is how it encloses every real surface + // colour, and why so much of its cube is unreachable. + Self::ProPhoto => Chromaticities { + red: [0.734_699, 0.265_301], + green: [0.159_597, 0.840_403], + blue: [0.036_598, 0.000_105], + white: D50, + }, + } + } + + /// The transfer function a file in this space carries. + pub fn transfer(self) -> Transfer { + match self { + Self::Srgb | Self::DisplayP3 => Transfer::Srgb, + // 563/256, which is what Adobe RGB (1998) specifies and, not by + // coincidence, exactly what an ICC `curv` tag's u8Fixed8 gamma can + // hold. Writing 2.2 instead would be a different space. + Self::AdobeRgb => Transfer::Gamma(563.0 / 256.0), + Self::ProPhoto => Transfer::Prophoto, + } + } + + /// Linear sRGB to this space's linear RGB, row-major. + /// + /// The transform the render's final stage needs. Linear sRGB is the + /// pipeline's connection space — the camera matrix converts into it, which + /// is what `cam_to_srgb` in the generated shader is named for — so every + /// output space is reached from there. + /// + /// Identity for [`ColourSpace::Srgb`], to within the rounding of an + /// inversion followed by a multiplication. + pub fn from_linear_srgb(self) -> [f32; 9] { + let src = ColourSpace::Srgb.chromaticities(); + let dst = self.chromaticities(); + // Through XYZ, adapting the white point on the way: ProPhoto is a D50 + // space, and handing D65 white to it unadapted would tint every export + // in it warm. + let adapt = adaptation(white_xyz(&src), white_xyz(&dst)); + narrow(mul(invert(rgb_to_xyz(&dst)), mul(adapt, rgb_to_xyz(&src)))) + } + + /// This space's linear RGB to the ICC profile connection space, row-major. + /// + /// The columns are the `rXYZ`, `gXYZ` and `bXYZ` colorant tags of a + /// matrix/TRC profile. Chromatically adapted to D50 because the PCS is + /// defined at D50 and nowhere else — a profile carrying unadapted D65 + /// colorants describes a space nobody asked for. + pub fn to_pcs_xyz(self) -> [f32; 9] { + let c = self.chromaticities(); + narrow(mul(adaptation(white_xyz(&c), PCS_D50), rgb_to_xyz(&c))) + } + + /// The white-point adaptation folded into [`Self::to_pcs_xyz`], row-major. + /// + /// An ICC profile carries this separately, in its `chad` tag, so that a + /// reader can undo the adaptation and recover the space's native white. + /// Without it the colorants alone are ambiguous: D65 primaries adapted to + /// D50 and genuine D50 primaries are the same nine numbers. + pub fn adaptation_to_pcs(self) -> [f32; 9] { + narrow(adaptation(white_xyz(&self.chromaticities()), PCS_D50)) + } +} + +/// The D65 white point, as sRGB, Display P3 and Adobe RGB all define it. +const D65: [f64; 2] = [0.3127, 0.3290]; + +/// The D50 white point, as ROMM RGB defines it. +const D50: [f64; 2] = [0.345_704, 0.358_540]; + +/// The profile connection space illuminant, to the precision ICC fixes it at. +/// +/// Written as the values a profile's header actually carries — 0x0000F6D6, +/// 0x00010000, 0x0000D32D as s15Fixed16 — rather than derived from a +/// chromaticity pair. A media white point that differs from the PCS +/// illuminant in the last bit is a profile some validators reject and some +/// readers quietly re-adapt. +const PCS_D50: [f64; 3] = [0.9642, 1.0, 0.8249]; + +/// The Bradford cone response matrix. +/// +/// Bradford rather than the simpler von Kries or XYZ scaling: it is what ICC +/// specifies for the `chad` tag, so a profile built with anything else would +/// describe colorants that disagree with the adaptation it declares. +const BRADFORD: M3 = [ + [0.8951, 0.2664, -0.1614], + [-0.7502, 1.7135, 0.0367], + [0.0389, -0.0685, 1.0296], +]; + +type M3 = [[f64; 3]; 3]; + +/// An xy chromaticity as an XYZ triple normalised to Y = 1. +fn xyz_from_xy(xy: [f64; 2]) -> [f64; 3] { + let [x, y] = xy; + [x / y, 1.0, (1.0 - x - y) / y] +} + +fn white_xyz(c: &Chromaticities) -> [f64; 3] { + xyz_from_xy(c.white) +} + +/// Linear RGB to XYZ for a set of primaries. +/// +/// The primaries fix the *directions* of the three columns; the white point +/// fixes their lengths, by the requirement that (1, 1, 1) render as the white. +fn rgb_to_xyz(c: &Chromaticities) -> M3 { + let r = xyz_from_xy(c.red); + let g = xyz_from_xy(c.green); + let b = xyz_from_xy(c.blue); + let directions = [[r[0], g[0], b[0]], [r[1], g[1], b[1]], [r[2], g[2], b[2]]]; + let scale = mul_vec(invert(directions), white_xyz(c)); + let mut m = directions; + for row in &mut m { + for (col, s) in row.iter_mut().zip(scale) { + *col *= s; + } + } + m +} + +/// Bradford chromatic adaptation between two white points, in XYZ. +fn adaptation(from: [f64; 3], to: [f64; 3]) -> M3 { + let s = mul_vec(BRADFORD, from); + let d = mul_vec(BRADFORD, to); + let scale = [ + [d[0] / s[0], 0.0, 0.0], + [0.0, d[1] / s[1], 0.0], + [0.0, 0.0, d[2] / s[2]], + ]; + mul(invert(BRADFORD), mul(scale, BRADFORD)) +} + +fn mul(a: M3, b: M3) -> M3 { + let mut out = [[0.0; 3]; 3]; + for (i, row) in out.iter_mut().enumerate() { + for (j, cell) in row.iter_mut().enumerate() { + *cell = (0..3).map(|k| a[i][k] * b[k][j]).sum(); + } + } + out +} + +fn mul_vec(m: M3, v: [f64; 3]) -> [f64; 3] { + [ + m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2], + m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2], + m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2], + ] +} + +/// Invert by the adjugate. +/// +/// No singular case to handle: every matrix inverted here is built from a set +/// of primaries that span a real gamut, or is the Bradford constant. A +/// degenerate one would mean a space with two identical primaries, which is +/// not a space. +fn invert(m: M3) -> M3 { + let cofactor = |a: usize, b: usize, c: usize, d: usize| m[a][b] * m[c][d] - m[a][d] * m[c][b]; + let a = cofactor(1, 1, 2, 2); + let b = -cofactor(1, 0, 2, 2); + let c = cofactor(1, 0, 2, 1); + let det = m[0][0] * a + m[0][1] * b + m[0][2] * c; + let inv = 1.0 / det; + [ + [ + a * inv, + -cofactor(0, 1, 2, 2) * inv, + cofactor(0, 1, 1, 2) * inv, + ], + [ + b * inv, + cofactor(0, 0, 2, 2) * inv, + -cofactor(0, 0, 1, 2) * inv, + ], + [ + c * inv, + -cofactor(0, 0, 2, 1) * inv, + cofactor(0, 0, 1, 1) * inv, + ], + ] +} + +/// Row-major `f64` matrix to the flat `f32` array the rest of the tree passes +/// around — the same shape `dr-decode` gives a camera matrix. +fn narrow(m: M3) -> [f32; 9] { + let mut out = [0.0f32; 9]; + for (i, row) in m.iter().enumerate() { + for (j, v) in row.iter().enumerate() { + out[i * 3 + j] = *v as f32; + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Compare against a published matrix, entry by entry. + fn assert_close(got: [f32; 9], want: [f32; 9], tol: f32, what: &str) { + for (i, (g, w)) in got.iter().zip(want).enumerate() { + assert!( + (g - w).abs() <= tol, + "{what} entry {i}: got {g}, published {w}\n got {got:?}\n want {want:?}" + ); + } + } + + #[test] + fn srgb_to_srgb_is_the_identity() { + // The property the composer relies on to leave the display path's + // shader alone: asking for the space the pipeline already works in + // must cost nothing. A matrix that is merely *close* would still be + // skipped, but one that is not close means the derivation itself is + // wrong in a way every other space would inherit. + assert_close( + ColourSpace::Srgb.from_linear_srgb(), + [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0], + 1e-5, + "sRGB -> sRGB", + ); + } + + #[test] + fn adobe_rgb_leaves_the_red_and_blue_primaries_where_they_were() { + // Adobe RGB (1998) shares sRGB's red and blue chromaticities exactly, + // so the conversion can only move green into red and green into blue. + // Any other non-zero entry means the primaries were mistyped or the + // matrix came out transposed — which a norm-based check would pass. + let m = ColourSpace::AdobeRgb.from_linear_srgb(); + assert_close( + m, + [0.7152, 0.2848, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0412, 0.9588], + 1e-3, + "sRGB -> Adobe RGB", + ); + } + + #[test] + fn display_p3_matches_the_published_conversion() { + // P3 shares sRGB's blue, so the third column is zero above the + // diagonal. The published matrix, to four places. + assert_close( + ColourSpace::DisplayP3.from_linear_srgb(), + [ + 0.8225, 0.1774, 0.0, 0.0332, 0.9669, 0.0, 0.0171, 0.0724, 0.9105, + ], + 1e-3, + "sRGB -> Display P3", + ); + } + + #[test] + fn prophoto_conversion_matches_the_published_bradford_matrix() { + // The one space whose conversion includes a white-point adaptation. + // Dropping the adaptation changes these by a percent or two — small + // enough to look plausible and large enough to tint every export. + assert_close( + ColourSpace::ProPhoto.from_linear_srgb(), + [ + 0.5294, 0.3300, 0.1406, 0.0983, 0.8734, 0.0283, 0.0169, 0.1178, 0.8653, + ], + 2e-3, + "sRGB -> ProPhoto", + ); + } + + #[test] + fn the_srgb_colorants_match_the_reference_profile() { + // The nine numbers in every sRGB IEC61966-2.1 profile ever shipped. + // This is what proves the D50 adaptation runs in the right direction: + // reversing it moves each entry by several percent. + // + // Row-major here, where a profile stores them column by column as + // three XYZ tags — so this also pins the orientation. + assert_close( + ColourSpace::Srgb.to_pcs_xyz(), + [ + 0.4360, 0.3851, 0.1431, // + 0.2225, 0.7169, 0.0606, // + 0.0139, 0.0971, 0.7141, + ], + 1e-3, + "sRGB colorants", + ); + } + + #[test] + fn the_prophoto_colorants_match_the_reference_profile() { + // ProPhoto is already D50, so its adaptation is a near-identity and + // its colorants are the raw primaries. Its blue Y of 0.0001 is the + // giveaway that the numbers are the real ROMM ones. + assert_close( + ColourSpace::ProPhoto.to_pcs_xyz(), + [ + 0.7977, 0.1352, 0.0313, // + 0.2880, 0.7119, 0.0001, // + 0.0000, 0.0000, 0.8251, + ], + 1e-3, + "ProPhoto colorants", + ); + } + + #[test] + fn every_space_renders_its_white_as_the_pcs_illuminant() { + // (1, 1, 1) in any RGB space is that space's white, and a profile's + // colorants must sum to D50 or every neutral in the file comes out + // tinted. The failure is invisible in a single patch and glaring + // across a grey ramp. + for space in ColourSpace::ALL { + let m = space.to_pcs_xyz(); + let sum = [m[0] + m[1] + m[2], m[3] + m[4] + m[5], m[6] + m[7] + m[8]]; + for (i, (got, want)) in sum.iter().zip(PCS_D50).enumerate() { + assert!( + (f64::from(*got) - want).abs() < 1e-3, + "{space:?} white component {i} is {got}, not the PCS {want}" + ); + } + } + } + + #[test] + fn the_wide_spaces_contain_the_whole_srgb_cube() { + // The claim a wide-gamut export makes: nothing sRGB could express is + // lost by encoding into a larger space. Every corner of the sRGB cube + // must land inside the unit cube of the destination — and a matrix + // derived in the wrong direction sends P3's red corner to 1.2, which + // is exactly the clipping this feature exists to avoid. + for space in ColourSpace::ALL { + let m = space.from_linear_srgb(); + for corner in 0..8u8 { + let v = [ + f32::from(corner & 1), + f32::from((corner >> 1) & 1), + f32::from((corner >> 2) & 1), + ]; + for row in 0..3 { + let out = m[row * 3] * v[0] + m[row * 3 + 1] * v[1] + m[row * 3 + 2] * v[2]; + assert!( + (-1e-4..=1.0 + 1e-4).contains(&out), + "{space:?} sends sRGB corner {v:?} channel {row} to {out}" + ); + } + } + } + } + + #[test] + fn a_wider_space_leaves_headroom_where_srgb_has_none() { + // The other half of the same claim, and the reason the feature is + // worth having: saturated sRGB primaries stop short of the wide + // space's own primaries, so there is room left for colours sRGB + // could not hold. Without this, "wide gamut" would be a relabelling. + for space in [ + ColourSpace::DisplayP3, + ColourSpace::AdobeRgb, + ColourSpace::ProPhoto, + ] { + let m = space.from_linear_srgb(); + // sRGB's fully saturated red, in the destination. + let red = [m[0], m[3], m[6]]; + assert!( + red[0] < 0.99, + "{space:?} maps sRGB red to {red:?}, leaving it no headroom" + ); + } + } + + #[test] + fn every_transfer_function_round_trips() { + // Encode then decode must be the identity, or a file exported in a + // space and reopened in it would drift a little further every pass. + // Checked near both ends, where a mismatched breakpoint hides. + for space in ColourSpace::ALL { + let t = space.transfer(); + for v in [0.0, 0.0005, 0.002, 0.05, 0.2159, 0.5, 0.9, 1.0] { + let back = t.decode(t.encode(v)); + assert!( + (back - v).abs() < 1e-4, + "{space:?} at {v}: encode/decode returned {back}" + ); + } + } + } + + #[test] + fn every_transfer_function_maps_the_ends_to_the_ends() { + // Black must stay black and white must stay white in every space. A + // curve that maps 1.0 to 0.998 makes every white in a file slightly + // grey, which is the sort of thing nobody notices until a print. + for space in ColourSpace::ALL { + let t = space.transfer(); + assert!(t.encode(0.0).abs() < 1e-6, "{space:?} black"); + assert!((t.encode(1.0) - 1.0).abs() < 1e-6, "{space:?} white"); + } + } + + #[test] + fn encoding_a_negative_is_black_rather_than_nan() { + // Out-of-gamut colours arrive negative. `powf` of a negative is NaN, + // and a NaN reaching an 8-bit cast is a black pixel with no clue as + // to where it came from. + for space in ColourSpace::ALL { + assert_eq!(space.transfer().encode(-0.2), 0.0, "{space:?}"); + } + } +} diff --git a/core/dr-types/src/lib.rs b/core/dr-types/src/lib.rs index 3fbeea9..2efd089 100644 --- a/core/dr-types/src/lib.rs +++ b/core/dr-types/src/lib.rs @@ -8,9 +8,11 @@ use std::collections::BTreeSet; use std::fmt; use std::ops::Range; +pub mod colour; pub mod selector; pub mod settings; +pub use colour::{Chromaticities, Transfer}; pub use selector::{ColourLabel, DateSelector, FlagState, Selector, Tier}; pub use settings::{ CacheSettings, CollisionPolicy, ColourSpace, DevelopSettings, ExportFormat, ExportSettings, diff --git a/core/dr-types/src/settings.rs b/core/dr-types/src/settings.rs index 110265f..7087b68 100644 --- a/core/dr-types/src/settings.rs +++ b/core/dr-types/src/settings.rs @@ -350,7 +350,13 @@ impl ExportFormat { } } -/// Output colour space, with its ICC profile embedded on export (FR-EXP-2). +/// TRACES: FR-EXP-2 +/// Output colour space, with its ICC profile embedded on export. +/// +/// The name alone. What each one *is* — its primaries, white point and +/// transfer function, and the matrices derived from them — lives in +/// [`crate::colour`], where the renderer and the profile writer read the same +/// numbers and so cannot describe a file as something it is not. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ColourSpace {