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