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::{CPUProcessor, Crop, Error, Flip, ImageProcessorTrait, Result, Rotation};
7use edgefirst_tensor::{DType, PixelFormat, Tensor, TensorDyn, TensorMapTrait, TensorTrait};
8use four_char_code::FourCharCode;
9use g2d_sys::{G2DFormat, G2DPhysical, G2DSurface, G2D};
10use std::{os::fd::AsRawFd, time::Instant};
11
12/// Convert a PixelFormat to the G2D-compatible FourCharCode.
13fn pixelfmt_to_fourcc(fmt: PixelFormat) -> FourCharCode {
14    use four_char_code::four_char_code;
15    match fmt {
16        PixelFormat::Rgb => four_char_code!("RGB "),
17        PixelFormat::Rgba => four_char_code!("RGBA"),
18        PixelFormat::Bgra => four_char_code!("BGRA"),
19        PixelFormat::Grey => four_char_code!("Y800"),
20        PixelFormat::Yuyv => four_char_code!("YUYV"),
21        PixelFormat::Vyuy => four_char_code!("VYUY"),
22        PixelFormat::Nv12 => four_char_code!("NV12"),
23        PixelFormat::Nv16 => four_char_code!("NV16"),
24        // Planar formats have no standard FourCC; use RGBA as fallback
25        _ => four_char_code!("RGBA"),
26    }
27}
28
29/// G2DConverter implements the ImageProcessor trait using the NXP G2D
30/// library for hardware-accelerated image processing on i.MX platforms.
31#[derive(Debug)]
32pub struct G2DProcessor {
33    g2d: G2D,
34}
35
36unsafe impl Send for G2DProcessor {}
37unsafe impl Sync for G2DProcessor {}
38
39impl G2DProcessor {
40    /// Creates a new G2DConverter instance.
41    pub fn new() -> Result<Self> {
42        let mut g2d = G2D::new("libg2d.so.2")?;
43        g2d.set_bt709_colorspace()?;
44
45        log::debug!("G2DConverter created with version {:?}", g2d.version());
46        Ok(Self { g2d })
47    }
48
49    /// Returns the G2D library version as defined by _G2D_VERSION in the shared
50    /// library.
51    pub fn version(&self) -> g2d_sys::Version {
52        self.g2d.version()
53    }
54
55    fn convert_impl(
56        &mut self,
57        src_dyn: &TensorDyn,
58        dst_dyn: &mut TensorDyn,
59        rotation: Rotation,
60        flip: Flip,
61        crop: Crop,
62    ) -> Result<()> {
63        let _span = tracing::trace_span!(
64            "g2d_convert",
65            src_fmt = ?src_dyn.format(),
66            dst_fmt = ?dst_dyn.format(),
67        )
68        .entered();
69        if log::log_enabled!(log::Level::Trace) {
70            log::trace!(
71                "G2D convert: {:?}({:?}/{:?}) → {:?}({:?}/{:?})",
72                src_dyn.format(),
73                src_dyn.dtype(),
74                src_dyn.memory(),
75                dst_dyn.format(),
76                dst_dyn.dtype(),
77                dst_dyn.memory(),
78            );
79        }
80
81        if src_dyn.dtype() != DType::U8 {
82            return Err(Error::NotSupported(
83                "G2D only supports u8 source tensors".to_string(),
84            ));
85        }
86        let is_int8_dst = dst_dyn.dtype() == DType::I8;
87        if dst_dyn.dtype() != DType::U8 && !is_int8_dst {
88            return Err(Error::NotSupported(
89                "G2D only supports u8 or i8 destination tensors".to_string(),
90            ));
91        }
92
93        let src_fmt = src_dyn.format().ok_or(Error::NotAnImage)?;
94        let dst_fmt = dst_dyn.format().ok_or(Error::NotAnImage)?;
95
96        // Validate supported format pairs
97        use PixelFormat::*;
98        match (src_fmt, dst_fmt) {
99            (Rgba, Rgba) => {}
100            (Rgba, Yuyv) => {}
101            (Rgba, Rgb) => {}
102            (Yuyv, Rgba) => {}
103            (Yuyv, Yuyv) => {}
104            (Yuyv, Rgb) => {}
105            // VYUY: i.MX8MP G2D hardware rejects VYUY blits (only YUYV/UYVY
106            // among packed YUV 4:2:2). ImageProcessor falls through to CPU.
107            (Nv12, Rgba) => {}
108            (Nv12, Yuyv) => {}
109            (Nv12, Rgb) => {}
110            (Rgba, Bgra) => {}
111            (Yuyv, Bgra) => {}
112            (Nv12, Bgra) => {}
113            (Bgra, Bgra) => {}
114            (s, d) => {
115                return Err(Error::NotSupported(format!(
116                    "G2D does not support {} to {} conversion",
117                    s, d
118                )));
119            }
120        }
121
122        crop.check_crop_dyn(src_dyn, dst_dyn)?;
123
124        let src = src_dyn.as_u8().unwrap();
125        // For i8 destinations, reinterpret as u8 for G2D (same byte layout).
126        // The XOR 0x80 post-pass is applied after the blit completes.
127        let dst = if is_int8_dst {
128            // SAFETY: Tensor<i8> and Tensor<u8> have identical memory layout.
129            // The T parameter only affects PhantomData<T> (zero-sized) in
130            // TensorStorage variants and the typed view from map(). The chroma
131            // field (Option<Box<Tensor<T>>>) is also layout-identical. This
132            // reinterpreted reference is used only for shape/fd access and the
133            // G2D blit (which operates on raw DMA bytes). It does not outlive
134            // dst_dyn and is never stored.
135            let i8_tensor = dst_dyn.as_i8_mut().unwrap();
136            unsafe { &mut *(i8_tensor as *mut Tensor<i8> as *mut Tensor<u8>) }
137        } else {
138            dst_dyn.as_u8_mut().unwrap()
139        };
140
141        let mut src_surface = tensor_to_g2d_surface(src)?;
142        let mut dst_surface = tensor_to_g2d_surface(dst)?;
143
144        src_surface.rot = match flip {
145            Flip::None => g2d_sys::g2d_rotation_G2D_ROTATION_0,
146            Flip::Vertical => g2d_sys::g2d_rotation_G2D_FLIP_V,
147            Flip::Horizontal => g2d_sys::g2d_rotation_G2D_FLIP_H,
148        };
149
150        dst_surface.rot = match rotation {
151            Rotation::None => g2d_sys::g2d_rotation_G2D_ROTATION_0,
152            Rotation::Clockwise90 => g2d_sys::g2d_rotation_G2D_ROTATION_90,
153            Rotation::Rotate180 => g2d_sys::g2d_rotation_G2D_ROTATION_180,
154            Rotation::CounterClockwise90 => g2d_sys::g2d_rotation_G2D_ROTATION_270,
155        };
156
157        if let Some(crop_rect) = crop.src_rect {
158            src_surface.left = crop_rect.left as i32;
159            src_surface.top = crop_rect.top as i32;
160            src_surface.right = (crop_rect.left + crop_rect.width) as i32;
161            src_surface.bottom = (crop_rect.top + crop_rect.height) as i32;
162        }
163
164        let dst_w = dst.width().unwrap();
165        let dst_h = dst.height().unwrap();
166
167        // Clear the destination with the letterbox color before blitting the
168        // image into the sub-region.
169        //
170        // g2d_clear does not support 3-byte-per-pixel formats (RGB888, BGR888).
171        // For those formats, fall back to CPU fill after the blit.
172        let needs_clear = crop.dst_color.is_some()
173            && crop.dst_rect.is_some_and(|dst_rect| {
174                dst_rect.left != 0
175                    || dst_rect.top != 0
176                    || dst_rect.width != dst_w
177                    || dst_rect.height != dst_h
178            });
179
180        if needs_clear && dst_fmt != Rgb {
181            if let Some(dst_color) = crop.dst_color {
182                let start = Instant::now();
183                self.g2d.clear(&mut dst_surface, dst_color)?;
184                log::trace!("g2d clear takes {:?}", start.elapsed());
185            }
186        }
187
188        if let Some(crop_rect) = crop.dst_rect {
189            // stride is in pixels; multiply by bytes-per-pixel (== channels()
190            // for u8 data) to get the byte offset.  All G2D destination
191            // formats are packed, so channels() == bpp always holds here.
192            dst_surface.planes[0] += ((crop_rect.top * dst_surface.stride as usize
193                + crop_rect.left)
194                * dst_fmt.channels()) as u64;
195
196            dst_surface.right = crop_rect.width as i32;
197            dst_surface.bottom = crop_rect.height as i32;
198            dst_surface.width = crop_rect.width as i32;
199            dst_surface.height = crop_rect.height as i32;
200        }
201
202        log::trace!("G2D blit: {src_fmt}→{dst_fmt} int8={is_int8_dst}");
203        self.g2d.blit(&src_surface, &dst_surface)?;
204        self.g2d.finish()?;
205        log::trace!("G2D blit complete");
206
207        // CPU fallback for RGB888 (unsupported by g2d_clear)
208        if needs_clear && dst_fmt == Rgb {
209            if let (Some(dst_color), Some(dst_rect)) = (crop.dst_color, crop.dst_rect) {
210                let start = Instant::now();
211                CPUProcessor::fill_image_outside_crop_u8(dst, dst_color, dst_rect)?;
212                log::trace!("cpu fill takes {:?}", start.elapsed());
213            }
214        }
215
216        // Apply XOR 0x80 for int8 output (u8→i8 bias conversion).
217        // map() issues DMA_BUF_IOCTL_SYNC(START) on the dst fd; for self-allocated
218        // CMA buffers this performs cache invalidation via the DrmAttachment.
219        // For foreign fds (e.g. the Neutron NPU DMA-BUF imported via from_fd()),
220        // the DrmAttachment is None and the sync ioctl is handled by the NPU driver.
221        // The map drop issues DMA_BUF_IOCTL_SYNC(END) so the NPU DMA engine sees
222        // the CPU-written XOR'd data on the next Invoke().
223        if is_int8_dst {
224            let start = Instant::now();
225            let mut map = dst.map()?;
226            crate::cpu::apply_int8_xor_bias(map.as_mut_slice(), dst_fmt);
227            log::trace!("g2d int8 XOR 0x80 post-pass takes {:?}", start.elapsed());
228        }
229
230        Ok(())
231    }
232}
233
234impl ImageProcessorTrait for G2DProcessor {
235    fn convert(
236        &mut self,
237        src: &TensorDyn,
238        dst: &mut TensorDyn,
239        rotation: Rotation,
240        flip: Flip,
241        crop: Crop,
242    ) -> Result<()> {
243        self.convert_impl(src, dst, rotation, flip, crop)
244    }
245
246    fn draw_decoded_masks(
247        &mut self,
248        dst: &mut TensorDyn,
249        detect: &[crate::DetectBox],
250        segmentation: &[crate::Segmentation],
251        overlay: crate::MaskOverlay<'_>,
252    ) -> Result<()> {
253        // G2D can produce the *frame* (background or cleared canvas) via
254        // hardware primitives — but has no rasterizer for boxes / masks.
255        // If detections are present, defer to another backend.
256        if !detect.is_empty() || !segmentation.is_empty() {
257            return Err(Error::NotImplemented(
258                "G2D does not support drawing detection or segmentation overlays".to_string(),
259            ));
260        }
261        draw_empty_frame_g2d(&mut self.g2d, dst, overlay.background)
262    }
263
264    fn draw_proto_masks(
265        &mut self,
266        dst: &mut TensorDyn,
267        detect: &[crate::DetectBox],
268        _proto_data: &crate::ProtoData,
269        overlay: crate::MaskOverlay<'_>,
270    ) -> Result<()> {
271        // Same logic as draw_decoded_masks: G2D handles empty-detection
272        // frames (clear or blit background) via the 2D hardware.
273        if !detect.is_empty() {
274            return Err(Error::NotImplemented(
275                "G2D does not support drawing detection or segmentation overlays".to_string(),
276            ));
277        }
278        draw_empty_frame_g2d(&mut self.g2d, dst, overlay.background)
279    }
280
281    fn set_class_colors(&mut self, _: &[[u8; 4]]) -> Result<()> {
282        Err(Error::NotImplemented(
283            "G2D does not support setting colors for rendering detection or segmentation overlays"
284                .to_string(),
285        ))
286    }
287}
288
289/// Produce the zero-detection frame on `dst` using G2D hardware ops.
290///
291/// `background == None` → `g2d_clear(dst, 0x00000000)` — actively fills the
292/// destination with transparent black using the 2D engine (no CPU touch).
293///
294/// `background == Some(bg)` → `g2d_blit(bg → dst)` — hardware copy of bg
295/// into dst. This is the "draw the cleared overlay onto the background"
296/// path; G2D has no pure copy primitive, so we use the blit engine with a
297/// 1:1 same-format surface pair, which is the hardware equivalent.
298///
299/// Both paths `finish()` before returning so dst is coherent for any
300/// downstream reader.
301fn draw_empty_frame_g2d(
302    g2d: &mut G2D,
303    dst_dyn: &mut TensorDyn,
304    background: Option<&TensorDyn>,
305) -> Result<()> {
306    if dst_dyn.dtype() != DType::U8 {
307        return Err(Error::NotSupported(
308            "G2D only supports u8 destination tensors".to_string(),
309        ));
310    }
311    let dst = dst_dyn.as_u8_mut().ok_or(Error::NotAnImage)?;
312
313    // DMA-only: G2D operates on physical addresses. A Mem-backed dst means
314    // we're on the wrong backend — surface a dispatch error so the caller
315    // can fall back.
316    if dst.as_dma().is_none() {
317        return Err(Error::NotImplemented(
318            "g2d only supports Dma memory".to_string(),
319        ));
320    }
321
322    let mut dst_surface = tensor_to_g2d_surface(dst)?;
323
324    match background {
325        None => {
326            // Case 1 — no background, no detections: hardware clear to
327            // transparent black. Every byte of dst is written by the 2D
328            // engine; no reliance on prior state.
329            let start = Instant::now();
330            g2d.clear(&mut dst_surface, [0, 0, 0, 0])?;
331            g2d.finish()?;
332            log::trace!("g2d clear (empty frame) takes {:?}", start.elapsed());
333        }
334        Some(bg_dyn) => {
335            // Case 2 — background, no detections: hardware blit bg → dst.
336            // Validate shape/format equivalence; if the caller handed us
337            // mismatched surfaces we return a structured error rather than
338            // silently producing wrong output.
339            if bg_dyn.shape() != dst.shape() {
340                return Err(Error::InvalidShape(
341                    "background shape does not match dst".into(),
342                ));
343            }
344            if bg_dyn.format() != dst.format() {
345                return Err(Error::InvalidShape(
346                    "background pixel format does not match dst".into(),
347                ));
348            }
349            if bg_dyn.dtype() != DType::U8 {
350                return Err(Error::NotSupported(
351                    "G2D only supports u8 background tensors".to_string(),
352                ));
353            }
354            let bg = bg_dyn.as_u8().ok_or(Error::NotAnImage)?;
355            if bg.as_dma().is_none() {
356                return Err(Error::NotImplemented(
357                    "g2d background must be Dma-backed".to_string(),
358                ));
359            }
360            let src_surface = tensor_to_g2d_surface(bg)?;
361            let start = Instant::now();
362            g2d.blit(&src_surface, &dst_surface)?;
363            g2d.finish()?;
364            log::trace!("g2d blit (bg→dst) takes {:?}", start.elapsed());
365        }
366    }
367    Ok(())
368}
369
370/// Build a `G2DSurface` from a `Tensor<u8>` that carries pixel-format metadata.
371///
372/// The tensor must be backed by DMA memory and must have a pixel format set.
373fn tensor_to_g2d_surface(img: &Tensor<u8>) -> Result<G2DSurface> {
374    let fmt = img.format().ok_or(Error::NotAnImage)?;
375    let dma = img
376        .as_dma()
377        .ok_or_else(|| Error::NotImplemented("g2d only supports Dma memory".to_string()))?;
378    let phys: G2DPhysical = dma.fd.as_raw_fd().try_into()?;
379
380    // NV12 is a two-plane format: Y plane followed by interleaved UV plane.
381    // planes[0] = Y plane start, planes[1] = UV plane start (Y size = width * height)
382    //
383    // plane_offset is the byte offset within the DMA-BUF where pixel data
384    // starts.  G2D works with raw physical addresses so we must add the
385    // offset ourselves — the hardware has no concept of a per-plane offset.
386    let base_addr = phys.address();
387    let luma_offset = img.plane_offset().unwrap_or(0) as u64;
388    let planes = if fmt == PixelFormat::Nv12 {
389        if img.is_multiplane() {
390            // Multiplane: UV in separate DMA-BUF, get its physical address
391            let chroma = img.chroma().unwrap();
392            let chroma_dma = chroma.as_dma().ok_or_else(|| {
393                Error::NotImplemented("g2d multiplane chroma must be DMA-backed".to_string())
394            })?;
395            let uv_phys: G2DPhysical = chroma_dma.fd.as_raw_fd().try_into()?;
396            let chroma_offset = img.chroma().and_then(|c| c.plane_offset()).unwrap_or(0) as u64;
397            [
398                base_addr + luma_offset,
399                uv_phys.address() + chroma_offset,
400                0,
401            ]
402        } else {
403            let w = img.width().unwrap();
404            let h = img.height().unwrap();
405            let stride = img.effective_row_stride().unwrap_or(w);
406            let uv_offset = (luma_offset as usize + stride * h) as u64;
407            [base_addr + luma_offset, base_addr + uv_offset, 0]
408        }
409    } else {
410        [base_addr + luma_offset, 0, 0]
411    };
412
413    let w = img.width().unwrap();
414    let h = img.height().unwrap();
415    let fourcc = pixelfmt_to_fourcc(fmt);
416
417    // G2D stride is in pixels.  effective_row_stride() returns bytes, so
418    // divide by the bytes-per-pixel (channels for u8 data) to convert.
419    let stride_pixels = match img.effective_row_stride() {
420        Some(s) => {
421            let channels = fmt.channels();
422            if s % channels != 0 {
423                return Err(Error::NotImplemented(
424                    "g2d requires row stride to be a multiple of bytes-per-pixel".to_string(),
425                ));
426            }
427            s / channels
428        }
429        None => w,
430    };
431
432    Ok(G2DSurface {
433        planes,
434        format: G2DFormat::try_from(fourcc)?.format(),
435        left: 0,
436        top: 0,
437        right: w as i32,
438        bottom: h as i32,
439        stride: stride_pixels as i32,
440        width: w as i32,
441        height: h as i32,
442        blendfunc: 0,
443        clrcolor: 0,
444        rot: 0,
445        global_alpha: 0,
446    })
447}
448
449#[cfg(feature = "g2d_test_formats")]
450#[cfg(test)]
451#[allow(deprecated)]
452mod g2d_tests {
453    use super::*;
454    use crate::{CPUProcessor, Flip, G2DProcessor, ImageProcessorTrait, Rect, Rotation};
455    use edgefirst_tensor::{
456        is_dma_available, DType, PixelFormat, TensorDyn, TensorMapTrait, TensorMemory, TensorTrait,
457    };
458    use image::buffer::ConvertBuffer;
459
460    #[test]
461    #[cfg(target_os = "linux")]
462    fn test_g2d_formats_no_resize() {
463        for i in [
464            PixelFormat::Rgba,
465            PixelFormat::Yuyv,
466            PixelFormat::Rgb,
467            PixelFormat::Grey,
468            PixelFormat::Nv12,
469        ] {
470            for o in [
471                PixelFormat::Rgba,
472                PixelFormat::Yuyv,
473                PixelFormat::Rgb,
474                PixelFormat::Grey,
475            ] {
476                let res = test_g2d_format_no_resize_(i, o);
477                if let Err(e) = res {
478                    println!("{i} to {o} failed: {e:?}");
479                } else {
480                    println!("{i} to {o} success");
481                }
482            }
483        }
484    }
485
486    fn test_g2d_format_no_resize_(
487        g2d_in_fmt: PixelFormat,
488        g2d_out_fmt: PixelFormat,
489    ) -> Result<(), crate::Error> {
490        let dst_width = 1280;
491        let dst_height = 720;
492        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
493        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgb), None)?;
494
495        // Create DMA buffer for G2D input
496        let mut src2 = TensorDyn::image(1280, 720, g2d_in_fmt, DType::U8, Some(TensorMemory::Dma))?;
497
498        let mut cpu_converter = CPUProcessor::new();
499
500        // For PixelFormat::Nv12 input, load from file since CPU doesn't support PixelFormat::Rgb→PixelFormat::Nv12
501        if g2d_in_fmt == PixelFormat::Nv12 {
502            let nv12_bytes = edgefirst_bench::testdata::read("zidane.nv12");
503            src2.as_u8()
504                .unwrap()
505                .map()?
506                .as_mut_slice()
507                .copy_from_slice(&nv12_bytes);
508        } else {
509            cpu_converter.convert(&src, &mut src2, Rotation::None, Flip::None, Crop::no_crop())?;
510        }
511
512        let mut g2d_dst = TensorDyn::image(
513            dst_width,
514            dst_height,
515            g2d_out_fmt,
516            DType::U8,
517            Some(TensorMemory::Dma),
518        )?;
519        let mut g2d_converter = G2DProcessor::new()?;
520        let src2_dyn = src2;
521        let mut g2d_dst_dyn = g2d_dst;
522        g2d_converter.convert(
523            &src2_dyn,
524            &mut g2d_dst_dyn,
525            Rotation::None,
526            Flip::None,
527            Crop::no_crop(),
528        )?;
529        g2d_dst = {
530            let mut __t = g2d_dst_dyn.into_u8().unwrap();
531            __t.set_format(g2d_out_fmt)
532                .map_err(|e| crate::Error::Internal(e.to_string()))?;
533            TensorDyn::from(__t)
534        };
535
536        let mut cpu_dst =
537            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgb, DType::U8, None)?;
538        cpu_converter.convert(
539            &g2d_dst,
540            &mut cpu_dst,
541            Rotation::None,
542            Flip::None,
543            Crop::no_crop(),
544        )?;
545
546        compare_images(
547            &src,
548            &cpu_dst,
549            0.98,
550            &format!("{g2d_in_fmt}_to_{g2d_out_fmt}"),
551        )
552    }
553
554    #[test]
555    #[cfg(target_os = "linux")]
556    fn test_g2d_formats_with_resize() {
557        for i in [
558            PixelFormat::Rgba,
559            PixelFormat::Yuyv,
560            PixelFormat::Rgb,
561            PixelFormat::Grey,
562            PixelFormat::Nv12,
563        ] {
564            for o in [
565                PixelFormat::Rgba,
566                PixelFormat::Yuyv,
567                PixelFormat::Rgb,
568                PixelFormat::Grey,
569            ] {
570                let res = test_g2d_format_with_resize_(i, o);
571                if let Err(e) = res {
572                    println!("{i} to {o} failed: {e:?}");
573                } else {
574                    println!("{i} to {o} success");
575                }
576            }
577        }
578    }
579
580    #[test]
581    #[cfg(target_os = "linux")]
582    fn test_g2d_formats_with_resize_dst_crop() {
583        for i in [
584            PixelFormat::Rgba,
585            PixelFormat::Yuyv,
586            PixelFormat::Rgb,
587            PixelFormat::Grey,
588            PixelFormat::Nv12,
589        ] {
590            for o in [
591                PixelFormat::Rgba,
592                PixelFormat::Yuyv,
593                PixelFormat::Rgb,
594                PixelFormat::Grey,
595            ] {
596                let res = test_g2d_format_with_resize_dst_crop(i, o);
597                if let Err(e) = res {
598                    println!("{i} to {o} failed: {e:?}");
599                } else {
600                    println!("{i} to {o} success");
601                }
602            }
603        }
604    }
605
606    fn test_g2d_format_with_resize_(
607        g2d_in_fmt: PixelFormat,
608        g2d_out_fmt: PixelFormat,
609    ) -> Result<(), crate::Error> {
610        let dst_width = 600;
611        let dst_height = 400;
612        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
613        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgb), None)?;
614
615        let mut cpu_converter = CPUProcessor::new();
616
617        let mut reference = TensorDyn::image(
618            dst_width,
619            dst_height,
620            PixelFormat::Rgb,
621            DType::U8,
622            Some(TensorMemory::Dma),
623        )?;
624        cpu_converter.convert(
625            &src,
626            &mut reference,
627            Rotation::None,
628            Flip::None,
629            Crop::no_crop(),
630        )?;
631
632        // Create DMA buffer for G2D input
633        let mut src2 = TensorDyn::image(1280, 720, g2d_in_fmt, DType::U8, Some(TensorMemory::Dma))?;
634
635        // For PixelFormat::Nv12 input, load from file since CPU doesn't support PixelFormat::Rgb→PixelFormat::Nv12
636        if g2d_in_fmt == PixelFormat::Nv12 {
637            let nv12_bytes = edgefirst_bench::testdata::read("zidane.nv12");
638            src2.as_u8()
639                .unwrap()
640                .map()?
641                .as_mut_slice()
642                .copy_from_slice(&nv12_bytes);
643        } else {
644            cpu_converter.convert(&src, &mut src2, Rotation::None, Flip::None, Crop::no_crop())?;
645        }
646
647        let mut g2d_dst = TensorDyn::image(
648            dst_width,
649            dst_height,
650            g2d_out_fmt,
651            DType::U8,
652            Some(TensorMemory::Dma),
653        )?;
654        let mut g2d_converter = G2DProcessor::new()?;
655        let src2_dyn = src2;
656        let mut g2d_dst_dyn = g2d_dst;
657        g2d_converter.convert(
658            &src2_dyn,
659            &mut g2d_dst_dyn,
660            Rotation::None,
661            Flip::None,
662            Crop::no_crop(),
663        )?;
664        g2d_dst = {
665            let mut __t = g2d_dst_dyn.into_u8().unwrap();
666            __t.set_format(g2d_out_fmt)
667                .map_err(|e| crate::Error::Internal(e.to_string()))?;
668            TensorDyn::from(__t)
669        };
670
671        let mut cpu_dst =
672            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgb, DType::U8, None)?;
673        cpu_converter.convert(
674            &g2d_dst,
675            &mut cpu_dst,
676            Rotation::None,
677            Flip::None,
678            Crop::no_crop(),
679        )?;
680
681        compare_images(
682            &reference,
683            &cpu_dst,
684            0.98,
685            &format!("{g2d_in_fmt}_to_{g2d_out_fmt}_resized"),
686        )
687    }
688
689    fn test_g2d_format_with_resize_dst_crop(
690        g2d_in_fmt: PixelFormat,
691        g2d_out_fmt: PixelFormat,
692    ) -> Result<(), crate::Error> {
693        let dst_width = 600;
694        let dst_height = 400;
695        let crop = Crop {
696            src_rect: None,
697            dst_rect: Some(Rect {
698                top: 100,
699                left: 100,
700                height: 100,
701                width: 200,
702            }),
703            dst_color: None,
704        };
705        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
706        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgb), None)?;
707
708        let mut cpu_converter = CPUProcessor::new();
709
710        let mut reference = TensorDyn::image(
711            dst_width,
712            dst_height,
713            PixelFormat::Rgb,
714            DType::U8,
715            Some(TensorMemory::Dma),
716        )?;
717        reference
718            .as_u8()
719            .unwrap()
720            .map()
721            .unwrap()
722            .as_mut_slice()
723            .fill(128);
724        cpu_converter.convert(&src, &mut reference, Rotation::None, Flip::None, crop)?;
725
726        // Create DMA buffer for G2D input
727        let mut src2 = TensorDyn::image(1280, 720, g2d_in_fmt, DType::U8, Some(TensorMemory::Dma))?;
728
729        // For PixelFormat::Nv12 input, load from file since CPU doesn't support PixelFormat::Rgb→PixelFormat::Nv12
730        if g2d_in_fmt == PixelFormat::Nv12 {
731            let nv12_bytes = edgefirst_bench::testdata::read("zidane.nv12");
732            src2.as_u8()
733                .unwrap()
734                .map()?
735                .as_mut_slice()
736                .copy_from_slice(&nv12_bytes);
737        } else {
738            cpu_converter.convert(&src, &mut src2, Rotation::None, Flip::None, Crop::no_crop())?;
739        }
740
741        let mut g2d_dst = TensorDyn::image(
742            dst_width,
743            dst_height,
744            g2d_out_fmt,
745            DType::U8,
746            Some(TensorMemory::Dma),
747        )?;
748        g2d_dst
749            .as_u8()
750            .unwrap()
751            .map()
752            .unwrap()
753            .as_mut_slice()
754            .fill(128);
755        let mut g2d_converter = G2DProcessor::new()?;
756        let src2_dyn = src2;
757        let mut g2d_dst_dyn = g2d_dst;
758        g2d_converter.convert(
759            &src2_dyn,
760            &mut g2d_dst_dyn,
761            Rotation::None,
762            Flip::None,
763            crop,
764        )?;
765        g2d_dst = {
766            let mut __t = g2d_dst_dyn.into_u8().unwrap();
767            __t.set_format(g2d_out_fmt)
768                .map_err(|e| crate::Error::Internal(e.to_string()))?;
769            TensorDyn::from(__t)
770        };
771
772        let mut cpu_dst =
773            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgb, DType::U8, None)?;
774        cpu_converter.convert(
775            &g2d_dst,
776            &mut cpu_dst,
777            Rotation::None,
778            Flip::None,
779            Crop::no_crop(),
780        )?;
781
782        compare_images(
783            &reference,
784            &cpu_dst,
785            0.98,
786            &format!("{g2d_in_fmt}_to_{g2d_out_fmt}_resized_dst_crop"),
787        )
788    }
789
790    fn compare_images(
791        img1: &TensorDyn,
792        img2: &TensorDyn,
793        threshold: f64,
794        name: &str,
795    ) -> Result<(), crate::Error> {
796        assert_eq!(img1.height(), img2.height(), "Heights differ");
797        assert_eq!(img1.width(), img2.width(), "Widths differ");
798        assert_eq!(
799            img1.format().unwrap(),
800            img2.format().unwrap(),
801            "PixelFormat differ"
802        );
803        assert!(
804            matches!(img1.format().unwrap(), PixelFormat::Rgb | PixelFormat::Rgba),
805            "format must be Rgb or Rgba for comparison"
806        );
807        let image1 = match img1.format().unwrap() {
808            PixelFormat::Rgb => image::RgbImage::from_vec(
809                img1.width().unwrap() as u32,
810                img1.height().unwrap() as u32,
811                img1.as_u8().unwrap().map().unwrap().to_vec(),
812            )
813            .unwrap(),
814            PixelFormat::Rgba => image::RgbaImage::from_vec(
815                img1.width().unwrap() as u32,
816                img1.height().unwrap() as u32,
817                img1.as_u8().unwrap().map().unwrap().to_vec(),
818            )
819            .unwrap()
820            .convert(),
821
822            _ => unreachable!(),
823        };
824
825        let image2 = match img2.format().unwrap() {
826            PixelFormat::Rgb => image::RgbImage::from_vec(
827                img2.width().unwrap() as u32,
828                img2.height().unwrap() as u32,
829                img2.as_u8().unwrap().map().unwrap().to_vec(),
830            )
831            .unwrap(),
832            PixelFormat::Rgba => image::RgbaImage::from_vec(
833                img2.width().unwrap() as u32,
834                img2.height().unwrap() as u32,
835                img2.as_u8().unwrap().map().unwrap().to_vec(),
836            )
837            .unwrap()
838            .convert(),
839
840            _ => unreachable!(),
841        };
842
843        let similarity = image_compare::rgb_similarity_structure(
844            &image_compare::Algorithm::RootMeanSquared,
845            &image1,
846            &image2,
847        )
848        .expect("Image Comparison failed");
849
850        if similarity.score < threshold {
851            image1.save(format!("{name}_1.png")).unwrap();
852            image2.save(format!("{name}_2.png")).unwrap();
853            return Err(Error::Internal(format!(
854                "{name}: converted image and target image have similarity score too low: {} < {}",
855                similarity.score, threshold
856            )));
857        }
858        Ok(())
859    }
860
861    // =========================================================================
862    // PixelFormat::Nv12 Reference Validation Tests
863    // These tests compare G2D PixelFormat::Nv12 conversions against ffmpeg-generated references
864    // =========================================================================
865
866    fn load_raw_image(
867        width: usize,
868        height: usize,
869        format: PixelFormat,
870        memory: Option<TensorMemory>,
871        bytes: &[u8],
872    ) -> Result<TensorDyn, crate::Error> {
873        let img = TensorDyn::image(width, height, format, DType::U8, memory)?;
874        let mut map = img.as_u8().unwrap().map()?;
875        map.as_mut_slice()[..bytes.len()].copy_from_slice(bytes);
876        Ok(img)
877    }
878
879    /// Test G2D PixelFormat::Nv12→PixelFormat::Rgba conversion against ffmpeg reference
880    #[test]
881    #[cfg(target_os = "linux")]
882    fn test_g2d_nv12_to_rgba_reference() -> Result<(), crate::Error> {
883        if !is_dma_available() {
884            return Ok(());
885        }
886        // Load PixelFormat::Nv12 source
887        let src = load_raw_image(
888            1280,
889            720,
890            PixelFormat::Nv12,
891            Some(TensorMemory::Dma),
892            &edgefirst_bench::testdata::read("camera720p.nv12"),
893        )?;
894
895        // Load PixelFormat::Rgba reference (ffmpeg-generated)
896        let reference = load_raw_image(
897            1280,
898            720,
899            PixelFormat::Rgba,
900            None,
901            &edgefirst_bench::testdata::read("camera720p.rgba"),
902        )?;
903
904        // Convert using G2D
905        let mut dst = TensorDyn::image(
906            1280,
907            720,
908            PixelFormat::Rgba,
909            DType::U8,
910            Some(TensorMemory::Dma),
911        )?;
912        let mut g2d = G2DProcessor::new()?;
913        let src_dyn = src;
914        let mut dst_dyn = dst;
915        g2d.convert(
916            &src_dyn,
917            &mut dst_dyn,
918            Rotation::None,
919            Flip::None,
920            Crop::no_crop(),
921        )?;
922        dst = {
923            let mut __t = dst_dyn.into_u8().unwrap();
924            __t.set_format(PixelFormat::Rgba)
925                .map_err(|e| crate::Error::Internal(e.to_string()))?;
926            TensorDyn::from(__t)
927        };
928
929        // Copy to CPU for comparison
930        let cpu_dst = TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None)?;
931        cpu_dst
932            .as_u8()
933            .unwrap()
934            .map()?
935            .as_mut_slice()
936            .copy_from_slice(dst.as_u8().unwrap().map()?.as_slice());
937
938        compare_images(&reference, &cpu_dst, 0.98, "g2d_nv12_to_rgba_reference")
939    }
940
941    /// Test G2D PixelFormat::Nv12→PixelFormat::Rgb conversion against ffmpeg reference
942    #[test]
943    #[cfg(target_os = "linux")]
944    fn test_g2d_nv12_to_rgb_reference() -> Result<(), crate::Error> {
945        if !is_dma_available() {
946            return Ok(());
947        }
948        // Load PixelFormat::Nv12 source
949        let src = load_raw_image(
950            1280,
951            720,
952            PixelFormat::Nv12,
953            Some(TensorMemory::Dma),
954            &edgefirst_bench::testdata::read("camera720p.nv12"),
955        )?;
956
957        // Load PixelFormat::Rgb reference (ffmpeg-generated)
958        let reference = load_raw_image(
959            1280,
960            720,
961            PixelFormat::Rgb,
962            None,
963            &edgefirst_bench::testdata::read("camera720p.rgb"),
964        )?;
965
966        // Convert using G2D
967        let mut dst = TensorDyn::image(
968            1280,
969            720,
970            PixelFormat::Rgb,
971            DType::U8,
972            Some(TensorMemory::Dma),
973        )?;
974        let mut g2d = G2DProcessor::new()?;
975        let src_dyn = src;
976        let mut dst_dyn = dst;
977        g2d.convert(
978            &src_dyn,
979            &mut dst_dyn,
980            Rotation::None,
981            Flip::None,
982            Crop::no_crop(),
983        )?;
984        dst = {
985            let mut __t = dst_dyn.into_u8().unwrap();
986            __t.set_format(PixelFormat::Rgb)
987                .map_err(|e| crate::Error::Internal(e.to_string()))?;
988            TensorDyn::from(__t)
989        };
990
991        // Copy to CPU for comparison
992        let cpu_dst = TensorDyn::image(1280, 720, PixelFormat::Rgb, DType::U8, None)?;
993        cpu_dst
994            .as_u8()
995            .unwrap()
996            .map()?
997            .as_mut_slice()
998            .copy_from_slice(dst.as_u8().unwrap().map()?.as_slice());
999
1000        compare_images(&reference, &cpu_dst, 0.98, "g2d_nv12_to_rgb_reference")
1001    }
1002
1003    /// Test G2D PixelFormat::Yuyv→PixelFormat::Rgba conversion against ffmpeg reference
1004    #[test]
1005    #[cfg(target_os = "linux")]
1006    fn test_g2d_yuyv_to_rgba_reference() -> Result<(), crate::Error> {
1007        if !is_dma_available() {
1008            return Ok(());
1009        }
1010        // Load PixelFormat::Yuyv source
1011        let src = load_raw_image(
1012            1280,
1013            720,
1014            PixelFormat::Yuyv,
1015            Some(TensorMemory::Dma),
1016            &edgefirst_bench::testdata::read("camera720p.yuyv"),
1017        )?;
1018
1019        // Load PixelFormat::Rgba reference (ffmpeg-generated)
1020        let reference = load_raw_image(
1021            1280,
1022            720,
1023            PixelFormat::Rgba,
1024            None,
1025            &edgefirst_bench::testdata::read("camera720p.rgba"),
1026        )?;
1027
1028        // Convert using G2D
1029        let mut dst = TensorDyn::image(
1030            1280,
1031            720,
1032            PixelFormat::Rgba,
1033            DType::U8,
1034            Some(TensorMemory::Dma),
1035        )?;
1036        let mut g2d = G2DProcessor::new()?;
1037        let src_dyn = src;
1038        let mut dst_dyn = dst;
1039        g2d.convert(
1040            &src_dyn,
1041            &mut dst_dyn,
1042            Rotation::None,
1043            Flip::None,
1044            Crop::no_crop(),
1045        )?;
1046        dst = {
1047            let mut __t = dst_dyn.into_u8().unwrap();
1048            __t.set_format(PixelFormat::Rgba)
1049                .map_err(|e| crate::Error::Internal(e.to_string()))?;
1050            TensorDyn::from(__t)
1051        };
1052
1053        // Copy to CPU for comparison
1054        let cpu_dst = TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None)?;
1055        cpu_dst
1056            .as_u8()
1057            .unwrap()
1058            .map()?
1059            .as_mut_slice()
1060            .copy_from_slice(dst.as_u8().unwrap().map()?.as_slice());
1061
1062        compare_images(&reference, &cpu_dst, 0.98, "g2d_yuyv_to_rgba_reference")
1063    }
1064
1065    /// Test G2D PixelFormat::Yuyv→PixelFormat::Rgb conversion against ffmpeg reference
1066    #[test]
1067    #[cfg(target_os = "linux")]
1068    fn test_g2d_yuyv_to_rgb_reference() -> Result<(), crate::Error> {
1069        if !is_dma_available() {
1070            return Ok(());
1071        }
1072        // Load PixelFormat::Yuyv source
1073        let src = load_raw_image(
1074            1280,
1075            720,
1076            PixelFormat::Yuyv,
1077            Some(TensorMemory::Dma),
1078            &edgefirst_bench::testdata::read("camera720p.yuyv"),
1079        )?;
1080
1081        // Load PixelFormat::Rgb reference (ffmpeg-generated)
1082        let reference = load_raw_image(
1083            1280,
1084            720,
1085            PixelFormat::Rgb,
1086            None,
1087            &edgefirst_bench::testdata::read("camera720p.rgb"),
1088        )?;
1089
1090        // Convert using G2D
1091        let mut dst = TensorDyn::image(
1092            1280,
1093            720,
1094            PixelFormat::Rgb,
1095            DType::U8,
1096            Some(TensorMemory::Dma),
1097        )?;
1098        let mut g2d = G2DProcessor::new()?;
1099        let src_dyn = src;
1100        let mut dst_dyn = dst;
1101        g2d.convert(
1102            &src_dyn,
1103            &mut dst_dyn,
1104            Rotation::None,
1105            Flip::None,
1106            Crop::no_crop(),
1107        )?;
1108        dst = {
1109            let mut __t = dst_dyn.into_u8().unwrap();
1110            __t.set_format(PixelFormat::Rgb)
1111                .map_err(|e| crate::Error::Internal(e.to_string()))?;
1112            TensorDyn::from(__t)
1113        };
1114
1115        // Copy to CPU for comparison
1116        let cpu_dst = TensorDyn::image(1280, 720, PixelFormat::Rgb, DType::U8, None)?;
1117        cpu_dst
1118            .as_u8()
1119            .unwrap()
1120            .map()?
1121            .as_mut_slice()
1122            .copy_from_slice(dst.as_u8().unwrap().map()?.as_slice());
1123
1124        compare_images(&reference, &cpu_dst, 0.98, "g2d_yuyv_to_rgb_reference")
1125    }
1126
1127    /// Test G2D native PixelFormat::Bgra conversion for all supported source formats.
1128    /// Compares G2D src→PixelFormat::Bgra against G2D src→PixelFormat::Rgba by verifying R↔B swap.
1129    #[test]
1130    #[cfg(target_os = "linux")]
1131    #[ignore = "G2D on i.MX 8MP rejects BGRA as destination format; re-enable when supported"]
1132    fn test_g2d_bgra_no_resize() {
1133        for src_fmt in [
1134            PixelFormat::Rgba,
1135            PixelFormat::Yuyv,
1136            PixelFormat::Nv12,
1137            PixelFormat::Bgra,
1138        ] {
1139            test_g2d_bgra_no_resize_(src_fmt).unwrap_or_else(|e| {
1140                panic!("{src_fmt} to PixelFormat::Bgra failed: {e:?}");
1141            });
1142        }
1143    }
1144
1145    fn test_g2d_bgra_no_resize_(g2d_in_fmt: PixelFormat) -> Result<(), crate::Error> {
1146        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
1147        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgb), None)?;
1148
1149        // Create DMA buffer for G2D input
1150        let mut src2 = TensorDyn::image(1280, 720, g2d_in_fmt, DType::U8, Some(TensorMemory::Dma))?;
1151        let mut cpu_converter = CPUProcessor::new();
1152
1153        if g2d_in_fmt == PixelFormat::Nv12 {
1154            let nv12_bytes = edgefirst_bench::testdata::read("zidane.nv12");
1155            src2.as_u8()
1156                .unwrap()
1157                .map()?
1158                .as_mut_slice()
1159                .copy_from_slice(&nv12_bytes);
1160        } else {
1161            cpu_converter.convert(&src, &mut src2, Rotation::None, Flip::None, Crop::no_crop())?;
1162        }
1163
1164        let mut g2d = G2DProcessor::new()?;
1165
1166        // Convert to PixelFormat::Bgra via G2D
1167        let mut bgra_dst = TensorDyn::image(
1168            1280,
1169            720,
1170            PixelFormat::Bgra,
1171            DType::U8,
1172            Some(TensorMemory::Dma),
1173        )?;
1174        let src2_dyn = src2;
1175        let mut bgra_dst_dyn = bgra_dst;
1176        g2d.convert(
1177            &src2_dyn,
1178            &mut bgra_dst_dyn,
1179            Rotation::None,
1180            Flip::None,
1181            Crop::no_crop(),
1182        )?;
1183        bgra_dst = {
1184            let mut __t = bgra_dst_dyn.into_u8().unwrap();
1185            __t.set_format(PixelFormat::Bgra)
1186                .map_err(|e| crate::Error::Internal(e.to_string()))?;
1187            TensorDyn::from(__t)
1188        };
1189
1190        // Reconstruct src2 from dyn for PixelFormat::Rgba conversion
1191        let src2 = {
1192            let mut __t = src2_dyn.into_u8().unwrap();
1193            __t.set_format(g2d_in_fmt)
1194                .map_err(|e| crate::Error::Internal(e.to_string()))?;
1195            TensorDyn::from(__t)
1196        };
1197
1198        // Convert to PixelFormat::Rgba via G2D as reference
1199        let mut rgba_dst = TensorDyn::image(
1200            1280,
1201            720,
1202            PixelFormat::Rgba,
1203            DType::U8,
1204            Some(TensorMemory::Dma),
1205        )?;
1206        let src2_dyn2 = src2;
1207        let mut rgba_dst_dyn = rgba_dst;
1208        g2d.convert(
1209            &src2_dyn2,
1210            &mut rgba_dst_dyn,
1211            Rotation::None,
1212            Flip::None,
1213            Crop::no_crop(),
1214        )?;
1215        rgba_dst = {
1216            let mut __t = rgba_dst_dyn.into_u8().unwrap();
1217            __t.set_format(PixelFormat::Rgba)
1218                .map_err(|e| crate::Error::Internal(e.to_string()))?;
1219            TensorDyn::from(__t)
1220        };
1221
1222        // Copy both to CPU memory for comparison
1223        let bgra_cpu = TensorDyn::image(1280, 720, PixelFormat::Bgra, DType::U8, None)?;
1224        bgra_cpu
1225            .as_u8()
1226            .unwrap()
1227            .map()?
1228            .as_mut_slice()
1229            .copy_from_slice(bgra_dst.as_u8().unwrap().map()?.as_slice());
1230
1231        let rgba_cpu = TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None)?;
1232        rgba_cpu
1233            .as_u8()
1234            .unwrap()
1235            .map()?
1236            .as_mut_slice()
1237            .copy_from_slice(rgba_dst.as_u8().unwrap().map()?.as_slice());
1238
1239        // Verify PixelFormat::Bgra output has R↔B swapped vs PixelFormat::Rgba output
1240        let bgra_map = bgra_cpu.as_u8().unwrap().map()?;
1241        let rgba_map = rgba_cpu.as_u8().unwrap().map()?;
1242        let bgra_buf = bgra_map.as_slice();
1243        let rgba_buf = rgba_map.as_slice();
1244
1245        assert_eq!(bgra_buf.len(), rgba_buf.len());
1246        for (i, (bc, rc)) in bgra_buf
1247            .chunks_exact(4)
1248            .zip(rgba_buf.chunks_exact(4))
1249            .enumerate()
1250        {
1251            assert_eq!(
1252                bc[0], rc[2],
1253                "{g2d_in_fmt} to PixelFormat::Bgra: pixel {i} B mismatch",
1254            );
1255            assert_eq!(
1256                bc[1], rc[1],
1257                "{g2d_in_fmt} to PixelFormat::Bgra: pixel {i} G mismatch",
1258            );
1259            assert_eq!(
1260                bc[2], rc[0],
1261                "{g2d_in_fmt} to PixelFormat::Bgra: pixel {i} R mismatch",
1262            );
1263            assert_eq!(
1264                bc[3], rc[3],
1265                "{g2d_in_fmt} to PixelFormat::Bgra: pixel {i} A mismatch",
1266            );
1267        }
1268        Ok(())
1269    }
1270
1271    // =========================================================================
1272    // tensor_to_g2d_surface offset & stride unit tests
1273    //
1274    // These tests verify that plane_offset and effective_row_stride are
1275    // correctly propagated into the G2DSurface.  They require DMA memory
1276    // but do NOT require G2D hardware — only the DMA_BUF_IOCTL_PHYS ioctl.
1277    // =========================================================================
1278
1279    /// Helper: build a DMA-backed Tensor<u8> with an optional plane_offset
1280    /// and an optional row stride, then return the G2DSurface.
1281    fn surface_for(
1282        width: usize,
1283        height: usize,
1284        fmt: PixelFormat,
1285        offset: Option<usize>,
1286        row_stride: Option<usize>,
1287    ) -> Result<G2DSurface, crate::Error> {
1288        use edgefirst_tensor::TensorMemory;
1289        let mut t = Tensor::<u8>::image(width, height, fmt, Some(TensorMemory::Dma))?;
1290        if let Some(o) = offset {
1291            t.set_plane_offset(o);
1292        }
1293        if let Some(s) = row_stride {
1294            t.set_row_stride_unchecked(s);
1295        }
1296        tensor_to_g2d_surface(&t)
1297    }
1298
1299    #[test]
1300    fn g2d_surface_single_plane_no_offset() {
1301        if !is_dma_available() {
1302            return;
1303        }
1304        let s = surface_for(640, 480, PixelFormat::Rgba, None, None).unwrap();
1305        // planes[0] must be non-zero (valid physical address), no offset
1306        assert_ne!(s.planes[0], 0);
1307        assert_eq!(s.stride, 640);
1308    }
1309
1310    #[test]
1311    fn g2d_surface_single_plane_with_offset() {
1312        if !is_dma_available() {
1313            return;
1314        }
1315        use edgefirst_tensor::TensorMemory;
1316        let mut t =
1317            Tensor::<u8>::image(640, 480, PixelFormat::Rgba, Some(TensorMemory::Dma)).unwrap();
1318        let s0 = tensor_to_g2d_surface(&t).unwrap();
1319        t.set_plane_offset(4096);
1320        let s1 = tensor_to_g2d_surface(&t).unwrap();
1321        assert_eq!(s1.planes[0], s0.planes[0] + 4096);
1322    }
1323
1324    #[test]
1325    fn g2d_surface_single_plane_zero_offset() {
1326        if !is_dma_available() {
1327            return;
1328        }
1329        use edgefirst_tensor::TensorMemory;
1330        let mut t =
1331            Tensor::<u8>::image(640, 480, PixelFormat::Rgba, Some(TensorMemory::Dma)).unwrap();
1332        let s_none = tensor_to_g2d_surface(&t).unwrap();
1333        t.set_plane_offset(0);
1334        let s_zero = tensor_to_g2d_surface(&t).unwrap();
1335        // offset=0 should produce the same address as no offset
1336        assert_eq!(s_none.planes[0], s_zero.planes[0]);
1337    }
1338
1339    #[test]
1340    fn g2d_surface_stride_rgba() {
1341        if !is_dma_available() {
1342            return;
1343        }
1344        // Default stride: width in pixels = 640
1345        let s_default = surface_for(640, 480, PixelFormat::Rgba, None, None).unwrap();
1346        assert_eq!(s_default.stride, 640);
1347
1348        // Custom stride: 2816 bytes / 4 channels = 704 pixels
1349        let s_custom = surface_for(640, 480, PixelFormat::Rgba, None, Some(2816)).unwrap();
1350        assert_eq!(s_custom.stride, 704);
1351    }
1352
1353    #[test]
1354    fn g2d_surface_stride_rgb() {
1355        if !is_dma_available() {
1356            return;
1357        }
1358        let s_default = surface_for(640, 480, PixelFormat::Rgb, None, None).unwrap();
1359        assert_eq!(s_default.stride, 640);
1360
1361        // Padded: 1980 bytes / 3 channels = 660 pixels
1362        let s_custom = surface_for(640, 480, PixelFormat::Rgb, None, Some(1980)).unwrap();
1363        assert_eq!(s_custom.stride, 660);
1364    }
1365
1366    #[test]
1367    fn g2d_surface_stride_grey() {
1368        if !is_dma_available() {
1369            return;
1370        }
1371        // Grey (Y800) may not be supported by all G2D hardware versions
1372        let s = match surface_for(640, 480, PixelFormat::Grey, None, Some(1024)) {
1373            Ok(s) => s,
1374            Err(crate::Error::G2D(..)) => return,
1375            Err(e) => panic!("unexpected error: {e:?}"),
1376        };
1377        // Grey: 1 channel. stride in bytes = stride in pixels
1378        assert_eq!(s.stride, 1024);
1379    }
1380
1381    #[test]
1382    fn g2d_surface_contiguous_nv12_offset() {
1383        if !is_dma_available() {
1384            return;
1385        }
1386        use edgefirst_tensor::TensorMemory;
1387        let mut t =
1388            Tensor::<u8>::image(640, 480, PixelFormat::Nv12, Some(TensorMemory::Dma)).unwrap();
1389        let s0 = tensor_to_g2d_surface(&t).unwrap();
1390
1391        t.set_plane_offset(8192);
1392        let s1 = tensor_to_g2d_surface(&t).unwrap();
1393
1394        // Luma plane should shift by offset
1395        assert_eq!(s1.planes[0], s0.planes[0] + 8192);
1396        // UV plane = base + offset + stride * height
1397        // Without offset: UV = base + 640 * 480 = base + 307200
1398        // With offset 8192: UV = base + 8192 + 640 * 480 = base + 315392
1399        assert_eq!(s1.planes[1], s0.planes[1] + 8192);
1400    }
1401
1402    #[test]
1403    fn g2d_surface_contiguous_nv12_stride() {
1404        if !is_dma_available() {
1405            return;
1406        }
1407        // NV12: 1 byte per pixel for Y. stride 640 bytes = 640 pixels.
1408        let s = surface_for(640, 480, PixelFormat::Nv12, None, None).unwrap();
1409        assert_eq!(s.stride, 640);
1410
1411        // Padded stride: 1024 bytes = 1024 pixels (NV12 channels = 1)
1412        let s_padded = surface_for(640, 480, PixelFormat::Nv12, None, Some(1024)).unwrap();
1413        assert_eq!(s_padded.stride, 1024);
1414    }
1415
1416    #[test]
1417    fn g2d_surface_multiplane_nv12_offset() {
1418        if !is_dma_available() {
1419            return;
1420        }
1421        use edgefirst_tensor::TensorMemory;
1422
1423        // Create luma and chroma as separate DMA tensors
1424        let mut luma =
1425            Tensor::<u8>::new(&[480, 640], Some(TensorMemory::Dma), Some("luma")).unwrap();
1426        let mut chroma =
1427            Tensor::<u8>::new(&[240, 640], Some(TensorMemory::Dma), Some("chroma")).unwrap();
1428
1429        // Get baseline physical addresses with no offsets
1430        let luma_base = {
1431            let dma = luma.as_dma().unwrap();
1432            let phys: G2DPhysical = dma.fd.as_raw_fd().try_into().unwrap();
1433            phys.address()
1434        };
1435        let chroma_base = {
1436            let dma = chroma.as_dma().unwrap();
1437            let phys: G2DPhysical = dma.fd.as_raw_fd().try_into().unwrap();
1438            phys.address()
1439        };
1440
1441        // Set offsets and build multiplane tensor
1442        luma.set_plane_offset(4096);
1443        chroma.set_plane_offset(2048);
1444        let combined = Tensor::<u8>::from_planes(luma, chroma, PixelFormat::Nv12).unwrap();
1445        let s = tensor_to_g2d_surface(&combined).unwrap();
1446
1447        // Luma should include its offset
1448        assert_eq!(s.planes[0], luma_base + 4096);
1449        // Chroma should include its offset
1450        assert_eq!(s.planes[1], chroma_base + 2048);
1451    }
1452}