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::colorimetry::effective_colorimetry;
7use crate::{CPUProcessor, Error, Flip, ImageProcessorTrait, ResolvedCrop, Result, Rotation};
8use edgefirst_tensor::{
9    ColorEncoding, ColorRange, Colorimetry, DType, PixelFormat, Tensor, TensorDyn, TensorMemory,
10    TensorTrait,
11};
12use four_char_code::FourCharCode;
13use g2d_sys::{G2DFormat, G2DPhysical, G2DSurface, G2D};
14use std::{os::fd::AsRawFd, time::Instant};
15
16/// Pure colorimetry-eligibility predicate for the G2D backend.
17///
18/// G2D is **matrix-only**: the g2d-sys API exposes `set_bt601_colorspace()` and
19/// `set_bt709_colorspace()` (the YCbCr matrix selection) but has **no control
20/// over quantization range** — the hardware is effectively limited-range only —
21/// and exposes **no BT.2020 matrix**. Therefore:
22///
23/// - A YUV conversion whose resolved range is `Full` must be DECLINED so the
24///   `ImageProcessor` dispatch falls through to the OpenGL / CPU backends,
25///   which honour full vs limited range correctly.
26/// - Any `Bt2020`-encoded conversion must be DECLINED (G2D cannot express it),
27///   again falling through to GL / CPU.
28///
29/// `src_is_yuv` is `true` when the conversion has a YUV side whose colorimetry
30/// matters (YUV→RGB uses the source colorimetry, RGB→YUV uses the destination).
31/// For RGB→RGB (no YUV side) the full-range rule is N/A and only the BT.2020
32/// matrix restriction applies.
33pub(crate) fn g2d_can_handle(cm: &Colorimetry, src_is_yuv: bool) -> bool {
34    // G2D matrix-only (no range control) and no BT.2020: decline full-range YUV
35    // and BT.2020.
36    !(src_is_yuv && cm.range == Some(ColorRange::Full))
37        && cm.encoding != Some(ColorEncoding::Bt2020)
38}
39
40/// The source formats G2D blits from.
41const G2D_SOURCES: [PixelFormat; 4] = [
42    PixelFormat::Rgba,
43    PixelFormat::Yuyv,
44    PixelFormat::Nv12,
45    PixelFormat::Bgra,
46];
47
48/// Whether G2D blits a `src` image into a `dst` image. VYUY is absent: the
49/// i.MX8MP G2D rejects VYUY blits, so `ImageProcessor` falls through to CPU.
50fn g2d_supports(src: PixelFormat, dst: PixelFormat) -> bool {
51    use PixelFormat::*;
52    matches!(
53        (src, dst),
54        (Rgba | Yuyv | Nv12, Rgba | Yuyv | Rgb | Bgra) | (Bgra, Bgra)
55    )
56}
57
58/// The CPU access G2D needs on a `format`/`dtype` destination in `memory`,
59/// or `None` when G2D does not take it (see [`g2d_post_pass_access`]).
60pub(crate) fn dst_cpu_access(
61    memory: TensorMemory,
62    format: PixelFormat,
63    dtype: DType,
64) -> Option<edgefirst_tensor::CpuAccess> {
65    let takes = memory == TensorMemory::DmaBuf
66        && matches!(dtype, DType::U8 | DType::I8)
67        && G2D_SOURCES.iter().any(|&s| g2d_supports(s, format));
68    takes.then(|| g2d_post_pass_access(format, dtype, true))
69}
70
71/// The CPU access G2D's own post-passes need on a destination: `ReadWrite`
72/// for `I8` (the bias reads back the blit) and for an `Rgb` letterbox border
73/// (`g2d_clear` has no 3-byte formats, and a CPU write of part of a
74/// device-written buffer needs a cache-invalidating map), else `None`.
75pub(crate) fn g2d_post_pass_access(
76    format: PixelFormat,
77    dtype: DType,
78    fills_border: bool,
79) -> edgefirst_tensor::CpuAccess {
80    use edgefirst_tensor::CpuAccess;
81    if dtype == DType::I8 || (fills_border && format == PixelFormat::Rgb) {
82        CpuAccess::ReadWrite
83    } else {
84        CpuAccess::None
85    }
86}
87
88/// Whether `crop` fills a letterbox border around a partial placement in a
89/// `dst_w`x`dst_h` destination.
90fn fills_border(crop: &ResolvedCrop, dst_w: usize, dst_h: usize) -> bool {
91    crop.dst_color.is_some()
92        && crop
93            .dst_rect
94            .is_some_and(|r| r.left != 0 || r.top != 0 || r.width != dst_w || r.height != dst_h)
95}
96
97/// Refuse a destination whose declared CPU access does not cover G2D's
98/// post-passes, before anything is blitted.
99fn require_post_pass_access(dst: &TensorDyn, crop: &ResolvedCrop) -> Result<()> {
100    let (Some(fmt), Some(w), Some(h)) = (dst.format(), dst.width(), dst.height()) else {
101        return Ok(());
102    };
103    let needed = g2d_post_pass_access(fmt, dst.dtype(), fills_border(crop, w, h));
104    Ok(dst.cpu_access().require(needed)?)
105}
106
107/// Convert a PixelFormat to the G2D-compatible FourCharCode.
108fn pixelfmt_to_fourcc(fmt: PixelFormat) -> FourCharCode {
109    use four_char_code::four_char_code;
110    match fmt {
111        PixelFormat::Rgb => four_char_code!("RGB "),
112        PixelFormat::Rgba => four_char_code!("RGBA"),
113        PixelFormat::Bgra => four_char_code!("BGRA"),
114        PixelFormat::Grey => four_char_code!("Y800"),
115        PixelFormat::Yuyv => four_char_code!("YUYV"),
116        PixelFormat::Vyuy => four_char_code!("VYUY"),
117        PixelFormat::Nv12 => four_char_code!("NV12"),
118        PixelFormat::Nv16 => four_char_code!("NV16"),
119        // Planar formats have no standard FourCC; use RGBA as fallback
120        _ => four_char_code!("RGBA"),
121    }
122}
123
124/// G2DConverter implements the ImageProcessor trait using the NXP G2D
125/// library for hardware-accelerated image processing on i.MX platforms.
126#[derive(Debug)]
127pub struct G2DProcessor {
128    g2d: G2D,
129    interpolation: crate::Interpolation,
130}
131
132unsafe impl Send for G2DProcessor {}
133unsafe impl Sync for G2DProcessor {}
134
135impl G2DProcessor {
136    /// Creates a new G2DConverter instance.
137    ///
138    /// The BT.709 matrix set here is only a safe default; each `convert()` call
139    /// re-programs the matrix from the resolved colorimetry of the YUV side of
140    /// that conversion (see `convert_impl`). G2D is matrix-only — it has no
141    /// range control (limited-range only) and no BT.2020 matrix — so full-range
142    /// YUV and BT.2020 conversions are declined and fall through to GL/CPU.
143    pub fn new() -> Result<Self> {
144        let mut g2d = G2D::new("libg2d.so.2")?;
145        // Safe initial default only: every `convert()` re-programs the matrix
146        // from the resolved colorimetry of that conversion (see `convert_impl`).
147        // The g2d-sys API selects only the matrix, not full-vs-limited range, so
148        // G2D is limited-range only — full-range and BT.2020 are declined and
149        // fall through to GL/CPU (a structural hardware limit, not a stop-gap).
150        g2d.set_bt601_colorspace()?;
151
152        log::debug!("G2DConverter created with version {:?}", g2d.version());
153        Ok(Self {
154            g2d,
155            interpolation: crate::Interpolation::default(),
156        })
157    }
158
159    /// Sets how resizing converts resample (see [`crate::Interpolation`]).
160    /// G2D scales with a fixed hardware filter, so under
161    /// [`crate::Interpolation::Nearest`] it declines converts that resize and
162    /// leaves them to a backend that can sample nearest-neighbour.
163    pub fn set_interpolation(&mut self, mode: crate::Interpolation) {
164        self.interpolation = mode;
165    }
166
167    /// Returns the G2D library version as defined by _G2D_VERSION in the shared
168    /// library.
169    pub fn version(&self) -> g2d_sys::Version {
170        self.g2d.version()
171    }
172
173    fn convert_impl(
174        &mut self,
175        src_dyn: &TensorDyn,
176        dst_dyn: &mut TensorDyn,
177        rotation: Rotation,
178        flip: Flip,
179        crop: ResolvedCrop,
180    ) -> Result<()> {
181        let _span = tracing::trace_span!(
182            "image.convert.g2d",
183            src_fmt = ?src_dyn.format(),
184            dst_fmt = ?dst_dyn.format(),
185        )
186        .entered();
187        if log::log_enabled!(log::Level::Trace) {
188            log::trace!(
189                "G2D convert: {:?}({:?}/{:?}) → {:?}({:?}/{:?})",
190                src_dyn.format(),
191                src_dyn.dtype(),
192                src_dyn.memory(),
193                dst_dyn.format(),
194                dst_dyn.dtype(),
195                dst_dyn.memory(),
196            );
197        }
198
199        if src_dyn.dtype() != DType::U8 {
200            return Err(Error::NotSupported(
201                "G2D only supports u8 source tensors".to_string(),
202            ));
203        }
204        let is_int8_dst = dst_dyn.dtype() == DType::I8;
205        if dst_dyn.dtype() != DType::U8 && !is_int8_dst {
206            return Err(Error::NotSupported(
207                "G2D only supports u8 or i8 destination tensors".to_string(),
208            ));
209        }
210
211        let src_fmt = src_dyn.format().ok_or(Error::NotAnImage)?;
212        let dst_fmt = dst_dyn.format().ok_or(Error::NotAnImage)?;
213
214        if !g2d_supports(src_fmt, dst_fmt) {
215            return Err(Error::NotSupported(format!(
216                "G2D does not support {src_fmt} to {dst_fmt} conversion"
217            )));
218        }
219        require_post_pass_access(dst_dyn, &crop)?;
220
221        // ── Per-conversion colorspace matrix ─────────────────────────
222        // G2D is matrix-only (no range control, no BT.2020). Resolve the
223        // colorimetry of the YUV side of this conversion and program the
224        // matching matrix before the blit. YUV→RGB uses the *source*
225        // colorimetry; RGB→YUV uses the *destination*. RGB→RGB has no YUV side
226        // so the matrix is irrelevant and left untouched.
227        //
228        // Full-range YUV and BT.2020 are declined by `g2d_can_handle` in the
229        // ImageProcessor dispatch (lib.rs) BEFORE we get here, so those fall
230        // through to the GL / CPU backends which honour range and BT.2020. We
231        // still gate here defensively so a direct G2DProcessor caller behaves.
232        let src_is_yuv = src_fmt.is_yuv();
233        let dst_is_yuv = dst_fmt.is_yuv();
234        if src_is_yuv || dst_is_yuv {
235            let cm = if src_is_yuv {
236                effective_colorimetry(src_dyn)
237            } else {
238                effective_colorimetry(dst_dyn)
239            };
240            if !g2d_can_handle(&cm, true) {
241                return Err(Error::NotSupported(format!(
242                    "G2D cannot express colorimetry {:?}/{:?} (matrix-only, \
243                     no range control, no BT.2020)",
244                    cm.encoding, cm.range
245                )));
246            }
247            match cm.encoding {
248                Some(ColorEncoding::Bt601) => self.g2d.set_bt601_colorspace()?,
249                Some(ColorEncoding::Bt709) => self.g2d.set_bt709_colorspace()?,
250                // BT.2020 already declined above; any future encoding falls
251                // back to the BT.709 matrix (closest HD-grade approximation).
252                _ => self.g2d.set_bt709_colorspace()?,
253            }
254        }
255
256        // Source/placement already validated by `Crop::resolve` at the entry.
257        let src = src_dyn.as_typed::<u8>().unwrap();
258        // For i8 destinations, reinterpret as u8 for G2D (same byte layout).
259        // The XOR 0x80 post-pass is applied after the blit completes.
260        let dst = if is_int8_dst {
261            // SAFETY: Tensor<i8> and Tensor<u8> have identical memory layout.
262            // The T parameter only affects PhantomData<T> (zero-sized) in
263            // TensorStorage variants and the typed view from map(). The chroma
264            // field (Option<Box<Tensor<T>>>) is also layout-identical. This
265            // reinterpreted reference is used only for shape/fd access and the
266            // G2D blit (which operates on raw DMA bytes). It does not outlive
267            // dst_dyn and is never stored.
268            let i8_tensor = dst_dyn.as_typed_mut::<i8>().unwrap();
269            unsafe { &mut *(i8_tensor as *mut Tensor<i8> as *mut Tensor<u8>) }
270        } else {
271            dst_dyn.as_typed_mut::<u8>().unwrap()
272        };
273
274        let mut src_surface = tensor_to_g2d_surface(src)?;
275        let mut dst_surface = tensor_to_g2d_surface(dst)?;
276
277        src_surface.rot = match flip {
278            Flip::None => g2d_sys::g2d_rotation_G2D_ROTATION_0,
279            Flip::Vertical => g2d_sys::g2d_rotation_G2D_FLIP_V,
280            Flip::Horizontal => g2d_sys::g2d_rotation_G2D_FLIP_H,
281        };
282
283        dst_surface.rot = match rotation {
284            Rotation::None => g2d_sys::g2d_rotation_G2D_ROTATION_0,
285            Rotation::Clockwise90 => g2d_sys::g2d_rotation_G2D_ROTATION_90,
286            Rotation::Rotate180 => g2d_sys::g2d_rotation_G2D_ROTATION_180,
287            Rotation::CounterClockwise90 => g2d_sys::g2d_rotation_G2D_ROTATION_270,
288        };
289
290        if let Some(crop_rect) = crop.src_rect {
291            src_surface.left = crop_rect.left as i32;
292            src_surface.top = crop_rect.top as i32;
293            src_surface.right = (crop_rect.left + crop_rect.width) as i32;
294            src_surface.bottom = (crop_rect.top + crop_rect.height) as i32;
295        }
296
297        let dst_w = dst.width().unwrap();
298        let dst_h = dst.height().unwrap();
299
300        // Clear the destination with the letterbox color before blitting the
301        // image into the sub-region.
302        //
303        // g2d_clear does not support 3-byte-per-pixel formats (RGB888, BGR888).
304        // For those formats, fall back to CPU fill after the blit.
305        let needs_clear = fills_border(&crop, dst_w, dst_h);
306
307        if needs_clear && dst_fmt != PixelFormat::Rgb {
308            if let Some(dst_color) = crop.dst_color {
309                let start = Instant::now();
310                self.g2d.clear(&mut dst_surface, dst_color)?;
311                log::trace!("g2d clear takes {:?}", start.elapsed());
312            }
313        }
314
315        if let Some(crop_rect) = crop.dst_rect {
316            // stride is in pixels; multiply by bytes-per-pixel (== channels()
317            // for u8 data) to get the byte offset.  All G2D destination
318            // formats are packed, so channels() == bpp always holds here.
319            dst_surface.planes[0] += ((crop_rect.top * dst_surface.stride as usize
320                + crop_rect.left)
321                * dst_fmt.channels()) as u64;
322
323            dst_surface.right = crop_rect.width as i32;
324            dst_surface.bottom = crop_rect.height as i32;
325            dst_surface.width = crop_rect.width as i32;
326            dst_surface.height = crop_rect.height as i32;
327        }
328
329        // ── i.MX95 DPU even-dimension workaround ──────────────────────────
330        // The i.MX95 DPU G2D backend garbles a chroma-subsampled blit whose
331        // width or height is odd — it drops the partial trailing chroma sample
332        // and corrupts the ENTIRE output (max_diff ~255); the i.MX8MP Vivante
333        // backend tolerates it. When either side is a chroma-subsampled YUV
334        // format, round BOTH blit rectangles up to even on each *subsampled*
335        // axis so the DPU's chroma geometry is exact. The width pad always lands
336        // in the 64-aligned row stride (both sides). The height pad reads the
337        // source's chroma plane (within its allocation) and writes ONE extra
338        // destination row — applied only when the destination's (page-aligned)
339        // DMA allocation can hold it; otherwise G2D declines so the caller falls
340        // back to GL/CPU, which convert odd dimensions natively. Only the
341        // logical w×h region is consumed downstream, so the padding is unused.
342        let (sx_src, sy_src) = chroma_subsample_shifts(src_fmt);
343        let (sx_dst, sy_dst) = chroma_subsample_shifts(dst_fmt);
344        let pad_w = (sx_src | sx_dst) > 0;
345        let pad_h = (sy_src | sy_dst) > 0;
346        if pad_w || pad_h {
347            let even = |v: i32| v + (v & 1);
348            if pad_h && even(dst_surface.bottom) != dst_surface.bottom {
349                // The padded blit writes one extra destination row. It is safe
350                // only when the blit starts at the destination's top (no crop
351                // placement offset) and the extra row stays within the
352                // destination's DMA mapping. A self-allocated DMA-BUF is mapped
353                // at page granularity, so its strided allocation
354                // (row_bytes × height) is physically backed up to the next page
355                // boundary. Query the runtime page size — aarch64 kernels may use
356                // 16 KiB or 64 KiB pages, and hard-coding 4 KiB would
357                // under-estimate the mapping and needlessly reject a destination
358                // that is in fact page-backed.
359                let page = match unsafe { libc::sysconf(libc::_SC_PAGESIZE) } {
360                    n if n > 0 => n as usize,
361                    _ => 4096,
362                };
363                let row_bytes = dst_surface.stride as usize * dst_fmt.channels();
364                let mapped = (row_bytes * dst_h).next_multiple_of(page);
365                let needed = row_bytes * even(dst_surface.bottom) as usize;
366                if crop.dst_rect.is_some() || needed > mapped {
367                    return Err(Error::NotSupported(format!(
368                        "G2D: odd-height {dst_fmt} destination cannot hold the padding \
369                         row for the i.MX95 DPU even-dimension workaround; deferring to \
370                         GL/CPU"
371                    )));
372                }
373            }
374            if pad_w {
375                src_surface.right = even(src_surface.right);
376                src_surface.width = even(src_surface.width);
377                dst_surface.right = even(dst_surface.right);
378                dst_surface.width = even(dst_surface.width);
379            }
380            if pad_h {
381                src_surface.bottom = even(src_surface.bottom);
382                src_surface.height = even(src_surface.height);
383                dst_surface.bottom = even(dst_surface.bottom);
384                dst_surface.height = even(dst_surface.height);
385            }
386        }
387
388        log::trace!("G2D blit: {src_fmt}→{dst_fmt} int8={is_int8_dst}");
389        self.g2d.blit(&src_surface, &dst_surface)?;
390        self.g2d.finish()?;
391        log::trace!("G2D blit complete");
392
393        // CPU fallback for RGB888 (unsupported by g2d_clear)
394        if needs_clear && dst_fmt == PixelFormat::Rgb {
395            if let (Some(dst_color), Some(dst_rect)) = (crop.dst_color, crop.dst_rect) {
396                let start = Instant::now();
397                CPUProcessor::fill_image_outside_crop_u8(dst, dst_color, dst_rect, true)?;
398                log::trace!("cpu fill takes {:?}", start.elapsed());
399            }
400        }
401
402        // Apply XOR 0x80 for int8 output (u8→i8 bias conversion), confined to
403        // each row's pixel bytes: the destination's pitch may exceed its pixels
404        // (a padded allocation, or a `view()` tile whose trailing bytes are the
405        // parent's neighbouring pixels), and a flat walk of the mapping would
406        // bias those bytes too.
407        // The helper's map issues DMA_BUF_IOCTL_SYNC(START) on the dst fd; for
408        // self-allocated CMA buffers this performs cache invalidation via the
409        // DrmAttachment. For foreign fds (e.g. the Neutron NPU DMA-BUF imported
410        // via from_fd()), the DrmAttachment is None and the sync ioctl is
411        // handled by the NPU driver. The map drop issues DMA_BUF_IOCTL_SYNC(END)
412        // so the NPU DMA engine sees the CPU-written XOR'd data on the next
413        // Invoke().
414        if is_int8_dst {
415            let start = Instant::now();
416            crate::cpu::apply_int8_xor_bias_rows(dst, dst_fmt)?;
417            log::trace!("g2d int8 XOR 0x80 post-pass takes {:?}", start.elapsed());
418        }
419
420        Ok(())
421    }
422}
423
424impl ImageProcessorTrait for G2DProcessor {
425    fn convert(
426        &mut self,
427        src: &TensorDyn,
428        dst: &mut TensorDyn,
429        options: Option<crate::ConvertOptions>,
430    ) -> Result<()> {
431        let options = options.unwrap_or_default();
432        let (rotation, flip, crop) = (
433            options.get_rotation(),
434            options.get_flip(),
435            options.get_crop(),
436        );
437        // A view()/batch() destination needs no special handling here: G2D blits
438        // to the destination's `plane_offset` + parent `row_stride`, so a sub-view
439        // (the tile) lands in its window of the parent buffer natively — the same
440        // mechanism a whole-tensor blit uses. (The cache-key/glViewport batch
441        // path is GL-specific; G2D addresses the tile by offset+stride.)
442        let crop = crop.resolve(
443            src.width().unwrap_or(0),
444            src.height().unwrap_or(0),
445            dst.width().unwrap_or(0),
446            dst.height().unwrap_or(0),
447            options.get_placement(),
448        )?;
449        if crate::options::output_map_for(&options, dst, crop.dst_rect.is_some())?.is_some() {
450            return Err(Error::NotSupported(
451                "G2D does not normalize or quantize; GL or CPU maps the output".into(),
452            ));
453        }
454        if options
455            .get_interpolation()
456            .unwrap_or(self.interpolation)
457            .supported()
458            == crate::Interpolation::Nearest
459            && crop.resizes(
460                (src.width().unwrap_or(0), src.height().unwrap_or(0)),
461                (dst.width().unwrap_or(0), dst.height().unwrap_or(0)),
462                rotation,
463            )
464        {
465            return Err(Error::NotSupported(
466                "G2D scales with a fixed filter; Interpolation::Nearest resizes elsewhere".into(),
467            ));
468        }
469        self.convert_impl(src, dst, rotation, flip, crop)
470    }
471
472    fn draw_decoded_masks(
473        &mut self,
474        dst: &mut TensorDyn,
475        detect: &[crate::DetectBox],
476        segmentation: &[crate::Segmentation],
477        overlay: crate::MaskOverlay<'_>,
478    ) -> Result<()> {
479        // G2D can produce the *frame* (background or cleared canvas) via
480        // hardware primitives — but has no rasterizer for boxes / masks.
481        // If detections are present, defer to another backend.
482        if !detect.is_empty() || !segmentation.is_empty() {
483            return Err(Error::NotImplemented(
484                "G2D does not support drawing detection or segmentation overlays".to_string(),
485            ));
486        }
487        draw_empty_frame_g2d(&mut self.g2d, dst, overlay.background)
488    }
489
490    fn draw_proto_masks(
491        &mut self,
492        dst: &mut TensorDyn,
493        detect: &[crate::DetectBox],
494        _proto_data: &crate::ProtoData,
495        overlay: crate::MaskOverlay<'_>,
496    ) -> Result<()> {
497        // Same logic as draw_decoded_masks: G2D handles empty-detection
498        // frames (clear or blit background) via the 2D hardware.
499        if !detect.is_empty() {
500            return Err(Error::NotImplemented(
501                "G2D does not support drawing detection or segmentation overlays".to_string(),
502            ));
503        }
504        draw_empty_frame_g2d(&mut self.g2d, dst, overlay.background)
505    }
506
507    fn set_class_colors(&mut self, _: &[[u8; 4]]) -> Result<()> {
508        Err(Error::NotImplemented(
509            "G2D does not support setting colors for rendering detection or segmentation overlays"
510                .to_string(),
511        ))
512    }
513
514    fn set_box_line_width(&mut self, _: u32) -> Result<()> {
515        Err(Error::NotImplemented(
516            "G2D does not support setting the box line width for rendering detection overlays"
517                .to_string(),
518        ))
519    }
520}
521
522/// Produce the zero-detection frame on `dst` using G2D hardware ops.
523///
524/// `background == None` → `g2d_clear(dst, 0x00000000)` — actively fills the
525/// destination with transparent black using the 2D engine (no CPU touch).
526///
527/// `background == Some(bg)` → `g2d_blit(bg → dst)` — hardware copy of bg
528/// into dst. This is the "draw the cleared overlay onto the background"
529/// path; G2D has no pure copy primitive, so we use the blit engine with a
530/// 1:1 same-format surface pair, which is the hardware equivalent.
531///
532/// Both paths `finish()` before returning so dst is coherent for any
533/// downstream reader.
534fn draw_empty_frame_g2d(
535    g2d: &mut G2D,
536    dst_dyn: &mut TensorDyn,
537    background: Option<&TensorDyn>,
538) -> Result<()> {
539    if dst_dyn.dtype() != DType::U8 {
540        return Err(Error::NotSupported(
541            "G2D only supports u8 destination tensors".to_string(),
542        ));
543    }
544    let dst = dst_dyn.as_typed_mut::<u8>().ok_or(Error::NotAnImage)?;
545
546    // DMA-only: G2D operates on physical addresses. A Mem-backed dst means
547    // we're on the wrong backend — surface a dispatch error so the caller
548    // can fall back.
549    // A capability probe, so it asks the backend-agnostic question.
550    // `as_dma()` answers `None` for EVERY tensor on the `dynamic` backend
551    // -- `DmaTensor<T>` is `static`-internal storage it never constructs --
552    // so this used to decline G2D unconditionally there, silently giving up
553    // the i.MX hardware path on the exact tensors it was written for. See
554    // task P2b's review, F3.
555    if TensorTrait::memory(dst) != TensorMemory::DmaBuf {
556        return Err(Error::NotImplemented(
557            "g2d only supports Dma memory".to_string(),
558        ));
559    }
560
561    let mut dst_surface = tensor_to_g2d_surface(dst)?;
562
563    match background {
564        None => {
565            // Case 1 — no background, no detections: hardware clear to
566            // transparent black. Every byte of dst is written by the 2D
567            // engine; no reliance on prior state.
568            let start = Instant::now();
569            g2d.clear(&mut dst_surface, [0, 0, 0, 0])?;
570            g2d.finish()?;
571            log::trace!("g2d clear (empty frame) takes {:?}", start.elapsed());
572        }
573        Some(bg_dyn) => {
574            // Case 2 — background, no detections: hardware blit bg → dst.
575            // Validate shape/format equivalence; if the caller handed us
576            // mismatched surfaces we return a structured error rather than
577            // silently producing wrong output.
578            if bg_dyn.shape() != dst.shape() {
579                return Err(Error::InvalidShape(
580                    "background shape does not match dst".into(),
581                ));
582            }
583            if bg_dyn.format() != dst.format() {
584                return Err(Error::InvalidShape(
585                    "background pixel format does not match dst".into(),
586                ));
587            }
588            if bg_dyn.dtype() != DType::U8 {
589                return Err(Error::NotSupported(
590                    "G2D only supports u8 background tensors".to_string(),
591                ));
592            }
593            let bg = bg_dyn.as_typed::<u8>().ok_or(Error::NotAnImage)?;
594            // Backend-agnostic probe -- see the `dst` check above.
595            if TensorTrait::memory(bg) != TensorMemory::DmaBuf {
596                return Err(Error::NotImplemented(
597                    "g2d background must be Dma-backed".to_string(),
598                ));
599            }
600            let src_surface = tensor_to_g2d_surface(bg)?;
601            let start = Instant::now();
602            g2d.blit(&src_surface, &dst_surface)?;
603            g2d.finish()?;
604            log::trace!("g2d blit (bg→dst) takes {:?}", start.elapsed());
605        }
606    }
607    Ok(())
608}
609
610/// Chroma subsampling of `fmt` as `(shift_x, shift_y)` — 1 = half resolution on
611/// that axis, 0 = full. Drives the i.MX95 DPU even-dimension workaround: a blit
612/// dimension that the format subsamples must be even or the DPU garbles it.
613fn chroma_subsample_shifts(fmt: PixelFormat) -> (u32, u32) {
614    match fmt.chroma_layout() {
615        Some(cl) => (cl.shift_x, cl.shift_y),
616        // Packed 4:2:2 (YUYV / VYUY) subsample horizontally; all other packed
617        // formats (RGB/RGBA/BGRA/Grey) are full-resolution.
618        None if matches!(fmt, PixelFormat::Yuyv | PixelFormat::Vyuy) => (1, 0),
619        None => (0, 0),
620    }
621}
622
623/// Build a `G2DSurface` from a `Tensor<u8>` that carries pixel-format metadata.
624///
625/// The tensor must be backed by DMA memory and must have a pixel format set.
626fn tensor_to_g2d_surface(img: &Tensor<u8>) -> Result<G2DSurface> {
627    let fmt = img.format().ok_or(Error::NotAnImage)?;
628    // `dmabuf()`, not `as_dma().fd`: same file descriptor, but reachable on
629    // both backends. See the probe above.
630    let fd = img
631        .dmabuf()
632        .map_err(|e| Error::NotImplemented(format!("g2d only supports Dma memory ({e})")))?;
633    let phys: G2DPhysical = fd.as_raw_fd().try_into()?;
634
635    // NV12 is a two-plane format: Y plane followed by interleaved UV plane.
636    // planes[0] = Y plane start, planes[1] = UV plane start (Y size = width * height)
637    //
638    // plane_offset is the byte offset within the DMA-BUF where pixel data
639    // starts.  G2D works with raw physical addresses so we must add the
640    // offset ourselves — the hardware has no concept of a per-plane offset.
641    let base_addr = phys.address();
642    let luma_offset = img.plane_offset().unwrap_or(0) as u64;
643    let planes = if fmt == PixelFormat::Nv12 {
644        if img.is_multiplane() {
645            // Multiplane: UV in separate DMA-BUF, get its physical address
646            let chroma = img.chroma().unwrap();
647            let chroma_fd = chroma.dmabuf().map_err(|e| {
648                Error::NotImplemented(format!("g2d multiplane chroma must be DMA-backed ({e})"))
649            })?;
650            let uv_phys: G2DPhysical = chroma_fd.as_raw_fd().try_into()?;
651            let chroma_offset = img.chroma().and_then(|c| c.plane_offset()).unwrap_or(0) as u64;
652            [
653                base_addr + luma_offset,
654                uv_phys.address() + chroma_offset,
655                0,
656            ]
657        } else {
658            let w = img.width().unwrap();
659            let h = img.height().unwrap();
660            let stride = img.effective_row_stride().unwrap_or(w.next_multiple_of(2));
661            let uv_offset = (luma_offset as usize + stride * h) as u64;
662            [base_addr + luma_offset, base_addr + uv_offset, 0]
663        }
664    } else {
665        [base_addr + luma_offset, 0, 0]
666    };
667
668    let w = img.width().unwrap();
669    let h = img.height().unwrap();
670    let fourcc = pixelfmt_to_fourcc(fmt);
671
672    // G2D stride is in pixels.  effective_row_stride() returns bytes, so
673    // divide by the bytes-per-pixel (channels for u8 data) to convert.
674    let stride_pixels = match img.effective_row_stride() {
675        Some(s) => {
676            let channels = fmt.channels();
677            if s % channels != 0 {
678                return Err(Error::NotImplemented(
679                    "g2d requires row stride to be a multiple of bytes-per-pixel".to_string(),
680                ));
681            }
682            s / channels
683        }
684        None => w,
685    };
686
687    Ok(G2DSurface {
688        planes,
689        format: G2DFormat::try_from(fourcc)?.format(),
690        left: 0,
691        top: 0,
692        right: w as i32,
693        bottom: h as i32,
694        stride: stride_pixels as i32,
695        width: w as i32,
696        height: h as i32,
697        blendfunc: 0,
698        clrcolor: 0,
699        rot: 0,
700        global_alpha: 0,
701    })
702}
703
704#[cfg(test)]
705mod g2d_predicate_tests {
706    use super::*;
707    use edgefirst_tensor::{ColorEncoding, ColorRange, Colorimetry};
708
709    #[test]
710    fn g2d_declines_full_range_and_bt2020_yuv() {
711        let full = Colorimetry::default()
712            .with_range(ColorRange::Full)
713            .with_encoding(ColorEncoding::Bt709);
714        let lim = Colorimetry::default()
715            .with_range(ColorRange::Limited)
716            .with_encoding(ColorEncoding::Bt709);
717        let bt2020 = Colorimetry::default()
718            .with_range(ColorRange::Limited)
719            .with_encoding(ColorEncoding::Bt2020);
720        assert!(!g2d_can_handle(&full, true)); // full-range YUV → decline
721        assert!(g2d_can_handle(&lim, true)); // limited 709 YUV → ok
722        assert!(!g2d_can_handle(&bt2020, true)); // BT.2020 → decline
723        assert!(g2d_can_handle(&full, false)); // no YUV side → rule N/A → ok
724    }
725
726    #[test]
727    fn post_pass_access_covers_the_int8_bias_and_the_rgb_border() {
728        use edgefirst_tensor::CpuAccess;
729        let pass = g2d_post_pass_access;
730        assert_eq!(
731            pass(PixelFormat::Rgba, DType::I8, false),
732            CpuAccess::ReadWrite
733        );
734        assert_eq!(
735            pass(PixelFormat::Rgb, DType::I8, true),
736            CpuAccess::ReadWrite
737        );
738        assert_eq!(pass(PixelFormat::Rgb, DType::U8, false), CpuAccess::None);
739        assert_eq!(pass(PixelFormat::Rgba, DType::U8, true), CpuAccess::None);
740        let dma = TensorMemory::DmaBuf;
741        assert_eq!(
742            dst_cpu_access(dma, PixelFormat::Bgra, DType::I8),
743            Some(CpuAccess::ReadWrite)
744        );
745        assert_eq!(
746            dst_cpu_access(dma, PixelFormat::Rgba, DType::U8),
747            Some(CpuAccess::None)
748        );
749        assert_eq!(dst_cpu_access(dma, PixelFormat::Nv12, DType::U8), None);
750        assert_eq!(dst_cpu_access(dma, PixelFormat::Rgba, DType::F32), None);
751        assert_eq!(
752            dst_cpu_access(TensorMemory::Mem, PixelFormat::Rgba, DType::U8),
753            None
754        );
755        assert!(g2d_supports(PixelFormat::Nv12, PixelFormat::Rgb));
756        assert!(!g2d_supports(PixelFormat::Bgra, PixelFormat::Rgba));
757        assert!(!g2d_supports(PixelFormat::Vyuy, PixelFormat::Rgba));
758    }
759
760    /// A destination whose declaration cannot cover the post-passes is
761    /// refused before any blit.
762    #[test]
763    fn post_passes_are_checked_against_the_destination_up_front() {
764        use edgefirst_tensor::{CpuAccess, Error as TensorError};
765        let image = |fmt, dtype, access| {
766            TensorDyn::image(64, 48, fmt, dtype, Some(TensorMemory::Mem), access).unwrap()
767        };
768        let border = ResolvedCrop {
769            src_rect: None,
770            dst_rect: Some(crate::Rect::new(0, 8, 64, 32)),
771            dst_color: Some([0, 0, 0, 255]),
772        };
773        let denied = |r: Result<()>, requested| match r {
774            Err(Error::Tensor(TensorError::CpuAccessDenied { requested: r, .. })) => {
775                assert_eq!(r, requested)
776            }
777            other => panic!("expected CpuAccessDenied, got {other:?}"),
778        };
779        let int8 = image(PixelFormat::Rgba, DType::I8, CpuAccess::Write);
780        denied(
781            require_post_pass_access(&int8, &ResolvedCrop::no_crop()),
782            CpuAccess::ReadWrite,
783        );
784        let rgb = image(PixelFormat::Rgb, DType::U8, CpuAccess::None);
785        denied(
786            require_post_pass_access(&rgb, &border),
787            CpuAccess::ReadWrite,
788        );
789        require_post_pass_access(&rgb, &ResolvedCrop::no_crop()).unwrap();
790        let rgba = image(PixelFormat::Rgba, DType::U8, CpuAccess::None);
791        require_post_pass_access(&rgba, &border).unwrap();
792    }
793
794    /// The `Rgb` border is a CPU write of part of a G2D-written buffer, so a
795    /// write-only destination is refused: its map would not invalidate.
796    #[test]
797    fn rgb_border_post_pass_needs_readwrite() {
798        use edgefirst_tensor::CpuAccess;
799        assert_eq!(
800            g2d_post_pass_access(PixelFormat::Rgb, DType::U8, true),
801            CpuAccess::ReadWrite
802        );
803        let border = ResolvedCrop {
804            src_rect: None,
805            dst_rect: Some(crate::Rect::new(8, 0, 48, 48)),
806            dst_color: Some([0, 0, 0, 255]),
807        };
808        let image = |access| {
809            TensorDyn::image(
810                64,
811                48,
812                PixelFormat::Rgb,
813                DType::U8,
814                Some(TensorMemory::Mem),
815                access,
816            )
817            .unwrap()
818        };
819        assert!(matches!(
820            require_post_pass_access(&image(CpuAccess::Write), &border),
821            Err(Error::Tensor(
822                edgefirst_tensor::Error::CpuAccessDenied { .. }
823            ))
824        ));
825        require_post_pass_access(&image(CpuAccess::ReadWrite), &border).unwrap();
826    }
827}
828
829#[cfg(test)]
830#[allow(deprecated)]
831mod g2d_tests {
832    use super::*;
833    use crate::test_support::is_dma_available;
834    use crate::{CPUProcessor, G2DProcessor, ImageProcessorTrait};
835    use edgefirst_tensor::{
836        DType, PixelFormat, TensorDyn, TensorMapTrait, TensorMemory, TensorTrait,
837    };
838    use image::buffer::ConvertBuffer;
839
840    /// G2D is usable only where `libg2d.so.2` loads — the NXP i.MX boards. On
841    /// V3D/Tegra/x86 the library is absent, so `G2DProcessor::new()` and the
842    /// surface physical-address resolution these tests exercise both fail. DMA
843    /// being available does NOT imply G2D (e.g. rpi5/V3D has DMA but no G2D), so
844    /// these tests must gate on G2D, not just DMA, or they panic/fail on
845    /// non-G2D hardware instead of skipping.
846    #[cfg(target_os = "linux")]
847    fn is_g2d_available() -> bool {
848        crate::test_support::g2d_required_or(G2DProcessor::new().is_ok())
849    }
850
851    /// Whether this host has a usable DMA heap and G2D; reports a skip when it has not.
852    #[cfg(target_os = "linux")]
853    fn dma_and_g2d_or_skip() -> bool {
854        let why = if !is_dma_available() {
855            "DMA not available"
856        } else if !is_g2d_available() {
857            "G2D not available"
858        } else {
859            return true;
860        };
861        crate::test_support::report_skip(&format!(
862            "{}: {why}",
863            crate::test_support::current_test()
864        ));
865        false
866    }
867
868    #[test]
869    #[cfg(target_os = "linux")]
870    fn test_g2d_formats_no_resize() {
871        if !dma_and_g2d_or_skip() {
872            return;
873        }
874        for i in [
875            PixelFormat::Rgba,
876            PixelFormat::Yuyv,
877            PixelFormat::Rgb,
878            PixelFormat::Grey,
879            PixelFormat::Nv12,
880        ] {
881            for o in [
882                PixelFormat::Rgba,
883                PixelFormat::Yuyv,
884                PixelFormat::Rgb,
885                PixelFormat::Grey,
886            ] {
887                let res = test_g2d_format_no_resize_(i, o);
888                if let Err(e) = res {
889                    crate::test_support::report_skip(&format!(
890                        "{}: {i} to {o} failed: {e:?}",
891                        crate::test_support::current_test()
892                    ));
893                }
894            }
895        }
896    }
897
898    fn test_g2d_format_no_resize_(
899        g2d_in_fmt: PixelFormat,
900        g2d_out_fmt: PixelFormat,
901    ) -> Result<(), crate::Error> {
902        let dst_width = 1280;
903        let dst_height = 720;
904        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
905        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgb), None)?;
906
907        // Create DMA buffer for G2D input
908        let mut src2 = TensorDyn::image(
909            1280,
910            720,
911            g2d_in_fmt,
912            DType::U8,
913            Some(TensorMemory::DmaBuf),
914            edgefirst_tensor::CpuAccess::ReadWrite,
915        )?;
916
917        let mut cpu_converter = CPUProcessor::new();
918
919        // For PixelFormat::Nv12 input, load from file since CPU doesn't support PixelFormat::Rgb→PixelFormat::Nv12
920        if g2d_in_fmt == PixelFormat::Nv12 {
921            let nv12_bytes = edgefirst_bench::testdata::read("zidane.nv12");
922            src2.as_typed::<u8>()
923                .unwrap()
924                .map()?
925                .as_mut_slice()
926                .copy_from_slice(&nv12_bytes);
927        } else {
928            cpu_converter.convert(&src, &mut src2, None)?;
929        }
930
931        let mut g2d_dst = TensorDyn::image(
932            dst_width,
933            dst_height,
934            g2d_out_fmt,
935            DType::U8,
936            Some(TensorMemory::DmaBuf),
937            edgefirst_tensor::CpuAccess::ReadWrite,
938        )?;
939        let mut g2d_converter = G2DProcessor::new()?;
940        let src2_dyn = src2;
941        let mut g2d_dst_dyn = g2d_dst;
942        g2d_converter.convert(&src2_dyn, &mut g2d_dst_dyn, None)?;
943        g2d_dst = {
944            let mut __t = g2d_dst_dyn.into_u8().unwrap();
945            __t.set_format(g2d_out_fmt)
946                .map_err(|e| crate::Error::Internal(e.to_string()))?;
947            TensorDyn::from(__t)
948        };
949
950        let mut cpu_dst = TensorDyn::image(
951            dst_width,
952            dst_height,
953            PixelFormat::Rgb,
954            DType::U8,
955            None,
956            edgefirst_tensor::CpuAccess::ReadWrite,
957        )?;
958        cpu_converter.convert(&g2d_dst, &mut cpu_dst, None)?;
959
960        compare_images(
961            &src,
962            &cpu_dst,
963            0.98,
964            &format!("{g2d_in_fmt}_to_{g2d_out_fmt}"),
965        )
966    }
967
968    #[test]
969    #[cfg(target_os = "linux")]
970    fn test_g2d_formats_with_resize() {
971        if !dma_and_g2d_or_skip() {
972            return;
973        }
974        for i in [
975            PixelFormat::Rgba,
976            PixelFormat::Yuyv,
977            PixelFormat::Rgb,
978            PixelFormat::Grey,
979            PixelFormat::Nv12,
980        ] {
981            for o in [
982                PixelFormat::Rgba,
983                PixelFormat::Yuyv,
984                PixelFormat::Rgb,
985                PixelFormat::Grey,
986            ] {
987                let res = test_g2d_format_with_resize_(i, o);
988                if let Err(e) = res {
989                    crate::test_support::report_skip(&format!(
990                        "{}: {i} to {o} failed: {e:?}",
991                        crate::test_support::current_test()
992                    ));
993                }
994            }
995        }
996    }
997
998    #[test]
999    #[cfg(target_os = "linux")]
1000    fn test_g2d_formats_with_resize_dst_crop() {
1001        if !dma_and_g2d_or_skip() {
1002            return;
1003        }
1004        for i in [
1005            PixelFormat::Rgba,
1006            PixelFormat::Yuyv,
1007            PixelFormat::Rgb,
1008            PixelFormat::Grey,
1009            PixelFormat::Nv12,
1010        ] {
1011            for o in [
1012                PixelFormat::Rgba,
1013                PixelFormat::Yuyv,
1014                PixelFormat::Rgb,
1015                PixelFormat::Grey,
1016            ] {
1017                let res = test_g2d_format_with_resize_dst_crop(i, o);
1018                if let Err(e) = res {
1019                    crate::test_support::report_skip(&format!(
1020                        "{}: {i} to {o} failed: {e:?}",
1021                        crate::test_support::current_test()
1022                    ));
1023                }
1024            }
1025        }
1026    }
1027
1028    fn test_g2d_format_with_resize_(
1029        g2d_in_fmt: PixelFormat,
1030        g2d_out_fmt: PixelFormat,
1031    ) -> Result<(), crate::Error> {
1032        let dst_width = 600;
1033        let dst_height = 400;
1034        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
1035        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgb), None)?;
1036
1037        let mut cpu_converter = CPUProcessor::new();
1038
1039        let mut reference = TensorDyn::image(
1040            dst_width,
1041            dst_height,
1042            PixelFormat::Rgb,
1043            DType::U8,
1044            Some(TensorMemory::DmaBuf),
1045            edgefirst_tensor::CpuAccess::ReadWrite,
1046        )?;
1047        cpu_converter.convert(&src, &mut reference, None)?;
1048
1049        // Create DMA buffer for G2D input
1050        let mut src2 = TensorDyn::image(
1051            1280,
1052            720,
1053            g2d_in_fmt,
1054            DType::U8,
1055            Some(TensorMemory::DmaBuf),
1056            edgefirst_tensor::CpuAccess::ReadWrite,
1057        )?;
1058
1059        // For PixelFormat::Nv12 input, load from file since CPU doesn't support PixelFormat::Rgb→PixelFormat::Nv12
1060        if g2d_in_fmt == PixelFormat::Nv12 {
1061            let nv12_bytes = edgefirst_bench::testdata::read("zidane.nv12");
1062            src2.as_typed::<u8>()
1063                .unwrap()
1064                .map()?
1065                .as_mut_slice()
1066                .copy_from_slice(&nv12_bytes);
1067        } else {
1068            cpu_converter.convert(&src, &mut src2, None)?;
1069        }
1070
1071        let mut g2d_dst = TensorDyn::image(
1072            dst_width,
1073            dst_height,
1074            g2d_out_fmt,
1075            DType::U8,
1076            Some(TensorMemory::DmaBuf),
1077            edgefirst_tensor::CpuAccess::ReadWrite,
1078        )?;
1079        let mut g2d_converter = G2DProcessor::new()?;
1080        let src2_dyn = src2;
1081        let mut g2d_dst_dyn = g2d_dst;
1082        g2d_converter.convert(&src2_dyn, &mut g2d_dst_dyn, None)?;
1083        g2d_dst = {
1084            let mut __t = g2d_dst_dyn.into_u8().unwrap();
1085            __t.set_format(g2d_out_fmt)
1086                .map_err(|e| crate::Error::Internal(e.to_string()))?;
1087            TensorDyn::from(__t)
1088        };
1089
1090        let mut cpu_dst = TensorDyn::image(
1091            dst_width,
1092            dst_height,
1093            PixelFormat::Rgb,
1094            DType::U8,
1095            None,
1096            edgefirst_tensor::CpuAccess::ReadWrite,
1097        )?;
1098        cpu_converter.convert(&g2d_dst, &mut cpu_dst, None)?;
1099
1100        compare_images(
1101            &reference,
1102            &cpu_dst,
1103            0.98,
1104            &format!("{g2d_in_fmt}_to_{g2d_out_fmt}_resized"),
1105        )
1106    }
1107
1108    fn test_g2d_format_with_resize_dst_crop(
1109        g2d_in_fmt: PixelFormat,
1110        g2d_out_fmt: PixelFormat,
1111    ) -> Result<(), crate::Error> {
1112        let dst_width = 600;
1113        let dst_height = 400;
1114        // The destination sub-rectangle is now expressed as a `view()` of the
1115        // destination (the old `Crop.dst_rect` was removed); G2D blits into the
1116        // view's window via its offset + parent stride. Region is (x=left, y=top,
1117        // width, height).
1118        let region = crate::Region::new(100, 100, 200, 100);
1119        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
1120        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgb), None)?;
1121
1122        let mut cpu_converter = CPUProcessor::new();
1123
1124        let reference = TensorDyn::image(
1125            dst_width,
1126            dst_height,
1127            PixelFormat::Rgb,
1128            DType::U8,
1129            Some(TensorMemory::DmaBuf),
1130            edgefirst_tensor::CpuAccess::ReadWrite,
1131        )?;
1132        reference
1133            .as_typed::<u8>()
1134            .unwrap()
1135            .map()
1136            .unwrap()
1137            .as_mut_slice()
1138            .fill(128);
1139        cpu_converter.convert(&src, &mut reference.view(region)?, None)?;
1140
1141        // Create DMA buffer for G2D input
1142        let mut src2 = TensorDyn::image(
1143            1280,
1144            720,
1145            g2d_in_fmt,
1146            DType::U8,
1147            Some(TensorMemory::DmaBuf),
1148            edgefirst_tensor::CpuAccess::ReadWrite,
1149        )?;
1150
1151        // For PixelFormat::Nv12 input, load from file since CPU doesn't support PixelFormat::Rgb→PixelFormat::Nv12
1152        if g2d_in_fmt == PixelFormat::Nv12 {
1153            let nv12_bytes = edgefirst_bench::testdata::read("zidane.nv12");
1154            src2.as_typed::<u8>()
1155                .unwrap()
1156                .map()?
1157                .as_mut_slice()
1158                .copy_from_slice(&nv12_bytes);
1159        } else {
1160            cpu_converter.convert(&src, &mut src2, None)?;
1161        }
1162
1163        let mut g2d_dst = TensorDyn::image(
1164            dst_width,
1165            dst_height,
1166            g2d_out_fmt,
1167            DType::U8,
1168            Some(TensorMemory::DmaBuf),
1169            edgefirst_tensor::CpuAccess::ReadWrite,
1170        )?;
1171        g2d_dst
1172            .as_typed::<u8>()
1173            .unwrap()
1174            .map()
1175            .unwrap()
1176            .as_mut_slice()
1177            .fill(128);
1178        let mut g2d_converter = G2DProcessor::new()?;
1179        let src2_dyn = src2;
1180        let g2d_dst_dyn = g2d_dst;
1181        g2d_converter.convert(&src2_dyn, &mut g2d_dst_dyn.view(region)?, None)?;
1182        g2d_dst = {
1183            let mut __t = g2d_dst_dyn.into_u8().unwrap();
1184            __t.set_format(g2d_out_fmt)
1185                .map_err(|e| crate::Error::Internal(e.to_string()))?;
1186            TensorDyn::from(__t)
1187        };
1188
1189        let mut cpu_dst = TensorDyn::image(
1190            dst_width,
1191            dst_height,
1192            PixelFormat::Rgb,
1193            DType::U8,
1194            None,
1195            edgefirst_tensor::CpuAccess::ReadWrite,
1196        )?;
1197        cpu_converter.convert(&g2d_dst, &mut cpu_dst, None)?;
1198
1199        compare_images(
1200            &reference,
1201            &cpu_dst,
1202            0.98,
1203            &format!("{g2d_in_fmt}_to_{g2d_out_fmt}_resized_dst_crop"),
1204        )
1205    }
1206
1207    fn compare_images(
1208        img1: &TensorDyn,
1209        img2: &TensorDyn,
1210        threshold: f64,
1211        name: &str,
1212    ) -> Result<(), crate::Error> {
1213        assert_eq!(img1.height(), img2.height(), "Heights differ");
1214        assert_eq!(img1.width(), img2.width(), "Widths differ");
1215        assert_eq!(
1216            img1.format().unwrap(),
1217            img2.format().unwrap(),
1218            "PixelFormat differ"
1219        );
1220        assert!(
1221            matches!(img1.format().unwrap(), PixelFormat::Rgb | PixelFormat::Rgba),
1222            "format must be Rgb or Rgba for comparison"
1223        );
1224        let image1 = match img1.format().unwrap() {
1225            PixelFormat::Rgb => image::RgbImage::from_vec(
1226                img1.width().unwrap() as u32,
1227                img1.height().unwrap() as u32,
1228                img1.as_typed::<u8>().unwrap().map().unwrap().to_vec(),
1229            )
1230            .unwrap(),
1231            PixelFormat::Rgba => image::RgbaImage::from_vec(
1232                img1.width().unwrap() as u32,
1233                img1.height().unwrap() as u32,
1234                img1.as_typed::<u8>().unwrap().map().unwrap().to_vec(),
1235            )
1236            .unwrap()
1237            .convert(),
1238
1239            _ => unreachable!(),
1240        };
1241
1242        let image2 = match img2.format().unwrap() {
1243            PixelFormat::Rgb => image::RgbImage::from_vec(
1244                img2.width().unwrap() as u32,
1245                img2.height().unwrap() as u32,
1246                img2.as_typed::<u8>().unwrap().map().unwrap().to_vec(),
1247            )
1248            .unwrap(),
1249            PixelFormat::Rgba => image::RgbaImage::from_vec(
1250                img2.width().unwrap() as u32,
1251                img2.height().unwrap() as u32,
1252                img2.as_typed::<u8>().unwrap().map().unwrap().to_vec(),
1253            )
1254            .unwrap()
1255            .convert(),
1256
1257            _ => unreachable!(),
1258        };
1259
1260        let similarity = image_compare::rgb_similarity_structure(
1261            &image_compare::Algorithm::RootMeanSquared,
1262            &image1,
1263            &image2,
1264        )
1265        .expect("Image Comparison failed");
1266
1267        if similarity.score < threshold {
1268            image1.save(format!("{name}_1.png")).unwrap();
1269            image2.save(format!("{name}_2.png")).unwrap();
1270            return Err(Error::Internal(format!(
1271                "{name}: converted image and target image have similarity score too low: {} < {}",
1272                similarity.score, threshold
1273            )));
1274        }
1275        Ok(())
1276    }
1277
1278    // =========================================================================
1279    // PixelFormat::Nv12 Reference Validation Tests
1280    // These tests compare G2D PixelFormat::Nv12 conversions against ffmpeg-generated references
1281    // =========================================================================
1282
1283    fn load_raw_image(
1284        width: usize,
1285        height: usize,
1286        format: PixelFormat,
1287        memory: Option<TensorMemory>,
1288        bytes: &[u8],
1289    ) -> Result<TensorDyn, crate::Error> {
1290        let img = TensorDyn::image(
1291            width,
1292            height,
1293            format,
1294            DType::U8,
1295            memory,
1296            edgefirst_tensor::CpuAccess::ReadWrite,
1297        )?;
1298        let mut map = img.as_typed::<u8>().unwrap().map()?;
1299        let dst = map.as_mut_slice();
1300        if bytes.len() > dst.len() {
1301            return Err(crate::Error::InvalidShape(format!(
1302                "load_raw_image: {} input bytes exceed {}-byte image buffer",
1303                bytes.len(),
1304                dst.len()
1305            )));
1306        }
1307        dst[..bytes.len()].copy_from_slice(bytes);
1308        Ok(img)
1309    }
1310
1311    /// Test G2D PixelFormat::Nv12→PixelFormat::Rgba conversion against ffmpeg reference
1312    #[test]
1313    #[cfg(target_os = "linux")]
1314    fn test_g2d_nv12_to_rgba_reference() -> Result<(), crate::Error> {
1315        if !dma_and_g2d_or_skip() {
1316            return Ok(());
1317        }
1318        // Load PixelFormat::Nv12 source
1319        let src = load_raw_image(
1320            1280,
1321            720,
1322            PixelFormat::Nv12,
1323            Some(TensorMemory::DmaBuf),
1324            &edgefirst_bench::testdata::read("camera720p.nv12"),
1325        )?;
1326
1327        // Load PixelFormat::Rgba reference (ffmpeg-generated)
1328        let reference = load_raw_image(
1329            1280,
1330            720,
1331            PixelFormat::Rgba,
1332            None,
1333            &edgefirst_bench::testdata::read("camera720p.rgba"),
1334        )?;
1335
1336        // Convert using G2D
1337        let mut dst = TensorDyn::image(
1338            1280,
1339            720,
1340            PixelFormat::Rgba,
1341            DType::U8,
1342            Some(TensorMemory::DmaBuf),
1343            edgefirst_tensor::CpuAccess::ReadWrite,
1344        )?;
1345        let mut g2d = G2DProcessor::new()?;
1346        let src_dyn = src;
1347        let mut dst_dyn = dst;
1348        g2d.convert(&src_dyn, &mut dst_dyn, None)?;
1349        dst = {
1350            let mut __t = dst_dyn.into_u8().unwrap();
1351            __t.set_format(PixelFormat::Rgba)
1352                .map_err(|e| crate::Error::Internal(e.to_string()))?;
1353            TensorDyn::from(__t)
1354        };
1355
1356        // Copy to CPU for comparison
1357        let cpu_dst = TensorDyn::image(
1358            1280,
1359            720,
1360            PixelFormat::Rgba,
1361            DType::U8,
1362            None,
1363            edgefirst_tensor::CpuAccess::ReadWrite,
1364        )?;
1365        cpu_dst
1366            .as_typed::<u8>()
1367            .unwrap()
1368            .map()?
1369            .as_mut_slice()
1370            .copy_from_slice(dst.as_typed::<u8>().unwrap().map()?.as_slice());
1371
1372        compare_images(&reference, &cpu_dst, 0.98, "g2d_nv12_to_rgba_reference")
1373    }
1374
1375    /// Test G2D PixelFormat::Nv12→PixelFormat::Rgb conversion against ffmpeg reference
1376    #[test]
1377    #[cfg(target_os = "linux")]
1378    fn test_g2d_nv12_to_rgb_reference() -> Result<(), crate::Error> {
1379        if !dma_and_g2d_or_skip() {
1380            return Ok(());
1381        }
1382        // Load PixelFormat::Nv12 source
1383        let src = load_raw_image(
1384            1280,
1385            720,
1386            PixelFormat::Nv12,
1387            Some(TensorMemory::DmaBuf),
1388            &edgefirst_bench::testdata::read("camera720p.nv12"),
1389        )?;
1390
1391        // Load PixelFormat::Rgb reference (ffmpeg-generated)
1392        let reference = load_raw_image(
1393            1280,
1394            720,
1395            PixelFormat::Rgb,
1396            None,
1397            &edgefirst_bench::testdata::read("camera720p.rgb"),
1398        )?;
1399
1400        // Convert using G2D
1401        let mut dst = TensorDyn::image(
1402            1280,
1403            720,
1404            PixelFormat::Rgb,
1405            DType::U8,
1406            Some(TensorMemory::DmaBuf),
1407            edgefirst_tensor::CpuAccess::ReadWrite,
1408        )?;
1409        let mut g2d = G2DProcessor::new()?;
1410        let src_dyn = src;
1411        let mut dst_dyn = dst;
1412        g2d.convert(&src_dyn, &mut dst_dyn, None)?;
1413        dst = {
1414            let mut __t = dst_dyn.into_u8().unwrap();
1415            __t.set_format(PixelFormat::Rgb)
1416                .map_err(|e| crate::Error::Internal(e.to_string()))?;
1417            TensorDyn::from(__t)
1418        };
1419
1420        // Copy to CPU for comparison
1421        let cpu_dst = TensorDyn::image(
1422            1280,
1423            720,
1424            PixelFormat::Rgb,
1425            DType::U8,
1426            None,
1427            edgefirst_tensor::CpuAccess::ReadWrite,
1428        )?;
1429        cpu_dst
1430            .as_typed::<u8>()
1431            .unwrap()
1432            .map()?
1433            .as_mut_slice()
1434            .copy_from_slice(dst.as_typed::<u8>().unwrap().map()?.as_slice());
1435
1436        compare_images(&reference, &cpu_dst, 0.98, "g2d_nv12_to_rgb_reference")
1437    }
1438
1439    /// Test G2D PixelFormat::Yuyv→PixelFormat::Rgba conversion against ffmpeg reference
1440    #[test]
1441    #[cfg(target_os = "linux")]
1442    fn test_g2d_yuyv_to_rgba_reference() -> Result<(), crate::Error> {
1443        if !dma_and_g2d_or_skip() {
1444            return Ok(());
1445        }
1446        // Load PixelFormat::Yuyv source
1447        let src = load_raw_image(
1448            1280,
1449            720,
1450            PixelFormat::Yuyv,
1451            Some(TensorMemory::DmaBuf),
1452            &edgefirst_bench::testdata::read("camera720p.yuyv"),
1453        )?;
1454
1455        // Load PixelFormat::Rgba reference (ffmpeg-generated)
1456        let reference = load_raw_image(
1457            1280,
1458            720,
1459            PixelFormat::Rgba,
1460            None,
1461            &edgefirst_bench::testdata::read("camera720p.rgba"),
1462        )?;
1463
1464        // Convert using G2D
1465        let mut dst = TensorDyn::image(
1466            1280,
1467            720,
1468            PixelFormat::Rgba,
1469            DType::U8,
1470            Some(TensorMemory::DmaBuf),
1471            edgefirst_tensor::CpuAccess::ReadWrite,
1472        )?;
1473        let mut g2d = G2DProcessor::new()?;
1474        let src_dyn = src;
1475        let mut dst_dyn = dst;
1476        g2d.convert(&src_dyn, &mut dst_dyn, None)?;
1477        dst = {
1478            let mut __t = dst_dyn.into_u8().unwrap();
1479            __t.set_format(PixelFormat::Rgba)
1480                .map_err(|e| crate::Error::Internal(e.to_string()))?;
1481            TensorDyn::from(__t)
1482        };
1483
1484        // Copy to CPU for comparison
1485        let cpu_dst = TensorDyn::image(
1486            1280,
1487            720,
1488            PixelFormat::Rgba,
1489            DType::U8,
1490            None,
1491            edgefirst_tensor::CpuAccess::ReadWrite,
1492        )?;
1493        cpu_dst
1494            .as_typed::<u8>()
1495            .unwrap()
1496            .map()?
1497            .as_mut_slice()
1498            .copy_from_slice(dst.as_typed::<u8>().unwrap().map()?.as_slice());
1499
1500        compare_images(&reference, &cpu_dst, 0.98, "g2d_yuyv_to_rgba_reference")
1501    }
1502
1503    /// Test G2D PixelFormat::Yuyv→PixelFormat::Rgb conversion against ffmpeg reference
1504    #[test]
1505    #[cfg(target_os = "linux")]
1506    fn test_g2d_yuyv_to_rgb_reference() -> Result<(), crate::Error> {
1507        if !dma_and_g2d_or_skip() {
1508            return Ok(());
1509        }
1510        // Load PixelFormat::Yuyv source
1511        let src = load_raw_image(
1512            1280,
1513            720,
1514            PixelFormat::Yuyv,
1515            Some(TensorMemory::DmaBuf),
1516            &edgefirst_bench::testdata::read("camera720p.yuyv"),
1517        )?;
1518
1519        // Load PixelFormat::Rgb reference (ffmpeg-generated)
1520        let reference = load_raw_image(
1521            1280,
1522            720,
1523            PixelFormat::Rgb,
1524            None,
1525            &edgefirst_bench::testdata::read("camera720p.rgb"),
1526        )?;
1527
1528        // Convert using G2D
1529        let mut dst = TensorDyn::image(
1530            1280,
1531            720,
1532            PixelFormat::Rgb,
1533            DType::U8,
1534            Some(TensorMemory::DmaBuf),
1535            edgefirst_tensor::CpuAccess::ReadWrite,
1536        )?;
1537        let mut g2d = G2DProcessor::new()?;
1538        let src_dyn = src;
1539        let mut dst_dyn = dst;
1540        g2d.convert(&src_dyn, &mut dst_dyn, None)?;
1541        dst = {
1542            let mut __t = dst_dyn.into_u8().unwrap();
1543            __t.set_format(PixelFormat::Rgb)
1544                .map_err(|e| crate::Error::Internal(e.to_string()))?;
1545            TensorDyn::from(__t)
1546        };
1547
1548        // Copy to CPU for comparison
1549        let cpu_dst = TensorDyn::image(
1550            1280,
1551            720,
1552            PixelFormat::Rgb,
1553            DType::U8,
1554            None,
1555            edgefirst_tensor::CpuAccess::ReadWrite,
1556        )?;
1557        cpu_dst
1558            .as_typed::<u8>()
1559            .unwrap()
1560            .map()?
1561            .as_mut_slice()
1562            .copy_from_slice(dst.as_typed::<u8>().unwrap().map()?.as_slice());
1563
1564        compare_images(&reference, &cpu_dst, 0.98, "g2d_yuyv_to_rgb_reference")
1565    }
1566
1567    /// Test G2D native PixelFormat::Bgra conversion for all supported source formats.
1568    /// Compares G2D src→PixelFormat::Bgra against G2D src→PixelFormat::Rgba by verifying R↔B swap.
1569    #[test]
1570    #[cfg(target_os = "linux")]
1571    #[ignore = "G2D on i.MX 8MP rejects BGRA as destination format; re-enable when supported"]
1572    fn test_g2d_bgra_no_resize() {
1573        for src_fmt in [
1574            PixelFormat::Rgba,
1575            PixelFormat::Yuyv,
1576            PixelFormat::Nv12,
1577            PixelFormat::Bgra,
1578        ] {
1579            test_g2d_bgra_no_resize_(src_fmt).unwrap_or_else(|e| {
1580                panic!("{src_fmt} to PixelFormat::Bgra failed: {e:?}");
1581            });
1582        }
1583    }
1584
1585    fn test_g2d_bgra_no_resize_(g2d_in_fmt: PixelFormat) -> Result<(), crate::Error> {
1586        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
1587        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgb), None)?;
1588
1589        // Create DMA buffer for G2D input
1590        let mut src2 = TensorDyn::image(
1591            1280,
1592            720,
1593            g2d_in_fmt,
1594            DType::U8,
1595            Some(TensorMemory::DmaBuf),
1596            edgefirst_tensor::CpuAccess::ReadWrite,
1597        )?;
1598        let mut cpu_converter = CPUProcessor::new();
1599
1600        if g2d_in_fmt == PixelFormat::Nv12 {
1601            let nv12_bytes = edgefirst_bench::testdata::read("zidane.nv12");
1602            src2.as_typed::<u8>()
1603                .unwrap()
1604                .map()?
1605                .as_mut_slice()
1606                .copy_from_slice(&nv12_bytes);
1607        } else {
1608            cpu_converter.convert(&src, &mut src2, None)?;
1609        }
1610
1611        let mut g2d = G2DProcessor::new()?;
1612
1613        // Convert to PixelFormat::Bgra via G2D
1614        let mut bgra_dst = TensorDyn::image(
1615            1280,
1616            720,
1617            PixelFormat::Bgra,
1618            DType::U8,
1619            Some(TensorMemory::DmaBuf),
1620            edgefirst_tensor::CpuAccess::ReadWrite,
1621        )?;
1622        let src2_dyn = src2;
1623        let mut bgra_dst_dyn = bgra_dst;
1624        g2d.convert(&src2_dyn, &mut bgra_dst_dyn, None)?;
1625        bgra_dst = {
1626            let mut __t = bgra_dst_dyn.into_u8().unwrap();
1627            __t.set_format(PixelFormat::Bgra)
1628                .map_err(|e| crate::Error::Internal(e.to_string()))?;
1629            TensorDyn::from(__t)
1630        };
1631
1632        // Reconstruct src2 from dyn for PixelFormat::Rgba conversion
1633        let src2 = {
1634            let mut __t = src2_dyn.into_u8().unwrap();
1635            __t.set_format(g2d_in_fmt)
1636                .map_err(|e| crate::Error::Internal(e.to_string()))?;
1637            TensorDyn::from(__t)
1638        };
1639
1640        // Convert to PixelFormat::Rgba via G2D as reference
1641        let mut rgba_dst = TensorDyn::image(
1642            1280,
1643            720,
1644            PixelFormat::Rgba,
1645            DType::U8,
1646            Some(TensorMemory::DmaBuf),
1647            edgefirst_tensor::CpuAccess::ReadWrite,
1648        )?;
1649        let src2_dyn2 = src2;
1650        let mut rgba_dst_dyn = rgba_dst;
1651        g2d.convert(&src2_dyn2, &mut rgba_dst_dyn, None)?;
1652        rgba_dst = {
1653            let mut __t = rgba_dst_dyn.into_u8().unwrap();
1654            __t.set_format(PixelFormat::Rgba)
1655                .map_err(|e| crate::Error::Internal(e.to_string()))?;
1656            TensorDyn::from(__t)
1657        };
1658
1659        // Copy both to CPU memory for comparison
1660        let bgra_cpu = TensorDyn::image(
1661            1280,
1662            720,
1663            PixelFormat::Bgra,
1664            DType::U8,
1665            None,
1666            edgefirst_tensor::CpuAccess::ReadWrite,
1667        )?;
1668        bgra_cpu
1669            .as_typed::<u8>()
1670            .unwrap()
1671            .map()?
1672            .as_mut_slice()
1673            .copy_from_slice(bgra_dst.as_typed::<u8>().unwrap().map()?.as_slice());
1674
1675        let rgba_cpu = TensorDyn::image(
1676            1280,
1677            720,
1678            PixelFormat::Rgba,
1679            DType::U8,
1680            None,
1681            edgefirst_tensor::CpuAccess::ReadWrite,
1682        )?;
1683        rgba_cpu
1684            .as_typed::<u8>()
1685            .unwrap()
1686            .map()?
1687            .as_mut_slice()
1688            .copy_from_slice(rgba_dst.as_typed::<u8>().unwrap().map()?.as_slice());
1689
1690        // Verify PixelFormat::Bgra output has R↔B swapped vs PixelFormat::Rgba output
1691        let bgra_map = bgra_cpu.as_typed::<u8>().unwrap().map()?;
1692        let rgba_map = rgba_cpu.as_typed::<u8>().unwrap().map()?;
1693        let bgra_buf = bgra_map.as_slice();
1694        let rgba_buf = rgba_map.as_slice();
1695
1696        assert_eq!(bgra_buf.len(), rgba_buf.len());
1697        for (i, (bc, rc)) in bgra_buf
1698            .as_chunks::<4>()
1699            .0
1700            .iter()
1701            .zip(rgba_buf.as_chunks::<4>().0)
1702            .enumerate()
1703        {
1704            assert_eq!(
1705                bc[0], rc[2],
1706                "{g2d_in_fmt} to PixelFormat::Bgra: pixel {i} B mismatch",
1707            );
1708            assert_eq!(
1709                bc[1], rc[1],
1710                "{g2d_in_fmt} to PixelFormat::Bgra: pixel {i} G mismatch",
1711            );
1712            assert_eq!(
1713                bc[2], rc[0],
1714                "{g2d_in_fmt} to PixelFormat::Bgra: pixel {i} R mismatch",
1715            );
1716            assert_eq!(
1717                bc[3], rc[3],
1718                "{g2d_in_fmt} to PixelFormat::Bgra: pixel {i} A mismatch",
1719            );
1720        }
1721        Ok(())
1722    }
1723
1724    // =========================================================================
1725    // tensor_to_g2d_surface offset & stride unit tests
1726    //
1727    // These tests verify that plane_offset and effective_row_stride are
1728    // correctly propagated into the G2DSurface.  They require DMA memory
1729    // but do NOT require G2D hardware — only the DMA_BUF_IOCTL_PHYS ioctl.
1730    // =========================================================================
1731
1732    /// Helper: build a DMA-backed Tensor<u8> with an optional plane_offset
1733    /// and an optional row stride, then return the G2DSurface.
1734    fn surface_for(
1735        width: usize,
1736        height: usize,
1737        fmt: PixelFormat,
1738        offset: Option<usize>,
1739        row_stride: Option<usize>,
1740    ) -> Result<G2DSurface, crate::Error> {
1741        use edgefirst_tensor::TensorMemory;
1742        let mut t = Tensor::<u8>::image(
1743            width,
1744            height,
1745            fmt,
1746            Some(TensorMemory::DmaBuf),
1747            edgefirst_tensor::CpuAccess::ReadWrite,
1748        )?;
1749        if let Some(o) = offset {
1750            t.set_plane_offset(o);
1751        }
1752        if let Some(s) = row_stride {
1753            t.set_row_stride_unchecked(s);
1754        }
1755        tensor_to_g2d_surface(&t)
1756    }
1757
1758    #[test]
1759    fn g2d_surface_single_plane_no_offset() {
1760        if !dma_and_g2d_or_skip() {
1761            return;
1762        }
1763        let s = surface_for(640, 480, PixelFormat::Rgba, None, None).unwrap();
1764        // planes[0] must be non-zero (valid physical address), no offset
1765        assert_ne!(s.planes[0], 0);
1766        assert_eq!(s.stride, 640);
1767    }
1768
1769    #[test]
1770    fn g2d_surface_single_plane_with_offset() {
1771        if !dma_and_g2d_or_skip() {
1772            return;
1773        }
1774        use edgefirst_tensor::TensorMemory;
1775        let mut t = Tensor::<u8>::image(
1776            640,
1777            480,
1778            PixelFormat::Rgba,
1779            Some(TensorMemory::DmaBuf),
1780            edgefirst_tensor::CpuAccess::ReadWrite,
1781        )
1782        .unwrap();
1783        let s0 = tensor_to_g2d_surface(&t).unwrap();
1784        t.set_plane_offset(4096);
1785        let s1 = tensor_to_g2d_surface(&t).unwrap();
1786        assert_eq!(s1.planes[0], s0.planes[0] + 4096);
1787    }
1788
1789    #[test]
1790    fn g2d_surface_single_plane_zero_offset() {
1791        if !dma_and_g2d_or_skip() {
1792            return;
1793        }
1794        use edgefirst_tensor::TensorMemory;
1795        let mut t = Tensor::<u8>::image(
1796            640,
1797            480,
1798            PixelFormat::Rgba,
1799            Some(TensorMemory::DmaBuf),
1800            edgefirst_tensor::CpuAccess::ReadWrite,
1801        )
1802        .unwrap();
1803        let s_none = tensor_to_g2d_surface(&t).unwrap();
1804        t.set_plane_offset(0);
1805        let s_zero = tensor_to_g2d_surface(&t).unwrap();
1806        // offset=0 should produce the same address as no offset
1807        assert_eq!(s_none.planes[0], s_zero.planes[0]);
1808    }
1809
1810    #[test]
1811    fn g2d_surface_stride_rgba() {
1812        if !dma_and_g2d_or_skip() {
1813            return;
1814        }
1815        // Default stride: width in pixels = 640
1816        let s_default = surface_for(640, 480, PixelFormat::Rgba, None, None).unwrap();
1817        assert_eq!(s_default.stride, 640);
1818
1819        // Custom stride: 2816 bytes / 4 channels = 704 pixels
1820        let s_custom = surface_for(640, 480, PixelFormat::Rgba, None, Some(2816)).unwrap();
1821        assert_eq!(s_custom.stride, 704);
1822    }
1823
1824    #[test]
1825    fn g2d_surface_stride_rgb() {
1826        if !dma_and_g2d_or_skip() {
1827            return;
1828        }
1829        let s_default = surface_for(640, 480, PixelFormat::Rgb, None, None).unwrap();
1830        assert_eq!(s_default.stride, 640);
1831
1832        // Padded: 1980 bytes / 3 channels = 660 pixels
1833        let s_custom = surface_for(640, 480, PixelFormat::Rgb, None, Some(1980)).unwrap();
1834        assert_eq!(s_custom.stride, 660);
1835    }
1836
1837    #[test]
1838    fn g2d_surface_stride_grey() {
1839        if !dma_and_g2d_or_skip() {
1840            return;
1841        }
1842        // Grey (Y800) may not be supported by all G2D hardware versions
1843        let s = match surface_for(640, 480, PixelFormat::Grey, None, Some(1024)) {
1844            Ok(s) => s,
1845            Err(e @ crate::Error::G2D(..)) => {
1846                crate::test_support::report_skip(&format!(
1847                    "g2d_surface_stride_grey: this G2D does not support Y800: {e:?}"
1848                ));
1849                return;
1850            }
1851            Err(e) => panic!("unexpected error: {e:?}"),
1852        };
1853        // Grey: 1 channel. stride in bytes = stride in pixels
1854        assert_eq!(s.stride, 1024);
1855    }
1856
1857    #[test]
1858    fn g2d_surface_contiguous_nv12_offset() {
1859        if !dma_and_g2d_or_skip() {
1860            return;
1861        }
1862        use edgefirst_tensor::TensorMemory;
1863        let mut t = Tensor::<u8>::image(
1864            640,
1865            480,
1866            PixelFormat::Nv12,
1867            Some(TensorMemory::DmaBuf),
1868            edgefirst_tensor::CpuAccess::ReadWrite,
1869        )
1870        .unwrap();
1871        let s0 = tensor_to_g2d_surface(&t).unwrap();
1872
1873        t.set_plane_offset(8192);
1874        let s1 = tensor_to_g2d_surface(&t).unwrap();
1875
1876        // Luma plane should shift by offset
1877        assert_eq!(s1.planes[0], s0.planes[0] + 8192);
1878        // UV plane = base + offset + stride * height
1879        // Without offset: UV = base + 640 * 480 = base + 307200
1880        // With offset 8192: UV = base + 8192 + 640 * 480 = base + 315392
1881        assert_eq!(s1.planes[1], s0.planes[1] + 8192);
1882    }
1883
1884    #[test]
1885    fn g2d_surface_contiguous_nv12_stride() {
1886        if !dma_and_g2d_or_skip() {
1887            return;
1888        }
1889        // NV12: 1 byte per pixel for Y. stride 640 bytes = 640 pixels.
1890        let s = surface_for(640, 480, PixelFormat::Nv12, None, None).unwrap();
1891        assert_eq!(s.stride, 640);
1892
1893        // Padded stride: 1024 bytes = 1024 pixels (NV12 channels = 1)
1894        let s_padded = surface_for(640, 480, PixelFormat::Nv12, None, Some(1024)).unwrap();
1895        assert_eq!(s_padded.stride, 1024);
1896    }
1897
1898    #[test]
1899    fn g2d_surface_multiplane_nv12_offset() {
1900        if !dma_and_g2d_or_skip() {
1901            return;
1902        }
1903        use edgefirst_tensor::TensorMemory;
1904
1905        // Create luma and chroma as separate DMA tensors
1906        let mut luma =
1907            Tensor::<u8>::new(&[480, 640], Some(TensorMemory::DmaBuf), Some("luma")).unwrap();
1908        let mut chroma =
1909            Tensor::<u8>::new(&[240, 640], Some(TensorMemory::DmaBuf), Some("chroma")).unwrap();
1910
1911        // Get baseline physical addresses with no offsets
1912        let luma_base = {
1913            let dma = luma.as_dma().unwrap();
1914            let phys: G2DPhysical = dma.fd.as_raw_fd().try_into().unwrap();
1915            phys.address()
1916        };
1917        let chroma_base = {
1918            let dma = chroma.as_dma().unwrap();
1919            let phys: G2DPhysical = dma.fd.as_raw_fd().try_into().unwrap();
1920            phys.address()
1921        };
1922
1923        // Set offsets and build multiplane tensor
1924        luma.set_plane_offset(4096);
1925        chroma.set_plane_offset(2048);
1926        let combined = Tensor::<u8>::from_planes(luma, chroma, PixelFormat::Nv12).unwrap();
1927        let s = tensor_to_g2d_surface(&combined).unwrap();
1928
1929        // Luma should include its offset
1930        assert_eq!(s.planes[0], luma_base + 4096);
1931        // Chroma should include its offset
1932        assert_eq!(s.planes[1], chroma_base + 2048);
1933    }
1934
1935    // =========================================================================
1936    // Odd-dimension end-to-end cells (Deliverable C)
1937    //
1938    // Design contract:
1939    //   • Source is a patterned NV12 tensor (Dma) filled stride-aware with a
1940    //     pattern varying in both X and Y.  A Mem copy carries the same fill for
1941    //     the CPU reference (trusted oracle from odd_dim_cpu.rs).
1942    //   • G2D output (Dma RGBA) is compared against the CPU output pixel-by-pixel
1943    //     at each tensor's own `effective_row_stride`.
1944    //   • Tolerance: ±4 (same as GL).  G2D BT.601 is limited-range on the matrix
1945    //     but historically matches within ±4 on real hardware; the tolerance is
1946    //     tightened to ±6 only if on-target validation shows it is needed.
1947    //   • If G2D rejects the dimensions (returns Err), the test documents the
1948    //     behaviour via `assert!` rather than silently passing.
1949    //   • Both test cells skip at runtime if DMA is unavailable or G2D init fails.
1950    // =========================================================================
1951
1952    /// Fill a Nv12 tensor (any memory type) with a patterned, stride-aware
1953    /// synthetic image that varies in both X and Y.
1954    ///
1955    /// Pattern: `Y(r,c) = (r*3 + c*5) % 256`
1956    /// Chroma column `cc`, chroma row `cr_row` (NV12: 4:2:0 — half in both dims):
1957    ///   `Cb = (cc*7 + cr_row*11 + 40) % 256`
1958    ///   `Cr = (cc*13 + cr_row*3 + 80) % 256`
1959    ///
1960    /// This matches `make_odd_both_source` in `odd_dim_cpu.rs` so the two test
1961    /// suites share an analytic ground truth.
1962    #[cfg(target_os = "linux")]
1963    fn fill_patterned_nv12(t: &TensorDyn) {
1964        let w = t.width().unwrap();
1965        let h = t.height().unwrap();
1966        let stride = t.effective_row_stride().unwrap();
1967
1968        // NV12 (4:2:0): chroma is ceil(H/2) rows of W/2 pairs.
1969        let chroma_h = h.div_ceil(2);
1970        let chroma_w = w.div_ceil(2);
1971
1972        let bound = t.as_typed::<u8>().unwrap();
1973        let mut m = bound.map().unwrap();
1974        let buf = m.as_mut_slice();
1975        let uv_start = stride * h;
1976
1977        // Luma: diagonal gradient.
1978        for r in 0..h {
1979            for c in 0..w {
1980                buf[r * stride + c] = ((r * 3 + c * 5) % 256) as u8;
1981            }
1982        }
1983        // Chroma: UV row pitch == stride for NV12.
1984        for cr_row in 0..chroma_h {
1985            for cc in 0..chroma_w {
1986                let cb_val = ((cc * 7 + cr_row * 11 + 40) % 256) as u8;
1987                let cr_val = ((cc * 13 + cr_row * 3 + 80) % 256) as u8;
1988                let uv_byte = uv_start + cr_row * stride + cc * 2;
1989                buf[uv_byte] = cb_val;
1990                buf[uv_byte + 1] = cr_val;
1991            }
1992        }
1993    }
1994
1995    /// Compare a G2D RGBA `u8` DMA tensor against a CPU RGBA `u8` reference,
1996    /// reading both at their real `effective_row_stride`.
1997    ///
1998    /// Returns `(max_diff, first_pixel_over_threshold)`.
1999    #[cfg(target_os = "linux")]
2000    fn compare_g2d_vs_cpu_rgba(
2001        g2d_dst: &TensorDyn,
2002        cpu_dst: &TensorDyn,
2003        w: usize,
2004        h: usize,
2005        tol: u32,
2006    ) -> (u32, Option<(usize, usize, usize)>) {
2007        let channels = 4usize; // RGBA
2008        let g2d_t = g2d_dst.as_typed::<u8>().unwrap();
2009        let cpu_t = cpu_dst.as_typed::<u8>().unwrap();
2010        let g2d_stride = g2d_t.effective_row_stride().unwrap_or(w * channels);
2011        let cpu_stride = cpu_t.effective_row_stride().unwrap_or(w * channels);
2012        let g2d_map = g2d_t.map().unwrap();
2013        let cpu_map = cpu_t.map().unwrap();
2014        let g2d_px = g2d_map.as_slice();
2015        let cpu_px = cpu_map.as_slice();
2016        let mut max_diff = 0u32;
2017        let mut first_fail: Option<(usize, usize, usize)> = None;
2018        for row in 0..h {
2019            for col in 0..w {
2020                for ch in 0..3usize {
2021                    // Compare RGB channels only; alpha is hardware-defined
2022                    let gi = row * g2d_stride + col * channels + ch;
2023                    let ci = row * cpu_stride + col * channels + ch;
2024                    let d = (g2d_px[gi] as i32 - cpu_px[ci] as i32).unsigned_abs();
2025                    if d > max_diff {
2026                        max_diff = d;
2027                    }
2028                    if first_fail.is_none() && d > tol {
2029                        first_fail = Some((col, row, ch));
2030                    }
2031                }
2032            }
2033        }
2034        (max_diff, first_fail)
2035    }
2036
2037    // -------------------------------------------------------------------------
2038    // D-01: NV12 odd-W (65×64) → RGBA via G2D vs CPU reference
2039    // -------------------------------------------------------------------------
2040
2041    /// D-01: NV12 odd-width (65×64) → RGBA via G2D, compared to CPU reference.
2042    ///
2043    /// Asserts:
2044    ///   (a) G2D accepts odd-width NV12 without returning an error.
2045    ///   (b) G2D output matches CPU reference within ±4 on RGB channels.
2046    ///       (Alpha is hardware-defined and excluded from the comparison.)
2047    ///
2048    /// On-target validation has since run: the G2D-vs-CPU residual on an
2049    /// i.MX8MP is 23–24 across widths 63–81, so the ±4 tolerance only warns and
2050    /// the `max_diff <= 35` assertion is what actually fails. Do not relax
2051    /// either without a new measurement on the board.
2052    #[test]
2053    #[cfg(target_os = "linux")]
2054    fn d01_nv12_odd_w_g2d_vs_cpu() {
2055        if !is_dma_available() {
2056            crate::test_support::report_skip("d01_nv12_odd_w_g2d_vs_cpu - DMA not available");
2057            return;
2058        }
2059        let (w, h) = (65usize, 64usize);
2060
2061        let src_dma = TensorDyn::image(
2062            w,
2063            h,
2064            PixelFormat::Nv12,
2065            DType::U8,
2066            Some(TensorMemory::DmaBuf),
2067            edgefirst_tensor::CpuAccess::ReadWrite,
2068        )
2069        .unwrap();
2070        let src_mem = TensorDyn::image(
2071            w,
2072            h,
2073            PixelFormat::Nv12,
2074            DType::U8,
2075            Some(TensorMemory::Mem),
2076            edgefirst_tensor::CpuAccess::ReadWrite,
2077        )
2078        .unwrap();
2079        fill_patterned_nv12(&src_dma);
2080        fill_patterned_nv12(&src_mem);
2081
2082        // CPU reference: Nv12 → Rgba.
2083        let mut cpu_dst = TensorDyn::image(
2084            w,
2085            h,
2086            PixelFormat::Rgba,
2087            DType::U8,
2088            None,
2089            edgefirst_tensor::CpuAccess::ReadWrite,
2090        )
2091        .unwrap();
2092        CPUProcessor::new()
2093            .convert(&src_mem, &mut cpu_dst, None)
2094            .unwrap();
2095
2096        // G2D convert: Nv12 → Rgba.
2097        let mut g2d_dst = TensorDyn::image(
2098            w,
2099            h,
2100            PixelFormat::Rgba,
2101            DType::U8,
2102            Some(TensorMemory::DmaBuf),
2103            edgefirst_tensor::CpuAccess::ReadWrite,
2104        )
2105        .unwrap();
2106        let mut g2d = match G2DProcessor::new() {
2107            Ok(g) => g,
2108            Err(e) => {
2109                crate::test_support::g2d_required_or(false);
2110                crate::test_support::report_skip(&format!(
2111                    "d01_nv12_odd_w_g2d_vs_cpu - G2D not available: {e}"
2112                ));
2113                return;
2114            }
2115        };
2116
2117        // (a) G2D must accept odd-width NV12 without error.
2118        g2d.convert(&src_dma, &mut g2d_dst, None)
2119            .unwrap_or_else(|e| {
2120                panic!("D-01: G2D rejected odd-width (65) NV12 source: {e}");
2121            });
2122
2123        // (b) G2D output ≈ CPU reference ±4 on RGB channels.
2124        let tol = 4u32;
2125        let (max_diff, first_fail) = compare_g2d_vs_cpu_rgba(&g2d_dst, &cpu_dst, w, h, tol);
2126        eprintln!("D-01 NV12 odd-W G2D vs CPU: max_diff={max_diff}");
2127        // Both CPU and G2D resolve this untagged odd-W NV12 source to
2128        // limited-range BT.601 (G2D is limited-range matrix-only), so the
2129        // YUV-matrix delta that once forced the loose >64 bound has closed. What
2130        // remains is G2D's own fixed-point rounding; its exact mechanism has not
2131        // been characterised, only its magnitude, measured below.
2132        //
2133        // Measured on an i.MX8MP (the only G2D board): max_diff 23 at 63×64,
2134        // 64×64, 65×64 and 65×63, and 24 at 80×64 and 81×64. The >35 bound has
2135        // ~11 of headroom over that and still trips a real geometry/stride
2136        // regression — the odd-W stride handling this test guards.
2137        //
2138        // History: this bound was tightened 64 → 35 without a hardware run (the
2139        // imx8mp lane had stopped executing), and the first run that reached it
2140        // read 255. That was NOT G2D: the pattern's `% 256` ramps synthesise
2141        // super-white luma, which the CPU reference's fast kernel wrapped to
2142        // black. See `fast_path_luma` in `cpu/convert.rs`. The full-range
2143        // pattern is deliberate — it is what caught that bug — so keep it.
2144        if max_diff > tol {
2145            eprintln!(
2146                "WARNING: D-01 NV12 odd-W G2D vs CPU max_diff={max_diff} > {tol} \
2147                 (G2D fixed-point rounding; first bad at {first_fail:?})"
2148            );
2149        }
2150        assert!(
2151            max_diff <= 35,
2152            "D-01: gross NV12 odd-W G2D vs CPU mismatch max_diff={max_diff} (>35); first bad at {first_fail:?}"
2153        );
2154    }
2155
2156    // -------------------------------------------------------------------------
2157    // D-03: NV12 odd-both (65×63) → RGBA via G2D vs CPU reference
2158    // -------------------------------------------------------------------------
2159
2160    /// D-03: NV12 odd-width AND odd-height (65×63) → RGBA via G2D vs CPU reference.
2161    ///
2162    /// This is the strictest G2D odd-dimension cell: it exercises both the
2163    /// stride-boundary at the last luma row AND the half-height chroma plane
2164    /// boundary.  If the G2D hardware rejects the conversion (e.g. hardware
2165    /// alignment requirement on chroma height), this test documents that via a
2166    /// panic rather than passing silently.
2167    #[test]
2168    #[cfg(target_os = "linux")]
2169    fn d03_nv12_odd_both_g2d_vs_cpu() {
2170        if !is_dma_available() {
2171            crate::test_support::report_skip("d03_nv12_odd_both_g2d_vs_cpu - DMA not available");
2172            return;
2173        }
2174        let (w, h) = (65usize, 63usize);
2175
2176        let src_dma = TensorDyn::image(
2177            w,
2178            h,
2179            PixelFormat::Nv12,
2180            DType::U8,
2181            Some(TensorMemory::DmaBuf),
2182            edgefirst_tensor::CpuAccess::ReadWrite,
2183        )
2184        .unwrap();
2185        let src_mem = TensorDyn::image(
2186            w,
2187            h,
2188            PixelFormat::Nv12,
2189            DType::U8,
2190            Some(TensorMemory::Mem),
2191            edgefirst_tensor::CpuAccess::ReadWrite,
2192        )
2193        .unwrap();
2194        fill_patterned_nv12(&src_dma);
2195        fill_patterned_nv12(&src_mem);
2196
2197        // CPU reference: Nv12 → Rgba.
2198        let mut cpu_dst = TensorDyn::image(
2199            w,
2200            h,
2201            PixelFormat::Rgba,
2202            DType::U8,
2203            None,
2204            edgefirst_tensor::CpuAccess::ReadWrite,
2205        )
2206        .unwrap();
2207        CPUProcessor::new()
2208            .convert(&src_mem, &mut cpu_dst, None)
2209            .unwrap();
2210
2211        // G2D convert: Nv12 → Rgba.
2212        let mut g2d_dst = TensorDyn::image(
2213            w,
2214            h,
2215            PixelFormat::Rgba,
2216            DType::U8,
2217            Some(TensorMemory::DmaBuf),
2218            edgefirst_tensor::CpuAccess::ReadWrite,
2219        )
2220        .unwrap();
2221        let mut g2d = match G2DProcessor::new() {
2222            Ok(g) => g,
2223            Err(e) => {
2224                crate::test_support::g2d_required_or(false);
2225                crate::test_support::report_skip(&format!(
2226                    "d03_nv12_odd_both_g2d_vs_cpu - G2D not available: {e}"
2227                ));
2228                return;
2229            }
2230        };
2231
2232        // G2D must accept odd-both NV12 without error (documented behaviour).
2233        // If hardware rejects it, the panic message is the intended failure report.
2234        g2d.convert(&src_dma, &mut g2d_dst, None)
2235            .unwrap_or_else(|e| {
2236                panic!("D-03: G2D rejected odd-both (65×63) NV12 source: {e}");
2237            });
2238
2239        let tol = 4u32;
2240        let (max_diff, first_fail) = compare_g2d_vs_cpu_rgba(&g2d_dst, &cpu_dst, w, h, tol);
2241        eprintln!("D-03 NV12 odd-both G2D vs CPU: max_diff={max_diff}");
2242        // The YUV-matrix delta has closed — see d01 above for the measured
2243        // per-width figures behind the >35 bound, and for why this pattern's
2244        // super-white luma must stay. Warn above ±tol on G2D fixed-point
2245        // rounding, fail only on a gross >35 geometry/stride regression.
2246        if max_diff > tol {
2247            eprintln!(
2248                "WARNING: D-03 NV12 odd-both G2D vs CPU max_diff={max_diff} > {tol} \
2249                 (G2D fixed-point rounding; first bad at {first_fail:?})"
2250            );
2251        }
2252        assert!(
2253            max_diff <= 35,
2254            "D-03: gross NV12 odd-both G2D vs CPU mismatch max_diff={max_diff} (>35); first bad at {first_fail:?}"
2255        );
2256    }
2257    /// An `I8` destination gets its bias from a CPU post-pass after the blit,
2258    /// and that pass must stay inside each row's pixels: the bytes between one
2259    /// row's pixels and the next are an allocation pad or a `view()` parent's
2260    /// neighbouring tile, and a flat walk of the mapping biased both.
2261    ///
2262    /// The oracle is the same blit into a `U8` destination at the same pitch,
2263    /// G2D being deterministic for an identical blit: every pixel byte of the
2264    /// `I8` result must be that byte XOR 0x80 (alpha excepted), and both
2265    /// destinations' pads must still hold the poison they were filled with.
2266    /// The `U8` pad is asserted first, so a DPU that writes the pad is not
2267    /// misread as a bias-pass regression.
2268    #[test]
2269    #[cfg(target_os = "linux")]
2270    fn g2d_int8_dst_biases_only_the_pixel_bytes_at_the_declared_stride() {
2271        if !dma_and_g2d_or_skip() {
2272            return;
2273        }
2274        const W: usize = 64;
2275        const H: usize = 16;
2276        const POISON: u8 = 0xAB;
2277        let fmt = PixelFormat::Rgba;
2278        let bpp = fmt.channels();
2279        let row_bytes = W * bpp;
2280        // 64 bytes of pad past the pixels keeps the pitch 64-aligned for G2D.
2281        let stride = row_bytes + 64;
2282
2283        let src = TensorDyn::image(
2284            W,
2285            H,
2286            fmt,
2287            DType::U8,
2288            Some(TensorMemory::DmaBuf),
2289            edgefirst_tensor::CpuAccess::ReadWrite,
2290        )
2291        .expect("DMA source");
2292        {
2293            let t = src.as_typed::<u8>().unwrap();
2294            let s = t.effective_row_stride().unwrap();
2295            let mut m = t.map().unwrap();
2296            let buf = m.as_mut_slice();
2297            for y in 0..H {
2298                for i in 0..row_bytes {
2299                    buf[y * s + i] = (y * 31 + i * 7) as u8;
2300                }
2301            }
2302        }
2303
2304        let mut u8_dst = TensorDyn::image_with_stride(
2305            W,
2306            H,
2307            fmt,
2308            DType::U8,
2309            stride,
2310            Some(TensorMemory::DmaBuf),
2311            edgefirst_tensor::CpuAccess::ReadWrite,
2312        )
2313        .expect("padded U8 destination");
2314        let mut i8_dst = TensorDyn::image_with_stride(
2315            W,
2316            H,
2317            fmt,
2318            DType::I8,
2319            stride,
2320            Some(TensorMemory::DmaBuf),
2321            edgefirst_tensor::CpuAccess::ReadWrite,
2322        )
2323        .expect("padded I8 destination");
2324        assert_eq!(i8_dst.effective_row_stride(), Some(stride));
2325        // Both destinations are poisoned: the `U8` pad is what says whether
2326        // G2D itself wrote past the pixels, and without it a DPU that does
2327        // would look like the bias pass over-reaching.
2328        u8_dst
2329            .as_typed::<u8>()
2330            .unwrap()
2331            .map()
2332            .unwrap()
2333            .as_mut_slice()
2334            .fill(POISON);
2335        i8_dst
2336            .as_typed::<i8>()
2337            .unwrap()
2338            .map()
2339            .unwrap()
2340            .as_mut_slice()
2341            .fill(POISON as i8);
2342
2343        let mut g2d = G2DProcessor::new().unwrap();
2344        g2d.convert(&src, &mut u8_dst, None).expect("U8 blit");
2345        g2d.convert(&src, &mut i8_dst, None).expect("I8 blit");
2346
2347        let u8_stride = u8_dst.effective_row_stride().unwrap();
2348        let want = u8_dst
2349            .as_typed::<u8>()
2350            .unwrap()
2351            .map()
2352            .unwrap()
2353            .as_slice()
2354            .to_vec();
2355        let got: Vec<u8> = i8_dst
2356            .as_typed::<i8>()
2357            .unwrap()
2358            .map()
2359            .unwrap()
2360            .as_slice()
2361            .iter()
2362            .map(|&v| v as u8)
2363            .collect();
2364        // Both mappings are indexed to the end of the last row's pad below;
2365        // say so here rather than failing on an index panic that names no
2366        // stride.
2367        assert!(
2368            got.len() >= H * stride && want.len() >= H * u8_stride,
2369            "a {stride} B pitch over {H} rows needs {} B, mapped {} (I8) and {} (U8)",
2370            H * stride,
2371            got.len(),
2372            want.len()
2373        );
2374        // The blit's own pad first: everything below reads the `U8`
2375        // destination as the oracle, so a G2D write past the pixels has to be
2376        // named as G2D's rather than surface as a bias-pass failure.
2377        for y in 0..H {
2378            assert!(
2379                want[y * u8_stride + row_bytes..(y + 1) * u8_stride]
2380                    .iter()
2381                    .all(|&b| b == POISON),
2382                "row {y}: the G2D blit itself wrote past the pixels; the I8 \
2383                 assertions below cannot separate that from the bias pass"
2384            );
2385        }
2386        for y in 0..H {
2387            for x in 0..W {
2388                for c in 0..bpp {
2389                    let g = got[y * stride + x * bpp + c];
2390                    let w = want[y * u8_stride + x * bpp + c];
2391                    let expect = if c == bpp - 1 { w } else { w ^ 0x80 };
2392                    assert_eq!(g, expect, "pixel ({x},{y}) channel {c}");
2393                }
2394            }
2395            assert!(
2396                got[y * stride + row_bytes..(y + 1) * stride]
2397                    .iter()
2398                    .all(|&b| b == POISON),
2399                "row {y}: the padding past the pixels was biased"
2400            );
2401        }
2402    }
2403}