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::DmaBuf`. 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::DmaBuf) => {}
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;
322#[cfg(feature = "codec")]
323pub use edgefirst_codec as codec;
324
325#[cfg(test)]
326use edgefirst_tensor::ProtoLayout;
327#[doc(inline)]
328pub use edgefirst_tensor::Region;
329#[cfg(any(
330    test,
331    all(any(target_os = "linux", target_os = "windows"), feature = "opengl")
332))]
333use edgefirst_tensor::Tensor;
334use edgefirst_tensor::{
335    DType, PixelFormat, PixelLayout, TensorDyn, TensorMemory, TensorTrait as _,
336};
337use edgefirst_tensor::{DetectBox, ProtoData, Segmentation};
338use enum_dispatch::enum_dispatch;
339pub use error::{Error, Result};
340#[cfg(target_os = "linux")]
341pub use g2d::G2DProcessor;
342#[cfg(all(
343    any(
344        target_os = "linux",
345        target_os = "macos",
346        target_os = "ios",
347        target_os = "android",
348        target_os = "windows"
349    ),
350    feature = "opengl"
351))]
352pub use opengl_headless::EglDisplayKind;
353#[cfg(all(
354    any(
355        target_os = "linux",
356        target_os = "macos",
357        target_os = "ios",
358        target_os = "android",
359        target_os = "windows"
360    ),
361    feature = "opengl"
362))]
363pub use opengl_headless::GLProcessorThreaded;
364#[cfg(all(
365    any(
366        target_os = "linux",
367        target_os = "macos",
368        target_os = "ios",
369        target_os = "android",
370        target_os = "windows"
371    ),
372    feature = "opengl"
373))]
374pub use opengl_headless::Int8InterpolationMode;
375#[cfg(target_os = "linux")]
376#[cfg(feature = "opengl")]
377pub use opengl_headless::{probe_egl_displays, EglDisplayInfo};
378// EGLImage cache counter snapshots (diagnostics): see
379// `GLProcessorThreaded::egl_cache_stats` and the steady-state import gate in
380// `crates/image/ARCHITECTURE.md § image.convert.gl.egl_import`.
381#[cfg(all(
382    any(
383        target_os = "linux",
384        target_os = "macos",
385        target_os = "ios",
386        target_os = "android",
387        target_os = "windows"
388    ),
389    feature = "opengl"
390))]
391pub use opengl_headless::{CacheStats, ConvertStats, GlCacheStats};
392use std::{fmt::Display, time::Instant};
393
394mod colorimetry;
395mod cpu;
396mod error;
397mod g2d;
398#[path = "gl/mod.rs"]
399mod opengl_headless;
400mod tiling;
401pub use tiling::{tile_grid, TilePlacement, TileSpec, TilingConfig};
402
403// Use `edgefirst_tensor::PixelFormat` variants (Rgb, Rgba, Grey, etc.) and
404// `TensorDyn` / `Tensor<u8>` with `.format()` metadata instead.
405
406#[derive(Debug, Clone, Copy, PartialEq, Eq)]
407pub enum Rotation {
408    None = 0,
409    Clockwise90 = 1,
410    Rotate180 = 2,
411    CounterClockwise90 = 3,
412}
413impl Rotation {
414    /// Creates a Rotation enum from an angle in degrees. The angle must be a
415    /// multiple of 90.
416    ///
417    /// # Panics
418    /// Panics if the angle is not a multiple of 90.
419    ///
420    /// # Examples
421    /// ```rust
422    /// # use edgefirst_image::Rotation;
423    /// let rotation = Rotation::from_degrees_clockwise(270);
424    /// assert_eq!(rotation, Rotation::CounterClockwise90);
425    /// ```
426    pub fn from_degrees_clockwise(angle: usize) -> Rotation {
427        match angle.rem_euclid(360) {
428            0 => Rotation::None,
429            90 => Rotation::Clockwise90,
430            180 => Rotation::Rotate180,
431            270 => Rotation::CounterClockwise90,
432            _ => panic!("rotation angle is not a multiple of 90"),
433        }
434    }
435}
436
437#[derive(Debug, Clone, Copy, PartialEq, Eq)]
438pub enum Flip {
439    None = 0,
440    Vertical = 1,
441    Horizontal = 2,
442}
443
444/// Controls how the color palette index is chosen for each detected object.
445#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
446pub enum ColorMode {
447    /// Color is chosen by object class label (`det.label`). Default.
448    ///
449    /// Preserves backward compatibility and is correct for semantic
450    /// segmentation where colors carry class meaning.
451    #[default]
452    Class,
453    /// Color is chosen by instance order (loop index, zero-based).
454    ///
455    /// Each detected object gets a unique color regardless of class,
456    /// useful for instance segmentation.
457    Instance,
458    /// Color is chosen by track ID (future use; currently behaves like
459    /// [`Instance`](Self::Instance)).
460    Track,
461}
462
463impl ColorMode {
464    /// Return the palette index for a detection given its loop index and label.
465    #[inline]
466    pub fn index(self, idx: usize, label: usize) -> usize {
467        match self {
468            ColorMode::Class => label,
469            ColorMode::Instance | ColorMode::Track => idx,
470        }
471    }
472}
473
474/// Controls the resolution and coordinate frame of masks produced by
475/// [`ImageProcessor::materialize_masks`].
476///
477/// - [`Proto`](Self::Proto) returns per-detection tiles at proto-plane
478///   resolution (e.g. 48×32 u8 for a typical COCO bbox on a 160×160 proto
479///   plane). This is the historical behavior of `materialize_masks` and the
480///   fastest path because no upsample runs inside HAL. Mask values are
481///   continuous sigmoid output quantized to `uint8 [0, 255]`.
482/// - [`Scaled`](Self::Scaled) returns per-detection tiles at caller-specified
483///   pixel resolution by upsampling the full proto plane once and cropping by
484///   bbox after sigmoid. The upsample uses bilinear interpolation with
485///   edge-clamp sampling — semantically equivalent to Ultralytics'
486///   `process_masks_retina` reference. When a `letterbox` is also passed to
487///   [`materialize_masks`], the inverse letterbox transform is applied during
488///   the upsample so mask pixels land in original-content coordinates
489///   (drop-in for overlay on the original image). Mask values are binary
490///   `uint8 {0, 255}` after thresholding sigmoid > 0.5 — interchangeable
491///   with `Proto` output via the same `> 127` test.
492///
493/// [`materialize_masks`]: ImageProcessor::materialize_masks
494#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
495pub enum MaskResolution {
496    /// Per-detection tile at proto-plane resolution (default).
497    #[default]
498    Proto,
499    /// Per-detection tile at `(width, height)` pixel resolution in the
500    /// coordinate frame determined by the `letterbox` parameter of
501    /// [`ImageProcessor::materialize_masks`].
502    Scaled {
503        /// Target pixel width of the output coordinate frame.
504        width: u32,
505        /// Target pixel height of the output coordinate frame.
506        height: u32,
507    },
508}
509
510/// Options for mask overlay rendering.
511///
512/// Controls how segmentation masks are composited onto the destination image:
513/// - `background`: when set, the background image is drawn first and masks
514///   are composited over it (result written to `dst`). When `None`, `dst` is
515///   cleared to `0x00000000` (fully transparent) before masks are drawn.
516///   **`dst` is always fully overwritten — its prior contents are never
517///   preserved.** Callers who used to pre-load an image into `dst` before
518///   calling `draw_decoded_masks` / `draw_proto_masks` must now supply that
519///   image via `background` instead (behaviour changed in v0.16.4).
520/// - `opacity`: scales the alpha of rendered mask colors. `1.0` (default)
521///   preserves the class color's alpha unchanged; `0.5` makes masks
522///   semi-transparent.
523/// - `color_mode`: controls whether colors are assigned by class label,
524///   instance index, or track ID. Defaults to [`ColorMode::Class`].
525#[derive(Debug, Clone, Copy)]
526pub struct MaskOverlay<'a> {
527    /// Compositing source image. Must have the same dimensions and pixel
528    /// format as `dst`. When `Some`, the output is `background + masks`.
529    /// When `None`, `dst` is cleared to `0x00000000` before masks are drawn.
530    pub background: Option<&'a TensorDyn>,
531    pub opacity: f32,
532    /// Normalized letterbox region `[xmin, ymin, xmax, ymax]` in model-input
533    /// space that contains actual image content (the rest is padding).
534    ///
535    /// When set, bounding boxes and mask coordinates from the decoder (which
536    /// are in model-input normalized space) are mapped back to the original
537    /// image coordinate space before rendering.
538    ///
539    /// Use [`with_letterbox_crop`](Self::with_letterbox_crop) to compute this
540    /// from the [`Crop`] that was used in the model input [`convert`](crate::ImageProcessorTrait::convert) call.
541    pub letterbox: Option<[f32; 4]>,
542    pub color_mode: ColorMode,
543}
544
545impl Default for MaskOverlay<'_> {
546    fn default() -> Self {
547        Self {
548            background: None,
549            opacity: 1.0,
550            letterbox: None,
551            color_mode: ColorMode::Class,
552        }
553    }
554}
555
556impl<'a> MaskOverlay<'a> {
557    pub fn new() -> Self {
558        Self::default()
559    }
560
561    /// Set the compositing source image.
562    ///
563    /// `bg` must have the same dimensions and pixel format as the `dst` passed
564    /// to [`draw_decoded_masks`](crate::ImageProcessorTrait::draw_decoded_masks) /
565    /// [`draw_proto_masks`](crate::ImageProcessorTrait::draw_proto_masks).
566    /// The output will be `bg + masks`. Without a background, `dst` is cleared
567    /// to `0x00000000`.
568    pub fn with_background(mut self, bg: &'a TensorDyn) -> Self {
569        self.background = Some(bg);
570        self
571    }
572
573    pub fn with_opacity(mut self, opacity: f32) -> Self {
574        self.opacity = opacity.clamp(0.0, 1.0);
575        self
576    }
577
578    pub fn with_color_mode(mut self, mode: ColorMode) -> Self {
579        self.color_mode = mode;
580        self
581    }
582
583    /// Set the letterbox transform from the [`Crop`] used when preparing the
584    /// model input, so that bounding boxes and masks are correctly mapped back
585    /// to the original image coordinate space during rendering.
586    ///
587    /// Pass the same `crop` that was given to
588    /// [`convert`](crate::ImageProcessorTrait::convert) along with the model
589    /// input dimensions (`model_w` × `model_h`).
590    ///
591    /// Has no effect when `crop.dst_rect` is `None` (no letterbox applied).
592    pub fn with_letterbox_crop(
593        mut self,
594        crop: &Crop,
595        src_w: usize,
596        src_h: usize,
597        model_w: usize,
598        model_h: usize,
599    ) -> Self {
600        // The letterbox placement is resolved from the same source/destination
601        // dimensions `convert()` used, so the inverse map matches the render.
602        if let Ok(resolved) = crop.resolve(src_w, src_h, model_w, model_h) {
603            if let Some(r) = resolved.dst_rect {
604                self.letterbox = Some([
605                    r.left as f32 / model_w as f32,
606                    r.top as f32 / model_h as f32,
607                    (r.left + r.width) as f32 / model_w as f32,
608                    (r.top + r.height) as f32 / model_h as f32,
609                ]);
610            }
611        }
612        self
613    }
614}
615
616/// Copy a [`Segmentation`], replacing its bounds with `bbox`.
617///
618/// `TensorDyn` is deliberately not `Clone` (it may own an fd), so the mask
619/// bytes are copied explicitly. This is only reached on the letterbox path
620/// where the bounds must be rewritten to match an un-letterboxed detect box.
621fn copy_segmentation(s: &Segmentation, bbox: [f32; 4]) -> Result<Segmentation> {
622    use edgefirst_tensor::TensorMapTrait as _;
623    let src = s
624        .segmentation
625        .as_typed::<u8>()
626        .ok_or_else(|| Error::InvalidShape("segmentation must be a U8 tensor".into()))?;
627    let map = src.map_read()?;
628    // `Some(Mem)`, NOT `None`. `None` means auto-select, which prefers DMA on
629    // Linux and IOSurface on macOS -- so this would take a dma_heap ioctl and
630    // hold an fd PER DETECTION PER FRAME for a mask of a few hundred bytes,
631    // and turn every downstream map_read() in the render loop into a
632    // DMA_BUF_IOCTL_SYNC instead of a no-op. A mask copy is CPU-only scratch.
633    // `mask_to_tensor` reaches Mem the same way, via from_arrayview3.
634    let dst = edgefirst_tensor::Tensor::<u8>::new(
635        s.segmentation.shape(),
636        Some(edgefirst_tensor::TensorMemory::Mem),
637        None,
638    )
639    .map_err(|e| Error::InvalidShape(format!("segmentation copy: {e}")))?;
640    {
641        let mut dmap = dst.map_mut()?;
642        dmap.as_mut_slice().copy_from_slice(map.as_slice());
643    }
644    Ok(Segmentation {
645        xmin: bbox[0],
646        ymin: bbox[1],
647        xmax: bbox[2],
648        ymax: bbox[3],
649        segmentation: dst.into(),
650    })
651}
652
653/// Apply the inverse letterbox transform to a bounding box.
654///
655/// `lb` is `[lx0, ly0, lx1, ly1]` — the normalized region of the model input
656/// that contains actual image content (output of
657/// [`MaskOverlay::with_letterbox_crop`]). Converts model-input-normalized
658/// coords to output-image-normalized coords, clamped to `[0.0, 1.0]`, and
659/// canonicalises the bbox (ensures xmin <= xmax).
660///
661/// Thin wrapper over [`edgefirst_tensor::unletter_norm`]: the single home for
662/// the inverse-letterbox math sits beside the type it transforms, so this mask
663/// path and `edgefirst_decoder::tiling`'s detection lift share one
664/// implementation.
665#[inline]
666fn unletter_bbox(bbox: DetectBox, lb: [f32; 4]) -> DetectBox {
667    DetectBox {
668        bbox: edgefirst_tensor::unletter_norm(bbox.bbox, lb),
669        ..bbox
670    }
671}
672
673/// How a source is fit into the requested destination shape.
674#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
675pub enum Fit {
676    /// Stretch the source (or source crop) to fill the whole destination.
677    #[default]
678    Stretch,
679    /// Preserve the *source* aspect ratio, centring it in the destination and
680    /// padding the remainder with `pad` (RGBA — e.g. `[114, 114, 114, 255]` for
681    /// YOLO-style preprocessing).
682    Letterbox { pad: [u8; 4] },
683}
684
685/// Source-side convert geometry: which sub-rectangle of the source to sample
686/// (`source`) and how to fit it into the destination (`fit`). Destination
687/// *placement* is the destination itself — a tensor, or a [`Region`] view /
688/// `batch` tile of one — not a field here.
689#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
690pub struct Crop {
691    /// Sub-rectangle of the source to sample. `None` samples the whole source.
692    pub source: Option<Region>,
693    /// How the source is fit into the destination shape.
694    pub fit: Fit,
695}
696
697impl Crop {
698    /// A no-op crop: whole source, stretched to fill the whole destination.
699    pub fn new() -> Self {
700        Self::default()
701    }
702
703    /// Alias for [`Crop::new`] — whole source, stretch to fill.
704    pub fn no_crop() -> Self {
705        Self::default()
706    }
707
708    /// Letterbox fit: preserve the source aspect ratio, padding the remainder
709    /// with `pad` (RGBA).
710    pub fn letterbox(pad: [u8; 4]) -> Self {
711        Self {
712            source: None,
713            fit: Fit::Letterbox { pad },
714        }
715    }
716
717    /// Sample only `source` of the input (builder).
718    pub fn with_source(mut self, source: Option<Region>) -> Self {
719        self.source = source;
720        self
721    }
722
723    /// Set the fit mode (builder).
724    pub fn with_fit(mut self, fit: Fit) -> Self {
725        self.fit = fit;
726        self
727    }
728
729    /// Resolve to the effective backend geometry for a `src_w`×`src_h` source
730    /// and `dst_w`×`dst_h` destination: the source sampling rect, the
731    /// destination placement rect (`None` = whole destination), and the pad
732    /// colour. **The single place letterbox placement is computed** — every
733    /// backend consumes the resolved rects rather than re-deriving them.
734    pub(crate) fn resolve(
735        &self,
736        src_w: usize,
737        src_h: usize,
738        dst_w: usize,
739        dst_h: usize,
740    ) -> Result<ResolvedCrop, Error> {
741        let src_rect = self.source.map(region_to_rect);
742        // The letterbox aspect uses the *effective* source content — the source
743        // crop when set, else the full source.
744        let (sw, sh) = match self.source {
745            Some(r) => (r.width, r.height),
746            None => (src_w, src_h),
747        };
748        let resolved = match self.fit {
749            Fit::Stretch => ResolvedCrop {
750                src_rect,
751                dst_rect: None,
752                dst_color: None,
753            },
754            Fit::Letterbox { pad } => ResolvedCrop {
755                src_rect,
756                dst_rect: Some(letterbox_rect(sw, sh, dst_w, dst_h)),
757                dst_color: Some(pad),
758            },
759        };
760        resolved.check_crop_dims(src_w, src_h, dst_w, dst_h)?;
761        Ok(resolved)
762    }
763
764    /// Validate against `TensorDyn` source and destination dimensions.
765    pub fn check_crop_dyn(
766        &self,
767        src: &edgefirst_tensor::TensorDyn,
768        dst: &edgefirst_tensor::TensorDyn,
769    ) -> Result<(), Error> {
770        self.resolve(
771            src.width().unwrap_or(0),
772            src.height().unwrap_or(0),
773            dst.width().unwrap_or(0),
774            dst.height().unwrap_or(0),
775        )
776        .map(|_| ())
777    }
778}
779
780/// Resolved crop geometry consumed by the backends. Produced by
781/// [`Crop::resolve`]; the backends read these fields directly (the same shape
782/// the public `Crop` carried before destination placement moved to the view).
783#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
784pub(crate) struct ResolvedCrop {
785    pub(crate) src_rect: Option<Rect>,
786    pub(crate) dst_rect: Option<Rect>,
787    pub(crate) dst_color: Option<[u8; 4]>,
788}
789
790impl ResolvedCrop {
791    /// A no-op resolved crop (whole source → whole destination, no pad).
792    #[allow(dead_code)] // used by unit tests and the batch render paths
793    pub(crate) fn no_crop() -> Self {
794        Self::default()
795    }
796
797    /// Validate the resolved rects against explicit dimensions.
798    pub(crate) fn check_crop_dims(
799        &self,
800        src_w: usize,
801        src_h: usize,
802        dst_w: usize,
803        dst_h: usize,
804    ) -> Result<(), Error> {
805        let src_ok = self
806            .src_rect
807            .is_none_or(|r| r.left + r.width <= src_w && r.top + r.height <= src_h);
808        let dst_ok = self
809            .dst_rect
810            .is_none_or(|r| r.left + r.width <= dst_w && r.top + r.height <= dst_h);
811        match (src_ok, dst_ok) {
812            (true, true) => Ok(()),
813            (true, false) => Err(Error::CropInvalid(format!(
814                "Dest crop invalid: {:?}",
815                self.dst_rect
816            ))),
817            (false, true) => Err(Error::CropInvalid(format!(
818                "Src crop invalid: {:?}",
819                self.src_rect
820            ))),
821            (false, false) => Err(Error::CropInvalid(format!(
822                "Dest and Src crop invalid: {:?} {:?}",
823                self.dst_rect, self.src_rect
824            ))),
825        }
826    }
827}
828
829/// Convert a pixel [`Region`] to the internal [`Rect`] placement type.
830fn region_to_rect(r: Region) -> Rect {
831    Rect {
832        left: r.x,
833        top: r.y,
834        width: r.width,
835        height: r.height,
836    }
837}
838
839/// Centred aspect-preserving placement of an `sw`×`sh` source within a `dw`×`dh`
840/// destination — the canonical letterbox rectangle (one home, replacing the
841/// per-caller `calculate_letterbox` copies).
842fn letterbox_rect(sw: usize, sh: usize, dw: usize, dh: usize) -> Rect {
843    if sw == 0 || sh == 0 {
844        return Rect::new(0, 0, dw, dh);
845    }
846    let src_aspect = sw as f64 / sh as f64;
847    let dst_aspect = dw as f64 / dh as f64;
848    let (new_w, new_h) = if src_aspect > dst_aspect {
849        (dw, ((dw as f64 / src_aspect).round() as usize).max(1))
850    } else {
851        (((dh as f64 * src_aspect).round() as usize).max(1), dh)
852    };
853    let left = dw.saturating_sub(new_w) / 2;
854    let top = dh.saturating_sub(new_h) / 2;
855    Rect::new(left, top, new_w, new_h)
856}
857
858/// Internal placement rectangle (top-left + size). The **public** pixel
859/// sub-region type is [`Region`] (re-exported from `edgefirst-tensor`);
860/// `Rect` is the crate-private resolved-placement representation used by the
861/// CPU/G2D/GL backends after [`Crop::resolve`].
862#[derive(Debug, Clone, Copy, PartialEq, Eq)]
863pub(crate) struct Rect {
864    pub left: usize,
865    pub top: usize,
866    pub width: usize,
867    pub height: usize,
868}
869
870impl Rect {
871    // Creates a new Rect with the specified left, top, width, and height.
872    pub fn new(left: usize, top: usize, width: usize, height: usize) -> Self {
873        Self {
874            left,
875            top,
876            width,
877            height,
878        }
879    }
880}
881
882#[enum_dispatch(ImageProcessor)]
883pub trait ImageProcessorTrait {
884    /// Convert `src` into `dst`'s format and size, applying the crop first,
885    /// then the flip, then the rotation.
886    ///
887    /// The destination is normally an RGB-family colour — `Grey`, `Rgb`,
888    /// `Rgba`/`Bgra`, packed `HWC` or planar `CHW`. That is what the
889    /// accelerated paths target and what models expect. The CPU backend also
890    /// accepts a handful of YUV destinations (`Yuyv`→`Yuyv`, `Yuyv`→`Nv16`,
891    /// and the `Vyuy` equivalents); no GPU or G2D path does, so those pairs
892    /// always land on the CPU.
893    ///
894    /// # Arguments
895    ///
896    /// * `src` - The source image to convert from.
897    /// * `dst` - The destination image. Its shape and [`PixelFormat`] define
898    ///   the output; pass a [`view`](edgefirst_tensor::TensorDyn::view) or
899    ///   [`batch`](edgefirst_tensor::TensorDyn::batch) of a larger tensor to
900    ///   render into one tile of a batch.
901    /// * `rotation` - Rotation applied to the output, in 90° steps.
902    /// * `flip` - Horizontal, vertical, or both.
903    /// * `crop` - Source-side geometry: which sub-rectangle of `src` to sample
904    ///   ([`Crop::source`]) and how to fit it into `dst`'s shape
905    ///   ([`Crop::fit`] — stretch, or letterbox with a pad colour). Destination
906    ///   placement lives on `dst`, not here.
907    ///
908    /// # Errors
909    ///
910    /// - [`Error::CropInvalid`] if `crop.source` falls outside `src`.
911    /// - [`Error::NotAnImage`] if either tensor carries no `PixelFormat`.
912    /// - [`Error::NoConverter`] if no backend accepts the format pair — the
913    ///   dispatch tries OpenGL, then G2D, then CPU, so this means all three
914    ///   declined.
915    ///
916    /// Backend-specific failures (EGL import, G2D blit, allocation) surface as
917    /// their own variants. A backend *declining* a pair is not an error while
918    /// another remains in the chain; it silently costs the zero-copy path, and
919    /// [`ImageProcessor::convert_fallback_count`] counts those.
920    fn convert(
921        &mut self,
922        src: &TensorDyn,
923        dst: &mut TensorDyn,
924        rotation: Rotation,
925        flip: Flip,
926        crop: Crop,
927    ) -> Result<()>;
928
929    /// Draw pre-decoded detection boxes and segmentation masks onto `dst`.
930    ///
931    /// Supports two segmentation modes based on the mask channel count:
932    /// - **Instance segmentation** (`C=1`): one `Segmentation` per detection,
933    ///   `segmentation` and `detect` are zipped.
934    /// - **Semantic segmentation** (`C>1`): a single `Segmentation` covering
935    ///   all classes; only the first element is used.
936    ///
937    /// # Format requirements
938    ///
939    /// - CPU backend: `dst` must be `RGBA` or `RGB`.
940    /// - OpenGL backend: `dst` must be `RGBA`, `BGRA`, or `RGB`.
941    /// - G2D backend: only produces the base frame (empty detections);
942    ///   returns `NotImplemented` when any detection or segmentation is
943    ///   supplied.
944    ///
945    /// # Output contract
946    ///
947    /// This function always fully writes `dst` — it never relies on the
948    /// caller having pre-cleared the destination. The four cases are:
949    ///
950    /// | detections | background | output                              |
951    /// |------------|------------|-------------------------------------|
952    /// | none       | none       | dst cleared to `0x00000000`         |
953    /// | none       | set        | dst ← background                    |
954    /// | set        | none       | masks drawn over cleared dst        |
955    /// | set        | set        | masks drawn over background         |
956    ///
957    /// Each backend implements this with its native primitives: G2D uses
958    /// `g2d_clear` / `g2d_blit`, OpenGL uses `glClear` / DMA-BUF GPU blit
959    /// plus the mask program, and CPU uses direct buffer fill / memcpy as
960    /// the terminal fallback. CPU-memcpy of DMA buffers is avoided on the
961    /// accelerated paths.
962    ///
963    /// An empty `segmentation` slice is valid — only bounding boxes are drawn.
964    ///
965    /// `overlay` controls compositing: `background` is the compositing source
966    /// (must match `dst` in size and format); `opacity` scales mask alpha.
967    ///
968    /// # Buffer aliasing
969    ///
970    /// `dst` and `overlay.background` must reference **distinct underlying
971    /// buffers**. An aliased pair returns [`Error::AliasedBuffers`] without
972    /// dispatching to any backend — the GL path would otherwise read and
973    /// write the same texture in a single draw, which is undefined behaviour
974    /// on most drivers. Aliasing is detected via
975    /// [`TensorDyn::aliases`](edgefirst_tensor::TensorDyn::aliases), which
976    /// catches both shared-allocation clones and separate imports over the
977    /// same dmabuf fd.
978    ///
979    /// # Migration from v0.16.3 and earlier
980    ///
981    /// Prior to v0.16.4 the call silently preserved `dst`'s contents on empty
982    /// detections. That invariant no longer holds — `dst` is always fully
983    /// written. Callers who pre-loaded an image into `dst` before calling this
984    /// function must now pass that image via `overlay.background` instead.
985    fn draw_decoded_masks(
986        &mut self,
987        dst: &mut TensorDyn,
988        detect: &[DetectBox],
989        segmentation: &[Segmentation],
990        overlay: MaskOverlay<'_>,
991    ) -> Result<()>;
992
993    /// Draw masks from proto data onto image (fused decode+draw).
994    ///
995    /// For YOLO segmentation models, this avoids materializing intermediate
996    /// `Array3<u8>` masks. The `ProtoData` contains mask coefficients and the
997    /// prototype tensor; the renderer computes `mask_coeff @ protos` directly
998    /// at the output resolution using bilinear sampling.
999    ///
1000    /// `detect` and `proto_data.mask_coefficients` must have the same length
1001    /// (enforced by zip — excess entries are silently ignored). An empty
1002    /// `detect` slice is valid and produces the base frame — cleared or
1003    /// background-blitted — via the selected backend's native primitive.
1004    ///
1005    /// # Format requirements and output contract
1006    ///
1007    /// Same as [`draw_decoded_masks`](Self::draw_decoded_masks), including
1008    /// the "always fully writes dst" guarantee across all four
1009    /// detection/background combinations.
1010    ///
1011    /// `overlay` controls compositing — see [`draw_decoded_masks`](Self::draw_decoded_masks).
1012    fn draw_proto_masks(
1013        &mut self,
1014        dst: &mut TensorDyn,
1015        detect: &[DetectBox],
1016        proto_data: &ProtoData,
1017        overlay: MaskOverlay<'_>,
1018    ) -> Result<()>;
1019
1020    /// Sets the colors used for rendering segmentation masks. Up to 20 colors
1021    /// can be set.
1022    fn set_class_colors(&mut self, colors: &[[u8; 4]]) -> Result<()>;
1023
1024    /// Like [`convert`](Self::convert), but does not wait for the GPU to finish.
1025    ///
1026    /// This is the batch-preprocessing primitive: a caller renders `N` tiles
1027    /// into one batched destination by looping
1028    /// `convert_deferred(&src[n], &mut dst.batch(n)?, …)` and then calling
1029    /// [`flush`](Self::flush) **once**. On the OpenGL backend every deferred
1030    /// convert into sibling views of one buffer shares a single EGLImage import
1031    /// (the tile is a `glViewport`/`glScissor` ROI into the parent) and skips the
1032    /// per-tile `glFinish`; `flush` then issues a single GPU sync. The result of
1033    /// a deferred convert is **not** safe to read on the CPU (or map via CUDA)
1034    /// until `flush` returns.
1035    ///
1036    /// The default implementation is eager — it simply calls
1037    /// [`convert`](Self::convert), so CPU/G2D and any backend without a deferred
1038    /// fast path remain correct (each call completes synchronously and `flush`
1039    /// is a no-op).
1040    fn convert_deferred(
1041        &mut self,
1042        src: &TensorDyn,
1043        dst: &mut TensorDyn,
1044        rotation: Rotation,
1045        flip: Flip,
1046        crop: Crop,
1047    ) -> Result<()> {
1048        self.convert(src, dst, rotation, flip, crop)
1049    }
1050
1051    /// Complete all work enqueued by [`convert_deferred`](Self::convert_deferred)
1052    /// since the last flush, issuing a single GPU synchronization.
1053    ///
1054    /// After this returns, every deferred destination is finished and safe to
1055    /// read back or `cuda_map`. Backends with no deferred path (the default)
1056    /// return `Ok(())` immediately, since their converts already completed.
1057    fn flush(&mut self) -> Result<()> {
1058        Ok(())
1059    }
1060}
1061
1062/// Configuration for [`ImageProcessor`] construction.
1063///
1064/// Use with [`ImageProcessor::with_config`] to override the default EGL
1065/// display auto-detection and backend selection. The default configuration
1066/// preserves the existing auto-detection behaviour.
1067#[derive(Debug, Clone, Default)]
1068pub struct ImageProcessorConfig {
1069    /// Force OpenGL to use this EGL display type instead of auto-detecting.
1070    ///
1071    /// When `None`, the processor probes displays in priority order: GBM,
1072    /// PlatformDevice, Default. Use [`probe_egl_displays`] to discover
1073    /// which displays are available on the current system.
1074    ///
1075    /// Ignored when `EDGEFIRST_DISABLE_GL=1` is set, and on macOS and
1076    /// Windows (ANGLE is the only display there — Metal and Direct3D 11
1077    /// respectively; a `Some` value logs a debug note and is otherwise
1078    /// ignored).
1079    #[cfg(all(
1080        any(
1081            target_os = "linux",
1082            target_os = "macos",
1083            target_os = "ios",
1084            target_os = "android",
1085            target_os = "windows"
1086        ),
1087        feature = "opengl"
1088    ))]
1089    pub egl_display: Option<EglDisplayKind>,
1090
1091    /// Preferred compute backend.
1092    ///
1093    /// When set to a specific backend (not [`ComputeBackend::Auto`]), the
1094    /// processor initializes that backend with no fallback — returns an error if the conversion is not supported.
1095    /// This takes precedence over `EDGEFIRST_FORCE_BACKEND` and the
1096    /// `EDGEFIRST_DISABLE_*` environment variables.
1097    ///
1098    /// - [`ComputeBackend::OpenGl`]: init OpenGL + CPU, skip G2D
1099    /// - [`ComputeBackend::G2d`]: init G2D + CPU, skip OpenGL
1100    /// - [`ComputeBackend::Cpu`]: init CPU only
1101    /// - [`ComputeBackend::Auto`]: existing env-var-driven selection
1102    pub backend: ComputeBackend,
1103
1104    /// Colorimetry/performance trade-off for `convert()` (see
1105    /// [`ColorimetryMode`]). Defaults to [`ColorimetryMode::Fast`]. The
1106    /// `EDGEFIRST_COLORIMETRY` environment variable (`fast` | `exact`)
1107    /// overrides this setting when present.
1108    pub colorimetry: ColorimetryMode,
1109
1110    /// How many buffer imports the OpenGL backend keeps cached, per cache.
1111    ///
1112    /// `None` (the default) uses `EDGEFIRST_EGL_CACHE_CAPACITY` if set, and
1113    /// 16 otherwise. Precedence is:
1114    ///
1115    /// ```text
1116    /// egl_cache_capacity  >  EDGEFIRST_EGL_CACHE_CAPACITY  >  16
1117    /// ```
1118    ///
1119    /// Config beats the environment deliberately, matching
1120    /// [`backend`](Self::backend): an embedder that has measured its own
1121    /// pool must not be silently overridden by a stray environment variable
1122    /// in the deployment. `Some(0)` is rejected and falls through to the
1123    /// environment, then the default, exactly as an unparseable env value
1124    /// does.
1125    ///
1126    /// # This is a buffer *count*, not a size
1127    ///
1128    /// A cached import pins its buffer — the underlying dma-buf or IOSurface
1129    /// cannot be freed while an entry names it (that is what makes the cache
1130    /// correct as well as fast; see the "EGL image cache inside HAL" section
1131    /// of `ARCHITECTURE.md`). So the memory this holds depends entirely on
1132    /// how big the caller's buffers are:
1133    ///
1134    /// ```text
1135    /// worst-case pinned bytes                                (upper bound)
1136    ///     = capacity x 3 caches x frame size x live ImageProcessors
1137    ///
1138    /// capacity 16, 640x640 RGB   (1.2 MB)  ->   59 MB per processor
1139    /// capacity 16, 1080p NV12    (3.1 MB)  ->  149 MB per processor
1140    /// capacity 16, 4K NV12      (12.4 MB)  ->  597 MB per processor
1141    /// ```
1142    ///
1143    /// A ceiling rather than an exact total: the source and NV R8 caches
1144    /// often hold two imports of the *same* buffer, and two entries naming
1145    /// one buffer pin it once. The per-cache figure is the exact one.
1146    ///
1147    /// The bound is **per [`ImageProcessor`], times three caches** (source,
1148    /// destination, and the NV R8 source cache) — not per process and not
1149    /// per library. Two `ImageProcessor`s in one process have six
1150    /// independent caches and six independent capacities.
1151    ///
1152    /// Whether the worst case is ever approached is up to the caller. A
1153    /// pooled producer — V4L2 capture, typically 4–8 buffers over one or two
1154    /// sizes — settles at its pool size and never nears the bound. A stream
1155    /// that allocates a fresh buffer per frame holds the last `capacity` of
1156    /// them outright, which is the difference between "16 is free" and "16
1157    /// is 597 MB".
1158    ///
1159    /// Size it from measurement rather than guesswork:
1160    /// [`CacheStats::peak_entries`] reports the working set actually
1161    /// reached, and [`CacheStats::evictions`] tells you when the bound is
1162    /// costing you re-imports (it should be zero in a steady state).
1163    ///
1164    /// Ignored when the OpenGL backend is not in use.
1165    pub egl_cache_capacity: Option<usize>,
1166}
1167
1168/// How `convert()` trades colorimetric exactness against speed on platforms
1169/// where the exact path is expensive.
1170///
1171/// Today this affects one decision: NV12 sources on Vivante GC7000UL
1172/// (i.MX 8M Plus), where the hardware external sampler converts ~12× faster
1173/// than the colorimetry-exact in-shader matrix (2.5 ms vs 29 ms at 720p)
1174/// but applies the driver's fixed BT.601-limited matrix regardless of the
1175/// source's tagged colorimetry. Platforms where the exact path is already
1176/// the fastest correct path (Mali, V3D, Tegra, ANGLE) behave identically in
1177/// both modes.
1178///
1179/// Override at runtime with `EDGEFIRST_COLORIMETRY=fast|exact` (takes
1180/// precedence over the config field), or per-source by forcing a path with
1181/// `EDGEFIRST_NV_CONVERT_PATH`.
1182#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1183pub enum ColorimetryMode {
1184    /// Prefer the fastest path whose output is correct-enough video RGB
1185    /// (default; issue #106 policy). On Vivante, NV12 takes the hardware
1186    /// sampler even when the source is not BT.601-limited.
1187    #[default]
1188    Fast,
1189    /// Prefer bit-exact colorimetry everywhere: the fast path is used only
1190    /// when it matches the source's resolved (encoding, range) exactly.
1191    Exact,
1192}
1193
1194/// Compute backend selection for [`ImageProcessor`].
1195///
1196/// Use with [`ImageProcessorConfig::backend`] to select which backend the
1197/// processor should prefer. When a specific backend is selected, the
1198/// processor initializes that backend plus CPU as a fallback. When `Auto`
1199/// is used, the existing environment-variable-driven selection applies.
1200#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1201pub enum ComputeBackend {
1202    /// Auto-detect based on available hardware and environment variables.
1203    #[default]
1204    Auto,
1205    /// CPU-only processing (no hardware acceleration).
1206    Cpu,
1207    /// Prefer G2D hardware blitter (+ CPU fallback).
1208    G2d,
1209    /// Prefer OpenGL ES (+ CPU fallback).
1210    OpenGl,
1211}
1212
1213/// Backend forced via the `EDGEFIRST_FORCE_BACKEND` environment variable
1214/// or [`ImageProcessorConfig::backend`].
1215///
1216/// When set, the [`ImageProcessor`] only initializes and dispatches to the
1217/// selected backend — no fallback chain is used.
1218#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1219pub(crate) enum ForcedBackend {
1220    Cpu,
1221    G2d,
1222    OpenGl,
1223}
1224
1225/// Reports which float-color-buffer extensions the GPU backend detected.
1226/// Returned by [`ImageProcessor::supported_render_dtypes`]; the two flags
1227/// are independent.
1228///
1229/// **Linux:** reflects the real probe results from `GL_EXT_color_buffer_half_float`
1230/// and `GL_EXT_color_buffer_float`. On V3D (RPi 5) and Mali-G310 (i.MX 95)
1231/// both flags are typically `true`; on Vivante GC7000UL both are forced
1232/// `false` (float readback measured 170–320 ms — disabled). Tegra Orin
1233/// exposes both via PBO; the flags match the GPU report.
1234///
1235/// **macOS (ANGLE):** `f16 == true` gates the RGBA16F-packed IOSurface
1236/// path for F16 `PlanarRgb` destinations. `f32` reflects the GL
1237/// extension probe but is not actionable — ANGLE's
1238/// `EGL_ANGLE_iosurface_client_buffer` rejects every `(GL_FLOAT, *)`
1239/// combination with `EGL_BAD_ATTRIBUTE`, so there is no F32 IOSurface
1240/// path.
1241///
1242/// Regardless of these flags, [`ImageProcessor::convert`] never returns
1243/// an error due to float capability — it falls back to CPU when the GPU
1244/// path is unavailable.
1245#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1246pub struct RenderDtypeSupport {
1247    /// `GL_EXT_color_buffer_float` is available on the current GPU.
1248    ///
1249    /// On Linux, `true` enables F32 `Rgb` NHWC PBO readback. On macOS
1250    /// this flag is informational only — no F32 IOSurface path exists.
1251    pub f32: bool,
1252    /// `GL_EXT_color_buffer_half_float` is available on the current GPU.
1253    ///
1254    /// On Linux, `true` enables F16 `PlanarRgb` NCHW PBO readback and,
1255    /// on V3D/Mali, zero-copy DMA-BUF render. On macOS, `true` enables
1256    /// F16 `PlanarRgb` via RGBA16F-packed IOSurface (zero-copy).
1257    pub f16: bool,
1258}
1259
1260/// Returns `true` when a float PBO destination should be attempted for `dtype`.
1261///
1262/// Only F16 and F32 are eligible, and only when the corresponding flag in
1263/// `support` is set. U8/I8 and all other dtypes return `false` — they are
1264/// handled by the existing `dtype.size() == 1` PBO gate.
1265///
1266/// Linux + Windows only: the float PBO readback path belongs to the
1267/// PBO-transfer GL backends (native EGL on Linux, ANGLE/D3D11 on Windows);
1268/// macOS routes F16 through the RGBA16F-packed IOSurface instead and
1269/// never calls this. The sole runtime caller in `create_image` carries the
1270/// same gate, so leaving this ungated makes it dead code on macOS under
1271/// `-D warnings`. Its unit test (`float_pbo_eligibility`) carries the
1272/// matching gate.
1273#[cfg(all(any(target_os = "linux", target_os = "windows"), feature = "opengl"))]
1274pub(crate) fn float_pbo_eligible(dtype: DType, support: RenderDtypeSupport) -> bool {
1275    match dtype {
1276        DType::F16 => support.f16,
1277        DType::F32 => support.f32,
1278        _ => false,
1279    }
1280}
1281
1282/// Image converter that uses available hardware acceleration or CPU as a
1283/// fallback.
1284#[derive(Debug)]
1285pub struct ImageProcessor {
1286    /// CPU-based image converter as a fallback. This is only None if the
1287    /// EDGEFIRST_DISABLE_CPU environment variable is set.
1288    pub cpu: Option<CPUProcessor>,
1289
1290    #[cfg(target_os = "linux")]
1291    /// G2D-based image converter for Linux systems. This is only available if
1292    /// the EDGEFIRST_DISABLE_G2D environment variable is not set and libg2d.so
1293    /// is available.
1294    pub g2d: Option<G2DProcessor>,
1295    #[cfg(target_os = "linux")]
1296    #[cfg(feature = "opengl")]
1297    /// OpenGL-based image converter for Linux systems. This is only available
1298    /// if the EDGEFIRST_DISABLE_GL environment variable is not set and OpenGL
1299    /// ES is available.
1300    pub opengl: Option<GLProcessorThreaded>,
1301    #[cfg(any(
1302        target_os = "macos",
1303        target_os = "ios",
1304        target_os = "android",
1305        target_os = "windows"
1306    ))]
1307    #[cfg(feature = "opengl")]
1308    /// OpenGL-based image converter — the same unified
1309    /// `GLProcessorThreaded` engine as Linux (its worker owns a
1310    /// per-processor context). macOS/iOS run it via ANGLE + IOSurface
1311    /// (available when ANGLE's libEGL.dylib can be loaded — see
1312    /// README.md § macOS GPU Acceleration); Android runs it via the
1313    /// native EGL driver + AHardwareBuffer; Windows runs it via ANGLE +
1314    /// Direct3D 11 with PBO transfers (available when ANGLE's libEGL.dll
1315    /// can be loaded — see README.md § Windows GPU Acceleration).
1316    pub opengl: Option<GLProcessorThreaded>,
1317
1318    /// When set, only the specified backend is used — no fallback chain.
1319    pub(crate) forced_backend: Option<ForcedBackend>,
1320
1321    /// Converts where the GL backend declined and the auto chain fell
1322    /// through toward G2D/CPU. Owned by the dispatcher (the GL worker never
1323    /// sees these frames), unlike the per-feed counters in
1324    /// `GLProcessorThreaded::convert_stats`. Read via
1325    /// [`convert_fallback_count`](Self::convert_fallback_count).
1326    pub(crate) convert_fallbacks: std::sync::atomic::AtomicU64,
1327}
1328
1329unsafe impl Send for ImageProcessor {}
1330unsafe impl Sync for ImageProcessor {}
1331
1332impl ImageProcessor {
1333    /// Creates a new `ImageProcessor` instance, initializing available
1334    /// hardware converters based on the system capabilities and environment
1335    /// variables.
1336    ///
1337    /// # Examples
1338    /// ```rust,no_run
1339    /// # use edgefirst_image::{ImageProcessor, Rotation, Flip, Crop, ImageProcessorTrait};
1340    /// # use edgefirst_codec::{peek_info, ImageDecoder, ImageLoad};
1341    /// # use edgefirst_tensor::{CpuAccess, PixelFormat, DType, Tensor, TensorMemory};
1342    /// # fn main() -> Result<(), edgefirst_image::Error> {
1343    /// let image = std::fs::read("zidane.jpg")?;
1344    /// // The codec emits the source's native format (a colour JPEG decodes to
1345    /// // NV12) and configures the destination tensor during the decode.
1346    /// let info = peek_info(&image).expect("peek");
1347    /// let mut src = Tensor::<u8>::image(info.width, info.height, info.format,
1348    ///                                    Some(TensorMemory::Mem), CpuAccess::ReadWrite)?;
1349    /// let mut decoder = ImageDecoder::new();
1350    /// src.load_image(&mut decoder, &image).expect("decode");
1351    /// let mut converter = ImageProcessor::new()?;
1352    /// let mut dst =
1353    ///     converter.create_image(640, 480, PixelFormat::Rgb, DType::U8, None, CpuAccess::ReadWrite)?;
1354    /// converter.convert(&src.into(), &mut dst, Rotation::None, Flip::None, Crop::default())?;
1355    /// # Ok(())
1356    /// # }
1357    /// ```
1358    pub fn new() -> Result<Self> {
1359        Self::with_config(ImageProcessorConfig::default())
1360    }
1361
1362    /// Number of converts where the auto chain's GL backend declined and the
1363    /// frame fell through toward G2D/CPU — each one silently lost any
1364    /// zero-copy guarantee. A pipeline that expects to stay on the GPU
1365    /// asserts this stays flat across its steady-state loop; pair with
1366    /// `GLProcessorThreaded::convert_stats` to see how the frames that DID
1367    /// reach GL were fed. Always 0 under a forced backend (no chain).
1368    pub fn convert_fallback_count(&self) -> u64 {
1369        self.convert_fallbacks
1370            .load(std::sync::atomic::Ordering::Relaxed)
1371    }
1372
1373    /// Number of [`Compression::Any`](edgefirst_tensor::Compression)
1374    /// requests since process start that resolved to a linear layout
1375    /// (process-wide mirror of
1376    /// [`edgefirst_tensor::compression_fallback_count`], surfaced here
1377    /// beside [`convert_fallback_count`](Self::convert_fallback_count)
1378    /// so pipeline telemetry reads from one place). A pipeline that
1379    /// expects compressed convert destinations asserts this stays flat
1380    /// after warmup.
1381    pub fn compression_fallback_count(&self) -> u64 {
1382        edgefirst_tensor::compression_fallback_count()
1383    }
1384
1385    /// Convert and return a native sync-fence fd that signals when the
1386    /// GPU work completes, instead of blocking the CPU — the GL→NPU
1387    /// handoff (`EGL_ANDROID_native_fence_sync` on Android).
1388    ///
1389    /// `Ok(Some(fd))`: the destination buffer is still in flight; hand
1390    /// the fd to the consumer (e.g.
1391    /// `ANeuralNetworksExecution_startComputeWithDependencies`) or
1392    /// `poll()` it before reading. `Ok(None)`: the convert completed with
1393    /// the normal blocking contract (no native fence on this platform, or
1394    /// a non-GL backend handled the frame) — the destination is already
1395    /// safe. Semantics are otherwise identical to
1396    /// [`convert`](ImageProcessorTrait::convert), including the
1397    /// GL→G2D→CPU fallback chain.
1398    #[cfg(unix)]
1399    pub fn convert_with_fence(
1400        &mut self,
1401        src: &TensorDyn,
1402        dst: &mut TensorDyn,
1403        rotation: Rotation,
1404        flip: Flip,
1405        crop: Crop,
1406    ) -> Result<Option<std::os::fd::OwnedFd>> {
1407        #[cfg(any(
1408            target_os = "linux",
1409            target_os = "macos",
1410            target_os = "ios",
1411            target_os = "android",
1412            target_os = "windows"
1413        ))]
1414        #[cfg(feature = "opengl")]
1415        {
1416            let gl_forced = matches!(self.forced_backend, Some(ForcedBackend::OpenGl));
1417            if self.forced_backend.is_none() || gl_forced {
1418                if let Some(opengl) = self.opengl.as_mut() {
1419                    match opengl.convert_with_fence(src, dst, rotation, flip, crop) {
1420                        Ok(fd) => return Ok(fd),
1421                        Err(e) if gl_forced => return Err(e),
1422                        Err(e) => {
1423                            // Not counted here: the blocking chain below
1424                            // re-runs the dispatcher, whose decline arm
1425                            // counts the fallback exactly once.
1426                            log::debug!(
1427                                "convert_with_fence: opengl declined, \
1428                                 falling back to the blocking chain: {e}"
1429                            );
1430                        }
1431                    }
1432                } else if gl_forced {
1433                    return Err(Error::ForcedBackendUnavailable("opengl".into()));
1434                }
1435            }
1436        }
1437        // Blocking chain (G2D/CPU, forced non-GL, or non-GL builds):
1438        // completion == return, so no fence is needed.
1439        self.convert(src, dst, rotation, flip, crop)?;
1440        Ok(None)
1441    }
1442
1443    /// Report which float dtypes the GPU can render to.
1444    ///
1445    /// Probes `GL_EXT_color_buffer_half_float` and
1446    /// `GL_EXT_color_buffer_float` once at `ImageProcessor::new()` time
1447    /// and caches the result. Call this once at startup to decide whether
1448    /// to request F16 or F32 destination tensors; [`create_image`] uses
1449    /// the result internally to auto-select float PBO when supported.
1450    ///
1451    /// Returns `RenderDtypeSupport { f32: false, f16: false }` when no
1452    /// OpenGL backend is active or the `opengl` feature is disabled.
1453    ///
1454    /// [`create_image`]: Self::create_image
1455    pub fn supported_render_dtypes(&self) -> RenderDtypeSupport {
1456        #[cfg(all(
1457            any(
1458                target_os = "macos",
1459                target_os = "ios",
1460                target_os = "android",
1461                target_os = "windows"
1462            ),
1463            feature = "opengl"
1464        ))]
1465        if let Some(gl) = self.opengl.as_ref() {
1466            return gl.supported_render_dtypes();
1467        }
1468        #[cfg(all(target_os = "linux", feature = "opengl"))]
1469        if let Some(gl) = self.opengl.as_ref() {
1470            return gl.supported_render_dtypes();
1471        }
1472        RenderDtypeSupport {
1473            f32: false,
1474            f16: false,
1475        }
1476    }
1477
1478    /// Creates a new `ImageProcessor` with the given configuration.
1479    ///
1480    /// When [`ImageProcessorConfig::backend`] is set to a specific backend,
1481    /// environment variables are ignored and the processor initializes the
1482    /// requested backend plus CPU as a fallback.
1483    ///
1484    /// When `Auto`, the existing `EDGEFIRST_FORCE_BACKEND` and
1485    /// `EDGEFIRST_DISABLE_*` environment variables apply.
1486    #[allow(unused_variables)]
1487    pub fn with_config(config: ImageProcessorConfig) -> Result<Self> {
1488        // ── Config-driven backend selection ──────────────────────────
1489        // When the caller explicitly requests a backend via the config,
1490        // skip all environment variable logic.
1491        match config.backend {
1492            ComputeBackend::Cpu => {
1493                log::info!("ComputeBackend::Cpu — CPU only");
1494                return Ok(Self {
1495                    cpu: Some(CPUProcessor::new()),
1496                    #[cfg(target_os = "linux")]
1497                    g2d: None,
1498                    #[cfg(target_os = "linux")]
1499                    #[cfg(feature = "opengl")]
1500                    opengl: None,
1501                    #[cfg(any(
1502                        target_os = "macos",
1503                        target_os = "ios",
1504                        target_os = "android",
1505                        target_os = "windows"
1506                    ))]
1507                    #[cfg(feature = "opengl")]
1508                    opengl: None,
1509                    convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1510                    forced_backend: None,
1511                });
1512            }
1513            ComputeBackend::G2d => {
1514                log::info!("ComputeBackend::G2d — G2D + CPU fallback");
1515                #[cfg(target_os = "linux")]
1516                {
1517                    let g2d = match G2DProcessor::new() {
1518                        Ok(g) => Some(g),
1519                        Err(e) => {
1520                            log::warn!("G2D requested but failed to initialize: {e:?}");
1521                            None
1522                        }
1523                    };
1524                    return Ok(Self {
1525                        cpu: Some(CPUProcessor::new()),
1526                        g2d,
1527                        #[cfg(feature = "opengl")]
1528                        opengl: None,
1529                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1530                        forced_backend: None,
1531                    });
1532                }
1533                #[cfg(not(target_os = "linux"))]
1534                {
1535                    log::warn!("G2D requested but not available on this platform, using CPU");
1536                    return Ok(Self {
1537                        cpu: Some(CPUProcessor::new()),
1538                        #[cfg(any(
1539                            target_os = "macos",
1540                            target_os = "ios",
1541                            target_os = "android",
1542                            target_os = "windows"
1543                        ))]
1544                        #[cfg(feature = "opengl")]
1545                        opengl: None,
1546                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1547                        forced_backend: None,
1548                    });
1549                }
1550            }
1551            ComputeBackend::OpenGl => {
1552                log::info!("ComputeBackend::OpenGl — OpenGL + CPU fallback");
1553                #[cfg(target_os = "linux")]
1554                {
1555                    #[cfg(feature = "opengl")]
1556                    let opengl = match GLProcessorThreaded::with_cache_capacity(
1557                        config.egl_display,
1558                        config.egl_cache_capacity,
1559                    ) {
1560                        Ok(gl) => Some(gl),
1561                        Err(e) => {
1562                            log::warn!("OpenGL requested but failed to initialize: {e:?}");
1563                            None
1564                        }
1565                    };
1566                    return Ok(Self {
1567                        cpu: Some(CPUProcessor::new()),
1568                        g2d: None,
1569                        #[cfg(feature = "opengl")]
1570                        opengl,
1571                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1572                        forced_backend: None,
1573                    }
1574                    .apply_colorimetry_mode(config.colorimetry));
1575                }
1576                #[cfg(any(target_os = "macos", target_os = "ios"))]
1577                {
1578                    #[cfg(feature = "opengl")]
1579                    let opengl = match GLProcessorThreaded::with_cache_capacity(
1580                        config.egl_display,
1581                        config.egl_cache_capacity,
1582                    ) {
1583                        Ok(gl) => Some(gl),
1584                        Err(e) => {
1585                            log::warn!(
1586                                "OpenGL requested on macOS but ANGLE init failed: {e:?} \
1587                                 (install ANGLE via `brew install startergo/angle/angle` \
1588                                 and re-sign the dylibs — see README.md § macOS GPU \
1589                                 Acceleration). Falling back to CPU."
1590                            );
1591                            None
1592                        }
1593                    };
1594                    return Ok(Self {
1595                        cpu: Some(CPUProcessor::new()),
1596                        #[cfg(feature = "opengl")]
1597                        opengl,
1598                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1599                        forced_backend: None,
1600                    }
1601                    .apply_colorimetry_mode(config.colorimetry));
1602                }
1603                #[cfg(target_os = "android")]
1604                {
1605                    #[cfg(feature = "opengl")]
1606                    let opengl = match GLProcessorThreaded::with_cache_capacity(
1607                        config.egl_display,
1608                        config.egl_cache_capacity,
1609                    ) {
1610                        Ok(gl) => Some(gl),
1611                        Err(e) => {
1612                            log::warn!(
1613                                "OpenGL requested but native EGL init failed: {e:?}. \
1614                                 Falling back to CPU."
1615                            );
1616                            None
1617                        }
1618                    };
1619                    return Ok(Self {
1620                        cpu: Some(CPUProcessor::new()),
1621                        #[cfg(feature = "opengl")]
1622                        opengl,
1623                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1624                        forced_backend: None,
1625                    }
1626                    .apply_colorimetry_mode(config.colorimetry));
1627                }
1628                #[cfg(target_os = "windows")]
1629                {
1630                    #[cfg(feature = "opengl")]
1631                    let opengl = match GLProcessorThreaded::with_cache_capacity(
1632                        config.egl_display,
1633                        config.egl_cache_capacity,
1634                    ) {
1635                        Ok(gl) => Some(gl),
1636                        Err(e) => {
1637                            log::warn!(
1638                                "OpenGL requested on Windows but ANGLE/D3D11 init failed: {e:?} \
1639                                 (fetch ANGLE with `scripts/fetch-angle.sh` and set \
1640                                 EDGEFIRST_ANGLE_PATH to the directory containing libEGL.dll + \
1641                                 libGLESv2.dll, or place the two DLLs next to the executable — \
1642                                 see README.md § Windows GPU Acceleration). Falling back to CPU."
1643                            );
1644                            None
1645                        }
1646                    };
1647                    return Ok(Self {
1648                        cpu: Some(CPUProcessor::new()),
1649                        #[cfg(feature = "opengl")]
1650                        opengl,
1651                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1652                        forced_backend: None,
1653                    }
1654                    .apply_colorimetry_mode(config.colorimetry));
1655                }
1656                #[cfg(not(any(
1657                    target_os = "linux",
1658                    target_os = "macos",
1659                    target_os = "ios",
1660                    target_os = "android",
1661                    target_os = "windows"
1662                )))]
1663                {
1664                    log::warn!("OpenGL requested but not available on this platform, using CPU");
1665                    return Ok(Self {
1666                        cpu: Some(CPUProcessor::new()),
1667                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1668                        forced_backend: None,
1669                    });
1670                }
1671            }
1672            ComputeBackend::Auto => { /* fall through to env-var logic below */ }
1673        }
1674
1675        // ── EDGEFIRST_FORCE_BACKEND ──────────────────────────────────
1676        // When set, only the requested backend is initialised and no
1677        // fallback chain is used. Accepted values (case-insensitive):
1678        //   "cpu", "g2d", "opengl"
1679        if let Ok(val) = std::env::var("EDGEFIRST_FORCE_BACKEND") {
1680            let val_lower = val.to_lowercase();
1681            let forced = match val_lower.as_str() {
1682                "cpu" => ForcedBackend::Cpu,
1683                "g2d" => ForcedBackend::G2d,
1684                "opengl" => ForcedBackend::OpenGl,
1685                other => {
1686                    return Err(Error::ForcedBackendUnavailable(format!(
1687                        "unknown EDGEFIRST_FORCE_BACKEND value: {other:?} (expected cpu, g2d, or opengl)"
1688                    )));
1689                }
1690            };
1691
1692            log::info!("EDGEFIRST_FORCE_BACKEND={val} — only initializing {val_lower} backend");
1693
1694            return match forced {
1695                ForcedBackend::Cpu => Ok(Self {
1696                    cpu: Some(CPUProcessor::new()),
1697                    #[cfg(target_os = "linux")]
1698                    g2d: None,
1699                    #[cfg(target_os = "linux")]
1700                    #[cfg(feature = "opengl")]
1701                    opengl: None,
1702                    #[cfg(any(
1703                        target_os = "macos",
1704                        target_os = "ios",
1705                        target_os = "android",
1706                        target_os = "windows"
1707                    ))]
1708                    #[cfg(feature = "opengl")]
1709                    opengl: None,
1710                    convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1711                    forced_backend: Some(ForcedBackend::Cpu),
1712                }),
1713                ForcedBackend::G2d => {
1714                    #[cfg(target_os = "linux")]
1715                    {
1716                        let g2d = G2DProcessor::new().map_err(|e| {
1717                            Error::ForcedBackendUnavailable(format!(
1718                                "g2d forced but failed to initialize: {e:?}"
1719                            ))
1720                        })?;
1721                        Ok(Self {
1722                            cpu: None,
1723                            g2d: Some(g2d),
1724                            #[cfg(feature = "opengl")]
1725                            opengl: None,
1726                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1727                            forced_backend: Some(ForcedBackend::G2d),
1728                        })
1729                    }
1730                    #[cfg(not(target_os = "linux"))]
1731                    {
1732                        Err(Error::ForcedBackendUnavailable(
1733                            "g2d backend is only available on Linux".into(),
1734                        ))
1735                    }
1736                }
1737                ForcedBackend::OpenGl => {
1738                    #[cfg(target_os = "linux")]
1739                    #[cfg(feature = "opengl")]
1740                    {
1741                        let opengl = GLProcessorThreaded::with_cache_capacity(
1742                            config.egl_display,
1743                            config.egl_cache_capacity,
1744                        )
1745                        .map_err(|e| {
1746                            Error::ForcedBackendUnavailable(format!(
1747                                "opengl forced but failed to initialize: {e:?}"
1748                            ))
1749                        })?;
1750                        Ok(Self {
1751                            cpu: None,
1752                            g2d: None,
1753                            opengl: Some(opengl),
1754                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1755                            forced_backend: Some(ForcedBackend::OpenGl),
1756                        }
1757                        .apply_colorimetry_mode(config.colorimetry))
1758                    }
1759                    #[cfg(any(target_os = "macos", target_os = "ios"))]
1760                    #[cfg(feature = "opengl")]
1761                    {
1762                        let opengl = GLProcessorThreaded::with_cache_capacity(
1763                            config.egl_display,
1764                            config.egl_cache_capacity,
1765                        )
1766                        .map_err(|e| {
1767                            Error::ForcedBackendUnavailable(format!(
1768                                "opengl forced on macOS but ANGLE init failed: {e:?}"
1769                            ))
1770                        })?;
1771                        Ok(Self {
1772                            cpu: None,
1773                            opengl: Some(opengl),
1774                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1775                            forced_backend: Some(ForcedBackend::OpenGl),
1776                        }
1777                        .apply_colorimetry_mode(config.colorimetry))
1778                    }
1779                    #[cfg(target_os = "android")]
1780                    #[cfg(feature = "opengl")]
1781                    {
1782                        let opengl = GLProcessorThreaded::with_cache_capacity(
1783                            config.egl_display,
1784                            config.egl_cache_capacity,
1785                        )
1786                        .map_err(|e| {
1787                            Error::ForcedBackendUnavailable(format!(
1788                                "opengl forced but native EGL init failed: {e:?}"
1789                            ))
1790                        })?;
1791                        Ok(Self {
1792                            cpu: None,
1793                            opengl: Some(opengl),
1794                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1795                            forced_backend: Some(ForcedBackend::OpenGl),
1796                        }
1797                        .apply_colorimetry_mode(config.colorimetry))
1798                    }
1799                    #[cfg(target_os = "windows")]
1800                    #[cfg(feature = "opengl")]
1801                    {
1802                        let opengl = GLProcessorThreaded::with_cache_capacity(
1803                            config.egl_display,
1804                            config.egl_cache_capacity,
1805                        )
1806                        .map_err(|e| {
1807                            Error::ForcedBackendUnavailable(format!(
1808                                "opengl forced on Windows but ANGLE/D3D11 init failed: {e:?}"
1809                            ))
1810                        })?;
1811                        Ok(Self {
1812                            cpu: None,
1813                            opengl: Some(opengl),
1814                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1815                            forced_backend: Some(ForcedBackend::OpenGl),
1816                        }
1817                        .apply_colorimetry_mode(config.colorimetry))
1818                    }
1819                    #[cfg(not(all(
1820                        any(
1821                            target_os = "linux",
1822                            target_os = "macos",
1823                            target_os = "ios",
1824                            target_os = "android",
1825                            target_os = "windows"
1826                        ),
1827                        feature = "opengl"
1828                    )))]
1829                    {
1830                        Err(Error::ForcedBackendUnavailable(
1831                            "opengl backend requires Linux, macOS, Android or Windows with the \
1832                             'opengl' feature enabled"
1833                                .into(),
1834                        ))
1835                    }
1836                }
1837            };
1838        }
1839
1840        // ── Existing DISABLE logic (unchanged) ──────────────────────
1841        #[cfg(target_os = "linux")]
1842        let g2d = if std::env::var("EDGEFIRST_DISABLE_G2D")
1843            .map(|x| x != "0" && x.to_lowercase() != "false")
1844            .unwrap_or(false)
1845        {
1846            log::debug!("EDGEFIRST_DISABLE_G2D is set");
1847            None
1848        } else {
1849            match G2DProcessor::new() {
1850                Ok(g2d_converter) => Some(g2d_converter),
1851                Err(err) => {
1852                    log::warn!("Failed to initialize G2D converter: {err:?}");
1853                    None
1854                }
1855            }
1856        };
1857
1858        #[cfg(target_os = "linux")]
1859        #[cfg(feature = "opengl")]
1860        let opengl = if std::env::var("EDGEFIRST_DISABLE_GL")
1861            .map(|x| x != "0" && x.to_lowercase() != "false")
1862            .unwrap_or(false)
1863        {
1864            log::debug!("EDGEFIRST_DISABLE_GL is set");
1865            None
1866        } else {
1867            match GLProcessorThreaded::with_cache_capacity(
1868                config.egl_display,
1869                config.egl_cache_capacity,
1870            ) {
1871                Ok(gl_converter) => Some(gl_converter),
1872                Err(err) => {
1873                    log::warn!("Failed to initialize GL converter: {err:?}");
1874                    None
1875                }
1876            }
1877        };
1878
1879        #[cfg(any(
1880            target_os = "macos",
1881            target_os = "ios",
1882            target_os = "android",
1883            target_os = "windows"
1884        ))]
1885        #[cfg(feature = "opengl")]
1886        let opengl = if std::env::var("EDGEFIRST_DISABLE_GL")
1887            .map(|x| x != "0" && x.to_lowercase() != "false")
1888            .unwrap_or(false)
1889        {
1890            log::debug!("EDGEFIRST_DISABLE_GL is set");
1891            None
1892        } else {
1893            match GLProcessorThreaded::with_cache_capacity(
1894                config.egl_display,
1895                config.egl_cache_capacity,
1896            ) {
1897                Ok(gl_converter) => Some(gl_converter),
1898                Err(err) => {
1899                    log::debug!(
1900                        "GL backend unavailable: {err:?} \
1901                         (CPU fallback will be used)"
1902                    );
1903                    None
1904                }
1905            }
1906        };
1907
1908        let cpu = if std::env::var("EDGEFIRST_DISABLE_CPU")
1909            .map(|x| x != "0" && x.to_lowercase() != "false")
1910            .unwrap_or(false)
1911        {
1912            log::debug!("EDGEFIRST_DISABLE_CPU is set");
1913            None
1914        } else {
1915            Some(CPUProcessor::new())
1916        };
1917        Ok(Self {
1918            cpu,
1919            #[cfg(target_os = "linux")]
1920            g2d,
1921            #[cfg(target_os = "linux")]
1922            #[cfg(feature = "opengl")]
1923            opengl,
1924            #[cfg(any(
1925                target_os = "macos",
1926                target_os = "ios",
1927                target_os = "android",
1928                target_os = "windows"
1929            ))]
1930            #[cfg(feature = "opengl")]
1931            opengl,
1932            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1933            forced_backend: None,
1934        }
1935        .apply_colorimetry_mode(config.colorimetry))
1936    }
1937
1938    /// Apply the configured [`ColorimetryMode`] to whichever backend honours
1939    /// it (currently the Linux GL backend); no-op elsewhere. Constructor
1940    /// plumbing for [`ImageProcessorConfig::colorimetry`].
1941    fn apply_colorimetry_mode(self, _mode: ColorimetryMode) -> Self {
1942        #[cfg(all(
1943            any(
1944                target_os = "linux",
1945                target_os = "macos",
1946                target_os = "ios",
1947                target_os = "android",
1948                target_os = "windows"
1949            ),
1950            feature = "opengl"
1951        ))]
1952        {
1953            let mut me = self;
1954            if let Err(e) = me.set_colorimetry_mode(_mode) {
1955                log::warn!("Failed to apply ColorimetryMode::{_mode:?}: {e:?}");
1956            }
1957            me
1958        }
1959        #[cfg(not(all(
1960            any(
1961                target_os = "linux",
1962                target_os = "macos",
1963                target_os = "ios",
1964                target_os = "android",
1965                target_os = "windows"
1966            ),
1967            feature = "opengl"
1968        )))]
1969        {
1970            let _ = _mode;
1971            self
1972        }
1973    }
1974
1975    /// Sets the colorimetry/performance trade-off (see [`ColorimetryMode`])
1976    /// on the OpenGL backend. No-op if OpenGL is not available. The
1977    /// `EDGEFIRST_COLORIMETRY` environment variable takes precedence — when
1978    /// it is set, this call logs and keeps the env-selected mode.
1979    #[cfg(all(
1980        any(
1981            target_os = "linux",
1982            target_os = "macos",
1983            target_os = "ios",
1984            target_os = "android",
1985            target_os = "windows"
1986        ),
1987        feature = "opengl"
1988    ))]
1989    pub fn set_colorimetry_mode(&mut self, mode: ColorimetryMode) -> Result<()> {
1990        if let Some(ref mut gl) = self.opengl {
1991            gl.set_colorimetry_mode(mode)?;
1992        }
1993        Ok(())
1994    }
1995
1996    /// Sets the interpolation mode for int8 proto textures on the OpenGL
1997    /// backend. No-op if OpenGL is not available.
1998    #[cfg(all(
1999        any(
2000            target_os = "linux",
2001            target_os = "macos",
2002            target_os = "ios",
2003            target_os = "android",
2004            target_os = "windows"
2005        ),
2006        feature = "opengl"
2007    ))]
2008    pub fn set_int8_interpolation_mode(&mut self, mode: Int8InterpolationMode) -> Result<()> {
2009        if let Some(ref mut gl) = self.opengl {
2010            gl.set_int8_interpolation_mode(mode)?;
2011        }
2012        Ok(())
2013    }
2014
2015    /// Allocate an image tensor from a declarative
2016    /// [`ImageDesc`](edgefirst_tensor::ImageDesc) request — the
2017    /// full-featured variant of [`create_image`](Self::create_image).
2018    ///
2019    /// Without a compression request this is exactly `create_image` (the
2020    /// processor's memory negotiation applies). With one, the allocation
2021    /// rides the tensor desc path un-negotiated: the layout decision
2022    /// belongs to the platform allocator, and the request's guards and
2023    /// fallback counting live there (see
2024    /// [`Tensor::image_desc`](edgefirst_tensor::Tensor::image_desc)).
2025    pub fn create_image_desc(&self, desc: &edgefirst_tensor::ImageDesc) -> Result<TensorDyn> {
2026        if desc.compression().is_none() {
2027            return self.create_image(
2028                desc.width(),
2029                desc.height(),
2030                desc.format(),
2031                desc.dtype(),
2032                desc.memory(),
2033                desc.access(),
2034            );
2035        }
2036        Ok(TensorDyn::image_desc(desc)?)
2037    }
2038
2039    /// Create a [`TensorDyn`] image with the best available memory backend.
2040    ///
2041    /// Priority: DMA-buf → float PBO (F16/F32) → u8/i8 PBO → system memory.
2042    ///
2043    /// Use this method instead of [`TensorDyn::image()`] when the tensor will
2044    /// be used with [`ImageProcessor::convert()`]. It selects the optimal
2045    /// memory backing (including PBO for GPU zero-copy) which direct
2046    /// allocation cannot achieve.
2047    ///
2048    /// This method is on [`ImageProcessor`] rather than [`ImageProcessorTrait`]
2049    /// because optimal allocation requires knowledge of the active compute
2050    /// backends (e.g. the GL context handle for PBO allocation). Individual
2051    /// backend implementations ([`CPUProcessor`], etc.) do not have this
2052    /// cross-backend visibility.
2053    ///
2054    /// **Float dtype behaviour:** when `dtype` is `F16` or `F32` and
2055    /// [`supported_render_dtypes`] reports the GPU supports that type,
2056    /// `memory: None` auto-selects a float PBO (Linux) or IOSurface (macOS
2057    /// F16 only). If GPU float support is absent the allocation falls through
2058    /// to `TensorMemory::Mem`; [`convert`] then uses the CPU path.
2059    /// Passing `memory: Some(TensorMemory::DmaBuf)` with `dtype: F32` always
2060    /// returns `Error::NotSupported` — no 32-bit-float DRM fourcc exists.
2061    ///
2062    /// [`supported_render_dtypes`]: Self::supported_render_dtypes
2063    /// [`convert`]: ImageProcessorTrait::convert
2064    ///
2065    /// # Arguments
2066    ///
2067    /// * `width` - Image width in pixels
2068    /// * `height` - Image height in pixels
2069    /// * `format` - Pixel format
2070    /// * `dtype` - Element data type (e.g. `DType::U8`, `DType::F16`, `DType::F32`)
2071    /// * `memory` - Optional memory type override; when `None`, the best
2072    ///   available backend is selected automatically.
2073    /// * `access` - Declares the CPU involvement you plan on. Use
2074    ///   [`CpuAccess::None`](edgefirst_tensor::CpuAccess) for a destination
2075    ///   that only hardware reads (an NPU input), and `ReadWrite` when you
2076    ///   will map it. Hardware access is always implied. Mapping a tensor
2077    ///   beyond its declaration is counted by
2078    ///   [`unplanned_cpu_access_count`](edgefirst_tensor::unplanned_cpu_access_count).
2079    ///
2080    /// # Returns
2081    ///
2082    /// A [`TensorDyn`] backed by the highest-performance memory type
2083    /// available on this system.
2084    ///
2085    /// # Examples
2086    ///
2087    /// ```rust,no_run
2088    /// # use edgefirst_image::ImageProcessor;
2089    /// # use edgefirst_tensor::{CpuAccess, DType, PixelFormat};
2090    /// # fn main() -> Result<(), edgefirst_image::Error> {
2091    /// let processor = ImageProcessor::new()?;
2092    ///
2093    /// // Model input the NPU reads directly — no CPU mapping planned.
2094    /// let input = processor.create_image(
2095    ///     640, 640, PixelFormat::Rgb, DType::U8, None, CpuAccess::None,
2096    /// )?;
2097    /// # let _ = input;
2098    /// # Ok(())
2099    /// # }
2100    /// ```
2101    ///
2102    /// # Pitch alignment for DMA-backed allocations
2103    ///
2104    /// DMA-BUF imports into the GL backend (Mali Valhall on i.MX 95
2105    /// specifically) require every row pitch to be a multiple of
2106    /// [`GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES`] (currently 64). When this
2107    /// method lands on `TensorMemory::DmaBuf`, the underlying allocation is
2108    /// silently padded so the row stride satisfies that requirement.
2109    ///
2110    /// **The user-requested `width` is preserved** — `tensor.width()`
2111    /// returns the same value you passed in. The padding is carried by
2112    /// [`TensorDyn::row_stride`] / `effective_row_stride()`, which the
2113    /// GL backend reads when importing the buffer as an EGLImage.
2114    /// Callers that compute byte offsets from the tensor must use the
2115    /// stride, not `width × bytes_per_pixel`; the CPU mapping spans the
2116    /// full `stride × height` bytes.
2117    ///
2118    /// Pre-aligned widths (640, 1280, 1920, 3008, 3840 …) allocate
2119    /// exactly `width × bpp × height` bytes with no padding. PBO and
2120    /// Mem fallbacks never pad — they don't go through EGLImage import.
2121    ///
2122    /// See also [`align_width_for_gpu_pitch`] for an advisory helper
2123    /// that external callers (GStreamer plugins, video pipelines) can
2124    /// use to size their own DMA-BUFs for GL compatibility.
2125    ///
2126    /// # Errors
2127    ///
2128    /// Returns an error if all allocation strategies fail.
2129    pub fn create_image(
2130        &self,
2131        width: usize,
2132        height: usize,
2133        format: PixelFormat,
2134        dtype: DType,
2135        memory: Option<TensorMemory>,
2136        access: edgefirst_tensor::CpuAccess,
2137    ) -> Result<TensorDyn> {
2138        // Compute the GPU-aligned row stride in bytes for this image.
2139        // `None` means either the format has no defined primary-plane bpp
2140        // (unknown future layout) or the stride calculation would overflow
2141        // — in both cases we fall back to the natural layout via the plain
2142        // `TensorDyn::image` constructor, and the slow-path warning inside
2143        // `draw_*_masks` will fire if the subsequent GL import fails.
2144        //
2145        // DMA allocation is Linux-only (see `TensorMemory::DmaBuf` cfg gate),
2146        // so both the stride computation and the helper closure are gated
2147        // accordingly — the callers below are already Linux-only.
2148        #[cfg(target_os = "linux")]
2149        let dma_stride_bytes: Option<usize> = primary_plane_bpp(format, dtype.size())
2150            .and_then(|bpp| width.checked_mul(bpp))
2151            .and_then(align_pitch_bytes_to_gpu_alignment);
2152
2153        // Helper: allocate a DMA image, using the padded-stride constructor
2154        // when the computed stride exceeds the natural pitch, otherwise the
2155        // plain constructor (byte-identical result in the common case).
2156        #[cfg(target_os = "linux")]
2157        let try_dma = || -> Result<TensorDyn> {
2158            // Stride padding is only meaningful for packed pixel layouts
2159            // (RGBA8, BGRA8, RGB888, Grey) — the formats the GL backend
2160            // renders into. Semi-planar (NV12, NV16) and planar (PlanarRgb,
2161            // PlanarRgba) tensors go through `TensorDyn::image(...)` with
2162            // their natural layout; they're imported from camera capture
2163            // via `from_fd` far more often than allocated here, and
2164            // `Tensor::image_with_stride` explicitly rejects them.
2165            let packed = format.layout() == edgefirst_tensor::PixelLayout::Packed;
2166            match dma_stride_bytes {
2167                Some(stride)
2168                    if packed
2169                        && primary_plane_bpp(format, dtype.size())
2170                            .and_then(|bpp| width.checked_mul(bpp))
2171                            .is_some_and(|natural| stride > natural) =>
2172                {
2173                    log::debug!(
2174                        "create_image: padding row stride for {format:?} {width}x{height} \
2175                         from natural pitch to {stride} bytes for GPU alignment"
2176                    );
2177                    Ok(TensorDyn::image_with_stride(
2178                        width,
2179                        height,
2180                        format,
2181                        dtype,
2182                        stride,
2183                        Some(edgefirst_tensor::TensorMemory::DmaBuf),
2184                        access,
2185                    )?)
2186                }
2187                _ => Ok(TensorDyn::image(
2188                    width,
2189                    height,
2190                    format,
2191                    dtype,
2192                    Some(edgefirst_tensor::TensorMemory::DmaBuf),
2193                    access,
2194                )?),
2195            }
2196        };
2197
2198        // If an explicit memory type is requested, honour it directly.
2199        // On Linux, `TensorMemory::DmaBuf` gets the padded-stride treatment;
2200        // other memory types take the user-requested width verbatim.
2201        // On macOS, `TensorMemory::DmaBuf` dispatches through `TensorDyn::image`
2202        // which selects the IOSurface allocation path (FourCC-formatted)
2203        // for image-mappable formats, or falls back to SHM/Mem otherwise.
2204        match memory {
2205            #[cfg(target_os = "linux")]
2206            Some(TensorMemory::DmaBuf) => {
2207                // F32 has no 32-bit-float DRM fourcc; callers must use PBO instead.
2208                if dtype == DType::F32 {
2209                    return Err(Error::NotSupported(
2210                        "F32 has no 32-bit-float DRM format for DMA-BUF; \
2211                         use TensorMemory::Pbo for F32"
2212                            .to_string(),
2213                    ));
2214                }
2215                return try_dma();
2216            }
2217            Some(mem) => {
2218                return Ok(TensorDyn::image(
2219                    width,
2220                    height,
2221                    format,
2222                    dtype,
2223                    Some(mem),
2224                    access,
2225                )?);
2226            }
2227            None => {}
2228        }
2229
2230        // macOS: when the GL backend is active with the IOSurface
2231        // transfer path, prefer Dma (IOSurface on Apple, AHardwareBuffer
2232        // on Android) for zero-copy import. Formats without a zero-copy
2233        // mapping now ERROR under explicit Dma (the explicit-Dma
2234        // contract), so auto-select catches that error here and falls
2235        // back to host storage — loudly, via the debug log below.
2236        // Windows is not listed: no zero-copy backing exists there yet, so
2237        // create_image goes straight to the PBO arms below.
2238        #[cfg(any(target_os = "macos", target_os = "ios", target_os = "android"))]
2239        #[cfg(feature = "opengl")]
2240        if let Some(gl) = self.opengl.as_ref() {
2241            let _ = gl; // probe_transfer_backend lives behind the platform trait
2242            match TensorDyn::image(
2243                width,
2244                height,
2245                format,
2246                dtype,
2247                Some(edgefirst_tensor::TensorMemory::DmaBuf),
2248                access,
2249            ) {
2250                Ok(img) => return Ok(img),
2251                Err(e) => {
2252                    // Falling back to a non-zero-copy destination is a real
2253                    // perf cliff — never do it silently (on-device triage
2254                    // starts from this line).
2255                    log::debug!(
2256                        "create_image: zero-copy Dma allocation declined \
2257                         ({format:?}/{dtype:?} {width}x{height}): {e:?}; using fallback storage"
2258                    );
2259                }
2260            }
2261        }
2262
2263        // Try DMA first on Linux — skip only when GL has explicitly selected PBO
2264        // as the preferred transfer path (PBO is better than DMA in that case).
2265        #[cfg(target_os = "linux")]
2266        {
2267            #[cfg(feature = "opengl")]
2268            let gl_uses_pbo = self
2269                .opengl
2270                .as_ref()
2271                .is_some_and(|gl| gl.transfer_backend() == opengl_headless::TransferBackend::Pbo);
2272            #[cfg(not(feature = "opengl"))]
2273            let gl_uses_pbo = false;
2274
2275            if !gl_uses_pbo {
2276                if let Ok(img) = try_dma() {
2277                    return Ok(img);
2278                }
2279            }
2280        }
2281
2282        // Try PBO (if GL available).
2283        // PBO buffers are u8-sized; the int8 shader emulates i8 output via
2284        // XOR 0x80 on the same underlying buffer, so both U8 and I8 work.
2285        #[cfg(any(target_os = "linux", target_os = "windows"))]
2286        #[cfg(feature = "opengl")]
2287        if dtype.size() == 1 {
2288            if let Some(gl) = &self.opengl {
2289                match gl.create_pbo_image(width, height, format) {
2290                    Ok(t) => {
2291                        if dtype == DType::I8 {
2292                            // SAFETY: Tensor<u8> and Tensor<i8> are layout-
2293                            // identical (same element size, no T-dependent
2294                            // drop glue). The int8 shader applies XOR 0x80
2295                            // on the same PBO buffer. Same rationale as
2296                            // gl::processor::tensor_i8_as_u8_mut.
2297                            // Invariant: PBO tensors never have chroma
2298                            // (create_pbo_image → Tensor::wrap sets it None).
2299                            debug_assert!(
2300                                t.chroma().is_none(),
2301                                "PBO i8 transmute requires chroma == None"
2302                            );
2303                            let t_i8: Tensor<i8> = unsafe { std::mem::transmute(t) };
2304                            return Ok(TensorDyn::from(t_i8));
2305                        }
2306                        return Ok(TensorDyn::from(t));
2307                    }
2308                    Err(e) => log::debug!("PBO image creation failed, falling back to Mem: {e:?}"),
2309                }
2310            }
2311        }
2312
2313        // Try float PBO when the GPU backend reports support for this dtype.
2314        // Falls through to Mem on error (same policy as u8 PBO above).
2315        #[cfg(any(target_os = "linux", target_os = "windows"))]
2316        #[cfg(feature = "opengl")]
2317        if float_pbo_eligible(dtype, self.supported_render_dtypes()) {
2318            if let Some(gl) = &self.opengl {
2319                match gl.create_pbo_image_dtype(width, height, format, dtype) {
2320                    Ok(t) => return Ok(t),
2321                    Err(e) => {
2322                        log::debug!(
2323                            "Float PBO image creation failed for {dtype:?}, \
2324                             falling back to Mem: {e:?}"
2325                        );
2326                    }
2327                }
2328            }
2329        }
2330
2331        // Fallback to Mem
2332        Ok(TensorDyn::image(
2333            width,
2334            height,
2335            format,
2336            dtype,
2337            Some(edgefirst_tensor::TensorMemory::Mem),
2338            access,
2339        )?)
2340    }
2341
2342    /// Import an external DMA-BUF image.
2343    ///
2344    /// Each [`PlaneDescriptor`] owns an already-duped fd; this method
2345    /// consumes the descriptors and takes ownership of those fds (whether
2346    /// the call succeeds or fails).
2347    ///
2348    /// The caller must ensure the DMA-BUF allocation is large enough for the
2349    /// specified width, height, format, and any stride/offset on the plane
2350    /// descriptors. No buffer-size validation is performed; an undersized
2351    /// buffer may cause GPU faults or EGL import failure.
2352    ///
2353    /// # Arguments
2354    ///
2355    /// * `image` - Plane descriptor for the primary (or only) plane
2356    /// * `chroma` - Optional plane descriptor for the UV chroma plane
2357    ///   (required for multiplane NV12)
2358    /// * `width` - Image width in pixels
2359    /// * `height` - Image height in pixels
2360    /// * `format` - Pixel format of the buffer
2361    /// * `dtype` - Element data type (e.g. `DType::U8`)
2362    ///
2363    /// # Returns
2364    ///
2365    /// A `TensorDyn` configured as an image.
2366    ///
2367    /// # Errors
2368    ///
2369    /// * [`Error::NotSupported`] if `chroma` is `Some` for a non-semi-planar
2370    ///   format, or multiplane NV16 (not yet supported), or the fd is not
2371    ///   DMA-backed
2372    /// * [`Error::InvalidShape`] if NV12 height is odd
2373    ///
2374    /// # Platform
2375    ///
2376    /// Linux only.
2377    ///
2378    /// # Examples
2379    ///
2380    /// ```rust,ignore
2381    /// use edgefirst_tensor::PlaneDescriptor;
2382    ///
2383    /// // Single-plane RGBA
2384    /// let pd = PlaneDescriptor::new(fd.as_fd())?;
2385    /// let src = proc.import_image(pd, None, 1920, 1080, PixelFormat::Rgba, DType::U8, None)?;
2386    ///
2387    /// // Multi-plane NV12 with stride
2388    /// let y_pd = PlaneDescriptor::new(y_fd.as_fd())?.with_stride(2048);
2389    /// let uv_pd = PlaneDescriptor::new(uv_fd.as_fd())?.with_stride(2048);
2390    /// let src = proc.import_image(y_pd, Some(uv_pd), 1920, 1080,
2391    ///                             PixelFormat::Nv12, DType::U8, None)?;
2392    /// ```
2393    // Import inherently needs plane(s) + geometry + format + dtype + colorimetry;
2394    // a params struct would obscure more than it clarifies here.
2395    #[allow(clippy::too_many_arguments)]
2396    #[cfg(target_os = "linux")]
2397    pub fn import_image(
2398        &self,
2399        image: edgefirst_tensor::PlaneDescriptor,
2400        chroma: Option<edgefirst_tensor::PlaneDescriptor>,
2401        width: usize,
2402        height: usize,
2403        format: PixelFormat,
2404        dtype: DType,
2405        colorimetry: Option<edgefirst_tensor::Colorimetry>,
2406    ) -> Result<TensorDyn> {
2407        use edgefirst_tensor::{Tensor, TensorMemory};
2408
2409        // Capture stride/offset from descriptors before consuming them
2410        let image_stride = image.stride();
2411        let image_offset = image.offset();
2412        let chroma_stride = chroma.as_ref().and_then(|c| c.stride());
2413        let chroma_offset = chroma.as_ref().and_then(|c| c.offset());
2414
2415        if let Some(chroma_pd) = chroma {
2416            // ── Multiplane path ──────────────────────────────────────
2417            // Multiplane tensors are backed by Tensor<u8> (or transmuted to
2418            // Tensor<i8>). Reject other dtypes to avoid silently returning a
2419            // tensor with the wrong element type.
2420            if dtype != DType::U8 && dtype != DType::I8 {
2421                return Err(Error::NotSupported(format!(
2422                    "multiplane import only supports U8/I8, got {dtype:?}"
2423                )));
2424            }
2425            if format.layout() != PixelLayout::SemiPlanar {
2426                return Err(Error::NotSupported(format!(
2427                    "import_image with chroma requires a semi-planar format, got {format:?}"
2428                )));
2429            }
2430
2431            let chroma_h = match format {
2432                PixelFormat::Nv12 => {
2433                    // NV12 (4:2:0): ceil(H/2) chroma rows — odd heights are valid.
2434                    height.div_ceil(2)
2435                }
2436                // NV16 multiplane will be supported in a future release;
2437                // the GL backend currently only handles NV12 plane1 attributes.
2438                PixelFormat::Nv16 => {
2439                    return Err(Error::NotSupported(
2440                        "multiplane NV16 is not yet supported; use contiguous NV16 instead".into(),
2441                    ))
2442                }
2443                _ => {
2444                    return Err(Error::NotSupported(format!(
2445                        "unsupported semi-planar format: {format:?}"
2446                    )))
2447                }
2448            };
2449
2450            let luma = Tensor::<u8>::from_fd(image.into_fd(), &[height, width], Some("luma"))?;
2451            if luma.memory() != TensorMemory::DmaBuf {
2452                return Err(Error::NotSupported(format!(
2453                    "luma fd must be DMA-backed, got {:?}",
2454                    luma.memory()
2455                )));
2456            }
2457
2458            let chroma_tensor =
2459                Tensor::<u8>::from_fd(chroma_pd.into_fd(), &[chroma_h, width], Some("chroma"))?;
2460            if chroma_tensor.memory() != TensorMemory::DmaBuf {
2461                return Err(Error::NotSupported(format!(
2462                    "chroma fd must be DMA-backed, got {:?}",
2463                    chroma_tensor.memory()
2464                )));
2465            }
2466
2467            // from_planes creates the combined tensor with format set,
2468            // preserving luma's row_stride (currently None since luma was raw).
2469            let mut tensor = Tensor::<u8>::from_planes(luma, chroma_tensor, format)?;
2470
2471            // Apply stride/offset to the combined tensor (luma plane)
2472            if let Some(s) = image_stride {
2473                tensor.set_row_stride(s)?;
2474            }
2475            if let Some(o) = image_offset {
2476                tensor.set_plane_offset(o);
2477            }
2478
2479            // Apply stride/offset to the chroma sub-tensor.
2480            // The chroma tensor is a raw 2D [chroma_h, width] tensor without
2481            // format metadata, so we validate stride manually rather than
2482            // using set_row_stride (which requires format).
2483            if let Some(chroma_ref) = tensor.chroma_mut() {
2484                if let Some(s) = chroma_stride {
2485                    if s < width {
2486                        return Err(Error::InvalidShape(format!(
2487                            "chroma stride {s} < minimum {width} for {format:?}"
2488                        )));
2489                    }
2490                    chroma_ref.set_row_stride_unchecked(s);
2491                }
2492                if let Some(o) = chroma_offset {
2493                    chroma_ref.set_plane_offset(o);
2494                }
2495            }
2496
2497            if dtype == DType::I8 {
2498                // SAFETY: Tensor<u8> and Tensor<i8> have identical layout because
2499                // the struct contains only type-erased storage (OwnedFd, shape, name),
2500                // no inline T values. This assertion catches layout drift at compile time.
2501                const {
2502                    assert!(std::mem::size_of::<Tensor<u8>>() == std::mem::size_of::<Tensor<i8>>());
2503                    assert!(
2504                        std::mem::align_of::<Tensor<u8>>() == std::mem::align_of::<Tensor<i8>>()
2505                    );
2506                }
2507                let tensor_i8: Tensor<i8> = unsafe { std::mem::transmute(tensor) };
2508                let mut dyn_tensor = TensorDyn::from(tensor_i8);
2509                dyn_tensor.set_colorimetry(colorimetry);
2510                return Ok(dyn_tensor);
2511            }
2512            let mut dyn_tensor = TensorDyn::from(tensor);
2513            dyn_tensor.set_colorimetry(colorimetry);
2514            Ok(dyn_tensor)
2515        } else {
2516            // ── Single-plane path ────────────────────────────────────
2517            // Canonical shape (Packed [H,W,C] / Planar [C,H,W] / SemiPlanar
2518            // [total_h, W]); `allocation_shape` supports NV12/NV16/NV24 (the old
2519            // hand-rolled match erroneously rejected NV24).
2520            let shape = format.allocation_shape(width, height).ok_or_else(|| {
2521                Error::NotSupported(format!(
2522                    "unsupported pixel format for import_image: {format:?}"
2523                ))
2524            })?;
2525            let tensor = TensorDyn::from_fd(image.into_fd(), &shape, dtype, None)?;
2526            if tensor.memory() != TensorMemory::DmaBuf {
2527                return Err(Error::NotSupported(format!(
2528                    "import_image requires DMA-backed fd, got {:?}",
2529                    tensor.memory()
2530                )));
2531            }
2532            let mut tensor = tensor.with_format(format)?;
2533            if let Some(s) = image_stride {
2534                tensor.set_row_stride(s)?;
2535            }
2536            if let Some(o) = image_offset {
2537                tensor.set_plane_offset(o);
2538            }
2539            tensor.set_colorimetry(colorimetry);
2540            Ok(tensor)
2541        }
2542    }
2543
2544    /// Decode model outputs and draw segmentation masks onto `dst`.
2545    ///
2546    /// The processor decodes via the provided
2547    /// [`Decoder`](edgefirst_decoder::Decoder), selects the optimal rendering
2548    /// path (hybrid CPU+GL or fused GPU), and composites masks onto `dst`.
2549    ///
2550    /// Returns the detected bounding boxes.
2551    ///
2552    /// **Requires the `decode` feature**, which is off by default because it
2553    /// is the only thing in this crate that needs `edgefirst-decoder`. This
2554    /// method sequences a decode and a draw; both halves are available
2555    /// unconditionally and are equally fused, so without the feature write:
2556    ///
2557    /// ```ignore
2558    /// let mut boxes = Vec::new();
2559    /// match decoder.decode_proto(outputs, &mut boxes)? {
2560    ///     Some(protos) => proc.draw_proto_masks(dst, &boxes, &protos, overlay)?,
2561    ///     None => {
2562    ///         let mut masks = Vec::new();
2563    ///         decoder.decode(outputs, &mut boxes, &mut masks)?;
2564    ///         proc.draw_decoded_masks(dst, &boxes, &masks, overlay)?;
2565    ///     }
2566    /// }
2567    /// ```
2568    #[cfg(feature = "decode")]
2569    pub fn draw_masks(
2570        &mut self,
2571        decoder: &edgefirst_decoder::Decoder,
2572        outputs: &[&TensorDyn],
2573        dst: &mut TensorDyn,
2574        overlay: MaskOverlay<'_>,
2575    ) -> Result<Vec<DetectBox>> {
2576        let mut output_boxes = Vec::with_capacity(100);
2577
2578        // Try proto path first (fused rendering without materializing masks)
2579        let proto_result = decoder
2580            .decode_proto(outputs, &mut output_boxes)
2581            .map_err(|e| Error::Internal(format!("decode_proto: {e:#?}")))?;
2582
2583        if let Some(proto_data) = proto_result {
2584            self.draw_proto_masks(dst, &output_boxes, &proto_data, overlay)?;
2585        } else {
2586            // Detection-only or unsupported model: full decode + render
2587            let mut output_masks = Vec::with_capacity(100);
2588            decoder
2589                .decode(outputs, &mut output_boxes, &mut output_masks)
2590                .map_err(|e| Error::Internal(format!("decode: {e:#?}")))?;
2591            self.draw_decoded_masks(dst, &output_boxes, &output_masks, overlay)?;
2592        }
2593        Ok(output_boxes)
2594    }
2595
2596    /// Decode tracked model outputs and draw segmentation masks onto `dst`.
2597    ///
2598    /// Like [`draw_masks`](Self::draw_masks) but integrates a tracker for
2599    /// maintaining object identities across frames. The tracker runs after
2600    /// NMS but before mask extraction.
2601    ///
2602    /// Returns detected boxes and track info.
2603    #[cfg(feature = "tracker")]
2604    pub fn draw_masks_tracked<TR: edgefirst_tracker::Tracker<DetectBox>>(
2605        &mut self,
2606        decoder: &edgefirst_decoder::Decoder,
2607        tracker: &mut TR,
2608        timestamp: u64,
2609        outputs: &[&TensorDyn],
2610        dst: &mut TensorDyn,
2611        overlay: MaskOverlay<'_>,
2612    ) -> Result<(Vec<DetectBox>, Vec<edgefirst_tracker::TrackInfo>)> {
2613        let mut output_boxes = Vec::with_capacity(100);
2614        let mut output_tracks = Vec::new();
2615
2616        let proto_result = decoder
2617            .decode_proto_tracked(
2618                tracker,
2619                timestamp,
2620                outputs,
2621                &mut output_boxes,
2622                &mut output_tracks,
2623            )
2624            .map_err(|e| Error::Internal(format!("decode_proto_tracked: {e:#?}")))?;
2625
2626        if let Some(proto_data) = proto_result {
2627            self.draw_proto_masks(dst, &output_boxes, &proto_data, overlay)?;
2628        } else {
2629            // Note: decode_proto_tracked returns None for detection-only/ModelPack
2630            // models WITHOUT calling the tracker. The else branch below is the
2631            // first (and only) tracker call for those model types.
2632            let mut output_masks = Vec::with_capacity(100);
2633            decoder
2634                .decode_tracked(
2635                    tracker,
2636                    timestamp,
2637                    outputs,
2638                    &mut output_boxes,
2639                    &mut output_masks,
2640                    &mut output_tracks,
2641                )
2642                .map_err(|e| Error::Internal(format!("decode_tracked: {e:#?}")))?;
2643            self.draw_decoded_masks(dst, &output_boxes, &output_masks, overlay)?;
2644        }
2645        Ok((output_boxes, output_tracks))
2646    }
2647
2648    /// Materialize per-instance segmentation masks from raw prototype data.
2649    ///
2650    /// Computes `mask_coeff @ protos` with sigmoid activation for each detection,
2651    /// producing compact masks at prototype resolution (e.g., 160×160 crops).
2652    /// Mask values are continuous sigmoid confidence outputs quantized to u8
2653    /// (0 = background, 255 = full confidence), NOT binary thresholded.
2654    ///
2655    /// The returned [`Vec<Segmentation>`] can be:
2656    /// - Inspected or exported for analytics, IoU computation, etc.
2657    /// - Passed directly to [`ImageProcessorTrait::draw_decoded_masks`] for
2658    ///   GPU-interpolated rendering.
2659    ///
2660    /// # Performance Note
2661    ///
2662    /// Calling `materialize_masks` + `draw_decoded_masks` separately prevents
2663    /// the HAL from using its internal fused optimization path. For render-only
2664    /// use cases, prefer [`ImageProcessorTrait::draw_proto_masks`] which selects
2665    /// the fastest path automatically (currently 1.6×–27× faster on tested
2666    /// platforms). Use this method when you need access to the intermediate masks.
2667    ///
2668    /// # Errors
2669    ///
2670    /// Returns [`Error::NoConverter`] if the CPU backend is not available.
2671    pub fn materialize_masks(
2672        &mut self,
2673        detect: &[DetectBox],
2674        proto_data: &ProtoData,
2675        letterbox: Option<[f32; 4]>,
2676        resolution: MaskResolution,
2677    ) -> Result<Vec<Segmentation>> {
2678        let cpu = self.cpu.as_mut().ok_or(Error::NoConverter)?;
2679        match resolution {
2680            MaskResolution::Proto => cpu.materialize_segmentations(detect, proto_data, letterbox),
2681            MaskResolution::Scaled { width, height } => {
2682                cpu.materialize_scaled_segmentations(detect, proto_data, letterbox, width, height)
2683            }
2684        }
2685    }
2686}
2687
2688impl ImageProcessorTrait for ImageProcessor {
2689    /// Converts the source image to the destination image format and size. The
2690    /// image is cropped first, then flipped, then rotated
2691    ///
2692    /// Prefer hardware accelerators when available, falling back to CPU if
2693    /// necessary.
2694    fn convert(
2695        &mut self,
2696        src: &TensorDyn,
2697        dst: &mut TensorDyn,
2698        rotation: Rotation,
2699        flip: Flip,
2700        crop: Crop,
2701    ) -> Result<()> {
2702        let start = Instant::now();
2703        let src_fmt = src.format();
2704        let dst_fmt = dst.format();
2705        let _span = tracing::trace_span!(
2706            "image.convert",
2707            ?src_fmt,
2708            ?dst_fmt,
2709            src_memory = ?src.memory(),
2710            dst_memory = ?dst.memory(),
2711            ?rotation,
2712            ?flip,
2713        )
2714        .entered();
2715        log::trace!(
2716            "convert: {src_fmt:?}({:?}/{:?}) → {dst_fmt:?}({:?}/{:?}), \
2717             rotation={rotation:?}, flip={flip:?}, backend={:?}",
2718            src.dtype(),
2719            src.memory(),
2720            dst.dtype(),
2721            dst.memory(),
2722            self.forced_backend,
2723        );
2724
2725        // ── Forced backend: no fallback chain ────────────────────────
2726        if let Some(forced) = self.forced_backend {
2727            return match forced {
2728                ForcedBackend::Cpu => {
2729                    if let Some(cpu) = self.cpu.as_mut() {
2730                        let r = cpu.convert(src, dst, rotation, flip, crop);
2731                        log::trace!(
2732                            "convert: forced=cpu result={} ({:?})",
2733                            if r.is_ok() { "ok" } else { "err" },
2734                            start.elapsed()
2735                        );
2736                        return r;
2737                    }
2738                    Err(Error::ForcedBackendUnavailable("cpu".into()))
2739                }
2740                ForcedBackend::G2d => {
2741                    #[cfg(target_os = "linux")]
2742                    if let Some(g2d) = self.g2d.as_mut() {
2743                        let r = g2d.convert(src, dst, rotation, flip, crop);
2744                        log::trace!(
2745                            "convert: forced=g2d result={} ({:?})",
2746                            if r.is_ok() { "ok" } else { "err" },
2747                            start.elapsed()
2748                        );
2749                        return r;
2750                    }
2751                    Err(Error::ForcedBackendUnavailable("g2d".into()))
2752                }
2753                ForcedBackend::OpenGl => {
2754                    #[cfg(any(
2755                        target_os = "linux",
2756                        target_os = "macos",
2757                        target_os = "ios",
2758                        target_os = "android",
2759                        target_os = "windows"
2760                    ))]
2761                    #[cfg(feature = "opengl")]
2762                    if let Some(opengl) = self.opengl.as_mut() {
2763                        let r = opengl.convert(src, dst, rotation, flip, crop);
2764                        log::trace!(
2765                            "convert: forced=opengl result={} ({:?})",
2766                            if r.is_ok() { "ok" } else { "err" },
2767                            start.elapsed()
2768                        );
2769                        return r;
2770                    }
2771                    Err(Error::ForcedBackendUnavailable("opengl".into()))
2772                }
2773            };
2774        }
2775
2776        // ── Auto fallback chain: OpenGL → G2D → CPU ──────────────────
2777        #[cfg(any(
2778            target_os = "linux",
2779            target_os = "macos",
2780            target_os = "ios",
2781            target_os = "android",
2782            target_os = "windows"
2783        ))]
2784        #[cfg(feature = "opengl")]
2785        if let Some(opengl) = self.opengl.as_mut() {
2786            match opengl.convert(src, dst, rotation, flip, crop) {
2787                Ok(_) => {
2788                    log::trace!(
2789                        "convert: auto selected=opengl for {src_fmt:?}→{dst_fmt:?} ({:?})",
2790                        start.elapsed()
2791                    );
2792                    return Ok(());
2793                }
2794                Err(e) => {
2795                    self.convert_fallbacks
2796                        .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2797                    log::debug!(
2798                        "convert: auto opengl declined {src_fmt:?}@{:?}→{dst_fmt:?}@{:?}, \
2799                         falling back toward G2D/CPU: {e}",
2800                        src.memory(),
2801                        dst.memory(),
2802                    );
2803                }
2804            }
2805        }
2806
2807        #[cfg(target_os = "linux")]
2808        if let Some(g2d) = self.g2d.as_mut() {
2809            // G2D is matrix-only (no range control, no BT.2020). For any
2810            // conversion with a YUV side, resolve that side's colorimetry and
2811            // skip G2D entirely when it cannot be expressed (full-range YUV or
2812            // BT.2020), letting the chain fall through to GL/CPU which honour
2813            // range and BT.2020. YUV→RGB uses the source colorimetry; RGB→YUV
2814            // uses the destination. RGB→RGB has no YUV side and is unaffected.
2815            let src_is_yuv = src.format().is_some_and(|f| f.is_yuv());
2816            let dst_is_yuv = dst.format().is_some_and(|f| f.is_yuv());
2817            let g2d_eligible = if src_is_yuv || dst_is_yuv {
2818                let cm = if src_is_yuv {
2819                    crate::colorimetry::effective_colorimetry(src)
2820                } else {
2821                    crate::colorimetry::effective_colorimetry(dst)
2822                };
2823                crate::g2d::g2d_can_handle(&cm, true)
2824            } else {
2825                true
2826            };
2827            if !g2d_eligible {
2828                log::trace!(
2829                    "convert: auto g2d skipped {src_fmt:?}→{dst_fmt:?} \
2830                     (colorimetry not expressible: full-range/BT.2020)"
2831                );
2832            } else {
2833                match g2d.convert(src, dst, rotation, flip, crop) {
2834                    Ok(_) => {
2835                        log::trace!(
2836                            "convert: auto selected=g2d for {src_fmt:?}→{dst_fmt:?} ({:?})",
2837                            start.elapsed()
2838                        );
2839                        return Ok(());
2840                    }
2841                    Err(e) => {
2842                        log::trace!("convert: auto g2d declined {src_fmt:?}→{dst_fmt:?}: {e}");
2843                    }
2844                }
2845            }
2846        }
2847
2848        if let Some(cpu) = self.cpu.as_mut() {
2849            match cpu.convert(src, dst, rotation, flip, crop) {
2850                Ok(_) => {
2851                    log::trace!(
2852                        "convert: auto selected=cpu for {src_fmt:?}→{dst_fmt:?} ({:?})",
2853                        start.elapsed()
2854                    );
2855                    return Ok(());
2856                }
2857                Err(e) => {
2858                    log::trace!("convert: auto cpu failed {src_fmt:?}→{dst_fmt:?}: {e}");
2859                    return Err(e);
2860                }
2861            }
2862        }
2863        Err(Error::NoConverter)
2864    }
2865
2866    fn convert_deferred(
2867        &mut self,
2868        src: &TensorDyn,
2869        dst: &mut TensorDyn,
2870        rotation: Rotation,
2871        flip: Flip,
2872        crop: Crop,
2873    ) -> Result<()> {
2874        // Deferred batching is an OpenGL optimization (shared parent EGLImage +
2875        // no per-tile glFinish). Route to the GL backend's deferred path when GL
2876        // is forced or auto-selectable; on a GL decline fall back to an eager
2877        // convert (the auto chain), which is correct everywhere — it completes
2878        // synchronously and `flush` stays a no-op for non-GL backends.
2879        #[cfg(any(
2880            target_os = "linux",
2881            target_os = "macos",
2882            target_os = "ios",
2883            target_os = "android",
2884            target_os = "windows"
2885        ))]
2886        #[cfg(feature = "opengl")]
2887        {
2888            let gl_forced = matches!(self.forced_backend, Some(ForcedBackend::OpenGl));
2889            if gl_forced || self.forced_backend.is_none() {
2890                if let Some(opengl) = self.opengl.as_mut() {
2891                    match opengl.convert_deferred(src, dst, rotation, flip, crop) {
2892                        Ok(()) => return Ok(()),
2893                        Err(e) => {
2894                            log::trace!("convert_deferred: gl declined: {e}; eager fallback");
2895                            // A forced-GL caller gets the GL error, matching
2896                            // `convert`'s no-fallback forced-backend contract.
2897                            if gl_forced {
2898                                return Err(e);
2899                            }
2900                        }
2901                    }
2902                }
2903            }
2904        }
2905        self.convert(src, dst, rotation, flip, crop)
2906    }
2907
2908    fn flush(&mut self) -> Result<()> {
2909        let _span = tracing::trace_span!("image.flush").entered();
2910        // Only the OpenGL backend defers; flushing it issues the single GPU
2911        // sync. CPU/G2D converts already completed, so there is nothing to flush.
2912        #[cfg(any(
2913            target_os = "linux",
2914            target_os = "macos",
2915            target_os = "ios",
2916            target_os = "android",
2917            target_os = "windows"
2918        ))]
2919        #[cfg(feature = "opengl")]
2920        if let Some(opengl) = self.opengl.as_mut() {
2921            return opengl.flush();
2922        }
2923        Ok(())
2924    }
2925
2926    fn draw_decoded_masks(
2927        &mut self,
2928        dst: &mut TensorDyn,
2929        detect: &[DetectBox],
2930        segmentation: &[Segmentation],
2931        overlay: MaskOverlay<'_>,
2932    ) -> Result<()> {
2933        let _span = tracing::trace_span!(
2934            "image.draw_decoded_masks",
2935            n_detections = detect.len(),
2936            n_segmentations = segmentation.len(),
2937        )
2938        .entered();
2939        let start = Instant::now();
2940
2941        if let Some(bg) = overlay.background {
2942            if bg.aliases(dst) {
2943                return Err(Error::AliasedBuffers(
2944                    "background must not reference the same buffer as dst".to_string(),
2945                ));
2946            }
2947        }
2948
2949        // Un-letterbox detect boxes and segmentation bboxes for rendering when
2950        // a letterbox was applied to prepare the model input.
2951        let lb_boxes: Vec<DetectBox>;
2952        let lb_segs: Vec<Segmentation>;
2953        let (detect, segmentation) = if let Some(lb) = overlay.letterbox {
2954            lb_boxes = detect.iter().map(|&d| unletter_bbox(d, lb)).collect();
2955            // Keep segmentation bboxes in sync with the transformed detect boxes
2956            // when we have a 1:1 correspondence (instance segmentation).
2957            if segmentation.len() == lb_boxes.len() {
2958                // Only the four bounds change; the mask bytes are copied
2959                // wholesale because `TensorDyn` is not `Clone`. The memcpy is
2960                // what `Array3<u8>::clone()` already paid; `copy_segmentation`
2961                // pins the allocation to Mem so the ALLOCATOR cost matches too
2962                // (see there). Rendering from borrowed masks plus a separate
2963                // bounds list would avoid the copy entirely.
2964                lb_segs = segmentation
2965                    .iter()
2966                    .zip(lb_boxes.iter())
2967                    .map(|(s, d)| copy_segmentation(s, d.bbox.into()))
2968                    .collect::<Result<Vec<_>>>()?;
2969                (lb_boxes.as_slice(), lb_segs.as_slice())
2970            } else {
2971                // No 1:1 correspondence, so the segmentation bounds are left
2972                // as they are -- borrow rather than the copy `to_vec()` used
2973                // to make.
2974                (lb_boxes.as_slice(), segmentation)
2975            }
2976        } else {
2977            (detect, segmentation)
2978        };
2979        #[cfg(target_os = "linux")]
2980        let is_empty_frame = detect.is_empty() && segmentation.is_empty();
2981
2982        // ── Forced backend: no fallback chain ────────────────────────
2983        if let Some(forced) = self.forced_backend {
2984            return match forced {
2985                ForcedBackend::Cpu => {
2986                    if let Some(cpu) = self.cpu.as_mut() {
2987                        return cpu.draw_decoded_masks(dst, detect, segmentation, overlay);
2988                    }
2989                    Err(Error::ForcedBackendUnavailable("cpu".into()))
2990                }
2991                ForcedBackend::G2d => {
2992                    // G2D can only produce empty frames (clear / bg blit).
2993                    // For populated frames it has no rasterizer — fail loudly.
2994                    #[cfg(target_os = "linux")]
2995                    if let Some(g2d) = self.g2d.as_mut() {
2996                        return g2d.draw_decoded_masks(dst, detect, segmentation, overlay);
2997                    }
2998                    Err(Error::ForcedBackendUnavailable("g2d".into()))
2999                }
3000                ForcedBackend::OpenGl => {
3001                    // GL handles background natively via GPU blit, and now
3002                    // actively clears when there is no background.
3003                    #[cfg(target_os = "linux")]
3004                    #[cfg(feature = "opengl")]
3005                    if let Some(opengl) = self.opengl.as_mut() {
3006                        return opengl.draw_decoded_masks(dst, detect, segmentation, overlay);
3007                    }
3008                    Err(Error::ForcedBackendUnavailable("opengl".into()))
3009                }
3010            };
3011        }
3012
3013        // ── Auto dispatch ──────────────────────────────────────────
3014        // Empty frames prefer G2D when available — a single g2d_clear or
3015        // g2d_blit is the cheapest HW path to produce the correct output
3016        // and avoids spinning up the GL pipeline every zero-detection
3017        // frame in a triple-buffered display loop.
3018        #[cfg(target_os = "linux")]
3019        if is_empty_frame {
3020            if let Some(g2d) = self.g2d.as_mut() {
3021                match g2d.draw_decoded_masks(dst, detect, segmentation, overlay) {
3022                    Ok(_) => {
3023                        log::trace!(
3024                            "draw_decoded_masks empty frame via g2d in {:?}",
3025                            start.elapsed()
3026                        );
3027                        return Ok(());
3028                    }
3029                    Err(e) => log::trace!("g2d empty-frame path unavailable: {e:?}"),
3030                }
3031            }
3032        }
3033
3034        // Populated frames (or G2D unavailable): GL first, CPU fallback.
3035        // Both backends now own their own base-layer handling (bg blit
3036        // or clear), so we hand the overlay through untouched.
3037        #[cfg(target_os = "linux")]
3038        #[cfg(feature = "opengl")]
3039        if let Some(opengl) = self.opengl.as_mut() {
3040            log::trace!(
3041                "draw_decoded_masks started with opengl in {:?}",
3042                start.elapsed()
3043            );
3044            match opengl.draw_decoded_masks(dst, detect, segmentation, overlay) {
3045                Ok(_) => {
3046                    log::trace!("draw_decoded_masks with opengl in {:?}", start.elapsed());
3047                    return Ok(());
3048                }
3049                Err(e) => {
3050                    log::trace!("draw_decoded_masks didn't work with opengl: {e:?}")
3051                }
3052            }
3053        }
3054
3055        log::trace!(
3056            "draw_decoded_masks started with cpu in {:?}",
3057            start.elapsed()
3058        );
3059        if let Some(cpu) = self.cpu.as_mut() {
3060            match cpu.draw_decoded_masks(dst, detect, segmentation, overlay) {
3061                Ok(_) => {
3062                    log::trace!("draw_decoded_masks with cpu in {:?}", start.elapsed());
3063                    return Ok(());
3064                }
3065                Err(e) => {
3066                    log::trace!("draw_decoded_masks didn't work with cpu: {e:?}");
3067                    return Err(e);
3068                }
3069            }
3070        }
3071        Err(Error::NoConverter)
3072    }
3073
3074    fn draw_proto_masks(
3075        &mut self,
3076        dst: &mut TensorDyn,
3077        detect: &[DetectBox],
3078        proto_data: &ProtoData,
3079        overlay: MaskOverlay<'_>,
3080    ) -> Result<()> {
3081        let start = Instant::now();
3082
3083        if let Some(bg) = overlay.background {
3084            if bg.aliases(dst) {
3085                return Err(Error::AliasedBuffers(
3086                    "background must not reference the same buffer as dst".to_string(),
3087                ));
3088            }
3089        }
3090
3091        // Un-letterbox detect boxes for rendering when a letterbox was applied
3092        // to prepare the model input.  The original `detect` coords are still
3093        // passed to `materialize_segmentations` (which needs model-space coords
3094        // to correctly crop the proto tensor) alongside `overlay.letterbox` so
3095        // it can emit `Segmentation` structs in output-image space.
3096        let lb_boxes: Vec<DetectBox>;
3097        let render_detect = if let Some(lb) = overlay.letterbox {
3098            lb_boxes = detect.iter().map(|&d| unletter_bbox(d, lb)).collect();
3099            lb_boxes.as_slice()
3100        } else {
3101            detect
3102        };
3103        #[cfg(target_os = "linux")]
3104        let is_empty_frame = detect.is_empty();
3105
3106        // ── Forced backend: no fallback chain ────────────────────────
3107        if let Some(forced) = self.forced_backend {
3108            return match forced {
3109                ForcedBackend::Cpu => {
3110                    if let Some(cpu) = self.cpu.as_mut() {
3111                        return cpu.draw_proto_masks(dst, render_detect, proto_data, overlay);
3112                    }
3113                    Err(Error::ForcedBackendUnavailable("cpu".into()))
3114                }
3115                ForcedBackend::G2d => {
3116                    #[cfg(target_os = "linux")]
3117                    if let Some(g2d) = self.g2d.as_mut() {
3118                        return g2d.draw_proto_masks(dst, render_detect, proto_data, overlay);
3119                    }
3120                    Err(Error::ForcedBackendUnavailable("g2d".into()))
3121                }
3122                ForcedBackend::OpenGl => {
3123                    #[cfg(target_os = "linux")]
3124                    #[cfg(feature = "opengl")]
3125                    if let Some(opengl) = self.opengl.as_mut() {
3126                        return opengl.draw_proto_masks(dst, render_detect, proto_data, overlay);
3127                    }
3128                    Err(Error::ForcedBackendUnavailable("opengl".into()))
3129                }
3130            };
3131        }
3132
3133        // ── Auto dispatch ──────────────────────────────────────────
3134        // Empty frames: prefer G2D — cheapest HW path (clear or bg blit).
3135        #[cfg(target_os = "linux")]
3136        if is_empty_frame {
3137            if let Some(g2d) = self.g2d.as_mut() {
3138                match g2d.draw_proto_masks(dst, render_detect, proto_data, overlay) {
3139                    Ok(_) => {
3140                        log::trace!(
3141                            "draw_proto_masks empty frame via g2d in {:?}",
3142                            start.elapsed()
3143                        );
3144                        return Ok(());
3145                    }
3146                    Err(e) => log::trace!("g2d empty-frame path unavailable: {e:?}"),
3147                }
3148            }
3149        }
3150
3151        // Hybrid path: CPU materialize + GL overlay (benchmarked faster than
3152        // full-GPU draw_proto_masks on all tested platforms: 27× on imx8mp,
3153        // 4× on imx95, 2.5× on rpi5, 1.6× on x86).
3154        // GL owns its own bg-blit / glClear — we pass the overlay through.
3155        //
3156        // CPU materialize needs `&mut` for its MaskScratch buffers; GL also
3157        // needs `&mut`. The CPU borrow is scoped to its block so the
3158        // subsequent GL borrow is free to take over `self`.
3159        #[cfg(target_os = "linux")]
3160        #[cfg(feature = "opengl")]
3161        if let (Some(_), Some(_)) = (self.cpu.as_ref(), self.opengl.as_ref()) {
3162            let segmentation = match self.cpu.as_mut() {
3163                Some(cpu) => {
3164                    log::trace!(
3165                        "draw_proto_masks started with hybrid (cpu+opengl) in {:?}",
3166                        start.elapsed()
3167                    );
3168                    cpu.materialize_segmentations(detect, proto_data, overlay.letterbox)?
3169                }
3170                None => unreachable!("cpu presence checked above"),
3171            };
3172            if let Some(opengl) = self.opengl.as_mut() {
3173                match opengl.draw_decoded_masks(dst, render_detect, &segmentation, overlay) {
3174                    Ok(_) => {
3175                        log::trace!(
3176                            "draw_proto_masks with hybrid (cpu+opengl) in {:?}",
3177                            start.elapsed()
3178                        );
3179                        return Ok(());
3180                    }
3181                    Err(e) => {
3182                        log::trace!(
3183                            "draw_proto_masks hybrid path failed, falling back to cpu: {e:?}"
3184                        );
3185                    }
3186                }
3187            }
3188        }
3189
3190        let Some(cpu) = self.cpu.as_mut() else {
3191            return Err(Error::Internal(
3192                "draw_proto_masks requires CPU backend for fallback path".into(),
3193            ));
3194        };
3195        log::trace!("draw_proto_masks started with cpu in {:?}", start.elapsed());
3196        cpu.draw_proto_masks(dst, render_detect, proto_data, overlay)
3197    }
3198
3199    fn set_class_colors(&mut self, colors: &[[u8; 4]]) -> Result<()> {
3200        let start = Instant::now();
3201
3202        // ── Forced backend: no fallback chain ────────────────────────
3203        if let Some(forced) = self.forced_backend {
3204            return match forced {
3205                ForcedBackend::Cpu => {
3206                    if let Some(cpu) = self.cpu.as_mut() {
3207                        return cpu.set_class_colors(colors);
3208                    }
3209                    Err(Error::ForcedBackendUnavailable("cpu".into()))
3210                }
3211                ForcedBackend::G2d => Err(Error::NotSupported(
3212                    "g2d does not support set_class_colors".into(),
3213                )),
3214                ForcedBackend::OpenGl => {
3215                    #[cfg(target_os = "linux")]
3216                    #[cfg(feature = "opengl")]
3217                    if let Some(opengl) = self.opengl.as_mut() {
3218                        return opengl.set_class_colors(colors);
3219                    }
3220                    Err(Error::ForcedBackendUnavailable("opengl".into()))
3221                }
3222            };
3223        }
3224
3225        // skip G2D as it doesn't support rendering to image
3226
3227        #[cfg(target_os = "linux")]
3228        #[cfg(feature = "opengl")]
3229        if let Some(opengl) = self.opengl.as_mut() {
3230            log::trace!("image started with opengl in {:?}", start.elapsed());
3231            match opengl.set_class_colors(colors) {
3232                Ok(_) => {
3233                    log::trace!("colors set with opengl in {:?}", start.elapsed());
3234                    return Ok(());
3235                }
3236                Err(e) => {
3237                    log::trace!("colors didn't set with opengl: {e:?}")
3238                }
3239            }
3240        }
3241        log::trace!("image started with cpu in {:?}", start.elapsed());
3242        if let Some(cpu) = self.cpu.as_mut() {
3243            match cpu.set_class_colors(colors) {
3244                Ok(_) => {
3245                    log::trace!("colors set with cpu in {:?}", start.elapsed());
3246                    return Ok(());
3247                }
3248                Err(e) => {
3249                    log::trace!("colors didn't set with cpu: {e:?}");
3250                    return Err(e);
3251                }
3252            }
3253        }
3254        Err(Error::NoConverter)
3255    }
3256}
3257
3258// ---------------------------------------------------------------------------
3259// Image loading / saving helpers
3260// ---------------------------------------------------------------------------
3261
3262/// Test-only convenience helper that peeks the image header, allocates a
3263/// tensor sized to the image (honoring DMA pitch padding on Linux when
3264/// requested), and decodes via [`edgefirst_codec`]. Mirrors the semantics of
3265/// the removed public `load_image` API for test sites; production callers
3266/// should use the explicit peek → allocate → decode pattern directly.
3267#[cfg(all(test, feature = "codec"))]
3268pub(crate) fn load_image_test_helper(
3269    image: &[u8],
3270    format: Option<PixelFormat>,
3271    memory: Option<TensorMemory>,
3272) -> Result<TensorDyn> {
3273    use edgefirst_codec::{peek_info, ImageDecoder, ImageLoad};
3274
3275    // Peek the source header to get its NATIVE format and dimensions. The
3276    // codec now emits the source's native format (JPEG → Nv12/Grey, PNG →
3277    // Rgb/Rgba/Grey) and configures the destination tensor itself.
3278    let info = peek_info(image)?;
3279    let native_fmt = info.format;
3280    let w = info.width;
3281    let h = info.height;
3282
3283    let mut decoder = ImageDecoder::new();
3284
3285    // Decode into a native-format tensor. The decoder sets the tensor's
3286    // dims+format, so we allocate it sized to the native layout.
3287    #[cfg(target_os = "linux")]
3288    let native_src = {
3289        if let Some(aligned_pitch) = padded_dma_pitch_for(native_fmt, w, &memory) {
3290            let mut dma = Tensor::<u8>::image_with_stride(
3291                w,
3292                h,
3293                native_fmt,
3294                aligned_pitch,
3295                Some(TensorMemory::DmaBuf),
3296                edgefirst_tensor::CpuAccess::ReadWrite,
3297            )?;
3298            dma.load_image(&mut decoder, image)?;
3299            TensorDyn::from(dma)
3300        } else {
3301            let mut img = Tensor::<u8>::image(
3302                w,
3303                h,
3304                native_fmt,
3305                memory,
3306                edgefirst_tensor::CpuAccess::ReadWrite,
3307            )?;
3308            img.load_image(&mut decoder, image)?;
3309            TensorDyn::from(img)
3310        }
3311    };
3312    #[cfg(not(target_os = "linux"))]
3313    let native_src = {
3314        let mut img = Tensor::<u8>::image(
3315            w,
3316            h,
3317            native_fmt,
3318            memory,
3319            edgefirst_tensor::CpuAccess::ReadWrite,
3320        )?;
3321        img.load_image(&mut decoder, image)?;
3322        TensorDyn::from(img)
3323    };
3324
3325    // If the caller requested a different format, convert into it (same
3326    // dims) using a headless CPU-backed processor so the helper works
3327    // without GPU/G2D hardware.
3328    match format {
3329        Some(f) if f != native_fmt => {
3330            let mut dst = TensorDyn::image(
3331                w,
3332                h,
3333                f,
3334                DType::U8,
3335                memory,
3336                edgefirst_tensor::CpuAccess::ReadWrite,
3337            )?;
3338            // `ImageProcessorConfig` has platform-specific fields: on Linux it
3339            // carries extra GL/G2D options so `..Default::default()` is needed,
3340            // but on macOS `backend` is the only field, making the update
3341            // redundant (clippy::needless_update). Allow it for cross-platform
3342            // parity — the alternative (field reassign) trips
3343            // clippy::field_reassign_with_default on Linux instead.
3344            #[allow(clippy::needless_update)]
3345            let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
3346                backend: ComputeBackend::Cpu,
3347                ..Default::default()
3348            })?;
3349            proc.convert(
3350                &native_src,
3351                &mut dst,
3352                Rotation::None,
3353                Flip::None,
3354                Crop::default(),
3355            )?;
3356            Ok(dst)
3357        }
3358        _ => Ok(native_src),
3359    }
3360}
3361
3362/// Save a [`TensorDyn`] image as a JPEG file.
3363///
3364/// Only packed RGB and RGBA formats are supported.
3365pub fn save_jpeg(tensor: &TensorDyn, path: impl AsRef<std::path::Path>, quality: u8) -> Result<()> {
3366    let t = tensor.as_typed::<u8>().ok_or(Error::UnsupportedFormat(
3367        "save_jpeg requires u8 tensor".to_string(),
3368    ))?;
3369    let fmt = t.format().ok_or(Error::NotAnImage)?;
3370    if fmt.layout() != PixelLayout::Packed {
3371        return Err(Error::NotImplemented(
3372            "Saving planar images is not supported".to_string(),
3373        ));
3374    }
3375
3376    let colour = match fmt {
3377        PixelFormat::Rgb => jpeg_encoder::ColorType::Rgb,
3378        PixelFormat::Rgba => jpeg_encoder::ColorType::Rgba,
3379        _ => {
3380            return Err(Error::NotImplemented(
3381                "Unsupported image format for saving".to_string(),
3382            ));
3383        }
3384    };
3385
3386    let w = t.width().ok_or(Error::NotAnImage)?;
3387    let h = t.height().ok_or(Error::NotAnImage)?;
3388    let encoder = jpeg_encoder::Encoder::new_file(path, quality)?;
3389    let tensor_map = t.map_read()?;
3390
3391    encoder.encode(&tensor_map, w as u16, h as u16, colour)?;
3392
3393    Ok(())
3394}
3395
3396pub(crate) struct FunctionTimer<T: Display> {
3397    name: T,
3398    start: std::time::Instant,
3399}
3400
3401impl<T: Display> FunctionTimer<T> {
3402    pub fn new(name: T) -> Self {
3403        Self {
3404            name,
3405            start: std::time::Instant::now(),
3406        }
3407    }
3408}
3409
3410impl<T: Display> Drop for FunctionTimer<T> {
3411    fn drop(&mut self) {
3412        log::trace!("{} elapsed: {:?}", self.name, self.start.elapsed())
3413    }
3414}
3415
3416const DEFAULT_COLORS: [[f32; 4]; 20] = [
3417    [0., 1., 0., 0.7],
3418    [1., 0.5568628, 0., 0.7],
3419    [0.25882353, 0.15294118, 0.13333333, 0.7],
3420    [0.8, 0.7647059, 0.78039216, 0.7],
3421    [0.3137255, 0.3137255, 0.3137255, 0.7],
3422    [0.1411765, 0.3098039, 0.1215686, 0.7],
3423    [1., 0.95686275, 0.5137255, 0.7],
3424    [0.3529412, 0.32156863, 0., 0.7],
3425    [0.4235294, 0.6235294, 0.6509804, 0.7],
3426    [0.5098039, 0.5098039, 0.7294118, 0.7],
3427    [0.00784314, 0.18823529, 0.29411765, 0.7],
3428    [0.0, 0.2706, 1.0, 0.7],
3429    [0.0, 0.0, 0.0, 0.7],
3430    [0.0, 0.5, 0.0, 0.7],
3431    [1.0, 0.0, 0.0, 0.7],
3432    [0.0, 0.0, 1.0, 0.7],
3433    [1.0, 0.5, 0.5, 0.7],
3434    [0.1333, 0.5451, 0.1333, 0.7],
3435    [0.1176, 0.4118, 0.8235, 0.7],
3436    [1., 1., 1., 0.7],
3437];
3438
3439const fn denorm<const M: usize, const N: usize>(a: [[f32; M]; N]) -> [[u8; M]; N] {
3440    let mut result = [[0; M]; N];
3441    let mut i = 0;
3442    while i < N {
3443        let mut j = 0;
3444        while j < M {
3445            result[i][j] = (a[i][j] * 255.0).round() as u8;
3446            j += 1;
3447        }
3448        i += 1;
3449    }
3450    result
3451}
3452
3453const DEFAULT_COLORS_U8: [[u8; 4]; 20] = denorm(DEFAULT_COLORS);
3454
3455#[cfg(test)]
3456#[cfg_attr(coverage_nightly, coverage(off))]
3457mod alignment_tests {
3458    use super::*;
3459
3460    #[test]
3461    fn align_width_rgba8_common_widths() {
3462        // RGBA8 (bpp=4, lcm(64,4)=64, so width must round to multiple of 16 px).
3463        assert_eq!(align_width_for_gpu_pitch(640, 4), 640); // 2560 byte pitch — already aligned
3464        assert_eq!(align_width_for_gpu_pitch(1280, 4), 1280); // 5120
3465        assert_eq!(align_width_for_gpu_pitch(1920, 4), 1920); // 7680
3466        assert_eq!(align_width_for_gpu_pitch(3840, 4), 3840); // 15360
3467                                                              // crowd.png case from the imx95 investigation:
3468        assert_eq!(align_width_for_gpu_pitch(3004, 4), 3008); // 12016 → 12032
3469        assert_eq!(align_width_for_gpu_pitch(3000, 4), 3008); // 12000 → 12032
3470        assert_eq!(align_width_for_gpu_pitch(17, 4), 32); // 68 → 128
3471        assert_eq!(align_width_for_gpu_pitch(1, 4), 16); // 4 → 64
3472    }
3473
3474    #[test]
3475    fn align_width_rgb888_packed() {
3476        // RGB888 (bpp=3, lcm(64,3)=192, so width must round to multiple of 64 px).
3477        assert_eq!(align_width_for_gpu_pitch(64, 3), 64); // 192 byte pitch
3478        assert_eq!(align_width_for_gpu_pitch(640, 3), 640); // 1920
3479        assert_eq!(align_width_for_gpu_pitch(1, 3), 64); // 3 → 192
3480        assert_eq!(align_width_for_gpu_pitch(65, 3), 128); // 195 → 384
3481                                                           // Verify the rounded width × bpp is a clean multiple of the LCM.
3482        for w in [3004usize, 1281, 100, 17] {
3483            let padded = align_width_for_gpu_pitch(w, 3);
3484            assert!(padded >= w);
3485            assert_eq!((padded * 3) % 64, 0);
3486            assert_eq!((padded * 3) % 3, 0);
3487        }
3488    }
3489
3490    #[test]
3491    fn align_width_grey_u8() {
3492        // Grey (bpp=1, lcm(64,1)=64, so width must round to multiple of 64 px).
3493        assert_eq!(align_width_for_gpu_pitch(64, 1), 64);
3494        assert_eq!(align_width_for_gpu_pitch(640, 1), 640);
3495        assert_eq!(align_width_for_gpu_pitch(1, 1), 64);
3496        assert_eq!(align_width_for_gpu_pitch(65, 1), 128);
3497    }
3498
3499    #[test]
3500    fn align_width_zero_inputs() {
3501        assert_eq!(align_width_for_gpu_pitch(0, 4), 0);
3502        assert_eq!(align_width_for_gpu_pitch(640, 0), 640);
3503    }
3504
3505    #[test]
3506    fn align_width_never_returns_smaller_than_input() {
3507        // Spot-check the "returned width >= input width" contract across a
3508        // range of values that would previously have hit `width * bpp`
3509        // overflow paths.
3510        for &bpp in &[1usize, 2, 3, 4, 8] {
3511            for &w in &[
3512                1usize,
3513                17,
3514                64,
3515                65,
3516                100,
3517                1280,
3518                1281,
3519                1920,
3520                3004,
3521                3072,
3522                3840,
3523                usize::MAX / 8,
3524                usize::MAX / 4,
3525                usize::MAX / 2,
3526                usize::MAX - 1,
3527                usize::MAX,
3528            ] {
3529                let aligned = align_width_for_gpu_pitch(w, bpp);
3530                assert!(
3531                    aligned >= w,
3532                    "align_width_for_gpu_pitch({w}, {bpp}) = {aligned} < {w}"
3533                );
3534            }
3535        }
3536    }
3537
3538    #[test]
3539    fn align_width_overflow_returns_unaligned_not_smaller() {
3540        // For width values close to usize::MAX, padding up would wrap. The
3541        // function must return the original width rather than wrapping or
3542        // panicking. A pre-aligned width round-trips unchanged even at the
3543        // extreme.
3544        let aligned_extreme = usize::MAX - 15; // 16-pixel boundary for RGBA8
3545        assert_eq!(
3546            align_width_for_gpu_pitch(aligned_extreme, 4),
3547            aligned_extreme
3548        );
3549        // A misaligned extreme value cannot be rounded up — the function
3550        // returns the original.
3551        let misaligned_extreme = usize::MAX - 1;
3552        let result = align_width_for_gpu_pitch(misaligned_extreme, 4);
3553        assert!(
3554            result == misaligned_extreme || result >= misaligned_extreme,
3555            "extreme misaligned width must not be rounded down to {result}"
3556        );
3557    }
3558
3559    #[test]
3560    fn checked_lcm_basic_and_overflow() {
3561        assert_eq!(checked_num_integer_lcm(64, 4), Some(64));
3562        assert_eq!(checked_num_integer_lcm(64, 3), Some(192));
3563        assert_eq!(checked_num_integer_lcm(64, 1), Some(64));
3564        assert_eq!(checked_num_integer_lcm(0, 4), Some(0));
3565        assert_eq!(checked_num_integer_lcm(64, 0), Some(0));
3566        // Coprime values whose product exceeds usize::MAX must return None.
3567        assert_eq!(
3568            checked_num_integer_lcm(usize::MAX, usize::MAX - 1),
3569            None,
3570            "coprime extreme values must overflow detect, not panic"
3571        );
3572    }
3573
3574    #[test]
3575    fn primary_plane_bpp_known_formats() {
3576        // Packed formats use channels × elem_size.
3577        assert_eq!(primary_plane_bpp(PixelFormat::Rgba, 1), Some(4));
3578        assert_eq!(primary_plane_bpp(PixelFormat::Bgra, 1), Some(4));
3579        assert_eq!(primary_plane_bpp(PixelFormat::Rgb, 1), Some(3));
3580        assert_eq!(primary_plane_bpp(PixelFormat::Grey, 1), Some(1));
3581        // Semi-planar (NV12) reports the luma plane's bpp.
3582        assert_eq!(primary_plane_bpp(PixelFormat::Nv12, 1), Some(1));
3583    }
3584}
3585
3586#[cfg(test)]
3587#[cfg_attr(coverage_nightly, coverage(off))]
3588#[allow(deprecated)]
3589mod image_tests {
3590    use super::*;
3591    use crate::{CPUProcessor, Rotation};
3592    #[cfg(target_os = "linux")]
3593    use edgefirst_tensor::is_dma_available;
3594    use edgefirst_tensor::{TensorMapTrait, TensorMemory, TensorTrait};
3595    use image::buffer::ConvertBuffer;
3596
3597    /// Test helper: call `ImageProcessorTrait::convert()` on two `TensorDyn`s
3598    /// by going through the `TensorDyn` API.
3599    ///
3600    /// Returns the `(src_image, dst_image)` reconstructed from the TensorDyn
3601    /// round-trip so the caller can feed them to `compare_images` etc.
3602    fn convert_img(
3603        proc: &mut dyn ImageProcessorTrait,
3604        src: TensorDyn,
3605        dst: TensorDyn,
3606        rotation: Rotation,
3607        flip: Flip,
3608        crop: Crop,
3609    ) -> (Result<()>, TensorDyn, TensorDyn) {
3610        let src_fourcc = src.format().unwrap();
3611        let dst_fourcc = dst.format().unwrap();
3612        let src_dyn = src;
3613        let mut dst_dyn = dst;
3614        let result = proc.convert(&src_dyn, &mut dst_dyn, rotation, flip, crop);
3615        let src_back = {
3616            let mut __t = src_dyn.into_u8().unwrap();
3617            __t.set_format(src_fourcc).unwrap();
3618            TensorDyn::from(__t)
3619        };
3620        let dst_back = {
3621            let mut __t = dst_dyn.into_u8().unwrap();
3622            __t.set_format(dst_fourcc).unwrap();
3623            TensorDyn::from(__t)
3624        };
3625        (result, src_back, dst_back)
3626    }
3627
3628    #[ctor::ctor(unsafe)]
3629    fn init() {
3630        env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
3631    }
3632
3633    macro_rules! function {
3634        () => {{
3635            fn f() {}
3636            fn type_name_of<T>(_: T) -> &'static str {
3637                std::any::type_name::<T>()
3638            }
3639            let name = type_name_of(f);
3640
3641            // Find and cut the rest of the path
3642            match &name[..name.len() - 3].rfind(':') {
3643                Some(pos) => &name[pos + 1..name.len() - 3],
3644                None => &name[..name.len() - 3],
3645            }
3646        }};
3647    }
3648
3649    /// Master oracle for the view/batch **destination** batch engine: render `N`
3650    /// tiles into row-bands of ONE tall destination and assert each band equals
3651    /// the same source converted standalone — proving correct band placement and
3652    /// that a later tile's letterbox clear / draw never wipes a sibling band. On
3653    /// the Linux GL backend the `N` `convert_deferred` calls share ONE parent
3654    /// EGLImage import (each tile is a `glViewport`/`glScissor` ROI) and sync
3655    /// once at `flush()`; other backends fall back to an eager per-band convert
3656    /// (CPU writes via offset + parent stride). Either way the oracle must hold.
3657    ///
3658    /// Identical source/tile size makes the convert an exact copy, so the
3659    /// assertion is backend-agnostic (no GL-vs-CPU resampling drift). Distinct
3660    /// solid colors per tile make any sibling wipe a hard failure.
3661    #[test]
3662    fn batch_view_dst_tiles_match_standalone() {
3663        let mut proc = match ImageProcessor::new() {
3664            Ok(p) => p,
3665            Err(e) => {
3666                eprintln!(
3667                    "SKIPPED: {} — ImageProcessor init failed ({e:?})",
3668                    function!()
3669                );
3670                return;
3671            }
3672        };
3673        let n = 3usize;
3674        let (w, h) = (32usize, 24usize);
3675        let colors: [[u8; 4]; 3] = [[210, 40, 40, 255], [40, 210, 40, 255], [40, 40, 210, 255]];
3676        let make_src = |c: [u8; 4]| -> TensorDyn {
3677            let bytes: Vec<u8> = c.iter().copied().cycle().take(w * h * 4).collect();
3678            load_bytes_to_tensor(w, h, PixelFormat::Rgba, Some(TensorMemory::Mem), &bytes).unwrap()
3679        };
3680        // Tall destination: N stacked row-bands. DMA so the Linux GL band path
3681        // runs (one parent import + per-tile glViewport); skip if unavailable.
3682        let parent = match TensorDyn::image(
3683            w,
3684            n * h,
3685            PixelFormat::Rgba,
3686            DType::U8,
3687            Some(TensorMemory::DmaBuf),
3688            edgefirst_tensor::CpuAccess::ReadWrite,
3689        ) {
3690            Ok(d) => d,
3691            Err(e) => {
3692                eprintln!(
3693                    "SKIPPED: {} — tall DMA destination alloc failed ({e:?})",
3694                    function!()
3695                );
3696                return;
3697            }
3698        };
3699
3700        // Deferred batch: one parent import, glViewport/scissor per band, one sync.
3701        for (i, &c) in colors.iter().enumerate().take(n) {
3702            let mut tile = parent.view(Region::new(0, i * h, w, h)).unwrap();
3703            proc.convert_deferred(
3704                &make_src(c),
3705                &mut tile,
3706                Rotation::None,
3707                Flip::None,
3708                Crop::no_crop(),
3709            )
3710            .unwrap_or_else(|e| panic!("convert_deferred tile {i}: {e:?}"));
3711        }
3712        proc.flush().unwrap();
3713
3714        for (i, &c) in colors.iter().enumerate().take(n) {
3715            // Standalone full-buffer convert of the same source = the oracle.
3716            let mut solo = TensorDyn::image(
3717                w,
3718                h,
3719                PixelFormat::Rgba,
3720                DType::U8,
3721                Some(TensorMemory::DmaBuf),
3722                edgefirst_tensor::CpuAccess::ReadWrite,
3723            )
3724            .unwrap();
3725            proc.convert(
3726                &make_src(c),
3727                &mut solo,
3728                Rotation::None,
3729                Flip::None,
3730                Crop::no_crop(),
3731            )
3732            .unwrap();
3733
3734            let band = parent.view(Region::new(0, i * h, w, h)).unwrap();
3735            let band_bytes = band
3736                .as_typed::<u8>()
3737                .unwrap()
3738                .map()
3739                .unwrap()
3740                .as_slice()
3741                .to_vec();
3742            let solo_bytes = solo
3743                .as_typed::<u8>()
3744                .unwrap()
3745                .map()
3746                .unwrap()
3747                .as_slice()
3748                .to_vec();
3749            assert_eq!(
3750                band_bytes, solo_bytes,
3751                "tile {i}: band differs from standalone convert (placement or sibling wipe)"
3752            );
3753            assert!(
3754                band_bytes.as_chunks::<4>().0.iter().all(|&p| p == c),
3755                "tile {i}: band is not the expected solid color {c:?} (sibling wipe?)"
3756            );
3757        }
3758    }
3759
3760    /// Build a `w`×`h` RGBA frame whose pixels encode their position, so a crop
3761    /// at any origin has distinct content (catches crop-origin / band-placement
3762    /// bugs a solid color cannot).
3763    #[cfg(test)]
3764    fn gradient_frame(w: usize, h: usize) -> TensorDyn {
3765        let mut bytes = vec![0u8; w * h * 4];
3766        for y in 0..h {
3767            for x in 0..w {
3768                let i = (y * w + x) * 4;
3769                bytes[i] = x as u8;
3770                bytes[i + 1] = y as u8;
3771                bytes[i + 2] = (x ^ y) as u8;
3772                bytes[i + 3] = 255;
3773            }
3774        }
3775        load_bytes_to_tensor(w, h, PixelFormat::Rgba, Some(TensorMemory::Mem), &bytes).unwrap()
3776    }
3777
3778    /// `tile_into` band == standalone crop-convert of the same source region,
3779    /// on the CPU backend (runs on CI without a GPU). Distinct gradient content
3780    /// makes a wrong crop origin or a sibling-band wipe a hard failure.
3781    #[test]
3782    fn tile_into_cpu_distinct_content_parity() {
3783        let mut proc = match ImageProcessor::with_config(ImageProcessorConfig {
3784            backend: ComputeBackend::Cpu,
3785            ..Default::default()
3786        }) {
3787            Ok(p) => p,
3788            Err(e) => {
3789                eprintln!("SKIPPED: {} — CPU init failed ({e:?})", function!());
3790                return;
3791            }
3792        };
3793        let (fw, fh) = (96usize, 64usize);
3794        let src = gradient_frame(fw, fh);
3795        let cfg = TilingConfig::new(32, 32).with_overlap(0.0); // exact 3×2 tiling
3796        let n = tile_grid(fh, fw, 32, 32, 0.0).len();
3797        assert_eq!(n, 6);
3798
3799        let mut parent = proc
3800            .alloc_tile_batch(
3801                n,
3802                &cfg,
3803                PixelFormat::Rgba,
3804                DType::U8,
3805                Some(TensorMemory::Mem),
3806                edgefirst_tensor::CpuAccess::ReadWrite,
3807            )
3808            .unwrap();
3809        let placements = proc.tile_into(&src, &mut parent, &cfg).unwrap();
3810        assert_eq!(placements.len(), n);
3811
3812        for p in &placements {
3813            let source = Region::new(
3814                p.origin.0 as usize,
3815                p.origin.1 as usize,
3816                p.crop_size.0 as usize,
3817                p.crop_size.1 as usize,
3818            );
3819            let mut solo = TensorDyn::image(
3820                32,
3821                32,
3822                PixelFormat::Rgba,
3823                DType::U8,
3824                Some(TensorMemory::Mem),
3825                edgefirst_tensor::CpuAccess::ReadWrite,
3826            )
3827            .unwrap();
3828            proc.convert(
3829                &src,
3830                &mut solo,
3831                Rotation::None,
3832                Flip::None,
3833                Crop::default()
3834                    .with_source(Some(source))
3835                    .with_fit(Fit::Stretch),
3836            )
3837            .unwrap();
3838            let band = parent.view(Region::new(0, p.index * 32, 32, 32)).unwrap();
3839            let band_bytes = band
3840                .as_typed::<u8>()
3841                .unwrap()
3842                .map()
3843                .unwrap()
3844                .as_slice()
3845                .to_vec();
3846            let solo_bytes = solo
3847                .as_typed::<u8>()
3848                .unwrap()
3849                .map()
3850                .unwrap()
3851                .as_slice()
3852                .to_vec();
3853            assert_eq!(
3854                band_bytes, solo_bytes,
3855                "tile {} band differs from standalone crop-convert",
3856                p.index
3857            );
3858        }
3859    }
3860
3861    /// Streaming `tile_one` into a single slot == the corresponding batched
3862    /// `tile_into` band (proves the two paths agree).
3863    #[test]
3864    fn tile_one_matches_tile_into_band() {
3865        let mut proc = match ImageProcessor::with_config(ImageProcessorConfig {
3866            backend: ComputeBackend::Cpu,
3867            ..Default::default()
3868        }) {
3869            Ok(p) => p,
3870            Err(e) => {
3871                eprintln!("SKIPPED: {} — CPU init failed ({e:?})", function!());
3872                return;
3873            }
3874        };
3875        let (fw, fh) = (96usize, 64usize);
3876        let src = gradient_frame(fw, fh);
3877        let cfg = TilingConfig::new(32, 32).with_overlap(0.0);
3878        let n = tile_grid(fh, fw, 32, 32, 0.0).len();
3879
3880        let mut parent = proc
3881            .alloc_tile_batch(
3882                n,
3883                &cfg,
3884                PixelFormat::Rgba,
3885                DType::U8,
3886                Some(TensorMemory::Mem),
3887                edgefirst_tensor::CpuAccess::ReadWrite,
3888            )
3889            .unwrap();
3890        proc.tile_into(&src, &mut parent, &cfg).unwrap();
3891
3892        let plan = proc.plan_tiles(fw, fh, &cfg).unwrap();
3893        for p in &plan {
3894            let mut slot = TensorDyn::image(
3895                32,
3896                32,
3897                PixelFormat::Rgba,
3898                DType::U8,
3899                Some(TensorMemory::Mem),
3900                edgefirst_tensor::CpuAccess::ReadWrite,
3901            )
3902            .unwrap();
3903            proc.tile_one(&src, &mut slot, p, &cfg).unwrap();
3904            proc.flush().unwrap();
3905            let band = parent.view(Region::new(0, p.index * 32, 32, 32)).unwrap();
3906            let slot_bytes = slot
3907                .as_typed::<u8>()
3908                .unwrap()
3909                .map()
3910                .unwrap()
3911                .as_slice()
3912                .to_vec();
3913            let band_bytes = band
3914                .as_typed::<u8>()
3915                .unwrap()
3916                .map()
3917                .unwrap()
3918                .as_slice()
3919                .to_vec();
3920            assert_eq!(
3921                slot_bytes, band_bytes,
3922                "tile {} stream != batch band",
3923                p.index
3924            );
3925        }
3926    }
3927
3928    /// `plan_tiles` returns correct per-tile metadata for a 4K frame (pure, no
3929    /// GPU render).
3930    /// `tile_into` through the auto backend with a DMA parent — exercises the
3931    /// GL single-import band path on a GPU machine (skips when DMA/GL is
3932    /// unavailable). The source is sampled via `Crop::with_source`, never
3933    /// `src.view()`, so all tiles share one source import.
3934    #[test]
3935    fn tile_into_auto_dma_parity() {
3936        let mut proc = match ImageProcessor::new() {
3937            Ok(p) => p,
3938            Err(e) => {
3939                eprintln!(
3940                    "SKIPPED: {} — ImageProcessor init failed ({e:?})",
3941                    function!()
3942                );
3943                return;
3944            }
3945        };
3946        let (fw, fh) = (96usize, 64usize);
3947        let src = gradient_frame(fw, fh);
3948        let cfg = TilingConfig::new(32, 32).with_overlap(0.0);
3949        let n = tile_grid(fh, fw, 32, 32, 0.0).len();
3950
3951        let mut parent = match proc.alloc_tile_batch(
3952            n,
3953            &cfg,
3954            PixelFormat::Rgba,
3955            DType::U8,
3956            Some(TensorMemory::DmaBuf),
3957            edgefirst_tensor::CpuAccess::ReadWrite,
3958        ) {
3959            Ok(p) => p,
3960            Err(e) => {
3961                eprintln!(
3962                    "SKIPPED: {} — tall DMA parent alloc failed ({e:?})",
3963                    function!()
3964                );
3965                return;
3966            }
3967        };
3968        let placements = proc.tile_into(&src, &mut parent, &cfg).unwrap();
3969
3970        for p in &placements {
3971            let source = Region::new(
3972                p.origin.0 as usize,
3973                p.origin.1 as usize,
3974                p.crop_size.0 as usize,
3975                p.crop_size.1 as usize,
3976            );
3977            let mut solo = TensorDyn::image(
3978                32,
3979                32,
3980                PixelFormat::Rgba,
3981                DType::U8,
3982                Some(TensorMemory::DmaBuf),
3983                edgefirst_tensor::CpuAccess::ReadWrite,
3984            )
3985            .unwrap();
3986            proc.convert(
3987                &src,
3988                &mut solo,
3989                Rotation::None,
3990                Flip::None,
3991                Crop::default()
3992                    .with_source(Some(source))
3993                    .with_fit(Fit::Stretch),
3994            )
3995            .unwrap();
3996            let band = parent.view(Region::new(0, p.index * 32, 32, 32)).unwrap();
3997            // Structural-similarity tolerance (the house GPU-parity bar) rather
3998            // than exact bytes: rendering a tile into a viewport band at a
3999            // non-zero offset vs. a standalone origin render diverges by sampling
4000            // rounding on virtualized GPUs (paravirtual Metal/ANGLE on macOS CI),
4001            // the same tolerance class as the other cross-backend parity tests.
4002            compare_images(
4003                &band,
4004                &solo,
4005                0.98,
4006                &format!("{}_tile{}", function!(), p.index),
4007            );
4008        }
4009    }
4010
4011    /// `tile_into` rejects a destination too small to hold all tile bands.
4012    #[test]
4013    fn tile_into_undersized_dst_errors() {
4014        let mut proc = match ImageProcessor::with_config(ImageProcessorConfig {
4015            backend: ComputeBackend::Cpu,
4016            ..Default::default()
4017        }) {
4018            Ok(p) => p,
4019            Err(e) => {
4020                eprintln!("SKIPPED: {} — CPU init failed ({e:?})", function!());
4021                return;
4022            }
4023        };
4024        let (fw, fh) = (96usize, 64usize);
4025        let src = gradient_frame(fw, fh);
4026        let cfg = TilingConfig::new(32, 32).with_overlap(0.0); // 6 tiles
4027                                                               // Allocate a parent for only 2 bands, far short of 6.
4028        let mut small = TensorDyn::image(
4029            32,
4030            2 * 32,
4031            PixelFormat::Rgba,
4032            DType::U8,
4033            Some(TensorMemory::Mem),
4034            edgefirst_tensor::CpuAccess::ReadWrite,
4035        )
4036        .unwrap();
4037        let r = proc.tile_into(&src, &mut small, &cfg);
4038        assert!(
4039            matches!(r, Err(Error::InvalidShape(_))),
4040            "expected InvalidShape, got {r:?}"
4041        );
4042    }
4043
4044    /// `alloc_tile_batch` / `plan_tiles` reject an invalid config (zero tile).
4045    #[test]
4046    fn tiling_alloc_rejects_invalid_config() {
4047        let proc = match ImageProcessor::with_config(ImageProcessorConfig {
4048            backend: ComputeBackend::Cpu,
4049            ..Default::default()
4050        }) {
4051            Ok(p) => p,
4052            Err(e) => {
4053                eprintln!("SKIPPED: {} — CPU init failed ({e:?})", function!());
4054                return;
4055            }
4056        };
4057        let bad = TilingConfig::new(0, 640);
4058        assert!(proc.plan_tiles(1920, 1080, &bad).is_err());
4059        assert!(proc
4060            .alloc_tile_batch(
4061                4,
4062                &bad,
4063                PixelFormat::Rgba,
4064                DType::U8,
4065                Some(TensorMemory::Mem),
4066                edgefirst_tensor::CpuAccess::ReadWrite,
4067            )
4068            .is_err());
4069    }
4070
4071    #[test]
4072    fn plan_tiles_metadata_4k() {
4073        let proc = match ImageProcessor::with_config(ImageProcessorConfig {
4074            backend: ComputeBackend::Cpu,
4075            ..Default::default()
4076        }) {
4077            Ok(p) => p,
4078            Err(e) => {
4079                eprintln!("SKIPPED: {} — CPU init failed ({e:?})", function!());
4080                return;
4081            }
4082        };
4083        let cfg = TilingConfig::new(640, 640).with_overlap(0.2);
4084        let plan = proc.plan_tiles(3840, 2160, &cfg).unwrap();
4085        assert_eq!(plan.len(), 32);
4086        assert!(plan.iter().all(|p| p.count == 32));
4087        assert!(plan.iter().all(|p| p.crop_size == (640.0, 640.0)));
4088        assert!(plan.iter().all(|p| p.letterbox.is_none())); // stretch fit
4089        assert!(plan.iter().all(|p| p.frame_dims == (3840.0, 2160.0)));
4090        assert_eq!(plan[0].origin, (0.0, 0.0));
4091    }
4092
4093    #[test]
4094    fn test_invalid_crop() {
4095        let src = TensorDyn::image(
4096            100,
4097            100,
4098            PixelFormat::Rgb,
4099            DType::U8,
4100            None,
4101            edgefirst_tensor::CpuAccess::ReadWrite,
4102        )
4103        .unwrap();
4104        let dst = TensorDyn::image(
4105            100,
4106            100,
4107            PixelFormat::Rgb,
4108            DType::U8,
4109            None,
4110            edgefirst_tensor::CpuAccess::ReadWrite,
4111        )
4112        .unwrap();
4113
4114        // A source crop exceeding the source bounds is rejected.
4115        let crop = Crop::new().with_source(Some(Region::new(50, 50, 60, 60)));
4116        assert!(matches!(
4117            crop.check_crop_dyn(&src, &dst),
4118            Err(Error::CropInvalid(_))
4119        ));
4120
4121        // A source crop within bounds is valid.
4122        let crop = Crop::new().with_source(Some(Region::new(0, 0, 10, 10)));
4123        assert!(crop.check_crop_dyn(&src, &dst).is_ok());
4124
4125        // Letterbox is always valid — placement is computed within the dst.
4126        assert!(Crop::letterbox([0, 0, 0, 255])
4127            .check_crop_dyn(&src, &dst)
4128            .is_ok());
4129    }
4130
4131    #[test]
4132    fn test_invalid_tensor_format() -> Result<(), Error> {
4133        // 4D tensor cannot be set to a 3-channel pixel format
4134        let mut tensor = Tensor::<u8>::new(&[720, 1280, 4, 1], None, None)?;
4135        let result = tensor.set_format(PixelFormat::Rgb);
4136        assert!(result.is_err(), "4D tensor should reject set_format");
4137
4138        // Tensor with wrong channel count for the format
4139        let mut tensor = Tensor::<u8>::new(&[720, 1280, 4], None, None)?;
4140        let result = tensor.set_format(PixelFormat::Rgb);
4141        assert!(result.is_err(), "4-channel tensor should reject RGB format");
4142
4143        Ok(())
4144    }
4145
4146    #[test]
4147    fn test_invalid_image_file() -> Result<(), Error> {
4148        let result = crate::load_image_test_helper(&[123; 5000], None, None);
4149        assert!(
4150            matches!(result, Err(Error::Codec(_))),
4151            "unrecognised bytes should surface as Error::Codec, got {result:?}"
4152        );
4153        Ok(())
4154    }
4155
4156    #[test]
4157    fn test_invalid_jpeg_format() -> Result<(), Error> {
4158        let result = crate::load_image_test_helper(&[123; 5000], Some(PixelFormat::Yuyv), None);
4159        // YUYV is not a valid decode target; peek_info fails before the magic-
4160        // bytes check, so the precise variant depends on which error fires first.
4161        assert!(
4162            matches!(result, Err(Error::Codec(_))),
4163            "Yuyv target with garbage bytes should surface as Error::Codec, got {result:?}"
4164        );
4165        Ok(())
4166    }
4167
4168    #[test]
4169    fn test_load_resize_save() {
4170        let file = edgefirst_bench::testdata::read("zidane.jpg");
4171        let img = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4172        assert_eq!(img.width(), Some(1280));
4173        assert_eq!(img.height(), Some(720));
4174
4175        let dst = TensorDyn::image(
4176            640,
4177            360,
4178            PixelFormat::Rgba,
4179            DType::U8,
4180            None,
4181            edgefirst_tensor::CpuAccess::ReadWrite,
4182        )
4183        .unwrap();
4184        let mut converter = CPUProcessor::new();
4185        let (result, _img, dst) = convert_img(
4186            &mut converter,
4187            img,
4188            dst,
4189            Rotation::None,
4190            Flip::None,
4191            Crop::no_crop(),
4192        );
4193        result.unwrap();
4194        assert_eq!(dst.width(), Some(640));
4195        assert_eq!(dst.height(), Some(360));
4196
4197        crate::save_jpeg(&dst, "zidane_resized.jpg", 80).unwrap();
4198
4199        let file = std::fs::read("zidane_resized.jpg").unwrap();
4200        // With `format: None` the helper returns the source's native format.
4201        // The codec now decodes colour JPEGs to NV12 (was RGB previously).
4202        let img = crate::load_image_test_helper(&file, None, None).unwrap();
4203        assert_eq!(img.width(), Some(640));
4204        assert_eq!(img.height(), Some(360));
4205        assert_eq!(img.format().unwrap(), PixelFormat::Nv12);
4206    }
4207
4208    #[test]
4209    fn test_from_tensor_planar() -> Result<(), Error> {
4210        let mut tensor = Tensor::new(&[3, 720, 1280], None, None)?;
4211        tensor
4212            .map()?
4213            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.8bps"));
4214        let planar = {
4215            tensor
4216                .set_format(PixelFormat::PlanarRgb)
4217                .map_err(|e| crate::Error::Internal(e.to_string()))?;
4218            TensorDyn::from(tensor)
4219        };
4220
4221        let rbga = load_bytes_to_tensor(
4222            1280,
4223            720,
4224            PixelFormat::Rgba,
4225            None,
4226            &edgefirst_bench::testdata::read("camera720p.rgba"),
4227        )?;
4228        compare_images_convert_to_rgb(&planar, &rbga, 0.98, function!());
4229
4230        Ok(())
4231    }
4232
4233    #[test]
4234    fn test_from_tensor_invalid_format() {
4235        // PixelFormat::from_fourcc_str returns None for unknown FourCC codes.
4236        // Since there's no "TEST" pixel format, this validates graceful handling.
4237        assert!(PixelFormat::from_fourcc(u32::from_le_bytes(*b"TEST")).is_none());
4238    }
4239
4240    #[test]
4241    #[should_panic(expected = "Failed to save planar RGB image")]
4242    fn test_save_planar() {
4243        let planar_img = load_bytes_to_tensor(
4244            1280,
4245            720,
4246            PixelFormat::PlanarRgb,
4247            None,
4248            &edgefirst_bench::testdata::read("camera720p.8bps"),
4249        )
4250        .unwrap();
4251
4252        let save_path = "/tmp/planar_rgb.jpg";
4253        crate::save_jpeg(&planar_img, save_path, 90).expect("Failed to save planar RGB image");
4254    }
4255
4256    #[test]
4257    #[should_panic(expected = "Failed to save YUYV image")]
4258    fn test_save_yuyv() {
4259        let planar_img = load_bytes_to_tensor(
4260            1280,
4261            720,
4262            PixelFormat::Yuyv,
4263            None,
4264            &edgefirst_bench::testdata::read("camera720p.yuyv"),
4265        )
4266        .unwrap();
4267
4268        let save_path = "/tmp/yuyv.jpg";
4269        crate::save_jpeg(&planar_img, save_path, 90).expect("Failed to save YUYV image");
4270    }
4271
4272    #[test]
4273    fn test_rotation_angle() {
4274        assert_eq!(Rotation::from_degrees_clockwise(0), Rotation::None);
4275        assert_eq!(Rotation::from_degrees_clockwise(90), Rotation::Clockwise90);
4276        assert_eq!(Rotation::from_degrees_clockwise(180), Rotation::Rotate180);
4277        assert_eq!(
4278            Rotation::from_degrees_clockwise(270),
4279            Rotation::CounterClockwise90
4280        );
4281        assert_eq!(Rotation::from_degrees_clockwise(360), Rotation::None);
4282        assert_eq!(Rotation::from_degrees_clockwise(450), Rotation::Clockwise90);
4283        assert_eq!(Rotation::from_degrees_clockwise(540), Rotation::Rotate180);
4284        assert_eq!(
4285            Rotation::from_degrees_clockwise(630),
4286            Rotation::CounterClockwise90
4287        );
4288    }
4289
4290    #[test]
4291    #[should_panic(expected = "rotation angle is not a multiple of 90")]
4292    fn test_rotation_angle_panic() {
4293        Rotation::from_degrees_clockwise(361);
4294    }
4295
4296    #[test]
4297    fn test_disable_env_var() -> Result<(), Error> {
4298        // Acquire the env-var mutex for the entire test body so we never race
4299        // with test_force_backend_* or test_draw_proto_masks_no_cpu_returns_error.
4300        let _lock = acquire_env_lock();
4301
4302        // Snapshot ALL env vars we might touch so the RAII guard restores them
4303        // on exit (even on panic), preventing env-var poisoning of other tests.
4304        let _guard = EnvGuard::snapshot(&[
4305            "EDGEFIRST_FORCE_BACKEND",
4306            "EDGEFIRST_DISABLE_GL",
4307            "EDGEFIRST_DISABLE_G2D",
4308            "EDGEFIRST_DISABLE_CPU",
4309        ]);
4310
4311        // EDGEFIRST_FORCE_BACKEND takes precedence over EDGEFIRST_DISABLE_*,
4312        // so clear it for the duration of this test.
4313        unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") };
4314
4315        #[cfg(target_os = "linux")]
4316        {
4317            unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
4318            let converter = ImageProcessor::new()?;
4319            assert!(converter.g2d.is_none());
4320            unsafe { std::env::remove_var("EDGEFIRST_DISABLE_G2D") };
4321        }
4322
4323        #[cfg(any(target_os = "linux", target_os = "windows"))]
4324        #[cfg(feature = "opengl")]
4325        {
4326            unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
4327            let converter = ImageProcessor::new()?;
4328            assert!(converter.opengl.is_none());
4329            unsafe { std::env::remove_var("EDGEFIRST_DISABLE_GL") };
4330        }
4331
4332        unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
4333        let converter = ImageProcessor::new()?;
4334        assert!(converter.cpu.is_none());
4335        unsafe { std::env::remove_var("EDGEFIRST_DISABLE_CPU") };
4336
4337        // Disable everything — convert must return NoConverter.
4338        unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
4339        unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
4340        unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
4341        let mut converter = ImageProcessor::new()?;
4342
4343        let src = TensorDyn::image(
4344            1280,
4345            720,
4346            PixelFormat::Rgba,
4347            DType::U8,
4348            None,
4349            edgefirst_tensor::CpuAccess::ReadWrite,
4350        )?;
4351        let dst = TensorDyn::image(
4352            640,
4353            360,
4354            PixelFormat::Rgba,
4355            DType::U8,
4356            None,
4357            edgefirst_tensor::CpuAccess::ReadWrite,
4358        )?;
4359        let (result, _src, _dst) = convert_img(
4360            &mut converter,
4361            src,
4362            dst,
4363            Rotation::None,
4364            Flip::None,
4365            Crop::no_crop(),
4366        );
4367        assert!(matches!(result, Err(Error::NoConverter)));
4368        // _guard restores all env vars on drop.
4369        Ok(())
4370    }
4371
4372    #[test]
4373    fn test_unsupported_conversion() {
4374        let src = TensorDyn::image(
4375            1280,
4376            720,
4377            PixelFormat::Nv12,
4378            DType::U8,
4379            None,
4380            edgefirst_tensor::CpuAccess::ReadWrite,
4381        )
4382        .unwrap();
4383        let dst = TensorDyn::image(
4384            640,
4385            360,
4386            PixelFormat::Nv12,
4387            DType::U8,
4388            None,
4389            edgefirst_tensor::CpuAccess::ReadWrite,
4390        )
4391        .unwrap();
4392        let mut converter = ImageProcessor::new().unwrap();
4393        let (result, _src, _dst) = convert_img(
4394            &mut converter,
4395            src,
4396            dst,
4397            Rotation::None,
4398            Flip::None,
4399            Crop::no_crop(),
4400        );
4401        log::debug!("result: {:?}", result);
4402        assert!(matches!(
4403            result,
4404            Err(Error::NotSupported(e)) if e.starts_with("Conversion from NV12 to NV12")
4405        ));
4406    }
4407
4408    #[test]
4409    fn test_load_grey() {
4410        // A single-component (greyscale) JPEG decodes to its native GREY
4411        // format, which has no even-dimension constraint, so the 1024×681
4412        // `grey.jpg` loads and converts to RGBA successfully.
4413        let grey_img = crate::load_image_test_helper(
4414            &edgefirst_bench::testdata::read("grey.jpg"),
4415            Some(PixelFormat::Rgba),
4416            None,
4417        )
4418        .unwrap();
4419        assert_eq!(grey_img.width(), Some(1024));
4420        assert_eq!(grey_img.height(), Some(681));
4421
4422        // `grey-rgb.jpg` holds the same grey content but is encoded as a
4423        // 3-component (colour) JPEG, so the codec decodes it to native NV12.
4424        // Its 1024×681 dimensions have an odd height; NV12 now represents odd
4425        // dimensions via the `H + ceil(H/2)` combined-plane height, so the
4426        // decode succeeds and converts to RGBA at the true dimensions.
4427        let grey_but_rgb = crate::load_image_test_helper(
4428            &edgefirst_bench::testdata::read("grey-rgb.jpg"),
4429            Some(PixelFormat::Rgba),
4430            None,
4431        )
4432        .expect("odd-height colour JPEG should decode to NV12 and convert to RGBA");
4433        assert_eq!(grey_but_rgb.width(), Some(1024));
4434        assert_eq!(grey_but_rgb.height(), Some(681));
4435    }
4436
4437    #[test]
4438    fn test_new_nv12() {
4439        let nv12 = TensorDyn::image(
4440            1280,
4441            720,
4442            PixelFormat::Nv12,
4443            DType::U8,
4444            None,
4445            edgefirst_tensor::CpuAccess::ReadWrite,
4446        )
4447        .unwrap();
4448        assert_eq!(nv12.height(), Some(720));
4449        assert_eq!(nv12.width(), Some(1280));
4450        assert_eq!(nv12.format().unwrap(), PixelFormat::Nv12);
4451        // PixelFormat::Nv12.channels() returns 1 (luma plane channel count)
4452        assert_eq!(nv12.format().unwrap().channels(), 1);
4453        assert!(nv12.format().is_some_and(
4454            |f| f.layout() == PixelLayout::Planar || f.layout() == PixelLayout::SemiPlanar
4455        ))
4456    }
4457
4458    #[test]
4459    #[cfg(any(target_os = "linux", target_os = "windows"))]
4460    fn test_new_image_converter() {
4461        let dst_width = 640;
4462        let dst_height = 360;
4463        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4464        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4465
4466        let mut converter = ImageProcessor::new().unwrap();
4467        let converter_dst = converter
4468            .create_image(
4469                dst_width,
4470                dst_height,
4471                PixelFormat::Rgba,
4472                DType::U8,
4473                None,
4474                edgefirst_tensor::CpuAccess::ReadWrite,
4475            )
4476            .unwrap();
4477        let (result, src, converter_dst) = convert_img(
4478            &mut converter,
4479            src,
4480            converter_dst,
4481            Rotation::None,
4482            Flip::None,
4483            Crop::no_crop(),
4484        );
4485        result.unwrap();
4486
4487        let cpu_dst = TensorDyn::image(
4488            dst_width,
4489            dst_height,
4490            PixelFormat::Rgba,
4491            DType::U8,
4492            None,
4493            edgefirst_tensor::CpuAccess::ReadWrite,
4494        )
4495        .unwrap();
4496        let mut cpu_converter = CPUProcessor::new();
4497        let (result, _src, cpu_dst) = convert_img(
4498            &mut cpu_converter,
4499            src,
4500            cpu_dst,
4501            Rotation::None,
4502            Flip::None,
4503            Crop::no_crop(),
4504        );
4505        result.unwrap();
4506
4507        compare_images(&converter_dst, &cpu_dst, 0.98, function!());
4508    }
4509
4510    #[test]
4511    #[cfg(any(target_os = "linux", target_os = "windows"))]
4512    fn test_create_image_dtype_i8() {
4513        let mut converter = ImageProcessor::new().unwrap();
4514
4515        // I8 image should allocate successfully via create_image
4516        let dst = converter
4517            .create_image(
4518                320,
4519                240,
4520                PixelFormat::Rgb,
4521                DType::I8,
4522                None,
4523                edgefirst_tensor::CpuAccess::ReadWrite,
4524            )
4525            .unwrap();
4526        assert_eq!(dst.dtype(), DType::I8);
4527        assert!(dst.width() == Some(320));
4528        assert!(dst.height() == Some(240));
4529        assert_eq!(dst.format(), Some(PixelFormat::Rgb));
4530
4531        // U8 for comparison
4532        let dst_u8 = converter
4533            .create_image(
4534                320,
4535                240,
4536                PixelFormat::Rgb,
4537                DType::U8,
4538                None,
4539                edgefirst_tensor::CpuAccess::ReadWrite,
4540            )
4541            .unwrap();
4542        assert_eq!(dst_u8.dtype(), DType::U8);
4543
4544        // Convert into I8 dst should succeed
4545        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4546        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4547        let mut dst_i8 = converter
4548            .create_image(
4549                320,
4550                240,
4551                PixelFormat::Rgb,
4552                DType::I8,
4553                None,
4554                edgefirst_tensor::CpuAccess::ReadWrite,
4555            )
4556            .unwrap();
4557        converter
4558            .convert(
4559                &src,
4560                &mut dst_i8,
4561                Rotation::None,
4562                Flip::None,
4563                Crop::no_crop(),
4564            )
4565            .unwrap();
4566    }
4567
4568    #[test]
4569    #[cfg(target_os = "linux")]
4570    fn test_create_image_nv12_dma_non_aligned_width() {
4571        // create_image is fully stride-aware: a non-64-aligned NV12 DMA tensor
4572        // may legitimately carry a GPU-pitch-padded row stride — that is the
4573        // intended behaviour, not a bug. Verify the logical geometry is preserved
4574        // for any width and that a reported stride is a valid (>= logical)
4575        // padding, rather than asserting the absence of a stride.
4576        let converter = ImageProcessor::new().unwrap();
4577
4578        // 100 is intentionally not a multiple of 64 (the GPU pitch alignment).
4579        let result = converter.create_image(
4580            100,
4581            64,
4582            PixelFormat::Nv12,
4583            DType::U8,
4584            Some(TensorMemory::DmaBuf),
4585            edgefirst_tensor::CpuAccess::ReadWrite,
4586        );
4587
4588        match result {
4589            Ok(img) => {
4590                assert_eq!(img.width(), Some(100));
4591                assert_eq!(img.height(), Some(64));
4592                assert_eq!(img.format(), Some(PixelFormat::Nv12));
4593                if let Some(stride) = img.row_stride() {
4594                    assert!(
4595                        stride >= 100,
4596                        "NV12 row_stride {stride} must be >= the logical width (100)",
4597                    );
4598                }
4599            }
4600            Err(e) => {
4601                // Skip cleanly on hosts without a dma-heap.
4602                eprintln!("SKIPPED: create_image NV12 DMA non-aligned width: {e}");
4603            }
4604        }
4605    }
4606
4607    #[test]
4608    #[ignore] // Hangs on desktop platforms where DMA-buf is unavailable and PBO
4609              // fallback triggers a GPU driver hang during SHM→texture upload (e.g.,
4610              // NVIDIA without /dev/dma_heap permissions). Works on embedded targets.
4611    fn test_crop_skip() {
4612        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4613        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4614
4615        let mut converter = ImageProcessor::new().unwrap();
4616        let converter_dst = converter
4617            .create_image(
4618                1280,
4619                720,
4620                PixelFormat::Rgba,
4621                DType::U8,
4622                None,
4623                edgefirst_tensor::CpuAccess::ReadWrite,
4624            )
4625            .unwrap();
4626        let crop = Crop::new().with_source(Some(Region::new(0, 0, 640, 640)));
4627        let (result, src, converter_dst) = convert_img(
4628            &mut converter,
4629            src,
4630            converter_dst,
4631            Rotation::None,
4632            Flip::None,
4633            crop,
4634        );
4635        result.unwrap();
4636
4637        let cpu_dst = TensorDyn::image(
4638            1280,
4639            720,
4640            PixelFormat::Rgba,
4641            DType::U8,
4642            None,
4643            edgefirst_tensor::CpuAccess::ReadWrite,
4644        )
4645        .unwrap();
4646        let mut cpu_converter = CPUProcessor::new();
4647        let (result, _src, cpu_dst) = convert_img(
4648            &mut cpu_converter,
4649            src,
4650            cpu_dst,
4651            Rotation::None,
4652            Flip::None,
4653            crop,
4654        );
4655        result.unwrap();
4656
4657        compare_images(&converter_dst, &cpu_dst, 0.99999, function!());
4658    }
4659
4660    #[test]
4661    fn test_invalid_pixel_format() {
4662        // PixelFormat::from_fourcc returns None for unknown formats,
4663        // so TensorDyn::image cannot be called with an invalid format.
4664        assert!(PixelFormat::from_fourcc(u32::from_le_bytes(*b"TEST")).is_none());
4665    }
4666
4667    // Helper function to check if G2D library is available (Linux/i.MX8 only)
4668    #[cfg(target_os = "linux")]
4669    static G2D_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
4670
4671    #[cfg(target_os = "linux")]
4672    fn is_g2d_available() -> bool {
4673        *G2D_AVAILABLE.get_or_init(|| G2DProcessor::new().is_ok())
4674    }
4675
4676    // Every GL platform: Linux EGL, ANGLE/Metal (macOS, iOS), Android EGL,
4677    // ANGLE/D3D11 (Windows). `GLProcessorThreaded::new` fails cleanly where
4678    // the driver or ANGLE is missing, so the `test_opengl_*` callers below
4679    // self-skip there (and `HAL_TEST_REQUIRE_GL=1` lanes catch that via
4680    // `gl_backend_available_canary`).
4681    #[cfg(any(
4682        target_os = "linux",
4683        target_os = "macos",
4684        target_os = "ios",
4685        target_os = "android",
4686        target_os = "windows"
4687    ))]
4688    #[cfg(feature = "opengl")]
4689    static GL_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
4690
4691    #[cfg(any(
4692        target_os = "linux",
4693        target_os = "macos",
4694        target_os = "ios",
4695        target_os = "android",
4696        target_os = "windows"
4697    ))]
4698    #[cfg(feature = "opengl")]
4699    // Helper function to check if OpenGL is available
4700    fn is_opengl_available() -> bool {
4701        *GL_AVAILABLE.get_or_init(|| GLProcessorThreaded::new(None).is_ok())
4702    }
4703
4704    /// CI canary: fails the lane when the GL backend cannot initialize.
4705    ///
4706    /// Every GL test in this suite self-skips when the backend is
4707    /// unavailable — correct for developer machines, but it means a broken
4708    /// CI GL stack (e.g. the macOS ANGLE re-sign step regressing, the exact
4709    /// failure mode documented in the workflow) ships an untested GL
4710    /// backend behind a green lane. Gated on `HAL_TEST_REQUIRE_GL=1`, set
4711    /// only by CI jobs that install a working GL stack; local runs without
4712    /// one pass trivially. On macOS it additionally requires
4713    /// `HAL_TEST_ALLOW_DLOPEN_ANGLE`, so coverage pass 1 (unsigned
4714    /// binaries, dlopen gate closed) skips it and pass 2 (signed) enforces.
4715    #[test]
4716    #[cfg(feature = "opengl")]
4717    fn gl_backend_available_canary() {
4718        let require_gl = std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1");
4719        if !require_gl {
4720            eprintln!(
4721                "SKIPPED: {} — HAL_TEST_REQUIRE_GL is not set to 1",
4722                function!()
4723            );
4724            return;
4725        }
4726        #[cfg(target_os = "macos")]
4727        if std::env::var_os("HAL_TEST_ALLOW_DLOPEN_ANGLE").is_none() {
4728            eprintln!(
4729                "SKIPPED: {} — ANGLE dlopen gate closed (coverage pass 1)",
4730                function!()
4731            );
4732            return;
4733        }
4734        GLProcessorThreaded::new(None).expect(
4735            "HAL_TEST_REQUIRE_GL=1 but the GL backend failed to initialize — \
4736             check the ANGLE install/re-sign step and binary entitlements \
4737             (macOS) or the EGL stack (Linux)",
4738        );
4739    }
4740
4741    #[test]
4742    fn test_load_jpeg_with_exif() {
4743        use edgefirst_codec::peek_info;
4744
4745        // The migrated codec NEVER applies EXIF orientation: it decodes to the
4746        // source's native (un-rotated) dimensions and reports the rotation via
4747        // ImageInfo. `zidane_rotated_exif.jpg` carries EXIF orientation 6
4748        // (90° clockwise) over a 1280×720 frame.
4749        let file = edgefirst_bench::testdata::read("zidane_rotated_exif.jpg").to_vec();
4750        let info = peek_info(&file).unwrap();
4751        assert_eq!(info.rotation_degrees, 90);
4752        assert!(!info.flip_horizontal);
4753
4754        let loaded = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4755        // Native (un-rotated) dimensions — the decode does not rotate.
4756        assert_eq!(loaded.width(), Some(1280));
4757        assert_eq!(loaded.height(), Some(720));
4758
4759        // Applying the reported rotation downstream reproduces the upright
4760        // image: it matches `zidane.jpg` rotated by the same 90° clockwise.
4761        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4762        let cpu_src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4763
4764        let rotation = Rotation::from_degrees_clockwise(info.rotation_degrees as usize);
4765        let (dst_width, dst_height) = (cpu_src.height().unwrap(), cpu_src.width().unwrap());
4766
4767        let cpu_dst = TensorDyn::image(
4768            dst_width,
4769            dst_height,
4770            PixelFormat::Rgba,
4771            DType::U8,
4772            None,
4773            edgefirst_tensor::CpuAccess::ReadWrite,
4774        )
4775        .unwrap();
4776        let mut cpu_converter = CPUProcessor::new();
4777
4778        // Rotate the native-orientation `loaded` frame and the native `zidane`
4779        // frame by the same reported rotation; the results must agree.
4780        let loaded_rotated = TensorDyn::image(
4781            dst_width,
4782            dst_height,
4783            PixelFormat::Rgba,
4784            DType::U8,
4785            None,
4786            edgefirst_tensor::CpuAccess::ReadWrite,
4787        )
4788        .unwrap();
4789        let (r0, _loaded, loaded_rotated) = convert_img(
4790            &mut cpu_converter,
4791            loaded,
4792            loaded_rotated,
4793            rotation,
4794            Flip::None,
4795            Crop::no_crop(),
4796        );
4797        r0.unwrap();
4798
4799        let (result, _cpu_src, cpu_dst) = convert_img(
4800            &mut cpu_converter,
4801            cpu_src,
4802            cpu_dst,
4803            rotation,
4804            Flip::None,
4805            Crop::no_crop(),
4806        );
4807        result.unwrap();
4808
4809        compare_images(&loaded_rotated, &cpu_dst, 0.98, function!());
4810    }
4811
4812    #[test]
4813    fn test_load_png_with_exif() {
4814        use edgefirst_codec::peek_info;
4815
4816        // PNGs also report EXIF orientation without applying it.
4817        // `zidane_rotated_exif_180.png` carries EXIF orientation 3 (180°).
4818        let file = edgefirst_bench::testdata::read("zidane_rotated_exif_180.png").to_vec();
4819        let info = peek_info(&file).unwrap();
4820        assert_eq!(info.rotation_degrees, 180);
4821        assert!(!info.flip_horizontal);
4822
4823        let loaded = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4824        // Native (un-rotated) dimensions — PNG decodes upright as authored.
4825        assert_eq!(loaded.height(), Some(720));
4826        assert_eq!(loaded.width(), Some(1280));
4827
4828        // The PNG fixture stores upright `zidane` pixels tagged with a 180°
4829        // EXIF orientation. Because the codec no longer applies the rotation,
4830        // the decoded pixels match `zidane.jpg` directly (no convert needed).
4831        // Re-applying the reported rotation to both must still agree.
4832        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4833        let cpu_src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4834
4835        let rotation = Rotation::from_degrees_clockwise(info.rotation_degrees as usize);
4836        let cpu_dst = TensorDyn::image(
4837            1280,
4838            720,
4839            PixelFormat::Rgba,
4840            DType::U8,
4841            None,
4842            edgefirst_tensor::CpuAccess::ReadWrite,
4843        )
4844        .unwrap();
4845        let mut cpu_converter = CPUProcessor::new();
4846
4847        let (result, _cpu_src, cpu_dst) = convert_img(
4848            &mut cpu_converter,
4849            cpu_src,
4850            cpu_dst,
4851            rotation,
4852            Flip::None,
4853            Crop::no_crop(),
4854        );
4855        result.unwrap();
4856
4857        // Rotate the decoded PNG by the same reported angle so both frames are
4858        // in the same (180°-rotated) orientation before comparing.
4859        let loaded_rotated = TensorDyn::image(
4860            1280,
4861            720,
4862            PixelFormat::Rgba,
4863            DType::U8,
4864            None,
4865            edgefirst_tensor::CpuAccess::ReadWrite,
4866        )
4867        .unwrap();
4868        let (r0, _loaded, loaded_rotated) = convert_img(
4869            &mut cpu_converter,
4870            loaded,
4871            loaded_rotated,
4872            rotation,
4873            Flip::None,
4874            Crop::no_crop(),
4875        );
4876        r0.unwrap();
4877
4878        // Threshold 0.95 (was 0.98): `loaded` comes from a lossless PNG decode
4879        // while `cpu_src` (zidane.jpg) now decodes through native NV12 (chroma
4880        // subsampling) before the RGBA conversion, so the two paths differ by a
4881        // couple of percent versus the old direct-RGB JPEG decode.
4882        compare_images(&loaded_rotated, &cpu_dst, 0.95, function!());
4883    }
4884
4885    /// Synthesise an RGB JPEG with a deterministic pattern at `(width, height)`
4886    /// using the workspace's `jpeg-encoder` crate (the `image` crate is
4887    /// compiled without its JPEG feature). Used to exercise the decoder /
4888    /// pitch-padding paths for arbitrary dimensions without having to bundle
4889    /// a fixture file per test size.
4890    #[cfg(target_os = "linux")]
4891    fn make_rgb_jpeg(width: u32, height: u32) -> Vec<u8> {
4892        let mut bytes = Vec::with_capacity((width * height * 3) as usize);
4893        for y in 0..height {
4894            for x in 0..width {
4895                bytes.push(((x + y) & 0xFF) as u8);
4896                bytes.push(((x.wrapping_mul(3)) & 0xFF) as u8);
4897                bytes.push(((y.wrapping_mul(5)) & 0xFF) as u8);
4898            }
4899        }
4900        let mut out = Vec::new();
4901        let encoder = jpeg_encoder::Encoder::new(&mut out, 85);
4902        encoder
4903            .encode(
4904                &bytes,
4905                width as u16,
4906                height as u16,
4907                jpeg_encoder::ColorType::Rgb,
4908            )
4909            .expect("jpeg-encoder must succeed on trivial input");
4910        out
4911    }
4912
4913    /// End-to-end: a 375×333 RGBA JPEG (width NOT divisible by 4) loaded
4914    /// via the pitch-padded DMA path and letterboxed through the GL
4915    /// backend must produce correct output. Before the Rgba/Bgra
4916    /// width%4 relaxation in `DmaImportAttrs::from_tensor`, this case
4917    /// failed the pre-check and forced a CPU texture upload fallback;
4918    /// with the relaxation, EGL import succeeds at the driver level and
4919    /// the GL fast path runs. Output correctness is checked against a
4920    /// CPU reference (convert ran with `EDGEFIRST_FORCE_BACKEND=cpu`).
4921    #[test]
4922    #[cfg(target_os = "linux")]
4923    #[cfg(feature = "opengl")]
4924    fn test_convert_rgba_non_4_aligned_width_end_to_end() {
4925        use edgefirst_tensor::is_dma_available;
4926        if !is_dma_available() {
4927            eprintln!(
4928                "SKIPPED: test_convert_rgba_non_4_aligned_width_end_to_end — DMA not available"
4929            );
4930            return;
4931        }
4932        // 375 is the canonical failure width from dataset loaders —
4933        // 375 * 4 = 1500 bytes/row, pitch-padded to 1536. Width%4 = 3,
4934        // so the old pre-check rejected it; new code accepts it.
4935        let jpeg = make_rgb_jpeg(375, 333);
4936        let src_gl = crate::load_image_test_helper(&jpeg, Some(PixelFormat::Rgba), None).unwrap();
4937        assert_eq!(src_gl.width(), Some(375));
4938        // Row stride must still be pitch-padded (separate concern from width).
4939        let stride = src_gl.row_stride().unwrap();
4940        assert_eq!(stride, 1536, "expected padded pitch 1536, got {stride}");
4941
4942        // GL-backed convert into a pitch-aligned 640×640 Rgba dest.
4943        let mut gl_proc = ImageProcessor::new().unwrap();
4944        let gl_dst = gl_proc
4945            .create_image(
4946                640,
4947                640,
4948                PixelFormat::Rgba,
4949                DType::U8,
4950                None,
4951                edgefirst_tensor::CpuAccess::ReadWrite,
4952            )
4953            .unwrap();
4954        let (r_gl, _src_gl, gl_dst) = convert_img(
4955            &mut gl_proc,
4956            src_gl,
4957            gl_dst,
4958            Rotation::None,
4959            Flip::None,
4960            Crop::no_crop(),
4961        );
4962        r_gl.expect("GL-backed convert must succeed for 375x333 Rgba src");
4963
4964        // CPU reference via a fresh load so the two paths start from
4965        // byte-identical inputs. `with_config(backend=Cpu)` forces the
4966        // CPU-only processor regardless of which backends the host has
4967        // available.
4968        let src_cpu =
4969            crate::load_image_test_helper(&jpeg, Some(PixelFormat::Rgba), Some(TensorMemory::Mem))
4970                .unwrap();
4971        let mut cpu_proc = ImageProcessor::with_config(ImageProcessorConfig {
4972            backend: ComputeBackend::Cpu,
4973            ..Default::default()
4974        })
4975        .unwrap();
4976        let cpu_dst = TensorDyn::image(
4977            640,
4978            640,
4979            PixelFormat::Rgba,
4980            DType::U8,
4981            Some(TensorMemory::Mem),
4982            edgefirst_tensor::CpuAccess::ReadWrite,
4983        )
4984        .unwrap();
4985        let (r_cpu, _src_cpu, cpu_dst) = convert_img(
4986            &mut cpu_proc,
4987            src_cpu,
4988            cpu_dst,
4989            Rotation::None,
4990            Flip::None,
4991            Crop::no_crop(),
4992        );
4993        r_cpu.unwrap();
4994
4995        // Structural similarity: the GL path may have gone through EGL
4996        // import OR fallen back to CPU texture upload — either way, the
4997        // output must match the CPU reference closely.
4998        compare_images(&gl_dst, &cpu_dst, 0.95, function!());
4999    }
5000
5001    /// Regression lock: loading a JPEG at a non-64-aligned RGBA pitch (e.g.
5002    /// 500×333 → natural pitch 2000, needs to be padded to 2048) must go
5003    /// through `image_with_stride` and set `row_stride()` / `effective_row_stride()`
5004    /// to the padded value. The earlier pitch-padding commit fixed this in
5005    /// `load_jpeg`; a regression would surface as `row_stride == None` or
5006    /// `effective_row_stride == 2000`.
5007    #[test]
5008    #[cfg(target_os = "linux")]
5009    fn test_load_jpeg_rgba_non_aligned_pitch_padded_dma() {
5010        use edgefirst_tensor::is_dma_available;
5011        if !is_dma_available() {
5012            eprintln!(
5013                "SKIPPED: test_load_jpeg_rgba_non_aligned_pitch_padded_dma — DMA not available"
5014            );
5015            return;
5016        }
5017        // Widths that force a non-64-aligned natural RGBA pitch. All three
5018        // are divisible by 4 so the EGL width-alignment pre-check passes.
5019        // The pitch-padding fix is what makes these importable at all.
5020        for &w in &[500u32, 612, 428] {
5021            let jpeg = make_rgb_jpeg(w, 333);
5022            let loaded =
5023                crate::load_image_test_helper(&jpeg, Some(PixelFormat::Rgba), None).unwrap();
5024            let natural = (w as usize) * 4;
5025            let aligned = crate::align_pitch_bytes_to_gpu_alignment(natural).unwrap();
5026            assert!(
5027                aligned > natural,
5028                "test sanity: width {w} should be unaligned"
5029            );
5030            let stride = loaded
5031                .row_stride()
5032                .expect("padded DMA path must set an explicit row_stride — regression if None");
5033            assert_eq!(
5034                stride, aligned,
5035                "width {w}: expected padded stride {aligned}, got {stride} \
5036                 (regression: pitch-padding branch skipped?)"
5037            );
5038            let eff = loaded.effective_row_stride().unwrap();
5039            assert_eq!(
5040                eff, aligned,
5041                "effective_row_stride must match stored stride"
5042            );
5043            assert_eq!(loaded.width(), Some(w as usize));
5044            assert_eq!(loaded.height(), Some(333));
5045        }
5046    }
5047
5048    /// `padded_dma_pitch_for` must respect the caller's memory choice and
5049    /// must NOT route into the pitch-padded DMA path when the caller left
5050    /// the choice to the allocator (`None`) but DMA is unavailable on the
5051    /// host. The padded path requires `image_with_stride`, which always
5052    /// allocates DMA — taking it on a system without `/dev/dma_heap`
5053    /// would convert a normally-working image load into a hard failure
5054    /// (since `Tensor::image(..., None)` would have fallen back to
5055    /// SHM/Mem).
5056    #[test]
5057    #[cfg(target_os = "linux")]
5058    fn test_padded_dma_pitch_for_respects_memory_choice() {
5059        use edgefirst_tensor::{is_dma_available, TensorMemory};
5060
5061        // 500×4 = 2000 → padded to 2048 by GPU alignment. Use it for
5062        // every case so any "no padding" answer is unambiguous.
5063        let unaligned_w = 500;
5064
5065        // Caller asks for Mem / Shm: never pad, regardless of DMA.
5066        assert_eq!(
5067            crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &Some(TensorMemory::Mem),),
5068            None,
5069            "Mem must never trigger DMA padding"
5070        );
5071        assert_eq!(
5072            crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &Some(TensorMemory::Shm),),
5073            None,
5074            "Shm must never trigger DMA padding"
5075        );
5076
5077        // Caller explicitly asks for DMA: always pad if width needs it.
5078        // Even if the runtime can't actually allocate DMA, the caller
5079        // owns that decision and the resulting allocation error is
5080        // their problem, not ours.
5081        assert_eq!(
5082            crate::padded_dma_pitch_for(
5083                PixelFormat::Rgba,
5084                unaligned_w,
5085                &Some(TensorMemory::DmaBuf),
5086            ),
5087            Some(2048),
5088            "explicit Dma must pad regardless of runtime DMA availability"
5089        );
5090
5091        // Caller leaves it to the allocator: behaviour depends on
5092        // host-runtime DMA availability. This is the case the fix
5093        // guards against.
5094        let none_result = crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &None);
5095        if is_dma_available() {
5096            assert_eq!(
5097                none_result,
5098                Some(2048),
5099                "memory=None + DMA available → pad (will route through DMA)"
5100            );
5101        } else {
5102            assert_eq!(
5103                none_result, None,
5104                "memory=None + DMA unavailable → must NOT pad (would force \
5105                 image_with_stride into a DMA-only allocation that fails). \
5106                 Regression: padded_dma_pitch_for ignored is_dma_available()."
5107            );
5108        }
5109    }
5110
5111    // Synthesise a small greyscale PNG in memory at `(width, height)` with a
5112    // deterministic ramp pattern so multiple tests can cross-check output
5113    // without bundling an extra fixture file.
5114    fn make_grey_png(width: u32, height: u32) -> Vec<u8> {
5115        let mut bytes = Vec::with_capacity((width * height) as usize);
5116        for y in 0..height {
5117            for x in 0..width {
5118                bytes.push(((x + y) & 0xFF) as u8);
5119            }
5120        }
5121        let img = image::GrayImage::from_vec(width, height, bytes).unwrap();
5122        let mut buf = Vec::new();
5123        img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
5124            .unwrap();
5125        buf
5126    }
5127
5128    /// Greyscale PNG with a width that forces a pitch-misaligned natural
5129    /// row stride (612 bytes is not a multiple of the 64-byte GPU pitch
5130    /// alignment) must still load via the pitch-padded DMA path. Gated on
5131    /// DMA availability because `image_with_stride` is DMA-only.
5132    #[test]
5133    #[cfg(target_os = "linux")]
5134    fn test_load_png_grey_misaligned_width_dma() {
5135        use edgefirst_tensor::is_dma_available;
5136        if !is_dma_available() {
5137            eprintln!("SKIPPED: test_load_png_grey_misaligned_width_dma — DMA not available");
5138            return;
5139        }
5140        let png = make_grey_png(612, 388);
5141        let loaded = crate::load_image_test_helper(&png, Some(PixelFormat::Grey), None).unwrap();
5142        assert_eq!(loaded.width(), Some(612));
5143        assert_eq!(loaded.height(), Some(388));
5144        assert_eq!(loaded.format(), Some(PixelFormat::Grey));
5145
5146        // Round-trip pixels — natural-pitch DMA-BUFs pad the stride so we
5147        // must indirect through row_stride() rather than assume width.
5148        let map = loaded.as_typed::<u8>().unwrap().map().unwrap();
5149        let stride = loaded.row_stride().unwrap_or(612);
5150        assert!(stride >= 612);
5151        let bytes: &[u8] = &map;
5152        for y in 0..388usize {
5153            for x in 0..612usize {
5154                let expected = ((x + y) & 0xFF) as u8;
5155                let got = bytes[y * stride + x];
5156                assert_eq!(
5157                    got, expected,
5158                    "grey png mismatch at ({x},{y}): got {got} expected {expected}"
5159                );
5160            }
5161        }
5162    }
5163
5164    /// Greyscale PNG loaded with explicit Mem backing — runs on any
5165    /// platform (no DMA permission requirement) and covers the
5166    /// decoder-native Luma → Grey no-conversion path.
5167    #[test]
5168    fn test_load_png_grey_mem() {
5169        use edgefirst_tensor::TensorMemory;
5170        let png = make_grey_png(612, 100);
5171        let loaded =
5172            crate::load_image_test_helper(&png, Some(PixelFormat::Grey), Some(TensorMemory::Mem))
5173                .unwrap();
5174        assert_eq!(loaded.width(), Some(612));
5175        assert_eq!(loaded.height(), Some(100));
5176        assert_eq!(loaded.format(), Some(PixelFormat::Grey));
5177        let map = loaded.as_typed::<u8>().unwrap().map().unwrap();
5178        let bytes: &[u8] = &map;
5179        // Mem allocation uses the natural pitch — 612 bytes per row, exact.
5180        assert_eq!(bytes.len(), 612 * 100);
5181        for y in 0..100 {
5182            for x in 0..612 {
5183                assert_eq!(bytes[y * 612 + x], ((x + y) & 0xFF) as u8);
5184            }
5185        }
5186    }
5187
5188    /// Greyscale PNG decoded into RGB — exercises the decoder-colorspace
5189    /// mismatch path (Luma → Rgb via CPU converter). Uses Mem memory to
5190    /// stay portable to host-side test environments.
5191    #[test]
5192    fn test_load_png_grey_to_rgb_mem() {
5193        use edgefirst_tensor::TensorMemory;
5194        let png = make_grey_png(620, 240);
5195        let loaded =
5196            crate::load_image_test_helper(&png, Some(PixelFormat::Rgb), Some(TensorMemory::Mem))
5197                .unwrap();
5198        assert_eq!(loaded.width(), Some(620));
5199        assert_eq!(loaded.height(), Some(240));
5200        assert_eq!(loaded.format(), Some(PixelFormat::Rgb));
5201
5202        // Greyscale promoted to RGB replicates luma into each channel.
5203        let map = loaded.as_typed::<u8>().unwrap().map().unwrap();
5204        let bytes: &[u8] = &map;
5205        for (x, y) in [(0usize, 0usize), (100, 50), (619, 239)] {
5206            let expected = ((x + y) & 0xFF) as u8;
5207            let off = (y * 620 + x) * 3;
5208            assert_eq!(bytes[off], expected, "R@{x},{y}");
5209            assert_eq!(bytes[off + 1], expected, "G@{x},{y}");
5210            assert_eq!(bytes[off + 2], expected, "B@{x},{y}");
5211        }
5212    }
5213
5214    #[test]
5215    #[cfg(target_os = "linux")]
5216    fn test_g2d_resize() {
5217        if !is_g2d_available() {
5218            eprintln!("SKIPPED: test_g2d_resize - G2D library (libg2d.so.2) not available");
5219            return;
5220        }
5221        if !is_dma_available() {
5222            eprintln!(
5223                "SKIPPED: test_g2d_resize - DMA memory allocation not available (permission denied or no DMA-BUF support)"
5224            );
5225            return;
5226        }
5227
5228        let dst_width = 640;
5229        let dst_height = 360;
5230        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5231        let src = crate::load_image_test_helper(
5232            &file,
5233            Some(PixelFormat::Rgba),
5234            Some(TensorMemory::DmaBuf),
5235        )
5236        .unwrap();
5237
5238        let g2d_dst = TensorDyn::image(
5239            dst_width,
5240            dst_height,
5241            PixelFormat::Rgba,
5242            DType::U8,
5243            Some(TensorMemory::DmaBuf),
5244            edgefirst_tensor::CpuAccess::ReadWrite,
5245        )
5246        .unwrap();
5247        let mut g2d_converter = G2DProcessor::new().unwrap();
5248        let (result, src, g2d_dst) = convert_img(
5249            &mut g2d_converter,
5250            src,
5251            g2d_dst,
5252            Rotation::None,
5253            Flip::None,
5254            Crop::no_crop(),
5255        );
5256        result.unwrap();
5257
5258        let cpu_dst = TensorDyn::image(
5259            dst_width,
5260            dst_height,
5261            PixelFormat::Rgba,
5262            DType::U8,
5263            None,
5264            edgefirst_tensor::CpuAccess::ReadWrite,
5265        )
5266        .unwrap();
5267        let mut cpu_converter = CPUProcessor::new();
5268        let (result, _src, cpu_dst) = convert_img(
5269            &mut cpu_converter,
5270            src,
5271            cpu_dst,
5272            Rotation::None,
5273            Flip::None,
5274            Crop::no_crop(),
5275        );
5276        result.unwrap();
5277
5278        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
5279        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
5280        // the YUV-matrix delta that forced 0.95 has closed; tightened to
5281        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
5282        // structural gap not exercised by these limited-range fixtures.
5283        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
5284    }
5285
5286    #[test]
5287    #[cfg(any(
5288        target_os = "linux",
5289        target_os = "macos",
5290        target_os = "ios",
5291        target_os = "android",
5292        target_os = "windows"
5293    ))]
5294    #[cfg(feature = "opengl")]
5295    fn test_opengl_resize() {
5296        if !is_opengl_available() {
5297            eprintln!("SKIPPED: {} - OpenGL not available", function!());
5298            return;
5299        }
5300
5301        let dst_width = 640;
5302        let dst_height = 360;
5303        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5304        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5305
5306        let cpu_dst = TensorDyn::image(
5307            dst_width,
5308            dst_height,
5309            PixelFormat::Rgba,
5310            DType::U8,
5311            None,
5312            edgefirst_tensor::CpuAccess::ReadWrite,
5313        )
5314        .unwrap();
5315        let mut cpu_converter = CPUProcessor::new();
5316        let (result, src, cpu_dst) = convert_img(
5317            &mut cpu_converter,
5318            src,
5319            cpu_dst,
5320            Rotation::None,
5321            Flip::None,
5322            Crop::no_crop(),
5323        );
5324        result.unwrap();
5325
5326        let mut src = src;
5327        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
5328
5329        for _ in 0..5 {
5330            let gl_dst = TensorDyn::image(
5331                dst_width,
5332                dst_height,
5333                PixelFormat::Rgba,
5334                DType::U8,
5335                None,
5336                edgefirst_tensor::CpuAccess::ReadWrite,
5337            )
5338            .unwrap();
5339            let (result, src_back, gl_dst) = convert_img(
5340                &mut gl_converter,
5341                src,
5342                gl_dst,
5343                Rotation::None,
5344                Flip::None,
5345                Crop::no_crop(),
5346            );
5347            result.unwrap();
5348            src = src_back;
5349
5350            compare_images(&gl_dst, &cpu_dst, 0.98, function!());
5351        }
5352    }
5353
5354    #[test]
5355    #[cfg(any(
5356        target_os = "linux",
5357        target_os = "macos",
5358        target_os = "ios",
5359        target_os = "android",
5360        target_os = "windows"
5361    ))]
5362    #[cfg(feature = "opengl")]
5363    fn test_opengl_10_threads() {
5364        if !is_opengl_available() {
5365            eprintln!("SKIPPED: {} - OpenGL not available", function!());
5366            return;
5367        }
5368
5369        let handles: Vec<_> = (0..10)
5370            .map(|i| {
5371                std::thread::Builder::new()
5372                    .name(format!("Thread {i}"))
5373                    .spawn(test_opengl_resize)
5374                    .unwrap()
5375            })
5376            .collect();
5377        handles.into_iter().for_each(|h| {
5378            if let Err(e) = h.join() {
5379                std::panic::resume_unwind(e)
5380            }
5381        });
5382    }
5383
5384    #[test]
5385    #[cfg(any(
5386        target_os = "linux",
5387        target_os = "macos",
5388        target_os = "ios",
5389        target_os = "android",
5390        target_os = "windows"
5391    ))]
5392    #[cfg(feature = "opengl")]
5393    fn test_opengl_grey() {
5394        if !is_opengl_available() {
5395            eprintln!("SKIPPED: {} - OpenGL not available", function!());
5396            return;
5397        }
5398
5399        let img = crate::load_image_test_helper(
5400            &edgefirst_bench::testdata::read("grey.jpg"),
5401            Some(PixelFormat::Grey),
5402            None,
5403        )
5404        .unwrap();
5405
5406        let gl_dst = TensorDyn::image(
5407            640,
5408            640,
5409            PixelFormat::Grey,
5410            DType::U8,
5411            None,
5412            edgefirst_tensor::CpuAccess::ReadWrite,
5413        )
5414        .unwrap();
5415        let cpu_dst = TensorDyn::image(
5416            640,
5417            640,
5418            PixelFormat::Grey,
5419            DType::U8,
5420            None,
5421            edgefirst_tensor::CpuAccess::ReadWrite,
5422        )
5423        .unwrap();
5424
5425        let mut converter = CPUProcessor::new();
5426
5427        let (result, img, cpu_dst) = convert_img(
5428            &mut converter,
5429            img,
5430            cpu_dst,
5431            Rotation::None,
5432            Flip::None,
5433            Crop::no_crop(),
5434        );
5435        result.unwrap();
5436
5437        let mut gl = GLProcessorThreaded::new(None).unwrap();
5438        let (result, _img, gl_dst) = convert_img(
5439            &mut gl,
5440            img,
5441            gl_dst,
5442            Rotation::None,
5443            Flip::None,
5444            Crop::no_crop(),
5445        );
5446        result.unwrap();
5447
5448        compare_images(&gl_dst, &cpu_dst, 0.98, function!());
5449    }
5450
5451    #[test]
5452    #[cfg(target_os = "linux")]
5453    fn test_g2d_src_crop() {
5454        if !is_g2d_available() {
5455            eprintln!("SKIPPED: test_g2d_src_crop - G2D library (libg2d.so.2) not available");
5456            return;
5457        }
5458        if !is_dma_available() {
5459            eprintln!(
5460                "SKIPPED: test_g2d_src_crop - DMA memory allocation not available (permission denied or no DMA-BUF support)"
5461            );
5462            return;
5463        }
5464
5465        let dst_width = 640;
5466        let dst_height = 640;
5467        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5468        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5469
5470        let cpu_dst = TensorDyn::image(
5471            dst_width,
5472            dst_height,
5473            PixelFormat::Rgba,
5474            DType::U8,
5475            None,
5476            edgefirst_tensor::CpuAccess::ReadWrite,
5477        )
5478        .unwrap();
5479        let mut cpu_converter = CPUProcessor::new();
5480        let crop = Crop::new().with_source(Some(Region::new(0, 0, 640, 360)));
5481        let (result, src, cpu_dst) = convert_img(
5482            &mut cpu_converter,
5483            src,
5484            cpu_dst,
5485            Rotation::None,
5486            Flip::None,
5487            crop,
5488        );
5489        result.unwrap();
5490
5491        let g2d_dst = TensorDyn::image(
5492            dst_width,
5493            dst_height,
5494            PixelFormat::Rgba,
5495            DType::U8,
5496            None,
5497            edgefirst_tensor::CpuAccess::ReadWrite,
5498        )
5499        .unwrap();
5500        let mut g2d_converter = G2DProcessor::new().unwrap();
5501        let (result, _src, g2d_dst) = convert_img(
5502            &mut g2d_converter,
5503            src,
5504            g2d_dst,
5505            Rotation::None,
5506            Flip::None,
5507            crop,
5508        );
5509        result.unwrap();
5510
5511        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
5512        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
5513        // the YUV-matrix delta that forced 0.95 has closed; tightened to
5514        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
5515        // structural gap not exercised by these limited-range fixtures.
5516        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
5517    }
5518
5519    #[test]
5520    #[cfg(target_os = "linux")]
5521    fn test_g2d_dst_crop() {
5522        if !is_g2d_available() {
5523            eprintln!("SKIPPED: test_g2d_dst_crop - G2D library (libg2d.so.2) not available");
5524            return;
5525        }
5526        if !is_dma_available() {
5527            eprintln!(
5528                "SKIPPED: test_g2d_dst_crop - DMA memory allocation not available (permission denied or no DMA-BUF support)"
5529            );
5530            return;
5531        }
5532
5533        let dst_width = 640;
5534        let dst_height = 640;
5535        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5536        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5537
5538        let cpu_dst = TensorDyn::image(
5539            dst_width,
5540            dst_height,
5541            PixelFormat::Rgba,
5542            DType::U8,
5543            None,
5544            edgefirst_tensor::CpuAccess::ReadWrite,
5545        )
5546        .unwrap();
5547        let mut cpu_converter = CPUProcessor::new();
5548        let crop = Crop::new();
5549        let (result, src, cpu_dst) = convert_img(
5550            &mut cpu_converter,
5551            src,
5552            cpu_dst,
5553            Rotation::None,
5554            Flip::None,
5555            crop,
5556        );
5557        result.unwrap();
5558
5559        let g2d_dst = TensorDyn::image(
5560            dst_width,
5561            dst_height,
5562            PixelFormat::Rgba,
5563            DType::U8,
5564            None,
5565            edgefirst_tensor::CpuAccess::ReadWrite,
5566        )
5567        .unwrap();
5568        let mut g2d_converter = G2DProcessor::new().unwrap();
5569        let (result, _src, g2d_dst) = convert_img(
5570            &mut g2d_converter,
5571            src,
5572            g2d_dst,
5573            Rotation::None,
5574            Flip::None,
5575            crop,
5576        );
5577        result.unwrap();
5578
5579        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
5580        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
5581        // the YUV-matrix delta that forced 0.95 has closed; tightened to
5582        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
5583        // structural gap not exercised by these limited-range fixtures.
5584        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
5585    }
5586
5587    #[test]
5588    #[cfg(target_os = "linux")]
5589    fn test_g2d_all_rgba() {
5590        if !is_g2d_available() {
5591            eprintln!("SKIPPED: test_g2d_all_rgba - G2D library (libg2d.so.2) not available");
5592            return;
5593        }
5594        if !is_dma_available() {
5595            eprintln!(
5596                "SKIPPED: test_g2d_all_rgba - DMA memory allocation not available (permission denied or no DMA-BUF support)"
5597            );
5598            return;
5599        }
5600
5601        let dst_width = 640;
5602        let dst_height = 640;
5603        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5604        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5605        let src_dyn = src;
5606
5607        let mut cpu_dst = TensorDyn::image(
5608            dst_width,
5609            dst_height,
5610            PixelFormat::Rgba,
5611            DType::U8,
5612            None,
5613            edgefirst_tensor::CpuAccess::ReadWrite,
5614        )
5615        .unwrap();
5616        let mut cpu_converter = CPUProcessor::new();
5617        let mut g2d_dst = TensorDyn::image(
5618            dst_width,
5619            dst_height,
5620            PixelFormat::Rgba,
5621            DType::U8,
5622            None,
5623            edgefirst_tensor::CpuAccess::ReadWrite,
5624        )
5625        .unwrap();
5626        let mut g2d_converter = G2DProcessor::new().unwrap();
5627
5628        let crop = Crop::new().with_source(Some(Region::new(50, 120, 1024, 576)));
5629
5630        for rot in [
5631            Rotation::None,
5632            Rotation::Clockwise90,
5633            Rotation::Rotate180,
5634            Rotation::CounterClockwise90,
5635        ] {
5636            cpu_dst
5637                .as_typed::<u8>()
5638                .unwrap()
5639                .map()
5640                .unwrap()
5641                .as_mut_slice()
5642                .fill(114);
5643            g2d_dst
5644                .as_typed::<u8>()
5645                .unwrap()
5646                .map()
5647                .unwrap()
5648                .as_mut_slice()
5649                .fill(114);
5650            for flip in [Flip::None, Flip::Horizontal, Flip::Vertical] {
5651                let mut cpu_dst_dyn = cpu_dst;
5652                cpu_converter
5653                    .convert(&src_dyn, &mut cpu_dst_dyn, Rotation::None, Flip::None, crop)
5654                    .unwrap();
5655                cpu_dst = {
5656                    let mut __t = cpu_dst_dyn.into_u8().unwrap();
5657                    __t.set_format(PixelFormat::Rgba).unwrap();
5658                    TensorDyn::from(__t)
5659                };
5660
5661                let mut g2d_dst_dyn = g2d_dst;
5662                g2d_converter
5663                    .convert(&src_dyn, &mut g2d_dst_dyn, Rotation::None, Flip::None, crop)
5664                    .unwrap();
5665                g2d_dst = {
5666                    let mut __t = g2d_dst_dyn.into_u8().unwrap();
5667                    __t.set_format(PixelFormat::Rgba).unwrap();
5668                    TensorDyn::from(__t)
5669                };
5670
5671                compare_images(
5672                    &g2d_dst,
5673                    &cpu_dst,
5674                    0.98,
5675                    &format!("{} {:?} {:?}", function!(), rot, flip),
5676                );
5677            }
5678        }
5679    }
5680
5681    #[test]
5682    #[cfg(any(
5683        target_os = "linux",
5684        target_os = "macos",
5685        target_os = "ios",
5686        target_os = "android",
5687        target_os = "windows"
5688    ))]
5689    #[cfg(feature = "opengl")]
5690    fn test_opengl_src_crop() {
5691        if !is_opengl_available() {
5692            eprintln!("SKIPPED: {} - OpenGL not available", function!());
5693            return;
5694        }
5695
5696        let dst_width = 640;
5697        let dst_height = 360;
5698        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5699        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5700        let crop = Crop::new().with_source(Some(Region::new(320, 180, 1280 - 320, 720 - 180)));
5701
5702        let cpu_dst = TensorDyn::image(
5703            dst_width,
5704            dst_height,
5705            PixelFormat::Rgba,
5706            DType::U8,
5707            None,
5708            edgefirst_tensor::CpuAccess::ReadWrite,
5709        )
5710        .unwrap();
5711        let mut cpu_converter = CPUProcessor::new();
5712        let (result, src, cpu_dst) = convert_img(
5713            &mut cpu_converter,
5714            src,
5715            cpu_dst,
5716            Rotation::None,
5717            Flip::None,
5718            crop,
5719        );
5720        result.unwrap();
5721
5722        let gl_dst = TensorDyn::image(
5723            dst_width,
5724            dst_height,
5725            PixelFormat::Rgba,
5726            DType::U8,
5727            None,
5728            edgefirst_tensor::CpuAccess::ReadWrite,
5729        )
5730        .unwrap();
5731        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
5732        let (result, _src, gl_dst) = convert_img(
5733            &mut gl_converter,
5734            src,
5735            gl_dst,
5736            Rotation::None,
5737            Flip::None,
5738            crop,
5739        );
5740        result.unwrap();
5741
5742        compare_images(&gl_dst, &cpu_dst, 0.98, function!());
5743    }
5744
5745    #[test]
5746    #[cfg(any(
5747        target_os = "linux",
5748        target_os = "macos",
5749        target_os = "ios",
5750        target_os = "android",
5751        target_os = "windows"
5752    ))]
5753    #[cfg(feature = "opengl")]
5754    fn test_opengl_dst_crop() {
5755        if !is_opengl_available() {
5756            eprintln!("SKIPPED: {} - OpenGL not available", function!());
5757            return;
5758        }
5759
5760        let dst_width = 640;
5761        let dst_height = 640;
5762        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5763        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5764
5765        let cpu_dst = TensorDyn::image(
5766            dst_width,
5767            dst_height,
5768            PixelFormat::Rgba,
5769            DType::U8,
5770            None,
5771            edgefirst_tensor::CpuAccess::ReadWrite,
5772        )
5773        .unwrap();
5774        let mut cpu_converter = CPUProcessor::new();
5775        let crop = Crop::new();
5776        let (result, src, cpu_dst) = convert_img(
5777            &mut cpu_converter,
5778            src,
5779            cpu_dst,
5780            Rotation::None,
5781            Flip::None,
5782            crop,
5783        );
5784        result.unwrap();
5785
5786        let gl_dst = TensorDyn::image(
5787            dst_width,
5788            dst_height,
5789            PixelFormat::Rgba,
5790            DType::U8,
5791            None,
5792            edgefirst_tensor::CpuAccess::ReadWrite,
5793        )
5794        .unwrap();
5795        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
5796        let (result, _src, gl_dst) = convert_img(
5797            &mut gl_converter,
5798            src,
5799            gl_dst,
5800            Rotation::None,
5801            Flip::None,
5802            crop,
5803        );
5804        result.unwrap();
5805
5806        compare_images(&gl_dst, &cpu_dst, 0.98, function!());
5807    }
5808
5809    #[test]
5810    #[cfg(any(
5811        target_os = "linux",
5812        target_os = "macos",
5813        target_os = "ios",
5814        target_os = "android",
5815        target_os = "windows"
5816    ))]
5817    #[cfg(feature = "opengl")]
5818    fn test_opengl_all_rgba() {
5819        if !is_opengl_available() {
5820            eprintln!("SKIPPED: {} - OpenGL not available", function!());
5821            return;
5822        }
5823
5824        let dst_width = 640;
5825        let dst_height = 640;
5826        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5827
5828        let mut cpu_converter = CPUProcessor::new();
5829
5830        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
5831
5832        // Every backing this host can allocate: Shm is Unix-only, and the
5833        // zero-copy slot is DMA-BUF on Linux / IOSurface on macOS (nothing on
5834        // Windows yet).
5835        let mut mem = vec![None, Some(TensorMemory::Mem)];
5836        if edgefirst_tensor::is_shm_available() {
5837            mem.push(Some(TensorMemory::Shm));
5838        }
5839        if edgefirst_tensor::is_gpu_buffer_available() {
5840            mem.push(Some(TensorMemory::DmaBuf));
5841        }
5842        let crop = Crop::new().with_source(Some(Region::new(50, 120, 1024, 576)));
5843        for m in mem {
5844            let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), m).unwrap();
5845            let src_dyn = src;
5846
5847            for rot in [
5848                Rotation::None,
5849                Rotation::Clockwise90,
5850                Rotation::Rotate180,
5851                Rotation::CounterClockwise90,
5852            ] {
5853                for flip in [Flip::None, Flip::Horizontal, Flip::Vertical] {
5854                    let cpu_dst = TensorDyn::image(
5855                        dst_width,
5856                        dst_height,
5857                        PixelFormat::Rgba,
5858                        DType::U8,
5859                        m,
5860                        edgefirst_tensor::CpuAccess::ReadWrite,
5861                    )
5862                    .unwrap();
5863                    let gl_dst = TensorDyn::image(
5864                        dst_width,
5865                        dst_height,
5866                        PixelFormat::Rgba,
5867                        DType::U8,
5868                        m,
5869                        edgefirst_tensor::CpuAccess::ReadWrite,
5870                    )
5871                    .unwrap();
5872                    cpu_dst
5873                        .as_typed::<u8>()
5874                        .unwrap()
5875                        .map()
5876                        .unwrap()
5877                        .as_mut_slice()
5878                        .fill(114);
5879                    gl_dst
5880                        .as_typed::<u8>()
5881                        .unwrap()
5882                        .map()
5883                        .unwrap()
5884                        .as_mut_slice()
5885                        .fill(114);
5886
5887                    let mut cpu_dst_dyn = cpu_dst;
5888                    cpu_converter
5889                        .convert(&src_dyn, &mut cpu_dst_dyn, Rotation::None, Flip::None, crop)
5890                        .unwrap();
5891                    let cpu_dst = {
5892                        let mut __t = cpu_dst_dyn.into_u8().unwrap();
5893                        __t.set_format(PixelFormat::Rgba).unwrap();
5894                        TensorDyn::from(__t)
5895                    };
5896
5897                    let mut gl_dst_dyn = gl_dst;
5898                    gl_converter
5899                        .convert(&src_dyn, &mut gl_dst_dyn, Rotation::None, Flip::None, crop)
5900                        .map_err(|e| {
5901                            log::error!("error mem {m:?} rot {rot:?} error: {e:?}");
5902                            e
5903                        })
5904                        .unwrap();
5905                    let gl_dst = {
5906                        let mut __t = gl_dst_dyn.into_u8().unwrap();
5907                        __t.set_format(PixelFormat::Rgba).unwrap();
5908                        TensorDyn::from(__t)
5909                    };
5910
5911                    compare_images(
5912                        &gl_dst,
5913                        &cpu_dst,
5914                        0.98,
5915                        &format!("{} {:?} {:?}", function!(), rot, flip),
5916                    );
5917                }
5918            }
5919        }
5920    }
5921
5922    #[test]
5923    #[cfg(target_os = "linux")]
5924    fn test_cpu_rotate() {
5925        for rot in [
5926            Rotation::Clockwise90,
5927            Rotation::Rotate180,
5928            Rotation::CounterClockwise90,
5929        ] {
5930            test_cpu_rotate_(rot);
5931        }
5932    }
5933
5934    #[cfg(target_os = "linux")]
5935    fn test_cpu_rotate_(rot: Rotation) {
5936        // This test rotates the image 4 times and checks that the image was returned to
5937        // be the same Currently doesn't check if rotations actually rotated in
5938        // right direction
5939        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5940
5941        let unchanged_src =
5942            crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5943        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5944
5945        let (dst_width, dst_height) = match rot {
5946            Rotation::None | Rotation::Rotate180 => (src.width().unwrap(), src.height().unwrap()),
5947            Rotation::Clockwise90 | Rotation::CounterClockwise90 => {
5948                (src.height().unwrap(), src.width().unwrap())
5949            }
5950        };
5951
5952        let cpu_dst = TensorDyn::image(
5953            dst_width,
5954            dst_height,
5955            PixelFormat::Rgba,
5956            DType::U8,
5957            None,
5958            edgefirst_tensor::CpuAccess::ReadWrite,
5959        )
5960        .unwrap();
5961        let mut cpu_converter = CPUProcessor::new();
5962
5963        // After rotating 4 times, the image should be the same as the original
5964
5965        let (result, src, cpu_dst) = convert_img(
5966            &mut cpu_converter,
5967            src,
5968            cpu_dst,
5969            rot,
5970            Flip::None,
5971            Crop::no_crop(),
5972        );
5973        result.unwrap();
5974
5975        let (result, cpu_dst, src) = convert_img(
5976            &mut cpu_converter,
5977            cpu_dst,
5978            src,
5979            rot,
5980            Flip::None,
5981            Crop::no_crop(),
5982        );
5983        result.unwrap();
5984
5985        let (result, src, cpu_dst) = convert_img(
5986            &mut cpu_converter,
5987            src,
5988            cpu_dst,
5989            rot,
5990            Flip::None,
5991            Crop::no_crop(),
5992        );
5993        result.unwrap();
5994
5995        let (result, _cpu_dst, src) = convert_img(
5996            &mut cpu_converter,
5997            cpu_dst,
5998            src,
5999            rot,
6000            Flip::None,
6001            Crop::no_crop(),
6002        );
6003        result.unwrap();
6004
6005        compare_images(&src, &unchanged_src, 0.98, function!());
6006    }
6007
6008    #[test]
6009    #[cfg(any(
6010        target_os = "linux",
6011        target_os = "macos",
6012        target_os = "ios",
6013        target_os = "android",
6014        target_os = "windows"
6015    ))]
6016    #[cfg(feature = "opengl")]
6017    fn test_opengl_rotate() {
6018        if !is_opengl_available() {
6019            eprintln!("SKIPPED: {} - OpenGL not available", function!());
6020            return;
6021        }
6022
6023        let size = (1280, 720);
6024        // Same backing list as `test_opengl_all_rgba`.
6025        let mut mem = vec![None, Some(TensorMemory::Mem)];
6026        if edgefirst_tensor::is_shm_available() {
6027            mem.push(Some(TensorMemory::Shm));
6028        }
6029        if edgefirst_tensor::is_gpu_buffer_available() {
6030            mem.push(Some(TensorMemory::DmaBuf));
6031        }
6032        for m in mem {
6033            for rot in [
6034                Rotation::Clockwise90,
6035                Rotation::Rotate180,
6036                Rotation::CounterClockwise90,
6037            ] {
6038                test_opengl_rotate_(size, rot, m);
6039            }
6040        }
6041    }
6042
6043    #[cfg(any(
6044        target_os = "linux",
6045        target_os = "macos",
6046        target_os = "ios",
6047        target_os = "android",
6048        target_os = "windows"
6049    ))]
6050    #[cfg(feature = "opengl")]
6051    fn test_opengl_rotate_(
6052        size: (usize, usize),
6053        rot: Rotation,
6054        tensor_memory: Option<TensorMemory>,
6055    ) {
6056        let (dst_width, dst_height) = match rot {
6057            Rotation::None | Rotation::Rotate180 => size,
6058            Rotation::Clockwise90 | Rotation::CounterClockwise90 => (size.1, size.0),
6059        };
6060
6061        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
6062        let src =
6063            crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), tensor_memory).unwrap();
6064
6065        let cpu_dst = TensorDyn::image(
6066            dst_width,
6067            dst_height,
6068            PixelFormat::Rgba,
6069            DType::U8,
6070            None,
6071            edgefirst_tensor::CpuAccess::ReadWrite,
6072        )
6073        .unwrap();
6074        let mut cpu_converter = CPUProcessor::new();
6075
6076        let (result, mut src, cpu_dst) = convert_img(
6077            &mut cpu_converter,
6078            src,
6079            cpu_dst,
6080            rot,
6081            Flip::None,
6082            Crop::no_crop(),
6083        );
6084        result.unwrap();
6085
6086        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
6087
6088        for _ in 0..5 {
6089            let gl_dst = TensorDyn::image(
6090                dst_width,
6091                dst_height,
6092                PixelFormat::Rgba,
6093                DType::U8,
6094                tensor_memory,
6095                edgefirst_tensor::CpuAccess::ReadWrite,
6096            )
6097            .unwrap();
6098            let (result, src_back, gl_dst) = convert_img(
6099                &mut gl_converter,
6100                src,
6101                gl_dst,
6102                rot,
6103                Flip::None,
6104                Crop::no_crop(),
6105            );
6106            result.unwrap();
6107            src = src_back;
6108            compare_images(&gl_dst, &cpu_dst, 0.98, function!());
6109        }
6110    }
6111
6112    #[test]
6113    #[cfg(target_os = "linux")]
6114    fn test_g2d_rotate() {
6115        if !is_g2d_available() {
6116            eprintln!("SKIPPED: test_g2d_rotate - G2D library (libg2d.so.2) not available");
6117            return;
6118        }
6119        if !is_dma_available() {
6120            eprintln!(
6121                "SKIPPED: test_g2d_rotate - DMA memory allocation not available (permission denied or no DMA-BUF support)"
6122            );
6123            return;
6124        }
6125
6126        let size = (1280, 720);
6127        for rot in [
6128            Rotation::Clockwise90,
6129            Rotation::Rotate180,
6130            Rotation::CounterClockwise90,
6131        ] {
6132            test_g2d_rotate_(size, rot);
6133        }
6134    }
6135
6136    #[cfg(target_os = "linux")]
6137    fn test_g2d_rotate_(size: (usize, usize), rot: Rotation) {
6138        let (dst_width, dst_height) = match rot {
6139            Rotation::None | Rotation::Rotate180 => size,
6140            Rotation::Clockwise90 | Rotation::CounterClockwise90 => (size.1, size.0),
6141        };
6142
6143        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
6144        let src = crate::load_image_test_helper(
6145            &file,
6146            Some(PixelFormat::Rgba),
6147            Some(TensorMemory::DmaBuf),
6148        )
6149        .unwrap();
6150
6151        let cpu_dst = TensorDyn::image(
6152            dst_width,
6153            dst_height,
6154            PixelFormat::Rgba,
6155            DType::U8,
6156            None,
6157            edgefirst_tensor::CpuAccess::ReadWrite,
6158        )
6159        .unwrap();
6160        let mut cpu_converter = CPUProcessor::new();
6161
6162        let (result, src, cpu_dst) = convert_img(
6163            &mut cpu_converter,
6164            src,
6165            cpu_dst,
6166            rot,
6167            Flip::None,
6168            Crop::no_crop(),
6169        );
6170        result.unwrap();
6171
6172        let g2d_dst = TensorDyn::image(
6173            dst_width,
6174            dst_height,
6175            PixelFormat::Rgba,
6176            DType::U8,
6177            Some(TensorMemory::DmaBuf),
6178            edgefirst_tensor::CpuAccess::ReadWrite,
6179        )
6180        .unwrap();
6181        let mut g2d_converter = G2DProcessor::new().unwrap();
6182
6183        let (result, _src, g2d_dst) = convert_img(
6184            &mut g2d_converter,
6185            src,
6186            g2d_dst,
6187            rot,
6188            Flip::None,
6189            Crop::no_crop(),
6190        );
6191        result.unwrap();
6192
6193        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
6194        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
6195        // the YUV-matrix delta that forced 0.95 has closed; tightened to
6196        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
6197        // structural gap not exercised by these limited-range fixtures.
6198        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
6199    }
6200
6201    #[test]
6202    fn test_rgba_to_yuyv_resize_cpu() {
6203        let src = load_bytes_to_tensor(
6204            1280,
6205            720,
6206            PixelFormat::Rgba,
6207            None,
6208            &edgefirst_bench::testdata::read("camera720p.rgba"),
6209        )
6210        .unwrap();
6211
6212        let (dst_width, dst_height) = (640, 360);
6213
6214        let dst = TensorDyn::image(
6215            dst_width,
6216            dst_height,
6217            PixelFormat::Yuyv,
6218            DType::U8,
6219            None,
6220            edgefirst_tensor::CpuAccess::ReadWrite,
6221        )
6222        .unwrap();
6223
6224        let dst_through_yuyv = TensorDyn::image(
6225            dst_width,
6226            dst_height,
6227            PixelFormat::Rgba,
6228            DType::U8,
6229            None,
6230            edgefirst_tensor::CpuAccess::ReadWrite,
6231        )
6232        .unwrap();
6233        let dst_direct = TensorDyn::image(
6234            dst_width,
6235            dst_height,
6236            PixelFormat::Rgba,
6237            DType::U8,
6238            None,
6239            edgefirst_tensor::CpuAccess::ReadWrite,
6240        )
6241        .unwrap();
6242
6243        let mut cpu_converter = CPUProcessor::new();
6244
6245        let (result, src, dst) = convert_img(
6246            &mut cpu_converter,
6247            src,
6248            dst,
6249            Rotation::None,
6250            Flip::None,
6251            Crop::no_crop(),
6252        );
6253        result.unwrap();
6254
6255        let (result, _dst, dst_through_yuyv) = convert_img(
6256            &mut cpu_converter,
6257            dst,
6258            dst_through_yuyv,
6259            Rotation::None,
6260            Flip::None,
6261            Crop::no_crop(),
6262        );
6263        result.unwrap();
6264
6265        let (result, _src, dst_direct) = convert_img(
6266            &mut cpu_converter,
6267            src,
6268            dst_direct,
6269            Rotation::None,
6270            Flip::None,
6271            Crop::no_crop(),
6272        );
6273        result.unwrap();
6274
6275        compare_images(&dst_through_yuyv, &dst_direct, 0.98, function!());
6276    }
6277
6278    #[test]
6279    #[cfg(any(
6280        target_os = "linux",
6281        target_os = "macos",
6282        target_os = "ios",
6283        target_os = "android",
6284        target_os = "windows"
6285    ))]
6286    #[cfg(feature = "opengl")]
6287    #[ignore = "opengl doesn't support rendering to PixelFormat::Yuyv texture"]
6288    fn test_rgba_to_yuyv_resize_opengl() {
6289        if !is_opengl_available() {
6290            eprintln!("SKIPPED: {} - OpenGL not available", function!());
6291            return;
6292        }
6293
6294        if !edgefirst_tensor::is_gpu_buffer_available() {
6295            eprintln!(
6296                "SKIPPED: {} - zero-copy GPU buffer (DMA-BUF / IOSurface) not available on this host",
6297                function!()
6298            );
6299            return;
6300        }
6301
6302        let src = load_bytes_to_tensor(
6303            1280,
6304            720,
6305            PixelFormat::Rgba,
6306            None,
6307            &edgefirst_bench::testdata::read("camera720p.rgba"),
6308        )
6309        .unwrap();
6310
6311        let (dst_width, dst_height) = (640, 360);
6312
6313        let dst = TensorDyn::image(
6314            dst_width,
6315            dst_height,
6316            PixelFormat::Yuyv,
6317            DType::U8,
6318            Some(TensorMemory::DmaBuf),
6319            edgefirst_tensor::CpuAccess::ReadWrite,
6320        )
6321        .unwrap();
6322
6323        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
6324
6325        let (result, src, dst) = convert_img(
6326            &mut gl_converter,
6327            src,
6328            dst,
6329            Rotation::None,
6330            Flip::None,
6331            Crop::letterbox([255, 255, 255, 255]),
6332        );
6333        result.unwrap();
6334
6335        std::fs::write(
6336            "rgba_to_yuyv_opengl.yuyv",
6337            dst.as_typed::<u8>().unwrap().map().unwrap().as_slice(),
6338        )
6339        .unwrap();
6340        let cpu_dst = TensorDyn::image(
6341            dst_width,
6342            dst_height,
6343            PixelFormat::Yuyv,
6344            DType::U8,
6345            Some(TensorMemory::DmaBuf),
6346            edgefirst_tensor::CpuAccess::ReadWrite,
6347        )
6348        .unwrap();
6349        let (result, _src, cpu_dst) = convert_img(
6350            &mut CPUProcessor::new(),
6351            src,
6352            cpu_dst,
6353            Rotation::None,
6354            Flip::None,
6355            Crop::no_crop(),
6356        );
6357        result.unwrap();
6358
6359        compare_images_convert_to_rgb(&dst, &cpu_dst, 0.98, function!());
6360    }
6361
6362    #[test]
6363    #[cfg(target_os = "linux")]
6364    fn test_rgba_to_yuyv_resize_g2d() {
6365        if !is_g2d_available() {
6366            eprintln!(
6367                "SKIPPED: test_rgba_to_yuyv_resize_g2d - G2D library (libg2d.so.2) not available"
6368            );
6369            return;
6370        }
6371        if !is_dma_available() {
6372            eprintln!(
6373                "SKIPPED: test_rgba_to_yuyv_resize_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
6374            );
6375            return;
6376        }
6377
6378        let src = load_bytes_to_tensor(
6379            1280,
6380            720,
6381            PixelFormat::Rgba,
6382            Some(TensorMemory::DmaBuf),
6383            &edgefirst_bench::testdata::read("camera720p.rgba"),
6384        )
6385        .unwrap();
6386
6387        let (dst_width, dst_height) = (1280, 720);
6388
6389        let cpu_dst = TensorDyn::image(
6390            dst_width,
6391            dst_height,
6392            PixelFormat::Yuyv,
6393            DType::U8,
6394            Some(TensorMemory::DmaBuf),
6395            edgefirst_tensor::CpuAccess::ReadWrite,
6396        )
6397        .unwrap();
6398
6399        let g2d_dst = TensorDyn::image(
6400            dst_width,
6401            dst_height,
6402            PixelFormat::Yuyv,
6403            DType::U8,
6404            Some(TensorMemory::DmaBuf),
6405            edgefirst_tensor::CpuAccess::ReadWrite,
6406        )
6407        .unwrap();
6408
6409        let mut g2d_converter = G2DProcessor::new().unwrap();
6410        let crop = Crop::new();
6411
6412        g2d_dst
6413            .as_typed::<u8>()
6414            .unwrap()
6415            .map()
6416            .unwrap()
6417            .as_mut_slice()
6418            .fill(128);
6419        let (result, src, g2d_dst) = convert_img(
6420            &mut g2d_converter,
6421            src,
6422            g2d_dst,
6423            Rotation::None,
6424            Flip::None,
6425            crop,
6426        );
6427        result.unwrap();
6428
6429        let cpu_dst_img = cpu_dst;
6430        cpu_dst_img
6431            .as_typed::<u8>()
6432            .unwrap()
6433            .map()
6434            .unwrap()
6435            .as_mut_slice()
6436            .fill(128);
6437        let (result, _src, cpu_dst) = convert_img(
6438            &mut CPUProcessor::new(),
6439            src,
6440            cpu_dst_img,
6441            Rotation::None,
6442            Flip::None,
6443            crop,
6444        );
6445        result.unwrap();
6446
6447        compare_images_convert_to_rgb(&cpu_dst, &g2d_dst, 0.98, function!());
6448    }
6449
6450    #[test]
6451    fn test_yuyv_to_rgba_cpu() {
6452        let file = edgefirst_bench::testdata::read("camera720p.yuyv").to_vec();
6453        let src = TensorDyn::image(
6454            1280,
6455            720,
6456            PixelFormat::Yuyv,
6457            DType::U8,
6458            None,
6459            edgefirst_tensor::CpuAccess::ReadWrite,
6460        )
6461        .unwrap();
6462        src.as_typed::<u8>()
6463            .unwrap()
6464            .map()
6465            .unwrap()
6466            .as_mut_slice()
6467            .copy_from_slice(&file);
6468
6469        let dst = TensorDyn::image(
6470            1280,
6471            720,
6472            PixelFormat::Rgba,
6473            DType::U8,
6474            None,
6475            edgefirst_tensor::CpuAccess::ReadWrite,
6476        )
6477        .unwrap();
6478        let mut cpu_converter = CPUProcessor::new();
6479
6480        let (result, _src, dst) = convert_img(
6481            &mut cpu_converter,
6482            src,
6483            dst,
6484            Rotation::None,
6485            Flip::None,
6486            Crop::no_crop(),
6487        );
6488        result.unwrap();
6489
6490        let target_image = TensorDyn::image(
6491            1280,
6492            720,
6493            PixelFormat::Rgba,
6494            DType::U8,
6495            None,
6496            edgefirst_tensor::CpuAccess::ReadWrite,
6497        )
6498        .unwrap();
6499        target_image
6500            .as_typed::<u8>()
6501            .unwrap()
6502            .map()
6503            .unwrap()
6504            .as_mut_slice()
6505            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
6506
6507        // CPU path resolves the untagged 720p source to BT.709 limited (height
6508        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
6509        compare_images(&dst, &target_image, 0.98, function!());
6510    }
6511
6512    #[test]
6513    fn test_yuyv_to_rgb_cpu() {
6514        let file = edgefirst_bench::testdata::read("camera720p.yuyv").to_vec();
6515        let src = TensorDyn::image(
6516            1280,
6517            720,
6518            PixelFormat::Yuyv,
6519            DType::U8,
6520            None,
6521            edgefirst_tensor::CpuAccess::ReadWrite,
6522        )
6523        .unwrap();
6524        src.as_typed::<u8>()
6525            .unwrap()
6526            .map()
6527            .unwrap()
6528            .as_mut_slice()
6529            .copy_from_slice(&file);
6530
6531        let dst = TensorDyn::image(
6532            1280,
6533            720,
6534            PixelFormat::Rgb,
6535            DType::U8,
6536            None,
6537            edgefirst_tensor::CpuAccess::ReadWrite,
6538        )
6539        .unwrap();
6540        let mut cpu_converter = CPUProcessor::new();
6541
6542        let (result, _src, dst) = convert_img(
6543            &mut cpu_converter,
6544            src,
6545            dst,
6546            Rotation::None,
6547            Flip::None,
6548            Crop::no_crop(),
6549        );
6550        result.unwrap();
6551
6552        let target_image = TensorDyn::image(
6553            1280,
6554            720,
6555            PixelFormat::Rgb,
6556            DType::U8,
6557            None,
6558            edgefirst_tensor::CpuAccess::ReadWrite,
6559        )
6560        .unwrap();
6561        target_image
6562            .as_typed::<u8>()
6563            .unwrap()
6564            .map()
6565            .unwrap()
6566            .as_mut_slice()
6567            .as_chunks_mut::<3>()
6568            .0
6569            .iter_mut()
6570            .zip(
6571                edgefirst_bench::testdata::read("camera720p.rgba")
6572                    .as_chunks::<4>()
6573                    .0,
6574            )
6575            .for_each(|(dst, src)| *dst = [src[0], src[1], src[2]]);
6576
6577        // CPU path resolves the untagged 720p source to BT.709 limited (height
6578        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
6579        compare_images(&dst, &target_image, 0.98, function!());
6580    }
6581
6582    #[test]
6583    #[cfg(target_os = "linux")]
6584    fn test_yuyv_to_rgba_g2d() {
6585        if !is_g2d_available() {
6586            eprintln!("SKIPPED: test_yuyv_to_rgba_g2d - G2D library (libg2d.so.2) not available");
6587            return;
6588        }
6589        if !is_dma_available() {
6590            eprintln!(
6591                "SKIPPED: test_yuyv_to_rgba_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
6592            );
6593            return;
6594        }
6595
6596        let src = load_bytes_to_tensor(
6597            1280,
6598            720,
6599            PixelFormat::Yuyv,
6600            None,
6601            &edgefirst_bench::testdata::read("camera720p.yuyv"),
6602        )
6603        .unwrap();
6604
6605        let dst = TensorDyn::image(
6606            1280,
6607            720,
6608            PixelFormat::Rgba,
6609            DType::U8,
6610            Some(TensorMemory::DmaBuf),
6611            edgefirst_tensor::CpuAccess::ReadWrite,
6612        )
6613        .unwrap();
6614        let mut g2d_converter = G2DProcessor::new().unwrap();
6615
6616        let (result, _src, dst) = convert_img(
6617            &mut g2d_converter,
6618            src,
6619            dst,
6620            Rotation::None,
6621            Flip::None,
6622            Crop::no_crop(),
6623        );
6624        result.unwrap();
6625
6626        let target_image = TensorDyn::image(
6627            1280,
6628            720,
6629            PixelFormat::Rgba,
6630            DType::U8,
6631            None,
6632            edgefirst_tensor::CpuAccess::ReadWrite,
6633        )
6634        .unwrap();
6635        target_image
6636            .as_typed::<u8>()
6637            .unwrap()
6638            .map()
6639            .unwrap()
6640            .as_mut_slice()
6641            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
6642
6643        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
6644        // so the matrix delta vs the reference that forced 0.95 has closed;
6645        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
6646        compare_images(&dst, &target_image, 0.98, function!());
6647    }
6648
6649    #[test]
6650    #[cfg(any(
6651        target_os = "linux",
6652        target_os = "macos",
6653        target_os = "ios",
6654        target_os = "android",
6655        target_os = "windows"
6656    ))]
6657    #[cfg(feature = "opengl")]
6658    fn test_yuyv_to_rgba_opengl() {
6659        if !is_opengl_available() {
6660            eprintln!("SKIPPED: {} - OpenGL not available", function!());
6661            return;
6662        }
6663        // Zero-copy YUYV source: DMA-BUF on Linux, IOSurface on macOS; skips
6664        // where no such backing exists (Windows today).
6665        if !edgefirst_tensor::is_gpu_buffer_available() {
6666            eprintln!(
6667                "SKIPPED: {} - zero-copy GPU buffer (DMA-BUF / IOSurface) not available on this host",
6668                function!()
6669            );
6670            return;
6671        }
6672
6673        let src = load_bytes_to_tensor(
6674            1280,
6675            720,
6676            PixelFormat::Yuyv,
6677            Some(TensorMemory::DmaBuf),
6678            &edgefirst_bench::testdata::read("camera720p.yuyv"),
6679        )
6680        .unwrap();
6681
6682        let dst = TensorDyn::image(
6683            1280,
6684            720,
6685            PixelFormat::Rgba,
6686            DType::U8,
6687            Some(TensorMemory::DmaBuf),
6688            edgefirst_tensor::CpuAccess::ReadWrite,
6689        )
6690        .unwrap();
6691        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
6692
6693        let (result, _src, dst) = convert_img(
6694            &mut gl_converter,
6695            src,
6696            dst,
6697            Rotation::None,
6698            Flip::None,
6699            Crop::no_crop(),
6700        );
6701        // Desktop NVIDIA cannot sample YUYV as a GL texture; i.MX/Vivante can.
6702        // Production ImageProcessor falls back to CPU; this oracle skips.
6703        if let Err(crate::Error::NotSupported(msg)) = &result {
6704            eprintln!("SKIPPED: {} — {msg}", function!());
6705            return;
6706        }
6707        result.unwrap();
6708
6709        let target_image = TensorDyn::image(
6710            1280,
6711            720,
6712            PixelFormat::Rgba,
6713            DType::U8,
6714            None,
6715            edgefirst_tensor::CpuAccess::ReadWrite,
6716        )
6717        .unwrap();
6718        target_image
6719            .as_typed::<u8>()
6720            .unwrap()
6721            .map()
6722            .unwrap()
6723            .as_mut_slice()
6724            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
6725
6726        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
6727        // so the matrix delta vs the reference that forced 0.95 has closed;
6728        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
6729        compare_images(&dst, &target_image, 0.98, function!());
6730    }
6731
6732    /// macOS analog of `test_yuyv_to_rgba_opengl` — drives the ANGLE +
6733    /// IOSurface backend end-to-end and compares against the same
6734    /// reference image. Skips silently if ANGLE isn't installed so the
6735    /// test suite still passes on CI hosts without the Homebrew tap.
6736    /// Step-1 probe: proves ANGLE's Metal IOSurface-client-buffer path accepts
6737    /// an `L008`→`GL_RED` (R8) binding — the foundation for sampling the
6738    /// contiguous semi-planar YUV buffer as a single R8 texture. Renders a
6739    /// GREY (R8 IOSurface) source through the GL backend to RGBA and checks the
6740    /// luma round-trips to R=G=B (identity GREY→RGB).
6741    #[test]
6742    #[cfg(target_os = "macos")]
6743    #[cfg(feature = "opengl")]
6744    fn test_grey_r8_iosurface_to_rgba_opengl_macos() {
6745        let mut proc = match GLProcessorThreaded::new(None) {
6746            Ok(p) => p,
6747            Err(e) => {
6748                eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
6749                return;
6750            }
6751        };
6752
6753        let (w, h) = (16usize, 16usize);
6754        let src = TensorDyn::image(
6755            w,
6756            h,
6757            PixelFormat::Grey,
6758            DType::U8,
6759            Some(TensorMemory::DmaBuf),
6760            edgefirst_tensor::CpuAccess::ReadWrite,
6761        )
6762        .expect("GREY IOSurface (R8/L008) should allocate — proves the FourCC mapping");
6763        // Known luma ramp: value = (x * 13 + y * 7) & 0xff.
6764        {
6765            let su8 = src.as_typed::<u8>().unwrap();
6766            let stride = src
6767                .as_typed::<u8>()
6768                .unwrap()
6769                .effective_row_stride()
6770                .unwrap();
6771            let mut m = su8.map().unwrap();
6772            let buf = m.as_mut_slice();
6773            for y in 0..h {
6774                for x in 0..w {
6775                    buf[y * stride + x] = ((x * 13 + y * 7) & 0xff) as u8;
6776                }
6777            }
6778        }
6779
6780        let dst = TensorDyn::image(
6781            w,
6782            h,
6783            PixelFormat::Rgba,
6784            DType::U8,
6785            Some(TensorMemory::DmaBuf),
6786            edgefirst_tensor::CpuAccess::ReadWrite,
6787        )
6788        .unwrap();
6789        let (result, src_back, dst) = convert_img(
6790            &mut proc,
6791            src,
6792            dst,
6793            Rotation::None,
6794            Flip::None,
6795            Crop::no_crop(),
6796        );
6797        result.expect("GREY(R8 IOSurface) → RGBA must convert on ANGLE (R8 binding works)");
6798
6799        let src_stride = src_back
6800            .as_typed::<u8>()
6801            .unwrap()
6802            .effective_row_stride()
6803            .unwrap();
6804        let src_map = src_back.as_typed::<u8>().unwrap().map().unwrap();
6805        let sbytes = src_map.as_slice();
6806        let dst_stride = dst
6807            .as_typed::<u8>()
6808            .unwrap()
6809            .effective_row_stride()
6810            .unwrap();
6811        let dst_map = dst.as_typed::<u8>().unwrap().map().unwrap();
6812        let dbytes = dst_map.as_slice();
6813        for y in 0..h {
6814            for x in 0..w {
6815                let yv = sbytes[y * src_stride + x] as i16;
6816                let p = y * dst_stride + x * 4;
6817                for c in 0..3 {
6818                    assert!(
6819                        (dbytes[p + c] as i16 - yv).abs() <= 2,
6820                        "pixel ({x},{y}) ch{c} = {} expected ~{yv} (GREY→RGB identity)",
6821                        dbytes[p + c]
6822                    );
6823                }
6824            }
6825        }
6826    }
6827
6828    /// Two-pass GPU chain: NV12 (R8 IOSurface) → PlanarRgb F16, the profiler's
6829    /// preprocess. Verifies the chained `convert_nv_to_planar_float`
6830    /// (NV12→RGBA8 then the verified RGBA8→PlanarRgb F16) executes on ANGLE and
6831    /// produces a sane F16 planar result: a neutral-grey NV12 input (Y=U=V=128,
6832    /// BT.601 full ⇒ RGB≈0.5) must yield all three planes ≈0.5 (half-float).
6833    #[test]
6834    #[cfg(target_os = "macos")]
6835    #[cfg(feature = "opengl")]
6836    fn test_nv12_to_planar_f16_two_pass_opengl_macos() {
6837        let mut gpu = match GLProcessorThreaded::new(None) {
6838            Ok(p) => p,
6839            Err(e) => {
6840                eprintln!("SKIPPED: {} — init failed ({e:?})", function!());
6841                return;
6842            }
6843        };
6844        let (w, h) = (64usize, 64usize);
6845        let src = match TensorDyn::image(
6846            w,
6847            h,
6848            PixelFormat::Nv12,
6849            DType::U8,
6850            Some(TensorMemory::DmaBuf),
6851            edgefirst_tensor::CpuAccess::ReadWrite,
6852        ) {
6853            Ok(t) => t,
6854            Err(e) => {
6855                eprintln!("SKIPPED: {} — NV12 IOSurface alloc: {e:?}", function!());
6856                return;
6857            }
6858        };
6859        src.as_typed::<u8>()
6860            .unwrap()
6861            .map()
6862            .unwrap()
6863            .as_mut_slice()
6864            .fill(128); // Y=U=V=128
6865
6866        let dst = match TensorDyn::image(
6867            w,
6868            h,
6869            PixelFormat::PlanarRgb,
6870            DType::F16,
6871            Some(TensorMemory::DmaBuf),
6872            edgefirst_tensor::CpuAccess::ReadWrite,
6873        ) {
6874            Ok(t) => t,
6875            Err(e) => {
6876                eprintln!("SKIPPED: {} — F16 PlanarRgb IOSurface: {e:?}", function!());
6877                return;
6878            }
6879        };
6880        let mut dst = dst;
6881        // Call convert directly (the convert_img helper restores u8 only).
6882        if let Err(e) = ImageProcessorTrait::convert(
6883            &mut gpu,
6884            &src,
6885            &mut dst,
6886            Rotation::None,
6887            Flip::None,
6888            Crop::no_crop(),
6889        ) {
6890            // GL_EXT_color_buffer_half_float may be absent on some configs; the
6891            // RGBA8 pass-1 + F16 pass-2 path then can't render. Skip rather than
6892            // fail on a capability gap (the same policy as the F16 path tests).
6893            eprintln!(
6894                "SKIPPED: {} — NV12→PlanarRgb F16 not available ({e:?})",
6895                function!()
6896            );
6897            return;
6898        }
6899        let dt = dst.as_typed::<half::f16>().expect("dst is F16 PlanarRgb");
6900        let map = dt.map().unwrap();
6901        let vals = map.as_slice();
6902        // Neutral grey → ~0.5 in every plane. Allow generous tolerance for the
6903        // mediump YUV math + half-float rounding.
6904        let mut checked = 0usize;
6905        for &v in vals.iter() {
6906            let f = f32::from(v);
6907            assert!(
6908                (0.40..=0.60).contains(&f),
6909                "planar F16 value {f} not ~0.5 for neutral-grey NV12"
6910            );
6911            checked += 1;
6912        }
6913        assert!(
6914            checked >= w * h * 3,
6915            "expected >= 3 planes of samples, got {checked}"
6916        );
6917    }
6918
6919    /// Profiler-shaped two-pass: a reused **R8/Grey pool** (allocated larger
6920    /// than the frame, the NV24 worst case `3·H`) is reconfigured to an NV12
6921    /// frame, filled at the preserved physical stride, and converted with a
6922    /// letterbox `src_rect` crop into a model-sized PlanarRgb F16 destination —
6923    /// exactly the orchestrator's preprocess. Guards against the pooled
6924    /// two-pass NV→PlanarRgb F16 path hanging/erroring (the exact-size
6925    /// `test_nv12_to_planar_f16_two_pass` never exercised the larger pool).
6926    #[test]
6927    #[cfg(target_os = "macos")]
6928    #[cfg(feature = "opengl")]
6929    fn test_nv12_to_planar_f16_two_pass_pool_opengl_macos() {
6930        let mut gpu = match GLProcessorThreaded::new(None) {
6931            Ok(p) => p,
6932            Err(e) => {
6933                eprintln!("SKIPPED: {} — init failed ({e:?})", function!());
6934                return;
6935            }
6936        };
6937        // Frame 96×64 in a 256×768 R8 pool (3·256 height; bpr padded past 96).
6938        let (fw, fh) = (96usize, 64usize);
6939        let (pool_w, pool_h) = (256usize, 768usize);
6940        let (model_w, model_h) = (128usize, 128usize);
6941
6942        let mut src = match TensorDyn::image(
6943            pool_w,
6944            pool_h,
6945            PixelFormat::Grey,
6946            DType::U8,
6947            Some(TensorMemory::DmaBuf),
6948            edgefirst_tensor::CpuAccess::ReadWrite,
6949        ) {
6950            Ok(t) => t,
6951            Err(e) => {
6952                eprintln!("SKIPPED: {} — R8 pool alloc: {e:?}", function!());
6953                return;
6954            }
6955        };
6956        src.configure_image(fw, fh, PixelFormat::Nv12)
6957            .unwrap_or_else(|e| panic!("configure_image NV12 on pool: {e}"));
6958        let stride = src
6959            .as_typed::<u8>()
6960            .unwrap()
6961            .effective_row_stride()
6962            .unwrap();
6963        src.as_typed::<u8>()
6964            .unwrap()
6965            .map()
6966            .unwrap()
6967            .as_mut_slice()
6968            .fill(128); // neutral grey
6969
6970        let mut dst = match TensorDyn::image(
6971            model_w,
6972            model_h,
6973            PixelFormat::PlanarRgb,
6974            DType::F16,
6975            Some(TensorMemory::DmaBuf),
6976            edgefirst_tensor::CpuAccess::ReadWrite,
6977        ) {
6978            Ok(t) => t,
6979            Err(e) => {
6980                eprintln!("SKIPPED: {} — F16 PlanarRgb dst: {e:?}", function!());
6981                return;
6982            }
6983        };
6984
6985        // Letterbox crop like the profiler: the backend computes the band.
6986        let _ = model_w;
6987        let crop = Crop::new()
6988            .with_source(Some(Region::new(0, 0, fw, fh)))
6989            .with_fit(Fit::Letterbox {
6990                pad: [0, 0, 0, 255],
6991            });
6992        if let Err(e) =
6993            ImageProcessorTrait::convert(&mut gpu, &src, &mut dst, Rotation::None, Flip::None, crop)
6994        {
6995            eprintln!(
6996                "SKIPPED: {} — NV12→PlanarRgb F16 unavailable ({e:?})",
6997                function!()
6998            );
6999            return;
7000        }
7001        let _ = stride;
7002        // Neutral grey → ~0.5 inside the letterbox band; just assert the convert
7003        // completed and produced finite values (no hang, no NaN garbage).
7004        let dt = dst.as_typed::<half::f16>().expect("dst F16");
7005        let map = dt.map().unwrap();
7006        let any_half = map.as_slice().iter().any(|&v| {
7007            let f = f32::from(v);
7008            (0.40..=0.60).contains(&f)
7009        });
7010        assert!(any_half, "expected ~0.5 grey samples in the letterbox band");
7011    }
7012
7013    /// Mirrors the orchestrator: the GL processor is created on one thread
7014    /// and `convert()` is called from a *different* thread (the profiler's
7015    /// Pre-processing worker). Reproduces (or rules out) the GL-context /
7016    /// `glFinish` cross-thread hang seen in the live pipeline. A 20 s watchdog
7017    /// fails loudly rather than hanging the whole test binary.
7018    #[test]
7019    #[cfg(target_os = "macos")]
7020    #[cfg(feature = "opengl")]
7021    fn test_nv12_to_planar_f16_cross_thread_opengl_macos() {
7022        use std::sync::mpsc;
7023        // Public ImageProcessor (Send) created HERE (the main test thread),
7024        // exactly like the orchestrator builds `config.processor` during setup.
7025        let mut proc = match ImageProcessor::new() {
7026            Ok(p) => p,
7027            Err(e) => {
7028                eprintln!("SKIPPED: {} — init failed ({e:?})", function!());
7029                return;
7030            }
7031        };
7032        let (fw, fh) = (96usize, 64usize);
7033        let mut src = match TensorDyn::image(
7034            256,
7035            768,
7036            PixelFormat::Grey,
7037            DType::U8,
7038            Some(TensorMemory::DmaBuf),
7039            edgefirst_tensor::CpuAccess::ReadWrite,
7040        ) {
7041            Ok(t) => t,
7042            Err(e) => {
7043                eprintln!("SKIPPED: {} — pool: {e:?}", function!());
7044                return;
7045            }
7046        };
7047        src.configure_image(fw, fh, PixelFormat::Nv12).unwrap();
7048        src.as_typed::<u8>()
7049            .unwrap()
7050            .map()
7051            .unwrap()
7052            .as_mut_slice()
7053            .fill(128);
7054        let mut dst = match TensorDyn::image(
7055            128,
7056            128,
7057            PixelFormat::PlanarRgb,
7058            DType::F16,
7059            Some(TensorMemory::DmaBuf),
7060            edgefirst_tensor::CpuAccess::ReadWrite,
7061        ) {
7062            Ok(t) => t,
7063            Err(e) => {
7064                eprintln!("SKIPPED: {} — dst: {e:?}", function!());
7065                return;
7066            }
7067        };
7068        let crop = Crop::new().with_source(Some(Region::new(0, 0, fw, fh)));
7069
7070        // ...then MOVED to a worker thread where convert() runs — exactly the
7071        // orchestrator's create-on-setup / convert-on-Pre-processing split.
7072        let (tx, rx) = mpsc::channel::<bool>();
7073        let worker = std::thread::spawn(move || {
7074            let _ = ImageProcessorTrait::convert(
7075                &mut proc,
7076                &src,
7077                &mut dst,
7078                Rotation::None,
7079                Flip::None,
7080                crop,
7081            );
7082            let _ = tx.send(true);
7083        });
7084        match rx.recv_timeout(std::time::Duration::from_secs(20)) {
7085            Ok(_) => { let _ = worker.join(); }
7086            Err(_) => panic!(
7087                "cross-thread NV12→PlanarRgb convert HUNG (>20s) — reproduces the orchestrator deadlock"
7088            ),
7089        }
7090    }
7091
7092    /// Reproduces the profiler's progressive-slowdown/hang: one processor
7093    /// converting many **varying-size** NV frames (like a COCO dataset) from a
7094    /// reused R8 pool into a fixed PlanarRgb F16 model input. The two-pass path
7095    /// reallocated its RGBA intermediate per frame-size, churning/leaking
7096    /// pbuffers until the GPU stalled. Asserts per-convert latency stays bounded
7097    /// (no runaway) over many iterations.
7098    #[test]
7099    #[cfg(target_os = "macos")]
7100    #[cfg(feature = "opengl")]
7101    fn test_nv_to_planar_f16_varying_sizes_no_leak_opengl_macos() {
7102        let mut gpu = match GLProcessorThreaded::new(None) {
7103            Ok(p) => p,
7104            Err(e) => {
7105                eprintln!("SKIPPED: {} — init failed ({e:?})", function!());
7106                return;
7107            }
7108        };
7109        // Mirror the orchestrator's ring buffers at depth 4: a pool of source R8
7110        // tensors AND a pool of PlanarRgb F16 dst slots, both cycled per frame.
7111        let (max_w, max_h) = (640usize, 640usize);
7112        let depth = 4usize;
7113        let mut srcs = Vec::new();
7114        let mut dsts = Vec::new();
7115        for _ in 0..depth {
7116            srcs.push(
7117                match TensorDyn::image(
7118                    max_w,
7119                    max_h * 3,
7120                    PixelFormat::Grey,
7121                    DType::U8,
7122                    Some(TensorMemory::DmaBuf),
7123                    edgefirst_tensor::CpuAccess::ReadWrite,
7124                ) {
7125                    Ok(t) => t,
7126                    Err(e) => {
7127                        eprintln!("SKIPPED: {} — pool: {e:?}", function!());
7128                        return;
7129                    }
7130                },
7131            );
7132            dsts.push(
7133                match TensorDyn::image(
7134                    640,
7135                    640,
7136                    PixelFormat::PlanarRgb,
7137                    DType::F16,
7138                    Some(TensorMemory::DmaBuf),
7139                    edgefirst_tensor::CpuAccess::ReadWrite,
7140                ) {
7141                    Ok(t) => t,
7142                    Err(e) => {
7143                        eprintln!("SKIPPED: {} — dst: {e:?}", function!());
7144                        return;
7145                    }
7146                },
7147            );
7148        }
7149        // COCO-like assorted frame sizes (all ≤ max), cycled.
7150        let sizes = [
7151            (640, 480),
7152            (500, 375),
7153            (640, 427),
7154            (333, 500),
7155            (480, 640),
7156            (612, 612),
7157            (428, 640),
7158            (576, 432),
7159        ];
7160        let mut first_ms = 0f64;
7161        let mut last_ms = 0f64;
7162        let iters = 40usize;
7163        for i in 0..iters {
7164            let (fw, fh) = sizes[i % sizes.len()];
7165            let src = &mut srcs[i % depth];
7166            let dst = &mut dsts[i % depth];
7167            src.configure_image(fw, fh, PixelFormat::Nv24).unwrap();
7168            src.as_typed::<u8>()
7169                .unwrap()
7170                .map()
7171                .unwrap()
7172                .as_mut_slice()
7173                .fill(128);
7174            let crop = Crop::new().with_source(Some(Region::new(0, 0, fw, fh)));
7175            let t0 = std::time::Instant::now();
7176            ImageProcessorTrait::convert(&mut gpu, src, dst, Rotation::None, Flip::None, crop)
7177                .unwrap_or_else(|e| panic!("convert iter {i} ({fw}×{fh}): {e}"));
7178            let ms = t0.elapsed().as_secs_f64() * 1e3;
7179            if i == 2 {
7180                first_ms = ms;
7181            }
7182            if i == iters - 1 {
7183                last_ms = ms;
7184            }
7185        }
7186        eprintln!("first={first_ms:.2}ms last={last_ms:.2}ms");
7187        assert!(
7188            last_ms < first_ms * 5.0 + 5.0,
7189            "convert latency ran away: first {first_ms:.2}ms → last {last_ms:.2}ms (intermediate/pbuffer leak)"
7190        );
7191    }
7192
7193    /// Step-2 verification: NV12/NV16/NV24 (R8 IOSurface) → RGBA on the GPU
7194    /// must match the CPU `yuv` kernels within shader rounding. Fills an
7195    /// IOSurface source and a Mem source from the same logical YUV pattern
7196    /// (each at its own row stride), converts the IOSurface on the GPU and the
7197    /// Mem one on the CPU, and compares. Exercises the in-shader semi-planar
7198    /// addressing for all three subsamplings (incl. NV24's 2×-wide UV rows).
7199    #[test]
7200    #[cfg(target_os = "macos")]
7201    #[cfg(feature = "opengl")]
7202    fn test_nv12_nv16_nv24_to_rgba_opengl_macos() {
7203        let mut gpu = match GLProcessorThreaded::new(None) {
7204            Ok(p) => p,
7205            Err(e) => {
7206                eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
7207                return;
7208            }
7209        };
7210        let mut cpu = CPUProcessor::new();
7211
7212        // Fill Y plus the interleaved UV plane in the canonical semi-planar
7213        // layout — exactly what the codec writes and the CPU `yuv`-crate reader
7214        // expects: the Y plane is `h` rows at the buffer's row stride; the UV
7215        // plane starts at `h * stride`; each chroma row advances
7216        // `uv_grid_rows * stride` bytes (NV24 carries a full-resolution `2*W`
7217        // byte line == two grid rows, NV12/NV16 one row of `W/2` pairs); each
7218        // (Cb,Cr) pair is two consecutive bytes at column `cx * 2`. This is
7219        // stride-correct for both the tight Mem buffer and the padded IOSurface.
7220        //
7221        // Takes explicit w/h so the closure can be reused across multiple frame
7222        // sizes (even and odd) without capturing a fixed outer variable.
7223        let fill = |buf: &mut [u8], stride: usize, fmt: PixelFormat, w: usize, h: usize| {
7224            for y in 0..h {
7225                for x in 0..w {
7226                    buf[y * stride + x] = ((x * 9 + y * 5) & 0xff) as u8;
7227                }
7228            }
7229            let (cw, ch, uv_grid_rows) = match fmt {
7230                PixelFormat::Nv12 => (w / 2, h / 2, 1usize),
7231                PixelFormat::Nv16 => (w / 2, h, 1usize),
7232                _ => (w, h, 2usize), // Nv24: full-res chroma, 2W bytes/row
7233            };
7234            let uv_plane = h * stride;
7235            for cy in 0..ch {
7236                for cx in 0..cw {
7237                    let off = uv_plane + cy * uv_grid_rows * stride + cx * 2;
7238                    buf[off] = ((cx * 11 + 30) & 0xff) as u8;
7239                    buf[off + 1] = ((cy * 7 + 200) & 0xff) as u8;
7240                }
7241            }
7242        };
7243
7244        for fmt in [PixelFormat::Nv12, PixelFormat::Nv16, PixelFormat::Nv24] {
7245            for (w, h) in [
7246                (16usize, 16usize), // original even-dim case
7247                (15, 16),           // odd-W
7248                (16, 15),           // odd-H
7249            ] {
7250                let mem = TensorDyn::image(
7251                    w,
7252                    h,
7253                    fmt,
7254                    DType::U8,
7255                    None,
7256                    edgefirst_tensor::CpuAccess::ReadWrite,
7257                )
7258                .unwrap();
7259                let mem_stride = mem
7260                    .as_typed::<u8>()
7261                    .unwrap()
7262                    .effective_row_stride()
7263                    .unwrap();
7264                fill(
7265                    mem.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice(),
7266                    mem_stride,
7267                    fmt,
7268                    w,
7269                    h,
7270                );
7271                let cpu_dst = TensorDyn::image(
7272                    w,
7273                    h,
7274                    PixelFormat::Rgba,
7275                    DType::U8,
7276                    None,
7277                    edgefirst_tensor::CpuAccess::ReadWrite,
7278                )
7279                .unwrap();
7280                let (r, _s, cpu_dst) = convert_img(
7281                    &mut cpu,
7282                    mem,
7283                    cpu_dst,
7284                    Rotation::None,
7285                    Flip::None,
7286                    Crop::no_crop(),
7287                );
7288                r.unwrap_or_else(|e| panic!("CPU {fmt:?}->{w}x{h}->RGBA: {e}"));
7289
7290                let ios = TensorDyn::image(
7291                    w,
7292                    h,
7293                    fmt,
7294                    DType::U8,
7295                    Some(TensorMemory::DmaBuf),
7296                    edgefirst_tensor::CpuAccess::ReadWrite,
7297                )
7298                .unwrap_or_else(|e| panic!("{fmt:?} {w}x{h} IOSurface alloc: {e}"));
7299                let ios_stride = ios
7300                    .as_typed::<u8>()
7301                    .unwrap()
7302                    .effective_row_stride()
7303                    .unwrap();
7304                fill(
7305                    ios.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice(),
7306                    ios_stride,
7307                    fmt,
7308                    w,
7309                    h,
7310                );
7311                let gpu_dst = TensorDyn::image(
7312                    w,
7313                    h,
7314                    PixelFormat::Rgba,
7315                    DType::U8,
7316                    Some(TensorMemory::DmaBuf),
7317                    edgefirst_tensor::CpuAccess::ReadWrite,
7318                )
7319                .unwrap();
7320                let (r, _s, gpu_dst) = convert_img(
7321                    &mut gpu,
7322                    ios,
7323                    gpu_dst,
7324                    Rotation::None,
7325                    Flip::None,
7326                    Crop::no_crop(),
7327                );
7328                r.unwrap_or_else(|e| panic!("GPU {fmt:?}->{w}x{h}->RGBA on ANGLE: {e}"));
7329
7330                let cs = cpu_dst
7331                    .as_typed::<u8>()
7332                    .unwrap()
7333                    .effective_row_stride()
7334                    .unwrap();
7335                let cmap = cpu_dst.as_typed::<u8>().unwrap().map().unwrap();
7336                let cb = cmap.as_slice();
7337                let gs = gpu_dst
7338                    .as_typed::<u8>()
7339                    .unwrap()
7340                    .effective_row_stride()
7341                    .unwrap();
7342                let gmap = gpu_dst.as_typed::<u8>().unwrap().map().unwrap();
7343                let gb = gmap.as_slice();
7344                let mut max_d = 0i16;
7345                for y in 0..h {
7346                    for x in 0..w {
7347                        for c in 0..3 {
7348                            let cv = cb[y * cs + x * 4 + c] as i16;
7349                            let gv = gb[y * gs + x * 4 + c] as i16;
7350                            max_d = max_d.max((cv - gv).abs());
7351                        }
7352                    }
7353                }
7354                assert!(
7355                    max_d <= 3,
7356                    "{fmt:?} {w}x{h}: GPU vs CPU RGBA max channel diff {max_d} > 3"
7357                );
7358            }
7359        }
7360    }
7361
7362    /// Phase-0 gate: the reused-pool / larger-surface case. A single R8 pool
7363    /// IOSurface (allocated bigger than the frame, so its `bytesPerRow` exceeds
7364    /// the frame's even width) is reconfigured to each NV frame, filled at the
7365    /// preserved physical stride, and converted on the GPU. This proves ANGLE
7366    /// binds the *whole* physical surface as a pbuffer and that `texelFetch`
7367    /// resolves the frame's Y/UV texels through the surface's real `bytesPerRow`
7368    /// (the physical-grid / logical-ROI decoupling). GPU must match CPU ≤3 LSB.
7369    #[test]
7370    #[cfg(target_os = "macos")]
7371    #[cfg(feature = "opengl")]
7372    fn test_nv_to_rgba_larger_pool_surface_opengl_macos() {
7373        let mut gpu = match GLProcessorThreaded::new(None) {
7374            Ok(p) => p,
7375            Err(e) => {
7376                eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
7377                return;
7378            }
7379        };
7380        let mut cpu = CPUProcessor::new();
7381        // The pool is generously oversized (256-wide → bpr 256, well beyond any
7382        // frame's even width) and tall enough for NV24's 3·H at the test frames.
7383        let (pool_w, pool_h) = (256usize, 256usize);
7384
7385        // Canonical semi-planar fill: Y plane at the row stride, then the UV
7386        // plane at `h * stride` with each chroma row advancing `uv_grid_rows *
7387        // stride` bytes. Stride-correct for both tight Mem and padded IOSurface.
7388        let fill = |buf: &mut [u8], stride: usize, fmt: PixelFormat, w: usize, h: usize| {
7389            for y in 0..h {
7390                for x in 0..w {
7391                    buf[y * stride + x] = ((x * 9 + y * 5) & 0xff) as u8;
7392                }
7393            }
7394            let (cw, ch, uv_grid_rows) = match fmt {
7395                PixelFormat::Nv12 => (w / 2, h / 2, 1usize),
7396                PixelFormat::Nv16 => (w / 2, h, 1usize),
7397                _ => (w, h, 2usize), // Nv24: full-res chroma, 2W bytes/row
7398            };
7399            let uv_plane = h * stride;
7400            for cy in 0..ch {
7401                for cx in 0..cw {
7402                    let off = uv_plane + cy * uv_grid_rows * stride + cx * 2;
7403                    buf[off] = ((cx * 11 + 30) & 0xff) as u8;
7404                    buf[off + 1] = ((cy * 7 + 200) & 0xff) as u8;
7405                }
7406            }
7407        };
7408
7409        for fmt in [PixelFormat::Nv12, PixelFormat::Nv16, PixelFormat::Nv24] {
7410            for (w, h) in [
7411                (40usize, 24usize), // original even-dim case
7412                (15, 16),           // odd-W
7413                (16, 15),           // odd-H
7414            ] {
7415                // `ew` is the minimum even extent; the pool stride must exceed it
7416                // to actually exercise the physical-stride shader decoupling.
7417                let ew = w.next_multiple_of(2);
7418
7419                // CPU reference from a tightly-packed Mem tensor at the frame size.
7420                let mem = TensorDyn::image(
7421                    w,
7422                    h,
7423                    fmt,
7424                    DType::U8,
7425                    None,
7426                    edgefirst_tensor::CpuAccess::ReadWrite,
7427                )
7428                .unwrap();
7429                let mem_stride = mem
7430                    .as_typed::<u8>()
7431                    .unwrap()
7432                    .effective_row_stride()
7433                    .unwrap();
7434                fill(
7435                    mem.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice(),
7436                    mem_stride,
7437                    fmt,
7438                    w,
7439                    h,
7440                );
7441                let cpu_dst = TensorDyn::image(
7442                    w,
7443                    h,
7444                    PixelFormat::Rgba,
7445                    DType::U8,
7446                    None,
7447                    edgefirst_tensor::CpuAccess::ReadWrite,
7448                )
7449                .unwrap();
7450                let (r, _s, cpu_dst) = convert_img(
7451                    &mut cpu,
7452                    mem,
7453                    cpu_dst,
7454                    Rotation::None,
7455                    Flip::None,
7456                    Crop::no_crop(),
7457                );
7458                r.unwrap_or_else(|e| panic!("CPU {fmt:?}->{w}x{h}->RGBA: {e}"));
7459
7460                // GPU source: a LARGER R8 pool surface, reconfigured down to the
7461                // frame. Phase 1 preserves the pool's padded `bytesPerRow` as the
7462                // tensor's row stride; the fill writes the frame at that stride.
7463                let mut ios = match TensorDyn::image(
7464                    pool_w,
7465                    pool_h,
7466                    PixelFormat::Grey,
7467                    DType::U8,
7468                    Some(TensorMemory::DmaBuf),
7469                    edgefirst_tensor::CpuAccess::ReadWrite,
7470                ) {
7471                    Ok(t) => t,
7472                    Err(e) => {
7473                        eprintln!("SKIPPED: {} — R8 pool IOSurface alloc: {e:?}", function!());
7474                        return;
7475                    }
7476                };
7477                ios.configure_image(w, h, fmt)
7478                    .unwrap_or_else(|e| panic!("configure_image {fmt:?} {w}x{h} on pool: {e}"));
7479                let ios_stride = ios
7480                    .as_typed::<u8>()
7481                    .unwrap()
7482                    .effective_row_stride()
7483                    .unwrap();
7484                assert!(
7485                    ios_stride > ew,
7486                    "{fmt:?} {w}x{h}: pool stride {ios_stride} should exceed even width {ew} \
7487                     (test must exercise padding)"
7488                );
7489                fill(
7490                    ios.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice(),
7491                    ios_stride,
7492                    fmt,
7493                    w,
7494                    h,
7495                );
7496
7497                let gpu_dst = TensorDyn::image(
7498                    w,
7499                    h,
7500                    PixelFormat::Rgba,
7501                    DType::U8,
7502                    Some(TensorMemory::DmaBuf),
7503                    edgefirst_tensor::CpuAccess::ReadWrite,
7504                )
7505                .unwrap();
7506                let (r, _s, gpu_dst) = convert_img(
7507                    &mut gpu,
7508                    ios,
7509                    gpu_dst,
7510                    Rotation::None,
7511                    Flip::None,
7512                    Crop::no_crop(),
7513                );
7514                r.unwrap_or_else(|e| {
7515                    panic!("GPU {fmt:?}->{w}x{h}->RGBA (pool surface) on ANGLE: {e}")
7516                });
7517
7518                let cs = cpu_dst
7519                    .as_typed::<u8>()
7520                    .unwrap()
7521                    .effective_row_stride()
7522                    .unwrap();
7523                let cmap = cpu_dst.as_typed::<u8>().unwrap().map().unwrap();
7524                let cb = cmap.as_slice();
7525                let gs = gpu_dst
7526                    .as_typed::<u8>()
7527                    .unwrap()
7528                    .effective_row_stride()
7529                    .unwrap();
7530                let gmap = gpu_dst.as_typed::<u8>().unwrap().map().unwrap();
7531                let gb = gmap.as_slice();
7532                let mut max_d = 0i16;
7533                for y in 0..h {
7534                    for x in 0..w {
7535                        for c in 0..3 {
7536                            let cv = cb[y * cs + x * 4 + c] as i16;
7537                            let gv = gb[y * gs + x * 4 + c] as i16;
7538                            max_d = max_d.max((cv - gv).abs());
7539                        }
7540                    }
7541                }
7542                assert!(
7543                    max_d <= 3,
7544                    "{fmt:?} {w}x{h}: GPU(pool surface) vs CPU RGBA max channel diff {max_d} > 3"
7545                );
7546            }
7547        }
7548    }
7549
7550    #[test]
7551    #[cfg(target_os = "macos")]
7552    #[cfg(feature = "opengl")]
7553    fn test_yuyv_to_rgba_opengl_macos() {
7554        let mut proc = match GLProcessorThreaded::new(None) {
7555            Ok(p) => p,
7556            Err(e) => {
7557                eprintln!(
7558                    "SKIPPED: {} — GL engine init failed ({e:?}). \
7559                     Install ANGLE via `brew install startergo/angle/angle` \
7560                     and re-sign per README.md § macOS GPU Acceleration to \
7561                     run this test.",
7562                    function!()
7563                );
7564                return;
7565            }
7566        };
7567
7568        let src = load_bytes_to_tensor(
7569            1280,
7570            720,
7571            PixelFormat::Yuyv,
7572            Some(TensorMemory::DmaBuf),
7573            &edgefirst_bench::testdata::read("camera720p.yuyv"),
7574        )
7575        .unwrap();
7576
7577        let dst = TensorDyn::image(
7578            1280,
7579            720,
7580            PixelFormat::Rgba,
7581            DType::U8,
7582            Some(TensorMemory::DmaBuf),
7583            edgefirst_tensor::CpuAccess::ReadWrite,
7584        )
7585        .unwrap();
7586
7587        let (result, _src, dst) = convert_img(
7588            &mut proc,
7589            src,
7590            dst,
7591            Rotation::None,
7592            Flip::None,
7593            Crop::no_crop(),
7594        );
7595        result.unwrap();
7596
7597        let target_image = TensorDyn::image(
7598            1280,
7599            720,
7600            PixelFormat::Rgba,
7601            DType::U8,
7602            None,
7603            edgefirst_tensor::CpuAccess::ReadWrite,
7604        )
7605        .unwrap();
7606        target_image
7607            .as_typed::<u8>()
7608            .unwrap()
7609            .map()
7610            .unwrap()
7611            .as_mut_slice()
7612            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
7613
7614        // macOS YUYV shader now threads per-tensor colorimetry: the untagged
7615        // camera720p source resolves to BT.709 limited (matching the BT.709
7616        // reference), measured 0.9973 on ANGLE — up from 0.9733 under the old
7617        // BT.601-full stop-gap. 0.98 leaves headroom for cross-GPU variance.
7618        compare_images(&dst, &target_image, 0.98, function!());
7619    }
7620
7621    /// Multi-resolution smoke test: convert YUYV→RGBA via the GL
7622    /// backend at a small (64×32) frame and a 4K (3840×2160) frame,
7623    /// both filled with a synthetic mid-grey pattern. Validates the
7624    /// shader math at the chroma-pairing boundary on small textures
7625    /// and exercises the IOSurface bytes-per-row alignment path at 4K
7626    /// (3840 pixels × 2 bytes/pixel = 7680 bytes, naturally 64-aligned).
7627    ///
7628    /// Resolutions below 32 pixels wide aren't tested because the
7629    /// IOSurface allocator pads bpr to 64 bytes — for a 4-px-wide
7630    /// YUYV surface that's 8 bytes data + 56 bytes padding per row,
7631    /// which exercises a sampling pattern that's ANGLE-version
7632    /// dependent rather than HAL-correctness dependent.
7633    ///
7634    /// This complements `test_yuyv_to_rgba_opengl_macos` (which checks
7635    /// pixel-exact correctness against a reference image at 720p) by
7636    /// ensuring the pipeline does not crash or produce gross errors at
7637    /// resolution extremes. Pixel-exact validation at 4K would require
7638    /// a 30 MB reference file we don't want to bundle.
7639    #[test]
7640    #[cfg(target_os = "macos")]
7641    #[cfg(feature = "opengl")]
7642    fn test_yuyv_to_rgba_opengl_macos_multi_resolution() {
7643        let mut proc = match GLProcessorThreaded::new(None) {
7644            Ok(p) => p,
7645            Err(e) => {
7646                eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
7647                return;
7648            }
7649        };
7650
7651        for (w, h) in [(64usize, 32usize), (3840, 2160)] {
7652            // Synthetic YUYV: Y=128 (mid-grey luma), U=V=128 (neutral
7653            // chroma) → RGB grey at the output.
7654            let bytes_per_row = w * 2;
7655            let mut yuyv = vec![0u8; bytes_per_row * h];
7656            for chunk in yuyv.as_chunks_mut::<4>().0 {
7657                chunk[0] = 128; // Y0
7658                chunk[1] = 128; // U
7659                chunk[2] = 128; // Y1
7660                chunk[3] = 128; // V
7661            }
7662
7663            let src =
7664                load_bytes_to_tensor(w, h, PixelFormat::Yuyv, Some(TensorMemory::DmaBuf), &yuyv)
7665                    .unwrap();
7666
7667            let dst = TensorDyn::image(
7668                w,
7669                h,
7670                PixelFormat::Rgba,
7671                DType::U8,
7672                Some(TensorMemory::DmaBuf),
7673                edgefirst_tensor::CpuAccess::ReadWrite,
7674            )
7675            .unwrap();
7676
7677            let (result, _src, dst) = convert_img(
7678                &mut proc,
7679                src,
7680                dst,
7681                Rotation::None,
7682                Flip::None,
7683                Crop::no_crop(),
7684            );
7685            result.expect("GL convert should succeed at this resolution");
7686
7687            // The neutral-chroma input must produce a near-grey output;
7688            // BT.709 limited-range maps Y=128/UV=128 → roughly
7689            // (130, 130, 130). Allow ±4 LSB for `mediump float` shader
7690            // rounding.
7691            let dst_u8 = dst.as_typed::<u8>().unwrap();
7692            let dst_map = dst_u8.map().unwrap();
7693            let dst_bytes = dst_map.as_slice();
7694            assert_eq!(dst_bytes.len(), w * h * 4, "RGBA byte count");
7695            for px in dst_bytes.as_chunks::<4>().0 {
7696                for (i, &c) in px[..3].iter().enumerate() {
7697                    assert!(
7698                        (120..=140).contains(&c),
7699                        "{}: channel {i} = {c} (expected ~128 ±12) at {w}×{h}",
7700                        function!(),
7701                    );
7702                }
7703                assert_eq!(px[3], 255, "alpha must be 1.0");
7704            }
7705        }
7706    }
7707
7708    /// Verify that two consecutive convert() calls on the same source
7709    /// tensor reuse the cached EGL pbuffer. Tests the cache hit path
7710    /// added with the macOS GL backend hardening — without it, each
7711    /// frame would pay `eglCreatePbufferFromClientBuffer` + destroy.
7712    ///
7713    /// This is a behaviour test rather than a perf test (the timing
7714    /// difference is 100-200µs which is too noisy to assert on); we
7715    /// check that the second call succeeds and produces a result
7716    /// identical to the first.
7717    #[test]
7718    #[cfg(target_os = "macos")]
7719    #[cfg(feature = "opengl")]
7720    fn test_macos_gl_pbuffer_cache_reuses_surfaces() {
7721        let mut proc = match GLProcessorThreaded::new(None) {
7722            Ok(p) => p,
7723            Err(e) => {
7724                eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
7725                return;
7726            }
7727        };
7728
7729        // Allocate one source + one destination, run convert twice.
7730        let mut yuyv = vec![0u8; 64 * 32 * 2];
7731        for chunk in yuyv.as_chunks_mut::<4>().0 {
7732            chunk[0] = 200;
7733            chunk[1] = 100;
7734            chunk[2] = 200;
7735            chunk[3] = 156;
7736        }
7737        let src =
7738            load_bytes_to_tensor(64, 32, PixelFormat::Yuyv, Some(TensorMemory::DmaBuf), &yuyv)
7739                .unwrap();
7740        let dst = TensorDyn::image(
7741            64,
7742            32,
7743            PixelFormat::Rgba,
7744            DType::U8,
7745            Some(TensorMemory::DmaBuf),
7746            edgefirst_tensor::CpuAccess::ReadWrite,
7747        )
7748        .unwrap();
7749
7750        let (r1, src, dst) = convert_img(
7751            &mut proc,
7752            src,
7753            dst,
7754            Rotation::None,
7755            Flip::None,
7756            Crop::no_crop(),
7757        );
7758        r1.unwrap();
7759        let first: Vec<u8> = dst
7760            .as_typed::<u8>()
7761            .unwrap()
7762            .map()
7763            .unwrap()
7764            .as_slice()
7765            .to_vec();
7766
7767        let (r2, _src, dst) = convert_img(
7768            &mut proc,
7769            src,
7770            dst,
7771            Rotation::None,
7772            Flip::None,
7773            Crop::no_crop(),
7774        );
7775        r2.unwrap();
7776        let second: Vec<u8> = dst
7777            .as_typed::<u8>()
7778            .unwrap()
7779            .map()
7780            .unwrap()
7781            .as_slice()
7782            .to_vec();
7783
7784        assert_eq!(first, second, "cache-hit conversion must be deterministic");
7785    }
7786
7787    /// Steady-state import gate (macOS half of the Linux
7788    /// `dma_pool_steady_state_zero_imports` test): an N-frame convert loop
7789    /// over a fixed pool of IOSurface tensors must create ZERO new EGL
7790    /// pbuffers after the pool has been seen once — pbuffer-cache misses
7791    /// stay flat while hits grow. Counter-based hardening of
7792    /// `test_macos_gl_pbuffer_cache_reuses_surfaces` above: a refactor that
7793    /// re-imports per frame passes the pixel-equality test but fails this.
7794    #[test]
7795    #[cfg(target_os = "macos")]
7796    #[cfg(feature = "opengl")]
7797    fn test_macos_gl_pbuffer_cache_steady_state() {
7798        let mut proc = match GLProcessorThreaded::new(None) {
7799            Ok(p) => p,
7800            Err(e) => {
7801                eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
7802                return;
7803            }
7804        };
7805
7806        let (w, h) = (64usize, 32usize);
7807        const POOL: usize = 3;
7808        const FRAMES: usize = 100;
7809
7810        let yuyv = vec![128u8; w * h * 2];
7811        let pool: Vec<TensorDyn> = (0..POOL)
7812            .map(|_| {
7813                load_bytes_to_tensor(w, h, PixelFormat::Yuyv, Some(TensorMemory::DmaBuf), &yuyv)
7814                    .unwrap()
7815            })
7816            .collect();
7817        let mut dst = TensorDyn::image(
7818            w,
7819            h,
7820            PixelFormat::Rgba,
7821            DType::U8,
7822            Some(TensorMemory::DmaBuf),
7823            edgefirst_tensor::CpuAccess::ReadWrite,
7824        )
7825        .unwrap();
7826
7827        // Warmup: two passes over the pool import every surface once.
7828        for src in pool.iter().cycle().take(POOL * 2) {
7829            proc.convert(src, &mut dst, Rotation::None, Flip::None, Crop::no_crop())
7830                .unwrap();
7831        }
7832        let warm = proc.egl_cache_stats().unwrap();
7833
7834        for src in pool.iter().cycle().take(FRAMES) {
7835            proc.convert(src, &mut dst, Rotation::None, Flip::None, Crop::no_crop())
7836                .unwrap();
7837        }
7838        let steady = proc.egl_cache_stats().unwrap();
7839
7840        assert_eq!(
7841            warm.total_misses(),
7842            steady.total_misses(),
7843            "steady-state loop created new imports (warm {warm:?}, steady {steady:?})"
7844        );
7845        let hits = |s: &GlCacheStats| s.src.hits + s.dst.hits + s.nv_r8.hits;
7846        assert!(
7847            hits(&steady) - hits(&warm) >= FRAMES as u64,
7848            "expected at least {FRAMES} import-cache hits over the loop, got {}",
7849            hits(&steady) - hits(&warm)
7850        );
7851    }
7852
7853    /// Backend assertion for the F16 zero-copy path: when the GL backend
7854    /// initialized (ANGLE on macOS) and reports F16 color-buffer support,
7855    /// the NV12→PlanarRgb-F16 IOSurface convert MUST be handled by the GL
7856    /// engine — proven by the engine's import-cache counters moving, not
7857    /// just by output correctness. This is the guard against the
7858    /// silent-CPU-fallback failure mode: a misclassified IOSurface F16
7859    /// destination keeps every output-correctness test green while
7860    /// quietly running ~10× slower on CPU; only a backend observable
7861    /// catches it. Skips ONLY when GL itself is unavailable or the
7862    /// configuration lacks F16 — a convert error or a CPU-routed convert
7863    /// with the capability present is a FAILURE.
7864    #[test]
7865    #[cfg(target_os = "macos")]
7866    #[cfg(feature = "opengl")]
7867    fn test_macos_gl_f16_planar_is_gl_backed() {
7868        let mut proc = ImageProcessor::new().expect("ImageProcessor");
7869        let Some(ref gl) = proc.opengl else {
7870            eprintln!("SKIPPED: {} — GL backend unavailable", function!());
7871            return;
7872        };
7873        if !gl.supported_render_dtypes().f16 {
7874            eprintln!(
7875                "SKIPPED: {} — configuration lacks F16 color-buffer support",
7876                function!()
7877            );
7878            return;
7879        }
7880        let stats_before = gl.egl_cache_stats().expect("cache stats");
7881
7882        let src = TensorDyn::image(
7883            1280,
7884            720,
7885            PixelFormat::Nv12,
7886            DType::U8,
7887            Some(TensorMemory::DmaBuf),
7888            edgefirst_tensor::CpuAccess::ReadWrite,
7889        )
7890        .unwrap();
7891        {
7892            let t = src.as_typed::<u8>().unwrap();
7893            let mut m = t.map().unwrap();
7894            for (i, b) in m.as_mut_slice().iter_mut().enumerate() {
7895                *b = ((i * 31) % 211) as u8;
7896            }
7897        }
7898        let mut dst = TensorDyn::image(
7899            640,
7900            640,
7901            PixelFormat::PlanarRgb,
7902            DType::F16,
7903            Some(TensorMemory::DmaBuf),
7904            edgefirst_tensor::CpuAccess::ReadWrite,
7905        )
7906        .unwrap();
7907
7908        proc.convert(
7909            &src,
7910            &mut dst,
7911            Rotation::None,
7912            Flip::None,
7913            Crop::letterbox([114, 114, 114, 255]),
7914        )
7915        .expect("F16 capability reported but the NV12→PlanarF16 convert failed");
7916        let stats_after = proc
7917            .opengl
7918            .as_ref()
7919            .expect("GL backend present")
7920            .egl_cache_stats()
7921            .expect("cache stats");
7922        // ≥ 2 misses: the fused convert imports the zero-copy NV source
7923        // (pass 1) AND the F16 destination (pass 2). Requiring both means a
7924        // convert that imported its source, failed mid-engine, and fell back
7925        // to CPU (1 miss) cannot satisfy this gate.
7926        assert!(
7927            stats_after.total_misses() >= stats_before.total_misses() + 2,
7928            "convert succeeded but the GL engine did not import both the \
7929             source and the F16 destination — the work did not (fully) run \
7930             on the GL backend (silent CPU fallback); misses before={} after={}",
7931            stats_before.total_misses(),
7932            stats_after.total_misses()
7933        );
7934    }
7935
7936    /// Portable oracle for the fused NV12→PlanarRgb-F16 engine convert
7937    /// (two GL passes: NV→RGBA intermediate, then the packed RGBA16F
7938    /// render). New on every platform with F16 render support — macOS
7939    /// IOSurface and Linux DMA-BUF alike. Compares against the CPU
7940    /// backend's reference within the float-path tolerance.
7941    #[test]
7942    #[cfg(feature = "opengl")]
7943    fn test_nv12_to_planar_f16_fused_engine_vs_cpu() {
7944        let mut gl = match ImageProcessor::with_config(ImageProcessorConfig {
7945            backend: ComputeBackend::OpenGl,
7946            ..Default::default()
7947        }) {
7948            Ok(p) if p.opengl.is_some() => p,
7949            _ => {
7950                eprintln!("SKIPPED: {} — GL backend unavailable", function!());
7951                return;
7952            }
7953        };
7954        if !gl
7955            .opengl
7956            .as_ref()
7957            .map(|g| g.supported_render_dtypes().f16)
7958            .unwrap_or(false)
7959        {
7960            eprintln!("SKIPPED: {} — no F16 render support", function!());
7961            return;
7962        }
7963        let mem = if edgefirst_tensor::is_gpu_buffer_available() {
7964            TensorMemory::DmaBuf
7965        } else {
7966            eprintln!("SKIPPED: {} — no zero-copy buffers", function!());
7967            return;
7968        };
7969
7970        let src = TensorDyn::image(
7971            1280,
7972            720,
7973            PixelFormat::Nv12,
7974            DType::U8,
7975            Some(mem),
7976            edgefirst_tensor::CpuAccess::ReadWrite,
7977        )
7978        .unwrap();
7979        {
7980            // Smooth gradients, NOT noise: the GL and CPU paths upsample
7981            // chroma with different kernels (nearest vs bilinear), which
7982            // legitimately diverges on per-texel chroma noise. Gradients
7983            // keep that kernel difference sub-LSB while still exercising
7984            // the full matrix math and the letterbox geometry.
7985            let t = src.as_typed::<u8>().unwrap();
7986            let mut m = t.map().unwrap();
7987            let buf = m.as_mut_slice();
7988            let (w, h) = (1280usize, 720usize);
7989            for y in 0..h {
7990                for x in 0..w {
7991                    buf[y * w + x] = ((x * 255) / w) as u8; // luma ramp
7992                }
7993            }
7994            for y in 0..(h / 2) {
7995                for x in 0..(w / 2) {
7996                    let o = h * w + y * w + 2 * x;
7997                    buf[o] = ((y * 255) / (h / 2)) as u8; // U vertical ramp
7998                    buf[o + 1] = (((x + y) * 255) / (w / 2 + h / 2)) as u8; // V diagonal
7999                }
8000            }
8001        }
8002        let crop = Crop::letterbox([114, 114, 114, 255]);
8003        let mut gl_dst = TensorDyn::image(
8004            640,
8005            640,
8006            PixelFormat::PlanarRgb,
8007            DType::F16,
8008            Some(mem),
8009            edgefirst_tensor::CpuAccess::ReadWrite,
8010        )
8011        .unwrap();
8012        // Drive the GL backend DIRECTLY: a convert through `ImageProcessor`
8013        // silently falls back to CPU on a GL error, turning this oracle into
8014        // a CPU-vs-CPU tautology. A direct call surfaces the engine error.
8015        // GPUs that allocate DMA-BUF but cannot complete an RGBA16F FBO
8016        // (desktop NVIDIA: GL_FRAMEBUFFER_INCOMPLETE_ATTACHMENT 0x8cd6)
8017        // take the production CPU fallback; this oracle cannot run there.
8018        match gl.opengl.as_mut().expect("GL backend present").convert(
8019            &src,
8020            &mut gl_dst,
8021            Rotation::None,
8022            Flip::None,
8023            crop,
8024        ) {
8025            Ok(()) => {}
8026            Err(crate::Error::NotSupported(msg)) => {
8027                eprintln!("SKIPPED: {} — {msg}", function!());
8028                return;
8029            }
8030            Err(e) => panic!("fused NV12→PlanarF16 GL convert: {e}"),
8031        }
8032
8033        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
8034            backend: ComputeBackend::Cpu,
8035            ..Default::default()
8036        })
8037        .unwrap();
8038        let mut cpu_dst = TensorDyn::image(
8039            640,
8040            640,
8041            PixelFormat::PlanarRgb,
8042            DType::F16,
8043            Some(TensorMemory::Mem),
8044            edgefirst_tensor::CpuAccess::ReadWrite,
8045        )
8046        .unwrap();
8047        cpu.convert(&src, &mut cpu_dst, Rotation::None, Flip::None, crop)
8048            .expect("CPU reference convert");
8049
8050        let g = gl_dst
8051            .as_typed::<half::f16>()
8052            .unwrap()
8053            .map()
8054            .unwrap()
8055            .as_slice()
8056            .to_vec();
8057        let c = cpu_dst
8058            .as_typed::<half::f16>()
8059            .unwrap()
8060            .map()
8061            .unwrap()
8062            .as_slice()
8063            .to_vec();
8064        assert_eq!(g.len(), c.len());
8065        let mut max_diff = 0.0f32;
8066        let mut max_at = 0usize;
8067        for (i, (a, b)) in g.iter().zip(c.iter()).enumerate() {
8068            let d = (a.to_f32() - b.to_f32()).abs();
8069            if d > max_diff {
8070                max_diff = d;
8071                max_at = i;
8072            }
8073        }
8074        // Localize: plane (R/G/B), row, col of the worst element.
8075        let (plane, rem) = (max_at / (640 * 640), max_at % (640 * 640));
8076        let (row, col) = (rem / 640, rem % 640);
8077        eprintln!(
8078            "fused-vs-cpu: max_diff={max_diff} at plane={plane} row={row} col={col} \
8079             gl={} cpu={}",
8080            g[max_at].to_f32(),
8081            c[max_at].to_f32()
8082        );
8083        // Two GPU passes (8-bit intermediate + linear filtering) vs the
8084        // CPU's direct path: allow a few 8-bit steps of divergence.
8085        assert!(
8086            max_diff <= 4.0 / 255.0 + 1e-3,
8087            "fused NV12→PlanarF16 diverges from CPU reference: max_diff={max_diff}"
8088        );
8089    }
8090
8091    /// Zero-copy source → heap destination through the GL engine,
8092    /// driven on the GL backend DIRECTLY so a GL error cannot hide
8093    /// behind the `ImageProcessor` CPU fallback (the shape that exposed
8094    /// the macOS `glReadnPixels` failure: imported source, rendered,
8095    /// then errored at the heap readback). RGBA→BGRA so the convert is
8096    /// a pure byte shuffle: no chroma kernel or colorimetry ambiguity
8097    /// (Vivante's NV fast path legitimately diverges from the CPU
8098    /// reference), and the readback stays GL_RGBA (V3D rejects RGB
8099    /// readbacks).
8100    #[test]
8101    #[cfg(feature = "opengl")]
8102    fn test_zero_copy_src_to_mem_dst_gl_direct() {
8103        let mut proc = match ImageProcessor::new() {
8104            Ok(p) if p.opengl.is_some() => p,
8105            _ => {
8106                eprintln!("SKIPPED: {} — GL backend unavailable", function!());
8107                return;
8108            }
8109        };
8110        if !edgefirst_tensor::is_gpu_buffer_available() {
8111            eprintln!("SKIPPED: {} — no zero-copy buffers", function!());
8112            return;
8113        }
8114
8115        let src = TensorDyn::image(
8116            1280,
8117            720,
8118            PixelFormat::Rgba,
8119            DType::U8,
8120            Some(TensorMemory::DmaBuf),
8121            edgefirst_tensor::CpuAccess::ReadWrite,
8122        )
8123        .unwrap();
8124        {
8125            let t = src.as_typed::<u8>().unwrap();
8126            let mut m = t.map().unwrap();
8127            for (i, b) in m.as_mut_slice().iter_mut().enumerate() {
8128                *b = ((i * 31) % 211) as u8;
8129            }
8130        }
8131        let mut gl_dst = TensorDyn::image(
8132            1280,
8133            720,
8134            PixelFormat::Bgra,
8135            DType::U8,
8136            Some(TensorMemory::Mem),
8137            edgefirst_tensor::CpuAccess::ReadWrite,
8138        )
8139        .unwrap();
8140        proc.opengl
8141            .as_mut()
8142            .expect("GL backend present")
8143            .convert(
8144                &src,
8145                &mut gl_dst,
8146                Rotation::None,
8147                Flip::None,
8148                Crop::no_crop(),
8149            )
8150            .expect("zero-copy src → heap dst GL convert");
8151
8152        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
8153            backend: ComputeBackend::Cpu,
8154            ..Default::default()
8155        })
8156        .unwrap();
8157        let mut cpu_dst = TensorDyn::image(
8158            1280,
8159            720,
8160            PixelFormat::Bgra,
8161            DType::U8,
8162            Some(TensorMemory::Mem),
8163            edgefirst_tensor::CpuAccess::ReadWrite,
8164        )
8165        .unwrap();
8166        cpu.convert(
8167            &src,
8168            &mut cpu_dst,
8169            Rotation::None,
8170            Flip::None,
8171            Crop::no_crop(),
8172        )
8173        .expect("CPU reference convert");
8174
8175        let g = gl_dst
8176            .as_typed::<u8>()
8177            .unwrap()
8178            .map()
8179            .unwrap()
8180            .as_slice()
8181            .to_vec();
8182        let c = cpu_dst
8183            .as_typed::<u8>()
8184            .unwrap()
8185            .map()
8186            .unwrap()
8187            .as_slice()
8188            .to_vec();
8189        assert_eq!(g.len(), c.len());
8190        let max_diff = g
8191            .iter()
8192            .zip(c.iter())
8193            .map(|(a, b)| a.abs_diff(*b))
8194            .max()
8195            .unwrap();
8196        // Same-size byte shuffle: GL samples texel centers 1:1, so allow
8197        // only rounding slack.
8198        assert!(
8199            max_diff <= 2,
8200            "zero-copy src → heap dst diverges from CPU reference: max_diff={max_diff}"
8201        );
8202    }
8203
8204    #[test]
8205    #[cfg(target_os = "linux")]
8206    fn test_yuyv_to_rgb_g2d() {
8207        if !is_g2d_available() {
8208            eprintln!("SKIPPED: test_yuyv_to_rgb_g2d - G2D library (libg2d.so.2) not available");
8209            return;
8210        }
8211        if !is_dma_available() {
8212            eprintln!(
8213                "SKIPPED: test_yuyv_to_rgb_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
8214            );
8215            return;
8216        }
8217
8218        let src = load_bytes_to_tensor(
8219            1280,
8220            720,
8221            PixelFormat::Yuyv,
8222            None,
8223            &edgefirst_bench::testdata::read("camera720p.yuyv"),
8224        )
8225        .unwrap();
8226
8227        let g2d_dst = TensorDyn::image(
8228            1280,
8229            720,
8230            PixelFormat::Rgb,
8231            DType::U8,
8232            Some(TensorMemory::DmaBuf),
8233            edgefirst_tensor::CpuAccess::ReadWrite,
8234        )
8235        .unwrap();
8236        let mut g2d_converter = G2DProcessor::new().unwrap();
8237
8238        let (result, src, g2d_dst) = convert_img(
8239            &mut g2d_converter,
8240            src,
8241            g2d_dst,
8242            Rotation::None,
8243            Flip::None,
8244            Crop::no_crop(),
8245        );
8246        result.unwrap();
8247
8248        let cpu_dst = TensorDyn::image(
8249            1280,
8250            720,
8251            PixelFormat::Rgb,
8252            DType::U8,
8253            None,
8254            edgefirst_tensor::CpuAccess::ReadWrite,
8255        )
8256        .unwrap();
8257        let mut cpu_converter: CPUProcessor = CPUProcessor::new();
8258
8259        let (result, _src, cpu_dst) = convert_img(
8260            &mut cpu_converter,
8261            src,
8262            cpu_dst,
8263            Rotation::None,
8264            Flip::None,
8265            Crop::no_crop(),
8266        );
8267        result.unwrap();
8268
8269        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
8270        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
8271        // the YUV-matrix delta that forced 0.95 has closed; tightened to
8272        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
8273        // structural gap not exercised by these limited-range fixtures.
8274        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
8275    }
8276
8277    #[test]
8278    #[cfg(target_os = "linux")]
8279    fn test_yuyv_to_yuyv_resize_g2d() {
8280        if !is_g2d_available() {
8281            eprintln!(
8282                "SKIPPED: test_yuyv_to_yuyv_resize_g2d - G2D library (libg2d.so.2) not available"
8283            );
8284            return;
8285        }
8286        if !is_dma_available() {
8287            eprintln!(
8288                "SKIPPED: test_yuyv_to_yuyv_resize_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
8289            );
8290            return;
8291        }
8292
8293        let src = load_bytes_to_tensor(
8294            1280,
8295            720,
8296            PixelFormat::Yuyv,
8297            None,
8298            &edgefirst_bench::testdata::read("camera720p.yuyv"),
8299        )
8300        .unwrap();
8301
8302        let g2d_dst = TensorDyn::image(
8303            600,
8304            400,
8305            PixelFormat::Yuyv,
8306            DType::U8,
8307            Some(TensorMemory::DmaBuf),
8308            edgefirst_tensor::CpuAccess::ReadWrite,
8309        )
8310        .unwrap();
8311        let mut g2d_converter = G2DProcessor::new().unwrap();
8312
8313        let (result, src, g2d_dst) = convert_img(
8314            &mut g2d_converter,
8315            src,
8316            g2d_dst,
8317            Rotation::None,
8318            Flip::None,
8319            Crop::no_crop(),
8320        );
8321        result.unwrap();
8322
8323        let cpu_dst = TensorDyn::image(
8324            600,
8325            400,
8326            PixelFormat::Yuyv,
8327            DType::U8,
8328            None,
8329            edgefirst_tensor::CpuAccess::ReadWrite,
8330        )
8331        .unwrap();
8332        let mut cpu_converter: CPUProcessor = CPUProcessor::new();
8333
8334        let (result, _src, cpu_dst) = convert_img(
8335            &mut cpu_converter,
8336            src,
8337            cpu_dst,
8338            Rotation::None,
8339            Flip::None,
8340            Crop::no_crop(),
8341        );
8342        result.unwrap();
8343
8344        // G2D has poor colorimetry support: its hardware YUYV resize/sampling
8345        // diverges from the CPU reference by enough that the similarity score
8346        // sits around 0.85 on every measured G2D core (i.MX 8M Plus, i.MX 95).
8347        // The threshold is held at 0.85 so the test guards against gross
8348        // regressions while tolerating the driver's inherent colorimetry error.
8349        // TODO: compare YUYV↔YUYV directly without a YUYV→RGB convert.
8350        eprintln!(
8351            "WARNING: G2D has poor colorimetry support — YUYV resize diverges from the \
8352             CPU reference (~0.85 similarity); threshold held at 0.85, not 0.95."
8353        );
8354        compare_images_convert_to_rgb(&g2d_dst, &cpu_dst, 0.85, function!());
8355    }
8356
8357    #[test]
8358    fn test_yuyv_to_rgba_resize_cpu() {
8359        let src = load_bytes_to_tensor(
8360            1280,
8361            720,
8362            PixelFormat::Yuyv,
8363            None,
8364            &edgefirst_bench::testdata::read("camera720p.yuyv"),
8365        )
8366        .unwrap();
8367
8368        let (dst_width, dst_height) = (960, 540);
8369
8370        let dst = TensorDyn::image(
8371            dst_width,
8372            dst_height,
8373            PixelFormat::Rgba,
8374            DType::U8,
8375            None,
8376            edgefirst_tensor::CpuAccess::ReadWrite,
8377        )
8378        .unwrap();
8379        let mut cpu_converter = CPUProcessor::new();
8380
8381        let (result, _src, dst) = convert_img(
8382            &mut cpu_converter,
8383            src,
8384            dst,
8385            Rotation::None,
8386            Flip::None,
8387            Crop::no_crop(),
8388        );
8389        result.unwrap();
8390
8391        let dst_target = TensorDyn::image(
8392            dst_width,
8393            dst_height,
8394            PixelFormat::Rgba,
8395            DType::U8,
8396            None,
8397            edgefirst_tensor::CpuAccess::ReadWrite,
8398        )
8399        .unwrap();
8400        let src_target = load_bytes_to_tensor(
8401            1280,
8402            720,
8403            PixelFormat::Rgba,
8404            None,
8405            &edgefirst_bench::testdata::read("camera720p.rgba"),
8406        )
8407        .unwrap();
8408        let (result, _src_target, dst_target) = convert_img(
8409            &mut cpu_converter,
8410            src_target,
8411            dst_target,
8412            Rotation::None,
8413            Flip::None,
8414            Crop::no_crop(),
8415        );
8416        result.unwrap();
8417
8418        // CPU path resolves the untagged 720p source to BT.709 limited (height
8419        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
8420        compare_images(&dst, &dst_target, 0.98, function!());
8421    }
8422
8423    #[test]
8424    #[cfg(target_os = "linux")]
8425    fn test_yuyv_to_rgba_crop_flip_g2d() {
8426        if !is_g2d_available() {
8427            eprintln!(
8428                "SKIPPED: test_yuyv_to_rgba_crop_flip_g2d - G2D library (libg2d.so.2) not available"
8429            );
8430            return;
8431        }
8432        if !is_dma_available() {
8433            eprintln!(
8434                "SKIPPED: test_yuyv_to_rgba_crop_flip_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
8435            );
8436            return;
8437        }
8438
8439        let src = load_bytes_to_tensor(
8440            1280,
8441            720,
8442            PixelFormat::Yuyv,
8443            Some(TensorMemory::DmaBuf),
8444            &edgefirst_bench::testdata::read("camera720p.yuyv"),
8445        )
8446        .unwrap();
8447
8448        let (dst_width, dst_height) = (640, 640);
8449
8450        let dst_g2d = TensorDyn::image(
8451            dst_width,
8452            dst_height,
8453            PixelFormat::Rgba,
8454            DType::U8,
8455            Some(TensorMemory::DmaBuf),
8456            edgefirst_tensor::CpuAccess::ReadWrite,
8457        )
8458        .unwrap();
8459        let mut g2d_converter = G2DProcessor::new().unwrap();
8460        let crop = Crop::new().with_source(Some(Region::new(20, 15, 400, 300)));
8461
8462        let (result, src, dst_g2d) = convert_img(
8463            &mut g2d_converter,
8464            src,
8465            dst_g2d,
8466            Rotation::None,
8467            Flip::Horizontal,
8468            crop,
8469        );
8470        result.unwrap();
8471
8472        let dst_cpu = TensorDyn::image(
8473            dst_width,
8474            dst_height,
8475            PixelFormat::Rgba,
8476            DType::U8,
8477            Some(TensorMemory::DmaBuf),
8478            edgefirst_tensor::CpuAccess::ReadWrite,
8479        )
8480        .unwrap();
8481        let mut cpu_converter = CPUProcessor::new();
8482
8483        let (result, _src, dst_cpu) = convert_img(
8484            &mut cpu_converter,
8485            src,
8486            dst_cpu,
8487            Rotation::None,
8488            Flip::Horizontal,
8489            crop,
8490        );
8491        result.unwrap();
8492        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
8493        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
8494        // the YUV-matrix delta that forced 0.95 has closed; tightened to
8495        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
8496        // structural gap not exercised by these limited-range fixtures.
8497        compare_images(&dst_g2d, &dst_cpu, 0.98, function!());
8498    }
8499
8500    #[test]
8501    #[cfg(any(
8502        target_os = "linux",
8503        target_os = "macos",
8504        target_os = "ios",
8505        target_os = "android",
8506        target_os = "windows"
8507    ))]
8508    #[cfg(feature = "opengl")]
8509    fn test_yuyv_to_rgba_crop_flip_opengl() {
8510        if !is_opengl_available() {
8511            eprintln!("SKIPPED: {} - OpenGL not available", function!());
8512            return;
8513        }
8514
8515        if !edgefirst_tensor::is_gpu_buffer_available() {
8516            eprintln!(
8517                "SKIPPED: {} - zero-copy GPU buffer (DMA-BUF / IOSurface) not available on this host",
8518                function!()
8519            );
8520            return;
8521        }
8522
8523        let src = load_bytes_to_tensor(
8524            1280,
8525            720,
8526            PixelFormat::Yuyv,
8527            Some(TensorMemory::DmaBuf),
8528            &edgefirst_bench::testdata::read("camera720p.yuyv"),
8529        )
8530        .unwrap();
8531
8532        let (dst_width, dst_height) = (640, 640);
8533
8534        let dst_gl = TensorDyn::image(
8535            dst_width,
8536            dst_height,
8537            PixelFormat::Rgba,
8538            DType::U8,
8539            Some(TensorMemory::DmaBuf),
8540            edgefirst_tensor::CpuAccess::ReadWrite,
8541        )
8542        .unwrap();
8543        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
8544        let crop = Crop::new().with_source(Some(Region::new(20, 15, 400, 300)));
8545
8546        let (result, src, dst_gl) = convert_img(
8547            &mut gl_converter,
8548            src,
8549            dst_gl,
8550            Rotation::None,
8551            Flip::Horizontal,
8552            crop,
8553        );
8554        // Desktop NVIDIA cannot sample YUYV as a GL texture; i.MX/Vivante can.
8555        if let Err(crate::Error::NotSupported(msg)) = &result {
8556            eprintln!("SKIPPED: {} — {msg}", function!());
8557            return;
8558        }
8559        result.unwrap();
8560
8561        let dst_cpu = TensorDyn::image(
8562            dst_width,
8563            dst_height,
8564            PixelFormat::Rgba,
8565            DType::U8,
8566            Some(TensorMemory::DmaBuf),
8567            edgefirst_tensor::CpuAccess::ReadWrite,
8568        )
8569        .unwrap();
8570        let mut cpu_converter = CPUProcessor::new();
8571
8572        let (result, _src, dst_cpu) = convert_img(
8573            &mut cpu_converter,
8574            src,
8575            dst_cpu,
8576            Rotation::None,
8577            Flip::Horizontal,
8578            crop,
8579        );
8580        result.unwrap();
8581        // Post-WS1 the GL path applies the resolved colorimetry via the EGL
8582        // YUV color-space/sample-range hints, so the matrix delta that forced
8583        // 0.95 has closed; tightened to 0.98 (driver-matrix rounding confirmed
8584        // on the GPU lanes).
8585        compare_images(&dst_gl, &dst_cpu, 0.98, function!());
8586    }
8587
8588    #[test]
8589    fn test_vyuy_to_rgba_cpu() {
8590        let file = edgefirst_bench::testdata::read("camera720p.vyuy").to_vec();
8591        let src = TensorDyn::image(
8592            1280,
8593            720,
8594            PixelFormat::Vyuy,
8595            DType::U8,
8596            None,
8597            edgefirst_tensor::CpuAccess::ReadWrite,
8598        )
8599        .unwrap();
8600        src.as_typed::<u8>()
8601            .unwrap()
8602            .map()
8603            .unwrap()
8604            .as_mut_slice()
8605            .copy_from_slice(&file);
8606
8607        let dst = TensorDyn::image(
8608            1280,
8609            720,
8610            PixelFormat::Rgba,
8611            DType::U8,
8612            None,
8613            edgefirst_tensor::CpuAccess::ReadWrite,
8614        )
8615        .unwrap();
8616        let mut cpu_converter = CPUProcessor::new();
8617
8618        let (result, _src, dst) = convert_img(
8619            &mut cpu_converter,
8620            src,
8621            dst,
8622            Rotation::None,
8623            Flip::None,
8624            Crop::no_crop(),
8625        );
8626        result.unwrap();
8627
8628        let target_image = TensorDyn::image(
8629            1280,
8630            720,
8631            PixelFormat::Rgba,
8632            DType::U8,
8633            None,
8634            edgefirst_tensor::CpuAccess::ReadWrite,
8635        )
8636        .unwrap();
8637        target_image
8638            .as_typed::<u8>()
8639            .unwrap()
8640            .map()
8641            .unwrap()
8642            .as_mut_slice()
8643            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
8644
8645        // CPU path resolves the untagged 720p source to BT.709 limited (height
8646        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
8647        compare_images(&dst, &target_image, 0.98, function!());
8648    }
8649
8650    #[test]
8651    fn test_vyuy_to_rgb_cpu() {
8652        let file = edgefirst_bench::testdata::read("camera720p.vyuy").to_vec();
8653        let src = TensorDyn::image(
8654            1280,
8655            720,
8656            PixelFormat::Vyuy,
8657            DType::U8,
8658            None,
8659            edgefirst_tensor::CpuAccess::ReadWrite,
8660        )
8661        .unwrap();
8662        src.as_typed::<u8>()
8663            .unwrap()
8664            .map()
8665            .unwrap()
8666            .as_mut_slice()
8667            .copy_from_slice(&file);
8668
8669        let dst = TensorDyn::image(
8670            1280,
8671            720,
8672            PixelFormat::Rgb,
8673            DType::U8,
8674            None,
8675            edgefirst_tensor::CpuAccess::ReadWrite,
8676        )
8677        .unwrap();
8678        let mut cpu_converter = CPUProcessor::new();
8679
8680        let (result, _src, dst) = convert_img(
8681            &mut cpu_converter,
8682            src,
8683            dst,
8684            Rotation::None,
8685            Flip::None,
8686            Crop::no_crop(),
8687        );
8688        result.unwrap();
8689
8690        let target_image = TensorDyn::image(
8691            1280,
8692            720,
8693            PixelFormat::Rgb,
8694            DType::U8,
8695            None,
8696            edgefirst_tensor::CpuAccess::ReadWrite,
8697        )
8698        .unwrap();
8699        target_image
8700            .as_typed::<u8>()
8701            .unwrap()
8702            .map()
8703            .unwrap()
8704            .as_mut_slice()
8705            .as_chunks_mut::<3>()
8706            .0
8707            .iter_mut()
8708            .zip(
8709                edgefirst_bench::testdata::read("camera720p.rgba")
8710                    .as_chunks::<4>()
8711                    .0,
8712            )
8713            .for_each(|(dst, src)| *dst = [src[0], src[1], src[2]]);
8714
8715        // CPU path resolves the untagged 720p source to BT.709 limited (height
8716        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
8717        compare_images(&dst, &target_image, 0.98, function!());
8718    }
8719
8720    #[test]
8721    #[cfg(target_os = "linux")]
8722    #[ignore = "G2D does not support VYUY; re-enable when hardware support is added"]
8723    fn test_vyuy_to_rgba_g2d() {
8724        if !is_g2d_available() {
8725            eprintln!("SKIPPED: test_vyuy_to_rgba_g2d - G2D library (libg2d.so.2) not available");
8726            return;
8727        }
8728        if !is_dma_available() {
8729            eprintln!(
8730                "SKIPPED: test_vyuy_to_rgba_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
8731            );
8732            return;
8733        }
8734
8735        let src = load_bytes_to_tensor(
8736            1280,
8737            720,
8738            PixelFormat::Vyuy,
8739            None,
8740            &edgefirst_bench::testdata::read("camera720p.vyuy"),
8741        )
8742        .unwrap();
8743
8744        let dst = TensorDyn::image(
8745            1280,
8746            720,
8747            PixelFormat::Rgba,
8748            DType::U8,
8749            Some(TensorMemory::DmaBuf),
8750            edgefirst_tensor::CpuAccess::ReadWrite,
8751        )
8752        .unwrap();
8753        let mut g2d_converter = G2DProcessor::new().unwrap();
8754
8755        let (result, _src, dst) = convert_img(
8756            &mut g2d_converter,
8757            src,
8758            dst,
8759            Rotation::None,
8760            Flip::None,
8761            Crop::no_crop(),
8762        );
8763        match result {
8764            Err(Error::G2D(_)) => {
8765                eprintln!("SKIPPED: test_vyuy_to_rgba_g2d - G2D does not support PixelFormat::Vyuy format");
8766                return;
8767            }
8768            r => r.unwrap(),
8769        }
8770
8771        let target_image = TensorDyn::image(
8772            1280,
8773            720,
8774            PixelFormat::Rgba,
8775            DType::U8,
8776            None,
8777            edgefirst_tensor::CpuAccess::ReadWrite,
8778        )
8779        .unwrap();
8780        target_image
8781            .as_typed::<u8>()
8782            .unwrap()
8783            .map()
8784            .unwrap()
8785            .as_mut_slice()
8786            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
8787
8788        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
8789        // so the matrix delta vs the reference that forced 0.95 has closed;
8790        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
8791        compare_images(&dst, &target_image, 0.98, function!());
8792    }
8793
8794    #[test]
8795    #[cfg(target_os = "linux")]
8796    #[ignore = "G2D does not support VYUY; re-enable when hardware support is added"]
8797    fn test_vyuy_to_rgb_g2d() {
8798        if !is_g2d_available() {
8799            eprintln!("SKIPPED: test_vyuy_to_rgb_g2d - G2D library (libg2d.so.2) not available");
8800            return;
8801        }
8802        if !is_dma_available() {
8803            eprintln!(
8804                "SKIPPED: test_vyuy_to_rgb_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
8805            );
8806            return;
8807        }
8808
8809        let src = load_bytes_to_tensor(
8810            1280,
8811            720,
8812            PixelFormat::Vyuy,
8813            None,
8814            &edgefirst_bench::testdata::read("camera720p.vyuy"),
8815        )
8816        .unwrap();
8817
8818        let g2d_dst = TensorDyn::image(
8819            1280,
8820            720,
8821            PixelFormat::Rgb,
8822            DType::U8,
8823            Some(TensorMemory::DmaBuf),
8824            edgefirst_tensor::CpuAccess::ReadWrite,
8825        )
8826        .unwrap();
8827        let mut g2d_converter = G2DProcessor::new().unwrap();
8828
8829        let (result, src, g2d_dst) = convert_img(
8830            &mut g2d_converter,
8831            src,
8832            g2d_dst,
8833            Rotation::None,
8834            Flip::None,
8835            Crop::no_crop(),
8836        );
8837        match result {
8838            Err(Error::G2D(_)) => {
8839                eprintln!(
8840                    "SKIPPED: test_vyuy_to_rgb_g2d - G2D does not support PixelFormat::Vyuy format"
8841                );
8842                return;
8843            }
8844            r => r.unwrap(),
8845        }
8846
8847        let cpu_dst = TensorDyn::image(
8848            1280,
8849            720,
8850            PixelFormat::Rgb,
8851            DType::U8,
8852            None,
8853            edgefirst_tensor::CpuAccess::ReadWrite,
8854        )
8855        .unwrap();
8856        let mut cpu_converter: CPUProcessor = CPUProcessor::new();
8857
8858        let (result, _src, cpu_dst) = convert_img(
8859            &mut cpu_converter,
8860            src,
8861            cpu_dst,
8862            Rotation::None,
8863            Flip::None,
8864            Crop::no_crop(),
8865        );
8866        result.unwrap();
8867
8868        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
8869        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
8870        // the YUV-matrix delta that forced 0.95 has closed; tightened to
8871        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
8872        // structural gap not exercised by these limited-range fixtures.
8873        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
8874    }
8875
8876    #[test]
8877    #[cfg(any(
8878        target_os = "linux",
8879        target_os = "macos",
8880        target_os = "ios",
8881        target_os = "android",
8882        target_os = "windows"
8883    ))]
8884    #[cfg(feature = "opengl")]
8885    fn test_vyuy_to_rgba_opengl() {
8886        if !is_opengl_available() {
8887            eprintln!("SKIPPED: {} - OpenGL not available", function!());
8888            return;
8889        }
8890        if !edgefirst_tensor::is_gpu_buffer_available() {
8891            eprintln!(
8892                "SKIPPED: {} - zero-copy GPU buffer (DMA-BUF / IOSurface) not available on this host",
8893                function!()
8894            );
8895            return;
8896        }
8897
8898        // VYUY has a DMA-BUF FourCC on Linux but no IOSurface mapping on
8899        // macOS/iOS, so the zero-copy allocation itself is the per-format
8900        // probe: a platform that declines it is a skip, not a failure.
8901        let src = match load_bytes_to_tensor(
8902            1280,
8903            720,
8904            PixelFormat::Vyuy,
8905            Some(TensorMemory::DmaBuf),
8906            &edgefirst_bench::testdata::read("camera720p.vyuy"),
8907        ) {
8908            Ok(src) => src,
8909            Err(e) => {
8910                eprintln!(
8911                    "SKIPPED: {} - zero-copy VYUY source not allocatable on this platform ({e})",
8912                    function!()
8913                );
8914                return;
8915            }
8916        };
8917
8918        let dst = TensorDyn::image(
8919            1280,
8920            720,
8921            PixelFormat::Rgba,
8922            DType::U8,
8923            Some(TensorMemory::DmaBuf),
8924            edgefirst_tensor::CpuAccess::ReadWrite,
8925        )
8926        .unwrap();
8927        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
8928
8929        let (result, _src, dst) = convert_img(
8930            &mut gl_converter,
8931            src,
8932            dst,
8933            Rotation::None,
8934            Flip::None,
8935            Crop::no_crop(),
8936        );
8937        match result {
8938            Err(Error::NotSupported(_)) => {
8939                eprintln!(
8940                    "SKIPPED: {} - OpenGL does not support PixelFormat::Vyuy DMA format",
8941                    function!()
8942                );
8943                return;
8944            }
8945            r => r.unwrap(),
8946        }
8947
8948        let target_image = TensorDyn::image(
8949            1280,
8950            720,
8951            PixelFormat::Rgba,
8952            DType::U8,
8953            None,
8954            edgefirst_tensor::CpuAccess::ReadWrite,
8955        )
8956        .unwrap();
8957        target_image
8958            .as_typed::<u8>()
8959            .unwrap()
8960            .map()
8961            .unwrap()
8962            .as_mut_slice()
8963            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
8964
8965        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
8966        // so the matrix delta vs the reference that forced 0.95 has closed;
8967        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
8968        compare_images(&dst, &target_image, 0.98, function!());
8969    }
8970
8971    #[test]
8972    fn test_nv12_to_rgba_cpu() {
8973        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
8974        let src = TensorDyn::image(
8975            1280,
8976            720,
8977            PixelFormat::Nv12,
8978            DType::U8,
8979            None,
8980            edgefirst_tensor::CpuAccess::ReadWrite,
8981        )
8982        .unwrap();
8983        src.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
8984            .copy_from_slice(&file);
8985
8986        let dst = TensorDyn::image(
8987            1280,
8988            720,
8989            PixelFormat::Rgba,
8990            DType::U8,
8991            None,
8992            edgefirst_tensor::CpuAccess::ReadWrite,
8993        )
8994        .unwrap();
8995        let mut cpu_converter = CPUProcessor::new();
8996
8997        let (result, _src, dst) = convert_img(
8998            &mut cpu_converter,
8999            src,
9000            dst,
9001            Rotation::None,
9002            Flip::None,
9003            Crop::no_crop(),
9004        );
9005        result.unwrap();
9006
9007        let target_image = crate::load_image_test_helper(
9008            &edgefirst_bench::testdata::read("zidane.jpg"),
9009            Some(PixelFormat::Rgba),
9010            None,
9011        )
9012        .unwrap();
9013
9014        // Threshold 0.95 (was 0.98): the reference now decodes the colour JPEG
9015        // to native NV12 and then converts to RGBA (was a direct JPEG → RGBA
9016        // decode), so it differs slightly from the RGBA derived from the
9017        // separate `zidane.nv12` fixture.
9018        compare_images(&dst, &target_image, 0.95, function!());
9019    }
9020
9021    #[test]
9022    fn test_nv12_odd_height_to_rgb_cpu() {
9023        // Odd height (even width) — the logical-odd case, e.g. 640×483. The
9024        // contiguous NV12 buffer is `[5 + ceil(5/2), 8]` = `[8, 8]` (5 luma rows
9025        // + 3 chroma rows). A neutral-grey fill (Y=U=V=128, BT.601 full-range)
9026        // must convert to a uniform grey RGB, exercising the odd-height
9027        // chroma-row count and the logical-height derivation in convert.
9028        // (Odd *width* is rounded to an even buffer at allocation, so it is
9029        // covered by the decode integration tests rather than here.)
9030        // CPU-only test: pin to tight host memory (None auto-selects pitch-padded
9031        // DMA on i.MX, which would leave the dst's row padding unconverted and
9032        // break the flat byte scan below).
9033        let mut src = TensorDyn::image(
9034            8,
9035            5,
9036            PixelFormat::Nv12,
9037            DType::U8,
9038            Some(TensorMemory::Mem),
9039            edgefirst_tensor::CpuAccess::ReadWrite,
9040        )
9041        .unwrap();
9042        assert_eq!(src.shape(), &[8, 8]);
9043        assert_eq!((src.width(), src.height()), (Some(8), Some(5)));
9044        src.as_typed::<u8>()
9045            .unwrap()
9046            .map()
9047            .unwrap()
9048            .as_mut_slice()
9049            .fill(128);
9050        // Tag BT.601 full-range so Y=128 decodes to grey 128 (the neutral-grey
9051        // identity this test asserts). Without a tag, the colorimetry heuristic
9052        // resolves an SD tensor to BT.601 *limited*, expanding Y=128 → ~131.
9053        src.set_colorimetry(Some(
9054            edgefirst_tensor::Colorimetry::default()
9055                .with_encoding(edgefirst_tensor::ColorEncoding::Bt601)
9056                .with_range(edgefirst_tensor::ColorRange::Full),
9057        ));
9058
9059        let dst = TensorDyn::image(
9060            8,
9061            5,
9062            PixelFormat::Rgb,
9063            DType::U8,
9064            Some(TensorMemory::Mem),
9065            edgefirst_tensor::CpuAccess::ReadWrite,
9066        )
9067        .unwrap();
9068        let mut cpu_converter = CPUProcessor::new();
9069        let (result, _src, dst) = convert_img(
9070            &mut cpu_converter,
9071            src,
9072            dst,
9073            Rotation::None,
9074            Flip::None,
9075            Crop::no_crop(),
9076        );
9077        result.unwrap();
9078
9079        assert_eq!((dst.width(), dst.height()), (Some(8), Some(5)));
9080        let map = dst.as_typed::<u8>().unwrap().map().unwrap();
9081        for (i, &b) in map.as_slice().iter().enumerate() {
9082            assert!(
9083                (b as i16 - 128).abs() <= 2,
9084                "pixel byte {i} = {b}, expected ~128 for neutral-grey NV12"
9085            );
9086        }
9087    }
9088
9089    #[test]
9090    fn test_nv24_to_rgb_cpu() {
9091        // NV24 (4:4:4) at 8×4: contiguous buffer is [4*3, 8] = [12, 8] — Y plane
9092        // (4 rows) + full-res interleaved UV plane (8 rows = 2H, 2W bytes per
9093        // chroma row). Neutral-grey fill (Y=U=V=128) must convert to uniform
9094        // grey RGB, exercising the 2× UV stride and shape[0]/3 height recovery.
9095        // CPU-only test: pin to tight host memory (see test_nv12_odd_height_to_rgb_cpu).
9096        let mut src = TensorDyn::image(
9097            8,
9098            4,
9099            PixelFormat::Nv24,
9100            DType::U8,
9101            Some(TensorMemory::Mem),
9102            edgefirst_tensor::CpuAccess::ReadWrite,
9103        )
9104        .unwrap();
9105        assert_eq!(src.shape(), &[12, 8]);
9106        assert_eq!((src.width(), src.height()), (Some(8), Some(4)));
9107        src.as_typed::<u8>()
9108            .unwrap()
9109            .map()
9110            .unwrap()
9111            .as_mut_slice()
9112            .fill(128);
9113        // Tag BT.601 full-range (see test_nv12_odd_height_to_rgb_cpu): without it
9114        // the heuristic picks limited range and Y=128 expands to ~131.
9115        src.set_colorimetry(Some(
9116            edgefirst_tensor::Colorimetry::default()
9117                .with_encoding(edgefirst_tensor::ColorEncoding::Bt601)
9118                .with_range(edgefirst_tensor::ColorRange::Full),
9119        ));
9120
9121        let dst = TensorDyn::image(
9122            8,
9123            4,
9124            PixelFormat::Rgb,
9125            DType::U8,
9126            Some(TensorMemory::Mem),
9127            edgefirst_tensor::CpuAccess::ReadWrite,
9128        )
9129        .unwrap();
9130        let mut cpu_converter = CPUProcessor::new();
9131        let (result, _src, dst) = convert_img(
9132            &mut cpu_converter,
9133            src,
9134            dst,
9135            Rotation::None,
9136            Flip::None,
9137            Crop::no_crop(),
9138        );
9139        result.unwrap();
9140
9141        assert_eq!((dst.width(), dst.height()), (Some(8), Some(4)));
9142        let map = dst.as_typed::<u8>().unwrap().map().unwrap();
9143        for (i, &b) in map.as_slice().iter().enumerate() {
9144            assert!(
9145                (b as i16 - 128).abs() <= 2,
9146                "pixel byte {i} = {b}, expected ~128 for neutral-grey NV24"
9147            );
9148        }
9149    }
9150
9151    #[test]
9152    fn cpu_nv12_to_rgb_respects_tagged_bt2020() {
9153        // A uniform but *saturated* chroma sample (U/V far from neutral) so the
9154        // YUV→RGB matrix — not just the range — drives the result. Decoding the
9155        // same NV12 bytes under BT.601 / BT.709 / BT.2020 must yield three
9156        // distinct RGB triples, proving the CPU path honours the source's tagged
9157        // ColorEncoding instead of a hardcoded matrix. (G2D declines BT.2020 and
9158        // falls through to this CPU path; QA F9.)
9159        // CPU-only test: pin to tight host memory (see test_nv12_odd_height_to_rgb_cpu).
9160        fn decode_tagged(enc: edgefirst_tensor::ColorEncoding) -> [u8; 3] {
9161            let mut src = TensorDyn::image(
9162                8,
9163                4,
9164                PixelFormat::Nv12,
9165                DType::U8,
9166                Some(TensorMemory::Mem),
9167                edgefirst_tensor::CpuAccess::ReadWrite,
9168            )
9169            .unwrap();
9170            // NV12 8×4: 32-byte Y plane + 16-byte interleaved UV plane (4:2:0).
9171            assert_eq!(src.shape(), &[6, 8]);
9172            {
9173                let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
9174                let buf = map.as_mut_slice();
9175                buf[..32].fill(120); // Y
9176                for px in buf[32..].as_chunks_mut::<2>().0 {
9177                    px[0] = 180; // U / Cb
9178                    px[1] = 64; // V / Cr
9179                }
9180            }
9181            // Range held constant (Limited) across all three so only the encoding
9182            // matrix varies between runs.
9183            src.set_colorimetry(Some(
9184                edgefirst_tensor::Colorimetry::default()
9185                    .with_encoding(enc)
9186                    .with_range(edgefirst_tensor::ColorRange::Limited),
9187            ));
9188            let dst = TensorDyn::image(
9189                8,
9190                4,
9191                PixelFormat::Rgb,
9192                DType::U8,
9193                Some(TensorMemory::Mem),
9194                edgefirst_tensor::CpuAccess::ReadWrite,
9195            )
9196            .unwrap();
9197            let mut cpu = CPUProcessor::new();
9198            let (result, _src, dst) = convert_img(
9199                &mut cpu,
9200                src,
9201                dst,
9202                Rotation::None,
9203                Flip::None,
9204                Crop::no_crop(),
9205            );
9206            result.unwrap();
9207            let map = dst.as_typed::<u8>().unwrap().map().unwrap();
9208            let s = map.as_slice();
9209            [s[0], s[1], s[2]]
9210        }
9211
9212        let bt601 = decode_tagged(edgefirst_tensor::ColorEncoding::Bt601);
9213        let bt709 = decode_tagged(edgefirst_tensor::ColorEncoding::Bt709);
9214        let bt2020 = decode_tagged(edgefirst_tensor::ColorEncoding::Bt2020);
9215
9216        assert_ne!(
9217            bt2020, bt601,
9218            "BT.2020 must decode differently from BT.601 ({bt2020:?} vs {bt601:?})"
9219        );
9220        assert_ne!(
9221            bt2020, bt709,
9222            "BT.2020 must decode differently from BT.709 ({bt2020:?} vs {bt709:?})"
9223        );
9224        assert_ne!(
9225            bt601, bt709,
9226            "BT.601 must decode differently from BT.709 ({bt601:?} vs {bt709:?})"
9227        );
9228    }
9229
9230    #[test]
9231    fn test_nv12_to_rgb_cpu() {
9232        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
9233        let src = TensorDyn::image(
9234            1280,
9235            720,
9236            PixelFormat::Nv12,
9237            DType::U8,
9238            None,
9239            edgefirst_tensor::CpuAccess::ReadWrite,
9240        )
9241        .unwrap();
9242        src.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
9243            .copy_from_slice(&file);
9244
9245        let dst = TensorDyn::image(
9246            1280,
9247            720,
9248            PixelFormat::Rgb,
9249            DType::U8,
9250            None,
9251            edgefirst_tensor::CpuAccess::ReadWrite,
9252        )
9253        .unwrap();
9254        let mut cpu_converter = CPUProcessor::new();
9255
9256        let (result, _src, dst) = convert_img(
9257            &mut cpu_converter,
9258            src,
9259            dst,
9260            Rotation::None,
9261            Flip::None,
9262            Crop::no_crop(),
9263        );
9264        result.unwrap();
9265
9266        let target_image = crate::load_image_test_helper(
9267            &edgefirst_bench::testdata::read("zidane.jpg"),
9268            Some(PixelFormat::Rgb),
9269            None,
9270        )
9271        .unwrap();
9272
9273        // Threshold 0.95 (was 0.98): the reference now decodes the colour JPEG
9274        // to native NV12 and then converts to RGB (was a direct JPEG → RGB
9275        // decode), so it differs slightly from the RGB derived from the
9276        // separate `zidane.nv12` fixture.
9277        compare_images(&dst, &target_image, 0.95, function!());
9278    }
9279
9280    #[test]
9281    fn test_nv12_to_grey_cpu() {
9282        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
9283        let src = TensorDyn::image(
9284            1280,
9285            720,
9286            PixelFormat::Nv12,
9287            DType::U8,
9288            None,
9289            edgefirst_tensor::CpuAccess::ReadWrite,
9290        )
9291        .unwrap();
9292        src.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
9293            .copy_from_slice(&file);
9294
9295        let dst = TensorDyn::image(
9296            1280,
9297            720,
9298            PixelFormat::Grey,
9299            DType::U8,
9300            None,
9301            edgefirst_tensor::CpuAccess::ReadWrite,
9302        )
9303        .unwrap();
9304        let mut cpu_converter = CPUProcessor::new();
9305
9306        let (result, _src, dst) = convert_img(
9307            &mut cpu_converter,
9308            src,
9309            dst,
9310            Rotation::None,
9311            Flip::None,
9312            Crop::no_crop(),
9313        );
9314        result.unwrap();
9315
9316        let target_image = crate::load_image_test_helper(
9317            &edgefirst_bench::testdata::read("zidane.jpg"),
9318            Some(PixelFormat::Grey),
9319            None,
9320        )
9321        .unwrap();
9322
9323        // Threshold 0.95 (was 0.98): the reference grey frame now comes from
9324        // the colour JPEG decoded to native NV12 and then converted to GREY
9325        // (was a direct JPEG → GREY decode), so it differs slightly from the
9326        // grey derived from the `zidane.nv12` fixture.
9327        compare_images(&dst, &target_image, 0.95, function!());
9328    }
9329
9330    #[test]
9331    fn test_nv12_to_yuyv_cpu() {
9332        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
9333        let src = TensorDyn::image(
9334            1280,
9335            720,
9336            PixelFormat::Nv12,
9337            DType::U8,
9338            None,
9339            edgefirst_tensor::CpuAccess::ReadWrite,
9340        )
9341        .unwrap();
9342        src.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
9343            .copy_from_slice(&file);
9344
9345        let dst = TensorDyn::image(
9346            1280,
9347            720,
9348            PixelFormat::Yuyv,
9349            DType::U8,
9350            None,
9351            edgefirst_tensor::CpuAccess::ReadWrite,
9352        )
9353        .unwrap();
9354        let mut cpu_converter = CPUProcessor::new();
9355
9356        let (result, _src, dst) = convert_img(
9357            &mut cpu_converter,
9358            src,
9359            dst,
9360            Rotation::None,
9361            Flip::None,
9362            Crop::no_crop(),
9363        );
9364        result.unwrap();
9365
9366        let target_image = crate::load_image_test_helper(
9367            &edgefirst_bench::testdata::read("zidane.jpg"),
9368            Some(PixelFormat::Rgb),
9369            None,
9370        )
9371        .unwrap();
9372
9373        // Threshold 0.95 (was 0.98): the reference now decodes the colour JPEG
9374        // to native NV12 and then converts to RGB (was a direct JPEG → RGB
9375        // decode), so it differs slightly from the YUYV-sourced frame derived
9376        // from the separate `zidane.nv12` fixture.
9377        compare_images_convert_to_rgb(&dst, &target_image, 0.95, function!());
9378    }
9379
9380    #[test]
9381    fn test_cpu_resize_nv16() {
9382        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
9383        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
9384
9385        let cpu_nv16_dst = TensorDyn::image(
9386            640,
9387            640,
9388            PixelFormat::Nv16,
9389            DType::U8,
9390            None,
9391            edgefirst_tensor::CpuAccess::ReadWrite,
9392        )
9393        .unwrap();
9394        let cpu_rgb_dst = TensorDyn::image(
9395            640,
9396            640,
9397            PixelFormat::Rgb,
9398            DType::U8,
9399            None,
9400            edgefirst_tensor::CpuAccess::ReadWrite,
9401        )
9402        .unwrap();
9403        let mut cpu_converter = CPUProcessor::new();
9404        let crop = Crop::letterbox([255, 128, 0, 255]);
9405
9406        let (result, src, cpu_nv16_dst) = convert_img(
9407            &mut cpu_converter,
9408            src,
9409            cpu_nv16_dst,
9410            Rotation::None,
9411            Flip::None,
9412            crop,
9413        );
9414        result.unwrap();
9415
9416        let (result, _src, cpu_rgb_dst) = convert_img(
9417            &mut cpu_converter,
9418            src,
9419            cpu_rgb_dst,
9420            Rotation::None,
9421            Flip::None,
9422            crop,
9423        );
9424        result.unwrap();
9425        compare_images_convert_to_rgb(
9426            &cpu_nv16_dst,
9427            &cpu_rgb_dst,
9428            yuv_threshold(0.99),
9429            function!(),
9430        );
9431    }
9432
9433    /// Similarity threshold for a comparison whose path runs a YUV↔RGB
9434    /// conversion.
9435    ///
9436    /// [`crate::cpu::convert`]'s `yuv_mode()` deliberately selects
9437    /// `YuvConversionMode::Fast` on cores without Armv8.1 `rdm`
9438    /// (Cortex-A53/A35 class, e.g. the i.MX 8M Plus), because `Balanced`
9439    /// emulates `SQRDMLAH` there and costs roughly 2×. `Fast` is a
9440    /// lower-precision integer approximation with a documented error of up to
9441    /// ~2/255, so a threshold calibrated on an `rdm`-capable core is
9442    /// unreachable on those cores **by design**: NV16 measures 0.9885 against
9443    /// 0.99 on an A53, while every `rdm` core passes.
9444    ///
9445    /// No CI runner is A53-class, so this only reproduces on target.
9446    ///
9447    /// Relaxing per-core keeps the assertion tight where the precise path
9448    /// runs, instead of loosening it everywhere to accommodate one core.
9449    fn yuv_threshold(balanced: f64) -> f64 {
9450        #[cfg(target_arch = "aarch64")]
9451        {
9452            if !std::arch::is_aarch64_feature_detected!("rdm") {
9453                return balanced - 0.01;
9454            }
9455        }
9456        balanced
9457    }
9458
9459    fn load_bytes_to_tensor(
9460        width: usize,
9461        height: usize,
9462        format: PixelFormat,
9463        memory: Option<TensorMemory>,
9464        bytes: &[u8],
9465    ) -> Result<TensorDyn, Error> {
9466        let src = TensorDyn::image(
9467            width,
9468            height,
9469            format,
9470            DType::U8,
9471            memory,
9472            edgefirst_tensor::CpuAccess::ReadWrite,
9473        )?;
9474        src.as_typed::<u8>()
9475            .unwrap()
9476            .map()?
9477            .as_mut_slice()
9478            .copy_from_slice(bytes);
9479        Ok(src)
9480    }
9481
9482    // DEDUP: this function is also defined verbatim in
9483    // `crates/image/src/gl/tests.rs` (inside `mod gl_tests`). Both copies
9484    // must be kept in sync. Cross-module sharing would require either a
9485    // `pub(crate)` test-helper module (which pollutes the non-test API) or a
9486    // separate test-utils crate — both are disproportionate for a single
9487    // helper. If the implementation ever diverges, extract to a shared
9488    // `test_helpers` module in this crate.
9489    fn compare_images(img1: &TensorDyn, img2: &TensorDyn, threshold: f64, name: &str) {
9490        assert_eq!(img1.height(), img2.height(), "Heights differ");
9491        assert_eq!(img1.width(), img2.width(), "Widths differ");
9492        assert_eq!(
9493            img1.format().unwrap(),
9494            img2.format().unwrap(),
9495            "PixelFormat differ"
9496        );
9497        assert!(
9498            matches!(
9499                img1.format().unwrap(),
9500                PixelFormat::Rgb | PixelFormat::Rgba | PixelFormat::Grey | PixelFormat::PlanarRgb
9501            ),
9502            "format must be Rgb or Rgba for comparison"
9503        );
9504
9505        let image1 = match img1.format().unwrap() {
9506            PixelFormat::Rgb => image::RgbImage::from_vec(
9507                img1.width().unwrap() as u32,
9508                img1.height().unwrap() as u32,
9509                img1.as_typed::<u8>().unwrap().map().unwrap().to_vec(),
9510            )
9511            .unwrap(),
9512            PixelFormat::Rgba => image::RgbaImage::from_vec(
9513                img1.width().unwrap() as u32,
9514                img1.height().unwrap() as u32,
9515                img1.as_typed::<u8>().unwrap().map().unwrap().to_vec(),
9516            )
9517            .unwrap()
9518            .convert(),
9519            PixelFormat::Grey => image::GrayImage::from_vec(
9520                img1.width().unwrap() as u32,
9521                img1.height().unwrap() as u32,
9522                img1.as_typed::<u8>().unwrap().map().unwrap().to_vec(),
9523            )
9524            .unwrap()
9525            .convert(),
9526            PixelFormat::PlanarRgb => image::GrayImage::from_vec(
9527                img1.width().unwrap() as u32,
9528                (img1.height().unwrap() * 3) as u32,
9529                img1.as_typed::<u8>().unwrap().map().unwrap().to_vec(),
9530            )
9531            .unwrap()
9532            .convert(),
9533            _ => return,
9534        };
9535
9536        let image2 = match img2.format().unwrap() {
9537            PixelFormat::Rgb => image::RgbImage::from_vec(
9538                img2.width().unwrap() as u32,
9539                img2.height().unwrap() as u32,
9540                img2.as_typed::<u8>().unwrap().map().unwrap().to_vec(),
9541            )
9542            .unwrap(),
9543            PixelFormat::Rgba => image::RgbaImage::from_vec(
9544                img2.width().unwrap() as u32,
9545                img2.height().unwrap() as u32,
9546                img2.as_typed::<u8>().unwrap().map().unwrap().to_vec(),
9547            )
9548            .unwrap()
9549            .convert(),
9550            PixelFormat::Grey => image::GrayImage::from_vec(
9551                img2.width().unwrap() as u32,
9552                img2.height().unwrap() as u32,
9553                img2.as_typed::<u8>().unwrap().map().unwrap().to_vec(),
9554            )
9555            .unwrap()
9556            .convert(),
9557            PixelFormat::PlanarRgb => image::GrayImage::from_vec(
9558                img2.width().unwrap() as u32,
9559                (img2.height().unwrap() * 3) as u32,
9560                img2.as_typed::<u8>().unwrap().map().unwrap().to_vec(),
9561            )
9562            .unwrap()
9563            .convert(),
9564            _ => return,
9565        };
9566
9567        let similarity = image_compare::rgb_similarity_structure(
9568            &image_compare::Algorithm::RootMeanSquared,
9569            &image1,
9570            &image2,
9571        )
9572        .expect("Image Comparison failed");
9573        if similarity.score < threshold {
9574            // image1.save(format!("{name}_1.png"));
9575            // image2.save(format!("{name}_2.png"));
9576            similarity
9577                .image
9578                .to_color_map()
9579                .save(format!("{name}.png"))
9580                .unwrap();
9581            panic!(
9582                "{name}: converted image and target image have similarity score too low: {} < {}",
9583                similarity.score, threshold
9584            )
9585        }
9586    }
9587
9588    fn compare_images_convert_to_rgb(
9589        img1: &TensorDyn,
9590        img2: &TensorDyn,
9591        threshold: f64,
9592        name: &str,
9593    ) {
9594        assert_eq!(img1.height(), img2.height(), "Heights differ");
9595        assert_eq!(img1.width(), img2.width(), "Widths differ");
9596
9597        let mut img_rgb1 = TensorDyn::image(
9598            img1.width().unwrap(),
9599            img1.height().unwrap(),
9600            PixelFormat::Rgb,
9601            DType::U8,
9602            Some(TensorMemory::Mem),
9603            edgefirst_tensor::CpuAccess::ReadWrite,
9604        )
9605        .unwrap();
9606        let mut img_rgb2 = TensorDyn::image(
9607            img1.width().unwrap(),
9608            img1.height().unwrap(),
9609            PixelFormat::Rgb,
9610            DType::U8,
9611            Some(TensorMemory::Mem),
9612            edgefirst_tensor::CpuAccess::ReadWrite,
9613        )
9614        .unwrap();
9615        let mut __cv = CPUProcessor::default();
9616        let r1 = __cv.convert(
9617            img1,
9618            &mut img_rgb1,
9619            crate::Rotation::None,
9620            crate::Flip::None,
9621            crate::Crop::default(),
9622        );
9623        let r2 = __cv.convert(
9624            img2,
9625            &mut img_rgb2,
9626            crate::Rotation::None,
9627            crate::Flip::None,
9628            crate::Crop::default(),
9629        );
9630        if r1.is_err() || r2.is_err() {
9631            // Fallback: compare raw bytes as greyscale strip
9632            let w = img1.width().unwrap() as u32;
9633            let data1 = img1.as_typed::<u8>().unwrap().map().unwrap().to_vec();
9634            let data2 = img2.as_typed::<u8>().unwrap().map().unwrap().to_vec();
9635            let h1 = (data1.len() as u32) / w;
9636            let h2 = (data2.len() as u32) / w;
9637            let g1 = image::GrayImage::from_vec(w, h1, data1).unwrap();
9638            let g2 = image::GrayImage::from_vec(w, h2, data2).unwrap();
9639            let similarity = image_compare::gray_similarity_structure(
9640                &image_compare::Algorithm::RootMeanSquared,
9641                &g1,
9642                &g2,
9643            )
9644            .expect("Image Comparison failed");
9645            if similarity.score < threshold {
9646                panic!(
9647                    "{name}: converted image and target image have similarity score too low: {} < {}",
9648                    similarity.score, threshold
9649                )
9650            }
9651            return;
9652        }
9653
9654        let image1 = image::RgbImage::from_vec(
9655            img_rgb1.width().unwrap() as u32,
9656            img_rgb1.height().unwrap() as u32,
9657            img_rgb1.as_typed::<u8>().unwrap().map().unwrap().to_vec(),
9658        )
9659        .unwrap();
9660
9661        let image2 = image::RgbImage::from_vec(
9662            img_rgb2.width().unwrap() as u32,
9663            img_rgb2.height().unwrap() as u32,
9664            img_rgb2.as_typed::<u8>().unwrap().map().unwrap().to_vec(),
9665        )
9666        .unwrap();
9667
9668        let similarity = image_compare::rgb_similarity_structure(
9669            &image_compare::Algorithm::RootMeanSquared,
9670            &image1,
9671            &image2,
9672        )
9673        .expect("Image Comparison failed");
9674        if similarity.score < threshold {
9675            // image1.save(format!("{name}_1.png"));
9676            // image2.save(format!("{name}_2.png"));
9677            similarity
9678                .image
9679                .to_color_map()
9680                .save(format!("{name}.png"))
9681                .unwrap();
9682            panic!(
9683                "{name}: converted image and target image have similarity score too low: {} < {}",
9684                similarity.score, threshold
9685            )
9686        }
9687    }
9688
9689    // =========================================================================
9690    // PixelFormat::Nv12 Format Tests
9691    // =========================================================================
9692
9693    #[test]
9694    fn test_nv12_image_creation() {
9695        let width = 640;
9696        let height = 480;
9697        let img = TensorDyn::image(
9698            width,
9699            height,
9700            PixelFormat::Nv12,
9701            DType::U8,
9702            None,
9703            edgefirst_tensor::CpuAccess::ReadWrite,
9704        )
9705        .unwrap();
9706
9707        assert_eq!(img.width(), Some(width));
9708        assert_eq!(img.height(), Some(height));
9709        assert_eq!(img.format().unwrap(), PixelFormat::Nv12);
9710        // PixelFormat::Nv12 uses shape [H*3/2, W] to store Y plane + UV plane
9711        assert_eq!(
9712            img.as_typed::<u8>().unwrap().shape(),
9713            &[height * 3 / 2, width]
9714        );
9715    }
9716
9717    #[test]
9718    fn test_nv12_channels() {
9719        let img = TensorDyn::image(
9720            640,
9721            480,
9722            PixelFormat::Nv12,
9723            DType::U8,
9724            None,
9725            edgefirst_tensor::CpuAccess::ReadWrite,
9726        )
9727        .unwrap();
9728        // PixelFormat::Nv12.channels() returns 1 (luma plane)
9729        assert_eq!(img.format().unwrap().channels(), 1);
9730    }
9731
9732    // =========================================================================
9733    // Tensor Format Metadata Tests
9734    // =========================================================================
9735
9736    #[test]
9737    fn test_tensor_set_format_planar() {
9738        let mut tensor = Tensor::<u8>::new(&[3, 480, 640], None, None).unwrap();
9739        tensor.set_format(PixelFormat::PlanarRgb).unwrap();
9740        assert_eq!(tensor.format(), Some(PixelFormat::PlanarRgb));
9741        assert_eq!(tensor.width(), Some(640));
9742        assert_eq!(tensor.height(), Some(480));
9743    }
9744
9745    #[test]
9746    fn test_tensor_set_format_interleaved() {
9747        let mut tensor = Tensor::<u8>::new(&[480, 640, 4], None, None).unwrap();
9748        tensor.set_format(PixelFormat::Rgba).unwrap();
9749        assert_eq!(tensor.format(), Some(PixelFormat::Rgba));
9750        assert_eq!(tensor.width(), Some(640));
9751        assert_eq!(tensor.height(), Some(480));
9752    }
9753
9754    #[test]
9755    fn test_tensordyn_image_rgb() {
9756        let img = TensorDyn::image(
9757            640,
9758            480,
9759            PixelFormat::Rgb,
9760            DType::U8,
9761            None,
9762            edgefirst_tensor::CpuAccess::ReadWrite,
9763        )
9764        .unwrap();
9765        assert_eq!(img.width(), Some(640));
9766        assert_eq!(img.height(), Some(480));
9767        assert_eq!(img.format(), Some(PixelFormat::Rgb));
9768    }
9769
9770    #[test]
9771    fn test_tensordyn_image_planar_rgb() {
9772        let img = TensorDyn::image(
9773            640,
9774            480,
9775            PixelFormat::PlanarRgb,
9776            DType::U8,
9777            None,
9778            edgefirst_tensor::CpuAccess::ReadWrite,
9779        )
9780        .unwrap();
9781        assert_eq!(img.width(), Some(640));
9782        assert_eq!(img.height(), Some(480));
9783        assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
9784    }
9785
9786    #[test]
9787    fn test_rgb_int8_format() {
9788        // Int8 variant: same PixelFormat::Rgb but with DType::I8
9789        let img = TensorDyn::image(
9790            1280,
9791            720,
9792            PixelFormat::Rgb,
9793            DType::I8,
9794            Some(TensorMemory::Mem),
9795            edgefirst_tensor::CpuAccess::ReadWrite,
9796        )
9797        .unwrap();
9798        assert_eq!(img.width(), Some(1280));
9799        assert_eq!(img.height(), Some(720));
9800        assert_eq!(img.format(), Some(PixelFormat::Rgb));
9801        assert_eq!(img.dtype(), DType::I8);
9802    }
9803
9804    #[test]
9805    fn test_planar_rgb_int8_format() {
9806        let img = TensorDyn::image(
9807            1280,
9808            720,
9809            PixelFormat::PlanarRgb,
9810            DType::I8,
9811            Some(TensorMemory::Mem),
9812            edgefirst_tensor::CpuAccess::ReadWrite,
9813        )
9814        .unwrap();
9815        assert_eq!(img.width(), Some(1280));
9816        assert_eq!(img.height(), Some(720));
9817        assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
9818        assert_eq!(img.dtype(), DType::I8);
9819    }
9820
9821    #[test]
9822    fn test_rgb_from_tensor() {
9823        let mut tensor = Tensor::<u8>::new(&[720, 1280, 3], None, None).unwrap();
9824        tensor.set_format(PixelFormat::Rgb).unwrap();
9825        let img = TensorDyn::from(tensor);
9826        assert_eq!(img.width(), Some(1280));
9827        assert_eq!(img.height(), Some(720));
9828        assert_eq!(img.format(), Some(PixelFormat::Rgb));
9829    }
9830
9831    #[test]
9832    fn test_planar_rgb_from_tensor() {
9833        let mut tensor = Tensor::<u8>::new(&[3, 720, 1280], None, None).unwrap();
9834        tensor.set_format(PixelFormat::PlanarRgb).unwrap();
9835        let img = TensorDyn::from(tensor);
9836        assert_eq!(img.width(), Some(1280));
9837        assert_eq!(img.height(), Some(720));
9838        assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
9839    }
9840
9841    #[test]
9842    fn test_dtype_determines_int8() {
9843        // DType::I8 indicates int8 data
9844        let u8_img = TensorDyn::image(
9845            64,
9846            64,
9847            PixelFormat::Rgb,
9848            DType::U8,
9849            None,
9850            edgefirst_tensor::CpuAccess::ReadWrite,
9851        )
9852        .unwrap();
9853        let i8_img = TensorDyn::image(
9854            64,
9855            64,
9856            PixelFormat::Rgb,
9857            DType::I8,
9858            None,
9859            edgefirst_tensor::CpuAccess::ReadWrite,
9860        )
9861        .unwrap();
9862        assert_eq!(u8_img.dtype(), DType::U8);
9863        assert_eq!(i8_img.dtype(), DType::I8);
9864    }
9865
9866    #[test]
9867    fn test_pixel_layout_packed_vs_planar() {
9868        // Packed vs planar layout classification
9869        assert_eq!(PixelFormat::Rgb.layout(), PixelLayout::Packed);
9870        assert_eq!(PixelFormat::Rgba.layout(), PixelLayout::Packed);
9871        assert_eq!(PixelFormat::PlanarRgb.layout(), PixelLayout::Planar);
9872        assert_eq!(PixelFormat::Nv12.layout(), PixelLayout::SemiPlanar);
9873    }
9874
9875    /// Integration test that exercises the PBO-to-PBO convert path.
9876    /// A PBO cannot hand out a host address that outlives its map guard, and
9877    /// the error has to say why rather than reading as unfinished work.
9878    ///
9879    /// `glMapBufferRange`'s pointer is valid only until `glUnmapBuffer`, and
9880    /// this backend maps with `MAP_READ_BIT | MAP_WRITE_BIT` — there is no
9881    /// `GL_MAP_PERSISTENT_BIT` and no `glBufferStorage` in the tree. Pinning
9882    /// would mean holding the buffer mapped across GL work, which is the
9883    /// opposite of what a PBO is for. `EXT_buffer_storage` would change the
9884    /// answer, but that is a buffer-allocation feature, not wiring.
9885    #[cfg(target_os = "linux")]
9886    #[cfg(feature = "opengl")]
9887    #[test]
9888    fn pbo_reports_why_it_cannot_pin() {
9889        use edgefirst_tensor::{Error, TensorTrait};
9890
9891        let converter = ImageProcessor::new().unwrap();
9892        let is_pbo = converter
9893            .opengl
9894            .as_ref()
9895            .is_some_and(|gl| gl.transfer_backend() == opengl_headless::TransferBackend::Pbo);
9896        if !is_pbo {
9897            // Direct stderr, not eprintln!: libtest captures println!/eprintln!
9898            // and replays it only for FAILING tests, so a skip would print
9899            // "... ok" with its reason discarded and be indistinguishable from
9900            // having run. See TESTING.md.
9901            use std::io::Write;
9902            let _ = writeln!(
9903                &mut std::io::stderr(),
9904                "SKIPPED: pbo_reports_why_it_cannot_pin - transfer backend is not PBO"
9905            );
9906            return;
9907        }
9908
9909        let t = converter
9910            .create_image(
9911                64,
9912                64,
9913                PixelFormat::Rgba,
9914                DType::U8,
9915                None,
9916                edgefirst_tensor::CpuAccess::ReadWrite,
9917            )
9918            .unwrap();
9919        assert_eq!(t.as_typed::<u8>().unwrap().memory(), TensorMemory::Pbo);
9920        let t = t.as_typed::<u8>().unwrap();
9921
9922        match t.pin_host(edgefirst_tensor::CpuAccess::ReadWrite) {
9923            Err(Error::NotImplemented(m)) => {
9924                assert!(
9925                    m.contains("glMapBufferRange"),
9926                    "must name the constraint: {m}"
9927                );
9928                assert!(m.contains("map()"), "must point at the supported API: {m}");
9929            }
9930            other => panic!("expected an explained NotImplemented, got {other:?}"),
9931        }
9932        // Sync is unavailable for the same reason, and must not silently succeed.
9933        assert!(matches!(
9934            t.sync_for_cpu(edgefirst_tensor::CpuAccess::Read),
9935            Err(Error::NotImplemented(_))
9936        ));
9937
9938        // The supported path still works, which is what makes the refusal safe.
9939        let g = t.map().expect("map must work where pin cannot");
9940        assert!(!g.as_slice().is_empty());
9941    }
9942
9943    /// Uses ImageProcessor::create_image() to allocate PBO-backed tensors,
9944    /// then converts between them. Skipped when GL is unavailable or the
9945    /// backend is not PBO (e.g. DMA-buf systems).
9946    #[cfg(target_os = "linux")]
9947    #[cfg(feature = "opengl")]
9948    #[test]
9949    fn test_convert_pbo_to_pbo() {
9950        let mut converter = ImageProcessor::new().unwrap();
9951
9952        // Skip if GL is not available or backend is not PBO
9953        let is_pbo = converter
9954            .opengl
9955            .as_ref()
9956            .is_some_and(|gl| gl.transfer_backend() == opengl_headless::TransferBackend::Pbo);
9957        if !is_pbo {
9958            log::warn!("Skipping test_convert_pbo_to_pbo: backend is not PBO");
9959            return;
9960        }
9961
9962        let src_w = 640;
9963        let src_h = 480;
9964        let dst_w = 320;
9965        let dst_h = 240;
9966
9967        // Create PBO-backed source image
9968        let pbo_src = converter
9969            .create_image(
9970                src_w,
9971                src_h,
9972                PixelFormat::Rgba,
9973                DType::U8,
9974                None,
9975                edgefirst_tensor::CpuAccess::ReadWrite,
9976            )
9977            .unwrap();
9978        assert_eq!(
9979            pbo_src.as_typed::<u8>().unwrap().memory(),
9980            TensorMemory::Pbo,
9981            "create_image should produce a PBO tensor"
9982        );
9983
9984        // Fill source PBO with test pattern: load JPEG then convert Mem→PBO
9985        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
9986        let jpeg_src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
9987
9988        // Resize JPEG into a Mem temp of the right size, then copy into PBO
9989        let mem_src = TensorDyn::image(
9990            src_w,
9991            src_h,
9992            PixelFormat::Rgba,
9993            DType::U8,
9994            Some(TensorMemory::Mem),
9995            edgefirst_tensor::CpuAccess::ReadWrite,
9996        )
9997        .unwrap();
9998        let (result, _jpeg_src, mem_src) = convert_img(
9999            &mut CPUProcessor::new(),
10000            jpeg_src,
10001            mem_src,
10002            Rotation::None,
10003            Flip::None,
10004            Crop::no_crop(),
10005        );
10006        result.unwrap();
10007
10008        // Copy pixel data into the PBO source by mapping it
10009        {
10010            let src_data = mem_src.as_typed::<u8>().unwrap().map().unwrap();
10011            let mut pbo_map = pbo_src.as_typed::<u8>().unwrap().map().unwrap();
10012            pbo_map.copy_from_slice(&src_data);
10013        }
10014
10015        // Create PBO-backed destination image
10016        let pbo_dst = converter
10017            .create_image(
10018                dst_w,
10019                dst_h,
10020                PixelFormat::Rgba,
10021                DType::U8,
10022                None,
10023                edgefirst_tensor::CpuAccess::ReadWrite,
10024            )
10025            .unwrap();
10026        assert_eq!(
10027            pbo_dst.as_typed::<u8>().unwrap().memory(),
10028            TensorMemory::Pbo
10029        );
10030
10031        // Convert PBO→PBO (this exercises convert_pbo_to_pbo)
10032        let mut pbo_dst = pbo_dst;
10033        let result = converter.convert(
10034            &pbo_src,
10035            &mut pbo_dst,
10036            Rotation::None,
10037            Flip::None,
10038            Crop::no_crop(),
10039        );
10040        result.unwrap();
10041
10042        // Verify: compare with CPU-only conversion of the same input
10043        let cpu_dst = TensorDyn::image(
10044            dst_w,
10045            dst_h,
10046            PixelFormat::Rgba,
10047            DType::U8,
10048            Some(TensorMemory::Mem),
10049            edgefirst_tensor::CpuAccess::ReadWrite,
10050        )
10051        .unwrap();
10052        let (result, _mem_src, cpu_dst) = convert_img(
10053            &mut CPUProcessor::new(),
10054            mem_src,
10055            cpu_dst,
10056            Rotation::None,
10057            Flip::None,
10058            Crop::no_crop(),
10059        );
10060        result.unwrap();
10061
10062        let pbo_dst_img = {
10063            let mut __t = pbo_dst.into_u8().unwrap();
10064            __t.set_format(PixelFormat::Rgba).unwrap();
10065            TensorDyn::from(__t)
10066        };
10067        compare_images(&pbo_dst_img, &cpu_dst, 0.95, function!());
10068        log::info!("test_convert_pbo_to_pbo: PASS — PBO-to-PBO convert matches CPU reference");
10069    }
10070
10071    #[test]
10072    fn test_image_bgra() {
10073        let img = TensorDyn::image(
10074            640,
10075            480,
10076            PixelFormat::Bgra,
10077            DType::U8,
10078            Some(edgefirst_tensor::TensorMemory::Mem),
10079            edgefirst_tensor::CpuAccess::ReadWrite,
10080        )
10081        .unwrap();
10082        assert_eq!(img.width(), Some(640));
10083        assert_eq!(img.height(), Some(480));
10084        assert_eq!(img.format().unwrap().channels(), 4);
10085        assert_eq!(img.format().unwrap(), PixelFormat::Bgra);
10086    }
10087
10088    // ========================================================================
10089    // Tests for EDGEFIRST_FORCE_BACKEND env var
10090    // ========================================================================
10091
10092    #[test]
10093    fn test_force_backend_cpu() {
10094        let _lock = acquire_env_lock();
10095        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
10096        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
10097        let converter = ImageProcessor::new().unwrap();
10098        assert!(converter.cpu.is_some());
10099        assert_eq!(converter.forced_backend, Some(ForcedBackend::Cpu));
10100    }
10101
10102    #[test]
10103    fn test_force_backend_invalid() {
10104        let _lock = acquire_env_lock();
10105        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
10106        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "invalid") };
10107        let result = ImageProcessor::new();
10108        assert!(
10109            matches!(&result, Err(Error::ForcedBackendUnavailable(s)) if s.contains("unknown")),
10110            "invalid backend value should return ForcedBackendUnavailable error: {result:?}"
10111        );
10112    }
10113
10114    #[test]
10115    fn test_force_backend_unset() {
10116        let _lock = acquire_env_lock();
10117        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
10118        unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") };
10119        let converter = ImageProcessor::new().unwrap();
10120        assert!(converter.forced_backend.is_none());
10121    }
10122
10123    // ========================================================================
10124    // Tests for hybrid mask path error handling
10125    // ========================================================================
10126
10127    #[test]
10128    fn test_draw_proto_masks_no_cpu_returns_error() {
10129        // Serialize against all other env-var-mutating tests.
10130        let _lock = acquire_env_lock();
10131        let _guard = EnvGuard::snapshot(&[
10132            "EDGEFIRST_FORCE_BACKEND",
10133            "EDGEFIRST_DISABLE_GL",
10134            "EDGEFIRST_DISABLE_G2D",
10135            "EDGEFIRST_DISABLE_CPU",
10136        ]);
10137
10138        // Disable all backends so cpu.is_none() after construction.
10139        unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
10140        unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
10141        unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
10142
10143        let mut converter = ImageProcessor::new().unwrap();
10144        assert!(converter.cpu.is_none(), "CPU should be disabled");
10145
10146        let dst = TensorDyn::image(
10147            640,
10148            480,
10149            PixelFormat::Rgba,
10150            DType::U8,
10151            Some(TensorMemory::Mem),
10152            edgefirst_tensor::CpuAccess::ReadWrite,
10153        )
10154        .unwrap();
10155        let mut dst_dyn = dst;
10156        let det = [DetectBox {
10157            bbox: edgefirst_tensor::BoundingBox {
10158                xmin: 0.1,
10159                ymin: 0.1,
10160                xmax: 0.5,
10161                ymax: 0.5,
10162            },
10163            score: 0.9,
10164            label: 0,
10165        }];
10166        let proto_data = {
10167            use edgefirst_tensor::{Tensor, TensorDyn};
10168            let coeff_t = Tensor::<f32>::from_slice(&[0.5_f32; 4], &[1, 4]).unwrap();
10169            let protos_t =
10170                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
10171            ProtoData {
10172                mask_coefficients: TensorDyn::F32(coeff_t),
10173                protos: TensorDyn::F32(protos_t),
10174                layout: ProtoLayout::Nhwc,
10175            }
10176        };
10177        let result =
10178            converter.draw_proto_masks(&mut dst_dyn, &det, &proto_data, Default::default());
10179        assert!(
10180            matches!(&result, Err(Error::Internal(s)) if s.contains("CPU backend")),
10181            "draw_proto_masks without CPU should return Internal error: {result:?}"
10182        );
10183    }
10184
10185    #[test]
10186    fn test_draw_proto_masks_cpu_fallback_works() {
10187        // Force CPU-only backend to ensure the CPU fallback path executes.
10188        // Serialized under ENV_MUTEX so we don't race with disable-var tests.
10189        let _lock = acquire_env_lock();
10190        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
10191        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
10192        let mut converter = ImageProcessor::new().unwrap();
10193        assert!(converter.cpu.is_some());
10194
10195        let dst = TensorDyn::image(
10196            64,
10197            64,
10198            PixelFormat::Rgba,
10199            DType::U8,
10200            Some(TensorMemory::Mem),
10201            edgefirst_tensor::CpuAccess::ReadWrite,
10202        )
10203        .unwrap();
10204        let mut dst_dyn = dst;
10205        let det = [DetectBox {
10206            bbox: edgefirst_tensor::BoundingBox {
10207                xmin: 0.1,
10208                ymin: 0.1,
10209                xmax: 0.5,
10210                ymax: 0.5,
10211            },
10212            score: 0.9,
10213            label: 0,
10214        }];
10215        let proto_data = {
10216            use edgefirst_tensor::{Tensor, TensorDyn};
10217            let coeff_t = Tensor::<f32>::from_slice(&[0.5_f32; 4], &[1, 4]).unwrap();
10218            let protos_t =
10219                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
10220            ProtoData {
10221                mask_coefficients: TensorDyn::F32(coeff_t),
10222                protos: TensorDyn::F32(protos_t),
10223                layout: ProtoLayout::Nhwc,
10224            }
10225        };
10226        let result =
10227            converter.draw_proto_masks(&mut dst_dyn, &det, &proto_data, Default::default());
10228        assert!(result.is_ok(), "CPU fallback path should work: {result:?}");
10229    }
10230
10231    // ============================================================
10232    // draw_decoded_masks / draw_proto_masks — 4-scenario pixel-
10233    // verified tests. Exercises each backend against the full
10234    // output-contract matrix:
10235    //
10236    //   | detections | background | expected dst             |
10237    //   |------------|------------|--------------------------|
10238    //   | empty      | none       | fully cleared (0x00)     |
10239    //   | empty      | set        | fully equal to bg        |
10240    //   | set        | none       | cleared outside box +    |
10241    //   |            |            | mask-coloured inside     |
10242    //   | set        | set        | bg outside box + mask    |
10243    //   |            |            | blended inside           |
10244    //
10245    // Every test pre-fills dst with a non-zero "dirty" pattern so
10246    // that any silent `return Ok(())` leaks the pattern into the
10247    // asserted output and fails loudly.
10248    // ============================================================
10249
10250    // =========================================================================
10251    // Env-var serialisation helpers
10252    //
10253    // ALL tests that mutate any EDGEFIRST_* backend env var must hold
10254    // ENV_MUTEX for their full duration.  This single mutex serialises
10255    // test_disable_env_var, test_draw_proto_masks_no_cpu_returns_error,
10256    // test_force_backend_*, with_force_backend, and with_env — preventing
10257    // any two of them from racing in a parallel `cargo test` run.
10258    // =========================================================================
10259
10260    /// Acquire the process-wide env-var mutex.  Returns a guard that must be
10261    /// kept alive for the entire duration of the test body.
10262    fn acquire_env_lock() -> std::sync::MutexGuard<'static, ()> {
10263        use std::sync::{Mutex, OnceLock};
10264        static ENV_MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
10265        ENV_MUTEX
10266            .get_or_init(|| Mutex::new(()))
10267            .lock()
10268            .unwrap_or_else(|e| e.into_inner())
10269    }
10270
10271    /// RAII guard that snapshots a set of env vars on construction and
10272    /// restores them on `Drop`, even if the test panics.
10273    struct EnvGuard {
10274        vars: Vec<(&'static str, Option<String>)>,
10275    }
10276
10277    impl EnvGuard {
10278        /// Snapshot the current values of `names`.  Call this while holding
10279        /// the env lock (the lock is not taken here — that is the caller's
10280        /// responsibility so the lock scope can be wider than the guard).
10281        fn snapshot(names: &[&'static str]) -> Self {
10282            Self {
10283                vars: names.iter().map(|&k| (k, std::env::var(k).ok())).collect(),
10284            }
10285        }
10286    }
10287
10288    impl Drop for EnvGuard {
10289        fn drop(&mut self) {
10290            for (k, v) in &self.vars {
10291                match v {
10292                    Some(s) => unsafe { std::env::set_var(k, s) },
10293                    None => unsafe { std::env::remove_var(k) },
10294                }
10295            }
10296        }
10297    }
10298
10299    /// Run `body` with `EDGEFIRST_FORCE_BACKEND` temporarily set (or
10300    /// removed), restoring the prior value afterward. Tests are env-
10301    /// serialized via the process-wide `ENV_MUTEX`.
10302    fn with_force_backend<R>(value: Option<&str>, body: impl FnOnce() -> R) -> R {
10303        let _lock = acquire_env_lock();
10304        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
10305        match value {
10306            Some(v) => unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", v) },
10307            None => unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") },
10308        }
10309        body()
10310    }
10311
10312    /// Allocate an RGBA image tensor and pre-fill every byte with a
10313    /// distinctive non-zero pattern. Any test that relies on the old
10314    /// "dst is already cleared" assumption will see this pattern leak
10315    /// through to the output and fail.
10316    fn make_dirty_dst(w: usize, h: usize, mem: Option<TensorMemory>) -> TensorDyn {
10317        let dst = TensorDyn::image(
10318            w,
10319            h,
10320            PixelFormat::Rgba,
10321            DType::U8,
10322            mem,
10323            edgefirst_tensor::CpuAccess::ReadWrite,
10324        )
10325        .unwrap();
10326        {
10327            use edgefirst_tensor::TensorMapTrait;
10328            let u8t = dst.as_typed::<u8>().unwrap();
10329            let mut map = u8t.map().unwrap();
10330            for (i, b) in map.as_mut_slice().iter_mut().enumerate() {
10331                *b = 0xA0u8.wrapping_add((i as u8) & 0x3F);
10332            }
10333        }
10334        dst
10335    }
10336
10337    /// Allocate an RGBA background filled with a constant colour.
10338    fn make_bg(w: usize, h: usize, mem: Option<TensorMemory>, rgba: [u8; 4]) -> TensorDyn {
10339        let bg = TensorDyn::image(
10340            w,
10341            h,
10342            PixelFormat::Rgba,
10343            DType::U8,
10344            mem,
10345            edgefirst_tensor::CpuAccess::ReadWrite,
10346        )
10347        .unwrap();
10348        {
10349            use edgefirst_tensor::TensorMapTrait;
10350            let u8t = bg.as_typed::<u8>().unwrap();
10351            let mut map = u8t.map().unwrap();
10352            for chunk in map.as_mut_slice().as_chunks_mut::<4>().0 {
10353                chunk.copy_from_slice(&rgba);
10354            }
10355        }
10356        bg
10357    }
10358
10359    fn pixel_at(dst: &TensorDyn, x: usize, y: usize) -> [u8; 4] {
10360        use edgefirst_tensor::TensorMapTrait;
10361        let w = dst.width().unwrap();
10362        let off = (y * w + x) * 4;
10363        let u8t = dst.as_typed::<u8>().unwrap();
10364        let map = u8t.map().unwrap();
10365        let s = map.as_slice();
10366        [s[off], s[off + 1], s[off + 2], s[off + 3]]
10367    }
10368
10369    fn assert_every_pixel_eq(dst: &TensorDyn, expected: [u8; 4], case: &str) {
10370        use edgefirst_tensor::TensorMapTrait;
10371        let u8t = dst.as_typed::<u8>().unwrap();
10372        let map = u8t.map().unwrap();
10373        for (i, chunk) in map.as_slice().as_chunks::<4>().0.iter().enumerate() {
10374            assert_eq!(
10375                chunk, &expected,
10376                "{case}: pixel idx {i} = {chunk:?}, expected {expected:?}"
10377            );
10378        }
10379    }
10380
10381    /// Scenario 1: empty detections, empty segmentation, no background
10382    /// → dst must be fully cleared to 0x00000000.
10383    fn scenario_empty_no_bg(processor: &mut ImageProcessor, case: &str) {
10384        let mut dst = make_dirty_dst(64, 64, None);
10385        processor
10386            .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::default())
10387            .unwrap_or_else(|e| panic!("{case}/decoded_masks empty+no-bg failed: {e:?}"));
10388        assert_every_pixel_eq(&dst, [0, 0, 0, 0], &format!("{case}/decoded"));
10389
10390        let mut dst = make_dirty_dst(64, 64, None);
10391        let proto = {
10392            use edgefirst_tensor::{Tensor, TensorDyn};
10393            // Placeholder (no detections); shape [1, 4] to keep the tensor well-formed.
10394            let coeff_t = Tensor::<f32>::from_slice(&[0.0_f32; 4], &[1, 4]).unwrap();
10395            let protos_t =
10396                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
10397            ProtoData {
10398                mask_coefficients: TensorDyn::F32(coeff_t),
10399                protos: TensorDyn::F32(protos_t),
10400                layout: ProtoLayout::Nhwc,
10401            }
10402        };
10403        processor
10404            .draw_proto_masks(&mut dst, &[], &proto, MaskOverlay::default())
10405            .unwrap_or_else(|e| panic!("{case}/proto_masks empty+no-bg failed: {e:?}"));
10406        assert_every_pixel_eq(&dst, [0, 0, 0, 0], &format!("{case}/proto"));
10407    }
10408
10409    /// Scenario 2: empty detections, empty segmentation, background set
10410    /// → dst must be fully equal to bg.
10411    fn scenario_empty_with_bg(processor: &mut ImageProcessor, case: &str) {
10412        let bg_color = [42, 99, 200, 255];
10413        let bg = make_bg(64, 64, None, bg_color);
10414        let overlay = MaskOverlay::new().with_background(&bg);
10415
10416        let mut dst = make_dirty_dst(64, 64, None);
10417        processor
10418            .draw_decoded_masks(&mut dst, &[], &[], overlay)
10419            .unwrap_or_else(|e| panic!("{case}/decoded_masks empty+bg failed: {e:?}"));
10420        assert_every_pixel_eq(&dst, bg_color, &format!("{case}/decoded bg blit"));
10421
10422        let mut dst = make_dirty_dst(64, 64, None);
10423        let proto = {
10424            use edgefirst_tensor::{Tensor, TensorDyn};
10425            // Placeholder (no detections); shape [1, 4] to keep the tensor well-formed.
10426            let coeff_t = Tensor::<f32>::from_slice(&[0.0_f32; 4], &[1, 4]).unwrap();
10427            let protos_t =
10428                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
10429            ProtoData {
10430                mask_coefficients: TensorDyn::F32(coeff_t),
10431                protos: TensorDyn::F32(protos_t),
10432                layout: ProtoLayout::Nhwc,
10433            }
10434        };
10435        processor
10436            .draw_proto_masks(&mut dst, &[], &proto, overlay)
10437            .unwrap_or_else(|e| panic!("{case}/proto_masks empty+bg failed: {e:?}"));
10438        assert_every_pixel_eq(&dst, bg_color, &format!("{case}/proto bg blit"));
10439    }
10440
10441    /// Scenario 3: one detection with a fully-opaque segmentation fill,
10442    /// no background → outside the box dst must be 0x00, inside it must
10443    /// be a non-zero mask colour (the render_segmentation output).
10444    fn scenario_detect_no_bg(processor: &mut ImageProcessor, case: &str) {
10445        use edgefirst_tensor::Segmentation;
10446        use ndarray::Array3;
10447        processor
10448            .set_class_colors(&[[200, 80, 40, 255]])
10449            .expect("set_class_colors");
10450
10451        let detect = DetectBox {
10452            bbox: [0.25, 0.25, 0.75, 0.75].into(),
10453            score: 0.99,
10454            label: 0,
10455        };
10456        let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
10457        let seg = Segmentation {
10458            segmentation: edgefirst_tensor::Tensor::from_arrayview3(seg_arr.view())
10459                .unwrap()
10460                .into(),
10461            xmin: 0.25,
10462            ymin: 0.25,
10463            xmax: 0.75,
10464            ymax: 0.75,
10465        };
10466
10467        let mut dst = make_dirty_dst(64, 64, None);
10468        processor
10469            .draw_decoded_masks(&mut dst, &[detect], &[seg], MaskOverlay::default())
10470            .unwrap_or_else(|e| panic!("{case}/decoded_masks detect+no-bg failed: {e:?}"));
10471
10472        // Outside the bbox (corner): must be cleared black.
10473        let corner = pixel_at(&dst, 2, 2);
10474        assert_eq!(
10475            corner,
10476            [0, 0, 0, 0],
10477            "{case}/decoded: corner (2,2) leaked dirty pattern: {corner:?}"
10478        );
10479        // Inside the bbox (center): the mask colour must be visible.
10480        // Any non-zero pixel is acceptable — exact rendering varies
10481        // between backends (GL smoothstep, CPU nearest).
10482        let center = pixel_at(&dst, 32, 32);
10483        assert!(
10484            center != [0, 0, 0, 0],
10485            "{case}/decoded: center (32,32) was not coloured: {center:?}"
10486        );
10487    }
10488
10489    /// Scenario 4: detection + background. Outside the box must match
10490    /// bg; inside the box must NOT match bg (mask blended on top).
10491    fn scenario_detect_with_bg(processor: &mut ImageProcessor, case: &str) {
10492        use edgefirst_tensor::Segmentation;
10493        use ndarray::Array3;
10494        processor
10495            .set_class_colors(&[[200, 80, 40, 255]])
10496            .expect("set_class_colors");
10497        let bg_color = [10, 20, 30, 255];
10498        let bg = make_bg(64, 64, None, bg_color);
10499
10500        let detect = DetectBox {
10501            bbox: [0.25, 0.25, 0.75, 0.75].into(),
10502            score: 0.99,
10503            label: 0,
10504        };
10505        let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
10506        let seg = Segmentation {
10507            segmentation: edgefirst_tensor::Tensor::from_arrayview3(seg_arr.view())
10508                .unwrap()
10509                .into(),
10510            xmin: 0.25,
10511            ymin: 0.25,
10512            xmax: 0.75,
10513            ymax: 0.75,
10514        };
10515
10516        let overlay = MaskOverlay::new().with_background(&bg);
10517        let mut dst = make_dirty_dst(64, 64, None);
10518        processor
10519            .draw_decoded_masks(&mut dst, &[detect], &[seg], overlay)
10520            .unwrap_or_else(|e| panic!("{case}/decoded_masks detect+bg failed: {e:?}"));
10521
10522        // Outside the bbox (corner): bg colour.
10523        let corner = pixel_at(&dst, 2, 2);
10524        assert_eq!(
10525            corner, bg_color,
10526            "{case}/decoded: corner (2,2) should show bg {bg_color:?} got {corner:?}"
10527        );
10528        // Inside the bbox (center): mask blended on bg, must differ from
10529        // pure bg (alpha-blend with mask colour produces a distinct shade).
10530        let center = pixel_at(&dst, 32, 32);
10531        assert!(
10532            center != bg_color,
10533            "{case}/decoded: center (32,32) should differ from bg {bg_color:?}, got {center:?}"
10534        );
10535    }
10536
10537    /// Run all 4 scenarios against the processor. Skip gracefully if
10538    /// construction fails (backend unavailable on this host).
10539    fn run_all_scenarios(
10540        force_backend: Option<&'static str>,
10541        case: &'static str,
10542        require_dma_for_bg: bool,
10543    ) {
10544        if require_dma_for_bg && !edgefirst_tensor::is_dma_available() {
10545            eprintln!("SKIPPED: {case} — DMA not available on this host");
10546            return;
10547        }
10548        let processor_result = with_force_backend(force_backend, ImageProcessor::new);
10549        let mut processor = match processor_result {
10550            Ok(p) => p,
10551            Err(e) => {
10552                eprintln!("SKIPPED: {case} — backend init failed: {e:?}");
10553                return;
10554            }
10555        };
10556        scenario_empty_no_bg(&mut processor, case);
10557        scenario_empty_with_bg(&mut processor, case);
10558        scenario_detect_no_bg(&mut processor, case);
10559        scenario_detect_with_bg(&mut processor, case);
10560    }
10561
10562    #[test]
10563    fn test_draw_masks_4_scenarios_cpu() {
10564        run_all_scenarios(Some("cpu"), "cpu", false);
10565    }
10566
10567    #[test]
10568    fn test_draw_masks_4_scenarios_auto() {
10569        run_all_scenarios(None, "auto", false);
10570    }
10571
10572    #[cfg(target_os = "linux")]
10573    #[cfg(feature = "opengl")]
10574    #[test]
10575    fn test_draw_masks_4_scenarios_opengl() {
10576        run_all_scenarios(Some("opengl"), "opengl", false);
10577    }
10578
10579    /// G2D forced backend: exercises the zero-detection empty-frame
10580    /// paths via `g2d_clear` and `g2d_blit`. Scenarios 3 and 4 (with
10581    /// detections) expect `NotImplemented` since G2D has no rasterizer
10582    /// for boxes / masks.
10583    #[cfg(target_os = "linux")]
10584    #[test]
10585    fn test_draw_masks_zero_detection_g2d_forced() {
10586        if !edgefirst_tensor::is_dma_available() {
10587            eprintln!("SKIPPED: g2d forced — DMA not available on this host");
10588            return;
10589        }
10590        let processor_result = with_force_backend(Some("g2d"), ImageProcessor::new);
10591        let mut processor = match processor_result {
10592            Ok(p) => p,
10593            Err(e) => {
10594                eprintln!("SKIPPED: g2d forced — init failed: {e:?}");
10595                return;
10596            }
10597        };
10598
10599        // Case 1: empty + no bg. G2D requires DMA-backed dst.
10600        let mut dst = TensorDyn::image(
10601            64,
10602            64,
10603            PixelFormat::Rgba,
10604            DType::U8,
10605            Some(TensorMemory::DmaBuf),
10606            edgefirst_tensor::CpuAccess::ReadWrite,
10607        )
10608        .unwrap();
10609        {
10610            use edgefirst_tensor::TensorMapTrait;
10611            let u8t = dst.as_typed_mut::<u8>().unwrap();
10612            let mut map = u8t.map().unwrap();
10613            map.as_mut_slice().fill(0xBB);
10614        }
10615        processor
10616            .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::default())
10617            .expect("g2d empty+no-bg");
10618        assert_every_pixel_eq(&dst, [0, 0, 0, 0], "g2d/case1 cleared");
10619
10620        // Case 2: empty + bg. Both surfaces DMA-backed for g2d_blit.
10621        let bg_color = [7, 11, 13, 255];
10622        let bg = {
10623            let t = TensorDyn::image(
10624                64,
10625                64,
10626                PixelFormat::Rgba,
10627                DType::U8,
10628                Some(TensorMemory::DmaBuf),
10629                edgefirst_tensor::CpuAccess::ReadWrite,
10630            )
10631            .unwrap();
10632            {
10633                use edgefirst_tensor::TensorMapTrait;
10634                let u8t = t.as_typed::<u8>().unwrap();
10635                let mut map = u8t.map().unwrap();
10636                for chunk in map.as_mut_slice().as_chunks_mut::<4>().0 {
10637                    chunk.copy_from_slice(&bg_color);
10638                }
10639            }
10640            t
10641        };
10642        let mut dst = TensorDyn::image(
10643            64,
10644            64,
10645            PixelFormat::Rgba,
10646            DType::U8,
10647            Some(TensorMemory::DmaBuf),
10648            edgefirst_tensor::CpuAccess::ReadWrite,
10649        )
10650        .unwrap();
10651        {
10652            use edgefirst_tensor::TensorMapTrait;
10653            let u8t = dst.as_typed_mut::<u8>().unwrap();
10654            let mut map = u8t.map().unwrap();
10655            map.as_mut_slice().fill(0x55);
10656        }
10657        processor
10658            .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::new().with_background(&bg))
10659            .expect("g2d empty+bg");
10660        assert_every_pixel_eq(&dst, bg_color, "g2d/case2 bg blit");
10661
10662        // Case 3 and 4: detect present — must return NotImplemented.
10663        let detect = DetectBox {
10664            bbox: [0.25, 0.25, 0.75, 0.75].into(),
10665            score: 0.9,
10666            label: 0,
10667        };
10668        let mut dst = TensorDyn::image(
10669            64,
10670            64,
10671            PixelFormat::Rgba,
10672            DType::U8,
10673            Some(TensorMemory::DmaBuf),
10674            edgefirst_tensor::CpuAccess::ReadWrite,
10675        )
10676        .unwrap();
10677        let err = processor
10678            .draw_decoded_masks(&mut dst, &[detect], &[], MaskOverlay::default())
10679            .expect_err("g2d must reject detect-present draw_decoded_masks");
10680        assert!(
10681            matches!(err, Error::NotImplemented(_)),
10682            "g2d case3 wrong error: {err:?}"
10683        );
10684    }
10685
10686    #[test]
10687    fn test_set_format_then_cpu_convert() {
10688        // Force CPU backend; serialized under ENV_MUTEX to avoid racing with
10689        // test_force_backend_* and test_disable_env_var.
10690        let _lock = acquire_env_lock();
10691        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
10692        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
10693        let mut processor = ImageProcessor::new().unwrap();
10694
10695        // Load a source image
10696        let image = edgefirst_bench::testdata::read("zidane.jpg");
10697        let src = load_image_test_helper(&image, Some(PixelFormat::Rgba), None).unwrap();
10698
10699        // Create a raw tensor, then attach format — simulating the from_fd workflow
10700        let mut dst =
10701            TensorDyn::new(&[640, 640, 3], DType::U8, Some(TensorMemory::Mem), None).unwrap();
10702        dst.set_format(PixelFormat::Rgb).unwrap();
10703
10704        // Convert should work with the set_format-annotated tensor
10705        processor
10706            .convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
10707            .unwrap();
10708
10709        // Verify format survived conversion
10710        assert_eq!(dst.format(), Some(PixelFormat::Rgb));
10711        assert_eq!(dst.width(), Some(640));
10712        assert_eq!(dst.height(), Some(640));
10713    }
10714
10715    /// Verify that creating multiple ImageProcessors on the same thread and
10716    /// performing a resize on each does not deadlock or error.
10717    ///
10718    /// Uses automatic memory allocation (DMA → PBO → Mem fallback) so that
10719    /// hardware backends (OpenGL, G2D) are exercised on capable targets.
10720    #[test]
10721    fn test_multiple_image_processors_same_thread() {
10722        // Hold the env mutex so env-var-mutating tests can't corrupt the state
10723        // seen by ImageProcessor::new() calls during this test.
10724        let _lock = acquire_env_lock();
10725        let mut processors: Vec<ImageProcessor> = (0..4)
10726            .map(|_| ImageProcessor::new().expect("ImageProcessor::new() failed"))
10727            .collect();
10728
10729        for proc in &mut processors {
10730            let src = proc
10731                .create_image(
10732                    128,
10733                    128,
10734                    PixelFormat::Rgb,
10735                    DType::U8,
10736                    None,
10737                    edgefirst_tensor::CpuAccess::ReadWrite,
10738                )
10739                .expect("create src failed");
10740            let mut dst = proc
10741                .create_image(
10742                    64,
10743                    64,
10744                    PixelFormat::Rgb,
10745                    DType::U8,
10746                    None,
10747                    edgefirst_tensor::CpuAccess::ReadWrite,
10748                )
10749                .expect("create dst failed");
10750            proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
10751                .expect("convert failed");
10752            assert_eq!(dst.width(), Some(64));
10753            assert_eq!(dst.height(), Some(64));
10754        }
10755    }
10756
10757    /// Verify that creating ImageProcessors on separate threads and performing
10758    /// a resize on each does not deadlock or error.
10759    ///
10760    /// Uses automatic memory allocation (DMA → PBO → Mem fallback) so that
10761    /// hardware backends (OpenGL, G2D) are exercised on capable targets.
10762    /// A 60-second timeout prevents CI from hanging on deadlock regressions.
10763    #[test]
10764    fn test_multiple_image_processors_separate_threads() {
10765        use std::sync::mpsc;
10766        use std::time::Duration;
10767
10768        // The Vivante GC7000UL driver (i.MX 8M Plus) double-frees on concurrent
10769        // EGL context teardown — four processors spun up on four threads here
10770        // trips it and aborts the whole test binary (SIGABRT, not a catchable
10771        // panic). The bug is the driver's, not the HAL's; this test is kept so
10772        // it still exercises the multi-context path on every other GPU. The
10773        // on-target GitHub Actions imx8mp runner sets EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS
10774        // to skip just this case there, while the run stays red anywhere else
10775        // a regression appears. (Skip, not #[ignore]: the platform is decided
10776        // at runtime, not compile time.)
10777        if std::env::var_os("EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS").is_some() {
10778            eprintln!(
10779                "SKIPPED: test_multiple_image_processors_separate_threads — known Vivante \
10780                 GC7000UL concurrent-EGL-teardown double-free \
10781                 (EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS set)"
10782            );
10783            return;
10784        }
10785
10786        const TIMEOUT: Duration = Duration::from_secs(60);
10787
10788        // Hold the env mutex so env-var-mutating tests can't corrupt ImageProcessor::new()
10789        // calls made inside the spawned threads during this test.
10790        let _lock = acquire_env_lock();
10791
10792        let (tx, rx) = mpsc::channel::<()>();
10793
10794        std::thread::spawn(move || {
10795            let handles: Vec<_> = (0..4)
10796                .map(|i| {
10797                    std::thread::spawn(move || {
10798                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
10799                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
10800                        });
10801                        let src = proc
10802                            .create_image(
10803                                128,
10804                                128,
10805                                PixelFormat::Rgb,
10806                                DType::U8,
10807                                None,
10808                                edgefirst_tensor::CpuAccess::ReadWrite,
10809                            )
10810                            .unwrap_or_else(|e| panic!("create src failed on thread {i}: {e}"));
10811                        let mut dst = proc
10812                            .create_image(
10813                                64,
10814                                64,
10815                                PixelFormat::Rgb,
10816                                DType::U8,
10817                                None,
10818                                edgefirst_tensor::CpuAccess::ReadWrite,
10819                            )
10820                            .unwrap_or_else(|e| panic!("create dst failed on thread {i}: {e}"));
10821                        proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
10822                            .unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
10823                        assert_eq!(dst.width(), Some(64));
10824                        assert_eq!(dst.height(), Some(64));
10825                    })
10826                })
10827                .collect();
10828
10829            for (i, h) in handles.into_iter().enumerate() {
10830                h.join()
10831                    .unwrap_or_else(|e| panic!("thread {i} panicked: {e:?}"));
10832            }
10833
10834            let _ = tx.send(());
10835        });
10836
10837        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
10838            panic!("test_multiple_image_processors_separate_threads timed out after {TIMEOUT:?}")
10839        });
10840    }
10841
10842    /// Verify that 4 fully-initialized ImageProcessors on separate threads can
10843    /// all operate concurrently without deadlocking each other.
10844    ///
10845    /// All processors are created first, then a barrier synchronizes them so
10846    /// they all start converting at the same instant — maximizing contention.
10847    /// A 60-second timeout prevents CI from hanging on deadlock regressions.
10848    #[test]
10849    fn test_image_processors_concurrent_operations() {
10850        use std::sync::{mpsc, Arc, Barrier};
10851        use std::time::Duration;
10852
10853        const N: usize = 4;
10854        const ROUNDS: usize = 10;
10855        const TIMEOUT: Duration = Duration::from_secs(60);
10856
10857        // Hold the env mutex so env-var-mutating tests can't corrupt ImageProcessor::new()
10858        // calls made inside the spawned threads during this test.
10859        let _lock = acquire_env_lock();
10860
10861        let (tx, rx) = mpsc::channel::<()>();
10862
10863        std::thread::spawn(move || {
10864            let barrier = Arc::new(Barrier::new(N));
10865
10866            let handles: Vec<_> = (0..N)
10867                .map(|i| {
10868                    let barrier = Arc::clone(&barrier);
10869                    std::thread::spawn(move || {
10870                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
10871                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
10872                        });
10873
10874                        // All threads wait here until every processor is initialized.
10875                        barrier.wait();
10876
10877                        // Now all 4 hammer the GPU concurrently.
10878                        for round in 0..ROUNDS {
10879                            let src = proc
10880                                .create_image(
10881                                    128,
10882                                    128,
10883                                    PixelFormat::Rgb,
10884                                    DType::U8,
10885                                    None,
10886                                    edgefirst_tensor::CpuAccess::ReadWrite,
10887                                )
10888                                .unwrap_or_else(|e| {
10889                                    panic!("create src failed on thread {i} round {round}: {e}")
10890                                });
10891                            let mut dst = proc
10892                                .create_image(
10893                                    64,
10894                                    64,
10895                                    PixelFormat::Rgb,
10896                                    DType::U8,
10897                                    None,
10898                                    edgefirst_tensor::CpuAccess::ReadWrite,
10899                                )
10900                                .unwrap_or_else(|e| {
10901                                    panic!("create dst failed on thread {i} round {round}: {e}")
10902                                });
10903                            proc.convert(
10904                                &src,
10905                                &mut dst,
10906                                Rotation::None,
10907                                Flip::None,
10908                                Crop::default(),
10909                            )
10910                            .unwrap_or_else(|e| {
10911                                panic!("convert failed on thread {i} round {round}: {e}")
10912                            });
10913                            assert_eq!(dst.width(), Some(64));
10914                            assert_eq!(dst.height(), Some(64));
10915                        }
10916                    })
10917                })
10918                .collect();
10919
10920            for (i, h) in handles.into_iter().enumerate() {
10921                h.join()
10922                    .unwrap_or_else(|e| panic!("thread {i} panicked: {e:?}"));
10923            }
10924
10925            let _ = tx.send(());
10926        });
10927
10928        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
10929            panic!("test_image_processors_concurrent_operations timed out after {TIMEOUT:?}")
10930        });
10931    }
10932
10933    /// THE parallel-processors demonstration test: 4 ImageProcessors on 4
10934    /// threads, each with its own GL context and worker, converting
10935    /// per-thread-DISTINCT synthetic inputs concurrently (barrier-released);
10936    /// every output must byte-match the thread's own pre-barrier sequential
10937    /// oracle from the same processor. On LifecycleOnly platforms (Mali,
10938    /// V3D, Tegra, llvmpipe, macOS) the converts genuinely overlap on the
10939    /// GPU; on Vivante they serialize via the Full policy and the test still
10940    /// must pass. Distinct inputs make any cross-processor state leakage
10941    /// (wrong texture, wrong context, clobbered upload) visible as a byte
10942    /// diff rather than a coincidental match — proven by a scratch
10943    /// cross-wire run (neighbor's input post-oracle) failing on every
10944    /// thread with ~53% of bytes diverged.
10945    ///
10946    /// Skipped under EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS like
10947    /// `test_multiple_image_processors_separate_threads`: the galcore driver
10948    /// can abort intermittently on concurrent multi-processor lifecycles
10949    /// regardless of locking (P0 spike: reproduces fully serialized).
10950    #[test]
10951    fn test_parallel_processors_unique_outputs() {
10952        use std::sync::{mpsc, Arc, Barrier};
10953        use std::time::Duration;
10954
10955        const N: usize = 4;
10956        const ROUNDS: usize = 25;
10957        const TIMEOUT: Duration = Duration::from_secs(60);
10958
10959        if std::env::var_os("EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS").is_some() {
10960            eprintln!(
10961                "SKIPPED: test_parallel_processors_unique_outputs — known Vivante \
10962                 GC7000UL concurrent-multi-processor driver abort \
10963                 (EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS set)"
10964            );
10965            return;
10966        }
10967
10968        let _lock = acquire_env_lock();
10969        let (tx, rx) = mpsc::channel::<()>();
10970
10971        std::thread::spawn(move || {
10972            let barrier = Arc::new(Barrier::new(N));
10973            let handles: Vec<_> = (0..N)
10974                .map(|i| {
10975                    let barrier = Arc::clone(&barrier);
10976                    std::thread::spawn(move || {
10977                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
10978                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
10979                        });
10980                        // CI-weight geometry (llvmpipe renders on the CPU).
10981                        let (w, h) = (640usize, 480usize);
10982                        let mem = if edgefirst_tensor::is_dma_available() {
10983                            Some(TensorMemory::DmaBuf)
10984                        } else {
10985                            Some(TensorMemory::Mem)
10986                        };
10987                        let src = proc
10988                            .create_image(
10989                                w,
10990                                h,
10991                                PixelFormat::Nv12,
10992                                DType::U8,
10993                                mem,
10994                                edgefirst_tensor::CpuAccess::ReadWrite,
10995                            )
10996                            .unwrap();
10997                        {
10998                            let t = src.as_typed::<u8>().unwrap();
10999                            let mut m = t.map().unwrap();
11000                            let s = m.as_mut_slice();
11001                            for (j, b) in s[..w * h].iter_mut().enumerate() {
11002                                *b = ((i * 53 + j) % 200 + 16) as u8;
11003                            }
11004                            for b in &mut s[w * h..] {
11005                                *b = (80 + i * 24) as u8;
11006                            }
11007                        }
11008                        let lb = Crop::letterbox([114, 114, 114, 255]);
11009                        let convert_once = |proc: &mut ImageProcessor| -> Vec<u8> {
11010                            let mut dst = proc
11011                                .create_image(
11012                                    320,
11013                                    320,
11014                                    PixelFormat::Rgba,
11015                                    DType::U8,
11016                                    mem,
11017                                    edgefirst_tensor::CpuAccess::ReadWrite,
11018                                )
11019                                .unwrap();
11020                            proc.convert(&src, &mut dst, Rotation::None, Flip::None, lb)
11021                                .unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
11022                            let t = dst.as_typed::<u8>().unwrap();
11023                            let m = t.map().unwrap();
11024                            m.as_slice().to_vec()
11025                        };
11026
11027                        let oracle = convert_once(&mut proc);
11028                        barrier.wait();
11029                        for round in 0..ROUNDS {
11030                            let out = convert_once(&mut proc);
11031                            let diffs = oracle.iter().zip(&out).filter(|(a, b)| a != b).count();
11032                            assert!(
11033                                diffs == 0,
11034                                "thread {i} round {round}: {diffs}/{} bytes diverged \
11035                                 from this processor's own oracle — cross-processor \
11036                                 GL state leakage under parallel execution",
11037                                oracle.len()
11038                            );
11039                        }
11040                    })
11041                })
11042                .collect();
11043
11044            for (i, h) in handles.into_iter().enumerate() {
11045                h.join()
11046                    .unwrap_or_else(|e| panic!("parallel thread {i} panicked: {e:?}"));
11047            }
11048            let _ = tx.send(());
11049        });
11050
11051        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
11052            panic!("test_parallel_processors_unique_outputs timed out after {TIMEOUT:?}")
11053        });
11054    }
11055
11056    /// Heavy on-demand stressor for the GL serialization policy: 4
11057    /// processors × 4 threads × barrier × 200 NV12 720p → RGB 640 letterbox
11058    /// converts (DMA where available); every output must byte-match the
11059    /// thread's own pre-barrier sequential oracle from the same processor.
11060    /// Ignored by default (heavy; board tool — the CI-weight version is
11061    /// `test_parallel_processors_unique_outputs`). Run explicitly, optionally
11062    /// pinning the policy via EDGEFIRST_GL_SERIALIZE=full|lifecycle:
11063    ///   <test binary> stress_parallel_processors_oracle --ignored
11064    #[test]
11065    #[ignore = "heavy on-demand GL-parallelism stressor; run explicitly on boards"]
11066    fn stress_parallel_processors_oracle() {
11067        use std::sync::{mpsc, Arc, Barrier};
11068        use std::time::Duration;
11069
11070        const N: usize = 4;
11071        const ROUNDS: usize = 200;
11072        const TIMEOUT: Duration = Duration::from_secs(600);
11073
11074        let _lock = acquire_env_lock();
11075        let (tx, rx) = mpsc::channel::<()>();
11076
11077        std::thread::spawn(move || {
11078            let barrier = Arc::new(Barrier::new(N));
11079            let handles: Vec<_> = (0..N)
11080                .map(|i| {
11081                    let barrier = Arc::clone(&barrier);
11082                    std::thread::spawn(move || {
11083                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
11084                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
11085                        });
11086                        let (w, h) = (1280usize, 720usize);
11087                        let mem = if edgefirst_tensor::is_dma_available() {
11088                            Some(TensorMemory::DmaBuf)
11089                        } else {
11090                            Some(TensorMemory::Mem)
11091                        };
11092
11093                        // Per-thread-distinct synthetic NV12 so cross-wired
11094                        // GL state between processors shows up as a byte diff.
11095                        let src = proc
11096                            .create_image(
11097                                w,
11098                                h,
11099                                PixelFormat::Nv12,
11100                                DType::U8,
11101                                mem,
11102                                edgefirst_tensor::CpuAccess::ReadWrite,
11103                            )
11104                            .unwrap();
11105                        {
11106                            let t = src.as_typed::<u8>().unwrap();
11107                            let mut m = t.map().unwrap();
11108                            let s = m.as_mut_slice();
11109                            for (j, b) in s[..w * h].iter_mut().enumerate() {
11110                                *b = ((i * 37 + j) % 200 + 16) as u8;
11111                            }
11112                            for b in &mut s[w * h..] {
11113                                *b = (96 + i * 16) as u8;
11114                            }
11115                        }
11116                        let lb = Crop::letterbox([114, 114, 114, 255]);
11117
11118                        let convert_once = |proc: &mut ImageProcessor| -> Vec<u8> {
11119                            let mut dst = proc
11120                                .create_image(
11121                                    640,
11122                                    640,
11123                                    PixelFormat::Rgb,
11124                                    DType::U8,
11125                                    mem,
11126                                    edgefirst_tensor::CpuAccess::ReadWrite,
11127                                )
11128                                .unwrap();
11129                            proc.convert(&src, &mut dst, Rotation::None, Flip::None, lb)
11130                                .unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
11131                            let t = dst.as_typed::<u8>().unwrap();
11132                            let m = t.map().unwrap();
11133                            m.as_slice().to_vec()
11134                        };
11135
11136                        let oracle = convert_once(&mut proc);
11137                        barrier.wait();
11138                        for round in 0..ROUNDS {
11139                            let out = convert_once(&mut proc);
11140                            let diffs = oracle.iter().zip(&out).filter(|(a, b)| a != b).count();
11141                            assert!(
11142                                diffs == 0,
11143                                "thread {i} round {round}: {diffs}/{} bytes diverged \
11144                                 from the pre-barrier oracle",
11145                                oracle.len()
11146                            );
11147                        }
11148                    })
11149                })
11150                .collect();
11151
11152            for (i, h) in handles.into_iter().enumerate() {
11153                h.join()
11154                    .unwrap_or_else(|e| panic!("stressor thread {i} panicked: {e:?}"));
11155            }
11156            let _ = tx.send(());
11157        });
11158
11159        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
11160            panic!("stress_parallel_processors_oracle timed out after {TIMEOUT:?}")
11161        });
11162    }
11163
11164    // =========================================================================
11165    // F16 / F32 auto-chain fallback integration tests
11166    // =========================================================================
11167
11168    /// Proves the auto-chain (OpenGL → G2D → CPU) NEVER errors for a float
11169    /// combo the GL path does NOT cover.
11170    ///
11171    /// `Yuyv → Rgb F32` is not handled by the GL float render path (which
11172    /// only covers `Rgba → PlanarRgb F16` and `Rgba → Rgb F32`), so the
11173    /// chain falls through to the CPU float path. Before commit 868a7649
11174    /// added CPU U8→F32/F16 support this would have returned `Err`; now it
11175    /// must return `Ok` with output in `[0, 1]` and all values finite.
11176    #[test]
11177    fn convert_f32_auto_never_errors_non_gl_combo() {
11178        const W: usize = 64;
11179        const H: usize = 64;
11180
11181        // Build a small synthetic YUYV source (Y=128, U=128, V=128 → near-grey).
11182        // YUYV packs two pixels into 4 bytes: [Y0, U, Y1, V] per macropixel.
11183        let src = TensorDyn::image(
11184            W,
11185            H,
11186            PixelFormat::Yuyv,
11187            DType::U8,
11188            Some(TensorMemory::Mem),
11189            edgefirst_tensor::CpuAccess::ReadWrite,
11190        )
11191        .unwrap();
11192        {
11193            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
11194            let data = map.as_mut_slice();
11195            for chunk in data.as_chunks_mut::<4>().0 {
11196                chunk[0] = 128; // Y0
11197                chunk[1] = 128; // U
11198                chunk[2] = 160; // Y1 — distinct so a layout bug is visible
11199                chunk[3] = 128; // V
11200            }
11201        }
11202
11203        let mut dst = TensorDyn::image(
11204            W,
11205            H,
11206            PixelFormat::Rgb,
11207            DType::F32,
11208            Some(TensorMemory::Mem),
11209            edgefirst_tensor::CpuAccess::ReadWrite,
11210        )
11211        .unwrap();
11212
11213        let mut proc = ImageProcessor::new().unwrap();
11214        let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
11215        assert!(
11216            result.is_ok(),
11217            "auto-chain Yuyv→Rgb F32 must not error: {:?}",
11218            result.err()
11219        );
11220
11221        // Verify all output values are finite and in [0, 1].
11222        let map = dst.as_typed::<f32>().unwrap().map().unwrap();
11223        let floats = map.as_slice();
11224        assert_eq!(floats.len(), W * H * 3, "unexpected output element count");
11225        for (i, &v) in floats.iter().enumerate() {
11226            assert!(
11227                v.is_finite() && (0.0..=1.0).contains(&v),
11228                "output[{i}]={v} is not finite or not in [0,1]"
11229            );
11230        }
11231
11232        // WEAK-1: Anti-all-zero spot-check.  A Y=128 YUYV source normalises to
11233        // ≈0.502 on the luma channel.  If the buffer is all-zero (e.g. the CPU
11234        // path never wrote to it) this assertion catches the regression.
11235        let first_non_zero = floats.iter().find(|&&v| v > 0.01);
11236        assert!(
11237            first_non_zero.is_some(),
11238            "all-zero output detected — CPU path likely did not write to the destination buffer"
11239        );
11240        // Y=128 → luma ≈ 0.502.  Spot-check the first pixel's R channel
11241        // (which carries luma for a near-grey YUV source).
11242        let r0 = floats[0];
11243        assert!(
11244            (r0 - 0.502_f32).abs() < 0.05,
11245            "first pixel R={r0} expected ≈0.502 (Y=128 neutral grey from YUYV source)"
11246        );
11247    }
11248
11249    /// Proves CPU-forced `Rgba → PlanarRgb F16` correctness.
11250    ///
11251    /// Uses a source with clearly distinct per-channel values so a
11252    /// plane-swap or layout bug surfaces immediately. Tolerance is 2^-9
11253    /// (one F16 ULP at 0.5, i.e. roughly 1/512).
11254    #[test]
11255    // `ImageProcessorConfig` carries a Linux-only `egl_display` field, so
11256    // `{ backend, ..Default::default() }` is a genuine update on Linux but
11257    // covers no remaining fields on macOS, where `clippy::needless_update`
11258    // then fires. `allow` (not `expect`) because the lint is platform-
11259    // conditional — it does not fire on Linux.
11260    #[allow(clippy::needless_update)]
11261    fn convert_f16_forced_cpu_correct() {
11262        const W: usize = 16;
11263        const H: usize = 16;
11264        const TOL: f32 = 1.0 / 512.0; // 2^-9
11265
11266        // pixel (y,x): R = 50+x, G = 100+y*8, B = 200
11267        let src = TensorDyn::image(
11268            W,
11269            H,
11270            PixelFormat::Rgba,
11271            DType::U8,
11272            Some(TensorMemory::Mem),
11273            edgefirst_tensor::CpuAccess::ReadWrite,
11274        )
11275        .unwrap();
11276        {
11277            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
11278            let data = map.as_mut_slice();
11279            for y in 0..H {
11280                for x in 0..W {
11281                    let i = y * W + x;
11282                    data[i * 4] = (50 + x) as u8; // R: 50..65
11283                    data[i * 4 + 1] = (100 + y * 8) as u8; // G: 100..220
11284                    data[i * 4 + 2] = 200; // B: constant
11285                    data[i * 4 + 3] = 255;
11286                }
11287            }
11288        }
11289
11290        let mut dst = TensorDyn::image(
11291            W,
11292            H,
11293            PixelFormat::PlanarRgb,
11294            DType::F16,
11295            Some(TensorMemory::Mem),
11296            edgefirst_tensor::CpuAccess::ReadWrite,
11297        )
11298        .unwrap();
11299
11300        let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
11301            backend: ComputeBackend::Cpu,
11302            ..Default::default()
11303        })
11304        .unwrap();
11305        proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
11306            .expect("forced-CPU Rgba→PlanarRgb F16 must not error");
11307
11308        let src_map = src.as_typed::<u8>().unwrap().map().unwrap();
11309        let src_bytes = src_map.as_slice();
11310        let dst_map = dst.as_typed::<half::f16>().unwrap().map().unwrap();
11311        let dst_halfs = dst_map.as_slice();
11312
11313        let plane = W * H;
11314        assert_eq!(dst_halfs.len(), plane * 3, "wrong output element count");
11315
11316        for y in 0..H {
11317            for x in 0..W {
11318                let i = y * W + x;
11319                let r_exp = src_bytes[i * 4] as f32 / 255.0;
11320                let g_exp = src_bytes[i * 4 + 1] as f32 / 255.0;
11321                let b_exp = src_bytes[i * 4 + 2] as f32 / 255.0;
11322
11323                let r_got = dst_halfs[i].to_f32();
11324                let g_got = dst_halfs[plane + i].to_f32();
11325                let b_got = dst_halfs[2 * plane + i].to_f32();
11326
11327                assert!(
11328                    (r_got - r_exp).abs() <= TOL,
11329                    "R plane ({x},{y}): got {r_got}, expected {r_exp}"
11330                );
11331                assert!(
11332                    (g_got - g_exp).abs() <= TOL,
11333                    "G plane ({x},{y}): got {g_got}, expected {g_exp}"
11334                );
11335                assert!(
11336                    (b_got - b_exp).abs() <= TOL,
11337                    "B plane ({x},{y}): got {b_got}, expected {b_exp}"
11338                );
11339
11340                // Catch plane-swap: R and G must differ (they have different formulas).
11341                if src_bytes[i * 4] != src_bytes[i * 4 + 1] {
11342                    assert_ne!(r_got, g_got, "R and G planes must differ at ({x},{y})");
11343                }
11344            }
11345        }
11346    }
11347
11348    /// Proves the auto-chain falls through to CPU for `Rgba → Rgb F32` with
11349    /// rotation set.
11350    ///
11351    /// The GL float render path rejects any call where rotation ≠ None,
11352    /// returning an error that causes the chain to continue. Before the CPU
11353    /// float fallback this would have produced an error at the end of the
11354    /// chain; now it must reach CPU and return `Ok` with finite `[0, 1]` output.
11355    #[test]
11356    fn convert_f32_with_rotation_falls_back() {
11357        const W: usize = 16;
11358        const H: usize = 16;
11359
11360        // RGBA8 source with a known gradient (distinct per-channel values).
11361        let src = TensorDyn::image(
11362            W,
11363            H,
11364            PixelFormat::Rgba,
11365            DType::U8,
11366            Some(TensorMemory::Mem),
11367            edgefirst_tensor::CpuAccess::ReadWrite,
11368        )
11369        .unwrap();
11370        {
11371            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
11372            let data = map.as_mut_slice();
11373            for y in 0..H {
11374                for x in 0..W {
11375                    let i = y * W + x;
11376                    data[i * 4] = (x * 16) as u8; // R
11377                    data[i * 4 + 1] = (y * 16) as u8; // G
11378                    data[i * 4 + 2] = 128; // B
11379                    data[i * 4 + 3] = 255;
11380                }
11381            }
11382        }
11383
11384        // Rotation swaps W and H, so dst is [W, H] (H×W output).
11385        let mut dst = TensorDyn::image(
11386            H, // dst W = src H after 90° rotation
11387            W, // dst H = src W after 90° rotation
11388            PixelFormat::Rgb,
11389            DType::F32,
11390            Some(TensorMemory::Mem),
11391            edgefirst_tensor::CpuAccess::ReadWrite,
11392        )
11393        .unwrap();
11394
11395        let mut proc = ImageProcessor::new().unwrap();
11396        let result = proc.convert(
11397            &src,
11398            &mut dst,
11399            Rotation::Clockwise90,
11400            Flip::None,
11401            Crop::default(),
11402        );
11403        assert!(
11404            result.is_ok(),
11405            "auto-chain Rgba→Rgb F32 with Rot90 must not error: {:?}",
11406            result.err()
11407        );
11408
11409        let map = dst.as_typed::<f32>().unwrap().map().unwrap();
11410        let floats = map.as_slice();
11411        assert_eq!(floats.len(), H * W * 3, "unexpected output element count");
11412        for (i, &v) in floats.iter().enumerate() {
11413            assert!(
11414                v.is_finite() && (0.0..=1.0).contains(&v),
11415                "output[{i}]={v} is not finite or not in [0,1]"
11416            );
11417        }
11418    }
11419
11420    /// GL-vs-CPU identity parity for `Rgba → PlanarRgb F16`.
11421    ///
11422    /// Converts the same RGBA8 source via forced `OpenGl` and forced `Cpu`,
11423    /// then verifies the two F16 output tensors agree element-wise within
11424    /// 2^-8 (two F16 ULPs at 0.5). Skipped when OpenGL or F16 render is
11425    /// unavailable.
11426    #[test]
11427    #[cfg(all(
11428        any(
11429            target_os = "linux",
11430            target_os = "macos",
11431            target_os = "ios",
11432            target_os = "android",
11433            target_os = "windows"
11434        ),
11435        feature = "opengl"
11436    ))]
11437    fn convert_f16_gl_cpu_parity_identity() {
11438        if !is_opengl_available() {
11439            eprintln!("SKIPPED: convert_f16_gl_cpu_parity_identity - OpenGL not available");
11440            return;
11441        }
11442
11443        const W: usize = 16;
11444        const H: usize = 16;
11445        const TOL: f32 = 1.0 / 256.0; // 2^-8
11446
11447        // pixel (y,x): R = 40+x, G = 80+y*10, B = 180
11448        let src = TensorDyn::image(
11449            W,
11450            H,
11451            PixelFormat::Rgba,
11452            DType::U8,
11453            Some(TensorMemory::Mem),
11454            edgefirst_tensor::CpuAccess::ReadWrite,
11455        )
11456        .unwrap();
11457        {
11458            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
11459            let data = map.as_mut_slice();
11460            for y in 0..H {
11461                for x in 0..W {
11462                    let i = y * W + x;
11463                    data[i * 4] = (40 + x) as u8; // R
11464                    data[i * 4 + 1] = (80 + y * 10) as u8; // G
11465                    data[i * 4 + 2] = 180; // B
11466                    data[i * 4 + 3] = 255;
11467                }
11468            }
11469        }
11470
11471        // GL path.
11472        let gl_result = {
11473            let mut gl_proc = match ImageProcessor::with_config(ImageProcessorConfig {
11474                backend: ComputeBackend::OpenGl,
11475                ..Default::default()
11476            }) {
11477                Ok(p) => p,
11478                Err(e) => {
11479                    eprintln!(
11480                        "SKIPPED: convert_f16_gl_cpu_parity_identity - GL backend unavailable: {e}"
11481                    );
11482                    return;
11483                }
11484            };
11485
11486            if !gl_proc.supported_render_dtypes().f16 {
11487                eprintln!("SKIPPED: convert_f16_gl_cpu_parity_identity - F16 render not supported");
11488                return;
11489            }
11490
11491            let mut dst = TensorDyn::image(
11492                W,
11493                H,
11494                PixelFormat::PlanarRgb,
11495                DType::F16,
11496                Some(TensorMemory::Mem),
11497                edgefirst_tensor::CpuAccess::ReadWrite,
11498            )
11499            .unwrap();
11500            match gl_proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default()) {
11501                Ok(()) => dst,
11502                Err(e) => {
11503                    eprintln!(
11504                        "SKIPPED: convert_f16_gl_cpu_parity_identity - GL convert failed: {e}"
11505                    );
11506                    return;
11507                }
11508            }
11509        };
11510
11511        // CPU path.
11512        let cpu_result = {
11513            let mut cpu_proc = ImageProcessor::with_config(ImageProcessorConfig {
11514                backend: ComputeBackend::Cpu,
11515                ..Default::default()
11516            })
11517            .unwrap();
11518            let mut dst = TensorDyn::image(
11519                W,
11520                H,
11521                PixelFormat::PlanarRgb,
11522                DType::F16,
11523                Some(TensorMemory::Mem),
11524                edgefirst_tensor::CpuAccess::ReadWrite,
11525            )
11526            .unwrap();
11527            cpu_proc
11528                .convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
11529                .expect("forced-CPU Rgba→PlanarRgb F16 must not error");
11530            dst
11531        };
11532
11533        // Compare element-wise.
11534        let gl_map = gl_result.as_typed::<half::f16>().unwrap().map().unwrap();
11535        let cpu_map = cpu_result.as_typed::<half::f16>().unwrap().map().unwrap();
11536        let gl_halfs = gl_map.as_slice();
11537        let cpu_halfs = cpu_map.as_slice();
11538
11539        assert_eq!(
11540            gl_halfs.len(),
11541            cpu_halfs.len(),
11542            "GL and CPU output sizes differ"
11543        );
11544
11545        let plane = W * H;
11546        let channel_names = ["R", "G", "B"];
11547        for (idx, (gl_h, cpu_h)) in gl_halfs.iter().zip(cpu_halfs.iter()).enumerate() {
11548            let gl_v = gl_h.to_f32();
11549            let cpu_v = cpu_h.to_f32();
11550            let err = (gl_v - cpu_v).abs();
11551            let ch = channel_names[idx / plane];
11552            let pixel = idx % plane;
11553            assert!(
11554                err <= TOL,
11555                "GL vs CPU mismatch at {ch}[{pixel}]: GL={gl_v}, CPU={cpu_v}, err={err} > tol={TOL}"
11556            );
11557        }
11558    }
11559
11560    // =========================================================================
11561    // GAP-1: supported_render_dtypes() Linux smoke test
11562    // =========================================================================
11563
11564    /// Exercises the real Linux/Windows GL path that reads
11565    /// `gl.supported_render_dtypes()`. Skipped when no GL backend is
11566    /// available (CI/host without a GPU).
11567    #[test]
11568    #[cfg(all(any(target_os = "linux", target_os = "windows"), feature = "opengl"))]
11569    fn supported_render_dtypes_linux_smoke() {
11570        let proc = match ImageProcessor::new() {
11571            Ok(p) => p,
11572            Err(e) => {
11573                eprintln!("SKIPPED: supported_render_dtypes_linux_smoke — ImageProcessor::new() failed: {e}");
11574                return;
11575            }
11576        };
11577        if proc.opengl.is_none() {
11578            eprintln!("SKIPPED: supported_render_dtypes_linux_smoke — no GL backend on this host");
11579            return;
11580        }
11581        // The call must complete without panicking or deadlocking.
11582        let support = proc.supported_render_dtypes();
11583        eprintln!(
11584            "supported_render_dtypes_linux_smoke: f16={} f32={}",
11585            support.f16, support.f32
11586        );
11587        // No assertion on the specific values — they are hardware-dependent.
11588    }
11589
11590    // =========================================================================
11591    // GAP-2: F16 PlanarRgb with width NOT divisible by 4 falls back to CPU
11592    // =========================================================================
11593
11594    /// The GL float render path rejects PlanarRgb F16 destinations whose width
11595    /// is not a multiple of 4 (the packed RGBA16F swizzle trick requires W%4==0).
11596    /// The auto-chain must transparently fall through to the CPU path, which has
11597    /// no such restriction.  W=18, H=16 is chosen so W%4 == 2.
11598    #[test]
11599    fn convert_f16_pbo_non_4_aligned_width_falls_back() {
11600        const W: usize = 18; // 18 % 4 == 2 — NOT divisible by 4
11601        const H: usize = 16;
11602
11603        // RGBA8 source filled with a flat mid-grey.
11604        let src = TensorDyn::image(
11605            W,
11606            H,
11607            PixelFormat::Rgba,
11608            DType::U8,
11609            Some(TensorMemory::Mem),
11610            edgefirst_tensor::CpuAccess::ReadWrite,
11611        )
11612        .unwrap();
11613        {
11614            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
11615            let data = map.as_mut_slice();
11616            for chunk in data.as_chunks_mut::<4>().0 {
11617                chunk[0] = 128;
11618                chunk[1] = 64;
11619                chunk[2] = 200;
11620                chunk[3] = 255;
11621            }
11622        }
11623
11624        // F16 PlanarRgb destination in Mem (GL would use PBO, but we want
11625        // to exercise the fallback chain without hardware dependency).
11626        let mut dst = TensorDyn::image(
11627            W,
11628            H,
11629            PixelFormat::PlanarRgb,
11630            DType::F16,
11631            Some(TensorMemory::Mem),
11632            edgefirst_tensor::CpuAccess::ReadWrite,
11633        )
11634        .unwrap();
11635
11636        // Use the default auto-chain so the GL path can attempt and reject,
11637        // then the CPU path succeeds.
11638        let mut proc = ImageProcessor::new().unwrap();
11639        let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
11640        assert!(
11641            result.is_ok(),
11642            "auto-chain PlanarRgb F16 W%4!=0 must not error (CPU fallback): {:?}",
11643            result.err()
11644        );
11645
11646        // All output values must be finite and in [0, 1].
11647        let map = dst.as_typed::<half::f16>().unwrap().map().unwrap();
11648        let halfs = map.as_slice();
11649        assert_eq!(halfs.len(), W * H * 3, "unexpected element count");
11650        for (i, h) in halfs.iter().enumerate() {
11651            let v = h.to_f32();
11652            assert!(
11653                v.is_finite() && (0.0..=1.0).contains(&v),
11654                "output[{i}]={v} is not finite or not in [0,1]"
11655            );
11656        }
11657    }
11658
11659    // =========================================================================
11660    // GAP-4: NV12 → Rgb F32 and NV12 → PlanarRgb F16, forced CPU
11661    // =========================================================================
11662
11663    /// CPU widen-composition: NV12 (non-RGBA source) → Rgb F32.
11664    ///
11665    /// NV12 requires a two-stage CPU conversion (NV12→Rgba then Rgba→F32)
11666    /// which was untested. A wrong intermediate format selection silently
11667    /// produces garbage. Y=128, U=V=128 → near-neutral grey → R≈G≈B≈0.5.
11668    #[test]
11669    // Linux-only `egl_display` field makes `..Default::default()` needless
11670    // on macOS only; see `convert_f16_forced_cpu_correct`.
11671    #[allow(clippy::needless_update)]
11672    fn convert_nv12_to_rgb_f32_cpu() {
11673        const W: usize = 16;
11674        const H: usize = 16; // must be even for NV12
11675
11676        // Build a valid NV12 tensor: shape [H*3/2, W], luma=128, chroma=128.
11677        let src = TensorDyn::image(
11678            W,
11679            H,
11680            PixelFormat::Nv12,
11681            DType::U8,
11682            Some(TensorMemory::Mem),
11683            edgefirst_tensor::CpuAccess::ReadWrite,
11684        )
11685        .unwrap();
11686        {
11687            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
11688            map.as_mut_slice().fill(128); // Y=128, U=V=128 → neutral grey
11689        }
11690
11691        let mut dst = TensorDyn::image(
11692            W,
11693            H,
11694            PixelFormat::Rgb,
11695            DType::F32,
11696            Some(TensorMemory::Mem),
11697            edgefirst_tensor::CpuAccess::ReadWrite,
11698        )
11699        .unwrap();
11700
11701        let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
11702            backend: ComputeBackend::Cpu,
11703            ..Default::default()
11704        })
11705        .unwrap();
11706
11707        let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
11708        assert!(
11709            result.is_ok(),
11710            "forced-CPU NV12→Rgb F32 must not error: {:?}",
11711            result.err()
11712        );
11713
11714        let map = dst.as_typed::<f32>().unwrap().map().unwrap();
11715        let floats = map.as_slice();
11716        assert_eq!(floats.len(), W * H * 3, "unexpected element count");
11717        for (i, &v) in floats.iter().enumerate() {
11718            assert!(
11719                v.is_finite() && (0.0..=1.0).contains(&v),
11720                "output[{i}]={v} is not finite or not in [0,1]"
11721            );
11722        }
11723        // Anti-all-zero: Y=128 → luma channel ≈ 0.5 after YUV→RGB.
11724        let non_zero = floats.iter().any(|&v| v > 0.01);
11725        assert!(non_zero, "all-zero output from NV12→Rgb F32 CPU path");
11726    }
11727
11728    /// CPU widen-composition: NV12 (non-RGBA source) → PlanarRgb F16.
11729    ///
11730    /// Same rationale as `convert_nv12_to_rgb_f32_cpu` but for F16 output.
11731    #[test]
11732    // Linux-only `egl_display` field makes `..Default::default()` needless
11733    // on macOS only; see `convert_f16_forced_cpu_correct`.
11734    #[allow(clippy::needless_update)]
11735    fn convert_nv12_to_planar_rgb_f16_cpu() {
11736        const W: usize = 16;
11737        const H: usize = 16;
11738
11739        let src = TensorDyn::image(
11740            W,
11741            H,
11742            PixelFormat::Nv12,
11743            DType::U8,
11744            Some(TensorMemory::Mem),
11745            edgefirst_tensor::CpuAccess::ReadWrite,
11746        )
11747        .unwrap();
11748        {
11749            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
11750            map.as_mut_slice().fill(128);
11751        }
11752
11753        let mut dst = TensorDyn::image(
11754            W,
11755            H,
11756            PixelFormat::PlanarRgb,
11757            DType::F16,
11758            Some(TensorMemory::Mem),
11759            edgefirst_tensor::CpuAccess::ReadWrite,
11760        )
11761        .unwrap();
11762
11763        let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
11764            backend: ComputeBackend::Cpu,
11765            ..Default::default()
11766        })
11767        .unwrap();
11768
11769        let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
11770        assert!(
11771            result.is_ok(),
11772            "forced-CPU NV12→PlanarRgb F16 must not error: {:?}",
11773            result.err()
11774        );
11775
11776        let map = dst.as_typed::<half::f16>().unwrap().map().unwrap();
11777        let halfs = map.as_slice();
11778        assert_eq!(halfs.len(), W * H * 3, "unexpected element count");
11779        for (i, h) in halfs.iter().enumerate() {
11780            let v = h.to_f32();
11781            assert!(
11782                v.is_finite() && (0.0..=1.0).contains(&v),
11783                "output[{i}]={v} is not finite or not in [0,1]"
11784            );
11785        }
11786        let non_zero = halfs.iter().any(|h| h.to_f32() > 0.01);
11787        assert!(non_zero, "all-zero output from NV12→PlanarRgb F16 CPU path");
11788    }
11789
11790    // =========================================================================
11791    // GAP-5: create_image F32 + DMA must return NotSupported
11792    // =========================================================================
11793
11794    /// `create_image_desc` without a compression request is exactly
11795    /// `create_image` (the processor's memory negotiation applies); with
11796    /// `Compression::Any` off-Android it resolves linear and counts the
11797    /// fallback in the processor-visible mirror.
11798    #[test]
11799    fn create_image_desc_negotiates_and_counts_fallbacks() {
11800        use edgefirst_tensor::{CpuAccess, ImageDesc};
11801        let proc = ImageProcessor::new().unwrap();
11802
11803        let desc =
11804            ImageDesc::new(64, 64, PixelFormat::Rgba, DType::U8).with_access(CpuAccess::ReadWrite);
11805        let plain = proc.create_image_desc(&desc).unwrap();
11806        let classic = proc
11807            .create_image(
11808                64,
11809                64,
11810                PixelFormat::Rgba,
11811                DType::U8,
11812                None,
11813                CpuAccess::ReadWrite,
11814            )
11815            .unwrap();
11816        assert_eq!(plain.memory(), classic.memory(), "same negotiation path");
11817        assert_eq!(plain.compression(), None);
11818
11819        #[cfg(not(target_os = "android"))]
11820        {
11821            use edgefirst_tensor::Compression;
11822            let before = proc.compression_fallback_count();
11823            let desc = ImageDesc::new(64, 64, PixelFormat::Rgba, DType::U8)
11824                .with_compression(Compression::Any);
11825            let t = proc.create_image_desc(&desc).unwrap();
11826            assert_eq!(t.compression(), None, "no vendor tile scheme off-Android");
11827            assert!(
11828                proc.compression_fallback_count() > before,
11829                "Any resolving linear must count"
11830            );
11831        }
11832    }
11833
11834    /// There is no DRM FourCC for F32 images, so `create_image` with
11835    /// `TensorMemory::DmaBuf` and `DType::F32` must return `Err(NotSupported)`.
11836    #[test]
11837    #[cfg(target_os = "linux")]
11838    fn create_image_f32_dma_rejected() {
11839        let proc = ImageProcessor::new().unwrap();
11840        let result = proc.create_image(
11841            64,
11842            64,
11843            PixelFormat::Rgb,
11844            DType::F32,
11845            Some(TensorMemory::DmaBuf),
11846            edgefirst_tensor::CpuAccess::ReadWrite,
11847        );
11848        assert!(
11849            result.is_err(),
11850            "create_image(F32, Dma) must fail — no DRM fourcc for f32"
11851        );
11852    }
11853
11854    /// Verify that `import_image` stores the supplied `Option<Colorimetry>` on
11855    /// the returned `TensorDyn`.
11856    ///
11857    /// `import_image` requires a DMA-backed fd on Linux.  When DMA is
11858    /// unavailable we skip the DMA call but still verify the storage contract
11859    /// by inspecting `set_colorimetry` / `colorimetry` on a plain `TensorDyn`
11860    /// constructed the same way the function body does it — and we confirm via
11861    /// code-read that the new parameter is unconditionally stored.
11862    #[test]
11863    #[cfg(target_os = "linux")]
11864    fn import_image_carries_colorimetry() {
11865        use edgefirst_tensor::{ColorEncoding, ColorRange, Colorimetry, TensorMemory};
11866
11867        let expected = Colorimetry::default()
11868            .with_encoding(ColorEncoding::Bt709)
11869            .with_range(ColorRange::Limited);
11870
11871        if !is_dma_available() {
11872            // DMA unavailable on this host: exercise the storage path via
11873            // TensorDyn directly (mirrors what import_image does internally).
11874            let mut t = TensorDyn::image(
11875                8,
11876                8,
11877                PixelFormat::Rgba,
11878                DType::U8,
11879                Some(TensorMemory::Mem),
11880                edgefirst_tensor::CpuAccess::ReadWrite,
11881            )
11882            .expect("alloc");
11883            assert_eq!(t.colorimetry(), None, "colorimetry must start as None");
11884            t.set_colorimetry(Some(expected));
11885            assert_eq!(
11886                t.colorimetry(),
11887                Some(expected),
11888                "set_colorimetry must round-trip"
11889            );
11890            eprintln!("SKIPPED import_image_carries_colorimetry (DMA unavailable); storage contract verified via TensorDyn");
11891            return;
11892        }
11893
11894        // DMA is available: allocate a real DMA tensor, extract its fd, and
11895        // call import_image with an explicit Colorimetry.
11896        use edgefirst_tensor::{PlaneDescriptor, Tensor};
11897
11898        let rgba_bytes = 64 * 64 * 4; // 64×64 RGBA8
11899        let dma_tensor = Tensor::<u8>::new(
11900            &[rgba_bytes],
11901            Some(TensorMemory::DmaBuf),
11902            Some("import_test"),
11903        )
11904        .expect("dma alloc");
11905        let pd =
11906            PlaneDescriptor::new(dma_tensor.dmabuf().expect("dma fd")).expect("PlaneDescriptor");
11907
11908        let proc = ImageProcessor::new().expect("ImageProcessor");
11909        let result = proc.import_image(
11910            pd,
11911            None,
11912            64,
11913            64,
11914            PixelFormat::Rgba,
11915            DType::U8,
11916            Some(expected),
11917        );
11918        let tensor = result.expect("import_image must succeed on DMA fd");
11919        assert_eq!(
11920            tensor.colorimetry(),
11921            Some(expected),
11922            "import_image must store the supplied colorimetry on the returned TensorDyn"
11923        );
11924    }
11925}