Skip to main content

zenpixels_convert/
hdr.rs

1//! HDR processing utilities.
2//!
3//! Provides HDR metadata types (content light level, mastering display),
4//! and basic tone mapping helpers. The core PQ/HLG EOTF/OETF math is
5//! always available through the main conversion pipeline in
6//! [`ConvertPlan`](crate::ConvertPlan).
7
8use crate::TransferFunction;
9
10/// HDR content light level metadata (CEA-861.3 / CTA-861-H).
11///
12/// Describes the peak brightness characteristics of HDR content.
13/// Used by AVIF, JXL, PNG (cLLi chunk), and video containers.
14#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
15pub struct ContentLightLevel {
16    /// Maximum Content Light Level (MaxCLL) in cd/m² (nits).
17    /// Peak luminance of any single pixel in the content.
18    pub max_content_light_level: u16,
19    /// Maximum Frame-Average Light Level (MaxFALL) in cd/m².
20    /// Peak average luminance of any single frame.
21    pub max_frame_average_light_level: u16,
22}
23
24impl ContentLightLevel {
25    /// Create content light level metadata.
26    pub const fn new(max_content_light_level: u16, max_frame_average_light_level: u16) -> Self {
27        Self {
28            max_content_light_level,
29            max_frame_average_light_level,
30        }
31    }
32}
33
34/// Mastering display color volume metadata (SMPTE ST 2086).
35///
36/// Describes the display on which the content was mastered, enabling
37/// downstream displays to reproduce the creator's intent.
38#[derive(Clone, Copy, Debug, Default, PartialEq)]
39pub struct MasteringDisplay {
40    /// RGB primaries of the mastering display in CIE 1931 xy coordinates.
41    /// `[[rx, ry], [gx, gy], [bx, by]]`.
42    pub primaries_xy: [[f32; 2]; 3],
43    /// White point in CIE 1931 xy coordinates `[wx, wy]`.
44    pub white_point_xy: [f32; 2],
45    /// Maximum display luminance in cd/m².
46    pub max_luminance: f32,
47    /// Minimum display luminance in cd/m².
48    pub min_luminance: f32,
49}
50
51impl MasteringDisplay {
52    /// Create mastering display metadata from CIE 1931 xy coordinates and cd/m² luminances.
53    pub const fn new(
54        primaries_xy: [[f32; 2]; 3],
55        white_point_xy: [f32; 2],
56        max_luminance: f32,
57        min_luminance: f32,
58    ) -> Self {
59        Self {
60            primaries_xy,
61            white_point_xy,
62            max_luminance,
63            min_luminance,
64        }
65    }
66
67    /// BT.2020 primaries with D65 white point, 10000 nits peak (HDR10 reference).
68    pub const HDR10_REFERENCE: Self = Self {
69        primaries_xy: [[0.708, 0.292], [0.170, 0.797], [0.131, 0.046]],
70        white_point_xy: [0.3127, 0.3290],
71        max_luminance: 10000.0,
72        min_luminance: 0.0001,
73    };
74
75    /// Display P3 primaries with D65 white point, 1000 nits.
76    pub const DISPLAY_P3_1000: Self = Self {
77        primaries_xy: [[0.680, 0.320], [0.265, 0.690], [0.150, 0.060]],
78        white_point_xy: [0.3127, 0.3290],
79        max_luminance: 1000.0,
80        min_luminance: 0.0001,
81    };
82}
83
84/// Describes the HDR characteristics of pixel data.
85///
86/// Bundles transfer function, content light level, and mastering display
87/// metadata to provide everything needed for HDR processing.
88#[derive(Clone, Copy, Debug, PartialEq)]
89pub struct HdrMetadata {
90    /// Transfer function (PQ, HLG, sRGB, Linear, etc.).
91    pub transfer: TransferFunction,
92    /// Content light level (MaxCLL/MaxFALL). Optional.
93    pub content_light_level: Option<ContentLightLevel>,
94    /// Mastering display color volume. Optional.
95    pub mastering_display: Option<MasteringDisplay>,
96}
97
98impl HdrMetadata {
99    /// True if this describes HDR content (PQ or HLG transfer function).
100    #[must_use]
101    pub fn is_hdr(&self) -> bool {
102        matches!(self.transfer, TransferFunction::Pq | TransferFunction::Hlg)
103    }
104
105    /// True if this describes SDR content.
106    #[must_use]
107    pub fn is_sdr(&self) -> bool {
108        !self.is_hdr()
109    }
110
111    /// Create HDR10 metadata with PQ transfer.
112    pub fn hdr10(cll: ContentLightLevel) -> Self {
113        Self {
114            transfer: TransferFunction::Pq,
115            content_light_level: Some(cll),
116            mastering_display: Some(MasteringDisplay::HDR10_REFERENCE),
117        }
118    }
119
120    /// Create HLG metadata.
121    pub fn hlg() -> Self {
122        Self {
123            transfer: TransferFunction::Hlg,
124            content_light_level: None,
125            mastering_display: None,
126        }
127    }
128}
129
130// ---------------------------------------------------------------------------
131// Naive HDR ↔ SDR tone mapping (built-in, no deps)
132// ---------------------------------------------------------------------------
133
134/// Simple Reinhard-style tone mapping: HDR linear → SDR linear.
135///
136/// Maps linear light [0, ∞) → [0, 1) using `v / (1 + v)`.
137/// Preserves relative brightness ordering. Does not use any display
138/// metadata — for proper tone mapping, use a dedicated HDR tone mapping
139/// library.
140#[inline]
141#[must_use]
142pub fn reinhard_tonemap(v: f32) -> f32 {
143    v / (1.0 + v)
144}
145
146/// Inverse Reinhard: SDR linear → HDR linear.
147///
148/// Maps [0, 1) → [0, ∞) using `v / (1 - v)`.
149#[inline]
150#[must_use]
151pub fn reinhard_inverse(v: f32) -> f32 {
152    if v >= 1.0 {
153        return f32::MAX;
154    }
155    v / (1.0 - v)
156}
157
158/// Simple exposure-based tone mapping.
159///
160/// `exposure` is in stops relative to 1.0. Positive values brighten,
161/// negative darken. The result is clamped to [0, 1].
162///
163/// Requires `std` because `f32::powf` is not available in `no_std`.
164#[cfg(feature = "std")]
165#[inline]
166#[must_use]
167pub fn exposure_tonemap(v: f32, exposure: f32) -> f32 {
168    (v * 2.0f32.powf(exposure)).clamp(0.0, 1.0)
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn reinhard_boundaries() {
177        assert_eq!(reinhard_tonemap(0.0), 0.0);
178        assert!((reinhard_tonemap(1.0) - 0.5).abs() < 1e-6);
179        assert!(reinhard_tonemap(1000.0) > 0.99);
180        assert!(reinhard_tonemap(1000.0) < 1.0);
181    }
182
183    #[test]
184    fn reinhard_roundtrip() {
185        for &v in &[0.0, 0.1, 0.5, 1.0, 2.0, 10.0, 100.0] {
186            let mapped = reinhard_tonemap(v);
187            let unmapped = reinhard_inverse(mapped);
188            assert!(
189                (unmapped - v).abs() < 1e-4,
190                "Reinhard roundtrip failed for {v}: got {unmapped}"
191            );
192        }
193    }
194
195    #[test]
196    fn hdr_metadata_is_hdr() {
197        assert!(HdrMetadata::hdr10(ContentLightLevel::default()).is_hdr());
198        assert!(HdrMetadata::hlg().is_hdr());
199        assert!(
200            HdrMetadata {
201                transfer: TransferFunction::Srgb,
202                content_light_level: None,
203                mastering_display: None,
204            }
205            .is_sdr()
206        );
207    }
208
209    #[test]
210    fn content_light_level_new() {
211        let cll = ContentLightLevel::new(1000, 500);
212        assert_eq!(cll.max_content_light_level, 1000);
213        assert_eq!(cll.max_frame_average_light_level, 500);
214    }
215
216    #[test]
217    fn content_light_level_default() {
218        let cll = ContentLightLevel::default();
219        assert_eq!(cll.max_content_light_level, 0);
220        assert_eq!(cll.max_frame_average_light_level, 0);
221    }
222
223    #[test]
224    fn mastering_display_new() {
225        let md = MasteringDisplay::new(
226            [[0.68, 0.32], [0.265, 0.69], [0.15, 0.06]],
227            [0.3127, 0.329],
228            1000.0,
229            0.001,
230        );
231        assert_eq!(md.max_luminance, 1000.0);
232        assert_eq!(md.min_luminance, 0.001);
233    }
234
235    #[test]
236    fn mastering_display_constants() {
237        assert_eq!(MasteringDisplay::HDR10_REFERENCE.max_luminance, 10000.0);
238        assert_eq!(MasteringDisplay::DISPLAY_P3_1000.max_luminance, 1000.0);
239    }
240
241    #[test]
242    fn hdr10_constructor() {
243        let cll = ContentLightLevel::new(4000, 1000);
244        let meta = HdrMetadata::hdr10(cll);
245        assert!(meta.is_hdr());
246        assert_eq!(meta.transfer, TransferFunction::Pq);
247        assert_eq!(meta.content_light_level, Some(cll));
248        assert!(meta.mastering_display.is_some());
249    }
250
251    #[test]
252    fn hlg_constructor() {
253        let meta = HdrMetadata::hlg();
254        assert!(meta.is_hdr());
255        assert_eq!(meta.transfer, TransferFunction::Hlg);
256        assert!(meta.content_light_level.is_none());
257        assert!(meta.mastering_display.is_none());
258    }
259
260    #[test]
261    #[cfg(feature = "std")]
262    fn exposure_tonemap_values() {
263        // 0 stops = unchanged (clamped to [0,1]).
264        assert!((exposure_tonemap(0.5, 0.0) - 0.5).abs() < 1e-6);
265        // +1 stop = doubled.
266        assert!((exposure_tonemap(0.25, 1.0) - 0.5).abs() < 1e-5);
267        // -1 stop = halved.
268        assert!((exposure_tonemap(0.5, -1.0) - 0.25).abs() < 1e-5);
269        // Clamped to [0,1].
270        assert_eq!(exposure_tonemap(0.8, 1.0), 1.0);
271        assert_eq!(exposure_tonemap(0.0, 5.0), 0.0);
272    }
273
274    #[test]
275    fn reinhard_inverse_at_one() {
276        assert_eq!(reinhard_inverse(1.0), f32::MAX);
277    }
278
279    #[test]
280    fn content_light_level_clone_eq() {
281        let a = ContentLightLevel::new(100, 50);
282        let b = a;
283        assert_eq!(a, b);
284    }
285
286    #[test]
287    #[cfg(feature = "std")]
288    fn content_light_level_hash() {
289        use core::hash::{Hash, Hasher};
290        let a = ContentLightLevel::new(100, 50);
291        let b = a;
292        let mut h1 = std::hash::DefaultHasher::new();
293        a.hash(&mut h1);
294        let mut h2 = std::hash::DefaultHasher::new();
295        b.hash(&mut h2);
296        assert_eq!(h1.finish(), h2.finish());
297    }
298
299    #[test]
300    fn hdr_metadata_clone_partial_eq() {
301        let a = HdrMetadata::hlg();
302        let b = a;
303        assert_eq!(a, b);
304    }
305}