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