Skip to main content

edgefirst_tensor/
format.rs

1// SPDX-FileCopyrightText: Copyright 2025 Au-Zone Technologies
2// SPDX-License-Identifier: Apache-2.0
3
4use serde::{Deserialize, Serialize};
5use std::fmt;
6
7/// Pixel format identifier.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
9#[repr(u8)]
10#[non_exhaustive]
11pub enum PixelFormat {
12    /// Packed RGB [H, W, 3]
13    Rgb = 1,
14    /// Packed RGBA [H, W, 4]
15    Rgba,
16    /// Packed BGRA [H, W, 4]
17    Bgra,
18    /// Grayscale [H, W, 1]
19    Grey,
20    /// Packed YUV 4:2:2, YUYV byte order [H, W, 2]
21    Yuyv,
22    /// Packed YUV 4:2:2, VYUY byte order [H, W, 2]
23    Vyuy,
24    /// Semi-planar YUV 4:2:0 [H*3/2, W] or multiplane [H, W] + [H/2, W]
25    Nv12,
26    /// Semi-planar YUV 4:2:2 [H*2, W] or multiplane [H, W] + [H, W]
27    Nv16,
28    /// Planar RGB, channels-first [3, H, W]
29    PlanarRgb,
30    /// Planar RGBA, channels-first [4, H, W]
31    PlanarRgba,
32    /// Semi-planar YUV 4:4:4, contiguous shape `[H*3, W]`. Full-resolution
33    /// chroma: Y plane (H rows of W bytes) + interleaved Cb/Cr plane (H image
34    /// rows of W pairs = 2W bytes/row, laid out as 2H rows of W) → 3H rows
35    /// total. Multiplane NV24 is not yet supported (see `from_planes`). Added
36    /// last to keep the existing `#[repr(u8)]` discriminants (and any
37    /// serialized values) stable.
38    Nv24,
39}
40
41/// Memory layout category.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
43#[non_exhaustive]
44pub enum PixelLayout {
45    /// Interleaved channels: [H, W, C]
46    Packed,
47    /// Channels-first: [C, H, W]
48    Planar,
49    /// Luma plane + interleaved chroma plane
50    SemiPlanar,
51}
52
53/// Chroma addressing parameters for a semi-planar (NV12/NV16/NV24) format —
54/// the single source of truth shared by the codec writer, CPU readers, and the
55/// Linux + macOS GL shaders. See [`PixelFormat::chroma_layout`].
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub struct ChromaLayout {
58    /// Right-shift applied to the luma `x` to get the chroma column: 1 = half
59    /// horizontal resolution (NV12/NV16), 0 = full resolution (NV24).
60    pub shift_x: u32,
61    /// Right-shift applied to the luma `y` to get the chroma row: 1 = half
62    /// vertical resolution (NV12), 0 = full vertical resolution (NV16/NV24).
63    pub shift_y: u32,
64    /// Physical buffer rows the UV plane advances per chroma line: 1 for
65    /// NV12/NV16 (one `(Cb,Cr)` line fits in a single stride-wide row), 2 for
66    /// NV24 (a full-width `2*W`-byte chroma line spans two stride-wide rows).
67    pub uv_rows_per_luma: usize,
68}
69
70/// FourCC code constants (V4L2/DRM compatible).
71const FOURCC_RGB: u32 = u32::from_le_bytes(*b"RGB ");
72const FOURCC_RGBA: u32 = u32::from_le_bytes(*b"RGBA");
73const FOURCC_BGRA: u32 = u32::from_le_bytes(*b"BGRA");
74const FOURCC_GREY: u32 = u32::from_le_bytes(*b"Y800");
75const FOURCC_YUYV: u32 = u32::from_le_bytes(*b"YUYV");
76const FOURCC_VYUY: u32 = u32::from_le_bytes(*b"VYUY");
77const FOURCC_NV12: u32 = u32::from_le_bytes(*b"NV12");
78const FOURCC_NV16: u32 = u32::from_le_bytes(*b"NV16");
79const FOURCC_NV24: u32 = u32::from_le_bytes(*b"NV24");
80
81impl PixelFormat {
82    /// Returns the number of channels for this pixel format.
83    ///
84    /// For semi-planar formats (NV12, NV16), this returns 1 (the luma channel
85    /// count for the primary plane). For packed formats, this is the total
86    /// number of interleaved components per pixel.
87    pub const fn channels(&self) -> usize {
88        match self {
89            Self::Rgb | Self::PlanarRgb => 3,
90            Self::Rgba | Self::Bgra | Self::PlanarRgba => 4,
91            Self::Grey | Self::Nv12 | Self::Nv16 | Self::Nv24 => 1,
92            Self::Yuyv | Self::Vyuy => 2,
93        }
94    }
95
96    /// Returns the memory layout category for this pixel format.
97    pub const fn layout(&self) -> PixelLayout {
98        match self {
99            Self::Rgb | Self::Rgba | Self::Bgra | Self::Grey | Self::Yuyv | Self::Vyuy => {
100                PixelLayout::Packed
101            }
102            Self::PlanarRgb | Self::PlanarRgba => PixelLayout::Planar,
103            Self::Nv12 | Self::Nv16 | Self::Nv24 => PixelLayout::SemiPlanar,
104        }
105    }
106
107    /// The tensor shape for this format at `width`×`height`, or `None` if the
108    /// dimensions are invalid for the format, or the format is an unsupported
109    /// semi-planar variant (any `SemiPlanar` variant other than `Nv12`,
110    /// `Nv16`, and `Nv24`).
111    ///
112    /// Odd dimensions are fully supported.  The combined-plane height for NV12
113    /// is `height + ceil(height / 2)` (luma rows + chroma rows), which equals
114    /// the classic `height * 3 / 2` for even heights and stays exact for odd
115    /// ones — e.g. 483 → 725 rows (483 luma + 242 chroma).
116    ///
117    /// For semi-planar formats the shape carries the **logical** width as-is
118    /// (odd widths are preserved, e.g. `[720, 789]` for a 789×384 NV12).
119    /// The row stride recorded separately on the tensor is `>= even(width)` and
120    /// 64-byte aligned; it may exceed the logical width.  Use
121    /// `effective_row_stride()` to determine the true byte pitch for
122    /// mapping and allocation.  Allocation byte size = `total_h * row_stride`,
123    /// NOT the shape product.
124    pub fn image_shape(&self, width: usize, height: usize) -> Option<Vec<usize>> {
125        match self.layout() {
126            PixelLayout::Packed => Some(vec![height, width, self.channels()]),
127            PixelLayout::Planar => Some(vec![self.channels(), height, width]),
128            PixelLayout::SemiPlanar => {
129                // Shape carries logical width; row_stride (>= even(width), 64-aligned)
130                // is stored separately on the Tensor and governs byte layout.
131                Some(vec![self.combined_plane_height(height)?, width])
132            }
133        }
134    }
135
136    /// Combined-plane height in physical (stride-wide) rows for a semi-planar
137    /// format: the Y rows plus the interleaved-UV rows.
138    ///
139    ///   * NV12 (4:2:0): `H + ceil(H/2)` — exact for odd heights (e.g. 483 →
140    ///     725 = 483 luma + 242 chroma), equals the classic `H*3/2` for even.
141    ///   * NV16 (4:2:2): `2H` (one full-height chroma row per luma row).
142    ///   * NV24 (4:4:4): `3H` (a full-width `2W`-byte chroma line spans two
143    ///     stride-wide buffer rows, so `2H` chroma rows).
144    ///
145    /// Returns `None` for non-semi-planar formats (and unsupported SemiPlanar
146    /// variants). This is the single source of truth for the vertical extent of
147    /// the contiguous NV* buffer — [`image_shape`](Self::image_shape), the GL
148    /// DMA-BUF/IOSurface imports, the PBO allocator, and the gpu-probe all
149    /// derive from it, so the combined-plane height can never drift between them.
150    pub const fn combined_plane_height(&self, height: usize) -> Option<usize> {
151        match self {
152            PixelFormat::Nv12 => Some(height + height.div_ceil(2)),
153            PixelFormat::Nv16 => Some(height * 2),
154            PixelFormat::Nv24 => Some(height * 3),
155            _ => None,
156        }
157    }
158
159    /// Per-format semi-planar chroma addressing parameters, shared by the codec
160    /// writer ([`uv_rows_per_luma`](ChromaLayout::uv_rows_per_luma)), the CPU
161    /// readers, and both GL shaders so the combined-plane chroma geometry has a
162    /// single source of truth. Returns `None` for non-semi-planar formats.
163    pub const fn chroma_layout(&self) -> Option<ChromaLayout> {
164        match self {
165            // 4:2:0 — half horizontal & vertical chroma resolution.
166            PixelFormat::Nv12 => Some(ChromaLayout {
167                shift_x: 1,
168                shift_y: 1,
169                uv_rows_per_luma: 1,
170            }),
171            // 4:2:2 — half horizontal, full vertical.
172            PixelFormat::Nv16 => Some(ChromaLayout {
173                shift_x: 1,
174                shift_y: 0,
175                uv_rows_per_luma: 1,
176            }),
177            // 4:4:4 — full resolution; the 2W-byte chroma line spans two rows.
178            PixelFormat::Nv24 => Some(ChromaLayout {
179                shift_x: 0,
180                shift_y: 0,
181                uv_rows_per_luma: 2,
182            }),
183            _ => None,
184        }
185    }
186
187    /// Physical GPU-surface dimensions `(pitch_width, total_h)` in texels for a
188    /// semi-planar combined plane bound as one `bpe`-byte-per-element texture,
189    /// or `None` for non-semi-planar formats.
190    ///
191    /// The width is rounded up to the 64-aligned row pitch (`== bytes_per_row`)
192    /// rather than left at the even logical width. ANGLE (and tiled GPUs in
193    /// general) will not address texels beyond a surface's declared width via
194    /// `texelFetch`, so a surface narrower than its padded `bytes_per_row`
195    /// leaves the padding columns unreachable. That is fatal for NV24 (4:4:4):
196    /// its chroma line is `2*W` interleaved bytes, which spills past the even
197    /// width into those padding columns whenever the row is padded
198    /// (`bytes_per_row > even_width`). Making the surface width equal the pitch
199    /// keeps every byte addressable and costs nothing — `bytes_per_row` is
200    /// already this value.
201    ///
202    /// Single source of truth for both IOSurface allocators (the tensor crate's
203    /// `IoSurfaceTensor::new_image` and the image crate's `ImageLayout`), so
204    /// they cannot diverge.
205    pub fn semi_planar_surface_dims(
206        &self,
207        width: usize,
208        height: usize,
209        bpe: usize,
210    ) -> Option<(usize, usize)> {
211        let total_h = self.combined_plane_height(height)?;
212        // image_shape carries the logical width; round its byte pitch up to 64
213        // (bpe == 1 for the R8 combined-plane binding, so pitch == aligned width).
214        let pitch_width = (width * bpe).next_multiple_of(64) / bpe;
215        Some((pitch_width, total_h))
216    }
217
218    /// Returns `true` if this format encodes YUV (luma/chroma) data.
219    pub const fn is_yuv(&self) -> bool {
220        matches!(
221            self,
222            Self::Yuyv | Self::Vyuy | Self::Nv12 | Self::Nv16 | Self::Nv24
223        )
224    }
225
226    /// Returns `true` if this format includes an alpha channel.
227    pub const fn has_alpha(&self) -> bool {
228        matches!(self, Self::Rgba | Self::Bgra | Self::PlanarRgba)
229    }
230
231    /// Returns the V4L2/DRM FourCC code for this format, or `0` for formats
232    /// that have no standard FourCC representation (e.g., `PlanarRgb`).
233    pub const fn to_fourcc(&self) -> u32 {
234        match self {
235            Self::Rgb => FOURCC_RGB,
236            Self::Rgba => FOURCC_RGBA,
237            Self::Bgra => FOURCC_BGRA,
238            Self::Grey => FOURCC_GREY,
239            Self::Yuyv => FOURCC_YUYV,
240            Self::Vyuy => FOURCC_VYUY,
241            Self::Nv12 => FOURCC_NV12,
242            Self::Nv16 => FOURCC_NV16,
243            Self::Nv24 => FOURCC_NV24,
244            Self::PlanarRgb | Self::PlanarRgba => 0,
245        }
246    }
247
248    /// Converts a V4L2/DRM FourCC code to a `PixelFormat`, returning `None`
249    /// for unrecognized or zero codes.
250    pub const fn from_fourcc(fourcc: u32) -> Option<Self> {
251        match fourcc {
252            FOURCC_RGB => Some(Self::Rgb),
253            FOURCC_RGBA => Some(Self::Rgba),
254            FOURCC_BGRA => Some(Self::Bgra),
255            FOURCC_GREY => Some(Self::Grey),
256            FOURCC_YUYV => Some(Self::Yuyv),
257            FOURCC_VYUY => Some(Self::Vyuy),
258            FOURCC_NV12 => Some(Self::Nv12),
259            FOURCC_NV16 => Some(Self::Nv16),
260            FOURCC_NV24 => Some(Self::Nv24),
261            _ => None,
262        }
263    }
264}
265
266impl fmt::Display for PixelFormat {
267    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
268        let fcc = self.to_fourcc();
269        if fcc != 0 {
270            let bytes = fcc.to_le_bytes();
271            for &b in &bytes {
272                if b == b' ' {
273                    break;
274                }
275                write!(f, "{}", b as char)?;
276            }
277            Ok(())
278        } else {
279            write!(f, "{self:?}")
280        }
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    #[test]
289    fn channels() {
290        assert_eq!(PixelFormat::Rgb.channels(), 3);
291        assert_eq!(PixelFormat::Rgba.channels(), 4);
292        assert_eq!(PixelFormat::Bgra.channels(), 4);
293        assert_eq!(PixelFormat::Grey.channels(), 1);
294        assert_eq!(PixelFormat::Yuyv.channels(), 2);
295        assert_eq!(PixelFormat::Vyuy.channels(), 2);
296        assert_eq!(PixelFormat::Nv12.channels(), 1);
297        assert_eq!(PixelFormat::Nv16.channels(), 1);
298        assert_eq!(PixelFormat::Nv24.channels(), 1);
299        assert_eq!(PixelFormat::PlanarRgb.channels(), 3);
300        assert_eq!(PixelFormat::PlanarRgba.channels(), 4);
301    }
302
303    #[test]
304    fn layout() {
305        assert_eq!(PixelFormat::Rgb.layout(), PixelLayout::Packed);
306        assert_eq!(PixelFormat::Rgba.layout(), PixelLayout::Packed);
307        assert_eq!(PixelFormat::Bgra.layout(), PixelLayout::Packed);
308        assert_eq!(PixelFormat::Grey.layout(), PixelLayout::Packed);
309        assert_eq!(PixelFormat::Yuyv.layout(), PixelLayout::Packed);
310        assert_eq!(PixelFormat::Vyuy.layout(), PixelLayout::Packed);
311        assert_eq!(PixelFormat::Nv12.layout(), PixelLayout::SemiPlanar);
312        assert_eq!(PixelFormat::Nv16.layout(), PixelLayout::SemiPlanar);
313        assert_eq!(PixelFormat::Nv24.layout(), PixelLayout::SemiPlanar);
314        assert_eq!(PixelFormat::PlanarRgb.layout(), PixelLayout::Planar);
315        assert_eq!(PixelFormat::PlanarRgba.layout(), PixelLayout::Planar);
316    }
317
318    #[test]
319    fn is_yuv() {
320        assert!(!PixelFormat::Rgb.is_yuv());
321        assert!(!PixelFormat::Rgba.is_yuv());
322        assert!(PixelFormat::Yuyv.is_yuv());
323        assert!(PixelFormat::Vyuy.is_yuv());
324        assert!(PixelFormat::Nv12.is_yuv());
325        assert!(PixelFormat::Nv16.is_yuv());
326        assert!(PixelFormat::Nv24.is_yuv());
327        assert!(!PixelFormat::PlanarRgb.is_yuv());
328    }
329
330    #[test]
331    fn has_alpha() {
332        assert!(!PixelFormat::Rgb.has_alpha());
333        assert!(PixelFormat::Rgba.has_alpha());
334        assert!(PixelFormat::Bgra.has_alpha());
335        assert!(!PixelFormat::Grey.has_alpha());
336        assert!(!PixelFormat::Yuyv.has_alpha());
337        assert!(!PixelFormat::PlanarRgb.has_alpha());
338        assert!(PixelFormat::PlanarRgba.has_alpha());
339    }
340
341    #[test]
342    fn fourcc_roundtrip() {
343        for fmt in [
344            PixelFormat::Rgb,
345            PixelFormat::Rgba,
346            PixelFormat::Bgra,
347            PixelFormat::Grey,
348            PixelFormat::Yuyv,
349            PixelFormat::Vyuy,
350            PixelFormat::Nv12,
351            PixelFormat::Nv16,
352            PixelFormat::Nv24,
353        ] {
354            let fcc = fmt.to_fourcc();
355            assert_ne!(fcc, 0, "{fmt:?} should have a fourcc code");
356            assert_eq!(
357                PixelFormat::from_fourcc(fcc),
358                Some(fmt),
359                "roundtrip failed for {fmt:?}"
360            );
361        }
362    }
363
364    #[test]
365    fn fourcc_planar_returns_zero() {
366        assert_eq!(PixelFormat::PlanarRgb.to_fourcc(), 0);
367        assert_eq!(PixelFormat::PlanarRgba.to_fourcc(), 0);
368    }
369
370    #[test]
371    fn from_fourcc_unknown() {
372        assert_eq!(PixelFormat::from_fourcc(0), None);
373        assert_eq!(PixelFormat::from_fourcc(0xDEADBEEF), None);
374    }
375
376    #[test]
377    fn display_fourcc_formats() {
378        assert_eq!(format!("{}", PixelFormat::Rgba), "RGBA");
379        assert_eq!(format!("{}", PixelFormat::Nv12), "NV12");
380        assert_eq!(format!("{}", PixelFormat::Yuyv), "YUYV");
381        // Grey uses V4L2 FourCC "Y800", not "GREY"
382        assert_eq!(format!("{}", PixelFormat::Grey), "Y800");
383    }
384
385    #[test]
386    fn display_planar_formats() {
387        assert_eq!(format!("{}", PixelFormat::PlanarRgb), "PlanarRgb");
388        assert_eq!(format!("{}", PixelFormat::PlanarRgba), "PlanarRgba");
389    }
390
391    #[test]
392    fn repr_starts_at_one() {
393        assert_eq!(PixelFormat::Rgb as u8, 1);
394    }
395
396    #[test]
397    fn serde_roundtrip() {
398        let fmt = PixelFormat::Nv12;
399        let json = serde_json::to_string(&fmt).unwrap();
400        let back: PixelFormat = serde_json::from_str(&json).unwrap();
401        assert_eq!(fmt, back);
402    }
403}