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