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), edgefirst_tensor::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/// Handle returned by `convert_with_fence`: a sync_file fd on Unix, an
343/// event handle on Windows that is set when the GPU work completes.
344#[cfg(all(
345    any(
346        target_os = "linux",
347        target_os = "macos",
348        target_os = "ios",
349        target_os = "android",
350        target_os = "windows"
351    ),
352    feature = "opengl"
353))]
354pub use opengl_headless::CompletionFence;
355#[cfg(all(
356    any(
357        target_os = "linux",
358        target_os = "macos",
359        target_os = "ios",
360        target_os = "android",
361        target_os = "windows"
362    ),
363    feature = "opengl"
364))]
365pub use opengl_headless::EglDisplayKind;
366#[cfg(all(
367    any(
368        target_os = "linux",
369        target_os = "macos",
370        target_os = "ios",
371        target_os = "android",
372        target_os = "windows"
373    ),
374    feature = "opengl"
375))]
376pub use opengl_headless::GLProcessorThreaded;
377#[cfg(all(
378    any(
379        target_os = "linux",
380        target_os = "macos",
381        target_os = "ios",
382        target_os = "android",
383        target_os = "windows"
384    ),
385    feature = "opengl"
386))]
387pub use opengl_headless::Int8InterpolationMode;
388#[cfg(target_os = "linux")]
389#[cfg(feature = "opengl")]
390pub use opengl_headless::{probe_egl_displays, EglDisplayInfo};
391// EGLImage cache counter snapshots (diagnostics): see
392// `GLProcessorThreaded::egl_cache_stats` and the steady-state import gate in
393// `crates/image/ARCHITECTURE.md § image.convert.gl.egl_import`.
394#[cfg(all(
395    any(
396        target_os = "linux",
397        target_os = "macos",
398        target_os = "ios",
399        target_os = "android",
400        target_os = "windows"
401    ),
402    feature = "opengl"
403))]
404pub use opengl_headless::{CacheStats, ConvertStats, GlCacheStats};
405use std::{fmt::Display, time::Instant};
406
407mod colorimetry;
408mod cpu;
409mod error;
410mod g2d;
411#[path = "gl/mod.rs"]
412mod opengl_headless;
413#[cfg(test)]
414mod test_support;
415mod tiling;
416pub use tiling::{tile_grid, TilePlacement, TileSpec, TilingConfig};
417
418// Use `edgefirst_tensor::PixelFormat` variants (Rgb, Rgba, Grey, etc.) and
419// `TensorDyn` / `Tensor<u8>` with `.format()` metadata instead.
420
421#[derive(Debug, Clone, Copy, PartialEq, Eq)]
422pub enum Rotation {
423    None = 0,
424    Clockwise90 = 1,
425    Rotate180 = 2,
426    CounterClockwise90 = 3,
427}
428impl Rotation {
429    /// Creates a Rotation enum from an angle in degrees. The angle must be a
430    /// multiple of 90.
431    ///
432    /// # Panics
433    /// Panics if the angle is not a multiple of 90.
434    ///
435    /// # Examples
436    /// ```rust
437    /// # use edgefirst_image::Rotation;
438    /// let rotation = Rotation::from_degrees_clockwise(270);
439    /// assert_eq!(rotation, Rotation::CounterClockwise90);
440    /// ```
441    pub fn from_degrees_clockwise(angle: usize) -> Rotation {
442        match angle.rem_euclid(360) {
443            0 => Rotation::None,
444            90 => Rotation::Clockwise90,
445            180 => Rotation::Rotate180,
446            270 => Rotation::CounterClockwise90,
447            _ => panic!("rotation angle is not a multiple of 90"),
448        }
449    }
450}
451
452#[derive(Debug, Clone, Copy, PartialEq, Eq)]
453pub enum Flip {
454    None = 0,
455    Vertical = 1,
456    Horizontal = 2,
457}
458
459/// Controls how the color palette index is chosen for each detected object.
460#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
461pub enum ColorMode {
462    /// Color is chosen by object class label (`det.label`). Default.
463    ///
464    /// Preserves backward compatibility and is correct for semantic
465    /// segmentation where colors carry class meaning.
466    #[default]
467    Class,
468    /// Color is chosen by instance order (loop index, zero-based).
469    ///
470    /// Each detected object gets a unique color regardless of class,
471    /// useful for instance segmentation.
472    Instance,
473    /// Color is chosen by track ID (future use; currently behaves like
474    /// [`Instance`](Self::Instance)).
475    Track,
476}
477
478impl ColorMode {
479    /// Return the palette index for a detection given its loop index and label.
480    #[inline]
481    pub fn index(self, idx: usize, label: usize) -> usize {
482        match self {
483            ColorMode::Class => label,
484            ColorMode::Instance | ColorMode::Track => idx,
485        }
486    }
487}
488
489/// Controls the resolution and coordinate frame of masks produced by
490/// [`ImageProcessor::materialize_masks`].
491///
492/// - [`Proto`](Self::Proto) returns per-detection tiles at proto-plane
493///   resolution (e.g. 48×32 u8 for a typical COCO bbox on a 160×160 proto
494///   plane). This is the historical behavior of `materialize_masks` and the
495///   fastest path because no upsample runs inside HAL. Mask values are
496///   continuous sigmoid output quantized to `uint8 [0, 255]`.
497/// - [`Scaled`](Self::Scaled) returns per-detection tiles at caller-specified
498///   pixel resolution by upsampling the full proto plane once and cropping by
499///   bbox after sigmoid. The upsample uses bilinear interpolation with
500///   edge-clamp sampling — semantically equivalent to Ultralytics'
501///   `process_masks_retina` reference. When a `letterbox` is also passed to
502///   [`materialize_masks`], the inverse letterbox transform is applied during
503///   the upsample so mask pixels land in original-content coordinates
504///   (drop-in for overlay on the original image). Mask values are binary
505///   `uint8 {0, 255}` after thresholding sigmoid > 0.5 — interchangeable
506///   with `Proto` output via the same `> 127` test.
507///
508/// [`materialize_masks`]: ImageProcessor::materialize_masks
509#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
510pub enum MaskResolution {
511    /// Per-detection tile at proto-plane resolution (default).
512    #[default]
513    Proto,
514    /// Per-detection tile at `(width, height)` pixel resolution in the
515    /// coordinate frame determined by the `letterbox` parameter of
516    /// [`ImageProcessor::materialize_masks`].
517    Scaled {
518        /// Target pixel width of the output coordinate frame.
519        width: u32,
520        /// Target pixel height of the output coordinate frame.
521        height: u32,
522    },
523}
524
525/// Options for mask overlay rendering.
526///
527/// Controls how segmentation masks are composited onto the destination image:
528/// - `background`: when set, the background image is drawn first and masks
529///   are composited over it (result written to `dst`). When `None`, `dst` is
530///   cleared to `0x00000000` (fully transparent) before masks are drawn.
531///   **`dst` is always fully overwritten — its prior contents are never
532///   preserved.** Callers who used to pre-load an image into `dst` before
533///   calling `draw_decoded_masks` / `draw_proto_masks` must now supply that
534///   image via `background` instead (behaviour changed in v0.16.4).
535/// - `opacity`: scales the alpha of rendered mask colors. `1.0` (default)
536///   preserves the class color's alpha unchanged; `0.5` makes masks
537///   semi-transparent.
538/// - `color_mode`: controls whether colors are assigned by class label,
539///   instance index, or track ID. Defaults to [`ColorMode::Class`].
540#[derive(Debug, Clone, Copy)]
541pub struct MaskOverlay<'a> {
542    /// Compositing source image. Must have the same dimensions and pixel
543    /// format as `dst`. When `Some`, the output is `background + masks`.
544    /// When `None`, `dst` is cleared to `0x00000000` before masks are drawn.
545    pub background: Option<&'a TensorDyn>,
546    pub opacity: f32,
547    /// Normalized letterbox region `[xmin, ymin, xmax, ymax]` in model-input
548    /// space that contains actual image content (the rest is padding).
549    ///
550    /// When set, bounding boxes and mask coordinates from the decoder (which
551    /// are in model-input normalized space) are mapped back to the original
552    /// image coordinate space before rendering.
553    ///
554    /// Use [`with_letterbox_crop`](Self::with_letterbox_crop) to compute this
555    /// from the [`Crop`] that was used in the model input [`convert`](crate::ImageProcessorTrait::convert) call.
556    pub letterbox: Option<[f32; 4]>,
557    pub color_mode: ColorMode,
558}
559
560impl Default for MaskOverlay<'_> {
561    fn default() -> Self {
562        Self {
563            background: None,
564            opacity: 1.0,
565            letterbox: None,
566            color_mode: ColorMode::Class,
567        }
568    }
569}
570
571impl<'a> MaskOverlay<'a> {
572    pub fn new() -> Self {
573        Self::default()
574    }
575
576    /// Set the compositing source image.
577    ///
578    /// `bg` must have the same dimensions and pixel format as the `dst` passed
579    /// to [`draw_decoded_masks`](crate::ImageProcessorTrait::draw_decoded_masks) /
580    /// [`draw_proto_masks`](crate::ImageProcessorTrait::draw_proto_masks).
581    /// The output will be `bg + masks`. Without a background, `dst` is cleared
582    /// to `0x00000000`.
583    pub fn with_background(mut self, bg: &'a TensorDyn) -> Self {
584        self.background = Some(bg);
585        self
586    }
587
588    pub fn with_opacity(mut self, opacity: f32) -> Self {
589        self.opacity = opacity.clamp(0.0, 1.0);
590        self
591    }
592
593    pub fn with_color_mode(mut self, mode: ColorMode) -> Self {
594        self.color_mode = mode;
595        self
596    }
597
598    /// Set the letterbox transform from the [`Crop`] used when preparing the
599    /// model input, so that bounding boxes and masks are correctly mapped back
600    /// to the original image coordinate space during rendering.
601    ///
602    /// Pass the same `crop` that was given to
603    /// [`convert`](crate::ImageProcessorTrait::convert) along with the model
604    /// input dimensions (`model_w` × `model_h`).
605    ///
606    /// Has no effect when `crop.dst_rect` is `None` (no letterbox applied).
607    pub fn with_letterbox_crop(
608        mut self,
609        crop: &Crop,
610        src_w: usize,
611        src_h: usize,
612        model_w: usize,
613        model_h: usize,
614    ) -> Self {
615        // The letterbox placement is resolved from the same source/destination
616        // dimensions `convert()` used, so the inverse map matches the render.
617        if let Ok(resolved) = crop.resolve(src_w, src_h, model_w, model_h) {
618            if let Some(r) = resolved.dst_rect {
619                self.letterbox = Some([
620                    r.left as f32 / model_w as f32,
621                    r.top as f32 / model_h as f32,
622                    (r.left + r.width) as f32 / model_w as f32,
623                    (r.top + r.height) as f32 / model_h as f32,
624                ]);
625            }
626        }
627        self
628    }
629}
630
631/// Copy a [`Segmentation`], replacing its bounds with `bbox`.
632///
633/// `TensorDyn` is deliberately not `Clone` (it may own an fd), so the mask
634/// bytes are copied explicitly. This is only reached on the letterbox path
635/// where the bounds must be rewritten to match an un-letterboxed detect box.
636fn copy_segmentation(s: &Segmentation, bbox: [f32; 4]) -> Result<Segmentation> {
637    use edgefirst_tensor::TensorMapTrait as _;
638    let src = s
639        .segmentation
640        .as_typed::<u8>()
641        .ok_or_else(|| Error::InvalidShape("segmentation must be a U8 tensor".into()))?;
642    let map = src.map_read()?;
643    // `Some(Mem)`, NOT `None`. `None` means auto-select, which prefers DMA on
644    // Linux and IOSurface on macOS -- so this would take a dma_heap ioctl and
645    // hold an fd PER DETECTION PER FRAME for a mask of a few hundred bytes,
646    // and turn every downstream map_read() in the render loop into a
647    // DMA_BUF_IOCTL_SYNC instead of a no-op. A mask copy is CPU-only scratch.
648    // `mask_to_tensor` reaches Mem the same way, via from_arrayview3.
649    let dst = edgefirst_tensor::Tensor::<u8>::new(
650        s.segmentation.shape(),
651        Some(edgefirst_tensor::TensorMemory::Mem),
652        None,
653    )
654    .map_err(|e| Error::InvalidShape(format!("segmentation copy: {e}")))?;
655    {
656        let mut dmap = dst.map_mut()?;
657        dmap.as_mut_slice().copy_from_slice(map.as_slice());
658    }
659    Ok(Segmentation {
660        xmin: bbox[0],
661        ymin: bbox[1],
662        xmax: bbox[2],
663        ymax: bbox[3],
664        segmentation: dst.into(),
665    })
666}
667
668/// Apply the inverse letterbox transform to a bounding box.
669///
670/// `lb` is `[lx0, ly0, lx1, ly1]` — the normalized region of the model input
671/// that contains actual image content (output of
672/// [`MaskOverlay::with_letterbox_crop`]). Converts model-input-normalized
673/// coords to output-image-normalized coords, clamped to `[0.0, 1.0]`, and
674/// canonicalises the bbox (ensures xmin <= xmax).
675///
676/// Thin wrapper over [`edgefirst_tensor::unletter_norm`]: the single home for
677/// the inverse-letterbox math sits beside the type it transforms, so this mask
678/// path and `edgefirst_decoder::tiling`'s detection lift share one
679/// implementation.
680#[inline]
681fn unletter_bbox(bbox: DetectBox, lb: [f32; 4]) -> DetectBox {
682    DetectBox {
683        bbox: edgefirst_tensor::unletter_norm(bbox.bbox, lb),
684        ..bbox
685    }
686}
687
688/// How a source is fit into the requested destination shape.
689#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
690pub enum Fit {
691    /// Stretch the source (or source crop) to fill the whole destination.
692    #[default]
693    Stretch,
694    /// Preserve the *source* aspect ratio, centring it in the destination and
695    /// padding the remainder with `pad` (RGBA — e.g. `[114, 114, 114, 255]` for
696    /// YOLO-style preprocessing).
697    Letterbox { pad: [u8; 4] },
698}
699
700/// Source-side convert geometry: which sub-rectangle of the source to sample
701/// (`source`) and how to fit it into the destination (`fit`). Destination
702/// *placement* is the destination itself — a tensor, or a [`Region`] view /
703/// `batch` tile of one — not a field here.
704#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
705pub struct Crop {
706    /// Sub-rectangle of the source to sample. `None` samples the whole source.
707    pub source: Option<Region>,
708    /// How the source is fit into the destination shape.
709    pub fit: Fit,
710}
711
712impl Crop {
713    /// A no-op crop: whole source, stretched to fill the whole destination.
714    pub fn new() -> Self {
715        Self::default()
716    }
717
718    /// Alias for [`Crop::new`] — whole source, stretch to fill.
719    pub fn no_crop() -> Self {
720        Self::default()
721    }
722
723    /// Letterbox fit: preserve the source aspect ratio, padding the remainder
724    /// with `pad` (RGBA).
725    pub fn letterbox(pad: [u8; 4]) -> Self {
726        Self {
727            source: None,
728            fit: Fit::Letterbox { pad },
729        }
730    }
731
732    /// Sample only `source` of the input (builder).
733    pub fn with_source(mut self, source: Option<Region>) -> Self {
734        self.source = source;
735        self
736    }
737
738    /// Set the fit mode (builder).
739    pub fn with_fit(mut self, fit: Fit) -> Self {
740        self.fit = fit;
741        self
742    }
743
744    /// Resolve to the effective backend geometry for a `src_w`×`src_h` source
745    /// and `dst_w`×`dst_h` destination: the source sampling rect, the
746    /// destination placement rect (`None` = whole destination), and the pad
747    /// colour. **The single place letterbox placement is computed** — every
748    /// backend consumes the resolved rects rather than re-deriving them.
749    pub(crate) fn resolve(
750        &self,
751        src_w: usize,
752        src_h: usize,
753        dst_w: usize,
754        dst_h: usize,
755    ) -> Result<ResolvedCrop, Error> {
756        let src_rect = self.source.map(region_to_rect);
757        // The letterbox aspect uses the *effective* source content — the source
758        // crop when set, else the full source.
759        let (sw, sh) = match self.source {
760            Some(r) => (r.width, r.height),
761            None => (src_w, src_h),
762        };
763        let resolved = match self.fit {
764            Fit::Stretch => ResolvedCrop {
765                src_rect,
766                dst_rect: None,
767                dst_color: None,
768            },
769            Fit::Letterbox { pad } => ResolvedCrop {
770                src_rect,
771                dst_rect: Some(letterbox_rect(sw, sh, dst_w, dst_h)),
772                dst_color: Some(pad),
773            },
774        };
775        resolved.check_crop_dims(src_w, src_h, dst_w, dst_h)?;
776        Ok(resolved)
777    }
778
779    /// Validate against `TensorDyn` source and destination dimensions.
780    pub fn check_crop_dyn(
781        &self,
782        src: &edgefirst_tensor::TensorDyn,
783        dst: &edgefirst_tensor::TensorDyn,
784    ) -> Result<(), Error> {
785        self.resolve(
786            src.width().unwrap_or(0),
787            src.height().unwrap_or(0),
788            dst.width().unwrap_or(0),
789            dst.height().unwrap_or(0),
790        )
791        .map(|_| ())
792    }
793}
794
795/// Resolved crop geometry consumed by the backends. Produced by
796/// [`Crop::resolve`]; the backends read these fields directly (the same shape
797/// the public `Crop` carried before destination placement moved to the view).
798#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
799pub(crate) struct ResolvedCrop {
800    pub(crate) src_rect: Option<Rect>,
801    pub(crate) dst_rect: Option<Rect>,
802    pub(crate) dst_color: Option<[u8; 4]>,
803}
804
805impl ResolvedCrop {
806    /// A no-op resolved crop (whole source → whole destination, no pad).
807    #[allow(dead_code)] // used by unit tests and the batch render paths
808    pub(crate) fn no_crop() -> Self {
809        Self::default()
810    }
811
812    /// Whether a convert from a `src`-sized source to a `dst`-sized
813    /// destination with this crop changes the size of the sampled region. A
814    /// quarter-turn rotation swaps the source region's axes.
815    #[allow(dead_code)] // unused when no resampling backend is compiled in
816    pub(crate) fn resizes(
817        &self,
818        (src_w, src_h): (usize, usize),
819        (dst_w, dst_h): (usize, usize),
820        rotation: Rotation,
821    ) -> bool {
822        let (sw, sh) = self
823            .src_rect
824            .map_or((src_w, src_h), |r| (r.width, r.height));
825        let (dw, dh) = self
826            .dst_rect
827            .map_or((dst_w, dst_h), |r| (r.width, r.height));
828        let (sw, sh) = match rotation {
829            Rotation::Clockwise90 | Rotation::CounterClockwise90 => (sh, sw),
830            Rotation::None | Rotation::Rotate180 => (sw, sh),
831        };
832        (sw, sh) != (dw, dh)
833    }
834
835    /// Validate the resolved rects against explicit dimensions.
836    pub(crate) fn check_crop_dims(
837        &self,
838        src_w: usize,
839        src_h: usize,
840        dst_w: usize,
841        dst_h: usize,
842    ) -> Result<(), Error> {
843        let src_ok = self
844            .src_rect
845            .is_none_or(|r| r.left + r.width <= src_w && r.top + r.height <= src_h);
846        let dst_ok = self
847            .dst_rect
848            .is_none_or(|r| r.left + r.width <= dst_w && r.top + r.height <= dst_h);
849        match (src_ok, dst_ok) {
850            (true, true) => Ok(()),
851            (true, false) => Err(Error::CropInvalid(format!(
852                "Dest crop invalid: {:?}",
853                self.dst_rect
854            ))),
855            (false, true) => Err(Error::CropInvalid(format!(
856                "Src crop invalid: {:?}",
857                self.src_rect
858            ))),
859            (false, false) => Err(Error::CropInvalid(format!(
860                "Dest and Src crop invalid: {:?} {:?}",
861                self.dst_rect, self.src_rect
862            ))),
863        }
864    }
865}
866
867/// Convert a pixel [`Region`] to the internal [`Rect`] placement type.
868fn region_to_rect(r: Region) -> Rect {
869    Rect {
870        left: r.x,
871        top: r.y,
872        width: r.width,
873        height: r.height,
874    }
875}
876
877/// Centred aspect-preserving placement of an `sw`×`sh` source within a `dw`×`dh`
878/// destination — the canonical letterbox rectangle (one home, replacing the
879/// per-caller `calculate_letterbox` copies).
880fn letterbox_rect(sw: usize, sh: usize, dw: usize, dh: usize) -> Rect {
881    if sw == 0 || sh == 0 {
882        return Rect::new(0, 0, dw, dh);
883    }
884    let src_aspect = sw as f64 / sh as f64;
885    let dst_aspect = dw as f64 / dh as f64;
886    let (new_w, new_h) = if src_aspect > dst_aspect {
887        (dw, ((dw as f64 / src_aspect).round() as usize).max(1))
888    } else {
889        (((dh as f64 * src_aspect).round() as usize).max(1), dh)
890    };
891    let left = dw.saturating_sub(new_w) / 2;
892    let top = dh.saturating_sub(new_h) / 2;
893    Rect::new(left, top, new_w, new_h)
894}
895
896/// Internal placement rectangle (top-left + size). The **public** pixel
897/// sub-region type is [`Region`] (re-exported from `edgefirst-tensor`);
898/// `Rect` is the crate-private resolved-placement representation used by the
899/// CPU/G2D/GL backends after [`Crop::resolve`].
900#[derive(Debug, Clone, Copy, PartialEq, Eq)]
901pub(crate) struct Rect {
902    pub left: usize,
903    pub top: usize,
904    pub width: usize,
905    pub height: usize,
906}
907
908impl Rect {
909    // Creates a new Rect with the specified left, top, width, and height.
910    pub fn new(left: usize, top: usize, width: usize, height: usize) -> Self {
911        Self {
912            left,
913            top,
914            width,
915            height,
916        }
917    }
918}
919
920#[enum_dispatch(ImageProcessor)]
921pub trait ImageProcessorTrait {
922    /// Convert `src` into `dst`'s format and size, applying the crop first,
923    /// then the flip, then the rotation.
924    ///
925    /// The destination is normally an RGB-family colour — `Grey`, `Rgb`,
926    /// `Rgba`/`Bgra`, packed `HWC` or planar `CHW`. That is what the
927    /// accelerated paths target and what models expect. The CPU backend also
928    /// accepts a handful of YUV destinations (`Yuyv`→`Yuyv`, `Yuyv`→`Nv16`,
929    /// and the `Vyuy` equivalents); no GPU or G2D path does, so those pairs
930    /// always land on the CPU.
931    ///
932    /// # Arguments
933    ///
934    /// * `src` - The source image to convert from.
935    /// * `dst` - The destination image. Its shape and [`PixelFormat`] define
936    ///   the output; pass a [`view`](edgefirst_tensor::TensorDyn::view) or
937    ///   [`batch`](edgefirst_tensor::TensorDyn::batch) of a larger tensor to
938    ///   render into one tile of a batch.
939    /// * `rotation` - Rotation applied to the output, in 90° steps.
940    /// * `flip` - Horizontal, vertical, or both.
941    /// * `crop` - Source-side geometry: which sub-rectangle of `src` to sample
942    ///   ([`Crop::source`]) and how to fit it into `dst`'s shape
943    ///   ([`Crop::fit`] — stretch, or letterbox with a pad colour). Destination
944    ///   placement lives on `dst`, not here.
945    ///
946    /// # Errors
947    ///
948    /// - [`Error::CropInvalid`] if `crop.source` falls outside `src`.
949    /// - [`Error::NotAnImage`] if either tensor carries no `PixelFormat`.
950    /// - [`Error::NoConverter`] if no backend accepts the format pair — the
951    ///   dispatch tries OpenGL, then G2D, then CPU, so this means all three
952    ///   declined.
953    ///
954    /// Backend-specific failures (EGL import, G2D blit, allocation) surface as
955    /// their own variants. A backend *declining* a pair is not an error while
956    /// another remains in the chain; it silently costs the zero-copy path, and
957    /// [`ImageProcessor::convert_fallback_count`] counts those.
958    fn convert(
959        &mut self,
960        src: &TensorDyn,
961        dst: &mut TensorDyn,
962        rotation: Rotation,
963        flip: Flip,
964        crop: Crop,
965    ) -> Result<()>;
966
967    /// Draw pre-decoded detection boxes and segmentation masks onto `dst`.
968    ///
969    /// Supports two segmentation modes based on the mask channel count:
970    /// - **Instance segmentation** (`C=1`): one `Segmentation` per detection,
971    ///   `segmentation` and `detect` are zipped.
972    /// - **Semantic segmentation** (`C>1`): a single `Segmentation` covering
973    ///   all classes; only the first element is used.
974    ///
975    /// # Format requirements
976    ///
977    /// - CPU backend: `dst` must be `RGBA` or `RGB`.
978    /// - OpenGL backend: `dst` must be `RGBA`, `BGRA`, or `RGB`.
979    /// - G2D backend: only produces the base frame (empty detections);
980    ///   returns `NotImplemented` when any detection or segmentation is
981    ///   supplied.
982    ///
983    /// # Output contract
984    ///
985    /// This function always fully writes `dst` — it never relies on the
986    /// caller having pre-cleared the destination. The four cases are:
987    ///
988    /// | detections | background | output                              |
989    /// |------------|------------|-------------------------------------|
990    /// | none       | none       | dst cleared to `0x00000000`         |
991    /// | none       | set        | dst ← background                    |
992    /// | set        | none       | masks drawn over cleared dst        |
993    /// | set        | set        | masks drawn over background         |
994    ///
995    /// Each backend implements this with its native primitives: G2D uses
996    /// `g2d_clear` / `g2d_blit`, OpenGL uses `glClear` / DMA-BUF GPU blit
997    /// plus the mask program, and CPU uses direct buffer fill / memcpy as
998    /// the terminal fallback. CPU-memcpy of DMA buffers is avoided on the
999    /// accelerated paths.
1000    ///
1001    /// An empty `segmentation` slice is valid — only bounding boxes are drawn.
1002    ///
1003    /// `overlay` controls compositing: `background` is the compositing source
1004    /// (must match `dst` in size and format); `opacity` scales mask alpha.
1005    ///
1006    /// A `Bgra` background onto a zero-copy destination (a D3D11 texture, an
1007    /// IOSurface, a dma-buf) returns [`Error::NotSupported`]: the GL
1008    /// base-layer draw has no `Bgra` arm and the CPU backend renders only
1009    /// `Rgba`/`Rgb`. It previously returned `Ok` with `dst` unwritten. A
1010    /// `Bgra` destination with no background is unaffected.
1011    ///
1012    /// # Buffer aliasing
1013    ///
1014    /// `dst` and `overlay.background` must reference **distinct underlying
1015    /// buffers**. An aliased pair returns [`Error::AliasedBuffers`] without
1016    /// dispatching to any backend — the GL path would otherwise read and
1017    /// write the same texture in a single draw, which is undefined behaviour
1018    /// on most drivers. Aliasing is detected via
1019    /// [`TensorDyn::aliases`](edgefirst_tensor::TensorDyn::aliases), which
1020    /// catches both shared-allocation clones and separate imports over the
1021    /// same dmabuf fd.
1022    ///
1023    /// # Migration from v0.16.3 and earlier
1024    ///
1025    /// Prior to v0.16.4 the call silently preserved `dst`'s contents on empty
1026    /// detections. That invariant no longer holds — `dst` is always fully
1027    /// written. Callers who pre-loaded an image into `dst` before calling this
1028    /// function must now pass that image via `overlay.background` instead.
1029    fn draw_decoded_masks(
1030        &mut self,
1031        dst: &mut TensorDyn,
1032        detect: &[DetectBox],
1033        segmentation: &[Segmentation],
1034        overlay: MaskOverlay<'_>,
1035    ) -> Result<()>;
1036
1037    /// Draw masks from proto data onto image (fused decode+draw).
1038    ///
1039    /// For YOLO segmentation models, this avoids materializing intermediate
1040    /// `Array3<u8>` masks. The `ProtoData` contains mask coefficients and the
1041    /// prototype tensor; the renderer computes `mask_coeff @ protos` directly
1042    /// at the output resolution using bilinear sampling.
1043    ///
1044    /// `detect` and `proto_data.mask_coefficients` must have the same length
1045    /// (enforced by zip — excess entries are silently ignored). An empty
1046    /// `detect` slice is valid and produces the base frame — cleared or
1047    /// background-blitted — via the selected backend's native primitive.
1048    ///
1049    /// # Format requirements and output contract
1050    ///
1051    /// Same as [`draw_decoded_masks`](Self::draw_decoded_masks), including
1052    /// the "always fully writes dst" guarantee across all four
1053    /// detection/background combinations.
1054    ///
1055    /// `overlay` controls compositing — see [`draw_decoded_masks`](Self::draw_decoded_masks).
1056    fn draw_proto_masks(
1057        &mut self,
1058        dst: &mut TensorDyn,
1059        detect: &[DetectBox],
1060        proto_data: &ProtoData,
1061        overlay: MaskOverlay<'_>,
1062    ) -> Result<()>;
1063
1064    /// Sets the colors used for rendering segmentation masks. Up to 20 colors
1065    /// can be set.
1066    ///
1067    /// On [`ImageProcessor`] with no forced backend this reaches every
1068    /// available backend rather than the first that accepts, because a
1069    /// later draw may land on any of them. A forced backend receives the
1070    /// colors alone.
1071    fn set_class_colors(&mut self, colors: &[[u8; 4]]) -> Result<()>;
1072
1073    /// Like [`convert`](Self::convert), but does not wait for the GPU to finish.
1074    ///
1075    /// This is the batch-preprocessing primitive: a caller renders `N` tiles
1076    /// into one batched destination by looping
1077    /// `convert_deferred(&src[n], &mut dst.batch(n)?, …)` and then calling
1078    /// [`flush`](Self::flush) **once**. On the OpenGL backend every deferred
1079    /// convert into sibling views of one buffer shares a single EGLImage import
1080    /// (the tile is a `glViewport`/`glScissor` ROI into the parent) and skips the
1081    /// per-tile `glFinish`; `flush` then issues a single GPU sync. The result of
1082    /// a deferred convert is **not** safe to read on the CPU (or map via CUDA)
1083    /// until `flush` returns.
1084    ///
1085    /// The default implementation is eager — it simply calls
1086    /// [`convert`](Self::convert), so CPU/G2D and any backend without a deferred
1087    /// fast path remain correct (each call completes synchronously and `flush`
1088    /// is a no-op).
1089    fn convert_deferred(
1090        &mut self,
1091        src: &TensorDyn,
1092        dst: &mut TensorDyn,
1093        rotation: Rotation,
1094        flip: Flip,
1095        crop: Crop,
1096    ) -> Result<()> {
1097        self.convert(src, dst, rotation, flip, crop)
1098    }
1099
1100    /// Complete all work enqueued by [`convert_deferred`](Self::convert_deferred)
1101    /// since the last flush, issuing a single GPU synchronization.
1102    ///
1103    /// After this returns, every deferred destination is finished and safe to
1104    /// read back or `cuda_map`. Backends with no deferred path (the default)
1105    /// return `Ok(())` immediately, since their converts already completed.
1106    fn flush(&mut self) -> Result<()> {
1107        Ok(())
1108    }
1109}
1110
1111/// Configuration for [`ImageProcessor`] construction.
1112///
1113/// Use with [`ImageProcessor::with_config`] to override the default EGL
1114/// display auto-detection and backend selection. The default configuration
1115/// preserves the existing auto-detection behaviour.
1116#[derive(Debug, Clone, Default)]
1117pub struct ImageProcessorConfig {
1118    /// Force OpenGL to use this EGL display type instead of auto-detecting.
1119    ///
1120    /// When `None`, the processor probes displays in priority order: GBM,
1121    /// PlatformDevice, Default. Use [`probe_egl_displays`] to discover
1122    /// which displays are available on the current system.
1123    ///
1124    /// Ignored when `EDGEFIRST_DISABLE_GL=1` is set, and on macOS and
1125    /// Windows (ANGLE is the only display there — Metal and Direct3D 11
1126    /// respectively; a `Some` value logs a debug note and is otherwise
1127    /// ignored).
1128    #[cfg(all(
1129        any(
1130            target_os = "linux",
1131            target_os = "macos",
1132            target_os = "ios",
1133            target_os = "android",
1134            target_os = "windows"
1135        ),
1136        feature = "opengl"
1137    ))]
1138    pub egl_display: Option<EglDisplayKind>,
1139
1140    /// Preferred compute backend.
1141    ///
1142    /// When set to a specific backend (not [`ComputeBackend::Auto`]), the
1143    /// processor initializes that backend with no fallback — returns an error if the conversion is not supported.
1144    /// This takes precedence over `EDGEFIRST_FORCE_BACKEND` and the
1145    /// `EDGEFIRST_DISABLE_*` environment variables.
1146    ///
1147    /// - [`ComputeBackend::OpenGl`]: init OpenGL + CPU, skip G2D
1148    /// - [`ComputeBackend::G2d`]: init G2D + CPU, skip OpenGL
1149    /// - [`ComputeBackend::Cpu`]: init CPU only
1150    /// - [`ComputeBackend::Auto`]: existing env-var-driven selection
1151    pub backend: ComputeBackend,
1152
1153    /// Colorimetry/performance trade-off for `convert()` (see
1154    /// [`ColorimetryMode`]). Defaults to [`ColorimetryMode::Fast`]. The
1155    /// `EDGEFIRST_COLORIMETRY` environment variable (`fast` | `exact`)
1156    /// overrides this setting when present.
1157    pub colorimetry: ColorimetryMode,
1158
1159    /// How `convert()` resamples when the source (or source crop) and the
1160    /// destination differ in size (see [`Interpolation`]). Defaults to
1161    /// [`Interpolation::Bilinear`]. The `EDGEFIRST_INTERPOLATION` environment
1162    /// variable (`nearest` | `bilinear`) overrides this setting when present.
1163    pub interpolation: Interpolation,
1164
1165    /// How many buffer imports the OpenGL backend keeps cached, per cache.
1166    ///
1167    /// `None` (the default) uses `EDGEFIRST_EGL_CACHE_CAPACITY` if set, and
1168    /// 16 otherwise. Precedence is:
1169    ///
1170    /// ```text
1171    /// egl_cache_capacity  >  EDGEFIRST_EGL_CACHE_CAPACITY  >  16
1172    /// ```
1173    ///
1174    /// Config beats the environment deliberately, matching
1175    /// [`backend`](Self::backend): an embedder that has measured its own
1176    /// pool must not be silently overridden by a stray environment variable
1177    /// in the deployment. `Some(0)` is rejected and falls through to the
1178    /// environment, then the default, exactly as an unparseable env value
1179    /// does.
1180    ///
1181    /// # This is a buffer *count*, not a size
1182    ///
1183    /// A cached import pins its buffer — the underlying dma-buf or IOSurface
1184    /// cannot be freed while an entry names it (that is what makes the cache
1185    /// correct as well as fast; see the "EGL image cache inside HAL" section
1186    /// of `ARCHITECTURE.md`). So the memory this holds depends entirely on
1187    /// how big the caller's buffers are:
1188    ///
1189    /// ```text
1190    /// worst-case pinned bytes                                (upper bound)
1191    ///     = capacity x 3 caches x frame size x live ImageProcessors
1192    ///
1193    /// capacity 16, 640x640 RGB   (1.2 MB)  ->   59 MB per processor
1194    /// capacity 16, 1080p NV12    (3.1 MB)  ->  149 MB per processor
1195    /// capacity 16, 4K NV12      (12.4 MB)  ->  597 MB per processor
1196    /// ```
1197    ///
1198    /// A ceiling rather than an exact total: the source and NV R8 caches
1199    /// often hold two imports of the *same* buffer, and two entries naming
1200    /// one buffer pin it once. The per-cache figure is the exact one.
1201    ///
1202    /// The bound is **per [`ImageProcessor`], times three caches** (source,
1203    /// destination, and the NV R8 source cache) — not per process and not
1204    /// per library. Two `ImageProcessor`s in one process have six
1205    /// independent caches and six independent capacities. A fourth cache,
1206    /// the chroma-plane views of hardware-filtered NV resizes, has the same
1207    /// capacity but imports only buffers the NV R8 cache also holds, so it
1208    /// adds entries without pinning more memory.
1209    ///
1210    /// Whether the worst case is ever approached is up to the caller. A
1211    /// pooled producer — V4L2 capture, typically 4–8 buffers over one or two
1212    /// sizes — settles at its pool size and never nears the bound. A stream
1213    /// that allocates a fresh buffer per frame holds the last `capacity` of
1214    /// them outright, which is the difference between "16 is free" and "16
1215    /// is 597 MB".
1216    ///
1217    /// Size it from measurement rather than guesswork:
1218    /// [`CacheStats::peak_entries`] reports the working set actually
1219    /// reached, and [`CacheStats::evictions`] tells you when the bound is
1220    /// costing you re-imports (it should be zero in a steady state).
1221    ///
1222    /// Ignored when the OpenGL backend is not in use.
1223    pub egl_cache_capacity: Option<usize>,
1224}
1225
1226/// How `convert()` trades colorimetric exactness against speed on platforms
1227/// where the exact path is expensive.
1228///
1229/// Today this affects one decision: NV12 sources on Vivante GC7000UL
1230/// (i.MX 8M Plus), where the hardware external sampler converts ~12× faster
1231/// than the colorimetry-exact in-shader matrix (2.5 ms vs 29 ms at 720p)
1232/// but applies the driver's fixed BT.601-limited matrix regardless of the
1233/// source's tagged colorimetry. Platforms where the exact path is already
1234/// the fastest correct path (Mali, V3D, Tegra, ANGLE) behave identically in
1235/// both modes.
1236///
1237/// Override at runtime with `EDGEFIRST_COLORIMETRY=fast|exact` (takes
1238/// precedence over the config field), or per-source by forcing a path with
1239/// `EDGEFIRST_NV_CONVERT_PATH`.
1240#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1241pub enum ColorimetryMode {
1242    /// Prefer the fastest path whose output is correct-enough video RGB
1243    /// (default; issue #106 policy). On Vivante, NV12 takes the hardware
1244    /// sampler even when the source is not BT.601-limited.
1245    #[default]
1246    Fast,
1247    /// Prefer bit-exact colorimetry everywhere: the fast path is used only
1248    /// when it matches the source's resolved (encoding, range) exactly.
1249    Exact,
1250}
1251
1252/// How `convert()` resamples when it resizes.
1253///
1254/// Every backend honours it: the CPU backend through its resizer, the
1255/// OpenGL backend through its texture filtering and NV shader choice, and
1256/// G2D, which has a fixed hardware filter, by declining resizing converts
1257/// under [`Interpolation::Nearest`] so another backend runs them. Converts
1258/// that do not resize read each source pixel exactly in either mode.
1259///
1260/// Override at runtime with `EDGEFIRST_INTERPOLATION=nearest|bilinear`,
1261/// which takes precedence over [`ImageProcessorConfig::interpolation`] and
1262/// [`ImageProcessor::set_interpolation`].
1263#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1264#[non_exhaustive]
1265pub enum Interpolation {
1266    /// Nearest-neighbour: each output pixel copies one source pixel. The
1267    /// fastest mode, at the cost of aliasing that lowers detection accuracy
1268    /// on resized inputs.
1269    Nearest,
1270    /// Bilinear with half-pixel centres (default). The GL backend matches
1271    /// OpenCV `INTER_LINEAR`, the preprocessing most detectors are trained
1272    /// with; the CPU backend's bilinear widens its kernel when downscaling,
1273    /// which smooths more than `INTER_LINEAR` below 1×.
1274    #[default]
1275    Bilinear,
1276}
1277
1278impl Interpolation {
1279    /// The mode selected by `EDGEFIRST_INTERPOLATION`, if it is set to a
1280    /// recognised value. An unrecognised value is logged and ignored.
1281    pub(crate) fn from_env() -> Option<Self> {
1282        let value = std::env::var("EDGEFIRST_INTERPOLATION").ok()?;
1283        match value.trim().to_ascii_lowercase().as_str() {
1284            "nearest" => Some(Self::Nearest),
1285            "bilinear" => Some(Self::Bilinear),
1286            "" => None,
1287            other => {
1288                log::warn!(
1289                    "EDGEFIRST_INTERPOLATION={other:?} is not recognised \
1290                     (expected nearest or bilinear); ignoring it"
1291                );
1292                None
1293            }
1294        }
1295    }
1296}
1297
1298/// Compute backend selection for [`ImageProcessor`].
1299///
1300/// Use with [`ImageProcessorConfig::backend`] to select which backend the
1301/// processor should prefer. When a specific backend is selected, the
1302/// processor initializes that backend plus CPU as a fallback. When `Auto`
1303/// is used, the existing environment-variable-driven selection applies.
1304#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1305pub enum ComputeBackend {
1306    /// Auto-detect based on available hardware and environment variables.
1307    #[default]
1308    Auto,
1309    /// CPU-only processing (no hardware acceleration).
1310    Cpu,
1311    /// Prefer G2D hardware blitter (+ CPU fallback).
1312    G2d,
1313    /// Prefer OpenGL ES (+ CPU fallback).
1314    OpenGl,
1315}
1316
1317/// Backend forced via the `EDGEFIRST_FORCE_BACKEND` environment variable
1318/// or [`ImageProcessorConfig::backend`].
1319///
1320/// When set, the [`ImageProcessor`] only initializes and dispatches to the
1321/// selected backend — no fallback chain is used.
1322#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1323pub(crate) enum ForcedBackend {
1324    Cpu,
1325    G2d,
1326    OpenGl,
1327}
1328
1329/// Reports which float-color-buffer extensions the GPU backend detected.
1330/// Returned by [`ImageProcessor::supported_render_dtypes`]; the two flags
1331/// are independent.
1332///
1333/// **Linux:** reflects the real probe results from `GL_EXT_color_buffer_half_float`
1334/// and `GL_EXT_color_buffer_float`. On V3D (RPi 5) and Mali-G310 (i.MX 95)
1335/// both flags are typically `true`; on Vivante GC7000UL both are forced
1336/// `false` (float readback measured 170–320 ms — disabled). Tegra Orin
1337/// exposes both via PBO; the flags match the GPU report.
1338///
1339/// **macOS (ANGLE):** `f16 == true` gates the RGBA16F-packed IOSurface
1340/// path for F16 `PlanarRgb` destinations. `f32` reflects the GL
1341/// extension probe but is not actionable — ANGLE's
1342/// `EGL_ANGLE_iosurface_client_buffer` rejects every `(GL_FLOAT, *)`
1343/// combination with `EGL_BAD_ATTRIBUTE`, so there is no F32 IOSurface
1344/// path.
1345///
1346/// **Windows (ANGLE/D3D11):** `f16` enables the RGBA16F texture paths and
1347/// `f32` the RGBA32F ones for every float layout (`float_dispatch.rs`) —
1348/// `PlanarRgb` and `PlanarRgba` NCHW, interleaved `Rgb`, and `Rgba`.
1349///
1350/// Regardless of these flags, [`ImageProcessor::convert`] never returns
1351/// an error due to float capability — it falls back to CPU when the GPU
1352/// path is unavailable.
1353#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1354pub struct RenderDtypeSupport {
1355    /// `GL_EXT_color_buffer_float` is available on the current GPU.
1356    ///
1357    /// On Linux, `true` enables F32 `Rgb` NHWC PBO readback. On macOS
1358    /// this flag is informational only — no F32 IOSurface path exists.
1359    /// On Windows, `true` enables the RGBA32F zero-copy texture render
1360    /// for every F32 layout.
1361    pub f32: bool,
1362    /// `GL_EXT_color_buffer_half_float` is available on the current GPU.
1363    ///
1364    /// On Linux, `true` enables F16 `PlanarRgb` NCHW PBO readback and,
1365    /// on V3D/Mali, zero-copy DMA-BUF render. On macOS, `true` enables
1366    /// F16 `PlanarRgb` via RGBA16F-packed IOSurface (zero-copy). On
1367    /// Windows, `true` enables the RGBA16F zero-copy texture render for
1368    /// every F16 layout.
1369    pub f16: bool,
1370}
1371
1372/// Returns `true` when a float PBO destination should be attempted for `dtype`.
1373///
1374/// Only F16 and F32 are eligible, and only when the corresponding flag in
1375/// `support` is set. U8/I8 and all other dtypes return `false` — they are
1376/// handled by the existing `dtype.size() == 1` PBO gate.
1377///
1378/// The same question `create_image` asks before trying a zero-copy float
1379/// destination, which is why that gate calls this rather than restating it:
1380/// a float image is only worth allocating on the GPU where the display can
1381/// render into that dtype at all.
1382///
1383/// Compiled on every platform with a GL backend -- the name is historical
1384/// (it began as the float *PBO* gate) and both callers are live everywhere
1385/// the `opengl` feature builds a backend.
1386#[cfg(all(
1387    any(
1388        target_os = "linux",
1389        target_os = "windows",
1390        target_os = "macos",
1391        target_os = "ios",
1392        target_os = "android"
1393    ),
1394    feature = "opengl"
1395))]
1396pub(crate) fn float_pbo_eligible(dtype: DType, support: RenderDtypeSupport) -> bool {
1397    match dtype {
1398        DType::F16 => support.f16,
1399        DType::F32 => support.f32,
1400        _ => false,
1401    }
1402}
1403
1404/// Image converter that uses available hardware acceleration or CPU as a
1405/// fallback.
1406#[derive(Debug)]
1407pub struct ImageProcessor {
1408    /// CPU-based image converter as a fallback. This is only None if the
1409    /// EDGEFIRST_DISABLE_CPU environment variable is set.
1410    pub cpu: Option<CPUProcessor>,
1411
1412    #[cfg(target_os = "linux")]
1413    /// G2D-based image converter for Linux systems. This is only available if
1414    /// the EDGEFIRST_DISABLE_G2D environment variable is not set and libg2d.so
1415    /// is available.
1416    pub g2d: Option<G2DProcessor>,
1417    #[cfg(target_os = "linux")]
1418    #[cfg(feature = "opengl")]
1419    /// OpenGL-based image converter for Linux systems. This is only available
1420    /// if the EDGEFIRST_DISABLE_GL environment variable is not set and OpenGL
1421    /// ES is available.
1422    pub opengl: Option<GLProcessorThreaded>,
1423    #[cfg(any(
1424        target_os = "macos",
1425        target_os = "ios",
1426        target_os = "android",
1427        target_os = "windows"
1428    ))]
1429    #[cfg(feature = "opengl")]
1430    /// OpenGL-based image converter — the same unified
1431    /// `GLProcessorThreaded` engine as Linux (its worker owns a
1432    /// per-processor context). macOS/iOS run it via ANGLE + IOSurface
1433    /// (available when ANGLE's libEGL.dylib can be loaded — see
1434    /// README.md § macOS GPU Acceleration); Android runs it via the
1435    /// native EGL driver + AHardwareBuffer; Windows runs it via ANGLE +
1436    /// Direct3D 11 with PBO transfers (available when ANGLE's libEGL.dll
1437    /// can be loaded — see README.md § Windows GPU Acceleration).
1438    pub opengl: Option<GLProcessorThreaded>,
1439
1440    /// When set, only the specified backend is used — no fallback chain.
1441    pub(crate) forced_backend: Option<ForcedBackend>,
1442
1443    /// Converts where the GL backend declined and the auto chain fell
1444    /// through toward G2D/CPU. Owned by the dispatcher (the GL worker never
1445    /// sees these frames), unlike the per-feed counters in
1446    /// `GLProcessorThreaded::convert_stats`. Read via
1447    /// [`convert_fallback_count`](Self::convert_fallback_count).
1448    pub(crate) convert_fallbacks: std::sync::atomic::AtomicU64,
1449}
1450
1451unsafe impl Send for ImageProcessor {}
1452unsafe impl Sync for ImageProcessor {}
1453
1454impl ImageProcessor {
1455    /// Creates a new `ImageProcessor` instance, initializing available
1456    /// hardware converters based on the system capabilities and environment
1457    /// variables.
1458    ///
1459    /// # Examples
1460    /// ```rust,no_run
1461    /// # use edgefirst_image::{ImageProcessor, Rotation, Flip, Crop, ImageProcessorTrait};
1462    /// # use edgefirst_codec::{peek_info, ImageDecoder, ImageLoad};
1463    /// # use edgefirst_tensor::{CpuAccess, PixelFormat, DType, Tensor, TensorMemory};
1464    /// # fn main() -> Result<(), edgefirst_image::Error> {
1465    /// let image = std::fs::read("zidane.jpg")?;
1466    /// // The codec emits the source's native format (a colour JPEG decodes to
1467    /// // NV12) and configures the destination tensor during the decode.
1468    /// let info = peek_info(&image).expect("peek");
1469    /// let mut src = Tensor::<u8>::image(info.width, info.height, info.format,
1470    ///                                    Some(TensorMemory::Mem), edgefirst_tensor::CpuAccess::ReadWrite)?;
1471    /// let mut decoder = ImageDecoder::new();
1472    /// src.load_image(&mut decoder, &image).expect("decode");
1473    /// let mut converter = ImageProcessor::new()?;
1474    /// let mut dst =
1475    ///     converter.create_image(640, 480, PixelFormat::Rgb, DType::U8, None, CpuAccess::ReadWrite)?;
1476    /// converter.convert(&src.into(), &mut dst, Rotation::None, Flip::None, Crop::default())?;
1477    /// # Ok(())
1478    /// # }
1479    /// ```
1480    pub fn new() -> Result<Self> {
1481        Self::with_config(ImageProcessorConfig::default())
1482    }
1483
1484    /// Number of converts where the auto chain's GL backend declined and the
1485    /// frame fell through toward G2D/CPU — each one silently lost any
1486    /// zero-copy guarantee. A pipeline that expects to stay on the GPU
1487    /// asserts this stays flat across its steady-state loop; pair with
1488    /// `GLProcessorThreaded::convert_stats` to see how the frames that DID
1489    /// reach GL were fed. Always 0 under a forced backend (no chain).
1490    pub fn convert_fallback_count(&self) -> u64 {
1491        self.convert_fallbacks
1492            .load(std::sync::atomic::Ordering::Relaxed)
1493    }
1494
1495    /// Number of [`Compression::Any`](edgefirst_tensor::Compression)
1496    /// requests since process start that resolved to a linear layout
1497    /// (process-wide mirror of
1498    /// [`edgefirst_tensor::compression_fallback_count`], surfaced here
1499    /// beside [`convert_fallback_count`](Self::convert_fallback_count)
1500    /// so pipeline telemetry reads from one place). A pipeline that
1501    /// expects compressed convert destinations asserts this stays flat
1502    /// after warmup.
1503    pub fn compression_fallback_count(&self) -> u64 {
1504        edgefirst_tensor::compression_fallback_count()
1505    }
1506
1507    /// Convert and return a native fence that signals when the GPU work
1508    /// completes, instead of blocking the CPU — the GL→NPU handoff
1509    /// (`EGL_ANDROID_native_fence_sync` on Android). On Windows the handle
1510    /// is a manual-reset event set when the GPU work completes; wait on it
1511    /// with `WaitForSingleObject`.
1512    ///
1513    /// `Ok(Some(fence))`: the destination buffer is still in flight; hand
1514    /// the fence to the consumer (e.g.
1515    /// `ANeuralNetworksExecution_startComputeWithDependencies` on Android,
1516    /// or `WaitForSingleObject` on Windows) or poll it before reading.
1517    /// `Ok(None)`: the convert completed with the normal blocking contract
1518    /// (no native fence on this platform, or a non-GL backend handled the
1519    /// frame) — the destination is already safe. Semantics are otherwise
1520    /// identical to [`convert`](ImageProcessorTrait::convert), including
1521    /// the GL→G2D→CPU fallback chain.
1522    ///
1523    /// On Windows, **for a texture destination**, the event and the
1524    /// destination's own `Tensor::gpu_completion` name the same fence value:
1525    /// one flush and one signal cover the convert, and the value recorded on
1526    /// `dst` is the one this event waits for, so a consumer may use either.
1527    /// A destination that is not a texture records nothing, and the event
1528    /// then carries a value of its own that only it can be waited on for.
1529    pub fn convert_with_fence(
1530        &mut self,
1531        src: &TensorDyn,
1532        dst: &mut TensorDyn,
1533        rotation: Rotation,
1534        flip: Flip,
1535        crop: Crop,
1536    ) -> Result<Option<CompletionFence>> {
1537        #[cfg(any(
1538            target_os = "linux",
1539            target_os = "macos",
1540            target_os = "ios",
1541            target_os = "android",
1542            target_os = "windows"
1543        ))]
1544        #[cfg(feature = "opengl")]
1545        {
1546            let gl_forced = matches!(self.forced_backend, Some(ForcedBackend::OpenGl));
1547            if self.forced_backend.is_none() || gl_forced {
1548                if let Some(opengl) = self.opengl.as_mut() {
1549                    match opengl.convert_with_fence(src, dst, rotation, flip, crop) {
1550                        Ok(fd) => return Ok(fd),
1551                        Err(e) if gl_forced => return Err(e),
1552                        Err(e) => {
1553                            // Not counted here: the blocking chain below
1554                            // re-runs the dispatcher, whose decline arm
1555                            // counts the fallback exactly once.
1556                            log::debug!(
1557                                "convert_with_fence: opengl declined, \
1558                                 falling back to the blocking chain: {e}"
1559                            );
1560                        }
1561                    }
1562                } else if gl_forced {
1563                    return Err(Error::ForcedBackendUnavailable("opengl".into()));
1564                }
1565            }
1566        }
1567        // Blocking chain (G2D/CPU, forced non-GL, or non-GL builds):
1568        // completion == return, so no fence is needed.
1569        self.convert(src, dst, rotation, flip, crop)?;
1570        Ok(None)
1571    }
1572
1573    /// Report which float dtypes the GPU can render to.
1574    ///
1575    /// Probes `GL_EXT_color_buffer_half_float` and
1576    /// `GL_EXT_color_buffer_float` once at `ImageProcessor::new()` time
1577    /// and caches the result. Call this once at startup to decide whether
1578    /// to request F16 or F32 destination tensors; [`create_image`] uses
1579    /// the result internally to auto-select float PBO when supported.
1580    ///
1581    /// Returns `RenderDtypeSupport { f32: false, f16: false }` when no
1582    /// OpenGL backend is active or the `opengl` feature is disabled.
1583    ///
1584    /// [`create_image`]: Self::create_image
1585    pub fn supported_render_dtypes(&self) -> RenderDtypeSupport {
1586        #[cfg(all(
1587            any(
1588                target_os = "macos",
1589                target_os = "ios",
1590                target_os = "android",
1591                target_os = "windows"
1592            ),
1593            feature = "opengl"
1594        ))]
1595        if let Some(gl) = self.opengl.as_ref() {
1596            return gl.supported_render_dtypes();
1597        }
1598        #[cfg(all(target_os = "linux", feature = "opengl"))]
1599        if let Some(gl) = self.opengl.as_ref() {
1600            return gl.supported_render_dtypes();
1601        }
1602        RenderDtypeSupport {
1603            f32: false,
1604            f16: false,
1605        }
1606    }
1607
1608    /// Creates a new `ImageProcessor` with the given configuration.
1609    ///
1610    /// When [`ImageProcessorConfig::backend`] is set to a specific backend,
1611    /// environment variables are ignored and the processor initializes the
1612    /// requested backend plus CPU as a fallback.
1613    ///
1614    /// When `Auto`, the existing `EDGEFIRST_FORCE_BACKEND` and
1615    /// `EDGEFIRST_DISABLE_*` environment variables apply.
1616    pub fn with_config(config: ImageProcessorConfig) -> Result<Self> {
1617        let interpolation = config.interpolation;
1618        let mut processor = Self::with_config_backends(config)?;
1619        processor.set_interpolation(interpolation)?;
1620        Ok(processor)
1621    }
1622
1623    /// The backend selection half of [`Self::with_config`].
1624    #[allow(unused_variables)]
1625    fn with_config_backends(config: ImageProcessorConfig) -> Result<Self> {
1626        // ── Config-driven backend selection ──────────────────────────
1627        // When the caller explicitly requests a backend via the config,
1628        // skip all environment variable logic.
1629        match config.backend {
1630            ComputeBackend::Cpu => {
1631                log::info!("ComputeBackend::Cpu — CPU only");
1632                return Ok(Self {
1633                    cpu: Some(CPUProcessor::new()),
1634                    #[cfg(target_os = "linux")]
1635                    g2d: None,
1636                    #[cfg(target_os = "linux")]
1637                    #[cfg(feature = "opengl")]
1638                    opengl: None,
1639                    #[cfg(any(
1640                        target_os = "macos",
1641                        target_os = "ios",
1642                        target_os = "android",
1643                        target_os = "windows"
1644                    ))]
1645                    #[cfg(feature = "opengl")]
1646                    opengl: None,
1647                    convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1648                    forced_backend: None,
1649                });
1650            }
1651            ComputeBackend::G2d => {
1652                log::info!("ComputeBackend::G2d — G2D + CPU fallback");
1653                #[cfg(target_os = "linux")]
1654                {
1655                    let g2d = match G2DProcessor::new() {
1656                        Ok(g) => Some(g),
1657                        Err(e) => {
1658                            log::warn!("G2D requested but failed to initialize: {e:?}");
1659                            None
1660                        }
1661                    };
1662                    return Ok(Self {
1663                        cpu: Some(CPUProcessor::new()),
1664                        g2d,
1665                        #[cfg(feature = "opengl")]
1666                        opengl: None,
1667                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1668                        forced_backend: None,
1669                    });
1670                }
1671                #[cfg(not(target_os = "linux"))]
1672                {
1673                    log::warn!("G2D requested but not available on this platform, using CPU");
1674                    return Ok(Self {
1675                        cpu: Some(CPUProcessor::new()),
1676                        #[cfg(any(
1677                            target_os = "macos",
1678                            target_os = "ios",
1679                            target_os = "android",
1680                            target_os = "windows"
1681                        ))]
1682                        #[cfg(feature = "opengl")]
1683                        opengl: None,
1684                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1685                        forced_backend: None,
1686                    });
1687                }
1688            }
1689            ComputeBackend::OpenGl => {
1690                log::info!("ComputeBackend::OpenGl — OpenGL + CPU fallback");
1691                #[cfg(target_os = "linux")]
1692                {
1693                    #[cfg(feature = "opengl")]
1694                    let opengl = match GLProcessorThreaded::with_cache_capacity(
1695                        config.egl_display,
1696                        config.egl_cache_capacity,
1697                    ) {
1698                        Ok(gl) => Some(gl),
1699                        Err(e) => {
1700                            log::warn!("OpenGL requested but failed to initialize: {e:?}");
1701                            None
1702                        }
1703                    };
1704                    return Ok(Self {
1705                        cpu: Some(CPUProcessor::new()),
1706                        g2d: None,
1707                        #[cfg(feature = "opengl")]
1708                        opengl,
1709                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1710                        forced_backend: None,
1711                    }
1712                    .apply_colorimetry_mode(config.colorimetry));
1713                }
1714                #[cfg(any(target_os = "macos", target_os = "ios"))]
1715                {
1716                    #[cfg(feature = "opengl")]
1717                    let opengl = match GLProcessorThreaded::with_cache_capacity(
1718                        config.egl_display,
1719                        config.egl_cache_capacity,
1720                    ) {
1721                        Ok(gl) => Some(gl),
1722                        Err(e) => {
1723                            log::warn!(
1724                                "OpenGL requested on macOS but ANGLE init failed: {e:?} \
1725                                 (install ANGLE via `brew install startergo/angle/angle` \
1726                                 and re-sign the dylibs — see README.md § macOS GPU \
1727                                 Acceleration). Falling back to CPU."
1728                            );
1729                            None
1730                        }
1731                    };
1732                    return Ok(Self {
1733                        cpu: Some(CPUProcessor::new()),
1734                        #[cfg(feature = "opengl")]
1735                        opengl,
1736                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1737                        forced_backend: None,
1738                    }
1739                    .apply_colorimetry_mode(config.colorimetry));
1740                }
1741                #[cfg(target_os = "android")]
1742                {
1743                    #[cfg(feature = "opengl")]
1744                    let opengl = match GLProcessorThreaded::with_cache_capacity(
1745                        config.egl_display,
1746                        config.egl_cache_capacity,
1747                    ) {
1748                        Ok(gl) => Some(gl),
1749                        Err(e) => {
1750                            log::warn!(
1751                                "OpenGL requested but native EGL init failed: {e:?}. \
1752                                 Falling back to CPU."
1753                            );
1754                            None
1755                        }
1756                    };
1757                    return Ok(Self {
1758                        cpu: Some(CPUProcessor::new()),
1759                        #[cfg(feature = "opengl")]
1760                        opengl,
1761                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1762                        forced_backend: None,
1763                    }
1764                    .apply_colorimetry_mode(config.colorimetry));
1765                }
1766                #[cfg(target_os = "windows")]
1767                {
1768                    #[cfg(feature = "opengl")]
1769                    let opengl = match GLProcessorThreaded::with_cache_capacity(
1770                        config.egl_display,
1771                        config.egl_cache_capacity,
1772                    ) {
1773                        Ok(gl) => Some(gl),
1774                        Err(e) => {
1775                            log::warn!(
1776                                "OpenGL requested on Windows but ANGLE/D3D11 init failed: {e:?} \
1777                                 (fetch ANGLE with `scripts/fetch-angle.sh` and set \
1778                                 EDGEFIRST_ANGLE_PATH to the directory containing libEGL.dll + \
1779                                 libGLESv2.dll, or place the two DLLs next to the executable — \
1780                                 see README.md § Windows GPU Acceleration). Falling back to CPU."
1781                            );
1782                            None
1783                        }
1784                    };
1785                    return Ok(Self {
1786                        cpu: Some(CPUProcessor::new()),
1787                        #[cfg(feature = "opengl")]
1788                        opengl,
1789                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1790                        forced_backend: None,
1791                    }
1792                    .apply_colorimetry_mode(config.colorimetry));
1793                }
1794                #[cfg(not(any(
1795                    target_os = "linux",
1796                    target_os = "macos",
1797                    target_os = "ios",
1798                    target_os = "android",
1799                    target_os = "windows"
1800                )))]
1801                {
1802                    log::warn!("OpenGL requested but not available on this platform, using CPU");
1803                    return Ok(Self {
1804                        cpu: Some(CPUProcessor::new()),
1805                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1806                        forced_backend: None,
1807                    });
1808                }
1809            }
1810            ComputeBackend::Auto => { /* fall through to env-var logic below */ }
1811        }
1812
1813        // ── EDGEFIRST_FORCE_BACKEND ──────────────────────────────────
1814        // When set, only the requested backend is initialised and no
1815        // fallback chain is used. Accepted values (case-insensitive):
1816        //   "cpu", "g2d", "opengl"
1817        if let Ok(val) = std::env::var("EDGEFIRST_FORCE_BACKEND") {
1818            let val_lower = val.to_lowercase();
1819            let forced = match val_lower.as_str() {
1820                "cpu" => ForcedBackend::Cpu,
1821                "g2d" => ForcedBackend::G2d,
1822                "opengl" => ForcedBackend::OpenGl,
1823                other => {
1824                    return Err(Error::ForcedBackendUnavailable(format!(
1825                        "unknown EDGEFIRST_FORCE_BACKEND value: {other:?} (expected cpu, g2d, or opengl)"
1826                    )));
1827                }
1828            };
1829
1830            log::info!("EDGEFIRST_FORCE_BACKEND={val} — only initializing {val_lower} backend");
1831
1832            return match forced {
1833                ForcedBackend::Cpu => Ok(Self {
1834                    cpu: Some(CPUProcessor::new()),
1835                    #[cfg(target_os = "linux")]
1836                    g2d: None,
1837                    #[cfg(target_os = "linux")]
1838                    #[cfg(feature = "opengl")]
1839                    opengl: None,
1840                    #[cfg(any(
1841                        target_os = "macos",
1842                        target_os = "ios",
1843                        target_os = "android",
1844                        target_os = "windows"
1845                    ))]
1846                    #[cfg(feature = "opengl")]
1847                    opengl: None,
1848                    convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1849                    forced_backend: Some(ForcedBackend::Cpu),
1850                }),
1851                ForcedBackend::G2d => {
1852                    #[cfg(target_os = "linux")]
1853                    {
1854                        let g2d = G2DProcessor::new().map_err(|e| {
1855                            Error::ForcedBackendUnavailable(format!(
1856                                "g2d forced but failed to initialize: {e:?}"
1857                            ))
1858                        })?;
1859                        Ok(Self {
1860                            cpu: None,
1861                            g2d: Some(g2d),
1862                            #[cfg(feature = "opengl")]
1863                            opengl: None,
1864                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1865                            forced_backend: Some(ForcedBackend::G2d),
1866                        })
1867                    }
1868                    #[cfg(not(target_os = "linux"))]
1869                    {
1870                        Err(Error::ForcedBackendUnavailable(
1871                            "g2d backend is only available on Linux".into(),
1872                        ))
1873                    }
1874                }
1875                ForcedBackend::OpenGl => {
1876                    #[cfg(target_os = "linux")]
1877                    #[cfg(feature = "opengl")]
1878                    {
1879                        let opengl = GLProcessorThreaded::with_cache_capacity(
1880                            config.egl_display,
1881                            config.egl_cache_capacity,
1882                        )
1883                        .map_err(|e| {
1884                            Error::ForcedBackendUnavailable(format!(
1885                                "opengl forced but failed to initialize: {e:?}"
1886                            ))
1887                        })?;
1888                        Ok(Self {
1889                            cpu: None,
1890                            g2d: None,
1891                            opengl: Some(opengl),
1892                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1893                            forced_backend: Some(ForcedBackend::OpenGl),
1894                        }
1895                        .apply_colorimetry_mode(config.colorimetry))
1896                    }
1897                    #[cfg(any(target_os = "macos", target_os = "ios"))]
1898                    #[cfg(feature = "opengl")]
1899                    {
1900                        let opengl = GLProcessorThreaded::with_cache_capacity(
1901                            config.egl_display,
1902                            config.egl_cache_capacity,
1903                        )
1904                        .map_err(|e| {
1905                            Error::ForcedBackendUnavailable(format!(
1906                                "opengl forced on macOS but ANGLE init failed: {e:?}"
1907                            ))
1908                        })?;
1909                        Ok(Self {
1910                            cpu: None,
1911                            opengl: Some(opengl),
1912                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1913                            forced_backend: Some(ForcedBackend::OpenGl),
1914                        }
1915                        .apply_colorimetry_mode(config.colorimetry))
1916                    }
1917                    #[cfg(target_os = "android")]
1918                    #[cfg(feature = "opengl")]
1919                    {
1920                        let opengl = GLProcessorThreaded::with_cache_capacity(
1921                            config.egl_display,
1922                            config.egl_cache_capacity,
1923                        )
1924                        .map_err(|e| {
1925                            Error::ForcedBackendUnavailable(format!(
1926                                "opengl forced but native EGL init failed: {e:?}"
1927                            ))
1928                        })?;
1929                        Ok(Self {
1930                            cpu: None,
1931                            opengl: Some(opengl),
1932                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1933                            forced_backend: Some(ForcedBackend::OpenGl),
1934                        }
1935                        .apply_colorimetry_mode(config.colorimetry))
1936                    }
1937                    #[cfg(target_os = "windows")]
1938                    #[cfg(feature = "opengl")]
1939                    {
1940                        let opengl = GLProcessorThreaded::with_cache_capacity(
1941                            config.egl_display,
1942                            config.egl_cache_capacity,
1943                        )
1944                        .map_err(|e| {
1945                            Error::ForcedBackendUnavailable(format!(
1946                                "opengl forced on Windows but ANGLE/D3D11 init failed: {e:?}"
1947                            ))
1948                        })?;
1949                        Ok(Self {
1950                            cpu: None,
1951                            opengl: Some(opengl),
1952                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1953                            forced_backend: Some(ForcedBackend::OpenGl),
1954                        }
1955                        .apply_colorimetry_mode(config.colorimetry))
1956                    }
1957                    #[cfg(not(all(
1958                        any(
1959                            target_os = "linux",
1960                            target_os = "macos",
1961                            target_os = "ios",
1962                            target_os = "android",
1963                            target_os = "windows"
1964                        ),
1965                        feature = "opengl"
1966                    )))]
1967                    {
1968                        Err(Error::ForcedBackendUnavailable(
1969                            "opengl backend requires Linux, macOS, Android or Windows with the \
1970                             'opengl' feature enabled"
1971                                .into(),
1972                        ))
1973                    }
1974                }
1975            };
1976        }
1977
1978        // ── Existing DISABLE logic (unchanged) ──────────────────────
1979        #[cfg(target_os = "linux")]
1980        let g2d = if std::env::var("EDGEFIRST_DISABLE_G2D")
1981            .map(|x| x != "0" && x.to_lowercase() != "false")
1982            .unwrap_or(false)
1983        {
1984            log::debug!("EDGEFIRST_DISABLE_G2D is set");
1985            None
1986        } else {
1987            match G2DProcessor::new() {
1988                Ok(g2d_converter) => Some(g2d_converter),
1989                Err(err) => {
1990                    log::warn!("Failed to initialize G2D converter: {err:?}");
1991                    None
1992                }
1993            }
1994        };
1995
1996        #[cfg(target_os = "linux")]
1997        #[cfg(feature = "opengl")]
1998        let opengl = if std::env::var("EDGEFIRST_DISABLE_GL")
1999            .map(|x| x != "0" && x.to_lowercase() != "false")
2000            .unwrap_or(false)
2001        {
2002            log::debug!("EDGEFIRST_DISABLE_GL is set");
2003            None
2004        } else {
2005            match GLProcessorThreaded::with_cache_capacity(
2006                config.egl_display,
2007                config.egl_cache_capacity,
2008            ) {
2009                Ok(gl_converter) => Some(gl_converter),
2010                Err(err) => {
2011                    log::warn!("Failed to initialize GL converter: {err:?}");
2012                    None
2013                }
2014            }
2015        };
2016
2017        #[cfg(any(
2018            target_os = "macos",
2019            target_os = "ios",
2020            target_os = "android",
2021            target_os = "windows"
2022        ))]
2023        #[cfg(feature = "opengl")]
2024        let opengl = if std::env::var("EDGEFIRST_DISABLE_GL")
2025            .map(|x| x != "0" && x.to_lowercase() != "false")
2026            .unwrap_or(false)
2027        {
2028            log::debug!("EDGEFIRST_DISABLE_GL is set");
2029            None
2030        } else {
2031            match GLProcessorThreaded::with_cache_capacity(
2032                config.egl_display,
2033                config.egl_cache_capacity,
2034            ) {
2035                Ok(gl_converter) => Some(gl_converter),
2036                Err(err) => {
2037                    log::debug!(
2038                        "GL backend unavailable: {err:?} \
2039                         (CPU fallback will be used)"
2040                    );
2041                    None
2042                }
2043            }
2044        };
2045
2046        let cpu = if std::env::var("EDGEFIRST_DISABLE_CPU")
2047            .map(|x| x != "0" && x.to_lowercase() != "false")
2048            .unwrap_or(false)
2049        {
2050            log::debug!("EDGEFIRST_DISABLE_CPU is set");
2051            None
2052        } else {
2053            Some(CPUProcessor::new())
2054        };
2055        Ok(Self {
2056            cpu,
2057            #[cfg(target_os = "linux")]
2058            g2d,
2059            #[cfg(target_os = "linux")]
2060            #[cfg(feature = "opengl")]
2061            opengl,
2062            #[cfg(any(
2063                target_os = "macos",
2064                target_os = "ios",
2065                target_os = "android",
2066                target_os = "windows"
2067            ))]
2068            #[cfg(feature = "opengl")]
2069            opengl,
2070            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
2071            forced_backend: None,
2072        }
2073        .apply_colorimetry_mode(config.colorimetry))
2074    }
2075
2076    /// Apply the configured [`ColorimetryMode`] to whichever backend honours
2077    /// it (currently the Linux GL backend); no-op elsewhere. Constructor
2078    /// plumbing for [`ImageProcessorConfig::colorimetry`].
2079    fn apply_colorimetry_mode(self, _mode: ColorimetryMode) -> Self {
2080        #[cfg(all(
2081            any(
2082                target_os = "linux",
2083                target_os = "macos",
2084                target_os = "ios",
2085                target_os = "android",
2086                target_os = "windows"
2087            ),
2088            feature = "opengl"
2089        ))]
2090        {
2091            let mut me = self;
2092            if let Err(e) = me.set_colorimetry_mode(_mode) {
2093                log::warn!("Failed to apply ColorimetryMode::{_mode:?}: {e:?}");
2094            }
2095            me
2096        }
2097        #[cfg(not(all(
2098            any(
2099                target_os = "linux",
2100                target_os = "macos",
2101                target_os = "ios",
2102                target_os = "android",
2103                target_os = "windows"
2104            ),
2105            feature = "opengl"
2106        )))]
2107        {
2108            let _ = _mode;
2109            self
2110        }
2111    }
2112
2113    /// Sets the colorimetry/performance trade-off (see [`ColorimetryMode`])
2114    /// on the OpenGL backend. No-op if OpenGL is not available. The
2115    /// `EDGEFIRST_COLORIMETRY` environment variable takes precedence — when
2116    /// it is set, this call logs and keeps the env-selected mode.
2117    #[cfg(all(
2118        any(
2119            target_os = "linux",
2120            target_os = "macos",
2121            target_os = "ios",
2122            target_os = "android",
2123            target_os = "windows"
2124        ),
2125        feature = "opengl"
2126    ))]
2127    pub fn set_colorimetry_mode(&mut self, mode: ColorimetryMode) -> Result<()> {
2128        if let Some(ref mut gl) = self.opengl {
2129            gl.set_colorimetry_mode(mode)?;
2130        }
2131        Ok(())
2132    }
2133
2134    /// Sets how `convert()` resamples when it resizes (see [`Interpolation`])
2135    /// on every backend this processor holds. The `EDGEFIRST_INTERPOLATION`
2136    /// environment variable takes precedence: when it is set, this call logs
2137    /// and keeps the env-selected mode.
2138    pub fn set_interpolation(&mut self, mode: Interpolation) -> Result<()> {
2139        let mode = match Interpolation::from_env() {
2140            Some(env) => {
2141                if env != mode {
2142                    log::info!(
2143                        "EDGEFIRST_INTERPOLATION pins Interpolation::{env:?}; \
2144                         ignoring Interpolation::{mode:?}"
2145                    );
2146                }
2147                env
2148            }
2149            None => mode,
2150        };
2151        if let Some(cpu) = self.cpu.as_mut() {
2152            cpu.set_interpolation(mode);
2153        }
2154        #[cfg(target_os = "linux")]
2155        if let Some(g2d) = self.g2d.as_mut() {
2156            g2d.set_interpolation(mode);
2157        }
2158        #[cfg(all(
2159            any(
2160                target_os = "linux",
2161                target_os = "macos",
2162                target_os = "ios",
2163                target_os = "android",
2164                target_os = "windows"
2165            ),
2166            feature = "opengl"
2167        ))]
2168        if let Some(gl) = self.opengl.as_mut() {
2169            gl.set_interpolation(mode)?;
2170        }
2171        Ok(())
2172    }
2173
2174    /// Sets the interpolation mode for int8 proto textures on the OpenGL
2175    /// backend. No-op if OpenGL is not available.
2176    #[cfg(all(
2177        any(
2178            target_os = "linux",
2179            target_os = "macos",
2180            target_os = "ios",
2181            target_os = "android",
2182            target_os = "windows"
2183        ),
2184        feature = "opengl"
2185    ))]
2186    pub fn set_int8_interpolation_mode(&mut self, mode: Int8InterpolationMode) -> Result<()> {
2187        if let Some(ref mut gl) = self.opengl {
2188            gl.set_int8_interpolation_mode(mode)?;
2189        }
2190        Ok(())
2191    }
2192
2193    /// Allocate an image tensor from a declarative
2194    /// [`ImageDesc`](edgefirst_tensor::ImageDesc) request — the
2195    /// full-featured variant of [`create_image`](Self::create_image).
2196    ///
2197    /// Without a compression request this is exactly `create_image` (the
2198    /// processor's memory negotiation applies). With one, the allocation
2199    /// rides the tensor desc path un-negotiated: the layout decision
2200    /// belongs to the platform allocator, and the request's guards and
2201    /// fallback counting live there (see
2202    /// [`Tensor::image_desc`](edgefirst_tensor::Tensor::image_desc)).
2203    pub fn create_image_desc(&self, desc: &edgefirst_tensor::ImageDesc) -> Result<TensorDyn> {
2204        if desc.compression().is_none() {
2205            return self.create_image(
2206                desc.width(),
2207                desc.height(),
2208                desc.format(),
2209                desc.dtype(),
2210                desc.memory(),
2211                desc.access(),
2212            );
2213        }
2214        Ok(TensorDyn::image_desc(desc)?)
2215    }
2216
2217    /// Create a [`TensorDyn`] image with the best available memory backend.
2218    ///
2219    /// Priority: DMA-buf → float PBO (F16/F32) → u8/i8 PBO → system memory.
2220    ///
2221    /// Use this method instead of [`TensorDyn::image()`] when the tensor will
2222    /// be used with [`ImageProcessor::convert()`]. It selects the optimal
2223    /// memory backing (including PBO for GPU zero-copy) which direct
2224    /// allocation cannot achieve.
2225    ///
2226    /// This method is on [`ImageProcessor`] rather than [`ImageProcessorTrait`]
2227    /// because optimal allocation requires knowledge of the active compute
2228    /// backends (e.g. the GL context handle for PBO allocation). Individual
2229    /// backend implementations ([`CPUProcessor`], etc.) do not have this
2230    /// cross-backend visibility.
2231    ///
2232    /// **Float dtype behaviour:** when `dtype` is `F16` or `F32` and
2233    /// [`supported_render_dtypes`] reports the GPU supports that type,
2234    /// `memory: None` auto-selects a float PBO (Linux) or IOSurface (macOS
2235    /// F16 only). If GPU float support is absent the allocation falls through
2236    /// to `TensorMemory::Mem`; [`convert`] then uses the CPU path.
2237    /// Passing `memory: Some(TensorMemory::DmaBuf)` with `dtype: F32` always
2238    /// returns `Error::NotSupported` — no 32-bit-float DRM fourcc exists.
2239    ///
2240    /// [`supported_render_dtypes`]: Self::supported_render_dtypes
2241    /// [`convert`]: ImageProcessorTrait::convert
2242    ///
2243    /// # Arguments
2244    ///
2245    /// * `width` - Image width in pixels
2246    /// * `height` - Image height in pixels
2247    /// * `format` - Pixel format
2248    /// * `dtype` - Element data type (e.g. `DType::U8`, `DType::F16`, `DType::F32`)
2249    /// * `memory` - Optional memory type override; when `None`, the best
2250    ///   available backend is selected automatically.
2251    /// * `access` - Declares the CPU involvement you plan on. Use
2252    ///   [`CpuAccess::None`](edgefirst_tensor::CpuAccess) for a destination
2253    ///   that only hardware reads (an NPU input), and `ReadWrite` when you
2254    ///   will map it. Hardware access is always implied. Mapping a tensor
2255    ///   beyond its declaration is counted by
2256    ///   [`unplanned_cpu_access_count`](edgefirst_tensor::unplanned_cpu_access_count).
2257    ///
2258    /// # Returns
2259    ///
2260    /// A [`TensorDyn`] backed by the highest-performance memory type
2261    /// available on this system.
2262    ///
2263    /// # Examples
2264    ///
2265    /// ```rust,no_run
2266    /// # use edgefirst_image::ImageProcessor;
2267    /// # use edgefirst_tensor::{CpuAccess, DType, PixelFormat};
2268    /// # fn main() -> Result<(), edgefirst_image::Error> {
2269    /// let processor = ImageProcessor::new()?;
2270    ///
2271    /// // Model input the NPU reads directly — no CPU mapping planned.
2272    /// let input = processor.create_image(
2273    ///     640, 640, PixelFormat::Rgb, DType::U8, None, CpuAccess::None,
2274    /// )?;
2275    /// # let _ = input;
2276    /// # Ok(())
2277    /// # }
2278    /// ```
2279    ///
2280    /// # Pitch alignment for DMA-backed allocations
2281    ///
2282    /// DMA-BUF imports into the GL backend (Mali Valhall on i.MX 95
2283    /// specifically) require every row pitch to be a multiple of
2284    /// [`GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES`] (currently 64). When this
2285    /// method lands on `TensorMemory::DmaBuf`, the underlying allocation is
2286    /// silently padded so the row stride satisfies that requirement.
2287    ///
2288    /// **The user-requested `width` is preserved** — `tensor.width()`
2289    /// returns the same value you passed in. The padding is carried by
2290    /// [`TensorDyn::row_stride`] / `effective_row_stride()`, which the
2291    /// GL backend reads when importing the buffer as an EGLImage.
2292    /// Callers that compute byte offsets from the tensor must use the
2293    /// stride, not `width × bytes_per_pixel`; the CPU mapping spans the
2294    /// full `stride × height` bytes.
2295    ///
2296    /// Pre-aligned widths (640, 1280, 1920, 3008, 3840 …) allocate
2297    /// exactly `width × bpp × height` bytes with no padding. PBO and
2298    /// Mem fallbacks never pad — they don't go through EGLImage import.
2299    ///
2300    /// See also [`align_width_for_gpu_pitch`] for an advisory helper
2301    /// that external callers (GStreamer plugins, video pipelines) can
2302    /// use to size their own DMA-BUFs for GL compatibility.
2303    ///
2304    /// # Errors
2305    ///
2306    /// Returns an error if all allocation strategies fail.
2307    pub fn create_image(
2308        &self,
2309        width: usize,
2310        height: usize,
2311        format: PixelFormat,
2312        dtype: DType,
2313        memory: Option<TensorMemory>,
2314        access: edgefirst_tensor::CpuAccess,
2315    ) -> Result<TensorDyn> {
2316        // Compute the GPU-aligned row stride in bytes for this image.
2317        // `None` means either the format has no defined primary-plane bpp
2318        // (unknown future layout) or the stride calculation would overflow
2319        // — in both cases we fall back to the natural layout via the plain
2320        // `TensorDyn::image` constructor, and the slow-path warning inside
2321        // `draw_*_masks` will fire if the subsequent GL import fails.
2322        //
2323        // DMA allocation is Linux-only (see `TensorMemory::DmaBuf` cfg gate),
2324        // so both the stride computation and the helper closure are gated
2325        // accordingly — the callers below are already Linux-only.
2326        #[cfg(target_os = "linux")]
2327        let dma_stride_bytes: Option<usize> = primary_plane_bpp(format, dtype.size())
2328            .and_then(|bpp| width.checked_mul(bpp))
2329            .and_then(align_pitch_bytes_to_gpu_alignment);
2330
2331        // Helper: allocate a DMA image, using the padded-stride constructor
2332        // when the computed stride exceeds the natural pitch, otherwise the
2333        // plain constructor (byte-identical result in the common case).
2334        #[cfg(target_os = "linux")]
2335        let try_dma = || -> Result<TensorDyn> {
2336            // Stride padding is only meaningful for packed pixel layouts
2337            // (RGBA8, BGRA8, RGB888, Grey) — the formats the GL backend
2338            // renders into. Semi-planar (NV12, NV16) and planar (PlanarRgb,
2339            // PlanarRgba) tensors go through `TensorDyn::image(...)` with
2340            // their natural layout; they're imported from camera capture
2341            // via `from_fd` far more often than allocated here, and
2342            // `Tensor::image_with_stride` explicitly rejects them.
2343            let packed = format.layout() == edgefirst_tensor::PixelLayout::Packed;
2344            match dma_stride_bytes {
2345                Some(stride)
2346                    if packed
2347                        && primary_plane_bpp(format, dtype.size())
2348                            .and_then(|bpp| width.checked_mul(bpp))
2349                            .is_some_and(|natural| stride > natural) =>
2350                {
2351                    log::debug!(
2352                        "create_image: padding row stride for {format:?} {width}x{height} \
2353                         from natural pitch to {stride} bytes for GPU alignment"
2354                    );
2355                    Ok(TensorDyn::image_with_stride(
2356                        width,
2357                        height,
2358                        format,
2359                        dtype,
2360                        stride,
2361                        Some(edgefirst_tensor::TensorMemory::DmaBuf),
2362                        access,
2363                    )?)
2364                }
2365                _ => Ok(TensorDyn::image(
2366                    width,
2367                    height,
2368                    format,
2369                    dtype,
2370                    Some(edgefirst_tensor::TensorMemory::DmaBuf),
2371                    access,
2372                )?),
2373            }
2374        };
2375
2376        // If an explicit memory type is requested, honour it directly.
2377        // On Linux, `TensorMemory::DmaBuf` gets the padded-stride treatment;
2378        // other memory types take the user-requested width verbatim.
2379        // On macOS, `TensorMemory::DmaBuf` dispatches through `TensorDyn::image`
2380        // which selects the IOSurface allocation path (FourCC-formatted)
2381        // for image-mappable formats, or falls back to SHM/Mem otherwise.
2382        match memory {
2383            #[cfg(target_os = "linux")]
2384            Some(TensorMemory::DmaBuf) => {
2385                // F32 has no 32-bit-float DRM fourcc; callers must use PBO instead.
2386                if dtype == DType::F32 {
2387                    return Err(Error::NotSupported(
2388                        "F32 has no 32-bit-float DRM format for DMA-BUF; \
2389                         use TensorMemory::Pbo for F32"
2390                            .to_string(),
2391                    ));
2392                }
2393                return try_dma();
2394            }
2395            Some(mem) => {
2396                return Ok(TensorDyn::image(
2397                    width,
2398                    height,
2399                    format,
2400                    dtype,
2401                    Some(mem),
2402                    access,
2403                )?);
2404            }
2405            None => {}
2406        }
2407
2408        // macOS/Android/Windows: when the GL backend is active, prefer Dma
2409        // (IOSurface on Apple, AHardwareBuffer on Android, a D3D11 texture
2410        // on Windows) for zero-copy import. Formats without a zero-copy
2411        // mapping now ERROR under explicit Dma (the explicit-Dma
2412        // contract), so auto-select catches that error here and falls
2413        // back to host storage — loudly, via the debug log below.
2414        #[cfg(any(
2415            target_os = "macos",
2416            target_os = "ios",
2417            target_os = "android",
2418            target_os = "windows"
2419        ))]
2420        #[cfg(feature = "opengl")]
2421        if let Some(gl) = self.opengl.as_ref() {
2422            let _ = gl;
2423            // Float textures only where the display can render into them;
2424            // otherwise the float PBO step below applies. The same question
2425            // that step asks, asked through the same helper so the two
2426            // cannot answer differently.
2427            let support = self.supported_render_dtypes();
2428            let dtype_ok =
2429                !matches!(dtype, DType::F16 | DType::F32) || float_pbo_eligible(dtype, support);
2430            if dtype_ok {
2431                match TensorDyn::image(
2432                    width,
2433                    height,
2434                    format,
2435                    dtype,
2436                    Some(edgefirst_tensor::TensorMemory::DmaBuf),
2437                    access,
2438                ) {
2439                    Ok(img) => return Ok(img),
2440                    Err(e) => {
2441                        // Falling back to a non-zero-copy destination is a real
2442                        // perf cliff — never do it silently (on-device triage
2443                        // starts from this line).
2444                        log::debug!(
2445                            "create_image: zero-copy Dma allocation declined \
2446                             ({format:?}/{dtype:?} {width}x{height}): {e:?}; using fallback storage"
2447                        );
2448                    }
2449                }
2450            }
2451        }
2452
2453        // Try DMA first on Linux — skip only when GL has explicitly selected PBO
2454        // as the preferred transfer path (PBO is better than DMA in that case).
2455        #[cfg(target_os = "linux")]
2456        {
2457            #[cfg(feature = "opengl")]
2458            let gl_uses_pbo = self
2459                .opengl
2460                .as_ref()
2461                .is_some_and(|gl| gl.transfer_backend() == opengl_headless::TransferBackend::Pbo);
2462            #[cfg(not(feature = "opengl"))]
2463            let gl_uses_pbo = false;
2464
2465            if !gl_uses_pbo {
2466                if let Ok(img) = try_dma() {
2467                    return Ok(img);
2468                }
2469            }
2470        }
2471
2472        // Try PBO (if GL available).
2473        // PBO buffers are u8-sized; the int8 shader emulates i8 output via
2474        // XOR 0x80 on the same underlying buffer, so both U8 and I8 work.
2475        #[cfg(any(target_os = "linux", target_os = "windows"))]
2476        #[cfg(feature = "opengl")]
2477        if dtype.size() == 1 {
2478            if let Some(gl) = &self.opengl {
2479                match gl.create_pbo_image(width, height, format) {
2480                    Ok(t) => {
2481                        if dtype == DType::I8 {
2482                            // SAFETY: Tensor<u8> and Tensor<i8> are layout-
2483                            // identical (same element size, no T-dependent
2484                            // drop glue). The int8 shader applies XOR 0x80
2485                            // on the same PBO buffer. Same rationale as
2486                            // gl::processor::tensor_i8_as_u8_mut.
2487                            // Invariant: PBO tensors never have chroma
2488                            // (create_pbo_image → Tensor::wrap sets it None).
2489                            debug_assert!(
2490                                t.chroma().is_none(),
2491                                "PBO i8 transmute requires chroma == None"
2492                            );
2493                            let t_i8: Tensor<i8> = unsafe { std::mem::transmute(t) };
2494                            return Ok(TensorDyn::from(t_i8));
2495                        }
2496                        return Ok(TensorDyn::from(t));
2497                    }
2498                    Err(e) => log::debug!("PBO image creation failed, falling back to Mem: {e:?}"),
2499                }
2500            }
2501        }
2502
2503        // Try float PBO when the GPU backend reports support for this dtype.
2504        // Falls through to Mem on error (same policy as u8 PBO above).
2505        #[cfg(any(target_os = "linux", target_os = "windows"))]
2506        #[cfg(feature = "opengl")]
2507        if float_pbo_eligible(dtype, self.supported_render_dtypes()) {
2508            if let Some(gl) = &self.opengl {
2509                match gl.create_pbo_image_dtype(width, height, format, dtype) {
2510                    Ok(t) => return Ok(t),
2511                    Err(e) => {
2512                        log::debug!(
2513                            "Float PBO image creation failed for {dtype:?}, \
2514                             falling back to Mem: {e:?}"
2515                        );
2516                    }
2517                }
2518            }
2519        }
2520
2521        // Fallback to Mem
2522        Ok(TensorDyn::image(
2523            width,
2524            height,
2525            format,
2526            dtype,
2527            Some(edgefirst_tensor::TensorMemory::Mem),
2528            access,
2529        )?)
2530    }
2531
2532    /// Import an external DMA-BUF image.
2533    ///
2534    /// Each [`PlaneDescriptor`] owns an already-duped fd; this method
2535    /// consumes the descriptors and takes ownership of those fds (whether
2536    /// the call succeeds or fails).
2537    ///
2538    /// The caller must ensure the DMA-BUF allocation is large enough for the
2539    /// specified width, height, format, and any stride/offset on the plane
2540    /// descriptors. No buffer-size validation is performed; an undersized
2541    /// buffer may cause GPU faults or EGL import failure.
2542    ///
2543    /// # Arguments
2544    ///
2545    /// * `image` - Plane descriptor for the primary (or only) plane
2546    /// * `chroma` - Optional plane descriptor for the UV chroma plane
2547    ///   (required for multiplane NV12)
2548    /// * `width` - Image width in pixels
2549    /// * `height` - Image height in pixels
2550    /// * `format` - Pixel format of the buffer
2551    /// * `dtype` - Element data type (e.g. `DType::U8`)
2552    ///
2553    /// # Returns
2554    ///
2555    /// A `TensorDyn` configured as an image.
2556    ///
2557    /// # Errors
2558    ///
2559    /// * [`Error::NotSupported`] if `chroma` is `Some` for a non-semi-planar
2560    ///   format, or multiplane NV16 (not yet supported), or the fd is not
2561    ///   DMA-backed
2562    /// * [`Error::InvalidShape`] if NV12 height is odd
2563    ///
2564    /// # Platform
2565    ///
2566    /// Linux only.
2567    ///
2568    /// # Examples
2569    ///
2570    /// ```rust,ignore
2571    /// use edgefirst_tensor::PlaneDescriptor;
2572    ///
2573    /// // Single-plane RGBA
2574    /// let pd = PlaneDescriptor::new(fd.as_fd())?;
2575    /// let src = proc.import_image(pd, None, 1920, 1080, PixelFormat::Rgba, DType::U8, None)?;
2576    ///
2577    /// // Multi-plane NV12 with stride
2578    /// let y_pd = PlaneDescriptor::new(y_fd.as_fd())?.with_stride(2048);
2579    /// let uv_pd = PlaneDescriptor::new(uv_fd.as_fd())?.with_stride(2048);
2580    /// let src = proc.import_image(y_pd, Some(uv_pd), 1920, 1080,
2581    ///                             PixelFormat::Nv12, DType::U8, None)?;
2582    /// ```
2583    // Import inherently needs plane(s) + geometry + format + dtype + colorimetry;
2584    // a params struct would obscure more than it clarifies here.
2585    #[allow(clippy::too_many_arguments)]
2586    #[cfg(target_os = "linux")]
2587    pub fn import_image(
2588        &self,
2589        image: edgefirst_tensor::PlaneDescriptor,
2590        chroma: Option<edgefirst_tensor::PlaneDescriptor>,
2591        width: usize,
2592        height: usize,
2593        format: PixelFormat,
2594        dtype: DType,
2595        colorimetry: Option<edgefirst_tensor::Colorimetry>,
2596    ) -> Result<TensorDyn> {
2597        use edgefirst_tensor::{Tensor, TensorMemory};
2598
2599        // Capture stride/offset from descriptors before consuming them
2600        let image_stride = image.stride();
2601        let image_offset = image.offset();
2602        let chroma_stride = chroma.as_ref().and_then(|c| c.stride());
2603        let chroma_offset = chroma.as_ref().and_then(|c| c.offset());
2604
2605        if let Some(chroma_pd) = chroma {
2606            // ── Multiplane path ──────────────────────────────────────
2607            // Multiplane tensors are backed by Tensor<u8> (or transmuted to
2608            // Tensor<i8>). Reject other dtypes to avoid silently returning a
2609            // tensor with the wrong element type.
2610            if dtype != DType::U8 && dtype != DType::I8 {
2611                return Err(Error::NotSupported(format!(
2612                    "multiplane import only supports U8/I8, got {dtype:?}"
2613                )));
2614            }
2615            if format.layout() != PixelLayout::SemiPlanar {
2616                return Err(Error::NotSupported(format!(
2617                    "import_image with chroma requires a semi-planar format, got {format:?}"
2618                )));
2619            }
2620
2621            let chroma_h = match format {
2622                PixelFormat::Nv12 => {
2623                    // NV12 (4:2:0): ceil(H/2) chroma rows — odd heights are valid.
2624                    height.div_ceil(2)
2625                }
2626                // NV16 multiplane will be supported in a future release;
2627                // the GL backend currently only handles NV12 plane1 attributes.
2628                PixelFormat::Nv16 => {
2629                    return Err(Error::NotSupported(
2630                        "multiplane NV16 is not yet supported; use contiguous NV16 instead".into(),
2631                    ))
2632                }
2633                _ => {
2634                    return Err(Error::NotSupported(format!(
2635                        "unsupported semi-planar format: {format:?}"
2636                    )))
2637                }
2638            };
2639
2640            let luma = Tensor::<u8>::from_fd(image.into_fd(), &[height, width], Some("luma"))?;
2641            if luma.memory() != TensorMemory::DmaBuf {
2642                return Err(Error::NotSupported(format!(
2643                    "luma fd must be DMA-backed, got {:?}",
2644                    luma.memory()
2645                )));
2646            }
2647
2648            let mut chroma_tensor =
2649                Tensor::<u8>::from_fd(chroma_pd.into_fd(), &[chroma_h, width], Some("chroma"))?;
2650            if chroma_tensor.memory() != TensorMemory::DmaBuf {
2651                return Err(Error::NotSupported(format!(
2652                    "chroma fd must be DMA-backed, got {:?}",
2653                    chroma_tensor.memory()
2654                )));
2655            }
2656
2657            // Geometry on the chroma plane must be set before from_planes
2658            // consumes it: the combined handle answers chroma() from the
2659            // library, and there is no chroma_mut() on the dynamic lens.
2660            if let Some(s) = chroma_stride {
2661                if s < width {
2662                    return Err(Error::InvalidShape(format!(
2663                        "chroma stride {s} < minimum {width} for {format:?}"
2664                    )));
2665                }
2666                chroma_tensor.set_row_stride_unchecked(s);
2667            }
2668            if let Some(o) = chroma_offset {
2669                chroma_tensor.set_plane_offset(o);
2670            }
2671
2672            // from_planes creates the combined tensor with format set,
2673            // preserving luma's row_stride (currently None since luma was raw).
2674            let mut tensor = Tensor::<u8>::from_planes(luma, chroma_tensor, format)?;
2675
2676            // Apply stride/offset to the combined tensor (luma plane)
2677            if let Some(s) = image_stride {
2678                tensor.set_row_stride(s)?;
2679            }
2680            if let Some(o) = image_offset {
2681                tensor.set_plane_offset(o);
2682            }
2683
2684            if dtype == DType::I8 {
2685                // SAFETY: Tensor<u8> and Tensor<i8> have identical layout because
2686                // the struct contains only type-erased storage (OwnedFd, shape, name),
2687                // no inline T values. This assertion catches layout drift at compile time.
2688                const {
2689                    assert!(std::mem::size_of::<Tensor<u8>>() == std::mem::size_of::<Tensor<i8>>());
2690                    assert!(
2691                        std::mem::align_of::<Tensor<u8>>() == std::mem::align_of::<Tensor<i8>>()
2692                    );
2693                }
2694                let tensor_i8: Tensor<i8> = unsafe { std::mem::transmute(tensor) };
2695                let mut dyn_tensor = TensorDyn::from(tensor_i8);
2696                dyn_tensor.set_colorimetry(colorimetry);
2697                return Ok(dyn_tensor);
2698            }
2699            let mut dyn_tensor = TensorDyn::from(tensor);
2700            dyn_tensor.set_colorimetry(colorimetry);
2701            Ok(dyn_tensor)
2702        } else {
2703            // ── Single-plane path ────────────────────────────────────
2704            // Canonical shape (Packed [H,W,C] / Planar [C,H,W] / SemiPlanar
2705            // [total_h, W]); `allocation_shape` supports NV12/NV16/NV24 (the old
2706            // hand-rolled match erroneously rejected NV24).
2707            let shape = format.allocation_shape(width, height).ok_or_else(|| {
2708                Error::NotSupported(format!(
2709                    "unsupported pixel format for import_image: {format:?}"
2710                ))
2711            })?;
2712            let tensor = TensorDyn::from_fd(image.into_fd(), &shape, dtype, None)?;
2713            if tensor.memory() != TensorMemory::DmaBuf {
2714                return Err(Error::NotSupported(format!(
2715                    "import_image requires DMA-backed fd, got {:?}",
2716                    tensor.memory()
2717                )));
2718            }
2719            let mut tensor = tensor.with_format(format)?;
2720            if let Some(s) = image_stride {
2721                tensor.set_row_stride(s)?;
2722            }
2723            if let Some(o) = image_offset {
2724                tensor.set_plane_offset(o);
2725            }
2726            tensor.set_colorimetry(colorimetry);
2727            Ok(tensor)
2728        }
2729    }
2730
2731    /// Decode model outputs and draw segmentation masks onto `dst`.
2732    ///
2733    /// The processor decodes via the provided
2734    /// [`Decoder`](edgefirst_decoder::Decoder), selects the optimal rendering
2735    /// path (hybrid CPU+GL or fused GPU), and composites masks onto `dst`.
2736    ///
2737    /// Returns the detected bounding boxes.
2738    ///
2739    /// **Requires the `decode` feature**, which is off by default because it
2740    /// is the only thing in this crate that needs `edgefirst-decoder`. This
2741    /// method sequences a decode and a draw; both halves are available
2742    /// unconditionally and are equally fused, so without the feature write:
2743    ///
2744    /// ```ignore
2745    /// let mut boxes = Vec::new();
2746    /// match decoder.decode_proto(outputs, &mut boxes)? {
2747    ///     Some(protos) => proc.draw_proto_masks(dst, &boxes, &protos, overlay)?,
2748    ///     None => {
2749    ///         let mut masks = Vec::new();
2750    ///         decoder.decode(outputs, &mut boxes, &mut masks)?;
2751    ///         proc.draw_decoded_masks(dst, &boxes, &masks, overlay)?;
2752    ///     }
2753    /// }
2754    /// ```
2755    #[cfg(feature = "decode")]
2756    pub fn draw_masks(
2757        &mut self,
2758        decoder: &edgefirst_decoder::Decoder,
2759        outputs: &[&TensorDyn],
2760        dst: &mut TensorDyn,
2761        overlay: MaskOverlay<'_>,
2762    ) -> Result<Vec<DetectBox>> {
2763        let mut output_boxes = Vec::with_capacity(100);
2764
2765        // Try proto path first (fused rendering without materializing masks)
2766        let proto_result = decoder
2767            .decode_proto(outputs, &mut output_boxes)
2768            .map_err(|e| Error::Internal(format!("decode_proto: {e:#?}")))?;
2769
2770        if let Some(proto_data) = proto_result {
2771            self.draw_proto_masks(dst, &output_boxes, &proto_data, overlay)?;
2772        } else {
2773            // Detection-only or unsupported model: full decode + render
2774            let mut output_masks = Vec::with_capacity(100);
2775            decoder
2776                .decode(outputs, &mut output_boxes, &mut output_masks)
2777                .map_err(|e| Error::Internal(format!("decode: {e:#?}")))?;
2778            self.draw_decoded_masks(dst, &output_boxes, &output_masks, overlay)?;
2779        }
2780        Ok(output_boxes)
2781    }
2782
2783    /// Decode tracked model outputs and draw segmentation masks onto `dst`.
2784    ///
2785    /// Like [`draw_masks`](Self::draw_masks) but integrates a tracker for
2786    /// maintaining object identities across frames. The tracker runs after
2787    /// NMS but before mask extraction.
2788    ///
2789    /// Returns detected boxes and track info.
2790    #[cfg(feature = "tracker")]
2791    pub fn draw_masks_tracked<TR: edgefirst_tracker::Tracker<DetectBox>>(
2792        &mut self,
2793        decoder: &edgefirst_decoder::Decoder,
2794        tracker: &mut TR,
2795        timestamp: u64,
2796        outputs: &[&TensorDyn],
2797        dst: &mut TensorDyn,
2798        overlay: MaskOverlay<'_>,
2799    ) -> Result<(Vec<DetectBox>, Vec<edgefirst_tracker::TrackInfo>)> {
2800        let mut output_boxes = Vec::with_capacity(100);
2801        let mut output_tracks = Vec::new();
2802
2803        let proto_result = decoder
2804            .decode_proto_tracked(
2805                tracker,
2806                timestamp,
2807                outputs,
2808                &mut output_boxes,
2809                &mut output_tracks,
2810            )
2811            .map_err(|e| Error::Internal(format!("decode_proto_tracked: {e:#?}")))?;
2812
2813        if let Some(proto_data) = proto_result {
2814            self.draw_proto_masks(dst, &output_boxes, &proto_data, overlay)?;
2815        } else {
2816            // Note: decode_proto_tracked returns None for detection-only/ModelPack
2817            // models WITHOUT calling the tracker. The else branch below is the
2818            // first (and only) tracker call for those model types.
2819            let mut output_masks = Vec::with_capacity(100);
2820            decoder
2821                .decode_tracked(
2822                    tracker,
2823                    timestamp,
2824                    outputs,
2825                    &mut output_boxes,
2826                    &mut output_masks,
2827                    &mut output_tracks,
2828                )
2829                .map_err(|e| Error::Internal(format!("decode_tracked: {e:#?}")))?;
2830            self.draw_decoded_masks(dst, &output_boxes, &output_masks, overlay)?;
2831        }
2832        Ok((output_boxes, output_tracks))
2833    }
2834
2835    /// Materialize per-instance segmentation masks from raw prototype data.
2836    ///
2837    /// Computes `mask_coeff @ protos` with sigmoid activation for each detection,
2838    /// producing compact masks at prototype resolution (e.g., 160×160 crops).
2839    /// Mask values are continuous sigmoid confidence outputs quantized to u8
2840    /// (0 = background, 255 = full confidence), NOT binary thresholded.
2841    ///
2842    /// The returned [`Vec<Segmentation>`] can be:
2843    /// - Inspected or exported for analytics, IoU computation, etc.
2844    /// - Passed directly to [`ImageProcessorTrait::draw_decoded_masks`] for
2845    ///   GPU-interpolated rendering.
2846    ///
2847    /// # Performance Note
2848    ///
2849    /// Calling `materialize_masks` + `draw_decoded_masks` separately prevents
2850    /// the HAL from using its internal fused optimization path. For render-only
2851    /// use cases, prefer [`ImageProcessorTrait::draw_proto_masks`] which selects
2852    /// the fastest path automatically (currently 1.6×–27× faster on tested
2853    /// platforms). Use this method when you need access to the intermediate masks.
2854    ///
2855    /// # Errors
2856    ///
2857    /// Returns [`Error::NoConverter`] if the CPU backend is not available.
2858    pub fn materialize_masks(
2859        &mut self,
2860        detect: &[DetectBox],
2861        proto_data: &ProtoData,
2862        letterbox: Option<[f32; 4]>,
2863        resolution: MaskResolution,
2864    ) -> Result<Vec<Segmentation>> {
2865        let cpu = self.cpu.as_mut().ok_or(Error::NoConverter)?;
2866        match resolution {
2867            MaskResolution::Proto => cpu.materialize_segmentations(detect, proto_data, letterbox),
2868            MaskResolution::Scaled { width, height } => {
2869                cpu.materialize_scaled_segmentations(detect, proto_data, letterbox, width, height)
2870            }
2871        }
2872    }
2873}
2874
2875impl ImageProcessorTrait for ImageProcessor {
2876    /// Converts the source image to the destination image format and size. The
2877    /// image is cropped first, then flipped, then rotated
2878    ///
2879    /// Prefer hardware accelerators when available, falling back to CPU if
2880    /// necessary.
2881    fn convert(
2882        &mut self,
2883        src: &TensorDyn,
2884        dst: &mut TensorDyn,
2885        rotation: Rotation,
2886        flip: Flip,
2887        crop: Crop,
2888    ) -> Result<()> {
2889        let start = Instant::now();
2890        let src_fmt = src.format();
2891        let dst_fmt = dst.format();
2892        let _span = tracing::trace_span!(
2893            "image.convert",
2894            ?src_fmt,
2895            ?dst_fmt,
2896            src_memory = ?src.memory(),
2897            dst_memory = ?dst.memory(),
2898            ?rotation,
2899            ?flip,
2900        )
2901        .entered();
2902        log::trace!(
2903            "convert: {src_fmt:?}({:?}/{:?}) → {dst_fmt:?}({:?}/{:?}), \
2904             rotation={rotation:?}, flip={flip:?}, backend={:?}",
2905            src.dtype(),
2906            src.memory(),
2907            dst.dtype(),
2908            dst.memory(),
2909            self.forced_backend,
2910        );
2911
2912        // ── Forced backend: no fallback chain ────────────────────────
2913        if let Some(forced) = self.forced_backend {
2914            return match forced {
2915                ForcedBackend::Cpu => {
2916                    if let Some(cpu) = self.cpu.as_mut() {
2917                        let r = cpu.convert(src, dst, rotation, flip, crop);
2918                        log::trace!(
2919                            "convert: forced=cpu result={} ({:?})",
2920                            if r.is_ok() { "ok" } else { "err" },
2921                            start.elapsed()
2922                        );
2923                        return r;
2924                    }
2925                    Err(Error::ForcedBackendUnavailable("cpu".into()))
2926                }
2927                ForcedBackend::G2d => {
2928                    #[cfg(target_os = "linux")]
2929                    if let Some(g2d) = self.g2d.as_mut() {
2930                        let r = g2d.convert(src, dst, rotation, flip, crop);
2931                        log::trace!(
2932                            "convert: forced=g2d result={} ({:?})",
2933                            if r.is_ok() { "ok" } else { "err" },
2934                            start.elapsed()
2935                        );
2936                        return r;
2937                    }
2938                    Err(Error::ForcedBackendUnavailable("g2d".into()))
2939                }
2940                ForcedBackend::OpenGl => {
2941                    #[cfg(any(
2942                        target_os = "linux",
2943                        target_os = "macos",
2944                        target_os = "ios",
2945                        target_os = "android",
2946                        target_os = "windows"
2947                    ))]
2948                    #[cfg(feature = "opengl")]
2949                    if let Some(opengl) = self.opengl.as_mut() {
2950                        let r = opengl.convert(src, dst, rotation, flip, crop);
2951                        log::trace!(
2952                            "convert: forced=opengl result={} ({:?})",
2953                            if r.is_ok() { "ok" } else { "err" },
2954                            start.elapsed()
2955                        );
2956                        return r;
2957                    }
2958                    Err(Error::ForcedBackendUnavailable("opengl".into()))
2959                }
2960            };
2961        }
2962
2963        // ── Auto fallback chain: OpenGL → G2D → CPU ──────────────────
2964        #[cfg(any(
2965            target_os = "linux",
2966            target_os = "macos",
2967            target_os = "ios",
2968            target_os = "android",
2969            target_os = "windows"
2970        ))]
2971        #[cfg(feature = "opengl")]
2972        if let Some(opengl) = self.opengl.as_mut() {
2973            match opengl.convert(src, dst, rotation, flip, crop) {
2974                Ok(_) => {
2975                    log::trace!(
2976                        "convert: auto selected=opengl for {src_fmt:?}→{dst_fmt:?} ({:?})",
2977                        start.elapsed()
2978                    );
2979                    return Ok(());
2980                }
2981                Err(e) => {
2982                    self.convert_fallbacks
2983                        .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2984                    log::debug!(
2985                        "convert: auto opengl declined {src_fmt:?}@{:?}→{dst_fmt:?}@{:?}, \
2986                         falling back toward G2D/CPU: {e}",
2987                        src.memory(),
2988                        dst.memory(),
2989                    );
2990                }
2991            }
2992        }
2993
2994        #[cfg(target_os = "linux")]
2995        if let Some(g2d) = self.g2d.as_mut() {
2996            // G2D is matrix-only (no range control, no BT.2020). For any
2997            // conversion with a YUV side, resolve that side's colorimetry and
2998            // skip G2D entirely when it cannot be expressed (full-range YUV or
2999            // BT.2020), letting the chain fall through to GL/CPU which honour
3000            // range and BT.2020. YUV→RGB uses the source colorimetry; RGB→YUV
3001            // uses the destination. RGB→RGB has no YUV side and is unaffected.
3002            let src_is_yuv = src.format().is_some_and(|f| f.is_yuv());
3003            let dst_is_yuv = dst.format().is_some_and(|f| f.is_yuv());
3004            let g2d_eligible = if src_is_yuv || dst_is_yuv {
3005                let cm = if src_is_yuv {
3006                    crate::colorimetry::effective_colorimetry(src)
3007                } else {
3008                    crate::colorimetry::effective_colorimetry(dst)
3009                };
3010                crate::g2d::g2d_can_handle(&cm, true)
3011            } else {
3012                true
3013            };
3014            if !g2d_eligible {
3015                log::trace!(
3016                    "convert: auto g2d skipped {src_fmt:?}→{dst_fmt:?} \
3017                     (colorimetry not expressible: full-range/BT.2020)"
3018                );
3019            } else {
3020                match g2d.convert(src, dst, rotation, flip, crop) {
3021                    Ok(_) => {
3022                        log::trace!(
3023                            "convert: auto selected=g2d for {src_fmt:?}→{dst_fmt:?} ({:?})",
3024                            start.elapsed()
3025                        );
3026                        return Ok(());
3027                    }
3028                    Err(e) => {
3029                        log::trace!("convert: auto g2d declined {src_fmt:?}→{dst_fmt:?}: {e}");
3030                    }
3031                }
3032            }
3033        }
3034
3035        if let Some(cpu) = self.cpu.as_mut() {
3036            match cpu.convert(src, dst, rotation, flip, crop) {
3037                Ok(_) => {
3038                    log::trace!(
3039                        "convert: auto selected=cpu for {src_fmt:?}→{dst_fmt:?} ({:?})",
3040                        start.elapsed()
3041                    );
3042                    return Ok(());
3043                }
3044                Err(e) => {
3045                    log::trace!("convert: auto cpu failed {src_fmt:?}→{dst_fmt:?}: {e}");
3046                    return Err(e);
3047                }
3048            }
3049        }
3050        Err(Error::NoConverter)
3051    }
3052
3053    fn convert_deferred(
3054        &mut self,
3055        src: &TensorDyn,
3056        dst: &mut TensorDyn,
3057        rotation: Rotation,
3058        flip: Flip,
3059        crop: Crop,
3060    ) -> Result<()> {
3061        // Deferred batching is an OpenGL optimization (shared parent EGLImage +
3062        // no per-tile glFinish). Route to the GL backend's deferred path when GL
3063        // is forced or auto-selectable; on a GL decline fall back to an eager
3064        // convert (the auto chain), which is correct everywhere — it completes
3065        // synchronously and `flush` stays a no-op for non-GL backends.
3066        #[cfg(any(
3067            target_os = "linux",
3068            target_os = "macos",
3069            target_os = "ios",
3070            target_os = "android",
3071            target_os = "windows"
3072        ))]
3073        #[cfg(feature = "opengl")]
3074        {
3075            let gl_forced = matches!(self.forced_backend, Some(ForcedBackend::OpenGl));
3076            if gl_forced || self.forced_backend.is_none() {
3077                if let Some(opengl) = self.opengl.as_mut() {
3078                    match opengl.convert_deferred(src, dst, rotation, flip, crop) {
3079                        Ok(()) => return Ok(()),
3080                        Err(e) => {
3081                            log::trace!("convert_deferred: gl declined: {e}; eager fallback");
3082                            // A forced-GL caller gets the GL error, matching
3083                            // `convert`'s no-fallback forced-backend contract.
3084                            if gl_forced {
3085                                return Err(e);
3086                            }
3087                        }
3088                    }
3089                }
3090            }
3091        }
3092        self.convert(src, dst, rotation, flip, crop)
3093    }
3094
3095    fn flush(&mut self) -> Result<()> {
3096        let _span = tracing::trace_span!("image.flush").entered();
3097        // Only the OpenGL backend defers; flushing it issues the single GPU
3098        // sync. CPU/G2D converts already completed, so there is nothing to flush.
3099        #[cfg(any(
3100            target_os = "linux",
3101            target_os = "macos",
3102            target_os = "ios",
3103            target_os = "android",
3104            target_os = "windows"
3105        ))]
3106        #[cfg(feature = "opengl")]
3107        if let Some(opengl) = self.opengl.as_mut() {
3108            return opengl.flush();
3109        }
3110        Ok(())
3111    }
3112
3113    fn draw_decoded_masks(
3114        &mut self,
3115        dst: &mut TensorDyn,
3116        detect: &[DetectBox],
3117        segmentation: &[Segmentation],
3118        overlay: MaskOverlay<'_>,
3119    ) -> Result<()> {
3120        let _span = tracing::trace_span!(
3121            "image.draw_decoded_masks",
3122            n_detections = detect.len(),
3123            n_segmentations = segmentation.len(),
3124        )
3125        .entered();
3126        let start = Instant::now();
3127
3128        if let Some(bg) = overlay.background {
3129            if bg.aliases(dst) {
3130                return Err(Error::AliasedBuffers(
3131                    "background must not reference the same buffer as dst".to_string(),
3132                ));
3133            }
3134        }
3135
3136        // Un-letterbox detect boxes and segmentation bboxes for rendering when
3137        // a letterbox was applied to prepare the model input.
3138        let lb_boxes: Vec<DetectBox>;
3139        let lb_segs: Vec<Segmentation>;
3140        let (detect, segmentation) = if let Some(lb) = overlay.letterbox {
3141            lb_boxes = detect.iter().map(|&d| unletter_bbox(d, lb)).collect();
3142            // Keep segmentation bboxes in sync with the transformed detect boxes
3143            // when we have a 1:1 correspondence (instance segmentation).
3144            if segmentation.len() == lb_boxes.len() {
3145                // Only the four bounds change; the mask bytes are copied
3146                // wholesale because `TensorDyn` is not `Clone`. The memcpy is
3147                // what `Array3<u8>::clone()` already paid; `copy_segmentation`
3148                // pins the allocation to Mem so the ALLOCATOR cost matches too
3149                // (see there). Rendering from borrowed masks plus a separate
3150                // bounds list would avoid the copy entirely.
3151                lb_segs = segmentation
3152                    .iter()
3153                    .zip(lb_boxes.iter())
3154                    .map(|(s, d)| copy_segmentation(s, d.bbox.into()))
3155                    .collect::<Result<Vec<_>>>()?;
3156                (lb_boxes.as_slice(), lb_segs.as_slice())
3157            } else {
3158                // No 1:1 correspondence, so the segmentation bounds are left
3159                // as they are -- borrow rather than the copy `to_vec()` used
3160                // to make.
3161                (lb_boxes.as_slice(), segmentation)
3162            }
3163        } else {
3164            (detect, segmentation)
3165        };
3166        #[cfg(target_os = "linux")]
3167        let is_empty_frame = detect.is_empty() && segmentation.is_empty();
3168
3169        // ── Forced backend: no fallback chain ────────────────────────
3170        if let Some(forced) = self.forced_backend {
3171            return match forced {
3172                ForcedBackend::Cpu => {
3173                    if let Some(cpu) = self.cpu.as_mut() {
3174                        return cpu.draw_decoded_masks(dst, detect, segmentation, overlay);
3175                    }
3176                    Err(Error::ForcedBackendUnavailable("cpu".into()))
3177                }
3178                ForcedBackend::G2d => {
3179                    // G2D can only produce empty frames (clear / bg blit).
3180                    // For populated frames it has no rasterizer — fail loudly.
3181                    #[cfg(target_os = "linux")]
3182                    if let Some(g2d) = self.g2d.as_mut() {
3183                        return g2d.draw_decoded_masks(dst, detect, segmentation, overlay);
3184                    }
3185                    Err(Error::ForcedBackendUnavailable("g2d".into()))
3186                }
3187                ForcedBackend::OpenGl => {
3188                    // GL handles background natively via GPU blit, and now
3189                    // actively clears when there is no background.
3190                    #[cfg(any(
3191                        target_os = "linux",
3192                        target_os = "macos",
3193                        target_os = "ios",
3194                        target_os = "android",
3195                        target_os = "windows"
3196                    ))]
3197                    #[cfg(feature = "opengl")]
3198                    if let Some(opengl) = self.opengl.as_mut() {
3199                        return opengl.draw_decoded_masks(dst, detect, segmentation, overlay);
3200                    }
3201                    Err(Error::ForcedBackendUnavailable("opengl".into()))
3202                }
3203            };
3204        }
3205
3206        // ── Auto dispatch ──────────────────────────────────────────
3207        // Empty frames prefer G2D when available — a single g2d_clear or
3208        // g2d_blit is the cheapest HW path to produce the correct output
3209        // and avoids spinning up the GL pipeline every zero-detection
3210        // frame in a triple-buffered display loop.
3211        #[cfg(target_os = "linux")]
3212        if is_empty_frame {
3213            if let Some(g2d) = self.g2d.as_mut() {
3214                match g2d.draw_decoded_masks(dst, detect, segmentation, overlay) {
3215                    Ok(_) => {
3216                        log::trace!(
3217                            "draw_decoded_masks empty frame via g2d in {:?}",
3218                            start.elapsed()
3219                        );
3220                        return Ok(());
3221                    }
3222                    Err(e) => log::trace!("g2d empty-frame path unavailable: {e:?}"),
3223                }
3224            }
3225        }
3226
3227        // Populated frames (or G2D unavailable): GL first, CPU fallback.
3228        // Both backends now own their own base-layer handling (bg blit
3229        // or clear), so we hand the overlay through untouched.
3230        #[cfg(any(
3231            target_os = "linux",
3232            target_os = "macos",
3233            target_os = "ios",
3234            target_os = "android",
3235            target_os = "windows"
3236        ))]
3237        #[cfg(feature = "opengl")]
3238        if let Some(opengl) = self.opengl.as_mut() {
3239            log::trace!(
3240                "draw_decoded_masks started with opengl in {:?}",
3241                start.elapsed()
3242            );
3243            match opengl.draw_decoded_masks(dst, detect, segmentation, overlay) {
3244                Ok(_) => {
3245                    log::trace!("draw_decoded_masks with opengl in {:?}", start.elapsed());
3246                    return Ok(());
3247                }
3248                Err(e) => {
3249                    log::trace!("draw_decoded_masks didn't work with opengl: {e:?}")
3250                }
3251            }
3252        }
3253
3254        log::trace!(
3255            "draw_decoded_masks started with cpu in {:?}",
3256            start.elapsed()
3257        );
3258        if let Some(cpu) = self.cpu.as_mut() {
3259            match cpu.draw_decoded_masks(dst, detect, segmentation, overlay) {
3260                Ok(_) => {
3261                    log::trace!("draw_decoded_masks with cpu in {:?}", start.elapsed());
3262                    return Ok(());
3263                }
3264                Err(e) => {
3265                    log::trace!("draw_decoded_masks didn't work with cpu: {e:?}");
3266                    return Err(e);
3267                }
3268            }
3269        }
3270        Err(Error::NoConverter)
3271    }
3272
3273    fn draw_proto_masks(
3274        &mut self,
3275        dst: &mut TensorDyn,
3276        detect: &[DetectBox],
3277        proto_data: &ProtoData,
3278        overlay: MaskOverlay<'_>,
3279    ) -> Result<()> {
3280        let start = Instant::now();
3281
3282        if let Some(bg) = overlay.background {
3283            if bg.aliases(dst) {
3284                return Err(Error::AliasedBuffers(
3285                    "background must not reference the same buffer as dst".to_string(),
3286                ));
3287            }
3288        }
3289
3290        // Un-letterbox detect boxes for rendering when a letterbox was applied
3291        // to prepare the model input.  The original `detect` coords are still
3292        // passed to `materialize_segmentations` (which needs model-space coords
3293        // to correctly crop the proto tensor) alongside `overlay.letterbox` so
3294        // it can emit `Segmentation` structs in output-image space.
3295        let lb_boxes: Vec<DetectBox>;
3296        let render_detect = if let Some(lb) = overlay.letterbox {
3297            lb_boxes = detect.iter().map(|&d| unletter_bbox(d, lb)).collect();
3298            lb_boxes.as_slice()
3299        } else {
3300            detect
3301        };
3302        #[cfg(target_os = "linux")]
3303        let is_empty_frame = detect.is_empty();
3304
3305        // ── Forced backend: no fallback chain ────────────────────────
3306        if let Some(forced) = self.forced_backend {
3307            return match forced {
3308                ForcedBackend::Cpu => {
3309                    if let Some(cpu) = self.cpu.as_mut() {
3310                        return cpu.draw_proto_masks(dst, render_detect, proto_data, overlay);
3311                    }
3312                    Err(Error::ForcedBackendUnavailable("cpu".into()))
3313                }
3314                ForcedBackend::G2d => {
3315                    #[cfg(target_os = "linux")]
3316                    if let Some(g2d) = self.g2d.as_mut() {
3317                        return g2d.draw_proto_masks(dst, render_detect, proto_data, overlay);
3318                    }
3319                    Err(Error::ForcedBackendUnavailable("g2d".into()))
3320                }
3321                ForcedBackend::OpenGl => {
3322                    #[cfg(any(
3323                        target_os = "linux",
3324                        target_os = "macos",
3325                        target_os = "ios",
3326                        target_os = "android",
3327                        target_os = "windows"
3328                    ))]
3329                    #[cfg(feature = "opengl")]
3330                    if let Some(opengl) = self.opengl.as_mut() {
3331                        return opengl.draw_proto_masks(dst, render_detect, proto_data, overlay);
3332                    }
3333                    Err(Error::ForcedBackendUnavailable("opengl".into()))
3334                }
3335            };
3336        }
3337
3338        // ── Auto dispatch ──────────────────────────────────────────
3339        // Empty frames: prefer G2D — cheapest HW path (clear or bg blit).
3340        #[cfg(target_os = "linux")]
3341        if is_empty_frame {
3342            if let Some(g2d) = self.g2d.as_mut() {
3343                match g2d.draw_proto_masks(dst, render_detect, proto_data, overlay) {
3344                    Ok(_) => {
3345                        log::trace!(
3346                            "draw_proto_masks empty frame via g2d in {:?}",
3347                            start.elapsed()
3348                        );
3349                        return Ok(());
3350                    }
3351                    Err(e) => log::trace!("g2d empty-frame path unavailable: {e:?}"),
3352                }
3353            }
3354        }
3355
3356        // Hybrid path: CPU materialize + GL overlay (benchmarked faster than
3357        // full-GPU draw_proto_masks on all tested platforms: 27× on imx8mp,
3358        // 4× on imx95, 2.5× on rpi5, 1.6× on x86).
3359        // GL owns its own bg-blit / glClear — we pass the overlay through.
3360        //
3361        // CPU materialize needs `&mut` for its MaskScratch buffers; GL also
3362        // needs `&mut`. The CPU borrow is scoped to its block so the
3363        // subsequent GL borrow is free to take over `self`.
3364        #[cfg(any(
3365            target_os = "linux",
3366            target_os = "macos",
3367            target_os = "ios",
3368            target_os = "android",
3369            target_os = "windows"
3370        ))]
3371        #[cfg(feature = "opengl")]
3372        if let (Some(_), Some(_)) = (self.cpu.as_ref(), self.opengl.as_ref()) {
3373            let segmentation = match self.cpu.as_mut() {
3374                Some(cpu) => {
3375                    log::trace!(
3376                        "draw_proto_masks started with hybrid (cpu+opengl) in {:?}",
3377                        start.elapsed()
3378                    );
3379                    cpu.materialize_segmentations(detect, proto_data, overlay.letterbox)?
3380                }
3381                None => unreachable!("cpu presence checked above"),
3382            };
3383            if let Some(opengl) = self.opengl.as_mut() {
3384                match opengl.draw_decoded_masks(dst, render_detect, &segmentation, overlay) {
3385                    Ok(_) => {
3386                        log::trace!(
3387                            "draw_proto_masks with hybrid (cpu+opengl) in {:?}",
3388                            start.elapsed()
3389                        );
3390                        return Ok(());
3391                    }
3392                    Err(e) => {
3393                        log::trace!(
3394                            "draw_proto_masks hybrid path failed, falling back to cpu: {e:?}"
3395                        );
3396                    }
3397                }
3398            }
3399        }
3400
3401        let Some(cpu) = self.cpu.as_mut() else {
3402            return Err(Error::Internal(
3403                "draw_proto_masks requires CPU backend for fallback path".into(),
3404            ));
3405        };
3406        log::trace!("draw_proto_masks started with cpu in {:?}", start.elapsed());
3407        cpu.draw_proto_masks(dst, render_detect, proto_data, overlay)
3408    }
3409
3410    fn set_class_colors(&mut self, colors: &[[u8; 4]]) -> Result<()> {
3411        let start = Instant::now();
3412
3413        // ── Forced backend: no fallback chain ────────────────────────
3414        if let Some(forced) = self.forced_backend {
3415            return match forced {
3416                ForcedBackend::Cpu => {
3417                    if let Some(cpu) = self.cpu.as_mut() {
3418                        return cpu.set_class_colors(colors);
3419                    }
3420                    Err(Error::ForcedBackendUnavailable("cpu".into()))
3421                }
3422                ForcedBackend::G2d => Err(Error::NotSupported(
3423                    "g2d does not support set_class_colors".into(),
3424                )),
3425                ForcedBackend::OpenGl => {
3426                    #[cfg(any(
3427                        target_os = "linux",
3428                        target_os = "macos",
3429                        target_os = "ios",
3430                        target_os = "android",
3431                        target_os = "windows"
3432                    ))]
3433                    #[cfg(feature = "opengl")]
3434                    if let Some(opengl) = self.opengl.as_mut() {
3435                        return opengl.set_class_colors(colors);
3436                    }
3437                    Err(Error::ForcedBackendUnavailable("opengl".into()))
3438                }
3439            };
3440        }
3441
3442        // skip G2D as it doesn't support rendering to image
3443
3444        // Every available backend, not the first that accepts. A palette is
3445        // state a later draw reads, and which backend that draw lands on is
3446        // not known here: GL declines a `view()` destination, an unsupported
3447        // format or an import failure, and the CPU backend takes over. A
3448        // backend that never received the colours would render
3449        // `DEFAULT_COLORS_U8` with nothing to show the caller why.
3450        let mut backends = 0usize;
3451        let mut first_err = None;
3452        #[cfg(any(
3453            target_os = "linux",
3454            target_os = "macos",
3455            target_os = "ios",
3456            target_os = "android",
3457            target_os = "windows"
3458        ))]
3459        #[cfg(feature = "opengl")]
3460        if let Some(opengl) = self.opengl.as_mut() {
3461            backends += 1;
3462            match opengl.set_class_colors(colors) {
3463                Ok(()) => log::trace!("colors set with opengl in {:?}", start.elapsed()),
3464                Err(e) => {
3465                    log::trace!("colors didn't set with opengl: {e:?}");
3466                    first_err.get_or_insert(e);
3467                }
3468            }
3469        }
3470        if let Some(cpu) = self.cpu.as_mut() {
3471            backends += 1;
3472            match cpu.set_class_colors(colors) {
3473                Ok(()) => log::trace!("colors set with cpu in {:?}", start.elapsed()),
3474                Err(e) => {
3475                    log::trace!("colors didn't set with cpu: {e:?}");
3476                    first_err.get_or_insert(e);
3477                }
3478            }
3479        }
3480        // The first error, so a GL failure is not hidden by the CPU backend
3481        // accepting afterwards; the colours the other backends took are kept
3482        // either way, since a partial palette beats a stale one.
3483        match first_err {
3484            Some(e) => Err(e),
3485            None if backends > 0 => Ok(()),
3486            None => Err(Error::NoConverter),
3487        }
3488    }
3489}
3490
3491// ---------------------------------------------------------------------------
3492// Image loading / saving helpers
3493// ---------------------------------------------------------------------------
3494
3495/// Test-only convenience helper that peeks the image header, allocates a
3496/// tensor sized to the image (honoring DMA pitch padding on Linux when
3497/// requested), and decodes via [`edgefirst_codec`]. Mirrors the semantics of
3498/// the removed public `load_image` API for test sites; production callers
3499/// should use the explicit peek → allocate → decode pattern directly.
3500#[cfg(all(test, feature = "codec"))]
3501pub(crate) fn load_image_test_helper(
3502    image: &[u8],
3503    format: Option<PixelFormat>,
3504    memory: Option<TensorMemory>,
3505) -> Result<TensorDyn> {
3506    use edgefirst_codec::{peek_info, ImageDecoder, ImageLoad};
3507
3508    // Peek the source header to get its NATIVE format and dimensions. The
3509    // codec now emits the source's native format (JPEG → Nv12/Grey, PNG →
3510    // Rgb/Rgba/Grey) and configures the destination tensor itself.
3511    let info = peek_info(image)?;
3512    let native_fmt = info.format;
3513    let w = info.width;
3514    let h = info.height;
3515
3516    let mut decoder = ImageDecoder::new();
3517
3518    // Decode into a native-format tensor. The decoder sets the tensor's
3519    // dims+format, so we allocate it sized to the native layout.
3520    #[cfg(target_os = "linux")]
3521    let native_src = {
3522        if let Some(aligned_pitch) = padded_dma_pitch_for(native_fmt, w, &memory) {
3523            let mut dma = Tensor::<u8>::image_with_stride(
3524                w,
3525                h,
3526                native_fmt,
3527                aligned_pitch,
3528                Some(TensorMemory::DmaBuf),
3529                edgefirst_tensor::CpuAccess::ReadWrite,
3530            )?;
3531            dma.load_image(&mut decoder, image)?;
3532            TensorDyn::from(dma)
3533        } else {
3534            let mut img = Tensor::<u8>::image(
3535                w,
3536                h,
3537                native_fmt,
3538                memory,
3539                edgefirst_tensor::CpuAccess::ReadWrite,
3540            )?;
3541            img.load_image(&mut decoder, image)?;
3542            TensorDyn::from(img)
3543        }
3544    };
3545    #[cfg(not(target_os = "linux"))]
3546    let native_src = {
3547        let mut img = Tensor::<u8>::image(
3548            w,
3549            h,
3550            native_fmt,
3551            memory,
3552            edgefirst_tensor::CpuAccess::ReadWrite,
3553        )?;
3554        img.load_image(&mut decoder, image)?;
3555        TensorDyn::from(img)
3556    };
3557
3558    // If the caller requested a different format, convert into it (same
3559    // dims) using a headless CPU-backed processor so the helper works
3560    // without GPU/G2D hardware.
3561    match format {
3562        Some(f) if f != native_fmt => {
3563            let mut dst = TensorDyn::image(
3564                w,
3565                h,
3566                f,
3567                DType::U8,
3568                memory,
3569                edgefirst_tensor::CpuAccess::ReadWrite,
3570            )?;
3571            // `ImageProcessorConfig` has platform-specific fields: on Linux it
3572            // carries extra GL/G2D options so `..Default::default()` is needed,
3573            // but on macOS `backend` is the only field, making the update
3574            // redundant (clippy::needless_update). Allow it for cross-platform
3575            // parity — the alternative (field reassign) trips
3576            // clippy::field_reassign_with_default on Linux instead.
3577            #[allow(clippy::needless_update)]
3578            let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
3579                backend: ComputeBackend::Cpu,
3580                ..Default::default()
3581            })?;
3582            proc.convert(
3583                &native_src,
3584                &mut dst,
3585                Rotation::None,
3586                Flip::None,
3587                Crop::default(),
3588            )?;
3589            Ok(dst)
3590        }
3591        _ => Ok(native_src),
3592    }
3593}
3594
3595/// Save a [`TensorDyn`] image as a JPEG file.
3596///
3597/// Only packed RGB and RGBA formats are supported.
3598pub fn save_jpeg(tensor: &TensorDyn, path: impl AsRef<std::path::Path>, quality: u8) -> Result<()> {
3599    let t = tensor.as_typed::<u8>().ok_or(Error::UnsupportedFormat(
3600        "save_jpeg requires u8 tensor".to_string(),
3601    ))?;
3602    let fmt = t.format().ok_or(Error::NotAnImage)?;
3603    if fmt.layout() != PixelLayout::Packed {
3604        return Err(Error::NotImplemented(
3605            "Saving planar images is not supported".to_string(),
3606        ));
3607    }
3608
3609    let colour = match fmt {
3610        PixelFormat::Rgb => jpeg_encoder::ColorType::Rgb,
3611        PixelFormat::Rgba => jpeg_encoder::ColorType::Rgba,
3612        _ => {
3613            return Err(Error::NotImplemented(
3614                "Unsupported image format for saving".to_string(),
3615            ));
3616        }
3617    };
3618
3619    let w = t.width().ok_or(Error::NotAnImage)?;
3620    let h = t.height().ok_or(Error::NotAnImage)?;
3621    let encoder = jpeg_encoder::Encoder::new_file(path, quality)?;
3622    let tensor_map = t.map_read()?;
3623
3624    encoder.encode(&tensor_map, w as u16, h as u16, colour)?;
3625
3626    Ok(())
3627}
3628
3629pub(crate) struct FunctionTimer<T: Display> {
3630    name: T,
3631    start: std::time::Instant,
3632}
3633
3634impl<T: Display> FunctionTimer<T> {
3635    pub fn new(name: T) -> Self {
3636        Self {
3637            name,
3638            start: std::time::Instant::now(),
3639        }
3640    }
3641}
3642
3643impl<T: Display> Drop for FunctionTimer<T> {
3644    fn drop(&mut self) {
3645        log::trace!("{} elapsed: {:?}", self.name, self.start.elapsed())
3646    }
3647}
3648
3649const DEFAULT_COLORS: [[f32; 4]; 20] = [
3650    [0., 1., 0., 0.7],
3651    [1., 0.5568628, 0., 0.7],
3652    [0.25882353, 0.15294118, 0.13333333, 0.7],
3653    [0.8, 0.7647059, 0.78039216, 0.7],
3654    [0.3137255, 0.3137255, 0.3137255, 0.7],
3655    [0.1411765, 0.3098039, 0.1215686, 0.7],
3656    [1., 0.95686275, 0.5137255, 0.7],
3657    [0.3529412, 0.32156863, 0., 0.7],
3658    [0.4235294, 0.6235294, 0.6509804, 0.7],
3659    [0.5098039, 0.5098039, 0.7294118, 0.7],
3660    [0.00784314, 0.18823529, 0.29411765, 0.7],
3661    [0.0, 0.2706, 1.0, 0.7],
3662    [0.0, 0.0, 0.0, 0.7],
3663    [0.0, 0.5, 0.0, 0.7],
3664    [1.0, 0.0, 0.0, 0.7],
3665    [0.0, 0.0, 1.0, 0.7],
3666    [1.0, 0.5, 0.5, 0.7],
3667    [0.1333, 0.5451, 0.1333, 0.7],
3668    [0.1176, 0.4118, 0.8235, 0.7],
3669    [1., 1., 1., 0.7],
3670];
3671
3672const fn denorm<const M: usize, const N: usize>(a: [[f32; M]; N]) -> [[u8; M]; N] {
3673    let mut result = [[0; M]; N];
3674    let mut i = 0;
3675    while i < N {
3676        let mut j = 0;
3677        while j < M {
3678            result[i][j] = (a[i][j] * 255.0).round() as u8;
3679            j += 1;
3680        }
3681        i += 1;
3682    }
3683    result
3684}
3685
3686const DEFAULT_COLORS_U8: [[u8; 4]; 20] = denorm(DEFAULT_COLORS);
3687
3688#[cfg(test)]
3689#[cfg_attr(coverage_nightly, coverage(off))]
3690mod alignment_tests {
3691    use super::*;
3692
3693    #[test]
3694    fn align_width_rgba8_common_widths() {
3695        // RGBA8 (bpp=4, lcm(64,4)=64, so width must round to multiple of 16 px).
3696        assert_eq!(align_width_for_gpu_pitch(640, 4), 640); // 2560 byte pitch — already aligned
3697        assert_eq!(align_width_for_gpu_pitch(1280, 4), 1280); // 5120
3698        assert_eq!(align_width_for_gpu_pitch(1920, 4), 1920); // 7680
3699        assert_eq!(align_width_for_gpu_pitch(3840, 4), 3840); // 15360
3700                                                              // crowd.png case from the imx95 investigation:
3701        assert_eq!(align_width_for_gpu_pitch(3004, 4), 3008); // 12016 → 12032
3702        assert_eq!(align_width_for_gpu_pitch(3000, 4), 3008); // 12000 → 12032
3703        assert_eq!(align_width_for_gpu_pitch(17, 4), 32); // 68 → 128
3704        assert_eq!(align_width_for_gpu_pitch(1, 4), 16); // 4 → 64
3705    }
3706
3707    #[test]
3708    fn align_width_rgb888_packed() {
3709        // RGB888 (bpp=3, lcm(64,3)=192, so width must round to multiple of 64 px).
3710        assert_eq!(align_width_for_gpu_pitch(64, 3), 64); // 192 byte pitch
3711        assert_eq!(align_width_for_gpu_pitch(640, 3), 640); // 1920
3712        assert_eq!(align_width_for_gpu_pitch(1, 3), 64); // 3 → 192
3713        assert_eq!(align_width_for_gpu_pitch(65, 3), 128); // 195 → 384
3714                                                           // Verify the rounded width × bpp is a clean multiple of the LCM.
3715        for w in [3004usize, 1281, 100, 17] {
3716            let padded = align_width_for_gpu_pitch(w, 3);
3717            assert!(padded >= w);
3718            assert_eq!((padded * 3) % 64, 0);
3719            assert_eq!((padded * 3) % 3, 0);
3720        }
3721    }
3722
3723    #[test]
3724    fn align_width_grey_u8() {
3725        // Grey (bpp=1, lcm(64,1)=64, so width must round to multiple of 64 px).
3726        assert_eq!(align_width_for_gpu_pitch(64, 1), 64);
3727        assert_eq!(align_width_for_gpu_pitch(640, 1), 640);
3728        assert_eq!(align_width_for_gpu_pitch(1, 1), 64);
3729        assert_eq!(align_width_for_gpu_pitch(65, 1), 128);
3730    }
3731
3732    #[test]
3733    fn align_width_zero_inputs() {
3734        assert_eq!(align_width_for_gpu_pitch(0, 4), 0);
3735        assert_eq!(align_width_for_gpu_pitch(640, 0), 640);
3736    }
3737
3738    #[test]
3739    fn align_width_never_returns_smaller_than_input() {
3740        // Spot-check the "returned width >= input width" contract across a
3741        // range of values that would previously have hit `width * bpp`
3742        // overflow paths.
3743        for &bpp in &[1usize, 2, 3, 4, 8] {
3744            for &w in &[
3745                1usize,
3746                17,
3747                64,
3748                65,
3749                100,
3750                1280,
3751                1281,
3752                1920,
3753                3004,
3754                3072,
3755                3840,
3756                usize::MAX / 8,
3757                usize::MAX / 4,
3758                usize::MAX / 2,
3759                usize::MAX - 1,
3760                usize::MAX,
3761            ] {
3762                let aligned = align_width_for_gpu_pitch(w, bpp);
3763                assert!(
3764                    aligned >= w,
3765                    "align_width_for_gpu_pitch({w}, {bpp}) = {aligned} < {w}"
3766                );
3767            }
3768        }
3769    }
3770
3771    #[test]
3772    fn align_width_overflow_returns_unaligned_not_smaller() {
3773        // For width values close to usize::MAX, padding up would wrap. The
3774        // function must return the original width rather than wrapping or
3775        // panicking. A pre-aligned width round-trips unchanged even at the
3776        // extreme.
3777        let aligned_extreme = usize::MAX - 15; // 16-pixel boundary for RGBA8
3778        assert_eq!(
3779            align_width_for_gpu_pitch(aligned_extreme, 4),
3780            aligned_extreme
3781        );
3782        // A misaligned extreme value cannot be rounded up — the function
3783        // returns the original.
3784        let misaligned_extreme = usize::MAX - 1;
3785        let result = align_width_for_gpu_pitch(misaligned_extreme, 4);
3786        assert!(
3787            result == misaligned_extreme || result >= misaligned_extreme,
3788            "extreme misaligned width must not be rounded down to {result}"
3789        );
3790    }
3791
3792    #[test]
3793    fn checked_lcm_basic_and_overflow() {
3794        assert_eq!(checked_num_integer_lcm(64, 4), Some(64));
3795        assert_eq!(checked_num_integer_lcm(64, 3), Some(192));
3796        assert_eq!(checked_num_integer_lcm(64, 1), Some(64));
3797        assert_eq!(checked_num_integer_lcm(0, 4), Some(0));
3798        assert_eq!(checked_num_integer_lcm(64, 0), Some(0));
3799        // Coprime values whose product exceeds usize::MAX must return None.
3800        assert_eq!(
3801            checked_num_integer_lcm(usize::MAX, usize::MAX - 1),
3802            None,
3803            "coprime extreme values must overflow detect, not panic"
3804        );
3805    }
3806
3807    #[test]
3808    fn primary_plane_bpp_known_formats() {
3809        // Packed formats use channels × elem_size.
3810        assert_eq!(primary_plane_bpp(PixelFormat::Rgba, 1), Some(4));
3811        assert_eq!(primary_plane_bpp(PixelFormat::Bgra, 1), Some(4));
3812        assert_eq!(primary_plane_bpp(PixelFormat::Rgb, 1), Some(3));
3813        assert_eq!(primary_plane_bpp(PixelFormat::Grey, 1), Some(1));
3814        // Semi-planar (NV12) reports the luma plane's bpp.
3815        assert_eq!(primary_plane_bpp(PixelFormat::Nv12, 1), Some(1));
3816    }
3817}
3818
3819#[cfg(test)]
3820#[cfg_attr(coverage_nightly, coverage(off))]
3821#[allow(deprecated)]
3822mod image_tests {
3823    use super::*;
3824    use crate::{CPUProcessor, Rotation};
3825    #[cfg(target_os = "linux")]
3826    use edgefirst_tensor::is_dma_available;
3827    use edgefirst_tensor::{TensorMapTrait, TensorMemory, TensorTrait};
3828    use image::buffer::ConvertBuffer;
3829
3830    /// Test helper: call `ImageProcessorTrait::convert()` on two `TensorDyn`s
3831    /// by going through the `TensorDyn` API.
3832    ///
3833    /// Returns the `(src_image, dst_image)` reconstructed from the TensorDyn
3834    /// round-trip so the caller can feed them to `compare_images` etc.
3835    fn convert_img(
3836        proc: &mut dyn ImageProcessorTrait,
3837        src: TensorDyn,
3838        dst: TensorDyn,
3839        rotation: Rotation,
3840        flip: Flip,
3841        crop: Crop,
3842    ) -> (Result<()>, TensorDyn, TensorDyn) {
3843        let src_fourcc = src.format().unwrap();
3844        let dst_fourcc = dst.format().unwrap();
3845        let src_dyn = src;
3846        let mut dst_dyn = dst;
3847        let result = proc.convert(&src_dyn, &mut dst_dyn, rotation, flip, crop);
3848        let src_back = {
3849            let mut __t = src_dyn.into_u8().unwrap();
3850            __t.set_format(src_fourcc).unwrap();
3851            TensorDyn::from(__t)
3852        };
3853        let dst_back = {
3854            let mut __t = dst_dyn.into_u8().unwrap();
3855            __t.set_format(dst_fourcc).unwrap();
3856            TensorDyn::from(__t)
3857        };
3858        (result, src_back, dst_back)
3859    }
3860
3861    #[ctor::ctor(unsafe)]
3862    fn init() {
3863        env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
3864    }
3865
3866    macro_rules! function {
3867        () => {{
3868            fn f() {}
3869            fn type_name_of<T>(_: T) -> &'static str {
3870                std::any::type_name::<T>()
3871            }
3872            let name = type_name_of(f);
3873
3874            // Find and cut the rest of the path
3875            match &name[..name.len() - 3].rfind(':') {
3876                Some(pos) => &name[pos + 1..name.len() - 3],
3877                None => &name[..name.len() - 3],
3878            }
3879        }};
3880    }
3881
3882    /// Master oracle for the view/batch **destination** batch engine: render `N`
3883    /// tiles into row-bands of ONE tall destination and assert each band equals
3884    /// the same source converted standalone — proving correct band placement and
3885    /// that a later tile's letterbox clear / draw never wipes a sibling band. On
3886    /// the Linux GL backend the `N` `convert_deferred` calls share ONE parent
3887    /// EGLImage import (each tile is a `glViewport`/`glScissor` ROI) and sync
3888    /// once at `flush()`; other backends fall back to an eager per-band convert
3889    /// (CPU writes via offset + parent stride). Either way the oracle must hold.
3890    ///
3891    /// Identical source/tile size makes the convert an exact copy, so the
3892    /// assertion is backend-agnostic (no GL-vs-CPU resampling drift). Distinct
3893    /// solid colors per tile make any sibling wipe a hard failure.
3894    #[test]
3895    fn batch_view_dst_tiles_match_standalone() {
3896        let mut proc = match ImageProcessor::new() {
3897            Ok(p) => p,
3898            Err(e) => {
3899                crate::test_support::report_skip(&format!(
3900                    "{} — ImageProcessor init failed ({e:?})",
3901                    function!()
3902                ));
3903                return;
3904            }
3905        };
3906        let n = 3usize;
3907        let (w, h) = (32usize, 24usize);
3908        let colors: [[u8; 4]; 3] = [[210, 40, 40, 255], [40, 210, 40, 255], [40, 40, 210, 255]];
3909        let make_src = |c: [u8; 4]| -> TensorDyn {
3910            let bytes: Vec<u8> = c.iter().copied().cycle().take(w * h * 4).collect();
3911            load_bytes_to_tensor(w, h, PixelFormat::Rgba, Some(TensorMemory::Mem), &bytes).unwrap()
3912        };
3913        // Tall destination: N stacked row-bands. DMA so the Linux GL band path
3914        // runs (one parent import + per-tile glViewport); skip if unavailable.
3915        let parent = match TensorDyn::image(
3916            w,
3917            n * h,
3918            PixelFormat::Rgba,
3919            DType::U8,
3920            Some(TensorMemory::DmaBuf),
3921            edgefirst_tensor::CpuAccess::ReadWrite,
3922        ) {
3923            Ok(d) => d,
3924            Err(e) => {
3925                crate::test_support::report_skip(&format!(
3926                    "{} — tall DMA destination alloc failed ({e:?})",
3927                    function!()
3928                ));
3929                return;
3930            }
3931        };
3932
3933        // Deferred batch: one parent import, glViewport/scissor per band, one sync.
3934        for (i, &c) in colors.iter().enumerate().take(n) {
3935            let mut tile = parent.view(Region::new(0, i * h, w, h)).unwrap();
3936            proc.convert_deferred(
3937                &make_src(c),
3938                &mut tile,
3939                Rotation::None,
3940                Flip::None,
3941                Crop::no_crop(),
3942            )
3943            .unwrap_or_else(|e| panic!("convert_deferred tile {i}: {e:?}"));
3944        }
3945        proc.flush().unwrap();
3946
3947        for (i, &c) in colors.iter().enumerate().take(n) {
3948            // Standalone full-buffer convert of the same source = the oracle.
3949            let mut solo = TensorDyn::image(
3950                w,
3951                h,
3952                PixelFormat::Rgba,
3953                DType::U8,
3954                Some(TensorMemory::DmaBuf),
3955                edgefirst_tensor::CpuAccess::ReadWrite,
3956            )
3957            .unwrap();
3958            proc.convert(
3959                &make_src(c),
3960                &mut solo,
3961                Rotation::None,
3962                Flip::None,
3963                Crop::no_crop(),
3964            )
3965            .unwrap();
3966
3967            let band = parent.view(Region::new(0, i * h, w, h)).unwrap();
3968            let band_bytes = band
3969                .as_typed::<u8>()
3970                .unwrap()
3971                .map()
3972                .unwrap()
3973                .as_slice()
3974                .to_vec();
3975            let solo_bytes = solo
3976                .as_typed::<u8>()
3977                .unwrap()
3978                .map()
3979                .unwrap()
3980                .as_slice()
3981                .to_vec();
3982            assert_eq!(
3983                band_bytes, solo_bytes,
3984                "tile {i}: band differs from standalone convert (placement or sibling wipe)"
3985            );
3986            assert!(
3987                band_bytes.as_chunks::<4>().0.iter().all(|&p| p == c),
3988                "tile {i}: band is not the expected solid color {c:?} (sibling wipe?)"
3989            );
3990        }
3991    }
3992
3993    /// Build a `w`×`h` RGBA frame whose pixels encode their position, so a crop
3994    /// at any origin has distinct content (catches crop-origin / band-placement
3995    /// bugs a solid color cannot).
3996    #[cfg(test)]
3997    fn gradient_frame(w: usize, h: usize) -> TensorDyn {
3998        let mut bytes = vec![0u8; w * h * 4];
3999        for y in 0..h {
4000            for x in 0..w {
4001                let i = (y * w + x) * 4;
4002                bytes[i] = x as u8;
4003                bytes[i + 1] = y as u8;
4004                bytes[i + 2] = (x ^ y) as u8;
4005                bytes[i + 3] = 255;
4006            }
4007        }
4008        load_bytes_to_tensor(w, h, PixelFormat::Rgba, Some(TensorMemory::Mem), &bytes).unwrap()
4009    }
4010
4011    /// `tile_into` band == standalone crop-convert of the same source region,
4012    /// on the CPU backend (runs on CI without a GPU). Distinct gradient content
4013    /// makes a wrong crop origin or a sibling-band wipe a hard failure.
4014    #[test]
4015    fn tile_into_cpu_distinct_content_parity() {
4016        let mut proc = match ImageProcessor::with_config(ImageProcessorConfig {
4017            backend: ComputeBackend::Cpu,
4018            ..Default::default()
4019        }) {
4020            Ok(p) => p,
4021            Err(e) => {
4022                crate::test_support::report_skip(&format!(
4023                    "{} — CPU init failed ({e:?})",
4024                    function!()
4025                ));
4026                return;
4027            }
4028        };
4029        let (fw, fh) = (96usize, 64usize);
4030        let src = gradient_frame(fw, fh);
4031        let cfg = TilingConfig::new(32, 32).with_overlap(0.0); // exact 3×2 tiling
4032        let n = tile_grid(fh, fw, 32, 32, 0.0).len();
4033        assert_eq!(n, 6);
4034
4035        let mut parent = proc
4036            .alloc_tile_batch(
4037                n,
4038                &cfg,
4039                PixelFormat::Rgba,
4040                DType::U8,
4041                Some(TensorMemory::Mem),
4042                edgefirst_tensor::CpuAccess::ReadWrite,
4043            )
4044            .unwrap();
4045        let placements = proc.tile_into(&src, &mut parent, &cfg).unwrap();
4046        assert_eq!(placements.len(), n);
4047
4048        for p in &placements {
4049            let source = Region::new(
4050                p.origin.0 as usize,
4051                p.origin.1 as usize,
4052                p.crop_size.0 as usize,
4053                p.crop_size.1 as usize,
4054            );
4055            let mut solo = TensorDyn::image(
4056                32,
4057                32,
4058                PixelFormat::Rgba,
4059                DType::U8,
4060                Some(TensorMemory::Mem),
4061                edgefirst_tensor::CpuAccess::ReadWrite,
4062            )
4063            .unwrap();
4064            proc.convert(
4065                &src,
4066                &mut solo,
4067                Rotation::None,
4068                Flip::None,
4069                Crop::default()
4070                    .with_source(Some(source))
4071                    .with_fit(Fit::Stretch),
4072            )
4073            .unwrap();
4074            let band = parent.view(Region::new(0, p.index * 32, 32, 32)).unwrap();
4075            let band_bytes = band
4076                .as_typed::<u8>()
4077                .unwrap()
4078                .map()
4079                .unwrap()
4080                .as_slice()
4081                .to_vec();
4082            let solo_bytes = solo
4083                .as_typed::<u8>()
4084                .unwrap()
4085                .map()
4086                .unwrap()
4087                .as_slice()
4088                .to_vec();
4089            assert_eq!(
4090                band_bytes, solo_bytes,
4091                "tile {} band differs from standalone crop-convert",
4092                p.index
4093            );
4094        }
4095    }
4096
4097    /// Streaming `tile_one` into a single slot == the corresponding batched
4098    /// `tile_into` band (proves the two paths agree).
4099    #[test]
4100    fn tile_one_matches_tile_into_band() {
4101        let mut proc = match ImageProcessor::with_config(ImageProcessorConfig {
4102            backend: ComputeBackend::Cpu,
4103            ..Default::default()
4104        }) {
4105            Ok(p) => p,
4106            Err(e) => {
4107                crate::test_support::report_skip(&format!(
4108                    "{} — CPU init failed ({e:?})",
4109                    function!()
4110                ));
4111                return;
4112            }
4113        };
4114        let (fw, fh) = (96usize, 64usize);
4115        let src = gradient_frame(fw, fh);
4116        let cfg = TilingConfig::new(32, 32).with_overlap(0.0);
4117        let n = tile_grid(fh, fw, 32, 32, 0.0).len();
4118
4119        let mut parent = proc
4120            .alloc_tile_batch(
4121                n,
4122                &cfg,
4123                PixelFormat::Rgba,
4124                DType::U8,
4125                Some(TensorMemory::Mem),
4126                edgefirst_tensor::CpuAccess::ReadWrite,
4127            )
4128            .unwrap();
4129        proc.tile_into(&src, &mut parent, &cfg).unwrap();
4130
4131        let plan = proc.plan_tiles(fw, fh, &cfg).unwrap();
4132        for p in &plan {
4133            let mut slot = TensorDyn::image(
4134                32,
4135                32,
4136                PixelFormat::Rgba,
4137                DType::U8,
4138                Some(TensorMemory::Mem),
4139                edgefirst_tensor::CpuAccess::ReadWrite,
4140            )
4141            .unwrap();
4142            proc.tile_one(&src, &mut slot, p, &cfg).unwrap();
4143            proc.flush().unwrap();
4144            let band = parent.view(Region::new(0, p.index * 32, 32, 32)).unwrap();
4145            let slot_bytes = slot
4146                .as_typed::<u8>()
4147                .unwrap()
4148                .map()
4149                .unwrap()
4150                .as_slice()
4151                .to_vec();
4152            let band_bytes = band
4153                .as_typed::<u8>()
4154                .unwrap()
4155                .map()
4156                .unwrap()
4157                .as_slice()
4158                .to_vec();
4159            assert_eq!(
4160                slot_bytes, band_bytes,
4161                "tile {} stream != batch band",
4162                p.index
4163            );
4164        }
4165    }
4166
4167    /// `plan_tiles` returns correct per-tile metadata for a 4K frame (pure, no
4168    /// GPU render).
4169    /// `tile_into` through the auto backend with a DMA parent — exercises the
4170    /// GL single-import band path on a GPU machine (skips when DMA/GL is
4171    /// unavailable). The source is sampled via `Crop::with_source`, never
4172    /// `src.view()`, so all tiles share one source import.
4173    #[test]
4174    fn tile_into_auto_dma_parity() {
4175        let mut proc = match ImageProcessor::new() {
4176            Ok(p) => p,
4177            Err(e) => {
4178                crate::test_support::report_skip(&format!(
4179                    "{} — ImageProcessor init failed ({e:?})",
4180                    function!()
4181                ));
4182                return;
4183            }
4184        };
4185        let (fw, fh) = (96usize, 64usize);
4186        let src = gradient_frame(fw, fh);
4187        let cfg = TilingConfig::new(32, 32).with_overlap(0.0);
4188        let n = tile_grid(fh, fw, 32, 32, 0.0).len();
4189
4190        let mut parent = match proc.alloc_tile_batch(
4191            n,
4192            &cfg,
4193            PixelFormat::Rgba,
4194            DType::U8,
4195            Some(TensorMemory::DmaBuf),
4196            edgefirst_tensor::CpuAccess::ReadWrite,
4197        ) {
4198            Ok(p) => p,
4199            Err(e) => {
4200                crate::test_support::report_skip(&format!(
4201                    "{} — tall DMA parent alloc failed ({e:?})",
4202                    function!()
4203                ));
4204                return;
4205            }
4206        };
4207        let placements = proc.tile_into(&src, &mut parent, &cfg).unwrap();
4208
4209        for p in &placements {
4210            let source = Region::new(
4211                p.origin.0 as usize,
4212                p.origin.1 as usize,
4213                p.crop_size.0 as usize,
4214                p.crop_size.1 as usize,
4215            );
4216            let mut solo = TensorDyn::image(
4217                32,
4218                32,
4219                PixelFormat::Rgba,
4220                DType::U8,
4221                Some(TensorMemory::DmaBuf),
4222                edgefirst_tensor::CpuAccess::ReadWrite,
4223            )
4224            .unwrap();
4225            proc.convert(
4226                &src,
4227                &mut solo,
4228                Rotation::None,
4229                Flip::None,
4230                Crop::default()
4231                    .with_source(Some(source))
4232                    .with_fit(Fit::Stretch),
4233            )
4234            .unwrap();
4235            let band = parent.view(Region::new(0, p.index * 32, 32, 32)).unwrap();
4236            // Structural-similarity tolerance (the house GPU-parity bar) rather
4237            // than exact bytes: rendering a tile into a viewport band at a
4238            // non-zero offset vs. a standalone origin render diverges by sampling
4239            // rounding on virtualized GPUs (paravirtual Metal/ANGLE on macOS CI),
4240            // the same tolerance class as the other cross-backend parity tests.
4241            compare_images(
4242                &band,
4243                &solo,
4244                0.98,
4245                &format!("{}_tile{}", function!(), p.index),
4246            );
4247        }
4248    }
4249
4250    /// `tile_into` rejects a destination too small to hold all tile bands.
4251    #[test]
4252    fn tile_into_undersized_dst_errors() {
4253        let mut proc = match ImageProcessor::with_config(ImageProcessorConfig {
4254            backend: ComputeBackend::Cpu,
4255            ..Default::default()
4256        }) {
4257            Ok(p) => p,
4258            Err(e) => {
4259                crate::test_support::report_skip(&format!(
4260                    "{} — CPU init failed ({e:?})",
4261                    function!()
4262                ));
4263                return;
4264            }
4265        };
4266        let (fw, fh) = (96usize, 64usize);
4267        let src = gradient_frame(fw, fh);
4268        let cfg = TilingConfig::new(32, 32).with_overlap(0.0); // 6 tiles
4269                                                               // Allocate a parent for only 2 bands, far short of 6.
4270        let mut small = TensorDyn::image(
4271            32,
4272            2 * 32,
4273            PixelFormat::Rgba,
4274            DType::U8,
4275            Some(TensorMemory::Mem),
4276            edgefirst_tensor::CpuAccess::ReadWrite,
4277        )
4278        .unwrap();
4279        let r = proc.tile_into(&src, &mut small, &cfg);
4280        assert!(
4281            matches!(r, Err(Error::InvalidShape(_))),
4282            "expected InvalidShape, got {r:?}"
4283        );
4284    }
4285
4286    /// `alloc_tile_batch` / `plan_tiles` reject an invalid config (zero tile).
4287    #[test]
4288    fn tiling_alloc_rejects_invalid_config() {
4289        let proc = match ImageProcessor::with_config(ImageProcessorConfig {
4290            backend: ComputeBackend::Cpu,
4291            ..Default::default()
4292        }) {
4293            Ok(p) => p,
4294            Err(e) => {
4295                crate::test_support::report_skip(&format!(
4296                    "{} — CPU init failed ({e:?})",
4297                    function!()
4298                ));
4299                return;
4300            }
4301        };
4302        let bad = TilingConfig::new(0, 640);
4303        assert!(proc.plan_tiles(1920, 1080, &bad).is_err());
4304        assert!(proc
4305            .alloc_tile_batch(
4306                4,
4307                &bad,
4308                PixelFormat::Rgba,
4309                DType::U8,
4310                Some(TensorMemory::Mem),
4311                edgefirst_tensor::CpuAccess::ReadWrite,
4312            )
4313            .is_err());
4314    }
4315
4316    #[test]
4317    fn plan_tiles_metadata_4k() {
4318        let proc = match ImageProcessor::with_config(ImageProcessorConfig {
4319            backend: ComputeBackend::Cpu,
4320            ..Default::default()
4321        }) {
4322            Ok(p) => p,
4323            Err(e) => {
4324                crate::test_support::report_skip(&format!(
4325                    "{} — CPU init failed ({e:?})",
4326                    function!()
4327                ));
4328                return;
4329            }
4330        };
4331        let cfg = TilingConfig::new(640, 640).with_overlap(0.2);
4332        let plan = proc.plan_tiles(3840, 2160, &cfg).unwrap();
4333        assert_eq!(plan.len(), 32);
4334        assert!(plan.iter().all(|p| p.count == 32));
4335        assert!(plan.iter().all(|p| p.crop_size == (640.0, 640.0)));
4336        assert!(plan.iter().all(|p| p.letterbox.is_none())); // stretch fit
4337        assert!(plan.iter().all(|p| p.frame_dims == (3840.0, 2160.0)));
4338        assert_eq!(plan[0].origin, (0.0, 0.0));
4339    }
4340
4341    #[test]
4342    fn test_invalid_crop() {
4343        let src = TensorDyn::image(
4344            100,
4345            100,
4346            PixelFormat::Rgb,
4347            DType::U8,
4348            None,
4349            edgefirst_tensor::CpuAccess::ReadWrite,
4350        )
4351        .unwrap();
4352        let dst = TensorDyn::image(
4353            100,
4354            100,
4355            PixelFormat::Rgb,
4356            DType::U8,
4357            None,
4358            edgefirst_tensor::CpuAccess::ReadWrite,
4359        )
4360        .unwrap();
4361
4362        // A source crop exceeding the source bounds is rejected.
4363        let crop = Crop::new().with_source(Some(Region::new(50, 50, 60, 60)));
4364        assert!(matches!(
4365            crop.check_crop_dyn(&src, &dst),
4366            Err(Error::CropInvalid(_))
4367        ));
4368
4369        // A source crop within bounds is valid.
4370        let crop = Crop::new().with_source(Some(Region::new(0, 0, 10, 10)));
4371        assert!(crop.check_crop_dyn(&src, &dst).is_ok());
4372
4373        // Letterbox is always valid — placement is computed within the dst.
4374        assert!(Crop::letterbox([0, 0, 0, 255])
4375            .check_crop_dyn(&src, &dst)
4376            .is_ok());
4377    }
4378
4379    #[test]
4380    fn test_invalid_tensor_format() -> Result<(), Error> {
4381        // 4D tensor cannot be set to a 3-channel pixel format
4382        let mut tensor = Tensor::<u8>::new(&[720, 1280, 4, 1], None, None)?;
4383        let result = tensor.set_format(PixelFormat::Rgb);
4384        assert!(result.is_err(), "4D tensor should reject set_format");
4385
4386        // Tensor with wrong channel count for the format
4387        let mut tensor = Tensor::<u8>::new(&[720, 1280, 4], None, None)?;
4388        let result = tensor.set_format(PixelFormat::Rgb);
4389        assert!(result.is_err(), "4-channel tensor should reject RGB format");
4390
4391        Ok(())
4392    }
4393
4394    #[test]
4395    fn test_invalid_image_file() -> Result<(), Error> {
4396        let result = crate::load_image_test_helper(&[123; 5000], None, None);
4397        assert!(
4398            matches!(result, Err(Error::Codec(_))),
4399            "unrecognised bytes should surface as Error::Codec, got {result:?}"
4400        );
4401        Ok(())
4402    }
4403
4404    #[test]
4405    fn test_invalid_jpeg_format() -> Result<(), Error> {
4406        let result = crate::load_image_test_helper(&[123; 5000], Some(PixelFormat::Yuyv), None);
4407        // YUYV is not a valid decode target; peek_info fails before the magic-
4408        // bytes check, so the precise variant depends on which error fires first.
4409        assert!(
4410            matches!(result, Err(Error::Codec(_))),
4411            "Yuyv target with garbage bytes should surface as Error::Codec, got {result:?}"
4412        );
4413        Ok(())
4414    }
4415
4416    #[test]
4417    fn test_load_resize_save() {
4418        let file = edgefirst_bench::testdata::read("zidane.jpg");
4419        let img = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4420        assert_eq!(img.width(), Some(1280));
4421        assert_eq!(img.height(), Some(720));
4422
4423        let dst = TensorDyn::image(
4424            640,
4425            360,
4426            PixelFormat::Rgba,
4427            DType::U8,
4428            None,
4429            edgefirst_tensor::CpuAccess::ReadWrite,
4430        )
4431        .unwrap();
4432        let mut converter = CPUProcessor::new();
4433        let (result, _img, dst) = convert_img(
4434            &mut converter,
4435            img,
4436            dst,
4437            Rotation::None,
4438            Flip::None,
4439            Crop::no_crop(),
4440        );
4441        result.unwrap();
4442        assert_eq!(dst.width(), Some(640));
4443        assert_eq!(dst.height(), Some(360));
4444
4445        crate::save_jpeg(&dst, "zidane_resized.jpg", 80).unwrap();
4446
4447        let file = std::fs::read("zidane_resized.jpg").unwrap();
4448        // With `format: None` the helper returns the source's native format.
4449        // The codec now decodes colour JPEGs to NV12 (was RGB previously).
4450        let img = crate::load_image_test_helper(&file, None, None).unwrap();
4451        assert_eq!(img.width(), Some(640));
4452        assert_eq!(img.height(), Some(360));
4453        assert_eq!(img.format().unwrap(), PixelFormat::Nv12);
4454    }
4455
4456    #[test]
4457    fn test_from_tensor_planar() -> Result<(), Error> {
4458        let mut tensor = Tensor::new(&[3, 720, 1280], None, None)?;
4459        tensor
4460            .map()?
4461            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.8bps"));
4462        let planar = {
4463            tensor
4464                .set_format(PixelFormat::PlanarRgb)
4465                .map_err(|e| crate::Error::Internal(e.to_string()))?;
4466            TensorDyn::from(tensor)
4467        };
4468
4469        let rbga = load_bytes_to_tensor(
4470            1280,
4471            720,
4472            PixelFormat::Rgba,
4473            None,
4474            &edgefirst_bench::testdata::read("camera720p.rgba"),
4475        )?;
4476        compare_images_convert_to_rgb(&planar, &rbga, 0.98, function!());
4477
4478        Ok(())
4479    }
4480
4481    #[test]
4482    fn test_from_tensor_invalid_format() {
4483        // PixelFormat::from_fourcc_str returns None for unknown FourCC codes.
4484        // Since there's no "TEST" pixel format, this validates graceful handling.
4485        assert!(PixelFormat::from_fourcc(u32::from_le_bytes(*b"TEST")).is_none());
4486    }
4487
4488    #[test]
4489    #[should_panic(expected = "Failed to save planar RGB image")]
4490    fn test_save_planar() {
4491        let planar_img = load_bytes_to_tensor(
4492            1280,
4493            720,
4494            PixelFormat::PlanarRgb,
4495            None,
4496            &edgefirst_bench::testdata::read("camera720p.8bps"),
4497        )
4498        .unwrap();
4499
4500        let save_path = "/tmp/planar_rgb.jpg";
4501        crate::save_jpeg(&planar_img, save_path, 90).expect("Failed to save planar RGB image");
4502    }
4503
4504    #[test]
4505    #[should_panic(expected = "Failed to save YUYV image")]
4506    fn test_save_yuyv() {
4507        let planar_img = load_bytes_to_tensor(
4508            1280,
4509            720,
4510            PixelFormat::Yuyv,
4511            None,
4512            &edgefirst_bench::testdata::read("camera720p.yuyv"),
4513        )
4514        .unwrap();
4515
4516        let save_path = "/tmp/yuyv.jpg";
4517        crate::save_jpeg(&planar_img, save_path, 90).expect("Failed to save YUYV image");
4518    }
4519
4520    #[test]
4521    fn test_rotation_angle() {
4522        assert_eq!(Rotation::from_degrees_clockwise(0), Rotation::None);
4523        assert_eq!(Rotation::from_degrees_clockwise(90), Rotation::Clockwise90);
4524        assert_eq!(Rotation::from_degrees_clockwise(180), Rotation::Rotate180);
4525        assert_eq!(
4526            Rotation::from_degrees_clockwise(270),
4527            Rotation::CounterClockwise90
4528        );
4529        assert_eq!(Rotation::from_degrees_clockwise(360), Rotation::None);
4530        assert_eq!(Rotation::from_degrees_clockwise(450), Rotation::Clockwise90);
4531        assert_eq!(Rotation::from_degrees_clockwise(540), Rotation::Rotate180);
4532        assert_eq!(
4533            Rotation::from_degrees_clockwise(630),
4534            Rotation::CounterClockwise90
4535        );
4536    }
4537
4538    #[test]
4539    #[should_panic(expected = "rotation angle is not a multiple of 90")]
4540    fn test_rotation_angle_panic() {
4541        Rotation::from_degrees_clockwise(361);
4542    }
4543
4544    #[test]
4545    fn test_disable_env_var() -> Result<(), Error> {
4546        // Acquire the env-var mutex for the entire test body so we never race
4547        // with test_force_backend_* or test_draw_proto_masks_no_cpu_returns_error.
4548        let _lock = acquire_env_lock();
4549
4550        // Snapshot ALL env vars we might touch so the RAII guard restores them
4551        // on exit (even on panic), preventing env-var poisoning of other tests.
4552        let _guard = EnvGuard::snapshot(&[
4553            "EDGEFIRST_FORCE_BACKEND",
4554            "EDGEFIRST_DISABLE_GL",
4555            "EDGEFIRST_DISABLE_G2D",
4556            "EDGEFIRST_DISABLE_CPU",
4557        ]);
4558
4559        // EDGEFIRST_FORCE_BACKEND takes precedence over EDGEFIRST_DISABLE_*,
4560        // so clear it for the duration of this test.
4561        unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") };
4562
4563        #[cfg(target_os = "linux")]
4564        {
4565            unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
4566            let converter = ImageProcessor::new()?;
4567            assert!(converter.g2d.is_none());
4568            unsafe { std::env::remove_var("EDGEFIRST_DISABLE_G2D") };
4569        }
4570
4571        #[cfg(any(target_os = "linux", target_os = "windows"))]
4572        #[cfg(feature = "opengl")]
4573        {
4574            unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
4575            let converter = ImageProcessor::new()?;
4576            assert!(converter.opengl.is_none());
4577            unsafe { std::env::remove_var("EDGEFIRST_DISABLE_GL") };
4578        }
4579
4580        unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
4581        let converter = ImageProcessor::new()?;
4582        assert!(converter.cpu.is_none());
4583        unsafe { std::env::remove_var("EDGEFIRST_DISABLE_CPU") };
4584
4585        // Disable everything — convert must return NoConverter.
4586        unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
4587        unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
4588        unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
4589        let mut converter = ImageProcessor::new()?;
4590
4591        let src = TensorDyn::image(
4592            1280,
4593            720,
4594            PixelFormat::Rgba,
4595            DType::U8,
4596            None,
4597            edgefirst_tensor::CpuAccess::ReadWrite,
4598        )?;
4599        let dst = TensorDyn::image(
4600            640,
4601            360,
4602            PixelFormat::Rgba,
4603            DType::U8,
4604            None,
4605            edgefirst_tensor::CpuAccess::ReadWrite,
4606        )?;
4607        let (result, _src, _dst) = convert_img(
4608            &mut converter,
4609            src,
4610            dst,
4611            Rotation::None,
4612            Flip::None,
4613            Crop::no_crop(),
4614        );
4615        assert!(matches!(result, Err(Error::NoConverter)));
4616        // _guard restores all env vars on drop.
4617        Ok(())
4618    }
4619
4620    #[test]
4621    fn test_unsupported_conversion() {
4622        let src = TensorDyn::image(
4623            1280,
4624            720,
4625            PixelFormat::Nv12,
4626            DType::U8,
4627            None,
4628            edgefirst_tensor::CpuAccess::ReadWrite,
4629        )
4630        .unwrap();
4631        let dst = TensorDyn::image(
4632            640,
4633            360,
4634            PixelFormat::Nv12,
4635            DType::U8,
4636            None,
4637            edgefirst_tensor::CpuAccess::ReadWrite,
4638        )
4639        .unwrap();
4640        let mut converter = ImageProcessor::new().unwrap();
4641        let (result, _src, _dst) = convert_img(
4642            &mut converter,
4643            src,
4644            dst,
4645            Rotation::None,
4646            Flip::None,
4647            Crop::no_crop(),
4648        );
4649        log::debug!("result: {:?}", result);
4650        assert!(matches!(
4651            result,
4652            Err(Error::NotSupported(e)) if e.starts_with("Conversion from NV12 to NV12")
4653        ));
4654    }
4655
4656    #[test]
4657    fn test_load_grey() {
4658        // A single-component (greyscale) JPEG decodes to its native GREY
4659        // format, which has no even-dimension constraint, so the 1024×681
4660        // `grey.jpg` loads and converts to RGBA successfully.
4661        let grey_img = crate::load_image_test_helper(
4662            &edgefirst_bench::testdata::read("grey.jpg"),
4663            Some(PixelFormat::Rgba),
4664            None,
4665        )
4666        .unwrap();
4667        assert_eq!(grey_img.width(), Some(1024));
4668        assert_eq!(grey_img.height(), Some(681));
4669
4670        // `grey-rgb.jpg` holds the same grey content but is encoded as a
4671        // 3-component (colour) JPEG, so the codec decodes it to native NV12.
4672        // Its 1024×681 dimensions have an odd height; NV12 now represents odd
4673        // dimensions via the `H + ceil(H/2)` combined-plane height, so the
4674        // decode succeeds and converts to RGBA at the true dimensions.
4675        let grey_but_rgb = crate::load_image_test_helper(
4676            &edgefirst_bench::testdata::read("grey-rgb.jpg"),
4677            Some(PixelFormat::Rgba),
4678            None,
4679        )
4680        .expect("odd-height colour JPEG should decode to NV12 and convert to RGBA");
4681        assert_eq!(grey_but_rgb.width(), Some(1024));
4682        assert_eq!(grey_but_rgb.height(), Some(681));
4683    }
4684
4685    #[test]
4686    fn test_new_nv12() {
4687        let nv12 = TensorDyn::image(
4688            1280,
4689            720,
4690            PixelFormat::Nv12,
4691            DType::U8,
4692            None,
4693            edgefirst_tensor::CpuAccess::ReadWrite,
4694        )
4695        .unwrap();
4696        assert_eq!(nv12.height(), Some(720));
4697        assert_eq!(nv12.width(), Some(1280));
4698        assert_eq!(nv12.format().unwrap(), PixelFormat::Nv12);
4699        // PixelFormat::Nv12.channels() returns 1 (luma plane channel count)
4700        assert_eq!(nv12.format().unwrap().channels(), 1);
4701        assert!(nv12.format().is_some_and(
4702            |f| f.layout() == PixelLayout::Planar || f.layout() == PixelLayout::SemiPlanar
4703        ))
4704    }
4705
4706    #[test]
4707    #[cfg(any(target_os = "linux", target_os = "windows"))]
4708    fn test_new_image_converter() {
4709        let dst_width = 640;
4710        let dst_height = 360;
4711        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4712        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4713
4714        let mut converter = ImageProcessor::new().unwrap();
4715        let converter_dst = converter
4716            .create_image(
4717                dst_width,
4718                dst_height,
4719                PixelFormat::Rgba,
4720                DType::U8,
4721                None,
4722                edgefirst_tensor::CpuAccess::ReadWrite,
4723            )
4724            .unwrap();
4725        let (result, src, converter_dst) = convert_img(
4726            &mut converter,
4727            src,
4728            converter_dst,
4729            Rotation::None,
4730            Flip::None,
4731            Crop::no_crop(),
4732        );
4733        result.unwrap();
4734
4735        let cpu_dst = TensorDyn::image(
4736            dst_width,
4737            dst_height,
4738            PixelFormat::Rgba,
4739            DType::U8,
4740            None,
4741            edgefirst_tensor::CpuAccess::ReadWrite,
4742        )
4743        .unwrap();
4744        let mut cpu_converter = CPUProcessor::new();
4745        let (result, _src, cpu_dst) = convert_img(
4746            &mut cpu_converter,
4747            src,
4748            cpu_dst,
4749            Rotation::None,
4750            Flip::None,
4751            Crop::no_crop(),
4752        );
4753        result.unwrap();
4754
4755        compare_images(&converter_dst, &cpu_dst, 0.98, function!());
4756    }
4757
4758    #[test]
4759    #[cfg(any(target_os = "linux", target_os = "windows"))]
4760    fn test_create_image_dtype_i8() {
4761        let mut converter = ImageProcessor::new().unwrap();
4762
4763        // I8 image should allocate successfully via create_image
4764        let dst = converter
4765            .create_image(
4766                320,
4767                240,
4768                PixelFormat::Rgb,
4769                DType::I8,
4770                None,
4771                edgefirst_tensor::CpuAccess::ReadWrite,
4772            )
4773            .unwrap();
4774        assert_eq!(dst.dtype(), DType::I8);
4775        assert!(dst.width() == Some(320));
4776        assert!(dst.height() == Some(240));
4777        assert_eq!(dst.format(), Some(PixelFormat::Rgb));
4778
4779        // U8 for comparison
4780        let dst_u8 = converter
4781            .create_image(
4782                320,
4783                240,
4784                PixelFormat::Rgb,
4785                DType::U8,
4786                None,
4787                edgefirst_tensor::CpuAccess::ReadWrite,
4788            )
4789            .unwrap();
4790        assert_eq!(dst_u8.dtype(), DType::U8);
4791
4792        // Convert into I8 dst should succeed
4793        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4794        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4795        let mut dst_i8 = converter
4796            .create_image(
4797                320,
4798                240,
4799                PixelFormat::Rgb,
4800                DType::I8,
4801                None,
4802                edgefirst_tensor::CpuAccess::ReadWrite,
4803            )
4804            .unwrap();
4805        converter
4806            .convert(
4807                &src,
4808                &mut dst_i8,
4809                Rotation::None,
4810                Flip::None,
4811                Crop::no_crop(),
4812            )
4813            .unwrap();
4814    }
4815
4816    #[test]
4817    #[cfg(target_os = "linux")]
4818    fn test_create_image_nv12_dma_non_aligned_width() {
4819        // create_image is fully stride-aware: a non-64-aligned NV12 DMA tensor
4820        // may legitimately carry a GPU-pitch-padded row stride — that is the
4821        // intended behaviour, not a bug. Verify the logical geometry is preserved
4822        // for any width and that a reported stride is a valid (>= logical)
4823        // padding, rather than asserting the absence of a stride.
4824        let converter = ImageProcessor::new().unwrap();
4825
4826        // 100 is intentionally not a multiple of 64 (the GPU pitch alignment).
4827        let result = converter.create_image(
4828            100,
4829            64,
4830            PixelFormat::Nv12,
4831            DType::U8,
4832            Some(TensorMemory::DmaBuf),
4833            edgefirst_tensor::CpuAccess::ReadWrite,
4834        );
4835
4836        match result {
4837            Ok(img) => {
4838                assert_eq!(img.width(), Some(100));
4839                assert_eq!(img.height(), Some(64));
4840                assert_eq!(img.format(), Some(PixelFormat::Nv12));
4841                if let Some(stride) = img.row_stride() {
4842                    assert!(
4843                        stride >= 100,
4844                        "NV12 row_stride {stride} must be >= the logical width (100)",
4845                    );
4846                }
4847            }
4848            Err(e) => {
4849                // Skip cleanly on hosts without a dma-heap.
4850                crate::test_support::report_skip(&format!(
4851                    "create_image NV12 DMA non-aligned width: {e}"
4852                ));
4853            }
4854        }
4855    }
4856
4857    #[test]
4858    #[ignore] // Hangs on desktop platforms where DMA-buf is unavailable and PBO
4859              // fallback triggers a GPU driver hang during SHM→texture upload (e.g.,
4860              // NVIDIA without /dev/dma_heap permissions). Works on embedded targets.
4861    fn test_crop_skip() {
4862        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4863        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4864
4865        let mut converter = ImageProcessor::new().unwrap();
4866        let converter_dst = converter
4867            .create_image(
4868                1280,
4869                720,
4870                PixelFormat::Rgba,
4871                DType::U8,
4872                None,
4873                edgefirst_tensor::CpuAccess::ReadWrite,
4874            )
4875            .unwrap();
4876        let crop = Crop::new().with_source(Some(Region::new(0, 0, 640, 640)));
4877        let (result, src, converter_dst) = convert_img(
4878            &mut converter,
4879            src,
4880            converter_dst,
4881            Rotation::None,
4882            Flip::None,
4883            crop,
4884        );
4885        result.unwrap();
4886
4887        let cpu_dst = TensorDyn::image(
4888            1280,
4889            720,
4890            PixelFormat::Rgba,
4891            DType::U8,
4892            None,
4893            edgefirst_tensor::CpuAccess::ReadWrite,
4894        )
4895        .unwrap();
4896        let mut cpu_converter = CPUProcessor::new();
4897        let (result, _src, cpu_dst) = convert_img(
4898            &mut cpu_converter,
4899            src,
4900            cpu_dst,
4901            Rotation::None,
4902            Flip::None,
4903            crop,
4904        );
4905        result.unwrap();
4906
4907        compare_images(&converter_dst, &cpu_dst, 0.99999, function!());
4908    }
4909
4910    #[test]
4911    fn test_invalid_pixel_format() {
4912        // PixelFormat::from_fourcc returns None for unknown formats,
4913        // so TensorDyn::image cannot be called with an invalid format.
4914        assert!(PixelFormat::from_fourcc(u32::from_le_bytes(*b"TEST")).is_none());
4915    }
4916
4917    // Helper function to check if G2D library is available (Linux/i.MX8 only)
4918    #[cfg(target_os = "linux")]
4919    static G2D_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
4920
4921    #[cfg(target_os = "linux")]
4922    fn is_g2d_available() -> bool {
4923        *G2D_AVAILABLE.get_or_init(|| G2DProcessor::new().is_ok())
4924    }
4925
4926    // Every GL platform: Linux EGL, ANGLE/Metal (macOS, iOS), Android EGL,
4927    // ANGLE/D3D11 (Windows). `GLProcessorThreaded::new` fails cleanly where
4928    // the driver or ANGLE is missing, so the `test_opengl_*` callers below
4929    // self-skip there (and `HAL_TEST_REQUIRE_GL=1` lanes catch that via
4930    // `gl_backend_available_canary`).
4931    #[cfg(any(
4932        target_os = "linux",
4933        target_os = "macos",
4934        target_os = "ios",
4935        target_os = "android",
4936        target_os = "windows"
4937    ))]
4938    #[cfg(feature = "opengl")]
4939    static GL_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
4940
4941    #[cfg(any(
4942        target_os = "linux",
4943        target_os = "macos",
4944        target_os = "ios",
4945        target_os = "android",
4946        target_os = "windows"
4947    ))]
4948    #[cfg(feature = "opengl")]
4949    // Helper function to check if OpenGL is available
4950    fn is_opengl_available() -> bool {
4951        *GL_AVAILABLE.get_or_init(|| GLProcessorThreaded::new(None).is_ok())
4952    }
4953
4954    /// CI canary: fails the lane when the GL backend cannot initialize.
4955    ///
4956    /// Every GL test in this suite self-skips when the backend is
4957    /// unavailable — correct for developer machines, but it means a broken
4958    /// CI GL stack (e.g. the macOS ANGLE re-sign step regressing, the exact
4959    /// failure mode documented in the workflow) ships an untested GL
4960    /// backend behind a green lane. Gated on `HAL_TEST_REQUIRE_GL=1`, set
4961    /// only by CI jobs that install a working GL stack; local runs without
4962    /// one pass trivially. On macOS it additionally requires
4963    /// `HAL_TEST_ALLOW_DLOPEN_ANGLE`, so coverage pass 1 (unsigned
4964    /// binaries, dlopen gate closed) skips it and pass 2 (signed) enforces.
4965    #[test]
4966    #[cfg(feature = "opengl")]
4967    fn gl_backend_available_canary() {
4968        let require_gl = std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1");
4969        if !require_gl {
4970            crate::test_support::report_skip(&format!(
4971                "{} — HAL_TEST_REQUIRE_GL is not set to 1",
4972                function!()
4973            ));
4974            return;
4975        }
4976        #[cfg(target_os = "macos")]
4977        if std::env::var_os("HAL_TEST_ALLOW_DLOPEN_ANGLE").is_none() {
4978            crate::test_support::report_skip(&format!(
4979                "{} — ANGLE dlopen gate closed (coverage pass 1)",
4980                function!()
4981            ));
4982            return;
4983        }
4984        GLProcessorThreaded::new(None).expect(
4985            "HAL_TEST_REQUIRE_GL=1 but the GL backend failed to initialize — \
4986             check the ANGLE install/re-sign step and binary entitlements \
4987             (macOS) or the EGL stack (Linux)",
4988        );
4989    }
4990
4991    #[test]
4992    fn test_load_jpeg_with_exif() {
4993        use edgefirst_codec::peek_info;
4994
4995        // The migrated codec NEVER applies EXIF orientation: it decodes to the
4996        // source's native (un-rotated) dimensions and reports the rotation via
4997        // ImageInfo. `zidane_rotated_exif.jpg` carries EXIF orientation 6
4998        // (90° clockwise) over a 1280×720 frame.
4999        let file = edgefirst_bench::testdata::read("zidane_rotated_exif.jpg").to_vec();
5000        let info = peek_info(&file).unwrap();
5001        assert_eq!(info.rotation_degrees, 90);
5002        assert!(!info.flip_horizontal);
5003
5004        let loaded = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5005        // Native (un-rotated) dimensions — the decode does not rotate.
5006        assert_eq!(loaded.width(), Some(1280));
5007        assert_eq!(loaded.height(), Some(720));
5008
5009        // Applying the reported rotation downstream reproduces the upright
5010        // image: it matches `zidane.jpg` rotated by the same 90° clockwise.
5011        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5012        let cpu_src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5013
5014        let rotation = Rotation::from_degrees_clockwise(info.rotation_degrees as usize);
5015        let (dst_width, dst_height) = (cpu_src.height().unwrap(), cpu_src.width().unwrap());
5016
5017        let cpu_dst = TensorDyn::image(
5018            dst_width,
5019            dst_height,
5020            PixelFormat::Rgba,
5021            DType::U8,
5022            None,
5023            edgefirst_tensor::CpuAccess::ReadWrite,
5024        )
5025        .unwrap();
5026        let mut cpu_converter = CPUProcessor::new();
5027
5028        // Rotate the native-orientation `loaded` frame and the native `zidane`
5029        // frame by the same reported rotation; the results must agree.
5030        let loaded_rotated = TensorDyn::image(
5031            dst_width,
5032            dst_height,
5033            PixelFormat::Rgba,
5034            DType::U8,
5035            None,
5036            edgefirst_tensor::CpuAccess::ReadWrite,
5037        )
5038        .unwrap();
5039        let (r0, _loaded, loaded_rotated) = convert_img(
5040            &mut cpu_converter,
5041            loaded,
5042            loaded_rotated,
5043            rotation,
5044            Flip::None,
5045            Crop::no_crop(),
5046        );
5047        r0.unwrap();
5048
5049        let (result, _cpu_src, cpu_dst) = convert_img(
5050            &mut cpu_converter,
5051            cpu_src,
5052            cpu_dst,
5053            rotation,
5054            Flip::None,
5055            Crop::no_crop(),
5056        );
5057        result.unwrap();
5058
5059        compare_images(&loaded_rotated, &cpu_dst, 0.98, function!());
5060    }
5061
5062    #[test]
5063    fn test_load_png_with_exif() {
5064        use edgefirst_codec::peek_info;
5065
5066        // PNGs also report EXIF orientation without applying it.
5067        // `zidane_rotated_exif_180.png` carries EXIF orientation 3 (180°).
5068        let file = edgefirst_bench::testdata::read("zidane_rotated_exif_180.png").to_vec();
5069        let info = peek_info(&file).unwrap();
5070        assert_eq!(info.rotation_degrees, 180);
5071        assert!(!info.flip_horizontal);
5072
5073        let loaded = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5074        // Native (un-rotated) dimensions — PNG decodes upright as authored.
5075        assert_eq!(loaded.height(), Some(720));
5076        assert_eq!(loaded.width(), Some(1280));
5077
5078        // The PNG fixture stores upright `zidane` pixels tagged with a 180°
5079        // EXIF orientation. Because the codec no longer applies the rotation,
5080        // the decoded pixels match `zidane.jpg` directly (no convert needed).
5081        // Re-applying the reported rotation to both must still agree.
5082        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5083        let cpu_src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5084
5085        let rotation = Rotation::from_degrees_clockwise(info.rotation_degrees as usize);
5086        let cpu_dst = TensorDyn::image(
5087            1280,
5088            720,
5089            PixelFormat::Rgba,
5090            DType::U8,
5091            None,
5092            edgefirst_tensor::CpuAccess::ReadWrite,
5093        )
5094        .unwrap();
5095        let mut cpu_converter = CPUProcessor::new();
5096
5097        let (result, _cpu_src, cpu_dst) = convert_img(
5098            &mut cpu_converter,
5099            cpu_src,
5100            cpu_dst,
5101            rotation,
5102            Flip::None,
5103            Crop::no_crop(),
5104        );
5105        result.unwrap();
5106
5107        // Rotate the decoded PNG by the same reported angle so both frames are
5108        // in the same (180°-rotated) orientation before comparing.
5109        let loaded_rotated = TensorDyn::image(
5110            1280,
5111            720,
5112            PixelFormat::Rgba,
5113            DType::U8,
5114            None,
5115            edgefirst_tensor::CpuAccess::ReadWrite,
5116        )
5117        .unwrap();
5118        let (r0, _loaded, loaded_rotated) = convert_img(
5119            &mut cpu_converter,
5120            loaded,
5121            loaded_rotated,
5122            rotation,
5123            Flip::None,
5124            Crop::no_crop(),
5125        );
5126        r0.unwrap();
5127
5128        // Threshold 0.95 (was 0.98): `loaded` comes from a lossless PNG decode
5129        // while `cpu_src` (zidane.jpg) now decodes through native NV12 (chroma
5130        // subsampling) before the RGBA conversion, so the two paths differ by a
5131        // couple of percent versus the old direct-RGB JPEG decode.
5132        compare_images(&loaded_rotated, &cpu_dst, 0.95, function!());
5133    }
5134
5135    /// Synthesise an RGB JPEG with a deterministic pattern at `(width, height)`
5136    /// using the workspace's `jpeg-encoder` crate (the `image` crate is
5137    /// compiled without its JPEG feature). Used to exercise the decoder /
5138    /// pitch-padding paths for arbitrary dimensions without having to bundle
5139    /// a fixture file per test size.
5140    #[cfg(target_os = "linux")]
5141    fn make_rgb_jpeg(width: u32, height: u32) -> Vec<u8> {
5142        let mut bytes = Vec::with_capacity((width * height * 3) as usize);
5143        for y in 0..height {
5144            for x in 0..width {
5145                bytes.push(((x + y) & 0xFF) as u8);
5146                bytes.push(((x.wrapping_mul(3)) & 0xFF) as u8);
5147                bytes.push(((y.wrapping_mul(5)) & 0xFF) as u8);
5148            }
5149        }
5150        let mut out = Vec::new();
5151        let encoder = jpeg_encoder::Encoder::new(&mut out, 85);
5152        encoder
5153            .encode(
5154                &bytes,
5155                width as u16,
5156                height as u16,
5157                jpeg_encoder::ColorType::Rgb,
5158            )
5159            .expect("jpeg-encoder must succeed on trivial input");
5160        out
5161    }
5162
5163    /// End-to-end: a 375×333 RGBA JPEG (width NOT divisible by 4) loaded
5164    /// via the pitch-padded DMA path and letterboxed through the GL
5165    /// backend must produce correct output. Before the Rgba/Bgra
5166    /// width%4 relaxation in `DmaImportAttrs::from_tensor`, this case
5167    /// failed the pre-check and forced a CPU texture upload fallback;
5168    /// with the relaxation, EGL import succeeds at the driver level and
5169    /// the GL fast path runs. Output correctness is checked against a
5170    /// CPU reference (convert ran with `EDGEFIRST_FORCE_BACKEND=cpu`).
5171    #[test]
5172    #[cfg(target_os = "linux")]
5173    #[cfg(feature = "opengl")]
5174    fn test_convert_rgba_non_4_aligned_width_end_to_end() {
5175        use edgefirst_tensor::is_dma_available;
5176        if !is_dma_available() {
5177            crate::test_support::report_skip(
5178                "test_convert_rgba_non_4_aligned_width_end_to_end — DMA not available",
5179            );
5180            return;
5181        }
5182        // 375 is the canonical failure width from dataset loaders —
5183        // 375 * 4 = 1500 bytes/row, pitch-padded to 1536. Width%4 = 3,
5184        // so the old pre-check rejected it; new code accepts it.
5185        let jpeg = make_rgb_jpeg(375, 333);
5186        let src_gl = crate::load_image_test_helper(&jpeg, Some(PixelFormat::Rgba), None).unwrap();
5187        assert_eq!(src_gl.width(), Some(375));
5188        // Row stride must still be pitch-padded (separate concern from width).
5189        let stride = src_gl.row_stride().unwrap();
5190        assert_eq!(stride, 1536, "expected padded pitch 1536, got {stride}");
5191
5192        // GL-backed convert into a pitch-aligned 640×640 Rgba dest.
5193        let mut gl_proc = ImageProcessor::new().unwrap();
5194        let gl_dst = gl_proc
5195            .create_image(
5196                640,
5197                640,
5198                PixelFormat::Rgba,
5199                DType::U8,
5200                None,
5201                edgefirst_tensor::CpuAccess::ReadWrite,
5202            )
5203            .unwrap();
5204        let (r_gl, _src_gl, gl_dst) = convert_img(
5205            &mut gl_proc,
5206            src_gl,
5207            gl_dst,
5208            Rotation::None,
5209            Flip::None,
5210            Crop::no_crop(),
5211        );
5212        r_gl.expect("GL-backed convert must succeed for 375x333 Rgba src");
5213
5214        // CPU reference via a fresh load so the two paths start from
5215        // byte-identical inputs. `with_config(backend=Cpu)` forces the
5216        // CPU-only processor regardless of which backends the host has
5217        // available.
5218        let src_cpu =
5219            crate::load_image_test_helper(&jpeg, Some(PixelFormat::Rgba), Some(TensorMemory::Mem))
5220                .unwrap();
5221        let mut cpu_proc = ImageProcessor::with_config(ImageProcessorConfig {
5222            backend: ComputeBackend::Cpu,
5223            ..Default::default()
5224        })
5225        .unwrap();
5226        let cpu_dst = TensorDyn::image(
5227            640,
5228            640,
5229            PixelFormat::Rgba,
5230            DType::U8,
5231            Some(TensorMemory::Mem),
5232            edgefirst_tensor::CpuAccess::ReadWrite,
5233        )
5234        .unwrap();
5235        let (r_cpu, _src_cpu, cpu_dst) = convert_img(
5236            &mut cpu_proc,
5237            src_cpu,
5238            cpu_dst,
5239            Rotation::None,
5240            Flip::None,
5241            Crop::no_crop(),
5242        );
5243        r_cpu.unwrap();
5244
5245        // Structural similarity: the GL path may have gone through EGL
5246        // import OR fallen back to CPU texture upload — either way, the
5247        // output must match the CPU reference closely.
5248        compare_images(&gl_dst, &cpu_dst, 0.95, function!());
5249    }
5250
5251    /// Regression lock: loading a JPEG at a non-64-aligned RGBA pitch (e.g.
5252    /// 500×333 → natural pitch 2000, needs to be padded to 2048) must go
5253    /// through `image_with_stride` and set `row_stride()` / `effective_row_stride()`
5254    /// to the padded value. The earlier pitch-padding commit fixed this in
5255    /// `load_jpeg`; a regression would surface as `row_stride == None` or
5256    /// `effective_row_stride == 2000`.
5257    #[test]
5258    #[cfg(target_os = "linux")]
5259    fn test_load_jpeg_rgba_non_aligned_pitch_padded_dma() {
5260        use edgefirst_tensor::is_dma_available;
5261        if !is_dma_available() {
5262            crate::test_support::report_skip(
5263                "test_load_jpeg_rgba_non_aligned_pitch_padded_dma — DMA not available",
5264            );
5265            return;
5266        }
5267        // Widths that force a non-64-aligned natural RGBA pitch. All three
5268        // are divisible by 4 so the EGL width-alignment pre-check passes.
5269        // The pitch-padding fix is what makes these importable at all.
5270        for &w in &[500u32, 612, 428] {
5271            let jpeg = make_rgb_jpeg(w, 333);
5272            let loaded =
5273                crate::load_image_test_helper(&jpeg, Some(PixelFormat::Rgba), None).unwrap();
5274            let natural = (w as usize) * 4;
5275            let aligned = crate::align_pitch_bytes_to_gpu_alignment(natural).unwrap();
5276            assert!(
5277                aligned > natural,
5278                "test sanity: width {w} should be unaligned"
5279            );
5280            let stride = loaded
5281                .row_stride()
5282                .expect("padded DMA path must set an explicit row_stride — regression if None");
5283            assert_eq!(
5284                stride, aligned,
5285                "width {w}: expected padded stride {aligned}, got {stride} \
5286                 (regression: pitch-padding branch skipped?)"
5287            );
5288            let eff = loaded.effective_row_stride().unwrap();
5289            assert_eq!(
5290                eff, aligned,
5291                "effective_row_stride must match stored stride"
5292            );
5293            assert_eq!(loaded.width(), Some(w as usize));
5294            assert_eq!(loaded.height(), Some(333));
5295        }
5296    }
5297
5298    /// `padded_dma_pitch_for` must respect the caller's memory choice and
5299    /// must NOT route into the pitch-padded DMA path when the caller left
5300    /// the choice to the allocator (`None`) but DMA is unavailable on the
5301    /// host. The padded path requires `image_with_stride`, which always
5302    /// allocates DMA — taking it on a system without `/dev/dma_heap`
5303    /// would convert a normally-working image load into a hard failure
5304    /// (since `Tensor::image(..., None)` would have fallen back to
5305    /// SHM/Mem).
5306    #[test]
5307    #[cfg(target_os = "linux")]
5308    fn test_padded_dma_pitch_for_respects_memory_choice() {
5309        use edgefirst_tensor::{is_dma_available, TensorMemory};
5310
5311        // 500×4 = 2000 → padded to 2048 by GPU alignment. Use it for
5312        // every case so any "no padding" answer is unambiguous.
5313        let unaligned_w = 500;
5314
5315        // Caller asks for Mem / Shm: never pad, regardless of DMA.
5316        assert_eq!(
5317            crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &Some(TensorMemory::Mem),),
5318            None,
5319            "Mem must never trigger DMA padding"
5320        );
5321        assert_eq!(
5322            crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &Some(TensorMemory::Shm),),
5323            None,
5324            "Shm must never trigger DMA padding"
5325        );
5326
5327        // Caller explicitly asks for DMA: always pad if width needs it.
5328        // Even if the runtime can't actually allocate DMA, the caller
5329        // owns that decision and the resulting allocation error is
5330        // their problem, not ours.
5331        assert_eq!(
5332            crate::padded_dma_pitch_for(
5333                PixelFormat::Rgba,
5334                unaligned_w,
5335                &Some(TensorMemory::DmaBuf),
5336            ),
5337            Some(2048),
5338            "explicit Dma must pad regardless of runtime DMA availability"
5339        );
5340
5341        // Caller leaves it to the allocator: behaviour depends on
5342        // host-runtime DMA availability. This is the case the fix
5343        // guards against.
5344        let none_result = crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &None);
5345        if is_dma_available() {
5346            assert_eq!(
5347                none_result,
5348                Some(2048),
5349                "memory=None + DMA available → pad (will route through DMA)"
5350            );
5351        } else {
5352            assert_eq!(
5353                none_result, None,
5354                "memory=None + DMA unavailable → must NOT pad (would force \
5355                 image_with_stride into a DMA-only allocation that fails). \
5356                 Regression: padded_dma_pitch_for ignored is_dma_available()."
5357            );
5358        }
5359    }
5360
5361    // Synthesise a small greyscale PNG in memory at `(width, height)` with a
5362    // deterministic ramp pattern so multiple tests can cross-check output
5363    // without bundling an extra fixture file.
5364    fn make_grey_png(width: u32, height: u32) -> Vec<u8> {
5365        let mut bytes = Vec::with_capacity((width * height) as usize);
5366        for y in 0..height {
5367            for x in 0..width {
5368                bytes.push(((x + y) & 0xFF) as u8);
5369            }
5370        }
5371        let img = image::GrayImage::from_vec(width, height, bytes).unwrap();
5372        let mut buf = Vec::new();
5373        img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
5374            .unwrap();
5375        buf
5376    }
5377
5378    /// Greyscale PNG with a width that forces a pitch-misaligned natural
5379    /// row stride (612 bytes is not a multiple of the 64-byte GPU pitch
5380    /// alignment) must still load via the pitch-padded DMA path. Gated on
5381    /// DMA availability because `image_with_stride` is DMA-only.
5382    #[test]
5383    #[cfg(target_os = "linux")]
5384    fn test_load_png_grey_misaligned_width_dma() {
5385        use edgefirst_tensor::is_dma_available;
5386        if !is_dma_available() {
5387            crate::test_support::report_skip(
5388                "test_load_png_grey_misaligned_width_dma — DMA not available",
5389            );
5390            return;
5391        }
5392        let png = make_grey_png(612, 388);
5393        let loaded = crate::load_image_test_helper(&png, Some(PixelFormat::Grey), None).unwrap();
5394        assert_eq!(loaded.width(), Some(612));
5395        assert_eq!(loaded.height(), Some(388));
5396        assert_eq!(loaded.format(), Some(PixelFormat::Grey));
5397
5398        // Round-trip pixels — natural-pitch DMA-BUFs pad the stride so we
5399        // must indirect through row_stride() rather than assume width.
5400        let map = loaded.as_typed::<u8>().unwrap().map().unwrap();
5401        let stride = loaded.row_stride().unwrap_or(612);
5402        assert!(stride >= 612);
5403        let bytes: &[u8] = &map;
5404        for y in 0..388usize {
5405            for x in 0..612usize {
5406                let expected = ((x + y) & 0xFF) as u8;
5407                let got = bytes[y * stride + x];
5408                assert_eq!(
5409                    got, expected,
5410                    "grey png mismatch at ({x},{y}): got {got} expected {expected}"
5411                );
5412            }
5413        }
5414    }
5415
5416    /// Greyscale PNG loaded with explicit Mem backing — runs on any
5417    /// platform (no DMA permission requirement) and covers the
5418    /// decoder-native Luma → Grey no-conversion path.
5419    #[test]
5420    fn test_load_png_grey_mem() {
5421        use edgefirst_tensor::TensorMemory;
5422        let png = make_grey_png(612, 100);
5423        let loaded =
5424            crate::load_image_test_helper(&png, Some(PixelFormat::Grey), Some(TensorMemory::Mem))
5425                .unwrap();
5426        assert_eq!(loaded.width(), Some(612));
5427        assert_eq!(loaded.height(), Some(100));
5428        assert_eq!(loaded.format(), Some(PixelFormat::Grey));
5429        let map = loaded.as_typed::<u8>().unwrap().map().unwrap();
5430        let bytes: &[u8] = &map;
5431        // Mem allocation uses the natural pitch — 612 bytes per row, exact.
5432        assert_eq!(bytes.len(), 612 * 100);
5433        for y in 0..100 {
5434            for x in 0..612 {
5435                assert_eq!(bytes[y * 612 + x], ((x + y) & 0xFF) as u8);
5436            }
5437        }
5438    }
5439
5440    /// Greyscale PNG decoded into RGB — exercises the decoder-colorspace
5441    /// mismatch path (Luma → Rgb via CPU converter). Uses Mem memory to
5442    /// stay portable to host-side test environments.
5443    #[test]
5444    fn test_load_png_grey_to_rgb_mem() {
5445        use edgefirst_tensor::TensorMemory;
5446        let png = make_grey_png(620, 240);
5447        let loaded =
5448            crate::load_image_test_helper(&png, Some(PixelFormat::Rgb), Some(TensorMemory::Mem))
5449                .unwrap();
5450        assert_eq!(loaded.width(), Some(620));
5451        assert_eq!(loaded.height(), Some(240));
5452        assert_eq!(loaded.format(), Some(PixelFormat::Rgb));
5453
5454        // Greyscale promoted to RGB replicates luma into each channel.
5455        let map = loaded.as_typed::<u8>().unwrap().map().unwrap();
5456        let bytes: &[u8] = &map;
5457        for (x, y) in [(0usize, 0usize), (100, 50), (619, 239)] {
5458            let expected = ((x + y) & 0xFF) as u8;
5459            let off = (y * 620 + x) * 3;
5460            assert_eq!(bytes[off], expected, "R@{x},{y}");
5461            assert_eq!(bytes[off + 1], expected, "G@{x},{y}");
5462            assert_eq!(bytes[off + 2], expected, "B@{x},{y}");
5463        }
5464    }
5465
5466    #[test]
5467    #[cfg(target_os = "linux")]
5468    fn test_g2d_resize() {
5469        if !is_g2d_available() {
5470            crate::test_support::report_skip(
5471                "test_g2d_resize - G2D library (libg2d.so.2) not available",
5472            );
5473            return;
5474        }
5475        if !is_dma_available() {
5476            crate::test_support::report_skip("test_g2d_resize - DMA memory allocation not available (permission denied or no DMA-BUF support)");
5477            return;
5478        }
5479
5480        let dst_width = 640;
5481        let dst_height = 360;
5482        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5483        let src = crate::load_image_test_helper(
5484            &file,
5485            Some(PixelFormat::Rgba),
5486            Some(TensorMemory::DmaBuf),
5487        )
5488        .unwrap();
5489
5490        let g2d_dst = TensorDyn::image(
5491            dst_width,
5492            dst_height,
5493            PixelFormat::Rgba,
5494            DType::U8,
5495            Some(TensorMemory::DmaBuf),
5496            edgefirst_tensor::CpuAccess::ReadWrite,
5497        )
5498        .unwrap();
5499        let mut g2d_converter = G2DProcessor::new().unwrap();
5500        let (result, src, g2d_dst) = convert_img(
5501            &mut g2d_converter,
5502            src,
5503            g2d_dst,
5504            Rotation::None,
5505            Flip::None,
5506            Crop::no_crop(),
5507        );
5508        result.unwrap();
5509
5510        let cpu_dst = TensorDyn::image(
5511            dst_width,
5512            dst_height,
5513            PixelFormat::Rgba,
5514            DType::U8,
5515            None,
5516            edgefirst_tensor::CpuAccess::ReadWrite,
5517        )
5518        .unwrap();
5519        let mut cpu_converter = CPUProcessor::new();
5520        let (result, _src, cpu_dst) = convert_img(
5521            &mut cpu_converter,
5522            src,
5523            cpu_dst,
5524            Rotation::None,
5525            Flip::None,
5526            Crop::no_crop(),
5527        );
5528        result.unwrap();
5529
5530        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
5531        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
5532        // the YUV-matrix delta that forced 0.95 has closed; tightened to
5533        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
5534        // structural gap not exercised by these limited-range fixtures.
5535        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
5536    }
5537
5538    #[test]
5539    #[cfg(any(
5540        target_os = "linux",
5541        target_os = "macos",
5542        target_os = "ios",
5543        target_os = "android",
5544        target_os = "windows"
5545    ))]
5546    #[cfg(feature = "opengl")]
5547    fn test_opengl_resize() {
5548        if !is_opengl_available() {
5549            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
5550            return;
5551        }
5552
5553        let dst_width = 640;
5554        let dst_height = 360;
5555        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5556        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5557
5558        let cpu_dst = TensorDyn::image(
5559            dst_width,
5560            dst_height,
5561            PixelFormat::Rgba,
5562            DType::U8,
5563            None,
5564            edgefirst_tensor::CpuAccess::ReadWrite,
5565        )
5566        .unwrap();
5567        let mut cpu_converter = CPUProcessor::new();
5568        let (result, src, cpu_dst) = convert_img(
5569            &mut cpu_converter,
5570            src,
5571            cpu_dst,
5572            Rotation::None,
5573            Flip::None,
5574            Crop::no_crop(),
5575        );
5576        result.unwrap();
5577
5578        let mut src = src;
5579        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
5580
5581        for _ in 0..5 {
5582            let gl_dst = TensorDyn::image(
5583                dst_width,
5584                dst_height,
5585                PixelFormat::Rgba,
5586                DType::U8,
5587                None,
5588                edgefirst_tensor::CpuAccess::ReadWrite,
5589            )
5590            .unwrap();
5591            let (result, src_back, gl_dst) = convert_img(
5592                &mut gl_converter,
5593                src,
5594                gl_dst,
5595                Rotation::None,
5596                Flip::None,
5597                Crop::no_crop(),
5598            );
5599            result.unwrap();
5600            src = src_back;
5601
5602            compare_images(&gl_dst, &cpu_dst, 0.98, function!());
5603        }
5604    }
5605
5606    #[test]
5607    #[cfg(any(
5608        target_os = "linux",
5609        target_os = "macos",
5610        target_os = "ios",
5611        target_os = "android",
5612        target_os = "windows"
5613    ))]
5614    #[cfg(feature = "opengl")]
5615    fn test_opengl_10_threads() {
5616        if !is_opengl_available() {
5617            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
5618            return;
5619        }
5620
5621        let handles: Vec<_> = (0..10)
5622            .map(|i| {
5623                std::thread::Builder::new()
5624                    .name(format!("Thread {i}"))
5625                    .spawn(test_opengl_resize)
5626                    .unwrap()
5627            })
5628            .collect();
5629        handles.into_iter().for_each(|h| {
5630            if let Err(e) = h.join() {
5631                std::panic::resume_unwind(e)
5632            }
5633        });
5634    }
5635
5636    #[test]
5637    #[cfg(any(
5638        target_os = "linux",
5639        target_os = "macos",
5640        target_os = "ios",
5641        target_os = "android",
5642        target_os = "windows"
5643    ))]
5644    #[cfg(feature = "opengl")]
5645    fn test_opengl_grey() {
5646        if !is_opengl_available() {
5647            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
5648            return;
5649        }
5650
5651        let img = crate::load_image_test_helper(
5652            &edgefirst_bench::testdata::read("grey.jpg"),
5653            Some(PixelFormat::Grey),
5654            None,
5655        )
5656        .unwrap();
5657
5658        let gl_dst = TensorDyn::image(
5659            640,
5660            640,
5661            PixelFormat::Grey,
5662            DType::U8,
5663            None,
5664            edgefirst_tensor::CpuAccess::ReadWrite,
5665        )
5666        .unwrap();
5667        let cpu_dst = TensorDyn::image(
5668            640,
5669            640,
5670            PixelFormat::Grey,
5671            DType::U8,
5672            None,
5673            edgefirst_tensor::CpuAccess::ReadWrite,
5674        )
5675        .unwrap();
5676
5677        let mut converter = CPUProcessor::new();
5678
5679        let (result, img, cpu_dst) = convert_img(
5680            &mut converter,
5681            img,
5682            cpu_dst,
5683            Rotation::None,
5684            Flip::None,
5685            Crop::no_crop(),
5686        );
5687        result.unwrap();
5688
5689        let mut gl = GLProcessorThreaded::new(None).unwrap();
5690        let (result, _img, gl_dst) = convert_img(
5691            &mut gl,
5692            img,
5693            gl_dst,
5694            Rotation::None,
5695            Flip::None,
5696            Crop::no_crop(),
5697        );
5698        result.unwrap();
5699
5700        compare_images(&gl_dst, &cpu_dst, 0.98, function!());
5701    }
5702
5703    #[test]
5704    #[cfg(target_os = "linux")]
5705    fn test_g2d_src_crop() {
5706        if !is_g2d_available() {
5707            crate::test_support::report_skip(
5708                "test_g2d_src_crop - G2D library (libg2d.so.2) not available",
5709            );
5710            return;
5711        }
5712        if !is_dma_available() {
5713            crate::test_support::report_skip("test_g2d_src_crop - DMA memory allocation not available (permission denied or no DMA-BUF support)");
5714            return;
5715        }
5716
5717        let dst_width = 640;
5718        let dst_height = 640;
5719        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5720        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5721
5722        let cpu_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 cpu_converter = CPUProcessor::new();
5732        let crop = Crop::new().with_source(Some(Region::new(0, 0, 640, 360)));
5733        let (result, src, cpu_dst) = convert_img(
5734            &mut cpu_converter,
5735            src,
5736            cpu_dst,
5737            Rotation::None,
5738            Flip::None,
5739            crop,
5740        );
5741        result.unwrap();
5742
5743        let g2d_dst = TensorDyn::image(
5744            dst_width,
5745            dst_height,
5746            PixelFormat::Rgba,
5747            DType::U8,
5748            None,
5749            edgefirst_tensor::CpuAccess::ReadWrite,
5750        )
5751        .unwrap();
5752        let mut g2d_converter = G2DProcessor::new().unwrap();
5753        let (result, _src, g2d_dst) = convert_img(
5754            &mut g2d_converter,
5755            src,
5756            g2d_dst,
5757            Rotation::None,
5758            Flip::None,
5759            crop,
5760        );
5761        result.unwrap();
5762
5763        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
5764        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
5765        // the YUV-matrix delta that forced 0.95 has closed; tightened to
5766        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
5767        // structural gap not exercised by these limited-range fixtures.
5768        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
5769    }
5770
5771    #[test]
5772    #[cfg(target_os = "linux")]
5773    fn test_g2d_dst_crop() {
5774        if !is_g2d_available() {
5775            crate::test_support::report_skip(
5776                "test_g2d_dst_crop - G2D library (libg2d.so.2) not available",
5777            );
5778            return;
5779        }
5780        if !is_dma_available() {
5781            crate::test_support::report_skip("test_g2d_dst_crop - DMA memory allocation not available (permission denied or no DMA-BUF support)");
5782            return;
5783        }
5784
5785        let dst_width = 640;
5786        let dst_height = 640;
5787        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5788        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5789
5790        let cpu_dst = TensorDyn::image(
5791            dst_width,
5792            dst_height,
5793            PixelFormat::Rgba,
5794            DType::U8,
5795            None,
5796            edgefirst_tensor::CpuAccess::ReadWrite,
5797        )
5798        .unwrap();
5799        let mut cpu_converter = CPUProcessor::new();
5800        let crop = Crop::new();
5801        let (result, src, cpu_dst) = convert_img(
5802            &mut cpu_converter,
5803            src,
5804            cpu_dst,
5805            Rotation::None,
5806            Flip::None,
5807            crop,
5808        );
5809        result.unwrap();
5810
5811        let g2d_dst = TensorDyn::image(
5812            dst_width,
5813            dst_height,
5814            PixelFormat::Rgba,
5815            DType::U8,
5816            None,
5817            edgefirst_tensor::CpuAccess::ReadWrite,
5818        )
5819        .unwrap();
5820        let mut g2d_converter = G2DProcessor::new().unwrap();
5821        let (result, _src, g2d_dst) = convert_img(
5822            &mut g2d_converter,
5823            src,
5824            g2d_dst,
5825            Rotation::None,
5826            Flip::None,
5827            crop,
5828        );
5829        result.unwrap();
5830
5831        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
5832        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
5833        // the YUV-matrix delta that forced 0.95 has closed; tightened to
5834        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
5835        // structural gap not exercised by these limited-range fixtures.
5836        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
5837    }
5838
5839    #[test]
5840    #[cfg(target_os = "linux")]
5841    fn test_g2d_all_rgba() {
5842        if !is_g2d_available() {
5843            crate::test_support::report_skip(
5844                "test_g2d_all_rgba - G2D library (libg2d.so.2) not available",
5845            );
5846            return;
5847        }
5848        if !is_dma_available() {
5849            crate::test_support::report_skip("test_g2d_all_rgba - DMA memory allocation not available (permission denied or no DMA-BUF support)");
5850            return;
5851        }
5852
5853        let dst_width = 640;
5854        let dst_height = 640;
5855        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5856        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5857        let src_dyn = src;
5858
5859        let mut cpu_dst = TensorDyn::image(
5860            dst_width,
5861            dst_height,
5862            PixelFormat::Rgba,
5863            DType::U8,
5864            None,
5865            edgefirst_tensor::CpuAccess::ReadWrite,
5866        )
5867        .unwrap();
5868        let mut cpu_converter = CPUProcessor::new();
5869        let mut g2d_dst = TensorDyn::image(
5870            dst_width,
5871            dst_height,
5872            PixelFormat::Rgba,
5873            DType::U8,
5874            None,
5875            edgefirst_tensor::CpuAccess::ReadWrite,
5876        )
5877        .unwrap();
5878        let mut g2d_converter = G2DProcessor::new().unwrap();
5879
5880        let crop = Crop::new().with_source(Some(Region::new(50, 120, 1024, 576)));
5881
5882        for rot in [
5883            Rotation::None,
5884            Rotation::Clockwise90,
5885            Rotation::Rotate180,
5886            Rotation::CounterClockwise90,
5887        ] {
5888            cpu_dst
5889                .as_typed::<u8>()
5890                .unwrap()
5891                .map()
5892                .unwrap()
5893                .as_mut_slice()
5894                .fill(114);
5895            g2d_dst
5896                .as_typed::<u8>()
5897                .unwrap()
5898                .map()
5899                .unwrap()
5900                .as_mut_slice()
5901                .fill(114);
5902            for flip in [Flip::None, Flip::Horizontal, Flip::Vertical] {
5903                let mut cpu_dst_dyn = cpu_dst;
5904                cpu_converter
5905                    .convert(&src_dyn, &mut cpu_dst_dyn, Rotation::None, Flip::None, crop)
5906                    .unwrap();
5907                cpu_dst = {
5908                    let mut __t = cpu_dst_dyn.into_u8().unwrap();
5909                    __t.set_format(PixelFormat::Rgba).unwrap();
5910                    TensorDyn::from(__t)
5911                };
5912
5913                let mut g2d_dst_dyn = g2d_dst;
5914                g2d_converter
5915                    .convert(&src_dyn, &mut g2d_dst_dyn, Rotation::None, Flip::None, crop)
5916                    .unwrap();
5917                g2d_dst = {
5918                    let mut __t = g2d_dst_dyn.into_u8().unwrap();
5919                    __t.set_format(PixelFormat::Rgba).unwrap();
5920                    TensorDyn::from(__t)
5921                };
5922
5923                compare_images(
5924                    &g2d_dst,
5925                    &cpu_dst,
5926                    0.98,
5927                    &format!("{} {:?} {:?}", function!(), rot, flip),
5928                );
5929            }
5930        }
5931    }
5932
5933    #[test]
5934    #[cfg(any(
5935        target_os = "linux",
5936        target_os = "macos",
5937        target_os = "ios",
5938        target_os = "android",
5939        target_os = "windows"
5940    ))]
5941    #[cfg(feature = "opengl")]
5942    fn test_opengl_src_crop() {
5943        if !is_opengl_available() {
5944            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
5945            return;
5946        }
5947
5948        let dst_width = 640;
5949        let dst_height = 360;
5950        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5951        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5952        let crop = Crop::new().with_source(Some(Region::new(320, 180, 1280 - 320, 720 - 180)));
5953
5954        let cpu_dst = TensorDyn::image(
5955            dst_width,
5956            dst_height,
5957            PixelFormat::Rgba,
5958            DType::U8,
5959            None,
5960            edgefirst_tensor::CpuAccess::ReadWrite,
5961        )
5962        .unwrap();
5963        let mut cpu_converter = CPUProcessor::new();
5964        let (result, src, cpu_dst) = convert_img(
5965            &mut cpu_converter,
5966            src,
5967            cpu_dst,
5968            Rotation::None,
5969            Flip::None,
5970            crop,
5971        );
5972        result.unwrap();
5973
5974        let gl_dst = TensorDyn::image(
5975            dst_width,
5976            dst_height,
5977            PixelFormat::Rgba,
5978            DType::U8,
5979            None,
5980            edgefirst_tensor::CpuAccess::ReadWrite,
5981        )
5982        .unwrap();
5983        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
5984        let (result, _src, gl_dst) = convert_img(
5985            &mut gl_converter,
5986            src,
5987            gl_dst,
5988            Rotation::None,
5989            Flip::None,
5990            crop,
5991        );
5992        result.unwrap();
5993
5994        compare_images(&gl_dst, &cpu_dst, 0.98, function!());
5995    }
5996
5997    #[test]
5998    #[cfg(any(
5999        target_os = "linux",
6000        target_os = "macos",
6001        target_os = "ios",
6002        target_os = "android",
6003        target_os = "windows"
6004    ))]
6005    #[cfg(feature = "opengl")]
6006    fn test_opengl_dst_crop() {
6007        if !is_opengl_available() {
6008            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
6009            return;
6010        }
6011
6012        let dst_width = 640;
6013        let dst_height = 640;
6014        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
6015        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
6016
6017        let cpu_dst = TensorDyn::image(
6018            dst_width,
6019            dst_height,
6020            PixelFormat::Rgba,
6021            DType::U8,
6022            None,
6023            edgefirst_tensor::CpuAccess::ReadWrite,
6024        )
6025        .unwrap();
6026        let mut cpu_converter = CPUProcessor::new();
6027        let crop = Crop::new();
6028        let (result, src, cpu_dst) = convert_img(
6029            &mut cpu_converter,
6030            src,
6031            cpu_dst,
6032            Rotation::None,
6033            Flip::None,
6034            crop,
6035        );
6036        result.unwrap();
6037
6038        let gl_dst = TensorDyn::image(
6039            dst_width,
6040            dst_height,
6041            PixelFormat::Rgba,
6042            DType::U8,
6043            None,
6044            edgefirst_tensor::CpuAccess::ReadWrite,
6045        )
6046        .unwrap();
6047        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
6048        let (result, _src, gl_dst) = convert_img(
6049            &mut gl_converter,
6050            src,
6051            gl_dst,
6052            Rotation::None,
6053            Flip::None,
6054            crop,
6055        );
6056        result.unwrap();
6057
6058        compare_images(&gl_dst, &cpu_dst, 0.98, function!());
6059    }
6060    /// A deterministic RGBA8 `Mem` source: `R = x`, `G = y`, `B = x + y`,
6061    /// `A = 255`, each `& 255`. Written at the tensor's own row stride so a
6062    /// padded allocation still gets the pattern at the right columns.
6063    #[cfg(all(target_os = "windows", feature = "opengl"))]
6064    fn synthetic_rgba(w: usize, h: usize) -> TensorDyn {
6065        let t = TensorDyn::image(
6066            w,
6067            h,
6068            PixelFormat::Rgba,
6069            DType::U8,
6070            Some(TensorMemory::Mem),
6071            edgefirst_tensor::CpuAccess::ReadWrite,
6072        )
6073        .unwrap();
6074        fill_synthetic_rgba(&t);
6075        t
6076    }
6077
6078    /// Every pixel of `actual` equals `expected`, compared through the
6079    /// tensors' own row compaction so a padded texture and a tight `Mem`
6080    /// image compare as images. `compare_images` scores similarity and
6081    /// accepts anything at a 0.0 threshold; this is the exact check.
6082    #[cfg(all(target_os = "windows", feature = "opengl"))]
6083    fn assert_pixels_identical(actual: &TensorDyn, expected: &TensorDyn, name: &str) {
6084        assert_eq!(actual.shape(), expected.shape(), "{name}: shapes differ");
6085        let flat = |t: &TensorDyn| {
6086            let typed = t.as_typed::<u8>().expect("u8 image");
6087            let mut out = vec![0u8; t.shape().iter().product::<usize>()];
6088            typed.copy_to_flat(&mut out).expect("compact the rows");
6089            out
6090        };
6091        let (a, e) = (flat(actual), flat(expected));
6092        let mismatches = a.iter().zip(&e).filter(|(x, y)| x != y).count();
6093        assert_eq!(
6094            mismatches,
6095            0,
6096            "{name}: {mismatches} of {} bytes differ (first rows: {:?} vs {:?})",
6097            a.len(),
6098            &a[..16.min(a.len())],
6099            &e[..16.min(e.len())]
6100        );
6101    }
6102
6103    /// Write the `synthetic_rgba` pattern into any writable RGBA8 tensor,
6104    /// a texture included, at its own row stride.
6105    #[cfg(all(target_os = "windows", feature = "opengl"))]
6106    fn fill_synthetic_rgba(t: &TensorDyn) {
6107        let w = t.width().unwrap();
6108        let h = t.height().unwrap();
6109        let bound = t.as_typed::<u8>().unwrap();
6110        let stride = bound.effective_row_stride().unwrap_or(w * 4);
6111        let mut m = bound.map().unwrap();
6112        let px = m.as_mut_slice();
6113        for y in 0..h {
6114            for x in 0..w {
6115                let o = y * stride + x * 4;
6116                px[o] = x as u8;
6117                px[o + 1] = y as u8;
6118                px[o + 2] = (x + y) as u8;
6119                px[o + 3] = 255;
6120            }
6121        }
6122    }
6123
6124    /// Element-wise comparison of two float images of the same format and
6125    /// dtype.
6126    ///
6127    /// Both sides are read through `copy_to_flat`, the tensor's own row
6128    /// compaction, for the reason `compare_images` does: a Windows texture
6129    /// destination pads its rows, so reading the head of the mapping as a
6130    /// tight image would read padding as values. F16 values are widened with
6131    /// `half::f16::to_f64` before the comparison.
6132    #[cfg(all(target_os = "windows", feature = "opengl"))]
6133    fn compare_float_images(a: &TensorDyn, b: &TensorDyn, tol: f64, what: &str) {
6134        assert_eq!(a.format(), b.format(), "{what}: pixel formats differ");
6135        assert_eq!(a.dtype(), b.dtype(), "{what}: dtypes differ");
6136        assert_eq!(a.shape(), b.shape(), "{what}: shapes differ");
6137        let values = |t: &TensorDyn| -> Vec<f64> {
6138            let elems: usize = t.shape().iter().product();
6139            match t.dtype() {
6140                DType::F32 => {
6141                    let typed = t.as_typed::<f32>().expect("F32 image");
6142                    let mut bytes = vec![0u8; elems * 4];
6143                    typed
6144                        .copy_to_flat(&mut bytes)
6145                        .expect("compact the padded rows");
6146                    bytes
6147                        .as_chunks::<4>()
6148                        .0
6149                        .iter()
6150                        .map(|c| f32::from_le_bytes(*c) as f64)
6151                        .collect()
6152                }
6153                DType::F16 => {
6154                    let typed = t.as_typed::<half::f16>().expect("F16 image");
6155                    let mut bytes = vec![0u8; elems * 2];
6156                    typed
6157                        .copy_to_flat(&mut bytes)
6158                        .expect("compact the padded rows");
6159                    bytes
6160                        .as_chunks::<2>()
6161                        .0
6162                        .iter()
6163                        .map(|c| half::f16::from_le_bytes(*c).to_f64())
6164                        .collect()
6165                }
6166                other => panic!("{what}: {other:?} is not a float dtype"),
6167            }
6168        };
6169        let (av, bv) = (values(a), values(b));
6170        assert_eq!(av.len(), bv.len(), "{what}: element counts differ");
6171        for (i, (x, y)) in av.iter().zip(bv.iter()).enumerate() {
6172            assert!(
6173                (x - y).abs() <= tol,
6174                "{what}: element {i} differs by {} (gpu {x}, cpu {y}, tol {tol})",
6175                (x - y).abs()
6176            );
6177        }
6178    }
6179
6180    /// The float PBO readback has to land each destination row at the
6181    /// destination's own pitch, exactly as the u8 PBO readback does.
6182    ///
6183    /// The packed render target's rows ARE the destination's rows -- `(W/4,
6184    /// 3H)` RGBA16F texels, one plane row per surface row, for
6185    /// `PlanarRgb`/F16; `(W*3, H)` R32F texels, one image row per surface
6186    /// row, for `Rgb`/F32 -- and the read was tight whatever pitch the
6187    /// destination declared. A pool tensor narrowed with `configure_image`
6188    /// that keeps the pool's pitch is exactly that destination, so every row
6189    /// after the first landed short.
6190    ///
6191    /// Both routes the pitch can take are covered. `POOL_W`'s pitch is a
6192    /// whole number of texels for both formats, which `GL_PACK_ROW_LENGTH`
6193    /// expresses; the F16 case adds a 4-byte pad, which an 8-byte texel does
6194    /// not divide, so those rows are read tight and spread afterwards. The
6195    /// tight stride is the control: it must still take the read it always
6196    /// did.
6197    ///
6198    /// The oracle is the same convert into a tight PBO of the same geometry.
6199    /// `compare_float_images` reads both sides through `copy_to_flat`, the
6200    /// tensor's own row compaction, so it compares logical elements and not
6201    /// mapping offsets.
6202    #[test]
6203    #[cfg(all(target_os = "windows", feature = "opengl"))]
6204    fn windows_padded_float_pbo_rows_land_at_the_declared_stride() {
6205        if !is_opengl_available() {
6206            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
6207            return;
6208        }
6209        const W: usize = 64;
6210        const H: usize = 32;
6211        const POOL_W: usize = 96;
6212
6213        let mut gpu = ImageProcessor::new().unwrap();
6214        let support = gpu.supported_render_dtypes();
6215        let src = synthetic_rgba(W, H);
6216
6217        // (format, dtype, supported, the pitches to declare)
6218        let cases: [(PixelFormat, DType, bool, &[usize]); 2] = [
6219            (
6220                PixelFormat::PlanarRgb,
6221                DType::F16,
6222                support.f16,
6223                // Tight plane row, the pool's pitch (a whole texel count),
6224                // and a 4-byte pad that no 8-byte texel divides.
6225                &[W * 2, POOL_W * 2, W * 2 + 4],
6226            ),
6227            (
6228                PixelFormat::Rgb,
6229                DType::F32,
6230                support.f32,
6231                &[W * 3 * 4, POOL_W * 3 * 4],
6232            ),
6233        ];
6234
6235        for (fmt, dtype, supported, strides) in cases {
6236            if !supported {
6237                crate::test_support::report_skip(&format!(
6238                    "{} - {fmt:?}/{dtype:?} not reported",
6239                    function!()
6240                ));
6241                continue;
6242            }
6243            let Some(gl) = gpu.opengl.as_ref() else {
6244                crate::test_support::report_skip(&format!("{} - no GL processor", function!()));
6245                return;
6246            };
6247            // The oracle: a tight PBO of the target geometry, same route.
6248            let mut reference = match gl.create_pbo_image_dtype(W, H, fmt, dtype) {
6249                Ok(t) => t,
6250                Err(e) => {
6251                    crate::test_support::report_skip(&format!(
6252                        "{} - no float PBO images: {e:?}",
6253                        function!()
6254                    ));
6255                    return;
6256                }
6257            };
6258            assert_eq!(
6259                reference.memory(),
6260                TensorMemory::Pbo,
6261                "{fmt:?}/{dtype:?}: the destination must be a PBO to reach this readback"
6262            );
6263            gpu.convert(
6264                &src,
6265                &mut reference,
6266                Rotation::None,
6267                Flip::None,
6268                Crop::default(),
6269            )
6270            .unwrap_or_else(|e| panic!("{fmt:?}/{dtype:?}: tight convert failed: {e}"));
6271
6272            for &stride in strides {
6273                let label = format!("{fmt:?}/{dtype:?} at a {stride} B pitch");
6274                // Allocate at the pool width, then narrow the geometry and
6275                // declare the pitch, as the u8 stride tests do.
6276                let mut padded = gpu
6277                    .opengl
6278                    .as_ref()
6279                    .expect("GL processor")
6280                    .create_pbo_image_dtype(POOL_W, H, fmt, dtype)
6281                    .unwrap_or_else(|e| panic!("{label}: pool PBO: {e:?}"));
6282                padded
6283                    .configure_image(W, H, fmt)
6284                    .unwrap_or_else(|e| panic!("{label}: narrow the pool tensor: {e}"));
6285                padded
6286                    .set_row_stride(stride)
6287                    .unwrap_or_else(|e| panic!("{label}: declare the pitch: {e}"));
6288                assert_eq!(padded.effective_row_stride(), Some(stride), "{label}");
6289
6290                let before = gpu.convert_fallback_count();
6291                gpu.convert(
6292                    &src,
6293                    &mut padded,
6294                    Rotation::None,
6295                    Flip::None,
6296                    Crop::default(),
6297                )
6298                .unwrap_or_else(|e| panic!("{label}: padded convert failed: {e}"));
6299                // A CPU fallback writes the destination with the stride-aware
6300                // CPU writers and would pass without the GL readback running.
6301                assert_eq!(
6302                    gpu.convert_fallback_count(),
6303                    before,
6304                    "{label}: fell back to the CPU backend, so the float PBO readback under test never ran"
6305                );
6306
6307                let tol = if dtype == DType::F16 {
6308                    1.0 / 1024.0
6309                } else {
6310                    1e-6
6311                };
6312                compare_float_images(&padded, &reference, tol, &label);
6313            }
6314        }
6315    }
6316
6317    /// Every float destination layout the GL backend reports it can render
6318    /// must render zero-copy into the texture and match the CPU converter
6319    /// element for element: F16 and F32 planar (three and four planes),
6320    /// interleaved float RGB, and float RGBA.
6321    ///
6322    /// The destination memory is requested explicitly so each layout is
6323    /// exercised as a texture whatever `create_image`'s own allocation
6324    /// policy would pick for it; the policy itself is covered by
6325    /// `windows_create_image_prefers_textures_and_convert_with_fence_returns_a_set_event`.
6326    #[test]
6327    #[cfg(all(target_os = "windows", feature = "opengl"))]
6328    fn windows_float_destinations_match_the_cpu_converter_for_every_layout() {
6329        if !is_opengl_available() {
6330            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
6331            return;
6332        }
6333        if !edgefirst_tensor::is_gpu_buffer_available() {
6334            crate::test_support::report_skip(&format!(
6335                "{} - no zero-copy GPU buffers",
6336                function!()
6337            ));
6338            return;
6339        }
6340        let mut gpu = ImageProcessor::new().unwrap();
6341        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
6342            backend: ComputeBackend::Cpu,
6343            ..Default::default()
6344        })
6345        .unwrap();
6346        let support = gpu.supported_render_dtypes();
6347        eprintln!("{}: supported_render_dtypes = {support:?}", function!());
6348        let src = synthetic_rgba(640, 480);
6349        for (fmt, dtype, need) in [
6350            (PixelFormat::PlanarRgb, DType::F16, support.f16),
6351            (PixelFormat::PlanarRgb, DType::F32, support.f32),
6352            (PixelFormat::PlanarRgba, DType::F16, support.f16),
6353            (PixelFormat::PlanarRgba, DType::F32, support.f32),
6354            (PixelFormat::Rgb, DType::F16, support.f16),
6355            (PixelFormat::Rgb, DType::F32, support.f32),
6356            (PixelFormat::Rgba, DType::F16, support.f16),
6357            (PixelFormat::Rgba, DType::F32, support.f32),
6358        ] {
6359            if !need {
6360                eprintln!("{}: {fmt:?}/{dtype:?} not reported", function!());
6361                continue;
6362            }
6363            let mut dst_gpu = gpu
6364                .create_image(
6365                    640,
6366                    480,
6367                    fmt,
6368                    dtype,
6369                    Some(TensorMemory::DmaBuf),
6370                    edgefirst_tensor::CpuAccess::ReadWrite,
6371                )
6372                .unwrap();
6373            assert_eq!(
6374                dst_gpu.memory(),
6375                TensorMemory::DmaBuf,
6376                "{fmt:?}/{dtype:?} must be a texture"
6377            );
6378            let mut dst_cpu = TensorDyn::image(
6379                640,
6380                480,
6381                fmt,
6382                dtype,
6383                Some(TensorMemory::Mem),
6384                edgefirst_tensor::CpuAccess::ReadWrite,
6385            )
6386            .unwrap();
6387            let before = gpu.convert_fallback_count();
6388            gpu.convert(
6389                &src,
6390                &mut dst_gpu,
6391                Rotation::None,
6392                Flip::None,
6393                Crop::default(),
6394            )
6395            .unwrap();
6396            cpu.convert(
6397                &src,
6398                &mut dst_cpu,
6399                Rotation::None,
6400                Flip::None,
6401                Crop::default(),
6402            )
6403            .unwrap();
6404            assert_eq!(
6405                gpu.convert_fallback_count(),
6406                before,
6407                "{fmt:?}/{dtype:?} fell back to CPU"
6408            );
6409            let tol = if dtype == DType::F16 {
6410                1.0 / 1024.0
6411            } else {
6412                1e-6
6413            };
6414            compare_float_images(&dst_gpu, &dst_cpu, tol, &format!("{fmt:?}/{dtype:?}"));
6415        }
6416    }
6417
6418    // The tests below wait on a Windows completion fence directly, so they
6419    // need the raw wait call — no crate in this workspace wraps it.
6420    #[cfg(target_os = "windows")]
6421    #[link(name = "kernel32")]
6422    extern "system" {
6423        fn WaitForSingleObject(handle: *mut std::ffi::c_void, milliseconds: u32) -> u32;
6424    }
6425
6426    /// A batch of deferred converts records one distinct fence value per
6427    /// tile and submits once, at `flush`.
6428    ///
6429    /// The values are what a GPU consumer waits on, so they have to stay
6430    /// per-destination and strictly increasing; the submission is what
6431    /// batching exists to spare, so there must be exactly one of it. The
6432    /// destinations are compared against the same convert done standalone,
6433    /// which is what proves the deferred submission did not lose a tile.
6434    #[cfg(all(target_os = "windows", feature = "opengl"))]
6435    #[test]
6436    fn windows_deferred_batch_records_one_value_per_tile_and_submits_once() {
6437        use edgefirst_tensor::CpuAccess;
6438
6439        if !edgefirst_tensor::is_gpu_buffer_available() {
6440            crate::test_support::report_skip(&format!(
6441                "{} - no zero-copy GPU buffers",
6442                function!()
6443            ));
6444            return;
6445        }
6446        let mut p = match ImageProcessor::new() {
6447            Ok(p) => p,
6448            Err(e) => {
6449                crate::test_support::report_skip(&format!(
6450                    "{} - ImageProcessor init failed ({e:?})",
6451                    function!()
6452                ));
6453                return;
6454            }
6455        };
6456        let (w, h, tiles) = (640usize, 480usize, 4usize);
6457        let src = synthetic_rgba(w, h);
6458        let mut dsts: Vec<TensorDyn> = (0..tiles)
6459            .map(|_| {
6460                p.create_image(w, h, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
6461                    .unwrap()
6462            })
6463            .collect();
6464        if dsts[0].memory() != TensorMemory::DmaBuf {
6465            crate::test_support::report_skip(&format!(
6466                "{} - destinations are not textures",
6467                function!()
6468            ));
6469            return;
6470        }
6471        for dst in &mut dsts {
6472            p.convert_deferred(&src, dst, Rotation::None, Flip::None, Crop::default())
6473                .unwrap();
6474        }
6475        let values: Vec<u64> = dsts
6476            .iter()
6477            .map(|d| {
6478                d.gpu_completion()
6479                    .unwrap()
6480                    .expect("each deferred convert records its own completion")
6481                    .value
6482            })
6483            .collect();
6484        for pair in values.windows(2) {
6485            assert!(
6486                pair[1] > pair[0],
6487                "the batch allocated {values:?}, which is not one increasing value per tile"
6488            );
6489        }
6490        p.flush().unwrap();
6491        // The one submission the batch owed has happened, so every recorded
6492        // value is reachable.
6493        let dev = edgefirst_tensor::d3d11::device().unwrap();
6494        let last = *values.last().unwrap();
6495        let ev = dev.event_for(last).unwrap();
6496        // SAFETY: `ev` owns a live Win32 event handle for the duration of
6497        // this call.
6498        assert_eq!(
6499            unsafe {
6500                WaitForSingleObject(
6501                    std::os::windows::io::AsRawHandle::as_raw_handle(&ev) as _,
6502                    5000,
6503                )
6504            },
6505            0,
6506            "the batch's newest value {last} never arrived after flush"
6507        );
6508
6509        let mut solo = p
6510            .create_image(w, h, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
6511            .unwrap();
6512        p.convert(&src, &mut solo, Rotation::None, Flip::None, Crop::default())
6513            .unwrap();
6514        // Read through the tensor's own compaction: a Windows texture's
6515        // staging rows are pitch-padded, so a flat read of the mapping would
6516        // compare padding as pixels.
6517        let flat = |img: &TensorDyn| -> Vec<u8> {
6518            let t = img.as_typed::<u8>().expect("u8 image");
6519            let mut out = vec![0u8; t.shape().iter().product::<usize>()];
6520            t.copy_to_flat(&mut out).expect("compact the padded rows");
6521            out
6522        };
6523        let want = flat(&solo);
6524        for (i, dst) in dsts.iter().enumerate() {
6525            assert_eq!(
6526                flat(dst),
6527                want,
6528                "tile {i} does not match a standalone convert"
6529            );
6530        }
6531    }
6532
6533    /// A processor dropped with a batch still open submits what that batch
6534    /// recorded.
6535    ///
6536    /// The values are already published on destinations that outlive the
6537    /// processor, so a consumer may be waiting on one; a deferred signal that
6538    /// is never submitted is a wait nothing can satisfy. `flush` is what
6539    /// normally pays it, and the whole point here is that the caller never
6540    /// calls it.
6541    #[cfg(all(target_os = "windows", feature = "opengl"))]
6542    #[test]
6543    fn windows_dropping_a_processor_submits_an_open_batch() {
6544        use edgefirst_tensor::CpuAccess;
6545
6546        if !edgefirst_tensor::is_gpu_buffer_available() {
6547            crate::test_support::report_skip(&format!(
6548                "{} - no zero-copy GPU buffers",
6549                function!()
6550            ));
6551            return;
6552        }
6553        let src = synthetic_rgba(320, 240);
6554        let recorded = {
6555            let mut p = match ImageProcessor::new() {
6556                Ok(p) => p,
6557                Err(e) => {
6558                    crate::test_support::report_skip(&format!(
6559                        "{} - init failed ({e:?})",
6560                        function!()
6561                    ));
6562                    return;
6563                }
6564            };
6565            let mut dst = p
6566                .create_image(
6567                    320,
6568                    240,
6569                    PixelFormat::Rgba,
6570                    DType::U8,
6571                    None,
6572                    CpuAccess::Read,
6573                )
6574                .unwrap();
6575            if dst.memory() != TensorMemory::DmaBuf {
6576                crate::test_support::report_skip(&format!(
6577                    "{} - destination is not a texture",
6578                    function!()
6579                ));
6580                return;
6581            }
6582            p.convert_deferred(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
6583                .unwrap();
6584            // No `flush`: the drop below is what has to settle the batch.
6585            dst.gpu_completion()
6586                .unwrap()
6587                .expect("the deferred convert recorded a completion")
6588                .value
6589        };
6590
6591        let dev = edgefirst_tensor::d3d11::device().unwrap();
6592        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
6593        while dev.completed_value() < recorded {
6594            assert!(
6595                std::time::Instant::now() < deadline,
6596                "the dropped processor never submitted its batch: fence at {}, recorded {recorded}",
6597                dev.completed_value()
6598            );
6599            std::thread::yield_now();
6600        }
6601    }
6602
6603    /// After Task B5, `create_image(.., None, ..)` reaches the Windows
6604    /// Dma-first arm (a D3D11 texture, not a PBO or `Mem` tensor), and
6605    /// `convert_with_fence` is available on every platform: on Windows it
6606    /// returns a manual-reset event set when the GPU work completes.
6607    #[cfg(all(target_os = "windows", feature = "opengl"))]
6608    #[test]
6609    fn windows_create_image_prefers_textures_and_convert_with_fence_returns_a_set_event() {
6610        use edgefirst_tensor::CpuAccess;
6611        use std::os::windows::io::AsRawHandle;
6612
6613        if !edgefirst_tensor::is_gpu_buffer_available() {
6614            crate::test_support::report_skip(&format!(
6615                "{} - no zero-copy GPU buffers",
6616                function!()
6617            ));
6618            return;
6619        }
6620        let mut p = ImageProcessor::new().unwrap();
6621        let src = synthetic_rgba(320, 240);
6622        let mut dst = p
6623            .create_image(
6624                320,
6625                240,
6626                PixelFormat::Rgba,
6627                DType::U8,
6628                None,
6629                CpuAccess::Read,
6630            )
6631            .unwrap();
6632        // Without a GL backend (CI's no-ANGLE coverage pass) `create_image`
6633        // answers host memory and there is no fence to wait on; the lanes
6634        // that set HAL_TEST_REQUIRE_GL=1 still hold the texture-first claim.
6635        if dst.memory() != TensorMemory::DmaBuf
6636            && !std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1")
6637        {
6638            crate::test_support::report_skip(&format!(
6639                "{} - create_image gave {:?}, not a texture (no GL backend)",
6640                function!(),
6641                dst.memory()
6642            ));
6643            return;
6644        }
6645        assert_eq!(
6646            dst.memory(),
6647            TensorMemory::DmaBuf,
6648            "create_image(None) must prefer a texture on Windows"
6649        );
6650        // Read before the wait: the recorded value must be the one the fence
6651        // names, not an older point the destination happened to carry.
6652        let before = dst.gpu_completion().unwrap().map(|c| c.value);
6653        let fence = p
6654            .convert_with_fence(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
6655            .unwrap()
6656            .expect("Windows exports a fence");
6657        let recorded = dst
6658            .gpu_completion()
6659            .unwrap()
6660            .expect("the convert recorded a completion")
6661            .value;
6662        assert!(
6663            before.is_none_or(|b| recorded > b),
6664            "the fenced convert recorded a newer value than {before:?}"
6665        );
6666        // One flush and one signal for the whole call: the value the
6667        // destination carries is the newest the device has allocated, so
6668        // nothing was signalled after `record_completion`. Two signals (the
6669        // shape this replaced) would leave `last_signalled` one past it.
6670        assert_eq!(
6671            edgefirst_tensor::d3d11::device().unwrap().last_signalled(),
6672            recorded,
6673            "a fenced convert must signal exactly once"
6674        );
6675        // SAFETY: `fence` owns a live Win32 event handle returned by
6676        // `convert_with_fence`; WaitForSingleObject only reads the handle,
6677        // which stays valid for the duration of this call because `fence`
6678        // is not dropped until after it.
6679        assert_eq!(
6680            unsafe { WaitForSingleObject(fence.as_raw_handle() as _, 5000) },
6681            0,
6682            "fence was not set within 5 seconds"
6683        );
6684        // The event and the destination's own completion name the same
6685        // value, so a consumer may wait on either: the event fired, and the
6686        // device's completed value has reached the value `dst` reports.
6687        assert!(
6688            edgefirst_tensor::d3d11::device().unwrap().completed_value() >= recorded,
6689            "the event fired, so the recorded value {recorded} is complete"
6690        );
6691        assert_eq!(
6692            dst.gpu_completion().unwrap().map(|c| c.value),
6693            Some(recorded),
6694            "the destination still reports the value the event waited on"
6695        );
6696        let mut expected = TensorDyn::image(
6697            320,
6698            240,
6699            PixelFormat::Rgba,
6700            DType::U8,
6701            Some(TensorMemory::Mem),
6702            CpuAccess::ReadWrite,
6703        )
6704        .unwrap();
6705        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
6706            backend: ComputeBackend::Cpu,
6707            ..Default::default()
6708        })
6709        .unwrap();
6710        cpu.convert(
6711            &src,
6712            &mut expected,
6713            Rotation::None,
6714            Flip::None,
6715            Crop::default(),
6716        )
6717        .unwrap();
6718        compare_images(&dst, &expected, 0.0, "fenced convert");
6719    }
6720
6721    /// A Windows GL test's destination must be a texture; without a GL backend
6722    /// (CI's no-ANGLE coverage pass) `create_image` answers host memory and the
6723    /// test skips, unless the lane set HAL_TEST_REQUIRE_GL=1, which keeps the
6724    /// texture-first claim a hard assertion.
6725    #[cfg(all(target_os = "windows", feature = "opengl"))]
6726    fn texture_or_skip(t: &TensorDyn, test: &str) -> bool {
6727        if t.memory() == TensorMemory::DmaBuf {
6728            return true;
6729        }
6730        if std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1") {
6731            panic!(
6732                "{test}: create_image(None) must prefer a texture on Windows, got {:?}",
6733                t.memory()
6734            );
6735        }
6736        crate::test_support::report_skip(&format!(
6737            "{test} - create_image gave {:?}, not a texture (no GL backend)",
6738            t.memory()
6739        ));
6740        false
6741    }
6742
6743    /// A draw into a texture destination records a completion on it the way
6744    /// a convert does, so a device consumer can wait on the drawn frame. The
6745    /// destination is `CpuAccess::Read`: only the GL backend can write it,
6746    /// so a CPU fallback fails the draw outright instead of leaving the
6747    /// completion empty.
6748    #[cfg(all(target_os = "windows", feature = "opengl"))]
6749    #[test]
6750    fn windows_draw_decoded_masks_into_a_texture_records_a_completion() {
6751        use edgefirst_tensor::{CpuAccess, Segmentation};
6752        use ndarray::Array3;
6753
6754        if !edgefirst_tensor::is_gpu_buffer_available() {
6755            crate::test_support::report_skip(&format!(
6756                "{} - no zero-copy GPU buffers",
6757                function!()
6758            ));
6759            return;
6760        }
6761        let mut p = ImageProcessor::new().unwrap();
6762        let mut dst = p
6763            .create_image(64, 64, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
6764            .unwrap();
6765        if !texture_or_skip(&dst, function!()) {
6766            return;
6767        }
6768        assert!(
6769            dst.gpu_completion().unwrap().is_none(),
6770            "nothing has written this texture yet"
6771        );
6772
6773        let detect = DetectBox {
6774            bbox: [0.25, 0.25, 0.75, 0.75].into(),
6775            score: 0.99,
6776            label: 0,
6777        };
6778        let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
6779        let seg = Segmentation {
6780            segmentation: edgefirst_tensor::Tensor::from_arrayview3(seg_arr.view())
6781                .unwrap()
6782                .into(),
6783            xmin: 0.25,
6784            ymin: 0.25,
6785            xmax: 0.75,
6786            ymax: 0.75,
6787        };
6788        p.draw_decoded_masks(&mut dst, &[detect], &[seg], MaskOverlay::default())
6789            .expect("draw into a read-only texture");
6790
6791        let completion = dst
6792            .gpu_completion()
6793            .unwrap()
6794            .expect("the draw recorded no completion on dst");
6795        assert!(completion.value > 0, "completion value must be non-zero");
6796
6797        // The drawn frame is read back through the texture's staging copy;
6798        // `copy_to_flat` compacts its padded rows into tight RGBA pixels and
6799        // needs only a read mapping, which the read-only texture allows.
6800        // The corner is cleared and the box centre carries the mask colour.
6801        let mut flat = vec![0u8; 64 * 64 * 4];
6802        dst.as_typed::<u8>()
6803            .unwrap()
6804            .copy_to_flat(&mut flat)
6805            .expect("read the drawn texture");
6806        let px = |x: usize, y: usize| -> [u8; 4] {
6807            let o = (y * 64 + x) * 4;
6808            [flat[o], flat[o + 1], flat[o + 2], flat[o + 3]]
6809        };
6810        assert_eq!(px(2, 2), [0, 0, 0, 0], "corner (2,2) was not cleared");
6811        assert_ne!(px(32, 32), [0, 0, 0, 0], "centre (32,32) was not coloured");
6812    }
6813
6814    /// The full-GPU proto draw (forced GL, no CPU materialize) records a
6815    /// completion on a texture destination too.
6816    #[cfg(all(target_os = "windows", feature = "opengl"))]
6817    #[test]
6818    fn windows_draw_proto_masks_into_a_texture_records_a_completion() {
6819        use edgefirst_tensor::{CpuAccess, Tensor};
6820
6821        if !edgefirst_tensor::is_gpu_buffer_available() {
6822            crate::test_support::report_skip(&format!(
6823                "{} - no zero-copy GPU buffers",
6824                function!()
6825            ));
6826            return;
6827        }
6828        // Forcing OpenGL fails outright without a GL backend (CI's no-ANGLE
6829        // coverage pass); that is the same skip the texture guard below gives,
6830        // unless the lane set HAL_TEST_REQUIRE_GL=1.
6831        let mut p = match with_force_backend(Some("opengl"), ImageProcessor::new) {
6832            Ok(p) => p,
6833            Err(e) if !std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1") => {
6834                crate::test_support::report_skip(&format!(
6835                    "{} - no OpenGL backend: {e}",
6836                    function!()
6837                ));
6838                return;
6839            }
6840            Err(e) => panic!("forced OpenGL backend unavailable: {e}"),
6841        };
6842        let mut dst = p
6843            .create_image(64, 64, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
6844            .unwrap();
6845        if !texture_or_skip(&dst, function!()) {
6846            return;
6847        }
6848
6849        let detect = DetectBox {
6850            bbox: [0.25, 0.25, 0.75, 0.75].into(),
6851            score: 0.99,
6852            label: 0,
6853        };
6854        // One prototype channel of ones and a positive coefficient: every
6855        // pixel of the box is foreground.
6856        let proto = ProtoData {
6857            mask_coefficients: TensorDyn::F32(
6858                Tensor::<f32>::from_slice(&[1.0_f32], &[1, 1]).unwrap(),
6859            ),
6860            protos: TensorDyn::F32(
6861                Tensor::<f32>::from_slice(&vec![1.0_f32; 8 * 8], &[8, 8, 1]).unwrap(),
6862            ),
6863            layout: ProtoLayout::Nhwc,
6864        };
6865        p.draw_proto_masks(&mut dst, &[detect], &proto, MaskOverlay::default())
6866            .expect("forced-GL proto draw into a read-only texture");
6867
6868        let completion = dst
6869            .gpu_completion()
6870            .unwrap()
6871            .expect("the proto draw recorded no completion on dst");
6872        assert!(completion.value > 0, "completion value must be non-zero");
6873    }
6874
6875    /// An empty draw with a host-memory background onto a texture
6876    /// destination leaves the background in the texture, rows in order.
6877    /// The gradient pattern makes a missing or vertically flipped base
6878    /// layer differ from the expected image.
6879    #[cfg(all(target_os = "windows", feature = "opengl"))]
6880    #[test]
6881    fn windows_draw_decoded_masks_composites_a_host_background_onto_a_texture() {
6882        use edgefirst_tensor::CpuAccess;
6883
6884        if !edgefirst_tensor::is_gpu_buffer_available() {
6885            crate::test_support::report_skip(&format!(
6886                "{} - no zero-copy GPU buffers",
6887                function!()
6888            ));
6889            return;
6890        }
6891        let mut p = ImageProcessor::new().unwrap();
6892        let bg = synthetic_rgba(64, 48);
6893        let mut dst = p
6894            .create_image(64, 48, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
6895            .unwrap();
6896        if !texture_or_skip(&dst, function!()) {
6897            return;
6898        }
6899        p.draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::new().with_background(&bg))
6900            .expect("empty draw with a host background onto a texture");
6901        assert_pixels_identical(&dst, &bg, "host background onto a texture");
6902        assert!(dst.gpu_completion().unwrap().is_some());
6903    }
6904
6905    /// The same draw with the background in a texture of its own: the
6906    /// base layer is sampled from the background texture directly.
6907    #[cfg(all(target_os = "windows", feature = "opengl"))]
6908    #[test]
6909    fn windows_draw_decoded_masks_composites_a_texture_background_onto_a_texture() {
6910        use edgefirst_tensor::CpuAccess;
6911
6912        if !edgefirst_tensor::is_gpu_buffer_available() {
6913            crate::test_support::report_skip(&format!(
6914                "{} - no zero-copy GPU buffers",
6915                function!()
6916            ));
6917            return;
6918        }
6919        let mut p = ImageProcessor::new().unwrap();
6920        let expected = synthetic_rgba(64, 48);
6921        let bg = p
6922            .create_image(
6923                64,
6924                48,
6925                PixelFormat::Rgba,
6926                DType::U8,
6927                None,
6928                CpuAccess::ReadWrite,
6929            )
6930            .unwrap();
6931        if !texture_or_skip(&bg, function!()) {
6932            return;
6933        }
6934        fill_synthetic_rgba(&bg);
6935        let mut dst = p
6936            .create_image(64, 48, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
6937            .unwrap();
6938        assert_eq!(dst.memory(), TensorMemory::DmaBuf);
6939        p.draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::new().with_background(&bg))
6940            .expect("empty draw with a texture background onto a texture");
6941        assert_pixels_identical(&dst, &expected, "texture background onto a texture");
6942        assert!(dst.gpu_completion().unwrap().is_some());
6943    }
6944
6945    /// A draw into a `view()` of a texture fills that band and leaves its
6946    /// sibling alone. The GL draw declines a sub-region destination
6947    /// (`check_draw_dst_placement`) and the CPU backend, which offsets into
6948    /// the parent, takes it; the zero-copy arm would otherwise have cleared
6949    /// the parent's first rows instead of the view's.
6950    #[cfg(all(target_os = "windows", feature = "opengl"))]
6951    #[test]
6952    fn windows_draw_decoded_masks_into_a_texture_view_fills_only_its_band() {
6953        use edgefirst_tensor::CpuAccess;
6954
6955        if !edgefirst_tensor::is_gpu_buffer_available() {
6956            crate::test_support::report_skip(&format!(
6957                "{} - no zero-copy GPU buffers",
6958                function!()
6959            ));
6960            return;
6961        }
6962        let (w, h) = (32usize, 64usize);
6963        let mut p = ImageProcessor::new().unwrap();
6964        let parent = p
6965            .create_image(
6966                w,
6967                h,
6968                PixelFormat::Rgba,
6969                DType::U8,
6970                None,
6971                CpuAccess::ReadWrite,
6972            )
6973            .unwrap();
6974        if !texture_or_skip(&parent, function!()) {
6975            return;
6976        }
6977        fill_synthetic_rgba(&parent);
6978
6979        // The lower band only. An empty draw with no background clears its
6980        // destination, so a write at the parent's origin would show up as
6981        // cleared pixels in the upper band.
6982        let mut band = parent
6983            .view(edgefirst_tensor::Region::new(0, h / 2, w, h / 2))
6984            .unwrap();
6985        p.draw_decoded_masks(&mut band, &[], &[], MaskOverlay::default())
6986            .expect("empty draw into a texture view");
6987        drop(band);
6988
6989        let mut flat = vec![0u8; w * h * 4];
6990        parent
6991            .as_typed::<u8>()
6992            .unwrap()
6993            .copy_to_flat(&mut flat)
6994            .expect("read the parent texture");
6995        for y in 0..h {
6996            for x in 0..w {
6997                let o = (y * w + x) * 4;
6998                let got = [flat[o], flat[o + 1], flat[o + 2], flat[o + 3]];
6999                let want = if y < h / 2 {
7000                    [x as u8, y as u8, (x + y) as u8, 255]
7001                } else {
7002                    [0, 0, 0, 0]
7003                };
7004                assert_eq!(got, want, "pixel ({x},{y})");
7005            }
7006        }
7007
7008        // The GL decline is what sent the draw above to the CPU: with GL
7009        // forced there is no CPU arm to fall through to, so it surfaces.
7010        let mut forced = with_force_backend(Some("opengl"), ImageProcessor::new).unwrap();
7011        let mut band = parent
7012            .view(edgefirst_tensor::Region::new(0, h / 2, w, h / 2))
7013            .unwrap();
7014        let err = forced
7015            .draw_decoded_masks(&mut band, &[], &[], MaskOverlay::default())
7016            .expect_err("forced GL must decline a view destination");
7017        assert!(
7018            matches!(err, Error::NotSupported(_)),
7019            "expected NotSupported, got {err:?}"
7020        );
7021    }
7022
7023    /// `set_class_colors` reaches the CPU backend even when GL accepts it, so
7024    /// a draw GL declines still renders the caller's palette. The view
7025    /// destination is the reachable decline: `check_draw_dst_placement`
7026    /// refuses it and the CPU backend draws the mask.
7027    #[cfg(all(target_os = "windows", feature = "opengl"))]
7028    #[test]
7029    fn windows_class_colors_reach_the_cpu_backend_behind_a_declined_gl_draw() {
7030        use edgefirst_tensor::{CpuAccess, Segmentation};
7031        use ndarray::Array3;
7032
7033        if !edgefirst_tensor::is_gpu_buffer_available() {
7034            crate::test_support::report_skip(&format!(
7035                "{} - no zero-copy GPU buffers",
7036                function!()
7037            ));
7038            return;
7039        }
7040        const CUSTOM: [u8; 4] = [7, 200, 13, 255];
7041        assert_ne!(
7042            CUSTOM[..3],
7043            DEFAULT_COLORS_U8[0][..3],
7044            "the custom colour must differ from the default palette's, or \
7045             this test cannot tell them apart"
7046        );
7047
7048        let (w, h) = (32usize, 64usize);
7049        let mut p = ImageProcessor::new().unwrap();
7050        let parent = p
7051            .create_image(
7052                w,
7053                h,
7054                PixelFormat::Rgba,
7055                DType::U8,
7056                None,
7057                CpuAccess::ReadWrite,
7058            )
7059            .unwrap();
7060        // Without a GL backend (CI's no-ANGLE coverage pass) `create_image`
7061        // answers host memory and nothing declines the draw; the test needs
7062        // the texture-backed parent so the view is the reachable decline. The
7063        // lanes that set HAL_TEST_REQUIRE_GL=1 still require the texture.
7064        if parent.memory() != TensorMemory::DmaBuf
7065            && !std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1")
7066        {
7067            crate::test_support::report_skip(&format!(
7068                "{} - create_image gave {:?}, not a texture (no GL backend)",
7069                function!(),
7070                parent.memory()
7071            ));
7072            return;
7073        }
7074        p.set_class_colors(&[CUSTOM]).unwrap();
7075
7076        let detect = DetectBox {
7077            bbox: [0.25, 0.25, 0.75, 0.75].into(),
7078            score: 0.99,
7079            label: 0,
7080        };
7081        let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
7082        let seg = Segmentation {
7083            segmentation: edgefirst_tensor::Tensor::from_arrayview3(seg_arr.view())
7084                .unwrap()
7085                .into(),
7086            xmin: 0.25,
7087            ymin: 0.25,
7088            xmax: 0.75,
7089            ymax: 0.75,
7090        };
7091        let mut band = parent
7092            .view(edgefirst_tensor::Region::new(0, h / 2, w, h / 2))
7093            .unwrap();
7094        p.draw_decoded_masks(&mut band, &[detect], &[seg], MaskOverlay::default())
7095            .expect("draw into a texture view");
7096        drop(band);
7097
7098        let mut flat = vec![0u8; w * h * 4];
7099        parent
7100            .as_typed::<u8>()
7101            .unwrap()
7102            .copy_to_flat(&mut flat)
7103            .expect("read the parent texture");
7104        // The band's centre, inside the box. The mask blend writes RGB only;
7105        // alpha is whatever the base layer left, which is the clear.
7106        let o = ((h / 2 + h / 4) * w + w / 2) * 4;
7107        assert_eq!(
7108            &flat[o..o + 3],
7109            &CUSTOM[..3],
7110            "the CPU backend drew the default palette, so it never received \
7111             set_class_colors"
7112        );
7113    }
7114
7115    /// Two independently-created `ImageProcessor`s, each converting into its
7116    /// own texture destination in lockstep, must not bleed GL state into
7117    /// each other across 200 iterations (the destination created by
7118    /// `create_image(None)` is the texture arm this task adds).
7119    #[cfg(all(target_os = "windows", feature = "opengl"))]
7120    #[test]
7121    fn two_processors_alternate_on_texture_destinations_without_state_bleed() {
7122        use edgefirst_tensor::CpuAccess;
7123
7124        if !edgefirst_tensor::is_gpu_buffer_available() {
7125            crate::test_support::report_skip(&format!(
7126                "{} - no zero-copy GPU buffers",
7127                function!()
7128            ));
7129            return;
7130        }
7131        let mut a = ImageProcessor::new().unwrap();
7132        let mut b = ImageProcessor::new().unwrap();
7133        let src_a = synthetic_rgba(512, 512);
7134        let src_b = synthetic_rgba(256, 256);
7135        let mut dst_a = a
7136            .create_image(
7137                512,
7138                512,
7139                PixelFormat::Rgba,
7140                DType::U8,
7141                None,
7142                CpuAccess::Read,
7143            )
7144            .unwrap();
7145        let mut dst_b = b
7146            .create_image(
7147                256,
7148                256,
7149                PixelFormat::Rgba,
7150                DType::U8,
7151                None,
7152                CpuAccess::Read,
7153            )
7154            .unwrap();
7155        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
7156            backend: ComputeBackend::Cpu,
7157            ..Default::default()
7158        })
7159        .unwrap();
7160        let mut ref_a = TensorDyn::image(
7161            512,
7162            512,
7163            PixelFormat::Rgba,
7164            DType::U8,
7165            Some(TensorMemory::Mem),
7166            CpuAccess::ReadWrite,
7167        )
7168        .unwrap();
7169        let mut ref_b = TensorDyn::image(
7170            256,
7171            256,
7172            PixelFormat::Rgba,
7173            DType::U8,
7174            Some(TensorMemory::Mem),
7175            CpuAccess::ReadWrite,
7176        )
7177        .unwrap();
7178        cpu.convert(
7179            &src_a,
7180            &mut ref_a,
7181            Rotation::None,
7182            Flip::None,
7183            Crop::default(),
7184        )
7185        .unwrap();
7186        cpu.convert(
7187            &src_b,
7188            &mut ref_b,
7189            Rotation::None,
7190            Flip::None,
7191            Crop::default(),
7192        )
7193        .unwrap();
7194        for _ in 0..200 {
7195            a.convert(
7196                &src_a,
7197                &mut dst_a,
7198                Rotation::None,
7199                Flip::None,
7200                Crop::default(),
7201            )
7202            .unwrap();
7203            b.convert(
7204                &src_b,
7205                &mut dst_b,
7206                Rotation::None,
7207                Flip::None,
7208                Crop::default(),
7209            )
7210            .unwrap();
7211            compare_images(&dst_a, &ref_a, 0.0, "processor A");
7212            compare_images(&dst_b, &ref_b, 0.0, "processor B");
7213        }
7214    }
7215
7216    #[test]
7217    #[cfg(any(
7218        target_os = "linux",
7219        target_os = "macos",
7220        target_os = "ios",
7221        target_os = "android",
7222        target_os = "windows"
7223    ))]
7224    #[cfg(feature = "opengl")]
7225    fn test_opengl_all_rgba() {
7226        if !is_opengl_available() {
7227            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
7228            return;
7229        }
7230
7231        let dst_width = 640;
7232        let dst_height = 640;
7233        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
7234
7235        let mut cpu_converter = CPUProcessor::new();
7236
7237        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
7238
7239        // Every backing this host can allocate: Shm is Unix-only, and the
7240        // zero-copy slot is DMA-BUF on Linux / IOSurface on macOS (nothing on
7241        // Windows yet).
7242        let mut mem = vec![None, Some(TensorMemory::Mem)];
7243        if edgefirst_tensor::is_shm_available() {
7244            mem.push(Some(TensorMemory::Shm));
7245        }
7246        if edgefirst_tensor::is_gpu_buffer_available() {
7247            mem.push(Some(TensorMemory::DmaBuf));
7248        }
7249        let crop = Crop::new().with_source(Some(Region::new(50, 120, 1024, 576)));
7250        for m in mem {
7251            let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), m).unwrap();
7252            let src_dyn = src;
7253
7254            for rot in [
7255                Rotation::None,
7256                Rotation::Clockwise90,
7257                Rotation::Rotate180,
7258                Rotation::CounterClockwise90,
7259            ] {
7260                for flip in [Flip::None, Flip::Horizontal, Flip::Vertical] {
7261                    let cpu_dst = TensorDyn::image(
7262                        dst_width,
7263                        dst_height,
7264                        PixelFormat::Rgba,
7265                        DType::U8,
7266                        m,
7267                        edgefirst_tensor::CpuAccess::ReadWrite,
7268                    )
7269                    .unwrap();
7270                    let gl_dst = TensorDyn::image(
7271                        dst_width,
7272                        dst_height,
7273                        PixelFormat::Rgba,
7274                        DType::U8,
7275                        m,
7276                        edgefirst_tensor::CpuAccess::ReadWrite,
7277                    )
7278                    .unwrap();
7279                    cpu_dst
7280                        .as_typed::<u8>()
7281                        .unwrap()
7282                        .map()
7283                        .unwrap()
7284                        .as_mut_slice()
7285                        .fill(114);
7286                    gl_dst
7287                        .as_typed::<u8>()
7288                        .unwrap()
7289                        .map()
7290                        .unwrap()
7291                        .as_mut_slice()
7292                        .fill(114);
7293
7294                    let mut cpu_dst_dyn = cpu_dst;
7295                    cpu_converter
7296                        .convert(&src_dyn, &mut cpu_dst_dyn, Rotation::None, Flip::None, crop)
7297                        .unwrap();
7298                    let cpu_dst = {
7299                        let mut __t = cpu_dst_dyn.into_u8().unwrap();
7300                        __t.set_format(PixelFormat::Rgba).unwrap();
7301                        TensorDyn::from(__t)
7302                    };
7303
7304                    let mut gl_dst_dyn = gl_dst;
7305                    gl_converter
7306                        .convert(&src_dyn, &mut gl_dst_dyn, Rotation::None, Flip::None, crop)
7307                        .map_err(|e| {
7308                            log::error!("error mem {m:?} rot {rot:?} error: {e:?}");
7309                            e
7310                        })
7311                        .unwrap();
7312                    let gl_dst = {
7313                        let mut __t = gl_dst_dyn.into_u8().unwrap();
7314                        __t.set_format(PixelFormat::Rgba).unwrap();
7315                        TensorDyn::from(__t)
7316                    };
7317
7318                    compare_images(
7319                        &gl_dst,
7320                        &cpu_dst,
7321                        0.98,
7322                        &format!("{} {:?} {:?}", function!(), rot, flip),
7323                    );
7324                }
7325            }
7326        }
7327    }
7328
7329    #[test]
7330    #[cfg(target_os = "linux")]
7331    fn test_cpu_rotate() {
7332        for rot in [
7333            Rotation::Clockwise90,
7334            Rotation::Rotate180,
7335            Rotation::CounterClockwise90,
7336        ] {
7337            test_cpu_rotate_(rot);
7338        }
7339    }
7340
7341    #[cfg(target_os = "linux")]
7342    fn test_cpu_rotate_(rot: Rotation) {
7343        // This test rotates the image 4 times and checks that the image was returned to
7344        // be the same Currently doesn't check if rotations actually rotated in
7345        // right direction
7346        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
7347
7348        let unchanged_src =
7349            crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
7350        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
7351
7352        let (dst_width, dst_height) = match rot {
7353            Rotation::None | Rotation::Rotate180 => (src.width().unwrap(), src.height().unwrap()),
7354            Rotation::Clockwise90 | Rotation::CounterClockwise90 => {
7355                (src.height().unwrap(), src.width().unwrap())
7356            }
7357        };
7358
7359        let cpu_dst = TensorDyn::image(
7360            dst_width,
7361            dst_height,
7362            PixelFormat::Rgba,
7363            DType::U8,
7364            None,
7365            edgefirst_tensor::CpuAccess::ReadWrite,
7366        )
7367        .unwrap();
7368        let mut cpu_converter = CPUProcessor::new();
7369
7370        // After rotating 4 times, the image should be the same as the original
7371
7372        let (result, src, cpu_dst) = convert_img(
7373            &mut cpu_converter,
7374            src,
7375            cpu_dst,
7376            rot,
7377            Flip::None,
7378            Crop::no_crop(),
7379        );
7380        result.unwrap();
7381
7382        let (result, cpu_dst, src) = convert_img(
7383            &mut cpu_converter,
7384            cpu_dst,
7385            src,
7386            rot,
7387            Flip::None,
7388            Crop::no_crop(),
7389        );
7390        result.unwrap();
7391
7392        let (result, src, cpu_dst) = convert_img(
7393            &mut cpu_converter,
7394            src,
7395            cpu_dst,
7396            rot,
7397            Flip::None,
7398            Crop::no_crop(),
7399        );
7400        result.unwrap();
7401
7402        let (result, _cpu_dst, src) = convert_img(
7403            &mut cpu_converter,
7404            cpu_dst,
7405            src,
7406            rot,
7407            Flip::None,
7408            Crop::no_crop(),
7409        );
7410        result.unwrap();
7411
7412        compare_images(&src, &unchanged_src, 0.98, function!());
7413    }
7414
7415    #[test]
7416    #[cfg(any(
7417        target_os = "linux",
7418        target_os = "macos",
7419        target_os = "ios",
7420        target_os = "android",
7421        target_os = "windows"
7422    ))]
7423    #[cfg(feature = "opengl")]
7424    fn test_opengl_rotate() {
7425        if !is_opengl_available() {
7426            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
7427            return;
7428        }
7429
7430        let size = (1280, 720);
7431        // Same backing list as `test_opengl_all_rgba`.
7432        let mut mem = vec![None, Some(TensorMemory::Mem)];
7433        if edgefirst_tensor::is_shm_available() {
7434            mem.push(Some(TensorMemory::Shm));
7435        }
7436        if edgefirst_tensor::is_gpu_buffer_available() {
7437            mem.push(Some(TensorMemory::DmaBuf));
7438        }
7439        for m in mem {
7440            for rot in [
7441                Rotation::Clockwise90,
7442                Rotation::Rotate180,
7443                Rotation::CounterClockwise90,
7444            ] {
7445                test_opengl_rotate_(size, rot, m);
7446            }
7447        }
7448    }
7449
7450    #[cfg(any(
7451        target_os = "linux",
7452        target_os = "macos",
7453        target_os = "ios",
7454        target_os = "android",
7455        target_os = "windows"
7456    ))]
7457    #[cfg(feature = "opengl")]
7458    fn test_opengl_rotate_(
7459        size: (usize, usize),
7460        rot: Rotation,
7461        tensor_memory: Option<TensorMemory>,
7462    ) {
7463        let (dst_width, dst_height) = match rot {
7464            Rotation::None | Rotation::Rotate180 => size,
7465            Rotation::Clockwise90 | Rotation::CounterClockwise90 => (size.1, size.0),
7466        };
7467
7468        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
7469        let src =
7470            crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), tensor_memory).unwrap();
7471
7472        let cpu_dst = TensorDyn::image(
7473            dst_width,
7474            dst_height,
7475            PixelFormat::Rgba,
7476            DType::U8,
7477            None,
7478            edgefirst_tensor::CpuAccess::ReadWrite,
7479        )
7480        .unwrap();
7481        let mut cpu_converter = CPUProcessor::new();
7482
7483        let (result, mut src, cpu_dst) = convert_img(
7484            &mut cpu_converter,
7485            src,
7486            cpu_dst,
7487            rot,
7488            Flip::None,
7489            Crop::no_crop(),
7490        );
7491        result.unwrap();
7492
7493        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
7494
7495        for _ in 0..5 {
7496            let gl_dst = TensorDyn::image(
7497                dst_width,
7498                dst_height,
7499                PixelFormat::Rgba,
7500                DType::U8,
7501                tensor_memory,
7502                edgefirst_tensor::CpuAccess::ReadWrite,
7503            )
7504            .unwrap();
7505            let (result, src_back, gl_dst) = convert_img(
7506                &mut gl_converter,
7507                src,
7508                gl_dst,
7509                rot,
7510                Flip::None,
7511                Crop::no_crop(),
7512            );
7513            result.unwrap();
7514            src = src_back;
7515            compare_images(&gl_dst, &cpu_dst, 0.98, function!());
7516        }
7517    }
7518
7519    #[test]
7520    #[cfg(target_os = "linux")]
7521    fn test_g2d_rotate() {
7522        if !is_g2d_available() {
7523            crate::test_support::report_skip(
7524                "test_g2d_rotate - G2D library (libg2d.so.2) not available",
7525            );
7526            return;
7527        }
7528        if !is_dma_available() {
7529            crate::test_support::report_skip("test_g2d_rotate - DMA memory allocation not available (permission denied or no DMA-BUF support)");
7530            return;
7531        }
7532
7533        let size = (1280, 720);
7534        for rot in [
7535            Rotation::Clockwise90,
7536            Rotation::Rotate180,
7537            Rotation::CounterClockwise90,
7538        ] {
7539            test_g2d_rotate_(size, rot);
7540        }
7541    }
7542
7543    #[cfg(target_os = "linux")]
7544    fn test_g2d_rotate_(size: (usize, usize), rot: Rotation) {
7545        let (dst_width, dst_height) = match rot {
7546            Rotation::None | Rotation::Rotate180 => size,
7547            Rotation::Clockwise90 | Rotation::CounterClockwise90 => (size.1, size.0),
7548        };
7549
7550        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
7551        let src = crate::load_image_test_helper(
7552            &file,
7553            Some(PixelFormat::Rgba),
7554            Some(TensorMemory::DmaBuf),
7555        )
7556        .unwrap();
7557
7558        let cpu_dst = TensorDyn::image(
7559            dst_width,
7560            dst_height,
7561            PixelFormat::Rgba,
7562            DType::U8,
7563            None,
7564            edgefirst_tensor::CpuAccess::ReadWrite,
7565        )
7566        .unwrap();
7567        let mut cpu_converter = CPUProcessor::new();
7568
7569        let (result, src, cpu_dst) = convert_img(
7570            &mut cpu_converter,
7571            src,
7572            cpu_dst,
7573            rot,
7574            Flip::None,
7575            Crop::no_crop(),
7576        );
7577        result.unwrap();
7578
7579        let g2d_dst = TensorDyn::image(
7580            dst_width,
7581            dst_height,
7582            PixelFormat::Rgba,
7583            DType::U8,
7584            Some(TensorMemory::DmaBuf),
7585            edgefirst_tensor::CpuAccess::ReadWrite,
7586        )
7587        .unwrap();
7588        let mut g2d_converter = G2DProcessor::new().unwrap();
7589
7590        let (result, _src, g2d_dst) = convert_img(
7591            &mut g2d_converter,
7592            src,
7593            g2d_dst,
7594            rot,
7595            Flip::None,
7596            Crop::no_crop(),
7597        );
7598        result.unwrap();
7599
7600        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
7601        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
7602        // the YUV-matrix delta that forced 0.95 has closed; tightened to
7603        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
7604        // structural gap not exercised by these limited-range fixtures.
7605        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
7606    }
7607
7608    #[test]
7609    fn test_rgba_to_yuyv_resize_cpu() {
7610        let src = load_bytes_to_tensor(
7611            1280,
7612            720,
7613            PixelFormat::Rgba,
7614            None,
7615            &edgefirst_bench::testdata::read("camera720p.rgba"),
7616        )
7617        .unwrap();
7618
7619        let (dst_width, dst_height) = (640, 360);
7620
7621        let dst = TensorDyn::image(
7622            dst_width,
7623            dst_height,
7624            PixelFormat::Yuyv,
7625            DType::U8,
7626            None,
7627            edgefirst_tensor::CpuAccess::ReadWrite,
7628        )
7629        .unwrap();
7630
7631        let dst_through_yuyv = TensorDyn::image(
7632            dst_width,
7633            dst_height,
7634            PixelFormat::Rgba,
7635            DType::U8,
7636            None,
7637            edgefirst_tensor::CpuAccess::ReadWrite,
7638        )
7639        .unwrap();
7640        let dst_direct = TensorDyn::image(
7641            dst_width,
7642            dst_height,
7643            PixelFormat::Rgba,
7644            DType::U8,
7645            None,
7646            edgefirst_tensor::CpuAccess::ReadWrite,
7647        )
7648        .unwrap();
7649
7650        let mut cpu_converter = CPUProcessor::new();
7651
7652        let (result, src, dst) = convert_img(
7653            &mut cpu_converter,
7654            src,
7655            dst,
7656            Rotation::None,
7657            Flip::None,
7658            Crop::no_crop(),
7659        );
7660        result.unwrap();
7661
7662        let (result, _dst, dst_through_yuyv) = convert_img(
7663            &mut cpu_converter,
7664            dst,
7665            dst_through_yuyv,
7666            Rotation::None,
7667            Flip::None,
7668            Crop::no_crop(),
7669        );
7670        result.unwrap();
7671
7672        let (result, _src, dst_direct) = convert_img(
7673            &mut cpu_converter,
7674            src,
7675            dst_direct,
7676            Rotation::None,
7677            Flip::None,
7678            Crop::no_crop(),
7679        );
7680        result.unwrap();
7681
7682        compare_images(&dst_through_yuyv, &dst_direct, 0.98, function!());
7683    }
7684
7685    #[test]
7686    #[cfg(any(
7687        target_os = "linux",
7688        target_os = "macos",
7689        target_os = "ios",
7690        target_os = "android",
7691        target_os = "windows"
7692    ))]
7693    #[cfg(feature = "opengl")]
7694    #[ignore = "opengl doesn't support rendering to PixelFormat::Yuyv texture"]
7695    fn test_rgba_to_yuyv_resize_opengl() {
7696        if !is_opengl_available() {
7697            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
7698            return;
7699        }
7700
7701        if !edgefirst_tensor::is_gpu_buffer_available() {
7702            crate::test_support::report_skip(&format!(
7703                "{} - zero-copy GPU buffer (DMA-BUF / IOSurface) not available on this host",
7704                function!()
7705            ));
7706            return;
7707        }
7708
7709        let src = load_bytes_to_tensor(
7710            1280,
7711            720,
7712            PixelFormat::Rgba,
7713            None,
7714            &edgefirst_bench::testdata::read("camera720p.rgba"),
7715        )
7716        .unwrap();
7717
7718        let (dst_width, dst_height) = (640, 360);
7719
7720        let dst = TensorDyn::image(
7721            dst_width,
7722            dst_height,
7723            PixelFormat::Yuyv,
7724            DType::U8,
7725            Some(TensorMemory::DmaBuf),
7726            edgefirst_tensor::CpuAccess::ReadWrite,
7727        )
7728        .unwrap();
7729
7730        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
7731
7732        let (result, src, dst) = convert_img(
7733            &mut gl_converter,
7734            src,
7735            dst,
7736            Rotation::None,
7737            Flip::None,
7738            Crop::letterbox([255, 255, 255, 255]),
7739        );
7740        result.unwrap();
7741
7742        std::fs::write(
7743            "rgba_to_yuyv_opengl.yuyv",
7744            dst.as_typed::<u8>().unwrap().map().unwrap().as_slice(),
7745        )
7746        .unwrap();
7747        let cpu_dst = TensorDyn::image(
7748            dst_width,
7749            dst_height,
7750            PixelFormat::Yuyv,
7751            DType::U8,
7752            Some(TensorMemory::DmaBuf),
7753            edgefirst_tensor::CpuAccess::ReadWrite,
7754        )
7755        .unwrap();
7756        let (result, _src, cpu_dst) = convert_img(
7757            &mut CPUProcessor::new(),
7758            src,
7759            cpu_dst,
7760            Rotation::None,
7761            Flip::None,
7762            Crop::no_crop(),
7763        );
7764        result.unwrap();
7765
7766        compare_images_convert_to_rgb(&dst, &cpu_dst, 0.98, function!());
7767    }
7768
7769    #[test]
7770    #[cfg(target_os = "linux")]
7771    fn test_rgba_to_yuyv_resize_g2d() {
7772        if !is_g2d_available() {
7773            crate::test_support::report_skip(
7774                "test_rgba_to_yuyv_resize_g2d - G2D library (libg2d.so.2) not available",
7775            );
7776            return;
7777        }
7778        if !is_dma_available() {
7779            crate::test_support::report_skip("test_rgba_to_yuyv_resize_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
7780            return;
7781        }
7782
7783        let src = load_bytes_to_tensor(
7784            1280,
7785            720,
7786            PixelFormat::Rgba,
7787            Some(TensorMemory::DmaBuf),
7788            &edgefirst_bench::testdata::read("camera720p.rgba"),
7789        )
7790        .unwrap();
7791
7792        let (dst_width, dst_height) = (1280, 720);
7793
7794        let cpu_dst = TensorDyn::image(
7795            dst_width,
7796            dst_height,
7797            PixelFormat::Yuyv,
7798            DType::U8,
7799            Some(TensorMemory::DmaBuf),
7800            edgefirst_tensor::CpuAccess::ReadWrite,
7801        )
7802        .unwrap();
7803
7804        let g2d_dst = TensorDyn::image(
7805            dst_width,
7806            dst_height,
7807            PixelFormat::Yuyv,
7808            DType::U8,
7809            Some(TensorMemory::DmaBuf),
7810            edgefirst_tensor::CpuAccess::ReadWrite,
7811        )
7812        .unwrap();
7813
7814        let mut g2d_converter = G2DProcessor::new().unwrap();
7815        let crop = Crop::new();
7816
7817        g2d_dst
7818            .as_typed::<u8>()
7819            .unwrap()
7820            .map()
7821            .unwrap()
7822            .as_mut_slice()
7823            .fill(128);
7824        let (result, src, g2d_dst) = convert_img(
7825            &mut g2d_converter,
7826            src,
7827            g2d_dst,
7828            Rotation::None,
7829            Flip::None,
7830            crop,
7831        );
7832        result.unwrap();
7833
7834        let cpu_dst_img = cpu_dst;
7835        cpu_dst_img
7836            .as_typed::<u8>()
7837            .unwrap()
7838            .map()
7839            .unwrap()
7840            .as_mut_slice()
7841            .fill(128);
7842        let (result, _src, cpu_dst) = convert_img(
7843            &mut CPUProcessor::new(),
7844            src,
7845            cpu_dst_img,
7846            Rotation::None,
7847            Flip::None,
7848            crop,
7849        );
7850        result.unwrap();
7851
7852        compare_images_convert_to_rgb(&cpu_dst, &g2d_dst, 0.98, function!());
7853    }
7854
7855    #[test]
7856    fn test_yuyv_to_rgba_cpu() {
7857        let file = edgefirst_bench::testdata::read("camera720p.yuyv").to_vec();
7858        let src = TensorDyn::image(
7859            1280,
7860            720,
7861            PixelFormat::Yuyv,
7862            DType::U8,
7863            None,
7864            edgefirst_tensor::CpuAccess::ReadWrite,
7865        )
7866        .unwrap();
7867        src.as_typed::<u8>()
7868            .unwrap()
7869            .map()
7870            .unwrap()
7871            .as_mut_slice()
7872            .copy_from_slice(&file);
7873
7874        let dst = TensorDyn::image(
7875            1280,
7876            720,
7877            PixelFormat::Rgba,
7878            DType::U8,
7879            None,
7880            edgefirst_tensor::CpuAccess::ReadWrite,
7881        )
7882        .unwrap();
7883        let mut cpu_converter = CPUProcessor::new();
7884
7885        let (result, _src, dst) = convert_img(
7886            &mut cpu_converter,
7887            src,
7888            dst,
7889            Rotation::None,
7890            Flip::None,
7891            Crop::no_crop(),
7892        );
7893        result.unwrap();
7894
7895        let target_image = TensorDyn::image(
7896            1280,
7897            720,
7898            PixelFormat::Rgba,
7899            DType::U8,
7900            None,
7901            edgefirst_tensor::CpuAccess::ReadWrite,
7902        )
7903        .unwrap();
7904        target_image
7905            .as_typed::<u8>()
7906            .unwrap()
7907            .map()
7908            .unwrap()
7909            .as_mut_slice()
7910            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
7911
7912        // CPU path resolves the untagged 720p source to BT.709 limited (height
7913        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
7914        compare_images(&dst, &target_image, 0.98, function!());
7915    }
7916
7917    #[test]
7918    fn test_yuyv_to_rgb_cpu() {
7919        let file = edgefirst_bench::testdata::read("camera720p.yuyv").to_vec();
7920        let src = TensorDyn::image(
7921            1280,
7922            720,
7923            PixelFormat::Yuyv,
7924            DType::U8,
7925            None,
7926            edgefirst_tensor::CpuAccess::ReadWrite,
7927        )
7928        .unwrap();
7929        src.as_typed::<u8>()
7930            .unwrap()
7931            .map()
7932            .unwrap()
7933            .as_mut_slice()
7934            .copy_from_slice(&file);
7935
7936        let dst = TensorDyn::image(
7937            1280,
7938            720,
7939            PixelFormat::Rgb,
7940            DType::U8,
7941            None,
7942            edgefirst_tensor::CpuAccess::ReadWrite,
7943        )
7944        .unwrap();
7945        let mut cpu_converter = CPUProcessor::new();
7946
7947        let (result, _src, dst) = convert_img(
7948            &mut cpu_converter,
7949            src,
7950            dst,
7951            Rotation::None,
7952            Flip::None,
7953            Crop::no_crop(),
7954        );
7955        result.unwrap();
7956
7957        let target_image = TensorDyn::image(
7958            1280,
7959            720,
7960            PixelFormat::Rgb,
7961            DType::U8,
7962            None,
7963            edgefirst_tensor::CpuAccess::ReadWrite,
7964        )
7965        .unwrap();
7966        target_image
7967            .as_typed::<u8>()
7968            .unwrap()
7969            .map()
7970            .unwrap()
7971            .as_mut_slice()
7972            .as_chunks_mut::<3>()
7973            .0
7974            .iter_mut()
7975            .zip(
7976                edgefirst_bench::testdata::read("camera720p.rgba")
7977                    .as_chunks::<4>()
7978                    .0,
7979            )
7980            .for_each(|(dst, src)| *dst = [src[0], src[1], src[2]]);
7981
7982        // CPU path resolves the untagged 720p source to BT.709 limited (height
7983        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
7984        compare_images(&dst, &target_image, 0.98, function!());
7985    }
7986
7987    #[test]
7988    #[cfg(target_os = "linux")]
7989    fn test_yuyv_to_rgba_g2d() {
7990        if !is_g2d_available() {
7991            crate::test_support::report_skip(
7992                "test_yuyv_to_rgba_g2d - G2D library (libg2d.so.2) not available",
7993            );
7994            return;
7995        }
7996        if !is_dma_available() {
7997            crate::test_support::report_skip("test_yuyv_to_rgba_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
7998            return;
7999        }
8000
8001        let src = load_bytes_to_tensor(
8002            1280,
8003            720,
8004            PixelFormat::Yuyv,
8005            None,
8006            &edgefirst_bench::testdata::read("camera720p.yuyv"),
8007        )
8008        .unwrap();
8009
8010        let dst = TensorDyn::image(
8011            1280,
8012            720,
8013            PixelFormat::Rgba,
8014            DType::U8,
8015            Some(TensorMemory::DmaBuf),
8016            edgefirst_tensor::CpuAccess::ReadWrite,
8017        )
8018        .unwrap();
8019        let mut g2d_converter = G2DProcessor::new().unwrap();
8020
8021        let (result, _src, dst) = convert_img(
8022            &mut g2d_converter,
8023            src,
8024            dst,
8025            Rotation::None,
8026            Flip::None,
8027            Crop::no_crop(),
8028        );
8029        result.unwrap();
8030
8031        let target_image = TensorDyn::image(
8032            1280,
8033            720,
8034            PixelFormat::Rgba,
8035            DType::U8,
8036            None,
8037            edgefirst_tensor::CpuAccess::ReadWrite,
8038        )
8039        .unwrap();
8040        target_image
8041            .as_typed::<u8>()
8042            .unwrap()
8043            .map()
8044            .unwrap()
8045            .as_mut_slice()
8046            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
8047
8048        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
8049        // so the matrix delta vs the reference that forced 0.95 has closed;
8050        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
8051        compare_images(&dst, &target_image, 0.98, function!());
8052    }
8053
8054    #[test]
8055    #[cfg(any(
8056        target_os = "linux",
8057        target_os = "macos",
8058        target_os = "ios",
8059        target_os = "android",
8060        target_os = "windows"
8061    ))]
8062    #[cfg(feature = "opengl")]
8063    fn test_yuyv_to_rgba_opengl() {
8064        if !is_opengl_available() {
8065            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
8066            return;
8067        }
8068        // Zero-copy YUYV source: DMA-BUF on Linux, IOSurface on macOS, a
8069        // D3D11 texture on Windows; skips where no such backing exists.
8070        if !edgefirst_tensor::is_gpu_buffer_available() {
8071            crate::test_support::report_skip(&format!(
8072                "{} - zero-copy GPU buffer (DMA-BUF / IOSurface) not available on this host",
8073                function!()
8074            ));
8075            return;
8076        }
8077
8078        let src = load_bytes_to_tensor(
8079            1280,
8080            720,
8081            PixelFormat::Yuyv,
8082            Some(TensorMemory::DmaBuf),
8083            &edgefirst_bench::testdata::read("camera720p.yuyv"),
8084        )
8085        .unwrap();
8086
8087        let dst = TensorDyn::image(
8088            1280,
8089            720,
8090            PixelFormat::Rgba,
8091            DType::U8,
8092            Some(TensorMemory::DmaBuf),
8093            edgefirst_tensor::CpuAccess::ReadWrite,
8094        )
8095        .unwrap();
8096        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
8097
8098        let (result, _src, dst) = convert_img(
8099            &mut gl_converter,
8100            src,
8101            dst,
8102            Rotation::None,
8103            Flip::None,
8104            Crop::no_crop(),
8105        );
8106        // Desktop NVIDIA cannot sample YUYV as a GL texture; i.MX/Vivante can.
8107        // Production ImageProcessor falls back to CPU; this oracle skips.
8108        if let Err(crate::Error::NotSupported(msg)) = &result {
8109            crate::test_support::report_skip(&format!("{} — {msg}", function!()));
8110            return;
8111        }
8112        result.unwrap();
8113
8114        let target_image = TensorDyn::image(
8115            1280,
8116            720,
8117            PixelFormat::Rgba,
8118            DType::U8,
8119            None,
8120            edgefirst_tensor::CpuAccess::ReadWrite,
8121        )
8122        .unwrap();
8123        target_image
8124            .as_typed::<u8>()
8125            .unwrap()
8126            .map()
8127            .unwrap()
8128            .as_mut_slice()
8129            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
8130
8131        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
8132        // so the matrix delta vs the reference that forced 0.95 has closed;
8133        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
8134        compare_images(&dst, &target_image, 0.98, function!());
8135    }
8136
8137    /// macOS analog of `test_yuyv_to_rgba_opengl` — drives the ANGLE +
8138    /// IOSurface backend end-to-end and compares against the same
8139    /// reference image. Skips silently if ANGLE isn't installed so the
8140    /// test suite still passes on CI hosts without the Homebrew tap.
8141    /// Step-1 probe: proves ANGLE's Metal IOSurface-client-buffer path accepts
8142    /// an `L008`→`GL_RED` (R8) binding — the foundation for sampling the
8143    /// contiguous semi-planar YUV buffer as a single R8 texture. Renders a
8144    /// GREY (R8 IOSurface) source through the GL backend to RGBA and checks the
8145    /// luma round-trips to R=G=B (identity GREY→RGB).
8146    #[test]
8147    #[cfg(target_os = "macos")]
8148    #[cfg(feature = "opengl")]
8149    fn test_grey_r8_iosurface_to_rgba_opengl_macos() {
8150        let mut proc = match GLProcessorThreaded::new(None) {
8151            Ok(p) => p,
8152            Err(e) => {
8153                crate::test_support::report_skip(&format!(
8154                    "{} — GL engine init failed ({e:?})",
8155                    function!()
8156                ));
8157                return;
8158            }
8159        };
8160
8161        let (w, h) = (16usize, 16usize);
8162        let src = TensorDyn::image(
8163            w,
8164            h,
8165            PixelFormat::Grey,
8166            DType::U8,
8167            Some(TensorMemory::DmaBuf),
8168            edgefirst_tensor::CpuAccess::ReadWrite,
8169        )
8170        .expect("GREY IOSurface (R8/L008) should allocate — proves the FourCC mapping");
8171        // Known luma ramp: value = (x * 13 + y * 7) & 0xff.
8172        {
8173            let su8 = src.as_typed::<u8>().unwrap();
8174            let stride = src
8175                .as_typed::<u8>()
8176                .unwrap()
8177                .effective_row_stride()
8178                .unwrap();
8179            let mut m = su8.map().unwrap();
8180            let buf = m.as_mut_slice();
8181            for y in 0..h {
8182                for x in 0..w {
8183                    buf[y * stride + x] = ((x * 13 + y * 7) & 0xff) as u8;
8184                }
8185            }
8186        }
8187
8188        let dst = TensorDyn::image(
8189            w,
8190            h,
8191            PixelFormat::Rgba,
8192            DType::U8,
8193            Some(TensorMemory::DmaBuf),
8194            edgefirst_tensor::CpuAccess::ReadWrite,
8195        )
8196        .unwrap();
8197        let (result, src_back, dst) = convert_img(
8198            &mut proc,
8199            src,
8200            dst,
8201            Rotation::None,
8202            Flip::None,
8203            Crop::no_crop(),
8204        );
8205        result.expect("GREY(R8 IOSurface) → RGBA must convert on ANGLE (R8 binding works)");
8206
8207        let src_stride = src_back
8208            .as_typed::<u8>()
8209            .unwrap()
8210            .effective_row_stride()
8211            .unwrap();
8212        let src_map = src_back.as_typed::<u8>().unwrap().map().unwrap();
8213        let sbytes = src_map.as_slice();
8214        let dst_stride = dst
8215            .as_typed::<u8>()
8216            .unwrap()
8217            .effective_row_stride()
8218            .unwrap();
8219        let dst_map = dst.as_typed::<u8>().unwrap().map().unwrap();
8220        let dbytes = dst_map.as_slice();
8221        for y in 0..h {
8222            for x in 0..w {
8223                let yv = sbytes[y * src_stride + x] as i16;
8224                let p = y * dst_stride + x * 4;
8225                for c in 0..3 {
8226                    assert!(
8227                        (dbytes[p + c] as i16 - yv).abs() <= 2,
8228                        "pixel ({x},{y}) ch{c} = {} expected ~{yv} (GREY→RGB identity)",
8229                        dbytes[p + c]
8230                    );
8231                }
8232            }
8233        }
8234    }
8235
8236    /// Two-pass GPU chain: NV12 (R8 IOSurface) → PlanarRgb F16, the profiler's
8237    /// preprocess. Verifies the chained `convert_nv_to_planar_float`
8238    /// (NV12→RGBA8 then the verified RGBA8→PlanarRgb F16) executes on ANGLE and
8239    /// produces a sane F16 planar result: a neutral-grey NV12 input (Y=U=V=128,
8240    /// BT.601 full ⇒ RGB≈0.5) must yield all three planes ≈0.5 (half-float).
8241    #[test]
8242    #[cfg(target_os = "macos")]
8243    #[cfg(feature = "opengl")]
8244    fn test_nv12_to_planar_f16_two_pass_opengl_macos() {
8245        let mut gpu = match GLProcessorThreaded::new(None) {
8246            Ok(p) => p,
8247            Err(e) => {
8248                crate::test_support::report_skip(&format!("{} — init failed ({e:?})", function!()));
8249                return;
8250            }
8251        };
8252        let (w, h) = (64usize, 64usize);
8253        let src = match TensorDyn::image(
8254            w,
8255            h,
8256            PixelFormat::Nv12,
8257            DType::U8,
8258            Some(TensorMemory::DmaBuf),
8259            edgefirst_tensor::CpuAccess::ReadWrite,
8260        ) {
8261            Ok(t) => t,
8262            Err(e) => {
8263                crate::test_support::report_skip(&format!(
8264                    "{} — NV12 IOSurface alloc: {e:?}",
8265                    function!()
8266                ));
8267                return;
8268            }
8269        };
8270        src.as_typed::<u8>()
8271            .unwrap()
8272            .map()
8273            .unwrap()
8274            .as_mut_slice()
8275            .fill(128); // Y=U=V=128
8276
8277        let dst = match TensorDyn::image(
8278            w,
8279            h,
8280            PixelFormat::PlanarRgb,
8281            DType::F16,
8282            Some(TensorMemory::DmaBuf),
8283            edgefirst_tensor::CpuAccess::ReadWrite,
8284        ) {
8285            Ok(t) => t,
8286            Err(e) => {
8287                crate::test_support::report_skip(&format!(
8288                    "{} — F16 PlanarRgb IOSurface: {e:?}",
8289                    function!()
8290                ));
8291                return;
8292            }
8293        };
8294        let mut dst = dst;
8295        // Call convert directly (the convert_img helper restores u8 only).
8296        if let Err(e) = ImageProcessorTrait::convert(
8297            &mut gpu,
8298            &src,
8299            &mut dst,
8300            Rotation::None,
8301            Flip::None,
8302            Crop::no_crop(),
8303        ) {
8304            // GL_EXT_color_buffer_half_float may be absent on some configs; the
8305            // RGBA8 pass-1 + F16 pass-2 path then can't render. Skip rather than
8306            // fail on a capability gap (the same policy as the F16 path tests).
8307            crate::test_support::report_skip(&format!(
8308                "{} — NV12→PlanarRgb F16 not available ({e:?})",
8309                function!()
8310            ));
8311            return;
8312        }
8313        let dt = dst.as_typed::<half::f16>().expect("dst is F16 PlanarRgb");
8314        let map = dt.map().unwrap();
8315        let vals = map.as_slice();
8316        // Neutral grey → ~0.5 in every plane. Allow generous tolerance for the
8317        // mediump YUV math + half-float rounding.
8318        let mut checked = 0usize;
8319        for &v in vals.iter() {
8320            let f = f32::from(v);
8321            assert!(
8322                (0.40..=0.60).contains(&f),
8323                "planar F16 value {f} not ~0.5 for neutral-grey NV12"
8324            );
8325            checked += 1;
8326        }
8327        assert!(
8328            checked >= w * h * 3,
8329            "expected >= 3 planes of samples, got {checked}"
8330        );
8331    }
8332
8333    /// Profiler-shaped two-pass: a reused **R8/Grey pool** (allocated larger
8334    /// than the frame, the NV24 worst case `3·H`) is reconfigured to an NV12
8335    /// frame, filled at the preserved physical stride, and converted with a
8336    /// letterbox `src_rect` crop into a model-sized PlanarRgb F16 destination —
8337    /// exactly the orchestrator's preprocess. Guards against the pooled
8338    /// two-pass NV→PlanarRgb F16 path hanging/erroring (the exact-size
8339    /// `test_nv12_to_planar_f16_two_pass` never exercised the larger pool).
8340    #[test]
8341    #[cfg(target_os = "macos")]
8342    #[cfg(feature = "opengl")]
8343    fn test_nv12_to_planar_f16_two_pass_pool_opengl_macos() {
8344        let mut gpu = match GLProcessorThreaded::new(None) {
8345            Ok(p) => p,
8346            Err(e) => {
8347                crate::test_support::report_skip(&format!("{} — init failed ({e:?})", function!()));
8348                return;
8349            }
8350        };
8351        // Frame 96×64 in a 256×768 R8 pool (3·256 height; bpr padded past 96).
8352        let (fw, fh) = (96usize, 64usize);
8353        let (pool_w, pool_h) = (256usize, 768usize);
8354        let (model_w, model_h) = (128usize, 128usize);
8355
8356        let mut src = match TensorDyn::image(
8357            pool_w,
8358            pool_h,
8359            PixelFormat::Grey,
8360            DType::U8,
8361            Some(TensorMemory::DmaBuf),
8362            edgefirst_tensor::CpuAccess::ReadWrite,
8363        ) {
8364            Ok(t) => t,
8365            Err(e) => {
8366                crate::test_support::report_skip(&format!(
8367                    "{} — R8 pool alloc: {e:?}",
8368                    function!()
8369                ));
8370                return;
8371            }
8372        };
8373        src.configure_image(fw, fh, PixelFormat::Nv12)
8374            .unwrap_or_else(|e| panic!("configure_image NV12 on pool: {e}"));
8375        let stride = src
8376            .as_typed::<u8>()
8377            .unwrap()
8378            .effective_row_stride()
8379            .unwrap();
8380        src.as_typed::<u8>()
8381            .unwrap()
8382            .map()
8383            .unwrap()
8384            .as_mut_slice()
8385            .fill(128); // neutral grey
8386
8387        let mut dst = match TensorDyn::image(
8388            model_w,
8389            model_h,
8390            PixelFormat::PlanarRgb,
8391            DType::F16,
8392            Some(TensorMemory::DmaBuf),
8393            edgefirst_tensor::CpuAccess::ReadWrite,
8394        ) {
8395            Ok(t) => t,
8396            Err(e) => {
8397                crate::test_support::report_skip(&format!(
8398                    "{} — F16 PlanarRgb dst: {e:?}",
8399                    function!()
8400                ));
8401                return;
8402            }
8403        };
8404
8405        // Letterbox crop like the profiler: the backend computes the band.
8406        let _ = model_w;
8407        let crop = Crop::new()
8408            .with_source(Some(Region::new(0, 0, fw, fh)))
8409            .with_fit(Fit::Letterbox {
8410                pad: [0, 0, 0, 255],
8411            });
8412        if let Err(e) =
8413            ImageProcessorTrait::convert(&mut gpu, &src, &mut dst, Rotation::None, Flip::None, crop)
8414        {
8415            crate::test_support::report_skip(&format!(
8416                "{} — NV12→PlanarRgb F16 unavailable ({e:?})",
8417                function!()
8418            ));
8419            return;
8420        }
8421        let _ = stride;
8422        // Neutral grey → ~0.5 inside the letterbox band; just assert the convert
8423        // completed and produced finite values (no hang, no NaN garbage).
8424        let dt = dst.as_typed::<half::f16>().expect("dst F16");
8425        let map = dt.map().unwrap();
8426        let any_half = map.as_slice().iter().any(|&v| {
8427            let f = f32::from(v);
8428            (0.40..=0.60).contains(&f)
8429        });
8430        assert!(any_half, "expected ~0.5 grey samples in the letterbox band");
8431    }
8432
8433    /// Mirrors the orchestrator: the GL processor is created on one thread
8434    /// and `convert()` is called from a *different* thread (the profiler's
8435    /// Pre-processing worker). Reproduces (or rules out) the GL-context /
8436    /// `glFinish` cross-thread hang seen in the live pipeline. A 20 s watchdog
8437    /// fails loudly rather than hanging the whole test binary.
8438    #[test]
8439    #[cfg(target_os = "macos")]
8440    #[cfg(feature = "opengl")]
8441    fn test_nv12_to_planar_f16_cross_thread_opengl_macos() {
8442        use std::sync::mpsc;
8443        // Public ImageProcessor (Send) created HERE (the main test thread),
8444        // exactly like the orchestrator builds `config.processor` during setup.
8445        let mut proc = match ImageProcessor::new() {
8446            Ok(p) => p,
8447            Err(e) => {
8448                crate::test_support::report_skip(&format!("{} — init failed ({e:?})", function!()));
8449                return;
8450            }
8451        };
8452        let (fw, fh) = (96usize, 64usize);
8453        let mut src = match TensorDyn::image(
8454            256,
8455            768,
8456            PixelFormat::Grey,
8457            DType::U8,
8458            Some(TensorMemory::DmaBuf),
8459            edgefirst_tensor::CpuAccess::ReadWrite,
8460        ) {
8461            Ok(t) => t,
8462            Err(e) => {
8463                crate::test_support::report_skip(&format!("{} — pool: {e:?}", function!()));
8464                return;
8465            }
8466        };
8467        src.configure_image(fw, fh, PixelFormat::Nv12).unwrap();
8468        src.as_typed::<u8>()
8469            .unwrap()
8470            .map()
8471            .unwrap()
8472            .as_mut_slice()
8473            .fill(128);
8474        let mut dst = match TensorDyn::image(
8475            128,
8476            128,
8477            PixelFormat::PlanarRgb,
8478            DType::F16,
8479            Some(TensorMemory::DmaBuf),
8480            edgefirst_tensor::CpuAccess::ReadWrite,
8481        ) {
8482            Ok(t) => t,
8483            Err(e) => {
8484                crate::test_support::report_skip(&format!("{} — dst: {e:?}", function!()));
8485                return;
8486            }
8487        };
8488        let crop = Crop::new().with_source(Some(Region::new(0, 0, fw, fh)));
8489
8490        // ...then MOVED to a worker thread where convert() runs — exactly the
8491        // orchestrator's create-on-setup / convert-on-Pre-processing split.
8492        let (tx, rx) = mpsc::channel::<bool>();
8493        let worker = std::thread::spawn(move || {
8494            let _ = ImageProcessorTrait::convert(
8495                &mut proc,
8496                &src,
8497                &mut dst,
8498                Rotation::None,
8499                Flip::None,
8500                crop,
8501            );
8502            let _ = tx.send(true);
8503        });
8504        match rx.recv_timeout(std::time::Duration::from_secs(20)) {
8505            Ok(_) => { let _ = worker.join(); }
8506            Err(_) => panic!(
8507                "cross-thread NV12→PlanarRgb convert HUNG (>20s) — reproduces the orchestrator deadlock"
8508            ),
8509        }
8510    }
8511
8512    /// Reproduces the profiler's progressive-slowdown/hang: one processor
8513    /// converting many **varying-size** NV frames (like a COCO dataset) from a
8514    /// reused R8 pool into a fixed PlanarRgb F16 model input. The two-pass path
8515    /// reallocated its RGBA intermediate per frame-size, churning/leaking
8516    /// pbuffers until the GPU stalled. Asserts per-convert latency stays bounded
8517    /// (no runaway) over many iterations.
8518    #[test]
8519    #[cfg(target_os = "macos")]
8520    #[cfg(feature = "opengl")]
8521    fn test_nv_to_planar_f16_varying_sizes_no_leak_opengl_macos() {
8522        let mut gpu = match GLProcessorThreaded::new(None) {
8523            Ok(p) => p,
8524            Err(e) => {
8525                crate::test_support::report_skip(&format!("{} — init failed ({e:?})", function!()));
8526                return;
8527            }
8528        };
8529        // Mirror the orchestrator's ring buffers at depth 4: a pool of source R8
8530        // tensors AND a pool of PlanarRgb F16 dst slots, both cycled per frame.
8531        let (max_w, max_h) = (640usize, 640usize);
8532        let depth = 4usize;
8533        let mut srcs = Vec::new();
8534        let mut dsts = Vec::new();
8535        for _ in 0..depth {
8536            srcs.push(
8537                match TensorDyn::image(
8538                    max_w,
8539                    max_h * 3,
8540                    PixelFormat::Grey,
8541                    DType::U8,
8542                    Some(TensorMemory::DmaBuf),
8543                    edgefirst_tensor::CpuAccess::ReadWrite,
8544                ) {
8545                    Ok(t) => t,
8546                    Err(e) => {
8547                        crate::test_support::report_skip(&format!("{} — pool: {e:?}", function!()));
8548                        return;
8549                    }
8550                },
8551            );
8552            dsts.push(
8553                match TensorDyn::image(
8554                    640,
8555                    640,
8556                    PixelFormat::PlanarRgb,
8557                    DType::F16,
8558                    Some(TensorMemory::DmaBuf),
8559                    edgefirst_tensor::CpuAccess::ReadWrite,
8560                ) {
8561                    Ok(t) => t,
8562                    Err(e) => {
8563                        crate::test_support::report_skip(&format!("{} — dst: {e:?}", function!()));
8564                        return;
8565                    }
8566                },
8567            );
8568        }
8569        // COCO-like assorted frame sizes (all ≤ max), cycled.
8570        let sizes = [
8571            (640, 480),
8572            (500, 375),
8573            (640, 427),
8574            (333, 500),
8575            (480, 640),
8576            (612, 612),
8577            (428, 640),
8578            (576, 432),
8579        ];
8580        let mut first_ms = 0f64;
8581        let mut last_ms = 0f64;
8582        let iters = 40usize;
8583        for i in 0..iters {
8584            let (fw, fh) = sizes[i % sizes.len()];
8585            let src = &mut srcs[i % depth];
8586            let dst = &mut dsts[i % depth];
8587            src.configure_image(fw, fh, PixelFormat::Nv24).unwrap();
8588            src.as_typed::<u8>()
8589                .unwrap()
8590                .map()
8591                .unwrap()
8592                .as_mut_slice()
8593                .fill(128);
8594            let crop = Crop::new().with_source(Some(Region::new(0, 0, fw, fh)));
8595            let t0 = std::time::Instant::now();
8596            ImageProcessorTrait::convert(&mut gpu, src, dst, Rotation::None, Flip::None, crop)
8597                .unwrap_or_else(|e| panic!("convert iter {i} ({fw}×{fh}): {e}"));
8598            let ms = t0.elapsed().as_secs_f64() * 1e3;
8599            if i == 2 {
8600                first_ms = ms;
8601            }
8602            if i == iters - 1 {
8603                last_ms = ms;
8604            }
8605        }
8606        eprintln!("first={first_ms:.2}ms last={last_ms:.2}ms");
8607        assert!(
8608            last_ms < first_ms * 5.0 + 5.0,
8609            "convert latency ran away: first {first_ms:.2}ms → last {last_ms:.2}ms (intermediate/pbuffer leak)"
8610        );
8611    }
8612
8613    /// Step-2 verification: NV12/NV16/NV24 (R8 IOSurface) → RGBA on the GPU
8614    /// must match the CPU `yuv` kernels within shader rounding. Fills an
8615    /// IOSurface source and a Mem source from the same logical YUV pattern
8616    /// (each at its own row stride), converts the IOSurface on the GPU and the
8617    /// Mem one on the CPU, and compares. Exercises the in-shader semi-planar
8618    /// addressing for all three subsamplings (incl. NV24's 2×-wide UV rows).
8619    #[test]
8620    #[cfg(target_os = "macos")]
8621    #[cfg(feature = "opengl")]
8622    fn test_nv12_nv16_nv24_to_rgba_opengl_macos() {
8623        let mut gpu = match GLProcessorThreaded::new(None) {
8624            Ok(p) => p,
8625            Err(e) => {
8626                crate::test_support::report_skip(&format!(
8627                    "{} — GL engine init failed ({e:?})",
8628                    function!()
8629                ));
8630                return;
8631            }
8632        };
8633        let mut cpu = CPUProcessor::new();
8634
8635        // Fill Y plus the interleaved UV plane in the canonical semi-planar
8636        // layout — exactly what the codec writes and the CPU `yuv`-crate reader
8637        // expects: the Y plane is `h` rows at the buffer's row stride; the UV
8638        // plane starts at `h * stride`; each chroma row advances
8639        // `uv_grid_rows * stride` bytes (NV24 carries a full-resolution `2*W`
8640        // byte line == two grid rows, NV12/NV16 one row of `W/2` pairs); each
8641        // (Cb,Cr) pair is two consecutive bytes at column `cx * 2`. This is
8642        // stride-correct for both the tight Mem buffer and the padded IOSurface.
8643        //
8644        // Takes explicit w/h so the closure can be reused across multiple frame
8645        // sizes (even and odd) without capturing a fixed outer variable.
8646        let fill = |buf: &mut [u8], stride: usize, fmt: PixelFormat, w: usize, h: usize| {
8647            for y in 0..h {
8648                for x in 0..w {
8649                    buf[y * stride + x] = ((x * 9 + y * 5) & 0xff) as u8;
8650                }
8651            }
8652            let (cw, ch, uv_grid_rows) = match fmt {
8653                PixelFormat::Nv12 => (w / 2, h / 2, 1usize),
8654                PixelFormat::Nv16 => (w / 2, h, 1usize),
8655                _ => (w, h, 2usize), // Nv24: full-res chroma, 2W bytes/row
8656            };
8657            let uv_plane = h * stride;
8658            for cy in 0..ch {
8659                for cx in 0..cw {
8660                    let off = uv_plane + cy * uv_grid_rows * stride + cx * 2;
8661                    buf[off] = ((cx * 11 + 30) & 0xff) as u8;
8662                    buf[off + 1] = ((cy * 7 + 200) & 0xff) as u8;
8663                }
8664            }
8665        };
8666
8667        for fmt in [PixelFormat::Nv12, PixelFormat::Nv16, PixelFormat::Nv24] {
8668            for (w, h) in [
8669                (16usize, 16usize), // original even-dim case
8670                (15, 16),           // odd-W
8671                (16, 15),           // odd-H
8672            ] {
8673                let mem = TensorDyn::image(
8674                    w,
8675                    h,
8676                    fmt,
8677                    DType::U8,
8678                    None,
8679                    edgefirst_tensor::CpuAccess::ReadWrite,
8680                )
8681                .unwrap();
8682                let mem_stride = mem
8683                    .as_typed::<u8>()
8684                    .unwrap()
8685                    .effective_row_stride()
8686                    .unwrap();
8687                fill(
8688                    mem.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice(),
8689                    mem_stride,
8690                    fmt,
8691                    w,
8692                    h,
8693                );
8694                let cpu_dst = TensorDyn::image(
8695                    w,
8696                    h,
8697                    PixelFormat::Rgba,
8698                    DType::U8,
8699                    None,
8700                    edgefirst_tensor::CpuAccess::ReadWrite,
8701                )
8702                .unwrap();
8703                let (r, _s, cpu_dst) = convert_img(
8704                    &mut cpu,
8705                    mem,
8706                    cpu_dst,
8707                    Rotation::None,
8708                    Flip::None,
8709                    Crop::no_crop(),
8710                );
8711                r.unwrap_or_else(|e| panic!("CPU {fmt:?}->{w}x{h}->RGBA: {e}"));
8712
8713                let ios = TensorDyn::image(
8714                    w,
8715                    h,
8716                    fmt,
8717                    DType::U8,
8718                    Some(TensorMemory::DmaBuf),
8719                    edgefirst_tensor::CpuAccess::ReadWrite,
8720                )
8721                .unwrap_or_else(|e| panic!("{fmt:?} {w}x{h} IOSurface alloc: {e}"));
8722                let ios_stride = ios
8723                    .as_typed::<u8>()
8724                    .unwrap()
8725                    .effective_row_stride()
8726                    .unwrap();
8727                fill(
8728                    ios.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice(),
8729                    ios_stride,
8730                    fmt,
8731                    w,
8732                    h,
8733                );
8734                let gpu_dst = TensorDyn::image(
8735                    w,
8736                    h,
8737                    PixelFormat::Rgba,
8738                    DType::U8,
8739                    Some(TensorMemory::DmaBuf),
8740                    edgefirst_tensor::CpuAccess::ReadWrite,
8741                )
8742                .unwrap();
8743                let (r, _s, gpu_dst) = convert_img(
8744                    &mut gpu,
8745                    ios,
8746                    gpu_dst,
8747                    Rotation::None,
8748                    Flip::None,
8749                    Crop::no_crop(),
8750                );
8751                r.unwrap_or_else(|e| panic!("GPU {fmt:?}->{w}x{h}->RGBA on ANGLE: {e}"));
8752
8753                let cs = cpu_dst
8754                    .as_typed::<u8>()
8755                    .unwrap()
8756                    .effective_row_stride()
8757                    .unwrap();
8758                let cmap = cpu_dst.as_typed::<u8>().unwrap().map().unwrap();
8759                let cb = cmap.as_slice();
8760                let gs = gpu_dst
8761                    .as_typed::<u8>()
8762                    .unwrap()
8763                    .effective_row_stride()
8764                    .unwrap();
8765                let gmap = gpu_dst.as_typed::<u8>().unwrap().map().unwrap();
8766                let gb = gmap.as_slice();
8767                let mut max_d = 0i16;
8768                for y in 0..h {
8769                    for x in 0..w {
8770                        for c in 0..3 {
8771                            let cv = cb[y * cs + x * 4 + c] as i16;
8772                            let gv = gb[y * gs + x * 4 + c] as i16;
8773                            max_d = max_d.max((cv - gv).abs());
8774                        }
8775                    }
8776                }
8777                assert!(
8778                    max_d <= 3,
8779                    "{fmt:?} {w}x{h}: GPU vs CPU RGBA max channel diff {max_d} > 3"
8780                );
8781            }
8782        }
8783    }
8784
8785    /// Phase-0 gate: the reused-pool / larger-surface case. A single R8 pool
8786    /// IOSurface (allocated bigger than the frame, so its `bytesPerRow` exceeds
8787    /// the frame's even width) is reconfigured to each NV frame, filled at the
8788    /// preserved physical stride, and converted on the GPU. This proves ANGLE
8789    /// binds the *whole* physical surface as a pbuffer and that `texelFetch`
8790    /// resolves the frame's Y/UV texels through the surface's real `bytesPerRow`
8791    /// (the physical-grid / logical-ROI decoupling). GPU must match CPU ≤3 LSB.
8792    #[test]
8793    #[cfg(target_os = "macos")]
8794    #[cfg(feature = "opengl")]
8795    fn test_nv_to_rgba_larger_pool_surface_opengl_macos() {
8796        let mut gpu = match GLProcessorThreaded::new(None) {
8797            Ok(p) => p,
8798            Err(e) => {
8799                crate::test_support::report_skip(&format!(
8800                    "{} — GL engine init failed ({e:?})",
8801                    function!()
8802                ));
8803                return;
8804            }
8805        };
8806        let mut cpu = CPUProcessor::new();
8807        // The pool is generously oversized (256-wide → bpr 256, well beyond any
8808        // frame's even width) and tall enough for NV24's 3·H at the test frames.
8809        let (pool_w, pool_h) = (256usize, 256usize);
8810
8811        // Canonical semi-planar fill: Y plane at the row stride, then the UV
8812        // plane at `h * stride` with each chroma row advancing `uv_grid_rows *
8813        // stride` bytes. Stride-correct for both tight Mem and padded IOSurface.
8814        let fill = |buf: &mut [u8], stride: usize, fmt: PixelFormat, w: usize, h: usize| {
8815            for y in 0..h {
8816                for x in 0..w {
8817                    buf[y * stride + x] = ((x * 9 + y * 5) & 0xff) as u8;
8818                }
8819            }
8820            let (cw, ch, uv_grid_rows) = match fmt {
8821                PixelFormat::Nv12 => (w / 2, h / 2, 1usize),
8822                PixelFormat::Nv16 => (w / 2, h, 1usize),
8823                _ => (w, h, 2usize), // Nv24: full-res chroma, 2W bytes/row
8824            };
8825            let uv_plane = h * stride;
8826            for cy in 0..ch {
8827                for cx in 0..cw {
8828                    let off = uv_plane + cy * uv_grid_rows * stride + cx * 2;
8829                    buf[off] = ((cx * 11 + 30) & 0xff) as u8;
8830                    buf[off + 1] = ((cy * 7 + 200) & 0xff) as u8;
8831                }
8832            }
8833        };
8834
8835        for fmt in [PixelFormat::Nv12, PixelFormat::Nv16, PixelFormat::Nv24] {
8836            for (w, h) in [
8837                (40usize, 24usize), // original even-dim case
8838                (15, 16),           // odd-W
8839                (16, 15),           // odd-H
8840            ] {
8841                // `ew` is the minimum even extent; the pool stride must exceed it
8842                // to actually exercise the physical-stride shader decoupling.
8843                let ew = w.next_multiple_of(2);
8844
8845                // CPU reference from a tightly-packed Mem tensor at the frame size.
8846                let mem = TensorDyn::image(
8847                    w,
8848                    h,
8849                    fmt,
8850                    DType::U8,
8851                    None,
8852                    edgefirst_tensor::CpuAccess::ReadWrite,
8853                )
8854                .unwrap();
8855                let mem_stride = mem
8856                    .as_typed::<u8>()
8857                    .unwrap()
8858                    .effective_row_stride()
8859                    .unwrap();
8860                fill(
8861                    mem.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice(),
8862                    mem_stride,
8863                    fmt,
8864                    w,
8865                    h,
8866                );
8867                let cpu_dst = TensorDyn::image(
8868                    w,
8869                    h,
8870                    PixelFormat::Rgba,
8871                    DType::U8,
8872                    None,
8873                    edgefirst_tensor::CpuAccess::ReadWrite,
8874                )
8875                .unwrap();
8876                let (r, _s, cpu_dst) = convert_img(
8877                    &mut cpu,
8878                    mem,
8879                    cpu_dst,
8880                    Rotation::None,
8881                    Flip::None,
8882                    Crop::no_crop(),
8883                );
8884                r.unwrap_or_else(|e| panic!("CPU {fmt:?}->{w}x{h}->RGBA: {e}"));
8885
8886                // GPU source: a LARGER R8 pool surface, reconfigured down to the
8887                // frame. Phase 1 preserves the pool's padded `bytesPerRow` as the
8888                // tensor's row stride; the fill writes the frame at that stride.
8889                let mut ios = match TensorDyn::image(
8890                    pool_w,
8891                    pool_h,
8892                    PixelFormat::Grey,
8893                    DType::U8,
8894                    Some(TensorMemory::DmaBuf),
8895                    edgefirst_tensor::CpuAccess::ReadWrite,
8896                ) {
8897                    Ok(t) => t,
8898                    Err(e) => {
8899                        crate::test_support::report_skip(&format!(
8900                            "{} — R8 pool IOSurface alloc: {e:?}",
8901                            function!()
8902                        ));
8903                        return;
8904                    }
8905                };
8906                ios.configure_image(w, h, fmt)
8907                    .unwrap_or_else(|e| panic!("configure_image {fmt:?} {w}x{h} on pool: {e}"));
8908                let ios_stride = ios
8909                    .as_typed::<u8>()
8910                    .unwrap()
8911                    .effective_row_stride()
8912                    .unwrap();
8913                assert!(
8914                    ios_stride > ew,
8915                    "{fmt:?} {w}x{h}: pool stride {ios_stride} should exceed even width {ew} \
8916                     (test must exercise padding)"
8917                );
8918                fill(
8919                    ios.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice(),
8920                    ios_stride,
8921                    fmt,
8922                    w,
8923                    h,
8924                );
8925
8926                let gpu_dst = TensorDyn::image(
8927                    w,
8928                    h,
8929                    PixelFormat::Rgba,
8930                    DType::U8,
8931                    Some(TensorMemory::DmaBuf),
8932                    edgefirst_tensor::CpuAccess::ReadWrite,
8933                )
8934                .unwrap();
8935                let (r, _s, gpu_dst) = convert_img(
8936                    &mut gpu,
8937                    ios,
8938                    gpu_dst,
8939                    Rotation::None,
8940                    Flip::None,
8941                    Crop::no_crop(),
8942                );
8943                r.unwrap_or_else(|e| {
8944                    panic!("GPU {fmt:?}->{w}x{h}->RGBA (pool surface) on ANGLE: {e}")
8945                });
8946
8947                let cs = cpu_dst
8948                    .as_typed::<u8>()
8949                    .unwrap()
8950                    .effective_row_stride()
8951                    .unwrap();
8952                let cmap = cpu_dst.as_typed::<u8>().unwrap().map().unwrap();
8953                let cb = cmap.as_slice();
8954                let gs = gpu_dst
8955                    .as_typed::<u8>()
8956                    .unwrap()
8957                    .effective_row_stride()
8958                    .unwrap();
8959                let gmap = gpu_dst.as_typed::<u8>().unwrap().map().unwrap();
8960                let gb = gmap.as_slice();
8961                let mut max_d = 0i16;
8962                for y in 0..h {
8963                    for x in 0..w {
8964                        for c in 0..3 {
8965                            let cv = cb[y * cs + x * 4 + c] as i16;
8966                            let gv = gb[y * gs + x * 4 + c] as i16;
8967                            max_d = max_d.max((cv - gv).abs());
8968                        }
8969                    }
8970                }
8971                assert!(
8972                    max_d <= 3,
8973                    "{fmt:?} {w}x{h}: GPU(pool surface) vs CPU RGBA max channel diff {max_d} > 3"
8974                );
8975            }
8976        }
8977    }
8978
8979    #[test]
8980    #[cfg(target_os = "macos")]
8981    #[cfg(feature = "opengl")]
8982    fn test_yuyv_to_rgba_opengl_macos() {
8983        let mut proc = match GLProcessorThreaded::new(None) {
8984            Ok(p) => p,
8985            Err(e) => {
8986                crate::test_support::report_skip(&format!(
8987                    "{} — GL engine init failed ({e:?}). \
8988                     Install ANGLE via `brew install startergo/angle/angle` \
8989                     and re-sign per README.md § macOS GPU Acceleration to \
8990                     run this test.",
8991                    function!()
8992                ));
8993                return;
8994            }
8995        };
8996
8997        let src = load_bytes_to_tensor(
8998            1280,
8999            720,
9000            PixelFormat::Yuyv,
9001            Some(TensorMemory::DmaBuf),
9002            &edgefirst_bench::testdata::read("camera720p.yuyv"),
9003        )
9004        .unwrap();
9005
9006        let dst = TensorDyn::image(
9007            1280,
9008            720,
9009            PixelFormat::Rgba,
9010            DType::U8,
9011            Some(TensorMemory::DmaBuf),
9012            edgefirst_tensor::CpuAccess::ReadWrite,
9013        )
9014        .unwrap();
9015
9016        let (result, _src, dst) = convert_img(
9017            &mut proc,
9018            src,
9019            dst,
9020            Rotation::None,
9021            Flip::None,
9022            Crop::no_crop(),
9023        );
9024        result.unwrap();
9025
9026        let target_image = TensorDyn::image(
9027            1280,
9028            720,
9029            PixelFormat::Rgba,
9030            DType::U8,
9031            None,
9032            edgefirst_tensor::CpuAccess::ReadWrite,
9033        )
9034        .unwrap();
9035        target_image
9036            .as_typed::<u8>()
9037            .unwrap()
9038            .map()
9039            .unwrap()
9040            .as_mut_slice()
9041            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
9042
9043        // macOS YUYV shader now threads per-tensor colorimetry: the untagged
9044        // camera720p source resolves to BT.709 limited (matching the BT.709
9045        // reference), measured 0.9973 on ANGLE — up from 0.9733 under the old
9046        // BT.601-full stop-gap. 0.98 leaves headroom for cross-GPU variance.
9047        compare_images(&dst, &target_image, 0.98, function!());
9048    }
9049
9050    /// Multi-resolution smoke test: convert YUYV→RGBA via the GL
9051    /// backend at a small (64×32) frame and a 4K (3840×2160) frame,
9052    /// both filled with a synthetic mid-grey pattern. Validates the
9053    /// shader math at the chroma-pairing boundary on small textures
9054    /// and exercises the IOSurface bytes-per-row alignment path at 4K
9055    /// (3840 pixels × 2 bytes/pixel = 7680 bytes, naturally 64-aligned).
9056    ///
9057    /// Resolutions below 32 pixels wide aren't tested because the
9058    /// IOSurface allocator pads bpr to 64 bytes — for a 4-px-wide
9059    /// YUYV surface that's 8 bytes data + 56 bytes padding per row,
9060    /// which exercises a sampling pattern that's ANGLE-version
9061    /// dependent rather than HAL-correctness dependent.
9062    ///
9063    /// This complements `test_yuyv_to_rgba_opengl_macos` (which checks
9064    /// pixel-exact correctness against a reference image at 720p) by
9065    /// ensuring the pipeline does not crash or produce gross errors at
9066    /// resolution extremes. Pixel-exact validation at 4K would require
9067    /// a 30 MB reference file we don't want to bundle.
9068    #[test]
9069    #[cfg(target_os = "macos")]
9070    #[cfg(feature = "opengl")]
9071    fn test_yuyv_to_rgba_opengl_macos_multi_resolution() {
9072        let mut proc = match GLProcessorThreaded::new(None) {
9073            Ok(p) => p,
9074            Err(e) => {
9075                crate::test_support::report_skip(&format!(
9076                    "{} — GL engine init failed ({e:?})",
9077                    function!()
9078                ));
9079                return;
9080            }
9081        };
9082
9083        for (w, h) in [(64usize, 32usize), (3840, 2160)] {
9084            // Synthetic YUYV: Y=128 (mid-grey luma), U=V=128 (neutral
9085            // chroma) → RGB grey at the output.
9086            let bytes_per_row = w * 2;
9087            let mut yuyv = vec![0u8; bytes_per_row * h];
9088            for chunk in yuyv.as_chunks_mut::<4>().0 {
9089                chunk[0] = 128; // Y0
9090                chunk[1] = 128; // U
9091                chunk[2] = 128; // Y1
9092                chunk[3] = 128; // V
9093            }
9094
9095            let src =
9096                load_bytes_to_tensor(w, h, PixelFormat::Yuyv, Some(TensorMemory::DmaBuf), &yuyv)
9097                    .unwrap();
9098
9099            let dst = TensorDyn::image(
9100                w,
9101                h,
9102                PixelFormat::Rgba,
9103                DType::U8,
9104                Some(TensorMemory::DmaBuf),
9105                edgefirst_tensor::CpuAccess::ReadWrite,
9106            )
9107            .unwrap();
9108
9109            let (result, _src, dst) = convert_img(
9110                &mut proc,
9111                src,
9112                dst,
9113                Rotation::None,
9114                Flip::None,
9115                Crop::no_crop(),
9116            );
9117            result.expect("GL convert should succeed at this resolution");
9118
9119            // The neutral-chroma input must produce a near-grey output;
9120            // BT.709 limited-range maps Y=128/UV=128 → roughly
9121            // (130, 130, 130). Allow ±4 LSB for `mediump float` shader
9122            // rounding.
9123            let dst_u8 = dst.as_typed::<u8>().unwrap();
9124            let dst_map = dst_u8.map().unwrap();
9125            let dst_bytes = dst_map.as_slice();
9126            assert_eq!(dst_bytes.len(), w * h * 4, "RGBA byte count");
9127            for px in dst_bytes.as_chunks::<4>().0 {
9128                for (i, &c) in px[..3].iter().enumerate() {
9129                    assert!(
9130                        (120..=140).contains(&c),
9131                        "{}: channel {i} = {c} (expected ~128 ±12) at {w}×{h}",
9132                        function!(),
9133                    );
9134                }
9135                assert_eq!(px[3], 255, "alpha must be 1.0");
9136            }
9137        }
9138    }
9139
9140    /// Verify that two consecutive convert() calls on the same source
9141    /// tensor reuse the cached EGL pbuffer. Tests the cache hit path
9142    /// added with the macOS GL backend hardening — without it, each
9143    /// frame would pay `eglCreatePbufferFromClientBuffer` + destroy.
9144    ///
9145    /// This is a behaviour test rather than a perf test (the timing
9146    /// difference is 100-200µs which is too noisy to assert on); we
9147    /// check that the second call succeeds and produces a result
9148    /// identical to the first.
9149    #[test]
9150    #[cfg(target_os = "macos")]
9151    #[cfg(feature = "opengl")]
9152    fn test_macos_gl_pbuffer_cache_reuses_surfaces() {
9153        let mut proc = match GLProcessorThreaded::new(None) {
9154            Ok(p) => p,
9155            Err(e) => {
9156                crate::test_support::report_skip(&format!(
9157                    "{} — GL engine init failed ({e:?})",
9158                    function!()
9159                ));
9160                return;
9161            }
9162        };
9163
9164        // Allocate one source + one destination, run convert twice.
9165        let mut yuyv = vec![0u8; 64 * 32 * 2];
9166        for chunk in yuyv.as_chunks_mut::<4>().0 {
9167            chunk[0] = 200;
9168            chunk[1] = 100;
9169            chunk[2] = 200;
9170            chunk[3] = 156;
9171        }
9172        let src =
9173            load_bytes_to_tensor(64, 32, PixelFormat::Yuyv, Some(TensorMemory::DmaBuf), &yuyv)
9174                .unwrap();
9175        let dst = TensorDyn::image(
9176            64,
9177            32,
9178            PixelFormat::Rgba,
9179            DType::U8,
9180            Some(TensorMemory::DmaBuf),
9181            edgefirst_tensor::CpuAccess::ReadWrite,
9182        )
9183        .unwrap();
9184
9185        let (r1, src, dst) = convert_img(
9186            &mut proc,
9187            src,
9188            dst,
9189            Rotation::None,
9190            Flip::None,
9191            Crop::no_crop(),
9192        );
9193        r1.unwrap();
9194        let first: Vec<u8> = dst
9195            .as_typed::<u8>()
9196            .unwrap()
9197            .map()
9198            .unwrap()
9199            .as_slice()
9200            .to_vec();
9201
9202        let (r2, _src, dst) = convert_img(
9203            &mut proc,
9204            src,
9205            dst,
9206            Rotation::None,
9207            Flip::None,
9208            Crop::no_crop(),
9209        );
9210        r2.unwrap();
9211        let second: Vec<u8> = dst
9212            .as_typed::<u8>()
9213            .unwrap()
9214            .map()
9215            .unwrap()
9216            .as_slice()
9217            .to_vec();
9218
9219        assert_eq!(first, second, "cache-hit conversion must be deterministic");
9220    }
9221
9222    /// Steady-state import gate (macOS half of the Linux
9223    /// `dma_pool_steady_state_zero_imports` test): an N-frame convert loop
9224    /// over a fixed pool of IOSurface tensors must create ZERO new EGL
9225    /// pbuffers after the pool has been seen once — pbuffer-cache misses
9226    /// stay flat while hits grow. Counter-based hardening of
9227    /// `test_macos_gl_pbuffer_cache_reuses_surfaces` above: a refactor that
9228    /// re-imports per frame passes the pixel-equality test but fails this.
9229    #[test]
9230    #[cfg(target_os = "macos")]
9231    #[cfg(feature = "opengl")]
9232    fn test_macos_gl_pbuffer_cache_steady_state() {
9233        let mut proc = match GLProcessorThreaded::new(None) {
9234            Ok(p) => p,
9235            Err(e) => {
9236                crate::test_support::report_skip(&format!(
9237                    "{} — GL engine init failed ({e:?})",
9238                    function!()
9239                ));
9240                return;
9241            }
9242        };
9243
9244        let (w, h) = (64usize, 32usize);
9245        const POOL: usize = 3;
9246        const FRAMES: usize = 100;
9247
9248        let yuyv = vec![128u8; w * h * 2];
9249        let pool: Vec<TensorDyn> = (0..POOL)
9250            .map(|_| {
9251                load_bytes_to_tensor(w, h, PixelFormat::Yuyv, Some(TensorMemory::DmaBuf), &yuyv)
9252                    .unwrap()
9253            })
9254            .collect();
9255        let mut dst = TensorDyn::image(
9256            w,
9257            h,
9258            PixelFormat::Rgba,
9259            DType::U8,
9260            Some(TensorMemory::DmaBuf),
9261            edgefirst_tensor::CpuAccess::ReadWrite,
9262        )
9263        .unwrap();
9264
9265        // Warmup: two passes over the pool import every surface once.
9266        for src in pool.iter().cycle().take(POOL * 2) {
9267            proc.convert(src, &mut dst, Rotation::None, Flip::None, Crop::no_crop())
9268                .unwrap();
9269        }
9270        let warm = proc.egl_cache_stats().unwrap();
9271
9272        for src in pool.iter().cycle().take(FRAMES) {
9273            proc.convert(src, &mut dst, Rotation::None, Flip::None, Crop::no_crop())
9274                .unwrap();
9275        }
9276        let steady = proc.egl_cache_stats().unwrap();
9277
9278        assert_eq!(
9279            warm.total_misses(),
9280            steady.total_misses(),
9281            "steady-state loop created new imports (warm {warm:?}, steady {steady:?})"
9282        );
9283        let hits = |s: &GlCacheStats| s.src.hits + s.dst.hits + s.nv_r8.hits + s.nv_uv.hits;
9284        assert!(
9285            hits(&steady) - hits(&warm) >= FRAMES as u64,
9286            "expected at least {FRAMES} import-cache hits over the loop, got {}",
9287            hits(&steady) - hits(&warm)
9288        );
9289    }
9290
9291    /// Backend assertion for the F16 zero-copy path: when the GL backend
9292    /// initialized (ANGLE on macOS) and reports F16 color-buffer support,
9293    /// the NV12→PlanarRgb-F16 IOSurface convert MUST be handled by the GL
9294    /// engine — proven by the engine's import-cache counters moving, not
9295    /// just by output correctness. This is the guard against the
9296    /// silent-CPU-fallback failure mode: a misclassified IOSurface F16
9297    /// destination keeps every output-correctness test green while
9298    /// quietly running ~10× slower on CPU; only a backend observable
9299    /// catches it. Skips ONLY when GL itself is unavailable or the
9300    /// configuration lacks F16 — a convert error or a CPU-routed convert
9301    /// with the capability present is a FAILURE.
9302    #[test]
9303    #[cfg(target_os = "macos")]
9304    #[cfg(feature = "opengl")]
9305    fn test_macos_gl_f16_planar_is_gl_backed() {
9306        let mut proc = ImageProcessor::new().expect("ImageProcessor");
9307        let Some(ref gl) = proc.opengl else {
9308            crate::test_support::report_skip(&format!("{} — GL backend unavailable", function!()));
9309            return;
9310        };
9311        if !gl.supported_render_dtypes().f16 {
9312            crate::test_support::report_skip(&format!(
9313                "{} — configuration lacks F16 color-buffer support",
9314                function!()
9315            ));
9316            return;
9317        }
9318        let stats_before = gl.egl_cache_stats().expect("cache stats");
9319
9320        let src = TensorDyn::image(
9321            1280,
9322            720,
9323            PixelFormat::Nv12,
9324            DType::U8,
9325            Some(TensorMemory::DmaBuf),
9326            edgefirst_tensor::CpuAccess::ReadWrite,
9327        )
9328        .unwrap();
9329        {
9330            let t = src.as_typed::<u8>().unwrap();
9331            let mut m = t.map().unwrap();
9332            for (i, b) in m.as_mut_slice().iter_mut().enumerate() {
9333                *b = ((i * 31) % 211) as u8;
9334            }
9335        }
9336        let mut dst = TensorDyn::image(
9337            640,
9338            640,
9339            PixelFormat::PlanarRgb,
9340            DType::F16,
9341            Some(TensorMemory::DmaBuf),
9342            edgefirst_tensor::CpuAccess::ReadWrite,
9343        )
9344        .unwrap();
9345
9346        proc.convert(
9347            &src,
9348            &mut dst,
9349            Rotation::None,
9350            Flip::None,
9351            Crop::letterbox([114, 114, 114, 255]),
9352        )
9353        .expect("F16 capability reported but the NV12→PlanarF16 convert failed");
9354        let stats_after = proc
9355            .opengl
9356            .as_ref()
9357            .expect("GL backend present")
9358            .egl_cache_stats()
9359            .expect("cache stats");
9360        // ≥ 2 misses: the fused convert imports the zero-copy NV source
9361        // (pass 1) AND the F16 destination (pass 2). Requiring both means a
9362        // convert that imported its source, failed mid-engine, and fell back
9363        // to CPU (1 miss) cannot satisfy this gate.
9364        assert!(
9365            stats_after.total_misses() >= stats_before.total_misses() + 2,
9366            "convert succeeded but the GL engine did not import both the \
9367             source and the F16 destination — the work did not (fully) run \
9368             on the GL backend (silent CPU fallback); misses before={} after={}",
9369            stats_before.total_misses(),
9370            stats_after.total_misses()
9371        );
9372    }
9373
9374    /// Portable oracle for the fused NV12→PlanarRgb-F16 engine convert
9375    /// (two GL passes: NV→RGBA intermediate, then the packed RGBA16F
9376    /// render). New on every platform with F16 render support — macOS
9377    /// IOSurface and Linux DMA-BUF alike. Compares against the CPU
9378    /// backend's reference within the float-path tolerance.
9379    #[test]
9380    #[cfg(feature = "opengl")]
9381    fn test_nv12_to_planar_f16_fused_engine_vs_cpu() {
9382        let mut gl = match ImageProcessor::with_config(ImageProcessorConfig {
9383            backend: ComputeBackend::OpenGl,
9384            ..Default::default()
9385        }) {
9386            Ok(p) if p.opengl.is_some() => p,
9387            _ => {
9388                crate::test_support::report_skip(&format!(
9389                    "{} — GL backend unavailable",
9390                    function!()
9391                ));
9392                return;
9393            }
9394        };
9395        if !gl
9396            .opengl
9397            .as_ref()
9398            .map(|g| g.supported_render_dtypes().f16)
9399            .unwrap_or(false)
9400        {
9401            crate::test_support::report_skip(&format!("{} — no F16 render support", function!()));
9402            return;
9403        }
9404        let mem = if edgefirst_tensor::is_gpu_buffer_available() {
9405            TensorMemory::DmaBuf
9406        } else {
9407            crate::test_support::report_skip(&format!("{} — no zero-copy buffers", function!()));
9408            return;
9409        };
9410
9411        let src = TensorDyn::image(
9412            1280,
9413            720,
9414            PixelFormat::Nv12,
9415            DType::U8,
9416            Some(mem),
9417            edgefirst_tensor::CpuAccess::ReadWrite,
9418        )
9419        .unwrap();
9420        {
9421            // Smooth gradients, NOT noise: the GL and CPU paths upsample
9422            // chroma with different kernels (nearest vs bilinear), which
9423            // legitimately diverges on per-texel chroma noise. Gradients
9424            // keep that kernel difference sub-LSB while still exercising
9425            // the full matrix math and the letterbox geometry.
9426            let t = src.as_typed::<u8>().unwrap();
9427            let mut m = t.map().unwrap();
9428            let buf = m.as_mut_slice();
9429            let (w, h) = (1280usize, 720usize);
9430            for y in 0..h {
9431                for x in 0..w {
9432                    buf[y * w + x] = ((x * 255) / w) as u8; // luma ramp
9433                }
9434            }
9435            for y in 0..(h / 2) {
9436                for x in 0..(w / 2) {
9437                    let o = h * w + y * w + 2 * x;
9438                    buf[o] = ((y * 255) / (h / 2)) as u8; // U vertical ramp
9439                    buf[o + 1] = (((x + y) * 255) / (w / 2 + h / 2)) as u8; // V diagonal
9440                }
9441            }
9442        }
9443        let crop = Crop::letterbox([114, 114, 114, 255]);
9444        let mut gl_dst = TensorDyn::image(
9445            640,
9446            640,
9447            PixelFormat::PlanarRgb,
9448            DType::F16,
9449            Some(mem),
9450            edgefirst_tensor::CpuAccess::ReadWrite,
9451        )
9452        .unwrap();
9453        // Drive the GL backend DIRECTLY: a convert through `ImageProcessor`
9454        // silently falls back to CPU on a GL error, turning this oracle into
9455        // a CPU-vs-CPU tautology. A direct call surfaces the engine error.
9456        // GPUs that allocate DMA-BUF but cannot complete an RGBA16F FBO
9457        // (desktop NVIDIA: GL_FRAMEBUFFER_INCOMPLETE_ATTACHMENT 0x8cd6)
9458        // take the production CPU fallback; this oracle cannot run there.
9459        match gl.opengl.as_mut().expect("GL backend present").convert(
9460            &src,
9461            &mut gl_dst,
9462            Rotation::None,
9463            Flip::None,
9464            crop,
9465        ) {
9466            Ok(()) => {}
9467            Err(crate::Error::NotSupported(msg)) => {
9468                crate::test_support::report_skip(&format!("{} — {msg}", function!()));
9469                return;
9470            }
9471            Err(e) => panic!("fused NV12→PlanarF16 GL convert: {e}"),
9472        }
9473
9474        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
9475            backend: ComputeBackend::Cpu,
9476            ..Default::default()
9477        })
9478        .unwrap();
9479        let mut cpu_dst = TensorDyn::image(
9480            640,
9481            640,
9482            PixelFormat::PlanarRgb,
9483            DType::F16,
9484            Some(TensorMemory::Mem),
9485            edgefirst_tensor::CpuAccess::ReadWrite,
9486        )
9487        .unwrap();
9488        cpu.convert(&src, &mut cpu_dst, Rotation::None, Flip::None, crop)
9489            .expect("CPU reference convert");
9490
9491        let g = gl_dst
9492            .as_typed::<half::f16>()
9493            .unwrap()
9494            .map()
9495            .unwrap()
9496            .as_slice()
9497            .to_vec();
9498        let c = cpu_dst
9499            .as_typed::<half::f16>()
9500            .unwrap()
9501            .map()
9502            .unwrap()
9503            .as_slice()
9504            .to_vec();
9505        assert_eq!(g.len(), c.len());
9506        let mut max_diff = 0.0f32;
9507        let mut max_at = 0usize;
9508        for (i, (a, b)) in g.iter().zip(c.iter()).enumerate() {
9509            let d = (a.to_f32() - b.to_f32()).abs();
9510            if d > max_diff {
9511                max_diff = d;
9512                max_at = i;
9513            }
9514        }
9515        // Localize: plane (R/G/B), row, col of the worst element.
9516        let (plane, rem) = (max_at / (640 * 640), max_at % (640 * 640));
9517        let (row, col) = (rem / 640, rem % 640);
9518        eprintln!(
9519            "fused-vs-cpu: max_diff={max_diff} at plane={plane} row={row} col={col} \
9520             gl={} cpu={}",
9521            g[max_at].to_f32(),
9522            c[max_at].to_f32()
9523        );
9524        // Two GPU passes (8-bit intermediate + linear filtering) vs the
9525        // CPU's direct path: allow a few 8-bit steps of divergence.
9526        assert!(
9527            max_diff <= 4.0 / 255.0 + 1e-3,
9528            "fused NV12→PlanarF16 diverges from CPU reference: max_diff={max_diff}"
9529        );
9530    }
9531
9532    /// Zero-copy source → heap destination through the GL engine,
9533    /// driven on the GL backend DIRECTLY so a GL error cannot hide
9534    /// behind the `ImageProcessor` CPU fallback (the shape that exposed
9535    /// the macOS `glReadnPixels` failure: imported source, rendered,
9536    /// then errored at the heap readback). RGBA→BGRA so the convert is
9537    /// a pure byte shuffle: no chroma kernel or colorimetry ambiguity
9538    /// (Vivante's NV fast path legitimately diverges from the CPU
9539    /// reference), and the readback stays GL_RGBA (V3D rejects RGB
9540    /// readbacks).
9541    #[test]
9542    #[cfg(feature = "opengl")]
9543    fn test_zero_copy_src_to_mem_dst_gl_direct() {
9544        let mut proc = match ImageProcessor::new() {
9545            Ok(p) if p.opengl.is_some() => p,
9546            _ => {
9547                crate::test_support::report_skip(&format!(
9548                    "{} — GL backend unavailable",
9549                    function!()
9550                ));
9551                return;
9552            }
9553        };
9554        if !edgefirst_tensor::is_gpu_buffer_available() {
9555            crate::test_support::report_skip(&format!("{} — no zero-copy buffers", function!()));
9556            return;
9557        }
9558
9559        let src = TensorDyn::image(
9560            1280,
9561            720,
9562            PixelFormat::Rgba,
9563            DType::U8,
9564            Some(TensorMemory::DmaBuf),
9565            edgefirst_tensor::CpuAccess::ReadWrite,
9566        )
9567        .unwrap();
9568        {
9569            let t = src.as_typed::<u8>().unwrap();
9570            let mut m = t.map().unwrap();
9571            for (i, b) in m.as_mut_slice().iter_mut().enumerate() {
9572                *b = ((i * 31) % 211) as u8;
9573            }
9574        }
9575        let mut gl_dst = TensorDyn::image(
9576            1280,
9577            720,
9578            PixelFormat::Bgra,
9579            DType::U8,
9580            Some(TensorMemory::Mem),
9581            edgefirst_tensor::CpuAccess::ReadWrite,
9582        )
9583        .unwrap();
9584        proc.opengl
9585            .as_mut()
9586            .expect("GL backend present")
9587            .convert(
9588                &src,
9589                &mut gl_dst,
9590                Rotation::None,
9591                Flip::None,
9592                Crop::no_crop(),
9593            )
9594            .expect("zero-copy src → heap dst GL convert");
9595
9596        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
9597            backend: ComputeBackend::Cpu,
9598            ..Default::default()
9599        })
9600        .unwrap();
9601        let mut cpu_dst = TensorDyn::image(
9602            1280,
9603            720,
9604            PixelFormat::Bgra,
9605            DType::U8,
9606            Some(TensorMemory::Mem),
9607            edgefirst_tensor::CpuAccess::ReadWrite,
9608        )
9609        .unwrap();
9610        cpu.convert(
9611            &src,
9612            &mut cpu_dst,
9613            Rotation::None,
9614            Flip::None,
9615            Crop::no_crop(),
9616        )
9617        .expect("CPU reference convert");
9618
9619        let g = gl_dst
9620            .as_typed::<u8>()
9621            .unwrap()
9622            .map()
9623            .unwrap()
9624            .as_slice()
9625            .to_vec();
9626        let c = cpu_dst
9627            .as_typed::<u8>()
9628            .unwrap()
9629            .map()
9630            .unwrap()
9631            .as_slice()
9632            .to_vec();
9633        assert_eq!(g.len(), c.len());
9634        let max_diff = g
9635            .iter()
9636            .zip(c.iter())
9637            .map(|(a, b)| a.abs_diff(*b))
9638            .max()
9639            .unwrap();
9640        // Same-size byte shuffle: GL samples texel centers 1:1, so allow
9641        // only rounding slack.
9642        assert!(
9643            max_diff <= 2,
9644            "zero-copy src → heap dst diverges from CPU reference: max_diff={max_diff}"
9645        );
9646    }
9647
9648    #[test]
9649    #[cfg(target_os = "linux")]
9650    fn test_yuyv_to_rgb_g2d() {
9651        if !is_g2d_available() {
9652            crate::test_support::report_skip(
9653                "test_yuyv_to_rgb_g2d - G2D library (libg2d.so.2) not available",
9654            );
9655            return;
9656        }
9657        if !is_dma_available() {
9658            crate::test_support::report_skip("test_yuyv_to_rgb_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
9659            return;
9660        }
9661
9662        let src = load_bytes_to_tensor(
9663            1280,
9664            720,
9665            PixelFormat::Yuyv,
9666            None,
9667            &edgefirst_bench::testdata::read("camera720p.yuyv"),
9668        )
9669        .unwrap();
9670
9671        let g2d_dst = TensorDyn::image(
9672            1280,
9673            720,
9674            PixelFormat::Rgb,
9675            DType::U8,
9676            Some(TensorMemory::DmaBuf),
9677            edgefirst_tensor::CpuAccess::ReadWrite,
9678        )
9679        .unwrap();
9680        let mut g2d_converter = G2DProcessor::new().unwrap();
9681
9682        let (result, src, g2d_dst) = convert_img(
9683            &mut g2d_converter,
9684            src,
9685            g2d_dst,
9686            Rotation::None,
9687            Flip::None,
9688            Crop::no_crop(),
9689        );
9690        result.unwrap();
9691
9692        let cpu_dst = TensorDyn::image(
9693            1280,
9694            720,
9695            PixelFormat::Rgb,
9696            DType::U8,
9697            None,
9698            edgefirst_tensor::CpuAccess::ReadWrite,
9699        )
9700        .unwrap();
9701        let mut cpu_converter: CPUProcessor = CPUProcessor::new();
9702
9703        let (result, _src, cpu_dst) = convert_img(
9704            &mut cpu_converter,
9705            src,
9706            cpu_dst,
9707            Rotation::None,
9708            Flip::None,
9709            Crop::no_crop(),
9710        );
9711        result.unwrap();
9712
9713        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
9714        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
9715        // the YUV-matrix delta that forced 0.95 has closed; tightened to
9716        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
9717        // structural gap not exercised by these limited-range fixtures.
9718        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
9719    }
9720
9721    #[test]
9722    #[cfg(target_os = "linux")]
9723    fn test_yuyv_to_yuyv_resize_g2d() {
9724        if !is_g2d_available() {
9725            crate::test_support::report_skip(
9726                "test_yuyv_to_yuyv_resize_g2d - G2D library (libg2d.so.2) not available",
9727            );
9728            return;
9729        }
9730        if !is_dma_available() {
9731            crate::test_support::report_skip("test_yuyv_to_yuyv_resize_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
9732            return;
9733        }
9734
9735        let src = load_bytes_to_tensor(
9736            1280,
9737            720,
9738            PixelFormat::Yuyv,
9739            None,
9740            &edgefirst_bench::testdata::read("camera720p.yuyv"),
9741        )
9742        .unwrap();
9743
9744        let g2d_dst = TensorDyn::image(
9745            600,
9746            400,
9747            PixelFormat::Yuyv,
9748            DType::U8,
9749            Some(TensorMemory::DmaBuf),
9750            edgefirst_tensor::CpuAccess::ReadWrite,
9751        )
9752        .unwrap();
9753        let mut g2d_converter = G2DProcessor::new().unwrap();
9754
9755        let (result, src, g2d_dst) = convert_img(
9756            &mut g2d_converter,
9757            src,
9758            g2d_dst,
9759            Rotation::None,
9760            Flip::None,
9761            Crop::no_crop(),
9762        );
9763        result.unwrap();
9764
9765        let cpu_dst = TensorDyn::image(
9766            600,
9767            400,
9768            PixelFormat::Yuyv,
9769            DType::U8,
9770            None,
9771            edgefirst_tensor::CpuAccess::ReadWrite,
9772        )
9773        .unwrap();
9774        let mut cpu_converter: CPUProcessor = CPUProcessor::new();
9775
9776        let (result, _src, cpu_dst) = convert_img(
9777            &mut cpu_converter,
9778            src,
9779            cpu_dst,
9780            Rotation::None,
9781            Flip::None,
9782            Crop::no_crop(),
9783        );
9784        result.unwrap();
9785
9786        // G2D has poor colorimetry support: its hardware YUYV resize/sampling
9787        // diverges from the CPU reference by enough that the similarity score
9788        // sits around 0.85 on every measured G2D core (i.MX 8M Plus, i.MX 95).
9789        // The threshold is held at 0.85 so the test guards against gross
9790        // regressions while tolerating the driver's inherent colorimetry error.
9791        // TODO: compare YUYV↔YUYV directly without a YUYV→RGB convert.
9792        eprintln!(
9793            "WARNING: G2D has poor colorimetry support — YUYV resize diverges from the \
9794             CPU reference (~0.85 similarity); threshold held at 0.85, not 0.95."
9795        );
9796        compare_images_convert_to_rgb(&g2d_dst, &cpu_dst, 0.85, function!());
9797    }
9798
9799    #[test]
9800    fn test_yuyv_to_rgba_resize_cpu() {
9801        let src = load_bytes_to_tensor(
9802            1280,
9803            720,
9804            PixelFormat::Yuyv,
9805            None,
9806            &edgefirst_bench::testdata::read("camera720p.yuyv"),
9807        )
9808        .unwrap();
9809
9810        let (dst_width, dst_height) = (960, 540);
9811
9812        let dst = TensorDyn::image(
9813            dst_width,
9814            dst_height,
9815            PixelFormat::Rgba,
9816            DType::U8,
9817            None,
9818            edgefirst_tensor::CpuAccess::ReadWrite,
9819        )
9820        .unwrap();
9821        let mut cpu_converter = CPUProcessor::new();
9822
9823        let (result, _src, dst) = convert_img(
9824            &mut cpu_converter,
9825            src,
9826            dst,
9827            Rotation::None,
9828            Flip::None,
9829            Crop::no_crop(),
9830        );
9831        result.unwrap();
9832
9833        let dst_target = TensorDyn::image(
9834            dst_width,
9835            dst_height,
9836            PixelFormat::Rgba,
9837            DType::U8,
9838            None,
9839            edgefirst_tensor::CpuAccess::ReadWrite,
9840        )
9841        .unwrap();
9842        let src_target = load_bytes_to_tensor(
9843            1280,
9844            720,
9845            PixelFormat::Rgba,
9846            None,
9847            &edgefirst_bench::testdata::read("camera720p.rgba"),
9848        )
9849        .unwrap();
9850        let (result, _src_target, dst_target) = convert_img(
9851            &mut cpu_converter,
9852            src_target,
9853            dst_target,
9854            Rotation::None,
9855            Flip::None,
9856            Crop::no_crop(),
9857        );
9858        result.unwrap();
9859
9860        // CPU path resolves the untagged 720p source to BT.709 limited (height
9861        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
9862        compare_images(&dst, &dst_target, 0.98, function!());
9863    }
9864
9865    #[test]
9866    #[cfg(target_os = "linux")]
9867    fn test_yuyv_to_rgba_crop_flip_g2d() {
9868        if !is_g2d_available() {
9869            crate::test_support::report_skip(
9870                "test_yuyv_to_rgba_crop_flip_g2d - G2D library (libg2d.so.2) not available",
9871            );
9872            return;
9873        }
9874        if !is_dma_available() {
9875            crate::test_support::report_skip("test_yuyv_to_rgba_crop_flip_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
9876            return;
9877        }
9878
9879        let src = load_bytes_to_tensor(
9880            1280,
9881            720,
9882            PixelFormat::Yuyv,
9883            Some(TensorMemory::DmaBuf),
9884            &edgefirst_bench::testdata::read("camera720p.yuyv"),
9885        )
9886        .unwrap();
9887
9888        let (dst_width, dst_height) = (640, 640);
9889
9890        let dst_g2d = TensorDyn::image(
9891            dst_width,
9892            dst_height,
9893            PixelFormat::Rgba,
9894            DType::U8,
9895            Some(TensorMemory::DmaBuf),
9896            edgefirst_tensor::CpuAccess::ReadWrite,
9897        )
9898        .unwrap();
9899        let mut g2d_converter = G2DProcessor::new().unwrap();
9900        let crop = Crop::new().with_source(Some(Region::new(20, 15, 400, 300)));
9901
9902        let (result, src, dst_g2d) = convert_img(
9903            &mut g2d_converter,
9904            src,
9905            dst_g2d,
9906            Rotation::None,
9907            Flip::Horizontal,
9908            crop,
9909        );
9910        result.unwrap();
9911
9912        let dst_cpu = TensorDyn::image(
9913            dst_width,
9914            dst_height,
9915            PixelFormat::Rgba,
9916            DType::U8,
9917            Some(TensorMemory::DmaBuf),
9918            edgefirst_tensor::CpuAccess::ReadWrite,
9919        )
9920        .unwrap();
9921        let mut cpu_converter = CPUProcessor::new();
9922
9923        let (result, _src, dst_cpu) = convert_img(
9924            &mut cpu_converter,
9925            src,
9926            dst_cpu,
9927            Rotation::None,
9928            Flip::Horizontal,
9929            crop,
9930        );
9931        result.unwrap();
9932        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
9933        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
9934        // the YUV-matrix delta that forced 0.95 has closed; tightened to
9935        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
9936        // structural gap not exercised by these limited-range fixtures.
9937        compare_images(&dst_g2d, &dst_cpu, 0.98, function!());
9938    }
9939
9940    #[test]
9941    #[cfg(any(
9942        target_os = "linux",
9943        target_os = "macos",
9944        target_os = "ios",
9945        target_os = "android",
9946        target_os = "windows"
9947    ))]
9948    #[cfg(feature = "opengl")]
9949    fn test_yuyv_to_rgba_crop_flip_opengl() {
9950        if !is_opengl_available() {
9951            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
9952            return;
9953        }
9954
9955        if !edgefirst_tensor::is_gpu_buffer_available() {
9956            crate::test_support::report_skip(&format!(
9957                "{} - zero-copy GPU buffer (DMA-BUF / IOSurface) not available on this host",
9958                function!()
9959            ));
9960            return;
9961        }
9962
9963        let src = load_bytes_to_tensor(
9964            1280,
9965            720,
9966            PixelFormat::Yuyv,
9967            Some(TensorMemory::DmaBuf),
9968            &edgefirst_bench::testdata::read("camera720p.yuyv"),
9969        )
9970        .unwrap();
9971
9972        let (dst_width, dst_height) = (640, 640);
9973
9974        let dst_gl = TensorDyn::image(
9975            dst_width,
9976            dst_height,
9977            PixelFormat::Rgba,
9978            DType::U8,
9979            Some(TensorMemory::DmaBuf),
9980            edgefirst_tensor::CpuAccess::ReadWrite,
9981        )
9982        .unwrap();
9983        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
9984        let crop = Crop::new().with_source(Some(Region::new(20, 15, 400, 300)));
9985
9986        let (result, src, dst_gl) = convert_img(
9987            &mut gl_converter,
9988            src,
9989            dst_gl,
9990            Rotation::None,
9991            Flip::Horizontal,
9992            crop,
9993        );
9994        // Desktop NVIDIA cannot sample YUYV as a GL texture; i.MX/Vivante can.
9995        if let Err(crate::Error::NotSupported(msg)) = &result {
9996            crate::test_support::report_skip(&format!("{} — {msg}", function!()));
9997            return;
9998        }
9999        result.unwrap();
10000
10001        let dst_cpu = TensorDyn::image(
10002            dst_width,
10003            dst_height,
10004            PixelFormat::Rgba,
10005            DType::U8,
10006            Some(TensorMemory::DmaBuf),
10007            edgefirst_tensor::CpuAccess::ReadWrite,
10008        )
10009        .unwrap();
10010        let mut cpu_converter = CPUProcessor::new();
10011
10012        let (result, _src, dst_cpu) = convert_img(
10013            &mut cpu_converter,
10014            src,
10015            dst_cpu,
10016            Rotation::None,
10017            Flip::Horizontal,
10018            crop,
10019        );
10020        result.unwrap();
10021        // Post-WS1 the GL path applies the resolved colorimetry via the EGL
10022        // YUV color-space/sample-range hints, so the matrix delta that forced
10023        // 0.95 has closed; tightened to 0.98 (driver-matrix rounding confirmed
10024        // on the GPU lanes).
10025        compare_images(&dst_gl, &dst_cpu, 0.98, function!());
10026    }
10027
10028    #[test]
10029    fn test_vyuy_to_rgba_cpu() {
10030        let file = edgefirst_bench::testdata::read("camera720p.vyuy").to_vec();
10031        let src = TensorDyn::image(
10032            1280,
10033            720,
10034            PixelFormat::Vyuy,
10035            DType::U8,
10036            None,
10037            edgefirst_tensor::CpuAccess::ReadWrite,
10038        )
10039        .unwrap();
10040        src.as_typed::<u8>()
10041            .unwrap()
10042            .map()
10043            .unwrap()
10044            .as_mut_slice()
10045            .copy_from_slice(&file);
10046
10047        let dst = TensorDyn::image(
10048            1280,
10049            720,
10050            PixelFormat::Rgba,
10051            DType::U8,
10052            None,
10053            edgefirst_tensor::CpuAccess::ReadWrite,
10054        )
10055        .unwrap();
10056        let mut cpu_converter = CPUProcessor::new();
10057
10058        let (result, _src, dst) = convert_img(
10059            &mut cpu_converter,
10060            src,
10061            dst,
10062            Rotation::None,
10063            Flip::None,
10064            Crop::no_crop(),
10065        );
10066        result.unwrap();
10067
10068        let target_image = TensorDyn::image(
10069            1280,
10070            720,
10071            PixelFormat::Rgba,
10072            DType::U8,
10073            None,
10074            edgefirst_tensor::CpuAccess::ReadWrite,
10075        )
10076        .unwrap();
10077        target_image
10078            .as_typed::<u8>()
10079            .unwrap()
10080            .map()
10081            .unwrap()
10082            .as_mut_slice()
10083            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
10084
10085        // CPU path resolves the untagged 720p source to BT.709 limited (height
10086        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
10087        compare_images(&dst, &target_image, 0.98, function!());
10088    }
10089
10090    #[test]
10091    fn test_vyuy_to_rgb_cpu() {
10092        let file = edgefirst_bench::testdata::read("camera720p.vyuy").to_vec();
10093        let src = TensorDyn::image(
10094            1280,
10095            720,
10096            PixelFormat::Vyuy,
10097            DType::U8,
10098            None,
10099            edgefirst_tensor::CpuAccess::ReadWrite,
10100        )
10101        .unwrap();
10102        src.as_typed::<u8>()
10103            .unwrap()
10104            .map()
10105            .unwrap()
10106            .as_mut_slice()
10107            .copy_from_slice(&file);
10108
10109        let dst = TensorDyn::image(
10110            1280,
10111            720,
10112            PixelFormat::Rgb,
10113            DType::U8,
10114            None,
10115            edgefirst_tensor::CpuAccess::ReadWrite,
10116        )
10117        .unwrap();
10118        let mut cpu_converter = CPUProcessor::new();
10119
10120        let (result, _src, dst) = convert_img(
10121            &mut cpu_converter,
10122            src,
10123            dst,
10124            Rotation::None,
10125            Flip::None,
10126            Crop::no_crop(),
10127        );
10128        result.unwrap();
10129
10130        let target_image = TensorDyn::image(
10131            1280,
10132            720,
10133            PixelFormat::Rgb,
10134            DType::U8,
10135            None,
10136            edgefirst_tensor::CpuAccess::ReadWrite,
10137        )
10138        .unwrap();
10139        target_image
10140            .as_typed::<u8>()
10141            .unwrap()
10142            .map()
10143            .unwrap()
10144            .as_mut_slice()
10145            .as_chunks_mut::<3>()
10146            .0
10147            .iter_mut()
10148            .zip(
10149                edgefirst_bench::testdata::read("camera720p.rgba")
10150                    .as_chunks::<4>()
10151                    .0,
10152            )
10153            .for_each(|(dst, src)| *dst = [src[0], src[1], src[2]]);
10154
10155        // CPU path resolves the untagged 720p source to BT.709 limited (height
10156        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
10157        compare_images(&dst, &target_image, 0.98, function!());
10158    }
10159
10160    #[test]
10161    #[cfg(target_os = "linux")]
10162    #[ignore = "G2D does not support VYUY; re-enable when hardware support is added"]
10163    fn test_vyuy_to_rgba_g2d() {
10164        if !is_g2d_available() {
10165            crate::test_support::report_skip(
10166                "test_vyuy_to_rgba_g2d - G2D library (libg2d.so.2) not available",
10167            );
10168            return;
10169        }
10170        if !is_dma_available() {
10171            crate::test_support::report_skip("test_vyuy_to_rgba_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
10172            return;
10173        }
10174
10175        let src = load_bytes_to_tensor(
10176            1280,
10177            720,
10178            PixelFormat::Vyuy,
10179            None,
10180            &edgefirst_bench::testdata::read("camera720p.vyuy"),
10181        )
10182        .unwrap();
10183
10184        let dst = TensorDyn::image(
10185            1280,
10186            720,
10187            PixelFormat::Rgba,
10188            DType::U8,
10189            Some(TensorMemory::DmaBuf),
10190            edgefirst_tensor::CpuAccess::ReadWrite,
10191        )
10192        .unwrap();
10193        let mut g2d_converter = G2DProcessor::new().unwrap();
10194
10195        let (result, _src, dst) = convert_img(
10196            &mut g2d_converter,
10197            src,
10198            dst,
10199            Rotation::None,
10200            Flip::None,
10201            Crop::no_crop(),
10202        );
10203        match result {
10204            Err(Error::G2D(_)) => {
10205                crate::test_support::report_skip(
10206                    "test_vyuy_to_rgba_g2d - G2D does not support PixelFormat::Vyuy format",
10207                );
10208                return;
10209            }
10210            r => r.unwrap(),
10211        }
10212
10213        let target_image = TensorDyn::image(
10214            1280,
10215            720,
10216            PixelFormat::Rgba,
10217            DType::U8,
10218            None,
10219            edgefirst_tensor::CpuAccess::ReadWrite,
10220        )
10221        .unwrap();
10222        target_image
10223            .as_typed::<u8>()
10224            .unwrap()
10225            .map()
10226            .unwrap()
10227            .as_mut_slice()
10228            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
10229
10230        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
10231        // so the matrix delta vs the reference that forced 0.95 has closed;
10232        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
10233        compare_images(&dst, &target_image, 0.98, function!());
10234    }
10235
10236    #[test]
10237    #[cfg(target_os = "linux")]
10238    #[ignore = "G2D does not support VYUY; re-enable when hardware support is added"]
10239    fn test_vyuy_to_rgb_g2d() {
10240        if !is_g2d_available() {
10241            crate::test_support::report_skip(
10242                "test_vyuy_to_rgb_g2d - G2D library (libg2d.so.2) not available",
10243            );
10244            return;
10245        }
10246        if !is_dma_available() {
10247            crate::test_support::report_skip("test_vyuy_to_rgb_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
10248            return;
10249        }
10250
10251        let src = load_bytes_to_tensor(
10252            1280,
10253            720,
10254            PixelFormat::Vyuy,
10255            None,
10256            &edgefirst_bench::testdata::read("camera720p.vyuy"),
10257        )
10258        .unwrap();
10259
10260        let g2d_dst = TensorDyn::image(
10261            1280,
10262            720,
10263            PixelFormat::Rgb,
10264            DType::U8,
10265            Some(TensorMemory::DmaBuf),
10266            edgefirst_tensor::CpuAccess::ReadWrite,
10267        )
10268        .unwrap();
10269        let mut g2d_converter = G2DProcessor::new().unwrap();
10270
10271        let (result, src, g2d_dst) = convert_img(
10272            &mut g2d_converter,
10273            src,
10274            g2d_dst,
10275            Rotation::None,
10276            Flip::None,
10277            Crop::no_crop(),
10278        );
10279        match result {
10280            Err(Error::G2D(_)) => {
10281                crate::test_support::report_skip(
10282                    "test_vyuy_to_rgb_g2d - G2D does not support PixelFormat::Vyuy format",
10283                );
10284                return;
10285            }
10286            r => r.unwrap(),
10287        }
10288
10289        let cpu_dst = TensorDyn::image(
10290            1280,
10291            720,
10292            PixelFormat::Rgb,
10293            DType::U8,
10294            None,
10295            edgefirst_tensor::CpuAccess::ReadWrite,
10296        )
10297        .unwrap();
10298        let mut cpu_converter: CPUProcessor = CPUProcessor::new();
10299
10300        let (result, _src, cpu_dst) = convert_img(
10301            &mut cpu_converter,
10302            src,
10303            cpu_dst,
10304            Rotation::None,
10305            Flip::None,
10306            Crop::no_crop(),
10307        );
10308        result.unwrap();
10309
10310        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
10311        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
10312        // the YUV-matrix delta that forced 0.95 has closed; tightened to
10313        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
10314        // structural gap not exercised by these limited-range fixtures.
10315        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
10316    }
10317
10318    #[test]
10319    #[cfg(any(
10320        target_os = "linux",
10321        target_os = "macos",
10322        target_os = "ios",
10323        target_os = "android",
10324        target_os = "windows"
10325    ))]
10326    #[cfg(feature = "opengl")]
10327    fn test_vyuy_to_rgba_opengl() {
10328        if !is_opengl_available() {
10329            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
10330            return;
10331        }
10332        if !edgefirst_tensor::is_gpu_buffer_available() {
10333            crate::test_support::report_skip(&format!(
10334                "{} - zero-copy GPU buffer (DMA-BUF / IOSurface) not available on this host",
10335                function!()
10336            ));
10337            return;
10338        }
10339
10340        // VYUY has a DMA-BUF FourCC on Linux but no IOSurface mapping on
10341        // macOS/iOS, so the zero-copy allocation itself is the per-format
10342        // probe: a platform that declines it is a skip, not a failure.
10343        let src = match load_bytes_to_tensor(
10344            1280,
10345            720,
10346            PixelFormat::Vyuy,
10347            Some(TensorMemory::DmaBuf),
10348            &edgefirst_bench::testdata::read("camera720p.vyuy"),
10349        ) {
10350            Ok(src) => src,
10351            Err(e) => {
10352                crate::test_support::report_skip(&format!(
10353                    "{} - zero-copy VYUY source not allocatable on this platform ({e})",
10354                    function!()
10355                ));
10356                return;
10357            }
10358        };
10359
10360        let dst = TensorDyn::image(
10361            1280,
10362            720,
10363            PixelFormat::Rgba,
10364            DType::U8,
10365            Some(TensorMemory::DmaBuf),
10366            edgefirst_tensor::CpuAccess::ReadWrite,
10367        )
10368        .unwrap();
10369        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
10370
10371        let (result, _src, dst) = convert_img(
10372            &mut gl_converter,
10373            src,
10374            dst,
10375            Rotation::None,
10376            Flip::None,
10377            Crop::no_crop(),
10378        );
10379        match result {
10380            Err(Error::NotSupported(_)) => {
10381                crate::test_support::report_skip(&format!(
10382                    "{} - OpenGL does not support PixelFormat::Vyuy DMA format",
10383                    function!()
10384                ));
10385                return;
10386            }
10387            r => r.unwrap(),
10388        }
10389
10390        let target_image = TensorDyn::image(
10391            1280,
10392            720,
10393            PixelFormat::Rgba,
10394            DType::U8,
10395            None,
10396            edgefirst_tensor::CpuAccess::ReadWrite,
10397        )
10398        .unwrap();
10399        target_image
10400            .as_typed::<u8>()
10401            .unwrap()
10402            .map()
10403            .unwrap()
10404            .as_mut_slice()
10405            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
10406
10407        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
10408        // so the matrix delta vs the reference that forced 0.95 has closed;
10409        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
10410        compare_images(&dst, &target_image, 0.98, function!());
10411    }
10412
10413    #[test]
10414    fn test_nv12_to_rgba_cpu() {
10415        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
10416        let src = TensorDyn::image(
10417            1280,
10418            720,
10419            PixelFormat::Nv12,
10420            DType::U8,
10421            None,
10422            edgefirst_tensor::CpuAccess::ReadWrite,
10423        )
10424        .unwrap();
10425        src.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
10426            .copy_from_slice(&file);
10427
10428        let dst = TensorDyn::image(
10429            1280,
10430            720,
10431            PixelFormat::Rgba,
10432            DType::U8,
10433            None,
10434            edgefirst_tensor::CpuAccess::ReadWrite,
10435        )
10436        .unwrap();
10437        let mut cpu_converter = CPUProcessor::new();
10438
10439        let (result, _src, dst) = convert_img(
10440            &mut cpu_converter,
10441            src,
10442            dst,
10443            Rotation::None,
10444            Flip::None,
10445            Crop::no_crop(),
10446        );
10447        result.unwrap();
10448
10449        let target_image = crate::load_image_test_helper(
10450            &edgefirst_bench::testdata::read("zidane.jpg"),
10451            Some(PixelFormat::Rgba),
10452            None,
10453        )
10454        .unwrap();
10455
10456        // Threshold 0.95 (was 0.98): the reference now decodes the colour JPEG
10457        // to native NV12 and then converts to RGBA (was a direct JPEG → RGBA
10458        // decode), so it differs slightly from the RGBA derived from the
10459        // separate `zidane.nv12` fixture.
10460        compare_images(&dst, &target_image, 0.95, function!());
10461    }
10462
10463    #[test]
10464    fn test_nv12_odd_height_to_rgb_cpu() {
10465        // Odd height (even width) — the logical-odd case, e.g. 640×483. The
10466        // contiguous NV12 buffer is `[5 + ceil(5/2), 8]` = `[8, 8]` (5 luma rows
10467        // + 3 chroma rows). A neutral-grey fill (Y=U=V=128, BT.601 full-range)
10468        // must convert to a uniform grey RGB, exercising the odd-height
10469        // chroma-row count and the logical-height derivation in convert.
10470        // (Odd *width* is rounded to an even buffer at allocation, so it is
10471        // covered by the decode integration tests rather than here.)
10472        // CPU-only test: pin to tight host memory (None auto-selects pitch-padded
10473        // DMA on i.MX, which would leave the dst's row padding unconverted and
10474        // break the flat byte scan below).
10475        let mut src = TensorDyn::image(
10476            8,
10477            5,
10478            PixelFormat::Nv12,
10479            DType::U8,
10480            Some(TensorMemory::Mem),
10481            edgefirst_tensor::CpuAccess::ReadWrite,
10482        )
10483        .unwrap();
10484        assert_eq!(src.shape(), &[8, 8]);
10485        assert_eq!((src.width(), src.height()), (Some(8), Some(5)));
10486        src.as_typed::<u8>()
10487            .unwrap()
10488            .map()
10489            .unwrap()
10490            .as_mut_slice()
10491            .fill(128);
10492        // Tag BT.601 full-range so Y=128 decodes to grey 128 (the neutral-grey
10493        // identity this test asserts). Without a tag, the colorimetry heuristic
10494        // resolves an SD tensor to BT.601 *limited*, expanding Y=128 → ~131.
10495        src.set_colorimetry(Some(
10496            edgefirst_tensor::Colorimetry::default()
10497                .with_encoding(edgefirst_tensor::ColorEncoding::Bt601)
10498                .with_range(edgefirst_tensor::ColorRange::Full),
10499        ));
10500
10501        let dst = TensorDyn::image(
10502            8,
10503            5,
10504            PixelFormat::Rgb,
10505            DType::U8,
10506            Some(TensorMemory::Mem),
10507            edgefirst_tensor::CpuAccess::ReadWrite,
10508        )
10509        .unwrap();
10510        let mut cpu_converter = CPUProcessor::new();
10511        let (result, _src, dst) = convert_img(
10512            &mut cpu_converter,
10513            src,
10514            dst,
10515            Rotation::None,
10516            Flip::None,
10517            Crop::no_crop(),
10518        );
10519        result.unwrap();
10520
10521        assert_eq!((dst.width(), dst.height()), (Some(8), Some(5)));
10522        let map = dst.as_typed::<u8>().unwrap().map().unwrap();
10523        for (i, &b) in map.as_slice().iter().enumerate() {
10524            assert!(
10525                (b as i16 - 128).abs() <= 2,
10526                "pixel byte {i} = {b}, expected ~128 for neutral-grey NV12"
10527            );
10528        }
10529    }
10530
10531    #[test]
10532    fn test_nv24_to_rgb_cpu() {
10533        // NV24 (4:4:4) at 8×4: contiguous buffer is [4*3, 8] = [12, 8] — Y plane
10534        // (4 rows) + full-res interleaved UV plane (8 rows = 2H, 2W bytes per
10535        // chroma row). Neutral-grey fill (Y=U=V=128) must convert to uniform
10536        // grey RGB, exercising the 2× UV stride and shape[0]/3 height recovery.
10537        // CPU-only test: pin to tight host memory (see test_nv12_odd_height_to_rgb_cpu).
10538        let mut src = TensorDyn::image(
10539            8,
10540            4,
10541            PixelFormat::Nv24,
10542            DType::U8,
10543            Some(TensorMemory::Mem),
10544            edgefirst_tensor::CpuAccess::ReadWrite,
10545        )
10546        .unwrap();
10547        assert_eq!(src.shape(), &[12, 8]);
10548        assert_eq!((src.width(), src.height()), (Some(8), Some(4)));
10549        src.as_typed::<u8>()
10550            .unwrap()
10551            .map()
10552            .unwrap()
10553            .as_mut_slice()
10554            .fill(128);
10555        // Tag BT.601 full-range (see test_nv12_odd_height_to_rgb_cpu): without it
10556        // the heuristic picks limited range and Y=128 expands to ~131.
10557        src.set_colorimetry(Some(
10558            edgefirst_tensor::Colorimetry::default()
10559                .with_encoding(edgefirst_tensor::ColorEncoding::Bt601)
10560                .with_range(edgefirst_tensor::ColorRange::Full),
10561        ));
10562
10563        let dst = TensorDyn::image(
10564            8,
10565            4,
10566            PixelFormat::Rgb,
10567            DType::U8,
10568            Some(TensorMemory::Mem),
10569            edgefirst_tensor::CpuAccess::ReadWrite,
10570        )
10571        .unwrap();
10572        let mut cpu_converter = CPUProcessor::new();
10573        let (result, _src, dst) = convert_img(
10574            &mut cpu_converter,
10575            src,
10576            dst,
10577            Rotation::None,
10578            Flip::None,
10579            Crop::no_crop(),
10580        );
10581        result.unwrap();
10582
10583        assert_eq!((dst.width(), dst.height()), (Some(8), Some(4)));
10584        let map = dst.as_typed::<u8>().unwrap().map().unwrap();
10585        for (i, &b) in map.as_slice().iter().enumerate() {
10586            assert!(
10587                (b as i16 - 128).abs() <= 2,
10588                "pixel byte {i} = {b}, expected ~128 for neutral-grey NV24"
10589            );
10590        }
10591    }
10592
10593    #[test]
10594    fn cpu_nv12_to_rgb_respects_tagged_bt2020() {
10595        // A uniform but *saturated* chroma sample (U/V far from neutral) so the
10596        // YUV→RGB matrix — not just the range — drives the result. Decoding the
10597        // same NV12 bytes under BT.601 / BT.709 / BT.2020 must yield three
10598        // distinct RGB triples, proving the CPU path honours the source's tagged
10599        // ColorEncoding instead of a hardcoded matrix. (G2D declines BT.2020 and
10600        // falls through to this CPU path; QA F9.)
10601        // CPU-only test: pin to tight host memory (see test_nv12_odd_height_to_rgb_cpu).
10602        fn decode_tagged(enc: edgefirst_tensor::ColorEncoding) -> [u8; 3] {
10603            let mut src = TensorDyn::image(
10604                8,
10605                4,
10606                PixelFormat::Nv12,
10607                DType::U8,
10608                Some(TensorMemory::Mem),
10609                edgefirst_tensor::CpuAccess::ReadWrite,
10610            )
10611            .unwrap();
10612            // NV12 8×4: 32-byte Y plane + 16-byte interleaved UV plane (4:2:0).
10613            assert_eq!(src.shape(), &[6, 8]);
10614            {
10615                let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
10616                let buf = map.as_mut_slice();
10617                buf[..32].fill(120); // Y
10618                for px in buf[32..].as_chunks_mut::<2>().0 {
10619                    px[0] = 180; // U / Cb
10620                    px[1] = 64; // V / Cr
10621                }
10622            }
10623            // Range held constant (Limited) across all three so only the encoding
10624            // matrix varies between runs.
10625            src.set_colorimetry(Some(
10626                edgefirst_tensor::Colorimetry::default()
10627                    .with_encoding(enc)
10628                    .with_range(edgefirst_tensor::ColorRange::Limited),
10629            ));
10630            let dst = TensorDyn::image(
10631                8,
10632                4,
10633                PixelFormat::Rgb,
10634                DType::U8,
10635                Some(TensorMemory::Mem),
10636                edgefirst_tensor::CpuAccess::ReadWrite,
10637            )
10638            .unwrap();
10639            let mut cpu = CPUProcessor::new();
10640            let (result, _src, dst) = convert_img(
10641                &mut cpu,
10642                src,
10643                dst,
10644                Rotation::None,
10645                Flip::None,
10646                Crop::no_crop(),
10647            );
10648            result.unwrap();
10649            let map = dst.as_typed::<u8>().unwrap().map().unwrap();
10650            let s = map.as_slice();
10651            [s[0], s[1], s[2]]
10652        }
10653
10654        let bt601 = decode_tagged(edgefirst_tensor::ColorEncoding::Bt601);
10655        let bt709 = decode_tagged(edgefirst_tensor::ColorEncoding::Bt709);
10656        let bt2020 = decode_tagged(edgefirst_tensor::ColorEncoding::Bt2020);
10657
10658        assert_ne!(
10659            bt2020, bt601,
10660            "BT.2020 must decode differently from BT.601 ({bt2020:?} vs {bt601:?})"
10661        );
10662        assert_ne!(
10663            bt2020, bt709,
10664            "BT.2020 must decode differently from BT.709 ({bt2020:?} vs {bt709:?})"
10665        );
10666        assert_ne!(
10667            bt601, bt709,
10668            "BT.601 must decode differently from BT.709 ({bt601:?} vs {bt709:?})"
10669        );
10670    }
10671
10672    #[test]
10673    fn test_nv12_to_rgb_cpu() {
10674        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
10675        let src = TensorDyn::image(
10676            1280,
10677            720,
10678            PixelFormat::Nv12,
10679            DType::U8,
10680            None,
10681            edgefirst_tensor::CpuAccess::ReadWrite,
10682        )
10683        .unwrap();
10684        src.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
10685            .copy_from_slice(&file);
10686
10687        let dst = TensorDyn::image(
10688            1280,
10689            720,
10690            PixelFormat::Rgb,
10691            DType::U8,
10692            None,
10693            edgefirst_tensor::CpuAccess::ReadWrite,
10694        )
10695        .unwrap();
10696        let mut cpu_converter = CPUProcessor::new();
10697
10698        let (result, _src, dst) = convert_img(
10699            &mut cpu_converter,
10700            src,
10701            dst,
10702            Rotation::None,
10703            Flip::None,
10704            Crop::no_crop(),
10705        );
10706        result.unwrap();
10707
10708        let target_image = crate::load_image_test_helper(
10709            &edgefirst_bench::testdata::read("zidane.jpg"),
10710            Some(PixelFormat::Rgb),
10711            None,
10712        )
10713        .unwrap();
10714
10715        // Threshold 0.95 (was 0.98): the reference now decodes the colour JPEG
10716        // to native NV12 and then converts to RGB (was a direct JPEG → RGB
10717        // decode), so it differs slightly from the RGB derived from the
10718        // separate `zidane.nv12` fixture.
10719        compare_images(&dst, &target_image, 0.95, function!());
10720    }
10721
10722    #[test]
10723    fn test_nv12_to_grey_cpu() {
10724        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
10725        let src = TensorDyn::image(
10726            1280,
10727            720,
10728            PixelFormat::Nv12,
10729            DType::U8,
10730            None,
10731            edgefirst_tensor::CpuAccess::ReadWrite,
10732        )
10733        .unwrap();
10734        src.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
10735            .copy_from_slice(&file);
10736
10737        let dst = TensorDyn::image(
10738            1280,
10739            720,
10740            PixelFormat::Grey,
10741            DType::U8,
10742            None,
10743            edgefirst_tensor::CpuAccess::ReadWrite,
10744        )
10745        .unwrap();
10746        let mut cpu_converter = CPUProcessor::new();
10747
10748        let (result, _src, dst) = convert_img(
10749            &mut cpu_converter,
10750            src,
10751            dst,
10752            Rotation::None,
10753            Flip::None,
10754            Crop::no_crop(),
10755        );
10756        result.unwrap();
10757
10758        let target_image = crate::load_image_test_helper(
10759            &edgefirst_bench::testdata::read("zidane.jpg"),
10760            Some(PixelFormat::Grey),
10761            None,
10762        )
10763        .unwrap();
10764
10765        // Threshold 0.95 (was 0.98): the reference grey frame now comes from
10766        // the colour JPEG decoded to native NV12 and then converted to GREY
10767        // (was a direct JPEG → GREY decode), so it differs slightly from the
10768        // grey derived from the `zidane.nv12` fixture.
10769        compare_images(&dst, &target_image, 0.95, function!());
10770    }
10771
10772    #[test]
10773    fn test_nv12_to_yuyv_cpu() {
10774        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
10775        let src = TensorDyn::image(
10776            1280,
10777            720,
10778            PixelFormat::Nv12,
10779            DType::U8,
10780            None,
10781            edgefirst_tensor::CpuAccess::ReadWrite,
10782        )
10783        .unwrap();
10784        src.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
10785            .copy_from_slice(&file);
10786
10787        let dst = TensorDyn::image(
10788            1280,
10789            720,
10790            PixelFormat::Yuyv,
10791            DType::U8,
10792            None,
10793            edgefirst_tensor::CpuAccess::ReadWrite,
10794        )
10795        .unwrap();
10796        let mut cpu_converter = CPUProcessor::new();
10797
10798        let (result, _src, dst) = convert_img(
10799            &mut cpu_converter,
10800            src,
10801            dst,
10802            Rotation::None,
10803            Flip::None,
10804            Crop::no_crop(),
10805        );
10806        result.unwrap();
10807
10808        let target_image = crate::load_image_test_helper(
10809            &edgefirst_bench::testdata::read("zidane.jpg"),
10810            Some(PixelFormat::Rgb),
10811            None,
10812        )
10813        .unwrap();
10814
10815        // Threshold 0.95 (was 0.98): the reference now decodes the colour JPEG
10816        // to native NV12 and then converts to RGB (was a direct JPEG → RGB
10817        // decode), so it differs slightly from the YUYV-sourced frame derived
10818        // from the separate `zidane.nv12` fixture.
10819        compare_images_convert_to_rgb(&dst, &target_image, 0.95, function!());
10820    }
10821
10822    #[test]
10823    fn test_cpu_resize_nv16() {
10824        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
10825        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
10826
10827        let cpu_nv16_dst = TensorDyn::image(
10828            640,
10829            640,
10830            PixelFormat::Nv16,
10831            DType::U8,
10832            None,
10833            edgefirst_tensor::CpuAccess::ReadWrite,
10834        )
10835        .unwrap();
10836        let cpu_rgb_dst = TensorDyn::image(
10837            640,
10838            640,
10839            PixelFormat::Rgb,
10840            DType::U8,
10841            None,
10842            edgefirst_tensor::CpuAccess::ReadWrite,
10843        )
10844        .unwrap();
10845        let mut cpu_converter = CPUProcessor::new();
10846        let crop = Crop::letterbox([255, 128, 0, 255]);
10847
10848        let (result, src, cpu_nv16_dst) = convert_img(
10849            &mut cpu_converter,
10850            src,
10851            cpu_nv16_dst,
10852            Rotation::None,
10853            Flip::None,
10854            crop,
10855        );
10856        result.unwrap();
10857
10858        let (result, _src, cpu_rgb_dst) = convert_img(
10859            &mut cpu_converter,
10860            src,
10861            cpu_rgb_dst,
10862            Rotation::None,
10863            Flip::None,
10864            crop,
10865        );
10866        result.unwrap();
10867        compare_images_convert_to_rgb(
10868            &cpu_nv16_dst,
10869            &cpu_rgb_dst,
10870            yuv_threshold(0.99),
10871            function!(),
10872        );
10873    }
10874
10875    /// Similarity threshold for a comparison whose path runs a YUV↔RGB
10876    /// conversion.
10877    ///
10878    /// [`crate::cpu::convert`]'s `yuv_mode()` deliberately selects
10879    /// `YuvConversionMode::Fast` on cores without Armv8.1 `rdm`
10880    /// (Cortex-A53/A35 class, e.g. the i.MX 8M Plus), because `Balanced`
10881    /// emulates `SQRDMLAH` there and costs roughly 2×. `Fast` is a
10882    /// lower-precision integer approximation with a documented error of up to
10883    /// ~2/255, so a threshold calibrated on an `rdm`-capable core is
10884    /// unreachable on those cores **by design**: NV16 measures 0.9885 against
10885    /// 0.99 on an A53, while every `rdm` core passes.
10886    ///
10887    /// No CI runner is A53-class, so this only reproduces on target.
10888    ///
10889    /// Relaxing per-core keeps the assertion tight where the precise path
10890    /// runs, instead of loosening it everywhere to accommodate one core.
10891    fn yuv_threshold(balanced: f64) -> f64 {
10892        #[cfg(target_arch = "aarch64")]
10893        {
10894            if !std::arch::is_aarch64_feature_detected!("rdm") {
10895                return balanced - 0.01;
10896            }
10897        }
10898        balanced
10899    }
10900
10901    fn load_bytes_to_tensor(
10902        width: usize,
10903        height: usize,
10904        format: PixelFormat,
10905        memory: Option<TensorMemory>,
10906        bytes: &[u8],
10907    ) -> Result<TensorDyn, Error> {
10908        let src = TensorDyn::image(
10909            width,
10910            height,
10911            format,
10912            DType::U8,
10913            memory,
10914            edgefirst_tensor::CpuAccess::ReadWrite,
10915        )?;
10916        src.as_typed::<u8>()
10917            .unwrap()
10918            .map()?
10919            .as_mut_slice()
10920            .copy_from_slice(bytes);
10921        Ok(src)
10922    }
10923
10924    // DEDUP: this function is also defined verbatim in
10925    // `crates/image/src/gl/tests.rs` (inside `mod gl_tests`). Both copies
10926    // must be kept in sync. Cross-module sharing would require either a
10927    // `pub(crate)` test-helper module (which pollutes the non-test API) or a
10928    // separate test-utils crate — both are disproportionate for a single
10929    // helper. If the implementation ever diverges, extract to a shared
10930    // `test_helpers` module in this crate.
10931    fn compare_images(img1: &TensorDyn, img2: &TensorDyn, threshold: f64, name: &str) {
10932        assert_eq!(img1.height(), img2.height(), "Heights differ");
10933        assert_eq!(img1.width(), img2.width(), "Widths differ");
10934        assert_eq!(
10935            img1.format().unwrap(),
10936            img2.format().unwrap(),
10937            "PixelFormat differ"
10938        );
10939        assert!(
10940            matches!(
10941                img1.format().unwrap(),
10942                PixelFormat::Rgb | PixelFormat::Rgba | PixelFormat::Grey | PixelFormat::PlanarRgb
10943            ),
10944            "format must be Rgb or Rgba for comparison"
10945        );
10946
10947        // A pitch-padded destination -- the D3D11 staging texture behind a
10948        // Windows `TensorMemory::DmaBuf` tensor pads an RGBA row to 128 bytes
10949        // -- spaces its rows at `effective_row_stride()`, so reading the head
10950        // of the mapping as a tight image would read padding as pixels.
10951        // `copy_to_flat` is the tensor's own compaction and degenerates to one
10952        // memcpy when the tensor is tight.
10953        fn flat(img: &TensorDyn) -> Vec<u8> {
10954            let t = img.as_typed::<u8>().expect("u8 image");
10955            let mut out = vec![0u8; t.shape().iter().product::<usize>()];
10956            t.copy_to_flat(&mut out).expect("compact the padded rows");
10957            out
10958        }
10959
10960        let image1 = match img1.format().unwrap() {
10961            PixelFormat::Rgb => image::RgbImage::from_vec(
10962                img1.width().unwrap() as u32,
10963                img1.height().unwrap() as u32,
10964                flat(img1),
10965            )
10966            .unwrap(),
10967            PixelFormat::Rgba => image::RgbaImage::from_vec(
10968                img1.width().unwrap() as u32,
10969                img1.height().unwrap() as u32,
10970                flat(img1),
10971            )
10972            .unwrap()
10973            .convert(),
10974            PixelFormat::Grey => image::GrayImage::from_vec(
10975                img1.width().unwrap() as u32,
10976                img1.height().unwrap() as u32,
10977                flat(img1),
10978            )
10979            .unwrap()
10980            .convert(),
10981            PixelFormat::PlanarRgb => image::GrayImage::from_vec(
10982                img1.width().unwrap() as u32,
10983                (img1.height().unwrap() * 3) as u32,
10984                flat(img1),
10985            )
10986            .unwrap()
10987            .convert(),
10988            _ => return,
10989        };
10990
10991        let image2 = match img2.format().unwrap() {
10992            PixelFormat::Rgb => image::RgbImage::from_vec(
10993                img2.width().unwrap() as u32,
10994                img2.height().unwrap() as u32,
10995                flat(img2),
10996            )
10997            .unwrap(),
10998            PixelFormat::Rgba => image::RgbaImage::from_vec(
10999                img2.width().unwrap() as u32,
11000                img2.height().unwrap() as u32,
11001                flat(img2),
11002            )
11003            .unwrap()
11004            .convert(),
11005            PixelFormat::Grey => image::GrayImage::from_vec(
11006                img2.width().unwrap() as u32,
11007                img2.height().unwrap() as u32,
11008                flat(img2),
11009            )
11010            .unwrap()
11011            .convert(),
11012            PixelFormat::PlanarRgb => image::GrayImage::from_vec(
11013                img2.width().unwrap() as u32,
11014                (img2.height().unwrap() * 3) as u32,
11015                flat(img2),
11016            )
11017            .unwrap()
11018            .convert(),
11019            _ => return,
11020        };
11021
11022        let similarity = image_compare::rgb_similarity_structure(
11023            &image_compare::Algorithm::RootMeanSquared,
11024            &image1,
11025            &image2,
11026        )
11027        .expect("Image Comparison failed");
11028        if similarity.score < threshold {
11029            // image1.save(format!("{name}_1.png"));
11030            // image2.save(format!("{name}_2.png"));
11031            similarity
11032                .image
11033                .to_color_map()
11034                .save(format!("{name}.png"))
11035                .unwrap();
11036            panic!(
11037                "{name}: converted image and target image have similarity score too low: {} < {}",
11038                similarity.score, threshold
11039            )
11040        }
11041    }
11042
11043    fn compare_images_convert_to_rgb(
11044        img1: &TensorDyn,
11045        img2: &TensorDyn,
11046        threshold: f64,
11047        name: &str,
11048    ) {
11049        assert_eq!(img1.height(), img2.height(), "Heights differ");
11050        assert_eq!(img1.width(), img2.width(), "Widths differ");
11051
11052        let mut img_rgb1 = TensorDyn::image(
11053            img1.width().unwrap(),
11054            img1.height().unwrap(),
11055            PixelFormat::Rgb,
11056            DType::U8,
11057            Some(TensorMemory::Mem),
11058            edgefirst_tensor::CpuAccess::ReadWrite,
11059        )
11060        .unwrap();
11061        let mut img_rgb2 = TensorDyn::image(
11062            img1.width().unwrap(),
11063            img1.height().unwrap(),
11064            PixelFormat::Rgb,
11065            DType::U8,
11066            Some(TensorMemory::Mem),
11067            edgefirst_tensor::CpuAccess::ReadWrite,
11068        )
11069        .unwrap();
11070        let mut __cv = CPUProcessor::default();
11071        let r1 = __cv.convert(
11072            img1,
11073            &mut img_rgb1,
11074            crate::Rotation::None,
11075            crate::Flip::None,
11076            crate::Crop::default(),
11077        );
11078        let r2 = __cv.convert(
11079            img2,
11080            &mut img_rgb2,
11081            crate::Rotation::None,
11082            crate::Flip::None,
11083            crate::Crop::default(),
11084        );
11085        if r1.is_err() || r2.is_err() {
11086            // Fallback: compare raw bytes as greyscale strip
11087            let w = img1.width().unwrap() as u32;
11088            let data1 = img1.as_typed::<u8>().unwrap().map().unwrap().to_vec();
11089            let data2 = img2.as_typed::<u8>().unwrap().map().unwrap().to_vec();
11090            let h1 = (data1.len() as u32) / w;
11091            let h2 = (data2.len() as u32) / w;
11092            let g1 = image::GrayImage::from_vec(w, h1, data1).unwrap();
11093            let g2 = image::GrayImage::from_vec(w, h2, data2).unwrap();
11094            let similarity = image_compare::gray_similarity_structure(
11095                &image_compare::Algorithm::RootMeanSquared,
11096                &g1,
11097                &g2,
11098            )
11099            .expect("Image Comparison failed");
11100            if similarity.score < threshold {
11101                panic!(
11102                    "{name}: converted image and target image have similarity score too low: {} < {}",
11103                    similarity.score, threshold
11104                )
11105            }
11106            return;
11107        }
11108
11109        let image1 = image::RgbImage::from_vec(
11110            img_rgb1.width().unwrap() as u32,
11111            img_rgb1.height().unwrap() as u32,
11112            img_rgb1.as_typed::<u8>().unwrap().map().unwrap().to_vec(),
11113        )
11114        .unwrap();
11115
11116        let image2 = image::RgbImage::from_vec(
11117            img_rgb2.width().unwrap() as u32,
11118            img_rgb2.height().unwrap() as u32,
11119            img_rgb2.as_typed::<u8>().unwrap().map().unwrap().to_vec(),
11120        )
11121        .unwrap();
11122
11123        let similarity = image_compare::rgb_similarity_structure(
11124            &image_compare::Algorithm::RootMeanSquared,
11125            &image1,
11126            &image2,
11127        )
11128        .expect("Image Comparison failed");
11129        if similarity.score < threshold {
11130            // image1.save(format!("{name}_1.png"));
11131            // image2.save(format!("{name}_2.png"));
11132            similarity
11133                .image
11134                .to_color_map()
11135                .save(format!("{name}.png"))
11136                .unwrap();
11137            panic!(
11138                "{name}: converted image and target image have similarity score too low: {} < {}",
11139                similarity.score, threshold
11140            )
11141        }
11142    }
11143
11144    // =========================================================================
11145    // PixelFormat::Nv12 Format Tests
11146    // =========================================================================
11147
11148    #[test]
11149    fn test_nv12_image_creation() {
11150        let width = 640;
11151        let height = 480;
11152        let img = TensorDyn::image(
11153            width,
11154            height,
11155            PixelFormat::Nv12,
11156            DType::U8,
11157            None,
11158            edgefirst_tensor::CpuAccess::ReadWrite,
11159        )
11160        .unwrap();
11161
11162        assert_eq!(img.width(), Some(width));
11163        assert_eq!(img.height(), Some(height));
11164        assert_eq!(img.format().unwrap(), PixelFormat::Nv12);
11165        // PixelFormat::Nv12 uses shape [H*3/2, W] to store Y plane + UV plane
11166        assert_eq!(
11167            img.as_typed::<u8>().unwrap().shape(),
11168            &[height * 3 / 2, width]
11169        );
11170    }
11171
11172    #[test]
11173    fn test_nv12_channels() {
11174        let img = TensorDyn::image(
11175            640,
11176            480,
11177            PixelFormat::Nv12,
11178            DType::U8,
11179            None,
11180            edgefirst_tensor::CpuAccess::ReadWrite,
11181        )
11182        .unwrap();
11183        // PixelFormat::Nv12.channels() returns 1 (luma plane)
11184        assert_eq!(img.format().unwrap().channels(), 1);
11185    }
11186
11187    // =========================================================================
11188    // Tensor Format Metadata Tests
11189    // =========================================================================
11190
11191    #[test]
11192    fn test_tensor_set_format_planar() {
11193        let mut tensor = Tensor::<u8>::new(&[3, 480, 640], None, None).unwrap();
11194        tensor.set_format(PixelFormat::PlanarRgb).unwrap();
11195        assert_eq!(tensor.format(), Some(PixelFormat::PlanarRgb));
11196        assert_eq!(tensor.width(), Some(640));
11197        assert_eq!(tensor.height(), Some(480));
11198    }
11199
11200    #[test]
11201    fn test_tensor_set_format_interleaved() {
11202        let mut tensor = Tensor::<u8>::new(&[480, 640, 4], None, None).unwrap();
11203        tensor.set_format(PixelFormat::Rgba).unwrap();
11204        assert_eq!(tensor.format(), Some(PixelFormat::Rgba));
11205        assert_eq!(tensor.width(), Some(640));
11206        assert_eq!(tensor.height(), Some(480));
11207    }
11208
11209    #[test]
11210    fn test_tensordyn_image_rgb() {
11211        let img = TensorDyn::image(
11212            640,
11213            480,
11214            PixelFormat::Rgb,
11215            DType::U8,
11216            None,
11217            edgefirst_tensor::CpuAccess::ReadWrite,
11218        )
11219        .unwrap();
11220        assert_eq!(img.width(), Some(640));
11221        assert_eq!(img.height(), Some(480));
11222        assert_eq!(img.format(), Some(PixelFormat::Rgb));
11223    }
11224
11225    #[test]
11226    fn test_tensordyn_image_planar_rgb() {
11227        let img = TensorDyn::image(
11228            640,
11229            480,
11230            PixelFormat::PlanarRgb,
11231            DType::U8,
11232            None,
11233            edgefirst_tensor::CpuAccess::ReadWrite,
11234        )
11235        .unwrap();
11236        assert_eq!(img.width(), Some(640));
11237        assert_eq!(img.height(), Some(480));
11238        assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
11239    }
11240
11241    #[test]
11242    fn test_rgb_int8_format() {
11243        // Int8 variant: same PixelFormat::Rgb but with DType::I8
11244        let img = TensorDyn::image(
11245            1280,
11246            720,
11247            PixelFormat::Rgb,
11248            DType::I8,
11249            Some(TensorMemory::Mem),
11250            edgefirst_tensor::CpuAccess::ReadWrite,
11251        )
11252        .unwrap();
11253        assert_eq!(img.width(), Some(1280));
11254        assert_eq!(img.height(), Some(720));
11255        assert_eq!(img.format(), Some(PixelFormat::Rgb));
11256        assert_eq!(img.dtype(), DType::I8);
11257    }
11258
11259    #[test]
11260    fn test_planar_rgb_int8_format() {
11261        let img = TensorDyn::image(
11262            1280,
11263            720,
11264            PixelFormat::PlanarRgb,
11265            DType::I8,
11266            Some(TensorMemory::Mem),
11267            edgefirst_tensor::CpuAccess::ReadWrite,
11268        )
11269        .unwrap();
11270        assert_eq!(img.width(), Some(1280));
11271        assert_eq!(img.height(), Some(720));
11272        assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
11273        assert_eq!(img.dtype(), DType::I8);
11274    }
11275
11276    #[test]
11277    fn test_rgb_from_tensor() {
11278        let mut tensor = Tensor::<u8>::new(&[720, 1280, 3], None, None).unwrap();
11279        tensor.set_format(PixelFormat::Rgb).unwrap();
11280        let img = TensorDyn::from(tensor);
11281        assert_eq!(img.width(), Some(1280));
11282        assert_eq!(img.height(), Some(720));
11283        assert_eq!(img.format(), Some(PixelFormat::Rgb));
11284    }
11285
11286    #[test]
11287    fn test_planar_rgb_from_tensor() {
11288        let mut tensor = Tensor::<u8>::new(&[3, 720, 1280], None, None).unwrap();
11289        tensor.set_format(PixelFormat::PlanarRgb).unwrap();
11290        let img = TensorDyn::from(tensor);
11291        assert_eq!(img.width(), Some(1280));
11292        assert_eq!(img.height(), Some(720));
11293        assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
11294    }
11295
11296    #[test]
11297    fn test_dtype_determines_int8() {
11298        // DType::I8 indicates int8 data
11299        let u8_img = TensorDyn::image(
11300            64,
11301            64,
11302            PixelFormat::Rgb,
11303            DType::U8,
11304            None,
11305            edgefirst_tensor::CpuAccess::ReadWrite,
11306        )
11307        .unwrap();
11308        let i8_img = TensorDyn::image(
11309            64,
11310            64,
11311            PixelFormat::Rgb,
11312            DType::I8,
11313            None,
11314            edgefirst_tensor::CpuAccess::ReadWrite,
11315        )
11316        .unwrap();
11317        assert_eq!(u8_img.dtype(), DType::U8);
11318        assert_eq!(i8_img.dtype(), DType::I8);
11319    }
11320
11321    #[test]
11322    fn test_pixel_layout_packed_vs_planar() {
11323        // Packed vs planar layout classification
11324        assert_eq!(PixelFormat::Rgb.layout(), PixelLayout::Packed);
11325        assert_eq!(PixelFormat::Rgba.layout(), PixelLayout::Packed);
11326        assert_eq!(PixelFormat::PlanarRgb.layout(), PixelLayout::Planar);
11327        assert_eq!(PixelFormat::Nv12.layout(), PixelLayout::SemiPlanar);
11328    }
11329
11330    /// Integration test that exercises the PBO-to-PBO convert path.
11331    /// A PBO cannot hand out a host address that outlives its map guard, and
11332    /// the error has to say why rather than reading as unfinished work.
11333    ///
11334    /// `glMapBufferRange`'s pointer is valid only until `glUnmapBuffer`, and
11335    /// this backend maps with `MAP_READ_BIT | MAP_WRITE_BIT` — there is no
11336    /// `GL_MAP_PERSISTENT_BIT` and no `glBufferStorage` in the tree. Pinning
11337    /// would mean holding the buffer mapped across GL work, which is the
11338    /// opposite of what a PBO is for. `EXT_buffer_storage` would change the
11339    /// answer, but that is a buffer-allocation feature, not wiring.
11340    #[cfg(target_os = "linux")]
11341    #[cfg(feature = "opengl")]
11342    #[test]
11343    fn pbo_reports_why_it_cannot_pin() {
11344        use edgefirst_tensor::{Error, TensorTrait};
11345
11346        let converter = ImageProcessor::new().unwrap();
11347        let is_pbo = converter
11348            .opengl
11349            .as_ref()
11350            .is_some_and(|gl| gl.transfer_backend() == opengl_headless::TransferBackend::Pbo);
11351        if !is_pbo {
11352            // Direct stderr, not eprintln!: libtest captures println!/eprintln!
11353            // and replays it only for FAILING tests, so a skip would print
11354            // "... ok" with its reason discarded and be indistinguishable from
11355            // having run. See TESTING.md.
11356            use std::io::Write;
11357            let _ = writeln!(
11358                &mut std::io::stderr(),
11359                "SKIPPED: pbo_reports_why_it_cannot_pin - transfer backend is not PBO"
11360            );
11361            return;
11362        }
11363
11364        let t = converter
11365            .create_image(
11366                64,
11367                64,
11368                PixelFormat::Rgba,
11369                DType::U8,
11370                None,
11371                edgefirst_tensor::CpuAccess::ReadWrite,
11372            )
11373            .unwrap();
11374        assert_eq!(t.as_typed::<u8>().unwrap().memory(), TensorMemory::Pbo);
11375        let t = t.as_typed::<u8>().unwrap();
11376
11377        match t.pin_host(edgefirst_tensor::CpuAccess::ReadWrite) {
11378            Err(Error::NotImplemented(m)) => {
11379                assert!(
11380                    m.contains("glMapBufferRange"),
11381                    "must name the constraint: {m}"
11382                );
11383                assert!(m.contains("map()"), "must point at the supported API: {m}");
11384            }
11385            other => panic!("expected an explained NotImplemented, got {other:?}"),
11386        }
11387        // Sync is unavailable for the same reason, and must not silently succeed.
11388        assert!(matches!(
11389            t.sync_for_cpu(edgefirst_tensor::CpuAccess::Read),
11390            Err(Error::NotImplemented(_))
11391        ));
11392
11393        // The supported path still works, which is what makes the refusal safe.
11394        let g = t.map().expect("map must work where pin cannot");
11395        assert!(!g.as_slice().is_empty());
11396    }
11397
11398    /// Uses ImageProcessor::create_image() to allocate PBO-backed tensors,
11399    /// then converts between them. Skipped when GL is unavailable or the
11400    /// backend is not PBO (e.g. DMA-buf systems).
11401    #[cfg(target_os = "linux")]
11402    #[cfg(feature = "opengl")]
11403    #[test]
11404    fn test_convert_pbo_to_pbo() {
11405        let mut converter = ImageProcessor::new().unwrap();
11406
11407        // Skip if GL is not available or backend is not PBO
11408        let is_pbo = converter
11409            .opengl
11410            .as_ref()
11411            .is_some_and(|gl| gl.transfer_backend() == opengl_headless::TransferBackend::Pbo);
11412        if !is_pbo {
11413            log::warn!("Skipping test_convert_pbo_to_pbo: backend is not PBO");
11414            return;
11415        }
11416
11417        let src_w = 640;
11418        let src_h = 480;
11419        let dst_w = 320;
11420        let dst_h = 240;
11421
11422        // Create PBO-backed source image
11423        let pbo_src = converter
11424            .create_image(
11425                src_w,
11426                src_h,
11427                PixelFormat::Rgba,
11428                DType::U8,
11429                None,
11430                edgefirst_tensor::CpuAccess::ReadWrite,
11431            )
11432            .unwrap();
11433        assert_eq!(
11434            pbo_src.as_typed::<u8>().unwrap().memory(),
11435            TensorMemory::Pbo,
11436            "create_image should produce a PBO tensor"
11437        );
11438
11439        // Fill source PBO with test pattern: load JPEG then convert Mem→PBO
11440        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
11441        let jpeg_src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
11442
11443        // Resize JPEG into a Mem temp of the right size, then copy into PBO
11444        let mem_src = TensorDyn::image(
11445            src_w,
11446            src_h,
11447            PixelFormat::Rgba,
11448            DType::U8,
11449            Some(TensorMemory::Mem),
11450            edgefirst_tensor::CpuAccess::ReadWrite,
11451        )
11452        .unwrap();
11453        let (result, _jpeg_src, mem_src) = convert_img(
11454            &mut CPUProcessor::new(),
11455            jpeg_src,
11456            mem_src,
11457            Rotation::None,
11458            Flip::None,
11459            Crop::no_crop(),
11460        );
11461        result.unwrap();
11462
11463        // Copy pixel data into the PBO source by mapping it
11464        {
11465            let src_data = mem_src.as_typed::<u8>().unwrap().map().unwrap();
11466            let mut pbo_map = pbo_src.as_typed::<u8>().unwrap().map().unwrap();
11467            pbo_map.copy_from_slice(&src_data);
11468        }
11469
11470        // Create PBO-backed destination image
11471        let pbo_dst = converter
11472            .create_image(
11473                dst_w,
11474                dst_h,
11475                PixelFormat::Rgba,
11476                DType::U8,
11477                None,
11478                edgefirst_tensor::CpuAccess::ReadWrite,
11479            )
11480            .unwrap();
11481        assert_eq!(
11482            pbo_dst.as_typed::<u8>().unwrap().memory(),
11483            TensorMemory::Pbo
11484        );
11485
11486        // Convert PBO→PBO (this exercises convert_pbo_to_pbo)
11487        let mut pbo_dst = pbo_dst;
11488        let result = converter.convert(
11489            &pbo_src,
11490            &mut pbo_dst,
11491            Rotation::None,
11492            Flip::None,
11493            Crop::no_crop(),
11494        );
11495        result.unwrap();
11496
11497        // Verify: compare with CPU-only conversion of the same input
11498        let cpu_dst = TensorDyn::image(
11499            dst_w,
11500            dst_h,
11501            PixelFormat::Rgba,
11502            DType::U8,
11503            Some(TensorMemory::Mem),
11504            edgefirst_tensor::CpuAccess::ReadWrite,
11505        )
11506        .unwrap();
11507        let (result, _mem_src, cpu_dst) = convert_img(
11508            &mut CPUProcessor::new(),
11509            mem_src,
11510            cpu_dst,
11511            Rotation::None,
11512            Flip::None,
11513            Crop::no_crop(),
11514        );
11515        result.unwrap();
11516
11517        let pbo_dst_img = {
11518            let mut __t = pbo_dst.into_u8().unwrap();
11519            __t.set_format(PixelFormat::Rgba).unwrap();
11520            TensorDyn::from(__t)
11521        };
11522        compare_images(&pbo_dst_img, &cpu_dst, 0.95, function!());
11523        log::info!("test_convert_pbo_to_pbo: PASS — PBO-to-PBO convert matches CPU reference");
11524    }
11525
11526    #[test]
11527    fn test_image_bgra() {
11528        let img = TensorDyn::image(
11529            640,
11530            480,
11531            PixelFormat::Bgra,
11532            DType::U8,
11533            Some(edgefirst_tensor::TensorMemory::Mem),
11534            edgefirst_tensor::CpuAccess::ReadWrite,
11535        )
11536        .unwrap();
11537        assert_eq!(img.width(), Some(640));
11538        assert_eq!(img.height(), Some(480));
11539        assert_eq!(img.format().unwrap().channels(), 4);
11540        assert_eq!(img.format().unwrap(), PixelFormat::Bgra);
11541    }
11542
11543    // ========================================================================
11544    // Tests for EDGEFIRST_FORCE_BACKEND env var
11545    // ========================================================================
11546
11547    #[test]
11548    fn test_force_backend_cpu() {
11549        let _lock = acquire_env_lock();
11550        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
11551        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
11552        let converter = ImageProcessor::new().unwrap();
11553        assert!(converter.cpu.is_some());
11554        assert_eq!(converter.forced_backend, Some(ForcedBackend::Cpu));
11555    }
11556
11557    #[test]
11558    fn test_force_backend_invalid() {
11559        let _lock = acquire_env_lock();
11560        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
11561        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "invalid") };
11562        let result = ImageProcessor::new();
11563        assert!(
11564            matches!(&result, Err(Error::ForcedBackendUnavailable(s)) if s.contains("unknown")),
11565            "invalid backend value should return ForcedBackendUnavailable error: {result:?}"
11566        );
11567    }
11568
11569    #[test]
11570    fn test_force_backend_unset() {
11571        let _lock = acquire_env_lock();
11572        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
11573        unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") };
11574        let converter = ImageProcessor::new().unwrap();
11575        assert!(converter.forced_backend.is_none());
11576    }
11577
11578    // ========================================================================
11579    // Tests for hybrid mask path error handling
11580    // ========================================================================
11581
11582    #[test]
11583    fn test_draw_proto_masks_no_cpu_returns_error() {
11584        // Serialize against all other env-var-mutating tests.
11585        let _lock = acquire_env_lock();
11586        let _guard = EnvGuard::snapshot(&[
11587            "EDGEFIRST_FORCE_BACKEND",
11588            "EDGEFIRST_DISABLE_GL",
11589            "EDGEFIRST_DISABLE_G2D",
11590            "EDGEFIRST_DISABLE_CPU",
11591        ]);
11592
11593        // Disable all backends so cpu.is_none() after construction.
11594        unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
11595        unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
11596        unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
11597
11598        let mut converter = ImageProcessor::new().unwrap();
11599        assert!(converter.cpu.is_none(), "CPU should be disabled");
11600
11601        let dst = TensorDyn::image(
11602            640,
11603            480,
11604            PixelFormat::Rgba,
11605            DType::U8,
11606            Some(TensorMemory::Mem),
11607            edgefirst_tensor::CpuAccess::ReadWrite,
11608        )
11609        .unwrap();
11610        let mut dst_dyn = dst;
11611        let det = [DetectBox {
11612            bbox: edgefirst_tensor::BoundingBox {
11613                xmin: 0.1,
11614                ymin: 0.1,
11615                xmax: 0.5,
11616                ymax: 0.5,
11617            },
11618            score: 0.9,
11619            label: 0,
11620        }];
11621        let proto_data = {
11622            use edgefirst_tensor::{Tensor, TensorDyn};
11623            let coeff_t = Tensor::<f32>::from_slice(&[0.5_f32; 4], &[1, 4]).unwrap();
11624            let protos_t =
11625                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
11626            ProtoData {
11627                mask_coefficients: TensorDyn::F32(coeff_t),
11628                protos: TensorDyn::F32(protos_t),
11629                layout: ProtoLayout::Nhwc,
11630            }
11631        };
11632        let result =
11633            converter.draw_proto_masks(&mut dst_dyn, &det, &proto_data, Default::default());
11634        assert!(
11635            matches!(&result, Err(Error::Internal(s)) if s.contains("CPU backend")),
11636            "draw_proto_masks without CPU should return Internal error: {result:?}"
11637        );
11638    }
11639
11640    #[test]
11641    fn test_draw_proto_masks_cpu_fallback_works() {
11642        // Force CPU-only backend to ensure the CPU fallback path executes.
11643        // Serialized under ENV_MUTEX so we don't race with disable-var tests.
11644        let _lock = acquire_env_lock();
11645        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
11646        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
11647        let mut converter = ImageProcessor::new().unwrap();
11648        assert!(converter.cpu.is_some());
11649
11650        let dst = TensorDyn::image(
11651            64,
11652            64,
11653            PixelFormat::Rgba,
11654            DType::U8,
11655            Some(TensorMemory::Mem),
11656            edgefirst_tensor::CpuAccess::ReadWrite,
11657        )
11658        .unwrap();
11659        let mut dst_dyn = dst;
11660        let det = [DetectBox {
11661            bbox: edgefirst_tensor::BoundingBox {
11662                xmin: 0.1,
11663                ymin: 0.1,
11664                xmax: 0.5,
11665                ymax: 0.5,
11666            },
11667            score: 0.9,
11668            label: 0,
11669        }];
11670        let proto_data = {
11671            use edgefirst_tensor::{Tensor, TensorDyn};
11672            let coeff_t = Tensor::<f32>::from_slice(&[0.5_f32; 4], &[1, 4]).unwrap();
11673            let protos_t =
11674                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
11675            ProtoData {
11676                mask_coefficients: TensorDyn::F32(coeff_t),
11677                protos: TensorDyn::F32(protos_t),
11678                layout: ProtoLayout::Nhwc,
11679            }
11680        };
11681        let result =
11682            converter.draw_proto_masks(&mut dst_dyn, &det, &proto_data, Default::default());
11683        assert!(result.is_ok(), "CPU fallback path should work: {result:?}");
11684    }
11685
11686    // ============================================================
11687    // draw_decoded_masks / draw_proto_masks — 4-scenario pixel-
11688    // verified tests. Exercises each backend against the full
11689    // output-contract matrix:
11690    //
11691    //   | detections | background | expected dst             |
11692    //   |------------|------------|--------------------------|
11693    //   | empty      | none       | fully cleared (0x00)     |
11694    //   | empty      | set        | fully equal to bg        |
11695    //   | set        | none       | cleared outside box +    |
11696    //   |            |            | mask-coloured inside     |
11697    //   | set        | set        | bg outside box + mask    |
11698    //   |            |            | blended inside           |
11699    //
11700    // Every test pre-fills dst with a non-zero "dirty" pattern so
11701    // that any silent `return Ok(())` leaks the pattern into the
11702    // asserted output and fails loudly.
11703    // ============================================================
11704
11705    // =========================================================================
11706    // Env-var serialisation helpers
11707    //
11708    // ALL tests that mutate any EDGEFIRST_* backend env var must hold
11709    // ENV_MUTEX for their full duration.  This single mutex serialises
11710    // test_disable_env_var, test_draw_proto_masks_no_cpu_returns_error,
11711    // test_force_backend_*, with_force_backend, and with_env — preventing
11712    // any two of them from racing in a parallel `cargo test` run.
11713    // =========================================================================
11714
11715    /// Acquire the process-wide env-var mutex.  Returns a guard that must be
11716    /// kept alive for the entire duration of the test body.
11717    fn acquire_env_lock() -> std::sync::MutexGuard<'static, ()> {
11718        use std::sync::{Mutex, OnceLock};
11719        static ENV_MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
11720        ENV_MUTEX
11721            .get_or_init(|| Mutex::new(()))
11722            .lock()
11723            .unwrap_or_else(|e| e.into_inner())
11724    }
11725
11726    /// RAII guard that snapshots a set of env vars on construction and
11727    /// restores them on `Drop`, even if the test panics.
11728    struct EnvGuard {
11729        vars: Vec<(&'static str, Option<String>)>,
11730    }
11731
11732    impl EnvGuard {
11733        /// Snapshot the current values of `names`.  Call this while holding
11734        /// the env lock (the lock is not taken here — that is the caller's
11735        /// responsibility so the lock scope can be wider than the guard).
11736        fn snapshot(names: &[&'static str]) -> Self {
11737            Self {
11738                vars: names.iter().map(|&k| (k, std::env::var(k).ok())).collect(),
11739            }
11740        }
11741    }
11742
11743    impl Drop for EnvGuard {
11744        fn drop(&mut self) {
11745            for (k, v) in &self.vars {
11746                match v {
11747                    Some(s) => unsafe { std::env::set_var(k, s) },
11748                    None => unsafe { std::env::remove_var(k) },
11749                }
11750            }
11751        }
11752    }
11753
11754    /// `EDGEFIRST_INTERPOLATION` accepts `nearest` and `bilinear` in any
11755    /// case and ignores anything else.
11756    #[test]
11757    fn test_interpolation_env_parse() {
11758        let _lock = acquire_env_lock();
11759        let _guard = EnvGuard::snapshot(&["EDGEFIRST_INTERPOLATION"]);
11760        for (value, want) in [
11761            (Some("nearest"), Some(Interpolation::Nearest)),
11762            (Some(" BILINEAR "), Some(Interpolation::Bilinear)),
11763            (Some("lanczos"), None),
11764            (Some(""), None),
11765            (None, None),
11766        ] {
11767            match value {
11768                Some(v) => unsafe { std::env::set_var("EDGEFIRST_INTERPOLATION", v) },
11769                None => unsafe { std::env::remove_var("EDGEFIRST_INTERPOLATION") },
11770            }
11771            assert_eq!(
11772                Interpolation::from_env(),
11773                want,
11774                "EDGEFIRST_INTERPOLATION={value:?}"
11775            );
11776        }
11777    }
11778
11779    /// `ImageProcessorConfig::interpolation` reaches the CPU backend, and
11780    /// `EDGEFIRST_INTERPOLATION` overrides it.
11781    #[test]
11782    fn test_interpolation_config_and_env_reach_cpu() {
11783        let _lock = acquire_env_lock();
11784        let _guard = EnvGuard::snapshot(&["EDGEFIRST_INTERPOLATION"]);
11785        let mut checker = Vec::with_capacity(8 * 8 * 4);
11786        for y in 0..8 {
11787            for x in 0..8 {
11788                let v = if (x + y) % 2 == 0 { 0u8 } else { 255 };
11789                checker.extend_from_slice(&[v, v, v, 255]);
11790            }
11791        }
11792        let resize = |processor: &mut dyn ImageProcessorTrait| {
11793            let mut src = TensorDyn::image(
11794                8,
11795                8,
11796                PixelFormat::Rgba,
11797                DType::U8,
11798                Some(TensorMemory::Mem),
11799                edgefirst_tensor::CpuAccess::ReadWrite,
11800            )
11801            .unwrap();
11802            src.as_u8_mut()
11803                .unwrap()
11804                .map_mut()
11805                .unwrap()
11806                .as_mut_slice()
11807                .copy_from_slice(&checker);
11808            let mut dst = TensorDyn::image(
11809                12,
11810                12,
11811                PixelFormat::Rgba,
11812                DType::U8,
11813                Some(TensorMemory::Mem),
11814                edgefirst_tensor::CpuAccess::ReadWrite,
11815            )
11816            .unwrap();
11817            processor
11818                .convert(&src, &mut dst, Rotation::None, Flip::None, Crop::no_crop())
11819                .unwrap();
11820            dst.as_u8().unwrap().map().unwrap().as_slice().to_vec()
11821        };
11822        let nearest = resize(&mut CPUProcessor::new_nearest());
11823        let bilinear = resize(&mut CPUProcessor::new());
11824        assert_ne!(nearest, bilinear, "the checker must tell the filters apart");
11825        let config = |interpolation| ImageProcessorConfig {
11826            backend: ComputeBackend::Cpu,
11827            interpolation,
11828            ..Default::default()
11829        };
11830
11831        unsafe { std::env::remove_var("EDGEFIRST_INTERPOLATION") };
11832        let mut p = ImageProcessor::with_config(config(Interpolation::Nearest)).unwrap();
11833        assert_eq!(resize(&mut p), nearest, "config Nearest");
11834        p.set_interpolation(Interpolation::Bilinear).unwrap();
11835        assert_eq!(resize(&mut p), bilinear, "set_interpolation(Bilinear)");
11836
11837        unsafe { std::env::set_var("EDGEFIRST_INTERPOLATION", "nearest") };
11838        let mut p = ImageProcessor::with_config(config(Interpolation::Bilinear)).unwrap();
11839        assert_eq!(
11840            resize(&mut p),
11841            nearest,
11842            "EDGEFIRST_INTERPOLATION pins Nearest"
11843        );
11844        p.set_interpolation(Interpolation::Bilinear).unwrap();
11845        assert_eq!(
11846            resize(&mut p),
11847            nearest,
11848            "the env pin outlives set_interpolation"
11849        );
11850    }
11851
11852    /// Run `body` with `EDGEFIRST_FORCE_BACKEND` temporarily set (or
11853    /// removed), restoring the prior value afterward. Tests are env-
11854    /// serialized via the process-wide `ENV_MUTEX`.
11855    fn with_force_backend<R>(value: Option<&str>, body: impl FnOnce() -> R) -> R {
11856        let _lock = acquire_env_lock();
11857        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
11858        match value {
11859            Some(v) => unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", v) },
11860            None => unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") },
11861        }
11862        body()
11863    }
11864
11865    /// Allocate an RGBA image tensor and pre-fill every byte with a
11866    /// distinctive non-zero pattern. Any test that relies on the old
11867    /// "dst is already cleared" assumption will see this pattern leak
11868    /// through to the output and fail.
11869    fn make_dirty_dst(w: usize, h: usize, mem: Option<TensorMemory>) -> TensorDyn {
11870        let dst = TensorDyn::image(
11871            w,
11872            h,
11873            PixelFormat::Rgba,
11874            DType::U8,
11875            mem,
11876            edgefirst_tensor::CpuAccess::ReadWrite,
11877        )
11878        .unwrap();
11879        {
11880            use edgefirst_tensor::TensorMapTrait;
11881            let u8t = dst.as_typed::<u8>().unwrap();
11882            let mut map = u8t.map().unwrap();
11883            for (i, b) in map.as_mut_slice().iter_mut().enumerate() {
11884                *b = 0xA0u8.wrapping_add((i as u8) & 0x3F);
11885            }
11886        }
11887        dst
11888    }
11889
11890    /// Allocate an RGBA background filled with a constant colour.
11891    fn make_bg(w: usize, h: usize, mem: Option<TensorMemory>, rgba: [u8; 4]) -> TensorDyn {
11892        let bg = TensorDyn::image(
11893            w,
11894            h,
11895            PixelFormat::Rgba,
11896            DType::U8,
11897            mem,
11898            edgefirst_tensor::CpuAccess::ReadWrite,
11899        )
11900        .unwrap();
11901        {
11902            use edgefirst_tensor::TensorMapTrait;
11903            let u8t = bg.as_typed::<u8>().unwrap();
11904            let mut map = u8t.map().unwrap();
11905            for chunk in map.as_mut_slice().as_chunks_mut::<4>().0 {
11906                chunk.copy_from_slice(&rgba);
11907            }
11908        }
11909        bg
11910    }
11911
11912    fn pixel_at(dst: &TensorDyn, x: usize, y: usize) -> [u8; 4] {
11913        use edgefirst_tensor::TensorMapTrait;
11914        let w = dst.width().unwrap();
11915        let off = (y * w + x) * 4;
11916        let u8t = dst.as_typed::<u8>().unwrap();
11917        let map = u8t.map().unwrap();
11918        let s = map.as_slice();
11919        [s[off], s[off + 1], s[off + 2], s[off + 3]]
11920    }
11921
11922    fn assert_every_pixel_eq(dst: &TensorDyn, expected: [u8; 4], case: &str) {
11923        use edgefirst_tensor::TensorMapTrait;
11924        let u8t = dst.as_typed::<u8>().unwrap();
11925        let map = u8t.map().unwrap();
11926        for (i, chunk) in map.as_slice().as_chunks::<4>().0.iter().enumerate() {
11927            assert_eq!(
11928                chunk, &expected,
11929                "{case}: pixel idx {i} = {chunk:?}, expected {expected:?}"
11930            );
11931        }
11932    }
11933
11934    /// Scenario 1: empty detections, empty segmentation, no background
11935    /// → dst must be fully cleared to 0x00000000.
11936    fn scenario_empty_no_bg(processor: &mut ImageProcessor, case: &str) {
11937        let mut dst = make_dirty_dst(64, 64, None);
11938        processor
11939            .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::default())
11940            .unwrap_or_else(|e| panic!("{case}/decoded_masks empty+no-bg failed: {e:?}"));
11941        assert_every_pixel_eq(&dst, [0, 0, 0, 0], &format!("{case}/decoded"));
11942
11943        let mut dst = make_dirty_dst(64, 64, None);
11944        let proto = {
11945            use edgefirst_tensor::{Tensor, TensorDyn};
11946            // Placeholder (no detections); shape [1, 4] to keep the tensor well-formed.
11947            let coeff_t = Tensor::<f32>::from_slice(&[0.0_f32; 4], &[1, 4]).unwrap();
11948            let protos_t =
11949                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
11950            ProtoData {
11951                mask_coefficients: TensorDyn::F32(coeff_t),
11952                protos: TensorDyn::F32(protos_t),
11953                layout: ProtoLayout::Nhwc,
11954            }
11955        };
11956        processor
11957            .draw_proto_masks(&mut dst, &[], &proto, MaskOverlay::default())
11958            .unwrap_or_else(|e| panic!("{case}/proto_masks empty+no-bg failed: {e:?}"));
11959        assert_every_pixel_eq(&dst, [0, 0, 0, 0], &format!("{case}/proto"));
11960    }
11961
11962    /// Scenario 2: empty detections, empty segmentation, background set
11963    /// → dst must be fully equal to bg.
11964    fn scenario_empty_with_bg(processor: &mut ImageProcessor, case: &str) {
11965        let bg_color = [42, 99, 200, 255];
11966        let bg = make_bg(64, 64, None, bg_color);
11967        let overlay = MaskOverlay::new().with_background(&bg);
11968
11969        let mut dst = make_dirty_dst(64, 64, None);
11970        processor
11971            .draw_decoded_masks(&mut dst, &[], &[], overlay)
11972            .unwrap_or_else(|e| panic!("{case}/decoded_masks empty+bg failed: {e:?}"));
11973        assert_every_pixel_eq(&dst, bg_color, &format!("{case}/decoded bg blit"));
11974
11975        let mut dst = make_dirty_dst(64, 64, None);
11976        let proto = {
11977            use edgefirst_tensor::{Tensor, TensorDyn};
11978            // Placeholder (no detections); shape [1, 4] to keep the tensor well-formed.
11979            let coeff_t = Tensor::<f32>::from_slice(&[0.0_f32; 4], &[1, 4]).unwrap();
11980            let protos_t =
11981                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
11982            ProtoData {
11983                mask_coefficients: TensorDyn::F32(coeff_t),
11984                protos: TensorDyn::F32(protos_t),
11985                layout: ProtoLayout::Nhwc,
11986            }
11987        };
11988        processor
11989            .draw_proto_masks(&mut dst, &[], &proto, overlay)
11990            .unwrap_or_else(|e| panic!("{case}/proto_masks empty+bg failed: {e:?}"));
11991        assert_every_pixel_eq(&dst, bg_color, &format!("{case}/proto bg blit"));
11992    }
11993
11994    /// Scenario 3: one detection with a fully-opaque segmentation fill,
11995    /// no background → outside the box dst must be 0x00, inside it must
11996    /// be a non-zero mask colour (the render_segmentation output).
11997    fn scenario_detect_no_bg(processor: &mut ImageProcessor, case: &str) {
11998        use edgefirst_tensor::Segmentation;
11999        use ndarray::Array3;
12000        processor
12001            .set_class_colors(&[[200, 80, 40, 255]])
12002            .expect("set_class_colors");
12003
12004        let detect = DetectBox {
12005            bbox: [0.25, 0.25, 0.75, 0.75].into(),
12006            score: 0.99,
12007            label: 0,
12008        };
12009        let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
12010        let seg = Segmentation {
12011            segmentation: edgefirst_tensor::Tensor::from_arrayview3(seg_arr.view())
12012                .unwrap()
12013                .into(),
12014            xmin: 0.25,
12015            ymin: 0.25,
12016            xmax: 0.75,
12017            ymax: 0.75,
12018        };
12019
12020        let mut dst = make_dirty_dst(64, 64, None);
12021        processor
12022            .draw_decoded_masks(&mut dst, &[detect], &[seg], MaskOverlay::default())
12023            .unwrap_or_else(|e| panic!("{case}/decoded_masks detect+no-bg failed: {e:?}"));
12024
12025        // Outside the bbox (corner): must be cleared black.
12026        let corner = pixel_at(&dst, 2, 2);
12027        assert_eq!(
12028            corner,
12029            [0, 0, 0, 0],
12030            "{case}/decoded: corner (2,2) leaked dirty pattern: {corner:?}"
12031        );
12032        // Inside the bbox (center): the mask colour must be visible.
12033        // Any non-zero pixel is acceptable — exact rendering varies
12034        // between backends (GL smoothstep, CPU nearest).
12035        let center = pixel_at(&dst, 32, 32);
12036        assert!(
12037            center != [0, 0, 0, 0],
12038            "{case}/decoded: center (32,32) was not coloured: {center:?}"
12039        );
12040    }
12041
12042    /// Scenario 4: detection + background. Outside the box must match
12043    /// bg; inside the box must NOT match bg (mask blended on top).
12044    fn scenario_detect_with_bg(processor: &mut ImageProcessor, case: &str) {
12045        use edgefirst_tensor::Segmentation;
12046        use ndarray::Array3;
12047        processor
12048            .set_class_colors(&[[200, 80, 40, 255]])
12049            .expect("set_class_colors");
12050        let bg_color = [10, 20, 30, 255];
12051        let bg = make_bg(64, 64, None, bg_color);
12052
12053        let detect = DetectBox {
12054            bbox: [0.25, 0.25, 0.75, 0.75].into(),
12055            score: 0.99,
12056            label: 0,
12057        };
12058        let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
12059        let seg = Segmentation {
12060            segmentation: edgefirst_tensor::Tensor::from_arrayview3(seg_arr.view())
12061                .unwrap()
12062                .into(),
12063            xmin: 0.25,
12064            ymin: 0.25,
12065            xmax: 0.75,
12066            ymax: 0.75,
12067        };
12068
12069        let overlay = MaskOverlay::new().with_background(&bg);
12070        let mut dst = make_dirty_dst(64, 64, None);
12071        processor
12072            .draw_decoded_masks(&mut dst, &[detect], &[seg], overlay)
12073            .unwrap_or_else(|e| panic!("{case}/decoded_masks detect+bg failed: {e:?}"));
12074
12075        // Outside the bbox (corner): bg colour.
12076        let corner = pixel_at(&dst, 2, 2);
12077        assert_eq!(
12078            corner, bg_color,
12079            "{case}/decoded: corner (2,2) should show bg {bg_color:?} got {corner:?}"
12080        );
12081        // Inside the bbox (center): mask blended on bg, must differ from
12082        // pure bg (alpha-blend with mask colour produces a distinct shade).
12083        let center = pixel_at(&dst, 32, 32);
12084        assert!(
12085            center != bg_color,
12086            "{case}/decoded: center (32,32) should differ from bg {bg_color:?}, got {center:?}"
12087        );
12088    }
12089
12090    /// Run all 4 scenarios against the processor. Skip gracefully if
12091    /// construction fails (backend unavailable on this host).
12092    fn run_all_scenarios(
12093        force_backend: Option<&'static str>,
12094        case: &'static str,
12095        require_dma_for_bg: bool,
12096    ) {
12097        if require_dma_for_bg && !edgefirst_tensor::is_dma_available() {
12098            crate::test_support::report_skip(&format!("{case} — DMA not available on this host"));
12099            return;
12100        }
12101        let processor_result = with_force_backend(force_backend, ImageProcessor::new);
12102        let mut processor = match processor_result {
12103            Ok(p) => p,
12104            Err(e) => {
12105                crate::test_support::report_skip(&format!("{case} — backend init failed: {e:?}"));
12106                return;
12107            }
12108        };
12109        scenario_empty_no_bg(&mut processor, case);
12110        scenario_empty_with_bg(&mut processor, case);
12111        scenario_detect_no_bg(&mut processor, case);
12112        scenario_detect_with_bg(&mut processor, case);
12113    }
12114
12115    #[test]
12116    fn test_draw_masks_4_scenarios_cpu() {
12117        run_all_scenarios(Some("cpu"), "cpu", false);
12118    }
12119
12120    #[test]
12121    fn test_draw_masks_4_scenarios_auto() {
12122        run_all_scenarios(None, "auto", false);
12123    }
12124
12125    #[cfg(any(
12126        target_os = "linux",
12127        target_os = "macos",
12128        target_os = "ios",
12129        target_os = "android",
12130        target_os = "windows"
12131    ))]
12132    #[cfg(feature = "opengl")]
12133    #[test]
12134    fn test_draw_masks_4_scenarios_opengl() {
12135        run_all_scenarios(Some("opengl"), "opengl", false);
12136    }
12137
12138    /// G2D forced backend: exercises the zero-detection empty-frame
12139    /// paths via `g2d_clear` and `g2d_blit`. Scenarios 3 and 4 (with
12140    /// detections) expect `NotImplemented` since G2D has no rasterizer
12141    /// for boxes / masks.
12142    #[cfg(target_os = "linux")]
12143    #[test]
12144    fn test_draw_masks_zero_detection_g2d_forced() {
12145        if !edgefirst_tensor::is_dma_available() {
12146            crate::test_support::report_skip("g2d forced — DMA not available on this host");
12147            return;
12148        }
12149        let processor_result = with_force_backend(Some("g2d"), ImageProcessor::new);
12150        let mut processor = match processor_result {
12151            Ok(p) => p,
12152            Err(e) => {
12153                crate::test_support::report_skip(&format!("g2d forced — init failed: {e:?}"));
12154                return;
12155            }
12156        };
12157
12158        // Case 1: empty + no bg. G2D requires DMA-backed dst.
12159        let mut dst = TensorDyn::image(
12160            64,
12161            64,
12162            PixelFormat::Rgba,
12163            DType::U8,
12164            Some(TensorMemory::DmaBuf),
12165            edgefirst_tensor::CpuAccess::ReadWrite,
12166        )
12167        .unwrap();
12168        {
12169            use edgefirst_tensor::TensorMapTrait;
12170            let u8t = dst.as_typed_mut::<u8>().unwrap();
12171            let mut map = u8t.map().unwrap();
12172            map.as_mut_slice().fill(0xBB);
12173        }
12174        processor
12175            .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::default())
12176            .expect("g2d empty+no-bg");
12177        assert_every_pixel_eq(&dst, [0, 0, 0, 0], "g2d/case1 cleared");
12178
12179        // Case 2: empty + bg. Both surfaces DMA-backed for g2d_blit.
12180        let bg_color = [7, 11, 13, 255];
12181        let bg = {
12182            let t = TensorDyn::image(
12183                64,
12184                64,
12185                PixelFormat::Rgba,
12186                DType::U8,
12187                Some(TensorMemory::DmaBuf),
12188                edgefirst_tensor::CpuAccess::ReadWrite,
12189            )
12190            .unwrap();
12191            {
12192                use edgefirst_tensor::TensorMapTrait;
12193                let u8t = t.as_typed::<u8>().unwrap();
12194                let mut map = u8t.map().unwrap();
12195                for chunk in map.as_mut_slice().as_chunks_mut::<4>().0 {
12196                    chunk.copy_from_slice(&bg_color);
12197                }
12198            }
12199            t
12200        };
12201        let mut dst = TensorDyn::image(
12202            64,
12203            64,
12204            PixelFormat::Rgba,
12205            DType::U8,
12206            Some(TensorMemory::DmaBuf),
12207            edgefirst_tensor::CpuAccess::ReadWrite,
12208        )
12209        .unwrap();
12210        {
12211            use edgefirst_tensor::TensorMapTrait;
12212            let u8t = dst.as_typed_mut::<u8>().unwrap();
12213            let mut map = u8t.map().unwrap();
12214            map.as_mut_slice().fill(0x55);
12215        }
12216        processor
12217            .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::new().with_background(&bg))
12218            .expect("g2d empty+bg");
12219        assert_every_pixel_eq(&dst, bg_color, "g2d/case2 bg blit");
12220
12221        // Case 3 and 4: detect present — must return NotImplemented.
12222        let detect = DetectBox {
12223            bbox: [0.25, 0.25, 0.75, 0.75].into(),
12224            score: 0.9,
12225            label: 0,
12226        };
12227        let mut dst = TensorDyn::image(
12228            64,
12229            64,
12230            PixelFormat::Rgba,
12231            DType::U8,
12232            Some(TensorMemory::DmaBuf),
12233            edgefirst_tensor::CpuAccess::ReadWrite,
12234        )
12235        .unwrap();
12236        let err = processor
12237            .draw_decoded_masks(&mut dst, &[detect], &[], MaskOverlay::default())
12238            .expect_err("g2d must reject detect-present draw_decoded_masks");
12239        assert!(
12240            matches!(err, Error::NotImplemented(_)),
12241            "g2d case3 wrong error: {err:?}"
12242        );
12243    }
12244
12245    #[test]
12246    fn test_set_format_then_cpu_convert() {
12247        // Force CPU backend; serialized under ENV_MUTEX to avoid racing with
12248        // test_force_backend_* and test_disable_env_var.
12249        let _lock = acquire_env_lock();
12250        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
12251        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
12252        let mut processor = ImageProcessor::new().unwrap();
12253
12254        // Load a source image
12255        let image = edgefirst_bench::testdata::read("zidane.jpg");
12256        let src = load_image_test_helper(&image, Some(PixelFormat::Rgba), None).unwrap();
12257
12258        // Create a raw tensor, then attach format — simulating the from_fd workflow
12259        let mut dst =
12260            TensorDyn::new(&[640, 640, 3], DType::U8, Some(TensorMemory::Mem), None).unwrap();
12261        dst.set_format(PixelFormat::Rgb).unwrap();
12262
12263        // Convert should work with the set_format-annotated tensor
12264        processor
12265            .convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
12266            .unwrap();
12267
12268        // Verify format survived conversion
12269        assert_eq!(dst.format(), Some(PixelFormat::Rgb));
12270        assert_eq!(dst.width(), Some(640));
12271        assert_eq!(dst.height(), Some(640));
12272    }
12273
12274    /// Verify that creating multiple ImageProcessors on the same thread and
12275    /// performing a resize on each does not deadlock or error.
12276    ///
12277    /// Uses automatic memory allocation (DMA → PBO → Mem fallback) so that
12278    /// hardware backends (OpenGL, G2D) are exercised on capable targets.
12279    #[test]
12280    fn test_multiple_image_processors_same_thread() {
12281        // Hold the env mutex so env-var-mutating tests can't corrupt the state
12282        // seen by ImageProcessor::new() calls during this test.
12283        let _lock = acquire_env_lock();
12284        let mut processors: Vec<ImageProcessor> = (0..4)
12285            .map(|_| ImageProcessor::new().expect("ImageProcessor::new() failed"))
12286            .collect();
12287
12288        for proc in &mut processors {
12289            let src = proc
12290                .create_image(
12291                    128,
12292                    128,
12293                    PixelFormat::Rgb,
12294                    DType::U8,
12295                    None,
12296                    edgefirst_tensor::CpuAccess::ReadWrite,
12297                )
12298                .expect("create src failed");
12299            let mut dst = proc
12300                .create_image(
12301                    64,
12302                    64,
12303                    PixelFormat::Rgb,
12304                    DType::U8,
12305                    None,
12306                    edgefirst_tensor::CpuAccess::ReadWrite,
12307                )
12308                .expect("create dst failed");
12309            proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
12310                .expect("convert failed");
12311            assert_eq!(dst.width(), Some(64));
12312            assert_eq!(dst.height(), Some(64));
12313        }
12314    }
12315
12316    /// Verify that creating ImageProcessors on separate threads and performing
12317    /// a resize on each does not deadlock or error.
12318    ///
12319    /// Uses automatic memory allocation (DMA → PBO → Mem fallback) so that
12320    /// hardware backends (OpenGL, G2D) are exercised on capable targets.
12321    /// A 60-second timeout prevents CI from hanging on deadlock regressions.
12322    #[test]
12323    fn test_multiple_image_processors_separate_threads() {
12324        use std::sync::mpsc;
12325        use std::time::Duration;
12326
12327        // The Vivante GC7000UL driver (i.MX 8M Plus) double-frees on concurrent
12328        // EGL context teardown — four processors spun up on four threads here
12329        // trips it and aborts the whole test binary (SIGABRT, not a catchable
12330        // panic). The bug is the driver's, not the HAL's; this test is kept so
12331        // it still exercises the multi-context path on every other GPU. The
12332        // board lane and scripts/on-target-test.sh set
12333        // EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS on any board with /dev/galcore
12334        // to skip just this case there, while the run stays red anywhere else
12335        // a regression appears. (Skip, not #[ignore]: the platform is decided
12336        // at runtime, not compile time.)
12337        if std::env::var_os("EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS").is_some() {
12338            crate::test_support::report_skip(
12339                "test_multiple_image_processors_separate_threads — known Vivante \
12340                 GC7000UL concurrent-EGL-teardown double-free \
12341                 (EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS set)",
12342            );
12343            return;
12344        }
12345
12346        const TIMEOUT: Duration = Duration::from_secs(60);
12347
12348        // Hold the env mutex so env-var-mutating tests can't corrupt ImageProcessor::new()
12349        // calls made inside the spawned threads during this test.
12350        let _lock = acquire_env_lock();
12351
12352        let (tx, rx) = mpsc::channel::<()>();
12353
12354        std::thread::spawn(move || {
12355            let handles: Vec<_> = (0..4)
12356                .map(|i| {
12357                    std::thread::spawn(move || {
12358                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
12359                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
12360                        });
12361                        let src = proc
12362                            .create_image(
12363                                128,
12364                                128,
12365                                PixelFormat::Rgb,
12366                                DType::U8,
12367                                None,
12368                                edgefirst_tensor::CpuAccess::ReadWrite,
12369                            )
12370                            .unwrap_or_else(|e| panic!("create src failed on thread {i}: {e}"));
12371                        let mut dst = proc
12372                            .create_image(
12373                                64,
12374                                64,
12375                                PixelFormat::Rgb,
12376                                DType::U8,
12377                                None,
12378                                edgefirst_tensor::CpuAccess::ReadWrite,
12379                            )
12380                            .unwrap_or_else(|e| panic!("create dst failed on thread {i}: {e}"));
12381                        proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
12382                            .unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
12383                        assert_eq!(dst.width(), Some(64));
12384                        assert_eq!(dst.height(), Some(64));
12385                    })
12386                })
12387                .collect();
12388
12389            for (i, h) in handles.into_iter().enumerate() {
12390                h.join()
12391                    .unwrap_or_else(|e| panic!("thread {i} panicked: {e:?}"));
12392            }
12393
12394            let _ = tx.send(());
12395        });
12396
12397        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
12398            panic!("test_multiple_image_processors_separate_threads timed out after {TIMEOUT:?}")
12399        });
12400    }
12401
12402    /// Verify that 4 fully-initialized ImageProcessors on separate threads can
12403    /// all operate concurrently without deadlocking each other.
12404    ///
12405    /// All processors are created first, then a barrier synchronizes them so
12406    /// they all start converting at the same instant — maximizing contention.
12407    /// A 60-second timeout prevents CI from hanging on deadlock regressions.
12408    #[test]
12409    fn test_image_processors_concurrent_operations() {
12410        use std::sync::{mpsc, Arc, Barrier};
12411        use std::time::Duration;
12412
12413        const N: usize = 4;
12414        const ROUNDS: usize = 10;
12415        const TIMEOUT: Duration = Duration::from_secs(60);
12416
12417        // Hold the env mutex so env-var-mutating tests can't corrupt ImageProcessor::new()
12418        // calls made inside the spawned threads during this test.
12419        let _lock = acquire_env_lock();
12420
12421        let (tx, rx) = mpsc::channel::<()>();
12422
12423        std::thread::spawn(move || {
12424            let barrier = Arc::new(Barrier::new(N));
12425
12426            let handles: Vec<_> = (0..N)
12427                .map(|i| {
12428                    let barrier = Arc::clone(&barrier);
12429                    std::thread::spawn(move || {
12430                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
12431                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
12432                        });
12433
12434                        // All threads wait here until every processor is initialized.
12435                        barrier.wait();
12436
12437                        // Now all 4 hammer the GPU concurrently.
12438                        for round in 0..ROUNDS {
12439                            let src = proc
12440                                .create_image(
12441                                    128,
12442                                    128,
12443                                    PixelFormat::Rgb,
12444                                    DType::U8,
12445                                    None,
12446                                    edgefirst_tensor::CpuAccess::ReadWrite,
12447                                )
12448                                .unwrap_or_else(|e| {
12449                                    panic!("create src failed on thread {i} round {round}: {e}")
12450                                });
12451                            let mut dst = proc
12452                                .create_image(
12453                                    64,
12454                                    64,
12455                                    PixelFormat::Rgb,
12456                                    DType::U8,
12457                                    None,
12458                                    edgefirst_tensor::CpuAccess::ReadWrite,
12459                                )
12460                                .unwrap_or_else(|e| {
12461                                    panic!("create dst failed on thread {i} round {round}: {e}")
12462                                });
12463                            proc.convert(
12464                                &src,
12465                                &mut dst,
12466                                Rotation::None,
12467                                Flip::None,
12468                                Crop::default(),
12469                            )
12470                            .unwrap_or_else(|e| {
12471                                panic!("convert failed on thread {i} round {round}: {e}")
12472                            });
12473                            assert_eq!(dst.width(), Some(64));
12474                            assert_eq!(dst.height(), Some(64));
12475                        }
12476                    })
12477                })
12478                .collect();
12479
12480            for (i, h) in handles.into_iter().enumerate() {
12481                h.join()
12482                    .unwrap_or_else(|e| panic!("thread {i} panicked: {e:?}"));
12483            }
12484
12485            let _ = tx.send(());
12486        });
12487
12488        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
12489            panic!("test_image_processors_concurrent_operations timed out after {TIMEOUT:?}")
12490        });
12491    }
12492
12493    /// THE parallel-processors demonstration test: 4 ImageProcessors on 4
12494    /// threads, each with its own GL context and worker, converting
12495    /// per-thread-DISTINCT synthetic inputs concurrently (barrier-released);
12496    /// every output must byte-match the thread's own pre-barrier sequential
12497    /// oracle from the same processor. On LifecycleOnly platforms (Mali,
12498    /// V3D, Tegra, llvmpipe, macOS) the converts genuinely overlap on the
12499    /// GPU; on Vivante they serialize via the Full policy and the test still
12500    /// must pass. Distinct inputs make any cross-processor state leakage
12501    /// (wrong texture, wrong context, clobbered upload) visible as a byte
12502    /// diff rather than a coincidental match — proven by a scratch
12503    /// cross-wire run (neighbor's input post-oracle) failing on every
12504    /// thread with ~53% of bytes diverged.
12505    ///
12506    /// Skipped under EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS like
12507    /// `test_multiple_image_processors_separate_threads`: the galcore driver
12508    /// can abort intermittently on concurrent multi-processor lifecycles
12509    /// regardless of locking (P0 spike: reproduces fully serialized).
12510    #[test]
12511    fn test_parallel_processors_unique_outputs() {
12512        use std::sync::{mpsc, Arc, Barrier};
12513        use std::time::Duration;
12514
12515        const N: usize = 4;
12516        const ROUNDS: usize = 25;
12517        const TIMEOUT: Duration = Duration::from_secs(60);
12518
12519        if std::env::var_os("EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS").is_some() {
12520            crate::test_support::report_skip(
12521                "test_parallel_processors_unique_outputs — known Vivante \
12522                 GC7000UL concurrent-multi-processor driver abort \
12523                 (EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS set)",
12524            );
12525            return;
12526        }
12527
12528        let _lock = acquire_env_lock();
12529        let (tx, rx) = mpsc::channel::<()>();
12530
12531        std::thread::spawn(move || {
12532            let barrier = Arc::new(Barrier::new(N));
12533            let handles: Vec<_> = (0..N)
12534                .map(|i| {
12535                    let barrier = Arc::clone(&barrier);
12536                    std::thread::spawn(move || {
12537                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
12538                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
12539                        });
12540                        // CI-weight geometry (llvmpipe renders on the CPU).
12541                        let (w, h) = (640usize, 480usize);
12542                        let mem = if edgefirst_tensor::is_dma_available() {
12543                            Some(TensorMemory::DmaBuf)
12544                        } else {
12545                            Some(TensorMemory::Mem)
12546                        };
12547                        let src = proc
12548                            .create_image(
12549                                w,
12550                                h,
12551                                PixelFormat::Nv12,
12552                                DType::U8,
12553                                mem,
12554                                edgefirst_tensor::CpuAccess::ReadWrite,
12555                            )
12556                            .unwrap();
12557                        {
12558                            let t = src.as_typed::<u8>().unwrap();
12559                            let mut m = t.map().unwrap();
12560                            let s = m.as_mut_slice();
12561                            for (j, b) in s[..w * h].iter_mut().enumerate() {
12562                                *b = ((i * 53 + j) % 200 + 16) as u8;
12563                            }
12564                            for b in &mut s[w * h..] {
12565                                *b = (80 + i * 24) as u8;
12566                            }
12567                        }
12568                        let lb = Crop::letterbox([114, 114, 114, 255]);
12569                        let convert_once = |proc: &mut ImageProcessor| -> Vec<u8> {
12570                            let mut dst = proc
12571                                .create_image(
12572                                    320,
12573                                    320,
12574                                    PixelFormat::Rgba,
12575                                    DType::U8,
12576                                    mem,
12577                                    edgefirst_tensor::CpuAccess::ReadWrite,
12578                                )
12579                                .unwrap();
12580                            proc.convert(&src, &mut dst, Rotation::None, Flip::None, lb)
12581                                .unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
12582                            let t = dst.as_typed::<u8>().unwrap();
12583                            let m = t.map().unwrap();
12584                            m.as_slice().to_vec()
12585                        };
12586
12587                        let oracle = convert_once(&mut proc);
12588                        barrier.wait();
12589                        for round in 0..ROUNDS {
12590                            let out = convert_once(&mut proc);
12591                            let diffs = oracle.iter().zip(&out).filter(|(a, b)| a != b).count();
12592                            assert!(
12593                                diffs == 0,
12594                                "thread {i} round {round}: {diffs}/{} bytes diverged \
12595                                 from this processor's own oracle — cross-processor \
12596                                 GL state leakage under parallel execution",
12597                                oracle.len()
12598                            );
12599                        }
12600                    })
12601                })
12602                .collect();
12603
12604            for (i, h) in handles.into_iter().enumerate() {
12605                h.join()
12606                    .unwrap_or_else(|e| panic!("parallel thread {i} panicked: {e:?}"));
12607            }
12608            let _ = tx.send(());
12609        });
12610
12611        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
12612            panic!("test_parallel_processors_unique_outputs timed out after {TIMEOUT:?}")
12613        });
12614    }
12615
12616    /// Heavy on-demand stressor for the GL serialization policy: 4
12617    /// processors × 4 threads × barrier × 200 NV12 720p → RGB 640 letterbox
12618    /// converts (DMA where available); every output must byte-match the
12619    /// thread's own pre-barrier sequential oracle from the same processor.
12620    /// Ignored by default (heavy; board tool — the CI-weight version is
12621    /// `test_parallel_processors_unique_outputs`). Run explicitly, optionally
12622    /// pinning the policy via EDGEFIRST_GL_SERIALIZE=full|lifecycle:
12623    ///   <test binary> stress_parallel_processors_oracle --ignored
12624    #[test]
12625    #[ignore = "heavy on-demand GL-parallelism stressor; run explicitly on boards"]
12626    fn stress_parallel_processors_oracle() {
12627        use std::sync::{mpsc, Arc, Barrier};
12628        use std::time::Duration;
12629
12630        const N: usize = 4;
12631        const ROUNDS: usize = 200;
12632        const TIMEOUT: Duration = Duration::from_secs(600);
12633
12634        let _lock = acquire_env_lock();
12635        let (tx, rx) = mpsc::channel::<()>();
12636
12637        std::thread::spawn(move || {
12638            let barrier = Arc::new(Barrier::new(N));
12639            let handles: Vec<_> = (0..N)
12640                .map(|i| {
12641                    let barrier = Arc::clone(&barrier);
12642                    std::thread::spawn(move || {
12643                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
12644                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
12645                        });
12646                        let (w, h) = (1280usize, 720usize);
12647                        let mem = if edgefirst_tensor::is_dma_available() {
12648                            Some(TensorMemory::DmaBuf)
12649                        } else {
12650                            Some(TensorMemory::Mem)
12651                        };
12652
12653                        // Per-thread-distinct synthetic NV12 so cross-wired
12654                        // GL state between processors shows up as a byte diff.
12655                        let src = proc
12656                            .create_image(
12657                                w,
12658                                h,
12659                                PixelFormat::Nv12,
12660                                DType::U8,
12661                                mem,
12662                                edgefirst_tensor::CpuAccess::ReadWrite,
12663                            )
12664                            .unwrap();
12665                        {
12666                            let t = src.as_typed::<u8>().unwrap();
12667                            let mut m = t.map().unwrap();
12668                            let s = m.as_mut_slice();
12669                            for (j, b) in s[..w * h].iter_mut().enumerate() {
12670                                *b = ((i * 37 + j) % 200 + 16) as u8;
12671                            }
12672                            for b in &mut s[w * h..] {
12673                                *b = (96 + i * 16) as u8;
12674                            }
12675                        }
12676                        let lb = Crop::letterbox([114, 114, 114, 255]);
12677
12678                        let convert_once = |proc: &mut ImageProcessor| -> Vec<u8> {
12679                            let mut dst = proc
12680                                .create_image(
12681                                    640,
12682                                    640,
12683                                    PixelFormat::Rgb,
12684                                    DType::U8,
12685                                    mem,
12686                                    edgefirst_tensor::CpuAccess::ReadWrite,
12687                                )
12688                                .unwrap();
12689                            proc.convert(&src, &mut dst, Rotation::None, Flip::None, lb)
12690                                .unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
12691                            let t = dst.as_typed::<u8>().unwrap();
12692                            let m = t.map().unwrap();
12693                            m.as_slice().to_vec()
12694                        };
12695
12696                        let oracle = convert_once(&mut proc);
12697                        barrier.wait();
12698                        for round in 0..ROUNDS {
12699                            let out = convert_once(&mut proc);
12700                            let diffs = oracle.iter().zip(&out).filter(|(a, b)| a != b).count();
12701                            assert!(
12702                                diffs == 0,
12703                                "thread {i} round {round}: {diffs}/{} bytes diverged \
12704                                 from the pre-barrier oracle",
12705                                oracle.len()
12706                            );
12707                        }
12708                    })
12709                })
12710                .collect();
12711
12712            for (i, h) in handles.into_iter().enumerate() {
12713                h.join()
12714                    .unwrap_or_else(|e| panic!("stressor thread {i} panicked: {e:?}"));
12715            }
12716            let _ = tx.send(());
12717        });
12718
12719        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
12720            panic!("stress_parallel_processors_oracle timed out after {TIMEOUT:?}")
12721        });
12722    }
12723
12724    // =========================================================================
12725    // F16 / F32 auto-chain fallback integration tests
12726    // =========================================================================
12727
12728    /// Proves the auto-chain (OpenGL → G2D → CPU) NEVER errors for a float
12729    /// combo the GL path does NOT cover.
12730    ///
12731    /// `Yuyv → Rgb F32` is not handled by the GL float render path (which
12732    /// only covers `Rgba → PlanarRgb F16` and `Rgba → Rgb F32`), so the
12733    /// chain falls through to the CPU float path. Before commit 868a7649
12734    /// added CPU U8→F32/F16 support this would have returned `Err`; now it
12735    /// must return `Ok` with output in `[0, 1]` and all values finite.
12736    #[test]
12737    fn convert_f32_auto_never_errors_non_gl_combo() {
12738        const W: usize = 64;
12739        const H: usize = 64;
12740
12741        // Build a small synthetic YUYV source (Y=128, U=128, V=128 → near-grey).
12742        // YUYV packs two pixels into 4 bytes: [Y0, U, Y1, V] per macropixel.
12743        let src = TensorDyn::image(
12744            W,
12745            H,
12746            PixelFormat::Yuyv,
12747            DType::U8,
12748            Some(TensorMemory::Mem),
12749            edgefirst_tensor::CpuAccess::ReadWrite,
12750        )
12751        .unwrap();
12752        {
12753            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
12754            let data = map.as_mut_slice();
12755            for chunk in data.as_chunks_mut::<4>().0 {
12756                chunk[0] = 128; // Y0
12757                chunk[1] = 128; // U
12758                chunk[2] = 160; // Y1 — distinct so a layout bug is visible
12759                chunk[3] = 128; // V
12760            }
12761        }
12762
12763        let mut dst = TensorDyn::image(
12764            W,
12765            H,
12766            PixelFormat::Rgb,
12767            DType::F32,
12768            Some(TensorMemory::Mem),
12769            edgefirst_tensor::CpuAccess::ReadWrite,
12770        )
12771        .unwrap();
12772
12773        let mut proc = ImageProcessor::new().unwrap();
12774        let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
12775        assert!(
12776            result.is_ok(),
12777            "auto-chain Yuyv→Rgb F32 must not error: {:?}",
12778            result.err()
12779        );
12780
12781        // Verify all output values are finite and in [0, 1].
12782        let map = dst.as_typed::<f32>().unwrap().map().unwrap();
12783        let floats = map.as_slice();
12784        assert_eq!(floats.len(), W * H * 3, "unexpected output element count");
12785        for (i, &v) in floats.iter().enumerate() {
12786            assert!(
12787                v.is_finite() && (0.0..=1.0).contains(&v),
12788                "output[{i}]={v} is not finite or not in [0,1]"
12789            );
12790        }
12791
12792        // WEAK-1: Anti-all-zero spot-check.  A Y=128 YUYV source normalises to
12793        // ≈0.502 on the luma channel.  If the buffer is all-zero (e.g. the CPU
12794        // path never wrote to it) this assertion catches the regression.
12795        let first_non_zero = floats.iter().find(|&&v| v > 0.01);
12796        assert!(
12797            first_non_zero.is_some(),
12798            "all-zero output detected — CPU path likely did not write to the destination buffer"
12799        );
12800        // Y=128 → luma ≈ 0.502.  Spot-check the first pixel's R channel
12801        // (which carries luma for a near-grey YUV source).
12802        let r0 = floats[0];
12803        assert!(
12804            (r0 - 0.502_f32).abs() < 0.05,
12805            "first pixel R={r0} expected ≈0.502 (Y=128 neutral grey from YUYV source)"
12806        );
12807    }
12808
12809    /// Proves CPU-forced `Rgba → PlanarRgb F16` correctness.
12810    ///
12811    /// Uses a source with clearly distinct per-channel values so a
12812    /// plane-swap or layout bug surfaces immediately. Tolerance is 2^-9
12813    /// (one F16 ULP at 0.5, i.e. roughly 1/512).
12814    #[test]
12815    // `ImageProcessorConfig` carries a Linux-only `egl_display` field, so
12816    // `{ backend, ..Default::default() }` is a genuine update on Linux but
12817    // covers no remaining fields on macOS, where `clippy::needless_update`
12818    // then fires. `allow` (not `expect`) because the lint is platform-
12819    // conditional — it does not fire on Linux.
12820    #[allow(clippy::needless_update)]
12821    fn convert_f16_forced_cpu_correct() {
12822        const W: usize = 16;
12823        const H: usize = 16;
12824        const TOL: f32 = 1.0 / 512.0; // 2^-9
12825
12826        // pixel (y,x): R = 50+x, G = 100+y*8, B = 200
12827        let src = TensorDyn::image(
12828            W,
12829            H,
12830            PixelFormat::Rgba,
12831            DType::U8,
12832            Some(TensorMemory::Mem),
12833            edgefirst_tensor::CpuAccess::ReadWrite,
12834        )
12835        .unwrap();
12836        {
12837            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
12838            let data = map.as_mut_slice();
12839            for y in 0..H {
12840                for x in 0..W {
12841                    let i = y * W + x;
12842                    data[i * 4] = (50 + x) as u8; // R: 50..65
12843                    data[i * 4 + 1] = (100 + y * 8) as u8; // G: 100..220
12844                    data[i * 4 + 2] = 200; // B: constant
12845                    data[i * 4 + 3] = 255;
12846                }
12847            }
12848        }
12849
12850        let mut dst = TensorDyn::image(
12851            W,
12852            H,
12853            PixelFormat::PlanarRgb,
12854            DType::F16,
12855            Some(TensorMemory::Mem),
12856            edgefirst_tensor::CpuAccess::ReadWrite,
12857        )
12858        .unwrap();
12859
12860        let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
12861            backend: ComputeBackend::Cpu,
12862            ..Default::default()
12863        })
12864        .unwrap();
12865        proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
12866            .expect("forced-CPU Rgba→PlanarRgb F16 must not error");
12867
12868        let src_map = src.as_typed::<u8>().unwrap().map().unwrap();
12869        let src_bytes = src_map.as_slice();
12870        let dst_map = dst.as_typed::<half::f16>().unwrap().map().unwrap();
12871        let dst_halfs = dst_map.as_slice();
12872
12873        let plane = W * H;
12874        assert_eq!(dst_halfs.len(), plane * 3, "wrong output element count");
12875
12876        for y in 0..H {
12877            for x in 0..W {
12878                let i = y * W + x;
12879                let r_exp = src_bytes[i * 4] as f32 / 255.0;
12880                let g_exp = src_bytes[i * 4 + 1] as f32 / 255.0;
12881                let b_exp = src_bytes[i * 4 + 2] as f32 / 255.0;
12882
12883                let r_got = dst_halfs[i].to_f32();
12884                let g_got = dst_halfs[plane + i].to_f32();
12885                let b_got = dst_halfs[2 * plane + i].to_f32();
12886
12887                assert!(
12888                    (r_got - r_exp).abs() <= TOL,
12889                    "R plane ({x},{y}): got {r_got}, expected {r_exp}"
12890                );
12891                assert!(
12892                    (g_got - g_exp).abs() <= TOL,
12893                    "G plane ({x},{y}): got {g_got}, expected {g_exp}"
12894                );
12895                assert!(
12896                    (b_got - b_exp).abs() <= TOL,
12897                    "B plane ({x},{y}): got {b_got}, expected {b_exp}"
12898                );
12899
12900                // Catch plane-swap: R and G must differ (they have different formulas).
12901                if src_bytes[i * 4] != src_bytes[i * 4 + 1] {
12902                    assert_ne!(r_got, g_got, "R and G planes must differ at ({x},{y})");
12903                }
12904            }
12905        }
12906    }
12907
12908    /// Proves the auto-chain falls through to CPU for `Rgba → Rgb F32` with
12909    /// rotation set.
12910    ///
12911    /// The GL float render path rejects any call where rotation ≠ None,
12912    /// returning an error that causes the chain to continue. Before the CPU
12913    /// float fallback this would have produced an error at the end of the
12914    /// chain; now it must reach CPU and return `Ok` with finite `[0, 1]` output.
12915    #[test]
12916    fn convert_f32_with_rotation_falls_back() {
12917        const W: usize = 16;
12918        const H: usize = 16;
12919
12920        // RGBA8 source with a known gradient (distinct per-channel values).
12921        let src = TensorDyn::image(
12922            W,
12923            H,
12924            PixelFormat::Rgba,
12925            DType::U8,
12926            Some(TensorMemory::Mem),
12927            edgefirst_tensor::CpuAccess::ReadWrite,
12928        )
12929        .unwrap();
12930        {
12931            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
12932            let data = map.as_mut_slice();
12933            for y in 0..H {
12934                for x in 0..W {
12935                    let i = y * W + x;
12936                    data[i * 4] = (x * 16) as u8; // R
12937                    data[i * 4 + 1] = (y * 16) as u8; // G
12938                    data[i * 4 + 2] = 128; // B
12939                    data[i * 4 + 3] = 255;
12940                }
12941            }
12942        }
12943
12944        // Rotation swaps W and H, so dst is [W, H] (H×W output).
12945        let mut dst = TensorDyn::image(
12946            H, // dst W = src H after 90° rotation
12947            W, // dst H = src W after 90° rotation
12948            PixelFormat::Rgb,
12949            DType::F32,
12950            Some(TensorMemory::Mem),
12951            edgefirst_tensor::CpuAccess::ReadWrite,
12952        )
12953        .unwrap();
12954
12955        let mut proc = ImageProcessor::new().unwrap();
12956        let result = proc.convert(
12957            &src,
12958            &mut dst,
12959            Rotation::Clockwise90,
12960            Flip::None,
12961            Crop::default(),
12962        );
12963        assert!(
12964            result.is_ok(),
12965            "auto-chain Rgba→Rgb F32 with Rot90 must not error: {:?}",
12966            result.err()
12967        );
12968
12969        let map = dst.as_typed::<f32>().unwrap().map().unwrap();
12970        let floats = map.as_slice();
12971        assert_eq!(floats.len(), H * W * 3, "unexpected output element count");
12972        for (i, &v) in floats.iter().enumerate() {
12973            assert!(
12974                v.is_finite() && (0.0..=1.0).contains(&v),
12975                "output[{i}]={v} is not finite or not in [0,1]"
12976            );
12977        }
12978    }
12979
12980    /// GL-vs-CPU identity parity for `Rgba → PlanarRgb F16`.
12981    ///
12982    /// Converts the same RGBA8 source via the `OpenGl` backend and the
12983    /// forced `Cpu` backend, then verifies the two F16 output tensors agree
12984    /// element-wise within 2^-8 (two F16 ULPs at 0.5). Skipped when OpenGL
12985    /// or F16 render is unavailable, and when the host cannot hold the
12986    /// zero-copy F16 destination the GL float route needs.
12987    ///
12988    /// **The GL route is asserted, not assumed** (issue #179).
12989    /// `ComputeBackend::OpenGl` is not a forced backend: it leaves
12990    /// `forced_backend` at `None`, so a GL decline logs at debug and the CPU
12991    /// serves the same convert. Both sides of the comparison would then be
12992    /// the CPU's answer and the parity claim would be a tautology — which is
12993    /// exactly how a host whose RGBA16F FBO is incomplete (desktop NVIDIA:
12994    /// `GL_FRAMEBUFFER_INCOMPLETE_ATTACHMENT`) passed while proving nothing,
12995    /// `supported_render_dtypes().f16` above notwithstanding: that flag is
12996    /// an extension query, not a completed render. `convert_fallback_count()`
12997    /// staying flat across the GL convert is the proof, and a decline is
12998    /// reported as a skip rather than a failure because it is a property of
12999    /// the host's driver, not of this code — except under
13000    /// `HAL_TEST_REQUIRE_GL=1`, which turns it into a named failure, the
13001    /// same opt-in `crates/image/tests/dst_view_stride.rs` and
13002    /// `reconstructed_view_draw.rs` carry for a GL backend that fails to
13003    /// come up. A lane that has asserted it has GL should not be quietly
13004    /// losing this test's coverage.
13005    #[test]
13006    #[cfg(all(
13007        any(
13008            target_os = "linux",
13009            target_os = "macos",
13010            target_os = "ios",
13011            target_os = "android",
13012            target_os = "windows"
13013        ),
13014        feature = "opengl"
13015    ))]
13016    fn convert_f16_gl_cpu_parity_identity() {
13017        // The macOS coverage lane sets `HAL_TEST_REQUIRE_GL=1` for both
13018        // passes but deliberately hobbles pass 1 (unsigned binaries, the
13019        // ANGLE dlopen gate closed). Excluded there the same way
13020        // `gl_backend_available_canary` excludes it, so the gate below
13021        // enforces in pass 2 only.
13022        #[cfg(target_os = "macos")]
13023        if std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1")
13024            && std::env::var_os("HAL_TEST_ALLOW_DLOPEN_ANGLE").is_none()
13025        {
13026            crate::test_support::report_skip(
13027                "convert_f16_gl_cpu_parity_identity - ANGLE dlopen gate closed (coverage pass 1)",
13028            );
13029            return;
13030        }
13031        if !is_opengl_available() {
13032            crate::test_support::report_skip(
13033                "convert_f16_gl_cpu_parity_identity - OpenGL not available",
13034            );
13035            return;
13036        }
13037
13038        const W: usize = 16;
13039        const H: usize = 16;
13040        const TOL: f32 = 1.0 / 256.0; // 2^-8
13041
13042        // pixel (y,x): R = 40+x, G = 80+y*10, B = 180
13043        let src = TensorDyn::image(
13044            W,
13045            H,
13046            PixelFormat::Rgba,
13047            DType::U8,
13048            Some(TensorMemory::Mem),
13049            edgefirst_tensor::CpuAccess::ReadWrite,
13050        )
13051        .unwrap();
13052        {
13053            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
13054            let data = map.as_mut_slice();
13055            for y in 0..H {
13056                for x in 0..W {
13057                    let i = y * W + x;
13058                    data[i * 4] = (40 + x) as u8; // R
13059                    data[i * 4 + 1] = (80 + y * 10) as u8; // G
13060                    data[i * 4 + 2] = 180; // B
13061                    data[i * 4 + 3] = 255;
13062                }
13063            }
13064        }
13065
13066        // GL path.
13067        let gl_result = {
13068            let mut gl_proc = match ImageProcessor::with_config(ImageProcessorConfig {
13069                backend: ComputeBackend::OpenGl,
13070                ..Default::default()
13071            }) {
13072                Ok(p) => p,
13073                Err(e) => {
13074                    crate::test_support::report_skip(&format!(
13075                        "convert_f16_gl_cpu_parity_identity - GL backend unavailable: {e}"
13076                    ));
13077                    return;
13078                }
13079            };
13080
13081            if !gl_proc.supported_render_dtypes().f16 {
13082                crate::test_support::report_skip(
13083                    "convert_f16_gl_cpu_parity_identity - F16 render not supported",
13084                );
13085                return;
13086            }
13087
13088            // A destination the GL float route actually serves. A
13089            // `TensorMemory::Mem` float destination has no arm in
13090            // `float_dispatch::classify_float_render` at all: it classifies as
13091            // `FloatRenderPath::None`, the u8 route rejects the dtype, and the
13092            // CPU serves every convert. With a host destination the GL half
13093            // below was therefore unreachable and this test could only ever
13094            // skip. `(Rgba, PlanarRgb, F16, DmaBuf)` -- `ZeroCopyF16Nchw` --
13095            // is the zero-copy float tuple EVERY platform's capability set
13096            // serves: it is unconditional in `classify_float_render`, so it
13097            // holds for the `PlanarF16` set Linux, Android and ANGLE/macOS
13098            // declare as well as the `All` set Windows declares.
13099            //
13100            // One spelling covers all of them. `TensorMemory::DmaBuf` is the
13101            // portable name for the platform-native zero-copy GPU buffer --
13102            // a DMA-BUF on Linux, an IOSurface on macOS/iOS, an
13103            // AHardwareBuffer on Android, an `ID3D11Texture2D` on Windows --
13104            // and the classifier matches on that one variant for all of them,
13105            // so this needs no `cfg`. Where the platform cannot hold one the
13106            // allocation fails or falls back, and both are reported below.
13107            let mut dst = match TensorDyn::image(
13108                W,
13109                H,
13110                PixelFormat::PlanarRgb,
13111                DType::F16,
13112                Some(TensorMemory::DmaBuf),
13113                edgefirst_tensor::CpuAccess::ReadWrite,
13114            ) {
13115                Ok(t) if t.memory() == TensorMemory::DmaBuf => t,
13116                // No routable float destination on this host. Reported here,
13117                // BEFORE the `HAL_TEST_REQUIRE_GL` gate below: that gate
13118                // exists to catch a GL backend declining a destination it
13119                // should serve, not a host that cannot hold one.
13120                Ok(t) => {
13121                    crate::test_support::report_skip(&format!(
13122                        "convert_f16_gl_cpu_parity_identity - the zero-copy F16 \
13123                         destination fell back to {:?}",
13124                        t.memory()
13125                    ));
13126                    return;
13127                }
13128                Err(e) => {
13129                    crate::test_support::report_skip(&format!(
13130                        "convert_f16_gl_cpu_parity_identity - no zero-copy F16 \
13131                         destination on this host: {e}"
13132                    ));
13133                    return;
13134                }
13135            };
13136            let require_gl = std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1");
13137            let fallbacks_before = gl_proc.convert_fallback_count();
13138            match gl_proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default()) {
13139                Ok(()) => {
13140                    if gl_proc.convert_fallback_count() != fallbacks_before {
13141                        assert!(
13142                            !require_gl,
13143                            "HAL_TEST_REQUIRE_GL=1 but \
13144                             convert_f16_gl_cpu_parity_identity would have skipped \
13145                             while reporting success: the GL F16 path declined and \
13146                             the CPU served the convert"
13147                        );
13148                        crate::test_support::report_skip(
13149                            "convert_f16_gl_cpu_parity_identity - the GL F16 path \
13150                             declined and the CPU served the convert; comparing that \
13151                             against the forced-CPU result would prove nothing",
13152                        );
13153                        return;
13154                    }
13155                    dst
13156                }
13157                Err(e) => {
13158                    crate::test_support::report_skip(&format!(
13159                        "convert_f16_gl_cpu_parity_identity - GL convert failed: {e}"
13160                    ));
13161                    return;
13162                }
13163            }
13164        };
13165
13166        // CPU path.
13167        let cpu_result = {
13168            let mut cpu_proc = ImageProcessor::with_config(ImageProcessorConfig {
13169                backend: ComputeBackend::Cpu,
13170                ..Default::default()
13171            })
13172            .unwrap();
13173            let mut dst = TensorDyn::image(
13174                W,
13175                H,
13176                PixelFormat::PlanarRgb,
13177                DType::F16,
13178                Some(TensorMemory::Mem),
13179                edgefirst_tensor::CpuAccess::ReadWrite,
13180            )
13181            .unwrap();
13182            cpu_proc
13183                .convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
13184                .expect("forced-CPU Rgba→PlanarRgb F16 must not error");
13185            dst
13186        };
13187
13188        // Compare element-wise. Both sides are read through `copy_to_flat`:
13189        // the GL destination is a packed zero-copy surface whose rows can be
13190        // spaced at a driver pitch, so its mapping is not a flat run of the
13191        // logical elements.
13192        let flat_f16 = |t: &TensorDyn| -> Vec<half::f16> {
13193            let typed = t.as_typed::<half::f16>().expect("F16 image");
13194            let mut bytes = vec![0u8; typed.shape().iter().product::<usize>() * 2];
13195            typed
13196                .copy_to_flat(&mut bytes)
13197                .expect("compact any padded rows");
13198            bytes
13199                .as_chunks::<2>()
13200                .0
13201                .iter()
13202                .map(|&c| half::f16::from_le_bytes(c))
13203                .collect()
13204        };
13205        let gl_halfs = flat_f16(&gl_result);
13206        let cpu_halfs = flat_f16(&cpu_result);
13207
13208        assert_eq!(
13209            gl_halfs.len(),
13210            cpu_halfs.len(),
13211            "GL and CPU output sizes differ"
13212        );
13213
13214        let plane = W * H;
13215        let channel_names = ["R", "G", "B"];
13216        for (idx, (gl_h, cpu_h)) in gl_halfs.iter().zip(cpu_halfs.iter()).enumerate() {
13217            let gl_v = gl_h.to_f32();
13218            let cpu_v = cpu_h.to_f32();
13219            let err = (gl_v - cpu_v).abs();
13220            let ch = channel_names[idx / plane];
13221            let pixel = idx % plane;
13222            assert!(
13223                err <= TOL,
13224                "GL vs CPU mismatch at {ch}[{pixel}]: GL={gl_v}, CPU={cpu_v}, err={err} > tol={TOL}"
13225            );
13226        }
13227    }
13228
13229    // =========================================================================
13230    // GAP-1: supported_render_dtypes() Linux smoke test
13231    // =========================================================================
13232
13233    /// Exercises the real Linux/Windows GL path that reads
13234    /// `gl.supported_render_dtypes()`. Skipped when no GL backend is
13235    /// available (CI/host without a GPU).
13236    #[test]
13237    #[cfg(all(any(target_os = "linux", target_os = "windows"), feature = "opengl"))]
13238    fn supported_render_dtypes_linux_smoke() {
13239        let proc = match ImageProcessor::new() {
13240            Ok(p) => p,
13241            Err(e) => {
13242                crate::test_support::report_skip(&format!(
13243                    "supported_render_dtypes_linux_smoke — ImageProcessor::new() failed: {e}"
13244                ));
13245                return;
13246            }
13247        };
13248        if proc.opengl.is_none() {
13249            crate::test_support::report_skip(
13250                "supported_render_dtypes_linux_smoke — no GL backend on this host",
13251            );
13252            return;
13253        }
13254        // The call must complete without panicking or deadlocking.
13255        let support = proc.supported_render_dtypes();
13256        eprintln!(
13257            "supported_render_dtypes_linux_smoke: f16={} f32={}",
13258            support.f16, support.f32
13259        );
13260        // No assertion on the specific values — they are hardware-dependent.
13261    }
13262
13263    // =========================================================================
13264    // GAP-2: F16 PlanarRgb with width NOT divisible by 4 falls back to CPU
13265    // =========================================================================
13266
13267    /// The GL float render path rejects PlanarRgb F16 destinations whose width
13268    /// is not a multiple of 4 (the packed RGBA16F swizzle trick requires W%4==0).
13269    /// The auto-chain must transparently fall through to the CPU path, which has
13270    /// no such restriction.  W=18, H=16 is chosen so W%4 == 2.
13271    #[test]
13272    fn convert_f16_pbo_non_4_aligned_width_falls_back() {
13273        const W: usize = 18; // 18 % 4 == 2 — NOT divisible by 4
13274        const H: usize = 16;
13275
13276        // RGBA8 source filled with a flat mid-grey.
13277        let src = TensorDyn::image(
13278            W,
13279            H,
13280            PixelFormat::Rgba,
13281            DType::U8,
13282            Some(TensorMemory::Mem),
13283            edgefirst_tensor::CpuAccess::ReadWrite,
13284        )
13285        .unwrap();
13286        {
13287            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
13288            let data = map.as_mut_slice();
13289            for chunk in data.as_chunks_mut::<4>().0 {
13290                chunk[0] = 128;
13291                chunk[1] = 64;
13292                chunk[2] = 200;
13293                chunk[3] = 255;
13294            }
13295        }
13296
13297        // F16 PlanarRgb destination in Mem (GL would use PBO, but we want
13298        // to exercise the fallback chain without hardware dependency).
13299        let mut dst = TensorDyn::image(
13300            W,
13301            H,
13302            PixelFormat::PlanarRgb,
13303            DType::F16,
13304            Some(TensorMemory::Mem),
13305            edgefirst_tensor::CpuAccess::ReadWrite,
13306        )
13307        .unwrap();
13308
13309        // Use the default auto-chain so the GL path can attempt and reject,
13310        // then the CPU path succeeds.
13311        let mut proc = ImageProcessor::new().unwrap();
13312        let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
13313        assert!(
13314            result.is_ok(),
13315            "auto-chain PlanarRgb F16 W%4!=0 must not error (CPU fallback): {:?}",
13316            result.err()
13317        );
13318
13319        // All output values must be finite and in [0, 1].
13320        let map = dst.as_typed::<half::f16>().unwrap().map().unwrap();
13321        let halfs = map.as_slice();
13322        assert_eq!(halfs.len(), W * H * 3, "unexpected element count");
13323        for (i, h) in halfs.iter().enumerate() {
13324            let v = h.to_f32();
13325            assert!(
13326                v.is_finite() && (0.0..=1.0).contains(&v),
13327                "output[{i}]={v} is not finite or not in [0,1]"
13328            );
13329        }
13330    }
13331
13332    // =========================================================================
13333    // GAP-4: NV12 → Rgb F32 and NV12 → PlanarRgb F16, forced CPU
13334    // =========================================================================
13335
13336    /// CPU widen-composition: NV12 (non-RGBA source) → Rgb F32.
13337    ///
13338    /// NV12 requires a two-stage CPU conversion (NV12→Rgba then Rgba→F32)
13339    /// which was untested. A wrong intermediate format selection silently
13340    /// produces garbage. Y=128, U=V=128 → near-neutral grey → R≈G≈B≈0.5.
13341    #[test]
13342    // Linux-only `egl_display` field makes `..Default::default()` needless
13343    // on macOS only; see `convert_f16_forced_cpu_correct`.
13344    #[allow(clippy::needless_update)]
13345    fn convert_nv12_to_rgb_f32_cpu() {
13346        const W: usize = 16;
13347        const H: usize = 16; // must be even for NV12
13348
13349        // Build a valid NV12 tensor: shape [H*3/2, W], luma=128, chroma=128.
13350        let src = TensorDyn::image(
13351            W,
13352            H,
13353            PixelFormat::Nv12,
13354            DType::U8,
13355            Some(TensorMemory::Mem),
13356            edgefirst_tensor::CpuAccess::ReadWrite,
13357        )
13358        .unwrap();
13359        {
13360            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
13361            map.as_mut_slice().fill(128); // Y=128, U=V=128 → neutral grey
13362        }
13363
13364        let mut dst = TensorDyn::image(
13365            W,
13366            H,
13367            PixelFormat::Rgb,
13368            DType::F32,
13369            Some(TensorMemory::Mem),
13370            edgefirst_tensor::CpuAccess::ReadWrite,
13371        )
13372        .unwrap();
13373
13374        let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
13375            backend: ComputeBackend::Cpu,
13376            ..Default::default()
13377        })
13378        .unwrap();
13379
13380        let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
13381        assert!(
13382            result.is_ok(),
13383            "forced-CPU NV12→Rgb F32 must not error: {:?}",
13384            result.err()
13385        );
13386
13387        let map = dst.as_typed::<f32>().unwrap().map().unwrap();
13388        let floats = map.as_slice();
13389        assert_eq!(floats.len(), W * H * 3, "unexpected element count");
13390        for (i, &v) in floats.iter().enumerate() {
13391            assert!(
13392                v.is_finite() && (0.0..=1.0).contains(&v),
13393                "output[{i}]={v} is not finite or not in [0,1]"
13394            );
13395        }
13396        // Anti-all-zero: Y=128 → luma channel ≈ 0.5 after YUV→RGB.
13397        let non_zero = floats.iter().any(|&v| v > 0.01);
13398        assert!(non_zero, "all-zero output from NV12→Rgb F32 CPU path");
13399    }
13400
13401    /// CPU widen-composition: NV12 (non-RGBA source) → PlanarRgb F16.
13402    ///
13403    /// Same rationale as `convert_nv12_to_rgb_f32_cpu` but for F16 output.
13404    #[test]
13405    // Linux-only `egl_display` field makes `..Default::default()` needless
13406    // on macOS only; see `convert_f16_forced_cpu_correct`.
13407    #[allow(clippy::needless_update)]
13408    fn convert_nv12_to_planar_rgb_f16_cpu() {
13409        const W: usize = 16;
13410        const H: usize = 16;
13411
13412        let src = TensorDyn::image(
13413            W,
13414            H,
13415            PixelFormat::Nv12,
13416            DType::U8,
13417            Some(TensorMemory::Mem),
13418            edgefirst_tensor::CpuAccess::ReadWrite,
13419        )
13420        .unwrap();
13421        {
13422            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
13423            map.as_mut_slice().fill(128);
13424        }
13425
13426        let mut dst = TensorDyn::image(
13427            W,
13428            H,
13429            PixelFormat::PlanarRgb,
13430            DType::F16,
13431            Some(TensorMemory::Mem),
13432            edgefirst_tensor::CpuAccess::ReadWrite,
13433        )
13434        .unwrap();
13435
13436        let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
13437            backend: ComputeBackend::Cpu,
13438            ..Default::default()
13439        })
13440        .unwrap();
13441
13442        let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
13443        assert!(
13444            result.is_ok(),
13445            "forced-CPU NV12→PlanarRgb F16 must not error: {:?}",
13446            result.err()
13447        );
13448
13449        let map = dst.as_typed::<half::f16>().unwrap().map().unwrap();
13450        let halfs = map.as_slice();
13451        assert_eq!(halfs.len(), W * H * 3, "unexpected element count");
13452        for (i, h) in halfs.iter().enumerate() {
13453            let v = h.to_f32();
13454            assert!(
13455                v.is_finite() && (0.0..=1.0).contains(&v),
13456                "output[{i}]={v} is not finite or not in [0,1]"
13457            );
13458        }
13459        let non_zero = halfs.iter().any(|h| h.to_f32() > 0.01);
13460        assert!(non_zero, "all-zero output from NV12→PlanarRgb F16 CPU path");
13461    }
13462
13463    // =========================================================================
13464    // GAP-5: create_image F32 + DMA must return NotSupported
13465    // =========================================================================
13466
13467    /// `create_image_desc` without a compression request is exactly
13468    /// `create_image` (the processor's memory negotiation applies); with
13469    /// `Compression::Any` off-Android it resolves linear and counts the
13470    /// fallback in the processor-visible mirror.
13471    #[test]
13472    fn create_image_desc_negotiates_and_counts_fallbacks() {
13473        use edgefirst_tensor::{CpuAccess, ImageDesc};
13474        let proc = ImageProcessor::new().unwrap();
13475
13476        let desc =
13477            ImageDesc::new(64, 64, PixelFormat::Rgba, DType::U8).with_access(CpuAccess::ReadWrite);
13478        let plain = proc.create_image_desc(&desc).unwrap();
13479        let classic = proc
13480            .create_image(
13481                64,
13482                64,
13483                PixelFormat::Rgba,
13484                DType::U8,
13485                None,
13486                CpuAccess::ReadWrite,
13487            )
13488            .unwrap();
13489        assert_eq!(plain.memory(), classic.memory(), "same negotiation path");
13490        assert_eq!(plain.compression(), None);
13491
13492        #[cfg(not(target_os = "android"))]
13493        {
13494            use edgefirst_tensor::Compression;
13495            let before = proc.compression_fallback_count();
13496            let desc = ImageDesc::new(64, 64, PixelFormat::Rgba, DType::U8)
13497                .with_compression(Compression::Any);
13498            let t = proc.create_image_desc(&desc).unwrap();
13499            assert_eq!(t.compression(), None, "no vendor tile scheme off-Android");
13500            assert!(
13501                proc.compression_fallback_count() > before,
13502                "Any resolving linear must count"
13503            );
13504        }
13505    }
13506
13507    /// There is no DRM FourCC for F32 images, so `create_image` with
13508    /// `TensorMemory::DmaBuf` and `DType::F32` must return `Err(NotSupported)`.
13509    #[test]
13510    #[cfg(target_os = "linux")]
13511    fn create_image_f32_dma_rejected() {
13512        let proc = ImageProcessor::new().unwrap();
13513        let result = proc.create_image(
13514            64,
13515            64,
13516            PixelFormat::Rgb,
13517            DType::F32,
13518            Some(TensorMemory::DmaBuf),
13519            edgefirst_tensor::CpuAccess::ReadWrite,
13520        );
13521        assert!(
13522            result.is_err(),
13523            "create_image(F32, Dma) must fail — no DRM fourcc for f32"
13524        );
13525    }
13526
13527    /// Verify that `import_image` stores the supplied `Option<Colorimetry>` on
13528    /// the returned `TensorDyn`.
13529    ///
13530    /// `import_image` requires a DMA-backed fd on Linux.  When DMA is
13531    /// unavailable we skip the DMA call but still verify the storage contract
13532    /// by inspecting `set_colorimetry` / `colorimetry` on a plain `TensorDyn`
13533    /// constructed the same way the function body does it — and we confirm via
13534    /// code-read that the new parameter is unconditionally stored.
13535    #[test]
13536    #[cfg(target_os = "linux")]
13537    fn import_image_carries_colorimetry() {
13538        use edgefirst_tensor::{ColorEncoding, ColorRange, Colorimetry, TensorMemory};
13539
13540        let expected = Colorimetry::default()
13541            .with_encoding(ColorEncoding::Bt709)
13542            .with_range(ColorRange::Limited);
13543
13544        if !is_dma_available() {
13545            // DMA unavailable on this host: exercise the storage path via
13546            // TensorDyn directly (mirrors what import_image does internally).
13547            let mut t = TensorDyn::image(
13548                8,
13549                8,
13550                PixelFormat::Rgba,
13551                DType::U8,
13552                Some(TensorMemory::Mem),
13553                edgefirst_tensor::CpuAccess::ReadWrite,
13554            )
13555            .expect("alloc");
13556            assert_eq!(t.colorimetry(), None, "colorimetry must start as None");
13557            t.set_colorimetry(Some(expected));
13558            assert_eq!(
13559                t.colorimetry(),
13560                Some(expected),
13561                "set_colorimetry must round-trip"
13562            );
13563            crate::test_support::report_skip("import_image_carries_colorimetry (DMA unavailable); storage contract verified via TensorDyn");
13564            return;
13565        }
13566
13567        // DMA is available: allocate a real DMA tensor, extract its fd, and
13568        // call import_image with an explicit Colorimetry.
13569        use edgefirst_tensor::{PlaneDescriptor, Tensor};
13570
13571        let rgba_bytes = 64 * 64 * 4; // 64×64 RGBA8
13572        let dma_tensor = Tensor::<u8>::new(
13573            &[rgba_bytes],
13574            Some(TensorMemory::DmaBuf),
13575            Some("import_test"),
13576        )
13577        .expect("dma alloc");
13578        let pd =
13579            PlaneDescriptor::new(dma_tensor.dmabuf().expect("dma fd")).expect("PlaneDescriptor");
13580
13581        let proc = ImageProcessor::new().expect("ImageProcessor");
13582        let result = proc.import_image(
13583            pd,
13584            None,
13585            64,
13586            64,
13587            PixelFormat::Rgba,
13588            DType::U8,
13589            Some(expected),
13590        );
13591        let tensor = result.expect("import_image must succeed on DMA fd");
13592        assert_eq!(
13593            tensor.colorimetry(),
13594            Some(expected),
13595            "import_image must store the supplied colorimetry on the returned TensorDyn"
13596        );
13597    }
13598}