LR-PROF-CAMERACOLOR: camera local tone mapping (ProfileGainTableMap) as a per-photo opt-in

Renders a raw's DNG ProfileGainTableMap (Apple ProRAW's local tone mapping)
when the photo's Profile option "Camera local tone mapping" is on
(`profile.camera_local_tone`, off by default: Lightroom Classic renders
ProRAW without it). Built on #427's parser; supersedes the decode-time
applier of the first version of #273.

- raw/gaintable: GainTableMap::gain / evaluator (GainEval) per DNG 1.7.1:
  bilinear between tables with edges replicated, input clamp(weights .
  (R,G,B,min,max))^gamma in linear ProPhoto after the baseline exposure,
  index input x MapPointsN clamped to the last point. Saturating cell
  math; maps whose sizes don't match their gains aren't evaluated.
  SourcePlacement: where a developed source sits in the active area.
- pipeline/local_tone: the gain per output pixel in the scene-linear
  stage, before the white balance, mapped back through the frame (crop,
  user orientation, warp) and the EXIF orientation. lin_needs_cpu routes
  the stage to the CPU when it's on (CPU and GPU match); lin_key includes
  it. SourceInfo::local_tone carries the map from the decoder.
- develop: Profile::camera_local_tone (omitted from JSON when off, so
  existing settings and their hashes are unchanged); Profile row checkbox
  when the source has a map; translations.
- RENDER_CACHE_VERSION 17. Docs: parity row and What's New as an opt-in.

Off renders pixel-identical to main on two iPhone 15 Pro ProRAWs (12 and
48 MP). On adds ~60-75 ms to the scene-linear stage at 1536x2048 on a
2-core VM, the same for both files (cost follows output pixels).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VFqow2P1EtDxt6nRjjQoTA
This commit is contained in:
BuonBredaandClaude Opus 5.5 committed 2026-10-09 11:11:52 +02:00
1 parent c435d143de
commit fa1565d855
19 files changed
+439 -13

No files matched your search

+6 -1
View File
@@ -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 }
}
}
+77 -1
View File
@@ -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<Arc<lightcraft_pipeline::local_tone::LocalTone>> {
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::<f32>();
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);
+2 -1
View File
@@ -62,7 +62,7 @@ impl SettingsHashes {
}
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
pub const RENDER_CACHE_VERSION: u64 = 16;
pub const RENDER_CACHE_VERSION: u64 = 17;
/// 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];
@@ -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::<SourceInfo>() + mattes + self.denoised.as_ref().map_or(0, |t| t.bytes())
}
+2 -2
View File
@@ -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),
+11 -2
View File
@@ -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<tone::CameraTone>,
/// Segmentation mattes stored in the file (DNG semantic masks): AI masks use them.
pub mattes: Option<Arc<masks::Mattes>>,
/// 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<Arc<local_tone::LocalTone>>,
}
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);
+62
View File
@@ -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);
}
});
}
+245 -2
View File
@@ -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<GainEval<'_>> {
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<f32>) -> 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);
}
}
+2 -1
View File
@@ -522,7 +522,8 @@ pub struct ColorData {
pub baseline_sharpness: Option<f64>,
/// 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,
}
+2 -1
View File
@@ -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).
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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": "ローカルから除く",
+2
View File
@@ -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",
+2
View File
@@ -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": "Убрать из «Локального»",
+2
View File
@@ -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": "从本地移除",
+2
View File
@@ -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": "從本機移除",
+12
View File
@@ -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| {
+1 -1
View File
@@ -295,7 +295,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 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 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 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 |
+3 -1
View File
@@ -38,7 +38,9 @@
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.
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.