diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index c620be92..bad30e14 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -676,8 +676,12 @@ mod tests { assert!(info.camera_tone.is_none()); // a map that removes all saturation, and a tone curve let grey = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[0.0, 0.0, 1.0]; 8], srgb_value: false }; - raw.color.profile = - ProfileLook { hue_sat_map: [Some(grey), None], look_table: None, tone_curve: ToneCurve::from_tag(&[0.0, 0.0, 0.18, 0.3, 1.0, 1.0]) }; + raw.color.profile = ProfileLook { + hue_sat_map: [Some(grey), None], + look_table: None, + tone_curve: ToneCurve::from_tag(&[0.0, 0.0, 0.18, 0.3, 1.0, 1.0]), + gain_table_map: None, + }; let with = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); let (after, info) = load_bytes(&with, 64).unwrap(); assert!(sat(&after) < 1e-3, "saturation {} → {}", sat(&before), sat(&after)); @@ -695,6 +699,33 @@ mod tests { assert!((mean(&dim) / mean(&before) - 0.5).abs() < 0.02, "{} vs {}", mean(&dim), mean(&before)); } + /// Apple ProRAW: like Lightroom Classic, the default render ignores the file's + /// `ProfileGainTableMap` (Apple's local tone mapping); a map that would double every pixel + /// changes nothing. + #[test] + fn dng_gain_table_map_is_not_rendered() { + use lightcraft_raw::gaintable::GainTableMap; + let plain = crate::tests_xmp::synthetic_dng_with(None, Default::default()); + let mut raw = lightcraft_raw::decode(&plain).unwrap(); + let (before, _) = load_bytes(&lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(), 64).unwrap(); + raw.color.profile.gain_table_map = Some(GainTableMap { + points_v: 1, + points_h: 1, + points_n: 1, + spacing_v: 1.0, + spacing_h: 1.0, + origin_v: 0.0, + origin_h: 0.0, + weights: [0.2, 0.2, 0.2, 0.2, 0.2], + gamma: 1.0, + gains: vec![2.0], + }); + let with_map = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); + assert!(lightcraft_raw::decode(&with_map).unwrap().color.profile.gain_table_map.is_some(), "the map is kept"); + let (after, _) = load_bytes(&with_map, 64).unwrap(); + assert_eq!(before.data, after.data); + } + #[test] fn unsupported_raw_falls_back_to_embedded_preview() { let b = cr3_with_preview(48, 32); diff --git a/crates/merge/src/output.rs b/crates/merge/src/output.rs index 442fa2ee..fc12bb3a 100644 --- a/crates/merge/src/output.rs +++ b/crates/merge/src/output.rs @@ -29,10 +29,16 @@ pub enum DngSamples { U16, } -/// The DNG colour tags for frames of `color`. +/// The DNG colour tags for frames of `color`. A source's gain table map is dropped: it is +/// positioned on that one capture's active area, which a merged image (aligned, oriented, +/// stitched, cropped) no longer shares. pub fn color_data(color: &FrameColor) -> ColorData { match color { - FrameColor::Camera(c) => (**c).clone(), + FrameColor::Camera(c) => { + let mut c = (**c).clone(); + c.profile.gain_table_map = None; + c + } FrameColor::Linear { to_xyz_d50 } => { let to_xyz_d65 = bradford(D50, D65).mul(to_xyz_d50); let cm = to_xyz_d65.inverse().unwrap_or(Mat3::IDENTITY); diff --git a/crates/raw/src/dng.rs b/crates/raw/src/dng.rs index 9601da63..05b34900 100644 --- a/crates/raw/src/dng.rs +++ b/crates/raw/src/dng.rs @@ -1,13 +1,14 @@ //! DNG (Adobe Digital Negative Specification 1.7): raw IFD selection, pixel data (via [`crate::tiffraw`]), //! linearization, black/white levels, active area, default crop, CFA description, colour tags, opcode lists. +use crate::gaintable::GainTableMap; use crate::profile::{HsvTable, ProfileLook, ToneCurve}; use crate::tiffraw::{Packing, read_image_in}; use crate::{BlackLevel, Cfa, ColorData, Mat3, Mode, RawData, RawError, RawFormat, RawImage, Rect, Result, opcodes}; use lightcraft_color::Xy; use lightcraft_geom::Orientation; use lightcraft_tiff::tags::{self as t, photometric}; -use lightcraft_tiff::{Ifd, Tiff}; +use lightcraft_tiff::{ByteOrder, Ifd, Tiff}; /// The main raw IFD: full-resolution (NewSubfileType 0) CFA or LinearRaw image with the most pixels. pub(crate) fn raw_ifd(tiff: &Tiff) -> Option<&Ifd> { @@ -33,7 +34,8 @@ fn vec3(v: Option>) -> Option<[f64; 3]> { } /// Colour tags: DNG puts them in IFD0, but some writers use the raw IFD; prefer the raw IFD. -pub(crate) fn color_data(ifd0: &Ifd, raw: &Ifd) -> ColorData { +/// `order` is the file's byte order (for the binary gain-table-map tags). +pub(crate) fn color_data(ifd0: &Ifd, raw: &Ifd, order: ByteOrder) -> ColorData { let get = |tag: u16| raw.f64s(tag).or_else(|| ifd0.f64s(tag)); let geti = |tag: u16| raw.u16(tag).or_else(|| ifd0.u16(tag)); ColorData { @@ -46,13 +48,14 @@ pub(crate) fn color_data(ifd0: &Ifd, raw: &Ifd) -> ColorData { as_shot_white_xy: get(t::AS_SHOT_WHITE_XY).filter(|v| v.len() == 2 && v[0] > 0.0 && v[1] > 0.0).map(|v| Xy::new(v[0], v[1])), baseline_exposure: get(t::BASELINE_EXPOSURE).and_then(|v| v.first().copied()).filter(|v| v.is_finite()).unwrap_or(0.0) + get(t::BASELINE_EXPOSURE_OFFSET).and_then(|v| v.first().copied()).filter(|v| v.is_finite()).unwrap_or(0.0), - profile: profile_look(ifd0, raw), + profile: profile_look(ifd0, raw, order), } } -/// The profile look tags (`ProfileHueSatMap*`, `ProfileLookTable*`, `ProfileToneCurve`); malformed -/// ones are ignored. Like the colour tags, read from the raw IFD first, else IFD 0. -pub(crate) fn profile_look(ifd0: &Ifd, raw: &Ifd) -> ProfileLook { +/// The profile look tags (`ProfileHueSatMap*`, `ProfileLookTable*`, `ProfileToneCurve`, +/// `ProfileGainTableMap*`); malformed ones are ignored. Like the colour tags, read from the raw IFD +/// first, else IFD 0. +pub(crate) fn profile_look(ifd0: &Ifd, raw: &Ifd, order: ByteOrder) -> ProfileLook { let pick = |tag: u16| if raw.contains(tag) { raw } else { ifd0 }; let table = |dims: u16, data: u16, enc: u16| { let ifd = pick(dims); @@ -67,9 +70,22 @@ pub(crate) fn profile_look(ifd0: &Ifd, raw: &Ifd) -> ProfileLook { ], look_table: table(t::PROFILE_LOOK_TABLE_DIMS, t::PROFILE_LOOK_TABLE_DATA, t::PROFILE_LOOK_TABLE_ENCODING), tone_curve: pick(t::PROFILE_TONE_CURVE).f64s(t::PROFILE_TONE_CURVE).and_then(|v| ToneCurve::from_tag(&v)), + gain_table_map: gain_table_map(ifd0, raw, order), } } +/// The gain table map (DNG 1.7 precedence): `ProfileGainTableMap2` from IFD 0 (the embedded +/// camera profile), else from the raw IFD; else `ProfileGainTableMap` from the raw IFD (where DNG +/// 1.6 put it, and where Apple writes it), else from IFD 0 (where DNG 1.7 says it was meant to be). +/// A malformed tag falls through to the next one. +fn gain_table_map(ifd0: &Ifd, raw: &Ifd, order: ByteOrder) -> Option { + let parse = |ifd: &Ifd, tag: u16| ifd.bytes(tag).and_then(|b| GainTableMap::parse(b, order, tag == t::PROFILE_GAIN_TABLE_MAP_2)); + parse(ifd0, t::PROFILE_GAIN_TABLE_MAP_2) + .or_else(|| parse(raw, t::PROFILE_GAIN_TABLE_MAP_2)) + .or_else(|| parse(raw, t::PROFILE_GAIN_TABLE_MAP)) + .or_else(|| parse(ifd0, t::PROFILE_GAIN_TABLE_MAP)) +} + pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let tiff = Tiff::parse(bytes)?; let ifd0 = &tiff.ifds[0]; @@ -172,7 +188,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { active_area, crop, orientation: Orientation::from_exif(ifd0.u16(t::ORIENTATION).unwrap_or(1)), - color: color_data(ifd0, raw), + color: color_data(ifd0, raw, tiff.order), wb_multipliers: None, linearized, opcodes, diff --git a/crates/raw/src/dngwrite.rs b/crates/raw/src/dngwrite.rs index bd46a2c5..bf5ccc95 100644 --- a/crates/raw/src/dngwrite.rs +++ b/crates/raw/src/dngwrite.rs @@ -214,6 +214,11 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result> { if let Some(curve) = &p.tone_curve { ifd.set(t::PROFILE_TONE_CURVE, Value::Float(curve.points.iter().flatten().copied().collect())); } + if let Some(map) = &p.gain_table_map { + // the raw IFD is IFD 0 here, where both tag versions are valid + let (version2, bytes) = map.to_bytes(opts.order); + ifd.set(if version2 { t::PROFILE_GAIN_TABLE_MAP_2 } else { t::PROFILE_GAIN_TABLE_MAP }, Value::Undefined(bytes)); + } for (list, tag) in [(&raw.opcodes.list1, t::OPCODE_LIST_1), (&raw.opcodes.list2, t::OPCODE_LIST_2), (&raw.opcodes.list3, t::OPCODE_LIST_3)] { if !list.is_empty() { ifd.set(tag, Value::Undefined(opcodes::write_list(list))); diff --git a/crates/raw/src/gaintable.rs b/crates/raw/src/gaintable.rs new file mode 100644 index 00000000..750a1c70 --- /dev/null +++ b/crates/raw/src/gaintable.rs @@ -0,0 +1,325 @@ +//! DNG `ProfileGainTableMap` (tag 52525, DNG 1.6) and `ProfileGainTableMap2` (tag 52544, DNG 1.7): +//! a coarse grid of 1D gain tables carrying the maker's local tone mapping. Apple ProRAW has one. +//! +//! We read and write the tag (a DNG export keeps it) but don't render it: Lightroom Classic +//! renders Apple ProRAW without the map (see [`crate::profile`]), and so do we. +//! +//! - The grid has `MapPointsV × MapPointsH` tables of `MapPointsN` gains. Its origin and spacing +//! are relative to the active area (1.0 = the active area's height or width). +//! - The table input is the dot product of (R, G, B, min, max) with the tag's five weights; +//! version 2 then raises it to the tag's `Gamma`. +//! - Version 2 stores gains as u8 or u16 (mapped linearly onto `GainMin..=GainMax`), f16 or f32. +//! It takes precedence over version 1 when both are present. +//! +//! Malformed tags are ignored: a wrong size, zero or huge dimensions, non-finite or negative gains, +//! non-positive spacing, or gamma outside 0.25..=4. +//! +//! Byte order: the tag's fields are TIFF types (LONG, DOUBLE, FLOAT, …), and neither DNG 1.6 nor +//! 1.7.1 gives this tag a byte order of its own, as they do for opcode lists and +//! `OriginalRawFileData` (always big-endian). What applies is the general rule of the DNG 1.6 +//! specification ("DNG Format Overview" → "Byte Order", worded the same in 1.7.1): +//! +//! > DNG readers are required to support either byte order, even for files from a particular +//! > camera model. +//! +//! (, "DNG Specification".) So we read +//! the fields in the file's byte order, and fall back to big-endian for writers that followed the +//! opcode-list convention. A read in the wrong order can't pass for a valid map: one of +//! `MapPointsV` / `MapPointsH` is below 256 (their product is at most [`MAX_TABLES`]), so swapped +//! it is at least 2^24 and the size checks reject it. We write in the file's byte order. + +use lightcraft_tiff::ByteOrder; +use serde::{Deserialize, Serialize}; + +/// Upper bound on `MapPointsV × MapPointsH` (Apple uses 6 × 8). +const MAX_TABLES: usize = 1 << 12; +/// Upper bound on the total number of gains (Apple: 6 × 8 × 257 = 12 336). +const MAX_GAINS: usize = 1 << 22; + +/// A parsed `ProfileGainTableMap` / `ProfileGainTableMap2`. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct GainTableMap { + /// Tables vertically (`MapPointsV`) and horizontally (`MapPointsH`), and points per table (`MapPointsN`). + pub points_v: usize, + pub points_h: usize, + pub points_n: usize, + /// Grid spacing and origin, relative to the active area's height (`_v`) and width (`_h`). + pub spacing_v: f64, + pub spacing_h: f64, + pub origin_v: f64, + pub origin_h: f64, + /// Weights of (R, G, B, min, max) for the table input. + pub weights: [f32; 5], + /// Exponent for the table input (1.0 for the version-1 tag). + pub gamma: f32, + /// `points_v × points_h × points_n` gains, row-major with the table points innermost. Integer + /// storage has already been mapped onto `GainMin..=GainMax`. Mapping before interpolating is + /// the same as mapping after, because the mapping is linear. + pub gains: Vec, +} + +impl GainTableMap { + /// Parse the tag's bytes from a file in byte order `order` (`version2`: `ProfileGainTableMap2`): + /// in that order, else big-endian (see the module docs). `None` when malformed. + pub fn parse(b: &[u8], order: ByteOrder, version2: bool) -> Option { + Self::parse_in(b, order, version2).or_else(|| if order == ByteOrder::Big { None } else { Self::parse_in(b, ByteOrder::Big, version2) }) + } + + /// Parse the tag's bytes with every multi-byte field in `order`. + fn parse_in(b: &[u8], order: ByteOrder, version2: bool) -> Option { + let take = |o: usize, n: usize| b.get(o..o.checked_add(n)?); + let u32_at = |o: usize| take(o, 4).and_then(|s| s.try_into().ok()).map(|a: [u8; 4]| order.u32(a)); + let f32_at = |o: usize| u32_at(o).map(f32::from_bits); + let f64_at = |o: usize| take(o, 8).and_then(|s| s.try_into().ok()).map(|a: [u8; 8]| f64::from_bits(order.u64(a))); + let (points_v, points_h) = (u32_at(0)? as usize, u32_at(4)? as usize); + let (spacing_v, spacing_h, origin_v, origin_h) = (f64_at(8)?, f64_at(16)?, f64_at(24)?, f64_at(32)?); + let points_n = u32_at(40)? as usize; + let mut weights = [0f32; 5]; + for (i, w) in weights.iter_mut().enumerate() { + *w = f32_at(44 + 4 * i)?; + } + let tables = points_v.checked_mul(points_h).filter(|&t| t > 0 && t <= MAX_TABLES)?; + let count = tables.checked_mul(points_n).filter(|&c| c > 0 && c <= MAX_GAINS)?; + let spacing_ok = |s: f64, points: usize| s.is_finite() && (s > 0.0 || points == 1); + if !spacing_ok(spacing_v, points_v) + || !spacing_ok(spacing_h, points_h) + || !origin_v.is_finite() + || !origin_h.is_finite() + || !weights.iter().all(|w| w.is_finite()) + { + return None; + } + let (header, data_type, gamma, gain_min, gain_max) = + if version2 { (80, u32_at(64)?, f32_at(68)?, f32_at(72)?, f32_at(76)?) } else { (64, 3, 1.0, 0.0, 1.0) }; + if !(0.25..=4.0).contains(&gamma) { + return None; + } + let width = match data_type { + 0 => 1, + 1 | 2 => 2, + 3 => 4, + _ => return None, + }; + if b.len() != count.checked_mul(width)?.checked_add(header)? { + return None; + } + let data = b.get(header..)?; + let scaled = |q: f32, max: f32| gain_min + q / max * (gain_max - gain_min); + let gains: Vec = match data_type { + 0 => data.iter().map(|&q| scaled(q as f32, 255.0)).collect(), + 1 => data.as_chunks::<2>().0.iter().map(|c| scaled(order.u16(*c) as f32, 65535.0)).collect(), + 2 => data.as_chunks::<2>().0.iter().map(|c| crate::unpack::f16_to_f32(order.u16(*c))).collect(), + _ => data.as_chunks::<4>().0.iter().map(|c| f32::from_bits(order.u32(*c))).collect(), + }; + if gains.len() != count || !gains.iter().all(|g| g.is_finite() && *g >= 0.0) { + return None; + } + Some(GainTableMap { points_v, points_h, points_n, spacing_v, spacing_h, origin_v, origin_h, weights, gamma, gains }) + } + + /// The tag's bytes in `order`: version 1 (DNG 1.6, f32 gains) when the gamma is 1, else + /// version 2 with f32 gains. Returns `(version2, bytes)`. + pub fn to_bytes(&self, order: ByteOrder) -> (bool, Vec) { + let version2 = self.gamma != 1.0; + let mut out = Vec::with_capacity(80 + 4 * self.gains.len()); + let u32b = |v: u32| match order { + ByteOrder::Big => v.to_be_bytes(), + ByteOrder::Little => v.to_le_bytes(), + }; + let f64b = |v: f64| match order { + ByteOrder::Big => v.to_be_bytes(), + ByteOrder::Little => v.to_le_bytes(), + }; + let dim = |v: usize| u32b(u32::try_from(v).unwrap_or(u32::MAX)); + out.extend_from_slice(&dim(self.points_v)); + out.extend_from_slice(&dim(self.points_h)); + for v in [self.spacing_v, self.spacing_h, self.origin_v, self.origin_h] { + out.extend_from_slice(&f64b(v)); + } + out.extend_from_slice(&dim(self.points_n)); + for w in self.weights { + out.extend_from_slice(&u32b(w.to_bits())); + } + if version2 { + // DataType 3 (f32); GainMin/GainMax are ignored for float data + for v in [3, self.gamma.to_bits(), 0f32.to_bits(), 1f32.to_bits()] { + out.extend_from_slice(&u32b(v)); + } + } + for g in &self.gains { + out.extend_from_slice(&u32b(g.to_bits())); + } + (version2, out) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Tag bytes: the header fields (`v2`: data type, gamma, gain min, gain max), then `data`. + fn tag( + order: ByteOrder, + dims: [u32; 3], + spacing: [f64; 2], + origin: [f64; 2], + weights: [f32; 5], + v2: Option<(u32, f32, f32, f32)>, + data: &[u8], + ) -> Vec { + let be = order == ByteOrder::Big; + let u = |v: u32| if be { v.to_be_bytes() } else { v.to_le_bytes() }; + let d = |v: f64| if be { v.to_be_bytes() } else { v.to_le_bytes() }; + let mut b = Vec::new(); + b.extend_from_slice(&u(dims[0])); + b.extend_from_slice(&u(dims[1])); + for v in [spacing[0], spacing[1], origin[0], origin[1]] { + b.extend_from_slice(&d(v)); + } + b.extend_from_slice(&u(dims[2])); + for w in weights { + b.extend_from_slice(&u(w.to_bits())); + } + if let Some((dt, gamma, min, max)) = v2 { + for v in [dt, gamma.to_bits(), min.to_bits(), max.to_bits()] { + b.extend_from_slice(&u(v)); + } + } + b.extend_from_slice(data); + b + } + + fn floats(order: ByteOrder, g: &[f32]) -> Vec { + g.iter().flat_map(|v| if order == ByteOrder::Big { v.to_be_bytes() } else { v.to_le_bytes() }).collect() + } + + const GREEN: [f32; 5] = [0.0, 1.0, 0.0, 0.0, 0.0]; + + /// 2 × 2 tables of N = 4 points: table (r, c) = base · [1, 2, 3, 4] with base 1, 2, 3, 4. + fn grid() -> Vec { + (0..4).flat_map(|t| (1..=4).map(move |i| (t + 1) as f32 * i as f32)).collect() + } + + #[test] + fn version_2_storage_types_and_gamma() { + let order = ByteOrder::Little; + let v2 = |dt: u32, gamma: f32, data: &[u8]| { + GainTableMap::parse(&tag(order, [1, 1, 4], [1.0, 1.0], [0.0, 0.0], GREEN, Some((dt, gamma, 0.5, 2.5)), data), order, true) + }; + // u8: 0 → GainMin, 255 → GainMax + let m = v2(0, 1.0, &[0, 51, 255, 255]).unwrap(); + let want = [0.5, 0.9, 2.5, 2.5]; + assert!(m.gains.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-6), "{:?}", m.gains); + // u16 + let m = v2(1, 1.0, &[0, 0, 0xff, 0xff, 0, 0, 0xff, 0xff]).unwrap(); + assert_eq!(m.gains, vec![0.5, 2.5, 0.5, 2.5]); + // f16 ignores GainMin/GainMax: 1.0, 2.0, 0.5, 0 + let m = v2(2, 1.0, &[0x00, 0x3c, 0x00, 0x40, 0x00, 0x38, 0, 0]).unwrap(); + assert_eq!(m.gains, vec![1.0, 2.0, 0.5, 0.0]); + // f32 with gamma 2 + let m = v2(3, 2.0, &floats(order, &[1.0, 2.0, 3.0, 4.0])).unwrap(); + assert_eq!((m.gains.clone(), m.gamma), (vec![1.0, 2.0, 3.0, 4.0], 2.0)); + // unknown data type, gamma out of range, size for another type: ignored + assert!(v2(4, 1.0, &[0; 4]).is_none()); + assert!(v2(0, 0.1, &[0; 4]).is_none()); + assert!(v2(0, 5.0, &[0; 4]).is_none()); + assert!(v2(1, 1.0, &[0; 4]).is_none()); + // negative integer range + let neg = tag(order, [1, 1, 4], [1.0, 1.0], [0.0, 0.0], GREEN, Some((0, 1.0, -1.0, 1.0)), &[0; 4]); + assert!(GainTableMap::parse(&neg, order, true).is_none()); + } + + #[test] + fn malformed_tags_are_rejected() { + let order = ByteOrder::Big; + let good = tag(order, [2, 2, 4], [0.5, 0.5], [0.25, 0.25], GREEN, None, &floats(order, &grid())); + assert!(GainTableMap::parse(&good, order, false).is_some()); + // every truncation (and an extra byte) + for n in 0..good.len() { + assert!(GainTableMap::parse(&good[..n], order, false).is_none(), "{n} bytes"); + } + let mut long = good.clone(); + long.push(0); + assert!(GainTableMap::parse(&long, order, false).is_none()); + // the wrong byte order reads absurd dimensions + assert!(GainTableMap::parse_in(&good, ByteOrder::Little, false).is_none()); + // read as version 2 the header is too short for the data + assert!(GainTableMap::parse(&good, order, true).is_none()); + let bad = |dims: [u32; 3], spacing: [f64; 2], origin: [f64; 2], gains: &[f32]| { + GainTableMap::parse(&tag(order, dims, spacing, origin, GREEN, None, &floats(order, gains)), order, false) + }; + let mut g = grid(); + g[5] = f32::NAN; + assert!(bad([2, 2, 4], [0.5, 0.5], [0.0, 0.0], &g).is_none(), "NaN gain"); + g[5] = -1.0; + assert!(bad([2, 2, 4], [0.5, 0.5], [0.0, 0.0], &g).is_none(), "negative gain"); + g[5] = f32::INFINITY; + assert!(bad([2, 2, 4], [0.5, 0.5], [0.0, 0.0], &g).is_none(), "infinite gain"); + assert!(bad([2, 2, 4], [0.0, 0.5], [0.0, 0.0], &grid()).is_none(), "zero spacing"); + assert!(bad([2, 2, 4], [0.5, f64::NAN], [0.0, 0.0], &grid()).is_none(), "NaN spacing"); + assert!(bad([2, 2, 4], [0.5, 0.5], [f64::INFINITY, 0.0], &grid()).is_none(), "infinite origin"); + assert!(bad([0, 2, 4], [0.5, 0.5], [0.0, 0.0], &[]).is_none(), "no rows"); + assert!(bad([1, 1, 0], [0.5, 0.5], [0.0, 0.0], &[]).is_none(), "no points"); + // huge dimensions (whose product overflows or exceeds the cap) are rejected before allocating + assert!(bad([u32::MAX, u32::MAX, u32::MAX], [0.5, 0.5], [0.0, 0.0], &grid()).is_none()); + assert!(bad([4096, 4096, 1], [0.5, 0.5], [0.0, 0.0], &grid()).is_none()); + assert!(bad([1, 1, u32::MAX], [0.5, 0.5], [0.0, 0.0], &grid()).is_none()); + // a single table needs no spacing + assert!(bad([1, 1, 2], [0.0, 0.0], [0.0, 0.0], &[1.0, 2.0]).is_some()); + let mut w = GREEN; + w[3] = f32::NAN; + assert!(GainTableMap::parse(&tag(order, [1, 1, 1], [1.0, 1.0], [0.0, 0.0], w, None, &floats(order, &[1.0])), order, false).is_none()); + } + + /// The payload is read in the file's byte order, else big-endian; a little-endian payload in + /// a big-endian file is malformed. + #[test] + fn byte_order_is_the_files_with_a_big_endian_fallback() { + use ByteOrder::{Big, Little}; + let v1 = |o: ByteOrder| tag(o, [2, 2, 4], [0.5, 0.5], [0.25, 0.25], GREEN, None, &floats(o, &grid())); + let v2 = |o: ByteOrder| { + let mut gains = Vec::new(); + for q in [0u16, 0x1234, 0xffff, 0x8000] { + gains.extend_from_slice(&if o == Big { q.to_be_bytes() } else { q.to_le_bytes() }); + } + tag(o, [1, 1, 4], [1.0, 1.0], [0.0, 0.0], GREEN, Some((1, 2.0, 0.5, 2.5)), &gains) + }; + let (want1, want2) = (GainTableMap::parse_in(&v1(Big), Big, false).unwrap(), GainTableMap::parse_in(&v2(Big), Big, true).unwrap()); + assert_eq!(want1.gains, grid()); + assert_eq!((want2.gains[0], want2.gains[2], want2.gamma), (0.5, 2.5, 2.0)); + // in the file's order + assert_eq!(GainTableMap::parse(&v1(Little), Little, false).as_ref(), Some(&want1)); + assert_eq!(GainTableMap::parse(&v2(Little), Little, true).as_ref(), Some(&want2)); + assert_eq!(GainTableMap::parse(&v1(Big), Big, false).as_ref(), Some(&want1)); + // a big-endian payload in a little-endian file: the fallback + assert_eq!(GainTableMap::parse(&v1(Big), Little, false).as_ref(), Some(&want1)); + assert_eq!(GainTableMap::parse(&v2(Big), Little, true).as_ref(), Some(&want2)); + // no little-endian fallback in a big-endian file + assert!(GainTableMap::parse(&v1(Little), Big, false).is_none()); + assert!(GainTableMap::parse(&v2(Little), Big, true).is_none()); + // whatever the grid, a read in the wrong order fails the size checks + for dims in [[1, 1, 1], [6, 8, 257], [48, 64, 2], [255, 16, 1], [256, 16, 1], [1, 4096, 1]] { + for (o, wrong) in [(Big, Little), (Little, Big)] { + let g = vec![1.0; dims.iter().product::() as usize]; + let b = tag(o, dims, [0.5, 0.5], [0.0, 0.0], GREEN, None, &floats(o, &g)); + assert!(GainTableMap::parse_in(&b, o, false).is_some(), "{dims:?}"); + assert!(GainTableMap::parse_in(&b, wrong, false).is_none(), "{dims:?} read {wrong:?}"); + } + } + } + + #[test] + fn writes_what_it_reads() { + for order in [ByteOrder::Big, ByteOrder::Little] { + let m = + GainTableMap::parse(&tag(order, [2, 2, 4], [0.5, 0.5], [0.25, 0.25], GREEN, None, &floats(order, &grid())), order, false).unwrap(); + let (v2, b) = m.to_bytes(order); + assert!(!v2); + assert_eq!(GainTableMap::parse(&b, order, false).unwrap(), m); + let g = GainTableMap { gamma: 2.0, ..m }; + let (v2, b) = g.to_bytes(order); + assert!(v2); + assert_eq!(GainTableMap::parse(&b, order, true).unwrap(), g); + } + } +} diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 895dfde6..b9bc029a 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -9,7 +9,7 @@ //! produces the same at 1/k of the size straight from the mosaic (previews, thumbnails). //! - [`color`] implements the DNG colour model (dual-illuminant interpolation, forward matrices, white balance) //! and produces camera → linear Rec.2020 D65 matrices; [`profile`] reads and applies a DNG's own profile -//! look tables and tone curve. +//! look tables and tone curve, and [`gaintable`] its gain table map (Apple ProRAW's local tone mapping). //! //! Formats: DNG (uncompressed, lossless JPEG, lossy JPEG (Smart Previews), Deflate incl. floating point, JPEG XL (DNG 1.7, //! `jxl` feature, on by default), tiled/stripped, CFA and LinearRaw), @@ -28,6 +28,7 @@ pub mod color; pub mod demosaic; mod dng; pub mod dngwrite; +pub mod gaintable; pub mod highlight; #[cfg(feature = "jxl")] mod jxl; @@ -513,7 +514,8 @@ pub struct ColorData { /// EV to add for a "normal" rendering (`BaselineExposure` + `BaselineExposureOffset`). pub baseline_exposure: f64, /// The file's own camera-profile look (`ProfileHueSatMap*`, `ProfileLookTable*`, - /// `ProfileToneCurve`), applied by [`color`]'s users at render time. + /// `ProfileToneCurve`), applied by [`color`]'s users at render time, and its + /// `ProfileGainTableMap*`, kept (a DNG export writes it back) but not rendered. #[serde(default)] pub profile: profile::ProfileLook, } diff --git a/crates/raw/src/profile.rs b/crates/raw/src/profile.rs index 8b9761ab..a21444c4 100644 --- a/crates/raw/src/profile.rs +++ b/crates/raw/src/profile.rs @@ -1,5 +1,6 @@ //! DNG camera-profile look data carried by the file itself (Adobe DNG Specification 1.6/1.7, -//! chapter 6 and the `ProfileHueSatMap*`, `ProfileLookTable*` and `ProfileToneCurve` tags). +//! chapter 6 and the `ProfileHueSatMap*`, `ProfileLookTable*`, `ProfileToneCurve` and +//! `ProfileGainTableMap*` tags). //! //! - A hue/saturation/value table holds, for a grid of (value, hue, saturation) inputs, a hue shift //! in degrees, a saturation scale and a value scale (value-major, hue-middle, saturation-minor). @@ -10,11 +11,17 @@ //! - `ProfileHueSatMapData1/2` follow the colour matrices (interpolated by the same illuminant //! weight); `ProfileLookTableData` comes after exposure compensation and before any tone curve. //! - `ProfileToneCurve`: (input, output) pairs in linear gamma from (0, 0) to (1, 1). +//! - `ProfileGainTableMap*` ([`crate::gaintable`]) is read and kept (a DNG export writes it back) +//! but not rendered: Lightroom Classic renders Apple ProRAW without it (measured on the iPhone +//! 12 Pro corpus file against Lightroom Classic 15.6: with the map the default render is far +//! brighter than Lightroom's, mean ΔE00 19; without it Lightroom's output is a tight function of +//! the hue/saturation map, exposure compensation and tone curve alone). //! //! The data is read from the user's own file at run time; nothing here ships profile data. //! Values above 1.0 (highlight headroom) keep their headroom: the table's value scale only ever //! lowers them, so a lookup can't clip what the scene-referred pipeline still needs. +use crate::gaintable::GainTableMap; use lightcraft_color::transfer::{linear_to_srgb, srgb_to_linear}; use lightcraft_color::{D50, D65, Mat3, PROPHOTO, REC2020, bradford}; use serde::{Deserialize, Serialize}; @@ -207,11 +214,14 @@ pub struct ProfileLook { pub look_table: Option, /// `ProfileToneCurve`. pub tone_curve: Option, + /// `ProfileGainTableMap2`, else `ProfileGainTableMap`. + #[serde(default)] + pub gain_table_map: Option, } impl ProfileLook { pub fn is_empty(&self) -> bool { - self.hue_sat_map.iter().all(Option::is_none) && self.look_table.is_none() && self.tone_curve.is_none() + self.hue_sat_map.iter().all(Option::is_none) && self.look_table.is_none() && self.tone_curve.is_none() && self.gain_table_map.is_none() } /// The hue/saturation map for calibration-1 weight `g` (one table: used for any white). @@ -360,7 +370,7 @@ mod tests { let m = HsvTable::blend(&a, &b, 0.25).unwrap(); assert_eq!(m.data[1], [7.5, 1.75, 1.0]); assert!(HsvTable::blend(&a, &identity(3, 2, 1), 0.5).is_none()); - let look = ProfileLook { hue_sat_map: [Some(a.clone()), None], look_table: Some(a), tone_curve: None }; + let look = ProfileLook { hue_sat_map: [Some(a.clone()), None], look_table: Some(a), tone_curve: None, gain_table_map: None }; let t = ProfileTables::new(&look, 0.5).unwrap(); let c = [0.3, 0.2, 0.1]; assert!(close(t.apply(c, 2.0), [0.6, 0.4, 0.2], 1e-4), "{:?}", t.apply(c, 2.0)); diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/corpus.rs index da93f36d..683381c4 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -252,9 +252,11 @@ fn corpus_nef_12_bit_black_level_matches_14_bit() { } /// Issue #138: DNGs converted by Adobe software carry their camera profile's hue/saturation map and -/// look table; we read them (and render with them). Camera-written DNGs here carry none. +/// look table; we read them (and render with them). Apple ProRAW carries a tone curve and a gain +/// table map (its local tone mapping, read and kept but not rendered, as in Lightroom Classic). +/// Other camera-written DNGs here carry none. #[test] -fn corpus_adobe_dngs_carry_profile_looks() { +fn corpus_dngs_carry_profile_looks() { let dir = corpus_root().join("raw"); let Ok(rd) = std::fs::read_dir(&dir) else { eprintln!("skip: {} absent", dir.display()); @@ -278,14 +280,24 @@ fn corpus_adobe_dngs_carry_profile_looks() { let g = t.apply([0.18; 3], 1.0); assert!(g.iter().all(|v| (v - g[0]).abs() < 0.01 * g[0].max(0.01)), "{name}: grey → {g:?}"); } + if name.starts_with("dng-apple-") { + seen += 1; + let map = look.gain_table_map.as_ref().unwrap_or_else(|| panic!("{name}: no gain table map")); + assert!(map.points_v > 1 && map.points_h > 1 && map.points_n > 1, "{name}"); + assert!(look.tone_curve.is_some(), "{name}: no tone curve"); + // the map lifts dark tones (Apple's local tone mapping), most at the darkest input + let first = &map.gains[..map.points_n]; + assert!(first[0] > 1.5 && first[0] >= first[map.points_n - 1], "{name}: first table {first:?}"); + } eprintln!( - "{name:44} profile look: hsm {} look {} tone {}", + "{name:44} profile look: hsm {} look {} tone {} gain map {}", look.hue_sat_map[0].is_some(), look.look_table.is_some(), - look.tone_curve.is_some() + look.tone_curve.is_some(), + look.gain_table_map.is_some() ); } - eprintln!("{seen} Adobe-converted DNGs checked"); + eprintln!("{seen} DNGs with profile looks checked"); } /// Issue #148: Sony ARWs from before ~2017 carry no plain white-balance, black-level or crop tags in the raw IFD. diff --git a/crates/raw/tests/dng.rs b/crates/raw/tests/dng.rs index bc3a9e4d..9cbbb0ea 100644 --- a/crates/raw/tests/dng.rs +++ b/crates/raw/tests/dng.rs @@ -707,3 +707,120 @@ fn jpeg_xl_preview_ifd_is_offered() { assert_eq!(embedded_preview(&bytes).unwrap(), preview); assert_eq!(decode(&bytes).unwrap().data, raw.data); } + +/// `ProfileGainTableMap` / `ProfileGainTableMap2` bytes in `order`: a `dims` = (V, H, N) grid with +/// spacing 0.5 and origin 0.25, green-only input weights, `v2` = (data type, gamma, gain min, +/// gain max), then `data` (the stored gains, already in `order`). +fn gain_map_tag(order: ByteOrder, dims: [u32; 3], v2: Option<(u32, f32, f32, f32)>, data: &[u8]) -> Vec { + let be = order == ByteOrder::Big; + let u = |v: u32| if be { v.to_be_bytes() } else { v.to_le_bytes() }; + let mut b = Vec::new(); + b.extend_from_slice(&u(dims[0])); + b.extend_from_slice(&u(dims[1])); + for v in [0.5f64, 0.5, 0.25, 0.25] { + b.extend_from_slice(&if be { v.to_be_bytes() } else { v.to_le_bytes() }); + } + b.extend_from_slice(&u(dims[2])); + for w in [0.0f32, 1.0, 0.0, 0.0, 0.0] { + b.extend_from_slice(&u(w.to_bits())); + } + if let Some((dt, gamma, min, max)) = v2 { + for v in [dt, gamma.to_bits(), min.to_bits(), max.to_bits()] { + b.extend_from_slice(&u(v)); + } + } + b.extend_from_slice(data); + b +} + +fn f32_bytes(v: &[f32], order: ByteOrder) -> Vec { + v.iter().flat_map(|f| if order == ByteOrder::Big { f.to_be_bytes() } else { f.to_le_bytes() }).collect() +} + +/// [`profile_dng`] with binary tags in the raw IFD (where Apple writes `ProfileGainTableMap`) and +/// in IFD 0. +fn gain_map_dng(order: ByteOrder, raw_tags: &[(u16, &[u8])], ifd0_tags: &[(u16, &[u8])]) -> Vec { + let (w, h) = (8, 6); + let px = pattern(w, h, 3, 16); + let mut raw = base_ifd(w, h, 16, 3, false); + raw.set(t::COMPRESSION, Value::Short(vec![1])); + raw.set_image(ImageData::Strips { rows_per_strip: h as u32, strips: vec![u16_bytes(&px, order)] }); + for (tag, b) in raw_tags { + raw.set(*tag, Value::Undefined(b.to_vec())); + } + dng_with(raw, order, |ifd0| { + for (tag, b) in ifd0_tags { + ifd0.set(*tag, Value::Undefined(b.to_vec())); + } + }) +} + +#[test] +fn gain_table_maps_follow_dng_precedence() { + const V1: u16 = t::PROFILE_GAIN_TABLE_MAP; + const V2: u16 = t::PROFILE_GAIN_TABLE_MAP_2; + for order in [ByteOrder::Big, ByteOrder::Little] { + let v1 = gain_map_tag(order, [2, 2, 4], None, &f32_bytes(&(1..=16).map(|i| i as f32 * 0.25).collect::>(), order)); + // Apple's layout: version 1 in the raw IFD, in the file's byte order + let apple = gain_map_dng(order, &[(V1, &v1[..])], &[]); + let img = decode(&apple).unwrap(); + let map = img.color.profile.gain_table_map.clone().expect("gain table map"); + assert_eq!((map.points_v, map.points_h, map.points_n, map.gamma), (2, 2, 4, 1.0)); + assert_eq!((map.spacing_v, map.origin_h, map.weights[1]), (0.5, 0.25, 1.0)); + assert_eq!(map.gains[5], 1.5); + assert_eq!(lightcraft_raw::probe_info(&apple).unwrap().color.profile, img.color.profile); + // our DNG writer keeps it + let again = decode(&write_dng(&img, &DngWriteOptions::default()).unwrap()).unwrap(); + assert_eq!(again.color.profile.gain_table_map.as_ref(), Some(&map)); + // version 1 in IFD 0 (where DNG 1.7 says it was meant to go) is read too + let in_ifd0 = decode(&gain_map_dng(order, &[], &[(V1, &v1[..])])).unwrap(); + assert_eq!(in_ifd0.color.profile.gain_table_map.as_ref(), Some(&map)); + // version 2 in IFD 0 supersedes version 1: 8-bit gains over 0.5..=1.5, gamma 2 + let v2 = gain_map_tag(order, [1, 1, 2], Some((0, 2.0, 0.5, 1.5)), &[0, 255]); + let img = decode(&gain_map_dng(order, &[(V1, &v1[..])], &[(V2, &v2[..])])).unwrap(); + let map2 = img.color.profile.gain_table_map.clone().unwrap(); + assert_eq!((map2.points_n, map2.gamma, map2.gains.clone()), (2, 2.0, vec![0.5, 1.5])); + // a gamma ≠ 1 survives our writer (as version 2) + let again = decode(&write_dng(&img, &DngWriteOptions::default()).unwrap()).unwrap(); + assert_eq!(again.color.profile.gain_table_map, Some(map2)); + // a malformed version 2 (gamma outside 0.25..=4) falls back to version 1 + let bad_v2 = gain_map_tag(order, [1, 1, 2], Some((0, 9.0, 0.5, 1.5)), &[0, 255]); + assert_eq!(decode(&gain_map_dng(order, &[(V1, &v1[..])], &[(V2, &bad_v2[..])])).unwrap().color.profile.gain_table_map, Some(map)); + } +} + +#[test] +fn malformed_gain_table_maps_are_ignored() { + let order = ByteOrder::Little; + let ok = gain_map_tag(order, [1, 1, 2], None, &f32_bytes(&[1.0, 2.0], order)); + let cases: Vec<(&str, Vec, bool)> = vec![ + ("valid", ok.clone(), false), + ("empty", vec![], false), + ("header only", ok[..64].to_vec(), false), + ("one byte short", ok[..ok.len() - 1].to_vec(), false), + ("NaN gain", gain_map_tag(order, [1, 1, 2], None, &f32_bytes(&[1.0, f32::NAN], order)), false), + ("negative gain", gain_map_tag(order, [1, 1, 2], None, &f32_bytes(&[1.0, -2.0], order)), false), + ("huge dimensions", gain_map_tag(order, [u32::MAX, u32::MAX, u32::MAX], None, &f32_bytes(&[1.0, 2.0], order)), false), + ("zero points", gain_map_tag(order, [1, 0, 2], None, &[]), false), + ("unknown data type", gain_map_tag(order, [1, 1, 2], Some((9, 1.0, 0.0, 1.0)), &[0, 0]), true), + ("NaN gain range", gain_map_tag(order, [1, 1, 2], Some((0, 1.0, f32::NAN, 1.0)), &[0, 255]), true), + ]; + for (what, bytes, v2) in cases { + let tag = if v2 { t::PROFILE_GAIN_TABLE_MAP_2 } else { t::PROFILE_GAIN_TABLE_MAP }; + let img = decode(&gain_map_dng(order, &[(tag, &bytes[..])], &[])).unwrap_or_else(|e| panic!("{what}: {e}")); + assert_eq!(img.color.profile.gain_table_map.is_some(), what == "valid", "{what}"); + } +} + +/// The map is read in the file's byte order, else big-endian (`lightcraft_raw::gaintable`): a +/// big-endian payload in a little-endian file is read, a little-endian one in a big-endian file +/// is not. +#[test] +fn gain_table_map_byte_orders() { + use ByteOrder::{Big, Little}; + let payload = |o: ByteOrder| gain_map_tag(o, [1, 1, 2], None, &f32_bytes(&[1.0, 2.0], o)); + for (file, data, read) in [(Little, Little, true), (Little, Big, true), (Big, Big, true), (Big, Little, false)] { + let img = decode(&gain_map_dng(file, &[(t::PROFILE_GAIN_TABLE_MAP, &payload(data)[..])], &[])).unwrap(); + assert_eq!(img.color.profile.gain_table_map.map(|m| m.gains), read.then(|| vec![1.0, 2.0]), "{data:?} payload, {file:?} file"); + } +} diff --git a/crates/tiff/src/tags.rs b/crates/tiff/src/tags.rs index aa24ec87..acdc7669 100644 --- a/crates/tiff/src/tags.rs +++ b/crates/tiff/src/tags.rs @@ -172,6 +172,7 @@ pub const OPCODE_LIST_2: u16 = 51009; pub const OPCODE_LIST_3: u16 = 51022; pub const NOISE_PROFILE: u16 = 51041; pub const DEFAULT_USER_CROP: u16 = 51125; +pub const PROFILE_GAIN_TABLE_MAP: u16 = 52525; pub const CALIBRATION_ILLUMINANT_3: u16 = 52529; pub const CAMERA_CALIBRATION_3: u16 = 52530; pub const COLOR_MATRIX_3: u16 = 52531; @@ -179,6 +180,7 @@ pub const FORWARD_MATRIX_3: u16 = 52532; pub const ILLUMINANT_DATA_1: u16 = 52533; pub const ILLUMINANT_DATA_2: u16 = 52534; pub const ILLUMINANT_DATA_3: u16 = 52535; +pub const PROFILE_GAIN_TABLE_MAP_2: u16 = 52544; // --- Compression codes (TIFF 6.0, DNG, and widely used registrations) pub mod compression { diff --git a/docs/parity.md b/docs/parity.md index 61ec9903..628cd862 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -290,7 +290,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-PROF-ADOBE | Standard raw looks (own equivalents) | P0 | ✅ | `crates/engine/src/presets.rs` (`PROFILES`), `crates/pipeline/src/profiles.rs` | six own looks: Color, Neutral, Vivid, Landscape, Portrait, Monochrome | | LR-PROF-ADAPTIVE | Adaptive profiles | P2 | ⬜ | | | | LR-PROF-CAMERA | Camera-matching looks | P2 | ⬜ | | | -| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2 and Pentax PEF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10), kept with smart previews (see docs/camera-preview-colour.md); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25) (`docs/camera-preview-colour.md`); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today | +| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export but not rendered, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2 and Pentax PEF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10), kept with smart previews (see docs/camera-preview-colour.md); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25) (`docs/camera-preview-colour.md`); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today | | LR-PROF-CREATIVE | Creative profiles (own) | P2 | ✅ | `cmd:develop.profile`, `crates/pipeline/src/profiles.rs` | 16 own looks in Film / Cinematic / Muted / B&W (tone + point-curve fades, colour grading, mixer / B&W mix); scale with `ctl:profile.amount`; sliders untouched | | LR-PROF-LEGACY | Legacy profiles | P2 | ⬜ | | | | LR-PROF-NONRAW | Profiles for non-raw files | P0 | ✅ | `cmd:develop.profile` | same looks apply to JPEG/TIFF | diff --git a/docs/whats-new.md b/docs/whats-new.md index d9d1fde5..f6361b04 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -20,6 +20,8 @@ - JPEG XL compressed DNGs (DNG 1.7) now open: lossless tiles decode sample for sample (checked on synthetic files); lossy tiles decode too, but no real file has checked them yet. A JPEG XL preview stored in the DNG is used like an embedded JPEG. +- Apple ProRAW's gain table map (its local tone mapping, `ProfileGainTableMap`) is now read and kept when a photo is + exported or converted to DNG. It is not applied to the render: Lightroom Classic renders ProRAW without it. - Panasonic and Leica raws (RW2, RWL) are now corrected for lens distortion the way the camera corrects its own JPEG (issue #256): the correction the camera records in the file is applied under Lens Corrections ▸ Enable Profile Corrections, on by default for newly imported photos, with the same framing as the camera's JPEG. At 12 mm the diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 5c6f5d1e..af598509 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -341,6 +341,9 @@ const RAW_SAMPLES: &[(&str, &str)] = &[ "dng-adobe-canon-5d3-lossy.dng", "https://raw.pixls.us/getfile.php/1023/nice/Adobe%20DNG%20Converter%20-%20Canon%20EOS%205D%20Mark%20III%20-%20Lossy%20JPEG%20compression%20%283:2%29.DNG", ), + // Apple ProRAW (iPhone 12 Pro, iOS 14.3): LinearRaw LJ92 tiles, ProfileToneCurve, ProfileGainTableMap, a sky matte. + // sha256 e91e77a4533ed7cce551d83330676ea5c47dd5e55fb38adda7819366afdbdfc2 + ("dng-apple-iphone12pro-proraw.dng", "https://raw.pixls.us/data/Apple/iPhone%2012%20Pro/IMG_1361.DNG"), ( "dng-canon-5d3-14bit-small.dng", "https://raw.pixls.us/getfile.php/2204/nice/Canon%20-%20EOS%205D%20Mark%20III%20-%2014bit%2014bit%20%282.3471882640587%29.dng",