Skip to main content

zenpixels/
hdr.rs

1//! HDR metadata types.
2//!
3//! Pure data types for HDR content description. These travel with pixel
4//! data alongside [`Cicp`](crate::Cicp) and [`ColorContext`](crate::ColorContext).
5//!
6//! For tone mapping and HDR processing functions, see
7//! [`zenpixels-convert::hdr`](https://docs.rs/zenpixels-convert/latest/zenpixels_convert/hdr/).
8//!
9//! For SOTA content-light-level measurement (MaxCLL / MaxFALL — histogram-
10//! based, percentile-aware, SIMD-accelerated), see the `measure` module
11//! and the `CllMeasure` extension trait in `zenpixels-convert`.
12
13// `PixelSlice` / `PixelFormat` / `TransferFunction` are imported only for the
14// deprecated `ContentLightLevel::measure` inherent method — kept fully working
15// through the 0.2.x line for semver stability. New code should use
16// `zenpixels_convert::hdr::measure::CllMeasure::measure_max` (the audited
17// production-best, SIMD path). Removal of this method stays queued for the
18// next breaking release.
19use crate::{PixelFormat, PixelSlice, TransferFunction};
20
21/// The absolute luminance, in cd/m² (nits), that a relative-linear sample
22/// value of `1.0` represents — the "diffuse white" (a.k.a. nominal diffuse
23/// white / SDR reference white) anchor that bridges relative-linear pixel
24/// data to absolute display light.
25///
26/// This is the single scalar the rest of the industry uses for that bridge:
27/// OpenEXR's `whiteLuminance` ("nits of RGB (1,1,1)"), JPEG XL's
28/// `intensity_target`, libheif's `ndwt` (nominal diffuse white), and
29/// libplacebo's SDR-white constant. The cross-vendor default is
30/// [`BT2408`](Self::BT2408) = 203 cd/m².
31///
32/// It is a *typed* anchor on purpose: HDR code mixes nits, PQ-encoded `[0,1]`,
33/// log2 gain, and headroom ratios — passing a bare `f32` invites unit
34/// confusion. Use [`DiffuseWhite::new`] / [`DiffuseWhite::nits`].
35#[derive(Clone, Copy, Debug)]
36pub struct DiffuseWhite(f32);
37
38// Bit-exact equality so `DiffuseWhite` — and therefore `ColorContext` — keeps
39// `Eq` despite wrapping `f32`. A luminance anchor is always a sane, finite,
40// positive cd/m² value (203, 100, 10000, …), so a bitwise compare is reflexive
41// and consistent; the -0.0 / NaN cases a value compare would treat differently
42// never occur for an anchor.
43impl PartialEq for DiffuseWhite {
44    fn eq(&self, other: &Self) -> bool {
45        self.0.to_bits() == other.0.to_bits()
46    }
47}
48impl Eq for DiffuseWhite {}
49
50impl DiffuseWhite {
51    /// ITU-R BT.2408 HDR reference white: **203 cd/m²**. The cross-industry
52    /// default anchor for relative-linear HDR (matches Chrome `SDRWhiteLevel`,
53    /// Skia skcms, CSS `rec2100-linear`, and libplacebo).
54    pub const BT2408: Self = Self(203.0);
55
56    /// An anchor of `nits` cd/m² (the luminance that relative-linear `1.0`
57    /// represents).
58    #[must_use]
59    pub const fn new(nits: f32) -> Self {
60        Self(nits)
61    }
62
63    /// The anchor in cd/m² (nits).
64    #[must_use]
65    pub const fn nits(self) -> f32 {
66        self.0
67    }
68}
69
70impl Default for DiffuseWhite {
71    /// [`BT2408`](Self::BT2408) — 203 cd/m².
72    fn default() -> Self {
73        Self::BT2408
74    }
75}
76
77/// Round non-negative nits to a CTA-861.3 `u16` code (saturating).
78///
79/// `nits` is a luminance — always `≥ 0` at the call sites. Round-half-up is
80/// then `(nits + 0.5)` truncated, and the float→int `as` cast saturates to
81/// `[0, u16::MAX]` (mapping negatives and NaN to 0). Done by hand because
82/// `f64::round` lives in `std` (libm) and this crate builds `no_std`.
83///
84/// Used only by the deprecated [`ContentLightLevel::measure`]; the
85/// maintained copy lives in `zenpixels-convert`'s `hdr::measure`.
86#[inline]
87fn nits_to_u16(nits: f64) -> u16 {
88    (nits + 0.5) as u16
89}
90
91/// Reduce one row of `N`-channel f32 pixels to
92/// `(max, sum)` of the per-pixel `max(R, G, B)`.
93///
94/// `N` is the channel count (3 = `Rgb`, 4 = `Rgba`); only the first three
95/// lanes are read, so any alpha is ignored. Each channel is folded from `0.0`,
96/// so `f32::max`'s non-NaN-propagating semantics drop NaN and negative samples.
97/// `chunk` is reborrowed as a fixed-size `&[f32; N]` so the bounds checks fall
98/// away and LLVM can vectorize the reduction. The sum accumulates in `f64`:
99/// a 4K frame is ~8M pixels, beyond f32's precision for a running total.
100///
101/// Used only by the deprecated [`ContentLightLevel::measure`]; the
102/// maintained (SIMD) copy lives in `zenpixels-convert`'s `hdr::measure`.
103#[inline]
104fn row_max_sum<const N: usize>(row: &[f32]) -> (f32, f64) {
105    let mut row_max = 0.0f32;
106    let mut row_sum = 0.0f64;
107    for chunk in row.chunks_exact(N) {
108        // `chunks_exact(N)` yields exactly-`N` slices — the conversion is infallible.
109        let px: &[f32; N] = chunk.try_into().unwrap();
110        let m = 0.0f32.max(px[0]).max(px[1]).max(px[2]);
111        row_max = row_max.max(m);
112        row_sum += f64::from(m);
113    }
114    (row_max, row_sum)
115}
116
117/// HDR content light level metadata (CEA-861.3 / CTA-861-H).
118///
119/// Describes the peak brightness characteristics of HDR content.
120/// Used by AVIF, JXL, PNG (cLLi chunk), and video containers.
121#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
122pub struct ContentLightLevel {
123    /// Maximum Content Light Level (MaxCLL) in cd/m² (nits).
124    /// Peak luminance of any single pixel in the content.
125    pub max_content_light_level: u16,
126    /// Maximum Frame-Average Light Level (MaxFALL) in cd/m².
127    /// Peak average luminance of any single frame.
128    pub max_frame_average_light_level: u16,
129}
130
131impl ContentLightLevel {
132    /// Create content light level metadata.
133    pub const fn new(max_content_light_level: u16, max_frame_average_light_level: u16) -> Self {
134        Self {
135            max_content_light_level,
136            max_frame_average_light_level,
137        }
138    }
139
140    /// Tail-tightest percentile for explicit percentile-based MaxCLL.
141    ///
142    /// `0.99999` — the 99.999th percentile, dropping the top 0.001 % of
143    /// pixels. Empirically the tail-tightest tested value in the
144    /// 2026-06-22 audited HDR→SDR shootout (76 imazen-26 samples × 20
145    /// curves × 4 peak methods, scored on tail-aware metrics + OKLab
146    /// Euclidean ΔE against the producer SDR base): won every per-image
147    /// tail metric (`de2000_p95`, `de2000_p99`, `de_ok_p95`) by 1.4-1.8 %
148    /// over the literal-max alternative.
149    ///
150    /// **The recommended production default is the literal max**
151    /// (`CllMeasure::measure_max`), NOT this percentile — the same
152    /// shootout showed `measure_max` wins on 3 of 6 metrics including
153    /// the user-visible `pct_above_de5` (11 % fewer clearly-different
154    /// pixels). This constant exists for callers who explicitly opt
155    /// into percentile-based measurement via `measure_percentile`
156    /// because their content policy needs the tail-tighter trade-off
157    /// (defect-noisy capture path, single hot pixels would over-drive
158    /// downstream tone-mapping). See
159    /// `zen/zentone/benchmarks/shootout_2026-06-22_findings_v2.md`.
160    ///
161    /// **Sparse-bright cliff.** Content that occupies < 0.001 % of
162    /// pixels is silently dropped at any image size. For 24 MP that's
163    /// anything below ~240 pixels; for 1 MP, below ~10 pixels; for
164    /// small images (< 100 000 pixels) the fraction rounds to "any
165    /// single bright pixel". Astrophotography, fireworks, and
166    /// candle-in-dark-room content where every bright pixel is
167    /// legitimate should use the literal max reading instead.
168    ///
169    /// **Bin quantisation.** The percentile readout reports the
170    /// lower edge of the log2 histogram bin that contains the
171    /// percentile-threshold pixel — up to ~2 % (one bin = ~0.02 stops)
172    /// below the literal max even when all content lives in one bin.
173    /// Acceptable for HDR metadata at the u16-nits granularity CTA-861.3
174    /// encodes, but documented so callers comparing against the literal
175    /// max know to expect this.
176    ///
177    /// Used by `CllMeasure::measure_robust` in `zenpixels-convert`.
178    /// Explicit callers who want a non-default percentile pass their own
179    /// value to `CllMeasure::measure_percentile`.
180    #[doc(hidden)]
181    pub const DEFAULT_PERCENTILE: f32 = 0.99999;
182
183    /// **Deprecated** — superseded by
184    /// [`zenpixels_convert::hdr::measure::CllMeasure::measure_max`], the
185    /// maintained (SIMD, ≥1 Gpix/s on Zen 4 / AVX2) implementation that won
186    /// the 2026-06-22 audited HDR→SDR shootout on 3 of 6 ranking criteria
187    /// including the user-visible `pct_above_de5`. This method stays fully
188    /// functional through the 0.2.x line (never panics); removal is queued
189    /// for the next breaking release.
190    ///
191    /// Measures MaxCLL / MaxFALL (CTA-861.3-A) from relative-linear RGB(A)
192    /// f32 pixels, with `white` anchoring the scale (sample `1.0` = `white`
193    /// nits; [`DiffuseWhite::BT2408`] — 203 — is the convention).
194    ///
195    /// Semantics per CTA-861.3-A as PNG 3rd ed §11.3.2.8 imports it for stills
196    /// (one still = one frame): **MaxCLL** is the brightest pixel's
197    /// `max(R, G, B)` in cd/m², **MaxFALL** is the image's average of per-pixel
198    /// `max(R, G, B)`. Negative/NaN samples clamp to 0; an alpha lane is
199    /// ignored; strided rows are handled.
200    ///
201    /// Returns `None` if the descriptor is not relative-linear
202    /// `RgbF32`/`RgbaF32` — cd/m² is only defined in linear light, and
203    /// inverting a transfer function is the conversion pipeline's job
204    /// (`zenpixels_convert::convert_buffer`). Zero-area input yields
205    /// `Some(0, 0)`.
206    #[must_use]
207    #[doc(hidden)]
208    #[deprecated(
209        since = "0.2.16",
210        note = "use zenpixels_convert::hdr::measure::CllMeasure::measure_max instead (the maintained SIMD path). This method remains functional through 0.2.x; removal queued for the next breaking release."
211    )]
212    pub fn measure(px: PixelSlice<'_>, white: DiffuseWhite) -> Option<Self> {
213        let desc = px.descriptor();
214        let channels = match desc.pixel_format() {
215            PixelFormat::RgbF32 => 3,
216            PixelFormat::RgbaF32 => 4,
217            _ => return None,
218        };
219        if desc.transfer != TransferFunction::Linear {
220            return None;
221        }
222        let w = px.width() as usize;
223        let h = px.rows() as usize;
224        if w == 0 || h == 0 {
225            return Some(Self::new(0, 0));
226        }
227        let stride = px.stride();
228        let bytes = px.as_strided_bytes();
229        let row_len = w * channels * 4;
230
231        // Reduce in relative-linear units, then scale by the anchor once at the
232        // end — ∑(mᵢ·w) = (∑mᵢ)·w, fewer multiplies for the same f64 result.
233        let mut max_lin = 0.0f32;
234        let mut sum_lin = 0.0f64;
235        for row in 0..h {
236            let row_bytes = &bytes[row * stride..row * stride + row_len];
237            // f32 buffers are channel-aligned (the `PixelBuffer` alignment
238            // invariant), and `row_len` is a multiple of 4, so this cast never
239            // straddles a sample — and reading whole f32s lets the reduction
240            // vectorize, unlike per-byte `from_ne_bytes`.
241            let floats: &[f32] = bytemuck::cast_slice(row_bytes);
242            let (row_max, row_sum) = if channels == 3 {
243                row_max_sum::<3>(floats)
244            } else {
245                row_max_sum::<4>(floats)
246            };
247            max_lin = max_lin.max(row_max);
248            sum_lin += row_sum;
249        }
250        let wn = f64::from(white.nits());
251        let max_nits = f64::from(max_lin) * wn;
252        let fall = sum_lin / (w as f64 * h as f64) * wn;
253        Some(Self::new(nits_to_u16(max_nits), nits_to_u16(fall)))
254    }
255}
256
257/// Mastering display color volume metadata (SMPTE ST 2086).
258///
259/// Describes the display on which the content was mastered, enabling
260/// downstream displays to reproduce the creator's intent.
261#[derive(Clone, Copy, Debug, Default, PartialEq)]
262pub struct MasteringDisplay {
263    /// RGB primaries of the mastering display in CIE 1931 xy coordinates.
264    /// `[[rx, ry], [gx, gy], [bx, by]]`.
265    pub primaries_xy: [[f32; 2]; 3],
266    /// White point in CIE 1931 xy coordinates `[wx, wy]`.
267    pub white_point_xy: [f32; 2],
268    /// Maximum display luminance in cd/m².
269    pub max_luminance: f32,
270    /// Minimum display luminance in cd/m².
271    pub min_luminance: f32,
272}
273
274impl MasteringDisplay {
275    /// Create mastering display metadata from CIE 1931 xy coordinates and cd/m² luminances.
276    pub const fn new(
277        primaries_xy: [[f32; 2]; 3],
278        white_point_xy: [f32; 2],
279        max_luminance: f32,
280        min_luminance: f32,
281    ) -> Self {
282        Self {
283            primaries_xy,
284            white_point_xy,
285            max_luminance,
286            min_luminance,
287        }
288    }
289
290    /// BT.2020 primaries with D65 white point, 10000 nits peak (HDR10 reference).
291    pub const HDR10_REFERENCE: Self = Self {
292        primaries_xy: [[0.708, 0.292], [0.170, 0.797], [0.131, 0.046]],
293        white_point_xy: [0.3127, 0.3290],
294        max_luminance: 10000.0,
295        min_luminance: 0.0001,
296    };
297
298    /// Display P3 primaries with D65 white point, 1000 nits.
299    pub const DISPLAY_P3_1000: Self = Self {
300        primaries_xy: [[0.680, 0.320], [0.265, 0.690], [0.150, 0.060]],
301        white_point_xy: [0.3127, 0.3290],
302        max_luminance: 1000.0,
303        min_luminance: 0.0001,
304    };
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use crate::{PixelBuffer, PixelDescriptor};
311    use alloc::vec::Vec;
312
313    fn rgbf32(pixels: &[[f32; 3]], w: u32, h: u32) -> PixelBuffer {
314        let mut data = Vec::with_capacity(pixels.len() * 12);
315        for p in pixels {
316            for c in p {
317                data.extend_from_slice(&c.to_ne_bytes());
318            }
319        }
320        PixelBuffer::from_vec(data, w, h, PixelDescriptor::RGBF32_LINEAR).unwrap()
321    }
322
323    // The deprecated `measure` regained its working 0.2.14 body after briefly
324    // carrying an `unimplemented!()` shim on the unreleased 0.2.16 line —
325    // these tests pin that it computes real values (and never panics) until
326    // its queued removal in the next breaking release. The maintained
327    // replacement is `zenpixels-convert`'s `CllMeasure::measure_max`;
328    // cross-crate consistency is pinned in
329    // `zenpixels-convert/tests/deprecated_measure_parity.rs`.
330    #[test]
331    #[allow(deprecated)]
332    fn measure_two_grays_cta_stills_semantics() {
333        // [1.0, 2.0] @ 203: MaxCLL = 2·203 = 406; MaxFALL = avg(203, 406) = 304.5 → 305.
334        let buf = rgbf32(&[[1.0; 3], [2.0; 3]], 2, 1);
335        let cll = ContentLightLevel::measure(buf.as_slice(), DiffuseWhite::BT2408).unwrap();
336        assert_eq!(cll.max_content_light_level, 406);
337        assert_eq!(cll.max_frame_average_light_level, 305);
338    }
339
340    #[test]
341    #[allow(deprecated)]
342    fn measure_handles_stride_and_ignores_padding() {
343        use crate::PixelSlice;
344        // 2×2 RGB f32: 6 real f32/row, padded to 9 f32/row (36-byte stride, a
345        // multiple of the 12-byte pixel). The padding holds a 1e9 sentinel — if
346        // a row cast ever ran past `width*bpp`, MaxCLL would explode to ~2e11.
347        let (w, h, row_floats) = (2u32, 2u32, 9usize);
348        let mut data = alloc::vec![1.0e9f32; row_floats * h as usize];
349        let pixels = [[0.5f32; 3], [1.0; 3], [2.0; 3], [0.25; 3]];
350        for (i, p) in pixels.iter().enumerate() {
351            let base = (i / w as usize) * row_floats + (i % w as usize) * 3;
352            data[base..base + 3].copy_from_slice(p);
353        }
354        // `Vec<f32>` is f32-aligned, so the byte view satisfies the slice's
355        // alignment contract; stride 36 is a multiple of the f32 size.
356        let bytes: &[u8] = bytemuck::cast_slice(&data);
357        let px =
358            PixelSlice::new(bytes, w, h, row_floats * 4, PixelDescriptor::RGBF32_LINEAR).unwrap();
359        let cll = ContentLightLevel::measure(px, DiffuseWhite::BT2408).unwrap();
360        // Peak max(R,G,B) = 2.0 → 406; FALL = avg(0.5,1,2,0.25)·203 = 190.3 → 190.
361        assert_eq!(cll.max_content_light_level, 406);
362        assert_eq!(cll.max_frame_average_light_level, 190);
363    }
364
365    #[test]
366    #[allow(deprecated)]
367    fn measure_clamps_nan_and_negative() {
368        let buf = rgbf32(&[[-1.0, f32::NAN, 0.5]], 1, 1);
369        let cll = ContentLightLevel::measure(buf.as_slice(), DiffuseWhite::BT2408).unwrap();
370        // max(R,G,B) folds from 0.0 → 0.5 · 203 = 101.5 → 102.
371        assert_eq!(cll.max_content_light_level, 102);
372        assert_eq!(cll.max_frame_average_light_level, 102);
373    }
374
375    #[test]
376    #[allow(deprecated)]
377    fn measure_ignores_alpha_and_custom_white() {
378        let mut data = Vec::new();
379        for c in [0.5f32, 0.5, 0.5, 7.0] {
380            data.extend_from_slice(&c.to_ne_bytes());
381        }
382        let buf = PixelBuffer::from_vec(data, 1, 1, PixelDescriptor::RGBAF32_LINEAR).unwrap();
383        // alpha 7.0 ignored; custom 100-nit white: 0.5 · 100 = 50.
384        let cll = ContentLightLevel::measure(buf.as_slice(), DiffuseWhite::new(100.0)).unwrap();
385        assert_eq!(cll.max_content_light_level, 50);
386    }
387
388    #[test]
389    #[allow(deprecated)]
390    fn measure_rejects_non_linear_and_non_f32() {
391        let u8buf =
392            PixelBuffer::from_vec(alloc::vec![0u8; 3], 1, 1, PixelDescriptor::RGB8_SRGB).unwrap();
393        assert!(ContentLightLevel::measure(u8buf.as_slice(), DiffuseWhite::BT2408).is_none());
394
395        let nonlinear = PixelDescriptor::RGBF32_LINEAR.with_transfer(TransferFunction::Srgb);
396        let mut data = Vec::new();
397        for c in [0.5f32; 3] {
398            data.extend_from_slice(&c.to_ne_bytes());
399        }
400        let buf = PixelBuffer::from_vec(data, 1, 1, nonlinear).unwrap();
401        assert!(ContentLightLevel::measure(buf.as_slice(), DiffuseWhite::BT2408).is_none());
402    }
403
404    #[test]
405    fn diffuse_white_defaults_to_bt2408() {
406        assert_eq!(DiffuseWhite::default(), DiffuseWhite::BT2408);
407        assert_eq!(DiffuseWhite::BT2408.nits(), 203.0);
408        assert_eq!(DiffuseWhite::new(100.0).nits(), 100.0);
409    }
410
411    #[test]
412    fn diffuse_white_custom_anchor_round_trips() {
413        // Anchor metadata is byte-identical-preserved through the constructor:
414        // a custom 100 cd/m² (HDR home-tier mastering) and 10 000 cd/m² (PQ
415        // peak) both round-trip through `new` → `nits` losslessly.
416        assert_eq!(DiffuseWhite::new(100.0).nits(), 100.0);
417        assert_eq!(DiffuseWhite::new(10_000.0).nits(), 10_000.0);
418        // PartialEq honours bit equality (see the impl above) so two
419        // independently constructed anchors compare equal.
420        assert_eq!(DiffuseWhite::new(203.0), DiffuseWhite::BT2408);
421    }
422
423    #[test]
424    fn default_percentile_constant_is_stable() {
425        // Pin the constant — `zenpixels-convert::CllMeasure::measure_percentile`
426        // reads this as its industry-tail default. Any change here breaks the
427        // documented production tail metric.
428        assert_eq!(ContentLightLevel::DEFAULT_PERCENTILE, 0.99999);
429    }
430
431    #[test]
432    fn content_light_level_clone_eq() {
433        let a = ContentLightLevel::new(100, 50);
434        let b = a;
435        assert_eq!(a, b);
436    }
437
438    #[test]
439    #[cfg(feature = "std")]
440    fn content_light_level_hash() {
441        use core::hash::{Hash, Hasher};
442        let a = ContentLightLevel::new(100, 50);
443        let b = a;
444        let mut h1 = std::hash::DefaultHasher::new();
445        a.hash(&mut h1);
446        let mut h2 = std::hash::DefaultHasher::new();
447        b.hash(&mut h2);
448        assert_eq!(h1.finish(), h2.finish());
449    }
450
451    #[test]
452    fn mastering_display_constants() {
453        assert_eq!(MasteringDisplay::HDR10_REFERENCE.max_luminance, 10000.0);
454        assert_eq!(MasteringDisplay::DISPLAY_P3_1000.max_luminance, 1000.0);
455    }
456}