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