diff --git a/crates/engine/src/media/source.rs b/crates/engine/src/media/source.rs index 8f6a9c28c..8490d2ee2 100644 --- a/crates/engine/src/media/source.rs +++ b/crates/engine/src/media/source.rs @@ -1,7 +1,7 @@ use super::{Arc, Deserialize, DevelopSettings, MediaKind, Photo, Rgb32f, Rgba8, Serialize, SourceInfo}; /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 70; +pub const RENDER_CACHE_VERSION: u64 = 71; /// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache). pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512]; diff --git a/crates/raw/src/jxl.rs b/crates/raw/src/jxl.rs index 209f8f4de..128f4eeea 100644 --- a/crates/raw/src/jxl.rs +++ b/crates/raw/src/jxl.rs @@ -12,7 +12,9 @@ //! Lossless (non-XYB) tiles are rendered in the codestream's own colour encoding, which is the identity, //! keeping the values in the raw IFD's sample space. Lossy (XYB) tiles are decoded by jxl-oxide to linear //! sRGB primaries; the codestream's declared encoding is then restored by hand: its primaries with a -//! plain 3×3 matrix, then its transfer curve. The samples of a lossy tile mean what they would in a +//! plain 3×3 matrix, then its transfer curve. A grey (single-plane) lossy tile is still coded as three +//! XYB channels; its declared grey encoding is restored as the luminance (CIE Y, D65 white) of the +//! linear sRGB output, then its transfer curve. The samples of a lossy tile mean what they would in a //! lossless one with the same header, so a declared transfer curve is part of the stored values, not a //! display hint. Lightroom Classic's lossy LinearRaw DNGs (Convert to DNG with lossy compression) store //! perceptually shaped samples, label them with the sRGB curve and linearise them with a cubic @@ -126,7 +128,7 @@ enum Output { /// Decide how to render a chunk from its header: whether it is XYB-coded, the declared colour /// encoding and the intensity target. Lossy data is only accepted when the declared encoding is an RGB -/// one with a D65 white point and an SDR transfer curve (linear, sRGB, BT.709 or a power: what raw DNG +/// one (three planes) or a grey one (one plane) with a D65 white point and an SDR transfer curve (linear, sRGB, BT.709 or a power: what raw DNG /// writers signal) and no HDR tone mapping would apply, otherwise the samples cannot be restored /// faithfully. fn output_for(xyb: bool, encoding: &ColourEncoding, intensity_target: f32, planes: usize) -> Result { @@ -134,13 +136,15 @@ fn output_for(xyb: bool, encoding: &ColourEncoding, intensity_target: f32, plane return Ok(Output::AsCoded); } let unsupported = |what: &str| RawError::Unsupported(format!("lossy JPEG XL tile with {what}")); - if planes != 3 { - return Err(unsupported("a single colour plane")); - } let ColourEncoding::Enum(e) = encoding else { return Err(unsupported("an ICC profile instead of a colour encoding")); }; - if e.colour_space != ColourSpace::Rgb { + // a single plane is declared grey, three are declared RGB (the header's grey flag is the colour space) + let grey = planes == 1; + if grey && e.colour_space != ColourSpace::Grey { + return Err(unsupported("a single colour plane that is not declared grey")); + } + if !grey && e.colour_space != ColourSpace::Rgb { return Err(unsupported("a declared encoding that is not RGB")); } let curve = match e.tf { @@ -167,6 +171,13 @@ fn output_for(xyb: bool, encoding: &ColourEncoding, intensity_target: f32, plane if intensity_target > 255.0 { return Err(unsupported("an intensity target above 255 nits")); } + if grey { + // a grey encoding has no primaries, only the white point: its single plane is the luminance (CIE Y) + // of the decoder's linear sRGB, which is the row of sRGB → XYZ that gives Y. The rows of the + // matrix are all that row; only the first is used for one plane. + let y = rgb_to_xyz(SRGB)[1].map(|v| v as f32); + return Ok(Output::FromXyb { matrix: Some([y; 3]), curve }); + } let primaries = match e.primaries { Primaries::Srgb => return Ok(Output::FromXyb { matrix: None, curve }), Primaries::Bt2100 => BT2100, @@ -337,6 +348,25 @@ mod tests { assert_eq!(output_for(true, &linear(Primaries::Srgb), 255.0, 3).unwrap(), Output::FromXyb { matrix: None, curve: Curve::Linear }); } + #[test] + fn lossy_grey_data_is_the_luminance_in_the_declared_curve() { + // Lightroom Classic's merged monochrome LinearRaw DNGs: one plane, declared grey with a linear + // (16-bit float) or an sRGB (16-bit integer) curve + let grey = |tf| { + let mut e = EnumColourEncoding::srgb_linear(RenderingIntent::Perceptual); + e.colour_space = ColourSpace::Grey; + e.tf = tf; + output_for(true, &ColourEncoding::Enum(e), 255.0, 1).unwrap() + }; + let Output::FromXyb { matrix: Some(m), curve: Curve::Linear } = grey(TransferFunction::Linear) else { panic!("grey, linear") }; + // the luminance of linear sRGB (IEC 61966-2-1: 0.2126, 0.7152, 0.0722); white stays white + for (v, e) in m[0].iter().zip([0.2126, 0.7152, 0.0722]) { + assert!((v - e).abs() < 1e-4, "{m:?}"); + } + assert!((m[0].iter().sum::() - 1.0).abs() < 1e-5, "{m:?}"); + assert!(matches!(grey(TransferFunction::Srgb), Output::FromXyb { curve: Curve::Srgb, .. })); + } + #[test] fn lossy_data_keeps_the_declared_transfer_curve() { // Lightroom Classic's lossy LinearRaw DNGs: Rec.2100 primaries, sRGB transfer curve @@ -463,10 +493,68 @@ mod tests { } } + /// A fixture tile re-declared grey: its colour encoding's colour space goes from RGB to Grey and, + /// as a grey encoding has no primaries, the 6-bit BT.2100 primaries field is dropped (ISO/IEC 18181-1 + /// ColourEncoding; the bits after it move up and the header's zero padding to the frame grows by 6). + /// The XYB frame data is untouched, so the codestream is one grey plane coded as three XYB channels + /// whose colour is the gradient's, like Lightroom Classic's merged monochrome DNGs. + fn as_grey(tile: &[u8]) -> Vec { + // bit offset of the colour space, read LSB first, and the frame's first byte in these fixture tiles + const AT: usize = 42; + const FRAME: usize = 8; + let mut bits: Vec = tile.iter().flat_map(|b| (0..8).map(move |i| (b >> i) & 1)).collect(); + // colour space Rgb (selector 0), white point D65 (selector 1), primaries Bt2100 (selector 2 + 4 bits of 7) + assert_eq!(bits[AT..AT + 10], [0, 0, 1, 0, 0, 1, 1, 1, 1, 0], "the fixture's colour encoding"); + bits[AT] = 1; // colour space Grey (selector 1) + bits.drain(AT + 4..AT + 10); + bits.splice(FRAME * 8 - 6..FRAME * 8 - 6, [0; 6]); + bits.as_chunks::<8>().0.iter().map(|b| b.iter().enumerate().fold(0, |a, (i, v)| a | v << i)).collect() + } + + #[test] + fn lossy_grey_tiles_decode_to_the_luminance_of_their_colour() { + let srgb_eotf = |v: f32| if v <= 0.040_45 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }; + let y = rgb_to_xyz(BT2100)[1].map(|v| v as f32); + for (name, tile, curve) in [("linear", LINEAR_TILE, Curve::Linear), ("sRGB", SRGB_TILE, Curve::Srgb)] { + let grey = as_grey(tile); + // the decoder still renders three unequal channels: the restore must reduce them, not pick one + let mut img = JxlImage::builder().read(std::io::Cursor::new(&grey)).unwrap(); + assert!(img.image_header().metadata.grayscale(), "{name}: declared grey"); + img.request_color_encoding(EnumColourEncoding::srgb_linear(RenderingIntent::Relative)); + let render = img.render_frame(0).unwrap(); + let fb = render.image_all_channels(); + assert_eq!(fb.channels(), 3, "{name}"); + assert!((fb.buf()[2] - fb.buf()[0]).abs() > 0.1, "{name}: the rendered channels differ"); + + let ChunkPx::U16(px) = decode(&grey, 16, 16, 16, 16, 1, 16, false).unwrap() else { panic!("{name}: integer samples") }; + assert_eq!(px.len(), 256, "{name}: one plane"); + let (mut sum, mut worst) = (0.0f32, 0.0f32); + for (i, got) in px.iter().enumerate() { + // the gradient's samples are coded in the tile's curve: back to linear for the luminance + let rgb = gradient(i % 16, i / 16).map(|v| if curve == Curve::Srgb { srgb_eotf(v / 65535.0) } else { v / 65535.0 }); + let want = curve.encode(y[0] * rgb[0] + y[1] * rgb[1] + y[2] * rgb[2]) * 65535.0; + let err = (f32::from(*got) - want).abs(); + sum += err; + worst = worst.max(err); + } + let mean = sum / px.len() as f32; + assert!(mean < 0.02 * 65535.0, "{name}: mean error {mean}"); + assert!(worst < 0.1 * 65535.0, "{name}: worst error {worst}"); + } + } + #[test] fn lossy_data_that_cannot_be_restored_is_unsupported() { let bad = |xyb_planes: usize, e: ColourEncoding, it: f32| matches!(output_for(true, &e, it, xyb_planes), Err(RawError::Unsupported(_))); - assert!(bad(1, linear(Primaries::Srgb), 255.0), "grey"); + assert!(bad(1, linear(Primaries::Srgb), 255.0), "one plane declared RGB"); + let mut g = EnumColourEncoding::srgb_linear(RenderingIntent::Relative); + g.colour_space = ColourSpace::Grey; + assert!(bad(1, ColourEncoding::Enum(g.clone()), 10_000.0), "grey with an HDR intensity target"); + g.tf = TransferFunction::Pq; + assert!(bad(1, ColourEncoding::Enum(g.clone()), 255.0), "grey with PQ"); + g.tf = TransferFunction::Linear; + g.white_point = WhitePoint::Dci; + assert!(bad(1, ColourEncoding::Enum(g), 255.0), "grey with a white point other than D65"); assert!(bad(3, linear(Primaries::Srgb), 10_000.0), "HDR intensity target would tone-map"); let mut e = EnumColourEncoding::srgb_linear(RenderingIntent::Relative); for tf in [TransferFunction::Pq, TransferFunction::Hlg, TransferFunction::Unknown, TransferFunction::Gamma { g: 0, inverted: true }] {