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