Skip to main content

zenpixels_convert/
ext.rs

1//! Extension traits that add conversion methods to zenpixels interchange types.
2//!
3//! These traits bridge the type–conversion boundary: the types live in
4//! `zenpixels` (no heavy deps), while the conversion math lives here
5//! (depends on `linear-srgb`).
6
7use zenpixels::{ColorPrimaries, TransferFunction};
8
9use crate::convert::{hlg_eotf, hlg_oetf, pq_eotf, pq_oetf};
10use crate::gamut::GamutMatrix;
11
12// ---------------------------------------------------------------------------
13// TransferFunctionExt
14// ---------------------------------------------------------------------------
15
16/// Adds scalar EOTF/OETF methods to [`TransferFunction`].
17pub trait TransferFunctionExt {
18    /// Scalar EOTF: encoded signal → linear light.
19    ///
20    /// Canonical reference implementation for testing SIMD paths.
21    #[must_use]
22    fn linearize(&self, v: f32) -> f32;
23
24    /// Scalar OETF: linear light → encoded signal.
25    ///
26    /// Canonical reference implementation for testing SIMD paths.
27    #[must_use]
28    fn delinearize(&self, v: f32) -> f32;
29}
30
31impl TransferFunctionExt for TransferFunction {
32    #[allow(unreachable_patterns)]
33    fn linearize(&self, v: f32) -> f32 {
34        match self {
35            Self::Linear | Self::Unknown => v,
36            Self::Srgb => linear_srgb::precise::srgb_to_linear(v),
37            Self::Bt709 => linear_srgb::tf::bt709_to_linear(v),
38            Self::Pq => pq_eotf(v),
39            Self::Hlg => hlg_eotf(v),
40            _ => v,
41        }
42    }
43
44    #[allow(unreachable_patterns)]
45    fn delinearize(&self, v: f32) -> f32 {
46        match self {
47            Self::Linear | Self::Unknown => v,
48            Self::Srgb => linear_srgb::precise::linear_to_srgb(v),
49            Self::Bt709 => linear_srgb::tf::linear_to_bt709(v),
50            Self::Pq => pq_oetf(v),
51            Self::Hlg => hlg_oetf(v),
52            _ => v,
53        }
54    }
55}
56
57// ---------------------------------------------------------------------------
58// ColorPrimariesExt
59// ---------------------------------------------------------------------------
60
61/// Adds XYZ matrix lookups to [`ColorPrimaries`].
62#[allow(clippy::wrong_self_convention)]
63pub trait ColorPrimariesExt {
64    /// Linear RGB → CIE XYZ (D65 white point).
65    ///
66    /// Returns `None` for [`Unknown`](ColorPrimaries::Unknown).
67    fn to_xyz_matrix(&self) -> Option<&'static GamutMatrix>;
68
69    /// CIE XYZ (D65 white point) → linear RGB.
70    ///
71    /// Returns `None` for [`Unknown`](ColorPrimaries::Unknown).
72    fn from_xyz_matrix(&self) -> Option<&'static GamutMatrix>;
73}
74
75impl ColorPrimariesExt for ColorPrimaries {
76    #[allow(unreachable_patterns)]
77    fn to_xyz_matrix(&self) -> Option<&'static GamutMatrix> {
78        match self {
79            Self::Bt709 => Some(&crate::gamut::BT709_TO_XYZ),
80            Self::DisplayP3 => Some(&crate::gamut::DISPLAY_P3_TO_XYZ),
81            Self::Bt2020 => Some(&crate::gamut::BT2020_TO_XYZ),
82            _ => None,
83        }
84    }
85
86    #[allow(unreachable_patterns)]
87    fn from_xyz_matrix(&self) -> Option<&'static GamutMatrix> {
88        match self {
89            Self::Bt709 => Some(&crate::gamut::XYZ_TO_BT709),
90            Self::DisplayP3 => Some(&crate::gamut::XYZ_TO_DISPLAY_P3),
91            Self::Bt2020 => Some(&crate::gamut::XYZ_TO_BT2020),
92            _ => None,
93        }
94    }
95}
96
97// ---------------------------------------------------------------------------
98// PixelBufferConvertExt
99// ---------------------------------------------------------------------------
100
101use alloc::sync::Arc;
102use whereat::{At, ResultAtExt};
103use zenpixels::PixelDescriptor;
104use zenpixels::buffer::PixelBuffer;
105use zenpixels::descriptor::{AlphaMode, ChannelLayout, ChannelType};
106
107/// Adds format conversion methods to type-erased [`PixelBuffer`].
108pub trait PixelBufferConvertExt {
109    /// Convert pixel data to a different layout and depth.
110    ///
111    /// Uses [`RowConverter`](crate::RowConverter) for transfer-function-aware
112    /// conversion. Color metadata is preserved.
113    ///
114    /// **Allocates** a new [`PixelBuffer`].
115    fn convert_to(&self, target: PixelDescriptor) -> Result<PixelBuffer, At<crate::ConvertError>>;
116
117    /// Add an alpha channel. **Allocates** a new `PixelBuffer`.
118    ///
119    /// - Gray → GrayAlpha (opaque alpha)
120    /// - Rgb → Rgba (opaque alpha)
121    /// - Already has alpha → identity copy
122    fn try_add_alpha(&self) -> Result<PixelBuffer, At<crate::ConvertError>>;
123
124    /// Widen to U16 depth (lossless, ×257). **Allocates** a new `PixelBuffer`.
125    fn try_widen_to_u16(&self) -> Result<PixelBuffer, At<crate::ConvertError>>;
126
127    /// Narrow to U8 depth (lossy, rounded). **Allocates** a new `PixelBuffer`.
128    fn try_narrow_to_u8(&self) -> Result<PixelBuffer, At<crate::ConvertError>>;
129
130    /// Convert to linear-light F32, preserving channel layout and primaries.
131    ///
132    /// This is the EOTF step of a scene-referred pipeline: decoded pixels
133    /// (sRGB, BT.709, PQ, HLG) are converted to linear light for processing.
134    ///
135    /// **Allocates** a new `PixelBuffer`.
136    fn linearize(&self) -> Result<PixelBuffer, At<crate::ConvertError>>;
137
138    /// Apply a transfer function to a linear-light buffer.
139    ///
140    /// This is the OETF step: linear-light pixels are encoded for display
141    /// or storage. The buffer should be in F32 linear light; if it is in a
142    /// different transfer function, the conversion goes through linear as
143    /// an intermediate step.
144    ///
145    /// **Allocates** a new `PixelBuffer`.
146    fn delinearize(
147        &self,
148        transfer: TransferFunction,
149    ) -> Result<PixelBuffer, At<crate::ConvertError>>;
150}
151
152/// Typed convenience conversions that return `PixelBuffer<P>`.
153///
154/// Requires the `rgb` feature for the concrete pixel types.
155#[cfg(feature = "rgb")]
156pub trait PixelBufferConvertTypedExt: PixelBufferConvertExt {
157    /// Convert to RGB8, allocating a new buffer.
158    fn to_rgb8(&self) -> PixelBuffer<rgb::Rgb<u8>>;
159
160    /// Convert to RGBA8, allocating a new buffer.
161    fn to_rgba8(&self) -> PixelBuffer<rgb::Rgba<u8>>;
162
163    /// Convert to Gray8, allocating a new buffer.
164    fn to_gray8(&self) -> PixelBuffer<rgb::Gray<u8>>;
165
166    /// Convert to BGRA8, allocating a new buffer.
167    fn to_bgra8(&self) -> PixelBuffer<rgb::alt::BGRA<u8>>;
168}
169
170/// Reject conversions that require a CMS plugin from these
171/// no-CMS-argument extension entry points.
172///
173/// The trait-level methods (`convert_to`, `try_widen_to_u16`, …) don't
174/// take a [`PluggableCms`](crate::cms::PluggableCms), so CMYK / Lab /
175/// XYZ / any other non-native color model surfaces as a typed
176/// [`ConvertError::NeedsCms`] here — pre-0.2.16 this was an
177/// `assert_not_cmyk` panic. Callers that need CMS dispatch should
178/// build a [`RowConverter`](crate::RowConverter) directly via
179/// [`new_explicit_with_cms`](crate::RowConverter::new_explicit_with_cms).
180#[inline]
181fn check_needs_cms(
182    from: &PixelDescriptor,
183    to: &PixelDescriptor,
184) -> Result<(), At<crate::ConvertError>> {
185    if crate::convert::requires_cms(from, to) {
186        return Err(whereat::at!(crate::ConvertError::NeedsCms {
187            from: *from,
188            to: *to,
189        }));
190    }
191    Ok(())
192}
193
194impl PixelBufferConvertExt for PixelBuffer {
195    #[track_caller]
196    fn convert_to(&self, target: PixelDescriptor) -> Result<PixelBuffer, At<crate::ConvertError>> {
197        let src_desc = self.descriptor();
198        check_needs_cms(&src_desc, &target)?;
199        if src_desc == target {
200            // Identity — just copy.
201            let dst_stride = target.aligned_stride(self.width());
202            let total = dst_stride
203                .checked_mul(self.height() as usize)
204                .ok_or_else(|| whereat::at!(crate::ConvertError::AllocationFailed))?;
205            let mut out = alloc::vec![0u8; total];
206            let src_slice = self.as_slice();
207            for y in 0..self.height() {
208                let src_row = src_slice.row(y);
209                let dst_start = y as usize * dst_stride;
210                out[dst_start..dst_start + src_row.len()].copy_from_slice(src_row);
211            }
212            let mut buf = PixelBuffer::from_vec(out, self.width(), self.height(), target)
213                .map_err_at(crate::ConvertError::from)?;
214            if let Some(ctx) = self.color_context() {
215                buf = buf.with_color_context(Arc::clone(ctx));
216            }
217            return Ok(buf);
218        }
219
220        let mut converter = crate::RowConverter::new(src_desc, target).at()?;
221
222        let dst_stride = target.aligned_stride(self.width());
223        let total = dst_stride
224            .checked_mul(self.height() as usize)
225            .ok_or_else(|| whereat::at!(crate::ConvertError::AllocationFailed))?;
226        let mut out = alloc::vec![0u8; total];
227
228        let src_slice = self.as_slice();
229        for y in 0..self.height() {
230            let src_row = src_slice.row(y);
231            let dst_start = y as usize * dst_stride;
232            let dst_end = dst_start + dst_stride;
233            converter.convert_row(src_row, &mut out[dst_start..dst_end], self.width());
234        }
235
236        let mut buf = PixelBuffer::from_vec(out, self.width(), self.height(), target)
237            .map_err_at(crate::ConvertError::from)?;
238        if let Some(ctx) = self.color_context() {
239            buf = buf.with_color_context(Arc::clone(ctx));
240        }
241        Ok(buf)
242    }
243
244    #[track_caller]
245    fn try_add_alpha(&self) -> Result<PixelBuffer, At<crate::ConvertError>> {
246        let desc = self.descriptor();
247        let target_layout = match desc.layout() {
248            ChannelLayout::Gray => ChannelLayout::GrayAlpha,
249            ChannelLayout::Rgb => ChannelLayout::Rgba,
250            other => other,
251        };
252        let alpha = if target_layout.has_alpha() && desc.alpha().is_none() {
253            Some(AlphaMode::Straight)
254        } else {
255            desc.alpha()
256        };
257        let target =
258            PixelDescriptor::new(desc.channel_type(), target_layout, alpha, desc.transfer());
259        self.convert_to(target)
260    }
261
262    #[track_caller]
263    fn try_widen_to_u16(&self) -> Result<PixelBuffer, At<crate::ConvertError>> {
264        let desc = self.descriptor();
265        let target = PixelDescriptor::new(
266            ChannelType::U16,
267            desc.layout(),
268            desc.alpha(),
269            desc.transfer(),
270        );
271        self.convert_to(target)
272    }
273
274    #[track_caller]
275    fn try_narrow_to_u8(&self) -> Result<PixelBuffer, At<crate::ConvertError>> {
276        let desc = self.descriptor();
277        let target = PixelDescriptor::new(
278            ChannelType::U8,
279            desc.layout(),
280            desc.alpha(),
281            desc.transfer(),
282        );
283        self.convert_to(target)
284    }
285
286    #[track_caller]
287    fn linearize(&self) -> Result<PixelBuffer, At<crate::ConvertError>> {
288        let desc = self.descriptor();
289        let target = PixelDescriptor::new_full(
290            ChannelType::F32,
291            desc.layout(),
292            desc.alpha(),
293            TransferFunction::Linear,
294            desc.primaries,
295        );
296        self.convert_to(target)
297    }
298
299    #[track_caller]
300    fn delinearize(
301        &self,
302        transfer: TransferFunction,
303    ) -> Result<PixelBuffer, At<crate::ConvertError>> {
304        let target = self.descriptor().with_transfer(transfer);
305        self.convert_to(target)
306    }
307}
308
309/// Adds HDR-aware conversion methods to [`PixelBuffer`].
310///
311/// HDR→SDR conversions need a source-peak luminance to parameterize the
312/// BT.2446-A tone-map curve. Plain
313/// [`convert_to`](PixelBufferConvertExt::convert_to) refuses such cases
314/// with [`ConvertError::HdrSourceRequiresPeak`](crate::ConvertError::HdrSourceRequiresPeak).
315/// These methods supply the peak — either explicitly
316/// ([`convert_to_with_hdr_config`](Self::convert_to_with_hdr_config)) or
317/// by measuring MaxCLL from the buffer itself
318/// ([`convert_to_sdr`](Self::convert_to_sdr)).
319///
320/// Gated behind `hdr-experimental`.
321#[cfg(feature = "hdr-experimental")]
322pub trait PixelBufferHdrConvertExt {
323    /// Convert this HDR buffer to `target` (typically an SDR descriptor —
324    /// sRGB / BT.709 / Gamma22), auto-measuring source peak via
325    /// [`CllMeasure::measure_max`](crate::hdr::CllMeasure::measure_max)
326    /// (the production-default per the 2026-06-22 audited shootout —
327    /// wins 3 of 6 ranking criteria including the user-visible
328    /// `pct_above_de5`, see `DEFAULT_PERCENTILE` docs for the alternative
329    /// percentile path).
330    ///
331    /// For non-HDR sources this falls back to
332    /// [`convert_to`](PixelBufferConvertExt::convert_to) (so the call is
333    /// safe to use when the source's HDR-ness isn't known up front).
334    ///
335    /// **Allocates** a new [`PixelBuffer`].
336    fn convert_to_sdr(
337        &self,
338        target: PixelDescriptor,
339    ) -> Result<PixelBuffer, At<crate::ConvertError>>;
340
341    /// Convert this HDR buffer to `target` with explicit HDR knobs.
342    ///
343    /// `hdr.source_peak_nits` is mandatory and parameterizes the BT.2446-A
344    /// curve. `target_peak_nits` defaults to `100.0` (SDR), `gamut_knee`
345    /// to `0.96` — start from
346    /// [`HdrConfig::for_source_peak`](crate::HdrConfig::for_source_peak).
347    ///
348    /// For non-HDR sources the `hdr` argument is ignored and the call
349    /// behaves like [`convert_to`](PixelBufferConvertExt::convert_to).
350    ///
351    /// **Allocates** a new [`PixelBuffer`].
352    fn convert_to_with_hdr_config(
353        &self,
354        target: PixelDescriptor,
355        hdr: crate::HdrConfig,
356    ) -> Result<PixelBuffer, At<crate::ConvertError>>;
357}
358
359#[cfg(feature = "hdr-experimental")]
360impl PixelBufferHdrConvertExt for PixelBuffer {
361    #[track_caller]
362    fn convert_to_sdr(
363        &self,
364        target: PixelDescriptor,
365    ) -> Result<PixelBuffer, At<crate::ConvertError>> {
366        use crate::hdr::{CllMeasure, LightLevelMethod};
367        use zenpixels::hdr::{ContentLightLevel, DiffuseWhite};
368
369        let src_desc = self.descriptor();
370        check_needs_cms(&src_desc, &target)?;
371
372        // Non-HDR source: short-circuit to the regular convert_to path
373        // (which now rejects HDR→SDR loudly, so this is purely the
374        // "doesn't matter, source isn't HDR" branch).
375        if !matches!(
376            src_desc.transfer(),
377            TransferFunction::Pq | TransferFunction::Hlg
378        ) {
379            return self.convert_to(target);
380        }
381
382        // Measure source peak. For PQ buffers we need linear-light F32
383        // first (CllMeasure operates on relative-linear RGB f32).
384        // For HLG, same thing.
385        let lin_desc = PixelDescriptor::new_full(
386            ChannelType::F32,
387            if src_desc.has_alpha() {
388                ChannelLayout::Rgba
389            } else {
390                ChannelLayout::Rgb
391            },
392            src_desc.alpha(),
393            TransferFunction::Linear,
394            src_desc.primaries,
395        );
396        let linear_src = self.convert_to(lin_desc)?;
397        let lin_slice = linear_src.as_slice();
398        let diffuse_white = self
399            .color_context()
400            .and_then(|c| c.diffuse_white)
401            .unwrap_or(DiffuseWhite::BT2408);
402        let cll =
403            ContentLightLevel::measure_max(lin_slice, diffuse_white, LightLevelMethod::MaxRgb)
404                .unwrap_or(ContentLightLevel::new(1000, 0));
405        let source_peak_nits = f32::from(cll.max_content_light_level).max(100.0);
406        self.convert_to_with_hdr_config(target, crate::HdrConfig::for_source_peak(source_peak_nits))
407    }
408
409    #[track_caller]
410    fn convert_to_with_hdr_config(
411        &self,
412        target: PixelDescriptor,
413        hdr: crate::HdrConfig,
414    ) -> Result<PixelBuffer, At<crate::ConvertError>> {
415        let src_desc = self.descriptor();
416        check_needs_cms(&src_desc, &target)?;
417        // Do NOT short-circuit on `src_desc == target` — the HDR-aware
418        // constructor still needs to run the tone-map + soft-compress
419        // chain when both descriptors are e.g. `RGBF32_LINEAR`. The plan
420        // itself decides whether HDR work is needed based on the source's
421        // transfer function (SDR-encoded sources fall through to plain
422        // `ConvertPlan::new`).
423
424        let plan = crate::ConvertPlan::new_with_hdr_config(src_desc, target, hdr).at()?;
425        let mut converter = crate::RowConverter::from_plan(plan);
426
427        let dst_stride = target.aligned_stride(self.width());
428        let total = dst_stride
429            .checked_mul(self.height() as usize)
430            .ok_or_else(|| whereat::at!(crate::ConvertError::AllocationFailed))?;
431        let mut out = alloc::vec![0u8; total];
432
433        let src_slice = self.as_slice();
434        for y in 0..self.height() {
435            let src_row = src_slice.row(y);
436            let dst_start = y as usize * dst_stride;
437            let dst_end = dst_start + dst_stride;
438            converter.convert_row(src_row, &mut out[dst_start..dst_end], self.width());
439        }
440
441        let mut buf = PixelBuffer::from_vec(out, self.width(), self.height(), target)
442            .map_err_at(crate::ConvertError::from)?;
443        if let Some(ctx) = self.color_context() {
444            buf = buf.with_color_context(Arc::clone(ctx));
445        }
446        Ok(buf)
447    }
448}
449
450#[cfg(feature = "rgb")]
451use zenpixels::buffer::Pixel;
452
453#[cfg(feature = "rgb")]
454impl PixelBufferConvertTypedExt for PixelBuffer {
455    fn to_rgb8(&self) -> PixelBuffer<rgb::Rgb<u8>> {
456        convert_to_typed(self, PixelDescriptor::RGB8_SRGB)
457    }
458
459    fn to_rgba8(&self) -> PixelBuffer<rgb::Rgba<u8>> {
460        convert_to_typed(self, PixelDescriptor::RGBA8_SRGB)
461    }
462
463    fn to_gray8(&self) -> PixelBuffer<rgb::Gray<u8>> {
464        convert_to_typed(self, PixelDescriptor::GRAY8_SRGB)
465    }
466
467    fn to_bgra8(&self) -> PixelBuffer<rgb::alt::BGRA<u8>> {
468        convert_to_typed(self, PixelDescriptor::BGRA8_SRGB)
469    }
470}
471
472/// Internal: convert to any target descriptor, returning a typed buffer.
473#[cfg(feature = "rgb")]
474fn convert_to_typed<Q: Pixel>(buf: &PixelBuffer, target: PixelDescriptor) -> PixelBuffer<Q> {
475    use alloc::vec;
476    let mut conv = crate::RowConverter::new(buf.descriptor(), target)
477        .expect("RowConverter: no conversion path");
478    let dst_bpp = target.bytes_per_pixel();
479    let dst_stride = target.aligned_stride(buf.width());
480    let total = dst_stride * buf.height() as usize;
481    let mut out = vec![0u8; total];
482    let src_slice = buf.as_slice();
483    for y in 0..buf.height() {
484        let src_row = src_slice.row(y);
485        let dst_start = y as usize * dst_stride;
486        let dst_end = dst_start + buf.width() as usize * dst_bpp;
487        conv.convert_row(src_row, &mut out[dst_start..dst_end], buf.width());
488    }
489    // We need to construct PixelBuffer<Q> from raw parts.
490    // Use from_vec to build the erased form, then reinterpret.
491    let erased = PixelBuffer::from_vec(out, buf.width(), buf.height(), target)
492        .expect("convert_to_typed: buffer construction failed");
493    // Carry over color context
494    let erased = if let Some(ctx) = buf.color_context() {
495        erased.with_color_context(Arc::clone(ctx))
496    } else {
497        erased
498    };
499    erased
500        .try_typed::<Q>()
501        .expect("convert_to_typed: type mismatch after conversion")
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507
508    // --- CMYK guard tests ---
509    //
510    // These used to be `#[should_panic]` against `assert_not_cmyk` (the
511    // pre-#44 ABORT behaviour). Per the 0.2.16 NeedsCms migration the
512    // trait-level entry points return a typed `ConvertError::NeedsCms`
513    // instead — callers wanting CMS dispatch build a `RowConverter` with
514    // `new_explicit_with_cms(_, _, _, Some(&MoxCms))` and re-issue.
515
516    #[test]
517    fn cmyk_source_returns_needs_cms_from_convert_to() {
518        let cmyk_data = vec![0u8; 4 * 4]; // 4 pixels
519        let buf = PixelBuffer::from_vec(cmyk_data, 2, 2, PixelDescriptor::CMYK8).unwrap();
520        let err = match buf.convert_to(PixelDescriptor::RGB8_SRGB) {
521            Ok(_) => panic!("CMYK→RGB on the no-CMS extension entry must error"),
522            Err(e) => e,
523        };
524        assert!(
525            matches!(*err.error(), crate::ConvertError::NeedsCms { .. }),
526            "expected NeedsCms, got {:?}",
527            err.error(),
528        );
529    }
530
531    #[test]
532    fn cmyk_target_returns_needs_cms_from_convert_to() {
533        let rgb_data = vec![0u8; 3 * 4]; // 4 pixels
534        let buf = PixelBuffer::from_vec(rgb_data, 2, 2, PixelDescriptor::RGB8_SRGB).unwrap();
535        let err = match buf.convert_to(PixelDescriptor::CMYK8) {
536            Ok(_) => panic!("RGB→CMYK on the no-CMS extension entry must error"),
537            Err(e) => e,
538        };
539        assert!(
540            matches!(*err.error(), crate::ConvertError::NeedsCms { .. }),
541            "expected NeedsCms, got {:?}",
542            err.error(),
543        );
544    }
545
546    // --- TransferFunction linearize/delinearize tests ---
547
548    #[test]
549    fn srgb_linearize_roundtrip() {
550        let tf = TransferFunction::Srgb;
551        for &v in &[0.0, 0.04045, 0.1, 0.5, 0.73, 1.0] {
552            let lin = tf.linearize(v);
553            let back = tf.delinearize(lin);
554            assert!(
555                (v - back).abs() < 1e-5,
556                "sRGB roundtrip failed for {v}: linearize={lin}, delinearize={back}"
557            );
558        }
559    }
560
561    #[test]
562    fn pq_linearize_roundtrip() {
563        let tf = TransferFunction::Pq;
564        // linear-srgb 0.6 rational poly: ~3e-4 roundtrip error at low signal.
565        // Tighten to 1e-5 after upgrading to linear-srgb with two-range EOTF.
566        for &v in &[0.0, 0.1, 0.5, 0.75, 1.0] {
567            let lin = tf.linearize(v);
568            let back = tf.delinearize(lin);
569            assert!(
570                (v - back).abs() < 5e-4,
571                "PQ roundtrip failed for {v}: linearize={lin}, delinearize={back}"
572            );
573        }
574    }
575
576    #[test]
577    fn hlg_linearize_roundtrip() {
578        let tf = TransferFunction::Hlg;
579        for &v in &[0.0, 0.1, 0.3, 0.5, 0.8, 1.0] {
580            let lin = tf.linearize(v);
581            let back = tf.delinearize(lin);
582            assert!(
583                (v - back).abs() < 1e-4,
584                "HLG roundtrip failed for {v}: linearize={lin}, delinearize={back}"
585            );
586        }
587    }
588
589    #[test]
590    fn linear_identity() {
591        let tf = TransferFunction::Linear;
592        for &v in &[0.0, 0.5, 1.0] {
593            assert_eq!(tf.linearize(v), v);
594            assert_eq!(tf.delinearize(v), v);
595        }
596    }
597
598    // --- ColorPrimaries XYZ matrix tests ---
599
600    #[test]
601    fn xyz_matrix_availability() {
602        assert!(ColorPrimaries::Bt709.to_xyz_matrix().is_some());
603        assert!(ColorPrimaries::Bt709.from_xyz_matrix().is_some());
604        assert!(ColorPrimaries::DisplayP3.to_xyz_matrix().is_some());
605        assert!(ColorPrimaries::Bt2020.to_xyz_matrix().is_some());
606        assert!(ColorPrimaries::Unknown.to_xyz_matrix().is_none());
607        assert!(ColorPrimaries::Unknown.from_xyz_matrix().is_none());
608    }
609
610    #[test]
611    fn xyz_roundtrip_bt709() {
612        let to = ColorPrimaries::Bt709.to_xyz_matrix().unwrap();
613        let from = ColorPrimaries::Bt709.from_xyz_matrix().unwrap();
614        let rgb = [0.5f32, 0.3, 0.8];
615        let mut v = rgb;
616        crate::gamut::apply_matrix_f32(&mut v, to);
617        crate::gamut::apply_matrix_f32(&mut v, from);
618        for c in 0..3 {
619            assert!(
620                (v[c] - rgb[c]).abs() < 1e-4,
621                "XYZ roundtrip BT.709 ch{c}: {:.6} vs {:.6}",
622                v[c],
623                rgb[c]
624            );
625        }
626    }
627
628    // --- Bt709 and Unknown transfer function tests ---
629
630    #[test]
631    fn bt709_linearize_roundtrip() {
632        let tf = TransferFunction::Bt709;
633        for &v in &[0.0, 0.04045, 0.1, 0.5, 0.73, 1.0] {
634            let lin = tf.linearize(v);
635            let back = tf.delinearize(lin);
636            assert!(
637                (v - back).abs() < 1e-5,
638                "BT.709 roundtrip failed for {v}: linearize={lin}, delinearize={back}"
639            );
640        }
641    }
642
643    #[test]
644    fn unknown_transfer_identity() {
645        let tf = TransferFunction::Unknown;
646        for &v in &[0.0, 0.25, 0.5, 0.75, 1.0] {
647            assert_eq!(
648                tf.linearize(v),
649                v,
650                "Unknown linearize should be identity for {v}"
651            );
652            assert_eq!(
653                tf.delinearize(v),
654                v,
655                "Unknown delinearize should be identity for {v}"
656            );
657        }
658    }
659
660    // --- PixelBufferConvertExt tests ---
661
662    use super::PixelBufferConvertExt;
663
664    #[test]
665    fn convert_to_identity() {
666        let data = vec![100u8, 150, 200, 50, 100, 150];
667        let buf = PixelBuffer::from_vec(data.clone(), 2, 1, PixelDescriptor::RGB8_SRGB).unwrap();
668        let out = buf.convert_to(PixelDescriptor::RGB8_SRGB).unwrap();
669        assert_eq!(out.descriptor(), PixelDescriptor::RGB8_SRGB);
670        assert_eq!(out.width(), 2);
671        assert_eq!(out.height(), 1);
672        assert_eq!(&out.as_slice().row(0)[..6], &data[..]);
673    }
674
675    #[test]
676    fn convert_to_rgba8() {
677        let data = vec![100u8, 150, 200, 50, 100, 150];
678        let buf = PixelBuffer::from_vec(data, 2, 1, PixelDescriptor::RGB8_SRGB).unwrap();
679        let out = buf.convert_to(PixelDescriptor::RGBA8_SRGB).unwrap();
680        assert_eq!(out.descriptor(), PixelDescriptor::RGBA8_SRGB);
681        let slice = out.as_slice();
682        let row = slice.row(0);
683        // Pixel 0: R=100, G=150, B=200, A=255
684        assert_eq!(row[0], 100);
685        assert_eq!(row[1], 150);
686        assert_eq!(row[2], 200);
687        assert_eq!(row[3], 255);
688        // Pixel 1: R=50, G=100, B=150, A=255
689        assert_eq!(row[4], 50);
690        assert_eq!(row[5], 100);
691        assert_eq!(row[6], 150);
692        assert_eq!(row[7], 255);
693    }
694
695    #[test]
696    fn try_add_alpha_rgb() {
697        let data = vec![100u8, 150, 200, 50, 100, 150];
698        let buf = PixelBuffer::from_vec(data, 2, 1, PixelDescriptor::RGB8_SRGB).unwrap();
699        let out = buf.try_add_alpha().unwrap();
700        // Should now be RGBA with straight alpha
701        assert_eq!(
702            out.descriptor().layout(),
703            zenpixels::descriptor::ChannelLayout::Rgba
704        );
705        let slice = out.as_slice();
706        let row = slice.row(0);
707        assert_eq!(row[3], 255);
708        assert_eq!(row[7], 255);
709    }
710
711    #[test]
712    fn try_widen_to_u16() {
713        let data = vec![100u8, 150, 200, 50, 100, 150];
714        let buf = PixelBuffer::from_vec(data, 2, 1, PixelDescriptor::RGB8_SRGB).unwrap();
715        let out = buf.try_widen_to_u16().unwrap();
716        assert_eq!(
717            out.descriptor().channel_type(),
718            zenpixels::descriptor::ChannelType::U16
719        );
720        let slice = out.as_slice();
721        let row = slice.row(0);
722        // U16 little-endian: value * 257
723        for (i, &expected_u8) in [100u8, 150, 200, 50, 100, 150].iter().enumerate() {
724            let lo = row[i * 2];
725            let hi = row[i * 2 + 1];
726            let val = u16::from_le_bytes([lo, hi]);
727            let expected = expected_u8 as u16 * 257;
728            assert_eq!(
729                val, expected,
730                "channel {i}: expected {expected} (u8={expected_u8}*257), got {val}"
731            );
732        }
733    }
734
735    #[test]
736    fn linearize_srgb_to_linear_f32() {
737        let data = vec![128u8, 128, 128, 64, 64, 64];
738        let buf = PixelBuffer::from_vec(data, 2, 1, PixelDescriptor::RGB8_SRGB).unwrap();
739        let lin = buf.linearize().unwrap();
740        assert_eq!(lin.descriptor().transfer(), TransferFunction::Linear);
741        assert_eq!(
742            lin.descriptor().channel_type(),
743            zenpixels::descriptor::ChannelType::F32
744        );
745        assert_eq!(lin.descriptor().primaries, ColorPrimaries::Bt709);
746        // sRGB 128/255 ≈ 0.502 → linear ≈ 0.216
747        let slice = lin.as_slice();
748        let row = slice.row(0);
749        let r = f32::from_le_bytes([row[0], row[1], row[2], row[3]]);
750        assert!(
751            (r - 0.216).abs() < 0.01,
752            "sRGB 128 should linearize to ~0.216, got {r}"
753        );
754    }
755
756    #[test]
757    fn delinearize_linear_to_srgb() {
758        // Create linear F32 buffer
759        let linear_val: f32 = 0.216;
760        let mut data = vec![0u8; 24]; // 2 pixels × 3 channels × 4 bytes
761        for i in 0..6 {
762            let bytes = linear_val.to_le_bytes();
763            data[i * 4..i * 4 + 4].copy_from_slice(&bytes);
764        }
765        let buf = PixelBuffer::from_vec(data, 2, 1, PixelDescriptor::RGBF32_LINEAR).unwrap();
766        let srgb = buf.delinearize(TransferFunction::Srgb).unwrap();
767        assert_eq!(srgb.descriptor().transfer(), TransferFunction::Srgb);
768        // Linear 0.216 → sRGB ≈ 0.502
769        let slice = srgb.as_slice();
770        let row = slice.row(0);
771        let r = f32::from_le_bytes([row[0], row[1], row[2], row[3]]);
772        assert!(
773            (r - 0.502).abs() < 0.01,
774            "linear 0.216 should delinearize to ~0.502, got {r}"
775        );
776    }
777
778    #[test]
779    fn linearize_delinearize_roundtrip() {
780        let data = vec![100u8, 150, 200, 50, 100, 150];
781        let buf = PixelBuffer::from_vec(data.clone(), 2, 1, PixelDescriptor::RGB8_SRGB).unwrap();
782        let lin = buf.linearize().unwrap();
783        // Now delinearize back to sRGB F32
784        let back = lin.delinearize(TransferFunction::Srgb).unwrap();
785        // Values should round-trip within F32 precision
786        let slice = back.as_slice();
787        let row = slice.row(0);
788        let r = f32::from_le_bytes([row[0], row[1], row[2], row[3]]);
789        let expected = 100.0 / 255.0;
790        assert!(
791            (r - expected).abs() < 0.005,
792            "roundtrip pixel 0 R: expected ~{expected}, got {r}"
793        );
794    }
795
796    #[test]
797    fn linearize_preserves_alpha() {
798        let data = vec![100u8, 150, 200, 128, 50, 100, 150, 64];
799        let buf = PixelBuffer::from_vec(data, 2, 1, PixelDescriptor::RGBA8_SRGB).unwrap();
800        let lin = buf.linearize().unwrap();
801        assert_eq!(
802            lin.descriptor().layout(),
803            zenpixels::descriptor::ChannelLayout::Rgba
804        );
805        assert!(lin.descriptor().alpha().is_some());
806    }
807
808    #[test]
809    fn linearize_preserves_primaries() {
810        let data = vec![100u8, 150, 200, 50, 100, 150];
811        let desc = PixelDescriptor::RGB8_SRGB.with_primaries(ColorPrimaries::DisplayP3);
812        let buf = PixelBuffer::from_vec(data, 2, 1, desc).unwrap();
813        let lin = buf.linearize().unwrap();
814        assert_eq!(lin.descriptor().primaries, ColorPrimaries::DisplayP3);
815    }
816
817    #[test]
818    fn linearize_already_linear_is_identity() {
819        let val: f32 = 0.5;
820        let mut data = vec![0u8; 12]; // 1 pixel × 3 channels × 4 bytes
821        for i in 0..3 {
822            data[i * 4..i * 4 + 4].copy_from_slice(&val.to_le_bytes());
823        }
824        let buf = PixelBuffer::from_vec(data, 1, 1, PixelDescriptor::RGBF32_LINEAR).unwrap();
825        let lin = buf.linearize().unwrap();
826        let slice = lin.as_slice();
827        let row = slice.row(0);
828        let r = f32::from_le_bytes([row[0], row[1], row[2], row[3]]);
829        assert!(
830            (r - val).abs() < 1e-6,
831            "already-linear should be identity, got {r}"
832        );
833    }
834
835    #[test]
836    fn try_narrow_to_u8() {
837        // Create RGB16 buffer with known values
838        let values: [u16; 6] = [
839            100 * 257,
840            150 * 257,
841            200 * 257,
842            50 * 257,
843            100 * 257,
844            150 * 257,
845        ];
846        let mut data = vec![0u8; 12];
847        for (i, &v) in values.iter().enumerate() {
848            let bytes = v.to_le_bytes();
849            data[i * 2] = bytes[0];
850            data[i * 2 + 1] = bytes[1];
851        }
852        let buf = PixelBuffer::from_vec(data, 2, 1, PixelDescriptor::RGB16_SRGB).unwrap();
853        let out = buf.try_narrow_to_u8().unwrap();
854        assert_eq!(
855            out.descriptor().channel_type(),
856            zenpixels::descriptor::ChannelType::U8
857        );
858        let slice = out.as_slice();
859        let row = slice.row(0);
860        assert_eq!(row[0], 100);
861        assert_eq!(row[1], 150);
862        assert_eq!(row[2], 200);
863        assert_eq!(row[3], 50);
864        assert_eq!(row[4], 100);
865        assert_eq!(row[5], 150);
866    }
867
868    #[test]
869    #[cfg(feature = "rgb")]
870    fn to_rgb8() {
871        // Start with RGBA8 buffer, convert to typed RGB8
872        let data = vec![100u8, 150, 200, 255, 50, 100, 150, 255];
873        let buf = PixelBuffer::from_vec(data, 2, 1, PixelDescriptor::RGBA8_SRGB).unwrap();
874        let typed: PixelBuffer<rgb::Rgb<u8>> = buf.to_rgb8();
875        assert_eq!(typed.width(), 2);
876        assert_eq!(typed.height(), 1);
877        let slice = typed.as_slice();
878        let row = slice.row(0);
879        // Alpha should be dropped: 3 bytes per pixel
880        assert_eq!(row[0], 100);
881        assert_eq!(row[1], 150);
882        assert_eq!(row[2], 200);
883        assert_eq!(row[3], 50);
884        assert_eq!(row[4], 100);
885        assert_eq!(row[5], 150);
886    }
887
888    #[test]
889    #[cfg(feature = "rgb")]
890    fn to_rgba8() {
891        let data = vec![100u8, 150, 200, 50, 100, 150];
892        let buf = PixelBuffer::from_vec(data, 2, 1, PixelDescriptor::RGB8_SRGB).unwrap();
893        let typed: PixelBuffer<rgb::Rgba<u8>> = buf.to_rgba8();
894        assert_eq!(typed.width(), 2);
895        assert_eq!(typed.height(), 1);
896        let slice = typed.as_slice();
897        let row = slice.row(0);
898        // RGB -> RGBA with alpha=255
899        assert_eq!(row[0], 100);
900        assert_eq!(row[1], 150);
901        assert_eq!(row[2], 200);
902        assert_eq!(row[3], 255);
903        assert_eq!(row[4], 50);
904        assert_eq!(row[5], 100);
905        assert_eq!(row[6], 150);
906        assert_eq!(row[7], 255);
907    }
908
909    #[test]
910    #[cfg(feature = "rgb")]
911    fn to_gray8() {
912        let data = vec![100u8, 150, 200, 50, 100, 150];
913        let buf = PixelBuffer::from_vec(data, 2, 1, PixelDescriptor::RGB8_SRGB).unwrap();
914        let typed: PixelBuffer<rgb::Gray<u8>> = buf.to_gray8();
915        assert_eq!(typed.width(), 2);
916        assert_eq!(typed.height(), 1);
917        let slice = typed.as_slice();
918        let row = slice.row(0);
919        // Gray values should be luminance-weighted, not zero
920        assert!(row[0] > 0, "gray pixel 0 should be non-zero");
921        assert!(row[1] > 0, "gray pixel 1 should be non-zero");
922    }
923
924    #[test]
925    #[cfg(feature = "rgb")]
926    fn to_bgra8() {
927        let data = vec![100u8, 150, 200, 50, 100, 150];
928        let buf = PixelBuffer::from_vec(data, 2, 1, PixelDescriptor::RGB8_SRGB).unwrap();
929        let typed: PixelBuffer<rgb::alt::BGRA<u8>> = buf.to_bgra8();
930        assert_eq!(typed.width(), 2);
931        assert_eq!(typed.height(), 1);
932        let slice = typed.as_slice();
933        let row = slice.row(0);
934        // BGRA layout: B, G, R, A
935        // Pixel 0: R=100, G=150, B=200 -> BGRA = 200, 150, 100, 255
936        assert_eq!(row[0], 200);
937        assert_eq!(row[1], 150);
938        assert_eq!(row[2], 100);
939        assert_eq!(row[3], 255);
940        // Pixel 1: R=50, G=100, B=150 -> BGRA = 150, 100, 50, 255
941        assert_eq!(row[4], 150);
942        assert_eq!(row[5], 100);
943        assert_eq!(row[6], 50);
944        assert_eq!(row[7], 255);
945    }
946}