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