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// Declared through `ef_vocabulary!` (see `vocabulary.rs`): `.code()` is this
8// crate's wire numbering, mirrored by `protocol::format` and (via an
9// explicit, fallible outlier mapping -- see `to_hal_pixel_format` in the
10// capi crate) the C ABI's `hal_pixel_format`, which was numbered
11// independently before this vocabulary was unified and keeps its own frozen
12// values.
13//
14// Stays `#[non_exhaustive]`, deliberately: this attribute protects
15// DOWNSTREAM, not us -- `profiler`, `mobile-sdk` and any third party with an
16// exhaustive `match PixelFormat` keep compiling the day a variant such as
17// `Nv21` is added here. Removing it would narrow a published promise, and
18// narrowing is not reversible: re-adding it later breaks every downstream
19// exhaustive match that has grown in the meantime. That a new variant is
20// already a coordinated, single-declaration change *in this repo* (the
21// macro's whole point) says nothing about matches written outside it.
22// Consequence: a C mapping of `PixelFormat` cannot be a
23// compile-enforced exhaustive match (rustc requires a wildcard arm on a
24// `#[non_exhaustive]` enum outside its defining crate) -- it keeps a
25// wildcard `Err` arm.
26//
27// `as_str()`'s wire strings are a THIRD spelling, deliberately distinct from
28// both `Display` (FourCC-derived: `RGB`, `Y800`, ...) and the `serde` derive
29// (PascalCase variant names: `Rgb`, `PlanarRgb`, ...) -- those two existed
30// before this design and stay as they are; unifying them with the wire
31// vocabulary is not a cleanup, it is a breaking change to two formats that
32// are not this one. This declaration is the format table the schemas repo's
33// `Tensor.msg` cites as canonical, derived by grepping every format-string
34// literal already in use across that repo:
35//
36// | Variant      | `as_str()`     | Convention                                |
37// |--------------|----------------|--------------------------------------------|
38// | `Rgb`        | `"rgb8"`       | ROS `sensor_msgs/Image` encoding (36 uses)  |
39// | `Rgba`       | `"rgba8"`      | ROS encoding                               |
40// | `Bgra`       | `"bgra8"`      | ROS encoding                               |
41// | `Grey`       | `"mono8"`      | ROS encoding (2 uses)                      |
42// | `Yuyv`       | `"YUYV"`       | FourCC                                     |
43// | `Vyuy`       | `"VYUY"`       | FourCC                                     |
44// | `Nv12`       | `"NV12"`       | FourCC (11 uses, vs. 3 stray `"nv12"`)     |
45// | `Nv16`       | `"NV16"`       | FourCC                                     |
46// | `PlanarRgb`  | `"rgb8_planar"`  | ROS encoding, planar; no dimension-order  |
47// | `PlanarRgba` | `"rgba8_planar"` | suffix -- `shape` is the addressing grid  |
48// | `Nv24`       | `"NV24"`       | FourCC                                     |
49//
50// Case follows the format's home ecosystem, not one blanket rule: RGB-family
51// names and `Grey`/`mono8` come from ROS `sensor_msgs/Image`, which is
52// lowercase; YUV-family names are FourCC codes, which are uppercase. The two
53// planar spellings drop the `_nchw` suffix the one pre-existing schemas-repo
54// site (`rgb8_planar_nchw`) still carries -- redundant now that dimension
55// order lives in `shape`, not in the format name. That site needs
56// reconciling in the schemas repo; it is not changed here.
57crate::ef_vocabulary! {
58    /// Pixel format identifier.
59    #[derive(Serialize, Deserialize)]
60    #[non_exhaustive]
61    pub enum PixelFormat {
62        /// Packed RGB [H, W, 3]
63        Rgb = 1, "rgb8", RGB,
64        /// Packed RGBA [H, W, 4]
65        Rgba = 2, "rgba8", RGBA,
66        /// Packed BGRA [H, W, 4]
67        Bgra = 3, "bgra8", BGRA,
68        /// Grayscale [H, W, 1]
69        Grey = 4, "mono8", GREY,
70        /// Packed YUV 4:2:2, YUYV byte order [H, W, 2]
71        Yuyv = 5, "YUYV", YUYV,
72        /// Packed YUV 4:2:2, VYUY byte order [H, W, 2]
73        Vyuy = 6, "VYUY", VYUY,
74        /// Semi-planar YUV 4:2:0 [H*3/2, W] or multiplane [H, W] + [H/2, W]
75        Nv12 = 7, "NV12", NV12,
76        /// Semi-planar YUV 4:2:2 [H*2, W] or multiplane [H, W] + [H, W]
77        Nv16 = 8, "NV16", NV16,
78        /// Planar RGB, channels-first [3, H, W]
79        PlanarRgb = 9, "rgb8_planar", PLANAR_RGB,
80        /// Planar RGBA, channels-first [4, H, W]
81        PlanarRgba = 10, "rgba8_planar", PLANAR_RGBA,
82        /// Semi-planar YUV 4:4:4, contiguous shape `[H*3, W]`. Full-resolution
83        /// chroma: Y plane (H rows of W bytes) + interleaved Cb/Cr plane (H image
84        /// rows of W pairs = 2W bytes/row, laid out as 2H rows of W) → 3H rows
85        /// total. Multiplane NV24 is not yet supported (see `from_planes`). Added
86        /// last to keep the existing discriminants (and any serialized values)
87        /// stable.
88        Nv24 = 11, "NV24", NV24,
89    }
90    // `#[doc(hidden)]`: has to be `pub` for `protocol::format`'s re-export
91    // below to compile (see `ef_vocabulary!`'s doc comment), but it is
92    // emission plumbing, not a second documented API -- `protocol::format`
93    // is the canonical, documented path.
94    #[doc(hidden)]
95    pub mod pixel_format_wire;
96}
97
98/// Memory layout category.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
100#[non_exhaustive]
101pub enum PixelLayout {
102    /// Interleaved channels: [H, W, C]
103    Packed,
104    /// Channels-first: [C, H, W]
105    Planar,
106    /// Luma plane + interleaved chroma plane
107    SemiPlanar,
108}
109
110/// Chroma addressing parameters for a semi-planar (NV12/NV16/NV24) format —
111/// the single source of truth shared by the codec writer, CPU readers, and the
112/// Linux + macOS GL shaders. See [`PixelFormat::chroma_layout`].
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub struct ChromaLayout {
115    /// Right-shift applied to the luma `x` to get the chroma column: 1 = half
116    /// horizontal resolution (NV12/NV16), 0 = full resolution (NV24).
117    pub shift_x: u32,
118    /// Right-shift applied to the luma `y` to get the chroma row: 1 = half
119    /// vertical resolution (NV12), 0 = full vertical resolution (NV16/NV24).
120    pub shift_y: u32,
121    /// Physical buffer rows the UV plane advances per chroma line: 1 for
122    /// NV12/NV16 (one `(Cb,Cr)` line fits in a single stride-wide row), 2 for
123    /// NV24 (a full-width `2*W`-byte chroma line spans two stride-wide rows).
124    pub uv_rows_per_luma: usize,
125}
126
127/// Where one plane's bytes physically live inside a tensor's allocation.
128///
129/// The counterpart to [`PixelFormat::addressing_shape`]: the grid says how a
130/// consumer indexes the image, this says where the bytes are. Buffer extent
131/// comes from here, never from the shape — for a subsampled format
132/// `product(shape) * dtype_size` is smaller than the allocation.
133///
134/// Field names and widths mirror `TensorPlane.msg` so the two map 1:1.
135/// `modifier` is deliberately absent: it describes how a *producer* tiled or
136/// compressed the plane (AFBC on Mali, UBWC on Adreno) and cannot be derived
137/// from the format alone, so it is carried on the tensor rather than computed
138/// here.
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub struct PlaneGeometry {
141    /// Byte offset of this plane from the start of the allocation.
142    pub offset: u64,
143    /// Bytes per line of this plane.
144    pub stride: u64,
145    /// Plane extent in bytes (`stride * rows`).
146    pub size: u64,
147}
148
149// V4L2 pixel format codes (`V4L2_PIX_FMT_*` from <linux/videodev2.h>),
150// stable kernel UAPI. Declared here rather than imported because this crate
151// builds on every platform and `edgefirst-v4l2` is Linux-only; the Linux
152// test `to_fourcc_matches_edgefirst_v4l2` asserts each against
153// `edgefirst_v4l2::uapi`.
154/// `V4L2_PIX_FMT_RGB24`.
155const FOURCC_RGB: u32 = u32::from_le_bytes(*b"RGB3");
156/// `V4L2_PIX_FMT_RGBA32` (bytes R, G, B, A; also DRM `ABGR8888`).
157const FOURCC_RGBA: u32 = u32::from_le_bytes(*b"AB24");
158/// `V4L2_PIX_FMT_ABGR32` (bytes B, G, R, A; also DRM `ARGB8888`).
159const FOURCC_BGRA: u32 = u32::from_le_bytes(*b"AR24");
160/// `V4L2_PIX_FMT_GREY`.
161const FOURCC_GREY: u32 = u32::from_le_bytes(*b"GREY");
162const FOURCC_YUYV: u32 = u32::from_le_bytes(*b"YUYV");
163const FOURCC_VYUY: u32 = u32::from_le_bytes(*b"VYUY");
164const FOURCC_NV12: u32 = u32::from_le_bytes(*b"NV12");
165const FOURCC_NV16: u32 = u32::from_le_bytes(*b"NV16");
166const FOURCC_NV24: u32 = u32::from_le_bytes(*b"NV24");
167
168impl PixelFormat {
169    /// Returns the number of channels for this pixel format.
170    ///
171    /// For semi-planar formats (NV12, NV16), this returns 1 (the luma channel
172    /// count for the primary plane). For packed formats, this is the total
173    /// number of interleaved components per pixel.
174    pub const fn channels(&self) -> usize {
175        match self {
176            Self::Rgb | Self::PlanarRgb => 3,
177            Self::Rgba | Self::Bgra | Self::PlanarRgba => 4,
178            Self::Grey | Self::Nv12 | Self::Nv16 | Self::Nv24 => 1,
179            Self::Yuyv | Self::Vyuy => 2,
180        }
181    }
182
183    /// Returns the memory layout category for this pixel format.
184    pub const fn layout(&self) -> PixelLayout {
185        match self {
186            Self::Rgb | Self::Rgba | Self::Bgra | Self::Grey | Self::Yuyv | Self::Vyuy => {
187                PixelLayout::Packed
188            }
189            Self::PlanarRgb | Self::PlanarRgba => PixelLayout::Planar,
190            Self::Nv12 | Self::Nv16 | Self::Nv24 => PixelLayout::SemiPlanar,
191        }
192    }
193
194    /// The **allocation geometry** for this format at `width`×`height`, or
195    /// `None` if the dimensions are invalid for the format, or the format is
196    /// an unsupported semi-planar variant (any `SemiPlanar` variant other than
197    /// `Nv12`, `Nv16`, and `Nv24`).
198    ///
199    /// This is the shape that *sizes a buffer*. For a subsampled format it is
200    /// deliberately not the shape a consumer indexes with — NV12 640×480
201    /// allocates `[720, 640]` here but addresses a `[480, 640]` luma grid.
202    /// Use [`addressing_shape`](Self::addressing_shape) for the latter, which
203    /// is what descriptors, blobs and `Tensor.msg` carry.
204    ///
205    /// Renamed from `image_shape` when the two meanings were separated: every
206    /// caller of the old name sized an allocation with it (`Tensor::image`,
207    /// `import_image`, and the two PBO helpers), so a single name called "the
208    /// shape" made picking the wrong one the default. There is now no such
209    /// name.
210    ///
211    /// Odd dimensions are fully supported.  The combined-plane height for NV12
212    /// is `height + ceil(height / 2)` (luma rows + chroma rows), which equals
213    /// the classic `height * 3 / 2` for even heights and stays exact for odd
214    /// ones — e.g. 483 → 725 rows (483 luma + 242 chroma).
215    ///
216    /// For semi-planar formats the shape carries the **logical** width as-is
217    /// (odd widths are preserved, e.g. `[720, 789]` for a 789×384 NV12).
218    /// The row stride recorded separately on the tensor is `>= even(width)` and
219    /// 64-byte aligned; it may exceed the logical width.  Use
220    /// `effective_row_stride()` to determine the true byte pitch for
221    /// mapping and allocation.  Allocation byte size = `total_h * row_stride`,
222    /// NOT the shape product.
223    pub fn allocation_shape(&self, width: usize, height: usize) -> Option<Vec<usize>> {
224        match self.layout() {
225            PixelLayout::Packed => Some(vec![height, width, self.channels()]),
226            PixelLayout::Planar => Some(vec![self.channels(), height, width]),
227            PixelLayout::SemiPlanar => {
228                // Shape carries logical width; row_stride (>= even(width), 64-aligned)
229                // is stored separately on the Tensor and governs byte layout.
230                Some(vec![self.combined_plane_height(height)?, width])
231            }
232        }
233    }
234
235    /// The **addressing grid**: how a consumer logically indexes this image.
236    ///
237    /// This is the shape that belongs in a descriptor, a blob, or a
238    /// `Tensor.msg` — never the allocation size. For a subsampled format
239    /// `product(addressing_shape) * dtype_size` is *smaller* than the buffer:
240    /// NV12 640×480 addresses a 640×480 luma grid inside a 460 800-byte
241    /// allocation. Buffer extent comes from the plane table, never from here.
242    ///
243    /// Contrast [`allocation_shape`](Self::allocation_shape), which is the
244    /// buffer geometry every allocating caller must keep using.
245    ///
246    /// The per-format convention is pinned here so producers cannot each
247    /// invent one; it matches the merged `schemas` golden fixtures.
248    pub fn addressing_shape(&self, width: usize, height: usize) -> Option<Vec<usize>> {
249        match self.layout() {
250            // A channel dimension only when there is more than one channel to
251            // address: `mono8` is `[h, w]`, not `[h, w, 1]`. That keeps it
252            // consistent with the semi-planar grid below (also one sample per
253            // pixel position), matches how every array library presents
254            // grayscale, and matches the merged schemas `mono8` golden. The
255            // spec's format table does not cover single-channel packed
256            // formats; this is the rule chosen for them.
257            PixelLayout::Packed if self.channels() == 1 => Some(vec![height, width]),
258            PixelLayout::Packed => Some(vec![height, width, self.channels()]),
259            PixelLayout::Planar => Some(vec![self.channels(), height, width]),
260            // The luma/sample grid. Chroma is subsampled and is not separately
261            // addressable through `shape`; its geometry lives in the plane
262            // table. Validated for the same dimension support as
263            // `allocation_shape` so the two agree on what they accept.
264            PixelLayout::SemiPlanar => {
265                self.combined_plane_height(height)?;
266                Some(vec![height, width])
267            }
268        }
269    }
270
271    /// Where each plane's bytes live, given the allocation's row pitch.
272    ///
273    /// `row_stride` is the byte pitch of a **luma/packed** row — the value
274    /// [`Tensor::effective_row_stride`](crate::Tensor::effective_row_stride)
275    /// reports, which on a DMA backing is 64-byte aligned and may exceed
276    /// `width * bytes_per_pixel`. Planes are laid out contiguously in the
277    /// order a consumer expects them (Y then UV; R then G then B).
278    ///
279    /// Returns `None` for an unsupported semi-planar variant, or if the
280    /// geometry overflows `u64` — dimensions reaching this from an imported
281    /// descriptor are untrusted, and a wrapped size would look small enough
282    /// to pass a capacity check it should fail.
283    ///
284    /// Chroma extent is derived from
285    /// [`combined_plane_height`](Self::combined_plane_height) rather than
286    /// recomputed, so the two cannot drift on odd heights.
287    pub fn plane_table(
288        &self,
289        width: usize,
290        height: usize,
291        row_stride: usize,
292    ) -> Option<Vec<PlaneGeometry>> {
293        let _ = width; // extent is governed by row_stride, not logical width
294        let stride = u64::try_from(row_stride).ok()?;
295        let rows = u64::try_from(height).ok()?;
296        let plane_bytes = stride.checked_mul(rows)?;
297
298        match self.layout() {
299            PixelLayout::Packed => Some(vec![PlaneGeometry {
300                offset: 0,
301                stride,
302                size: plane_bytes,
303            }]),
304            PixelLayout::Planar => {
305                let n = u64::try_from(self.channels()).ok()?;
306                (0..n)
307                    .map(|i| {
308                        Some(PlaneGeometry {
309                            offset: i.checked_mul(plane_bytes)?,
310                            stride,
311                            size: plane_bytes,
312                        })
313                    })
314                    .collect()
315            }
316            PixelLayout::SemiPlanar => {
317                // Derived, never recomputed: `H + ceil(H/2)` for NV12 is exact
318                // for odd heights where a hand-written `H/2` is not.
319                let total_rows = u64::try_from(self.combined_plane_height(height)?).ok()?;
320                let chroma_rows = total_rows.checked_sub(rows)?;
321                Some(vec![
322                    PlaneGeometry {
323                        offset: 0,
324                        stride,
325                        size: plane_bytes,
326                    },
327                    PlaneGeometry {
328                        offset: plane_bytes,
329                        stride,
330                        size: stride.checked_mul(chroma_rows)?,
331                    },
332                ])
333            }
334        }
335    }
336
337    /// Combined-plane height in physical (stride-wide) rows for a semi-planar
338    /// format: the Y rows plus the interleaved-UV rows.
339    ///
340    ///   * NV12 (4:2:0): `H + ceil(H/2)` — exact for odd heights (e.g. 483 →
341    ///     725 = 483 luma + 242 chroma), equals the classic `H*3/2` for even.
342    ///   * NV16 (4:2:2): `2H` (one full-height chroma row per luma row).
343    ///   * NV24 (4:4:4): `3H` (a full-width `2W`-byte chroma line spans two
344    ///     stride-wide buffer rows, so `2H` chroma rows).
345    ///
346    /// Returns `None` for non-semi-planar formats (and unsupported SemiPlanar
347    /// variants). This is the single source of truth for the vertical extent of
348    /// the contiguous NV* buffer — [`allocation_shape`](Self::allocation_shape), the GL
349    /// DMA-BUF/IOSurface imports, the PBO allocator, and the gpu-probe all
350    /// derive from it, so the combined-plane height can never drift between them.
351    pub const fn combined_plane_height(&self, height: usize) -> Option<usize> {
352        match self {
353            PixelFormat::Nv12 => Some(height + height.div_ceil(2)),
354            PixelFormat::Nv16 => Some(height * 2),
355            PixelFormat::Nv24 => Some(height * 3),
356            _ => None,
357        }
358    }
359
360    /// The image height whose combined plane height is `combined`, or `None`
361    /// when no image height produces it (721 for NV12, say) and for every
362    /// non-semi-planar format.
363    ///
364    /// The inverse of [`combined_plane_height`](Self::combined_plane_height),
365    /// and verified against it before returning: the forward map is not
366    /// surjective, so a shape carrying a combined height nothing produced must
367    /// be rejected rather than rounded to a nearby image height.
368    ///
369    /// [`crate::image_dims_from_shape`] uses it to recover the logical height
370    /// from a semi-planar allocation shape.
371    pub fn image_height_from_combined(&self, combined: usize) -> Option<usize> {
372        let candidate = match self {
373            // Checked: a garbled combined height read off a descriptor must
374            // not wrap into a plausible-looking image height.
375            PixelFormat::Nv12 => combined.checked_mul(2)? / 3,
376            PixelFormat::Nv16 => combined / 2,
377            PixelFormat::Nv24 => combined / 3,
378            _ => return None,
379        };
380        (self.combined_plane_height(candidate) == Some(combined)).then_some(candidate)
381    }
382
383    /// Per-format semi-planar chroma addressing parameters, shared by the codec
384    /// writer ([`uv_rows_per_luma`](ChromaLayout::uv_rows_per_luma)), the CPU
385    /// readers, and both GL shaders so the combined-plane chroma geometry has a
386    /// single source of truth. Returns `None` for non-semi-planar formats.
387    pub const fn chroma_layout(&self) -> Option<ChromaLayout> {
388        match self {
389            // 4:2:0 — half horizontal & vertical chroma resolution.
390            PixelFormat::Nv12 => Some(ChromaLayout {
391                shift_x: 1,
392                shift_y: 1,
393                uv_rows_per_luma: 1,
394            }),
395            // 4:2:2 — half horizontal, full vertical.
396            PixelFormat::Nv16 => Some(ChromaLayout {
397                shift_x: 1,
398                shift_y: 0,
399                uv_rows_per_luma: 1,
400            }),
401            // 4:4:4 — full resolution; the 2W-byte chroma line spans two rows.
402            PixelFormat::Nv24 => Some(ChromaLayout {
403                shift_x: 0,
404                shift_y: 0,
405                uv_rows_per_luma: 2,
406            }),
407            _ => None,
408        }
409    }
410
411    /// Physical GPU-surface dimensions `(pitch_width, total_h)` in texels for a
412    /// semi-planar combined plane bound as one `bpe`-byte-per-element texture,
413    /// or `None` for non-semi-planar formats.
414    ///
415    /// The width is rounded up to the 64-aligned row pitch (`== bytes_per_row`)
416    /// rather than left at the even logical width. ANGLE (and tiled GPUs in
417    /// general) will not address texels beyond a surface's declared width via
418    /// `texelFetch`, so a surface narrower than its padded `bytes_per_row`
419    /// leaves the padding columns unreachable. That is fatal for NV24 (4:4:4):
420    /// its chroma line is `2*W` interleaved bytes, which spills past the even
421    /// width into those padding columns whenever the row is padded
422    /// (`bytes_per_row > even_width`). Making the surface width equal the pitch
423    /// keeps every byte addressable and costs nothing — `bytes_per_row` is
424    /// already this value.
425    ///
426    /// Single source of truth for both IOSurface allocators (the tensor crate's
427    /// `IoSurfaceTensor::new_image` and the image crate's `ImageLayout`), so
428    /// they cannot diverge.
429    pub fn semi_planar_surface_dims(
430        &self,
431        width: usize,
432        height: usize,
433        bpe: usize,
434    ) -> Option<(usize, usize)> {
435        let total_h = self.combined_plane_height(height)?;
436        // allocation_shape carries the logical width; round its byte pitch up to 64
437        // (bpe == 1 for the R8 combined-plane binding, so pitch == aligned width).
438        let pitch_width = (width * bpe).next_multiple_of(64) / bpe;
439        Some((pitch_width, total_h))
440    }
441
442    /// Returns `true` if this format encodes YUV (luma/chroma) data.
443    pub const fn is_yuv(&self) -> bool {
444        matches!(
445            self,
446            Self::Yuyv | Self::Vyuy | Self::Nv12 | Self::Nv16 | Self::Nv24
447        )
448    }
449
450    /// Returns `true` if this is an RGB or HSV pixel format.
451    ///
452    /// This is the `is_rgb_or_hsv` input of the kernel's
453    /// `V4L2_MAP_QUANTIZATION_DEFAULT()` macro, which
454    /// [`Colorimetry::from_v4l2`](crate::Colorimetry::from_v4l2) takes to
455    /// resolve a `DEFAULT` quantization: these formats default to full range.
456    ///
457    /// `Rgb`, `Rgba`, `Bgra`, `PlanarRgb` and `PlanarRgba` return `true`.
458    /// The YUV formats and `Grey` return `false`; the kernel lists greyscale
459    /// as a YUV encoding. `PixelFormat` has no Bayer or HSV formats, which a
460    /// V4L2 classifier by fourcc also counts as RGB.
461    ///
462    /// ```
463    /// use edgefirst_tensor::PixelFormat;
464    ///
465    /// assert!(PixelFormat::Rgb.is_rgb_or_hsv());
466    /// assert!(!PixelFormat::Yuyv.is_rgb_or_hsv());
467    /// assert!(!PixelFormat::Grey.is_rgb_or_hsv());
468    /// ```
469    pub const fn is_rgb_or_hsv(&self) -> bool {
470        match self {
471            Self::Rgb | Self::Rgba | Self::Bgra | Self::PlanarRgb | Self::PlanarRgba => true,
472            Self::Grey | Self::Yuyv | Self::Vyuy | Self::Nv12 | Self::Nv16 | Self::Nv24 => false,
473        }
474    }
475
476    /// Returns `true` if this format includes an alpha channel.
477    pub const fn has_alpha(&self) -> bool {
478        matches!(self, Self::Rgba | Self::Bgra | Self::PlanarRgba)
479    }
480
481    /// Returns the V4L2 pixel format code (`V4L2_PIX_FMT_*`) for this
482    /// format, or `0` for formats that have no V4L2 code (e.g., `PlanarRgb`).
483    ///
484    /// The codes for the four-channel and YUV formats coincide with the DRM
485    /// FourCC for the same memory layout; `Rgb` (`RGB3`) and `Grey` (`GREY`)
486    /// are V4L2-only spellings. This is not the [`PixelFormat::as_str`] wire
487    /// name, nor the [`Display`](fmt::Display) text.
488    pub const fn to_fourcc(&self) -> u32 {
489        match self {
490            Self::Rgb => FOURCC_RGB,
491            Self::Rgba => FOURCC_RGBA,
492            Self::Bgra => FOURCC_BGRA,
493            Self::Grey => FOURCC_GREY,
494            Self::Yuyv => FOURCC_YUYV,
495            Self::Vyuy => FOURCC_VYUY,
496            Self::Nv12 => FOURCC_NV12,
497            Self::Nv16 => FOURCC_NV16,
498            Self::Nv24 => FOURCC_NV24,
499            Self::PlanarRgb | Self::PlanarRgba => 0,
500        }
501    }
502
503    /// Converts a V4L2 pixel format code (see [`Self::to_fourcc`]) to a
504    /// `PixelFormat`, returning `None` for unrecognized or zero codes.
505    pub const fn from_fourcc(fourcc: u32) -> Option<Self> {
506        match fourcc {
507            FOURCC_RGB => Some(Self::Rgb),
508            FOURCC_RGBA => Some(Self::Rgba),
509            FOURCC_BGRA => Some(Self::Bgra),
510            FOURCC_GREY => Some(Self::Grey),
511            FOURCC_YUYV => Some(Self::Yuyv),
512            FOURCC_VYUY => Some(Self::Vyuy),
513            FOURCC_NV12 => Some(Self::Nv12),
514            FOURCC_NV16 => Some(Self::Nv16),
515            FOURCC_NV24 => Some(Self::Nv24),
516            _ => None,
517        }
518    }
519}
520
521/// Human-readable name for messages and logs. Neither the wire name
522/// ([`PixelFormat::as_str`]) nor the V4L2 code ([`PixelFormat::to_fourcc`]).
523impl fmt::Display for PixelFormat {
524    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
525        let name = match self {
526            Self::Rgb => "RGB",
527            Self::Rgba => "RGBA",
528            Self::Bgra => "BGRA",
529            Self::Grey => "Y800",
530            Self::Yuyv => "YUYV",
531            Self::Vyuy => "VYUY",
532            Self::Nv12 => "NV12",
533            Self::Nv16 => "NV16",
534            Self::Nv24 => "NV24",
535            Self::PlanarRgb => "PlanarRgb",
536            Self::PlanarRgba => "PlanarRgba",
537        };
538        f.write_str(name)
539    }
540}
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545
546    #[test]
547    fn chroma_layout_matches_the_subsampling_of_every_format() {
548        for &fmt in PixelFormat::all() {
549            let expected = match fmt {
550                PixelFormat::Nv12 => Some(ChromaLayout {
551                    shift_x: 1,
552                    shift_y: 1,
553                    uv_rows_per_luma: 1,
554                }),
555                PixelFormat::Nv16 => Some(ChromaLayout {
556                    shift_x: 1,
557                    shift_y: 0,
558                    uv_rows_per_luma: 1,
559                }),
560                PixelFormat::Nv24 => Some(ChromaLayout {
561                    shift_x: 0,
562                    shift_y: 0,
563                    uv_rows_per_luma: 2,
564                }),
565                _ => None,
566            };
567            assert_eq!(fmt.chroma_layout(), expected, "{fmt:?}");
568        }
569    }
570
571    #[test]
572    fn semi_planar_surface_dims_rounds_the_byte_pitch_to_64() {
573        // Two bytes per element: 100 texels = 200 bytes, padded to 256 bytes
574        // = 128 texels.
575        assert_eq!(
576            PixelFormat::Nv12.semi_planar_surface_dims(100, 50, 2),
577            Some((128, 75))
578        );
579        assert_eq!(
580            PixelFormat::Nv12.semi_planar_surface_dims(100, 50, 1),
581            Some((128, 75))
582        );
583        assert_eq!(
584            PixelFormat::Nv16.semi_planar_surface_dims(100, 50, 1),
585            Some((128, 100))
586        );
587        assert_eq!(
588            PixelFormat::Nv24.semi_planar_surface_dims(321, 241, 1),
589            Some((384, 723))
590        );
591        // Four bytes per element: 40 texels = 160 bytes, padded to 192 bytes
592        // = 48 texels.
593        assert_eq!(
594            PixelFormat::Nv24.semi_planar_surface_dims(40, 8, 4),
595            Some((48, 24))
596        );
597        // An already-aligned pitch is left alone.
598        assert_eq!(
599            PixelFormat::Nv12.semi_planar_surface_dims(64, 64, 1),
600            Some((64, 96))
601        );
602    }
603
604    #[test]
605    fn semi_planar_surface_dims_is_none_for_non_semi_planar_formats() {
606        for &fmt in PixelFormat::all() {
607            if fmt.chroma_layout().is_none() {
608                assert_eq!(fmt.semi_planar_surface_dims(64, 64, 1), None, "{fmt:?}");
609            }
610        }
611    }
612
613    #[test]
614    fn addressing_shape_is_the_grid_not_the_allocation() {
615        // The spec's format table, and the merged schemas goldens
616        // (schemas 71b56ed, fixture "Tensor": NV12 640x480 -> shape [480, 640]).
617        assert_eq!(
618            PixelFormat::Nv12.addressing_shape(640, 480).unwrap(),
619            vec![480, 640]
620        );
621        assert_eq!(
622            PixelFormat::Rgb.addressing_shape(640, 480).unwrap(),
623            vec![480, 640, 3]
624        );
625        assert_eq!(
626            PixelFormat::PlanarRgb.addressing_shape(640, 480).unwrap(),
627            vec![3, 480, 640]
628        );
629        // schemas fixture "Tensor_inline" uses format "mono8" with shape [2, 4].
630        assert_eq!(
631            PixelFormat::Grey.addressing_shape(4, 2).unwrap(),
632            vec![2, 4]
633        );
634    }
635
636    #[test]
637    fn allocation_shape_still_sizes_the_buffer() {
638        // Unchanged from `image_shape`: NV12 keeps the combined-plane height,
639        // because four callers size real allocations with this.
640        assert_eq!(
641            PixelFormat::Nv12.allocation_shape(640, 480).unwrap(),
642            vec![720, 640]
643        );
644        // Odd height stays exact: 483 luma + 242 chroma rows.
645        assert_eq!(
646            PixelFormat::Nv12.allocation_shape(640, 483).unwrap(),
647            vec![725, 640]
648        );
649        // Multi-channel, non-subsampled formats: the two agree exactly.
650        for f in [PixelFormat::Rgb, PixelFormat::PlanarRgb, PixelFormat::Rgba] {
651            assert_eq!(
652                f.allocation_shape(64, 48),
653                f.addressing_shape(64, 48),
654                "{f:?}: allocation and grid coincide when nothing is subsampled"
655            );
656        }
657        // Single-channel packed is the second place they legitimately differ:
658        // the allocation keeps its trailing 1-channel dimension, the grid
659        // drops it. Same element count either way, so nothing is mis-sized.
660        assert_eq!(
661            PixelFormat::Grey.allocation_shape(64, 48).unwrap(),
662            vec![48, 64, 1]
663        );
664        assert_eq!(
665            PixelFormat::Grey.addressing_shape(64, 48).unwrap(),
666            vec![48, 64]
667        );
668        let a: usize = PixelFormat::Grey
669            .allocation_shape(64, 48)
670            .unwrap()
671            .iter()
672            .product();
673        let g: usize = PixelFormat::Grey
674            .addressing_shape(64, 48)
675            .unwrap()
676            .iter()
677            .product();
678        assert_eq!(
679            a, g,
680            "dropping a 1-sized dimension cannot change the element count"
681        );
682    }
683
684    #[test]
685    fn the_grid_is_not_the_buffer_size_for_subsampled_formats() {
686        // The consequence the spec says must be documented, not discovered.
687        let grid: usize = PixelFormat::Nv12
688            .addressing_shape(640, 480)
689            .unwrap()
690            .iter()
691            .product();
692        let alloc: usize = PixelFormat::Nv12
693            .allocation_shape(640, 480)
694            .unwrap()
695            .iter()
696            .product();
697        assert_eq!(grid, 640 * 480, "the grid addresses luma samples only");
698        assert_eq!(alloc, 640 * 480 * 3 / 2, "the buffer holds luma + chroma");
699        assert!(
700            grid < alloc,
701            "product(shape) * dtype_size is NOT the buffer size"
702        );
703    }
704
705    #[test]
706    fn nv12_plane_table_matches_the_schemas_golden() {
707        // (golden) schemas 71b56ed, scripts/generate_cdr_testdata.py fixture
708        // "Tensor": one allocation, Y at 0 and UV at w*h, both stride 640.
709        let planes = PixelFormat::Nv12.plane_table(640, 480, 640).unwrap();
710        assert_eq!(planes.len(), 2);
711        assert_eq!(planes[0].offset, 0);
712        assert_eq!(planes[0].stride, 640);
713        assert_eq!(planes[0].size, 640 * 480);
714        assert_eq!(planes[1].offset, 640 * 480);
715        assert_eq!(planes[1].stride, 640);
716        assert_eq!(planes[1].size, 640 * 480 / 2);
717    }
718
719    #[test]
720    fn plane_counts_follow_the_spec_table() {
721        assert_eq!(PixelFormat::Rgb.plane_table(64, 48, 192).unwrap().len(), 1);
722        assert_eq!(PixelFormat::Grey.plane_table(64, 48, 64).unwrap().len(), 1);
723        assert_eq!(
724            PixelFormat::PlanarRgb
725                .plane_table(64, 48, 64)
726                .unwrap()
727                .len(),
728            3
729        );
730        assert_eq!(PixelFormat::Nv12.plane_table(64, 48, 64).unwrap().len(), 2);
731    }
732
733    #[test]
734    fn plane_table_honours_a_padded_luma_stride() {
735        // The case with no representation before the plane table existed: a
736        // 64-byte-aligned luma pitch wider than the logical width pushes
737        // chroma's offset out. Implicit `w*h` chroma placement gets this wrong.
738        let planes = PixelFormat::Nv12.plane_table(640, 480, 704).unwrap();
739        assert_eq!(planes[0].stride, 704);
740        assert_eq!(
741            planes[1].offset,
742            704 * 480,
743            "chroma starts after the PADDED luma plane, not after w*h"
744        );
745        assert_eq!(planes[1].size, 704 * 240);
746    }
747
748    #[test]
749    fn plane_table_extent_agrees_with_allocation_shape() {
750        // The two must never drift: total plane bytes == the allocation the
751        // combined-plane height implies. Odd height included, since that is
752        // where a duplicated `h/2` would diverge from `h + ceil(h/2)`.
753        for (w, h) in [(640, 480), (789, 383), (64, 1)] {
754            let stride = w; // tight, so the comparison is exact
755            let planes = PixelFormat::Nv12.plane_table(w, h, stride).unwrap();
756            let total: u64 = planes.iter().map(|p| p.size).sum();
757            let rows = PixelFormat::Nv12.combined_plane_height(h).unwrap();
758            assert_eq!(
759                total,
760                (stride * rows) as u64,
761                "{w}x{h}: plane bytes must equal combined_plane_height * stride"
762            );
763        }
764    }
765
766    #[test]
767    fn plane_table_rejects_overflow_rather_than_wrapping() {
768        // Dimensions come from untrusted descriptors elsewhere in this crate;
769        // a wrapped size would look small enough to pass a capacity check.
770        assert!(PixelFormat::Nv12
771            .plane_table(usize::MAX, usize::MAX, usize::MAX)
772            .is_none());
773    }
774
775    #[test]
776    fn channels() {
777        assert_eq!(PixelFormat::Rgb.channels(), 3);
778        assert_eq!(PixelFormat::Rgba.channels(), 4);
779        assert_eq!(PixelFormat::Bgra.channels(), 4);
780        assert_eq!(PixelFormat::Grey.channels(), 1);
781        assert_eq!(PixelFormat::Yuyv.channels(), 2);
782        assert_eq!(PixelFormat::Vyuy.channels(), 2);
783        assert_eq!(PixelFormat::Nv12.channels(), 1);
784        assert_eq!(PixelFormat::Nv16.channels(), 1);
785        assert_eq!(PixelFormat::Nv24.channels(), 1);
786        assert_eq!(PixelFormat::PlanarRgb.channels(), 3);
787        assert_eq!(PixelFormat::PlanarRgba.channels(), 4);
788    }
789
790    #[test]
791    fn layout() {
792        assert_eq!(PixelFormat::Rgb.layout(), PixelLayout::Packed);
793        assert_eq!(PixelFormat::Rgba.layout(), PixelLayout::Packed);
794        assert_eq!(PixelFormat::Bgra.layout(), PixelLayout::Packed);
795        assert_eq!(PixelFormat::Grey.layout(), PixelLayout::Packed);
796        assert_eq!(PixelFormat::Yuyv.layout(), PixelLayout::Packed);
797        assert_eq!(PixelFormat::Vyuy.layout(), PixelLayout::Packed);
798        assert_eq!(PixelFormat::Nv12.layout(), PixelLayout::SemiPlanar);
799        assert_eq!(PixelFormat::Nv16.layout(), PixelLayout::SemiPlanar);
800        assert_eq!(PixelFormat::Nv24.layout(), PixelLayout::SemiPlanar);
801        assert_eq!(PixelFormat::PlanarRgb.layout(), PixelLayout::Planar);
802        assert_eq!(PixelFormat::PlanarRgba.layout(), PixelLayout::Planar);
803    }
804
805    #[test]
806    fn is_yuv() {
807        assert!(!PixelFormat::Rgb.is_yuv());
808        assert!(!PixelFormat::Rgba.is_yuv());
809        assert!(PixelFormat::Yuyv.is_yuv());
810        assert!(PixelFormat::Vyuy.is_yuv());
811        assert!(PixelFormat::Nv12.is_yuv());
812        assert!(PixelFormat::Nv16.is_yuv());
813        assert!(PixelFormat::Nv24.is_yuv());
814        assert!(!PixelFormat::PlanarRgb.is_yuv());
815    }
816
817    #[test]
818    fn is_rgb_or_hsv_is_every_format_but_yuv_and_grey() {
819        for &format in PixelFormat::all() {
820            assert_eq!(
821                format.is_rgb_or_hsv(),
822                !format.is_yuv() && format != PixelFormat::Grey,
823                "{format:?}"
824            );
825        }
826    }
827
828    #[test]
829    fn has_alpha() {
830        assert!(!PixelFormat::Rgb.has_alpha());
831        assert!(PixelFormat::Rgba.has_alpha());
832        assert!(PixelFormat::Bgra.has_alpha());
833        assert!(!PixelFormat::Grey.has_alpha());
834        assert!(!PixelFormat::Yuyv.has_alpha());
835        assert!(!PixelFormat::PlanarRgb.has_alpha());
836        assert!(PixelFormat::PlanarRgba.has_alpha());
837    }
838
839    #[test]
840    fn fourcc_roundtrip() {
841        for fmt in [
842            PixelFormat::Rgb,
843            PixelFormat::Rgba,
844            PixelFormat::Bgra,
845            PixelFormat::Grey,
846            PixelFormat::Yuyv,
847            PixelFormat::Vyuy,
848            PixelFormat::Nv12,
849            PixelFormat::Nv16,
850            PixelFormat::Nv24,
851        ] {
852            let fcc = fmt.to_fourcc();
853            assert_ne!(fcc, 0, "{fmt:?} should have a fourcc code");
854            assert_eq!(
855                PixelFormat::from_fourcc(fcc),
856                Some(fmt),
857                "roundtrip failed for {fmt:?}"
858            );
859        }
860    }
861
862    #[test]
863    fn combined_plane_height_inverts_for_every_semi_planar_height() {
864        for fmt in [PixelFormat::Nv12, PixelFormat::Nv16, PixelFormat::Nv24] {
865            for h in 1..=64usize {
866                let combined = fmt.combined_plane_height(h).unwrap();
867                assert_eq!(
868                    fmt.image_height_from_combined(combined),
869                    Some(h),
870                    "{fmt:?} height {h} -> combined {combined}"
871                );
872            }
873        }
874    }
875
876    #[test]
877    fn image_height_from_combined_rejects_unproduced_heights() {
878        // NV12 maps 480 -> 720 and 481 -> 722; nothing maps to 721.
879        assert_eq!(PixelFormat::Nv12.image_height_from_combined(721), None);
880        // NV16 doubles, so every odd combined height is unreachable.
881        assert_eq!(PixelFormat::Nv16.image_height_from_combined(7), None);
882        // NV24 triples.
883        assert_eq!(PixelFormat::Nv24.image_height_from_combined(8), None);
884    }
885
886    #[test]
887    fn image_height_from_combined_is_none_for_non_semi_planar() {
888        for fmt in [
889            PixelFormat::Rgba,
890            PixelFormat::Rgb,
891            PixelFormat::Grey,
892            PixelFormat::Yuyv,
893            PixelFormat::PlanarRgb,
894        ] {
895            assert_eq!(fmt.image_height_from_combined(12), None, "{fmt:?}");
896        }
897    }
898
899    #[cfg(target_os = "linux")]
900    #[test]
901    fn to_fourcc_matches_edgefirst_v4l2() {
902        use edgefirst_v4l2::uapi;
903        let expect = [
904            (PixelFormat::Rgb, uapi::V4L2_PIX_FMT_RGB24),
905            // `edgefirst-v4l2` 0.2.1 does not declare `V4L2_PIX_FMT_RGBA32`;
906            // built with its own `fourcc` packer from the kernel's spelling.
907            (PixelFormat::Rgba, uapi::fourcc(b'A', b'B', b'2', b'4')),
908            (PixelFormat::Bgra, uapi::V4L2_PIX_FMT_ABGR32),
909            (PixelFormat::Grey, uapi::V4L2_PIX_FMT_GREY),
910            (PixelFormat::Yuyv, uapi::V4L2_PIX_FMT_YUYV),
911            (PixelFormat::Vyuy, uapi::V4L2_PIX_FMT_VYUY),
912            (PixelFormat::Nv12, uapi::V4L2_PIX_FMT_NV12),
913            (PixelFormat::Nv16, uapi::V4L2_PIX_FMT_NV16),
914            (PixelFormat::Nv24, uapi::V4L2_PIX_FMT_NV24),
915        ];
916        for (fmt, code) in expect {
917            assert_eq!(
918                fmt.to_fourcc(),
919                code,
920                "{fmt:?}: {} vs {}",
921                uapi::fourcc_str(fmt.to_fourcc()),
922                uapi::fourcc_str(code)
923            );
924        }
925    }
926
927    #[test]
928    fn fourcc_planar_returns_zero() {
929        assert_eq!(PixelFormat::PlanarRgb.to_fourcc(), 0);
930        assert_eq!(PixelFormat::PlanarRgba.to_fourcc(), 0);
931    }
932
933    #[test]
934    fn from_fourcc_unknown() {
935        assert_eq!(PixelFormat::from_fourcc(0), None);
936        assert_eq!(PixelFormat::from_fourcc(0xDEADBEEF), None);
937    }
938
939    #[test]
940    fn display_fourcc_formats() {
941        assert_eq!(format!("{}", PixelFormat::Rgba), "RGBA");
942        assert_eq!(format!("{}", PixelFormat::Nv12), "NV12");
943        assert_eq!(format!("{}", PixelFormat::Yuyv), "YUYV");
944        assert_eq!(format!("{}", PixelFormat::Rgb), "RGB");
945        assert_eq!(format!("{}", PixelFormat::Bgra), "BGRA");
946        assert_eq!(format!("{}", PixelFormat::Grey), "Y800");
947    }
948
949    #[test]
950    fn display_planar_formats() {
951        assert_eq!(format!("{}", PixelFormat::PlanarRgb), "PlanarRgb");
952        assert_eq!(format!("{}", PixelFormat::PlanarRgba), "PlanarRgba");
953    }
954
955    #[test]
956    fn repr_starts_at_one() {
957        assert_eq!(PixelFormat::Rgb as u8, 1);
958    }
959
960    #[test]
961    fn serde_roundtrip() {
962        let fmt = PixelFormat::Nv12;
963        let json = serde_json::to_string(&fmt).unwrap();
964        let back: PixelFormat = serde_json::from_str(&json).unwrap();
965        assert_eq!(fmt, back);
966    }
967}