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)]
451mod g2d_tests {
452    use super::*;
453    use crate::{CPUProcessor, Flip, G2DProcessor, ImageProcessorTrait, Rect, Rotation};
454    use edgefirst_tensor::{
455        is_dma_available, DType, PixelFormat, TensorDyn, TensorMapTrait, TensorMemory, TensorTrait,
456    };
457    use image::buffer::ConvertBuffer;
458
459    #[test]
460    #[cfg(target_os = "linux")]
461    fn test_g2d_formats_no_resize() {
462        for i in [
463            PixelFormat::Rgba,
464            PixelFormat::Yuyv,
465            PixelFormat::Rgb,
466            PixelFormat::Grey,
467            PixelFormat::Nv12,
468        ] {
469            for o in [
470                PixelFormat::Rgba,
471                PixelFormat::Yuyv,
472                PixelFormat::Rgb,
473                PixelFormat::Grey,
474            ] {
475                let res = test_g2d_format_no_resize_(i, o);
476                if let Err(e) = res {
477                    println!("{i} to {o} failed: {e:?}");
478                } else {
479                    println!("{i} to {o} success");
480                }
481            }
482        }
483    }
484
485    fn test_g2d_format_no_resize_(
486        g2d_in_fmt: PixelFormat,
487        g2d_out_fmt: PixelFormat,
488    ) -> Result<(), crate::Error> {
489        let dst_width = 1280;
490        let dst_height = 720;
491        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
492        let src = crate::load_image(&file, Some(PixelFormat::Rgb), None)?;
493
494        // Create DMA buffer for G2D input
495        let mut src2 = TensorDyn::image(1280, 720, g2d_in_fmt, DType::U8, Some(TensorMemory::Dma))?;
496
497        let mut cpu_converter = CPUProcessor::new();
498
499        // For PixelFormat::Nv12 input, load from file since CPU doesn't support PixelFormat::Rgb→PixelFormat::Nv12
500        if g2d_in_fmt == PixelFormat::Nv12 {
501            let nv12_bytes = edgefirst_bench::testdata::read("zidane.nv12");
502            src2.as_u8()
503                .unwrap()
504                .map()?
505                .as_mut_slice()
506                .copy_from_slice(&nv12_bytes);
507        } else {
508            cpu_converter.convert(&src, &mut src2, Rotation::None, Flip::None, Crop::no_crop())?;
509        }
510
511        let mut g2d_dst = TensorDyn::image(
512            dst_width,
513            dst_height,
514            g2d_out_fmt,
515            DType::U8,
516            Some(TensorMemory::Dma),
517        )?;
518        let mut g2d_converter = G2DProcessor::new()?;
519        let src2_dyn = src2;
520        let mut g2d_dst_dyn = g2d_dst;
521        g2d_converter.convert(
522            &src2_dyn,
523            &mut g2d_dst_dyn,
524            Rotation::None,
525            Flip::None,
526            Crop::no_crop(),
527        )?;
528        g2d_dst = {
529            let mut __t = g2d_dst_dyn.into_u8().unwrap();
530            __t.set_format(g2d_out_fmt)
531                .map_err(|e| crate::Error::Internal(e.to_string()))?;
532            TensorDyn::from(__t)
533        };
534
535        let mut cpu_dst =
536            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgb, DType::U8, None)?;
537        cpu_converter.convert(
538            &g2d_dst,
539            &mut cpu_dst,
540            Rotation::None,
541            Flip::None,
542            Crop::no_crop(),
543        )?;
544
545        compare_images(
546            &src,
547            &cpu_dst,
548            0.98,
549            &format!("{g2d_in_fmt}_to_{g2d_out_fmt}"),
550        )
551    }
552
553    #[test]
554    #[cfg(target_os = "linux")]
555    fn test_g2d_formats_with_resize() {
556        for i in [
557            PixelFormat::Rgba,
558            PixelFormat::Yuyv,
559            PixelFormat::Rgb,
560            PixelFormat::Grey,
561            PixelFormat::Nv12,
562        ] {
563            for o in [
564                PixelFormat::Rgba,
565                PixelFormat::Yuyv,
566                PixelFormat::Rgb,
567                PixelFormat::Grey,
568            ] {
569                let res = test_g2d_format_with_resize_(i, o);
570                if let Err(e) = res {
571                    println!("{i} to {o} failed: {e:?}");
572                } else {
573                    println!("{i} to {o} success");
574                }
575            }
576        }
577    }
578
579    #[test]
580    #[cfg(target_os = "linux")]
581    fn test_g2d_formats_with_resize_dst_crop() {
582        for i in [
583            PixelFormat::Rgba,
584            PixelFormat::Yuyv,
585            PixelFormat::Rgb,
586            PixelFormat::Grey,
587            PixelFormat::Nv12,
588        ] {
589            for o in [
590                PixelFormat::Rgba,
591                PixelFormat::Yuyv,
592                PixelFormat::Rgb,
593                PixelFormat::Grey,
594            ] {
595                let res = test_g2d_format_with_resize_dst_crop(i, o);
596                if let Err(e) = res {
597                    println!("{i} to {o} failed: {e:?}");
598                } else {
599                    println!("{i} to {o} success");
600                }
601            }
602        }
603    }
604
605    fn test_g2d_format_with_resize_(
606        g2d_in_fmt: PixelFormat,
607        g2d_out_fmt: PixelFormat,
608    ) -> Result<(), crate::Error> {
609        let dst_width = 600;
610        let dst_height = 400;
611        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
612        let src = crate::load_image(&file, Some(PixelFormat::Rgb), None)?;
613
614        let mut cpu_converter = CPUProcessor::new();
615
616        let mut reference = TensorDyn::image(
617            dst_width,
618            dst_height,
619            PixelFormat::Rgb,
620            DType::U8,
621            Some(TensorMemory::Dma),
622        )?;
623        cpu_converter.convert(
624            &src,
625            &mut reference,
626            Rotation::None,
627            Flip::None,
628            Crop::no_crop(),
629        )?;
630
631        // Create DMA buffer for G2D input
632        let mut src2 = TensorDyn::image(1280, 720, g2d_in_fmt, DType::U8, Some(TensorMemory::Dma))?;
633
634        // For PixelFormat::Nv12 input, load from file since CPU doesn't support PixelFormat::Rgb→PixelFormat::Nv12
635        if g2d_in_fmt == PixelFormat::Nv12 {
636            let nv12_bytes = edgefirst_bench::testdata::read("zidane.nv12");
637            src2.as_u8()
638                .unwrap()
639                .map()?
640                .as_mut_slice()
641                .copy_from_slice(&nv12_bytes);
642        } else {
643            cpu_converter.convert(&src, &mut src2, Rotation::None, Flip::None, Crop::no_crop())?;
644        }
645
646        let mut g2d_dst = TensorDyn::image(
647            dst_width,
648            dst_height,
649            g2d_out_fmt,
650            DType::U8,
651            Some(TensorMemory::Dma),
652        )?;
653        let mut g2d_converter = G2DProcessor::new()?;
654        let src2_dyn = src2;
655        let mut g2d_dst_dyn = g2d_dst;
656        g2d_converter.convert(
657            &src2_dyn,
658            &mut g2d_dst_dyn,
659            Rotation::None,
660            Flip::None,
661            Crop::no_crop(),
662        )?;
663        g2d_dst = {
664            let mut __t = g2d_dst_dyn.into_u8().unwrap();
665            __t.set_format(g2d_out_fmt)
666                .map_err(|e| crate::Error::Internal(e.to_string()))?;
667            TensorDyn::from(__t)
668        };
669
670        let mut cpu_dst =
671            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgb, DType::U8, None)?;
672        cpu_converter.convert(
673            &g2d_dst,
674            &mut cpu_dst,
675            Rotation::None,
676            Flip::None,
677            Crop::no_crop(),
678        )?;
679
680        compare_images(
681            &reference,
682            &cpu_dst,
683            0.98,
684            &format!("{g2d_in_fmt}_to_{g2d_out_fmt}_resized"),
685        )
686    }
687
688    fn test_g2d_format_with_resize_dst_crop(
689        g2d_in_fmt: PixelFormat,
690        g2d_out_fmt: PixelFormat,
691    ) -> Result<(), crate::Error> {
692        let dst_width = 600;
693        let dst_height = 400;
694        let crop = Crop {
695            src_rect: None,
696            dst_rect: Some(Rect {
697                top: 100,
698                left: 100,
699                height: 100,
700                width: 200,
701            }),
702            dst_color: None,
703        };
704        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
705        let src = crate::load_image(&file, Some(PixelFormat::Rgb), None)?;
706
707        let mut cpu_converter = CPUProcessor::new();
708
709        let mut reference = TensorDyn::image(
710            dst_width,
711            dst_height,
712            PixelFormat::Rgb,
713            DType::U8,
714            Some(TensorMemory::Dma),
715        )?;
716        reference
717            .as_u8()
718            .unwrap()
719            .map()
720            .unwrap()
721            .as_mut_slice()
722            .fill(128);
723        cpu_converter.convert(&src, &mut reference, Rotation::None, Flip::None, crop)?;
724
725        // Create DMA buffer for G2D input
726        let mut src2 = TensorDyn::image(1280, 720, g2d_in_fmt, DType::U8, Some(TensorMemory::Dma))?;
727
728        // For PixelFormat::Nv12 input, load from file since CPU doesn't support PixelFormat::Rgb→PixelFormat::Nv12
729        if g2d_in_fmt == PixelFormat::Nv12 {
730            let nv12_bytes = edgefirst_bench::testdata::read("zidane.nv12");
731            src2.as_u8()
732                .unwrap()
733                .map()?
734                .as_mut_slice()
735                .copy_from_slice(&nv12_bytes);
736        } else {
737            cpu_converter.convert(&src, &mut src2, Rotation::None, Flip::None, Crop::no_crop())?;
738        }
739
740        let mut g2d_dst = TensorDyn::image(
741            dst_width,
742            dst_height,
743            g2d_out_fmt,
744            DType::U8,
745            Some(TensorMemory::Dma),
746        )?;
747        g2d_dst
748            .as_u8()
749            .unwrap()
750            .map()
751            .unwrap()
752            .as_mut_slice()
753            .fill(128);
754        let mut g2d_converter = G2DProcessor::new()?;
755        let src2_dyn = src2;
756        let mut g2d_dst_dyn = g2d_dst;
757        g2d_converter.convert(
758            &src2_dyn,
759            &mut g2d_dst_dyn,
760            Rotation::None,
761            Flip::None,
762            crop,
763        )?;
764        g2d_dst = {
765            let mut __t = g2d_dst_dyn.into_u8().unwrap();
766            __t.set_format(g2d_out_fmt)
767                .map_err(|e| crate::Error::Internal(e.to_string()))?;
768            TensorDyn::from(__t)
769        };
770
771        let mut cpu_dst =
772            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgb, DType::U8, None)?;
773        cpu_converter.convert(
774            &g2d_dst,
775            &mut cpu_dst,
776            Rotation::None,
777            Flip::None,
778            Crop::no_crop(),
779        )?;
780
781        compare_images(
782            &reference,
783            &cpu_dst,
784            0.98,
785            &format!("{g2d_in_fmt}_to_{g2d_out_fmt}_resized_dst_crop"),
786        )
787    }
788
789    fn compare_images(
790        img1: &TensorDyn,
791        img2: &TensorDyn,
792        threshold: f64,
793        name: &str,
794    ) -> Result<(), crate::Error> {
795        assert_eq!(img1.height(), img2.height(), "Heights differ");
796        assert_eq!(img1.width(), img2.width(), "Widths differ");
797        assert_eq!(
798            img1.format().unwrap(),
799            img2.format().unwrap(),
800            "PixelFormat differ"
801        );
802        assert!(
803            matches!(img1.format().unwrap(), PixelFormat::Rgb | PixelFormat::Rgba),
804            "format must be Rgb or Rgba for comparison"
805        );
806        let image1 = match img1.format().unwrap() {
807            PixelFormat::Rgb => image::RgbImage::from_vec(
808                img1.width().unwrap() as u32,
809                img1.height().unwrap() as u32,
810                img1.as_u8().unwrap().map().unwrap().to_vec(),
811            )
812            .unwrap(),
813            PixelFormat::Rgba => image::RgbaImage::from_vec(
814                img1.width().unwrap() as u32,
815                img1.height().unwrap() as u32,
816                img1.as_u8().unwrap().map().unwrap().to_vec(),
817            )
818            .unwrap()
819            .convert(),
820
821            _ => unreachable!(),
822        };
823
824        let image2 = match img2.format().unwrap() {
825            PixelFormat::Rgb => image::RgbImage::from_vec(
826                img2.width().unwrap() as u32,
827                img2.height().unwrap() as u32,
828                img2.as_u8().unwrap().map().unwrap().to_vec(),
829            )
830            .unwrap(),
831            PixelFormat::Rgba => image::RgbaImage::from_vec(
832                img2.width().unwrap() as u32,
833                img2.height().unwrap() as u32,
834                img2.as_u8().unwrap().map().unwrap().to_vec(),
835            )
836            .unwrap()
837            .convert(),
838
839            _ => unreachable!(),
840        };
841
842        let similarity = image_compare::rgb_similarity_structure(
843            &image_compare::Algorithm::RootMeanSquared,
844            &image1,
845            &image2,
846        )
847        .expect("Image Comparison failed");
848
849        if similarity.score < threshold {
850            image1.save(format!("{name}_1.png")).unwrap();
851            image2.save(format!("{name}_2.png")).unwrap();
852            return Err(Error::Internal(format!(
853                "{name}: converted image and target image have similarity score too low: {} < {}",
854                similarity.score, threshold
855            )));
856        }
857        Ok(())
858    }
859
860    // =========================================================================
861    // PixelFormat::Nv12 Reference Validation Tests
862    // These tests compare G2D PixelFormat::Nv12 conversions against ffmpeg-generated references
863    // =========================================================================
864
865    fn load_raw_image(
866        width: usize,
867        height: usize,
868        format: PixelFormat,
869        memory: Option<TensorMemory>,
870        bytes: &[u8],
871    ) -> Result<TensorDyn, crate::Error> {
872        let img = TensorDyn::image(width, height, format, DType::U8, memory)?;
873        let mut map = img.as_u8().unwrap().map()?;
874        map.as_mut_slice()[..bytes.len()].copy_from_slice(bytes);
875        Ok(img)
876    }
877
878    /// Test G2D PixelFormat::Nv12→PixelFormat::Rgba conversion against ffmpeg reference
879    #[test]
880    #[cfg(target_os = "linux")]
881    fn test_g2d_nv12_to_rgba_reference() -> Result<(), crate::Error> {
882        if !is_dma_available() {
883            return Ok(());
884        }
885        // Load PixelFormat::Nv12 source
886        let src = load_raw_image(
887            1280,
888            720,
889            PixelFormat::Nv12,
890            Some(TensorMemory::Dma),
891            &edgefirst_bench::testdata::read("camera720p.nv12"),
892        )?;
893
894        // Load PixelFormat::Rgba reference (ffmpeg-generated)
895        let reference = load_raw_image(
896            1280,
897            720,
898            PixelFormat::Rgba,
899            None,
900            &edgefirst_bench::testdata::read("camera720p.rgba"),
901        )?;
902
903        // Convert using G2D
904        let mut dst = TensorDyn::image(
905            1280,
906            720,
907            PixelFormat::Rgba,
908            DType::U8,
909            Some(TensorMemory::Dma),
910        )?;
911        let mut g2d = G2DProcessor::new()?;
912        let src_dyn = src;
913        let mut dst_dyn = dst;
914        g2d.convert(
915            &src_dyn,
916            &mut dst_dyn,
917            Rotation::None,
918            Flip::None,
919            Crop::no_crop(),
920        )?;
921        dst = {
922            let mut __t = dst_dyn.into_u8().unwrap();
923            __t.set_format(PixelFormat::Rgba)
924                .map_err(|e| crate::Error::Internal(e.to_string()))?;
925            TensorDyn::from(__t)
926        };
927
928        // Copy to CPU for comparison
929        let cpu_dst = TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None)?;
930        cpu_dst
931            .as_u8()
932            .unwrap()
933            .map()?
934            .as_mut_slice()
935            .copy_from_slice(dst.as_u8().unwrap().map()?.as_slice());
936
937        compare_images(&reference, &cpu_dst, 0.98, "g2d_nv12_to_rgba_reference")
938    }
939
940    /// Test G2D PixelFormat::Nv12→PixelFormat::Rgb conversion against ffmpeg reference
941    #[test]
942    #[cfg(target_os = "linux")]
943    fn test_g2d_nv12_to_rgb_reference() -> Result<(), crate::Error> {
944        if !is_dma_available() {
945            return Ok(());
946        }
947        // Load PixelFormat::Nv12 source
948        let src = load_raw_image(
949            1280,
950            720,
951            PixelFormat::Nv12,
952            Some(TensorMemory::Dma),
953            &edgefirst_bench::testdata::read("camera720p.nv12"),
954        )?;
955
956        // Load PixelFormat::Rgb reference (ffmpeg-generated)
957        let reference = load_raw_image(
958            1280,
959            720,
960            PixelFormat::Rgb,
961            None,
962            &edgefirst_bench::testdata::read("camera720p.rgb"),
963        )?;
964
965        // Convert using G2D
966        let mut dst = TensorDyn::image(
967            1280,
968            720,
969            PixelFormat::Rgb,
970            DType::U8,
971            Some(TensorMemory::Dma),
972        )?;
973        let mut g2d = G2DProcessor::new()?;
974        let src_dyn = src;
975        let mut dst_dyn = dst;
976        g2d.convert(
977            &src_dyn,
978            &mut dst_dyn,
979            Rotation::None,
980            Flip::None,
981            Crop::no_crop(),
982        )?;
983        dst = {
984            let mut __t = dst_dyn.into_u8().unwrap();
985            __t.set_format(PixelFormat::Rgb)
986                .map_err(|e| crate::Error::Internal(e.to_string()))?;
987            TensorDyn::from(__t)
988        };
989
990        // Copy to CPU for comparison
991        let cpu_dst = TensorDyn::image(1280, 720, PixelFormat::Rgb, DType::U8, None)?;
992        cpu_dst
993            .as_u8()
994            .unwrap()
995            .map()?
996            .as_mut_slice()
997            .copy_from_slice(dst.as_u8().unwrap().map()?.as_slice());
998
999        compare_images(&reference, &cpu_dst, 0.98, "g2d_nv12_to_rgb_reference")
1000    }
1001
1002    /// Test G2D PixelFormat::Yuyv→PixelFormat::Rgba conversion against ffmpeg reference
1003    #[test]
1004    #[cfg(target_os = "linux")]
1005    fn test_g2d_yuyv_to_rgba_reference() -> Result<(), crate::Error> {
1006        if !is_dma_available() {
1007            return Ok(());
1008        }
1009        // Load PixelFormat::Yuyv source
1010        let src = load_raw_image(
1011            1280,
1012            720,
1013            PixelFormat::Yuyv,
1014            Some(TensorMemory::Dma),
1015            &edgefirst_bench::testdata::read("camera720p.yuyv"),
1016        )?;
1017
1018        // Load PixelFormat::Rgba reference (ffmpeg-generated)
1019        let reference = load_raw_image(
1020            1280,
1021            720,
1022            PixelFormat::Rgba,
1023            None,
1024            &edgefirst_bench::testdata::read("camera720p.rgba"),
1025        )?;
1026
1027        // Convert using G2D
1028        let mut dst = TensorDyn::image(
1029            1280,
1030            720,
1031            PixelFormat::Rgba,
1032            DType::U8,
1033            Some(TensorMemory::Dma),
1034        )?;
1035        let mut g2d = G2DProcessor::new()?;
1036        let src_dyn = src;
1037        let mut dst_dyn = dst;
1038        g2d.convert(
1039            &src_dyn,
1040            &mut dst_dyn,
1041            Rotation::None,
1042            Flip::None,
1043            Crop::no_crop(),
1044        )?;
1045        dst = {
1046            let mut __t = dst_dyn.into_u8().unwrap();
1047            __t.set_format(PixelFormat::Rgba)
1048                .map_err(|e| crate::Error::Internal(e.to_string()))?;
1049            TensorDyn::from(__t)
1050        };
1051
1052        // Copy to CPU for comparison
1053        let cpu_dst = TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None)?;
1054        cpu_dst
1055            .as_u8()
1056            .unwrap()
1057            .map()?
1058            .as_mut_slice()
1059            .copy_from_slice(dst.as_u8().unwrap().map()?.as_slice());
1060
1061        compare_images(&reference, &cpu_dst, 0.98, "g2d_yuyv_to_rgba_reference")
1062    }
1063
1064    /// Test G2D PixelFormat::Yuyv→PixelFormat::Rgb conversion against ffmpeg reference
1065    #[test]
1066    #[cfg(target_os = "linux")]
1067    fn test_g2d_yuyv_to_rgb_reference() -> Result<(), crate::Error> {
1068        if !is_dma_available() {
1069            return Ok(());
1070        }
1071        // Load PixelFormat::Yuyv source
1072        let src = load_raw_image(
1073            1280,
1074            720,
1075            PixelFormat::Yuyv,
1076            Some(TensorMemory::Dma),
1077            &edgefirst_bench::testdata::read("camera720p.yuyv"),
1078        )?;
1079
1080        // Load PixelFormat::Rgb reference (ffmpeg-generated)
1081        let reference = load_raw_image(
1082            1280,
1083            720,
1084            PixelFormat::Rgb,
1085            None,
1086            &edgefirst_bench::testdata::read("camera720p.rgb"),
1087        )?;
1088
1089        // Convert using G2D
1090        let mut dst = TensorDyn::image(
1091            1280,
1092            720,
1093            PixelFormat::Rgb,
1094            DType::U8,
1095            Some(TensorMemory::Dma),
1096        )?;
1097        let mut g2d = G2DProcessor::new()?;
1098        let src_dyn = src;
1099        let mut dst_dyn = dst;
1100        g2d.convert(
1101            &src_dyn,
1102            &mut dst_dyn,
1103            Rotation::None,
1104            Flip::None,
1105            Crop::no_crop(),
1106        )?;
1107        dst = {
1108            let mut __t = dst_dyn.into_u8().unwrap();
1109            __t.set_format(PixelFormat::Rgb)
1110                .map_err(|e| crate::Error::Internal(e.to_string()))?;
1111            TensorDyn::from(__t)
1112        };
1113
1114        // Copy to CPU for comparison
1115        let cpu_dst = TensorDyn::image(1280, 720, PixelFormat::Rgb, DType::U8, None)?;
1116        cpu_dst
1117            .as_u8()
1118            .unwrap()
1119            .map()?
1120            .as_mut_slice()
1121            .copy_from_slice(dst.as_u8().unwrap().map()?.as_slice());
1122
1123        compare_images(&reference, &cpu_dst, 0.98, "g2d_yuyv_to_rgb_reference")
1124    }
1125
1126    /// Test G2D native PixelFormat::Bgra conversion for all supported source formats.
1127    /// Compares G2D src→PixelFormat::Bgra against G2D src→PixelFormat::Rgba by verifying R↔B swap.
1128    #[test]
1129    #[cfg(target_os = "linux")]
1130    #[ignore = "G2D on i.MX 8MP rejects BGRA as destination format; re-enable when supported"]
1131    fn test_g2d_bgra_no_resize() {
1132        for src_fmt in [
1133            PixelFormat::Rgba,
1134            PixelFormat::Yuyv,
1135            PixelFormat::Nv12,
1136            PixelFormat::Bgra,
1137        ] {
1138            test_g2d_bgra_no_resize_(src_fmt).unwrap_or_else(|e| {
1139                panic!("{src_fmt} to PixelFormat::Bgra failed: {e:?}");
1140            });
1141        }
1142    }
1143
1144    fn test_g2d_bgra_no_resize_(g2d_in_fmt: PixelFormat) -> Result<(), crate::Error> {
1145        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
1146        let src = crate::load_image(&file, Some(PixelFormat::Rgb), None)?;
1147
1148        // Create DMA buffer for G2D input
1149        let mut src2 = TensorDyn::image(1280, 720, g2d_in_fmt, DType::U8, Some(TensorMemory::Dma))?;
1150        let mut cpu_converter = CPUProcessor::new();
1151
1152        if g2d_in_fmt == PixelFormat::Nv12 {
1153            let nv12_bytes = edgefirst_bench::testdata::read("zidane.nv12");
1154            src2.as_u8()
1155                .unwrap()
1156                .map()?
1157                .as_mut_slice()
1158                .copy_from_slice(&nv12_bytes);
1159        } else {
1160            cpu_converter.convert(&src, &mut src2, Rotation::None, Flip::None, Crop::no_crop())?;
1161        }
1162
1163        let mut g2d = G2DProcessor::new()?;
1164
1165        // Convert to PixelFormat::Bgra via G2D
1166        let mut bgra_dst = TensorDyn::image(
1167            1280,
1168            720,
1169            PixelFormat::Bgra,
1170            DType::U8,
1171            Some(TensorMemory::Dma),
1172        )?;
1173        let src2_dyn = src2;
1174        let mut bgra_dst_dyn = bgra_dst;
1175        g2d.convert(
1176            &src2_dyn,
1177            &mut bgra_dst_dyn,
1178            Rotation::None,
1179            Flip::None,
1180            Crop::no_crop(),
1181        )?;
1182        bgra_dst = {
1183            let mut __t = bgra_dst_dyn.into_u8().unwrap();
1184            __t.set_format(PixelFormat::Bgra)
1185                .map_err(|e| crate::Error::Internal(e.to_string()))?;
1186            TensorDyn::from(__t)
1187        };
1188
1189        // Reconstruct src2 from dyn for PixelFormat::Rgba conversion
1190        let src2 = {
1191            let mut __t = src2_dyn.into_u8().unwrap();
1192            __t.set_format(g2d_in_fmt)
1193                .map_err(|e| crate::Error::Internal(e.to_string()))?;
1194            TensorDyn::from(__t)
1195        };
1196
1197        // Convert to PixelFormat::Rgba via G2D as reference
1198        let mut rgba_dst = TensorDyn::image(
1199            1280,
1200            720,
1201            PixelFormat::Rgba,
1202            DType::U8,
1203            Some(TensorMemory::Dma),
1204        )?;
1205        let src2_dyn2 = src2;
1206        let mut rgba_dst_dyn = rgba_dst;
1207        g2d.convert(
1208            &src2_dyn2,
1209            &mut rgba_dst_dyn,
1210            Rotation::None,
1211            Flip::None,
1212            Crop::no_crop(),
1213        )?;
1214        rgba_dst = {
1215            let mut __t = rgba_dst_dyn.into_u8().unwrap();
1216            __t.set_format(PixelFormat::Rgba)
1217                .map_err(|e| crate::Error::Internal(e.to_string()))?;
1218            TensorDyn::from(__t)
1219        };
1220
1221        // Copy both to CPU memory for comparison
1222        let bgra_cpu = TensorDyn::image(1280, 720, PixelFormat::Bgra, DType::U8, None)?;
1223        bgra_cpu
1224            .as_u8()
1225            .unwrap()
1226            .map()?
1227            .as_mut_slice()
1228            .copy_from_slice(bgra_dst.as_u8().unwrap().map()?.as_slice());
1229
1230        let rgba_cpu = TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None)?;
1231        rgba_cpu
1232            .as_u8()
1233            .unwrap()
1234            .map()?
1235            .as_mut_slice()
1236            .copy_from_slice(rgba_dst.as_u8().unwrap().map()?.as_slice());
1237
1238        // Verify PixelFormat::Bgra output has R↔B swapped vs PixelFormat::Rgba output
1239        let bgra_map = bgra_cpu.as_u8().unwrap().map()?;
1240        let rgba_map = rgba_cpu.as_u8().unwrap().map()?;
1241        let bgra_buf = bgra_map.as_slice();
1242        let rgba_buf = rgba_map.as_slice();
1243
1244        assert_eq!(bgra_buf.len(), rgba_buf.len());
1245        for (i, (bc, rc)) in bgra_buf
1246            .chunks_exact(4)
1247            .zip(rgba_buf.chunks_exact(4))
1248            .enumerate()
1249        {
1250            assert_eq!(
1251                bc[0], rc[2],
1252                "{g2d_in_fmt} to PixelFormat::Bgra: pixel {i} B mismatch",
1253            );
1254            assert_eq!(
1255                bc[1], rc[1],
1256                "{g2d_in_fmt} to PixelFormat::Bgra: pixel {i} G mismatch",
1257            );
1258            assert_eq!(
1259                bc[2], rc[0],
1260                "{g2d_in_fmt} to PixelFormat::Bgra: pixel {i} R mismatch",
1261            );
1262            assert_eq!(
1263                bc[3], rc[3],
1264                "{g2d_in_fmt} to PixelFormat::Bgra: pixel {i} A mismatch",
1265            );
1266        }
1267        Ok(())
1268    }
1269
1270    // =========================================================================
1271    // tensor_to_g2d_surface offset & stride unit tests
1272    //
1273    // These tests verify that plane_offset and effective_row_stride are
1274    // correctly propagated into the G2DSurface.  They require DMA memory
1275    // but do NOT require G2D hardware — only the DMA_BUF_IOCTL_PHYS ioctl.
1276    // =========================================================================
1277
1278    /// Helper: build a DMA-backed Tensor<u8> with an optional plane_offset
1279    /// and an optional row stride, then return the G2DSurface.
1280    fn surface_for(
1281        width: usize,
1282        height: usize,
1283        fmt: PixelFormat,
1284        offset: Option<usize>,
1285        row_stride: Option<usize>,
1286    ) -> Result<G2DSurface, crate::Error> {
1287        use edgefirst_tensor::TensorMemory;
1288        let mut t = Tensor::<u8>::image(width, height, fmt, Some(TensorMemory::Dma))?;
1289        if let Some(o) = offset {
1290            t.set_plane_offset(o);
1291        }
1292        if let Some(s) = row_stride {
1293            t.set_row_stride_unchecked(s);
1294        }
1295        tensor_to_g2d_surface(&t)
1296    }
1297
1298    #[test]
1299    fn g2d_surface_single_plane_no_offset() {
1300        if !is_dma_available() {
1301            return;
1302        }
1303        let s = surface_for(640, 480, PixelFormat::Rgba, None, None).unwrap();
1304        // planes[0] must be non-zero (valid physical address), no offset
1305        assert_ne!(s.planes[0], 0);
1306        assert_eq!(s.stride, 640);
1307    }
1308
1309    #[test]
1310    fn g2d_surface_single_plane_with_offset() {
1311        if !is_dma_available() {
1312            return;
1313        }
1314        use edgefirst_tensor::TensorMemory;
1315        let mut t =
1316            Tensor::<u8>::image(640, 480, PixelFormat::Rgba, Some(TensorMemory::Dma)).unwrap();
1317        let s0 = tensor_to_g2d_surface(&t).unwrap();
1318        t.set_plane_offset(4096);
1319        let s1 = tensor_to_g2d_surface(&t).unwrap();
1320        assert_eq!(s1.planes[0], s0.planes[0] + 4096);
1321    }
1322
1323    #[test]
1324    fn g2d_surface_single_plane_zero_offset() {
1325        if !is_dma_available() {
1326            return;
1327        }
1328        use edgefirst_tensor::TensorMemory;
1329        let mut t =
1330            Tensor::<u8>::image(640, 480, PixelFormat::Rgba, Some(TensorMemory::Dma)).unwrap();
1331        let s_none = tensor_to_g2d_surface(&t).unwrap();
1332        t.set_plane_offset(0);
1333        let s_zero = tensor_to_g2d_surface(&t).unwrap();
1334        // offset=0 should produce the same address as no offset
1335        assert_eq!(s_none.planes[0], s_zero.planes[0]);
1336    }
1337
1338    #[test]
1339    fn g2d_surface_stride_rgba() {
1340        if !is_dma_available() {
1341            return;
1342        }
1343        // Default stride: width in pixels = 640
1344        let s_default = surface_for(640, 480, PixelFormat::Rgba, None, None).unwrap();
1345        assert_eq!(s_default.stride, 640);
1346
1347        // Custom stride: 2816 bytes / 4 channels = 704 pixels
1348        let s_custom = surface_for(640, 480, PixelFormat::Rgba, None, Some(2816)).unwrap();
1349        assert_eq!(s_custom.stride, 704);
1350    }
1351
1352    #[test]
1353    fn g2d_surface_stride_rgb() {
1354        if !is_dma_available() {
1355            return;
1356        }
1357        let s_default = surface_for(640, 480, PixelFormat::Rgb, None, None).unwrap();
1358        assert_eq!(s_default.stride, 640);
1359
1360        // Padded: 1980 bytes / 3 channels = 660 pixels
1361        let s_custom = surface_for(640, 480, PixelFormat::Rgb, None, Some(1980)).unwrap();
1362        assert_eq!(s_custom.stride, 660);
1363    }
1364
1365    #[test]
1366    fn g2d_surface_stride_grey() {
1367        if !is_dma_available() {
1368            return;
1369        }
1370        // Grey (Y800) may not be supported by all G2D hardware versions
1371        let s = match surface_for(640, 480, PixelFormat::Grey, None, Some(1024)) {
1372            Ok(s) => s,
1373            Err(crate::Error::G2D(..)) => return,
1374            Err(e) => panic!("unexpected error: {e:?}"),
1375        };
1376        // Grey: 1 channel. stride in bytes = stride in pixels
1377        assert_eq!(s.stride, 1024);
1378    }
1379
1380    #[test]
1381    fn g2d_surface_contiguous_nv12_offset() {
1382        if !is_dma_available() {
1383            return;
1384        }
1385        use edgefirst_tensor::TensorMemory;
1386        let mut t =
1387            Tensor::<u8>::image(640, 480, PixelFormat::Nv12, Some(TensorMemory::Dma)).unwrap();
1388        let s0 = tensor_to_g2d_surface(&t).unwrap();
1389
1390        t.set_plane_offset(8192);
1391        let s1 = tensor_to_g2d_surface(&t).unwrap();
1392
1393        // Luma plane should shift by offset
1394        assert_eq!(s1.planes[0], s0.planes[0] + 8192);
1395        // UV plane = base + offset + stride * height
1396        // Without offset: UV = base + 640 * 480 = base + 307200
1397        // With offset 8192: UV = base + 8192 + 640 * 480 = base + 315392
1398        assert_eq!(s1.planes[1], s0.planes[1] + 8192);
1399    }
1400
1401    #[test]
1402    fn g2d_surface_contiguous_nv12_stride() {
1403        if !is_dma_available() {
1404            return;
1405        }
1406        // NV12: 1 byte per pixel for Y. stride 640 bytes = 640 pixels.
1407        let s = surface_for(640, 480, PixelFormat::Nv12, None, None).unwrap();
1408        assert_eq!(s.stride, 640);
1409
1410        // Padded stride: 1024 bytes = 1024 pixels (NV12 channels = 1)
1411        let s_padded = surface_for(640, 480, PixelFormat::Nv12, None, Some(1024)).unwrap();
1412        assert_eq!(s_padded.stride, 1024);
1413    }
1414
1415    #[test]
1416    fn g2d_surface_multiplane_nv12_offset() {
1417        if !is_dma_available() {
1418            return;
1419        }
1420        use edgefirst_tensor::TensorMemory;
1421
1422        // Create luma and chroma as separate DMA tensors
1423        let mut luma =
1424            Tensor::<u8>::new(&[480, 640], Some(TensorMemory::Dma), Some("luma")).unwrap();
1425        let mut chroma =
1426            Tensor::<u8>::new(&[240, 640], Some(TensorMemory::Dma), Some("chroma")).unwrap();
1427
1428        // Get baseline physical addresses with no offsets
1429        let luma_base = {
1430            let dma = luma.as_dma().unwrap();
1431            let phys: G2DPhysical = dma.fd.as_raw_fd().try_into().unwrap();
1432            phys.address()
1433        };
1434        let chroma_base = {
1435            let dma = chroma.as_dma().unwrap();
1436            let phys: G2DPhysical = dma.fd.as_raw_fd().try_into().unwrap();
1437            phys.address()
1438        };
1439
1440        // Set offsets and build multiplane tensor
1441        luma.set_plane_offset(4096);
1442        chroma.set_plane_offset(2048);
1443        let combined = Tensor::<u8>::from_planes(luma, chroma, PixelFormat::Nv12).unwrap();
1444        let s = tensor_to_g2d_surface(&combined).unwrap();
1445
1446        // Luma should include its offset
1447        assert_eq!(s.planes[0], luma_base + 4096);
1448        // Chroma should include its offset
1449        assert_eq!(s.planes[1], chroma_base + 2048);
1450    }
1451}