From 8b9a0200843d9ef4d47e3d72f6ca7f53531e4bdc Mon Sep 17 00:00:00 2001 From: Alex Chambers Date: Sat, 10 Oct 2026 02:43:11 +0200 Subject: [PATCH] LR-EDIT-AUTO: Lightroom-like Auto, calibrated on Lightroom's Auto results (#318, #209) Auto was a percentile rule that set Exposure about 1.4 EV higher than Lightroom's Auto on the same photos (mean +1.81 against +0.44 EV) and barely used Shadows: on 24 Sony raw photos its results were further from Lightroom's Auto results (median dE76 22.0) than leaving the photo unedited (7.9), about 18 L* too bright. Auto is now Lightroom's shape of rule with values chosen for our results: - Exposure = -0.58 - 0.41 * the scene's log-average luminance (EV from middle grey, scene-linear, under the photo's white balance, so it does not depend on the base tone curve), clamped to -2..3 EV. The slope is Lightroom's: its Auto's chosen Exposure against our log average, r -0.92; - Highlights -66 and Shadows +52: Lightroom's own average Auto values; - Contrast +68 and Vibrance +30 (our scales; our base tone is flatter); Whites, Blacks and Saturation 0. The exposure offset, Contrast and Vibrance were fitted jointly so our renders match Lightroom's Auto exports on the 16 photos whose starting look Sony's DRO doesn't brighten (#568 changes the other 8). Held out one photo at a time, each fold fitted from its own training photos only: median dE76 6.38 on those 16 (8.28 unedited, 18.81 with the old rule), 12 of 16 closer than unedited; 7.70 on all 24 (7.92 unedited); on the 8 DRO photos 9.39 (7.00 unedited), their brighter start showing through. Freeing Highlights and Shadows too (5 fitted values) did no better held out (6.51) and left them poorly determined across folds. The log average skips pixels that aren't finite (a damaged decode) through the proxy's resampling and white balance; with no valid pixel, Exposure 0. Values are black-box observations of Lightroom (the settings its Auto chose and its exports), as in #514. Auto only applies when asked for, so existing edits keep their look; HDR merges with Auto Settings use the same rule. Co-Authored-By: Claude Opus 5.5 --- crates/pipeline/src/auto.rs | 152 +++++++++++++++++++++++++++--------- docs/parity.md | 2 +- 2 files changed, 116 insertions(+), 38 deletions(-) diff --git a/crates/pipeline/src/auto.rs b/crates/pipeline/src/auto.rs index 3455fff2..59af5fe3 100644 --- a/crates/pipeline/src/auto.rs +++ b/crates/pipeline/src/auto.rs @@ -1,4 +1,5 @@ -//! Auto tone and auto white balance (histogram / grey-world statistics on a proxy). +//! Auto tone (a Lightroom-like recipe, see [`auto_tone`]) and auto white balance (grey-world +//! statistics on a proxy). use lightcraft_color::cct::xy_to_temp_tint; use lightcraft_color::{REC2020, Xy, bradford, luminance_2020}; @@ -21,40 +22,83 @@ pub struct AutoTone { pub saturation: f64, } -fn percentile(sorted: &[f32], q: f32) -> f32 { - if sorted.is_empty() { - return 0.0; +/// Exposure in EV that Auto adds per EV of the scene's log-average luminance (the slope is negative: +/// darker scenes get more). The slope is Lightroom's: regressing the Exposure its Auto chose on 24 +/// raw photos against our own [`scene_log_mean`] of each gives -0.41 EV/EV (r = -0.92). +const EXPOSURE_SLOPE: f64 = -0.41; +/// Exposure for a scene whose log-average luminance is middle grey: fitted, with [`CONTRAST`] and +/// [`VIBRANCE`], so our renders match Lightroom's Auto results on those photos (see [`auto_tone`]). +const EXPOSURE_AT_GREY: f64 = -0.58; +/// Highlights and Shadows are Lightroom's own: the average its Auto chose on the 24 photos (our +/// sliders aim at the same as its). Freeing them as well matched no better on held-out photos and +/// left them poorly determined. +const HIGHLIGHTS: f64 = -66.0; +const SHADOWS: f64 = 52.0; +/// Contrast and Vibrance are fitted: our slider scales differ from Lightroom's, and our base tone is +/// flatter, so Contrast is far above its +6. Whites, Blacks and Saturation stay at 0 (Whites and +/// Blacks made no difference to the match; Lightroom leaves Saturation at about 0). +const CONTRAST: f64 = 68.0; +const VIBRANCE: f64 = 30.0; + +/// The scene's log-average luminance under the current white balance, in EV relative to middle grey +/// (the mean of log2(Y / 0.18) over a 512 px proxy, scene-linear, before any tone curve). Pixels that +/// aren't finite (a damaged decode) are left out: they get no weight when the proxy is made, and +/// proxy pixels made only of them are skipped. `None` when no pixel is valid. +pub fn scene_log_mean(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings) -> Option { + let (mut clean, mut weight) = (src.clone(), Rgb32f::new(src.width, src.height)); + let mut any = false; + for (p, w) in clean.data.iter_mut().zip(weight.data.iter_mut()) { + if p.iter().all(|v| v.is_finite()) { + *w = [1.0; 3]; + any = true; + } else { + *p = [0.0; 3]; + } } - sorted[((sorted.len() - 1) as f32 * q.clamp(0.0, 1.0)) as usize] + if !any { + return None; + } + let fit = |img: &Rgb32f| lightcraft_raster::resample::fit(img, 512, 512, lightcraft_raster::resample::Filter::Box); + let (img, weight) = (fit(&clean), fit(&weight)); + // the mean of the valid pixels under each proxy pixel + let kept: Vec<[f32; 3]> = img.data.iter().zip(&weight.data).filter(|(_, w)| w[0] > 1e-6).map(|(p, w)| p.map(|v| v / w[0])).collect(); + let mut kept = Rgb32f { width: kept.len(), height: 1, data: kept }; + let base = DevelopSettings { wb: s.wb, ..DevelopSettings::default() }; + // (white balance is per pixel: the valid pixels can be balanced in a row of their own) + crate::local::scene_linear_pre(&mut kept, info, &base); + let (mut sum, mut n) = (0f64, 0usize); + for c in &kept.data { + let ev = (luminance_2020(*c).max(1e-6) / 0.18).log2(); + if ev.is_finite() { + sum += ev as f64; + n += 1; + } + } + (n > 0).then(|| sum / n as f64) } -/// Compute auto tone values for `src` under the current white balance (ignores current tone values). +/// Auto settings for `src` under the current white balance (ignores current tone values), aiming at +/// what Lightroom's Auto does: the Exposure a scene needs follows its log-average luminance +/// ([`scene_log_mean`], scene-linear, so the rule doesn't depend on the base tone curve), and the +/// other sliders are one fixed recipe. Calibrated black-box against Lightroom's observed behaviour +/// (the values its Auto chose and its exported results) on 24 Sony raw photos, of which 16 have a +/// starting look that Sony's in-camera DRO doesn't brighten. Held out one photo at a time, those 16 +/// come out at a median dE76 of 6.4 from Lightroom's Auto results (8.3 unedited; 18.8 with the +/// previous percentile rule, which ended about 16 L* too bright); all 24 at 7.7 (7.9 unedited). +/// Rendered sources (JPEGs) are already toned, so their log-average reads brighter and Auto changes +/// their exposure less. A source without a single valid pixel gets Exposure 0 (and the rest of the +/// recipe). pub fn auto_tone(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings) -> AutoTone { - let mut img = lightcraft_raster::resample::fit(src, 512, 512, lightcraft_raster::resample::Filter::Box); - let mut base = DevelopSettings { wb: s.wb, ..DevelopSettings::default() }; - base.light.exposure = 0.0; - crate::local::scene_linear_pre(&mut img, info, &base); - let mut ev: Vec = img.data.iter().map(|c| (luminance_2020(*c).max(1e-6) / 0.18).log2()).collect(); - ev.sort_by(|a, b| a.total_cmp(b)); - let median = percentile(&ev, 0.5); - let exposure = (-median * 0.85 - 0.1).clamp(-4.0, 4.0); - let (p01, p05, p95, p995) = - (percentile(&ev, 0.01) + exposure, percentile(&ev, 0.05) + exposure, percentile(&ev, 0.95) + exposure, percentile(&ev, 0.995) + exposure); - let highlights = if p995 > 2.2 { -((p995 - 2.2) * 38.0).min(90.0) } else { 0.0 }; - let shadows = if p05 < -4.0 { ((-4.0 - p05) * 22.0).min(70.0) } else { 0.0 }; - let spread = p95 - p05; - let contrast = ((6.5 - spread) * 6.0).clamp(-20.0, 30.0); - let whites = if p995 < 1.8 { ((1.8 - p995) * 25.0).min(40.0) } else { -((p995 - 3.5).max(0.0) * 10.0).min(30.0) }; - let blacks = if p01 > -5.0 { -((p01 + 5.0) * 10.0).min(35.0) } else { ((-7.0 - p01).max(0.0) * 8.0).min(20.0) }; + let exposure = scene_log_mean(src, info, s).map_or(0.0, |m| (EXPOSURE_AT_GREY + EXPOSURE_SLOPE * m).clamp(-2.0, 3.0)); AutoTone { - exposure: (exposure as f64 * 100.0).round() / 100.0, - contrast: contrast.round() as f64, - highlights: highlights.round() as f64, - shadows: shadows.round() as f64, - whites: whites.round() as f64, - blacks: blacks.round() as f64, - vibrance: 12.0, - saturation: 3.0, + exposure: (exposure * 100.0).round() / 100.0, + contrast: CONTRAST, + highlights: HIGHLIGHTS, + shadows: SHADOWS, + whites: 0.0, + blacks: 0.0, + vibrance: VIBRANCE, + saturation: 0.0, } } @@ -144,13 +188,47 @@ mod tests { use super::*; #[test] - fn dark_image_gets_positive_exposure() { - let img = Rgb32f::from_fn(64, 64, |x, _| [0.01 + x as f32 * 0.0003; 3]); - let a = auto_tone(&img, &SourceInfo::default(), &DevelopSettings::default()); - assert!(a.exposure > 1.5, "{a:?}"); - let bright = Rgb32f::from_fn(64, 64, |x, _| [0.8 + x as f32 * 0.01; 3]); - let b = auto_tone(&bright, &SourceInfo::default(), &DevelopSettings::default()); - assert!(b.exposure < -1.0, "{b:?}"); + fn exposure_follows_the_log_average_luminance() { + let flat = |y: f32| Rgb32f::filled(64, 64, [y; 3]); + let auto = |img: &Rgb32f| auto_tone(img, &SourceInfo::default(), &DevelopSettings::default()); + let log_mean = |img: &Rgb32f| scene_log_mean(img, &SourceInfo::default(), &DevelopSettings::default()).unwrap(); + // a scene at middle grey gets the exposure at grey; each EV darker adds 0.41 EV + assert!(log_mean(&flat(0.18)).abs() < 1e-3); + assert_eq!(auto(&flat(0.18)).exposure, -0.58); + assert!((auto(&flat(0.18 / 8.0)).exposure - (-0.58 + 3.0 * 0.41)).abs() < 1e-9); + // darker scenes get more, brighter ones less, within -2..3 EV + let ramp = |lo: f32, step: f32| Rgb32f::from_fn(64, 64, |x, _| [lo + x as f32 * step; 3]); + let (dark, bright) = (auto(&ramp(0.01, 0.0003)), auto(&ramp(0.8, 0.01))); + assert!(dark.exposure > 0.5 && bright.exposure < -1.0, "{dark:?} {bright:?}"); + assert_eq!(auto(&flat(1e-9)).exposure, 3.0); + assert_eq!(auto(&flat(1e6)).exposure, -2.0); + // the rest is one recipe + assert_eq!( + (dark.contrast, dark.highlights, dark.shadows, dark.whites, dark.blacks, dark.vibrance, dark.saturation), + (68.0, -66.0, 52.0, 0.0, 0.0, 30.0, 0.0) + ); + assert_eq!((dark.contrast, dark.shadows), (bright.contrast, bright.shadows)); + } + + #[test] + fn pixels_that_are_not_finite_are_left_out() { + let auto = |img: &Rgb32f| auto_tone(img, &SourceInfo::default(), &DevelopSettings::default()); + // half middle grey, half NaN (or infinite): the grey half alone decides, as if the rest weren't there + for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + let half = Rgb32f::from_fn(64, 64, |x, _| if x < 32 { [0.18; 3] } else { [bad; 3] }); + assert_eq!(auto(&half).exposure, -0.58, "{bad}"); + // also through the resampling of a source larger than the proxy (proxy pixels on the border mix both halves) + let big = Rgb32f::from_fn(1030, 700, |x, _| if x < 515 { [0.045; 3] } else { [bad; 3] }); + assert!((auto(&big).exposure - (-0.58 + 2.0 * 0.41)).abs() < 1e-9, "{bad}"); + // a few broken pixels scattered over a grey image change nothing + let speckled = Rgb32f::from_fn(1030, 700, |x, y| if (x * 7 + y * 13) % 97 == 0 { [bad; 3] } else { [0.18; 3] }); + assert_eq!(auto(&speckled).exposure, -0.58, "{bad}"); + } + // with no valid pixel at all (or none at all) there is nothing to measure: Exposure 0, the recipe as usual + for img in [Rgb32f::filled(8, 8, [f32::NAN; 3]), Rgb32f::filled(600, 600, [f32::INFINITY; 3]), Rgb32f::new(0, 0)] { + let a = auto(&img); + assert_eq!((a.exposure, a.contrast, a.vibrance), (0.0, 68.0, 30.0)); + } } #[test] diff --git a/docs/parity.md b/docs/parity.md index 4c27755b..7eb91d5b 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -236,7 +236,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-EDIT-AUTO | Auto settings | P0 | ✅ | `cmd:develop.auto`, `crates/pipeline/src/auto.rs` | | +| LR-EDIT-AUTO | Auto settings | P0 | ✅ | `cmd:develop.auto`, `crates/pipeline/src/auto.rs` | a Lightroom-like recipe calibrated black-box on Lightroom's Auto results for 24 Sony raws: Exposure from the scene's scene-linear log-average luminance (Lightroom's observed slope, -0.41 EV per EV), Highlights -66 and Shadows +52 (Lightroom's average Auto values), Contrast +68 and Vibrance +30 fitted on our scales. Held out one photo at a time: on the 16 photos whose start Sony's DRO doesn't brighten, a median dE76 of 6.4 from Lightroom's Auto results against 8.3 unedited; on all 24, 7.7 against 7.9 (issues #318, #209). Calibrated on Sony files only; Whites and Blacks stay 0 | | LR-EDIT-BW | Black & white | P0 | ✅ | `cmd:develop.treatment` | | | LR-EDIT-HDR-MODE | HDR editing | P2 | ⬜ | | | | LR-EDIT-LIGHT-EXPOSURE | Exposure | P0 | ✅ | `ctl:light.exposure` | |