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