Merge PR #2003: Lightroom lossy DNGs: decode their JPEG XL tiles and render them like Lightroom

This commit is contained in:
aiongg
2026-10-21 14:52:36 +00:00
5 changed files with 163 additions and 48 deletions
+24 -14
View File
@@ -34,7 +34,8 @@ use lightcraft_raw::{
/// 7: the sensor proxy follows the file's lens warp when the camera JPEG is lens-corrected.
/// 8: the bundled NIKON Z 6_2 profile.
/// 9: the bundled NIKON Z 8 and NIKON D5300 profiles.
pub const LOOK_VERSION: u32 = 9;
/// 10: DNG tone fit for profiles with a look table and for 1024 px (Lightroom) previews.
pub const LOOK_VERSION: u32 = 10;
#[derive(Clone, Debug)]
pub(crate) struct CameraLook {
@@ -107,14 +108,13 @@ fn identity_tone_curve(curve: Option<&lightcraft_raw::profile::ToneCurve>) -> bo
}
/// A DNG with its own colour model whose tone is not (yet) defined by the file: a matrix, no
/// non-identity `ProfileToneCurve`, no `LookTable` (a look table is the file's own rendering).
/// non-identity `ProfileToneCurve` and no `ProfileGainTableMap`. A `LookTable` does not define the tone: an
/// Adobe Standard profile carries one without a tone curve and is rendered with Lightroom's default curve on
/// top (Lightroom Classic's lossy DNGs rendered flat and dull without a fit), and the fit applies the tables
/// to its proxy, so it fits only the tone around them.
pub(crate) fn dng_tone_eligible(raw: &RawImage, t: &CameraTransform) -> bool {
let p = &raw.color.profile;
raw.format == RawFormat::Dng
&& !t.matrix_is_fallback
&& identity_tone_curve(p.tone_curve.as_ref())
&& p.look_table.is_none()
&& p.gain_table_map.is_none()
raw.format == RawFormat::Dng && !t.matrix_is_fallback && identity_tone_curve(p.tone_curve.as_ref()) && p.gain_table_map.is_none()
}
/// [`starting_colour`] with the JPEG fit (the look, and whether the JPEG is black and white) and the black-and-white
@@ -424,9 +424,9 @@ const FIT_PREVIEW_MAX_PIXELS: u64 = 128_000_000;
/// A DNG's tone and chroma curves fitted to its own camera JPEG ([`dng_tone_eligible`]); the file's colour model
/// (matrix, profile tables, white balance) stays as it is: the proxy goes through the file's tables and the given
/// colour of the search is the identity. Same held-out acceptance gates as [`fit_look`]; the preview must be near
/// the raw's size, show the same picture ([`structure_agrees`]), and the curve must not clip more highlights than
/// the plain render ([`clips_more`]).
/// colour of the search is the identity. Same held-out acceptance gates as [`fit_look`]; the preview must not be a
/// thumbnail ([`MIN_PREVIEW_FRACTION`]), must show the same picture ([`structure_agrees`]), and the curve must not
/// clip more highlights than the plain render ([`clips_more`]).
fn fit_dng_tone(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&lightcraft_develop::EmbeddedLens>) -> Option<CameraLook> {
// Logged under LIGHTCRAFT_PROFILE so a corpus run can count the outcome per gate.
let refuse = |why: &str| {
@@ -500,8 +500,9 @@ fn correct_vignetting(sensor: &mut Rgb32f, lens: Option<&lightcraft_develop::Emb
}
/// The embedded preview's long edge must be at least this share of the raw's (and 384 px): a DNG whose preview is a
/// small thumbnail is not fitted.
const MIN_PREVIEW_FRACTION: f64 = 0.9;
/// small thumbnail is not fitted. The fit itself runs on a proxy of at most 384 px, so the 1024 px preview
/// Lightroom writes into its DNGs (15% of a 33 MP raw's long edge) is plenty.
const MIN_PREVIEW_FRACTION: f64 = 0.1;
/// Lowest preview luminance and channel value a tone-only fit trains on (the other fits use 0.015 and 0.004): a dark
/// night scene has most of its pixels below 0.015, and a curve fitted without them overshoots its shadows.
@@ -3959,7 +3960,7 @@ mod tests {
}
/// Which DNGs get a tone fitted to their camera JPEG: a colour matrix, a tone curve that is absent or the
/// identity, no look table; and no other raw format.
/// identity, with or without a look table; and no other raw format.
#[test]
fn dng_tone_gates_follow_the_files_own_tags() {
use lightcraft_raw::profile::{HsvTable, ToneCurve};
@@ -3981,6 +3982,15 @@ mod tests {
assert!(curved.profile.tone_curve.is_some() && !eligible(RawFormat::Dng, curved), "the file's own tone curve is kept");
let mut looked = color;
looked.profile.look_table = HsvTable::from_tags(&[1, 2, 1], &[0.0, 1.0, 1.0, 0.0, 1.0, 1.0], 0);
assert!(looked.profile.look_table.is_some() && !eligible(RawFormat::Dng, looked), "a look table is the file's own rendering");
assert!(looked.profile.look_table.is_some() && eligible(RawFormat::Dng, looked), "a look table defines colour, not tone");
}
/// The 1024 px preview Lightroom Classic writes into a 7008 px DNG is fitted; a 256 px thumbnail is not.
#[test]
fn dng_tone_fit_takes_lightroom_sized_previews() {
let floor = |raw_long: f64| (MIN_PREVIEW_FRACTION * raw_long).max(384.0);
assert!(1024.0 >= floor(7008.0));
assert!(256.0 < floor(7008.0));
assert!(1024.0 >= floor(9504.0), "a 61 MP raw too");
}
}
+1 -1
View File
@@ -62,7 +62,7 @@ impl SettingsHashes {
}
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
pub const RENDER_CACHE_VERSION: u64 = 64;
pub const RENDER_CACHE_VERSION: u64 = 65;
/// 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];
+131 -28
View File
@@ -11,11 +11,18 @@
//!
//! 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 primaries are then restored with a plain 3×3 matrix. We
//! never let jxl-oxide convert to the declared encoding itself: that step gamut-maps and clips to
//! [0, 1], which destroys raw values above 1.0 (a `WhiteLevel` of 32768 puts them far above) and
//! desaturates. The result is the linear sample space the DNG's `WhiteLevel` refers to, up to the
//! codec's loss, and can exceed 1.0 or dip below 0.
//! 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
//! 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
//! `MapPolynomial` per plane in `OpcodeList2`. Measured against Lightroom's own preview of two such
//! files, the polynomial applied to the samples in the declared encoding tracks Lightroom's rendering
//! (midtone log-log slope 0.98 and 1.38, about 1 EV apart); applied to the decoder's linear output it is
//! 3.6 EV too dark. We never let jxl-oxide convert to the declared encoding itself:
//! that step gamut-maps and clips to [0, 1], which destroys raw values above 1.0 (a `WhiteLevel` of
//! 32768 puts them far above) and desaturates. The result is the sample space the DNG's `WhiteLevel`
//! refers to, up to the codec's loss, and can exceed 1.0 or dip below 0.
//!
//! Every tile is decoded with wide (32-bit) modular buffers. A lossy tile whose header sets
//! `modular_16bit_buffers` can still carry values that need more than 16 bits; the narrow buffers then
@@ -79,20 +86,49 @@ fn rgb_to_xyz(p: [[f64; 2]; 3]) -> Matrix {
std::array::from_fn(|i| std::array::from_fn(|j| prim[i][j] * s[j]))
}
/// A declared transfer curve, applied to linear values (linear → coded). Mirrored for negative values
/// and not clipped above 1.0, so lossy undershoot and raw values above white survive.
#[derive(Debug, Clone, Copy, PartialEq)]
enum Curve {
Linear,
/// IEC 61966-2-1 (sRGB).
Srgb,
/// ITU-R BT.709.
Bt709,
/// A pure power: coded = linear^exponent.
Power(f32),
}
impl Curve {
fn encode(self, v: f32) -> f32 {
let a = v.abs();
let e = match self {
Curve::Linear => return v,
Curve::Srgb if a <= 0.003_130_8 => 12.92 * a,
Curve::Srgb => 1.055 * a.powf(1.0 / 2.4) - 0.055,
Curve::Bt709 if a < 0.018 => 4.5 * a,
Curve::Bt709 => 1.099 * a.powf(0.45) - 0.099,
Curve::Power(p) => a.powf(p),
};
e.copysign(v)
}
}
/// How a chunk is rendered by jxl-oxide.
#[derive(Debug, Clone, Copy, PartialEq)]
enum Output {
/// In the codestream's own colour encoding (non-XYB: the identity).
AsCoded,
/// XYB (lossy) data: linear sRGB primaries from the decoder, then `Some(matrix)` to the declared
/// primaries (`None` when those are sRGB's).
FromXyb(Option<[[f32; 3]; 3]>),
/// XYB (lossy) data: linear sRGB primaries from the decoder, then `matrix` to the declared
/// primaries (`None` when those are sRGB's), then the declared transfer `curve`.
FromXyb { matrix: Option<[[f32; 3]; 3]>, curve: Curve },
}
/// 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 a
/// linear RGB one with a D65 white point (what a raw DNG writer signals) and no HDR tone mapping would
/// apply, otherwise the samples cannot be restored faithfully.
/// 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
/// 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<Output> {
if !xyb {
return Ok(Output::AsCoded);
@@ -104,8 +140,26 @@ fn output_for(xyb: bool, encoding: &ColourEncoding, intensity_target: f32, plane
let ColourEncoding::Enum(e) = encoding else {
return Err(unsupported("an ICC profile instead of a colour encoding"));
};
if e.colour_space != ColourSpace::Rgb || e.tf != TransferFunction::Linear {
return Err(unsupported("a declared encoding that is not linear RGB"));
if e.colour_space != ColourSpace::Rgb {
return Err(unsupported("a declared encoding that is not RGB"));
}
let curve = match e.tf {
TransferFunction::Linear => Curve::Linear,
TransferFunction::Srgb => Curve::Srgb,
TransferFunction::Bt709 => Curve::Bt709,
TransferFunction::Dci => Curve::Power(1.0 / 2.6),
// as jxl-oxide reads it: a codestream's gamma is the encoding exponent (×1e7) with `inverted`
TransferFunction::Gamma { g, inverted } => {
let g = g as f32 / 1e7;
Curve::Power(if inverted { g } else { 1.0 / g })
}
TransferFunction::Pq | TransferFunction::Hlg => return Err(unsupported("an HDR transfer curve (PQ or HLG)")),
TransferFunction::Unknown => return Err(unsupported("an unknown transfer curve")),
};
if let Curve::Power(p) = curve
&& !(p.is_finite() && p > 0.0)
{
return Err(unsupported("an invalid gamma"));
}
if e.white_point != WhitePoint::D65 {
return Err(unsupported("a white point other than D65"));
@@ -114,7 +168,7 @@ fn output_for(xyb: bool, encoding: &ColourEncoding, intensity_target: f32, plane
return Err(unsupported("an intensity target above 255 nits"));
}
let primaries = match e.primaries {
Primaries::Srgb => return Ok(Output::FromXyb(None)),
Primaries::Srgb => return Ok(Output::FromXyb { matrix: None, curve }),
Primaries::Bt2100 => BT2100,
Primaries::P3 => P3,
Primaries::Custom { red, green, blue } => [red, green, blue].map(|c| c.as_float().map(f64::from)),
@@ -126,7 +180,7 @@ fn output_for(xyb: bool, encoding: &ColourEncoding, intensity_target: f32, plane
if m.iter().flatten().any(|v| !v.is_finite()) {
return Err(unsupported("degenerate primaries"));
}
Ok(Output::FromXyb(Some(m.map(|r| r.map(|v| v as f32)))))
Ok(Output::FromXyb { matrix: Some(m.map(|r| r.map(|v| v as f32))), curve })
}
fn corrupt(e: impl std::fmt::Display) -> RawError {
@@ -177,7 +231,7 @@ fn open(src: &[u8], cw: usize, ch: usize, vw: usize, vh: usize, cpp: usize, bits
d => return Err(RawError::Corrupt(format!("JPEG XL tile sample type {d:?} does not match the IFD"))),
};
let output = output_for(header.metadata.xyb_encoded, &header.metadata.colour_encoding, header.metadata.tone_mapping.intensity_target, planes)?;
if matches!(output, Output::FromXyb(_)) {
if matches!(output, Output::FromXyb { .. }) {
img.request_color_encoding(EnumColourEncoding::srgb_linear(RenderingIntent::Relative));
}
Ok((img, max, output))
@@ -205,18 +259,23 @@ pub(crate) fn decode(src: &[u8], cw: usize, ch: usize, vw: usize, vh: usize, cpp
}
let n = cw.saturating_mul(ch).saturating_mul(cpp);
let rows = fb.buf().chunks_exact(jw * fc);
// lossy data: restore the declared primaries (see the module documentation)
let matrix = match output {
Output::FromXyb(m) => m,
Output::AsCoded => None,
// lossy data: restore the declared primaries and transfer curve (see the module documentation)
let (matrix, curve) = match output {
Output::FromXyb { matrix, curve } => (matrix, curve),
Output::AsCoded => (None, Curve::Linear),
};
let restore = |s: &[f32], d: &mut [f32]| match matrix {
Some(m) => {
for (d, row) in d.iter_mut().zip(&m) {
*d = row[0] * s[0] + row[1] * s[1] + row[2] * s[2];
let restore = |s: &[f32], d: &mut [f32]| {
match matrix {
Some(m) => {
for (d, row) in d.iter_mut().zip(&m) {
*d = row[0] * s[0] + row[1] * s[1] + row[2] * s[2];
}
}
None => d.copy_from_slice(&s[..cpp]),
}
if curve != Curve::Linear {
d.iter_mut().for_each(|v| *v = curve.encode(*v));
}
None => d.copy_from_slice(&s[..cpp]),
};
Ok(if float {
let mut out = vec![0f32; n];
@@ -257,7 +316,7 @@ mod tests {
fn matrix(o: Output) -> [[f32; 3]; 3] {
match o {
Output::FromXyb(Some(m)) => m,
Output::FromXyb { matrix: Some(m), .. } => m,
other => panic!("expected a matrix, got {other:?}"),
}
}
@@ -275,7 +334,47 @@ mod tests {
#[test]
fn lossy_data_with_srgb_primaries_needs_no_matrix() {
assert_eq!(output_for(true, &linear(Primaries::Srgb), 255.0, 3).unwrap(), Output::FromXyb(None));
assert_eq!(output_for(true, &linear(Primaries::Srgb), 255.0, 3).unwrap(), Output::FromXyb { matrix: None, curve: Curve::Linear });
}
#[test]
fn lossy_data_keeps_the_declared_transfer_curve() {
// Lightroom Classic's lossy LinearRaw DNGs: Rec.2100 primaries, sRGB transfer curve
let mut e = EnumColourEncoding::srgb_linear(RenderingIntent::Perceptual);
e.primaries = Primaries::Bt2100;
e.tf = TransferFunction::Srgb;
let curve = |tf| {
let mut e = e.clone();
e.tf = tf;
match output_for(true, &ColourEncoding::Enum(e), 255.0, 3).unwrap() {
Output::FromXyb { matrix: Some(_), curve } => curve,
other => panic!("expected a matrix and a curve, got {other:?}"),
}
};
assert_eq!(curve(TransferFunction::Srgb), Curve::Srgb);
assert_eq!(curve(TransferFunction::Bt709), Curve::Bt709);
assert_eq!(curve(TransferFunction::Dci), Curve::Power(1.0 / 2.6));
// a codestream's gamma (as read: inverted) is the encoding exponent; jxl-oxide's own γ 2.2 is not
assert_eq!(curve(TransferFunction::Gamma { g: 4_545_455, inverted: true }), Curve::Power(0.454_545_5));
assert_eq!(curve(TransferFunction::Gamma { g: 22_000_000, inverted: false }), Curve::Power(1.0 / 2.2));
}
#[test]
fn transfer_curves_encode_unclipped_and_mirrored() {
let close = |a: f32, b: f32| (a - b).abs() < 1e-4;
// sRGB: linear toe, 18 % grey, white, and the toe/power junction is continuous
assert!(close(Curve::Srgb.encode(0.002), 0.002 * 12.92));
assert!(close(Curve::Srgb.encode(0.18), 0.461_356));
assert!(close(Curve::Srgb.encode(1.0), 1.0));
assert!(close(Curve::Srgb.encode(0.003_130_8), Curve::Srgb.encode(0.003_130_9)));
assert!(close(Curve::Bt709.encode(1.0), 1.0));
assert!((Curve::Bt709.encode(0.017_999) - Curve::Bt709.encode(0.018)).abs() < 1e-3);
assert!(close(Curve::Power(0.5).encode(0.25), 0.5));
// raw values above white keep growing, lossy undershoot keeps its sign, linear is the identity
assert!(Curve::Srgb.encode(2.0) > 1.3);
assert!(close(Curve::Srgb.encode(-0.18), -0.461_356));
assert_eq!(Curve::Linear.encode(-3.5), -3.5);
assert_eq!(Curve::Power(0.5).encode(0.0), 0.0);
}
#[test]
@@ -306,8 +405,12 @@ mod tests {
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(3, linear(Primaries::Srgb), 10_000.0), "HDR intensity target would tone-map");
assert!(bad(3, ColourEncoding::Enum(EnumColourEncoding::srgb_gamma22(RenderingIntent::Relative)), 255.0), "non-linear");
let mut e = EnumColourEncoding::srgb_linear(RenderingIntent::Relative);
for tf in [TransferFunction::Pq, TransferFunction::Hlg, TransferFunction::Unknown, TransferFunction::Gamma { g: 0, inverted: true }] {
e.tf = tf;
assert!(bad(3, ColourEncoding::Enum(e.clone()), 255.0), "{tf:?}");
}
e.tf = TransferFunction::Linear;
e.white_point = WhitePoint::Dci;
assert!(bad(3, ColourEncoding::Enum(e.clone()), 255.0), "white point");
e.white_point = WhitePoint::D65;
File diff suppressed because one or more lines are too long
+4 -2
View File
@@ -1,6 +1,6 @@
# RAW parity: cameras, formats, demosaic, colour and lens profiles
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** patch (X-Trans CMOS 4 bodies take the X-T3's spectral matrices) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (lossy JPEG XL DNG tiles with a non-linear declared transfer curve decode; X-Trans CMOS 4 bodies take the X-T3's spectral matrices) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7)
Raw processing is the deepest single dimension of Lightroom parity: a photographer whose camera
does not decode, or decodes with the wrong colour, cannot switch. This document measures it model by
@@ -155,7 +155,7 @@ checked (the per-model CC0 sweeps of October 2026); compressed ORF (OM / Olympus
| SRW | uncompressed, 12-bit packed | ✅ | EX1 corpus; NX10, NX20 |
| SRW | compressed 32770 / 32772 / 32773 | ⬜ | refused |
| DNG | uncompressed, LJ92, lossy JPEG / Smart Preview, Deflate incl. float, LinearRaw, tiles / strips, row / column interleave | ✅ | 6 corpus DNGs; 4 ILCE-6400 JXL DNGs; 38 Leica Q3 DNGs (lossless JPEG, 14-bit, private check) |
| DNG | JPEG XL (DNG 1.7, lossless and lossy) | ✅ | synthetic bit-exact; real lossless and lossy files |
| DNG | JPEG XL (DNG 1.7, lossless and lossy) | ✅ | synthetic bit-exact; real lossless and lossy files; lossy tiles keep their declared transfer curve (Lightroom Classic 15.1 LinearRaw DNGs label linear data with the sRGB curve); PQ / HLG tiles preview-only |
| DNG | Apple ProRAW (incl. semantic mattes) | ✅ | iPhone 12 Pro corpus; ProfileGainTableMap read but off (ΔE00 19 against Lightroom with it on) |
| DNG | phone DNGs (Pixel, Galaxy, Xiaomi…) | 🟡 | Pixel 2 XL corpus; 13 further Apple, Google and Samsung models in the pinned sweep; white-level pile-up fix #548 |
| DNG | GoPro GPR (VC-5) | ⬜ | refused |
@@ -288,6 +288,8 @@ Beyond decoding:
| Date | Change | Summary |
|---|---|---|
| 2026-10-18 | patch | X-T30, X-T30 II, X-Pro3, X100V, X-S10 and X-E4 take the X-T3's spectral matrices (same 26.1 MP X-Trans CMOS 4 sensor): an X-T30 raw with a black-and-white (Acros) camera JPEG no longer opens in the muted neutral fallback |
| 2026-10-15 | minor | DNGs with a look table and no tone curve (Lightroom-written, Adobe Standard) fit their tone to the embedded preview, also Lightroom's 1024 px one: two lossy ILCE-7CM2 DNGs no longer render 0.8 EV dark and flat |
| 2026-10-15 | minor | Lossy JPEG XL DNG tiles that declare a non-linear SDR transfer curve (sRGB, BT.709, DCI, gamma) decode with that curve restored instead of falling back to the embedded preview: Lightroom Classic 15.1 LinearRaw DNGs (Rec. 2100 primaries, sRGB curve; 2 private ILCE-7CM2 files) |
| 2026-10-15 | minor | Hasselblad 3FR / FFF embedded previews: the uncompressed 8-bit RGB image, handed on as a baseline TIFF (checked on the CC0 H4D-40 FFF, 1217 × 913, and CFV 3FR, 218 × 218, upright through the file's orientation); counts unchanged |
| 2026-10-15 | minor | Hasselblad 3FR / FFF get their own reader (`crates/raw/src/vendor/hasselblad.rs`): an FFF takes its colour matrix from the original 3FR header in tag `0xC51B` instead of developing with the neutral matrix; RGGB checked against the camera preview on the CC0 H4D-40 FFF and CFV 3FR; counts unchanged |
| 2026-10-14 | minor | Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (`coverage_sweep_corpus.rs`, `coverage-sweep.tsv`; `docs/raw/coverage.py` 515 → 545 verified rows); the counts above are not re-measured here. Sensor verification does not establish colour parity (#1277) |