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