Skip to main content

edgefirst_image/
lib.rs

1// SPDX-FileCopyrightText: Copyright 2025 Au-Zone Technologies
2// SPDX-License-Identifier: Apache-2.0
3
4/*!
5
6## EdgeFirst HAL - Image Converter
7
8The `edgefirst_image` crate is part of the EdgeFirst Hardware Abstraction
9Layer (HAL) and provides functionality for converting images between
10different formats and sizes.  The crate is designed to work with hardware
11acceleration when available, but also provides a CPU-based fallback for
12environments where hardware acceleration is not present or not suitable.
13
14What the crate covers:
15- Packed and planar RGB families (`Rgb`, `Rgba`, `Bgra`, `Grey`,
16  `PlanarRgb`, `PlanarRgba`), semi-planar YUV sources (`Nv12`, `Nv16`,
17  `Nv24`), and packed YUV (`Yuyv`, `Vyuy`).
18- Source crop, resize, letterbox, 90° rotation, and flipping in one call.
19- Segmentation-mask rendering, including a fused GPU proto path.
20- SAHI-style input tiling for small-object detection in large frames
21  ([`TilingConfig`], [`ImageProcessor::tile_into`]).
22- Hardware acceleration (OpenGL, NXP G2D) with a CPU fallback that is
23  always available.
24
25The crate uses [`TensorDyn`] from `edgefirst_tensor` to represent images,
26with [`PixelFormat`] metadata describing the pixel layout. The
27[`ImageProcessor`] struct manages the conversion process, selecting
28the appropriate conversion method based on the available hardware.
29
30Geometry is **source-side**: [`Crop`] says which sub-rectangle of the source
31to sample and how to fit it into the destination's shape. Where the output
32lands is the destination itself — pass a
33[`view`](edgefirst_tensor::TensorDyn::view) or
34[`batch`](edgefirst_tensor::TensorDyn::batch) of a larger tensor to render
35into one tile of a batch.
36
37## Examples
38
39```rust
40# use edgefirst_image::{ImageProcessor, Rotation, Flip, Crop, ImageProcessorTrait};
41# use edgefirst_codec::{peek_info, ImageDecoder, ImageLoad};
42# use edgefirst_tensor::{CpuAccess, PixelFormat, DType, Tensor, TensorMemory};
43# fn main() -> Result<(), edgefirst_image::Error> {
44let image = edgefirst_bench::testdata::read("zidane.jpg");
45// The codec emits the source's native format (a colour JPEG decodes to NV12)
46// and configures the destination tensor's dims+format during the decode.
47let info = peek_info(&image).expect("peek");
48// Heap tensors are CPU-touched on both sides (codec decode writes, the
49// engine's upload/CPU-fallback paths read), so declare ReadWrite. Pure
50// hardware pipelines allocate their convert destinations with
51// `CpuAccess::None` instead.
52let mut src = Tensor::<u8>::image(info.width, info.height, info.format,
53                                   Some(TensorMemory::Mem), CpuAccess::ReadWrite)?;
54let mut decoder = ImageDecoder::new();
55src.load_image(&mut decoder, &image).expect("decode");
56// Convert the native NV12 frame to packed RGB for downstream processing.
57let mut converter = ImageProcessor::new()?;
58let mut dst =
59    converter.create_image(640, 480, PixelFormat::Rgb, DType::U8, None, CpuAccess::ReadWrite)?;
60converter.convert(&src.into(), &mut dst, Rotation::None, Flip::None, Crop::default())?;
61# Ok(())
62# }
63```
64
65## Environment Variables
66The behavior of the `edgefirst_image::ImageProcessor` struct can be influenced by the
67following environment variables:
68- `EDGEFIRST_FORCE_BACKEND`: When set to `cpu`, `g2d`, or `opengl` (case-insensitive),
69  only that single backend is initialized and no fallback chain is used. If the
70  forced backend fails to initialize, an error is returned immediately. This is
71  useful for benchmarking individual backends in isolation. When this variable is
72  set, the `EDGEFIRST_DISABLE_*` variables are ignored.
73- `EDGEFIRST_DISABLE_GL`: If set to `1`, disables the use of OpenGL for image
74  conversion, forcing the use of CPU or other available hardware methods.
75- `EDGEFIRST_DISABLE_G2D`: If set to `1`, disables the use of G2D for image
76  conversion, forcing the use of CPU or other available hardware methods.
77- `EDGEFIRST_DISABLE_CPU`: If set to `1`, disables the use of CPU for image
78  conversion, forcing the use of hardware acceleration methods. If no hardware
79  acceleration methods are available, an error will be returned when attempting
80  to create an `ImageProcessor`.
81
82Additionally the TensorMemory used by default allocations can be controlled using the
83`EDGEFIRST_TENSOR_FORCE_MEM` environment variable. If set to `1`, default tensor memory
84uses system memory. This will disable the use of specialized memory regions for tensors
85and hardware acceleration. However, this will increase the performance of the CPU converter.
86*/
87#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
88
89/// Retained constructor: installs the coverage flush-on-abort handler for this
90/// crate's instrumented test binary. See `edgefirst_tensor::covguard`. Only
91/// present under coverage on Linux (`.init_array` is ELF-only; flush is Linux-only).
92#[cfg(all(coverage, target_os = "linux"))]
93#[used]
94#[link_section = ".init_array"]
95static __EDGEFIRST_COV_INSTALL: extern "C" fn() = {
96    extern "C" fn ctor() {
97        edgefirst_tensor::covguard::install();
98    }
99    ctor
100};
101
102/// Pitch alignment requirement for DMA-BUF tensors that may be imported as
103/// EGLImages by the GL backend. Mali Valhall (i.MX 95 / G310) rejects
104/// `eglCreateImageKHR` with `EGL_BAD_ALLOC` for any DMA-BUF whose row pitch
105/// is not a multiple of 64 bytes; Vivante GC7000UL (i.MX 8MP) accepts any
106/// pitch so the constant is harmless on that path. 64 is the smallest
107/// alignment that satisfies every embedded ARM GPU we ship to.
108///
109/// Applied automatically inside [`ImageProcessor::create_image`] when the
110/// allocation lands on `TensorMemory::Dma`. External callers that allocate
111/// their own DMA-BUF tensors (e.g. GStreamer plugins, video pipelines) can
112/// use [`align_width_for_gpu_pitch`] to compute a width whose resulting row
113/// stride satisfies this requirement.
114pub const GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES: usize = 64;
115
116/// Round `width` (in pixels) up so the resulting row stride
117/// `width * bpp` is a multiple of [`GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES`]
118/// AND a multiple of `bpp` (so the rounded width is an integer pixel count).
119///
120/// `bpp` must be the per-pixel byte count for the image's primary plane
121/// (e.g. 4 for RGBA8/BGRA8, 3 for RGB888, 1 for Grey/NV12-luma).
122///
123/// External callers — GStreamer plugins, video pipelines, anyone wrapping a
124/// foreign DMA-BUF — should call this when sizing the destination so that
125/// `eglCreateImageKHR` doesn't reject the import on Mali. Pre-aligned widths
126/// (640, 1280, 1920, 3008, 3840 …) round-trip unchanged; misaligned widths
127/// are bumped up to the next valid value.
128///
129/// # Overflow behaviour
130///
131/// All arithmetic is checked. If the alignment computation or the rounded
132/// width would overflow `usize`, the function logs a warning and returns the
133/// original `width` unchanged rather than wrapping or producing a smaller
134/// value. Callers can rely on the returned width being **at least** the
135/// requested width.
136///
137/// `bpp == 0` and `width == 0` short-circuit to return the input unchanged.
138///
139/// # Examples
140///
141/// ```
142/// use edgefirst_image::align_width_for_gpu_pitch;
143///
144/// // RGBA8 (bpp=4): width must round to a multiple of 16 pixels (64-byte stride).
145/// assert_eq!(align_width_for_gpu_pitch(1920, 4), 1920); // already aligned
146/// assert_eq!(align_width_for_gpu_pitch(3004, 4), 3008); // crowd.png case: +4 px
147/// assert_eq!(align_width_for_gpu_pitch(1281, 4), 1296); // +15 px
148///
149/// // RGB888 (bpp=3): width must round to a multiple of 64 pixels (192-byte stride).
150/// assert_eq!(align_width_for_gpu_pitch(640, 3), 640);
151/// assert_eq!(align_width_for_gpu_pitch(641, 3), 704);
152/// ```
153pub fn align_width_for_gpu_pitch(width: usize, bpp: usize) -> usize {
154    if bpp == 0 || width == 0 {
155        return width;
156    }
157
158    // The minimum aligned stride must be a common multiple of both the
159    // GPU's pitch alignment and the per-pixel byte count. Using the LCM
160    // guarantees the rounded stride is an integer multiple of `bpp`, so
161    // converting back to a pixel count is exact.
162    //
163    // Compute the alignment in pixels (`width_alignment`) so we never need
164    // to multiply `width * bpp`, which is the only operation that could
165    // realistically overflow for large caller-supplied widths.
166    let Some(lcm_alignment) = checked_num_integer_lcm(GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES, bpp)
167    else {
168        log::warn!(
169            "align_width_for_gpu_pitch: lcm({GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES}, {bpp}) \
170             overflows usize, returning unaligned width {width}"
171        );
172        return width;
173    };
174    if lcm_alignment == 0 {
175        return width;
176    }
177
178    debug_assert_eq!(lcm_alignment % bpp, 0);
179    let width_alignment = lcm_alignment / bpp;
180    if width_alignment == 0 {
181        return width;
182    }
183
184    let remainder = width % width_alignment;
185    if remainder == 0 {
186        return width;
187    }
188
189    let pad = width_alignment - remainder;
190    match width.checked_add(pad) {
191        Some(aligned) => aligned,
192        None => {
193            log::warn!(
194                "align_width_for_gpu_pitch: width {width} + pad {pad} overflows usize, \
195                 returning unaligned (caller should use a smaller width or pre-aligned size)"
196            );
197            width
198        }
199    }
200}
201
202/// Round `min_pitch_bytes` up to the next multiple of
203/// [`GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES`]. Returns `None` if the rounded
204/// value would overflow `usize`. Returns `Some(0)` for input 0.
205///
206/// Used internally by [`ImageProcessor::create_image`] to compute the
207/// padded row stride for DMA-backed image allocations. External callers
208/// that need pixel-counted alignment (instead of raw byte pitch) should
209/// use [`align_width_for_gpu_pitch`] instead.
210#[cfg(target_os = "linux")]
211pub(crate) fn align_pitch_bytes_to_gpu_alignment(min_pitch_bytes: usize) -> Option<usize> {
212    let alignment = GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES;
213    if min_pitch_bytes == 0 {
214        return Some(0);
215    }
216    let remainder = min_pitch_bytes % alignment;
217    if remainder == 0 {
218        return Some(min_pitch_bytes);
219    }
220    min_pitch_bytes.checked_add(alignment - remainder)
221}
222
223/// Overflow-safe least common multiple. Returns `None` when `(a / gcd) * b`
224/// would wrap.
225fn checked_num_integer_lcm(a: usize, b: usize) -> Option<usize> {
226    if a == 0 || b == 0 {
227        return Some(0);
228    }
229    let g = num_integer_gcd(a, b);
230    // a / g is exact (g divides a by definition) and at most a, so this
231    // division never panics. Only the subsequent multiply can overflow.
232    (a / g).checked_mul(b)
233}
234
235fn num_integer_gcd(a: usize, b: usize) -> usize {
236    if b == 0 {
237        a
238    } else {
239        num_integer_gcd(b, a % b)
240    }
241}
242
243/// Bytes-per-pixel for the primary plane of `format` at element size `elem`.
244/// Returns `None` for formats that don't have a single packed BPP (semi-planar
245/// chroma is handled separately, returning the luma-plane bpp).
246///
247/// External callers can use this together with [`align_width_for_gpu_pitch`]
248/// to size their own DMA-BUFs without having to remember per-format BPPs:
249///
250/// ```
251/// use edgefirst_image::{align_width_for_gpu_pitch, primary_plane_bpp};
252/// use edgefirst_tensor::PixelFormat;
253///
254/// let bpp = primary_plane_bpp(PixelFormat::Rgba, 1).unwrap();
255/// let aligned = align_width_for_gpu_pitch(3004, bpp);
256/// assert_eq!(aligned, 3008);
257/// ```
258pub fn primary_plane_bpp(format: PixelFormat, elem: usize) -> Option<usize> {
259    use edgefirst_tensor::PixelLayout;
260    match format.layout() {
261        PixelLayout::Packed => Some(format.channels() * elem),
262        PixelLayout::Planar => Some(elem),
263        // For NV12/NV16 the luma plane is single-channel so the pitch
264        // matches `elem`; the chroma plane uses the same pitch in bytes
265        // (UV is half-width but two interleaved channels = same pitch).
266        PixelLayout::SemiPlanar => Some(elem),
267        // `PixelLayout` is non-exhaustive — fall through unaligned for
268        // any future variant we don't yet recognise.
269        _ => None,
270    }
271}
272
273/// Return the GPU-aligned pitch in bytes when a DMA-backed image of
274/// `width × fmt` would need row-stride padding, or `None` when the
275/// natural pitch already satisfies `GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES`
276/// or the caller has explicitly requested non-DMA memory.
277///
278/// Mali G310 (i.MX 95) rejects `eglCreateImage` from DMA-BUFs whose
279/// `PLANE0_PITCH_EXT` is not a multiple of 64 bytes, surfacing as
280/// `EGL_BAD_ALLOC`. The `load_image_test_helper` test-only helper
281/// in this crate uses this to decide whether to allocate a tensor
282/// with padded row stride before invoking the decode path; production
283/// callers do the equivalent peek → allocate → decode dance themselves
284/// (see crate-level docs).
285#[cfg(all(target_os = "linux", test))]
286pub(crate) fn padded_dma_pitch_for(
287    fmt: PixelFormat,
288    width: usize,
289    memory: &Option<TensorMemory>,
290) -> Option<usize> {
291    // Only pad when the caller explicitly requested DMA, or when they
292    // left memory selection to the allocator AND DMA is actually
293    // available. `Tensor::image_with_stride(..., None)` always routes
294    // through DMA allocation, so treating `None` as "DMA wanted"
295    // unconditionally would convert a normally-working image load into
296    // a hard failure on systems where DMA is unavailable (sandboxed
297    // CI, missing `/dev/dma_heap`, permission-denied containers) —
298    // whereas `Tensor::image(..., None)` would have fallen back to
299    // SHM/Mem there.
300    match memory {
301        Some(TensorMemory::Dma) => {}
302        None if edgefirst_tensor::is_dma_available() => {}
303        _ => return None,
304    }
305    // Padding only applies to packed layouts — `Tensor::image_with_stride`
306    // rejects semi-planar / planar formats, and those take their own
307    // per-plane pitches on import anyway.
308    if fmt.layout() != PixelLayout::Packed {
309        return None;
310    }
311    let bpp = primary_plane_bpp(fmt, 1)?;
312    let natural = width.checked_mul(bpp)?;
313    let aligned = align_pitch_bytes_to_gpu_alignment(natural)?;
314    if aligned > natural {
315        Some(aligned)
316    } else {
317        None
318    }
319}
320
321pub use cpu::CPUProcessor;
322pub use edgefirst_codec as codec;
323
324#[cfg(test)]
325use edgefirst_decoder::ProtoLayout;
326use edgefirst_decoder::{DetectBox, ProtoData, Segmentation};
327#[doc(inline)]
328pub use edgefirst_tensor::Region;
329#[cfg(any(test, all(target_os = "linux", feature = "opengl")))]
330use edgefirst_tensor::Tensor;
331use edgefirst_tensor::{
332    DType, PixelFormat, PixelLayout, TensorDyn, TensorMemory, TensorTrait as _,
333};
334use enum_dispatch::enum_dispatch;
335pub use error::{Error, Result};
336#[cfg(target_os = "linux")]
337pub use g2d::G2DProcessor;
338#[cfg(all(
339    any(
340        target_os = "linux",
341        target_os = "macos",
342        target_os = "ios",
343        target_os = "android"
344    ),
345    feature = "opengl"
346))]
347pub use opengl_headless::EglDisplayKind;
348#[cfg(all(
349    any(
350        target_os = "linux",
351        target_os = "macos",
352        target_os = "ios",
353        target_os = "android"
354    ),
355    feature = "opengl"
356))]
357pub use opengl_headless::GLProcessorThreaded;
358#[cfg(all(
359    any(
360        target_os = "linux",
361        target_os = "macos",
362        target_os = "ios",
363        target_os = "android"
364    ),
365    feature = "opengl"
366))]
367pub use opengl_headless::Int8InterpolationMode;
368#[cfg(target_os = "linux")]
369#[cfg(feature = "opengl")]
370pub use opengl_headless::{probe_egl_displays, EglDisplayInfo};
371// EGLImage cache counter snapshots (diagnostics): see
372// `GLProcessorThreaded::egl_cache_stats` and the steady-state import gate in
373// `crates/image/ARCHITECTURE.md § image.convert.gl.egl_import`.
374#[cfg(all(
375    any(
376        target_os = "linux",
377        target_os = "macos",
378        target_os = "ios",
379        target_os = "android"
380    ),
381    feature = "opengl"
382))]
383pub use opengl_headless::{CacheStats, ConvertStats, GlCacheStats};
384use std::{fmt::Display, time::Instant};
385
386mod colorimetry;
387mod cpu;
388mod error;
389mod g2d;
390#[path = "gl/mod.rs"]
391mod opengl_headless;
392mod tiling;
393pub use tiling::{tile_grid, TilePlacement, TileSpec, TilingConfig};
394
395// Use `edgefirst_tensor::PixelFormat` variants (Rgb, Rgba, Grey, etc.) and
396// `TensorDyn` / `Tensor<u8>` with `.format()` metadata instead.
397
398#[derive(Debug, Clone, Copy, PartialEq, Eq)]
399pub enum Rotation {
400    None = 0,
401    Clockwise90 = 1,
402    Rotate180 = 2,
403    CounterClockwise90 = 3,
404}
405impl Rotation {
406    /// Creates a Rotation enum from an angle in degrees. The angle must be a
407    /// multiple of 90.
408    ///
409    /// # Panics
410    /// Panics if the angle is not a multiple of 90.
411    ///
412    /// # Examples
413    /// ```rust
414    /// # use edgefirst_image::Rotation;
415    /// let rotation = Rotation::from_degrees_clockwise(270);
416    /// assert_eq!(rotation, Rotation::CounterClockwise90);
417    /// ```
418    pub fn from_degrees_clockwise(angle: usize) -> Rotation {
419        match angle.rem_euclid(360) {
420            0 => Rotation::None,
421            90 => Rotation::Clockwise90,
422            180 => Rotation::Rotate180,
423            270 => Rotation::CounterClockwise90,
424            _ => panic!("rotation angle is not a multiple of 90"),
425        }
426    }
427}
428
429#[derive(Debug, Clone, Copy, PartialEq, Eq)]
430pub enum Flip {
431    None = 0,
432    Vertical = 1,
433    Horizontal = 2,
434}
435
436/// Controls how the color palette index is chosen for each detected object.
437#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
438pub enum ColorMode {
439    /// Color is chosen by object class label (`det.label`). Default.
440    ///
441    /// Preserves backward compatibility and is correct for semantic
442    /// segmentation where colors carry class meaning.
443    #[default]
444    Class,
445    /// Color is chosen by instance order (loop index, zero-based).
446    ///
447    /// Each detected object gets a unique color regardless of class,
448    /// useful for instance segmentation.
449    Instance,
450    /// Color is chosen by track ID (future use; currently behaves like
451    /// [`Instance`](Self::Instance)).
452    Track,
453}
454
455impl ColorMode {
456    /// Return the palette index for a detection given its loop index and label.
457    #[inline]
458    pub fn index(self, idx: usize, label: usize) -> usize {
459        match self {
460            ColorMode::Class => label,
461            ColorMode::Instance | ColorMode::Track => idx,
462        }
463    }
464}
465
466/// Controls the resolution and coordinate frame of masks produced by
467/// [`ImageProcessor::materialize_masks`].
468///
469/// - [`Proto`](Self::Proto) returns per-detection tiles at proto-plane
470///   resolution (e.g. 48×32 u8 for a typical COCO bbox on a 160×160 proto
471///   plane). This is the historical behavior of `materialize_masks` and the
472///   fastest path because no upsample runs inside HAL. Mask values are
473///   continuous sigmoid output quantized to `uint8 [0, 255]`.
474/// - [`Scaled`](Self::Scaled) returns per-detection tiles at caller-specified
475///   pixel resolution by upsampling the full proto plane once and cropping by
476///   bbox after sigmoid. The upsample uses bilinear interpolation with
477///   edge-clamp sampling — semantically equivalent to Ultralytics'
478///   `process_masks_retina` reference. When a `letterbox` is also passed to
479///   [`materialize_masks`], the inverse letterbox transform is applied during
480///   the upsample so mask pixels land in original-content coordinates
481///   (drop-in for overlay on the original image). Mask values are binary
482///   `uint8 {0, 255}` after thresholding sigmoid > 0.5 — interchangeable
483///   with `Proto` output via the same `> 127` test.
484///
485/// [`materialize_masks`]: ImageProcessor::materialize_masks
486#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
487pub enum MaskResolution {
488    /// Per-detection tile at proto-plane resolution (default).
489    #[default]
490    Proto,
491    /// Per-detection tile at `(width, height)` pixel resolution in the
492    /// coordinate frame determined by the `letterbox` parameter of
493    /// [`ImageProcessor::materialize_masks`].
494    Scaled {
495        /// Target pixel width of the output coordinate frame.
496        width: u32,
497        /// Target pixel height of the output coordinate frame.
498        height: u32,
499    },
500}
501
502/// Options for mask overlay rendering.
503///
504/// Controls how segmentation masks are composited onto the destination image:
505/// - `background`: when set, the background image is drawn first and masks
506///   are composited over it (result written to `dst`). When `None`, `dst` is
507///   cleared to `0x00000000` (fully transparent) before masks are drawn.
508///   **`dst` is always fully overwritten — its prior contents are never
509///   preserved.** Callers who used to pre-load an image into `dst` before
510///   calling `draw_decoded_masks` / `draw_proto_masks` must now supply that
511///   image via `background` instead (behaviour changed in v0.16.4).
512/// - `opacity`: scales the alpha of rendered mask colors. `1.0` (default)
513///   preserves the class color's alpha unchanged; `0.5` makes masks
514///   semi-transparent.
515/// - `color_mode`: controls whether colors are assigned by class label,
516///   instance index, or track ID. Defaults to [`ColorMode::Class`].
517#[derive(Debug, Clone, Copy)]
518pub struct MaskOverlay<'a> {
519    /// Compositing source image. Must have the same dimensions and pixel
520    /// format as `dst`. When `Some`, the output is `background + masks`.
521    /// When `None`, `dst` is cleared to `0x00000000` before masks are drawn.
522    pub background: Option<&'a TensorDyn>,
523    pub opacity: f32,
524    /// Normalized letterbox region `[xmin, ymin, xmax, ymax]` in model-input
525    /// space that contains actual image content (the rest is padding).
526    ///
527    /// When set, bounding boxes and mask coordinates from the decoder (which
528    /// are in model-input normalized space) are mapped back to the original
529    /// image coordinate space before rendering.
530    ///
531    /// Use [`with_letterbox_crop`](Self::with_letterbox_crop) to compute this
532    /// from the [`Crop`] that was used in the model input [`convert`](crate::ImageProcessorTrait::convert) call.
533    pub letterbox: Option<[f32; 4]>,
534    pub color_mode: ColorMode,
535}
536
537impl Default for MaskOverlay<'_> {
538    fn default() -> Self {
539        Self {
540            background: None,
541            opacity: 1.0,
542            letterbox: None,
543            color_mode: ColorMode::Class,
544        }
545    }
546}
547
548impl<'a> MaskOverlay<'a> {
549    pub fn new() -> Self {
550        Self::default()
551    }
552
553    /// Set the compositing source image.
554    ///
555    /// `bg` must have the same dimensions and pixel format as the `dst` passed
556    /// to [`draw_decoded_masks`](crate::ImageProcessorTrait::draw_decoded_masks) /
557    /// [`draw_proto_masks`](crate::ImageProcessorTrait::draw_proto_masks).
558    /// The output will be `bg + masks`. Without a background, `dst` is cleared
559    /// to `0x00000000`.
560    pub fn with_background(mut self, bg: &'a TensorDyn) -> Self {
561        self.background = Some(bg);
562        self
563    }
564
565    pub fn with_opacity(mut self, opacity: f32) -> Self {
566        self.opacity = opacity.clamp(0.0, 1.0);
567        self
568    }
569
570    pub fn with_color_mode(mut self, mode: ColorMode) -> Self {
571        self.color_mode = mode;
572        self
573    }
574
575    /// Set the letterbox transform from the [`Crop`] used when preparing the
576    /// model input, so that bounding boxes and masks are correctly mapped back
577    /// to the original image coordinate space during rendering.
578    ///
579    /// Pass the same `crop` that was given to
580    /// [`convert`](crate::ImageProcessorTrait::convert) along with the model
581    /// input dimensions (`model_w` × `model_h`).
582    ///
583    /// Has no effect when `crop.dst_rect` is `None` (no letterbox applied).
584    pub fn with_letterbox_crop(
585        mut self,
586        crop: &Crop,
587        src_w: usize,
588        src_h: usize,
589        model_w: usize,
590        model_h: usize,
591    ) -> Self {
592        // The letterbox placement is resolved from the same source/destination
593        // dimensions `convert()` used, so the inverse map matches the render.
594        if let Ok(resolved) = crop.resolve(src_w, src_h, model_w, model_h) {
595            if let Some(r) = resolved.dst_rect {
596                self.letterbox = Some([
597                    r.left as f32 / model_w as f32,
598                    r.top as f32 / model_h as f32,
599                    (r.left + r.width) as f32 / model_w as f32,
600                    (r.top + r.height) as f32 / model_h as f32,
601                ]);
602            }
603        }
604        self
605    }
606}
607
608/// Apply the inverse letterbox transform to a bounding box.
609///
610/// `letterbox` is `[lx0, ly0, lx1, ly1]` — the normalized region of the model
611/// input that contains actual image content (output of
612/// [`MaskOverlay::with_letterbox_crop`]).
613///
614/// Converts model-input-normalized coords to output-image-normalized coords,
615/// clamped to `[0.0, 1.0]`. Also canonicalises the bbox (ensures xmin ≤ xmax).
616///
617/// Thin wrapper over [`edgefirst_decoder::tiling::unletter_norm`] — the single
618/// home for the inverse-letterbox math lives in the lower `decoder` crate so the
619/// tiled-detection lift and this mask path share one implementation.
620#[inline]
621fn unletter_bbox(bbox: DetectBox, lb: [f32; 4]) -> DetectBox {
622    DetectBox {
623        bbox: edgefirst_decoder::tiling::unletter_norm(bbox.bbox, lb),
624        ..bbox
625    }
626}
627
628/// How a source is fit into the requested destination shape.
629#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
630pub enum Fit {
631    /// Stretch the source (or source crop) to fill the whole destination.
632    #[default]
633    Stretch,
634    /// Preserve the *source* aspect ratio, centring it in the destination and
635    /// padding the remainder with `pad` (RGBA — e.g. `[114, 114, 114, 255]` for
636    /// YOLO-style preprocessing).
637    Letterbox { pad: [u8; 4] },
638}
639
640/// Source-side convert geometry: which sub-rectangle of the source to sample
641/// (`source`) and how to fit it into the destination (`fit`). Destination
642/// *placement* is the destination itself — a tensor, or a [`Region`] view /
643/// `batch` tile of one — not a field here.
644#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
645pub struct Crop {
646    /// Sub-rectangle of the source to sample. `None` samples the whole source.
647    pub source: Option<Region>,
648    /// How the source is fit into the destination shape.
649    pub fit: Fit,
650}
651
652impl Crop {
653    /// A no-op crop: whole source, stretched to fill the whole destination.
654    pub fn new() -> Self {
655        Self::default()
656    }
657
658    /// Alias for [`Crop::new`] — whole source, stretch to fill.
659    pub fn no_crop() -> Self {
660        Self::default()
661    }
662
663    /// Letterbox fit: preserve the source aspect ratio, padding the remainder
664    /// with `pad` (RGBA).
665    pub fn letterbox(pad: [u8; 4]) -> Self {
666        Self {
667            source: None,
668            fit: Fit::Letterbox { pad },
669        }
670    }
671
672    /// Sample only `source` of the input (builder).
673    pub fn with_source(mut self, source: Option<Region>) -> Self {
674        self.source = source;
675        self
676    }
677
678    /// Set the fit mode (builder).
679    pub fn with_fit(mut self, fit: Fit) -> Self {
680        self.fit = fit;
681        self
682    }
683
684    /// Resolve to the effective backend geometry for a `src_w`×`src_h` source
685    /// and `dst_w`×`dst_h` destination: the source sampling rect, the
686    /// destination placement rect (`None` = whole destination), and the pad
687    /// colour. **The single place letterbox placement is computed** — every
688    /// backend consumes the resolved rects rather than re-deriving them.
689    pub(crate) fn resolve(
690        &self,
691        src_w: usize,
692        src_h: usize,
693        dst_w: usize,
694        dst_h: usize,
695    ) -> Result<ResolvedCrop, Error> {
696        let src_rect = self.source.map(region_to_rect);
697        // The letterbox aspect uses the *effective* source content — the source
698        // crop when set, else the full source.
699        let (sw, sh) = match self.source {
700            Some(r) => (r.width, r.height),
701            None => (src_w, src_h),
702        };
703        let resolved = match self.fit {
704            Fit::Stretch => ResolvedCrop {
705                src_rect,
706                dst_rect: None,
707                dst_color: None,
708            },
709            Fit::Letterbox { pad } => ResolvedCrop {
710                src_rect,
711                dst_rect: Some(letterbox_rect(sw, sh, dst_w, dst_h)),
712                dst_color: Some(pad),
713            },
714        };
715        resolved.check_crop_dims(src_w, src_h, dst_w, dst_h)?;
716        Ok(resolved)
717    }
718
719    /// Validate against `TensorDyn` source and destination dimensions.
720    pub fn check_crop_dyn(
721        &self,
722        src: &edgefirst_tensor::TensorDyn,
723        dst: &edgefirst_tensor::TensorDyn,
724    ) -> Result<(), Error> {
725        self.resolve(
726            src.width().unwrap_or(0),
727            src.height().unwrap_or(0),
728            dst.width().unwrap_or(0),
729            dst.height().unwrap_or(0),
730        )
731        .map(|_| ())
732    }
733}
734
735/// Resolved crop geometry consumed by the backends. Produced by
736/// [`Crop::resolve`]; the backends read these fields directly (the same shape
737/// the public `Crop` carried before destination placement moved to the view).
738#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
739pub(crate) struct ResolvedCrop {
740    pub(crate) src_rect: Option<Rect>,
741    pub(crate) dst_rect: Option<Rect>,
742    pub(crate) dst_color: Option<[u8; 4]>,
743}
744
745impl ResolvedCrop {
746    /// A no-op resolved crop (whole source → whole destination, no pad).
747    #[allow(dead_code)] // used by unit tests and the batch render paths
748    pub(crate) fn no_crop() -> Self {
749        Self::default()
750    }
751
752    /// Validate the resolved rects against explicit dimensions.
753    pub(crate) fn check_crop_dims(
754        &self,
755        src_w: usize,
756        src_h: usize,
757        dst_w: usize,
758        dst_h: usize,
759    ) -> Result<(), Error> {
760        let src_ok = self
761            .src_rect
762            .is_none_or(|r| r.left + r.width <= src_w && r.top + r.height <= src_h);
763        let dst_ok = self
764            .dst_rect
765            .is_none_or(|r| r.left + r.width <= dst_w && r.top + r.height <= dst_h);
766        match (src_ok, dst_ok) {
767            (true, true) => Ok(()),
768            (true, false) => Err(Error::CropInvalid(format!(
769                "Dest crop invalid: {:?}",
770                self.dst_rect
771            ))),
772            (false, true) => Err(Error::CropInvalid(format!(
773                "Src crop invalid: {:?}",
774                self.src_rect
775            ))),
776            (false, false) => Err(Error::CropInvalid(format!(
777                "Dest and Src crop invalid: {:?} {:?}",
778                self.dst_rect, self.src_rect
779            ))),
780        }
781    }
782}
783
784/// Convert a pixel [`Region`] to the internal [`Rect`] placement type.
785fn region_to_rect(r: Region) -> Rect {
786    Rect {
787        left: r.x,
788        top: r.y,
789        width: r.width,
790        height: r.height,
791    }
792}
793
794/// Centred aspect-preserving placement of an `sw`×`sh` source within a `dw`×`dh`
795/// destination — the canonical letterbox rectangle (one home, replacing the
796/// per-caller `calculate_letterbox` copies).
797fn letterbox_rect(sw: usize, sh: usize, dw: usize, dh: usize) -> Rect {
798    if sw == 0 || sh == 0 {
799        return Rect::new(0, 0, dw, dh);
800    }
801    let src_aspect = sw as f64 / sh as f64;
802    let dst_aspect = dw as f64 / dh as f64;
803    let (new_w, new_h) = if src_aspect > dst_aspect {
804        (dw, ((dw as f64 / src_aspect).round() as usize).max(1))
805    } else {
806        (((dh as f64 * src_aspect).round() as usize).max(1), dh)
807    };
808    let left = dw.saturating_sub(new_w) / 2;
809    let top = dh.saturating_sub(new_h) / 2;
810    Rect::new(left, top, new_w, new_h)
811}
812
813/// Internal placement rectangle (top-left + size). The **public** pixel
814/// sub-region type is [`Region`] (re-exported from `edgefirst-tensor`);
815/// `Rect` is the crate-private resolved-placement representation used by the
816/// CPU/G2D/GL backends after [`Crop::resolve`].
817#[derive(Debug, Clone, Copy, PartialEq, Eq)]
818pub(crate) struct Rect {
819    pub left: usize,
820    pub top: usize,
821    pub width: usize,
822    pub height: usize,
823}
824
825impl Rect {
826    // Creates a new Rect with the specified left, top, width, and height.
827    pub fn new(left: usize, top: usize, width: usize, height: usize) -> Self {
828        Self {
829            left,
830            top,
831            width,
832            height,
833        }
834    }
835}
836
837#[enum_dispatch(ImageProcessor)]
838pub trait ImageProcessorTrait {
839    /// Convert `src` into `dst`'s format and size, applying the crop first,
840    /// then the flip, then the rotation.
841    ///
842    /// The destination is normally an RGB-family colour — `Grey`, `Rgb`,
843    /// `Rgba`/`Bgra`, packed `HWC` or planar `CHW`. That is what the
844    /// accelerated paths target and what models expect. The CPU backend also
845    /// accepts a handful of YUV destinations (`Yuyv`→`Yuyv`, `Yuyv`→`Nv16`,
846    /// and the `Vyuy` equivalents); no GPU or G2D path does, so those pairs
847    /// always land on the CPU.
848    ///
849    /// # Arguments
850    ///
851    /// * `src` - The source image to convert from.
852    /// * `dst` - The destination image. Its shape and [`PixelFormat`] define
853    ///   the output; pass a [`view`](edgefirst_tensor::TensorDyn::view) or
854    ///   [`batch`](edgefirst_tensor::TensorDyn::batch) of a larger tensor to
855    ///   render into one tile of a batch.
856    /// * `rotation` - Rotation applied to the output, in 90° steps.
857    /// * `flip` - Horizontal, vertical, or both.
858    /// * `crop` - Source-side geometry: which sub-rectangle of `src` to sample
859    ///   ([`Crop::source`]) and how to fit it into `dst`'s shape
860    ///   ([`Crop::fit`] — stretch, or letterbox with a pad colour). Destination
861    ///   placement lives on `dst`, not here.
862    ///
863    /// # Errors
864    ///
865    /// - [`Error::CropInvalid`] if `crop.source` falls outside `src`.
866    /// - [`Error::NotAnImage`] if either tensor carries no `PixelFormat`.
867    /// - [`Error::NoConverter`] if no backend accepts the format pair — the
868    ///   dispatch tries OpenGL, then G2D, then CPU, so this means all three
869    ///   declined.
870    ///
871    /// Backend-specific failures (EGL import, G2D blit, allocation) surface as
872    /// their own variants. A backend *declining* a pair is not an error while
873    /// another remains in the chain; it silently costs the zero-copy path, and
874    /// [`ImageProcessor::convert_fallback_count`] counts those.
875    fn convert(
876        &mut self,
877        src: &TensorDyn,
878        dst: &mut TensorDyn,
879        rotation: Rotation,
880        flip: Flip,
881        crop: Crop,
882    ) -> Result<()>;
883
884    /// Draw pre-decoded detection boxes and segmentation masks onto `dst`.
885    ///
886    /// Supports two segmentation modes based on the mask channel count:
887    /// - **Instance segmentation** (`C=1`): one `Segmentation` per detection,
888    ///   `segmentation` and `detect` are zipped.
889    /// - **Semantic segmentation** (`C>1`): a single `Segmentation` covering
890    ///   all classes; only the first element is used.
891    ///
892    /// # Format requirements
893    ///
894    /// - CPU backend: `dst` must be `RGBA` or `RGB`.
895    /// - OpenGL backend: `dst` must be `RGBA`, `BGRA`, or `RGB`.
896    /// - G2D backend: only produces the base frame (empty detections);
897    ///   returns `NotImplemented` when any detection or segmentation is
898    ///   supplied.
899    ///
900    /// # Output contract
901    ///
902    /// This function always fully writes `dst` — it never relies on the
903    /// caller having pre-cleared the destination. The four cases are:
904    ///
905    /// | detections | background | output                              |
906    /// |------------|------------|-------------------------------------|
907    /// | none       | none       | dst cleared to `0x00000000`         |
908    /// | none       | set        | dst ← background                    |
909    /// | set        | none       | masks drawn over cleared dst        |
910    /// | set        | set        | masks drawn over background         |
911    ///
912    /// Each backend implements this with its native primitives: G2D uses
913    /// `g2d_clear` / `g2d_blit`, OpenGL uses `glClear` / DMA-BUF GPU blit
914    /// plus the mask program, and CPU uses direct buffer fill / memcpy as
915    /// the terminal fallback. CPU-memcpy of DMA buffers is avoided on the
916    /// accelerated paths.
917    ///
918    /// An empty `segmentation` slice is valid — only bounding boxes are drawn.
919    ///
920    /// `overlay` controls compositing: `background` is the compositing source
921    /// (must match `dst` in size and format); `opacity` scales mask alpha.
922    ///
923    /// # Buffer aliasing
924    ///
925    /// `dst` and `overlay.background` must reference **distinct underlying
926    /// buffers**. An aliased pair returns [`Error::AliasedBuffers`] without
927    /// dispatching to any backend — the GL path would otherwise read and
928    /// write the same texture in a single draw, which is undefined behaviour
929    /// on most drivers. Aliasing is detected via
930    /// [`TensorDyn::aliases`](edgefirst_tensor::TensorDyn::aliases), which
931    /// catches both shared-allocation clones and separate imports over the
932    /// same dmabuf fd.
933    ///
934    /// # Migration from v0.16.3 and earlier
935    ///
936    /// Prior to v0.16.4 the call silently preserved `dst`'s contents on empty
937    /// detections. That invariant no longer holds — `dst` is always fully
938    /// written. Callers who pre-loaded an image into `dst` before calling this
939    /// function must now pass that image via `overlay.background` instead.
940    fn draw_decoded_masks(
941        &mut self,
942        dst: &mut TensorDyn,
943        detect: &[DetectBox],
944        segmentation: &[Segmentation],
945        overlay: MaskOverlay<'_>,
946    ) -> Result<()>;
947
948    /// Draw masks from proto data onto image (fused decode+draw).
949    ///
950    /// For YOLO segmentation models, this avoids materializing intermediate
951    /// `Array3<u8>` masks. The `ProtoData` contains mask coefficients and the
952    /// prototype tensor; the renderer computes `mask_coeff @ protos` directly
953    /// at the output resolution using bilinear sampling.
954    ///
955    /// `detect` and `proto_data.mask_coefficients` must have the same length
956    /// (enforced by zip — excess entries are silently ignored). An empty
957    /// `detect` slice is valid and produces the base frame — cleared or
958    /// background-blitted — via the selected backend's native primitive.
959    ///
960    /// # Format requirements and output contract
961    ///
962    /// Same as [`draw_decoded_masks`](Self::draw_decoded_masks), including
963    /// the "always fully writes dst" guarantee across all four
964    /// detection/background combinations.
965    ///
966    /// `overlay` controls compositing — see [`draw_decoded_masks`](Self::draw_decoded_masks).
967    fn draw_proto_masks(
968        &mut self,
969        dst: &mut TensorDyn,
970        detect: &[DetectBox],
971        proto_data: &ProtoData,
972        overlay: MaskOverlay<'_>,
973    ) -> Result<()>;
974
975    /// Sets the colors used for rendering segmentation masks. Up to 20 colors
976    /// can be set.
977    fn set_class_colors(&mut self, colors: &[[u8; 4]]) -> Result<()>;
978
979    /// Like [`convert`](Self::convert), but does not wait for the GPU to finish.
980    ///
981    /// This is the batch-preprocessing primitive: a caller renders `N` tiles
982    /// into one batched destination by looping
983    /// `convert_deferred(&src[n], &mut dst.batch(n)?, …)` and then calling
984    /// [`flush`](Self::flush) **once**. On the OpenGL backend every deferred
985    /// convert into sibling views of one buffer shares a single EGLImage import
986    /// (the tile is a `glViewport`/`glScissor` ROI into the parent) and skips the
987    /// per-tile `glFinish`; `flush` then issues a single GPU sync. The result of
988    /// a deferred convert is **not** safe to read on the CPU (or map via CUDA)
989    /// until `flush` returns.
990    ///
991    /// The default implementation is eager — it simply calls
992    /// [`convert`](Self::convert), so CPU/G2D and any backend without a deferred
993    /// fast path remain correct (each call completes synchronously and `flush`
994    /// is a no-op).
995    fn convert_deferred(
996        &mut self,
997        src: &TensorDyn,
998        dst: &mut TensorDyn,
999        rotation: Rotation,
1000        flip: Flip,
1001        crop: Crop,
1002    ) -> Result<()> {
1003        self.convert(src, dst, rotation, flip, crop)
1004    }
1005
1006    /// Complete all work enqueued by [`convert_deferred`](Self::convert_deferred)
1007    /// since the last flush, issuing a single GPU synchronization.
1008    ///
1009    /// After this returns, every deferred destination is finished and safe to
1010    /// read back or `cuda_map`. Backends with no deferred path (the default)
1011    /// return `Ok(())` immediately, since their converts already completed.
1012    fn flush(&mut self) -> Result<()> {
1013        Ok(())
1014    }
1015}
1016
1017/// Configuration for [`ImageProcessor`] construction.
1018///
1019/// Use with [`ImageProcessor::with_config`] to override the default EGL
1020/// display auto-detection and backend selection. The default configuration
1021/// preserves the existing auto-detection behaviour.
1022#[derive(Debug, Clone, Default)]
1023pub struct ImageProcessorConfig {
1024    /// Force OpenGL to use this EGL display type instead of auto-detecting.
1025    ///
1026    /// When `None`, the processor probes displays in priority order: GBM,
1027    /// PlatformDevice, Default. Use [`probe_egl_displays`] to discover
1028    /// which displays are available on the current system.
1029    ///
1030    /// Ignored when `EDGEFIRST_DISABLE_GL=1` is set, and on macOS
1031    /// (ANGLE/Metal is the only display there; a `Some` value logs a
1032    /// debug note and is otherwise ignored).
1033    #[cfg(all(
1034        any(
1035            target_os = "linux",
1036            target_os = "macos",
1037            target_os = "ios",
1038            target_os = "android"
1039        ),
1040        feature = "opengl"
1041    ))]
1042    pub egl_display: Option<EglDisplayKind>,
1043
1044    /// Preferred compute backend.
1045    ///
1046    /// When set to a specific backend (not [`ComputeBackend::Auto`]), the
1047    /// processor initializes that backend with no fallback — returns an error if the conversion is not supported.
1048    /// This takes precedence over `EDGEFIRST_FORCE_BACKEND` and the
1049    /// `EDGEFIRST_DISABLE_*` environment variables.
1050    ///
1051    /// - [`ComputeBackend::OpenGl`]: init OpenGL + CPU, skip G2D
1052    /// - [`ComputeBackend::G2d`]: init G2D + CPU, skip OpenGL
1053    /// - [`ComputeBackend::Cpu`]: init CPU only
1054    /// - [`ComputeBackend::Auto`]: existing env-var-driven selection
1055    pub backend: ComputeBackend,
1056
1057    /// Colorimetry/performance trade-off for `convert()` (see
1058    /// [`ColorimetryMode`]). Defaults to [`ColorimetryMode::Fast`]. The
1059    /// `EDGEFIRST_COLORIMETRY` environment variable (`fast` | `exact`)
1060    /// overrides this setting when present.
1061    pub colorimetry: ColorimetryMode,
1062}
1063
1064/// How `convert()` trades colorimetric exactness against speed on platforms
1065/// where the exact path is expensive.
1066///
1067/// Today this affects one decision: NV12 sources on Vivante GC7000UL
1068/// (i.MX 8M Plus), where the hardware external sampler converts ~12× faster
1069/// than the colorimetry-exact in-shader matrix (2.5 ms vs 29 ms at 720p)
1070/// but applies the driver's fixed BT.601-limited matrix regardless of the
1071/// source's tagged colorimetry. Platforms where the exact path is already
1072/// the fastest correct path (Mali, V3D, Tegra, ANGLE) behave identically in
1073/// both modes.
1074///
1075/// Override at runtime with `EDGEFIRST_COLORIMETRY=fast|exact` (takes
1076/// precedence over the config field), or per-source by forcing a path with
1077/// `EDGEFIRST_NV_CONVERT_PATH`.
1078#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1079pub enum ColorimetryMode {
1080    /// Prefer the fastest path whose output is correct-enough video RGB
1081    /// (default; issue #106 policy). On Vivante, NV12 takes the hardware
1082    /// sampler even when the source is not BT.601-limited.
1083    #[default]
1084    Fast,
1085    /// Prefer bit-exact colorimetry everywhere: the fast path is used only
1086    /// when it matches the source's resolved (encoding, range) exactly.
1087    Exact,
1088}
1089
1090/// Compute backend selection for [`ImageProcessor`].
1091///
1092/// Use with [`ImageProcessorConfig::backend`] to select which backend the
1093/// processor should prefer. When a specific backend is selected, the
1094/// processor initializes that backend plus CPU as a fallback. When `Auto`
1095/// is used, the existing environment-variable-driven selection applies.
1096#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1097pub enum ComputeBackend {
1098    /// Auto-detect based on available hardware and environment variables.
1099    #[default]
1100    Auto,
1101    /// CPU-only processing (no hardware acceleration).
1102    Cpu,
1103    /// Prefer G2D hardware blitter (+ CPU fallback).
1104    G2d,
1105    /// Prefer OpenGL ES (+ CPU fallback).
1106    OpenGl,
1107}
1108
1109/// Backend forced via the `EDGEFIRST_FORCE_BACKEND` environment variable
1110/// or [`ImageProcessorConfig::backend`].
1111///
1112/// When set, the [`ImageProcessor`] only initializes and dispatches to the
1113/// selected backend — no fallback chain is used.
1114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1115pub(crate) enum ForcedBackend {
1116    Cpu,
1117    G2d,
1118    OpenGl,
1119}
1120
1121/// Reports which float-color-buffer extensions the GPU backend detected.
1122/// Returned by [`ImageProcessor::supported_render_dtypes`]; the two flags
1123/// are independent.
1124///
1125/// **Linux:** reflects the real probe results from `GL_EXT_color_buffer_half_float`
1126/// and `GL_EXT_color_buffer_float`. On V3D (RPi 5) and Mali-G310 (i.MX 95)
1127/// both flags are typically `true`; on Vivante GC7000UL both are forced
1128/// `false` (float readback measured 170–320 ms — disabled). Tegra Orin
1129/// exposes both via PBO; the flags match the GPU report.
1130///
1131/// **macOS (ANGLE):** `f16 == true` gates the RGBA16F-packed IOSurface
1132/// path for F16 `PlanarRgb` destinations. `f32` reflects the GL
1133/// extension probe but is not actionable — ANGLE's
1134/// `EGL_ANGLE_iosurface_client_buffer` rejects every `(GL_FLOAT, *)`
1135/// combination with `EGL_BAD_ATTRIBUTE`, so there is no F32 IOSurface
1136/// path.
1137///
1138/// Regardless of these flags, [`ImageProcessor::convert`] never returns
1139/// an error due to float capability — it falls back to CPU when the GPU
1140/// path is unavailable.
1141#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1142pub struct RenderDtypeSupport {
1143    /// `GL_EXT_color_buffer_float` is available on the current GPU.
1144    ///
1145    /// On Linux, `true` enables F32 `Rgb` NHWC PBO readback. On macOS
1146    /// this flag is informational only — no F32 IOSurface path exists.
1147    pub f32: bool,
1148    /// `GL_EXT_color_buffer_half_float` is available on the current GPU.
1149    ///
1150    /// On Linux, `true` enables F16 `PlanarRgb` NCHW PBO readback and,
1151    /// on V3D/Mali, zero-copy DMA-BUF render. On macOS, `true` enables
1152    /// F16 `PlanarRgb` via RGBA16F-packed IOSurface (zero-copy).
1153    pub f16: bool,
1154}
1155
1156/// Returns `true` when a float PBO destination should be attempted for `dtype`.
1157///
1158/// Only F16 and F32 are eligible, and only when the corresponding flag in
1159/// `support` is set. U8/I8 and all other dtypes return `false` — they are
1160/// handled by the existing `dtype.size() == 1` PBO gate.
1161///
1162/// Linux-only: the float PBO readback path is the Linux GL backend's
1163/// mechanism; macOS routes F16 through the RGBA16F-packed IOSurface
1164/// instead and never calls this. The sole runtime caller in
1165/// `create_image` is `cfg(all(target_os = "linux", feature = "opengl"))`,
1166/// so leaving this ungated makes it dead code on macOS under
1167/// `-D warnings`. Its unit test (`float_pbo_eligibility`) carries the
1168/// matching gate.
1169#[cfg(all(target_os = "linux", feature = "opengl"))]
1170pub(crate) fn float_pbo_eligible(dtype: DType, support: RenderDtypeSupport) -> bool {
1171    match dtype {
1172        DType::F16 => support.f16,
1173        DType::F32 => support.f32,
1174        _ => false,
1175    }
1176}
1177
1178/// Image converter that uses available hardware acceleration or CPU as a
1179/// fallback.
1180#[derive(Debug)]
1181pub struct ImageProcessor {
1182    /// CPU-based image converter as a fallback. This is only None if the
1183    /// EDGEFIRST_DISABLE_CPU environment variable is set.
1184    pub cpu: Option<CPUProcessor>,
1185
1186    #[cfg(target_os = "linux")]
1187    /// G2D-based image converter for Linux systems. This is only available if
1188    /// the EDGEFIRST_DISABLE_G2D environment variable is not set and libg2d.so
1189    /// is available.
1190    pub g2d: Option<G2DProcessor>,
1191    #[cfg(target_os = "linux")]
1192    #[cfg(feature = "opengl")]
1193    /// OpenGL-based image converter for Linux systems. This is only available
1194    /// if the EDGEFIRST_DISABLE_GL environment variable is not set and OpenGL
1195    /// ES is available.
1196    pub opengl: Option<GLProcessorThreaded>,
1197    #[cfg(any(target_os = "macos", target_os = "ios", target_os = "android"))]
1198    #[cfg(feature = "opengl")]
1199    /// OpenGL-based image converter — the same unified
1200    /// `GLProcessorThreaded` engine as Linux (its worker owns a
1201    /// per-processor context). macOS/iOS run it via ANGLE + IOSurface
1202    /// (available when ANGLE's libEGL.dylib can be loaded — see
1203    /// README.md § macOS GPU Acceleration); Android runs it via the
1204    /// native EGL driver + AHardwareBuffer.
1205    pub opengl: Option<GLProcessorThreaded>,
1206
1207    /// When set, only the specified backend is used — no fallback chain.
1208    pub(crate) forced_backend: Option<ForcedBackend>,
1209
1210    /// Converts where the GL backend declined and the auto chain fell
1211    /// through toward G2D/CPU. Owned by the dispatcher (the GL worker never
1212    /// sees these frames), unlike the per-feed counters in
1213    /// `GLProcessorThreaded::convert_stats`. Read via
1214    /// [`convert_fallback_count`](Self::convert_fallback_count).
1215    pub(crate) convert_fallbacks: std::sync::atomic::AtomicU64,
1216}
1217
1218unsafe impl Send for ImageProcessor {}
1219unsafe impl Sync for ImageProcessor {}
1220
1221impl ImageProcessor {
1222    /// Creates a new `ImageProcessor` instance, initializing available
1223    /// hardware converters based on the system capabilities and environment
1224    /// variables.
1225    ///
1226    /// # Examples
1227    /// ```rust,no_run
1228    /// # use edgefirst_image::{ImageProcessor, Rotation, Flip, Crop, ImageProcessorTrait};
1229    /// # use edgefirst_codec::{peek_info, ImageDecoder, ImageLoad};
1230    /// # use edgefirst_tensor::{CpuAccess, PixelFormat, DType, Tensor, TensorMemory};
1231    /// # fn main() -> Result<(), edgefirst_image::Error> {
1232    /// let image = std::fs::read("zidane.jpg")?;
1233    /// // The codec emits the source's native format (a colour JPEG decodes to
1234    /// // NV12) and configures the destination tensor during the decode.
1235    /// let info = peek_info(&image).expect("peek");
1236    /// let mut src = Tensor::<u8>::image(info.width, info.height, info.format,
1237    ///                                    Some(TensorMemory::Mem), CpuAccess::ReadWrite)?;
1238    /// let mut decoder = ImageDecoder::new();
1239    /// src.load_image(&mut decoder, &image).expect("decode");
1240    /// let mut converter = ImageProcessor::new()?;
1241    /// let mut dst =
1242    ///     converter.create_image(640, 480, PixelFormat::Rgb, DType::U8, None, CpuAccess::ReadWrite)?;
1243    /// converter.convert(&src.into(), &mut dst, Rotation::None, Flip::None, Crop::default())?;
1244    /// # Ok(())
1245    /// # }
1246    /// ```
1247    pub fn new() -> Result<Self> {
1248        Self::with_config(ImageProcessorConfig::default())
1249    }
1250
1251    /// Number of converts where the auto chain's GL backend declined and the
1252    /// frame fell through toward G2D/CPU — each one silently lost any
1253    /// zero-copy guarantee. A pipeline that expects to stay on the GPU
1254    /// asserts this stays flat across its steady-state loop; pair with
1255    /// `GLProcessorThreaded::convert_stats` to see how the frames that DID
1256    /// reach GL were fed. Always 0 under a forced backend (no chain).
1257    pub fn convert_fallback_count(&self) -> u64 {
1258        self.convert_fallbacks
1259            .load(std::sync::atomic::Ordering::Relaxed)
1260    }
1261
1262    /// Number of [`Compression::Any`](edgefirst_tensor::Compression)
1263    /// requests since process start that resolved to a linear layout
1264    /// (process-wide mirror of
1265    /// [`edgefirst_tensor::compression_fallback_count`], surfaced here
1266    /// beside [`convert_fallback_count`](Self::convert_fallback_count)
1267    /// so pipeline telemetry reads from one place). A pipeline that
1268    /// expects compressed convert destinations asserts this stays flat
1269    /// after warmup.
1270    pub fn compression_fallback_count(&self) -> u64 {
1271        edgefirst_tensor::compression_fallback_count()
1272    }
1273
1274    /// Convert and return a native sync-fence fd that signals when the
1275    /// GPU work completes, instead of blocking the CPU — the GL→NPU
1276    /// handoff (`EGL_ANDROID_native_fence_sync` on Android).
1277    ///
1278    /// `Ok(Some(fd))`: the destination buffer is still in flight; hand
1279    /// the fd to the consumer (e.g.
1280    /// `ANeuralNetworksExecution_startComputeWithDependencies`) or
1281    /// `poll()` it before reading. `Ok(None)`: the convert completed with
1282    /// the normal blocking contract (no native fence on this platform, or
1283    /// a non-GL backend handled the frame) — the destination is already
1284    /// safe. Semantics are otherwise identical to
1285    /// [`convert`](ImageProcessorTrait::convert), including the
1286    /// GL→G2D→CPU fallback chain.
1287    #[cfg(unix)]
1288    pub fn convert_with_fence(
1289        &mut self,
1290        src: &TensorDyn,
1291        dst: &mut TensorDyn,
1292        rotation: Rotation,
1293        flip: Flip,
1294        crop: Crop,
1295    ) -> Result<Option<std::os::fd::OwnedFd>> {
1296        #[cfg(any(
1297            target_os = "linux",
1298            target_os = "macos",
1299            target_os = "ios",
1300            target_os = "android"
1301        ))]
1302        #[cfg(feature = "opengl")]
1303        {
1304            let gl_forced = matches!(self.forced_backend, Some(ForcedBackend::OpenGl));
1305            if self.forced_backend.is_none() || gl_forced {
1306                if let Some(opengl) = self.opengl.as_mut() {
1307                    match opengl.convert_with_fence(src, dst, rotation, flip, crop) {
1308                        Ok(fd) => return Ok(fd),
1309                        Err(e) if gl_forced => return Err(e),
1310                        Err(e) => {
1311                            // Not counted here: the blocking chain below
1312                            // re-runs the dispatcher, whose decline arm
1313                            // counts the fallback exactly once.
1314                            log::debug!(
1315                                "convert_with_fence: opengl declined, \
1316                                 falling back to the blocking chain: {e}"
1317                            );
1318                        }
1319                    }
1320                } else if gl_forced {
1321                    return Err(Error::ForcedBackendUnavailable("opengl".into()));
1322                }
1323            }
1324        }
1325        // Blocking chain (G2D/CPU, forced non-GL, or non-GL builds):
1326        // completion == return, so no fence is needed.
1327        self.convert(src, dst, rotation, flip, crop)?;
1328        Ok(None)
1329    }
1330
1331    /// Report which float dtypes the GPU can render to.
1332    ///
1333    /// Probes `GL_EXT_color_buffer_half_float` and
1334    /// `GL_EXT_color_buffer_float` once at `ImageProcessor::new()` time
1335    /// and caches the result. Call this once at startup to decide whether
1336    /// to request F16 or F32 destination tensors; [`create_image`] uses
1337    /// the result internally to auto-select float PBO when supported.
1338    ///
1339    /// Returns `RenderDtypeSupport { f32: false, f16: false }` when no
1340    /// OpenGL backend is active or the `opengl` feature is disabled.
1341    ///
1342    /// [`create_image`]: Self::create_image
1343    pub fn supported_render_dtypes(&self) -> RenderDtypeSupport {
1344        #[cfg(all(
1345            any(target_os = "macos", target_os = "ios", target_os = "android"),
1346            feature = "opengl"
1347        ))]
1348        if let Some(gl) = self.opengl.as_ref() {
1349            return gl.supported_render_dtypes();
1350        }
1351        #[cfg(all(target_os = "linux", feature = "opengl"))]
1352        if let Some(gl) = self.opengl.as_ref() {
1353            return gl.supported_render_dtypes();
1354        }
1355        RenderDtypeSupport {
1356            f32: false,
1357            f16: false,
1358        }
1359    }
1360
1361    /// Creates a new `ImageProcessor` with the given configuration.
1362    ///
1363    /// When [`ImageProcessorConfig::backend`] is set to a specific backend,
1364    /// environment variables are ignored and the processor initializes the
1365    /// requested backend plus CPU as a fallback.
1366    ///
1367    /// When `Auto`, the existing `EDGEFIRST_FORCE_BACKEND` and
1368    /// `EDGEFIRST_DISABLE_*` environment variables apply.
1369    #[allow(unused_variables)]
1370    pub fn with_config(config: ImageProcessorConfig) -> Result<Self> {
1371        // ── Config-driven backend selection ──────────────────────────
1372        // When the caller explicitly requests a backend via the config,
1373        // skip all environment variable logic.
1374        match config.backend {
1375            ComputeBackend::Cpu => {
1376                log::info!("ComputeBackend::Cpu — CPU only");
1377                return Ok(Self {
1378                    cpu: Some(CPUProcessor::new()),
1379                    #[cfg(target_os = "linux")]
1380                    g2d: None,
1381                    #[cfg(target_os = "linux")]
1382                    #[cfg(feature = "opengl")]
1383                    opengl: None,
1384                    #[cfg(any(target_os = "macos", target_os = "ios", target_os = "android"))]
1385                    #[cfg(feature = "opengl")]
1386                    opengl: None,
1387                    convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1388                    forced_backend: None,
1389                });
1390            }
1391            ComputeBackend::G2d => {
1392                log::info!("ComputeBackend::G2d — G2D + CPU fallback");
1393                #[cfg(target_os = "linux")]
1394                {
1395                    let g2d = match G2DProcessor::new() {
1396                        Ok(g) => Some(g),
1397                        Err(e) => {
1398                            log::warn!("G2D requested but failed to initialize: {e:?}");
1399                            None
1400                        }
1401                    };
1402                    return Ok(Self {
1403                        cpu: Some(CPUProcessor::new()),
1404                        g2d,
1405                        #[cfg(feature = "opengl")]
1406                        opengl: None,
1407                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1408                        forced_backend: None,
1409                    });
1410                }
1411                #[cfg(not(target_os = "linux"))]
1412                {
1413                    log::warn!("G2D requested but not available on this platform, using CPU");
1414                    return Ok(Self {
1415                        cpu: Some(CPUProcessor::new()),
1416                        #[cfg(any(target_os = "macos", target_os = "ios", target_os = "android"))]
1417                        #[cfg(feature = "opengl")]
1418                        opengl: None,
1419                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1420                        forced_backend: None,
1421                    });
1422                }
1423            }
1424            ComputeBackend::OpenGl => {
1425                log::info!("ComputeBackend::OpenGl — OpenGL + CPU fallback");
1426                #[cfg(target_os = "linux")]
1427                {
1428                    #[cfg(feature = "opengl")]
1429                    let opengl = match GLProcessorThreaded::new(config.egl_display) {
1430                        Ok(gl) => Some(gl),
1431                        Err(e) => {
1432                            log::warn!("OpenGL requested but failed to initialize: {e:?}");
1433                            None
1434                        }
1435                    };
1436                    return Ok(Self {
1437                        cpu: Some(CPUProcessor::new()),
1438                        g2d: None,
1439                        #[cfg(feature = "opengl")]
1440                        opengl,
1441                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1442                        forced_backend: None,
1443                    }
1444                    .apply_colorimetry_mode(config.colorimetry));
1445                }
1446                #[cfg(any(target_os = "macos", target_os = "ios"))]
1447                {
1448                    #[cfg(feature = "opengl")]
1449                    let opengl = match GLProcessorThreaded::new(config.egl_display) {
1450                        Ok(gl) => Some(gl),
1451                        Err(e) => {
1452                            log::warn!(
1453                                "OpenGL requested on macOS but ANGLE init failed: {e:?} \
1454                                 (install ANGLE via `brew install startergo/angle/angle` \
1455                                 and re-sign the dylibs — see README.md § macOS GPU \
1456                                 Acceleration). Falling back to CPU."
1457                            );
1458                            None
1459                        }
1460                    };
1461                    return Ok(Self {
1462                        cpu: Some(CPUProcessor::new()),
1463                        #[cfg(feature = "opengl")]
1464                        opengl,
1465                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1466                        forced_backend: None,
1467                    }
1468                    .apply_colorimetry_mode(config.colorimetry));
1469                }
1470                #[cfg(target_os = "android")]
1471                {
1472                    #[cfg(feature = "opengl")]
1473                    let opengl = match GLProcessorThreaded::new(config.egl_display) {
1474                        Ok(gl) => Some(gl),
1475                        Err(e) => {
1476                            log::warn!(
1477                                "OpenGL requested but native EGL init failed: {e:?}. \
1478                                 Falling back to CPU."
1479                            );
1480                            None
1481                        }
1482                    };
1483                    return Ok(Self {
1484                        cpu: Some(CPUProcessor::new()),
1485                        #[cfg(feature = "opengl")]
1486                        opengl,
1487                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1488                        forced_backend: None,
1489                    }
1490                    .apply_colorimetry_mode(config.colorimetry));
1491                }
1492                #[cfg(not(any(
1493                    target_os = "linux",
1494                    target_os = "macos",
1495                    target_os = "ios",
1496                    target_os = "android"
1497                )))]
1498                {
1499                    log::warn!("OpenGL requested but not available on this platform, using CPU");
1500                    return Ok(Self {
1501                        cpu: Some(CPUProcessor::new()),
1502                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1503                        forced_backend: None,
1504                    });
1505                }
1506            }
1507            ComputeBackend::Auto => { /* fall through to env-var logic below */ }
1508        }
1509
1510        // ── EDGEFIRST_FORCE_BACKEND ──────────────────────────────────
1511        // When set, only the requested backend is initialised and no
1512        // fallback chain is used. Accepted values (case-insensitive):
1513        //   "cpu", "g2d", "opengl"
1514        if let Ok(val) = std::env::var("EDGEFIRST_FORCE_BACKEND") {
1515            let val_lower = val.to_lowercase();
1516            let forced = match val_lower.as_str() {
1517                "cpu" => ForcedBackend::Cpu,
1518                "g2d" => ForcedBackend::G2d,
1519                "opengl" => ForcedBackend::OpenGl,
1520                other => {
1521                    return Err(Error::ForcedBackendUnavailable(format!(
1522                        "unknown EDGEFIRST_FORCE_BACKEND value: {other:?} (expected cpu, g2d, or opengl)"
1523                    )));
1524                }
1525            };
1526
1527            log::info!("EDGEFIRST_FORCE_BACKEND={val} — only initializing {val_lower} backend");
1528
1529            return match forced {
1530                ForcedBackend::Cpu => Ok(Self {
1531                    cpu: Some(CPUProcessor::new()),
1532                    #[cfg(target_os = "linux")]
1533                    g2d: None,
1534                    #[cfg(target_os = "linux")]
1535                    #[cfg(feature = "opengl")]
1536                    opengl: None,
1537                    #[cfg(any(target_os = "macos", target_os = "ios", target_os = "android"))]
1538                    #[cfg(feature = "opengl")]
1539                    opengl: None,
1540                    convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1541                    forced_backend: Some(ForcedBackend::Cpu),
1542                }),
1543                ForcedBackend::G2d => {
1544                    #[cfg(target_os = "linux")]
1545                    {
1546                        let g2d = G2DProcessor::new().map_err(|e| {
1547                            Error::ForcedBackendUnavailable(format!(
1548                                "g2d forced but failed to initialize: {e:?}"
1549                            ))
1550                        })?;
1551                        Ok(Self {
1552                            cpu: None,
1553                            g2d: Some(g2d),
1554                            #[cfg(feature = "opengl")]
1555                            opengl: None,
1556                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1557                            forced_backend: Some(ForcedBackend::G2d),
1558                        })
1559                    }
1560                    #[cfg(not(target_os = "linux"))]
1561                    {
1562                        Err(Error::ForcedBackendUnavailable(
1563                            "g2d backend is only available on Linux".into(),
1564                        ))
1565                    }
1566                }
1567                ForcedBackend::OpenGl => {
1568                    #[cfg(target_os = "linux")]
1569                    #[cfg(feature = "opengl")]
1570                    {
1571                        let opengl = GLProcessorThreaded::new(config.egl_display).map_err(|e| {
1572                            Error::ForcedBackendUnavailable(format!(
1573                                "opengl forced but failed to initialize: {e:?}"
1574                            ))
1575                        })?;
1576                        Ok(Self {
1577                            cpu: None,
1578                            g2d: None,
1579                            opengl: Some(opengl),
1580                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1581                            forced_backend: Some(ForcedBackend::OpenGl),
1582                        }
1583                        .apply_colorimetry_mode(config.colorimetry))
1584                    }
1585                    #[cfg(any(target_os = "macos", target_os = "ios"))]
1586                    #[cfg(feature = "opengl")]
1587                    {
1588                        let opengl = GLProcessorThreaded::new(config.egl_display).map_err(|e| {
1589                            Error::ForcedBackendUnavailable(format!(
1590                                "opengl forced on macOS but ANGLE init failed: {e:?}"
1591                            ))
1592                        })?;
1593                        Ok(Self {
1594                            cpu: None,
1595                            opengl: Some(opengl),
1596                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1597                            forced_backend: Some(ForcedBackend::OpenGl),
1598                        }
1599                        .apply_colorimetry_mode(config.colorimetry))
1600                    }
1601                    #[cfg(target_os = "android")]
1602                    #[cfg(feature = "opengl")]
1603                    {
1604                        let opengl = GLProcessorThreaded::new(config.egl_display).map_err(|e| {
1605                            Error::ForcedBackendUnavailable(format!(
1606                                "opengl forced but native EGL init failed: {e:?}"
1607                            ))
1608                        })?;
1609                        Ok(Self {
1610                            cpu: None,
1611                            opengl: Some(opengl),
1612                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1613                            forced_backend: Some(ForcedBackend::OpenGl),
1614                        }
1615                        .apply_colorimetry_mode(config.colorimetry))
1616                    }
1617                    #[cfg(not(all(
1618                        any(
1619                            target_os = "linux",
1620                            target_os = "macos",
1621                            target_os = "ios",
1622                            target_os = "android"
1623                        ),
1624                        feature = "opengl"
1625                    )))]
1626                    {
1627                        Err(Error::ForcedBackendUnavailable(
1628                            "opengl backend requires Linux or macOS with the 'opengl' feature \
1629                             enabled"
1630                                .into(),
1631                        ))
1632                    }
1633                }
1634            };
1635        }
1636
1637        // ── Existing DISABLE logic (unchanged) ──────────────────────
1638        #[cfg(target_os = "linux")]
1639        let g2d = if std::env::var("EDGEFIRST_DISABLE_G2D")
1640            .map(|x| x != "0" && x.to_lowercase() != "false")
1641            .unwrap_or(false)
1642        {
1643            log::debug!("EDGEFIRST_DISABLE_G2D is set");
1644            None
1645        } else {
1646            match G2DProcessor::new() {
1647                Ok(g2d_converter) => Some(g2d_converter),
1648                Err(err) => {
1649                    log::warn!("Failed to initialize G2D converter: {err:?}");
1650                    None
1651                }
1652            }
1653        };
1654
1655        #[cfg(target_os = "linux")]
1656        #[cfg(feature = "opengl")]
1657        let opengl = if std::env::var("EDGEFIRST_DISABLE_GL")
1658            .map(|x| x != "0" && x.to_lowercase() != "false")
1659            .unwrap_or(false)
1660        {
1661            log::debug!("EDGEFIRST_DISABLE_GL is set");
1662            None
1663        } else {
1664            match GLProcessorThreaded::new(config.egl_display) {
1665                Ok(gl_converter) => Some(gl_converter),
1666                Err(err) => {
1667                    log::warn!("Failed to initialize GL converter: {err:?}");
1668                    None
1669                }
1670            }
1671        };
1672
1673        #[cfg(any(target_os = "macos", target_os = "ios", target_os = "android"))]
1674        #[cfg(feature = "opengl")]
1675        let opengl = if std::env::var("EDGEFIRST_DISABLE_GL")
1676            .map(|x| x != "0" && x.to_lowercase() != "false")
1677            .unwrap_or(false)
1678        {
1679            log::debug!("EDGEFIRST_DISABLE_GL is set");
1680            None
1681        } else {
1682            match GLProcessorThreaded::new(config.egl_display) {
1683                Ok(gl_converter) => Some(gl_converter),
1684                Err(err) => {
1685                    log::debug!(
1686                        "GL backend unavailable: {err:?} \
1687                         (CPU fallback will be used)"
1688                    );
1689                    None
1690                }
1691            }
1692        };
1693
1694        let cpu = if std::env::var("EDGEFIRST_DISABLE_CPU")
1695            .map(|x| x != "0" && x.to_lowercase() != "false")
1696            .unwrap_or(false)
1697        {
1698            log::debug!("EDGEFIRST_DISABLE_CPU is set");
1699            None
1700        } else {
1701            Some(CPUProcessor::new())
1702        };
1703        Ok(Self {
1704            cpu,
1705            #[cfg(target_os = "linux")]
1706            g2d,
1707            #[cfg(target_os = "linux")]
1708            #[cfg(feature = "opengl")]
1709            opengl,
1710            #[cfg(any(target_os = "macos", target_os = "ios", target_os = "android"))]
1711            #[cfg(feature = "opengl")]
1712            opengl,
1713            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1714            forced_backend: None,
1715        }
1716        .apply_colorimetry_mode(config.colorimetry))
1717    }
1718
1719    /// Apply the configured [`ColorimetryMode`] to whichever backend honours
1720    /// it (currently the Linux GL backend); no-op elsewhere. Constructor
1721    /// plumbing for [`ImageProcessorConfig::colorimetry`].
1722    fn apply_colorimetry_mode(self, _mode: ColorimetryMode) -> Self {
1723        #[cfg(all(
1724            any(
1725                target_os = "linux",
1726                target_os = "macos",
1727                target_os = "ios",
1728                target_os = "android"
1729            ),
1730            feature = "opengl"
1731        ))]
1732        {
1733            let mut me = self;
1734            if let Err(e) = me.set_colorimetry_mode(_mode) {
1735                log::warn!("Failed to apply ColorimetryMode::{_mode:?}: {e:?}");
1736            }
1737            me
1738        }
1739        #[cfg(not(all(
1740            any(
1741                target_os = "linux",
1742                target_os = "macos",
1743                target_os = "ios",
1744                target_os = "android"
1745            ),
1746            feature = "opengl"
1747        )))]
1748        {
1749            let _ = _mode;
1750            self
1751        }
1752    }
1753
1754    /// Sets the colorimetry/performance trade-off (see [`ColorimetryMode`])
1755    /// on the OpenGL backend. No-op if OpenGL is not available. The
1756    /// `EDGEFIRST_COLORIMETRY` environment variable takes precedence — when
1757    /// it is set, this call logs and keeps the env-selected mode.
1758    #[cfg(all(
1759        any(
1760            target_os = "linux",
1761            target_os = "macos",
1762            target_os = "ios",
1763            target_os = "android"
1764        ),
1765        feature = "opengl"
1766    ))]
1767    pub fn set_colorimetry_mode(&mut self, mode: ColorimetryMode) -> Result<()> {
1768        if let Some(ref mut gl) = self.opengl {
1769            gl.set_colorimetry_mode(mode)?;
1770        }
1771        Ok(())
1772    }
1773
1774    /// Sets the interpolation mode for int8 proto textures on the OpenGL
1775    /// backend. No-op if OpenGL is not available.
1776    #[cfg(all(
1777        any(
1778            target_os = "linux",
1779            target_os = "macos",
1780            target_os = "ios",
1781            target_os = "android"
1782        ),
1783        feature = "opengl"
1784    ))]
1785    pub fn set_int8_interpolation_mode(&mut self, mode: Int8InterpolationMode) -> Result<()> {
1786        if let Some(ref mut gl) = self.opengl {
1787            gl.set_int8_interpolation_mode(mode)?;
1788        }
1789        Ok(())
1790    }
1791
1792    /// Allocate an image tensor from a declarative
1793    /// [`ImageDesc`](edgefirst_tensor::ImageDesc) request — the
1794    /// full-featured variant of [`create_image`](Self::create_image).
1795    ///
1796    /// Without a compression request this is exactly `create_image` (the
1797    /// processor's memory negotiation applies). With one, the allocation
1798    /// rides the tensor desc path un-negotiated: the layout decision
1799    /// belongs to the platform allocator, and the request's guards and
1800    /// fallback counting live there (see
1801    /// [`Tensor::image_desc`](edgefirst_tensor::Tensor::image_desc)).
1802    pub fn create_image_desc(&self, desc: &edgefirst_tensor::ImageDesc) -> Result<TensorDyn> {
1803        if desc.compression().is_none() {
1804            return self.create_image(
1805                desc.width(),
1806                desc.height(),
1807                desc.format(),
1808                desc.dtype(),
1809                desc.memory(),
1810                desc.access(),
1811            );
1812        }
1813        Ok(TensorDyn::image_desc(desc)?)
1814    }
1815
1816    /// Create a [`TensorDyn`] image with the best available memory backend.
1817    ///
1818    /// Priority: DMA-buf → float PBO (F16/F32) → u8/i8 PBO → system memory.
1819    ///
1820    /// Use this method instead of [`TensorDyn::image()`] when the tensor will
1821    /// be used with [`ImageProcessor::convert()`]. It selects the optimal
1822    /// memory backing (including PBO for GPU zero-copy) which direct
1823    /// allocation cannot achieve.
1824    ///
1825    /// This method is on [`ImageProcessor`] rather than [`ImageProcessorTrait`]
1826    /// because optimal allocation requires knowledge of the active compute
1827    /// backends (e.g. the GL context handle for PBO allocation). Individual
1828    /// backend implementations ([`CPUProcessor`], etc.) do not have this
1829    /// cross-backend visibility.
1830    ///
1831    /// **Float dtype behaviour:** when `dtype` is `F16` or `F32` and
1832    /// [`supported_render_dtypes`] reports the GPU supports that type,
1833    /// `memory: None` auto-selects a float PBO (Linux) or IOSurface (macOS
1834    /// F16 only). If GPU float support is absent the allocation falls through
1835    /// to `TensorMemory::Mem`; [`convert`] then uses the CPU path.
1836    /// Passing `memory: Some(TensorMemory::Dma)` with `dtype: F32` always
1837    /// returns `Error::NotSupported` — no 32-bit-float DRM fourcc exists.
1838    ///
1839    /// [`supported_render_dtypes`]: Self::supported_render_dtypes
1840    /// [`convert`]: ImageProcessorTrait::convert
1841    ///
1842    /// # Arguments
1843    ///
1844    /// * `width` - Image width in pixels
1845    /// * `height` - Image height in pixels
1846    /// * `format` - Pixel format
1847    /// * `dtype` - Element data type (e.g. `DType::U8`, `DType::F16`, `DType::F32`)
1848    /// * `memory` - Optional memory type override; when `None`, the best
1849    ///   available backend is selected automatically.
1850    /// * `access` - Declares the CPU involvement you plan on. Use
1851    ///   [`CpuAccess::None`](edgefirst_tensor::CpuAccess) for a destination
1852    ///   that only hardware reads (an NPU input), and `ReadWrite` when you
1853    ///   will map it. Hardware access is always implied. Mapping a tensor
1854    ///   beyond its declaration is counted by
1855    ///   [`unplanned_cpu_access_count`](edgefirst_tensor::unplanned_cpu_access_count).
1856    ///
1857    /// # Returns
1858    ///
1859    /// A [`TensorDyn`] backed by the highest-performance memory type
1860    /// available on this system.
1861    ///
1862    /// # Examples
1863    ///
1864    /// ```rust,no_run
1865    /// # use edgefirst_image::ImageProcessor;
1866    /// # use edgefirst_tensor::{CpuAccess, DType, PixelFormat};
1867    /// # fn main() -> Result<(), edgefirst_image::Error> {
1868    /// let processor = ImageProcessor::new()?;
1869    ///
1870    /// // Model input the NPU reads directly — no CPU mapping planned.
1871    /// let input = processor.create_image(
1872    ///     640, 640, PixelFormat::Rgb, DType::U8, None, CpuAccess::None,
1873    /// )?;
1874    /// # let _ = input;
1875    /// # Ok(())
1876    /// # }
1877    /// ```
1878    ///
1879    /// # Pitch alignment for DMA-backed allocations
1880    ///
1881    /// DMA-BUF imports into the GL backend (Mali Valhall on i.MX 95
1882    /// specifically) require every row pitch to be a multiple of
1883    /// [`GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES`] (currently 64). When this
1884    /// method lands on `TensorMemory::Dma`, the underlying allocation is
1885    /// silently padded so the row stride satisfies that requirement.
1886    ///
1887    /// **The user-requested `width` is preserved** — `tensor.width()`
1888    /// returns the same value you passed in. The padding is carried by
1889    /// [`TensorDyn::row_stride`] / `effective_row_stride()`, which the
1890    /// GL backend reads when importing the buffer as an EGLImage.
1891    /// Callers that compute byte offsets from the tensor must use the
1892    /// stride, not `width × bytes_per_pixel`; the CPU mapping spans the
1893    /// full `stride × height` bytes.
1894    ///
1895    /// Pre-aligned widths (640, 1280, 1920, 3008, 3840 …) allocate
1896    /// exactly `width × bpp × height` bytes with no padding. PBO and
1897    /// Mem fallbacks never pad — they don't go through EGLImage import.
1898    ///
1899    /// See also [`align_width_for_gpu_pitch`] for an advisory helper
1900    /// that external callers (GStreamer plugins, video pipelines) can
1901    /// use to size their own DMA-BUFs for GL compatibility.
1902    ///
1903    /// # Errors
1904    ///
1905    /// Returns an error if all allocation strategies fail.
1906    pub fn create_image(
1907        &self,
1908        width: usize,
1909        height: usize,
1910        format: PixelFormat,
1911        dtype: DType,
1912        memory: Option<TensorMemory>,
1913        access: edgefirst_tensor::CpuAccess,
1914    ) -> Result<TensorDyn> {
1915        // Compute the GPU-aligned row stride in bytes for this image.
1916        // `None` means either the format has no defined primary-plane bpp
1917        // (unknown future layout) or the stride calculation would overflow
1918        // — in both cases we fall back to the natural layout via the plain
1919        // `TensorDyn::image` constructor, and the slow-path warning inside
1920        // `draw_*_masks` will fire if the subsequent GL import fails.
1921        //
1922        // DMA allocation is Linux-only (see `TensorMemory::Dma` cfg gate),
1923        // so both the stride computation and the helper closure are gated
1924        // accordingly — the callers below are already Linux-only.
1925        #[cfg(target_os = "linux")]
1926        let dma_stride_bytes: Option<usize> = primary_plane_bpp(format, dtype.size())
1927            .and_then(|bpp| width.checked_mul(bpp))
1928            .and_then(align_pitch_bytes_to_gpu_alignment);
1929
1930        // Helper: allocate a DMA image, using the padded-stride constructor
1931        // when the computed stride exceeds the natural pitch, otherwise the
1932        // plain constructor (byte-identical result in the common case).
1933        #[cfg(target_os = "linux")]
1934        let try_dma = || -> Result<TensorDyn> {
1935            // Stride padding is only meaningful for packed pixel layouts
1936            // (RGBA8, BGRA8, RGB888, Grey) — the formats the GL backend
1937            // renders into. Semi-planar (NV12, NV16) and planar (PlanarRgb,
1938            // PlanarRgba) tensors go through `TensorDyn::image(...)` with
1939            // their natural layout; they're imported from camera capture
1940            // via `from_fd` far more often than allocated here, and
1941            // `Tensor::image_with_stride` explicitly rejects them.
1942            let packed = format.layout() == edgefirst_tensor::PixelLayout::Packed;
1943            match dma_stride_bytes {
1944                Some(stride)
1945                    if packed
1946                        && primary_plane_bpp(format, dtype.size())
1947                            .and_then(|bpp| width.checked_mul(bpp))
1948                            .is_some_and(|natural| stride > natural) =>
1949                {
1950                    log::debug!(
1951                        "create_image: padding row stride for {format:?} {width}x{height} \
1952                         from natural pitch to {stride} bytes for GPU alignment"
1953                    );
1954                    Ok(TensorDyn::image_with_stride(
1955                        width,
1956                        height,
1957                        format,
1958                        dtype,
1959                        stride,
1960                        Some(edgefirst_tensor::TensorMemory::Dma),
1961                        access,
1962                    )?)
1963                }
1964                _ => Ok(TensorDyn::image(
1965                    width,
1966                    height,
1967                    format,
1968                    dtype,
1969                    Some(edgefirst_tensor::TensorMemory::Dma),
1970                    access,
1971                )?),
1972            }
1973        };
1974
1975        // If an explicit memory type is requested, honour it directly.
1976        // On Linux, `TensorMemory::Dma` gets the padded-stride treatment;
1977        // other memory types take the user-requested width verbatim.
1978        // On macOS, `TensorMemory::Dma` dispatches through `TensorDyn::image`
1979        // which selects the IOSurface allocation path (FourCC-formatted)
1980        // for image-mappable formats, or falls back to SHM/Mem otherwise.
1981        match memory {
1982            #[cfg(target_os = "linux")]
1983            Some(TensorMemory::Dma) => {
1984                // F32 has no 32-bit-float DRM fourcc; callers must use PBO instead.
1985                if dtype == DType::F32 {
1986                    return Err(Error::NotSupported(
1987                        "F32 has no 32-bit-float DRM format for DMA-BUF; \
1988                         use TensorMemory::Pbo for F32"
1989                            .to_string(),
1990                    ));
1991                }
1992                return try_dma();
1993            }
1994            Some(mem) => {
1995                return Ok(TensorDyn::image(
1996                    width,
1997                    height,
1998                    format,
1999                    dtype,
2000                    Some(mem),
2001                    access,
2002                )?);
2003            }
2004            None => {}
2005        }
2006
2007        // macOS: when the GL backend is active with the IOSurface
2008        // transfer path, prefer Dma (IOSurface on Apple, AHardwareBuffer
2009        // on Android) for zero-copy import. Formats without a zero-copy
2010        // mapping now ERROR under explicit Dma (the explicit-Dma
2011        // contract), so auto-select catches that error here and falls
2012        // back to host storage — loudly, via the debug log below.
2013        #[cfg(any(target_os = "macos", target_os = "ios", target_os = "android"))]
2014        #[cfg(feature = "opengl")]
2015        if let Some(gl) = self.opengl.as_ref() {
2016            let _ = gl; // probe_transfer_backend lives behind the platform trait
2017            match TensorDyn::image(
2018                width,
2019                height,
2020                format,
2021                dtype,
2022                Some(edgefirst_tensor::TensorMemory::Dma),
2023                access,
2024            ) {
2025                Ok(img) => return Ok(img),
2026                Err(e) => {
2027                    // Falling back to a non-zero-copy destination is a real
2028                    // perf cliff — never do it silently (on-device triage
2029                    // starts from this line).
2030                    log::debug!(
2031                        "create_image: zero-copy Dma allocation declined \
2032                         ({format:?}/{dtype:?} {width}x{height}): {e:?}; using fallback storage"
2033                    );
2034                }
2035            }
2036        }
2037
2038        // Try DMA first on Linux — skip only when GL has explicitly selected PBO
2039        // as the preferred transfer path (PBO is better than DMA in that case).
2040        #[cfg(target_os = "linux")]
2041        {
2042            #[cfg(feature = "opengl")]
2043            let gl_uses_pbo = self
2044                .opengl
2045                .as_ref()
2046                .is_some_and(|gl| gl.transfer_backend() == opengl_headless::TransferBackend::Pbo);
2047            #[cfg(not(feature = "opengl"))]
2048            let gl_uses_pbo = false;
2049
2050            if !gl_uses_pbo {
2051                if let Ok(img) = try_dma() {
2052                    return Ok(img);
2053                }
2054            }
2055        }
2056
2057        // Try PBO (if GL available).
2058        // PBO buffers are u8-sized; the int8 shader emulates i8 output via
2059        // XOR 0x80 on the same underlying buffer, so both U8 and I8 work.
2060        #[cfg(target_os = "linux")]
2061        #[cfg(feature = "opengl")]
2062        if dtype.size() == 1 {
2063            if let Some(gl) = &self.opengl {
2064                match gl.create_pbo_image(width, height, format) {
2065                    Ok(t) => {
2066                        if dtype == DType::I8 {
2067                            // SAFETY: Tensor<u8> and Tensor<i8> are layout-
2068                            // identical (same element size, no T-dependent
2069                            // drop glue). The int8 shader applies XOR 0x80
2070                            // on the same PBO buffer. Same rationale as
2071                            // gl::processor::tensor_i8_as_u8_mut.
2072                            // Invariant: PBO tensors never have chroma
2073                            // (create_pbo_image → Tensor::wrap sets it None).
2074                            debug_assert!(
2075                                t.chroma().is_none(),
2076                                "PBO i8 transmute requires chroma == None"
2077                            );
2078                            let t_i8: Tensor<i8> = unsafe { std::mem::transmute(t) };
2079                            return Ok(TensorDyn::from(t_i8));
2080                        }
2081                        return Ok(TensorDyn::from(t));
2082                    }
2083                    Err(e) => log::debug!("PBO image creation failed, falling back to Mem: {e:?}"),
2084                }
2085            }
2086        }
2087
2088        // Try float PBO when the GPU backend reports support for this dtype.
2089        // Falls through to Mem on error (same policy as u8 PBO above).
2090        #[cfg(target_os = "linux")]
2091        #[cfg(feature = "opengl")]
2092        if float_pbo_eligible(dtype, self.supported_render_dtypes()) {
2093            if let Some(gl) = &self.opengl {
2094                match gl.create_pbo_image_dtype(width, height, format, dtype) {
2095                    Ok(t) => return Ok(t),
2096                    Err(e) => {
2097                        log::debug!(
2098                            "Float PBO image creation failed for {dtype:?}, \
2099                             falling back to Mem: {e:?}"
2100                        );
2101                    }
2102                }
2103            }
2104        }
2105
2106        // Fallback to Mem
2107        Ok(TensorDyn::image(
2108            width,
2109            height,
2110            format,
2111            dtype,
2112            Some(edgefirst_tensor::TensorMemory::Mem),
2113            access,
2114        )?)
2115    }
2116
2117    /// Import an external DMA-BUF image.
2118    ///
2119    /// Each [`PlaneDescriptor`] owns an already-duped fd; this method
2120    /// consumes the descriptors and takes ownership of those fds (whether
2121    /// the call succeeds or fails).
2122    ///
2123    /// The caller must ensure the DMA-BUF allocation is large enough for the
2124    /// specified width, height, format, and any stride/offset on the plane
2125    /// descriptors. No buffer-size validation is performed; an undersized
2126    /// buffer may cause GPU faults or EGL import failure.
2127    ///
2128    /// # Arguments
2129    ///
2130    /// * `image` - Plane descriptor for the primary (or only) plane
2131    /// * `chroma` - Optional plane descriptor for the UV chroma plane
2132    ///   (required for multiplane NV12)
2133    /// * `width` - Image width in pixels
2134    /// * `height` - Image height in pixels
2135    /// * `format` - Pixel format of the buffer
2136    /// * `dtype` - Element data type (e.g. `DType::U8`)
2137    ///
2138    /// # Returns
2139    ///
2140    /// A `TensorDyn` configured as an image.
2141    ///
2142    /// # Errors
2143    ///
2144    /// * [`Error::NotSupported`] if `chroma` is `Some` for a non-semi-planar
2145    ///   format, or multiplane NV16 (not yet supported), or the fd is not
2146    ///   DMA-backed
2147    /// * [`Error::InvalidShape`] if NV12 height is odd
2148    ///
2149    /// # Platform
2150    ///
2151    /// Linux only.
2152    ///
2153    /// # Examples
2154    ///
2155    /// ```rust,ignore
2156    /// use edgefirst_tensor::PlaneDescriptor;
2157    ///
2158    /// // Single-plane RGBA
2159    /// let pd = PlaneDescriptor::new(fd.as_fd())?;
2160    /// let src = proc.import_image(pd, None, 1920, 1080, PixelFormat::Rgba, DType::U8, None)?;
2161    ///
2162    /// // Multi-plane NV12 with stride
2163    /// let y_pd = PlaneDescriptor::new(y_fd.as_fd())?.with_stride(2048);
2164    /// let uv_pd = PlaneDescriptor::new(uv_fd.as_fd())?.with_stride(2048);
2165    /// let src = proc.import_image(y_pd, Some(uv_pd), 1920, 1080,
2166    ///                             PixelFormat::Nv12, DType::U8, None)?;
2167    /// ```
2168    // Import inherently needs plane(s) + geometry + format + dtype + colorimetry;
2169    // a params struct would obscure more than it clarifies here.
2170    #[allow(clippy::too_many_arguments)]
2171    #[cfg(target_os = "linux")]
2172    pub fn import_image(
2173        &self,
2174        image: edgefirst_tensor::PlaneDescriptor,
2175        chroma: Option<edgefirst_tensor::PlaneDescriptor>,
2176        width: usize,
2177        height: usize,
2178        format: PixelFormat,
2179        dtype: DType,
2180        colorimetry: Option<edgefirst_tensor::Colorimetry>,
2181    ) -> Result<TensorDyn> {
2182        use edgefirst_tensor::{Tensor, TensorMemory};
2183
2184        // Capture stride/offset from descriptors before consuming them
2185        let image_stride = image.stride();
2186        let image_offset = image.offset();
2187        let chroma_stride = chroma.as_ref().and_then(|c| c.stride());
2188        let chroma_offset = chroma.as_ref().and_then(|c| c.offset());
2189
2190        if let Some(chroma_pd) = chroma {
2191            // ── Multiplane path ──────────────────────────────────────
2192            // Multiplane tensors are backed by Tensor<u8> (or transmuted to
2193            // Tensor<i8>). Reject other dtypes to avoid silently returning a
2194            // tensor with the wrong element type.
2195            if dtype != DType::U8 && dtype != DType::I8 {
2196                return Err(Error::NotSupported(format!(
2197                    "multiplane import only supports U8/I8, got {dtype:?}"
2198                )));
2199            }
2200            if format.layout() != PixelLayout::SemiPlanar {
2201                return Err(Error::NotSupported(format!(
2202                    "import_image with chroma requires a semi-planar format, got {format:?}"
2203                )));
2204            }
2205
2206            let chroma_h = match format {
2207                PixelFormat::Nv12 => {
2208                    // NV12 (4:2:0): ceil(H/2) chroma rows — odd heights are valid.
2209                    height.div_ceil(2)
2210                }
2211                // NV16 multiplane will be supported in a future release;
2212                // the GL backend currently only handles NV12 plane1 attributes.
2213                PixelFormat::Nv16 => {
2214                    return Err(Error::NotSupported(
2215                        "multiplane NV16 is not yet supported; use contiguous NV16 instead".into(),
2216                    ))
2217                }
2218                _ => {
2219                    return Err(Error::NotSupported(format!(
2220                        "unsupported semi-planar format: {format:?}"
2221                    )))
2222                }
2223            };
2224
2225            let luma = Tensor::<u8>::from_fd(image.into_fd(), &[height, width], Some("luma"))?;
2226            if luma.memory() != TensorMemory::Dma {
2227                return Err(Error::NotSupported(format!(
2228                    "luma fd must be DMA-backed, got {:?}",
2229                    luma.memory()
2230                )));
2231            }
2232
2233            let chroma_tensor =
2234                Tensor::<u8>::from_fd(chroma_pd.into_fd(), &[chroma_h, width], Some("chroma"))?;
2235            if chroma_tensor.memory() != TensorMemory::Dma {
2236                return Err(Error::NotSupported(format!(
2237                    "chroma fd must be DMA-backed, got {:?}",
2238                    chroma_tensor.memory()
2239                )));
2240            }
2241
2242            // from_planes creates the combined tensor with format set,
2243            // preserving luma's row_stride (currently None since luma was raw).
2244            let mut tensor = Tensor::<u8>::from_planes(luma, chroma_tensor, format)?;
2245
2246            // Apply stride/offset to the combined tensor (luma plane)
2247            if let Some(s) = image_stride {
2248                tensor.set_row_stride(s)?;
2249            }
2250            if let Some(o) = image_offset {
2251                tensor.set_plane_offset(o);
2252            }
2253
2254            // Apply stride/offset to the chroma sub-tensor.
2255            // The chroma tensor is a raw 2D [chroma_h, width] tensor without
2256            // format metadata, so we validate stride manually rather than
2257            // using set_row_stride (which requires format).
2258            if let Some(chroma_ref) = tensor.chroma_mut() {
2259                if let Some(s) = chroma_stride {
2260                    if s < width {
2261                        return Err(Error::InvalidShape(format!(
2262                            "chroma stride {s} < minimum {width} for {format:?}"
2263                        )));
2264                    }
2265                    chroma_ref.set_row_stride_unchecked(s);
2266                }
2267                if let Some(o) = chroma_offset {
2268                    chroma_ref.set_plane_offset(o);
2269                }
2270            }
2271
2272            if dtype == DType::I8 {
2273                // SAFETY: Tensor<u8> and Tensor<i8> have identical layout because
2274                // the struct contains only type-erased storage (OwnedFd, shape, name),
2275                // no inline T values. This assertion catches layout drift at compile time.
2276                const {
2277                    assert!(std::mem::size_of::<Tensor<u8>>() == std::mem::size_of::<Tensor<i8>>());
2278                    assert!(
2279                        std::mem::align_of::<Tensor<u8>>() == std::mem::align_of::<Tensor<i8>>()
2280                    );
2281                }
2282                let tensor_i8: Tensor<i8> = unsafe { std::mem::transmute(tensor) };
2283                let mut dyn_tensor = TensorDyn::from(tensor_i8);
2284                dyn_tensor.set_colorimetry(colorimetry);
2285                return Ok(dyn_tensor);
2286            }
2287            let mut dyn_tensor = TensorDyn::from(tensor);
2288            dyn_tensor.set_colorimetry(colorimetry);
2289            Ok(dyn_tensor)
2290        } else {
2291            // ── Single-plane path ────────────────────────────────────
2292            // Canonical shape (Packed [H,W,C] / Planar [C,H,W] / SemiPlanar
2293            // [total_h, W]); `image_shape` supports NV12/NV16/NV24 (the old
2294            // hand-rolled match erroneously rejected NV24).
2295            let shape = format.image_shape(width, height).ok_or_else(|| {
2296                Error::NotSupported(format!(
2297                    "unsupported pixel format for import_image: {format:?}"
2298                ))
2299            })?;
2300            let tensor = TensorDyn::from_fd(image.into_fd(), &shape, dtype, None)?;
2301            if tensor.memory() != TensorMemory::Dma {
2302                return Err(Error::NotSupported(format!(
2303                    "import_image requires DMA-backed fd, got {:?}",
2304                    tensor.memory()
2305                )));
2306            }
2307            let mut tensor = tensor.with_format(format)?;
2308            if let Some(s) = image_stride {
2309                tensor.set_row_stride(s)?;
2310            }
2311            if let Some(o) = image_offset {
2312                tensor.set_plane_offset(o);
2313            }
2314            tensor.set_colorimetry(colorimetry);
2315            Ok(tensor)
2316        }
2317    }
2318
2319    /// Decode model outputs and draw segmentation masks onto `dst`.
2320    ///
2321    /// This is the primary mask rendering API. The processor decodes via the
2322    /// provided [`Decoder`](edgefirst_decoder::Decoder), selects the optimal rendering path (hybrid
2323    /// CPU+GL or fused GPU), and composites masks onto `dst`.
2324    ///
2325    /// Returns the detected bounding boxes.
2326    pub fn draw_masks(
2327        &mut self,
2328        decoder: &edgefirst_decoder::Decoder,
2329        outputs: &[&TensorDyn],
2330        dst: &mut TensorDyn,
2331        overlay: MaskOverlay<'_>,
2332    ) -> Result<Vec<DetectBox>> {
2333        let mut output_boxes = Vec::with_capacity(100);
2334
2335        // Try proto path first (fused rendering without materializing masks)
2336        let proto_result = decoder
2337            .decode_proto(outputs, &mut output_boxes)
2338            .map_err(|e| Error::Internal(format!("decode_proto: {e:#?}")))?;
2339
2340        if let Some(proto_data) = proto_result {
2341            self.draw_proto_masks(dst, &output_boxes, &proto_data, overlay)?;
2342        } else {
2343            // Detection-only or unsupported model: full decode + render
2344            let mut output_masks = Vec::with_capacity(100);
2345            decoder
2346                .decode(outputs, &mut output_boxes, &mut output_masks)
2347                .map_err(|e| Error::Internal(format!("decode: {e:#?}")))?;
2348            self.draw_decoded_masks(dst, &output_boxes, &output_masks, overlay)?;
2349        }
2350        Ok(output_boxes)
2351    }
2352
2353    /// Decode tracked model outputs and draw segmentation masks onto `dst`.
2354    ///
2355    /// Like [`draw_masks`](Self::draw_masks) but integrates a tracker for
2356    /// maintaining object identities across frames. The tracker runs after
2357    /// NMS but before mask extraction.
2358    ///
2359    /// Returns detected boxes and track info.
2360    #[cfg(feature = "tracker")]
2361    pub fn draw_masks_tracked<TR: edgefirst_tracker::Tracker<DetectBox>>(
2362        &mut self,
2363        decoder: &edgefirst_decoder::Decoder,
2364        tracker: &mut TR,
2365        timestamp: u64,
2366        outputs: &[&TensorDyn],
2367        dst: &mut TensorDyn,
2368        overlay: MaskOverlay<'_>,
2369    ) -> Result<(Vec<DetectBox>, Vec<edgefirst_tracker::TrackInfo>)> {
2370        let mut output_boxes = Vec::with_capacity(100);
2371        let mut output_tracks = Vec::new();
2372
2373        let proto_result = decoder
2374            .decode_proto_tracked(
2375                tracker,
2376                timestamp,
2377                outputs,
2378                &mut output_boxes,
2379                &mut output_tracks,
2380            )
2381            .map_err(|e| Error::Internal(format!("decode_proto_tracked: {e:#?}")))?;
2382
2383        if let Some(proto_data) = proto_result {
2384            self.draw_proto_masks(dst, &output_boxes, &proto_data, overlay)?;
2385        } else {
2386            // Note: decode_proto_tracked returns None for detection-only/ModelPack
2387            // models WITHOUT calling the tracker. The else branch below is the
2388            // first (and only) tracker call for those model types.
2389            let mut output_masks = Vec::with_capacity(100);
2390            decoder
2391                .decode_tracked(
2392                    tracker,
2393                    timestamp,
2394                    outputs,
2395                    &mut output_boxes,
2396                    &mut output_masks,
2397                    &mut output_tracks,
2398                )
2399                .map_err(|e| Error::Internal(format!("decode_tracked: {e:#?}")))?;
2400            self.draw_decoded_masks(dst, &output_boxes, &output_masks, overlay)?;
2401        }
2402        Ok((output_boxes, output_tracks))
2403    }
2404
2405    /// Materialize per-instance segmentation masks from raw prototype data.
2406    ///
2407    /// Computes `mask_coeff @ protos` with sigmoid activation for each detection,
2408    /// producing compact masks at prototype resolution (e.g., 160×160 crops).
2409    /// Mask values are continuous sigmoid confidence outputs quantized to u8
2410    /// (0 = background, 255 = full confidence), NOT binary thresholded.
2411    ///
2412    /// The returned [`Vec<Segmentation>`] can be:
2413    /// - Inspected or exported for analytics, IoU computation, etc.
2414    /// - Passed directly to [`ImageProcessorTrait::draw_decoded_masks`] for
2415    ///   GPU-interpolated rendering.
2416    ///
2417    /// # Performance Note
2418    ///
2419    /// Calling `materialize_masks` + `draw_decoded_masks` separately prevents
2420    /// the HAL from using its internal fused optimization path. For render-only
2421    /// use cases, prefer [`ImageProcessorTrait::draw_proto_masks`] which selects
2422    /// the fastest path automatically (currently 1.6×–27× faster on tested
2423    /// platforms). Use this method when you need access to the intermediate masks.
2424    ///
2425    /// # Errors
2426    ///
2427    /// Returns [`Error::NoConverter`] if the CPU backend is not available.
2428    pub fn materialize_masks(
2429        &mut self,
2430        detect: &[DetectBox],
2431        proto_data: &ProtoData,
2432        letterbox: Option<[f32; 4]>,
2433        resolution: MaskResolution,
2434    ) -> Result<Vec<Segmentation>> {
2435        let cpu = self.cpu.as_mut().ok_or(Error::NoConverter)?;
2436        match resolution {
2437            MaskResolution::Proto => cpu.materialize_segmentations(detect, proto_data, letterbox),
2438            MaskResolution::Scaled { width, height } => {
2439                cpu.materialize_scaled_segmentations(detect, proto_data, letterbox, width, height)
2440            }
2441        }
2442    }
2443}
2444
2445impl ImageProcessorTrait for ImageProcessor {
2446    /// Converts the source image to the destination image format and size. The
2447    /// image is cropped first, then flipped, then rotated
2448    ///
2449    /// Prefer hardware accelerators when available, falling back to CPU if
2450    /// necessary.
2451    fn convert(
2452        &mut self,
2453        src: &TensorDyn,
2454        dst: &mut TensorDyn,
2455        rotation: Rotation,
2456        flip: Flip,
2457        crop: Crop,
2458    ) -> Result<()> {
2459        let start = Instant::now();
2460        let src_fmt = src.format();
2461        let dst_fmt = dst.format();
2462        let _span = tracing::trace_span!(
2463            "image.convert",
2464            ?src_fmt,
2465            ?dst_fmt,
2466            src_memory = ?src.memory(),
2467            dst_memory = ?dst.memory(),
2468            ?rotation,
2469            ?flip,
2470        )
2471        .entered();
2472        log::trace!(
2473            "convert: {src_fmt:?}({:?}/{:?}) → {dst_fmt:?}({:?}/{:?}), \
2474             rotation={rotation:?}, flip={flip:?}, backend={:?}",
2475            src.dtype(),
2476            src.memory(),
2477            dst.dtype(),
2478            dst.memory(),
2479            self.forced_backend,
2480        );
2481
2482        // ── Forced backend: no fallback chain ────────────────────────
2483        if let Some(forced) = self.forced_backend {
2484            return match forced {
2485                ForcedBackend::Cpu => {
2486                    if let Some(cpu) = self.cpu.as_mut() {
2487                        let r = cpu.convert(src, dst, rotation, flip, crop);
2488                        log::trace!(
2489                            "convert: forced=cpu result={} ({:?})",
2490                            if r.is_ok() { "ok" } else { "err" },
2491                            start.elapsed()
2492                        );
2493                        return r;
2494                    }
2495                    Err(Error::ForcedBackendUnavailable("cpu".into()))
2496                }
2497                ForcedBackend::G2d => {
2498                    #[cfg(target_os = "linux")]
2499                    if let Some(g2d) = self.g2d.as_mut() {
2500                        let r = g2d.convert(src, dst, rotation, flip, crop);
2501                        log::trace!(
2502                            "convert: forced=g2d result={} ({:?})",
2503                            if r.is_ok() { "ok" } else { "err" },
2504                            start.elapsed()
2505                        );
2506                        return r;
2507                    }
2508                    Err(Error::ForcedBackendUnavailable("g2d".into()))
2509                }
2510                ForcedBackend::OpenGl => {
2511                    #[cfg(any(
2512                        target_os = "linux",
2513                        target_os = "macos",
2514                        target_os = "ios",
2515                        target_os = "android"
2516                    ))]
2517                    #[cfg(feature = "opengl")]
2518                    if let Some(opengl) = self.opengl.as_mut() {
2519                        let r = opengl.convert(src, dst, rotation, flip, crop);
2520                        log::trace!(
2521                            "convert: forced=opengl result={} ({:?})",
2522                            if r.is_ok() { "ok" } else { "err" },
2523                            start.elapsed()
2524                        );
2525                        return r;
2526                    }
2527                    Err(Error::ForcedBackendUnavailable("opengl".into()))
2528                }
2529            };
2530        }
2531
2532        // ── Auto fallback chain: OpenGL → G2D → CPU ──────────────────
2533        #[cfg(any(
2534            target_os = "linux",
2535            target_os = "macos",
2536            target_os = "ios",
2537            target_os = "android"
2538        ))]
2539        #[cfg(feature = "opengl")]
2540        if let Some(opengl) = self.opengl.as_mut() {
2541            match opengl.convert(src, dst, rotation, flip, crop) {
2542                Ok(_) => {
2543                    log::trace!(
2544                        "convert: auto selected=opengl for {src_fmt:?}→{dst_fmt:?} ({:?})",
2545                        start.elapsed()
2546                    );
2547                    return Ok(());
2548                }
2549                Err(e) => {
2550                    self.convert_fallbacks
2551                        .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2552                    log::debug!(
2553                        "convert: auto opengl declined {src_fmt:?}@{:?}→{dst_fmt:?}@{:?}, \
2554                         falling back toward G2D/CPU: {e}",
2555                        src.memory(),
2556                        dst.memory(),
2557                    );
2558                }
2559            }
2560        }
2561
2562        #[cfg(target_os = "linux")]
2563        if let Some(g2d) = self.g2d.as_mut() {
2564            // G2D is matrix-only (no range control, no BT.2020). For any
2565            // conversion with a YUV side, resolve that side's colorimetry and
2566            // skip G2D entirely when it cannot be expressed (full-range YUV or
2567            // BT.2020), letting the chain fall through to GL/CPU which honour
2568            // range and BT.2020. YUV→RGB uses the source colorimetry; RGB→YUV
2569            // uses the destination. RGB→RGB has no YUV side and is unaffected.
2570            let src_is_yuv = src.format().is_some_and(|f| f.is_yuv());
2571            let dst_is_yuv = dst.format().is_some_and(|f| f.is_yuv());
2572            let g2d_eligible = if src_is_yuv || dst_is_yuv {
2573                let cm = if src_is_yuv {
2574                    crate::colorimetry::effective_colorimetry(src)
2575                } else {
2576                    crate::colorimetry::effective_colorimetry(dst)
2577                };
2578                crate::g2d::g2d_can_handle(&cm, true)
2579            } else {
2580                true
2581            };
2582            if !g2d_eligible {
2583                log::trace!(
2584                    "convert: auto g2d skipped {src_fmt:?}→{dst_fmt:?} \
2585                     (colorimetry not expressible: full-range/BT.2020)"
2586                );
2587            } else {
2588                match g2d.convert(src, dst, rotation, flip, crop) {
2589                    Ok(_) => {
2590                        log::trace!(
2591                            "convert: auto selected=g2d for {src_fmt:?}→{dst_fmt:?} ({:?})",
2592                            start.elapsed()
2593                        );
2594                        return Ok(());
2595                    }
2596                    Err(e) => {
2597                        log::trace!("convert: auto g2d declined {src_fmt:?}→{dst_fmt:?}: {e}");
2598                    }
2599                }
2600            }
2601        }
2602
2603        if let Some(cpu) = self.cpu.as_mut() {
2604            match cpu.convert(src, dst, rotation, flip, crop) {
2605                Ok(_) => {
2606                    log::trace!(
2607                        "convert: auto selected=cpu for {src_fmt:?}→{dst_fmt:?} ({:?})",
2608                        start.elapsed()
2609                    );
2610                    return Ok(());
2611                }
2612                Err(e) => {
2613                    log::trace!("convert: auto cpu failed {src_fmt:?}→{dst_fmt:?}: {e}");
2614                    return Err(e);
2615                }
2616            }
2617        }
2618        Err(Error::NoConverter)
2619    }
2620
2621    fn convert_deferred(
2622        &mut self,
2623        src: &TensorDyn,
2624        dst: &mut TensorDyn,
2625        rotation: Rotation,
2626        flip: Flip,
2627        crop: Crop,
2628    ) -> Result<()> {
2629        // Deferred batching is an OpenGL optimization (shared parent EGLImage +
2630        // no per-tile glFinish). Route to the GL backend's deferred path when GL
2631        // is forced or auto-selectable; on a GL decline fall back to an eager
2632        // convert (the auto chain), which is correct everywhere — it completes
2633        // synchronously and `flush` stays a no-op for non-GL backends.
2634        #[cfg(any(
2635            target_os = "linux",
2636            target_os = "macos",
2637            target_os = "ios",
2638            target_os = "android"
2639        ))]
2640        #[cfg(feature = "opengl")]
2641        {
2642            let gl_forced = matches!(self.forced_backend, Some(ForcedBackend::OpenGl));
2643            if gl_forced || self.forced_backend.is_none() {
2644                if let Some(opengl) = self.opengl.as_mut() {
2645                    match opengl.convert_deferred(src, dst, rotation, flip, crop) {
2646                        Ok(()) => return Ok(()),
2647                        Err(e) => {
2648                            log::trace!("convert_deferred: gl declined: {e}; eager fallback");
2649                            // A forced-GL caller gets the GL error, matching
2650                            // `convert`'s no-fallback forced-backend contract.
2651                            if gl_forced {
2652                                return Err(e);
2653                            }
2654                        }
2655                    }
2656                }
2657            }
2658        }
2659        self.convert(src, dst, rotation, flip, crop)
2660    }
2661
2662    fn flush(&mut self) -> Result<()> {
2663        let _span = tracing::trace_span!("image.flush").entered();
2664        // Only the OpenGL backend defers; flushing it issues the single GPU
2665        // sync. CPU/G2D converts already completed, so there is nothing to flush.
2666        #[cfg(any(
2667            target_os = "linux",
2668            target_os = "macos",
2669            target_os = "ios",
2670            target_os = "android"
2671        ))]
2672        #[cfg(feature = "opengl")]
2673        if let Some(opengl) = self.opengl.as_mut() {
2674            return opengl.flush();
2675        }
2676        Ok(())
2677    }
2678
2679    fn draw_decoded_masks(
2680        &mut self,
2681        dst: &mut TensorDyn,
2682        detect: &[DetectBox],
2683        segmentation: &[Segmentation],
2684        overlay: MaskOverlay<'_>,
2685    ) -> Result<()> {
2686        let _span = tracing::trace_span!(
2687            "image.draw_decoded_masks",
2688            n_detections = detect.len(),
2689            n_segmentations = segmentation.len(),
2690        )
2691        .entered();
2692        let start = Instant::now();
2693
2694        if let Some(bg) = overlay.background {
2695            if bg.aliases(dst) {
2696                return Err(Error::AliasedBuffers(
2697                    "background must not reference the same buffer as dst".to_string(),
2698                ));
2699            }
2700        }
2701
2702        // Un-letterbox detect boxes and segmentation bboxes for rendering when
2703        // a letterbox was applied to prepare the model input.
2704        let lb_boxes: Vec<DetectBox>;
2705        let lb_segs: Vec<Segmentation>;
2706        let (detect, segmentation) = if let Some(lb) = overlay.letterbox {
2707            lb_boxes = detect.iter().map(|&d| unletter_bbox(d, lb)).collect();
2708            // Keep segmentation bboxes in sync with the transformed detect boxes
2709            // when we have a 1:1 correspondence (instance segmentation).
2710            lb_segs = if segmentation.len() == lb_boxes.len() {
2711                segmentation
2712                    .iter()
2713                    .zip(lb_boxes.iter())
2714                    .map(|(s, d)| Segmentation {
2715                        xmin: d.bbox.xmin,
2716                        ymin: d.bbox.ymin,
2717                        xmax: d.bbox.xmax,
2718                        ymax: d.bbox.ymax,
2719                        segmentation: s.segmentation.clone(),
2720                    })
2721                    .collect()
2722            } else {
2723                segmentation.to_vec()
2724            };
2725            (lb_boxes.as_slice(), lb_segs.as_slice())
2726        } else {
2727            (detect, segmentation)
2728        };
2729        #[cfg(target_os = "linux")]
2730        let is_empty_frame = detect.is_empty() && segmentation.is_empty();
2731
2732        // ── Forced backend: no fallback chain ────────────────────────
2733        if let Some(forced) = self.forced_backend {
2734            return match forced {
2735                ForcedBackend::Cpu => {
2736                    if let Some(cpu) = self.cpu.as_mut() {
2737                        return cpu.draw_decoded_masks(dst, detect, segmentation, overlay);
2738                    }
2739                    Err(Error::ForcedBackendUnavailable("cpu".into()))
2740                }
2741                ForcedBackend::G2d => {
2742                    // G2D can only produce empty frames (clear / bg blit).
2743                    // For populated frames it has no rasterizer — fail loudly.
2744                    #[cfg(target_os = "linux")]
2745                    if let Some(g2d) = self.g2d.as_mut() {
2746                        return g2d.draw_decoded_masks(dst, detect, segmentation, overlay);
2747                    }
2748                    Err(Error::ForcedBackendUnavailable("g2d".into()))
2749                }
2750                ForcedBackend::OpenGl => {
2751                    // GL handles background natively via GPU blit, and now
2752                    // actively clears when there is no background.
2753                    #[cfg(target_os = "linux")]
2754                    #[cfg(feature = "opengl")]
2755                    if let Some(opengl) = self.opengl.as_mut() {
2756                        return opengl.draw_decoded_masks(dst, detect, segmentation, overlay);
2757                    }
2758                    Err(Error::ForcedBackendUnavailable("opengl".into()))
2759                }
2760            };
2761        }
2762
2763        // ── Auto dispatch ──────────────────────────────────────────
2764        // Empty frames prefer G2D when available — a single g2d_clear or
2765        // g2d_blit is the cheapest HW path to produce the correct output
2766        // and avoids spinning up the GL pipeline every zero-detection
2767        // frame in a triple-buffered display loop.
2768        #[cfg(target_os = "linux")]
2769        if is_empty_frame {
2770            if let Some(g2d) = self.g2d.as_mut() {
2771                match g2d.draw_decoded_masks(dst, detect, segmentation, overlay) {
2772                    Ok(_) => {
2773                        log::trace!(
2774                            "draw_decoded_masks empty frame via g2d in {:?}",
2775                            start.elapsed()
2776                        );
2777                        return Ok(());
2778                    }
2779                    Err(e) => log::trace!("g2d empty-frame path unavailable: {e:?}"),
2780                }
2781            }
2782        }
2783
2784        // Populated frames (or G2D unavailable): GL first, CPU fallback.
2785        // Both backends now own their own base-layer handling (bg blit
2786        // or clear), so we hand the overlay through untouched.
2787        #[cfg(target_os = "linux")]
2788        #[cfg(feature = "opengl")]
2789        if let Some(opengl) = self.opengl.as_mut() {
2790            log::trace!(
2791                "draw_decoded_masks started with opengl in {:?}",
2792                start.elapsed()
2793            );
2794            match opengl.draw_decoded_masks(dst, detect, segmentation, overlay) {
2795                Ok(_) => {
2796                    log::trace!("draw_decoded_masks with opengl in {:?}", start.elapsed());
2797                    return Ok(());
2798                }
2799                Err(e) => {
2800                    log::trace!("draw_decoded_masks didn't work with opengl: {e:?}")
2801                }
2802            }
2803        }
2804
2805        log::trace!(
2806            "draw_decoded_masks started with cpu in {:?}",
2807            start.elapsed()
2808        );
2809        if let Some(cpu) = self.cpu.as_mut() {
2810            match cpu.draw_decoded_masks(dst, detect, segmentation, overlay) {
2811                Ok(_) => {
2812                    log::trace!("draw_decoded_masks with cpu in {:?}", start.elapsed());
2813                    return Ok(());
2814                }
2815                Err(e) => {
2816                    log::trace!("draw_decoded_masks didn't work with cpu: {e:?}");
2817                    return Err(e);
2818                }
2819            }
2820        }
2821        Err(Error::NoConverter)
2822    }
2823
2824    fn draw_proto_masks(
2825        &mut self,
2826        dst: &mut TensorDyn,
2827        detect: &[DetectBox],
2828        proto_data: &ProtoData,
2829        overlay: MaskOverlay<'_>,
2830    ) -> Result<()> {
2831        let start = Instant::now();
2832
2833        if let Some(bg) = overlay.background {
2834            if bg.aliases(dst) {
2835                return Err(Error::AliasedBuffers(
2836                    "background must not reference the same buffer as dst".to_string(),
2837                ));
2838            }
2839        }
2840
2841        // Un-letterbox detect boxes for rendering when a letterbox was applied
2842        // to prepare the model input.  The original `detect` coords are still
2843        // passed to `materialize_segmentations` (which needs model-space coords
2844        // to correctly crop the proto tensor) alongside `overlay.letterbox` so
2845        // it can emit `Segmentation` structs in output-image space.
2846        let lb_boxes: Vec<DetectBox>;
2847        let render_detect = if let Some(lb) = overlay.letterbox {
2848            lb_boxes = detect.iter().map(|&d| unletter_bbox(d, lb)).collect();
2849            lb_boxes.as_slice()
2850        } else {
2851            detect
2852        };
2853        #[cfg(target_os = "linux")]
2854        let is_empty_frame = detect.is_empty();
2855
2856        // ── Forced backend: no fallback chain ────────────────────────
2857        if let Some(forced) = self.forced_backend {
2858            return match forced {
2859                ForcedBackend::Cpu => {
2860                    if let Some(cpu) = self.cpu.as_mut() {
2861                        return cpu.draw_proto_masks(dst, render_detect, proto_data, overlay);
2862                    }
2863                    Err(Error::ForcedBackendUnavailable("cpu".into()))
2864                }
2865                ForcedBackend::G2d => {
2866                    #[cfg(target_os = "linux")]
2867                    if let Some(g2d) = self.g2d.as_mut() {
2868                        return g2d.draw_proto_masks(dst, render_detect, proto_data, overlay);
2869                    }
2870                    Err(Error::ForcedBackendUnavailable("g2d".into()))
2871                }
2872                ForcedBackend::OpenGl => {
2873                    #[cfg(target_os = "linux")]
2874                    #[cfg(feature = "opengl")]
2875                    if let Some(opengl) = self.opengl.as_mut() {
2876                        return opengl.draw_proto_masks(dst, render_detect, proto_data, overlay);
2877                    }
2878                    Err(Error::ForcedBackendUnavailable("opengl".into()))
2879                }
2880            };
2881        }
2882
2883        // ── Auto dispatch ──────────────────────────────────────────
2884        // Empty frames: prefer G2D — cheapest HW path (clear or bg blit).
2885        #[cfg(target_os = "linux")]
2886        if is_empty_frame {
2887            if let Some(g2d) = self.g2d.as_mut() {
2888                match g2d.draw_proto_masks(dst, render_detect, proto_data, overlay) {
2889                    Ok(_) => {
2890                        log::trace!(
2891                            "draw_proto_masks empty frame via g2d in {:?}",
2892                            start.elapsed()
2893                        );
2894                        return Ok(());
2895                    }
2896                    Err(e) => log::trace!("g2d empty-frame path unavailable: {e:?}"),
2897                }
2898            }
2899        }
2900
2901        // Hybrid path: CPU materialize + GL overlay (benchmarked faster than
2902        // full-GPU draw_proto_masks on all tested platforms: 27× on imx8mp,
2903        // 4× on imx95, 2.5× on rpi5, 1.6× on x86).
2904        // GL owns its own bg-blit / glClear — we pass the overlay through.
2905        //
2906        // CPU materialize needs `&mut` for its MaskScratch buffers; GL also
2907        // needs `&mut`. The CPU borrow is scoped to its block so the
2908        // subsequent GL borrow is free to take over `self`.
2909        #[cfg(target_os = "linux")]
2910        #[cfg(feature = "opengl")]
2911        if let (Some(_), Some(_)) = (self.cpu.as_ref(), self.opengl.as_ref()) {
2912            let segmentation = match self.cpu.as_mut() {
2913                Some(cpu) => {
2914                    log::trace!(
2915                        "draw_proto_masks started with hybrid (cpu+opengl) in {:?}",
2916                        start.elapsed()
2917                    );
2918                    cpu.materialize_segmentations(detect, proto_data, overlay.letterbox)?
2919                }
2920                None => unreachable!("cpu presence checked above"),
2921            };
2922            if let Some(opengl) = self.opengl.as_mut() {
2923                match opengl.draw_decoded_masks(dst, render_detect, &segmentation, overlay) {
2924                    Ok(_) => {
2925                        log::trace!(
2926                            "draw_proto_masks with hybrid (cpu+opengl) in {:?}",
2927                            start.elapsed()
2928                        );
2929                        return Ok(());
2930                    }
2931                    Err(e) => {
2932                        log::trace!(
2933                            "draw_proto_masks hybrid path failed, falling back to cpu: {e:?}"
2934                        );
2935                    }
2936                }
2937            }
2938        }
2939
2940        let Some(cpu) = self.cpu.as_mut() else {
2941            return Err(Error::Internal(
2942                "draw_proto_masks requires CPU backend for fallback path".into(),
2943            ));
2944        };
2945        log::trace!("draw_proto_masks started with cpu in {:?}", start.elapsed());
2946        cpu.draw_proto_masks(dst, render_detect, proto_data, overlay)
2947    }
2948
2949    fn set_class_colors(&mut self, colors: &[[u8; 4]]) -> Result<()> {
2950        let start = Instant::now();
2951
2952        // ── Forced backend: no fallback chain ────────────────────────
2953        if let Some(forced) = self.forced_backend {
2954            return match forced {
2955                ForcedBackend::Cpu => {
2956                    if let Some(cpu) = self.cpu.as_mut() {
2957                        return cpu.set_class_colors(colors);
2958                    }
2959                    Err(Error::ForcedBackendUnavailable("cpu".into()))
2960                }
2961                ForcedBackend::G2d => Err(Error::NotSupported(
2962                    "g2d does not support set_class_colors".into(),
2963                )),
2964                ForcedBackend::OpenGl => {
2965                    #[cfg(target_os = "linux")]
2966                    #[cfg(feature = "opengl")]
2967                    if let Some(opengl) = self.opengl.as_mut() {
2968                        return opengl.set_class_colors(colors);
2969                    }
2970                    Err(Error::ForcedBackendUnavailable("opengl".into()))
2971                }
2972            };
2973        }
2974
2975        // skip G2D as it doesn't support rendering to image
2976
2977        #[cfg(target_os = "linux")]
2978        #[cfg(feature = "opengl")]
2979        if let Some(opengl) = self.opengl.as_mut() {
2980            log::trace!("image started with opengl in {:?}", start.elapsed());
2981            match opengl.set_class_colors(colors) {
2982                Ok(_) => {
2983                    log::trace!("colors set with opengl in {:?}", start.elapsed());
2984                    return Ok(());
2985                }
2986                Err(e) => {
2987                    log::trace!("colors didn't set with opengl: {e:?}")
2988                }
2989            }
2990        }
2991        log::trace!("image started with cpu in {:?}", start.elapsed());
2992        if let Some(cpu) = self.cpu.as_mut() {
2993            match cpu.set_class_colors(colors) {
2994                Ok(_) => {
2995                    log::trace!("colors set with cpu in {:?}", start.elapsed());
2996                    return Ok(());
2997                }
2998                Err(e) => {
2999                    log::trace!("colors didn't set with cpu: {e:?}");
3000                    return Err(e);
3001                }
3002            }
3003        }
3004        Err(Error::NoConverter)
3005    }
3006}
3007
3008// ---------------------------------------------------------------------------
3009// Image loading / saving helpers
3010// ---------------------------------------------------------------------------
3011
3012/// Test-only convenience helper that peeks the image header, allocates a
3013/// tensor sized to the image (honoring DMA pitch padding on Linux when
3014/// requested), and decodes via [`edgefirst_codec`]. Mirrors the semantics of
3015/// the removed public `load_image` API for test sites; production callers
3016/// should use the explicit peek → allocate → decode pattern directly.
3017#[cfg(test)]
3018pub(crate) fn load_image_test_helper(
3019    image: &[u8],
3020    format: Option<PixelFormat>,
3021    memory: Option<TensorMemory>,
3022) -> Result<TensorDyn> {
3023    use edgefirst_codec::{peek_info, ImageDecoder, ImageLoad};
3024
3025    // Peek the source header to get its NATIVE format and dimensions. The
3026    // codec now emits the source's native format (JPEG → Nv12/Grey, PNG →
3027    // Rgb/Rgba/Grey) and configures the destination tensor itself.
3028    let info = peek_info(image)?;
3029    let native_fmt = info.format;
3030    let w = info.width;
3031    let h = info.height;
3032
3033    let mut decoder = ImageDecoder::new();
3034
3035    // Decode into a native-format tensor. The decoder sets the tensor's
3036    // dims+format, so we allocate it sized to the native layout.
3037    #[cfg(target_os = "linux")]
3038    let native_src = {
3039        if let Some(aligned_pitch) = padded_dma_pitch_for(native_fmt, w, &memory) {
3040            let mut dma = Tensor::<u8>::image_with_stride(
3041                w,
3042                h,
3043                native_fmt,
3044                aligned_pitch,
3045                Some(TensorMemory::Dma),
3046                edgefirst_tensor::CpuAccess::ReadWrite,
3047            )?;
3048            dma.load_image(&mut decoder, image)?;
3049            TensorDyn::from(dma)
3050        } else {
3051            let mut img = Tensor::<u8>::image(
3052                w,
3053                h,
3054                native_fmt,
3055                memory,
3056                edgefirst_tensor::CpuAccess::ReadWrite,
3057            )?;
3058            img.load_image(&mut decoder, image)?;
3059            TensorDyn::from(img)
3060        }
3061    };
3062    #[cfg(not(target_os = "linux"))]
3063    let native_src = {
3064        let mut img = Tensor::<u8>::image(
3065            w,
3066            h,
3067            native_fmt,
3068            memory,
3069            edgefirst_tensor::CpuAccess::ReadWrite,
3070        )?;
3071        img.load_image(&mut decoder, image)?;
3072        TensorDyn::from(img)
3073    };
3074
3075    // If the caller requested a different format, convert into it (same
3076    // dims) using a headless CPU-backed processor so the helper works
3077    // without GPU/G2D hardware.
3078    match format {
3079        Some(f) if f != native_fmt => {
3080            let mut dst = TensorDyn::image(
3081                w,
3082                h,
3083                f,
3084                DType::U8,
3085                memory,
3086                edgefirst_tensor::CpuAccess::ReadWrite,
3087            )?;
3088            // `ImageProcessorConfig` has platform-specific fields: on Linux it
3089            // carries extra GL/G2D options so `..Default::default()` is needed,
3090            // but on macOS `backend` is the only field, making the update
3091            // redundant (clippy::needless_update). Allow it for cross-platform
3092            // parity — the alternative (field reassign) trips
3093            // clippy::field_reassign_with_default on Linux instead.
3094            #[allow(clippy::needless_update)]
3095            let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
3096                backend: ComputeBackend::Cpu,
3097                ..Default::default()
3098            })?;
3099            proc.convert(
3100                &native_src,
3101                &mut dst,
3102                Rotation::None,
3103                Flip::None,
3104                Crop::default(),
3105            )?;
3106            Ok(dst)
3107        }
3108        _ => Ok(native_src),
3109    }
3110}
3111
3112/// Save a [`TensorDyn`] image as a JPEG file.
3113///
3114/// Only packed RGB and RGBA formats are supported.
3115pub fn save_jpeg(tensor: &TensorDyn, path: impl AsRef<std::path::Path>, quality: u8) -> Result<()> {
3116    let t = tensor.as_u8().ok_or(Error::UnsupportedFormat(
3117        "save_jpeg requires u8 tensor".to_string(),
3118    ))?;
3119    let fmt = t.format().ok_or(Error::NotAnImage)?;
3120    if fmt.layout() != PixelLayout::Packed {
3121        return Err(Error::NotImplemented(
3122            "Saving planar images is not supported".to_string(),
3123        ));
3124    }
3125
3126    let colour = match fmt {
3127        PixelFormat::Rgb => jpeg_encoder::ColorType::Rgb,
3128        PixelFormat::Rgba => jpeg_encoder::ColorType::Rgba,
3129        _ => {
3130            return Err(Error::NotImplemented(
3131                "Unsupported image format for saving".to_string(),
3132            ));
3133        }
3134    };
3135
3136    let w = t.width().ok_or(Error::NotAnImage)?;
3137    let h = t.height().ok_or(Error::NotAnImage)?;
3138    let encoder = jpeg_encoder::Encoder::new_file(path, quality)?;
3139    let tensor_map = t.map_read()?;
3140
3141    encoder.encode(&tensor_map, w as u16, h as u16, colour)?;
3142
3143    Ok(())
3144}
3145
3146pub(crate) struct FunctionTimer<T: Display> {
3147    name: T,
3148    start: std::time::Instant,
3149}
3150
3151impl<T: Display> FunctionTimer<T> {
3152    pub fn new(name: T) -> Self {
3153        Self {
3154            name,
3155            start: std::time::Instant::now(),
3156        }
3157    }
3158}
3159
3160impl<T: Display> Drop for FunctionTimer<T> {
3161    fn drop(&mut self) {
3162        log::trace!("{} elapsed: {:?}", self.name, self.start.elapsed())
3163    }
3164}
3165
3166const DEFAULT_COLORS: [[f32; 4]; 20] = [
3167    [0., 1., 0., 0.7],
3168    [1., 0.5568628, 0., 0.7],
3169    [0.25882353, 0.15294118, 0.13333333, 0.7],
3170    [0.8, 0.7647059, 0.78039216, 0.7],
3171    [0.3137255, 0.3137255, 0.3137255, 0.7],
3172    [0.1411765, 0.3098039, 0.1215686, 0.7],
3173    [1., 0.95686275, 0.5137255, 0.7],
3174    [0.3529412, 0.32156863, 0., 0.7],
3175    [0.4235294, 0.6235294, 0.6509804, 0.7],
3176    [0.5098039, 0.5098039, 0.7294118, 0.7],
3177    [0.00784314, 0.18823529, 0.29411765, 0.7],
3178    [0.0, 0.2706, 1.0, 0.7],
3179    [0.0, 0.0, 0.0, 0.7],
3180    [0.0, 0.5, 0.0, 0.7],
3181    [1.0, 0.0, 0.0, 0.7],
3182    [0.0, 0.0, 1.0, 0.7],
3183    [1.0, 0.5, 0.5, 0.7],
3184    [0.1333, 0.5451, 0.1333, 0.7],
3185    [0.1176, 0.4118, 0.8235, 0.7],
3186    [1., 1., 1., 0.7],
3187];
3188
3189const fn denorm<const M: usize, const N: usize>(a: [[f32; M]; N]) -> [[u8; M]; N] {
3190    let mut result = [[0; M]; N];
3191    let mut i = 0;
3192    while i < N {
3193        let mut j = 0;
3194        while j < M {
3195            result[i][j] = (a[i][j] * 255.0).round() as u8;
3196            j += 1;
3197        }
3198        i += 1;
3199    }
3200    result
3201}
3202
3203const DEFAULT_COLORS_U8: [[u8; 4]; 20] = denorm(DEFAULT_COLORS);
3204
3205#[cfg(test)]
3206#[cfg_attr(coverage_nightly, coverage(off))]
3207mod alignment_tests {
3208    use super::*;
3209
3210    #[test]
3211    fn align_width_rgba8_common_widths() {
3212        // RGBA8 (bpp=4, lcm(64,4)=64, so width must round to multiple of 16 px).
3213        assert_eq!(align_width_for_gpu_pitch(640, 4), 640); // 2560 byte pitch — already aligned
3214        assert_eq!(align_width_for_gpu_pitch(1280, 4), 1280); // 5120
3215        assert_eq!(align_width_for_gpu_pitch(1920, 4), 1920); // 7680
3216        assert_eq!(align_width_for_gpu_pitch(3840, 4), 3840); // 15360
3217                                                              // crowd.png case from the imx95 investigation:
3218        assert_eq!(align_width_for_gpu_pitch(3004, 4), 3008); // 12016 → 12032
3219        assert_eq!(align_width_for_gpu_pitch(3000, 4), 3008); // 12000 → 12032
3220        assert_eq!(align_width_for_gpu_pitch(17, 4), 32); // 68 → 128
3221        assert_eq!(align_width_for_gpu_pitch(1, 4), 16); // 4 → 64
3222    }
3223
3224    #[test]
3225    fn align_width_rgb888_packed() {
3226        // RGB888 (bpp=3, lcm(64,3)=192, so width must round to multiple of 64 px).
3227        assert_eq!(align_width_for_gpu_pitch(64, 3), 64); // 192 byte pitch
3228        assert_eq!(align_width_for_gpu_pitch(640, 3), 640); // 1920
3229        assert_eq!(align_width_for_gpu_pitch(1, 3), 64); // 3 → 192
3230        assert_eq!(align_width_for_gpu_pitch(65, 3), 128); // 195 → 384
3231                                                           // Verify the rounded width × bpp is a clean multiple of the LCM.
3232        for w in [3004usize, 1281, 100, 17] {
3233            let padded = align_width_for_gpu_pitch(w, 3);
3234            assert!(padded >= w);
3235            assert_eq!((padded * 3) % 64, 0);
3236            assert_eq!((padded * 3) % 3, 0);
3237        }
3238    }
3239
3240    #[test]
3241    fn align_width_grey_u8() {
3242        // Grey (bpp=1, lcm(64,1)=64, so width must round to multiple of 64 px).
3243        assert_eq!(align_width_for_gpu_pitch(64, 1), 64);
3244        assert_eq!(align_width_for_gpu_pitch(640, 1), 640);
3245        assert_eq!(align_width_for_gpu_pitch(1, 1), 64);
3246        assert_eq!(align_width_for_gpu_pitch(65, 1), 128);
3247    }
3248
3249    #[test]
3250    fn align_width_zero_inputs() {
3251        assert_eq!(align_width_for_gpu_pitch(0, 4), 0);
3252        assert_eq!(align_width_for_gpu_pitch(640, 0), 640);
3253    }
3254
3255    #[test]
3256    fn align_width_never_returns_smaller_than_input() {
3257        // Spot-check the "returned width >= input width" contract across a
3258        // range of values that would previously have hit `width * bpp`
3259        // overflow paths.
3260        for &bpp in &[1usize, 2, 3, 4, 8] {
3261            for &w in &[
3262                1usize,
3263                17,
3264                64,
3265                65,
3266                100,
3267                1280,
3268                1281,
3269                1920,
3270                3004,
3271                3072,
3272                3840,
3273                usize::MAX / 8,
3274                usize::MAX / 4,
3275                usize::MAX / 2,
3276                usize::MAX - 1,
3277                usize::MAX,
3278            ] {
3279                let aligned = align_width_for_gpu_pitch(w, bpp);
3280                assert!(
3281                    aligned >= w,
3282                    "align_width_for_gpu_pitch({w}, {bpp}) = {aligned} < {w}"
3283                );
3284            }
3285        }
3286    }
3287
3288    #[test]
3289    fn align_width_overflow_returns_unaligned_not_smaller() {
3290        // For width values close to usize::MAX, padding up would wrap. The
3291        // function must return the original width rather than wrapping or
3292        // panicking. A pre-aligned width round-trips unchanged even at the
3293        // extreme.
3294        let aligned_extreme = usize::MAX - 15; // 16-pixel boundary for RGBA8
3295        assert_eq!(
3296            align_width_for_gpu_pitch(aligned_extreme, 4),
3297            aligned_extreme
3298        );
3299        // A misaligned extreme value cannot be rounded up — the function
3300        // returns the original.
3301        let misaligned_extreme = usize::MAX - 1;
3302        let result = align_width_for_gpu_pitch(misaligned_extreme, 4);
3303        assert!(
3304            result == misaligned_extreme || result >= misaligned_extreme,
3305            "extreme misaligned width must not be rounded down to {result}"
3306        );
3307    }
3308
3309    #[test]
3310    fn checked_lcm_basic_and_overflow() {
3311        assert_eq!(checked_num_integer_lcm(64, 4), Some(64));
3312        assert_eq!(checked_num_integer_lcm(64, 3), Some(192));
3313        assert_eq!(checked_num_integer_lcm(64, 1), Some(64));
3314        assert_eq!(checked_num_integer_lcm(0, 4), Some(0));
3315        assert_eq!(checked_num_integer_lcm(64, 0), Some(0));
3316        // Coprime values whose product exceeds usize::MAX must return None.
3317        assert_eq!(
3318            checked_num_integer_lcm(usize::MAX, usize::MAX - 1),
3319            None,
3320            "coprime extreme values must overflow detect, not panic"
3321        );
3322    }
3323
3324    #[test]
3325    fn primary_plane_bpp_known_formats() {
3326        // Packed formats use channels × elem_size.
3327        assert_eq!(primary_plane_bpp(PixelFormat::Rgba, 1), Some(4));
3328        assert_eq!(primary_plane_bpp(PixelFormat::Bgra, 1), Some(4));
3329        assert_eq!(primary_plane_bpp(PixelFormat::Rgb, 1), Some(3));
3330        assert_eq!(primary_plane_bpp(PixelFormat::Grey, 1), Some(1));
3331        // Semi-planar (NV12) reports the luma plane's bpp.
3332        assert_eq!(primary_plane_bpp(PixelFormat::Nv12, 1), Some(1));
3333    }
3334}
3335
3336#[cfg(test)]
3337#[cfg_attr(coverage_nightly, coverage(off))]
3338#[allow(deprecated)]
3339mod image_tests {
3340    use super::*;
3341    use crate::{CPUProcessor, Rotation};
3342    #[cfg(target_os = "linux")]
3343    use edgefirst_tensor::is_dma_available;
3344    use edgefirst_tensor::{TensorMapTrait, TensorMemory, TensorTrait};
3345    use image::buffer::ConvertBuffer;
3346
3347    /// Test helper: call `ImageProcessorTrait::convert()` on two `TensorDyn`s
3348    /// by going through the `TensorDyn` API.
3349    ///
3350    /// Returns the `(src_image, dst_image)` reconstructed from the TensorDyn
3351    /// round-trip so the caller can feed them to `compare_images` etc.
3352    fn convert_img(
3353        proc: &mut dyn ImageProcessorTrait,
3354        src: TensorDyn,
3355        dst: TensorDyn,
3356        rotation: Rotation,
3357        flip: Flip,
3358        crop: Crop,
3359    ) -> (Result<()>, TensorDyn, TensorDyn) {
3360        let src_fourcc = src.format().unwrap();
3361        let dst_fourcc = dst.format().unwrap();
3362        let src_dyn = src;
3363        let mut dst_dyn = dst;
3364        let result = proc.convert(&src_dyn, &mut dst_dyn, rotation, flip, crop);
3365        let src_back = {
3366            let mut __t = src_dyn.into_u8().unwrap();
3367            __t.set_format(src_fourcc).unwrap();
3368            TensorDyn::from(__t)
3369        };
3370        let dst_back = {
3371            let mut __t = dst_dyn.into_u8().unwrap();
3372            __t.set_format(dst_fourcc).unwrap();
3373            TensorDyn::from(__t)
3374        };
3375        (result, src_back, dst_back)
3376    }
3377
3378    #[ctor::ctor(unsafe)]
3379    fn init() {
3380        env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
3381    }
3382
3383    macro_rules! function {
3384        () => {{
3385            fn f() {}
3386            fn type_name_of<T>(_: T) -> &'static str {
3387                std::any::type_name::<T>()
3388            }
3389            let name = type_name_of(f);
3390
3391            // Find and cut the rest of the path
3392            match &name[..name.len() - 3].rfind(':') {
3393                Some(pos) => &name[pos + 1..name.len() - 3],
3394                None => &name[..name.len() - 3],
3395            }
3396        }};
3397    }
3398
3399    /// Master oracle for the view/batch **destination** batch engine: render `N`
3400    /// tiles into row-bands of ONE tall destination and assert each band equals
3401    /// the same source converted standalone — proving correct band placement and
3402    /// that a later tile's letterbox clear / draw never wipes a sibling band. On
3403    /// the Linux GL backend the `N` `convert_deferred` calls share ONE parent
3404    /// EGLImage import (each tile is a `glViewport`/`glScissor` ROI) and sync
3405    /// once at `flush()`; other backends fall back to an eager per-band convert
3406    /// (CPU writes via offset + parent stride). Either way the oracle must hold.
3407    ///
3408    /// Identical source/tile size makes the convert an exact copy, so the
3409    /// assertion is backend-agnostic (no GL-vs-CPU resampling drift). Distinct
3410    /// solid colors per tile make any sibling wipe a hard failure.
3411    #[test]
3412    fn batch_view_dst_tiles_match_standalone() {
3413        let mut proc = match ImageProcessor::new() {
3414            Ok(p) => p,
3415            Err(e) => {
3416                eprintln!(
3417                    "SKIPPED: {} — ImageProcessor init failed ({e:?})",
3418                    function!()
3419                );
3420                return;
3421            }
3422        };
3423        let n = 3usize;
3424        let (w, h) = (32usize, 24usize);
3425        let colors: [[u8; 4]; 3] = [[210, 40, 40, 255], [40, 210, 40, 255], [40, 40, 210, 255]];
3426        let make_src = |c: [u8; 4]| -> TensorDyn {
3427            let bytes: Vec<u8> = c.iter().copied().cycle().take(w * h * 4).collect();
3428            load_bytes_to_tensor(w, h, PixelFormat::Rgba, Some(TensorMemory::Mem), &bytes).unwrap()
3429        };
3430        // Tall destination: N stacked row-bands. DMA so the Linux GL band path
3431        // runs (one parent import + per-tile glViewport); skip if unavailable.
3432        let parent = match TensorDyn::image(
3433            w,
3434            n * h,
3435            PixelFormat::Rgba,
3436            DType::U8,
3437            Some(TensorMemory::Dma),
3438            edgefirst_tensor::CpuAccess::ReadWrite,
3439        ) {
3440            Ok(d) => d,
3441            Err(e) => {
3442                eprintln!(
3443                    "SKIPPED: {} — tall DMA destination alloc failed ({e:?})",
3444                    function!()
3445                );
3446                return;
3447            }
3448        };
3449
3450        // Deferred batch: one parent import, glViewport/scissor per band, one sync.
3451        for (i, &c) in colors.iter().enumerate().take(n) {
3452            let mut tile = parent.view(Region::new(0, i * h, w, h)).unwrap();
3453            proc.convert_deferred(
3454                &make_src(c),
3455                &mut tile,
3456                Rotation::None,
3457                Flip::None,
3458                Crop::no_crop(),
3459            )
3460            .unwrap_or_else(|e| panic!("convert_deferred tile {i}: {e:?}"));
3461        }
3462        proc.flush().unwrap();
3463
3464        for (i, &c) in colors.iter().enumerate().take(n) {
3465            // Standalone full-buffer convert of the same source = the oracle.
3466            let mut solo = TensorDyn::image(
3467                w,
3468                h,
3469                PixelFormat::Rgba,
3470                DType::U8,
3471                Some(TensorMemory::Dma),
3472                edgefirst_tensor::CpuAccess::ReadWrite,
3473            )
3474            .unwrap();
3475            proc.convert(
3476                &make_src(c),
3477                &mut solo,
3478                Rotation::None,
3479                Flip::None,
3480                Crop::no_crop(),
3481            )
3482            .unwrap();
3483
3484            let band = parent.view(Region::new(0, i * h, w, h)).unwrap();
3485            let band_bytes = band.as_u8().unwrap().map().unwrap().as_slice().to_vec();
3486            let solo_bytes = solo.as_u8().unwrap().map().unwrap().as_slice().to_vec();
3487            assert_eq!(
3488                band_bytes, solo_bytes,
3489                "tile {i}: band differs from standalone convert (placement or sibling wipe)"
3490            );
3491            assert!(
3492                band_bytes.chunks_exact(4).all(|p| p == c),
3493                "tile {i}: band is not the expected solid color {c:?} (sibling wipe?)"
3494            );
3495        }
3496    }
3497
3498    /// Build a `w`×`h` RGBA frame whose pixels encode their position, so a crop
3499    /// at any origin has distinct content (catches crop-origin / band-placement
3500    /// bugs a solid color cannot).
3501    #[cfg(test)]
3502    fn gradient_frame(w: usize, h: usize) -> TensorDyn {
3503        let mut bytes = vec![0u8; w * h * 4];
3504        for y in 0..h {
3505            for x in 0..w {
3506                let i = (y * w + x) * 4;
3507                bytes[i] = x as u8;
3508                bytes[i + 1] = y as u8;
3509                bytes[i + 2] = (x ^ y) as u8;
3510                bytes[i + 3] = 255;
3511            }
3512        }
3513        load_bytes_to_tensor(w, h, PixelFormat::Rgba, Some(TensorMemory::Mem), &bytes).unwrap()
3514    }
3515
3516    /// `tile_into` band == standalone crop-convert of the same source region,
3517    /// on the CPU backend (runs on CI without a GPU). Distinct gradient content
3518    /// makes a wrong crop origin or a sibling-band wipe a hard failure.
3519    #[test]
3520    fn tile_into_cpu_distinct_content_parity() {
3521        let mut proc = match ImageProcessor::with_config(ImageProcessorConfig {
3522            backend: ComputeBackend::Cpu,
3523            ..Default::default()
3524        }) {
3525            Ok(p) => p,
3526            Err(e) => {
3527                eprintln!("SKIPPED: {} — CPU init failed ({e:?})", function!());
3528                return;
3529            }
3530        };
3531        let (fw, fh) = (96usize, 64usize);
3532        let src = gradient_frame(fw, fh);
3533        let cfg = TilingConfig::new(32, 32).with_overlap(0.0); // exact 3×2 tiling
3534        let n = tile_grid(fh, fw, 32, 32, 0.0).len();
3535        assert_eq!(n, 6);
3536
3537        let mut parent = proc
3538            .alloc_tile_batch(
3539                n,
3540                &cfg,
3541                PixelFormat::Rgba,
3542                DType::U8,
3543                Some(TensorMemory::Mem),
3544                edgefirst_tensor::CpuAccess::ReadWrite,
3545            )
3546            .unwrap();
3547        let placements = proc.tile_into(&src, &mut parent, &cfg).unwrap();
3548        assert_eq!(placements.len(), n);
3549
3550        for p in &placements {
3551            let source = Region::new(
3552                p.origin.0 as usize,
3553                p.origin.1 as usize,
3554                p.crop_size.0 as usize,
3555                p.crop_size.1 as usize,
3556            );
3557            let mut solo = TensorDyn::image(
3558                32,
3559                32,
3560                PixelFormat::Rgba,
3561                DType::U8,
3562                Some(TensorMemory::Mem),
3563                edgefirst_tensor::CpuAccess::ReadWrite,
3564            )
3565            .unwrap();
3566            proc.convert(
3567                &src,
3568                &mut solo,
3569                Rotation::None,
3570                Flip::None,
3571                Crop::default()
3572                    .with_source(Some(source))
3573                    .with_fit(Fit::Stretch),
3574            )
3575            .unwrap();
3576            let band = parent.view(Region::new(0, p.index * 32, 32, 32)).unwrap();
3577            let band_bytes = band.as_u8().unwrap().map().unwrap().as_slice().to_vec();
3578            let solo_bytes = solo.as_u8().unwrap().map().unwrap().as_slice().to_vec();
3579            assert_eq!(
3580                band_bytes, solo_bytes,
3581                "tile {} band differs from standalone crop-convert",
3582                p.index
3583            );
3584        }
3585    }
3586
3587    /// Streaming `tile_one` into a single slot == the corresponding batched
3588    /// `tile_into` band (proves the two paths agree).
3589    #[test]
3590    fn tile_one_matches_tile_into_band() {
3591        let mut proc = match ImageProcessor::with_config(ImageProcessorConfig {
3592            backend: ComputeBackend::Cpu,
3593            ..Default::default()
3594        }) {
3595            Ok(p) => p,
3596            Err(e) => {
3597                eprintln!("SKIPPED: {} — CPU init failed ({e:?})", function!());
3598                return;
3599            }
3600        };
3601        let (fw, fh) = (96usize, 64usize);
3602        let src = gradient_frame(fw, fh);
3603        let cfg = TilingConfig::new(32, 32).with_overlap(0.0);
3604        let n = tile_grid(fh, fw, 32, 32, 0.0).len();
3605
3606        let mut parent = proc
3607            .alloc_tile_batch(
3608                n,
3609                &cfg,
3610                PixelFormat::Rgba,
3611                DType::U8,
3612                Some(TensorMemory::Mem),
3613                edgefirst_tensor::CpuAccess::ReadWrite,
3614            )
3615            .unwrap();
3616        proc.tile_into(&src, &mut parent, &cfg).unwrap();
3617
3618        let plan = proc.plan_tiles(fw, fh, &cfg).unwrap();
3619        for p in &plan {
3620            let mut slot = TensorDyn::image(
3621                32,
3622                32,
3623                PixelFormat::Rgba,
3624                DType::U8,
3625                Some(TensorMemory::Mem),
3626                edgefirst_tensor::CpuAccess::ReadWrite,
3627            )
3628            .unwrap();
3629            proc.tile_one(&src, &mut slot, p, &cfg).unwrap();
3630            proc.flush().unwrap();
3631            let band = parent.view(Region::new(0, p.index * 32, 32, 32)).unwrap();
3632            let slot_bytes = slot.as_u8().unwrap().map().unwrap().as_slice().to_vec();
3633            let band_bytes = band.as_u8().unwrap().map().unwrap().as_slice().to_vec();
3634            assert_eq!(
3635                slot_bytes, band_bytes,
3636                "tile {} stream != batch band",
3637                p.index
3638            );
3639        }
3640    }
3641
3642    /// `plan_tiles` returns correct per-tile metadata for a 4K frame (pure, no
3643    /// GPU render).
3644    /// `tile_into` through the auto backend with a DMA parent — exercises the
3645    /// GL single-import band path on a GPU machine (skips when DMA/GL is
3646    /// unavailable). The source is sampled via `Crop::with_source`, never
3647    /// `src.view()`, so all tiles share one source import.
3648    #[test]
3649    fn tile_into_auto_dma_parity() {
3650        let mut proc = match ImageProcessor::new() {
3651            Ok(p) => p,
3652            Err(e) => {
3653                eprintln!(
3654                    "SKIPPED: {} — ImageProcessor init failed ({e:?})",
3655                    function!()
3656                );
3657                return;
3658            }
3659        };
3660        let (fw, fh) = (96usize, 64usize);
3661        let src = gradient_frame(fw, fh);
3662        let cfg = TilingConfig::new(32, 32).with_overlap(0.0);
3663        let n = tile_grid(fh, fw, 32, 32, 0.0).len();
3664
3665        let mut parent = match proc.alloc_tile_batch(
3666            n,
3667            &cfg,
3668            PixelFormat::Rgba,
3669            DType::U8,
3670            Some(TensorMemory::Dma),
3671            edgefirst_tensor::CpuAccess::ReadWrite,
3672        ) {
3673            Ok(p) => p,
3674            Err(e) => {
3675                eprintln!(
3676                    "SKIPPED: {} — tall DMA parent alloc failed ({e:?})",
3677                    function!()
3678                );
3679                return;
3680            }
3681        };
3682        let placements = proc.tile_into(&src, &mut parent, &cfg).unwrap();
3683
3684        for p in &placements {
3685            let source = Region::new(
3686                p.origin.0 as usize,
3687                p.origin.1 as usize,
3688                p.crop_size.0 as usize,
3689                p.crop_size.1 as usize,
3690            );
3691            let mut solo = TensorDyn::image(
3692                32,
3693                32,
3694                PixelFormat::Rgba,
3695                DType::U8,
3696                Some(TensorMemory::Dma),
3697                edgefirst_tensor::CpuAccess::ReadWrite,
3698            )
3699            .unwrap();
3700            proc.convert(
3701                &src,
3702                &mut solo,
3703                Rotation::None,
3704                Flip::None,
3705                Crop::default()
3706                    .with_source(Some(source))
3707                    .with_fit(Fit::Stretch),
3708            )
3709            .unwrap();
3710            let band = parent.view(Region::new(0, p.index * 32, 32, 32)).unwrap();
3711            // Structural-similarity tolerance (the house GPU-parity bar) rather
3712            // than exact bytes: rendering a tile into a viewport band at a
3713            // non-zero offset vs. a standalone origin render diverges by sampling
3714            // rounding on virtualized GPUs (paravirtual Metal/ANGLE on macOS CI),
3715            // the same tolerance class as the other cross-backend parity tests.
3716            compare_images(
3717                &band,
3718                &solo,
3719                0.98,
3720                &format!("{}_tile{}", function!(), p.index),
3721            );
3722        }
3723    }
3724
3725    /// `tile_into` rejects a destination too small to hold all tile bands.
3726    #[test]
3727    fn tile_into_undersized_dst_errors() {
3728        let mut proc = match ImageProcessor::with_config(ImageProcessorConfig {
3729            backend: ComputeBackend::Cpu,
3730            ..Default::default()
3731        }) {
3732            Ok(p) => p,
3733            Err(e) => {
3734                eprintln!("SKIPPED: {} — CPU init failed ({e:?})", function!());
3735                return;
3736            }
3737        };
3738        let (fw, fh) = (96usize, 64usize);
3739        let src = gradient_frame(fw, fh);
3740        let cfg = TilingConfig::new(32, 32).with_overlap(0.0); // 6 tiles
3741                                                               // Allocate a parent for only 2 bands, far short of 6.
3742        let mut small = TensorDyn::image(
3743            32,
3744            2 * 32,
3745            PixelFormat::Rgba,
3746            DType::U8,
3747            Some(TensorMemory::Mem),
3748            edgefirst_tensor::CpuAccess::ReadWrite,
3749        )
3750        .unwrap();
3751        let r = proc.tile_into(&src, &mut small, &cfg);
3752        assert!(
3753            matches!(r, Err(Error::InvalidShape(_))),
3754            "expected InvalidShape, got {r:?}"
3755        );
3756    }
3757
3758    /// `alloc_tile_batch` / `plan_tiles` reject an invalid config (zero tile).
3759    #[test]
3760    fn tiling_alloc_rejects_invalid_config() {
3761        let proc = match ImageProcessor::with_config(ImageProcessorConfig {
3762            backend: ComputeBackend::Cpu,
3763            ..Default::default()
3764        }) {
3765            Ok(p) => p,
3766            Err(e) => {
3767                eprintln!("SKIPPED: {} — CPU init failed ({e:?})", function!());
3768                return;
3769            }
3770        };
3771        let bad = TilingConfig::new(0, 640);
3772        assert!(proc.plan_tiles(1920, 1080, &bad).is_err());
3773        assert!(proc
3774            .alloc_tile_batch(
3775                4,
3776                &bad,
3777                PixelFormat::Rgba,
3778                DType::U8,
3779                Some(TensorMemory::Mem),
3780                edgefirst_tensor::CpuAccess::ReadWrite,
3781            )
3782            .is_err());
3783    }
3784
3785    #[test]
3786    fn plan_tiles_metadata_4k() {
3787        let proc = match ImageProcessor::with_config(ImageProcessorConfig {
3788            backend: ComputeBackend::Cpu,
3789            ..Default::default()
3790        }) {
3791            Ok(p) => p,
3792            Err(e) => {
3793                eprintln!("SKIPPED: {} — CPU init failed ({e:?})", function!());
3794                return;
3795            }
3796        };
3797        let cfg = TilingConfig::new(640, 640).with_overlap(0.2);
3798        let plan = proc.plan_tiles(3840, 2160, &cfg).unwrap();
3799        assert_eq!(plan.len(), 32);
3800        assert!(plan.iter().all(|p| p.count == 32));
3801        assert!(plan.iter().all(|p| p.crop_size == (640.0, 640.0)));
3802        assert!(plan.iter().all(|p| p.letterbox.is_none())); // stretch fit
3803        assert!(plan.iter().all(|p| p.frame_dims == (3840.0, 2160.0)));
3804        assert_eq!(plan[0].origin, (0.0, 0.0));
3805    }
3806
3807    #[test]
3808    fn test_invalid_crop() {
3809        let src = TensorDyn::image(
3810            100,
3811            100,
3812            PixelFormat::Rgb,
3813            DType::U8,
3814            None,
3815            edgefirst_tensor::CpuAccess::ReadWrite,
3816        )
3817        .unwrap();
3818        let dst = TensorDyn::image(
3819            100,
3820            100,
3821            PixelFormat::Rgb,
3822            DType::U8,
3823            None,
3824            edgefirst_tensor::CpuAccess::ReadWrite,
3825        )
3826        .unwrap();
3827
3828        // A source crop exceeding the source bounds is rejected.
3829        let crop = Crop::new().with_source(Some(Region::new(50, 50, 60, 60)));
3830        assert!(matches!(
3831            crop.check_crop_dyn(&src, &dst),
3832            Err(Error::CropInvalid(_))
3833        ));
3834
3835        // A source crop within bounds is valid.
3836        let crop = Crop::new().with_source(Some(Region::new(0, 0, 10, 10)));
3837        assert!(crop.check_crop_dyn(&src, &dst).is_ok());
3838
3839        // Letterbox is always valid — placement is computed within the dst.
3840        assert!(Crop::letterbox([0, 0, 0, 255])
3841            .check_crop_dyn(&src, &dst)
3842            .is_ok());
3843    }
3844
3845    #[test]
3846    fn test_invalid_tensor_format() -> Result<(), Error> {
3847        // 4D tensor cannot be set to a 3-channel pixel format
3848        let mut tensor = Tensor::<u8>::new(&[720, 1280, 4, 1], None, None)?;
3849        let result = tensor.set_format(PixelFormat::Rgb);
3850        assert!(result.is_err(), "4D tensor should reject set_format");
3851
3852        // Tensor with wrong channel count for the format
3853        let mut tensor = Tensor::<u8>::new(&[720, 1280, 4], None, None)?;
3854        let result = tensor.set_format(PixelFormat::Rgb);
3855        assert!(result.is_err(), "4-channel tensor should reject RGB format");
3856
3857        Ok(())
3858    }
3859
3860    #[test]
3861    fn test_invalid_image_file() -> Result<(), Error> {
3862        let result = crate::load_image_test_helper(&[123; 5000], None, None);
3863        assert!(
3864            matches!(result, Err(Error::Codec(_))),
3865            "unrecognised bytes should surface as Error::Codec, got {result:?}"
3866        );
3867        Ok(())
3868    }
3869
3870    #[test]
3871    fn test_invalid_jpeg_format() -> Result<(), Error> {
3872        let result = crate::load_image_test_helper(&[123; 5000], Some(PixelFormat::Yuyv), None);
3873        // YUYV is not a valid decode target; peek_info fails before the magic-
3874        // bytes check, so the precise variant depends on which error fires first.
3875        assert!(
3876            matches!(result, Err(Error::Codec(_))),
3877            "Yuyv target with garbage bytes should surface as Error::Codec, got {result:?}"
3878        );
3879        Ok(())
3880    }
3881
3882    #[test]
3883    fn test_load_resize_save() {
3884        let file = edgefirst_bench::testdata::read("zidane.jpg");
3885        let img = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
3886        assert_eq!(img.width(), Some(1280));
3887        assert_eq!(img.height(), Some(720));
3888
3889        let dst = TensorDyn::image(
3890            640,
3891            360,
3892            PixelFormat::Rgba,
3893            DType::U8,
3894            None,
3895            edgefirst_tensor::CpuAccess::ReadWrite,
3896        )
3897        .unwrap();
3898        let mut converter = CPUProcessor::new();
3899        let (result, _img, dst) = convert_img(
3900            &mut converter,
3901            img,
3902            dst,
3903            Rotation::None,
3904            Flip::None,
3905            Crop::no_crop(),
3906        );
3907        result.unwrap();
3908        assert_eq!(dst.width(), Some(640));
3909        assert_eq!(dst.height(), Some(360));
3910
3911        crate::save_jpeg(&dst, "zidane_resized.jpg", 80).unwrap();
3912
3913        let file = std::fs::read("zidane_resized.jpg").unwrap();
3914        // With `format: None` the helper returns the source's native format.
3915        // The codec now decodes colour JPEGs to NV12 (was RGB previously).
3916        let img = crate::load_image_test_helper(&file, None, None).unwrap();
3917        assert_eq!(img.width(), Some(640));
3918        assert_eq!(img.height(), Some(360));
3919        assert_eq!(img.format().unwrap(), PixelFormat::Nv12);
3920    }
3921
3922    #[test]
3923    fn test_from_tensor_planar() -> Result<(), Error> {
3924        let mut tensor = Tensor::new(&[3, 720, 1280], None, None)?;
3925        tensor
3926            .map()?
3927            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.8bps"));
3928        let planar = {
3929            tensor
3930                .set_format(PixelFormat::PlanarRgb)
3931                .map_err(|e| crate::Error::Internal(e.to_string()))?;
3932            TensorDyn::from(tensor)
3933        };
3934
3935        let rbga = load_bytes_to_tensor(
3936            1280,
3937            720,
3938            PixelFormat::Rgba,
3939            None,
3940            &edgefirst_bench::testdata::read("camera720p.rgba"),
3941        )?;
3942        compare_images_convert_to_rgb(&planar, &rbga, 0.98, function!());
3943
3944        Ok(())
3945    }
3946
3947    #[test]
3948    fn test_from_tensor_invalid_format() {
3949        // PixelFormat::from_fourcc_str returns None for unknown FourCC codes.
3950        // Since there's no "TEST" pixel format, this validates graceful handling.
3951        assert!(PixelFormat::from_fourcc(u32::from_le_bytes(*b"TEST")).is_none());
3952    }
3953
3954    #[test]
3955    #[should_panic(expected = "Failed to save planar RGB image")]
3956    fn test_save_planar() {
3957        let planar_img = load_bytes_to_tensor(
3958            1280,
3959            720,
3960            PixelFormat::PlanarRgb,
3961            None,
3962            &edgefirst_bench::testdata::read("camera720p.8bps"),
3963        )
3964        .unwrap();
3965
3966        let save_path = "/tmp/planar_rgb.jpg";
3967        crate::save_jpeg(&planar_img, save_path, 90).expect("Failed to save planar RGB image");
3968    }
3969
3970    #[test]
3971    #[should_panic(expected = "Failed to save YUYV image")]
3972    fn test_save_yuyv() {
3973        let planar_img = load_bytes_to_tensor(
3974            1280,
3975            720,
3976            PixelFormat::Yuyv,
3977            None,
3978            &edgefirst_bench::testdata::read("camera720p.yuyv"),
3979        )
3980        .unwrap();
3981
3982        let save_path = "/tmp/yuyv.jpg";
3983        crate::save_jpeg(&planar_img, save_path, 90).expect("Failed to save YUYV image");
3984    }
3985
3986    #[test]
3987    fn test_rotation_angle() {
3988        assert_eq!(Rotation::from_degrees_clockwise(0), Rotation::None);
3989        assert_eq!(Rotation::from_degrees_clockwise(90), Rotation::Clockwise90);
3990        assert_eq!(Rotation::from_degrees_clockwise(180), Rotation::Rotate180);
3991        assert_eq!(
3992            Rotation::from_degrees_clockwise(270),
3993            Rotation::CounterClockwise90
3994        );
3995        assert_eq!(Rotation::from_degrees_clockwise(360), Rotation::None);
3996        assert_eq!(Rotation::from_degrees_clockwise(450), Rotation::Clockwise90);
3997        assert_eq!(Rotation::from_degrees_clockwise(540), Rotation::Rotate180);
3998        assert_eq!(
3999            Rotation::from_degrees_clockwise(630),
4000            Rotation::CounterClockwise90
4001        );
4002    }
4003
4004    #[test]
4005    #[should_panic(expected = "rotation angle is not a multiple of 90")]
4006    fn test_rotation_angle_panic() {
4007        Rotation::from_degrees_clockwise(361);
4008    }
4009
4010    #[test]
4011    fn test_disable_env_var() -> Result<(), Error> {
4012        // Acquire the env-var mutex for the entire test body so we never race
4013        // with test_force_backend_* or test_draw_proto_masks_no_cpu_returns_error.
4014        let _lock = acquire_env_lock();
4015
4016        // Snapshot ALL env vars we might touch so the RAII guard restores them
4017        // on exit (even on panic), preventing env-var poisoning of other tests.
4018        let _guard = EnvGuard::snapshot(&[
4019            "EDGEFIRST_FORCE_BACKEND",
4020            "EDGEFIRST_DISABLE_GL",
4021            "EDGEFIRST_DISABLE_G2D",
4022            "EDGEFIRST_DISABLE_CPU",
4023        ]);
4024
4025        // EDGEFIRST_FORCE_BACKEND takes precedence over EDGEFIRST_DISABLE_*,
4026        // so clear it for the duration of this test.
4027        unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") };
4028
4029        #[cfg(target_os = "linux")]
4030        {
4031            unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
4032            let converter = ImageProcessor::new()?;
4033            assert!(converter.g2d.is_none());
4034            unsafe { std::env::remove_var("EDGEFIRST_DISABLE_G2D") };
4035        }
4036
4037        #[cfg(target_os = "linux")]
4038        #[cfg(feature = "opengl")]
4039        {
4040            unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
4041            let converter = ImageProcessor::new()?;
4042            assert!(converter.opengl.is_none());
4043            unsafe { std::env::remove_var("EDGEFIRST_DISABLE_GL") };
4044        }
4045
4046        unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
4047        let converter = ImageProcessor::new()?;
4048        assert!(converter.cpu.is_none());
4049        unsafe { std::env::remove_var("EDGEFIRST_DISABLE_CPU") };
4050
4051        // Disable everything — convert must return NoConverter.
4052        unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
4053        unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
4054        unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
4055        let mut converter = ImageProcessor::new()?;
4056
4057        let src = TensorDyn::image(
4058            1280,
4059            720,
4060            PixelFormat::Rgba,
4061            DType::U8,
4062            None,
4063            edgefirst_tensor::CpuAccess::ReadWrite,
4064        )?;
4065        let dst = TensorDyn::image(
4066            640,
4067            360,
4068            PixelFormat::Rgba,
4069            DType::U8,
4070            None,
4071            edgefirst_tensor::CpuAccess::ReadWrite,
4072        )?;
4073        let (result, _src, _dst) = convert_img(
4074            &mut converter,
4075            src,
4076            dst,
4077            Rotation::None,
4078            Flip::None,
4079            Crop::no_crop(),
4080        );
4081        assert!(matches!(result, Err(Error::NoConverter)));
4082        // _guard restores all env vars on drop.
4083        Ok(())
4084    }
4085
4086    #[test]
4087    fn test_unsupported_conversion() {
4088        let src = TensorDyn::image(
4089            1280,
4090            720,
4091            PixelFormat::Nv12,
4092            DType::U8,
4093            None,
4094            edgefirst_tensor::CpuAccess::ReadWrite,
4095        )
4096        .unwrap();
4097        let dst = TensorDyn::image(
4098            640,
4099            360,
4100            PixelFormat::Nv12,
4101            DType::U8,
4102            None,
4103            edgefirst_tensor::CpuAccess::ReadWrite,
4104        )
4105        .unwrap();
4106        let mut converter = ImageProcessor::new().unwrap();
4107        let (result, _src, _dst) = convert_img(
4108            &mut converter,
4109            src,
4110            dst,
4111            Rotation::None,
4112            Flip::None,
4113            Crop::no_crop(),
4114        );
4115        log::debug!("result: {:?}", result);
4116        assert!(matches!(
4117            result,
4118            Err(Error::NotSupported(e)) if e.starts_with("Conversion from NV12 to NV12")
4119        ));
4120    }
4121
4122    #[test]
4123    fn test_load_grey() {
4124        // A single-component (greyscale) JPEG decodes to its native GREY
4125        // format, which has no even-dimension constraint, so the 1024×681
4126        // `grey.jpg` loads and converts to RGBA successfully.
4127        let grey_img = crate::load_image_test_helper(
4128            &edgefirst_bench::testdata::read("grey.jpg"),
4129            Some(PixelFormat::Rgba),
4130            None,
4131        )
4132        .unwrap();
4133        assert_eq!(grey_img.width(), Some(1024));
4134        assert_eq!(grey_img.height(), Some(681));
4135
4136        // `grey-rgb.jpg` holds the same grey content but is encoded as a
4137        // 3-component (colour) JPEG, so the codec decodes it to native NV12.
4138        // Its 1024×681 dimensions have an odd height; NV12 now represents odd
4139        // dimensions via the `H + ceil(H/2)` combined-plane height, so the
4140        // decode succeeds and converts to RGBA at the true dimensions.
4141        let grey_but_rgb = crate::load_image_test_helper(
4142            &edgefirst_bench::testdata::read("grey-rgb.jpg"),
4143            Some(PixelFormat::Rgba),
4144            None,
4145        )
4146        .expect("odd-height colour JPEG should decode to NV12 and convert to RGBA");
4147        assert_eq!(grey_but_rgb.width(), Some(1024));
4148        assert_eq!(grey_but_rgb.height(), Some(681));
4149    }
4150
4151    #[test]
4152    fn test_new_nv12() {
4153        let nv12 = TensorDyn::image(
4154            1280,
4155            720,
4156            PixelFormat::Nv12,
4157            DType::U8,
4158            None,
4159            edgefirst_tensor::CpuAccess::ReadWrite,
4160        )
4161        .unwrap();
4162        assert_eq!(nv12.height(), Some(720));
4163        assert_eq!(nv12.width(), Some(1280));
4164        assert_eq!(nv12.format().unwrap(), PixelFormat::Nv12);
4165        // PixelFormat::Nv12.channels() returns 1 (luma plane channel count)
4166        assert_eq!(nv12.format().unwrap().channels(), 1);
4167        assert!(nv12.format().is_some_and(
4168            |f| f.layout() == PixelLayout::Planar || f.layout() == PixelLayout::SemiPlanar
4169        ))
4170    }
4171
4172    #[test]
4173    #[cfg(target_os = "linux")]
4174    fn test_new_image_converter() {
4175        let dst_width = 640;
4176        let dst_height = 360;
4177        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4178        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4179
4180        let mut converter = ImageProcessor::new().unwrap();
4181        let converter_dst = converter
4182            .create_image(
4183                dst_width,
4184                dst_height,
4185                PixelFormat::Rgba,
4186                DType::U8,
4187                None,
4188                edgefirst_tensor::CpuAccess::ReadWrite,
4189            )
4190            .unwrap();
4191        let (result, src, converter_dst) = convert_img(
4192            &mut converter,
4193            src,
4194            converter_dst,
4195            Rotation::None,
4196            Flip::None,
4197            Crop::no_crop(),
4198        );
4199        result.unwrap();
4200
4201        let cpu_dst = TensorDyn::image(
4202            dst_width,
4203            dst_height,
4204            PixelFormat::Rgba,
4205            DType::U8,
4206            None,
4207            edgefirst_tensor::CpuAccess::ReadWrite,
4208        )
4209        .unwrap();
4210        let mut cpu_converter = CPUProcessor::new();
4211        let (result, _src, cpu_dst) = convert_img(
4212            &mut cpu_converter,
4213            src,
4214            cpu_dst,
4215            Rotation::None,
4216            Flip::None,
4217            Crop::no_crop(),
4218        );
4219        result.unwrap();
4220
4221        compare_images(&converter_dst, &cpu_dst, 0.98, function!());
4222    }
4223
4224    #[test]
4225    #[cfg(target_os = "linux")]
4226    fn test_create_image_dtype_i8() {
4227        let mut converter = ImageProcessor::new().unwrap();
4228
4229        // I8 image should allocate successfully via create_image
4230        let dst = converter
4231            .create_image(
4232                320,
4233                240,
4234                PixelFormat::Rgb,
4235                DType::I8,
4236                None,
4237                edgefirst_tensor::CpuAccess::ReadWrite,
4238            )
4239            .unwrap();
4240        assert_eq!(dst.dtype(), DType::I8);
4241        assert!(dst.width() == Some(320));
4242        assert!(dst.height() == Some(240));
4243        assert_eq!(dst.format(), Some(PixelFormat::Rgb));
4244
4245        // U8 for comparison
4246        let dst_u8 = converter
4247            .create_image(
4248                320,
4249                240,
4250                PixelFormat::Rgb,
4251                DType::U8,
4252                None,
4253                edgefirst_tensor::CpuAccess::ReadWrite,
4254            )
4255            .unwrap();
4256        assert_eq!(dst_u8.dtype(), DType::U8);
4257
4258        // Convert into I8 dst should succeed
4259        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4260        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4261        let mut dst_i8 = converter
4262            .create_image(
4263                320,
4264                240,
4265                PixelFormat::Rgb,
4266                DType::I8,
4267                None,
4268                edgefirst_tensor::CpuAccess::ReadWrite,
4269            )
4270            .unwrap();
4271        converter
4272            .convert(
4273                &src,
4274                &mut dst_i8,
4275                Rotation::None,
4276                Flip::None,
4277                Crop::no_crop(),
4278            )
4279            .unwrap();
4280    }
4281
4282    #[test]
4283    #[cfg(target_os = "linux")]
4284    fn test_create_image_nv12_dma_non_aligned_width() {
4285        // create_image is fully stride-aware: a non-64-aligned NV12 DMA tensor
4286        // may legitimately carry a GPU-pitch-padded row stride — that is the
4287        // intended behaviour, not a bug. Verify the logical geometry is preserved
4288        // for any width and that a reported stride is a valid (>= logical)
4289        // padding, rather than asserting the absence of a stride.
4290        let converter = ImageProcessor::new().unwrap();
4291
4292        // 100 is intentionally not a multiple of 64 (the GPU pitch alignment).
4293        let result = converter.create_image(
4294            100,
4295            64,
4296            PixelFormat::Nv12,
4297            DType::U8,
4298            Some(TensorMemory::Dma),
4299            edgefirst_tensor::CpuAccess::ReadWrite,
4300        );
4301
4302        match result {
4303            Ok(img) => {
4304                assert_eq!(img.width(), Some(100));
4305                assert_eq!(img.height(), Some(64));
4306                assert_eq!(img.format(), Some(PixelFormat::Nv12));
4307                if let Some(stride) = img.row_stride() {
4308                    assert!(
4309                        stride >= 100,
4310                        "NV12 row_stride {stride} must be >= the logical width (100)",
4311                    );
4312                }
4313            }
4314            Err(e) => {
4315                // Skip cleanly on hosts without a dma-heap.
4316                eprintln!("SKIPPED: create_image NV12 DMA non-aligned width: {e}");
4317            }
4318        }
4319    }
4320
4321    #[test]
4322    #[ignore] // Hangs on desktop platforms where DMA-buf is unavailable and PBO
4323              // fallback triggers a GPU driver hang during SHM→texture upload (e.g.,
4324              // NVIDIA without /dev/dma_heap permissions). Works on embedded targets.
4325    fn test_crop_skip() {
4326        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4327        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4328
4329        let mut converter = ImageProcessor::new().unwrap();
4330        let converter_dst = converter
4331            .create_image(
4332                1280,
4333                720,
4334                PixelFormat::Rgba,
4335                DType::U8,
4336                None,
4337                edgefirst_tensor::CpuAccess::ReadWrite,
4338            )
4339            .unwrap();
4340        let crop = Crop::new().with_source(Some(Region::new(0, 0, 640, 640)));
4341        let (result, src, converter_dst) = convert_img(
4342            &mut converter,
4343            src,
4344            converter_dst,
4345            Rotation::None,
4346            Flip::None,
4347            crop,
4348        );
4349        result.unwrap();
4350
4351        let cpu_dst = TensorDyn::image(
4352            1280,
4353            720,
4354            PixelFormat::Rgba,
4355            DType::U8,
4356            None,
4357            edgefirst_tensor::CpuAccess::ReadWrite,
4358        )
4359        .unwrap();
4360        let mut cpu_converter = CPUProcessor::new();
4361        let (result, _src, cpu_dst) = convert_img(
4362            &mut cpu_converter,
4363            src,
4364            cpu_dst,
4365            Rotation::None,
4366            Flip::None,
4367            crop,
4368        );
4369        result.unwrap();
4370
4371        compare_images(&converter_dst, &cpu_dst, 0.99999, function!());
4372    }
4373
4374    #[test]
4375    fn test_invalid_pixel_format() {
4376        // PixelFormat::from_fourcc returns None for unknown formats,
4377        // so TensorDyn::image cannot be called with an invalid format.
4378        assert!(PixelFormat::from_fourcc(u32::from_le_bytes(*b"TEST")).is_none());
4379    }
4380
4381    // Helper function to check if G2D library is available (Linux/i.MX8 only)
4382    #[cfg(target_os = "linux")]
4383    static G2D_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
4384
4385    #[cfg(target_os = "linux")]
4386    fn is_g2d_available() -> bool {
4387        *G2D_AVAILABLE.get_or_init(|| G2DProcessor::new().is_ok())
4388    }
4389
4390    #[cfg(target_os = "linux")]
4391    #[cfg(feature = "opengl")]
4392    static GL_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
4393
4394    #[cfg(target_os = "linux")]
4395    #[cfg(feature = "opengl")]
4396    // Helper function to check if OpenGL is available
4397    fn is_opengl_available() -> bool {
4398        #[cfg(all(target_os = "linux", feature = "opengl"))]
4399        {
4400            *GL_AVAILABLE.get_or_init(|| GLProcessorThreaded::new(None).is_ok())
4401        }
4402
4403        #[cfg(not(all(target_os = "linux", feature = "opengl")))]
4404        {
4405            false
4406        }
4407    }
4408
4409    /// CI canary: fails the lane when the GL backend cannot initialize.
4410    ///
4411    /// Every GL test in this suite self-skips when the backend is
4412    /// unavailable — correct for developer machines, but it means a broken
4413    /// CI GL stack (e.g. the macOS ANGLE re-sign step regressing, the exact
4414    /// failure mode documented in the workflow) ships an untested GL
4415    /// backend behind a green lane. Gated on `HAL_TEST_REQUIRE_GL=1`, set
4416    /// only by CI jobs that install a working GL stack; local runs without
4417    /// one pass trivially. On macOS it additionally requires
4418    /// `HAL_TEST_ALLOW_DLOPEN_ANGLE`, so coverage pass 1 (unsigned
4419    /// binaries, dlopen gate closed) skips it and pass 2 (signed) enforces.
4420    #[test]
4421    #[cfg(feature = "opengl")]
4422    fn gl_backend_available_canary() {
4423        let require_gl = std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1");
4424        if !require_gl {
4425            eprintln!(
4426                "SKIPPED: {} — HAL_TEST_REQUIRE_GL is not set to 1",
4427                function!()
4428            );
4429            return;
4430        }
4431        #[cfg(target_os = "macos")]
4432        if std::env::var_os("HAL_TEST_ALLOW_DLOPEN_ANGLE").is_none() {
4433            eprintln!(
4434                "SKIPPED: {} — ANGLE dlopen gate closed (coverage pass 1)",
4435                function!()
4436            );
4437            return;
4438        }
4439        GLProcessorThreaded::new(None).expect(
4440            "HAL_TEST_REQUIRE_GL=1 but the GL backend failed to initialize — \
4441             check the ANGLE install/re-sign step and binary entitlements \
4442             (macOS) or the EGL stack (Linux)",
4443        );
4444    }
4445
4446    #[test]
4447    fn test_load_jpeg_with_exif() {
4448        use edgefirst_codec::peek_info;
4449
4450        // The migrated codec NEVER applies EXIF orientation: it decodes to the
4451        // source's native (un-rotated) dimensions and reports the rotation via
4452        // ImageInfo. `zidane_rotated_exif.jpg` carries EXIF orientation 6
4453        // (90° clockwise) over a 1280×720 frame.
4454        let file = edgefirst_bench::testdata::read("zidane_rotated_exif.jpg").to_vec();
4455        let info = peek_info(&file).unwrap();
4456        assert_eq!(info.rotation_degrees, 90);
4457        assert!(!info.flip_horizontal);
4458
4459        let loaded = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4460        // Native (un-rotated) dimensions — the decode does not rotate.
4461        assert_eq!(loaded.width(), Some(1280));
4462        assert_eq!(loaded.height(), Some(720));
4463
4464        // Applying the reported rotation downstream reproduces the upright
4465        // image: it matches `zidane.jpg` rotated by the same 90° clockwise.
4466        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4467        let cpu_src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4468
4469        let rotation = Rotation::from_degrees_clockwise(info.rotation_degrees as usize);
4470        let (dst_width, dst_height) = (cpu_src.height().unwrap(), cpu_src.width().unwrap());
4471
4472        let cpu_dst = TensorDyn::image(
4473            dst_width,
4474            dst_height,
4475            PixelFormat::Rgba,
4476            DType::U8,
4477            None,
4478            edgefirst_tensor::CpuAccess::ReadWrite,
4479        )
4480        .unwrap();
4481        let mut cpu_converter = CPUProcessor::new();
4482
4483        // Rotate the native-orientation `loaded` frame and the native `zidane`
4484        // frame by the same reported rotation; the results must agree.
4485        let loaded_rotated = TensorDyn::image(
4486            dst_width,
4487            dst_height,
4488            PixelFormat::Rgba,
4489            DType::U8,
4490            None,
4491            edgefirst_tensor::CpuAccess::ReadWrite,
4492        )
4493        .unwrap();
4494        let (r0, _loaded, loaded_rotated) = convert_img(
4495            &mut cpu_converter,
4496            loaded,
4497            loaded_rotated,
4498            rotation,
4499            Flip::None,
4500            Crop::no_crop(),
4501        );
4502        r0.unwrap();
4503
4504        let (result, _cpu_src, cpu_dst) = convert_img(
4505            &mut cpu_converter,
4506            cpu_src,
4507            cpu_dst,
4508            rotation,
4509            Flip::None,
4510            Crop::no_crop(),
4511        );
4512        result.unwrap();
4513
4514        compare_images(&loaded_rotated, &cpu_dst, 0.98, function!());
4515    }
4516
4517    #[test]
4518    fn test_load_png_with_exif() {
4519        use edgefirst_codec::peek_info;
4520
4521        // PNGs also report EXIF orientation without applying it.
4522        // `zidane_rotated_exif_180.png` carries EXIF orientation 3 (180°).
4523        let file = edgefirst_bench::testdata::read("zidane_rotated_exif_180.png").to_vec();
4524        let info = peek_info(&file).unwrap();
4525        assert_eq!(info.rotation_degrees, 180);
4526        assert!(!info.flip_horizontal);
4527
4528        let loaded = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4529        // Native (un-rotated) dimensions — PNG decodes upright as authored.
4530        assert_eq!(loaded.height(), Some(720));
4531        assert_eq!(loaded.width(), Some(1280));
4532
4533        // The PNG fixture stores upright `zidane` pixels tagged with a 180°
4534        // EXIF orientation. Because the codec no longer applies the rotation,
4535        // the decoded pixels match `zidane.jpg` directly (no convert needed).
4536        // Re-applying the reported rotation to both must still agree.
4537        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4538        let cpu_src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4539
4540        let rotation = Rotation::from_degrees_clockwise(info.rotation_degrees as usize);
4541        let cpu_dst = TensorDyn::image(
4542            1280,
4543            720,
4544            PixelFormat::Rgba,
4545            DType::U8,
4546            None,
4547            edgefirst_tensor::CpuAccess::ReadWrite,
4548        )
4549        .unwrap();
4550        let mut cpu_converter = CPUProcessor::new();
4551
4552        let (result, _cpu_src, cpu_dst) = convert_img(
4553            &mut cpu_converter,
4554            cpu_src,
4555            cpu_dst,
4556            rotation,
4557            Flip::None,
4558            Crop::no_crop(),
4559        );
4560        result.unwrap();
4561
4562        // Rotate the decoded PNG by the same reported angle so both frames are
4563        // in the same (180°-rotated) orientation before comparing.
4564        let loaded_rotated = TensorDyn::image(
4565            1280,
4566            720,
4567            PixelFormat::Rgba,
4568            DType::U8,
4569            None,
4570            edgefirst_tensor::CpuAccess::ReadWrite,
4571        )
4572        .unwrap();
4573        let (r0, _loaded, loaded_rotated) = convert_img(
4574            &mut cpu_converter,
4575            loaded,
4576            loaded_rotated,
4577            rotation,
4578            Flip::None,
4579            Crop::no_crop(),
4580        );
4581        r0.unwrap();
4582
4583        // Threshold 0.95 (was 0.98): `loaded` comes from a lossless PNG decode
4584        // while `cpu_src` (zidane.jpg) now decodes through native NV12 (chroma
4585        // subsampling) before the RGBA conversion, so the two paths differ by a
4586        // couple of percent versus the old direct-RGB JPEG decode.
4587        compare_images(&loaded_rotated, &cpu_dst, 0.95, function!());
4588    }
4589
4590    /// Synthesise an RGB JPEG with a deterministic pattern at `(width, height)`
4591    /// using the workspace's `jpeg-encoder` crate (the `image` crate is
4592    /// compiled without its JPEG feature). Used to exercise the decoder /
4593    /// pitch-padding paths for arbitrary dimensions without having to bundle
4594    /// a fixture file per test size.
4595    #[cfg(target_os = "linux")]
4596    fn make_rgb_jpeg(width: u32, height: u32) -> Vec<u8> {
4597        let mut bytes = Vec::with_capacity((width * height * 3) as usize);
4598        for y in 0..height {
4599            for x in 0..width {
4600                bytes.push(((x + y) & 0xFF) as u8);
4601                bytes.push(((x.wrapping_mul(3)) & 0xFF) as u8);
4602                bytes.push(((y.wrapping_mul(5)) & 0xFF) as u8);
4603            }
4604        }
4605        let mut out = Vec::new();
4606        let encoder = jpeg_encoder::Encoder::new(&mut out, 85);
4607        encoder
4608            .encode(
4609                &bytes,
4610                width as u16,
4611                height as u16,
4612                jpeg_encoder::ColorType::Rgb,
4613            )
4614            .expect("jpeg-encoder must succeed on trivial input");
4615        out
4616    }
4617
4618    /// End-to-end: a 375×333 RGBA JPEG (width NOT divisible by 4) loaded
4619    /// via the pitch-padded DMA path and letterboxed through the GL
4620    /// backend must produce correct output. Before the Rgba/Bgra
4621    /// width%4 relaxation in `DmaImportAttrs::from_tensor`, this case
4622    /// failed the pre-check and forced a CPU texture upload fallback;
4623    /// with the relaxation, EGL import succeeds at the driver level and
4624    /// the GL fast path runs. Output correctness is checked against a
4625    /// CPU reference (convert ran with `EDGEFIRST_FORCE_BACKEND=cpu`).
4626    #[test]
4627    #[cfg(target_os = "linux")]
4628    #[cfg(feature = "opengl")]
4629    fn test_convert_rgba_non_4_aligned_width_end_to_end() {
4630        use edgefirst_tensor::is_dma_available;
4631        if !is_dma_available() {
4632            eprintln!(
4633                "SKIPPED: test_convert_rgba_non_4_aligned_width_end_to_end — DMA not available"
4634            );
4635            return;
4636        }
4637        // 375 is the canonical failure width from dataset loaders —
4638        // 375 * 4 = 1500 bytes/row, pitch-padded to 1536. Width%4 = 3,
4639        // so the old pre-check rejected it; new code accepts it.
4640        let jpeg = make_rgb_jpeg(375, 333);
4641        let src_gl = crate::load_image_test_helper(&jpeg, Some(PixelFormat::Rgba), None).unwrap();
4642        assert_eq!(src_gl.width(), Some(375));
4643        // Row stride must still be pitch-padded (separate concern from width).
4644        let stride = src_gl.row_stride().unwrap();
4645        assert_eq!(stride, 1536, "expected padded pitch 1536, got {stride}");
4646
4647        // GL-backed convert into a pitch-aligned 640×640 Rgba dest.
4648        let mut gl_proc = ImageProcessor::new().unwrap();
4649        let gl_dst = gl_proc
4650            .create_image(
4651                640,
4652                640,
4653                PixelFormat::Rgba,
4654                DType::U8,
4655                None,
4656                edgefirst_tensor::CpuAccess::ReadWrite,
4657            )
4658            .unwrap();
4659        let (r_gl, _src_gl, gl_dst) = convert_img(
4660            &mut gl_proc,
4661            src_gl,
4662            gl_dst,
4663            Rotation::None,
4664            Flip::None,
4665            Crop::no_crop(),
4666        );
4667        r_gl.expect("GL-backed convert must succeed for 375x333 Rgba src");
4668
4669        // CPU reference via a fresh load so the two paths start from
4670        // byte-identical inputs. `with_config(backend=Cpu)` forces the
4671        // CPU-only processor regardless of which backends the host has
4672        // available.
4673        let src_cpu =
4674            crate::load_image_test_helper(&jpeg, Some(PixelFormat::Rgba), Some(TensorMemory::Mem))
4675                .unwrap();
4676        let mut cpu_proc = ImageProcessor::with_config(ImageProcessorConfig {
4677            backend: ComputeBackend::Cpu,
4678            ..Default::default()
4679        })
4680        .unwrap();
4681        let cpu_dst = TensorDyn::image(
4682            640,
4683            640,
4684            PixelFormat::Rgba,
4685            DType::U8,
4686            Some(TensorMemory::Mem),
4687            edgefirst_tensor::CpuAccess::ReadWrite,
4688        )
4689        .unwrap();
4690        let (r_cpu, _src_cpu, cpu_dst) = convert_img(
4691            &mut cpu_proc,
4692            src_cpu,
4693            cpu_dst,
4694            Rotation::None,
4695            Flip::None,
4696            Crop::no_crop(),
4697        );
4698        r_cpu.unwrap();
4699
4700        // Structural similarity: the GL path may have gone through EGL
4701        // import OR fallen back to CPU texture upload — either way, the
4702        // output must match the CPU reference closely.
4703        compare_images(&gl_dst, &cpu_dst, 0.95, function!());
4704    }
4705
4706    /// Regression lock: loading a JPEG at a non-64-aligned RGBA pitch (e.g.
4707    /// 500×333 → natural pitch 2000, needs to be padded to 2048) must go
4708    /// through `image_with_stride` and set `row_stride()` / `effective_row_stride()`
4709    /// to the padded value. The earlier pitch-padding commit fixed this in
4710    /// `load_jpeg`; a regression would surface as `row_stride == None` or
4711    /// `effective_row_stride == 2000`.
4712    #[test]
4713    #[cfg(target_os = "linux")]
4714    fn test_load_jpeg_rgba_non_aligned_pitch_padded_dma() {
4715        use edgefirst_tensor::is_dma_available;
4716        if !is_dma_available() {
4717            eprintln!(
4718                "SKIPPED: test_load_jpeg_rgba_non_aligned_pitch_padded_dma — DMA not available"
4719            );
4720            return;
4721        }
4722        // Widths that force a non-64-aligned natural RGBA pitch. All three
4723        // are divisible by 4 so the EGL width-alignment pre-check passes.
4724        // The pitch-padding fix is what makes these importable at all.
4725        for &w in &[500u32, 612, 428] {
4726            let jpeg = make_rgb_jpeg(w, 333);
4727            let loaded =
4728                crate::load_image_test_helper(&jpeg, Some(PixelFormat::Rgba), None).unwrap();
4729            let natural = (w as usize) * 4;
4730            let aligned = crate::align_pitch_bytes_to_gpu_alignment(natural).unwrap();
4731            assert!(
4732                aligned > natural,
4733                "test sanity: width {w} should be unaligned"
4734            );
4735            let stride = loaded
4736                .row_stride()
4737                .expect("padded DMA path must set an explicit row_stride — regression if None");
4738            assert_eq!(
4739                stride, aligned,
4740                "width {w}: expected padded stride {aligned}, got {stride} \
4741                 (regression: pitch-padding branch skipped?)"
4742            );
4743            let eff = loaded.effective_row_stride().unwrap();
4744            assert_eq!(
4745                eff, aligned,
4746                "effective_row_stride must match stored stride"
4747            );
4748            assert_eq!(loaded.width(), Some(w as usize));
4749            assert_eq!(loaded.height(), Some(333));
4750        }
4751    }
4752
4753    /// `padded_dma_pitch_for` must respect the caller's memory choice and
4754    /// must NOT route into the pitch-padded DMA path when the caller left
4755    /// the choice to the allocator (`None`) but DMA is unavailable on the
4756    /// host. The padded path requires `image_with_stride`, which always
4757    /// allocates DMA — taking it on a system without `/dev/dma_heap`
4758    /// would convert a normally-working image load into a hard failure
4759    /// (since `Tensor::image(..., None)` would have fallen back to
4760    /// SHM/Mem).
4761    #[test]
4762    #[cfg(target_os = "linux")]
4763    fn test_padded_dma_pitch_for_respects_memory_choice() {
4764        use edgefirst_tensor::{is_dma_available, TensorMemory};
4765
4766        // 500×4 = 2000 → padded to 2048 by GPU alignment. Use it for
4767        // every case so any "no padding" answer is unambiguous.
4768        let unaligned_w = 500;
4769
4770        // Caller asks for Mem / Shm: never pad, regardless of DMA.
4771        assert_eq!(
4772            crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &Some(TensorMemory::Mem),),
4773            None,
4774            "Mem must never trigger DMA padding"
4775        );
4776        assert_eq!(
4777            crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &Some(TensorMemory::Shm),),
4778            None,
4779            "Shm must never trigger DMA padding"
4780        );
4781
4782        // Caller explicitly asks for DMA: always pad if width needs it.
4783        // Even if the runtime can't actually allocate DMA, the caller
4784        // owns that decision and the resulting allocation error is
4785        // their problem, not ours.
4786        assert_eq!(
4787            crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &Some(TensorMemory::Dma),),
4788            Some(2048),
4789            "explicit Dma must pad regardless of runtime DMA availability"
4790        );
4791
4792        // Caller leaves it to the allocator: behaviour depends on
4793        // host-runtime DMA availability. This is the case the fix
4794        // guards against.
4795        let none_result = crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &None);
4796        if is_dma_available() {
4797            assert_eq!(
4798                none_result,
4799                Some(2048),
4800                "memory=None + DMA available → pad (will route through DMA)"
4801            );
4802        } else {
4803            assert_eq!(
4804                none_result, None,
4805                "memory=None + DMA unavailable → must NOT pad (would force \
4806                 image_with_stride into a DMA-only allocation that fails). \
4807                 Regression: padded_dma_pitch_for ignored is_dma_available()."
4808            );
4809        }
4810    }
4811
4812    // Synthesise a small greyscale PNG in memory at `(width, height)` with a
4813    // deterministic ramp pattern so multiple tests can cross-check output
4814    // without bundling an extra fixture file.
4815    fn make_grey_png(width: u32, height: u32) -> Vec<u8> {
4816        let mut bytes = Vec::with_capacity((width * height) as usize);
4817        for y in 0..height {
4818            for x in 0..width {
4819                bytes.push(((x + y) & 0xFF) as u8);
4820            }
4821        }
4822        let img = image::GrayImage::from_vec(width, height, bytes).unwrap();
4823        let mut buf = Vec::new();
4824        img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
4825            .unwrap();
4826        buf
4827    }
4828
4829    /// Greyscale PNG with a width that forces a pitch-misaligned natural
4830    /// row stride (612 bytes is not a multiple of the 64-byte GPU pitch
4831    /// alignment) must still load via the pitch-padded DMA path. Gated on
4832    /// DMA availability because `image_with_stride` is DMA-only.
4833    #[test]
4834    #[cfg(target_os = "linux")]
4835    fn test_load_png_grey_misaligned_width_dma() {
4836        use edgefirst_tensor::is_dma_available;
4837        if !is_dma_available() {
4838            eprintln!("SKIPPED: test_load_png_grey_misaligned_width_dma — DMA not available");
4839            return;
4840        }
4841        let png = make_grey_png(612, 388);
4842        let loaded = crate::load_image_test_helper(&png, Some(PixelFormat::Grey), None).unwrap();
4843        assert_eq!(loaded.width(), Some(612));
4844        assert_eq!(loaded.height(), Some(388));
4845        assert_eq!(loaded.format(), Some(PixelFormat::Grey));
4846
4847        // Round-trip pixels — natural-pitch DMA-BUFs pad the stride so we
4848        // must indirect through row_stride() rather than assume width.
4849        let map = loaded.as_u8().unwrap().map().unwrap();
4850        let stride = loaded.row_stride().unwrap_or(612);
4851        assert!(stride >= 612);
4852        let bytes: &[u8] = &map;
4853        for y in 0..388usize {
4854            for x in 0..612usize {
4855                let expected = ((x + y) & 0xFF) as u8;
4856                let got = bytes[y * stride + x];
4857                assert_eq!(
4858                    got, expected,
4859                    "grey png mismatch at ({x},{y}): got {got} expected {expected}"
4860                );
4861            }
4862        }
4863    }
4864
4865    /// Greyscale PNG loaded with explicit Mem backing — runs on any
4866    /// platform (no DMA permission requirement) and covers the
4867    /// decoder-native Luma → Grey no-conversion path.
4868    #[test]
4869    fn test_load_png_grey_mem() {
4870        use edgefirst_tensor::TensorMemory;
4871        let png = make_grey_png(612, 100);
4872        let loaded =
4873            crate::load_image_test_helper(&png, Some(PixelFormat::Grey), Some(TensorMemory::Mem))
4874                .unwrap();
4875        assert_eq!(loaded.width(), Some(612));
4876        assert_eq!(loaded.height(), Some(100));
4877        assert_eq!(loaded.format(), Some(PixelFormat::Grey));
4878        let map = loaded.as_u8().unwrap().map().unwrap();
4879        let bytes: &[u8] = &map;
4880        // Mem allocation uses the natural pitch — 612 bytes per row, exact.
4881        assert_eq!(bytes.len(), 612 * 100);
4882        for y in 0..100 {
4883            for x in 0..612 {
4884                assert_eq!(bytes[y * 612 + x], ((x + y) & 0xFF) as u8);
4885            }
4886        }
4887    }
4888
4889    /// Greyscale PNG decoded into RGB — exercises the decoder-colorspace
4890    /// mismatch path (Luma → Rgb via CPU converter). Uses Mem memory to
4891    /// stay portable to host-side test environments.
4892    #[test]
4893    fn test_load_png_grey_to_rgb_mem() {
4894        use edgefirst_tensor::TensorMemory;
4895        let png = make_grey_png(620, 240);
4896        let loaded =
4897            crate::load_image_test_helper(&png, Some(PixelFormat::Rgb), Some(TensorMemory::Mem))
4898                .unwrap();
4899        assert_eq!(loaded.width(), Some(620));
4900        assert_eq!(loaded.height(), Some(240));
4901        assert_eq!(loaded.format(), Some(PixelFormat::Rgb));
4902
4903        // Greyscale promoted to RGB replicates luma into each channel.
4904        let map = loaded.as_u8().unwrap().map().unwrap();
4905        let bytes: &[u8] = &map;
4906        for (x, y) in [(0usize, 0usize), (100, 50), (619, 239)] {
4907            let expected = ((x + y) & 0xFF) as u8;
4908            let off = (y * 620 + x) * 3;
4909            assert_eq!(bytes[off], expected, "R@{x},{y}");
4910            assert_eq!(bytes[off + 1], expected, "G@{x},{y}");
4911            assert_eq!(bytes[off + 2], expected, "B@{x},{y}");
4912        }
4913    }
4914
4915    #[test]
4916    #[cfg(target_os = "linux")]
4917    fn test_g2d_resize() {
4918        if !is_g2d_available() {
4919            eprintln!("SKIPPED: test_g2d_resize - G2D library (libg2d.so.2) not available");
4920            return;
4921        }
4922        if !is_dma_available() {
4923            eprintln!(
4924                "SKIPPED: test_g2d_resize - DMA memory allocation not available (permission denied or no DMA-BUF support)"
4925            );
4926            return;
4927        }
4928
4929        let dst_width = 640;
4930        let dst_height = 360;
4931        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4932        let src =
4933            crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), Some(TensorMemory::Dma))
4934                .unwrap();
4935
4936        let g2d_dst = TensorDyn::image(
4937            dst_width,
4938            dst_height,
4939            PixelFormat::Rgba,
4940            DType::U8,
4941            Some(TensorMemory::Dma),
4942            edgefirst_tensor::CpuAccess::ReadWrite,
4943        )
4944        .unwrap();
4945        let mut g2d_converter = G2DProcessor::new().unwrap();
4946        let (result, src, g2d_dst) = convert_img(
4947            &mut g2d_converter,
4948            src,
4949            g2d_dst,
4950            Rotation::None,
4951            Flip::None,
4952            Crop::no_crop(),
4953        );
4954        result.unwrap();
4955
4956        let cpu_dst = TensorDyn::image(
4957            dst_width,
4958            dst_height,
4959            PixelFormat::Rgba,
4960            DType::U8,
4961            None,
4962            edgefirst_tensor::CpuAccess::ReadWrite,
4963        )
4964        .unwrap();
4965        let mut cpu_converter = CPUProcessor::new();
4966        let (result, _src, cpu_dst) = convert_img(
4967            &mut cpu_converter,
4968            src,
4969            cpu_dst,
4970            Rotation::None,
4971            Flip::None,
4972            Crop::no_crop(),
4973        );
4974        result.unwrap();
4975
4976        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
4977        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
4978        // the YUV-matrix delta that forced 0.95 has closed; tightened to
4979        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
4980        // structural gap not exercised by these limited-range fixtures.
4981        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
4982    }
4983
4984    #[test]
4985    #[cfg(target_os = "linux")]
4986    #[cfg(feature = "opengl")]
4987    fn test_opengl_resize() {
4988        if !is_opengl_available() {
4989            eprintln!("SKIPPED: {} - OpenGL not available", function!());
4990            return;
4991        }
4992
4993        let dst_width = 640;
4994        let dst_height = 360;
4995        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4996        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4997
4998        let cpu_dst = TensorDyn::image(
4999            dst_width,
5000            dst_height,
5001            PixelFormat::Rgba,
5002            DType::U8,
5003            None,
5004            edgefirst_tensor::CpuAccess::ReadWrite,
5005        )
5006        .unwrap();
5007        let mut cpu_converter = CPUProcessor::new();
5008        let (result, src, cpu_dst) = convert_img(
5009            &mut cpu_converter,
5010            src,
5011            cpu_dst,
5012            Rotation::None,
5013            Flip::None,
5014            Crop::no_crop(),
5015        );
5016        result.unwrap();
5017
5018        let mut src = src;
5019        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
5020
5021        for _ in 0..5 {
5022            let gl_dst = TensorDyn::image(
5023                dst_width,
5024                dst_height,
5025                PixelFormat::Rgba,
5026                DType::U8,
5027                None,
5028                edgefirst_tensor::CpuAccess::ReadWrite,
5029            )
5030            .unwrap();
5031            let (result, src_back, gl_dst) = convert_img(
5032                &mut gl_converter,
5033                src,
5034                gl_dst,
5035                Rotation::None,
5036                Flip::None,
5037                Crop::no_crop(),
5038            );
5039            result.unwrap();
5040            src = src_back;
5041
5042            compare_images(&gl_dst, &cpu_dst, 0.98, function!());
5043        }
5044    }
5045
5046    #[test]
5047    #[cfg(target_os = "linux")]
5048    #[cfg(feature = "opengl")]
5049    fn test_opengl_10_threads() {
5050        if !is_opengl_available() {
5051            eprintln!("SKIPPED: {} - OpenGL not available", function!());
5052            return;
5053        }
5054
5055        let handles: Vec<_> = (0..10)
5056            .map(|i| {
5057                std::thread::Builder::new()
5058                    .name(format!("Thread {i}"))
5059                    .spawn(test_opengl_resize)
5060                    .unwrap()
5061            })
5062            .collect();
5063        handles.into_iter().for_each(|h| {
5064            if let Err(e) = h.join() {
5065                std::panic::resume_unwind(e)
5066            }
5067        });
5068    }
5069
5070    #[test]
5071    #[cfg(target_os = "linux")]
5072    #[cfg(feature = "opengl")]
5073    fn test_opengl_grey() {
5074        if !is_opengl_available() {
5075            eprintln!("SKIPPED: {} - OpenGL not available", function!());
5076            return;
5077        }
5078
5079        let img = crate::load_image_test_helper(
5080            &edgefirst_bench::testdata::read("grey.jpg"),
5081            Some(PixelFormat::Grey),
5082            None,
5083        )
5084        .unwrap();
5085
5086        let gl_dst = TensorDyn::image(
5087            640,
5088            640,
5089            PixelFormat::Grey,
5090            DType::U8,
5091            None,
5092            edgefirst_tensor::CpuAccess::ReadWrite,
5093        )
5094        .unwrap();
5095        let cpu_dst = TensorDyn::image(
5096            640,
5097            640,
5098            PixelFormat::Grey,
5099            DType::U8,
5100            None,
5101            edgefirst_tensor::CpuAccess::ReadWrite,
5102        )
5103        .unwrap();
5104
5105        let mut converter = CPUProcessor::new();
5106
5107        let (result, img, cpu_dst) = convert_img(
5108            &mut converter,
5109            img,
5110            cpu_dst,
5111            Rotation::None,
5112            Flip::None,
5113            Crop::no_crop(),
5114        );
5115        result.unwrap();
5116
5117        let mut gl = GLProcessorThreaded::new(None).unwrap();
5118        let (result, _img, gl_dst) = convert_img(
5119            &mut gl,
5120            img,
5121            gl_dst,
5122            Rotation::None,
5123            Flip::None,
5124            Crop::no_crop(),
5125        );
5126        result.unwrap();
5127
5128        compare_images(&gl_dst, &cpu_dst, 0.98, function!());
5129    }
5130
5131    #[test]
5132    #[cfg(target_os = "linux")]
5133    fn test_g2d_src_crop() {
5134        if !is_g2d_available() {
5135            eprintln!("SKIPPED: test_g2d_src_crop - G2D library (libg2d.so.2) not available");
5136            return;
5137        }
5138        if !is_dma_available() {
5139            eprintln!(
5140                "SKIPPED: test_g2d_src_crop - DMA memory allocation not available (permission denied or no DMA-BUF support)"
5141            );
5142            return;
5143        }
5144
5145        let dst_width = 640;
5146        let dst_height = 640;
5147        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5148        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5149
5150        let cpu_dst = TensorDyn::image(
5151            dst_width,
5152            dst_height,
5153            PixelFormat::Rgba,
5154            DType::U8,
5155            None,
5156            edgefirst_tensor::CpuAccess::ReadWrite,
5157        )
5158        .unwrap();
5159        let mut cpu_converter = CPUProcessor::new();
5160        let crop = Crop::new().with_source(Some(Region::new(0, 0, 640, 360)));
5161        let (result, src, cpu_dst) = convert_img(
5162            &mut cpu_converter,
5163            src,
5164            cpu_dst,
5165            Rotation::None,
5166            Flip::None,
5167            crop,
5168        );
5169        result.unwrap();
5170
5171        let g2d_dst = TensorDyn::image(
5172            dst_width,
5173            dst_height,
5174            PixelFormat::Rgba,
5175            DType::U8,
5176            None,
5177            edgefirst_tensor::CpuAccess::ReadWrite,
5178        )
5179        .unwrap();
5180        let mut g2d_converter = G2DProcessor::new().unwrap();
5181        let (result, _src, g2d_dst) = convert_img(
5182            &mut g2d_converter,
5183            src,
5184            g2d_dst,
5185            Rotation::None,
5186            Flip::None,
5187            crop,
5188        );
5189        result.unwrap();
5190
5191        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
5192        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
5193        // the YUV-matrix delta that forced 0.95 has closed; tightened to
5194        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
5195        // structural gap not exercised by these limited-range fixtures.
5196        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
5197    }
5198
5199    #[test]
5200    #[cfg(target_os = "linux")]
5201    fn test_g2d_dst_crop() {
5202        if !is_g2d_available() {
5203            eprintln!("SKIPPED: test_g2d_dst_crop - G2D library (libg2d.so.2) not available");
5204            return;
5205        }
5206        if !is_dma_available() {
5207            eprintln!(
5208                "SKIPPED: test_g2d_dst_crop - DMA memory allocation not available (permission denied or no DMA-BUF support)"
5209            );
5210            return;
5211        }
5212
5213        let dst_width = 640;
5214        let dst_height = 640;
5215        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5216        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5217
5218        let cpu_dst = TensorDyn::image(
5219            dst_width,
5220            dst_height,
5221            PixelFormat::Rgba,
5222            DType::U8,
5223            None,
5224            edgefirst_tensor::CpuAccess::ReadWrite,
5225        )
5226        .unwrap();
5227        let mut cpu_converter = CPUProcessor::new();
5228        let crop = Crop::new();
5229        let (result, src, cpu_dst) = convert_img(
5230            &mut cpu_converter,
5231            src,
5232            cpu_dst,
5233            Rotation::None,
5234            Flip::None,
5235            crop,
5236        );
5237        result.unwrap();
5238
5239        let g2d_dst = TensorDyn::image(
5240            dst_width,
5241            dst_height,
5242            PixelFormat::Rgba,
5243            DType::U8,
5244            None,
5245            edgefirst_tensor::CpuAccess::ReadWrite,
5246        )
5247        .unwrap();
5248        let mut g2d_converter = G2DProcessor::new().unwrap();
5249        let (result, _src, g2d_dst) = convert_img(
5250            &mut g2d_converter,
5251            src,
5252            g2d_dst,
5253            Rotation::None,
5254            Flip::None,
5255            crop,
5256        );
5257        result.unwrap();
5258
5259        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
5260        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
5261        // the YUV-matrix delta that forced 0.95 has closed; tightened to
5262        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
5263        // structural gap not exercised by these limited-range fixtures.
5264        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
5265    }
5266
5267    #[test]
5268    #[cfg(target_os = "linux")]
5269    fn test_g2d_all_rgba() {
5270        if !is_g2d_available() {
5271            eprintln!("SKIPPED: test_g2d_all_rgba - G2D library (libg2d.so.2) not available");
5272            return;
5273        }
5274        if !is_dma_available() {
5275            eprintln!(
5276                "SKIPPED: test_g2d_all_rgba - DMA memory allocation not available (permission denied or no DMA-BUF support)"
5277            );
5278            return;
5279        }
5280
5281        let dst_width = 640;
5282        let dst_height = 640;
5283        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5284        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5285        let src_dyn = src;
5286
5287        let mut cpu_dst = TensorDyn::image(
5288            dst_width,
5289            dst_height,
5290            PixelFormat::Rgba,
5291            DType::U8,
5292            None,
5293            edgefirst_tensor::CpuAccess::ReadWrite,
5294        )
5295        .unwrap();
5296        let mut cpu_converter = CPUProcessor::new();
5297        let mut g2d_dst = TensorDyn::image(
5298            dst_width,
5299            dst_height,
5300            PixelFormat::Rgba,
5301            DType::U8,
5302            None,
5303            edgefirst_tensor::CpuAccess::ReadWrite,
5304        )
5305        .unwrap();
5306        let mut g2d_converter = G2DProcessor::new().unwrap();
5307
5308        let crop = Crop::new().with_source(Some(Region::new(50, 120, 1024, 576)));
5309
5310        for rot in [
5311            Rotation::None,
5312            Rotation::Clockwise90,
5313            Rotation::Rotate180,
5314            Rotation::CounterClockwise90,
5315        ] {
5316            cpu_dst
5317                .as_u8()
5318                .unwrap()
5319                .map()
5320                .unwrap()
5321                .as_mut_slice()
5322                .fill(114);
5323            g2d_dst
5324                .as_u8()
5325                .unwrap()
5326                .map()
5327                .unwrap()
5328                .as_mut_slice()
5329                .fill(114);
5330            for flip in [Flip::None, Flip::Horizontal, Flip::Vertical] {
5331                let mut cpu_dst_dyn = cpu_dst;
5332                cpu_converter
5333                    .convert(&src_dyn, &mut cpu_dst_dyn, Rotation::None, Flip::None, crop)
5334                    .unwrap();
5335                cpu_dst = {
5336                    let mut __t = cpu_dst_dyn.into_u8().unwrap();
5337                    __t.set_format(PixelFormat::Rgba).unwrap();
5338                    TensorDyn::from(__t)
5339                };
5340
5341                let mut g2d_dst_dyn = g2d_dst;
5342                g2d_converter
5343                    .convert(&src_dyn, &mut g2d_dst_dyn, Rotation::None, Flip::None, crop)
5344                    .unwrap();
5345                g2d_dst = {
5346                    let mut __t = g2d_dst_dyn.into_u8().unwrap();
5347                    __t.set_format(PixelFormat::Rgba).unwrap();
5348                    TensorDyn::from(__t)
5349                };
5350
5351                compare_images(
5352                    &g2d_dst,
5353                    &cpu_dst,
5354                    0.98,
5355                    &format!("{} {:?} {:?}", function!(), rot, flip),
5356                );
5357            }
5358        }
5359    }
5360
5361    #[test]
5362    #[cfg(target_os = "linux")]
5363    #[cfg(feature = "opengl")]
5364    fn test_opengl_src_crop() {
5365        if !is_opengl_available() {
5366            eprintln!("SKIPPED: {} - OpenGL not available", function!());
5367            return;
5368        }
5369
5370        let dst_width = 640;
5371        let dst_height = 360;
5372        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5373        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5374        let crop = Crop::new().with_source(Some(Region::new(320, 180, 1280 - 320, 720 - 180)));
5375
5376        let cpu_dst = TensorDyn::image(
5377            dst_width,
5378            dst_height,
5379            PixelFormat::Rgba,
5380            DType::U8,
5381            None,
5382            edgefirst_tensor::CpuAccess::ReadWrite,
5383        )
5384        .unwrap();
5385        let mut cpu_converter = CPUProcessor::new();
5386        let (result, src, cpu_dst) = convert_img(
5387            &mut cpu_converter,
5388            src,
5389            cpu_dst,
5390            Rotation::None,
5391            Flip::None,
5392            crop,
5393        );
5394        result.unwrap();
5395
5396        let gl_dst = TensorDyn::image(
5397            dst_width,
5398            dst_height,
5399            PixelFormat::Rgba,
5400            DType::U8,
5401            None,
5402            edgefirst_tensor::CpuAccess::ReadWrite,
5403        )
5404        .unwrap();
5405        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
5406        let (result, _src, gl_dst) = convert_img(
5407            &mut gl_converter,
5408            src,
5409            gl_dst,
5410            Rotation::None,
5411            Flip::None,
5412            crop,
5413        );
5414        result.unwrap();
5415
5416        compare_images(&gl_dst, &cpu_dst, 0.98, function!());
5417    }
5418
5419    #[test]
5420    #[cfg(target_os = "linux")]
5421    #[cfg(feature = "opengl")]
5422    fn test_opengl_dst_crop() {
5423        if !is_opengl_available() {
5424            eprintln!("SKIPPED: {} - OpenGL not available", function!());
5425            return;
5426        }
5427
5428        let dst_width = 640;
5429        let dst_height = 640;
5430        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5431        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5432
5433        let cpu_dst = TensorDyn::image(
5434            dst_width,
5435            dst_height,
5436            PixelFormat::Rgba,
5437            DType::U8,
5438            None,
5439            edgefirst_tensor::CpuAccess::ReadWrite,
5440        )
5441        .unwrap();
5442        let mut cpu_converter = CPUProcessor::new();
5443        let crop = Crop::new();
5444        let (result, src, cpu_dst) = convert_img(
5445            &mut cpu_converter,
5446            src,
5447            cpu_dst,
5448            Rotation::None,
5449            Flip::None,
5450            crop,
5451        );
5452        result.unwrap();
5453
5454        let gl_dst = TensorDyn::image(
5455            dst_width,
5456            dst_height,
5457            PixelFormat::Rgba,
5458            DType::U8,
5459            None,
5460            edgefirst_tensor::CpuAccess::ReadWrite,
5461        )
5462        .unwrap();
5463        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
5464        let (result, _src, gl_dst) = convert_img(
5465            &mut gl_converter,
5466            src,
5467            gl_dst,
5468            Rotation::None,
5469            Flip::None,
5470            crop,
5471        );
5472        result.unwrap();
5473
5474        compare_images(&gl_dst, &cpu_dst, 0.98, function!());
5475    }
5476
5477    #[test]
5478    #[cfg(target_os = "linux")]
5479    #[cfg(feature = "opengl")]
5480    fn test_opengl_all_rgba() {
5481        if !is_opengl_available() {
5482            eprintln!("SKIPPED: {} - OpenGL not available", function!());
5483            return;
5484        }
5485
5486        let dst_width = 640;
5487        let dst_height = 640;
5488        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5489
5490        let mut cpu_converter = CPUProcessor::new();
5491
5492        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
5493
5494        let mut mem = vec![None, Some(TensorMemory::Mem), Some(TensorMemory::Shm)];
5495        if is_dma_available() {
5496            mem.push(Some(TensorMemory::Dma));
5497        }
5498        let crop = Crop::new().with_source(Some(Region::new(50, 120, 1024, 576)));
5499        for m in mem {
5500            let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), m).unwrap();
5501            let src_dyn = src;
5502
5503            for rot in [
5504                Rotation::None,
5505                Rotation::Clockwise90,
5506                Rotation::Rotate180,
5507                Rotation::CounterClockwise90,
5508            ] {
5509                for flip in [Flip::None, Flip::Horizontal, Flip::Vertical] {
5510                    let cpu_dst = TensorDyn::image(
5511                        dst_width,
5512                        dst_height,
5513                        PixelFormat::Rgba,
5514                        DType::U8,
5515                        m,
5516                        edgefirst_tensor::CpuAccess::ReadWrite,
5517                    )
5518                    .unwrap();
5519                    let gl_dst = TensorDyn::image(
5520                        dst_width,
5521                        dst_height,
5522                        PixelFormat::Rgba,
5523                        DType::U8,
5524                        m,
5525                        edgefirst_tensor::CpuAccess::ReadWrite,
5526                    )
5527                    .unwrap();
5528                    cpu_dst
5529                        .as_u8()
5530                        .unwrap()
5531                        .map()
5532                        .unwrap()
5533                        .as_mut_slice()
5534                        .fill(114);
5535                    gl_dst
5536                        .as_u8()
5537                        .unwrap()
5538                        .map()
5539                        .unwrap()
5540                        .as_mut_slice()
5541                        .fill(114);
5542
5543                    let mut cpu_dst_dyn = cpu_dst;
5544                    cpu_converter
5545                        .convert(&src_dyn, &mut cpu_dst_dyn, Rotation::None, Flip::None, crop)
5546                        .unwrap();
5547                    let cpu_dst = {
5548                        let mut __t = cpu_dst_dyn.into_u8().unwrap();
5549                        __t.set_format(PixelFormat::Rgba).unwrap();
5550                        TensorDyn::from(__t)
5551                    };
5552
5553                    let mut gl_dst_dyn = gl_dst;
5554                    gl_converter
5555                        .convert(&src_dyn, &mut gl_dst_dyn, Rotation::None, Flip::None, crop)
5556                        .map_err(|e| {
5557                            log::error!("error mem {m:?} rot {rot:?} error: {e:?}");
5558                            e
5559                        })
5560                        .unwrap();
5561                    let gl_dst = {
5562                        let mut __t = gl_dst_dyn.into_u8().unwrap();
5563                        __t.set_format(PixelFormat::Rgba).unwrap();
5564                        TensorDyn::from(__t)
5565                    };
5566
5567                    compare_images(
5568                        &gl_dst,
5569                        &cpu_dst,
5570                        0.98,
5571                        &format!("{} {:?} {:?}", function!(), rot, flip),
5572                    );
5573                }
5574            }
5575        }
5576    }
5577
5578    #[test]
5579    #[cfg(target_os = "linux")]
5580    fn test_cpu_rotate() {
5581        for rot in [
5582            Rotation::Clockwise90,
5583            Rotation::Rotate180,
5584            Rotation::CounterClockwise90,
5585        ] {
5586            test_cpu_rotate_(rot);
5587        }
5588    }
5589
5590    #[cfg(target_os = "linux")]
5591    fn test_cpu_rotate_(rot: Rotation) {
5592        // This test rotates the image 4 times and checks that the image was returned to
5593        // be the same Currently doesn't check if rotations actually rotated in
5594        // right direction
5595        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5596
5597        let unchanged_src =
5598            crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5599        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5600
5601        let (dst_width, dst_height) = match rot {
5602            Rotation::None | Rotation::Rotate180 => (src.width().unwrap(), src.height().unwrap()),
5603            Rotation::Clockwise90 | Rotation::CounterClockwise90 => {
5604                (src.height().unwrap(), src.width().unwrap())
5605            }
5606        };
5607
5608        let cpu_dst = TensorDyn::image(
5609            dst_width,
5610            dst_height,
5611            PixelFormat::Rgba,
5612            DType::U8,
5613            None,
5614            edgefirst_tensor::CpuAccess::ReadWrite,
5615        )
5616        .unwrap();
5617        let mut cpu_converter = CPUProcessor::new();
5618
5619        // After rotating 4 times, the image should be the same as the original
5620
5621        let (result, src, cpu_dst) = convert_img(
5622            &mut cpu_converter,
5623            src,
5624            cpu_dst,
5625            rot,
5626            Flip::None,
5627            Crop::no_crop(),
5628        );
5629        result.unwrap();
5630
5631        let (result, cpu_dst, src) = convert_img(
5632            &mut cpu_converter,
5633            cpu_dst,
5634            src,
5635            rot,
5636            Flip::None,
5637            Crop::no_crop(),
5638        );
5639        result.unwrap();
5640
5641        let (result, src, cpu_dst) = convert_img(
5642            &mut cpu_converter,
5643            src,
5644            cpu_dst,
5645            rot,
5646            Flip::None,
5647            Crop::no_crop(),
5648        );
5649        result.unwrap();
5650
5651        let (result, _cpu_dst, src) = convert_img(
5652            &mut cpu_converter,
5653            cpu_dst,
5654            src,
5655            rot,
5656            Flip::None,
5657            Crop::no_crop(),
5658        );
5659        result.unwrap();
5660
5661        compare_images(&src, &unchanged_src, 0.98, function!());
5662    }
5663
5664    #[test]
5665    #[cfg(target_os = "linux")]
5666    #[cfg(feature = "opengl")]
5667    fn test_opengl_rotate() {
5668        if !is_opengl_available() {
5669            eprintln!("SKIPPED: {} - OpenGL not available", function!());
5670            return;
5671        }
5672
5673        let size = (1280, 720);
5674        let mut mem = vec![None, Some(TensorMemory::Shm), Some(TensorMemory::Mem)];
5675
5676        if is_dma_available() {
5677            mem.push(Some(TensorMemory::Dma));
5678        }
5679        for m in mem {
5680            for rot in [
5681                Rotation::Clockwise90,
5682                Rotation::Rotate180,
5683                Rotation::CounterClockwise90,
5684            ] {
5685                test_opengl_rotate_(size, rot, m);
5686            }
5687        }
5688    }
5689
5690    #[cfg(target_os = "linux")]
5691    #[cfg(feature = "opengl")]
5692    fn test_opengl_rotate_(
5693        size: (usize, usize),
5694        rot: Rotation,
5695        tensor_memory: Option<TensorMemory>,
5696    ) {
5697        let (dst_width, dst_height) = match rot {
5698            Rotation::None | Rotation::Rotate180 => size,
5699            Rotation::Clockwise90 | Rotation::CounterClockwise90 => (size.1, size.0),
5700        };
5701
5702        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5703        let src =
5704            crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), tensor_memory).unwrap();
5705
5706        let cpu_dst = TensorDyn::image(
5707            dst_width,
5708            dst_height,
5709            PixelFormat::Rgba,
5710            DType::U8,
5711            None,
5712            edgefirst_tensor::CpuAccess::ReadWrite,
5713        )
5714        .unwrap();
5715        let mut cpu_converter = CPUProcessor::new();
5716
5717        let (result, mut src, cpu_dst) = convert_img(
5718            &mut cpu_converter,
5719            src,
5720            cpu_dst,
5721            rot,
5722            Flip::None,
5723            Crop::no_crop(),
5724        );
5725        result.unwrap();
5726
5727        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
5728
5729        for _ in 0..5 {
5730            let gl_dst = TensorDyn::image(
5731                dst_width,
5732                dst_height,
5733                PixelFormat::Rgba,
5734                DType::U8,
5735                tensor_memory,
5736                edgefirst_tensor::CpuAccess::ReadWrite,
5737            )
5738            .unwrap();
5739            let (result, src_back, gl_dst) = convert_img(
5740                &mut gl_converter,
5741                src,
5742                gl_dst,
5743                rot,
5744                Flip::None,
5745                Crop::no_crop(),
5746            );
5747            result.unwrap();
5748            src = src_back;
5749            compare_images(&gl_dst, &cpu_dst, 0.98, function!());
5750        }
5751    }
5752
5753    #[test]
5754    #[cfg(target_os = "linux")]
5755    fn test_g2d_rotate() {
5756        if !is_g2d_available() {
5757            eprintln!("SKIPPED: test_g2d_rotate - G2D library (libg2d.so.2) not available");
5758            return;
5759        }
5760        if !is_dma_available() {
5761            eprintln!(
5762                "SKIPPED: test_g2d_rotate - DMA memory allocation not available (permission denied or no DMA-BUF support)"
5763            );
5764            return;
5765        }
5766
5767        let size = (1280, 720);
5768        for rot in [
5769            Rotation::Clockwise90,
5770            Rotation::Rotate180,
5771            Rotation::CounterClockwise90,
5772        ] {
5773            test_g2d_rotate_(size, rot);
5774        }
5775    }
5776
5777    #[cfg(target_os = "linux")]
5778    fn test_g2d_rotate_(size: (usize, usize), rot: Rotation) {
5779        let (dst_width, dst_height) = match rot {
5780            Rotation::None | Rotation::Rotate180 => size,
5781            Rotation::Clockwise90 | Rotation::CounterClockwise90 => (size.1, size.0),
5782        };
5783
5784        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5785        let src =
5786            crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), Some(TensorMemory::Dma))
5787                .unwrap();
5788
5789        let cpu_dst = TensorDyn::image(
5790            dst_width,
5791            dst_height,
5792            PixelFormat::Rgba,
5793            DType::U8,
5794            None,
5795            edgefirst_tensor::CpuAccess::ReadWrite,
5796        )
5797        .unwrap();
5798        let mut cpu_converter = CPUProcessor::new();
5799
5800        let (result, src, cpu_dst) = convert_img(
5801            &mut cpu_converter,
5802            src,
5803            cpu_dst,
5804            rot,
5805            Flip::None,
5806            Crop::no_crop(),
5807        );
5808        result.unwrap();
5809
5810        let g2d_dst = TensorDyn::image(
5811            dst_width,
5812            dst_height,
5813            PixelFormat::Rgba,
5814            DType::U8,
5815            Some(TensorMemory::Dma),
5816            edgefirst_tensor::CpuAccess::ReadWrite,
5817        )
5818        .unwrap();
5819        let mut g2d_converter = G2DProcessor::new().unwrap();
5820
5821        let (result, _src, g2d_dst) = convert_img(
5822            &mut g2d_converter,
5823            src,
5824            g2d_dst,
5825            rot,
5826            Flip::None,
5827            Crop::no_crop(),
5828        );
5829        result.unwrap();
5830
5831        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
5832        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
5833        // the YUV-matrix delta that forced 0.95 has closed; tightened to
5834        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
5835        // structural gap not exercised by these limited-range fixtures.
5836        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
5837    }
5838
5839    #[test]
5840    fn test_rgba_to_yuyv_resize_cpu() {
5841        let src = load_bytes_to_tensor(
5842            1280,
5843            720,
5844            PixelFormat::Rgba,
5845            None,
5846            &edgefirst_bench::testdata::read("camera720p.rgba"),
5847        )
5848        .unwrap();
5849
5850        let (dst_width, dst_height) = (640, 360);
5851
5852        let dst = TensorDyn::image(
5853            dst_width,
5854            dst_height,
5855            PixelFormat::Yuyv,
5856            DType::U8,
5857            None,
5858            edgefirst_tensor::CpuAccess::ReadWrite,
5859        )
5860        .unwrap();
5861
5862        let dst_through_yuyv = TensorDyn::image(
5863            dst_width,
5864            dst_height,
5865            PixelFormat::Rgba,
5866            DType::U8,
5867            None,
5868            edgefirst_tensor::CpuAccess::ReadWrite,
5869        )
5870        .unwrap();
5871        let dst_direct = TensorDyn::image(
5872            dst_width,
5873            dst_height,
5874            PixelFormat::Rgba,
5875            DType::U8,
5876            None,
5877            edgefirst_tensor::CpuAccess::ReadWrite,
5878        )
5879        .unwrap();
5880
5881        let mut cpu_converter = CPUProcessor::new();
5882
5883        let (result, src, dst) = convert_img(
5884            &mut cpu_converter,
5885            src,
5886            dst,
5887            Rotation::None,
5888            Flip::None,
5889            Crop::no_crop(),
5890        );
5891        result.unwrap();
5892
5893        let (result, _dst, dst_through_yuyv) = convert_img(
5894            &mut cpu_converter,
5895            dst,
5896            dst_through_yuyv,
5897            Rotation::None,
5898            Flip::None,
5899            Crop::no_crop(),
5900        );
5901        result.unwrap();
5902
5903        let (result, _src, dst_direct) = convert_img(
5904            &mut cpu_converter,
5905            src,
5906            dst_direct,
5907            Rotation::None,
5908            Flip::None,
5909            Crop::no_crop(),
5910        );
5911        result.unwrap();
5912
5913        compare_images(&dst_through_yuyv, &dst_direct, 0.98, function!());
5914    }
5915
5916    #[test]
5917    #[cfg(target_os = "linux")]
5918    #[cfg(feature = "opengl")]
5919    #[ignore = "opengl doesn't support rendering to PixelFormat::Yuyv texture"]
5920    fn test_rgba_to_yuyv_resize_opengl() {
5921        if !is_opengl_available() {
5922            eprintln!("SKIPPED: {} - OpenGL not available", function!());
5923            return;
5924        }
5925
5926        if !is_dma_available() {
5927            eprintln!(
5928                "SKIPPED: {} - DMA memory allocation not available (permission denied or no DMA-BUF support)",
5929                function!()
5930            );
5931            return;
5932        }
5933
5934        let src = load_bytes_to_tensor(
5935            1280,
5936            720,
5937            PixelFormat::Rgba,
5938            None,
5939            &edgefirst_bench::testdata::read("camera720p.rgba"),
5940        )
5941        .unwrap();
5942
5943        let (dst_width, dst_height) = (640, 360);
5944
5945        let dst = TensorDyn::image(
5946            dst_width,
5947            dst_height,
5948            PixelFormat::Yuyv,
5949            DType::U8,
5950            Some(TensorMemory::Dma),
5951            edgefirst_tensor::CpuAccess::ReadWrite,
5952        )
5953        .unwrap();
5954
5955        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
5956
5957        let (result, src, dst) = convert_img(
5958            &mut gl_converter,
5959            src,
5960            dst,
5961            Rotation::None,
5962            Flip::None,
5963            Crop::letterbox([255, 255, 255, 255]),
5964        );
5965        result.unwrap();
5966
5967        std::fs::write(
5968            "rgba_to_yuyv_opengl.yuyv",
5969            dst.as_u8().unwrap().map().unwrap().as_slice(),
5970        )
5971        .unwrap();
5972        let cpu_dst = TensorDyn::image(
5973            dst_width,
5974            dst_height,
5975            PixelFormat::Yuyv,
5976            DType::U8,
5977            Some(TensorMemory::Dma),
5978            edgefirst_tensor::CpuAccess::ReadWrite,
5979        )
5980        .unwrap();
5981        let (result, _src, cpu_dst) = convert_img(
5982            &mut CPUProcessor::new(),
5983            src,
5984            cpu_dst,
5985            Rotation::None,
5986            Flip::None,
5987            Crop::no_crop(),
5988        );
5989        result.unwrap();
5990
5991        compare_images_convert_to_rgb(&dst, &cpu_dst, 0.98, function!());
5992    }
5993
5994    #[test]
5995    #[cfg(target_os = "linux")]
5996    fn test_rgba_to_yuyv_resize_g2d() {
5997        if !is_g2d_available() {
5998            eprintln!(
5999                "SKIPPED: test_rgba_to_yuyv_resize_g2d - G2D library (libg2d.so.2) not available"
6000            );
6001            return;
6002        }
6003        if !is_dma_available() {
6004            eprintln!(
6005                "SKIPPED: test_rgba_to_yuyv_resize_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
6006            );
6007            return;
6008        }
6009
6010        let src = load_bytes_to_tensor(
6011            1280,
6012            720,
6013            PixelFormat::Rgba,
6014            Some(TensorMemory::Dma),
6015            &edgefirst_bench::testdata::read("camera720p.rgba"),
6016        )
6017        .unwrap();
6018
6019        let (dst_width, dst_height) = (1280, 720);
6020
6021        let cpu_dst = TensorDyn::image(
6022            dst_width,
6023            dst_height,
6024            PixelFormat::Yuyv,
6025            DType::U8,
6026            Some(TensorMemory::Dma),
6027            edgefirst_tensor::CpuAccess::ReadWrite,
6028        )
6029        .unwrap();
6030
6031        let g2d_dst = TensorDyn::image(
6032            dst_width,
6033            dst_height,
6034            PixelFormat::Yuyv,
6035            DType::U8,
6036            Some(TensorMemory::Dma),
6037            edgefirst_tensor::CpuAccess::ReadWrite,
6038        )
6039        .unwrap();
6040
6041        let mut g2d_converter = G2DProcessor::new().unwrap();
6042        let crop = Crop::new();
6043
6044        g2d_dst
6045            .as_u8()
6046            .unwrap()
6047            .map()
6048            .unwrap()
6049            .as_mut_slice()
6050            .fill(128);
6051        let (result, src, g2d_dst) = convert_img(
6052            &mut g2d_converter,
6053            src,
6054            g2d_dst,
6055            Rotation::None,
6056            Flip::None,
6057            crop,
6058        );
6059        result.unwrap();
6060
6061        let cpu_dst_img = cpu_dst;
6062        cpu_dst_img
6063            .as_u8()
6064            .unwrap()
6065            .map()
6066            .unwrap()
6067            .as_mut_slice()
6068            .fill(128);
6069        let (result, _src, cpu_dst) = convert_img(
6070            &mut CPUProcessor::new(),
6071            src,
6072            cpu_dst_img,
6073            Rotation::None,
6074            Flip::None,
6075            crop,
6076        );
6077        result.unwrap();
6078
6079        compare_images_convert_to_rgb(&cpu_dst, &g2d_dst, 0.98, function!());
6080    }
6081
6082    #[test]
6083    fn test_yuyv_to_rgba_cpu() {
6084        let file = edgefirst_bench::testdata::read("camera720p.yuyv").to_vec();
6085        let src = TensorDyn::image(
6086            1280,
6087            720,
6088            PixelFormat::Yuyv,
6089            DType::U8,
6090            None,
6091            edgefirst_tensor::CpuAccess::ReadWrite,
6092        )
6093        .unwrap();
6094        src.as_u8()
6095            .unwrap()
6096            .map()
6097            .unwrap()
6098            .as_mut_slice()
6099            .copy_from_slice(&file);
6100
6101        let dst = TensorDyn::image(
6102            1280,
6103            720,
6104            PixelFormat::Rgba,
6105            DType::U8,
6106            None,
6107            edgefirst_tensor::CpuAccess::ReadWrite,
6108        )
6109        .unwrap();
6110        let mut cpu_converter = CPUProcessor::new();
6111
6112        let (result, _src, dst) = convert_img(
6113            &mut cpu_converter,
6114            src,
6115            dst,
6116            Rotation::None,
6117            Flip::None,
6118            Crop::no_crop(),
6119        );
6120        result.unwrap();
6121
6122        let target_image = TensorDyn::image(
6123            1280,
6124            720,
6125            PixelFormat::Rgba,
6126            DType::U8,
6127            None,
6128            edgefirst_tensor::CpuAccess::ReadWrite,
6129        )
6130        .unwrap();
6131        target_image
6132            .as_u8()
6133            .unwrap()
6134            .map()
6135            .unwrap()
6136            .as_mut_slice()
6137            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
6138
6139        // CPU path resolves the untagged 720p source to BT.709 limited (height
6140        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
6141        compare_images(&dst, &target_image, 0.98, function!());
6142    }
6143
6144    #[test]
6145    fn test_yuyv_to_rgb_cpu() {
6146        let file = edgefirst_bench::testdata::read("camera720p.yuyv").to_vec();
6147        let src = TensorDyn::image(
6148            1280,
6149            720,
6150            PixelFormat::Yuyv,
6151            DType::U8,
6152            None,
6153            edgefirst_tensor::CpuAccess::ReadWrite,
6154        )
6155        .unwrap();
6156        src.as_u8()
6157            .unwrap()
6158            .map()
6159            .unwrap()
6160            .as_mut_slice()
6161            .copy_from_slice(&file);
6162
6163        let dst = TensorDyn::image(
6164            1280,
6165            720,
6166            PixelFormat::Rgb,
6167            DType::U8,
6168            None,
6169            edgefirst_tensor::CpuAccess::ReadWrite,
6170        )
6171        .unwrap();
6172        let mut cpu_converter = CPUProcessor::new();
6173
6174        let (result, _src, dst) = convert_img(
6175            &mut cpu_converter,
6176            src,
6177            dst,
6178            Rotation::None,
6179            Flip::None,
6180            Crop::no_crop(),
6181        );
6182        result.unwrap();
6183
6184        let target_image = TensorDyn::image(
6185            1280,
6186            720,
6187            PixelFormat::Rgb,
6188            DType::U8,
6189            None,
6190            edgefirst_tensor::CpuAccess::ReadWrite,
6191        )
6192        .unwrap();
6193        target_image
6194            .as_u8()
6195            .unwrap()
6196            .map()
6197            .unwrap()
6198            .as_mut_slice()
6199            .as_chunks_mut::<3>()
6200            .0
6201            .iter_mut()
6202            .zip(
6203                edgefirst_bench::testdata::read("camera720p.rgba")
6204                    .as_chunks::<4>()
6205                    .0,
6206            )
6207            .for_each(|(dst, src)| *dst = [src[0], src[1], src[2]]);
6208
6209        // CPU path resolves the untagged 720p source to BT.709 limited (height
6210        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
6211        compare_images(&dst, &target_image, 0.98, function!());
6212    }
6213
6214    #[test]
6215    #[cfg(target_os = "linux")]
6216    fn test_yuyv_to_rgba_g2d() {
6217        if !is_g2d_available() {
6218            eprintln!("SKIPPED: test_yuyv_to_rgba_g2d - G2D library (libg2d.so.2) not available");
6219            return;
6220        }
6221        if !is_dma_available() {
6222            eprintln!(
6223                "SKIPPED: test_yuyv_to_rgba_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
6224            );
6225            return;
6226        }
6227
6228        let src = load_bytes_to_tensor(
6229            1280,
6230            720,
6231            PixelFormat::Yuyv,
6232            None,
6233            &edgefirst_bench::testdata::read("camera720p.yuyv"),
6234        )
6235        .unwrap();
6236
6237        let dst = TensorDyn::image(
6238            1280,
6239            720,
6240            PixelFormat::Rgba,
6241            DType::U8,
6242            Some(TensorMemory::Dma),
6243            edgefirst_tensor::CpuAccess::ReadWrite,
6244        )
6245        .unwrap();
6246        let mut g2d_converter = G2DProcessor::new().unwrap();
6247
6248        let (result, _src, dst) = convert_img(
6249            &mut g2d_converter,
6250            src,
6251            dst,
6252            Rotation::None,
6253            Flip::None,
6254            Crop::no_crop(),
6255        );
6256        result.unwrap();
6257
6258        let target_image = TensorDyn::image(
6259            1280,
6260            720,
6261            PixelFormat::Rgba,
6262            DType::U8,
6263            None,
6264            edgefirst_tensor::CpuAccess::ReadWrite,
6265        )
6266        .unwrap();
6267        target_image
6268            .as_u8()
6269            .unwrap()
6270            .map()
6271            .unwrap()
6272            .as_mut_slice()
6273            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
6274
6275        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
6276        // so the matrix delta vs the reference that forced 0.95 has closed;
6277        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
6278        compare_images(&dst, &target_image, 0.98, function!());
6279    }
6280
6281    #[test]
6282    #[cfg(target_os = "linux")]
6283    #[cfg(feature = "opengl")]
6284    fn test_yuyv_to_rgba_opengl() {
6285        if !is_opengl_available() {
6286            eprintln!("SKIPPED: {} - OpenGL not available", function!());
6287            return;
6288        }
6289        if !is_dma_available() {
6290            eprintln!(
6291                "SKIPPED: {} - DMA memory allocation not available (permission denied or no DMA-BUF support)",
6292                function!()
6293            );
6294            return;
6295        }
6296
6297        let src = load_bytes_to_tensor(
6298            1280,
6299            720,
6300            PixelFormat::Yuyv,
6301            Some(TensorMemory::Dma),
6302            &edgefirst_bench::testdata::read("camera720p.yuyv"),
6303        )
6304        .unwrap();
6305
6306        let dst = TensorDyn::image(
6307            1280,
6308            720,
6309            PixelFormat::Rgba,
6310            DType::U8,
6311            Some(TensorMemory::Dma),
6312            edgefirst_tensor::CpuAccess::ReadWrite,
6313        )
6314        .unwrap();
6315        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
6316
6317        let (result, _src, dst) = convert_img(
6318            &mut gl_converter,
6319            src,
6320            dst,
6321            Rotation::None,
6322            Flip::None,
6323            Crop::no_crop(),
6324        );
6325        result.unwrap();
6326
6327        let target_image = TensorDyn::image(
6328            1280,
6329            720,
6330            PixelFormat::Rgba,
6331            DType::U8,
6332            None,
6333            edgefirst_tensor::CpuAccess::ReadWrite,
6334        )
6335        .unwrap();
6336        target_image
6337            .as_u8()
6338            .unwrap()
6339            .map()
6340            .unwrap()
6341            .as_mut_slice()
6342            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
6343
6344        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
6345        // so the matrix delta vs the reference that forced 0.95 has closed;
6346        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
6347        compare_images(&dst, &target_image, 0.98, function!());
6348    }
6349
6350    /// macOS analog of `test_yuyv_to_rgba_opengl` — drives the ANGLE +
6351    /// IOSurface backend end-to-end and compares against the same
6352    /// reference image. Skips silently if ANGLE isn't installed so the
6353    /// test suite still passes on CI hosts without the Homebrew tap.
6354    /// Step-1 probe: proves ANGLE's Metal IOSurface-client-buffer path accepts
6355    /// an `L008`→`GL_RED` (R8) binding — the foundation for sampling the
6356    /// contiguous semi-planar YUV buffer as a single R8 texture. Renders a
6357    /// GREY (R8 IOSurface) source through the GL backend to RGBA and checks the
6358    /// luma round-trips to R=G=B (identity GREY→RGB).
6359    #[test]
6360    #[cfg(target_os = "macos")]
6361    #[cfg(feature = "opengl")]
6362    fn test_grey_r8_iosurface_to_rgba_opengl_macos() {
6363        let mut proc = match GLProcessorThreaded::new(None) {
6364            Ok(p) => p,
6365            Err(e) => {
6366                eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
6367                return;
6368            }
6369        };
6370
6371        let (w, h) = (16usize, 16usize);
6372        let src = TensorDyn::image(
6373            w,
6374            h,
6375            PixelFormat::Grey,
6376            DType::U8,
6377            Some(TensorMemory::Dma),
6378            edgefirst_tensor::CpuAccess::ReadWrite,
6379        )
6380        .expect("GREY IOSurface (R8/L008) should allocate — proves the FourCC mapping");
6381        // Known luma ramp: value = (x * 13 + y * 7) & 0xff.
6382        {
6383            let su8 = src.as_u8().unwrap();
6384            let stride = src.as_u8().unwrap().effective_row_stride().unwrap();
6385            let mut m = su8.map().unwrap();
6386            let buf = m.as_mut_slice();
6387            for y in 0..h {
6388                for x in 0..w {
6389                    buf[y * stride + x] = ((x * 13 + y * 7) & 0xff) as u8;
6390                }
6391            }
6392        }
6393
6394        let dst = TensorDyn::image(
6395            w,
6396            h,
6397            PixelFormat::Rgba,
6398            DType::U8,
6399            Some(TensorMemory::Dma),
6400            edgefirst_tensor::CpuAccess::ReadWrite,
6401        )
6402        .unwrap();
6403        let (result, src_back, dst) = convert_img(
6404            &mut proc,
6405            src,
6406            dst,
6407            Rotation::None,
6408            Flip::None,
6409            Crop::no_crop(),
6410        );
6411        result.expect("GREY(R8 IOSurface) → RGBA must convert on ANGLE (R8 binding works)");
6412
6413        let src_stride = src_back.as_u8().unwrap().effective_row_stride().unwrap();
6414        let src_map = src_back.as_u8().unwrap().map().unwrap();
6415        let sbytes = src_map.as_slice();
6416        let dst_stride = dst.as_u8().unwrap().effective_row_stride().unwrap();
6417        let dst_map = dst.as_u8().unwrap().map().unwrap();
6418        let dbytes = dst_map.as_slice();
6419        for y in 0..h {
6420            for x in 0..w {
6421                let yv = sbytes[y * src_stride + x] as i16;
6422                let p = y * dst_stride + x * 4;
6423                for c in 0..3 {
6424                    assert!(
6425                        (dbytes[p + c] as i16 - yv).abs() <= 2,
6426                        "pixel ({x},{y}) ch{c} = {} expected ~{yv} (GREY→RGB identity)",
6427                        dbytes[p + c]
6428                    );
6429                }
6430            }
6431        }
6432    }
6433
6434    /// Two-pass GPU chain: NV12 (R8 IOSurface) → PlanarRgb F16, the profiler's
6435    /// preprocess. Verifies the chained `convert_nv_to_planar_float`
6436    /// (NV12→RGBA8 then the verified RGBA8→PlanarRgb F16) executes on ANGLE and
6437    /// produces a sane F16 planar result: a neutral-grey NV12 input (Y=U=V=128,
6438    /// BT.601 full ⇒ RGB≈0.5) must yield all three planes ≈0.5 (half-float).
6439    #[test]
6440    #[cfg(target_os = "macos")]
6441    #[cfg(feature = "opengl")]
6442    fn test_nv12_to_planar_f16_two_pass_opengl_macos() {
6443        let mut gpu = match GLProcessorThreaded::new(None) {
6444            Ok(p) => p,
6445            Err(e) => {
6446                eprintln!("SKIPPED: {} — init failed ({e:?})", function!());
6447                return;
6448            }
6449        };
6450        let (w, h) = (64usize, 64usize);
6451        let src = match TensorDyn::image(
6452            w,
6453            h,
6454            PixelFormat::Nv12,
6455            DType::U8,
6456            Some(TensorMemory::Dma),
6457            edgefirst_tensor::CpuAccess::ReadWrite,
6458        ) {
6459            Ok(t) => t,
6460            Err(e) => {
6461                eprintln!("SKIPPED: {} — NV12 IOSurface alloc: {e:?}", function!());
6462                return;
6463            }
6464        };
6465        src.as_u8().unwrap().map().unwrap().as_mut_slice().fill(128); // Y=U=V=128
6466
6467        let dst = match TensorDyn::image(
6468            w,
6469            h,
6470            PixelFormat::PlanarRgb,
6471            DType::F16,
6472            Some(TensorMemory::Dma),
6473            edgefirst_tensor::CpuAccess::ReadWrite,
6474        ) {
6475            Ok(t) => t,
6476            Err(e) => {
6477                eprintln!("SKIPPED: {} — F16 PlanarRgb IOSurface: {e:?}", function!());
6478                return;
6479            }
6480        };
6481        let mut dst = dst;
6482        // Call convert directly (the convert_img helper restores u8 only).
6483        if let Err(e) = ImageProcessorTrait::convert(
6484            &mut gpu,
6485            &src,
6486            &mut dst,
6487            Rotation::None,
6488            Flip::None,
6489            Crop::no_crop(),
6490        ) {
6491            // GL_EXT_color_buffer_half_float may be absent on some configs; the
6492            // RGBA8 pass-1 + F16 pass-2 path then can't render. Skip rather than
6493            // fail on a capability gap (the same policy as the F16 path tests).
6494            eprintln!(
6495                "SKIPPED: {} — NV12→PlanarRgb F16 not available ({e:?})",
6496                function!()
6497            );
6498            return;
6499        }
6500        let dt = dst.as_f16().expect("dst is F16 PlanarRgb");
6501        let map = dt.map().unwrap();
6502        let vals = map.as_slice();
6503        // Neutral grey → ~0.5 in every plane. Allow generous tolerance for the
6504        // mediump YUV math + half-float rounding.
6505        let mut checked = 0usize;
6506        for &v in vals.iter() {
6507            let f = f32::from(v);
6508            assert!(
6509                (0.40..=0.60).contains(&f),
6510                "planar F16 value {f} not ~0.5 for neutral-grey NV12"
6511            );
6512            checked += 1;
6513        }
6514        assert!(
6515            checked >= w * h * 3,
6516            "expected >= 3 planes of samples, got {checked}"
6517        );
6518    }
6519
6520    /// Profiler-shaped two-pass: a reused **R8/Grey pool** (allocated larger
6521    /// than the frame, the NV24 worst case `3·H`) is reconfigured to an NV12
6522    /// frame, filled at the preserved physical stride, and converted with a
6523    /// letterbox `src_rect` crop into a model-sized PlanarRgb F16 destination —
6524    /// exactly the orchestrator's preprocess. Guards against the pooled
6525    /// two-pass NV→PlanarRgb F16 path hanging/erroring (the exact-size
6526    /// `test_nv12_to_planar_f16_two_pass` never exercised the larger pool).
6527    #[test]
6528    #[cfg(target_os = "macos")]
6529    #[cfg(feature = "opengl")]
6530    fn test_nv12_to_planar_f16_two_pass_pool_opengl_macos() {
6531        let mut gpu = match GLProcessorThreaded::new(None) {
6532            Ok(p) => p,
6533            Err(e) => {
6534                eprintln!("SKIPPED: {} — init failed ({e:?})", function!());
6535                return;
6536            }
6537        };
6538        // Frame 96×64 in a 256×768 R8 pool (3·256 height; bpr padded past 96).
6539        let (fw, fh) = (96usize, 64usize);
6540        let (pool_w, pool_h) = (256usize, 768usize);
6541        let (model_w, model_h) = (128usize, 128usize);
6542
6543        let mut src = match TensorDyn::image(
6544            pool_w,
6545            pool_h,
6546            PixelFormat::Grey,
6547            DType::U8,
6548            Some(TensorMemory::Dma),
6549            edgefirst_tensor::CpuAccess::ReadWrite,
6550        ) {
6551            Ok(t) => t,
6552            Err(e) => {
6553                eprintln!("SKIPPED: {} — R8 pool alloc: {e:?}", function!());
6554                return;
6555            }
6556        };
6557        src.configure_image(fw, fh, PixelFormat::Nv12)
6558            .unwrap_or_else(|e| panic!("configure_image NV12 on pool: {e}"));
6559        let stride = src.as_u8().unwrap().effective_row_stride().unwrap();
6560        src.as_u8().unwrap().map().unwrap().as_mut_slice().fill(128); // neutral grey
6561
6562        let mut dst = match TensorDyn::image(
6563            model_w,
6564            model_h,
6565            PixelFormat::PlanarRgb,
6566            DType::F16,
6567            Some(TensorMemory::Dma),
6568            edgefirst_tensor::CpuAccess::ReadWrite,
6569        ) {
6570            Ok(t) => t,
6571            Err(e) => {
6572                eprintln!("SKIPPED: {} — F16 PlanarRgb dst: {e:?}", function!());
6573                return;
6574            }
6575        };
6576
6577        // Letterbox crop like the profiler: the backend computes the band.
6578        let _ = model_w;
6579        let crop = Crop::new()
6580            .with_source(Some(Region::new(0, 0, fw, fh)))
6581            .with_fit(Fit::Letterbox {
6582                pad: [0, 0, 0, 255],
6583            });
6584        if let Err(e) =
6585            ImageProcessorTrait::convert(&mut gpu, &src, &mut dst, Rotation::None, Flip::None, crop)
6586        {
6587            eprintln!(
6588                "SKIPPED: {} — NV12→PlanarRgb F16 unavailable ({e:?})",
6589                function!()
6590            );
6591            return;
6592        }
6593        let _ = stride;
6594        // Neutral grey → ~0.5 inside the letterbox band; just assert the convert
6595        // completed and produced finite values (no hang, no NaN garbage).
6596        let dt = dst.as_f16().expect("dst F16");
6597        let map = dt.map().unwrap();
6598        let any_half = map.as_slice().iter().any(|&v| {
6599            let f = f32::from(v);
6600            (0.40..=0.60).contains(&f)
6601        });
6602        assert!(any_half, "expected ~0.5 grey samples in the letterbox band");
6603    }
6604
6605    /// Mirrors the orchestrator: the GL processor is created on one thread
6606    /// and `convert()` is called from a *different* thread (the profiler's
6607    /// Pre-processing worker). Reproduces (or rules out) the GL-context /
6608    /// `glFinish` cross-thread hang seen in the live pipeline. A 20 s watchdog
6609    /// fails loudly rather than hanging the whole test binary.
6610    #[test]
6611    #[cfg(target_os = "macos")]
6612    #[cfg(feature = "opengl")]
6613    fn test_nv12_to_planar_f16_cross_thread_opengl_macos() {
6614        use std::sync::mpsc;
6615        // Public ImageProcessor (Send) created HERE (the main test thread),
6616        // exactly like the orchestrator builds `config.processor` during setup.
6617        let mut proc = match ImageProcessor::new() {
6618            Ok(p) => p,
6619            Err(e) => {
6620                eprintln!("SKIPPED: {} — init failed ({e:?})", function!());
6621                return;
6622            }
6623        };
6624        let (fw, fh) = (96usize, 64usize);
6625        let mut src = match TensorDyn::image(
6626            256,
6627            768,
6628            PixelFormat::Grey,
6629            DType::U8,
6630            Some(TensorMemory::Dma),
6631            edgefirst_tensor::CpuAccess::ReadWrite,
6632        ) {
6633            Ok(t) => t,
6634            Err(e) => {
6635                eprintln!("SKIPPED: {} — pool: {e:?}", function!());
6636                return;
6637            }
6638        };
6639        src.configure_image(fw, fh, PixelFormat::Nv12).unwrap();
6640        src.as_u8().unwrap().map().unwrap().as_mut_slice().fill(128);
6641        let mut dst = match TensorDyn::image(
6642            128,
6643            128,
6644            PixelFormat::PlanarRgb,
6645            DType::F16,
6646            Some(TensorMemory::Dma),
6647            edgefirst_tensor::CpuAccess::ReadWrite,
6648        ) {
6649            Ok(t) => t,
6650            Err(e) => {
6651                eprintln!("SKIPPED: {} — dst: {e:?}", function!());
6652                return;
6653            }
6654        };
6655        let crop = Crop::new().with_source(Some(Region::new(0, 0, fw, fh)));
6656
6657        // ...then MOVED to a worker thread where convert() runs — exactly the
6658        // orchestrator's create-on-setup / convert-on-Pre-processing split.
6659        let (tx, rx) = mpsc::channel::<bool>();
6660        let worker = std::thread::spawn(move || {
6661            let _ = ImageProcessorTrait::convert(
6662                &mut proc,
6663                &src,
6664                &mut dst,
6665                Rotation::None,
6666                Flip::None,
6667                crop,
6668            );
6669            let _ = tx.send(true);
6670        });
6671        match rx.recv_timeout(std::time::Duration::from_secs(20)) {
6672            Ok(_) => { let _ = worker.join(); }
6673            Err(_) => panic!(
6674                "cross-thread NV12→PlanarRgb convert HUNG (>20s) — reproduces the orchestrator deadlock"
6675            ),
6676        }
6677    }
6678
6679    /// Reproduces the profiler's progressive-slowdown/hang: one processor
6680    /// converting many **varying-size** NV frames (like a COCO dataset) from a
6681    /// reused R8 pool into a fixed PlanarRgb F16 model input. The two-pass path
6682    /// reallocated its RGBA intermediate per frame-size, churning/leaking
6683    /// pbuffers until the GPU stalled. Asserts per-convert latency stays bounded
6684    /// (no runaway) over many iterations.
6685    #[test]
6686    #[cfg(target_os = "macos")]
6687    #[cfg(feature = "opengl")]
6688    fn test_nv_to_planar_f16_varying_sizes_no_leak_opengl_macos() {
6689        let mut gpu = match GLProcessorThreaded::new(None) {
6690            Ok(p) => p,
6691            Err(e) => {
6692                eprintln!("SKIPPED: {} — init failed ({e:?})", function!());
6693                return;
6694            }
6695        };
6696        // Mirror the orchestrator's ring buffers at depth 4: a pool of source R8
6697        // tensors AND a pool of PlanarRgb F16 dst slots, both cycled per frame.
6698        let (max_w, max_h) = (640usize, 640usize);
6699        let depth = 4usize;
6700        let mut srcs = Vec::new();
6701        let mut dsts = Vec::new();
6702        for _ in 0..depth {
6703            srcs.push(
6704                match TensorDyn::image(
6705                    max_w,
6706                    max_h * 3,
6707                    PixelFormat::Grey,
6708                    DType::U8,
6709                    Some(TensorMemory::Dma),
6710                    edgefirst_tensor::CpuAccess::ReadWrite,
6711                ) {
6712                    Ok(t) => t,
6713                    Err(e) => {
6714                        eprintln!("SKIPPED: {} — pool: {e:?}", function!());
6715                        return;
6716                    }
6717                },
6718            );
6719            dsts.push(
6720                match TensorDyn::image(
6721                    640,
6722                    640,
6723                    PixelFormat::PlanarRgb,
6724                    DType::F16,
6725                    Some(TensorMemory::Dma),
6726                    edgefirst_tensor::CpuAccess::ReadWrite,
6727                ) {
6728                    Ok(t) => t,
6729                    Err(e) => {
6730                        eprintln!("SKIPPED: {} — dst: {e:?}", function!());
6731                        return;
6732                    }
6733                },
6734            );
6735        }
6736        // COCO-like assorted frame sizes (all ≤ max), cycled.
6737        let sizes = [
6738            (640, 480),
6739            (500, 375),
6740            (640, 427),
6741            (333, 500),
6742            (480, 640),
6743            (612, 612),
6744            (428, 640),
6745            (576, 432),
6746        ];
6747        let mut first_ms = 0f64;
6748        let mut last_ms = 0f64;
6749        let iters = 40usize;
6750        for i in 0..iters {
6751            let (fw, fh) = sizes[i % sizes.len()];
6752            let src = &mut srcs[i % depth];
6753            let dst = &mut dsts[i % depth];
6754            src.configure_image(fw, fh, PixelFormat::Nv24).unwrap();
6755            src.as_u8().unwrap().map().unwrap().as_mut_slice().fill(128);
6756            let crop = Crop::new().with_source(Some(Region::new(0, 0, fw, fh)));
6757            let t0 = std::time::Instant::now();
6758            ImageProcessorTrait::convert(&mut gpu, src, dst, Rotation::None, Flip::None, crop)
6759                .unwrap_or_else(|e| panic!("convert iter {i} ({fw}×{fh}): {e}"));
6760            let ms = t0.elapsed().as_secs_f64() * 1e3;
6761            if i == 2 {
6762                first_ms = ms;
6763            }
6764            if i == iters - 1 {
6765                last_ms = ms;
6766            }
6767        }
6768        eprintln!("first={first_ms:.2}ms last={last_ms:.2}ms");
6769        assert!(
6770            last_ms < first_ms * 5.0 + 5.0,
6771            "convert latency ran away: first {first_ms:.2}ms → last {last_ms:.2}ms (intermediate/pbuffer leak)"
6772        );
6773    }
6774
6775    /// Step-2 verification: NV12/NV16/NV24 (R8 IOSurface) → RGBA on the GPU
6776    /// must match the CPU `yuv` kernels within shader rounding. Fills an
6777    /// IOSurface source and a Mem source from the same logical YUV pattern
6778    /// (each at its own row stride), converts the IOSurface on the GPU and the
6779    /// Mem one on the CPU, and compares. Exercises the in-shader semi-planar
6780    /// addressing for all three subsamplings (incl. NV24's 2×-wide UV rows).
6781    #[test]
6782    #[cfg(target_os = "macos")]
6783    #[cfg(feature = "opengl")]
6784    fn test_nv12_nv16_nv24_to_rgba_opengl_macos() {
6785        let mut gpu = match GLProcessorThreaded::new(None) {
6786            Ok(p) => p,
6787            Err(e) => {
6788                eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
6789                return;
6790            }
6791        };
6792        let mut cpu = CPUProcessor::new();
6793
6794        // Fill Y plus the interleaved UV plane in the canonical semi-planar
6795        // layout — exactly what the codec writes and the CPU `yuv`-crate reader
6796        // expects: the Y plane is `h` rows at the buffer's row stride; the UV
6797        // plane starts at `h * stride`; each chroma row advances
6798        // `uv_grid_rows * stride` bytes (NV24 carries a full-resolution `2*W`
6799        // byte line == two grid rows, NV12/NV16 one row of `W/2` pairs); each
6800        // (Cb,Cr) pair is two consecutive bytes at column `cx * 2`. This is
6801        // stride-correct for both the tight Mem buffer and the padded IOSurface.
6802        //
6803        // Takes explicit w/h so the closure can be reused across multiple frame
6804        // sizes (even and odd) without capturing a fixed outer variable.
6805        let fill = |buf: &mut [u8], stride: usize, fmt: PixelFormat, w: usize, h: usize| {
6806            for y in 0..h {
6807                for x in 0..w {
6808                    buf[y * stride + x] = ((x * 9 + y * 5) & 0xff) as u8;
6809                }
6810            }
6811            let (cw, ch, uv_grid_rows) = match fmt {
6812                PixelFormat::Nv12 => (w / 2, h / 2, 1usize),
6813                PixelFormat::Nv16 => (w / 2, h, 1usize),
6814                _ => (w, h, 2usize), // Nv24: full-res chroma, 2W bytes/row
6815            };
6816            let uv_plane = h * stride;
6817            for cy in 0..ch {
6818                for cx in 0..cw {
6819                    let off = uv_plane + cy * uv_grid_rows * stride + cx * 2;
6820                    buf[off] = ((cx * 11 + 30) & 0xff) as u8;
6821                    buf[off + 1] = ((cy * 7 + 200) & 0xff) as u8;
6822                }
6823            }
6824        };
6825
6826        for fmt in [PixelFormat::Nv12, PixelFormat::Nv16, PixelFormat::Nv24] {
6827            for (w, h) in [
6828                (16usize, 16usize), // original even-dim case
6829                (15, 16),           // odd-W
6830                (16, 15),           // odd-H
6831            ] {
6832                let mem = TensorDyn::image(
6833                    w,
6834                    h,
6835                    fmt,
6836                    DType::U8,
6837                    None,
6838                    edgefirst_tensor::CpuAccess::ReadWrite,
6839                )
6840                .unwrap();
6841                let mem_stride = mem.as_u8().unwrap().effective_row_stride().unwrap();
6842                fill(
6843                    mem.as_u8().unwrap().map().unwrap().as_mut_slice(),
6844                    mem_stride,
6845                    fmt,
6846                    w,
6847                    h,
6848                );
6849                let cpu_dst = TensorDyn::image(
6850                    w,
6851                    h,
6852                    PixelFormat::Rgba,
6853                    DType::U8,
6854                    None,
6855                    edgefirst_tensor::CpuAccess::ReadWrite,
6856                )
6857                .unwrap();
6858                let (r, _s, cpu_dst) = convert_img(
6859                    &mut cpu,
6860                    mem,
6861                    cpu_dst,
6862                    Rotation::None,
6863                    Flip::None,
6864                    Crop::no_crop(),
6865                );
6866                r.unwrap_or_else(|e| panic!("CPU {fmt:?}->{w}x{h}->RGBA: {e}"));
6867
6868                let ios = TensorDyn::image(
6869                    w,
6870                    h,
6871                    fmt,
6872                    DType::U8,
6873                    Some(TensorMemory::Dma),
6874                    edgefirst_tensor::CpuAccess::ReadWrite,
6875                )
6876                .unwrap_or_else(|e| panic!("{fmt:?} {w}x{h} IOSurface alloc: {e}"));
6877                let ios_stride = ios.as_u8().unwrap().effective_row_stride().unwrap();
6878                fill(
6879                    ios.as_u8().unwrap().map().unwrap().as_mut_slice(),
6880                    ios_stride,
6881                    fmt,
6882                    w,
6883                    h,
6884                );
6885                let gpu_dst = TensorDyn::image(
6886                    w,
6887                    h,
6888                    PixelFormat::Rgba,
6889                    DType::U8,
6890                    Some(TensorMemory::Dma),
6891                    edgefirst_tensor::CpuAccess::ReadWrite,
6892                )
6893                .unwrap();
6894                let (r, _s, gpu_dst) = convert_img(
6895                    &mut gpu,
6896                    ios,
6897                    gpu_dst,
6898                    Rotation::None,
6899                    Flip::None,
6900                    Crop::no_crop(),
6901                );
6902                r.unwrap_or_else(|e| panic!("GPU {fmt:?}->{w}x{h}->RGBA on ANGLE: {e}"));
6903
6904                let cs = cpu_dst.as_u8().unwrap().effective_row_stride().unwrap();
6905                let cmap = cpu_dst.as_u8().unwrap().map().unwrap();
6906                let cb = cmap.as_slice();
6907                let gs = gpu_dst.as_u8().unwrap().effective_row_stride().unwrap();
6908                let gmap = gpu_dst.as_u8().unwrap().map().unwrap();
6909                let gb = gmap.as_slice();
6910                let mut max_d = 0i16;
6911                for y in 0..h {
6912                    for x in 0..w {
6913                        for c in 0..3 {
6914                            let cv = cb[y * cs + x * 4 + c] as i16;
6915                            let gv = gb[y * gs + x * 4 + c] as i16;
6916                            max_d = max_d.max((cv - gv).abs());
6917                        }
6918                    }
6919                }
6920                assert!(
6921                    max_d <= 3,
6922                    "{fmt:?} {w}x{h}: GPU vs CPU RGBA max channel diff {max_d} > 3"
6923                );
6924            }
6925        }
6926    }
6927
6928    /// Phase-0 gate: the reused-pool / larger-surface case. A single R8 pool
6929    /// IOSurface (allocated bigger than the frame, so its `bytesPerRow` exceeds
6930    /// the frame's even width) is reconfigured to each NV frame, filled at the
6931    /// preserved physical stride, and converted on the GPU. This proves ANGLE
6932    /// binds the *whole* physical surface as a pbuffer and that `texelFetch`
6933    /// resolves the frame's Y/UV texels through the surface's real `bytesPerRow`
6934    /// (the physical-grid / logical-ROI decoupling). GPU must match CPU ≤3 LSB.
6935    #[test]
6936    #[cfg(target_os = "macos")]
6937    #[cfg(feature = "opengl")]
6938    fn test_nv_to_rgba_larger_pool_surface_opengl_macos() {
6939        let mut gpu = match GLProcessorThreaded::new(None) {
6940            Ok(p) => p,
6941            Err(e) => {
6942                eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
6943                return;
6944            }
6945        };
6946        let mut cpu = CPUProcessor::new();
6947        // The pool is generously oversized (256-wide → bpr 256, well beyond any
6948        // frame's even width) and tall enough for NV24's 3·H at the test frames.
6949        let (pool_w, pool_h) = (256usize, 256usize);
6950
6951        // Canonical semi-planar fill: Y plane at the row stride, then the UV
6952        // plane at `h * stride` with each chroma row advancing `uv_grid_rows *
6953        // stride` bytes. Stride-correct for both tight Mem and padded IOSurface.
6954        let fill = |buf: &mut [u8], stride: usize, fmt: PixelFormat, w: usize, h: usize| {
6955            for y in 0..h {
6956                for x in 0..w {
6957                    buf[y * stride + x] = ((x * 9 + y * 5) & 0xff) as u8;
6958                }
6959            }
6960            let (cw, ch, uv_grid_rows) = match fmt {
6961                PixelFormat::Nv12 => (w / 2, h / 2, 1usize),
6962                PixelFormat::Nv16 => (w / 2, h, 1usize),
6963                _ => (w, h, 2usize), // Nv24: full-res chroma, 2W bytes/row
6964            };
6965            let uv_plane = h * stride;
6966            for cy in 0..ch {
6967                for cx in 0..cw {
6968                    let off = uv_plane + cy * uv_grid_rows * stride + cx * 2;
6969                    buf[off] = ((cx * 11 + 30) & 0xff) as u8;
6970                    buf[off + 1] = ((cy * 7 + 200) & 0xff) as u8;
6971                }
6972            }
6973        };
6974
6975        for fmt in [PixelFormat::Nv12, PixelFormat::Nv16, PixelFormat::Nv24] {
6976            for (w, h) in [
6977                (40usize, 24usize), // original even-dim case
6978                (15, 16),           // odd-W
6979                (16, 15),           // odd-H
6980            ] {
6981                // `ew` is the minimum even extent; the pool stride must exceed it
6982                // to actually exercise the physical-stride shader decoupling.
6983                let ew = w.next_multiple_of(2);
6984
6985                // CPU reference from a tightly-packed Mem tensor at the frame size.
6986                let mem = TensorDyn::image(
6987                    w,
6988                    h,
6989                    fmt,
6990                    DType::U8,
6991                    None,
6992                    edgefirst_tensor::CpuAccess::ReadWrite,
6993                )
6994                .unwrap();
6995                let mem_stride = mem.as_u8().unwrap().effective_row_stride().unwrap();
6996                fill(
6997                    mem.as_u8().unwrap().map().unwrap().as_mut_slice(),
6998                    mem_stride,
6999                    fmt,
7000                    w,
7001                    h,
7002                );
7003                let cpu_dst = TensorDyn::image(
7004                    w,
7005                    h,
7006                    PixelFormat::Rgba,
7007                    DType::U8,
7008                    None,
7009                    edgefirst_tensor::CpuAccess::ReadWrite,
7010                )
7011                .unwrap();
7012                let (r, _s, cpu_dst) = convert_img(
7013                    &mut cpu,
7014                    mem,
7015                    cpu_dst,
7016                    Rotation::None,
7017                    Flip::None,
7018                    Crop::no_crop(),
7019                );
7020                r.unwrap_or_else(|e| panic!("CPU {fmt:?}->{w}x{h}->RGBA: {e}"));
7021
7022                // GPU source: a LARGER R8 pool surface, reconfigured down to the
7023                // frame. Phase 1 preserves the pool's padded `bytesPerRow` as the
7024                // tensor's row stride; the fill writes the frame at that stride.
7025                let mut ios = match TensorDyn::image(
7026                    pool_w,
7027                    pool_h,
7028                    PixelFormat::Grey,
7029                    DType::U8,
7030                    Some(TensorMemory::Dma),
7031                    edgefirst_tensor::CpuAccess::ReadWrite,
7032                ) {
7033                    Ok(t) => t,
7034                    Err(e) => {
7035                        eprintln!("SKIPPED: {} — R8 pool IOSurface alloc: {e:?}", function!());
7036                        return;
7037                    }
7038                };
7039                ios.configure_image(w, h, fmt)
7040                    .unwrap_or_else(|e| panic!("configure_image {fmt:?} {w}x{h} on pool: {e}"));
7041                let ios_stride = ios.as_u8().unwrap().effective_row_stride().unwrap();
7042                assert!(
7043                    ios_stride > ew,
7044                    "{fmt:?} {w}x{h}: pool stride {ios_stride} should exceed even width {ew} \
7045                     (test must exercise padding)"
7046                );
7047                fill(
7048                    ios.as_u8().unwrap().map().unwrap().as_mut_slice(),
7049                    ios_stride,
7050                    fmt,
7051                    w,
7052                    h,
7053                );
7054
7055                let gpu_dst = TensorDyn::image(
7056                    w,
7057                    h,
7058                    PixelFormat::Rgba,
7059                    DType::U8,
7060                    Some(TensorMemory::Dma),
7061                    edgefirst_tensor::CpuAccess::ReadWrite,
7062                )
7063                .unwrap();
7064                let (r, _s, gpu_dst) = convert_img(
7065                    &mut gpu,
7066                    ios,
7067                    gpu_dst,
7068                    Rotation::None,
7069                    Flip::None,
7070                    Crop::no_crop(),
7071                );
7072                r.unwrap_or_else(|e| {
7073                    panic!("GPU {fmt:?}->{w}x{h}->RGBA (pool surface) on ANGLE: {e}")
7074                });
7075
7076                let cs = cpu_dst.as_u8().unwrap().effective_row_stride().unwrap();
7077                let cmap = cpu_dst.as_u8().unwrap().map().unwrap();
7078                let cb = cmap.as_slice();
7079                let gs = gpu_dst.as_u8().unwrap().effective_row_stride().unwrap();
7080                let gmap = gpu_dst.as_u8().unwrap().map().unwrap();
7081                let gb = gmap.as_slice();
7082                let mut max_d = 0i16;
7083                for y in 0..h {
7084                    for x in 0..w {
7085                        for c in 0..3 {
7086                            let cv = cb[y * cs + x * 4 + c] as i16;
7087                            let gv = gb[y * gs + x * 4 + c] as i16;
7088                            max_d = max_d.max((cv - gv).abs());
7089                        }
7090                    }
7091                }
7092                assert!(
7093                    max_d <= 3,
7094                    "{fmt:?} {w}x{h}: GPU(pool surface) vs CPU RGBA max channel diff {max_d} > 3"
7095                );
7096            }
7097        }
7098    }
7099
7100    #[test]
7101    #[cfg(target_os = "macos")]
7102    #[cfg(feature = "opengl")]
7103    fn test_yuyv_to_rgba_opengl_macos() {
7104        let mut proc = match GLProcessorThreaded::new(None) {
7105            Ok(p) => p,
7106            Err(e) => {
7107                eprintln!(
7108                    "SKIPPED: {} — GL engine init failed ({e:?}). \
7109                     Install ANGLE via `brew install startergo/angle/angle` \
7110                     and re-sign per README.md § macOS GPU Acceleration to \
7111                     run this test.",
7112                    function!()
7113                );
7114                return;
7115            }
7116        };
7117
7118        let src = load_bytes_to_tensor(
7119            1280,
7120            720,
7121            PixelFormat::Yuyv,
7122            Some(TensorMemory::Dma),
7123            &edgefirst_bench::testdata::read("camera720p.yuyv"),
7124        )
7125        .unwrap();
7126
7127        let dst = TensorDyn::image(
7128            1280,
7129            720,
7130            PixelFormat::Rgba,
7131            DType::U8,
7132            Some(TensorMemory::Dma),
7133            edgefirst_tensor::CpuAccess::ReadWrite,
7134        )
7135        .unwrap();
7136
7137        let (result, _src, dst) = convert_img(
7138            &mut proc,
7139            src,
7140            dst,
7141            Rotation::None,
7142            Flip::None,
7143            Crop::no_crop(),
7144        );
7145        result.unwrap();
7146
7147        let target_image = TensorDyn::image(
7148            1280,
7149            720,
7150            PixelFormat::Rgba,
7151            DType::U8,
7152            None,
7153            edgefirst_tensor::CpuAccess::ReadWrite,
7154        )
7155        .unwrap();
7156        target_image
7157            .as_u8()
7158            .unwrap()
7159            .map()
7160            .unwrap()
7161            .as_mut_slice()
7162            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
7163
7164        // macOS YUYV shader now threads per-tensor colorimetry: the untagged
7165        // camera720p source resolves to BT.709 limited (matching the BT.709
7166        // reference), measured 0.9973 on ANGLE — up from 0.9733 under the old
7167        // BT.601-full stop-gap. 0.98 leaves headroom for cross-GPU variance.
7168        compare_images(&dst, &target_image, 0.98, function!());
7169    }
7170
7171    /// Multi-resolution smoke test: convert YUYV→RGBA via the GL
7172    /// backend at a small (64×32) frame and a 4K (3840×2160) frame,
7173    /// both filled with a synthetic mid-grey pattern. Validates the
7174    /// shader math at the chroma-pairing boundary on small textures
7175    /// and exercises the IOSurface bytes-per-row alignment path at 4K
7176    /// (3840 pixels × 2 bytes/pixel = 7680 bytes, naturally 64-aligned).
7177    ///
7178    /// Resolutions below 32 pixels wide aren't tested because the
7179    /// IOSurface allocator pads bpr to 64 bytes — for a 4-px-wide
7180    /// YUYV surface that's 8 bytes data + 56 bytes padding per row,
7181    /// which exercises a sampling pattern that's ANGLE-version
7182    /// dependent rather than HAL-correctness dependent.
7183    ///
7184    /// This complements `test_yuyv_to_rgba_opengl_macos` (which checks
7185    /// pixel-exact correctness against a reference image at 720p) by
7186    /// ensuring the pipeline does not crash or produce gross errors at
7187    /// resolution extremes. Pixel-exact validation at 4K would require
7188    /// a 30 MB reference file we don't want to bundle.
7189    #[test]
7190    #[cfg(target_os = "macos")]
7191    #[cfg(feature = "opengl")]
7192    fn test_yuyv_to_rgba_opengl_macos_multi_resolution() {
7193        let mut proc = match GLProcessorThreaded::new(None) {
7194            Ok(p) => p,
7195            Err(e) => {
7196                eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
7197                return;
7198            }
7199        };
7200
7201        for (w, h) in [(64usize, 32usize), (3840, 2160)] {
7202            // Synthetic YUYV: Y=128 (mid-grey luma), U=V=128 (neutral
7203            // chroma) → RGB grey at the output.
7204            let bytes_per_row = w * 2;
7205            let mut yuyv = vec![0u8; bytes_per_row * h];
7206            for chunk in yuyv.chunks_exact_mut(4) {
7207                chunk[0] = 128; // Y0
7208                chunk[1] = 128; // U
7209                chunk[2] = 128; // Y1
7210                chunk[3] = 128; // V
7211            }
7212
7213            let src = load_bytes_to_tensor(w, h, PixelFormat::Yuyv, Some(TensorMemory::Dma), &yuyv)
7214                .unwrap();
7215
7216            let dst = TensorDyn::image(
7217                w,
7218                h,
7219                PixelFormat::Rgba,
7220                DType::U8,
7221                Some(TensorMemory::Dma),
7222                edgefirst_tensor::CpuAccess::ReadWrite,
7223            )
7224            .unwrap();
7225
7226            let (result, _src, dst) = convert_img(
7227                &mut proc,
7228                src,
7229                dst,
7230                Rotation::None,
7231                Flip::None,
7232                Crop::no_crop(),
7233            );
7234            result.expect("GL convert should succeed at this resolution");
7235
7236            // The neutral-chroma input must produce a near-grey output;
7237            // BT.709 limited-range maps Y=128/UV=128 → roughly
7238            // (130, 130, 130). Allow ±4 LSB for `mediump float` shader
7239            // rounding.
7240            let dst_u8 = dst.as_u8().unwrap();
7241            let dst_map = dst_u8.map().unwrap();
7242            let dst_bytes = dst_map.as_slice();
7243            assert_eq!(dst_bytes.len(), w * h * 4, "RGBA byte count");
7244            for px in dst_bytes.chunks_exact(4) {
7245                for (i, &c) in px[..3].iter().enumerate() {
7246                    assert!(
7247                        (120..=140).contains(&c),
7248                        "{}: channel {i} = {c} (expected ~128 ±12) at {w}×{h}",
7249                        function!(),
7250                    );
7251                }
7252                assert_eq!(px[3], 255, "alpha must be 1.0");
7253            }
7254        }
7255    }
7256
7257    /// Verify that two consecutive convert() calls on the same source
7258    /// tensor reuse the cached EGL pbuffer. Tests the cache hit path
7259    /// added with the macOS GL backend hardening — without it, each
7260    /// frame would pay `eglCreatePbufferFromClientBuffer` + destroy.
7261    ///
7262    /// This is a behaviour test rather than a perf test (the timing
7263    /// difference is 100-200µs which is too noisy to assert on); we
7264    /// check that the second call succeeds and produces a result
7265    /// identical to the first.
7266    #[test]
7267    #[cfg(target_os = "macos")]
7268    #[cfg(feature = "opengl")]
7269    fn test_macos_gl_pbuffer_cache_reuses_surfaces() {
7270        let mut proc = match GLProcessorThreaded::new(None) {
7271            Ok(p) => p,
7272            Err(e) => {
7273                eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
7274                return;
7275            }
7276        };
7277
7278        // Allocate one source + one destination, run convert twice.
7279        let mut yuyv = vec![0u8; 64 * 32 * 2];
7280        for chunk in yuyv.chunks_exact_mut(4) {
7281            chunk[0] = 200;
7282            chunk[1] = 100;
7283            chunk[2] = 200;
7284            chunk[3] = 156;
7285        }
7286        let src = load_bytes_to_tensor(64, 32, PixelFormat::Yuyv, Some(TensorMemory::Dma), &yuyv)
7287            .unwrap();
7288        let dst = TensorDyn::image(
7289            64,
7290            32,
7291            PixelFormat::Rgba,
7292            DType::U8,
7293            Some(TensorMemory::Dma),
7294            edgefirst_tensor::CpuAccess::ReadWrite,
7295        )
7296        .unwrap();
7297
7298        let (r1, src, dst) = convert_img(
7299            &mut proc,
7300            src,
7301            dst,
7302            Rotation::None,
7303            Flip::None,
7304            Crop::no_crop(),
7305        );
7306        r1.unwrap();
7307        let first: Vec<u8> = dst.as_u8().unwrap().map().unwrap().as_slice().to_vec();
7308
7309        let (r2, _src, dst) = convert_img(
7310            &mut proc,
7311            src,
7312            dst,
7313            Rotation::None,
7314            Flip::None,
7315            Crop::no_crop(),
7316        );
7317        r2.unwrap();
7318        let second: Vec<u8> = dst.as_u8().unwrap().map().unwrap().as_slice().to_vec();
7319
7320        assert_eq!(first, second, "cache-hit conversion must be deterministic");
7321    }
7322
7323    /// Steady-state import gate (macOS half of the Linux
7324    /// `dma_pool_steady_state_zero_imports` test): an N-frame convert loop
7325    /// over a fixed pool of IOSurface tensors must create ZERO new EGL
7326    /// pbuffers after the pool has been seen once — pbuffer-cache misses
7327    /// stay flat while hits grow. Counter-based hardening of
7328    /// `test_macos_gl_pbuffer_cache_reuses_surfaces` above: a refactor that
7329    /// re-imports per frame passes the pixel-equality test but fails this.
7330    #[test]
7331    #[cfg(target_os = "macos")]
7332    #[cfg(feature = "opengl")]
7333    fn test_macos_gl_pbuffer_cache_steady_state() {
7334        let mut proc = match GLProcessorThreaded::new(None) {
7335            Ok(p) => p,
7336            Err(e) => {
7337                eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
7338                return;
7339            }
7340        };
7341
7342        let (w, h) = (64usize, 32usize);
7343        const POOL: usize = 3;
7344        const FRAMES: usize = 100;
7345
7346        let yuyv = vec![128u8; w * h * 2];
7347        let pool: Vec<TensorDyn> = (0..POOL)
7348            .map(|_| {
7349                load_bytes_to_tensor(w, h, PixelFormat::Yuyv, Some(TensorMemory::Dma), &yuyv)
7350                    .unwrap()
7351            })
7352            .collect();
7353        let mut dst = TensorDyn::image(
7354            w,
7355            h,
7356            PixelFormat::Rgba,
7357            DType::U8,
7358            Some(TensorMemory::Dma),
7359            edgefirst_tensor::CpuAccess::ReadWrite,
7360        )
7361        .unwrap();
7362
7363        // Warmup: two passes over the pool import every surface once.
7364        for src in pool.iter().cycle().take(POOL * 2) {
7365            proc.convert(src, &mut dst, Rotation::None, Flip::None, Crop::no_crop())
7366                .unwrap();
7367        }
7368        let warm = proc.egl_cache_stats().unwrap();
7369
7370        for src in pool.iter().cycle().take(FRAMES) {
7371            proc.convert(src, &mut dst, Rotation::None, Flip::None, Crop::no_crop())
7372                .unwrap();
7373        }
7374        let steady = proc.egl_cache_stats().unwrap();
7375
7376        assert_eq!(
7377            warm.total_misses(),
7378            steady.total_misses(),
7379            "steady-state loop created new imports (warm {warm:?}, steady {steady:?})"
7380        );
7381        let hits = |s: &GlCacheStats| s.src.hits + s.dst.hits + s.nv_r8.hits;
7382        assert!(
7383            hits(&steady) - hits(&warm) >= FRAMES as u64,
7384            "expected at least {FRAMES} import-cache hits over the loop, got {}",
7385            hits(&steady) - hits(&warm)
7386        );
7387    }
7388
7389    /// Backend assertion for the F16 zero-copy path: when the GL backend
7390    /// initialized (ANGLE on macOS) and reports F16 color-buffer support,
7391    /// the NV12→PlanarRgb-F16 IOSurface convert MUST be handled by the GL
7392    /// engine — proven by the engine's import-cache counters moving, not
7393    /// just by output correctness. This is the guard against the
7394    /// silent-CPU-fallback failure mode: a misclassified IOSurface F16
7395    /// destination keeps every output-correctness test green while
7396    /// quietly running ~10× slower on CPU; only a backend observable
7397    /// catches it. Skips ONLY when GL itself is unavailable or the
7398    /// configuration lacks F16 — a convert error or a CPU-routed convert
7399    /// with the capability present is a FAILURE.
7400    #[test]
7401    #[cfg(target_os = "macos")]
7402    #[cfg(feature = "opengl")]
7403    fn test_macos_gl_f16_planar_is_gl_backed() {
7404        let mut proc = ImageProcessor::new().expect("ImageProcessor");
7405        let Some(ref gl) = proc.opengl else {
7406            eprintln!("SKIPPED: {} — GL backend unavailable", function!());
7407            return;
7408        };
7409        if !gl.supported_render_dtypes().f16 {
7410            eprintln!(
7411                "SKIPPED: {} — configuration lacks F16 color-buffer support",
7412                function!()
7413            );
7414            return;
7415        }
7416        let stats_before = gl.egl_cache_stats().expect("cache stats");
7417
7418        let src = TensorDyn::image(
7419            1280,
7420            720,
7421            PixelFormat::Nv12,
7422            DType::U8,
7423            Some(TensorMemory::Dma),
7424            edgefirst_tensor::CpuAccess::ReadWrite,
7425        )
7426        .unwrap();
7427        {
7428            let t = src.as_u8().unwrap();
7429            let mut m = t.map().unwrap();
7430            for (i, b) in m.as_mut_slice().iter_mut().enumerate() {
7431                *b = ((i * 31) % 211) as u8;
7432            }
7433        }
7434        let mut dst = TensorDyn::image(
7435            640,
7436            640,
7437            PixelFormat::PlanarRgb,
7438            DType::F16,
7439            Some(TensorMemory::Dma),
7440            edgefirst_tensor::CpuAccess::ReadWrite,
7441        )
7442        .unwrap();
7443
7444        proc.convert(
7445            &src,
7446            &mut dst,
7447            Rotation::None,
7448            Flip::None,
7449            Crop::letterbox([114, 114, 114, 255]),
7450        )
7451        .expect("F16 capability reported but the NV12→PlanarF16 convert failed");
7452        let stats_after = proc
7453            .opengl
7454            .as_ref()
7455            .expect("GL backend present")
7456            .egl_cache_stats()
7457            .expect("cache stats");
7458        // ≥ 2 misses: the fused convert imports the zero-copy NV source
7459        // (pass 1) AND the F16 destination (pass 2). Requiring both means a
7460        // convert that imported its source, failed mid-engine, and fell back
7461        // to CPU (1 miss) cannot satisfy this gate.
7462        assert!(
7463            stats_after.total_misses() >= stats_before.total_misses() + 2,
7464            "convert succeeded but the GL engine did not import both the \
7465             source and the F16 destination — the work did not (fully) run \
7466             on the GL backend (silent CPU fallback); misses before={} after={}",
7467            stats_before.total_misses(),
7468            stats_after.total_misses()
7469        );
7470    }
7471
7472    /// Portable oracle for the fused NV12→PlanarRgb-F16 engine convert
7473    /// (two GL passes: NV→RGBA intermediate, then the packed RGBA16F
7474    /// render). New on every platform with F16 render support — macOS
7475    /// IOSurface and Linux DMA-BUF alike. Compares against the CPU
7476    /// backend's reference within the float-path tolerance.
7477    #[test]
7478    #[cfg(feature = "opengl")]
7479    fn test_nv12_to_planar_f16_fused_engine_vs_cpu() {
7480        let mut gl = match ImageProcessor::with_config(ImageProcessorConfig {
7481            backend: ComputeBackend::OpenGl,
7482            ..Default::default()
7483        }) {
7484            Ok(p) if p.opengl.is_some() => p,
7485            _ => {
7486                eprintln!("SKIPPED: {} — GL backend unavailable", function!());
7487                return;
7488            }
7489        };
7490        if !gl
7491            .opengl
7492            .as_ref()
7493            .map(|g| g.supported_render_dtypes().f16)
7494            .unwrap_or(false)
7495        {
7496            eprintln!("SKIPPED: {} — no F16 render support", function!());
7497            return;
7498        }
7499        let mem = if edgefirst_tensor::is_gpu_buffer_available() {
7500            TensorMemory::Dma
7501        } else {
7502            eprintln!("SKIPPED: {} — no zero-copy buffers", function!());
7503            return;
7504        };
7505
7506        let src = TensorDyn::image(
7507            1280,
7508            720,
7509            PixelFormat::Nv12,
7510            DType::U8,
7511            Some(mem),
7512            edgefirst_tensor::CpuAccess::ReadWrite,
7513        )
7514        .unwrap();
7515        {
7516            // Smooth gradients, NOT noise: the GL and CPU paths upsample
7517            // chroma with different kernels (nearest vs bilinear), which
7518            // legitimately diverges on per-texel chroma noise. Gradients
7519            // keep that kernel difference sub-LSB while still exercising
7520            // the full matrix math and the letterbox geometry.
7521            let t = src.as_u8().unwrap();
7522            let mut m = t.map().unwrap();
7523            let buf = m.as_mut_slice();
7524            let (w, h) = (1280usize, 720usize);
7525            for y in 0..h {
7526                for x in 0..w {
7527                    buf[y * w + x] = ((x * 255) / w) as u8; // luma ramp
7528                }
7529            }
7530            for y in 0..(h / 2) {
7531                for x in 0..(w / 2) {
7532                    let o = h * w + y * w + 2 * x;
7533                    buf[o] = ((y * 255) / (h / 2)) as u8; // U vertical ramp
7534                    buf[o + 1] = (((x + y) * 255) / (w / 2 + h / 2)) as u8; // V diagonal
7535                }
7536            }
7537        }
7538        let crop = Crop::letterbox([114, 114, 114, 255]);
7539        let mut gl_dst = TensorDyn::image(
7540            640,
7541            640,
7542            PixelFormat::PlanarRgb,
7543            DType::F16,
7544            Some(mem),
7545            edgefirst_tensor::CpuAccess::ReadWrite,
7546        )
7547        .unwrap();
7548        // Drive the GL backend DIRECTLY: a convert through `ImageProcessor`
7549        // silently falls back to CPU on a GL error, turning this oracle into
7550        // a CPU-vs-CPU tautology. A direct call surfaces the engine error.
7551        gl.opengl
7552            .as_mut()
7553            .expect("GL backend present")
7554            .convert(&src, &mut gl_dst, Rotation::None, Flip::None, crop)
7555            .expect("fused NV12→PlanarF16 GL convert");
7556
7557        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
7558            backend: ComputeBackend::Cpu,
7559            ..Default::default()
7560        })
7561        .unwrap();
7562        let mut cpu_dst = TensorDyn::image(
7563            640,
7564            640,
7565            PixelFormat::PlanarRgb,
7566            DType::F16,
7567            Some(TensorMemory::Mem),
7568            edgefirst_tensor::CpuAccess::ReadWrite,
7569        )
7570        .unwrap();
7571        cpu.convert(&src, &mut cpu_dst, Rotation::None, Flip::None, crop)
7572            .expect("CPU reference convert");
7573
7574        let g = gl_dst.as_f16().unwrap().map().unwrap().as_slice().to_vec();
7575        let c = cpu_dst.as_f16().unwrap().map().unwrap().as_slice().to_vec();
7576        assert_eq!(g.len(), c.len());
7577        let mut max_diff = 0.0f32;
7578        let mut max_at = 0usize;
7579        for (i, (a, b)) in g.iter().zip(c.iter()).enumerate() {
7580            let d = (a.to_f32() - b.to_f32()).abs();
7581            if d > max_diff {
7582                max_diff = d;
7583                max_at = i;
7584            }
7585        }
7586        // Localize: plane (R/G/B), row, col of the worst element.
7587        let (plane, rem) = (max_at / (640 * 640), max_at % (640 * 640));
7588        let (row, col) = (rem / 640, rem % 640);
7589        eprintln!(
7590            "fused-vs-cpu: max_diff={max_diff} at plane={plane} row={row} col={col} \
7591             gl={} cpu={}",
7592            g[max_at].to_f32(),
7593            c[max_at].to_f32()
7594        );
7595        // Two GPU passes (8-bit intermediate + linear filtering) vs the
7596        // CPU's direct path: allow a few 8-bit steps of divergence.
7597        assert!(
7598            max_diff <= 4.0 / 255.0 + 1e-3,
7599            "fused NV12→PlanarF16 diverges from CPU reference: max_diff={max_diff}"
7600        );
7601    }
7602
7603    /// Zero-copy source → heap destination through the GL engine,
7604    /// driven on the GL backend DIRECTLY so a GL error cannot hide
7605    /// behind the `ImageProcessor` CPU fallback (the shape that exposed
7606    /// the macOS `glReadnPixels` failure: imported source, rendered,
7607    /// then errored at the heap readback). RGBA→BGRA so the convert is
7608    /// a pure byte shuffle: no chroma kernel or colorimetry ambiguity
7609    /// (Vivante's NV fast path legitimately diverges from the CPU
7610    /// reference), and the readback stays GL_RGBA (V3D rejects RGB
7611    /// readbacks).
7612    #[test]
7613    #[cfg(feature = "opengl")]
7614    fn test_zero_copy_src_to_mem_dst_gl_direct() {
7615        let mut proc = match ImageProcessor::new() {
7616            Ok(p) if p.opengl.is_some() => p,
7617            _ => {
7618                eprintln!("SKIPPED: {} — GL backend unavailable", function!());
7619                return;
7620            }
7621        };
7622        if !edgefirst_tensor::is_gpu_buffer_available() {
7623            eprintln!("SKIPPED: {} — no zero-copy buffers", function!());
7624            return;
7625        }
7626
7627        let src = TensorDyn::image(
7628            1280,
7629            720,
7630            PixelFormat::Rgba,
7631            DType::U8,
7632            Some(TensorMemory::Dma),
7633            edgefirst_tensor::CpuAccess::ReadWrite,
7634        )
7635        .unwrap();
7636        {
7637            let t = src.as_u8().unwrap();
7638            let mut m = t.map().unwrap();
7639            for (i, b) in m.as_mut_slice().iter_mut().enumerate() {
7640                *b = ((i * 31) % 211) as u8;
7641            }
7642        }
7643        let mut gl_dst = TensorDyn::image(
7644            1280,
7645            720,
7646            PixelFormat::Bgra,
7647            DType::U8,
7648            Some(TensorMemory::Mem),
7649            edgefirst_tensor::CpuAccess::ReadWrite,
7650        )
7651        .unwrap();
7652        proc.opengl
7653            .as_mut()
7654            .expect("GL backend present")
7655            .convert(
7656                &src,
7657                &mut gl_dst,
7658                Rotation::None,
7659                Flip::None,
7660                Crop::no_crop(),
7661            )
7662            .expect("zero-copy src → heap dst GL convert");
7663
7664        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
7665            backend: ComputeBackend::Cpu,
7666            ..Default::default()
7667        })
7668        .unwrap();
7669        let mut cpu_dst = TensorDyn::image(
7670            1280,
7671            720,
7672            PixelFormat::Bgra,
7673            DType::U8,
7674            Some(TensorMemory::Mem),
7675            edgefirst_tensor::CpuAccess::ReadWrite,
7676        )
7677        .unwrap();
7678        cpu.convert(
7679            &src,
7680            &mut cpu_dst,
7681            Rotation::None,
7682            Flip::None,
7683            Crop::no_crop(),
7684        )
7685        .expect("CPU reference convert");
7686
7687        let g = gl_dst.as_u8().unwrap().map().unwrap().as_slice().to_vec();
7688        let c = cpu_dst.as_u8().unwrap().map().unwrap().as_slice().to_vec();
7689        assert_eq!(g.len(), c.len());
7690        let max_diff = g
7691            .iter()
7692            .zip(c.iter())
7693            .map(|(a, b)| a.abs_diff(*b))
7694            .max()
7695            .unwrap();
7696        // Same-size byte shuffle: GL samples texel centers 1:1, so allow
7697        // only rounding slack.
7698        assert!(
7699            max_diff <= 2,
7700            "zero-copy src → heap dst diverges from CPU reference: max_diff={max_diff}"
7701        );
7702    }
7703
7704    #[test]
7705    #[cfg(target_os = "linux")]
7706    fn test_yuyv_to_rgb_g2d() {
7707        if !is_g2d_available() {
7708            eprintln!("SKIPPED: test_yuyv_to_rgb_g2d - G2D library (libg2d.so.2) not available");
7709            return;
7710        }
7711        if !is_dma_available() {
7712            eprintln!(
7713                "SKIPPED: test_yuyv_to_rgb_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
7714            );
7715            return;
7716        }
7717
7718        let src = load_bytes_to_tensor(
7719            1280,
7720            720,
7721            PixelFormat::Yuyv,
7722            None,
7723            &edgefirst_bench::testdata::read("camera720p.yuyv"),
7724        )
7725        .unwrap();
7726
7727        let g2d_dst = TensorDyn::image(
7728            1280,
7729            720,
7730            PixelFormat::Rgb,
7731            DType::U8,
7732            Some(TensorMemory::Dma),
7733            edgefirst_tensor::CpuAccess::ReadWrite,
7734        )
7735        .unwrap();
7736        let mut g2d_converter = G2DProcessor::new().unwrap();
7737
7738        let (result, src, g2d_dst) = convert_img(
7739            &mut g2d_converter,
7740            src,
7741            g2d_dst,
7742            Rotation::None,
7743            Flip::None,
7744            Crop::no_crop(),
7745        );
7746        result.unwrap();
7747
7748        let cpu_dst = TensorDyn::image(
7749            1280,
7750            720,
7751            PixelFormat::Rgb,
7752            DType::U8,
7753            None,
7754            edgefirst_tensor::CpuAccess::ReadWrite,
7755        )
7756        .unwrap();
7757        let mut cpu_converter: CPUProcessor = CPUProcessor::new();
7758
7759        let (result, _src, cpu_dst) = convert_img(
7760            &mut cpu_converter,
7761            src,
7762            cpu_dst,
7763            Rotation::None,
7764            Flip::None,
7765            Crop::no_crop(),
7766        );
7767        result.unwrap();
7768
7769        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
7770        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
7771        // the YUV-matrix delta that forced 0.95 has closed; tightened to
7772        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
7773        // structural gap not exercised by these limited-range fixtures.
7774        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
7775    }
7776
7777    #[test]
7778    #[cfg(target_os = "linux")]
7779    fn test_yuyv_to_yuyv_resize_g2d() {
7780        if !is_g2d_available() {
7781            eprintln!(
7782                "SKIPPED: test_yuyv_to_yuyv_resize_g2d - G2D library (libg2d.so.2) not available"
7783            );
7784            return;
7785        }
7786        if !is_dma_available() {
7787            eprintln!(
7788                "SKIPPED: test_yuyv_to_yuyv_resize_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
7789            );
7790            return;
7791        }
7792
7793        let src = load_bytes_to_tensor(
7794            1280,
7795            720,
7796            PixelFormat::Yuyv,
7797            None,
7798            &edgefirst_bench::testdata::read("camera720p.yuyv"),
7799        )
7800        .unwrap();
7801
7802        let g2d_dst = TensorDyn::image(
7803            600,
7804            400,
7805            PixelFormat::Yuyv,
7806            DType::U8,
7807            Some(TensorMemory::Dma),
7808            edgefirst_tensor::CpuAccess::ReadWrite,
7809        )
7810        .unwrap();
7811        let mut g2d_converter = G2DProcessor::new().unwrap();
7812
7813        let (result, src, g2d_dst) = convert_img(
7814            &mut g2d_converter,
7815            src,
7816            g2d_dst,
7817            Rotation::None,
7818            Flip::None,
7819            Crop::no_crop(),
7820        );
7821        result.unwrap();
7822
7823        let cpu_dst = TensorDyn::image(
7824            600,
7825            400,
7826            PixelFormat::Yuyv,
7827            DType::U8,
7828            None,
7829            edgefirst_tensor::CpuAccess::ReadWrite,
7830        )
7831        .unwrap();
7832        let mut cpu_converter: CPUProcessor = CPUProcessor::new();
7833
7834        let (result, _src, cpu_dst) = convert_img(
7835            &mut cpu_converter,
7836            src,
7837            cpu_dst,
7838            Rotation::None,
7839            Flip::None,
7840            Crop::no_crop(),
7841        );
7842        result.unwrap();
7843
7844        // G2D has poor colorimetry support: its hardware YUYV resize/sampling
7845        // diverges from the CPU reference by enough that the similarity score
7846        // sits around 0.85 on every measured G2D core (i.MX 8M Plus, i.MX 95).
7847        // The threshold is held at 0.85 so the test guards against gross
7848        // regressions while tolerating the driver's inherent colorimetry error.
7849        // TODO: compare YUYV↔YUYV directly without a YUYV→RGB convert.
7850        eprintln!(
7851            "WARNING: G2D has poor colorimetry support — YUYV resize diverges from the \
7852             CPU reference (~0.85 similarity); threshold held at 0.85, not 0.95."
7853        );
7854        compare_images_convert_to_rgb(&g2d_dst, &cpu_dst, 0.85, function!());
7855    }
7856
7857    #[test]
7858    fn test_yuyv_to_rgba_resize_cpu() {
7859        let src = load_bytes_to_tensor(
7860            1280,
7861            720,
7862            PixelFormat::Yuyv,
7863            None,
7864            &edgefirst_bench::testdata::read("camera720p.yuyv"),
7865        )
7866        .unwrap();
7867
7868        let (dst_width, dst_height) = (960, 540);
7869
7870        let dst = TensorDyn::image(
7871            dst_width,
7872            dst_height,
7873            PixelFormat::Rgba,
7874            DType::U8,
7875            None,
7876            edgefirst_tensor::CpuAccess::ReadWrite,
7877        )
7878        .unwrap();
7879        let mut cpu_converter = CPUProcessor::new();
7880
7881        let (result, _src, dst) = convert_img(
7882            &mut cpu_converter,
7883            src,
7884            dst,
7885            Rotation::None,
7886            Flip::None,
7887            Crop::no_crop(),
7888        );
7889        result.unwrap();
7890
7891        let dst_target = TensorDyn::image(
7892            dst_width,
7893            dst_height,
7894            PixelFormat::Rgba,
7895            DType::U8,
7896            None,
7897            edgefirst_tensor::CpuAccess::ReadWrite,
7898        )
7899        .unwrap();
7900        let src_target = load_bytes_to_tensor(
7901            1280,
7902            720,
7903            PixelFormat::Rgba,
7904            None,
7905            &edgefirst_bench::testdata::read("camera720p.rgba"),
7906        )
7907        .unwrap();
7908        let (result, _src_target, dst_target) = convert_img(
7909            &mut cpu_converter,
7910            src_target,
7911            dst_target,
7912            Rotation::None,
7913            Flip::None,
7914            Crop::no_crop(),
7915        );
7916        result.unwrap();
7917
7918        // CPU path resolves the untagged 720p source to BT.709 limited (height
7919        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
7920        compare_images(&dst, &dst_target, 0.98, function!());
7921    }
7922
7923    #[test]
7924    #[cfg(target_os = "linux")]
7925    fn test_yuyv_to_rgba_crop_flip_g2d() {
7926        if !is_g2d_available() {
7927            eprintln!(
7928                "SKIPPED: test_yuyv_to_rgba_crop_flip_g2d - G2D library (libg2d.so.2) not available"
7929            );
7930            return;
7931        }
7932        if !is_dma_available() {
7933            eprintln!(
7934                "SKIPPED: test_yuyv_to_rgba_crop_flip_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
7935            );
7936            return;
7937        }
7938
7939        let src = load_bytes_to_tensor(
7940            1280,
7941            720,
7942            PixelFormat::Yuyv,
7943            Some(TensorMemory::Dma),
7944            &edgefirst_bench::testdata::read("camera720p.yuyv"),
7945        )
7946        .unwrap();
7947
7948        let (dst_width, dst_height) = (640, 640);
7949
7950        let dst_g2d = TensorDyn::image(
7951            dst_width,
7952            dst_height,
7953            PixelFormat::Rgba,
7954            DType::U8,
7955            Some(TensorMemory::Dma),
7956            edgefirst_tensor::CpuAccess::ReadWrite,
7957        )
7958        .unwrap();
7959        let mut g2d_converter = G2DProcessor::new().unwrap();
7960        let crop = Crop::new().with_source(Some(Region::new(20, 15, 400, 300)));
7961
7962        let (result, src, dst_g2d) = convert_img(
7963            &mut g2d_converter,
7964            src,
7965            dst_g2d,
7966            Rotation::None,
7967            Flip::Horizontal,
7968            crop,
7969        );
7970        result.unwrap();
7971
7972        let dst_cpu = TensorDyn::image(
7973            dst_width,
7974            dst_height,
7975            PixelFormat::Rgba,
7976            DType::U8,
7977            Some(TensorMemory::Dma),
7978            edgefirst_tensor::CpuAccess::ReadWrite,
7979        )
7980        .unwrap();
7981        let mut cpu_converter = CPUProcessor::new();
7982
7983        let (result, _src, dst_cpu) = convert_img(
7984            &mut cpu_converter,
7985            src,
7986            dst_cpu,
7987            Rotation::None,
7988            Flip::Horizontal,
7989            crop,
7990        );
7991        result.unwrap();
7992        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
7993        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
7994        // the YUV-matrix delta that forced 0.95 has closed; tightened to
7995        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
7996        // structural gap not exercised by these limited-range fixtures.
7997        compare_images(&dst_g2d, &dst_cpu, 0.98, function!());
7998    }
7999
8000    #[test]
8001    #[cfg(target_os = "linux")]
8002    #[cfg(feature = "opengl")]
8003    fn test_yuyv_to_rgba_crop_flip_opengl() {
8004        if !is_opengl_available() {
8005            eprintln!("SKIPPED: {} - OpenGL not available", function!());
8006            return;
8007        }
8008
8009        if !is_dma_available() {
8010            eprintln!(
8011                "SKIPPED: {} - DMA memory allocation not available (permission denied or no DMA-BUF support)",
8012                function!()
8013            );
8014            return;
8015        }
8016
8017        let src = load_bytes_to_tensor(
8018            1280,
8019            720,
8020            PixelFormat::Yuyv,
8021            Some(TensorMemory::Dma),
8022            &edgefirst_bench::testdata::read("camera720p.yuyv"),
8023        )
8024        .unwrap();
8025
8026        let (dst_width, dst_height) = (640, 640);
8027
8028        let dst_gl = TensorDyn::image(
8029            dst_width,
8030            dst_height,
8031            PixelFormat::Rgba,
8032            DType::U8,
8033            Some(TensorMemory::Dma),
8034            edgefirst_tensor::CpuAccess::ReadWrite,
8035        )
8036        .unwrap();
8037        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
8038        let crop = Crop::new().with_source(Some(Region::new(20, 15, 400, 300)));
8039
8040        let (result, src, dst_gl) = convert_img(
8041            &mut gl_converter,
8042            src,
8043            dst_gl,
8044            Rotation::None,
8045            Flip::Horizontal,
8046            crop,
8047        );
8048        result.unwrap();
8049
8050        let dst_cpu = TensorDyn::image(
8051            dst_width,
8052            dst_height,
8053            PixelFormat::Rgba,
8054            DType::U8,
8055            Some(TensorMemory::Dma),
8056            edgefirst_tensor::CpuAccess::ReadWrite,
8057        )
8058        .unwrap();
8059        let mut cpu_converter = CPUProcessor::new();
8060
8061        let (result, _src, dst_cpu) = convert_img(
8062            &mut cpu_converter,
8063            src,
8064            dst_cpu,
8065            Rotation::None,
8066            Flip::Horizontal,
8067            crop,
8068        );
8069        result.unwrap();
8070        // Post-WS1 the GL path applies the resolved colorimetry via the EGL
8071        // YUV color-space/sample-range hints, so the matrix delta that forced
8072        // 0.95 has closed; tightened to 0.98 (driver-matrix rounding confirmed
8073        // on the GPU lanes).
8074        compare_images(&dst_gl, &dst_cpu, 0.98, function!());
8075    }
8076
8077    #[test]
8078    fn test_vyuy_to_rgba_cpu() {
8079        let file = edgefirst_bench::testdata::read("camera720p.vyuy").to_vec();
8080        let src = TensorDyn::image(
8081            1280,
8082            720,
8083            PixelFormat::Vyuy,
8084            DType::U8,
8085            None,
8086            edgefirst_tensor::CpuAccess::ReadWrite,
8087        )
8088        .unwrap();
8089        src.as_u8()
8090            .unwrap()
8091            .map()
8092            .unwrap()
8093            .as_mut_slice()
8094            .copy_from_slice(&file);
8095
8096        let dst = TensorDyn::image(
8097            1280,
8098            720,
8099            PixelFormat::Rgba,
8100            DType::U8,
8101            None,
8102            edgefirst_tensor::CpuAccess::ReadWrite,
8103        )
8104        .unwrap();
8105        let mut cpu_converter = CPUProcessor::new();
8106
8107        let (result, _src, dst) = convert_img(
8108            &mut cpu_converter,
8109            src,
8110            dst,
8111            Rotation::None,
8112            Flip::None,
8113            Crop::no_crop(),
8114        );
8115        result.unwrap();
8116
8117        let target_image = TensorDyn::image(
8118            1280,
8119            720,
8120            PixelFormat::Rgba,
8121            DType::U8,
8122            None,
8123            edgefirst_tensor::CpuAccess::ReadWrite,
8124        )
8125        .unwrap();
8126        target_image
8127            .as_u8()
8128            .unwrap()
8129            .map()
8130            .unwrap()
8131            .as_mut_slice()
8132            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
8133
8134        // CPU path resolves the untagged 720p source to BT.709 limited (height
8135        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
8136        compare_images(&dst, &target_image, 0.98, function!());
8137    }
8138
8139    #[test]
8140    fn test_vyuy_to_rgb_cpu() {
8141        let file = edgefirst_bench::testdata::read("camera720p.vyuy").to_vec();
8142        let src = TensorDyn::image(
8143            1280,
8144            720,
8145            PixelFormat::Vyuy,
8146            DType::U8,
8147            None,
8148            edgefirst_tensor::CpuAccess::ReadWrite,
8149        )
8150        .unwrap();
8151        src.as_u8()
8152            .unwrap()
8153            .map()
8154            .unwrap()
8155            .as_mut_slice()
8156            .copy_from_slice(&file);
8157
8158        let dst = TensorDyn::image(
8159            1280,
8160            720,
8161            PixelFormat::Rgb,
8162            DType::U8,
8163            None,
8164            edgefirst_tensor::CpuAccess::ReadWrite,
8165        )
8166        .unwrap();
8167        let mut cpu_converter = CPUProcessor::new();
8168
8169        let (result, _src, dst) = convert_img(
8170            &mut cpu_converter,
8171            src,
8172            dst,
8173            Rotation::None,
8174            Flip::None,
8175            Crop::no_crop(),
8176        );
8177        result.unwrap();
8178
8179        let target_image = TensorDyn::image(
8180            1280,
8181            720,
8182            PixelFormat::Rgb,
8183            DType::U8,
8184            None,
8185            edgefirst_tensor::CpuAccess::ReadWrite,
8186        )
8187        .unwrap();
8188        target_image
8189            .as_u8()
8190            .unwrap()
8191            .map()
8192            .unwrap()
8193            .as_mut_slice()
8194            .as_chunks_mut::<3>()
8195            .0
8196            .iter_mut()
8197            .zip(
8198                edgefirst_bench::testdata::read("camera720p.rgba")
8199                    .as_chunks::<4>()
8200                    .0,
8201            )
8202            .for_each(|(dst, src)| *dst = [src[0], src[1], src[2]]);
8203
8204        // CPU path resolves the untagged 720p source to BT.709 limited (height
8205        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
8206        compare_images(&dst, &target_image, 0.98, function!());
8207    }
8208
8209    #[test]
8210    #[cfg(target_os = "linux")]
8211    #[ignore = "G2D does not support VYUY; re-enable when hardware support is added"]
8212    fn test_vyuy_to_rgba_g2d() {
8213        if !is_g2d_available() {
8214            eprintln!("SKIPPED: test_vyuy_to_rgba_g2d - G2D library (libg2d.so.2) not available");
8215            return;
8216        }
8217        if !is_dma_available() {
8218            eprintln!(
8219                "SKIPPED: test_vyuy_to_rgba_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
8220            );
8221            return;
8222        }
8223
8224        let src = load_bytes_to_tensor(
8225            1280,
8226            720,
8227            PixelFormat::Vyuy,
8228            None,
8229            &edgefirst_bench::testdata::read("camera720p.vyuy"),
8230        )
8231        .unwrap();
8232
8233        let dst = TensorDyn::image(
8234            1280,
8235            720,
8236            PixelFormat::Rgba,
8237            DType::U8,
8238            Some(TensorMemory::Dma),
8239            edgefirst_tensor::CpuAccess::ReadWrite,
8240        )
8241        .unwrap();
8242        let mut g2d_converter = G2DProcessor::new().unwrap();
8243
8244        let (result, _src, dst) = convert_img(
8245            &mut g2d_converter,
8246            src,
8247            dst,
8248            Rotation::None,
8249            Flip::None,
8250            Crop::no_crop(),
8251        );
8252        match result {
8253            Err(Error::G2D(_)) => {
8254                eprintln!("SKIPPED: test_vyuy_to_rgba_g2d - G2D does not support PixelFormat::Vyuy format");
8255                return;
8256            }
8257            r => r.unwrap(),
8258        }
8259
8260        let target_image = TensorDyn::image(
8261            1280,
8262            720,
8263            PixelFormat::Rgba,
8264            DType::U8,
8265            None,
8266            edgefirst_tensor::CpuAccess::ReadWrite,
8267        )
8268        .unwrap();
8269        target_image
8270            .as_u8()
8271            .unwrap()
8272            .map()
8273            .unwrap()
8274            .as_mut_slice()
8275            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
8276
8277        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
8278        // so the matrix delta vs the reference that forced 0.95 has closed;
8279        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
8280        compare_images(&dst, &target_image, 0.98, function!());
8281    }
8282
8283    #[test]
8284    #[cfg(target_os = "linux")]
8285    #[ignore = "G2D does not support VYUY; re-enable when hardware support is added"]
8286    fn test_vyuy_to_rgb_g2d() {
8287        if !is_g2d_available() {
8288            eprintln!("SKIPPED: test_vyuy_to_rgb_g2d - G2D library (libg2d.so.2) not available");
8289            return;
8290        }
8291        if !is_dma_available() {
8292            eprintln!(
8293                "SKIPPED: test_vyuy_to_rgb_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
8294            );
8295            return;
8296        }
8297
8298        let src = load_bytes_to_tensor(
8299            1280,
8300            720,
8301            PixelFormat::Vyuy,
8302            None,
8303            &edgefirst_bench::testdata::read("camera720p.vyuy"),
8304        )
8305        .unwrap();
8306
8307        let g2d_dst = TensorDyn::image(
8308            1280,
8309            720,
8310            PixelFormat::Rgb,
8311            DType::U8,
8312            Some(TensorMemory::Dma),
8313            edgefirst_tensor::CpuAccess::ReadWrite,
8314        )
8315        .unwrap();
8316        let mut g2d_converter = G2DProcessor::new().unwrap();
8317
8318        let (result, src, g2d_dst) = convert_img(
8319            &mut g2d_converter,
8320            src,
8321            g2d_dst,
8322            Rotation::None,
8323            Flip::None,
8324            Crop::no_crop(),
8325        );
8326        match result {
8327            Err(Error::G2D(_)) => {
8328                eprintln!(
8329                    "SKIPPED: test_vyuy_to_rgb_g2d - G2D does not support PixelFormat::Vyuy format"
8330                );
8331                return;
8332            }
8333            r => r.unwrap(),
8334        }
8335
8336        let cpu_dst = TensorDyn::image(
8337            1280,
8338            720,
8339            PixelFormat::Rgb,
8340            DType::U8,
8341            None,
8342            edgefirst_tensor::CpuAccess::ReadWrite,
8343        )
8344        .unwrap();
8345        let mut cpu_converter: CPUProcessor = CPUProcessor::new();
8346
8347        let (result, _src, cpu_dst) = convert_img(
8348            &mut cpu_converter,
8349            src,
8350            cpu_dst,
8351            Rotation::None,
8352            Flip::None,
8353            Crop::no_crop(),
8354        );
8355        result.unwrap();
8356
8357        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
8358        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
8359        // the YUV-matrix delta that forced 0.95 has closed; tightened to
8360        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
8361        // structural gap not exercised by these limited-range fixtures.
8362        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
8363    }
8364
8365    #[test]
8366    #[cfg(target_os = "linux")]
8367    #[cfg(feature = "opengl")]
8368    fn test_vyuy_to_rgba_opengl() {
8369        if !is_opengl_available() {
8370            eprintln!("SKIPPED: {} - OpenGL not available", function!());
8371            return;
8372        }
8373        if !is_dma_available() {
8374            eprintln!(
8375                "SKIPPED: {} - DMA memory allocation not available (permission denied or no DMA-BUF support)",
8376                function!()
8377            );
8378            return;
8379        }
8380
8381        let src = load_bytes_to_tensor(
8382            1280,
8383            720,
8384            PixelFormat::Vyuy,
8385            Some(TensorMemory::Dma),
8386            &edgefirst_bench::testdata::read("camera720p.vyuy"),
8387        )
8388        .unwrap();
8389
8390        let dst = TensorDyn::image(
8391            1280,
8392            720,
8393            PixelFormat::Rgba,
8394            DType::U8,
8395            Some(TensorMemory::Dma),
8396            edgefirst_tensor::CpuAccess::ReadWrite,
8397        )
8398        .unwrap();
8399        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
8400
8401        let (result, _src, dst) = convert_img(
8402            &mut gl_converter,
8403            src,
8404            dst,
8405            Rotation::None,
8406            Flip::None,
8407            Crop::no_crop(),
8408        );
8409        match result {
8410            Err(Error::NotSupported(_)) => {
8411                eprintln!(
8412                    "SKIPPED: {} - OpenGL does not support PixelFormat::Vyuy DMA format",
8413                    function!()
8414                );
8415                return;
8416            }
8417            r => r.unwrap(),
8418        }
8419
8420        let target_image = TensorDyn::image(
8421            1280,
8422            720,
8423            PixelFormat::Rgba,
8424            DType::U8,
8425            None,
8426            edgefirst_tensor::CpuAccess::ReadWrite,
8427        )
8428        .unwrap();
8429        target_image
8430            .as_u8()
8431            .unwrap()
8432            .map()
8433            .unwrap()
8434            .as_mut_slice()
8435            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
8436
8437        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
8438        // so the matrix delta vs the reference that forced 0.95 has closed;
8439        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
8440        compare_images(&dst, &target_image, 0.98, function!());
8441    }
8442
8443    #[test]
8444    fn test_nv12_to_rgba_cpu() {
8445        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
8446        let src = TensorDyn::image(
8447            1280,
8448            720,
8449            PixelFormat::Nv12,
8450            DType::U8,
8451            None,
8452            edgefirst_tensor::CpuAccess::ReadWrite,
8453        )
8454        .unwrap();
8455        src.as_u8().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
8456            .copy_from_slice(&file);
8457
8458        let dst = TensorDyn::image(
8459            1280,
8460            720,
8461            PixelFormat::Rgba,
8462            DType::U8,
8463            None,
8464            edgefirst_tensor::CpuAccess::ReadWrite,
8465        )
8466        .unwrap();
8467        let mut cpu_converter = CPUProcessor::new();
8468
8469        let (result, _src, dst) = convert_img(
8470            &mut cpu_converter,
8471            src,
8472            dst,
8473            Rotation::None,
8474            Flip::None,
8475            Crop::no_crop(),
8476        );
8477        result.unwrap();
8478
8479        let target_image = crate::load_image_test_helper(
8480            &edgefirst_bench::testdata::read("zidane.jpg"),
8481            Some(PixelFormat::Rgba),
8482            None,
8483        )
8484        .unwrap();
8485
8486        // Threshold 0.95 (was 0.98): the reference now decodes the colour JPEG
8487        // to native NV12 and then converts to RGBA (was a direct JPEG → RGBA
8488        // decode), so it differs slightly from the RGBA derived from the
8489        // separate `zidane.nv12` fixture.
8490        compare_images(&dst, &target_image, 0.95, function!());
8491    }
8492
8493    #[test]
8494    fn test_nv12_odd_height_to_rgb_cpu() {
8495        // Odd height (even width) — the logical-odd case, e.g. 640×483. The
8496        // contiguous NV12 buffer is `[5 + ceil(5/2), 8]` = `[8, 8]` (5 luma rows
8497        // + 3 chroma rows). A neutral-grey fill (Y=U=V=128, BT.601 full-range)
8498        // must convert to a uniform grey RGB, exercising the odd-height
8499        // chroma-row count and the logical-height derivation in convert.
8500        // (Odd *width* is rounded to an even buffer at allocation, so it is
8501        // covered by the decode integration tests rather than here.)
8502        // CPU-only test: pin to tight host memory (None auto-selects pitch-padded
8503        // DMA on i.MX, which would leave the dst's row padding unconverted and
8504        // break the flat byte scan below).
8505        let mut src = TensorDyn::image(
8506            8,
8507            5,
8508            PixelFormat::Nv12,
8509            DType::U8,
8510            Some(TensorMemory::Mem),
8511            edgefirst_tensor::CpuAccess::ReadWrite,
8512        )
8513        .unwrap();
8514        assert_eq!(src.shape(), &[8, 8]);
8515        assert_eq!((src.width(), src.height()), (Some(8), Some(5)));
8516        src.as_u8().unwrap().map().unwrap().as_mut_slice().fill(128);
8517        // Tag BT.601 full-range so Y=128 decodes to grey 128 (the neutral-grey
8518        // identity this test asserts). Without a tag, the colorimetry heuristic
8519        // resolves an SD tensor to BT.601 *limited*, expanding Y=128 → ~131.
8520        src.set_colorimetry(Some(
8521            edgefirst_tensor::Colorimetry::default()
8522                .with_encoding(edgefirst_tensor::ColorEncoding::Bt601)
8523                .with_range(edgefirst_tensor::ColorRange::Full),
8524        ));
8525
8526        let dst = TensorDyn::image(
8527            8,
8528            5,
8529            PixelFormat::Rgb,
8530            DType::U8,
8531            Some(TensorMemory::Mem),
8532            edgefirst_tensor::CpuAccess::ReadWrite,
8533        )
8534        .unwrap();
8535        let mut cpu_converter = CPUProcessor::new();
8536        let (result, _src, dst) = convert_img(
8537            &mut cpu_converter,
8538            src,
8539            dst,
8540            Rotation::None,
8541            Flip::None,
8542            Crop::no_crop(),
8543        );
8544        result.unwrap();
8545
8546        assert_eq!((dst.width(), dst.height()), (Some(8), Some(5)));
8547        let map = dst.as_u8().unwrap().map().unwrap();
8548        for (i, &b) in map.as_slice().iter().enumerate() {
8549            assert!(
8550                (b as i16 - 128).abs() <= 2,
8551                "pixel byte {i} = {b}, expected ~128 for neutral-grey NV12"
8552            );
8553        }
8554    }
8555
8556    #[test]
8557    fn test_nv24_to_rgb_cpu() {
8558        // NV24 (4:4:4) at 8×4: contiguous buffer is [4*3, 8] = [12, 8] — Y plane
8559        // (4 rows) + full-res interleaved UV plane (8 rows = 2H, 2W bytes per
8560        // chroma row). Neutral-grey fill (Y=U=V=128) must convert to uniform
8561        // grey RGB, exercising the 2× UV stride and shape[0]/3 height recovery.
8562        // CPU-only test: pin to tight host memory (see test_nv12_odd_height_to_rgb_cpu).
8563        let mut src = TensorDyn::image(
8564            8,
8565            4,
8566            PixelFormat::Nv24,
8567            DType::U8,
8568            Some(TensorMemory::Mem),
8569            edgefirst_tensor::CpuAccess::ReadWrite,
8570        )
8571        .unwrap();
8572        assert_eq!(src.shape(), &[12, 8]);
8573        assert_eq!((src.width(), src.height()), (Some(8), Some(4)));
8574        src.as_u8().unwrap().map().unwrap().as_mut_slice().fill(128);
8575        // Tag BT.601 full-range (see test_nv12_odd_height_to_rgb_cpu): without it
8576        // the heuristic picks limited range and Y=128 expands to ~131.
8577        src.set_colorimetry(Some(
8578            edgefirst_tensor::Colorimetry::default()
8579                .with_encoding(edgefirst_tensor::ColorEncoding::Bt601)
8580                .with_range(edgefirst_tensor::ColorRange::Full),
8581        ));
8582
8583        let dst = TensorDyn::image(
8584            8,
8585            4,
8586            PixelFormat::Rgb,
8587            DType::U8,
8588            Some(TensorMemory::Mem),
8589            edgefirst_tensor::CpuAccess::ReadWrite,
8590        )
8591        .unwrap();
8592        let mut cpu_converter = CPUProcessor::new();
8593        let (result, _src, dst) = convert_img(
8594            &mut cpu_converter,
8595            src,
8596            dst,
8597            Rotation::None,
8598            Flip::None,
8599            Crop::no_crop(),
8600        );
8601        result.unwrap();
8602
8603        assert_eq!((dst.width(), dst.height()), (Some(8), Some(4)));
8604        let map = dst.as_u8().unwrap().map().unwrap();
8605        for (i, &b) in map.as_slice().iter().enumerate() {
8606            assert!(
8607                (b as i16 - 128).abs() <= 2,
8608                "pixel byte {i} = {b}, expected ~128 for neutral-grey NV24"
8609            );
8610        }
8611    }
8612
8613    #[test]
8614    fn cpu_nv12_to_rgb_respects_tagged_bt2020() {
8615        // A uniform but *saturated* chroma sample (U/V far from neutral) so the
8616        // YUV→RGB matrix — not just the range — drives the result. Decoding the
8617        // same NV12 bytes under BT.601 / BT.709 / BT.2020 must yield three
8618        // distinct RGB triples, proving the CPU path honours the source's tagged
8619        // ColorEncoding instead of a hardcoded matrix. (G2D declines BT.2020 and
8620        // falls through to this CPU path; QA F9.)
8621        // CPU-only test: pin to tight host memory (see test_nv12_odd_height_to_rgb_cpu).
8622        fn decode_tagged(enc: edgefirst_tensor::ColorEncoding) -> [u8; 3] {
8623            let mut src = TensorDyn::image(
8624                8,
8625                4,
8626                PixelFormat::Nv12,
8627                DType::U8,
8628                Some(TensorMemory::Mem),
8629                edgefirst_tensor::CpuAccess::ReadWrite,
8630            )
8631            .unwrap();
8632            // NV12 8×4: 32-byte Y plane + 16-byte interleaved UV plane (4:2:0).
8633            assert_eq!(src.shape(), &[6, 8]);
8634            {
8635                let mut map = src.as_u8().unwrap().map().unwrap();
8636                let buf = map.as_mut_slice();
8637                buf[..32].fill(120); // Y
8638                for px in buf[32..].chunks_exact_mut(2) {
8639                    px[0] = 180; // U / Cb
8640                    px[1] = 64; // V / Cr
8641                }
8642            }
8643            // Range held constant (Limited) across all three so only the encoding
8644            // matrix varies between runs.
8645            src.set_colorimetry(Some(
8646                edgefirst_tensor::Colorimetry::default()
8647                    .with_encoding(enc)
8648                    .with_range(edgefirst_tensor::ColorRange::Limited),
8649            ));
8650            let dst = TensorDyn::image(
8651                8,
8652                4,
8653                PixelFormat::Rgb,
8654                DType::U8,
8655                Some(TensorMemory::Mem),
8656                edgefirst_tensor::CpuAccess::ReadWrite,
8657            )
8658            .unwrap();
8659            let mut cpu = CPUProcessor::new();
8660            let (result, _src, dst) = convert_img(
8661                &mut cpu,
8662                src,
8663                dst,
8664                Rotation::None,
8665                Flip::None,
8666                Crop::no_crop(),
8667            );
8668            result.unwrap();
8669            let map = dst.as_u8().unwrap().map().unwrap();
8670            let s = map.as_slice();
8671            [s[0], s[1], s[2]]
8672        }
8673
8674        let bt601 = decode_tagged(edgefirst_tensor::ColorEncoding::Bt601);
8675        let bt709 = decode_tagged(edgefirst_tensor::ColorEncoding::Bt709);
8676        let bt2020 = decode_tagged(edgefirst_tensor::ColorEncoding::Bt2020);
8677
8678        assert_ne!(
8679            bt2020, bt601,
8680            "BT.2020 must decode differently from BT.601 ({bt2020:?} vs {bt601:?})"
8681        );
8682        assert_ne!(
8683            bt2020, bt709,
8684            "BT.2020 must decode differently from BT.709 ({bt2020:?} vs {bt709:?})"
8685        );
8686        assert_ne!(
8687            bt601, bt709,
8688            "BT.601 must decode differently from BT.709 ({bt601:?} vs {bt709:?})"
8689        );
8690    }
8691
8692    #[test]
8693    fn test_nv12_to_rgb_cpu() {
8694        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
8695        let src = TensorDyn::image(
8696            1280,
8697            720,
8698            PixelFormat::Nv12,
8699            DType::U8,
8700            None,
8701            edgefirst_tensor::CpuAccess::ReadWrite,
8702        )
8703        .unwrap();
8704        src.as_u8().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
8705            .copy_from_slice(&file);
8706
8707        let dst = TensorDyn::image(
8708            1280,
8709            720,
8710            PixelFormat::Rgb,
8711            DType::U8,
8712            None,
8713            edgefirst_tensor::CpuAccess::ReadWrite,
8714        )
8715        .unwrap();
8716        let mut cpu_converter = CPUProcessor::new();
8717
8718        let (result, _src, dst) = convert_img(
8719            &mut cpu_converter,
8720            src,
8721            dst,
8722            Rotation::None,
8723            Flip::None,
8724            Crop::no_crop(),
8725        );
8726        result.unwrap();
8727
8728        let target_image = crate::load_image_test_helper(
8729            &edgefirst_bench::testdata::read("zidane.jpg"),
8730            Some(PixelFormat::Rgb),
8731            None,
8732        )
8733        .unwrap();
8734
8735        // Threshold 0.95 (was 0.98): the reference now decodes the colour JPEG
8736        // to native NV12 and then converts to RGB (was a direct JPEG → RGB
8737        // decode), so it differs slightly from the RGB derived from the
8738        // separate `zidane.nv12` fixture.
8739        compare_images(&dst, &target_image, 0.95, function!());
8740    }
8741
8742    #[test]
8743    fn test_nv12_to_grey_cpu() {
8744        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
8745        let src = TensorDyn::image(
8746            1280,
8747            720,
8748            PixelFormat::Nv12,
8749            DType::U8,
8750            None,
8751            edgefirst_tensor::CpuAccess::ReadWrite,
8752        )
8753        .unwrap();
8754        src.as_u8().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
8755            .copy_from_slice(&file);
8756
8757        let dst = TensorDyn::image(
8758            1280,
8759            720,
8760            PixelFormat::Grey,
8761            DType::U8,
8762            None,
8763            edgefirst_tensor::CpuAccess::ReadWrite,
8764        )
8765        .unwrap();
8766        let mut cpu_converter = CPUProcessor::new();
8767
8768        let (result, _src, dst) = convert_img(
8769            &mut cpu_converter,
8770            src,
8771            dst,
8772            Rotation::None,
8773            Flip::None,
8774            Crop::no_crop(),
8775        );
8776        result.unwrap();
8777
8778        let target_image = crate::load_image_test_helper(
8779            &edgefirst_bench::testdata::read("zidane.jpg"),
8780            Some(PixelFormat::Grey),
8781            None,
8782        )
8783        .unwrap();
8784
8785        // Threshold 0.95 (was 0.98): the reference grey frame now comes from
8786        // the colour JPEG decoded to native NV12 and then converted to GREY
8787        // (was a direct JPEG → GREY decode), so it differs slightly from the
8788        // grey derived from the `zidane.nv12` fixture.
8789        compare_images(&dst, &target_image, 0.95, function!());
8790    }
8791
8792    #[test]
8793    fn test_nv12_to_yuyv_cpu() {
8794        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
8795        let src = TensorDyn::image(
8796            1280,
8797            720,
8798            PixelFormat::Nv12,
8799            DType::U8,
8800            None,
8801            edgefirst_tensor::CpuAccess::ReadWrite,
8802        )
8803        .unwrap();
8804        src.as_u8().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
8805            .copy_from_slice(&file);
8806
8807        let dst = TensorDyn::image(
8808            1280,
8809            720,
8810            PixelFormat::Yuyv,
8811            DType::U8,
8812            None,
8813            edgefirst_tensor::CpuAccess::ReadWrite,
8814        )
8815        .unwrap();
8816        let mut cpu_converter = CPUProcessor::new();
8817
8818        let (result, _src, dst) = convert_img(
8819            &mut cpu_converter,
8820            src,
8821            dst,
8822            Rotation::None,
8823            Flip::None,
8824            Crop::no_crop(),
8825        );
8826        result.unwrap();
8827
8828        let target_image = crate::load_image_test_helper(
8829            &edgefirst_bench::testdata::read("zidane.jpg"),
8830            Some(PixelFormat::Rgb),
8831            None,
8832        )
8833        .unwrap();
8834
8835        // Threshold 0.95 (was 0.98): the reference now decodes the colour JPEG
8836        // to native NV12 and then converts to RGB (was a direct JPEG → RGB
8837        // decode), so it differs slightly from the YUYV-sourced frame derived
8838        // from the separate `zidane.nv12` fixture.
8839        compare_images_convert_to_rgb(&dst, &target_image, 0.95, function!());
8840    }
8841
8842    #[test]
8843    fn test_cpu_resize_nv16() {
8844        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
8845        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
8846
8847        let cpu_nv16_dst = TensorDyn::image(
8848            640,
8849            640,
8850            PixelFormat::Nv16,
8851            DType::U8,
8852            None,
8853            edgefirst_tensor::CpuAccess::ReadWrite,
8854        )
8855        .unwrap();
8856        let cpu_rgb_dst = TensorDyn::image(
8857            640,
8858            640,
8859            PixelFormat::Rgb,
8860            DType::U8,
8861            None,
8862            edgefirst_tensor::CpuAccess::ReadWrite,
8863        )
8864        .unwrap();
8865        let mut cpu_converter = CPUProcessor::new();
8866        let crop = Crop::letterbox([255, 128, 0, 255]);
8867
8868        let (result, src, cpu_nv16_dst) = convert_img(
8869            &mut cpu_converter,
8870            src,
8871            cpu_nv16_dst,
8872            Rotation::None,
8873            Flip::None,
8874            crop,
8875        );
8876        result.unwrap();
8877
8878        let (result, _src, cpu_rgb_dst) = convert_img(
8879            &mut cpu_converter,
8880            src,
8881            cpu_rgb_dst,
8882            Rotation::None,
8883            Flip::None,
8884            crop,
8885        );
8886        result.unwrap();
8887        compare_images_convert_to_rgb(&cpu_nv16_dst, &cpu_rgb_dst, 0.99, function!());
8888    }
8889
8890    fn load_bytes_to_tensor(
8891        width: usize,
8892        height: usize,
8893        format: PixelFormat,
8894        memory: Option<TensorMemory>,
8895        bytes: &[u8],
8896    ) -> Result<TensorDyn, Error> {
8897        let src = TensorDyn::image(
8898            width,
8899            height,
8900            format,
8901            DType::U8,
8902            memory,
8903            edgefirst_tensor::CpuAccess::ReadWrite,
8904        )?;
8905        src.as_u8()
8906            .unwrap()
8907            .map()?
8908            .as_mut_slice()
8909            .copy_from_slice(bytes);
8910        Ok(src)
8911    }
8912
8913    // DEDUP: this function is also defined verbatim in
8914    // `crates/image/src/gl/tests.rs` (inside `mod gl_tests`). Both copies
8915    // must be kept in sync. Cross-module sharing would require either a
8916    // `pub(crate)` test-helper module (which pollutes the non-test API) or a
8917    // separate test-utils crate — both are disproportionate for a single
8918    // helper. If the implementation ever diverges, extract to a shared
8919    // `test_helpers` module in this crate.
8920    fn compare_images(img1: &TensorDyn, img2: &TensorDyn, threshold: f64, name: &str) {
8921        assert_eq!(img1.height(), img2.height(), "Heights differ");
8922        assert_eq!(img1.width(), img2.width(), "Widths differ");
8923        assert_eq!(
8924            img1.format().unwrap(),
8925            img2.format().unwrap(),
8926            "PixelFormat differ"
8927        );
8928        assert!(
8929            matches!(
8930                img1.format().unwrap(),
8931                PixelFormat::Rgb | PixelFormat::Rgba | PixelFormat::Grey | PixelFormat::PlanarRgb
8932            ),
8933            "format must be Rgb or Rgba for comparison"
8934        );
8935
8936        let image1 = match img1.format().unwrap() {
8937            PixelFormat::Rgb => image::RgbImage::from_vec(
8938                img1.width().unwrap() as u32,
8939                img1.height().unwrap() as u32,
8940                img1.as_u8().unwrap().map().unwrap().to_vec(),
8941            )
8942            .unwrap(),
8943            PixelFormat::Rgba => image::RgbaImage::from_vec(
8944                img1.width().unwrap() as u32,
8945                img1.height().unwrap() as u32,
8946                img1.as_u8().unwrap().map().unwrap().to_vec(),
8947            )
8948            .unwrap()
8949            .convert(),
8950            PixelFormat::Grey => image::GrayImage::from_vec(
8951                img1.width().unwrap() as u32,
8952                img1.height().unwrap() as u32,
8953                img1.as_u8().unwrap().map().unwrap().to_vec(),
8954            )
8955            .unwrap()
8956            .convert(),
8957            PixelFormat::PlanarRgb => image::GrayImage::from_vec(
8958                img1.width().unwrap() as u32,
8959                (img1.height().unwrap() * 3) as u32,
8960                img1.as_u8().unwrap().map().unwrap().to_vec(),
8961            )
8962            .unwrap()
8963            .convert(),
8964            _ => return,
8965        };
8966
8967        let image2 = match img2.format().unwrap() {
8968            PixelFormat::Rgb => image::RgbImage::from_vec(
8969                img2.width().unwrap() as u32,
8970                img2.height().unwrap() as u32,
8971                img2.as_u8().unwrap().map().unwrap().to_vec(),
8972            )
8973            .unwrap(),
8974            PixelFormat::Rgba => image::RgbaImage::from_vec(
8975                img2.width().unwrap() as u32,
8976                img2.height().unwrap() as u32,
8977                img2.as_u8().unwrap().map().unwrap().to_vec(),
8978            )
8979            .unwrap()
8980            .convert(),
8981            PixelFormat::Grey => image::GrayImage::from_vec(
8982                img2.width().unwrap() as u32,
8983                img2.height().unwrap() as u32,
8984                img2.as_u8().unwrap().map().unwrap().to_vec(),
8985            )
8986            .unwrap()
8987            .convert(),
8988            PixelFormat::PlanarRgb => image::GrayImage::from_vec(
8989                img2.width().unwrap() as u32,
8990                (img2.height().unwrap() * 3) as u32,
8991                img2.as_u8().unwrap().map().unwrap().to_vec(),
8992            )
8993            .unwrap()
8994            .convert(),
8995            _ => return,
8996        };
8997
8998        let similarity = image_compare::rgb_similarity_structure(
8999            &image_compare::Algorithm::RootMeanSquared,
9000            &image1,
9001            &image2,
9002        )
9003        .expect("Image Comparison failed");
9004        if similarity.score < threshold {
9005            // image1.save(format!("{name}_1.png"));
9006            // image2.save(format!("{name}_2.png"));
9007            similarity
9008                .image
9009                .to_color_map()
9010                .save(format!("{name}.png"))
9011                .unwrap();
9012            panic!(
9013                "{name}: converted image and target image have similarity score too low: {} < {}",
9014                similarity.score, threshold
9015            )
9016        }
9017    }
9018
9019    fn compare_images_convert_to_rgb(
9020        img1: &TensorDyn,
9021        img2: &TensorDyn,
9022        threshold: f64,
9023        name: &str,
9024    ) {
9025        assert_eq!(img1.height(), img2.height(), "Heights differ");
9026        assert_eq!(img1.width(), img2.width(), "Widths differ");
9027
9028        let mut img_rgb1 = TensorDyn::image(
9029            img1.width().unwrap(),
9030            img1.height().unwrap(),
9031            PixelFormat::Rgb,
9032            DType::U8,
9033            Some(TensorMemory::Mem),
9034            edgefirst_tensor::CpuAccess::ReadWrite,
9035        )
9036        .unwrap();
9037        let mut img_rgb2 = TensorDyn::image(
9038            img1.width().unwrap(),
9039            img1.height().unwrap(),
9040            PixelFormat::Rgb,
9041            DType::U8,
9042            Some(TensorMemory::Mem),
9043            edgefirst_tensor::CpuAccess::ReadWrite,
9044        )
9045        .unwrap();
9046        let mut __cv = CPUProcessor::default();
9047        let r1 = __cv.convert(
9048            img1,
9049            &mut img_rgb1,
9050            crate::Rotation::None,
9051            crate::Flip::None,
9052            crate::Crop::default(),
9053        );
9054        let r2 = __cv.convert(
9055            img2,
9056            &mut img_rgb2,
9057            crate::Rotation::None,
9058            crate::Flip::None,
9059            crate::Crop::default(),
9060        );
9061        if r1.is_err() || r2.is_err() {
9062            // Fallback: compare raw bytes as greyscale strip
9063            let w = img1.width().unwrap() as u32;
9064            let data1 = img1.as_u8().unwrap().map().unwrap().to_vec();
9065            let data2 = img2.as_u8().unwrap().map().unwrap().to_vec();
9066            let h1 = (data1.len() as u32) / w;
9067            let h2 = (data2.len() as u32) / w;
9068            let g1 = image::GrayImage::from_vec(w, h1, data1).unwrap();
9069            let g2 = image::GrayImage::from_vec(w, h2, data2).unwrap();
9070            let similarity = image_compare::gray_similarity_structure(
9071                &image_compare::Algorithm::RootMeanSquared,
9072                &g1,
9073                &g2,
9074            )
9075            .expect("Image Comparison failed");
9076            if similarity.score < threshold {
9077                panic!(
9078                    "{name}: converted image and target image have similarity score too low: {} < {}",
9079                    similarity.score, threshold
9080                )
9081            }
9082            return;
9083        }
9084
9085        let image1 = image::RgbImage::from_vec(
9086            img_rgb1.width().unwrap() as u32,
9087            img_rgb1.height().unwrap() as u32,
9088            img_rgb1.as_u8().unwrap().map().unwrap().to_vec(),
9089        )
9090        .unwrap();
9091
9092        let image2 = image::RgbImage::from_vec(
9093            img_rgb2.width().unwrap() as u32,
9094            img_rgb2.height().unwrap() as u32,
9095            img_rgb2.as_u8().unwrap().map().unwrap().to_vec(),
9096        )
9097        .unwrap();
9098
9099        let similarity = image_compare::rgb_similarity_structure(
9100            &image_compare::Algorithm::RootMeanSquared,
9101            &image1,
9102            &image2,
9103        )
9104        .expect("Image Comparison failed");
9105        if similarity.score < threshold {
9106            // image1.save(format!("{name}_1.png"));
9107            // image2.save(format!("{name}_2.png"));
9108            similarity
9109                .image
9110                .to_color_map()
9111                .save(format!("{name}.png"))
9112                .unwrap();
9113            panic!(
9114                "{name}: converted image and target image have similarity score too low: {} < {}",
9115                similarity.score, threshold
9116            )
9117        }
9118    }
9119
9120    // =========================================================================
9121    // PixelFormat::Nv12 Format Tests
9122    // =========================================================================
9123
9124    #[test]
9125    fn test_nv12_image_creation() {
9126        let width = 640;
9127        let height = 480;
9128        let img = TensorDyn::image(
9129            width,
9130            height,
9131            PixelFormat::Nv12,
9132            DType::U8,
9133            None,
9134            edgefirst_tensor::CpuAccess::ReadWrite,
9135        )
9136        .unwrap();
9137
9138        assert_eq!(img.width(), Some(width));
9139        assert_eq!(img.height(), Some(height));
9140        assert_eq!(img.format().unwrap(), PixelFormat::Nv12);
9141        // PixelFormat::Nv12 uses shape [H*3/2, W] to store Y plane + UV plane
9142        assert_eq!(img.as_u8().unwrap().shape(), &[height * 3 / 2, width]);
9143    }
9144
9145    #[test]
9146    fn test_nv12_channels() {
9147        let img = TensorDyn::image(
9148            640,
9149            480,
9150            PixelFormat::Nv12,
9151            DType::U8,
9152            None,
9153            edgefirst_tensor::CpuAccess::ReadWrite,
9154        )
9155        .unwrap();
9156        // PixelFormat::Nv12.channels() returns 1 (luma plane)
9157        assert_eq!(img.format().unwrap().channels(), 1);
9158    }
9159
9160    // =========================================================================
9161    // Tensor Format Metadata Tests
9162    // =========================================================================
9163
9164    #[test]
9165    fn test_tensor_set_format_planar() {
9166        let mut tensor = Tensor::<u8>::new(&[3, 480, 640], None, None).unwrap();
9167        tensor.set_format(PixelFormat::PlanarRgb).unwrap();
9168        assert_eq!(tensor.format(), Some(PixelFormat::PlanarRgb));
9169        assert_eq!(tensor.width(), Some(640));
9170        assert_eq!(tensor.height(), Some(480));
9171    }
9172
9173    #[test]
9174    fn test_tensor_set_format_interleaved() {
9175        let mut tensor = Tensor::<u8>::new(&[480, 640, 4], None, None).unwrap();
9176        tensor.set_format(PixelFormat::Rgba).unwrap();
9177        assert_eq!(tensor.format(), Some(PixelFormat::Rgba));
9178        assert_eq!(tensor.width(), Some(640));
9179        assert_eq!(tensor.height(), Some(480));
9180    }
9181
9182    #[test]
9183    fn test_tensordyn_image_rgb() {
9184        let img = TensorDyn::image(
9185            640,
9186            480,
9187            PixelFormat::Rgb,
9188            DType::U8,
9189            None,
9190            edgefirst_tensor::CpuAccess::ReadWrite,
9191        )
9192        .unwrap();
9193        assert_eq!(img.width(), Some(640));
9194        assert_eq!(img.height(), Some(480));
9195        assert_eq!(img.format(), Some(PixelFormat::Rgb));
9196    }
9197
9198    #[test]
9199    fn test_tensordyn_image_planar_rgb() {
9200        let img = TensorDyn::image(
9201            640,
9202            480,
9203            PixelFormat::PlanarRgb,
9204            DType::U8,
9205            None,
9206            edgefirst_tensor::CpuAccess::ReadWrite,
9207        )
9208        .unwrap();
9209        assert_eq!(img.width(), Some(640));
9210        assert_eq!(img.height(), Some(480));
9211        assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
9212    }
9213
9214    #[test]
9215    fn test_rgb_int8_format() {
9216        // Int8 variant: same PixelFormat::Rgb but with DType::I8
9217        let img = TensorDyn::image(
9218            1280,
9219            720,
9220            PixelFormat::Rgb,
9221            DType::I8,
9222            Some(TensorMemory::Mem),
9223            edgefirst_tensor::CpuAccess::ReadWrite,
9224        )
9225        .unwrap();
9226        assert_eq!(img.width(), Some(1280));
9227        assert_eq!(img.height(), Some(720));
9228        assert_eq!(img.format(), Some(PixelFormat::Rgb));
9229        assert_eq!(img.dtype(), DType::I8);
9230    }
9231
9232    #[test]
9233    fn test_planar_rgb_int8_format() {
9234        let img = TensorDyn::image(
9235            1280,
9236            720,
9237            PixelFormat::PlanarRgb,
9238            DType::I8,
9239            Some(TensorMemory::Mem),
9240            edgefirst_tensor::CpuAccess::ReadWrite,
9241        )
9242        .unwrap();
9243        assert_eq!(img.width(), Some(1280));
9244        assert_eq!(img.height(), Some(720));
9245        assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
9246        assert_eq!(img.dtype(), DType::I8);
9247    }
9248
9249    #[test]
9250    fn test_rgb_from_tensor() {
9251        let mut tensor = Tensor::<u8>::new(&[720, 1280, 3], None, None).unwrap();
9252        tensor.set_format(PixelFormat::Rgb).unwrap();
9253        let img = TensorDyn::from(tensor);
9254        assert_eq!(img.width(), Some(1280));
9255        assert_eq!(img.height(), Some(720));
9256        assert_eq!(img.format(), Some(PixelFormat::Rgb));
9257    }
9258
9259    #[test]
9260    fn test_planar_rgb_from_tensor() {
9261        let mut tensor = Tensor::<u8>::new(&[3, 720, 1280], None, None).unwrap();
9262        tensor.set_format(PixelFormat::PlanarRgb).unwrap();
9263        let img = TensorDyn::from(tensor);
9264        assert_eq!(img.width(), Some(1280));
9265        assert_eq!(img.height(), Some(720));
9266        assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
9267    }
9268
9269    #[test]
9270    fn test_dtype_determines_int8() {
9271        // DType::I8 indicates int8 data
9272        let u8_img = TensorDyn::image(
9273            64,
9274            64,
9275            PixelFormat::Rgb,
9276            DType::U8,
9277            None,
9278            edgefirst_tensor::CpuAccess::ReadWrite,
9279        )
9280        .unwrap();
9281        let i8_img = TensorDyn::image(
9282            64,
9283            64,
9284            PixelFormat::Rgb,
9285            DType::I8,
9286            None,
9287            edgefirst_tensor::CpuAccess::ReadWrite,
9288        )
9289        .unwrap();
9290        assert_eq!(u8_img.dtype(), DType::U8);
9291        assert_eq!(i8_img.dtype(), DType::I8);
9292    }
9293
9294    #[test]
9295    fn test_pixel_layout_packed_vs_planar() {
9296        // Packed vs planar layout classification
9297        assert_eq!(PixelFormat::Rgb.layout(), PixelLayout::Packed);
9298        assert_eq!(PixelFormat::Rgba.layout(), PixelLayout::Packed);
9299        assert_eq!(PixelFormat::PlanarRgb.layout(), PixelLayout::Planar);
9300        assert_eq!(PixelFormat::Nv12.layout(), PixelLayout::SemiPlanar);
9301    }
9302
9303    /// Integration test that exercises the PBO-to-PBO convert path.
9304    /// Uses ImageProcessor::create_image() to allocate PBO-backed tensors,
9305    /// then converts between them. Skipped when GL is unavailable or the
9306    /// backend is not PBO (e.g. DMA-buf systems).
9307    #[cfg(target_os = "linux")]
9308    #[cfg(feature = "opengl")]
9309    #[test]
9310    fn test_convert_pbo_to_pbo() {
9311        let mut converter = ImageProcessor::new().unwrap();
9312
9313        // Skip if GL is not available or backend is not PBO
9314        let is_pbo = converter
9315            .opengl
9316            .as_ref()
9317            .is_some_and(|gl| gl.transfer_backend() == opengl_headless::TransferBackend::Pbo);
9318        if !is_pbo {
9319            eprintln!("Skipping test_convert_pbo_to_pbo: backend is not PBO");
9320            return;
9321        }
9322
9323        let src_w = 640;
9324        let src_h = 480;
9325        let dst_w = 320;
9326        let dst_h = 240;
9327
9328        // Create PBO-backed source image
9329        let pbo_src = converter
9330            .create_image(
9331                src_w,
9332                src_h,
9333                PixelFormat::Rgba,
9334                DType::U8,
9335                None,
9336                edgefirst_tensor::CpuAccess::ReadWrite,
9337            )
9338            .unwrap();
9339        assert_eq!(
9340            pbo_src.as_u8().unwrap().memory(),
9341            TensorMemory::Pbo,
9342            "create_image should produce a PBO tensor"
9343        );
9344
9345        // Fill source PBO with test pattern: load JPEG then convert Mem→PBO
9346        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
9347        let jpeg_src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
9348
9349        // Resize JPEG into a Mem temp of the right size, then copy into PBO
9350        let mem_src = TensorDyn::image(
9351            src_w,
9352            src_h,
9353            PixelFormat::Rgba,
9354            DType::U8,
9355            Some(TensorMemory::Mem),
9356            edgefirst_tensor::CpuAccess::ReadWrite,
9357        )
9358        .unwrap();
9359        let (result, _jpeg_src, mem_src) = convert_img(
9360            &mut CPUProcessor::new(),
9361            jpeg_src,
9362            mem_src,
9363            Rotation::None,
9364            Flip::None,
9365            Crop::no_crop(),
9366        );
9367        result.unwrap();
9368
9369        // Copy pixel data into the PBO source by mapping it
9370        {
9371            let src_data = mem_src.as_u8().unwrap().map().unwrap();
9372            let mut pbo_map = pbo_src.as_u8().unwrap().map().unwrap();
9373            pbo_map.copy_from_slice(&src_data);
9374        }
9375
9376        // Create PBO-backed destination image
9377        let pbo_dst = converter
9378            .create_image(
9379                dst_w,
9380                dst_h,
9381                PixelFormat::Rgba,
9382                DType::U8,
9383                None,
9384                edgefirst_tensor::CpuAccess::ReadWrite,
9385            )
9386            .unwrap();
9387        assert_eq!(pbo_dst.as_u8().unwrap().memory(), TensorMemory::Pbo);
9388
9389        // Convert PBO→PBO (this exercises convert_pbo_to_pbo)
9390        let mut pbo_dst = pbo_dst;
9391        let result = converter.convert(
9392            &pbo_src,
9393            &mut pbo_dst,
9394            Rotation::None,
9395            Flip::None,
9396            Crop::no_crop(),
9397        );
9398        result.unwrap();
9399
9400        // Verify: compare with CPU-only conversion of the same input
9401        let cpu_dst = TensorDyn::image(
9402            dst_w,
9403            dst_h,
9404            PixelFormat::Rgba,
9405            DType::U8,
9406            Some(TensorMemory::Mem),
9407            edgefirst_tensor::CpuAccess::ReadWrite,
9408        )
9409        .unwrap();
9410        let (result, _mem_src, cpu_dst) = convert_img(
9411            &mut CPUProcessor::new(),
9412            mem_src,
9413            cpu_dst,
9414            Rotation::None,
9415            Flip::None,
9416            Crop::no_crop(),
9417        );
9418        result.unwrap();
9419
9420        let pbo_dst_img = {
9421            let mut __t = pbo_dst.into_u8().unwrap();
9422            __t.set_format(PixelFormat::Rgba).unwrap();
9423            TensorDyn::from(__t)
9424        };
9425        compare_images(&pbo_dst_img, &cpu_dst, 0.95, function!());
9426        log::info!("test_convert_pbo_to_pbo: PASS — PBO-to-PBO convert matches CPU reference");
9427    }
9428
9429    #[test]
9430    fn test_image_bgra() {
9431        let img = TensorDyn::image(
9432            640,
9433            480,
9434            PixelFormat::Bgra,
9435            DType::U8,
9436            Some(edgefirst_tensor::TensorMemory::Mem),
9437            edgefirst_tensor::CpuAccess::ReadWrite,
9438        )
9439        .unwrap();
9440        assert_eq!(img.width(), Some(640));
9441        assert_eq!(img.height(), Some(480));
9442        assert_eq!(img.format().unwrap().channels(), 4);
9443        assert_eq!(img.format().unwrap(), PixelFormat::Bgra);
9444    }
9445
9446    // ========================================================================
9447    // Tests for EDGEFIRST_FORCE_BACKEND env var
9448    // ========================================================================
9449
9450    #[test]
9451    fn test_force_backend_cpu() {
9452        let _lock = acquire_env_lock();
9453        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
9454        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
9455        let converter = ImageProcessor::new().unwrap();
9456        assert!(converter.cpu.is_some());
9457        assert_eq!(converter.forced_backend, Some(ForcedBackend::Cpu));
9458    }
9459
9460    #[test]
9461    fn test_force_backend_invalid() {
9462        let _lock = acquire_env_lock();
9463        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
9464        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "invalid") };
9465        let result = ImageProcessor::new();
9466        assert!(
9467            matches!(&result, Err(Error::ForcedBackendUnavailable(s)) if s.contains("unknown")),
9468            "invalid backend value should return ForcedBackendUnavailable error: {result:?}"
9469        );
9470    }
9471
9472    #[test]
9473    fn test_force_backend_unset() {
9474        let _lock = acquire_env_lock();
9475        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
9476        unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") };
9477        let converter = ImageProcessor::new().unwrap();
9478        assert!(converter.forced_backend.is_none());
9479    }
9480
9481    // ========================================================================
9482    // Tests for hybrid mask path error handling
9483    // ========================================================================
9484
9485    #[test]
9486    fn test_draw_proto_masks_no_cpu_returns_error() {
9487        // Serialize against all other env-var-mutating tests.
9488        let _lock = acquire_env_lock();
9489        let _guard = EnvGuard::snapshot(&[
9490            "EDGEFIRST_FORCE_BACKEND",
9491            "EDGEFIRST_DISABLE_GL",
9492            "EDGEFIRST_DISABLE_G2D",
9493            "EDGEFIRST_DISABLE_CPU",
9494        ]);
9495
9496        // Disable all backends so cpu.is_none() after construction.
9497        unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
9498        unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
9499        unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
9500
9501        let mut converter = ImageProcessor::new().unwrap();
9502        assert!(converter.cpu.is_none(), "CPU should be disabled");
9503
9504        let dst = TensorDyn::image(
9505            640,
9506            480,
9507            PixelFormat::Rgba,
9508            DType::U8,
9509            Some(TensorMemory::Mem),
9510            edgefirst_tensor::CpuAccess::ReadWrite,
9511        )
9512        .unwrap();
9513        let mut dst_dyn = dst;
9514        let det = [DetectBox {
9515            bbox: edgefirst_decoder::BoundingBox {
9516                xmin: 0.1,
9517                ymin: 0.1,
9518                xmax: 0.5,
9519                ymax: 0.5,
9520            },
9521            score: 0.9,
9522            label: 0,
9523        }];
9524        let proto_data = {
9525            use edgefirst_tensor::{Tensor, TensorDyn};
9526            let coeff_t = Tensor::<f32>::from_slice(&[0.5_f32; 4], &[1, 4]).unwrap();
9527            let protos_t =
9528                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
9529            ProtoData {
9530                mask_coefficients: TensorDyn::F32(coeff_t),
9531                protos: TensorDyn::F32(protos_t),
9532                layout: ProtoLayout::Nhwc,
9533            }
9534        };
9535        let result =
9536            converter.draw_proto_masks(&mut dst_dyn, &det, &proto_data, Default::default());
9537        assert!(
9538            matches!(&result, Err(Error::Internal(s)) if s.contains("CPU backend")),
9539            "draw_proto_masks without CPU should return Internal error: {result:?}"
9540        );
9541    }
9542
9543    #[test]
9544    fn test_draw_proto_masks_cpu_fallback_works() {
9545        // Force CPU-only backend to ensure the CPU fallback path executes.
9546        // Serialized under ENV_MUTEX so we don't race with disable-var tests.
9547        let _lock = acquire_env_lock();
9548        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
9549        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
9550        let mut converter = ImageProcessor::new().unwrap();
9551        assert!(converter.cpu.is_some());
9552
9553        let dst = TensorDyn::image(
9554            64,
9555            64,
9556            PixelFormat::Rgba,
9557            DType::U8,
9558            Some(TensorMemory::Mem),
9559            edgefirst_tensor::CpuAccess::ReadWrite,
9560        )
9561        .unwrap();
9562        let mut dst_dyn = dst;
9563        let det = [DetectBox {
9564            bbox: edgefirst_decoder::BoundingBox {
9565                xmin: 0.1,
9566                ymin: 0.1,
9567                xmax: 0.5,
9568                ymax: 0.5,
9569            },
9570            score: 0.9,
9571            label: 0,
9572        }];
9573        let proto_data = {
9574            use edgefirst_tensor::{Tensor, TensorDyn};
9575            let coeff_t = Tensor::<f32>::from_slice(&[0.5_f32; 4], &[1, 4]).unwrap();
9576            let protos_t =
9577                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
9578            ProtoData {
9579                mask_coefficients: TensorDyn::F32(coeff_t),
9580                protos: TensorDyn::F32(protos_t),
9581                layout: ProtoLayout::Nhwc,
9582            }
9583        };
9584        let result =
9585            converter.draw_proto_masks(&mut dst_dyn, &det, &proto_data, Default::default());
9586        assert!(result.is_ok(), "CPU fallback path should work: {result:?}");
9587    }
9588
9589    // ============================================================
9590    // draw_decoded_masks / draw_proto_masks — 4-scenario pixel-
9591    // verified tests. Exercises each backend against the full
9592    // output-contract matrix:
9593    //
9594    //   | detections | background | expected dst             |
9595    //   |------------|------------|--------------------------|
9596    //   | empty      | none       | fully cleared (0x00)     |
9597    //   | empty      | set        | fully equal to bg        |
9598    //   | set        | none       | cleared outside box +    |
9599    //   |            |            | mask-coloured inside     |
9600    //   | set        | set        | bg outside box + mask    |
9601    //   |            |            | blended inside           |
9602    //
9603    // Every test pre-fills dst with a non-zero "dirty" pattern so
9604    // that any silent `return Ok(())` leaks the pattern into the
9605    // asserted output and fails loudly.
9606    // ============================================================
9607
9608    // =========================================================================
9609    // Env-var serialisation helpers
9610    //
9611    // ALL tests that mutate any EDGEFIRST_* backend env var must hold
9612    // ENV_MUTEX for their full duration.  This single mutex serialises
9613    // test_disable_env_var, test_draw_proto_masks_no_cpu_returns_error,
9614    // test_force_backend_*, with_force_backend, and with_env — preventing
9615    // any two of them from racing in a parallel `cargo test` run.
9616    // =========================================================================
9617
9618    /// Acquire the process-wide env-var mutex.  Returns a guard that must be
9619    /// kept alive for the entire duration of the test body.
9620    fn acquire_env_lock() -> std::sync::MutexGuard<'static, ()> {
9621        use std::sync::{Mutex, OnceLock};
9622        static ENV_MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
9623        ENV_MUTEX
9624            .get_or_init(|| Mutex::new(()))
9625            .lock()
9626            .unwrap_or_else(|e| e.into_inner())
9627    }
9628
9629    /// RAII guard that snapshots a set of env vars on construction and
9630    /// restores them on `Drop`, even if the test panics.
9631    struct EnvGuard {
9632        vars: Vec<(&'static str, Option<String>)>,
9633    }
9634
9635    impl EnvGuard {
9636        /// Snapshot the current values of `names`.  Call this while holding
9637        /// the env lock (the lock is not taken here — that is the caller's
9638        /// responsibility so the lock scope can be wider than the guard).
9639        fn snapshot(names: &[&'static str]) -> Self {
9640            Self {
9641                vars: names.iter().map(|&k| (k, std::env::var(k).ok())).collect(),
9642            }
9643        }
9644    }
9645
9646    impl Drop for EnvGuard {
9647        fn drop(&mut self) {
9648            for (k, v) in &self.vars {
9649                match v {
9650                    Some(s) => unsafe { std::env::set_var(k, s) },
9651                    None => unsafe { std::env::remove_var(k) },
9652                }
9653            }
9654        }
9655    }
9656
9657    /// Run `body` with `EDGEFIRST_FORCE_BACKEND` temporarily set (or
9658    /// removed), restoring the prior value afterward. Tests are env-
9659    /// serialized via the process-wide `ENV_MUTEX`.
9660    fn with_force_backend<R>(value: Option<&str>, body: impl FnOnce() -> R) -> R {
9661        let _lock = acquire_env_lock();
9662        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
9663        match value {
9664            Some(v) => unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", v) },
9665            None => unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") },
9666        }
9667        body()
9668    }
9669
9670    /// Allocate an RGBA image tensor and pre-fill every byte with a
9671    /// distinctive non-zero pattern. Any test that relies on the old
9672    /// "dst is already cleared" assumption will see this pattern leak
9673    /// through to the output and fail.
9674    fn make_dirty_dst(w: usize, h: usize, mem: Option<TensorMemory>) -> TensorDyn {
9675        let dst = TensorDyn::image(
9676            w,
9677            h,
9678            PixelFormat::Rgba,
9679            DType::U8,
9680            mem,
9681            edgefirst_tensor::CpuAccess::ReadWrite,
9682        )
9683        .unwrap();
9684        {
9685            use edgefirst_tensor::TensorMapTrait;
9686            let u8t = dst.as_u8().unwrap();
9687            let mut map = u8t.map().unwrap();
9688            for (i, b) in map.as_mut_slice().iter_mut().enumerate() {
9689                *b = 0xA0u8.wrapping_add((i as u8) & 0x3F);
9690            }
9691        }
9692        dst
9693    }
9694
9695    /// Allocate an RGBA background filled with a constant colour.
9696    fn make_bg(w: usize, h: usize, mem: Option<TensorMemory>, rgba: [u8; 4]) -> TensorDyn {
9697        let bg = TensorDyn::image(
9698            w,
9699            h,
9700            PixelFormat::Rgba,
9701            DType::U8,
9702            mem,
9703            edgefirst_tensor::CpuAccess::ReadWrite,
9704        )
9705        .unwrap();
9706        {
9707            use edgefirst_tensor::TensorMapTrait;
9708            let u8t = bg.as_u8().unwrap();
9709            let mut map = u8t.map().unwrap();
9710            for chunk in map.as_mut_slice().chunks_exact_mut(4) {
9711                chunk.copy_from_slice(&rgba);
9712            }
9713        }
9714        bg
9715    }
9716
9717    fn pixel_at(dst: &TensorDyn, x: usize, y: usize) -> [u8; 4] {
9718        use edgefirst_tensor::TensorMapTrait;
9719        let w = dst.width().unwrap();
9720        let off = (y * w + x) * 4;
9721        let u8t = dst.as_u8().unwrap();
9722        let map = u8t.map().unwrap();
9723        let s = map.as_slice();
9724        [s[off], s[off + 1], s[off + 2], s[off + 3]]
9725    }
9726
9727    fn assert_every_pixel_eq(dst: &TensorDyn, expected: [u8; 4], case: &str) {
9728        use edgefirst_tensor::TensorMapTrait;
9729        let u8t = dst.as_u8().unwrap();
9730        let map = u8t.map().unwrap();
9731        for (i, chunk) in map.as_slice().chunks_exact(4).enumerate() {
9732            assert_eq!(
9733                chunk, &expected,
9734                "{case}: pixel idx {i} = {chunk:?}, expected {expected:?}"
9735            );
9736        }
9737    }
9738
9739    /// Scenario 1: empty detections, empty segmentation, no background
9740    /// → dst must be fully cleared to 0x00000000.
9741    fn scenario_empty_no_bg(processor: &mut ImageProcessor, case: &str) {
9742        let mut dst = make_dirty_dst(64, 64, None);
9743        processor
9744            .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::default())
9745            .unwrap_or_else(|e| panic!("{case}/decoded_masks empty+no-bg failed: {e:?}"));
9746        assert_every_pixel_eq(&dst, [0, 0, 0, 0], &format!("{case}/decoded"));
9747
9748        let mut dst = make_dirty_dst(64, 64, None);
9749        let proto = {
9750            use edgefirst_tensor::{Tensor, TensorDyn};
9751            // Placeholder (no detections); shape [1, 4] to keep the tensor well-formed.
9752            let coeff_t = Tensor::<f32>::from_slice(&[0.0_f32; 4], &[1, 4]).unwrap();
9753            let protos_t =
9754                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
9755            ProtoData {
9756                mask_coefficients: TensorDyn::F32(coeff_t),
9757                protos: TensorDyn::F32(protos_t),
9758                layout: ProtoLayout::Nhwc,
9759            }
9760        };
9761        processor
9762            .draw_proto_masks(&mut dst, &[], &proto, MaskOverlay::default())
9763            .unwrap_or_else(|e| panic!("{case}/proto_masks empty+no-bg failed: {e:?}"));
9764        assert_every_pixel_eq(&dst, [0, 0, 0, 0], &format!("{case}/proto"));
9765    }
9766
9767    /// Scenario 2: empty detections, empty segmentation, background set
9768    /// → dst must be fully equal to bg.
9769    fn scenario_empty_with_bg(processor: &mut ImageProcessor, case: &str) {
9770        let bg_color = [42, 99, 200, 255];
9771        let bg = make_bg(64, 64, None, bg_color);
9772        let overlay = MaskOverlay::new().with_background(&bg);
9773
9774        let mut dst = make_dirty_dst(64, 64, None);
9775        processor
9776            .draw_decoded_masks(&mut dst, &[], &[], overlay)
9777            .unwrap_or_else(|e| panic!("{case}/decoded_masks empty+bg failed: {e:?}"));
9778        assert_every_pixel_eq(&dst, bg_color, &format!("{case}/decoded bg blit"));
9779
9780        let mut dst = make_dirty_dst(64, 64, None);
9781        let proto = {
9782            use edgefirst_tensor::{Tensor, TensorDyn};
9783            // Placeholder (no detections); shape [1, 4] to keep the tensor well-formed.
9784            let coeff_t = Tensor::<f32>::from_slice(&[0.0_f32; 4], &[1, 4]).unwrap();
9785            let protos_t =
9786                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
9787            ProtoData {
9788                mask_coefficients: TensorDyn::F32(coeff_t),
9789                protos: TensorDyn::F32(protos_t),
9790                layout: ProtoLayout::Nhwc,
9791            }
9792        };
9793        processor
9794            .draw_proto_masks(&mut dst, &[], &proto, overlay)
9795            .unwrap_or_else(|e| panic!("{case}/proto_masks empty+bg failed: {e:?}"));
9796        assert_every_pixel_eq(&dst, bg_color, &format!("{case}/proto bg blit"));
9797    }
9798
9799    /// Scenario 3: one detection with a fully-opaque segmentation fill,
9800    /// no background → outside the box dst must be 0x00, inside it must
9801    /// be a non-zero mask colour (the render_segmentation output).
9802    fn scenario_detect_no_bg(processor: &mut ImageProcessor, case: &str) {
9803        use edgefirst_decoder::Segmentation;
9804        use ndarray::Array3;
9805        processor
9806            .set_class_colors(&[[200, 80, 40, 255]])
9807            .expect("set_class_colors");
9808
9809        let detect = DetectBox {
9810            bbox: [0.25, 0.25, 0.75, 0.75].into(),
9811            score: 0.99,
9812            label: 0,
9813        };
9814        let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
9815        let seg = Segmentation {
9816            segmentation: seg_arr,
9817            xmin: 0.25,
9818            ymin: 0.25,
9819            xmax: 0.75,
9820            ymax: 0.75,
9821        };
9822
9823        let mut dst = make_dirty_dst(64, 64, None);
9824        processor
9825            .draw_decoded_masks(&mut dst, &[detect], &[seg], MaskOverlay::default())
9826            .unwrap_or_else(|e| panic!("{case}/decoded_masks detect+no-bg failed: {e:?}"));
9827
9828        // Outside the bbox (corner): must be cleared black.
9829        let corner = pixel_at(&dst, 2, 2);
9830        assert_eq!(
9831            corner,
9832            [0, 0, 0, 0],
9833            "{case}/decoded: corner (2,2) leaked dirty pattern: {corner:?}"
9834        );
9835        // Inside the bbox (center): the mask colour must be visible.
9836        // Any non-zero pixel is acceptable — exact rendering varies
9837        // between backends (GL smoothstep, CPU nearest).
9838        let center = pixel_at(&dst, 32, 32);
9839        assert!(
9840            center != [0, 0, 0, 0],
9841            "{case}/decoded: center (32,32) was not coloured: {center:?}"
9842        );
9843    }
9844
9845    /// Scenario 4: detection + background. Outside the box must match
9846    /// bg; inside the box must NOT match bg (mask blended on top).
9847    fn scenario_detect_with_bg(processor: &mut ImageProcessor, case: &str) {
9848        use edgefirst_decoder::Segmentation;
9849        use ndarray::Array3;
9850        processor
9851            .set_class_colors(&[[200, 80, 40, 255]])
9852            .expect("set_class_colors");
9853        let bg_color = [10, 20, 30, 255];
9854        let bg = make_bg(64, 64, None, bg_color);
9855
9856        let detect = DetectBox {
9857            bbox: [0.25, 0.25, 0.75, 0.75].into(),
9858            score: 0.99,
9859            label: 0,
9860        };
9861        let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
9862        let seg = Segmentation {
9863            segmentation: seg_arr,
9864            xmin: 0.25,
9865            ymin: 0.25,
9866            xmax: 0.75,
9867            ymax: 0.75,
9868        };
9869
9870        let overlay = MaskOverlay::new().with_background(&bg);
9871        let mut dst = make_dirty_dst(64, 64, None);
9872        processor
9873            .draw_decoded_masks(&mut dst, &[detect], &[seg], overlay)
9874            .unwrap_or_else(|e| panic!("{case}/decoded_masks detect+bg failed: {e:?}"));
9875
9876        // Outside the bbox (corner): bg colour.
9877        let corner = pixel_at(&dst, 2, 2);
9878        assert_eq!(
9879            corner, bg_color,
9880            "{case}/decoded: corner (2,2) should show bg {bg_color:?} got {corner:?}"
9881        );
9882        // Inside the bbox (center): mask blended on bg, must differ from
9883        // pure bg (alpha-blend with mask colour produces a distinct shade).
9884        let center = pixel_at(&dst, 32, 32);
9885        assert!(
9886            center != bg_color,
9887            "{case}/decoded: center (32,32) should differ from bg {bg_color:?}, got {center:?}"
9888        );
9889    }
9890
9891    /// Run all 4 scenarios against the processor. Skip gracefully if
9892    /// construction fails (backend unavailable on this host).
9893    fn run_all_scenarios(
9894        force_backend: Option<&'static str>,
9895        case: &'static str,
9896        require_dma_for_bg: bool,
9897    ) {
9898        if require_dma_for_bg && !edgefirst_tensor::is_dma_available() {
9899            eprintln!("SKIPPED: {case} — DMA not available on this host");
9900            return;
9901        }
9902        let processor_result = with_force_backend(force_backend, ImageProcessor::new);
9903        let mut processor = match processor_result {
9904            Ok(p) => p,
9905            Err(e) => {
9906                eprintln!("SKIPPED: {case} — backend init failed: {e:?}");
9907                return;
9908            }
9909        };
9910        scenario_empty_no_bg(&mut processor, case);
9911        scenario_empty_with_bg(&mut processor, case);
9912        scenario_detect_no_bg(&mut processor, case);
9913        scenario_detect_with_bg(&mut processor, case);
9914    }
9915
9916    #[test]
9917    fn test_draw_masks_4_scenarios_cpu() {
9918        run_all_scenarios(Some("cpu"), "cpu", false);
9919    }
9920
9921    #[test]
9922    fn test_draw_masks_4_scenarios_auto() {
9923        run_all_scenarios(None, "auto", false);
9924    }
9925
9926    #[cfg(target_os = "linux")]
9927    #[cfg(feature = "opengl")]
9928    #[test]
9929    fn test_draw_masks_4_scenarios_opengl() {
9930        run_all_scenarios(Some("opengl"), "opengl", false);
9931    }
9932
9933    /// G2D forced backend: exercises the zero-detection empty-frame
9934    /// paths via `g2d_clear` and `g2d_blit`. Scenarios 3 and 4 (with
9935    /// detections) expect `NotImplemented` since G2D has no rasterizer
9936    /// for boxes / masks.
9937    #[cfg(target_os = "linux")]
9938    #[test]
9939    fn test_draw_masks_zero_detection_g2d_forced() {
9940        if !edgefirst_tensor::is_dma_available() {
9941            eprintln!("SKIPPED: g2d forced — DMA not available on this host");
9942            return;
9943        }
9944        let processor_result = with_force_backend(Some("g2d"), ImageProcessor::new);
9945        let mut processor = match processor_result {
9946            Ok(p) => p,
9947            Err(e) => {
9948                eprintln!("SKIPPED: g2d forced — init failed: {e:?}");
9949                return;
9950            }
9951        };
9952
9953        // Case 1: empty + no bg. G2D requires DMA-backed dst.
9954        let mut dst = TensorDyn::image(
9955            64,
9956            64,
9957            PixelFormat::Rgba,
9958            DType::U8,
9959            Some(TensorMemory::Dma),
9960            edgefirst_tensor::CpuAccess::ReadWrite,
9961        )
9962        .unwrap();
9963        {
9964            use edgefirst_tensor::TensorMapTrait;
9965            let u8t = dst.as_u8_mut().unwrap();
9966            let mut map = u8t.map().unwrap();
9967            map.as_mut_slice().fill(0xBB);
9968        }
9969        processor
9970            .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::default())
9971            .expect("g2d empty+no-bg");
9972        assert_every_pixel_eq(&dst, [0, 0, 0, 0], "g2d/case1 cleared");
9973
9974        // Case 2: empty + bg. Both surfaces DMA-backed for g2d_blit.
9975        let bg_color = [7, 11, 13, 255];
9976        let bg = {
9977            let t = TensorDyn::image(
9978                64,
9979                64,
9980                PixelFormat::Rgba,
9981                DType::U8,
9982                Some(TensorMemory::Dma),
9983                edgefirst_tensor::CpuAccess::ReadWrite,
9984            )
9985            .unwrap();
9986            {
9987                use edgefirst_tensor::TensorMapTrait;
9988                let u8t = t.as_u8().unwrap();
9989                let mut map = u8t.map().unwrap();
9990                for chunk in map.as_mut_slice().chunks_exact_mut(4) {
9991                    chunk.copy_from_slice(&bg_color);
9992                }
9993            }
9994            t
9995        };
9996        let mut dst = TensorDyn::image(
9997            64,
9998            64,
9999            PixelFormat::Rgba,
10000            DType::U8,
10001            Some(TensorMemory::Dma),
10002            edgefirst_tensor::CpuAccess::ReadWrite,
10003        )
10004        .unwrap();
10005        {
10006            use edgefirst_tensor::TensorMapTrait;
10007            let u8t = dst.as_u8_mut().unwrap();
10008            let mut map = u8t.map().unwrap();
10009            map.as_mut_slice().fill(0x55);
10010        }
10011        processor
10012            .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::new().with_background(&bg))
10013            .expect("g2d empty+bg");
10014        assert_every_pixel_eq(&dst, bg_color, "g2d/case2 bg blit");
10015
10016        // Case 3 and 4: detect present — must return NotImplemented.
10017        let detect = DetectBox {
10018            bbox: [0.25, 0.25, 0.75, 0.75].into(),
10019            score: 0.9,
10020            label: 0,
10021        };
10022        let mut dst = TensorDyn::image(
10023            64,
10024            64,
10025            PixelFormat::Rgba,
10026            DType::U8,
10027            Some(TensorMemory::Dma),
10028            edgefirst_tensor::CpuAccess::ReadWrite,
10029        )
10030        .unwrap();
10031        let err = processor
10032            .draw_decoded_masks(&mut dst, &[detect], &[], MaskOverlay::default())
10033            .expect_err("g2d must reject detect-present draw_decoded_masks");
10034        assert!(
10035            matches!(err, Error::NotImplemented(_)),
10036            "g2d case3 wrong error: {err:?}"
10037        );
10038    }
10039
10040    #[test]
10041    fn test_set_format_then_cpu_convert() {
10042        // Force CPU backend; serialized under ENV_MUTEX to avoid racing with
10043        // test_force_backend_* and test_disable_env_var.
10044        let _lock = acquire_env_lock();
10045        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
10046        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
10047        let mut processor = ImageProcessor::new().unwrap();
10048
10049        // Load a source image
10050        let image = edgefirst_bench::testdata::read("zidane.jpg");
10051        let src = load_image_test_helper(&image, Some(PixelFormat::Rgba), None).unwrap();
10052
10053        // Create a raw tensor, then attach format — simulating the from_fd workflow
10054        let mut dst =
10055            TensorDyn::new(&[640, 640, 3], DType::U8, Some(TensorMemory::Mem), None).unwrap();
10056        dst.set_format(PixelFormat::Rgb).unwrap();
10057
10058        // Convert should work with the set_format-annotated tensor
10059        processor
10060            .convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
10061            .unwrap();
10062
10063        // Verify format survived conversion
10064        assert_eq!(dst.format(), Some(PixelFormat::Rgb));
10065        assert_eq!(dst.width(), Some(640));
10066        assert_eq!(dst.height(), Some(640));
10067    }
10068
10069    /// Verify that creating multiple ImageProcessors on the same thread and
10070    /// performing a resize on each does not deadlock or error.
10071    ///
10072    /// Uses automatic memory allocation (DMA → PBO → Mem fallback) so that
10073    /// hardware backends (OpenGL, G2D) are exercised on capable targets.
10074    #[test]
10075    fn test_multiple_image_processors_same_thread() {
10076        // Hold the env mutex so env-var-mutating tests can't corrupt the state
10077        // seen by ImageProcessor::new() calls during this test.
10078        let _lock = acquire_env_lock();
10079        let mut processors: Vec<ImageProcessor> = (0..4)
10080            .map(|_| ImageProcessor::new().expect("ImageProcessor::new() failed"))
10081            .collect();
10082
10083        for proc in &mut processors {
10084            let src = proc
10085                .create_image(
10086                    128,
10087                    128,
10088                    PixelFormat::Rgb,
10089                    DType::U8,
10090                    None,
10091                    edgefirst_tensor::CpuAccess::ReadWrite,
10092                )
10093                .expect("create src failed");
10094            let mut dst = proc
10095                .create_image(
10096                    64,
10097                    64,
10098                    PixelFormat::Rgb,
10099                    DType::U8,
10100                    None,
10101                    edgefirst_tensor::CpuAccess::ReadWrite,
10102                )
10103                .expect("create dst failed");
10104            proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
10105                .expect("convert failed");
10106            assert_eq!(dst.width(), Some(64));
10107            assert_eq!(dst.height(), Some(64));
10108        }
10109    }
10110
10111    /// Verify that creating ImageProcessors on separate threads and performing
10112    /// a resize on each does not deadlock or error.
10113    ///
10114    /// Uses automatic memory allocation (DMA → PBO → Mem fallback) so that
10115    /// hardware backends (OpenGL, G2D) are exercised on capable targets.
10116    /// A 60-second timeout prevents CI from hanging on deadlock regressions.
10117    #[test]
10118    fn test_multiple_image_processors_separate_threads() {
10119        use std::sync::mpsc;
10120        use std::time::Duration;
10121
10122        // The Vivante GC7000UL driver (i.MX 8M Plus) double-frees on concurrent
10123        // EGL context teardown — four processors spun up on four threads here
10124        // trips it and aborts the whole test binary (SIGABRT, not a catchable
10125        // panic). The bug is the driver's, not the HAL's; this test is kept so
10126        // it still exercises the multi-context path on every other GPU. The
10127        // on-target GitHub Actions imx8mp runner sets EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS
10128        // to skip just this case there, while the run stays red anywhere else
10129        // a regression appears. (Skip, not #[ignore]: the platform is decided
10130        // at runtime, not compile time.)
10131        if std::env::var_os("EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS").is_some() {
10132            eprintln!(
10133                "SKIPPED: test_multiple_image_processors_separate_threads — known Vivante \
10134                 GC7000UL concurrent-EGL-teardown double-free \
10135                 (EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS set)"
10136            );
10137            return;
10138        }
10139
10140        const TIMEOUT: Duration = Duration::from_secs(60);
10141
10142        // Hold the env mutex so env-var-mutating tests can't corrupt ImageProcessor::new()
10143        // calls made inside the spawned threads during this test.
10144        let _lock = acquire_env_lock();
10145
10146        let (tx, rx) = mpsc::channel::<()>();
10147
10148        std::thread::spawn(move || {
10149            let handles: Vec<_> = (0..4)
10150                .map(|i| {
10151                    std::thread::spawn(move || {
10152                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
10153                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
10154                        });
10155                        let src = proc
10156                            .create_image(
10157                                128,
10158                                128,
10159                                PixelFormat::Rgb,
10160                                DType::U8,
10161                                None,
10162                                edgefirst_tensor::CpuAccess::ReadWrite,
10163                            )
10164                            .unwrap_or_else(|e| panic!("create src failed on thread {i}: {e}"));
10165                        let mut dst = proc
10166                            .create_image(
10167                                64,
10168                                64,
10169                                PixelFormat::Rgb,
10170                                DType::U8,
10171                                None,
10172                                edgefirst_tensor::CpuAccess::ReadWrite,
10173                            )
10174                            .unwrap_or_else(|e| panic!("create dst failed on thread {i}: {e}"));
10175                        proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
10176                            .unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
10177                        assert_eq!(dst.width(), Some(64));
10178                        assert_eq!(dst.height(), Some(64));
10179                    })
10180                })
10181                .collect();
10182
10183            for (i, h) in handles.into_iter().enumerate() {
10184                h.join()
10185                    .unwrap_or_else(|e| panic!("thread {i} panicked: {e:?}"));
10186            }
10187
10188            let _ = tx.send(());
10189        });
10190
10191        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
10192            panic!("test_multiple_image_processors_separate_threads timed out after {TIMEOUT:?}")
10193        });
10194    }
10195
10196    /// Verify that 4 fully-initialized ImageProcessors on separate threads can
10197    /// all operate concurrently without deadlocking each other.
10198    ///
10199    /// All processors are created first, then a barrier synchronizes them so
10200    /// they all start converting at the same instant — maximizing contention.
10201    /// A 60-second timeout prevents CI from hanging on deadlock regressions.
10202    #[test]
10203    fn test_image_processors_concurrent_operations() {
10204        use std::sync::{mpsc, Arc, Barrier};
10205        use std::time::Duration;
10206
10207        const N: usize = 4;
10208        const ROUNDS: usize = 10;
10209        const TIMEOUT: Duration = Duration::from_secs(60);
10210
10211        // Hold the env mutex so env-var-mutating tests can't corrupt ImageProcessor::new()
10212        // calls made inside the spawned threads during this test.
10213        let _lock = acquire_env_lock();
10214
10215        let (tx, rx) = mpsc::channel::<()>();
10216
10217        std::thread::spawn(move || {
10218            let barrier = Arc::new(Barrier::new(N));
10219
10220            let handles: Vec<_> = (0..N)
10221                .map(|i| {
10222                    let barrier = Arc::clone(&barrier);
10223                    std::thread::spawn(move || {
10224                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
10225                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
10226                        });
10227
10228                        // All threads wait here until every processor is initialized.
10229                        barrier.wait();
10230
10231                        // Now all 4 hammer the GPU concurrently.
10232                        for round in 0..ROUNDS {
10233                            let src = proc
10234                                .create_image(
10235                                    128,
10236                                    128,
10237                                    PixelFormat::Rgb,
10238                                    DType::U8,
10239                                    None,
10240                                    edgefirst_tensor::CpuAccess::ReadWrite,
10241                                )
10242                                .unwrap_or_else(|e| {
10243                                    panic!("create src failed on thread {i} round {round}: {e}")
10244                                });
10245                            let mut dst = proc
10246                                .create_image(
10247                                    64,
10248                                    64,
10249                                    PixelFormat::Rgb,
10250                                    DType::U8,
10251                                    None,
10252                                    edgefirst_tensor::CpuAccess::ReadWrite,
10253                                )
10254                                .unwrap_or_else(|e| {
10255                                    panic!("create dst failed on thread {i} round {round}: {e}")
10256                                });
10257                            proc.convert(
10258                                &src,
10259                                &mut dst,
10260                                Rotation::None,
10261                                Flip::None,
10262                                Crop::default(),
10263                            )
10264                            .unwrap_or_else(|e| {
10265                                panic!("convert failed on thread {i} round {round}: {e}")
10266                            });
10267                            assert_eq!(dst.width(), Some(64));
10268                            assert_eq!(dst.height(), Some(64));
10269                        }
10270                    })
10271                })
10272                .collect();
10273
10274            for (i, h) in handles.into_iter().enumerate() {
10275                h.join()
10276                    .unwrap_or_else(|e| panic!("thread {i} panicked: {e:?}"));
10277            }
10278
10279            let _ = tx.send(());
10280        });
10281
10282        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
10283            panic!("test_image_processors_concurrent_operations timed out after {TIMEOUT:?}")
10284        });
10285    }
10286
10287    /// THE parallel-processors demonstration test: 4 ImageProcessors on 4
10288    /// threads, each with its own GL context and worker, converting
10289    /// per-thread-DISTINCT synthetic inputs concurrently (barrier-released);
10290    /// every output must byte-match the thread's own pre-barrier sequential
10291    /// oracle from the same processor. On LifecycleOnly platforms (Mali,
10292    /// V3D, Tegra, llvmpipe, macOS) the converts genuinely overlap on the
10293    /// GPU; on Vivante they serialize via the Full policy and the test still
10294    /// must pass. Distinct inputs make any cross-processor state leakage
10295    /// (wrong texture, wrong context, clobbered upload) visible as a byte
10296    /// diff rather than a coincidental match — proven by a scratch
10297    /// cross-wire run (neighbor's input post-oracle) failing on every
10298    /// thread with ~53% of bytes diverged.
10299    ///
10300    /// Skipped under EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS like
10301    /// `test_multiple_image_processors_separate_threads`: the galcore driver
10302    /// can abort intermittently on concurrent multi-processor lifecycles
10303    /// regardless of locking (P0 spike: reproduces fully serialized).
10304    #[test]
10305    fn test_parallel_processors_unique_outputs() {
10306        use std::sync::{mpsc, Arc, Barrier};
10307        use std::time::Duration;
10308
10309        const N: usize = 4;
10310        const ROUNDS: usize = 25;
10311        const TIMEOUT: Duration = Duration::from_secs(60);
10312
10313        if std::env::var_os("EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS").is_some() {
10314            eprintln!(
10315                "SKIPPED: test_parallel_processors_unique_outputs — known Vivante \
10316                 GC7000UL concurrent-multi-processor driver abort \
10317                 (EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS set)"
10318            );
10319            return;
10320        }
10321
10322        let _lock = acquire_env_lock();
10323        let (tx, rx) = mpsc::channel::<()>();
10324
10325        std::thread::spawn(move || {
10326            let barrier = Arc::new(Barrier::new(N));
10327            let handles: Vec<_> = (0..N)
10328                .map(|i| {
10329                    let barrier = Arc::clone(&barrier);
10330                    std::thread::spawn(move || {
10331                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
10332                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
10333                        });
10334                        // CI-weight geometry (llvmpipe renders on the CPU).
10335                        let (w, h) = (640usize, 480usize);
10336                        let mem = if edgefirst_tensor::is_dma_available() {
10337                            Some(TensorMemory::Dma)
10338                        } else {
10339                            Some(TensorMemory::Mem)
10340                        };
10341                        let src = proc
10342                            .create_image(
10343                                w,
10344                                h,
10345                                PixelFormat::Nv12,
10346                                DType::U8,
10347                                mem,
10348                                edgefirst_tensor::CpuAccess::ReadWrite,
10349                            )
10350                            .unwrap();
10351                        {
10352                            let t = src.as_u8().unwrap();
10353                            let mut m = t.map().unwrap();
10354                            let s = m.as_mut_slice();
10355                            for (j, b) in s[..w * h].iter_mut().enumerate() {
10356                                *b = ((i * 53 + j) % 200 + 16) as u8;
10357                            }
10358                            for b in &mut s[w * h..] {
10359                                *b = (80 + i * 24) as u8;
10360                            }
10361                        }
10362                        let lb = Crop::letterbox([114, 114, 114, 255]);
10363                        let convert_once = |proc: &mut ImageProcessor| -> Vec<u8> {
10364                            let mut dst = proc
10365                                .create_image(
10366                                    320,
10367                                    320,
10368                                    PixelFormat::Rgba,
10369                                    DType::U8,
10370                                    mem,
10371                                    edgefirst_tensor::CpuAccess::ReadWrite,
10372                                )
10373                                .unwrap();
10374                            proc.convert(&src, &mut dst, Rotation::None, Flip::None, lb)
10375                                .unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
10376                            let t = dst.as_u8().unwrap();
10377                            let m = t.map().unwrap();
10378                            m.as_slice().to_vec()
10379                        };
10380
10381                        let oracle = convert_once(&mut proc);
10382                        barrier.wait();
10383                        for round in 0..ROUNDS {
10384                            let out = convert_once(&mut proc);
10385                            let diffs = oracle.iter().zip(&out).filter(|(a, b)| a != b).count();
10386                            assert!(
10387                                diffs == 0,
10388                                "thread {i} round {round}: {diffs}/{} bytes diverged \
10389                                 from this processor's own oracle — cross-processor \
10390                                 GL state leakage under parallel execution",
10391                                oracle.len()
10392                            );
10393                        }
10394                    })
10395                })
10396                .collect();
10397
10398            for (i, h) in handles.into_iter().enumerate() {
10399                h.join()
10400                    .unwrap_or_else(|e| panic!("parallel thread {i} panicked: {e:?}"));
10401            }
10402            let _ = tx.send(());
10403        });
10404
10405        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
10406            panic!("test_parallel_processors_unique_outputs timed out after {TIMEOUT:?}")
10407        });
10408    }
10409
10410    /// Heavy on-demand stressor for the GL serialization policy: 4
10411    /// processors × 4 threads × barrier × 200 NV12 720p → RGB 640 letterbox
10412    /// converts (DMA where available); every output must byte-match the
10413    /// thread's own pre-barrier sequential oracle from the same processor.
10414    /// Ignored by default (heavy; board tool — the CI-weight version is
10415    /// `test_parallel_processors_unique_outputs`). Run explicitly, optionally
10416    /// pinning the policy via EDGEFIRST_GL_SERIALIZE=full|lifecycle:
10417    ///   <test binary> stress_parallel_processors_oracle --ignored
10418    #[test]
10419    #[ignore = "heavy on-demand GL-parallelism stressor; run explicitly on boards"]
10420    fn stress_parallel_processors_oracle() {
10421        use std::sync::{mpsc, Arc, Barrier};
10422        use std::time::Duration;
10423
10424        const N: usize = 4;
10425        const ROUNDS: usize = 200;
10426        const TIMEOUT: Duration = Duration::from_secs(600);
10427
10428        let _lock = acquire_env_lock();
10429        let (tx, rx) = mpsc::channel::<()>();
10430
10431        std::thread::spawn(move || {
10432            let barrier = Arc::new(Barrier::new(N));
10433            let handles: Vec<_> = (0..N)
10434                .map(|i| {
10435                    let barrier = Arc::clone(&barrier);
10436                    std::thread::spawn(move || {
10437                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
10438                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
10439                        });
10440                        let (w, h) = (1280usize, 720usize);
10441                        let mem = if edgefirst_tensor::is_dma_available() {
10442                            Some(TensorMemory::Dma)
10443                        } else {
10444                            Some(TensorMemory::Mem)
10445                        };
10446
10447                        // Per-thread-distinct synthetic NV12 so cross-wired
10448                        // GL state between processors shows up as a byte diff.
10449                        let src = proc
10450                            .create_image(
10451                                w,
10452                                h,
10453                                PixelFormat::Nv12,
10454                                DType::U8,
10455                                mem,
10456                                edgefirst_tensor::CpuAccess::ReadWrite,
10457                            )
10458                            .unwrap();
10459                        {
10460                            let t = src.as_u8().unwrap();
10461                            let mut m = t.map().unwrap();
10462                            let s = m.as_mut_slice();
10463                            for (j, b) in s[..w * h].iter_mut().enumerate() {
10464                                *b = ((i * 37 + j) % 200 + 16) as u8;
10465                            }
10466                            for b in &mut s[w * h..] {
10467                                *b = (96 + i * 16) as u8;
10468                            }
10469                        }
10470                        let lb = Crop::letterbox([114, 114, 114, 255]);
10471
10472                        let convert_once = |proc: &mut ImageProcessor| -> Vec<u8> {
10473                            let mut dst = proc
10474                                .create_image(
10475                                    640,
10476                                    640,
10477                                    PixelFormat::Rgb,
10478                                    DType::U8,
10479                                    mem,
10480                                    edgefirst_tensor::CpuAccess::ReadWrite,
10481                                )
10482                                .unwrap();
10483                            proc.convert(&src, &mut dst, Rotation::None, Flip::None, lb)
10484                                .unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
10485                            let t = dst.as_u8().unwrap();
10486                            let m = t.map().unwrap();
10487                            m.as_slice().to_vec()
10488                        };
10489
10490                        let oracle = convert_once(&mut proc);
10491                        barrier.wait();
10492                        for round in 0..ROUNDS {
10493                            let out = convert_once(&mut proc);
10494                            let diffs = oracle.iter().zip(&out).filter(|(a, b)| a != b).count();
10495                            assert!(
10496                                diffs == 0,
10497                                "thread {i} round {round}: {diffs}/{} bytes diverged \
10498                                 from the pre-barrier oracle",
10499                                oracle.len()
10500                            );
10501                        }
10502                    })
10503                })
10504                .collect();
10505
10506            for (i, h) in handles.into_iter().enumerate() {
10507                h.join()
10508                    .unwrap_or_else(|e| panic!("stressor thread {i} panicked: {e:?}"));
10509            }
10510            let _ = tx.send(());
10511        });
10512
10513        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
10514            panic!("stress_parallel_processors_oracle timed out after {TIMEOUT:?}")
10515        });
10516    }
10517
10518    // =========================================================================
10519    // F16 / F32 auto-chain fallback integration tests
10520    // =========================================================================
10521
10522    /// Proves the auto-chain (OpenGL → G2D → CPU) NEVER errors for a float
10523    /// combo the GL path does NOT cover.
10524    ///
10525    /// `Yuyv → Rgb F32` is not handled by the GL float render path (which
10526    /// only covers `Rgba → PlanarRgb F16` and `Rgba → Rgb F32`), so the
10527    /// chain falls through to the CPU float path. Before commit 868a7649
10528    /// added CPU U8→F32/F16 support this would have returned `Err`; now it
10529    /// must return `Ok` with output in `[0, 1]` and all values finite.
10530    #[test]
10531    fn convert_f32_auto_never_errors_non_gl_combo() {
10532        const W: usize = 64;
10533        const H: usize = 64;
10534
10535        // Build a small synthetic YUYV source (Y=128, U=128, V=128 → near-grey).
10536        // YUYV packs two pixels into 4 bytes: [Y0, U, Y1, V] per macropixel.
10537        let src = TensorDyn::image(
10538            W,
10539            H,
10540            PixelFormat::Yuyv,
10541            DType::U8,
10542            Some(TensorMemory::Mem),
10543            edgefirst_tensor::CpuAccess::ReadWrite,
10544        )
10545        .unwrap();
10546        {
10547            let mut map = src.as_u8().unwrap().map().unwrap();
10548            let data = map.as_mut_slice();
10549            for chunk in data.chunks_exact_mut(4) {
10550                chunk[0] = 128; // Y0
10551                chunk[1] = 128; // U
10552                chunk[2] = 160; // Y1 — distinct so a layout bug is visible
10553                chunk[3] = 128; // V
10554            }
10555        }
10556
10557        let mut dst = TensorDyn::image(
10558            W,
10559            H,
10560            PixelFormat::Rgb,
10561            DType::F32,
10562            Some(TensorMemory::Mem),
10563            edgefirst_tensor::CpuAccess::ReadWrite,
10564        )
10565        .unwrap();
10566
10567        let mut proc = ImageProcessor::new().unwrap();
10568        let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
10569        assert!(
10570            result.is_ok(),
10571            "auto-chain Yuyv→Rgb F32 must not error: {:?}",
10572            result.err()
10573        );
10574
10575        // Verify all output values are finite and in [0, 1].
10576        let map = dst.as_f32().unwrap().map().unwrap();
10577        let floats = map.as_slice();
10578        assert_eq!(floats.len(), W * H * 3, "unexpected output element count");
10579        for (i, &v) in floats.iter().enumerate() {
10580            assert!(
10581                v.is_finite() && (0.0..=1.0).contains(&v),
10582                "output[{i}]={v} is not finite or not in [0,1]"
10583            );
10584        }
10585
10586        // WEAK-1: Anti-all-zero spot-check.  A Y=128 YUYV source normalises to
10587        // ≈0.502 on the luma channel.  If the buffer is all-zero (e.g. the CPU
10588        // path never wrote to it) this assertion catches the regression.
10589        let first_non_zero = floats.iter().find(|&&v| v > 0.01);
10590        assert!(
10591            first_non_zero.is_some(),
10592            "all-zero output detected — CPU path likely did not write to the destination buffer"
10593        );
10594        // Y=128 → luma ≈ 0.502.  Spot-check the first pixel's R channel
10595        // (which carries luma for a near-grey YUV source).
10596        let r0 = floats[0];
10597        assert!(
10598            (r0 - 0.502_f32).abs() < 0.05,
10599            "first pixel R={r0} expected ≈0.502 (Y=128 neutral grey from YUYV source)"
10600        );
10601    }
10602
10603    /// Proves CPU-forced `Rgba → PlanarRgb F16` correctness.
10604    ///
10605    /// Uses a source with clearly distinct per-channel values so a
10606    /// plane-swap or layout bug surfaces immediately. Tolerance is 2^-9
10607    /// (one F16 ULP at 0.5, i.e. roughly 1/512).
10608    #[test]
10609    // `ImageProcessorConfig` carries a Linux-only `egl_display` field, so
10610    // `{ backend, ..Default::default() }` is a genuine update on Linux but
10611    // covers no remaining fields on macOS, where `clippy::needless_update`
10612    // then fires. `allow` (not `expect`) because the lint is platform-
10613    // conditional — it does not fire on Linux.
10614    #[allow(clippy::needless_update)]
10615    fn convert_f16_forced_cpu_correct() {
10616        const W: usize = 16;
10617        const H: usize = 16;
10618        const TOL: f32 = 1.0 / 512.0; // 2^-9
10619
10620        // pixel (y,x): R = 50+x, G = 100+y*8, B = 200
10621        let src = TensorDyn::image(
10622            W,
10623            H,
10624            PixelFormat::Rgba,
10625            DType::U8,
10626            Some(TensorMemory::Mem),
10627            edgefirst_tensor::CpuAccess::ReadWrite,
10628        )
10629        .unwrap();
10630        {
10631            let mut map = src.as_u8().unwrap().map().unwrap();
10632            let data = map.as_mut_slice();
10633            for y in 0..H {
10634                for x in 0..W {
10635                    let i = y * W + x;
10636                    data[i * 4] = (50 + x) as u8; // R: 50..65
10637                    data[i * 4 + 1] = (100 + y * 8) as u8; // G: 100..220
10638                    data[i * 4 + 2] = 200; // B: constant
10639                    data[i * 4 + 3] = 255;
10640                }
10641            }
10642        }
10643
10644        let mut dst = TensorDyn::image(
10645            W,
10646            H,
10647            PixelFormat::PlanarRgb,
10648            DType::F16,
10649            Some(TensorMemory::Mem),
10650            edgefirst_tensor::CpuAccess::ReadWrite,
10651        )
10652        .unwrap();
10653
10654        let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
10655            backend: ComputeBackend::Cpu,
10656            ..Default::default()
10657        })
10658        .unwrap();
10659        proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
10660            .expect("forced-CPU Rgba→PlanarRgb F16 must not error");
10661
10662        let src_map = src.as_u8().unwrap().map().unwrap();
10663        let src_bytes = src_map.as_slice();
10664        let dst_map = dst.as_f16().unwrap().map().unwrap();
10665        let dst_halfs = dst_map.as_slice();
10666
10667        let plane = W * H;
10668        assert_eq!(dst_halfs.len(), plane * 3, "wrong output element count");
10669
10670        for y in 0..H {
10671            for x in 0..W {
10672                let i = y * W + x;
10673                let r_exp = src_bytes[i * 4] as f32 / 255.0;
10674                let g_exp = src_bytes[i * 4 + 1] as f32 / 255.0;
10675                let b_exp = src_bytes[i * 4 + 2] as f32 / 255.0;
10676
10677                let r_got = dst_halfs[i].to_f32();
10678                let g_got = dst_halfs[plane + i].to_f32();
10679                let b_got = dst_halfs[2 * plane + i].to_f32();
10680
10681                assert!(
10682                    (r_got - r_exp).abs() <= TOL,
10683                    "R plane ({x},{y}): got {r_got}, expected {r_exp}"
10684                );
10685                assert!(
10686                    (g_got - g_exp).abs() <= TOL,
10687                    "G plane ({x},{y}): got {g_got}, expected {g_exp}"
10688                );
10689                assert!(
10690                    (b_got - b_exp).abs() <= TOL,
10691                    "B plane ({x},{y}): got {b_got}, expected {b_exp}"
10692                );
10693
10694                // Catch plane-swap: R and G must differ (they have different formulas).
10695                if src_bytes[i * 4] != src_bytes[i * 4 + 1] {
10696                    assert_ne!(r_got, g_got, "R and G planes must differ at ({x},{y})");
10697                }
10698            }
10699        }
10700    }
10701
10702    /// Proves the auto-chain falls through to CPU for `Rgba → Rgb F32` with
10703    /// rotation set.
10704    ///
10705    /// The GL float render path rejects any call where rotation ≠ None,
10706    /// returning an error that causes the chain to continue. Before the CPU
10707    /// float fallback this would have produced an error at the end of the
10708    /// chain; now it must reach CPU and return `Ok` with finite `[0, 1]` output.
10709    #[test]
10710    fn convert_f32_with_rotation_falls_back() {
10711        const W: usize = 16;
10712        const H: usize = 16;
10713
10714        // RGBA8 source with a known gradient (distinct per-channel values).
10715        let src = TensorDyn::image(
10716            W,
10717            H,
10718            PixelFormat::Rgba,
10719            DType::U8,
10720            Some(TensorMemory::Mem),
10721            edgefirst_tensor::CpuAccess::ReadWrite,
10722        )
10723        .unwrap();
10724        {
10725            let mut map = src.as_u8().unwrap().map().unwrap();
10726            let data = map.as_mut_slice();
10727            for y in 0..H {
10728                for x in 0..W {
10729                    let i = y * W + x;
10730                    data[i * 4] = (x * 16) as u8; // R
10731                    data[i * 4 + 1] = (y * 16) as u8; // G
10732                    data[i * 4 + 2] = 128; // B
10733                    data[i * 4 + 3] = 255;
10734                }
10735            }
10736        }
10737
10738        // Rotation swaps W and H, so dst is [W, H] (H×W output).
10739        let mut dst = TensorDyn::image(
10740            H, // dst W = src H after 90° rotation
10741            W, // dst H = src W after 90° rotation
10742            PixelFormat::Rgb,
10743            DType::F32,
10744            Some(TensorMemory::Mem),
10745            edgefirst_tensor::CpuAccess::ReadWrite,
10746        )
10747        .unwrap();
10748
10749        let mut proc = ImageProcessor::new().unwrap();
10750        let result = proc.convert(
10751            &src,
10752            &mut dst,
10753            Rotation::Clockwise90,
10754            Flip::None,
10755            Crop::default(),
10756        );
10757        assert!(
10758            result.is_ok(),
10759            "auto-chain Rgba→Rgb F32 with Rot90 must not error: {:?}",
10760            result.err()
10761        );
10762
10763        let map = dst.as_f32().unwrap().map().unwrap();
10764        let floats = map.as_slice();
10765        assert_eq!(floats.len(), H * W * 3, "unexpected output element count");
10766        for (i, &v) in floats.iter().enumerate() {
10767            assert!(
10768                v.is_finite() && (0.0..=1.0).contains(&v),
10769                "output[{i}]={v} is not finite or not in [0,1]"
10770            );
10771        }
10772    }
10773
10774    /// GL-vs-CPU identity parity for `Rgba → PlanarRgb F16`.
10775    ///
10776    /// Converts the same RGBA8 source via forced `OpenGl` and forced `Cpu`,
10777    /// then verifies the two F16 output tensors agree element-wise within
10778    /// 2^-8 (two F16 ULPs at 0.5). Skipped when OpenGL or F16 render is
10779    /// unavailable.
10780    #[test]
10781    #[cfg(all(target_os = "linux", feature = "opengl"))]
10782    fn convert_f16_gl_cpu_parity_identity() {
10783        if !is_opengl_available() {
10784            eprintln!("SKIPPED: convert_f16_gl_cpu_parity_identity - OpenGL not available");
10785            return;
10786        }
10787
10788        const W: usize = 16;
10789        const H: usize = 16;
10790        const TOL: f32 = 1.0 / 256.0; // 2^-8
10791
10792        // pixel (y,x): R = 40+x, G = 80+y*10, B = 180
10793        let src = TensorDyn::image(
10794            W,
10795            H,
10796            PixelFormat::Rgba,
10797            DType::U8,
10798            Some(TensorMemory::Mem),
10799            edgefirst_tensor::CpuAccess::ReadWrite,
10800        )
10801        .unwrap();
10802        {
10803            let mut map = src.as_u8().unwrap().map().unwrap();
10804            let data = map.as_mut_slice();
10805            for y in 0..H {
10806                for x in 0..W {
10807                    let i = y * W + x;
10808                    data[i * 4] = (40 + x) as u8; // R
10809                    data[i * 4 + 1] = (80 + y * 10) as u8; // G
10810                    data[i * 4 + 2] = 180; // B
10811                    data[i * 4 + 3] = 255;
10812                }
10813            }
10814        }
10815
10816        // GL path.
10817        let gl_result = {
10818            let mut gl_proc = match ImageProcessor::with_config(ImageProcessorConfig {
10819                backend: ComputeBackend::OpenGl,
10820                ..Default::default()
10821            }) {
10822                Ok(p) => p,
10823                Err(e) => {
10824                    eprintln!(
10825                        "SKIPPED: convert_f16_gl_cpu_parity_identity - GL backend unavailable: {e}"
10826                    );
10827                    return;
10828                }
10829            };
10830
10831            if !gl_proc.supported_render_dtypes().f16 {
10832                eprintln!("SKIPPED: convert_f16_gl_cpu_parity_identity - F16 render not supported");
10833                return;
10834            }
10835
10836            let mut dst = TensorDyn::image(
10837                W,
10838                H,
10839                PixelFormat::PlanarRgb,
10840                DType::F16,
10841                Some(TensorMemory::Mem),
10842                edgefirst_tensor::CpuAccess::ReadWrite,
10843            )
10844            .unwrap();
10845            match gl_proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default()) {
10846                Ok(()) => dst,
10847                Err(e) => {
10848                    eprintln!(
10849                        "SKIPPED: convert_f16_gl_cpu_parity_identity - GL convert failed: {e}"
10850                    );
10851                    return;
10852                }
10853            }
10854        };
10855
10856        // CPU path.
10857        let cpu_result = {
10858            let mut cpu_proc = ImageProcessor::with_config(ImageProcessorConfig {
10859                backend: ComputeBackend::Cpu,
10860                ..Default::default()
10861            })
10862            .unwrap();
10863            let mut dst = TensorDyn::image(
10864                W,
10865                H,
10866                PixelFormat::PlanarRgb,
10867                DType::F16,
10868                Some(TensorMemory::Mem),
10869                edgefirst_tensor::CpuAccess::ReadWrite,
10870            )
10871            .unwrap();
10872            cpu_proc
10873                .convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
10874                .expect("forced-CPU Rgba→PlanarRgb F16 must not error");
10875            dst
10876        };
10877
10878        // Compare element-wise.
10879        let gl_map = gl_result.as_f16().unwrap().map().unwrap();
10880        let cpu_map = cpu_result.as_f16().unwrap().map().unwrap();
10881        let gl_halfs = gl_map.as_slice();
10882        let cpu_halfs = cpu_map.as_slice();
10883
10884        assert_eq!(
10885            gl_halfs.len(),
10886            cpu_halfs.len(),
10887            "GL and CPU output sizes differ"
10888        );
10889
10890        let plane = W * H;
10891        let channel_names = ["R", "G", "B"];
10892        for (idx, (gl_h, cpu_h)) in gl_halfs.iter().zip(cpu_halfs.iter()).enumerate() {
10893            let gl_v = gl_h.to_f32();
10894            let cpu_v = cpu_h.to_f32();
10895            let err = (gl_v - cpu_v).abs();
10896            let ch = channel_names[idx / plane];
10897            let pixel = idx % plane;
10898            assert!(
10899                err <= TOL,
10900                "GL vs CPU mismatch at {ch}[{pixel}]: GL={gl_v}, CPU={cpu_v}, err={err} > tol={TOL}"
10901            );
10902        }
10903    }
10904
10905    // =========================================================================
10906    // GAP-1: supported_render_dtypes() Linux smoke test
10907    // =========================================================================
10908
10909    /// Exercises the real Linux GL path that reads `gl.supported_render_dtypes()`.
10910    /// Skipped when no GL backend is available (CI/host without a GPU).
10911    #[test]
10912    #[cfg(all(target_os = "linux", feature = "opengl"))]
10913    fn supported_render_dtypes_linux_smoke() {
10914        let proc = match ImageProcessor::new() {
10915            Ok(p) => p,
10916            Err(e) => {
10917                eprintln!("SKIPPED: supported_render_dtypes_linux_smoke — ImageProcessor::new() failed: {e}");
10918                return;
10919            }
10920        };
10921        if proc.opengl.is_none() {
10922            eprintln!("SKIPPED: supported_render_dtypes_linux_smoke — no GL backend on this host");
10923            return;
10924        }
10925        // The call must complete without panicking or deadlocking.
10926        let support = proc.supported_render_dtypes();
10927        eprintln!(
10928            "supported_render_dtypes_linux_smoke: f16={} f32={}",
10929            support.f16, support.f32
10930        );
10931        // No assertion on the specific values — they are hardware-dependent.
10932    }
10933
10934    // =========================================================================
10935    // GAP-2: F16 PlanarRgb with width NOT divisible by 4 falls back to CPU
10936    // =========================================================================
10937
10938    /// The GL float render path rejects PlanarRgb F16 destinations whose width
10939    /// is not a multiple of 4 (the packed RGBA16F swizzle trick requires W%4==0).
10940    /// The auto-chain must transparently fall through to the CPU path, which has
10941    /// no such restriction.  W=18, H=16 is chosen so W%4 == 2.
10942    #[test]
10943    fn convert_f16_pbo_non_4_aligned_width_falls_back() {
10944        const W: usize = 18; // 18 % 4 == 2 — NOT divisible by 4
10945        const H: usize = 16;
10946
10947        // RGBA8 source filled with a flat mid-grey.
10948        let src = TensorDyn::image(
10949            W,
10950            H,
10951            PixelFormat::Rgba,
10952            DType::U8,
10953            Some(TensorMemory::Mem),
10954            edgefirst_tensor::CpuAccess::ReadWrite,
10955        )
10956        .unwrap();
10957        {
10958            let mut map = src.as_u8().unwrap().map().unwrap();
10959            let data = map.as_mut_slice();
10960            for chunk in data.chunks_exact_mut(4) {
10961                chunk[0] = 128;
10962                chunk[1] = 64;
10963                chunk[2] = 200;
10964                chunk[3] = 255;
10965            }
10966        }
10967
10968        // F16 PlanarRgb destination in Mem (GL would use PBO, but we want
10969        // to exercise the fallback chain without hardware dependency).
10970        let mut dst = TensorDyn::image(
10971            W,
10972            H,
10973            PixelFormat::PlanarRgb,
10974            DType::F16,
10975            Some(TensorMemory::Mem),
10976            edgefirst_tensor::CpuAccess::ReadWrite,
10977        )
10978        .unwrap();
10979
10980        // Use the default auto-chain so the GL path can attempt and reject,
10981        // then the CPU path succeeds.
10982        let mut proc = ImageProcessor::new().unwrap();
10983        let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
10984        assert!(
10985            result.is_ok(),
10986            "auto-chain PlanarRgb F16 W%4!=0 must not error (CPU fallback): {:?}",
10987            result.err()
10988        );
10989
10990        // All output values must be finite and in [0, 1].
10991        let map = dst.as_f16().unwrap().map().unwrap();
10992        let halfs = map.as_slice();
10993        assert_eq!(halfs.len(), W * H * 3, "unexpected element count");
10994        for (i, h) in halfs.iter().enumerate() {
10995            let v = h.to_f32();
10996            assert!(
10997                v.is_finite() && (0.0..=1.0).contains(&v),
10998                "output[{i}]={v} is not finite or not in [0,1]"
10999            );
11000        }
11001    }
11002
11003    // =========================================================================
11004    // GAP-4: NV12 → Rgb F32 and NV12 → PlanarRgb F16, forced CPU
11005    // =========================================================================
11006
11007    /// CPU widen-composition: NV12 (non-RGBA source) → Rgb F32.
11008    ///
11009    /// NV12 requires a two-stage CPU conversion (NV12→Rgba then Rgba→F32)
11010    /// which was untested. A wrong intermediate format selection silently
11011    /// produces garbage. Y=128, U=V=128 → near-neutral grey → R≈G≈B≈0.5.
11012    #[test]
11013    // Linux-only `egl_display` field makes `..Default::default()` needless
11014    // on macOS only; see `convert_f16_forced_cpu_correct`.
11015    #[allow(clippy::needless_update)]
11016    fn convert_nv12_to_rgb_f32_cpu() {
11017        const W: usize = 16;
11018        const H: usize = 16; // must be even for NV12
11019
11020        // Build a valid NV12 tensor: shape [H*3/2, W], luma=128, chroma=128.
11021        let src = TensorDyn::image(
11022            W,
11023            H,
11024            PixelFormat::Nv12,
11025            DType::U8,
11026            Some(TensorMemory::Mem),
11027            edgefirst_tensor::CpuAccess::ReadWrite,
11028        )
11029        .unwrap();
11030        {
11031            let mut map = src.as_u8().unwrap().map().unwrap();
11032            map.as_mut_slice().fill(128); // Y=128, U=V=128 → neutral grey
11033        }
11034
11035        let mut dst = TensorDyn::image(
11036            W,
11037            H,
11038            PixelFormat::Rgb,
11039            DType::F32,
11040            Some(TensorMemory::Mem),
11041            edgefirst_tensor::CpuAccess::ReadWrite,
11042        )
11043        .unwrap();
11044
11045        let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
11046            backend: ComputeBackend::Cpu,
11047            ..Default::default()
11048        })
11049        .unwrap();
11050
11051        let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
11052        assert!(
11053            result.is_ok(),
11054            "forced-CPU NV12→Rgb F32 must not error: {:?}",
11055            result.err()
11056        );
11057
11058        let map = dst.as_f32().unwrap().map().unwrap();
11059        let floats = map.as_slice();
11060        assert_eq!(floats.len(), W * H * 3, "unexpected element count");
11061        for (i, &v) in floats.iter().enumerate() {
11062            assert!(
11063                v.is_finite() && (0.0..=1.0).contains(&v),
11064                "output[{i}]={v} is not finite or not in [0,1]"
11065            );
11066        }
11067        // Anti-all-zero: Y=128 → luma channel ≈ 0.5 after YUV→RGB.
11068        let non_zero = floats.iter().any(|&v| v > 0.01);
11069        assert!(non_zero, "all-zero output from NV12→Rgb F32 CPU path");
11070    }
11071
11072    /// CPU widen-composition: NV12 (non-RGBA source) → PlanarRgb F16.
11073    ///
11074    /// Same rationale as `convert_nv12_to_rgb_f32_cpu` but for F16 output.
11075    #[test]
11076    // Linux-only `egl_display` field makes `..Default::default()` needless
11077    // on macOS only; see `convert_f16_forced_cpu_correct`.
11078    #[allow(clippy::needless_update)]
11079    fn convert_nv12_to_planar_rgb_f16_cpu() {
11080        const W: usize = 16;
11081        const H: usize = 16;
11082
11083        let src = TensorDyn::image(
11084            W,
11085            H,
11086            PixelFormat::Nv12,
11087            DType::U8,
11088            Some(TensorMemory::Mem),
11089            edgefirst_tensor::CpuAccess::ReadWrite,
11090        )
11091        .unwrap();
11092        {
11093            let mut map = src.as_u8().unwrap().map().unwrap();
11094            map.as_mut_slice().fill(128);
11095        }
11096
11097        let mut dst = TensorDyn::image(
11098            W,
11099            H,
11100            PixelFormat::PlanarRgb,
11101            DType::F16,
11102            Some(TensorMemory::Mem),
11103            edgefirst_tensor::CpuAccess::ReadWrite,
11104        )
11105        .unwrap();
11106
11107        let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
11108            backend: ComputeBackend::Cpu,
11109            ..Default::default()
11110        })
11111        .unwrap();
11112
11113        let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
11114        assert!(
11115            result.is_ok(),
11116            "forced-CPU NV12→PlanarRgb F16 must not error: {:?}",
11117            result.err()
11118        );
11119
11120        let map = dst.as_f16().unwrap().map().unwrap();
11121        let halfs = map.as_slice();
11122        assert_eq!(halfs.len(), W * H * 3, "unexpected element count");
11123        for (i, h) in halfs.iter().enumerate() {
11124            let v = h.to_f32();
11125            assert!(
11126                v.is_finite() && (0.0..=1.0).contains(&v),
11127                "output[{i}]={v} is not finite or not in [0,1]"
11128            );
11129        }
11130        let non_zero = halfs.iter().any(|h| h.to_f32() > 0.01);
11131        assert!(non_zero, "all-zero output from NV12→PlanarRgb F16 CPU path");
11132    }
11133
11134    // =========================================================================
11135    // GAP-5: create_image F32 + DMA must return NotSupported
11136    // =========================================================================
11137
11138    /// `create_image_desc` without a compression request is exactly
11139    /// `create_image` (the processor's memory negotiation applies); with
11140    /// `Compression::Any` off-Android it resolves linear and counts the
11141    /// fallback in the processor-visible mirror.
11142    #[test]
11143    fn create_image_desc_negotiates_and_counts_fallbacks() {
11144        use edgefirst_tensor::{Compression, CpuAccess, ImageDesc};
11145        let proc = ImageProcessor::new().unwrap();
11146
11147        let desc =
11148            ImageDesc::new(64, 64, PixelFormat::Rgba, DType::U8).with_access(CpuAccess::ReadWrite);
11149        let plain = proc.create_image_desc(&desc).unwrap();
11150        let classic = proc
11151            .create_image(
11152                64,
11153                64,
11154                PixelFormat::Rgba,
11155                DType::U8,
11156                None,
11157                CpuAccess::ReadWrite,
11158            )
11159            .unwrap();
11160        assert_eq!(plain.memory(), classic.memory(), "same negotiation path");
11161        assert_eq!(plain.compression(), None);
11162
11163        #[cfg(not(target_os = "android"))]
11164        {
11165            let before = proc.compression_fallback_count();
11166            let desc = ImageDesc::new(64, 64, PixelFormat::Rgba, DType::U8)
11167                .with_compression(Compression::Any);
11168            let t = proc.create_image_desc(&desc).unwrap();
11169            assert_eq!(t.compression(), None, "no vendor tile scheme off-Android");
11170            assert!(
11171                proc.compression_fallback_count() > before,
11172                "Any resolving linear must count"
11173            );
11174        }
11175    }
11176
11177    /// There is no DRM FourCC for F32 images, so `create_image` with
11178    /// `TensorMemory::Dma` and `DType::F32` must return `Err(NotSupported)`.
11179    #[test]
11180    #[cfg(target_os = "linux")]
11181    fn create_image_f32_dma_rejected() {
11182        let proc = ImageProcessor::new().unwrap();
11183        let result = proc.create_image(
11184            64,
11185            64,
11186            PixelFormat::Rgb,
11187            DType::F32,
11188            Some(TensorMemory::Dma),
11189            edgefirst_tensor::CpuAccess::ReadWrite,
11190        );
11191        assert!(
11192            result.is_err(),
11193            "create_image(F32, Dma) must fail — no DRM fourcc for f32"
11194        );
11195    }
11196
11197    /// Verify that `import_image` stores the supplied `Option<Colorimetry>` on
11198    /// the returned `TensorDyn`.
11199    ///
11200    /// `import_image` requires a DMA-backed fd on Linux.  When DMA is
11201    /// unavailable we skip the DMA call but still verify the storage contract
11202    /// by inspecting `set_colorimetry` / `colorimetry` on a plain `TensorDyn`
11203    /// constructed the same way the function body does it — and we confirm via
11204    /// code-read that the new parameter is unconditionally stored.
11205    #[test]
11206    #[cfg(target_os = "linux")]
11207    fn import_image_carries_colorimetry() {
11208        use edgefirst_tensor::{ColorEncoding, ColorRange, Colorimetry, TensorMemory};
11209
11210        let expected = Colorimetry::default()
11211            .with_encoding(ColorEncoding::Bt709)
11212            .with_range(ColorRange::Limited);
11213
11214        if !is_dma_available() {
11215            // DMA unavailable on this host: exercise the storage path via
11216            // TensorDyn directly (mirrors what import_image does internally).
11217            let mut t = TensorDyn::image(
11218                8,
11219                8,
11220                PixelFormat::Rgba,
11221                DType::U8,
11222                Some(TensorMemory::Mem),
11223                edgefirst_tensor::CpuAccess::ReadWrite,
11224            )
11225            .expect("alloc");
11226            assert_eq!(t.colorimetry(), None, "colorimetry must start as None");
11227            t.set_colorimetry(Some(expected));
11228            assert_eq!(
11229                t.colorimetry(),
11230                Some(expected),
11231                "set_colorimetry must round-trip"
11232            );
11233            eprintln!("SKIPPED import_image_carries_colorimetry (DMA unavailable); storage contract verified via TensorDyn");
11234            return;
11235        }
11236
11237        // DMA is available: allocate a real DMA tensor, extract its fd, and
11238        // call import_image with an explicit Colorimetry.
11239        use edgefirst_tensor::{PlaneDescriptor, Tensor};
11240
11241        let rgba_bytes = 64 * 64 * 4; // 64×64 RGBA8
11242        let dma_tensor =
11243            Tensor::<u8>::new(&[rgba_bytes], Some(TensorMemory::Dma), Some("import_test"))
11244                .expect("dma alloc");
11245        let pd =
11246            PlaneDescriptor::new(dma_tensor.dmabuf().expect("dma fd")).expect("PlaneDescriptor");
11247
11248        let proc = ImageProcessor::new().expect("ImageProcessor");
11249        let result = proc.import_image(
11250            pd,
11251            None,
11252            64,
11253            64,
11254            PixelFormat::Rgba,
11255            DType::U8,
11256            Some(expected),
11257        );
11258        let tensor = result.expect("import_image must succeed on DMA fd");
11259        assert_eq!(
11260            tensor.colorimetry(),
11261            Some(expected),
11262            "import_image must store the supplied colorimetry on the returned TensorDyn"
11263        );
11264    }
11265}