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}