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    TensorMemory, 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_typed::<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_typed_mut::<i8>().unwrap();
209            unsafe { &mut *(i8_tensor as *mut Tensor<i8> as *mut Tensor<u8>) }
210        } else {
211            dst_dyn.as_typed_mut::<u8>().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_typed_mut::<u8>().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    // A capability probe, so it asks the backend-agnostic question.
460    // `as_dma()` answers `None` for EVERY tensor on the `dynamic` backend
461    // -- `DmaTensor<T>` is `static`-internal storage it never constructs --
462    // so this used to decline G2D unconditionally there, silently giving up
463    // the i.MX hardware path on the exact tensors it was written for. See
464    // task P2b's review, F3.
465    if TensorTrait::memory(dst) != TensorMemory::DmaBuf {
466        return Err(Error::NotImplemented(
467            "g2d only supports Dma memory".to_string(),
468        ));
469    }
470
471    let mut dst_surface = tensor_to_g2d_surface(dst)?;
472
473    match background {
474        None => {
475            // Case 1 — no background, no detections: hardware clear to
476            // transparent black. Every byte of dst is written by the 2D
477            // engine; no reliance on prior state.
478            let start = Instant::now();
479            g2d.clear(&mut dst_surface, [0, 0, 0, 0])?;
480            g2d.finish()?;
481            log::trace!("g2d clear (empty frame) takes {:?}", start.elapsed());
482        }
483        Some(bg_dyn) => {
484            // Case 2 — background, no detections: hardware blit bg → dst.
485            // Validate shape/format equivalence; if the caller handed us
486            // mismatched surfaces we return a structured error rather than
487            // silently producing wrong output.
488            if bg_dyn.shape() != dst.shape() {
489                return Err(Error::InvalidShape(
490                    "background shape does not match dst".into(),
491                ));
492            }
493            if bg_dyn.format() != dst.format() {
494                return Err(Error::InvalidShape(
495                    "background pixel format does not match dst".into(),
496                ));
497            }
498            if bg_dyn.dtype() != DType::U8 {
499                return Err(Error::NotSupported(
500                    "G2D only supports u8 background tensors".to_string(),
501                ));
502            }
503            let bg = bg_dyn.as_typed::<u8>().ok_or(Error::NotAnImage)?;
504            // Backend-agnostic probe -- see the `dst` check above.
505            if TensorTrait::memory(bg) != TensorMemory::DmaBuf {
506                return Err(Error::NotImplemented(
507                    "g2d background must be Dma-backed".to_string(),
508                ));
509            }
510            let src_surface = tensor_to_g2d_surface(bg)?;
511            let start = Instant::now();
512            g2d.blit(&src_surface, &dst_surface)?;
513            g2d.finish()?;
514            log::trace!("g2d blit (bg→dst) takes {:?}", start.elapsed());
515        }
516    }
517    Ok(())
518}
519
520/// Chroma subsampling of `fmt` as `(shift_x, shift_y)` — 1 = half resolution on
521/// that axis, 0 = full. Drives the i.MX95 DPU even-dimension workaround: a blit
522/// dimension that the format subsamples must be even or the DPU garbles it.
523fn chroma_subsample_shifts(fmt: PixelFormat) -> (u32, u32) {
524    match fmt.chroma_layout() {
525        Some(cl) => (cl.shift_x, cl.shift_y),
526        // Packed 4:2:2 (YUYV / VYUY) subsample horizontally; all other packed
527        // formats (RGB/RGBA/BGRA/Grey) are full-resolution.
528        None if matches!(fmt, PixelFormat::Yuyv | PixelFormat::Vyuy) => (1, 0),
529        None => (0, 0),
530    }
531}
532
533/// Build a `G2DSurface` from a `Tensor<u8>` that carries pixel-format metadata.
534///
535/// The tensor must be backed by DMA memory and must have a pixel format set.
536fn tensor_to_g2d_surface(img: &Tensor<u8>) -> Result<G2DSurface> {
537    let fmt = img.format().ok_or(Error::NotAnImage)?;
538    // `dmabuf()`, not `as_dma().fd`: same file descriptor, but reachable on
539    // both backends. See the probe above.
540    let fd = img
541        .dmabuf()
542        .map_err(|e| Error::NotImplemented(format!("g2d only supports Dma memory ({e})")))?;
543    let phys: G2DPhysical = fd.as_raw_fd().try_into()?;
544
545    // NV12 is a two-plane format: Y plane followed by interleaved UV plane.
546    // planes[0] = Y plane start, planes[1] = UV plane start (Y size = width * height)
547    //
548    // plane_offset is the byte offset within the DMA-BUF where pixel data
549    // starts.  G2D works with raw physical addresses so we must add the
550    // offset ourselves — the hardware has no concept of a per-plane offset.
551    let base_addr = phys.address();
552    let luma_offset = img.plane_offset().unwrap_or(0) as u64;
553    let planes = if fmt == PixelFormat::Nv12 {
554        if img.is_multiplane() {
555            // Multiplane: UV in separate DMA-BUF, get its physical address
556            let chroma = img.chroma().unwrap();
557            let chroma_fd = chroma.dmabuf().map_err(|e| {
558                Error::NotImplemented(format!("g2d multiplane chroma must be DMA-backed ({e})"))
559            })?;
560            let uv_phys: G2DPhysical = chroma_fd.as_raw_fd().try_into()?;
561            let chroma_offset = img.chroma().and_then(|c| c.plane_offset()).unwrap_or(0) as u64;
562            [
563                base_addr + luma_offset,
564                uv_phys.address() + chroma_offset,
565                0,
566            ]
567        } else {
568            let w = img.width().unwrap();
569            let h = img.height().unwrap();
570            let stride = img.effective_row_stride().unwrap_or(w.next_multiple_of(2));
571            let uv_offset = (luma_offset as usize + stride * h) as u64;
572            [base_addr + luma_offset, base_addr + uv_offset, 0]
573        }
574    } else {
575        [base_addr + luma_offset, 0, 0]
576    };
577
578    let w = img.width().unwrap();
579    let h = img.height().unwrap();
580    let fourcc = pixelfmt_to_fourcc(fmt);
581
582    // G2D stride is in pixels.  effective_row_stride() returns bytes, so
583    // divide by the bytes-per-pixel (channels for u8 data) to convert.
584    let stride_pixels = match img.effective_row_stride() {
585        Some(s) => {
586            let channels = fmt.channels();
587            if s % channels != 0 {
588                return Err(Error::NotImplemented(
589                    "g2d requires row stride to be a multiple of bytes-per-pixel".to_string(),
590                ));
591            }
592            s / channels
593        }
594        None => w,
595    };
596
597    Ok(G2DSurface {
598        planes,
599        format: G2DFormat::try_from(fourcc)?.format(),
600        left: 0,
601        top: 0,
602        right: w as i32,
603        bottom: h as i32,
604        stride: stride_pixels as i32,
605        width: w as i32,
606        height: h as i32,
607        blendfunc: 0,
608        clrcolor: 0,
609        rot: 0,
610        global_alpha: 0,
611    })
612}
613
614#[cfg(test)]
615mod g2d_predicate_tests {
616    use super::*;
617    use edgefirst_tensor::{ColorEncoding, ColorRange, Colorimetry};
618
619    #[test]
620    fn g2d_declines_full_range_and_bt2020_yuv() {
621        let full = Colorimetry::default()
622            .with_range(ColorRange::Full)
623            .with_encoding(ColorEncoding::Bt709);
624        let lim = Colorimetry::default()
625            .with_range(ColorRange::Limited)
626            .with_encoding(ColorEncoding::Bt709);
627        let bt2020 = Colorimetry::default()
628            .with_range(ColorRange::Limited)
629            .with_encoding(ColorEncoding::Bt2020);
630        assert!(!g2d_can_handle(&full, true)); // full-range YUV → decline
631        assert!(g2d_can_handle(&lim, true)); // limited 709 YUV → ok
632        assert!(!g2d_can_handle(&bt2020, true)); // BT.2020 → decline
633        assert!(g2d_can_handle(&full, false)); // no YUV side → rule N/A → ok
634    }
635}
636
637#[cfg(feature = "g2d_test_formats")]
638#[cfg(test)]
639#[allow(deprecated)]
640mod g2d_tests {
641    use super::*;
642    use crate::{CPUProcessor, Flip, G2DProcessor, ImageProcessorTrait, Rotation};
643    use edgefirst_tensor::{
644        is_dma_available, DType, PixelFormat, TensorDyn, TensorMapTrait, TensorMemory, TensorTrait,
645    };
646    use image::buffer::ConvertBuffer;
647
648    /// G2D is usable only where `libg2d.so.2` loads — the NXP i.MX boards. On
649    /// V3D/Tegra/x86 the library is absent, so `G2DProcessor::new()` and the
650    /// surface physical-address resolution these tests exercise both fail. DMA
651    /// being available does NOT imply G2D (e.g. rpi5/V3D has DMA but no G2D), so
652    /// these tests must gate on G2D, not just DMA, or they panic/fail on
653    /// non-G2D hardware instead of skipping.
654    #[cfg(target_os = "linux")]
655    fn is_g2d_available() -> bool {
656        G2DProcessor::new().is_ok()
657    }
658
659    #[test]
660    #[cfg(target_os = "linux")]
661    fn test_g2d_formats_no_resize() {
662        for i in [
663            PixelFormat::Rgba,
664            PixelFormat::Yuyv,
665            PixelFormat::Rgb,
666            PixelFormat::Grey,
667            PixelFormat::Nv12,
668        ] {
669            for o in [
670                PixelFormat::Rgba,
671                PixelFormat::Yuyv,
672                PixelFormat::Rgb,
673                PixelFormat::Grey,
674            ] {
675                let res = test_g2d_format_no_resize_(i, o);
676                if let Err(e) = res {
677                    println!("{i} to {o} failed: {e:?}");
678                } else {
679                    println!("{i} to {o} success");
680                }
681            }
682        }
683    }
684
685    fn test_g2d_format_no_resize_(
686        g2d_in_fmt: PixelFormat,
687        g2d_out_fmt: PixelFormat,
688    ) -> Result<(), crate::Error> {
689        let dst_width = 1280;
690        let dst_height = 720;
691        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
692        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgb), None)?;
693
694        // Create DMA buffer for G2D input
695        let mut src2 = TensorDyn::image(
696            1280,
697            720,
698            g2d_in_fmt,
699            DType::U8,
700            Some(TensorMemory::DmaBuf),
701            edgefirst_tensor::CpuAccess::ReadWrite,
702        )?;
703
704        let mut cpu_converter = CPUProcessor::new();
705
706        // For PixelFormat::Nv12 input, load from file since CPU doesn't support PixelFormat::Rgb→PixelFormat::Nv12
707        if g2d_in_fmt == PixelFormat::Nv12 {
708            let nv12_bytes = edgefirst_bench::testdata::read("zidane.nv12");
709            src2.as_typed::<u8>()
710                .unwrap()
711                .map()?
712                .as_mut_slice()
713                .copy_from_slice(&nv12_bytes);
714        } else {
715            cpu_converter.convert(&src, &mut src2, Rotation::None, Flip::None, Crop::no_crop())?;
716        }
717
718        let mut g2d_dst = TensorDyn::image(
719            dst_width,
720            dst_height,
721            g2d_out_fmt,
722            DType::U8,
723            Some(TensorMemory::DmaBuf),
724            edgefirst_tensor::CpuAccess::ReadWrite,
725        )?;
726        let mut g2d_converter = G2DProcessor::new()?;
727        let src2_dyn = src2;
728        let mut g2d_dst_dyn = g2d_dst;
729        g2d_converter.convert(
730            &src2_dyn,
731            &mut g2d_dst_dyn,
732            Rotation::None,
733            Flip::None,
734            Crop::no_crop(),
735        )?;
736        g2d_dst = {
737            let mut __t = g2d_dst_dyn.into_u8().unwrap();
738            __t.set_format(g2d_out_fmt)
739                .map_err(|e| crate::Error::Internal(e.to_string()))?;
740            TensorDyn::from(__t)
741        };
742
743        let mut cpu_dst = TensorDyn::image(
744            dst_width,
745            dst_height,
746            PixelFormat::Rgb,
747            DType::U8,
748            None,
749            edgefirst_tensor::CpuAccess::ReadWrite,
750        )?;
751        cpu_converter.convert(
752            &g2d_dst,
753            &mut cpu_dst,
754            Rotation::None,
755            Flip::None,
756            Crop::no_crop(),
757        )?;
758
759        compare_images(
760            &src,
761            &cpu_dst,
762            0.98,
763            &format!("{g2d_in_fmt}_to_{g2d_out_fmt}"),
764        )
765    }
766
767    #[test]
768    #[cfg(target_os = "linux")]
769    fn test_g2d_formats_with_resize() {
770        for i in [
771            PixelFormat::Rgba,
772            PixelFormat::Yuyv,
773            PixelFormat::Rgb,
774            PixelFormat::Grey,
775            PixelFormat::Nv12,
776        ] {
777            for o in [
778                PixelFormat::Rgba,
779                PixelFormat::Yuyv,
780                PixelFormat::Rgb,
781                PixelFormat::Grey,
782            ] {
783                let res = test_g2d_format_with_resize_(i, o);
784                if let Err(e) = res {
785                    println!("{i} to {o} failed: {e:?}");
786                } else {
787                    println!("{i} to {o} success");
788                }
789            }
790        }
791    }
792
793    #[test]
794    #[cfg(target_os = "linux")]
795    fn test_g2d_formats_with_resize_dst_crop() {
796        for i in [
797            PixelFormat::Rgba,
798            PixelFormat::Yuyv,
799            PixelFormat::Rgb,
800            PixelFormat::Grey,
801            PixelFormat::Nv12,
802        ] {
803            for o in [
804                PixelFormat::Rgba,
805                PixelFormat::Yuyv,
806                PixelFormat::Rgb,
807                PixelFormat::Grey,
808            ] {
809                let res = test_g2d_format_with_resize_dst_crop(i, o);
810                if let Err(e) = res {
811                    println!("{i} to {o} failed: {e:?}");
812                } else {
813                    println!("{i} to {o} success");
814                }
815            }
816        }
817    }
818
819    fn test_g2d_format_with_resize_(
820        g2d_in_fmt: PixelFormat,
821        g2d_out_fmt: PixelFormat,
822    ) -> Result<(), crate::Error> {
823        let dst_width = 600;
824        let dst_height = 400;
825        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
826        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgb), None)?;
827
828        let mut cpu_converter = CPUProcessor::new();
829
830        let mut reference = TensorDyn::image(
831            dst_width,
832            dst_height,
833            PixelFormat::Rgb,
834            DType::U8,
835            Some(TensorMemory::DmaBuf),
836            edgefirst_tensor::CpuAccess::ReadWrite,
837        )?;
838        cpu_converter.convert(
839            &src,
840            &mut reference,
841            Rotation::None,
842            Flip::None,
843            Crop::no_crop(),
844        )?;
845
846        // Create DMA buffer for G2D input
847        let mut src2 = TensorDyn::image(
848            1280,
849            720,
850            g2d_in_fmt,
851            DType::U8,
852            Some(TensorMemory::DmaBuf),
853            edgefirst_tensor::CpuAccess::ReadWrite,
854        )?;
855
856        // For PixelFormat::Nv12 input, load from file since CPU doesn't support PixelFormat::Rgb→PixelFormat::Nv12
857        if g2d_in_fmt == PixelFormat::Nv12 {
858            let nv12_bytes = edgefirst_bench::testdata::read("zidane.nv12");
859            src2.as_typed::<u8>()
860                .unwrap()
861                .map()?
862                .as_mut_slice()
863                .copy_from_slice(&nv12_bytes);
864        } else {
865            cpu_converter.convert(&src, &mut src2, Rotation::None, Flip::None, Crop::no_crop())?;
866        }
867
868        let mut g2d_dst = TensorDyn::image(
869            dst_width,
870            dst_height,
871            g2d_out_fmt,
872            DType::U8,
873            Some(TensorMemory::DmaBuf),
874            edgefirst_tensor::CpuAccess::ReadWrite,
875        )?;
876        let mut g2d_converter = G2DProcessor::new()?;
877        let src2_dyn = src2;
878        let mut g2d_dst_dyn = g2d_dst;
879        g2d_converter.convert(
880            &src2_dyn,
881            &mut g2d_dst_dyn,
882            Rotation::None,
883            Flip::None,
884            Crop::no_crop(),
885        )?;
886        g2d_dst = {
887            let mut __t = g2d_dst_dyn.into_u8().unwrap();
888            __t.set_format(g2d_out_fmt)
889                .map_err(|e| crate::Error::Internal(e.to_string()))?;
890            TensorDyn::from(__t)
891        };
892
893        let mut cpu_dst = TensorDyn::image(
894            dst_width,
895            dst_height,
896            PixelFormat::Rgb,
897            DType::U8,
898            None,
899            edgefirst_tensor::CpuAccess::ReadWrite,
900        )?;
901        cpu_converter.convert(
902            &g2d_dst,
903            &mut cpu_dst,
904            Rotation::None,
905            Flip::None,
906            Crop::no_crop(),
907        )?;
908
909        compare_images(
910            &reference,
911            &cpu_dst,
912            0.98,
913            &format!("{g2d_in_fmt}_to_{g2d_out_fmt}_resized"),
914        )
915    }
916
917    fn test_g2d_format_with_resize_dst_crop(
918        g2d_in_fmt: PixelFormat,
919        g2d_out_fmt: PixelFormat,
920    ) -> Result<(), crate::Error> {
921        let dst_width = 600;
922        let dst_height = 400;
923        // The destination sub-rectangle is now expressed as a `view()` of the
924        // destination (the old `Crop.dst_rect` was removed); G2D blits into the
925        // view's window via its offset + parent stride. Region is (x=left, y=top,
926        // width, height).
927        let region = crate::Region::new(100, 100, 200, 100);
928        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
929        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgb), None)?;
930
931        let mut cpu_converter = CPUProcessor::new();
932
933        let reference = TensorDyn::image(
934            dst_width,
935            dst_height,
936            PixelFormat::Rgb,
937            DType::U8,
938            Some(TensorMemory::DmaBuf),
939            edgefirst_tensor::CpuAccess::ReadWrite,
940        )?;
941        reference
942            .as_typed::<u8>()
943            .unwrap()
944            .map()
945            .unwrap()
946            .as_mut_slice()
947            .fill(128);
948        cpu_converter.convert(
949            &src,
950            &mut reference.view(region)?,
951            Rotation::None,
952            Flip::None,
953            Crop::no_crop(),
954        )?;
955
956        // Create DMA buffer for G2D input
957        let mut src2 = TensorDyn::image(
958            1280,
959            720,
960            g2d_in_fmt,
961            DType::U8,
962            Some(TensorMemory::DmaBuf),
963            edgefirst_tensor::CpuAccess::ReadWrite,
964        )?;
965
966        // For PixelFormat::Nv12 input, load from file since CPU doesn't support PixelFormat::Rgb→PixelFormat::Nv12
967        if g2d_in_fmt == PixelFormat::Nv12 {
968            let nv12_bytes = edgefirst_bench::testdata::read("zidane.nv12");
969            src2.as_typed::<u8>()
970                .unwrap()
971                .map()?
972                .as_mut_slice()
973                .copy_from_slice(&nv12_bytes);
974        } else {
975            cpu_converter.convert(&src, &mut src2, Rotation::None, Flip::None, Crop::no_crop())?;
976        }
977
978        let mut g2d_dst = TensorDyn::image(
979            dst_width,
980            dst_height,
981            g2d_out_fmt,
982            DType::U8,
983            Some(TensorMemory::DmaBuf),
984            edgefirst_tensor::CpuAccess::ReadWrite,
985        )?;
986        g2d_dst
987            .as_typed::<u8>()
988            .unwrap()
989            .map()
990            .unwrap()
991            .as_mut_slice()
992            .fill(128);
993        let mut g2d_converter = G2DProcessor::new()?;
994        let src2_dyn = src2;
995        let g2d_dst_dyn = g2d_dst;
996        g2d_converter.convert(
997            &src2_dyn,
998            &mut g2d_dst_dyn.view(region)?,
999            Rotation::None,
1000            Flip::None,
1001            Crop::no_crop(),
1002        )?;
1003        g2d_dst = {
1004            let mut __t = g2d_dst_dyn.into_u8().unwrap();
1005            __t.set_format(g2d_out_fmt)
1006                .map_err(|e| crate::Error::Internal(e.to_string()))?;
1007            TensorDyn::from(__t)
1008        };
1009
1010        let mut cpu_dst = TensorDyn::image(
1011            dst_width,
1012            dst_height,
1013            PixelFormat::Rgb,
1014            DType::U8,
1015            None,
1016            edgefirst_tensor::CpuAccess::ReadWrite,
1017        )?;
1018        cpu_converter.convert(
1019            &g2d_dst,
1020            &mut cpu_dst,
1021            Rotation::None,
1022            Flip::None,
1023            Crop::no_crop(),
1024        )?;
1025
1026        compare_images(
1027            &reference,
1028            &cpu_dst,
1029            0.98,
1030            &format!("{g2d_in_fmt}_to_{g2d_out_fmt}_resized_dst_crop"),
1031        )
1032    }
1033
1034    fn compare_images(
1035        img1: &TensorDyn,
1036        img2: &TensorDyn,
1037        threshold: f64,
1038        name: &str,
1039    ) -> Result<(), crate::Error> {
1040        assert_eq!(img1.height(), img2.height(), "Heights differ");
1041        assert_eq!(img1.width(), img2.width(), "Widths differ");
1042        assert_eq!(
1043            img1.format().unwrap(),
1044            img2.format().unwrap(),
1045            "PixelFormat differ"
1046        );
1047        assert!(
1048            matches!(img1.format().unwrap(), PixelFormat::Rgb | PixelFormat::Rgba),
1049            "format must be Rgb or Rgba for comparison"
1050        );
1051        let image1 = match img1.format().unwrap() {
1052            PixelFormat::Rgb => image::RgbImage::from_vec(
1053                img1.width().unwrap() as u32,
1054                img1.height().unwrap() as u32,
1055                img1.as_typed::<u8>().unwrap().map().unwrap().to_vec(),
1056            )
1057            .unwrap(),
1058            PixelFormat::Rgba => image::RgbaImage::from_vec(
1059                img1.width().unwrap() as u32,
1060                img1.height().unwrap() as u32,
1061                img1.as_typed::<u8>().unwrap().map().unwrap().to_vec(),
1062            )
1063            .unwrap()
1064            .convert(),
1065
1066            _ => unreachable!(),
1067        };
1068
1069        let image2 = match img2.format().unwrap() {
1070            PixelFormat::Rgb => image::RgbImage::from_vec(
1071                img2.width().unwrap() as u32,
1072                img2.height().unwrap() as u32,
1073                img2.as_typed::<u8>().unwrap().map().unwrap().to_vec(),
1074            )
1075            .unwrap(),
1076            PixelFormat::Rgba => image::RgbaImage::from_vec(
1077                img2.width().unwrap() as u32,
1078                img2.height().unwrap() as u32,
1079                img2.as_typed::<u8>().unwrap().map().unwrap().to_vec(),
1080            )
1081            .unwrap()
1082            .convert(),
1083
1084            _ => unreachable!(),
1085        };
1086
1087        let similarity = image_compare::rgb_similarity_structure(
1088            &image_compare::Algorithm::RootMeanSquared,
1089            &image1,
1090            &image2,
1091        )
1092        .expect("Image Comparison failed");
1093
1094        if similarity.score < threshold {
1095            image1.save(format!("{name}_1.png")).unwrap();
1096            image2.save(format!("{name}_2.png")).unwrap();
1097            return Err(Error::Internal(format!(
1098                "{name}: converted image and target image have similarity score too low: {} < {}",
1099                similarity.score, threshold
1100            )));
1101        }
1102        Ok(())
1103    }
1104
1105    // =========================================================================
1106    // PixelFormat::Nv12 Reference Validation Tests
1107    // These tests compare G2D PixelFormat::Nv12 conversions against ffmpeg-generated references
1108    // =========================================================================
1109
1110    fn load_raw_image(
1111        width: usize,
1112        height: usize,
1113        format: PixelFormat,
1114        memory: Option<TensorMemory>,
1115        bytes: &[u8],
1116    ) -> Result<TensorDyn, crate::Error> {
1117        let img = TensorDyn::image(
1118            width,
1119            height,
1120            format,
1121            DType::U8,
1122            memory,
1123            edgefirst_tensor::CpuAccess::ReadWrite,
1124        )?;
1125        let mut map = img.as_typed::<u8>().unwrap().map()?;
1126        let dst = map.as_mut_slice();
1127        if bytes.len() > dst.len() {
1128            return Err(crate::Error::InvalidShape(format!(
1129                "load_raw_image: {} input bytes exceed {}-byte image buffer",
1130                bytes.len(),
1131                dst.len()
1132            )));
1133        }
1134        dst[..bytes.len()].copy_from_slice(bytes);
1135        Ok(img)
1136    }
1137
1138    /// Test G2D PixelFormat::Nv12→PixelFormat::Rgba conversion against ffmpeg reference
1139    #[test]
1140    #[cfg(target_os = "linux")]
1141    fn test_g2d_nv12_to_rgba_reference() -> Result<(), crate::Error> {
1142        if !is_dma_available() || !is_g2d_available() {
1143            return Ok(());
1144        }
1145        // Load PixelFormat::Nv12 source
1146        let src = load_raw_image(
1147            1280,
1148            720,
1149            PixelFormat::Nv12,
1150            Some(TensorMemory::DmaBuf),
1151            &edgefirst_bench::testdata::read("camera720p.nv12"),
1152        )?;
1153
1154        // Load PixelFormat::Rgba reference (ffmpeg-generated)
1155        let reference = load_raw_image(
1156            1280,
1157            720,
1158            PixelFormat::Rgba,
1159            None,
1160            &edgefirst_bench::testdata::read("camera720p.rgba"),
1161        )?;
1162
1163        // Convert using G2D
1164        let mut dst = TensorDyn::image(
1165            1280,
1166            720,
1167            PixelFormat::Rgba,
1168            DType::U8,
1169            Some(TensorMemory::DmaBuf),
1170            edgefirst_tensor::CpuAccess::ReadWrite,
1171        )?;
1172        let mut g2d = G2DProcessor::new()?;
1173        let src_dyn = src;
1174        let mut dst_dyn = dst;
1175        g2d.convert(
1176            &src_dyn,
1177            &mut dst_dyn,
1178            Rotation::None,
1179            Flip::None,
1180            Crop::no_crop(),
1181        )?;
1182        dst = {
1183            let mut __t = dst_dyn.into_u8().unwrap();
1184            __t.set_format(PixelFormat::Rgba)
1185                .map_err(|e| crate::Error::Internal(e.to_string()))?;
1186            TensorDyn::from(__t)
1187        };
1188
1189        // Copy to CPU for comparison
1190        let cpu_dst = TensorDyn::image(
1191            1280,
1192            720,
1193            PixelFormat::Rgba,
1194            DType::U8,
1195            None,
1196            edgefirst_tensor::CpuAccess::ReadWrite,
1197        )?;
1198        cpu_dst
1199            .as_typed::<u8>()
1200            .unwrap()
1201            .map()?
1202            .as_mut_slice()
1203            .copy_from_slice(dst.as_typed::<u8>().unwrap().map()?.as_slice());
1204
1205        compare_images(&reference, &cpu_dst, 0.98, "g2d_nv12_to_rgba_reference")
1206    }
1207
1208    /// Test G2D PixelFormat::Nv12→PixelFormat::Rgb conversion against ffmpeg reference
1209    #[test]
1210    #[cfg(target_os = "linux")]
1211    fn test_g2d_nv12_to_rgb_reference() -> Result<(), crate::Error> {
1212        if !is_dma_available() || !is_g2d_available() {
1213            return Ok(());
1214        }
1215        // Load PixelFormat::Nv12 source
1216        let src = load_raw_image(
1217            1280,
1218            720,
1219            PixelFormat::Nv12,
1220            Some(TensorMemory::DmaBuf),
1221            &edgefirst_bench::testdata::read("camera720p.nv12"),
1222        )?;
1223
1224        // Load PixelFormat::Rgb reference (ffmpeg-generated)
1225        let reference = load_raw_image(
1226            1280,
1227            720,
1228            PixelFormat::Rgb,
1229            None,
1230            &edgefirst_bench::testdata::read("camera720p.rgb"),
1231        )?;
1232
1233        // Convert using G2D
1234        let mut dst = TensorDyn::image(
1235            1280,
1236            720,
1237            PixelFormat::Rgb,
1238            DType::U8,
1239            Some(TensorMemory::DmaBuf),
1240            edgefirst_tensor::CpuAccess::ReadWrite,
1241        )?;
1242        let mut g2d = G2DProcessor::new()?;
1243        let src_dyn = src;
1244        let mut dst_dyn = dst;
1245        g2d.convert(
1246            &src_dyn,
1247            &mut dst_dyn,
1248            Rotation::None,
1249            Flip::None,
1250            Crop::no_crop(),
1251        )?;
1252        dst = {
1253            let mut __t = dst_dyn.into_u8().unwrap();
1254            __t.set_format(PixelFormat::Rgb)
1255                .map_err(|e| crate::Error::Internal(e.to_string()))?;
1256            TensorDyn::from(__t)
1257        };
1258
1259        // Copy to CPU for comparison
1260        let cpu_dst = TensorDyn::image(
1261            1280,
1262            720,
1263            PixelFormat::Rgb,
1264            DType::U8,
1265            None,
1266            edgefirst_tensor::CpuAccess::ReadWrite,
1267        )?;
1268        cpu_dst
1269            .as_typed::<u8>()
1270            .unwrap()
1271            .map()?
1272            .as_mut_slice()
1273            .copy_from_slice(dst.as_typed::<u8>().unwrap().map()?.as_slice());
1274
1275        compare_images(&reference, &cpu_dst, 0.98, "g2d_nv12_to_rgb_reference")
1276    }
1277
1278    /// Test G2D PixelFormat::Yuyv→PixelFormat::Rgba conversion against ffmpeg reference
1279    #[test]
1280    #[cfg(target_os = "linux")]
1281    fn test_g2d_yuyv_to_rgba_reference() -> Result<(), crate::Error> {
1282        if !is_dma_available() || !is_g2d_available() {
1283            return Ok(());
1284        }
1285        // Load PixelFormat::Yuyv source
1286        let src = load_raw_image(
1287            1280,
1288            720,
1289            PixelFormat::Yuyv,
1290            Some(TensorMemory::DmaBuf),
1291            &edgefirst_bench::testdata::read("camera720p.yuyv"),
1292        )?;
1293
1294        // Load PixelFormat::Rgba reference (ffmpeg-generated)
1295        let reference = load_raw_image(
1296            1280,
1297            720,
1298            PixelFormat::Rgba,
1299            None,
1300            &edgefirst_bench::testdata::read("camera720p.rgba"),
1301        )?;
1302
1303        // Convert using G2D
1304        let mut dst = TensorDyn::image(
1305            1280,
1306            720,
1307            PixelFormat::Rgba,
1308            DType::U8,
1309            Some(TensorMemory::DmaBuf),
1310            edgefirst_tensor::CpuAccess::ReadWrite,
1311        )?;
1312        let mut g2d = G2DProcessor::new()?;
1313        let src_dyn = src;
1314        let mut dst_dyn = dst;
1315        g2d.convert(
1316            &src_dyn,
1317            &mut dst_dyn,
1318            Rotation::None,
1319            Flip::None,
1320            Crop::no_crop(),
1321        )?;
1322        dst = {
1323            let mut __t = dst_dyn.into_u8().unwrap();
1324            __t.set_format(PixelFormat::Rgba)
1325                .map_err(|e| crate::Error::Internal(e.to_string()))?;
1326            TensorDyn::from(__t)
1327        };
1328
1329        // Copy to CPU for comparison
1330        let cpu_dst = TensorDyn::image(
1331            1280,
1332            720,
1333            PixelFormat::Rgba,
1334            DType::U8,
1335            None,
1336            edgefirst_tensor::CpuAccess::ReadWrite,
1337        )?;
1338        cpu_dst
1339            .as_typed::<u8>()
1340            .unwrap()
1341            .map()?
1342            .as_mut_slice()
1343            .copy_from_slice(dst.as_typed::<u8>().unwrap().map()?.as_slice());
1344
1345        compare_images(&reference, &cpu_dst, 0.98, "g2d_yuyv_to_rgba_reference")
1346    }
1347
1348    /// Test G2D PixelFormat::Yuyv→PixelFormat::Rgb conversion against ffmpeg reference
1349    #[test]
1350    #[cfg(target_os = "linux")]
1351    fn test_g2d_yuyv_to_rgb_reference() -> Result<(), crate::Error> {
1352        if !is_dma_available() || !is_g2d_available() {
1353            return Ok(());
1354        }
1355        // Load PixelFormat::Yuyv source
1356        let src = load_raw_image(
1357            1280,
1358            720,
1359            PixelFormat::Yuyv,
1360            Some(TensorMemory::DmaBuf),
1361            &edgefirst_bench::testdata::read("camera720p.yuyv"),
1362        )?;
1363
1364        // Load PixelFormat::Rgb reference (ffmpeg-generated)
1365        let reference = load_raw_image(
1366            1280,
1367            720,
1368            PixelFormat::Rgb,
1369            None,
1370            &edgefirst_bench::testdata::read("camera720p.rgb"),
1371        )?;
1372
1373        // Convert using G2D
1374        let mut dst = TensorDyn::image(
1375            1280,
1376            720,
1377            PixelFormat::Rgb,
1378            DType::U8,
1379            Some(TensorMemory::DmaBuf),
1380            edgefirst_tensor::CpuAccess::ReadWrite,
1381        )?;
1382        let mut g2d = G2DProcessor::new()?;
1383        let src_dyn = src;
1384        let mut dst_dyn = dst;
1385        g2d.convert(
1386            &src_dyn,
1387            &mut dst_dyn,
1388            Rotation::None,
1389            Flip::None,
1390            Crop::no_crop(),
1391        )?;
1392        dst = {
1393            let mut __t = dst_dyn.into_u8().unwrap();
1394            __t.set_format(PixelFormat::Rgb)
1395                .map_err(|e| crate::Error::Internal(e.to_string()))?;
1396            TensorDyn::from(__t)
1397        };
1398
1399        // Copy to CPU for comparison
1400        let cpu_dst = TensorDyn::image(
1401            1280,
1402            720,
1403            PixelFormat::Rgb,
1404            DType::U8,
1405            None,
1406            edgefirst_tensor::CpuAccess::ReadWrite,
1407        )?;
1408        cpu_dst
1409            .as_typed::<u8>()
1410            .unwrap()
1411            .map()?
1412            .as_mut_slice()
1413            .copy_from_slice(dst.as_typed::<u8>().unwrap().map()?.as_slice());
1414
1415        compare_images(&reference, &cpu_dst, 0.98, "g2d_yuyv_to_rgb_reference")
1416    }
1417
1418    /// Test G2D native PixelFormat::Bgra conversion for all supported source formats.
1419    /// Compares G2D src→PixelFormat::Bgra against G2D src→PixelFormat::Rgba by verifying R↔B swap.
1420    #[test]
1421    #[cfg(target_os = "linux")]
1422    #[ignore = "G2D on i.MX 8MP rejects BGRA as destination format; re-enable when supported"]
1423    fn test_g2d_bgra_no_resize() {
1424        for src_fmt in [
1425            PixelFormat::Rgba,
1426            PixelFormat::Yuyv,
1427            PixelFormat::Nv12,
1428            PixelFormat::Bgra,
1429        ] {
1430            test_g2d_bgra_no_resize_(src_fmt).unwrap_or_else(|e| {
1431                panic!("{src_fmt} to PixelFormat::Bgra failed: {e:?}");
1432            });
1433        }
1434    }
1435
1436    fn test_g2d_bgra_no_resize_(g2d_in_fmt: PixelFormat) -> Result<(), crate::Error> {
1437        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
1438        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgb), None)?;
1439
1440        // Create DMA buffer for G2D input
1441        let mut src2 = TensorDyn::image(
1442            1280,
1443            720,
1444            g2d_in_fmt,
1445            DType::U8,
1446            Some(TensorMemory::DmaBuf),
1447            edgefirst_tensor::CpuAccess::ReadWrite,
1448        )?;
1449        let mut cpu_converter = CPUProcessor::new();
1450
1451        if g2d_in_fmt == PixelFormat::Nv12 {
1452            let nv12_bytes = edgefirst_bench::testdata::read("zidane.nv12");
1453            src2.as_typed::<u8>()
1454                .unwrap()
1455                .map()?
1456                .as_mut_slice()
1457                .copy_from_slice(&nv12_bytes);
1458        } else {
1459            cpu_converter.convert(&src, &mut src2, Rotation::None, Flip::None, Crop::no_crop())?;
1460        }
1461
1462        let mut g2d = G2DProcessor::new()?;
1463
1464        // Convert to PixelFormat::Bgra via G2D
1465        let mut bgra_dst = TensorDyn::image(
1466            1280,
1467            720,
1468            PixelFormat::Bgra,
1469            DType::U8,
1470            Some(TensorMemory::DmaBuf),
1471            edgefirst_tensor::CpuAccess::ReadWrite,
1472        )?;
1473        let src2_dyn = src2;
1474        let mut bgra_dst_dyn = bgra_dst;
1475        g2d.convert(
1476            &src2_dyn,
1477            &mut bgra_dst_dyn,
1478            Rotation::None,
1479            Flip::None,
1480            Crop::no_crop(),
1481        )?;
1482        bgra_dst = {
1483            let mut __t = bgra_dst_dyn.into_u8().unwrap();
1484            __t.set_format(PixelFormat::Bgra)
1485                .map_err(|e| crate::Error::Internal(e.to_string()))?;
1486            TensorDyn::from(__t)
1487        };
1488
1489        // Reconstruct src2 from dyn for PixelFormat::Rgba conversion
1490        let src2 = {
1491            let mut __t = src2_dyn.into_u8().unwrap();
1492            __t.set_format(g2d_in_fmt)
1493                .map_err(|e| crate::Error::Internal(e.to_string()))?;
1494            TensorDyn::from(__t)
1495        };
1496
1497        // Convert to PixelFormat::Rgba via G2D as reference
1498        let mut rgba_dst = TensorDyn::image(
1499            1280,
1500            720,
1501            PixelFormat::Rgba,
1502            DType::U8,
1503            Some(TensorMemory::DmaBuf),
1504            edgefirst_tensor::CpuAccess::ReadWrite,
1505        )?;
1506        let src2_dyn2 = src2;
1507        let mut rgba_dst_dyn = rgba_dst;
1508        g2d.convert(
1509            &src2_dyn2,
1510            &mut rgba_dst_dyn,
1511            Rotation::None,
1512            Flip::None,
1513            Crop::no_crop(),
1514        )?;
1515        rgba_dst = {
1516            let mut __t = rgba_dst_dyn.into_u8().unwrap();
1517            __t.set_format(PixelFormat::Rgba)
1518                .map_err(|e| crate::Error::Internal(e.to_string()))?;
1519            TensorDyn::from(__t)
1520        };
1521
1522        // Copy both to CPU memory for comparison
1523        let bgra_cpu = TensorDyn::image(
1524            1280,
1525            720,
1526            PixelFormat::Bgra,
1527            DType::U8,
1528            None,
1529            edgefirst_tensor::CpuAccess::ReadWrite,
1530        )?;
1531        bgra_cpu
1532            .as_typed::<u8>()
1533            .unwrap()
1534            .map()?
1535            .as_mut_slice()
1536            .copy_from_slice(bgra_dst.as_typed::<u8>().unwrap().map()?.as_slice());
1537
1538        let rgba_cpu = TensorDyn::image(
1539            1280,
1540            720,
1541            PixelFormat::Rgba,
1542            DType::U8,
1543            None,
1544            edgefirst_tensor::CpuAccess::ReadWrite,
1545        )?;
1546        rgba_cpu
1547            .as_typed::<u8>()
1548            .unwrap()
1549            .map()?
1550            .as_mut_slice()
1551            .copy_from_slice(rgba_dst.as_typed::<u8>().unwrap().map()?.as_slice());
1552
1553        // Verify PixelFormat::Bgra output has R↔B swapped vs PixelFormat::Rgba output
1554        let bgra_map = bgra_cpu.as_typed::<u8>().unwrap().map()?;
1555        let rgba_map = rgba_cpu.as_typed::<u8>().unwrap().map()?;
1556        let bgra_buf = bgra_map.as_slice();
1557        let rgba_buf = rgba_map.as_slice();
1558
1559        assert_eq!(bgra_buf.len(), rgba_buf.len());
1560        for (i, (bc, rc)) in bgra_buf
1561            .as_chunks::<4>()
1562            .0
1563            .iter()
1564            .zip(rgba_buf.as_chunks::<4>().0)
1565            .enumerate()
1566        {
1567            assert_eq!(
1568                bc[0], rc[2],
1569                "{g2d_in_fmt} to PixelFormat::Bgra: pixel {i} B mismatch",
1570            );
1571            assert_eq!(
1572                bc[1], rc[1],
1573                "{g2d_in_fmt} to PixelFormat::Bgra: pixel {i} G mismatch",
1574            );
1575            assert_eq!(
1576                bc[2], rc[0],
1577                "{g2d_in_fmt} to PixelFormat::Bgra: pixel {i} R mismatch",
1578            );
1579            assert_eq!(
1580                bc[3], rc[3],
1581                "{g2d_in_fmt} to PixelFormat::Bgra: pixel {i} A mismatch",
1582            );
1583        }
1584        Ok(())
1585    }
1586
1587    // =========================================================================
1588    // tensor_to_g2d_surface offset & stride unit tests
1589    //
1590    // These tests verify that plane_offset and effective_row_stride are
1591    // correctly propagated into the G2DSurface.  They require DMA memory
1592    // but do NOT require G2D hardware — only the DMA_BUF_IOCTL_PHYS ioctl.
1593    // =========================================================================
1594
1595    /// Helper: build a DMA-backed Tensor<u8> with an optional plane_offset
1596    /// and an optional row stride, then return the G2DSurface.
1597    fn surface_for(
1598        width: usize,
1599        height: usize,
1600        fmt: PixelFormat,
1601        offset: Option<usize>,
1602        row_stride: Option<usize>,
1603    ) -> Result<G2DSurface, crate::Error> {
1604        use edgefirst_tensor::TensorMemory;
1605        let mut t = Tensor::<u8>::image(
1606            width,
1607            height,
1608            fmt,
1609            Some(TensorMemory::DmaBuf),
1610            edgefirst_tensor::CpuAccess::ReadWrite,
1611        )?;
1612        if let Some(o) = offset {
1613            t.set_plane_offset(o);
1614        }
1615        if let Some(s) = row_stride {
1616            t.set_row_stride_unchecked(s);
1617        }
1618        tensor_to_g2d_surface(&t)
1619    }
1620
1621    #[test]
1622    fn g2d_surface_single_plane_no_offset() {
1623        if !is_dma_available() || !is_g2d_available() {
1624            return;
1625        }
1626        let s = surface_for(640, 480, PixelFormat::Rgba, None, None).unwrap();
1627        // planes[0] must be non-zero (valid physical address), no offset
1628        assert_ne!(s.planes[0], 0);
1629        assert_eq!(s.stride, 640);
1630    }
1631
1632    #[test]
1633    fn g2d_surface_single_plane_with_offset() {
1634        if !is_dma_available() || !is_g2d_available() {
1635            return;
1636        }
1637        use edgefirst_tensor::TensorMemory;
1638        let mut t = Tensor::<u8>::image(
1639            640,
1640            480,
1641            PixelFormat::Rgba,
1642            Some(TensorMemory::DmaBuf),
1643            edgefirst_tensor::CpuAccess::ReadWrite,
1644        )
1645        .unwrap();
1646        let s0 = tensor_to_g2d_surface(&t).unwrap();
1647        t.set_plane_offset(4096);
1648        let s1 = tensor_to_g2d_surface(&t).unwrap();
1649        assert_eq!(s1.planes[0], s0.planes[0] + 4096);
1650    }
1651
1652    #[test]
1653    fn g2d_surface_single_plane_zero_offset() {
1654        if !is_dma_available() || !is_g2d_available() {
1655            return;
1656        }
1657        use edgefirst_tensor::TensorMemory;
1658        let mut t = Tensor::<u8>::image(
1659            640,
1660            480,
1661            PixelFormat::Rgba,
1662            Some(TensorMemory::DmaBuf),
1663            edgefirst_tensor::CpuAccess::ReadWrite,
1664        )
1665        .unwrap();
1666        let s_none = tensor_to_g2d_surface(&t).unwrap();
1667        t.set_plane_offset(0);
1668        let s_zero = tensor_to_g2d_surface(&t).unwrap();
1669        // offset=0 should produce the same address as no offset
1670        assert_eq!(s_none.planes[0], s_zero.planes[0]);
1671    }
1672
1673    #[test]
1674    fn g2d_surface_stride_rgba() {
1675        if !is_dma_available() || !is_g2d_available() {
1676            return;
1677        }
1678        // Default stride: width in pixels = 640
1679        let s_default = surface_for(640, 480, PixelFormat::Rgba, None, None).unwrap();
1680        assert_eq!(s_default.stride, 640);
1681
1682        // Custom stride: 2816 bytes / 4 channels = 704 pixels
1683        let s_custom = surface_for(640, 480, PixelFormat::Rgba, None, Some(2816)).unwrap();
1684        assert_eq!(s_custom.stride, 704);
1685    }
1686
1687    #[test]
1688    fn g2d_surface_stride_rgb() {
1689        if !is_dma_available() || !is_g2d_available() {
1690            return;
1691        }
1692        let s_default = surface_for(640, 480, PixelFormat::Rgb, None, None).unwrap();
1693        assert_eq!(s_default.stride, 640);
1694
1695        // Padded: 1980 bytes / 3 channels = 660 pixels
1696        let s_custom = surface_for(640, 480, PixelFormat::Rgb, None, Some(1980)).unwrap();
1697        assert_eq!(s_custom.stride, 660);
1698    }
1699
1700    #[test]
1701    fn g2d_surface_stride_grey() {
1702        if !is_dma_available() || !is_g2d_available() {
1703            return;
1704        }
1705        // Grey (Y800) may not be supported by all G2D hardware versions
1706        let s = match surface_for(640, 480, PixelFormat::Grey, None, Some(1024)) {
1707            Ok(s) => s,
1708            Err(crate::Error::G2D(..)) => return,
1709            Err(e) => panic!("unexpected error: {e:?}"),
1710        };
1711        // Grey: 1 channel. stride in bytes = stride in pixels
1712        assert_eq!(s.stride, 1024);
1713    }
1714
1715    #[test]
1716    fn g2d_surface_contiguous_nv12_offset() {
1717        if !is_dma_available() || !is_g2d_available() {
1718            return;
1719        }
1720        use edgefirst_tensor::TensorMemory;
1721        let mut t = Tensor::<u8>::image(
1722            640,
1723            480,
1724            PixelFormat::Nv12,
1725            Some(TensorMemory::DmaBuf),
1726            edgefirst_tensor::CpuAccess::ReadWrite,
1727        )
1728        .unwrap();
1729        let s0 = tensor_to_g2d_surface(&t).unwrap();
1730
1731        t.set_plane_offset(8192);
1732        let s1 = tensor_to_g2d_surface(&t).unwrap();
1733
1734        // Luma plane should shift by offset
1735        assert_eq!(s1.planes[0], s0.planes[0] + 8192);
1736        // UV plane = base + offset + stride * height
1737        // Without offset: UV = base + 640 * 480 = base + 307200
1738        // With offset 8192: UV = base + 8192 + 640 * 480 = base + 315392
1739        assert_eq!(s1.planes[1], s0.planes[1] + 8192);
1740    }
1741
1742    #[test]
1743    fn g2d_surface_contiguous_nv12_stride() {
1744        if !is_dma_available() || !is_g2d_available() {
1745            return;
1746        }
1747        // NV12: 1 byte per pixel for Y. stride 640 bytes = 640 pixels.
1748        let s = surface_for(640, 480, PixelFormat::Nv12, None, None).unwrap();
1749        assert_eq!(s.stride, 640);
1750
1751        // Padded stride: 1024 bytes = 1024 pixels (NV12 channels = 1)
1752        let s_padded = surface_for(640, 480, PixelFormat::Nv12, None, Some(1024)).unwrap();
1753        assert_eq!(s_padded.stride, 1024);
1754    }
1755
1756    #[test]
1757    fn g2d_surface_multiplane_nv12_offset() {
1758        if !is_dma_available() || !is_g2d_available() {
1759            return;
1760        }
1761        use edgefirst_tensor::TensorMemory;
1762
1763        // Create luma and chroma as separate DMA tensors
1764        let mut luma =
1765            Tensor::<u8>::new(&[480, 640], Some(TensorMemory::DmaBuf), Some("luma")).unwrap();
1766        let mut chroma =
1767            Tensor::<u8>::new(&[240, 640], Some(TensorMemory::DmaBuf), Some("chroma")).unwrap();
1768
1769        // Get baseline physical addresses with no offsets
1770        let luma_base = {
1771            let dma = luma.as_dma().unwrap();
1772            let phys: G2DPhysical = dma.fd.as_raw_fd().try_into().unwrap();
1773            phys.address()
1774        };
1775        let chroma_base = {
1776            let dma = chroma.as_dma().unwrap();
1777            let phys: G2DPhysical = dma.fd.as_raw_fd().try_into().unwrap();
1778            phys.address()
1779        };
1780
1781        // Set offsets and build multiplane tensor
1782        luma.set_plane_offset(4096);
1783        chroma.set_plane_offset(2048);
1784        let combined = Tensor::<u8>::from_planes(luma, chroma, PixelFormat::Nv12).unwrap();
1785        let s = tensor_to_g2d_surface(&combined).unwrap();
1786
1787        // Luma should include its offset
1788        assert_eq!(s.planes[0], luma_base + 4096);
1789        // Chroma should include its offset
1790        assert_eq!(s.planes[1], chroma_base + 2048);
1791    }
1792
1793    // =========================================================================
1794    // Odd-dimension end-to-end cells (Deliverable C)
1795    //
1796    // Design contract:
1797    //   • Source is a patterned NV12 tensor (Dma) filled stride-aware with a
1798    //     pattern varying in both X and Y.  A Mem copy carries the same fill for
1799    //     the CPU reference (trusted oracle from odd_dim_cpu.rs).
1800    //   • G2D output (Dma RGBA) is compared against the CPU output pixel-by-pixel
1801    //     at each tensor's own `effective_row_stride`.
1802    //   • Tolerance: ±4 (same as GL).  G2D BT.601 is limited-range on the matrix
1803    //     but historically matches within ±4 on real hardware; the tolerance is
1804    //     tightened to ±6 only if on-target validation shows it is needed.
1805    //   • If G2D rejects the dimensions (returns Err), the test documents the
1806    //     behaviour via `assert!` rather than silently passing.
1807    //   • Both test cells skip at runtime if DMA is unavailable or G2D init fails.
1808    // =========================================================================
1809
1810    /// Fill a Nv12 tensor (any memory type) with a patterned, stride-aware
1811    /// synthetic image that varies in both X and Y.
1812    ///
1813    /// Pattern: `Y(r,c) = (r*3 + c*5) % 256`
1814    /// Chroma column `cc`, chroma row `cr_row` (NV12: 4:2:0 — half in both dims):
1815    ///   `Cb = (cc*7 + cr_row*11 + 40) % 256`
1816    ///   `Cr = (cc*13 + cr_row*3 + 80) % 256`
1817    ///
1818    /// This matches `make_odd_both_source` in `odd_dim_cpu.rs` so the two test
1819    /// suites share an analytic ground truth.
1820    #[cfg(target_os = "linux")]
1821    fn fill_patterned_nv12(t: &TensorDyn) {
1822        let w = t.width().unwrap();
1823        let h = t.height().unwrap();
1824        let stride = t.effective_row_stride().unwrap();
1825
1826        // NV12 (4:2:0): chroma is ceil(H/2) rows of W/2 pairs.
1827        let chroma_h = h.div_ceil(2);
1828        let chroma_w = w.div_ceil(2);
1829
1830        let bound = t.as_typed::<u8>().unwrap();
1831        let mut m = bound.map().unwrap();
1832        let buf = m.as_mut_slice();
1833        let uv_start = stride * h;
1834
1835        // Luma: diagonal gradient.
1836        for r in 0..h {
1837            for c in 0..w {
1838                buf[r * stride + c] = ((r * 3 + c * 5) % 256) as u8;
1839            }
1840        }
1841        // Chroma: UV row pitch == stride for NV12.
1842        for cr_row in 0..chroma_h {
1843            for cc in 0..chroma_w {
1844                let cb_val = ((cc * 7 + cr_row * 11 + 40) % 256) as u8;
1845                let cr_val = ((cc * 13 + cr_row * 3 + 80) % 256) as u8;
1846                let uv_byte = uv_start + cr_row * stride + cc * 2;
1847                buf[uv_byte] = cb_val;
1848                buf[uv_byte + 1] = cr_val;
1849            }
1850        }
1851    }
1852
1853    /// Compare a G2D RGBA `u8` DMA tensor against a CPU RGBA `u8` reference,
1854    /// reading both at their real `effective_row_stride`.
1855    ///
1856    /// Returns `(max_diff, first_pixel_over_threshold)`.
1857    #[cfg(target_os = "linux")]
1858    fn compare_g2d_vs_cpu_rgba(
1859        g2d_dst: &TensorDyn,
1860        cpu_dst: &TensorDyn,
1861        w: usize,
1862        h: usize,
1863        tol: u32,
1864    ) -> (u32, Option<(usize, usize, usize)>) {
1865        let channels = 4usize; // RGBA
1866        let g2d_t = g2d_dst.as_typed::<u8>().unwrap();
1867        let cpu_t = cpu_dst.as_typed::<u8>().unwrap();
1868        let g2d_stride = g2d_t.effective_row_stride().unwrap_or(w * channels);
1869        let cpu_stride = cpu_t.effective_row_stride().unwrap_or(w * channels);
1870        let g2d_map = g2d_t.map().unwrap();
1871        let cpu_map = cpu_t.map().unwrap();
1872        let g2d_px = g2d_map.as_slice();
1873        let cpu_px = cpu_map.as_slice();
1874        let mut max_diff = 0u32;
1875        let mut first_fail: Option<(usize, usize, usize)> = None;
1876        for row in 0..h {
1877            for col in 0..w {
1878                for ch in 0..3usize {
1879                    // Compare RGB channels only; alpha is hardware-defined
1880                    let gi = row * g2d_stride + col * channels + ch;
1881                    let ci = row * cpu_stride + col * channels + ch;
1882                    let d = (g2d_px[gi] as i32 - cpu_px[ci] as i32).unsigned_abs();
1883                    if d > max_diff {
1884                        max_diff = d;
1885                    }
1886                    if first_fail.is_none() && d > tol {
1887                        first_fail = Some((col, row, ch));
1888                    }
1889                }
1890            }
1891        }
1892        (max_diff, first_fail)
1893    }
1894
1895    // -------------------------------------------------------------------------
1896    // D-01: NV12 odd-W (65×64) → RGBA via G2D vs CPU reference
1897    // -------------------------------------------------------------------------
1898
1899    /// D-01: NV12 odd-width (65×64) → RGBA via G2D, compared to CPU reference.
1900    ///
1901    /// Asserts:
1902    ///   (a) G2D accepts odd-width NV12 without returning an error.
1903    ///   (b) G2D output matches CPU reference within ±4 on RGB channels.
1904    ///       (Alpha is hardware-defined and excluded from the comparison.)
1905    ///
1906    /// If on-target validation shows the G2D hardware has wider tolerance for
1907    /// odd widths, the tolerance may be relaxed to ±6 with a justification
1908    /// comment — but only after observing an actual on-target failure, not
1909    /// preemptively.
1910    #[test]
1911    #[cfg(target_os = "linux")]
1912    fn d01_nv12_odd_w_g2d_vs_cpu() {
1913        if !is_dma_available() {
1914            eprintln!("SKIPPED: d01_nv12_odd_w_g2d_vs_cpu - DMA not available");
1915            return;
1916        }
1917        let (w, h) = (65usize, 64usize);
1918
1919        let src_dma = TensorDyn::image(
1920            w,
1921            h,
1922            PixelFormat::Nv12,
1923            DType::U8,
1924            Some(TensorMemory::DmaBuf),
1925            edgefirst_tensor::CpuAccess::ReadWrite,
1926        )
1927        .unwrap();
1928        let src_mem = TensorDyn::image(
1929            w,
1930            h,
1931            PixelFormat::Nv12,
1932            DType::U8,
1933            Some(TensorMemory::Mem),
1934            edgefirst_tensor::CpuAccess::ReadWrite,
1935        )
1936        .unwrap();
1937        fill_patterned_nv12(&src_dma);
1938        fill_patterned_nv12(&src_mem);
1939
1940        // CPU reference: Nv12 → Rgba.
1941        let mut cpu_dst = TensorDyn::image(
1942            w,
1943            h,
1944            PixelFormat::Rgba,
1945            DType::U8,
1946            None,
1947            edgefirst_tensor::CpuAccess::ReadWrite,
1948        )
1949        .unwrap();
1950        CPUProcessor::new()
1951            .convert(
1952                &src_mem,
1953                &mut cpu_dst,
1954                Rotation::None,
1955                Flip::None,
1956                Crop::no_crop(),
1957            )
1958            .unwrap();
1959
1960        // G2D convert: Nv12 → Rgba.
1961        let mut g2d_dst = TensorDyn::image(
1962            w,
1963            h,
1964            PixelFormat::Rgba,
1965            DType::U8,
1966            Some(TensorMemory::DmaBuf),
1967            edgefirst_tensor::CpuAccess::ReadWrite,
1968        )
1969        .unwrap();
1970        let mut g2d = match G2DProcessor::new() {
1971            Ok(g) => g,
1972            Err(e) => {
1973                eprintln!("SKIPPED: d01_nv12_odd_w_g2d_vs_cpu - G2D not available: {e}");
1974                return;
1975            }
1976        };
1977
1978        // (a) G2D must accept odd-width NV12 without error.
1979        g2d.convert(
1980            &src_dma,
1981            &mut g2d_dst,
1982            Rotation::None,
1983            Flip::None,
1984            Crop::no_crop(),
1985        )
1986        .unwrap_or_else(|e| {
1987            panic!("D-01: G2D rejected odd-width (65) NV12 source: {e}");
1988        });
1989
1990        // (b) G2D output ≈ CPU reference ±4 on RGB channels.
1991        let tol = 4u32;
1992        let (max_diff, first_fail) = compare_g2d_vs_cpu_rgba(&g2d_dst, &cpu_dst, w, h, tol);
1993        eprintln!("D-01 NV12 odd-W G2D vs CPU: max_diff={max_diff}");
1994        // Post-WS1 both CPU and G2D resolve this untagged odd-W NV12 source to
1995        // limited-range BT.601 (G2D is limited-range matrix-only), so the
1996        // YUV-matrix delta that previously forced the loose >64 bound has
1997        // closed; the residual is G2D fixed-point rounding. Warn above the tight
1998        // ±tol, fail on >35 (was 64) so a real geometry/stride regression — the
1999        // odd-W stride handling this test guards — still trips.
2000        if max_diff > tol {
2001            eprintln!(
2002                "WARNING: D-01 NV12 odd-W G2D vs CPU max_diff={max_diff} > {tol} \
2003                 (G2D fixed-point rounding; first bad at {first_fail:?})"
2004            );
2005        }
2006        assert!(
2007            max_diff <= 35,
2008            "D-01: gross NV12 odd-W G2D vs CPU mismatch max_diff={max_diff} (>35); first bad at {first_fail:?}"
2009        );
2010    }
2011
2012    // -------------------------------------------------------------------------
2013    // D-03: NV12 odd-both (65×63) → RGBA via G2D vs CPU reference
2014    // -------------------------------------------------------------------------
2015
2016    /// D-03: NV12 odd-width AND odd-height (65×63) → RGBA via G2D vs CPU reference.
2017    ///
2018    /// This is the strictest G2D odd-dimension cell: it exercises both the
2019    /// stride-boundary at the last luma row AND the half-height chroma plane
2020    /// boundary.  If the G2D hardware rejects the conversion (e.g. hardware
2021    /// alignment requirement on chroma height), this test documents that via a
2022    /// panic rather than passing silently.
2023    #[test]
2024    #[cfg(target_os = "linux")]
2025    fn d03_nv12_odd_both_g2d_vs_cpu() {
2026        if !is_dma_available() {
2027            eprintln!("SKIPPED: d03_nv12_odd_both_g2d_vs_cpu - DMA not available");
2028            return;
2029        }
2030        let (w, h) = (65usize, 63usize);
2031
2032        let src_dma = TensorDyn::image(
2033            w,
2034            h,
2035            PixelFormat::Nv12,
2036            DType::U8,
2037            Some(TensorMemory::DmaBuf),
2038            edgefirst_tensor::CpuAccess::ReadWrite,
2039        )
2040        .unwrap();
2041        let src_mem = TensorDyn::image(
2042            w,
2043            h,
2044            PixelFormat::Nv12,
2045            DType::U8,
2046            Some(TensorMemory::Mem),
2047            edgefirst_tensor::CpuAccess::ReadWrite,
2048        )
2049        .unwrap();
2050        fill_patterned_nv12(&src_dma);
2051        fill_patterned_nv12(&src_mem);
2052
2053        // CPU reference: Nv12 → Rgba.
2054        let mut cpu_dst = TensorDyn::image(
2055            w,
2056            h,
2057            PixelFormat::Rgba,
2058            DType::U8,
2059            None,
2060            edgefirst_tensor::CpuAccess::ReadWrite,
2061        )
2062        .unwrap();
2063        CPUProcessor::new()
2064            .convert(
2065                &src_mem,
2066                &mut cpu_dst,
2067                Rotation::None,
2068                Flip::None,
2069                Crop::no_crop(),
2070            )
2071            .unwrap();
2072
2073        // G2D convert: Nv12 → Rgba.
2074        let mut g2d_dst = TensorDyn::image(
2075            w,
2076            h,
2077            PixelFormat::Rgba,
2078            DType::U8,
2079            Some(TensorMemory::DmaBuf),
2080            edgefirst_tensor::CpuAccess::ReadWrite,
2081        )
2082        .unwrap();
2083        let mut g2d = match G2DProcessor::new() {
2084            Ok(g) => g,
2085            Err(e) => {
2086                eprintln!("SKIPPED: d03_nv12_odd_both_g2d_vs_cpu - G2D not available: {e}");
2087                return;
2088            }
2089        };
2090
2091        // G2D must accept odd-both NV12 without error (documented behaviour).
2092        // If hardware rejects it, the panic message is the intended failure report.
2093        g2d.convert(
2094            &src_dma,
2095            &mut g2d_dst,
2096            Rotation::None,
2097            Flip::None,
2098            Crop::no_crop(),
2099        )
2100        .unwrap_or_else(|e| {
2101            panic!("D-03: G2D rejected odd-both (65×63) NV12 source: {e}");
2102        });
2103
2104        let tol = 4u32;
2105        let (max_diff, first_fail) = compare_g2d_vs_cpu_rgba(&g2d_dst, &cpu_dst, w, h, tol);
2106        eprintln!("D-03 NV12 odd-both G2D vs CPU: max_diff={max_diff}");
2107        // Post-WS1 the YUV-matrix delta has closed — see test_d01 above. Warn
2108        // above ±tol on G2D fixed-point rounding, fail only on a gross >35
2109        // geometry/stride regression (was 64).
2110        if max_diff > tol {
2111            eprintln!(
2112                "WARNING: D-03 NV12 odd-both G2D vs CPU max_diff={max_diff} > {tol} \
2113                 (G2D fixed-point rounding; first bad at {first_fail:?})"
2114            );
2115        }
2116        assert!(
2117            max_diff <= 35,
2118            "D-03: gross NV12 odd-both G2D vs CPU mismatch max_diff={max_diff} (>35); first bad at {first_fail:?}"
2119        );
2120    }
2121}