Skip to main content

zenpixels_convert/
hdr.rs

1//! HDR processing utilities.
2//!
3//! Re-exports [`ContentLightLevel`] and [`MasteringDisplay`] from the
4//! `zenpixels` crate for convenience. Adds [`HdrMetadata`] (which bundles
5//! transfer function with the metadata types) and tone mapping helpers.
6//!
7//! The core PQ/HLG EOTF/OETF math is always available through the main
8//! conversion pipeline in [`ConvertPlan`](crate::ConvertPlan).
9
10use crate::TransferFunction;
11
12// Re-export metadata types from the core crate.
13pub use zenpixels::hdr::{ContentLightLevel, MasteringDisplay};
14
15/// Describes the HDR characteristics of pixel data.
16///
17/// Bundles transfer function, content light level, and mastering display
18/// metadata to provide everything needed for HDR processing.
19#[derive(Clone, Copy, Debug, PartialEq)]
20pub struct HdrMetadata {
21    /// Transfer function (PQ, HLG, sRGB, Linear, etc.).
22    pub transfer: TransferFunction,
23    /// Content light level (MaxCLL/MaxFALL). Optional.
24    pub content_light_level: Option<ContentLightLevel>,
25    /// Mastering display color volume. Optional.
26    pub mastering_display: Option<MasteringDisplay>,
27}
28
29impl HdrMetadata {
30    /// True if this describes HDR content (PQ or HLG transfer function).
31    #[must_use]
32    pub fn is_hdr(&self) -> bool {
33        matches!(self.transfer, TransferFunction::Pq | TransferFunction::Hlg)
34    }
35
36    /// True if this describes SDR content.
37    #[must_use]
38    pub fn is_sdr(&self) -> bool {
39        !self.is_hdr()
40    }
41
42    /// Create HDR10 metadata with PQ transfer.
43    pub fn hdr10(cll: ContentLightLevel) -> Self {
44        Self {
45            transfer: TransferFunction::Pq,
46            content_light_level: Some(cll),
47            mastering_display: Some(MasteringDisplay::HDR10_REFERENCE),
48        }
49    }
50
51    /// Create HLG metadata.
52    pub fn hlg() -> Self {
53        Self {
54            transfer: TransferFunction::Hlg,
55            content_light_level: None,
56            mastering_display: None,
57        }
58    }
59}
60
61// ---------------------------------------------------------------------------
62// Naive HDR ↔ SDR tone mapping (built-in, no deps)
63// ---------------------------------------------------------------------------
64
65/// Simple Reinhard-style tone mapping: HDR linear → SDR linear.
66///
67/// Maps linear light [0, ∞) → [0, 1) using `v / (1 + v)`.
68/// Preserves relative brightness ordering. Does not use any display
69/// metadata — for proper tone mapping, use a dedicated HDR tone mapping
70/// library.
71#[inline]
72#[must_use]
73pub fn reinhard_tonemap(v: f32) -> f32 {
74    v / (1.0 + v)
75}
76
77/// Inverse Reinhard: SDR linear → HDR linear.
78///
79/// Maps [0, 1) → [0, ∞) using `v / (1 - v)`.
80#[inline]
81#[must_use]
82pub fn reinhard_inverse(v: f32) -> f32 {
83    if v >= 1.0 {
84        return f32::MAX;
85    }
86    v / (1.0 - v)
87}
88
89/// Simple exposure-based tone mapping.
90///
91/// `exposure` is in stops relative to 1.0. Positive values brighten,
92/// negative darken. The result is clamped to [0, 1].
93///
94/// Requires `std` because `f32::powf` is not available in `no_std`.
95#[cfg(feature = "std")]
96#[inline]
97#[must_use]
98pub fn exposure_tonemap(v: f32, exposure: f32) -> f32 {
99    (v * 2.0f32.powf(exposure)).clamp(0.0, 1.0)
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn reinhard_boundaries() {
108        assert_eq!(reinhard_tonemap(0.0), 0.0);
109        assert!((reinhard_tonemap(1.0) - 0.5).abs() < 1e-6);
110        assert!(reinhard_tonemap(1000.0) > 0.99);
111        assert!(reinhard_tonemap(1000.0) < 1.0);
112    }
113
114    #[test]
115    fn reinhard_roundtrip() {
116        for &v in &[0.0, 0.1, 0.5, 1.0, 2.0, 10.0, 100.0] {
117            let mapped = reinhard_tonemap(v);
118            let unmapped = reinhard_inverse(mapped);
119            assert!(
120                (unmapped - v).abs() < 1e-4,
121                "Reinhard roundtrip failed for {v}: got {unmapped}"
122            );
123        }
124    }
125
126    #[test]
127    fn hdr_metadata_is_hdr() {
128        assert!(HdrMetadata::hdr10(ContentLightLevel::default()).is_hdr());
129        assert!(HdrMetadata::hlg().is_hdr());
130        assert!(
131            HdrMetadata {
132                transfer: TransferFunction::Srgb,
133                content_light_level: None,
134                mastering_display: None,
135            }
136            .is_sdr()
137        );
138    }
139
140    #[test]
141    fn hdr10_constructor() {
142        let cll = ContentLightLevel::new(4000, 1000);
143        let meta = HdrMetadata::hdr10(cll);
144        assert!(meta.is_hdr());
145        assert_eq!(meta.transfer, TransferFunction::Pq);
146        assert_eq!(meta.content_light_level, Some(cll));
147        assert!(meta.mastering_display.is_some());
148    }
149
150    #[test]
151    fn hlg_constructor() {
152        let meta = HdrMetadata::hlg();
153        assert!(meta.is_hdr());
154        assert_eq!(meta.transfer, TransferFunction::Hlg);
155        assert!(meta.content_light_level.is_none());
156        assert!(meta.mastering_display.is_none());
157    }
158
159    #[test]
160    #[cfg(feature = "std")]
161    fn exposure_tonemap_values() {
162        // 0 stops = unchanged (clamped to [0,1]).
163        assert!((exposure_tonemap(0.5, 0.0) - 0.5).abs() < 1e-6);
164        // +1 stop = doubled.
165        assert!((exposure_tonemap(0.25, 1.0) - 0.5).abs() < 1e-5);
166        // -1 stop = halved.
167        assert!((exposure_tonemap(0.5, -1.0) - 0.25).abs() < 1e-5);
168        // Clamped to [0,1].
169        assert_eq!(exposure_tonemap(0.8, 1.0), 1.0);
170        assert_eq!(exposure_tonemap(0.0, 5.0), 0.0);
171    }
172
173    #[test]
174    fn reinhard_inverse_at_one() {
175        assert_eq!(reinhard_inverse(1.0), f32::MAX);
176    }
177
178    #[test]
179    fn hdr_metadata_clone_partial_eq() {
180        let a = HdrMetadata::hlg();
181        let b = a;
182        assert_eq!(a, b);
183    }
184}