From 71eac70696c81408f2723f87784b27706acb79ec Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 6 Oct 2026 10:36:30 -0400 Subject: [PATCH] Issue #138: DNG: apply the file's own camera-profile look (HueSatMap, LookTable, ToneCurve) Lightroom-converted DNGs carry their camera profile's ProfileHueSatMapData1/2 and ProfileLookTableData (and sometimes ProfileToneCurve). We used only the colour matrices, so such DNGs rendered flat and muted. - raw: new `profile` module reads the tags (DNG spec ch. 6: value-major, hue-middle, saturation-minor tables, wrap-around hue, tri-linear lookups, sRGB value encoding) from the raw IFD or IFD 0; malformed tags are ignored. - engine: the raw loader applies the hue/sat map (interpolated by the same illuminant weight as the matrices) before BaselineExposure and the look table after it, in linear ProPhoto; a ProfileToneCurve becomes the source's camera tone. Without one the standard base tone is kept. - dngwrite keeps the look with the data (conversions, smart previews, merges). - Render cache version 7. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/files.rs | 68 ++++++- crates/engine/src/media.rs | 2 +- crates/merge/src/output.rs | 1 + crates/raw/src/dng.rs | 22 +++ crates/raw/src/dngwrite.rs | 32 +++ crates/raw/src/lib.rs | 8 +- crates/raw/src/profile.rs | 387 +++++++++++++++++++++++++++++++++++++ crates/raw/tests/corpus.rs | 37 ++++ crates/raw/tests/dng.rs | 71 +++++++ crates/tiff/src/tags.rs | 9 + docs/parity.md | 2 +- 11 files changed, 627 insertions(+), 12 deletions(-) create mode 100644 crates/raw/src/profile.rs diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index b4c9f863..f4a57a6f 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -253,13 +253,19 @@ fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result< let m = camera_look.map(|p| p.matrix.mul(&t.matrix)).unwrap_or(t.matrix).to_f32(); let gain = 2f32.powf(t.baseline_exposure as f32); let wb = t.wb; + // A DNG's own profile look (hue/saturation map, look table), DNG spec chapter 6. + let tables = lightcraft_raw::profile::ProfileTables::new(&raw.color.profile, lightcraft_raw::color::illuminant_weight(&raw.color, xy)); img.map_in_place(|p| { let c = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]]; - [ - ((m[0][0] * c[0] + m[0][1] * c[1] + m[0][2] * c[2]) * gain).max(0.0), - ((m[1][0] * c[0] + m[1][1] * c[1] + m[1][2] * c[2]) * gain).max(0.0), - ((m[2][0] * c[0] + m[2][1] * c[1] + m[2][2] * c[2]) * gain).max(0.0), - ] + let rgb = [ + m[0][0] * c[0] + m[0][1] * c[1] + m[0][2] * c[2], + m[1][0] * c[0] + m[1][1] * c[1] + m[1][2] * c[2], + m[2][0] * c[0] + m[2][1] * c[1] + m[2][2] * c[2], + ]; + match &tables { + Some(tables) => tables.apply(rgb, gain).map(|v| v.max(0.0)), + None => rgb.map(|v| (v * gain).max(0.0)), + } }); stages.push(("colour", t0.elapsed())); let img = fit(&img, max_edge, max_edge, Filter::Box); @@ -280,11 +286,9 @@ fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result< } let (temp, tint) = xy_to_temp_tint(xy); let relative = raw.format == lightcraft_raw::RawFormat::Arw && t.matrix_is_fallback; + let camera_tone = camera_look.map(|p| p.tone).or_else(|| raw.color.profile.tone_curve.as_ref().and_then(dng_tone_curve)); let (temp, tint) = if relative { (6500.0, 0.0) } else { (temp.round(), tint.round()) }; - return Ok(( - img, - SourceInfo { raw: true, as_shot_temp: temp, as_shot_tint: tint, lens, relative_wb: relative, camera_tone: camera_look.map(|p| p.tone) }, - )); + return Ok((img, SourceInfo { raw: true, as_shot_temp: temp, as_shot_tint: tint, lens, relative_wb: relative, camera_tone })); } let d = lightcraft_codecs::decode(&bytes, lightcraft_codecs::DecodeOptions::fit(max_edge as u32, max_edge as u32)).map_err(|e| e.to_string())?; drop(bytes); @@ -293,6 +297,17 @@ fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result< Ok((img.into_oriented(Orientation::from_exif(d.orientation)), SourceInfo::default())) } +/// A DNG `ProfileToneCurve` (linear in, linear out, 1.0 = white after exposure compensation) as the +/// finish stage's camera tone curve: 32 knots, log-spaced over 12 stops below white; above white +/// the camera tone's shoulder continues it. +pub(crate) fn dng_tone_curve(curve: &lightcraft_raw::profile::ToneCurve) -> Option { + let knots: [[f32; 2]; 32] = std::array::from_fn(|i| { + let x = 2f32.powf(-12.0 + 12.0 * i as f32 / 31.0); + [x, curve.eval(x).min(0.9995)] + }); + lightcraft_pipeline::tone::CameraTone::new(knots) +} + /// Orientation for an embedded preview: its own EXIF orientation when it has one, else the raw file's. fn preview_orientation(raw_bytes: &[u8], jpeg_orientation: u16) -> Orientation { if jpeg_orientation > 1 { @@ -398,6 +413,41 @@ mod tests { b } + /// Issue #138: a DNG's own profile look (hue/saturation map, look table, tone curve) is + /// applied when it's developed — Lightroom-converted DNGs rendered flat and muted without it. + #[test] + fn dng_profile_look_is_applied() { + use lightcraft_raw::profile::{HsvTable, ProfileLook, ToneCurve}; + let plain = crate::tests_xmp::synthetic_dng_with(None, Default::default()); + let mut raw = lightcraft_raw::decode(&plain).unwrap(); + let sat = |img: &Rgb32f| { + img.data.iter().map(|p| (p[0].max(p[1]).max(p[2]) - p[0].min(p[1]).min(p[2])) / p[0].max(p[1]).max(p[2]).max(1e-6)).sum::() + / img.data.len() as f32 + }; + let (before, info) = load_bytes(&plain, 64).unwrap(); + assert!(sat(&before) > 0.05, "the synthetic scene is coloured"); + 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]) }; + 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)); + // a saturation-only map keeps brightness roughly (HSV value is kept in ProPhoto RGB) + let mean = |img: &Rgb32f| img.data.iter().map(|p| p[0].max(p[1]).max(p[2])).sum::() / img.data.len() as f32; + assert!((0.8..1.1).contains(&(mean(&after) / mean(&before))), "{} vs {}", mean(&after), mean(&before)); + let tone = info.camera_tone.expect("the DNG tone curve becomes the camera tone"); + assert!((tone.apply(0.18) - 0.3).abs() < 0.01, "{}", tone.apply(0.18)); + // a look table alone changes the render too (applied after exposure) + raw.color.profile = ProfileLook { + look_table: Some(HsvTable { hue_divisions: 1, sat_divisions: 2, val_divisions: 1, data: vec![[0.0, 1.0, 0.5]; 2], srgb_value: false }), + ..Default::default() + }; + let (dim, _) = load_bytes(&lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(), 64).unwrap(); + assert!((mean(&dim) / mean(&before) - 0.5).abs() < 0.02, "{} vs {}", mean(&dim), mean(&before)); + } + #[test] fn unsupported_raw_falls_back_to_embedded_preview() { let b = cr3_with_preview(48, 32); diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 7fd8a596..f2f1028d 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -26,7 +26,7 @@ use lightcraft_raster::{Histogram, Rgb32f, Rgba8}; use serde::{Deserialize, Serialize}; /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 6; +pub const RENDER_CACHE_VERSION: u64 = 7; /// 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/merge/src/output.rs b/crates/merge/src/output.rs index 3d0bbfe5..442fa2ee 100644 --- a/crates/merge/src/output.rs +++ b/crates/merge/src/output.rs @@ -45,6 +45,7 @@ pub fn color_data(color: &FrameColor) -> ColorData { as_shot_neutral: Some([1.0, 1.0, 1.0]), as_shot_white_xy: None, baseline_exposure: 0.0, + profile: Default::default(), } } } diff --git a/crates/raw/src/dng.rs b/crates/raw/src/dng.rs index a7ef1987..1e80c0b3 100644 --- a/crates/raw/src/dng.rs +++ b/crates/raw/src/dng.rs @@ -1,6 +1,7 @@ //! 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::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; @@ -45,6 +46,27 @@ 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), + } +} + +/// 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 { + let pick = |tag: u16| if raw.contains(tag) { raw } else { ifd0 }; + let table = |dims: u16, data: u16, enc: u16| { + let ifd = pick(dims); + let dims = ifd.u64s(dims)?; + let data = ifd.f64s(data)?; + HsvTable::from_tags(&dims, &data, ifd.u64s(enc).and_then(|v| v.first().copied()).unwrap_or(0)) + }; + ProfileLook { + hue_sat_map: [ + table(t::PROFILE_HUE_SAT_MAP_DIMS, t::PROFILE_HUE_SAT_MAP_DATA_1, t::PROFILE_HUE_SAT_MAP_ENCODING), + table(t::PROFILE_HUE_SAT_MAP_DIMS, t::PROFILE_HUE_SAT_MAP_DATA_2, t::PROFILE_HUE_SAT_MAP_ENCODING), + ], + 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)), } } diff --git a/crates/raw/src/dngwrite.rs b/crates/raw/src/dngwrite.rs index ac8ce799..3df8d706 100644 --- a/crates/raw/src/dngwrite.rs +++ b/crates/raw/src/dngwrite.rs @@ -179,6 +179,38 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result> { ifd.set(t::AS_SHOT_WHITE_XY, rat_vec(&[xy.x, xy.y])); } ifd.set(t::BASELINE_EXPOSURE, Value::SRational(vec![srational(c.baseline_exposure)])); + // the source's own profile look travels with its data (conversions, smart previews, merges) + let p = &c.profile; + let table_tags = |ifd: &mut IfdBuilder, table: &crate::profile::HsvTable, dims: u16, enc: u16| { + ifd.set(dims, Value::Long(vec![table.hue_divisions as u32, table.sat_divisions as u32, table.val_divisions as u32])); + if table.srgb_value { + ifd.set(enc, Value::Long(vec![1])); + } + }; + let flat = |table: &crate::profile::HsvTable| Value::Float(table.data.iter().flatten().copied().collect()); + match &p.hue_sat_map { + [Some(a), b] => { + table_tags(&mut ifd, a, t::PROFILE_HUE_SAT_MAP_DIMS, t::PROFILE_HUE_SAT_MAP_ENCODING); + ifd.set(t::PROFILE_HUE_SAT_MAP_DATA_1, flat(a)); + if let Some(b) = + b.as_ref().filter(|b| (b.hue_divisions, b.sat_divisions, b.val_divisions) == (a.hue_divisions, a.sat_divisions, a.val_divisions)) + { + ifd.set(t::PROFILE_HUE_SAT_MAP_DATA_2, flat(b)); + } + } + [None, Some(b)] => { + table_tags(&mut ifd, b, t::PROFILE_HUE_SAT_MAP_DIMS, t::PROFILE_HUE_SAT_MAP_ENCODING); + ifd.set(t::PROFILE_HUE_SAT_MAP_DATA_1, flat(b)); + } + [None, None] => {} + } + if let Some(l) = &p.look_table { + table_tags(&mut ifd, l, t::PROFILE_LOOK_TABLE_DIMS, t::PROFILE_LOOK_TABLE_ENCODING); + ifd.set(t::PROFILE_LOOK_TABLE_DATA, flat(l)); + } + if let Some(curve) = &p.tone_curve { + ifd.set(t::PROFILE_TONE_CURVE, Value::Float(curve.points.iter().flatten().copied().collect())); + } 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/lib.rs b/crates/raw/src/lib.rs index 29e280b6..d4791e7f 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -8,7 +8,8 @@ //! [`RawImage::develop`] does all of it plus `OpcodeList3` and the default crop; [`RawImage::develop_binned`] //! 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. +//! and produces camera → linear Rec.2020 D65 matrices; [`profile`] reads and applies a DNG's own profile +//! look tables and tone curve. //! //! Formats: DNG (uncompressed, lossless JPEG, lossy JPEG (Smart Previews), Deflate incl. floating point, tiled/stripped, CFA and LinearRaw), //! Canon CR2, Nikon NEF/NRW (uncompressed, Huffman lossless / lossy compressed), Sony ARW (uncompressed, ARW2, lossless), Fujifilm RAF (uncompressed Bayer @@ -29,6 +30,7 @@ pub mod highlight; pub mod ljpeg; pub mod opcodes; mod preview; +pub mod profile; mod tiffraw; mod unpack; mod vendor; @@ -393,6 +395,10 @@ pub struct ColorData { pub as_shot_white_xy: Option, /// 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. + #[serde(default)] + pub profile: profile::ProfileLook, } /// A decoded raw image. diff --git a/crates/raw/src/profile.rs b/crates/raw/src/profile.rs new file mode 100644 index 00000000..8b9761ab --- /dev/null +++ b/crates/raw/src/profile.rs @@ -0,0 +1,387 @@ +//! 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). +//! +//! - 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). +//! Hue samples are spread evenly around the circle with wrap-around, saturation and value samples +//! evenly over 0..=1; a dimension with one division is constant. Lookups are tri-linear. +//! - Tables are applied in HSV of linear ProPhoto RGB (D50). With encoding 1 ("sRGB") the value +//! axis is indexed through the sRGB transfer curve (3D tables only). +//! - `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). +//! +//! 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 lightcraft_color::transfer::{linear_to_srgb, srgb_to_linear}; +use lightcraft_color::{D50, D65, Mat3, PROPHOTO, REC2020, bradford}; +use serde::{Deserialize, Serialize}; + +/// Upper bound on table entries (Adobe profiles use ≤ 90 × 30 × 16; guards header-driven sizes). +const MAX_ENTRIES: usize = 1 << 20; + +/// A DNG hue/saturation/value mapping table (`ProfileHueSatMapData*` / `ProfileLookTableData`). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct HsvTable { + pub hue_divisions: usize, + pub sat_divisions: usize, + pub val_divisions: usize, + /// `(hue shift °, saturation scale, value scale)`, value-major, hue-middle, saturation-minor. + pub data: Vec<[f32; 3]>, + /// The value axis is indexed through the sRGB encoding curve (`…Encoding` = 1). + pub srgb_value: bool, +} + +impl HsvTable { + /// A table from its `…Dims` (hue, saturation, value divisions), data floats and encoding. + /// `None` when the dimensions are out of range, the data length doesn't match or a value is + /// not finite. + pub fn from_tags(dims: &[u64], data: &[f64], encoding: u64) -> Option { + let [h, s, v] = <[u64; 3]>::try_from(dims).ok()?; + let (h, s, v) = (usize::try_from(h).ok()?, usize::try_from(s).ok()?, usize::try_from(v).ok()?); + if h < 1 || s < 2 || v < 1 || h > 4096 || s > 4096 || v > 4096 { + return None; + } + let n = h.checked_mul(s)?.checked_mul(v)?; + if n > MAX_ENTRIES || data.len() != n * 3 || data.iter().any(|x| !x.is_finite()) { + return None; + } + let data = data.as_chunks::<3>().0.iter().map(|c| [c[0] as f32, (c[1] as f32).max(0.0), (c[2] as f32).max(0.0)]).collect(); + Some(HsvTable { hue_divisions: h, sat_divisions: s, val_divisions: v, data, srgb_value: encoding == 1 && v > 1 }) + } + + /// The entry-wise blend `g · a + (1 − g) · b` of two tables of the same shape (`None` otherwise). + pub fn blend(a: &HsvTable, b: &HsvTable, g: f64) -> Option { + if (a.hue_divisions, a.sat_divisions, a.val_divisions) != (b.hue_divisions, b.sat_divisions, b.val_divisions) { + return None; + } + let g = g.clamp(0.0, 1.0) as f32; + let data = a.data.iter().zip(&b.data).map(|(x, y)| std::array::from_fn(|i| g * x[i] + (1.0 - g) * y[i])).collect(); + Some(HsvTable { data, ..a.clone() }) + } + + fn entry(&self, v: usize, h: usize, s: usize) -> [f32; 3] { + self.data.get((v * self.hue_divisions + h) * self.sat_divisions + s).copied().unwrap_or([0.0, 1.0, 1.0]) + } + + /// Tri-linear lookup at hue `h` (degrees), saturation `s` and value `v` (table coordinates). + pub fn lookup(&self, h: f32, s: f32, v: f32) -> [f32; 3] { + let hf = (h.rem_euclid(360.0) / 360.0 * self.hue_divisions as f32).clamp(0.0, self.hue_divisions as f32); + let h0 = (hf as usize).min(self.hue_divisions - 1); + let h1 = (h0 + 1) % self.hue_divisions; + let th = (hf - h0 as f32).clamp(0.0, 1.0); + let axis = |x: f32, n: usize| { + let f = x.clamp(0.0, 1.0) * (n - 1) as f32; + let i0 = (f as usize).min(n.saturating_sub(2)); + let i1 = (i0 + 1).min(n - 1); + (i0, i1, (f - i0 as f32).clamp(0.0, 1.0)) + }; + let (s0, s1, ts) = axis(s, self.sat_divisions); + let (v0, v1, tv) = if self.val_divisions > 1 { axis(v, self.val_divisions) } else { (0, 0, 0.0) }; + let lerp = |a: [f32; 3], b: [f32; 3], t: f32| -> [f32; 3] { std::array::from_fn(|i| a[i] + (b[i] - a[i]) * t) }; + let plane = |vi: usize| { + let lo = lerp(self.entry(vi, h0, s0), self.entry(vi, h0, s1), ts); + let hi = lerp(self.entry(vi, h1, s0), self.entry(vi, h1, s1), ts); + lerp(lo, hi, th) + }; + if v0 == v1 { plane(v0) } else { lerp(plane(v0), plane(v1), tv) } + } + + /// Apply the table to one linear ProPhoto RGB colour. + pub fn apply(&self, rgb: [f32; 3]) -> [f32; 3] { + let max = rgb[0].max(rgb[1]).max(rgb[2]); + if max.is_nan() || max <= 0.0 || !max.is_finite() { + return rgb; + } + let min = rgb[0].min(rgb[1]).min(rgb[2]); + let d = max - min; + let hue = hue_of(rgb, max, d); + let sat = d / max; + let v_index = if self.srgb_value { linear_to_srgb(max.min(1.0)) } else { max }; + let [shift, ss, vs] = self.lookup(hue, sat, v_index); + // saturation and value: scaled and clipped to 1.0 (DNG chapter 6), except that inputs + // already above 1 (out-of-ProPhoto colours, highlight headroom) are only ever lowered. + let s2 = limited(sat, ss); + let v2 = if self.srgb_value && max <= 1.0 { + srgb_to_linear((linear_to_srgb(max) * vs).min(1.0)) + } else if self.srgb_value { + max * srgb_to_linear(vs.min(1.0)).min(1.0) + } else { + limited(max, vs) + }; + hsv_to_rgb(hue + shift, s2, v2) + } +} + +/// `x · k` clipped to 1, for `x ≤ 1`; above 1, only scale factors below 1 apply (continuous at 1). +fn limited(x: f32, k: f32) -> f32 { + if x <= 1.0 { (x * k).min(1.0) } else { x * k.min(1.0) } +} + +fn hue_of(c: [f32; 3], max: f32, d: f32) -> f32 { + if d <= 0.0 { + return 0.0; + } + let h = if max == c[0] { + ((c[1] - c[2]) / d).rem_euclid(6.0) + } else if max == c[1] { + (c[2] - c[0]) / d + 2.0 + } else { + (c[0] - c[1]) / d + 4.0 + }; + h * 60.0 +} + +/// HSV → RGB for any saturation ≥ 0 (above 1 yields negative components, the inverse of +/// [`hue_of`] for out-of-gamut inputs). +fn hsv_to_rgb(h: f32, s: f32, v: f32) -> [f32; 3] { + let hp = h.rem_euclid(360.0) / 60.0; + let sector = (hp as u32).min(5); + let f = hp - sector as f32; + let (p, q, t) = (v * (1.0 - s), v * (1.0 - s * f), v * (1.0 - s * (1.0 - f))); + match sector { + 0 => [v, t, p], + 1 => [q, v, p], + 2 => [p, v, t], + 3 => [p, q, v], + 4 => [t, p, v], + _ => [v, p, q], + } +} + +/// A DNG tone curve (`ProfileToneCurve`): linear-gamma (input, output) pairs, increasing input. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ToneCurve { + pub points: Vec<[f32; 2]>, +} + +impl ToneCurve { + /// From the tag's flat float pairs. `None` unless there are 2..=65536 finite pairs within + /// 0..=1 with strictly increasing inputs. + pub fn from_tag(v: &[f64]) -> Option { + if !v.len().is_multiple_of(2) || v.len() < 4 || v.len() > 131_072 { + return None; + } + let points: Vec<[f32; 2]> = v.as_chunks::<2>().0.iter().map(|p| [p[0] as f32, p[1] as f32]).collect(); + let ok = points.iter().all(|p| p.iter().all(|x| x.is_finite() && (0.0..=1.0).contains(x))) && points.windows(2).all(|w| w[1][0] > w[0][0]); + ok.then_some(ToneCurve { points }) + } + + /// The curve at `x` (0..=1): monotone cubic (Fritsch–Carlson) through the samples. + pub fn eval(&self, x: f32) -> f32 { + let p = &self.points; + let n = p.len(); + let x = x.clamp(p[0][0], p[n - 1][0]); + let i = p.partition_point(|q| q[0] <= x).clamp(1, n - 1) - 1; + let slope = |k: usize| (p[k + 1][1] - p[k][1]) / (p[k + 1][0] - p[k][0]); + let tangent = |k: usize| -> f32 { + if k == 0 { + return slope(0); + } + if k == n - 1 { + return slope(n - 2); + } + let (a, b) = (slope(k - 1), slope(k)); + if a * b <= 0.0 { 0.0 } else { 2.0 / (1.0 / a + 1.0 / b) } + }; + let (x0, x1) = (p[i][0], p[i + 1][0]); + let h = x1 - x0; + let t = (x - x0) / h; + let (t2, t3) = (t * t, t * t * t); + let y = (2.0 * t3 - 3.0 * t2 + 1.0) * p[i][1] + + (t3 - 2.0 * t2 + t) * h * tangent(i) + + (-2.0 * t3 + 3.0 * t2) * p[i + 1][1] + + (t3 - t2) * h * tangent(i + 1); + y.clamp(0.0, 1.0) + } +} + +/// The DNG profile look tags of a file (all optional). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct ProfileLook { + /// `ProfileHueSatMapData1/2` (same shape, `ProfileHueSatMapDims`). + pub hue_sat_map: [Option; 2], + /// `ProfileLookTableData`. + pub look_table: Option, + /// `ProfileToneCurve`. + pub tone_curve: 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() + } + + /// The hue/saturation map for calibration-1 weight `g` (one table: used for any white). + pub fn hue_sat_for(&self, g: f64) -> Option { + match &self.hue_sat_map { + [Some(a), Some(b)] => HsvTable::blend(a, b, g).or_else(|| Some(a.clone())), + [Some(a), None] | [None, Some(a)] => Some(a.clone()), + [None, None] => None, + } + } +} + +/// The tables resolved for one white balance, applied to linear Rec.2020 D65 pixels. +#[derive(Clone, Debug)] +pub struct ProfileTables { + hue_sat: Option, + look: Option, + to_prophoto: [[f32; 3]; 3], + from_prophoto: [[f32; 3]; 3], +} + +impl ProfileTables { + /// `None` when the profile has no hue/saturation map and no look table. + pub fn new(look: &ProfileLook, illuminant_weight: f64) -> Option { + let hue_sat = look.hue_sat_for(illuminant_weight); + if hue_sat.is_none() && look.look_table.is_none() { + return None; + } + let to: Mat3 = PROPHOTO.from_xyz().mul(&bradford(D65, D50)).mul(&REC2020.to_xyz()); + let from = to.inverse()?; + Some(ProfileTables { hue_sat, look: look.look_table.clone(), to_prophoto: to.to_f32(), from_prophoto: from.to_f32() }) + } + + /// Hue/saturation map, then `gain` (exposure compensation), then the look table: + /// linear Rec.2020 D65 in, linear Rec.2020 D65 out. + #[inline] + pub fn apply(&self, rgb: [f32; 3], gain: f32) -> [f32; 3] { + let m = &self.to_prophoto; + let mut p: [f32; 3] = std::array::from_fn(|i| m[i][0] * rgb[0] + m[i][1] * rgb[1] + m[i][2] * rgb[2]); + if let Some(t) = &self.hue_sat { + p = t.apply(p); + } + p = p.map(|v| v * gain); + if let Some(t) = &self.look { + p = t.apply(p); + } + let m = &self.from_prophoto; + std::array::from_fn(|i| m[i][0] * p[0] + m[i][1] * p[1] + m[i][2] * p[2]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn identity(h: usize, s: usize, v: usize) -> HsvTable { + HsvTable { hue_divisions: h, sat_divisions: s, val_divisions: v, data: vec![[0.0, 1.0, 1.0]; h * s * v], srgb_value: false } + } + + fn close(a: [f32; 3], b: [f32; 3], tol: f32) -> bool { + a.iter().zip(&b).all(|(x, y)| (x - y).abs() <= tol) + } + + #[test] + fn identity_table_is_identity() { + let t = identity(6, 3, 1); + for c in [[0.2, 0.5, 0.1], [0.9, 0.9, 0.9], [0.0, 0.0, 0.4], [3.0, 1.0, 0.5], [0.3, -0.05, 0.2]] { + assert!(close(t.apply(c), c, 1e-5), "{c:?} → {:?}", t.apply(c)); + } + assert_eq!(t.apply([0.0; 3]), [0.0; 3]); + assert_eq!(t.apply([-1.0, -2.0, -0.5]), [-1.0, -2.0, -0.5]); + } + + #[test] + fn parses_tags_and_rejects_bad_shapes() { + let data: Vec = (0..4).flat_map(|_| [0.0, 1.0, 1.0]).collect(); + assert!(HsvTable::from_tags(&[2, 2, 1], &data, 0).is_some()); + assert!(HsvTable::from_tags(&[2, 2], &data, 0).is_none()); + assert!(HsvTable::from_tags(&[2, 1, 2], &data, 0).is_none(), "saturation needs ≥ 2 divisions"); + assert!(HsvTable::from_tags(&[2, 2, 2], &data, 0).is_none(), "length mismatch"); + assert!(HsvTable::from_tags(&[u64::MAX, 2, 2], &data, 0).is_none()); + let mut nan = data.clone(); + nan[1] = f64::NAN; + assert!(HsvTable::from_tags(&[2, 2, 1], &nan, 0).is_none()); + // the sRGB encoding only applies to 3D tables + assert!(!HsvTable::from_tags(&[2, 2, 1], &data, 1).unwrap().srgb_value); + } + + #[test] + fn saturation_scale_follows_the_table_layout() { + // 4 hues (0°, 90°, 180°, 270°), 2 saturations; saturation-minor: entry (h, s) at h·2 + s. + let mut t = identity(4, 2, 1); + t.data[1] = [0.0, 1.5, 1.0]; // hue 0°, saturation 1 → ×1.5 + let red = [0.5, 0.4, 0.4]; // hue 0°, saturation 0.2 + let out = t.apply(red); + // half-way along the saturation axis is ×1.1 (0.2 → 0.22 sat), value kept + let sat = (out[0] - out[1].min(out[2])) / out[0]; + assert!((sat - 0.2 * 1.1).abs() < 1e-4, "{out:?}"); + assert!((out[0] - 0.5).abs() < 1e-6); + // other hues are untouched (hue 180°) + let cyan = [0.3, 0.4, 0.4]; + assert!(close(t.apply(cyan), cyan, 1e-6)); + // saturation clips to 1.0 + assert!(t.apply([1.0, 0.1, 0.1]).iter().all(|v| *v >= -1e-6)); + } + + #[test] + fn hue_wraps_and_value_scales() { + // 2 hues: entries at 0° and 180°, hue shift +20° at 0°, value ×0.5 at 180°. + let mut t = identity(2, 2, 1); + t.data = vec![[20.0, 1.0, 1.0], [20.0, 1.0, 1.0], [0.0, 1.0, 0.5], [0.0, 1.0, 0.5]]; + // 350° sits between the last sample (180°) and the first (0° = 360°), 10/180 from 360 + assert!((t.lookup(350.0, 0.5, 0.5)[0] - 20.0 * (170.0 / 180.0)).abs() < 1e-3); + assert!((t.lookup(180.0, 0.5, 0.5)[2] - 0.5).abs() < 1e-6); + // headroom above 1 only goes down + let bright = [2.0, 2.4, 2.4]; + let out = t.apply(bright); + assert!((out[1] - 1.2).abs() < 1e-5, "{out:?}"); + let mut up = identity(1, 2, 1); + up.data = vec![[0.0, 1.0, 2.0]; 2]; + assert!((up.apply([0.4, 0.4, 0.4])[0] - 0.8).abs() < 1e-6); + assert!((up.apply([0.8, 0.8, 0.8])[0] - 1.0).abs() < 1e-6, "clipped to 1"); + assert!((up.apply([3.0, 3.0, 3.0])[0] - 3.0).abs() < 1e-6, "headroom kept"); + } + + #[test] + fn value_axis_and_srgb_encoding() { + // 1 hue, 2 sats, 2 values: saturation ×2 at value 1 only + let mut t = identity(1, 2, 2); + t.data[3] = [0.0, 2.0, 1.0]; + let sat = |c: [f32; 3]| (c[0] - c[2]) / c[0]; + let dark = [0.25, 0.2, 0.2]; // V 0.25, S 0.2 + // linear: 1/4 of the way → S scale at s=0.2: lerp(1, lerp(1,2,0.2)=1.2, 0.25) = 1.05 + assert!((sat(t.apply(dark)) - 0.2 * 1.05).abs() < 1e-4); + t.srgb_value = true; + let k = linear_to_srgb(0.25); // ≈ 0.537 + let want = 0.2 * (1.0 + 0.2 * k); + assert!((sat(t.apply(dark)) - want).abs() < 1e-4); + } + + #[test] + fn blend_and_tables_round_trip_through_prophoto() { + let a = identity(2, 2, 1); + let mut b = identity(2, 2, 1); + b.data[1] = [10.0, 2.0, 1.0]; + 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 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)); + assert!(ProfileTables::new(&ProfileLook::default(), 0.5).is_none()); + } + + #[test] + fn tone_curve_interpolates_monotonically() { + assert!(ToneCurve::from_tag(&[0.0, 0.0]).is_none()); + assert!(ToneCurve::from_tag(&[0.0, 0.0, 0.5, 0.6, 0.4, 0.7, 1.0, 1.0]).is_none(), "inputs must increase"); + assert!(ToneCurve::from_tag(&[0.0, 0.0, 1.0, 1.5]).is_none()); + let c = ToneCurve::from_tag(&[0.0, 0.0, 0.25, 0.4, 0.5, 0.7, 1.0, 1.0]).unwrap(); + assert_eq!(c.eval(0.0), 0.0); + assert!((c.eval(0.25) - 0.4).abs() < 1e-6); + assert!((c.eval(1.0) - 1.0).abs() < 1e-6); + let mut last = 0.0; + for i in 0..=100 { + let y = c.eval(i as f32 / 100.0); + assert!(y >= last - 1e-6, "monotone at {i}"); + last = y; + } + assert!(c.eval(0.1) > 0.1 && c.eval(0.1) < 0.4); + } +} diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/corpus.rs index 8d9ab89f..de1b28b9 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -193,3 +193,40 @@ fn corpus_nef_compressed_matches_uncompressed() { assert!(hi <= 4095.0 && hi - lo > 500.0, "D7000 block means {lo}..{hi}"); } } + +/// 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. +#[test] +fn corpus_adobe_dngs_carry_profile_looks() { + let dir = corpus_root().join("raw"); + let Ok(rd) = std::fs::read_dir(&dir) else { + eprintln!("skip: {} absent", dir.display()); + return; + }; + let mut seen = 0; + for p in rd.flatten().map(|e| e.path()) { + let name = p.file_name().unwrap().to_string_lossy().to_lowercase(); + if !name.starts_with("dng-") || !name.ends_with(".dng") { + continue; + } + let info = probe_info(&std::fs::read(&p).unwrap()).unwrap_or_else(|e| panic!("{name}: {e}")); + let look = &info.color.profile; + if name.starts_with("dng-adobe-") { + seen += 1; + let hsm = look.hue_sat_map[0].as_ref().unwrap_or_else(|| panic!("{name}: no hue/sat map")); + assert!(hsm.hue_divisions > 1 && hsm.sat_divisions > 1, "{name}"); + assert!(look.look_table.is_some(), "{name}: no look table"); + // a profile applied to a mid grey keeps it (close to) neutral + let t = lightcraft_raw::profile::ProfileTables::new(look, 0.5).unwrap(); + 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:?}"); + } + eprintln!( + "{name:44} profile look: hsm {} look {} tone {}", + look.hue_sat_map[0].is_some(), + look.look_table.is_some(), + look.tone_curve.is_some() + ); + } + eprintln!("{seen} Adobe-converted DNGs checked"); +} diff --git a/crates/raw/tests/dng.rs b/crates/raw/tests/dng.rs index f670401b..5f0aad28 100644 --- a/crates/raw/tests/dng.rs +++ b/crates/raw/tests/dng.rs @@ -182,6 +182,11 @@ fn base_ifd(w: usize, h: usize, bits: u16, cpp: usize, cfa: bool) -> IfdBuilder } fn dng(raw: IfdBuilder, order: ByteOrder) -> Vec { + dng_with(raw, order, |_| {}) +} + +/// [`dng`] with extra IFD 0 tags (where DNG writers put camera-profile tags). +fn dng_with(raw: IfdBuilder, order: ByteOrder, extra: impl FnOnce(&mut IfdBuilder)) -> Vec { let mut ifd0 = IfdBuilder::new(); ifd0.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1])); ifd0.set(t::DNG_VERSION, Value::Byte(vec![1, 6, 0, 0])); @@ -200,6 +205,7 @@ fn dng(raw: IfdBuilder, order: ByteOrder) -> Vec { exif.set(t::ISO_SPEED, Value::Short(vec![200])); ifd0.set_child(t::EXIF_IFD, exif); ifd0.add_sub_ifd(raw); + extra(&mut ifd0); TiffWriter::new(order, false).write(&[ifd0]).unwrap() } @@ -461,3 +467,68 @@ fn rejects_unsupported_and_broken() { raw.set_image(ImageData::Strips { rows_per_strip: 8, strips: vec![vec![0; 128]] }); assert!(decode(&dng(raw, ByteOrder::Little)).is_err()); } + +/// A Lightroom-style DNG: profile look tags in IFD 0, LinearRaw data in a SubIFD (issue #138). +fn profile_dng(order: ByteOrder, extra: impl FnOnce(&mut IfdBuilder)) -> 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)] }); + dng_with(raw, order, extra) +} + +fn hsv_floats(h: usize, s: usize, v: usize, f: impl Fn(usize, usize, usize) -> [f32; 3]) -> Vec { + (0..v).flat_map(|vi| (0..h).flat_map(move |hi| (0..s).map(move |si| (vi, hi, si)))).flat_map(|(vi, hi, si)| f(vi, hi, si)).collect() +} + +#[test] +fn profile_look_tags_are_read_from_ifd0() { + for order in [ByteOrder::Little, ByteOrder::Big] { + let bytes = profile_dng(order, |ifd0| { + ifd0.set(t::PROFILE_HUE_SAT_MAP_DIMS, Value::Long(vec![6, 3, 1])); + ifd0.set(t::PROFILE_HUE_SAT_MAP_DATA_1, Value::Float(hsv_floats(6, 3, 1, |_, h, s| [h as f32, 1.0 + s as f32 * 0.1, 1.0]))); + ifd0.set(t::PROFILE_HUE_SAT_MAP_DATA_2, Value::Float(hsv_floats(6, 3, 1, |_, _, _| [0.0, 1.0, 1.0]))); + ifd0.set(t::PROFILE_LOOK_TABLE_DIMS, Value::Long(vec![4, 2, 3])); + ifd0.set(t::PROFILE_LOOK_TABLE_DATA, Value::Float(hsv_floats(4, 2, 3, |v, _, _| [0.0, 1.0, 1.0 - v as f32 * 0.05]))); + ifd0.set(t::PROFILE_LOOK_TABLE_ENCODING, Value::Long(vec![1])); + ifd0.set(t::PROFILE_TONE_CURVE, Value::Float(vec![0.0, 0.0, 0.25, 0.15, 0.5, 0.55, 1.0, 1.0])); + }); + let img = decode(&bytes).unwrap(); + assert_eq!(img.cpp, 3); + let p = &img.color.profile; + let hsm = p.hue_sat_map[0].as_ref().unwrap(); + assert_eq!((hsm.hue_divisions, hsm.sat_divisions, hsm.val_divisions), (6, 3, 1)); + // value-major, hue-middle, saturation-minor: entry (h = 2, s = 1) is at 2·3 + 1 + assert_eq!(hsm.data[7], [2.0, 1.1, 1.0]); + assert!(p.hue_sat_map[1].is_some()); + let look = p.look_table.as_ref().unwrap(); + assert_eq!((look.hue_divisions, look.sat_divisions, look.val_divisions, look.srgb_value), (4, 2, 3, true)); + assert_eq!(look.data[2 * 4 * 2], [0.0, 1.0, 0.9]); + assert_eq!(p.tone_curve.as_ref().unwrap().points.len(), 4); + // the header-only probe sees the same profile + assert_eq!(lightcraft_raw::probe_info(&bytes).unwrap().color.profile, *p); + // our DNG writer keeps the look with the data (conversions, smart previews) + let again = decode(&write_dng(&img, &DngWriteOptions::default()).unwrap()).unwrap(); + assert_eq!(again.color.profile, *p); + } +} + +#[test] +fn malformed_profile_look_tags_are_ignored() { + let bytes = profile_dng(ByteOrder::Little, |ifd0| { + // data count doesn't match the dimensions + ifd0.set(t::PROFILE_HUE_SAT_MAP_DIMS, Value::Long(vec![6, 3, 1])); + ifd0.set(t::PROFILE_HUE_SAT_MAP_DATA_1, Value::Float(vec![0.0, 1.0, 1.0])); + // one saturation division is not allowed + ifd0.set(t::PROFILE_LOOK_TABLE_DIMS, Value::Long(vec![2, 1, 1])); + ifd0.set(t::PROFILE_LOOK_TABLE_DATA, Value::Float(vec![0.0, 1.0, 1.0, 0.0, 1.0, 1.0])); + // decreasing inputs and a NaN + ifd0.set(t::PROFILE_TONE_CURVE, Value::Float(vec![0.0, 0.0, 0.6, 0.5, 0.4, f32::NAN, 1.0, 1.0])); + }); + let img = decode(&bytes).unwrap(); + assert!(img.color.profile.is_empty(), "{:?}", img.color.profile); + // and without any of the tags there is nothing to apply + let plain = decode(&profile_dng(ByteOrder::Little, |_| {})).unwrap(); + assert!(plain.color.profile.is_empty()); +} diff --git a/crates/tiff/src/tags.rs b/crates/tiff/src/tags.rs index c7b21e34..aa24ec87 100644 --- a/crates/tiff/src/tags.rs +++ b/crates/tiff/src/tags.rs @@ -152,6 +152,15 @@ pub const CAMERA_CALIBRATION_SIGNATURE: u16 = 50931; pub const PROFILE_CALIBRATION_SIGNATURE: u16 = 50932; pub const AS_SHOT_PROFILE_NAME: u16 = 50934; pub const PROFILE_NAME: u16 = 50936; +pub const PROFILE_HUE_SAT_MAP_DIMS: u16 = 50937; +pub const PROFILE_HUE_SAT_MAP_DATA_1: u16 = 50938; +pub const PROFILE_HUE_SAT_MAP_DATA_2: u16 = 50939; +pub const PROFILE_TONE_CURVE: u16 = 50940; +pub const PROFILE_EMBED_POLICY: u16 = 50941; +pub const PROFILE_LOOK_TABLE_DIMS: u16 = 50981; +pub const PROFILE_LOOK_TABLE_DATA: u16 = 50982; +pub const PROFILE_HUE_SAT_MAP_ENCODING: u16 = 51107; +pub const PROFILE_LOOK_TABLE_ENCODING: u16 = 51108; pub const BASELINE_EXPOSURE_OFFSET: u16 = 51109; pub const DEFAULT_BLACK_RENDER: u16 = 51110; pub const FORWARD_MATRIX_1: u16 = 50964; diff --git a/docs/parity.md b/docs/parity.md index cfc61c96..48e1537f 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -274,7 +274,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/engine/src/camera_preview.rs` | DNG files use the colour matrices they carry; Sony ARW gets a guarded file-local fit to its own embedded JPEG — chromaticity matrix + tone curve, relative WB, kept with smart previews (see docs/camera-preview-colour.md); on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; 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/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 gets a guarded file-local fit to its own embedded JPEG — chromaticity matrix + tone curve, relative WB, kept with smart previews (see docs/camera-preview-colour.md); on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; 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 |