Skip to main content

edgefirst_image/
g2d.rs

1// SPDX-FileCopyrightText: Copyright 2025 Au-Zone Technologies
2// SPDX-License-Identifier: Apache-2.0
3
4#![cfg(target_os = "linux")]
5
6use crate::colorimetry::effective_colorimetry;
7use crate::{CPUProcessor, Crop, Error, Flip, ImageProcessorTrait, ResolvedCrop, Result, Rotation};
8use edgefirst_tensor::{
9    ColorEncoding, ColorRange, Colorimetry, DType, PixelFormat, Tensor, TensorDyn, TensorMapTrait,
10    TensorTrait,
11};
12use four_char_code::FourCharCode;
13use g2d_sys::{G2DFormat, G2DPhysical, G2DSurface, G2D};
14use std::{os::fd::AsRawFd, time::Instant};
15
16/// Pure colorimetry-eligibility predicate for the G2D backend.
17///
18/// G2D is **matrix-only**: the g2d-sys API exposes `set_bt601_colorspace()` and
19/// `set_bt709_colorspace()` (the YCbCr matrix selection) but has **no control
20/// over quantization range** — the hardware is effectively limited-range only —
21/// and exposes **no BT.2020 matrix**. Therefore:
22///
23/// - A YUV conversion whose resolved range is `Full` must be DECLINED so the
24///   `ImageProcessor` dispatch falls through to the OpenGL / CPU backends,
25///   which honour full vs limited range correctly.
26/// - Any `Bt2020`-encoded conversion must be DECLINED (G2D cannot express it),
27///   again falling through to GL / CPU.
28///
29/// `src_is_yuv` is `true` when the conversion has a YUV side whose colorimetry
30/// matters (YUV→RGB uses the source colorimetry, RGB→YUV uses the destination).
31/// For RGB→RGB (no YUV side) the full-range rule is N/A and only the BT.2020
32/// matrix restriction applies.
33pub(crate) fn g2d_can_handle(cm: &Colorimetry, src_is_yuv: bool) -> bool {
34    // G2D matrix-only (no range control) and no BT.2020: decline full-range YUV
35    // and BT.2020.
36    !(src_is_yuv && cm.range == Some(ColorRange::Full))
37        && cm.encoding != Some(ColorEncoding::Bt2020)
38}
39
40/// Convert a PixelFormat to the G2D-compatible FourCharCode.
41fn pixelfmt_to_fourcc(fmt: PixelFormat) -> FourCharCode {
42    use four_char_code::four_char_code;
43    match fmt {
44        PixelFormat::Rgb => four_char_code!("RGB "),
45        PixelFormat::Rgba => four_char_code!("RGBA"),
46        PixelFormat::Bgra => four_char_code!("BGRA"),
47        PixelFormat::Grey => four_char_code!("Y800"),
48        PixelFormat::Yuyv => four_char_code!("YUYV"),
49        PixelFormat::Vyuy => four_char_code!("VYUY"),
50        PixelFormat::Nv12 => four_char_code!("NV12"),
51        PixelFormat::Nv16 => four_char_code!("NV16"),
52        // Planar formats have no standard FourCC; use RGBA as fallback
53        _ => four_char_code!("RGBA"),
54    }
55}
56
57/// G2DConverter implements the ImageProcessor trait using the NXP G2D
58/// library for hardware-accelerated image processing on i.MX platforms.
59#[derive(Debug)]
60pub struct G2DProcessor {
61    g2d: G2D,
62}
63
64unsafe impl Send for G2DProcessor {}
65unsafe impl Sync for G2DProcessor {}
66
67impl G2DProcessor {
68    /// Creates a new G2DConverter instance.
69    ///
70    /// The BT.709 matrix set here is only a safe default; each `convert()` call
71    /// re-programs the matrix from the resolved colorimetry of the YUV side of
72    /// that conversion (see `convert_impl`). G2D is matrix-only — it has no
73    /// range control (limited-range only) and no BT.2020 matrix — so full-range
74    /// YUV and BT.2020 conversions are declined and fall through to GL/CPU.
75    pub fn new() -> Result<Self> {
76        let mut g2d = G2D::new("libg2d.so.2")?;
77        // Safe initial default only: every `convert()` re-programs the matrix
78        // from the resolved colorimetry of that conversion (see `convert_impl`).
79        // The g2d-sys API selects only the matrix, not full-vs-limited range, so
80        // G2D is limited-range only — full-range and BT.2020 are declined and
81        // fall through to GL/CPU (a structural hardware limit, not a stop-gap).
82        g2d.set_bt601_colorspace()?;
83
84        log::debug!("G2DConverter created with version {:?}", g2d.version());
85        Ok(Self { g2d })
86    }
87
88    /// Returns the G2D library version as defined by _G2D_VERSION in the shared
89    /// library.
90    pub fn version(&self) -> g2d_sys::Version {
91        self.g2d.version()
92    }
93
94    fn convert_impl(
95        &mut self,
96        src_dyn: &TensorDyn,
97        dst_dyn: &mut TensorDyn,
98        rotation: Rotation,
99        flip: Flip,
100        crop: ResolvedCrop,
101    ) -> Result<()> {
102        let _span = tracing::trace_span!(
103            "image.convert.g2d",
104            src_fmt = ?src_dyn.format(),
105            dst_fmt = ?dst_dyn.format(),
106        )
107        .entered();
108        if log::log_enabled!(log::Level::Trace) {
109            log::trace!(
110                "G2D convert: {:?}({:?}/{:?}) → {:?}({:?}/{:?})",
111                src_dyn.format(),
112                src_dyn.dtype(),
113                src_dyn.memory(),
114                dst_dyn.format(),
115                dst_dyn.dtype(),
116                dst_dyn.memory(),
117            );
118        }
119
120        if src_dyn.dtype() != DType::U8 {
121            return Err(Error::NotSupported(
122                "G2D only supports u8 source tensors".to_string(),
123            ));
124        }
125        let is_int8_dst = dst_dyn.dtype() == DType::I8;
126        if dst_dyn.dtype() != DType::U8 && !is_int8_dst {
127            return Err(Error::NotSupported(
128                "G2D only supports u8 or i8 destination tensors".to_string(),
129            ));
130        }
131
132        let src_fmt = src_dyn.format().ok_or(Error::NotAnImage)?;
133        let dst_fmt = dst_dyn.format().ok_or(Error::NotAnImage)?;
134
135        // Validate supported format pairs
136        use PixelFormat::*;
137        match (src_fmt, dst_fmt) {
138            (Rgba, Rgba) => {}
139            (Rgba, Yuyv) => {}
140            (Rgba, Rgb) => {}
141            (Yuyv, Rgba) => {}
142            (Yuyv, Yuyv) => {}
143            (Yuyv, Rgb) => {}
144            // VYUY: i.MX8MP G2D hardware rejects VYUY blits (only YUYV/UYVY
145            // among packed YUV 4:2:2). ImageProcessor falls through to CPU.
146            (Nv12, Rgba) => {}
147            (Nv12, Yuyv) => {}
148            (Nv12, Rgb) => {}
149            (Rgba, Bgra) => {}
150            (Yuyv, Bgra) => {}
151            (Nv12, Bgra) => {}
152            (Bgra, Bgra) => {}
153            (s, d) => {
154                return Err(Error::NotSupported(format!(
155                    "G2D does not support {} to {} conversion",
156                    s, d
157                )));
158            }
159        }
160
161        // ── Per-conversion colorspace matrix ─────────────────────────
162        // G2D is matrix-only (no range control, no BT.2020). Resolve the
163        // colorimetry of the YUV side of this conversion and program the
164        // matching matrix before the blit. YUV→RGB uses the *source*
165        // colorimetry; RGB→YUV uses the *destination*. RGB→RGB has no YUV side
166        // so the matrix is irrelevant and left untouched.
167        //
168        // Full-range YUV and BT.2020 are declined by `g2d_can_handle` in the
169        // ImageProcessor dispatch (lib.rs) BEFORE we get here, so those fall
170        // through to the GL / CPU backends which honour range and BT.2020. We
171        // still gate here defensively so a direct G2DProcessor caller behaves.
172        let src_is_yuv = src_fmt.is_yuv();
173        let dst_is_yuv = dst_fmt.is_yuv();
174        if src_is_yuv || dst_is_yuv {
175            let cm = if src_is_yuv {
176                effective_colorimetry(src_dyn)
177            } else {
178                effective_colorimetry(dst_dyn)
179            };
180            if !g2d_can_handle(&cm, true) {
181                return Err(Error::NotSupported(format!(
182                    "G2D cannot express colorimetry {:?}/{:?} (matrix-only, \
183                     no range control, no BT.2020)",
184                    cm.encoding, cm.range
185                )));
186            }
187            match cm.encoding {
188                Some(ColorEncoding::Bt601) => self.g2d.set_bt601_colorspace()?,
189                Some(ColorEncoding::Bt709) => self.g2d.set_bt709_colorspace()?,
190                // BT.2020 already declined above; any future encoding falls
191                // back to the BT.709 matrix (closest HD-grade approximation).
192                _ => self.g2d.set_bt709_colorspace()?,
193            }
194        }
195
196        // Source/placement already validated by `Crop::resolve` at the entry.
197        let src = src_dyn.as_u8().unwrap();
198        // For i8 destinations, reinterpret as u8 for G2D (same byte layout).
199        // The XOR 0x80 post-pass is applied after the blit completes.
200        let dst = if is_int8_dst {
201            // SAFETY: Tensor<i8> and Tensor<u8> have identical memory layout.
202            // The T parameter only affects PhantomData<T> (zero-sized) in
203            // TensorStorage variants and the typed view from map(). The chroma
204            // field (Option<Box<Tensor<T>>>) is also layout-identical. This
205            // reinterpreted reference is used only for shape/fd access and the
206            // G2D blit (which operates on raw DMA bytes). It does not outlive
207            // dst_dyn and is never stored.
208            let i8_tensor = dst_dyn.as_i8_mut().unwrap();
209            unsafe { &mut *(i8_tensor as *mut Tensor<i8> as *mut Tensor<u8>) }
210        } else {
211            dst_dyn.as_u8_mut().unwrap()
212        };
213
214        let mut src_surface = tensor_to_g2d_surface(src)?;
215        let mut dst_surface = tensor_to_g2d_surface(dst)?;
216
217        src_surface.rot = match flip {
218            Flip::None => g2d_sys::g2d_rotation_G2D_ROTATION_0,
219            Flip::Vertical => g2d_sys::g2d_rotation_G2D_FLIP_V,
220            Flip::Horizontal => g2d_sys::g2d_rotation_G2D_FLIP_H,
221        };
222
223        dst_surface.rot = match rotation {
224            Rotation::None => g2d_sys::g2d_rotation_G2D_ROTATION_0,
225            Rotation::Clockwise90 => g2d_sys::g2d_rotation_G2D_ROTATION_90,
226            Rotation::Rotate180 => g2d_sys::g2d_rotation_G2D_ROTATION_180,
227            Rotation::CounterClockwise90 => g2d_sys::g2d_rotation_G2D_ROTATION_270,
228        };
229
230        if let Some(crop_rect) = crop.src_rect {
231            src_surface.left = crop_rect.left as i32;
232            src_surface.top = crop_rect.top as i32;
233            src_surface.right = (crop_rect.left + crop_rect.width) as i32;
234            src_surface.bottom = (crop_rect.top + crop_rect.height) as i32;
235        }
236
237        let dst_w = dst.width().unwrap();
238        let dst_h = dst.height().unwrap();
239
240        // Clear the destination with the letterbox color before blitting the
241        // image into the sub-region.
242        //
243        // g2d_clear does not support 3-byte-per-pixel formats (RGB888, BGR888).
244        // For those formats, fall back to CPU fill after the blit.
245        let needs_clear = crop.dst_color.is_some()
246            && crop.dst_rect.is_some_and(|dst_rect| {
247                dst_rect.left != 0
248                    || dst_rect.top != 0
249                    || dst_rect.width != dst_w
250                    || dst_rect.height != dst_h
251            });
252
253        if needs_clear && dst_fmt != Rgb {
254            if let Some(dst_color) = crop.dst_color {
255                let start = Instant::now();
256                self.g2d.clear(&mut dst_surface, dst_color)?;
257                log::trace!("g2d clear takes {:?}", start.elapsed());
258            }
259        }
260
261        if let Some(crop_rect) = crop.dst_rect {
262            // stride is in pixels; multiply by bytes-per-pixel (== channels()
263            // for u8 data) to get the byte offset.  All G2D destination
264            // formats are packed, so channels() == bpp always holds here.
265            dst_surface.planes[0] += ((crop_rect.top * dst_surface.stride as usize
266                + crop_rect.left)
267                * dst_fmt.channels()) as u64;
268
269            dst_surface.right = crop_rect.width as i32;
270            dst_surface.bottom = crop_rect.height as i32;
271            dst_surface.width = crop_rect.width as i32;
272            dst_surface.height = crop_rect.height as i32;
273        }
274
275        // ── i.MX95 DPU even-dimension workaround ──────────────────────────
276        // The i.MX95 DPU G2D backend garbles a chroma-subsampled blit whose
277        // width or height is odd — it drops the partial trailing chroma sample
278        // and corrupts the ENTIRE output (max_diff ~255); the i.MX8MP Vivante
279        // backend tolerates it. When either side is a chroma-subsampled YUV
280        // format, round BOTH blit rectangles up to even on each *subsampled*
281        // axis so the DPU's chroma geometry is exact. The width pad always lands
282        // in the 64-aligned row stride (both sides). The height pad reads the
283        // source's chroma plane (within its allocation) and writes ONE extra
284        // destination row — applied only when the destination's (page-aligned)
285        // DMA allocation can hold it; otherwise G2D declines so the caller falls
286        // back to GL/CPU, which convert odd dimensions natively. Only the
287        // logical w×h region is consumed downstream, so the padding is unused.
288        let (sx_src, sy_src) = chroma_subsample_shifts(src_fmt);
289        let (sx_dst, sy_dst) = chroma_subsample_shifts(dst_fmt);
290        let pad_w = (sx_src | sx_dst) > 0;
291        let pad_h = (sy_src | sy_dst) > 0;
292        if pad_w || pad_h {
293            let even = |v: i32| v + (v & 1);
294            if pad_h && even(dst_surface.bottom) != dst_surface.bottom {
295                // The padded blit writes one extra destination row. It is safe
296                // only when the blit starts at the destination's top (no crop
297                // placement offset) and the extra row stays within the
298                // destination's DMA mapping. A self-allocated DMA-BUF is mapped
299                // at page granularity, so its strided allocation
300                // (row_bytes × height) is physically backed up to the next page
301                // boundary. Query the runtime page size — aarch64 kernels may use
302                // 16 KiB or 64 KiB pages, and hard-coding 4 KiB would
303                // under-estimate the mapping and needlessly reject a destination
304                // that is in fact page-backed.
305                let page = match unsafe { libc::sysconf(libc::_SC_PAGESIZE) } {
306                    n if n > 0 => n as usize,
307                    _ => 4096,
308                };
309                let row_bytes = dst_surface.stride as usize * dst_fmt.channels();
310                let mapped = (row_bytes * dst_h).next_multiple_of(page);
311                let needed = row_bytes * even(dst_surface.bottom) as usize;
312                if crop.dst_rect.is_some() || needed > mapped {
313                    return Err(Error::NotSupported(format!(
314                        "G2D: odd-height {dst_fmt} destination cannot hold the padding \
315                         row for the i.MX95 DPU even-dimension workaround; deferring to \
316                         GL/CPU"
317                    )));
318                }
319            }
320            if pad_w {
321                src_surface.right = even(src_surface.right);
322                src_surface.width = even(src_surface.width);
323                dst_surface.right = even(dst_surface.right);
324                dst_surface.width = even(dst_surface.width);
325            }
326            if pad_h {
327                src_surface.bottom = even(src_surface.bottom);
328                src_surface.height = even(src_surface.height);
329                dst_surface.bottom = even(dst_surface.bottom);
330                dst_surface.height = even(dst_surface.height);
331            }
332        }
333
334        log::trace!("G2D blit: {src_fmt}→{dst_fmt} int8={is_int8_dst}");
335        self.g2d.blit(&src_surface, &dst_surface)?;
336        self.g2d.finish()?;
337        log::trace!("G2D blit complete");
338
339        // CPU fallback for RGB888 (unsupported by g2d_clear)
340        if needs_clear && dst_fmt == Rgb {
341            if let (Some(dst_color), Some(dst_rect)) = (crop.dst_color, crop.dst_rect) {
342                let start = Instant::now();
343                CPUProcessor::fill_image_outside_crop_u8(dst, dst_color, dst_rect)?;
344                log::trace!("cpu fill takes {:?}", start.elapsed());
345            }
346        }
347
348        // Apply XOR 0x80 for int8 output (u8→i8 bias conversion).
349        // map() issues DMA_BUF_IOCTL_SYNC(START) on the dst fd; for self-allocated
350        // CMA buffers this performs cache invalidation via the DrmAttachment.
351        // For foreign fds (e.g. the Neutron NPU DMA-BUF imported via from_fd()),
352        // the DrmAttachment is None and the sync ioctl is handled by the NPU driver.
353        // The map drop issues DMA_BUF_IOCTL_SYNC(END) so the NPU DMA engine sees
354        // the CPU-written XOR'd data on the next Invoke().
355        if is_int8_dst {
356            let start = Instant::now();
357            let mut map = dst.map()?;
358            crate::cpu::apply_int8_xor_bias(map.as_mut_slice(), dst_fmt);
359            log::trace!("g2d int8 XOR 0x80 post-pass takes {:?}", start.elapsed());
360        }
361
362        Ok(())
363    }
364}
365
366impl ImageProcessorTrait for G2DProcessor {
367    fn convert(
368        &mut self,
369        src: &TensorDyn,
370        dst: &mut TensorDyn,
371        rotation: Rotation,
372        flip: Flip,
373        crop: Crop,
374    ) -> Result<()> {
375        // A view()/batch() destination needs no special handling here: G2D blits
376        // to the destination's `plane_offset` + parent `row_stride`, so a sub-view
377        // (the tile) lands in its window of the parent buffer natively — the same
378        // mechanism a whole-tensor blit uses. (The cache-key/glViewport batch
379        // path is GL-specific; G2D addresses the tile by offset+stride.)
380        let crop = crop.resolve(
381            src.width().unwrap_or(0),
382            src.height().unwrap_or(0),
383            dst.width().unwrap_or(0),
384            dst.height().unwrap_or(0),
385        )?;
386        self.convert_impl(src, dst, rotation, flip, crop)
387    }
388
389    fn draw_decoded_masks(
390        &mut self,
391        dst: &mut TensorDyn,
392        detect: &[crate::DetectBox],
393        segmentation: &[crate::Segmentation],
394        overlay: crate::MaskOverlay<'_>,
395    ) -> Result<()> {
396        // G2D can produce the *frame* (background or cleared canvas) via
397        // hardware primitives — but has no rasterizer for boxes / masks.
398        // If detections are present, defer to another backend.
399        if !detect.is_empty() || !segmentation.is_empty() {
400            return Err(Error::NotImplemented(
401                "G2D does not support drawing detection or segmentation overlays".to_string(),
402            ));
403        }
404        draw_empty_frame_g2d(&mut self.g2d, dst, overlay.background)
405    }
406
407    fn draw_proto_masks(
408        &mut self,
409        dst: &mut TensorDyn,
410        detect: &[crate::DetectBox],
411        _proto_data: &crate::ProtoData,
412        overlay: crate::MaskOverlay<'_>,
413    ) -> Result<()> {
414        // Same logic as draw_decoded_masks: G2D handles empty-detection
415        // frames (clear or blit background) via the 2D hardware.
416        if !detect.is_empty() {
417            return Err(Error::NotImplemented(
418                "G2D does not support drawing detection or segmentation overlays".to_string(),
419            ));
420        }
421        draw_empty_frame_g2d(&mut self.g2d, dst, overlay.background)
422    }
423
424    fn set_class_colors(&mut self, _: &[[u8; 4]]) -> Result<()> {
425        Err(Error::NotImplemented(
426            "G2D does not support setting colors for rendering detection or segmentation overlays"
427                .to_string(),
428        ))
429    }
430}
431
432/// Produce the zero-detection frame on `dst` using G2D hardware ops.
433///
434/// `background == None` → `g2d_clear(dst, 0x00000000)` — actively fills the
435/// destination with transparent black using the 2D engine (no CPU touch).
436///
437/// `background == Some(bg)` → `g2d_blit(bg → dst)` — hardware copy of bg
438/// into dst. This is the "draw the cleared overlay onto the background"
439/// path; G2D has no pure copy primitive, so we use the blit engine with a
440/// 1:1 same-format surface pair, which is the hardware equivalent.
441///
442/// Both paths `finish()` before returning so dst is coherent for any
443/// downstream reader.
444fn draw_empty_frame_g2d(
445    g2d: &mut G2D,
446    dst_dyn: &mut TensorDyn,
447    background: Option<&TensorDyn>,
448) -> Result<()> {
449    if dst_dyn.dtype() != DType::U8 {
450        return Err(Error::NotSupported(
451            "G2D only supports u8 destination tensors".to_string(),
452        ));
453    }
454    let dst = dst_dyn.as_u8_mut().ok_or(Error::NotAnImage)?;
455
456    // DMA-only: G2D operates on physical addresses. A Mem-backed dst means
457    // we're on the wrong backend — surface a dispatch error so the caller
458    // can fall back.
459    if dst.as_dma().is_none() {
460        return Err(Error::NotImplemented(
461            "g2d only supports Dma memory".to_string(),
462        ));
463    }
464
465    let mut dst_surface = tensor_to_g2d_surface(dst)?;
466
467    match background {
468        None => {
469            // Case 1 — no background, no detections: hardware clear to
470            // transparent black. Every byte of dst is written by the 2D
471            // engine; no reliance on prior state.
472            let start = Instant::now();
473            g2d.clear(&mut dst_surface, [0, 0, 0, 0])?;
474            g2d.finish()?;
475            log::trace!("g2d clear (empty frame) takes {:?}", start.elapsed());
476        }
477        Some(bg_dyn) => {
478            // Case 2 — background, no detections: hardware blit bg → dst.
479            // Validate shape/format equivalence; if the caller handed us
480            // mismatched surfaces we return a structured error rather than
481            // silently producing wrong output.
482            if bg_dyn.shape() != dst.shape() {
483                return Err(Error::InvalidShape(
484                    "background shape does not match dst".into(),
485                ));
486            }
487            if bg_dyn.format() != dst.format() {
488                return Err(Error::InvalidShape(
489                    "background pixel format does not match dst".into(),
490                ));
491            }
492            if bg_dyn.dtype() != DType::U8 {
493                return Err(Error::NotSupported(
494                    "G2D only supports u8 background tensors".to_string(),
495                ));
496            }
497            let bg = bg_dyn.as_u8().ok_or(Error::NotAnImage)?;
498            if bg.as_dma().is_none() {
499                return Err(Error::NotImplemented(
500                    "g2d background must be Dma-backed".to_string(),
501                ));
502            }
503            let src_surface = tensor_to_g2d_surface(bg)?;
504            let start = Instant::now();
505            g2d.blit(&src_surface, &dst_surface)?;
506            g2d.finish()?;
507            log::trace!("g2d blit (bg→dst) takes {:?}", start.elapsed());
508        }
509    }
510    Ok(())
511}
512
513/// Chroma subsampling of `fmt` as `(shift_x, shift_y)` — 1 = half resolution on
514/// that axis, 0 = full. Drives the i.MX95 DPU even-dimension workaround: a blit
515/// dimension that the format subsamples must be even or the DPU garbles it.
516fn chroma_subsample_shifts(fmt: PixelFormat) -> (u32, u32) {
517    match fmt.chroma_layout() {
518        Some(cl) => (cl.shift_x, cl.shift_y),
519        // Packed 4:2:2 (YUYV / VYUY) subsample horizontally; all other packed
520        // formats (RGB/RGBA/BGRA/Grey) are full-resolution.
521        None if matches!(fmt, PixelFormat::Yuyv | PixelFormat::Vyuy) => (1, 0),
522        None => (0, 0),
523    }
524}
525
526/// Build a `G2DSurface` from a `Tensor<u8>` that carries pixel-format metadata.
527///
528/// The tensor must be backed by DMA memory and must have a pixel format set.
529fn tensor_to_g2d_surface(img: &Tensor<u8>) -> Result<G2DSurface> {
530    let fmt = img.format().ok_or(Error::NotAnImage)?;
531    let dma = img
532        .as_dma()
533        .ok_or_else(|| Error::NotImplemented("g2d only supports Dma memory".to_string()))?;
534    let phys: G2DPhysical = dma.fd.as_raw_fd().try_into()?;
535
536    // NV12 is a two-plane format: Y plane followed by interleaved UV plane.
537    // planes[0] = Y plane start, planes[1] = UV plane start (Y size = width * height)
538    //
539    // plane_offset is the byte offset within the DMA-BUF where pixel data
540    // starts.  G2D works with raw physical addresses so we must add the
541    // offset ourselves — the hardware has no concept of a per-plane offset.
542    let base_addr = phys.address();
543    let luma_offset = img.plane_offset().unwrap_or(0) as u64;
544    let planes = if fmt == PixelFormat::Nv12 {
545        if img.is_multiplane() {
546            // Multiplane: UV in separate DMA-BUF, get its physical address
547            let chroma = img.chroma().unwrap();
548            let chroma_dma = chroma.as_dma().ok_or_else(|| {
549                Error::NotImplemented("g2d multiplane chroma must be DMA-backed".to_string())
550            })?;
551            let uv_phys: G2DPhysical = chroma_dma.fd.as_raw_fd().try_into()?;
552            let chroma_offset = img.chroma().and_then(|c| c.plane_offset()).unwrap_or(0) as u64;
553            [
554                base_addr + luma_offset,
555                uv_phys.address() + chroma_offset,
556                0,
557            ]
558        } else {
559            let w = img.width().unwrap();
560            let h = img.height().unwrap();
561            let stride = img.effective_row_stride().unwrap_or(w.next_multiple_of(2));
562            let uv_offset = (luma_offset as usize + stride * h) as u64;
563            [base_addr + luma_offset, base_addr + uv_offset, 0]
564        }
565    } else {
566        [base_addr + luma_offset, 0, 0]
567    };
568
569    let w = img.width().unwrap();
570    let h = img.height().unwrap();
571    let fourcc = pixelfmt_to_fourcc(fmt);
572
573    // G2D stride is in pixels.  effective_row_stride() returns bytes, so
574    // divide by the bytes-per-pixel (channels for u8 data) to convert.
575    let stride_pixels = match img.effective_row_stride() {
576        Some(s) => {
577            let channels = fmt.channels();
578            if s % channels != 0 {
579                return Err(Error::NotImplemented(
580                    "g2d requires row stride to be a multiple of bytes-per-pixel".to_string(),
581                ));
582            }
583            s / channels
584        }
585        None => w,
586    };
587
588    Ok(G2DSurface {
589        planes,
590        format: G2DFormat::try_from(fourcc)?.format(),
591        left: 0,
592        top: 0,
593        right: w as i32,
594        bottom: h as i32,
595        stride: stride_pixels as i32,
596        width: w as i32,
597        height: h as i32,
598        blendfunc: 0,
599        clrcolor: 0,
600        rot: 0,
601        global_alpha: 0,
602    })
603}
604
605#[cfg(test)]
606mod g2d_predicate_tests {
607    use super::*;
608    use edgefirst_tensor::{ColorEncoding, ColorRange, Colorimetry};
609
610    #[test]
611    fn g2d_declines_full_range_and_bt2020_yuv() {
612        let full = Colorimetry::default()
613            .with_range(ColorRange::Full)
614            .with_encoding(ColorEncoding::Bt709);
615        let lim = Colorimetry::default()
616            .with_range(ColorRange::Limited)
617            .with_encoding(ColorEncoding::Bt709);
618        let bt2020 = Colorimetry::default()
619            .with_range(ColorRange::Limited)
620            .with_encoding(ColorEncoding::Bt2020);
621        assert!(!g2d_can_handle(&full, true)); // full-range YUV → decline
622        assert!(g2d_can_handle(&lim, true)); // limited 709 YUV → ok
623        assert!(!g2d_can_handle(&bt2020, true)); // BT.2020 → decline
624        assert!(g2d_can_handle(&full, false)); // no YUV side → rule N/A → ok
625    }
626}
627
628#[cfg(feature = "g2d_test_formats")]
629#[cfg(test)]
630#[allow(deprecated)]
631mod g2d_tests {
632    use super::*;
633    use crate::{CPUProcessor, Flip, G2DProcessor, ImageProcessorTrait, Rotation};
634    use edgefirst_tensor::{
635        is_dma_available, DType, PixelFormat, TensorDyn, TensorMapTrait, TensorMemory, TensorTrait,
636    };
637    use image::buffer::ConvertBuffer;
638
639    /// G2D is usable only where `libg2d.so.2` loads — the NXP i.MX boards. On
640    /// V3D/Tegra/x86 the library is absent, so `G2DProcessor::new()` and the
641    /// surface physical-address resolution these tests exercise both fail. DMA
642    /// being available does NOT imply G2D (e.g. rpi5/V3D has DMA but no G2D), so
643    /// these tests must gate on G2D, not just DMA, or they panic/fail on
644    /// non-G2D hardware instead of skipping.
645    #[cfg(target_os = "linux")]
646    fn is_g2d_available() -> bool {
647        G2DProcessor::new().is_ok()
648    }
649
650    #[test]
651    #[cfg(target_os = "linux")]
652    fn test_g2d_formats_no_resize() {
653        for i in [
654            PixelFormat::Rgba,
655            PixelFormat::Yuyv,
656            PixelFormat::Rgb,
657            PixelFormat::Grey,
658            PixelFormat::Nv12,
659        ] {
660            for o in [
661                PixelFormat::Rgba,
662                PixelFormat::Yuyv,
663                PixelFormat::Rgb,
664                PixelFormat::Grey,
665            ] {
666                let res = test_g2d_format_no_resize_(i, o);
667                if let Err(e) = res {
668                    println!("{i} to {o} failed: {e:?}");
669                } else {
670                    println!("{i} to {o} success");
671                }
672            }
673        }
674    }
675
676    fn test_g2d_format_no_resize_(
677        g2d_in_fmt: PixelFormat,
678        g2d_out_fmt: PixelFormat,
679    ) -> Result<(), crate::Error> {
680        let dst_width = 1280;
681        let dst_height = 720;
682        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
683        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgb), None)?;
684
685        // Create DMA buffer for G2D input
686        let mut src2 = TensorDyn::image(1280, 720, g2d_in_fmt, DType::U8, Some(TensorMemory::Dma))?;
687
688        let mut cpu_converter = CPUProcessor::new();
689
690        // For PixelFormat::Nv12 input, load from file since CPU doesn't support PixelFormat::Rgb→PixelFormat::Nv12
691        if g2d_in_fmt == PixelFormat::Nv12 {
692            let nv12_bytes = edgefirst_bench::testdata::read("zidane.nv12");
693            src2.as_u8()
694                .unwrap()
695                .map()?
696                .as_mut_slice()
697                .copy_from_slice(&nv12_bytes);
698        } else {
699            cpu_converter.convert(&src, &mut src2, Rotation::None, Flip::None, Crop::no_crop())?;
700        }
701
702        let mut g2d_dst = TensorDyn::image(
703            dst_width,
704            dst_height,
705            g2d_out_fmt,
706            DType::U8,
707            Some(TensorMemory::Dma),
708        )?;
709        let mut g2d_converter = G2DProcessor::new()?;
710        let src2_dyn = src2;
711        let mut g2d_dst_dyn = g2d_dst;
712        g2d_converter.convert(
713            &src2_dyn,
714            &mut g2d_dst_dyn,
715            Rotation::None,
716            Flip::None,
717            Crop::no_crop(),
718        )?;
719        g2d_dst = {
720            let mut __t = g2d_dst_dyn.into_u8().unwrap();
721            __t.set_format(g2d_out_fmt)
722                .map_err(|e| crate::Error::Internal(e.to_string()))?;
723            TensorDyn::from(__t)
724        };
725
726        let mut cpu_dst =
727            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgb, DType::U8, None)?;
728        cpu_converter.convert(
729            &g2d_dst,
730            &mut cpu_dst,
731            Rotation::None,
732            Flip::None,
733            Crop::no_crop(),
734        )?;
735
736        compare_images(
737            &src,
738            &cpu_dst,
739            0.98,
740            &format!("{g2d_in_fmt}_to_{g2d_out_fmt}"),
741        )
742    }
743
744    #[test]
745    #[cfg(target_os = "linux")]
746    fn test_g2d_formats_with_resize() {
747        for i in [
748            PixelFormat::Rgba,
749            PixelFormat::Yuyv,
750            PixelFormat::Rgb,
751            PixelFormat::Grey,
752            PixelFormat::Nv12,
753        ] {
754            for o in [
755                PixelFormat::Rgba,
756                PixelFormat::Yuyv,
757                PixelFormat::Rgb,
758                PixelFormat::Grey,
759            ] {
760                let res = test_g2d_format_with_resize_(i, o);
761                if let Err(e) = res {
762                    println!("{i} to {o} failed: {e:?}");
763                } else {
764                    println!("{i} to {o} success");
765                }
766            }
767        }
768    }
769
770    #[test]
771    #[cfg(target_os = "linux")]
772    fn test_g2d_formats_with_resize_dst_crop() {
773        for i in [
774            PixelFormat::Rgba,
775            PixelFormat::Yuyv,
776            PixelFormat::Rgb,
777            PixelFormat::Grey,
778            PixelFormat::Nv12,
779        ] {
780            for o in [
781                PixelFormat::Rgba,
782                PixelFormat::Yuyv,
783                PixelFormat::Rgb,
784                PixelFormat::Grey,
785            ] {
786                let res = test_g2d_format_with_resize_dst_crop(i, o);
787                if let Err(e) = res {
788                    println!("{i} to {o} failed: {e:?}");
789                } else {
790                    println!("{i} to {o} success");
791                }
792            }
793        }
794    }
795
796    fn test_g2d_format_with_resize_(
797        g2d_in_fmt: PixelFormat,
798        g2d_out_fmt: PixelFormat,
799    ) -> Result<(), crate::Error> {
800        let dst_width = 600;
801        let dst_height = 400;
802        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
803        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgb), None)?;
804
805        let mut cpu_converter = CPUProcessor::new();
806
807        let mut reference = TensorDyn::image(
808            dst_width,
809            dst_height,
810            PixelFormat::Rgb,
811            DType::U8,
812            Some(TensorMemory::Dma),
813        )?;
814        cpu_converter.convert(
815            &src,
816            &mut reference,
817            Rotation::None,
818            Flip::None,
819            Crop::no_crop(),
820        )?;
821
822        // Create DMA buffer for G2D input
823        let mut src2 = TensorDyn::image(1280, 720, g2d_in_fmt, DType::U8, Some(TensorMemory::Dma))?;
824
825        // For PixelFormat::Nv12 input, load from file since CPU doesn't support PixelFormat::Rgb→PixelFormat::Nv12
826        if g2d_in_fmt == PixelFormat::Nv12 {
827            let nv12_bytes = edgefirst_bench::testdata::read("zidane.nv12");
828            src2.as_u8()
829                .unwrap()
830                .map()?
831                .as_mut_slice()
832                .copy_from_slice(&nv12_bytes);
833        } else {
834            cpu_converter.convert(&src, &mut src2, Rotation::None, Flip::None, Crop::no_crop())?;
835        }
836
837        let mut g2d_dst = TensorDyn::image(
838            dst_width,
839            dst_height,
840            g2d_out_fmt,
841            DType::U8,
842            Some(TensorMemory::Dma),
843        )?;
844        let mut g2d_converter = G2DProcessor::new()?;
845        let src2_dyn = src2;
846        let mut g2d_dst_dyn = g2d_dst;
847        g2d_converter.convert(
848            &src2_dyn,
849            &mut g2d_dst_dyn,
850            Rotation::None,
851            Flip::None,
852            Crop::no_crop(),
853        )?;
854        g2d_dst = {
855            let mut __t = g2d_dst_dyn.into_u8().unwrap();
856            __t.set_format(g2d_out_fmt)
857                .map_err(|e| crate::Error::Internal(e.to_string()))?;
858            TensorDyn::from(__t)
859        };
860
861        let mut cpu_dst =
862            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgb, DType::U8, None)?;
863        cpu_converter.convert(
864            &g2d_dst,
865            &mut cpu_dst,
866            Rotation::None,
867            Flip::None,
868            Crop::no_crop(),
869        )?;
870
871        compare_images(
872            &reference,
873            &cpu_dst,
874            0.98,
875            &format!("{g2d_in_fmt}_to_{g2d_out_fmt}_resized"),
876        )
877    }
878
879    fn test_g2d_format_with_resize_dst_crop(
880        g2d_in_fmt: PixelFormat,
881        g2d_out_fmt: PixelFormat,
882    ) -> Result<(), crate::Error> {
883        let dst_width = 600;
884        let dst_height = 400;
885        // The destination sub-rectangle is now expressed as a `view()` of the
886        // destination (the old `Crop.dst_rect` was removed); G2D blits into the
887        // view's window via its offset + parent stride. Region is (x=left, y=top,
888        // width, height).
889        let region = crate::Region::new(100, 100, 200, 100);
890        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
891        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgb), None)?;
892
893        let mut cpu_converter = CPUProcessor::new();
894
895        let reference = TensorDyn::image(
896            dst_width,
897            dst_height,
898            PixelFormat::Rgb,
899            DType::U8,
900            Some(TensorMemory::Dma),
901        )?;
902        reference
903            .as_u8()
904            .unwrap()
905            .map()
906            .unwrap()
907            .as_mut_slice()
908            .fill(128);
909        cpu_converter.convert(
910            &src,
911            &mut reference.view(region)?,
912            Rotation::None,
913            Flip::None,
914            Crop::no_crop(),
915        )?;
916
917        // Create DMA buffer for G2D input
918        let mut src2 = TensorDyn::image(1280, 720, g2d_in_fmt, DType::U8, Some(TensorMemory::Dma))?;
919
920        // For PixelFormat::Nv12 input, load from file since CPU doesn't support PixelFormat::Rgb→PixelFormat::Nv12
921        if g2d_in_fmt == PixelFormat::Nv12 {
922            let nv12_bytes = edgefirst_bench::testdata::read("zidane.nv12");
923            src2.as_u8()
924                .unwrap()
925                .map()?
926                .as_mut_slice()
927                .copy_from_slice(&nv12_bytes);
928        } else {
929            cpu_converter.convert(&src, &mut src2, Rotation::None, Flip::None, Crop::no_crop())?;
930        }
931
932        let mut g2d_dst = TensorDyn::image(
933            dst_width,
934            dst_height,
935            g2d_out_fmt,
936            DType::U8,
937            Some(TensorMemory::Dma),
938        )?;
939        g2d_dst
940            .as_u8()
941            .unwrap()
942            .map()
943            .unwrap()
944            .as_mut_slice()
945            .fill(128);
946        let mut g2d_converter = G2DProcessor::new()?;
947        let src2_dyn = src2;
948        let g2d_dst_dyn = g2d_dst;
949        g2d_converter.convert(
950            &src2_dyn,
951            &mut g2d_dst_dyn.view(region)?,
952            Rotation::None,
953            Flip::None,
954            Crop::no_crop(),
955        )?;
956        g2d_dst = {
957            let mut __t = g2d_dst_dyn.into_u8().unwrap();
958            __t.set_format(g2d_out_fmt)
959                .map_err(|e| crate::Error::Internal(e.to_string()))?;
960            TensorDyn::from(__t)
961        };
962
963        let mut cpu_dst =
964            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgb, DType::U8, None)?;
965        cpu_converter.convert(
966            &g2d_dst,
967            &mut cpu_dst,
968            Rotation::None,
969            Flip::None,
970            Crop::no_crop(),
971        )?;
972
973        compare_images(
974            &reference,
975            &cpu_dst,
976            0.98,
977            &format!("{g2d_in_fmt}_to_{g2d_out_fmt}_resized_dst_crop"),
978        )
979    }
980
981    fn compare_images(
982        img1: &TensorDyn,
983        img2: &TensorDyn,
984        threshold: f64,
985        name: &str,
986    ) -> Result<(), crate::Error> {
987        assert_eq!(img1.height(), img2.height(), "Heights differ");
988        assert_eq!(img1.width(), img2.width(), "Widths differ");
989        assert_eq!(
990            img1.format().unwrap(),
991            img2.format().unwrap(),
992            "PixelFormat differ"
993        );
994        assert!(
995            matches!(img1.format().unwrap(), PixelFormat::Rgb | PixelFormat::Rgba),
996            "format must be Rgb or Rgba for comparison"
997        );
998        let image1 = match img1.format().unwrap() {
999            PixelFormat::Rgb => image::RgbImage::from_vec(
1000                img1.width().unwrap() as u32,
1001                img1.height().unwrap() as u32,
1002                img1.as_u8().unwrap().map().unwrap().to_vec(),
1003            )
1004            .unwrap(),
1005            PixelFormat::Rgba => image::RgbaImage::from_vec(
1006                img1.width().unwrap() as u32,
1007                img1.height().unwrap() as u32,
1008                img1.as_u8().unwrap().map().unwrap().to_vec(),
1009            )
1010            .unwrap()
1011            .convert(),
1012
1013            _ => unreachable!(),
1014        };
1015
1016        let image2 = match img2.format().unwrap() {
1017            PixelFormat::Rgb => image::RgbImage::from_vec(
1018                img2.width().unwrap() as u32,
1019                img2.height().unwrap() as u32,
1020                img2.as_u8().unwrap().map().unwrap().to_vec(),
1021            )
1022            .unwrap(),
1023            PixelFormat::Rgba => image::RgbaImage::from_vec(
1024                img2.width().unwrap() as u32,
1025                img2.height().unwrap() as u32,
1026                img2.as_u8().unwrap().map().unwrap().to_vec(),
1027            )
1028            .unwrap()
1029            .convert(),
1030
1031            _ => unreachable!(),
1032        };
1033
1034        let similarity = image_compare::rgb_similarity_structure(
1035            &image_compare::Algorithm::RootMeanSquared,
1036            &image1,
1037            &image2,
1038        )
1039        .expect("Image Comparison failed");
1040
1041        if similarity.score < threshold {
1042            image1.save(format!("{name}_1.png")).unwrap();
1043            image2.save(format!("{name}_2.png")).unwrap();
1044            return Err(Error::Internal(format!(
1045                "{name}: converted image and target image have similarity score too low: {} < {}",
1046                similarity.score, threshold
1047            )));
1048        }
1049        Ok(())
1050    }
1051
1052    // =========================================================================
1053    // PixelFormat::Nv12 Reference Validation Tests
1054    // These tests compare G2D PixelFormat::Nv12 conversions against ffmpeg-generated references
1055    // =========================================================================
1056
1057    fn load_raw_image(
1058        width: usize,
1059        height: usize,
1060        format: PixelFormat,
1061        memory: Option<TensorMemory>,
1062        bytes: &[u8],
1063    ) -> Result<TensorDyn, crate::Error> {
1064        let img = TensorDyn::image(width, height, format, DType::U8, memory)?;
1065        let mut map = img.as_u8().unwrap().map()?;
1066        let dst = map.as_mut_slice();
1067        if bytes.len() > dst.len() {
1068            return Err(crate::Error::InvalidShape(format!(
1069                "load_raw_image: {} input bytes exceed {}-byte image buffer",
1070                bytes.len(),
1071                dst.len()
1072            )));
1073        }
1074        dst[..bytes.len()].copy_from_slice(bytes);
1075        Ok(img)
1076    }
1077
1078    /// Test G2D PixelFormat::Nv12→PixelFormat::Rgba conversion against ffmpeg reference
1079    #[test]
1080    #[cfg(target_os = "linux")]
1081    fn test_g2d_nv12_to_rgba_reference() -> Result<(), crate::Error> {
1082        if !is_dma_available() || !is_g2d_available() {
1083            return Ok(());
1084        }
1085        // Load PixelFormat::Nv12 source
1086        let src = load_raw_image(
1087            1280,
1088            720,
1089            PixelFormat::Nv12,
1090            Some(TensorMemory::Dma),
1091            &edgefirst_bench::testdata::read("camera720p.nv12"),
1092        )?;
1093
1094        // Load PixelFormat::Rgba reference (ffmpeg-generated)
1095        let reference = load_raw_image(
1096            1280,
1097            720,
1098            PixelFormat::Rgba,
1099            None,
1100            &edgefirst_bench::testdata::read("camera720p.rgba"),
1101        )?;
1102
1103        // Convert using G2D
1104        let mut dst = TensorDyn::image(
1105            1280,
1106            720,
1107            PixelFormat::Rgba,
1108            DType::U8,
1109            Some(TensorMemory::Dma),
1110        )?;
1111        let mut g2d = G2DProcessor::new()?;
1112        let src_dyn = src;
1113        let mut dst_dyn = dst;
1114        g2d.convert(
1115            &src_dyn,
1116            &mut dst_dyn,
1117            Rotation::None,
1118            Flip::None,
1119            Crop::no_crop(),
1120        )?;
1121        dst = {
1122            let mut __t = dst_dyn.into_u8().unwrap();
1123            __t.set_format(PixelFormat::Rgba)
1124                .map_err(|e| crate::Error::Internal(e.to_string()))?;
1125            TensorDyn::from(__t)
1126        };
1127
1128        // Copy to CPU for comparison
1129        let cpu_dst = TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None)?;
1130        cpu_dst
1131            .as_u8()
1132            .unwrap()
1133            .map()?
1134            .as_mut_slice()
1135            .copy_from_slice(dst.as_u8().unwrap().map()?.as_slice());
1136
1137        compare_images(&reference, &cpu_dst, 0.98, "g2d_nv12_to_rgba_reference")
1138    }
1139
1140    /// Test G2D PixelFormat::Nv12→PixelFormat::Rgb conversion against ffmpeg reference
1141    #[test]
1142    #[cfg(target_os = "linux")]
1143    fn test_g2d_nv12_to_rgb_reference() -> Result<(), crate::Error> {
1144        if !is_dma_available() || !is_g2d_available() {
1145            return Ok(());
1146        }
1147        // Load PixelFormat::Nv12 source
1148        let src = load_raw_image(
1149            1280,
1150            720,
1151            PixelFormat::Nv12,
1152            Some(TensorMemory::Dma),
1153            &edgefirst_bench::testdata::read("camera720p.nv12"),
1154        )?;
1155
1156        // Load PixelFormat::Rgb reference (ffmpeg-generated)
1157        let reference = load_raw_image(
1158            1280,
1159            720,
1160            PixelFormat::Rgb,
1161            None,
1162            &edgefirst_bench::testdata::read("camera720p.rgb"),
1163        )?;
1164
1165        // Convert using G2D
1166        let mut dst = TensorDyn::image(
1167            1280,
1168            720,
1169            PixelFormat::Rgb,
1170            DType::U8,
1171            Some(TensorMemory::Dma),
1172        )?;
1173        let mut g2d = G2DProcessor::new()?;
1174        let src_dyn = src;
1175        let mut dst_dyn = dst;
1176        g2d.convert(
1177            &src_dyn,
1178            &mut dst_dyn,
1179            Rotation::None,
1180            Flip::None,
1181            Crop::no_crop(),
1182        )?;
1183        dst = {
1184            let mut __t = dst_dyn.into_u8().unwrap();
1185            __t.set_format(PixelFormat::Rgb)
1186                .map_err(|e| crate::Error::Internal(e.to_string()))?;
1187            TensorDyn::from(__t)
1188        };
1189
1190        // Copy to CPU for comparison
1191        let cpu_dst = TensorDyn::image(1280, 720, PixelFormat::Rgb, DType::U8, None)?;
1192        cpu_dst
1193            .as_u8()
1194            .unwrap()
1195            .map()?
1196            .as_mut_slice()
1197            .copy_from_slice(dst.as_u8().unwrap().map()?.as_slice());
1198
1199        compare_images(&reference, &cpu_dst, 0.98, "g2d_nv12_to_rgb_reference")
1200    }
1201
1202    /// Test G2D PixelFormat::Yuyv→PixelFormat::Rgba conversion against ffmpeg reference
1203    #[test]
1204    #[cfg(target_os = "linux")]
1205    fn test_g2d_yuyv_to_rgba_reference() -> Result<(), crate::Error> {
1206        if !is_dma_available() || !is_g2d_available() {
1207            return Ok(());
1208        }
1209        // Load PixelFormat::Yuyv source
1210        let src = load_raw_image(
1211            1280,
1212            720,
1213            PixelFormat::Yuyv,
1214            Some(TensorMemory::Dma),
1215            &edgefirst_bench::testdata::read("camera720p.yuyv"),
1216        )?;
1217
1218        // Load PixelFormat::Rgba reference (ffmpeg-generated)
1219        let reference = load_raw_image(
1220            1280,
1221            720,
1222            PixelFormat::Rgba,
1223            None,
1224            &edgefirst_bench::testdata::read("camera720p.rgba"),
1225        )?;
1226
1227        // Convert using G2D
1228        let mut dst = TensorDyn::image(
1229            1280,
1230            720,
1231            PixelFormat::Rgba,
1232            DType::U8,
1233            Some(TensorMemory::Dma),
1234        )?;
1235        let mut g2d = G2DProcessor::new()?;
1236        let src_dyn = src;
1237        let mut dst_dyn = dst;
1238        g2d.convert(
1239            &src_dyn,
1240            &mut dst_dyn,
1241            Rotation::None,
1242            Flip::None,
1243            Crop::no_crop(),
1244        )?;
1245        dst = {
1246            let mut __t = dst_dyn.into_u8().unwrap();
1247            __t.set_format(PixelFormat::Rgba)
1248                .map_err(|e| crate::Error::Internal(e.to_string()))?;
1249            TensorDyn::from(__t)
1250        };
1251
1252        // Copy to CPU for comparison
1253        let cpu_dst = TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None)?;
1254        cpu_dst
1255            .as_u8()
1256            .unwrap()
1257            .map()?
1258            .as_mut_slice()
1259            .copy_from_slice(dst.as_u8().unwrap().map()?.as_slice());
1260
1261        compare_images(&reference, &cpu_dst, 0.98, "g2d_yuyv_to_rgba_reference")
1262    }
1263
1264    /// Test G2D PixelFormat::Yuyv→PixelFormat::Rgb conversion against ffmpeg reference
1265    #[test]
1266    #[cfg(target_os = "linux")]
1267    fn test_g2d_yuyv_to_rgb_reference() -> Result<(), crate::Error> {
1268        if !is_dma_available() || !is_g2d_available() {
1269            return Ok(());
1270        }
1271        // Load PixelFormat::Yuyv source
1272        let src = load_raw_image(
1273            1280,
1274            720,
1275            PixelFormat::Yuyv,
1276            Some(TensorMemory::Dma),
1277            &edgefirst_bench::testdata::read("camera720p.yuyv"),
1278        )?;
1279
1280        // Load PixelFormat::Rgb reference (ffmpeg-generated)
1281        let reference = load_raw_image(
1282            1280,
1283            720,
1284            PixelFormat::Rgb,
1285            None,
1286            &edgefirst_bench::testdata::read("camera720p.rgb"),
1287        )?;
1288
1289        // Convert using G2D
1290        let mut dst = TensorDyn::image(
1291            1280,
1292            720,
1293            PixelFormat::Rgb,
1294            DType::U8,
1295            Some(TensorMemory::Dma),
1296        )?;
1297        let mut g2d = G2DProcessor::new()?;
1298        let src_dyn = src;
1299        let mut dst_dyn = dst;
1300        g2d.convert(
1301            &src_dyn,
1302            &mut dst_dyn,
1303            Rotation::None,
1304            Flip::None,
1305            Crop::no_crop(),
1306        )?;
1307        dst = {
1308            let mut __t = dst_dyn.into_u8().unwrap();
1309            __t.set_format(PixelFormat::Rgb)
1310                .map_err(|e| crate::Error::Internal(e.to_string()))?;
1311            TensorDyn::from(__t)
1312        };
1313
1314        // Copy to CPU for comparison
1315        let cpu_dst = TensorDyn::image(1280, 720, PixelFormat::Rgb, DType::U8, None)?;
1316        cpu_dst
1317            .as_u8()
1318            .unwrap()
1319            .map()?
1320            .as_mut_slice()
1321            .copy_from_slice(dst.as_u8().unwrap().map()?.as_slice());
1322
1323        compare_images(&reference, &cpu_dst, 0.98, "g2d_yuyv_to_rgb_reference")
1324    }
1325
1326    /// Test G2D native PixelFormat::Bgra conversion for all supported source formats.
1327    /// Compares G2D src→PixelFormat::Bgra against G2D src→PixelFormat::Rgba by verifying R↔B swap.
1328    #[test]
1329    #[cfg(target_os = "linux")]
1330    #[ignore = "G2D on i.MX 8MP rejects BGRA as destination format; re-enable when supported"]
1331    fn test_g2d_bgra_no_resize() {
1332        for src_fmt in [
1333            PixelFormat::Rgba,
1334            PixelFormat::Yuyv,
1335            PixelFormat::Nv12,
1336            PixelFormat::Bgra,
1337        ] {
1338            test_g2d_bgra_no_resize_(src_fmt).unwrap_or_else(|e| {
1339                panic!("{src_fmt} to PixelFormat::Bgra failed: {e:?}");
1340            });
1341        }
1342    }
1343
1344    fn test_g2d_bgra_no_resize_(g2d_in_fmt: PixelFormat) -> Result<(), crate::Error> {
1345        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
1346        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgb), None)?;
1347
1348        // Create DMA buffer for G2D input
1349        let mut src2 = TensorDyn::image(1280, 720, g2d_in_fmt, DType::U8, Some(TensorMemory::Dma))?;
1350        let mut cpu_converter = CPUProcessor::new();
1351
1352        if g2d_in_fmt == PixelFormat::Nv12 {
1353            let nv12_bytes = edgefirst_bench::testdata::read("zidane.nv12");
1354            src2.as_u8()
1355                .unwrap()
1356                .map()?
1357                .as_mut_slice()
1358                .copy_from_slice(&nv12_bytes);
1359        } else {
1360            cpu_converter.convert(&src, &mut src2, Rotation::None, Flip::None, Crop::no_crop())?;
1361        }
1362
1363        let mut g2d = G2DProcessor::new()?;
1364
1365        // Convert to PixelFormat::Bgra via G2D
1366        let mut bgra_dst = TensorDyn::image(
1367            1280,
1368            720,
1369            PixelFormat::Bgra,
1370            DType::U8,
1371            Some(TensorMemory::Dma),
1372        )?;
1373        let src2_dyn = src2;
1374        let mut bgra_dst_dyn = bgra_dst;
1375        g2d.convert(
1376            &src2_dyn,
1377            &mut bgra_dst_dyn,
1378            Rotation::None,
1379            Flip::None,
1380            Crop::no_crop(),
1381        )?;
1382        bgra_dst = {
1383            let mut __t = bgra_dst_dyn.into_u8().unwrap();
1384            __t.set_format(PixelFormat::Bgra)
1385                .map_err(|e| crate::Error::Internal(e.to_string()))?;
1386            TensorDyn::from(__t)
1387        };
1388
1389        // Reconstruct src2 from dyn for PixelFormat::Rgba conversion
1390        let src2 = {
1391            let mut __t = src2_dyn.into_u8().unwrap();
1392            __t.set_format(g2d_in_fmt)
1393                .map_err(|e| crate::Error::Internal(e.to_string()))?;
1394            TensorDyn::from(__t)
1395        };
1396
1397        // Convert to PixelFormat::Rgba via G2D as reference
1398        let mut rgba_dst = TensorDyn::image(
1399            1280,
1400            720,
1401            PixelFormat::Rgba,
1402            DType::U8,
1403            Some(TensorMemory::Dma),
1404        )?;
1405        let src2_dyn2 = src2;
1406        let mut rgba_dst_dyn = rgba_dst;
1407        g2d.convert(
1408            &src2_dyn2,
1409            &mut rgba_dst_dyn,
1410            Rotation::None,
1411            Flip::None,
1412            Crop::no_crop(),
1413        )?;
1414        rgba_dst = {
1415            let mut __t = rgba_dst_dyn.into_u8().unwrap();
1416            __t.set_format(PixelFormat::Rgba)
1417                .map_err(|e| crate::Error::Internal(e.to_string()))?;
1418            TensorDyn::from(__t)
1419        };
1420
1421        // Copy both to CPU memory for comparison
1422        let bgra_cpu = TensorDyn::image(1280, 720, PixelFormat::Bgra, DType::U8, None)?;
1423        bgra_cpu
1424            .as_u8()
1425            .unwrap()
1426            .map()?
1427            .as_mut_slice()
1428            .copy_from_slice(bgra_dst.as_u8().unwrap().map()?.as_slice());
1429
1430        let rgba_cpu = TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None)?;
1431        rgba_cpu
1432            .as_u8()
1433            .unwrap()
1434            .map()?
1435            .as_mut_slice()
1436            .copy_from_slice(rgba_dst.as_u8().unwrap().map()?.as_slice());
1437
1438        // Verify PixelFormat::Bgra output has R↔B swapped vs PixelFormat::Rgba output
1439        let bgra_map = bgra_cpu.as_u8().unwrap().map()?;
1440        let rgba_map = rgba_cpu.as_u8().unwrap().map()?;
1441        let bgra_buf = bgra_map.as_slice();
1442        let rgba_buf = rgba_map.as_slice();
1443
1444        assert_eq!(bgra_buf.len(), rgba_buf.len());
1445        for (i, (bc, rc)) in bgra_buf
1446            .chunks_exact(4)
1447            .zip(rgba_buf.chunks_exact(4))
1448            .enumerate()
1449        {
1450            assert_eq!(
1451                bc[0], rc[2],
1452                "{g2d_in_fmt} to PixelFormat::Bgra: pixel {i} B mismatch",
1453            );
1454            assert_eq!(
1455                bc[1], rc[1],
1456                "{g2d_in_fmt} to PixelFormat::Bgra: pixel {i} G mismatch",
1457            );
1458            assert_eq!(
1459                bc[2], rc[0],
1460                "{g2d_in_fmt} to PixelFormat::Bgra: pixel {i} R mismatch",
1461            );
1462            assert_eq!(
1463                bc[3], rc[3],
1464                "{g2d_in_fmt} to PixelFormat::Bgra: pixel {i} A mismatch",
1465            );
1466        }
1467        Ok(())
1468    }
1469
1470    // =========================================================================
1471    // tensor_to_g2d_surface offset & stride unit tests
1472    //
1473    // These tests verify that plane_offset and effective_row_stride are
1474    // correctly propagated into the G2DSurface.  They require DMA memory
1475    // but do NOT require G2D hardware — only the DMA_BUF_IOCTL_PHYS ioctl.
1476    // =========================================================================
1477
1478    /// Helper: build a DMA-backed Tensor<u8> with an optional plane_offset
1479    /// and an optional row stride, then return the G2DSurface.
1480    fn surface_for(
1481        width: usize,
1482        height: usize,
1483        fmt: PixelFormat,
1484        offset: Option<usize>,
1485        row_stride: Option<usize>,
1486    ) -> Result<G2DSurface, crate::Error> {
1487        use edgefirst_tensor::TensorMemory;
1488        let mut t = Tensor::<u8>::image(width, height, fmt, Some(TensorMemory::Dma))?;
1489        if let Some(o) = offset {
1490            t.set_plane_offset(o);
1491        }
1492        if let Some(s) = row_stride {
1493            t.set_row_stride_unchecked(s);
1494        }
1495        tensor_to_g2d_surface(&t)
1496    }
1497
1498    #[test]
1499    fn g2d_surface_single_plane_no_offset() {
1500        if !is_dma_available() || !is_g2d_available() {
1501            return;
1502        }
1503        let s = surface_for(640, 480, PixelFormat::Rgba, None, None).unwrap();
1504        // planes[0] must be non-zero (valid physical address), no offset
1505        assert_ne!(s.planes[0], 0);
1506        assert_eq!(s.stride, 640);
1507    }
1508
1509    #[test]
1510    fn g2d_surface_single_plane_with_offset() {
1511        if !is_dma_available() || !is_g2d_available() {
1512            return;
1513        }
1514        use edgefirst_tensor::TensorMemory;
1515        let mut t =
1516            Tensor::<u8>::image(640, 480, PixelFormat::Rgba, Some(TensorMemory::Dma)).unwrap();
1517        let s0 = tensor_to_g2d_surface(&t).unwrap();
1518        t.set_plane_offset(4096);
1519        let s1 = tensor_to_g2d_surface(&t).unwrap();
1520        assert_eq!(s1.planes[0], s0.planes[0] + 4096);
1521    }
1522
1523    #[test]
1524    fn g2d_surface_single_plane_zero_offset() {
1525        if !is_dma_available() || !is_g2d_available() {
1526            return;
1527        }
1528        use edgefirst_tensor::TensorMemory;
1529        let mut t =
1530            Tensor::<u8>::image(640, 480, PixelFormat::Rgba, Some(TensorMemory::Dma)).unwrap();
1531        let s_none = tensor_to_g2d_surface(&t).unwrap();
1532        t.set_plane_offset(0);
1533        let s_zero = tensor_to_g2d_surface(&t).unwrap();
1534        // offset=0 should produce the same address as no offset
1535        assert_eq!(s_none.planes[0], s_zero.planes[0]);
1536    }
1537
1538    #[test]
1539    fn g2d_surface_stride_rgba() {
1540        if !is_dma_available() || !is_g2d_available() {
1541            return;
1542        }
1543        // Default stride: width in pixels = 640
1544        let s_default = surface_for(640, 480, PixelFormat::Rgba, None, None).unwrap();
1545        assert_eq!(s_default.stride, 640);
1546
1547        // Custom stride: 2816 bytes / 4 channels = 704 pixels
1548        let s_custom = surface_for(640, 480, PixelFormat::Rgba, None, Some(2816)).unwrap();
1549        assert_eq!(s_custom.stride, 704);
1550    }
1551
1552    #[test]
1553    fn g2d_surface_stride_rgb() {
1554        if !is_dma_available() || !is_g2d_available() {
1555            return;
1556        }
1557        let s_default = surface_for(640, 480, PixelFormat::Rgb, None, None).unwrap();
1558        assert_eq!(s_default.stride, 640);
1559
1560        // Padded: 1980 bytes / 3 channels = 660 pixels
1561        let s_custom = surface_for(640, 480, PixelFormat::Rgb, None, Some(1980)).unwrap();
1562        assert_eq!(s_custom.stride, 660);
1563    }
1564
1565    #[test]
1566    fn g2d_surface_stride_grey() {
1567        if !is_dma_available() || !is_g2d_available() {
1568            return;
1569        }
1570        // Grey (Y800) may not be supported by all G2D hardware versions
1571        let s = match surface_for(640, 480, PixelFormat::Grey, None, Some(1024)) {
1572            Ok(s) => s,
1573            Err(crate::Error::G2D(..)) => return,
1574            Err(e) => panic!("unexpected error: {e:?}"),
1575        };
1576        // Grey: 1 channel. stride in bytes = stride in pixels
1577        assert_eq!(s.stride, 1024);
1578    }
1579
1580    #[test]
1581    fn g2d_surface_contiguous_nv12_offset() {
1582        if !is_dma_available() || !is_g2d_available() {
1583            return;
1584        }
1585        use edgefirst_tensor::TensorMemory;
1586        let mut t =
1587            Tensor::<u8>::image(640, 480, PixelFormat::Nv12, Some(TensorMemory::Dma)).unwrap();
1588        let s0 = tensor_to_g2d_surface(&t).unwrap();
1589
1590        t.set_plane_offset(8192);
1591        let s1 = tensor_to_g2d_surface(&t).unwrap();
1592
1593        // Luma plane should shift by offset
1594        assert_eq!(s1.planes[0], s0.planes[0] + 8192);
1595        // UV plane = base + offset + stride * height
1596        // Without offset: UV = base + 640 * 480 = base + 307200
1597        // With offset 8192: UV = base + 8192 + 640 * 480 = base + 315392
1598        assert_eq!(s1.planes[1], s0.planes[1] + 8192);
1599    }
1600
1601    #[test]
1602    fn g2d_surface_contiguous_nv12_stride() {
1603        if !is_dma_available() || !is_g2d_available() {
1604            return;
1605        }
1606        // NV12: 1 byte per pixel for Y. stride 640 bytes = 640 pixels.
1607        let s = surface_for(640, 480, PixelFormat::Nv12, None, None).unwrap();
1608        assert_eq!(s.stride, 640);
1609
1610        // Padded stride: 1024 bytes = 1024 pixels (NV12 channels = 1)
1611        let s_padded = surface_for(640, 480, PixelFormat::Nv12, None, Some(1024)).unwrap();
1612        assert_eq!(s_padded.stride, 1024);
1613    }
1614
1615    #[test]
1616    fn g2d_surface_multiplane_nv12_offset() {
1617        if !is_dma_available() || !is_g2d_available() {
1618            return;
1619        }
1620        use edgefirst_tensor::TensorMemory;
1621
1622        // Create luma and chroma as separate DMA tensors
1623        let mut luma =
1624            Tensor::<u8>::new(&[480, 640], Some(TensorMemory::Dma), Some("luma")).unwrap();
1625        let mut chroma =
1626            Tensor::<u8>::new(&[240, 640], Some(TensorMemory::Dma), Some("chroma")).unwrap();
1627
1628        // Get baseline physical addresses with no offsets
1629        let luma_base = {
1630            let dma = luma.as_dma().unwrap();
1631            let phys: G2DPhysical = dma.fd.as_raw_fd().try_into().unwrap();
1632            phys.address()
1633        };
1634        let chroma_base = {
1635            let dma = chroma.as_dma().unwrap();
1636            let phys: G2DPhysical = dma.fd.as_raw_fd().try_into().unwrap();
1637            phys.address()
1638        };
1639
1640        // Set offsets and build multiplane tensor
1641        luma.set_plane_offset(4096);
1642        chroma.set_plane_offset(2048);
1643        let combined = Tensor::<u8>::from_planes(luma, chroma, PixelFormat::Nv12).unwrap();
1644        let s = tensor_to_g2d_surface(&combined).unwrap();
1645
1646        // Luma should include its offset
1647        assert_eq!(s.planes[0], luma_base + 4096);
1648        // Chroma should include its offset
1649        assert_eq!(s.planes[1], chroma_base + 2048);
1650    }
1651
1652    // =========================================================================
1653    // Odd-dimension end-to-end cells (Deliverable C)
1654    //
1655    // Design contract:
1656    //   • Source is a patterned NV12 tensor (Dma) filled stride-aware with a
1657    //     pattern varying in both X and Y.  A Mem copy carries the same fill for
1658    //     the CPU reference (trusted oracle from odd_dim_cpu.rs).
1659    //   • G2D output (Dma RGBA) is compared against the CPU output pixel-by-pixel
1660    //     at each tensor's own `effective_row_stride`.
1661    //   • Tolerance: ±4 (same as GL).  G2D BT.601 is limited-range on the matrix
1662    //     but historically matches within ±4 on real hardware; the tolerance is
1663    //     tightened to ±6 only if on-target validation shows it is needed.
1664    //   • If G2D rejects the dimensions (returns Err), the test documents the
1665    //     behaviour via `assert!` rather than silently passing.
1666    //   • Both test cells skip at runtime if DMA is unavailable or G2D init fails.
1667    // =========================================================================
1668
1669    /// Fill a Nv12 tensor (any memory type) with a patterned, stride-aware
1670    /// synthetic image that varies in both X and Y.
1671    ///
1672    /// Pattern: `Y(r,c) = (r*3 + c*5) % 256`
1673    /// Chroma column `cc`, chroma row `cr_row` (NV12: 4:2:0 — half in both dims):
1674    ///   `Cb = (cc*7 + cr_row*11 + 40) % 256`
1675    ///   `Cr = (cc*13 + cr_row*3 + 80) % 256`
1676    ///
1677    /// This matches `make_odd_both_source` in `odd_dim_cpu.rs` so the two test
1678    /// suites share an analytic ground truth.
1679    #[cfg(target_os = "linux")]
1680    fn fill_patterned_nv12(t: &TensorDyn) {
1681        let w = t.width().unwrap();
1682        let h = t.height().unwrap();
1683        let stride = t.effective_row_stride().unwrap();
1684
1685        // NV12 (4:2:0): chroma is ceil(H/2) rows of W/2 pairs.
1686        let chroma_h = h.div_ceil(2);
1687        let chroma_w = w.div_ceil(2);
1688
1689        let bound = t.as_u8().unwrap();
1690        let mut m = bound.map().unwrap();
1691        let buf = m.as_mut_slice();
1692        let uv_start = stride * h;
1693
1694        // Luma: diagonal gradient.
1695        for r in 0..h {
1696            for c in 0..w {
1697                buf[r * stride + c] = ((r * 3 + c * 5) % 256) as u8;
1698            }
1699        }
1700        // Chroma: UV row pitch == stride for NV12.
1701        for cr_row in 0..chroma_h {
1702            for cc in 0..chroma_w {
1703                let cb_val = ((cc * 7 + cr_row * 11 + 40) % 256) as u8;
1704                let cr_val = ((cc * 13 + cr_row * 3 + 80) % 256) as u8;
1705                let uv_byte = uv_start + cr_row * stride + cc * 2;
1706                buf[uv_byte] = cb_val;
1707                buf[uv_byte + 1] = cr_val;
1708            }
1709        }
1710    }
1711
1712    /// Compare a G2D RGBA `u8` DMA tensor against a CPU RGBA `u8` reference,
1713    /// reading both at their real `effective_row_stride`.
1714    ///
1715    /// Returns `(max_diff, first_pixel_over_threshold)`.
1716    #[cfg(target_os = "linux")]
1717    fn compare_g2d_vs_cpu_rgba(
1718        g2d_dst: &TensorDyn,
1719        cpu_dst: &TensorDyn,
1720        w: usize,
1721        h: usize,
1722        tol: u32,
1723    ) -> (u32, Option<(usize, usize, usize)>) {
1724        let channels = 4usize; // RGBA
1725        let g2d_t = g2d_dst.as_u8().unwrap();
1726        let cpu_t = cpu_dst.as_u8().unwrap();
1727        let g2d_stride = g2d_t.effective_row_stride().unwrap_or(w * channels);
1728        let cpu_stride = cpu_t.effective_row_stride().unwrap_or(w * channels);
1729        let g2d_map = g2d_t.map().unwrap();
1730        let cpu_map = cpu_t.map().unwrap();
1731        let g2d_px = g2d_map.as_slice();
1732        let cpu_px = cpu_map.as_slice();
1733        let mut max_diff = 0u32;
1734        let mut first_fail: Option<(usize, usize, usize)> = None;
1735        for row in 0..h {
1736            for col in 0..w {
1737                for ch in 0..3usize {
1738                    // Compare RGB channels only; alpha is hardware-defined
1739                    let gi = row * g2d_stride + col * channels + ch;
1740                    let ci = row * cpu_stride + col * channels + ch;
1741                    let d = (g2d_px[gi] as i32 - cpu_px[ci] as i32).unsigned_abs();
1742                    if d > max_diff {
1743                        max_diff = d;
1744                    }
1745                    if first_fail.is_none() && d > tol {
1746                        first_fail = Some((col, row, ch));
1747                    }
1748                }
1749            }
1750        }
1751        (max_diff, first_fail)
1752    }
1753
1754    // -------------------------------------------------------------------------
1755    // D-01: NV12 odd-W (65×64) → RGBA via G2D vs CPU reference
1756    // -------------------------------------------------------------------------
1757
1758    /// D-01: NV12 odd-width (65×64) → RGBA via G2D, compared to CPU reference.
1759    ///
1760    /// Asserts:
1761    ///   (a) G2D accepts odd-width NV12 without returning an error.
1762    ///   (b) G2D output matches CPU reference within ±4 on RGB channels.
1763    ///       (Alpha is hardware-defined and excluded from the comparison.)
1764    ///
1765    /// If on-target validation shows the G2D hardware has wider tolerance for
1766    /// odd widths, the tolerance may be relaxed to ±6 with a justification
1767    /// comment — but only after observing an actual on-target failure, not
1768    /// preemptively.
1769    #[test]
1770    #[cfg(target_os = "linux")]
1771    fn d01_nv12_odd_w_g2d_vs_cpu() {
1772        if !is_dma_available() {
1773            eprintln!("SKIPPED: d01_nv12_odd_w_g2d_vs_cpu - DMA not available");
1774            return;
1775        }
1776        let (w, h) = (65usize, 64usize);
1777
1778        let src_dma =
1779            TensorDyn::image(w, h, PixelFormat::Nv12, DType::U8, Some(TensorMemory::Dma)).unwrap();
1780        let src_mem =
1781            TensorDyn::image(w, h, PixelFormat::Nv12, DType::U8, Some(TensorMemory::Mem)).unwrap();
1782        fill_patterned_nv12(&src_dma);
1783        fill_patterned_nv12(&src_mem);
1784
1785        // CPU reference: Nv12 → Rgba.
1786        let mut cpu_dst = TensorDyn::image(w, h, PixelFormat::Rgba, DType::U8, None).unwrap();
1787        CPUProcessor::new()
1788            .convert(
1789                &src_mem,
1790                &mut cpu_dst,
1791                Rotation::None,
1792                Flip::None,
1793                Crop::no_crop(),
1794            )
1795            .unwrap();
1796
1797        // G2D convert: Nv12 → Rgba.
1798        let mut g2d_dst =
1799            TensorDyn::image(w, h, PixelFormat::Rgba, DType::U8, Some(TensorMemory::Dma)).unwrap();
1800        let mut g2d = match G2DProcessor::new() {
1801            Ok(g) => g,
1802            Err(e) => {
1803                eprintln!("SKIPPED: d01_nv12_odd_w_g2d_vs_cpu - G2D not available: {e}");
1804                return;
1805            }
1806        };
1807
1808        // (a) G2D must accept odd-width NV12 without error.
1809        g2d.convert(
1810            &src_dma,
1811            &mut g2d_dst,
1812            Rotation::None,
1813            Flip::None,
1814            Crop::no_crop(),
1815        )
1816        .unwrap_or_else(|e| {
1817            panic!("D-01: G2D rejected odd-width (65) NV12 source: {e}");
1818        });
1819
1820        // (b) G2D output ≈ CPU reference ±4 on RGB channels.
1821        let tol = 4u32;
1822        let (max_diff, first_fail) = compare_g2d_vs_cpu_rgba(&g2d_dst, &cpu_dst, w, h, tol);
1823        eprintln!("D-01 NV12 odd-W G2D vs CPU: max_diff={max_diff}");
1824        // Post-WS1 both CPU and G2D resolve this untagged odd-W NV12 source to
1825        // limited-range BT.601 (G2D is limited-range matrix-only), so the
1826        // YUV-matrix delta that previously forced the loose >64 bound has
1827        // closed; the residual is G2D fixed-point rounding. Warn above the tight
1828        // ±tol, fail on >35 (was 64) so a real geometry/stride regression — the
1829        // odd-W stride handling this test guards — still trips.
1830        if max_diff > tol {
1831            eprintln!(
1832                "WARNING: D-01 NV12 odd-W G2D vs CPU max_diff={max_diff} > {tol} \
1833                 (G2D fixed-point rounding; first bad at {first_fail:?})"
1834            );
1835        }
1836        assert!(
1837            max_diff <= 35,
1838            "D-01: gross NV12 odd-W G2D vs CPU mismatch max_diff={max_diff} (>35); first bad at {first_fail:?}"
1839        );
1840    }
1841
1842    // -------------------------------------------------------------------------
1843    // D-03: NV12 odd-both (65×63) → RGBA via G2D vs CPU reference
1844    // -------------------------------------------------------------------------
1845
1846    /// D-03: NV12 odd-width AND odd-height (65×63) → RGBA via G2D vs CPU reference.
1847    ///
1848    /// This is the strictest G2D odd-dimension cell: it exercises both the
1849    /// stride-boundary at the last luma row AND the half-height chroma plane
1850    /// boundary.  If the G2D hardware rejects the conversion (e.g. hardware
1851    /// alignment requirement on chroma height), this test documents that via a
1852    /// panic rather than passing silently.
1853    #[test]
1854    #[cfg(target_os = "linux")]
1855    fn d03_nv12_odd_both_g2d_vs_cpu() {
1856        if !is_dma_available() {
1857            eprintln!("SKIPPED: d03_nv12_odd_both_g2d_vs_cpu - DMA not available");
1858            return;
1859        }
1860        let (w, h) = (65usize, 63usize);
1861
1862        let src_dma =
1863            TensorDyn::image(w, h, PixelFormat::Nv12, DType::U8, Some(TensorMemory::Dma)).unwrap();
1864        let src_mem =
1865            TensorDyn::image(w, h, PixelFormat::Nv12, DType::U8, Some(TensorMemory::Mem)).unwrap();
1866        fill_patterned_nv12(&src_dma);
1867        fill_patterned_nv12(&src_mem);
1868
1869        // CPU reference: Nv12 → Rgba.
1870        let mut cpu_dst = TensorDyn::image(w, h, PixelFormat::Rgba, DType::U8, None).unwrap();
1871        CPUProcessor::new()
1872            .convert(
1873                &src_mem,
1874                &mut cpu_dst,
1875                Rotation::None,
1876                Flip::None,
1877                Crop::no_crop(),
1878            )
1879            .unwrap();
1880
1881        // G2D convert: Nv12 → Rgba.
1882        let mut g2d_dst =
1883            TensorDyn::image(w, h, PixelFormat::Rgba, DType::U8, Some(TensorMemory::Dma)).unwrap();
1884        let mut g2d = match G2DProcessor::new() {
1885            Ok(g) => g,
1886            Err(e) => {
1887                eprintln!("SKIPPED: d03_nv12_odd_both_g2d_vs_cpu - G2D not available: {e}");
1888                return;
1889            }
1890        };
1891
1892        // G2D must accept odd-both NV12 without error (documented behaviour).
1893        // If hardware rejects it, the panic message is the intended failure report.
1894        g2d.convert(
1895            &src_dma,
1896            &mut g2d_dst,
1897            Rotation::None,
1898            Flip::None,
1899            Crop::no_crop(),
1900        )
1901        .unwrap_or_else(|e| {
1902            panic!("D-03: G2D rejected odd-both (65×63) NV12 source: {e}");
1903        });
1904
1905        let tol = 4u32;
1906        let (max_diff, first_fail) = compare_g2d_vs_cpu_rgba(&g2d_dst, &cpu_dst, w, h, tol);
1907        eprintln!("D-03 NV12 odd-both G2D vs CPU: max_diff={max_diff}");
1908        // Post-WS1 the YUV-matrix delta has closed — see test_d01 above. Warn
1909        // above ±tol on G2D fixed-point rounding, fail only on a gross >35
1910        // geometry/stride regression (was 64).
1911        if max_diff > tol {
1912            eprintln!(
1913                "WARNING: D-03 NV12 odd-both G2D vs CPU max_diff={max_diff} > {tol} \
1914                 (G2D fixed-point rounding; first bad at {first_fail:?})"
1915            );
1916        }
1917        assert!(
1918            max_diff <= 35,
1919            "D-03: gross NV12 odd-both G2D vs CPU mismatch max_diff={max_diff} (>35); first bad at {first_fail:?}"
1920        );
1921    }
1922}