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}