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