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/// FourCC code constants (V4L2/DRM compatible).
150const FOURCC_RGB: u32 = u32::from_le_bytes(*b"RGB ");
151const FOURCC_RGBA: u32 = u32::from_le_bytes(*b"RGBA");
152const FOURCC_BGRA: u32 = u32::from_le_bytes(*b"BGRA");
153const FOURCC_GREY: u32 = u32::from_le_bytes(*b"Y800");
154const FOURCC_YUYV: u32 = u32::from_le_bytes(*b"YUYV");
155const FOURCC_VYUY: u32 = u32::from_le_bytes(*b"VYUY");
156const FOURCC_NV12: u32 = u32::from_le_bytes(*b"NV12");
157const FOURCC_NV16: u32 = u32::from_le_bytes(*b"NV16");
158const FOURCC_NV24: u32 = u32::from_le_bytes(*b"NV24");
159
160impl PixelFormat {
161 /// Returns the number of channels for this pixel format.
162 ///
163 /// For semi-planar formats (NV12, NV16), this returns 1 (the luma channel
164 /// count for the primary plane). For packed formats, this is the total
165 /// number of interleaved components per pixel.
166 pub const fn channels(&self) -> usize {
167 match self {
168 Self::Rgb | Self::PlanarRgb => 3,
169 Self::Rgba | Self::Bgra | Self::PlanarRgba => 4,
170 Self::Grey | Self::Nv12 | Self::Nv16 | Self::Nv24 => 1,
171 Self::Yuyv | Self::Vyuy => 2,
172 }
173 }
174
175 /// Returns the memory layout category for this pixel format.
176 pub const fn layout(&self) -> PixelLayout {
177 match self {
178 Self::Rgb | Self::Rgba | Self::Bgra | Self::Grey | Self::Yuyv | Self::Vyuy => {
179 PixelLayout::Packed
180 }
181 Self::PlanarRgb | Self::PlanarRgba => PixelLayout::Planar,
182 Self::Nv12 | Self::Nv16 | Self::Nv24 => PixelLayout::SemiPlanar,
183 }
184 }
185
186 /// The **allocation geometry** for this format at `width`×`height`, or
187 /// `None` if the dimensions are invalid for the format, or the format is
188 /// an unsupported semi-planar variant (any `SemiPlanar` variant other than
189 /// `Nv12`, `Nv16`, and `Nv24`).
190 ///
191 /// This is the shape that *sizes a buffer*. For a subsampled format it is
192 /// deliberately not the shape a consumer indexes with — NV12 640×480
193 /// allocates `[720, 640]` here but addresses a `[480, 640]` luma grid.
194 /// Use [`addressing_shape`](Self::addressing_shape) for the latter, which
195 /// is what descriptors, blobs and `Tensor.msg` carry.
196 ///
197 /// Renamed from `image_shape` when the two meanings were separated: every
198 /// caller of the old name sized an allocation with it (`Tensor::image`,
199 /// `import_image`, and the two PBO helpers), so a single name called "the
200 /// shape" made picking the wrong one the default. There is now no such
201 /// name.
202 ///
203 /// Odd dimensions are fully supported. The combined-plane height for NV12
204 /// is `height + ceil(height / 2)` (luma rows + chroma rows), which equals
205 /// the classic `height * 3 / 2` for even heights and stays exact for odd
206 /// ones — e.g. 483 → 725 rows (483 luma + 242 chroma).
207 ///
208 /// For semi-planar formats the shape carries the **logical** width as-is
209 /// (odd widths are preserved, e.g. `[720, 789]` for a 789×384 NV12).
210 /// The row stride recorded separately on the tensor is `>= even(width)` and
211 /// 64-byte aligned; it may exceed the logical width. Use
212 /// `effective_row_stride()` to determine the true byte pitch for
213 /// mapping and allocation. Allocation byte size = `total_h * row_stride`,
214 /// NOT the shape product.
215 pub fn allocation_shape(&self, width: usize, height: usize) -> Option<Vec<usize>> {
216 match self.layout() {
217 PixelLayout::Packed => Some(vec![height, width, self.channels()]),
218 PixelLayout::Planar => Some(vec![self.channels(), height, width]),
219 PixelLayout::SemiPlanar => {
220 // Shape carries logical width; row_stride (>= even(width), 64-aligned)
221 // is stored separately on the Tensor and governs byte layout.
222 Some(vec![self.combined_plane_height(height)?, width])
223 }
224 }
225 }
226
227 /// The **addressing grid**: how a consumer logically indexes this image.
228 ///
229 /// This is the shape that belongs in a descriptor, a blob, or a
230 /// `Tensor.msg` — never the allocation size. For a subsampled format
231 /// `product(addressing_shape) * dtype_size` is *smaller* than the buffer:
232 /// NV12 640×480 addresses a 640×480 luma grid inside a 460 800-byte
233 /// allocation. Buffer extent comes from the plane table, never from here.
234 ///
235 /// Contrast [`allocation_shape`](Self::allocation_shape), which is the
236 /// buffer geometry every allocating caller must keep using.
237 ///
238 /// The per-format convention is pinned here so producers cannot each
239 /// invent one; it matches the merged `schemas` golden fixtures.
240 pub fn addressing_shape(&self, width: usize, height: usize) -> Option<Vec<usize>> {
241 match self.layout() {
242 // A channel dimension only when there is more than one channel to
243 // address: `mono8` is `[h, w]`, not `[h, w, 1]`. That keeps it
244 // consistent with the semi-planar grid below (also one sample per
245 // pixel position), matches how every array library presents
246 // grayscale, and matches the merged schemas `mono8` golden. The
247 // spec's format table does not cover single-channel packed
248 // formats; this is the rule chosen for them.
249 PixelLayout::Packed if self.channels() == 1 => Some(vec![height, width]),
250 PixelLayout::Packed => Some(vec![height, width, self.channels()]),
251 PixelLayout::Planar => Some(vec![self.channels(), height, width]),
252 // The luma/sample grid. Chroma is subsampled and is not separately
253 // addressable through `shape`; its geometry lives in the plane
254 // table. Validated for the same dimension support as
255 // `allocation_shape` so the two agree on what they accept.
256 PixelLayout::SemiPlanar => {
257 self.combined_plane_height(height)?;
258 Some(vec![height, width])
259 }
260 }
261 }
262
263 /// Where each plane's bytes live, given the allocation's row pitch.
264 ///
265 /// `row_stride` is the byte pitch of a **luma/packed** row — the value
266 /// [`Tensor::effective_row_stride`](crate::Tensor::effective_row_stride)
267 /// reports, which on a DMA backing is 64-byte aligned and may exceed
268 /// `width * bytes_per_pixel`. Planes are laid out contiguously in the
269 /// order a consumer expects them (Y then UV; R then G then B).
270 ///
271 /// Returns `None` for an unsupported semi-planar variant, or if the
272 /// geometry overflows `u64` — dimensions reaching this from an imported
273 /// descriptor are untrusted, and a wrapped size would look small enough
274 /// to pass a capacity check it should fail.
275 ///
276 /// Chroma extent is derived from
277 /// [`combined_plane_height`](Self::combined_plane_height) rather than
278 /// recomputed, so the two cannot drift on odd heights.
279 pub fn plane_table(
280 &self,
281 width: usize,
282 height: usize,
283 row_stride: usize,
284 ) -> Option<Vec<PlaneGeometry>> {
285 let _ = width; // extent is governed by row_stride, not logical width
286 let stride = u64::try_from(row_stride).ok()?;
287 let rows = u64::try_from(height).ok()?;
288 let plane_bytes = stride.checked_mul(rows)?;
289
290 match self.layout() {
291 PixelLayout::Packed => Some(vec![PlaneGeometry {
292 offset: 0,
293 stride,
294 size: plane_bytes,
295 }]),
296 PixelLayout::Planar => {
297 let n = u64::try_from(self.channels()).ok()?;
298 (0..n)
299 .map(|i| {
300 Some(PlaneGeometry {
301 offset: i.checked_mul(plane_bytes)?,
302 stride,
303 size: plane_bytes,
304 })
305 })
306 .collect()
307 }
308 PixelLayout::SemiPlanar => {
309 // Derived, never recomputed: `H + ceil(H/2)` for NV12 is exact
310 // for odd heights where a hand-written `H/2` is not.
311 let total_rows = u64::try_from(self.combined_plane_height(height)?).ok()?;
312 let chroma_rows = total_rows.checked_sub(rows)?;
313 Some(vec![
314 PlaneGeometry {
315 offset: 0,
316 stride,
317 size: plane_bytes,
318 },
319 PlaneGeometry {
320 offset: plane_bytes,
321 stride,
322 size: stride.checked_mul(chroma_rows)?,
323 },
324 ])
325 }
326 }
327 }
328
329 /// Combined-plane height in physical (stride-wide) rows for a semi-planar
330 /// format: the Y rows plus the interleaved-UV rows.
331 ///
332 /// * NV12 (4:2:0): `H + ceil(H/2)` — exact for odd heights (e.g. 483 →
333 /// 725 = 483 luma + 242 chroma), equals the classic `H*3/2` for even.
334 /// * NV16 (4:2:2): `2H` (one full-height chroma row per luma row).
335 /// * NV24 (4:4:4): `3H` (a full-width `2W`-byte chroma line spans two
336 /// stride-wide buffer rows, so `2H` chroma rows).
337 ///
338 /// Returns `None` for non-semi-planar formats (and unsupported SemiPlanar
339 /// variants). This is the single source of truth for the vertical extent of
340 /// the contiguous NV* buffer — [`allocation_shape`](Self::allocation_shape), the GL
341 /// DMA-BUF/IOSurface imports, the PBO allocator, and the gpu-probe all
342 /// derive from it, so the combined-plane height can never drift between them.
343 pub const fn combined_plane_height(&self, height: usize) -> Option<usize> {
344 match self {
345 PixelFormat::Nv12 => Some(height + height.div_ceil(2)),
346 PixelFormat::Nv16 => Some(height * 2),
347 PixelFormat::Nv24 => Some(height * 3),
348 _ => None,
349 }
350 }
351
352 /// Per-format semi-planar chroma addressing parameters, shared by the codec
353 /// writer ([`uv_rows_per_luma`](ChromaLayout::uv_rows_per_luma)), the CPU
354 /// readers, and both GL shaders so the combined-plane chroma geometry has a
355 /// single source of truth. Returns `None` for non-semi-planar formats.
356 pub const fn chroma_layout(&self) -> Option<ChromaLayout> {
357 match self {
358 // 4:2:0 — half horizontal & vertical chroma resolution.
359 PixelFormat::Nv12 => Some(ChromaLayout {
360 shift_x: 1,
361 shift_y: 1,
362 uv_rows_per_luma: 1,
363 }),
364 // 4:2:2 — half horizontal, full vertical.
365 PixelFormat::Nv16 => Some(ChromaLayout {
366 shift_x: 1,
367 shift_y: 0,
368 uv_rows_per_luma: 1,
369 }),
370 // 4:4:4 — full resolution; the 2W-byte chroma line spans two rows.
371 PixelFormat::Nv24 => Some(ChromaLayout {
372 shift_x: 0,
373 shift_y: 0,
374 uv_rows_per_luma: 2,
375 }),
376 _ => None,
377 }
378 }
379
380 /// Physical GPU-surface dimensions `(pitch_width, total_h)` in texels for a
381 /// semi-planar combined plane bound as one `bpe`-byte-per-element texture,
382 /// or `None` for non-semi-planar formats.
383 ///
384 /// The width is rounded up to the 64-aligned row pitch (`== bytes_per_row`)
385 /// rather than left at the even logical width. ANGLE (and tiled GPUs in
386 /// general) will not address texels beyond a surface's declared width via
387 /// `texelFetch`, so a surface narrower than its padded `bytes_per_row`
388 /// leaves the padding columns unreachable. That is fatal for NV24 (4:4:4):
389 /// its chroma line is `2*W` interleaved bytes, which spills past the even
390 /// width into those padding columns whenever the row is padded
391 /// (`bytes_per_row > even_width`). Making the surface width equal the pitch
392 /// keeps every byte addressable and costs nothing — `bytes_per_row` is
393 /// already this value.
394 ///
395 /// Single source of truth for both IOSurface allocators (the tensor crate's
396 /// `IoSurfaceTensor::new_image` and the image crate's `ImageLayout`), so
397 /// they cannot diverge.
398 pub fn semi_planar_surface_dims(
399 &self,
400 width: usize,
401 height: usize,
402 bpe: usize,
403 ) -> Option<(usize, usize)> {
404 let total_h = self.combined_plane_height(height)?;
405 // allocation_shape carries the logical width; round its byte pitch up to 64
406 // (bpe == 1 for the R8 combined-plane binding, so pitch == aligned width).
407 let pitch_width = (width * bpe).next_multiple_of(64) / bpe;
408 Some((pitch_width, total_h))
409 }
410
411 /// Returns `true` if this format encodes YUV (luma/chroma) data.
412 pub const fn is_yuv(&self) -> bool {
413 matches!(
414 self,
415 Self::Yuyv | Self::Vyuy | Self::Nv12 | Self::Nv16 | Self::Nv24
416 )
417 }
418
419 /// Returns `true` if this format includes an alpha channel.
420 pub const fn has_alpha(&self) -> bool {
421 matches!(self, Self::Rgba | Self::Bgra | Self::PlanarRgba)
422 }
423
424 /// Returns the V4L2/DRM FourCC code for this format, or `0` for formats
425 /// that have no standard FourCC representation (e.g., `PlanarRgb`).
426 pub const fn to_fourcc(&self) -> u32 {
427 match self {
428 Self::Rgb => FOURCC_RGB,
429 Self::Rgba => FOURCC_RGBA,
430 Self::Bgra => FOURCC_BGRA,
431 Self::Grey => FOURCC_GREY,
432 Self::Yuyv => FOURCC_YUYV,
433 Self::Vyuy => FOURCC_VYUY,
434 Self::Nv12 => FOURCC_NV12,
435 Self::Nv16 => FOURCC_NV16,
436 Self::Nv24 => FOURCC_NV24,
437 Self::PlanarRgb | Self::PlanarRgba => 0,
438 }
439 }
440
441 /// Converts a V4L2/DRM FourCC code to a `PixelFormat`, returning `None`
442 /// for unrecognized or zero codes.
443 pub const fn from_fourcc(fourcc: u32) -> Option<Self> {
444 match fourcc {
445 FOURCC_RGB => Some(Self::Rgb),
446 FOURCC_RGBA => Some(Self::Rgba),
447 FOURCC_BGRA => Some(Self::Bgra),
448 FOURCC_GREY => Some(Self::Grey),
449 FOURCC_YUYV => Some(Self::Yuyv),
450 FOURCC_VYUY => Some(Self::Vyuy),
451 FOURCC_NV12 => Some(Self::Nv12),
452 FOURCC_NV16 => Some(Self::Nv16),
453 FOURCC_NV24 => Some(Self::Nv24),
454 _ => None,
455 }
456 }
457}
458
459impl fmt::Display for PixelFormat {
460 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
461 let fcc = self.to_fourcc();
462 if fcc != 0 {
463 let bytes = fcc.to_le_bytes();
464 for &b in &bytes {
465 if b == b' ' {
466 break;
467 }
468 write!(f, "{}", b as char)?;
469 }
470 Ok(())
471 } else {
472 write!(f, "{self:?}")
473 }
474 }
475}
476
477#[cfg(test)]
478mod tests {
479 use super::*;
480
481 #[test]
482 fn addressing_shape_is_the_grid_not_the_allocation() {
483 // The spec's format table, and the merged schemas goldens
484 // (schemas 71b56ed, fixture "Tensor": NV12 640x480 -> shape [480, 640]).
485 assert_eq!(
486 PixelFormat::Nv12.addressing_shape(640, 480).unwrap(),
487 vec![480, 640]
488 );
489 assert_eq!(
490 PixelFormat::Rgb.addressing_shape(640, 480).unwrap(),
491 vec![480, 640, 3]
492 );
493 assert_eq!(
494 PixelFormat::PlanarRgb.addressing_shape(640, 480).unwrap(),
495 vec![3, 480, 640]
496 );
497 // schemas fixture "Tensor_inline" uses format "mono8" with shape [2, 4].
498 assert_eq!(
499 PixelFormat::Grey.addressing_shape(4, 2).unwrap(),
500 vec![2, 4]
501 );
502 }
503
504 #[test]
505 fn allocation_shape_still_sizes_the_buffer() {
506 // Unchanged from `image_shape`: NV12 keeps the combined-plane height,
507 // because four callers size real allocations with this.
508 assert_eq!(
509 PixelFormat::Nv12.allocation_shape(640, 480).unwrap(),
510 vec![720, 640]
511 );
512 // Odd height stays exact: 483 luma + 242 chroma rows.
513 assert_eq!(
514 PixelFormat::Nv12.allocation_shape(640, 483).unwrap(),
515 vec![725, 640]
516 );
517 // Multi-channel, non-subsampled formats: the two agree exactly.
518 for f in [PixelFormat::Rgb, PixelFormat::PlanarRgb, PixelFormat::Rgba] {
519 assert_eq!(
520 f.allocation_shape(64, 48),
521 f.addressing_shape(64, 48),
522 "{f:?}: allocation and grid coincide when nothing is subsampled"
523 );
524 }
525 // Single-channel packed is the second place they legitimately differ:
526 // the allocation keeps its trailing 1-channel dimension, the grid
527 // drops it. Same element count either way, so nothing is mis-sized.
528 assert_eq!(
529 PixelFormat::Grey.allocation_shape(64, 48).unwrap(),
530 vec![48, 64, 1]
531 );
532 assert_eq!(
533 PixelFormat::Grey.addressing_shape(64, 48).unwrap(),
534 vec![48, 64]
535 );
536 let a: usize = PixelFormat::Grey
537 .allocation_shape(64, 48)
538 .unwrap()
539 .iter()
540 .product();
541 let g: usize = PixelFormat::Grey
542 .addressing_shape(64, 48)
543 .unwrap()
544 .iter()
545 .product();
546 assert_eq!(
547 a, g,
548 "dropping a 1-sized dimension cannot change the element count"
549 );
550 }
551
552 #[test]
553 fn the_grid_is_not_the_buffer_size_for_subsampled_formats() {
554 // The consequence the spec says must be documented, not discovered.
555 let grid: usize = PixelFormat::Nv12
556 .addressing_shape(640, 480)
557 .unwrap()
558 .iter()
559 .product();
560 let alloc: usize = PixelFormat::Nv12
561 .allocation_shape(640, 480)
562 .unwrap()
563 .iter()
564 .product();
565 assert_eq!(grid, 640 * 480, "the grid addresses luma samples only");
566 assert_eq!(alloc, 640 * 480 * 3 / 2, "the buffer holds luma + chroma");
567 assert!(
568 grid < alloc,
569 "product(shape) * dtype_size is NOT the buffer size"
570 );
571 }
572
573 #[test]
574 fn nv12_plane_table_matches_the_schemas_golden() {
575 // (golden) schemas 71b56ed, scripts/generate_cdr_testdata.py fixture
576 // "Tensor": one allocation, Y at 0 and UV at w*h, both stride 640.
577 let planes = PixelFormat::Nv12.plane_table(640, 480, 640).unwrap();
578 assert_eq!(planes.len(), 2);
579 assert_eq!(planes[0].offset, 0);
580 assert_eq!(planes[0].stride, 640);
581 assert_eq!(planes[0].size, 640 * 480);
582 assert_eq!(planes[1].offset, 640 * 480);
583 assert_eq!(planes[1].stride, 640);
584 assert_eq!(planes[1].size, 640 * 480 / 2);
585 }
586
587 #[test]
588 fn plane_counts_follow_the_spec_table() {
589 assert_eq!(PixelFormat::Rgb.plane_table(64, 48, 192).unwrap().len(), 1);
590 assert_eq!(PixelFormat::Grey.plane_table(64, 48, 64).unwrap().len(), 1);
591 assert_eq!(
592 PixelFormat::PlanarRgb
593 .plane_table(64, 48, 64)
594 .unwrap()
595 .len(),
596 3
597 );
598 assert_eq!(PixelFormat::Nv12.plane_table(64, 48, 64).unwrap().len(), 2);
599 }
600
601 #[test]
602 fn plane_table_honours_a_padded_luma_stride() {
603 // The case with no representation before the plane table existed: a
604 // 64-byte-aligned luma pitch wider than the logical width pushes
605 // chroma's offset out. Implicit `w*h` chroma placement gets this wrong.
606 let planes = PixelFormat::Nv12.plane_table(640, 480, 704).unwrap();
607 assert_eq!(planes[0].stride, 704);
608 assert_eq!(
609 planes[1].offset,
610 704 * 480,
611 "chroma starts after the PADDED luma plane, not after w*h"
612 );
613 assert_eq!(planes[1].size, 704 * 240);
614 }
615
616 #[test]
617 fn plane_table_extent_agrees_with_allocation_shape() {
618 // The two must never drift: total plane bytes == the allocation the
619 // combined-plane height implies. Odd height included, since that is
620 // where a duplicated `h/2` would diverge from `h + ceil(h/2)`.
621 for (w, h) in [(640, 480), (789, 383), (64, 1)] {
622 let stride = w; // tight, so the comparison is exact
623 let planes = PixelFormat::Nv12.plane_table(w, h, stride).unwrap();
624 let total: u64 = planes.iter().map(|p| p.size).sum();
625 let rows = PixelFormat::Nv12.combined_plane_height(h).unwrap();
626 assert_eq!(
627 total,
628 (stride * rows) as u64,
629 "{w}x{h}: plane bytes must equal combined_plane_height * stride"
630 );
631 }
632 }
633
634 #[test]
635 fn plane_table_rejects_overflow_rather_than_wrapping() {
636 // Dimensions come from untrusted descriptors elsewhere in this crate;
637 // a wrapped size would look small enough to pass a capacity check.
638 assert!(PixelFormat::Nv12
639 .plane_table(usize::MAX, usize::MAX, usize::MAX)
640 .is_none());
641 }
642
643 #[test]
644 fn channels() {
645 assert_eq!(PixelFormat::Rgb.channels(), 3);
646 assert_eq!(PixelFormat::Rgba.channels(), 4);
647 assert_eq!(PixelFormat::Bgra.channels(), 4);
648 assert_eq!(PixelFormat::Grey.channels(), 1);
649 assert_eq!(PixelFormat::Yuyv.channels(), 2);
650 assert_eq!(PixelFormat::Vyuy.channels(), 2);
651 assert_eq!(PixelFormat::Nv12.channels(), 1);
652 assert_eq!(PixelFormat::Nv16.channels(), 1);
653 assert_eq!(PixelFormat::Nv24.channels(), 1);
654 assert_eq!(PixelFormat::PlanarRgb.channels(), 3);
655 assert_eq!(PixelFormat::PlanarRgba.channels(), 4);
656 }
657
658 #[test]
659 fn layout() {
660 assert_eq!(PixelFormat::Rgb.layout(), PixelLayout::Packed);
661 assert_eq!(PixelFormat::Rgba.layout(), PixelLayout::Packed);
662 assert_eq!(PixelFormat::Bgra.layout(), PixelLayout::Packed);
663 assert_eq!(PixelFormat::Grey.layout(), PixelLayout::Packed);
664 assert_eq!(PixelFormat::Yuyv.layout(), PixelLayout::Packed);
665 assert_eq!(PixelFormat::Vyuy.layout(), PixelLayout::Packed);
666 assert_eq!(PixelFormat::Nv12.layout(), PixelLayout::SemiPlanar);
667 assert_eq!(PixelFormat::Nv16.layout(), PixelLayout::SemiPlanar);
668 assert_eq!(PixelFormat::Nv24.layout(), PixelLayout::SemiPlanar);
669 assert_eq!(PixelFormat::PlanarRgb.layout(), PixelLayout::Planar);
670 assert_eq!(PixelFormat::PlanarRgba.layout(), PixelLayout::Planar);
671 }
672
673 #[test]
674 fn is_yuv() {
675 assert!(!PixelFormat::Rgb.is_yuv());
676 assert!(!PixelFormat::Rgba.is_yuv());
677 assert!(PixelFormat::Yuyv.is_yuv());
678 assert!(PixelFormat::Vyuy.is_yuv());
679 assert!(PixelFormat::Nv12.is_yuv());
680 assert!(PixelFormat::Nv16.is_yuv());
681 assert!(PixelFormat::Nv24.is_yuv());
682 assert!(!PixelFormat::PlanarRgb.is_yuv());
683 }
684
685 #[test]
686 fn has_alpha() {
687 assert!(!PixelFormat::Rgb.has_alpha());
688 assert!(PixelFormat::Rgba.has_alpha());
689 assert!(PixelFormat::Bgra.has_alpha());
690 assert!(!PixelFormat::Grey.has_alpha());
691 assert!(!PixelFormat::Yuyv.has_alpha());
692 assert!(!PixelFormat::PlanarRgb.has_alpha());
693 assert!(PixelFormat::PlanarRgba.has_alpha());
694 }
695
696 #[test]
697 fn fourcc_roundtrip() {
698 for fmt in [
699 PixelFormat::Rgb,
700 PixelFormat::Rgba,
701 PixelFormat::Bgra,
702 PixelFormat::Grey,
703 PixelFormat::Yuyv,
704 PixelFormat::Vyuy,
705 PixelFormat::Nv12,
706 PixelFormat::Nv16,
707 PixelFormat::Nv24,
708 ] {
709 let fcc = fmt.to_fourcc();
710 assert_ne!(fcc, 0, "{fmt:?} should have a fourcc code");
711 assert_eq!(
712 PixelFormat::from_fourcc(fcc),
713 Some(fmt),
714 "roundtrip failed for {fmt:?}"
715 );
716 }
717 }
718
719 #[test]
720 fn fourcc_planar_returns_zero() {
721 assert_eq!(PixelFormat::PlanarRgb.to_fourcc(), 0);
722 assert_eq!(PixelFormat::PlanarRgba.to_fourcc(), 0);
723 }
724
725 #[test]
726 fn from_fourcc_unknown() {
727 assert_eq!(PixelFormat::from_fourcc(0), None);
728 assert_eq!(PixelFormat::from_fourcc(0xDEADBEEF), None);
729 }
730
731 #[test]
732 fn display_fourcc_formats() {
733 assert_eq!(format!("{}", PixelFormat::Rgba), "RGBA");
734 assert_eq!(format!("{}", PixelFormat::Nv12), "NV12");
735 assert_eq!(format!("{}", PixelFormat::Yuyv), "YUYV");
736 // Grey uses V4L2 FourCC "Y800", not "GREY"
737 assert_eq!(format!("{}", PixelFormat::Grey), "Y800");
738 }
739
740 #[test]
741 fn display_planar_formats() {
742 assert_eq!(format!("{}", PixelFormat::PlanarRgb), "PlanarRgb");
743 assert_eq!(format!("{}", PixelFormat::PlanarRgba), "PlanarRgba");
744 }
745
746 #[test]
747 fn repr_starts_at_one() {
748 assert_eq!(PixelFormat::Rgb as u8, 1);
749 }
750
751 #[test]
752 fn serde_roundtrip() {
753 let fmt = PixelFormat::Nv12;
754 let json = serde_json::to_string(&fmt).unwrap();
755 let back: PixelFormat = serde_json::from_str(&json).unwrap();
756 assert_eq!(fmt, back);
757 }
758}