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
170impl PixelBufferConvertExt for PixelBuffer {
171    #[track_caller]
172    fn convert_to(&self, target: PixelDescriptor) -> Result<PixelBuffer, At<crate::ConvertError>> {
173        let src_desc = self.descriptor();
174        if src_desc == target {
175            // Identity — just copy.
176            let dst_stride = target.aligned_stride(self.width());
177            let total = dst_stride
178                .checked_mul(self.height() as usize)
179                .ok_or_else(|| whereat::at!(crate::ConvertError::AllocationFailed))?;
180            let mut out = alloc::vec![0u8; total];
181            let src_slice = self.as_slice();
182            for y in 0..self.height() {
183                let src_row = src_slice.row(y);
184                let dst_start = y as usize * dst_stride;
185                out[dst_start..dst_start + src_row.len()].copy_from_slice(src_row);
186            }
187            let mut buf = PixelBuffer::from_vec(out, self.width(), self.height(), target)
188                .map_err(|_| whereat::at!(crate::ConvertError::AllocationFailed))?;
189            if let Some(ctx) = self.color_context() {
190                buf = buf.with_color_context(Arc::clone(ctx));
191            }
192            return Ok(buf);
193        }
194
195        let mut converter = crate::RowConverter::new(src_desc, target).at()?;
196
197        let dst_stride = target.aligned_stride(self.width());
198        let total = dst_stride
199            .checked_mul(self.height() as usize)
200            .ok_or_else(|| whereat::at!(crate::ConvertError::AllocationFailed))?;
201        let mut out = alloc::vec![0u8; total];
202
203        let src_slice = self.as_slice();
204        for y in 0..self.height() {
205            let src_row = src_slice.row(y);
206            let dst_start = y as usize * dst_stride;
207            let dst_end = dst_start + dst_stride;
208            converter.convert_row(src_row, &mut out[dst_start..dst_end], self.width());
209        }
210
211        let mut buf = PixelBuffer::from_vec(out, self.width(), self.height(), target)
212            .map_err(|_| whereat::at!(crate::ConvertError::AllocationFailed))?;
213        if let Some(ctx) = self.color_context() {
214            buf = buf.with_color_context(Arc::clone(ctx));
215        }
216        Ok(buf)
217    }
218
219    #[track_caller]
220    fn try_add_alpha(&self) -> Result<PixelBuffer, At<crate::ConvertError>> {
221        let desc = self.descriptor();
222        let target_layout = match desc.layout() {
223            ChannelLayout::Gray => ChannelLayout::GrayAlpha,
224            ChannelLayout::Rgb => ChannelLayout::Rgba,
225            other => other,
226        };
227        let alpha = if target_layout.has_alpha() && desc.alpha().is_none() {
228            Some(AlphaMode::Straight)
229        } else {
230            desc.alpha()
231        };
232        let target =
233            PixelDescriptor::new(desc.channel_type(), target_layout, alpha, desc.transfer());
234        self.convert_to(target)
235    }
236
237    #[track_caller]
238    fn try_widen_to_u16(&self) -> Result<PixelBuffer, At<crate::ConvertError>> {
239        let desc = self.descriptor();
240        let target = PixelDescriptor::new(
241            ChannelType::U16,
242            desc.layout(),
243            desc.alpha(),
244            desc.transfer(),
245        );
246        self.convert_to(target)
247    }
248
249    #[track_caller]
250    fn try_narrow_to_u8(&self) -> Result<PixelBuffer, At<crate::ConvertError>> {
251        let desc = self.descriptor();
252        let target = PixelDescriptor::new(
253            ChannelType::U8,
254            desc.layout(),
255            desc.alpha(),
256            desc.transfer(),
257        );
258        self.convert_to(target)
259    }
260
261    #[track_caller]
262    fn linearize(&self) -> Result<PixelBuffer, At<crate::ConvertError>> {
263        let desc = self.descriptor();
264        let target = PixelDescriptor::new_full(
265            ChannelType::F32,
266            desc.layout(),
267            desc.alpha(),
268            TransferFunction::Linear,
269            desc.primaries,
270        );
271        self.convert_to(target)
272    }
273
274    #[track_caller]
275    fn delinearize(
276        &self,
277        transfer: TransferFunction,
278    ) -> Result<PixelBuffer, At<crate::ConvertError>> {
279        let target = self.descriptor().with_transfer(transfer);
280        self.convert_to(target)
281    }
282}
283
284#[cfg(feature = "rgb")]
285use zenpixels::buffer::Pixel;
286
287#[cfg(feature = "rgb")]
288impl PixelBufferConvertTypedExt for PixelBuffer {
289    fn to_rgb8(&self) -> PixelBuffer<rgb::Rgb<u8>> {
290        convert_to_typed(self, PixelDescriptor::RGB8_SRGB)
291    }
292
293    fn to_rgba8(&self) -> PixelBuffer<rgb::Rgba<u8>> {
294        convert_to_typed(self, PixelDescriptor::RGBA8_SRGB)
295    }
296
297    fn to_gray8(&self) -> PixelBuffer<rgb::Gray<u8>> {
298        convert_to_typed(self, PixelDescriptor::GRAY8_SRGB)
299    }
300
301    fn to_bgra8(&self) -> PixelBuffer<rgb::alt::BGRA<u8>> {
302        convert_to_typed(self, PixelDescriptor::BGRA8_SRGB)
303    }
304}
305
306/// Internal: convert to any target descriptor, returning a typed buffer.
307#[cfg(feature = "rgb")]
308fn convert_to_typed<Q: Pixel>(buf: &PixelBuffer, target: PixelDescriptor) -> PixelBuffer<Q> {
309    use alloc::vec;
310    let mut conv = crate::RowConverter::new(buf.descriptor(), target)
311        .expect("RowConverter: no conversion path");
312    let dst_bpp = target.bytes_per_pixel();
313    let dst_stride = target.aligned_stride(buf.width());
314    let total = dst_stride * buf.height() as usize;
315    let mut out = vec![0u8; total];
316    let src_slice = buf.as_slice();
317    for y in 0..buf.height() {
318        let src_row = src_slice.row(y);
319        let dst_start = y as usize * dst_stride;
320        let dst_end = dst_start + buf.width() as usize * dst_bpp;
321        conv.convert_row(src_row, &mut out[dst_start..dst_end], buf.width());
322    }
323    // We need to construct PixelBuffer<Q> from raw parts.
324    // Use from_vec to build the erased form, then reinterpret.
325    let erased = PixelBuffer::from_vec(out, buf.width(), buf.height(), target)
326        .expect("convert_to_typed: buffer construction failed");
327    // Carry over color context
328    let erased = if let Some(ctx) = buf.color_context() {
329        erased.with_color_context(Arc::clone(ctx))
330    } else {
331        erased
332    };
333    erased
334        .try_typed::<Q>()
335        .expect("convert_to_typed: type mismatch after conversion")
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    // --- TransferFunction linearize/delinearize tests ---
343
344    #[test]
345    fn srgb_linearize_roundtrip() {
346        let tf = TransferFunction::Srgb;
347        for &v in &[0.0, 0.04045, 0.1, 0.5, 0.73, 1.0] {
348            let lin = tf.linearize(v);
349            let back = tf.delinearize(lin);
350            assert!(
351                (v - back).abs() < 1e-5,
352                "sRGB roundtrip failed for {v}: linearize={lin}, delinearize={back}"
353            );
354        }
355    }
356
357    #[test]
358    fn pq_linearize_roundtrip() {
359        let tf = TransferFunction::Pq;
360        // linear-srgb 0.6 rational poly: ~3e-4 roundtrip error at low signal.
361        // Tighten to 1e-5 after upgrading to linear-srgb with two-range EOTF.
362        for &v in &[0.0, 0.1, 0.5, 0.75, 1.0] {
363            let lin = tf.linearize(v);
364            let back = tf.delinearize(lin);
365            assert!(
366                (v - back).abs() < 5e-4,
367                "PQ roundtrip failed for {v}: linearize={lin}, delinearize={back}"
368            );
369        }
370    }
371
372    #[test]
373    fn hlg_linearize_roundtrip() {
374        let tf = TransferFunction::Hlg;
375        for &v in &[0.0, 0.1, 0.3, 0.5, 0.8, 1.0] {
376            let lin = tf.linearize(v);
377            let back = tf.delinearize(lin);
378            assert!(
379                (v - back).abs() < 1e-4,
380                "HLG roundtrip failed for {v}: linearize={lin}, delinearize={back}"
381            );
382        }
383    }
384
385    #[test]
386    fn linear_identity() {
387        let tf = TransferFunction::Linear;
388        for &v in &[0.0, 0.5, 1.0] {
389            assert_eq!(tf.linearize(v), v);
390            assert_eq!(tf.delinearize(v), v);
391        }
392    }
393
394    // --- ColorPrimaries XYZ matrix tests ---
395
396    #[test]
397    fn xyz_matrix_availability() {
398        assert!(ColorPrimaries::Bt709.to_xyz_matrix().is_some());
399        assert!(ColorPrimaries::Bt709.from_xyz_matrix().is_some());
400        assert!(ColorPrimaries::DisplayP3.to_xyz_matrix().is_some());
401        assert!(ColorPrimaries::Bt2020.to_xyz_matrix().is_some());
402        assert!(ColorPrimaries::Unknown.to_xyz_matrix().is_none());
403        assert!(ColorPrimaries::Unknown.from_xyz_matrix().is_none());
404    }
405
406    #[test]
407    fn xyz_roundtrip_bt709() {
408        let to = ColorPrimaries::Bt709.to_xyz_matrix().unwrap();
409        let from = ColorPrimaries::Bt709.from_xyz_matrix().unwrap();
410        let rgb = [0.5f32, 0.3, 0.8];
411        let mut v = rgb;
412        crate::gamut::apply_matrix_f32(&mut v, to);
413        crate::gamut::apply_matrix_f32(&mut v, from);
414        for c in 0..3 {
415            assert!(
416                (v[c] - rgb[c]).abs() < 1e-4,
417                "XYZ roundtrip BT.709 ch{c}: {:.6} vs {:.6}",
418                v[c],
419                rgb[c]
420            );
421        }
422    }
423
424    // --- Bt709 and Unknown transfer function tests ---
425
426    #[test]
427    fn bt709_linearize_roundtrip() {
428        let tf = TransferFunction::Bt709;
429        for &v in &[0.0, 0.04045, 0.1, 0.5, 0.73, 1.0] {
430            let lin = tf.linearize(v);
431            let back = tf.delinearize(lin);
432            assert!(
433                (v - back).abs() < 1e-5,
434                "BT.709 roundtrip failed for {v}: linearize={lin}, delinearize={back}"
435            );
436        }
437    }
438
439    #[test]
440    fn unknown_transfer_identity() {
441        let tf = TransferFunction::Unknown;
442        for &v in &[0.0, 0.25, 0.5, 0.75, 1.0] {
443            assert_eq!(
444                tf.linearize(v),
445                v,
446                "Unknown linearize should be identity for {v}"
447            );
448            assert_eq!(
449                tf.delinearize(v),
450                v,
451                "Unknown delinearize should be identity for {v}"
452            );
453        }
454    }
455
456    // --- PixelBufferConvertExt tests ---
457
458    use super::PixelBufferConvertExt;
459
460    #[test]
461    fn convert_to_identity() {
462        let data = vec![100u8, 150, 200, 50, 100, 150];
463        let buf = PixelBuffer::from_vec(data.clone(), 2, 1, PixelDescriptor::RGB8_SRGB).unwrap();
464        let out = buf.convert_to(PixelDescriptor::RGB8_SRGB).unwrap();
465        assert_eq!(out.descriptor(), PixelDescriptor::RGB8_SRGB);
466        assert_eq!(out.width(), 2);
467        assert_eq!(out.height(), 1);
468        assert_eq!(&out.as_slice().row(0)[..6], &data[..]);
469    }
470
471    #[test]
472    fn convert_to_rgba8() {
473        let data = vec![100u8, 150, 200, 50, 100, 150];
474        let buf = PixelBuffer::from_vec(data, 2, 1, PixelDescriptor::RGB8_SRGB).unwrap();
475        let out = buf.convert_to(PixelDescriptor::RGBA8_SRGB).unwrap();
476        assert_eq!(out.descriptor(), PixelDescriptor::RGBA8_SRGB);
477        let slice = out.as_slice();
478        let row = slice.row(0);
479        // Pixel 0: R=100, G=150, B=200, A=255
480        assert_eq!(row[0], 100);
481        assert_eq!(row[1], 150);
482        assert_eq!(row[2], 200);
483        assert_eq!(row[3], 255);
484        // Pixel 1: R=50, G=100, B=150, A=255
485        assert_eq!(row[4], 50);
486        assert_eq!(row[5], 100);
487        assert_eq!(row[6], 150);
488        assert_eq!(row[7], 255);
489    }
490
491    #[test]
492    fn try_add_alpha_rgb() {
493        let data = vec![100u8, 150, 200, 50, 100, 150];
494        let buf = PixelBuffer::from_vec(data, 2, 1, PixelDescriptor::RGB8_SRGB).unwrap();
495        let out = buf.try_add_alpha().unwrap();
496        // Should now be RGBA with straight alpha
497        assert_eq!(
498            out.descriptor().layout(),
499            zenpixels::descriptor::ChannelLayout::Rgba
500        );
501        let slice = out.as_slice();
502        let row = slice.row(0);
503        assert_eq!(row[3], 255);
504        assert_eq!(row[7], 255);
505    }
506
507    #[test]
508    fn try_widen_to_u16() {
509        let data = vec![100u8, 150, 200, 50, 100, 150];
510        let buf = PixelBuffer::from_vec(data, 2, 1, PixelDescriptor::RGB8_SRGB).unwrap();
511        let out = buf.try_widen_to_u16().unwrap();
512        assert_eq!(
513            out.descriptor().channel_type(),
514            zenpixels::descriptor::ChannelType::U16
515        );
516        let slice = out.as_slice();
517        let row = slice.row(0);
518        // U16 little-endian: value * 257
519        for (i, &expected_u8) in [100u8, 150, 200, 50, 100, 150].iter().enumerate() {
520            let lo = row[i * 2];
521            let hi = row[i * 2 + 1];
522            let val = u16::from_le_bytes([lo, hi]);
523            let expected = expected_u8 as u16 * 257;
524            assert_eq!(
525                val, expected,
526                "channel {i}: expected {expected} (u8={expected_u8}*257), got {val}"
527            );
528        }
529    }
530
531    #[test]
532    fn linearize_srgb_to_linear_f32() {
533        let data = vec![128u8, 128, 128, 64, 64, 64];
534        let buf = PixelBuffer::from_vec(data, 2, 1, PixelDescriptor::RGB8_SRGB).unwrap();
535        let lin = buf.linearize().unwrap();
536        assert_eq!(lin.descriptor().transfer(), TransferFunction::Linear);
537        assert_eq!(
538            lin.descriptor().channel_type(),
539            zenpixels::descriptor::ChannelType::F32
540        );
541        assert_eq!(lin.descriptor().primaries, ColorPrimaries::Bt709);
542        // sRGB 128/255 ≈ 0.502 → linear ≈ 0.216
543        let slice = lin.as_slice();
544        let row = slice.row(0);
545        let r = f32::from_le_bytes([row[0], row[1], row[2], row[3]]);
546        assert!(
547            (r - 0.216).abs() < 0.01,
548            "sRGB 128 should linearize to ~0.216, got {r}"
549        );
550    }
551
552    #[test]
553    fn delinearize_linear_to_srgb() {
554        // Create linear F32 buffer
555        let linear_val: f32 = 0.216;
556        let mut data = vec![0u8; 24]; // 2 pixels × 3 channels × 4 bytes
557        for i in 0..6 {
558            let bytes = linear_val.to_le_bytes();
559            data[i * 4..i * 4 + 4].copy_from_slice(&bytes);
560        }
561        let buf = PixelBuffer::from_vec(data, 2, 1, PixelDescriptor::RGBF32_LINEAR).unwrap();
562        let srgb = buf.delinearize(TransferFunction::Srgb).unwrap();
563        assert_eq!(srgb.descriptor().transfer(), TransferFunction::Srgb);
564        // Linear 0.216 → sRGB ≈ 0.502
565        let slice = srgb.as_slice();
566        let row = slice.row(0);
567        let r = f32::from_le_bytes([row[0], row[1], row[2], row[3]]);
568        assert!(
569            (r - 0.502).abs() < 0.01,
570            "linear 0.216 should delinearize to ~0.502, got {r}"
571        );
572    }
573
574    #[test]
575    fn linearize_delinearize_roundtrip() {
576        let data = vec![100u8, 150, 200, 50, 100, 150];
577        let buf = PixelBuffer::from_vec(data.clone(), 2, 1, PixelDescriptor::RGB8_SRGB).unwrap();
578        let lin = buf.linearize().unwrap();
579        // Now delinearize back to sRGB F32
580        let back = lin.delinearize(TransferFunction::Srgb).unwrap();
581        // Values should round-trip within F32 precision
582        let slice = back.as_slice();
583        let row = slice.row(0);
584        let r = f32::from_le_bytes([row[0], row[1], row[2], row[3]]);
585        let expected = 100.0 / 255.0;
586        assert!(
587            (r - expected).abs() < 0.005,
588            "roundtrip pixel 0 R: expected ~{expected}, got {r}"
589        );
590    }
591
592    #[test]
593    fn linearize_preserves_alpha() {
594        let data = vec![100u8, 150, 200, 128, 50, 100, 150, 64];
595        let buf = PixelBuffer::from_vec(data, 2, 1, PixelDescriptor::RGBA8_SRGB).unwrap();
596        let lin = buf.linearize().unwrap();
597        assert_eq!(
598            lin.descriptor().layout(),
599            zenpixels::descriptor::ChannelLayout::Rgba
600        );
601        assert!(lin.descriptor().alpha().is_some());
602    }
603
604    #[test]
605    fn linearize_preserves_primaries() {
606        let data = vec![100u8, 150, 200, 50, 100, 150];
607        let desc = PixelDescriptor::RGB8_SRGB.with_primaries(ColorPrimaries::DisplayP3);
608        let buf = PixelBuffer::from_vec(data, 2, 1, desc).unwrap();
609        let lin = buf.linearize().unwrap();
610        assert_eq!(lin.descriptor().primaries, ColorPrimaries::DisplayP3);
611    }
612
613    #[test]
614    fn linearize_already_linear_is_identity() {
615        let val: f32 = 0.5;
616        let mut data = vec![0u8; 12]; // 1 pixel × 3 channels × 4 bytes
617        for i in 0..3 {
618            data[i * 4..i * 4 + 4].copy_from_slice(&val.to_le_bytes());
619        }
620        let buf = PixelBuffer::from_vec(data, 1, 1, PixelDescriptor::RGBF32_LINEAR).unwrap();
621        let lin = buf.linearize().unwrap();
622        let slice = lin.as_slice();
623        let row = slice.row(0);
624        let r = f32::from_le_bytes([row[0], row[1], row[2], row[3]]);
625        assert!(
626            (r - val).abs() < 1e-6,
627            "already-linear should be identity, got {r}"
628        );
629    }
630
631    #[test]
632    fn try_narrow_to_u8() {
633        // Create RGB16 buffer with known values
634        let values: [u16; 6] = [
635            100 * 257,
636            150 * 257,
637            200 * 257,
638            50 * 257,
639            100 * 257,
640            150 * 257,
641        ];
642        let mut data = vec![0u8; 12];
643        for (i, &v) in values.iter().enumerate() {
644            let bytes = v.to_le_bytes();
645            data[i * 2] = bytes[0];
646            data[i * 2 + 1] = bytes[1];
647        }
648        let buf = PixelBuffer::from_vec(data, 2, 1, PixelDescriptor::RGB16_SRGB).unwrap();
649        let out = buf.try_narrow_to_u8().unwrap();
650        assert_eq!(
651            out.descriptor().channel_type(),
652            zenpixels::descriptor::ChannelType::U8
653        );
654        let slice = out.as_slice();
655        let row = slice.row(0);
656        assert_eq!(row[0], 100);
657        assert_eq!(row[1], 150);
658        assert_eq!(row[2], 200);
659        assert_eq!(row[3], 50);
660        assert_eq!(row[4], 100);
661        assert_eq!(row[5], 150);
662    }
663
664    #[test]
665    #[cfg(feature = "rgb")]
666    fn to_rgb8() {
667        // Start with RGBA8 buffer, convert to typed RGB8
668        let data = vec![100u8, 150, 200, 255, 50, 100, 150, 255];
669        let buf = PixelBuffer::from_vec(data, 2, 1, PixelDescriptor::RGBA8_SRGB).unwrap();
670        let typed: PixelBuffer<rgb::Rgb<u8>> = buf.to_rgb8();
671        assert_eq!(typed.width(), 2);
672        assert_eq!(typed.height(), 1);
673        let slice = typed.as_slice();
674        let row = slice.row(0);
675        // Alpha should be dropped: 3 bytes per pixel
676        assert_eq!(row[0], 100);
677        assert_eq!(row[1], 150);
678        assert_eq!(row[2], 200);
679        assert_eq!(row[3], 50);
680        assert_eq!(row[4], 100);
681        assert_eq!(row[5], 150);
682    }
683
684    #[test]
685    #[cfg(feature = "rgb")]
686    fn to_rgba8() {
687        let data = vec![100u8, 150, 200, 50, 100, 150];
688        let buf = PixelBuffer::from_vec(data, 2, 1, PixelDescriptor::RGB8_SRGB).unwrap();
689        let typed: PixelBuffer<rgb::Rgba<u8>> = buf.to_rgba8();
690        assert_eq!(typed.width(), 2);
691        assert_eq!(typed.height(), 1);
692        let slice = typed.as_slice();
693        let row = slice.row(0);
694        // RGB -> RGBA with alpha=255
695        assert_eq!(row[0], 100);
696        assert_eq!(row[1], 150);
697        assert_eq!(row[2], 200);
698        assert_eq!(row[3], 255);
699        assert_eq!(row[4], 50);
700        assert_eq!(row[5], 100);
701        assert_eq!(row[6], 150);
702        assert_eq!(row[7], 255);
703    }
704
705    #[test]
706    #[cfg(feature = "rgb")]
707    fn to_gray8() {
708        let data = vec![100u8, 150, 200, 50, 100, 150];
709        let buf = PixelBuffer::from_vec(data, 2, 1, PixelDescriptor::RGB8_SRGB).unwrap();
710        let typed: PixelBuffer<rgb::Gray<u8>> = buf.to_gray8();
711        assert_eq!(typed.width(), 2);
712        assert_eq!(typed.height(), 1);
713        let slice = typed.as_slice();
714        let row = slice.row(0);
715        // Gray values should be luminance-weighted, not zero
716        assert!(row[0] > 0, "gray pixel 0 should be non-zero");
717        assert!(row[1] > 0, "gray pixel 1 should be non-zero");
718    }
719
720    #[test]
721    #[cfg(feature = "rgb")]
722    fn to_bgra8() {
723        let data = vec![100u8, 150, 200, 50, 100, 150];
724        let buf = PixelBuffer::from_vec(data, 2, 1, PixelDescriptor::RGB8_SRGB).unwrap();
725        let typed: PixelBuffer<rgb::alt::BGRA<u8>> = buf.to_bgra8();
726        assert_eq!(typed.width(), 2);
727        assert_eq!(typed.height(), 1);
728        let slice = typed.as_slice();
729        let row = slice.row(0);
730        // BGRA layout: B, G, R, A
731        // Pixel 0: R=100, G=150, B=200 -> BGRA = 200, 150, 100, 255
732        assert_eq!(row[0], 200);
733        assert_eq!(row[1], 150);
734        assert_eq!(row[2], 100);
735        assert_eq!(row[3], 255);
736        // Pixel 1: R=50, G=100, B=150 -> BGRA = 150, 100, 50, 255
737        assert_eq!(row[4], 150);
738        assert_eq!(row[5], 100);
739        assert_eq!(row[6], 50);
740        assert_eq!(row[7], 255);
741    }
742}