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
515/// Produce the zero-detection frame on `dst` using G2D hardware ops.
516///
517/// `background == None` → `g2d_clear(dst, 0x00000000)` — actively fills the
518/// destination with transparent black using the 2D engine (no CPU touch).
519///
520/// `background == Some(bg)` → `g2d_blit(bg → dst)` — hardware copy of bg
521/// into dst. This is the "draw the cleared overlay onto the background"
522/// path; G2D has no pure copy primitive, so we use the blit engine with a
523/// 1:1 same-format surface pair, which is the hardware equivalent.
524///
525/// Both paths `finish()` before returning so dst is coherent for any
526/// downstream reader.
527fn draw_empty_frame_g2d(
528    g2d: &mut G2D,
529    dst_dyn: &mut TensorDyn,
530    background: Option<&TensorDyn>,
531) -> Result<()> {
532    if dst_dyn.dtype() != DType::U8 {
533        return Err(Error::NotSupported(
534            "G2D only supports u8 destination tensors".to_string(),
535        ));
536    }
537    let dst = dst_dyn.as_typed_mut::<u8>().ok_or(Error::NotAnImage)?;
538
539    // DMA-only: G2D operates on physical addresses. A Mem-backed dst means
540    // we're on the wrong backend — surface a dispatch error so the caller
541    // can fall back.
542    // A capability probe, so it asks the backend-agnostic question.
543    // `as_dma()` answers `None` for EVERY tensor on the `dynamic` backend
544    // -- `DmaTensor<T>` is `static`-internal storage it never constructs --
545    // so this used to decline G2D unconditionally there, silently giving up
546    // the i.MX hardware path on the exact tensors it was written for. See
547    // task P2b's review, F3.
548    if TensorTrait::memory(dst) != TensorMemory::DmaBuf {
549        return Err(Error::NotImplemented(
550            "g2d only supports Dma memory".to_string(),
551        ));
552    }
553
554    let mut dst_surface = tensor_to_g2d_surface(dst)?;
555
556    match background {
557        None => {
558            // Case 1 — no background, no detections: hardware clear to
559            // transparent black. Every byte of dst is written by the 2D
560            // engine; no reliance on prior state.
561            let start = Instant::now();
562            g2d.clear(&mut dst_surface, [0, 0, 0, 0])?;
563            g2d.finish()?;
564            log::trace!("g2d clear (empty frame) takes {:?}", start.elapsed());
565        }
566        Some(bg_dyn) => {
567            // Case 2 — background, no detections: hardware blit bg → dst.
568            // Validate shape/format equivalence; if the caller handed us
569            // mismatched surfaces we return a structured error rather than
570            // silently producing wrong output.
571            if bg_dyn.shape() != dst.shape() {
572                return Err(Error::InvalidShape(
573                    "background shape does not match dst".into(),
574                ));
575            }
576            if bg_dyn.format() != dst.format() {
577                return Err(Error::InvalidShape(
578                    "background pixel format does not match dst".into(),
579                ));
580            }
581            if bg_dyn.dtype() != DType::U8 {
582                return Err(Error::NotSupported(
583                    "G2D only supports u8 background tensors".to_string(),
584                ));
585            }
586            let bg = bg_dyn.as_typed::<u8>().ok_or(Error::NotAnImage)?;
587            // Backend-agnostic probe -- see the `dst` check above.
588            if TensorTrait::memory(bg) != TensorMemory::DmaBuf {
589                return Err(Error::NotImplemented(
590                    "g2d background must be Dma-backed".to_string(),
591                ));
592            }
593            let src_surface = tensor_to_g2d_surface(bg)?;
594            let start = Instant::now();
595            g2d.blit(&src_surface, &dst_surface)?;
596            g2d.finish()?;
597            log::trace!("g2d blit (bg→dst) takes {:?}", start.elapsed());
598        }
599    }
600    Ok(())
601}
602
603/// Chroma subsampling of `fmt` as `(shift_x, shift_y)` — 1 = half resolution on
604/// that axis, 0 = full. Drives the i.MX95 DPU even-dimension workaround: a blit
605/// dimension that the format subsamples must be even or the DPU garbles it.
606fn chroma_subsample_shifts(fmt: PixelFormat) -> (u32, u32) {
607    match fmt.chroma_layout() {
608        Some(cl) => (cl.shift_x, cl.shift_y),
609        // Packed 4:2:2 (YUYV / VYUY) subsample horizontally; all other packed
610        // formats (RGB/RGBA/BGRA/Grey) are full-resolution.
611        None if matches!(fmt, PixelFormat::Yuyv | PixelFormat::Vyuy) => (1, 0),
612        None => (0, 0),
613    }
614}
615
616/// Build a `G2DSurface` from a `Tensor<u8>` that carries pixel-format metadata.
617///
618/// The tensor must be backed by DMA memory and must have a pixel format set.
619fn tensor_to_g2d_surface(img: &Tensor<u8>) -> Result<G2DSurface> {
620    let fmt = img.format().ok_or(Error::NotAnImage)?;
621    // `dmabuf()`, not `as_dma().fd`: same file descriptor, but reachable on
622    // both backends. See the probe above.
623    let fd = img
624        .dmabuf()
625        .map_err(|e| Error::NotImplemented(format!("g2d only supports Dma memory ({e})")))?;
626    let phys: G2DPhysical = fd.as_raw_fd().try_into()?;
627
628    // NV12 is a two-plane format: Y plane followed by interleaved UV plane.
629    // planes[0] = Y plane start, planes[1] = UV plane start (Y size = width * height)
630    //
631    // plane_offset is the byte offset within the DMA-BUF where pixel data
632    // starts.  G2D works with raw physical addresses so we must add the
633    // offset ourselves — the hardware has no concept of a per-plane offset.
634    let base_addr = phys.address();
635    let luma_offset = img.plane_offset().unwrap_or(0) as u64;
636    let planes = if fmt == PixelFormat::Nv12 {
637        if img.is_multiplane() {
638            // Multiplane: UV in separate DMA-BUF, get its physical address
639            let chroma = img.chroma().unwrap();
640            let chroma_fd = chroma.dmabuf().map_err(|e| {
641                Error::NotImplemented(format!("g2d multiplane chroma must be DMA-backed ({e})"))
642            })?;
643            let uv_phys: G2DPhysical = chroma_fd.as_raw_fd().try_into()?;
644            let chroma_offset = img.chroma().and_then(|c| c.plane_offset()).unwrap_or(0) as u64;
645            [
646                base_addr + luma_offset,
647                uv_phys.address() + chroma_offset,
648                0,
649            ]
650        } else {
651            let w = img.width().unwrap();
652            let h = img.height().unwrap();
653            let stride = img.effective_row_stride().unwrap_or(w.next_multiple_of(2));
654            let uv_offset = (luma_offset as usize + stride * h) as u64;
655            [base_addr + luma_offset, base_addr + uv_offset, 0]
656        }
657    } else {
658        [base_addr + luma_offset, 0, 0]
659    };
660
661    let w = img.width().unwrap();
662    let h = img.height().unwrap();
663    let fourcc = pixelfmt_to_fourcc(fmt);
664
665    // G2D stride is in pixels.  effective_row_stride() returns bytes, so
666    // divide by the bytes-per-pixel (channels for u8 data) to convert.
667    let stride_pixels = match img.effective_row_stride() {
668        Some(s) => {
669            let channels = fmt.channels();
670            if s % channels != 0 {
671                return Err(Error::NotImplemented(
672                    "g2d requires row stride to be a multiple of bytes-per-pixel".to_string(),
673                ));
674            }
675            s / channels
676        }
677        None => w,
678    };
679
680    Ok(G2DSurface {
681        planes,
682        format: G2DFormat::try_from(fourcc)?.format(),
683        left: 0,
684        top: 0,
685        right: w as i32,
686        bottom: h as i32,
687        stride: stride_pixels as i32,
688        width: w as i32,
689        height: h as i32,
690        blendfunc: 0,
691        clrcolor: 0,
692        rot: 0,
693        global_alpha: 0,
694    })
695}
696
697#[cfg(test)]
698mod g2d_predicate_tests {
699    use super::*;
700    use edgefirst_tensor::{ColorEncoding, ColorRange, Colorimetry};
701
702    #[test]
703    fn g2d_declines_full_range_and_bt2020_yuv() {
704        let full = Colorimetry::default()
705            .with_range(ColorRange::Full)
706            .with_encoding(ColorEncoding::Bt709);
707        let lim = Colorimetry::default()
708            .with_range(ColorRange::Limited)
709            .with_encoding(ColorEncoding::Bt709);
710        let bt2020 = Colorimetry::default()
711            .with_range(ColorRange::Limited)
712            .with_encoding(ColorEncoding::Bt2020);
713        assert!(!g2d_can_handle(&full, true)); // full-range YUV → decline
714        assert!(g2d_can_handle(&lim, true)); // limited 709 YUV → ok
715        assert!(!g2d_can_handle(&bt2020, true)); // BT.2020 → decline
716        assert!(g2d_can_handle(&full, false)); // no YUV side → rule N/A → ok
717    }
718
719    #[test]
720    fn post_pass_access_covers_the_int8_bias_and_the_rgb_border() {
721        use edgefirst_tensor::CpuAccess;
722        let pass = g2d_post_pass_access;
723        assert_eq!(
724            pass(PixelFormat::Rgba, DType::I8, false),
725            CpuAccess::ReadWrite
726        );
727        assert_eq!(
728            pass(PixelFormat::Rgb, DType::I8, true),
729            CpuAccess::ReadWrite
730        );
731        assert_eq!(pass(PixelFormat::Rgb, DType::U8, false), CpuAccess::None);
732        assert_eq!(pass(PixelFormat::Rgba, DType::U8, true), CpuAccess::None);
733        let dma = TensorMemory::DmaBuf;
734        assert_eq!(
735            dst_cpu_access(dma, PixelFormat::Bgra, DType::I8),
736            Some(CpuAccess::ReadWrite)
737        );
738        assert_eq!(
739            dst_cpu_access(dma, PixelFormat::Rgba, DType::U8),
740            Some(CpuAccess::None)
741        );
742        assert_eq!(dst_cpu_access(dma, PixelFormat::Nv12, DType::U8), None);
743        assert_eq!(dst_cpu_access(dma, PixelFormat::Rgba, DType::F32), None);
744        assert_eq!(
745            dst_cpu_access(TensorMemory::Mem, PixelFormat::Rgba, DType::U8),
746            None
747        );
748        assert!(g2d_supports(PixelFormat::Nv12, PixelFormat::Rgb));
749        assert!(!g2d_supports(PixelFormat::Bgra, PixelFormat::Rgba));
750        assert!(!g2d_supports(PixelFormat::Vyuy, PixelFormat::Rgba));
751    }
752
753    /// A destination whose declaration cannot cover the post-passes is
754    /// refused before any blit.
755    #[test]
756    fn post_passes_are_checked_against_the_destination_up_front() {
757        use edgefirst_tensor::{CpuAccess, Error as TensorError};
758        let image = |fmt, dtype, access| {
759            TensorDyn::image(64, 48, fmt, dtype, Some(TensorMemory::Mem), access).unwrap()
760        };
761        let border = ResolvedCrop {
762            src_rect: None,
763            dst_rect: Some(crate::Rect::new(0, 8, 64, 32)),
764            dst_color: Some([0, 0, 0, 255]),
765        };
766        let denied = |r: Result<()>, requested| match r {
767            Err(Error::Tensor(TensorError::CpuAccessDenied { requested: r, .. })) => {
768                assert_eq!(r, requested)
769            }
770            other => panic!("expected CpuAccessDenied, got {other:?}"),
771        };
772        let int8 = image(PixelFormat::Rgba, DType::I8, CpuAccess::Write);
773        denied(
774            require_post_pass_access(&int8, &ResolvedCrop::no_crop()),
775            CpuAccess::ReadWrite,
776        );
777        let rgb = image(PixelFormat::Rgb, DType::U8, CpuAccess::None);
778        denied(
779            require_post_pass_access(&rgb, &border),
780            CpuAccess::ReadWrite,
781        );
782        require_post_pass_access(&rgb, &ResolvedCrop::no_crop()).unwrap();
783        let rgba = image(PixelFormat::Rgba, DType::U8, CpuAccess::None);
784        require_post_pass_access(&rgba, &border).unwrap();
785    }
786
787    /// The `Rgb` border is a CPU write of part of a G2D-written buffer, so a
788    /// write-only destination is refused: its map would not invalidate.
789    #[test]
790    fn rgb_border_post_pass_needs_readwrite() {
791        use edgefirst_tensor::CpuAccess;
792        assert_eq!(
793            g2d_post_pass_access(PixelFormat::Rgb, DType::U8, true),
794            CpuAccess::ReadWrite
795        );
796        let border = ResolvedCrop {
797            src_rect: None,
798            dst_rect: Some(crate::Rect::new(8, 0, 48, 48)),
799            dst_color: Some([0, 0, 0, 255]),
800        };
801        let image = |access| {
802            TensorDyn::image(
803                64,
804                48,
805                PixelFormat::Rgb,
806                DType::U8,
807                Some(TensorMemory::Mem),
808                access,
809            )
810            .unwrap()
811        };
812        assert!(matches!(
813            require_post_pass_access(&image(CpuAccess::Write), &border),
814            Err(Error::Tensor(
815                edgefirst_tensor::Error::CpuAccessDenied { .. }
816            ))
817        ));
818        require_post_pass_access(&image(CpuAccess::ReadWrite), &border).unwrap();
819    }
820}
821
822#[cfg(test)]
823#[allow(deprecated)]
824mod g2d_tests {
825    use super::*;
826    use crate::test_support::is_dma_available;
827    use crate::{CPUProcessor, G2DProcessor, ImageProcessorTrait};
828    use edgefirst_tensor::{
829        DType, PixelFormat, TensorDyn, TensorMapTrait, TensorMemory, TensorTrait,
830    };
831    use image::buffer::ConvertBuffer;
832
833    /// G2D is usable only where `libg2d.so.2` loads — the NXP i.MX boards. On
834    /// V3D/Tegra/x86 the library is absent, so `G2DProcessor::new()` and the
835    /// surface physical-address resolution these tests exercise both fail. DMA
836    /// being available does NOT imply G2D (e.g. rpi5/V3D has DMA but no G2D), so
837    /// these tests must gate on G2D, not just DMA, or they panic/fail on
838    /// non-G2D hardware instead of skipping.
839    #[cfg(target_os = "linux")]
840    fn is_g2d_available() -> bool {
841        crate::test_support::g2d_required_or(G2DProcessor::new().is_ok())
842    }
843
844    /// Whether this host has a usable DMA heap and G2D; reports a skip when it has not.
845    #[cfg(target_os = "linux")]
846    fn dma_and_g2d_or_skip() -> bool {
847        let why = if !is_dma_available() {
848            "DMA not available"
849        } else if !is_g2d_available() {
850            "G2D not available"
851        } else {
852            return true;
853        };
854        crate::test_support::report_skip(&format!(
855            "{}: {why}",
856            crate::test_support::current_test()
857        ));
858        false
859    }
860
861    #[test]
862    #[cfg(target_os = "linux")]
863    fn test_g2d_formats_no_resize() {
864        if !dma_and_g2d_or_skip() {
865            return;
866        }
867        for i in [
868            PixelFormat::Rgba,
869            PixelFormat::Yuyv,
870            PixelFormat::Rgb,
871            PixelFormat::Grey,
872            PixelFormat::Nv12,
873        ] {
874            for o in [
875                PixelFormat::Rgba,
876                PixelFormat::Yuyv,
877                PixelFormat::Rgb,
878                PixelFormat::Grey,
879            ] {
880                let res = test_g2d_format_no_resize_(i, o);
881                if let Err(e) = res {
882                    crate::test_support::report_skip(&format!(
883                        "{}: {i} to {o} failed: {e:?}",
884                        crate::test_support::current_test()
885                    ));
886                }
887            }
888        }
889    }
890
891    fn test_g2d_format_no_resize_(
892        g2d_in_fmt: PixelFormat,
893        g2d_out_fmt: PixelFormat,
894    ) -> Result<(), crate::Error> {
895        let dst_width = 1280;
896        let dst_height = 720;
897        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
898        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgb), None)?;
899
900        // Create DMA buffer for G2D input
901        let mut src2 = TensorDyn::image(
902            1280,
903            720,
904            g2d_in_fmt,
905            DType::U8,
906            Some(TensorMemory::DmaBuf),
907            edgefirst_tensor::CpuAccess::ReadWrite,
908        )?;
909
910        let mut cpu_converter = CPUProcessor::new();
911
912        // For PixelFormat::Nv12 input, load from file since CPU doesn't support PixelFormat::Rgb→PixelFormat::Nv12
913        if g2d_in_fmt == PixelFormat::Nv12 {
914            let nv12_bytes = edgefirst_bench::testdata::read("zidane.nv12");
915            src2.as_typed::<u8>()
916                .unwrap()
917                .map()?
918                .as_mut_slice()
919                .copy_from_slice(&nv12_bytes);
920        } else {
921            cpu_converter.convert(&src, &mut src2, None)?;
922        }
923
924        let mut g2d_dst = TensorDyn::image(
925            dst_width,
926            dst_height,
927            g2d_out_fmt,
928            DType::U8,
929            Some(TensorMemory::DmaBuf),
930            edgefirst_tensor::CpuAccess::ReadWrite,
931        )?;
932        let mut g2d_converter = G2DProcessor::new()?;
933        let src2_dyn = src2;
934        let mut g2d_dst_dyn = g2d_dst;
935        g2d_converter.convert(&src2_dyn, &mut g2d_dst_dyn, None)?;
936        g2d_dst = {
937            let mut __t = g2d_dst_dyn.into_u8().unwrap();
938            __t.set_format(g2d_out_fmt)
939                .map_err(|e| crate::Error::Internal(e.to_string()))?;
940            TensorDyn::from(__t)
941        };
942
943        let mut cpu_dst = TensorDyn::image(
944            dst_width,
945            dst_height,
946            PixelFormat::Rgb,
947            DType::U8,
948            None,
949            edgefirst_tensor::CpuAccess::ReadWrite,
950        )?;
951        cpu_converter.convert(&g2d_dst, &mut cpu_dst, None)?;
952
953        compare_images(
954            &src,
955            &cpu_dst,
956            0.98,
957            &format!("{g2d_in_fmt}_to_{g2d_out_fmt}"),
958        )
959    }
960
961    #[test]
962    #[cfg(target_os = "linux")]
963    fn test_g2d_formats_with_resize() {
964        if !dma_and_g2d_or_skip() {
965            return;
966        }
967        for i in [
968            PixelFormat::Rgba,
969            PixelFormat::Yuyv,
970            PixelFormat::Rgb,
971            PixelFormat::Grey,
972            PixelFormat::Nv12,
973        ] {
974            for o in [
975                PixelFormat::Rgba,
976                PixelFormat::Yuyv,
977                PixelFormat::Rgb,
978                PixelFormat::Grey,
979            ] {
980                let res = test_g2d_format_with_resize_(i, o);
981                if let Err(e) = res {
982                    crate::test_support::report_skip(&format!(
983                        "{}: {i} to {o} failed: {e:?}",
984                        crate::test_support::current_test()
985                    ));
986                }
987            }
988        }
989    }
990
991    #[test]
992    #[cfg(target_os = "linux")]
993    fn test_g2d_formats_with_resize_dst_crop() {
994        if !dma_and_g2d_or_skip() {
995            return;
996        }
997        for i in [
998            PixelFormat::Rgba,
999            PixelFormat::Yuyv,
1000            PixelFormat::Rgb,
1001            PixelFormat::Grey,
1002            PixelFormat::Nv12,
1003        ] {
1004            for o in [
1005                PixelFormat::Rgba,
1006                PixelFormat::Yuyv,
1007                PixelFormat::Rgb,
1008                PixelFormat::Grey,
1009            ] {
1010                let res = test_g2d_format_with_resize_dst_crop(i, o);
1011                if let Err(e) = res {
1012                    crate::test_support::report_skip(&format!(
1013                        "{}: {i} to {o} failed: {e:?}",
1014                        crate::test_support::current_test()
1015                    ));
1016                }
1017            }
1018        }
1019    }
1020
1021    fn test_g2d_format_with_resize_(
1022        g2d_in_fmt: PixelFormat,
1023        g2d_out_fmt: PixelFormat,
1024    ) -> Result<(), crate::Error> {
1025        let dst_width = 600;
1026        let dst_height = 400;
1027        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
1028        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgb), None)?;
1029
1030        let mut cpu_converter = CPUProcessor::new();
1031
1032        let mut reference = TensorDyn::image(
1033            dst_width,
1034            dst_height,
1035            PixelFormat::Rgb,
1036            DType::U8,
1037            Some(TensorMemory::DmaBuf),
1038            edgefirst_tensor::CpuAccess::ReadWrite,
1039        )?;
1040        cpu_converter.convert(&src, &mut reference, None)?;
1041
1042        // Create DMA buffer for G2D input
1043        let mut src2 = TensorDyn::image(
1044            1280,
1045            720,
1046            g2d_in_fmt,
1047            DType::U8,
1048            Some(TensorMemory::DmaBuf),
1049            edgefirst_tensor::CpuAccess::ReadWrite,
1050        )?;
1051
1052        // For PixelFormat::Nv12 input, load from file since CPU doesn't support PixelFormat::Rgb→PixelFormat::Nv12
1053        if g2d_in_fmt == PixelFormat::Nv12 {
1054            let nv12_bytes = edgefirst_bench::testdata::read("zidane.nv12");
1055            src2.as_typed::<u8>()
1056                .unwrap()
1057                .map()?
1058                .as_mut_slice()
1059                .copy_from_slice(&nv12_bytes);
1060        } else {
1061            cpu_converter.convert(&src, &mut src2, None)?;
1062        }
1063
1064        let mut g2d_dst = TensorDyn::image(
1065            dst_width,
1066            dst_height,
1067            g2d_out_fmt,
1068            DType::U8,
1069            Some(TensorMemory::DmaBuf),
1070            edgefirst_tensor::CpuAccess::ReadWrite,
1071        )?;
1072        let mut g2d_converter = G2DProcessor::new()?;
1073        let src2_dyn = src2;
1074        let mut g2d_dst_dyn = g2d_dst;
1075        g2d_converter.convert(&src2_dyn, &mut g2d_dst_dyn, None)?;
1076        g2d_dst = {
1077            let mut __t = g2d_dst_dyn.into_u8().unwrap();
1078            __t.set_format(g2d_out_fmt)
1079                .map_err(|e| crate::Error::Internal(e.to_string()))?;
1080            TensorDyn::from(__t)
1081        };
1082
1083        let mut cpu_dst = TensorDyn::image(
1084            dst_width,
1085            dst_height,
1086            PixelFormat::Rgb,
1087            DType::U8,
1088            None,
1089            edgefirst_tensor::CpuAccess::ReadWrite,
1090        )?;
1091        cpu_converter.convert(&g2d_dst, &mut cpu_dst, None)?;
1092
1093        compare_images(
1094            &reference,
1095            &cpu_dst,
1096            0.98,
1097            &format!("{g2d_in_fmt}_to_{g2d_out_fmt}_resized"),
1098        )
1099    }
1100
1101    fn test_g2d_format_with_resize_dst_crop(
1102        g2d_in_fmt: PixelFormat,
1103        g2d_out_fmt: PixelFormat,
1104    ) -> Result<(), crate::Error> {
1105        let dst_width = 600;
1106        let dst_height = 400;
1107        // The destination sub-rectangle is now expressed as a `view()` of the
1108        // destination (the old `Crop.dst_rect` was removed); G2D blits into the
1109        // view's window via its offset + parent stride. Region is (x=left, y=top,
1110        // width, height).
1111        let region = crate::Region::new(100, 100, 200, 100);
1112        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
1113        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgb), None)?;
1114
1115        let mut cpu_converter = CPUProcessor::new();
1116
1117        let reference = TensorDyn::image(
1118            dst_width,
1119            dst_height,
1120            PixelFormat::Rgb,
1121            DType::U8,
1122            Some(TensorMemory::DmaBuf),
1123            edgefirst_tensor::CpuAccess::ReadWrite,
1124        )?;
1125        reference
1126            .as_typed::<u8>()
1127            .unwrap()
1128            .map()
1129            .unwrap()
1130            .as_mut_slice()
1131            .fill(128);
1132        cpu_converter.convert(&src, &mut reference.view(region)?, None)?;
1133
1134        // Create DMA buffer for G2D input
1135        let mut src2 = TensorDyn::image(
1136            1280,
1137            720,
1138            g2d_in_fmt,
1139            DType::U8,
1140            Some(TensorMemory::DmaBuf),
1141            edgefirst_tensor::CpuAccess::ReadWrite,
1142        )?;
1143
1144        // For PixelFormat::Nv12 input, load from file since CPU doesn't support PixelFormat::Rgb→PixelFormat::Nv12
1145        if g2d_in_fmt == PixelFormat::Nv12 {
1146            let nv12_bytes = edgefirst_bench::testdata::read("zidane.nv12");
1147            src2.as_typed::<u8>()
1148                .unwrap()
1149                .map()?
1150                .as_mut_slice()
1151                .copy_from_slice(&nv12_bytes);
1152        } else {
1153            cpu_converter.convert(&src, &mut src2, None)?;
1154        }
1155
1156        let mut g2d_dst = TensorDyn::image(
1157            dst_width,
1158            dst_height,
1159            g2d_out_fmt,
1160            DType::U8,
1161            Some(TensorMemory::DmaBuf),
1162            edgefirst_tensor::CpuAccess::ReadWrite,
1163        )?;
1164        g2d_dst
1165            .as_typed::<u8>()
1166            .unwrap()
1167            .map()
1168            .unwrap()
1169            .as_mut_slice()
1170            .fill(128);
1171        let mut g2d_converter = G2DProcessor::new()?;
1172        let src2_dyn = src2;
1173        let g2d_dst_dyn = g2d_dst;
1174        g2d_converter.convert(&src2_dyn, &mut g2d_dst_dyn.view(region)?, None)?;
1175        g2d_dst = {
1176            let mut __t = g2d_dst_dyn.into_u8().unwrap();
1177            __t.set_format(g2d_out_fmt)
1178                .map_err(|e| crate::Error::Internal(e.to_string()))?;
1179            TensorDyn::from(__t)
1180        };
1181
1182        let mut cpu_dst = TensorDyn::image(
1183            dst_width,
1184            dst_height,
1185            PixelFormat::Rgb,
1186            DType::U8,
1187            None,
1188            edgefirst_tensor::CpuAccess::ReadWrite,
1189        )?;
1190        cpu_converter.convert(&g2d_dst, &mut cpu_dst, None)?;
1191
1192        compare_images(
1193            &reference,
1194            &cpu_dst,
1195            0.98,
1196            &format!("{g2d_in_fmt}_to_{g2d_out_fmt}_resized_dst_crop"),
1197        )
1198    }
1199
1200    fn compare_images(
1201        img1: &TensorDyn,
1202        img2: &TensorDyn,
1203        threshold: f64,
1204        name: &str,
1205    ) -> Result<(), crate::Error> {
1206        assert_eq!(img1.height(), img2.height(), "Heights differ");
1207        assert_eq!(img1.width(), img2.width(), "Widths differ");
1208        assert_eq!(
1209            img1.format().unwrap(),
1210            img2.format().unwrap(),
1211            "PixelFormat differ"
1212        );
1213        assert!(
1214            matches!(img1.format().unwrap(), PixelFormat::Rgb | PixelFormat::Rgba),
1215            "format must be Rgb or Rgba for comparison"
1216        );
1217        let image1 = match img1.format().unwrap() {
1218            PixelFormat::Rgb => image::RgbImage::from_vec(
1219                img1.width().unwrap() as u32,
1220                img1.height().unwrap() as u32,
1221                img1.as_typed::<u8>().unwrap().map().unwrap().to_vec(),
1222            )
1223            .unwrap(),
1224            PixelFormat::Rgba => image::RgbaImage::from_vec(
1225                img1.width().unwrap() as u32,
1226                img1.height().unwrap() as u32,
1227                img1.as_typed::<u8>().unwrap().map().unwrap().to_vec(),
1228            )
1229            .unwrap()
1230            .convert(),
1231
1232            _ => unreachable!(),
1233        };
1234
1235        let image2 = match img2.format().unwrap() {
1236            PixelFormat::Rgb => image::RgbImage::from_vec(
1237                img2.width().unwrap() as u32,
1238                img2.height().unwrap() as u32,
1239                img2.as_typed::<u8>().unwrap().map().unwrap().to_vec(),
1240            )
1241            .unwrap(),
1242            PixelFormat::Rgba => image::RgbaImage::from_vec(
1243                img2.width().unwrap() as u32,
1244                img2.height().unwrap() as u32,
1245                img2.as_typed::<u8>().unwrap().map().unwrap().to_vec(),
1246            )
1247            .unwrap()
1248            .convert(),
1249
1250            _ => unreachable!(),
1251        };
1252
1253        let similarity = image_compare::rgb_similarity_structure(
1254            &image_compare::Algorithm::RootMeanSquared,
1255            &image1,
1256            &image2,
1257        )
1258        .expect("Image Comparison failed");
1259
1260        if similarity.score < threshold {
1261            image1.save(format!("{name}_1.png")).unwrap();
1262            image2.save(format!("{name}_2.png")).unwrap();
1263            return Err(Error::Internal(format!(
1264                "{name}: converted image and target image have similarity score too low: {} < {}",
1265                similarity.score, threshold
1266            )));
1267        }
1268        Ok(())
1269    }
1270
1271    // =========================================================================
1272    // PixelFormat::Nv12 Reference Validation Tests
1273    // These tests compare G2D PixelFormat::Nv12 conversions against ffmpeg-generated references
1274    // =========================================================================
1275
1276    fn load_raw_image(
1277        width: usize,
1278        height: usize,
1279        format: PixelFormat,
1280        memory: Option<TensorMemory>,
1281        bytes: &[u8],
1282    ) -> Result<TensorDyn, crate::Error> {
1283        let img = TensorDyn::image(
1284            width,
1285            height,
1286            format,
1287            DType::U8,
1288            memory,
1289            edgefirst_tensor::CpuAccess::ReadWrite,
1290        )?;
1291        let mut map = img.as_typed::<u8>().unwrap().map()?;
1292        let dst = map.as_mut_slice();
1293        if bytes.len() > dst.len() {
1294            return Err(crate::Error::InvalidShape(format!(
1295                "load_raw_image: {} input bytes exceed {}-byte image buffer",
1296                bytes.len(),
1297                dst.len()
1298            )));
1299        }
1300        dst[..bytes.len()].copy_from_slice(bytes);
1301        Ok(img)
1302    }
1303
1304    /// Test G2D PixelFormat::Nv12→PixelFormat::Rgba conversion against ffmpeg reference
1305    #[test]
1306    #[cfg(target_os = "linux")]
1307    fn test_g2d_nv12_to_rgba_reference() -> Result<(), crate::Error> {
1308        if !dma_and_g2d_or_skip() {
1309            return Ok(());
1310        }
1311        // Load PixelFormat::Nv12 source
1312        let src = load_raw_image(
1313            1280,
1314            720,
1315            PixelFormat::Nv12,
1316            Some(TensorMemory::DmaBuf),
1317            &edgefirst_bench::testdata::read("camera720p.nv12"),
1318        )?;
1319
1320        // Load PixelFormat::Rgba reference (ffmpeg-generated)
1321        let reference = load_raw_image(
1322            1280,
1323            720,
1324            PixelFormat::Rgba,
1325            None,
1326            &edgefirst_bench::testdata::read("camera720p.rgba"),
1327        )?;
1328
1329        // Convert using G2D
1330        let mut dst = TensorDyn::image(
1331            1280,
1332            720,
1333            PixelFormat::Rgba,
1334            DType::U8,
1335            Some(TensorMemory::DmaBuf),
1336            edgefirst_tensor::CpuAccess::ReadWrite,
1337        )?;
1338        let mut g2d = G2DProcessor::new()?;
1339        let src_dyn = src;
1340        let mut dst_dyn = dst;
1341        g2d.convert(&src_dyn, &mut dst_dyn, None)?;
1342        dst = {
1343            let mut __t = dst_dyn.into_u8().unwrap();
1344            __t.set_format(PixelFormat::Rgba)
1345                .map_err(|e| crate::Error::Internal(e.to_string()))?;
1346            TensorDyn::from(__t)
1347        };
1348
1349        // Copy to CPU for comparison
1350        let cpu_dst = TensorDyn::image(
1351            1280,
1352            720,
1353            PixelFormat::Rgba,
1354            DType::U8,
1355            None,
1356            edgefirst_tensor::CpuAccess::ReadWrite,
1357        )?;
1358        cpu_dst
1359            .as_typed::<u8>()
1360            .unwrap()
1361            .map()?
1362            .as_mut_slice()
1363            .copy_from_slice(dst.as_typed::<u8>().unwrap().map()?.as_slice());
1364
1365        compare_images(&reference, &cpu_dst, 0.98, "g2d_nv12_to_rgba_reference")
1366    }
1367
1368    /// Test G2D PixelFormat::Nv12→PixelFormat::Rgb conversion against ffmpeg reference
1369    #[test]
1370    #[cfg(target_os = "linux")]
1371    fn test_g2d_nv12_to_rgb_reference() -> Result<(), crate::Error> {
1372        if !dma_and_g2d_or_skip() {
1373            return Ok(());
1374        }
1375        // Load PixelFormat::Nv12 source
1376        let src = load_raw_image(
1377            1280,
1378            720,
1379            PixelFormat::Nv12,
1380            Some(TensorMemory::DmaBuf),
1381            &edgefirst_bench::testdata::read("camera720p.nv12"),
1382        )?;
1383
1384        // Load PixelFormat::Rgb reference (ffmpeg-generated)
1385        let reference = load_raw_image(
1386            1280,
1387            720,
1388            PixelFormat::Rgb,
1389            None,
1390            &edgefirst_bench::testdata::read("camera720p.rgb"),
1391        )?;
1392
1393        // Convert using G2D
1394        let mut dst = TensorDyn::image(
1395            1280,
1396            720,
1397            PixelFormat::Rgb,
1398            DType::U8,
1399            Some(TensorMemory::DmaBuf),
1400            edgefirst_tensor::CpuAccess::ReadWrite,
1401        )?;
1402        let mut g2d = G2DProcessor::new()?;
1403        let src_dyn = src;
1404        let mut dst_dyn = dst;
1405        g2d.convert(&src_dyn, &mut dst_dyn, None)?;
1406        dst = {
1407            let mut __t = dst_dyn.into_u8().unwrap();
1408            __t.set_format(PixelFormat::Rgb)
1409                .map_err(|e| crate::Error::Internal(e.to_string()))?;
1410            TensorDyn::from(__t)
1411        };
1412
1413        // Copy to CPU for comparison
1414        let cpu_dst = TensorDyn::image(
1415            1280,
1416            720,
1417            PixelFormat::Rgb,
1418            DType::U8,
1419            None,
1420            edgefirst_tensor::CpuAccess::ReadWrite,
1421        )?;
1422        cpu_dst
1423            .as_typed::<u8>()
1424            .unwrap()
1425            .map()?
1426            .as_mut_slice()
1427            .copy_from_slice(dst.as_typed::<u8>().unwrap().map()?.as_slice());
1428
1429        compare_images(&reference, &cpu_dst, 0.98, "g2d_nv12_to_rgb_reference")
1430    }
1431
1432    /// Test G2D PixelFormat::Yuyv→PixelFormat::Rgba conversion against ffmpeg reference
1433    #[test]
1434    #[cfg(target_os = "linux")]
1435    fn test_g2d_yuyv_to_rgba_reference() -> Result<(), crate::Error> {
1436        if !dma_and_g2d_or_skip() {
1437            return Ok(());
1438        }
1439        // Load PixelFormat::Yuyv source
1440        let src = load_raw_image(
1441            1280,
1442            720,
1443            PixelFormat::Yuyv,
1444            Some(TensorMemory::DmaBuf),
1445            &edgefirst_bench::testdata::read("camera720p.yuyv"),
1446        )?;
1447
1448        // Load PixelFormat::Rgba reference (ffmpeg-generated)
1449        let reference = load_raw_image(
1450            1280,
1451            720,
1452            PixelFormat::Rgba,
1453            None,
1454            &edgefirst_bench::testdata::read("camera720p.rgba"),
1455        )?;
1456
1457        // Convert using G2D
1458        let mut dst = TensorDyn::image(
1459            1280,
1460            720,
1461            PixelFormat::Rgba,
1462            DType::U8,
1463            Some(TensorMemory::DmaBuf),
1464            edgefirst_tensor::CpuAccess::ReadWrite,
1465        )?;
1466        let mut g2d = G2DProcessor::new()?;
1467        let src_dyn = src;
1468        let mut dst_dyn = dst;
1469        g2d.convert(&src_dyn, &mut dst_dyn, None)?;
1470        dst = {
1471            let mut __t = dst_dyn.into_u8().unwrap();
1472            __t.set_format(PixelFormat::Rgba)
1473                .map_err(|e| crate::Error::Internal(e.to_string()))?;
1474            TensorDyn::from(__t)
1475        };
1476
1477        // Copy to CPU for comparison
1478        let cpu_dst = TensorDyn::image(
1479            1280,
1480            720,
1481            PixelFormat::Rgba,
1482            DType::U8,
1483            None,
1484            edgefirst_tensor::CpuAccess::ReadWrite,
1485        )?;
1486        cpu_dst
1487            .as_typed::<u8>()
1488            .unwrap()
1489            .map()?
1490            .as_mut_slice()
1491            .copy_from_slice(dst.as_typed::<u8>().unwrap().map()?.as_slice());
1492
1493        compare_images(&reference, &cpu_dst, 0.98, "g2d_yuyv_to_rgba_reference")
1494    }
1495
1496    /// Test G2D PixelFormat::Yuyv→PixelFormat::Rgb conversion against ffmpeg reference
1497    #[test]
1498    #[cfg(target_os = "linux")]
1499    fn test_g2d_yuyv_to_rgb_reference() -> Result<(), crate::Error> {
1500        if !dma_and_g2d_or_skip() {
1501            return Ok(());
1502        }
1503        // Load PixelFormat::Yuyv source
1504        let src = load_raw_image(
1505            1280,
1506            720,
1507            PixelFormat::Yuyv,
1508            Some(TensorMemory::DmaBuf),
1509            &edgefirst_bench::testdata::read("camera720p.yuyv"),
1510        )?;
1511
1512        // Load PixelFormat::Rgb reference (ffmpeg-generated)
1513        let reference = load_raw_image(
1514            1280,
1515            720,
1516            PixelFormat::Rgb,
1517            None,
1518            &edgefirst_bench::testdata::read("camera720p.rgb"),
1519        )?;
1520
1521        // Convert using G2D
1522        let mut dst = TensorDyn::image(
1523            1280,
1524            720,
1525            PixelFormat::Rgb,
1526            DType::U8,
1527            Some(TensorMemory::DmaBuf),
1528            edgefirst_tensor::CpuAccess::ReadWrite,
1529        )?;
1530        let mut g2d = G2DProcessor::new()?;
1531        let src_dyn = src;
1532        let mut dst_dyn = dst;
1533        g2d.convert(&src_dyn, &mut dst_dyn, None)?;
1534        dst = {
1535            let mut __t = dst_dyn.into_u8().unwrap();
1536            __t.set_format(PixelFormat::Rgb)
1537                .map_err(|e| crate::Error::Internal(e.to_string()))?;
1538            TensorDyn::from(__t)
1539        };
1540
1541        // Copy to CPU for comparison
1542        let cpu_dst = TensorDyn::image(
1543            1280,
1544            720,
1545            PixelFormat::Rgb,
1546            DType::U8,
1547            None,
1548            edgefirst_tensor::CpuAccess::ReadWrite,
1549        )?;
1550        cpu_dst
1551            .as_typed::<u8>()
1552            .unwrap()
1553            .map()?
1554            .as_mut_slice()
1555            .copy_from_slice(dst.as_typed::<u8>().unwrap().map()?.as_slice());
1556
1557        compare_images(&reference, &cpu_dst, 0.98, "g2d_yuyv_to_rgb_reference")
1558    }
1559
1560    /// Test G2D native PixelFormat::Bgra conversion for all supported source formats.
1561    /// Compares G2D src→PixelFormat::Bgra against G2D src→PixelFormat::Rgba by verifying R↔B swap.
1562    #[test]
1563    #[cfg(target_os = "linux")]
1564    #[ignore = "G2D on i.MX 8MP rejects BGRA as destination format; re-enable when supported"]
1565    fn test_g2d_bgra_no_resize() {
1566        for src_fmt in [
1567            PixelFormat::Rgba,
1568            PixelFormat::Yuyv,
1569            PixelFormat::Nv12,
1570            PixelFormat::Bgra,
1571        ] {
1572            test_g2d_bgra_no_resize_(src_fmt).unwrap_or_else(|e| {
1573                panic!("{src_fmt} to PixelFormat::Bgra failed: {e:?}");
1574            });
1575        }
1576    }
1577
1578    fn test_g2d_bgra_no_resize_(g2d_in_fmt: PixelFormat) -> Result<(), crate::Error> {
1579        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
1580        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgb), None)?;
1581
1582        // Create DMA buffer for G2D input
1583        let mut src2 = TensorDyn::image(
1584            1280,
1585            720,
1586            g2d_in_fmt,
1587            DType::U8,
1588            Some(TensorMemory::DmaBuf),
1589            edgefirst_tensor::CpuAccess::ReadWrite,
1590        )?;
1591        let mut cpu_converter = CPUProcessor::new();
1592
1593        if g2d_in_fmt == PixelFormat::Nv12 {
1594            let nv12_bytes = edgefirst_bench::testdata::read("zidane.nv12");
1595            src2.as_typed::<u8>()
1596                .unwrap()
1597                .map()?
1598                .as_mut_slice()
1599                .copy_from_slice(&nv12_bytes);
1600        } else {
1601            cpu_converter.convert(&src, &mut src2, None)?;
1602        }
1603
1604        let mut g2d = G2DProcessor::new()?;
1605
1606        // Convert to PixelFormat::Bgra via G2D
1607        let mut bgra_dst = TensorDyn::image(
1608            1280,
1609            720,
1610            PixelFormat::Bgra,
1611            DType::U8,
1612            Some(TensorMemory::DmaBuf),
1613            edgefirst_tensor::CpuAccess::ReadWrite,
1614        )?;
1615        let src2_dyn = src2;
1616        let mut bgra_dst_dyn = bgra_dst;
1617        g2d.convert(&src2_dyn, &mut bgra_dst_dyn, None)?;
1618        bgra_dst = {
1619            let mut __t = bgra_dst_dyn.into_u8().unwrap();
1620            __t.set_format(PixelFormat::Bgra)
1621                .map_err(|e| crate::Error::Internal(e.to_string()))?;
1622            TensorDyn::from(__t)
1623        };
1624
1625        // Reconstruct src2 from dyn for PixelFormat::Rgba conversion
1626        let src2 = {
1627            let mut __t = src2_dyn.into_u8().unwrap();
1628            __t.set_format(g2d_in_fmt)
1629                .map_err(|e| crate::Error::Internal(e.to_string()))?;
1630            TensorDyn::from(__t)
1631        };
1632
1633        // Convert to PixelFormat::Rgba via G2D as reference
1634        let mut rgba_dst = TensorDyn::image(
1635            1280,
1636            720,
1637            PixelFormat::Rgba,
1638            DType::U8,
1639            Some(TensorMemory::DmaBuf),
1640            edgefirst_tensor::CpuAccess::ReadWrite,
1641        )?;
1642        let src2_dyn2 = src2;
1643        let mut rgba_dst_dyn = rgba_dst;
1644        g2d.convert(&src2_dyn2, &mut rgba_dst_dyn, None)?;
1645        rgba_dst = {
1646            let mut __t = rgba_dst_dyn.into_u8().unwrap();
1647            __t.set_format(PixelFormat::Rgba)
1648                .map_err(|e| crate::Error::Internal(e.to_string()))?;
1649            TensorDyn::from(__t)
1650        };
1651
1652        // Copy both to CPU memory for comparison
1653        let bgra_cpu = TensorDyn::image(
1654            1280,
1655            720,
1656            PixelFormat::Bgra,
1657            DType::U8,
1658            None,
1659            edgefirst_tensor::CpuAccess::ReadWrite,
1660        )?;
1661        bgra_cpu
1662            .as_typed::<u8>()
1663            .unwrap()
1664            .map()?
1665            .as_mut_slice()
1666            .copy_from_slice(bgra_dst.as_typed::<u8>().unwrap().map()?.as_slice());
1667
1668        let rgba_cpu = TensorDyn::image(
1669            1280,
1670            720,
1671            PixelFormat::Rgba,
1672            DType::U8,
1673            None,
1674            edgefirst_tensor::CpuAccess::ReadWrite,
1675        )?;
1676        rgba_cpu
1677            .as_typed::<u8>()
1678            .unwrap()
1679            .map()?
1680            .as_mut_slice()
1681            .copy_from_slice(rgba_dst.as_typed::<u8>().unwrap().map()?.as_slice());
1682
1683        // Verify PixelFormat::Bgra output has R↔B swapped vs PixelFormat::Rgba output
1684        let bgra_map = bgra_cpu.as_typed::<u8>().unwrap().map()?;
1685        let rgba_map = rgba_cpu.as_typed::<u8>().unwrap().map()?;
1686        let bgra_buf = bgra_map.as_slice();
1687        let rgba_buf = rgba_map.as_slice();
1688
1689        assert_eq!(bgra_buf.len(), rgba_buf.len());
1690        for (i, (bc, rc)) in bgra_buf
1691            .as_chunks::<4>()
1692            .0
1693            .iter()
1694            .zip(rgba_buf.as_chunks::<4>().0)
1695            .enumerate()
1696        {
1697            assert_eq!(
1698                bc[0], rc[2],
1699                "{g2d_in_fmt} to PixelFormat::Bgra: pixel {i} B mismatch",
1700            );
1701            assert_eq!(
1702                bc[1], rc[1],
1703                "{g2d_in_fmt} to PixelFormat::Bgra: pixel {i} G mismatch",
1704            );
1705            assert_eq!(
1706                bc[2], rc[0],
1707                "{g2d_in_fmt} to PixelFormat::Bgra: pixel {i} R mismatch",
1708            );
1709            assert_eq!(
1710                bc[3], rc[3],
1711                "{g2d_in_fmt} to PixelFormat::Bgra: pixel {i} A mismatch",
1712            );
1713        }
1714        Ok(())
1715    }
1716
1717    // =========================================================================
1718    // tensor_to_g2d_surface offset & stride unit tests
1719    //
1720    // These tests verify that plane_offset and effective_row_stride are
1721    // correctly propagated into the G2DSurface.  They require DMA memory
1722    // but do NOT require G2D hardware — only the DMA_BUF_IOCTL_PHYS ioctl.
1723    // =========================================================================
1724
1725    /// Helper: build a DMA-backed Tensor<u8> with an optional plane_offset
1726    /// and an optional row stride, then return the G2DSurface.
1727    fn surface_for(
1728        width: usize,
1729        height: usize,
1730        fmt: PixelFormat,
1731        offset: Option<usize>,
1732        row_stride: Option<usize>,
1733    ) -> Result<G2DSurface, crate::Error> {
1734        use edgefirst_tensor::TensorMemory;
1735        let mut t = Tensor::<u8>::image(
1736            width,
1737            height,
1738            fmt,
1739            Some(TensorMemory::DmaBuf),
1740            edgefirst_tensor::CpuAccess::ReadWrite,
1741        )?;
1742        if let Some(o) = offset {
1743            t.set_plane_offset(o);
1744        }
1745        if let Some(s) = row_stride {
1746            t.set_row_stride_unchecked(s);
1747        }
1748        tensor_to_g2d_surface(&t)
1749    }
1750
1751    #[test]
1752    fn g2d_surface_single_plane_no_offset() {
1753        if !dma_and_g2d_or_skip() {
1754            return;
1755        }
1756        let s = surface_for(640, 480, PixelFormat::Rgba, None, None).unwrap();
1757        // planes[0] must be non-zero (valid physical address), no offset
1758        assert_ne!(s.planes[0], 0);
1759        assert_eq!(s.stride, 640);
1760    }
1761
1762    #[test]
1763    fn g2d_surface_single_plane_with_offset() {
1764        if !dma_and_g2d_or_skip() {
1765            return;
1766        }
1767        use edgefirst_tensor::TensorMemory;
1768        let mut t = Tensor::<u8>::image(
1769            640,
1770            480,
1771            PixelFormat::Rgba,
1772            Some(TensorMemory::DmaBuf),
1773            edgefirst_tensor::CpuAccess::ReadWrite,
1774        )
1775        .unwrap();
1776        let s0 = tensor_to_g2d_surface(&t).unwrap();
1777        t.set_plane_offset(4096);
1778        let s1 = tensor_to_g2d_surface(&t).unwrap();
1779        assert_eq!(s1.planes[0], s0.planes[0] + 4096);
1780    }
1781
1782    #[test]
1783    fn g2d_surface_single_plane_zero_offset() {
1784        if !dma_and_g2d_or_skip() {
1785            return;
1786        }
1787        use edgefirst_tensor::TensorMemory;
1788        let mut t = Tensor::<u8>::image(
1789            640,
1790            480,
1791            PixelFormat::Rgba,
1792            Some(TensorMemory::DmaBuf),
1793            edgefirst_tensor::CpuAccess::ReadWrite,
1794        )
1795        .unwrap();
1796        let s_none = tensor_to_g2d_surface(&t).unwrap();
1797        t.set_plane_offset(0);
1798        let s_zero = tensor_to_g2d_surface(&t).unwrap();
1799        // offset=0 should produce the same address as no offset
1800        assert_eq!(s_none.planes[0], s_zero.planes[0]);
1801    }
1802
1803    #[test]
1804    fn g2d_surface_stride_rgba() {
1805        if !dma_and_g2d_or_skip() {
1806            return;
1807        }
1808        // Default stride: width in pixels = 640
1809        let s_default = surface_for(640, 480, PixelFormat::Rgba, None, None).unwrap();
1810        assert_eq!(s_default.stride, 640);
1811
1812        // Custom stride: 2816 bytes / 4 channels = 704 pixels
1813        let s_custom = surface_for(640, 480, PixelFormat::Rgba, None, Some(2816)).unwrap();
1814        assert_eq!(s_custom.stride, 704);
1815    }
1816
1817    #[test]
1818    fn g2d_surface_stride_rgb() {
1819        if !dma_and_g2d_or_skip() {
1820            return;
1821        }
1822        let s_default = surface_for(640, 480, PixelFormat::Rgb, None, None).unwrap();
1823        assert_eq!(s_default.stride, 640);
1824
1825        // Padded: 1980 bytes / 3 channels = 660 pixels
1826        let s_custom = surface_for(640, 480, PixelFormat::Rgb, None, Some(1980)).unwrap();
1827        assert_eq!(s_custom.stride, 660);
1828    }
1829
1830    #[test]
1831    fn g2d_surface_stride_grey() {
1832        if !dma_and_g2d_or_skip() {
1833            return;
1834        }
1835        // Grey (Y800) may not be supported by all G2D hardware versions
1836        let s = match surface_for(640, 480, PixelFormat::Grey, None, Some(1024)) {
1837            Ok(s) => s,
1838            Err(e @ crate::Error::G2D(..)) => {
1839                crate::test_support::report_skip(&format!(
1840                    "g2d_surface_stride_grey: this G2D does not support Y800: {e:?}"
1841                ));
1842                return;
1843            }
1844            Err(e) => panic!("unexpected error: {e:?}"),
1845        };
1846        // Grey: 1 channel. stride in bytes = stride in pixels
1847        assert_eq!(s.stride, 1024);
1848    }
1849
1850    #[test]
1851    fn g2d_surface_contiguous_nv12_offset() {
1852        if !dma_and_g2d_or_skip() {
1853            return;
1854        }
1855        use edgefirst_tensor::TensorMemory;
1856        let mut t = Tensor::<u8>::image(
1857            640,
1858            480,
1859            PixelFormat::Nv12,
1860            Some(TensorMemory::DmaBuf),
1861            edgefirst_tensor::CpuAccess::ReadWrite,
1862        )
1863        .unwrap();
1864        let s0 = tensor_to_g2d_surface(&t).unwrap();
1865
1866        t.set_plane_offset(8192);
1867        let s1 = tensor_to_g2d_surface(&t).unwrap();
1868
1869        // Luma plane should shift by offset
1870        assert_eq!(s1.planes[0], s0.planes[0] + 8192);
1871        // UV plane = base + offset + stride * height
1872        // Without offset: UV = base + 640 * 480 = base + 307200
1873        // With offset 8192: UV = base + 8192 + 640 * 480 = base + 315392
1874        assert_eq!(s1.planes[1], s0.planes[1] + 8192);
1875    }
1876
1877    #[test]
1878    fn g2d_surface_contiguous_nv12_stride() {
1879        if !dma_and_g2d_or_skip() {
1880            return;
1881        }
1882        // NV12: 1 byte per pixel for Y. stride 640 bytes = 640 pixels.
1883        let s = surface_for(640, 480, PixelFormat::Nv12, None, None).unwrap();
1884        assert_eq!(s.stride, 640);
1885
1886        // Padded stride: 1024 bytes = 1024 pixels (NV12 channels = 1)
1887        let s_padded = surface_for(640, 480, PixelFormat::Nv12, None, Some(1024)).unwrap();
1888        assert_eq!(s_padded.stride, 1024);
1889    }
1890
1891    #[test]
1892    fn g2d_surface_multiplane_nv12_offset() {
1893        if !dma_and_g2d_or_skip() {
1894            return;
1895        }
1896        use edgefirst_tensor::TensorMemory;
1897
1898        // Create luma and chroma as separate DMA tensors
1899        let mut luma =
1900            Tensor::<u8>::new(&[480, 640], Some(TensorMemory::DmaBuf), Some("luma")).unwrap();
1901        let mut chroma =
1902            Tensor::<u8>::new(&[240, 640], Some(TensorMemory::DmaBuf), Some("chroma")).unwrap();
1903
1904        // Get baseline physical addresses with no offsets
1905        let luma_base = {
1906            let dma = luma.as_dma().unwrap();
1907            let phys: G2DPhysical = dma.fd.as_raw_fd().try_into().unwrap();
1908            phys.address()
1909        };
1910        let chroma_base = {
1911            let dma = chroma.as_dma().unwrap();
1912            let phys: G2DPhysical = dma.fd.as_raw_fd().try_into().unwrap();
1913            phys.address()
1914        };
1915
1916        // Set offsets and build multiplane tensor
1917        luma.set_plane_offset(4096);
1918        chroma.set_plane_offset(2048);
1919        let combined = Tensor::<u8>::from_planes(luma, chroma, PixelFormat::Nv12).unwrap();
1920        let s = tensor_to_g2d_surface(&combined).unwrap();
1921
1922        // Luma should include its offset
1923        assert_eq!(s.planes[0], luma_base + 4096);
1924        // Chroma should include its offset
1925        assert_eq!(s.planes[1], chroma_base + 2048);
1926    }
1927
1928    // =========================================================================
1929    // Odd-dimension end-to-end cells (Deliverable C)
1930    //
1931    // Design contract:
1932    //   • Source is a patterned NV12 tensor (Dma) filled stride-aware with a
1933    //     pattern varying in both X and Y.  A Mem copy carries the same fill for
1934    //     the CPU reference (trusted oracle from odd_dim_cpu.rs).
1935    //   • G2D output (Dma RGBA) is compared against the CPU output pixel-by-pixel
1936    //     at each tensor's own `effective_row_stride`.
1937    //   • Tolerance: ±4 (same as GL).  G2D BT.601 is limited-range on the matrix
1938    //     but historically matches within ±4 on real hardware; the tolerance is
1939    //     tightened to ±6 only if on-target validation shows it is needed.
1940    //   • If G2D rejects the dimensions (returns Err), the test documents the
1941    //     behaviour via `assert!` rather than silently passing.
1942    //   • Both test cells skip at runtime if DMA is unavailable or G2D init fails.
1943    // =========================================================================
1944
1945    /// Fill a Nv12 tensor (any memory type) with a patterned, stride-aware
1946    /// synthetic image that varies in both X and Y.
1947    ///
1948    /// Pattern: `Y(r,c) = (r*3 + c*5) % 256`
1949    /// Chroma column `cc`, chroma row `cr_row` (NV12: 4:2:0 — half in both dims):
1950    ///   `Cb = (cc*7 + cr_row*11 + 40) % 256`
1951    ///   `Cr = (cc*13 + cr_row*3 + 80) % 256`
1952    ///
1953    /// This matches `make_odd_both_source` in `odd_dim_cpu.rs` so the two test
1954    /// suites share an analytic ground truth.
1955    #[cfg(target_os = "linux")]
1956    fn fill_patterned_nv12(t: &TensorDyn) {
1957        let w = t.width().unwrap();
1958        let h = t.height().unwrap();
1959        let stride = t.effective_row_stride().unwrap();
1960
1961        // NV12 (4:2:0): chroma is ceil(H/2) rows of W/2 pairs.
1962        let chroma_h = h.div_ceil(2);
1963        let chroma_w = w.div_ceil(2);
1964
1965        let bound = t.as_typed::<u8>().unwrap();
1966        let mut m = bound.map().unwrap();
1967        let buf = m.as_mut_slice();
1968        let uv_start = stride * h;
1969
1970        // Luma: diagonal gradient.
1971        for r in 0..h {
1972            for c in 0..w {
1973                buf[r * stride + c] = ((r * 3 + c * 5) % 256) as u8;
1974            }
1975        }
1976        // Chroma: UV row pitch == stride for NV12.
1977        for cr_row in 0..chroma_h {
1978            for cc in 0..chroma_w {
1979                let cb_val = ((cc * 7 + cr_row * 11 + 40) % 256) as u8;
1980                let cr_val = ((cc * 13 + cr_row * 3 + 80) % 256) as u8;
1981                let uv_byte = uv_start + cr_row * stride + cc * 2;
1982                buf[uv_byte] = cb_val;
1983                buf[uv_byte + 1] = cr_val;
1984            }
1985        }
1986    }
1987
1988    /// Compare a G2D RGBA `u8` DMA tensor against a CPU RGBA `u8` reference,
1989    /// reading both at their real `effective_row_stride`.
1990    ///
1991    /// Returns `(max_diff, first_pixel_over_threshold)`.
1992    #[cfg(target_os = "linux")]
1993    fn compare_g2d_vs_cpu_rgba(
1994        g2d_dst: &TensorDyn,
1995        cpu_dst: &TensorDyn,
1996        w: usize,
1997        h: usize,
1998        tol: u32,
1999    ) -> (u32, Option<(usize, usize, usize)>) {
2000        let channels = 4usize; // RGBA
2001        let g2d_t = g2d_dst.as_typed::<u8>().unwrap();
2002        let cpu_t = cpu_dst.as_typed::<u8>().unwrap();
2003        let g2d_stride = g2d_t.effective_row_stride().unwrap_or(w * channels);
2004        let cpu_stride = cpu_t.effective_row_stride().unwrap_or(w * channels);
2005        let g2d_map = g2d_t.map().unwrap();
2006        let cpu_map = cpu_t.map().unwrap();
2007        let g2d_px = g2d_map.as_slice();
2008        let cpu_px = cpu_map.as_slice();
2009        let mut max_diff = 0u32;
2010        let mut first_fail: Option<(usize, usize, usize)> = None;
2011        for row in 0..h {
2012            for col in 0..w {
2013                for ch in 0..3usize {
2014                    // Compare RGB channels only; alpha is hardware-defined
2015                    let gi = row * g2d_stride + col * channels + ch;
2016                    let ci = row * cpu_stride + col * channels + ch;
2017                    let d = (g2d_px[gi] as i32 - cpu_px[ci] as i32).unsigned_abs();
2018                    if d > max_diff {
2019                        max_diff = d;
2020                    }
2021                    if first_fail.is_none() && d > tol {
2022                        first_fail = Some((col, row, ch));
2023                    }
2024                }
2025            }
2026        }
2027        (max_diff, first_fail)
2028    }
2029
2030    // -------------------------------------------------------------------------
2031    // D-01: NV12 odd-W (65×64) → RGBA via G2D vs CPU reference
2032    // -------------------------------------------------------------------------
2033
2034    /// D-01: NV12 odd-width (65×64) → RGBA via G2D, compared to CPU reference.
2035    ///
2036    /// Asserts:
2037    ///   (a) G2D accepts odd-width NV12 without returning an error.
2038    ///   (b) G2D output matches CPU reference within ±4 on RGB channels.
2039    ///       (Alpha is hardware-defined and excluded from the comparison.)
2040    ///
2041    /// On-target validation has since run: the G2D-vs-CPU residual on an
2042    /// i.MX8MP is 23–24 across widths 63–81, so the ±4 tolerance only warns and
2043    /// the `max_diff <= 35` assertion is what actually fails. Do not relax
2044    /// either without a new measurement on the board.
2045    #[test]
2046    #[cfg(target_os = "linux")]
2047    fn d01_nv12_odd_w_g2d_vs_cpu() {
2048        if !is_dma_available() {
2049            crate::test_support::report_skip("d01_nv12_odd_w_g2d_vs_cpu - DMA not available");
2050            return;
2051        }
2052        let (w, h) = (65usize, 64usize);
2053
2054        let src_dma = TensorDyn::image(
2055            w,
2056            h,
2057            PixelFormat::Nv12,
2058            DType::U8,
2059            Some(TensorMemory::DmaBuf),
2060            edgefirst_tensor::CpuAccess::ReadWrite,
2061        )
2062        .unwrap();
2063        let src_mem = TensorDyn::image(
2064            w,
2065            h,
2066            PixelFormat::Nv12,
2067            DType::U8,
2068            Some(TensorMemory::Mem),
2069            edgefirst_tensor::CpuAccess::ReadWrite,
2070        )
2071        .unwrap();
2072        fill_patterned_nv12(&src_dma);
2073        fill_patterned_nv12(&src_mem);
2074
2075        // CPU reference: Nv12 → Rgba.
2076        let mut cpu_dst = TensorDyn::image(
2077            w,
2078            h,
2079            PixelFormat::Rgba,
2080            DType::U8,
2081            None,
2082            edgefirst_tensor::CpuAccess::ReadWrite,
2083        )
2084        .unwrap();
2085        CPUProcessor::new()
2086            .convert(&src_mem, &mut cpu_dst, None)
2087            .unwrap();
2088
2089        // G2D convert: Nv12 → Rgba.
2090        let mut g2d_dst = TensorDyn::image(
2091            w,
2092            h,
2093            PixelFormat::Rgba,
2094            DType::U8,
2095            Some(TensorMemory::DmaBuf),
2096            edgefirst_tensor::CpuAccess::ReadWrite,
2097        )
2098        .unwrap();
2099        let mut g2d = match G2DProcessor::new() {
2100            Ok(g) => g,
2101            Err(e) => {
2102                crate::test_support::g2d_required_or(false);
2103                crate::test_support::report_skip(&format!(
2104                    "d01_nv12_odd_w_g2d_vs_cpu - G2D not available: {e}"
2105                ));
2106                return;
2107            }
2108        };
2109
2110        // (a) G2D must accept odd-width NV12 without error.
2111        g2d.convert(&src_dma, &mut g2d_dst, None)
2112            .unwrap_or_else(|e| {
2113                panic!("D-01: G2D rejected odd-width (65) NV12 source: {e}");
2114            });
2115
2116        // (b) G2D output ≈ CPU reference ±4 on RGB channels.
2117        let tol = 4u32;
2118        let (max_diff, first_fail) = compare_g2d_vs_cpu_rgba(&g2d_dst, &cpu_dst, w, h, tol);
2119        eprintln!("D-01 NV12 odd-W G2D vs CPU: max_diff={max_diff}");
2120        // Both CPU and G2D resolve this untagged odd-W NV12 source to
2121        // limited-range BT.601 (G2D is limited-range matrix-only), so the
2122        // YUV-matrix delta that once forced the loose >64 bound has closed. What
2123        // remains is G2D's own fixed-point rounding; its exact mechanism has not
2124        // been characterised, only its magnitude, measured below.
2125        //
2126        // Measured on an i.MX8MP (the only G2D board): max_diff 23 at 63×64,
2127        // 64×64, 65×64 and 65×63, and 24 at 80×64 and 81×64. The >35 bound has
2128        // ~11 of headroom over that and still trips a real geometry/stride
2129        // regression — the odd-W stride handling this test guards.
2130        //
2131        // History: this bound was tightened 64 → 35 without a hardware run (the
2132        // imx8mp lane had stopped executing), and the first run that reached it
2133        // read 255. That was NOT G2D: the pattern's `% 256` ramps synthesise
2134        // super-white luma, which the CPU reference's fast kernel wrapped to
2135        // black. See `fast_path_luma` in `cpu/convert.rs`. The full-range
2136        // pattern is deliberate — it is what caught that bug — so keep it.
2137        if max_diff > tol {
2138            eprintln!(
2139                "WARNING: D-01 NV12 odd-W G2D vs CPU max_diff={max_diff} > {tol} \
2140                 (G2D fixed-point rounding; first bad at {first_fail:?})"
2141            );
2142        }
2143        assert!(
2144            max_diff <= 35,
2145            "D-01: gross NV12 odd-W G2D vs CPU mismatch max_diff={max_diff} (>35); first bad at {first_fail:?}"
2146        );
2147    }
2148
2149    // -------------------------------------------------------------------------
2150    // D-03: NV12 odd-both (65×63) → RGBA via G2D vs CPU reference
2151    // -------------------------------------------------------------------------
2152
2153    /// D-03: NV12 odd-width AND odd-height (65×63) → RGBA via G2D vs CPU reference.
2154    ///
2155    /// This is the strictest G2D odd-dimension cell: it exercises both the
2156    /// stride-boundary at the last luma row AND the half-height chroma plane
2157    /// boundary.  If the G2D hardware rejects the conversion (e.g. hardware
2158    /// alignment requirement on chroma height), this test documents that via a
2159    /// panic rather than passing silently.
2160    #[test]
2161    #[cfg(target_os = "linux")]
2162    fn d03_nv12_odd_both_g2d_vs_cpu() {
2163        if !is_dma_available() {
2164            crate::test_support::report_skip("d03_nv12_odd_both_g2d_vs_cpu - DMA not available");
2165            return;
2166        }
2167        let (w, h) = (65usize, 63usize);
2168
2169        let src_dma = TensorDyn::image(
2170            w,
2171            h,
2172            PixelFormat::Nv12,
2173            DType::U8,
2174            Some(TensorMemory::DmaBuf),
2175            edgefirst_tensor::CpuAccess::ReadWrite,
2176        )
2177        .unwrap();
2178        let src_mem = TensorDyn::image(
2179            w,
2180            h,
2181            PixelFormat::Nv12,
2182            DType::U8,
2183            Some(TensorMemory::Mem),
2184            edgefirst_tensor::CpuAccess::ReadWrite,
2185        )
2186        .unwrap();
2187        fill_patterned_nv12(&src_dma);
2188        fill_patterned_nv12(&src_mem);
2189
2190        // CPU reference: Nv12 → Rgba.
2191        let mut cpu_dst = TensorDyn::image(
2192            w,
2193            h,
2194            PixelFormat::Rgba,
2195            DType::U8,
2196            None,
2197            edgefirst_tensor::CpuAccess::ReadWrite,
2198        )
2199        .unwrap();
2200        CPUProcessor::new()
2201            .convert(&src_mem, &mut cpu_dst, None)
2202            .unwrap();
2203
2204        // G2D convert: Nv12 → Rgba.
2205        let mut g2d_dst = TensorDyn::image(
2206            w,
2207            h,
2208            PixelFormat::Rgba,
2209            DType::U8,
2210            Some(TensorMemory::DmaBuf),
2211            edgefirst_tensor::CpuAccess::ReadWrite,
2212        )
2213        .unwrap();
2214        let mut g2d = match G2DProcessor::new() {
2215            Ok(g) => g,
2216            Err(e) => {
2217                crate::test_support::g2d_required_or(false);
2218                crate::test_support::report_skip(&format!(
2219                    "d03_nv12_odd_both_g2d_vs_cpu - G2D not available: {e}"
2220                ));
2221                return;
2222            }
2223        };
2224
2225        // G2D must accept odd-both NV12 without error (documented behaviour).
2226        // If hardware rejects it, the panic message is the intended failure report.
2227        g2d.convert(&src_dma, &mut g2d_dst, None)
2228            .unwrap_or_else(|e| {
2229                panic!("D-03: G2D rejected odd-both (65×63) NV12 source: {e}");
2230            });
2231
2232        let tol = 4u32;
2233        let (max_diff, first_fail) = compare_g2d_vs_cpu_rgba(&g2d_dst, &cpu_dst, w, h, tol);
2234        eprintln!("D-03 NV12 odd-both G2D vs CPU: max_diff={max_diff}");
2235        // The YUV-matrix delta has closed — see d01 above for the measured
2236        // per-width figures behind the >35 bound, and for why this pattern's
2237        // super-white luma must stay. Warn above ±tol on G2D fixed-point
2238        // rounding, fail only on a gross >35 geometry/stride regression.
2239        if max_diff > tol {
2240            eprintln!(
2241                "WARNING: D-03 NV12 odd-both G2D vs CPU max_diff={max_diff} > {tol} \
2242                 (G2D fixed-point rounding; first bad at {first_fail:?})"
2243            );
2244        }
2245        assert!(
2246            max_diff <= 35,
2247            "D-03: gross NV12 odd-both G2D vs CPU mismatch max_diff={max_diff} (>35); first bad at {first_fail:?}"
2248        );
2249    }
2250    /// An `I8` destination gets its bias from a CPU post-pass after the blit,
2251    /// and that pass must stay inside each row's pixels: the bytes between one
2252    /// row's pixels and the next are an allocation pad or a `view()` parent's
2253    /// neighbouring tile, and a flat walk of the mapping biased both.
2254    ///
2255    /// The oracle is the same blit into a `U8` destination at the same pitch,
2256    /// G2D being deterministic for an identical blit: every pixel byte of the
2257    /// `I8` result must be that byte XOR 0x80 (alpha excepted), and both
2258    /// destinations' pads must still hold the poison they were filled with.
2259    /// The `U8` pad is asserted first, so a DPU that writes the pad is not
2260    /// misread as a bias-pass regression.
2261    #[test]
2262    #[cfg(target_os = "linux")]
2263    fn g2d_int8_dst_biases_only_the_pixel_bytes_at_the_declared_stride() {
2264        if !dma_and_g2d_or_skip() {
2265            return;
2266        }
2267        const W: usize = 64;
2268        const H: usize = 16;
2269        const POISON: u8 = 0xAB;
2270        let fmt = PixelFormat::Rgba;
2271        let bpp = fmt.channels();
2272        let row_bytes = W * bpp;
2273        // 64 bytes of pad past the pixels keeps the pitch 64-aligned for G2D.
2274        let stride = row_bytes + 64;
2275
2276        let src = TensorDyn::image(
2277            W,
2278            H,
2279            fmt,
2280            DType::U8,
2281            Some(TensorMemory::DmaBuf),
2282            edgefirst_tensor::CpuAccess::ReadWrite,
2283        )
2284        .expect("DMA source");
2285        {
2286            let t = src.as_typed::<u8>().unwrap();
2287            let s = t.effective_row_stride().unwrap();
2288            let mut m = t.map().unwrap();
2289            let buf = m.as_mut_slice();
2290            for y in 0..H {
2291                for i in 0..row_bytes {
2292                    buf[y * s + i] = (y * 31 + i * 7) as u8;
2293                }
2294            }
2295        }
2296
2297        let mut u8_dst = TensorDyn::image_with_stride(
2298            W,
2299            H,
2300            fmt,
2301            DType::U8,
2302            stride,
2303            Some(TensorMemory::DmaBuf),
2304            edgefirst_tensor::CpuAccess::ReadWrite,
2305        )
2306        .expect("padded U8 destination");
2307        let mut i8_dst = TensorDyn::image_with_stride(
2308            W,
2309            H,
2310            fmt,
2311            DType::I8,
2312            stride,
2313            Some(TensorMemory::DmaBuf),
2314            edgefirst_tensor::CpuAccess::ReadWrite,
2315        )
2316        .expect("padded I8 destination");
2317        assert_eq!(i8_dst.effective_row_stride(), Some(stride));
2318        // Both destinations are poisoned: the `U8` pad is what says whether
2319        // G2D itself wrote past the pixels, and without it a DPU that does
2320        // would look like the bias pass over-reaching.
2321        u8_dst
2322            .as_typed::<u8>()
2323            .unwrap()
2324            .map()
2325            .unwrap()
2326            .as_mut_slice()
2327            .fill(POISON);
2328        i8_dst
2329            .as_typed::<i8>()
2330            .unwrap()
2331            .map()
2332            .unwrap()
2333            .as_mut_slice()
2334            .fill(POISON as i8);
2335
2336        let mut g2d = G2DProcessor::new().unwrap();
2337        g2d.convert(&src, &mut u8_dst, None).expect("U8 blit");
2338        g2d.convert(&src, &mut i8_dst, None).expect("I8 blit");
2339
2340        let u8_stride = u8_dst.effective_row_stride().unwrap();
2341        let want = u8_dst
2342            .as_typed::<u8>()
2343            .unwrap()
2344            .map()
2345            .unwrap()
2346            .as_slice()
2347            .to_vec();
2348        let got: Vec<u8> = i8_dst
2349            .as_typed::<i8>()
2350            .unwrap()
2351            .map()
2352            .unwrap()
2353            .as_slice()
2354            .iter()
2355            .map(|&v| v as u8)
2356            .collect();
2357        // Both mappings are indexed to the end of the last row's pad below;
2358        // say so here rather than failing on an index panic that names no
2359        // stride.
2360        assert!(
2361            got.len() >= H * stride && want.len() >= H * u8_stride,
2362            "a {stride} B pitch over {H} rows needs {} B, mapped {} (I8) and {} (U8)",
2363            H * stride,
2364            got.len(),
2365            want.len()
2366        );
2367        // The blit's own pad first: everything below reads the `U8`
2368        // destination as the oracle, so a G2D write past the pixels has to be
2369        // named as G2D's rather than surface as a bias-pass failure.
2370        for y in 0..H {
2371            assert!(
2372                want[y * u8_stride + row_bytes..(y + 1) * u8_stride]
2373                    .iter()
2374                    .all(|&b| b == POISON),
2375                "row {y}: the G2D blit itself wrote past the pixels; the I8 \
2376                 assertions below cannot separate that from the bias pass"
2377            );
2378        }
2379        for y in 0..H {
2380            for x in 0..W {
2381                for c in 0..bpp {
2382                    let g = got[y * stride + x * bpp + c];
2383                    let w = want[y * u8_stride + x * bpp + c];
2384                    let expect = if c == bpp - 1 { w } else { w ^ 0x80 };
2385                    assert_eq!(g, expect, "pixel ({x},{y}) channel {c}");
2386                }
2387            }
2388            assert!(
2389                got[y * stride + row_bytes..(y + 1) * stride]
2390                    .iter()
2391                    .all(|&b| b == POISON),
2392                "row {y}: the padding past the pixels was biased"
2393            );
2394        }
2395    }
2396}