Skip to main content

oxideav_basic/
filter.rs

1//! Small image-filter primitives, grouped here while there are too few to
2//! deserve their own crate.
3//!
4//! Currently exposes a single typed scalar tone-mapping operator: the
5//! Reinhard 2002 simple global form `Ld = L / (1 + L)`. The math is a
6//! published uncopyrightable fact (Reinhard et al., ACM TOG SIGGRAPH 2002),
7//! transcribed in `docs/image/filter/tone-mapping-operators.md` §2.2. This
8//! crate re-states the formula and pins it behind a typed wrapper so
9//! callers can't accidentally mix scene and display luminance domains.
10//!
11//! The operator has a closed-form inverse on `Ld ∈ [0, 1)` (see
12//! [`DisplayLuminance::to_scene`] below), which is what the round-trip
13//! unit test exercises. There is no implicit clamping: the caller decides
14//! whether to saturate `Ld` to a smaller range (e.g. for an 8-bit output).
15//!
16//! # Why a typed scalar
17//!
18//! The wider image-filter pipeline carries planar pixel data through other
19//! crates. The point of this module is *not* to be a fast batch-luminance
20//! tone-mapper — it is the operator's reference scalar form, suitable for
21//! per-pixel use, LUT building, or unit-testing other compressors against
22//! a known-good identity round-trip.
23
24/// Scene (world) luminance: a non-negative scalar in arbitrary linear units.
25///
26/// "Scene" here means pre-tone-mapping luminance, before the simple Reinhard
27/// curve `Ld = L / (1 + L)` has been applied. The struct does not enforce a
28/// specific unit (cd/m², linear-light normalized, …) — that's the caller's
29/// contract. It does enforce non-negativity and finiteness.
30#[derive(Clone, Copy, Debug, PartialEq)]
31pub struct SceneLuminance(f64);
32
33impl SceneLuminance {
34    /// Construct a scene luminance. Returns `None` for negative, NaN, or
35    /// infinite inputs — none of those are valid linear-light luminance.
36    pub fn new(l: f64) -> Option<Self> {
37        if l.is_finite() && l >= 0.0 {
38            Some(Self(l))
39        } else {
40            None
41        }
42    }
43
44    /// Inner scalar.
45    #[inline]
46    pub fn get(self) -> f64 {
47        self.0
48    }
49
50    /// Apply the Reinhard 2002 simple global tone-reproduction curve:
51    ///
52    /// `Ld = L / (1 + L)`
53    ///
54    /// The result is always in the half-open range `[0, 1)` for any
55    /// finite non-negative `L`, so the conversion is total.
56    ///
57    /// Source: `docs/image/filter/tone-mapping-operators.md` §2.2
58    /// (the simple form, no white point, no key scaling).
59    pub fn to_display(self) -> DisplayLuminance {
60        let l = self.0;
61        let ld = l / (1.0 + l);
62        // ld is in [0, 1) by construction for L ∈ [0, ∞), so the
63        // unchecked constructor is safe by maths.
64        DisplayLuminance(ld)
65    }
66}
67
68/// Display luminance after tone-mapping: a scalar in `[0, 1)`.
69///
70/// This is the output domain of [`SceneLuminance::to_display`]. The closed
71/// half-open range comes from the asymptote of `L/(1+L)` at `L → ∞`: 1 is
72/// approached but never reached, so any displayable pixel value lives
73/// strictly below 1. The closed-form inverse below relies on that.
74#[derive(Clone, Copy, Debug, PartialEq)]
75pub struct DisplayLuminance(f64);
76
77impl DisplayLuminance {
78    /// Construct a display luminance. Returns `None` for inputs outside
79    /// `[0, 1)`, NaN, or non-finite values. `1.0` is rejected because it
80    /// is unreachable by the forward curve (and the inverse blows up).
81    pub fn new(ld: f64) -> Option<Self> {
82        if ld.is_finite() && (0.0..1.0).contains(&ld) {
83            Some(Self(ld))
84        } else {
85            None
86        }
87    }
88
89    /// Inner scalar.
90    #[inline]
91    pub fn get(self) -> f64 {
92        self.0
93    }
94
95    /// Inverse of [`SceneLuminance::to_display`]:
96    ///
97    /// `L = Ld / (1 − Ld)`
98    ///
99    /// Derived by solving `Ld = L/(1+L)` for `L`. Total on the
100    /// half-open construction range `[0, 1)`. The result is non-negative
101    /// and finite (it grows without bound as `Ld → 1`, but the
102    /// constructor refuses `Ld == 1.0`, so we never hit infinity here).
103    pub fn to_scene(self) -> SceneLuminance {
104        let ld = self.0;
105        // 1.0 - ld is strictly positive because the constructor rejects ld == 1.
106        let l = ld / (1.0 - ld);
107        SceneLuminance(l)
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn scene_constructor_rejects_invalid() {
117        assert!(SceneLuminance::new(-0.1).is_none());
118        assert!(SceneLuminance::new(f64::NAN).is_none());
119        assert!(SceneLuminance::new(f64::INFINITY).is_none());
120        assert!(SceneLuminance::new(0.0).is_some());
121        assert!(SceneLuminance::new(1.0e6).is_some());
122    }
123
124    #[test]
125    fn display_constructor_rejects_invalid() {
126        assert!(DisplayLuminance::new(-0.01).is_none());
127        assert!(DisplayLuminance::new(1.0).is_none());
128        assert!(DisplayLuminance::new(1.001).is_none());
129        assert!(DisplayLuminance::new(f64::NAN).is_none());
130        assert!(DisplayLuminance::new(0.0).is_some());
131        assert!(DisplayLuminance::new(0.99999).is_some());
132    }
133
134    #[test]
135    fn known_curve_values() {
136        // L = 0 → Ld = 0 (no light maps to no light).
137        assert_eq!(SceneLuminance::new(0.0).unwrap().to_display().get(), 0.0);
138        // L = 1 → Ld = 0.5 (the symmetric point of the curve).
139        assert_eq!(SceneLuminance::new(1.0).unwrap().to_display().get(), 0.5);
140        // L = 3 → Ld = 0.75; L = 9 → Ld = 0.9.
141        assert!((SceneLuminance::new(3.0).unwrap().to_display().get() - 0.75).abs() < 1e-15);
142        assert!((SceneLuminance::new(9.0).unwrap().to_display().get() - 0.9).abs() < 1e-15);
143    }
144
145    #[test]
146    fn forward_output_is_strictly_below_one() {
147        // Even at very large L the curve stays strictly < 1.
148        let big = SceneLuminance::new(1.0e12).unwrap().to_display().get();
149        assert!(big < 1.0, "Ld must remain strictly below 1, got {big}");
150    }
151
152    /// Round-trip: `scene → display → scene` reproduces the input over a
153    /// representative range of scene luminances.
154    #[test]
155    fn scene_display_scene_roundtrip() {
156        // Test points span dark, mid, and high-light regions. The upper
157        // limit stays in a range where 1/(1+L) is representable with
158        // many significant bits; well above L≈1e7 the round-trip starts
159        // losing ULPs to the `1+L` cancellation and the tolerance below
160        // would no longer be meaningful.
161        let probes: &[f64] = &[
162            0.0, 1.0e-9, 0.001, 0.018, 0.18, 1.0, 2.5, 10.0, 100.0, 1.0e4, 1.0e6,
163        ];
164        for &l in probes {
165            let scene = SceneLuminance::new(l).unwrap();
166            let back = scene.to_display().to_scene();
167            // f64 has ~15-16 decimal digits of precision, but the
168            // `1+L` step costs ULPs that scale with L. A relative
169            // tolerance of 1e-9 is comfortably above the worst-case
170            // round-off across the probe set and well below any
171            // perceptual threshold.
172            let denom = if l > 0.0 { l } else { 1.0 };
173            let rel = (back.get() - l).abs() / denom;
174            assert!(
175                rel < 1e-9,
176                "round-trip lost precision at L={l}: got {got}, rel err {rel}",
177                got = back.get(),
178            );
179        }
180    }
181
182    /// Round-trip the other way too: `display → scene → display` over the
183    /// open `[0, 1)` range reproduces the input.
184    #[test]
185    fn display_scene_display_roundtrip() {
186        let probes: &[f64] = &[0.0, 0.001, 0.1, 0.25, 0.5, 0.75, 0.9, 0.99, 0.9999];
187        for &ld in probes {
188            let display = DisplayLuminance::new(ld).unwrap();
189            let back = display.to_scene().to_display();
190            let denom = if ld > 0.0 { ld } else { 1.0 };
191            let rel = (back.get() - ld).abs() / denom;
192            assert!(
193                rel < 1e-9,
194                "round-trip lost precision at Ld={ld}: got {got}, rel err {rel}",
195                got = back.get(),
196            );
197        }
198    }
199}