Skip to main content

rawshift_image/transforms/
tonemap.rs

1//! Tone mapping and sRGB encoding.
2//!
3//! Implements a tone reproduction pipeline for RAW image export:
4//!
5//! 1. Apply `BaselineExposure` as a linear gain in scene-linear space.
6//! 2. Apply the Hable/Uncharted2 filmic tone curve, with its white point set
7//!    to the post-gain maximum so that sensor white always maps to display white.
8//!    For negative BaselineExposure (extra highlight headroom), values above
9//!    the "intended white" get smooth shoulder rolloff instead of hard clipping.
10//! 3. Encode to sRGB (IEC 61966-2-1) with the piecewise gamma transfer function.
11//!
12//! Output is comparable to dcraw/libraw defaults and correctly handles both
13//! negative BaselineExposure (e.g. iPhone ProRAW at -0.83 EV) and positive values.
14
15use crate::core::image::RgbImage;
16use crate::processing::color::apply_gamma;
17
18/// Apply tone reproduction to an RGB image.
19///
20/// If `custom_gamma` is `Some(g)`, applies simple power-law gamma correction
21/// (useful for advanced users who want direct control).
22/// Otherwise, applies the full filmic tone mapping pipeline with optional
23/// BaselineExposure from the image metadata.
24///
25/// Input must be scene-linear, normalized to [0, 65535].
26/// After this call, data is display-referred and gamma-encoded in [0, 65535].
27pub fn apply_tone_reproduction(image: &mut RgbImage, custom_gamma: Option<f32>) {
28    if let Some(gamma) = custom_gamma {
29        apply_gamma(image, gamma);
30    } else {
31        apply_tonemap(image, image.baseline_exposure());
32    }
33}
34
35/// Apply the full tone reproduction pipeline to an RGB image.
36///
37/// Input must be scene-linear, normalized to [0, 65535].
38/// After this call, data is display-referred and sRGB-gamma encoded in [0, 65535].
39///
40/// # Arguments
41/// * `image`             - Linear RGB image, modified in place.
42/// * `baseline_exposure` - DNG BaselineExposure in EV. `None` = 0 EV (identity gain).
43pub fn apply_tonemap(image: &mut RgbImage, baseline_exposure: Option<f32>) {
44    let gain = baseline_exposure.map(|ev| 2.0f32.powf(ev)).unwrap_or(1.0);
45    let lut = build_lut(gain);
46    for pixel in &mut image.data {
47        *pixel = lut[*pixel as usize];
48    }
49}
50
51/// Build a 65 536-entry LUT: linear scene value → sRGB display value.
52///
53/// White-point strategy:
54/// - `white_point = gain`  (= 2^BaselineExposure * 1.0 for a fully-exposed sensor)
55/// - For negative EV (gain < 1): the post-gain max is < 1; setting W = gain makes
56///   the curve expand [0, gain] to full display output, exploiting highlight headroom.
57/// - For positive EV (gain > 1): sensor max maps through the shoulder to display white.
58/// - For EV = 0 (gain = 1): straight mapping with filmic shoulder.
59fn build_lut(gain: f32) -> Box<[u16; 65536]> {
60    // White point for the Hable curve: sensor max after gain should map to display white.
61    let white_point = gain.max(1e-6_f32);
62    let white_out = hable(white_point);
63
64    let mut table = Box::new([0u16; 65536]);
65    for (i, entry) in table.iter_mut().enumerate() {
66        let linear = i as f32 / 65535.0;
67        let exposed = linear * gain;
68        let tonemapped = (hable(exposed) / white_out).clamp(0.0, 1.0);
69        let srgb = srgb_encode(tonemapped);
70        *entry = (srgb * 65535.0 + 0.5) as u16;
71    }
72    table
73}
74
75/// Hable (Uncharted 2) filmic tone curve — single channel.
76///
77/// Reference: John Hable, "Filmic Tonemapping Operators", 2010.
78#[inline(always)]
79fn hable(x: f32) -> f32 {
80    const A: f32 = 0.15; // Shoulder strength
81    const B: f32 = 0.50; // Linear strength
82    const C: f32 = 0.10; // Linear angle
83    const D: f32 = 0.20; // Toe strength
84    const E: f32 = 0.02; // Toe numerator
85    const F: f32 = 0.30; // Toe denominator
86    ((x * (A * x + C * B) + D * E) / (x * (A * x + B) + D * F)) - E / F
87}
88
89/// sRGB piecewise transfer function (IEC 61966-2-1).
90///
91/// Converts a linear-light value in [0, 1] to an sRGB-encoded value in [0, 1].
92#[inline(always)]
93pub(crate) fn srgb_encode(linear: f32) -> f32 {
94    if linear <= 0.003_130_8 {
95        linear * 12.92
96    } else {
97        1.055 * linear.powf(1.0 / 2.4) - 0.055
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use crate::core::image::RgbImage;
105
106    fn make_image(values: &[u16]) -> RgbImage {
107        let n = values.len() as u32 / 3;
108        RgbImage::new(n, 1, values.to_vec())
109    }
110
111    #[test]
112    fn black_stays_black() {
113        let mut img = make_image(&[0, 0, 0]);
114        apply_tonemap(&mut img, None);
115        assert_eq!(img.data[0], 0);
116        assert_eq!(img.data[1], 0);
117        assert_eq!(img.data[2], 0);
118    }
119
120    #[test]
121    fn white_maps_to_white_no_exposure() {
122        // Sensor max (65535) with no baseline exposure should map to display white.
123        let mut img = make_image(&[65535, 65535, 65535]);
124        apply_tonemap(&mut img, None);
125        assert_eq!(img.data[0], 65535);
126    }
127
128    #[test]
129    fn negative_baseline_exposure_maps_sensor_white_to_display_white() {
130        // With negative EV, the camera captured extra highlight headroom.
131        // Sensor max should still map to display white (the curve remaps the range).
132        let mut img = make_image(&[65535, 65535, 65535]);
133        apply_tonemap(&mut img, Some(-0.83));
134        assert_eq!(img.data[0], 65535);
135    }
136
137    #[test]
138    fn negative_baseline_exposure_darkens_midtones() {
139        // Mid-grey with -EV should be darker than mid-grey with no EV, because
140        // the signal is attenuated before tone mapping.
141        let mut img_no_exp = make_image(&[32768, 32768, 32768]);
142        let mut img_neg_exp = make_image(&[32768, 32768, 32768]);
143        apply_tonemap(&mut img_no_exp, None);
144        apply_tonemap(&mut img_neg_exp, Some(-0.83));
145        assert!(
146            img_neg_exp.data[0] < img_no_exp.data[0],
147            "negative EV {} should darken mid-grey {}",
148            img_neg_exp.data[0],
149            img_no_exp.data[0]
150        );
151    }
152
153    #[test]
154    fn positive_baseline_exposure_brightens_midtones() {
155        let mut img_no_exp = make_image(&[16384, 16384, 16384]);
156        let mut img_pos_exp = make_image(&[16384, 16384, 16384]);
157        apply_tonemap(&mut img_no_exp, None);
158        apply_tonemap(&mut img_pos_exp, Some(0.5));
159        assert!(
160            img_pos_exp.data[0] > img_no_exp.data[0],
161            "positive EV {} should brighten {}",
162            img_pos_exp.data[0],
163            img_no_exp.data[0]
164        );
165    }
166
167    #[test]
168    fn srgb_encode_extremes() {
169        assert!((srgb_encode(0.0)).abs() < 1e-6);
170        assert!((srgb_encode(1.0) - 1.0).abs() < 1e-4);
171    }
172
173    #[test]
174    fn tone_reproduction_uses_gamma_when_specified() {
175        let mut img_gamma = make_image(&[32768, 32768, 32768]);
176        let mut img_filmic = make_image(&[32768, 32768, 32768]);
177        apply_tone_reproduction(&mut img_gamma, Some(2.2));
178        apply_tone_reproduction(&mut img_filmic, None);
179        // They should produce different results
180        assert_ne!(img_gamma.data[0], img_filmic.data[0]);
181    }
182
183    #[test]
184    fn tone_reproduction_none_matches_filmic() {
185        let mut img_repro = make_image(&[32768, 32768, 32768]);
186        let mut img_filmic = make_image(&[32768, 32768, 32768]);
187        apply_tone_reproduction(&mut img_repro, None);
188        apply_tonemap(&mut img_filmic, None);
189        assert_eq!(img_repro.data[0], img_filmic.data[0]);
190    }
191
192    #[test]
193    fn test_apply_tone_reproduction_clamps() {
194        // Values at white_level (65535) should map to display white (65535)
195        let mut img = make_image(&[65535, 65535, 65535]);
196        apply_tone_reproduction(&mut img, None);
197        assert_eq!(img.data[0], 65535, "white should stay at max after tonemap");
198        assert_eq!(img.data[1], 65535);
199        assert_eq!(img.data[2], 65535);
200    }
201
202    #[test]
203    fn test_linear_tone_reproduction() {
204        // With gamma=1.0, apply_tone_reproduction should be a near-identity for mid values.
205        // (gamma=1.0 is a fast-path in apply_gamma that does nothing)
206        let values = [1000u16, 32768, 65535];
207        let mut img = make_image(&values);
208        apply_tone_reproduction(&mut img, Some(1.0));
209        // gamma=1.0 is identity - values should be unchanged
210        assert_eq!(img.data[0], values[0]);
211        assert_eq!(img.data[1], values[1]);
212        assert_eq!(img.data[2], values[2]);
213    }
214
215    #[test]
216    fn test_tonemap_output_range() {
217        // All outputs must be in [0, 65535] regardless of gain
218        for ev in [-2.0f32, -0.83, 0.0, 0.5, 2.0] {
219            for val in [0u16, 1000, 32768, 65535] {
220                let mut img = make_image(&[val, val, val]);
221                apply_tonemap(&mut img, Some(ev));
222                // u16 is always in range [0, 65535] by definition
223                assert!(
224                    !img.data.is_empty(),
225                    "EV={}, input={}: output should not be empty",
226                    ev,
227                    val
228                );
229            }
230        }
231    }
232}