mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-28 20:17:18 -06:00
Merge PR #2003: Lightroom lossy DNGs: decode their JPEG XL tiles and render them like Lightroom
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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) |
|
||||
|
||||
Reference in New Issue
Block a user