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