Skip to main content

edgefirst_image/
lib.rs

1// SPDX-FileCopyrightText: Copyright 2025 Au-Zone Technologies
2// SPDX-License-Identifier: Apache-2.0
3
4/*!
5
6## EdgeFirst HAL - Image Converter
7
8The `edgefirst_image` crate is part of the EdgeFirst Hardware Abstraction
9Layer (HAL) and provides functionality for converting images between
10different formats and sizes.  The crate is designed to work with hardware
11acceleration when available, but also provides a CPU-based fallback for
12environments where hardware acceleration is not present or not suitable.
13
14What the crate covers:
15- Packed and planar RGB families (`Rgb`, `Rgba`, `Bgra`, `Grey`,
16  `PlanarRgb`, `PlanarRgba`), semi-planar YUV sources (`Nv12`, `Nv16`,
17  `Nv24`), and packed YUV (`Yuyv`, `Vyuy`).
18- Source crop, resize, letterbox, 90° rotation, and flipping in one call.
19- Segmentation-mask rendering, including a fused GPU proto path.
20- SAHI-style input tiling for small-object detection in large frames
21  ([`TilingConfig`], [`ImageProcessor::tile_into`]).
22- Hardware acceleration (OpenGL, NXP G2D) with a CPU fallback that is
23  always available.
24
25The crate uses [`TensorDyn`] from `edgefirst_tensor` to represent images,
26with [`PixelFormat`] metadata describing the pixel layout. The
27[`ImageProcessor`] struct manages the conversion process, selecting
28the appropriate conversion method based on the available hardware.
29
30Geometry is **source-side**: [`Crop`] says which sub-rectangle of the source
31to sample and how to fit it into the destination's shape. Where the output
32lands is the destination itself — pass a
33[`view`](edgefirst_tensor::TensorDyn::view) or
34[`batch`](edgefirst_tensor::TensorDyn::batch) of a larger tensor to render
35into one tile of a batch.
36
37## Examples
38
39```rust
40# use edgefirst_image::{ImageProcessor, Rotation, Flip, Crop, ImageProcessorTrait};
41# use edgefirst_codec::{peek_info, ImageDecoder, ImageLoad};
42# use edgefirst_tensor::{CpuAccess, PixelFormat, DType, Tensor, TensorMemory};
43# fn main() -> Result<(), edgefirst_image::Error> {
44let image = edgefirst_bench::testdata::read("zidane.jpg");
45// The codec emits the source's native format (a colour JPEG decodes to NV12)
46// and configures the destination tensor's dims+format during the decode.
47let info = peek_info(&image).expect("peek");
48// Heap tensors are CPU-touched on both sides (codec decode writes, the
49// engine's upload/CPU-fallback paths read), so declare ReadWrite. Pure
50// hardware pipelines allocate their convert destinations with
51// `CpuAccess::None` instead.
52let mut src = Tensor::<u8>::image(info.width, info.height, info.format,
53                                   Some(TensorMemory::Mem), CpuAccess::ReadWrite)?;
54let mut decoder = ImageDecoder::new();
55src.load_image(&mut decoder, &image).expect("decode");
56// Convert the native NV12 frame to packed RGB for downstream processing.
57let mut converter = ImageProcessor::new()?;
58let mut dst =
59    converter.create_image(640, 480, PixelFormat::Rgb, DType::U8, None, CpuAccess::ReadWrite)?;
60converter.convert(&src.into(), &mut dst, Rotation::None, Flip::None, Crop::default())?;
61# Ok(())
62# }
63```
64
65## Environment Variables
66The behavior of the `edgefirst_image::ImageProcessor` struct can be influenced by the
67following environment variables:
68- `EDGEFIRST_FORCE_BACKEND`: When set to `cpu`, `g2d`, or `opengl` (case-insensitive),
69  only that single backend is initialized and no fallback chain is used. If the
70  forced backend fails to initialize, an error is returned immediately. This is
71  useful for benchmarking individual backends in isolation. When this variable is
72  set, the `EDGEFIRST_DISABLE_*` variables are ignored.
73- `EDGEFIRST_DISABLE_GL`: If set to `1`, disables the use of OpenGL for image
74  conversion, forcing the use of CPU or other available hardware methods.
75- `EDGEFIRST_DISABLE_G2D`: If set to `1`, disables the use of G2D for image
76  conversion, forcing the use of CPU or other available hardware methods.
77- `EDGEFIRST_DISABLE_CPU`: If set to `1`, disables the use of CPU for image
78  conversion, forcing the use of hardware acceleration methods. If no hardware
79  acceleration methods are available, an error will be returned when attempting
80  to create an `ImageProcessor`.
81
82Additionally the TensorMemory used by default allocations can be controlled using the
83`EDGEFIRST_TENSOR_FORCE_MEM` environment variable. If set to `1`, default tensor memory
84uses system memory. This will disable the use of specialized memory regions for tensors
85and hardware acceleration. However, this will increase the performance of the CPU converter.
86*/
87#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
88
89/// Retained constructor: installs the coverage flush-on-abort handler for this
90/// crate's instrumented test binary. See `edgefirst_tensor::covguard`. Only
91/// present under coverage on Linux (`.init_array` is ELF-only; flush is Linux-only).
92#[cfg(all(coverage, target_os = "linux"))]
93#[used]
94#[link_section = ".init_array"]
95static __EDGEFIRST_COV_INSTALL: extern "C" fn() = {
96    extern "C" fn ctor() {
97        edgefirst_tensor::covguard::install();
98    }
99    ctor
100};
101
102/// Pitch alignment requirement for DMA-BUF tensors that may be imported as
103/// EGLImages by the GL backend. Mali Valhall (i.MX 95 / G310) rejects
104/// `eglCreateImageKHR` with `EGL_BAD_ALLOC` for any DMA-BUF whose row pitch
105/// is not a multiple of 64 bytes; Vivante GC7000UL (i.MX 8MP) accepts any
106/// pitch so the constant is harmless on that path. 64 is the smallest
107/// alignment that satisfies every embedded ARM GPU we ship to.
108///
109/// Applied automatically inside [`ImageProcessor::create_image`] when the
110/// allocation lands on `TensorMemory::DmaBuf`. External callers that allocate
111/// their own DMA-BUF tensors (e.g. GStreamer plugins, video pipelines) can
112/// use [`align_width_for_gpu_pitch`] to compute a width whose resulting row
113/// stride satisfies this requirement.
114pub const GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES: usize = 64;
115
116/// Round `width` (in pixels) up so the resulting row stride
117/// `width * bpp` is a multiple of [`GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES`]
118/// AND a multiple of `bpp` (so the rounded width is an integer pixel count).
119///
120/// `bpp` must be the per-pixel byte count for the image's primary plane
121/// (e.g. 4 for RGBA8/BGRA8, 3 for RGB888, 1 for Grey/NV12-luma).
122///
123/// External callers — GStreamer plugins, video pipelines, anyone wrapping a
124/// foreign DMA-BUF — should call this when sizing the destination so that
125/// `eglCreateImageKHR` doesn't reject the import on Mali. Pre-aligned widths
126/// (640, 1280, 1920, 3008, 3840 …) round-trip unchanged; misaligned widths
127/// are bumped up to the next valid value.
128///
129/// # Overflow behaviour
130///
131/// All arithmetic is checked. If the alignment computation or the rounded
132/// width would overflow `usize`, the function logs a warning and returns the
133/// original `width` unchanged rather than wrapping or producing a smaller
134/// value. Callers can rely on the returned width being **at least** the
135/// requested width.
136///
137/// `bpp == 0` and `width == 0` short-circuit to return the input unchanged.
138///
139/// # Examples
140///
141/// ```
142/// use edgefirst_image::align_width_for_gpu_pitch;
143///
144/// // RGBA8 (bpp=4): width must round to a multiple of 16 pixels (64-byte stride).
145/// assert_eq!(align_width_for_gpu_pitch(1920, 4), 1920); // already aligned
146/// assert_eq!(align_width_for_gpu_pitch(3004, 4), 3008); // crowd.png case: +4 px
147/// assert_eq!(align_width_for_gpu_pitch(1281, 4), 1296); // +15 px
148///
149/// // RGB888 (bpp=3): width must round to a multiple of 64 pixels (192-byte stride).
150/// assert_eq!(align_width_for_gpu_pitch(640, 3), 640);
151/// assert_eq!(align_width_for_gpu_pitch(641, 3), 704);
152/// ```
153pub fn align_width_for_gpu_pitch(width: usize, bpp: usize) -> usize {
154    if bpp == 0 || width == 0 {
155        return width;
156    }
157
158    // The minimum aligned stride must be a common multiple of both the
159    // GPU's pitch alignment and the per-pixel byte count. Using the LCM
160    // guarantees the rounded stride is an integer multiple of `bpp`, so
161    // converting back to a pixel count is exact.
162    //
163    // Compute the alignment in pixels (`width_alignment`) so we never need
164    // to multiply `width * bpp`, which is the only operation that could
165    // realistically overflow for large caller-supplied widths.
166    let Some(lcm_alignment) = checked_num_integer_lcm(GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES, bpp)
167    else {
168        log::warn!(
169            "align_width_for_gpu_pitch: lcm({GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES}, {bpp}) \
170             overflows usize, returning unaligned width {width}"
171        );
172        return width;
173    };
174    if lcm_alignment == 0 {
175        return width;
176    }
177
178    debug_assert_eq!(lcm_alignment % bpp, 0);
179    let width_alignment = lcm_alignment / bpp;
180    if width_alignment == 0 {
181        return width;
182    }
183
184    let remainder = width % width_alignment;
185    if remainder == 0 {
186        return width;
187    }
188
189    let pad = width_alignment - remainder;
190    match width.checked_add(pad) {
191        Some(aligned) => aligned,
192        None => {
193            log::warn!(
194                "align_width_for_gpu_pitch: width {width} + pad {pad} overflows usize, \
195                 returning unaligned (caller should use a smaller width or pre-aligned size)"
196            );
197            width
198        }
199    }
200}
201
202/// Round `min_pitch_bytes` up to the next multiple of
203/// [`GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES`]. Returns `None` if the rounded
204/// value would overflow `usize`. Returns `Some(0)` for input 0.
205///
206/// Used internally by [`ImageProcessor::create_image`] to compute the
207/// padded row stride for DMA-backed image allocations. External callers
208/// that need pixel-counted alignment (instead of raw byte pitch) should
209/// use [`align_width_for_gpu_pitch`] instead.
210#[cfg(target_os = "linux")]
211pub(crate) fn align_pitch_bytes_to_gpu_alignment(min_pitch_bytes: usize) -> Option<usize> {
212    let alignment = GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES;
213    if min_pitch_bytes == 0 {
214        return Some(0);
215    }
216    let remainder = min_pitch_bytes % alignment;
217    if remainder == 0 {
218        return Some(min_pitch_bytes);
219    }
220    min_pitch_bytes.checked_add(alignment - remainder)
221}
222
223/// Overflow-safe least common multiple. Returns `None` when `(a / gcd) * b`
224/// would wrap.
225fn checked_num_integer_lcm(a: usize, b: usize) -> Option<usize> {
226    if a == 0 || b == 0 {
227        return Some(0);
228    }
229    let g = num_integer_gcd(a, b);
230    // a / g is exact (g divides a by definition) and at most a, so this
231    // division never panics. Only the subsequent multiply can overflow.
232    (a / g).checked_mul(b)
233}
234
235fn num_integer_gcd(a: usize, b: usize) -> usize {
236    if b == 0 {
237        a
238    } else {
239        num_integer_gcd(b, a % b)
240    }
241}
242
243/// Bytes-per-pixel for the primary plane of `format` at element size `elem`.
244/// Returns `None` for formats that don't have a single packed BPP (semi-planar
245/// chroma is handled separately, returning the luma-plane bpp).
246///
247/// External callers can use this together with [`align_width_for_gpu_pitch`]
248/// to size their own DMA-BUFs without having to remember per-format BPPs:
249///
250/// ```
251/// use edgefirst_image::{align_width_for_gpu_pitch, primary_plane_bpp};
252/// use edgefirst_tensor::PixelFormat;
253///
254/// let bpp = primary_plane_bpp(PixelFormat::Rgba, 1).unwrap();
255/// let aligned = align_width_for_gpu_pitch(3004, bpp);
256/// assert_eq!(aligned, 3008);
257/// ```
258pub fn primary_plane_bpp(format: PixelFormat, elem: usize) -> Option<usize> {
259    use edgefirst_tensor::PixelLayout;
260    match format.layout() {
261        PixelLayout::Packed => Some(format.channels() * elem),
262        PixelLayout::Planar => Some(elem),
263        // For NV12/NV16 the luma plane is single-channel so the pitch
264        // matches `elem`; the chroma plane uses the same pitch in bytes
265        // (UV is half-width but two interleaved channels = same pitch).
266        PixelLayout::SemiPlanar => Some(elem),
267        // `PixelLayout` is non-exhaustive — fall through unaligned for
268        // any future variant we don't yet recognise.
269        _ => None,
270    }
271}
272
273/// Return the GPU-aligned pitch in bytes when a DMA-backed image of
274/// `width × fmt` would need row-stride padding, or `None` when the
275/// natural pitch already satisfies `GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES`
276/// or the caller has explicitly requested non-DMA memory.
277///
278/// Mali G310 (i.MX 95) rejects `eglCreateImage` from DMA-BUFs whose
279/// `PLANE0_PITCH_EXT` is not a multiple of 64 bytes, surfacing as
280/// `EGL_BAD_ALLOC`. The `load_image_test_helper` test-only helper
281/// in this crate uses this to decide whether to allocate a tensor
282/// with padded row stride before invoking the decode path; production
283/// callers do the equivalent peek → allocate → decode dance themselves
284/// (see crate-level docs).
285#[cfg(all(target_os = "linux", test))]
286pub(crate) fn padded_dma_pitch_for(
287    fmt: PixelFormat,
288    width: usize,
289    memory: &Option<TensorMemory>,
290) -> Option<usize> {
291    // Only pad when the caller explicitly requested DMA, or when they
292    // left memory selection to the allocator AND DMA is actually
293    // available. `Tensor::image_with_stride(..., None)` always routes
294    // through DMA allocation, so treating `None` as "DMA wanted"
295    // unconditionally would convert a normally-working image load into
296    // a hard failure on systems where DMA is unavailable (sandboxed
297    // CI, missing `/dev/dma_heap`, permission-denied containers) —
298    // whereas `Tensor::image(..., None)` would have fallen back to
299    // SHM/Mem there.
300    match memory {
301        Some(TensorMemory::DmaBuf) => {}
302        None if edgefirst_tensor::is_dma_available() => {}
303        _ => return None,
304    }
305    // Padding only applies to packed layouts — `Tensor::image_with_stride`
306    // rejects semi-planar / planar formats, and those take their own
307    // per-plane pitches on import anyway.
308    if fmt.layout() != PixelLayout::Packed {
309        return None;
310    }
311    let bpp = primary_plane_bpp(fmt, 1)?;
312    let natural = width.checked_mul(bpp)?;
313    let aligned = align_pitch_bytes_to_gpu_alignment(natural)?;
314    if aligned > natural {
315        Some(aligned)
316    } else {
317        None
318    }
319}
320
321pub use cpu::CPUProcessor;
322#[cfg(feature = "codec")]
323pub use edgefirst_codec as codec;
324
325#[cfg(test)]
326use edgefirst_tensor::ProtoLayout;
327#[doc(inline)]
328pub use edgefirst_tensor::Region;
329#[cfg(any(
330    test,
331    all(any(target_os = "linux", target_os = "windows"), feature = "opengl")
332))]
333use edgefirst_tensor::Tensor;
334use edgefirst_tensor::{
335    DType, PixelFormat, PixelLayout, TensorDyn, TensorMemory, TensorTrait as _,
336};
337use edgefirst_tensor::{DetectBox, ProtoData, Segmentation};
338use enum_dispatch::enum_dispatch;
339pub use error::{Error, Result};
340#[cfg(target_os = "linux")]
341pub use g2d::G2DProcessor;
342/// 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    /// Validate the resolved rects against explicit dimensions.
813    pub(crate) fn check_crop_dims(
814        &self,
815        src_w: usize,
816        src_h: usize,
817        dst_w: usize,
818        dst_h: usize,
819    ) -> Result<(), Error> {
820        let src_ok = self
821            .src_rect
822            .is_none_or(|r| r.left + r.width <= src_w && r.top + r.height <= src_h);
823        let dst_ok = self
824            .dst_rect
825            .is_none_or(|r| r.left + r.width <= dst_w && r.top + r.height <= dst_h);
826        match (src_ok, dst_ok) {
827            (true, true) => Ok(()),
828            (true, false) => Err(Error::CropInvalid(format!(
829                "Dest crop invalid: {:?}",
830                self.dst_rect
831            ))),
832            (false, true) => Err(Error::CropInvalid(format!(
833                "Src crop invalid: {:?}",
834                self.src_rect
835            ))),
836            (false, false) => Err(Error::CropInvalid(format!(
837                "Dest and Src crop invalid: {:?} {:?}",
838                self.dst_rect, self.src_rect
839            ))),
840        }
841    }
842}
843
844/// Convert a pixel [`Region`] to the internal [`Rect`] placement type.
845fn region_to_rect(r: Region) -> Rect {
846    Rect {
847        left: r.x,
848        top: r.y,
849        width: r.width,
850        height: r.height,
851    }
852}
853
854/// Centred aspect-preserving placement of an `sw`×`sh` source within a `dw`×`dh`
855/// destination — the canonical letterbox rectangle (one home, replacing the
856/// per-caller `calculate_letterbox` copies).
857fn letterbox_rect(sw: usize, sh: usize, dw: usize, dh: usize) -> Rect {
858    if sw == 0 || sh == 0 {
859        return Rect::new(0, 0, dw, dh);
860    }
861    let src_aspect = sw as f64 / sh as f64;
862    let dst_aspect = dw as f64 / dh as f64;
863    let (new_w, new_h) = if src_aspect > dst_aspect {
864        (dw, ((dw as f64 / src_aspect).round() as usize).max(1))
865    } else {
866        (((dh as f64 * src_aspect).round() as usize).max(1), dh)
867    };
868    let left = dw.saturating_sub(new_w) / 2;
869    let top = dh.saturating_sub(new_h) / 2;
870    Rect::new(left, top, new_w, new_h)
871}
872
873/// Internal placement rectangle (top-left + size). The **public** pixel
874/// sub-region type is [`Region`] (re-exported from `edgefirst-tensor`);
875/// `Rect` is the crate-private resolved-placement representation used by the
876/// CPU/G2D/GL backends after [`Crop::resolve`].
877#[derive(Debug, Clone, Copy, PartialEq, Eq)]
878pub(crate) struct Rect {
879    pub left: usize,
880    pub top: usize,
881    pub width: usize,
882    pub height: usize,
883}
884
885impl Rect {
886    // Creates a new Rect with the specified left, top, width, and height.
887    pub fn new(left: usize, top: usize, width: usize, height: usize) -> Self {
888        Self {
889            left,
890            top,
891            width,
892            height,
893        }
894    }
895}
896
897#[enum_dispatch(ImageProcessor)]
898pub trait ImageProcessorTrait {
899    /// Convert `src` into `dst`'s format and size, applying the crop first,
900    /// then the flip, then the rotation.
901    ///
902    /// The destination is normally an RGB-family colour — `Grey`, `Rgb`,
903    /// `Rgba`/`Bgra`, packed `HWC` or planar `CHW`. That is what the
904    /// accelerated paths target and what models expect. The CPU backend also
905    /// accepts a handful of YUV destinations (`Yuyv`→`Yuyv`, `Yuyv`→`Nv16`,
906    /// and the `Vyuy` equivalents); no GPU or G2D path does, so those pairs
907    /// always land on the CPU.
908    ///
909    /// # Arguments
910    ///
911    /// * `src` - The source image to convert from.
912    /// * `dst` - The destination image. Its shape and [`PixelFormat`] define
913    ///   the output; pass a [`view`](edgefirst_tensor::TensorDyn::view) or
914    ///   [`batch`](edgefirst_tensor::TensorDyn::batch) of a larger tensor to
915    ///   render into one tile of a batch.
916    /// * `rotation` - Rotation applied to the output, in 90° steps.
917    /// * `flip` - Horizontal, vertical, or both.
918    /// * `crop` - Source-side geometry: which sub-rectangle of `src` to sample
919    ///   ([`Crop::source`]) and how to fit it into `dst`'s shape
920    ///   ([`Crop::fit`] — stretch, or letterbox with a pad colour). Destination
921    ///   placement lives on `dst`, not here.
922    ///
923    /// # Errors
924    ///
925    /// - [`Error::CropInvalid`] if `crop.source` falls outside `src`.
926    /// - [`Error::NotAnImage`] if either tensor carries no `PixelFormat`.
927    /// - [`Error::NoConverter`] if no backend accepts the format pair — the
928    ///   dispatch tries OpenGL, then G2D, then CPU, so this means all three
929    ///   declined.
930    ///
931    /// Backend-specific failures (EGL import, G2D blit, allocation) surface as
932    /// their own variants. A backend *declining* a pair is not an error while
933    /// another remains in the chain; it silently costs the zero-copy path, and
934    /// [`ImageProcessor::convert_fallback_count`] counts those.
935    fn convert(
936        &mut self,
937        src: &TensorDyn,
938        dst: &mut TensorDyn,
939        rotation: Rotation,
940        flip: Flip,
941        crop: Crop,
942    ) -> Result<()>;
943
944    /// Draw pre-decoded detection boxes and segmentation masks onto `dst`.
945    ///
946    /// Supports two segmentation modes based on the mask channel count:
947    /// - **Instance segmentation** (`C=1`): one `Segmentation` per detection,
948    ///   `segmentation` and `detect` are zipped.
949    /// - **Semantic segmentation** (`C>1`): a single `Segmentation` covering
950    ///   all classes; only the first element is used.
951    ///
952    /// # Format requirements
953    ///
954    /// - CPU backend: `dst` must be `RGBA` or `RGB`.
955    /// - OpenGL backend: `dst` must be `RGBA`, `BGRA`, or `RGB`.
956    /// - G2D backend: only produces the base frame (empty detections);
957    ///   returns `NotImplemented` when any detection or segmentation is
958    ///   supplied.
959    ///
960    /// # Output contract
961    ///
962    /// This function always fully writes `dst` — it never relies on the
963    /// caller having pre-cleared the destination. The four cases are:
964    ///
965    /// | detections | background | output                              |
966    /// |------------|------------|-------------------------------------|
967    /// | none       | none       | dst cleared to `0x00000000`         |
968    /// | none       | set        | dst ← background                    |
969    /// | set        | none       | masks drawn over cleared dst        |
970    /// | set        | set        | masks drawn over background         |
971    ///
972    /// Each backend implements this with its native primitives: G2D uses
973    /// `g2d_clear` / `g2d_blit`, OpenGL uses `glClear` / DMA-BUF GPU blit
974    /// plus the mask program, and CPU uses direct buffer fill / memcpy as
975    /// the terminal fallback. CPU-memcpy of DMA buffers is avoided on the
976    /// accelerated paths.
977    ///
978    /// An empty `segmentation` slice is valid — only bounding boxes are drawn.
979    ///
980    /// `overlay` controls compositing: `background` is the compositing source
981    /// (must match `dst` in size and format); `opacity` scales mask alpha.
982    ///
983    /// A `Bgra` background onto a zero-copy destination (a D3D11 texture, an
984    /// IOSurface, a dma-buf) returns [`Error::NotSupported`]: the GL
985    /// base-layer draw has no `Bgra` arm and the CPU backend renders only
986    /// `Rgba`/`Rgb`. It previously returned `Ok` with `dst` unwritten. A
987    /// `Bgra` destination with no background is unaffected.
988    ///
989    /// # Buffer aliasing
990    ///
991    /// `dst` and `overlay.background` must reference **distinct underlying
992    /// buffers**. An aliased pair returns [`Error::AliasedBuffers`] without
993    /// dispatching to any backend — the GL path would otherwise read and
994    /// write the same texture in a single draw, which is undefined behaviour
995    /// on most drivers. Aliasing is detected via
996    /// [`TensorDyn::aliases`](edgefirst_tensor::TensorDyn::aliases), which
997    /// catches both shared-allocation clones and separate imports over the
998    /// same dmabuf fd.
999    ///
1000    /// # Migration from v0.16.3 and earlier
1001    ///
1002    /// Prior to v0.16.4 the call silently preserved `dst`'s contents on empty
1003    /// detections. That invariant no longer holds — `dst` is always fully
1004    /// written. Callers who pre-loaded an image into `dst` before calling this
1005    /// function must now pass that image via `overlay.background` instead.
1006    fn draw_decoded_masks(
1007        &mut self,
1008        dst: &mut TensorDyn,
1009        detect: &[DetectBox],
1010        segmentation: &[Segmentation],
1011        overlay: MaskOverlay<'_>,
1012    ) -> Result<()>;
1013
1014    /// Draw masks from proto data onto image (fused decode+draw).
1015    ///
1016    /// For YOLO segmentation models, this avoids materializing intermediate
1017    /// `Array3<u8>` masks. The `ProtoData` contains mask coefficients and the
1018    /// prototype tensor; the renderer computes `mask_coeff @ protos` directly
1019    /// at the output resolution using bilinear sampling.
1020    ///
1021    /// `detect` and `proto_data.mask_coefficients` must have the same length
1022    /// (enforced by zip — excess entries are silently ignored). An empty
1023    /// `detect` slice is valid and produces the base frame — cleared or
1024    /// background-blitted — via the selected backend's native primitive.
1025    ///
1026    /// # Format requirements and output contract
1027    ///
1028    /// Same as [`draw_decoded_masks`](Self::draw_decoded_masks), including
1029    /// the "always fully writes dst" guarantee across all four
1030    /// detection/background combinations.
1031    ///
1032    /// `overlay` controls compositing — see [`draw_decoded_masks`](Self::draw_decoded_masks).
1033    fn draw_proto_masks(
1034        &mut self,
1035        dst: &mut TensorDyn,
1036        detect: &[DetectBox],
1037        proto_data: &ProtoData,
1038        overlay: MaskOverlay<'_>,
1039    ) -> Result<()>;
1040
1041    /// Sets the colors used for rendering segmentation masks. Up to 20 colors
1042    /// can be set.
1043    ///
1044    /// On [`ImageProcessor`] with no forced backend this reaches every
1045    /// available backend rather than the first that accepts, because a
1046    /// later draw may land on any of them. A forced backend receives the
1047    /// colors alone.
1048    fn set_class_colors(&mut self, colors: &[[u8; 4]]) -> Result<()>;
1049
1050    /// Like [`convert`](Self::convert), but does not wait for the GPU to finish.
1051    ///
1052    /// This is the batch-preprocessing primitive: a caller renders `N` tiles
1053    /// into one batched destination by looping
1054    /// `convert_deferred(&src[n], &mut dst.batch(n)?, …)` and then calling
1055    /// [`flush`](Self::flush) **once**. On the OpenGL backend every deferred
1056    /// convert into sibling views of one buffer shares a single EGLImage import
1057    /// (the tile is a `glViewport`/`glScissor` ROI into the parent) and skips the
1058    /// per-tile `glFinish`; `flush` then issues a single GPU sync. The result of
1059    /// a deferred convert is **not** safe to read on the CPU (or map via CUDA)
1060    /// until `flush` returns.
1061    ///
1062    /// The default implementation is eager — it simply calls
1063    /// [`convert`](Self::convert), so CPU/G2D and any backend without a deferred
1064    /// fast path remain correct (each call completes synchronously and `flush`
1065    /// is a no-op).
1066    fn convert_deferred(
1067        &mut self,
1068        src: &TensorDyn,
1069        dst: &mut TensorDyn,
1070        rotation: Rotation,
1071        flip: Flip,
1072        crop: Crop,
1073    ) -> Result<()> {
1074        self.convert(src, dst, rotation, flip, crop)
1075    }
1076
1077    /// Complete all work enqueued by [`convert_deferred`](Self::convert_deferred)
1078    /// since the last flush, issuing a single GPU synchronization.
1079    ///
1080    /// After this returns, every deferred destination is finished and safe to
1081    /// read back or `cuda_map`. Backends with no deferred path (the default)
1082    /// return `Ok(())` immediately, since their converts already completed.
1083    fn flush(&mut self) -> Result<()> {
1084        Ok(())
1085    }
1086}
1087
1088/// Configuration for [`ImageProcessor`] construction.
1089///
1090/// Use with [`ImageProcessor::with_config`] to override the default EGL
1091/// display auto-detection and backend selection. The default configuration
1092/// preserves the existing auto-detection behaviour.
1093#[derive(Debug, Clone, Default)]
1094pub struct ImageProcessorConfig {
1095    /// Force OpenGL to use this EGL display type instead of auto-detecting.
1096    ///
1097    /// When `None`, the processor probes displays in priority order: GBM,
1098    /// PlatformDevice, Default. Use [`probe_egl_displays`] to discover
1099    /// which displays are available on the current system.
1100    ///
1101    /// Ignored when `EDGEFIRST_DISABLE_GL=1` is set, and on macOS and
1102    /// Windows (ANGLE is the only display there — Metal and Direct3D 11
1103    /// respectively; a `Some` value logs a debug note and is otherwise
1104    /// ignored).
1105    #[cfg(all(
1106        any(
1107            target_os = "linux",
1108            target_os = "macos",
1109            target_os = "ios",
1110            target_os = "android",
1111            target_os = "windows"
1112        ),
1113        feature = "opengl"
1114    ))]
1115    pub egl_display: Option<EglDisplayKind>,
1116
1117    /// Preferred compute backend.
1118    ///
1119    /// When set to a specific backend (not [`ComputeBackend::Auto`]), the
1120    /// processor initializes that backend with no fallback — returns an error if the conversion is not supported.
1121    /// This takes precedence over `EDGEFIRST_FORCE_BACKEND` and the
1122    /// `EDGEFIRST_DISABLE_*` environment variables.
1123    ///
1124    /// - [`ComputeBackend::OpenGl`]: init OpenGL + CPU, skip G2D
1125    /// - [`ComputeBackend::G2d`]: init G2D + CPU, skip OpenGL
1126    /// - [`ComputeBackend::Cpu`]: init CPU only
1127    /// - [`ComputeBackend::Auto`]: existing env-var-driven selection
1128    pub backend: ComputeBackend,
1129
1130    /// Colorimetry/performance trade-off for `convert()` (see
1131    /// [`ColorimetryMode`]). Defaults to [`ColorimetryMode::Fast`]. The
1132    /// `EDGEFIRST_COLORIMETRY` environment variable (`fast` | `exact`)
1133    /// overrides this setting when present.
1134    pub colorimetry: ColorimetryMode,
1135
1136    /// How many buffer imports the OpenGL backend keeps cached, per cache.
1137    ///
1138    /// `None` (the default) uses `EDGEFIRST_EGL_CACHE_CAPACITY` if set, and
1139    /// 16 otherwise. Precedence is:
1140    ///
1141    /// ```text
1142    /// egl_cache_capacity  >  EDGEFIRST_EGL_CACHE_CAPACITY  >  16
1143    /// ```
1144    ///
1145    /// Config beats the environment deliberately, matching
1146    /// [`backend`](Self::backend): an embedder that has measured its own
1147    /// pool must not be silently overridden by a stray environment variable
1148    /// in the deployment. `Some(0)` is rejected and falls through to the
1149    /// environment, then the default, exactly as an unparseable env value
1150    /// does.
1151    ///
1152    /// # This is a buffer *count*, not a size
1153    ///
1154    /// A cached import pins its buffer — the underlying dma-buf or IOSurface
1155    /// cannot be freed while an entry names it (that is what makes the cache
1156    /// correct as well as fast; see the "EGL image cache inside HAL" section
1157    /// of `ARCHITECTURE.md`). So the memory this holds depends entirely on
1158    /// how big the caller's buffers are:
1159    ///
1160    /// ```text
1161    /// worst-case pinned bytes                                (upper bound)
1162    ///     = capacity x 3 caches x frame size x live ImageProcessors
1163    ///
1164    /// capacity 16, 640x640 RGB   (1.2 MB)  ->   59 MB per processor
1165    /// capacity 16, 1080p NV12    (3.1 MB)  ->  149 MB per processor
1166    /// capacity 16, 4K NV12      (12.4 MB)  ->  597 MB per processor
1167    /// ```
1168    ///
1169    /// A ceiling rather than an exact total: the source and NV R8 caches
1170    /// often hold two imports of the *same* buffer, and two entries naming
1171    /// one buffer pin it once. The per-cache figure is the exact one.
1172    ///
1173    /// The bound is **per [`ImageProcessor`], times three caches** (source,
1174    /// destination, and the NV R8 source cache) — not per process and not
1175    /// per library. Two `ImageProcessor`s in one process have six
1176    /// independent caches and six independent capacities.
1177    ///
1178    /// Whether the worst case is ever approached is up to the caller. A
1179    /// pooled producer — V4L2 capture, typically 4–8 buffers over one or two
1180    /// sizes — settles at its pool size and never nears the bound. A stream
1181    /// that allocates a fresh buffer per frame holds the last `capacity` of
1182    /// them outright, which is the difference between "16 is free" and "16
1183    /// is 597 MB".
1184    ///
1185    /// Size it from measurement rather than guesswork:
1186    /// [`CacheStats::peak_entries`] reports the working set actually
1187    /// reached, and [`CacheStats::evictions`] tells you when the bound is
1188    /// costing you re-imports (it should be zero in a steady state).
1189    ///
1190    /// Ignored when the OpenGL backend is not in use.
1191    pub egl_cache_capacity: Option<usize>,
1192}
1193
1194/// How `convert()` trades colorimetric exactness against speed on platforms
1195/// where the exact path is expensive.
1196///
1197/// Today this affects one decision: NV12 sources on Vivante GC7000UL
1198/// (i.MX 8M Plus), where the hardware external sampler converts ~12× faster
1199/// than the colorimetry-exact in-shader matrix (2.5 ms vs 29 ms at 720p)
1200/// but applies the driver's fixed BT.601-limited matrix regardless of the
1201/// source's tagged colorimetry. Platforms where the exact path is already
1202/// the fastest correct path (Mali, V3D, Tegra, ANGLE) behave identically in
1203/// both modes.
1204///
1205/// Override at runtime with `EDGEFIRST_COLORIMETRY=fast|exact` (takes
1206/// precedence over the config field), or per-source by forcing a path with
1207/// `EDGEFIRST_NV_CONVERT_PATH`.
1208#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1209pub enum ColorimetryMode {
1210    /// Prefer the fastest path whose output is correct-enough video RGB
1211    /// (default; issue #106 policy). On Vivante, NV12 takes the hardware
1212    /// sampler even when the source is not BT.601-limited.
1213    #[default]
1214    Fast,
1215    /// Prefer bit-exact colorimetry everywhere: the fast path is used only
1216    /// when it matches the source's resolved (encoding, range) exactly.
1217    Exact,
1218}
1219
1220/// Compute backend selection for [`ImageProcessor`].
1221///
1222/// Use with [`ImageProcessorConfig::backend`] to select which backend the
1223/// processor should prefer. When a specific backend is selected, the
1224/// processor initializes that backend plus CPU as a fallback. When `Auto`
1225/// is used, the existing environment-variable-driven selection applies.
1226#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1227pub enum ComputeBackend {
1228    /// Auto-detect based on available hardware and environment variables.
1229    #[default]
1230    Auto,
1231    /// CPU-only processing (no hardware acceleration).
1232    Cpu,
1233    /// Prefer G2D hardware blitter (+ CPU fallback).
1234    G2d,
1235    /// Prefer OpenGL ES (+ CPU fallback).
1236    OpenGl,
1237}
1238
1239/// Backend forced via the `EDGEFIRST_FORCE_BACKEND` environment variable
1240/// or [`ImageProcessorConfig::backend`].
1241///
1242/// When set, the [`ImageProcessor`] only initializes and dispatches to the
1243/// selected backend — no fallback chain is used.
1244#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1245pub(crate) enum ForcedBackend {
1246    Cpu,
1247    G2d,
1248    OpenGl,
1249}
1250
1251/// Reports which float-color-buffer extensions the GPU backend detected.
1252/// Returned by [`ImageProcessor::supported_render_dtypes`]; the two flags
1253/// are independent.
1254///
1255/// **Linux:** reflects the real probe results from `GL_EXT_color_buffer_half_float`
1256/// and `GL_EXT_color_buffer_float`. On V3D (RPi 5) and Mali-G310 (i.MX 95)
1257/// both flags are typically `true`; on Vivante GC7000UL both are forced
1258/// `false` (float readback measured 170–320 ms — disabled). Tegra Orin
1259/// exposes both via PBO; the flags match the GPU report.
1260///
1261/// **macOS (ANGLE):** `f16 == true` gates the RGBA16F-packed IOSurface
1262/// path for F16 `PlanarRgb` destinations. `f32` reflects the GL
1263/// extension probe but is not actionable — ANGLE's
1264/// `EGL_ANGLE_iosurface_client_buffer` rejects every `(GL_FLOAT, *)`
1265/// combination with `EGL_BAD_ATTRIBUTE`, so there is no F32 IOSurface
1266/// path.
1267///
1268/// **Windows (ANGLE/D3D11):** `f16` enables the RGBA16F texture paths and
1269/// `f32` the RGBA32F ones for every float layout (`float_dispatch.rs`) —
1270/// `PlanarRgb` and `PlanarRgba` NCHW, interleaved `Rgb`, and `Rgba`.
1271///
1272/// Regardless of these flags, [`ImageProcessor::convert`] never returns
1273/// an error due to float capability — it falls back to CPU when the GPU
1274/// path is unavailable.
1275#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1276pub struct RenderDtypeSupport {
1277    /// `GL_EXT_color_buffer_float` is available on the current GPU.
1278    ///
1279    /// On Linux, `true` enables F32 `Rgb` NHWC PBO readback. On macOS
1280    /// this flag is informational only — no F32 IOSurface path exists.
1281    /// On Windows, `true` enables the RGBA32F zero-copy texture render
1282    /// for every F32 layout.
1283    pub f32: bool,
1284    /// `GL_EXT_color_buffer_half_float` is available on the current GPU.
1285    ///
1286    /// On Linux, `true` enables F16 `PlanarRgb` NCHW PBO readback and,
1287    /// on V3D/Mali, zero-copy DMA-BUF render. On macOS, `true` enables
1288    /// F16 `PlanarRgb` via RGBA16F-packed IOSurface (zero-copy). On
1289    /// Windows, `true` enables the RGBA16F zero-copy texture render for
1290    /// every F16 layout.
1291    pub f16: bool,
1292}
1293
1294/// Returns `true` when a float PBO destination should be attempted for `dtype`.
1295///
1296/// Only F16 and F32 are eligible, and only when the corresponding flag in
1297/// `support` is set. U8/I8 and all other dtypes return `false` — they are
1298/// handled by the existing `dtype.size() == 1` PBO gate.
1299///
1300/// The same question `create_image` asks before trying a zero-copy float
1301/// destination, which is why that gate calls this rather than restating it:
1302/// a float image is only worth allocating on the GPU where the display can
1303/// render into that dtype at all.
1304///
1305/// Compiled on every platform with a GL backend -- the name is historical
1306/// (it began as the float *PBO* gate) and both callers are live everywhere
1307/// the `opengl` feature builds a backend.
1308#[cfg(all(
1309    any(
1310        target_os = "linux",
1311        target_os = "windows",
1312        target_os = "macos",
1313        target_os = "ios",
1314        target_os = "android"
1315    ),
1316    feature = "opengl"
1317))]
1318pub(crate) fn float_pbo_eligible(dtype: DType, support: RenderDtypeSupport) -> bool {
1319    match dtype {
1320        DType::F16 => support.f16,
1321        DType::F32 => support.f32,
1322        _ => false,
1323    }
1324}
1325
1326/// Image converter that uses available hardware acceleration or CPU as a
1327/// fallback.
1328#[derive(Debug)]
1329pub struct ImageProcessor {
1330    /// CPU-based image converter as a fallback. This is only None if the
1331    /// EDGEFIRST_DISABLE_CPU environment variable is set.
1332    pub cpu: Option<CPUProcessor>,
1333
1334    #[cfg(target_os = "linux")]
1335    /// G2D-based image converter for Linux systems. This is only available if
1336    /// the EDGEFIRST_DISABLE_G2D environment variable is not set and libg2d.so
1337    /// is available.
1338    pub g2d: Option<G2DProcessor>,
1339    #[cfg(target_os = "linux")]
1340    #[cfg(feature = "opengl")]
1341    /// OpenGL-based image converter for Linux systems. This is only available
1342    /// if the EDGEFIRST_DISABLE_GL environment variable is not set and OpenGL
1343    /// ES is available.
1344    pub opengl: Option<GLProcessorThreaded>,
1345    #[cfg(any(
1346        target_os = "macos",
1347        target_os = "ios",
1348        target_os = "android",
1349        target_os = "windows"
1350    ))]
1351    #[cfg(feature = "opengl")]
1352    /// OpenGL-based image converter — the same unified
1353    /// `GLProcessorThreaded` engine as Linux (its worker owns a
1354    /// per-processor context). macOS/iOS run it via ANGLE + IOSurface
1355    /// (available when ANGLE's libEGL.dylib can be loaded — see
1356    /// README.md § macOS GPU Acceleration); Android runs it via the
1357    /// native EGL driver + AHardwareBuffer; Windows runs it via ANGLE +
1358    /// Direct3D 11 with PBO transfers (available when ANGLE's libEGL.dll
1359    /// can be loaded — see README.md § Windows GPU Acceleration).
1360    pub opengl: Option<GLProcessorThreaded>,
1361
1362    /// When set, only the specified backend is used — no fallback chain.
1363    pub(crate) forced_backend: Option<ForcedBackend>,
1364
1365    /// Converts where the GL backend declined and the auto chain fell
1366    /// through toward G2D/CPU. Owned by the dispatcher (the GL worker never
1367    /// sees these frames), unlike the per-feed counters in
1368    /// `GLProcessorThreaded::convert_stats`. Read via
1369    /// [`convert_fallback_count`](Self::convert_fallback_count).
1370    pub(crate) convert_fallbacks: std::sync::atomic::AtomicU64,
1371}
1372
1373unsafe impl Send for ImageProcessor {}
1374unsafe impl Sync for ImageProcessor {}
1375
1376impl ImageProcessor {
1377    /// Creates a new `ImageProcessor` instance, initializing available
1378    /// hardware converters based on the system capabilities and environment
1379    /// variables.
1380    ///
1381    /// # Examples
1382    /// ```rust,no_run
1383    /// # use edgefirst_image::{ImageProcessor, Rotation, Flip, Crop, ImageProcessorTrait};
1384    /// # use edgefirst_codec::{peek_info, ImageDecoder, ImageLoad};
1385    /// # use edgefirst_tensor::{CpuAccess, PixelFormat, DType, Tensor, TensorMemory};
1386    /// # fn main() -> Result<(), edgefirst_image::Error> {
1387    /// let image = std::fs::read("zidane.jpg")?;
1388    /// // The codec emits the source's native format (a colour JPEG decodes to
1389    /// // NV12) and configures the destination tensor during the decode.
1390    /// let info = peek_info(&image).expect("peek");
1391    /// let mut src = Tensor::<u8>::image(info.width, info.height, info.format,
1392    ///                                    Some(TensorMemory::Mem), CpuAccess::ReadWrite)?;
1393    /// let mut decoder = ImageDecoder::new();
1394    /// src.load_image(&mut decoder, &image).expect("decode");
1395    /// let mut converter = ImageProcessor::new()?;
1396    /// let mut dst =
1397    ///     converter.create_image(640, 480, PixelFormat::Rgb, DType::U8, None, CpuAccess::ReadWrite)?;
1398    /// converter.convert(&src.into(), &mut dst, Rotation::None, Flip::None, Crop::default())?;
1399    /// # Ok(())
1400    /// # }
1401    /// ```
1402    pub fn new() -> Result<Self> {
1403        Self::with_config(ImageProcessorConfig::default())
1404    }
1405
1406    /// Number of converts where the auto chain's GL backend declined and the
1407    /// frame fell through toward G2D/CPU — each one silently lost any
1408    /// zero-copy guarantee. A pipeline that expects to stay on the GPU
1409    /// asserts this stays flat across its steady-state loop; pair with
1410    /// `GLProcessorThreaded::convert_stats` to see how the frames that DID
1411    /// reach GL were fed. Always 0 under a forced backend (no chain).
1412    pub fn convert_fallback_count(&self) -> u64 {
1413        self.convert_fallbacks
1414            .load(std::sync::atomic::Ordering::Relaxed)
1415    }
1416
1417    /// Number of [`Compression::Any`](edgefirst_tensor::Compression)
1418    /// requests since process start that resolved to a linear layout
1419    /// (process-wide mirror of
1420    /// [`edgefirst_tensor::compression_fallback_count`], surfaced here
1421    /// beside [`convert_fallback_count`](Self::convert_fallback_count)
1422    /// so pipeline telemetry reads from one place). A pipeline that
1423    /// expects compressed convert destinations asserts this stays flat
1424    /// after warmup.
1425    pub fn compression_fallback_count(&self) -> u64 {
1426        edgefirst_tensor::compression_fallback_count()
1427    }
1428
1429    /// Convert and return a native fence that signals when the GPU work
1430    /// completes, instead of blocking the CPU — the GL→NPU handoff
1431    /// (`EGL_ANDROID_native_fence_sync` on Android). On Windows the handle
1432    /// is a manual-reset event set when the GPU work completes; wait on it
1433    /// with `WaitForSingleObject`.
1434    ///
1435    /// `Ok(Some(fence))`: the destination buffer is still in flight; hand
1436    /// the fence to the consumer (e.g.
1437    /// `ANeuralNetworksExecution_startComputeWithDependencies` on Android,
1438    /// or `WaitForSingleObject` on Windows) or poll it before reading.
1439    /// `Ok(None)`: the convert completed with the normal blocking contract
1440    /// (no native fence on this platform, or a non-GL backend handled the
1441    /// frame) — the destination is already safe. Semantics are otherwise
1442    /// identical to [`convert`](ImageProcessorTrait::convert), including
1443    /// the GL→G2D→CPU fallback chain.
1444    ///
1445    /// On Windows, **for a texture destination**, the event and the
1446    /// destination's own `Tensor::gpu_completion` name the same fence value:
1447    /// one flush and one signal cover the convert, and the value recorded on
1448    /// `dst` is the one this event waits for, so a consumer may use either.
1449    /// A destination that is not a texture records nothing, and the event
1450    /// then carries a value of its own that only it can be waited on for.
1451    pub fn convert_with_fence(
1452        &mut self,
1453        src: &TensorDyn,
1454        dst: &mut TensorDyn,
1455        rotation: Rotation,
1456        flip: Flip,
1457        crop: Crop,
1458    ) -> Result<Option<CompletionFence>> {
1459        #[cfg(any(
1460            target_os = "linux",
1461            target_os = "macos",
1462            target_os = "ios",
1463            target_os = "android",
1464            target_os = "windows"
1465        ))]
1466        #[cfg(feature = "opengl")]
1467        {
1468            let gl_forced = matches!(self.forced_backend, Some(ForcedBackend::OpenGl));
1469            if self.forced_backend.is_none() || gl_forced {
1470                if let Some(opengl) = self.opengl.as_mut() {
1471                    match opengl.convert_with_fence(src, dst, rotation, flip, crop) {
1472                        Ok(fd) => return Ok(fd),
1473                        Err(e) if gl_forced => return Err(e),
1474                        Err(e) => {
1475                            // Not counted here: the blocking chain below
1476                            // re-runs the dispatcher, whose decline arm
1477                            // counts the fallback exactly once.
1478                            log::debug!(
1479                                "convert_with_fence: opengl declined, \
1480                                 falling back to the blocking chain: {e}"
1481                            );
1482                        }
1483                    }
1484                } else if gl_forced {
1485                    return Err(Error::ForcedBackendUnavailable("opengl".into()));
1486                }
1487            }
1488        }
1489        // Blocking chain (G2D/CPU, forced non-GL, or non-GL builds):
1490        // completion == return, so no fence is needed.
1491        self.convert(src, dst, rotation, flip, crop)?;
1492        Ok(None)
1493    }
1494
1495    /// Report which float dtypes the GPU can render to.
1496    ///
1497    /// Probes `GL_EXT_color_buffer_half_float` and
1498    /// `GL_EXT_color_buffer_float` once at `ImageProcessor::new()` time
1499    /// and caches the result. Call this once at startup to decide whether
1500    /// to request F16 or F32 destination tensors; [`create_image`] uses
1501    /// the result internally to auto-select float PBO when supported.
1502    ///
1503    /// Returns `RenderDtypeSupport { f32: false, f16: false }` when no
1504    /// OpenGL backend is active or the `opengl` feature is disabled.
1505    ///
1506    /// [`create_image`]: Self::create_image
1507    pub fn supported_render_dtypes(&self) -> RenderDtypeSupport {
1508        #[cfg(all(
1509            any(
1510                target_os = "macos",
1511                target_os = "ios",
1512                target_os = "android",
1513                target_os = "windows"
1514            ),
1515            feature = "opengl"
1516        ))]
1517        if let Some(gl) = self.opengl.as_ref() {
1518            return gl.supported_render_dtypes();
1519        }
1520        #[cfg(all(target_os = "linux", feature = "opengl"))]
1521        if let Some(gl) = self.opengl.as_ref() {
1522            return gl.supported_render_dtypes();
1523        }
1524        RenderDtypeSupport {
1525            f32: false,
1526            f16: false,
1527        }
1528    }
1529
1530    /// Creates a new `ImageProcessor` with the given configuration.
1531    ///
1532    /// When [`ImageProcessorConfig::backend`] is set to a specific backend,
1533    /// environment variables are ignored and the processor initializes the
1534    /// requested backend plus CPU as a fallback.
1535    ///
1536    /// When `Auto`, the existing `EDGEFIRST_FORCE_BACKEND` and
1537    /// `EDGEFIRST_DISABLE_*` environment variables apply.
1538    #[allow(unused_variables)]
1539    pub fn with_config(config: ImageProcessorConfig) -> Result<Self> {
1540        // ── Config-driven backend selection ──────────────────────────
1541        // When the caller explicitly requests a backend via the config,
1542        // skip all environment variable logic.
1543        match config.backend {
1544            ComputeBackend::Cpu => {
1545                log::info!("ComputeBackend::Cpu — CPU only");
1546                return Ok(Self {
1547                    cpu: Some(CPUProcessor::new()),
1548                    #[cfg(target_os = "linux")]
1549                    g2d: None,
1550                    #[cfg(target_os = "linux")]
1551                    #[cfg(feature = "opengl")]
1552                    opengl: None,
1553                    #[cfg(any(
1554                        target_os = "macos",
1555                        target_os = "ios",
1556                        target_os = "android",
1557                        target_os = "windows"
1558                    ))]
1559                    #[cfg(feature = "opengl")]
1560                    opengl: None,
1561                    convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1562                    forced_backend: None,
1563                });
1564            }
1565            ComputeBackend::G2d => {
1566                log::info!("ComputeBackend::G2d — G2D + CPU fallback");
1567                #[cfg(target_os = "linux")]
1568                {
1569                    let g2d = match G2DProcessor::new() {
1570                        Ok(g) => Some(g),
1571                        Err(e) => {
1572                            log::warn!("G2D requested but failed to initialize: {e:?}");
1573                            None
1574                        }
1575                    };
1576                    return Ok(Self {
1577                        cpu: Some(CPUProcessor::new()),
1578                        g2d,
1579                        #[cfg(feature = "opengl")]
1580                        opengl: None,
1581                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1582                        forced_backend: None,
1583                    });
1584                }
1585                #[cfg(not(target_os = "linux"))]
1586                {
1587                    log::warn!("G2D requested but not available on this platform, using CPU");
1588                    return Ok(Self {
1589                        cpu: Some(CPUProcessor::new()),
1590                        #[cfg(any(
1591                            target_os = "macos",
1592                            target_os = "ios",
1593                            target_os = "android",
1594                            target_os = "windows"
1595                        ))]
1596                        #[cfg(feature = "opengl")]
1597                        opengl: None,
1598                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1599                        forced_backend: None,
1600                    });
1601                }
1602            }
1603            ComputeBackend::OpenGl => {
1604                log::info!("ComputeBackend::OpenGl — OpenGL + CPU fallback");
1605                #[cfg(target_os = "linux")]
1606                {
1607                    #[cfg(feature = "opengl")]
1608                    let opengl = match GLProcessorThreaded::with_cache_capacity(
1609                        config.egl_display,
1610                        config.egl_cache_capacity,
1611                    ) {
1612                        Ok(gl) => Some(gl),
1613                        Err(e) => {
1614                            log::warn!("OpenGL requested but failed to initialize: {e:?}");
1615                            None
1616                        }
1617                    };
1618                    return Ok(Self {
1619                        cpu: Some(CPUProcessor::new()),
1620                        g2d: None,
1621                        #[cfg(feature = "opengl")]
1622                        opengl,
1623                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1624                        forced_backend: None,
1625                    }
1626                    .apply_colorimetry_mode(config.colorimetry));
1627                }
1628                #[cfg(any(target_os = "macos", target_os = "ios"))]
1629                {
1630                    #[cfg(feature = "opengl")]
1631                    let opengl = match GLProcessorThreaded::with_cache_capacity(
1632                        config.egl_display,
1633                        config.egl_cache_capacity,
1634                    ) {
1635                        Ok(gl) => Some(gl),
1636                        Err(e) => {
1637                            log::warn!(
1638                                "OpenGL requested on macOS but ANGLE init failed: {e:?} \
1639                                 (install ANGLE via `brew install startergo/angle/angle` \
1640                                 and re-sign the dylibs — see README.md § macOS GPU \
1641                                 Acceleration). Falling back to CPU."
1642                            );
1643                            None
1644                        }
1645                    };
1646                    return Ok(Self {
1647                        cpu: Some(CPUProcessor::new()),
1648                        #[cfg(feature = "opengl")]
1649                        opengl,
1650                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1651                        forced_backend: None,
1652                    }
1653                    .apply_colorimetry_mode(config.colorimetry));
1654                }
1655                #[cfg(target_os = "android")]
1656                {
1657                    #[cfg(feature = "opengl")]
1658                    let opengl = match GLProcessorThreaded::with_cache_capacity(
1659                        config.egl_display,
1660                        config.egl_cache_capacity,
1661                    ) {
1662                        Ok(gl) => Some(gl),
1663                        Err(e) => {
1664                            log::warn!(
1665                                "OpenGL requested but native EGL init failed: {e:?}. \
1666                                 Falling back to CPU."
1667                            );
1668                            None
1669                        }
1670                    };
1671                    return Ok(Self {
1672                        cpu: Some(CPUProcessor::new()),
1673                        #[cfg(feature = "opengl")]
1674                        opengl,
1675                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1676                        forced_backend: None,
1677                    }
1678                    .apply_colorimetry_mode(config.colorimetry));
1679                }
1680                #[cfg(target_os = "windows")]
1681                {
1682                    #[cfg(feature = "opengl")]
1683                    let opengl = match GLProcessorThreaded::with_cache_capacity(
1684                        config.egl_display,
1685                        config.egl_cache_capacity,
1686                    ) {
1687                        Ok(gl) => Some(gl),
1688                        Err(e) => {
1689                            log::warn!(
1690                                "OpenGL requested on Windows but ANGLE/D3D11 init failed: {e:?} \
1691                                 (fetch ANGLE with `scripts/fetch-angle.sh` and set \
1692                                 EDGEFIRST_ANGLE_PATH to the directory containing libEGL.dll + \
1693                                 libGLESv2.dll, or place the two DLLs next to the executable — \
1694                                 see README.md § Windows GPU Acceleration). Falling back to CPU."
1695                            );
1696                            None
1697                        }
1698                    };
1699                    return Ok(Self {
1700                        cpu: Some(CPUProcessor::new()),
1701                        #[cfg(feature = "opengl")]
1702                        opengl,
1703                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1704                        forced_backend: None,
1705                    }
1706                    .apply_colorimetry_mode(config.colorimetry));
1707                }
1708                #[cfg(not(any(
1709                    target_os = "linux",
1710                    target_os = "macos",
1711                    target_os = "ios",
1712                    target_os = "android",
1713                    target_os = "windows"
1714                )))]
1715                {
1716                    log::warn!("OpenGL requested but not available on this platform, using CPU");
1717                    return Ok(Self {
1718                        cpu: Some(CPUProcessor::new()),
1719                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1720                        forced_backend: None,
1721                    });
1722                }
1723            }
1724            ComputeBackend::Auto => { /* fall through to env-var logic below */ }
1725        }
1726
1727        // ── EDGEFIRST_FORCE_BACKEND ──────────────────────────────────
1728        // When set, only the requested backend is initialised and no
1729        // fallback chain is used. Accepted values (case-insensitive):
1730        //   "cpu", "g2d", "opengl"
1731        if let Ok(val) = std::env::var("EDGEFIRST_FORCE_BACKEND") {
1732            let val_lower = val.to_lowercase();
1733            let forced = match val_lower.as_str() {
1734                "cpu" => ForcedBackend::Cpu,
1735                "g2d" => ForcedBackend::G2d,
1736                "opengl" => ForcedBackend::OpenGl,
1737                other => {
1738                    return Err(Error::ForcedBackendUnavailable(format!(
1739                        "unknown EDGEFIRST_FORCE_BACKEND value: {other:?} (expected cpu, g2d, or opengl)"
1740                    )));
1741                }
1742            };
1743
1744            log::info!("EDGEFIRST_FORCE_BACKEND={val} — only initializing {val_lower} backend");
1745
1746            return match forced {
1747                ForcedBackend::Cpu => Ok(Self {
1748                    cpu: Some(CPUProcessor::new()),
1749                    #[cfg(target_os = "linux")]
1750                    g2d: None,
1751                    #[cfg(target_os = "linux")]
1752                    #[cfg(feature = "opengl")]
1753                    opengl: None,
1754                    #[cfg(any(
1755                        target_os = "macos",
1756                        target_os = "ios",
1757                        target_os = "android",
1758                        target_os = "windows"
1759                    ))]
1760                    #[cfg(feature = "opengl")]
1761                    opengl: None,
1762                    convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1763                    forced_backend: Some(ForcedBackend::Cpu),
1764                }),
1765                ForcedBackend::G2d => {
1766                    #[cfg(target_os = "linux")]
1767                    {
1768                        let g2d = G2DProcessor::new().map_err(|e| {
1769                            Error::ForcedBackendUnavailable(format!(
1770                                "g2d forced but failed to initialize: {e:?}"
1771                            ))
1772                        })?;
1773                        Ok(Self {
1774                            cpu: None,
1775                            g2d: Some(g2d),
1776                            #[cfg(feature = "opengl")]
1777                            opengl: None,
1778                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1779                            forced_backend: Some(ForcedBackend::G2d),
1780                        })
1781                    }
1782                    #[cfg(not(target_os = "linux"))]
1783                    {
1784                        Err(Error::ForcedBackendUnavailable(
1785                            "g2d backend is only available on Linux".into(),
1786                        ))
1787                    }
1788                }
1789                ForcedBackend::OpenGl => {
1790                    #[cfg(target_os = "linux")]
1791                    #[cfg(feature = "opengl")]
1792                    {
1793                        let opengl = GLProcessorThreaded::with_cache_capacity(
1794                            config.egl_display,
1795                            config.egl_cache_capacity,
1796                        )
1797                        .map_err(|e| {
1798                            Error::ForcedBackendUnavailable(format!(
1799                                "opengl forced but failed to initialize: {e:?}"
1800                            ))
1801                        })?;
1802                        Ok(Self {
1803                            cpu: None,
1804                            g2d: None,
1805                            opengl: Some(opengl),
1806                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1807                            forced_backend: Some(ForcedBackend::OpenGl),
1808                        }
1809                        .apply_colorimetry_mode(config.colorimetry))
1810                    }
1811                    #[cfg(any(target_os = "macos", target_os = "ios"))]
1812                    #[cfg(feature = "opengl")]
1813                    {
1814                        let opengl = GLProcessorThreaded::with_cache_capacity(
1815                            config.egl_display,
1816                            config.egl_cache_capacity,
1817                        )
1818                        .map_err(|e| {
1819                            Error::ForcedBackendUnavailable(format!(
1820                                "opengl forced on macOS but ANGLE init failed: {e:?}"
1821                            ))
1822                        })?;
1823                        Ok(Self {
1824                            cpu: None,
1825                            opengl: Some(opengl),
1826                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1827                            forced_backend: Some(ForcedBackend::OpenGl),
1828                        }
1829                        .apply_colorimetry_mode(config.colorimetry))
1830                    }
1831                    #[cfg(target_os = "android")]
1832                    #[cfg(feature = "opengl")]
1833                    {
1834                        let opengl = GLProcessorThreaded::with_cache_capacity(
1835                            config.egl_display,
1836                            config.egl_cache_capacity,
1837                        )
1838                        .map_err(|e| {
1839                            Error::ForcedBackendUnavailable(format!(
1840                                "opengl forced but native EGL init failed: {e:?}"
1841                            ))
1842                        })?;
1843                        Ok(Self {
1844                            cpu: None,
1845                            opengl: Some(opengl),
1846                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1847                            forced_backend: Some(ForcedBackend::OpenGl),
1848                        }
1849                        .apply_colorimetry_mode(config.colorimetry))
1850                    }
1851                    #[cfg(target_os = "windows")]
1852                    #[cfg(feature = "opengl")]
1853                    {
1854                        let opengl = GLProcessorThreaded::with_cache_capacity(
1855                            config.egl_display,
1856                            config.egl_cache_capacity,
1857                        )
1858                        .map_err(|e| {
1859                            Error::ForcedBackendUnavailable(format!(
1860                                "opengl forced on Windows but ANGLE/D3D11 init failed: {e:?}"
1861                            ))
1862                        })?;
1863                        Ok(Self {
1864                            cpu: None,
1865                            opengl: Some(opengl),
1866                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1867                            forced_backend: Some(ForcedBackend::OpenGl),
1868                        }
1869                        .apply_colorimetry_mode(config.colorimetry))
1870                    }
1871                    #[cfg(not(all(
1872                        any(
1873                            target_os = "linux",
1874                            target_os = "macos",
1875                            target_os = "ios",
1876                            target_os = "android",
1877                            target_os = "windows"
1878                        ),
1879                        feature = "opengl"
1880                    )))]
1881                    {
1882                        Err(Error::ForcedBackendUnavailable(
1883                            "opengl backend requires Linux, macOS, Android or Windows with the \
1884                             'opengl' feature enabled"
1885                                .into(),
1886                        ))
1887                    }
1888                }
1889            };
1890        }
1891
1892        // ── Existing DISABLE logic (unchanged) ──────────────────────
1893        #[cfg(target_os = "linux")]
1894        let g2d = if std::env::var("EDGEFIRST_DISABLE_G2D")
1895            .map(|x| x != "0" && x.to_lowercase() != "false")
1896            .unwrap_or(false)
1897        {
1898            log::debug!("EDGEFIRST_DISABLE_G2D is set");
1899            None
1900        } else {
1901            match G2DProcessor::new() {
1902                Ok(g2d_converter) => Some(g2d_converter),
1903                Err(err) => {
1904                    log::warn!("Failed to initialize G2D converter: {err:?}");
1905                    None
1906                }
1907            }
1908        };
1909
1910        #[cfg(target_os = "linux")]
1911        #[cfg(feature = "opengl")]
1912        let opengl = if std::env::var("EDGEFIRST_DISABLE_GL")
1913            .map(|x| x != "0" && x.to_lowercase() != "false")
1914            .unwrap_or(false)
1915        {
1916            log::debug!("EDGEFIRST_DISABLE_GL is set");
1917            None
1918        } else {
1919            match GLProcessorThreaded::with_cache_capacity(
1920                config.egl_display,
1921                config.egl_cache_capacity,
1922            ) {
1923                Ok(gl_converter) => Some(gl_converter),
1924                Err(err) => {
1925                    log::warn!("Failed to initialize GL converter: {err:?}");
1926                    None
1927                }
1928            }
1929        };
1930
1931        #[cfg(any(
1932            target_os = "macos",
1933            target_os = "ios",
1934            target_os = "android",
1935            target_os = "windows"
1936        ))]
1937        #[cfg(feature = "opengl")]
1938        let opengl = if std::env::var("EDGEFIRST_DISABLE_GL")
1939            .map(|x| x != "0" && x.to_lowercase() != "false")
1940            .unwrap_or(false)
1941        {
1942            log::debug!("EDGEFIRST_DISABLE_GL is set");
1943            None
1944        } else {
1945            match GLProcessorThreaded::with_cache_capacity(
1946                config.egl_display,
1947                config.egl_cache_capacity,
1948            ) {
1949                Ok(gl_converter) => Some(gl_converter),
1950                Err(err) => {
1951                    log::debug!(
1952                        "GL backend unavailable: {err:?} \
1953                         (CPU fallback will be used)"
1954                    );
1955                    None
1956                }
1957            }
1958        };
1959
1960        let cpu = if std::env::var("EDGEFIRST_DISABLE_CPU")
1961            .map(|x| x != "0" && x.to_lowercase() != "false")
1962            .unwrap_or(false)
1963        {
1964            log::debug!("EDGEFIRST_DISABLE_CPU is set");
1965            None
1966        } else {
1967            Some(CPUProcessor::new())
1968        };
1969        Ok(Self {
1970            cpu,
1971            #[cfg(target_os = "linux")]
1972            g2d,
1973            #[cfg(target_os = "linux")]
1974            #[cfg(feature = "opengl")]
1975            opengl,
1976            #[cfg(any(
1977                target_os = "macos",
1978                target_os = "ios",
1979                target_os = "android",
1980                target_os = "windows"
1981            ))]
1982            #[cfg(feature = "opengl")]
1983            opengl,
1984            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1985            forced_backend: None,
1986        }
1987        .apply_colorimetry_mode(config.colorimetry))
1988    }
1989
1990    /// Apply the configured [`ColorimetryMode`] to whichever backend honours
1991    /// it (currently the Linux GL backend); no-op elsewhere. Constructor
1992    /// plumbing for [`ImageProcessorConfig::colorimetry`].
1993    fn apply_colorimetry_mode(self, _mode: ColorimetryMode) -> Self {
1994        #[cfg(all(
1995            any(
1996                target_os = "linux",
1997                target_os = "macos",
1998                target_os = "ios",
1999                target_os = "android",
2000                target_os = "windows"
2001            ),
2002            feature = "opengl"
2003        ))]
2004        {
2005            let mut me = self;
2006            if let Err(e) = me.set_colorimetry_mode(_mode) {
2007                log::warn!("Failed to apply ColorimetryMode::{_mode:?}: {e:?}");
2008            }
2009            me
2010        }
2011        #[cfg(not(all(
2012            any(
2013                target_os = "linux",
2014                target_os = "macos",
2015                target_os = "ios",
2016                target_os = "android",
2017                target_os = "windows"
2018            ),
2019            feature = "opengl"
2020        )))]
2021        {
2022            let _ = _mode;
2023            self
2024        }
2025    }
2026
2027    /// Sets the colorimetry/performance trade-off (see [`ColorimetryMode`])
2028    /// on the OpenGL backend. No-op if OpenGL is not available. The
2029    /// `EDGEFIRST_COLORIMETRY` environment variable takes precedence — when
2030    /// it is set, this call logs and keeps the env-selected mode.
2031    #[cfg(all(
2032        any(
2033            target_os = "linux",
2034            target_os = "macos",
2035            target_os = "ios",
2036            target_os = "android",
2037            target_os = "windows"
2038        ),
2039        feature = "opengl"
2040    ))]
2041    pub fn set_colorimetry_mode(&mut self, mode: ColorimetryMode) -> Result<()> {
2042        if let Some(ref mut gl) = self.opengl {
2043            gl.set_colorimetry_mode(mode)?;
2044        }
2045        Ok(())
2046    }
2047
2048    /// Sets the interpolation mode for int8 proto textures on the OpenGL
2049    /// backend. No-op if OpenGL is not available.
2050    #[cfg(all(
2051        any(
2052            target_os = "linux",
2053            target_os = "macos",
2054            target_os = "ios",
2055            target_os = "android",
2056            target_os = "windows"
2057        ),
2058        feature = "opengl"
2059    ))]
2060    pub fn set_int8_interpolation_mode(&mut self, mode: Int8InterpolationMode) -> Result<()> {
2061        if let Some(ref mut gl) = self.opengl {
2062            gl.set_int8_interpolation_mode(mode)?;
2063        }
2064        Ok(())
2065    }
2066
2067    /// Allocate an image tensor from a declarative
2068    /// [`ImageDesc`](edgefirst_tensor::ImageDesc) request — the
2069    /// full-featured variant of [`create_image`](Self::create_image).
2070    ///
2071    /// Without a compression request this is exactly `create_image` (the
2072    /// processor's memory negotiation applies). With one, the allocation
2073    /// rides the tensor desc path un-negotiated: the layout decision
2074    /// belongs to the platform allocator, and the request's guards and
2075    /// fallback counting live there (see
2076    /// [`Tensor::image_desc`](edgefirst_tensor::Tensor::image_desc)).
2077    pub fn create_image_desc(&self, desc: &edgefirst_tensor::ImageDesc) -> Result<TensorDyn> {
2078        if desc.compression().is_none() {
2079            return self.create_image(
2080                desc.width(),
2081                desc.height(),
2082                desc.format(),
2083                desc.dtype(),
2084                desc.memory(),
2085                desc.access(),
2086            );
2087        }
2088        Ok(TensorDyn::image_desc(desc)?)
2089    }
2090
2091    /// Create a [`TensorDyn`] image with the best available memory backend.
2092    ///
2093    /// Priority: DMA-buf → float PBO (F16/F32) → u8/i8 PBO → system memory.
2094    ///
2095    /// Use this method instead of [`TensorDyn::image()`] when the tensor will
2096    /// be used with [`ImageProcessor::convert()`]. It selects the optimal
2097    /// memory backing (including PBO for GPU zero-copy) which direct
2098    /// allocation cannot achieve.
2099    ///
2100    /// This method is on [`ImageProcessor`] rather than [`ImageProcessorTrait`]
2101    /// because optimal allocation requires knowledge of the active compute
2102    /// backends (e.g. the GL context handle for PBO allocation). Individual
2103    /// backend implementations ([`CPUProcessor`], etc.) do not have this
2104    /// cross-backend visibility.
2105    ///
2106    /// **Float dtype behaviour:** when `dtype` is `F16` or `F32` and
2107    /// [`supported_render_dtypes`] reports the GPU supports that type,
2108    /// `memory: None` auto-selects a float PBO (Linux) or IOSurface (macOS
2109    /// F16 only). If GPU float support is absent the allocation falls through
2110    /// to `TensorMemory::Mem`; [`convert`] then uses the CPU path.
2111    /// Passing `memory: Some(TensorMemory::DmaBuf)` with `dtype: F32` always
2112    /// returns `Error::NotSupported` — no 32-bit-float DRM fourcc exists.
2113    ///
2114    /// [`supported_render_dtypes`]: Self::supported_render_dtypes
2115    /// [`convert`]: ImageProcessorTrait::convert
2116    ///
2117    /// # Arguments
2118    ///
2119    /// * `width` - Image width in pixels
2120    /// * `height` - Image height in pixels
2121    /// * `format` - Pixel format
2122    /// * `dtype` - Element data type (e.g. `DType::U8`, `DType::F16`, `DType::F32`)
2123    /// * `memory` - Optional memory type override; when `None`, the best
2124    ///   available backend is selected automatically.
2125    /// * `access` - Declares the CPU involvement you plan on. Use
2126    ///   [`CpuAccess::None`](edgefirst_tensor::CpuAccess) for a destination
2127    ///   that only hardware reads (an NPU input), and `ReadWrite` when you
2128    ///   will map it. Hardware access is always implied. Mapping a tensor
2129    ///   beyond its declaration is counted by
2130    ///   [`unplanned_cpu_access_count`](edgefirst_tensor::unplanned_cpu_access_count).
2131    ///
2132    /// # Returns
2133    ///
2134    /// A [`TensorDyn`] backed by the highest-performance memory type
2135    /// available on this system.
2136    ///
2137    /// # Examples
2138    ///
2139    /// ```rust,no_run
2140    /// # use edgefirst_image::ImageProcessor;
2141    /// # use edgefirst_tensor::{CpuAccess, DType, PixelFormat};
2142    /// # fn main() -> Result<(), edgefirst_image::Error> {
2143    /// let processor = ImageProcessor::new()?;
2144    ///
2145    /// // Model input the NPU reads directly — no CPU mapping planned.
2146    /// let input = processor.create_image(
2147    ///     640, 640, PixelFormat::Rgb, DType::U8, None, CpuAccess::None,
2148    /// )?;
2149    /// # let _ = input;
2150    /// # Ok(())
2151    /// # }
2152    /// ```
2153    ///
2154    /// # Pitch alignment for DMA-backed allocations
2155    ///
2156    /// DMA-BUF imports into the GL backend (Mali Valhall on i.MX 95
2157    /// specifically) require every row pitch to be a multiple of
2158    /// [`GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES`] (currently 64). When this
2159    /// method lands on `TensorMemory::DmaBuf`, the underlying allocation is
2160    /// silently padded so the row stride satisfies that requirement.
2161    ///
2162    /// **The user-requested `width` is preserved** — `tensor.width()`
2163    /// returns the same value you passed in. The padding is carried by
2164    /// [`TensorDyn::row_stride`] / `effective_row_stride()`, which the
2165    /// GL backend reads when importing the buffer as an EGLImage.
2166    /// Callers that compute byte offsets from the tensor must use the
2167    /// stride, not `width × bytes_per_pixel`; the CPU mapping spans the
2168    /// full `stride × height` bytes.
2169    ///
2170    /// Pre-aligned widths (640, 1280, 1920, 3008, 3840 …) allocate
2171    /// exactly `width × bpp × height` bytes with no padding. PBO and
2172    /// Mem fallbacks never pad — they don't go through EGLImage import.
2173    ///
2174    /// See also [`align_width_for_gpu_pitch`] for an advisory helper
2175    /// that external callers (GStreamer plugins, video pipelines) can
2176    /// use to size their own DMA-BUFs for GL compatibility.
2177    ///
2178    /// # Errors
2179    ///
2180    /// Returns an error if all allocation strategies fail.
2181    pub fn create_image(
2182        &self,
2183        width: usize,
2184        height: usize,
2185        format: PixelFormat,
2186        dtype: DType,
2187        memory: Option<TensorMemory>,
2188        access: edgefirst_tensor::CpuAccess,
2189    ) -> Result<TensorDyn> {
2190        // Compute the GPU-aligned row stride in bytes for this image.
2191        // `None` means either the format has no defined primary-plane bpp
2192        // (unknown future layout) or the stride calculation would overflow
2193        // — in both cases we fall back to the natural layout via the plain
2194        // `TensorDyn::image` constructor, and the slow-path warning inside
2195        // `draw_*_masks` will fire if the subsequent GL import fails.
2196        //
2197        // DMA allocation is Linux-only (see `TensorMemory::DmaBuf` cfg gate),
2198        // so both the stride computation and the helper closure are gated
2199        // accordingly — the callers below are already Linux-only.
2200        #[cfg(target_os = "linux")]
2201        let dma_stride_bytes: Option<usize> = primary_plane_bpp(format, dtype.size())
2202            .and_then(|bpp| width.checked_mul(bpp))
2203            .and_then(align_pitch_bytes_to_gpu_alignment);
2204
2205        // Helper: allocate a DMA image, using the padded-stride constructor
2206        // when the computed stride exceeds the natural pitch, otherwise the
2207        // plain constructor (byte-identical result in the common case).
2208        #[cfg(target_os = "linux")]
2209        let try_dma = || -> Result<TensorDyn> {
2210            // Stride padding is only meaningful for packed pixel layouts
2211            // (RGBA8, BGRA8, RGB888, Grey) — the formats the GL backend
2212            // renders into. Semi-planar (NV12, NV16) and planar (PlanarRgb,
2213            // PlanarRgba) tensors go through `TensorDyn::image(...)` with
2214            // their natural layout; they're imported from camera capture
2215            // via `from_fd` far more often than allocated here, and
2216            // `Tensor::image_with_stride` explicitly rejects them.
2217            let packed = format.layout() == edgefirst_tensor::PixelLayout::Packed;
2218            match dma_stride_bytes {
2219                Some(stride)
2220                    if packed
2221                        && primary_plane_bpp(format, dtype.size())
2222                            .and_then(|bpp| width.checked_mul(bpp))
2223                            .is_some_and(|natural| stride > natural) =>
2224                {
2225                    log::debug!(
2226                        "create_image: padding row stride for {format:?} {width}x{height} \
2227                         from natural pitch to {stride} bytes for GPU alignment"
2228                    );
2229                    Ok(TensorDyn::image_with_stride(
2230                        width,
2231                        height,
2232                        format,
2233                        dtype,
2234                        stride,
2235                        Some(edgefirst_tensor::TensorMemory::DmaBuf),
2236                        access,
2237                    )?)
2238                }
2239                _ => Ok(TensorDyn::image(
2240                    width,
2241                    height,
2242                    format,
2243                    dtype,
2244                    Some(edgefirst_tensor::TensorMemory::DmaBuf),
2245                    access,
2246                )?),
2247            }
2248        };
2249
2250        // If an explicit memory type is requested, honour it directly.
2251        // On Linux, `TensorMemory::DmaBuf` gets the padded-stride treatment;
2252        // other memory types take the user-requested width verbatim.
2253        // On macOS, `TensorMemory::DmaBuf` dispatches through `TensorDyn::image`
2254        // which selects the IOSurface allocation path (FourCC-formatted)
2255        // for image-mappable formats, or falls back to SHM/Mem otherwise.
2256        match memory {
2257            #[cfg(target_os = "linux")]
2258            Some(TensorMemory::DmaBuf) => {
2259                // F32 has no 32-bit-float DRM fourcc; callers must use PBO instead.
2260                if dtype == DType::F32 {
2261                    return Err(Error::NotSupported(
2262                        "F32 has no 32-bit-float DRM format for DMA-BUF; \
2263                         use TensorMemory::Pbo for F32"
2264                            .to_string(),
2265                    ));
2266                }
2267                return try_dma();
2268            }
2269            Some(mem) => {
2270                return Ok(TensorDyn::image(
2271                    width,
2272                    height,
2273                    format,
2274                    dtype,
2275                    Some(mem),
2276                    access,
2277                )?);
2278            }
2279            None => {}
2280        }
2281
2282        // macOS/Android/Windows: when the GL backend is active, prefer Dma
2283        // (IOSurface on Apple, AHardwareBuffer on Android, a D3D11 texture
2284        // on Windows) for zero-copy import. Formats without a zero-copy
2285        // mapping now ERROR under explicit Dma (the explicit-Dma
2286        // contract), so auto-select catches that error here and falls
2287        // back to host storage — loudly, via the debug log below.
2288        #[cfg(any(
2289            target_os = "macos",
2290            target_os = "ios",
2291            target_os = "android",
2292            target_os = "windows"
2293        ))]
2294        #[cfg(feature = "opengl")]
2295        if let Some(gl) = self.opengl.as_ref() {
2296            let _ = gl;
2297            // Float textures only where the display can render into them;
2298            // otherwise the float PBO step below applies. The same question
2299            // that step asks, asked through the same helper so the two
2300            // cannot answer differently.
2301            let support = self.supported_render_dtypes();
2302            let dtype_ok =
2303                !matches!(dtype, DType::F16 | DType::F32) || float_pbo_eligible(dtype, support);
2304            if dtype_ok {
2305                match TensorDyn::image(
2306                    width,
2307                    height,
2308                    format,
2309                    dtype,
2310                    Some(edgefirst_tensor::TensorMemory::DmaBuf),
2311                    access,
2312                ) {
2313                    Ok(img) => return Ok(img),
2314                    Err(e) => {
2315                        // Falling back to a non-zero-copy destination is a real
2316                        // perf cliff — never do it silently (on-device triage
2317                        // starts from this line).
2318                        log::debug!(
2319                            "create_image: zero-copy Dma allocation declined \
2320                             ({format:?}/{dtype:?} {width}x{height}): {e:?}; using fallback storage"
2321                        );
2322                    }
2323                }
2324            }
2325        }
2326
2327        // Try DMA first on Linux — skip only when GL has explicitly selected PBO
2328        // as the preferred transfer path (PBO is better than DMA in that case).
2329        #[cfg(target_os = "linux")]
2330        {
2331            #[cfg(feature = "opengl")]
2332            let gl_uses_pbo = self
2333                .opengl
2334                .as_ref()
2335                .is_some_and(|gl| gl.transfer_backend() == opengl_headless::TransferBackend::Pbo);
2336            #[cfg(not(feature = "opengl"))]
2337            let gl_uses_pbo = false;
2338
2339            if !gl_uses_pbo {
2340                if let Ok(img) = try_dma() {
2341                    return Ok(img);
2342                }
2343            }
2344        }
2345
2346        // Try PBO (if GL available).
2347        // PBO buffers are u8-sized; the int8 shader emulates i8 output via
2348        // XOR 0x80 on the same underlying buffer, so both U8 and I8 work.
2349        #[cfg(any(target_os = "linux", target_os = "windows"))]
2350        #[cfg(feature = "opengl")]
2351        if dtype.size() == 1 {
2352            if let Some(gl) = &self.opengl {
2353                match gl.create_pbo_image(width, height, format) {
2354                    Ok(t) => {
2355                        if dtype == DType::I8 {
2356                            // SAFETY: Tensor<u8> and Tensor<i8> are layout-
2357                            // identical (same element size, no T-dependent
2358                            // drop glue). The int8 shader applies XOR 0x80
2359                            // on the same PBO buffer. Same rationale as
2360                            // gl::processor::tensor_i8_as_u8_mut.
2361                            // Invariant: PBO tensors never have chroma
2362                            // (create_pbo_image → Tensor::wrap sets it None).
2363                            debug_assert!(
2364                                t.chroma().is_none(),
2365                                "PBO i8 transmute requires chroma == None"
2366                            );
2367                            let t_i8: Tensor<i8> = unsafe { std::mem::transmute(t) };
2368                            return Ok(TensorDyn::from(t_i8));
2369                        }
2370                        return Ok(TensorDyn::from(t));
2371                    }
2372                    Err(e) => log::debug!("PBO image creation failed, falling back to Mem: {e:?}"),
2373                }
2374            }
2375        }
2376
2377        // Try float PBO when the GPU backend reports support for this dtype.
2378        // Falls through to Mem on error (same policy as u8 PBO above).
2379        #[cfg(any(target_os = "linux", target_os = "windows"))]
2380        #[cfg(feature = "opengl")]
2381        if float_pbo_eligible(dtype, self.supported_render_dtypes()) {
2382            if let Some(gl) = &self.opengl {
2383                match gl.create_pbo_image_dtype(width, height, format, dtype) {
2384                    Ok(t) => return Ok(t),
2385                    Err(e) => {
2386                        log::debug!(
2387                            "Float PBO image creation failed for {dtype:?}, \
2388                             falling back to Mem: {e:?}"
2389                        );
2390                    }
2391                }
2392            }
2393        }
2394
2395        // Fallback to Mem
2396        Ok(TensorDyn::image(
2397            width,
2398            height,
2399            format,
2400            dtype,
2401            Some(edgefirst_tensor::TensorMemory::Mem),
2402            access,
2403        )?)
2404    }
2405
2406    /// Import an external DMA-BUF image.
2407    ///
2408    /// Each [`PlaneDescriptor`] owns an already-duped fd; this method
2409    /// consumes the descriptors and takes ownership of those fds (whether
2410    /// the call succeeds or fails).
2411    ///
2412    /// The caller must ensure the DMA-BUF allocation is large enough for the
2413    /// specified width, height, format, and any stride/offset on the plane
2414    /// descriptors. No buffer-size validation is performed; an undersized
2415    /// buffer may cause GPU faults or EGL import failure.
2416    ///
2417    /// # Arguments
2418    ///
2419    /// * `image` - Plane descriptor for the primary (or only) plane
2420    /// * `chroma` - Optional plane descriptor for the UV chroma plane
2421    ///   (required for multiplane NV12)
2422    /// * `width` - Image width in pixels
2423    /// * `height` - Image height in pixels
2424    /// * `format` - Pixel format of the buffer
2425    /// * `dtype` - Element data type (e.g. `DType::U8`)
2426    ///
2427    /// # Returns
2428    ///
2429    /// A `TensorDyn` configured as an image.
2430    ///
2431    /// # Errors
2432    ///
2433    /// * [`Error::NotSupported`] if `chroma` is `Some` for a non-semi-planar
2434    ///   format, or multiplane NV16 (not yet supported), or the fd is not
2435    ///   DMA-backed
2436    /// * [`Error::InvalidShape`] if NV12 height is odd
2437    ///
2438    /// # Platform
2439    ///
2440    /// Linux only.
2441    ///
2442    /// # Examples
2443    ///
2444    /// ```rust,ignore
2445    /// use edgefirst_tensor::PlaneDescriptor;
2446    ///
2447    /// // Single-plane RGBA
2448    /// let pd = PlaneDescriptor::new(fd.as_fd())?;
2449    /// let src = proc.import_image(pd, None, 1920, 1080, PixelFormat::Rgba, DType::U8, None)?;
2450    ///
2451    /// // Multi-plane NV12 with stride
2452    /// let y_pd = PlaneDescriptor::new(y_fd.as_fd())?.with_stride(2048);
2453    /// let uv_pd = PlaneDescriptor::new(uv_fd.as_fd())?.with_stride(2048);
2454    /// let src = proc.import_image(y_pd, Some(uv_pd), 1920, 1080,
2455    ///                             PixelFormat::Nv12, DType::U8, None)?;
2456    /// ```
2457    // Import inherently needs plane(s) + geometry + format + dtype + colorimetry;
2458    // a params struct would obscure more than it clarifies here.
2459    #[allow(clippy::too_many_arguments)]
2460    #[cfg(target_os = "linux")]
2461    pub fn import_image(
2462        &self,
2463        image: edgefirst_tensor::PlaneDescriptor,
2464        chroma: Option<edgefirst_tensor::PlaneDescriptor>,
2465        width: usize,
2466        height: usize,
2467        format: PixelFormat,
2468        dtype: DType,
2469        colorimetry: Option<edgefirst_tensor::Colorimetry>,
2470    ) -> Result<TensorDyn> {
2471        use edgefirst_tensor::{Tensor, TensorMemory};
2472
2473        // Capture stride/offset from descriptors before consuming them
2474        let image_stride = image.stride();
2475        let image_offset = image.offset();
2476        let chroma_stride = chroma.as_ref().and_then(|c| c.stride());
2477        let chroma_offset = chroma.as_ref().and_then(|c| c.offset());
2478
2479        if let Some(chroma_pd) = chroma {
2480            // ── Multiplane path ──────────────────────────────────────
2481            // Multiplane tensors are backed by Tensor<u8> (or transmuted to
2482            // Tensor<i8>). Reject other dtypes to avoid silently returning a
2483            // tensor with the wrong element type.
2484            if dtype != DType::U8 && dtype != DType::I8 {
2485                return Err(Error::NotSupported(format!(
2486                    "multiplane import only supports U8/I8, got {dtype:?}"
2487                )));
2488            }
2489            if format.layout() != PixelLayout::SemiPlanar {
2490                return Err(Error::NotSupported(format!(
2491                    "import_image with chroma requires a semi-planar format, got {format:?}"
2492                )));
2493            }
2494
2495            let chroma_h = match format {
2496                PixelFormat::Nv12 => {
2497                    // NV12 (4:2:0): ceil(H/2) chroma rows — odd heights are valid.
2498                    height.div_ceil(2)
2499                }
2500                // NV16 multiplane will be supported in a future release;
2501                // the GL backend currently only handles NV12 plane1 attributes.
2502                PixelFormat::Nv16 => {
2503                    return Err(Error::NotSupported(
2504                        "multiplane NV16 is not yet supported; use contiguous NV16 instead".into(),
2505                    ))
2506                }
2507                _ => {
2508                    return Err(Error::NotSupported(format!(
2509                        "unsupported semi-planar format: {format:?}"
2510                    )))
2511                }
2512            };
2513
2514            let luma = Tensor::<u8>::from_fd(image.into_fd(), &[height, width], Some("luma"))?;
2515            if luma.memory() != TensorMemory::DmaBuf {
2516                return Err(Error::NotSupported(format!(
2517                    "luma fd must be DMA-backed, got {:?}",
2518                    luma.memory()
2519                )));
2520            }
2521
2522            let chroma_tensor =
2523                Tensor::<u8>::from_fd(chroma_pd.into_fd(), &[chroma_h, width], Some("chroma"))?;
2524            if chroma_tensor.memory() != TensorMemory::DmaBuf {
2525                return Err(Error::NotSupported(format!(
2526                    "chroma fd must be DMA-backed, got {:?}",
2527                    chroma_tensor.memory()
2528                )));
2529            }
2530
2531            // from_planes creates the combined tensor with format set,
2532            // preserving luma's row_stride (currently None since luma was raw).
2533            let mut tensor = Tensor::<u8>::from_planes(luma, chroma_tensor, format)?;
2534
2535            // Apply stride/offset to the combined tensor (luma plane)
2536            if let Some(s) = image_stride {
2537                tensor.set_row_stride(s)?;
2538            }
2539            if let Some(o) = image_offset {
2540                tensor.set_plane_offset(o);
2541            }
2542
2543            // Apply stride/offset to the chroma sub-tensor.
2544            // The chroma tensor is a raw 2D [chroma_h, width] tensor without
2545            // format metadata, so we validate stride manually rather than
2546            // using set_row_stride (which requires format).
2547            if let Some(chroma_ref) = tensor.chroma_mut() {
2548                if let Some(s) = chroma_stride {
2549                    if s < width {
2550                        return Err(Error::InvalidShape(format!(
2551                            "chroma stride {s} < minimum {width} for {format:?}"
2552                        )));
2553                    }
2554                    chroma_ref.set_row_stride_unchecked(s);
2555                }
2556                if let Some(o) = chroma_offset {
2557                    chroma_ref.set_plane_offset(o);
2558                }
2559            }
2560
2561            if dtype == DType::I8 {
2562                // SAFETY: Tensor<u8> and Tensor<i8> have identical layout because
2563                // the struct contains only type-erased storage (OwnedFd, shape, name),
2564                // no inline T values. This assertion catches layout drift at compile time.
2565                const {
2566                    assert!(std::mem::size_of::<Tensor<u8>>() == std::mem::size_of::<Tensor<i8>>());
2567                    assert!(
2568                        std::mem::align_of::<Tensor<u8>>() == std::mem::align_of::<Tensor<i8>>()
2569                    );
2570                }
2571                let tensor_i8: Tensor<i8> = unsafe { std::mem::transmute(tensor) };
2572                let mut dyn_tensor = TensorDyn::from(tensor_i8);
2573                dyn_tensor.set_colorimetry(colorimetry);
2574                return Ok(dyn_tensor);
2575            }
2576            let mut dyn_tensor = TensorDyn::from(tensor);
2577            dyn_tensor.set_colorimetry(colorimetry);
2578            Ok(dyn_tensor)
2579        } else {
2580            // ── Single-plane path ────────────────────────────────────
2581            // Canonical shape (Packed [H,W,C] / Planar [C,H,W] / SemiPlanar
2582            // [total_h, W]); `allocation_shape` supports NV12/NV16/NV24 (the old
2583            // hand-rolled match erroneously rejected NV24).
2584            let shape = format.allocation_shape(width, height).ok_or_else(|| {
2585                Error::NotSupported(format!(
2586                    "unsupported pixel format for import_image: {format:?}"
2587                ))
2588            })?;
2589            let tensor = TensorDyn::from_fd(image.into_fd(), &shape, dtype, None)?;
2590            if tensor.memory() != TensorMemory::DmaBuf {
2591                return Err(Error::NotSupported(format!(
2592                    "import_image requires DMA-backed fd, got {:?}",
2593                    tensor.memory()
2594                )));
2595            }
2596            let mut tensor = tensor.with_format(format)?;
2597            if let Some(s) = image_stride {
2598                tensor.set_row_stride(s)?;
2599            }
2600            if let Some(o) = image_offset {
2601                tensor.set_plane_offset(o);
2602            }
2603            tensor.set_colorimetry(colorimetry);
2604            Ok(tensor)
2605        }
2606    }
2607
2608    /// Decode model outputs and draw segmentation masks onto `dst`.
2609    ///
2610    /// The processor decodes via the provided
2611    /// [`Decoder`](edgefirst_decoder::Decoder), selects the optimal rendering
2612    /// path (hybrid CPU+GL or fused GPU), and composites masks onto `dst`.
2613    ///
2614    /// Returns the detected bounding boxes.
2615    ///
2616    /// **Requires the `decode` feature**, which is off by default because it
2617    /// is the only thing in this crate that needs `edgefirst-decoder`. This
2618    /// method sequences a decode and a draw; both halves are available
2619    /// unconditionally and are equally fused, so without the feature write:
2620    ///
2621    /// ```ignore
2622    /// let mut boxes = Vec::new();
2623    /// match decoder.decode_proto(outputs, &mut boxes)? {
2624    ///     Some(protos) => proc.draw_proto_masks(dst, &boxes, &protos, overlay)?,
2625    ///     None => {
2626    ///         let mut masks = Vec::new();
2627    ///         decoder.decode(outputs, &mut boxes, &mut masks)?;
2628    ///         proc.draw_decoded_masks(dst, &boxes, &masks, overlay)?;
2629    ///     }
2630    /// }
2631    /// ```
2632    #[cfg(feature = "decode")]
2633    pub fn draw_masks(
2634        &mut self,
2635        decoder: &edgefirst_decoder::Decoder,
2636        outputs: &[&TensorDyn],
2637        dst: &mut TensorDyn,
2638        overlay: MaskOverlay<'_>,
2639    ) -> Result<Vec<DetectBox>> {
2640        let mut output_boxes = Vec::with_capacity(100);
2641
2642        // Try proto path first (fused rendering without materializing masks)
2643        let proto_result = decoder
2644            .decode_proto(outputs, &mut output_boxes)
2645            .map_err(|e| Error::Internal(format!("decode_proto: {e:#?}")))?;
2646
2647        if let Some(proto_data) = proto_result {
2648            self.draw_proto_masks(dst, &output_boxes, &proto_data, overlay)?;
2649        } else {
2650            // Detection-only or unsupported model: full decode + render
2651            let mut output_masks = Vec::with_capacity(100);
2652            decoder
2653                .decode(outputs, &mut output_boxes, &mut output_masks)
2654                .map_err(|e| Error::Internal(format!("decode: {e:#?}")))?;
2655            self.draw_decoded_masks(dst, &output_boxes, &output_masks, overlay)?;
2656        }
2657        Ok(output_boxes)
2658    }
2659
2660    /// Decode tracked model outputs and draw segmentation masks onto `dst`.
2661    ///
2662    /// Like [`draw_masks`](Self::draw_masks) but integrates a tracker for
2663    /// maintaining object identities across frames. The tracker runs after
2664    /// NMS but before mask extraction.
2665    ///
2666    /// Returns detected boxes and track info.
2667    #[cfg(feature = "tracker")]
2668    pub fn draw_masks_tracked<TR: edgefirst_tracker::Tracker<DetectBox>>(
2669        &mut self,
2670        decoder: &edgefirst_decoder::Decoder,
2671        tracker: &mut TR,
2672        timestamp: u64,
2673        outputs: &[&TensorDyn],
2674        dst: &mut TensorDyn,
2675        overlay: MaskOverlay<'_>,
2676    ) -> Result<(Vec<DetectBox>, Vec<edgefirst_tracker::TrackInfo>)> {
2677        let mut output_boxes = Vec::with_capacity(100);
2678        let mut output_tracks = Vec::new();
2679
2680        let proto_result = decoder
2681            .decode_proto_tracked(
2682                tracker,
2683                timestamp,
2684                outputs,
2685                &mut output_boxes,
2686                &mut output_tracks,
2687            )
2688            .map_err(|e| Error::Internal(format!("decode_proto_tracked: {e:#?}")))?;
2689
2690        if let Some(proto_data) = proto_result {
2691            self.draw_proto_masks(dst, &output_boxes, &proto_data, overlay)?;
2692        } else {
2693            // Note: decode_proto_tracked returns None for detection-only/ModelPack
2694            // models WITHOUT calling the tracker. The else branch below is the
2695            // first (and only) tracker call for those model types.
2696            let mut output_masks = Vec::with_capacity(100);
2697            decoder
2698                .decode_tracked(
2699                    tracker,
2700                    timestamp,
2701                    outputs,
2702                    &mut output_boxes,
2703                    &mut output_masks,
2704                    &mut output_tracks,
2705                )
2706                .map_err(|e| Error::Internal(format!("decode_tracked: {e:#?}")))?;
2707            self.draw_decoded_masks(dst, &output_boxes, &output_masks, overlay)?;
2708        }
2709        Ok((output_boxes, output_tracks))
2710    }
2711
2712    /// Materialize per-instance segmentation masks from raw prototype data.
2713    ///
2714    /// Computes `mask_coeff @ protos` with sigmoid activation for each detection,
2715    /// producing compact masks at prototype resolution (e.g., 160×160 crops).
2716    /// Mask values are continuous sigmoid confidence outputs quantized to u8
2717    /// (0 = background, 255 = full confidence), NOT binary thresholded.
2718    ///
2719    /// The returned [`Vec<Segmentation>`] can be:
2720    /// - Inspected or exported for analytics, IoU computation, etc.
2721    /// - Passed directly to [`ImageProcessorTrait::draw_decoded_masks`] for
2722    ///   GPU-interpolated rendering.
2723    ///
2724    /// # Performance Note
2725    ///
2726    /// Calling `materialize_masks` + `draw_decoded_masks` separately prevents
2727    /// the HAL from using its internal fused optimization path. For render-only
2728    /// use cases, prefer [`ImageProcessorTrait::draw_proto_masks`] which selects
2729    /// the fastest path automatically (currently 1.6×–27× faster on tested
2730    /// platforms). Use this method when you need access to the intermediate masks.
2731    ///
2732    /// # Errors
2733    ///
2734    /// Returns [`Error::NoConverter`] if the CPU backend is not available.
2735    pub fn materialize_masks(
2736        &mut self,
2737        detect: &[DetectBox],
2738        proto_data: &ProtoData,
2739        letterbox: Option<[f32; 4]>,
2740        resolution: MaskResolution,
2741    ) -> Result<Vec<Segmentation>> {
2742        let cpu = self.cpu.as_mut().ok_or(Error::NoConverter)?;
2743        match resolution {
2744            MaskResolution::Proto => cpu.materialize_segmentations(detect, proto_data, letterbox),
2745            MaskResolution::Scaled { width, height } => {
2746                cpu.materialize_scaled_segmentations(detect, proto_data, letterbox, width, height)
2747            }
2748        }
2749    }
2750}
2751
2752impl ImageProcessorTrait for ImageProcessor {
2753    /// Converts the source image to the destination image format and size. The
2754    /// image is cropped first, then flipped, then rotated
2755    ///
2756    /// Prefer hardware accelerators when available, falling back to CPU if
2757    /// necessary.
2758    fn convert(
2759        &mut self,
2760        src: &TensorDyn,
2761        dst: &mut TensorDyn,
2762        rotation: Rotation,
2763        flip: Flip,
2764        crop: Crop,
2765    ) -> Result<()> {
2766        let start = Instant::now();
2767        let src_fmt = src.format();
2768        let dst_fmt = dst.format();
2769        let _span = tracing::trace_span!(
2770            "image.convert",
2771            ?src_fmt,
2772            ?dst_fmt,
2773            src_memory = ?src.memory(),
2774            dst_memory = ?dst.memory(),
2775            ?rotation,
2776            ?flip,
2777        )
2778        .entered();
2779        log::trace!(
2780            "convert: {src_fmt:?}({:?}/{:?}) → {dst_fmt:?}({:?}/{:?}), \
2781             rotation={rotation:?}, flip={flip:?}, backend={:?}",
2782            src.dtype(),
2783            src.memory(),
2784            dst.dtype(),
2785            dst.memory(),
2786            self.forced_backend,
2787        );
2788
2789        // ── Forced backend: no fallback chain ────────────────────────
2790        if let Some(forced) = self.forced_backend {
2791            return match forced {
2792                ForcedBackend::Cpu => {
2793                    if let Some(cpu) = self.cpu.as_mut() {
2794                        let r = cpu.convert(src, dst, rotation, flip, crop);
2795                        log::trace!(
2796                            "convert: forced=cpu result={} ({:?})",
2797                            if r.is_ok() { "ok" } else { "err" },
2798                            start.elapsed()
2799                        );
2800                        return r;
2801                    }
2802                    Err(Error::ForcedBackendUnavailable("cpu".into()))
2803                }
2804                ForcedBackend::G2d => {
2805                    #[cfg(target_os = "linux")]
2806                    if let Some(g2d) = self.g2d.as_mut() {
2807                        let r = g2d.convert(src, dst, rotation, flip, crop);
2808                        log::trace!(
2809                            "convert: forced=g2d result={} ({:?})",
2810                            if r.is_ok() { "ok" } else { "err" },
2811                            start.elapsed()
2812                        );
2813                        return r;
2814                    }
2815                    Err(Error::ForcedBackendUnavailable("g2d".into()))
2816                }
2817                ForcedBackend::OpenGl => {
2818                    #[cfg(any(
2819                        target_os = "linux",
2820                        target_os = "macos",
2821                        target_os = "ios",
2822                        target_os = "android",
2823                        target_os = "windows"
2824                    ))]
2825                    #[cfg(feature = "opengl")]
2826                    if let Some(opengl) = self.opengl.as_mut() {
2827                        let r = opengl.convert(src, dst, rotation, flip, crop);
2828                        log::trace!(
2829                            "convert: forced=opengl result={} ({:?})",
2830                            if r.is_ok() { "ok" } else { "err" },
2831                            start.elapsed()
2832                        );
2833                        return r;
2834                    }
2835                    Err(Error::ForcedBackendUnavailable("opengl".into()))
2836                }
2837            };
2838        }
2839
2840        // ── Auto fallback chain: OpenGL → G2D → CPU ──────────────────
2841        #[cfg(any(
2842            target_os = "linux",
2843            target_os = "macos",
2844            target_os = "ios",
2845            target_os = "android",
2846            target_os = "windows"
2847        ))]
2848        #[cfg(feature = "opengl")]
2849        if let Some(opengl) = self.opengl.as_mut() {
2850            match opengl.convert(src, dst, rotation, flip, crop) {
2851                Ok(_) => {
2852                    log::trace!(
2853                        "convert: auto selected=opengl for {src_fmt:?}→{dst_fmt:?} ({:?})",
2854                        start.elapsed()
2855                    );
2856                    return Ok(());
2857                }
2858                Err(e) => {
2859                    self.convert_fallbacks
2860                        .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2861                    log::debug!(
2862                        "convert: auto opengl declined {src_fmt:?}@{:?}→{dst_fmt:?}@{:?}, \
2863                         falling back toward G2D/CPU: {e}",
2864                        src.memory(),
2865                        dst.memory(),
2866                    );
2867                }
2868            }
2869        }
2870
2871        #[cfg(target_os = "linux")]
2872        if let Some(g2d) = self.g2d.as_mut() {
2873            // G2D is matrix-only (no range control, no BT.2020). For any
2874            // conversion with a YUV side, resolve that side's colorimetry and
2875            // skip G2D entirely when it cannot be expressed (full-range YUV or
2876            // BT.2020), letting the chain fall through to GL/CPU which honour
2877            // range and BT.2020. YUV→RGB uses the source colorimetry; RGB→YUV
2878            // uses the destination. RGB→RGB has no YUV side and is unaffected.
2879            let src_is_yuv = src.format().is_some_and(|f| f.is_yuv());
2880            let dst_is_yuv = dst.format().is_some_and(|f| f.is_yuv());
2881            let g2d_eligible = if src_is_yuv || dst_is_yuv {
2882                let cm = if src_is_yuv {
2883                    crate::colorimetry::effective_colorimetry(src)
2884                } else {
2885                    crate::colorimetry::effective_colorimetry(dst)
2886                };
2887                crate::g2d::g2d_can_handle(&cm, true)
2888            } else {
2889                true
2890            };
2891            if !g2d_eligible {
2892                log::trace!(
2893                    "convert: auto g2d skipped {src_fmt:?}→{dst_fmt:?} \
2894                     (colorimetry not expressible: full-range/BT.2020)"
2895                );
2896            } else {
2897                match g2d.convert(src, dst, rotation, flip, crop) {
2898                    Ok(_) => {
2899                        log::trace!(
2900                            "convert: auto selected=g2d for {src_fmt:?}→{dst_fmt:?} ({:?})",
2901                            start.elapsed()
2902                        );
2903                        return Ok(());
2904                    }
2905                    Err(e) => {
2906                        log::trace!("convert: auto g2d declined {src_fmt:?}→{dst_fmt:?}: {e}");
2907                    }
2908                }
2909            }
2910        }
2911
2912        if let Some(cpu) = self.cpu.as_mut() {
2913            match cpu.convert(src, dst, rotation, flip, crop) {
2914                Ok(_) => {
2915                    log::trace!(
2916                        "convert: auto selected=cpu for {src_fmt:?}→{dst_fmt:?} ({:?})",
2917                        start.elapsed()
2918                    );
2919                    return Ok(());
2920                }
2921                Err(e) => {
2922                    log::trace!("convert: auto cpu failed {src_fmt:?}→{dst_fmt:?}: {e}");
2923                    return Err(e);
2924                }
2925            }
2926        }
2927        Err(Error::NoConverter)
2928    }
2929
2930    fn convert_deferred(
2931        &mut self,
2932        src: &TensorDyn,
2933        dst: &mut TensorDyn,
2934        rotation: Rotation,
2935        flip: Flip,
2936        crop: Crop,
2937    ) -> Result<()> {
2938        // Deferred batching is an OpenGL optimization (shared parent EGLImage +
2939        // no per-tile glFinish). Route to the GL backend's deferred path when GL
2940        // is forced or auto-selectable; on a GL decline fall back to an eager
2941        // convert (the auto chain), which is correct everywhere — it completes
2942        // synchronously and `flush` stays a no-op for non-GL backends.
2943        #[cfg(any(
2944            target_os = "linux",
2945            target_os = "macos",
2946            target_os = "ios",
2947            target_os = "android",
2948            target_os = "windows"
2949        ))]
2950        #[cfg(feature = "opengl")]
2951        {
2952            let gl_forced = matches!(self.forced_backend, Some(ForcedBackend::OpenGl));
2953            if gl_forced || self.forced_backend.is_none() {
2954                if let Some(opengl) = self.opengl.as_mut() {
2955                    match opengl.convert_deferred(src, dst, rotation, flip, crop) {
2956                        Ok(()) => return Ok(()),
2957                        Err(e) => {
2958                            log::trace!("convert_deferred: gl declined: {e}; eager fallback");
2959                            // A forced-GL caller gets the GL error, matching
2960                            // `convert`'s no-fallback forced-backend contract.
2961                            if gl_forced {
2962                                return Err(e);
2963                            }
2964                        }
2965                    }
2966                }
2967            }
2968        }
2969        self.convert(src, dst, rotation, flip, crop)
2970    }
2971
2972    fn flush(&mut self) -> Result<()> {
2973        let _span = tracing::trace_span!("image.flush").entered();
2974        // Only the OpenGL backend defers; flushing it issues the single GPU
2975        // sync. CPU/G2D converts already completed, so there is nothing to flush.
2976        #[cfg(any(
2977            target_os = "linux",
2978            target_os = "macos",
2979            target_os = "ios",
2980            target_os = "android",
2981            target_os = "windows"
2982        ))]
2983        #[cfg(feature = "opengl")]
2984        if let Some(opengl) = self.opengl.as_mut() {
2985            return opengl.flush();
2986        }
2987        Ok(())
2988    }
2989
2990    fn draw_decoded_masks(
2991        &mut self,
2992        dst: &mut TensorDyn,
2993        detect: &[DetectBox],
2994        segmentation: &[Segmentation],
2995        overlay: MaskOverlay<'_>,
2996    ) -> Result<()> {
2997        let _span = tracing::trace_span!(
2998            "image.draw_decoded_masks",
2999            n_detections = detect.len(),
3000            n_segmentations = segmentation.len(),
3001        )
3002        .entered();
3003        let start = Instant::now();
3004
3005        if let Some(bg) = overlay.background {
3006            if bg.aliases(dst) {
3007                return Err(Error::AliasedBuffers(
3008                    "background must not reference the same buffer as dst".to_string(),
3009                ));
3010            }
3011        }
3012
3013        // Un-letterbox detect boxes and segmentation bboxes for rendering when
3014        // a letterbox was applied to prepare the model input.
3015        let lb_boxes: Vec<DetectBox>;
3016        let lb_segs: Vec<Segmentation>;
3017        let (detect, segmentation) = if let Some(lb) = overlay.letterbox {
3018            lb_boxes = detect.iter().map(|&d| unletter_bbox(d, lb)).collect();
3019            // Keep segmentation bboxes in sync with the transformed detect boxes
3020            // when we have a 1:1 correspondence (instance segmentation).
3021            if segmentation.len() == lb_boxes.len() {
3022                // Only the four bounds change; the mask bytes are copied
3023                // wholesale because `TensorDyn` is not `Clone`. The memcpy is
3024                // what `Array3<u8>::clone()` already paid; `copy_segmentation`
3025                // pins the allocation to Mem so the ALLOCATOR cost matches too
3026                // (see there). Rendering from borrowed masks plus a separate
3027                // bounds list would avoid the copy entirely.
3028                lb_segs = segmentation
3029                    .iter()
3030                    .zip(lb_boxes.iter())
3031                    .map(|(s, d)| copy_segmentation(s, d.bbox.into()))
3032                    .collect::<Result<Vec<_>>>()?;
3033                (lb_boxes.as_slice(), lb_segs.as_slice())
3034            } else {
3035                // No 1:1 correspondence, so the segmentation bounds are left
3036                // as they are -- borrow rather than the copy `to_vec()` used
3037                // to make.
3038                (lb_boxes.as_slice(), segmentation)
3039            }
3040        } else {
3041            (detect, segmentation)
3042        };
3043        #[cfg(target_os = "linux")]
3044        let is_empty_frame = detect.is_empty() && segmentation.is_empty();
3045
3046        // ── Forced backend: no fallback chain ────────────────────────
3047        if let Some(forced) = self.forced_backend {
3048            return match forced {
3049                ForcedBackend::Cpu => {
3050                    if let Some(cpu) = self.cpu.as_mut() {
3051                        return cpu.draw_decoded_masks(dst, detect, segmentation, overlay);
3052                    }
3053                    Err(Error::ForcedBackendUnavailable("cpu".into()))
3054                }
3055                ForcedBackend::G2d => {
3056                    // G2D can only produce empty frames (clear / bg blit).
3057                    // For populated frames it has no rasterizer — fail loudly.
3058                    #[cfg(target_os = "linux")]
3059                    if let Some(g2d) = self.g2d.as_mut() {
3060                        return g2d.draw_decoded_masks(dst, detect, segmentation, overlay);
3061                    }
3062                    Err(Error::ForcedBackendUnavailable("g2d".into()))
3063                }
3064                ForcedBackend::OpenGl => {
3065                    // GL handles background natively via GPU blit, and now
3066                    // actively clears when there is no background.
3067                    #[cfg(any(
3068                        target_os = "linux",
3069                        target_os = "macos",
3070                        target_os = "ios",
3071                        target_os = "android",
3072                        target_os = "windows"
3073                    ))]
3074                    #[cfg(feature = "opengl")]
3075                    if let Some(opengl) = self.opengl.as_mut() {
3076                        return opengl.draw_decoded_masks(dst, detect, segmentation, overlay);
3077                    }
3078                    Err(Error::ForcedBackendUnavailable("opengl".into()))
3079                }
3080            };
3081        }
3082
3083        // ── Auto dispatch ──────────────────────────────────────────
3084        // Empty frames prefer G2D when available — a single g2d_clear or
3085        // g2d_blit is the cheapest HW path to produce the correct output
3086        // and avoids spinning up the GL pipeline every zero-detection
3087        // frame in a triple-buffered display loop.
3088        #[cfg(target_os = "linux")]
3089        if is_empty_frame {
3090            if let Some(g2d) = self.g2d.as_mut() {
3091                match g2d.draw_decoded_masks(dst, detect, segmentation, overlay) {
3092                    Ok(_) => {
3093                        log::trace!(
3094                            "draw_decoded_masks empty frame via g2d in {:?}",
3095                            start.elapsed()
3096                        );
3097                        return Ok(());
3098                    }
3099                    Err(e) => log::trace!("g2d empty-frame path unavailable: {e:?}"),
3100                }
3101            }
3102        }
3103
3104        // Populated frames (or G2D unavailable): GL first, CPU fallback.
3105        // Both backends now own their own base-layer handling (bg blit
3106        // or clear), so we hand the overlay through untouched.
3107        #[cfg(any(
3108            target_os = "linux",
3109            target_os = "macos",
3110            target_os = "ios",
3111            target_os = "android",
3112            target_os = "windows"
3113        ))]
3114        #[cfg(feature = "opengl")]
3115        if let Some(opengl) = self.opengl.as_mut() {
3116            log::trace!(
3117                "draw_decoded_masks started with opengl in {:?}",
3118                start.elapsed()
3119            );
3120            match opengl.draw_decoded_masks(dst, detect, segmentation, overlay) {
3121                Ok(_) => {
3122                    log::trace!("draw_decoded_masks with opengl in {:?}", start.elapsed());
3123                    return Ok(());
3124                }
3125                Err(e) => {
3126                    log::trace!("draw_decoded_masks didn't work with opengl: {e:?}")
3127                }
3128            }
3129        }
3130
3131        log::trace!(
3132            "draw_decoded_masks started with cpu in {:?}",
3133            start.elapsed()
3134        );
3135        if let Some(cpu) = self.cpu.as_mut() {
3136            match cpu.draw_decoded_masks(dst, detect, segmentation, overlay) {
3137                Ok(_) => {
3138                    log::trace!("draw_decoded_masks with cpu in {:?}", start.elapsed());
3139                    return Ok(());
3140                }
3141                Err(e) => {
3142                    log::trace!("draw_decoded_masks didn't work with cpu: {e:?}");
3143                    return Err(e);
3144                }
3145            }
3146        }
3147        Err(Error::NoConverter)
3148    }
3149
3150    fn draw_proto_masks(
3151        &mut self,
3152        dst: &mut TensorDyn,
3153        detect: &[DetectBox],
3154        proto_data: &ProtoData,
3155        overlay: MaskOverlay<'_>,
3156    ) -> Result<()> {
3157        let start = Instant::now();
3158
3159        if let Some(bg) = overlay.background {
3160            if bg.aliases(dst) {
3161                return Err(Error::AliasedBuffers(
3162                    "background must not reference the same buffer as dst".to_string(),
3163                ));
3164            }
3165        }
3166
3167        // Un-letterbox detect boxes for rendering when a letterbox was applied
3168        // to prepare the model input.  The original `detect` coords are still
3169        // passed to `materialize_segmentations` (which needs model-space coords
3170        // to correctly crop the proto tensor) alongside `overlay.letterbox` so
3171        // it can emit `Segmentation` structs in output-image space.
3172        let lb_boxes: Vec<DetectBox>;
3173        let render_detect = if let Some(lb) = overlay.letterbox {
3174            lb_boxes = detect.iter().map(|&d| unletter_bbox(d, lb)).collect();
3175            lb_boxes.as_slice()
3176        } else {
3177            detect
3178        };
3179        #[cfg(target_os = "linux")]
3180        let is_empty_frame = detect.is_empty();
3181
3182        // ── Forced backend: no fallback chain ────────────────────────
3183        if let Some(forced) = self.forced_backend {
3184            return match forced {
3185                ForcedBackend::Cpu => {
3186                    if let Some(cpu) = self.cpu.as_mut() {
3187                        return cpu.draw_proto_masks(dst, render_detect, proto_data, overlay);
3188                    }
3189                    Err(Error::ForcedBackendUnavailable("cpu".into()))
3190                }
3191                ForcedBackend::G2d => {
3192                    #[cfg(target_os = "linux")]
3193                    if let Some(g2d) = self.g2d.as_mut() {
3194                        return g2d.draw_proto_masks(dst, render_detect, proto_data, overlay);
3195                    }
3196                    Err(Error::ForcedBackendUnavailable("g2d".into()))
3197                }
3198                ForcedBackend::OpenGl => {
3199                    #[cfg(any(
3200                        target_os = "linux",
3201                        target_os = "macos",
3202                        target_os = "ios",
3203                        target_os = "android",
3204                        target_os = "windows"
3205                    ))]
3206                    #[cfg(feature = "opengl")]
3207                    if let Some(opengl) = self.opengl.as_mut() {
3208                        return opengl.draw_proto_masks(dst, render_detect, proto_data, overlay);
3209                    }
3210                    Err(Error::ForcedBackendUnavailable("opengl".into()))
3211                }
3212            };
3213        }
3214
3215        // ── Auto dispatch ──────────────────────────────────────────
3216        // Empty frames: prefer G2D — cheapest HW path (clear or bg blit).
3217        #[cfg(target_os = "linux")]
3218        if is_empty_frame {
3219            if let Some(g2d) = self.g2d.as_mut() {
3220                match g2d.draw_proto_masks(dst, render_detect, proto_data, overlay) {
3221                    Ok(_) => {
3222                        log::trace!(
3223                            "draw_proto_masks empty frame via g2d in {:?}",
3224                            start.elapsed()
3225                        );
3226                        return Ok(());
3227                    }
3228                    Err(e) => log::trace!("g2d empty-frame path unavailable: {e:?}"),
3229                }
3230            }
3231        }
3232
3233        // Hybrid path: CPU materialize + GL overlay (benchmarked faster than
3234        // full-GPU draw_proto_masks on all tested platforms: 27× on imx8mp,
3235        // 4× on imx95, 2.5× on rpi5, 1.6× on x86).
3236        // GL owns its own bg-blit / glClear — we pass the overlay through.
3237        //
3238        // CPU materialize needs `&mut` for its MaskScratch buffers; GL also
3239        // needs `&mut`. The CPU borrow is scoped to its block so the
3240        // subsequent GL borrow is free to take over `self`.
3241        #[cfg(any(
3242            target_os = "linux",
3243            target_os = "macos",
3244            target_os = "ios",
3245            target_os = "android",
3246            target_os = "windows"
3247        ))]
3248        #[cfg(feature = "opengl")]
3249        if let (Some(_), Some(_)) = (self.cpu.as_ref(), self.opengl.as_ref()) {
3250            let segmentation = match self.cpu.as_mut() {
3251                Some(cpu) => {
3252                    log::trace!(
3253                        "draw_proto_masks started with hybrid (cpu+opengl) in {:?}",
3254                        start.elapsed()
3255                    );
3256                    cpu.materialize_segmentations(detect, proto_data, overlay.letterbox)?
3257                }
3258                None => unreachable!("cpu presence checked above"),
3259            };
3260            if let Some(opengl) = self.opengl.as_mut() {
3261                match opengl.draw_decoded_masks(dst, render_detect, &segmentation, overlay) {
3262                    Ok(_) => {
3263                        log::trace!(
3264                            "draw_proto_masks with hybrid (cpu+opengl) in {:?}",
3265                            start.elapsed()
3266                        );
3267                        return Ok(());
3268                    }
3269                    Err(e) => {
3270                        log::trace!(
3271                            "draw_proto_masks hybrid path failed, falling back to cpu: {e:?}"
3272                        );
3273                    }
3274                }
3275            }
3276        }
3277
3278        let Some(cpu) = self.cpu.as_mut() else {
3279            return Err(Error::Internal(
3280                "draw_proto_masks requires CPU backend for fallback path".into(),
3281            ));
3282        };
3283        log::trace!("draw_proto_masks started with cpu in {:?}", start.elapsed());
3284        cpu.draw_proto_masks(dst, render_detect, proto_data, overlay)
3285    }
3286
3287    fn set_class_colors(&mut self, colors: &[[u8; 4]]) -> Result<()> {
3288        let start = Instant::now();
3289
3290        // ── Forced backend: no fallback chain ────────────────────────
3291        if let Some(forced) = self.forced_backend {
3292            return match forced {
3293                ForcedBackend::Cpu => {
3294                    if let Some(cpu) = self.cpu.as_mut() {
3295                        return cpu.set_class_colors(colors);
3296                    }
3297                    Err(Error::ForcedBackendUnavailable("cpu".into()))
3298                }
3299                ForcedBackend::G2d => Err(Error::NotSupported(
3300                    "g2d does not support set_class_colors".into(),
3301                )),
3302                ForcedBackend::OpenGl => {
3303                    #[cfg(any(
3304                        target_os = "linux",
3305                        target_os = "macos",
3306                        target_os = "ios",
3307                        target_os = "android",
3308                        target_os = "windows"
3309                    ))]
3310                    #[cfg(feature = "opengl")]
3311                    if let Some(opengl) = self.opengl.as_mut() {
3312                        return opengl.set_class_colors(colors);
3313                    }
3314                    Err(Error::ForcedBackendUnavailable("opengl".into()))
3315                }
3316            };
3317        }
3318
3319        // skip G2D as it doesn't support rendering to image
3320
3321        // Every available backend, not the first that accepts. A palette is
3322        // state a later draw reads, and which backend that draw lands on is
3323        // not known here: GL declines a `view()` destination, an unsupported
3324        // format or an import failure, and the CPU backend takes over. A
3325        // backend that never received the colours would render
3326        // `DEFAULT_COLORS_U8` with nothing to show the caller why.
3327        let mut backends = 0usize;
3328        let mut first_err = None;
3329        #[cfg(any(
3330            target_os = "linux",
3331            target_os = "macos",
3332            target_os = "ios",
3333            target_os = "android",
3334            target_os = "windows"
3335        ))]
3336        #[cfg(feature = "opengl")]
3337        if let Some(opengl) = self.opengl.as_mut() {
3338            backends += 1;
3339            match opengl.set_class_colors(colors) {
3340                Ok(()) => log::trace!("colors set with opengl in {:?}", start.elapsed()),
3341                Err(e) => {
3342                    log::trace!("colors didn't set with opengl: {e:?}");
3343                    first_err.get_or_insert(e);
3344                }
3345            }
3346        }
3347        if let Some(cpu) = self.cpu.as_mut() {
3348            backends += 1;
3349            match cpu.set_class_colors(colors) {
3350                Ok(()) => log::trace!("colors set with cpu in {:?}", start.elapsed()),
3351                Err(e) => {
3352                    log::trace!("colors didn't set with cpu: {e:?}");
3353                    first_err.get_or_insert(e);
3354                }
3355            }
3356        }
3357        // The first error, so a GL failure is not hidden by the CPU backend
3358        // accepting afterwards; the colours the other backends took are kept
3359        // either way, since a partial palette beats a stale one.
3360        match first_err {
3361            Some(e) => Err(e),
3362            None if backends > 0 => Ok(()),
3363            None => Err(Error::NoConverter),
3364        }
3365    }
3366}
3367
3368// ---------------------------------------------------------------------------
3369// Image loading / saving helpers
3370// ---------------------------------------------------------------------------
3371
3372/// Test-only convenience helper that peeks the image header, allocates a
3373/// tensor sized to the image (honoring DMA pitch padding on Linux when
3374/// requested), and decodes via [`edgefirst_codec`]. Mirrors the semantics of
3375/// the removed public `load_image` API for test sites; production callers
3376/// should use the explicit peek → allocate → decode pattern directly.
3377#[cfg(all(test, feature = "codec"))]
3378pub(crate) fn load_image_test_helper(
3379    image: &[u8],
3380    format: Option<PixelFormat>,
3381    memory: Option<TensorMemory>,
3382) -> Result<TensorDyn> {
3383    use edgefirst_codec::{peek_info, ImageDecoder, ImageLoad};
3384
3385    // Peek the source header to get its NATIVE format and dimensions. The
3386    // codec now emits the source's native format (JPEG → Nv12/Grey, PNG →
3387    // Rgb/Rgba/Grey) and configures the destination tensor itself.
3388    let info = peek_info(image)?;
3389    let native_fmt = info.format;
3390    let w = info.width;
3391    let h = info.height;
3392
3393    let mut decoder = ImageDecoder::new();
3394
3395    // Decode into a native-format tensor. The decoder sets the tensor's
3396    // dims+format, so we allocate it sized to the native layout.
3397    #[cfg(target_os = "linux")]
3398    let native_src = {
3399        if let Some(aligned_pitch) = padded_dma_pitch_for(native_fmt, w, &memory) {
3400            let mut dma = Tensor::<u8>::image_with_stride(
3401                w,
3402                h,
3403                native_fmt,
3404                aligned_pitch,
3405                Some(TensorMemory::DmaBuf),
3406                edgefirst_tensor::CpuAccess::ReadWrite,
3407            )?;
3408            dma.load_image(&mut decoder, image)?;
3409            TensorDyn::from(dma)
3410        } else {
3411            let mut img = Tensor::<u8>::image(
3412                w,
3413                h,
3414                native_fmt,
3415                memory,
3416                edgefirst_tensor::CpuAccess::ReadWrite,
3417            )?;
3418            img.load_image(&mut decoder, image)?;
3419            TensorDyn::from(img)
3420        }
3421    };
3422    #[cfg(not(target_os = "linux"))]
3423    let native_src = {
3424        let mut img = Tensor::<u8>::image(
3425            w,
3426            h,
3427            native_fmt,
3428            memory,
3429            edgefirst_tensor::CpuAccess::ReadWrite,
3430        )?;
3431        img.load_image(&mut decoder, image)?;
3432        TensorDyn::from(img)
3433    };
3434
3435    // If the caller requested a different format, convert into it (same
3436    // dims) using a headless CPU-backed processor so the helper works
3437    // without GPU/G2D hardware.
3438    match format {
3439        Some(f) if f != native_fmt => {
3440            let mut dst = TensorDyn::image(
3441                w,
3442                h,
3443                f,
3444                DType::U8,
3445                memory,
3446                edgefirst_tensor::CpuAccess::ReadWrite,
3447            )?;
3448            // `ImageProcessorConfig` has platform-specific fields: on Linux it
3449            // carries extra GL/G2D options so `..Default::default()` is needed,
3450            // but on macOS `backend` is the only field, making the update
3451            // redundant (clippy::needless_update). Allow it for cross-platform
3452            // parity — the alternative (field reassign) trips
3453            // clippy::field_reassign_with_default on Linux instead.
3454            #[allow(clippy::needless_update)]
3455            let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
3456                backend: ComputeBackend::Cpu,
3457                ..Default::default()
3458            })?;
3459            proc.convert(
3460                &native_src,
3461                &mut dst,
3462                Rotation::None,
3463                Flip::None,
3464                Crop::default(),
3465            )?;
3466            Ok(dst)
3467        }
3468        _ => Ok(native_src),
3469    }
3470}
3471
3472/// Save a [`TensorDyn`] image as a JPEG file.
3473///
3474/// Only packed RGB and RGBA formats are supported.
3475pub fn save_jpeg(tensor: &TensorDyn, path: impl AsRef<std::path::Path>, quality: u8) -> Result<()> {
3476    let t = tensor.as_typed::<u8>().ok_or(Error::UnsupportedFormat(
3477        "save_jpeg requires u8 tensor".to_string(),
3478    ))?;
3479    let fmt = t.format().ok_or(Error::NotAnImage)?;
3480    if fmt.layout() != PixelLayout::Packed {
3481        return Err(Error::NotImplemented(
3482            "Saving planar images is not supported".to_string(),
3483        ));
3484    }
3485
3486    let colour = match fmt {
3487        PixelFormat::Rgb => jpeg_encoder::ColorType::Rgb,
3488        PixelFormat::Rgba => jpeg_encoder::ColorType::Rgba,
3489        _ => {
3490            return Err(Error::NotImplemented(
3491                "Unsupported image format for saving".to_string(),
3492            ));
3493        }
3494    };
3495
3496    let w = t.width().ok_or(Error::NotAnImage)?;
3497    let h = t.height().ok_or(Error::NotAnImage)?;
3498    let encoder = jpeg_encoder::Encoder::new_file(path, quality)?;
3499    let tensor_map = t.map_read()?;
3500
3501    encoder.encode(&tensor_map, w as u16, h as u16, colour)?;
3502
3503    Ok(())
3504}
3505
3506pub(crate) struct FunctionTimer<T: Display> {
3507    name: T,
3508    start: std::time::Instant,
3509}
3510
3511impl<T: Display> FunctionTimer<T> {
3512    pub fn new(name: T) -> Self {
3513        Self {
3514            name,
3515            start: std::time::Instant::now(),
3516        }
3517    }
3518}
3519
3520impl<T: Display> Drop for FunctionTimer<T> {
3521    fn drop(&mut self) {
3522        log::trace!("{} elapsed: {:?}", self.name, self.start.elapsed())
3523    }
3524}
3525
3526const DEFAULT_COLORS: [[f32; 4]; 20] = [
3527    [0., 1., 0., 0.7],
3528    [1., 0.5568628, 0., 0.7],
3529    [0.25882353, 0.15294118, 0.13333333, 0.7],
3530    [0.8, 0.7647059, 0.78039216, 0.7],
3531    [0.3137255, 0.3137255, 0.3137255, 0.7],
3532    [0.1411765, 0.3098039, 0.1215686, 0.7],
3533    [1., 0.95686275, 0.5137255, 0.7],
3534    [0.3529412, 0.32156863, 0., 0.7],
3535    [0.4235294, 0.6235294, 0.6509804, 0.7],
3536    [0.5098039, 0.5098039, 0.7294118, 0.7],
3537    [0.00784314, 0.18823529, 0.29411765, 0.7],
3538    [0.0, 0.2706, 1.0, 0.7],
3539    [0.0, 0.0, 0.0, 0.7],
3540    [0.0, 0.5, 0.0, 0.7],
3541    [1.0, 0.0, 0.0, 0.7],
3542    [0.0, 0.0, 1.0, 0.7],
3543    [1.0, 0.5, 0.5, 0.7],
3544    [0.1333, 0.5451, 0.1333, 0.7],
3545    [0.1176, 0.4118, 0.8235, 0.7],
3546    [1., 1., 1., 0.7],
3547];
3548
3549const fn denorm<const M: usize, const N: usize>(a: [[f32; M]; N]) -> [[u8; M]; N] {
3550    let mut result = [[0; M]; N];
3551    let mut i = 0;
3552    while i < N {
3553        let mut j = 0;
3554        while j < M {
3555            result[i][j] = (a[i][j] * 255.0).round() as u8;
3556            j += 1;
3557        }
3558        i += 1;
3559    }
3560    result
3561}
3562
3563const DEFAULT_COLORS_U8: [[u8; 4]; 20] = denorm(DEFAULT_COLORS);
3564
3565#[cfg(test)]
3566#[cfg_attr(coverage_nightly, coverage(off))]
3567mod alignment_tests {
3568    use super::*;
3569
3570    #[test]
3571    fn align_width_rgba8_common_widths() {
3572        // RGBA8 (bpp=4, lcm(64,4)=64, so width must round to multiple of 16 px).
3573        assert_eq!(align_width_for_gpu_pitch(640, 4), 640); // 2560 byte pitch — already aligned
3574        assert_eq!(align_width_for_gpu_pitch(1280, 4), 1280); // 5120
3575        assert_eq!(align_width_for_gpu_pitch(1920, 4), 1920); // 7680
3576        assert_eq!(align_width_for_gpu_pitch(3840, 4), 3840); // 15360
3577                                                              // crowd.png case from the imx95 investigation:
3578        assert_eq!(align_width_for_gpu_pitch(3004, 4), 3008); // 12016 → 12032
3579        assert_eq!(align_width_for_gpu_pitch(3000, 4), 3008); // 12000 → 12032
3580        assert_eq!(align_width_for_gpu_pitch(17, 4), 32); // 68 → 128
3581        assert_eq!(align_width_for_gpu_pitch(1, 4), 16); // 4 → 64
3582    }
3583
3584    #[test]
3585    fn align_width_rgb888_packed() {
3586        // RGB888 (bpp=3, lcm(64,3)=192, so width must round to multiple of 64 px).
3587        assert_eq!(align_width_for_gpu_pitch(64, 3), 64); // 192 byte pitch
3588        assert_eq!(align_width_for_gpu_pitch(640, 3), 640); // 1920
3589        assert_eq!(align_width_for_gpu_pitch(1, 3), 64); // 3 → 192
3590        assert_eq!(align_width_for_gpu_pitch(65, 3), 128); // 195 → 384
3591                                                           // Verify the rounded width × bpp is a clean multiple of the LCM.
3592        for w in [3004usize, 1281, 100, 17] {
3593            let padded = align_width_for_gpu_pitch(w, 3);
3594            assert!(padded >= w);
3595            assert_eq!((padded * 3) % 64, 0);
3596            assert_eq!((padded * 3) % 3, 0);
3597        }
3598    }
3599
3600    #[test]
3601    fn align_width_grey_u8() {
3602        // Grey (bpp=1, lcm(64,1)=64, so width must round to multiple of 64 px).
3603        assert_eq!(align_width_for_gpu_pitch(64, 1), 64);
3604        assert_eq!(align_width_for_gpu_pitch(640, 1), 640);
3605        assert_eq!(align_width_for_gpu_pitch(1, 1), 64);
3606        assert_eq!(align_width_for_gpu_pitch(65, 1), 128);
3607    }
3608
3609    #[test]
3610    fn align_width_zero_inputs() {
3611        assert_eq!(align_width_for_gpu_pitch(0, 4), 0);
3612        assert_eq!(align_width_for_gpu_pitch(640, 0), 640);
3613    }
3614
3615    #[test]
3616    fn align_width_never_returns_smaller_than_input() {
3617        // Spot-check the "returned width >= input width" contract across a
3618        // range of values that would previously have hit `width * bpp`
3619        // overflow paths.
3620        for &bpp in &[1usize, 2, 3, 4, 8] {
3621            for &w in &[
3622                1usize,
3623                17,
3624                64,
3625                65,
3626                100,
3627                1280,
3628                1281,
3629                1920,
3630                3004,
3631                3072,
3632                3840,
3633                usize::MAX / 8,
3634                usize::MAX / 4,
3635                usize::MAX / 2,
3636                usize::MAX - 1,
3637                usize::MAX,
3638            ] {
3639                let aligned = align_width_for_gpu_pitch(w, bpp);
3640                assert!(
3641                    aligned >= w,
3642                    "align_width_for_gpu_pitch({w}, {bpp}) = {aligned} < {w}"
3643                );
3644            }
3645        }
3646    }
3647
3648    #[test]
3649    fn align_width_overflow_returns_unaligned_not_smaller() {
3650        // For width values close to usize::MAX, padding up would wrap. The
3651        // function must return the original width rather than wrapping or
3652        // panicking. A pre-aligned width round-trips unchanged even at the
3653        // extreme.
3654        let aligned_extreme = usize::MAX - 15; // 16-pixel boundary for RGBA8
3655        assert_eq!(
3656            align_width_for_gpu_pitch(aligned_extreme, 4),
3657            aligned_extreme
3658        );
3659        // A misaligned extreme value cannot be rounded up — the function
3660        // returns the original.
3661        let misaligned_extreme = usize::MAX - 1;
3662        let result = align_width_for_gpu_pitch(misaligned_extreme, 4);
3663        assert!(
3664            result == misaligned_extreme || result >= misaligned_extreme,
3665            "extreme misaligned width must not be rounded down to {result}"
3666        );
3667    }
3668
3669    #[test]
3670    fn checked_lcm_basic_and_overflow() {
3671        assert_eq!(checked_num_integer_lcm(64, 4), Some(64));
3672        assert_eq!(checked_num_integer_lcm(64, 3), Some(192));
3673        assert_eq!(checked_num_integer_lcm(64, 1), Some(64));
3674        assert_eq!(checked_num_integer_lcm(0, 4), Some(0));
3675        assert_eq!(checked_num_integer_lcm(64, 0), Some(0));
3676        // Coprime values whose product exceeds usize::MAX must return None.
3677        assert_eq!(
3678            checked_num_integer_lcm(usize::MAX, usize::MAX - 1),
3679            None,
3680            "coprime extreme values must overflow detect, not panic"
3681        );
3682    }
3683
3684    #[test]
3685    fn primary_plane_bpp_known_formats() {
3686        // Packed formats use channels × elem_size.
3687        assert_eq!(primary_plane_bpp(PixelFormat::Rgba, 1), Some(4));
3688        assert_eq!(primary_plane_bpp(PixelFormat::Bgra, 1), Some(4));
3689        assert_eq!(primary_plane_bpp(PixelFormat::Rgb, 1), Some(3));
3690        assert_eq!(primary_plane_bpp(PixelFormat::Grey, 1), Some(1));
3691        // Semi-planar (NV12) reports the luma plane's bpp.
3692        assert_eq!(primary_plane_bpp(PixelFormat::Nv12, 1), Some(1));
3693    }
3694}
3695
3696#[cfg(test)]
3697#[cfg_attr(coverage_nightly, coverage(off))]
3698#[allow(deprecated)]
3699mod image_tests {
3700    use super::*;
3701    use crate::{CPUProcessor, Rotation};
3702    #[cfg(target_os = "linux")]
3703    use edgefirst_tensor::is_dma_available;
3704    use edgefirst_tensor::{TensorMapTrait, TensorMemory, TensorTrait};
3705    use image::buffer::ConvertBuffer;
3706
3707    /// Test helper: call `ImageProcessorTrait::convert()` on two `TensorDyn`s
3708    /// by going through the `TensorDyn` API.
3709    ///
3710    /// Returns the `(src_image, dst_image)` reconstructed from the TensorDyn
3711    /// round-trip so the caller can feed them to `compare_images` etc.
3712    fn convert_img(
3713        proc: &mut dyn ImageProcessorTrait,
3714        src: TensorDyn,
3715        dst: TensorDyn,
3716        rotation: Rotation,
3717        flip: Flip,
3718        crop: Crop,
3719    ) -> (Result<()>, TensorDyn, TensorDyn) {
3720        let src_fourcc = src.format().unwrap();
3721        let dst_fourcc = dst.format().unwrap();
3722        let src_dyn = src;
3723        let mut dst_dyn = dst;
3724        let result = proc.convert(&src_dyn, &mut dst_dyn, rotation, flip, crop);
3725        let src_back = {
3726            let mut __t = src_dyn.into_u8().unwrap();
3727            __t.set_format(src_fourcc).unwrap();
3728            TensorDyn::from(__t)
3729        };
3730        let dst_back = {
3731            let mut __t = dst_dyn.into_u8().unwrap();
3732            __t.set_format(dst_fourcc).unwrap();
3733            TensorDyn::from(__t)
3734        };
3735        (result, src_back, dst_back)
3736    }
3737
3738    #[ctor::ctor(unsafe)]
3739    fn init() {
3740        env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
3741    }
3742
3743    macro_rules! function {
3744        () => {{
3745            fn f() {}
3746            fn type_name_of<T>(_: T) -> &'static str {
3747                std::any::type_name::<T>()
3748            }
3749            let name = type_name_of(f);
3750
3751            // Find and cut the rest of the path
3752            match &name[..name.len() - 3].rfind(':') {
3753                Some(pos) => &name[pos + 1..name.len() - 3],
3754                None => &name[..name.len() - 3],
3755            }
3756        }};
3757    }
3758
3759    /// Master oracle for the view/batch **destination** batch engine: render `N`
3760    /// tiles into row-bands of ONE tall destination and assert each band equals
3761    /// the same source converted standalone — proving correct band placement and
3762    /// that a later tile's letterbox clear / draw never wipes a sibling band. On
3763    /// the Linux GL backend the `N` `convert_deferred` calls share ONE parent
3764    /// EGLImage import (each tile is a `glViewport`/`glScissor` ROI) and sync
3765    /// once at `flush()`; other backends fall back to an eager per-band convert
3766    /// (CPU writes via offset + parent stride). Either way the oracle must hold.
3767    ///
3768    /// Identical source/tile size makes the convert an exact copy, so the
3769    /// assertion is backend-agnostic (no GL-vs-CPU resampling drift). Distinct
3770    /// solid colors per tile make any sibling wipe a hard failure.
3771    #[test]
3772    fn batch_view_dst_tiles_match_standalone() {
3773        let mut proc = match ImageProcessor::new() {
3774            Ok(p) => p,
3775            Err(e) => {
3776                crate::test_support::report_skip(&format!(
3777                    "{} — ImageProcessor init failed ({e:?})",
3778                    function!()
3779                ));
3780                return;
3781            }
3782        };
3783        let n = 3usize;
3784        let (w, h) = (32usize, 24usize);
3785        let colors: [[u8; 4]; 3] = [[210, 40, 40, 255], [40, 210, 40, 255], [40, 40, 210, 255]];
3786        let make_src = |c: [u8; 4]| -> TensorDyn {
3787            let bytes: Vec<u8> = c.iter().copied().cycle().take(w * h * 4).collect();
3788            load_bytes_to_tensor(w, h, PixelFormat::Rgba, Some(TensorMemory::Mem), &bytes).unwrap()
3789        };
3790        // Tall destination: N stacked row-bands. DMA so the Linux GL band path
3791        // runs (one parent import + per-tile glViewport); skip if unavailable.
3792        let parent = match TensorDyn::image(
3793            w,
3794            n * h,
3795            PixelFormat::Rgba,
3796            DType::U8,
3797            Some(TensorMemory::DmaBuf),
3798            edgefirst_tensor::CpuAccess::ReadWrite,
3799        ) {
3800            Ok(d) => d,
3801            Err(e) => {
3802                crate::test_support::report_skip(&format!(
3803                    "{} — tall DMA destination alloc failed ({e:?})",
3804                    function!()
3805                ));
3806                return;
3807            }
3808        };
3809
3810        // Deferred batch: one parent import, glViewport/scissor per band, one sync.
3811        for (i, &c) in colors.iter().enumerate().take(n) {
3812            let mut tile = parent.view(Region::new(0, i * h, w, h)).unwrap();
3813            proc.convert_deferred(
3814                &make_src(c),
3815                &mut tile,
3816                Rotation::None,
3817                Flip::None,
3818                Crop::no_crop(),
3819            )
3820            .unwrap_or_else(|e| panic!("convert_deferred tile {i}: {e:?}"));
3821        }
3822        proc.flush().unwrap();
3823
3824        for (i, &c) in colors.iter().enumerate().take(n) {
3825            // Standalone full-buffer convert of the same source = the oracle.
3826            let mut solo = TensorDyn::image(
3827                w,
3828                h,
3829                PixelFormat::Rgba,
3830                DType::U8,
3831                Some(TensorMemory::DmaBuf),
3832                edgefirst_tensor::CpuAccess::ReadWrite,
3833            )
3834            .unwrap();
3835            proc.convert(
3836                &make_src(c),
3837                &mut solo,
3838                Rotation::None,
3839                Flip::None,
3840                Crop::no_crop(),
3841            )
3842            .unwrap();
3843
3844            let band = parent.view(Region::new(0, i * h, w, h)).unwrap();
3845            let band_bytes = band
3846                .as_typed::<u8>()
3847                .unwrap()
3848                .map()
3849                .unwrap()
3850                .as_slice()
3851                .to_vec();
3852            let solo_bytes = solo
3853                .as_typed::<u8>()
3854                .unwrap()
3855                .map()
3856                .unwrap()
3857                .as_slice()
3858                .to_vec();
3859            assert_eq!(
3860                band_bytes, solo_bytes,
3861                "tile {i}: band differs from standalone convert (placement or sibling wipe)"
3862            );
3863            assert!(
3864                band_bytes.as_chunks::<4>().0.iter().all(|&p| p == c),
3865                "tile {i}: band is not the expected solid color {c:?} (sibling wipe?)"
3866            );
3867        }
3868    }
3869
3870    /// Build a `w`×`h` RGBA frame whose pixels encode their position, so a crop
3871    /// at any origin has distinct content (catches crop-origin / band-placement
3872    /// bugs a solid color cannot).
3873    #[cfg(test)]
3874    fn gradient_frame(w: usize, h: usize) -> TensorDyn {
3875        let mut bytes = vec![0u8; w * h * 4];
3876        for y in 0..h {
3877            for x in 0..w {
3878                let i = (y * w + x) * 4;
3879                bytes[i] = x as u8;
3880                bytes[i + 1] = y as u8;
3881                bytes[i + 2] = (x ^ y) as u8;
3882                bytes[i + 3] = 255;
3883            }
3884        }
3885        load_bytes_to_tensor(w, h, PixelFormat::Rgba, Some(TensorMemory::Mem), &bytes).unwrap()
3886    }
3887
3888    /// `tile_into` band == standalone crop-convert of the same source region,
3889    /// on the CPU backend (runs on CI without a GPU). Distinct gradient content
3890    /// makes a wrong crop origin or a sibling-band wipe a hard failure.
3891    #[test]
3892    fn tile_into_cpu_distinct_content_parity() {
3893        let mut proc = match ImageProcessor::with_config(ImageProcessorConfig {
3894            backend: ComputeBackend::Cpu,
3895            ..Default::default()
3896        }) {
3897            Ok(p) => p,
3898            Err(e) => {
3899                crate::test_support::report_skip(&format!(
3900                    "{} — CPU init failed ({e:?})",
3901                    function!()
3902                ));
3903                return;
3904            }
3905        };
3906        let (fw, fh) = (96usize, 64usize);
3907        let src = gradient_frame(fw, fh);
3908        let cfg = TilingConfig::new(32, 32).with_overlap(0.0); // exact 3×2 tiling
3909        let n = tile_grid(fh, fw, 32, 32, 0.0).len();
3910        assert_eq!(n, 6);
3911
3912        let mut parent = proc
3913            .alloc_tile_batch(
3914                n,
3915                &cfg,
3916                PixelFormat::Rgba,
3917                DType::U8,
3918                Some(TensorMemory::Mem),
3919                edgefirst_tensor::CpuAccess::ReadWrite,
3920            )
3921            .unwrap();
3922        let placements = proc.tile_into(&src, &mut parent, &cfg).unwrap();
3923        assert_eq!(placements.len(), n);
3924
3925        for p in &placements {
3926            let source = Region::new(
3927                p.origin.0 as usize,
3928                p.origin.1 as usize,
3929                p.crop_size.0 as usize,
3930                p.crop_size.1 as usize,
3931            );
3932            let mut solo = TensorDyn::image(
3933                32,
3934                32,
3935                PixelFormat::Rgba,
3936                DType::U8,
3937                Some(TensorMemory::Mem),
3938                edgefirst_tensor::CpuAccess::ReadWrite,
3939            )
3940            .unwrap();
3941            proc.convert(
3942                &src,
3943                &mut solo,
3944                Rotation::None,
3945                Flip::None,
3946                Crop::default()
3947                    .with_source(Some(source))
3948                    .with_fit(Fit::Stretch),
3949            )
3950            .unwrap();
3951            let band = parent.view(Region::new(0, p.index * 32, 32, 32)).unwrap();
3952            let band_bytes = band
3953                .as_typed::<u8>()
3954                .unwrap()
3955                .map()
3956                .unwrap()
3957                .as_slice()
3958                .to_vec();
3959            let solo_bytes = solo
3960                .as_typed::<u8>()
3961                .unwrap()
3962                .map()
3963                .unwrap()
3964                .as_slice()
3965                .to_vec();
3966            assert_eq!(
3967                band_bytes, solo_bytes,
3968                "tile {} band differs from standalone crop-convert",
3969                p.index
3970            );
3971        }
3972    }
3973
3974    /// Streaming `tile_one` into a single slot == the corresponding batched
3975    /// `tile_into` band (proves the two paths agree).
3976    #[test]
3977    fn tile_one_matches_tile_into_band() {
3978        let mut proc = match ImageProcessor::with_config(ImageProcessorConfig {
3979            backend: ComputeBackend::Cpu,
3980            ..Default::default()
3981        }) {
3982            Ok(p) => p,
3983            Err(e) => {
3984                crate::test_support::report_skip(&format!(
3985                    "{} — CPU init failed ({e:?})",
3986                    function!()
3987                ));
3988                return;
3989            }
3990        };
3991        let (fw, fh) = (96usize, 64usize);
3992        let src = gradient_frame(fw, fh);
3993        let cfg = TilingConfig::new(32, 32).with_overlap(0.0);
3994        let n = tile_grid(fh, fw, 32, 32, 0.0).len();
3995
3996        let mut parent = proc
3997            .alloc_tile_batch(
3998                n,
3999                &cfg,
4000                PixelFormat::Rgba,
4001                DType::U8,
4002                Some(TensorMemory::Mem),
4003                edgefirst_tensor::CpuAccess::ReadWrite,
4004            )
4005            .unwrap();
4006        proc.tile_into(&src, &mut parent, &cfg).unwrap();
4007
4008        let plan = proc.plan_tiles(fw, fh, &cfg).unwrap();
4009        for p in &plan {
4010            let mut slot = TensorDyn::image(
4011                32,
4012                32,
4013                PixelFormat::Rgba,
4014                DType::U8,
4015                Some(TensorMemory::Mem),
4016                edgefirst_tensor::CpuAccess::ReadWrite,
4017            )
4018            .unwrap();
4019            proc.tile_one(&src, &mut slot, p, &cfg).unwrap();
4020            proc.flush().unwrap();
4021            let band = parent.view(Region::new(0, p.index * 32, 32, 32)).unwrap();
4022            let slot_bytes = slot
4023                .as_typed::<u8>()
4024                .unwrap()
4025                .map()
4026                .unwrap()
4027                .as_slice()
4028                .to_vec();
4029            let band_bytes = band
4030                .as_typed::<u8>()
4031                .unwrap()
4032                .map()
4033                .unwrap()
4034                .as_slice()
4035                .to_vec();
4036            assert_eq!(
4037                slot_bytes, band_bytes,
4038                "tile {} stream != batch band",
4039                p.index
4040            );
4041        }
4042    }
4043
4044    /// `plan_tiles` returns correct per-tile metadata for a 4K frame (pure, no
4045    /// GPU render).
4046    /// `tile_into` through the auto backend with a DMA parent — exercises the
4047    /// GL single-import band path on a GPU machine (skips when DMA/GL is
4048    /// unavailable). The source is sampled via `Crop::with_source`, never
4049    /// `src.view()`, so all tiles share one source import.
4050    #[test]
4051    fn tile_into_auto_dma_parity() {
4052        let mut proc = match ImageProcessor::new() {
4053            Ok(p) => p,
4054            Err(e) => {
4055                crate::test_support::report_skip(&format!(
4056                    "{} — ImageProcessor init failed ({e:?})",
4057                    function!()
4058                ));
4059                return;
4060            }
4061        };
4062        let (fw, fh) = (96usize, 64usize);
4063        let src = gradient_frame(fw, fh);
4064        let cfg = TilingConfig::new(32, 32).with_overlap(0.0);
4065        let n = tile_grid(fh, fw, 32, 32, 0.0).len();
4066
4067        let mut parent = match proc.alloc_tile_batch(
4068            n,
4069            &cfg,
4070            PixelFormat::Rgba,
4071            DType::U8,
4072            Some(TensorMemory::DmaBuf),
4073            edgefirst_tensor::CpuAccess::ReadWrite,
4074        ) {
4075            Ok(p) => p,
4076            Err(e) => {
4077                crate::test_support::report_skip(&format!(
4078                    "{} — tall DMA parent alloc failed ({e:?})",
4079                    function!()
4080                ));
4081                return;
4082            }
4083        };
4084        let placements = proc.tile_into(&src, &mut parent, &cfg).unwrap();
4085
4086        for p in &placements {
4087            let source = Region::new(
4088                p.origin.0 as usize,
4089                p.origin.1 as usize,
4090                p.crop_size.0 as usize,
4091                p.crop_size.1 as usize,
4092            );
4093            let mut solo = TensorDyn::image(
4094                32,
4095                32,
4096                PixelFormat::Rgba,
4097                DType::U8,
4098                Some(TensorMemory::DmaBuf),
4099                edgefirst_tensor::CpuAccess::ReadWrite,
4100            )
4101            .unwrap();
4102            proc.convert(
4103                &src,
4104                &mut solo,
4105                Rotation::None,
4106                Flip::None,
4107                Crop::default()
4108                    .with_source(Some(source))
4109                    .with_fit(Fit::Stretch),
4110            )
4111            .unwrap();
4112            let band = parent.view(Region::new(0, p.index * 32, 32, 32)).unwrap();
4113            // Structural-similarity tolerance (the house GPU-parity bar) rather
4114            // than exact bytes: rendering a tile into a viewport band at a
4115            // non-zero offset vs. a standalone origin render diverges by sampling
4116            // rounding on virtualized GPUs (paravirtual Metal/ANGLE on macOS CI),
4117            // the same tolerance class as the other cross-backend parity tests.
4118            compare_images(
4119                &band,
4120                &solo,
4121                0.98,
4122                &format!("{}_tile{}", function!(), p.index),
4123            );
4124        }
4125    }
4126
4127    /// `tile_into` rejects a destination too small to hold all tile bands.
4128    #[test]
4129    fn tile_into_undersized_dst_errors() {
4130        let mut proc = match ImageProcessor::with_config(ImageProcessorConfig {
4131            backend: ComputeBackend::Cpu,
4132            ..Default::default()
4133        }) {
4134            Ok(p) => p,
4135            Err(e) => {
4136                crate::test_support::report_skip(&format!(
4137                    "{} — CPU init failed ({e:?})",
4138                    function!()
4139                ));
4140                return;
4141            }
4142        };
4143        let (fw, fh) = (96usize, 64usize);
4144        let src = gradient_frame(fw, fh);
4145        let cfg = TilingConfig::new(32, 32).with_overlap(0.0); // 6 tiles
4146                                                               // Allocate a parent for only 2 bands, far short of 6.
4147        let mut small = TensorDyn::image(
4148            32,
4149            2 * 32,
4150            PixelFormat::Rgba,
4151            DType::U8,
4152            Some(TensorMemory::Mem),
4153            edgefirst_tensor::CpuAccess::ReadWrite,
4154        )
4155        .unwrap();
4156        let r = proc.tile_into(&src, &mut small, &cfg);
4157        assert!(
4158            matches!(r, Err(Error::InvalidShape(_))),
4159            "expected InvalidShape, got {r:?}"
4160        );
4161    }
4162
4163    /// `alloc_tile_batch` / `plan_tiles` reject an invalid config (zero tile).
4164    #[test]
4165    fn tiling_alloc_rejects_invalid_config() {
4166        let proc = match ImageProcessor::with_config(ImageProcessorConfig {
4167            backend: ComputeBackend::Cpu,
4168            ..Default::default()
4169        }) {
4170            Ok(p) => p,
4171            Err(e) => {
4172                crate::test_support::report_skip(&format!(
4173                    "{} — CPU init failed ({e:?})",
4174                    function!()
4175                ));
4176                return;
4177            }
4178        };
4179        let bad = TilingConfig::new(0, 640);
4180        assert!(proc.plan_tiles(1920, 1080, &bad).is_err());
4181        assert!(proc
4182            .alloc_tile_batch(
4183                4,
4184                &bad,
4185                PixelFormat::Rgba,
4186                DType::U8,
4187                Some(TensorMemory::Mem),
4188                edgefirst_tensor::CpuAccess::ReadWrite,
4189            )
4190            .is_err());
4191    }
4192
4193    #[test]
4194    fn plan_tiles_metadata_4k() {
4195        let proc = match ImageProcessor::with_config(ImageProcessorConfig {
4196            backend: ComputeBackend::Cpu,
4197            ..Default::default()
4198        }) {
4199            Ok(p) => p,
4200            Err(e) => {
4201                crate::test_support::report_skip(&format!(
4202                    "{} — CPU init failed ({e:?})",
4203                    function!()
4204                ));
4205                return;
4206            }
4207        };
4208        let cfg = TilingConfig::new(640, 640).with_overlap(0.2);
4209        let plan = proc.plan_tiles(3840, 2160, &cfg).unwrap();
4210        assert_eq!(plan.len(), 32);
4211        assert!(plan.iter().all(|p| p.count == 32));
4212        assert!(plan.iter().all(|p| p.crop_size == (640.0, 640.0)));
4213        assert!(plan.iter().all(|p| p.letterbox.is_none())); // stretch fit
4214        assert!(plan.iter().all(|p| p.frame_dims == (3840.0, 2160.0)));
4215        assert_eq!(plan[0].origin, (0.0, 0.0));
4216    }
4217
4218    #[test]
4219    fn test_invalid_crop() {
4220        let src = TensorDyn::image(
4221            100,
4222            100,
4223            PixelFormat::Rgb,
4224            DType::U8,
4225            None,
4226            edgefirst_tensor::CpuAccess::ReadWrite,
4227        )
4228        .unwrap();
4229        let dst = TensorDyn::image(
4230            100,
4231            100,
4232            PixelFormat::Rgb,
4233            DType::U8,
4234            None,
4235            edgefirst_tensor::CpuAccess::ReadWrite,
4236        )
4237        .unwrap();
4238
4239        // A source crop exceeding the source bounds is rejected.
4240        let crop = Crop::new().with_source(Some(Region::new(50, 50, 60, 60)));
4241        assert!(matches!(
4242            crop.check_crop_dyn(&src, &dst),
4243            Err(Error::CropInvalid(_))
4244        ));
4245
4246        // A source crop within bounds is valid.
4247        let crop = Crop::new().with_source(Some(Region::new(0, 0, 10, 10)));
4248        assert!(crop.check_crop_dyn(&src, &dst).is_ok());
4249
4250        // Letterbox is always valid — placement is computed within the dst.
4251        assert!(Crop::letterbox([0, 0, 0, 255])
4252            .check_crop_dyn(&src, &dst)
4253            .is_ok());
4254    }
4255
4256    #[test]
4257    fn test_invalid_tensor_format() -> Result<(), Error> {
4258        // 4D tensor cannot be set to a 3-channel pixel format
4259        let mut tensor = Tensor::<u8>::new(&[720, 1280, 4, 1], None, None)?;
4260        let result = tensor.set_format(PixelFormat::Rgb);
4261        assert!(result.is_err(), "4D tensor should reject set_format");
4262
4263        // Tensor with wrong channel count for the format
4264        let mut tensor = Tensor::<u8>::new(&[720, 1280, 4], None, None)?;
4265        let result = tensor.set_format(PixelFormat::Rgb);
4266        assert!(result.is_err(), "4-channel tensor should reject RGB format");
4267
4268        Ok(())
4269    }
4270
4271    #[test]
4272    fn test_invalid_image_file() -> Result<(), Error> {
4273        let result = crate::load_image_test_helper(&[123; 5000], None, None);
4274        assert!(
4275            matches!(result, Err(Error::Codec(_))),
4276            "unrecognised bytes should surface as Error::Codec, got {result:?}"
4277        );
4278        Ok(())
4279    }
4280
4281    #[test]
4282    fn test_invalid_jpeg_format() -> Result<(), Error> {
4283        let result = crate::load_image_test_helper(&[123; 5000], Some(PixelFormat::Yuyv), None);
4284        // YUYV is not a valid decode target; peek_info fails before the magic-
4285        // bytes check, so the precise variant depends on which error fires first.
4286        assert!(
4287            matches!(result, Err(Error::Codec(_))),
4288            "Yuyv target with garbage bytes should surface as Error::Codec, got {result:?}"
4289        );
4290        Ok(())
4291    }
4292
4293    #[test]
4294    fn test_load_resize_save() {
4295        let file = edgefirst_bench::testdata::read("zidane.jpg");
4296        let img = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4297        assert_eq!(img.width(), Some(1280));
4298        assert_eq!(img.height(), Some(720));
4299
4300        let dst = TensorDyn::image(
4301            640,
4302            360,
4303            PixelFormat::Rgba,
4304            DType::U8,
4305            None,
4306            edgefirst_tensor::CpuAccess::ReadWrite,
4307        )
4308        .unwrap();
4309        let mut converter = CPUProcessor::new();
4310        let (result, _img, dst) = convert_img(
4311            &mut converter,
4312            img,
4313            dst,
4314            Rotation::None,
4315            Flip::None,
4316            Crop::no_crop(),
4317        );
4318        result.unwrap();
4319        assert_eq!(dst.width(), Some(640));
4320        assert_eq!(dst.height(), Some(360));
4321
4322        crate::save_jpeg(&dst, "zidane_resized.jpg", 80).unwrap();
4323
4324        let file = std::fs::read("zidane_resized.jpg").unwrap();
4325        // With `format: None` the helper returns the source's native format.
4326        // The codec now decodes colour JPEGs to NV12 (was RGB previously).
4327        let img = crate::load_image_test_helper(&file, None, None).unwrap();
4328        assert_eq!(img.width(), Some(640));
4329        assert_eq!(img.height(), Some(360));
4330        assert_eq!(img.format().unwrap(), PixelFormat::Nv12);
4331    }
4332
4333    #[test]
4334    fn test_from_tensor_planar() -> Result<(), Error> {
4335        let mut tensor = Tensor::new(&[3, 720, 1280], None, None)?;
4336        tensor
4337            .map()?
4338            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.8bps"));
4339        let planar = {
4340            tensor
4341                .set_format(PixelFormat::PlanarRgb)
4342                .map_err(|e| crate::Error::Internal(e.to_string()))?;
4343            TensorDyn::from(tensor)
4344        };
4345
4346        let rbga = load_bytes_to_tensor(
4347            1280,
4348            720,
4349            PixelFormat::Rgba,
4350            None,
4351            &edgefirst_bench::testdata::read("camera720p.rgba"),
4352        )?;
4353        compare_images_convert_to_rgb(&planar, &rbga, 0.98, function!());
4354
4355        Ok(())
4356    }
4357
4358    #[test]
4359    fn test_from_tensor_invalid_format() {
4360        // PixelFormat::from_fourcc_str returns None for unknown FourCC codes.
4361        // Since there's no "TEST" pixel format, this validates graceful handling.
4362        assert!(PixelFormat::from_fourcc(u32::from_le_bytes(*b"TEST")).is_none());
4363    }
4364
4365    #[test]
4366    #[should_panic(expected = "Failed to save planar RGB image")]
4367    fn test_save_planar() {
4368        let planar_img = load_bytes_to_tensor(
4369            1280,
4370            720,
4371            PixelFormat::PlanarRgb,
4372            None,
4373            &edgefirst_bench::testdata::read("camera720p.8bps"),
4374        )
4375        .unwrap();
4376
4377        let save_path = "/tmp/planar_rgb.jpg";
4378        crate::save_jpeg(&planar_img, save_path, 90).expect("Failed to save planar RGB image");
4379    }
4380
4381    #[test]
4382    #[should_panic(expected = "Failed to save YUYV image")]
4383    fn test_save_yuyv() {
4384        let planar_img = load_bytes_to_tensor(
4385            1280,
4386            720,
4387            PixelFormat::Yuyv,
4388            None,
4389            &edgefirst_bench::testdata::read("camera720p.yuyv"),
4390        )
4391        .unwrap();
4392
4393        let save_path = "/tmp/yuyv.jpg";
4394        crate::save_jpeg(&planar_img, save_path, 90).expect("Failed to save YUYV image");
4395    }
4396
4397    #[test]
4398    fn test_rotation_angle() {
4399        assert_eq!(Rotation::from_degrees_clockwise(0), Rotation::None);
4400        assert_eq!(Rotation::from_degrees_clockwise(90), Rotation::Clockwise90);
4401        assert_eq!(Rotation::from_degrees_clockwise(180), Rotation::Rotate180);
4402        assert_eq!(
4403            Rotation::from_degrees_clockwise(270),
4404            Rotation::CounterClockwise90
4405        );
4406        assert_eq!(Rotation::from_degrees_clockwise(360), Rotation::None);
4407        assert_eq!(Rotation::from_degrees_clockwise(450), Rotation::Clockwise90);
4408        assert_eq!(Rotation::from_degrees_clockwise(540), Rotation::Rotate180);
4409        assert_eq!(
4410            Rotation::from_degrees_clockwise(630),
4411            Rotation::CounterClockwise90
4412        );
4413    }
4414
4415    #[test]
4416    #[should_panic(expected = "rotation angle is not a multiple of 90")]
4417    fn test_rotation_angle_panic() {
4418        Rotation::from_degrees_clockwise(361);
4419    }
4420
4421    #[test]
4422    fn test_disable_env_var() -> Result<(), Error> {
4423        // Acquire the env-var mutex for the entire test body so we never race
4424        // with test_force_backend_* or test_draw_proto_masks_no_cpu_returns_error.
4425        let _lock = acquire_env_lock();
4426
4427        // Snapshot ALL env vars we might touch so the RAII guard restores them
4428        // on exit (even on panic), preventing env-var poisoning of other tests.
4429        let _guard = EnvGuard::snapshot(&[
4430            "EDGEFIRST_FORCE_BACKEND",
4431            "EDGEFIRST_DISABLE_GL",
4432            "EDGEFIRST_DISABLE_G2D",
4433            "EDGEFIRST_DISABLE_CPU",
4434        ]);
4435
4436        // EDGEFIRST_FORCE_BACKEND takes precedence over EDGEFIRST_DISABLE_*,
4437        // so clear it for the duration of this test.
4438        unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") };
4439
4440        #[cfg(target_os = "linux")]
4441        {
4442            unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
4443            let converter = ImageProcessor::new()?;
4444            assert!(converter.g2d.is_none());
4445            unsafe { std::env::remove_var("EDGEFIRST_DISABLE_G2D") };
4446        }
4447
4448        #[cfg(any(target_os = "linux", target_os = "windows"))]
4449        #[cfg(feature = "opengl")]
4450        {
4451            unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
4452            let converter = ImageProcessor::new()?;
4453            assert!(converter.opengl.is_none());
4454            unsafe { std::env::remove_var("EDGEFIRST_DISABLE_GL") };
4455        }
4456
4457        unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
4458        let converter = ImageProcessor::new()?;
4459        assert!(converter.cpu.is_none());
4460        unsafe { std::env::remove_var("EDGEFIRST_DISABLE_CPU") };
4461
4462        // Disable everything — convert must return NoConverter.
4463        unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
4464        unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
4465        unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
4466        let mut converter = ImageProcessor::new()?;
4467
4468        let src = TensorDyn::image(
4469            1280,
4470            720,
4471            PixelFormat::Rgba,
4472            DType::U8,
4473            None,
4474            edgefirst_tensor::CpuAccess::ReadWrite,
4475        )?;
4476        let dst = TensorDyn::image(
4477            640,
4478            360,
4479            PixelFormat::Rgba,
4480            DType::U8,
4481            None,
4482            edgefirst_tensor::CpuAccess::ReadWrite,
4483        )?;
4484        let (result, _src, _dst) = convert_img(
4485            &mut converter,
4486            src,
4487            dst,
4488            Rotation::None,
4489            Flip::None,
4490            Crop::no_crop(),
4491        );
4492        assert!(matches!(result, Err(Error::NoConverter)));
4493        // _guard restores all env vars on drop.
4494        Ok(())
4495    }
4496
4497    #[test]
4498    fn test_unsupported_conversion() {
4499        let src = TensorDyn::image(
4500            1280,
4501            720,
4502            PixelFormat::Nv12,
4503            DType::U8,
4504            None,
4505            edgefirst_tensor::CpuAccess::ReadWrite,
4506        )
4507        .unwrap();
4508        let dst = TensorDyn::image(
4509            640,
4510            360,
4511            PixelFormat::Nv12,
4512            DType::U8,
4513            None,
4514            edgefirst_tensor::CpuAccess::ReadWrite,
4515        )
4516        .unwrap();
4517        let mut converter = ImageProcessor::new().unwrap();
4518        let (result, _src, _dst) = convert_img(
4519            &mut converter,
4520            src,
4521            dst,
4522            Rotation::None,
4523            Flip::None,
4524            Crop::no_crop(),
4525        );
4526        log::debug!("result: {:?}", result);
4527        assert!(matches!(
4528            result,
4529            Err(Error::NotSupported(e)) if e.starts_with("Conversion from NV12 to NV12")
4530        ));
4531    }
4532
4533    #[test]
4534    fn test_load_grey() {
4535        // A single-component (greyscale) JPEG decodes to its native GREY
4536        // format, which has no even-dimension constraint, so the 1024×681
4537        // `grey.jpg` loads and converts to RGBA successfully.
4538        let grey_img = crate::load_image_test_helper(
4539            &edgefirst_bench::testdata::read("grey.jpg"),
4540            Some(PixelFormat::Rgba),
4541            None,
4542        )
4543        .unwrap();
4544        assert_eq!(grey_img.width(), Some(1024));
4545        assert_eq!(grey_img.height(), Some(681));
4546
4547        // `grey-rgb.jpg` holds the same grey content but is encoded as a
4548        // 3-component (colour) JPEG, so the codec decodes it to native NV12.
4549        // Its 1024×681 dimensions have an odd height; NV12 now represents odd
4550        // dimensions via the `H + ceil(H/2)` combined-plane height, so the
4551        // decode succeeds and converts to RGBA at the true dimensions.
4552        let grey_but_rgb = crate::load_image_test_helper(
4553            &edgefirst_bench::testdata::read("grey-rgb.jpg"),
4554            Some(PixelFormat::Rgba),
4555            None,
4556        )
4557        .expect("odd-height colour JPEG should decode to NV12 and convert to RGBA");
4558        assert_eq!(grey_but_rgb.width(), Some(1024));
4559        assert_eq!(grey_but_rgb.height(), Some(681));
4560    }
4561
4562    #[test]
4563    fn test_new_nv12() {
4564        let nv12 = TensorDyn::image(
4565            1280,
4566            720,
4567            PixelFormat::Nv12,
4568            DType::U8,
4569            None,
4570            edgefirst_tensor::CpuAccess::ReadWrite,
4571        )
4572        .unwrap();
4573        assert_eq!(nv12.height(), Some(720));
4574        assert_eq!(nv12.width(), Some(1280));
4575        assert_eq!(nv12.format().unwrap(), PixelFormat::Nv12);
4576        // PixelFormat::Nv12.channels() returns 1 (luma plane channel count)
4577        assert_eq!(nv12.format().unwrap().channels(), 1);
4578        assert!(nv12.format().is_some_and(
4579            |f| f.layout() == PixelLayout::Planar || f.layout() == PixelLayout::SemiPlanar
4580        ))
4581    }
4582
4583    #[test]
4584    #[cfg(any(target_os = "linux", target_os = "windows"))]
4585    fn test_new_image_converter() {
4586        let dst_width = 640;
4587        let dst_height = 360;
4588        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4589        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4590
4591        let mut converter = ImageProcessor::new().unwrap();
4592        let converter_dst = converter
4593            .create_image(
4594                dst_width,
4595                dst_height,
4596                PixelFormat::Rgba,
4597                DType::U8,
4598                None,
4599                edgefirst_tensor::CpuAccess::ReadWrite,
4600            )
4601            .unwrap();
4602        let (result, src, converter_dst) = convert_img(
4603            &mut converter,
4604            src,
4605            converter_dst,
4606            Rotation::None,
4607            Flip::None,
4608            Crop::no_crop(),
4609        );
4610        result.unwrap();
4611
4612        let cpu_dst = TensorDyn::image(
4613            dst_width,
4614            dst_height,
4615            PixelFormat::Rgba,
4616            DType::U8,
4617            None,
4618            edgefirst_tensor::CpuAccess::ReadWrite,
4619        )
4620        .unwrap();
4621        let mut cpu_converter = CPUProcessor::new();
4622        let (result, _src, cpu_dst) = convert_img(
4623            &mut cpu_converter,
4624            src,
4625            cpu_dst,
4626            Rotation::None,
4627            Flip::None,
4628            Crop::no_crop(),
4629        );
4630        result.unwrap();
4631
4632        compare_images(&converter_dst, &cpu_dst, 0.98, function!());
4633    }
4634
4635    #[test]
4636    #[cfg(any(target_os = "linux", target_os = "windows"))]
4637    fn test_create_image_dtype_i8() {
4638        let mut converter = ImageProcessor::new().unwrap();
4639
4640        // I8 image should allocate successfully via create_image
4641        let dst = converter
4642            .create_image(
4643                320,
4644                240,
4645                PixelFormat::Rgb,
4646                DType::I8,
4647                None,
4648                edgefirst_tensor::CpuAccess::ReadWrite,
4649            )
4650            .unwrap();
4651        assert_eq!(dst.dtype(), DType::I8);
4652        assert!(dst.width() == Some(320));
4653        assert!(dst.height() == Some(240));
4654        assert_eq!(dst.format(), Some(PixelFormat::Rgb));
4655
4656        // U8 for comparison
4657        let dst_u8 = converter
4658            .create_image(
4659                320,
4660                240,
4661                PixelFormat::Rgb,
4662                DType::U8,
4663                None,
4664                edgefirst_tensor::CpuAccess::ReadWrite,
4665            )
4666            .unwrap();
4667        assert_eq!(dst_u8.dtype(), DType::U8);
4668
4669        // Convert into I8 dst should succeed
4670        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4671        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4672        let mut dst_i8 = converter
4673            .create_image(
4674                320,
4675                240,
4676                PixelFormat::Rgb,
4677                DType::I8,
4678                None,
4679                edgefirst_tensor::CpuAccess::ReadWrite,
4680            )
4681            .unwrap();
4682        converter
4683            .convert(
4684                &src,
4685                &mut dst_i8,
4686                Rotation::None,
4687                Flip::None,
4688                Crop::no_crop(),
4689            )
4690            .unwrap();
4691    }
4692
4693    #[test]
4694    #[cfg(target_os = "linux")]
4695    fn test_create_image_nv12_dma_non_aligned_width() {
4696        // create_image is fully stride-aware: a non-64-aligned NV12 DMA tensor
4697        // may legitimately carry a GPU-pitch-padded row stride — that is the
4698        // intended behaviour, not a bug. Verify the logical geometry is preserved
4699        // for any width and that a reported stride is a valid (>= logical)
4700        // padding, rather than asserting the absence of a stride.
4701        let converter = ImageProcessor::new().unwrap();
4702
4703        // 100 is intentionally not a multiple of 64 (the GPU pitch alignment).
4704        let result = converter.create_image(
4705            100,
4706            64,
4707            PixelFormat::Nv12,
4708            DType::U8,
4709            Some(TensorMemory::DmaBuf),
4710            edgefirst_tensor::CpuAccess::ReadWrite,
4711        );
4712
4713        match result {
4714            Ok(img) => {
4715                assert_eq!(img.width(), Some(100));
4716                assert_eq!(img.height(), Some(64));
4717                assert_eq!(img.format(), Some(PixelFormat::Nv12));
4718                if let Some(stride) = img.row_stride() {
4719                    assert!(
4720                        stride >= 100,
4721                        "NV12 row_stride {stride} must be >= the logical width (100)",
4722                    );
4723                }
4724            }
4725            Err(e) => {
4726                // Skip cleanly on hosts without a dma-heap.
4727                crate::test_support::report_skip(&format!(
4728                    "create_image NV12 DMA non-aligned width: {e}"
4729                ));
4730            }
4731        }
4732    }
4733
4734    #[test]
4735    #[ignore] // Hangs on desktop platforms where DMA-buf is unavailable and PBO
4736              // fallback triggers a GPU driver hang during SHM→texture upload (e.g.,
4737              // NVIDIA without /dev/dma_heap permissions). Works on embedded targets.
4738    fn test_crop_skip() {
4739        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4740        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4741
4742        let mut converter = ImageProcessor::new().unwrap();
4743        let converter_dst = converter
4744            .create_image(
4745                1280,
4746                720,
4747                PixelFormat::Rgba,
4748                DType::U8,
4749                None,
4750                edgefirst_tensor::CpuAccess::ReadWrite,
4751            )
4752            .unwrap();
4753        let crop = Crop::new().with_source(Some(Region::new(0, 0, 640, 640)));
4754        let (result, src, converter_dst) = convert_img(
4755            &mut converter,
4756            src,
4757            converter_dst,
4758            Rotation::None,
4759            Flip::None,
4760            crop,
4761        );
4762        result.unwrap();
4763
4764        let cpu_dst = TensorDyn::image(
4765            1280,
4766            720,
4767            PixelFormat::Rgba,
4768            DType::U8,
4769            None,
4770            edgefirst_tensor::CpuAccess::ReadWrite,
4771        )
4772        .unwrap();
4773        let mut cpu_converter = CPUProcessor::new();
4774        let (result, _src, cpu_dst) = convert_img(
4775            &mut cpu_converter,
4776            src,
4777            cpu_dst,
4778            Rotation::None,
4779            Flip::None,
4780            crop,
4781        );
4782        result.unwrap();
4783
4784        compare_images(&converter_dst, &cpu_dst, 0.99999, function!());
4785    }
4786
4787    #[test]
4788    fn test_invalid_pixel_format() {
4789        // PixelFormat::from_fourcc returns None for unknown formats,
4790        // so TensorDyn::image cannot be called with an invalid format.
4791        assert!(PixelFormat::from_fourcc(u32::from_le_bytes(*b"TEST")).is_none());
4792    }
4793
4794    // Helper function to check if G2D library is available (Linux/i.MX8 only)
4795    #[cfg(target_os = "linux")]
4796    static G2D_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
4797
4798    #[cfg(target_os = "linux")]
4799    fn is_g2d_available() -> bool {
4800        *G2D_AVAILABLE.get_or_init(|| G2DProcessor::new().is_ok())
4801    }
4802
4803    // Every GL platform: Linux EGL, ANGLE/Metal (macOS, iOS), Android EGL,
4804    // ANGLE/D3D11 (Windows). `GLProcessorThreaded::new` fails cleanly where
4805    // the driver or ANGLE is missing, so the `test_opengl_*` callers below
4806    // self-skip there (and `HAL_TEST_REQUIRE_GL=1` lanes catch that via
4807    // `gl_backend_available_canary`).
4808    #[cfg(any(
4809        target_os = "linux",
4810        target_os = "macos",
4811        target_os = "ios",
4812        target_os = "android",
4813        target_os = "windows"
4814    ))]
4815    #[cfg(feature = "opengl")]
4816    static GL_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
4817
4818    #[cfg(any(
4819        target_os = "linux",
4820        target_os = "macos",
4821        target_os = "ios",
4822        target_os = "android",
4823        target_os = "windows"
4824    ))]
4825    #[cfg(feature = "opengl")]
4826    // Helper function to check if OpenGL is available
4827    fn is_opengl_available() -> bool {
4828        *GL_AVAILABLE.get_or_init(|| GLProcessorThreaded::new(None).is_ok())
4829    }
4830
4831    /// CI canary: fails the lane when the GL backend cannot initialize.
4832    ///
4833    /// Every GL test in this suite self-skips when the backend is
4834    /// unavailable — correct for developer machines, but it means a broken
4835    /// CI GL stack (e.g. the macOS ANGLE re-sign step regressing, the exact
4836    /// failure mode documented in the workflow) ships an untested GL
4837    /// backend behind a green lane. Gated on `HAL_TEST_REQUIRE_GL=1`, set
4838    /// only by CI jobs that install a working GL stack; local runs without
4839    /// one pass trivially. On macOS it additionally requires
4840    /// `HAL_TEST_ALLOW_DLOPEN_ANGLE`, so coverage pass 1 (unsigned
4841    /// binaries, dlopen gate closed) skips it and pass 2 (signed) enforces.
4842    #[test]
4843    #[cfg(feature = "opengl")]
4844    fn gl_backend_available_canary() {
4845        let require_gl = std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1");
4846        if !require_gl {
4847            crate::test_support::report_skip(&format!(
4848                "{} — HAL_TEST_REQUIRE_GL is not set to 1",
4849                function!()
4850            ));
4851            return;
4852        }
4853        #[cfg(target_os = "macos")]
4854        if std::env::var_os("HAL_TEST_ALLOW_DLOPEN_ANGLE").is_none() {
4855            crate::test_support::report_skip(&format!(
4856                "{} — ANGLE dlopen gate closed (coverage pass 1)",
4857                function!()
4858            ));
4859            return;
4860        }
4861        GLProcessorThreaded::new(None).expect(
4862            "HAL_TEST_REQUIRE_GL=1 but the GL backend failed to initialize — \
4863             check the ANGLE install/re-sign step and binary entitlements \
4864             (macOS) or the EGL stack (Linux)",
4865        );
4866    }
4867
4868    #[test]
4869    fn test_load_jpeg_with_exif() {
4870        use edgefirst_codec::peek_info;
4871
4872        // The migrated codec NEVER applies EXIF orientation: it decodes to the
4873        // source's native (un-rotated) dimensions and reports the rotation via
4874        // ImageInfo. `zidane_rotated_exif.jpg` carries EXIF orientation 6
4875        // (90° clockwise) over a 1280×720 frame.
4876        let file = edgefirst_bench::testdata::read("zidane_rotated_exif.jpg").to_vec();
4877        let info = peek_info(&file).unwrap();
4878        assert_eq!(info.rotation_degrees, 90);
4879        assert!(!info.flip_horizontal);
4880
4881        let loaded = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4882        // Native (un-rotated) dimensions — the decode does not rotate.
4883        assert_eq!(loaded.width(), Some(1280));
4884        assert_eq!(loaded.height(), Some(720));
4885
4886        // Applying the reported rotation downstream reproduces the upright
4887        // image: it matches `zidane.jpg` rotated by the same 90° clockwise.
4888        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4889        let cpu_src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4890
4891        let rotation = Rotation::from_degrees_clockwise(info.rotation_degrees as usize);
4892        let (dst_width, dst_height) = (cpu_src.height().unwrap(), cpu_src.width().unwrap());
4893
4894        let cpu_dst = TensorDyn::image(
4895            dst_width,
4896            dst_height,
4897            PixelFormat::Rgba,
4898            DType::U8,
4899            None,
4900            edgefirst_tensor::CpuAccess::ReadWrite,
4901        )
4902        .unwrap();
4903        let mut cpu_converter = CPUProcessor::new();
4904
4905        // Rotate the native-orientation `loaded` frame and the native `zidane`
4906        // frame by the same reported rotation; the results must agree.
4907        let loaded_rotated = TensorDyn::image(
4908            dst_width,
4909            dst_height,
4910            PixelFormat::Rgba,
4911            DType::U8,
4912            None,
4913            edgefirst_tensor::CpuAccess::ReadWrite,
4914        )
4915        .unwrap();
4916        let (r0, _loaded, loaded_rotated) = convert_img(
4917            &mut cpu_converter,
4918            loaded,
4919            loaded_rotated,
4920            rotation,
4921            Flip::None,
4922            Crop::no_crop(),
4923        );
4924        r0.unwrap();
4925
4926        let (result, _cpu_src, cpu_dst) = convert_img(
4927            &mut cpu_converter,
4928            cpu_src,
4929            cpu_dst,
4930            rotation,
4931            Flip::None,
4932            Crop::no_crop(),
4933        );
4934        result.unwrap();
4935
4936        compare_images(&loaded_rotated, &cpu_dst, 0.98, function!());
4937    }
4938
4939    #[test]
4940    fn test_load_png_with_exif() {
4941        use edgefirst_codec::peek_info;
4942
4943        // PNGs also report EXIF orientation without applying it.
4944        // `zidane_rotated_exif_180.png` carries EXIF orientation 3 (180°).
4945        let file = edgefirst_bench::testdata::read("zidane_rotated_exif_180.png").to_vec();
4946        let info = peek_info(&file).unwrap();
4947        assert_eq!(info.rotation_degrees, 180);
4948        assert!(!info.flip_horizontal);
4949
4950        let loaded = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4951        // Native (un-rotated) dimensions — PNG decodes upright as authored.
4952        assert_eq!(loaded.height(), Some(720));
4953        assert_eq!(loaded.width(), Some(1280));
4954
4955        // The PNG fixture stores upright `zidane` pixels tagged with a 180°
4956        // EXIF orientation. Because the codec no longer applies the rotation,
4957        // the decoded pixels match `zidane.jpg` directly (no convert needed).
4958        // Re-applying the reported rotation to both must still agree.
4959        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4960        let cpu_src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4961
4962        let rotation = Rotation::from_degrees_clockwise(info.rotation_degrees as usize);
4963        let cpu_dst = TensorDyn::image(
4964            1280,
4965            720,
4966            PixelFormat::Rgba,
4967            DType::U8,
4968            None,
4969            edgefirst_tensor::CpuAccess::ReadWrite,
4970        )
4971        .unwrap();
4972        let mut cpu_converter = CPUProcessor::new();
4973
4974        let (result, _cpu_src, cpu_dst) = convert_img(
4975            &mut cpu_converter,
4976            cpu_src,
4977            cpu_dst,
4978            rotation,
4979            Flip::None,
4980            Crop::no_crop(),
4981        );
4982        result.unwrap();
4983
4984        // Rotate the decoded PNG by the same reported angle so both frames are
4985        // in the same (180°-rotated) orientation before comparing.
4986        let loaded_rotated = TensorDyn::image(
4987            1280,
4988            720,
4989            PixelFormat::Rgba,
4990            DType::U8,
4991            None,
4992            edgefirst_tensor::CpuAccess::ReadWrite,
4993        )
4994        .unwrap();
4995        let (r0, _loaded, loaded_rotated) = convert_img(
4996            &mut cpu_converter,
4997            loaded,
4998            loaded_rotated,
4999            rotation,
5000            Flip::None,
5001            Crop::no_crop(),
5002        );
5003        r0.unwrap();
5004
5005        // Threshold 0.95 (was 0.98): `loaded` comes from a lossless PNG decode
5006        // while `cpu_src` (zidane.jpg) now decodes through native NV12 (chroma
5007        // subsampling) before the RGBA conversion, so the two paths differ by a
5008        // couple of percent versus the old direct-RGB JPEG decode.
5009        compare_images(&loaded_rotated, &cpu_dst, 0.95, function!());
5010    }
5011
5012    /// Synthesise an RGB JPEG with a deterministic pattern at `(width, height)`
5013    /// using the workspace's `jpeg-encoder` crate (the `image` crate is
5014    /// compiled without its JPEG feature). Used to exercise the decoder /
5015    /// pitch-padding paths for arbitrary dimensions without having to bundle
5016    /// a fixture file per test size.
5017    #[cfg(target_os = "linux")]
5018    fn make_rgb_jpeg(width: u32, height: u32) -> Vec<u8> {
5019        let mut bytes = Vec::with_capacity((width * height * 3) as usize);
5020        for y in 0..height {
5021            for x in 0..width {
5022                bytes.push(((x + y) & 0xFF) as u8);
5023                bytes.push(((x.wrapping_mul(3)) & 0xFF) as u8);
5024                bytes.push(((y.wrapping_mul(5)) & 0xFF) as u8);
5025            }
5026        }
5027        let mut out = Vec::new();
5028        let encoder = jpeg_encoder::Encoder::new(&mut out, 85);
5029        encoder
5030            .encode(
5031                &bytes,
5032                width as u16,
5033                height as u16,
5034                jpeg_encoder::ColorType::Rgb,
5035            )
5036            .expect("jpeg-encoder must succeed on trivial input");
5037        out
5038    }
5039
5040    /// End-to-end: a 375×333 RGBA JPEG (width NOT divisible by 4) loaded
5041    /// via the pitch-padded DMA path and letterboxed through the GL
5042    /// backend must produce correct output. Before the Rgba/Bgra
5043    /// width%4 relaxation in `DmaImportAttrs::from_tensor`, this case
5044    /// failed the pre-check and forced a CPU texture upload fallback;
5045    /// with the relaxation, EGL import succeeds at the driver level and
5046    /// the GL fast path runs. Output correctness is checked against a
5047    /// CPU reference (convert ran with `EDGEFIRST_FORCE_BACKEND=cpu`).
5048    #[test]
5049    #[cfg(target_os = "linux")]
5050    #[cfg(feature = "opengl")]
5051    fn test_convert_rgba_non_4_aligned_width_end_to_end() {
5052        use edgefirst_tensor::is_dma_available;
5053        if !is_dma_available() {
5054            crate::test_support::report_skip(
5055                "test_convert_rgba_non_4_aligned_width_end_to_end — DMA not available",
5056            );
5057            return;
5058        }
5059        // 375 is the canonical failure width from dataset loaders —
5060        // 375 * 4 = 1500 bytes/row, pitch-padded to 1536. Width%4 = 3,
5061        // so the old pre-check rejected it; new code accepts it.
5062        let jpeg = make_rgb_jpeg(375, 333);
5063        let src_gl = crate::load_image_test_helper(&jpeg, Some(PixelFormat::Rgba), None).unwrap();
5064        assert_eq!(src_gl.width(), Some(375));
5065        // Row stride must still be pitch-padded (separate concern from width).
5066        let stride = src_gl.row_stride().unwrap();
5067        assert_eq!(stride, 1536, "expected padded pitch 1536, got {stride}");
5068
5069        // GL-backed convert into a pitch-aligned 640×640 Rgba dest.
5070        let mut gl_proc = ImageProcessor::new().unwrap();
5071        let gl_dst = gl_proc
5072            .create_image(
5073                640,
5074                640,
5075                PixelFormat::Rgba,
5076                DType::U8,
5077                None,
5078                edgefirst_tensor::CpuAccess::ReadWrite,
5079            )
5080            .unwrap();
5081        let (r_gl, _src_gl, gl_dst) = convert_img(
5082            &mut gl_proc,
5083            src_gl,
5084            gl_dst,
5085            Rotation::None,
5086            Flip::None,
5087            Crop::no_crop(),
5088        );
5089        r_gl.expect("GL-backed convert must succeed for 375x333 Rgba src");
5090
5091        // CPU reference via a fresh load so the two paths start from
5092        // byte-identical inputs. `with_config(backend=Cpu)` forces the
5093        // CPU-only processor regardless of which backends the host has
5094        // available.
5095        let src_cpu =
5096            crate::load_image_test_helper(&jpeg, Some(PixelFormat::Rgba), Some(TensorMemory::Mem))
5097                .unwrap();
5098        let mut cpu_proc = ImageProcessor::with_config(ImageProcessorConfig {
5099            backend: ComputeBackend::Cpu,
5100            ..Default::default()
5101        })
5102        .unwrap();
5103        let cpu_dst = TensorDyn::image(
5104            640,
5105            640,
5106            PixelFormat::Rgba,
5107            DType::U8,
5108            Some(TensorMemory::Mem),
5109            edgefirst_tensor::CpuAccess::ReadWrite,
5110        )
5111        .unwrap();
5112        let (r_cpu, _src_cpu, cpu_dst) = convert_img(
5113            &mut cpu_proc,
5114            src_cpu,
5115            cpu_dst,
5116            Rotation::None,
5117            Flip::None,
5118            Crop::no_crop(),
5119        );
5120        r_cpu.unwrap();
5121
5122        // Structural similarity: the GL path may have gone through EGL
5123        // import OR fallen back to CPU texture upload — either way, the
5124        // output must match the CPU reference closely.
5125        compare_images(&gl_dst, &cpu_dst, 0.95, function!());
5126    }
5127
5128    /// Regression lock: loading a JPEG at a non-64-aligned RGBA pitch (e.g.
5129    /// 500×333 → natural pitch 2000, needs to be padded to 2048) must go
5130    /// through `image_with_stride` and set `row_stride()` / `effective_row_stride()`
5131    /// to the padded value. The earlier pitch-padding commit fixed this in
5132    /// `load_jpeg`; a regression would surface as `row_stride == None` or
5133    /// `effective_row_stride == 2000`.
5134    #[test]
5135    #[cfg(target_os = "linux")]
5136    fn test_load_jpeg_rgba_non_aligned_pitch_padded_dma() {
5137        use edgefirst_tensor::is_dma_available;
5138        if !is_dma_available() {
5139            crate::test_support::report_skip(
5140                "test_load_jpeg_rgba_non_aligned_pitch_padded_dma — DMA not available",
5141            );
5142            return;
5143        }
5144        // Widths that force a non-64-aligned natural RGBA pitch. All three
5145        // are divisible by 4 so the EGL width-alignment pre-check passes.
5146        // The pitch-padding fix is what makes these importable at all.
5147        for &w in &[500u32, 612, 428] {
5148            let jpeg = make_rgb_jpeg(w, 333);
5149            let loaded =
5150                crate::load_image_test_helper(&jpeg, Some(PixelFormat::Rgba), None).unwrap();
5151            let natural = (w as usize) * 4;
5152            let aligned = crate::align_pitch_bytes_to_gpu_alignment(natural).unwrap();
5153            assert!(
5154                aligned > natural,
5155                "test sanity: width {w} should be unaligned"
5156            );
5157            let stride = loaded
5158                .row_stride()
5159                .expect("padded DMA path must set an explicit row_stride — regression if None");
5160            assert_eq!(
5161                stride, aligned,
5162                "width {w}: expected padded stride {aligned}, got {stride} \
5163                 (regression: pitch-padding branch skipped?)"
5164            );
5165            let eff = loaded.effective_row_stride().unwrap();
5166            assert_eq!(
5167                eff, aligned,
5168                "effective_row_stride must match stored stride"
5169            );
5170            assert_eq!(loaded.width(), Some(w as usize));
5171            assert_eq!(loaded.height(), Some(333));
5172        }
5173    }
5174
5175    /// `padded_dma_pitch_for` must respect the caller's memory choice and
5176    /// must NOT route into the pitch-padded DMA path when the caller left
5177    /// the choice to the allocator (`None`) but DMA is unavailable on the
5178    /// host. The padded path requires `image_with_stride`, which always
5179    /// allocates DMA — taking it on a system without `/dev/dma_heap`
5180    /// would convert a normally-working image load into a hard failure
5181    /// (since `Tensor::image(..., None)` would have fallen back to
5182    /// SHM/Mem).
5183    #[test]
5184    #[cfg(target_os = "linux")]
5185    fn test_padded_dma_pitch_for_respects_memory_choice() {
5186        use edgefirst_tensor::{is_dma_available, TensorMemory};
5187
5188        // 500×4 = 2000 → padded to 2048 by GPU alignment. Use it for
5189        // every case so any "no padding" answer is unambiguous.
5190        let unaligned_w = 500;
5191
5192        // Caller asks for Mem / Shm: never pad, regardless of DMA.
5193        assert_eq!(
5194            crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &Some(TensorMemory::Mem),),
5195            None,
5196            "Mem must never trigger DMA padding"
5197        );
5198        assert_eq!(
5199            crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &Some(TensorMemory::Shm),),
5200            None,
5201            "Shm must never trigger DMA padding"
5202        );
5203
5204        // Caller explicitly asks for DMA: always pad if width needs it.
5205        // Even if the runtime can't actually allocate DMA, the caller
5206        // owns that decision and the resulting allocation error is
5207        // their problem, not ours.
5208        assert_eq!(
5209            crate::padded_dma_pitch_for(
5210                PixelFormat::Rgba,
5211                unaligned_w,
5212                &Some(TensorMemory::DmaBuf),
5213            ),
5214            Some(2048),
5215            "explicit Dma must pad regardless of runtime DMA availability"
5216        );
5217
5218        // Caller leaves it to the allocator: behaviour depends on
5219        // host-runtime DMA availability. This is the case the fix
5220        // guards against.
5221        let none_result = crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &None);
5222        if is_dma_available() {
5223            assert_eq!(
5224                none_result,
5225                Some(2048),
5226                "memory=None + DMA available → pad (will route through DMA)"
5227            );
5228        } else {
5229            assert_eq!(
5230                none_result, None,
5231                "memory=None + DMA unavailable → must NOT pad (would force \
5232                 image_with_stride into a DMA-only allocation that fails). \
5233                 Regression: padded_dma_pitch_for ignored is_dma_available()."
5234            );
5235        }
5236    }
5237
5238    // Synthesise a small greyscale PNG in memory at `(width, height)` with a
5239    // deterministic ramp pattern so multiple tests can cross-check output
5240    // without bundling an extra fixture file.
5241    fn make_grey_png(width: u32, height: u32) -> Vec<u8> {
5242        let mut bytes = Vec::with_capacity((width * height) as usize);
5243        for y in 0..height {
5244            for x in 0..width {
5245                bytes.push(((x + y) & 0xFF) as u8);
5246            }
5247        }
5248        let img = image::GrayImage::from_vec(width, height, bytes).unwrap();
5249        let mut buf = Vec::new();
5250        img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
5251            .unwrap();
5252        buf
5253    }
5254
5255    /// Greyscale PNG with a width that forces a pitch-misaligned natural
5256    /// row stride (612 bytes is not a multiple of the 64-byte GPU pitch
5257    /// alignment) must still load via the pitch-padded DMA path. Gated on
5258    /// DMA availability because `image_with_stride` is DMA-only.
5259    #[test]
5260    #[cfg(target_os = "linux")]
5261    fn test_load_png_grey_misaligned_width_dma() {
5262        use edgefirst_tensor::is_dma_available;
5263        if !is_dma_available() {
5264            crate::test_support::report_skip(
5265                "test_load_png_grey_misaligned_width_dma — DMA not available",
5266            );
5267            return;
5268        }
5269        let png = make_grey_png(612, 388);
5270        let loaded = crate::load_image_test_helper(&png, Some(PixelFormat::Grey), None).unwrap();
5271        assert_eq!(loaded.width(), Some(612));
5272        assert_eq!(loaded.height(), Some(388));
5273        assert_eq!(loaded.format(), Some(PixelFormat::Grey));
5274
5275        // Round-trip pixels — natural-pitch DMA-BUFs pad the stride so we
5276        // must indirect through row_stride() rather than assume width.
5277        let map = loaded.as_typed::<u8>().unwrap().map().unwrap();
5278        let stride = loaded.row_stride().unwrap_or(612);
5279        assert!(stride >= 612);
5280        let bytes: &[u8] = &map;
5281        for y in 0..388usize {
5282            for x in 0..612usize {
5283                let expected = ((x + y) & 0xFF) as u8;
5284                let got = bytes[y * stride + x];
5285                assert_eq!(
5286                    got, expected,
5287                    "grey png mismatch at ({x},{y}): got {got} expected {expected}"
5288                );
5289            }
5290        }
5291    }
5292
5293    /// Greyscale PNG loaded with explicit Mem backing — runs on any
5294    /// platform (no DMA permission requirement) and covers the
5295    /// decoder-native Luma → Grey no-conversion path.
5296    #[test]
5297    fn test_load_png_grey_mem() {
5298        use edgefirst_tensor::TensorMemory;
5299        let png = make_grey_png(612, 100);
5300        let loaded =
5301            crate::load_image_test_helper(&png, Some(PixelFormat::Grey), Some(TensorMemory::Mem))
5302                .unwrap();
5303        assert_eq!(loaded.width(), Some(612));
5304        assert_eq!(loaded.height(), Some(100));
5305        assert_eq!(loaded.format(), Some(PixelFormat::Grey));
5306        let map = loaded.as_typed::<u8>().unwrap().map().unwrap();
5307        let bytes: &[u8] = &map;
5308        // Mem allocation uses the natural pitch — 612 bytes per row, exact.
5309        assert_eq!(bytes.len(), 612 * 100);
5310        for y in 0..100 {
5311            for x in 0..612 {
5312                assert_eq!(bytes[y * 612 + x], ((x + y) & 0xFF) as u8);
5313            }
5314        }
5315    }
5316
5317    /// Greyscale PNG decoded into RGB — exercises the decoder-colorspace
5318    /// mismatch path (Luma → Rgb via CPU converter). Uses Mem memory to
5319    /// stay portable to host-side test environments.
5320    #[test]
5321    fn test_load_png_grey_to_rgb_mem() {
5322        use edgefirst_tensor::TensorMemory;
5323        let png = make_grey_png(620, 240);
5324        let loaded =
5325            crate::load_image_test_helper(&png, Some(PixelFormat::Rgb), Some(TensorMemory::Mem))
5326                .unwrap();
5327        assert_eq!(loaded.width(), Some(620));
5328        assert_eq!(loaded.height(), Some(240));
5329        assert_eq!(loaded.format(), Some(PixelFormat::Rgb));
5330
5331        // Greyscale promoted to RGB replicates luma into each channel.
5332        let map = loaded.as_typed::<u8>().unwrap().map().unwrap();
5333        let bytes: &[u8] = &map;
5334        for (x, y) in [(0usize, 0usize), (100, 50), (619, 239)] {
5335            let expected = ((x + y) & 0xFF) as u8;
5336            let off = (y * 620 + x) * 3;
5337            assert_eq!(bytes[off], expected, "R@{x},{y}");
5338            assert_eq!(bytes[off + 1], expected, "G@{x},{y}");
5339            assert_eq!(bytes[off + 2], expected, "B@{x},{y}");
5340        }
5341    }
5342
5343    #[test]
5344    #[cfg(target_os = "linux")]
5345    fn test_g2d_resize() {
5346        if !is_g2d_available() {
5347            crate::test_support::report_skip(
5348                "test_g2d_resize - G2D library (libg2d.so.2) not available",
5349            );
5350            return;
5351        }
5352        if !is_dma_available() {
5353            crate::test_support::report_skip("test_g2d_resize - DMA memory allocation not available (permission denied or no DMA-BUF support)");
5354            return;
5355        }
5356
5357        let dst_width = 640;
5358        let dst_height = 360;
5359        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5360        let src = crate::load_image_test_helper(
5361            &file,
5362            Some(PixelFormat::Rgba),
5363            Some(TensorMemory::DmaBuf),
5364        )
5365        .unwrap();
5366
5367        let g2d_dst = TensorDyn::image(
5368            dst_width,
5369            dst_height,
5370            PixelFormat::Rgba,
5371            DType::U8,
5372            Some(TensorMemory::DmaBuf),
5373            edgefirst_tensor::CpuAccess::ReadWrite,
5374        )
5375        .unwrap();
5376        let mut g2d_converter = G2DProcessor::new().unwrap();
5377        let (result, src, g2d_dst) = convert_img(
5378            &mut g2d_converter,
5379            src,
5380            g2d_dst,
5381            Rotation::None,
5382            Flip::None,
5383            Crop::no_crop(),
5384        );
5385        result.unwrap();
5386
5387        let cpu_dst = TensorDyn::image(
5388            dst_width,
5389            dst_height,
5390            PixelFormat::Rgba,
5391            DType::U8,
5392            None,
5393            edgefirst_tensor::CpuAccess::ReadWrite,
5394        )
5395        .unwrap();
5396        let mut cpu_converter = CPUProcessor::new();
5397        let (result, _src, cpu_dst) = convert_img(
5398            &mut cpu_converter,
5399            src,
5400            cpu_dst,
5401            Rotation::None,
5402            Flip::None,
5403            Crop::no_crop(),
5404        );
5405        result.unwrap();
5406
5407        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
5408        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
5409        // the YUV-matrix delta that forced 0.95 has closed; tightened to
5410        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
5411        // structural gap not exercised by these limited-range fixtures.
5412        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
5413    }
5414
5415    #[test]
5416    #[cfg(any(
5417        target_os = "linux",
5418        target_os = "macos",
5419        target_os = "ios",
5420        target_os = "android",
5421        target_os = "windows"
5422    ))]
5423    #[cfg(feature = "opengl")]
5424    fn test_opengl_resize() {
5425        if !is_opengl_available() {
5426            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
5427            return;
5428        }
5429
5430        let dst_width = 640;
5431        let dst_height = 360;
5432        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5433        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5434
5435        let cpu_dst = TensorDyn::image(
5436            dst_width,
5437            dst_height,
5438            PixelFormat::Rgba,
5439            DType::U8,
5440            None,
5441            edgefirst_tensor::CpuAccess::ReadWrite,
5442        )
5443        .unwrap();
5444        let mut cpu_converter = CPUProcessor::new();
5445        let (result, src, cpu_dst) = convert_img(
5446            &mut cpu_converter,
5447            src,
5448            cpu_dst,
5449            Rotation::None,
5450            Flip::None,
5451            Crop::no_crop(),
5452        );
5453        result.unwrap();
5454
5455        let mut src = src;
5456        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
5457
5458        for _ in 0..5 {
5459            let gl_dst = TensorDyn::image(
5460                dst_width,
5461                dst_height,
5462                PixelFormat::Rgba,
5463                DType::U8,
5464                None,
5465                edgefirst_tensor::CpuAccess::ReadWrite,
5466            )
5467            .unwrap();
5468            let (result, src_back, gl_dst) = convert_img(
5469                &mut gl_converter,
5470                src,
5471                gl_dst,
5472                Rotation::None,
5473                Flip::None,
5474                Crop::no_crop(),
5475            );
5476            result.unwrap();
5477            src = src_back;
5478
5479            compare_images(&gl_dst, &cpu_dst, 0.98, function!());
5480        }
5481    }
5482
5483    #[test]
5484    #[cfg(any(
5485        target_os = "linux",
5486        target_os = "macos",
5487        target_os = "ios",
5488        target_os = "android",
5489        target_os = "windows"
5490    ))]
5491    #[cfg(feature = "opengl")]
5492    fn test_opengl_10_threads() {
5493        if !is_opengl_available() {
5494            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
5495            return;
5496        }
5497
5498        let handles: Vec<_> = (0..10)
5499            .map(|i| {
5500                std::thread::Builder::new()
5501                    .name(format!("Thread {i}"))
5502                    .spawn(test_opengl_resize)
5503                    .unwrap()
5504            })
5505            .collect();
5506        handles.into_iter().for_each(|h| {
5507            if let Err(e) = h.join() {
5508                std::panic::resume_unwind(e)
5509            }
5510        });
5511    }
5512
5513    #[test]
5514    #[cfg(any(
5515        target_os = "linux",
5516        target_os = "macos",
5517        target_os = "ios",
5518        target_os = "android",
5519        target_os = "windows"
5520    ))]
5521    #[cfg(feature = "opengl")]
5522    fn test_opengl_grey() {
5523        if !is_opengl_available() {
5524            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
5525            return;
5526        }
5527
5528        let img = crate::load_image_test_helper(
5529            &edgefirst_bench::testdata::read("grey.jpg"),
5530            Some(PixelFormat::Grey),
5531            None,
5532        )
5533        .unwrap();
5534
5535        let gl_dst = TensorDyn::image(
5536            640,
5537            640,
5538            PixelFormat::Grey,
5539            DType::U8,
5540            None,
5541            edgefirst_tensor::CpuAccess::ReadWrite,
5542        )
5543        .unwrap();
5544        let cpu_dst = TensorDyn::image(
5545            640,
5546            640,
5547            PixelFormat::Grey,
5548            DType::U8,
5549            None,
5550            edgefirst_tensor::CpuAccess::ReadWrite,
5551        )
5552        .unwrap();
5553
5554        let mut converter = CPUProcessor::new();
5555
5556        let (result, img, cpu_dst) = convert_img(
5557            &mut converter,
5558            img,
5559            cpu_dst,
5560            Rotation::None,
5561            Flip::None,
5562            Crop::no_crop(),
5563        );
5564        result.unwrap();
5565
5566        let mut gl = GLProcessorThreaded::new(None).unwrap();
5567        let (result, _img, gl_dst) = convert_img(
5568            &mut gl,
5569            img,
5570            gl_dst,
5571            Rotation::None,
5572            Flip::None,
5573            Crop::no_crop(),
5574        );
5575        result.unwrap();
5576
5577        compare_images(&gl_dst, &cpu_dst, 0.98, function!());
5578    }
5579
5580    #[test]
5581    #[cfg(target_os = "linux")]
5582    fn test_g2d_src_crop() {
5583        if !is_g2d_available() {
5584            crate::test_support::report_skip(
5585                "test_g2d_src_crop - G2D library (libg2d.so.2) not available",
5586            );
5587            return;
5588        }
5589        if !is_dma_available() {
5590            crate::test_support::report_skip("test_g2d_src_crop - DMA memory allocation not available (permission denied or no DMA-BUF support)");
5591            return;
5592        }
5593
5594        let dst_width = 640;
5595        let dst_height = 640;
5596        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5597        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5598
5599        let cpu_dst = TensorDyn::image(
5600            dst_width,
5601            dst_height,
5602            PixelFormat::Rgba,
5603            DType::U8,
5604            None,
5605            edgefirst_tensor::CpuAccess::ReadWrite,
5606        )
5607        .unwrap();
5608        let mut cpu_converter = CPUProcessor::new();
5609        let crop = Crop::new().with_source(Some(Region::new(0, 0, 640, 360)));
5610        let (result, src, cpu_dst) = convert_img(
5611            &mut cpu_converter,
5612            src,
5613            cpu_dst,
5614            Rotation::None,
5615            Flip::None,
5616            crop,
5617        );
5618        result.unwrap();
5619
5620        let g2d_dst = TensorDyn::image(
5621            dst_width,
5622            dst_height,
5623            PixelFormat::Rgba,
5624            DType::U8,
5625            None,
5626            edgefirst_tensor::CpuAccess::ReadWrite,
5627        )
5628        .unwrap();
5629        let mut g2d_converter = G2DProcessor::new().unwrap();
5630        let (result, _src, g2d_dst) = convert_img(
5631            &mut g2d_converter,
5632            src,
5633            g2d_dst,
5634            Rotation::None,
5635            Flip::None,
5636            crop,
5637        );
5638        result.unwrap();
5639
5640        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
5641        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
5642        // the YUV-matrix delta that forced 0.95 has closed; tightened to
5643        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
5644        // structural gap not exercised by these limited-range fixtures.
5645        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
5646    }
5647
5648    #[test]
5649    #[cfg(target_os = "linux")]
5650    fn test_g2d_dst_crop() {
5651        if !is_g2d_available() {
5652            crate::test_support::report_skip(
5653                "test_g2d_dst_crop - G2D library (libg2d.so.2) not available",
5654            );
5655            return;
5656        }
5657        if !is_dma_available() {
5658            crate::test_support::report_skip("test_g2d_dst_crop - DMA memory allocation not available (permission denied or no DMA-BUF support)");
5659            return;
5660        }
5661
5662        let dst_width = 640;
5663        let dst_height = 640;
5664        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5665        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5666
5667        let cpu_dst = TensorDyn::image(
5668            dst_width,
5669            dst_height,
5670            PixelFormat::Rgba,
5671            DType::U8,
5672            None,
5673            edgefirst_tensor::CpuAccess::ReadWrite,
5674        )
5675        .unwrap();
5676        let mut cpu_converter = CPUProcessor::new();
5677        let crop = Crop::new();
5678        let (result, src, cpu_dst) = convert_img(
5679            &mut cpu_converter,
5680            src,
5681            cpu_dst,
5682            Rotation::None,
5683            Flip::None,
5684            crop,
5685        );
5686        result.unwrap();
5687
5688        let g2d_dst = TensorDyn::image(
5689            dst_width,
5690            dst_height,
5691            PixelFormat::Rgba,
5692            DType::U8,
5693            None,
5694            edgefirst_tensor::CpuAccess::ReadWrite,
5695        )
5696        .unwrap();
5697        let mut g2d_converter = G2DProcessor::new().unwrap();
5698        let (result, _src, g2d_dst) = convert_img(
5699            &mut g2d_converter,
5700            src,
5701            g2d_dst,
5702            Rotation::None,
5703            Flip::None,
5704            crop,
5705        );
5706        result.unwrap();
5707
5708        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
5709        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
5710        // the YUV-matrix delta that forced 0.95 has closed; tightened to
5711        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
5712        // structural gap not exercised by these limited-range fixtures.
5713        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
5714    }
5715
5716    #[test]
5717    #[cfg(target_os = "linux")]
5718    fn test_g2d_all_rgba() {
5719        if !is_g2d_available() {
5720            crate::test_support::report_skip(
5721                "test_g2d_all_rgba - G2D library (libg2d.so.2) not available",
5722            );
5723            return;
5724        }
5725        if !is_dma_available() {
5726            crate::test_support::report_skip("test_g2d_all_rgba - DMA memory allocation not available (permission denied or no DMA-BUF support)");
5727            return;
5728        }
5729
5730        let dst_width = 640;
5731        let dst_height = 640;
5732        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5733        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5734        let src_dyn = src;
5735
5736        let mut cpu_dst = TensorDyn::image(
5737            dst_width,
5738            dst_height,
5739            PixelFormat::Rgba,
5740            DType::U8,
5741            None,
5742            edgefirst_tensor::CpuAccess::ReadWrite,
5743        )
5744        .unwrap();
5745        let mut cpu_converter = CPUProcessor::new();
5746        let mut g2d_dst = TensorDyn::image(
5747            dst_width,
5748            dst_height,
5749            PixelFormat::Rgba,
5750            DType::U8,
5751            None,
5752            edgefirst_tensor::CpuAccess::ReadWrite,
5753        )
5754        .unwrap();
5755        let mut g2d_converter = G2DProcessor::new().unwrap();
5756
5757        let crop = Crop::new().with_source(Some(Region::new(50, 120, 1024, 576)));
5758
5759        for rot in [
5760            Rotation::None,
5761            Rotation::Clockwise90,
5762            Rotation::Rotate180,
5763            Rotation::CounterClockwise90,
5764        ] {
5765            cpu_dst
5766                .as_typed::<u8>()
5767                .unwrap()
5768                .map()
5769                .unwrap()
5770                .as_mut_slice()
5771                .fill(114);
5772            g2d_dst
5773                .as_typed::<u8>()
5774                .unwrap()
5775                .map()
5776                .unwrap()
5777                .as_mut_slice()
5778                .fill(114);
5779            for flip in [Flip::None, Flip::Horizontal, Flip::Vertical] {
5780                let mut cpu_dst_dyn = cpu_dst;
5781                cpu_converter
5782                    .convert(&src_dyn, &mut cpu_dst_dyn, Rotation::None, Flip::None, crop)
5783                    .unwrap();
5784                cpu_dst = {
5785                    let mut __t = cpu_dst_dyn.into_u8().unwrap();
5786                    __t.set_format(PixelFormat::Rgba).unwrap();
5787                    TensorDyn::from(__t)
5788                };
5789
5790                let mut g2d_dst_dyn = g2d_dst;
5791                g2d_converter
5792                    .convert(&src_dyn, &mut g2d_dst_dyn, Rotation::None, Flip::None, crop)
5793                    .unwrap();
5794                g2d_dst = {
5795                    let mut __t = g2d_dst_dyn.into_u8().unwrap();
5796                    __t.set_format(PixelFormat::Rgba).unwrap();
5797                    TensorDyn::from(__t)
5798                };
5799
5800                compare_images(
5801                    &g2d_dst,
5802                    &cpu_dst,
5803                    0.98,
5804                    &format!("{} {:?} {:?}", function!(), rot, flip),
5805                );
5806            }
5807        }
5808    }
5809
5810    #[test]
5811    #[cfg(any(
5812        target_os = "linux",
5813        target_os = "macos",
5814        target_os = "ios",
5815        target_os = "android",
5816        target_os = "windows"
5817    ))]
5818    #[cfg(feature = "opengl")]
5819    fn test_opengl_src_crop() {
5820        if !is_opengl_available() {
5821            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
5822            return;
5823        }
5824
5825        let dst_width = 640;
5826        let dst_height = 360;
5827        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5828        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5829        let crop = Crop::new().with_source(Some(Region::new(320, 180, 1280 - 320, 720 - 180)));
5830
5831        let cpu_dst = TensorDyn::image(
5832            dst_width,
5833            dst_height,
5834            PixelFormat::Rgba,
5835            DType::U8,
5836            None,
5837            edgefirst_tensor::CpuAccess::ReadWrite,
5838        )
5839        .unwrap();
5840        let mut cpu_converter = CPUProcessor::new();
5841        let (result, src, cpu_dst) = convert_img(
5842            &mut cpu_converter,
5843            src,
5844            cpu_dst,
5845            Rotation::None,
5846            Flip::None,
5847            crop,
5848        );
5849        result.unwrap();
5850
5851        let gl_dst = TensorDyn::image(
5852            dst_width,
5853            dst_height,
5854            PixelFormat::Rgba,
5855            DType::U8,
5856            None,
5857            edgefirst_tensor::CpuAccess::ReadWrite,
5858        )
5859        .unwrap();
5860        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
5861        let (result, _src, gl_dst) = convert_img(
5862            &mut gl_converter,
5863            src,
5864            gl_dst,
5865            Rotation::None,
5866            Flip::None,
5867            crop,
5868        );
5869        result.unwrap();
5870
5871        compare_images(&gl_dst, &cpu_dst, 0.98, function!());
5872    }
5873
5874    #[test]
5875    #[cfg(any(
5876        target_os = "linux",
5877        target_os = "macos",
5878        target_os = "ios",
5879        target_os = "android",
5880        target_os = "windows"
5881    ))]
5882    #[cfg(feature = "opengl")]
5883    fn test_opengl_dst_crop() {
5884        if !is_opengl_available() {
5885            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
5886            return;
5887        }
5888
5889        let dst_width = 640;
5890        let dst_height = 640;
5891        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5892        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5893
5894        let cpu_dst = TensorDyn::image(
5895            dst_width,
5896            dst_height,
5897            PixelFormat::Rgba,
5898            DType::U8,
5899            None,
5900            edgefirst_tensor::CpuAccess::ReadWrite,
5901        )
5902        .unwrap();
5903        let mut cpu_converter = CPUProcessor::new();
5904        let crop = Crop::new();
5905        let (result, src, cpu_dst) = convert_img(
5906            &mut cpu_converter,
5907            src,
5908            cpu_dst,
5909            Rotation::None,
5910            Flip::None,
5911            crop,
5912        );
5913        result.unwrap();
5914
5915        let gl_dst = TensorDyn::image(
5916            dst_width,
5917            dst_height,
5918            PixelFormat::Rgba,
5919            DType::U8,
5920            None,
5921            edgefirst_tensor::CpuAccess::ReadWrite,
5922        )
5923        .unwrap();
5924        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
5925        let (result, _src, gl_dst) = convert_img(
5926            &mut gl_converter,
5927            src,
5928            gl_dst,
5929            Rotation::None,
5930            Flip::None,
5931            crop,
5932        );
5933        result.unwrap();
5934
5935        compare_images(&gl_dst, &cpu_dst, 0.98, function!());
5936    }
5937    /// A deterministic RGBA8 `Mem` source: `R = x`, `G = y`, `B = x + y`,
5938    /// `A = 255`, each `& 255`. Written at the tensor's own row stride so a
5939    /// padded allocation still gets the pattern at the right columns.
5940    #[cfg(all(target_os = "windows", feature = "opengl"))]
5941    fn synthetic_rgba(w: usize, h: usize) -> TensorDyn {
5942        let t = TensorDyn::image(
5943            w,
5944            h,
5945            PixelFormat::Rgba,
5946            DType::U8,
5947            Some(TensorMemory::Mem),
5948            edgefirst_tensor::CpuAccess::ReadWrite,
5949        )
5950        .unwrap();
5951        fill_synthetic_rgba(&t);
5952        t
5953    }
5954
5955    /// Every pixel of `actual` equals `expected`, compared through the
5956    /// tensors' own row compaction so a padded texture and a tight `Mem`
5957    /// image compare as images. `compare_images` scores similarity and
5958    /// accepts anything at a 0.0 threshold; this is the exact check.
5959    #[cfg(all(target_os = "windows", feature = "opengl"))]
5960    fn assert_pixels_identical(actual: &TensorDyn, expected: &TensorDyn, name: &str) {
5961        assert_eq!(actual.shape(), expected.shape(), "{name}: shapes differ");
5962        let flat = |t: &TensorDyn| {
5963            let typed = t.as_typed::<u8>().expect("u8 image");
5964            let mut out = vec![0u8; t.shape().iter().product::<usize>()];
5965            typed.copy_to_flat(&mut out).expect("compact the rows");
5966            out
5967        };
5968        let (a, e) = (flat(actual), flat(expected));
5969        let mismatches = a.iter().zip(&e).filter(|(x, y)| x != y).count();
5970        assert_eq!(
5971            mismatches,
5972            0,
5973            "{name}: {mismatches} of {} bytes differ (first rows: {:?} vs {:?})",
5974            a.len(),
5975            &a[..16.min(a.len())],
5976            &e[..16.min(e.len())]
5977        );
5978    }
5979
5980    /// Write the `synthetic_rgba` pattern into any writable RGBA8 tensor,
5981    /// a texture included, at its own row stride.
5982    #[cfg(all(target_os = "windows", feature = "opengl"))]
5983    fn fill_synthetic_rgba(t: &TensorDyn) {
5984        let w = t.width().unwrap();
5985        let h = t.height().unwrap();
5986        let bound = t.as_typed::<u8>().unwrap();
5987        let stride = bound.effective_row_stride().unwrap_or(w * 4);
5988        let mut m = bound.map().unwrap();
5989        let px = m.as_mut_slice();
5990        for y in 0..h {
5991            for x in 0..w {
5992                let o = y * stride + x * 4;
5993                px[o] = x as u8;
5994                px[o + 1] = y as u8;
5995                px[o + 2] = (x + y) as u8;
5996                px[o + 3] = 255;
5997            }
5998        }
5999    }
6000
6001    /// Element-wise comparison of two float images of the same format and
6002    /// dtype.
6003    ///
6004    /// Both sides are read through `copy_to_flat`, the tensor's own row
6005    /// compaction, for the reason `compare_images` does: a Windows texture
6006    /// destination pads its rows, so reading the head of the mapping as a
6007    /// tight image would read padding as values. F16 values are widened with
6008    /// `half::f16::to_f64` before the comparison.
6009    #[cfg(all(target_os = "windows", feature = "opengl"))]
6010    fn compare_float_images(a: &TensorDyn, b: &TensorDyn, tol: f64, what: &str) {
6011        assert_eq!(a.format(), b.format(), "{what}: pixel formats differ");
6012        assert_eq!(a.dtype(), b.dtype(), "{what}: dtypes differ");
6013        assert_eq!(a.shape(), b.shape(), "{what}: shapes differ");
6014        let values = |t: &TensorDyn| -> Vec<f64> {
6015            let elems: usize = t.shape().iter().product();
6016            match t.dtype() {
6017                DType::F32 => {
6018                    let typed = t.as_typed::<f32>().expect("F32 image");
6019                    let mut bytes = vec![0u8; elems * 4];
6020                    typed
6021                        .copy_to_flat(&mut bytes)
6022                        .expect("compact the padded rows");
6023                    bytes
6024                        .as_chunks::<4>()
6025                        .0
6026                        .iter()
6027                        .map(|c| f32::from_le_bytes(*c) as f64)
6028                        .collect()
6029                }
6030                DType::F16 => {
6031                    let typed = t.as_typed::<half::f16>().expect("F16 image");
6032                    let mut bytes = vec![0u8; elems * 2];
6033                    typed
6034                        .copy_to_flat(&mut bytes)
6035                        .expect("compact the padded rows");
6036                    bytes
6037                        .as_chunks::<2>()
6038                        .0
6039                        .iter()
6040                        .map(|c| half::f16::from_le_bytes(*c).to_f64())
6041                        .collect()
6042                }
6043                other => panic!("{what}: {other:?} is not a float dtype"),
6044            }
6045        };
6046        let (av, bv) = (values(a), values(b));
6047        assert_eq!(av.len(), bv.len(), "{what}: element counts differ");
6048        for (i, (x, y)) in av.iter().zip(bv.iter()).enumerate() {
6049            assert!(
6050                (x - y).abs() <= tol,
6051                "{what}: element {i} differs by {} (gpu {x}, cpu {y}, tol {tol})",
6052                (x - y).abs()
6053            );
6054        }
6055    }
6056
6057    /// The float PBO readback has to land each destination row at the
6058    /// destination's own pitch, exactly as the u8 PBO readback does.
6059    ///
6060    /// The packed render target's rows ARE the destination's rows -- `(W/4,
6061    /// 3H)` RGBA16F texels, one plane row per surface row, for
6062    /// `PlanarRgb`/F16; `(W*3, H)` R32F texels, one image row per surface
6063    /// row, for `Rgb`/F32 -- and the read was tight whatever pitch the
6064    /// destination declared. A pool tensor narrowed with `configure_image`
6065    /// that keeps the pool's pitch is exactly that destination, so every row
6066    /// after the first landed short.
6067    ///
6068    /// Both routes the pitch can take are covered. `POOL_W`'s pitch is a
6069    /// whole number of texels for both formats, which `GL_PACK_ROW_LENGTH`
6070    /// expresses; the F16 case adds a 4-byte pad, which an 8-byte texel does
6071    /// not divide, so those rows are read tight and spread afterwards. The
6072    /// tight stride is the control: it must still take the read it always
6073    /// did.
6074    ///
6075    /// The oracle is the same convert into a tight PBO of the same geometry.
6076    /// `compare_float_images` reads both sides through `copy_to_flat`, the
6077    /// tensor's own row compaction, so it compares logical elements and not
6078    /// mapping offsets.
6079    #[test]
6080    #[cfg(all(target_os = "windows", feature = "opengl"))]
6081    fn windows_padded_float_pbo_rows_land_at_the_declared_stride() {
6082        if !is_opengl_available() {
6083            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
6084            return;
6085        }
6086        const W: usize = 64;
6087        const H: usize = 32;
6088        const POOL_W: usize = 96;
6089
6090        let mut gpu = ImageProcessor::new().unwrap();
6091        let support = gpu.supported_render_dtypes();
6092        let src = synthetic_rgba(W, H);
6093
6094        // (format, dtype, supported, the pitches to declare)
6095        let cases: [(PixelFormat, DType, bool, &[usize]); 2] = [
6096            (
6097                PixelFormat::PlanarRgb,
6098                DType::F16,
6099                support.f16,
6100                // Tight plane row, the pool's pitch (a whole texel count),
6101                // and a 4-byte pad that no 8-byte texel divides.
6102                &[W * 2, POOL_W * 2, W * 2 + 4],
6103            ),
6104            (
6105                PixelFormat::Rgb,
6106                DType::F32,
6107                support.f32,
6108                &[W * 3 * 4, POOL_W * 3 * 4],
6109            ),
6110        ];
6111
6112        for (fmt, dtype, supported, strides) in cases {
6113            if !supported {
6114                crate::test_support::report_skip(&format!(
6115                    "{} - {fmt:?}/{dtype:?} not reported",
6116                    function!()
6117                ));
6118                continue;
6119            }
6120            let Some(gl) = gpu.opengl.as_ref() else {
6121                crate::test_support::report_skip(&format!("{} - no GL processor", function!()));
6122                return;
6123            };
6124            // The oracle: a tight PBO of the target geometry, same route.
6125            let mut reference = match gl.create_pbo_image_dtype(W, H, fmt, dtype) {
6126                Ok(t) => t,
6127                Err(e) => {
6128                    crate::test_support::report_skip(&format!(
6129                        "{} - no float PBO images: {e:?}",
6130                        function!()
6131                    ));
6132                    return;
6133                }
6134            };
6135            assert_eq!(
6136                reference.memory(),
6137                TensorMemory::Pbo,
6138                "{fmt:?}/{dtype:?}: the destination must be a PBO to reach this readback"
6139            );
6140            gpu.convert(
6141                &src,
6142                &mut reference,
6143                Rotation::None,
6144                Flip::None,
6145                Crop::default(),
6146            )
6147            .unwrap_or_else(|e| panic!("{fmt:?}/{dtype:?}: tight convert failed: {e}"));
6148
6149            for &stride in strides {
6150                let label = format!("{fmt:?}/{dtype:?} at a {stride} B pitch");
6151                // Allocate at the pool width, then narrow the geometry and
6152                // declare the pitch, as the u8 stride tests do.
6153                let mut padded = gpu
6154                    .opengl
6155                    .as_ref()
6156                    .expect("GL processor")
6157                    .create_pbo_image_dtype(POOL_W, H, fmt, dtype)
6158                    .unwrap_or_else(|e| panic!("{label}: pool PBO: {e:?}"));
6159                padded
6160                    .configure_image(W, H, fmt)
6161                    .unwrap_or_else(|e| panic!("{label}: narrow the pool tensor: {e}"));
6162                padded
6163                    .set_row_stride(stride)
6164                    .unwrap_or_else(|e| panic!("{label}: declare the pitch: {e}"));
6165                assert_eq!(padded.effective_row_stride(), Some(stride), "{label}");
6166
6167                let before = gpu.convert_fallback_count();
6168                gpu.convert(
6169                    &src,
6170                    &mut padded,
6171                    Rotation::None,
6172                    Flip::None,
6173                    Crop::default(),
6174                )
6175                .unwrap_or_else(|e| panic!("{label}: padded convert failed: {e}"));
6176                // A CPU fallback writes the destination with the stride-aware
6177                // CPU writers and would pass without the GL readback running.
6178                assert_eq!(
6179                    gpu.convert_fallback_count(),
6180                    before,
6181                    "{label}: fell back to the CPU backend, so the float PBO readback under test never ran"
6182                );
6183
6184                let tol = if dtype == DType::F16 {
6185                    1.0 / 1024.0
6186                } else {
6187                    1e-6
6188                };
6189                compare_float_images(&padded, &reference, tol, &label);
6190            }
6191        }
6192    }
6193
6194    /// Every float destination layout the GL backend reports it can render
6195    /// must render zero-copy into the texture and match the CPU converter
6196    /// element for element: F16 and F32 planar (three and four planes),
6197    /// interleaved float RGB, and float RGBA.
6198    ///
6199    /// The destination memory is requested explicitly so each layout is
6200    /// exercised as a texture whatever `create_image`'s own allocation
6201    /// policy would pick for it; the policy itself is covered by
6202    /// `windows_create_image_prefers_textures_and_convert_with_fence_returns_a_set_event`.
6203    #[test]
6204    #[cfg(all(target_os = "windows", feature = "opengl"))]
6205    fn windows_float_destinations_match_the_cpu_converter_for_every_layout() {
6206        if !is_opengl_available() {
6207            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
6208            return;
6209        }
6210        if !edgefirst_tensor::is_gpu_buffer_available() {
6211            crate::test_support::report_skip(&format!(
6212                "{} - no zero-copy GPU buffers",
6213                function!()
6214            ));
6215            return;
6216        }
6217        let mut gpu = ImageProcessor::new().unwrap();
6218        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
6219            backend: ComputeBackend::Cpu,
6220            ..Default::default()
6221        })
6222        .unwrap();
6223        let support = gpu.supported_render_dtypes();
6224        eprintln!("{}: supported_render_dtypes = {support:?}", function!());
6225        let src = synthetic_rgba(640, 480);
6226        for (fmt, dtype, need) in [
6227            (PixelFormat::PlanarRgb, DType::F16, support.f16),
6228            (PixelFormat::PlanarRgb, DType::F32, support.f32),
6229            (PixelFormat::PlanarRgba, DType::F16, support.f16),
6230            (PixelFormat::PlanarRgba, DType::F32, support.f32),
6231            (PixelFormat::Rgb, DType::F16, support.f16),
6232            (PixelFormat::Rgb, DType::F32, support.f32),
6233            (PixelFormat::Rgba, DType::F16, support.f16),
6234            (PixelFormat::Rgba, DType::F32, support.f32),
6235        ] {
6236            if !need {
6237                eprintln!("{}: {fmt:?}/{dtype:?} not reported", function!());
6238                continue;
6239            }
6240            let mut dst_gpu = gpu
6241                .create_image(
6242                    640,
6243                    480,
6244                    fmt,
6245                    dtype,
6246                    Some(TensorMemory::DmaBuf),
6247                    edgefirst_tensor::CpuAccess::ReadWrite,
6248                )
6249                .unwrap();
6250            assert_eq!(
6251                dst_gpu.memory(),
6252                TensorMemory::DmaBuf,
6253                "{fmt:?}/{dtype:?} must be a texture"
6254            );
6255            let mut dst_cpu = TensorDyn::image(
6256                640,
6257                480,
6258                fmt,
6259                dtype,
6260                Some(TensorMemory::Mem),
6261                edgefirst_tensor::CpuAccess::ReadWrite,
6262            )
6263            .unwrap();
6264            let before = gpu.convert_fallback_count();
6265            gpu.convert(
6266                &src,
6267                &mut dst_gpu,
6268                Rotation::None,
6269                Flip::None,
6270                Crop::default(),
6271            )
6272            .unwrap();
6273            cpu.convert(
6274                &src,
6275                &mut dst_cpu,
6276                Rotation::None,
6277                Flip::None,
6278                Crop::default(),
6279            )
6280            .unwrap();
6281            assert_eq!(
6282                gpu.convert_fallback_count(),
6283                before,
6284                "{fmt:?}/{dtype:?} fell back to CPU"
6285            );
6286            let tol = if dtype == DType::F16 {
6287                1.0 / 1024.0
6288            } else {
6289                1e-6
6290            };
6291            compare_float_images(&dst_gpu, &dst_cpu, tol, &format!("{fmt:?}/{dtype:?}"));
6292        }
6293    }
6294
6295    // The tests below wait on a Windows completion fence directly, so they
6296    // need the raw wait call — no crate in this workspace wraps it.
6297    #[cfg(target_os = "windows")]
6298    #[link(name = "kernel32")]
6299    extern "system" {
6300        fn WaitForSingleObject(handle: *mut std::ffi::c_void, milliseconds: u32) -> u32;
6301    }
6302
6303    /// A batch of deferred converts records one distinct fence value per
6304    /// tile and submits once, at `flush`.
6305    ///
6306    /// The values are what a GPU consumer waits on, so they have to stay
6307    /// per-destination and strictly increasing; the submission is what
6308    /// batching exists to spare, so there must be exactly one of it. The
6309    /// destinations are compared against the same convert done standalone,
6310    /// which is what proves the deferred submission did not lose a tile.
6311    #[cfg(all(target_os = "windows", feature = "opengl"))]
6312    #[test]
6313    fn windows_deferred_batch_records_one_value_per_tile_and_submits_once() {
6314        use edgefirst_tensor::CpuAccess;
6315
6316        if !edgefirst_tensor::is_gpu_buffer_available() {
6317            crate::test_support::report_skip(&format!(
6318                "{} - no zero-copy GPU buffers",
6319                function!()
6320            ));
6321            return;
6322        }
6323        let mut p = match ImageProcessor::new() {
6324            Ok(p) => p,
6325            Err(e) => {
6326                crate::test_support::report_skip(&format!(
6327                    "{} - ImageProcessor init failed ({e:?})",
6328                    function!()
6329                ));
6330                return;
6331            }
6332        };
6333        let (w, h, tiles) = (640usize, 480usize, 4usize);
6334        let src = synthetic_rgba(w, h);
6335        let mut dsts: Vec<TensorDyn> = (0..tiles)
6336            .map(|_| {
6337                p.create_image(w, h, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
6338                    .unwrap()
6339            })
6340            .collect();
6341        if dsts[0].memory() != TensorMemory::DmaBuf {
6342            crate::test_support::report_skip(&format!(
6343                "{} - destinations are not textures",
6344                function!()
6345            ));
6346            return;
6347        }
6348        for dst in &mut dsts {
6349            p.convert_deferred(&src, dst, Rotation::None, Flip::None, Crop::default())
6350                .unwrap();
6351        }
6352        let values: Vec<u64> = dsts
6353            .iter()
6354            .map(|d| {
6355                d.gpu_completion()
6356                    .unwrap()
6357                    .expect("each deferred convert records its own completion")
6358                    .value
6359            })
6360            .collect();
6361        for pair in values.windows(2) {
6362            assert!(
6363                pair[1] > pair[0],
6364                "the batch allocated {values:?}, which is not one increasing value per tile"
6365            );
6366        }
6367        p.flush().unwrap();
6368        // The one submission the batch owed has happened, so every recorded
6369        // value is reachable.
6370        let dev = edgefirst_tensor::d3d11::device().unwrap();
6371        let last = *values.last().unwrap();
6372        let ev = dev.event_for(last).unwrap();
6373        // SAFETY: `ev` owns a live Win32 event handle for the duration of
6374        // this call.
6375        assert_eq!(
6376            unsafe {
6377                WaitForSingleObject(
6378                    std::os::windows::io::AsRawHandle::as_raw_handle(&ev) as _,
6379                    5000,
6380                )
6381            },
6382            0,
6383            "the batch's newest value {last} never arrived after flush"
6384        );
6385
6386        let mut solo = p
6387            .create_image(w, h, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
6388            .unwrap();
6389        p.convert(&src, &mut solo, Rotation::None, Flip::None, Crop::default())
6390            .unwrap();
6391        // Read through the tensor's own compaction: a Windows texture's
6392        // staging rows are pitch-padded, so a flat read of the mapping would
6393        // compare padding as pixels.
6394        let flat = |img: &TensorDyn| -> Vec<u8> {
6395            let t = img.as_typed::<u8>().expect("u8 image");
6396            let mut out = vec![0u8; t.shape().iter().product::<usize>()];
6397            t.copy_to_flat(&mut out).expect("compact the padded rows");
6398            out
6399        };
6400        let want = flat(&solo);
6401        for (i, dst) in dsts.iter().enumerate() {
6402            assert_eq!(
6403                flat(dst),
6404                want,
6405                "tile {i} does not match a standalone convert"
6406            );
6407        }
6408    }
6409
6410    /// A processor dropped with a batch still open submits what that batch
6411    /// recorded.
6412    ///
6413    /// The values are already published on destinations that outlive the
6414    /// processor, so a consumer may be waiting on one; a deferred signal that
6415    /// is never submitted is a wait nothing can satisfy. `flush` is what
6416    /// normally pays it, and the whole point here is that the caller never
6417    /// calls it.
6418    #[cfg(all(target_os = "windows", feature = "opengl"))]
6419    #[test]
6420    fn windows_dropping_a_processor_submits_an_open_batch() {
6421        use edgefirst_tensor::CpuAccess;
6422
6423        if !edgefirst_tensor::is_gpu_buffer_available() {
6424            crate::test_support::report_skip(&format!(
6425                "{} - no zero-copy GPU buffers",
6426                function!()
6427            ));
6428            return;
6429        }
6430        let src = synthetic_rgba(320, 240);
6431        let recorded = {
6432            let mut p = match ImageProcessor::new() {
6433                Ok(p) => p,
6434                Err(e) => {
6435                    crate::test_support::report_skip(&format!(
6436                        "{} - init failed ({e:?})",
6437                        function!()
6438                    ));
6439                    return;
6440                }
6441            };
6442            let mut dst = p
6443                .create_image(
6444                    320,
6445                    240,
6446                    PixelFormat::Rgba,
6447                    DType::U8,
6448                    None,
6449                    CpuAccess::Read,
6450                )
6451                .unwrap();
6452            if dst.memory() != TensorMemory::DmaBuf {
6453                crate::test_support::report_skip(&format!(
6454                    "{} - destination is not a texture",
6455                    function!()
6456                ));
6457                return;
6458            }
6459            p.convert_deferred(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
6460                .unwrap();
6461            // No `flush`: the drop below is what has to settle the batch.
6462            dst.gpu_completion()
6463                .unwrap()
6464                .expect("the deferred convert recorded a completion")
6465                .value
6466        };
6467
6468        let dev = edgefirst_tensor::d3d11::device().unwrap();
6469        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
6470        while dev.completed_value() < recorded {
6471            assert!(
6472                std::time::Instant::now() < deadline,
6473                "the dropped processor never submitted its batch: fence at {}, recorded {recorded}",
6474                dev.completed_value()
6475            );
6476            std::thread::yield_now();
6477        }
6478    }
6479
6480    /// After Task B5, `create_image(.., None, ..)` reaches the Windows
6481    /// Dma-first arm (a D3D11 texture, not a PBO or `Mem` tensor), and
6482    /// `convert_with_fence` is available on every platform: on Windows it
6483    /// returns a manual-reset event set when the GPU work completes.
6484    #[cfg(all(target_os = "windows", feature = "opengl"))]
6485    #[test]
6486    fn windows_create_image_prefers_textures_and_convert_with_fence_returns_a_set_event() {
6487        use edgefirst_tensor::CpuAccess;
6488        use std::os::windows::io::AsRawHandle;
6489
6490        if !edgefirst_tensor::is_gpu_buffer_available() {
6491            crate::test_support::report_skip(&format!(
6492                "{} - no zero-copy GPU buffers",
6493                function!()
6494            ));
6495            return;
6496        }
6497        let mut p = ImageProcessor::new().unwrap();
6498        let src = synthetic_rgba(320, 240);
6499        let mut dst = p
6500            .create_image(
6501                320,
6502                240,
6503                PixelFormat::Rgba,
6504                DType::U8,
6505                None,
6506                CpuAccess::Read,
6507            )
6508            .unwrap();
6509        // Without a GL backend (CI's no-ANGLE coverage pass) `create_image`
6510        // answers host memory and there is no fence to wait on; the lanes
6511        // that set HAL_TEST_REQUIRE_GL=1 still hold the texture-first claim.
6512        if dst.memory() != TensorMemory::DmaBuf
6513            && !std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1")
6514        {
6515            crate::test_support::report_skip(&format!(
6516                "{} - create_image gave {:?}, not a texture (no GL backend)",
6517                function!(),
6518                dst.memory()
6519            ));
6520            return;
6521        }
6522        assert_eq!(
6523            dst.memory(),
6524            TensorMemory::DmaBuf,
6525            "create_image(None) must prefer a texture on Windows"
6526        );
6527        // Read before the wait: the recorded value must be the one the fence
6528        // names, not an older point the destination happened to carry.
6529        let before = dst.gpu_completion().unwrap().map(|c| c.value);
6530        let fence = p
6531            .convert_with_fence(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
6532            .unwrap()
6533            .expect("Windows exports a fence");
6534        let recorded = dst
6535            .gpu_completion()
6536            .unwrap()
6537            .expect("the convert recorded a completion")
6538            .value;
6539        assert!(
6540            before.is_none_or(|b| recorded > b),
6541            "the fenced convert recorded a newer value than {before:?}"
6542        );
6543        // One flush and one signal for the whole call: the value the
6544        // destination carries is the newest the device has allocated, so
6545        // nothing was signalled after `record_completion`. Two signals (the
6546        // shape this replaced) would leave `last_signalled` one past it.
6547        assert_eq!(
6548            edgefirst_tensor::d3d11::device().unwrap().last_signalled(),
6549            recorded,
6550            "a fenced convert must signal exactly once"
6551        );
6552        // SAFETY: `fence` owns a live Win32 event handle returned by
6553        // `convert_with_fence`; WaitForSingleObject only reads the handle,
6554        // which stays valid for the duration of this call because `fence`
6555        // is not dropped until after it.
6556        assert_eq!(
6557            unsafe { WaitForSingleObject(fence.as_raw_handle() as _, 5000) },
6558            0,
6559            "fence was not set within 5 seconds"
6560        );
6561        // The event and the destination's own completion name the same
6562        // value, so a consumer may wait on either: the event fired, and the
6563        // device's completed value has reached the value `dst` reports.
6564        assert!(
6565            edgefirst_tensor::d3d11::device().unwrap().completed_value() >= recorded,
6566            "the event fired, so the recorded value {recorded} is complete"
6567        );
6568        assert_eq!(
6569            dst.gpu_completion().unwrap().map(|c| c.value),
6570            Some(recorded),
6571            "the destination still reports the value the event waited on"
6572        );
6573        let mut expected = TensorDyn::image(
6574            320,
6575            240,
6576            PixelFormat::Rgba,
6577            DType::U8,
6578            Some(TensorMemory::Mem),
6579            CpuAccess::ReadWrite,
6580        )
6581        .unwrap();
6582        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
6583            backend: ComputeBackend::Cpu,
6584            ..Default::default()
6585        })
6586        .unwrap();
6587        cpu.convert(
6588            &src,
6589            &mut expected,
6590            Rotation::None,
6591            Flip::None,
6592            Crop::default(),
6593        )
6594        .unwrap();
6595        compare_images(&dst, &expected, 0.0, "fenced convert");
6596    }
6597
6598    /// A Windows GL test's destination must be a texture; without a GL backend
6599    /// (CI's no-ANGLE coverage pass) `create_image` answers host memory and the
6600    /// test skips, unless the lane set HAL_TEST_REQUIRE_GL=1, which keeps the
6601    /// texture-first claim a hard assertion.
6602    #[cfg(all(target_os = "windows", feature = "opengl"))]
6603    fn texture_or_skip(t: &TensorDyn, test: &str) -> bool {
6604        if t.memory() == TensorMemory::DmaBuf {
6605            return true;
6606        }
6607        if std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1") {
6608            panic!(
6609                "{test}: create_image(None) must prefer a texture on Windows, got {:?}",
6610                t.memory()
6611            );
6612        }
6613        crate::test_support::report_skip(&format!(
6614            "{test} - create_image gave {:?}, not a texture (no GL backend)",
6615            t.memory()
6616        ));
6617        false
6618    }
6619
6620    /// A draw into a texture destination records a completion on it the way
6621    /// a convert does, so a device consumer can wait on the drawn frame. The
6622    /// destination is `CpuAccess::Read`: only the GL backend can write it,
6623    /// so a CPU fallback fails the draw outright instead of leaving the
6624    /// completion empty.
6625    #[cfg(all(target_os = "windows", feature = "opengl"))]
6626    #[test]
6627    fn windows_draw_decoded_masks_into_a_texture_records_a_completion() {
6628        use edgefirst_tensor::{CpuAccess, Segmentation};
6629        use ndarray::Array3;
6630
6631        if !edgefirst_tensor::is_gpu_buffer_available() {
6632            crate::test_support::report_skip(&format!(
6633                "{} - no zero-copy GPU buffers",
6634                function!()
6635            ));
6636            return;
6637        }
6638        let mut p = ImageProcessor::new().unwrap();
6639        let mut dst = p
6640            .create_image(64, 64, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
6641            .unwrap();
6642        if !texture_or_skip(&dst, function!()) {
6643            return;
6644        }
6645        assert!(
6646            dst.gpu_completion().unwrap().is_none(),
6647            "nothing has written this texture yet"
6648        );
6649
6650        let detect = DetectBox {
6651            bbox: [0.25, 0.25, 0.75, 0.75].into(),
6652            score: 0.99,
6653            label: 0,
6654        };
6655        let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
6656        let seg = Segmentation {
6657            segmentation: edgefirst_tensor::Tensor::from_arrayview3(seg_arr.view())
6658                .unwrap()
6659                .into(),
6660            xmin: 0.25,
6661            ymin: 0.25,
6662            xmax: 0.75,
6663            ymax: 0.75,
6664        };
6665        p.draw_decoded_masks(&mut dst, &[detect], &[seg], MaskOverlay::default())
6666            .expect("draw into a read-only texture");
6667
6668        let completion = dst
6669            .gpu_completion()
6670            .unwrap()
6671            .expect("the draw recorded no completion on dst");
6672        assert!(completion.value > 0, "completion value must be non-zero");
6673
6674        // The drawn frame is read back through the texture's staging copy;
6675        // `copy_to_flat` compacts its padded rows into tight RGBA pixels and
6676        // needs only a read mapping, which the read-only texture allows.
6677        // The corner is cleared and the box centre carries the mask colour.
6678        let mut flat = vec![0u8; 64 * 64 * 4];
6679        dst.as_typed::<u8>()
6680            .unwrap()
6681            .copy_to_flat(&mut flat)
6682            .expect("read the drawn texture");
6683        let px = |x: usize, y: usize| -> [u8; 4] {
6684            let o = (y * 64 + x) * 4;
6685            [flat[o], flat[o + 1], flat[o + 2], flat[o + 3]]
6686        };
6687        assert_eq!(px(2, 2), [0, 0, 0, 0], "corner (2,2) was not cleared");
6688        assert_ne!(px(32, 32), [0, 0, 0, 0], "centre (32,32) was not coloured");
6689    }
6690
6691    /// The full-GPU proto draw (forced GL, no CPU materialize) records a
6692    /// completion on a texture destination too.
6693    #[cfg(all(target_os = "windows", feature = "opengl"))]
6694    #[test]
6695    fn windows_draw_proto_masks_into_a_texture_records_a_completion() {
6696        use edgefirst_tensor::{CpuAccess, Tensor};
6697
6698        if !edgefirst_tensor::is_gpu_buffer_available() {
6699            crate::test_support::report_skip(&format!(
6700                "{} - no zero-copy GPU buffers",
6701                function!()
6702            ));
6703            return;
6704        }
6705        // Forcing OpenGL fails outright without a GL backend (CI's no-ANGLE
6706        // coverage pass); that is the same skip the texture guard below gives,
6707        // unless the lane set HAL_TEST_REQUIRE_GL=1.
6708        let mut p = match with_force_backend(Some("opengl"), ImageProcessor::new) {
6709            Ok(p) => p,
6710            Err(e) if !std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1") => {
6711                crate::test_support::report_skip(&format!(
6712                    "{} - no OpenGL backend: {e}",
6713                    function!()
6714                ));
6715                return;
6716            }
6717            Err(e) => panic!("forced OpenGL backend unavailable: {e}"),
6718        };
6719        let mut dst = p
6720            .create_image(64, 64, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
6721            .unwrap();
6722        if !texture_or_skip(&dst, function!()) {
6723            return;
6724        }
6725
6726        let detect = DetectBox {
6727            bbox: [0.25, 0.25, 0.75, 0.75].into(),
6728            score: 0.99,
6729            label: 0,
6730        };
6731        // One prototype channel of ones and a positive coefficient: every
6732        // pixel of the box is foreground.
6733        let proto = ProtoData {
6734            mask_coefficients: TensorDyn::F32(
6735                Tensor::<f32>::from_slice(&[1.0_f32], &[1, 1]).unwrap(),
6736            ),
6737            protos: TensorDyn::F32(
6738                Tensor::<f32>::from_slice(&vec![1.0_f32; 8 * 8], &[8, 8, 1]).unwrap(),
6739            ),
6740            layout: ProtoLayout::Nhwc,
6741        };
6742        p.draw_proto_masks(&mut dst, &[detect], &proto, MaskOverlay::default())
6743            .expect("forced-GL proto draw into a read-only texture");
6744
6745        let completion = dst
6746            .gpu_completion()
6747            .unwrap()
6748            .expect("the proto draw recorded no completion on dst");
6749        assert!(completion.value > 0, "completion value must be non-zero");
6750    }
6751
6752    /// An empty draw with a host-memory background onto a texture
6753    /// destination leaves the background in the texture, rows in order.
6754    /// The gradient pattern makes a missing or vertically flipped base
6755    /// layer differ from the expected image.
6756    #[cfg(all(target_os = "windows", feature = "opengl"))]
6757    #[test]
6758    fn windows_draw_decoded_masks_composites_a_host_background_onto_a_texture() {
6759        use edgefirst_tensor::CpuAccess;
6760
6761        if !edgefirst_tensor::is_gpu_buffer_available() {
6762            crate::test_support::report_skip(&format!(
6763                "{} - no zero-copy GPU buffers",
6764                function!()
6765            ));
6766            return;
6767        }
6768        let mut p = ImageProcessor::new().unwrap();
6769        let bg = synthetic_rgba(64, 48);
6770        let mut dst = p
6771            .create_image(64, 48, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
6772            .unwrap();
6773        if !texture_or_skip(&dst, function!()) {
6774            return;
6775        }
6776        p.draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::new().with_background(&bg))
6777            .expect("empty draw with a host background onto a texture");
6778        assert_pixels_identical(&dst, &bg, "host background onto a texture");
6779        assert!(dst.gpu_completion().unwrap().is_some());
6780    }
6781
6782    /// The same draw with the background in a texture of its own: the
6783    /// base layer is sampled from the background texture directly.
6784    #[cfg(all(target_os = "windows", feature = "opengl"))]
6785    #[test]
6786    fn windows_draw_decoded_masks_composites_a_texture_background_onto_a_texture() {
6787        use edgefirst_tensor::CpuAccess;
6788
6789        if !edgefirst_tensor::is_gpu_buffer_available() {
6790            crate::test_support::report_skip(&format!(
6791                "{} - no zero-copy GPU buffers",
6792                function!()
6793            ));
6794            return;
6795        }
6796        let mut p = ImageProcessor::new().unwrap();
6797        let expected = synthetic_rgba(64, 48);
6798        let bg = p
6799            .create_image(
6800                64,
6801                48,
6802                PixelFormat::Rgba,
6803                DType::U8,
6804                None,
6805                CpuAccess::ReadWrite,
6806            )
6807            .unwrap();
6808        if !texture_or_skip(&bg, function!()) {
6809            return;
6810        }
6811        fill_synthetic_rgba(&bg);
6812        let mut dst = p
6813            .create_image(64, 48, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
6814            .unwrap();
6815        assert_eq!(dst.memory(), TensorMemory::DmaBuf);
6816        p.draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::new().with_background(&bg))
6817            .expect("empty draw with a texture background onto a texture");
6818        assert_pixels_identical(&dst, &expected, "texture background onto a texture");
6819        assert!(dst.gpu_completion().unwrap().is_some());
6820    }
6821
6822    /// A draw into a `view()` of a texture fills that band and leaves its
6823    /// sibling alone. The GL draw declines a sub-region destination
6824    /// (`check_draw_dst_placement`) and the CPU backend, which offsets into
6825    /// the parent, takes it; the zero-copy arm would otherwise have cleared
6826    /// the parent's first rows instead of the view's.
6827    #[cfg(all(target_os = "windows", feature = "opengl"))]
6828    #[test]
6829    fn windows_draw_decoded_masks_into_a_texture_view_fills_only_its_band() {
6830        use edgefirst_tensor::CpuAccess;
6831
6832        if !edgefirst_tensor::is_gpu_buffer_available() {
6833            crate::test_support::report_skip(&format!(
6834                "{} - no zero-copy GPU buffers",
6835                function!()
6836            ));
6837            return;
6838        }
6839        let (w, h) = (32usize, 64usize);
6840        let mut p = ImageProcessor::new().unwrap();
6841        let parent = p
6842            .create_image(
6843                w,
6844                h,
6845                PixelFormat::Rgba,
6846                DType::U8,
6847                None,
6848                CpuAccess::ReadWrite,
6849            )
6850            .unwrap();
6851        if !texture_or_skip(&parent, function!()) {
6852            return;
6853        }
6854        fill_synthetic_rgba(&parent);
6855
6856        // The lower band only. An empty draw with no background clears its
6857        // destination, so a write at the parent's origin would show up as
6858        // cleared pixels in the upper band.
6859        let mut band = parent
6860            .view(edgefirst_tensor::Region::new(0, h / 2, w, h / 2))
6861            .unwrap();
6862        p.draw_decoded_masks(&mut band, &[], &[], MaskOverlay::default())
6863            .expect("empty draw into a texture view");
6864        drop(band);
6865
6866        let mut flat = vec![0u8; w * h * 4];
6867        parent
6868            .as_typed::<u8>()
6869            .unwrap()
6870            .copy_to_flat(&mut flat)
6871            .expect("read the parent texture");
6872        for y in 0..h {
6873            for x in 0..w {
6874                let o = (y * w + x) * 4;
6875                let got = [flat[o], flat[o + 1], flat[o + 2], flat[o + 3]];
6876                let want = if y < h / 2 {
6877                    [x as u8, y as u8, (x + y) as u8, 255]
6878                } else {
6879                    [0, 0, 0, 0]
6880                };
6881                assert_eq!(got, want, "pixel ({x},{y})");
6882            }
6883        }
6884
6885        // The GL decline is what sent the draw above to the CPU: with GL
6886        // forced there is no CPU arm to fall through to, so it surfaces.
6887        let mut forced = with_force_backend(Some("opengl"), ImageProcessor::new).unwrap();
6888        let mut band = parent
6889            .view(edgefirst_tensor::Region::new(0, h / 2, w, h / 2))
6890            .unwrap();
6891        let err = forced
6892            .draw_decoded_masks(&mut band, &[], &[], MaskOverlay::default())
6893            .expect_err("forced GL must decline a view destination");
6894        assert!(
6895            matches!(err, Error::NotSupported(_)),
6896            "expected NotSupported, got {err:?}"
6897        );
6898    }
6899
6900    /// `set_class_colors` reaches the CPU backend even when GL accepts it, so
6901    /// a draw GL declines still renders the caller's palette. The view
6902    /// destination is the reachable decline: `check_draw_dst_placement`
6903    /// refuses it and the CPU backend draws the mask.
6904    #[cfg(all(target_os = "windows", feature = "opengl"))]
6905    #[test]
6906    fn windows_class_colors_reach_the_cpu_backend_behind_a_declined_gl_draw() {
6907        use edgefirst_tensor::{CpuAccess, Segmentation};
6908        use ndarray::Array3;
6909
6910        if !edgefirst_tensor::is_gpu_buffer_available() {
6911            crate::test_support::report_skip(&format!(
6912                "{} - no zero-copy GPU buffers",
6913                function!()
6914            ));
6915            return;
6916        }
6917        const CUSTOM: [u8; 4] = [7, 200, 13, 255];
6918        assert_ne!(
6919            CUSTOM[..3],
6920            DEFAULT_COLORS_U8[0][..3],
6921            "the custom colour must differ from the default palette's, or \
6922             this test cannot tell them apart"
6923        );
6924
6925        let (w, h) = (32usize, 64usize);
6926        let mut p = ImageProcessor::new().unwrap();
6927        let parent = p
6928            .create_image(
6929                w,
6930                h,
6931                PixelFormat::Rgba,
6932                DType::U8,
6933                None,
6934                CpuAccess::ReadWrite,
6935            )
6936            .unwrap();
6937        // Without a GL backend (CI's no-ANGLE coverage pass) `create_image`
6938        // answers host memory and nothing declines the draw; the test needs
6939        // the texture-backed parent so the view is the reachable decline. The
6940        // lanes that set HAL_TEST_REQUIRE_GL=1 still require the texture.
6941        if parent.memory() != TensorMemory::DmaBuf
6942            && !std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1")
6943        {
6944            crate::test_support::report_skip(&format!(
6945                "{} - create_image gave {:?}, not a texture (no GL backend)",
6946                function!(),
6947                parent.memory()
6948            ));
6949            return;
6950        }
6951        p.set_class_colors(&[CUSTOM]).unwrap();
6952
6953        let detect = DetectBox {
6954            bbox: [0.25, 0.25, 0.75, 0.75].into(),
6955            score: 0.99,
6956            label: 0,
6957        };
6958        let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
6959        let seg = Segmentation {
6960            segmentation: edgefirst_tensor::Tensor::from_arrayview3(seg_arr.view())
6961                .unwrap()
6962                .into(),
6963            xmin: 0.25,
6964            ymin: 0.25,
6965            xmax: 0.75,
6966            ymax: 0.75,
6967        };
6968        let mut band = parent
6969            .view(edgefirst_tensor::Region::new(0, h / 2, w, h / 2))
6970            .unwrap();
6971        p.draw_decoded_masks(&mut band, &[detect], &[seg], MaskOverlay::default())
6972            .expect("draw into a texture view");
6973        drop(band);
6974
6975        let mut flat = vec![0u8; w * h * 4];
6976        parent
6977            .as_typed::<u8>()
6978            .unwrap()
6979            .copy_to_flat(&mut flat)
6980            .expect("read the parent texture");
6981        // The band's centre, inside the box. The mask blend writes RGB only;
6982        // alpha is whatever the base layer left, which is the clear.
6983        let o = ((h / 2 + h / 4) * w + w / 2) * 4;
6984        assert_eq!(
6985            &flat[o..o + 3],
6986            &CUSTOM[..3],
6987            "the CPU backend drew the default palette, so it never received \
6988             set_class_colors"
6989        );
6990    }
6991
6992    /// Two independently-created `ImageProcessor`s, each converting into its
6993    /// own texture destination in lockstep, must not bleed GL state into
6994    /// each other across 200 iterations (the destination created by
6995    /// `create_image(None)` is the texture arm this task adds).
6996    #[cfg(all(target_os = "windows", feature = "opengl"))]
6997    #[test]
6998    fn two_processors_alternate_on_texture_destinations_without_state_bleed() {
6999        use edgefirst_tensor::CpuAccess;
7000
7001        if !edgefirst_tensor::is_gpu_buffer_available() {
7002            crate::test_support::report_skip(&format!(
7003                "{} - no zero-copy GPU buffers",
7004                function!()
7005            ));
7006            return;
7007        }
7008        let mut a = ImageProcessor::new().unwrap();
7009        let mut b = ImageProcessor::new().unwrap();
7010        let src_a = synthetic_rgba(512, 512);
7011        let src_b = synthetic_rgba(256, 256);
7012        let mut dst_a = a
7013            .create_image(
7014                512,
7015                512,
7016                PixelFormat::Rgba,
7017                DType::U8,
7018                None,
7019                CpuAccess::Read,
7020            )
7021            .unwrap();
7022        let mut dst_b = b
7023            .create_image(
7024                256,
7025                256,
7026                PixelFormat::Rgba,
7027                DType::U8,
7028                None,
7029                CpuAccess::Read,
7030            )
7031            .unwrap();
7032        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
7033            backend: ComputeBackend::Cpu,
7034            ..Default::default()
7035        })
7036        .unwrap();
7037        let mut ref_a = TensorDyn::image(
7038            512,
7039            512,
7040            PixelFormat::Rgba,
7041            DType::U8,
7042            Some(TensorMemory::Mem),
7043            CpuAccess::ReadWrite,
7044        )
7045        .unwrap();
7046        let mut ref_b = TensorDyn::image(
7047            256,
7048            256,
7049            PixelFormat::Rgba,
7050            DType::U8,
7051            Some(TensorMemory::Mem),
7052            CpuAccess::ReadWrite,
7053        )
7054        .unwrap();
7055        cpu.convert(
7056            &src_a,
7057            &mut ref_a,
7058            Rotation::None,
7059            Flip::None,
7060            Crop::default(),
7061        )
7062        .unwrap();
7063        cpu.convert(
7064            &src_b,
7065            &mut ref_b,
7066            Rotation::None,
7067            Flip::None,
7068            Crop::default(),
7069        )
7070        .unwrap();
7071        for _ in 0..200 {
7072            a.convert(
7073                &src_a,
7074                &mut dst_a,
7075                Rotation::None,
7076                Flip::None,
7077                Crop::default(),
7078            )
7079            .unwrap();
7080            b.convert(
7081                &src_b,
7082                &mut dst_b,
7083                Rotation::None,
7084                Flip::None,
7085                Crop::default(),
7086            )
7087            .unwrap();
7088            compare_images(&dst_a, &ref_a, 0.0, "processor A");
7089            compare_images(&dst_b, &ref_b, 0.0, "processor B");
7090        }
7091    }
7092
7093    #[test]
7094    #[cfg(any(
7095        target_os = "linux",
7096        target_os = "macos",
7097        target_os = "ios",
7098        target_os = "android",
7099        target_os = "windows"
7100    ))]
7101    #[cfg(feature = "opengl")]
7102    fn test_opengl_all_rgba() {
7103        if !is_opengl_available() {
7104            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
7105            return;
7106        }
7107
7108        let dst_width = 640;
7109        let dst_height = 640;
7110        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
7111
7112        let mut cpu_converter = CPUProcessor::new();
7113
7114        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
7115
7116        // Every backing this host can allocate: Shm is Unix-only, and the
7117        // zero-copy slot is DMA-BUF on Linux / IOSurface on macOS (nothing on
7118        // Windows yet).
7119        let mut mem = vec![None, Some(TensorMemory::Mem)];
7120        if edgefirst_tensor::is_shm_available() {
7121            mem.push(Some(TensorMemory::Shm));
7122        }
7123        if edgefirst_tensor::is_gpu_buffer_available() {
7124            mem.push(Some(TensorMemory::DmaBuf));
7125        }
7126        let crop = Crop::new().with_source(Some(Region::new(50, 120, 1024, 576)));
7127        for m in mem {
7128            let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), m).unwrap();
7129            let src_dyn = src;
7130
7131            for rot in [
7132                Rotation::None,
7133                Rotation::Clockwise90,
7134                Rotation::Rotate180,
7135                Rotation::CounterClockwise90,
7136            ] {
7137                for flip in [Flip::None, Flip::Horizontal, Flip::Vertical] {
7138                    let cpu_dst = TensorDyn::image(
7139                        dst_width,
7140                        dst_height,
7141                        PixelFormat::Rgba,
7142                        DType::U8,
7143                        m,
7144                        edgefirst_tensor::CpuAccess::ReadWrite,
7145                    )
7146                    .unwrap();
7147                    let gl_dst = TensorDyn::image(
7148                        dst_width,
7149                        dst_height,
7150                        PixelFormat::Rgba,
7151                        DType::U8,
7152                        m,
7153                        edgefirst_tensor::CpuAccess::ReadWrite,
7154                    )
7155                    .unwrap();
7156                    cpu_dst
7157                        .as_typed::<u8>()
7158                        .unwrap()
7159                        .map()
7160                        .unwrap()
7161                        .as_mut_slice()
7162                        .fill(114);
7163                    gl_dst
7164                        .as_typed::<u8>()
7165                        .unwrap()
7166                        .map()
7167                        .unwrap()
7168                        .as_mut_slice()
7169                        .fill(114);
7170
7171                    let mut cpu_dst_dyn = cpu_dst;
7172                    cpu_converter
7173                        .convert(&src_dyn, &mut cpu_dst_dyn, Rotation::None, Flip::None, crop)
7174                        .unwrap();
7175                    let cpu_dst = {
7176                        let mut __t = cpu_dst_dyn.into_u8().unwrap();
7177                        __t.set_format(PixelFormat::Rgba).unwrap();
7178                        TensorDyn::from(__t)
7179                    };
7180
7181                    let mut gl_dst_dyn = gl_dst;
7182                    gl_converter
7183                        .convert(&src_dyn, &mut gl_dst_dyn, Rotation::None, Flip::None, crop)
7184                        .map_err(|e| {
7185                            log::error!("error mem {m:?} rot {rot:?} error: {e:?}");
7186                            e
7187                        })
7188                        .unwrap();
7189                    let gl_dst = {
7190                        let mut __t = gl_dst_dyn.into_u8().unwrap();
7191                        __t.set_format(PixelFormat::Rgba).unwrap();
7192                        TensorDyn::from(__t)
7193                    };
7194
7195                    compare_images(
7196                        &gl_dst,
7197                        &cpu_dst,
7198                        0.98,
7199                        &format!("{} {:?} {:?}", function!(), rot, flip),
7200                    );
7201                }
7202            }
7203        }
7204    }
7205
7206    #[test]
7207    #[cfg(target_os = "linux")]
7208    fn test_cpu_rotate() {
7209        for rot in [
7210            Rotation::Clockwise90,
7211            Rotation::Rotate180,
7212            Rotation::CounterClockwise90,
7213        ] {
7214            test_cpu_rotate_(rot);
7215        }
7216    }
7217
7218    #[cfg(target_os = "linux")]
7219    fn test_cpu_rotate_(rot: Rotation) {
7220        // This test rotates the image 4 times and checks that the image was returned to
7221        // be the same Currently doesn't check if rotations actually rotated in
7222        // right direction
7223        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
7224
7225        let unchanged_src =
7226            crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
7227        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
7228
7229        let (dst_width, dst_height) = match rot {
7230            Rotation::None | Rotation::Rotate180 => (src.width().unwrap(), src.height().unwrap()),
7231            Rotation::Clockwise90 | Rotation::CounterClockwise90 => {
7232                (src.height().unwrap(), src.width().unwrap())
7233            }
7234        };
7235
7236        let cpu_dst = TensorDyn::image(
7237            dst_width,
7238            dst_height,
7239            PixelFormat::Rgba,
7240            DType::U8,
7241            None,
7242            edgefirst_tensor::CpuAccess::ReadWrite,
7243        )
7244        .unwrap();
7245        let mut cpu_converter = CPUProcessor::new();
7246
7247        // After rotating 4 times, the image should be the same as the original
7248
7249        let (result, src, cpu_dst) = convert_img(
7250            &mut cpu_converter,
7251            src,
7252            cpu_dst,
7253            rot,
7254            Flip::None,
7255            Crop::no_crop(),
7256        );
7257        result.unwrap();
7258
7259        let (result, cpu_dst, src) = convert_img(
7260            &mut cpu_converter,
7261            cpu_dst,
7262            src,
7263            rot,
7264            Flip::None,
7265            Crop::no_crop(),
7266        );
7267        result.unwrap();
7268
7269        let (result, src, cpu_dst) = convert_img(
7270            &mut cpu_converter,
7271            src,
7272            cpu_dst,
7273            rot,
7274            Flip::None,
7275            Crop::no_crop(),
7276        );
7277        result.unwrap();
7278
7279        let (result, _cpu_dst, src) = convert_img(
7280            &mut cpu_converter,
7281            cpu_dst,
7282            src,
7283            rot,
7284            Flip::None,
7285            Crop::no_crop(),
7286        );
7287        result.unwrap();
7288
7289        compare_images(&src, &unchanged_src, 0.98, function!());
7290    }
7291
7292    #[test]
7293    #[cfg(any(
7294        target_os = "linux",
7295        target_os = "macos",
7296        target_os = "ios",
7297        target_os = "android",
7298        target_os = "windows"
7299    ))]
7300    #[cfg(feature = "opengl")]
7301    fn test_opengl_rotate() {
7302        if !is_opengl_available() {
7303            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
7304            return;
7305        }
7306
7307        let size = (1280, 720);
7308        // Same backing list as `test_opengl_all_rgba`.
7309        let mut mem = vec![None, Some(TensorMemory::Mem)];
7310        if edgefirst_tensor::is_shm_available() {
7311            mem.push(Some(TensorMemory::Shm));
7312        }
7313        if edgefirst_tensor::is_gpu_buffer_available() {
7314            mem.push(Some(TensorMemory::DmaBuf));
7315        }
7316        for m in mem {
7317            for rot in [
7318                Rotation::Clockwise90,
7319                Rotation::Rotate180,
7320                Rotation::CounterClockwise90,
7321            ] {
7322                test_opengl_rotate_(size, rot, m);
7323            }
7324        }
7325    }
7326
7327    #[cfg(any(
7328        target_os = "linux",
7329        target_os = "macos",
7330        target_os = "ios",
7331        target_os = "android",
7332        target_os = "windows"
7333    ))]
7334    #[cfg(feature = "opengl")]
7335    fn test_opengl_rotate_(
7336        size: (usize, usize),
7337        rot: Rotation,
7338        tensor_memory: Option<TensorMemory>,
7339    ) {
7340        let (dst_width, dst_height) = match rot {
7341            Rotation::None | Rotation::Rotate180 => size,
7342            Rotation::Clockwise90 | Rotation::CounterClockwise90 => (size.1, size.0),
7343        };
7344
7345        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
7346        let src =
7347            crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), tensor_memory).unwrap();
7348
7349        let cpu_dst = TensorDyn::image(
7350            dst_width,
7351            dst_height,
7352            PixelFormat::Rgba,
7353            DType::U8,
7354            None,
7355            edgefirst_tensor::CpuAccess::ReadWrite,
7356        )
7357        .unwrap();
7358        let mut cpu_converter = CPUProcessor::new();
7359
7360        let (result, mut src, cpu_dst) = convert_img(
7361            &mut cpu_converter,
7362            src,
7363            cpu_dst,
7364            rot,
7365            Flip::None,
7366            Crop::no_crop(),
7367        );
7368        result.unwrap();
7369
7370        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
7371
7372        for _ in 0..5 {
7373            let gl_dst = TensorDyn::image(
7374                dst_width,
7375                dst_height,
7376                PixelFormat::Rgba,
7377                DType::U8,
7378                tensor_memory,
7379                edgefirst_tensor::CpuAccess::ReadWrite,
7380            )
7381            .unwrap();
7382            let (result, src_back, gl_dst) = convert_img(
7383                &mut gl_converter,
7384                src,
7385                gl_dst,
7386                rot,
7387                Flip::None,
7388                Crop::no_crop(),
7389            );
7390            result.unwrap();
7391            src = src_back;
7392            compare_images(&gl_dst, &cpu_dst, 0.98, function!());
7393        }
7394    }
7395
7396    #[test]
7397    #[cfg(target_os = "linux")]
7398    fn test_g2d_rotate() {
7399        if !is_g2d_available() {
7400            crate::test_support::report_skip(
7401                "test_g2d_rotate - G2D library (libg2d.so.2) not available",
7402            );
7403            return;
7404        }
7405        if !is_dma_available() {
7406            crate::test_support::report_skip("test_g2d_rotate - DMA memory allocation not available (permission denied or no DMA-BUF support)");
7407            return;
7408        }
7409
7410        let size = (1280, 720);
7411        for rot in [
7412            Rotation::Clockwise90,
7413            Rotation::Rotate180,
7414            Rotation::CounterClockwise90,
7415        ] {
7416            test_g2d_rotate_(size, rot);
7417        }
7418    }
7419
7420    #[cfg(target_os = "linux")]
7421    fn test_g2d_rotate_(size: (usize, usize), rot: Rotation) {
7422        let (dst_width, dst_height) = match rot {
7423            Rotation::None | Rotation::Rotate180 => size,
7424            Rotation::Clockwise90 | Rotation::CounterClockwise90 => (size.1, size.0),
7425        };
7426
7427        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
7428        let src = crate::load_image_test_helper(
7429            &file,
7430            Some(PixelFormat::Rgba),
7431            Some(TensorMemory::DmaBuf),
7432        )
7433        .unwrap();
7434
7435        let cpu_dst = TensorDyn::image(
7436            dst_width,
7437            dst_height,
7438            PixelFormat::Rgba,
7439            DType::U8,
7440            None,
7441            edgefirst_tensor::CpuAccess::ReadWrite,
7442        )
7443        .unwrap();
7444        let mut cpu_converter = CPUProcessor::new();
7445
7446        let (result, src, cpu_dst) = convert_img(
7447            &mut cpu_converter,
7448            src,
7449            cpu_dst,
7450            rot,
7451            Flip::None,
7452            Crop::no_crop(),
7453        );
7454        result.unwrap();
7455
7456        let g2d_dst = TensorDyn::image(
7457            dst_width,
7458            dst_height,
7459            PixelFormat::Rgba,
7460            DType::U8,
7461            Some(TensorMemory::DmaBuf),
7462            edgefirst_tensor::CpuAccess::ReadWrite,
7463        )
7464        .unwrap();
7465        let mut g2d_converter = G2DProcessor::new().unwrap();
7466
7467        let (result, _src, g2d_dst) = convert_img(
7468            &mut g2d_converter,
7469            src,
7470            g2d_dst,
7471            rot,
7472            Flip::None,
7473            Crop::no_crop(),
7474        );
7475        result.unwrap();
7476
7477        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
7478        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
7479        // the YUV-matrix delta that forced 0.95 has closed; tightened to
7480        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
7481        // structural gap not exercised by these limited-range fixtures.
7482        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
7483    }
7484
7485    #[test]
7486    fn test_rgba_to_yuyv_resize_cpu() {
7487        let src = load_bytes_to_tensor(
7488            1280,
7489            720,
7490            PixelFormat::Rgba,
7491            None,
7492            &edgefirst_bench::testdata::read("camera720p.rgba"),
7493        )
7494        .unwrap();
7495
7496        let (dst_width, dst_height) = (640, 360);
7497
7498        let dst = TensorDyn::image(
7499            dst_width,
7500            dst_height,
7501            PixelFormat::Yuyv,
7502            DType::U8,
7503            None,
7504            edgefirst_tensor::CpuAccess::ReadWrite,
7505        )
7506        .unwrap();
7507
7508        let dst_through_yuyv = TensorDyn::image(
7509            dst_width,
7510            dst_height,
7511            PixelFormat::Rgba,
7512            DType::U8,
7513            None,
7514            edgefirst_tensor::CpuAccess::ReadWrite,
7515        )
7516        .unwrap();
7517        let dst_direct = TensorDyn::image(
7518            dst_width,
7519            dst_height,
7520            PixelFormat::Rgba,
7521            DType::U8,
7522            None,
7523            edgefirst_tensor::CpuAccess::ReadWrite,
7524        )
7525        .unwrap();
7526
7527        let mut cpu_converter = CPUProcessor::new();
7528
7529        let (result, src, dst) = convert_img(
7530            &mut cpu_converter,
7531            src,
7532            dst,
7533            Rotation::None,
7534            Flip::None,
7535            Crop::no_crop(),
7536        );
7537        result.unwrap();
7538
7539        let (result, _dst, dst_through_yuyv) = convert_img(
7540            &mut cpu_converter,
7541            dst,
7542            dst_through_yuyv,
7543            Rotation::None,
7544            Flip::None,
7545            Crop::no_crop(),
7546        );
7547        result.unwrap();
7548
7549        let (result, _src, dst_direct) = convert_img(
7550            &mut cpu_converter,
7551            src,
7552            dst_direct,
7553            Rotation::None,
7554            Flip::None,
7555            Crop::no_crop(),
7556        );
7557        result.unwrap();
7558
7559        compare_images(&dst_through_yuyv, &dst_direct, 0.98, function!());
7560    }
7561
7562    #[test]
7563    #[cfg(any(
7564        target_os = "linux",
7565        target_os = "macos",
7566        target_os = "ios",
7567        target_os = "android",
7568        target_os = "windows"
7569    ))]
7570    #[cfg(feature = "opengl")]
7571    #[ignore = "opengl doesn't support rendering to PixelFormat::Yuyv texture"]
7572    fn test_rgba_to_yuyv_resize_opengl() {
7573        if !is_opengl_available() {
7574            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
7575            return;
7576        }
7577
7578        if !edgefirst_tensor::is_gpu_buffer_available() {
7579            crate::test_support::report_skip(&format!(
7580                "{} - zero-copy GPU buffer (DMA-BUF / IOSurface) not available on this host",
7581                function!()
7582            ));
7583            return;
7584        }
7585
7586        let src = load_bytes_to_tensor(
7587            1280,
7588            720,
7589            PixelFormat::Rgba,
7590            None,
7591            &edgefirst_bench::testdata::read("camera720p.rgba"),
7592        )
7593        .unwrap();
7594
7595        let (dst_width, dst_height) = (640, 360);
7596
7597        let dst = TensorDyn::image(
7598            dst_width,
7599            dst_height,
7600            PixelFormat::Yuyv,
7601            DType::U8,
7602            Some(TensorMemory::DmaBuf),
7603            edgefirst_tensor::CpuAccess::ReadWrite,
7604        )
7605        .unwrap();
7606
7607        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
7608
7609        let (result, src, dst) = convert_img(
7610            &mut gl_converter,
7611            src,
7612            dst,
7613            Rotation::None,
7614            Flip::None,
7615            Crop::letterbox([255, 255, 255, 255]),
7616        );
7617        result.unwrap();
7618
7619        std::fs::write(
7620            "rgba_to_yuyv_opengl.yuyv",
7621            dst.as_typed::<u8>().unwrap().map().unwrap().as_slice(),
7622        )
7623        .unwrap();
7624        let cpu_dst = TensorDyn::image(
7625            dst_width,
7626            dst_height,
7627            PixelFormat::Yuyv,
7628            DType::U8,
7629            Some(TensorMemory::DmaBuf),
7630            edgefirst_tensor::CpuAccess::ReadWrite,
7631        )
7632        .unwrap();
7633        let (result, _src, cpu_dst) = convert_img(
7634            &mut CPUProcessor::new(),
7635            src,
7636            cpu_dst,
7637            Rotation::None,
7638            Flip::None,
7639            Crop::no_crop(),
7640        );
7641        result.unwrap();
7642
7643        compare_images_convert_to_rgb(&dst, &cpu_dst, 0.98, function!());
7644    }
7645
7646    #[test]
7647    #[cfg(target_os = "linux")]
7648    fn test_rgba_to_yuyv_resize_g2d() {
7649        if !is_g2d_available() {
7650            crate::test_support::report_skip(
7651                "test_rgba_to_yuyv_resize_g2d - G2D library (libg2d.so.2) not available",
7652            );
7653            return;
7654        }
7655        if !is_dma_available() {
7656            crate::test_support::report_skip("test_rgba_to_yuyv_resize_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
7657            return;
7658        }
7659
7660        let src = load_bytes_to_tensor(
7661            1280,
7662            720,
7663            PixelFormat::Rgba,
7664            Some(TensorMemory::DmaBuf),
7665            &edgefirst_bench::testdata::read("camera720p.rgba"),
7666        )
7667        .unwrap();
7668
7669        let (dst_width, dst_height) = (1280, 720);
7670
7671        let cpu_dst = TensorDyn::image(
7672            dst_width,
7673            dst_height,
7674            PixelFormat::Yuyv,
7675            DType::U8,
7676            Some(TensorMemory::DmaBuf),
7677            edgefirst_tensor::CpuAccess::ReadWrite,
7678        )
7679        .unwrap();
7680
7681        let g2d_dst = TensorDyn::image(
7682            dst_width,
7683            dst_height,
7684            PixelFormat::Yuyv,
7685            DType::U8,
7686            Some(TensorMemory::DmaBuf),
7687            edgefirst_tensor::CpuAccess::ReadWrite,
7688        )
7689        .unwrap();
7690
7691        let mut g2d_converter = G2DProcessor::new().unwrap();
7692        let crop = Crop::new();
7693
7694        g2d_dst
7695            .as_typed::<u8>()
7696            .unwrap()
7697            .map()
7698            .unwrap()
7699            .as_mut_slice()
7700            .fill(128);
7701        let (result, src, g2d_dst) = convert_img(
7702            &mut g2d_converter,
7703            src,
7704            g2d_dst,
7705            Rotation::None,
7706            Flip::None,
7707            crop,
7708        );
7709        result.unwrap();
7710
7711        let cpu_dst_img = cpu_dst;
7712        cpu_dst_img
7713            .as_typed::<u8>()
7714            .unwrap()
7715            .map()
7716            .unwrap()
7717            .as_mut_slice()
7718            .fill(128);
7719        let (result, _src, cpu_dst) = convert_img(
7720            &mut CPUProcessor::new(),
7721            src,
7722            cpu_dst_img,
7723            Rotation::None,
7724            Flip::None,
7725            crop,
7726        );
7727        result.unwrap();
7728
7729        compare_images_convert_to_rgb(&cpu_dst, &g2d_dst, 0.98, function!());
7730    }
7731
7732    #[test]
7733    fn test_yuyv_to_rgba_cpu() {
7734        let file = edgefirst_bench::testdata::read("camera720p.yuyv").to_vec();
7735        let src = TensorDyn::image(
7736            1280,
7737            720,
7738            PixelFormat::Yuyv,
7739            DType::U8,
7740            None,
7741            edgefirst_tensor::CpuAccess::ReadWrite,
7742        )
7743        .unwrap();
7744        src.as_typed::<u8>()
7745            .unwrap()
7746            .map()
7747            .unwrap()
7748            .as_mut_slice()
7749            .copy_from_slice(&file);
7750
7751        let dst = TensorDyn::image(
7752            1280,
7753            720,
7754            PixelFormat::Rgba,
7755            DType::U8,
7756            None,
7757            edgefirst_tensor::CpuAccess::ReadWrite,
7758        )
7759        .unwrap();
7760        let mut cpu_converter = CPUProcessor::new();
7761
7762        let (result, _src, dst) = convert_img(
7763            &mut cpu_converter,
7764            src,
7765            dst,
7766            Rotation::None,
7767            Flip::None,
7768            Crop::no_crop(),
7769        );
7770        result.unwrap();
7771
7772        let target_image = TensorDyn::image(
7773            1280,
7774            720,
7775            PixelFormat::Rgba,
7776            DType::U8,
7777            None,
7778            edgefirst_tensor::CpuAccess::ReadWrite,
7779        )
7780        .unwrap();
7781        target_image
7782            .as_typed::<u8>()
7783            .unwrap()
7784            .map()
7785            .unwrap()
7786            .as_mut_slice()
7787            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
7788
7789        // CPU path resolves the untagged 720p source to BT.709 limited (height
7790        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
7791        compare_images(&dst, &target_image, 0.98, function!());
7792    }
7793
7794    #[test]
7795    fn test_yuyv_to_rgb_cpu() {
7796        let file = edgefirst_bench::testdata::read("camera720p.yuyv").to_vec();
7797        let src = TensorDyn::image(
7798            1280,
7799            720,
7800            PixelFormat::Yuyv,
7801            DType::U8,
7802            None,
7803            edgefirst_tensor::CpuAccess::ReadWrite,
7804        )
7805        .unwrap();
7806        src.as_typed::<u8>()
7807            .unwrap()
7808            .map()
7809            .unwrap()
7810            .as_mut_slice()
7811            .copy_from_slice(&file);
7812
7813        let dst = TensorDyn::image(
7814            1280,
7815            720,
7816            PixelFormat::Rgb,
7817            DType::U8,
7818            None,
7819            edgefirst_tensor::CpuAccess::ReadWrite,
7820        )
7821        .unwrap();
7822        let mut cpu_converter = CPUProcessor::new();
7823
7824        let (result, _src, dst) = convert_img(
7825            &mut cpu_converter,
7826            src,
7827            dst,
7828            Rotation::None,
7829            Flip::None,
7830            Crop::no_crop(),
7831        );
7832        result.unwrap();
7833
7834        let target_image = TensorDyn::image(
7835            1280,
7836            720,
7837            PixelFormat::Rgb,
7838            DType::U8,
7839            None,
7840            edgefirst_tensor::CpuAccess::ReadWrite,
7841        )
7842        .unwrap();
7843        target_image
7844            .as_typed::<u8>()
7845            .unwrap()
7846            .map()
7847            .unwrap()
7848            .as_mut_slice()
7849            .as_chunks_mut::<3>()
7850            .0
7851            .iter_mut()
7852            .zip(
7853                edgefirst_bench::testdata::read("camera720p.rgba")
7854                    .as_chunks::<4>()
7855                    .0,
7856            )
7857            .for_each(|(dst, src)| *dst = [src[0], src[1], src[2]]);
7858
7859        // CPU path resolves the untagged 720p source to BT.709 limited (height
7860        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
7861        compare_images(&dst, &target_image, 0.98, function!());
7862    }
7863
7864    #[test]
7865    #[cfg(target_os = "linux")]
7866    fn test_yuyv_to_rgba_g2d() {
7867        if !is_g2d_available() {
7868            crate::test_support::report_skip(
7869                "test_yuyv_to_rgba_g2d - G2D library (libg2d.so.2) not available",
7870            );
7871            return;
7872        }
7873        if !is_dma_available() {
7874            crate::test_support::report_skip("test_yuyv_to_rgba_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
7875            return;
7876        }
7877
7878        let src = load_bytes_to_tensor(
7879            1280,
7880            720,
7881            PixelFormat::Yuyv,
7882            None,
7883            &edgefirst_bench::testdata::read("camera720p.yuyv"),
7884        )
7885        .unwrap();
7886
7887        let dst = TensorDyn::image(
7888            1280,
7889            720,
7890            PixelFormat::Rgba,
7891            DType::U8,
7892            Some(TensorMemory::DmaBuf),
7893            edgefirst_tensor::CpuAccess::ReadWrite,
7894        )
7895        .unwrap();
7896        let mut g2d_converter = G2DProcessor::new().unwrap();
7897
7898        let (result, _src, dst) = convert_img(
7899            &mut g2d_converter,
7900            src,
7901            dst,
7902            Rotation::None,
7903            Flip::None,
7904            Crop::no_crop(),
7905        );
7906        result.unwrap();
7907
7908        let target_image = TensorDyn::image(
7909            1280,
7910            720,
7911            PixelFormat::Rgba,
7912            DType::U8,
7913            None,
7914            edgefirst_tensor::CpuAccess::ReadWrite,
7915        )
7916        .unwrap();
7917        target_image
7918            .as_typed::<u8>()
7919            .unwrap()
7920            .map()
7921            .unwrap()
7922            .as_mut_slice()
7923            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
7924
7925        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
7926        // so the matrix delta vs the reference that forced 0.95 has closed;
7927        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
7928        compare_images(&dst, &target_image, 0.98, function!());
7929    }
7930
7931    #[test]
7932    #[cfg(any(
7933        target_os = "linux",
7934        target_os = "macos",
7935        target_os = "ios",
7936        target_os = "android",
7937        target_os = "windows"
7938    ))]
7939    #[cfg(feature = "opengl")]
7940    fn test_yuyv_to_rgba_opengl() {
7941        if !is_opengl_available() {
7942            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
7943            return;
7944        }
7945        // Zero-copy YUYV source: DMA-BUF on Linux, IOSurface on macOS, a
7946        // D3D11 texture on Windows; skips where no such backing exists.
7947        if !edgefirst_tensor::is_gpu_buffer_available() {
7948            crate::test_support::report_skip(&format!(
7949                "{} - zero-copy GPU buffer (DMA-BUF / IOSurface) not available on this host",
7950                function!()
7951            ));
7952            return;
7953        }
7954
7955        let src = load_bytes_to_tensor(
7956            1280,
7957            720,
7958            PixelFormat::Yuyv,
7959            Some(TensorMemory::DmaBuf),
7960            &edgefirst_bench::testdata::read("camera720p.yuyv"),
7961        )
7962        .unwrap();
7963
7964        let dst = TensorDyn::image(
7965            1280,
7966            720,
7967            PixelFormat::Rgba,
7968            DType::U8,
7969            Some(TensorMemory::DmaBuf),
7970            edgefirst_tensor::CpuAccess::ReadWrite,
7971        )
7972        .unwrap();
7973        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
7974
7975        let (result, _src, dst) = convert_img(
7976            &mut gl_converter,
7977            src,
7978            dst,
7979            Rotation::None,
7980            Flip::None,
7981            Crop::no_crop(),
7982        );
7983        // Desktop NVIDIA cannot sample YUYV as a GL texture; i.MX/Vivante can.
7984        // Production ImageProcessor falls back to CPU; this oracle skips.
7985        if let Err(crate::Error::NotSupported(msg)) = &result {
7986            crate::test_support::report_skip(&format!("{} — {msg}", function!()));
7987            return;
7988        }
7989        result.unwrap();
7990
7991        let target_image = TensorDyn::image(
7992            1280,
7993            720,
7994            PixelFormat::Rgba,
7995            DType::U8,
7996            None,
7997            edgefirst_tensor::CpuAccess::ReadWrite,
7998        )
7999        .unwrap();
8000        target_image
8001            .as_typed::<u8>()
8002            .unwrap()
8003            .map()
8004            .unwrap()
8005            .as_mut_slice()
8006            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
8007
8008        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
8009        // so the matrix delta vs the reference that forced 0.95 has closed;
8010        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
8011        compare_images(&dst, &target_image, 0.98, function!());
8012    }
8013
8014    /// macOS analog of `test_yuyv_to_rgba_opengl` — drives the ANGLE +
8015    /// IOSurface backend end-to-end and compares against the same
8016    /// reference image. Skips silently if ANGLE isn't installed so the
8017    /// test suite still passes on CI hosts without the Homebrew tap.
8018    /// Step-1 probe: proves ANGLE's Metal IOSurface-client-buffer path accepts
8019    /// an `L008`→`GL_RED` (R8) binding — the foundation for sampling the
8020    /// contiguous semi-planar YUV buffer as a single R8 texture. Renders a
8021    /// GREY (R8 IOSurface) source through the GL backend to RGBA and checks the
8022    /// luma round-trips to R=G=B (identity GREY→RGB).
8023    #[test]
8024    #[cfg(target_os = "macos")]
8025    #[cfg(feature = "opengl")]
8026    fn test_grey_r8_iosurface_to_rgba_opengl_macos() {
8027        let mut proc = match GLProcessorThreaded::new(None) {
8028            Ok(p) => p,
8029            Err(e) => {
8030                crate::test_support::report_skip(&format!(
8031                    "{} — GL engine init failed ({e:?})",
8032                    function!()
8033                ));
8034                return;
8035            }
8036        };
8037
8038        let (w, h) = (16usize, 16usize);
8039        let src = TensorDyn::image(
8040            w,
8041            h,
8042            PixelFormat::Grey,
8043            DType::U8,
8044            Some(TensorMemory::DmaBuf),
8045            edgefirst_tensor::CpuAccess::ReadWrite,
8046        )
8047        .expect("GREY IOSurface (R8/L008) should allocate — proves the FourCC mapping");
8048        // Known luma ramp: value = (x * 13 + y * 7) & 0xff.
8049        {
8050            let su8 = src.as_typed::<u8>().unwrap();
8051            let stride = src
8052                .as_typed::<u8>()
8053                .unwrap()
8054                .effective_row_stride()
8055                .unwrap();
8056            let mut m = su8.map().unwrap();
8057            let buf = m.as_mut_slice();
8058            for y in 0..h {
8059                for x in 0..w {
8060                    buf[y * stride + x] = ((x * 13 + y * 7) & 0xff) as u8;
8061                }
8062            }
8063        }
8064
8065        let dst = TensorDyn::image(
8066            w,
8067            h,
8068            PixelFormat::Rgba,
8069            DType::U8,
8070            Some(TensorMemory::DmaBuf),
8071            edgefirst_tensor::CpuAccess::ReadWrite,
8072        )
8073        .unwrap();
8074        let (result, src_back, dst) = convert_img(
8075            &mut proc,
8076            src,
8077            dst,
8078            Rotation::None,
8079            Flip::None,
8080            Crop::no_crop(),
8081        );
8082        result.expect("GREY(R8 IOSurface) → RGBA must convert on ANGLE (R8 binding works)");
8083
8084        let src_stride = src_back
8085            .as_typed::<u8>()
8086            .unwrap()
8087            .effective_row_stride()
8088            .unwrap();
8089        let src_map = src_back.as_typed::<u8>().unwrap().map().unwrap();
8090        let sbytes = src_map.as_slice();
8091        let dst_stride = dst
8092            .as_typed::<u8>()
8093            .unwrap()
8094            .effective_row_stride()
8095            .unwrap();
8096        let dst_map = dst.as_typed::<u8>().unwrap().map().unwrap();
8097        let dbytes = dst_map.as_slice();
8098        for y in 0..h {
8099            for x in 0..w {
8100                let yv = sbytes[y * src_stride + x] as i16;
8101                let p = y * dst_stride + x * 4;
8102                for c in 0..3 {
8103                    assert!(
8104                        (dbytes[p + c] as i16 - yv).abs() <= 2,
8105                        "pixel ({x},{y}) ch{c} = {} expected ~{yv} (GREY→RGB identity)",
8106                        dbytes[p + c]
8107                    );
8108                }
8109            }
8110        }
8111    }
8112
8113    /// Two-pass GPU chain: NV12 (R8 IOSurface) → PlanarRgb F16, the profiler's
8114    /// preprocess. Verifies the chained `convert_nv_to_planar_float`
8115    /// (NV12→RGBA8 then the verified RGBA8→PlanarRgb F16) executes on ANGLE and
8116    /// produces a sane F16 planar result: a neutral-grey NV12 input (Y=U=V=128,
8117    /// BT.601 full ⇒ RGB≈0.5) must yield all three planes ≈0.5 (half-float).
8118    #[test]
8119    #[cfg(target_os = "macos")]
8120    #[cfg(feature = "opengl")]
8121    fn test_nv12_to_planar_f16_two_pass_opengl_macos() {
8122        let mut gpu = match GLProcessorThreaded::new(None) {
8123            Ok(p) => p,
8124            Err(e) => {
8125                crate::test_support::report_skip(&format!("{} — init failed ({e:?})", function!()));
8126                return;
8127            }
8128        };
8129        let (w, h) = (64usize, 64usize);
8130        let src = match TensorDyn::image(
8131            w,
8132            h,
8133            PixelFormat::Nv12,
8134            DType::U8,
8135            Some(TensorMemory::DmaBuf),
8136            edgefirst_tensor::CpuAccess::ReadWrite,
8137        ) {
8138            Ok(t) => t,
8139            Err(e) => {
8140                crate::test_support::report_skip(&format!(
8141                    "{} — NV12 IOSurface alloc: {e:?}",
8142                    function!()
8143                ));
8144                return;
8145            }
8146        };
8147        src.as_typed::<u8>()
8148            .unwrap()
8149            .map()
8150            .unwrap()
8151            .as_mut_slice()
8152            .fill(128); // Y=U=V=128
8153
8154        let dst = match TensorDyn::image(
8155            w,
8156            h,
8157            PixelFormat::PlanarRgb,
8158            DType::F16,
8159            Some(TensorMemory::DmaBuf),
8160            edgefirst_tensor::CpuAccess::ReadWrite,
8161        ) {
8162            Ok(t) => t,
8163            Err(e) => {
8164                crate::test_support::report_skip(&format!(
8165                    "{} — F16 PlanarRgb IOSurface: {e:?}",
8166                    function!()
8167                ));
8168                return;
8169            }
8170        };
8171        let mut dst = dst;
8172        // Call convert directly (the convert_img helper restores u8 only).
8173        if let Err(e) = ImageProcessorTrait::convert(
8174            &mut gpu,
8175            &src,
8176            &mut dst,
8177            Rotation::None,
8178            Flip::None,
8179            Crop::no_crop(),
8180        ) {
8181            // GL_EXT_color_buffer_half_float may be absent on some configs; the
8182            // RGBA8 pass-1 + F16 pass-2 path then can't render. Skip rather than
8183            // fail on a capability gap (the same policy as the F16 path tests).
8184            crate::test_support::report_skip(&format!(
8185                "{} — NV12→PlanarRgb F16 not available ({e:?})",
8186                function!()
8187            ));
8188            return;
8189        }
8190        let dt = dst.as_typed::<half::f16>().expect("dst is F16 PlanarRgb");
8191        let map = dt.map().unwrap();
8192        let vals = map.as_slice();
8193        // Neutral grey → ~0.5 in every plane. Allow generous tolerance for the
8194        // mediump YUV math + half-float rounding.
8195        let mut checked = 0usize;
8196        for &v in vals.iter() {
8197            let f = f32::from(v);
8198            assert!(
8199                (0.40..=0.60).contains(&f),
8200                "planar F16 value {f} not ~0.5 for neutral-grey NV12"
8201            );
8202            checked += 1;
8203        }
8204        assert!(
8205            checked >= w * h * 3,
8206            "expected >= 3 planes of samples, got {checked}"
8207        );
8208    }
8209
8210    /// Profiler-shaped two-pass: a reused **R8/Grey pool** (allocated larger
8211    /// than the frame, the NV24 worst case `3·H`) is reconfigured to an NV12
8212    /// frame, filled at the preserved physical stride, and converted with a
8213    /// letterbox `src_rect` crop into a model-sized PlanarRgb F16 destination —
8214    /// exactly the orchestrator's preprocess. Guards against the pooled
8215    /// two-pass NV→PlanarRgb F16 path hanging/erroring (the exact-size
8216    /// `test_nv12_to_planar_f16_two_pass` never exercised the larger pool).
8217    #[test]
8218    #[cfg(target_os = "macos")]
8219    #[cfg(feature = "opengl")]
8220    fn test_nv12_to_planar_f16_two_pass_pool_opengl_macos() {
8221        let mut gpu = match GLProcessorThreaded::new(None) {
8222            Ok(p) => p,
8223            Err(e) => {
8224                crate::test_support::report_skip(&format!("{} — init failed ({e:?})", function!()));
8225                return;
8226            }
8227        };
8228        // Frame 96×64 in a 256×768 R8 pool (3·256 height; bpr padded past 96).
8229        let (fw, fh) = (96usize, 64usize);
8230        let (pool_w, pool_h) = (256usize, 768usize);
8231        let (model_w, model_h) = (128usize, 128usize);
8232
8233        let mut src = match TensorDyn::image(
8234            pool_w,
8235            pool_h,
8236            PixelFormat::Grey,
8237            DType::U8,
8238            Some(TensorMemory::DmaBuf),
8239            edgefirst_tensor::CpuAccess::ReadWrite,
8240        ) {
8241            Ok(t) => t,
8242            Err(e) => {
8243                crate::test_support::report_skip(&format!(
8244                    "{} — R8 pool alloc: {e:?}",
8245                    function!()
8246                ));
8247                return;
8248            }
8249        };
8250        src.configure_image(fw, fh, PixelFormat::Nv12)
8251            .unwrap_or_else(|e| panic!("configure_image NV12 on pool: {e}"));
8252        let stride = src
8253            .as_typed::<u8>()
8254            .unwrap()
8255            .effective_row_stride()
8256            .unwrap();
8257        src.as_typed::<u8>()
8258            .unwrap()
8259            .map()
8260            .unwrap()
8261            .as_mut_slice()
8262            .fill(128); // neutral grey
8263
8264        let mut dst = match TensorDyn::image(
8265            model_w,
8266            model_h,
8267            PixelFormat::PlanarRgb,
8268            DType::F16,
8269            Some(TensorMemory::DmaBuf),
8270            edgefirst_tensor::CpuAccess::ReadWrite,
8271        ) {
8272            Ok(t) => t,
8273            Err(e) => {
8274                crate::test_support::report_skip(&format!(
8275                    "{} — F16 PlanarRgb dst: {e:?}",
8276                    function!()
8277                ));
8278                return;
8279            }
8280        };
8281
8282        // Letterbox crop like the profiler: the backend computes the band.
8283        let _ = model_w;
8284        let crop = Crop::new()
8285            .with_source(Some(Region::new(0, 0, fw, fh)))
8286            .with_fit(Fit::Letterbox {
8287                pad: [0, 0, 0, 255],
8288            });
8289        if let Err(e) =
8290            ImageProcessorTrait::convert(&mut gpu, &src, &mut dst, Rotation::None, Flip::None, crop)
8291        {
8292            crate::test_support::report_skip(&format!(
8293                "{} — NV12→PlanarRgb F16 unavailable ({e:?})",
8294                function!()
8295            ));
8296            return;
8297        }
8298        let _ = stride;
8299        // Neutral grey → ~0.5 inside the letterbox band; just assert the convert
8300        // completed and produced finite values (no hang, no NaN garbage).
8301        let dt = dst.as_typed::<half::f16>().expect("dst F16");
8302        let map = dt.map().unwrap();
8303        let any_half = map.as_slice().iter().any(|&v| {
8304            let f = f32::from(v);
8305            (0.40..=0.60).contains(&f)
8306        });
8307        assert!(any_half, "expected ~0.5 grey samples in the letterbox band");
8308    }
8309
8310    /// Mirrors the orchestrator: the GL processor is created on one thread
8311    /// and `convert()` is called from a *different* thread (the profiler's
8312    /// Pre-processing worker). Reproduces (or rules out) the GL-context /
8313    /// `glFinish` cross-thread hang seen in the live pipeline. A 20 s watchdog
8314    /// fails loudly rather than hanging the whole test binary.
8315    #[test]
8316    #[cfg(target_os = "macos")]
8317    #[cfg(feature = "opengl")]
8318    fn test_nv12_to_planar_f16_cross_thread_opengl_macos() {
8319        use std::sync::mpsc;
8320        // Public ImageProcessor (Send) created HERE (the main test thread),
8321        // exactly like the orchestrator builds `config.processor` during setup.
8322        let mut proc = match ImageProcessor::new() {
8323            Ok(p) => p,
8324            Err(e) => {
8325                crate::test_support::report_skip(&format!("{} — init failed ({e:?})", function!()));
8326                return;
8327            }
8328        };
8329        let (fw, fh) = (96usize, 64usize);
8330        let mut src = match TensorDyn::image(
8331            256,
8332            768,
8333            PixelFormat::Grey,
8334            DType::U8,
8335            Some(TensorMemory::DmaBuf),
8336            edgefirst_tensor::CpuAccess::ReadWrite,
8337        ) {
8338            Ok(t) => t,
8339            Err(e) => {
8340                crate::test_support::report_skip(&format!("{} — pool: {e:?}", function!()));
8341                return;
8342            }
8343        };
8344        src.configure_image(fw, fh, PixelFormat::Nv12).unwrap();
8345        src.as_typed::<u8>()
8346            .unwrap()
8347            .map()
8348            .unwrap()
8349            .as_mut_slice()
8350            .fill(128);
8351        let mut dst = match TensorDyn::image(
8352            128,
8353            128,
8354            PixelFormat::PlanarRgb,
8355            DType::F16,
8356            Some(TensorMemory::DmaBuf),
8357            edgefirst_tensor::CpuAccess::ReadWrite,
8358        ) {
8359            Ok(t) => t,
8360            Err(e) => {
8361                crate::test_support::report_skip(&format!("{} — dst: {e:?}", function!()));
8362                return;
8363            }
8364        };
8365        let crop = Crop::new().with_source(Some(Region::new(0, 0, fw, fh)));
8366
8367        // ...then MOVED to a worker thread where convert() runs — exactly the
8368        // orchestrator's create-on-setup / convert-on-Pre-processing split.
8369        let (tx, rx) = mpsc::channel::<bool>();
8370        let worker = std::thread::spawn(move || {
8371            let _ = ImageProcessorTrait::convert(
8372                &mut proc,
8373                &src,
8374                &mut dst,
8375                Rotation::None,
8376                Flip::None,
8377                crop,
8378            );
8379            let _ = tx.send(true);
8380        });
8381        match rx.recv_timeout(std::time::Duration::from_secs(20)) {
8382            Ok(_) => { let _ = worker.join(); }
8383            Err(_) => panic!(
8384                "cross-thread NV12→PlanarRgb convert HUNG (>20s) — reproduces the orchestrator deadlock"
8385            ),
8386        }
8387    }
8388
8389    /// Reproduces the profiler's progressive-slowdown/hang: one processor
8390    /// converting many **varying-size** NV frames (like a COCO dataset) from a
8391    /// reused R8 pool into a fixed PlanarRgb F16 model input. The two-pass path
8392    /// reallocated its RGBA intermediate per frame-size, churning/leaking
8393    /// pbuffers until the GPU stalled. Asserts per-convert latency stays bounded
8394    /// (no runaway) over many iterations.
8395    #[test]
8396    #[cfg(target_os = "macos")]
8397    #[cfg(feature = "opengl")]
8398    fn test_nv_to_planar_f16_varying_sizes_no_leak_opengl_macos() {
8399        let mut gpu = match GLProcessorThreaded::new(None) {
8400            Ok(p) => p,
8401            Err(e) => {
8402                crate::test_support::report_skip(&format!("{} — init failed ({e:?})", function!()));
8403                return;
8404            }
8405        };
8406        // Mirror the orchestrator's ring buffers at depth 4: a pool of source R8
8407        // tensors AND a pool of PlanarRgb F16 dst slots, both cycled per frame.
8408        let (max_w, max_h) = (640usize, 640usize);
8409        let depth = 4usize;
8410        let mut srcs = Vec::new();
8411        let mut dsts = Vec::new();
8412        for _ in 0..depth {
8413            srcs.push(
8414                match TensorDyn::image(
8415                    max_w,
8416                    max_h * 3,
8417                    PixelFormat::Grey,
8418                    DType::U8,
8419                    Some(TensorMemory::DmaBuf),
8420                    edgefirst_tensor::CpuAccess::ReadWrite,
8421                ) {
8422                    Ok(t) => t,
8423                    Err(e) => {
8424                        crate::test_support::report_skip(&format!("{} — pool: {e:?}", function!()));
8425                        return;
8426                    }
8427                },
8428            );
8429            dsts.push(
8430                match TensorDyn::image(
8431                    640,
8432                    640,
8433                    PixelFormat::PlanarRgb,
8434                    DType::F16,
8435                    Some(TensorMemory::DmaBuf),
8436                    edgefirst_tensor::CpuAccess::ReadWrite,
8437                ) {
8438                    Ok(t) => t,
8439                    Err(e) => {
8440                        crate::test_support::report_skip(&format!("{} — dst: {e:?}", function!()));
8441                        return;
8442                    }
8443                },
8444            );
8445        }
8446        // COCO-like assorted frame sizes (all ≤ max), cycled.
8447        let sizes = [
8448            (640, 480),
8449            (500, 375),
8450            (640, 427),
8451            (333, 500),
8452            (480, 640),
8453            (612, 612),
8454            (428, 640),
8455            (576, 432),
8456        ];
8457        let mut first_ms = 0f64;
8458        let mut last_ms = 0f64;
8459        let iters = 40usize;
8460        for i in 0..iters {
8461            let (fw, fh) = sizes[i % sizes.len()];
8462            let src = &mut srcs[i % depth];
8463            let dst = &mut dsts[i % depth];
8464            src.configure_image(fw, fh, PixelFormat::Nv24).unwrap();
8465            src.as_typed::<u8>()
8466                .unwrap()
8467                .map()
8468                .unwrap()
8469                .as_mut_slice()
8470                .fill(128);
8471            let crop = Crop::new().with_source(Some(Region::new(0, 0, fw, fh)));
8472            let t0 = std::time::Instant::now();
8473            ImageProcessorTrait::convert(&mut gpu, src, dst, Rotation::None, Flip::None, crop)
8474                .unwrap_or_else(|e| panic!("convert iter {i} ({fw}×{fh}): {e}"));
8475            let ms = t0.elapsed().as_secs_f64() * 1e3;
8476            if i == 2 {
8477                first_ms = ms;
8478            }
8479            if i == iters - 1 {
8480                last_ms = ms;
8481            }
8482        }
8483        eprintln!("first={first_ms:.2}ms last={last_ms:.2}ms");
8484        assert!(
8485            last_ms < first_ms * 5.0 + 5.0,
8486            "convert latency ran away: first {first_ms:.2}ms → last {last_ms:.2}ms (intermediate/pbuffer leak)"
8487        );
8488    }
8489
8490    /// Step-2 verification: NV12/NV16/NV24 (R8 IOSurface) → RGBA on the GPU
8491    /// must match the CPU `yuv` kernels within shader rounding. Fills an
8492    /// IOSurface source and a Mem source from the same logical YUV pattern
8493    /// (each at its own row stride), converts the IOSurface on the GPU and the
8494    /// Mem one on the CPU, and compares. Exercises the in-shader semi-planar
8495    /// addressing for all three subsamplings (incl. NV24's 2×-wide UV rows).
8496    #[test]
8497    #[cfg(target_os = "macos")]
8498    #[cfg(feature = "opengl")]
8499    fn test_nv12_nv16_nv24_to_rgba_opengl_macos() {
8500        let mut gpu = match GLProcessorThreaded::new(None) {
8501            Ok(p) => p,
8502            Err(e) => {
8503                crate::test_support::report_skip(&format!(
8504                    "{} — GL engine init failed ({e:?})",
8505                    function!()
8506                ));
8507                return;
8508            }
8509        };
8510        let mut cpu = CPUProcessor::new();
8511
8512        // Fill Y plus the interleaved UV plane in the canonical semi-planar
8513        // layout — exactly what the codec writes and the CPU `yuv`-crate reader
8514        // expects: the Y plane is `h` rows at the buffer's row stride; the UV
8515        // plane starts at `h * stride`; each chroma row advances
8516        // `uv_grid_rows * stride` bytes (NV24 carries a full-resolution `2*W`
8517        // byte line == two grid rows, NV12/NV16 one row of `W/2` pairs); each
8518        // (Cb,Cr) pair is two consecutive bytes at column `cx * 2`. This is
8519        // stride-correct for both the tight Mem buffer and the padded IOSurface.
8520        //
8521        // Takes explicit w/h so the closure can be reused across multiple frame
8522        // sizes (even and odd) without capturing a fixed outer variable.
8523        let fill = |buf: &mut [u8], stride: usize, fmt: PixelFormat, w: usize, h: usize| {
8524            for y in 0..h {
8525                for x in 0..w {
8526                    buf[y * stride + x] = ((x * 9 + y * 5) & 0xff) as u8;
8527                }
8528            }
8529            let (cw, ch, uv_grid_rows) = match fmt {
8530                PixelFormat::Nv12 => (w / 2, h / 2, 1usize),
8531                PixelFormat::Nv16 => (w / 2, h, 1usize),
8532                _ => (w, h, 2usize), // Nv24: full-res chroma, 2W bytes/row
8533            };
8534            let uv_plane = h * stride;
8535            for cy in 0..ch {
8536                for cx in 0..cw {
8537                    let off = uv_plane + cy * uv_grid_rows * stride + cx * 2;
8538                    buf[off] = ((cx * 11 + 30) & 0xff) as u8;
8539                    buf[off + 1] = ((cy * 7 + 200) & 0xff) as u8;
8540                }
8541            }
8542        };
8543
8544        for fmt in [PixelFormat::Nv12, PixelFormat::Nv16, PixelFormat::Nv24] {
8545            for (w, h) in [
8546                (16usize, 16usize), // original even-dim case
8547                (15, 16),           // odd-W
8548                (16, 15),           // odd-H
8549            ] {
8550                let mem = TensorDyn::image(
8551                    w,
8552                    h,
8553                    fmt,
8554                    DType::U8,
8555                    None,
8556                    edgefirst_tensor::CpuAccess::ReadWrite,
8557                )
8558                .unwrap();
8559                let mem_stride = mem
8560                    .as_typed::<u8>()
8561                    .unwrap()
8562                    .effective_row_stride()
8563                    .unwrap();
8564                fill(
8565                    mem.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice(),
8566                    mem_stride,
8567                    fmt,
8568                    w,
8569                    h,
8570                );
8571                let cpu_dst = TensorDyn::image(
8572                    w,
8573                    h,
8574                    PixelFormat::Rgba,
8575                    DType::U8,
8576                    None,
8577                    edgefirst_tensor::CpuAccess::ReadWrite,
8578                )
8579                .unwrap();
8580                let (r, _s, cpu_dst) = convert_img(
8581                    &mut cpu,
8582                    mem,
8583                    cpu_dst,
8584                    Rotation::None,
8585                    Flip::None,
8586                    Crop::no_crop(),
8587                );
8588                r.unwrap_or_else(|e| panic!("CPU {fmt:?}->{w}x{h}->RGBA: {e}"));
8589
8590                let ios = TensorDyn::image(
8591                    w,
8592                    h,
8593                    fmt,
8594                    DType::U8,
8595                    Some(TensorMemory::DmaBuf),
8596                    edgefirst_tensor::CpuAccess::ReadWrite,
8597                )
8598                .unwrap_or_else(|e| panic!("{fmt:?} {w}x{h} IOSurface alloc: {e}"));
8599                let ios_stride = ios
8600                    .as_typed::<u8>()
8601                    .unwrap()
8602                    .effective_row_stride()
8603                    .unwrap();
8604                fill(
8605                    ios.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice(),
8606                    ios_stride,
8607                    fmt,
8608                    w,
8609                    h,
8610                );
8611                let gpu_dst = TensorDyn::image(
8612                    w,
8613                    h,
8614                    PixelFormat::Rgba,
8615                    DType::U8,
8616                    Some(TensorMemory::DmaBuf),
8617                    edgefirst_tensor::CpuAccess::ReadWrite,
8618                )
8619                .unwrap();
8620                let (r, _s, gpu_dst) = convert_img(
8621                    &mut gpu,
8622                    ios,
8623                    gpu_dst,
8624                    Rotation::None,
8625                    Flip::None,
8626                    Crop::no_crop(),
8627                );
8628                r.unwrap_or_else(|e| panic!("GPU {fmt:?}->{w}x{h}->RGBA on ANGLE: {e}"));
8629
8630                let cs = cpu_dst
8631                    .as_typed::<u8>()
8632                    .unwrap()
8633                    .effective_row_stride()
8634                    .unwrap();
8635                let cmap = cpu_dst.as_typed::<u8>().unwrap().map().unwrap();
8636                let cb = cmap.as_slice();
8637                let gs = gpu_dst
8638                    .as_typed::<u8>()
8639                    .unwrap()
8640                    .effective_row_stride()
8641                    .unwrap();
8642                let gmap = gpu_dst.as_typed::<u8>().unwrap().map().unwrap();
8643                let gb = gmap.as_slice();
8644                let mut max_d = 0i16;
8645                for y in 0..h {
8646                    for x in 0..w {
8647                        for c in 0..3 {
8648                            let cv = cb[y * cs + x * 4 + c] as i16;
8649                            let gv = gb[y * gs + x * 4 + c] as i16;
8650                            max_d = max_d.max((cv - gv).abs());
8651                        }
8652                    }
8653                }
8654                assert!(
8655                    max_d <= 3,
8656                    "{fmt:?} {w}x{h}: GPU vs CPU RGBA max channel diff {max_d} > 3"
8657                );
8658            }
8659        }
8660    }
8661
8662    /// Phase-0 gate: the reused-pool / larger-surface case. A single R8 pool
8663    /// IOSurface (allocated bigger than the frame, so its `bytesPerRow` exceeds
8664    /// the frame's even width) is reconfigured to each NV frame, filled at the
8665    /// preserved physical stride, and converted on the GPU. This proves ANGLE
8666    /// binds the *whole* physical surface as a pbuffer and that `texelFetch`
8667    /// resolves the frame's Y/UV texels through the surface's real `bytesPerRow`
8668    /// (the physical-grid / logical-ROI decoupling). GPU must match CPU ≤3 LSB.
8669    #[test]
8670    #[cfg(target_os = "macos")]
8671    #[cfg(feature = "opengl")]
8672    fn test_nv_to_rgba_larger_pool_surface_opengl_macos() {
8673        let mut gpu = match GLProcessorThreaded::new(None) {
8674            Ok(p) => p,
8675            Err(e) => {
8676                crate::test_support::report_skip(&format!(
8677                    "{} — GL engine init failed ({e:?})",
8678                    function!()
8679                ));
8680                return;
8681            }
8682        };
8683        let mut cpu = CPUProcessor::new();
8684        // The pool is generously oversized (256-wide → bpr 256, well beyond any
8685        // frame's even width) and tall enough for NV24's 3·H at the test frames.
8686        let (pool_w, pool_h) = (256usize, 256usize);
8687
8688        // Canonical semi-planar fill: Y plane at the row stride, then the UV
8689        // plane at `h * stride` with each chroma row advancing `uv_grid_rows *
8690        // stride` bytes. Stride-correct for both tight Mem and padded IOSurface.
8691        let fill = |buf: &mut [u8], stride: usize, fmt: PixelFormat, w: usize, h: usize| {
8692            for y in 0..h {
8693                for x in 0..w {
8694                    buf[y * stride + x] = ((x * 9 + y * 5) & 0xff) as u8;
8695                }
8696            }
8697            let (cw, ch, uv_grid_rows) = match fmt {
8698                PixelFormat::Nv12 => (w / 2, h / 2, 1usize),
8699                PixelFormat::Nv16 => (w / 2, h, 1usize),
8700                _ => (w, h, 2usize), // Nv24: full-res chroma, 2W bytes/row
8701            };
8702            let uv_plane = h * stride;
8703            for cy in 0..ch {
8704                for cx in 0..cw {
8705                    let off = uv_plane + cy * uv_grid_rows * stride + cx * 2;
8706                    buf[off] = ((cx * 11 + 30) & 0xff) as u8;
8707                    buf[off + 1] = ((cy * 7 + 200) & 0xff) as u8;
8708                }
8709            }
8710        };
8711
8712        for fmt in [PixelFormat::Nv12, PixelFormat::Nv16, PixelFormat::Nv24] {
8713            for (w, h) in [
8714                (40usize, 24usize), // original even-dim case
8715                (15, 16),           // odd-W
8716                (16, 15),           // odd-H
8717            ] {
8718                // `ew` is the minimum even extent; the pool stride must exceed it
8719                // to actually exercise the physical-stride shader decoupling.
8720                let ew = w.next_multiple_of(2);
8721
8722                // CPU reference from a tightly-packed Mem tensor at the frame size.
8723                let mem = TensorDyn::image(
8724                    w,
8725                    h,
8726                    fmt,
8727                    DType::U8,
8728                    None,
8729                    edgefirst_tensor::CpuAccess::ReadWrite,
8730                )
8731                .unwrap();
8732                let mem_stride = mem
8733                    .as_typed::<u8>()
8734                    .unwrap()
8735                    .effective_row_stride()
8736                    .unwrap();
8737                fill(
8738                    mem.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice(),
8739                    mem_stride,
8740                    fmt,
8741                    w,
8742                    h,
8743                );
8744                let cpu_dst = TensorDyn::image(
8745                    w,
8746                    h,
8747                    PixelFormat::Rgba,
8748                    DType::U8,
8749                    None,
8750                    edgefirst_tensor::CpuAccess::ReadWrite,
8751                )
8752                .unwrap();
8753                let (r, _s, cpu_dst) = convert_img(
8754                    &mut cpu,
8755                    mem,
8756                    cpu_dst,
8757                    Rotation::None,
8758                    Flip::None,
8759                    Crop::no_crop(),
8760                );
8761                r.unwrap_or_else(|e| panic!("CPU {fmt:?}->{w}x{h}->RGBA: {e}"));
8762
8763                // GPU source: a LARGER R8 pool surface, reconfigured down to the
8764                // frame. Phase 1 preserves the pool's padded `bytesPerRow` as the
8765                // tensor's row stride; the fill writes the frame at that stride.
8766                let mut ios = match TensorDyn::image(
8767                    pool_w,
8768                    pool_h,
8769                    PixelFormat::Grey,
8770                    DType::U8,
8771                    Some(TensorMemory::DmaBuf),
8772                    edgefirst_tensor::CpuAccess::ReadWrite,
8773                ) {
8774                    Ok(t) => t,
8775                    Err(e) => {
8776                        crate::test_support::report_skip(&format!(
8777                            "{} — R8 pool IOSurface alloc: {e:?}",
8778                            function!()
8779                        ));
8780                        return;
8781                    }
8782                };
8783                ios.configure_image(w, h, fmt)
8784                    .unwrap_or_else(|e| panic!("configure_image {fmt:?} {w}x{h} on pool: {e}"));
8785                let ios_stride = ios
8786                    .as_typed::<u8>()
8787                    .unwrap()
8788                    .effective_row_stride()
8789                    .unwrap();
8790                assert!(
8791                    ios_stride > ew,
8792                    "{fmt:?} {w}x{h}: pool stride {ios_stride} should exceed even width {ew} \
8793                     (test must exercise padding)"
8794                );
8795                fill(
8796                    ios.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice(),
8797                    ios_stride,
8798                    fmt,
8799                    w,
8800                    h,
8801                );
8802
8803                let gpu_dst = TensorDyn::image(
8804                    w,
8805                    h,
8806                    PixelFormat::Rgba,
8807                    DType::U8,
8808                    Some(TensorMemory::DmaBuf),
8809                    edgefirst_tensor::CpuAccess::ReadWrite,
8810                )
8811                .unwrap();
8812                let (r, _s, gpu_dst) = convert_img(
8813                    &mut gpu,
8814                    ios,
8815                    gpu_dst,
8816                    Rotation::None,
8817                    Flip::None,
8818                    Crop::no_crop(),
8819                );
8820                r.unwrap_or_else(|e| {
8821                    panic!("GPU {fmt:?}->{w}x{h}->RGBA (pool surface) on ANGLE: {e}")
8822                });
8823
8824                let cs = cpu_dst
8825                    .as_typed::<u8>()
8826                    .unwrap()
8827                    .effective_row_stride()
8828                    .unwrap();
8829                let cmap = cpu_dst.as_typed::<u8>().unwrap().map().unwrap();
8830                let cb = cmap.as_slice();
8831                let gs = gpu_dst
8832                    .as_typed::<u8>()
8833                    .unwrap()
8834                    .effective_row_stride()
8835                    .unwrap();
8836                let gmap = gpu_dst.as_typed::<u8>().unwrap().map().unwrap();
8837                let gb = gmap.as_slice();
8838                let mut max_d = 0i16;
8839                for y in 0..h {
8840                    for x in 0..w {
8841                        for c in 0..3 {
8842                            let cv = cb[y * cs + x * 4 + c] as i16;
8843                            let gv = gb[y * gs + x * 4 + c] as i16;
8844                            max_d = max_d.max((cv - gv).abs());
8845                        }
8846                    }
8847                }
8848                assert!(
8849                    max_d <= 3,
8850                    "{fmt:?} {w}x{h}: GPU(pool surface) vs CPU RGBA max channel diff {max_d} > 3"
8851                );
8852            }
8853        }
8854    }
8855
8856    #[test]
8857    #[cfg(target_os = "macos")]
8858    #[cfg(feature = "opengl")]
8859    fn test_yuyv_to_rgba_opengl_macos() {
8860        let mut proc = match GLProcessorThreaded::new(None) {
8861            Ok(p) => p,
8862            Err(e) => {
8863                crate::test_support::report_skip(&format!(
8864                    "{} — GL engine init failed ({e:?}). \
8865                     Install ANGLE via `brew install startergo/angle/angle` \
8866                     and re-sign per README.md § macOS GPU Acceleration to \
8867                     run this test.",
8868                    function!()
8869                ));
8870                return;
8871            }
8872        };
8873
8874        let src = load_bytes_to_tensor(
8875            1280,
8876            720,
8877            PixelFormat::Yuyv,
8878            Some(TensorMemory::DmaBuf),
8879            &edgefirst_bench::testdata::read("camera720p.yuyv"),
8880        )
8881        .unwrap();
8882
8883        let dst = TensorDyn::image(
8884            1280,
8885            720,
8886            PixelFormat::Rgba,
8887            DType::U8,
8888            Some(TensorMemory::DmaBuf),
8889            edgefirst_tensor::CpuAccess::ReadWrite,
8890        )
8891        .unwrap();
8892
8893        let (result, _src, dst) = convert_img(
8894            &mut proc,
8895            src,
8896            dst,
8897            Rotation::None,
8898            Flip::None,
8899            Crop::no_crop(),
8900        );
8901        result.unwrap();
8902
8903        let target_image = TensorDyn::image(
8904            1280,
8905            720,
8906            PixelFormat::Rgba,
8907            DType::U8,
8908            None,
8909            edgefirst_tensor::CpuAccess::ReadWrite,
8910        )
8911        .unwrap();
8912        target_image
8913            .as_typed::<u8>()
8914            .unwrap()
8915            .map()
8916            .unwrap()
8917            .as_mut_slice()
8918            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
8919
8920        // macOS YUYV shader now threads per-tensor colorimetry: the untagged
8921        // camera720p source resolves to BT.709 limited (matching the BT.709
8922        // reference), measured 0.9973 on ANGLE — up from 0.9733 under the old
8923        // BT.601-full stop-gap. 0.98 leaves headroom for cross-GPU variance.
8924        compare_images(&dst, &target_image, 0.98, function!());
8925    }
8926
8927    /// Multi-resolution smoke test: convert YUYV→RGBA via the GL
8928    /// backend at a small (64×32) frame and a 4K (3840×2160) frame,
8929    /// both filled with a synthetic mid-grey pattern. Validates the
8930    /// shader math at the chroma-pairing boundary on small textures
8931    /// and exercises the IOSurface bytes-per-row alignment path at 4K
8932    /// (3840 pixels × 2 bytes/pixel = 7680 bytes, naturally 64-aligned).
8933    ///
8934    /// Resolutions below 32 pixels wide aren't tested because the
8935    /// IOSurface allocator pads bpr to 64 bytes — for a 4-px-wide
8936    /// YUYV surface that's 8 bytes data + 56 bytes padding per row,
8937    /// which exercises a sampling pattern that's ANGLE-version
8938    /// dependent rather than HAL-correctness dependent.
8939    ///
8940    /// This complements `test_yuyv_to_rgba_opengl_macos` (which checks
8941    /// pixel-exact correctness against a reference image at 720p) by
8942    /// ensuring the pipeline does not crash or produce gross errors at
8943    /// resolution extremes. Pixel-exact validation at 4K would require
8944    /// a 30 MB reference file we don't want to bundle.
8945    #[test]
8946    #[cfg(target_os = "macos")]
8947    #[cfg(feature = "opengl")]
8948    fn test_yuyv_to_rgba_opengl_macos_multi_resolution() {
8949        let mut proc = match GLProcessorThreaded::new(None) {
8950            Ok(p) => p,
8951            Err(e) => {
8952                crate::test_support::report_skip(&format!(
8953                    "{} — GL engine init failed ({e:?})",
8954                    function!()
8955                ));
8956                return;
8957            }
8958        };
8959
8960        for (w, h) in [(64usize, 32usize), (3840, 2160)] {
8961            // Synthetic YUYV: Y=128 (mid-grey luma), U=V=128 (neutral
8962            // chroma) → RGB grey at the output.
8963            let bytes_per_row = w * 2;
8964            let mut yuyv = vec![0u8; bytes_per_row * h];
8965            for chunk in yuyv.as_chunks_mut::<4>().0 {
8966                chunk[0] = 128; // Y0
8967                chunk[1] = 128; // U
8968                chunk[2] = 128; // Y1
8969                chunk[3] = 128; // V
8970            }
8971
8972            let src =
8973                load_bytes_to_tensor(w, h, PixelFormat::Yuyv, Some(TensorMemory::DmaBuf), &yuyv)
8974                    .unwrap();
8975
8976            let dst = TensorDyn::image(
8977                w,
8978                h,
8979                PixelFormat::Rgba,
8980                DType::U8,
8981                Some(TensorMemory::DmaBuf),
8982                edgefirst_tensor::CpuAccess::ReadWrite,
8983            )
8984            .unwrap();
8985
8986            let (result, _src, dst) = convert_img(
8987                &mut proc,
8988                src,
8989                dst,
8990                Rotation::None,
8991                Flip::None,
8992                Crop::no_crop(),
8993            );
8994            result.expect("GL convert should succeed at this resolution");
8995
8996            // The neutral-chroma input must produce a near-grey output;
8997            // BT.709 limited-range maps Y=128/UV=128 → roughly
8998            // (130, 130, 130). Allow ±4 LSB for `mediump float` shader
8999            // rounding.
9000            let dst_u8 = dst.as_typed::<u8>().unwrap();
9001            let dst_map = dst_u8.map().unwrap();
9002            let dst_bytes = dst_map.as_slice();
9003            assert_eq!(dst_bytes.len(), w * h * 4, "RGBA byte count");
9004            for px in dst_bytes.as_chunks::<4>().0 {
9005                for (i, &c) in px[..3].iter().enumerate() {
9006                    assert!(
9007                        (120..=140).contains(&c),
9008                        "{}: channel {i} = {c} (expected ~128 ±12) at {w}×{h}",
9009                        function!(),
9010                    );
9011                }
9012                assert_eq!(px[3], 255, "alpha must be 1.0");
9013            }
9014        }
9015    }
9016
9017    /// Verify that two consecutive convert() calls on the same source
9018    /// tensor reuse the cached EGL pbuffer. Tests the cache hit path
9019    /// added with the macOS GL backend hardening — without it, each
9020    /// frame would pay `eglCreatePbufferFromClientBuffer` + destroy.
9021    ///
9022    /// This is a behaviour test rather than a perf test (the timing
9023    /// difference is 100-200µs which is too noisy to assert on); we
9024    /// check that the second call succeeds and produces a result
9025    /// identical to the first.
9026    #[test]
9027    #[cfg(target_os = "macos")]
9028    #[cfg(feature = "opengl")]
9029    fn test_macos_gl_pbuffer_cache_reuses_surfaces() {
9030        let mut proc = match GLProcessorThreaded::new(None) {
9031            Ok(p) => p,
9032            Err(e) => {
9033                crate::test_support::report_skip(&format!(
9034                    "{} — GL engine init failed ({e:?})",
9035                    function!()
9036                ));
9037                return;
9038            }
9039        };
9040
9041        // Allocate one source + one destination, run convert twice.
9042        let mut yuyv = vec![0u8; 64 * 32 * 2];
9043        for chunk in yuyv.as_chunks_mut::<4>().0 {
9044            chunk[0] = 200;
9045            chunk[1] = 100;
9046            chunk[2] = 200;
9047            chunk[3] = 156;
9048        }
9049        let src =
9050            load_bytes_to_tensor(64, 32, PixelFormat::Yuyv, Some(TensorMemory::DmaBuf), &yuyv)
9051                .unwrap();
9052        let dst = TensorDyn::image(
9053            64,
9054            32,
9055            PixelFormat::Rgba,
9056            DType::U8,
9057            Some(TensorMemory::DmaBuf),
9058            edgefirst_tensor::CpuAccess::ReadWrite,
9059        )
9060        .unwrap();
9061
9062        let (r1, src, dst) = convert_img(
9063            &mut proc,
9064            src,
9065            dst,
9066            Rotation::None,
9067            Flip::None,
9068            Crop::no_crop(),
9069        );
9070        r1.unwrap();
9071        let first: Vec<u8> = dst
9072            .as_typed::<u8>()
9073            .unwrap()
9074            .map()
9075            .unwrap()
9076            .as_slice()
9077            .to_vec();
9078
9079        let (r2, _src, dst) = convert_img(
9080            &mut proc,
9081            src,
9082            dst,
9083            Rotation::None,
9084            Flip::None,
9085            Crop::no_crop(),
9086        );
9087        r2.unwrap();
9088        let second: Vec<u8> = dst
9089            .as_typed::<u8>()
9090            .unwrap()
9091            .map()
9092            .unwrap()
9093            .as_slice()
9094            .to_vec();
9095
9096        assert_eq!(first, second, "cache-hit conversion must be deterministic");
9097    }
9098
9099    /// Steady-state import gate (macOS half of the Linux
9100    /// `dma_pool_steady_state_zero_imports` test): an N-frame convert loop
9101    /// over a fixed pool of IOSurface tensors must create ZERO new EGL
9102    /// pbuffers after the pool has been seen once — pbuffer-cache misses
9103    /// stay flat while hits grow. Counter-based hardening of
9104    /// `test_macos_gl_pbuffer_cache_reuses_surfaces` above: a refactor that
9105    /// re-imports per frame passes the pixel-equality test but fails this.
9106    #[test]
9107    #[cfg(target_os = "macos")]
9108    #[cfg(feature = "opengl")]
9109    fn test_macos_gl_pbuffer_cache_steady_state() {
9110        let mut proc = match GLProcessorThreaded::new(None) {
9111            Ok(p) => p,
9112            Err(e) => {
9113                crate::test_support::report_skip(&format!(
9114                    "{} — GL engine init failed ({e:?})",
9115                    function!()
9116                ));
9117                return;
9118            }
9119        };
9120
9121        let (w, h) = (64usize, 32usize);
9122        const POOL: usize = 3;
9123        const FRAMES: usize = 100;
9124
9125        let yuyv = vec![128u8; w * h * 2];
9126        let pool: Vec<TensorDyn> = (0..POOL)
9127            .map(|_| {
9128                load_bytes_to_tensor(w, h, PixelFormat::Yuyv, Some(TensorMemory::DmaBuf), &yuyv)
9129                    .unwrap()
9130            })
9131            .collect();
9132        let mut dst = TensorDyn::image(
9133            w,
9134            h,
9135            PixelFormat::Rgba,
9136            DType::U8,
9137            Some(TensorMemory::DmaBuf),
9138            edgefirst_tensor::CpuAccess::ReadWrite,
9139        )
9140        .unwrap();
9141
9142        // Warmup: two passes over the pool import every surface once.
9143        for src in pool.iter().cycle().take(POOL * 2) {
9144            proc.convert(src, &mut dst, Rotation::None, Flip::None, Crop::no_crop())
9145                .unwrap();
9146        }
9147        let warm = proc.egl_cache_stats().unwrap();
9148
9149        for src in pool.iter().cycle().take(FRAMES) {
9150            proc.convert(src, &mut dst, Rotation::None, Flip::None, Crop::no_crop())
9151                .unwrap();
9152        }
9153        let steady = proc.egl_cache_stats().unwrap();
9154
9155        assert_eq!(
9156            warm.total_misses(),
9157            steady.total_misses(),
9158            "steady-state loop created new imports (warm {warm:?}, steady {steady:?})"
9159        );
9160        let hits = |s: &GlCacheStats| s.src.hits + s.dst.hits + s.nv_r8.hits;
9161        assert!(
9162            hits(&steady) - hits(&warm) >= FRAMES as u64,
9163            "expected at least {FRAMES} import-cache hits over the loop, got {}",
9164            hits(&steady) - hits(&warm)
9165        );
9166    }
9167
9168    /// Backend assertion for the F16 zero-copy path: when the GL backend
9169    /// initialized (ANGLE on macOS) and reports F16 color-buffer support,
9170    /// the NV12→PlanarRgb-F16 IOSurface convert MUST be handled by the GL
9171    /// engine — proven by the engine's import-cache counters moving, not
9172    /// just by output correctness. This is the guard against the
9173    /// silent-CPU-fallback failure mode: a misclassified IOSurface F16
9174    /// destination keeps every output-correctness test green while
9175    /// quietly running ~10× slower on CPU; only a backend observable
9176    /// catches it. Skips ONLY when GL itself is unavailable or the
9177    /// configuration lacks F16 — a convert error or a CPU-routed convert
9178    /// with the capability present is a FAILURE.
9179    #[test]
9180    #[cfg(target_os = "macos")]
9181    #[cfg(feature = "opengl")]
9182    fn test_macos_gl_f16_planar_is_gl_backed() {
9183        let mut proc = ImageProcessor::new().expect("ImageProcessor");
9184        let Some(ref gl) = proc.opengl else {
9185            crate::test_support::report_skip(&format!("{} — GL backend unavailable", function!()));
9186            return;
9187        };
9188        if !gl.supported_render_dtypes().f16 {
9189            crate::test_support::report_skip(&format!(
9190                "{} — configuration lacks F16 color-buffer support",
9191                function!()
9192            ));
9193            return;
9194        }
9195        let stats_before = gl.egl_cache_stats().expect("cache stats");
9196
9197        let src = TensorDyn::image(
9198            1280,
9199            720,
9200            PixelFormat::Nv12,
9201            DType::U8,
9202            Some(TensorMemory::DmaBuf),
9203            edgefirst_tensor::CpuAccess::ReadWrite,
9204        )
9205        .unwrap();
9206        {
9207            let t = src.as_typed::<u8>().unwrap();
9208            let mut m = t.map().unwrap();
9209            for (i, b) in m.as_mut_slice().iter_mut().enumerate() {
9210                *b = ((i * 31) % 211) as u8;
9211            }
9212        }
9213        let mut dst = TensorDyn::image(
9214            640,
9215            640,
9216            PixelFormat::PlanarRgb,
9217            DType::F16,
9218            Some(TensorMemory::DmaBuf),
9219            edgefirst_tensor::CpuAccess::ReadWrite,
9220        )
9221        .unwrap();
9222
9223        proc.convert(
9224            &src,
9225            &mut dst,
9226            Rotation::None,
9227            Flip::None,
9228            Crop::letterbox([114, 114, 114, 255]),
9229        )
9230        .expect("F16 capability reported but the NV12→PlanarF16 convert failed");
9231        let stats_after = proc
9232            .opengl
9233            .as_ref()
9234            .expect("GL backend present")
9235            .egl_cache_stats()
9236            .expect("cache stats");
9237        // ≥ 2 misses: the fused convert imports the zero-copy NV source
9238        // (pass 1) AND the F16 destination (pass 2). Requiring both means a
9239        // convert that imported its source, failed mid-engine, and fell back
9240        // to CPU (1 miss) cannot satisfy this gate.
9241        assert!(
9242            stats_after.total_misses() >= stats_before.total_misses() + 2,
9243            "convert succeeded but the GL engine did not import both the \
9244             source and the F16 destination — the work did not (fully) run \
9245             on the GL backend (silent CPU fallback); misses before={} after={}",
9246            stats_before.total_misses(),
9247            stats_after.total_misses()
9248        );
9249    }
9250
9251    /// Portable oracle for the fused NV12→PlanarRgb-F16 engine convert
9252    /// (two GL passes: NV→RGBA intermediate, then the packed RGBA16F
9253    /// render). New on every platform with F16 render support — macOS
9254    /// IOSurface and Linux DMA-BUF alike. Compares against the CPU
9255    /// backend's reference within the float-path tolerance.
9256    #[test]
9257    #[cfg(feature = "opengl")]
9258    fn test_nv12_to_planar_f16_fused_engine_vs_cpu() {
9259        let mut gl = match ImageProcessor::with_config(ImageProcessorConfig {
9260            backend: ComputeBackend::OpenGl,
9261            ..Default::default()
9262        }) {
9263            Ok(p) if p.opengl.is_some() => p,
9264            _ => {
9265                crate::test_support::report_skip(&format!(
9266                    "{} — GL backend unavailable",
9267                    function!()
9268                ));
9269                return;
9270            }
9271        };
9272        if !gl
9273            .opengl
9274            .as_ref()
9275            .map(|g| g.supported_render_dtypes().f16)
9276            .unwrap_or(false)
9277        {
9278            crate::test_support::report_skip(&format!("{} — no F16 render support", function!()));
9279            return;
9280        }
9281        let mem = if edgefirst_tensor::is_gpu_buffer_available() {
9282            TensorMemory::DmaBuf
9283        } else {
9284            crate::test_support::report_skip(&format!("{} — no zero-copy buffers", function!()));
9285            return;
9286        };
9287
9288        let src = TensorDyn::image(
9289            1280,
9290            720,
9291            PixelFormat::Nv12,
9292            DType::U8,
9293            Some(mem),
9294            edgefirst_tensor::CpuAccess::ReadWrite,
9295        )
9296        .unwrap();
9297        {
9298            // Smooth gradients, NOT noise: the GL and CPU paths upsample
9299            // chroma with different kernels (nearest vs bilinear), which
9300            // legitimately diverges on per-texel chroma noise. Gradients
9301            // keep that kernel difference sub-LSB while still exercising
9302            // the full matrix math and the letterbox geometry.
9303            let t = src.as_typed::<u8>().unwrap();
9304            let mut m = t.map().unwrap();
9305            let buf = m.as_mut_slice();
9306            let (w, h) = (1280usize, 720usize);
9307            for y in 0..h {
9308                for x in 0..w {
9309                    buf[y * w + x] = ((x * 255) / w) as u8; // luma ramp
9310                }
9311            }
9312            for y in 0..(h / 2) {
9313                for x in 0..(w / 2) {
9314                    let o = h * w + y * w + 2 * x;
9315                    buf[o] = ((y * 255) / (h / 2)) as u8; // U vertical ramp
9316                    buf[o + 1] = (((x + y) * 255) / (w / 2 + h / 2)) as u8; // V diagonal
9317                }
9318            }
9319        }
9320        let crop = Crop::letterbox([114, 114, 114, 255]);
9321        let mut gl_dst = TensorDyn::image(
9322            640,
9323            640,
9324            PixelFormat::PlanarRgb,
9325            DType::F16,
9326            Some(mem),
9327            edgefirst_tensor::CpuAccess::ReadWrite,
9328        )
9329        .unwrap();
9330        // Drive the GL backend DIRECTLY: a convert through `ImageProcessor`
9331        // silently falls back to CPU on a GL error, turning this oracle into
9332        // a CPU-vs-CPU tautology. A direct call surfaces the engine error.
9333        // GPUs that allocate DMA-BUF but cannot complete an RGBA16F FBO
9334        // (desktop NVIDIA: GL_FRAMEBUFFER_INCOMPLETE_ATTACHMENT 0x8cd6)
9335        // take the production CPU fallback; this oracle cannot run there.
9336        match gl.opengl.as_mut().expect("GL backend present").convert(
9337            &src,
9338            &mut gl_dst,
9339            Rotation::None,
9340            Flip::None,
9341            crop,
9342        ) {
9343            Ok(()) => {}
9344            Err(crate::Error::NotSupported(msg)) => {
9345                crate::test_support::report_skip(&format!("{} — {msg}", function!()));
9346                return;
9347            }
9348            Err(e) => panic!("fused NV12→PlanarF16 GL convert: {e}"),
9349        }
9350
9351        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
9352            backend: ComputeBackend::Cpu,
9353            ..Default::default()
9354        })
9355        .unwrap();
9356        let mut cpu_dst = TensorDyn::image(
9357            640,
9358            640,
9359            PixelFormat::PlanarRgb,
9360            DType::F16,
9361            Some(TensorMemory::Mem),
9362            edgefirst_tensor::CpuAccess::ReadWrite,
9363        )
9364        .unwrap();
9365        cpu.convert(&src, &mut cpu_dst, Rotation::None, Flip::None, crop)
9366            .expect("CPU reference convert");
9367
9368        let g = gl_dst
9369            .as_typed::<half::f16>()
9370            .unwrap()
9371            .map()
9372            .unwrap()
9373            .as_slice()
9374            .to_vec();
9375        let c = cpu_dst
9376            .as_typed::<half::f16>()
9377            .unwrap()
9378            .map()
9379            .unwrap()
9380            .as_slice()
9381            .to_vec();
9382        assert_eq!(g.len(), c.len());
9383        let mut max_diff = 0.0f32;
9384        let mut max_at = 0usize;
9385        for (i, (a, b)) in g.iter().zip(c.iter()).enumerate() {
9386            let d = (a.to_f32() - b.to_f32()).abs();
9387            if d > max_diff {
9388                max_diff = d;
9389                max_at = i;
9390            }
9391        }
9392        // Localize: plane (R/G/B), row, col of the worst element.
9393        let (plane, rem) = (max_at / (640 * 640), max_at % (640 * 640));
9394        let (row, col) = (rem / 640, rem % 640);
9395        eprintln!(
9396            "fused-vs-cpu: max_diff={max_diff} at plane={plane} row={row} col={col} \
9397             gl={} cpu={}",
9398            g[max_at].to_f32(),
9399            c[max_at].to_f32()
9400        );
9401        // Two GPU passes (8-bit intermediate + linear filtering) vs the
9402        // CPU's direct path: allow a few 8-bit steps of divergence.
9403        assert!(
9404            max_diff <= 4.0 / 255.0 + 1e-3,
9405            "fused NV12→PlanarF16 diverges from CPU reference: max_diff={max_diff}"
9406        );
9407    }
9408
9409    /// Zero-copy source → heap destination through the GL engine,
9410    /// driven on the GL backend DIRECTLY so a GL error cannot hide
9411    /// behind the `ImageProcessor` CPU fallback (the shape that exposed
9412    /// the macOS `glReadnPixels` failure: imported source, rendered,
9413    /// then errored at the heap readback). RGBA→BGRA so the convert is
9414    /// a pure byte shuffle: no chroma kernel or colorimetry ambiguity
9415    /// (Vivante's NV fast path legitimately diverges from the CPU
9416    /// reference), and the readback stays GL_RGBA (V3D rejects RGB
9417    /// readbacks).
9418    #[test]
9419    #[cfg(feature = "opengl")]
9420    fn test_zero_copy_src_to_mem_dst_gl_direct() {
9421        let mut proc = match ImageProcessor::new() {
9422            Ok(p) if p.opengl.is_some() => p,
9423            _ => {
9424                crate::test_support::report_skip(&format!(
9425                    "{} — GL backend unavailable",
9426                    function!()
9427                ));
9428                return;
9429            }
9430        };
9431        if !edgefirst_tensor::is_gpu_buffer_available() {
9432            crate::test_support::report_skip(&format!("{} — no zero-copy buffers", function!()));
9433            return;
9434        }
9435
9436        let src = TensorDyn::image(
9437            1280,
9438            720,
9439            PixelFormat::Rgba,
9440            DType::U8,
9441            Some(TensorMemory::DmaBuf),
9442            edgefirst_tensor::CpuAccess::ReadWrite,
9443        )
9444        .unwrap();
9445        {
9446            let t = src.as_typed::<u8>().unwrap();
9447            let mut m = t.map().unwrap();
9448            for (i, b) in m.as_mut_slice().iter_mut().enumerate() {
9449                *b = ((i * 31) % 211) as u8;
9450            }
9451        }
9452        let mut gl_dst = TensorDyn::image(
9453            1280,
9454            720,
9455            PixelFormat::Bgra,
9456            DType::U8,
9457            Some(TensorMemory::Mem),
9458            edgefirst_tensor::CpuAccess::ReadWrite,
9459        )
9460        .unwrap();
9461        proc.opengl
9462            .as_mut()
9463            .expect("GL backend present")
9464            .convert(
9465                &src,
9466                &mut gl_dst,
9467                Rotation::None,
9468                Flip::None,
9469                Crop::no_crop(),
9470            )
9471            .expect("zero-copy src → heap dst GL convert");
9472
9473        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
9474            backend: ComputeBackend::Cpu,
9475            ..Default::default()
9476        })
9477        .unwrap();
9478        let mut cpu_dst = TensorDyn::image(
9479            1280,
9480            720,
9481            PixelFormat::Bgra,
9482            DType::U8,
9483            Some(TensorMemory::Mem),
9484            edgefirst_tensor::CpuAccess::ReadWrite,
9485        )
9486        .unwrap();
9487        cpu.convert(
9488            &src,
9489            &mut cpu_dst,
9490            Rotation::None,
9491            Flip::None,
9492            Crop::no_crop(),
9493        )
9494        .expect("CPU reference convert");
9495
9496        let g = gl_dst
9497            .as_typed::<u8>()
9498            .unwrap()
9499            .map()
9500            .unwrap()
9501            .as_slice()
9502            .to_vec();
9503        let c = cpu_dst
9504            .as_typed::<u8>()
9505            .unwrap()
9506            .map()
9507            .unwrap()
9508            .as_slice()
9509            .to_vec();
9510        assert_eq!(g.len(), c.len());
9511        let max_diff = g
9512            .iter()
9513            .zip(c.iter())
9514            .map(|(a, b)| a.abs_diff(*b))
9515            .max()
9516            .unwrap();
9517        // Same-size byte shuffle: GL samples texel centers 1:1, so allow
9518        // only rounding slack.
9519        assert!(
9520            max_diff <= 2,
9521            "zero-copy src → heap dst diverges from CPU reference: max_diff={max_diff}"
9522        );
9523    }
9524
9525    #[test]
9526    #[cfg(target_os = "linux")]
9527    fn test_yuyv_to_rgb_g2d() {
9528        if !is_g2d_available() {
9529            crate::test_support::report_skip(
9530                "test_yuyv_to_rgb_g2d - G2D library (libg2d.so.2) not available",
9531            );
9532            return;
9533        }
9534        if !is_dma_available() {
9535            crate::test_support::report_skip("test_yuyv_to_rgb_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
9536            return;
9537        }
9538
9539        let src = load_bytes_to_tensor(
9540            1280,
9541            720,
9542            PixelFormat::Yuyv,
9543            None,
9544            &edgefirst_bench::testdata::read("camera720p.yuyv"),
9545        )
9546        .unwrap();
9547
9548        let g2d_dst = TensorDyn::image(
9549            1280,
9550            720,
9551            PixelFormat::Rgb,
9552            DType::U8,
9553            Some(TensorMemory::DmaBuf),
9554            edgefirst_tensor::CpuAccess::ReadWrite,
9555        )
9556        .unwrap();
9557        let mut g2d_converter = G2DProcessor::new().unwrap();
9558
9559        let (result, src, g2d_dst) = convert_img(
9560            &mut g2d_converter,
9561            src,
9562            g2d_dst,
9563            Rotation::None,
9564            Flip::None,
9565            Crop::no_crop(),
9566        );
9567        result.unwrap();
9568
9569        let cpu_dst = TensorDyn::image(
9570            1280,
9571            720,
9572            PixelFormat::Rgb,
9573            DType::U8,
9574            None,
9575            edgefirst_tensor::CpuAccess::ReadWrite,
9576        )
9577        .unwrap();
9578        let mut cpu_converter: CPUProcessor = CPUProcessor::new();
9579
9580        let (result, _src, cpu_dst) = convert_img(
9581            &mut cpu_converter,
9582            src,
9583            cpu_dst,
9584            Rotation::None,
9585            Flip::None,
9586            Crop::no_crop(),
9587        );
9588        result.unwrap();
9589
9590        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
9591        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
9592        // the YUV-matrix delta that forced 0.95 has closed; tightened to
9593        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
9594        // structural gap not exercised by these limited-range fixtures.
9595        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
9596    }
9597
9598    #[test]
9599    #[cfg(target_os = "linux")]
9600    fn test_yuyv_to_yuyv_resize_g2d() {
9601        if !is_g2d_available() {
9602            crate::test_support::report_skip(
9603                "test_yuyv_to_yuyv_resize_g2d - G2D library (libg2d.so.2) not available",
9604            );
9605            return;
9606        }
9607        if !is_dma_available() {
9608            crate::test_support::report_skip("test_yuyv_to_yuyv_resize_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
9609            return;
9610        }
9611
9612        let src = load_bytes_to_tensor(
9613            1280,
9614            720,
9615            PixelFormat::Yuyv,
9616            None,
9617            &edgefirst_bench::testdata::read("camera720p.yuyv"),
9618        )
9619        .unwrap();
9620
9621        let g2d_dst = TensorDyn::image(
9622            600,
9623            400,
9624            PixelFormat::Yuyv,
9625            DType::U8,
9626            Some(TensorMemory::DmaBuf),
9627            edgefirst_tensor::CpuAccess::ReadWrite,
9628        )
9629        .unwrap();
9630        let mut g2d_converter = G2DProcessor::new().unwrap();
9631
9632        let (result, src, g2d_dst) = convert_img(
9633            &mut g2d_converter,
9634            src,
9635            g2d_dst,
9636            Rotation::None,
9637            Flip::None,
9638            Crop::no_crop(),
9639        );
9640        result.unwrap();
9641
9642        let cpu_dst = TensorDyn::image(
9643            600,
9644            400,
9645            PixelFormat::Yuyv,
9646            DType::U8,
9647            None,
9648            edgefirst_tensor::CpuAccess::ReadWrite,
9649        )
9650        .unwrap();
9651        let mut cpu_converter: CPUProcessor = CPUProcessor::new();
9652
9653        let (result, _src, cpu_dst) = convert_img(
9654            &mut cpu_converter,
9655            src,
9656            cpu_dst,
9657            Rotation::None,
9658            Flip::None,
9659            Crop::no_crop(),
9660        );
9661        result.unwrap();
9662
9663        // G2D has poor colorimetry support: its hardware YUYV resize/sampling
9664        // diverges from the CPU reference by enough that the similarity score
9665        // sits around 0.85 on every measured G2D core (i.MX 8M Plus, i.MX 95).
9666        // The threshold is held at 0.85 so the test guards against gross
9667        // regressions while tolerating the driver's inherent colorimetry error.
9668        // TODO: compare YUYV↔YUYV directly without a YUYV→RGB convert.
9669        eprintln!(
9670            "WARNING: G2D has poor colorimetry support — YUYV resize diverges from the \
9671             CPU reference (~0.85 similarity); threshold held at 0.85, not 0.95."
9672        );
9673        compare_images_convert_to_rgb(&g2d_dst, &cpu_dst, 0.85, function!());
9674    }
9675
9676    #[test]
9677    fn test_yuyv_to_rgba_resize_cpu() {
9678        let src = load_bytes_to_tensor(
9679            1280,
9680            720,
9681            PixelFormat::Yuyv,
9682            None,
9683            &edgefirst_bench::testdata::read("camera720p.yuyv"),
9684        )
9685        .unwrap();
9686
9687        let (dst_width, dst_height) = (960, 540);
9688
9689        let dst = TensorDyn::image(
9690            dst_width,
9691            dst_height,
9692            PixelFormat::Rgba,
9693            DType::U8,
9694            None,
9695            edgefirst_tensor::CpuAccess::ReadWrite,
9696        )
9697        .unwrap();
9698        let mut cpu_converter = CPUProcessor::new();
9699
9700        let (result, _src, dst) = convert_img(
9701            &mut cpu_converter,
9702            src,
9703            dst,
9704            Rotation::None,
9705            Flip::None,
9706            Crop::no_crop(),
9707        );
9708        result.unwrap();
9709
9710        let dst_target = TensorDyn::image(
9711            dst_width,
9712            dst_height,
9713            PixelFormat::Rgba,
9714            DType::U8,
9715            None,
9716            edgefirst_tensor::CpuAccess::ReadWrite,
9717        )
9718        .unwrap();
9719        let src_target = load_bytes_to_tensor(
9720            1280,
9721            720,
9722            PixelFormat::Rgba,
9723            None,
9724            &edgefirst_bench::testdata::read("camera720p.rgba"),
9725        )
9726        .unwrap();
9727        let (result, _src_target, dst_target) = convert_img(
9728            &mut cpu_converter,
9729            src_target,
9730            dst_target,
9731            Rotation::None,
9732            Flip::None,
9733            Crop::no_crop(),
9734        );
9735        result.unwrap();
9736
9737        // CPU path resolves the untagged 720p source to BT.709 limited (height
9738        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
9739        compare_images(&dst, &dst_target, 0.98, function!());
9740    }
9741
9742    #[test]
9743    #[cfg(target_os = "linux")]
9744    fn test_yuyv_to_rgba_crop_flip_g2d() {
9745        if !is_g2d_available() {
9746            crate::test_support::report_skip(
9747                "test_yuyv_to_rgba_crop_flip_g2d - G2D library (libg2d.so.2) not available",
9748            );
9749            return;
9750        }
9751        if !is_dma_available() {
9752            crate::test_support::report_skip("test_yuyv_to_rgba_crop_flip_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
9753            return;
9754        }
9755
9756        let src = load_bytes_to_tensor(
9757            1280,
9758            720,
9759            PixelFormat::Yuyv,
9760            Some(TensorMemory::DmaBuf),
9761            &edgefirst_bench::testdata::read("camera720p.yuyv"),
9762        )
9763        .unwrap();
9764
9765        let (dst_width, dst_height) = (640, 640);
9766
9767        let dst_g2d = TensorDyn::image(
9768            dst_width,
9769            dst_height,
9770            PixelFormat::Rgba,
9771            DType::U8,
9772            Some(TensorMemory::DmaBuf),
9773            edgefirst_tensor::CpuAccess::ReadWrite,
9774        )
9775        .unwrap();
9776        let mut g2d_converter = G2DProcessor::new().unwrap();
9777        let crop = Crop::new().with_source(Some(Region::new(20, 15, 400, 300)));
9778
9779        let (result, src, dst_g2d) = convert_img(
9780            &mut g2d_converter,
9781            src,
9782            dst_g2d,
9783            Rotation::None,
9784            Flip::Horizontal,
9785            crop,
9786        );
9787        result.unwrap();
9788
9789        let dst_cpu = TensorDyn::image(
9790            dst_width,
9791            dst_height,
9792            PixelFormat::Rgba,
9793            DType::U8,
9794            Some(TensorMemory::DmaBuf),
9795            edgefirst_tensor::CpuAccess::ReadWrite,
9796        )
9797        .unwrap();
9798        let mut cpu_converter = CPUProcessor::new();
9799
9800        let (result, _src, dst_cpu) = convert_img(
9801            &mut cpu_converter,
9802            src,
9803            dst_cpu,
9804            Rotation::None,
9805            Flip::Horizontal,
9806            crop,
9807        );
9808        result.unwrap();
9809        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
9810        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
9811        // the YUV-matrix delta that forced 0.95 has closed; tightened to
9812        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
9813        // structural gap not exercised by these limited-range fixtures.
9814        compare_images(&dst_g2d, &dst_cpu, 0.98, function!());
9815    }
9816
9817    #[test]
9818    #[cfg(any(
9819        target_os = "linux",
9820        target_os = "macos",
9821        target_os = "ios",
9822        target_os = "android",
9823        target_os = "windows"
9824    ))]
9825    #[cfg(feature = "opengl")]
9826    fn test_yuyv_to_rgba_crop_flip_opengl() {
9827        if !is_opengl_available() {
9828            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
9829            return;
9830        }
9831
9832        if !edgefirst_tensor::is_gpu_buffer_available() {
9833            crate::test_support::report_skip(&format!(
9834                "{} - zero-copy GPU buffer (DMA-BUF / IOSurface) not available on this host",
9835                function!()
9836            ));
9837            return;
9838        }
9839
9840        let src = load_bytes_to_tensor(
9841            1280,
9842            720,
9843            PixelFormat::Yuyv,
9844            Some(TensorMemory::DmaBuf),
9845            &edgefirst_bench::testdata::read("camera720p.yuyv"),
9846        )
9847        .unwrap();
9848
9849        let (dst_width, dst_height) = (640, 640);
9850
9851        let dst_gl = TensorDyn::image(
9852            dst_width,
9853            dst_height,
9854            PixelFormat::Rgba,
9855            DType::U8,
9856            Some(TensorMemory::DmaBuf),
9857            edgefirst_tensor::CpuAccess::ReadWrite,
9858        )
9859        .unwrap();
9860        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
9861        let crop = Crop::new().with_source(Some(Region::new(20, 15, 400, 300)));
9862
9863        let (result, src, dst_gl) = convert_img(
9864            &mut gl_converter,
9865            src,
9866            dst_gl,
9867            Rotation::None,
9868            Flip::Horizontal,
9869            crop,
9870        );
9871        // Desktop NVIDIA cannot sample YUYV as a GL texture; i.MX/Vivante can.
9872        if let Err(crate::Error::NotSupported(msg)) = &result {
9873            crate::test_support::report_skip(&format!("{} — {msg}", function!()));
9874            return;
9875        }
9876        result.unwrap();
9877
9878        let dst_cpu = TensorDyn::image(
9879            dst_width,
9880            dst_height,
9881            PixelFormat::Rgba,
9882            DType::U8,
9883            Some(TensorMemory::DmaBuf),
9884            edgefirst_tensor::CpuAccess::ReadWrite,
9885        )
9886        .unwrap();
9887        let mut cpu_converter = CPUProcessor::new();
9888
9889        let (result, _src, dst_cpu) = convert_img(
9890            &mut cpu_converter,
9891            src,
9892            dst_cpu,
9893            Rotation::None,
9894            Flip::Horizontal,
9895            crop,
9896        );
9897        result.unwrap();
9898        // Post-WS1 the GL path applies the resolved colorimetry via the EGL
9899        // YUV color-space/sample-range hints, so the matrix delta that forced
9900        // 0.95 has closed; tightened to 0.98 (driver-matrix rounding confirmed
9901        // on the GPU lanes).
9902        compare_images(&dst_gl, &dst_cpu, 0.98, function!());
9903    }
9904
9905    #[test]
9906    fn test_vyuy_to_rgba_cpu() {
9907        let file = edgefirst_bench::testdata::read("camera720p.vyuy").to_vec();
9908        let src = TensorDyn::image(
9909            1280,
9910            720,
9911            PixelFormat::Vyuy,
9912            DType::U8,
9913            None,
9914            edgefirst_tensor::CpuAccess::ReadWrite,
9915        )
9916        .unwrap();
9917        src.as_typed::<u8>()
9918            .unwrap()
9919            .map()
9920            .unwrap()
9921            .as_mut_slice()
9922            .copy_from_slice(&file);
9923
9924        let dst = TensorDyn::image(
9925            1280,
9926            720,
9927            PixelFormat::Rgba,
9928            DType::U8,
9929            None,
9930            edgefirst_tensor::CpuAccess::ReadWrite,
9931        )
9932        .unwrap();
9933        let mut cpu_converter = CPUProcessor::new();
9934
9935        let (result, _src, dst) = convert_img(
9936            &mut cpu_converter,
9937            src,
9938            dst,
9939            Rotation::None,
9940            Flip::None,
9941            Crop::no_crop(),
9942        );
9943        result.unwrap();
9944
9945        let target_image = TensorDyn::image(
9946            1280,
9947            720,
9948            PixelFormat::Rgba,
9949            DType::U8,
9950            None,
9951            edgefirst_tensor::CpuAccess::ReadWrite,
9952        )
9953        .unwrap();
9954        target_image
9955            .as_typed::<u8>()
9956            .unwrap()
9957            .map()
9958            .unwrap()
9959            .as_mut_slice()
9960            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
9961
9962        // CPU path resolves the untagged 720p source to BT.709 limited (height
9963        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
9964        compare_images(&dst, &target_image, 0.98, function!());
9965    }
9966
9967    #[test]
9968    fn test_vyuy_to_rgb_cpu() {
9969        let file = edgefirst_bench::testdata::read("camera720p.vyuy").to_vec();
9970        let src = TensorDyn::image(
9971            1280,
9972            720,
9973            PixelFormat::Vyuy,
9974            DType::U8,
9975            None,
9976            edgefirst_tensor::CpuAccess::ReadWrite,
9977        )
9978        .unwrap();
9979        src.as_typed::<u8>()
9980            .unwrap()
9981            .map()
9982            .unwrap()
9983            .as_mut_slice()
9984            .copy_from_slice(&file);
9985
9986        let dst = TensorDyn::image(
9987            1280,
9988            720,
9989            PixelFormat::Rgb,
9990            DType::U8,
9991            None,
9992            edgefirst_tensor::CpuAccess::ReadWrite,
9993        )
9994        .unwrap();
9995        let mut cpu_converter = CPUProcessor::new();
9996
9997        let (result, _src, dst) = convert_img(
9998            &mut cpu_converter,
9999            src,
10000            dst,
10001            Rotation::None,
10002            Flip::None,
10003            Crop::no_crop(),
10004        );
10005        result.unwrap();
10006
10007        let target_image = TensorDyn::image(
10008            1280,
10009            720,
10010            PixelFormat::Rgb,
10011            DType::U8,
10012            None,
10013            edgefirst_tensor::CpuAccess::ReadWrite,
10014        )
10015        .unwrap();
10016        target_image
10017            .as_typed::<u8>()
10018            .unwrap()
10019            .map()
10020            .unwrap()
10021            .as_mut_slice()
10022            .as_chunks_mut::<3>()
10023            .0
10024            .iter_mut()
10025            .zip(
10026                edgefirst_bench::testdata::read("camera720p.rgba")
10027                    .as_chunks::<4>()
10028                    .0,
10029            )
10030            .for_each(|(dst, src)| *dst = [src[0], src[1], src[2]]);
10031
10032        // CPU path resolves the untagged 720p source to BT.709 limited (height
10033        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
10034        compare_images(&dst, &target_image, 0.98, function!());
10035    }
10036
10037    #[test]
10038    #[cfg(target_os = "linux")]
10039    #[ignore = "G2D does not support VYUY; re-enable when hardware support is added"]
10040    fn test_vyuy_to_rgba_g2d() {
10041        if !is_g2d_available() {
10042            crate::test_support::report_skip(
10043                "test_vyuy_to_rgba_g2d - G2D library (libg2d.so.2) not available",
10044            );
10045            return;
10046        }
10047        if !is_dma_available() {
10048            crate::test_support::report_skip("test_vyuy_to_rgba_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
10049            return;
10050        }
10051
10052        let src = load_bytes_to_tensor(
10053            1280,
10054            720,
10055            PixelFormat::Vyuy,
10056            None,
10057            &edgefirst_bench::testdata::read("camera720p.vyuy"),
10058        )
10059        .unwrap();
10060
10061        let dst = TensorDyn::image(
10062            1280,
10063            720,
10064            PixelFormat::Rgba,
10065            DType::U8,
10066            Some(TensorMemory::DmaBuf),
10067            edgefirst_tensor::CpuAccess::ReadWrite,
10068        )
10069        .unwrap();
10070        let mut g2d_converter = G2DProcessor::new().unwrap();
10071
10072        let (result, _src, dst) = convert_img(
10073            &mut g2d_converter,
10074            src,
10075            dst,
10076            Rotation::None,
10077            Flip::None,
10078            Crop::no_crop(),
10079        );
10080        match result {
10081            Err(Error::G2D(_)) => {
10082                crate::test_support::report_skip(
10083                    "test_vyuy_to_rgba_g2d - G2D does not support PixelFormat::Vyuy format",
10084                );
10085                return;
10086            }
10087            r => r.unwrap(),
10088        }
10089
10090        let target_image = TensorDyn::image(
10091            1280,
10092            720,
10093            PixelFormat::Rgba,
10094            DType::U8,
10095            None,
10096            edgefirst_tensor::CpuAccess::ReadWrite,
10097        )
10098        .unwrap();
10099        target_image
10100            .as_typed::<u8>()
10101            .unwrap()
10102            .map()
10103            .unwrap()
10104            .as_mut_slice()
10105            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
10106
10107        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
10108        // so the matrix delta vs the reference that forced 0.95 has closed;
10109        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
10110        compare_images(&dst, &target_image, 0.98, function!());
10111    }
10112
10113    #[test]
10114    #[cfg(target_os = "linux")]
10115    #[ignore = "G2D does not support VYUY; re-enable when hardware support is added"]
10116    fn test_vyuy_to_rgb_g2d() {
10117        if !is_g2d_available() {
10118            crate::test_support::report_skip(
10119                "test_vyuy_to_rgb_g2d - G2D library (libg2d.so.2) not available",
10120            );
10121            return;
10122        }
10123        if !is_dma_available() {
10124            crate::test_support::report_skip("test_vyuy_to_rgb_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
10125            return;
10126        }
10127
10128        let src = load_bytes_to_tensor(
10129            1280,
10130            720,
10131            PixelFormat::Vyuy,
10132            None,
10133            &edgefirst_bench::testdata::read("camera720p.vyuy"),
10134        )
10135        .unwrap();
10136
10137        let g2d_dst = TensorDyn::image(
10138            1280,
10139            720,
10140            PixelFormat::Rgb,
10141            DType::U8,
10142            Some(TensorMemory::DmaBuf),
10143            edgefirst_tensor::CpuAccess::ReadWrite,
10144        )
10145        .unwrap();
10146        let mut g2d_converter = G2DProcessor::new().unwrap();
10147
10148        let (result, src, g2d_dst) = convert_img(
10149            &mut g2d_converter,
10150            src,
10151            g2d_dst,
10152            Rotation::None,
10153            Flip::None,
10154            Crop::no_crop(),
10155        );
10156        match result {
10157            Err(Error::G2D(_)) => {
10158                crate::test_support::report_skip(
10159                    "test_vyuy_to_rgb_g2d - G2D does not support PixelFormat::Vyuy format",
10160                );
10161                return;
10162            }
10163            r => r.unwrap(),
10164        }
10165
10166        let cpu_dst = TensorDyn::image(
10167            1280,
10168            720,
10169            PixelFormat::Rgb,
10170            DType::U8,
10171            None,
10172            edgefirst_tensor::CpuAccess::ReadWrite,
10173        )
10174        .unwrap();
10175        let mut cpu_converter: CPUProcessor = CPUProcessor::new();
10176
10177        let (result, _src, cpu_dst) = convert_img(
10178            &mut cpu_converter,
10179            src,
10180            cpu_dst,
10181            Rotation::None,
10182            Flip::None,
10183            Crop::no_crop(),
10184        );
10185        result.unwrap();
10186
10187        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
10188        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
10189        // the YUV-matrix delta that forced 0.95 has closed; tightened to
10190        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
10191        // structural gap not exercised by these limited-range fixtures.
10192        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
10193    }
10194
10195    #[test]
10196    #[cfg(any(
10197        target_os = "linux",
10198        target_os = "macos",
10199        target_os = "ios",
10200        target_os = "android",
10201        target_os = "windows"
10202    ))]
10203    #[cfg(feature = "opengl")]
10204    fn test_vyuy_to_rgba_opengl() {
10205        if !is_opengl_available() {
10206            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
10207            return;
10208        }
10209        if !edgefirst_tensor::is_gpu_buffer_available() {
10210            crate::test_support::report_skip(&format!(
10211                "{} - zero-copy GPU buffer (DMA-BUF / IOSurface) not available on this host",
10212                function!()
10213            ));
10214            return;
10215        }
10216
10217        // VYUY has a DMA-BUF FourCC on Linux but no IOSurface mapping on
10218        // macOS/iOS, so the zero-copy allocation itself is the per-format
10219        // probe: a platform that declines it is a skip, not a failure.
10220        let src = match load_bytes_to_tensor(
10221            1280,
10222            720,
10223            PixelFormat::Vyuy,
10224            Some(TensorMemory::DmaBuf),
10225            &edgefirst_bench::testdata::read("camera720p.vyuy"),
10226        ) {
10227            Ok(src) => src,
10228            Err(e) => {
10229                crate::test_support::report_skip(&format!(
10230                    "{} - zero-copy VYUY source not allocatable on this platform ({e})",
10231                    function!()
10232                ));
10233                return;
10234            }
10235        };
10236
10237        let dst = TensorDyn::image(
10238            1280,
10239            720,
10240            PixelFormat::Rgba,
10241            DType::U8,
10242            Some(TensorMemory::DmaBuf),
10243            edgefirst_tensor::CpuAccess::ReadWrite,
10244        )
10245        .unwrap();
10246        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
10247
10248        let (result, _src, dst) = convert_img(
10249            &mut gl_converter,
10250            src,
10251            dst,
10252            Rotation::None,
10253            Flip::None,
10254            Crop::no_crop(),
10255        );
10256        match result {
10257            Err(Error::NotSupported(_)) => {
10258                crate::test_support::report_skip(&format!(
10259                    "{} - OpenGL does not support PixelFormat::Vyuy DMA format",
10260                    function!()
10261                ));
10262                return;
10263            }
10264            r => r.unwrap(),
10265        }
10266
10267        let target_image = TensorDyn::image(
10268            1280,
10269            720,
10270            PixelFormat::Rgba,
10271            DType::U8,
10272            None,
10273            edgefirst_tensor::CpuAccess::ReadWrite,
10274        )
10275        .unwrap();
10276        target_image
10277            .as_typed::<u8>()
10278            .unwrap()
10279            .map()
10280            .unwrap()
10281            .as_mut_slice()
10282            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
10283
10284        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
10285        // so the matrix delta vs the reference that forced 0.95 has closed;
10286        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
10287        compare_images(&dst, &target_image, 0.98, function!());
10288    }
10289
10290    #[test]
10291    fn test_nv12_to_rgba_cpu() {
10292        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
10293        let src = TensorDyn::image(
10294            1280,
10295            720,
10296            PixelFormat::Nv12,
10297            DType::U8,
10298            None,
10299            edgefirst_tensor::CpuAccess::ReadWrite,
10300        )
10301        .unwrap();
10302        src.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
10303            .copy_from_slice(&file);
10304
10305        let dst = TensorDyn::image(
10306            1280,
10307            720,
10308            PixelFormat::Rgba,
10309            DType::U8,
10310            None,
10311            edgefirst_tensor::CpuAccess::ReadWrite,
10312        )
10313        .unwrap();
10314        let mut cpu_converter = CPUProcessor::new();
10315
10316        let (result, _src, dst) = convert_img(
10317            &mut cpu_converter,
10318            src,
10319            dst,
10320            Rotation::None,
10321            Flip::None,
10322            Crop::no_crop(),
10323        );
10324        result.unwrap();
10325
10326        let target_image = crate::load_image_test_helper(
10327            &edgefirst_bench::testdata::read("zidane.jpg"),
10328            Some(PixelFormat::Rgba),
10329            None,
10330        )
10331        .unwrap();
10332
10333        // Threshold 0.95 (was 0.98): the reference now decodes the colour JPEG
10334        // to native NV12 and then converts to RGBA (was a direct JPEG → RGBA
10335        // decode), so it differs slightly from the RGBA derived from the
10336        // separate `zidane.nv12` fixture.
10337        compare_images(&dst, &target_image, 0.95, function!());
10338    }
10339
10340    #[test]
10341    fn test_nv12_odd_height_to_rgb_cpu() {
10342        // Odd height (even width) — the logical-odd case, e.g. 640×483. The
10343        // contiguous NV12 buffer is `[5 + ceil(5/2), 8]` = `[8, 8]` (5 luma rows
10344        // + 3 chroma rows). A neutral-grey fill (Y=U=V=128, BT.601 full-range)
10345        // must convert to a uniform grey RGB, exercising the odd-height
10346        // chroma-row count and the logical-height derivation in convert.
10347        // (Odd *width* is rounded to an even buffer at allocation, so it is
10348        // covered by the decode integration tests rather than here.)
10349        // CPU-only test: pin to tight host memory (None auto-selects pitch-padded
10350        // DMA on i.MX, which would leave the dst's row padding unconverted and
10351        // break the flat byte scan below).
10352        let mut src = TensorDyn::image(
10353            8,
10354            5,
10355            PixelFormat::Nv12,
10356            DType::U8,
10357            Some(TensorMemory::Mem),
10358            edgefirst_tensor::CpuAccess::ReadWrite,
10359        )
10360        .unwrap();
10361        assert_eq!(src.shape(), &[8, 8]);
10362        assert_eq!((src.width(), src.height()), (Some(8), Some(5)));
10363        src.as_typed::<u8>()
10364            .unwrap()
10365            .map()
10366            .unwrap()
10367            .as_mut_slice()
10368            .fill(128);
10369        // Tag BT.601 full-range so Y=128 decodes to grey 128 (the neutral-grey
10370        // identity this test asserts). Without a tag, the colorimetry heuristic
10371        // resolves an SD tensor to BT.601 *limited*, expanding Y=128 → ~131.
10372        src.set_colorimetry(Some(
10373            edgefirst_tensor::Colorimetry::default()
10374                .with_encoding(edgefirst_tensor::ColorEncoding::Bt601)
10375                .with_range(edgefirst_tensor::ColorRange::Full),
10376        ));
10377
10378        let dst = TensorDyn::image(
10379            8,
10380            5,
10381            PixelFormat::Rgb,
10382            DType::U8,
10383            Some(TensorMemory::Mem),
10384            edgefirst_tensor::CpuAccess::ReadWrite,
10385        )
10386        .unwrap();
10387        let mut cpu_converter = CPUProcessor::new();
10388        let (result, _src, dst) = convert_img(
10389            &mut cpu_converter,
10390            src,
10391            dst,
10392            Rotation::None,
10393            Flip::None,
10394            Crop::no_crop(),
10395        );
10396        result.unwrap();
10397
10398        assert_eq!((dst.width(), dst.height()), (Some(8), Some(5)));
10399        let map = dst.as_typed::<u8>().unwrap().map().unwrap();
10400        for (i, &b) in map.as_slice().iter().enumerate() {
10401            assert!(
10402                (b as i16 - 128).abs() <= 2,
10403                "pixel byte {i} = {b}, expected ~128 for neutral-grey NV12"
10404            );
10405        }
10406    }
10407
10408    #[test]
10409    fn test_nv24_to_rgb_cpu() {
10410        // NV24 (4:4:4) at 8×4: contiguous buffer is [4*3, 8] = [12, 8] — Y plane
10411        // (4 rows) + full-res interleaved UV plane (8 rows = 2H, 2W bytes per
10412        // chroma row). Neutral-grey fill (Y=U=V=128) must convert to uniform
10413        // grey RGB, exercising the 2× UV stride and shape[0]/3 height recovery.
10414        // CPU-only test: pin to tight host memory (see test_nv12_odd_height_to_rgb_cpu).
10415        let mut src = TensorDyn::image(
10416            8,
10417            4,
10418            PixelFormat::Nv24,
10419            DType::U8,
10420            Some(TensorMemory::Mem),
10421            edgefirst_tensor::CpuAccess::ReadWrite,
10422        )
10423        .unwrap();
10424        assert_eq!(src.shape(), &[12, 8]);
10425        assert_eq!((src.width(), src.height()), (Some(8), Some(4)));
10426        src.as_typed::<u8>()
10427            .unwrap()
10428            .map()
10429            .unwrap()
10430            .as_mut_slice()
10431            .fill(128);
10432        // Tag BT.601 full-range (see test_nv12_odd_height_to_rgb_cpu): without it
10433        // the heuristic picks limited range and Y=128 expands to ~131.
10434        src.set_colorimetry(Some(
10435            edgefirst_tensor::Colorimetry::default()
10436                .with_encoding(edgefirst_tensor::ColorEncoding::Bt601)
10437                .with_range(edgefirst_tensor::ColorRange::Full),
10438        ));
10439
10440        let dst = TensorDyn::image(
10441            8,
10442            4,
10443            PixelFormat::Rgb,
10444            DType::U8,
10445            Some(TensorMemory::Mem),
10446            edgefirst_tensor::CpuAccess::ReadWrite,
10447        )
10448        .unwrap();
10449        let mut cpu_converter = CPUProcessor::new();
10450        let (result, _src, dst) = convert_img(
10451            &mut cpu_converter,
10452            src,
10453            dst,
10454            Rotation::None,
10455            Flip::None,
10456            Crop::no_crop(),
10457        );
10458        result.unwrap();
10459
10460        assert_eq!((dst.width(), dst.height()), (Some(8), Some(4)));
10461        let map = dst.as_typed::<u8>().unwrap().map().unwrap();
10462        for (i, &b) in map.as_slice().iter().enumerate() {
10463            assert!(
10464                (b as i16 - 128).abs() <= 2,
10465                "pixel byte {i} = {b}, expected ~128 for neutral-grey NV24"
10466            );
10467        }
10468    }
10469
10470    #[test]
10471    fn cpu_nv12_to_rgb_respects_tagged_bt2020() {
10472        // A uniform but *saturated* chroma sample (U/V far from neutral) so the
10473        // YUV→RGB matrix — not just the range — drives the result. Decoding the
10474        // same NV12 bytes under BT.601 / BT.709 / BT.2020 must yield three
10475        // distinct RGB triples, proving the CPU path honours the source's tagged
10476        // ColorEncoding instead of a hardcoded matrix. (G2D declines BT.2020 and
10477        // falls through to this CPU path; QA F9.)
10478        // CPU-only test: pin to tight host memory (see test_nv12_odd_height_to_rgb_cpu).
10479        fn decode_tagged(enc: edgefirst_tensor::ColorEncoding) -> [u8; 3] {
10480            let mut src = TensorDyn::image(
10481                8,
10482                4,
10483                PixelFormat::Nv12,
10484                DType::U8,
10485                Some(TensorMemory::Mem),
10486                edgefirst_tensor::CpuAccess::ReadWrite,
10487            )
10488            .unwrap();
10489            // NV12 8×4: 32-byte Y plane + 16-byte interleaved UV plane (4:2:0).
10490            assert_eq!(src.shape(), &[6, 8]);
10491            {
10492                let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
10493                let buf = map.as_mut_slice();
10494                buf[..32].fill(120); // Y
10495                for px in buf[32..].as_chunks_mut::<2>().0 {
10496                    px[0] = 180; // U / Cb
10497                    px[1] = 64; // V / Cr
10498                }
10499            }
10500            // Range held constant (Limited) across all three so only the encoding
10501            // matrix varies between runs.
10502            src.set_colorimetry(Some(
10503                edgefirst_tensor::Colorimetry::default()
10504                    .with_encoding(enc)
10505                    .with_range(edgefirst_tensor::ColorRange::Limited),
10506            ));
10507            let dst = TensorDyn::image(
10508                8,
10509                4,
10510                PixelFormat::Rgb,
10511                DType::U8,
10512                Some(TensorMemory::Mem),
10513                edgefirst_tensor::CpuAccess::ReadWrite,
10514            )
10515            .unwrap();
10516            let mut cpu = CPUProcessor::new();
10517            let (result, _src, dst) = convert_img(
10518                &mut cpu,
10519                src,
10520                dst,
10521                Rotation::None,
10522                Flip::None,
10523                Crop::no_crop(),
10524            );
10525            result.unwrap();
10526            let map = dst.as_typed::<u8>().unwrap().map().unwrap();
10527            let s = map.as_slice();
10528            [s[0], s[1], s[2]]
10529        }
10530
10531        let bt601 = decode_tagged(edgefirst_tensor::ColorEncoding::Bt601);
10532        let bt709 = decode_tagged(edgefirst_tensor::ColorEncoding::Bt709);
10533        let bt2020 = decode_tagged(edgefirst_tensor::ColorEncoding::Bt2020);
10534
10535        assert_ne!(
10536            bt2020, bt601,
10537            "BT.2020 must decode differently from BT.601 ({bt2020:?} vs {bt601:?})"
10538        );
10539        assert_ne!(
10540            bt2020, bt709,
10541            "BT.2020 must decode differently from BT.709 ({bt2020:?} vs {bt709:?})"
10542        );
10543        assert_ne!(
10544            bt601, bt709,
10545            "BT.601 must decode differently from BT.709 ({bt601:?} vs {bt709:?})"
10546        );
10547    }
10548
10549    #[test]
10550    fn test_nv12_to_rgb_cpu() {
10551        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
10552        let src = TensorDyn::image(
10553            1280,
10554            720,
10555            PixelFormat::Nv12,
10556            DType::U8,
10557            None,
10558            edgefirst_tensor::CpuAccess::ReadWrite,
10559        )
10560        .unwrap();
10561        src.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
10562            .copy_from_slice(&file);
10563
10564        let dst = TensorDyn::image(
10565            1280,
10566            720,
10567            PixelFormat::Rgb,
10568            DType::U8,
10569            None,
10570            edgefirst_tensor::CpuAccess::ReadWrite,
10571        )
10572        .unwrap();
10573        let mut cpu_converter = CPUProcessor::new();
10574
10575        let (result, _src, dst) = convert_img(
10576            &mut cpu_converter,
10577            src,
10578            dst,
10579            Rotation::None,
10580            Flip::None,
10581            Crop::no_crop(),
10582        );
10583        result.unwrap();
10584
10585        let target_image = crate::load_image_test_helper(
10586            &edgefirst_bench::testdata::read("zidane.jpg"),
10587            Some(PixelFormat::Rgb),
10588            None,
10589        )
10590        .unwrap();
10591
10592        // Threshold 0.95 (was 0.98): the reference now decodes the colour JPEG
10593        // to native NV12 and then converts to RGB (was a direct JPEG → RGB
10594        // decode), so it differs slightly from the RGB derived from the
10595        // separate `zidane.nv12` fixture.
10596        compare_images(&dst, &target_image, 0.95, function!());
10597    }
10598
10599    #[test]
10600    fn test_nv12_to_grey_cpu() {
10601        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
10602        let src = TensorDyn::image(
10603            1280,
10604            720,
10605            PixelFormat::Nv12,
10606            DType::U8,
10607            None,
10608            edgefirst_tensor::CpuAccess::ReadWrite,
10609        )
10610        .unwrap();
10611        src.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
10612            .copy_from_slice(&file);
10613
10614        let dst = TensorDyn::image(
10615            1280,
10616            720,
10617            PixelFormat::Grey,
10618            DType::U8,
10619            None,
10620            edgefirst_tensor::CpuAccess::ReadWrite,
10621        )
10622        .unwrap();
10623        let mut cpu_converter = CPUProcessor::new();
10624
10625        let (result, _src, dst) = convert_img(
10626            &mut cpu_converter,
10627            src,
10628            dst,
10629            Rotation::None,
10630            Flip::None,
10631            Crop::no_crop(),
10632        );
10633        result.unwrap();
10634
10635        let target_image = crate::load_image_test_helper(
10636            &edgefirst_bench::testdata::read("zidane.jpg"),
10637            Some(PixelFormat::Grey),
10638            None,
10639        )
10640        .unwrap();
10641
10642        // Threshold 0.95 (was 0.98): the reference grey frame now comes from
10643        // the colour JPEG decoded to native NV12 and then converted to GREY
10644        // (was a direct JPEG → GREY decode), so it differs slightly from the
10645        // grey derived from the `zidane.nv12` fixture.
10646        compare_images(&dst, &target_image, 0.95, function!());
10647    }
10648
10649    #[test]
10650    fn test_nv12_to_yuyv_cpu() {
10651        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
10652        let src = TensorDyn::image(
10653            1280,
10654            720,
10655            PixelFormat::Nv12,
10656            DType::U8,
10657            None,
10658            edgefirst_tensor::CpuAccess::ReadWrite,
10659        )
10660        .unwrap();
10661        src.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
10662            .copy_from_slice(&file);
10663
10664        let dst = TensorDyn::image(
10665            1280,
10666            720,
10667            PixelFormat::Yuyv,
10668            DType::U8,
10669            None,
10670            edgefirst_tensor::CpuAccess::ReadWrite,
10671        )
10672        .unwrap();
10673        let mut cpu_converter = CPUProcessor::new();
10674
10675        let (result, _src, dst) = convert_img(
10676            &mut cpu_converter,
10677            src,
10678            dst,
10679            Rotation::None,
10680            Flip::None,
10681            Crop::no_crop(),
10682        );
10683        result.unwrap();
10684
10685        let target_image = crate::load_image_test_helper(
10686            &edgefirst_bench::testdata::read("zidane.jpg"),
10687            Some(PixelFormat::Rgb),
10688            None,
10689        )
10690        .unwrap();
10691
10692        // Threshold 0.95 (was 0.98): the reference now decodes the colour JPEG
10693        // to native NV12 and then converts to RGB (was a direct JPEG → RGB
10694        // decode), so it differs slightly from the YUYV-sourced frame derived
10695        // from the separate `zidane.nv12` fixture.
10696        compare_images_convert_to_rgb(&dst, &target_image, 0.95, function!());
10697    }
10698
10699    #[test]
10700    fn test_cpu_resize_nv16() {
10701        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
10702        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
10703
10704        let cpu_nv16_dst = TensorDyn::image(
10705            640,
10706            640,
10707            PixelFormat::Nv16,
10708            DType::U8,
10709            None,
10710            edgefirst_tensor::CpuAccess::ReadWrite,
10711        )
10712        .unwrap();
10713        let cpu_rgb_dst = TensorDyn::image(
10714            640,
10715            640,
10716            PixelFormat::Rgb,
10717            DType::U8,
10718            None,
10719            edgefirst_tensor::CpuAccess::ReadWrite,
10720        )
10721        .unwrap();
10722        let mut cpu_converter = CPUProcessor::new();
10723        let crop = Crop::letterbox([255, 128, 0, 255]);
10724
10725        let (result, src, cpu_nv16_dst) = convert_img(
10726            &mut cpu_converter,
10727            src,
10728            cpu_nv16_dst,
10729            Rotation::None,
10730            Flip::None,
10731            crop,
10732        );
10733        result.unwrap();
10734
10735        let (result, _src, cpu_rgb_dst) = convert_img(
10736            &mut cpu_converter,
10737            src,
10738            cpu_rgb_dst,
10739            Rotation::None,
10740            Flip::None,
10741            crop,
10742        );
10743        result.unwrap();
10744        compare_images_convert_to_rgb(
10745            &cpu_nv16_dst,
10746            &cpu_rgb_dst,
10747            yuv_threshold(0.99),
10748            function!(),
10749        );
10750    }
10751
10752    /// Similarity threshold for a comparison whose path runs a YUV↔RGB
10753    /// conversion.
10754    ///
10755    /// [`crate::cpu::convert`]'s `yuv_mode()` deliberately selects
10756    /// `YuvConversionMode::Fast` on cores without Armv8.1 `rdm`
10757    /// (Cortex-A53/A35 class, e.g. the i.MX 8M Plus), because `Balanced`
10758    /// emulates `SQRDMLAH` there and costs roughly 2×. `Fast` is a
10759    /// lower-precision integer approximation with a documented error of up to
10760    /// ~2/255, so a threshold calibrated on an `rdm`-capable core is
10761    /// unreachable on those cores **by design**: NV16 measures 0.9885 against
10762    /// 0.99 on an A53, while every `rdm` core passes.
10763    ///
10764    /// No CI runner is A53-class, so this only reproduces on target.
10765    ///
10766    /// Relaxing per-core keeps the assertion tight where the precise path
10767    /// runs, instead of loosening it everywhere to accommodate one core.
10768    fn yuv_threshold(balanced: f64) -> f64 {
10769        #[cfg(target_arch = "aarch64")]
10770        {
10771            if !std::arch::is_aarch64_feature_detected!("rdm") {
10772                return balanced - 0.01;
10773            }
10774        }
10775        balanced
10776    }
10777
10778    fn load_bytes_to_tensor(
10779        width: usize,
10780        height: usize,
10781        format: PixelFormat,
10782        memory: Option<TensorMemory>,
10783        bytes: &[u8],
10784    ) -> Result<TensorDyn, Error> {
10785        let src = TensorDyn::image(
10786            width,
10787            height,
10788            format,
10789            DType::U8,
10790            memory,
10791            edgefirst_tensor::CpuAccess::ReadWrite,
10792        )?;
10793        src.as_typed::<u8>()
10794            .unwrap()
10795            .map()?
10796            .as_mut_slice()
10797            .copy_from_slice(bytes);
10798        Ok(src)
10799    }
10800
10801    // DEDUP: this function is also defined verbatim in
10802    // `crates/image/src/gl/tests.rs` (inside `mod gl_tests`). Both copies
10803    // must be kept in sync. Cross-module sharing would require either a
10804    // `pub(crate)` test-helper module (which pollutes the non-test API) or a
10805    // separate test-utils crate — both are disproportionate for a single
10806    // helper. If the implementation ever diverges, extract to a shared
10807    // `test_helpers` module in this crate.
10808    fn compare_images(img1: &TensorDyn, img2: &TensorDyn, threshold: f64, name: &str) {
10809        assert_eq!(img1.height(), img2.height(), "Heights differ");
10810        assert_eq!(img1.width(), img2.width(), "Widths differ");
10811        assert_eq!(
10812            img1.format().unwrap(),
10813            img2.format().unwrap(),
10814            "PixelFormat differ"
10815        );
10816        assert!(
10817            matches!(
10818                img1.format().unwrap(),
10819                PixelFormat::Rgb | PixelFormat::Rgba | PixelFormat::Grey | PixelFormat::PlanarRgb
10820            ),
10821            "format must be Rgb or Rgba for comparison"
10822        );
10823
10824        // A pitch-padded destination -- the D3D11 staging texture behind a
10825        // Windows `TensorMemory::DmaBuf` tensor pads an RGBA row to 128 bytes
10826        // -- spaces its rows at `effective_row_stride()`, so reading the head
10827        // of the mapping as a tight image would read padding as pixels.
10828        // `copy_to_flat` is the tensor's own compaction and degenerates to one
10829        // memcpy when the tensor is tight.
10830        fn flat(img: &TensorDyn) -> Vec<u8> {
10831            let t = img.as_typed::<u8>().expect("u8 image");
10832            let mut out = vec![0u8; t.shape().iter().product::<usize>()];
10833            t.copy_to_flat(&mut out).expect("compact the padded rows");
10834            out
10835        }
10836
10837        let image1 = match img1.format().unwrap() {
10838            PixelFormat::Rgb => image::RgbImage::from_vec(
10839                img1.width().unwrap() as u32,
10840                img1.height().unwrap() as u32,
10841                flat(img1),
10842            )
10843            .unwrap(),
10844            PixelFormat::Rgba => image::RgbaImage::from_vec(
10845                img1.width().unwrap() as u32,
10846                img1.height().unwrap() as u32,
10847                flat(img1),
10848            )
10849            .unwrap()
10850            .convert(),
10851            PixelFormat::Grey => image::GrayImage::from_vec(
10852                img1.width().unwrap() as u32,
10853                img1.height().unwrap() as u32,
10854                flat(img1),
10855            )
10856            .unwrap()
10857            .convert(),
10858            PixelFormat::PlanarRgb => image::GrayImage::from_vec(
10859                img1.width().unwrap() as u32,
10860                (img1.height().unwrap() * 3) as u32,
10861                flat(img1),
10862            )
10863            .unwrap()
10864            .convert(),
10865            _ => return,
10866        };
10867
10868        let image2 = match img2.format().unwrap() {
10869            PixelFormat::Rgb => image::RgbImage::from_vec(
10870                img2.width().unwrap() as u32,
10871                img2.height().unwrap() as u32,
10872                flat(img2),
10873            )
10874            .unwrap(),
10875            PixelFormat::Rgba => image::RgbaImage::from_vec(
10876                img2.width().unwrap() as u32,
10877                img2.height().unwrap() as u32,
10878                flat(img2),
10879            )
10880            .unwrap()
10881            .convert(),
10882            PixelFormat::Grey => image::GrayImage::from_vec(
10883                img2.width().unwrap() as u32,
10884                img2.height().unwrap() as u32,
10885                flat(img2),
10886            )
10887            .unwrap()
10888            .convert(),
10889            PixelFormat::PlanarRgb => image::GrayImage::from_vec(
10890                img2.width().unwrap() as u32,
10891                (img2.height().unwrap() * 3) as u32,
10892                flat(img2),
10893            )
10894            .unwrap()
10895            .convert(),
10896            _ => return,
10897        };
10898
10899        let similarity = image_compare::rgb_similarity_structure(
10900            &image_compare::Algorithm::RootMeanSquared,
10901            &image1,
10902            &image2,
10903        )
10904        .expect("Image Comparison failed");
10905        if similarity.score < threshold {
10906            // image1.save(format!("{name}_1.png"));
10907            // image2.save(format!("{name}_2.png"));
10908            similarity
10909                .image
10910                .to_color_map()
10911                .save(format!("{name}.png"))
10912                .unwrap();
10913            panic!(
10914                "{name}: converted image and target image have similarity score too low: {} < {}",
10915                similarity.score, threshold
10916            )
10917        }
10918    }
10919
10920    fn compare_images_convert_to_rgb(
10921        img1: &TensorDyn,
10922        img2: &TensorDyn,
10923        threshold: f64,
10924        name: &str,
10925    ) {
10926        assert_eq!(img1.height(), img2.height(), "Heights differ");
10927        assert_eq!(img1.width(), img2.width(), "Widths differ");
10928
10929        let mut img_rgb1 = TensorDyn::image(
10930            img1.width().unwrap(),
10931            img1.height().unwrap(),
10932            PixelFormat::Rgb,
10933            DType::U8,
10934            Some(TensorMemory::Mem),
10935            edgefirst_tensor::CpuAccess::ReadWrite,
10936        )
10937        .unwrap();
10938        let mut img_rgb2 = TensorDyn::image(
10939            img1.width().unwrap(),
10940            img1.height().unwrap(),
10941            PixelFormat::Rgb,
10942            DType::U8,
10943            Some(TensorMemory::Mem),
10944            edgefirst_tensor::CpuAccess::ReadWrite,
10945        )
10946        .unwrap();
10947        let mut __cv = CPUProcessor::default();
10948        let r1 = __cv.convert(
10949            img1,
10950            &mut img_rgb1,
10951            crate::Rotation::None,
10952            crate::Flip::None,
10953            crate::Crop::default(),
10954        );
10955        let r2 = __cv.convert(
10956            img2,
10957            &mut img_rgb2,
10958            crate::Rotation::None,
10959            crate::Flip::None,
10960            crate::Crop::default(),
10961        );
10962        if r1.is_err() || r2.is_err() {
10963            // Fallback: compare raw bytes as greyscale strip
10964            let w = img1.width().unwrap() as u32;
10965            let data1 = img1.as_typed::<u8>().unwrap().map().unwrap().to_vec();
10966            let data2 = img2.as_typed::<u8>().unwrap().map().unwrap().to_vec();
10967            let h1 = (data1.len() as u32) / w;
10968            let h2 = (data2.len() as u32) / w;
10969            let g1 = image::GrayImage::from_vec(w, h1, data1).unwrap();
10970            let g2 = image::GrayImage::from_vec(w, h2, data2).unwrap();
10971            let similarity = image_compare::gray_similarity_structure(
10972                &image_compare::Algorithm::RootMeanSquared,
10973                &g1,
10974                &g2,
10975            )
10976            .expect("Image Comparison failed");
10977            if similarity.score < threshold {
10978                panic!(
10979                    "{name}: converted image and target image have similarity score too low: {} < {}",
10980                    similarity.score, threshold
10981                )
10982            }
10983            return;
10984        }
10985
10986        let image1 = image::RgbImage::from_vec(
10987            img_rgb1.width().unwrap() as u32,
10988            img_rgb1.height().unwrap() as u32,
10989            img_rgb1.as_typed::<u8>().unwrap().map().unwrap().to_vec(),
10990        )
10991        .unwrap();
10992
10993        let image2 = image::RgbImage::from_vec(
10994            img_rgb2.width().unwrap() as u32,
10995            img_rgb2.height().unwrap() as u32,
10996            img_rgb2.as_typed::<u8>().unwrap().map().unwrap().to_vec(),
10997        )
10998        .unwrap();
10999
11000        let similarity = image_compare::rgb_similarity_structure(
11001            &image_compare::Algorithm::RootMeanSquared,
11002            &image1,
11003            &image2,
11004        )
11005        .expect("Image Comparison failed");
11006        if similarity.score < threshold {
11007            // image1.save(format!("{name}_1.png"));
11008            // image2.save(format!("{name}_2.png"));
11009            similarity
11010                .image
11011                .to_color_map()
11012                .save(format!("{name}.png"))
11013                .unwrap();
11014            panic!(
11015                "{name}: converted image and target image have similarity score too low: {} < {}",
11016                similarity.score, threshold
11017            )
11018        }
11019    }
11020
11021    // =========================================================================
11022    // PixelFormat::Nv12 Format Tests
11023    // =========================================================================
11024
11025    #[test]
11026    fn test_nv12_image_creation() {
11027        let width = 640;
11028        let height = 480;
11029        let img = TensorDyn::image(
11030            width,
11031            height,
11032            PixelFormat::Nv12,
11033            DType::U8,
11034            None,
11035            edgefirst_tensor::CpuAccess::ReadWrite,
11036        )
11037        .unwrap();
11038
11039        assert_eq!(img.width(), Some(width));
11040        assert_eq!(img.height(), Some(height));
11041        assert_eq!(img.format().unwrap(), PixelFormat::Nv12);
11042        // PixelFormat::Nv12 uses shape [H*3/2, W] to store Y plane + UV plane
11043        assert_eq!(
11044            img.as_typed::<u8>().unwrap().shape(),
11045            &[height * 3 / 2, width]
11046        );
11047    }
11048
11049    #[test]
11050    fn test_nv12_channels() {
11051        let img = TensorDyn::image(
11052            640,
11053            480,
11054            PixelFormat::Nv12,
11055            DType::U8,
11056            None,
11057            edgefirst_tensor::CpuAccess::ReadWrite,
11058        )
11059        .unwrap();
11060        // PixelFormat::Nv12.channels() returns 1 (luma plane)
11061        assert_eq!(img.format().unwrap().channels(), 1);
11062    }
11063
11064    // =========================================================================
11065    // Tensor Format Metadata Tests
11066    // =========================================================================
11067
11068    #[test]
11069    fn test_tensor_set_format_planar() {
11070        let mut tensor = Tensor::<u8>::new(&[3, 480, 640], None, None).unwrap();
11071        tensor.set_format(PixelFormat::PlanarRgb).unwrap();
11072        assert_eq!(tensor.format(), Some(PixelFormat::PlanarRgb));
11073        assert_eq!(tensor.width(), Some(640));
11074        assert_eq!(tensor.height(), Some(480));
11075    }
11076
11077    #[test]
11078    fn test_tensor_set_format_interleaved() {
11079        let mut tensor = Tensor::<u8>::new(&[480, 640, 4], None, None).unwrap();
11080        tensor.set_format(PixelFormat::Rgba).unwrap();
11081        assert_eq!(tensor.format(), Some(PixelFormat::Rgba));
11082        assert_eq!(tensor.width(), Some(640));
11083        assert_eq!(tensor.height(), Some(480));
11084    }
11085
11086    #[test]
11087    fn test_tensordyn_image_rgb() {
11088        let img = TensorDyn::image(
11089            640,
11090            480,
11091            PixelFormat::Rgb,
11092            DType::U8,
11093            None,
11094            edgefirst_tensor::CpuAccess::ReadWrite,
11095        )
11096        .unwrap();
11097        assert_eq!(img.width(), Some(640));
11098        assert_eq!(img.height(), Some(480));
11099        assert_eq!(img.format(), Some(PixelFormat::Rgb));
11100    }
11101
11102    #[test]
11103    fn test_tensordyn_image_planar_rgb() {
11104        let img = TensorDyn::image(
11105            640,
11106            480,
11107            PixelFormat::PlanarRgb,
11108            DType::U8,
11109            None,
11110            edgefirst_tensor::CpuAccess::ReadWrite,
11111        )
11112        .unwrap();
11113        assert_eq!(img.width(), Some(640));
11114        assert_eq!(img.height(), Some(480));
11115        assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
11116    }
11117
11118    #[test]
11119    fn test_rgb_int8_format() {
11120        // Int8 variant: same PixelFormat::Rgb but with DType::I8
11121        let img = TensorDyn::image(
11122            1280,
11123            720,
11124            PixelFormat::Rgb,
11125            DType::I8,
11126            Some(TensorMemory::Mem),
11127            edgefirst_tensor::CpuAccess::ReadWrite,
11128        )
11129        .unwrap();
11130        assert_eq!(img.width(), Some(1280));
11131        assert_eq!(img.height(), Some(720));
11132        assert_eq!(img.format(), Some(PixelFormat::Rgb));
11133        assert_eq!(img.dtype(), DType::I8);
11134    }
11135
11136    #[test]
11137    fn test_planar_rgb_int8_format() {
11138        let img = TensorDyn::image(
11139            1280,
11140            720,
11141            PixelFormat::PlanarRgb,
11142            DType::I8,
11143            Some(TensorMemory::Mem),
11144            edgefirst_tensor::CpuAccess::ReadWrite,
11145        )
11146        .unwrap();
11147        assert_eq!(img.width(), Some(1280));
11148        assert_eq!(img.height(), Some(720));
11149        assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
11150        assert_eq!(img.dtype(), DType::I8);
11151    }
11152
11153    #[test]
11154    fn test_rgb_from_tensor() {
11155        let mut tensor = Tensor::<u8>::new(&[720, 1280, 3], None, None).unwrap();
11156        tensor.set_format(PixelFormat::Rgb).unwrap();
11157        let img = TensorDyn::from(tensor);
11158        assert_eq!(img.width(), Some(1280));
11159        assert_eq!(img.height(), Some(720));
11160        assert_eq!(img.format(), Some(PixelFormat::Rgb));
11161    }
11162
11163    #[test]
11164    fn test_planar_rgb_from_tensor() {
11165        let mut tensor = Tensor::<u8>::new(&[3, 720, 1280], None, None).unwrap();
11166        tensor.set_format(PixelFormat::PlanarRgb).unwrap();
11167        let img = TensorDyn::from(tensor);
11168        assert_eq!(img.width(), Some(1280));
11169        assert_eq!(img.height(), Some(720));
11170        assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
11171    }
11172
11173    #[test]
11174    fn test_dtype_determines_int8() {
11175        // DType::I8 indicates int8 data
11176        let u8_img = TensorDyn::image(
11177            64,
11178            64,
11179            PixelFormat::Rgb,
11180            DType::U8,
11181            None,
11182            edgefirst_tensor::CpuAccess::ReadWrite,
11183        )
11184        .unwrap();
11185        let i8_img = TensorDyn::image(
11186            64,
11187            64,
11188            PixelFormat::Rgb,
11189            DType::I8,
11190            None,
11191            edgefirst_tensor::CpuAccess::ReadWrite,
11192        )
11193        .unwrap();
11194        assert_eq!(u8_img.dtype(), DType::U8);
11195        assert_eq!(i8_img.dtype(), DType::I8);
11196    }
11197
11198    #[test]
11199    fn test_pixel_layout_packed_vs_planar() {
11200        // Packed vs planar layout classification
11201        assert_eq!(PixelFormat::Rgb.layout(), PixelLayout::Packed);
11202        assert_eq!(PixelFormat::Rgba.layout(), PixelLayout::Packed);
11203        assert_eq!(PixelFormat::PlanarRgb.layout(), PixelLayout::Planar);
11204        assert_eq!(PixelFormat::Nv12.layout(), PixelLayout::SemiPlanar);
11205    }
11206
11207    /// Integration test that exercises the PBO-to-PBO convert path.
11208    /// A PBO cannot hand out a host address that outlives its map guard, and
11209    /// the error has to say why rather than reading as unfinished work.
11210    ///
11211    /// `glMapBufferRange`'s pointer is valid only until `glUnmapBuffer`, and
11212    /// this backend maps with `MAP_READ_BIT | MAP_WRITE_BIT` — there is no
11213    /// `GL_MAP_PERSISTENT_BIT` and no `glBufferStorage` in the tree. Pinning
11214    /// would mean holding the buffer mapped across GL work, which is the
11215    /// opposite of what a PBO is for. `EXT_buffer_storage` would change the
11216    /// answer, but that is a buffer-allocation feature, not wiring.
11217    #[cfg(target_os = "linux")]
11218    #[cfg(feature = "opengl")]
11219    #[test]
11220    fn pbo_reports_why_it_cannot_pin() {
11221        use edgefirst_tensor::{Error, TensorTrait};
11222
11223        let converter = ImageProcessor::new().unwrap();
11224        let is_pbo = converter
11225            .opengl
11226            .as_ref()
11227            .is_some_and(|gl| gl.transfer_backend() == opengl_headless::TransferBackend::Pbo);
11228        if !is_pbo {
11229            // Direct stderr, not eprintln!: libtest captures println!/eprintln!
11230            // and replays it only for FAILING tests, so a skip would print
11231            // "... ok" with its reason discarded and be indistinguishable from
11232            // having run. See TESTING.md.
11233            use std::io::Write;
11234            let _ = writeln!(
11235                &mut std::io::stderr(),
11236                "SKIPPED: pbo_reports_why_it_cannot_pin - transfer backend is not PBO"
11237            );
11238            return;
11239        }
11240
11241        let t = converter
11242            .create_image(
11243                64,
11244                64,
11245                PixelFormat::Rgba,
11246                DType::U8,
11247                None,
11248                edgefirst_tensor::CpuAccess::ReadWrite,
11249            )
11250            .unwrap();
11251        assert_eq!(t.as_typed::<u8>().unwrap().memory(), TensorMemory::Pbo);
11252        let t = t.as_typed::<u8>().unwrap();
11253
11254        match t.pin_host(edgefirst_tensor::CpuAccess::ReadWrite) {
11255            Err(Error::NotImplemented(m)) => {
11256                assert!(
11257                    m.contains("glMapBufferRange"),
11258                    "must name the constraint: {m}"
11259                );
11260                assert!(m.contains("map()"), "must point at the supported API: {m}");
11261            }
11262            other => panic!("expected an explained NotImplemented, got {other:?}"),
11263        }
11264        // Sync is unavailable for the same reason, and must not silently succeed.
11265        assert!(matches!(
11266            t.sync_for_cpu(edgefirst_tensor::CpuAccess::Read),
11267            Err(Error::NotImplemented(_))
11268        ));
11269
11270        // The supported path still works, which is what makes the refusal safe.
11271        let g = t.map().expect("map must work where pin cannot");
11272        assert!(!g.as_slice().is_empty());
11273    }
11274
11275    /// Uses ImageProcessor::create_image() to allocate PBO-backed tensors,
11276    /// then converts between them. Skipped when GL is unavailable or the
11277    /// backend is not PBO (e.g. DMA-buf systems).
11278    #[cfg(target_os = "linux")]
11279    #[cfg(feature = "opengl")]
11280    #[test]
11281    fn test_convert_pbo_to_pbo() {
11282        let mut converter = ImageProcessor::new().unwrap();
11283
11284        // Skip if GL is not available or backend is not PBO
11285        let is_pbo = converter
11286            .opengl
11287            .as_ref()
11288            .is_some_and(|gl| gl.transfer_backend() == opengl_headless::TransferBackend::Pbo);
11289        if !is_pbo {
11290            log::warn!("Skipping test_convert_pbo_to_pbo: backend is not PBO");
11291            return;
11292        }
11293
11294        let src_w = 640;
11295        let src_h = 480;
11296        let dst_w = 320;
11297        let dst_h = 240;
11298
11299        // Create PBO-backed source image
11300        let pbo_src = converter
11301            .create_image(
11302                src_w,
11303                src_h,
11304                PixelFormat::Rgba,
11305                DType::U8,
11306                None,
11307                edgefirst_tensor::CpuAccess::ReadWrite,
11308            )
11309            .unwrap();
11310        assert_eq!(
11311            pbo_src.as_typed::<u8>().unwrap().memory(),
11312            TensorMemory::Pbo,
11313            "create_image should produce a PBO tensor"
11314        );
11315
11316        // Fill source PBO with test pattern: load JPEG then convert Mem→PBO
11317        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
11318        let jpeg_src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
11319
11320        // Resize JPEG into a Mem temp of the right size, then copy into PBO
11321        let mem_src = TensorDyn::image(
11322            src_w,
11323            src_h,
11324            PixelFormat::Rgba,
11325            DType::U8,
11326            Some(TensorMemory::Mem),
11327            edgefirst_tensor::CpuAccess::ReadWrite,
11328        )
11329        .unwrap();
11330        let (result, _jpeg_src, mem_src) = convert_img(
11331            &mut CPUProcessor::new(),
11332            jpeg_src,
11333            mem_src,
11334            Rotation::None,
11335            Flip::None,
11336            Crop::no_crop(),
11337        );
11338        result.unwrap();
11339
11340        // Copy pixel data into the PBO source by mapping it
11341        {
11342            let src_data = mem_src.as_typed::<u8>().unwrap().map().unwrap();
11343            let mut pbo_map = pbo_src.as_typed::<u8>().unwrap().map().unwrap();
11344            pbo_map.copy_from_slice(&src_data);
11345        }
11346
11347        // Create PBO-backed destination image
11348        let pbo_dst = converter
11349            .create_image(
11350                dst_w,
11351                dst_h,
11352                PixelFormat::Rgba,
11353                DType::U8,
11354                None,
11355                edgefirst_tensor::CpuAccess::ReadWrite,
11356            )
11357            .unwrap();
11358        assert_eq!(
11359            pbo_dst.as_typed::<u8>().unwrap().memory(),
11360            TensorMemory::Pbo
11361        );
11362
11363        // Convert PBO→PBO (this exercises convert_pbo_to_pbo)
11364        let mut pbo_dst = pbo_dst;
11365        let result = converter.convert(
11366            &pbo_src,
11367            &mut pbo_dst,
11368            Rotation::None,
11369            Flip::None,
11370            Crop::no_crop(),
11371        );
11372        result.unwrap();
11373
11374        // Verify: compare with CPU-only conversion of the same input
11375        let cpu_dst = TensorDyn::image(
11376            dst_w,
11377            dst_h,
11378            PixelFormat::Rgba,
11379            DType::U8,
11380            Some(TensorMemory::Mem),
11381            edgefirst_tensor::CpuAccess::ReadWrite,
11382        )
11383        .unwrap();
11384        let (result, _mem_src, cpu_dst) = convert_img(
11385            &mut CPUProcessor::new(),
11386            mem_src,
11387            cpu_dst,
11388            Rotation::None,
11389            Flip::None,
11390            Crop::no_crop(),
11391        );
11392        result.unwrap();
11393
11394        let pbo_dst_img = {
11395            let mut __t = pbo_dst.into_u8().unwrap();
11396            __t.set_format(PixelFormat::Rgba).unwrap();
11397            TensorDyn::from(__t)
11398        };
11399        compare_images(&pbo_dst_img, &cpu_dst, 0.95, function!());
11400        log::info!("test_convert_pbo_to_pbo: PASS — PBO-to-PBO convert matches CPU reference");
11401    }
11402
11403    #[test]
11404    fn test_image_bgra() {
11405        let img = TensorDyn::image(
11406            640,
11407            480,
11408            PixelFormat::Bgra,
11409            DType::U8,
11410            Some(edgefirst_tensor::TensorMemory::Mem),
11411            edgefirst_tensor::CpuAccess::ReadWrite,
11412        )
11413        .unwrap();
11414        assert_eq!(img.width(), Some(640));
11415        assert_eq!(img.height(), Some(480));
11416        assert_eq!(img.format().unwrap().channels(), 4);
11417        assert_eq!(img.format().unwrap(), PixelFormat::Bgra);
11418    }
11419
11420    // ========================================================================
11421    // Tests for EDGEFIRST_FORCE_BACKEND env var
11422    // ========================================================================
11423
11424    #[test]
11425    fn test_force_backend_cpu() {
11426        let _lock = acquire_env_lock();
11427        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
11428        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
11429        let converter = ImageProcessor::new().unwrap();
11430        assert!(converter.cpu.is_some());
11431        assert_eq!(converter.forced_backend, Some(ForcedBackend::Cpu));
11432    }
11433
11434    #[test]
11435    fn test_force_backend_invalid() {
11436        let _lock = acquire_env_lock();
11437        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
11438        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "invalid") };
11439        let result = ImageProcessor::new();
11440        assert!(
11441            matches!(&result, Err(Error::ForcedBackendUnavailable(s)) if s.contains("unknown")),
11442            "invalid backend value should return ForcedBackendUnavailable error: {result:?}"
11443        );
11444    }
11445
11446    #[test]
11447    fn test_force_backend_unset() {
11448        let _lock = acquire_env_lock();
11449        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
11450        unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") };
11451        let converter = ImageProcessor::new().unwrap();
11452        assert!(converter.forced_backend.is_none());
11453    }
11454
11455    // ========================================================================
11456    // Tests for hybrid mask path error handling
11457    // ========================================================================
11458
11459    #[test]
11460    fn test_draw_proto_masks_no_cpu_returns_error() {
11461        // Serialize against all other env-var-mutating tests.
11462        let _lock = acquire_env_lock();
11463        let _guard = EnvGuard::snapshot(&[
11464            "EDGEFIRST_FORCE_BACKEND",
11465            "EDGEFIRST_DISABLE_GL",
11466            "EDGEFIRST_DISABLE_G2D",
11467            "EDGEFIRST_DISABLE_CPU",
11468        ]);
11469
11470        // Disable all backends so cpu.is_none() after construction.
11471        unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
11472        unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
11473        unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
11474
11475        let mut converter = ImageProcessor::new().unwrap();
11476        assert!(converter.cpu.is_none(), "CPU should be disabled");
11477
11478        let dst = TensorDyn::image(
11479            640,
11480            480,
11481            PixelFormat::Rgba,
11482            DType::U8,
11483            Some(TensorMemory::Mem),
11484            edgefirst_tensor::CpuAccess::ReadWrite,
11485        )
11486        .unwrap();
11487        let mut dst_dyn = dst;
11488        let det = [DetectBox {
11489            bbox: edgefirst_tensor::BoundingBox {
11490                xmin: 0.1,
11491                ymin: 0.1,
11492                xmax: 0.5,
11493                ymax: 0.5,
11494            },
11495            score: 0.9,
11496            label: 0,
11497        }];
11498        let proto_data = {
11499            use edgefirst_tensor::{Tensor, TensorDyn};
11500            let coeff_t = Tensor::<f32>::from_slice(&[0.5_f32; 4], &[1, 4]).unwrap();
11501            let protos_t =
11502                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
11503            ProtoData {
11504                mask_coefficients: TensorDyn::F32(coeff_t),
11505                protos: TensorDyn::F32(protos_t),
11506                layout: ProtoLayout::Nhwc,
11507            }
11508        };
11509        let result =
11510            converter.draw_proto_masks(&mut dst_dyn, &det, &proto_data, Default::default());
11511        assert!(
11512            matches!(&result, Err(Error::Internal(s)) if s.contains("CPU backend")),
11513            "draw_proto_masks without CPU should return Internal error: {result:?}"
11514        );
11515    }
11516
11517    #[test]
11518    fn test_draw_proto_masks_cpu_fallback_works() {
11519        // Force CPU-only backend to ensure the CPU fallback path executes.
11520        // Serialized under ENV_MUTEX so we don't race with disable-var tests.
11521        let _lock = acquire_env_lock();
11522        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
11523        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
11524        let mut converter = ImageProcessor::new().unwrap();
11525        assert!(converter.cpu.is_some());
11526
11527        let dst = TensorDyn::image(
11528            64,
11529            64,
11530            PixelFormat::Rgba,
11531            DType::U8,
11532            Some(TensorMemory::Mem),
11533            edgefirst_tensor::CpuAccess::ReadWrite,
11534        )
11535        .unwrap();
11536        let mut dst_dyn = dst;
11537        let det = [DetectBox {
11538            bbox: edgefirst_tensor::BoundingBox {
11539                xmin: 0.1,
11540                ymin: 0.1,
11541                xmax: 0.5,
11542                ymax: 0.5,
11543            },
11544            score: 0.9,
11545            label: 0,
11546        }];
11547        let proto_data = {
11548            use edgefirst_tensor::{Tensor, TensorDyn};
11549            let coeff_t = Tensor::<f32>::from_slice(&[0.5_f32; 4], &[1, 4]).unwrap();
11550            let protos_t =
11551                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
11552            ProtoData {
11553                mask_coefficients: TensorDyn::F32(coeff_t),
11554                protos: TensorDyn::F32(protos_t),
11555                layout: ProtoLayout::Nhwc,
11556            }
11557        };
11558        let result =
11559            converter.draw_proto_masks(&mut dst_dyn, &det, &proto_data, Default::default());
11560        assert!(result.is_ok(), "CPU fallback path should work: {result:?}");
11561    }
11562
11563    // ============================================================
11564    // draw_decoded_masks / draw_proto_masks — 4-scenario pixel-
11565    // verified tests. Exercises each backend against the full
11566    // output-contract matrix:
11567    //
11568    //   | detections | background | expected dst             |
11569    //   |------------|------------|--------------------------|
11570    //   | empty      | none       | fully cleared (0x00)     |
11571    //   | empty      | set        | fully equal to bg        |
11572    //   | set        | none       | cleared outside box +    |
11573    //   |            |            | mask-coloured inside     |
11574    //   | set        | set        | bg outside box + mask    |
11575    //   |            |            | blended inside           |
11576    //
11577    // Every test pre-fills dst with a non-zero "dirty" pattern so
11578    // that any silent `return Ok(())` leaks the pattern into the
11579    // asserted output and fails loudly.
11580    // ============================================================
11581
11582    // =========================================================================
11583    // Env-var serialisation helpers
11584    //
11585    // ALL tests that mutate any EDGEFIRST_* backend env var must hold
11586    // ENV_MUTEX for their full duration.  This single mutex serialises
11587    // test_disable_env_var, test_draw_proto_masks_no_cpu_returns_error,
11588    // test_force_backend_*, with_force_backend, and with_env — preventing
11589    // any two of them from racing in a parallel `cargo test` run.
11590    // =========================================================================
11591
11592    /// Acquire the process-wide env-var mutex.  Returns a guard that must be
11593    /// kept alive for the entire duration of the test body.
11594    fn acquire_env_lock() -> std::sync::MutexGuard<'static, ()> {
11595        use std::sync::{Mutex, OnceLock};
11596        static ENV_MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
11597        ENV_MUTEX
11598            .get_or_init(|| Mutex::new(()))
11599            .lock()
11600            .unwrap_or_else(|e| e.into_inner())
11601    }
11602
11603    /// RAII guard that snapshots a set of env vars on construction and
11604    /// restores them on `Drop`, even if the test panics.
11605    struct EnvGuard {
11606        vars: Vec<(&'static str, Option<String>)>,
11607    }
11608
11609    impl EnvGuard {
11610        /// Snapshot the current values of `names`.  Call this while holding
11611        /// the env lock (the lock is not taken here — that is the caller's
11612        /// responsibility so the lock scope can be wider than the guard).
11613        fn snapshot(names: &[&'static str]) -> Self {
11614            Self {
11615                vars: names.iter().map(|&k| (k, std::env::var(k).ok())).collect(),
11616            }
11617        }
11618    }
11619
11620    impl Drop for EnvGuard {
11621        fn drop(&mut self) {
11622            for (k, v) in &self.vars {
11623                match v {
11624                    Some(s) => unsafe { std::env::set_var(k, s) },
11625                    None => unsafe { std::env::remove_var(k) },
11626                }
11627            }
11628        }
11629    }
11630
11631    /// Run `body` with `EDGEFIRST_FORCE_BACKEND` temporarily set (or
11632    /// removed), restoring the prior value afterward. Tests are env-
11633    /// serialized via the process-wide `ENV_MUTEX`.
11634    fn with_force_backend<R>(value: Option<&str>, body: impl FnOnce() -> R) -> R {
11635        let _lock = acquire_env_lock();
11636        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
11637        match value {
11638            Some(v) => unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", v) },
11639            None => unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") },
11640        }
11641        body()
11642    }
11643
11644    /// Allocate an RGBA image tensor and pre-fill every byte with a
11645    /// distinctive non-zero pattern. Any test that relies on the old
11646    /// "dst is already cleared" assumption will see this pattern leak
11647    /// through to the output and fail.
11648    fn make_dirty_dst(w: usize, h: usize, mem: Option<TensorMemory>) -> TensorDyn {
11649        let dst = TensorDyn::image(
11650            w,
11651            h,
11652            PixelFormat::Rgba,
11653            DType::U8,
11654            mem,
11655            edgefirst_tensor::CpuAccess::ReadWrite,
11656        )
11657        .unwrap();
11658        {
11659            use edgefirst_tensor::TensorMapTrait;
11660            let u8t = dst.as_typed::<u8>().unwrap();
11661            let mut map = u8t.map().unwrap();
11662            for (i, b) in map.as_mut_slice().iter_mut().enumerate() {
11663                *b = 0xA0u8.wrapping_add((i as u8) & 0x3F);
11664            }
11665        }
11666        dst
11667    }
11668
11669    /// Allocate an RGBA background filled with a constant colour.
11670    fn make_bg(w: usize, h: usize, mem: Option<TensorMemory>, rgba: [u8; 4]) -> TensorDyn {
11671        let bg = TensorDyn::image(
11672            w,
11673            h,
11674            PixelFormat::Rgba,
11675            DType::U8,
11676            mem,
11677            edgefirst_tensor::CpuAccess::ReadWrite,
11678        )
11679        .unwrap();
11680        {
11681            use edgefirst_tensor::TensorMapTrait;
11682            let u8t = bg.as_typed::<u8>().unwrap();
11683            let mut map = u8t.map().unwrap();
11684            for chunk in map.as_mut_slice().as_chunks_mut::<4>().0 {
11685                chunk.copy_from_slice(&rgba);
11686            }
11687        }
11688        bg
11689    }
11690
11691    fn pixel_at(dst: &TensorDyn, x: usize, y: usize) -> [u8; 4] {
11692        use edgefirst_tensor::TensorMapTrait;
11693        let w = dst.width().unwrap();
11694        let off = (y * w + x) * 4;
11695        let u8t = dst.as_typed::<u8>().unwrap();
11696        let map = u8t.map().unwrap();
11697        let s = map.as_slice();
11698        [s[off], s[off + 1], s[off + 2], s[off + 3]]
11699    }
11700
11701    fn assert_every_pixel_eq(dst: &TensorDyn, expected: [u8; 4], case: &str) {
11702        use edgefirst_tensor::TensorMapTrait;
11703        let u8t = dst.as_typed::<u8>().unwrap();
11704        let map = u8t.map().unwrap();
11705        for (i, chunk) in map.as_slice().as_chunks::<4>().0.iter().enumerate() {
11706            assert_eq!(
11707                chunk, &expected,
11708                "{case}: pixel idx {i} = {chunk:?}, expected {expected:?}"
11709            );
11710        }
11711    }
11712
11713    /// Scenario 1: empty detections, empty segmentation, no background
11714    /// → dst must be fully cleared to 0x00000000.
11715    fn scenario_empty_no_bg(processor: &mut ImageProcessor, case: &str) {
11716        let mut dst = make_dirty_dst(64, 64, None);
11717        processor
11718            .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::default())
11719            .unwrap_or_else(|e| panic!("{case}/decoded_masks empty+no-bg failed: {e:?}"));
11720        assert_every_pixel_eq(&dst, [0, 0, 0, 0], &format!("{case}/decoded"));
11721
11722        let mut dst = make_dirty_dst(64, 64, None);
11723        let proto = {
11724            use edgefirst_tensor::{Tensor, TensorDyn};
11725            // Placeholder (no detections); shape [1, 4] to keep the tensor well-formed.
11726            let coeff_t = Tensor::<f32>::from_slice(&[0.0_f32; 4], &[1, 4]).unwrap();
11727            let protos_t =
11728                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
11729            ProtoData {
11730                mask_coefficients: TensorDyn::F32(coeff_t),
11731                protos: TensorDyn::F32(protos_t),
11732                layout: ProtoLayout::Nhwc,
11733            }
11734        };
11735        processor
11736            .draw_proto_masks(&mut dst, &[], &proto, MaskOverlay::default())
11737            .unwrap_or_else(|e| panic!("{case}/proto_masks empty+no-bg failed: {e:?}"));
11738        assert_every_pixel_eq(&dst, [0, 0, 0, 0], &format!("{case}/proto"));
11739    }
11740
11741    /// Scenario 2: empty detections, empty segmentation, background set
11742    /// → dst must be fully equal to bg.
11743    fn scenario_empty_with_bg(processor: &mut ImageProcessor, case: &str) {
11744        let bg_color = [42, 99, 200, 255];
11745        let bg = make_bg(64, 64, None, bg_color);
11746        let overlay = MaskOverlay::new().with_background(&bg);
11747
11748        let mut dst = make_dirty_dst(64, 64, None);
11749        processor
11750            .draw_decoded_masks(&mut dst, &[], &[], overlay)
11751            .unwrap_or_else(|e| panic!("{case}/decoded_masks empty+bg failed: {e:?}"));
11752        assert_every_pixel_eq(&dst, bg_color, &format!("{case}/decoded bg blit"));
11753
11754        let mut dst = make_dirty_dst(64, 64, None);
11755        let proto = {
11756            use edgefirst_tensor::{Tensor, TensorDyn};
11757            // Placeholder (no detections); shape [1, 4] to keep the tensor well-formed.
11758            let coeff_t = Tensor::<f32>::from_slice(&[0.0_f32; 4], &[1, 4]).unwrap();
11759            let protos_t =
11760                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
11761            ProtoData {
11762                mask_coefficients: TensorDyn::F32(coeff_t),
11763                protos: TensorDyn::F32(protos_t),
11764                layout: ProtoLayout::Nhwc,
11765            }
11766        };
11767        processor
11768            .draw_proto_masks(&mut dst, &[], &proto, overlay)
11769            .unwrap_or_else(|e| panic!("{case}/proto_masks empty+bg failed: {e:?}"));
11770        assert_every_pixel_eq(&dst, bg_color, &format!("{case}/proto bg blit"));
11771    }
11772
11773    /// Scenario 3: one detection with a fully-opaque segmentation fill,
11774    /// no background → outside the box dst must be 0x00, inside it must
11775    /// be a non-zero mask colour (the render_segmentation output).
11776    fn scenario_detect_no_bg(processor: &mut ImageProcessor, case: &str) {
11777        use edgefirst_tensor::Segmentation;
11778        use ndarray::Array3;
11779        processor
11780            .set_class_colors(&[[200, 80, 40, 255]])
11781            .expect("set_class_colors");
11782
11783        let detect = DetectBox {
11784            bbox: [0.25, 0.25, 0.75, 0.75].into(),
11785            score: 0.99,
11786            label: 0,
11787        };
11788        let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
11789        let seg = Segmentation {
11790            segmentation: edgefirst_tensor::Tensor::from_arrayview3(seg_arr.view())
11791                .unwrap()
11792                .into(),
11793            xmin: 0.25,
11794            ymin: 0.25,
11795            xmax: 0.75,
11796            ymax: 0.75,
11797        };
11798
11799        let mut dst = make_dirty_dst(64, 64, None);
11800        processor
11801            .draw_decoded_masks(&mut dst, &[detect], &[seg], MaskOverlay::default())
11802            .unwrap_or_else(|e| panic!("{case}/decoded_masks detect+no-bg failed: {e:?}"));
11803
11804        // Outside the bbox (corner): must be cleared black.
11805        let corner = pixel_at(&dst, 2, 2);
11806        assert_eq!(
11807            corner,
11808            [0, 0, 0, 0],
11809            "{case}/decoded: corner (2,2) leaked dirty pattern: {corner:?}"
11810        );
11811        // Inside the bbox (center): the mask colour must be visible.
11812        // Any non-zero pixel is acceptable — exact rendering varies
11813        // between backends (GL smoothstep, CPU nearest).
11814        let center = pixel_at(&dst, 32, 32);
11815        assert!(
11816            center != [0, 0, 0, 0],
11817            "{case}/decoded: center (32,32) was not coloured: {center:?}"
11818        );
11819    }
11820
11821    /// Scenario 4: detection + background. Outside the box must match
11822    /// bg; inside the box must NOT match bg (mask blended on top).
11823    fn scenario_detect_with_bg(processor: &mut ImageProcessor, case: &str) {
11824        use edgefirst_tensor::Segmentation;
11825        use ndarray::Array3;
11826        processor
11827            .set_class_colors(&[[200, 80, 40, 255]])
11828            .expect("set_class_colors");
11829        let bg_color = [10, 20, 30, 255];
11830        let bg = make_bg(64, 64, None, bg_color);
11831
11832        let detect = DetectBox {
11833            bbox: [0.25, 0.25, 0.75, 0.75].into(),
11834            score: 0.99,
11835            label: 0,
11836        };
11837        let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
11838        let seg = Segmentation {
11839            segmentation: edgefirst_tensor::Tensor::from_arrayview3(seg_arr.view())
11840                .unwrap()
11841                .into(),
11842            xmin: 0.25,
11843            ymin: 0.25,
11844            xmax: 0.75,
11845            ymax: 0.75,
11846        };
11847
11848        let overlay = MaskOverlay::new().with_background(&bg);
11849        let mut dst = make_dirty_dst(64, 64, None);
11850        processor
11851            .draw_decoded_masks(&mut dst, &[detect], &[seg], overlay)
11852            .unwrap_or_else(|e| panic!("{case}/decoded_masks detect+bg failed: {e:?}"));
11853
11854        // Outside the bbox (corner): bg colour.
11855        let corner = pixel_at(&dst, 2, 2);
11856        assert_eq!(
11857            corner, bg_color,
11858            "{case}/decoded: corner (2,2) should show bg {bg_color:?} got {corner:?}"
11859        );
11860        // Inside the bbox (center): mask blended on bg, must differ from
11861        // pure bg (alpha-blend with mask colour produces a distinct shade).
11862        let center = pixel_at(&dst, 32, 32);
11863        assert!(
11864            center != bg_color,
11865            "{case}/decoded: center (32,32) should differ from bg {bg_color:?}, got {center:?}"
11866        );
11867    }
11868
11869    /// Run all 4 scenarios against the processor. Skip gracefully if
11870    /// construction fails (backend unavailable on this host).
11871    fn run_all_scenarios(
11872        force_backend: Option<&'static str>,
11873        case: &'static str,
11874        require_dma_for_bg: bool,
11875    ) {
11876        if require_dma_for_bg && !edgefirst_tensor::is_dma_available() {
11877            crate::test_support::report_skip(&format!("{case} — DMA not available on this host"));
11878            return;
11879        }
11880        let processor_result = with_force_backend(force_backend, ImageProcessor::new);
11881        let mut processor = match processor_result {
11882            Ok(p) => p,
11883            Err(e) => {
11884                crate::test_support::report_skip(&format!("{case} — backend init failed: {e:?}"));
11885                return;
11886            }
11887        };
11888        scenario_empty_no_bg(&mut processor, case);
11889        scenario_empty_with_bg(&mut processor, case);
11890        scenario_detect_no_bg(&mut processor, case);
11891        scenario_detect_with_bg(&mut processor, case);
11892    }
11893
11894    #[test]
11895    fn test_draw_masks_4_scenarios_cpu() {
11896        run_all_scenarios(Some("cpu"), "cpu", false);
11897    }
11898
11899    #[test]
11900    fn test_draw_masks_4_scenarios_auto() {
11901        run_all_scenarios(None, "auto", false);
11902    }
11903
11904    #[cfg(any(
11905        target_os = "linux",
11906        target_os = "macos",
11907        target_os = "ios",
11908        target_os = "android",
11909        target_os = "windows"
11910    ))]
11911    #[cfg(feature = "opengl")]
11912    #[test]
11913    fn test_draw_masks_4_scenarios_opengl() {
11914        run_all_scenarios(Some("opengl"), "opengl", false);
11915    }
11916
11917    /// G2D forced backend: exercises the zero-detection empty-frame
11918    /// paths via `g2d_clear` and `g2d_blit`. Scenarios 3 and 4 (with
11919    /// detections) expect `NotImplemented` since G2D has no rasterizer
11920    /// for boxes / masks.
11921    #[cfg(target_os = "linux")]
11922    #[test]
11923    fn test_draw_masks_zero_detection_g2d_forced() {
11924        if !edgefirst_tensor::is_dma_available() {
11925            crate::test_support::report_skip("g2d forced — DMA not available on this host");
11926            return;
11927        }
11928        let processor_result = with_force_backend(Some("g2d"), ImageProcessor::new);
11929        let mut processor = match processor_result {
11930            Ok(p) => p,
11931            Err(e) => {
11932                crate::test_support::report_skip(&format!("g2d forced — init failed: {e:?}"));
11933                return;
11934            }
11935        };
11936
11937        // Case 1: empty + no bg. G2D requires DMA-backed dst.
11938        let mut dst = TensorDyn::image(
11939            64,
11940            64,
11941            PixelFormat::Rgba,
11942            DType::U8,
11943            Some(TensorMemory::DmaBuf),
11944            edgefirst_tensor::CpuAccess::ReadWrite,
11945        )
11946        .unwrap();
11947        {
11948            use edgefirst_tensor::TensorMapTrait;
11949            let u8t = dst.as_typed_mut::<u8>().unwrap();
11950            let mut map = u8t.map().unwrap();
11951            map.as_mut_slice().fill(0xBB);
11952        }
11953        processor
11954            .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::default())
11955            .expect("g2d empty+no-bg");
11956        assert_every_pixel_eq(&dst, [0, 0, 0, 0], "g2d/case1 cleared");
11957
11958        // Case 2: empty + bg. Both surfaces DMA-backed for g2d_blit.
11959        let bg_color = [7, 11, 13, 255];
11960        let bg = {
11961            let t = TensorDyn::image(
11962                64,
11963                64,
11964                PixelFormat::Rgba,
11965                DType::U8,
11966                Some(TensorMemory::DmaBuf),
11967                edgefirst_tensor::CpuAccess::ReadWrite,
11968            )
11969            .unwrap();
11970            {
11971                use edgefirst_tensor::TensorMapTrait;
11972                let u8t = t.as_typed::<u8>().unwrap();
11973                let mut map = u8t.map().unwrap();
11974                for chunk in map.as_mut_slice().as_chunks_mut::<4>().0 {
11975                    chunk.copy_from_slice(&bg_color);
11976                }
11977            }
11978            t
11979        };
11980        let mut dst = TensorDyn::image(
11981            64,
11982            64,
11983            PixelFormat::Rgba,
11984            DType::U8,
11985            Some(TensorMemory::DmaBuf),
11986            edgefirst_tensor::CpuAccess::ReadWrite,
11987        )
11988        .unwrap();
11989        {
11990            use edgefirst_tensor::TensorMapTrait;
11991            let u8t = dst.as_typed_mut::<u8>().unwrap();
11992            let mut map = u8t.map().unwrap();
11993            map.as_mut_slice().fill(0x55);
11994        }
11995        processor
11996            .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::new().with_background(&bg))
11997            .expect("g2d empty+bg");
11998        assert_every_pixel_eq(&dst, bg_color, "g2d/case2 bg blit");
11999
12000        // Case 3 and 4: detect present — must return NotImplemented.
12001        let detect = DetectBox {
12002            bbox: [0.25, 0.25, 0.75, 0.75].into(),
12003            score: 0.9,
12004            label: 0,
12005        };
12006        let mut dst = TensorDyn::image(
12007            64,
12008            64,
12009            PixelFormat::Rgba,
12010            DType::U8,
12011            Some(TensorMemory::DmaBuf),
12012            edgefirst_tensor::CpuAccess::ReadWrite,
12013        )
12014        .unwrap();
12015        let err = processor
12016            .draw_decoded_masks(&mut dst, &[detect], &[], MaskOverlay::default())
12017            .expect_err("g2d must reject detect-present draw_decoded_masks");
12018        assert!(
12019            matches!(err, Error::NotImplemented(_)),
12020            "g2d case3 wrong error: {err:?}"
12021        );
12022    }
12023
12024    #[test]
12025    fn test_set_format_then_cpu_convert() {
12026        // Force CPU backend; serialized under ENV_MUTEX to avoid racing with
12027        // test_force_backend_* and test_disable_env_var.
12028        let _lock = acquire_env_lock();
12029        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
12030        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
12031        let mut processor = ImageProcessor::new().unwrap();
12032
12033        // Load a source image
12034        let image = edgefirst_bench::testdata::read("zidane.jpg");
12035        let src = load_image_test_helper(&image, Some(PixelFormat::Rgba), None).unwrap();
12036
12037        // Create a raw tensor, then attach format — simulating the from_fd workflow
12038        let mut dst =
12039            TensorDyn::new(&[640, 640, 3], DType::U8, Some(TensorMemory::Mem), None).unwrap();
12040        dst.set_format(PixelFormat::Rgb).unwrap();
12041
12042        // Convert should work with the set_format-annotated tensor
12043        processor
12044            .convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
12045            .unwrap();
12046
12047        // Verify format survived conversion
12048        assert_eq!(dst.format(), Some(PixelFormat::Rgb));
12049        assert_eq!(dst.width(), Some(640));
12050        assert_eq!(dst.height(), Some(640));
12051    }
12052
12053    /// Verify that creating multiple ImageProcessors on the same thread and
12054    /// performing a resize on each does not deadlock or error.
12055    ///
12056    /// Uses automatic memory allocation (DMA → PBO → Mem fallback) so that
12057    /// hardware backends (OpenGL, G2D) are exercised on capable targets.
12058    #[test]
12059    fn test_multiple_image_processors_same_thread() {
12060        // Hold the env mutex so env-var-mutating tests can't corrupt the state
12061        // seen by ImageProcessor::new() calls during this test.
12062        let _lock = acquire_env_lock();
12063        let mut processors: Vec<ImageProcessor> = (0..4)
12064            .map(|_| ImageProcessor::new().expect("ImageProcessor::new() failed"))
12065            .collect();
12066
12067        for proc in &mut processors {
12068            let src = proc
12069                .create_image(
12070                    128,
12071                    128,
12072                    PixelFormat::Rgb,
12073                    DType::U8,
12074                    None,
12075                    edgefirst_tensor::CpuAccess::ReadWrite,
12076                )
12077                .expect("create src failed");
12078            let mut dst = proc
12079                .create_image(
12080                    64,
12081                    64,
12082                    PixelFormat::Rgb,
12083                    DType::U8,
12084                    None,
12085                    edgefirst_tensor::CpuAccess::ReadWrite,
12086                )
12087                .expect("create dst failed");
12088            proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
12089                .expect("convert failed");
12090            assert_eq!(dst.width(), Some(64));
12091            assert_eq!(dst.height(), Some(64));
12092        }
12093    }
12094
12095    /// Verify that creating ImageProcessors on separate threads and performing
12096    /// a resize on each does not deadlock or error.
12097    ///
12098    /// Uses automatic memory allocation (DMA → PBO → Mem fallback) so that
12099    /// hardware backends (OpenGL, G2D) are exercised on capable targets.
12100    /// A 60-second timeout prevents CI from hanging on deadlock regressions.
12101    #[test]
12102    fn test_multiple_image_processors_separate_threads() {
12103        use std::sync::mpsc;
12104        use std::time::Duration;
12105
12106        // The Vivante GC7000UL driver (i.MX 8M Plus) double-frees on concurrent
12107        // EGL context teardown — four processors spun up on four threads here
12108        // trips it and aborts the whole test binary (SIGABRT, not a catchable
12109        // panic). The bug is the driver's, not the HAL's; this test is kept so
12110        // it still exercises the multi-context path on every other GPU. The
12111        // on-target GitHub Actions imx8mp runner sets EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS
12112        // to skip just this case there, while the run stays red anywhere else
12113        // a regression appears. (Skip, not #[ignore]: the platform is decided
12114        // at runtime, not compile time.)
12115        if std::env::var_os("EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS").is_some() {
12116            crate::test_support::report_skip(
12117                "test_multiple_image_processors_separate_threads — known Vivante \
12118                 GC7000UL concurrent-EGL-teardown double-free \
12119                 (EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS set)",
12120            );
12121            return;
12122        }
12123
12124        const TIMEOUT: Duration = Duration::from_secs(60);
12125
12126        // Hold the env mutex so env-var-mutating tests can't corrupt ImageProcessor::new()
12127        // calls made inside the spawned threads during this test.
12128        let _lock = acquire_env_lock();
12129
12130        let (tx, rx) = mpsc::channel::<()>();
12131
12132        std::thread::spawn(move || {
12133            let handles: Vec<_> = (0..4)
12134                .map(|i| {
12135                    std::thread::spawn(move || {
12136                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
12137                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
12138                        });
12139                        let src = proc
12140                            .create_image(
12141                                128,
12142                                128,
12143                                PixelFormat::Rgb,
12144                                DType::U8,
12145                                None,
12146                                edgefirst_tensor::CpuAccess::ReadWrite,
12147                            )
12148                            .unwrap_or_else(|e| panic!("create src failed on thread {i}: {e}"));
12149                        let mut dst = proc
12150                            .create_image(
12151                                64,
12152                                64,
12153                                PixelFormat::Rgb,
12154                                DType::U8,
12155                                None,
12156                                edgefirst_tensor::CpuAccess::ReadWrite,
12157                            )
12158                            .unwrap_or_else(|e| panic!("create dst failed on thread {i}: {e}"));
12159                        proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
12160                            .unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
12161                        assert_eq!(dst.width(), Some(64));
12162                        assert_eq!(dst.height(), Some(64));
12163                    })
12164                })
12165                .collect();
12166
12167            for (i, h) in handles.into_iter().enumerate() {
12168                h.join()
12169                    .unwrap_or_else(|e| panic!("thread {i} panicked: {e:?}"));
12170            }
12171
12172            let _ = tx.send(());
12173        });
12174
12175        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
12176            panic!("test_multiple_image_processors_separate_threads timed out after {TIMEOUT:?}")
12177        });
12178    }
12179
12180    /// Verify that 4 fully-initialized ImageProcessors on separate threads can
12181    /// all operate concurrently without deadlocking each other.
12182    ///
12183    /// All processors are created first, then a barrier synchronizes them so
12184    /// they all start converting at the same instant — maximizing contention.
12185    /// A 60-second timeout prevents CI from hanging on deadlock regressions.
12186    #[test]
12187    fn test_image_processors_concurrent_operations() {
12188        use std::sync::{mpsc, Arc, Barrier};
12189        use std::time::Duration;
12190
12191        const N: usize = 4;
12192        const ROUNDS: usize = 10;
12193        const TIMEOUT: Duration = Duration::from_secs(60);
12194
12195        // Hold the env mutex so env-var-mutating tests can't corrupt ImageProcessor::new()
12196        // calls made inside the spawned threads during this test.
12197        let _lock = acquire_env_lock();
12198
12199        let (tx, rx) = mpsc::channel::<()>();
12200
12201        std::thread::spawn(move || {
12202            let barrier = Arc::new(Barrier::new(N));
12203
12204            let handles: Vec<_> = (0..N)
12205                .map(|i| {
12206                    let barrier = Arc::clone(&barrier);
12207                    std::thread::spawn(move || {
12208                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
12209                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
12210                        });
12211
12212                        // All threads wait here until every processor is initialized.
12213                        barrier.wait();
12214
12215                        // Now all 4 hammer the GPU concurrently.
12216                        for round in 0..ROUNDS {
12217                            let src = proc
12218                                .create_image(
12219                                    128,
12220                                    128,
12221                                    PixelFormat::Rgb,
12222                                    DType::U8,
12223                                    None,
12224                                    edgefirst_tensor::CpuAccess::ReadWrite,
12225                                )
12226                                .unwrap_or_else(|e| {
12227                                    panic!("create src failed on thread {i} round {round}: {e}")
12228                                });
12229                            let mut dst = proc
12230                                .create_image(
12231                                    64,
12232                                    64,
12233                                    PixelFormat::Rgb,
12234                                    DType::U8,
12235                                    None,
12236                                    edgefirst_tensor::CpuAccess::ReadWrite,
12237                                )
12238                                .unwrap_or_else(|e| {
12239                                    panic!("create dst failed on thread {i} round {round}: {e}")
12240                                });
12241                            proc.convert(
12242                                &src,
12243                                &mut dst,
12244                                Rotation::None,
12245                                Flip::None,
12246                                Crop::default(),
12247                            )
12248                            .unwrap_or_else(|e| {
12249                                panic!("convert failed on thread {i} round {round}: {e}")
12250                            });
12251                            assert_eq!(dst.width(), Some(64));
12252                            assert_eq!(dst.height(), Some(64));
12253                        }
12254                    })
12255                })
12256                .collect();
12257
12258            for (i, h) in handles.into_iter().enumerate() {
12259                h.join()
12260                    .unwrap_or_else(|e| panic!("thread {i} panicked: {e:?}"));
12261            }
12262
12263            let _ = tx.send(());
12264        });
12265
12266        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
12267            panic!("test_image_processors_concurrent_operations timed out after {TIMEOUT:?}")
12268        });
12269    }
12270
12271    /// THE parallel-processors demonstration test: 4 ImageProcessors on 4
12272    /// threads, each with its own GL context and worker, converting
12273    /// per-thread-DISTINCT synthetic inputs concurrently (barrier-released);
12274    /// every output must byte-match the thread's own pre-barrier sequential
12275    /// oracle from the same processor. On LifecycleOnly platforms (Mali,
12276    /// V3D, Tegra, llvmpipe, macOS) the converts genuinely overlap on the
12277    /// GPU; on Vivante they serialize via the Full policy and the test still
12278    /// must pass. Distinct inputs make any cross-processor state leakage
12279    /// (wrong texture, wrong context, clobbered upload) visible as a byte
12280    /// diff rather than a coincidental match — proven by a scratch
12281    /// cross-wire run (neighbor's input post-oracle) failing on every
12282    /// thread with ~53% of bytes diverged.
12283    ///
12284    /// Skipped under EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS like
12285    /// `test_multiple_image_processors_separate_threads`: the galcore driver
12286    /// can abort intermittently on concurrent multi-processor lifecycles
12287    /// regardless of locking (P0 spike: reproduces fully serialized).
12288    #[test]
12289    fn test_parallel_processors_unique_outputs() {
12290        use std::sync::{mpsc, Arc, Barrier};
12291        use std::time::Duration;
12292
12293        const N: usize = 4;
12294        const ROUNDS: usize = 25;
12295        const TIMEOUT: Duration = Duration::from_secs(60);
12296
12297        if std::env::var_os("EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS").is_some() {
12298            crate::test_support::report_skip(
12299                "test_parallel_processors_unique_outputs — known Vivante \
12300                 GC7000UL concurrent-multi-processor driver abort \
12301                 (EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS set)",
12302            );
12303            return;
12304        }
12305
12306        let _lock = acquire_env_lock();
12307        let (tx, rx) = mpsc::channel::<()>();
12308
12309        std::thread::spawn(move || {
12310            let barrier = Arc::new(Barrier::new(N));
12311            let handles: Vec<_> = (0..N)
12312                .map(|i| {
12313                    let barrier = Arc::clone(&barrier);
12314                    std::thread::spawn(move || {
12315                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
12316                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
12317                        });
12318                        // CI-weight geometry (llvmpipe renders on the CPU).
12319                        let (w, h) = (640usize, 480usize);
12320                        let mem = if edgefirst_tensor::is_dma_available() {
12321                            Some(TensorMemory::DmaBuf)
12322                        } else {
12323                            Some(TensorMemory::Mem)
12324                        };
12325                        let src = proc
12326                            .create_image(
12327                                w,
12328                                h,
12329                                PixelFormat::Nv12,
12330                                DType::U8,
12331                                mem,
12332                                edgefirst_tensor::CpuAccess::ReadWrite,
12333                            )
12334                            .unwrap();
12335                        {
12336                            let t = src.as_typed::<u8>().unwrap();
12337                            let mut m = t.map().unwrap();
12338                            let s = m.as_mut_slice();
12339                            for (j, b) in s[..w * h].iter_mut().enumerate() {
12340                                *b = ((i * 53 + j) % 200 + 16) as u8;
12341                            }
12342                            for b in &mut s[w * h..] {
12343                                *b = (80 + i * 24) as u8;
12344                            }
12345                        }
12346                        let lb = Crop::letterbox([114, 114, 114, 255]);
12347                        let convert_once = |proc: &mut ImageProcessor| -> Vec<u8> {
12348                            let mut dst = proc
12349                                .create_image(
12350                                    320,
12351                                    320,
12352                                    PixelFormat::Rgba,
12353                                    DType::U8,
12354                                    mem,
12355                                    edgefirst_tensor::CpuAccess::ReadWrite,
12356                                )
12357                                .unwrap();
12358                            proc.convert(&src, &mut dst, Rotation::None, Flip::None, lb)
12359                                .unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
12360                            let t = dst.as_typed::<u8>().unwrap();
12361                            let m = t.map().unwrap();
12362                            m.as_slice().to_vec()
12363                        };
12364
12365                        let oracle = convert_once(&mut proc);
12366                        barrier.wait();
12367                        for round in 0..ROUNDS {
12368                            let out = convert_once(&mut proc);
12369                            let diffs = oracle.iter().zip(&out).filter(|(a, b)| a != b).count();
12370                            assert!(
12371                                diffs == 0,
12372                                "thread {i} round {round}: {diffs}/{} bytes diverged \
12373                                 from this processor's own oracle — cross-processor \
12374                                 GL state leakage under parallel execution",
12375                                oracle.len()
12376                            );
12377                        }
12378                    })
12379                })
12380                .collect();
12381
12382            for (i, h) in handles.into_iter().enumerate() {
12383                h.join()
12384                    .unwrap_or_else(|e| panic!("parallel thread {i} panicked: {e:?}"));
12385            }
12386            let _ = tx.send(());
12387        });
12388
12389        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
12390            panic!("test_parallel_processors_unique_outputs timed out after {TIMEOUT:?}")
12391        });
12392    }
12393
12394    /// Heavy on-demand stressor for the GL serialization policy: 4
12395    /// processors × 4 threads × barrier × 200 NV12 720p → RGB 640 letterbox
12396    /// converts (DMA where available); every output must byte-match the
12397    /// thread's own pre-barrier sequential oracle from the same processor.
12398    /// Ignored by default (heavy; board tool — the CI-weight version is
12399    /// `test_parallel_processors_unique_outputs`). Run explicitly, optionally
12400    /// pinning the policy via EDGEFIRST_GL_SERIALIZE=full|lifecycle:
12401    ///   <test binary> stress_parallel_processors_oracle --ignored
12402    #[test]
12403    #[ignore = "heavy on-demand GL-parallelism stressor; run explicitly on boards"]
12404    fn stress_parallel_processors_oracle() {
12405        use std::sync::{mpsc, Arc, Barrier};
12406        use std::time::Duration;
12407
12408        const N: usize = 4;
12409        const ROUNDS: usize = 200;
12410        const TIMEOUT: Duration = Duration::from_secs(600);
12411
12412        let _lock = acquire_env_lock();
12413        let (tx, rx) = mpsc::channel::<()>();
12414
12415        std::thread::spawn(move || {
12416            let barrier = Arc::new(Barrier::new(N));
12417            let handles: Vec<_> = (0..N)
12418                .map(|i| {
12419                    let barrier = Arc::clone(&barrier);
12420                    std::thread::spawn(move || {
12421                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
12422                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
12423                        });
12424                        let (w, h) = (1280usize, 720usize);
12425                        let mem = if edgefirst_tensor::is_dma_available() {
12426                            Some(TensorMemory::DmaBuf)
12427                        } else {
12428                            Some(TensorMemory::Mem)
12429                        };
12430
12431                        // Per-thread-distinct synthetic NV12 so cross-wired
12432                        // GL state between processors shows up as a byte diff.
12433                        let src = proc
12434                            .create_image(
12435                                w,
12436                                h,
12437                                PixelFormat::Nv12,
12438                                DType::U8,
12439                                mem,
12440                                edgefirst_tensor::CpuAccess::ReadWrite,
12441                            )
12442                            .unwrap();
12443                        {
12444                            let t = src.as_typed::<u8>().unwrap();
12445                            let mut m = t.map().unwrap();
12446                            let s = m.as_mut_slice();
12447                            for (j, b) in s[..w * h].iter_mut().enumerate() {
12448                                *b = ((i * 37 + j) % 200 + 16) as u8;
12449                            }
12450                            for b in &mut s[w * h..] {
12451                                *b = (96 + i * 16) as u8;
12452                            }
12453                        }
12454                        let lb = Crop::letterbox([114, 114, 114, 255]);
12455
12456                        let convert_once = |proc: &mut ImageProcessor| -> Vec<u8> {
12457                            let mut dst = proc
12458                                .create_image(
12459                                    640,
12460                                    640,
12461                                    PixelFormat::Rgb,
12462                                    DType::U8,
12463                                    mem,
12464                                    edgefirst_tensor::CpuAccess::ReadWrite,
12465                                )
12466                                .unwrap();
12467                            proc.convert(&src, &mut dst, Rotation::None, Flip::None, lb)
12468                                .unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
12469                            let t = dst.as_typed::<u8>().unwrap();
12470                            let m = t.map().unwrap();
12471                            m.as_slice().to_vec()
12472                        };
12473
12474                        let oracle = convert_once(&mut proc);
12475                        barrier.wait();
12476                        for round in 0..ROUNDS {
12477                            let out = convert_once(&mut proc);
12478                            let diffs = oracle.iter().zip(&out).filter(|(a, b)| a != b).count();
12479                            assert!(
12480                                diffs == 0,
12481                                "thread {i} round {round}: {diffs}/{} bytes diverged \
12482                                 from the pre-barrier oracle",
12483                                oracle.len()
12484                            );
12485                        }
12486                    })
12487                })
12488                .collect();
12489
12490            for (i, h) in handles.into_iter().enumerate() {
12491                h.join()
12492                    .unwrap_or_else(|e| panic!("stressor thread {i} panicked: {e:?}"));
12493            }
12494            let _ = tx.send(());
12495        });
12496
12497        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
12498            panic!("stress_parallel_processors_oracle timed out after {TIMEOUT:?}")
12499        });
12500    }
12501
12502    // =========================================================================
12503    // F16 / F32 auto-chain fallback integration tests
12504    // =========================================================================
12505
12506    /// Proves the auto-chain (OpenGL → G2D → CPU) NEVER errors for a float
12507    /// combo the GL path does NOT cover.
12508    ///
12509    /// `Yuyv → Rgb F32` is not handled by the GL float render path (which
12510    /// only covers `Rgba → PlanarRgb F16` and `Rgba → Rgb F32`), so the
12511    /// chain falls through to the CPU float path. Before commit 868a7649
12512    /// added CPU U8→F32/F16 support this would have returned `Err`; now it
12513    /// must return `Ok` with output in `[0, 1]` and all values finite.
12514    #[test]
12515    fn convert_f32_auto_never_errors_non_gl_combo() {
12516        const W: usize = 64;
12517        const H: usize = 64;
12518
12519        // Build a small synthetic YUYV source (Y=128, U=128, V=128 → near-grey).
12520        // YUYV packs two pixels into 4 bytes: [Y0, U, Y1, V] per macropixel.
12521        let src = TensorDyn::image(
12522            W,
12523            H,
12524            PixelFormat::Yuyv,
12525            DType::U8,
12526            Some(TensorMemory::Mem),
12527            edgefirst_tensor::CpuAccess::ReadWrite,
12528        )
12529        .unwrap();
12530        {
12531            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
12532            let data = map.as_mut_slice();
12533            for chunk in data.as_chunks_mut::<4>().0 {
12534                chunk[0] = 128; // Y0
12535                chunk[1] = 128; // U
12536                chunk[2] = 160; // Y1 — distinct so a layout bug is visible
12537                chunk[3] = 128; // V
12538            }
12539        }
12540
12541        let mut dst = TensorDyn::image(
12542            W,
12543            H,
12544            PixelFormat::Rgb,
12545            DType::F32,
12546            Some(TensorMemory::Mem),
12547            edgefirst_tensor::CpuAccess::ReadWrite,
12548        )
12549        .unwrap();
12550
12551        let mut proc = ImageProcessor::new().unwrap();
12552        let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
12553        assert!(
12554            result.is_ok(),
12555            "auto-chain Yuyv→Rgb F32 must not error: {:?}",
12556            result.err()
12557        );
12558
12559        // Verify all output values are finite and in [0, 1].
12560        let map = dst.as_typed::<f32>().unwrap().map().unwrap();
12561        let floats = map.as_slice();
12562        assert_eq!(floats.len(), W * H * 3, "unexpected output element count");
12563        for (i, &v) in floats.iter().enumerate() {
12564            assert!(
12565                v.is_finite() && (0.0..=1.0).contains(&v),
12566                "output[{i}]={v} is not finite or not in [0,1]"
12567            );
12568        }
12569
12570        // WEAK-1: Anti-all-zero spot-check.  A Y=128 YUYV source normalises to
12571        // ≈0.502 on the luma channel.  If the buffer is all-zero (e.g. the CPU
12572        // path never wrote to it) this assertion catches the regression.
12573        let first_non_zero = floats.iter().find(|&&v| v > 0.01);
12574        assert!(
12575            first_non_zero.is_some(),
12576            "all-zero output detected — CPU path likely did not write to the destination buffer"
12577        );
12578        // Y=128 → luma ≈ 0.502.  Spot-check the first pixel's R channel
12579        // (which carries luma for a near-grey YUV source).
12580        let r0 = floats[0];
12581        assert!(
12582            (r0 - 0.502_f32).abs() < 0.05,
12583            "first pixel R={r0} expected ≈0.502 (Y=128 neutral grey from YUYV source)"
12584        );
12585    }
12586
12587    /// Proves CPU-forced `Rgba → PlanarRgb F16` correctness.
12588    ///
12589    /// Uses a source with clearly distinct per-channel values so a
12590    /// plane-swap or layout bug surfaces immediately. Tolerance is 2^-9
12591    /// (one F16 ULP at 0.5, i.e. roughly 1/512).
12592    #[test]
12593    // `ImageProcessorConfig` carries a Linux-only `egl_display` field, so
12594    // `{ backend, ..Default::default() }` is a genuine update on Linux but
12595    // covers no remaining fields on macOS, where `clippy::needless_update`
12596    // then fires. `allow` (not `expect`) because the lint is platform-
12597    // conditional — it does not fire on Linux.
12598    #[allow(clippy::needless_update)]
12599    fn convert_f16_forced_cpu_correct() {
12600        const W: usize = 16;
12601        const H: usize = 16;
12602        const TOL: f32 = 1.0 / 512.0; // 2^-9
12603
12604        // pixel (y,x): R = 50+x, G = 100+y*8, B = 200
12605        let src = TensorDyn::image(
12606            W,
12607            H,
12608            PixelFormat::Rgba,
12609            DType::U8,
12610            Some(TensorMemory::Mem),
12611            edgefirst_tensor::CpuAccess::ReadWrite,
12612        )
12613        .unwrap();
12614        {
12615            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
12616            let data = map.as_mut_slice();
12617            for y in 0..H {
12618                for x in 0..W {
12619                    let i = y * W + x;
12620                    data[i * 4] = (50 + x) as u8; // R: 50..65
12621                    data[i * 4 + 1] = (100 + y * 8) as u8; // G: 100..220
12622                    data[i * 4 + 2] = 200; // B: constant
12623                    data[i * 4 + 3] = 255;
12624                }
12625            }
12626        }
12627
12628        let mut dst = TensorDyn::image(
12629            W,
12630            H,
12631            PixelFormat::PlanarRgb,
12632            DType::F16,
12633            Some(TensorMemory::Mem),
12634            edgefirst_tensor::CpuAccess::ReadWrite,
12635        )
12636        .unwrap();
12637
12638        let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
12639            backend: ComputeBackend::Cpu,
12640            ..Default::default()
12641        })
12642        .unwrap();
12643        proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
12644            .expect("forced-CPU Rgba→PlanarRgb F16 must not error");
12645
12646        let src_map = src.as_typed::<u8>().unwrap().map().unwrap();
12647        let src_bytes = src_map.as_slice();
12648        let dst_map = dst.as_typed::<half::f16>().unwrap().map().unwrap();
12649        let dst_halfs = dst_map.as_slice();
12650
12651        let plane = W * H;
12652        assert_eq!(dst_halfs.len(), plane * 3, "wrong output element count");
12653
12654        for y in 0..H {
12655            for x in 0..W {
12656                let i = y * W + x;
12657                let r_exp = src_bytes[i * 4] as f32 / 255.0;
12658                let g_exp = src_bytes[i * 4 + 1] as f32 / 255.0;
12659                let b_exp = src_bytes[i * 4 + 2] as f32 / 255.0;
12660
12661                let r_got = dst_halfs[i].to_f32();
12662                let g_got = dst_halfs[plane + i].to_f32();
12663                let b_got = dst_halfs[2 * plane + i].to_f32();
12664
12665                assert!(
12666                    (r_got - r_exp).abs() <= TOL,
12667                    "R plane ({x},{y}): got {r_got}, expected {r_exp}"
12668                );
12669                assert!(
12670                    (g_got - g_exp).abs() <= TOL,
12671                    "G plane ({x},{y}): got {g_got}, expected {g_exp}"
12672                );
12673                assert!(
12674                    (b_got - b_exp).abs() <= TOL,
12675                    "B plane ({x},{y}): got {b_got}, expected {b_exp}"
12676                );
12677
12678                // Catch plane-swap: R and G must differ (they have different formulas).
12679                if src_bytes[i * 4] != src_bytes[i * 4 + 1] {
12680                    assert_ne!(r_got, g_got, "R and G planes must differ at ({x},{y})");
12681                }
12682            }
12683        }
12684    }
12685
12686    /// Proves the auto-chain falls through to CPU for `Rgba → Rgb F32` with
12687    /// rotation set.
12688    ///
12689    /// The GL float render path rejects any call where rotation ≠ None,
12690    /// returning an error that causes the chain to continue. Before the CPU
12691    /// float fallback this would have produced an error at the end of the
12692    /// chain; now it must reach CPU and return `Ok` with finite `[0, 1]` output.
12693    #[test]
12694    fn convert_f32_with_rotation_falls_back() {
12695        const W: usize = 16;
12696        const H: usize = 16;
12697
12698        // RGBA8 source with a known gradient (distinct per-channel values).
12699        let src = TensorDyn::image(
12700            W,
12701            H,
12702            PixelFormat::Rgba,
12703            DType::U8,
12704            Some(TensorMemory::Mem),
12705            edgefirst_tensor::CpuAccess::ReadWrite,
12706        )
12707        .unwrap();
12708        {
12709            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
12710            let data = map.as_mut_slice();
12711            for y in 0..H {
12712                for x in 0..W {
12713                    let i = y * W + x;
12714                    data[i * 4] = (x * 16) as u8; // R
12715                    data[i * 4 + 1] = (y * 16) as u8; // G
12716                    data[i * 4 + 2] = 128; // B
12717                    data[i * 4 + 3] = 255;
12718                }
12719            }
12720        }
12721
12722        // Rotation swaps W and H, so dst is [W, H] (H×W output).
12723        let mut dst = TensorDyn::image(
12724            H, // dst W = src H after 90° rotation
12725            W, // dst H = src W after 90° rotation
12726            PixelFormat::Rgb,
12727            DType::F32,
12728            Some(TensorMemory::Mem),
12729            edgefirst_tensor::CpuAccess::ReadWrite,
12730        )
12731        .unwrap();
12732
12733        let mut proc = ImageProcessor::new().unwrap();
12734        let result = proc.convert(
12735            &src,
12736            &mut dst,
12737            Rotation::Clockwise90,
12738            Flip::None,
12739            Crop::default(),
12740        );
12741        assert!(
12742            result.is_ok(),
12743            "auto-chain Rgba→Rgb F32 with Rot90 must not error: {:?}",
12744            result.err()
12745        );
12746
12747        let map = dst.as_typed::<f32>().unwrap().map().unwrap();
12748        let floats = map.as_slice();
12749        assert_eq!(floats.len(), H * W * 3, "unexpected output element count");
12750        for (i, &v) in floats.iter().enumerate() {
12751            assert!(
12752                v.is_finite() && (0.0..=1.0).contains(&v),
12753                "output[{i}]={v} is not finite or not in [0,1]"
12754            );
12755        }
12756    }
12757
12758    /// GL-vs-CPU identity parity for `Rgba → PlanarRgb F16`.
12759    ///
12760    /// Converts the same RGBA8 source via the `OpenGl` backend and the
12761    /// forced `Cpu` backend, then verifies the two F16 output tensors agree
12762    /// element-wise within 2^-8 (two F16 ULPs at 0.5). Skipped when OpenGL
12763    /// or F16 render is unavailable, and when the host cannot hold the
12764    /// zero-copy F16 destination the GL float route needs.
12765    ///
12766    /// **The GL route is asserted, not assumed** (issue #179).
12767    /// `ComputeBackend::OpenGl` is not a forced backend: it leaves
12768    /// `forced_backend` at `None`, so a GL decline logs at debug and the CPU
12769    /// serves the same convert. Both sides of the comparison would then be
12770    /// the CPU's answer and the parity claim would be a tautology — which is
12771    /// exactly how a host whose RGBA16F FBO is incomplete (desktop NVIDIA:
12772    /// `GL_FRAMEBUFFER_INCOMPLETE_ATTACHMENT`) passed while proving nothing,
12773    /// `supported_render_dtypes().f16` above notwithstanding: that flag is
12774    /// an extension query, not a completed render. `convert_fallback_count()`
12775    /// staying flat across the GL convert is the proof, and a decline is
12776    /// reported as a skip rather than a failure because it is a property of
12777    /// the host's driver, not of this code — except under
12778    /// `HAL_TEST_REQUIRE_GL=1`, which turns it into a named failure, the
12779    /// same opt-in `crates/image/tests/dst_view_stride.rs` and
12780    /// `reconstructed_view_draw.rs` carry for a GL backend that fails to
12781    /// come up. A lane that has asserted it has GL should not be quietly
12782    /// losing this test's coverage.
12783    #[test]
12784    #[cfg(all(
12785        any(
12786            target_os = "linux",
12787            target_os = "macos",
12788            target_os = "ios",
12789            target_os = "android",
12790            target_os = "windows"
12791        ),
12792        feature = "opengl"
12793    ))]
12794    fn convert_f16_gl_cpu_parity_identity() {
12795        // The macOS coverage lane sets `HAL_TEST_REQUIRE_GL=1` for both
12796        // passes but deliberately hobbles pass 1 (unsigned binaries, the
12797        // ANGLE dlopen gate closed). Excluded there the same way
12798        // `gl_backend_available_canary` excludes it, so the gate below
12799        // enforces in pass 2 only.
12800        #[cfg(target_os = "macos")]
12801        if std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1")
12802            && std::env::var_os("HAL_TEST_ALLOW_DLOPEN_ANGLE").is_none()
12803        {
12804            crate::test_support::report_skip(
12805                "convert_f16_gl_cpu_parity_identity - ANGLE dlopen gate closed (coverage pass 1)",
12806            );
12807            return;
12808        }
12809        if !is_opengl_available() {
12810            crate::test_support::report_skip(
12811                "convert_f16_gl_cpu_parity_identity - OpenGL not available",
12812            );
12813            return;
12814        }
12815
12816        const W: usize = 16;
12817        const H: usize = 16;
12818        const TOL: f32 = 1.0 / 256.0; // 2^-8
12819
12820        // pixel (y,x): R = 40+x, G = 80+y*10, B = 180
12821        let src = TensorDyn::image(
12822            W,
12823            H,
12824            PixelFormat::Rgba,
12825            DType::U8,
12826            Some(TensorMemory::Mem),
12827            edgefirst_tensor::CpuAccess::ReadWrite,
12828        )
12829        .unwrap();
12830        {
12831            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
12832            let data = map.as_mut_slice();
12833            for y in 0..H {
12834                for x in 0..W {
12835                    let i = y * W + x;
12836                    data[i * 4] = (40 + x) as u8; // R
12837                    data[i * 4 + 1] = (80 + y * 10) as u8; // G
12838                    data[i * 4 + 2] = 180; // B
12839                    data[i * 4 + 3] = 255;
12840                }
12841            }
12842        }
12843
12844        // GL path.
12845        let gl_result = {
12846            let mut gl_proc = match ImageProcessor::with_config(ImageProcessorConfig {
12847                backend: ComputeBackend::OpenGl,
12848                ..Default::default()
12849            }) {
12850                Ok(p) => p,
12851                Err(e) => {
12852                    crate::test_support::report_skip(&format!(
12853                        "convert_f16_gl_cpu_parity_identity - GL backend unavailable: {e}"
12854                    ));
12855                    return;
12856                }
12857            };
12858
12859            if !gl_proc.supported_render_dtypes().f16 {
12860                crate::test_support::report_skip(
12861                    "convert_f16_gl_cpu_parity_identity - F16 render not supported",
12862                );
12863                return;
12864            }
12865
12866            // A destination the GL float route actually serves. A
12867            // `TensorMemory::Mem` float destination has no arm in
12868            // `float_dispatch::classify_float_render` at all: it classifies as
12869            // `FloatRenderPath::None`, the u8 route rejects the dtype, and the
12870            // CPU serves every convert. With a host destination the GL half
12871            // below was therefore unreachable and this test could only ever
12872            // skip. `(Rgba, PlanarRgb, F16, DmaBuf)` -- `ZeroCopyF16Nchw` --
12873            // is the zero-copy float tuple EVERY platform's capability set
12874            // serves: it is unconditional in `classify_float_render`, so it
12875            // holds for the `PlanarF16` set Linux, Android and ANGLE/macOS
12876            // declare as well as the `All` set Windows declares.
12877            //
12878            // One spelling covers all of them. `TensorMemory::DmaBuf` is the
12879            // portable name for the platform-native zero-copy GPU buffer --
12880            // a DMA-BUF on Linux, an IOSurface on macOS/iOS, an
12881            // AHardwareBuffer on Android, an `ID3D11Texture2D` on Windows --
12882            // and the classifier matches on that one variant for all of them,
12883            // so this needs no `cfg`. Where the platform cannot hold one the
12884            // allocation fails or falls back, and both are reported below.
12885            let mut dst = match TensorDyn::image(
12886                W,
12887                H,
12888                PixelFormat::PlanarRgb,
12889                DType::F16,
12890                Some(TensorMemory::DmaBuf),
12891                edgefirst_tensor::CpuAccess::ReadWrite,
12892            ) {
12893                Ok(t) if t.memory() == TensorMemory::DmaBuf => t,
12894                // No routable float destination on this host. Reported here,
12895                // BEFORE the `HAL_TEST_REQUIRE_GL` gate below: that gate
12896                // exists to catch a GL backend declining a destination it
12897                // should serve, not a host that cannot hold one.
12898                Ok(t) => {
12899                    crate::test_support::report_skip(&format!(
12900                        "convert_f16_gl_cpu_parity_identity - the zero-copy F16 \
12901                         destination fell back to {:?}",
12902                        t.memory()
12903                    ));
12904                    return;
12905                }
12906                Err(e) => {
12907                    crate::test_support::report_skip(&format!(
12908                        "convert_f16_gl_cpu_parity_identity - no zero-copy F16 \
12909                         destination on this host: {e}"
12910                    ));
12911                    return;
12912                }
12913            };
12914            let require_gl = std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1");
12915            let fallbacks_before = gl_proc.convert_fallback_count();
12916            match gl_proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default()) {
12917                Ok(()) => {
12918                    if gl_proc.convert_fallback_count() != fallbacks_before {
12919                        assert!(
12920                            !require_gl,
12921                            "HAL_TEST_REQUIRE_GL=1 but \
12922                             convert_f16_gl_cpu_parity_identity would have skipped \
12923                             while reporting success: the GL F16 path declined and \
12924                             the CPU served the convert"
12925                        );
12926                        crate::test_support::report_skip(
12927                            "convert_f16_gl_cpu_parity_identity - the GL F16 path \
12928                             declined and the CPU served the convert; comparing that \
12929                             against the forced-CPU result would prove nothing",
12930                        );
12931                        return;
12932                    }
12933                    dst
12934                }
12935                Err(e) => {
12936                    crate::test_support::report_skip(&format!(
12937                        "convert_f16_gl_cpu_parity_identity - GL convert failed: {e}"
12938                    ));
12939                    return;
12940                }
12941            }
12942        };
12943
12944        // CPU path.
12945        let cpu_result = {
12946            let mut cpu_proc = ImageProcessor::with_config(ImageProcessorConfig {
12947                backend: ComputeBackend::Cpu,
12948                ..Default::default()
12949            })
12950            .unwrap();
12951            let mut dst = TensorDyn::image(
12952                W,
12953                H,
12954                PixelFormat::PlanarRgb,
12955                DType::F16,
12956                Some(TensorMemory::Mem),
12957                edgefirst_tensor::CpuAccess::ReadWrite,
12958            )
12959            .unwrap();
12960            cpu_proc
12961                .convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
12962                .expect("forced-CPU Rgba→PlanarRgb F16 must not error");
12963            dst
12964        };
12965
12966        // Compare element-wise. Both sides are read through `copy_to_flat`:
12967        // the GL destination is a packed zero-copy surface whose rows can be
12968        // spaced at a driver pitch, so its mapping is not a flat run of the
12969        // logical elements.
12970        let flat_f16 = |t: &TensorDyn| -> Vec<half::f16> {
12971            let typed = t.as_typed::<half::f16>().expect("F16 image");
12972            let mut bytes = vec![0u8; typed.shape().iter().product::<usize>() * 2];
12973            typed
12974                .copy_to_flat(&mut bytes)
12975                .expect("compact any padded rows");
12976            bytes
12977                .as_chunks::<2>()
12978                .0
12979                .iter()
12980                .map(|&c| half::f16::from_le_bytes(c))
12981                .collect()
12982        };
12983        let gl_halfs = flat_f16(&gl_result);
12984        let cpu_halfs = flat_f16(&cpu_result);
12985
12986        assert_eq!(
12987            gl_halfs.len(),
12988            cpu_halfs.len(),
12989            "GL and CPU output sizes differ"
12990        );
12991
12992        let plane = W * H;
12993        let channel_names = ["R", "G", "B"];
12994        for (idx, (gl_h, cpu_h)) in gl_halfs.iter().zip(cpu_halfs.iter()).enumerate() {
12995            let gl_v = gl_h.to_f32();
12996            let cpu_v = cpu_h.to_f32();
12997            let err = (gl_v - cpu_v).abs();
12998            let ch = channel_names[idx / plane];
12999            let pixel = idx % plane;
13000            assert!(
13001                err <= TOL,
13002                "GL vs CPU mismatch at {ch}[{pixel}]: GL={gl_v}, CPU={cpu_v}, err={err} > tol={TOL}"
13003            );
13004        }
13005    }
13006
13007    // =========================================================================
13008    // GAP-1: supported_render_dtypes() Linux smoke test
13009    // =========================================================================
13010
13011    /// Exercises the real Linux/Windows GL path that reads
13012    /// `gl.supported_render_dtypes()`. Skipped when no GL backend is
13013    /// available (CI/host without a GPU).
13014    #[test]
13015    #[cfg(all(any(target_os = "linux", target_os = "windows"), feature = "opengl"))]
13016    fn supported_render_dtypes_linux_smoke() {
13017        let proc = match ImageProcessor::new() {
13018            Ok(p) => p,
13019            Err(e) => {
13020                crate::test_support::report_skip(&format!(
13021                    "supported_render_dtypes_linux_smoke — ImageProcessor::new() failed: {e}"
13022                ));
13023                return;
13024            }
13025        };
13026        if proc.opengl.is_none() {
13027            crate::test_support::report_skip(
13028                "supported_render_dtypes_linux_smoke — no GL backend on this host",
13029            );
13030            return;
13031        }
13032        // The call must complete without panicking or deadlocking.
13033        let support = proc.supported_render_dtypes();
13034        eprintln!(
13035            "supported_render_dtypes_linux_smoke: f16={} f32={}",
13036            support.f16, support.f32
13037        );
13038        // No assertion on the specific values — they are hardware-dependent.
13039    }
13040
13041    // =========================================================================
13042    // GAP-2: F16 PlanarRgb with width NOT divisible by 4 falls back to CPU
13043    // =========================================================================
13044
13045    /// The GL float render path rejects PlanarRgb F16 destinations whose width
13046    /// is not a multiple of 4 (the packed RGBA16F swizzle trick requires W%4==0).
13047    /// The auto-chain must transparently fall through to the CPU path, which has
13048    /// no such restriction.  W=18, H=16 is chosen so W%4 == 2.
13049    #[test]
13050    fn convert_f16_pbo_non_4_aligned_width_falls_back() {
13051        const W: usize = 18; // 18 % 4 == 2 — NOT divisible by 4
13052        const H: usize = 16;
13053
13054        // RGBA8 source filled with a flat mid-grey.
13055        let src = TensorDyn::image(
13056            W,
13057            H,
13058            PixelFormat::Rgba,
13059            DType::U8,
13060            Some(TensorMemory::Mem),
13061            edgefirst_tensor::CpuAccess::ReadWrite,
13062        )
13063        .unwrap();
13064        {
13065            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
13066            let data = map.as_mut_slice();
13067            for chunk in data.as_chunks_mut::<4>().0 {
13068                chunk[0] = 128;
13069                chunk[1] = 64;
13070                chunk[2] = 200;
13071                chunk[3] = 255;
13072            }
13073        }
13074
13075        // F16 PlanarRgb destination in Mem (GL would use PBO, but we want
13076        // to exercise the fallback chain without hardware dependency).
13077        let mut dst = TensorDyn::image(
13078            W,
13079            H,
13080            PixelFormat::PlanarRgb,
13081            DType::F16,
13082            Some(TensorMemory::Mem),
13083            edgefirst_tensor::CpuAccess::ReadWrite,
13084        )
13085        .unwrap();
13086
13087        // Use the default auto-chain so the GL path can attempt and reject,
13088        // then the CPU path succeeds.
13089        let mut proc = ImageProcessor::new().unwrap();
13090        let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
13091        assert!(
13092            result.is_ok(),
13093            "auto-chain PlanarRgb F16 W%4!=0 must not error (CPU fallback): {:?}",
13094            result.err()
13095        );
13096
13097        // All output values must be finite and in [0, 1].
13098        let map = dst.as_typed::<half::f16>().unwrap().map().unwrap();
13099        let halfs = map.as_slice();
13100        assert_eq!(halfs.len(), W * H * 3, "unexpected element count");
13101        for (i, h) in halfs.iter().enumerate() {
13102            let v = h.to_f32();
13103            assert!(
13104                v.is_finite() && (0.0..=1.0).contains(&v),
13105                "output[{i}]={v} is not finite or not in [0,1]"
13106            );
13107        }
13108    }
13109
13110    // =========================================================================
13111    // GAP-4: NV12 → Rgb F32 and NV12 → PlanarRgb F16, forced CPU
13112    // =========================================================================
13113
13114    /// CPU widen-composition: NV12 (non-RGBA source) → Rgb F32.
13115    ///
13116    /// NV12 requires a two-stage CPU conversion (NV12→Rgba then Rgba→F32)
13117    /// which was untested. A wrong intermediate format selection silently
13118    /// produces garbage. Y=128, U=V=128 → near-neutral grey → R≈G≈B≈0.5.
13119    #[test]
13120    // Linux-only `egl_display` field makes `..Default::default()` needless
13121    // on macOS only; see `convert_f16_forced_cpu_correct`.
13122    #[allow(clippy::needless_update)]
13123    fn convert_nv12_to_rgb_f32_cpu() {
13124        const W: usize = 16;
13125        const H: usize = 16; // must be even for NV12
13126
13127        // Build a valid NV12 tensor: shape [H*3/2, W], luma=128, chroma=128.
13128        let src = TensorDyn::image(
13129            W,
13130            H,
13131            PixelFormat::Nv12,
13132            DType::U8,
13133            Some(TensorMemory::Mem),
13134            edgefirst_tensor::CpuAccess::ReadWrite,
13135        )
13136        .unwrap();
13137        {
13138            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
13139            map.as_mut_slice().fill(128); // Y=128, U=V=128 → neutral grey
13140        }
13141
13142        let mut dst = TensorDyn::image(
13143            W,
13144            H,
13145            PixelFormat::Rgb,
13146            DType::F32,
13147            Some(TensorMemory::Mem),
13148            edgefirst_tensor::CpuAccess::ReadWrite,
13149        )
13150        .unwrap();
13151
13152        let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
13153            backend: ComputeBackend::Cpu,
13154            ..Default::default()
13155        })
13156        .unwrap();
13157
13158        let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
13159        assert!(
13160            result.is_ok(),
13161            "forced-CPU NV12→Rgb F32 must not error: {:?}",
13162            result.err()
13163        );
13164
13165        let map = dst.as_typed::<f32>().unwrap().map().unwrap();
13166        let floats = map.as_slice();
13167        assert_eq!(floats.len(), W * H * 3, "unexpected element count");
13168        for (i, &v) in floats.iter().enumerate() {
13169            assert!(
13170                v.is_finite() && (0.0..=1.0).contains(&v),
13171                "output[{i}]={v} is not finite or not in [0,1]"
13172            );
13173        }
13174        // Anti-all-zero: Y=128 → luma channel ≈ 0.5 after YUV→RGB.
13175        let non_zero = floats.iter().any(|&v| v > 0.01);
13176        assert!(non_zero, "all-zero output from NV12→Rgb F32 CPU path");
13177    }
13178
13179    /// CPU widen-composition: NV12 (non-RGBA source) → PlanarRgb F16.
13180    ///
13181    /// Same rationale as `convert_nv12_to_rgb_f32_cpu` but for F16 output.
13182    #[test]
13183    // Linux-only `egl_display` field makes `..Default::default()` needless
13184    // on macOS only; see `convert_f16_forced_cpu_correct`.
13185    #[allow(clippy::needless_update)]
13186    fn convert_nv12_to_planar_rgb_f16_cpu() {
13187        const W: usize = 16;
13188        const H: usize = 16;
13189
13190        let src = TensorDyn::image(
13191            W,
13192            H,
13193            PixelFormat::Nv12,
13194            DType::U8,
13195            Some(TensorMemory::Mem),
13196            edgefirst_tensor::CpuAccess::ReadWrite,
13197        )
13198        .unwrap();
13199        {
13200            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
13201            map.as_mut_slice().fill(128);
13202        }
13203
13204        let mut dst = TensorDyn::image(
13205            W,
13206            H,
13207            PixelFormat::PlanarRgb,
13208            DType::F16,
13209            Some(TensorMemory::Mem),
13210            edgefirst_tensor::CpuAccess::ReadWrite,
13211        )
13212        .unwrap();
13213
13214        let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
13215            backend: ComputeBackend::Cpu,
13216            ..Default::default()
13217        })
13218        .unwrap();
13219
13220        let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
13221        assert!(
13222            result.is_ok(),
13223            "forced-CPU NV12→PlanarRgb F16 must not error: {:?}",
13224            result.err()
13225        );
13226
13227        let map = dst.as_typed::<half::f16>().unwrap().map().unwrap();
13228        let halfs = map.as_slice();
13229        assert_eq!(halfs.len(), W * H * 3, "unexpected element count");
13230        for (i, h) in halfs.iter().enumerate() {
13231            let v = h.to_f32();
13232            assert!(
13233                v.is_finite() && (0.0..=1.0).contains(&v),
13234                "output[{i}]={v} is not finite or not in [0,1]"
13235            );
13236        }
13237        let non_zero = halfs.iter().any(|h| h.to_f32() > 0.01);
13238        assert!(non_zero, "all-zero output from NV12→PlanarRgb F16 CPU path");
13239    }
13240
13241    // =========================================================================
13242    // GAP-5: create_image F32 + DMA must return NotSupported
13243    // =========================================================================
13244
13245    /// `create_image_desc` without a compression request is exactly
13246    /// `create_image` (the processor's memory negotiation applies); with
13247    /// `Compression::Any` off-Android it resolves linear and counts the
13248    /// fallback in the processor-visible mirror.
13249    #[test]
13250    fn create_image_desc_negotiates_and_counts_fallbacks() {
13251        use edgefirst_tensor::{CpuAccess, ImageDesc};
13252        let proc = ImageProcessor::new().unwrap();
13253
13254        let desc =
13255            ImageDesc::new(64, 64, PixelFormat::Rgba, DType::U8).with_access(CpuAccess::ReadWrite);
13256        let plain = proc.create_image_desc(&desc).unwrap();
13257        let classic = proc
13258            .create_image(
13259                64,
13260                64,
13261                PixelFormat::Rgba,
13262                DType::U8,
13263                None,
13264                CpuAccess::ReadWrite,
13265            )
13266            .unwrap();
13267        assert_eq!(plain.memory(), classic.memory(), "same negotiation path");
13268        assert_eq!(plain.compression(), None);
13269
13270        #[cfg(not(target_os = "android"))]
13271        {
13272            use edgefirst_tensor::Compression;
13273            let before = proc.compression_fallback_count();
13274            let desc = ImageDesc::new(64, 64, PixelFormat::Rgba, DType::U8)
13275                .with_compression(Compression::Any);
13276            let t = proc.create_image_desc(&desc).unwrap();
13277            assert_eq!(t.compression(), None, "no vendor tile scheme off-Android");
13278            assert!(
13279                proc.compression_fallback_count() > before,
13280                "Any resolving linear must count"
13281            );
13282        }
13283    }
13284
13285    /// There is no DRM FourCC for F32 images, so `create_image` with
13286    /// `TensorMemory::DmaBuf` and `DType::F32` must return `Err(NotSupported)`.
13287    #[test]
13288    #[cfg(target_os = "linux")]
13289    fn create_image_f32_dma_rejected() {
13290        let proc = ImageProcessor::new().unwrap();
13291        let result = proc.create_image(
13292            64,
13293            64,
13294            PixelFormat::Rgb,
13295            DType::F32,
13296            Some(TensorMemory::DmaBuf),
13297            edgefirst_tensor::CpuAccess::ReadWrite,
13298        );
13299        assert!(
13300            result.is_err(),
13301            "create_image(F32, Dma) must fail — no DRM fourcc for f32"
13302        );
13303    }
13304
13305    /// Verify that `import_image` stores the supplied `Option<Colorimetry>` on
13306    /// the returned `TensorDyn`.
13307    ///
13308    /// `import_image` requires a DMA-backed fd on Linux.  When DMA is
13309    /// unavailable we skip the DMA call but still verify the storage contract
13310    /// by inspecting `set_colorimetry` / `colorimetry` on a plain `TensorDyn`
13311    /// constructed the same way the function body does it — and we confirm via
13312    /// code-read that the new parameter is unconditionally stored.
13313    #[test]
13314    #[cfg(target_os = "linux")]
13315    fn import_image_carries_colorimetry() {
13316        use edgefirst_tensor::{ColorEncoding, ColorRange, Colorimetry, TensorMemory};
13317
13318        let expected = Colorimetry::default()
13319            .with_encoding(ColorEncoding::Bt709)
13320            .with_range(ColorRange::Limited);
13321
13322        if !is_dma_available() {
13323            // DMA unavailable on this host: exercise the storage path via
13324            // TensorDyn directly (mirrors what import_image does internally).
13325            let mut t = TensorDyn::image(
13326                8,
13327                8,
13328                PixelFormat::Rgba,
13329                DType::U8,
13330                Some(TensorMemory::Mem),
13331                edgefirst_tensor::CpuAccess::ReadWrite,
13332            )
13333            .expect("alloc");
13334            assert_eq!(t.colorimetry(), None, "colorimetry must start as None");
13335            t.set_colorimetry(Some(expected));
13336            assert_eq!(
13337                t.colorimetry(),
13338                Some(expected),
13339                "set_colorimetry must round-trip"
13340            );
13341            crate::test_support::report_skip("import_image_carries_colorimetry (DMA unavailable); storage contract verified via TensorDyn");
13342            return;
13343        }
13344
13345        // DMA is available: allocate a real DMA tensor, extract its fd, and
13346        // call import_image with an explicit Colorimetry.
13347        use edgefirst_tensor::{PlaneDescriptor, Tensor};
13348
13349        let rgba_bytes = 64 * 64 * 4; // 64×64 RGBA8
13350        let dma_tensor = Tensor::<u8>::new(
13351            &[rgba_bytes],
13352            Some(TensorMemory::DmaBuf),
13353            Some("import_test"),
13354        )
13355        .expect("dma alloc");
13356        let pd =
13357            PlaneDescriptor::new(dma_tensor.dmabuf().expect("dma fd")).expect("PlaneDescriptor");
13358
13359        let proc = ImageProcessor::new().expect("ImageProcessor");
13360        let result = proc.import_image(
13361            pd,
13362            None,
13363            64,
13364            64,
13365            PixelFormat::Rgba,
13366            DType::U8,
13367            Some(expected),
13368        );
13369        let tensor = result.expect("import_image must succeed on DMA fd");
13370        assert_eq!(
13371            tensor.colorimetry(),
13372            Some(expected),
13373            "import_image must store the supplied colorimetry on the returned TensorDyn"
13374        );
13375    }
13376}