diff --git a/crates/develop/src/settings.rs b/crates/develop/src/settings.rs index 5f0db8ab..42011287 100644 --- a/crates/develop/src/settings.rs +++ b/crates/develop/src/settings.rs @@ -88,11 +88,16 @@ pub struct Profile { pub id: String, /// Creative profile amount 0..200 (%). pub amount: f64, + /// Render the raw's own local tone mapping (DNG `ProfileGainTableMap`, Apple ProRAW), as the + /// camera does. Off: rendered as Lightroom Classic does, without it. Omitted when off, so + /// settings without it keep their JSON (and hash). + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub camera_local_tone: bool, } impl Default for Profile { fn default() -> Self { - Self { id: "lc.color".into(), amount: 100.0 } + Self { id: "lc.color".into(), amount: 100.0, camera_local_tone: false } } } diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index 95481a4f..db762f00 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -358,11 +358,22 @@ fn load_bytes_now( Arc::new(lightcraft_pipeline::CameraColor { tags, developed_for: xy }) }); let camera_tone = camera_look.as_ref().map(|p| p.tone).or_else(|| raw.color.profile.tone_curve.as_ref().and_then(dng_tone_curve)); + let local_tone = local_tone(&raw); let (temp, tint) = if relative { (6500.0, 0.0) } else { (temp.round(), tint.round()) }; return Ok(( img, twin, - SourceInfo { raw: true, as_shot_temp: temp, as_shot_tint: tint, lens, relative_wb: relative, camera_color, camera_tone, mattes }, + SourceInfo { + raw: true, + as_shot_temp: temp, + as_shot_tint: tint, + lens, + relative_wb: relative, + camera_color, + camera_tone, + mattes, + local_tone, + }, )); } let d = lightcraft_codecs::decode(&bytes, fit_box(max_edge)).map_err(|e| e.to_string())?; @@ -372,6 +383,18 @@ fn load_bytes_now( Ok((img.into_oriented(Orientation::from_exif(d.orientation)), None, SourceInfo::default())) } +/// The raw's gain table map with where its developed picture (the default crop, oriented) sits in +/// the active area: rendered when the photo's "Camera local tone mapping" option is on. +fn local_tone(raw: &lightcraft_raw::RawImage) -> Option> { + let map = raw.color.profile.gain_table_map.clone()?; + let a = raw.active_area; + let (aw, ah) = (a.width.max(1) as f64, a.height.max(1) as f64); + let c = raw.develop_crop(a.width, a.height); + let rect = [c.x as f64 / aw, c.y as f64 / ah, c.width as f64 / aw, c.height as f64 / ah]; + let placement = lightcraft_raw::gaintable::SourcePlacement { rect, orientation: raw.orientation }; + Some(Arc::new(lightcraft_pipeline::local_tone::LocalTone { map, placement })) +} + /// The semantic masks of a DNG that AI masks understand, over the developed image (default crop, /// oriented like it). Person mattes without a confidently selected pixel are left out (the /// Subject mask then falls back to its heuristic); a sky matte counts even when empty (no sky). @@ -958,6 +981,59 @@ mod tests { assert_eq!(before.data, after.data); } + /// The Profile option "Camera local tone mapping" renders the map by position (in the raw's + /// active area, whatever the photo's orientation), and only when it's on. + #[test] + fn dng_gain_table_map_renders_when_asked() { + use lightcraft_pipeline::{RenderRequest, render}; + use lightcraft_raw::gaintable::GainTableMap; + let plain = crate::tests_xmp::synthetic_dng_with(None, Default::default()); + let mut raw = lightcraft_raw::decode(&plain).unwrap(); + // ×1 at the active area's left edge rising to ×4 at its right edge, whatever the colour + raw.color.profile.gain_table_map = Some(GainTableMap { + points_v: 1, + points_h: 2, + points_n: 1, + spacing_v: 1.0, + spacing_h: 1.0, + origin_v: 0.0, + origin_h: 0.0, + weights: [1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0, 0.0, 0.0], + gamma: 1.0, + gains: vec![1.0, 4.0], + }); + let with_map = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); + let (img, info) = load_bytes(&with_map, 64).unwrap(); + let (_, plain_info) = load_bytes(&plain, 64).unwrap(); + assert!(info.local_tone.is_some() && plain_info.local_tone.is_none()); + let req = RenderRequest::fit(64, 64); + let mut s = lightcraft_develop::DevelopSettings::default(); + // (darker, so that ×4 never reaches white) + s.light.exposure = -3.0; + let off = render(&img, &info, &s, &req).image; + assert_eq!(off.data, render(&img, &plain_info, &s, &req).image.data, "off: the map changes nothing"); + s.profile.camera_local_tone = true; + assert!(s.to_json().to_string().contains("camera_local_tone"), "the option is part of the settings (and their hash)"); + let on = render(&img, &info, &s, &req).image; + // brightness gained, per column: none at the left edge, most at the right + let column = |im: &lightcraft_raster::Rgba8, x: usize| (0..im.height).map(|y| im.data[y * im.width + x][1] as f32).sum::(); + let gain = |x: usize| column(&on, x) / column(&off, x).max(1.0); + let last = on.width - 1; + assert!((gain(0) - 1.0).abs() < 0.08, "left ×{}", gain(0)); + assert!(gain(last) > 1.4 && gain(last) > gain(last / 2) && gain(last / 2) > gain(0), "{} {} {}", gain(0), gain(last / 2), gain(last)); + // the photo turned upside down: the map follows the raw, so the brightened side swaps + s.orientation = lightcraft_geom::Orientation::Rotate180; + s.profile.camera_local_tone = false; + let off = render(&img, &info, &s, &req).image; + s.profile.camera_local_tone = true; + let on = render(&img, &info, &s, &req).image; + let gain = |x: usize| column(&on, x) / column(&off, x).max(1.0); + assert!(gain(0) > 1.4 && (gain(last) - 1.0).abs() < 0.08, "rotated: {} … {}", gain(0), gain(last)); + // a rendered (non-raw) source never gets it + let jpeg = SourceInfo { raw: false, ..info.clone() }; + assert!(!lightcraft_pipeline::local_tone::enabled(&jpeg, &s)); + } + #[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 ce41bfaa..1231221b 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -209,6 +209,7 @@ impl DecodedSource { /// Cache cost: the picture, its segmentation mattes, its denoised twin and room for the lazy mix. pub fn bytes(&self) -> usize { let mattes = self.info.as_ref().and_then(|i| i.mattes.as_ref()).map_or(0, |m| m.bytes()); + let mattes = mattes + self.info.as_ref().and_then(|i| i.local_tone.as_ref()).map_or(0, |t| t.map.gains.len() * 4); self.image.data.len() * 12 + 64 + std::mem::size_of::() + mattes + self.denoised.as_ref().map_or(0, |t| t.bytes()) } diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 7e7ac8a3..4a7c27ce 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -738,8 +738,8 @@ fn linear(cx: &mut Cx<'_>, sampled: &Buf, info: &SourceInfo, plan: &Plan<'_>, ho let (w, h) = (plan.w, plan.h); let n = w * h; let s = &*plan.settings; - let img = if lightcraft_pipeline::lin_needs_cpu(s) { - // defringe / spot removal: CPU + let img = if lightcraft_pipeline::lin_needs_cpu(s, info) { + // defringe / spot removal / local tone mapping: CPU let mut img = match host.sampled.take() { Some(i) => i, None => cx.read_rgb(sampled, w, h), diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 84f5bd98..274bcd61 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -30,6 +30,7 @@ pub mod dust; pub mod finish; pub mod geometry; pub mod local; +pub mod local_tone; pub mod lut; pub mod masks; pub mod optics; @@ -83,6 +84,9 @@ pub struct SourceInfo { pub camera_tone: Option, /// Segmentation mattes stored in the file (DNG semantic masks): AI masks use them. pub mattes: Option>, + /// The raw's own local tone mapping (DNG `ProfileGainTableMap`), rendered only when the + /// photo's Profile option asks for it ([`local_tone`]). + pub local_tone: Option>, } impl Default for SourceInfo { @@ -96,6 +100,7 @@ impl Default for SourceInfo { camera_color: None, camera_tone: None, mattes: None, + local_tone: None, } } } @@ -416,6 +421,8 @@ pub fn plan<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings, req: &R format!("{eyes:?}"), // defringe runs in this stage format!("{:?}", s.optics), + // and the local tone mapping + local_tone::enabled(info, s), [d.nr_luminance, d.nr_detail, d.nr_color, d.nr_color_detail, d.nr_color_smoothness].map(f64::to_bits), src_long, )); @@ -423,16 +430,18 @@ pub fn plan<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings, req: &R } /// Whether the scene-linear stage needs work only the CPU does (defringe, spot removal). -pub fn lin_needs_cpu(s: &DevelopSettings) -> bool { +pub fn lin_needs_cpu(s: &DevelopSettings, info: &SourceInfo) -> bool { let o = &s.optics; let defringe = s.section_enabled("optics") && (o.defringe_purple_amount > 0.0 || o.defringe_green_amount > 0.0); - defringe || !s.spots.is_empty() + defringe || !s.spots.is_empty() || local_tone::enabled(info, s) } /// The white-balanced, defringed, retouched image (before noise reduction): the CPU part of the /// scene-linear stage, in place. pub fn lin_cpu(img: &mut Rgb32f, info: &SourceInfo, p: &Plan<'_>) { let s = &*p.settings; + // (before the white balance: the gain's input is the as-shot development, as in the file) + local_tone::apply(img, info, p); local::white_balance(img, info, s); optics::defringe(img, s, p.px_per_long / optics::DEFRINGE_REF_LONG); spots::apply(img, &s.spots, &p.frame, p.px_per_long); diff --git a/crates/pipeline/src/local_tone.rs b/crates/pipeline/src/local_tone.rs new file mode 100644 index 00000000..c38a25c9 --- /dev/null +++ b/crates/pipeline/src/local_tone.rs @@ -0,0 +1,62 @@ +//! The camera's local tone mapping: a raw's DNG `ProfileGainTableMap` (Apple ProRAW), rendered +//! when the photo's Profile option "Camera local tone mapping" is on. Off by default: Lightroom +//! Classic renders ProRAW without it (see `lightcraft_raw::gaintable`). +//! +//! The map is a per-pixel gain that depends on the pixel's position in the raw's active area and +//! on its colour after the baseline exposure (linear ProPhoto). The source the pipeline gets is +//! exactly that colour (scene-linear Rec.2020 after the baseline exposure), so the gain is +//! evaluated here, on the resampled source at output resolution: a preview colours only the pixels +//! it shows. A gain multiplies R, G and B alike, so it commutes with the white balance (a matrix) +//! that follows. Files that also carry a `ProfileLookTable` get the gain after the table instead of +//! before it (DNG 1.7.1); Apple ProRAW has none. + +use lightcraft_color::{D50, D65, PROPHOTO, REC2020, bradford}; +use lightcraft_geom::Point; +use lightcraft_raster::Rgb32f; +use lightcraft_raw::gaintable::{GainTableMap, SourcePlacement}; + +use crate::{Plan, SourceInfo}; +use lightcraft_develop::DevelopSettings; + +/// A raw's gain table map and where its developed source sits in the active area. +#[derive(Clone, Debug, PartialEq)] +pub struct LocalTone { + pub map: GainTableMap, + pub placement: SourcePlacement, +} + +/// Whether the render applies the source's local tone mapping. +pub fn enabled(info: &SourceInfo, s: &DevelopSettings) -> bool { + s.profile.camera_local_tone && info.raw && info.local_tone.is_some() +} + +/// Multiply the resampled, not yet white-balanced source `img` (the output frame of `p`) by the +/// local tone gain at each pixel. Blank canvas (outside a lens / perspective warp) is left alone. +pub fn apply(img: &mut Rgb32f, info: &SourceInfo, p: &Plan<'_>) { + let Some(lt) = info.local_tone.as_deref().filter(|_| enabled(info, &p.settings)) else { return }; + let Some(eval) = lt.map.evaluator() else { return }; + let (w, h) = (img.width, img.height); + let frame = &p.frame; + let (ow, oh) = (frame.ow.max(1e-9), frame.oh.max(1e-9)); + let o2t = frame.out_to_oriented(w, h); + let user = frame.orient.inverse(); + let to_prophoto = PROPHOTO.from_xyz().mul(&bradford(D65, D50)).mul(&REC2020.to_xyz()).to_f32(); + crate::for_rows(&mut img.data, w, |y, row| { + for (x, px) in row.iter_mut().enumerate() { + // output pixel → user-oriented source px → EXIF-oriented source (0..1) → active area + let s = match &frame.warp { + Some(wp) => match wp.frame(&o2t, x, y) { + Some((_, s)) => s, + None => continue, + }, + None => o2t.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5)), + }; + let (u, v) = user.map(s.x / ow, s.y / oh, 1.0, 1.0); + let (ax, ay) = lt.placement.active(u, v); + let m = &to_prophoto; + let q: [f32; 3] = std::array::from_fn(|i| m[i][0] * px[0] + m[i][1] * px[1] + m[i][2] * px[2]); + let g = eval.gain(q, ax, ay); + *px = px.map(|c| c * g); + } + }); +} diff --git a/crates/raw/src/gaintable.rs b/crates/raw/src/gaintable.rs index 750a1c70..cec1d682 100644 --- a/crates/raw/src/gaintable.rs +++ b/crates/raw/src/gaintable.rs @@ -1,8 +1,20 @@ //! 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. +//! We read and write the tag (a DNG export keeps it) but don't render it by default: Lightroom +//! Classic renders Apple ProRAW without the map (see [`crate::profile`]), and so do we. The photo's +//! "camera local tone mapping" option renders it ([`GainTableMap::gain`], applied by the pipeline), +//! as the camera itself does. +//! +//! Applying it (DNG 1.7.1, `ProfileGainTableMap` → "Description"): +//! +//! - the four tables around a position are interpolated bilinearly; outside the grid the edge +//! tables are replicated. Positions are relative to the active area at pixel centres; +//! - the table input `clamp((R, G, B, min, max) · weights, 0, 1) ^ gamma` is computed in linear +//! RIMM (ProPhoto) after the baseline exposure; +//! - the gain is the table looked up linearly at `input × MapPointsN` ("multiply the table input +//! value by MapPointsN to compute the floating-point table index"), clamped to the last point, +//! and multiplies R, G and B. Results may exceed 1 and aren't clipped. //! //! - 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). @@ -153,6 +165,135 @@ impl GainTableMap { } } +/// Applying the map (see the module docs). Index arithmetic saturates and every lookup is +/// checked: the fields are public and the struct is deserializable, so a hand-built or damaged map +/// must not overflow or panic, whatever it holds. +impl GainTableMap { + /// The gain at relative active-area position (`x`, `y`) for `p`, a linear ProPhoto (RIMM) + /// colour after the baseline exposure. 1.0 for a map that can't be evaluated. For many pixels, + /// [`Self::evaluator`] once and [`GainEval::gain`] per pixel. + pub fn gain(&self, p: [f32; 3], x: f64, y: f64) -> f32 { + self.evaluator().map_or(1.0, |e| e.gain(p, x, y)) + } + + /// The map ready to evaluate per pixel, or `None` when it can't be (inconsistent sizes). + pub fn evaluator(&self) -> Option> { + let n = self.points_n; + let tables = self.points_v.saturating_mul(self.points_h); + if n == 0 || tables == 0 || tables.saturating_mul(n) != self.gains.len() { + return None; + } + let axis = |points: usize, origin: f64, spacing: f64| Axis { + last: points.saturating_sub(1), + origin, + // (a degenerate axis replicates its first table) + inv: if points > 1 && spacing.is_finite() && spacing > 0.0 { 1.0 / spacing } else { 0.0 }, + }; + Some(GainEval { map: self, v: axis(self.points_v, self.origin_v, self.spacing_v), h: axis(self.points_h, self.origin_h, self.spacing_h) }) + } +} + +/// One grid axis, prepared: last table index, origin and inverse spacing (0: one table). +#[derive(Clone, Copy, Debug)] +struct Axis { + last: usize, + origin: f64, + inv: f64, +} + +impl Axis { + /// The cell at relative position `pos`: (first table, second table, weight of the second). + /// Outside the grid the edge table is replicated. + #[inline] + fn cell(self, pos: f64) -> (usize, usize, f32) { + if self.last == 0 || self.inv == 0.0 { + return (0, 0, 0.0); + } + let f = (pos - self.origin) * self.inv; + let f = if f.is_nan() { 0.0 } else { f.clamp(0.0, self.last as f64) }; + // (≥ 0: `as` truncates like `floor`, which isn't inlined on baseline x86-64) + let i0 = (f as usize).min(self.last); + let i1 = i0.saturating_add(1).min(self.last); + (i0, i1, (f - i0 as f64) as f32) + } +} + +/// Grid cell along one axis (see [`Axis::cell`]). +#[cfg(test)] +fn cell(pos: f64, origin: f64, spacing: f64, points: usize) -> (usize, usize, f32) { + let inv = if points > 1 && spacing.is_finite() && spacing > 0.0 { 1.0 / spacing } else { 0.0 }; + Axis { last: points.saturating_sub(1), origin, inv }.cell(pos) +} + +/// A [`GainTableMap`] prepared for evaluating many pixels. +#[derive(Clone, Copy, Debug)] +pub struct GainEval<'a> { + map: &'a GainTableMap, + v: Axis, + h: Axis, +} + +impl GainEval<'_> { + /// [`GainTableMap::gain`]. + #[inline] + pub fn gain(&self, p: [f32; 3], x: f64, y: f64) -> f32 { + let m = self.map; + let (v0, v1, fv) = self.v.cell(y); + let (h0, h1, fh) = self.h.cell(x); + // one fractional index for the four tables (≥ 0, so `as` truncates) + let n = m.points_n; + let last = n - 1; + let idx = (input(m, p) * n as f32).min(last as f32); + let i0 = (idx as usize).min(last); + let i1 = i0.saturating_add(1).min(last); + let t = idx - i0 as f32; + // (v ≤ points_v − 1, h ≤ points_h − 1 and i ≤ n − 1, with points_v·points_h·n = gains.len() + // checked by `evaluator`: none of this overflows, and `get` still guards each read) + let at = |v: usize, h: usize| { + let table = m.gains.get((v * m.points_h + h) * n..).unwrap_or(&[]); + let g0 = table.get(i0).copied().unwrap_or(1.0); + let g1 = table.get(i1).copied().unwrap_or(1.0); + g0 + (g1 - g0) * t + }; + let (a, b, c, d) = (at(v0, h0), at(v0, h1), at(v1, h0), at(v1, h1)); + let top = a + (b - a) * fh; + let bottom = c + (d - c) * fh; + let g = top + (bottom - top) * fv; + if g.is_finite() { g.max(0.0) } else { 1.0 } + } +} + +/// The table input for `p` (linear ProPhoto, after the baseline exposure). +#[inline] +fn input(m: &GainTableMap, p: [f32; 3]) -> f32 { + let w = &m.weights; + let lo = p[0].min(p[1]).min(p[2]); + let hi = p[0].max(p[1]).max(p[2]); + let x = w[0] * p[0] + w[1] * p[1] + w[2] * p[2] + w[3] * lo + w[4] * hi; + let x = if x.is_nan() { 0.0 } else { x.clamp(0.0, 1.0) }; + if m.gamma == 1.0 { x } else { x.powf(m.gamma) } +} + +/// Where the pixels of a developed source sit in the raw's active area, for [`GainTableMap::gain`]. +/// The source is the default crop of the active area (`rect`, relative to the active area), +/// then EXIF-`orientation`ed. Resolution independent. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct SourcePlacement { + /// x, y, width, height of the developed crop, relative to the active area. + pub rect: [f64; 4], + /// The EXIF orientation applied to the developed crop. + pub orientation: lightcraft_geom::Orientation, +} + +impl SourcePlacement { + /// Relative active-area position of normalized (0..1) coordinates of the oriented source. + pub fn active(&self, u: f64, v: f64) -> (f64, f64) { + let (u, v) = self.orientation.inverse().map(u, v, 1.0, 1.0); + let [x, y, w, h] = self.rect; + (x + u * w, y + v * h) + } +} + #[cfg(test)] mod tests { use super::*; @@ -322,4 +463,106 @@ mod tests { assert_eq!(GainTableMap::parse(&b, order, true).unwrap(), g); } } + + fn map(dims: [usize; 3], spacing: [f64; 2], origin: [f64; 2], weights: [f32; 5], gains: Vec) -> GainTableMap { + let [points_v, points_h, points_n] = dims; + let [spacing_v, spacing_h] = spacing; + let [origin_v, origin_h] = origin; + GainTableMap { points_v, points_h, points_n, spacing_v, spacing_h, origin_v, origin_h, weights, gamma: 1.0, gains } + } + + const LUMA: [f32; 5] = [1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0, 0.0, 0.0]; + + #[test] + fn gain_follows_the_table_input_at_input_times_n() { + // one table of 3 points: index = input × 3 (DNG 1.7.1), clamped to the last point + let m = map([1, 1, 3], [1.0, 1.0], [0.0, 0.0], LUMA, vec![1.0, 2.0, 3.0]); + assert_eq!(m.gain([0.0; 3], 0.5, 0.5), 1.0); + assert!((m.gain([1.0 / 3.0; 3], 0.5, 0.5) - 2.0).abs() < 1e-5, "index 1"); + assert!((m.gain([1.0 / 6.0; 3], 0.5, 0.5) - 1.5).abs() < 1e-5, "index 0.5: linear in the table"); + assert!((m.gain([0.9; 3], 0.5, 0.5) - 3.0).abs() < 1e-5, "index 2.7 clamps to the last point"); + // min/max weights and gamma: input = max(R, G, B)², two points + let mut m = map([1, 1, 2], [1.0, 1.0], [0.0, 0.0], [0.0, 0.0, 0.0, 0.0, 1.0], vec![1.0, 3.0]); + m.gamma = 2.0; + // max 0.5 → 0.25 → index 0.5 → ×2 + assert!((m.gain([0.1, 0.5, 0.2], 0.0, 0.0) - 2.0).abs() < 1e-5); + } + + #[test] + fn tables_interpolate_across_the_grid_and_replicate_at_the_edges() { + // 1 × 2 constant tables at x = 0.25 (×1) and x = 0.75 (×3) + let m = map([1, 2, 1], [1.0, 0.5], [0.0, 0.25], LUMA, vec![1.0, 3.0]); + let g = |x: f64| m.gain([0.2; 3], x, 0.5); + assert!((g(0.25) - 1.0).abs() < 1e-5 && (g(0.5) - 2.0).abs() < 1e-5 && (g(0.75) - 3.0).abs() < 1e-5); + assert!((g(0.0) - 1.0).abs() < 1e-5 && (g(1.0) - 3.0).abs() < 1e-5); + assert!((g(-10.0) - 1.0).abs() < 1e-5 && (g(10.0) - 3.0).abs() < 1e-5); + // vertically too: 2 × 1 at y = 0 (×1) and y = 1 (×2) + let m = map([2, 1, 1], [1.0, 1.0], [0.0, 0.0], LUMA, vec![1.0, 2.0]); + assert!((m.gain([0.2; 3], 0.5, 0.25) - 1.25).abs() < 1e-5); + } + + #[test] + fn hand_built_maps_never_panic() { + // review on #273: `(i0 + 1).min(points - 1)` overflowed for points_v = usize::MAX at +inf + let mut m = map([usize::MAX, 1, 1], [1.0, 1.0], [0.0, 0.0], LUMA, vec![2.0]); + assert_eq!(m.gain([0.5; 3], 0.5, f64::INFINITY), 1.0, "inconsistent sizes: no gain"); + for (v, h, n, len) in [(usize::MAX, 1, 1, 1), (1, usize::MAX, usize::MAX, 3), (0, 0, 0, 0), (2, 2, 0, 0), (1, 1, 2, 1)] { + m.points_v = v; + m.points_h = h; + m.points_n = n; + m.gains = vec![2.0; len]; + for spacing in [0.0, -1.0, f64::NAN, f64::INFINITY, 1e-300] { + m.spacing_v = spacing; + m.spacing_h = spacing; + for p in [[0.5; 3], [f32::NAN; 3], [f32::INFINITY, 0.0, -1.0], [1e30; 3]] { + for (x, y) in [(0.5, 0.5), (f64::INFINITY, f64::NEG_INFINITY), (f64::NAN, f64::NAN)] { + assert!(m.gain(p, x, y).is_finite()); + } + } + } + } + // the grid cell itself saturates (the reviewer's case, reached directly) + assert_eq!(cell(f64::INFINITY, 0.0, 1.0, usize::MAX), (usize::MAX - 1, usize::MAX - 1, 0.0)); + assert_eq!(cell(f64::NEG_INFINITY, 0.0, 1.0, usize::MAX).0, 0); + // a consistent map with a huge grid and positions far outside it + let m = map([1, 1, 2], [1e-300, 1e-300], [0.0, 0.0], [f32::MAX; 5], vec![1.0, 4.0]); + assert!(m.gain([1e30; 3], f64::INFINITY, -f64::INFINITY).is_finite()); + } + + #[test] + fn placement_undoes_crop_and_orientation() { + // the source is the right half of the active area, rotated 90° clockwise + let p = SourcePlacement { rect: [0.5, 0.0, 0.5, 1.0], orientation: lightcraft_geom::Orientation::Rotate90 }; + // the oriented source's top-left came from the crop's bottom-left + let (x, y) = p.active(0.0, 0.0); + assert!((x - 0.5).abs() < 1e-12 && (y - 1.0).abs() < 1e-12, "{x} {y}"); + // its top-right came from the crop's top-left + let (x, y) = p.active(1.0, 0.0); + assert!((x - 0.5).abs() < 1e-12 && y.abs() < 1e-12, "{x} {y}"); + let plain = SourcePlacement { rect: [0.0, 0.0, 1.0, 1.0], orientation: lightcraft_geom::Orientation::Normal }; + assert_eq!(plain.active(0.25, 0.75), (0.25, 0.75)); + } +} + +/// `GTM_FILE=photo.dng cargo test --release -p lightcraft-raw gain_throughput -- --ignored --nocapture`: +/// the cost of [`GainEval::gain`] per pixel on a real file's map. +#[cfg(test)] +mod bench { + #[test] + #[ignore] + fn gain_throughput() { + let Some(path) = std::env::var_os("GTM_FILE") else { return }; + let raw = crate::decode(&std::fs::read(path).unwrap()).unwrap(); + let m = raw.color.profile.gain_table_map.expect("the file has no gain table map"); + let e = m.evaluator().unwrap(); + let n = 3_000_000usize; + let t = std::time::Instant::now(); + let mut acc = 0f32; + for i in 0..n { + let (x, y) = ((i % 2048) as f64 / 2048.0, (i / 2048) as f64 / 1536.0); + let v = ((i * 7919) % 1000) as f32 / 1000.0 * 0.5; + acc += e.gain([v, v * 0.9, v * 0.8], x, y); + } + println!("{}×{}×{} map: {:.1} ns/px ({acc})", m.points_v, m.points_h, m.points_n, t.elapsed().as_secs_f64() * 1e9 / n as f64); + } } diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 68761687..963a4be8 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -552,7 +552,8 @@ pub struct ColorData { pub baseline_sharpness: Option, /// The file's own camera-profile look (`ProfileHueSatMap*`, `ProfileLookTable*`, /// `ProfileToneCurve`), applied by [`color`]'s users at render time, and its - /// `ProfileGainTableMap*`, kept (a DNG export writes it back) but not rendered. + /// `ProfileGainTableMap*`, kept (a DNG export writes it back) and rendered only when a photo's + /// "Camera local tone mapping" option asks for it (`lightcraft_pipeline::local_tone`). #[serde(default)] pub profile: profile::ProfileLook, } diff --git a/crates/raw/src/profile.rs b/crates/raw/src/profile.rs index a21444c4..582bf944 100644 --- a/crates/raw/src/profile.rs +++ b/crates/raw/src/profile.rs @@ -12,7 +12,8 @@ //! 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 +//! but not rendered by default (the photo's "Camera local tone mapping" option renders it, in the +//! pipeline): 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). diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 64a485ed..c24b59cc 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1174,6 +1174,8 @@ "Remove (H)": "Entfernen (H)", "Remove CA": "Chromatische Aberration entfernen", "Remove Chromatic Aberration": "Chromatische Aberration entfernen", + "Camera local tone mapping": "Lokale Tonwertzuordnung der Kamera", + "Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "Die in der Raw-Datei gespeicherte lokale Tonwertzuordnung (Apple ProRAW) anwenden, wie die Kamera sie rendert", "Remove Disk from Library": "Datenträger aus Bibliothek entfernen", "Remove Disk from Library…": "Datenträger aus Bibliothek entfernen…", "Remove Face Box": "Gesichtsrahmen entfernen", diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 461fa2f6..387ac971 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1126,6 +1126,8 @@ "Remove (H)": "Eliminar (H)", "Remove CA": "Eliminar AC", "Remove Chromatic Aberration": "Eliminar aberración cromática", + "Camera local tone mapping": "Mapeo tonal local de la cámara", + "Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "Aplicar el mapeo tonal local guardado en el raw (Apple ProRAW), como lo renderiza la cámara", "Remove Disk from Library": "Quitar disco de la biblioteca", "Remove Disk from Library…": "Quitar disco de la biblioteca…", "Remove Face Box": "Quitar recuadro de cara", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 49f2ef80..b05baac2 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1087,6 +1087,8 @@ "Remove (H)": "除去(H)", "Remove CA": "色収差を除去", "Remove Chromatic Aberration": "色収差を除去", + "Camera local tone mapping": "カメラのローカルトーンマッピング", + "Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "RAW に保存されたローカルトーンマッピング(Apple ProRAW)をカメラと同じように適用", "Remove from Album": "アルバムから削除", "Remove from Favorites": "お気に入りから削除", "Remove from Local": "ローカルから除く", diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 81e331c5..ab7f3973 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1137,6 +1137,8 @@ "Remove (H)": "Remover (H)", "Remove CA": "Remover AC", "Remove Chromatic Aberration": "Remover aberração cromática", + "Camera local tone mapping": "Mapeamento tonal local da câmera", + "Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "Aplicar o mapeamento tonal local salvo no raw (Apple ProRAW), como a câmera o renderiza", "Remove Disk from Library": "Remover disco da biblioteca", "Remove Disk from Library…": "Remover disco da biblioteca…", "Remove Face Box": "Remover caixa de rosto", diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 56ed5798..517e2613 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -939,6 +939,8 @@ "Remove (H)": "Удалить (H)", "Remove CA": "Убрать ХА", "Remove Chromatic Aberration": "Убрать хроматические аберрации", + "Camera local tone mapping": "Локальная тональная коррекция камеры", + "Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "Применить локальную тональную коррекцию, сохранённую в raw (Apple ProRAW), как её рендерит камера", "Remove from Album": "Удалить из альбома", "Remove from Favorites": "Удалить из избранного", "Remove from Local": "Убрать из «Локального»", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 4074dfbd..46728b62 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1138,6 +1138,8 @@ "Remove (H)": "移除 (H)", "Remove CA": "移除色差", "Remove Chromatic Aberration": "移除色差", + "Camera local tone mapping": "相机局部色调映射", + "Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "按相机的渲染方式应用 raw 中保存的局部色调映射(Apple ProRAW)", "Remove from Album": "从相册中移除", "Remove from Favorites": "从收藏夹中移除", "Remove from Local": "从本地移除", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 96fcbb18..083c98c2 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1138,6 +1138,8 @@ "Remove (H)": "移除(H)", "Remove CA": "移除色差", "Remove Chromatic Aberration": "移除色差", + "Camera local tone mapping": "相機局部色調映射", + "Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "按相機的渲染方式套用 raw 中儲存的局部色調映射(Apple ProRAW)", "Remove from Album": "從相簿移除", "Remove from Favorites": "從喜好項目移除", "Remove from Local": "從本機移除", diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index fb8d121e..ed7c7704 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -153,6 +153,18 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { control(app, ui, &d, "profile.amount", true); ui.add_space(6.0); } + // a raw carrying its own local tone mapping (Apple ProRAW's gain table map): off renders it as + // Lightroom Classic does, on as the camera does + if app.session.source_info(id).local_tone.is_some() { + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 0, bottom: 10 }).show(ui, |ui| { + let mut on = d.profile.camera_local_tone; + let r = ui.checkbox(&mut on, crate::i18n::tr("Camera local tone mapping")); + crate::widgets::register(ui.ctx(), "check:profile.cameraLocalTone", r.rect); + if r.on_hover_text(crate::i18n::tr("Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it")).changed() { + let _ = app.run("develop.merge", json!({"settings": {"profile": {"camera_local_tone": on}}, "label": "Camera Local Tone Mapping"})); + } + }); + } divider(ui); section(app, ui, &d, "light", "Light", |app, ui, d| { diff --git a/docs/parity.md b/docs/parity.md index c4eb79a1..4c27755b 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -296,7 +296,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/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, Pentax PEF and Samsung SRW 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 and not rendered by default, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); the Profile option "Camera local tone mapping" (`profile.camera_local_tone`, per photo, off by default, `crates/pipeline/src/local_tone.rs`) renders it as the camera does, per output pixel in the scene-linear stage (CPU, also under the GPU path); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF and Samsung SRW 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 fa813336..33557867 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -47,7 +47,9 @@ preview, with the reason, instead of showing a black tile. 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. + exported or converted to DNG. It is not applied by default: Lightroom Classic renders ProRAW without it. To see a + ProRAW the way the iPhone renders it, turn on Profile ▸ Camera local tone mapping (shown for photos that carry the + map); the option is per photo, so presets and Copy Settings carry it. - iPhone ProRAW and other DNGs that carry their own segmentation mattes (DNG semantic masks) use them for the Select Sky, Subject and Background masks instead of our heuristics, so the sky is selected where the camera found it (checked on one CC0 iPhone 12 Pro ProRAW). Photos without mattes are unchanged.