From 953c4ee5fc308881760014b49911eb754b2709a4 Mon Sep 17 00:00:00 2001 From: radialmonster Date: Sat, 24 Oct 2026 14:50:40 -0400 Subject: [PATCH 1/2] raw: decode lossy single-plane (grey) JPEG XL tiles in LinearRaw DNGs Lightroom Classic's HDR and panorama merges of monochrome raws store a one-plane LinearRaw image as lossy (XYB) JPEG XL tiles whose codestream declares a grey colour encoding (16-bit float with a linear curve, or 16-bit integer with the sRGB curve). Those were refused as "lossy JPEG XL tile with a single colour plane", so the files opened preview-only. A lossy grey tile is still coded as three XYB channels. It is now restored like the RGB case: jxl-oxide renders linear sRGB (never the declared encoding, which would clip), the single plane is the luminance (CIE Y, D65) of that, then the declared transfer curve is applied. One plane must be declared grey and three RGB; HDR curves, other white points and intensity targets above 255 stay unsupported. RENDER_CACHE_VERSION 70 -> 71: files that showed their embedded preview before render from raw data now, under the same content key. Refs #275 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014ti8KemZbjE2ZgHgV1q1BM --- crates/engine/src/media/source.rs | 2 +- crates/raw/src/jxl.rs | 52 ++++++++++++++++++++++++++----- 2 files changed, 46 insertions(+), 8 deletions(-) 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..77bd3245c 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 @@ -466,7 +496,15 @@ mod tests { #[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 }] { From 20c1f3c0579a9748b270239c08d0527215a85a46 Mon Sep 17 00:00:00 2001 From: radialmonster Date: Sat, 24 Oct 2026 15:15:04 -0400 Subject: [PATCH 2/2] raw: test the grey JPEG XL restore through decode The existing synthetic lossy RGB fixture tiles are re-declared grey by a header edit (colour space RGB to Grey, dropping the 6-bit primaries field a grey encoding does not carry, per the ColourEncoding bundle). Their XYB frame data is unchanged, so they decode as one grey plane whose rendered channels differ. The test checks decode against the gradient's BT.2100 luminance in the linear and sRGB curves. Refs #275 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014ti8KemZbjE2ZgHgV1q1BM --- crates/raw/src/jxl.rs | 50 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/crates/raw/src/jxl.rs b/crates/raw/src/jxl.rs index 77bd3245c..128f4eeea 100644 --- a/crates/raw/src/jxl.rs +++ b/crates/raw/src/jxl.rs @@ -493,6 +493,56 @@ 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(_)));