Skip to main content

zenpixels_convert/hdr/
mod.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//!
10//! # Experimental: `measure` — content-light-level measurement
11//!
12//! Behind the `hdr-experimental` Cargo feature, the `measure` submodule
13//! exposes the `measure::CllMeasure` extension trait (with
14//! `measure_max` / `measure_robust` / `measure_max_smoothed` /
15//! `measure_percentile` / `measure_histogram` on
16//! [`ContentLightLevel`]), the `measure::LightLevelHistogram`
17//! primitive, and the `measure::LightLevelMethod` enum. (Plain code
18//! spans, not intra-doc links — the targets only exist when the feature
19//! is enabled, and this page renders either way.)
20//! The trait + module shape may move or rename ahead of 0.3.0 (in particular
21//! `measure_robust` is queued to become the unqualified `measure` once the
22//! deprecated 2-arg `ContentLightLevel::measure` ships its 0.3.0 removal);
23//! the underlying scan kernels and accuracy contracts are stable.
24
25/// BT.2020 NCL luma coefficients shared by the HDR submodules:
26/// `Y = 0.2627·R + 0.6780·G + 0.0593·B`. Used by [`measure`]'s
27/// `LuminanceBt2020` reduction and by the [`Bt2446A`] curve (both the
28/// SIMD body and the scalar remainder tail of `bt2446a_tier`). Pinned at
29/// the parent-module scope so the call sites stay in lock-step.
30#[cfg(feature = "hdr-experimental")]
31pub(super) const BT2020_LR: f32 = 0.2627;
32#[cfg(feature = "hdr-experimental")]
33pub(super) const BT2020_LG: f32 = 0.6780;
34#[cfg(feature = "hdr-experimental")]
35pub(super) const BT2020_LB: f32 = 0.0593;
36
37/// Content-light-level measurement (experimental).
38///
39/// Gated behind the `hdr-experimental` Cargo feature. See the parent
40/// module docs for stability notes.
41#[cfg(feature = "hdr-experimental")]
42pub mod measure;
43
44/// BT.2446 Method A tone-mapper (experimental).
45///
46/// Linear-light HDR → linear-light SDR curve. Gated behind
47/// `hdr-experimental` while the cross-crate API surface settles.
48#[cfg(feature = "hdr-experimental")]
49mod bt2446a;
50
51/// Soft chroma compression in OKLch with a precomputed gamut boundary LUT.
52/// Gated behind `hdr-experimental`.
53#[cfg(feature = "hdr-experimental")]
54mod gamut_compress;
55
56/// Re-exports of the experimental [`measure`] surface at the [`hdr`](self)
57/// boundary, so callers can write
58/// `use zenpixels_convert::hdr::CllMeasure;` instead of the full
59/// `zenpixels_convert::hdr::measure::CllMeasure` path. Same gating.
60#[cfg(feature = "hdr-experimental")]
61pub use measure::{CllMeasure, LightLevelHistogram, LightLevelMethod};
62
63// HDR → SDR conversion now lives in the main `ConvertPlan` infrastructure:
64// build via [`crate::ConvertPlan::new_with_hdr_peak`] /
65// [`crate::ConvertPlan::new_with_hdr_config`] and run through the standard
66// [`crate::RowConverter`] / [`crate::convert_buffer`] entry points. The
67// underlying `Bt2446A` and `SoftCompress` primitives stay public for advanced
68// callers that want to drive the math directly.
69#[cfg(feature = "hdr-experimental")]
70pub use bt2446a::Bt2446A;
71#[cfg(feature = "hdr-experimental")]
72pub use gamut_compress::{GamutBoundaryLut, SoftCompress};
73
74use crate::adapt::{convert_buffer_with_anchor, convert_into_with_anchor};
75use crate::error::ConvertError;
76use crate::{PixelBuffer, PixelDescriptor, PixelFormat, PixelSlice, TransferFunction};
77use alloc::sync::Arc;
78use whereat::At;
79use zenpixels::{Cicp, ColorContext};
80
81// Re-export metadata types from the core crate.
82pub use zenpixels::hdr::{ContentLightLevel, MasteringDisplay};
83// `quantize_to` reads the anchor from the source's `ColorContext`; the
84// canonical public home for the type is `zenpixels::hdr::DiffuseWhite`
85// (reachable through the core crate — not re-exported here).
86use zenpixels::hdr::DiffuseWhite;
87
88/// Describes the HDR characteristics of pixel data.
89///
90/// Bundles transfer function, content light level, and mastering display
91/// metadata to provide everything needed for HDR processing.
92///
93/// # Deprecated
94///
95/// This bundle is a redundant, weaker duplicate of the codec-layer carrier
96/// `zencodec::Metadata` (which the codecs actually populate, and which also
97/// carries CICP, ICC, EXIF/XMP, and orientation). It bundles `transfer` with
98/// CLL/mastering, which the prior art uniformly keeps separate (transfer
99/// belongs on the [`crate::PixelDescriptor`]; CLL and
100/// mastering are independent optional metadata). It has frozen public fields
101/// (not `#[non_exhaustive]`), so the absolute-luminance anchor and gain-map
102/// fields HDR needs cannot be added without a break. Scheduled for removal in
103/// 0.3.0 — see `CHANGELOG.md` "QUEUED BREAKING CHANGES". Carry CLL and
104/// mastering as the standalone [`ContentLightLevel`] / [`MasteringDisplay`]
105/// types, or use `zencodec::Metadata`.
106#[deprecated(
107    since = "0.2.14",
108    note = "redundant with zencodec::Metadata and frozen-shaped; carry ContentLightLevel / MasteringDisplay directly. Removal queued for 0.3.0."
109)]
110#[derive(Clone, Copy, Debug, PartialEq)]
111pub struct HdrMetadata {
112    /// Transfer function (PQ, HLG, sRGB, Linear, etc.).
113    pub transfer: TransferFunction,
114    /// Content light level (MaxCLL/MaxFALL). Optional.
115    pub content_light_level: Option<ContentLightLevel>,
116    /// Mastering display color volume. Optional.
117    pub mastering_display: Option<MasteringDisplay>,
118}
119
120#[allow(deprecated)]
121impl HdrMetadata {
122    /// True if this describes HDR content (PQ or HLG transfer function).
123    #[must_use]
124    pub fn is_hdr(&self) -> bool {
125        matches!(self.transfer, TransferFunction::Pq | TransferFunction::Hlg)
126    }
127
128    /// True if this describes SDR content.
129    #[must_use]
130    pub fn is_sdr(&self) -> bool {
131        !self.is_hdr()
132    }
133
134    /// Create HDR10 metadata with PQ transfer.
135    ///
136    /// The mastering display is [`MasteringDisplay::HDR10_REFERENCE`] — the
137    /// generic 1000-nit reference mastering volume, **not** measured
138    /// metadata from any real mastering session. Replace it when the
139    /// source carries an actual SMPTE ST 2086 record.
140    pub fn hdr10(cll: ContentLightLevel) -> Self {
141        Self {
142            transfer: TransferFunction::Pq,
143            content_light_level: Some(cll),
144            mastering_display: Some(MasteringDisplay::HDR10_REFERENCE),
145        }
146    }
147
148    /// Create HLG metadata.
149    pub fn hlg() -> Self {
150        Self {
151            transfer: TransferFunction::Hlg,
152            content_light_level: None,
153            mastering_display: None,
154        }
155    }
156}
157
158// ---------------------------------------------------------------------------
159// Naive HDR ↔ SDR tone mapping (deprecated — see `zentone` for the real one)
160// ---------------------------------------------------------------------------
161//
162// These three functions ship a Reinhard global operator and a plain
163// exposure-stop multiplier. Both are *toys*: no display adaptation, no
164// diffuse-white anchor, no chroma correction, no peak-luminance awareness.
165// On a real 1000-nit HDR pixel `v / (1+v)` returns ~1.0, which gets written
166// out as full-scale SDR — the same outlier-driven footgun pattern that got
167// `ContentLightLevel::measure` deprecated in 0.2.15. Same treatment, same
168// removal schedule. The canonical home for production HDR→SDR tone mapping
169// is the `zentone` crate (BT.2446 A/B/C, BT.2408, ACES, AgX, filmic-spline,
170// gain-map, SIMD strip processing); reach for it instead.
171
172/// Simple Reinhard-style tone mapping: HDR linear → SDR linear.
173///
174/// Maps linear light `[0, ∞]` → `[0, 1]` using `v / (1 + v)`.
175///
176/// Out-of-domain inputs are clamped rather than propagated: **negative
177/// values and NaN map to 0.0** (linear HDR buffers can legitimately carry
178/// small negatives from gamut-mapping ringing — pre-clamp, `-1.0` produced
179/// `-inf` and `-2.0` produced `+2.0`), and **`+∞` maps to 1.0** (the
180/// mathematical limit). The output never leaves `[0, 1]`; it reaches 1.0
181/// only at the float saturation edge.
182///
183/// Preserves relative brightness ordering. **Does not use any display
184/// metadata** — no diffuse-white anchor, no peak luminance, no chroma
185/// correction; the curve is calibrated to nothing in particular. On a
186/// 1000-nit HDR pixel it returns ~1.0, which then writes out as full-scale
187/// SDR.
188///
189/// # Deprecated
190///
191/// Naive global Reinhard with no display adaptation or chroma correction.
192/// Confirmed inferior to [`Bt2446A`](crate::hdr::Bt2446A) by the
193/// 2026-06-22 audited HDR→SDR shootout — Bt2446A wins mean ΔE2000 by
194/// 2-5× over every channel-independent curve tested. Use
195/// [`crate::hdr::Bt2446A`] (now lives in this crate after the
196/// `zentone` → `zenpixels-convert::hdr` extraction). Scheduled for
197/// removal in 0.3.0.
198#[deprecated(
199    since = "0.2.15",
200    note = "naive global Reinhard — confirmed inferior to Bt2446A by the 2026-06-22 audited shootout (~2-5x mean ΔE2000). Use `zenpixels_convert::hdr::Bt2446A` for production HDR→SDR mapping. Removal queued for 0.3.0."
201)]
202#[doc(hidden)]
203#[inline]
204#[must_use]
205pub fn reinhard_tonemap(v: f32) -> f32 {
206    // f32::max(NaN, 0.0) == 0.0, so one clamp handles negatives and NaN.
207    let v = v.max(0.0);
208    if v == f32::INFINITY {
209        return 1.0;
210    }
211    v / (1.0 + v)
212}
213
214/// Inverse Reinhard: SDR linear → HDR linear.
215///
216/// Maps `[0, 1)` → `[0, ∞)` using `v / (1 - v)`. Inputs ≥ 1.0 saturate to
217/// `f32::MAX` (1.0 has no finite preimage); **negative values and NaN map
218/// to 0.0**, mirroring [`reinhard_tonemap`]'s domain clamp.
219///
220/// # Deprecated
221///
222/// Symmetric round-trip for the now-deprecated [`reinhard_tonemap`]; no
223/// independent justification. Removal queued for 0.3.0.
224#[deprecated(
225    since = "0.2.15",
226    note = "round-trip partner for the deprecated reinhard_tonemap; no independent use. Removal queued for 0.3.0."
227)]
228#[doc(hidden)]
229#[inline]
230#[must_use]
231pub fn reinhard_inverse(v: f32) -> f32 {
232    let v = v.max(0.0);
233    if v >= 1.0 {
234        return f32::MAX;
235    }
236    v / (1.0 - v)
237}
238
239/// Simple exposure-based tone mapping.
240///
241/// `exposure` is in stops relative to 1.0. Positive values brighten,
242/// negative darken. The result is clamped to [0, 1]; **NaN input maps to
243/// 0.0** (consistent with [`reinhard_tonemap`]'s domain clamp).
244///
245/// Requires `std` because `f32::powf` is not available in `no_std`.
246///
247/// # Deprecated
248///
249/// A bare `v * 2^exposure` clamp — even weaker than [`reinhard_tonemap`].
250/// The 2026-06-22 audited shootout confirmed
251/// [`Bt2446A`](crate::hdr::Bt2446A) wins on every metric by a large
252/// margin. Removal queued for 0.3.0.
253#[deprecated(
254    since = "0.2.15",
255    note = "bare v * 2^exposure clamp; no display-aware mapping. Use `zenpixels_convert::hdr::Bt2446A` for production HDR→SDR mapping. Removal queued for 0.3.0."
256)]
257#[doc(hidden)]
258#[cfg(feature = "std")]
259#[inline]
260#[must_use]
261// Not clamp(): max(NaN, 0.0) == 0.0 makes the NaN result deterministic
262// (the documented contract above), where clamp would propagate NaN.
263#[allow(clippy::manual_clamp)]
264pub fn exposure_tonemap(v: f32, exposure: f32) -> f32 {
265    (v * 2.0f32.powf(exposure)).max(0.0).min(1.0)
266}
267
268// ---------------------------------------------------------------------------
269// HDR quantization (relative-linear f32 → a PQ HDR descriptor)
270// ---------------------------------------------------------------------------
271
272/// Shared `quantize_*` setup: read the anchor from the source `ColorContext`
273/// (default [`DiffuseWhite::BT2408`] = 203), validate the source is linear
274/// RGB(A) f32 and the target is PQ, and return the gamut-tagged source
275/// descriptor + anchor + dimensions. The source descriptor carries the target's
276/// primaries so no gamut step is planned (value-only quantize); the target's
277/// channel count then drives whether alpha is dropped or preserved.
278fn quantize_setup(
279    px: &PixelSlice<'_>,
280    target: PixelDescriptor,
281) -> Result<(PixelDescriptor, DiffuseWhite, u32, u32), At<ConvertError>> {
282    let diffuse_white = px
283        .color_context()
284        .and_then(|c| c.diffuse_white)
285        .unwrap_or(DiffuseWhite::BT2408);
286    let desc = px.descriptor();
287    let src = match desc.pixel_format() {
288        PixelFormat::RgbF32 => PixelDescriptor::RGBF32_LINEAR,
289        PixelFormat::RgbaF32 => PixelDescriptor::RGBAF32_LINEAR,
290        _ => return Err(whereat::at!(ConvertError::NoMatch { source: desc })),
291    }
292    .with_primaries(target.primaries);
293    if desc.transfer != TransferFunction::Linear {
294        return Err(whereat::at!(ConvertError::UnsupportedTransfer {
295            from: desc.transfer,
296            to: TransferFunction::Linear,
297        }));
298    }
299    // The pipeline anchors PQ at 1.0 = 10000 cd/m²; only PQ targets are handled.
300    if target.transfer != TransferFunction::Pq {
301        return Err(whereat::at!(ConvertError::NoPath {
302            from: desc,
303            to: target,
304        }));
305    }
306    let w = px.width();
307    let h = px.rows();
308    if w == 0 || h == 0 {
309        return Err(whereat::at!(ConvertError::InvalidWidth(w)));
310    }
311    Ok((src, diffuse_white, w, h))
312}
313
314/// Quantize relative-linear RGB(A) f32 pixels to a **PQ** HDR target
315/// descriptor (e.g. [`PixelDescriptor::RGB16_BT2100_PQ`]).
316///
317/// The absolute-luminance anchor — the nits that sample `1.0` represents — is
318/// read from the source `ColorContext`'s `diffuse_white`, defaulting to
319/// [`DiffuseWhite::BT2408`] (203, the cross-vendor relative-linear convention)
320/// when unsignaled. Attach a custom anchor with
321/// `ColorContext::with_diffuse_white` (e.g. a buffer reconstructed at a
322/// different reference white). The anchor threads **into the PQ `ConvertStep`s
323/// themselves**: the linear → PQ kernel scales the RGB lanes by `anchor / 10000`
324/// across the relative-linear ↔ PQ-absolute boundary, so this is a thin wrapper
325/// that hands the source — **strided and RGBA accepted as-is, no repack** —
326/// straight to the pipeline. Negatives fold to 0 and the PQ peak clamps
327/// in-kernel; codes match the f64 ST 2084 oracle within ±1.
328///
329/// **Alpha follows the target.** An RGB target (e.g.
330/// [`RGB16_BT2100_PQ`](PixelDescriptor::RGB16_BT2100_PQ)) drops alpha; an RGBA
331/// PQ target (`RGBA16.with_transfer(Pq).with_primaries(…)`) preserves it,
332/// carried linearly and **never PQ-encoded or anchor-scaled**.
333///
334/// **Primaries are not converted** — the source gamut is signaled as the
335/// target's (feed BT.2020-relative-linear for `RGB16_BT2100_PQ`). Measure CLL
336/// separately with [`ContentLightLevel::measure`].
337///
338/// The successor to the withdrawn `encode_pq16` (rationale:
339/// `docs/hdr-design-survey-2026-06-13.md`). With the anchor living on the plan
340/// (#45 S2), the quantizer is now a straight pass to
341/// `convert_buffer_with_anchor`; HLG is still excluded (its scene-referred
342/// anchor differs).
343///
344/// # Errors
345///
346/// - [`ConvertError::NoMatch`] if `px` is not `RgbF32`/`RgbaF32`;
347///   [`ConvertError::UnsupportedTransfer`] if it is not `Linear`.
348/// - [`ConvertError::NoPath`] if `target`'s transfer is not PQ (HLG's
349///   scene-referred anchor differs and is not handled here).
350/// - [`ConvertError::InvalidWidth`] for zero-area input, or any error the
351///   inner anchored conversion raises.
352pub fn quantize_to(
353    px: PixelSlice<'_>,
354    target: PixelDescriptor,
355) -> Result<PixelBuffer, At<ConvertError>> {
356    // Hand the (possibly strided, possibly RGBA) source straight to the anchored
357    // pipeline — no caller-side pre-scale or repack. The PQ kernel applies
358    // `white / 10000` to the RGB lanes, folds negatives to 0, and the plan
359    // drops or preserves alpha per `target`. The anchor travels with the pixels
360    // (S1a): `quantize_setup` reads it from the source `ColorContext`.
361    let (src, diffuse_white, w, h) = quantize_setup(&px, target)?;
362    let out = convert_buffer_with_anchor(
363        px.as_strided_bytes(),
364        w,
365        h,
366        px.stride(),
367        src,
368        target,
369        diffuse_white,
370    )?;
371    // Carry the envelope forward. `diffuse_white` is a *reference* — it survives
372    // the encode (it's the SDR-white nits a downstream encoder signals as
373    // `ndwt`), so the output self-describes it rather than silently dropping the
374    // anchor we just applied. The target's CICP (transfer/primaries/range) rides
375    // along so the buffer is fully described for re-encode.
376    let context = match Cicp::from_descriptor(&target) {
377        Some(cicp) => ColorContext::from_cicp(cicp),
378        None => ColorContext::default(),
379    }
380    .with_diffuse_white(diffuse_white);
381    Ok(out.with_color_context(Arc::new(context)))
382}
383
384/// [`quantize_to`] writing into a caller-provided `dst` — no output allocation.
385///
386/// The result is written at `dst_stride` bytes per row (pass
387/// `width * target.bytes_per_pixel()` for packed, or a larger stride to write
388/// into a sub-region of a bigger buffer); `dst` must hold
389/// `(rows - 1) * dst_stride + width * target.bpp` bytes, else
390/// [`ConvertError::BufferSize`]. Anchor sourcing, strided-**source** handling,
391/// and target-driven alpha (drop for an RGB target, preserve for an RGBA one)
392/// are identical to [`quantize_to`]; this only avoids allocating the output. Unlike
393/// [`quantize_to`], it writes raw bytes with no `PixelBuffer` to tag, so the
394/// caller owns the output's color envelope (e.g. re-attaching the
395/// `diffuse_white` anchor for a downstream encode).
396///
397/// Kept `pub(crate)` for now: the no-allocation capability is built and tested,
398/// but per the "no speculative `pub`" rule (and the §3.2 design doc, which routes
399/// the public HDR-convert surface through a future `PixelBuffer`-level entry and
400/// keeps the byte-level convert internal) it is not yet a public commitment.
401/// Promote the instant a concrete external consumer or §3.2 lands.
402///
403/// # Errors
404///
405/// The same validation errors as [`quantize_to`], plus
406/// [`ConvertError::BufferSize`] when `dst` is too small.
407// Exercised by the `quantize_into_*` unit tests; no non-test in-crate caller yet
408// (it is a staged, ready-to-promote public candidate — see above).
409#[allow(dead_code)]
410pub(crate) fn quantize_into(
411    px: PixelSlice<'_>,
412    target: PixelDescriptor,
413    dst: &mut [u8],
414    dst_stride: usize,
415) -> Result<(), At<ConvertError>> {
416    let (src, diffuse_white, w, h) = quantize_setup(&px, target)?;
417    convert_into_with_anchor(
418        px.as_strided_bytes(),
419        w,
420        h,
421        px.stride(),
422        src,
423        target,
424        diffuse_white,
425        dst,
426        dst_stride,
427    )
428}
429
430#[cfg(test)]
431// These tests exercise the deprecated-but-still-present HdrMetadata API.
432#[allow(deprecated)]
433mod tests {
434    use super::*;
435
436    #[test]
437    fn reinhard_boundaries() {
438        assert_eq!(reinhard_tonemap(0.0), 0.0);
439        assert!((reinhard_tonemap(1.0) - 0.5).abs() < 1e-6);
440        assert!(reinhard_tonemap(1000.0) > 0.99);
441        assert!(reinhard_tonemap(1000.0) < 1.0);
442    }
443
444    #[test]
445    fn reinhard_roundtrip() {
446        for &v in &[0.0, 0.1, 0.5, 1.0, 2.0, 10.0, 100.0] {
447            let mapped = reinhard_tonemap(v);
448            let unmapped = reinhard_inverse(mapped);
449            assert!(
450                (unmapped - v).abs() < 1e-4,
451                "Reinhard roundtrip failed for {v}: got {unmapped}"
452            );
453        }
454    }
455
456    #[test]
457    fn hdr_metadata_is_hdr() {
458        assert!(HdrMetadata::hdr10(ContentLightLevel::default()).is_hdr());
459        assert!(HdrMetadata::hlg().is_hdr());
460        assert!(
461            HdrMetadata {
462                transfer: TransferFunction::Srgb,
463                content_light_level: None,
464                mastering_display: None,
465            }
466            .is_sdr()
467        );
468    }
469
470    #[test]
471    fn hdr10_constructor() {
472        let cll = ContentLightLevel::new(4000, 1000);
473        let meta = HdrMetadata::hdr10(cll);
474        assert!(meta.is_hdr());
475        assert_eq!(meta.transfer, TransferFunction::Pq);
476        assert_eq!(meta.content_light_level, Some(cll));
477        assert!(meta.mastering_display.is_some());
478    }
479
480    #[test]
481    fn hlg_constructor() {
482        let meta = HdrMetadata::hlg();
483        assert!(meta.is_hdr());
484        assert_eq!(meta.transfer, TransferFunction::Hlg);
485        assert!(meta.content_light_level.is_none());
486        assert!(meta.mastering_display.is_none());
487    }
488
489    #[test]
490    #[cfg(feature = "std")]
491    fn exposure_tonemap_values() {
492        // 0 stops = unchanged (clamped to [0,1]).
493        assert!((exposure_tonemap(0.5, 0.0) - 0.5).abs() < 1e-6);
494        // +1 stop = doubled.
495        assert!((exposure_tonemap(0.25, 1.0) - 0.5).abs() < 1e-5);
496        // -1 stop = halved.
497        assert!((exposure_tonemap(0.5, -1.0) - 0.25).abs() < 1e-5);
498        // Clamped to [0,1].
499        assert_eq!(exposure_tonemap(0.8, 1.0), 1.0);
500        assert_eq!(exposure_tonemap(0.0, 5.0), 0.0);
501    }
502
503    #[test]
504    fn reinhard_inverse_at_one() {
505        assert_eq!(reinhard_inverse(1.0), f32::MAX);
506    }
507
508    #[test]
509    fn hdr_metadata_clone_partial_eq() {
510        let a = HdrMetadata::hlg();
511        let b = a;
512        assert_eq!(a, b);
513    }
514
515    // -- Rung 1 hardening (zenpixels#39): domain contracts + properties --
516
517    /// Independent f64 oracle for the f32 implementation.
518    fn reinhard_f64(v: f64) -> f64 {
519        v / (1.0 + v)
520    }
521
522    #[test]
523    fn reinhard_clamps_negatives_and_nan_to_zero() {
524        // Pre-clamp hazards: -1.0 → -inf, -2.0 → +2.0 (outside [0,1]).
525        assert_eq!(reinhard_tonemap(-0.25), 0.0);
526        assert_eq!(reinhard_tonemap(-1.0), 0.0);
527        assert_eq!(reinhard_tonemap(-2.0), 0.0);
528        assert_eq!(reinhard_tonemap(f32::NEG_INFINITY), 0.0);
529        assert_eq!(reinhard_tonemap(f32::NAN), 0.0);
530
531        assert_eq!(reinhard_inverse(-0.25), 0.0);
532        assert_eq!(reinhard_inverse(-1.0), 0.0);
533        assert_eq!(reinhard_inverse(f32::NAN), 0.0);
534    }
535
536    #[test]
537    fn reinhard_infinity_saturates_to_one() {
538        // inf/(1+inf) would be NaN; the limit is 1.0.
539        assert_eq!(reinhard_tonemap(f32::INFINITY), 1.0);
540        // The float saturation edge also rounds to 1.0 (MAX + 1 == MAX).
541        assert_eq!(reinhard_tonemap(f32::MAX), 1.0);
542    }
543
544    #[test]
545    fn reinhard_output_range_and_monotonicity() {
546        let grid: [f32; 13] = [
547            0.0,
548            1e-6,
549            1e-3,
550            0.05,
551            0.1,
552            0.5,
553            1.0,
554            2.0,
555            10.0,
556            1e3,
557            1e6,
558            1e9,
559            f32::MAX,
560        ];
561        let mut prev = -1.0f32;
562        for &v in &grid {
563            let out = reinhard_tonemap(v);
564            assert!(
565                (0.0..=1.0).contains(&out) && out.is_finite(),
566                "reinhard_tonemap({v}) = {out} escapes [0, 1]"
567            );
568            assert!(out >= prev, "not monotonic at {v}: {out} < {prev}");
569            // Strictly increasing while far from the saturation edge.
570            if v <= 1e6 && prev >= 0.0 {
571                assert!(out > prev, "not strictly increasing at {v}");
572            }
573            prev = out;
574        }
575    }
576
577    #[test]
578    fn reinhard_matches_f64_oracle() {
579        for &v in &[0.0f32, 1e-6, 1e-3, 0.1, 0.5, 1.0, 2.0, 10.0, 1e3, 1e5] {
580            let got = reinhard_tonemap(v) as f64;
581            let want = reinhard_f64(v as f64);
582            assert!(
583                (got - want).abs() < 1e-6,
584                "f32 impl diverges from f64 oracle at {v}: {got} vs {want}"
585            );
586        }
587    }
588
589    #[test]
590    fn reinhard_roundtrip_relative_error_bound() {
591        // inverse(tonemap(v)) ≈ v across eight decades. The inverse
592        // amplifies the f32 quantization of t = v/(1+v) (whose spacing is
593        // ~ε once t nears 1.0) by dv/dt = (1+v)², so the relative
594        // round-trip error grows ~linearly in v; bound it at 4ε·(1+v).
595        let mut v = 1e-4f32;
596        while v <= 1e4 {
597            let rt = reinhard_inverse(reinhard_tonemap(v));
598            let rel = ((f64::from(rt) - f64::from(v)) / f64::from(v)).abs();
599            let bound = 4.0 * f64::from(f32::EPSILON) * (1.0 + f64::from(v));
600            assert!(
601                rel < bound,
602                "roundtrip rel err {rel} > bound {bound} at {v} (got {rt})"
603            );
604            v *= 3.7;
605        }
606    }
607
608    #[test]
609    #[cfg(feature = "std")]
610    fn exposure_tonemap_nan_maps_to_zero() {
611        assert_eq!(exposure_tonemap(f32::NAN, 0.0), 0.0);
612        assert_eq!(exposure_tonemap(f32::NAN, 2.0), 0.0);
613        // Negative input still clamps to 0 (unchanged behavior).
614        assert_eq!(exposure_tonemap(-0.5, 0.0), 0.0);
615    }
616
617    // -- quantize_to (PQ16) parity with the f64 ST 2084 oracle --
618
619    use alloc::vec;
620    use alloc::vec::Vec;
621
622    /// f64 SMPTE ST 2084 inverse-EOTF oracle (exact constants).
623    fn pq_oracle(x: f64) -> f64 {
624        if x <= 0.0 {
625            return 0.0;
626        }
627        let m1 = 2610.0 / 16384.0;
628        let m2 = 2523.0 / 4096.0 * 128.0;
629        let c1 = 3424.0 / 4096.0;
630        let c2 = 2413.0 / 4096.0 * 32.0;
631        let c3 = 2392.0 / 4096.0 * 32.0;
632        let xp = x.powf(m1);
633        ((c1 + c2 * xp) / (1.0 + c3 * xp)).powf(m2)
634    }
635
636    fn rgbf32(pixels: &[[f32; 3]], w: u32, h: u32) -> PixelBuffer {
637        let mut data = Vec::with_capacity(pixels.len() * 12);
638        for p in pixels {
639            for c in p {
640                data.extend_from_slice(&c.to_ne_bytes());
641            }
642        }
643        PixelBuffer::from_vec(data, w, h, PixelDescriptor::RGBF32_LINEAR).unwrap()
644    }
645
646    fn rgbaf32(pixels: &[[f32; 4]], w: u32, h: u32) -> PixelBuffer {
647        let mut data = Vec::with_capacity(pixels.len() * 16);
648        for p in pixels {
649            for c in p {
650                data.extend_from_slice(&c.to_ne_bytes());
651            }
652        }
653        PixelBuffer::from_vec(data, w, h, PixelDescriptor::RGBAF32_LINEAR).unwrap()
654    }
655
656    /// RGBA16 PQ target (BT.2020), matching `RGB16_BT2100_PQ` plus an alpha lane.
657    fn rgba16_pq() -> PixelDescriptor {
658        PixelDescriptor::RGBA16
659            .with_transfer(TransferFunction::Pq)
660            .with_primaries(PixelDescriptor::RGB16_BT2100_PQ.primaries)
661    }
662
663    #[test]
664    fn quantize_to_pq16_white_and_peak() {
665        // 1.0 @ 203 nits → PQ(203/10000); 10000/203 → PQ(1.0) = code 65535.
666        let peak = 10_000.0 / 203.0;
667        let buf = rgbf32(&[[1.0; 3], [peak; 3]], 2, 1);
668        let out = quantize_to(buf.as_slice(), PixelDescriptor::RGB16_BT2100_PQ).unwrap();
669        assert_eq!(out.descriptor(), PixelDescriptor::RGB16_BT2100_PQ);
670        let bytes = out.as_slice().as_strided_bytes();
671        let code = |i: usize| u16::from_ne_bytes([bytes[2 * i], bytes[2 * i + 1]]);
672
673        let want_white = (pq_oracle(203.0 / 10_000.0) * 65535.0).round() as i64;
674        assert!((i64::from(code(0)) - want_white).abs() <= 1);
675        assert_eq!(code(3), 65535, "10000-nit peak clips to full code");
676    }
677
678    #[test]
679    fn quantize_to_pq16_matches_oracle_across_decades() {
680        let values = [0.001f32, 0.01, 0.1, 0.5, 1.0, 2.0, 8.0, 20.0, 49.0];
681        let pixels: Vec<[f32; 3]> = values.iter().map(|&v| [v; 3]).collect();
682        let buf = rgbf32(&pixels, values.len() as u32, 1);
683        let out = quantize_to(buf.as_slice(), PixelDescriptor::RGB16_BT2100_PQ).unwrap();
684        let bytes = out.as_slice().as_strided_bytes();
685        for (i, &v) in values.iter().enumerate() {
686            let got = i64::from(u16::from_ne_bytes([bytes[6 * i], bytes[6 * i + 1]]));
687            let x = f64::from(v) * 203.0 / 10_000.0;
688            let want = (pq_oracle(x) * 65535.0).round() as i64;
689            assert!(
690                (got - want).abs() <= 1,
691                "PQ16 at {v}: got {got}, oracle {want}"
692            );
693        }
694    }
695
696    #[test]
697    fn quantize_to_rejects_non_pq_target_and_non_linear_src() {
698        let buf = rgbf32(&[[0.5; 3]], 1, 1);
699        // HLG target → NoPath (anchor semantics differ).
700        let err = quantize_to(buf.as_slice(), PixelDescriptor::RGB16_BT2100_HLG).unwrap_err();
701        assert!(matches!(*err.error(), ConvertError::NoPath { .. }));
702        // Non-linear source → UnsupportedTransfer.
703        let srgb = PixelDescriptor::RGBF32_LINEAR.with_transfer(TransferFunction::Srgb);
704        let mut d = Vec::new();
705        for c in [0.5f32; 3] {
706            d.extend_from_slice(&c.to_ne_bytes());
707        }
708        let nb = PixelBuffer::from_vec(d, 1, 1, srgb).unwrap();
709        assert!(quantize_to(nb.as_slice(), PixelDescriptor::RGB16_BT2100_PQ).is_err());
710    }
711
712    #[test]
713    fn quantize_to_reads_anchor_from_color_context() {
714        use alloc::sync::Arc;
715        use zenpixels::{Cicp, ColorContext};
716        // A 100-nit anchor on the ColorContext (not the 203 default) must
717        // change the PQ scale — proving the anchor travels with the pixels.
718        let buf = rgbf32(&[[1.0; 3]], 1, 1).with_color_context(Arc::new(
719            ColorContext::from_cicp(Cicp::BT2100_PQ).with_diffuse_white(DiffuseWhite::new(100.0)),
720        ));
721        let out = quantize_to(buf.as_slice(), PixelDescriptor::RGB16_BT2100_PQ).unwrap();
722        let bytes = out.as_slice().as_strided_bytes();
723        let got = i64::from(u16::from_ne_bytes([bytes[0], bytes[1]]));
724        let want = (pq_oracle(100.0 / 10_000.0) * 65535.0).round() as i64;
725        assert!(
726            (got - want).abs() <= 1,
727            "100-nit anchor: got {got}, want {want}"
728        );
729        // The 100-nit result differs from the 203-nit default for the same input.
730        let want_203 = (pq_oracle(203.0 / 10_000.0) * 65535.0).round() as i64;
731        assert_ne!(want, want_203);
732    }
733
734    #[test]
735    fn quantize_to_preserves_alpha_for_rgba_target() {
736        // RGBA f32 linear → RGBA16 PQ: RGB take the anchored PQ OETF; alpha rides
737        // through linearly (never PQ-encoded). PQ-encoding 0.5 would give a code
738        // far from the linear 32768, so the assertion is a real discriminator.
739        let target = rgba16_pq();
740        let buf = rgbaf32(&[[1.0, 1.0, 1.0, 0.5], [2.0, 2.0, 2.0, 0.25]], 2, 1);
741        let out = quantize_to(buf.as_slice(), target).unwrap();
742        assert_eq!(out.descriptor(), target);
743        let bytes = out.as_slice().as_strided_bytes();
744        let code = |i: usize| u16::from_ne_bytes([bytes[2 * i], bytes[2 * i + 1]]);
745        for (px, g, a) in [(0usize, 1.0f64, 0.5f64), (1, 2.0, 0.25)] {
746            let r = i64::from(code(px * 4));
747            let want_rgb = (pq_oracle(g * 203.0 / 10_000.0) * 65535.0).round() as i64;
748            assert!(
749                (r - want_rgb).abs() <= 1,
750                "rgb @203: got {r} want {want_rgb}"
751            );
752            let alpha = code(px * 4 + 3);
753            let want_a = (a * 65535.0).round() as u16;
754            assert_eq!(
755                alpha, want_a,
756                "alpha linear passthrough: got {alpha} want {want_a}"
757            );
758        }
759    }
760
761    #[test]
762    fn quantize_to_honors_strided_input() {
763        // A padded source stride (one sentinel pixel per row) must quantize
764        // identically to the equivalent packed buffer.
765        let target = PixelDescriptor::RGB16_BT2100_PQ;
766        let stride = 2 * 12 + 12; // two RGB f32 pixels + one padding pixel
767        let mut data = vec![0u8; stride * 2];
768        for y in 0..2usize {
769            let mut off = y * stride;
770            for c in [0.1f32, 0.1, 0.1, 1.0, 1.0, 1.0] {
771                data[off..off + 4].copy_from_slice(&c.to_ne_bytes());
772                off += 4;
773            }
774            data[off..off + 4].copy_from_slice(&999.0f32.to_ne_bytes()); // sentinel
775        }
776        let strided = PixelSlice::new(&data, 2, 2, stride, PixelDescriptor::RGBF32_LINEAR).unwrap();
777        let got = quantize_to(strided, target).unwrap();
778
779        let packed = rgbf32(&[[0.1; 3], [1.0; 3], [0.1; 3], [1.0; 3]], 2, 2);
780        let want = quantize_to(packed.as_slice(), target).unwrap();
781        assert_eq!(
782            got.as_slice().as_strided_bytes(),
783            want.as_slice().as_strided_bytes(),
784            "strided input must quantize identically to packed"
785        );
786    }
787
788    #[test]
789    fn quantize_into_matches_quantize_to() {
790        let target = PixelDescriptor::RGB16_BT2100_PQ;
791        let buf = rgbf32(&[[0.1; 3], [1.0; 3], [2.0; 3]], 3, 1);
792        let want = quantize_to(buf.as_slice(), target).unwrap();
793        let row = 3 * target.bytes_per_pixel();
794        let mut dst = vec![0u8; row];
795        quantize_into(buf.as_slice(), target, &mut dst, row).unwrap();
796        assert_eq!(dst, want.as_slice().as_strided_bytes());
797    }
798
799    #[test]
800    fn quantize_into_honors_dst_stride() {
801        // Write two PQ16 rows into a padded destination; the padding bytes must
802        // be untouched and the row content must match a packed quantize.
803        let target = PixelDescriptor::RGB16_BT2100_PQ;
804        let buf = rgbf32(&[[0.1; 3], [1.0; 3], [0.1; 3], [1.0; 3]], 2, 2);
805        let want = quantize_to(buf.as_slice(), target).unwrap();
806        let want_bytes = want.as_slice().as_strided_bytes();
807        let row = 2 * target.bytes_per_pixel(); // packed row width
808        let dst_stride = row + 8; // 8 bytes of padding per row
809        let mut dst = vec![0xAAu8; dst_stride * 2];
810        quantize_into(buf.as_slice(), target, &mut dst, dst_stride).unwrap();
811        for y in 0..2 {
812            assert_eq!(
813                &dst[y * dst_stride..y * dst_stride + row],
814                &want_bytes[y * row..(y + 1) * row]
815            );
816            assert!(
817                dst[y * dst_stride + row..y * dst_stride + dst_stride]
818                    .iter()
819                    .all(|&b| b == 0xAA),
820                "padding row {y} must be untouched"
821            );
822        }
823    }
824
825    #[test]
826    fn quantize_into_rejects_undersized_dst() {
827        let target = PixelDescriptor::RGB16_BT2100_PQ;
828        let buf = rgbf32(&[[1.0; 3]], 1, 1);
829        let mut dst = vec![0u8; 2]; // one RGB16 pixel needs 6 bytes
830        let row = target.bytes_per_pixel();
831        let err = quantize_into(buf.as_slice(), target, &mut dst, row).unwrap_err();
832        assert!(matches!(*err.error(), ConvertError::BufferSize { .. }));
833    }
834
835    #[test]
836    fn quantize_to_carries_diffuse_white_anchor_onto_output() {
837        use alloc::sync::Arc;
838        use zenpixels::{Cicp, ColorContext};
839        let target = PixelDescriptor::RGB16_BT2100_PQ;
840
841        // A signaled 100-nit anchor must ride out on the output's ColorContext —
842        // the encode applied it, so the buffer self-describes it (the `ndwt`
843        // signal a downstream encoder needs), rather than silently dropping it.
844        let buf = rgbf32(&[[1.0; 3]], 1, 1).with_color_context(Arc::new(
845            ColorContext::from_cicp(Cicp::BT2100_PQ).with_diffuse_white(DiffuseWhite::new(100.0)),
846        ));
847        let out = quantize_to(buf.as_slice(), target).unwrap();
848        let ctx = out.color_context().expect("output carries a ColorContext");
849        assert_eq!(ctx.diffuse_white, Some(DiffuseWhite::new(100.0)));
850        assert!(ctx.cicp.is_some(), "target CICP rides along for re-encode");
851
852        // An unsignaled source still yields a self-describing output at the 203 default.
853        let plain = rgbf32(&[[1.0; 3]], 1, 1);
854        let out = quantize_to(plain.as_slice(), target).unwrap();
855        assert_eq!(
856            out.color_context().unwrap().diffuse_white,
857            Some(DiffuseWhite::BT2408)
858        );
859    }
860}