Skip to main content

edgefirst_image/
lib.rs

1// SPDX-FileCopyrightText: Copyright 2025 Au-Zone Technologies
2// SPDX-License-Identifier: Apache-2.0
3
4/*!
5
6## EdgeFirst HAL - Image Converter
7
8The `edgefirst_image` crate is part of the EdgeFirst Hardware Abstraction
9Layer (HAL) and provides functionality for converting images between
10different formats and sizes.  The crate is designed to work with hardware
11acceleration when available, but also provides a CPU-based fallback for
12environments where hardware acceleration is not present or not suitable.
13
14What the crate covers:
15- Packed and planar RGB families (`Rgb`, `Rgba`, `Bgra`, `Grey`,
16  `PlanarRgb`, `PlanarRgba`), semi-planar YUV sources (`Nv12`, `Nv16`,
17  `Nv24`), and packed YUV (`Yuyv`, `Vyuy`).
18- Source crop, resize, letterbox, 90° rotation, and flipping in one call.
19- Segmentation-mask rendering, including a fused GPU proto path.
20- SAHI-style input tiling for small-object detection in large frames
21  ([`TilingConfig`], [`ImageProcessor::tile_into`]).
22- Hardware acceleration (OpenGL, NXP G2D) with a CPU fallback that is
23  always available.
24
25The crate uses [`TensorDyn`] from `edgefirst_tensor` to represent images,
26with [`PixelFormat`] metadata describing the pixel layout. The
27[`ImageProcessor`] struct manages the conversion process, selecting
28the appropriate conversion method based on the available hardware.
29
30Geometry is **source-side**: [`Crop`] says which sub-rectangle of the source
31to sample and how to fit it into the destination's shape. Where the output
32lands is the destination itself — pass a
33[`view`](edgefirst_tensor::TensorDyn::view) or
34[`batch`](edgefirst_tensor::TensorDyn::batch) of a larger tensor to render
35into one tile of a batch.
36
37## Examples
38
39```rust
40# use edgefirst_image::{ImageProcessor, Rotation, Flip, Crop, ImageProcessorTrait};
41# use edgefirst_codec::{peek_info, ImageDecoder, ImageLoad};
42# use edgefirst_tensor::{CpuAccess, PixelFormat, DType, Tensor, TensorMemory};
43# fn main() -> Result<(), edgefirst_image::Error> {
44let image = edgefirst_bench::testdata::read("zidane.jpg");
45// The codec emits the source's native format (a colour JPEG decodes to NV12)
46// and configures the destination tensor's dims+format during the decode.
47let info = peek_info(&image).expect("peek");
48// Heap tensors are CPU-touched on both sides (codec decode writes, the
49// engine's upload/CPU-fallback paths read), so declare ReadWrite. Pure
50// hardware pipelines allocate their convert destinations with
51// `CpuAccess::None` instead.
52let mut src = Tensor::<u8>::image(info.width, info.height, info.format,
53                                   Some(TensorMemory::Mem), edgefirst_tensor::CpuAccess::ReadWrite)?;
54let mut decoder = ImageDecoder::new();
55src.load_image(&mut decoder, &image).expect("decode");
56// Convert the native NV12 frame to packed RGB for downstream processing.
57let mut converter = ImageProcessor::new()?;
58let mut dst =
59    converter.create_image(640, 480, PixelFormat::Rgb, DType::U8, None, CpuAccess::ReadWrite)?;
60converter.convert(&src.into(), &mut dst, Rotation::None, Flip::None, Crop::default())?;
61# Ok(())
62# }
63```
64
65## Environment Variables
66The behavior of the `edgefirst_image::ImageProcessor` struct can be influenced by the
67following environment variables:
68- `EDGEFIRST_FORCE_BACKEND`: When set to `cpu`, `g2d`, or `opengl` (case-insensitive),
69  only that single backend is initialized and no fallback chain is used. If the
70  forced backend fails to initialize, an error is returned immediately. This is
71  useful for benchmarking individual backends in isolation. When this variable is
72  set, the `EDGEFIRST_DISABLE_*` variables are ignored.
73- `EDGEFIRST_DISABLE_GL`: If set to `1`, disables the use of OpenGL for image
74  conversion, forcing the use of CPU or other available hardware methods.
75- `EDGEFIRST_DISABLE_G2D`: If set to `1`, disables the use of G2D for image
76  conversion, forcing the use of CPU or other available hardware methods.
77- `EDGEFIRST_DISABLE_CPU`: If set to `1`, disables the use of CPU for image
78  conversion, forcing the use of hardware acceleration methods. If no hardware
79  acceleration methods are available, an error will be returned when attempting
80  to create an `ImageProcessor`.
81
82Additionally the TensorMemory used by default allocations can be controlled using the
83`EDGEFIRST_TENSOR_FORCE_MEM` environment variable. If set to `1`, default tensor memory
84uses system memory. This will disable the use of specialized memory regions for tensors
85and hardware acceleration. However, this will increase the performance of the CPU converter.
86*/
87#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
88
89/// Retained constructor: installs the coverage flush-on-abort handler for this
90/// crate's instrumented test binary. See `edgefirst_tensor::covguard`. Only
91/// present under coverage on Linux (`.init_array` is ELF-only; flush is Linux-only).
92#[cfg(all(coverage, target_os = "linux"))]
93#[used]
94#[link_section = ".init_array"]
95static __EDGEFIRST_COV_INSTALL: extern "C" fn() = {
96    extern "C" fn ctor() {
97        edgefirst_tensor::covguard::install();
98    }
99    ctor
100};
101
102/// Pitch alignment requirement for DMA-BUF tensors that may be imported as
103/// EGLImages by the GL backend. Mali Valhall (i.MX 95 / G310) rejects
104/// `eglCreateImageKHR` with `EGL_BAD_ALLOC` for any DMA-BUF whose row pitch
105/// is not a multiple of 64 bytes; Vivante GC7000UL (i.MX 8MP) accepts any
106/// pitch so the constant is harmless on that path. 64 is the smallest
107/// alignment that satisfies every embedded ARM GPU we ship to.
108///
109/// Applied automatically inside [`ImageProcessor::create_image`] when the
110/// allocation lands on `TensorMemory::DmaBuf`. External callers that allocate
111/// their own DMA-BUF tensors (e.g. GStreamer plugins, video pipelines) can
112/// use [`align_width_for_gpu_pitch`] to compute a width whose resulting row
113/// stride satisfies this requirement.
114pub const GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES: usize = 64;
115
116/// Round `width` (in pixels) up so the resulting row stride
117/// `width * bpp` is a multiple of [`GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES`]
118/// AND a multiple of `bpp` (so the rounded width is an integer pixel count).
119///
120/// `bpp` must be the per-pixel byte count for the image's primary plane
121/// (e.g. 4 for RGBA8/BGRA8, 3 for RGB888, 1 for Grey/NV12-luma).
122///
123/// External callers — GStreamer plugins, video pipelines, anyone wrapping a
124/// foreign DMA-BUF — should call this when sizing the destination so that
125/// `eglCreateImageKHR` doesn't reject the import on Mali. Pre-aligned widths
126/// (640, 1280, 1920, 3008, 3840 …) round-trip unchanged; misaligned widths
127/// are bumped up to the next valid value.
128///
129/// # Overflow behaviour
130///
131/// All arithmetic is checked. If the alignment computation or the rounded
132/// width would overflow `usize`, the function logs a warning and returns the
133/// original `width` unchanged rather than wrapping or producing a smaller
134/// value. Callers can rely on the returned width being **at least** the
135/// requested width.
136///
137/// `bpp == 0` and `width == 0` short-circuit to return the input unchanged.
138///
139/// # Examples
140///
141/// ```
142/// use edgefirst_image::align_width_for_gpu_pitch;
143///
144/// // RGBA8 (bpp=4): width must round to a multiple of 16 pixels (64-byte stride).
145/// assert_eq!(align_width_for_gpu_pitch(1920, 4), 1920); // already aligned
146/// assert_eq!(align_width_for_gpu_pitch(3004, 4), 3008); // crowd.png case: +4 px
147/// assert_eq!(align_width_for_gpu_pitch(1281, 4), 1296); // +15 px
148///
149/// // RGB888 (bpp=3): width must round to a multiple of 64 pixels (192-byte stride).
150/// assert_eq!(align_width_for_gpu_pitch(640, 3), 640);
151/// assert_eq!(align_width_for_gpu_pitch(641, 3), 704);
152/// ```
153pub fn align_width_for_gpu_pitch(width: usize, bpp: usize) -> usize {
154    if bpp == 0 || width == 0 {
155        return width;
156    }
157
158    // The minimum aligned stride must be a common multiple of both the
159    // GPU's pitch alignment and the per-pixel byte count. Using the LCM
160    // guarantees the rounded stride is an integer multiple of `bpp`, so
161    // converting back to a pixel count is exact.
162    //
163    // Compute the alignment in pixels (`width_alignment`) so we never need
164    // to multiply `width * bpp`, which is the only operation that could
165    // realistically overflow for large caller-supplied widths.
166    let Some(lcm_alignment) = checked_num_integer_lcm(GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES, bpp)
167    else {
168        log::warn!(
169            "align_width_for_gpu_pitch: lcm({GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES}, {bpp}) \
170             overflows usize, returning unaligned width {width}"
171        );
172        return width;
173    };
174    if lcm_alignment == 0 {
175        return width;
176    }
177
178    debug_assert_eq!(lcm_alignment % bpp, 0);
179    let width_alignment = lcm_alignment / bpp;
180    if width_alignment == 0 {
181        return width;
182    }
183
184    let remainder = width % width_alignment;
185    if remainder == 0 {
186        return width;
187    }
188
189    let pad = width_alignment - remainder;
190    match width.checked_add(pad) {
191        Some(aligned) => aligned,
192        None => {
193            log::warn!(
194                "align_width_for_gpu_pitch: width {width} + pad {pad} overflows usize, \
195                 returning unaligned (caller should use a smaller width or pre-aligned size)"
196            );
197            width
198        }
199    }
200}
201
202/// Round `min_pitch_bytes` up to the next multiple of
203/// [`GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES`]. Returns `None` if the rounded
204/// value would overflow `usize`. Returns `Some(0)` for input 0.
205///
206/// Used internally by [`ImageProcessor::create_image`] to compute the
207/// padded row stride for DMA-backed image allocations. External callers
208/// that need pixel-counted alignment (instead of raw byte pitch) should
209/// use [`align_width_for_gpu_pitch`] instead.
210#[cfg(target_os = "linux")]
211pub(crate) fn align_pitch_bytes_to_gpu_alignment(min_pitch_bytes: usize) -> Option<usize> {
212    let alignment = GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES;
213    if min_pitch_bytes == 0 {
214        return Some(0);
215    }
216    let remainder = min_pitch_bytes % alignment;
217    if remainder == 0 {
218        return Some(min_pitch_bytes);
219    }
220    min_pitch_bytes.checked_add(alignment - remainder)
221}
222
223/// Overflow-safe least common multiple. Returns `None` when `(a / gcd) * b`
224/// would wrap.
225fn checked_num_integer_lcm(a: usize, b: usize) -> Option<usize> {
226    if a == 0 || b == 0 {
227        return Some(0);
228    }
229    let g = num_integer_gcd(a, b);
230    // a / g is exact (g divides a by definition) and at most a, so this
231    // division never panics. Only the subsequent multiply can overflow.
232    (a / g).checked_mul(b)
233}
234
235fn num_integer_gcd(a: usize, b: usize) -> usize {
236    if b == 0 {
237        a
238    } else {
239        num_integer_gcd(b, a % b)
240    }
241}
242
243/// Bytes-per-pixel for the primary plane of `format` at element size `elem`.
244/// Returns `None` for formats that don't have a single packed BPP (semi-planar
245/// chroma is handled separately, returning the luma-plane bpp).
246///
247/// External callers can use this together with [`align_width_for_gpu_pitch`]
248/// to size their own DMA-BUFs without having to remember per-format BPPs:
249///
250/// ```
251/// use edgefirst_image::{align_width_for_gpu_pitch, primary_plane_bpp};
252/// use edgefirst_tensor::PixelFormat;
253///
254/// let bpp = primary_plane_bpp(PixelFormat::Rgba, 1).unwrap();
255/// let aligned = align_width_for_gpu_pitch(3004, bpp);
256/// assert_eq!(aligned, 3008);
257/// ```
258pub fn primary_plane_bpp(format: PixelFormat, elem: usize) -> Option<usize> {
259    use edgefirst_tensor::PixelLayout;
260    match format.layout() {
261        PixelLayout::Packed => Some(format.channels() * elem),
262        PixelLayout::Planar => Some(elem),
263        // For NV12/NV16 the luma plane is single-channel so the pitch
264        // matches `elem`; the chroma plane uses the same pitch in bytes
265        // (UV is half-width but two interleaved channels = same pitch).
266        PixelLayout::SemiPlanar => Some(elem),
267        // `PixelLayout` is non-exhaustive — fall through unaligned for
268        // any future variant we don't yet recognise.
269        _ => None,
270    }
271}
272
273/// Return the GPU-aligned pitch in bytes when a DMA-backed image of
274/// `width × fmt` would need row-stride padding, or `None` when the
275/// natural pitch already satisfies `GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES`
276/// or the caller has explicitly requested non-DMA memory.
277///
278/// Mali G310 (i.MX 95) rejects `eglCreateImage` from DMA-BUFs whose
279/// `PLANE0_PITCH_EXT` is not a multiple of 64 bytes, surfacing as
280/// `EGL_BAD_ALLOC`. The `load_image_test_helper` test-only helper
281/// in this crate uses this to decide whether to allocate a tensor
282/// with padded row stride before invoking the decode path; production
283/// callers do the equivalent peek → allocate → decode dance themselves
284/// (see crate-level docs).
285#[cfg(all(target_os = "linux", test))]
286pub(crate) fn padded_dma_pitch_for(
287    fmt: PixelFormat,
288    width: usize,
289    memory: &Option<TensorMemory>,
290) -> Option<usize> {
291    // Only pad when the caller explicitly requested DMA, or when they
292    // left memory selection to the allocator AND DMA is actually
293    // available. `Tensor::image_with_stride(..., None)` always routes
294    // through DMA allocation, so treating `None` as "DMA wanted"
295    // unconditionally would convert a normally-working image load into
296    // a hard failure on systems where DMA is unavailable (sandboxed
297    // CI, missing `/dev/dma_heap`, permission-denied containers) —
298    // whereas `Tensor::image(..., None)` would have fallen back to
299    // SHM/Mem there.
300    match memory {
301        Some(TensorMemory::DmaBuf) => {}
302        None if edgefirst_tensor::is_dma_available() => {}
303        _ => return None,
304    }
305    // Padding only applies to packed layouts — `Tensor::image_with_stride`
306    // rejects semi-planar / planar formats, and those take their own
307    // per-plane pitches on import anyway.
308    if fmt.layout() != PixelLayout::Packed {
309        return None;
310    }
311    let bpp = primary_plane_bpp(fmt, 1)?;
312    let natural = width.checked_mul(bpp)?;
313    let aligned = align_pitch_bytes_to_gpu_alignment(natural)?;
314    if aligned > natural {
315        Some(aligned)
316    } else {
317        None
318    }
319}
320
321pub use cpu::CPUProcessor;
322#[cfg(feature = "codec")]
323pub use edgefirst_codec as codec;
324
325#[cfg(test)]
326use edgefirst_tensor::ProtoLayout;
327#[doc(inline)]
328pub use edgefirst_tensor::Region;
329#[cfg(any(
330    test,
331    all(any(target_os = "linux", target_os = "windows"), feature = "opengl")
332))]
333use edgefirst_tensor::Tensor;
334use edgefirst_tensor::{
335    DType, PixelFormat, PixelLayout, TensorDyn, TensorMemory, TensorTrait as _,
336};
337use edgefirst_tensor::{DetectBox, ProtoData, Segmentation};
338use enum_dispatch::enum_dispatch;
339pub use error::{Error, Result};
340#[cfg(target_os = "linux")]
341pub use g2d::G2DProcessor;
342/// Handle returned by `convert_with_fence`: a sync_file fd on Unix, an
343/// event handle on Windows that is set when the GPU work completes.
344#[cfg(all(
345    any(
346        target_os = "linux",
347        target_os = "macos",
348        target_os = "ios",
349        target_os = "android",
350        target_os = "windows"
351    ),
352    feature = "opengl"
353))]
354pub use opengl_headless::CompletionFence;
355#[cfg(all(
356    any(
357        target_os = "linux",
358        target_os = "macos",
359        target_os = "ios",
360        target_os = "android",
361        target_os = "windows"
362    ),
363    feature = "opengl"
364))]
365pub use opengl_headless::EglDisplayKind;
366#[cfg(all(
367    any(
368        target_os = "linux",
369        target_os = "macos",
370        target_os = "ios",
371        target_os = "android",
372        target_os = "windows"
373    ),
374    feature = "opengl"
375))]
376pub use opengl_headless::GLProcessorThreaded;
377#[cfg(all(
378    any(
379        target_os = "linux",
380        target_os = "macos",
381        target_os = "ios",
382        target_os = "android",
383        target_os = "windows"
384    ),
385    feature = "opengl"
386))]
387pub use opengl_headless::Int8InterpolationMode;
388#[cfg(target_os = "linux")]
389#[cfg(feature = "opengl")]
390pub use opengl_headless::{probe_egl_displays, EglDisplayInfo};
391// EGLImage cache counter snapshots (diagnostics): see
392// `GLProcessorThreaded::egl_cache_stats` and the steady-state import gate in
393// `crates/image/ARCHITECTURE.md § image.convert.gl.egl_import`.
394#[cfg(all(
395    any(
396        target_os = "linux",
397        target_os = "macos",
398        target_os = "ios",
399        target_os = "android",
400        target_os = "windows"
401    ),
402    feature = "opengl"
403))]
404pub use opengl_headless::{CacheStats, ConvertStats, GlCacheStats};
405use std::{fmt::Display, time::Instant};
406
407mod colorimetry;
408mod cpu;
409mod error;
410mod g2d;
411#[path = "gl/mod.rs"]
412mod opengl_headless;
413#[cfg(test)]
414mod test_support;
415mod tiling;
416pub use tiling::{tile_grid, TilePlacement, TileSpec, TilingConfig};
417
418// Use `edgefirst_tensor::PixelFormat` variants (Rgb, Rgba, Grey, etc.) and
419// `TensorDyn` / `Tensor<u8>` with `.format()` metadata instead.
420
421#[derive(Debug, Clone, Copy, PartialEq, Eq)]
422pub enum Rotation {
423    None = 0,
424    Clockwise90 = 1,
425    Rotate180 = 2,
426    CounterClockwise90 = 3,
427}
428impl Rotation {
429    /// Creates a Rotation enum from an angle in degrees. The angle must be a
430    /// multiple of 90.
431    ///
432    /// # Panics
433    /// Panics if the angle is not a multiple of 90.
434    ///
435    /// # Examples
436    /// ```rust
437    /// # use edgefirst_image::Rotation;
438    /// let rotation = Rotation::from_degrees_clockwise(270);
439    /// assert_eq!(rotation, Rotation::CounterClockwise90);
440    /// ```
441    pub fn from_degrees_clockwise(angle: usize) -> Rotation {
442        match angle.rem_euclid(360) {
443            0 => Rotation::None,
444            90 => Rotation::Clockwise90,
445            180 => Rotation::Rotate180,
446            270 => Rotation::CounterClockwise90,
447            _ => panic!("rotation angle is not a multiple of 90"),
448        }
449    }
450}
451
452#[derive(Debug, Clone, Copy, PartialEq, Eq)]
453pub enum Flip {
454    None = 0,
455    Vertical = 1,
456    Horizontal = 2,
457}
458
459/// Controls how the color palette index is chosen for each detected object.
460#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
461pub enum ColorMode {
462    /// Color is chosen by object class label (`det.label`). Default.
463    ///
464    /// Preserves backward compatibility and is correct for semantic
465    /// segmentation where colors carry class meaning.
466    #[default]
467    Class,
468    /// Color is chosen by instance order (loop index, zero-based).
469    ///
470    /// Each detected object gets a unique color regardless of class,
471    /// useful for instance segmentation.
472    Instance,
473    /// Color is chosen by track ID (future use; currently behaves like
474    /// [`Instance`](Self::Instance)).
475    Track,
476}
477
478impl ColorMode {
479    /// Return the palette index for a detection given its loop index and label.
480    #[inline]
481    pub fn index(self, idx: usize, label: usize) -> usize {
482        match self {
483            ColorMode::Class => label,
484            ColorMode::Instance | ColorMode::Track => idx,
485        }
486    }
487}
488
489/// Controls the resolution and coordinate frame of masks produced by
490/// [`ImageProcessor::materialize_masks`].
491///
492/// - [`Proto`](Self::Proto) returns per-detection tiles at proto-plane
493///   resolution (e.g. 48×32 u8 for a typical COCO bbox on a 160×160 proto
494///   plane). This is the historical behavior of `materialize_masks` and the
495///   fastest path because no upsample runs inside HAL. Mask values are
496///   continuous sigmoid output quantized to `uint8 [0, 255]`.
497/// - [`Scaled`](Self::Scaled) returns per-detection tiles at caller-specified
498///   pixel resolution by upsampling the full proto plane once and cropping by
499///   bbox after sigmoid. The upsample uses bilinear interpolation with
500///   edge-clamp sampling — semantically equivalent to Ultralytics'
501///   `process_masks_retina` reference. When a `letterbox` is also passed to
502///   [`materialize_masks`], the inverse letterbox transform is applied during
503///   the upsample so mask pixels land in original-content coordinates
504///   (drop-in for overlay on the original image). Mask values are binary
505///   `uint8 {0, 255}` after thresholding sigmoid > 0.5 — interchangeable
506///   with `Proto` output via the same `> 127` test.
507///
508/// [`materialize_masks`]: ImageProcessor::materialize_masks
509#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
510pub enum MaskResolution {
511    /// Per-detection tile at proto-plane resolution (default).
512    #[default]
513    Proto,
514    /// Per-detection tile at `(width, height)` pixel resolution in the
515    /// coordinate frame determined by the `letterbox` parameter of
516    /// [`ImageProcessor::materialize_masks`].
517    Scaled {
518        /// Target pixel width of the output coordinate frame.
519        width: u32,
520        /// Target pixel height of the output coordinate frame.
521        height: u32,
522    },
523}
524
525/// Options for mask overlay rendering.
526///
527/// Controls how segmentation masks are composited onto the destination image:
528/// - `background`: when set, the background image is drawn first and masks
529///   are composited over it (result written to `dst`). When `None`, `dst` is
530///   cleared to `0x00000000` (fully transparent) before masks are drawn.
531///   **`dst` is always fully overwritten — its prior contents are never
532///   preserved.** Callers who used to pre-load an image into `dst` before
533///   calling `draw_decoded_masks` / `draw_proto_masks` must now supply that
534///   image via `background` instead (behaviour changed in v0.16.4).
535/// - `opacity`: scales the alpha of rendered mask colors. `1.0` (default)
536///   preserves the class color's alpha unchanged; `0.5` makes masks
537///   semi-transparent.
538/// - `color_mode`: controls whether colors are assigned by class label,
539///   instance index, or track ID. Defaults to [`ColorMode::Class`].
540#[derive(Debug, Clone, Copy)]
541pub struct MaskOverlay<'a> {
542    /// Compositing source image. Must have the same dimensions and pixel
543    /// format as `dst`. When `Some`, the output is `background + masks`.
544    /// When `None`, `dst` is cleared to `0x00000000` before masks are drawn.
545    pub background: Option<&'a TensorDyn>,
546    pub opacity: f32,
547    /// Normalized letterbox region `[xmin, ymin, xmax, ymax]` in model-input
548    /// space that contains actual image content (the rest is padding).
549    ///
550    /// When set, bounding boxes and mask coordinates from the decoder (which
551    /// are in model-input normalized space) are mapped back to the original
552    /// image coordinate space before rendering.
553    ///
554    /// Use [`with_letterbox_crop`](Self::with_letterbox_crop) to compute this
555    /// from the [`Crop`] that was used in the model input [`convert`](crate::ImageProcessorTrait::convert) call.
556    pub letterbox: Option<[f32; 4]>,
557    pub color_mode: ColorMode,
558}
559
560impl Default for MaskOverlay<'_> {
561    fn default() -> Self {
562        Self {
563            background: None,
564            opacity: 1.0,
565            letterbox: None,
566            color_mode: ColorMode::Class,
567        }
568    }
569}
570
571impl<'a> MaskOverlay<'a> {
572    pub fn new() -> Self {
573        Self::default()
574    }
575
576    /// Set the compositing source image.
577    ///
578    /// `bg` must have the same dimensions and pixel format as the `dst` passed
579    /// to [`draw_decoded_masks`](crate::ImageProcessorTrait::draw_decoded_masks) /
580    /// [`draw_proto_masks`](crate::ImageProcessorTrait::draw_proto_masks).
581    /// The output will be `bg + masks`. Without a background, `dst` is cleared
582    /// to `0x00000000`.
583    pub fn with_background(mut self, bg: &'a TensorDyn) -> Self {
584        self.background = Some(bg);
585        self
586    }
587
588    pub fn with_opacity(mut self, opacity: f32) -> Self {
589        self.opacity = opacity.clamp(0.0, 1.0);
590        self
591    }
592
593    pub fn with_color_mode(mut self, mode: ColorMode) -> Self {
594        self.color_mode = mode;
595        self
596    }
597
598    /// Set the letterbox transform from the [`Crop`] used when preparing the
599    /// model input, so that bounding boxes and masks are correctly mapped back
600    /// to the original image coordinate space during rendering.
601    ///
602    /// Pass the same `crop` that was given to
603    /// [`convert`](crate::ImageProcessorTrait::convert) along with the model
604    /// input dimensions (`model_w` × `model_h`).
605    ///
606    /// Has no effect when `crop.dst_rect` is `None` (no letterbox applied).
607    pub fn with_letterbox_crop(
608        mut self,
609        crop: &Crop,
610        src_w: usize,
611        src_h: usize,
612        model_w: usize,
613        model_h: usize,
614    ) -> Self {
615        // The letterbox placement is resolved from the same source/destination
616        // dimensions `convert()` used, so the inverse map matches the render.
617        if let Ok(resolved) = crop.resolve(src_w, src_h, model_w, model_h) {
618            if let Some(r) = resolved.dst_rect {
619                self.letterbox = Some([
620                    r.left as f32 / model_w as f32,
621                    r.top as f32 / model_h as f32,
622                    (r.left + r.width) as f32 / model_w as f32,
623                    (r.top + r.height) as f32 / model_h as f32,
624                ]);
625            }
626        }
627        self
628    }
629}
630
631/// Copy a [`Segmentation`], replacing its bounds with `bbox`.
632///
633/// `TensorDyn` is deliberately not `Clone` (it may own an fd), so the mask
634/// bytes are copied explicitly. This is only reached on the letterbox path
635/// where the bounds must be rewritten to match an un-letterboxed detect box.
636fn copy_segmentation(s: &Segmentation, bbox: [f32; 4]) -> Result<Segmentation> {
637    use edgefirst_tensor::TensorMapTrait as _;
638    let src = s
639        .segmentation
640        .as_typed::<u8>()
641        .ok_or_else(|| Error::InvalidShape("segmentation must be a U8 tensor".into()))?;
642    let map = src.map_read()?;
643    // `Some(Mem)`, NOT `None`. `None` means auto-select, which prefers DMA on
644    // Linux and IOSurface on macOS -- so this would take a dma_heap ioctl and
645    // hold an fd PER DETECTION PER FRAME for a mask of a few hundred bytes,
646    // and turn every downstream map_read() in the render loop into a
647    // DMA_BUF_IOCTL_SYNC instead of a no-op. A mask copy is CPU-only scratch.
648    // `mask_to_tensor` reaches Mem the same way, via from_arrayview3.
649    let dst = edgefirst_tensor::Tensor::<u8>::new(
650        s.segmentation.shape(),
651        Some(edgefirst_tensor::TensorMemory::Mem),
652        None,
653    )
654    .map_err(|e| Error::InvalidShape(format!("segmentation copy: {e}")))?;
655    {
656        let mut dmap = dst.map_mut()?;
657        dmap.as_mut_slice().copy_from_slice(map.as_slice());
658    }
659    Ok(Segmentation {
660        xmin: bbox[0],
661        ymin: bbox[1],
662        xmax: bbox[2],
663        ymax: bbox[3],
664        segmentation: dst.into(),
665    })
666}
667
668/// Apply the inverse letterbox transform to a bounding box.
669///
670/// `lb` is `[lx0, ly0, lx1, ly1]` — the normalized region of the model input
671/// that contains actual image content (output of
672/// [`MaskOverlay::with_letterbox_crop`]). Converts model-input-normalized
673/// coords to output-image-normalized coords, clamped to `[0.0, 1.0]`, and
674/// canonicalises the bbox (ensures xmin <= xmax).
675///
676/// Thin wrapper over [`edgefirst_tensor::unletter_norm`]: the single home for
677/// the inverse-letterbox math sits beside the type it transforms, so this mask
678/// path and `edgefirst_decoder::tiling`'s detection lift share one
679/// implementation.
680#[inline]
681fn unletter_bbox(bbox: DetectBox, lb: [f32; 4]) -> DetectBox {
682    DetectBox {
683        bbox: edgefirst_tensor::unletter_norm(bbox.bbox, lb),
684        ..bbox
685    }
686}
687
688/// How a source is fit into the requested destination shape.
689#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
690pub enum Fit {
691    /// Stretch the source (or source crop) to fill the whole destination.
692    #[default]
693    Stretch,
694    /// Preserve the *source* aspect ratio, centring it in the destination and
695    /// padding the remainder with `pad` (RGBA — e.g. `[114, 114, 114, 255]` for
696    /// YOLO-style preprocessing).
697    Letterbox { pad: [u8; 4] },
698}
699
700/// Source-side convert geometry: which sub-rectangle of the source to sample
701/// (`source`) and how to fit it into the destination (`fit`). Destination
702/// *placement* is the destination itself — a tensor, or a [`Region`] view /
703/// `batch` tile of one — not a field here.
704#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
705pub struct Crop {
706    /// Sub-rectangle of the source to sample. `None` samples the whole source.
707    pub source: Option<Region>,
708    /// How the source is fit into the destination shape.
709    pub fit: Fit,
710}
711
712impl Crop {
713    /// A no-op crop: whole source, stretched to fill the whole destination.
714    pub fn new() -> Self {
715        Self::default()
716    }
717
718    /// Alias for [`Crop::new`] — whole source, stretch to fill.
719    pub fn no_crop() -> Self {
720        Self::default()
721    }
722
723    /// Letterbox fit: preserve the source aspect ratio, padding the remainder
724    /// with `pad` (RGBA).
725    pub fn letterbox(pad: [u8; 4]) -> Self {
726        Self {
727            source: None,
728            fit: Fit::Letterbox { pad },
729        }
730    }
731
732    /// Sample only `source` of the input (builder).
733    pub fn with_source(mut self, source: Option<Region>) -> Self {
734        self.source = source;
735        self
736    }
737
738    /// Set the fit mode (builder).
739    pub fn with_fit(mut self, fit: Fit) -> Self {
740        self.fit = fit;
741        self
742    }
743
744    /// Resolve to the effective backend geometry for a `src_w`×`src_h` source
745    /// and `dst_w`×`dst_h` destination: the source sampling rect, the
746    /// destination placement rect (`None` = whole destination), and the pad
747    /// colour. **The single place letterbox placement is computed** — every
748    /// backend consumes the resolved rects rather than re-deriving them.
749    pub(crate) fn resolve(
750        &self,
751        src_w: usize,
752        src_h: usize,
753        dst_w: usize,
754        dst_h: usize,
755    ) -> Result<ResolvedCrop, Error> {
756        let src_rect = self.source.map(region_to_rect);
757        // The letterbox aspect uses the *effective* source content — the source
758        // crop when set, else the full source.
759        let (sw, sh) = match self.source {
760            Some(r) => (r.width, r.height),
761            None => (src_w, src_h),
762        };
763        let resolved = match self.fit {
764            Fit::Stretch => ResolvedCrop {
765                src_rect,
766                dst_rect: None,
767                dst_color: None,
768            },
769            Fit::Letterbox { pad } => ResolvedCrop {
770                src_rect,
771                dst_rect: Some(letterbox_rect(sw, sh, dst_w, dst_h)),
772                dst_color: Some(pad),
773            },
774        };
775        resolved.check_crop_dims(src_w, src_h, dst_w, dst_h)?;
776        Ok(resolved)
777    }
778
779    /// Validate against `TensorDyn` source and destination dimensions.
780    pub fn check_crop_dyn(
781        &self,
782        src: &edgefirst_tensor::TensorDyn,
783        dst: &edgefirst_tensor::TensorDyn,
784    ) -> Result<(), Error> {
785        self.resolve(
786            src.width().unwrap_or(0),
787            src.height().unwrap_or(0),
788            dst.width().unwrap_or(0),
789            dst.height().unwrap_or(0),
790        )
791        .map(|_| ())
792    }
793}
794
795/// Resolved crop geometry consumed by the backends. Produced by
796/// [`Crop::resolve`]; the backends read these fields directly (the same shape
797/// the public `Crop` carried before destination placement moved to the view).
798#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
799pub(crate) struct ResolvedCrop {
800    pub(crate) src_rect: Option<Rect>,
801    pub(crate) dst_rect: Option<Rect>,
802    pub(crate) dst_color: Option<[u8; 4]>,
803}
804
805impl ResolvedCrop {
806    /// A no-op resolved crop (whole source → whole destination, no pad).
807    #[allow(dead_code)] // used by unit tests and the batch render paths
808    pub(crate) fn no_crop() -> Self {
809        Self::default()
810    }
811
812    /// Whether a convert from a `src`-sized source to a `dst`-sized
813    /// destination with this crop changes the size of the sampled region. A
814    /// quarter-turn rotation swaps the source region's axes.
815    #[allow(dead_code)] // unused when no resampling backend is compiled in
816    pub(crate) fn resizes(
817        &self,
818        (src_w, src_h): (usize, usize),
819        (dst_w, dst_h): (usize, usize),
820        rotation: Rotation,
821    ) -> bool {
822        let (sw, sh) = self
823            .src_rect
824            .map_or((src_w, src_h), |r| (r.width, r.height));
825        let (dw, dh) = self
826            .dst_rect
827            .map_or((dst_w, dst_h), |r| (r.width, r.height));
828        let (sw, sh) = match rotation {
829            Rotation::Clockwise90 | Rotation::CounterClockwise90 => (sh, sw),
830            Rotation::None | Rotation::Rotate180 => (sw, sh),
831        };
832        (sw, sh) != (dw, dh)
833    }
834
835    /// Validate the resolved rects against explicit dimensions.
836    pub(crate) fn check_crop_dims(
837        &self,
838        src_w: usize,
839        src_h: usize,
840        dst_w: usize,
841        dst_h: usize,
842    ) -> Result<(), Error> {
843        let src_ok = self
844            .src_rect
845            .is_none_or(|r| r.left + r.width <= src_w && r.top + r.height <= src_h);
846        let dst_ok = self
847            .dst_rect
848            .is_none_or(|r| r.left + r.width <= dst_w && r.top + r.height <= dst_h);
849        match (src_ok, dst_ok) {
850            (true, true) => Ok(()),
851            (true, false) => Err(Error::CropInvalid(format!(
852                "Dest crop invalid: {:?}",
853                self.dst_rect
854            ))),
855            (false, true) => Err(Error::CropInvalid(format!(
856                "Src crop invalid: {:?}",
857                self.src_rect
858            ))),
859            (false, false) => Err(Error::CropInvalid(format!(
860                "Dest and Src crop invalid: {:?} {:?}",
861                self.dst_rect, self.src_rect
862            ))),
863        }
864    }
865}
866
867/// Convert a pixel [`Region`] to the internal [`Rect`] placement type.
868fn region_to_rect(r: Region) -> Rect {
869    Rect {
870        left: r.x,
871        top: r.y,
872        width: r.width,
873        height: r.height,
874    }
875}
876
877/// Centred aspect-preserving placement of an `sw`×`sh` source within a `dw`×`dh`
878/// destination — the canonical letterbox rectangle (one home, replacing the
879/// per-caller `calculate_letterbox` copies).
880fn letterbox_rect(sw: usize, sh: usize, dw: usize, dh: usize) -> Rect {
881    if sw == 0 || sh == 0 {
882        return Rect::new(0, 0, dw, dh);
883    }
884    let src_aspect = sw as f64 / sh as f64;
885    let dst_aspect = dw as f64 / dh as f64;
886    let (new_w, new_h) = if src_aspect > dst_aspect {
887        (dw, ((dw as f64 / src_aspect).round() as usize).max(1))
888    } else {
889        (((dh as f64 * src_aspect).round() as usize).max(1), dh)
890    };
891    let left = dw.saturating_sub(new_w) / 2;
892    let top = dh.saturating_sub(new_h) / 2;
893    Rect::new(left, top, new_w, new_h)
894}
895
896/// Internal placement rectangle (top-left + size). The **public** pixel
897/// sub-region type is [`Region`] (re-exported from `edgefirst-tensor`);
898/// `Rect` is the crate-private resolved-placement representation used by the
899/// CPU/G2D/GL backends after [`Crop::resolve`].
900#[derive(Debug, Clone, Copy, PartialEq, Eq)]
901pub(crate) struct Rect {
902    pub left: usize,
903    pub top: usize,
904    pub width: usize,
905    pub height: usize,
906}
907
908impl Rect {
909    // Creates a new Rect with the specified left, top, width, and height.
910    pub fn new(left: usize, top: usize, width: usize, height: usize) -> Self {
911        Self {
912            left,
913            top,
914            width,
915            height,
916        }
917    }
918}
919
920#[enum_dispatch(ImageProcessor)]
921pub trait ImageProcessorTrait {
922    /// Convert `src` into `dst`'s format and size, applying the crop first,
923    /// then the flip, then the rotation.
924    ///
925    /// The destination is normally an RGB-family colour — `Grey`, `Rgb`,
926    /// `Rgba`/`Bgra`, packed `HWC` or planar `CHW`. That is what the
927    /// accelerated paths target and what models expect. The CPU backend also
928    /// accepts a handful of YUV destinations (`Yuyv`→`Yuyv`, `Yuyv`→`Nv16`,
929    /// and the `Vyuy` equivalents); no GPU or G2D path does, so those pairs
930    /// always land on the CPU.
931    ///
932    /// # Arguments
933    ///
934    /// * `src` - The source image to convert from.
935    /// * `dst` - The destination image. Its shape and [`PixelFormat`] define
936    ///   the output; pass a [`view`](edgefirst_tensor::TensorDyn::view) or
937    ///   [`batch`](edgefirst_tensor::TensorDyn::batch) of a larger tensor to
938    ///   render into one tile of a batch.
939    /// * `rotation` - Rotation applied to the output, in 90° steps.
940    /// * `flip` - Horizontal, vertical, or both.
941    /// * `crop` - Source-side geometry: which sub-rectangle of `src` to sample
942    ///   ([`Crop::source`]) and how to fit it into `dst`'s shape
943    ///   ([`Crop::fit`] — stretch, or letterbox with a pad colour). Destination
944    ///   placement lives on `dst`, not here.
945    ///
946    /// # Errors
947    ///
948    /// - [`Error::CropInvalid`] if `crop.source` falls outside `src`.
949    /// - [`Error::NotAnImage`] if either tensor carries no `PixelFormat`.
950    /// - [`Error::NoConverter`] if no backend accepts the format pair — the
951    ///   dispatch tries OpenGL, then G2D, then CPU, so this means all three
952    ///   declined.
953    ///
954    /// Backend-specific failures (EGL import, G2D blit, allocation) surface as
955    /// their own variants. A backend *declining* a pair is not an error while
956    /// another remains in the chain; it silently costs the zero-copy path, and
957    /// [`ImageProcessor::convert_fallback_count`] counts those.
958    fn convert(
959        &mut self,
960        src: &TensorDyn,
961        dst: &mut TensorDyn,
962        rotation: Rotation,
963        flip: Flip,
964        crop: Crop,
965    ) -> Result<()>;
966
967    /// Draw pre-decoded detection boxes and segmentation masks onto `dst`.
968    ///
969    /// Supports two segmentation modes based on the mask channel count:
970    /// - **Instance segmentation** (`C=1`): one `Segmentation` per detection,
971    ///   `segmentation` and `detect` are zipped.
972    /// - **Semantic segmentation** (`C>1`): a single `Segmentation` covering
973    ///   all classes; only the first element is used.
974    ///
975    /// # Format requirements
976    ///
977    /// - CPU backend: `dst` must be `RGBA` or `RGB`.
978    /// - OpenGL backend: `dst` must be `RGBA`, `BGRA`, or `RGB`.
979    /// - G2D backend: only produces the base frame (empty detections);
980    ///   returns `NotImplemented` when any detection or segmentation is
981    ///   supplied.
982    ///
983    /// # Output contract
984    ///
985    /// This function always fully writes `dst` — it never relies on the
986    /// caller having pre-cleared the destination. The four cases are:
987    ///
988    /// | detections | background | output                              |
989    /// |------------|------------|-------------------------------------|
990    /// | none       | none       | dst cleared to `0x00000000`         |
991    /// | none       | set        | dst ← background                    |
992    /// | set        | none       | masks drawn over cleared dst        |
993    /// | set        | set        | masks drawn over background         |
994    ///
995    /// Each backend implements this with its native primitives: G2D uses
996    /// `g2d_clear` / `g2d_blit`, OpenGL uses `glClear` / DMA-BUF GPU blit
997    /// plus the mask program, and CPU uses direct buffer fill / memcpy as
998    /// the terminal fallback. CPU-memcpy of DMA buffers is avoided on the
999    /// accelerated paths.
1000    ///
1001    /// An empty `segmentation` slice is valid — only bounding boxes are drawn.
1002    ///
1003    /// `overlay` controls compositing: `background` is the compositing source
1004    /// (must match `dst` in size and format); `opacity` scales mask alpha.
1005    ///
1006    /// A `Bgra` background onto a zero-copy destination (a D3D11 texture, an
1007    /// IOSurface, a dma-buf) returns [`Error::NotSupported`]: the GL
1008    /// base-layer draw has no `Bgra` arm and the CPU backend renders only
1009    /// `Rgba`/`Rgb`. It previously returned `Ok` with `dst` unwritten. A
1010    /// `Bgra` destination with no background is unaffected.
1011    ///
1012    /// # Buffer aliasing
1013    ///
1014    /// `dst` and `overlay.background` must reference **distinct underlying
1015    /// buffers**. An aliased pair returns [`Error::AliasedBuffers`] without
1016    /// dispatching to any backend — the GL path would otherwise read and
1017    /// write the same texture in a single draw, which is undefined behaviour
1018    /// on most drivers. Aliasing is detected via
1019    /// [`TensorDyn::aliases`](edgefirst_tensor::TensorDyn::aliases), which
1020    /// catches both shared-allocation clones and separate imports over the
1021    /// same dmabuf fd.
1022    ///
1023    /// # Migration from v0.16.3 and earlier
1024    ///
1025    /// Prior to v0.16.4 the call silently preserved `dst`'s contents on empty
1026    /// detections. That invariant no longer holds — `dst` is always fully
1027    /// written. Callers who pre-loaded an image into `dst` before calling this
1028    /// function must now pass that image via `overlay.background` instead.
1029    fn draw_decoded_masks(
1030        &mut self,
1031        dst: &mut TensorDyn,
1032        detect: &[DetectBox],
1033        segmentation: &[Segmentation],
1034        overlay: MaskOverlay<'_>,
1035    ) -> Result<()>;
1036
1037    /// Draw masks from proto data onto image (fused decode+draw).
1038    ///
1039    /// For YOLO segmentation models, this avoids materializing intermediate
1040    /// `Array3<u8>` masks. The `ProtoData` contains mask coefficients and the
1041    /// prototype tensor; the renderer computes `mask_coeff @ protos` directly
1042    /// at the output resolution using bilinear sampling.
1043    ///
1044    /// `detect` and `proto_data.mask_coefficients` must have the same length
1045    /// (enforced by zip — excess entries are silently ignored). An empty
1046    /// `detect` slice is valid and produces the base frame — cleared or
1047    /// background-blitted — via the selected backend's native primitive.
1048    ///
1049    /// # Format requirements and output contract
1050    ///
1051    /// Same as [`draw_decoded_masks`](Self::draw_decoded_masks), including
1052    /// the "always fully writes dst" guarantee across all four
1053    /// detection/background combinations.
1054    ///
1055    /// `overlay` controls compositing — see [`draw_decoded_masks`](Self::draw_decoded_masks).
1056    fn draw_proto_masks(
1057        &mut self,
1058        dst: &mut TensorDyn,
1059        detect: &[DetectBox],
1060        proto_data: &ProtoData,
1061        overlay: MaskOverlay<'_>,
1062    ) -> Result<()>;
1063
1064    /// Sets the colors used for rendering segmentation masks. Up to 20 colors
1065    /// can be set.
1066    ///
1067    /// On [`ImageProcessor`] with no forced backend this reaches every
1068    /// available backend rather than the first that accepts, because a
1069    /// later draw may land on any of them. A forced backend receives the
1070    /// colors alone.
1071    fn set_class_colors(&mut self, colors: &[[u8; 4]]) -> Result<()>;
1072
1073    /// Like [`convert`](Self::convert), but does not wait for the GPU to finish.
1074    ///
1075    /// This is the batch-preprocessing primitive: a caller renders `N` tiles
1076    /// into one batched destination by looping
1077    /// `convert_deferred(&src[n], &mut dst.batch(n)?, …)` and then calling
1078    /// [`flush`](Self::flush) **once**. On the OpenGL backend every deferred
1079    /// convert into sibling views of one buffer shares a single EGLImage import
1080    /// (the tile is a `glViewport`/`glScissor` ROI into the parent) and skips the
1081    /// per-tile `glFinish`; `flush` then issues a single GPU sync. The result of
1082    /// a deferred convert is **not** safe to read on the CPU (or map via CUDA)
1083    /// until `flush` returns.
1084    ///
1085    /// The default implementation is eager — it simply calls
1086    /// [`convert`](Self::convert), so CPU/G2D and any backend without a deferred
1087    /// fast path remain correct (each call completes synchronously and `flush`
1088    /// is a no-op).
1089    fn convert_deferred(
1090        &mut self,
1091        src: &TensorDyn,
1092        dst: &mut TensorDyn,
1093        rotation: Rotation,
1094        flip: Flip,
1095        crop: Crop,
1096    ) -> Result<()> {
1097        self.convert(src, dst, rotation, flip, crop)
1098    }
1099
1100    /// Complete all work enqueued by [`convert_deferred`](Self::convert_deferred)
1101    /// since the last flush, issuing a single GPU synchronization.
1102    ///
1103    /// After this returns, every deferred destination is finished and safe to
1104    /// read back or `cuda_map`. Backends with no deferred path (the default)
1105    /// return `Ok(())` immediately, since their converts already completed.
1106    fn flush(&mut self) -> Result<()> {
1107        Ok(())
1108    }
1109}
1110
1111/// Configuration for [`ImageProcessor`] construction.
1112///
1113/// Use with [`ImageProcessor::with_config`] to override the default EGL
1114/// display auto-detection and backend selection. The default configuration
1115/// preserves the existing auto-detection behaviour.
1116#[derive(Debug, Clone, Default)]
1117pub struct ImageProcessorConfig {
1118    /// Force OpenGL to use this EGL display type instead of auto-detecting.
1119    ///
1120    /// When `None`, the processor probes displays in priority order: GBM,
1121    /// PlatformDevice, Default. Use [`probe_egl_displays`] to discover
1122    /// which displays are available on the current system.
1123    ///
1124    /// Ignored when `EDGEFIRST_DISABLE_GL=1` is set, and on macOS and
1125    /// Windows (ANGLE is the only display there — Metal and Direct3D 11
1126    /// respectively; a `Some` value logs a debug note and is otherwise
1127    /// ignored).
1128    #[cfg(all(
1129        any(
1130            target_os = "linux",
1131            target_os = "macos",
1132            target_os = "ios",
1133            target_os = "android",
1134            target_os = "windows"
1135        ),
1136        feature = "opengl"
1137    ))]
1138    pub egl_display: Option<EglDisplayKind>,
1139
1140    /// Preferred compute backend.
1141    ///
1142    /// When set to a specific backend (not [`ComputeBackend::Auto`]), the
1143    /// processor initializes that backend with no fallback — returns an error if the conversion is not supported.
1144    /// This takes precedence over `EDGEFIRST_FORCE_BACKEND` and the
1145    /// `EDGEFIRST_DISABLE_*` environment variables.
1146    ///
1147    /// - [`ComputeBackend::OpenGl`]: init OpenGL + CPU, skip G2D
1148    /// - [`ComputeBackend::G2d`]: init G2D + CPU, skip OpenGL
1149    /// - [`ComputeBackend::Cpu`]: init CPU only
1150    /// - [`ComputeBackend::Auto`]: existing env-var-driven selection
1151    pub backend: ComputeBackend,
1152
1153    /// Colorimetry/performance trade-off for `convert()` (see
1154    /// [`ColorimetryMode`]). Defaults to [`ColorimetryMode::Fast`]. The
1155    /// `EDGEFIRST_COLORIMETRY` environment variable (`fast` | `exact`)
1156    /// overrides this setting when present.
1157    pub colorimetry: ColorimetryMode,
1158
1159    /// How `convert()` resamples when the source (or source crop) and the
1160    /// destination differ in size (see [`Interpolation`]). Defaults to
1161    /// [`Interpolation::Bilinear`]. The `EDGEFIRST_INTERPOLATION` environment
1162    /// variable (`nearest` | `bilinear`) overrides this setting when present.
1163    pub interpolation: Interpolation,
1164
1165    /// How many buffer imports the OpenGL backend keeps cached, per cache.
1166    ///
1167    /// `None` (the default) uses `EDGEFIRST_EGL_CACHE_CAPACITY` if set, and
1168    /// 16 otherwise. Precedence is:
1169    ///
1170    /// ```text
1171    /// egl_cache_capacity  >  EDGEFIRST_EGL_CACHE_CAPACITY  >  16
1172    /// ```
1173    ///
1174    /// Config beats the environment deliberately, matching
1175    /// [`backend`](Self::backend): an embedder that has measured its own
1176    /// pool must not be silently overridden by a stray environment variable
1177    /// in the deployment. `Some(0)` is rejected and falls through to the
1178    /// environment, then the default, exactly as an unparseable env value
1179    /// does.
1180    ///
1181    /// # This is a buffer *count*, not a size
1182    ///
1183    /// A cached import pins its buffer — the underlying dma-buf or IOSurface
1184    /// cannot be freed while an entry names it (that is what makes the cache
1185    /// correct as well as fast; see the "EGL image cache inside HAL" section
1186    /// of `ARCHITECTURE.md`). So the memory this holds depends entirely on
1187    /// how big the caller's buffers are:
1188    ///
1189    /// ```text
1190    /// worst-case pinned bytes                                (upper bound)
1191    ///     = capacity x 3 caches x frame size x live ImageProcessors
1192    ///
1193    /// capacity 16, 640x640 RGB   (1.2 MB)  ->   59 MB per processor
1194    /// capacity 16, 1080p NV12    (3.1 MB)  ->  149 MB per processor
1195    /// capacity 16, 4K NV12      (12.4 MB)  ->  597 MB per processor
1196    /// ```
1197    ///
1198    /// A ceiling rather than an exact total: the source and NV R8 caches
1199    /// often hold two imports of the *same* buffer, and two entries naming
1200    /// one buffer pin it once. The per-cache figure is the exact one.
1201    ///
1202    /// The bound is **per [`ImageProcessor`], times three caches** (source,
1203    /// destination, and the NV R8 source cache) — not per process and not
1204    /// per library. Two `ImageProcessor`s in one process have six
1205    /// independent caches and six independent capacities. A fourth cache,
1206    /// the chroma-plane views of hardware-filtered NV resizes, has the same
1207    /// capacity but imports only buffers the NV R8 cache also holds, so it
1208    /// adds entries without pinning more memory.
1209    ///
1210    /// Whether the worst case is ever approached is up to the caller. A
1211    /// pooled producer — V4L2 capture, typically 4–8 buffers over one or two
1212    /// sizes — settles at its pool size and never nears the bound. A stream
1213    /// that allocates a fresh buffer per frame holds the last `capacity` of
1214    /// them outright, which is the difference between "16 is free" and "16
1215    /// is 597 MB".
1216    ///
1217    /// Size it from measurement rather than guesswork:
1218    /// [`CacheStats::peak_entries`] reports the working set actually
1219    /// reached, and [`CacheStats::evictions`] tells you when the bound is
1220    /// costing you re-imports (it should be zero in a steady state).
1221    ///
1222    /// Ignored when the OpenGL backend is not in use.
1223    pub egl_cache_capacity: Option<usize>,
1224}
1225
1226/// How `convert()` trades colorimetric exactness against speed on platforms
1227/// where the exact path is expensive.
1228///
1229/// Today this affects one decision: NV12 sources on Vivante GC7000UL
1230/// (i.MX 8M Plus), where the hardware external sampler converts ~12× faster
1231/// than the colorimetry-exact in-shader matrix (2.5 ms vs 29 ms at 720p)
1232/// but applies the driver's fixed BT.601-limited matrix regardless of the
1233/// source's tagged colorimetry. Platforms where the exact path is already
1234/// the fastest correct path (Mali, V3D, Tegra, ANGLE) behave identically in
1235/// both modes.
1236///
1237/// Override at runtime with `EDGEFIRST_COLORIMETRY=fast|exact` (takes
1238/// precedence over the config field), or per-source by forcing a path with
1239/// `EDGEFIRST_NV_CONVERT_PATH`.
1240#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1241pub enum ColorimetryMode {
1242    /// Prefer the fastest path whose output is correct-enough video RGB
1243    /// (default; issue #106 policy). On Vivante, NV12 takes the hardware
1244    /// sampler even when the source is not BT.601-limited.
1245    #[default]
1246    Fast,
1247    /// Prefer bit-exact colorimetry everywhere: the fast path is used only
1248    /// when it matches the source's resolved (encoding, range) exactly.
1249    Exact,
1250}
1251
1252/// How `convert()` resamples when it resizes.
1253///
1254/// Every backend honours it: the CPU backend through its resizer, the
1255/// OpenGL backend through its texture filtering and NV shader choice, and
1256/// G2D, which has a fixed hardware filter, by declining resizing converts
1257/// under [`Interpolation::Nearest`] so another backend runs them. Converts
1258/// that do not resize read each source pixel exactly in either mode.
1259///
1260/// Override at runtime with `EDGEFIRST_INTERPOLATION=nearest|bilinear`,
1261/// which takes precedence over [`ImageProcessorConfig::interpolation`] and
1262/// [`ImageProcessor::set_interpolation`].
1263#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1264#[non_exhaustive]
1265pub enum Interpolation {
1266    /// Nearest-neighbour: each output pixel copies one source pixel. The
1267    /// fastest mode, at the cost of aliasing that lowers detection accuracy
1268    /// on resized inputs.
1269    Nearest,
1270    /// Bilinear with half-pixel centres (default). The GL backend matches
1271    /// OpenCV `INTER_LINEAR`, the preprocessing most detectors are trained
1272    /// with; the CPU backend's bilinear widens its kernel when downscaling,
1273    /// which smooths more than `INTER_LINEAR` below 1×.
1274    #[default]
1275    Bilinear,
1276}
1277
1278impl Interpolation {
1279    /// The mode selected by `EDGEFIRST_INTERPOLATION`, if it is set to a
1280    /// recognised value. An unrecognised value is logged and ignored.
1281    pub(crate) fn from_env() -> Option<Self> {
1282        let value = std::env::var("EDGEFIRST_INTERPOLATION").ok()?;
1283        match value.trim().to_ascii_lowercase().as_str() {
1284            "nearest" => Some(Self::Nearest),
1285            "bilinear" => Some(Self::Bilinear),
1286            "" => None,
1287            other => {
1288                log::warn!(
1289                    "EDGEFIRST_INTERPOLATION={other:?} is not recognised \
1290                     (expected nearest or bilinear); ignoring it"
1291                );
1292                None
1293            }
1294        }
1295    }
1296}
1297
1298/// Compute backend selection for [`ImageProcessor`].
1299///
1300/// Use with [`ImageProcessorConfig::backend`] to select which backend the
1301/// processor should prefer. When a specific backend is selected, the
1302/// processor initializes that backend plus CPU as a fallback. When `Auto`
1303/// is used, the existing environment-variable-driven selection applies.
1304#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1305pub enum ComputeBackend {
1306    /// Auto-detect based on available hardware and environment variables.
1307    #[default]
1308    Auto,
1309    /// CPU-only processing (no hardware acceleration).
1310    Cpu,
1311    /// Prefer G2D hardware blitter (+ CPU fallback).
1312    G2d,
1313    /// Prefer OpenGL ES (+ CPU fallback).
1314    OpenGl,
1315}
1316
1317/// Backend forced via the `EDGEFIRST_FORCE_BACKEND` environment variable
1318/// or [`ImageProcessorConfig::backend`].
1319///
1320/// When set, the [`ImageProcessor`] only initializes and dispatches to the
1321/// selected backend — no fallback chain is used.
1322#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1323pub(crate) enum ForcedBackend {
1324    Cpu,
1325    G2d,
1326    OpenGl,
1327}
1328
1329/// Reports which float-color-buffer extensions the GPU backend detected.
1330/// Returned by [`ImageProcessor::supported_render_dtypes`]; the two flags
1331/// are independent.
1332///
1333/// **Linux:** reflects the real probe results from `GL_EXT_color_buffer_half_float`
1334/// and `GL_EXT_color_buffer_float`. On V3D (RPi 5) and Mali-G310 (i.MX 95)
1335/// both flags are typically `true`; on Vivante GC7000UL both are forced
1336/// `false` (float readback measured 170–320 ms — disabled). Tegra Orin
1337/// exposes both via PBO; the flags match the GPU report.
1338///
1339/// **macOS (ANGLE):** `f16 == true` gates the RGBA16F-packed IOSurface
1340/// path for F16 `PlanarRgb` destinations. `f32` reflects the GL
1341/// extension probe but is not actionable — ANGLE's
1342/// `EGL_ANGLE_iosurface_client_buffer` rejects every `(GL_FLOAT, *)`
1343/// combination with `EGL_BAD_ATTRIBUTE`, so there is no F32 IOSurface
1344/// path.
1345///
1346/// **Windows (ANGLE/D3D11):** `f16` enables the RGBA16F texture paths and
1347/// `f32` the RGBA32F ones for every float layout (`float_dispatch.rs`) —
1348/// `PlanarRgb` and `PlanarRgba` NCHW, interleaved `Rgb`, and `Rgba`.
1349///
1350/// Regardless of these flags, [`ImageProcessor::convert`] never returns
1351/// an error due to float capability — it falls back to CPU when the GPU
1352/// path is unavailable.
1353#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1354pub struct RenderDtypeSupport {
1355    /// `GL_EXT_color_buffer_float` is available on the current GPU.
1356    ///
1357    /// On Linux, `true` enables F32 `Rgb` NHWC PBO readback. On macOS
1358    /// this flag is informational only — no F32 IOSurface path exists.
1359    /// On Windows, `true` enables the RGBA32F zero-copy texture render
1360    /// for every F32 layout.
1361    pub f32: bool,
1362    /// `GL_EXT_color_buffer_half_float` is available on the current GPU.
1363    ///
1364    /// On Linux, `true` enables F16 `PlanarRgb` NCHW PBO readback and,
1365    /// on V3D/Mali, zero-copy DMA-BUF render. On macOS, `true` enables
1366    /// F16 `PlanarRgb` via RGBA16F-packed IOSurface (zero-copy). On
1367    /// Windows, `true` enables the RGBA16F zero-copy texture render for
1368    /// every F16 layout.
1369    pub f16: bool,
1370}
1371
1372/// Returns `true` when a float PBO destination should be attempted for `dtype`.
1373///
1374/// Only F16 and F32 are eligible, and only when the corresponding flag in
1375/// `support` is set. U8/I8 and all other dtypes return `false` — they are
1376/// handled by the existing `dtype.size() == 1` PBO gate.
1377///
1378/// The same question `create_image` asks before trying a zero-copy float
1379/// destination, which is why that gate calls this rather than restating it:
1380/// a float image is only worth allocating on the GPU where the display can
1381/// render into that dtype at all.
1382///
1383/// Compiled on every platform with a GL backend -- the name is historical
1384/// (it began as the float *PBO* gate) and both callers are live everywhere
1385/// the `opengl` feature builds a backend.
1386#[cfg(all(
1387    any(
1388        target_os = "linux",
1389        target_os = "windows",
1390        target_os = "macos",
1391        target_os = "ios",
1392        target_os = "android"
1393    ),
1394    feature = "opengl"
1395))]
1396pub(crate) fn float_pbo_eligible(dtype: DType, support: RenderDtypeSupport) -> bool {
1397    match dtype {
1398        DType::F16 => support.f16,
1399        DType::F32 => support.f32,
1400        _ => false,
1401    }
1402}
1403
1404/// Image converter that uses available hardware acceleration or CPU as a
1405/// fallback.
1406#[derive(Debug)]
1407pub struct ImageProcessor {
1408    /// CPU-based image converter as a fallback. This is only None if the
1409    /// EDGEFIRST_DISABLE_CPU environment variable is set.
1410    pub cpu: Option<CPUProcessor>,
1411
1412    #[cfg(target_os = "linux")]
1413    /// G2D-based image converter for Linux systems. This is only available if
1414    /// the EDGEFIRST_DISABLE_G2D environment variable is not set and libg2d.so
1415    /// is available.
1416    pub g2d: Option<G2DProcessor>,
1417    #[cfg(target_os = "linux")]
1418    #[cfg(feature = "opengl")]
1419    /// OpenGL-based image converter for Linux systems. This is only available
1420    /// if the EDGEFIRST_DISABLE_GL environment variable is not set and OpenGL
1421    /// ES is available.
1422    pub opengl: Option<GLProcessorThreaded>,
1423    #[cfg(any(
1424        target_os = "macos",
1425        target_os = "ios",
1426        target_os = "android",
1427        target_os = "windows"
1428    ))]
1429    #[cfg(feature = "opengl")]
1430    /// OpenGL-based image converter — the same unified
1431    /// `GLProcessorThreaded` engine as Linux (its worker owns a
1432    /// per-processor context). macOS/iOS run it via ANGLE + IOSurface
1433    /// (available when ANGLE's libEGL.dylib can be loaded — see
1434    /// README.md § macOS GPU Acceleration); Android runs it via the
1435    /// native EGL driver + AHardwareBuffer; Windows runs it via ANGLE +
1436    /// Direct3D 11 with PBO transfers (available when ANGLE's libEGL.dll
1437    /// can be loaded — see README.md § Windows GPU Acceleration).
1438    pub opengl: Option<GLProcessorThreaded>,
1439
1440    /// When set, only the specified backend is used — no fallback chain.
1441    pub(crate) forced_backend: Option<ForcedBackend>,
1442
1443    /// Converts where the GL backend declined and the auto chain fell
1444    /// through toward G2D/CPU. Owned by the dispatcher (the GL worker never
1445    /// sees these frames), unlike the per-feed counters in
1446    /// `GLProcessorThreaded::convert_stats`. Read via
1447    /// [`convert_fallback_count`](Self::convert_fallback_count).
1448    pub(crate) convert_fallbacks: std::sync::atomic::AtomicU64,
1449}
1450
1451unsafe impl Send for ImageProcessor {}
1452unsafe impl Sync for ImageProcessor {}
1453
1454impl ImageProcessor {
1455    /// Creates a new `ImageProcessor` instance, initializing available
1456    /// hardware converters based on the system capabilities and environment
1457    /// variables.
1458    ///
1459    /// # Examples
1460    /// ```rust,no_run
1461    /// # use edgefirst_image::{ImageProcessor, Rotation, Flip, Crop, ImageProcessorTrait};
1462    /// # use edgefirst_codec::{peek_info, ImageDecoder, ImageLoad};
1463    /// # use edgefirst_tensor::{CpuAccess, PixelFormat, DType, Tensor, TensorMemory};
1464    /// # fn main() -> Result<(), edgefirst_image::Error> {
1465    /// let image = std::fs::read("zidane.jpg")?;
1466    /// // The codec emits the source's native format (a colour JPEG decodes to
1467    /// // NV12) and configures the destination tensor during the decode.
1468    /// let info = peek_info(&image).expect("peek");
1469    /// let mut src = Tensor::<u8>::image(info.width, info.height, info.format,
1470    ///                                    Some(TensorMemory::Mem), edgefirst_tensor::CpuAccess::ReadWrite)?;
1471    /// let mut decoder = ImageDecoder::new();
1472    /// src.load_image(&mut decoder, &image).expect("decode");
1473    /// let mut converter = ImageProcessor::new()?;
1474    /// let mut dst =
1475    ///     converter.create_image(640, 480, PixelFormat::Rgb, DType::U8, None, CpuAccess::ReadWrite)?;
1476    /// converter.convert(&src.into(), &mut dst, Rotation::None, Flip::None, Crop::default())?;
1477    /// # Ok(())
1478    /// # }
1479    /// ```
1480    pub fn new() -> Result<Self> {
1481        Self::with_config(ImageProcessorConfig::default())
1482    }
1483
1484    /// Number of converts where the auto chain's GL backend declined and the
1485    /// frame fell through toward G2D/CPU — each one silently lost any
1486    /// zero-copy guarantee. A pipeline that expects to stay on the GPU
1487    /// asserts this stays flat across its steady-state loop; pair with
1488    /// `GLProcessorThreaded::convert_stats` to see how the frames that DID
1489    /// reach GL were fed. Always 0 under a forced backend (no chain).
1490    pub fn convert_fallback_count(&self) -> u64 {
1491        self.convert_fallbacks
1492            .load(std::sync::atomic::Ordering::Relaxed)
1493    }
1494
1495    /// Number of [`Compression::Any`](edgefirst_tensor::Compression)
1496    /// requests since process start that resolved to a linear layout
1497    /// (process-wide mirror of
1498    /// [`edgefirst_tensor::compression_fallback_count`], surfaced here
1499    /// beside [`convert_fallback_count`](Self::convert_fallback_count)
1500    /// so pipeline telemetry reads from one place). A pipeline that
1501    /// expects compressed convert destinations asserts this stays flat
1502    /// after warmup.
1503    pub fn compression_fallback_count(&self) -> u64 {
1504        edgefirst_tensor::compression_fallback_count()
1505    }
1506
1507    /// Convert and return a native fence that signals when the GPU work
1508    /// completes, instead of blocking the CPU — the GL→NPU handoff
1509    /// (`EGL_ANDROID_native_fence_sync` on Android). On Windows the handle
1510    /// is a manual-reset event set when the GPU work completes; wait on it
1511    /// with `WaitForSingleObject`.
1512    ///
1513    /// `Ok(Some(fence))`: the destination buffer is still in flight; hand
1514    /// the fence to the consumer (e.g.
1515    /// `ANeuralNetworksExecution_startComputeWithDependencies` on Android,
1516    /// or `WaitForSingleObject` on Windows) or poll it before reading.
1517    /// `Ok(None)`: the convert completed with the normal blocking contract
1518    /// (no native fence on this platform, or a non-GL backend handled the
1519    /// frame) — the destination is already safe. Semantics are otherwise
1520    /// identical to [`convert`](ImageProcessorTrait::convert), including
1521    /// the GL→G2D→CPU fallback chain.
1522    ///
1523    /// On Windows, **for a texture destination**, the event and the
1524    /// destination's own `Tensor::gpu_completion` name the same fence value:
1525    /// one flush and one signal cover the convert, and the value recorded on
1526    /// `dst` is the one this event waits for, so a consumer may use either.
1527    /// A destination that is not a texture records nothing, and the event
1528    /// then carries a value of its own that only it can be waited on for.
1529    pub fn convert_with_fence(
1530        &mut self,
1531        src: &TensorDyn,
1532        dst: &mut TensorDyn,
1533        rotation: Rotation,
1534        flip: Flip,
1535        crop: Crop,
1536    ) -> Result<Option<CompletionFence>> {
1537        #[cfg(any(
1538            target_os = "linux",
1539            target_os = "macos",
1540            target_os = "ios",
1541            target_os = "android",
1542            target_os = "windows"
1543        ))]
1544        #[cfg(feature = "opengl")]
1545        {
1546            let gl_forced = matches!(self.forced_backend, Some(ForcedBackend::OpenGl));
1547            if self.forced_backend.is_none() || gl_forced {
1548                if let Some(opengl) = self.opengl.as_mut() {
1549                    match opengl.convert_with_fence(src, dst, rotation, flip, crop) {
1550                        Ok(fd) => return Ok(fd),
1551                        Err(e) if gl_forced => return Err(e),
1552                        Err(e) => {
1553                            // Not counted here: the blocking chain below
1554                            // re-runs the dispatcher, whose decline arm
1555                            // counts the fallback exactly once.
1556                            log::debug!(
1557                                "convert_with_fence: opengl declined, \
1558                                 falling back to the blocking chain: {e}"
1559                            );
1560                        }
1561                    }
1562                } else if gl_forced {
1563                    return Err(Error::ForcedBackendUnavailable("opengl".into()));
1564                }
1565            }
1566        }
1567        // Blocking chain (G2D/CPU, forced non-GL, or non-GL builds):
1568        // completion == return, so no fence is needed.
1569        self.convert(src, dst, rotation, flip, crop)?;
1570        Ok(None)
1571    }
1572
1573    /// Report which float dtypes the GPU can render to.
1574    ///
1575    /// Probes `GL_EXT_color_buffer_half_float` and
1576    /// `GL_EXT_color_buffer_float` once at `ImageProcessor::new()` time
1577    /// and caches the result. Call this once at startup to decide whether
1578    /// to request F16 or F32 destination tensors; [`create_image`] uses
1579    /// the result internally to auto-select float PBO when supported.
1580    ///
1581    /// Returns `RenderDtypeSupport { f32: false, f16: false }` when no
1582    /// OpenGL backend is active or the `opengl` feature is disabled.
1583    ///
1584    /// [`create_image`]: Self::create_image
1585    pub fn supported_render_dtypes(&self) -> RenderDtypeSupport {
1586        #[cfg(all(
1587            any(
1588                target_os = "macos",
1589                target_os = "ios",
1590                target_os = "android",
1591                target_os = "windows"
1592            ),
1593            feature = "opengl"
1594        ))]
1595        if let Some(gl) = self.opengl.as_ref() {
1596            return gl.supported_render_dtypes();
1597        }
1598        #[cfg(all(target_os = "linux", feature = "opengl"))]
1599        if let Some(gl) = self.opengl.as_ref() {
1600            return gl.supported_render_dtypes();
1601        }
1602        RenderDtypeSupport {
1603            f32: false,
1604            f16: false,
1605        }
1606    }
1607
1608    /// Creates a new `ImageProcessor` with the given configuration.
1609    ///
1610    /// When [`ImageProcessorConfig::backend`] is set to a specific backend,
1611    /// environment variables are ignored and the processor initializes the
1612    /// requested backend plus CPU as a fallback.
1613    ///
1614    /// When `Auto`, the existing `EDGEFIRST_FORCE_BACKEND` and
1615    /// `EDGEFIRST_DISABLE_*` environment variables apply.
1616    pub fn with_config(config: ImageProcessorConfig) -> Result<Self> {
1617        let interpolation = config.interpolation;
1618        let mut processor = Self::with_config_backends(config)?;
1619        processor.set_interpolation(interpolation)?;
1620        Ok(processor)
1621    }
1622
1623    /// The backend selection half of [`Self::with_config`].
1624    #[allow(unused_variables)]
1625    fn with_config_backends(config: ImageProcessorConfig) -> Result<Self> {
1626        // ── Config-driven backend selection ──────────────────────────
1627        // When the caller explicitly requests a backend via the config,
1628        // skip all environment variable logic.
1629        match config.backend {
1630            ComputeBackend::Cpu => {
1631                log::info!("ComputeBackend::Cpu — CPU only");
1632                return Ok(Self {
1633                    cpu: Some(CPUProcessor::new()),
1634                    #[cfg(target_os = "linux")]
1635                    g2d: None,
1636                    #[cfg(target_os = "linux")]
1637                    #[cfg(feature = "opengl")]
1638                    opengl: None,
1639                    #[cfg(any(
1640                        target_os = "macos",
1641                        target_os = "ios",
1642                        target_os = "android",
1643                        target_os = "windows"
1644                    ))]
1645                    #[cfg(feature = "opengl")]
1646                    opengl: None,
1647                    convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1648                    forced_backend: None,
1649                });
1650            }
1651            ComputeBackend::G2d => {
1652                log::info!("ComputeBackend::G2d — G2D + CPU fallback");
1653                #[cfg(target_os = "linux")]
1654                {
1655                    let g2d = match G2DProcessor::new() {
1656                        Ok(g) => Some(g),
1657                        Err(e) => {
1658                            log::warn!("G2D requested but failed to initialize: {e:?}");
1659                            None
1660                        }
1661                    };
1662                    return Ok(Self {
1663                        cpu: Some(CPUProcessor::new()),
1664                        g2d,
1665                        #[cfg(feature = "opengl")]
1666                        opengl: None,
1667                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1668                        forced_backend: None,
1669                    });
1670                }
1671                #[cfg(not(target_os = "linux"))]
1672                {
1673                    log::warn!("G2D requested but not available on this platform, using CPU");
1674                    return Ok(Self {
1675                        cpu: Some(CPUProcessor::new()),
1676                        #[cfg(any(
1677                            target_os = "macos",
1678                            target_os = "ios",
1679                            target_os = "android",
1680                            target_os = "windows"
1681                        ))]
1682                        #[cfg(feature = "opengl")]
1683                        opengl: None,
1684                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1685                        forced_backend: None,
1686                    });
1687                }
1688            }
1689            ComputeBackend::OpenGl => {
1690                log::info!("ComputeBackend::OpenGl — OpenGL + CPU fallback");
1691                #[cfg(target_os = "linux")]
1692                {
1693                    #[cfg(feature = "opengl")]
1694                    let opengl = match GLProcessorThreaded::with_cache_capacity(
1695                        config.egl_display,
1696                        config.egl_cache_capacity,
1697                    ) {
1698                        Ok(gl) => Some(gl),
1699                        Err(e) => {
1700                            log::warn!("OpenGL requested but failed to initialize: {e:?}");
1701                            None
1702                        }
1703                    };
1704                    return Ok(Self {
1705                        cpu: Some(CPUProcessor::new()),
1706                        g2d: None,
1707                        #[cfg(feature = "opengl")]
1708                        opengl,
1709                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1710                        forced_backend: None,
1711                    }
1712                    .apply_colorimetry_mode(config.colorimetry));
1713                }
1714                #[cfg(any(target_os = "macos", target_os = "ios"))]
1715                {
1716                    #[cfg(feature = "opengl")]
1717                    let opengl = match GLProcessorThreaded::with_cache_capacity(
1718                        config.egl_display,
1719                        config.egl_cache_capacity,
1720                    ) {
1721                        Ok(gl) => Some(gl),
1722                        Err(e) => {
1723                            log::warn!(
1724                                "OpenGL requested on macOS but ANGLE init failed: {e:?} \
1725                                 (install ANGLE via `brew install startergo/angle/angle` \
1726                                 and re-sign the dylibs — see README.md § macOS GPU \
1727                                 Acceleration). Falling back to CPU."
1728                            );
1729                            None
1730                        }
1731                    };
1732                    return Ok(Self {
1733                        cpu: Some(CPUProcessor::new()),
1734                        #[cfg(feature = "opengl")]
1735                        opengl,
1736                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1737                        forced_backend: None,
1738                    }
1739                    .apply_colorimetry_mode(config.colorimetry));
1740                }
1741                #[cfg(target_os = "android")]
1742                {
1743                    #[cfg(feature = "opengl")]
1744                    let opengl = match GLProcessorThreaded::with_cache_capacity(
1745                        config.egl_display,
1746                        config.egl_cache_capacity,
1747                    ) {
1748                        Ok(gl) => Some(gl),
1749                        Err(e) => {
1750                            log::warn!(
1751                                "OpenGL requested but native EGL init failed: {e:?}. \
1752                                 Falling back to CPU."
1753                            );
1754                            None
1755                        }
1756                    };
1757                    return Ok(Self {
1758                        cpu: Some(CPUProcessor::new()),
1759                        #[cfg(feature = "opengl")]
1760                        opengl,
1761                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1762                        forced_backend: None,
1763                    }
1764                    .apply_colorimetry_mode(config.colorimetry));
1765                }
1766                #[cfg(target_os = "windows")]
1767                {
1768                    #[cfg(feature = "opengl")]
1769                    let opengl = match GLProcessorThreaded::with_cache_capacity(
1770                        config.egl_display,
1771                        config.egl_cache_capacity,
1772                    ) {
1773                        Ok(gl) => Some(gl),
1774                        Err(e) => {
1775                            log::warn!(
1776                                "OpenGL requested on Windows but ANGLE/D3D11 init failed: {e:?} \
1777                                 (fetch ANGLE with `scripts/fetch-angle.sh` and set \
1778                                 EDGEFIRST_ANGLE_PATH to the directory containing libEGL.dll + \
1779                                 libGLESv2.dll, or place the two DLLs next to the executable — \
1780                                 see README.md § Windows GPU Acceleration). Falling back to CPU."
1781                            );
1782                            None
1783                        }
1784                    };
1785                    return Ok(Self {
1786                        cpu: Some(CPUProcessor::new()),
1787                        #[cfg(feature = "opengl")]
1788                        opengl,
1789                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1790                        forced_backend: None,
1791                    }
1792                    .apply_colorimetry_mode(config.colorimetry));
1793                }
1794                #[cfg(not(any(
1795                    target_os = "linux",
1796                    target_os = "macos",
1797                    target_os = "ios",
1798                    target_os = "android",
1799                    target_os = "windows"
1800                )))]
1801                {
1802                    log::warn!("OpenGL requested but not available on this platform, using CPU");
1803                    return Ok(Self {
1804                        cpu: Some(CPUProcessor::new()),
1805                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1806                        forced_backend: None,
1807                    });
1808                }
1809            }
1810            ComputeBackend::Auto => { /* fall through to env-var logic below */ }
1811        }
1812
1813        // ── EDGEFIRST_FORCE_BACKEND ──────────────────────────────────
1814        // When set, only the requested backend is initialised and no
1815        // fallback chain is used. Accepted values (case-insensitive):
1816        //   "cpu", "g2d", "opengl"
1817        if let Ok(val) = std::env::var("EDGEFIRST_FORCE_BACKEND") {
1818            let val_lower = val.to_lowercase();
1819            let forced = match val_lower.as_str() {
1820                "cpu" => ForcedBackend::Cpu,
1821                "g2d" => ForcedBackend::G2d,
1822                "opengl" => ForcedBackend::OpenGl,
1823                other => {
1824                    return Err(Error::ForcedBackendUnavailable(format!(
1825                        "unknown EDGEFIRST_FORCE_BACKEND value: {other:?} (expected cpu, g2d, or opengl)"
1826                    )));
1827                }
1828            };
1829
1830            log::info!("EDGEFIRST_FORCE_BACKEND={val} — only initializing {val_lower} backend");
1831
1832            return match forced {
1833                ForcedBackend::Cpu => Ok(Self {
1834                    cpu: Some(CPUProcessor::new()),
1835                    #[cfg(target_os = "linux")]
1836                    g2d: None,
1837                    #[cfg(target_os = "linux")]
1838                    #[cfg(feature = "opengl")]
1839                    opengl: None,
1840                    #[cfg(any(
1841                        target_os = "macos",
1842                        target_os = "ios",
1843                        target_os = "android",
1844                        target_os = "windows"
1845                    ))]
1846                    #[cfg(feature = "opengl")]
1847                    opengl: None,
1848                    convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1849                    forced_backend: Some(ForcedBackend::Cpu),
1850                }),
1851                ForcedBackend::G2d => {
1852                    #[cfg(target_os = "linux")]
1853                    {
1854                        let g2d = G2DProcessor::new().map_err(|e| {
1855                            Error::ForcedBackendUnavailable(format!(
1856                                "g2d forced but failed to initialize: {e:?}"
1857                            ))
1858                        })?;
1859                        Ok(Self {
1860                            cpu: None,
1861                            g2d: Some(g2d),
1862                            #[cfg(feature = "opengl")]
1863                            opengl: None,
1864                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1865                            forced_backend: Some(ForcedBackend::G2d),
1866                        })
1867                    }
1868                    #[cfg(not(target_os = "linux"))]
1869                    {
1870                        Err(Error::ForcedBackendUnavailable(
1871                            "g2d backend is only available on Linux".into(),
1872                        ))
1873                    }
1874                }
1875                ForcedBackend::OpenGl => {
1876                    #[cfg(target_os = "linux")]
1877                    #[cfg(feature = "opengl")]
1878                    {
1879                        let opengl = GLProcessorThreaded::with_cache_capacity(
1880                            config.egl_display,
1881                            config.egl_cache_capacity,
1882                        )
1883                        .map_err(|e| {
1884                            Error::ForcedBackendUnavailable(format!(
1885                                "opengl forced but failed to initialize: {e:?}"
1886                            ))
1887                        })?;
1888                        Ok(Self {
1889                            cpu: None,
1890                            g2d: None,
1891                            opengl: Some(opengl),
1892                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1893                            forced_backend: Some(ForcedBackend::OpenGl),
1894                        }
1895                        .apply_colorimetry_mode(config.colorimetry))
1896                    }
1897                    #[cfg(any(target_os = "macos", target_os = "ios"))]
1898                    #[cfg(feature = "opengl")]
1899                    {
1900                        let opengl = GLProcessorThreaded::with_cache_capacity(
1901                            config.egl_display,
1902                            config.egl_cache_capacity,
1903                        )
1904                        .map_err(|e| {
1905                            Error::ForcedBackendUnavailable(format!(
1906                                "opengl forced on macOS but ANGLE init failed: {e:?}"
1907                            ))
1908                        })?;
1909                        Ok(Self {
1910                            cpu: None,
1911                            opengl: Some(opengl),
1912                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1913                            forced_backend: Some(ForcedBackend::OpenGl),
1914                        }
1915                        .apply_colorimetry_mode(config.colorimetry))
1916                    }
1917                    #[cfg(target_os = "android")]
1918                    #[cfg(feature = "opengl")]
1919                    {
1920                        let opengl = GLProcessorThreaded::with_cache_capacity(
1921                            config.egl_display,
1922                            config.egl_cache_capacity,
1923                        )
1924                        .map_err(|e| {
1925                            Error::ForcedBackendUnavailable(format!(
1926                                "opengl forced but native EGL init failed: {e:?}"
1927                            ))
1928                        })?;
1929                        Ok(Self {
1930                            cpu: None,
1931                            opengl: Some(opengl),
1932                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1933                            forced_backend: Some(ForcedBackend::OpenGl),
1934                        }
1935                        .apply_colorimetry_mode(config.colorimetry))
1936                    }
1937                    #[cfg(target_os = "windows")]
1938                    #[cfg(feature = "opengl")]
1939                    {
1940                        let opengl = GLProcessorThreaded::with_cache_capacity(
1941                            config.egl_display,
1942                            config.egl_cache_capacity,
1943                        )
1944                        .map_err(|e| {
1945                            Error::ForcedBackendUnavailable(format!(
1946                                "opengl forced on Windows but ANGLE/D3D11 init failed: {e:?}"
1947                            ))
1948                        })?;
1949                        Ok(Self {
1950                            cpu: None,
1951                            opengl: Some(opengl),
1952                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1953                            forced_backend: Some(ForcedBackend::OpenGl),
1954                        }
1955                        .apply_colorimetry_mode(config.colorimetry))
1956                    }
1957                    #[cfg(not(all(
1958                        any(
1959                            target_os = "linux",
1960                            target_os = "macos",
1961                            target_os = "ios",
1962                            target_os = "android",
1963                            target_os = "windows"
1964                        ),
1965                        feature = "opengl"
1966                    )))]
1967                    {
1968                        Err(Error::ForcedBackendUnavailable(
1969                            "opengl backend requires Linux, macOS, Android or Windows with the \
1970                             'opengl' feature enabled"
1971                                .into(),
1972                        ))
1973                    }
1974                }
1975            };
1976        }
1977
1978        // ── Existing DISABLE logic (unchanged) ──────────────────────
1979        #[cfg(target_os = "linux")]
1980        let g2d = if std::env::var("EDGEFIRST_DISABLE_G2D")
1981            .map(|x| x != "0" && x.to_lowercase() != "false")
1982            .unwrap_or(false)
1983        {
1984            log::debug!("EDGEFIRST_DISABLE_G2D is set");
1985            None
1986        } else {
1987            match G2DProcessor::new() {
1988                Ok(g2d_converter) => Some(g2d_converter),
1989                Err(err) => {
1990                    log::warn!("Failed to initialize G2D converter: {err:?}");
1991                    None
1992                }
1993            }
1994        };
1995
1996        #[cfg(target_os = "linux")]
1997        #[cfg(feature = "opengl")]
1998        let opengl = if std::env::var("EDGEFIRST_DISABLE_GL")
1999            .map(|x| x != "0" && x.to_lowercase() != "false")
2000            .unwrap_or(false)
2001        {
2002            log::debug!("EDGEFIRST_DISABLE_GL is set");
2003            None
2004        } else {
2005            match GLProcessorThreaded::with_cache_capacity(
2006                config.egl_display,
2007                config.egl_cache_capacity,
2008            ) {
2009                Ok(gl_converter) => Some(gl_converter),
2010                Err(err) => {
2011                    log::warn!("Failed to initialize GL converter: {err:?}");
2012                    None
2013                }
2014            }
2015        };
2016
2017        #[cfg(any(
2018            target_os = "macos",
2019            target_os = "ios",
2020            target_os = "android",
2021            target_os = "windows"
2022        ))]
2023        #[cfg(feature = "opengl")]
2024        let opengl = if std::env::var("EDGEFIRST_DISABLE_GL")
2025            .map(|x| x != "0" && x.to_lowercase() != "false")
2026            .unwrap_or(false)
2027        {
2028            log::debug!("EDGEFIRST_DISABLE_GL is set");
2029            None
2030        } else {
2031            match GLProcessorThreaded::with_cache_capacity(
2032                config.egl_display,
2033                config.egl_cache_capacity,
2034            ) {
2035                Ok(gl_converter) => Some(gl_converter),
2036                Err(err) => {
2037                    log::debug!(
2038                        "GL backend unavailable: {err:?} \
2039                         (CPU fallback will be used)"
2040                    );
2041                    None
2042                }
2043            }
2044        };
2045
2046        let cpu = if std::env::var("EDGEFIRST_DISABLE_CPU")
2047            .map(|x| x != "0" && x.to_lowercase() != "false")
2048            .unwrap_or(false)
2049        {
2050            log::debug!("EDGEFIRST_DISABLE_CPU is set");
2051            None
2052        } else {
2053            Some(CPUProcessor::new())
2054        };
2055        Ok(Self {
2056            cpu,
2057            #[cfg(target_os = "linux")]
2058            g2d,
2059            #[cfg(target_os = "linux")]
2060            #[cfg(feature = "opengl")]
2061            opengl,
2062            #[cfg(any(
2063                target_os = "macos",
2064                target_os = "ios",
2065                target_os = "android",
2066                target_os = "windows"
2067            ))]
2068            #[cfg(feature = "opengl")]
2069            opengl,
2070            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
2071            forced_backend: None,
2072        }
2073        .apply_colorimetry_mode(config.colorimetry))
2074    }
2075
2076    /// Apply the configured [`ColorimetryMode`] to whichever backend honours
2077    /// it (currently the Linux GL backend); no-op elsewhere. Constructor
2078    /// plumbing for [`ImageProcessorConfig::colorimetry`].
2079    fn apply_colorimetry_mode(self, _mode: ColorimetryMode) -> Self {
2080        #[cfg(all(
2081            any(
2082                target_os = "linux",
2083                target_os = "macos",
2084                target_os = "ios",
2085                target_os = "android",
2086                target_os = "windows"
2087            ),
2088            feature = "opengl"
2089        ))]
2090        {
2091            let mut me = self;
2092            if let Err(e) = me.set_colorimetry_mode(_mode) {
2093                log::warn!("Failed to apply ColorimetryMode::{_mode:?}: {e:?}");
2094            }
2095            me
2096        }
2097        #[cfg(not(all(
2098            any(
2099                target_os = "linux",
2100                target_os = "macos",
2101                target_os = "ios",
2102                target_os = "android",
2103                target_os = "windows"
2104            ),
2105            feature = "opengl"
2106        )))]
2107        {
2108            let _ = _mode;
2109            self
2110        }
2111    }
2112
2113    /// Sets the colorimetry/performance trade-off (see [`ColorimetryMode`])
2114    /// on the OpenGL backend. No-op if OpenGL is not available. The
2115    /// `EDGEFIRST_COLORIMETRY` environment variable takes precedence — when
2116    /// it is set, this call logs and keeps the env-selected mode.
2117    #[cfg(all(
2118        any(
2119            target_os = "linux",
2120            target_os = "macos",
2121            target_os = "ios",
2122            target_os = "android",
2123            target_os = "windows"
2124        ),
2125        feature = "opengl"
2126    ))]
2127    pub fn set_colorimetry_mode(&mut self, mode: ColorimetryMode) -> Result<()> {
2128        if let Some(ref mut gl) = self.opengl {
2129            gl.set_colorimetry_mode(mode)?;
2130        }
2131        Ok(())
2132    }
2133
2134    /// Sets how `convert()` resamples when it resizes (see [`Interpolation`])
2135    /// on every backend this processor holds. The `EDGEFIRST_INTERPOLATION`
2136    /// environment variable takes precedence: when it is set, this call logs
2137    /// and keeps the env-selected mode.
2138    pub fn set_interpolation(&mut self, mode: Interpolation) -> Result<()> {
2139        let mode = match Interpolation::from_env() {
2140            Some(env) => {
2141                if env != mode {
2142                    log::info!(
2143                        "EDGEFIRST_INTERPOLATION pins Interpolation::{env:?}; \
2144                         ignoring Interpolation::{mode:?}"
2145                    );
2146                }
2147                env
2148            }
2149            None => mode,
2150        };
2151        if let Some(cpu) = self.cpu.as_mut() {
2152            cpu.set_interpolation(mode);
2153        }
2154        #[cfg(target_os = "linux")]
2155        if let Some(g2d) = self.g2d.as_mut() {
2156            g2d.set_interpolation(mode);
2157        }
2158        #[cfg(all(
2159            any(
2160                target_os = "linux",
2161                target_os = "macos",
2162                target_os = "ios",
2163                target_os = "android",
2164                target_os = "windows"
2165            ),
2166            feature = "opengl"
2167        ))]
2168        if let Some(gl) = self.opengl.as_mut() {
2169            gl.set_interpolation(mode)?;
2170        }
2171        Ok(())
2172    }
2173
2174    /// Sets the interpolation mode for int8 proto textures on the OpenGL
2175    /// backend. No-op if OpenGL is not available.
2176    #[cfg(all(
2177        any(
2178            target_os = "linux",
2179            target_os = "macos",
2180            target_os = "ios",
2181            target_os = "android",
2182            target_os = "windows"
2183        ),
2184        feature = "opengl"
2185    ))]
2186    pub fn set_int8_interpolation_mode(&mut self, mode: Int8InterpolationMode) -> Result<()> {
2187        if let Some(ref mut gl) = self.opengl {
2188            gl.set_int8_interpolation_mode(mode)?;
2189        }
2190        Ok(())
2191    }
2192
2193    /// Allocate an image tensor from a declarative
2194    /// [`ImageDesc`](edgefirst_tensor::ImageDesc) request — the
2195    /// full-featured variant of [`create_image`](Self::create_image).
2196    ///
2197    /// Without a compression or contiguous request this is exactly
2198    /// `create_image` (the processor's memory negotiation applies). With
2199    /// one, the allocation rides the tensor desc path un-negotiated: the
2200    /// layout decision belongs to the platform allocator, and the request's
2201    /// guards and fallback counting live there (see
2202    /// [`Tensor::image_desc`](edgefirst_tensor::Tensor::image_desc)). A
2203    /// contiguous request is a CMA DMA-BUF at the same 64-byte GPU pitch
2204    /// `create_image` uses, or an error; it never falls back to a PBO or
2205    /// host memory.
2206    pub fn create_image_desc(&self, desc: &edgefirst_tensor::ImageDesc) -> Result<TensorDyn> {
2207        if desc.compression().is_none() && !desc.contiguous() {
2208            return self.create_image(
2209                desc.width(),
2210                desc.height(),
2211                desc.format(),
2212                desc.dtype(),
2213                desc.memory(),
2214                desc.access(),
2215            );
2216        }
2217        Ok(TensorDyn::image_desc(desc)?)
2218    }
2219
2220    /// Create a [`TensorDyn`] image with the best available memory backend.
2221    ///
2222    /// Priority: DMA-buf → float PBO (F16/F32) → u8/i8 PBO → system memory.
2223    ///
2224    /// Use this method instead of [`TensorDyn::image()`] when the tensor will
2225    /// be used with [`ImageProcessor::convert()`]. It selects the optimal
2226    /// memory backing (including PBO for GPU zero-copy) which direct
2227    /// allocation cannot achieve.
2228    ///
2229    /// This method is on [`ImageProcessor`] rather than [`ImageProcessorTrait`]
2230    /// because optimal allocation requires knowledge of the active compute
2231    /// backends (e.g. the GL context handle for PBO allocation). Individual
2232    /// backend implementations ([`CPUProcessor`], etc.) do not have this
2233    /// cross-backend visibility.
2234    ///
2235    /// **Float dtype behaviour:** when `dtype` is `F16` or `F32` and
2236    /// [`supported_render_dtypes`] reports the GPU supports that type,
2237    /// `memory: None` auto-selects a float PBO (Linux) or IOSurface (macOS
2238    /// F16 only). If GPU float support is absent the allocation falls through
2239    /// to `TensorMemory::Mem`; [`convert`] then uses the CPU path.
2240    /// Passing `memory: Some(TensorMemory::DmaBuf)` with `dtype: F32` always
2241    /// returns `Error::NotSupported` — no 32-bit-float DRM fourcc exists.
2242    ///
2243    /// [`supported_render_dtypes`]: Self::supported_render_dtypes
2244    /// [`convert`]: ImageProcessorTrait::convert
2245    ///
2246    /// # Arguments
2247    ///
2248    /// * `width` - Image width in pixels
2249    /// * `height` - Image height in pixels
2250    /// * `format` - Pixel format
2251    /// * `dtype` - Element data type (e.g. `DType::U8`, `DType::F16`, `DType::F32`)
2252    /// * `memory` - Optional memory type override; when `None`, the best
2253    ///   available backend is selected automatically.
2254    /// * `access` - Declares the CPU involvement you plan on. Use
2255    ///   [`CpuAccess::None`](edgefirst_tensor::CpuAccess) for a destination
2256    ///   that only hardware reads (an NPU input), and `ReadWrite` when you
2257    ///   will map it. Hardware access is always implied. Mapping a tensor
2258    ///   beyond its declaration is counted by
2259    ///   [`unplanned_cpu_access_count`](edgefirst_tensor::unplanned_cpu_access_count).
2260    ///
2261    /// # Returns
2262    ///
2263    /// A [`TensorDyn`] backed by the highest-performance memory type
2264    /// available on this system.
2265    ///
2266    /// # Examples
2267    ///
2268    /// ```rust,no_run
2269    /// # use edgefirst_image::ImageProcessor;
2270    /// # use edgefirst_tensor::{CpuAccess, DType, PixelFormat};
2271    /// # fn main() -> Result<(), edgefirst_image::Error> {
2272    /// let processor = ImageProcessor::new()?;
2273    ///
2274    /// // Model input the NPU reads directly — no CPU mapping planned.
2275    /// let input = processor.create_image(
2276    ///     640, 640, PixelFormat::Rgb, DType::U8, None, CpuAccess::None,
2277    /// )?;
2278    /// # let _ = input;
2279    /// # Ok(())
2280    /// # }
2281    /// ```
2282    ///
2283    /// # Pitch alignment for DMA-backed allocations
2284    ///
2285    /// DMA-BUF imports into the GL backend (Mali Valhall on i.MX 95
2286    /// specifically) require every row pitch to be a multiple of
2287    /// [`GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES`] (currently 64). When this
2288    /// method lands on `TensorMemory::DmaBuf`, the underlying allocation is
2289    /// silently padded so the row stride satisfies that requirement.
2290    ///
2291    /// **The user-requested `width` is preserved** — `tensor.width()`
2292    /// returns the same value you passed in. The padding is carried by
2293    /// [`TensorDyn::row_stride`] / `effective_row_stride()`, which the
2294    /// GL backend reads when importing the buffer as an EGLImage.
2295    /// Callers that compute byte offsets from the tensor must use the
2296    /// stride, not `width × bytes_per_pixel`; the CPU mapping spans the
2297    /// full `stride × height` bytes.
2298    ///
2299    /// Pre-aligned widths (640, 1280, 1920, 3008, 3840 …) allocate
2300    /// exactly `width × bpp × height` bytes with no padding. PBO and
2301    /// Mem fallbacks never pad — they don't go through EGLImage import.
2302    ///
2303    /// See also [`align_width_for_gpu_pitch`] for an advisory helper
2304    /// that external callers (GStreamer plugins, video pipelines) can
2305    /// use to size their own DMA-BUFs for GL compatibility.
2306    ///
2307    /// # Errors
2308    ///
2309    /// Returns an error if all allocation strategies fail.
2310    pub fn create_image(
2311        &self,
2312        width: usize,
2313        height: usize,
2314        format: PixelFormat,
2315        dtype: DType,
2316        memory: Option<TensorMemory>,
2317        access: edgefirst_tensor::CpuAccess,
2318    ) -> Result<TensorDyn> {
2319        // Compute the GPU-aligned row stride in bytes for this image.
2320        // `None` means either the format has no defined primary-plane bpp
2321        // (unknown future layout) or the stride calculation would overflow
2322        // — in both cases we fall back to the natural layout via the plain
2323        // `TensorDyn::image` constructor, and the slow-path warning inside
2324        // `draw_*_masks` will fire if the subsequent GL import fails.
2325        //
2326        // DMA allocation is Linux-only (see `TensorMemory::DmaBuf` cfg gate),
2327        // so both the stride computation and the helper closure are gated
2328        // accordingly — the callers below are already Linux-only.
2329        #[cfg(target_os = "linux")]
2330        let dma_stride_bytes: Option<usize> = primary_plane_bpp(format, dtype.size())
2331            .and_then(|bpp| width.checked_mul(bpp))
2332            .and_then(align_pitch_bytes_to_gpu_alignment);
2333
2334        // Helper: allocate a DMA image, using the padded-stride constructor
2335        // when the computed stride exceeds the natural pitch, otherwise the
2336        // plain constructor (byte-identical result in the common case).
2337        #[cfg(target_os = "linux")]
2338        let try_dma = || -> Result<TensorDyn> {
2339            // Stride padding is only meaningful for packed pixel layouts
2340            // (RGBA8, BGRA8, RGB888, Grey) — the formats the GL backend
2341            // renders into. Semi-planar (NV12, NV16) and planar (PlanarRgb,
2342            // PlanarRgba) tensors go through `TensorDyn::image(...)` with
2343            // their natural layout; they're imported from camera capture
2344            // via `from_fd` far more often than allocated here, and
2345            // `Tensor::image_with_stride` explicitly rejects them.
2346            let packed = format.layout() == edgefirst_tensor::PixelLayout::Packed;
2347            match dma_stride_bytes {
2348                Some(stride)
2349                    if packed
2350                        && primary_plane_bpp(format, dtype.size())
2351                            .and_then(|bpp| width.checked_mul(bpp))
2352                            .is_some_and(|natural| stride > natural) =>
2353                {
2354                    log::debug!(
2355                        "create_image: padding row stride for {format:?} {width}x{height} \
2356                         from natural pitch to {stride} bytes for GPU alignment"
2357                    );
2358                    Ok(TensorDyn::image_with_stride(
2359                        width,
2360                        height,
2361                        format,
2362                        dtype,
2363                        stride,
2364                        Some(edgefirst_tensor::TensorMemory::DmaBuf),
2365                        access,
2366                    )?)
2367                }
2368                _ => Ok(TensorDyn::image(
2369                    width,
2370                    height,
2371                    format,
2372                    dtype,
2373                    Some(edgefirst_tensor::TensorMemory::DmaBuf),
2374                    access,
2375                )?),
2376            }
2377        };
2378
2379        // If an explicit memory type is requested, honour it directly.
2380        // On Linux, `TensorMemory::DmaBuf` gets the padded-stride treatment;
2381        // other memory types take the user-requested width verbatim.
2382        // On macOS, `TensorMemory::DmaBuf` dispatches through `TensorDyn::image`
2383        // which selects the IOSurface allocation path (FourCC-formatted)
2384        // for image-mappable formats, or falls back to SHM/Mem otherwise.
2385        match memory {
2386            #[cfg(target_os = "linux")]
2387            Some(TensorMemory::DmaBuf) => {
2388                // F32 has no 32-bit-float DRM fourcc; callers must use PBO instead.
2389                if dtype == DType::F32 {
2390                    return Err(Error::NotSupported(
2391                        "F32 has no 32-bit-float DRM format for DMA-BUF; \
2392                         use TensorMemory::Pbo for F32"
2393                            .to_string(),
2394                    ));
2395                }
2396                return try_dma();
2397            }
2398            Some(mem) => {
2399                return Ok(TensorDyn::image(
2400                    width,
2401                    height,
2402                    format,
2403                    dtype,
2404                    Some(mem),
2405                    access,
2406                )?);
2407            }
2408            None => {}
2409        }
2410
2411        // macOS/Android/Windows: when the GL backend is active, prefer Dma
2412        // (IOSurface on Apple, AHardwareBuffer on Android, a D3D11 texture
2413        // on Windows) for zero-copy import. Formats without a zero-copy
2414        // mapping now ERROR under explicit Dma (the explicit-Dma
2415        // contract), so auto-select catches that error here and falls
2416        // back to host storage — loudly, via the debug log below.
2417        #[cfg(any(
2418            target_os = "macos",
2419            target_os = "ios",
2420            target_os = "android",
2421            target_os = "windows"
2422        ))]
2423        #[cfg(feature = "opengl")]
2424        if let Some(gl) = self.opengl.as_ref() {
2425            let _ = gl;
2426            // Float textures only where the display can render into them;
2427            // otherwise the float PBO step below applies. The same question
2428            // that step asks, asked through the same helper so the two
2429            // cannot answer differently.
2430            let support = self.supported_render_dtypes();
2431            let dtype_ok =
2432                !matches!(dtype, DType::F16 | DType::F32) || float_pbo_eligible(dtype, support);
2433            if dtype_ok {
2434                match TensorDyn::image(
2435                    width,
2436                    height,
2437                    format,
2438                    dtype,
2439                    Some(edgefirst_tensor::TensorMemory::DmaBuf),
2440                    access,
2441                ) {
2442                    Ok(img) => return Ok(img),
2443                    Err(e) => {
2444                        // Falling back to a non-zero-copy destination is a real
2445                        // perf cliff — never do it silently (on-device triage
2446                        // starts from this line).
2447                        log::debug!(
2448                            "create_image: zero-copy Dma allocation declined \
2449                             ({format:?}/{dtype:?} {width}x{height}): {e:?}; using fallback storage"
2450                        );
2451                    }
2452                }
2453            }
2454        }
2455
2456        // Try DMA first on Linux — skip only when GL has explicitly selected PBO
2457        // as the preferred transfer path (PBO is better than DMA in that case).
2458        #[cfg(target_os = "linux")]
2459        {
2460            #[cfg(feature = "opengl")]
2461            let gl_uses_pbo = self
2462                .opengl
2463                .as_ref()
2464                .is_some_and(|gl| gl.transfer_backend() == opengl_headless::TransferBackend::Pbo);
2465            #[cfg(not(feature = "opengl"))]
2466            let gl_uses_pbo = false;
2467
2468            if !gl_uses_pbo {
2469                if let Ok(img) = try_dma() {
2470                    return Ok(img);
2471                }
2472            }
2473        }
2474
2475        // Try PBO (if GL available).
2476        // PBO buffers are u8-sized; the int8 shader emulates i8 output via
2477        // XOR 0x80 on the same underlying buffer, so both U8 and I8 work.
2478        #[cfg(any(target_os = "linux", target_os = "windows"))]
2479        #[cfg(feature = "opengl")]
2480        if dtype.size() == 1 {
2481            if let Some(gl) = &self.opengl {
2482                match gl.create_pbo_image(width, height, format) {
2483                    Ok(t) => {
2484                        if dtype == DType::I8 {
2485                            // SAFETY: Tensor<u8> and Tensor<i8> are layout-
2486                            // identical (same element size, no T-dependent
2487                            // drop glue). The int8 shader applies XOR 0x80
2488                            // on the same PBO buffer. Same rationale as
2489                            // gl::processor::tensor_i8_as_u8_mut.
2490                            // Invariant: PBO tensors never have chroma
2491                            // (create_pbo_image → Tensor::wrap sets it None).
2492                            debug_assert!(
2493                                t.chroma().is_none(),
2494                                "PBO i8 transmute requires chroma == None"
2495                            );
2496                            let t_i8: Tensor<i8> = unsafe { std::mem::transmute(t) };
2497                            return Ok(TensorDyn::from(t_i8));
2498                        }
2499                        return Ok(TensorDyn::from(t));
2500                    }
2501                    Err(e) => log::debug!("PBO image creation failed, falling back to Mem: {e:?}"),
2502                }
2503            }
2504        }
2505
2506        // Try float PBO when the GPU backend reports support for this dtype.
2507        // Falls through to Mem on error (same policy as u8 PBO above).
2508        #[cfg(any(target_os = "linux", target_os = "windows"))]
2509        #[cfg(feature = "opengl")]
2510        if float_pbo_eligible(dtype, self.supported_render_dtypes()) {
2511            if let Some(gl) = &self.opengl {
2512                match gl.create_pbo_image_dtype(width, height, format, dtype) {
2513                    Ok(t) => return Ok(t),
2514                    Err(e) => {
2515                        log::debug!(
2516                            "Float PBO image creation failed for {dtype:?}, \
2517                             falling back to Mem: {e:?}"
2518                        );
2519                    }
2520                }
2521            }
2522        }
2523
2524        // Fallback to Mem
2525        Ok(TensorDyn::image(
2526            width,
2527            height,
2528            format,
2529            dtype,
2530            Some(edgefirst_tensor::TensorMemory::Mem),
2531            access,
2532        )?)
2533    }
2534
2535    /// Import an external DMA-BUF image.
2536    ///
2537    /// Each [`PlaneDescriptor`] owns an already-duped fd; this method
2538    /// consumes the descriptors and takes ownership of those fds (whether
2539    /// the call succeeds or fails).
2540    ///
2541    /// The caller must ensure the DMA-BUF allocation is large enough for the
2542    /// specified width, height, format, and any stride/offset on the plane
2543    /// descriptors. No buffer-size validation is performed; an undersized
2544    /// buffer may cause GPU faults or EGL import failure.
2545    ///
2546    /// # Arguments
2547    ///
2548    /// * `image` - Plane descriptor for the primary (or only) plane
2549    /// * `chroma` - Optional plane descriptor for the UV chroma plane
2550    ///   (required for multiplane NV12)
2551    /// * `width` - Image width in pixels
2552    /// * `height` - Image height in pixels
2553    /// * `format` - Pixel format of the buffer
2554    /// * `dtype` - Element data type (e.g. `DType::U8`)
2555    ///
2556    /// # Returns
2557    ///
2558    /// A `TensorDyn` configured as an image.
2559    ///
2560    /// # Errors
2561    ///
2562    /// * [`Error::NotSupported`] if `chroma` is `Some` for a non-semi-planar
2563    ///   format, or multiplane NV16 (not yet supported), or the fd is not
2564    ///   DMA-backed
2565    /// * [`Error::InvalidShape`] if NV12 height is odd
2566    ///
2567    /// # Platform
2568    ///
2569    /// Linux only.
2570    ///
2571    /// # Examples
2572    ///
2573    /// ```rust,ignore
2574    /// use edgefirst_tensor::PlaneDescriptor;
2575    ///
2576    /// // Single-plane RGBA
2577    /// let pd = PlaneDescriptor::new(fd.as_fd())?;
2578    /// let src = proc.import_image(pd, None, 1920, 1080, PixelFormat::Rgba, DType::U8, None)?;
2579    ///
2580    /// // Multi-plane NV12 with stride
2581    /// let y_pd = PlaneDescriptor::new(y_fd.as_fd())?.with_stride(2048);
2582    /// let uv_pd = PlaneDescriptor::new(uv_fd.as_fd())?.with_stride(2048);
2583    /// let src = proc.import_image(y_pd, Some(uv_pd), 1920, 1080,
2584    ///                             PixelFormat::Nv12, DType::U8, None)?;
2585    /// ```
2586    // Import inherently needs plane(s) + geometry + format + dtype + colorimetry;
2587    // a params struct would obscure more than it clarifies here.
2588    #[allow(clippy::too_many_arguments)]
2589    #[cfg(target_os = "linux")]
2590    pub fn import_image(
2591        &self,
2592        image: edgefirst_tensor::PlaneDescriptor,
2593        chroma: Option<edgefirst_tensor::PlaneDescriptor>,
2594        width: usize,
2595        height: usize,
2596        format: PixelFormat,
2597        dtype: DType,
2598        colorimetry: Option<edgefirst_tensor::Colorimetry>,
2599    ) -> Result<TensorDyn> {
2600        use edgefirst_tensor::{Tensor, TensorMemory};
2601
2602        // Capture stride/offset from descriptors before consuming them
2603        let image_stride = image.stride();
2604        let image_offset = image.offset();
2605        let chroma_stride = chroma.as_ref().and_then(|c| c.stride());
2606        let chroma_offset = chroma.as_ref().and_then(|c| c.offset());
2607
2608        if let Some(chroma_pd) = chroma {
2609            // ── Multiplane path ──────────────────────────────────────
2610            // Multiplane tensors are backed by Tensor<u8> (or transmuted to
2611            // Tensor<i8>). Reject other dtypes to avoid silently returning a
2612            // tensor with the wrong element type.
2613            if dtype != DType::U8 && dtype != DType::I8 {
2614                return Err(Error::NotSupported(format!(
2615                    "multiplane import only supports U8/I8, got {dtype:?}"
2616                )));
2617            }
2618            if format.layout() != PixelLayout::SemiPlanar {
2619                return Err(Error::NotSupported(format!(
2620                    "import_image with chroma requires a semi-planar format, got {format:?}"
2621                )));
2622            }
2623
2624            let chroma_h = match format {
2625                PixelFormat::Nv12 => {
2626                    // NV12 (4:2:0): ceil(H/2) chroma rows — odd heights are valid.
2627                    height.div_ceil(2)
2628                }
2629                // NV16 multiplane will be supported in a future release;
2630                // the GL backend currently only handles NV12 plane1 attributes.
2631                PixelFormat::Nv16 => {
2632                    return Err(Error::NotSupported(
2633                        "multiplane NV16 is not yet supported; use contiguous NV16 instead".into(),
2634                    ))
2635                }
2636                _ => {
2637                    return Err(Error::NotSupported(format!(
2638                        "unsupported semi-planar format: {format:?}"
2639                    )))
2640                }
2641            };
2642
2643            let luma = Tensor::<u8>::from_fd(image.into_fd(), &[height, width], Some("luma"))?;
2644            if luma.memory() != TensorMemory::DmaBuf {
2645                return Err(Error::NotSupported(format!(
2646                    "luma fd must be DMA-backed, got {:?}",
2647                    luma.memory()
2648                )));
2649            }
2650
2651            let mut chroma_tensor =
2652                Tensor::<u8>::from_fd(chroma_pd.into_fd(), &[chroma_h, width], Some("chroma"))?;
2653            if chroma_tensor.memory() != TensorMemory::DmaBuf {
2654                return Err(Error::NotSupported(format!(
2655                    "chroma fd must be DMA-backed, got {:?}",
2656                    chroma_tensor.memory()
2657                )));
2658            }
2659
2660            // Geometry on the chroma plane must be set before from_planes
2661            // consumes it: the combined handle answers chroma() from the
2662            // library, and there is no chroma_mut() on the dynamic lens.
2663            if let Some(s) = chroma_stride {
2664                if s < width {
2665                    return Err(Error::InvalidShape(format!(
2666                        "chroma stride {s} < minimum {width} for {format:?}"
2667                    )));
2668                }
2669                chroma_tensor.set_row_stride_unchecked(s);
2670            }
2671            if let Some(o) = chroma_offset {
2672                chroma_tensor.set_plane_offset(o);
2673            }
2674
2675            // from_planes creates the combined tensor with format set,
2676            // preserving luma's row_stride (currently None since luma was raw).
2677            let mut tensor = Tensor::<u8>::from_planes(luma, chroma_tensor, format)?;
2678
2679            // Apply stride/offset to the combined tensor (luma plane)
2680            if let Some(s) = image_stride {
2681                tensor.set_row_stride(s)?;
2682            }
2683            if let Some(o) = image_offset {
2684                tensor.set_plane_offset(o);
2685            }
2686
2687            if dtype == DType::I8 {
2688                // SAFETY: Tensor<u8> and Tensor<i8> have identical layout because
2689                // the struct contains only type-erased storage (OwnedFd, shape, name),
2690                // no inline T values. This assertion catches layout drift at compile time.
2691                const {
2692                    assert!(std::mem::size_of::<Tensor<u8>>() == std::mem::size_of::<Tensor<i8>>());
2693                    assert!(
2694                        std::mem::align_of::<Tensor<u8>>() == std::mem::align_of::<Tensor<i8>>()
2695                    );
2696                }
2697                let tensor_i8: Tensor<i8> = unsafe { std::mem::transmute(tensor) };
2698                let mut dyn_tensor = TensorDyn::from(tensor_i8);
2699                dyn_tensor.set_colorimetry(colorimetry);
2700                return Ok(dyn_tensor);
2701            }
2702            let mut dyn_tensor = TensorDyn::from(tensor);
2703            dyn_tensor.set_colorimetry(colorimetry);
2704            Ok(dyn_tensor)
2705        } else {
2706            // ── Single-plane path ────────────────────────────────────
2707            // Canonical shape (Packed [H,W,C] / Planar [C,H,W] / SemiPlanar
2708            // [total_h, W]); `allocation_shape` supports NV12/NV16/NV24 (the old
2709            // hand-rolled match erroneously rejected NV24).
2710            let shape = format.allocation_shape(width, height).ok_or_else(|| {
2711                Error::NotSupported(format!(
2712                    "unsupported pixel format for import_image: {format:?}"
2713                ))
2714            })?;
2715            let tensor = TensorDyn::from_fd(image.into_fd(), &shape, dtype, None)?;
2716            if tensor.memory() != TensorMemory::DmaBuf {
2717                return Err(Error::NotSupported(format!(
2718                    "import_image requires DMA-backed fd, got {:?}",
2719                    tensor.memory()
2720                )));
2721            }
2722            let mut tensor = tensor.with_format(format)?;
2723            if let Some(s) = image_stride {
2724                tensor.set_row_stride(s)?;
2725            }
2726            if let Some(o) = image_offset {
2727                tensor.set_plane_offset(o);
2728            }
2729            tensor.set_colorimetry(colorimetry);
2730            Ok(tensor)
2731        }
2732    }
2733
2734    /// Decode model outputs and draw segmentation masks onto `dst`.
2735    ///
2736    /// The processor decodes via the provided
2737    /// [`Decoder`](edgefirst_decoder::Decoder), selects the optimal rendering
2738    /// path (hybrid CPU+GL or fused GPU), and composites masks onto `dst`.
2739    ///
2740    /// Returns the detected bounding boxes.
2741    ///
2742    /// **Requires the `decode` feature**, which is off by default because it
2743    /// is the only thing in this crate that needs `edgefirst-decoder`. This
2744    /// method sequences a decode and a draw; both halves are available
2745    /// unconditionally and are equally fused, so without the feature write:
2746    ///
2747    /// ```ignore
2748    /// let mut boxes = Vec::new();
2749    /// match decoder.decode_proto(outputs, &mut boxes)? {
2750    ///     Some(protos) => proc.draw_proto_masks(dst, &boxes, &protos, overlay)?,
2751    ///     None => {
2752    ///         let mut masks = Vec::new();
2753    ///         decoder.decode(outputs, &mut boxes, &mut masks)?;
2754    ///         proc.draw_decoded_masks(dst, &boxes, &masks, overlay)?;
2755    ///     }
2756    /// }
2757    /// ```
2758    #[cfg(feature = "decode")]
2759    pub fn draw_masks(
2760        &mut self,
2761        decoder: &edgefirst_decoder::Decoder,
2762        outputs: &[&TensorDyn],
2763        dst: &mut TensorDyn,
2764        overlay: MaskOverlay<'_>,
2765    ) -> Result<Vec<DetectBox>> {
2766        let mut output_boxes = Vec::with_capacity(100);
2767
2768        // Try proto path first (fused rendering without materializing masks)
2769        let proto_result = decoder
2770            .decode_proto(outputs, &mut output_boxes)
2771            .map_err(|e| Error::Internal(format!("decode_proto: {e:#?}")))?;
2772
2773        if let Some(proto_data) = proto_result {
2774            self.draw_proto_masks(dst, &output_boxes, &proto_data, overlay)?;
2775        } else {
2776            // Detection-only or unsupported model: full decode + render
2777            let mut output_masks = Vec::with_capacity(100);
2778            decoder
2779                .decode(outputs, &mut output_boxes, &mut output_masks)
2780                .map_err(|e| Error::Internal(format!("decode: {e:#?}")))?;
2781            self.draw_decoded_masks(dst, &output_boxes, &output_masks, overlay)?;
2782        }
2783        Ok(output_boxes)
2784    }
2785
2786    /// Decode tracked model outputs and draw segmentation masks onto `dst`.
2787    ///
2788    /// Like [`draw_masks`](Self::draw_masks) but integrates a tracker for
2789    /// maintaining object identities across frames. The tracker runs after
2790    /// NMS but before mask extraction.
2791    ///
2792    /// Returns detected boxes and track info.
2793    #[cfg(feature = "tracker")]
2794    pub fn draw_masks_tracked<TR: edgefirst_tracker::Tracker<DetectBox>>(
2795        &mut self,
2796        decoder: &edgefirst_decoder::Decoder,
2797        tracker: &mut TR,
2798        timestamp: u64,
2799        outputs: &[&TensorDyn],
2800        dst: &mut TensorDyn,
2801        overlay: MaskOverlay<'_>,
2802    ) -> Result<(Vec<DetectBox>, Vec<edgefirst_tracker::TrackInfo>)> {
2803        let mut output_boxes = Vec::with_capacity(100);
2804        let mut output_tracks = Vec::new();
2805
2806        let proto_result = decoder
2807            .decode_proto_tracked(
2808                tracker,
2809                timestamp,
2810                outputs,
2811                &mut output_boxes,
2812                &mut output_tracks,
2813            )
2814            .map_err(|e| Error::Internal(format!("decode_proto_tracked: {e:#?}")))?;
2815
2816        if let Some(proto_data) = proto_result {
2817            self.draw_proto_masks(dst, &output_boxes, &proto_data, overlay)?;
2818        } else {
2819            // Note: decode_proto_tracked returns None for detection-only/ModelPack
2820            // models WITHOUT calling the tracker. The else branch below is the
2821            // first (and only) tracker call for those model types.
2822            let mut output_masks = Vec::with_capacity(100);
2823            decoder
2824                .decode_tracked(
2825                    tracker,
2826                    timestamp,
2827                    outputs,
2828                    &mut output_boxes,
2829                    &mut output_masks,
2830                    &mut output_tracks,
2831                )
2832                .map_err(|e| Error::Internal(format!("decode_tracked: {e:#?}")))?;
2833            self.draw_decoded_masks(dst, &output_boxes, &output_masks, overlay)?;
2834        }
2835        Ok((output_boxes, output_tracks))
2836    }
2837
2838    /// Materialize per-instance segmentation masks from raw prototype data.
2839    ///
2840    /// Computes `mask_coeff @ protos` with sigmoid activation for each detection,
2841    /// producing compact masks at prototype resolution (e.g., 160×160 crops).
2842    /// Mask values are continuous sigmoid confidence outputs quantized to u8
2843    /// (0 = background, 255 = full confidence), NOT binary thresholded.
2844    ///
2845    /// The returned [`Vec<Segmentation>`] can be:
2846    /// - Inspected or exported for analytics, IoU computation, etc.
2847    /// - Passed directly to [`ImageProcessorTrait::draw_decoded_masks`] for
2848    ///   GPU-interpolated rendering.
2849    ///
2850    /// # Performance Note
2851    ///
2852    /// Calling `materialize_masks` + `draw_decoded_masks` separately prevents
2853    /// the HAL from using its internal fused optimization path. For render-only
2854    /// use cases, prefer [`ImageProcessorTrait::draw_proto_masks`] which selects
2855    /// the fastest path automatically (currently 1.6×–27× faster on tested
2856    /// platforms). Use this method when you need access to the intermediate masks.
2857    ///
2858    /// # Errors
2859    ///
2860    /// Returns [`Error::NoConverter`] if the CPU backend is not available.
2861    pub fn materialize_masks(
2862        &mut self,
2863        detect: &[DetectBox],
2864        proto_data: &ProtoData,
2865        letterbox: Option<[f32; 4]>,
2866        resolution: MaskResolution,
2867    ) -> Result<Vec<Segmentation>> {
2868        let cpu = self.cpu.as_mut().ok_or(Error::NoConverter)?;
2869        match resolution {
2870            MaskResolution::Proto => cpu.materialize_segmentations(detect, proto_data, letterbox),
2871            MaskResolution::Scaled { width, height } => {
2872                cpu.materialize_scaled_segmentations(detect, proto_data, letterbox, width, height)
2873            }
2874        }
2875    }
2876}
2877
2878impl ImageProcessorTrait for ImageProcessor {
2879    /// Converts the source image to the destination image format and size. The
2880    /// image is cropped first, then flipped, then rotated
2881    ///
2882    /// Prefer hardware accelerators when available, falling back to CPU if
2883    /// necessary.
2884    fn convert(
2885        &mut self,
2886        src: &TensorDyn,
2887        dst: &mut TensorDyn,
2888        rotation: Rotation,
2889        flip: Flip,
2890        crop: Crop,
2891    ) -> Result<()> {
2892        let start = Instant::now();
2893        let src_fmt = src.format();
2894        let dst_fmt = dst.format();
2895        let _span = tracing::trace_span!(
2896            "image.convert",
2897            ?src_fmt,
2898            ?dst_fmt,
2899            src_memory = ?src.memory(),
2900            dst_memory = ?dst.memory(),
2901            ?rotation,
2902            ?flip,
2903        )
2904        .entered();
2905        log::trace!(
2906            "convert: {src_fmt:?}({:?}/{:?}) → {dst_fmt:?}({:?}/{:?}), \
2907             rotation={rotation:?}, flip={flip:?}, backend={:?}",
2908            src.dtype(),
2909            src.memory(),
2910            dst.dtype(),
2911            dst.memory(),
2912            self.forced_backend,
2913        );
2914
2915        // ── Forced backend: no fallback chain ────────────────────────
2916        if let Some(forced) = self.forced_backend {
2917            return match forced {
2918                ForcedBackend::Cpu => {
2919                    if let Some(cpu) = self.cpu.as_mut() {
2920                        let r = cpu.convert(src, dst, rotation, flip, crop);
2921                        log::trace!(
2922                            "convert: forced=cpu result={} ({:?})",
2923                            if r.is_ok() { "ok" } else { "err" },
2924                            start.elapsed()
2925                        );
2926                        return r;
2927                    }
2928                    Err(Error::ForcedBackendUnavailable("cpu".into()))
2929                }
2930                ForcedBackend::G2d => {
2931                    #[cfg(target_os = "linux")]
2932                    if let Some(g2d) = self.g2d.as_mut() {
2933                        let r = g2d.convert(src, dst, rotation, flip, crop);
2934                        log::trace!(
2935                            "convert: forced=g2d result={} ({:?})",
2936                            if r.is_ok() { "ok" } else { "err" },
2937                            start.elapsed()
2938                        );
2939                        return r;
2940                    }
2941                    Err(Error::ForcedBackendUnavailable("g2d".into()))
2942                }
2943                ForcedBackend::OpenGl => {
2944                    #[cfg(any(
2945                        target_os = "linux",
2946                        target_os = "macos",
2947                        target_os = "ios",
2948                        target_os = "android",
2949                        target_os = "windows"
2950                    ))]
2951                    #[cfg(feature = "opengl")]
2952                    if let Some(opengl) = self.opengl.as_mut() {
2953                        let r = opengl.convert(src, dst, rotation, flip, crop);
2954                        log::trace!(
2955                            "convert: forced=opengl result={} ({:?})",
2956                            if r.is_ok() { "ok" } else { "err" },
2957                            start.elapsed()
2958                        );
2959                        return r;
2960                    }
2961                    Err(Error::ForcedBackendUnavailable("opengl".into()))
2962                }
2963            };
2964        }
2965
2966        // ── Auto fallback chain: OpenGL → G2D → CPU ──────────────────
2967        #[cfg(any(
2968            target_os = "linux",
2969            target_os = "macos",
2970            target_os = "ios",
2971            target_os = "android",
2972            target_os = "windows"
2973        ))]
2974        #[cfg(feature = "opengl")]
2975        if let Some(opengl) = self.opengl.as_mut() {
2976            match opengl.convert(src, dst, rotation, flip, crop) {
2977                Ok(_) => {
2978                    log::trace!(
2979                        "convert: auto selected=opengl for {src_fmt:?}→{dst_fmt:?} ({:?})",
2980                        start.elapsed()
2981                    );
2982                    return Ok(());
2983                }
2984                Err(e) => {
2985                    self.convert_fallbacks
2986                        .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2987                    log::debug!(
2988                        "convert: auto opengl declined {src_fmt:?}@{:?}→{dst_fmt:?}@{:?}, \
2989                         falling back toward G2D/CPU: {e}",
2990                        src.memory(),
2991                        dst.memory(),
2992                    );
2993                }
2994            }
2995        }
2996
2997        #[cfg(target_os = "linux")]
2998        if let Some(g2d) = self.g2d.as_mut() {
2999            // G2D is matrix-only (no range control, no BT.2020). For any
3000            // conversion with a YUV side, resolve that side's colorimetry and
3001            // skip G2D entirely when it cannot be expressed (full-range YUV or
3002            // BT.2020), letting the chain fall through to GL/CPU which honour
3003            // range and BT.2020. YUV→RGB uses the source colorimetry; RGB→YUV
3004            // uses the destination. RGB→RGB has no YUV side and is unaffected.
3005            let src_is_yuv = src.format().is_some_and(|f| f.is_yuv());
3006            let dst_is_yuv = dst.format().is_some_and(|f| f.is_yuv());
3007            let g2d_eligible = if src_is_yuv || dst_is_yuv {
3008                let cm = if src_is_yuv {
3009                    crate::colorimetry::effective_colorimetry(src)
3010                } else {
3011                    crate::colorimetry::effective_colorimetry(dst)
3012                };
3013                crate::g2d::g2d_can_handle(&cm, true)
3014            } else {
3015                true
3016            };
3017            if !g2d_eligible {
3018                log::trace!(
3019                    "convert: auto g2d skipped {src_fmt:?}→{dst_fmt:?} \
3020                     (colorimetry not expressible: full-range/BT.2020)"
3021                );
3022            } else {
3023                match g2d.convert(src, dst, rotation, flip, crop) {
3024                    Ok(_) => {
3025                        log::trace!(
3026                            "convert: auto selected=g2d for {src_fmt:?}→{dst_fmt:?} ({:?})",
3027                            start.elapsed()
3028                        );
3029                        return Ok(());
3030                    }
3031                    Err(e) => {
3032                        log::trace!("convert: auto g2d declined {src_fmt:?}→{dst_fmt:?}: {e}");
3033                    }
3034                }
3035            }
3036        }
3037
3038        if let Some(cpu) = self.cpu.as_mut() {
3039            match cpu.convert(src, dst, rotation, flip, crop) {
3040                Ok(_) => {
3041                    log::trace!(
3042                        "convert: auto selected=cpu for {src_fmt:?}→{dst_fmt:?} ({:?})",
3043                        start.elapsed()
3044                    );
3045                    return Ok(());
3046                }
3047                Err(e) => {
3048                    log::trace!("convert: auto cpu failed {src_fmt:?}→{dst_fmt:?}: {e}");
3049                    return Err(e);
3050                }
3051            }
3052        }
3053        Err(Error::NoConverter)
3054    }
3055
3056    fn convert_deferred(
3057        &mut self,
3058        src: &TensorDyn,
3059        dst: &mut TensorDyn,
3060        rotation: Rotation,
3061        flip: Flip,
3062        crop: Crop,
3063    ) -> Result<()> {
3064        // Deferred batching is an OpenGL optimization (shared parent EGLImage +
3065        // no per-tile glFinish). Route to the GL backend's deferred path when GL
3066        // is forced or auto-selectable; on a GL decline fall back to an eager
3067        // convert (the auto chain), which is correct everywhere — it completes
3068        // synchronously and `flush` stays a no-op for non-GL backends.
3069        #[cfg(any(
3070            target_os = "linux",
3071            target_os = "macos",
3072            target_os = "ios",
3073            target_os = "android",
3074            target_os = "windows"
3075        ))]
3076        #[cfg(feature = "opengl")]
3077        {
3078            let gl_forced = matches!(self.forced_backend, Some(ForcedBackend::OpenGl));
3079            if gl_forced || self.forced_backend.is_none() {
3080                if let Some(opengl) = self.opengl.as_mut() {
3081                    match opengl.convert_deferred(src, dst, rotation, flip, crop) {
3082                        Ok(()) => return Ok(()),
3083                        Err(e) => {
3084                            log::trace!("convert_deferred: gl declined: {e}; eager fallback");
3085                            // A forced-GL caller gets the GL error, matching
3086                            // `convert`'s no-fallback forced-backend contract.
3087                            if gl_forced {
3088                                return Err(e);
3089                            }
3090                        }
3091                    }
3092                }
3093            }
3094        }
3095        self.convert(src, dst, rotation, flip, crop)
3096    }
3097
3098    fn flush(&mut self) -> Result<()> {
3099        let _span = tracing::trace_span!("image.flush").entered();
3100        // Only the OpenGL backend defers; flushing it issues the single GPU
3101        // sync. CPU/G2D converts already completed, so there is nothing to flush.
3102        #[cfg(any(
3103            target_os = "linux",
3104            target_os = "macos",
3105            target_os = "ios",
3106            target_os = "android",
3107            target_os = "windows"
3108        ))]
3109        #[cfg(feature = "opengl")]
3110        if let Some(opengl) = self.opengl.as_mut() {
3111            return opengl.flush();
3112        }
3113        Ok(())
3114    }
3115
3116    fn draw_decoded_masks(
3117        &mut self,
3118        dst: &mut TensorDyn,
3119        detect: &[DetectBox],
3120        segmentation: &[Segmentation],
3121        overlay: MaskOverlay<'_>,
3122    ) -> Result<()> {
3123        let _span = tracing::trace_span!(
3124            "image.draw_decoded_masks",
3125            n_detections = detect.len(),
3126            n_segmentations = segmentation.len(),
3127        )
3128        .entered();
3129        let start = Instant::now();
3130
3131        if let Some(bg) = overlay.background {
3132            if bg.aliases(dst) {
3133                return Err(Error::AliasedBuffers(
3134                    "background must not reference the same buffer as dst".to_string(),
3135                ));
3136            }
3137        }
3138
3139        // Un-letterbox detect boxes and segmentation bboxes for rendering when
3140        // a letterbox was applied to prepare the model input.
3141        let lb_boxes: Vec<DetectBox>;
3142        let lb_segs: Vec<Segmentation>;
3143        let (detect, segmentation) = if let Some(lb) = overlay.letterbox {
3144            lb_boxes = detect.iter().map(|&d| unletter_bbox(d, lb)).collect();
3145            // Keep segmentation bboxes in sync with the transformed detect boxes
3146            // when we have a 1:1 correspondence (instance segmentation).
3147            if segmentation.len() == lb_boxes.len() {
3148                // Only the four bounds change; the mask bytes are copied
3149                // wholesale because `TensorDyn` is not `Clone`. The memcpy is
3150                // what `Array3<u8>::clone()` already paid; `copy_segmentation`
3151                // pins the allocation to Mem so the ALLOCATOR cost matches too
3152                // (see there). Rendering from borrowed masks plus a separate
3153                // bounds list would avoid the copy entirely.
3154                lb_segs = segmentation
3155                    .iter()
3156                    .zip(lb_boxes.iter())
3157                    .map(|(s, d)| copy_segmentation(s, d.bbox.into()))
3158                    .collect::<Result<Vec<_>>>()?;
3159                (lb_boxes.as_slice(), lb_segs.as_slice())
3160            } else {
3161                // No 1:1 correspondence, so the segmentation bounds are left
3162                // as they are -- borrow rather than the copy `to_vec()` used
3163                // to make.
3164                (lb_boxes.as_slice(), segmentation)
3165            }
3166        } else {
3167            (detect, segmentation)
3168        };
3169        #[cfg(target_os = "linux")]
3170        let is_empty_frame = detect.is_empty() && segmentation.is_empty();
3171
3172        // ── Forced backend: no fallback chain ────────────────────────
3173        if let Some(forced) = self.forced_backend {
3174            return match forced {
3175                ForcedBackend::Cpu => {
3176                    if let Some(cpu) = self.cpu.as_mut() {
3177                        return cpu.draw_decoded_masks(dst, detect, segmentation, overlay);
3178                    }
3179                    Err(Error::ForcedBackendUnavailable("cpu".into()))
3180                }
3181                ForcedBackend::G2d => {
3182                    // G2D can only produce empty frames (clear / bg blit).
3183                    // For populated frames it has no rasterizer — fail loudly.
3184                    #[cfg(target_os = "linux")]
3185                    if let Some(g2d) = self.g2d.as_mut() {
3186                        return g2d.draw_decoded_masks(dst, detect, segmentation, overlay);
3187                    }
3188                    Err(Error::ForcedBackendUnavailable("g2d".into()))
3189                }
3190                ForcedBackend::OpenGl => {
3191                    // GL handles background natively via GPU blit, and now
3192                    // actively clears when there is no background.
3193                    #[cfg(any(
3194                        target_os = "linux",
3195                        target_os = "macos",
3196                        target_os = "ios",
3197                        target_os = "android",
3198                        target_os = "windows"
3199                    ))]
3200                    #[cfg(feature = "opengl")]
3201                    if let Some(opengl) = self.opengl.as_mut() {
3202                        return opengl.draw_decoded_masks(dst, detect, segmentation, overlay);
3203                    }
3204                    Err(Error::ForcedBackendUnavailable("opengl".into()))
3205                }
3206            };
3207        }
3208
3209        // ── Auto dispatch ──────────────────────────────────────────
3210        // Empty frames prefer G2D when available — a single g2d_clear or
3211        // g2d_blit is the cheapest HW path to produce the correct output
3212        // and avoids spinning up the GL pipeline every zero-detection
3213        // frame in a triple-buffered display loop.
3214        #[cfg(target_os = "linux")]
3215        if is_empty_frame {
3216            if let Some(g2d) = self.g2d.as_mut() {
3217                match g2d.draw_decoded_masks(dst, detect, segmentation, overlay) {
3218                    Ok(_) => {
3219                        log::trace!(
3220                            "draw_decoded_masks empty frame via g2d in {:?}",
3221                            start.elapsed()
3222                        );
3223                        return Ok(());
3224                    }
3225                    Err(e) => log::trace!("g2d empty-frame path unavailable: {e:?}"),
3226                }
3227            }
3228        }
3229
3230        // Populated frames (or G2D unavailable): GL first, CPU fallback.
3231        // Both backends now own their own base-layer handling (bg blit
3232        // or clear), so we hand the overlay through untouched.
3233        #[cfg(any(
3234            target_os = "linux",
3235            target_os = "macos",
3236            target_os = "ios",
3237            target_os = "android",
3238            target_os = "windows"
3239        ))]
3240        #[cfg(feature = "opengl")]
3241        if let Some(opengl) = self.opengl.as_mut() {
3242            log::trace!(
3243                "draw_decoded_masks started with opengl in {:?}",
3244                start.elapsed()
3245            );
3246            match opengl.draw_decoded_masks(dst, detect, segmentation, overlay) {
3247                Ok(_) => {
3248                    log::trace!("draw_decoded_masks with opengl in {:?}", start.elapsed());
3249                    return Ok(());
3250                }
3251                Err(e) => {
3252                    log::trace!("draw_decoded_masks didn't work with opengl: {e:?}")
3253                }
3254            }
3255        }
3256
3257        log::trace!(
3258            "draw_decoded_masks started with cpu in {:?}",
3259            start.elapsed()
3260        );
3261        if let Some(cpu) = self.cpu.as_mut() {
3262            match cpu.draw_decoded_masks(dst, detect, segmentation, overlay) {
3263                Ok(_) => {
3264                    log::trace!("draw_decoded_masks with cpu in {:?}", start.elapsed());
3265                    return Ok(());
3266                }
3267                Err(e) => {
3268                    log::trace!("draw_decoded_masks didn't work with cpu: {e:?}");
3269                    return Err(e);
3270                }
3271            }
3272        }
3273        Err(Error::NoConverter)
3274    }
3275
3276    fn draw_proto_masks(
3277        &mut self,
3278        dst: &mut TensorDyn,
3279        detect: &[DetectBox],
3280        proto_data: &ProtoData,
3281        overlay: MaskOverlay<'_>,
3282    ) -> Result<()> {
3283        let start = Instant::now();
3284
3285        if let Some(bg) = overlay.background {
3286            if bg.aliases(dst) {
3287                return Err(Error::AliasedBuffers(
3288                    "background must not reference the same buffer as dst".to_string(),
3289                ));
3290            }
3291        }
3292
3293        // Un-letterbox detect boxes for rendering when a letterbox was applied
3294        // to prepare the model input.  The original `detect` coords are still
3295        // passed to `materialize_segmentations` (which needs model-space coords
3296        // to correctly crop the proto tensor) alongside `overlay.letterbox` so
3297        // it can emit `Segmentation` structs in output-image space.
3298        let lb_boxes: Vec<DetectBox>;
3299        let render_detect = if let Some(lb) = overlay.letterbox {
3300            lb_boxes = detect.iter().map(|&d| unletter_bbox(d, lb)).collect();
3301            lb_boxes.as_slice()
3302        } else {
3303            detect
3304        };
3305        #[cfg(target_os = "linux")]
3306        let is_empty_frame = detect.is_empty();
3307
3308        // ── Forced backend: no fallback chain ────────────────────────
3309        if let Some(forced) = self.forced_backend {
3310            return match forced {
3311                ForcedBackend::Cpu => {
3312                    if let Some(cpu) = self.cpu.as_mut() {
3313                        return cpu.draw_proto_masks(dst, render_detect, proto_data, overlay);
3314                    }
3315                    Err(Error::ForcedBackendUnavailable("cpu".into()))
3316                }
3317                ForcedBackend::G2d => {
3318                    #[cfg(target_os = "linux")]
3319                    if let Some(g2d) = self.g2d.as_mut() {
3320                        return g2d.draw_proto_masks(dst, render_detect, proto_data, overlay);
3321                    }
3322                    Err(Error::ForcedBackendUnavailable("g2d".into()))
3323                }
3324                ForcedBackend::OpenGl => {
3325                    #[cfg(any(
3326                        target_os = "linux",
3327                        target_os = "macos",
3328                        target_os = "ios",
3329                        target_os = "android",
3330                        target_os = "windows"
3331                    ))]
3332                    #[cfg(feature = "opengl")]
3333                    if let Some(opengl) = self.opengl.as_mut() {
3334                        return opengl.draw_proto_masks(dst, render_detect, proto_data, overlay);
3335                    }
3336                    Err(Error::ForcedBackendUnavailable("opengl".into()))
3337                }
3338            };
3339        }
3340
3341        // ── Auto dispatch ──────────────────────────────────────────
3342        // Empty frames: prefer G2D — cheapest HW path (clear or bg blit).
3343        #[cfg(target_os = "linux")]
3344        if is_empty_frame {
3345            if let Some(g2d) = self.g2d.as_mut() {
3346                match g2d.draw_proto_masks(dst, render_detect, proto_data, overlay) {
3347                    Ok(_) => {
3348                        log::trace!(
3349                            "draw_proto_masks empty frame via g2d in {:?}",
3350                            start.elapsed()
3351                        );
3352                        return Ok(());
3353                    }
3354                    Err(e) => log::trace!("g2d empty-frame path unavailable: {e:?}"),
3355                }
3356            }
3357        }
3358
3359        // Hybrid path: CPU materialize + GL overlay (benchmarked faster than
3360        // full-GPU draw_proto_masks on all tested platforms: 27× on imx8mp,
3361        // 4× on imx95, 2.5× on rpi5, 1.6× on x86).
3362        // GL owns its own bg-blit / glClear — we pass the overlay through.
3363        //
3364        // CPU materialize needs `&mut` for its MaskScratch buffers; GL also
3365        // needs `&mut`. The CPU borrow is scoped to its block so the
3366        // subsequent GL borrow is free to take over `self`.
3367        #[cfg(any(
3368            target_os = "linux",
3369            target_os = "macos",
3370            target_os = "ios",
3371            target_os = "android",
3372            target_os = "windows"
3373        ))]
3374        #[cfg(feature = "opengl")]
3375        if let (Some(_), Some(_)) = (self.cpu.as_ref(), self.opengl.as_ref()) {
3376            let segmentation = match self.cpu.as_mut() {
3377                Some(cpu) => {
3378                    log::trace!(
3379                        "draw_proto_masks started with hybrid (cpu+opengl) in {:?}",
3380                        start.elapsed()
3381                    );
3382                    cpu.materialize_segmentations(detect, proto_data, overlay.letterbox)?
3383                }
3384                None => unreachable!("cpu presence checked above"),
3385            };
3386            if let Some(opengl) = self.opengl.as_mut() {
3387                match opengl.draw_decoded_masks(dst, render_detect, &segmentation, overlay) {
3388                    Ok(_) => {
3389                        log::trace!(
3390                            "draw_proto_masks with hybrid (cpu+opengl) in {:?}",
3391                            start.elapsed()
3392                        );
3393                        return Ok(());
3394                    }
3395                    Err(e) => {
3396                        log::trace!(
3397                            "draw_proto_masks hybrid path failed, falling back to cpu: {e:?}"
3398                        );
3399                    }
3400                }
3401            }
3402        }
3403
3404        let Some(cpu) = self.cpu.as_mut() else {
3405            return Err(Error::Internal(
3406                "draw_proto_masks requires CPU backend for fallback path".into(),
3407            ));
3408        };
3409        log::trace!("draw_proto_masks started with cpu in {:?}", start.elapsed());
3410        cpu.draw_proto_masks(dst, render_detect, proto_data, overlay)
3411    }
3412
3413    fn set_class_colors(&mut self, colors: &[[u8; 4]]) -> Result<()> {
3414        let start = Instant::now();
3415
3416        // ── Forced backend: no fallback chain ────────────────────────
3417        if let Some(forced) = self.forced_backend {
3418            return match forced {
3419                ForcedBackend::Cpu => {
3420                    if let Some(cpu) = self.cpu.as_mut() {
3421                        return cpu.set_class_colors(colors);
3422                    }
3423                    Err(Error::ForcedBackendUnavailable("cpu".into()))
3424                }
3425                ForcedBackend::G2d => Err(Error::NotSupported(
3426                    "g2d does not support set_class_colors".into(),
3427                )),
3428                ForcedBackend::OpenGl => {
3429                    #[cfg(any(
3430                        target_os = "linux",
3431                        target_os = "macos",
3432                        target_os = "ios",
3433                        target_os = "android",
3434                        target_os = "windows"
3435                    ))]
3436                    #[cfg(feature = "opengl")]
3437                    if let Some(opengl) = self.opengl.as_mut() {
3438                        return opengl.set_class_colors(colors);
3439                    }
3440                    Err(Error::ForcedBackendUnavailable("opengl".into()))
3441                }
3442            };
3443        }
3444
3445        // skip G2D as it doesn't support rendering to image
3446
3447        // Every available backend, not the first that accepts. A palette is
3448        // state a later draw reads, and which backend that draw lands on is
3449        // not known here: GL declines a `view()` destination, an unsupported
3450        // format or an import failure, and the CPU backend takes over. A
3451        // backend that never received the colours would render
3452        // `DEFAULT_COLORS_U8` with nothing to show the caller why.
3453        let mut backends = 0usize;
3454        let mut first_err = None;
3455        #[cfg(any(
3456            target_os = "linux",
3457            target_os = "macos",
3458            target_os = "ios",
3459            target_os = "android",
3460            target_os = "windows"
3461        ))]
3462        #[cfg(feature = "opengl")]
3463        if let Some(opengl) = self.opengl.as_mut() {
3464            backends += 1;
3465            match opengl.set_class_colors(colors) {
3466                Ok(()) => log::trace!("colors set with opengl in {:?}", start.elapsed()),
3467                Err(e) => {
3468                    log::trace!("colors didn't set with opengl: {e:?}");
3469                    first_err.get_or_insert(e);
3470                }
3471            }
3472        }
3473        if let Some(cpu) = self.cpu.as_mut() {
3474            backends += 1;
3475            match cpu.set_class_colors(colors) {
3476                Ok(()) => log::trace!("colors set with cpu in {:?}", start.elapsed()),
3477                Err(e) => {
3478                    log::trace!("colors didn't set with cpu: {e:?}");
3479                    first_err.get_or_insert(e);
3480                }
3481            }
3482        }
3483        // The first error, so a GL failure is not hidden by the CPU backend
3484        // accepting afterwards; the colours the other backends took are kept
3485        // either way, since a partial palette beats a stale one.
3486        match first_err {
3487            Some(e) => Err(e),
3488            None if backends > 0 => Ok(()),
3489            None => Err(Error::NoConverter),
3490        }
3491    }
3492}
3493
3494// ---------------------------------------------------------------------------
3495// Image loading / saving helpers
3496// ---------------------------------------------------------------------------
3497
3498/// Test-only convenience helper that peeks the image header, allocates a
3499/// tensor sized to the image (honoring DMA pitch padding on Linux when
3500/// requested), and decodes via [`edgefirst_codec`]. Mirrors the semantics of
3501/// the removed public `load_image` API for test sites; production callers
3502/// should use the explicit peek → allocate → decode pattern directly.
3503#[cfg(all(test, feature = "codec"))]
3504pub(crate) fn load_image_test_helper(
3505    image: &[u8],
3506    format: Option<PixelFormat>,
3507    memory: Option<TensorMemory>,
3508) -> Result<TensorDyn> {
3509    use edgefirst_codec::{peek_info, ImageDecoder, ImageLoad};
3510
3511    // Peek the source header to get its NATIVE format and dimensions. The
3512    // codec now emits the source's native format (JPEG → Nv12/Grey, PNG →
3513    // Rgb/Rgba/Grey) and configures the destination tensor itself.
3514    let info = peek_info(image)?;
3515    let native_fmt = info.format;
3516    let w = info.width;
3517    let h = info.height;
3518
3519    let mut decoder = ImageDecoder::new();
3520
3521    // Decode into a native-format tensor. The decoder sets the tensor's
3522    // dims+format, so we allocate it sized to the native layout.
3523    #[cfg(target_os = "linux")]
3524    let native_src = {
3525        if let Some(aligned_pitch) = padded_dma_pitch_for(native_fmt, w, &memory) {
3526            let mut dma = Tensor::<u8>::image_with_stride(
3527                w,
3528                h,
3529                native_fmt,
3530                aligned_pitch,
3531                Some(TensorMemory::DmaBuf),
3532                edgefirst_tensor::CpuAccess::ReadWrite,
3533            )?;
3534            dma.load_image(&mut decoder, image)?;
3535            TensorDyn::from(dma)
3536        } else {
3537            let mut img = Tensor::<u8>::image(
3538                w,
3539                h,
3540                native_fmt,
3541                memory,
3542                edgefirst_tensor::CpuAccess::ReadWrite,
3543            )?;
3544            img.load_image(&mut decoder, image)?;
3545            TensorDyn::from(img)
3546        }
3547    };
3548    #[cfg(not(target_os = "linux"))]
3549    let native_src = {
3550        let mut img = Tensor::<u8>::image(
3551            w,
3552            h,
3553            native_fmt,
3554            memory,
3555            edgefirst_tensor::CpuAccess::ReadWrite,
3556        )?;
3557        img.load_image(&mut decoder, image)?;
3558        TensorDyn::from(img)
3559    };
3560
3561    // If the caller requested a different format, convert into it (same
3562    // dims) using a headless CPU-backed processor so the helper works
3563    // without GPU/G2D hardware.
3564    match format {
3565        Some(f) if f != native_fmt => {
3566            let mut dst = TensorDyn::image(
3567                w,
3568                h,
3569                f,
3570                DType::U8,
3571                memory,
3572                edgefirst_tensor::CpuAccess::ReadWrite,
3573            )?;
3574            // `ImageProcessorConfig` has platform-specific fields: on Linux it
3575            // carries extra GL/G2D options so `..Default::default()` is needed,
3576            // but on macOS `backend` is the only field, making the update
3577            // redundant (clippy::needless_update). Allow it for cross-platform
3578            // parity — the alternative (field reassign) trips
3579            // clippy::field_reassign_with_default on Linux instead.
3580            #[allow(clippy::needless_update)]
3581            let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
3582                backend: ComputeBackend::Cpu,
3583                ..Default::default()
3584            })?;
3585            proc.convert(
3586                &native_src,
3587                &mut dst,
3588                Rotation::None,
3589                Flip::None,
3590                Crop::default(),
3591            )?;
3592            Ok(dst)
3593        }
3594        _ => Ok(native_src),
3595    }
3596}
3597
3598/// Save a [`TensorDyn`] image as a JPEG file.
3599///
3600/// Only packed RGB and RGBA formats are supported.
3601pub fn save_jpeg(tensor: &TensorDyn, path: impl AsRef<std::path::Path>, quality: u8) -> Result<()> {
3602    let t = tensor.as_typed::<u8>().ok_or(Error::UnsupportedFormat(
3603        "save_jpeg requires u8 tensor".to_string(),
3604    ))?;
3605    let fmt = t.format().ok_or(Error::NotAnImage)?;
3606    if fmt.layout() != PixelLayout::Packed {
3607        return Err(Error::NotImplemented(
3608            "Saving planar images is not supported".to_string(),
3609        ));
3610    }
3611
3612    let colour = match fmt {
3613        PixelFormat::Rgb => jpeg_encoder::ColorType::Rgb,
3614        PixelFormat::Rgba => jpeg_encoder::ColorType::Rgba,
3615        _ => {
3616            return Err(Error::NotImplemented(
3617                "Unsupported image format for saving".to_string(),
3618            ));
3619        }
3620    };
3621
3622    let w = t.width().ok_or(Error::NotAnImage)?;
3623    let h = t.height().ok_or(Error::NotAnImage)?;
3624    let encoder = jpeg_encoder::Encoder::new_file(path, quality)?;
3625    let tensor_map = t.map_read()?;
3626
3627    encoder.encode(&tensor_map, w as u16, h as u16, colour)?;
3628
3629    Ok(())
3630}
3631
3632pub(crate) struct FunctionTimer<T: Display> {
3633    name: T,
3634    start: std::time::Instant,
3635}
3636
3637impl<T: Display> FunctionTimer<T> {
3638    pub fn new(name: T) -> Self {
3639        Self {
3640            name,
3641            start: std::time::Instant::now(),
3642        }
3643    }
3644}
3645
3646impl<T: Display> Drop for FunctionTimer<T> {
3647    fn drop(&mut self) {
3648        log::trace!("{} elapsed: {:?}", self.name, self.start.elapsed())
3649    }
3650}
3651
3652const DEFAULT_COLORS: [[f32; 4]; 20] = [
3653    [0., 1., 0., 0.7],
3654    [1., 0.5568628, 0., 0.7],
3655    [0.25882353, 0.15294118, 0.13333333, 0.7],
3656    [0.8, 0.7647059, 0.78039216, 0.7],
3657    [0.3137255, 0.3137255, 0.3137255, 0.7],
3658    [0.1411765, 0.3098039, 0.1215686, 0.7],
3659    [1., 0.95686275, 0.5137255, 0.7],
3660    [0.3529412, 0.32156863, 0., 0.7],
3661    [0.4235294, 0.6235294, 0.6509804, 0.7],
3662    [0.5098039, 0.5098039, 0.7294118, 0.7],
3663    [0.00784314, 0.18823529, 0.29411765, 0.7],
3664    [0.0, 0.2706, 1.0, 0.7],
3665    [0.0, 0.0, 0.0, 0.7],
3666    [0.0, 0.5, 0.0, 0.7],
3667    [1.0, 0.0, 0.0, 0.7],
3668    [0.0, 0.0, 1.0, 0.7],
3669    [1.0, 0.5, 0.5, 0.7],
3670    [0.1333, 0.5451, 0.1333, 0.7],
3671    [0.1176, 0.4118, 0.8235, 0.7],
3672    [1., 1., 1., 0.7],
3673];
3674
3675const fn denorm<const M: usize, const N: usize>(a: [[f32; M]; N]) -> [[u8; M]; N] {
3676    let mut result = [[0; M]; N];
3677    let mut i = 0;
3678    while i < N {
3679        let mut j = 0;
3680        while j < M {
3681            result[i][j] = (a[i][j] * 255.0).round() as u8;
3682            j += 1;
3683        }
3684        i += 1;
3685    }
3686    result
3687}
3688
3689const DEFAULT_COLORS_U8: [[u8; 4]; 20] = denorm(DEFAULT_COLORS);
3690
3691#[cfg(test)]
3692#[cfg_attr(coverage_nightly, coverage(off))]
3693mod alignment_tests {
3694    use super::*;
3695
3696    #[test]
3697    fn align_width_rgba8_common_widths() {
3698        // RGBA8 (bpp=4, lcm(64,4)=64, so width must round to multiple of 16 px).
3699        assert_eq!(align_width_for_gpu_pitch(640, 4), 640); // 2560 byte pitch — already aligned
3700        assert_eq!(align_width_for_gpu_pitch(1280, 4), 1280); // 5120
3701        assert_eq!(align_width_for_gpu_pitch(1920, 4), 1920); // 7680
3702        assert_eq!(align_width_for_gpu_pitch(3840, 4), 3840); // 15360
3703                                                              // crowd.png case from the imx95 investigation:
3704        assert_eq!(align_width_for_gpu_pitch(3004, 4), 3008); // 12016 → 12032
3705        assert_eq!(align_width_for_gpu_pitch(3000, 4), 3008); // 12000 → 12032
3706        assert_eq!(align_width_for_gpu_pitch(17, 4), 32); // 68 → 128
3707        assert_eq!(align_width_for_gpu_pitch(1, 4), 16); // 4 → 64
3708    }
3709
3710    #[test]
3711    fn align_width_rgb888_packed() {
3712        // RGB888 (bpp=3, lcm(64,3)=192, so width must round to multiple of 64 px).
3713        assert_eq!(align_width_for_gpu_pitch(64, 3), 64); // 192 byte pitch
3714        assert_eq!(align_width_for_gpu_pitch(640, 3), 640); // 1920
3715        assert_eq!(align_width_for_gpu_pitch(1, 3), 64); // 3 → 192
3716        assert_eq!(align_width_for_gpu_pitch(65, 3), 128); // 195 → 384
3717                                                           // Verify the rounded width × bpp is a clean multiple of the LCM.
3718        for w in [3004usize, 1281, 100, 17] {
3719            let padded = align_width_for_gpu_pitch(w, 3);
3720            assert!(padded >= w);
3721            assert_eq!((padded * 3) % 64, 0);
3722            assert_eq!((padded * 3) % 3, 0);
3723        }
3724    }
3725
3726    #[test]
3727    fn align_width_grey_u8() {
3728        // Grey (bpp=1, lcm(64,1)=64, so width must round to multiple of 64 px).
3729        assert_eq!(align_width_for_gpu_pitch(64, 1), 64);
3730        assert_eq!(align_width_for_gpu_pitch(640, 1), 640);
3731        assert_eq!(align_width_for_gpu_pitch(1, 1), 64);
3732        assert_eq!(align_width_for_gpu_pitch(65, 1), 128);
3733    }
3734
3735    #[test]
3736    fn align_width_zero_inputs() {
3737        assert_eq!(align_width_for_gpu_pitch(0, 4), 0);
3738        assert_eq!(align_width_for_gpu_pitch(640, 0), 640);
3739    }
3740
3741    #[test]
3742    fn align_width_never_returns_smaller_than_input() {
3743        // Spot-check the "returned width >= input width" contract across a
3744        // range of values that would previously have hit `width * bpp`
3745        // overflow paths.
3746        for &bpp in &[1usize, 2, 3, 4, 8] {
3747            for &w in &[
3748                1usize,
3749                17,
3750                64,
3751                65,
3752                100,
3753                1280,
3754                1281,
3755                1920,
3756                3004,
3757                3072,
3758                3840,
3759                usize::MAX / 8,
3760                usize::MAX / 4,
3761                usize::MAX / 2,
3762                usize::MAX - 1,
3763                usize::MAX,
3764            ] {
3765                let aligned = align_width_for_gpu_pitch(w, bpp);
3766                assert!(
3767                    aligned >= w,
3768                    "align_width_for_gpu_pitch({w}, {bpp}) = {aligned} < {w}"
3769                );
3770            }
3771        }
3772    }
3773
3774    #[test]
3775    fn align_width_overflow_returns_unaligned_not_smaller() {
3776        // For width values close to usize::MAX, padding up would wrap. The
3777        // function must return the original width rather than wrapping or
3778        // panicking. A pre-aligned width round-trips unchanged even at the
3779        // extreme.
3780        let aligned_extreme = usize::MAX - 15; // 16-pixel boundary for RGBA8
3781        assert_eq!(
3782            align_width_for_gpu_pitch(aligned_extreme, 4),
3783            aligned_extreme
3784        );
3785        // A misaligned extreme value cannot be rounded up — the function
3786        // returns the original.
3787        let misaligned_extreme = usize::MAX - 1;
3788        let result = align_width_for_gpu_pitch(misaligned_extreme, 4);
3789        assert!(
3790            result == misaligned_extreme || result >= misaligned_extreme,
3791            "extreme misaligned width must not be rounded down to {result}"
3792        );
3793    }
3794
3795    #[test]
3796    fn checked_lcm_basic_and_overflow() {
3797        assert_eq!(checked_num_integer_lcm(64, 4), Some(64));
3798        assert_eq!(checked_num_integer_lcm(64, 3), Some(192));
3799        assert_eq!(checked_num_integer_lcm(64, 1), Some(64));
3800        assert_eq!(checked_num_integer_lcm(0, 4), Some(0));
3801        assert_eq!(checked_num_integer_lcm(64, 0), Some(0));
3802        // Coprime values whose product exceeds usize::MAX must return None.
3803        assert_eq!(
3804            checked_num_integer_lcm(usize::MAX, usize::MAX - 1),
3805            None,
3806            "coprime extreme values must overflow detect, not panic"
3807        );
3808    }
3809
3810    #[test]
3811    fn primary_plane_bpp_known_formats() {
3812        // Packed formats use channels × elem_size.
3813        assert_eq!(primary_plane_bpp(PixelFormat::Rgba, 1), Some(4));
3814        assert_eq!(primary_plane_bpp(PixelFormat::Bgra, 1), Some(4));
3815        assert_eq!(primary_plane_bpp(PixelFormat::Rgb, 1), Some(3));
3816        assert_eq!(primary_plane_bpp(PixelFormat::Grey, 1), Some(1));
3817        // Semi-planar (NV12) reports the luma plane's bpp.
3818        assert_eq!(primary_plane_bpp(PixelFormat::Nv12, 1), Some(1));
3819    }
3820}
3821
3822#[cfg(test)]
3823#[cfg_attr(coverage_nightly, coverage(off))]
3824#[allow(deprecated)]
3825mod image_tests {
3826    use super::*;
3827    use crate::{CPUProcessor, Rotation};
3828    #[cfg(target_os = "linux")]
3829    use edgefirst_tensor::is_dma_available;
3830    use edgefirst_tensor::{TensorMapTrait, TensorMemory, TensorTrait};
3831    use image::buffer::ConvertBuffer;
3832
3833    /// Test helper: call `ImageProcessorTrait::convert()` on two `TensorDyn`s
3834    /// by going through the `TensorDyn` API.
3835    ///
3836    /// Returns the `(src_image, dst_image)` reconstructed from the TensorDyn
3837    /// round-trip so the caller can feed them to `compare_images` etc.
3838    fn convert_img(
3839        proc: &mut dyn ImageProcessorTrait,
3840        src: TensorDyn,
3841        dst: TensorDyn,
3842        rotation: Rotation,
3843        flip: Flip,
3844        crop: Crop,
3845    ) -> (Result<()>, TensorDyn, TensorDyn) {
3846        let src_fourcc = src.format().unwrap();
3847        let dst_fourcc = dst.format().unwrap();
3848        let src_dyn = src;
3849        let mut dst_dyn = dst;
3850        let result = proc.convert(&src_dyn, &mut dst_dyn, rotation, flip, crop);
3851        let src_back = {
3852            let mut __t = src_dyn.into_u8().unwrap();
3853            __t.set_format(src_fourcc).unwrap();
3854            TensorDyn::from(__t)
3855        };
3856        let dst_back = {
3857            let mut __t = dst_dyn.into_u8().unwrap();
3858            __t.set_format(dst_fourcc).unwrap();
3859            TensorDyn::from(__t)
3860        };
3861        (result, src_back, dst_back)
3862    }
3863
3864    #[ctor::ctor(unsafe)]
3865    fn init() {
3866        env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
3867    }
3868
3869    macro_rules! function {
3870        () => {{
3871            fn f() {}
3872            fn type_name_of<T>(_: T) -> &'static str {
3873                std::any::type_name::<T>()
3874            }
3875            let name = type_name_of(f);
3876
3877            // Find and cut the rest of the path
3878            match &name[..name.len() - 3].rfind(':') {
3879                Some(pos) => &name[pos + 1..name.len() - 3],
3880                None => &name[..name.len() - 3],
3881            }
3882        }};
3883    }
3884
3885    /// Master oracle for the view/batch **destination** batch engine: render `N`
3886    /// tiles into row-bands of ONE tall destination and assert each band equals
3887    /// the same source converted standalone — proving correct band placement and
3888    /// that a later tile's letterbox clear / draw never wipes a sibling band. On
3889    /// the Linux GL backend the `N` `convert_deferred` calls share ONE parent
3890    /// EGLImage import (each tile is a `glViewport`/`glScissor` ROI) and sync
3891    /// once at `flush()`; other backends fall back to an eager per-band convert
3892    /// (CPU writes via offset + parent stride). Either way the oracle must hold.
3893    ///
3894    /// Identical source/tile size makes the convert an exact copy, so the
3895    /// assertion is backend-agnostic (no GL-vs-CPU resampling drift). Distinct
3896    /// solid colors per tile make any sibling wipe a hard failure.
3897    #[test]
3898    fn batch_view_dst_tiles_match_standalone() {
3899        let mut proc = match ImageProcessor::new() {
3900            Ok(p) => p,
3901            Err(e) => {
3902                crate::test_support::report_skip(&format!(
3903                    "{} — ImageProcessor init failed ({e:?})",
3904                    function!()
3905                ));
3906                return;
3907            }
3908        };
3909        let n = 3usize;
3910        let (w, h) = (32usize, 24usize);
3911        let colors: [[u8; 4]; 3] = [[210, 40, 40, 255], [40, 210, 40, 255], [40, 40, 210, 255]];
3912        let make_src = |c: [u8; 4]| -> TensorDyn {
3913            let bytes: Vec<u8> = c.iter().copied().cycle().take(w * h * 4).collect();
3914            load_bytes_to_tensor(w, h, PixelFormat::Rgba, Some(TensorMemory::Mem), &bytes).unwrap()
3915        };
3916        // Tall destination: N stacked row-bands. DMA so the Linux GL band path
3917        // runs (one parent import + per-tile glViewport); skip if unavailable.
3918        let parent = match TensorDyn::image(
3919            w,
3920            n * h,
3921            PixelFormat::Rgba,
3922            DType::U8,
3923            Some(TensorMemory::DmaBuf),
3924            edgefirst_tensor::CpuAccess::ReadWrite,
3925        ) {
3926            Ok(d) => d,
3927            Err(e) => {
3928                crate::test_support::report_skip(&format!(
3929                    "{} — tall DMA destination alloc failed ({e:?})",
3930                    function!()
3931                ));
3932                return;
3933            }
3934        };
3935
3936        // Deferred batch: one parent import, glViewport/scissor per band, one sync.
3937        for (i, &c) in colors.iter().enumerate().take(n) {
3938            let mut tile = parent.view(Region::new(0, i * h, w, h)).unwrap();
3939            proc.convert_deferred(
3940                &make_src(c),
3941                &mut tile,
3942                Rotation::None,
3943                Flip::None,
3944                Crop::no_crop(),
3945            )
3946            .unwrap_or_else(|e| panic!("convert_deferred tile {i}: {e:?}"));
3947        }
3948        proc.flush().unwrap();
3949
3950        for (i, &c) in colors.iter().enumerate().take(n) {
3951            // Standalone full-buffer convert of the same source = the oracle.
3952            let mut solo = TensorDyn::image(
3953                w,
3954                h,
3955                PixelFormat::Rgba,
3956                DType::U8,
3957                Some(TensorMemory::DmaBuf),
3958                edgefirst_tensor::CpuAccess::ReadWrite,
3959            )
3960            .unwrap();
3961            proc.convert(
3962                &make_src(c),
3963                &mut solo,
3964                Rotation::None,
3965                Flip::None,
3966                Crop::no_crop(),
3967            )
3968            .unwrap();
3969
3970            let band = parent.view(Region::new(0, i * h, w, h)).unwrap();
3971            let band_bytes = band
3972                .as_typed::<u8>()
3973                .unwrap()
3974                .map()
3975                .unwrap()
3976                .as_slice()
3977                .to_vec();
3978            let solo_bytes = solo
3979                .as_typed::<u8>()
3980                .unwrap()
3981                .map()
3982                .unwrap()
3983                .as_slice()
3984                .to_vec();
3985            assert_eq!(
3986                band_bytes, solo_bytes,
3987                "tile {i}: band differs from standalone convert (placement or sibling wipe)"
3988            );
3989            assert!(
3990                band_bytes.as_chunks::<4>().0.iter().all(|&p| p == c),
3991                "tile {i}: band is not the expected solid color {c:?} (sibling wipe?)"
3992            );
3993        }
3994    }
3995
3996    /// Build a `w`×`h` RGBA frame whose pixels encode their position, so a crop
3997    /// at any origin has distinct content (catches crop-origin / band-placement
3998    /// bugs a solid color cannot).
3999    #[cfg(test)]
4000    fn gradient_frame(w: usize, h: usize) -> TensorDyn {
4001        let mut bytes = vec![0u8; w * h * 4];
4002        for y in 0..h {
4003            for x in 0..w {
4004                let i = (y * w + x) * 4;
4005                bytes[i] = x as u8;
4006                bytes[i + 1] = y as u8;
4007                bytes[i + 2] = (x ^ y) as u8;
4008                bytes[i + 3] = 255;
4009            }
4010        }
4011        load_bytes_to_tensor(w, h, PixelFormat::Rgba, Some(TensorMemory::Mem), &bytes).unwrap()
4012    }
4013
4014    /// `tile_into` band == standalone crop-convert of the same source region,
4015    /// on the CPU backend (runs on CI without a GPU). Distinct gradient content
4016    /// makes a wrong crop origin or a sibling-band wipe a hard failure.
4017    #[test]
4018    fn tile_into_cpu_distinct_content_parity() {
4019        let mut proc = match ImageProcessor::with_config(ImageProcessorConfig {
4020            backend: ComputeBackend::Cpu,
4021            ..Default::default()
4022        }) {
4023            Ok(p) => p,
4024            Err(e) => {
4025                crate::test_support::report_skip(&format!(
4026                    "{} — CPU init failed ({e:?})",
4027                    function!()
4028                ));
4029                return;
4030            }
4031        };
4032        let (fw, fh) = (96usize, 64usize);
4033        let src = gradient_frame(fw, fh);
4034        let cfg = TilingConfig::new(32, 32).with_overlap(0.0); // exact 3×2 tiling
4035        let n = tile_grid(fh, fw, 32, 32, 0.0).len();
4036        assert_eq!(n, 6);
4037
4038        let mut parent = proc
4039            .alloc_tile_batch(
4040                n,
4041                &cfg,
4042                PixelFormat::Rgba,
4043                DType::U8,
4044                Some(TensorMemory::Mem),
4045                edgefirst_tensor::CpuAccess::ReadWrite,
4046            )
4047            .unwrap();
4048        let placements = proc.tile_into(&src, &mut parent, &cfg).unwrap();
4049        assert_eq!(placements.len(), n);
4050
4051        for p in &placements {
4052            let source = Region::new(
4053                p.origin.0 as usize,
4054                p.origin.1 as usize,
4055                p.crop_size.0 as usize,
4056                p.crop_size.1 as usize,
4057            );
4058            let mut solo = TensorDyn::image(
4059                32,
4060                32,
4061                PixelFormat::Rgba,
4062                DType::U8,
4063                Some(TensorMemory::Mem),
4064                edgefirst_tensor::CpuAccess::ReadWrite,
4065            )
4066            .unwrap();
4067            proc.convert(
4068                &src,
4069                &mut solo,
4070                Rotation::None,
4071                Flip::None,
4072                Crop::default()
4073                    .with_source(Some(source))
4074                    .with_fit(Fit::Stretch),
4075            )
4076            .unwrap();
4077            let band = parent.view(Region::new(0, p.index * 32, 32, 32)).unwrap();
4078            let band_bytes = band
4079                .as_typed::<u8>()
4080                .unwrap()
4081                .map()
4082                .unwrap()
4083                .as_slice()
4084                .to_vec();
4085            let solo_bytes = solo
4086                .as_typed::<u8>()
4087                .unwrap()
4088                .map()
4089                .unwrap()
4090                .as_slice()
4091                .to_vec();
4092            assert_eq!(
4093                band_bytes, solo_bytes,
4094                "tile {} band differs from standalone crop-convert",
4095                p.index
4096            );
4097        }
4098    }
4099
4100    /// Streaming `tile_one` into a single slot == the corresponding batched
4101    /// `tile_into` band (proves the two paths agree).
4102    #[test]
4103    fn tile_one_matches_tile_into_band() {
4104        let mut proc = match ImageProcessor::with_config(ImageProcessorConfig {
4105            backend: ComputeBackend::Cpu,
4106            ..Default::default()
4107        }) {
4108            Ok(p) => p,
4109            Err(e) => {
4110                crate::test_support::report_skip(&format!(
4111                    "{} — CPU init failed ({e:?})",
4112                    function!()
4113                ));
4114                return;
4115            }
4116        };
4117        let (fw, fh) = (96usize, 64usize);
4118        let src = gradient_frame(fw, fh);
4119        let cfg = TilingConfig::new(32, 32).with_overlap(0.0);
4120        let n = tile_grid(fh, fw, 32, 32, 0.0).len();
4121
4122        let mut parent = proc
4123            .alloc_tile_batch(
4124                n,
4125                &cfg,
4126                PixelFormat::Rgba,
4127                DType::U8,
4128                Some(TensorMemory::Mem),
4129                edgefirst_tensor::CpuAccess::ReadWrite,
4130            )
4131            .unwrap();
4132        proc.tile_into(&src, &mut parent, &cfg).unwrap();
4133
4134        let plan = proc.plan_tiles(fw, fh, &cfg).unwrap();
4135        for p in &plan {
4136            let mut slot = TensorDyn::image(
4137                32,
4138                32,
4139                PixelFormat::Rgba,
4140                DType::U8,
4141                Some(TensorMemory::Mem),
4142                edgefirst_tensor::CpuAccess::ReadWrite,
4143            )
4144            .unwrap();
4145            proc.tile_one(&src, &mut slot, p, &cfg).unwrap();
4146            proc.flush().unwrap();
4147            let band = parent.view(Region::new(0, p.index * 32, 32, 32)).unwrap();
4148            let slot_bytes = slot
4149                .as_typed::<u8>()
4150                .unwrap()
4151                .map()
4152                .unwrap()
4153                .as_slice()
4154                .to_vec();
4155            let band_bytes = band
4156                .as_typed::<u8>()
4157                .unwrap()
4158                .map()
4159                .unwrap()
4160                .as_slice()
4161                .to_vec();
4162            assert_eq!(
4163                slot_bytes, band_bytes,
4164                "tile {} stream != batch band",
4165                p.index
4166            );
4167        }
4168    }
4169
4170    /// `plan_tiles` returns correct per-tile metadata for a 4K frame (pure, no
4171    /// GPU render).
4172    /// `tile_into` through the auto backend with a DMA parent — exercises the
4173    /// GL single-import band path on a GPU machine (skips when DMA/GL is
4174    /// unavailable). The source is sampled via `Crop::with_source`, never
4175    /// `src.view()`, so all tiles share one source import.
4176    #[test]
4177    fn tile_into_auto_dma_parity() {
4178        let mut proc = match ImageProcessor::new() {
4179            Ok(p) => p,
4180            Err(e) => {
4181                crate::test_support::report_skip(&format!(
4182                    "{} — ImageProcessor init failed ({e:?})",
4183                    function!()
4184                ));
4185                return;
4186            }
4187        };
4188        let (fw, fh) = (96usize, 64usize);
4189        let src = gradient_frame(fw, fh);
4190        let cfg = TilingConfig::new(32, 32).with_overlap(0.0);
4191        let n = tile_grid(fh, fw, 32, 32, 0.0).len();
4192
4193        let mut parent = match proc.alloc_tile_batch(
4194            n,
4195            &cfg,
4196            PixelFormat::Rgba,
4197            DType::U8,
4198            Some(TensorMemory::DmaBuf),
4199            edgefirst_tensor::CpuAccess::ReadWrite,
4200        ) {
4201            Ok(p) => p,
4202            Err(e) => {
4203                crate::test_support::report_skip(&format!(
4204                    "{} — tall DMA parent alloc failed ({e:?})",
4205                    function!()
4206                ));
4207                return;
4208            }
4209        };
4210        let placements = proc.tile_into(&src, &mut parent, &cfg).unwrap();
4211
4212        for p in &placements {
4213            let source = Region::new(
4214                p.origin.0 as usize,
4215                p.origin.1 as usize,
4216                p.crop_size.0 as usize,
4217                p.crop_size.1 as usize,
4218            );
4219            let mut solo = TensorDyn::image(
4220                32,
4221                32,
4222                PixelFormat::Rgba,
4223                DType::U8,
4224                Some(TensorMemory::DmaBuf),
4225                edgefirst_tensor::CpuAccess::ReadWrite,
4226            )
4227            .unwrap();
4228            proc.convert(
4229                &src,
4230                &mut solo,
4231                Rotation::None,
4232                Flip::None,
4233                Crop::default()
4234                    .with_source(Some(source))
4235                    .with_fit(Fit::Stretch),
4236            )
4237            .unwrap();
4238            let band = parent.view(Region::new(0, p.index * 32, 32, 32)).unwrap();
4239            // Structural-similarity tolerance (the house GPU-parity bar) rather
4240            // than exact bytes: rendering a tile into a viewport band at a
4241            // non-zero offset vs. a standalone origin render diverges by sampling
4242            // rounding on virtualized GPUs (paravirtual Metal/ANGLE on macOS CI),
4243            // the same tolerance class as the other cross-backend parity tests.
4244            compare_images(
4245                &band,
4246                &solo,
4247                0.98,
4248                &format!("{}_tile{}", function!(), p.index),
4249            );
4250        }
4251    }
4252
4253    /// `tile_into` rejects a destination too small to hold all tile bands.
4254    #[test]
4255    fn tile_into_undersized_dst_errors() {
4256        let mut proc = match ImageProcessor::with_config(ImageProcessorConfig {
4257            backend: ComputeBackend::Cpu,
4258            ..Default::default()
4259        }) {
4260            Ok(p) => p,
4261            Err(e) => {
4262                crate::test_support::report_skip(&format!(
4263                    "{} — CPU init failed ({e:?})",
4264                    function!()
4265                ));
4266                return;
4267            }
4268        };
4269        let (fw, fh) = (96usize, 64usize);
4270        let src = gradient_frame(fw, fh);
4271        let cfg = TilingConfig::new(32, 32).with_overlap(0.0); // 6 tiles
4272                                                               // Allocate a parent for only 2 bands, far short of 6.
4273        let mut small = TensorDyn::image(
4274            32,
4275            2 * 32,
4276            PixelFormat::Rgba,
4277            DType::U8,
4278            Some(TensorMemory::Mem),
4279            edgefirst_tensor::CpuAccess::ReadWrite,
4280        )
4281        .unwrap();
4282        let r = proc.tile_into(&src, &mut small, &cfg);
4283        assert!(
4284            matches!(r, Err(Error::InvalidShape(_))),
4285            "expected InvalidShape, got {r:?}"
4286        );
4287    }
4288
4289    /// `alloc_tile_batch` / `plan_tiles` reject an invalid config (zero tile).
4290    #[test]
4291    fn tiling_alloc_rejects_invalid_config() {
4292        let proc = match ImageProcessor::with_config(ImageProcessorConfig {
4293            backend: ComputeBackend::Cpu,
4294            ..Default::default()
4295        }) {
4296            Ok(p) => p,
4297            Err(e) => {
4298                crate::test_support::report_skip(&format!(
4299                    "{} — CPU init failed ({e:?})",
4300                    function!()
4301                ));
4302                return;
4303            }
4304        };
4305        let bad = TilingConfig::new(0, 640);
4306        assert!(proc.plan_tiles(1920, 1080, &bad).is_err());
4307        assert!(proc
4308            .alloc_tile_batch(
4309                4,
4310                &bad,
4311                PixelFormat::Rgba,
4312                DType::U8,
4313                Some(TensorMemory::Mem),
4314                edgefirst_tensor::CpuAccess::ReadWrite,
4315            )
4316            .is_err());
4317    }
4318
4319    #[test]
4320    fn plan_tiles_metadata_4k() {
4321        let proc = match ImageProcessor::with_config(ImageProcessorConfig {
4322            backend: ComputeBackend::Cpu,
4323            ..Default::default()
4324        }) {
4325            Ok(p) => p,
4326            Err(e) => {
4327                crate::test_support::report_skip(&format!(
4328                    "{} — CPU init failed ({e:?})",
4329                    function!()
4330                ));
4331                return;
4332            }
4333        };
4334        let cfg = TilingConfig::new(640, 640).with_overlap(0.2);
4335        let plan = proc.plan_tiles(3840, 2160, &cfg).unwrap();
4336        assert_eq!(plan.len(), 32);
4337        assert!(plan.iter().all(|p| p.count == 32));
4338        assert!(plan.iter().all(|p| p.crop_size == (640.0, 640.0)));
4339        assert!(plan.iter().all(|p| p.letterbox.is_none())); // stretch fit
4340        assert!(plan.iter().all(|p| p.frame_dims == (3840.0, 2160.0)));
4341        assert_eq!(plan[0].origin, (0.0, 0.0));
4342    }
4343
4344    #[test]
4345    fn test_invalid_crop() {
4346        let src = TensorDyn::image(
4347            100,
4348            100,
4349            PixelFormat::Rgb,
4350            DType::U8,
4351            None,
4352            edgefirst_tensor::CpuAccess::ReadWrite,
4353        )
4354        .unwrap();
4355        let dst = TensorDyn::image(
4356            100,
4357            100,
4358            PixelFormat::Rgb,
4359            DType::U8,
4360            None,
4361            edgefirst_tensor::CpuAccess::ReadWrite,
4362        )
4363        .unwrap();
4364
4365        // A source crop exceeding the source bounds is rejected.
4366        let crop = Crop::new().with_source(Some(Region::new(50, 50, 60, 60)));
4367        assert!(matches!(
4368            crop.check_crop_dyn(&src, &dst),
4369            Err(Error::CropInvalid(_))
4370        ));
4371
4372        // A source crop within bounds is valid.
4373        let crop = Crop::new().with_source(Some(Region::new(0, 0, 10, 10)));
4374        assert!(crop.check_crop_dyn(&src, &dst).is_ok());
4375
4376        // Letterbox is always valid — placement is computed within the dst.
4377        assert!(Crop::letterbox([0, 0, 0, 255])
4378            .check_crop_dyn(&src, &dst)
4379            .is_ok());
4380    }
4381
4382    #[test]
4383    fn test_invalid_tensor_format() -> Result<(), Error> {
4384        // 4D tensor cannot be set to a 3-channel pixel format
4385        let mut tensor = Tensor::<u8>::new(&[720, 1280, 4, 1], None, None)?;
4386        let result = tensor.set_format(PixelFormat::Rgb);
4387        assert!(result.is_err(), "4D tensor should reject set_format");
4388
4389        // Tensor with wrong channel count for the format
4390        let mut tensor = Tensor::<u8>::new(&[720, 1280, 4], None, None)?;
4391        let result = tensor.set_format(PixelFormat::Rgb);
4392        assert!(result.is_err(), "4-channel tensor should reject RGB format");
4393
4394        Ok(())
4395    }
4396
4397    #[test]
4398    fn test_invalid_image_file() -> Result<(), Error> {
4399        let result = crate::load_image_test_helper(&[123; 5000], None, None);
4400        assert!(
4401            matches!(result, Err(Error::Codec(_))),
4402            "unrecognised bytes should surface as Error::Codec, got {result:?}"
4403        );
4404        Ok(())
4405    }
4406
4407    #[test]
4408    fn test_invalid_jpeg_format() -> Result<(), Error> {
4409        let result = crate::load_image_test_helper(&[123; 5000], Some(PixelFormat::Yuyv), None);
4410        // YUYV is not a valid decode target; peek_info fails before the magic-
4411        // bytes check, so the precise variant depends on which error fires first.
4412        assert!(
4413            matches!(result, Err(Error::Codec(_))),
4414            "Yuyv target with garbage bytes should surface as Error::Codec, got {result:?}"
4415        );
4416        Ok(())
4417    }
4418
4419    #[test]
4420    fn test_load_resize_save() {
4421        let file = edgefirst_bench::testdata::read("zidane.jpg");
4422        let img = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4423        assert_eq!(img.width(), Some(1280));
4424        assert_eq!(img.height(), Some(720));
4425
4426        let dst = TensorDyn::image(
4427            640,
4428            360,
4429            PixelFormat::Rgba,
4430            DType::U8,
4431            None,
4432            edgefirst_tensor::CpuAccess::ReadWrite,
4433        )
4434        .unwrap();
4435        let mut converter = CPUProcessor::new();
4436        let (result, _img, dst) = convert_img(
4437            &mut converter,
4438            img,
4439            dst,
4440            Rotation::None,
4441            Flip::None,
4442            Crop::no_crop(),
4443        );
4444        result.unwrap();
4445        assert_eq!(dst.width(), Some(640));
4446        assert_eq!(dst.height(), Some(360));
4447
4448        crate::save_jpeg(&dst, "zidane_resized.jpg", 80).unwrap();
4449
4450        let file = std::fs::read("zidane_resized.jpg").unwrap();
4451        // With `format: None` the helper returns the source's native format.
4452        // The codec now decodes colour JPEGs to NV12 (was RGB previously).
4453        let img = crate::load_image_test_helper(&file, None, None).unwrap();
4454        assert_eq!(img.width(), Some(640));
4455        assert_eq!(img.height(), Some(360));
4456        assert_eq!(img.format().unwrap(), PixelFormat::Nv12);
4457    }
4458
4459    #[test]
4460    fn test_from_tensor_planar() -> Result<(), Error> {
4461        let mut tensor = Tensor::new(&[3, 720, 1280], None, None)?;
4462        tensor
4463            .map()?
4464            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.8bps"));
4465        let planar = {
4466            tensor
4467                .set_format(PixelFormat::PlanarRgb)
4468                .map_err(|e| crate::Error::Internal(e.to_string()))?;
4469            TensorDyn::from(tensor)
4470        };
4471
4472        let rbga = load_bytes_to_tensor(
4473            1280,
4474            720,
4475            PixelFormat::Rgba,
4476            None,
4477            &edgefirst_bench::testdata::read("camera720p.rgba"),
4478        )?;
4479        compare_images_convert_to_rgb(&planar, &rbga, 0.98, function!());
4480
4481        Ok(())
4482    }
4483
4484    #[test]
4485    fn test_from_tensor_invalid_format() {
4486        // PixelFormat::from_fourcc_str returns None for unknown FourCC codes.
4487        // Since there's no "TEST" pixel format, this validates graceful handling.
4488        assert!(PixelFormat::from_fourcc(u32::from_le_bytes(*b"TEST")).is_none());
4489    }
4490
4491    #[test]
4492    #[should_panic(expected = "Failed to save planar RGB image")]
4493    fn test_save_planar() {
4494        let planar_img = load_bytes_to_tensor(
4495            1280,
4496            720,
4497            PixelFormat::PlanarRgb,
4498            None,
4499            &edgefirst_bench::testdata::read("camera720p.8bps"),
4500        )
4501        .unwrap();
4502
4503        let save_path = "/tmp/planar_rgb.jpg";
4504        crate::save_jpeg(&planar_img, save_path, 90).expect("Failed to save planar RGB image");
4505    }
4506
4507    #[test]
4508    #[should_panic(expected = "Failed to save YUYV image")]
4509    fn test_save_yuyv() {
4510        let planar_img = load_bytes_to_tensor(
4511            1280,
4512            720,
4513            PixelFormat::Yuyv,
4514            None,
4515            &edgefirst_bench::testdata::read("camera720p.yuyv"),
4516        )
4517        .unwrap();
4518
4519        let save_path = "/tmp/yuyv.jpg";
4520        crate::save_jpeg(&planar_img, save_path, 90).expect("Failed to save YUYV image");
4521    }
4522
4523    #[test]
4524    fn test_rotation_angle() {
4525        assert_eq!(Rotation::from_degrees_clockwise(0), Rotation::None);
4526        assert_eq!(Rotation::from_degrees_clockwise(90), Rotation::Clockwise90);
4527        assert_eq!(Rotation::from_degrees_clockwise(180), Rotation::Rotate180);
4528        assert_eq!(
4529            Rotation::from_degrees_clockwise(270),
4530            Rotation::CounterClockwise90
4531        );
4532        assert_eq!(Rotation::from_degrees_clockwise(360), Rotation::None);
4533        assert_eq!(Rotation::from_degrees_clockwise(450), Rotation::Clockwise90);
4534        assert_eq!(Rotation::from_degrees_clockwise(540), Rotation::Rotate180);
4535        assert_eq!(
4536            Rotation::from_degrees_clockwise(630),
4537            Rotation::CounterClockwise90
4538        );
4539    }
4540
4541    #[test]
4542    #[should_panic(expected = "rotation angle is not a multiple of 90")]
4543    fn test_rotation_angle_panic() {
4544        Rotation::from_degrees_clockwise(361);
4545    }
4546
4547    #[test]
4548    fn test_disable_env_var() -> Result<(), Error> {
4549        // Acquire the env-var mutex for the entire test body so we never race
4550        // with test_force_backend_* or test_draw_proto_masks_no_cpu_returns_error.
4551        let _lock = acquire_env_lock();
4552
4553        // Snapshot ALL env vars we might touch so the RAII guard restores them
4554        // on exit (even on panic), preventing env-var poisoning of other tests.
4555        let _guard = EnvGuard::snapshot(&[
4556            "EDGEFIRST_FORCE_BACKEND",
4557            "EDGEFIRST_DISABLE_GL",
4558            "EDGEFIRST_DISABLE_G2D",
4559            "EDGEFIRST_DISABLE_CPU",
4560        ]);
4561
4562        // EDGEFIRST_FORCE_BACKEND takes precedence over EDGEFIRST_DISABLE_*,
4563        // so clear it for the duration of this test.
4564        unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") };
4565
4566        #[cfg(target_os = "linux")]
4567        {
4568            unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
4569            let converter = ImageProcessor::new()?;
4570            assert!(converter.g2d.is_none());
4571            unsafe { std::env::remove_var("EDGEFIRST_DISABLE_G2D") };
4572        }
4573
4574        #[cfg(any(target_os = "linux", target_os = "windows"))]
4575        #[cfg(feature = "opengl")]
4576        {
4577            unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
4578            let converter = ImageProcessor::new()?;
4579            assert!(converter.opengl.is_none());
4580            unsafe { std::env::remove_var("EDGEFIRST_DISABLE_GL") };
4581        }
4582
4583        unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
4584        let converter = ImageProcessor::new()?;
4585        assert!(converter.cpu.is_none());
4586        unsafe { std::env::remove_var("EDGEFIRST_DISABLE_CPU") };
4587
4588        // Disable everything — convert must return NoConverter.
4589        unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
4590        unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
4591        unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
4592        let mut converter = ImageProcessor::new()?;
4593
4594        let src = TensorDyn::image(
4595            1280,
4596            720,
4597            PixelFormat::Rgba,
4598            DType::U8,
4599            None,
4600            edgefirst_tensor::CpuAccess::ReadWrite,
4601        )?;
4602        let dst = TensorDyn::image(
4603            640,
4604            360,
4605            PixelFormat::Rgba,
4606            DType::U8,
4607            None,
4608            edgefirst_tensor::CpuAccess::ReadWrite,
4609        )?;
4610        let (result, _src, _dst) = convert_img(
4611            &mut converter,
4612            src,
4613            dst,
4614            Rotation::None,
4615            Flip::None,
4616            Crop::no_crop(),
4617        );
4618        assert!(matches!(result, Err(Error::NoConverter)));
4619        // _guard restores all env vars on drop.
4620        Ok(())
4621    }
4622
4623    #[test]
4624    fn test_unsupported_conversion() {
4625        let src = TensorDyn::image(
4626            1280,
4627            720,
4628            PixelFormat::Nv12,
4629            DType::U8,
4630            None,
4631            edgefirst_tensor::CpuAccess::ReadWrite,
4632        )
4633        .unwrap();
4634        let dst = TensorDyn::image(
4635            640,
4636            360,
4637            PixelFormat::Nv12,
4638            DType::U8,
4639            None,
4640            edgefirst_tensor::CpuAccess::ReadWrite,
4641        )
4642        .unwrap();
4643        let mut converter = ImageProcessor::new().unwrap();
4644        let (result, _src, _dst) = convert_img(
4645            &mut converter,
4646            src,
4647            dst,
4648            Rotation::None,
4649            Flip::None,
4650            Crop::no_crop(),
4651        );
4652        log::debug!("result: {:?}", result);
4653        assert!(matches!(
4654            result,
4655            Err(Error::NotSupported(e)) if e.starts_with("Conversion from NV12 to NV12")
4656        ));
4657    }
4658
4659    #[test]
4660    fn test_load_grey() {
4661        // A single-component (greyscale) JPEG decodes to its native GREY
4662        // format, which has no even-dimension constraint, so the 1024×681
4663        // `grey.jpg` loads and converts to RGBA successfully.
4664        let grey_img = crate::load_image_test_helper(
4665            &edgefirst_bench::testdata::read("grey.jpg"),
4666            Some(PixelFormat::Rgba),
4667            None,
4668        )
4669        .unwrap();
4670        assert_eq!(grey_img.width(), Some(1024));
4671        assert_eq!(grey_img.height(), Some(681));
4672
4673        // `grey-rgb.jpg` holds the same grey content but is encoded as a
4674        // 3-component (colour) JPEG, so the codec decodes it to native NV12.
4675        // Its 1024×681 dimensions have an odd height; NV12 now represents odd
4676        // dimensions via the `H + ceil(H/2)` combined-plane height, so the
4677        // decode succeeds and converts to RGBA at the true dimensions.
4678        let grey_but_rgb = crate::load_image_test_helper(
4679            &edgefirst_bench::testdata::read("grey-rgb.jpg"),
4680            Some(PixelFormat::Rgba),
4681            None,
4682        )
4683        .expect("odd-height colour JPEG should decode to NV12 and convert to RGBA");
4684        assert_eq!(grey_but_rgb.width(), Some(1024));
4685        assert_eq!(grey_but_rgb.height(), Some(681));
4686    }
4687
4688    #[test]
4689    fn test_new_nv12() {
4690        let nv12 = TensorDyn::image(
4691            1280,
4692            720,
4693            PixelFormat::Nv12,
4694            DType::U8,
4695            None,
4696            edgefirst_tensor::CpuAccess::ReadWrite,
4697        )
4698        .unwrap();
4699        assert_eq!(nv12.height(), Some(720));
4700        assert_eq!(nv12.width(), Some(1280));
4701        assert_eq!(nv12.format().unwrap(), PixelFormat::Nv12);
4702        // PixelFormat::Nv12.channels() returns 1 (luma plane channel count)
4703        assert_eq!(nv12.format().unwrap().channels(), 1);
4704        assert!(nv12.format().is_some_and(
4705            |f| f.layout() == PixelLayout::Planar || f.layout() == PixelLayout::SemiPlanar
4706        ))
4707    }
4708
4709    #[test]
4710    #[cfg(any(target_os = "linux", target_os = "windows"))]
4711    fn test_new_image_converter() {
4712        let dst_width = 640;
4713        let dst_height = 360;
4714        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4715        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4716
4717        let mut converter = ImageProcessor::new().unwrap();
4718        let converter_dst = converter
4719            .create_image(
4720                dst_width,
4721                dst_height,
4722                PixelFormat::Rgba,
4723                DType::U8,
4724                None,
4725                edgefirst_tensor::CpuAccess::ReadWrite,
4726            )
4727            .unwrap();
4728        let (result, src, converter_dst) = convert_img(
4729            &mut converter,
4730            src,
4731            converter_dst,
4732            Rotation::None,
4733            Flip::None,
4734            Crop::no_crop(),
4735        );
4736        result.unwrap();
4737
4738        let cpu_dst = TensorDyn::image(
4739            dst_width,
4740            dst_height,
4741            PixelFormat::Rgba,
4742            DType::U8,
4743            None,
4744            edgefirst_tensor::CpuAccess::ReadWrite,
4745        )
4746        .unwrap();
4747        let mut cpu_converter = CPUProcessor::new();
4748        let (result, _src, cpu_dst) = convert_img(
4749            &mut cpu_converter,
4750            src,
4751            cpu_dst,
4752            Rotation::None,
4753            Flip::None,
4754            Crop::no_crop(),
4755        );
4756        result.unwrap();
4757
4758        compare_images(&converter_dst, &cpu_dst, 0.98, function!());
4759    }
4760
4761    #[test]
4762    #[cfg(any(target_os = "linux", target_os = "windows"))]
4763    fn test_create_image_dtype_i8() {
4764        let mut converter = ImageProcessor::new().unwrap();
4765
4766        // I8 image should allocate successfully via create_image
4767        let dst = converter
4768            .create_image(
4769                320,
4770                240,
4771                PixelFormat::Rgb,
4772                DType::I8,
4773                None,
4774                edgefirst_tensor::CpuAccess::ReadWrite,
4775            )
4776            .unwrap();
4777        assert_eq!(dst.dtype(), DType::I8);
4778        assert!(dst.width() == Some(320));
4779        assert!(dst.height() == Some(240));
4780        assert_eq!(dst.format(), Some(PixelFormat::Rgb));
4781
4782        // U8 for comparison
4783        let dst_u8 = converter
4784            .create_image(
4785                320,
4786                240,
4787                PixelFormat::Rgb,
4788                DType::U8,
4789                None,
4790                edgefirst_tensor::CpuAccess::ReadWrite,
4791            )
4792            .unwrap();
4793        assert_eq!(dst_u8.dtype(), DType::U8);
4794
4795        // Convert into I8 dst should succeed
4796        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4797        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4798        let mut dst_i8 = converter
4799            .create_image(
4800                320,
4801                240,
4802                PixelFormat::Rgb,
4803                DType::I8,
4804                None,
4805                edgefirst_tensor::CpuAccess::ReadWrite,
4806            )
4807            .unwrap();
4808        converter
4809            .convert(
4810                &src,
4811                &mut dst_i8,
4812                Rotation::None,
4813                Flip::None,
4814                Crop::no_crop(),
4815            )
4816            .unwrap();
4817    }
4818
4819    #[test]
4820    #[cfg(target_os = "linux")]
4821    fn test_create_image_nv12_dma_non_aligned_width() {
4822        // create_image is fully stride-aware: a non-64-aligned NV12 DMA tensor
4823        // may legitimately carry a GPU-pitch-padded row stride — that is the
4824        // intended behaviour, not a bug. Verify the logical geometry is preserved
4825        // for any width and that a reported stride is a valid (>= logical)
4826        // padding, rather than asserting the absence of a stride.
4827        let converter = ImageProcessor::new().unwrap();
4828
4829        // 100 is intentionally not a multiple of 64 (the GPU pitch alignment).
4830        let result = converter.create_image(
4831            100,
4832            64,
4833            PixelFormat::Nv12,
4834            DType::U8,
4835            Some(TensorMemory::DmaBuf),
4836            edgefirst_tensor::CpuAccess::ReadWrite,
4837        );
4838
4839        match result {
4840            Ok(img) => {
4841                assert_eq!(img.width(), Some(100));
4842                assert_eq!(img.height(), Some(64));
4843                assert_eq!(img.format(), Some(PixelFormat::Nv12));
4844                if let Some(stride) = img.row_stride() {
4845                    assert!(
4846                        stride >= 100,
4847                        "NV12 row_stride {stride} must be >= the logical width (100)",
4848                    );
4849                }
4850            }
4851            Err(e) => {
4852                // Skip cleanly on hosts without a dma-heap.
4853                crate::test_support::report_skip(&format!(
4854                    "create_image NV12 DMA non-aligned width: {e}"
4855                ));
4856            }
4857        }
4858    }
4859
4860    #[test]
4861    #[ignore] // Hangs on desktop platforms where DMA-buf is unavailable and PBO
4862              // fallback triggers a GPU driver hang during SHM→texture upload (e.g.,
4863              // NVIDIA without /dev/dma_heap permissions). Works on embedded targets.
4864    fn test_crop_skip() {
4865        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4866        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4867
4868        let mut converter = ImageProcessor::new().unwrap();
4869        let converter_dst = converter
4870            .create_image(
4871                1280,
4872                720,
4873                PixelFormat::Rgba,
4874                DType::U8,
4875                None,
4876                edgefirst_tensor::CpuAccess::ReadWrite,
4877            )
4878            .unwrap();
4879        let crop = Crop::new().with_source(Some(Region::new(0, 0, 640, 640)));
4880        let (result, src, converter_dst) = convert_img(
4881            &mut converter,
4882            src,
4883            converter_dst,
4884            Rotation::None,
4885            Flip::None,
4886            crop,
4887        );
4888        result.unwrap();
4889
4890        let cpu_dst = TensorDyn::image(
4891            1280,
4892            720,
4893            PixelFormat::Rgba,
4894            DType::U8,
4895            None,
4896            edgefirst_tensor::CpuAccess::ReadWrite,
4897        )
4898        .unwrap();
4899        let mut cpu_converter = CPUProcessor::new();
4900        let (result, _src, cpu_dst) = convert_img(
4901            &mut cpu_converter,
4902            src,
4903            cpu_dst,
4904            Rotation::None,
4905            Flip::None,
4906            crop,
4907        );
4908        result.unwrap();
4909
4910        compare_images(&converter_dst, &cpu_dst, 0.99999, function!());
4911    }
4912
4913    #[test]
4914    fn test_invalid_pixel_format() {
4915        // PixelFormat::from_fourcc returns None for unknown formats,
4916        // so TensorDyn::image cannot be called with an invalid format.
4917        assert!(PixelFormat::from_fourcc(u32::from_le_bytes(*b"TEST")).is_none());
4918    }
4919
4920    // Helper function to check if G2D library is available (Linux/i.MX8 only)
4921    #[cfg(target_os = "linux")]
4922    static G2D_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
4923
4924    #[cfg(target_os = "linux")]
4925    fn is_g2d_available() -> bool {
4926        *G2D_AVAILABLE.get_or_init(|| G2DProcessor::new().is_ok())
4927    }
4928
4929    // Every GL platform: Linux EGL, ANGLE/Metal (macOS, iOS), Android EGL,
4930    // ANGLE/D3D11 (Windows). `GLProcessorThreaded::new` fails cleanly where
4931    // the driver or ANGLE is missing, so the `test_opengl_*` callers below
4932    // self-skip there (and `HAL_TEST_REQUIRE_GL=1` lanes catch that via
4933    // `gl_backend_available_canary`).
4934    #[cfg(any(
4935        target_os = "linux",
4936        target_os = "macos",
4937        target_os = "ios",
4938        target_os = "android",
4939        target_os = "windows"
4940    ))]
4941    #[cfg(feature = "opengl")]
4942    static GL_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
4943
4944    #[cfg(any(
4945        target_os = "linux",
4946        target_os = "macos",
4947        target_os = "ios",
4948        target_os = "android",
4949        target_os = "windows"
4950    ))]
4951    #[cfg(feature = "opengl")]
4952    // Helper function to check if OpenGL is available
4953    fn is_opengl_available() -> bool {
4954        *GL_AVAILABLE.get_or_init(|| GLProcessorThreaded::new(None).is_ok())
4955    }
4956
4957    /// CI canary: fails the lane when the GL backend cannot initialize.
4958    ///
4959    /// Every GL test in this suite self-skips when the backend is
4960    /// unavailable — correct for developer machines, but it means a broken
4961    /// CI GL stack (e.g. the macOS ANGLE re-sign step regressing, the exact
4962    /// failure mode documented in the workflow) ships an untested GL
4963    /// backend behind a green lane. Gated on `HAL_TEST_REQUIRE_GL=1`, set
4964    /// only by CI jobs that install a working GL stack; local runs without
4965    /// one pass trivially. On macOS it additionally requires
4966    /// `HAL_TEST_ALLOW_DLOPEN_ANGLE`, so coverage pass 1 (unsigned
4967    /// binaries, dlopen gate closed) skips it and pass 2 (signed) enforces.
4968    #[test]
4969    #[cfg(feature = "opengl")]
4970    fn gl_backend_available_canary() {
4971        let require_gl = std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1");
4972        if !require_gl {
4973            crate::test_support::report_skip(&format!(
4974                "{} — HAL_TEST_REQUIRE_GL is not set to 1",
4975                function!()
4976            ));
4977            return;
4978        }
4979        #[cfg(target_os = "macos")]
4980        if std::env::var_os("HAL_TEST_ALLOW_DLOPEN_ANGLE").is_none() {
4981            crate::test_support::report_skip(&format!(
4982                "{} — ANGLE dlopen gate closed (coverage pass 1)",
4983                function!()
4984            ));
4985            return;
4986        }
4987        GLProcessorThreaded::new(None).expect(
4988            "HAL_TEST_REQUIRE_GL=1 but the GL backend failed to initialize — \
4989             check the ANGLE install/re-sign step and binary entitlements \
4990             (macOS) or the EGL stack (Linux)",
4991        );
4992    }
4993
4994    #[test]
4995    fn test_load_jpeg_with_exif() {
4996        use edgefirst_codec::peek_info;
4997
4998        // The migrated codec NEVER applies EXIF orientation: it decodes to the
4999        // source's native (un-rotated) dimensions and reports the rotation via
5000        // ImageInfo. `zidane_rotated_exif.jpg` carries EXIF orientation 6
5001        // (90° clockwise) over a 1280×720 frame.
5002        let file = edgefirst_bench::testdata::read("zidane_rotated_exif.jpg").to_vec();
5003        let info = peek_info(&file).unwrap();
5004        assert_eq!(info.rotation_degrees, 90);
5005        assert!(!info.flip_horizontal);
5006
5007        let loaded = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5008        // Native (un-rotated) dimensions — the decode does not rotate.
5009        assert_eq!(loaded.width(), Some(1280));
5010        assert_eq!(loaded.height(), Some(720));
5011
5012        // Applying the reported rotation downstream reproduces the upright
5013        // image: it matches `zidane.jpg` rotated by the same 90° clockwise.
5014        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5015        let cpu_src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5016
5017        let rotation = Rotation::from_degrees_clockwise(info.rotation_degrees as usize);
5018        let (dst_width, dst_height) = (cpu_src.height().unwrap(), cpu_src.width().unwrap());
5019
5020        let cpu_dst = TensorDyn::image(
5021            dst_width,
5022            dst_height,
5023            PixelFormat::Rgba,
5024            DType::U8,
5025            None,
5026            edgefirst_tensor::CpuAccess::ReadWrite,
5027        )
5028        .unwrap();
5029        let mut cpu_converter = CPUProcessor::new();
5030
5031        // Rotate the native-orientation `loaded` frame and the native `zidane`
5032        // frame by the same reported rotation; the results must agree.
5033        let loaded_rotated = TensorDyn::image(
5034            dst_width,
5035            dst_height,
5036            PixelFormat::Rgba,
5037            DType::U8,
5038            None,
5039            edgefirst_tensor::CpuAccess::ReadWrite,
5040        )
5041        .unwrap();
5042        let (r0, _loaded, loaded_rotated) = convert_img(
5043            &mut cpu_converter,
5044            loaded,
5045            loaded_rotated,
5046            rotation,
5047            Flip::None,
5048            Crop::no_crop(),
5049        );
5050        r0.unwrap();
5051
5052        let (result, _cpu_src, cpu_dst) = convert_img(
5053            &mut cpu_converter,
5054            cpu_src,
5055            cpu_dst,
5056            rotation,
5057            Flip::None,
5058            Crop::no_crop(),
5059        );
5060        result.unwrap();
5061
5062        compare_images(&loaded_rotated, &cpu_dst, 0.98, function!());
5063    }
5064
5065    #[test]
5066    fn test_load_png_with_exif() {
5067        use edgefirst_codec::peek_info;
5068
5069        // PNGs also report EXIF orientation without applying it.
5070        // `zidane_rotated_exif_180.png` carries EXIF orientation 3 (180°).
5071        let file = edgefirst_bench::testdata::read("zidane_rotated_exif_180.png").to_vec();
5072        let info = peek_info(&file).unwrap();
5073        assert_eq!(info.rotation_degrees, 180);
5074        assert!(!info.flip_horizontal);
5075
5076        let loaded = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5077        // Native (un-rotated) dimensions — PNG decodes upright as authored.
5078        assert_eq!(loaded.height(), Some(720));
5079        assert_eq!(loaded.width(), Some(1280));
5080
5081        // The PNG fixture stores upright `zidane` pixels tagged with a 180°
5082        // EXIF orientation. Because the codec no longer applies the rotation,
5083        // the decoded pixels match `zidane.jpg` directly (no convert needed).
5084        // Re-applying the reported rotation to both must still agree.
5085        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5086        let cpu_src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5087
5088        let rotation = Rotation::from_degrees_clockwise(info.rotation_degrees as usize);
5089        let cpu_dst = TensorDyn::image(
5090            1280,
5091            720,
5092            PixelFormat::Rgba,
5093            DType::U8,
5094            None,
5095            edgefirst_tensor::CpuAccess::ReadWrite,
5096        )
5097        .unwrap();
5098        let mut cpu_converter = CPUProcessor::new();
5099
5100        let (result, _cpu_src, cpu_dst) = convert_img(
5101            &mut cpu_converter,
5102            cpu_src,
5103            cpu_dst,
5104            rotation,
5105            Flip::None,
5106            Crop::no_crop(),
5107        );
5108        result.unwrap();
5109
5110        // Rotate the decoded PNG by the same reported angle so both frames are
5111        // in the same (180°-rotated) orientation before comparing.
5112        let loaded_rotated = TensorDyn::image(
5113            1280,
5114            720,
5115            PixelFormat::Rgba,
5116            DType::U8,
5117            None,
5118            edgefirst_tensor::CpuAccess::ReadWrite,
5119        )
5120        .unwrap();
5121        let (r0, _loaded, loaded_rotated) = convert_img(
5122            &mut cpu_converter,
5123            loaded,
5124            loaded_rotated,
5125            rotation,
5126            Flip::None,
5127            Crop::no_crop(),
5128        );
5129        r0.unwrap();
5130
5131        // Threshold 0.95 (was 0.98): `loaded` comes from a lossless PNG decode
5132        // while `cpu_src` (zidane.jpg) now decodes through native NV12 (chroma
5133        // subsampling) before the RGBA conversion, so the two paths differ by a
5134        // couple of percent versus the old direct-RGB JPEG decode.
5135        compare_images(&loaded_rotated, &cpu_dst, 0.95, function!());
5136    }
5137
5138    /// Synthesise an RGB JPEG with a deterministic pattern at `(width, height)`
5139    /// using the workspace's `jpeg-encoder` crate (the `image` crate is
5140    /// compiled without its JPEG feature). Used to exercise the decoder /
5141    /// pitch-padding paths for arbitrary dimensions without having to bundle
5142    /// a fixture file per test size.
5143    #[cfg(target_os = "linux")]
5144    fn make_rgb_jpeg(width: u32, height: u32) -> Vec<u8> {
5145        let mut bytes = Vec::with_capacity((width * height * 3) as usize);
5146        for y in 0..height {
5147            for x in 0..width {
5148                bytes.push(((x + y) & 0xFF) as u8);
5149                bytes.push(((x.wrapping_mul(3)) & 0xFF) as u8);
5150                bytes.push(((y.wrapping_mul(5)) & 0xFF) as u8);
5151            }
5152        }
5153        let mut out = Vec::new();
5154        let encoder = jpeg_encoder::Encoder::new(&mut out, 85);
5155        encoder
5156            .encode(
5157                &bytes,
5158                width as u16,
5159                height as u16,
5160                jpeg_encoder::ColorType::Rgb,
5161            )
5162            .expect("jpeg-encoder must succeed on trivial input");
5163        out
5164    }
5165
5166    /// End-to-end: a 375×333 RGBA JPEG (width NOT divisible by 4) loaded
5167    /// via the pitch-padded DMA path and letterboxed through the GL
5168    /// backend must produce correct output. Before the Rgba/Bgra
5169    /// width%4 relaxation in `DmaImportAttrs::from_tensor`, this case
5170    /// failed the pre-check and forced a CPU texture upload fallback;
5171    /// with the relaxation, EGL import succeeds at the driver level and
5172    /// the GL fast path runs. Output correctness is checked against a
5173    /// CPU reference (convert ran with `EDGEFIRST_FORCE_BACKEND=cpu`).
5174    #[test]
5175    #[cfg(target_os = "linux")]
5176    #[cfg(feature = "opengl")]
5177    fn test_convert_rgba_non_4_aligned_width_end_to_end() {
5178        use edgefirst_tensor::is_dma_available;
5179        if !is_dma_available() {
5180            crate::test_support::report_skip(
5181                "test_convert_rgba_non_4_aligned_width_end_to_end — DMA not available",
5182            );
5183            return;
5184        }
5185        // 375 is the canonical failure width from dataset loaders —
5186        // 375 * 4 = 1500 bytes/row, pitch-padded to 1536. Width%4 = 3,
5187        // so the old pre-check rejected it; new code accepts it.
5188        let jpeg = make_rgb_jpeg(375, 333);
5189        let src_gl = crate::load_image_test_helper(&jpeg, Some(PixelFormat::Rgba), None).unwrap();
5190        assert_eq!(src_gl.width(), Some(375));
5191        // Row stride must still be pitch-padded (separate concern from width).
5192        let stride = src_gl.row_stride().unwrap();
5193        assert_eq!(stride, 1536, "expected padded pitch 1536, got {stride}");
5194
5195        // GL-backed convert into a pitch-aligned 640×640 Rgba dest.
5196        let mut gl_proc = ImageProcessor::new().unwrap();
5197        let gl_dst = gl_proc
5198            .create_image(
5199                640,
5200                640,
5201                PixelFormat::Rgba,
5202                DType::U8,
5203                None,
5204                edgefirst_tensor::CpuAccess::ReadWrite,
5205            )
5206            .unwrap();
5207        let (r_gl, _src_gl, gl_dst) = convert_img(
5208            &mut gl_proc,
5209            src_gl,
5210            gl_dst,
5211            Rotation::None,
5212            Flip::None,
5213            Crop::no_crop(),
5214        );
5215        r_gl.expect("GL-backed convert must succeed for 375x333 Rgba src");
5216
5217        // CPU reference via a fresh load so the two paths start from
5218        // byte-identical inputs. `with_config(backend=Cpu)` forces the
5219        // CPU-only processor regardless of which backends the host has
5220        // available.
5221        let src_cpu =
5222            crate::load_image_test_helper(&jpeg, Some(PixelFormat::Rgba), Some(TensorMemory::Mem))
5223                .unwrap();
5224        let mut cpu_proc = ImageProcessor::with_config(ImageProcessorConfig {
5225            backend: ComputeBackend::Cpu,
5226            ..Default::default()
5227        })
5228        .unwrap();
5229        let cpu_dst = TensorDyn::image(
5230            640,
5231            640,
5232            PixelFormat::Rgba,
5233            DType::U8,
5234            Some(TensorMemory::Mem),
5235            edgefirst_tensor::CpuAccess::ReadWrite,
5236        )
5237        .unwrap();
5238        let (r_cpu, _src_cpu, cpu_dst) = convert_img(
5239            &mut cpu_proc,
5240            src_cpu,
5241            cpu_dst,
5242            Rotation::None,
5243            Flip::None,
5244            Crop::no_crop(),
5245        );
5246        r_cpu.unwrap();
5247
5248        // Structural similarity: the GL path may have gone through EGL
5249        // import OR fallen back to CPU texture upload — either way, the
5250        // output must match the CPU reference closely.
5251        compare_images(&gl_dst, &cpu_dst, 0.95, function!());
5252    }
5253
5254    /// Regression lock: loading a JPEG at a non-64-aligned RGBA pitch (e.g.
5255    /// 500×333 → natural pitch 2000, needs to be padded to 2048) must go
5256    /// through `image_with_stride` and set `row_stride()` / `effective_row_stride()`
5257    /// to the padded value. The earlier pitch-padding commit fixed this in
5258    /// `load_jpeg`; a regression would surface as `row_stride == None` or
5259    /// `effective_row_stride == 2000`.
5260    #[test]
5261    #[cfg(target_os = "linux")]
5262    fn test_load_jpeg_rgba_non_aligned_pitch_padded_dma() {
5263        use edgefirst_tensor::is_dma_available;
5264        if !is_dma_available() {
5265            crate::test_support::report_skip(
5266                "test_load_jpeg_rgba_non_aligned_pitch_padded_dma — DMA not available",
5267            );
5268            return;
5269        }
5270        // Widths that force a non-64-aligned natural RGBA pitch. All three
5271        // are divisible by 4 so the EGL width-alignment pre-check passes.
5272        // The pitch-padding fix is what makes these importable at all.
5273        for &w in &[500u32, 612, 428] {
5274            let jpeg = make_rgb_jpeg(w, 333);
5275            let loaded =
5276                crate::load_image_test_helper(&jpeg, Some(PixelFormat::Rgba), None).unwrap();
5277            let natural = (w as usize) * 4;
5278            let aligned = crate::align_pitch_bytes_to_gpu_alignment(natural).unwrap();
5279            assert!(
5280                aligned > natural,
5281                "test sanity: width {w} should be unaligned"
5282            );
5283            let stride = loaded
5284                .row_stride()
5285                .expect("padded DMA path must set an explicit row_stride — regression if None");
5286            assert_eq!(
5287                stride, aligned,
5288                "width {w}: expected padded stride {aligned}, got {stride} \
5289                 (regression: pitch-padding branch skipped?)"
5290            );
5291            let eff = loaded.effective_row_stride().unwrap();
5292            assert_eq!(
5293                eff, aligned,
5294                "effective_row_stride must match stored stride"
5295            );
5296            assert_eq!(loaded.width(), Some(w as usize));
5297            assert_eq!(loaded.height(), Some(333));
5298        }
5299    }
5300
5301    /// `padded_dma_pitch_for` must respect the caller's memory choice and
5302    /// must NOT route into the pitch-padded DMA path when the caller left
5303    /// the choice to the allocator (`None`) but DMA is unavailable on the
5304    /// host. The padded path requires `image_with_stride`, which always
5305    /// allocates DMA — taking it on a system without `/dev/dma_heap`
5306    /// would convert a normally-working image load into a hard failure
5307    /// (since `Tensor::image(..., None)` would have fallen back to
5308    /// SHM/Mem).
5309    #[test]
5310    #[cfg(target_os = "linux")]
5311    fn test_padded_dma_pitch_for_respects_memory_choice() {
5312        use edgefirst_tensor::{is_dma_available, TensorMemory};
5313
5314        // 500×4 = 2000 → padded to 2048 by GPU alignment. Use it for
5315        // every case so any "no padding" answer is unambiguous.
5316        let unaligned_w = 500;
5317
5318        // Caller asks for Mem / Shm: never pad, regardless of DMA.
5319        assert_eq!(
5320            crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &Some(TensorMemory::Mem),),
5321            None,
5322            "Mem must never trigger DMA padding"
5323        );
5324        assert_eq!(
5325            crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &Some(TensorMemory::Shm),),
5326            None,
5327            "Shm must never trigger DMA padding"
5328        );
5329
5330        // Caller explicitly asks for DMA: always pad if width needs it.
5331        // Even if the runtime can't actually allocate DMA, the caller
5332        // owns that decision and the resulting allocation error is
5333        // their problem, not ours.
5334        assert_eq!(
5335            crate::padded_dma_pitch_for(
5336                PixelFormat::Rgba,
5337                unaligned_w,
5338                &Some(TensorMemory::DmaBuf),
5339            ),
5340            Some(2048),
5341            "explicit Dma must pad regardless of runtime DMA availability"
5342        );
5343
5344        // Caller leaves it to the allocator: behaviour depends on
5345        // host-runtime DMA availability. This is the case the fix
5346        // guards against.
5347        let none_result = crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &None);
5348        if is_dma_available() {
5349            assert_eq!(
5350                none_result,
5351                Some(2048),
5352                "memory=None + DMA available → pad (will route through DMA)"
5353            );
5354        } else {
5355            assert_eq!(
5356                none_result, None,
5357                "memory=None + DMA unavailable → must NOT pad (would force \
5358                 image_with_stride into a DMA-only allocation that fails). \
5359                 Regression: padded_dma_pitch_for ignored is_dma_available()."
5360            );
5361        }
5362    }
5363
5364    // Synthesise a small greyscale PNG in memory at `(width, height)` with a
5365    // deterministic ramp pattern so multiple tests can cross-check output
5366    // without bundling an extra fixture file.
5367    fn make_grey_png(width: u32, height: u32) -> Vec<u8> {
5368        let mut bytes = Vec::with_capacity((width * height) as usize);
5369        for y in 0..height {
5370            for x in 0..width {
5371                bytes.push(((x + y) & 0xFF) as u8);
5372            }
5373        }
5374        let img = image::GrayImage::from_vec(width, height, bytes).unwrap();
5375        let mut buf = Vec::new();
5376        img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
5377            .unwrap();
5378        buf
5379    }
5380
5381    /// Greyscale PNG with a width that forces a pitch-misaligned natural
5382    /// row stride (612 bytes is not a multiple of the 64-byte GPU pitch
5383    /// alignment) must still load via the pitch-padded DMA path. Gated on
5384    /// DMA availability because `image_with_stride` is DMA-only.
5385    #[test]
5386    #[cfg(target_os = "linux")]
5387    fn test_load_png_grey_misaligned_width_dma() {
5388        use edgefirst_tensor::is_dma_available;
5389        if !is_dma_available() {
5390            crate::test_support::report_skip(
5391                "test_load_png_grey_misaligned_width_dma — DMA not available",
5392            );
5393            return;
5394        }
5395        let png = make_grey_png(612, 388);
5396        let loaded = crate::load_image_test_helper(&png, Some(PixelFormat::Grey), None).unwrap();
5397        assert_eq!(loaded.width(), Some(612));
5398        assert_eq!(loaded.height(), Some(388));
5399        assert_eq!(loaded.format(), Some(PixelFormat::Grey));
5400
5401        // Round-trip pixels — natural-pitch DMA-BUFs pad the stride so we
5402        // must indirect through row_stride() rather than assume width.
5403        let map = loaded.as_typed::<u8>().unwrap().map().unwrap();
5404        let stride = loaded.row_stride().unwrap_or(612);
5405        assert!(stride >= 612);
5406        let bytes: &[u8] = &map;
5407        for y in 0..388usize {
5408            for x in 0..612usize {
5409                let expected = ((x + y) & 0xFF) as u8;
5410                let got = bytes[y * stride + x];
5411                assert_eq!(
5412                    got, expected,
5413                    "grey png mismatch at ({x},{y}): got {got} expected {expected}"
5414                );
5415            }
5416        }
5417    }
5418
5419    /// Greyscale PNG loaded with explicit Mem backing — runs on any
5420    /// platform (no DMA permission requirement) and covers the
5421    /// decoder-native Luma → Grey no-conversion path.
5422    #[test]
5423    fn test_load_png_grey_mem() {
5424        use edgefirst_tensor::TensorMemory;
5425        let png = make_grey_png(612, 100);
5426        let loaded =
5427            crate::load_image_test_helper(&png, Some(PixelFormat::Grey), Some(TensorMemory::Mem))
5428                .unwrap();
5429        assert_eq!(loaded.width(), Some(612));
5430        assert_eq!(loaded.height(), Some(100));
5431        assert_eq!(loaded.format(), Some(PixelFormat::Grey));
5432        let map = loaded.as_typed::<u8>().unwrap().map().unwrap();
5433        let bytes: &[u8] = &map;
5434        // Mem allocation uses the natural pitch — 612 bytes per row, exact.
5435        assert_eq!(bytes.len(), 612 * 100);
5436        for y in 0..100 {
5437            for x in 0..612 {
5438                assert_eq!(bytes[y * 612 + x], ((x + y) & 0xFF) as u8);
5439            }
5440        }
5441    }
5442
5443    /// Greyscale PNG decoded into RGB — exercises the decoder-colorspace
5444    /// mismatch path (Luma → Rgb via CPU converter). Uses Mem memory to
5445    /// stay portable to host-side test environments.
5446    #[test]
5447    fn test_load_png_grey_to_rgb_mem() {
5448        use edgefirst_tensor::TensorMemory;
5449        let png = make_grey_png(620, 240);
5450        let loaded =
5451            crate::load_image_test_helper(&png, Some(PixelFormat::Rgb), Some(TensorMemory::Mem))
5452                .unwrap();
5453        assert_eq!(loaded.width(), Some(620));
5454        assert_eq!(loaded.height(), Some(240));
5455        assert_eq!(loaded.format(), Some(PixelFormat::Rgb));
5456
5457        // Greyscale promoted to RGB replicates luma into each channel.
5458        let map = loaded.as_typed::<u8>().unwrap().map().unwrap();
5459        let bytes: &[u8] = &map;
5460        for (x, y) in [(0usize, 0usize), (100, 50), (619, 239)] {
5461            let expected = ((x + y) & 0xFF) as u8;
5462            let off = (y * 620 + x) * 3;
5463            assert_eq!(bytes[off], expected, "R@{x},{y}");
5464            assert_eq!(bytes[off + 1], expected, "G@{x},{y}");
5465            assert_eq!(bytes[off + 2], expected, "B@{x},{y}");
5466        }
5467    }
5468
5469    #[test]
5470    #[cfg(target_os = "linux")]
5471    fn test_g2d_resize() {
5472        if !is_g2d_available() {
5473            crate::test_support::report_skip(
5474                "test_g2d_resize - G2D library (libg2d.so.2) not available",
5475            );
5476            return;
5477        }
5478        if !is_dma_available() {
5479            crate::test_support::report_skip("test_g2d_resize - DMA memory allocation not available (permission denied or no DMA-BUF support)");
5480            return;
5481        }
5482
5483        let dst_width = 640;
5484        let dst_height = 360;
5485        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5486        let src = crate::load_image_test_helper(
5487            &file,
5488            Some(PixelFormat::Rgba),
5489            Some(TensorMemory::DmaBuf),
5490        )
5491        .unwrap();
5492
5493        let g2d_dst = TensorDyn::image(
5494            dst_width,
5495            dst_height,
5496            PixelFormat::Rgba,
5497            DType::U8,
5498            Some(TensorMemory::DmaBuf),
5499            edgefirst_tensor::CpuAccess::ReadWrite,
5500        )
5501        .unwrap();
5502        let mut g2d_converter = G2DProcessor::new().unwrap();
5503        let (result, src, g2d_dst) = convert_img(
5504            &mut g2d_converter,
5505            src,
5506            g2d_dst,
5507            Rotation::None,
5508            Flip::None,
5509            Crop::no_crop(),
5510        );
5511        result.unwrap();
5512
5513        let cpu_dst = TensorDyn::image(
5514            dst_width,
5515            dst_height,
5516            PixelFormat::Rgba,
5517            DType::U8,
5518            None,
5519            edgefirst_tensor::CpuAccess::ReadWrite,
5520        )
5521        .unwrap();
5522        let mut cpu_converter = CPUProcessor::new();
5523        let (result, _src, cpu_dst) = convert_img(
5524            &mut cpu_converter,
5525            src,
5526            cpu_dst,
5527            Rotation::None,
5528            Flip::None,
5529            Crop::no_crop(),
5530        );
5531        result.unwrap();
5532
5533        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
5534        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
5535        // the YUV-matrix delta that forced 0.95 has closed; tightened to
5536        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
5537        // structural gap not exercised by these limited-range fixtures.
5538        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
5539    }
5540
5541    #[test]
5542    #[cfg(any(
5543        target_os = "linux",
5544        target_os = "macos",
5545        target_os = "ios",
5546        target_os = "android",
5547        target_os = "windows"
5548    ))]
5549    #[cfg(feature = "opengl")]
5550    fn test_opengl_resize() {
5551        if !is_opengl_available() {
5552            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
5553            return;
5554        }
5555
5556        let dst_width = 640;
5557        let dst_height = 360;
5558        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5559        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5560
5561        let cpu_dst = TensorDyn::image(
5562            dst_width,
5563            dst_height,
5564            PixelFormat::Rgba,
5565            DType::U8,
5566            None,
5567            edgefirst_tensor::CpuAccess::ReadWrite,
5568        )
5569        .unwrap();
5570        let mut cpu_converter = CPUProcessor::new();
5571        let (result, src, cpu_dst) = convert_img(
5572            &mut cpu_converter,
5573            src,
5574            cpu_dst,
5575            Rotation::None,
5576            Flip::None,
5577            Crop::no_crop(),
5578        );
5579        result.unwrap();
5580
5581        let mut src = src;
5582        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
5583
5584        for _ in 0..5 {
5585            let gl_dst = TensorDyn::image(
5586                dst_width,
5587                dst_height,
5588                PixelFormat::Rgba,
5589                DType::U8,
5590                None,
5591                edgefirst_tensor::CpuAccess::ReadWrite,
5592            )
5593            .unwrap();
5594            let (result, src_back, gl_dst) = convert_img(
5595                &mut gl_converter,
5596                src,
5597                gl_dst,
5598                Rotation::None,
5599                Flip::None,
5600                Crop::no_crop(),
5601            );
5602            result.unwrap();
5603            src = src_back;
5604
5605            compare_images(&gl_dst, &cpu_dst, 0.98, function!());
5606        }
5607    }
5608
5609    #[test]
5610    #[cfg(any(
5611        target_os = "linux",
5612        target_os = "macos",
5613        target_os = "ios",
5614        target_os = "android",
5615        target_os = "windows"
5616    ))]
5617    #[cfg(feature = "opengl")]
5618    fn test_opengl_10_threads() {
5619        if !is_opengl_available() {
5620            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
5621            return;
5622        }
5623
5624        let handles: Vec<_> = (0..10)
5625            .map(|i| {
5626                std::thread::Builder::new()
5627                    .name(format!("Thread {i}"))
5628                    .spawn(test_opengl_resize)
5629                    .unwrap()
5630            })
5631            .collect();
5632        handles.into_iter().for_each(|h| {
5633            if let Err(e) = h.join() {
5634                std::panic::resume_unwind(e)
5635            }
5636        });
5637    }
5638
5639    #[test]
5640    #[cfg(any(
5641        target_os = "linux",
5642        target_os = "macos",
5643        target_os = "ios",
5644        target_os = "android",
5645        target_os = "windows"
5646    ))]
5647    #[cfg(feature = "opengl")]
5648    fn test_opengl_grey() {
5649        if !is_opengl_available() {
5650            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
5651            return;
5652        }
5653
5654        let img = crate::load_image_test_helper(
5655            &edgefirst_bench::testdata::read("grey.jpg"),
5656            Some(PixelFormat::Grey),
5657            None,
5658        )
5659        .unwrap();
5660
5661        let gl_dst = TensorDyn::image(
5662            640,
5663            640,
5664            PixelFormat::Grey,
5665            DType::U8,
5666            None,
5667            edgefirst_tensor::CpuAccess::ReadWrite,
5668        )
5669        .unwrap();
5670        let cpu_dst = TensorDyn::image(
5671            640,
5672            640,
5673            PixelFormat::Grey,
5674            DType::U8,
5675            None,
5676            edgefirst_tensor::CpuAccess::ReadWrite,
5677        )
5678        .unwrap();
5679
5680        let mut converter = CPUProcessor::new();
5681
5682        let (result, img, cpu_dst) = convert_img(
5683            &mut converter,
5684            img,
5685            cpu_dst,
5686            Rotation::None,
5687            Flip::None,
5688            Crop::no_crop(),
5689        );
5690        result.unwrap();
5691
5692        let mut gl = GLProcessorThreaded::new(None).unwrap();
5693        let (result, _img, gl_dst) = convert_img(
5694            &mut gl,
5695            img,
5696            gl_dst,
5697            Rotation::None,
5698            Flip::None,
5699            Crop::no_crop(),
5700        );
5701        result.unwrap();
5702
5703        compare_images(&gl_dst, &cpu_dst, 0.98, function!());
5704    }
5705
5706    #[test]
5707    #[cfg(target_os = "linux")]
5708    fn test_g2d_src_crop() {
5709        if !is_g2d_available() {
5710            crate::test_support::report_skip(
5711                "test_g2d_src_crop - G2D library (libg2d.so.2) not available",
5712            );
5713            return;
5714        }
5715        if !is_dma_available() {
5716            crate::test_support::report_skip("test_g2d_src_crop - DMA memory allocation not available (permission denied or no DMA-BUF support)");
5717            return;
5718        }
5719
5720        let dst_width = 640;
5721        let dst_height = 640;
5722        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5723        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5724
5725        let cpu_dst = TensorDyn::image(
5726            dst_width,
5727            dst_height,
5728            PixelFormat::Rgba,
5729            DType::U8,
5730            None,
5731            edgefirst_tensor::CpuAccess::ReadWrite,
5732        )
5733        .unwrap();
5734        let mut cpu_converter = CPUProcessor::new();
5735        let crop = Crop::new().with_source(Some(Region::new(0, 0, 640, 360)));
5736        let (result, src, cpu_dst) = convert_img(
5737            &mut cpu_converter,
5738            src,
5739            cpu_dst,
5740            Rotation::None,
5741            Flip::None,
5742            crop,
5743        );
5744        result.unwrap();
5745
5746        let 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        let (result, _src, g2d_dst) = convert_img(
5757            &mut g2d_converter,
5758            src,
5759            g2d_dst,
5760            Rotation::None,
5761            Flip::None,
5762            crop,
5763        );
5764        result.unwrap();
5765
5766        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
5767        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
5768        // the YUV-matrix delta that forced 0.95 has closed; tightened to
5769        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
5770        // structural gap not exercised by these limited-range fixtures.
5771        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
5772    }
5773
5774    #[test]
5775    #[cfg(target_os = "linux")]
5776    fn test_g2d_dst_crop() {
5777        if !is_g2d_available() {
5778            crate::test_support::report_skip(
5779                "test_g2d_dst_crop - G2D library (libg2d.so.2) not available",
5780            );
5781            return;
5782        }
5783        if !is_dma_available() {
5784            crate::test_support::report_skip("test_g2d_dst_crop - DMA memory allocation not available (permission denied or no DMA-BUF support)");
5785            return;
5786        }
5787
5788        let dst_width = 640;
5789        let dst_height = 640;
5790        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5791        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5792
5793        let cpu_dst = TensorDyn::image(
5794            dst_width,
5795            dst_height,
5796            PixelFormat::Rgba,
5797            DType::U8,
5798            None,
5799            edgefirst_tensor::CpuAccess::ReadWrite,
5800        )
5801        .unwrap();
5802        let mut cpu_converter = CPUProcessor::new();
5803        let crop = Crop::new();
5804        let (result, src, cpu_dst) = convert_img(
5805            &mut cpu_converter,
5806            src,
5807            cpu_dst,
5808            Rotation::None,
5809            Flip::None,
5810            crop,
5811        );
5812        result.unwrap();
5813
5814        let g2d_dst = TensorDyn::image(
5815            dst_width,
5816            dst_height,
5817            PixelFormat::Rgba,
5818            DType::U8,
5819            None,
5820            edgefirst_tensor::CpuAccess::ReadWrite,
5821        )
5822        .unwrap();
5823        let mut g2d_converter = G2DProcessor::new().unwrap();
5824        let (result, _src, g2d_dst) = convert_img(
5825            &mut g2d_converter,
5826            src,
5827            g2d_dst,
5828            Rotation::None,
5829            Flip::None,
5830            crop,
5831        );
5832        result.unwrap();
5833
5834        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
5835        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
5836        // the YUV-matrix delta that forced 0.95 has closed; tightened to
5837        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
5838        // structural gap not exercised by these limited-range fixtures.
5839        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
5840    }
5841
5842    #[test]
5843    #[cfg(target_os = "linux")]
5844    fn test_g2d_all_rgba() {
5845        if !is_g2d_available() {
5846            crate::test_support::report_skip(
5847                "test_g2d_all_rgba - G2D library (libg2d.so.2) not available",
5848            );
5849            return;
5850        }
5851        if !is_dma_available() {
5852            crate::test_support::report_skip("test_g2d_all_rgba - DMA memory allocation not available (permission denied or no DMA-BUF support)");
5853            return;
5854        }
5855
5856        let dst_width = 640;
5857        let dst_height = 640;
5858        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5859        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5860        let src_dyn = src;
5861
5862        let mut cpu_dst = TensorDyn::image(
5863            dst_width,
5864            dst_height,
5865            PixelFormat::Rgba,
5866            DType::U8,
5867            None,
5868            edgefirst_tensor::CpuAccess::ReadWrite,
5869        )
5870        .unwrap();
5871        let mut cpu_converter = CPUProcessor::new();
5872        let mut g2d_dst = TensorDyn::image(
5873            dst_width,
5874            dst_height,
5875            PixelFormat::Rgba,
5876            DType::U8,
5877            None,
5878            edgefirst_tensor::CpuAccess::ReadWrite,
5879        )
5880        .unwrap();
5881        let mut g2d_converter = G2DProcessor::new().unwrap();
5882
5883        let crop = Crop::new().with_source(Some(Region::new(50, 120, 1024, 576)));
5884
5885        for rot in [
5886            Rotation::None,
5887            Rotation::Clockwise90,
5888            Rotation::Rotate180,
5889            Rotation::CounterClockwise90,
5890        ] {
5891            cpu_dst
5892                .as_typed::<u8>()
5893                .unwrap()
5894                .map()
5895                .unwrap()
5896                .as_mut_slice()
5897                .fill(114);
5898            g2d_dst
5899                .as_typed::<u8>()
5900                .unwrap()
5901                .map()
5902                .unwrap()
5903                .as_mut_slice()
5904                .fill(114);
5905            for flip in [Flip::None, Flip::Horizontal, Flip::Vertical] {
5906                let mut cpu_dst_dyn = cpu_dst;
5907                cpu_converter
5908                    .convert(&src_dyn, &mut cpu_dst_dyn, Rotation::None, Flip::None, crop)
5909                    .unwrap();
5910                cpu_dst = {
5911                    let mut __t = cpu_dst_dyn.into_u8().unwrap();
5912                    __t.set_format(PixelFormat::Rgba).unwrap();
5913                    TensorDyn::from(__t)
5914                };
5915
5916                let mut g2d_dst_dyn = g2d_dst;
5917                g2d_converter
5918                    .convert(&src_dyn, &mut g2d_dst_dyn, Rotation::None, Flip::None, crop)
5919                    .unwrap();
5920                g2d_dst = {
5921                    let mut __t = g2d_dst_dyn.into_u8().unwrap();
5922                    __t.set_format(PixelFormat::Rgba).unwrap();
5923                    TensorDyn::from(__t)
5924                };
5925
5926                compare_images(
5927                    &g2d_dst,
5928                    &cpu_dst,
5929                    0.98,
5930                    &format!("{} {:?} {:?}", function!(), rot, flip),
5931                );
5932            }
5933        }
5934    }
5935
5936    #[test]
5937    #[cfg(any(
5938        target_os = "linux",
5939        target_os = "macos",
5940        target_os = "ios",
5941        target_os = "android",
5942        target_os = "windows"
5943    ))]
5944    #[cfg(feature = "opengl")]
5945    fn test_opengl_src_crop() {
5946        if !is_opengl_available() {
5947            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
5948            return;
5949        }
5950
5951        let dst_width = 640;
5952        let dst_height = 360;
5953        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5954        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5955        let crop = Crop::new().with_source(Some(Region::new(320, 180, 1280 - 320, 720 - 180)));
5956
5957        let cpu_dst = TensorDyn::image(
5958            dst_width,
5959            dst_height,
5960            PixelFormat::Rgba,
5961            DType::U8,
5962            None,
5963            edgefirst_tensor::CpuAccess::ReadWrite,
5964        )
5965        .unwrap();
5966        let mut cpu_converter = CPUProcessor::new();
5967        let (result, src, cpu_dst) = convert_img(
5968            &mut cpu_converter,
5969            src,
5970            cpu_dst,
5971            Rotation::None,
5972            Flip::None,
5973            crop,
5974        );
5975        result.unwrap();
5976
5977        let gl_dst = TensorDyn::image(
5978            dst_width,
5979            dst_height,
5980            PixelFormat::Rgba,
5981            DType::U8,
5982            None,
5983            edgefirst_tensor::CpuAccess::ReadWrite,
5984        )
5985        .unwrap();
5986        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
5987        let (result, _src, gl_dst) = convert_img(
5988            &mut gl_converter,
5989            src,
5990            gl_dst,
5991            Rotation::None,
5992            Flip::None,
5993            crop,
5994        );
5995        result.unwrap();
5996
5997        compare_images(&gl_dst, &cpu_dst, 0.98, function!());
5998    }
5999
6000    #[test]
6001    #[cfg(any(
6002        target_os = "linux",
6003        target_os = "macos",
6004        target_os = "ios",
6005        target_os = "android",
6006        target_os = "windows"
6007    ))]
6008    #[cfg(feature = "opengl")]
6009    fn test_opengl_dst_crop() {
6010        if !is_opengl_available() {
6011            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
6012            return;
6013        }
6014
6015        let dst_width = 640;
6016        let dst_height = 640;
6017        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
6018        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
6019
6020        let cpu_dst = TensorDyn::image(
6021            dst_width,
6022            dst_height,
6023            PixelFormat::Rgba,
6024            DType::U8,
6025            None,
6026            edgefirst_tensor::CpuAccess::ReadWrite,
6027        )
6028        .unwrap();
6029        let mut cpu_converter = CPUProcessor::new();
6030        let crop = Crop::new();
6031        let (result, src, cpu_dst) = convert_img(
6032            &mut cpu_converter,
6033            src,
6034            cpu_dst,
6035            Rotation::None,
6036            Flip::None,
6037            crop,
6038        );
6039        result.unwrap();
6040
6041        let gl_dst = TensorDyn::image(
6042            dst_width,
6043            dst_height,
6044            PixelFormat::Rgba,
6045            DType::U8,
6046            None,
6047            edgefirst_tensor::CpuAccess::ReadWrite,
6048        )
6049        .unwrap();
6050        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
6051        let (result, _src, gl_dst) = convert_img(
6052            &mut gl_converter,
6053            src,
6054            gl_dst,
6055            Rotation::None,
6056            Flip::None,
6057            crop,
6058        );
6059        result.unwrap();
6060
6061        compare_images(&gl_dst, &cpu_dst, 0.98, function!());
6062    }
6063    /// A deterministic RGBA8 `Mem` source: `R = x`, `G = y`, `B = x + y`,
6064    /// `A = 255`, each `& 255`. Written at the tensor's own row stride so a
6065    /// padded allocation still gets the pattern at the right columns.
6066    #[cfg(all(target_os = "windows", feature = "opengl"))]
6067    fn synthetic_rgba(w: usize, h: usize) -> TensorDyn {
6068        let t = TensorDyn::image(
6069            w,
6070            h,
6071            PixelFormat::Rgba,
6072            DType::U8,
6073            Some(TensorMemory::Mem),
6074            edgefirst_tensor::CpuAccess::ReadWrite,
6075        )
6076        .unwrap();
6077        fill_synthetic_rgba(&t);
6078        t
6079    }
6080
6081    /// Every pixel of `actual` equals `expected`, compared through the
6082    /// tensors' own row compaction so a padded texture and a tight `Mem`
6083    /// image compare as images. `compare_images` scores similarity and
6084    /// accepts anything at a 0.0 threshold; this is the exact check.
6085    #[cfg(all(target_os = "windows", feature = "opengl"))]
6086    fn assert_pixels_identical(actual: &TensorDyn, expected: &TensorDyn, name: &str) {
6087        assert_eq!(actual.shape(), expected.shape(), "{name}: shapes differ");
6088        let flat = |t: &TensorDyn| {
6089            let typed = t.as_typed::<u8>().expect("u8 image");
6090            let mut out = vec![0u8; t.shape().iter().product::<usize>()];
6091            typed.copy_to_flat(&mut out).expect("compact the rows");
6092            out
6093        };
6094        let (a, e) = (flat(actual), flat(expected));
6095        let mismatches = a.iter().zip(&e).filter(|(x, y)| x != y).count();
6096        assert_eq!(
6097            mismatches,
6098            0,
6099            "{name}: {mismatches} of {} bytes differ (first rows: {:?} vs {:?})",
6100            a.len(),
6101            &a[..16.min(a.len())],
6102            &e[..16.min(e.len())]
6103        );
6104    }
6105
6106    /// Write the `synthetic_rgba` pattern into any writable RGBA8 tensor,
6107    /// a texture included, at its own row stride.
6108    #[cfg(all(target_os = "windows", feature = "opengl"))]
6109    fn fill_synthetic_rgba(t: &TensorDyn) {
6110        let w = t.width().unwrap();
6111        let h = t.height().unwrap();
6112        let bound = t.as_typed::<u8>().unwrap();
6113        let stride = bound.effective_row_stride().unwrap_or(w * 4);
6114        let mut m = bound.map().unwrap();
6115        let px = m.as_mut_slice();
6116        for y in 0..h {
6117            for x in 0..w {
6118                let o = y * stride + x * 4;
6119                px[o] = x as u8;
6120                px[o + 1] = y as u8;
6121                px[o + 2] = (x + y) as u8;
6122                px[o + 3] = 255;
6123            }
6124        }
6125    }
6126
6127    /// Element-wise comparison of two float images of the same format and
6128    /// dtype.
6129    ///
6130    /// Both sides are read through `copy_to_flat`, the tensor's own row
6131    /// compaction, for the reason `compare_images` does: a Windows texture
6132    /// destination pads its rows, so reading the head of the mapping as a
6133    /// tight image would read padding as values. F16 values are widened with
6134    /// `half::f16::to_f64` before the comparison.
6135    #[cfg(all(target_os = "windows", feature = "opengl"))]
6136    fn compare_float_images(a: &TensorDyn, b: &TensorDyn, tol: f64, what: &str) {
6137        assert_eq!(a.format(), b.format(), "{what}: pixel formats differ");
6138        assert_eq!(a.dtype(), b.dtype(), "{what}: dtypes differ");
6139        assert_eq!(a.shape(), b.shape(), "{what}: shapes differ");
6140        let values = |t: &TensorDyn| -> Vec<f64> {
6141            let elems: usize = t.shape().iter().product();
6142            match t.dtype() {
6143                DType::F32 => {
6144                    let typed = t.as_typed::<f32>().expect("F32 image");
6145                    let mut bytes = vec![0u8; elems * 4];
6146                    typed
6147                        .copy_to_flat(&mut bytes)
6148                        .expect("compact the padded rows");
6149                    bytes
6150                        .as_chunks::<4>()
6151                        .0
6152                        .iter()
6153                        .map(|c| f32::from_le_bytes(*c) as f64)
6154                        .collect()
6155                }
6156                DType::F16 => {
6157                    let typed = t.as_typed::<half::f16>().expect("F16 image");
6158                    let mut bytes = vec![0u8; elems * 2];
6159                    typed
6160                        .copy_to_flat(&mut bytes)
6161                        .expect("compact the padded rows");
6162                    bytes
6163                        .as_chunks::<2>()
6164                        .0
6165                        .iter()
6166                        .map(|c| half::f16::from_le_bytes(*c).to_f64())
6167                        .collect()
6168                }
6169                other => panic!("{what}: {other:?} is not a float dtype"),
6170            }
6171        };
6172        let (av, bv) = (values(a), values(b));
6173        assert_eq!(av.len(), bv.len(), "{what}: element counts differ");
6174        for (i, (x, y)) in av.iter().zip(bv.iter()).enumerate() {
6175            assert!(
6176                (x - y).abs() <= tol,
6177                "{what}: element {i} differs by {} (gpu {x}, cpu {y}, tol {tol})",
6178                (x - y).abs()
6179            );
6180        }
6181    }
6182
6183    /// The float PBO readback has to land each destination row at the
6184    /// destination's own pitch, exactly as the u8 PBO readback does.
6185    ///
6186    /// The packed render target's rows ARE the destination's rows -- `(W/4,
6187    /// 3H)` RGBA16F texels, one plane row per surface row, for
6188    /// `PlanarRgb`/F16; `(W*3, H)` R32F texels, one image row per surface
6189    /// row, for `Rgb`/F32 -- and the read was tight whatever pitch the
6190    /// destination declared. A pool tensor narrowed with `configure_image`
6191    /// that keeps the pool's pitch is exactly that destination, so every row
6192    /// after the first landed short.
6193    ///
6194    /// Both routes the pitch can take are covered. `POOL_W`'s pitch is a
6195    /// whole number of texels for both formats, which `GL_PACK_ROW_LENGTH`
6196    /// expresses; the F16 case adds a 4-byte pad, which an 8-byte texel does
6197    /// not divide, so those rows are read tight and spread afterwards. The
6198    /// tight stride is the control: it must still take the read it always
6199    /// did.
6200    ///
6201    /// The oracle is the same convert into a tight PBO of the same geometry.
6202    /// `compare_float_images` reads both sides through `copy_to_flat`, the
6203    /// tensor's own row compaction, so it compares logical elements and not
6204    /// mapping offsets.
6205    #[test]
6206    #[cfg(all(target_os = "windows", feature = "opengl"))]
6207    fn windows_padded_float_pbo_rows_land_at_the_declared_stride() {
6208        if !is_opengl_available() {
6209            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
6210            return;
6211        }
6212        const W: usize = 64;
6213        const H: usize = 32;
6214        const POOL_W: usize = 96;
6215
6216        let mut gpu = ImageProcessor::new().unwrap();
6217        let support = gpu.supported_render_dtypes();
6218        let src = synthetic_rgba(W, H);
6219
6220        // (format, dtype, supported, the pitches to declare)
6221        let cases: [(PixelFormat, DType, bool, &[usize]); 2] = [
6222            (
6223                PixelFormat::PlanarRgb,
6224                DType::F16,
6225                support.f16,
6226                // Tight plane row, the pool's pitch (a whole texel count),
6227                // and a 4-byte pad that no 8-byte texel divides.
6228                &[W * 2, POOL_W * 2, W * 2 + 4],
6229            ),
6230            (
6231                PixelFormat::Rgb,
6232                DType::F32,
6233                support.f32,
6234                &[W * 3 * 4, POOL_W * 3 * 4],
6235            ),
6236        ];
6237
6238        for (fmt, dtype, supported, strides) in cases {
6239            if !supported {
6240                crate::test_support::report_skip(&format!(
6241                    "{} - {fmt:?}/{dtype:?} not reported",
6242                    function!()
6243                ));
6244                continue;
6245            }
6246            let Some(gl) = gpu.opengl.as_ref() else {
6247                crate::test_support::report_skip(&format!("{} - no GL processor", function!()));
6248                return;
6249            };
6250            // The oracle: a tight PBO of the target geometry, same route.
6251            let mut reference = match gl.create_pbo_image_dtype(W, H, fmt, dtype) {
6252                Ok(t) => t,
6253                Err(e) => {
6254                    crate::test_support::report_skip(&format!(
6255                        "{} - no float PBO images: {e:?}",
6256                        function!()
6257                    ));
6258                    return;
6259                }
6260            };
6261            assert_eq!(
6262                reference.memory(),
6263                TensorMemory::Pbo,
6264                "{fmt:?}/{dtype:?}: the destination must be a PBO to reach this readback"
6265            );
6266            gpu.convert(
6267                &src,
6268                &mut reference,
6269                Rotation::None,
6270                Flip::None,
6271                Crop::default(),
6272            )
6273            .unwrap_or_else(|e| panic!("{fmt:?}/{dtype:?}: tight convert failed: {e}"));
6274
6275            for &stride in strides {
6276                let label = format!("{fmt:?}/{dtype:?} at a {stride} B pitch");
6277                // Allocate at the pool width, then narrow the geometry and
6278                // declare the pitch, as the u8 stride tests do.
6279                let mut padded = gpu
6280                    .opengl
6281                    .as_ref()
6282                    .expect("GL processor")
6283                    .create_pbo_image_dtype(POOL_W, H, fmt, dtype)
6284                    .unwrap_or_else(|e| panic!("{label}: pool PBO: {e:?}"));
6285                padded
6286                    .configure_image(W, H, fmt)
6287                    .unwrap_or_else(|e| panic!("{label}: narrow the pool tensor: {e}"));
6288                padded
6289                    .set_row_stride(stride)
6290                    .unwrap_or_else(|e| panic!("{label}: declare the pitch: {e}"));
6291                assert_eq!(padded.effective_row_stride(), Some(stride), "{label}");
6292
6293                let before = gpu.convert_fallback_count();
6294                gpu.convert(
6295                    &src,
6296                    &mut padded,
6297                    Rotation::None,
6298                    Flip::None,
6299                    Crop::default(),
6300                )
6301                .unwrap_or_else(|e| panic!("{label}: padded convert failed: {e}"));
6302                // A CPU fallback writes the destination with the stride-aware
6303                // CPU writers and would pass without the GL readback running.
6304                assert_eq!(
6305                    gpu.convert_fallback_count(),
6306                    before,
6307                    "{label}: fell back to the CPU backend, so the float PBO readback under test never ran"
6308                );
6309
6310                let tol = if dtype == DType::F16 {
6311                    1.0 / 1024.0
6312                } else {
6313                    1e-6
6314                };
6315                compare_float_images(&padded, &reference, tol, &label);
6316            }
6317        }
6318    }
6319
6320    /// Every float destination layout the GL backend reports it can render
6321    /// must render zero-copy into the texture and match the CPU converter
6322    /// element for element: F16 and F32 planar (three and four planes),
6323    /// interleaved float RGB, and float RGBA.
6324    ///
6325    /// The destination memory is requested explicitly so each layout is
6326    /// exercised as a texture whatever `create_image`'s own allocation
6327    /// policy would pick for it; the policy itself is covered by
6328    /// `windows_create_image_prefers_textures_and_convert_with_fence_returns_a_set_event`.
6329    #[test]
6330    #[cfg(all(target_os = "windows", feature = "opengl"))]
6331    fn windows_float_destinations_match_the_cpu_converter_for_every_layout() {
6332        if !is_opengl_available() {
6333            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
6334            return;
6335        }
6336        if !edgefirst_tensor::is_gpu_buffer_available() {
6337            crate::test_support::report_skip(&format!(
6338                "{} - no zero-copy GPU buffers",
6339                function!()
6340            ));
6341            return;
6342        }
6343        let mut gpu = ImageProcessor::new().unwrap();
6344        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
6345            backend: ComputeBackend::Cpu,
6346            ..Default::default()
6347        })
6348        .unwrap();
6349        let support = gpu.supported_render_dtypes();
6350        eprintln!("{}: supported_render_dtypes = {support:?}", function!());
6351        let src = synthetic_rgba(640, 480);
6352        for (fmt, dtype, need) in [
6353            (PixelFormat::PlanarRgb, DType::F16, support.f16),
6354            (PixelFormat::PlanarRgb, DType::F32, support.f32),
6355            (PixelFormat::PlanarRgba, DType::F16, support.f16),
6356            (PixelFormat::PlanarRgba, DType::F32, support.f32),
6357            (PixelFormat::Rgb, DType::F16, support.f16),
6358            (PixelFormat::Rgb, DType::F32, support.f32),
6359            (PixelFormat::Rgba, DType::F16, support.f16),
6360            (PixelFormat::Rgba, DType::F32, support.f32),
6361        ] {
6362            if !need {
6363                eprintln!("{}: {fmt:?}/{dtype:?} not reported", function!());
6364                continue;
6365            }
6366            let mut dst_gpu = gpu
6367                .create_image(
6368                    640,
6369                    480,
6370                    fmt,
6371                    dtype,
6372                    Some(TensorMemory::DmaBuf),
6373                    edgefirst_tensor::CpuAccess::ReadWrite,
6374                )
6375                .unwrap();
6376            assert_eq!(
6377                dst_gpu.memory(),
6378                TensorMemory::DmaBuf,
6379                "{fmt:?}/{dtype:?} must be a texture"
6380            );
6381            let mut dst_cpu = TensorDyn::image(
6382                640,
6383                480,
6384                fmt,
6385                dtype,
6386                Some(TensorMemory::Mem),
6387                edgefirst_tensor::CpuAccess::ReadWrite,
6388            )
6389            .unwrap();
6390            let before = gpu.convert_fallback_count();
6391            gpu.convert(
6392                &src,
6393                &mut dst_gpu,
6394                Rotation::None,
6395                Flip::None,
6396                Crop::default(),
6397            )
6398            .unwrap();
6399            cpu.convert(
6400                &src,
6401                &mut dst_cpu,
6402                Rotation::None,
6403                Flip::None,
6404                Crop::default(),
6405            )
6406            .unwrap();
6407            assert_eq!(
6408                gpu.convert_fallback_count(),
6409                before,
6410                "{fmt:?}/{dtype:?} fell back to CPU"
6411            );
6412            let tol = if dtype == DType::F16 {
6413                1.0 / 1024.0
6414            } else {
6415                1e-6
6416            };
6417            compare_float_images(&dst_gpu, &dst_cpu, tol, &format!("{fmt:?}/{dtype:?}"));
6418        }
6419    }
6420
6421    // The tests below wait on a Windows completion fence directly, so they
6422    // need the raw wait call — no crate in this workspace wraps it.
6423    #[cfg(target_os = "windows")]
6424    #[link(name = "kernel32")]
6425    extern "system" {
6426        fn WaitForSingleObject(handle: *mut std::ffi::c_void, milliseconds: u32) -> u32;
6427    }
6428
6429    /// A batch of deferred converts records one distinct fence value per
6430    /// tile and submits once, at `flush`.
6431    ///
6432    /// The values are what a GPU consumer waits on, so they have to stay
6433    /// per-destination and strictly increasing; the submission is what
6434    /// batching exists to spare, so there must be exactly one of it. The
6435    /// destinations are compared against the same convert done standalone,
6436    /// which is what proves the deferred submission did not lose a tile.
6437    #[cfg(all(target_os = "windows", feature = "opengl"))]
6438    #[test]
6439    fn windows_deferred_batch_records_one_value_per_tile_and_submits_once() {
6440        use edgefirst_tensor::CpuAccess;
6441
6442        if !edgefirst_tensor::is_gpu_buffer_available() {
6443            crate::test_support::report_skip(&format!(
6444                "{} - no zero-copy GPU buffers",
6445                function!()
6446            ));
6447            return;
6448        }
6449        let mut p = match ImageProcessor::new() {
6450            Ok(p) => p,
6451            Err(e) => {
6452                crate::test_support::report_skip(&format!(
6453                    "{} - ImageProcessor init failed ({e:?})",
6454                    function!()
6455                ));
6456                return;
6457            }
6458        };
6459        let (w, h, tiles) = (640usize, 480usize, 4usize);
6460        let src = synthetic_rgba(w, h);
6461        let mut dsts: Vec<TensorDyn> = (0..tiles)
6462            .map(|_| {
6463                p.create_image(w, h, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
6464                    .unwrap()
6465            })
6466            .collect();
6467        if dsts[0].memory() != TensorMemory::DmaBuf {
6468            crate::test_support::report_skip(&format!(
6469                "{} - destinations are not textures",
6470                function!()
6471            ));
6472            return;
6473        }
6474        for dst in &mut dsts {
6475            p.convert_deferred(&src, dst, Rotation::None, Flip::None, Crop::default())
6476                .unwrap();
6477        }
6478        let values: Vec<u64> = dsts
6479            .iter()
6480            .map(|d| {
6481                d.gpu_completion()
6482                    .unwrap()
6483                    .expect("each deferred convert records its own completion")
6484                    .value
6485            })
6486            .collect();
6487        for pair in values.windows(2) {
6488            assert!(
6489                pair[1] > pair[0],
6490                "the batch allocated {values:?}, which is not one increasing value per tile"
6491            );
6492        }
6493        p.flush().unwrap();
6494        // The one submission the batch owed has happened, so every recorded
6495        // value is reachable.
6496        let dev = edgefirst_tensor::d3d11::device().unwrap();
6497        let last = *values.last().unwrap();
6498        let ev = dev.event_for(last).unwrap();
6499        // SAFETY: `ev` owns a live Win32 event handle for the duration of
6500        // this call.
6501        assert_eq!(
6502            unsafe {
6503                WaitForSingleObject(
6504                    std::os::windows::io::AsRawHandle::as_raw_handle(&ev) as _,
6505                    5000,
6506                )
6507            },
6508            0,
6509            "the batch's newest value {last} never arrived after flush"
6510        );
6511
6512        let mut solo = p
6513            .create_image(w, h, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
6514            .unwrap();
6515        p.convert(&src, &mut solo, Rotation::None, Flip::None, Crop::default())
6516            .unwrap();
6517        // Read through the tensor's own compaction: a Windows texture's
6518        // staging rows are pitch-padded, so a flat read of the mapping would
6519        // compare padding as pixels.
6520        let flat = |img: &TensorDyn| -> Vec<u8> {
6521            let t = img.as_typed::<u8>().expect("u8 image");
6522            let mut out = vec![0u8; t.shape().iter().product::<usize>()];
6523            t.copy_to_flat(&mut out).expect("compact the padded rows");
6524            out
6525        };
6526        let want = flat(&solo);
6527        for (i, dst) in dsts.iter().enumerate() {
6528            assert_eq!(
6529                flat(dst),
6530                want,
6531                "tile {i} does not match a standalone convert"
6532            );
6533        }
6534    }
6535
6536    /// A processor dropped with a batch still open submits what that batch
6537    /// recorded.
6538    ///
6539    /// The values are already published on destinations that outlive the
6540    /// processor, so a consumer may be waiting on one; a deferred signal that
6541    /// is never submitted is a wait nothing can satisfy. `flush` is what
6542    /// normally pays it, and the whole point here is that the caller never
6543    /// calls it.
6544    #[cfg(all(target_os = "windows", feature = "opengl"))]
6545    #[test]
6546    fn windows_dropping_a_processor_submits_an_open_batch() {
6547        use edgefirst_tensor::CpuAccess;
6548
6549        if !edgefirst_tensor::is_gpu_buffer_available() {
6550            crate::test_support::report_skip(&format!(
6551                "{} - no zero-copy GPU buffers",
6552                function!()
6553            ));
6554            return;
6555        }
6556        let src = synthetic_rgba(320, 240);
6557        let recorded = {
6558            let mut p = match ImageProcessor::new() {
6559                Ok(p) => p,
6560                Err(e) => {
6561                    crate::test_support::report_skip(&format!(
6562                        "{} - init failed ({e:?})",
6563                        function!()
6564                    ));
6565                    return;
6566                }
6567            };
6568            let mut dst = p
6569                .create_image(
6570                    320,
6571                    240,
6572                    PixelFormat::Rgba,
6573                    DType::U8,
6574                    None,
6575                    CpuAccess::Read,
6576                )
6577                .unwrap();
6578            if dst.memory() != TensorMemory::DmaBuf {
6579                crate::test_support::report_skip(&format!(
6580                    "{} - destination is not a texture",
6581                    function!()
6582                ));
6583                return;
6584            }
6585            p.convert_deferred(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
6586                .unwrap();
6587            // No `flush`: the drop below is what has to settle the batch.
6588            dst.gpu_completion()
6589                .unwrap()
6590                .expect("the deferred convert recorded a completion")
6591                .value
6592        };
6593
6594        let dev = edgefirst_tensor::d3d11::device().unwrap();
6595        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
6596        while dev.completed_value() < recorded {
6597            assert!(
6598                std::time::Instant::now() < deadline,
6599                "the dropped processor never submitted its batch: fence at {}, recorded {recorded}",
6600                dev.completed_value()
6601            );
6602            std::thread::yield_now();
6603        }
6604    }
6605
6606    /// After Task B5, `create_image(.., None, ..)` reaches the Windows
6607    /// Dma-first arm (a D3D11 texture, not a PBO or `Mem` tensor), and
6608    /// `convert_with_fence` is available on every platform: on Windows it
6609    /// returns a manual-reset event set when the GPU work completes.
6610    #[cfg(all(target_os = "windows", feature = "opengl"))]
6611    #[test]
6612    fn windows_create_image_prefers_textures_and_convert_with_fence_returns_a_set_event() {
6613        use edgefirst_tensor::CpuAccess;
6614        use std::os::windows::io::AsRawHandle;
6615
6616        if !edgefirst_tensor::is_gpu_buffer_available() {
6617            crate::test_support::report_skip(&format!(
6618                "{} - no zero-copy GPU buffers",
6619                function!()
6620            ));
6621            return;
6622        }
6623        let mut p = ImageProcessor::new().unwrap();
6624        let src = synthetic_rgba(320, 240);
6625        let mut dst = p
6626            .create_image(
6627                320,
6628                240,
6629                PixelFormat::Rgba,
6630                DType::U8,
6631                None,
6632                CpuAccess::Read,
6633            )
6634            .unwrap();
6635        // Without a GL backend (CI's no-ANGLE coverage pass) `create_image`
6636        // answers host memory and there is no fence to wait on; the lanes
6637        // that set HAL_TEST_REQUIRE_GL=1 still hold the texture-first claim.
6638        if dst.memory() != TensorMemory::DmaBuf
6639            && !std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1")
6640        {
6641            crate::test_support::report_skip(&format!(
6642                "{} - create_image gave {:?}, not a texture (no GL backend)",
6643                function!(),
6644                dst.memory()
6645            ));
6646            return;
6647        }
6648        assert_eq!(
6649            dst.memory(),
6650            TensorMemory::DmaBuf,
6651            "create_image(None) must prefer a texture on Windows"
6652        );
6653        // Read before the wait: the recorded value must be the one the fence
6654        // names, not an older point the destination happened to carry.
6655        let before = dst.gpu_completion().unwrap().map(|c| c.value);
6656        let fence = p
6657            .convert_with_fence(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
6658            .unwrap()
6659            .expect("Windows exports a fence");
6660        let recorded = dst
6661            .gpu_completion()
6662            .unwrap()
6663            .expect("the convert recorded a completion")
6664            .value;
6665        assert!(
6666            before.is_none_or(|b| recorded > b),
6667            "the fenced convert recorded a newer value than {before:?}"
6668        );
6669        // One flush and one signal for the whole call: the value the
6670        // destination carries is the newest the device has allocated, so
6671        // nothing was signalled after `record_completion`. Two signals (the
6672        // shape this replaced) would leave `last_signalled` one past it.
6673        assert_eq!(
6674            edgefirst_tensor::d3d11::device().unwrap().last_signalled(),
6675            recorded,
6676            "a fenced convert must signal exactly once"
6677        );
6678        // SAFETY: `fence` owns a live Win32 event handle returned by
6679        // `convert_with_fence`; WaitForSingleObject only reads the handle,
6680        // which stays valid for the duration of this call because `fence`
6681        // is not dropped until after it.
6682        assert_eq!(
6683            unsafe { WaitForSingleObject(fence.as_raw_handle() as _, 5000) },
6684            0,
6685            "fence was not set within 5 seconds"
6686        );
6687        // The event and the destination's own completion name the same
6688        // value, so a consumer may wait on either: the event fired, and the
6689        // device's completed value has reached the value `dst` reports.
6690        assert!(
6691            edgefirst_tensor::d3d11::device().unwrap().completed_value() >= recorded,
6692            "the event fired, so the recorded value {recorded} is complete"
6693        );
6694        assert_eq!(
6695            dst.gpu_completion().unwrap().map(|c| c.value),
6696            Some(recorded),
6697            "the destination still reports the value the event waited on"
6698        );
6699        let mut expected = TensorDyn::image(
6700            320,
6701            240,
6702            PixelFormat::Rgba,
6703            DType::U8,
6704            Some(TensorMemory::Mem),
6705            CpuAccess::ReadWrite,
6706        )
6707        .unwrap();
6708        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
6709            backend: ComputeBackend::Cpu,
6710            ..Default::default()
6711        })
6712        .unwrap();
6713        cpu.convert(
6714            &src,
6715            &mut expected,
6716            Rotation::None,
6717            Flip::None,
6718            Crop::default(),
6719        )
6720        .unwrap();
6721        compare_images(&dst, &expected, 0.0, "fenced convert");
6722    }
6723
6724    /// A Windows GL test's destination must be a texture; without a GL backend
6725    /// (CI's no-ANGLE coverage pass) `create_image` answers host memory and the
6726    /// test skips, unless the lane set HAL_TEST_REQUIRE_GL=1, which keeps the
6727    /// texture-first claim a hard assertion.
6728    #[cfg(all(target_os = "windows", feature = "opengl"))]
6729    fn texture_or_skip(t: &TensorDyn, test: &str) -> bool {
6730        if t.memory() == TensorMemory::DmaBuf {
6731            return true;
6732        }
6733        if std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1") {
6734            panic!(
6735                "{test}: create_image(None) must prefer a texture on Windows, got {:?}",
6736                t.memory()
6737            );
6738        }
6739        crate::test_support::report_skip(&format!(
6740            "{test} - create_image gave {:?}, not a texture (no GL backend)",
6741            t.memory()
6742        ));
6743        false
6744    }
6745
6746    /// A draw into a texture destination records a completion on it the way
6747    /// a convert does, so a device consumer can wait on the drawn frame. The
6748    /// destination is `CpuAccess::Read`: only the GL backend can write it,
6749    /// so a CPU fallback fails the draw outright instead of leaving the
6750    /// completion empty.
6751    #[cfg(all(target_os = "windows", feature = "opengl"))]
6752    #[test]
6753    fn windows_draw_decoded_masks_into_a_texture_records_a_completion() {
6754        use edgefirst_tensor::{CpuAccess, Segmentation};
6755        use ndarray::Array3;
6756
6757        if !edgefirst_tensor::is_gpu_buffer_available() {
6758            crate::test_support::report_skip(&format!(
6759                "{} - no zero-copy GPU buffers",
6760                function!()
6761            ));
6762            return;
6763        }
6764        let mut p = ImageProcessor::new().unwrap();
6765        let mut dst = p
6766            .create_image(64, 64, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
6767            .unwrap();
6768        if !texture_or_skip(&dst, function!()) {
6769            return;
6770        }
6771        assert!(
6772            dst.gpu_completion().unwrap().is_none(),
6773            "nothing has written this texture yet"
6774        );
6775
6776        let detect = DetectBox {
6777            bbox: [0.25, 0.25, 0.75, 0.75].into(),
6778            score: 0.99,
6779            label: 0,
6780        };
6781        let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
6782        let seg = Segmentation {
6783            segmentation: edgefirst_tensor::Tensor::from_arrayview3(seg_arr.view())
6784                .unwrap()
6785                .into(),
6786            xmin: 0.25,
6787            ymin: 0.25,
6788            xmax: 0.75,
6789            ymax: 0.75,
6790        };
6791        p.draw_decoded_masks(&mut dst, &[detect], &[seg], MaskOverlay::default())
6792            .expect("draw into a read-only texture");
6793
6794        let completion = dst
6795            .gpu_completion()
6796            .unwrap()
6797            .expect("the draw recorded no completion on dst");
6798        assert!(completion.value > 0, "completion value must be non-zero");
6799
6800        // The drawn frame is read back through the texture's staging copy;
6801        // `copy_to_flat` compacts its padded rows into tight RGBA pixels and
6802        // needs only a read mapping, which the read-only texture allows.
6803        // The corner is cleared and the box centre carries the mask colour.
6804        let mut flat = vec![0u8; 64 * 64 * 4];
6805        dst.as_typed::<u8>()
6806            .unwrap()
6807            .copy_to_flat(&mut flat)
6808            .expect("read the drawn texture");
6809        let px = |x: usize, y: usize| -> [u8; 4] {
6810            let o = (y * 64 + x) * 4;
6811            [flat[o], flat[o + 1], flat[o + 2], flat[o + 3]]
6812        };
6813        assert_eq!(px(2, 2), [0, 0, 0, 0], "corner (2,2) was not cleared");
6814        assert_ne!(px(32, 32), [0, 0, 0, 0], "centre (32,32) was not coloured");
6815    }
6816
6817    /// The full-GPU proto draw (forced GL, no CPU materialize) records a
6818    /// completion on a texture destination too.
6819    #[cfg(all(target_os = "windows", feature = "opengl"))]
6820    #[test]
6821    fn windows_draw_proto_masks_into_a_texture_records_a_completion() {
6822        use edgefirst_tensor::{CpuAccess, Tensor};
6823
6824        if !edgefirst_tensor::is_gpu_buffer_available() {
6825            crate::test_support::report_skip(&format!(
6826                "{} - no zero-copy GPU buffers",
6827                function!()
6828            ));
6829            return;
6830        }
6831        // Forcing OpenGL fails outright without a GL backend (CI's no-ANGLE
6832        // coverage pass); that is the same skip the texture guard below gives,
6833        // unless the lane set HAL_TEST_REQUIRE_GL=1.
6834        let mut p = match with_force_backend(Some("opengl"), ImageProcessor::new) {
6835            Ok(p) => p,
6836            Err(e) if !std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1") => {
6837                crate::test_support::report_skip(&format!(
6838                    "{} - no OpenGL backend: {e}",
6839                    function!()
6840                ));
6841                return;
6842            }
6843            Err(e) => panic!("forced OpenGL backend unavailable: {e}"),
6844        };
6845        let mut dst = p
6846            .create_image(64, 64, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
6847            .unwrap();
6848        if !texture_or_skip(&dst, function!()) {
6849            return;
6850        }
6851
6852        let detect = DetectBox {
6853            bbox: [0.25, 0.25, 0.75, 0.75].into(),
6854            score: 0.99,
6855            label: 0,
6856        };
6857        // One prototype channel of ones and a positive coefficient: every
6858        // pixel of the box is foreground.
6859        let proto = ProtoData {
6860            mask_coefficients: TensorDyn::F32(
6861                Tensor::<f32>::from_slice(&[1.0_f32], &[1, 1]).unwrap(),
6862            ),
6863            protos: TensorDyn::F32(
6864                Tensor::<f32>::from_slice(&vec![1.0_f32; 8 * 8], &[8, 8, 1]).unwrap(),
6865            ),
6866            layout: ProtoLayout::Nhwc,
6867        };
6868        p.draw_proto_masks(&mut dst, &[detect], &proto, MaskOverlay::default())
6869            .expect("forced-GL proto draw into a read-only texture");
6870
6871        let completion = dst
6872            .gpu_completion()
6873            .unwrap()
6874            .expect("the proto draw recorded no completion on dst");
6875        assert!(completion.value > 0, "completion value must be non-zero");
6876    }
6877
6878    /// An empty draw with a host-memory background onto a texture
6879    /// destination leaves the background in the texture, rows in order.
6880    /// The gradient pattern makes a missing or vertically flipped base
6881    /// layer differ from the expected image.
6882    #[cfg(all(target_os = "windows", feature = "opengl"))]
6883    #[test]
6884    fn windows_draw_decoded_masks_composites_a_host_background_onto_a_texture() {
6885        use edgefirst_tensor::CpuAccess;
6886
6887        if !edgefirst_tensor::is_gpu_buffer_available() {
6888            crate::test_support::report_skip(&format!(
6889                "{} - no zero-copy GPU buffers",
6890                function!()
6891            ));
6892            return;
6893        }
6894        let mut p = ImageProcessor::new().unwrap();
6895        let bg = synthetic_rgba(64, 48);
6896        let mut dst = p
6897            .create_image(64, 48, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
6898            .unwrap();
6899        if !texture_or_skip(&dst, function!()) {
6900            return;
6901        }
6902        p.draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::new().with_background(&bg))
6903            .expect("empty draw with a host background onto a texture");
6904        assert_pixels_identical(&dst, &bg, "host background onto a texture");
6905        assert!(dst.gpu_completion().unwrap().is_some());
6906    }
6907
6908    /// The same draw with the background in a texture of its own: the
6909    /// base layer is sampled from the background texture directly.
6910    #[cfg(all(target_os = "windows", feature = "opengl"))]
6911    #[test]
6912    fn windows_draw_decoded_masks_composites_a_texture_background_onto_a_texture() {
6913        use edgefirst_tensor::CpuAccess;
6914
6915        if !edgefirst_tensor::is_gpu_buffer_available() {
6916            crate::test_support::report_skip(&format!(
6917                "{} - no zero-copy GPU buffers",
6918                function!()
6919            ));
6920            return;
6921        }
6922        let mut p = ImageProcessor::new().unwrap();
6923        let expected = synthetic_rgba(64, 48);
6924        let bg = p
6925            .create_image(
6926                64,
6927                48,
6928                PixelFormat::Rgba,
6929                DType::U8,
6930                None,
6931                CpuAccess::ReadWrite,
6932            )
6933            .unwrap();
6934        if !texture_or_skip(&bg, function!()) {
6935            return;
6936        }
6937        fill_synthetic_rgba(&bg);
6938        let mut dst = p
6939            .create_image(64, 48, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
6940            .unwrap();
6941        assert_eq!(dst.memory(), TensorMemory::DmaBuf);
6942        p.draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::new().with_background(&bg))
6943            .expect("empty draw with a texture background onto a texture");
6944        assert_pixels_identical(&dst, &expected, "texture background onto a texture");
6945        assert!(dst.gpu_completion().unwrap().is_some());
6946    }
6947
6948    /// A draw into a `view()` of a texture fills that band and leaves its
6949    /// sibling alone. The GL draw declines a sub-region destination
6950    /// (`check_draw_dst_placement`) and the CPU backend, which offsets into
6951    /// the parent, takes it; the zero-copy arm would otherwise have cleared
6952    /// the parent's first rows instead of the view's.
6953    #[cfg(all(target_os = "windows", feature = "opengl"))]
6954    #[test]
6955    fn windows_draw_decoded_masks_into_a_texture_view_fills_only_its_band() {
6956        use edgefirst_tensor::CpuAccess;
6957
6958        if !edgefirst_tensor::is_gpu_buffer_available() {
6959            crate::test_support::report_skip(&format!(
6960                "{} - no zero-copy GPU buffers",
6961                function!()
6962            ));
6963            return;
6964        }
6965        let (w, h) = (32usize, 64usize);
6966        let mut p = ImageProcessor::new().unwrap();
6967        let parent = p
6968            .create_image(
6969                w,
6970                h,
6971                PixelFormat::Rgba,
6972                DType::U8,
6973                None,
6974                CpuAccess::ReadWrite,
6975            )
6976            .unwrap();
6977        if !texture_or_skip(&parent, function!()) {
6978            return;
6979        }
6980        fill_synthetic_rgba(&parent);
6981
6982        // The lower band only. An empty draw with no background clears its
6983        // destination, so a write at the parent's origin would show up as
6984        // cleared pixels in the upper band.
6985        let mut band = parent
6986            .view(edgefirst_tensor::Region::new(0, h / 2, w, h / 2))
6987            .unwrap();
6988        p.draw_decoded_masks(&mut band, &[], &[], MaskOverlay::default())
6989            .expect("empty draw into a texture view");
6990        drop(band);
6991
6992        let mut flat = vec![0u8; w * h * 4];
6993        parent
6994            .as_typed::<u8>()
6995            .unwrap()
6996            .copy_to_flat(&mut flat)
6997            .expect("read the parent texture");
6998        for y in 0..h {
6999            for x in 0..w {
7000                let o = (y * w + x) * 4;
7001                let got = [flat[o], flat[o + 1], flat[o + 2], flat[o + 3]];
7002                let want = if y < h / 2 {
7003                    [x as u8, y as u8, (x + y) as u8, 255]
7004                } else {
7005                    [0, 0, 0, 0]
7006                };
7007                assert_eq!(got, want, "pixel ({x},{y})");
7008            }
7009        }
7010
7011        // The GL decline is what sent the draw above to the CPU: with GL
7012        // forced there is no CPU arm to fall through to, so it surfaces.
7013        let mut forced = with_force_backend(Some("opengl"), ImageProcessor::new).unwrap();
7014        let mut band = parent
7015            .view(edgefirst_tensor::Region::new(0, h / 2, w, h / 2))
7016            .unwrap();
7017        let err = forced
7018            .draw_decoded_masks(&mut band, &[], &[], MaskOverlay::default())
7019            .expect_err("forced GL must decline a view destination");
7020        assert!(
7021            matches!(err, Error::NotSupported(_)),
7022            "expected NotSupported, got {err:?}"
7023        );
7024    }
7025
7026    /// `set_class_colors` reaches the CPU backend even when GL accepts it, so
7027    /// a draw GL declines still renders the caller's palette. The view
7028    /// destination is the reachable decline: `check_draw_dst_placement`
7029    /// refuses it and the CPU backend draws the mask.
7030    #[cfg(all(target_os = "windows", feature = "opengl"))]
7031    #[test]
7032    fn windows_class_colors_reach_the_cpu_backend_behind_a_declined_gl_draw() {
7033        use edgefirst_tensor::{CpuAccess, Segmentation};
7034        use ndarray::Array3;
7035
7036        if !edgefirst_tensor::is_gpu_buffer_available() {
7037            crate::test_support::report_skip(&format!(
7038                "{} - no zero-copy GPU buffers",
7039                function!()
7040            ));
7041            return;
7042        }
7043        const CUSTOM: [u8; 4] = [7, 200, 13, 255];
7044        assert_ne!(
7045            CUSTOM[..3],
7046            DEFAULT_COLORS_U8[0][..3],
7047            "the custom colour must differ from the default palette's, or \
7048             this test cannot tell them apart"
7049        );
7050
7051        let (w, h) = (32usize, 64usize);
7052        let mut p = ImageProcessor::new().unwrap();
7053        let parent = p
7054            .create_image(
7055                w,
7056                h,
7057                PixelFormat::Rgba,
7058                DType::U8,
7059                None,
7060                CpuAccess::ReadWrite,
7061            )
7062            .unwrap();
7063        // Without a GL backend (CI's no-ANGLE coverage pass) `create_image`
7064        // answers host memory and nothing declines the draw; the test needs
7065        // the texture-backed parent so the view is the reachable decline. The
7066        // lanes that set HAL_TEST_REQUIRE_GL=1 still require the texture.
7067        if parent.memory() != TensorMemory::DmaBuf
7068            && !std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1")
7069        {
7070            crate::test_support::report_skip(&format!(
7071                "{} - create_image gave {:?}, not a texture (no GL backend)",
7072                function!(),
7073                parent.memory()
7074            ));
7075            return;
7076        }
7077        p.set_class_colors(&[CUSTOM]).unwrap();
7078
7079        let detect = DetectBox {
7080            bbox: [0.25, 0.25, 0.75, 0.75].into(),
7081            score: 0.99,
7082            label: 0,
7083        };
7084        let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
7085        let seg = Segmentation {
7086            segmentation: edgefirst_tensor::Tensor::from_arrayview3(seg_arr.view())
7087                .unwrap()
7088                .into(),
7089            xmin: 0.25,
7090            ymin: 0.25,
7091            xmax: 0.75,
7092            ymax: 0.75,
7093        };
7094        let mut band = parent
7095            .view(edgefirst_tensor::Region::new(0, h / 2, w, h / 2))
7096            .unwrap();
7097        p.draw_decoded_masks(&mut band, &[detect], &[seg], MaskOverlay::default())
7098            .expect("draw into a texture view");
7099        drop(band);
7100
7101        let mut flat = vec![0u8; w * h * 4];
7102        parent
7103            .as_typed::<u8>()
7104            .unwrap()
7105            .copy_to_flat(&mut flat)
7106            .expect("read the parent texture");
7107        // The band's centre, inside the box. The mask blend writes RGB only;
7108        // alpha is whatever the base layer left, which is the clear.
7109        let o = ((h / 2 + h / 4) * w + w / 2) * 4;
7110        assert_eq!(
7111            &flat[o..o + 3],
7112            &CUSTOM[..3],
7113            "the CPU backend drew the default palette, so it never received \
7114             set_class_colors"
7115        );
7116    }
7117
7118    /// Two independently-created `ImageProcessor`s, each converting into its
7119    /// own texture destination in lockstep, must not bleed GL state into
7120    /// each other across 200 iterations (the destination created by
7121    /// `create_image(None)` is the texture arm this task adds).
7122    #[cfg(all(target_os = "windows", feature = "opengl"))]
7123    #[test]
7124    fn two_processors_alternate_on_texture_destinations_without_state_bleed() {
7125        use edgefirst_tensor::CpuAccess;
7126
7127        if !edgefirst_tensor::is_gpu_buffer_available() {
7128            crate::test_support::report_skip(&format!(
7129                "{} - no zero-copy GPU buffers",
7130                function!()
7131            ));
7132            return;
7133        }
7134        let mut a = ImageProcessor::new().unwrap();
7135        let mut b = ImageProcessor::new().unwrap();
7136        let src_a = synthetic_rgba(512, 512);
7137        let src_b = synthetic_rgba(256, 256);
7138        let mut dst_a = a
7139            .create_image(
7140                512,
7141                512,
7142                PixelFormat::Rgba,
7143                DType::U8,
7144                None,
7145                CpuAccess::Read,
7146            )
7147            .unwrap();
7148        let mut dst_b = b
7149            .create_image(
7150                256,
7151                256,
7152                PixelFormat::Rgba,
7153                DType::U8,
7154                None,
7155                CpuAccess::Read,
7156            )
7157            .unwrap();
7158        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
7159            backend: ComputeBackend::Cpu,
7160            ..Default::default()
7161        })
7162        .unwrap();
7163        let mut ref_a = TensorDyn::image(
7164            512,
7165            512,
7166            PixelFormat::Rgba,
7167            DType::U8,
7168            Some(TensorMemory::Mem),
7169            CpuAccess::ReadWrite,
7170        )
7171        .unwrap();
7172        let mut ref_b = TensorDyn::image(
7173            256,
7174            256,
7175            PixelFormat::Rgba,
7176            DType::U8,
7177            Some(TensorMemory::Mem),
7178            CpuAccess::ReadWrite,
7179        )
7180        .unwrap();
7181        cpu.convert(
7182            &src_a,
7183            &mut ref_a,
7184            Rotation::None,
7185            Flip::None,
7186            Crop::default(),
7187        )
7188        .unwrap();
7189        cpu.convert(
7190            &src_b,
7191            &mut ref_b,
7192            Rotation::None,
7193            Flip::None,
7194            Crop::default(),
7195        )
7196        .unwrap();
7197        for _ in 0..200 {
7198            a.convert(
7199                &src_a,
7200                &mut dst_a,
7201                Rotation::None,
7202                Flip::None,
7203                Crop::default(),
7204            )
7205            .unwrap();
7206            b.convert(
7207                &src_b,
7208                &mut dst_b,
7209                Rotation::None,
7210                Flip::None,
7211                Crop::default(),
7212            )
7213            .unwrap();
7214            compare_images(&dst_a, &ref_a, 0.0, "processor A");
7215            compare_images(&dst_b, &ref_b, 0.0, "processor B");
7216        }
7217    }
7218
7219    #[test]
7220    #[cfg(any(
7221        target_os = "linux",
7222        target_os = "macos",
7223        target_os = "ios",
7224        target_os = "android",
7225        target_os = "windows"
7226    ))]
7227    #[cfg(feature = "opengl")]
7228    fn test_opengl_all_rgba() {
7229        if !is_opengl_available() {
7230            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
7231            return;
7232        }
7233
7234        let dst_width = 640;
7235        let dst_height = 640;
7236        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
7237
7238        let mut cpu_converter = CPUProcessor::new();
7239
7240        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
7241
7242        // Every backing this host can allocate: Shm is Unix-only, and the
7243        // zero-copy slot is DMA-BUF on Linux / IOSurface on macOS (nothing on
7244        // Windows yet).
7245        let mut mem = vec![None, Some(TensorMemory::Mem)];
7246        if edgefirst_tensor::is_shm_available() {
7247            mem.push(Some(TensorMemory::Shm));
7248        }
7249        if edgefirst_tensor::is_gpu_buffer_available() {
7250            mem.push(Some(TensorMemory::DmaBuf));
7251        }
7252        let crop = Crop::new().with_source(Some(Region::new(50, 120, 1024, 576)));
7253        for m in mem {
7254            let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), m).unwrap();
7255            let src_dyn = src;
7256
7257            for rot in [
7258                Rotation::None,
7259                Rotation::Clockwise90,
7260                Rotation::Rotate180,
7261                Rotation::CounterClockwise90,
7262            ] {
7263                for flip in [Flip::None, Flip::Horizontal, Flip::Vertical] {
7264                    let cpu_dst = TensorDyn::image(
7265                        dst_width,
7266                        dst_height,
7267                        PixelFormat::Rgba,
7268                        DType::U8,
7269                        m,
7270                        edgefirst_tensor::CpuAccess::ReadWrite,
7271                    )
7272                    .unwrap();
7273                    let gl_dst = TensorDyn::image(
7274                        dst_width,
7275                        dst_height,
7276                        PixelFormat::Rgba,
7277                        DType::U8,
7278                        m,
7279                        edgefirst_tensor::CpuAccess::ReadWrite,
7280                    )
7281                    .unwrap();
7282                    cpu_dst
7283                        .as_typed::<u8>()
7284                        .unwrap()
7285                        .map()
7286                        .unwrap()
7287                        .as_mut_slice()
7288                        .fill(114);
7289                    gl_dst
7290                        .as_typed::<u8>()
7291                        .unwrap()
7292                        .map()
7293                        .unwrap()
7294                        .as_mut_slice()
7295                        .fill(114);
7296
7297                    let mut cpu_dst_dyn = cpu_dst;
7298                    cpu_converter
7299                        .convert(&src_dyn, &mut cpu_dst_dyn, Rotation::None, Flip::None, crop)
7300                        .unwrap();
7301                    let cpu_dst = {
7302                        let mut __t = cpu_dst_dyn.into_u8().unwrap();
7303                        __t.set_format(PixelFormat::Rgba).unwrap();
7304                        TensorDyn::from(__t)
7305                    };
7306
7307                    let mut gl_dst_dyn = gl_dst;
7308                    gl_converter
7309                        .convert(&src_dyn, &mut gl_dst_dyn, Rotation::None, Flip::None, crop)
7310                        .map_err(|e| {
7311                            log::error!("error mem {m:?} rot {rot:?} error: {e:?}");
7312                            e
7313                        })
7314                        .unwrap();
7315                    let gl_dst = {
7316                        let mut __t = gl_dst_dyn.into_u8().unwrap();
7317                        __t.set_format(PixelFormat::Rgba).unwrap();
7318                        TensorDyn::from(__t)
7319                    };
7320
7321                    compare_images(
7322                        &gl_dst,
7323                        &cpu_dst,
7324                        0.98,
7325                        &format!("{} {:?} {:?}", function!(), rot, flip),
7326                    );
7327                }
7328            }
7329        }
7330    }
7331
7332    #[test]
7333    #[cfg(target_os = "linux")]
7334    fn test_cpu_rotate() {
7335        for rot in [
7336            Rotation::Clockwise90,
7337            Rotation::Rotate180,
7338            Rotation::CounterClockwise90,
7339        ] {
7340            test_cpu_rotate_(rot);
7341        }
7342    }
7343
7344    #[cfg(target_os = "linux")]
7345    fn test_cpu_rotate_(rot: Rotation) {
7346        // This test rotates the image 4 times and checks that the image was returned to
7347        // be the same Currently doesn't check if rotations actually rotated in
7348        // right direction
7349        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
7350
7351        let unchanged_src =
7352            crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
7353        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
7354
7355        let (dst_width, dst_height) = match rot {
7356            Rotation::None | Rotation::Rotate180 => (src.width().unwrap(), src.height().unwrap()),
7357            Rotation::Clockwise90 | Rotation::CounterClockwise90 => {
7358                (src.height().unwrap(), src.width().unwrap())
7359            }
7360        };
7361
7362        let cpu_dst = TensorDyn::image(
7363            dst_width,
7364            dst_height,
7365            PixelFormat::Rgba,
7366            DType::U8,
7367            None,
7368            edgefirst_tensor::CpuAccess::ReadWrite,
7369        )
7370        .unwrap();
7371        let mut cpu_converter = CPUProcessor::new();
7372
7373        // After rotating 4 times, the image should be the same as the original
7374
7375        let (result, src, cpu_dst) = convert_img(
7376            &mut cpu_converter,
7377            src,
7378            cpu_dst,
7379            rot,
7380            Flip::None,
7381            Crop::no_crop(),
7382        );
7383        result.unwrap();
7384
7385        let (result, cpu_dst, src) = convert_img(
7386            &mut cpu_converter,
7387            cpu_dst,
7388            src,
7389            rot,
7390            Flip::None,
7391            Crop::no_crop(),
7392        );
7393        result.unwrap();
7394
7395        let (result, src, cpu_dst) = convert_img(
7396            &mut cpu_converter,
7397            src,
7398            cpu_dst,
7399            rot,
7400            Flip::None,
7401            Crop::no_crop(),
7402        );
7403        result.unwrap();
7404
7405        let (result, _cpu_dst, src) = convert_img(
7406            &mut cpu_converter,
7407            cpu_dst,
7408            src,
7409            rot,
7410            Flip::None,
7411            Crop::no_crop(),
7412        );
7413        result.unwrap();
7414
7415        compare_images(&src, &unchanged_src, 0.98, function!());
7416    }
7417
7418    #[test]
7419    #[cfg(any(
7420        target_os = "linux",
7421        target_os = "macos",
7422        target_os = "ios",
7423        target_os = "android",
7424        target_os = "windows"
7425    ))]
7426    #[cfg(feature = "opengl")]
7427    fn test_opengl_rotate() {
7428        if !is_opengl_available() {
7429            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
7430            return;
7431        }
7432
7433        let size = (1280, 720);
7434        // Same backing list as `test_opengl_all_rgba`.
7435        let mut mem = vec![None, Some(TensorMemory::Mem)];
7436        if edgefirst_tensor::is_shm_available() {
7437            mem.push(Some(TensorMemory::Shm));
7438        }
7439        if edgefirst_tensor::is_gpu_buffer_available() {
7440            mem.push(Some(TensorMemory::DmaBuf));
7441        }
7442        for m in mem {
7443            for rot in [
7444                Rotation::Clockwise90,
7445                Rotation::Rotate180,
7446                Rotation::CounterClockwise90,
7447            ] {
7448                test_opengl_rotate_(size, rot, m);
7449            }
7450        }
7451    }
7452
7453    #[cfg(any(
7454        target_os = "linux",
7455        target_os = "macos",
7456        target_os = "ios",
7457        target_os = "android",
7458        target_os = "windows"
7459    ))]
7460    #[cfg(feature = "opengl")]
7461    fn test_opengl_rotate_(
7462        size: (usize, usize),
7463        rot: Rotation,
7464        tensor_memory: Option<TensorMemory>,
7465    ) {
7466        let (dst_width, dst_height) = match rot {
7467            Rotation::None | Rotation::Rotate180 => size,
7468            Rotation::Clockwise90 | Rotation::CounterClockwise90 => (size.1, size.0),
7469        };
7470
7471        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
7472        let src =
7473            crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), tensor_memory).unwrap();
7474
7475        let cpu_dst = TensorDyn::image(
7476            dst_width,
7477            dst_height,
7478            PixelFormat::Rgba,
7479            DType::U8,
7480            None,
7481            edgefirst_tensor::CpuAccess::ReadWrite,
7482        )
7483        .unwrap();
7484        let mut cpu_converter = CPUProcessor::new();
7485
7486        let (result, mut src, cpu_dst) = convert_img(
7487            &mut cpu_converter,
7488            src,
7489            cpu_dst,
7490            rot,
7491            Flip::None,
7492            Crop::no_crop(),
7493        );
7494        result.unwrap();
7495
7496        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
7497
7498        for _ in 0..5 {
7499            let gl_dst = TensorDyn::image(
7500                dst_width,
7501                dst_height,
7502                PixelFormat::Rgba,
7503                DType::U8,
7504                tensor_memory,
7505                edgefirst_tensor::CpuAccess::ReadWrite,
7506            )
7507            .unwrap();
7508            let (result, src_back, gl_dst) = convert_img(
7509                &mut gl_converter,
7510                src,
7511                gl_dst,
7512                rot,
7513                Flip::None,
7514                Crop::no_crop(),
7515            );
7516            result.unwrap();
7517            src = src_back;
7518            compare_images(&gl_dst, &cpu_dst, 0.98, function!());
7519        }
7520    }
7521
7522    #[test]
7523    #[cfg(target_os = "linux")]
7524    fn test_g2d_rotate() {
7525        if !is_g2d_available() {
7526            crate::test_support::report_skip(
7527                "test_g2d_rotate - G2D library (libg2d.so.2) not available",
7528            );
7529            return;
7530        }
7531        if !is_dma_available() {
7532            crate::test_support::report_skip("test_g2d_rotate - DMA memory allocation not available (permission denied or no DMA-BUF support)");
7533            return;
7534        }
7535
7536        let size = (1280, 720);
7537        for rot in [
7538            Rotation::Clockwise90,
7539            Rotation::Rotate180,
7540            Rotation::CounterClockwise90,
7541        ] {
7542            test_g2d_rotate_(size, rot);
7543        }
7544    }
7545
7546    #[cfg(target_os = "linux")]
7547    fn test_g2d_rotate_(size: (usize, usize), rot: Rotation) {
7548        let (dst_width, dst_height) = match rot {
7549            Rotation::None | Rotation::Rotate180 => size,
7550            Rotation::Clockwise90 | Rotation::CounterClockwise90 => (size.1, size.0),
7551        };
7552
7553        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
7554        let src = crate::load_image_test_helper(
7555            &file,
7556            Some(PixelFormat::Rgba),
7557            Some(TensorMemory::DmaBuf),
7558        )
7559        .unwrap();
7560
7561        let cpu_dst = TensorDyn::image(
7562            dst_width,
7563            dst_height,
7564            PixelFormat::Rgba,
7565            DType::U8,
7566            None,
7567            edgefirst_tensor::CpuAccess::ReadWrite,
7568        )
7569        .unwrap();
7570        let mut cpu_converter = CPUProcessor::new();
7571
7572        let (result, src, cpu_dst) = convert_img(
7573            &mut cpu_converter,
7574            src,
7575            cpu_dst,
7576            rot,
7577            Flip::None,
7578            Crop::no_crop(),
7579        );
7580        result.unwrap();
7581
7582        let g2d_dst = TensorDyn::image(
7583            dst_width,
7584            dst_height,
7585            PixelFormat::Rgba,
7586            DType::U8,
7587            Some(TensorMemory::DmaBuf),
7588            edgefirst_tensor::CpuAccess::ReadWrite,
7589        )
7590        .unwrap();
7591        let mut g2d_converter = G2DProcessor::new().unwrap();
7592
7593        let (result, _src, g2d_dst) = convert_img(
7594            &mut g2d_converter,
7595            src,
7596            g2d_dst,
7597            rot,
7598            Flip::None,
7599            Crop::no_crop(),
7600        );
7601        result.unwrap();
7602
7603        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
7604        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
7605        // the YUV-matrix delta that forced 0.95 has closed; tightened to
7606        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
7607        // structural gap not exercised by these limited-range fixtures.
7608        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
7609    }
7610
7611    #[test]
7612    fn test_rgba_to_yuyv_resize_cpu() {
7613        let src = load_bytes_to_tensor(
7614            1280,
7615            720,
7616            PixelFormat::Rgba,
7617            None,
7618            &edgefirst_bench::testdata::read("camera720p.rgba"),
7619        )
7620        .unwrap();
7621
7622        let (dst_width, dst_height) = (640, 360);
7623
7624        let dst = TensorDyn::image(
7625            dst_width,
7626            dst_height,
7627            PixelFormat::Yuyv,
7628            DType::U8,
7629            None,
7630            edgefirst_tensor::CpuAccess::ReadWrite,
7631        )
7632        .unwrap();
7633
7634        let dst_through_yuyv = TensorDyn::image(
7635            dst_width,
7636            dst_height,
7637            PixelFormat::Rgba,
7638            DType::U8,
7639            None,
7640            edgefirst_tensor::CpuAccess::ReadWrite,
7641        )
7642        .unwrap();
7643        let dst_direct = TensorDyn::image(
7644            dst_width,
7645            dst_height,
7646            PixelFormat::Rgba,
7647            DType::U8,
7648            None,
7649            edgefirst_tensor::CpuAccess::ReadWrite,
7650        )
7651        .unwrap();
7652
7653        let mut cpu_converter = CPUProcessor::new();
7654
7655        let (result, src, dst) = convert_img(
7656            &mut cpu_converter,
7657            src,
7658            dst,
7659            Rotation::None,
7660            Flip::None,
7661            Crop::no_crop(),
7662        );
7663        result.unwrap();
7664
7665        let (result, _dst, dst_through_yuyv) = convert_img(
7666            &mut cpu_converter,
7667            dst,
7668            dst_through_yuyv,
7669            Rotation::None,
7670            Flip::None,
7671            Crop::no_crop(),
7672        );
7673        result.unwrap();
7674
7675        let (result, _src, dst_direct) = convert_img(
7676            &mut cpu_converter,
7677            src,
7678            dst_direct,
7679            Rotation::None,
7680            Flip::None,
7681            Crop::no_crop(),
7682        );
7683        result.unwrap();
7684
7685        compare_images(&dst_through_yuyv, &dst_direct, 0.98, function!());
7686    }
7687
7688    #[test]
7689    #[cfg(any(
7690        target_os = "linux",
7691        target_os = "macos",
7692        target_os = "ios",
7693        target_os = "android",
7694        target_os = "windows"
7695    ))]
7696    #[cfg(feature = "opengl")]
7697    #[ignore = "opengl doesn't support rendering to PixelFormat::Yuyv texture"]
7698    fn test_rgba_to_yuyv_resize_opengl() {
7699        if !is_opengl_available() {
7700            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
7701            return;
7702        }
7703
7704        if !edgefirst_tensor::is_gpu_buffer_available() {
7705            crate::test_support::report_skip(&format!(
7706                "{} - zero-copy GPU buffer (DMA-BUF / IOSurface) not available on this host",
7707                function!()
7708            ));
7709            return;
7710        }
7711
7712        let src = load_bytes_to_tensor(
7713            1280,
7714            720,
7715            PixelFormat::Rgba,
7716            None,
7717            &edgefirst_bench::testdata::read("camera720p.rgba"),
7718        )
7719        .unwrap();
7720
7721        let (dst_width, dst_height) = (640, 360);
7722
7723        let dst = TensorDyn::image(
7724            dst_width,
7725            dst_height,
7726            PixelFormat::Yuyv,
7727            DType::U8,
7728            Some(TensorMemory::DmaBuf),
7729            edgefirst_tensor::CpuAccess::ReadWrite,
7730        )
7731        .unwrap();
7732
7733        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
7734
7735        let (result, src, dst) = convert_img(
7736            &mut gl_converter,
7737            src,
7738            dst,
7739            Rotation::None,
7740            Flip::None,
7741            Crop::letterbox([255, 255, 255, 255]),
7742        );
7743        result.unwrap();
7744
7745        std::fs::write(
7746            "rgba_to_yuyv_opengl.yuyv",
7747            dst.as_typed::<u8>().unwrap().map().unwrap().as_slice(),
7748        )
7749        .unwrap();
7750        let cpu_dst = TensorDyn::image(
7751            dst_width,
7752            dst_height,
7753            PixelFormat::Yuyv,
7754            DType::U8,
7755            Some(TensorMemory::DmaBuf),
7756            edgefirst_tensor::CpuAccess::ReadWrite,
7757        )
7758        .unwrap();
7759        let (result, _src, cpu_dst) = convert_img(
7760            &mut CPUProcessor::new(),
7761            src,
7762            cpu_dst,
7763            Rotation::None,
7764            Flip::None,
7765            Crop::no_crop(),
7766        );
7767        result.unwrap();
7768
7769        compare_images_convert_to_rgb(&dst, &cpu_dst, 0.98, function!());
7770    }
7771
7772    #[test]
7773    #[cfg(target_os = "linux")]
7774    fn test_rgba_to_yuyv_resize_g2d() {
7775        if !is_g2d_available() {
7776            crate::test_support::report_skip(
7777                "test_rgba_to_yuyv_resize_g2d - G2D library (libg2d.so.2) not available",
7778            );
7779            return;
7780        }
7781        if !is_dma_available() {
7782            crate::test_support::report_skip("test_rgba_to_yuyv_resize_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
7783            return;
7784        }
7785
7786        let src = load_bytes_to_tensor(
7787            1280,
7788            720,
7789            PixelFormat::Rgba,
7790            Some(TensorMemory::DmaBuf),
7791            &edgefirst_bench::testdata::read("camera720p.rgba"),
7792        )
7793        .unwrap();
7794
7795        let (dst_width, dst_height) = (1280, 720);
7796
7797        let cpu_dst = TensorDyn::image(
7798            dst_width,
7799            dst_height,
7800            PixelFormat::Yuyv,
7801            DType::U8,
7802            Some(TensorMemory::DmaBuf),
7803            edgefirst_tensor::CpuAccess::ReadWrite,
7804        )
7805        .unwrap();
7806
7807        let g2d_dst = TensorDyn::image(
7808            dst_width,
7809            dst_height,
7810            PixelFormat::Yuyv,
7811            DType::U8,
7812            Some(TensorMemory::DmaBuf),
7813            edgefirst_tensor::CpuAccess::ReadWrite,
7814        )
7815        .unwrap();
7816
7817        let mut g2d_converter = G2DProcessor::new().unwrap();
7818        let crop = Crop::new();
7819
7820        g2d_dst
7821            .as_typed::<u8>()
7822            .unwrap()
7823            .map()
7824            .unwrap()
7825            .as_mut_slice()
7826            .fill(128);
7827        let (result, src, g2d_dst) = convert_img(
7828            &mut g2d_converter,
7829            src,
7830            g2d_dst,
7831            Rotation::None,
7832            Flip::None,
7833            crop,
7834        );
7835        result.unwrap();
7836
7837        let cpu_dst_img = cpu_dst;
7838        cpu_dst_img
7839            .as_typed::<u8>()
7840            .unwrap()
7841            .map()
7842            .unwrap()
7843            .as_mut_slice()
7844            .fill(128);
7845        let (result, _src, cpu_dst) = convert_img(
7846            &mut CPUProcessor::new(),
7847            src,
7848            cpu_dst_img,
7849            Rotation::None,
7850            Flip::None,
7851            crop,
7852        );
7853        result.unwrap();
7854
7855        compare_images_convert_to_rgb(&cpu_dst, &g2d_dst, 0.98, function!());
7856    }
7857
7858    #[test]
7859    fn test_yuyv_to_rgba_cpu() {
7860        let file = edgefirst_bench::testdata::read("camera720p.yuyv").to_vec();
7861        let src = TensorDyn::image(
7862            1280,
7863            720,
7864            PixelFormat::Yuyv,
7865            DType::U8,
7866            None,
7867            edgefirst_tensor::CpuAccess::ReadWrite,
7868        )
7869        .unwrap();
7870        src.as_typed::<u8>()
7871            .unwrap()
7872            .map()
7873            .unwrap()
7874            .as_mut_slice()
7875            .copy_from_slice(&file);
7876
7877        let dst = TensorDyn::image(
7878            1280,
7879            720,
7880            PixelFormat::Rgba,
7881            DType::U8,
7882            None,
7883            edgefirst_tensor::CpuAccess::ReadWrite,
7884        )
7885        .unwrap();
7886        let mut cpu_converter = CPUProcessor::new();
7887
7888        let (result, _src, dst) = convert_img(
7889            &mut cpu_converter,
7890            src,
7891            dst,
7892            Rotation::None,
7893            Flip::None,
7894            Crop::no_crop(),
7895        );
7896        result.unwrap();
7897
7898        let target_image = TensorDyn::image(
7899            1280,
7900            720,
7901            PixelFormat::Rgba,
7902            DType::U8,
7903            None,
7904            edgefirst_tensor::CpuAccess::ReadWrite,
7905        )
7906        .unwrap();
7907        target_image
7908            .as_typed::<u8>()
7909            .unwrap()
7910            .map()
7911            .unwrap()
7912            .as_mut_slice()
7913            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
7914
7915        // CPU path resolves the untagged 720p source to BT.709 limited (height
7916        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
7917        compare_images(&dst, &target_image, 0.98, function!());
7918    }
7919
7920    #[test]
7921    fn test_yuyv_to_rgb_cpu() {
7922        let file = edgefirst_bench::testdata::read("camera720p.yuyv").to_vec();
7923        let src = TensorDyn::image(
7924            1280,
7925            720,
7926            PixelFormat::Yuyv,
7927            DType::U8,
7928            None,
7929            edgefirst_tensor::CpuAccess::ReadWrite,
7930        )
7931        .unwrap();
7932        src.as_typed::<u8>()
7933            .unwrap()
7934            .map()
7935            .unwrap()
7936            .as_mut_slice()
7937            .copy_from_slice(&file);
7938
7939        let dst = TensorDyn::image(
7940            1280,
7941            720,
7942            PixelFormat::Rgb,
7943            DType::U8,
7944            None,
7945            edgefirst_tensor::CpuAccess::ReadWrite,
7946        )
7947        .unwrap();
7948        let mut cpu_converter = CPUProcessor::new();
7949
7950        let (result, _src, dst) = convert_img(
7951            &mut cpu_converter,
7952            src,
7953            dst,
7954            Rotation::None,
7955            Flip::None,
7956            Crop::no_crop(),
7957        );
7958        result.unwrap();
7959
7960        let target_image = TensorDyn::image(
7961            1280,
7962            720,
7963            PixelFormat::Rgb,
7964            DType::U8,
7965            None,
7966            edgefirst_tensor::CpuAccess::ReadWrite,
7967        )
7968        .unwrap();
7969        target_image
7970            .as_typed::<u8>()
7971            .unwrap()
7972            .map()
7973            .unwrap()
7974            .as_mut_slice()
7975            .as_chunks_mut::<3>()
7976            .0
7977            .iter_mut()
7978            .zip(
7979                edgefirst_bench::testdata::read("camera720p.rgba")
7980                    .as_chunks::<4>()
7981                    .0,
7982            )
7983            .for_each(|(dst, src)| *dst = [src[0], src[1], src[2]]);
7984
7985        // CPU path resolves the untagged 720p source to BT.709 limited (height
7986        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
7987        compare_images(&dst, &target_image, 0.98, function!());
7988    }
7989
7990    #[test]
7991    #[cfg(target_os = "linux")]
7992    fn test_yuyv_to_rgba_g2d() {
7993        if !is_g2d_available() {
7994            crate::test_support::report_skip(
7995                "test_yuyv_to_rgba_g2d - G2D library (libg2d.so.2) not available",
7996            );
7997            return;
7998        }
7999        if !is_dma_available() {
8000            crate::test_support::report_skip("test_yuyv_to_rgba_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
8001            return;
8002        }
8003
8004        let src = load_bytes_to_tensor(
8005            1280,
8006            720,
8007            PixelFormat::Yuyv,
8008            None,
8009            &edgefirst_bench::testdata::read("camera720p.yuyv"),
8010        )
8011        .unwrap();
8012
8013        let dst = TensorDyn::image(
8014            1280,
8015            720,
8016            PixelFormat::Rgba,
8017            DType::U8,
8018            Some(TensorMemory::DmaBuf),
8019            edgefirst_tensor::CpuAccess::ReadWrite,
8020        )
8021        .unwrap();
8022        let mut g2d_converter = G2DProcessor::new().unwrap();
8023
8024        let (result, _src, dst) = convert_img(
8025            &mut g2d_converter,
8026            src,
8027            dst,
8028            Rotation::None,
8029            Flip::None,
8030            Crop::no_crop(),
8031        );
8032        result.unwrap();
8033
8034        let target_image = TensorDyn::image(
8035            1280,
8036            720,
8037            PixelFormat::Rgba,
8038            DType::U8,
8039            None,
8040            edgefirst_tensor::CpuAccess::ReadWrite,
8041        )
8042        .unwrap();
8043        target_image
8044            .as_typed::<u8>()
8045            .unwrap()
8046            .map()
8047            .unwrap()
8048            .as_mut_slice()
8049            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
8050
8051        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
8052        // so the matrix delta vs the reference that forced 0.95 has closed;
8053        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
8054        compare_images(&dst, &target_image, 0.98, function!());
8055    }
8056
8057    #[test]
8058    #[cfg(any(
8059        target_os = "linux",
8060        target_os = "macos",
8061        target_os = "ios",
8062        target_os = "android",
8063        target_os = "windows"
8064    ))]
8065    #[cfg(feature = "opengl")]
8066    fn test_yuyv_to_rgba_opengl() {
8067        if !is_opengl_available() {
8068            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
8069            return;
8070        }
8071        // Zero-copy YUYV source: DMA-BUF on Linux, IOSurface on macOS, a
8072        // D3D11 texture on Windows; skips where no such backing exists.
8073        if !edgefirst_tensor::is_gpu_buffer_available() {
8074            crate::test_support::report_skip(&format!(
8075                "{} - zero-copy GPU buffer (DMA-BUF / IOSurface) not available on this host",
8076                function!()
8077            ));
8078            return;
8079        }
8080
8081        let src = load_bytes_to_tensor(
8082            1280,
8083            720,
8084            PixelFormat::Yuyv,
8085            Some(TensorMemory::DmaBuf),
8086            &edgefirst_bench::testdata::read("camera720p.yuyv"),
8087        )
8088        .unwrap();
8089
8090        let dst = TensorDyn::image(
8091            1280,
8092            720,
8093            PixelFormat::Rgba,
8094            DType::U8,
8095            Some(TensorMemory::DmaBuf),
8096            edgefirst_tensor::CpuAccess::ReadWrite,
8097        )
8098        .unwrap();
8099        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
8100
8101        let (result, _src, dst) = convert_img(
8102            &mut gl_converter,
8103            src,
8104            dst,
8105            Rotation::None,
8106            Flip::None,
8107            Crop::no_crop(),
8108        );
8109        // Desktop NVIDIA cannot sample YUYV as a GL texture; i.MX/Vivante can.
8110        // Production ImageProcessor falls back to CPU; this oracle skips.
8111        if let Err(crate::Error::NotSupported(msg)) = &result {
8112            crate::test_support::report_skip(&format!("{} — {msg}", function!()));
8113            return;
8114        }
8115        result.unwrap();
8116
8117        let target_image = TensorDyn::image(
8118            1280,
8119            720,
8120            PixelFormat::Rgba,
8121            DType::U8,
8122            None,
8123            edgefirst_tensor::CpuAccess::ReadWrite,
8124        )
8125        .unwrap();
8126        target_image
8127            .as_typed::<u8>()
8128            .unwrap()
8129            .map()
8130            .unwrap()
8131            .as_mut_slice()
8132            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
8133
8134        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
8135        // so the matrix delta vs the reference that forced 0.95 has closed;
8136        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
8137        compare_images(&dst, &target_image, 0.98, function!());
8138    }
8139
8140    /// macOS analog of `test_yuyv_to_rgba_opengl` — drives the ANGLE +
8141    /// IOSurface backend end-to-end and compares against the same
8142    /// reference image. Skips silently if ANGLE isn't installed so the
8143    /// test suite still passes on CI hosts without the Homebrew tap.
8144    /// Step-1 probe: proves ANGLE's Metal IOSurface-client-buffer path accepts
8145    /// an `L008`→`GL_RED` (R8) binding — the foundation for sampling the
8146    /// contiguous semi-planar YUV buffer as a single R8 texture. Renders a
8147    /// GREY (R8 IOSurface) source through the GL backend to RGBA and checks the
8148    /// luma round-trips to R=G=B (identity GREY→RGB).
8149    #[test]
8150    #[cfg(target_os = "macos")]
8151    #[cfg(feature = "opengl")]
8152    fn test_grey_r8_iosurface_to_rgba_opengl_macos() {
8153        let mut proc = match GLProcessorThreaded::new(None) {
8154            Ok(p) => p,
8155            Err(e) => {
8156                crate::test_support::report_skip(&format!(
8157                    "{} — GL engine init failed ({e:?})",
8158                    function!()
8159                ));
8160                return;
8161            }
8162        };
8163
8164        let (w, h) = (16usize, 16usize);
8165        let src = TensorDyn::image(
8166            w,
8167            h,
8168            PixelFormat::Grey,
8169            DType::U8,
8170            Some(TensorMemory::DmaBuf),
8171            edgefirst_tensor::CpuAccess::ReadWrite,
8172        )
8173        .expect("GREY IOSurface (R8/L008) should allocate — proves the FourCC mapping");
8174        // Known luma ramp: value = (x * 13 + y * 7) & 0xff.
8175        {
8176            let su8 = src.as_typed::<u8>().unwrap();
8177            let stride = src
8178                .as_typed::<u8>()
8179                .unwrap()
8180                .effective_row_stride()
8181                .unwrap();
8182            let mut m = su8.map().unwrap();
8183            let buf = m.as_mut_slice();
8184            for y in 0..h {
8185                for x in 0..w {
8186                    buf[y * stride + x] = ((x * 13 + y * 7) & 0xff) as u8;
8187                }
8188            }
8189        }
8190
8191        let dst = TensorDyn::image(
8192            w,
8193            h,
8194            PixelFormat::Rgba,
8195            DType::U8,
8196            Some(TensorMemory::DmaBuf),
8197            edgefirst_tensor::CpuAccess::ReadWrite,
8198        )
8199        .unwrap();
8200        let (result, src_back, dst) = convert_img(
8201            &mut proc,
8202            src,
8203            dst,
8204            Rotation::None,
8205            Flip::None,
8206            Crop::no_crop(),
8207        );
8208        result.expect("GREY(R8 IOSurface) → RGBA must convert on ANGLE (R8 binding works)");
8209
8210        let src_stride = src_back
8211            .as_typed::<u8>()
8212            .unwrap()
8213            .effective_row_stride()
8214            .unwrap();
8215        let src_map = src_back.as_typed::<u8>().unwrap().map().unwrap();
8216        let sbytes = src_map.as_slice();
8217        let dst_stride = dst
8218            .as_typed::<u8>()
8219            .unwrap()
8220            .effective_row_stride()
8221            .unwrap();
8222        let dst_map = dst.as_typed::<u8>().unwrap().map().unwrap();
8223        let dbytes = dst_map.as_slice();
8224        for y in 0..h {
8225            for x in 0..w {
8226                let yv = sbytes[y * src_stride + x] as i16;
8227                let p = y * dst_stride + x * 4;
8228                for c in 0..3 {
8229                    assert!(
8230                        (dbytes[p + c] as i16 - yv).abs() <= 2,
8231                        "pixel ({x},{y}) ch{c} = {} expected ~{yv} (GREY→RGB identity)",
8232                        dbytes[p + c]
8233                    );
8234                }
8235            }
8236        }
8237    }
8238
8239    /// Two-pass GPU chain: NV12 (R8 IOSurface) → PlanarRgb F16, the profiler's
8240    /// preprocess. Verifies the chained `convert_nv_to_planar_float`
8241    /// (NV12→RGBA8 then the verified RGBA8→PlanarRgb F16) executes on ANGLE and
8242    /// produces a sane F16 planar result: a neutral-grey NV12 input (Y=U=V=128,
8243    /// BT.601 full ⇒ RGB≈0.5) must yield all three planes ≈0.5 (half-float).
8244    #[test]
8245    #[cfg(target_os = "macos")]
8246    #[cfg(feature = "opengl")]
8247    fn test_nv12_to_planar_f16_two_pass_opengl_macos() {
8248        let mut gpu = match GLProcessorThreaded::new(None) {
8249            Ok(p) => p,
8250            Err(e) => {
8251                crate::test_support::report_skip(&format!("{} — init failed ({e:?})", function!()));
8252                return;
8253            }
8254        };
8255        let (w, h) = (64usize, 64usize);
8256        let src = match TensorDyn::image(
8257            w,
8258            h,
8259            PixelFormat::Nv12,
8260            DType::U8,
8261            Some(TensorMemory::DmaBuf),
8262            edgefirst_tensor::CpuAccess::ReadWrite,
8263        ) {
8264            Ok(t) => t,
8265            Err(e) => {
8266                crate::test_support::report_skip(&format!(
8267                    "{} — NV12 IOSurface alloc: {e:?}",
8268                    function!()
8269                ));
8270                return;
8271            }
8272        };
8273        src.as_typed::<u8>()
8274            .unwrap()
8275            .map()
8276            .unwrap()
8277            .as_mut_slice()
8278            .fill(128); // Y=U=V=128
8279
8280        let dst = match TensorDyn::image(
8281            w,
8282            h,
8283            PixelFormat::PlanarRgb,
8284            DType::F16,
8285            Some(TensorMemory::DmaBuf),
8286            edgefirst_tensor::CpuAccess::ReadWrite,
8287        ) {
8288            Ok(t) => t,
8289            Err(e) => {
8290                crate::test_support::report_skip(&format!(
8291                    "{} — F16 PlanarRgb IOSurface: {e:?}",
8292                    function!()
8293                ));
8294                return;
8295            }
8296        };
8297        let mut dst = dst;
8298        // Call convert directly (the convert_img helper restores u8 only).
8299        if let Err(e) = ImageProcessorTrait::convert(
8300            &mut gpu,
8301            &src,
8302            &mut dst,
8303            Rotation::None,
8304            Flip::None,
8305            Crop::no_crop(),
8306        ) {
8307            // GL_EXT_color_buffer_half_float may be absent on some configs; the
8308            // RGBA8 pass-1 + F16 pass-2 path then can't render. Skip rather than
8309            // fail on a capability gap (the same policy as the F16 path tests).
8310            crate::test_support::report_skip(&format!(
8311                "{} — NV12→PlanarRgb F16 not available ({e:?})",
8312                function!()
8313            ));
8314            return;
8315        }
8316        let dt = dst.as_typed::<half::f16>().expect("dst is F16 PlanarRgb");
8317        let map = dt.map().unwrap();
8318        let vals = map.as_slice();
8319        // Neutral grey → ~0.5 in every plane. Allow generous tolerance for the
8320        // mediump YUV math + half-float rounding.
8321        let mut checked = 0usize;
8322        for &v in vals.iter() {
8323            let f = f32::from(v);
8324            assert!(
8325                (0.40..=0.60).contains(&f),
8326                "planar F16 value {f} not ~0.5 for neutral-grey NV12"
8327            );
8328            checked += 1;
8329        }
8330        assert!(
8331            checked >= w * h * 3,
8332            "expected >= 3 planes of samples, got {checked}"
8333        );
8334    }
8335
8336    /// Profiler-shaped two-pass: a reused **R8/Grey pool** (allocated larger
8337    /// than the frame, the NV24 worst case `3·H`) is reconfigured to an NV12
8338    /// frame, filled at the preserved physical stride, and converted with a
8339    /// letterbox `src_rect` crop into a model-sized PlanarRgb F16 destination —
8340    /// exactly the orchestrator's preprocess. Guards against the pooled
8341    /// two-pass NV→PlanarRgb F16 path hanging/erroring (the exact-size
8342    /// `test_nv12_to_planar_f16_two_pass` never exercised the larger pool).
8343    #[test]
8344    #[cfg(target_os = "macos")]
8345    #[cfg(feature = "opengl")]
8346    fn test_nv12_to_planar_f16_two_pass_pool_opengl_macos() {
8347        let mut gpu = match GLProcessorThreaded::new(None) {
8348            Ok(p) => p,
8349            Err(e) => {
8350                crate::test_support::report_skip(&format!("{} — init failed ({e:?})", function!()));
8351                return;
8352            }
8353        };
8354        // Frame 96×64 in a 256×768 R8 pool (3·256 height; bpr padded past 96).
8355        let (fw, fh) = (96usize, 64usize);
8356        let (pool_w, pool_h) = (256usize, 768usize);
8357        let (model_w, model_h) = (128usize, 128usize);
8358
8359        let mut src = match TensorDyn::image(
8360            pool_w,
8361            pool_h,
8362            PixelFormat::Grey,
8363            DType::U8,
8364            Some(TensorMemory::DmaBuf),
8365            edgefirst_tensor::CpuAccess::ReadWrite,
8366        ) {
8367            Ok(t) => t,
8368            Err(e) => {
8369                crate::test_support::report_skip(&format!(
8370                    "{} — R8 pool alloc: {e:?}",
8371                    function!()
8372                ));
8373                return;
8374            }
8375        };
8376        src.configure_image(fw, fh, PixelFormat::Nv12)
8377            .unwrap_or_else(|e| panic!("configure_image NV12 on pool: {e}"));
8378        let stride = src
8379            .as_typed::<u8>()
8380            .unwrap()
8381            .effective_row_stride()
8382            .unwrap();
8383        src.as_typed::<u8>()
8384            .unwrap()
8385            .map()
8386            .unwrap()
8387            .as_mut_slice()
8388            .fill(128); // neutral grey
8389
8390        let mut dst = match TensorDyn::image(
8391            model_w,
8392            model_h,
8393            PixelFormat::PlanarRgb,
8394            DType::F16,
8395            Some(TensorMemory::DmaBuf),
8396            edgefirst_tensor::CpuAccess::ReadWrite,
8397        ) {
8398            Ok(t) => t,
8399            Err(e) => {
8400                crate::test_support::report_skip(&format!(
8401                    "{} — F16 PlanarRgb dst: {e:?}",
8402                    function!()
8403                ));
8404                return;
8405            }
8406        };
8407
8408        // Letterbox crop like the profiler: the backend computes the band.
8409        let _ = model_w;
8410        let crop = Crop::new()
8411            .with_source(Some(Region::new(0, 0, fw, fh)))
8412            .with_fit(Fit::Letterbox {
8413                pad: [0, 0, 0, 255],
8414            });
8415        if let Err(e) =
8416            ImageProcessorTrait::convert(&mut gpu, &src, &mut dst, Rotation::None, Flip::None, crop)
8417        {
8418            crate::test_support::report_skip(&format!(
8419                "{} — NV12→PlanarRgb F16 unavailable ({e:?})",
8420                function!()
8421            ));
8422            return;
8423        }
8424        let _ = stride;
8425        // Neutral grey → ~0.5 inside the letterbox band; just assert the convert
8426        // completed and produced finite values (no hang, no NaN garbage).
8427        let dt = dst.as_typed::<half::f16>().expect("dst F16");
8428        let map = dt.map().unwrap();
8429        let any_half = map.as_slice().iter().any(|&v| {
8430            let f = f32::from(v);
8431            (0.40..=0.60).contains(&f)
8432        });
8433        assert!(any_half, "expected ~0.5 grey samples in the letterbox band");
8434    }
8435
8436    /// Mirrors the orchestrator: the GL processor is created on one thread
8437    /// and `convert()` is called from a *different* thread (the profiler's
8438    /// Pre-processing worker). Reproduces (or rules out) the GL-context /
8439    /// `glFinish` cross-thread hang seen in the live pipeline. A 20 s watchdog
8440    /// fails loudly rather than hanging the whole test binary.
8441    #[test]
8442    #[cfg(target_os = "macos")]
8443    #[cfg(feature = "opengl")]
8444    fn test_nv12_to_planar_f16_cross_thread_opengl_macos() {
8445        use std::sync::mpsc;
8446        // Public ImageProcessor (Send) created HERE (the main test thread),
8447        // exactly like the orchestrator builds `config.processor` during setup.
8448        let mut proc = match ImageProcessor::new() {
8449            Ok(p) => p,
8450            Err(e) => {
8451                crate::test_support::report_skip(&format!("{} — init failed ({e:?})", function!()));
8452                return;
8453            }
8454        };
8455        let (fw, fh) = (96usize, 64usize);
8456        let mut src = match TensorDyn::image(
8457            256,
8458            768,
8459            PixelFormat::Grey,
8460            DType::U8,
8461            Some(TensorMemory::DmaBuf),
8462            edgefirst_tensor::CpuAccess::ReadWrite,
8463        ) {
8464            Ok(t) => t,
8465            Err(e) => {
8466                crate::test_support::report_skip(&format!("{} — pool: {e:?}", function!()));
8467                return;
8468            }
8469        };
8470        src.configure_image(fw, fh, PixelFormat::Nv12).unwrap();
8471        src.as_typed::<u8>()
8472            .unwrap()
8473            .map()
8474            .unwrap()
8475            .as_mut_slice()
8476            .fill(128);
8477        let mut dst = match TensorDyn::image(
8478            128,
8479            128,
8480            PixelFormat::PlanarRgb,
8481            DType::F16,
8482            Some(TensorMemory::DmaBuf),
8483            edgefirst_tensor::CpuAccess::ReadWrite,
8484        ) {
8485            Ok(t) => t,
8486            Err(e) => {
8487                crate::test_support::report_skip(&format!("{} — dst: {e:?}", function!()));
8488                return;
8489            }
8490        };
8491        let crop = Crop::new().with_source(Some(Region::new(0, 0, fw, fh)));
8492
8493        // ...then MOVED to a worker thread where convert() runs — exactly the
8494        // orchestrator's create-on-setup / convert-on-Pre-processing split.
8495        let (tx, rx) = mpsc::channel::<bool>();
8496        let worker = std::thread::spawn(move || {
8497            let _ = ImageProcessorTrait::convert(
8498                &mut proc,
8499                &src,
8500                &mut dst,
8501                Rotation::None,
8502                Flip::None,
8503                crop,
8504            );
8505            let _ = tx.send(true);
8506        });
8507        match rx.recv_timeout(std::time::Duration::from_secs(20)) {
8508            Ok(_) => { let _ = worker.join(); }
8509            Err(_) => panic!(
8510                "cross-thread NV12→PlanarRgb convert HUNG (>20s) — reproduces the orchestrator deadlock"
8511            ),
8512        }
8513    }
8514
8515    /// Reproduces the profiler's progressive-slowdown/hang: one processor
8516    /// converting many **varying-size** NV frames (like a COCO dataset) from a
8517    /// reused R8 pool into a fixed PlanarRgb F16 model input. The two-pass path
8518    /// reallocated its RGBA intermediate per frame-size, churning/leaking
8519    /// pbuffers until the GPU stalled. Asserts per-convert latency stays bounded
8520    /// (no runaway) over many iterations.
8521    #[test]
8522    #[cfg(target_os = "macos")]
8523    #[cfg(feature = "opengl")]
8524    fn test_nv_to_planar_f16_varying_sizes_no_leak_opengl_macos() {
8525        let mut gpu = match GLProcessorThreaded::new(None) {
8526            Ok(p) => p,
8527            Err(e) => {
8528                crate::test_support::report_skip(&format!("{} — init failed ({e:?})", function!()));
8529                return;
8530            }
8531        };
8532        // Mirror the orchestrator's ring buffers at depth 4: a pool of source R8
8533        // tensors AND a pool of PlanarRgb F16 dst slots, both cycled per frame.
8534        let (max_w, max_h) = (640usize, 640usize);
8535        let depth = 4usize;
8536        let mut srcs = Vec::new();
8537        let mut dsts = Vec::new();
8538        for _ in 0..depth {
8539            srcs.push(
8540                match TensorDyn::image(
8541                    max_w,
8542                    max_h * 3,
8543                    PixelFormat::Grey,
8544                    DType::U8,
8545                    Some(TensorMemory::DmaBuf),
8546                    edgefirst_tensor::CpuAccess::ReadWrite,
8547                ) {
8548                    Ok(t) => t,
8549                    Err(e) => {
8550                        crate::test_support::report_skip(&format!("{} — pool: {e:?}", function!()));
8551                        return;
8552                    }
8553                },
8554            );
8555            dsts.push(
8556                match TensorDyn::image(
8557                    640,
8558                    640,
8559                    PixelFormat::PlanarRgb,
8560                    DType::F16,
8561                    Some(TensorMemory::DmaBuf),
8562                    edgefirst_tensor::CpuAccess::ReadWrite,
8563                ) {
8564                    Ok(t) => t,
8565                    Err(e) => {
8566                        crate::test_support::report_skip(&format!("{} — dst: {e:?}", function!()));
8567                        return;
8568                    }
8569                },
8570            );
8571        }
8572        // COCO-like assorted frame sizes (all ≤ max), cycled.
8573        let sizes = [
8574            (640, 480),
8575            (500, 375),
8576            (640, 427),
8577            (333, 500),
8578            (480, 640),
8579            (612, 612),
8580            (428, 640),
8581            (576, 432),
8582        ];
8583        let mut first_ms = 0f64;
8584        let mut last_ms = 0f64;
8585        let iters = 40usize;
8586        for i in 0..iters {
8587            let (fw, fh) = sizes[i % sizes.len()];
8588            let src = &mut srcs[i % depth];
8589            let dst = &mut dsts[i % depth];
8590            src.configure_image(fw, fh, PixelFormat::Nv24).unwrap();
8591            src.as_typed::<u8>()
8592                .unwrap()
8593                .map()
8594                .unwrap()
8595                .as_mut_slice()
8596                .fill(128);
8597            let crop = Crop::new().with_source(Some(Region::new(0, 0, fw, fh)));
8598            let t0 = std::time::Instant::now();
8599            ImageProcessorTrait::convert(&mut gpu, src, dst, Rotation::None, Flip::None, crop)
8600                .unwrap_or_else(|e| panic!("convert iter {i} ({fw}×{fh}): {e}"));
8601            let ms = t0.elapsed().as_secs_f64() * 1e3;
8602            if i == 2 {
8603                first_ms = ms;
8604            }
8605            if i == iters - 1 {
8606                last_ms = ms;
8607            }
8608        }
8609        eprintln!("first={first_ms:.2}ms last={last_ms:.2}ms");
8610        assert!(
8611            last_ms < first_ms * 5.0 + 5.0,
8612            "convert latency ran away: first {first_ms:.2}ms → last {last_ms:.2}ms (intermediate/pbuffer leak)"
8613        );
8614    }
8615
8616    /// Step-2 verification: NV12/NV16/NV24 (R8 IOSurface) → RGBA on the GPU
8617    /// must match the CPU `yuv` kernels within shader rounding. Fills an
8618    /// IOSurface source and a Mem source from the same logical YUV pattern
8619    /// (each at its own row stride), converts the IOSurface on the GPU and the
8620    /// Mem one on the CPU, and compares. Exercises the in-shader semi-planar
8621    /// addressing for all three subsamplings (incl. NV24's 2×-wide UV rows).
8622    #[test]
8623    #[cfg(target_os = "macos")]
8624    #[cfg(feature = "opengl")]
8625    fn test_nv12_nv16_nv24_to_rgba_opengl_macos() {
8626        let mut gpu = match GLProcessorThreaded::new(None) {
8627            Ok(p) => p,
8628            Err(e) => {
8629                crate::test_support::report_skip(&format!(
8630                    "{} — GL engine init failed ({e:?})",
8631                    function!()
8632                ));
8633                return;
8634            }
8635        };
8636        let mut cpu = CPUProcessor::new();
8637
8638        // Fill Y plus the interleaved UV plane in the canonical semi-planar
8639        // layout — exactly what the codec writes and the CPU `yuv`-crate reader
8640        // expects: the Y plane is `h` rows at the buffer's row stride; the UV
8641        // plane starts at `h * stride`; each chroma row advances
8642        // `uv_grid_rows * stride` bytes (NV24 carries a full-resolution `2*W`
8643        // byte line == two grid rows, NV12/NV16 one row of `W/2` pairs); each
8644        // (Cb,Cr) pair is two consecutive bytes at column `cx * 2`. This is
8645        // stride-correct for both the tight Mem buffer and the padded IOSurface.
8646        //
8647        // Takes explicit w/h so the closure can be reused across multiple frame
8648        // sizes (even and odd) without capturing a fixed outer variable.
8649        let fill = |buf: &mut [u8], stride: usize, fmt: PixelFormat, w: usize, h: usize| {
8650            for y in 0..h {
8651                for x in 0..w {
8652                    buf[y * stride + x] = ((x * 9 + y * 5) & 0xff) as u8;
8653                }
8654            }
8655            let (cw, ch, uv_grid_rows) = match fmt {
8656                PixelFormat::Nv12 => (w / 2, h / 2, 1usize),
8657                PixelFormat::Nv16 => (w / 2, h, 1usize),
8658                _ => (w, h, 2usize), // Nv24: full-res chroma, 2W bytes/row
8659            };
8660            let uv_plane = h * stride;
8661            for cy in 0..ch {
8662                for cx in 0..cw {
8663                    let off = uv_plane + cy * uv_grid_rows * stride + cx * 2;
8664                    buf[off] = ((cx * 11 + 30) & 0xff) as u8;
8665                    buf[off + 1] = ((cy * 7 + 200) & 0xff) as u8;
8666                }
8667            }
8668        };
8669
8670        for fmt in [PixelFormat::Nv12, PixelFormat::Nv16, PixelFormat::Nv24] {
8671            for (w, h) in [
8672                (16usize, 16usize), // original even-dim case
8673                (15, 16),           // odd-W
8674                (16, 15),           // odd-H
8675            ] {
8676                let mem = TensorDyn::image(
8677                    w,
8678                    h,
8679                    fmt,
8680                    DType::U8,
8681                    None,
8682                    edgefirst_tensor::CpuAccess::ReadWrite,
8683                )
8684                .unwrap();
8685                let mem_stride = mem
8686                    .as_typed::<u8>()
8687                    .unwrap()
8688                    .effective_row_stride()
8689                    .unwrap();
8690                fill(
8691                    mem.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice(),
8692                    mem_stride,
8693                    fmt,
8694                    w,
8695                    h,
8696                );
8697                let cpu_dst = TensorDyn::image(
8698                    w,
8699                    h,
8700                    PixelFormat::Rgba,
8701                    DType::U8,
8702                    None,
8703                    edgefirst_tensor::CpuAccess::ReadWrite,
8704                )
8705                .unwrap();
8706                let (r, _s, cpu_dst) = convert_img(
8707                    &mut cpu,
8708                    mem,
8709                    cpu_dst,
8710                    Rotation::None,
8711                    Flip::None,
8712                    Crop::no_crop(),
8713                );
8714                r.unwrap_or_else(|e| panic!("CPU {fmt:?}->{w}x{h}->RGBA: {e}"));
8715
8716                let ios = TensorDyn::image(
8717                    w,
8718                    h,
8719                    fmt,
8720                    DType::U8,
8721                    Some(TensorMemory::DmaBuf),
8722                    edgefirst_tensor::CpuAccess::ReadWrite,
8723                )
8724                .unwrap_or_else(|e| panic!("{fmt:?} {w}x{h} IOSurface alloc: {e}"));
8725                let ios_stride = ios
8726                    .as_typed::<u8>()
8727                    .unwrap()
8728                    .effective_row_stride()
8729                    .unwrap();
8730                fill(
8731                    ios.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice(),
8732                    ios_stride,
8733                    fmt,
8734                    w,
8735                    h,
8736                );
8737                let gpu_dst = TensorDyn::image(
8738                    w,
8739                    h,
8740                    PixelFormat::Rgba,
8741                    DType::U8,
8742                    Some(TensorMemory::DmaBuf),
8743                    edgefirst_tensor::CpuAccess::ReadWrite,
8744                )
8745                .unwrap();
8746                let (r, _s, gpu_dst) = convert_img(
8747                    &mut gpu,
8748                    ios,
8749                    gpu_dst,
8750                    Rotation::None,
8751                    Flip::None,
8752                    Crop::no_crop(),
8753                );
8754                r.unwrap_or_else(|e| panic!("GPU {fmt:?}->{w}x{h}->RGBA on ANGLE: {e}"));
8755
8756                let cs = cpu_dst
8757                    .as_typed::<u8>()
8758                    .unwrap()
8759                    .effective_row_stride()
8760                    .unwrap();
8761                let cmap = cpu_dst.as_typed::<u8>().unwrap().map().unwrap();
8762                let cb = cmap.as_slice();
8763                let gs = gpu_dst
8764                    .as_typed::<u8>()
8765                    .unwrap()
8766                    .effective_row_stride()
8767                    .unwrap();
8768                let gmap = gpu_dst.as_typed::<u8>().unwrap().map().unwrap();
8769                let gb = gmap.as_slice();
8770                let mut max_d = 0i16;
8771                for y in 0..h {
8772                    for x in 0..w {
8773                        for c in 0..3 {
8774                            let cv = cb[y * cs + x * 4 + c] as i16;
8775                            let gv = gb[y * gs + x * 4 + c] as i16;
8776                            max_d = max_d.max((cv - gv).abs());
8777                        }
8778                    }
8779                }
8780                assert!(
8781                    max_d <= 3,
8782                    "{fmt:?} {w}x{h}: GPU vs CPU RGBA max channel diff {max_d} > 3"
8783                );
8784            }
8785        }
8786    }
8787
8788    /// Phase-0 gate: the reused-pool / larger-surface case. A single R8 pool
8789    /// IOSurface (allocated bigger than the frame, so its `bytesPerRow` exceeds
8790    /// the frame's even width) is reconfigured to each NV frame, filled at the
8791    /// preserved physical stride, and converted on the GPU. This proves ANGLE
8792    /// binds the *whole* physical surface as a pbuffer and that `texelFetch`
8793    /// resolves the frame's Y/UV texels through the surface's real `bytesPerRow`
8794    /// (the physical-grid / logical-ROI decoupling). GPU must match CPU ≤3 LSB.
8795    #[test]
8796    #[cfg(target_os = "macos")]
8797    #[cfg(feature = "opengl")]
8798    fn test_nv_to_rgba_larger_pool_surface_opengl_macos() {
8799        let mut gpu = match GLProcessorThreaded::new(None) {
8800            Ok(p) => p,
8801            Err(e) => {
8802                crate::test_support::report_skip(&format!(
8803                    "{} — GL engine init failed ({e:?})",
8804                    function!()
8805                ));
8806                return;
8807            }
8808        };
8809        let mut cpu = CPUProcessor::new();
8810        // The pool is generously oversized (256-wide → bpr 256, well beyond any
8811        // frame's even width) and tall enough for NV24's 3·H at the test frames.
8812        let (pool_w, pool_h) = (256usize, 256usize);
8813
8814        // Canonical semi-planar fill: Y plane at the row stride, then the UV
8815        // plane at `h * stride` with each chroma row advancing `uv_grid_rows *
8816        // stride` bytes. Stride-correct for both tight Mem and padded IOSurface.
8817        let fill = |buf: &mut [u8], stride: usize, fmt: PixelFormat, w: usize, h: usize| {
8818            for y in 0..h {
8819                for x in 0..w {
8820                    buf[y * stride + x] = ((x * 9 + y * 5) & 0xff) as u8;
8821                }
8822            }
8823            let (cw, ch, uv_grid_rows) = match fmt {
8824                PixelFormat::Nv12 => (w / 2, h / 2, 1usize),
8825                PixelFormat::Nv16 => (w / 2, h, 1usize),
8826                _ => (w, h, 2usize), // Nv24: full-res chroma, 2W bytes/row
8827            };
8828            let uv_plane = h * stride;
8829            for cy in 0..ch {
8830                for cx in 0..cw {
8831                    let off = uv_plane + cy * uv_grid_rows * stride + cx * 2;
8832                    buf[off] = ((cx * 11 + 30) & 0xff) as u8;
8833                    buf[off + 1] = ((cy * 7 + 200) & 0xff) as u8;
8834                }
8835            }
8836        };
8837
8838        for fmt in [PixelFormat::Nv12, PixelFormat::Nv16, PixelFormat::Nv24] {
8839            for (w, h) in [
8840                (40usize, 24usize), // original even-dim case
8841                (15, 16),           // odd-W
8842                (16, 15),           // odd-H
8843            ] {
8844                // `ew` is the minimum even extent; the pool stride must exceed it
8845                // to actually exercise the physical-stride shader decoupling.
8846                let ew = w.next_multiple_of(2);
8847
8848                // CPU reference from a tightly-packed Mem tensor at the frame size.
8849                let mem = TensorDyn::image(
8850                    w,
8851                    h,
8852                    fmt,
8853                    DType::U8,
8854                    None,
8855                    edgefirst_tensor::CpuAccess::ReadWrite,
8856                )
8857                .unwrap();
8858                let mem_stride = mem
8859                    .as_typed::<u8>()
8860                    .unwrap()
8861                    .effective_row_stride()
8862                    .unwrap();
8863                fill(
8864                    mem.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice(),
8865                    mem_stride,
8866                    fmt,
8867                    w,
8868                    h,
8869                );
8870                let cpu_dst = TensorDyn::image(
8871                    w,
8872                    h,
8873                    PixelFormat::Rgba,
8874                    DType::U8,
8875                    None,
8876                    edgefirst_tensor::CpuAccess::ReadWrite,
8877                )
8878                .unwrap();
8879                let (r, _s, cpu_dst) = convert_img(
8880                    &mut cpu,
8881                    mem,
8882                    cpu_dst,
8883                    Rotation::None,
8884                    Flip::None,
8885                    Crop::no_crop(),
8886                );
8887                r.unwrap_or_else(|e| panic!("CPU {fmt:?}->{w}x{h}->RGBA: {e}"));
8888
8889                // GPU source: a LARGER R8 pool surface, reconfigured down to the
8890                // frame. Phase 1 preserves the pool's padded `bytesPerRow` as the
8891                // tensor's row stride; the fill writes the frame at that stride.
8892                let mut ios = match TensorDyn::image(
8893                    pool_w,
8894                    pool_h,
8895                    PixelFormat::Grey,
8896                    DType::U8,
8897                    Some(TensorMemory::DmaBuf),
8898                    edgefirst_tensor::CpuAccess::ReadWrite,
8899                ) {
8900                    Ok(t) => t,
8901                    Err(e) => {
8902                        crate::test_support::report_skip(&format!(
8903                            "{} — R8 pool IOSurface alloc: {e:?}",
8904                            function!()
8905                        ));
8906                        return;
8907                    }
8908                };
8909                ios.configure_image(w, h, fmt)
8910                    .unwrap_or_else(|e| panic!("configure_image {fmt:?} {w}x{h} on pool: {e}"));
8911                let ios_stride = ios
8912                    .as_typed::<u8>()
8913                    .unwrap()
8914                    .effective_row_stride()
8915                    .unwrap();
8916                assert!(
8917                    ios_stride > ew,
8918                    "{fmt:?} {w}x{h}: pool stride {ios_stride} should exceed even width {ew} \
8919                     (test must exercise padding)"
8920                );
8921                fill(
8922                    ios.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice(),
8923                    ios_stride,
8924                    fmt,
8925                    w,
8926                    h,
8927                );
8928
8929                let gpu_dst = TensorDyn::image(
8930                    w,
8931                    h,
8932                    PixelFormat::Rgba,
8933                    DType::U8,
8934                    Some(TensorMemory::DmaBuf),
8935                    edgefirst_tensor::CpuAccess::ReadWrite,
8936                )
8937                .unwrap();
8938                let (r, _s, gpu_dst) = convert_img(
8939                    &mut gpu,
8940                    ios,
8941                    gpu_dst,
8942                    Rotation::None,
8943                    Flip::None,
8944                    Crop::no_crop(),
8945                );
8946                r.unwrap_or_else(|e| {
8947                    panic!("GPU {fmt:?}->{w}x{h}->RGBA (pool surface) on ANGLE: {e}")
8948                });
8949
8950                let cs = cpu_dst
8951                    .as_typed::<u8>()
8952                    .unwrap()
8953                    .effective_row_stride()
8954                    .unwrap();
8955                let cmap = cpu_dst.as_typed::<u8>().unwrap().map().unwrap();
8956                let cb = cmap.as_slice();
8957                let gs = gpu_dst
8958                    .as_typed::<u8>()
8959                    .unwrap()
8960                    .effective_row_stride()
8961                    .unwrap();
8962                let gmap = gpu_dst.as_typed::<u8>().unwrap().map().unwrap();
8963                let gb = gmap.as_slice();
8964                let mut max_d = 0i16;
8965                for y in 0..h {
8966                    for x in 0..w {
8967                        for c in 0..3 {
8968                            let cv = cb[y * cs + x * 4 + c] as i16;
8969                            let gv = gb[y * gs + x * 4 + c] as i16;
8970                            max_d = max_d.max((cv - gv).abs());
8971                        }
8972                    }
8973                }
8974                assert!(
8975                    max_d <= 3,
8976                    "{fmt:?} {w}x{h}: GPU(pool surface) vs CPU RGBA max channel diff {max_d} > 3"
8977                );
8978            }
8979        }
8980    }
8981
8982    #[test]
8983    #[cfg(target_os = "macos")]
8984    #[cfg(feature = "opengl")]
8985    fn test_yuyv_to_rgba_opengl_macos() {
8986        let mut proc = match GLProcessorThreaded::new(None) {
8987            Ok(p) => p,
8988            Err(e) => {
8989                crate::test_support::report_skip(&format!(
8990                    "{} — GL engine init failed ({e:?}). \
8991                     Install ANGLE via `brew install startergo/angle/angle` \
8992                     and re-sign per README.md § macOS GPU Acceleration to \
8993                     run this test.",
8994                    function!()
8995                ));
8996                return;
8997            }
8998        };
8999
9000        let src = load_bytes_to_tensor(
9001            1280,
9002            720,
9003            PixelFormat::Yuyv,
9004            Some(TensorMemory::DmaBuf),
9005            &edgefirst_bench::testdata::read("camera720p.yuyv"),
9006        )
9007        .unwrap();
9008
9009        let dst = TensorDyn::image(
9010            1280,
9011            720,
9012            PixelFormat::Rgba,
9013            DType::U8,
9014            Some(TensorMemory::DmaBuf),
9015            edgefirst_tensor::CpuAccess::ReadWrite,
9016        )
9017        .unwrap();
9018
9019        let (result, _src, dst) = convert_img(
9020            &mut proc,
9021            src,
9022            dst,
9023            Rotation::None,
9024            Flip::None,
9025            Crop::no_crop(),
9026        );
9027        result.unwrap();
9028
9029        let target_image = TensorDyn::image(
9030            1280,
9031            720,
9032            PixelFormat::Rgba,
9033            DType::U8,
9034            None,
9035            edgefirst_tensor::CpuAccess::ReadWrite,
9036        )
9037        .unwrap();
9038        target_image
9039            .as_typed::<u8>()
9040            .unwrap()
9041            .map()
9042            .unwrap()
9043            .as_mut_slice()
9044            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
9045
9046        // macOS YUYV shader now threads per-tensor colorimetry: the untagged
9047        // camera720p source resolves to BT.709 limited (matching the BT.709
9048        // reference), measured 0.9973 on ANGLE — up from 0.9733 under the old
9049        // BT.601-full stop-gap. 0.98 leaves headroom for cross-GPU variance.
9050        compare_images(&dst, &target_image, 0.98, function!());
9051    }
9052
9053    /// Multi-resolution smoke test: convert YUYV→RGBA via the GL
9054    /// backend at a small (64×32) frame and a 4K (3840×2160) frame,
9055    /// both filled with a synthetic mid-grey pattern. Validates the
9056    /// shader math at the chroma-pairing boundary on small textures
9057    /// and exercises the IOSurface bytes-per-row alignment path at 4K
9058    /// (3840 pixels × 2 bytes/pixel = 7680 bytes, naturally 64-aligned).
9059    ///
9060    /// Resolutions below 32 pixels wide aren't tested because the
9061    /// IOSurface allocator pads bpr to 64 bytes — for a 4-px-wide
9062    /// YUYV surface that's 8 bytes data + 56 bytes padding per row,
9063    /// which exercises a sampling pattern that's ANGLE-version
9064    /// dependent rather than HAL-correctness dependent.
9065    ///
9066    /// This complements `test_yuyv_to_rgba_opengl_macos` (which checks
9067    /// pixel-exact correctness against a reference image at 720p) by
9068    /// ensuring the pipeline does not crash or produce gross errors at
9069    /// resolution extremes. Pixel-exact validation at 4K would require
9070    /// a 30 MB reference file we don't want to bundle.
9071    #[test]
9072    #[cfg(target_os = "macos")]
9073    #[cfg(feature = "opengl")]
9074    fn test_yuyv_to_rgba_opengl_macos_multi_resolution() {
9075        let mut proc = match GLProcessorThreaded::new(None) {
9076            Ok(p) => p,
9077            Err(e) => {
9078                crate::test_support::report_skip(&format!(
9079                    "{} — GL engine init failed ({e:?})",
9080                    function!()
9081                ));
9082                return;
9083            }
9084        };
9085
9086        for (w, h) in [(64usize, 32usize), (3840, 2160)] {
9087            // Synthetic YUYV: Y=128 (mid-grey luma), U=V=128 (neutral
9088            // chroma) → RGB grey at the output.
9089            let bytes_per_row = w * 2;
9090            let mut yuyv = vec![0u8; bytes_per_row * h];
9091            for chunk in yuyv.as_chunks_mut::<4>().0 {
9092                chunk[0] = 128; // Y0
9093                chunk[1] = 128; // U
9094                chunk[2] = 128; // Y1
9095                chunk[3] = 128; // V
9096            }
9097
9098            let src =
9099                load_bytes_to_tensor(w, h, PixelFormat::Yuyv, Some(TensorMemory::DmaBuf), &yuyv)
9100                    .unwrap();
9101
9102            let dst = TensorDyn::image(
9103                w,
9104                h,
9105                PixelFormat::Rgba,
9106                DType::U8,
9107                Some(TensorMemory::DmaBuf),
9108                edgefirst_tensor::CpuAccess::ReadWrite,
9109            )
9110            .unwrap();
9111
9112            let (result, _src, dst) = convert_img(
9113                &mut proc,
9114                src,
9115                dst,
9116                Rotation::None,
9117                Flip::None,
9118                Crop::no_crop(),
9119            );
9120            result.expect("GL convert should succeed at this resolution");
9121
9122            // The neutral-chroma input must produce a near-grey output;
9123            // BT.709 limited-range maps Y=128/UV=128 → roughly
9124            // (130, 130, 130). Allow ±4 LSB for `mediump float` shader
9125            // rounding.
9126            let dst_u8 = dst.as_typed::<u8>().unwrap();
9127            let dst_map = dst_u8.map().unwrap();
9128            let dst_bytes = dst_map.as_slice();
9129            assert_eq!(dst_bytes.len(), w * h * 4, "RGBA byte count");
9130            for px in dst_bytes.as_chunks::<4>().0 {
9131                for (i, &c) in px[..3].iter().enumerate() {
9132                    assert!(
9133                        (120..=140).contains(&c),
9134                        "{}: channel {i} = {c} (expected ~128 ±12) at {w}×{h}",
9135                        function!(),
9136                    );
9137                }
9138                assert_eq!(px[3], 255, "alpha must be 1.0");
9139            }
9140        }
9141    }
9142
9143    /// Verify that two consecutive convert() calls on the same source
9144    /// tensor reuse the cached EGL pbuffer. Tests the cache hit path
9145    /// added with the macOS GL backend hardening — without it, each
9146    /// frame would pay `eglCreatePbufferFromClientBuffer` + destroy.
9147    ///
9148    /// This is a behaviour test rather than a perf test (the timing
9149    /// difference is 100-200µs which is too noisy to assert on); we
9150    /// check that the second call succeeds and produces a result
9151    /// identical to the first.
9152    #[test]
9153    #[cfg(target_os = "macos")]
9154    #[cfg(feature = "opengl")]
9155    fn test_macos_gl_pbuffer_cache_reuses_surfaces() {
9156        let mut proc = match GLProcessorThreaded::new(None) {
9157            Ok(p) => p,
9158            Err(e) => {
9159                crate::test_support::report_skip(&format!(
9160                    "{} — GL engine init failed ({e:?})",
9161                    function!()
9162                ));
9163                return;
9164            }
9165        };
9166
9167        // Allocate one source + one destination, run convert twice.
9168        let mut yuyv = vec![0u8; 64 * 32 * 2];
9169        for chunk in yuyv.as_chunks_mut::<4>().0 {
9170            chunk[0] = 200;
9171            chunk[1] = 100;
9172            chunk[2] = 200;
9173            chunk[3] = 156;
9174        }
9175        let src =
9176            load_bytes_to_tensor(64, 32, PixelFormat::Yuyv, Some(TensorMemory::DmaBuf), &yuyv)
9177                .unwrap();
9178        let dst = TensorDyn::image(
9179            64,
9180            32,
9181            PixelFormat::Rgba,
9182            DType::U8,
9183            Some(TensorMemory::DmaBuf),
9184            edgefirst_tensor::CpuAccess::ReadWrite,
9185        )
9186        .unwrap();
9187
9188        let (r1, src, dst) = convert_img(
9189            &mut proc,
9190            src,
9191            dst,
9192            Rotation::None,
9193            Flip::None,
9194            Crop::no_crop(),
9195        );
9196        r1.unwrap();
9197        let first: Vec<u8> = dst
9198            .as_typed::<u8>()
9199            .unwrap()
9200            .map()
9201            .unwrap()
9202            .as_slice()
9203            .to_vec();
9204
9205        let (r2, _src, dst) = convert_img(
9206            &mut proc,
9207            src,
9208            dst,
9209            Rotation::None,
9210            Flip::None,
9211            Crop::no_crop(),
9212        );
9213        r2.unwrap();
9214        let second: Vec<u8> = dst
9215            .as_typed::<u8>()
9216            .unwrap()
9217            .map()
9218            .unwrap()
9219            .as_slice()
9220            .to_vec();
9221
9222        assert_eq!(first, second, "cache-hit conversion must be deterministic");
9223    }
9224
9225    /// Steady-state import gate (macOS half of the Linux
9226    /// `dma_pool_steady_state_zero_imports` test): an N-frame convert loop
9227    /// over a fixed pool of IOSurface tensors must create ZERO new EGL
9228    /// pbuffers after the pool has been seen once — pbuffer-cache misses
9229    /// stay flat while hits grow. Counter-based hardening of
9230    /// `test_macos_gl_pbuffer_cache_reuses_surfaces` above: a refactor that
9231    /// re-imports per frame passes the pixel-equality test but fails this.
9232    #[test]
9233    #[cfg(target_os = "macos")]
9234    #[cfg(feature = "opengl")]
9235    fn test_macos_gl_pbuffer_cache_steady_state() {
9236        let mut proc = match GLProcessorThreaded::new(None) {
9237            Ok(p) => p,
9238            Err(e) => {
9239                crate::test_support::report_skip(&format!(
9240                    "{} — GL engine init failed ({e:?})",
9241                    function!()
9242                ));
9243                return;
9244            }
9245        };
9246
9247        let (w, h) = (64usize, 32usize);
9248        const POOL: usize = 3;
9249        const FRAMES: usize = 100;
9250
9251        let yuyv = vec![128u8; w * h * 2];
9252        let pool: Vec<TensorDyn> = (0..POOL)
9253            .map(|_| {
9254                load_bytes_to_tensor(w, h, PixelFormat::Yuyv, Some(TensorMemory::DmaBuf), &yuyv)
9255                    .unwrap()
9256            })
9257            .collect();
9258        let mut dst = TensorDyn::image(
9259            w,
9260            h,
9261            PixelFormat::Rgba,
9262            DType::U8,
9263            Some(TensorMemory::DmaBuf),
9264            edgefirst_tensor::CpuAccess::ReadWrite,
9265        )
9266        .unwrap();
9267
9268        // Warmup: two passes over the pool import every surface once.
9269        for src in pool.iter().cycle().take(POOL * 2) {
9270            proc.convert(src, &mut dst, Rotation::None, Flip::None, Crop::no_crop())
9271                .unwrap();
9272        }
9273        let warm = proc.egl_cache_stats().unwrap();
9274
9275        for src in pool.iter().cycle().take(FRAMES) {
9276            proc.convert(src, &mut dst, Rotation::None, Flip::None, Crop::no_crop())
9277                .unwrap();
9278        }
9279        let steady = proc.egl_cache_stats().unwrap();
9280
9281        assert_eq!(
9282            warm.total_misses(),
9283            steady.total_misses(),
9284            "steady-state loop created new imports (warm {warm:?}, steady {steady:?})"
9285        );
9286        let hits = |s: &GlCacheStats| s.src.hits + s.dst.hits + s.nv_r8.hits + s.nv_uv.hits;
9287        assert!(
9288            hits(&steady) - hits(&warm) >= FRAMES as u64,
9289            "expected at least {FRAMES} import-cache hits over the loop, got {}",
9290            hits(&steady) - hits(&warm)
9291        );
9292    }
9293
9294    /// Backend assertion for the F16 zero-copy path: when the GL backend
9295    /// initialized (ANGLE on macOS) and reports F16 color-buffer support,
9296    /// the NV12→PlanarRgb-F16 IOSurface convert MUST be handled by the GL
9297    /// engine — proven by the engine's import-cache counters moving, not
9298    /// just by output correctness. This is the guard against the
9299    /// silent-CPU-fallback failure mode: a misclassified IOSurface F16
9300    /// destination keeps every output-correctness test green while
9301    /// quietly running ~10× slower on CPU; only a backend observable
9302    /// catches it. Skips ONLY when GL itself is unavailable or the
9303    /// configuration lacks F16 — a convert error or a CPU-routed convert
9304    /// with the capability present is a FAILURE.
9305    #[test]
9306    #[cfg(target_os = "macos")]
9307    #[cfg(feature = "opengl")]
9308    fn test_macos_gl_f16_planar_is_gl_backed() {
9309        let mut proc = ImageProcessor::new().expect("ImageProcessor");
9310        let Some(ref gl) = proc.opengl else {
9311            crate::test_support::report_skip(&format!("{} — GL backend unavailable", function!()));
9312            return;
9313        };
9314        if !gl.supported_render_dtypes().f16 {
9315            crate::test_support::report_skip(&format!(
9316                "{} — configuration lacks F16 color-buffer support",
9317                function!()
9318            ));
9319            return;
9320        }
9321        let stats_before = gl.egl_cache_stats().expect("cache stats");
9322
9323        let src = TensorDyn::image(
9324            1280,
9325            720,
9326            PixelFormat::Nv12,
9327            DType::U8,
9328            Some(TensorMemory::DmaBuf),
9329            edgefirst_tensor::CpuAccess::ReadWrite,
9330        )
9331        .unwrap();
9332        {
9333            let t = src.as_typed::<u8>().unwrap();
9334            let mut m = t.map().unwrap();
9335            for (i, b) in m.as_mut_slice().iter_mut().enumerate() {
9336                *b = ((i * 31) % 211) as u8;
9337            }
9338        }
9339        let mut dst = TensorDyn::image(
9340            640,
9341            640,
9342            PixelFormat::PlanarRgb,
9343            DType::F16,
9344            Some(TensorMemory::DmaBuf),
9345            edgefirst_tensor::CpuAccess::ReadWrite,
9346        )
9347        .unwrap();
9348
9349        proc.convert(
9350            &src,
9351            &mut dst,
9352            Rotation::None,
9353            Flip::None,
9354            Crop::letterbox([114, 114, 114, 255]),
9355        )
9356        .expect("F16 capability reported but the NV12→PlanarF16 convert failed");
9357        let stats_after = proc
9358            .opengl
9359            .as_ref()
9360            .expect("GL backend present")
9361            .egl_cache_stats()
9362            .expect("cache stats");
9363        // ≥ 2 misses: the fused convert imports the zero-copy NV source
9364        // (pass 1) AND the F16 destination (pass 2). Requiring both means a
9365        // convert that imported its source, failed mid-engine, and fell back
9366        // to CPU (1 miss) cannot satisfy this gate.
9367        assert!(
9368            stats_after.total_misses() >= stats_before.total_misses() + 2,
9369            "convert succeeded but the GL engine did not import both the \
9370             source and the F16 destination — the work did not (fully) run \
9371             on the GL backend (silent CPU fallback); misses before={} after={}",
9372            stats_before.total_misses(),
9373            stats_after.total_misses()
9374        );
9375    }
9376
9377    /// Portable oracle for the fused NV12→PlanarRgb-F16 engine convert
9378    /// (two GL passes: NV→RGBA intermediate, then the packed RGBA16F
9379    /// render). New on every platform with F16 render support — macOS
9380    /// IOSurface and Linux DMA-BUF alike. Compares against the CPU
9381    /// backend's reference within the float-path tolerance.
9382    #[test]
9383    #[cfg(feature = "opengl")]
9384    fn test_nv12_to_planar_f16_fused_engine_vs_cpu() {
9385        let mut gl = match ImageProcessor::with_config(ImageProcessorConfig {
9386            backend: ComputeBackend::OpenGl,
9387            ..Default::default()
9388        }) {
9389            Ok(p) if p.opengl.is_some() => p,
9390            _ => {
9391                crate::test_support::report_skip(&format!(
9392                    "{} — GL backend unavailable",
9393                    function!()
9394                ));
9395                return;
9396            }
9397        };
9398        if !gl
9399            .opengl
9400            .as_ref()
9401            .map(|g| g.supported_render_dtypes().f16)
9402            .unwrap_or(false)
9403        {
9404            crate::test_support::report_skip(&format!("{} — no F16 render support", function!()));
9405            return;
9406        }
9407        let mem = if edgefirst_tensor::is_gpu_buffer_available() {
9408            TensorMemory::DmaBuf
9409        } else {
9410            crate::test_support::report_skip(&format!("{} — no zero-copy buffers", function!()));
9411            return;
9412        };
9413
9414        let src = TensorDyn::image(
9415            1280,
9416            720,
9417            PixelFormat::Nv12,
9418            DType::U8,
9419            Some(mem),
9420            edgefirst_tensor::CpuAccess::ReadWrite,
9421        )
9422        .unwrap();
9423        {
9424            // Smooth gradients, NOT noise: the GL and CPU paths upsample
9425            // chroma with different kernels (nearest vs bilinear), which
9426            // legitimately diverges on per-texel chroma noise. Gradients
9427            // keep that kernel difference sub-LSB while still exercising
9428            // the full matrix math and the letterbox geometry.
9429            let t = src.as_typed::<u8>().unwrap();
9430            let mut m = t.map().unwrap();
9431            let buf = m.as_mut_slice();
9432            let (w, h) = (1280usize, 720usize);
9433            for y in 0..h {
9434                for x in 0..w {
9435                    buf[y * w + x] = ((x * 255) / w) as u8; // luma ramp
9436                }
9437            }
9438            for y in 0..(h / 2) {
9439                for x in 0..(w / 2) {
9440                    let o = h * w + y * w + 2 * x;
9441                    buf[o] = ((y * 255) / (h / 2)) as u8; // U vertical ramp
9442                    buf[o + 1] = (((x + y) * 255) / (w / 2 + h / 2)) as u8; // V diagonal
9443                }
9444            }
9445        }
9446        let crop = Crop::letterbox([114, 114, 114, 255]);
9447        let mut gl_dst = TensorDyn::image(
9448            640,
9449            640,
9450            PixelFormat::PlanarRgb,
9451            DType::F16,
9452            Some(mem),
9453            edgefirst_tensor::CpuAccess::ReadWrite,
9454        )
9455        .unwrap();
9456        // Drive the GL backend DIRECTLY: a convert through `ImageProcessor`
9457        // silently falls back to CPU on a GL error, turning this oracle into
9458        // a CPU-vs-CPU tautology. A direct call surfaces the engine error.
9459        // GPUs that allocate DMA-BUF but cannot complete an RGBA16F FBO
9460        // (desktop NVIDIA: GL_FRAMEBUFFER_INCOMPLETE_ATTACHMENT 0x8cd6)
9461        // take the production CPU fallback; this oracle cannot run there.
9462        match gl.opengl.as_mut().expect("GL backend present").convert(
9463            &src,
9464            &mut gl_dst,
9465            Rotation::None,
9466            Flip::None,
9467            crop,
9468        ) {
9469            Ok(()) => {}
9470            Err(crate::Error::NotSupported(msg)) => {
9471                crate::test_support::report_skip(&format!("{} — {msg}", function!()));
9472                return;
9473            }
9474            Err(e) => panic!("fused NV12→PlanarF16 GL convert: {e}"),
9475        }
9476
9477        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
9478            backend: ComputeBackend::Cpu,
9479            ..Default::default()
9480        })
9481        .unwrap();
9482        let mut cpu_dst = TensorDyn::image(
9483            640,
9484            640,
9485            PixelFormat::PlanarRgb,
9486            DType::F16,
9487            Some(TensorMemory::Mem),
9488            edgefirst_tensor::CpuAccess::ReadWrite,
9489        )
9490        .unwrap();
9491        cpu.convert(&src, &mut cpu_dst, Rotation::None, Flip::None, crop)
9492            .expect("CPU reference convert");
9493
9494        let g = gl_dst
9495            .as_typed::<half::f16>()
9496            .unwrap()
9497            .map()
9498            .unwrap()
9499            .as_slice()
9500            .to_vec();
9501        let c = cpu_dst
9502            .as_typed::<half::f16>()
9503            .unwrap()
9504            .map()
9505            .unwrap()
9506            .as_slice()
9507            .to_vec();
9508        assert_eq!(g.len(), c.len());
9509        let mut max_diff = 0.0f32;
9510        let mut max_at = 0usize;
9511        for (i, (a, b)) in g.iter().zip(c.iter()).enumerate() {
9512            let d = (a.to_f32() - b.to_f32()).abs();
9513            if d > max_diff {
9514                max_diff = d;
9515                max_at = i;
9516            }
9517        }
9518        // Localize: plane (R/G/B), row, col of the worst element.
9519        let (plane, rem) = (max_at / (640 * 640), max_at % (640 * 640));
9520        let (row, col) = (rem / 640, rem % 640);
9521        eprintln!(
9522            "fused-vs-cpu: max_diff={max_diff} at plane={plane} row={row} col={col} \
9523             gl={} cpu={}",
9524            g[max_at].to_f32(),
9525            c[max_at].to_f32()
9526        );
9527        // Two GPU passes (8-bit intermediate + linear filtering) vs the
9528        // CPU's direct path: allow a few 8-bit steps of divergence.
9529        assert!(
9530            max_diff <= 4.0 / 255.0 + 1e-3,
9531            "fused NV12→PlanarF16 diverges from CPU reference: max_diff={max_diff}"
9532        );
9533    }
9534
9535    /// Zero-copy source → heap destination through the GL engine,
9536    /// driven on the GL backend DIRECTLY so a GL error cannot hide
9537    /// behind the `ImageProcessor` CPU fallback (the shape that exposed
9538    /// the macOS `glReadnPixels` failure: imported source, rendered,
9539    /// then errored at the heap readback). RGBA→BGRA so the convert is
9540    /// a pure byte shuffle: no chroma kernel or colorimetry ambiguity
9541    /// (Vivante's NV fast path legitimately diverges from the CPU
9542    /// reference), and the readback stays GL_RGBA (V3D rejects RGB
9543    /// readbacks).
9544    #[test]
9545    #[cfg(feature = "opengl")]
9546    fn test_zero_copy_src_to_mem_dst_gl_direct() {
9547        let mut proc = match ImageProcessor::new() {
9548            Ok(p) if p.opengl.is_some() => p,
9549            _ => {
9550                crate::test_support::report_skip(&format!(
9551                    "{} — GL backend unavailable",
9552                    function!()
9553                ));
9554                return;
9555            }
9556        };
9557        if !edgefirst_tensor::is_gpu_buffer_available() {
9558            crate::test_support::report_skip(&format!("{} — no zero-copy buffers", function!()));
9559            return;
9560        }
9561
9562        let src = TensorDyn::image(
9563            1280,
9564            720,
9565            PixelFormat::Rgba,
9566            DType::U8,
9567            Some(TensorMemory::DmaBuf),
9568            edgefirst_tensor::CpuAccess::ReadWrite,
9569        )
9570        .unwrap();
9571        {
9572            let t = src.as_typed::<u8>().unwrap();
9573            let mut m = t.map().unwrap();
9574            for (i, b) in m.as_mut_slice().iter_mut().enumerate() {
9575                *b = ((i * 31) % 211) as u8;
9576            }
9577        }
9578        let mut gl_dst = TensorDyn::image(
9579            1280,
9580            720,
9581            PixelFormat::Bgra,
9582            DType::U8,
9583            Some(TensorMemory::Mem),
9584            edgefirst_tensor::CpuAccess::ReadWrite,
9585        )
9586        .unwrap();
9587        proc.opengl
9588            .as_mut()
9589            .expect("GL backend present")
9590            .convert(
9591                &src,
9592                &mut gl_dst,
9593                Rotation::None,
9594                Flip::None,
9595                Crop::no_crop(),
9596            )
9597            .expect("zero-copy src → heap dst GL convert");
9598
9599        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
9600            backend: ComputeBackend::Cpu,
9601            ..Default::default()
9602        })
9603        .unwrap();
9604        let mut cpu_dst = TensorDyn::image(
9605            1280,
9606            720,
9607            PixelFormat::Bgra,
9608            DType::U8,
9609            Some(TensorMemory::Mem),
9610            edgefirst_tensor::CpuAccess::ReadWrite,
9611        )
9612        .unwrap();
9613        cpu.convert(
9614            &src,
9615            &mut cpu_dst,
9616            Rotation::None,
9617            Flip::None,
9618            Crop::no_crop(),
9619        )
9620        .expect("CPU reference convert");
9621
9622        let g = gl_dst
9623            .as_typed::<u8>()
9624            .unwrap()
9625            .map()
9626            .unwrap()
9627            .as_slice()
9628            .to_vec();
9629        let c = cpu_dst
9630            .as_typed::<u8>()
9631            .unwrap()
9632            .map()
9633            .unwrap()
9634            .as_slice()
9635            .to_vec();
9636        assert_eq!(g.len(), c.len());
9637        let max_diff = g
9638            .iter()
9639            .zip(c.iter())
9640            .map(|(a, b)| a.abs_diff(*b))
9641            .max()
9642            .unwrap();
9643        // Same-size byte shuffle: GL samples texel centers 1:1, so allow
9644        // only rounding slack.
9645        assert!(
9646            max_diff <= 2,
9647            "zero-copy src → heap dst diverges from CPU reference: max_diff={max_diff}"
9648        );
9649    }
9650
9651    #[test]
9652    #[cfg(target_os = "linux")]
9653    fn test_yuyv_to_rgb_g2d() {
9654        if !is_g2d_available() {
9655            crate::test_support::report_skip(
9656                "test_yuyv_to_rgb_g2d - G2D library (libg2d.so.2) not available",
9657            );
9658            return;
9659        }
9660        if !is_dma_available() {
9661            crate::test_support::report_skip("test_yuyv_to_rgb_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
9662            return;
9663        }
9664
9665        let src = load_bytes_to_tensor(
9666            1280,
9667            720,
9668            PixelFormat::Yuyv,
9669            None,
9670            &edgefirst_bench::testdata::read("camera720p.yuyv"),
9671        )
9672        .unwrap();
9673
9674        let g2d_dst = TensorDyn::image(
9675            1280,
9676            720,
9677            PixelFormat::Rgb,
9678            DType::U8,
9679            Some(TensorMemory::DmaBuf),
9680            edgefirst_tensor::CpuAccess::ReadWrite,
9681        )
9682        .unwrap();
9683        let mut g2d_converter = G2DProcessor::new().unwrap();
9684
9685        let (result, src, g2d_dst) = convert_img(
9686            &mut g2d_converter,
9687            src,
9688            g2d_dst,
9689            Rotation::None,
9690            Flip::None,
9691            Crop::no_crop(),
9692        );
9693        result.unwrap();
9694
9695        let cpu_dst = TensorDyn::image(
9696            1280,
9697            720,
9698            PixelFormat::Rgb,
9699            DType::U8,
9700            None,
9701            edgefirst_tensor::CpuAccess::ReadWrite,
9702        )
9703        .unwrap();
9704        let mut cpu_converter: CPUProcessor = CPUProcessor::new();
9705
9706        let (result, _src, cpu_dst) = convert_img(
9707            &mut cpu_converter,
9708            src,
9709            cpu_dst,
9710            Rotation::None,
9711            Flip::None,
9712            Crop::no_crop(),
9713        );
9714        result.unwrap();
9715
9716        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
9717        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
9718        // the YUV-matrix delta that forced 0.95 has closed; tightened to
9719        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
9720        // structural gap not exercised by these limited-range fixtures.
9721        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
9722    }
9723
9724    #[test]
9725    #[cfg(target_os = "linux")]
9726    fn test_yuyv_to_yuyv_resize_g2d() {
9727        if !is_g2d_available() {
9728            crate::test_support::report_skip(
9729                "test_yuyv_to_yuyv_resize_g2d - G2D library (libg2d.so.2) not available",
9730            );
9731            return;
9732        }
9733        if !is_dma_available() {
9734            crate::test_support::report_skip("test_yuyv_to_yuyv_resize_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
9735            return;
9736        }
9737
9738        let src = load_bytes_to_tensor(
9739            1280,
9740            720,
9741            PixelFormat::Yuyv,
9742            None,
9743            &edgefirst_bench::testdata::read("camera720p.yuyv"),
9744        )
9745        .unwrap();
9746
9747        let g2d_dst = TensorDyn::image(
9748            600,
9749            400,
9750            PixelFormat::Yuyv,
9751            DType::U8,
9752            Some(TensorMemory::DmaBuf),
9753            edgefirst_tensor::CpuAccess::ReadWrite,
9754        )
9755        .unwrap();
9756        let mut g2d_converter = G2DProcessor::new().unwrap();
9757
9758        let (result, src, g2d_dst) = convert_img(
9759            &mut g2d_converter,
9760            src,
9761            g2d_dst,
9762            Rotation::None,
9763            Flip::None,
9764            Crop::no_crop(),
9765        );
9766        result.unwrap();
9767
9768        let cpu_dst = TensorDyn::image(
9769            600,
9770            400,
9771            PixelFormat::Yuyv,
9772            DType::U8,
9773            None,
9774            edgefirst_tensor::CpuAccess::ReadWrite,
9775        )
9776        .unwrap();
9777        let mut cpu_converter: CPUProcessor = CPUProcessor::new();
9778
9779        let (result, _src, cpu_dst) = convert_img(
9780            &mut cpu_converter,
9781            src,
9782            cpu_dst,
9783            Rotation::None,
9784            Flip::None,
9785            Crop::no_crop(),
9786        );
9787        result.unwrap();
9788
9789        // G2D has poor colorimetry support: its hardware YUYV resize/sampling
9790        // diverges from the CPU reference by enough that the similarity score
9791        // sits around 0.85 on every measured G2D core (i.MX 8M Plus, i.MX 95).
9792        // The threshold is held at 0.85 so the test guards against gross
9793        // regressions while tolerating the driver's inherent colorimetry error.
9794        // TODO: compare YUYV↔YUYV directly without a YUYV→RGB convert.
9795        eprintln!(
9796            "WARNING: G2D has poor colorimetry support — YUYV resize diverges from the \
9797             CPU reference (~0.85 similarity); threshold held at 0.85, not 0.95."
9798        );
9799        compare_images_convert_to_rgb(&g2d_dst, &cpu_dst, 0.85, function!());
9800    }
9801
9802    #[test]
9803    fn test_yuyv_to_rgba_resize_cpu() {
9804        let src = load_bytes_to_tensor(
9805            1280,
9806            720,
9807            PixelFormat::Yuyv,
9808            None,
9809            &edgefirst_bench::testdata::read("camera720p.yuyv"),
9810        )
9811        .unwrap();
9812
9813        let (dst_width, dst_height) = (960, 540);
9814
9815        let dst = TensorDyn::image(
9816            dst_width,
9817            dst_height,
9818            PixelFormat::Rgba,
9819            DType::U8,
9820            None,
9821            edgefirst_tensor::CpuAccess::ReadWrite,
9822        )
9823        .unwrap();
9824        let mut cpu_converter = CPUProcessor::new();
9825
9826        let (result, _src, dst) = convert_img(
9827            &mut cpu_converter,
9828            src,
9829            dst,
9830            Rotation::None,
9831            Flip::None,
9832            Crop::no_crop(),
9833        );
9834        result.unwrap();
9835
9836        let dst_target = TensorDyn::image(
9837            dst_width,
9838            dst_height,
9839            PixelFormat::Rgba,
9840            DType::U8,
9841            None,
9842            edgefirst_tensor::CpuAccess::ReadWrite,
9843        )
9844        .unwrap();
9845        let src_target = load_bytes_to_tensor(
9846            1280,
9847            720,
9848            PixelFormat::Rgba,
9849            None,
9850            &edgefirst_bench::testdata::read("camera720p.rgba"),
9851        )
9852        .unwrap();
9853        let (result, _src_target, dst_target) = convert_img(
9854            &mut cpu_converter,
9855            src_target,
9856            dst_target,
9857            Rotation::None,
9858            Flip::None,
9859            Crop::no_crop(),
9860        );
9861        result.unwrap();
9862
9863        // CPU path resolves the untagged 720p source to BT.709 limited (height
9864        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
9865        compare_images(&dst, &dst_target, 0.98, function!());
9866    }
9867
9868    #[test]
9869    #[cfg(target_os = "linux")]
9870    fn test_yuyv_to_rgba_crop_flip_g2d() {
9871        if !is_g2d_available() {
9872            crate::test_support::report_skip(
9873                "test_yuyv_to_rgba_crop_flip_g2d - G2D library (libg2d.so.2) not available",
9874            );
9875            return;
9876        }
9877        if !is_dma_available() {
9878            crate::test_support::report_skip("test_yuyv_to_rgba_crop_flip_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
9879            return;
9880        }
9881
9882        let src = load_bytes_to_tensor(
9883            1280,
9884            720,
9885            PixelFormat::Yuyv,
9886            Some(TensorMemory::DmaBuf),
9887            &edgefirst_bench::testdata::read("camera720p.yuyv"),
9888        )
9889        .unwrap();
9890
9891        let (dst_width, dst_height) = (640, 640);
9892
9893        let dst_g2d = TensorDyn::image(
9894            dst_width,
9895            dst_height,
9896            PixelFormat::Rgba,
9897            DType::U8,
9898            Some(TensorMemory::DmaBuf),
9899            edgefirst_tensor::CpuAccess::ReadWrite,
9900        )
9901        .unwrap();
9902        let mut g2d_converter = G2DProcessor::new().unwrap();
9903        let crop = Crop::new().with_source(Some(Region::new(20, 15, 400, 300)));
9904
9905        let (result, src, dst_g2d) = convert_img(
9906            &mut g2d_converter,
9907            src,
9908            dst_g2d,
9909            Rotation::None,
9910            Flip::Horizontal,
9911            crop,
9912        );
9913        result.unwrap();
9914
9915        let dst_cpu = TensorDyn::image(
9916            dst_width,
9917            dst_height,
9918            PixelFormat::Rgba,
9919            DType::U8,
9920            Some(TensorMemory::DmaBuf),
9921            edgefirst_tensor::CpuAccess::ReadWrite,
9922        )
9923        .unwrap();
9924        let mut cpu_converter = CPUProcessor::new();
9925
9926        let (result, _src, dst_cpu) = convert_img(
9927            &mut cpu_converter,
9928            src,
9929            dst_cpu,
9930            Rotation::None,
9931            Flip::Horizontal,
9932            crop,
9933        );
9934        result.unwrap();
9935        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
9936        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
9937        // the YUV-matrix delta that forced 0.95 has closed; tightened to
9938        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
9939        // structural gap not exercised by these limited-range fixtures.
9940        compare_images(&dst_g2d, &dst_cpu, 0.98, function!());
9941    }
9942
9943    #[test]
9944    #[cfg(any(
9945        target_os = "linux",
9946        target_os = "macos",
9947        target_os = "ios",
9948        target_os = "android",
9949        target_os = "windows"
9950    ))]
9951    #[cfg(feature = "opengl")]
9952    fn test_yuyv_to_rgba_crop_flip_opengl() {
9953        if !is_opengl_available() {
9954            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
9955            return;
9956        }
9957
9958        if !edgefirst_tensor::is_gpu_buffer_available() {
9959            crate::test_support::report_skip(&format!(
9960                "{} - zero-copy GPU buffer (DMA-BUF / IOSurface) not available on this host",
9961                function!()
9962            ));
9963            return;
9964        }
9965
9966        let src = load_bytes_to_tensor(
9967            1280,
9968            720,
9969            PixelFormat::Yuyv,
9970            Some(TensorMemory::DmaBuf),
9971            &edgefirst_bench::testdata::read("camera720p.yuyv"),
9972        )
9973        .unwrap();
9974
9975        let (dst_width, dst_height) = (640, 640);
9976
9977        let dst_gl = TensorDyn::image(
9978            dst_width,
9979            dst_height,
9980            PixelFormat::Rgba,
9981            DType::U8,
9982            Some(TensorMemory::DmaBuf),
9983            edgefirst_tensor::CpuAccess::ReadWrite,
9984        )
9985        .unwrap();
9986        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
9987        let crop = Crop::new().with_source(Some(Region::new(20, 15, 400, 300)));
9988
9989        let (result, src, dst_gl) = convert_img(
9990            &mut gl_converter,
9991            src,
9992            dst_gl,
9993            Rotation::None,
9994            Flip::Horizontal,
9995            crop,
9996        );
9997        // Desktop NVIDIA cannot sample YUYV as a GL texture; i.MX/Vivante can.
9998        if let Err(crate::Error::NotSupported(msg)) = &result {
9999            crate::test_support::report_skip(&format!("{} — {msg}", function!()));
10000            return;
10001        }
10002        result.unwrap();
10003
10004        let dst_cpu = TensorDyn::image(
10005            dst_width,
10006            dst_height,
10007            PixelFormat::Rgba,
10008            DType::U8,
10009            Some(TensorMemory::DmaBuf),
10010            edgefirst_tensor::CpuAccess::ReadWrite,
10011        )
10012        .unwrap();
10013        let mut cpu_converter = CPUProcessor::new();
10014
10015        let (result, _src, dst_cpu) = convert_img(
10016            &mut cpu_converter,
10017            src,
10018            dst_cpu,
10019            Rotation::None,
10020            Flip::Horizontal,
10021            crop,
10022        );
10023        result.unwrap();
10024        // Post-WS1 the GL path applies the resolved colorimetry via the EGL
10025        // YUV color-space/sample-range hints, so the matrix delta that forced
10026        // 0.95 has closed; tightened to 0.98 (driver-matrix rounding confirmed
10027        // on the GPU lanes).
10028        compare_images(&dst_gl, &dst_cpu, 0.98, function!());
10029    }
10030
10031    #[test]
10032    fn test_vyuy_to_rgba_cpu() {
10033        let file = edgefirst_bench::testdata::read("camera720p.vyuy").to_vec();
10034        let src = TensorDyn::image(
10035            1280,
10036            720,
10037            PixelFormat::Vyuy,
10038            DType::U8,
10039            None,
10040            edgefirst_tensor::CpuAccess::ReadWrite,
10041        )
10042        .unwrap();
10043        src.as_typed::<u8>()
10044            .unwrap()
10045            .map()
10046            .unwrap()
10047            .as_mut_slice()
10048            .copy_from_slice(&file);
10049
10050        let dst = TensorDyn::image(
10051            1280,
10052            720,
10053            PixelFormat::Rgba,
10054            DType::U8,
10055            None,
10056            edgefirst_tensor::CpuAccess::ReadWrite,
10057        )
10058        .unwrap();
10059        let mut cpu_converter = CPUProcessor::new();
10060
10061        let (result, _src, dst) = convert_img(
10062            &mut cpu_converter,
10063            src,
10064            dst,
10065            Rotation::None,
10066            Flip::None,
10067            Crop::no_crop(),
10068        );
10069        result.unwrap();
10070
10071        let target_image = TensorDyn::image(
10072            1280,
10073            720,
10074            PixelFormat::Rgba,
10075            DType::U8,
10076            None,
10077            edgefirst_tensor::CpuAccess::ReadWrite,
10078        )
10079        .unwrap();
10080        target_image
10081            .as_typed::<u8>()
10082            .unwrap()
10083            .map()
10084            .unwrap()
10085            .as_mut_slice()
10086            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
10087
10088        // CPU path resolves the untagged 720p source to BT.709 limited (height
10089        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
10090        compare_images(&dst, &target_image, 0.98, function!());
10091    }
10092
10093    #[test]
10094    fn test_vyuy_to_rgb_cpu() {
10095        let file = edgefirst_bench::testdata::read("camera720p.vyuy").to_vec();
10096        let src = TensorDyn::image(
10097            1280,
10098            720,
10099            PixelFormat::Vyuy,
10100            DType::U8,
10101            None,
10102            edgefirst_tensor::CpuAccess::ReadWrite,
10103        )
10104        .unwrap();
10105        src.as_typed::<u8>()
10106            .unwrap()
10107            .map()
10108            .unwrap()
10109            .as_mut_slice()
10110            .copy_from_slice(&file);
10111
10112        let dst = TensorDyn::image(
10113            1280,
10114            720,
10115            PixelFormat::Rgb,
10116            DType::U8,
10117            None,
10118            edgefirst_tensor::CpuAccess::ReadWrite,
10119        )
10120        .unwrap();
10121        let mut cpu_converter = CPUProcessor::new();
10122
10123        let (result, _src, dst) = convert_img(
10124            &mut cpu_converter,
10125            src,
10126            dst,
10127            Rotation::None,
10128            Flip::None,
10129            Crop::no_crop(),
10130        );
10131        result.unwrap();
10132
10133        let target_image = TensorDyn::image(
10134            1280,
10135            720,
10136            PixelFormat::Rgb,
10137            DType::U8,
10138            None,
10139            edgefirst_tensor::CpuAccess::ReadWrite,
10140        )
10141        .unwrap();
10142        target_image
10143            .as_typed::<u8>()
10144            .unwrap()
10145            .map()
10146            .unwrap()
10147            .as_mut_slice()
10148            .as_chunks_mut::<3>()
10149            .0
10150            .iter_mut()
10151            .zip(
10152                edgefirst_bench::testdata::read("camera720p.rgba")
10153                    .as_chunks::<4>()
10154                    .0,
10155            )
10156            .for_each(|(dst, src)| *dst = [src[0], src[1], src[2]]);
10157
10158        // CPU path resolves the untagged 720p source to BT.709 limited (height
10159        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
10160        compare_images(&dst, &target_image, 0.98, function!());
10161    }
10162
10163    #[test]
10164    #[cfg(target_os = "linux")]
10165    #[ignore = "G2D does not support VYUY; re-enable when hardware support is added"]
10166    fn test_vyuy_to_rgba_g2d() {
10167        if !is_g2d_available() {
10168            crate::test_support::report_skip(
10169                "test_vyuy_to_rgba_g2d - G2D library (libg2d.so.2) not available",
10170            );
10171            return;
10172        }
10173        if !is_dma_available() {
10174            crate::test_support::report_skip("test_vyuy_to_rgba_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
10175            return;
10176        }
10177
10178        let src = load_bytes_to_tensor(
10179            1280,
10180            720,
10181            PixelFormat::Vyuy,
10182            None,
10183            &edgefirst_bench::testdata::read("camera720p.vyuy"),
10184        )
10185        .unwrap();
10186
10187        let dst = TensorDyn::image(
10188            1280,
10189            720,
10190            PixelFormat::Rgba,
10191            DType::U8,
10192            Some(TensorMemory::DmaBuf),
10193            edgefirst_tensor::CpuAccess::ReadWrite,
10194        )
10195        .unwrap();
10196        let mut g2d_converter = G2DProcessor::new().unwrap();
10197
10198        let (result, _src, dst) = convert_img(
10199            &mut g2d_converter,
10200            src,
10201            dst,
10202            Rotation::None,
10203            Flip::None,
10204            Crop::no_crop(),
10205        );
10206        match result {
10207            Err(Error::G2D(_)) => {
10208                crate::test_support::report_skip(
10209                    "test_vyuy_to_rgba_g2d - G2D does not support PixelFormat::Vyuy format",
10210                );
10211                return;
10212            }
10213            r => r.unwrap(),
10214        }
10215
10216        let target_image = TensorDyn::image(
10217            1280,
10218            720,
10219            PixelFormat::Rgba,
10220            DType::U8,
10221            None,
10222            edgefirst_tensor::CpuAccess::ReadWrite,
10223        )
10224        .unwrap();
10225        target_image
10226            .as_typed::<u8>()
10227            .unwrap()
10228            .map()
10229            .unwrap()
10230            .as_mut_slice()
10231            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
10232
10233        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
10234        // so the matrix delta vs the reference that forced 0.95 has closed;
10235        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
10236        compare_images(&dst, &target_image, 0.98, function!());
10237    }
10238
10239    #[test]
10240    #[cfg(target_os = "linux")]
10241    #[ignore = "G2D does not support VYUY; re-enable when hardware support is added"]
10242    fn test_vyuy_to_rgb_g2d() {
10243        if !is_g2d_available() {
10244            crate::test_support::report_skip(
10245                "test_vyuy_to_rgb_g2d - G2D library (libg2d.so.2) not available",
10246            );
10247            return;
10248        }
10249        if !is_dma_available() {
10250            crate::test_support::report_skip("test_vyuy_to_rgb_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
10251            return;
10252        }
10253
10254        let src = load_bytes_to_tensor(
10255            1280,
10256            720,
10257            PixelFormat::Vyuy,
10258            None,
10259            &edgefirst_bench::testdata::read("camera720p.vyuy"),
10260        )
10261        .unwrap();
10262
10263        let g2d_dst = TensorDyn::image(
10264            1280,
10265            720,
10266            PixelFormat::Rgb,
10267            DType::U8,
10268            Some(TensorMemory::DmaBuf),
10269            edgefirst_tensor::CpuAccess::ReadWrite,
10270        )
10271        .unwrap();
10272        let mut g2d_converter = G2DProcessor::new().unwrap();
10273
10274        let (result, src, g2d_dst) = convert_img(
10275            &mut g2d_converter,
10276            src,
10277            g2d_dst,
10278            Rotation::None,
10279            Flip::None,
10280            Crop::no_crop(),
10281        );
10282        match result {
10283            Err(Error::G2D(_)) => {
10284                crate::test_support::report_skip(
10285                    "test_vyuy_to_rgb_g2d - G2D does not support PixelFormat::Vyuy format",
10286                );
10287                return;
10288            }
10289            r => r.unwrap(),
10290        }
10291
10292        let cpu_dst = TensorDyn::image(
10293            1280,
10294            720,
10295            PixelFormat::Rgb,
10296            DType::U8,
10297            None,
10298            edgefirst_tensor::CpuAccess::ReadWrite,
10299        )
10300        .unwrap();
10301        let mut cpu_converter: CPUProcessor = CPUProcessor::new();
10302
10303        let (result, _src, cpu_dst) = convert_img(
10304            &mut cpu_converter,
10305            src,
10306            cpu_dst,
10307            Rotation::None,
10308            Flip::None,
10309            Crop::no_crop(),
10310        );
10311        result.unwrap();
10312
10313        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
10314        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
10315        // the YUV-matrix delta that forced 0.95 has closed; tightened to
10316        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
10317        // structural gap not exercised by these limited-range fixtures.
10318        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
10319    }
10320
10321    #[test]
10322    #[cfg(any(
10323        target_os = "linux",
10324        target_os = "macos",
10325        target_os = "ios",
10326        target_os = "android",
10327        target_os = "windows"
10328    ))]
10329    #[cfg(feature = "opengl")]
10330    fn test_vyuy_to_rgba_opengl() {
10331        if !is_opengl_available() {
10332            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
10333            return;
10334        }
10335        if !edgefirst_tensor::is_gpu_buffer_available() {
10336            crate::test_support::report_skip(&format!(
10337                "{} - zero-copy GPU buffer (DMA-BUF / IOSurface) not available on this host",
10338                function!()
10339            ));
10340            return;
10341        }
10342
10343        // VYUY has a DMA-BUF FourCC on Linux but no IOSurface mapping on
10344        // macOS/iOS, so the zero-copy allocation itself is the per-format
10345        // probe: a platform that declines it is a skip, not a failure.
10346        let src = match load_bytes_to_tensor(
10347            1280,
10348            720,
10349            PixelFormat::Vyuy,
10350            Some(TensorMemory::DmaBuf),
10351            &edgefirst_bench::testdata::read("camera720p.vyuy"),
10352        ) {
10353            Ok(src) => src,
10354            Err(e) => {
10355                crate::test_support::report_skip(&format!(
10356                    "{} - zero-copy VYUY source not allocatable on this platform ({e})",
10357                    function!()
10358                ));
10359                return;
10360            }
10361        };
10362
10363        let dst = TensorDyn::image(
10364            1280,
10365            720,
10366            PixelFormat::Rgba,
10367            DType::U8,
10368            Some(TensorMemory::DmaBuf),
10369            edgefirst_tensor::CpuAccess::ReadWrite,
10370        )
10371        .unwrap();
10372        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
10373
10374        let (result, _src, dst) = convert_img(
10375            &mut gl_converter,
10376            src,
10377            dst,
10378            Rotation::None,
10379            Flip::None,
10380            Crop::no_crop(),
10381        );
10382        match result {
10383            Err(Error::NotSupported(_)) => {
10384                crate::test_support::report_skip(&format!(
10385                    "{} - OpenGL does not support PixelFormat::Vyuy DMA format",
10386                    function!()
10387                ));
10388                return;
10389            }
10390            r => r.unwrap(),
10391        }
10392
10393        let target_image = TensorDyn::image(
10394            1280,
10395            720,
10396            PixelFormat::Rgba,
10397            DType::U8,
10398            None,
10399            edgefirst_tensor::CpuAccess::ReadWrite,
10400        )
10401        .unwrap();
10402        target_image
10403            .as_typed::<u8>()
10404            .unwrap()
10405            .map()
10406            .unwrap()
10407            .as_mut_slice()
10408            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
10409
10410        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
10411        // so the matrix delta vs the reference that forced 0.95 has closed;
10412        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
10413        compare_images(&dst, &target_image, 0.98, function!());
10414    }
10415
10416    #[test]
10417    fn test_nv12_to_rgba_cpu() {
10418        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
10419        let src = TensorDyn::image(
10420            1280,
10421            720,
10422            PixelFormat::Nv12,
10423            DType::U8,
10424            None,
10425            edgefirst_tensor::CpuAccess::ReadWrite,
10426        )
10427        .unwrap();
10428        src.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
10429            .copy_from_slice(&file);
10430
10431        let dst = TensorDyn::image(
10432            1280,
10433            720,
10434            PixelFormat::Rgba,
10435            DType::U8,
10436            None,
10437            edgefirst_tensor::CpuAccess::ReadWrite,
10438        )
10439        .unwrap();
10440        let mut cpu_converter = CPUProcessor::new();
10441
10442        let (result, _src, dst) = convert_img(
10443            &mut cpu_converter,
10444            src,
10445            dst,
10446            Rotation::None,
10447            Flip::None,
10448            Crop::no_crop(),
10449        );
10450        result.unwrap();
10451
10452        let target_image = crate::load_image_test_helper(
10453            &edgefirst_bench::testdata::read("zidane.jpg"),
10454            Some(PixelFormat::Rgba),
10455            None,
10456        )
10457        .unwrap();
10458
10459        // Threshold 0.95 (was 0.98): the reference now decodes the colour JPEG
10460        // to native NV12 and then converts to RGBA (was a direct JPEG → RGBA
10461        // decode), so it differs slightly from the RGBA derived from the
10462        // separate `zidane.nv12` fixture.
10463        compare_images(&dst, &target_image, 0.95, function!());
10464    }
10465
10466    #[test]
10467    fn test_nv12_odd_height_to_rgb_cpu() {
10468        // Odd height (even width) — the logical-odd case, e.g. 640×483. The
10469        // contiguous NV12 buffer is `[5 + ceil(5/2), 8]` = `[8, 8]` (5 luma rows
10470        // + 3 chroma rows). A neutral-grey fill (Y=U=V=128, BT.601 full-range)
10471        // must convert to a uniform grey RGB, exercising the odd-height
10472        // chroma-row count and the logical-height derivation in convert.
10473        // (Odd *width* is rounded to an even buffer at allocation, so it is
10474        // covered by the decode integration tests rather than here.)
10475        // CPU-only test: pin to tight host memory (None auto-selects pitch-padded
10476        // DMA on i.MX, which would leave the dst's row padding unconverted and
10477        // break the flat byte scan below).
10478        let mut src = TensorDyn::image(
10479            8,
10480            5,
10481            PixelFormat::Nv12,
10482            DType::U8,
10483            Some(TensorMemory::Mem),
10484            edgefirst_tensor::CpuAccess::ReadWrite,
10485        )
10486        .unwrap();
10487        assert_eq!(src.shape(), &[8, 8]);
10488        assert_eq!((src.width(), src.height()), (Some(8), Some(5)));
10489        src.as_typed::<u8>()
10490            .unwrap()
10491            .map()
10492            .unwrap()
10493            .as_mut_slice()
10494            .fill(128);
10495        // Tag BT.601 full-range so Y=128 decodes to grey 128 (the neutral-grey
10496        // identity this test asserts). Without a tag, the colorimetry heuristic
10497        // resolves an SD tensor to BT.601 *limited*, expanding Y=128 → ~131.
10498        src.set_colorimetry(Some(
10499            edgefirst_tensor::Colorimetry::default()
10500                .with_encoding(edgefirst_tensor::ColorEncoding::Bt601)
10501                .with_range(edgefirst_tensor::ColorRange::Full),
10502        ));
10503
10504        let dst = TensorDyn::image(
10505            8,
10506            5,
10507            PixelFormat::Rgb,
10508            DType::U8,
10509            Some(TensorMemory::Mem),
10510            edgefirst_tensor::CpuAccess::ReadWrite,
10511        )
10512        .unwrap();
10513        let mut cpu_converter = CPUProcessor::new();
10514        let (result, _src, dst) = convert_img(
10515            &mut cpu_converter,
10516            src,
10517            dst,
10518            Rotation::None,
10519            Flip::None,
10520            Crop::no_crop(),
10521        );
10522        result.unwrap();
10523
10524        assert_eq!((dst.width(), dst.height()), (Some(8), Some(5)));
10525        let map = dst.as_typed::<u8>().unwrap().map().unwrap();
10526        for (i, &b) in map.as_slice().iter().enumerate() {
10527            assert!(
10528                (b as i16 - 128).abs() <= 2,
10529                "pixel byte {i} = {b}, expected ~128 for neutral-grey NV12"
10530            );
10531        }
10532    }
10533
10534    #[test]
10535    fn test_nv24_to_rgb_cpu() {
10536        // NV24 (4:4:4) at 8×4: contiguous buffer is [4*3, 8] = [12, 8] — Y plane
10537        // (4 rows) + full-res interleaved UV plane (8 rows = 2H, 2W bytes per
10538        // chroma row). Neutral-grey fill (Y=U=V=128) must convert to uniform
10539        // grey RGB, exercising the 2× UV stride and shape[0]/3 height recovery.
10540        // CPU-only test: pin to tight host memory (see test_nv12_odd_height_to_rgb_cpu).
10541        let mut src = TensorDyn::image(
10542            8,
10543            4,
10544            PixelFormat::Nv24,
10545            DType::U8,
10546            Some(TensorMemory::Mem),
10547            edgefirst_tensor::CpuAccess::ReadWrite,
10548        )
10549        .unwrap();
10550        assert_eq!(src.shape(), &[12, 8]);
10551        assert_eq!((src.width(), src.height()), (Some(8), Some(4)));
10552        src.as_typed::<u8>()
10553            .unwrap()
10554            .map()
10555            .unwrap()
10556            .as_mut_slice()
10557            .fill(128);
10558        // Tag BT.601 full-range (see test_nv12_odd_height_to_rgb_cpu): without it
10559        // the heuristic picks limited range and Y=128 expands to ~131.
10560        src.set_colorimetry(Some(
10561            edgefirst_tensor::Colorimetry::default()
10562                .with_encoding(edgefirst_tensor::ColorEncoding::Bt601)
10563                .with_range(edgefirst_tensor::ColorRange::Full),
10564        ));
10565
10566        let dst = TensorDyn::image(
10567            8,
10568            4,
10569            PixelFormat::Rgb,
10570            DType::U8,
10571            Some(TensorMemory::Mem),
10572            edgefirst_tensor::CpuAccess::ReadWrite,
10573        )
10574        .unwrap();
10575        let mut cpu_converter = CPUProcessor::new();
10576        let (result, _src, dst) = convert_img(
10577            &mut cpu_converter,
10578            src,
10579            dst,
10580            Rotation::None,
10581            Flip::None,
10582            Crop::no_crop(),
10583        );
10584        result.unwrap();
10585
10586        assert_eq!((dst.width(), dst.height()), (Some(8), Some(4)));
10587        let map = dst.as_typed::<u8>().unwrap().map().unwrap();
10588        for (i, &b) in map.as_slice().iter().enumerate() {
10589            assert!(
10590                (b as i16 - 128).abs() <= 2,
10591                "pixel byte {i} = {b}, expected ~128 for neutral-grey NV24"
10592            );
10593        }
10594    }
10595
10596    #[test]
10597    fn cpu_nv12_to_rgb_respects_tagged_bt2020() {
10598        // A uniform but *saturated* chroma sample (U/V far from neutral) so the
10599        // YUV→RGB matrix — not just the range — drives the result. Decoding the
10600        // same NV12 bytes under BT.601 / BT.709 / BT.2020 must yield three
10601        // distinct RGB triples, proving the CPU path honours the source's tagged
10602        // ColorEncoding instead of a hardcoded matrix. (G2D declines BT.2020 and
10603        // falls through to this CPU path; QA F9.)
10604        // CPU-only test: pin to tight host memory (see test_nv12_odd_height_to_rgb_cpu).
10605        fn decode_tagged(enc: edgefirst_tensor::ColorEncoding) -> [u8; 3] {
10606            let mut src = TensorDyn::image(
10607                8,
10608                4,
10609                PixelFormat::Nv12,
10610                DType::U8,
10611                Some(TensorMemory::Mem),
10612                edgefirst_tensor::CpuAccess::ReadWrite,
10613            )
10614            .unwrap();
10615            // NV12 8×4: 32-byte Y plane + 16-byte interleaved UV plane (4:2:0).
10616            assert_eq!(src.shape(), &[6, 8]);
10617            {
10618                let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
10619                let buf = map.as_mut_slice();
10620                buf[..32].fill(120); // Y
10621                for px in buf[32..].as_chunks_mut::<2>().0 {
10622                    px[0] = 180; // U / Cb
10623                    px[1] = 64; // V / Cr
10624                }
10625            }
10626            // Range held constant (Limited) across all three so only the encoding
10627            // matrix varies between runs.
10628            src.set_colorimetry(Some(
10629                edgefirst_tensor::Colorimetry::default()
10630                    .with_encoding(enc)
10631                    .with_range(edgefirst_tensor::ColorRange::Limited),
10632            ));
10633            let dst = TensorDyn::image(
10634                8,
10635                4,
10636                PixelFormat::Rgb,
10637                DType::U8,
10638                Some(TensorMemory::Mem),
10639                edgefirst_tensor::CpuAccess::ReadWrite,
10640            )
10641            .unwrap();
10642            let mut cpu = CPUProcessor::new();
10643            let (result, _src, dst) = convert_img(
10644                &mut cpu,
10645                src,
10646                dst,
10647                Rotation::None,
10648                Flip::None,
10649                Crop::no_crop(),
10650            );
10651            result.unwrap();
10652            let map = dst.as_typed::<u8>().unwrap().map().unwrap();
10653            let s = map.as_slice();
10654            [s[0], s[1], s[2]]
10655        }
10656
10657        let bt601 = decode_tagged(edgefirst_tensor::ColorEncoding::Bt601);
10658        let bt709 = decode_tagged(edgefirst_tensor::ColorEncoding::Bt709);
10659        let bt2020 = decode_tagged(edgefirst_tensor::ColorEncoding::Bt2020);
10660
10661        assert_ne!(
10662            bt2020, bt601,
10663            "BT.2020 must decode differently from BT.601 ({bt2020:?} vs {bt601:?})"
10664        );
10665        assert_ne!(
10666            bt2020, bt709,
10667            "BT.2020 must decode differently from BT.709 ({bt2020:?} vs {bt709:?})"
10668        );
10669        assert_ne!(
10670            bt601, bt709,
10671            "BT.601 must decode differently from BT.709 ({bt601:?} vs {bt709:?})"
10672        );
10673    }
10674
10675    #[test]
10676    fn test_nv12_to_rgb_cpu() {
10677        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
10678        let src = TensorDyn::image(
10679            1280,
10680            720,
10681            PixelFormat::Nv12,
10682            DType::U8,
10683            None,
10684            edgefirst_tensor::CpuAccess::ReadWrite,
10685        )
10686        .unwrap();
10687        src.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
10688            .copy_from_slice(&file);
10689
10690        let dst = TensorDyn::image(
10691            1280,
10692            720,
10693            PixelFormat::Rgb,
10694            DType::U8,
10695            None,
10696            edgefirst_tensor::CpuAccess::ReadWrite,
10697        )
10698        .unwrap();
10699        let mut cpu_converter = CPUProcessor::new();
10700
10701        let (result, _src, dst) = convert_img(
10702            &mut cpu_converter,
10703            src,
10704            dst,
10705            Rotation::None,
10706            Flip::None,
10707            Crop::no_crop(),
10708        );
10709        result.unwrap();
10710
10711        let target_image = crate::load_image_test_helper(
10712            &edgefirst_bench::testdata::read("zidane.jpg"),
10713            Some(PixelFormat::Rgb),
10714            None,
10715        )
10716        .unwrap();
10717
10718        // Threshold 0.95 (was 0.98): the reference now decodes the colour JPEG
10719        // to native NV12 and then converts to RGB (was a direct JPEG → RGB
10720        // decode), so it differs slightly from the RGB derived from the
10721        // separate `zidane.nv12` fixture.
10722        compare_images(&dst, &target_image, 0.95, function!());
10723    }
10724
10725    #[test]
10726    fn test_nv12_to_grey_cpu() {
10727        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
10728        let src = TensorDyn::image(
10729            1280,
10730            720,
10731            PixelFormat::Nv12,
10732            DType::U8,
10733            None,
10734            edgefirst_tensor::CpuAccess::ReadWrite,
10735        )
10736        .unwrap();
10737        src.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
10738            .copy_from_slice(&file);
10739
10740        let dst = TensorDyn::image(
10741            1280,
10742            720,
10743            PixelFormat::Grey,
10744            DType::U8,
10745            None,
10746            edgefirst_tensor::CpuAccess::ReadWrite,
10747        )
10748        .unwrap();
10749        let mut cpu_converter = CPUProcessor::new();
10750
10751        let (result, _src, dst) = convert_img(
10752            &mut cpu_converter,
10753            src,
10754            dst,
10755            Rotation::None,
10756            Flip::None,
10757            Crop::no_crop(),
10758        );
10759        result.unwrap();
10760
10761        let target_image = crate::load_image_test_helper(
10762            &edgefirst_bench::testdata::read("zidane.jpg"),
10763            Some(PixelFormat::Grey),
10764            None,
10765        )
10766        .unwrap();
10767
10768        // Threshold 0.95 (was 0.98): the reference grey frame now comes from
10769        // the colour JPEG decoded to native NV12 and then converted to GREY
10770        // (was a direct JPEG → GREY decode), so it differs slightly from the
10771        // grey derived from the `zidane.nv12` fixture.
10772        compare_images(&dst, &target_image, 0.95, function!());
10773    }
10774
10775    #[test]
10776    fn test_nv12_to_yuyv_cpu() {
10777        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
10778        let src = TensorDyn::image(
10779            1280,
10780            720,
10781            PixelFormat::Nv12,
10782            DType::U8,
10783            None,
10784            edgefirst_tensor::CpuAccess::ReadWrite,
10785        )
10786        .unwrap();
10787        src.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
10788            .copy_from_slice(&file);
10789
10790        let dst = TensorDyn::image(
10791            1280,
10792            720,
10793            PixelFormat::Yuyv,
10794            DType::U8,
10795            None,
10796            edgefirst_tensor::CpuAccess::ReadWrite,
10797        )
10798        .unwrap();
10799        let mut cpu_converter = CPUProcessor::new();
10800
10801        let (result, _src, dst) = convert_img(
10802            &mut cpu_converter,
10803            src,
10804            dst,
10805            Rotation::None,
10806            Flip::None,
10807            Crop::no_crop(),
10808        );
10809        result.unwrap();
10810
10811        let target_image = crate::load_image_test_helper(
10812            &edgefirst_bench::testdata::read("zidane.jpg"),
10813            Some(PixelFormat::Rgb),
10814            None,
10815        )
10816        .unwrap();
10817
10818        // Threshold 0.95 (was 0.98): the reference now decodes the colour JPEG
10819        // to native NV12 and then converts to RGB (was a direct JPEG → RGB
10820        // decode), so it differs slightly from the YUYV-sourced frame derived
10821        // from the separate `zidane.nv12` fixture.
10822        compare_images_convert_to_rgb(&dst, &target_image, 0.95, function!());
10823    }
10824
10825    #[test]
10826    fn test_cpu_resize_nv16() {
10827        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
10828        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
10829
10830        let cpu_nv16_dst = TensorDyn::image(
10831            640,
10832            640,
10833            PixelFormat::Nv16,
10834            DType::U8,
10835            None,
10836            edgefirst_tensor::CpuAccess::ReadWrite,
10837        )
10838        .unwrap();
10839        let cpu_rgb_dst = TensorDyn::image(
10840            640,
10841            640,
10842            PixelFormat::Rgb,
10843            DType::U8,
10844            None,
10845            edgefirst_tensor::CpuAccess::ReadWrite,
10846        )
10847        .unwrap();
10848        let mut cpu_converter = CPUProcessor::new();
10849        let crop = Crop::letterbox([255, 128, 0, 255]);
10850
10851        let (result, src, cpu_nv16_dst) = convert_img(
10852            &mut cpu_converter,
10853            src,
10854            cpu_nv16_dst,
10855            Rotation::None,
10856            Flip::None,
10857            crop,
10858        );
10859        result.unwrap();
10860
10861        let (result, _src, cpu_rgb_dst) = convert_img(
10862            &mut cpu_converter,
10863            src,
10864            cpu_rgb_dst,
10865            Rotation::None,
10866            Flip::None,
10867            crop,
10868        );
10869        result.unwrap();
10870        compare_images_convert_to_rgb(
10871            &cpu_nv16_dst,
10872            &cpu_rgb_dst,
10873            yuv_threshold(0.99),
10874            function!(),
10875        );
10876    }
10877
10878    /// Similarity threshold for a comparison whose path runs a YUV↔RGB
10879    /// conversion.
10880    ///
10881    /// [`crate::cpu::convert`]'s `yuv_mode()` deliberately selects
10882    /// `YuvConversionMode::Fast` on cores without Armv8.1 `rdm`
10883    /// (Cortex-A53/A35 class, e.g. the i.MX 8M Plus), because `Balanced`
10884    /// emulates `SQRDMLAH` there and costs roughly 2×. `Fast` is a
10885    /// lower-precision integer approximation with a documented error of up to
10886    /// ~2/255, so a threshold calibrated on an `rdm`-capable core is
10887    /// unreachable on those cores **by design**: NV16 measures 0.9885 against
10888    /// 0.99 on an A53, while every `rdm` core passes.
10889    ///
10890    /// No CI runner is A53-class, so this only reproduces on target.
10891    ///
10892    /// Relaxing per-core keeps the assertion tight where the precise path
10893    /// runs, instead of loosening it everywhere to accommodate one core.
10894    fn yuv_threshold(balanced: f64) -> f64 {
10895        #[cfg(target_arch = "aarch64")]
10896        {
10897            if !std::arch::is_aarch64_feature_detected!("rdm") {
10898                return balanced - 0.01;
10899            }
10900        }
10901        balanced
10902    }
10903
10904    fn load_bytes_to_tensor(
10905        width: usize,
10906        height: usize,
10907        format: PixelFormat,
10908        memory: Option<TensorMemory>,
10909        bytes: &[u8],
10910    ) -> Result<TensorDyn, Error> {
10911        let src = TensorDyn::image(
10912            width,
10913            height,
10914            format,
10915            DType::U8,
10916            memory,
10917            edgefirst_tensor::CpuAccess::ReadWrite,
10918        )?;
10919        src.as_typed::<u8>()
10920            .unwrap()
10921            .map()?
10922            .as_mut_slice()
10923            .copy_from_slice(bytes);
10924        Ok(src)
10925    }
10926
10927    // DEDUP: this function is also defined verbatim in
10928    // `crates/image/src/gl/tests.rs` (inside `mod gl_tests`). Both copies
10929    // must be kept in sync. Cross-module sharing would require either a
10930    // `pub(crate)` test-helper module (which pollutes the non-test API) or a
10931    // separate test-utils crate — both are disproportionate for a single
10932    // helper. If the implementation ever diverges, extract to a shared
10933    // `test_helpers` module in this crate.
10934    fn compare_images(img1: &TensorDyn, img2: &TensorDyn, threshold: f64, name: &str) {
10935        assert_eq!(img1.height(), img2.height(), "Heights differ");
10936        assert_eq!(img1.width(), img2.width(), "Widths differ");
10937        assert_eq!(
10938            img1.format().unwrap(),
10939            img2.format().unwrap(),
10940            "PixelFormat differ"
10941        );
10942        assert!(
10943            matches!(
10944                img1.format().unwrap(),
10945                PixelFormat::Rgb | PixelFormat::Rgba | PixelFormat::Grey | PixelFormat::PlanarRgb
10946            ),
10947            "format must be Rgb or Rgba for comparison"
10948        );
10949
10950        // A pitch-padded destination -- the D3D11 staging texture behind a
10951        // Windows `TensorMemory::DmaBuf` tensor pads an RGBA row to 128 bytes
10952        // -- spaces its rows at `effective_row_stride()`, so reading the head
10953        // of the mapping as a tight image would read padding as pixels.
10954        // `copy_to_flat` is the tensor's own compaction and degenerates to one
10955        // memcpy when the tensor is tight.
10956        fn flat(img: &TensorDyn) -> Vec<u8> {
10957            let t = img.as_typed::<u8>().expect("u8 image");
10958            let mut out = vec![0u8; t.shape().iter().product::<usize>()];
10959            t.copy_to_flat(&mut out).expect("compact the padded rows");
10960            out
10961        }
10962
10963        let image1 = match img1.format().unwrap() {
10964            PixelFormat::Rgb => image::RgbImage::from_vec(
10965                img1.width().unwrap() as u32,
10966                img1.height().unwrap() as u32,
10967                flat(img1),
10968            )
10969            .unwrap(),
10970            PixelFormat::Rgba => image::RgbaImage::from_vec(
10971                img1.width().unwrap() as u32,
10972                img1.height().unwrap() as u32,
10973                flat(img1),
10974            )
10975            .unwrap()
10976            .convert(),
10977            PixelFormat::Grey => image::GrayImage::from_vec(
10978                img1.width().unwrap() as u32,
10979                img1.height().unwrap() as u32,
10980                flat(img1),
10981            )
10982            .unwrap()
10983            .convert(),
10984            PixelFormat::PlanarRgb => image::GrayImage::from_vec(
10985                img1.width().unwrap() as u32,
10986                (img1.height().unwrap() * 3) as u32,
10987                flat(img1),
10988            )
10989            .unwrap()
10990            .convert(),
10991            _ => return,
10992        };
10993
10994        let image2 = match img2.format().unwrap() {
10995            PixelFormat::Rgb => image::RgbImage::from_vec(
10996                img2.width().unwrap() as u32,
10997                img2.height().unwrap() as u32,
10998                flat(img2),
10999            )
11000            .unwrap(),
11001            PixelFormat::Rgba => image::RgbaImage::from_vec(
11002                img2.width().unwrap() as u32,
11003                img2.height().unwrap() as u32,
11004                flat(img2),
11005            )
11006            .unwrap()
11007            .convert(),
11008            PixelFormat::Grey => image::GrayImage::from_vec(
11009                img2.width().unwrap() as u32,
11010                img2.height().unwrap() as u32,
11011                flat(img2),
11012            )
11013            .unwrap()
11014            .convert(),
11015            PixelFormat::PlanarRgb => image::GrayImage::from_vec(
11016                img2.width().unwrap() as u32,
11017                (img2.height().unwrap() * 3) as u32,
11018                flat(img2),
11019            )
11020            .unwrap()
11021            .convert(),
11022            _ => return,
11023        };
11024
11025        let similarity = image_compare::rgb_similarity_structure(
11026            &image_compare::Algorithm::RootMeanSquared,
11027            &image1,
11028            &image2,
11029        )
11030        .expect("Image Comparison failed");
11031        if similarity.score < threshold {
11032            // image1.save(format!("{name}_1.png"));
11033            // image2.save(format!("{name}_2.png"));
11034            similarity
11035                .image
11036                .to_color_map()
11037                .save(format!("{name}.png"))
11038                .unwrap();
11039            panic!(
11040                "{name}: converted image and target image have similarity score too low: {} < {}",
11041                similarity.score, threshold
11042            )
11043        }
11044    }
11045
11046    fn compare_images_convert_to_rgb(
11047        img1: &TensorDyn,
11048        img2: &TensorDyn,
11049        threshold: f64,
11050        name: &str,
11051    ) {
11052        assert_eq!(img1.height(), img2.height(), "Heights differ");
11053        assert_eq!(img1.width(), img2.width(), "Widths differ");
11054
11055        let mut img_rgb1 = TensorDyn::image(
11056            img1.width().unwrap(),
11057            img1.height().unwrap(),
11058            PixelFormat::Rgb,
11059            DType::U8,
11060            Some(TensorMemory::Mem),
11061            edgefirst_tensor::CpuAccess::ReadWrite,
11062        )
11063        .unwrap();
11064        let mut img_rgb2 = TensorDyn::image(
11065            img1.width().unwrap(),
11066            img1.height().unwrap(),
11067            PixelFormat::Rgb,
11068            DType::U8,
11069            Some(TensorMemory::Mem),
11070            edgefirst_tensor::CpuAccess::ReadWrite,
11071        )
11072        .unwrap();
11073        let mut __cv = CPUProcessor::default();
11074        let r1 = __cv.convert(
11075            img1,
11076            &mut img_rgb1,
11077            crate::Rotation::None,
11078            crate::Flip::None,
11079            crate::Crop::default(),
11080        );
11081        let r2 = __cv.convert(
11082            img2,
11083            &mut img_rgb2,
11084            crate::Rotation::None,
11085            crate::Flip::None,
11086            crate::Crop::default(),
11087        );
11088        if r1.is_err() || r2.is_err() {
11089            // Fallback: compare raw bytes as greyscale strip
11090            let w = img1.width().unwrap() as u32;
11091            let data1 = img1.as_typed::<u8>().unwrap().map().unwrap().to_vec();
11092            let data2 = img2.as_typed::<u8>().unwrap().map().unwrap().to_vec();
11093            let h1 = (data1.len() as u32) / w;
11094            let h2 = (data2.len() as u32) / w;
11095            let g1 = image::GrayImage::from_vec(w, h1, data1).unwrap();
11096            let g2 = image::GrayImage::from_vec(w, h2, data2).unwrap();
11097            let similarity = image_compare::gray_similarity_structure(
11098                &image_compare::Algorithm::RootMeanSquared,
11099                &g1,
11100                &g2,
11101            )
11102            .expect("Image Comparison failed");
11103            if similarity.score < threshold {
11104                panic!(
11105                    "{name}: converted image and target image have similarity score too low: {} < {}",
11106                    similarity.score, threshold
11107                )
11108            }
11109            return;
11110        }
11111
11112        let image1 = image::RgbImage::from_vec(
11113            img_rgb1.width().unwrap() as u32,
11114            img_rgb1.height().unwrap() as u32,
11115            img_rgb1.as_typed::<u8>().unwrap().map().unwrap().to_vec(),
11116        )
11117        .unwrap();
11118
11119        let image2 = image::RgbImage::from_vec(
11120            img_rgb2.width().unwrap() as u32,
11121            img_rgb2.height().unwrap() as u32,
11122            img_rgb2.as_typed::<u8>().unwrap().map().unwrap().to_vec(),
11123        )
11124        .unwrap();
11125
11126        let similarity = image_compare::rgb_similarity_structure(
11127            &image_compare::Algorithm::RootMeanSquared,
11128            &image1,
11129            &image2,
11130        )
11131        .expect("Image Comparison failed");
11132        if similarity.score < threshold {
11133            // image1.save(format!("{name}_1.png"));
11134            // image2.save(format!("{name}_2.png"));
11135            similarity
11136                .image
11137                .to_color_map()
11138                .save(format!("{name}.png"))
11139                .unwrap();
11140            panic!(
11141                "{name}: converted image and target image have similarity score too low: {} < {}",
11142                similarity.score, threshold
11143            )
11144        }
11145    }
11146
11147    // =========================================================================
11148    // PixelFormat::Nv12 Format Tests
11149    // =========================================================================
11150
11151    #[test]
11152    fn test_nv12_image_creation() {
11153        let width = 640;
11154        let height = 480;
11155        let img = TensorDyn::image(
11156            width,
11157            height,
11158            PixelFormat::Nv12,
11159            DType::U8,
11160            None,
11161            edgefirst_tensor::CpuAccess::ReadWrite,
11162        )
11163        .unwrap();
11164
11165        assert_eq!(img.width(), Some(width));
11166        assert_eq!(img.height(), Some(height));
11167        assert_eq!(img.format().unwrap(), PixelFormat::Nv12);
11168        // PixelFormat::Nv12 uses shape [H*3/2, W] to store Y plane + UV plane
11169        assert_eq!(
11170            img.as_typed::<u8>().unwrap().shape(),
11171            &[height * 3 / 2, width]
11172        );
11173    }
11174
11175    #[test]
11176    fn test_nv12_channels() {
11177        let img = TensorDyn::image(
11178            640,
11179            480,
11180            PixelFormat::Nv12,
11181            DType::U8,
11182            None,
11183            edgefirst_tensor::CpuAccess::ReadWrite,
11184        )
11185        .unwrap();
11186        // PixelFormat::Nv12.channels() returns 1 (luma plane)
11187        assert_eq!(img.format().unwrap().channels(), 1);
11188    }
11189
11190    // =========================================================================
11191    // Tensor Format Metadata Tests
11192    // =========================================================================
11193
11194    #[test]
11195    fn test_tensor_set_format_planar() {
11196        let mut tensor = Tensor::<u8>::new(&[3, 480, 640], None, None).unwrap();
11197        tensor.set_format(PixelFormat::PlanarRgb).unwrap();
11198        assert_eq!(tensor.format(), Some(PixelFormat::PlanarRgb));
11199        assert_eq!(tensor.width(), Some(640));
11200        assert_eq!(tensor.height(), Some(480));
11201    }
11202
11203    #[test]
11204    fn test_tensor_set_format_interleaved() {
11205        let mut tensor = Tensor::<u8>::new(&[480, 640, 4], None, None).unwrap();
11206        tensor.set_format(PixelFormat::Rgba).unwrap();
11207        assert_eq!(tensor.format(), Some(PixelFormat::Rgba));
11208        assert_eq!(tensor.width(), Some(640));
11209        assert_eq!(tensor.height(), Some(480));
11210    }
11211
11212    #[test]
11213    fn test_tensordyn_image_rgb() {
11214        let img = TensorDyn::image(
11215            640,
11216            480,
11217            PixelFormat::Rgb,
11218            DType::U8,
11219            None,
11220            edgefirst_tensor::CpuAccess::ReadWrite,
11221        )
11222        .unwrap();
11223        assert_eq!(img.width(), Some(640));
11224        assert_eq!(img.height(), Some(480));
11225        assert_eq!(img.format(), Some(PixelFormat::Rgb));
11226    }
11227
11228    #[test]
11229    fn test_tensordyn_image_planar_rgb() {
11230        let img = TensorDyn::image(
11231            640,
11232            480,
11233            PixelFormat::PlanarRgb,
11234            DType::U8,
11235            None,
11236            edgefirst_tensor::CpuAccess::ReadWrite,
11237        )
11238        .unwrap();
11239        assert_eq!(img.width(), Some(640));
11240        assert_eq!(img.height(), Some(480));
11241        assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
11242    }
11243
11244    #[test]
11245    fn test_rgb_int8_format() {
11246        // Int8 variant: same PixelFormat::Rgb but with DType::I8
11247        let img = TensorDyn::image(
11248            1280,
11249            720,
11250            PixelFormat::Rgb,
11251            DType::I8,
11252            Some(TensorMemory::Mem),
11253            edgefirst_tensor::CpuAccess::ReadWrite,
11254        )
11255        .unwrap();
11256        assert_eq!(img.width(), Some(1280));
11257        assert_eq!(img.height(), Some(720));
11258        assert_eq!(img.format(), Some(PixelFormat::Rgb));
11259        assert_eq!(img.dtype(), DType::I8);
11260    }
11261
11262    #[test]
11263    fn test_planar_rgb_int8_format() {
11264        let img = TensorDyn::image(
11265            1280,
11266            720,
11267            PixelFormat::PlanarRgb,
11268            DType::I8,
11269            Some(TensorMemory::Mem),
11270            edgefirst_tensor::CpuAccess::ReadWrite,
11271        )
11272        .unwrap();
11273        assert_eq!(img.width(), Some(1280));
11274        assert_eq!(img.height(), Some(720));
11275        assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
11276        assert_eq!(img.dtype(), DType::I8);
11277    }
11278
11279    #[test]
11280    fn test_rgb_from_tensor() {
11281        let mut tensor = Tensor::<u8>::new(&[720, 1280, 3], None, None).unwrap();
11282        tensor.set_format(PixelFormat::Rgb).unwrap();
11283        let img = TensorDyn::from(tensor);
11284        assert_eq!(img.width(), Some(1280));
11285        assert_eq!(img.height(), Some(720));
11286        assert_eq!(img.format(), Some(PixelFormat::Rgb));
11287    }
11288
11289    #[test]
11290    fn test_planar_rgb_from_tensor() {
11291        let mut tensor = Tensor::<u8>::new(&[3, 720, 1280], None, None).unwrap();
11292        tensor.set_format(PixelFormat::PlanarRgb).unwrap();
11293        let img = TensorDyn::from(tensor);
11294        assert_eq!(img.width(), Some(1280));
11295        assert_eq!(img.height(), Some(720));
11296        assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
11297    }
11298
11299    #[test]
11300    fn test_dtype_determines_int8() {
11301        // DType::I8 indicates int8 data
11302        let u8_img = TensorDyn::image(
11303            64,
11304            64,
11305            PixelFormat::Rgb,
11306            DType::U8,
11307            None,
11308            edgefirst_tensor::CpuAccess::ReadWrite,
11309        )
11310        .unwrap();
11311        let i8_img = TensorDyn::image(
11312            64,
11313            64,
11314            PixelFormat::Rgb,
11315            DType::I8,
11316            None,
11317            edgefirst_tensor::CpuAccess::ReadWrite,
11318        )
11319        .unwrap();
11320        assert_eq!(u8_img.dtype(), DType::U8);
11321        assert_eq!(i8_img.dtype(), DType::I8);
11322    }
11323
11324    #[test]
11325    fn test_pixel_layout_packed_vs_planar() {
11326        // Packed vs planar layout classification
11327        assert_eq!(PixelFormat::Rgb.layout(), PixelLayout::Packed);
11328        assert_eq!(PixelFormat::Rgba.layout(), PixelLayout::Packed);
11329        assert_eq!(PixelFormat::PlanarRgb.layout(), PixelLayout::Planar);
11330        assert_eq!(PixelFormat::Nv12.layout(), PixelLayout::SemiPlanar);
11331    }
11332
11333    /// Integration test that exercises the PBO-to-PBO convert path.
11334    /// A PBO cannot hand out a host address that outlives its map guard, and
11335    /// the error has to say why rather than reading as unfinished work.
11336    ///
11337    /// `glMapBufferRange`'s pointer is valid only until `glUnmapBuffer`, and
11338    /// this backend maps with `MAP_READ_BIT | MAP_WRITE_BIT` — there is no
11339    /// `GL_MAP_PERSISTENT_BIT` and no `glBufferStorage` in the tree. Pinning
11340    /// would mean holding the buffer mapped across GL work, which is the
11341    /// opposite of what a PBO is for. `EXT_buffer_storage` would change the
11342    /// answer, but that is a buffer-allocation feature, not wiring.
11343    #[cfg(target_os = "linux")]
11344    #[cfg(feature = "opengl")]
11345    #[test]
11346    fn pbo_reports_why_it_cannot_pin() {
11347        use edgefirst_tensor::{Error, TensorTrait};
11348
11349        let converter = ImageProcessor::new().unwrap();
11350        let is_pbo = converter
11351            .opengl
11352            .as_ref()
11353            .is_some_and(|gl| gl.transfer_backend() == opengl_headless::TransferBackend::Pbo);
11354        if !is_pbo {
11355            // Direct stderr, not eprintln!: libtest captures println!/eprintln!
11356            // and replays it only for FAILING tests, so a skip would print
11357            // "... ok" with its reason discarded and be indistinguishable from
11358            // having run. See TESTING.md.
11359            use std::io::Write;
11360            let _ = writeln!(
11361                &mut std::io::stderr(),
11362                "SKIPPED: pbo_reports_why_it_cannot_pin - transfer backend is not PBO"
11363            );
11364            return;
11365        }
11366
11367        let t = converter
11368            .create_image(
11369                64,
11370                64,
11371                PixelFormat::Rgba,
11372                DType::U8,
11373                None,
11374                edgefirst_tensor::CpuAccess::ReadWrite,
11375            )
11376            .unwrap();
11377        assert_eq!(t.as_typed::<u8>().unwrap().memory(), TensorMemory::Pbo);
11378        let t = t.as_typed::<u8>().unwrap();
11379
11380        match t.pin_host(edgefirst_tensor::CpuAccess::ReadWrite) {
11381            Err(Error::NotImplemented(m)) => {
11382                assert!(
11383                    m.contains("glMapBufferRange"),
11384                    "must name the constraint: {m}"
11385                );
11386                assert!(m.contains("map()"), "must point at the supported API: {m}");
11387            }
11388            other => panic!("expected an explained NotImplemented, got {other:?}"),
11389        }
11390        // Sync is unavailable for the same reason, and must not silently succeed.
11391        assert!(matches!(
11392            t.sync_for_cpu(edgefirst_tensor::CpuAccess::Read),
11393            Err(Error::NotImplemented(_))
11394        ));
11395
11396        // The supported path still works, which is what makes the refusal safe.
11397        let g = t.map().expect("map must work where pin cannot");
11398        assert!(!g.as_slice().is_empty());
11399    }
11400
11401    /// Uses ImageProcessor::create_image() to allocate PBO-backed tensors,
11402    /// then converts between them. Skipped when GL is unavailable or the
11403    /// backend is not PBO (e.g. DMA-buf systems).
11404    #[cfg(target_os = "linux")]
11405    #[cfg(feature = "opengl")]
11406    #[test]
11407    fn test_convert_pbo_to_pbo() {
11408        let mut converter = ImageProcessor::new().unwrap();
11409
11410        // Skip if GL is not available or backend is not PBO
11411        let is_pbo = converter
11412            .opengl
11413            .as_ref()
11414            .is_some_and(|gl| gl.transfer_backend() == opengl_headless::TransferBackend::Pbo);
11415        if !is_pbo {
11416            log::warn!("Skipping test_convert_pbo_to_pbo: backend is not PBO");
11417            return;
11418        }
11419
11420        let src_w = 640;
11421        let src_h = 480;
11422        let dst_w = 320;
11423        let dst_h = 240;
11424
11425        // Create PBO-backed source image
11426        let pbo_src = converter
11427            .create_image(
11428                src_w,
11429                src_h,
11430                PixelFormat::Rgba,
11431                DType::U8,
11432                None,
11433                edgefirst_tensor::CpuAccess::ReadWrite,
11434            )
11435            .unwrap();
11436        assert_eq!(
11437            pbo_src.as_typed::<u8>().unwrap().memory(),
11438            TensorMemory::Pbo,
11439            "create_image should produce a PBO tensor"
11440        );
11441
11442        // Fill source PBO with test pattern: load JPEG then convert Mem→PBO
11443        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
11444        let jpeg_src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
11445
11446        // Resize JPEG into a Mem temp of the right size, then copy into PBO
11447        let mem_src = TensorDyn::image(
11448            src_w,
11449            src_h,
11450            PixelFormat::Rgba,
11451            DType::U8,
11452            Some(TensorMemory::Mem),
11453            edgefirst_tensor::CpuAccess::ReadWrite,
11454        )
11455        .unwrap();
11456        let (result, _jpeg_src, mem_src) = convert_img(
11457            &mut CPUProcessor::new(),
11458            jpeg_src,
11459            mem_src,
11460            Rotation::None,
11461            Flip::None,
11462            Crop::no_crop(),
11463        );
11464        result.unwrap();
11465
11466        // Copy pixel data into the PBO source by mapping it
11467        {
11468            let src_data = mem_src.as_typed::<u8>().unwrap().map().unwrap();
11469            let mut pbo_map = pbo_src.as_typed::<u8>().unwrap().map().unwrap();
11470            pbo_map.copy_from_slice(&src_data);
11471        }
11472
11473        // Create PBO-backed destination image
11474        let pbo_dst = converter
11475            .create_image(
11476                dst_w,
11477                dst_h,
11478                PixelFormat::Rgba,
11479                DType::U8,
11480                None,
11481                edgefirst_tensor::CpuAccess::ReadWrite,
11482            )
11483            .unwrap();
11484        assert_eq!(
11485            pbo_dst.as_typed::<u8>().unwrap().memory(),
11486            TensorMemory::Pbo
11487        );
11488
11489        // Convert PBO→PBO (this exercises convert_pbo_to_pbo)
11490        let mut pbo_dst = pbo_dst;
11491        let result = converter.convert(
11492            &pbo_src,
11493            &mut pbo_dst,
11494            Rotation::None,
11495            Flip::None,
11496            Crop::no_crop(),
11497        );
11498        result.unwrap();
11499
11500        // Verify: compare with CPU-only conversion of the same input
11501        let cpu_dst = TensorDyn::image(
11502            dst_w,
11503            dst_h,
11504            PixelFormat::Rgba,
11505            DType::U8,
11506            Some(TensorMemory::Mem),
11507            edgefirst_tensor::CpuAccess::ReadWrite,
11508        )
11509        .unwrap();
11510        let (result, _mem_src, cpu_dst) = convert_img(
11511            &mut CPUProcessor::new(),
11512            mem_src,
11513            cpu_dst,
11514            Rotation::None,
11515            Flip::None,
11516            Crop::no_crop(),
11517        );
11518        result.unwrap();
11519
11520        let pbo_dst_img = {
11521            let mut __t = pbo_dst.into_u8().unwrap();
11522            __t.set_format(PixelFormat::Rgba).unwrap();
11523            TensorDyn::from(__t)
11524        };
11525        compare_images(&pbo_dst_img, &cpu_dst, 0.95, function!());
11526        log::info!("test_convert_pbo_to_pbo: PASS — PBO-to-PBO convert matches CPU reference");
11527    }
11528
11529    #[test]
11530    fn test_image_bgra() {
11531        let img = TensorDyn::image(
11532            640,
11533            480,
11534            PixelFormat::Bgra,
11535            DType::U8,
11536            Some(edgefirst_tensor::TensorMemory::Mem),
11537            edgefirst_tensor::CpuAccess::ReadWrite,
11538        )
11539        .unwrap();
11540        assert_eq!(img.width(), Some(640));
11541        assert_eq!(img.height(), Some(480));
11542        assert_eq!(img.format().unwrap().channels(), 4);
11543        assert_eq!(img.format().unwrap(), PixelFormat::Bgra);
11544    }
11545
11546    // ========================================================================
11547    // Tests for EDGEFIRST_FORCE_BACKEND env var
11548    // ========================================================================
11549
11550    #[test]
11551    fn test_force_backend_cpu() {
11552        let _lock = acquire_env_lock();
11553        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
11554        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
11555        let converter = ImageProcessor::new().unwrap();
11556        assert!(converter.cpu.is_some());
11557        assert_eq!(converter.forced_backend, Some(ForcedBackend::Cpu));
11558    }
11559
11560    #[test]
11561    fn test_force_backend_invalid() {
11562        let _lock = acquire_env_lock();
11563        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
11564        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "invalid") };
11565        let result = ImageProcessor::new();
11566        assert!(
11567            matches!(&result, Err(Error::ForcedBackendUnavailable(s)) if s.contains("unknown")),
11568            "invalid backend value should return ForcedBackendUnavailable error: {result:?}"
11569        );
11570    }
11571
11572    #[test]
11573    fn test_force_backend_unset() {
11574        let _lock = acquire_env_lock();
11575        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
11576        unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") };
11577        let converter = ImageProcessor::new().unwrap();
11578        assert!(converter.forced_backend.is_none());
11579    }
11580
11581    // ========================================================================
11582    // Tests for hybrid mask path error handling
11583    // ========================================================================
11584
11585    #[test]
11586    fn test_draw_proto_masks_no_cpu_returns_error() {
11587        // Serialize against all other env-var-mutating tests.
11588        let _lock = acquire_env_lock();
11589        let _guard = EnvGuard::snapshot(&[
11590            "EDGEFIRST_FORCE_BACKEND",
11591            "EDGEFIRST_DISABLE_GL",
11592            "EDGEFIRST_DISABLE_G2D",
11593            "EDGEFIRST_DISABLE_CPU",
11594        ]);
11595
11596        // Disable all backends so cpu.is_none() after construction.
11597        unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
11598        unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
11599        unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
11600
11601        let mut converter = ImageProcessor::new().unwrap();
11602        assert!(converter.cpu.is_none(), "CPU should be disabled");
11603
11604        let dst = TensorDyn::image(
11605            640,
11606            480,
11607            PixelFormat::Rgba,
11608            DType::U8,
11609            Some(TensorMemory::Mem),
11610            edgefirst_tensor::CpuAccess::ReadWrite,
11611        )
11612        .unwrap();
11613        let mut dst_dyn = dst;
11614        let det = [DetectBox {
11615            bbox: edgefirst_tensor::BoundingBox {
11616                xmin: 0.1,
11617                ymin: 0.1,
11618                xmax: 0.5,
11619                ymax: 0.5,
11620            },
11621            score: 0.9,
11622            label: 0,
11623        }];
11624        let proto_data = {
11625            use edgefirst_tensor::{Tensor, TensorDyn};
11626            let coeff_t = Tensor::<f32>::from_slice(&[0.5_f32; 4], &[1, 4]).unwrap();
11627            let protos_t =
11628                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
11629            ProtoData {
11630                mask_coefficients: TensorDyn::F32(coeff_t),
11631                protos: TensorDyn::F32(protos_t),
11632                layout: ProtoLayout::Nhwc,
11633            }
11634        };
11635        let result =
11636            converter.draw_proto_masks(&mut dst_dyn, &det, &proto_data, Default::default());
11637        assert!(
11638            matches!(&result, Err(Error::Internal(s)) if s.contains("CPU backend")),
11639            "draw_proto_masks without CPU should return Internal error: {result:?}"
11640        );
11641    }
11642
11643    #[test]
11644    fn test_draw_proto_masks_cpu_fallback_works() {
11645        // Force CPU-only backend to ensure the CPU fallback path executes.
11646        // Serialized under ENV_MUTEX so we don't race with disable-var tests.
11647        let _lock = acquire_env_lock();
11648        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
11649        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
11650        let mut converter = ImageProcessor::new().unwrap();
11651        assert!(converter.cpu.is_some());
11652
11653        let dst = TensorDyn::image(
11654            64,
11655            64,
11656            PixelFormat::Rgba,
11657            DType::U8,
11658            Some(TensorMemory::Mem),
11659            edgefirst_tensor::CpuAccess::ReadWrite,
11660        )
11661        .unwrap();
11662        let mut dst_dyn = dst;
11663        let det = [DetectBox {
11664            bbox: edgefirst_tensor::BoundingBox {
11665                xmin: 0.1,
11666                ymin: 0.1,
11667                xmax: 0.5,
11668                ymax: 0.5,
11669            },
11670            score: 0.9,
11671            label: 0,
11672        }];
11673        let proto_data = {
11674            use edgefirst_tensor::{Tensor, TensorDyn};
11675            let coeff_t = Tensor::<f32>::from_slice(&[0.5_f32; 4], &[1, 4]).unwrap();
11676            let protos_t =
11677                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
11678            ProtoData {
11679                mask_coefficients: TensorDyn::F32(coeff_t),
11680                protos: TensorDyn::F32(protos_t),
11681                layout: ProtoLayout::Nhwc,
11682            }
11683        };
11684        let result =
11685            converter.draw_proto_masks(&mut dst_dyn, &det, &proto_data, Default::default());
11686        assert!(result.is_ok(), "CPU fallback path should work: {result:?}");
11687    }
11688
11689    // ============================================================
11690    // draw_decoded_masks / draw_proto_masks — 4-scenario pixel-
11691    // verified tests. Exercises each backend against the full
11692    // output-contract matrix:
11693    //
11694    //   | detections | background | expected dst             |
11695    //   |------------|------------|--------------------------|
11696    //   | empty      | none       | fully cleared (0x00)     |
11697    //   | empty      | set        | fully equal to bg        |
11698    //   | set        | none       | cleared outside box +    |
11699    //   |            |            | mask-coloured inside     |
11700    //   | set        | set        | bg outside box + mask    |
11701    //   |            |            | blended inside           |
11702    //
11703    // Every test pre-fills dst with a non-zero "dirty" pattern so
11704    // that any silent `return Ok(())` leaks the pattern into the
11705    // asserted output and fails loudly.
11706    // ============================================================
11707
11708    // =========================================================================
11709    // Env-var serialisation helpers
11710    //
11711    // ALL tests that mutate any EDGEFIRST_* backend env var must hold
11712    // ENV_MUTEX for their full duration.  This single mutex serialises
11713    // test_disable_env_var, test_draw_proto_masks_no_cpu_returns_error,
11714    // test_force_backend_*, with_force_backend, and with_env — preventing
11715    // any two of them from racing in a parallel `cargo test` run.
11716    // =========================================================================
11717
11718    /// Acquire the process-wide env-var mutex.  Returns a guard that must be
11719    /// kept alive for the entire duration of the test body.
11720    fn acquire_env_lock() -> std::sync::MutexGuard<'static, ()> {
11721        use std::sync::{Mutex, OnceLock};
11722        static ENV_MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
11723        ENV_MUTEX
11724            .get_or_init(|| Mutex::new(()))
11725            .lock()
11726            .unwrap_or_else(|e| e.into_inner())
11727    }
11728
11729    /// RAII guard that snapshots a set of env vars on construction and
11730    /// restores them on `Drop`, even if the test panics.
11731    struct EnvGuard {
11732        vars: Vec<(&'static str, Option<String>)>,
11733    }
11734
11735    impl EnvGuard {
11736        /// Snapshot the current values of `names`.  Call this while holding
11737        /// the env lock (the lock is not taken here — that is the caller's
11738        /// responsibility so the lock scope can be wider than the guard).
11739        fn snapshot(names: &[&'static str]) -> Self {
11740            Self {
11741                vars: names.iter().map(|&k| (k, std::env::var(k).ok())).collect(),
11742            }
11743        }
11744    }
11745
11746    impl Drop for EnvGuard {
11747        fn drop(&mut self) {
11748            for (k, v) in &self.vars {
11749                match v {
11750                    Some(s) => unsafe { std::env::set_var(k, s) },
11751                    None => unsafe { std::env::remove_var(k) },
11752                }
11753            }
11754        }
11755    }
11756
11757    /// `EDGEFIRST_INTERPOLATION` accepts `nearest` and `bilinear` in any
11758    /// case and ignores anything else.
11759    #[test]
11760    fn test_interpolation_env_parse() {
11761        let _lock = acquire_env_lock();
11762        let _guard = EnvGuard::snapshot(&["EDGEFIRST_INTERPOLATION"]);
11763        for (value, want) in [
11764            (Some("nearest"), Some(Interpolation::Nearest)),
11765            (Some(" BILINEAR "), Some(Interpolation::Bilinear)),
11766            (Some("lanczos"), None),
11767            (Some(""), None),
11768            (None, None),
11769        ] {
11770            match value {
11771                Some(v) => unsafe { std::env::set_var("EDGEFIRST_INTERPOLATION", v) },
11772                None => unsafe { std::env::remove_var("EDGEFIRST_INTERPOLATION") },
11773            }
11774            assert_eq!(
11775                Interpolation::from_env(),
11776                want,
11777                "EDGEFIRST_INTERPOLATION={value:?}"
11778            );
11779        }
11780    }
11781
11782    /// `ImageProcessorConfig::interpolation` reaches the CPU backend, and
11783    /// `EDGEFIRST_INTERPOLATION` overrides it.
11784    #[test]
11785    fn test_interpolation_config_and_env_reach_cpu() {
11786        let _lock = acquire_env_lock();
11787        let _guard = EnvGuard::snapshot(&["EDGEFIRST_INTERPOLATION"]);
11788        let mut checker = Vec::with_capacity(8 * 8 * 4);
11789        for y in 0..8 {
11790            for x in 0..8 {
11791                let v = if (x + y) % 2 == 0 { 0u8 } else { 255 };
11792                checker.extend_from_slice(&[v, v, v, 255]);
11793            }
11794        }
11795        let resize = |processor: &mut dyn ImageProcessorTrait| {
11796            let mut src = TensorDyn::image(
11797                8,
11798                8,
11799                PixelFormat::Rgba,
11800                DType::U8,
11801                Some(TensorMemory::Mem),
11802                edgefirst_tensor::CpuAccess::ReadWrite,
11803            )
11804            .unwrap();
11805            src.as_u8_mut()
11806                .unwrap()
11807                .map_mut()
11808                .unwrap()
11809                .as_mut_slice()
11810                .copy_from_slice(&checker);
11811            let mut dst = TensorDyn::image(
11812                12,
11813                12,
11814                PixelFormat::Rgba,
11815                DType::U8,
11816                Some(TensorMemory::Mem),
11817                edgefirst_tensor::CpuAccess::ReadWrite,
11818            )
11819            .unwrap();
11820            processor
11821                .convert(&src, &mut dst, Rotation::None, Flip::None, Crop::no_crop())
11822                .unwrap();
11823            dst.as_u8().unwrap().map().unwrap().as_slice().to_vec()
11824        };
11825        let nearest = resize(&mut CPUProcessor::new_nearest());
11826        let bilinear = resize(&mut CPUProcessor::new());
11827        assert_ne!(nearest, bilinear, "the checker must tell the filters apart");
11828        let config = |interpolation| ImageProcessorConfig {
11829            backend: ComputeBackend::Cpu,
11830            interpolation,
11831            ..Default::default()
11832        };
11833
11834        unsafe { std::env::remove_var("EDGEFIRST_INTERPOLATION") };
11835        let mut p = ImageProcessor::with_config(config(Interpolation::Nearest)).unwrap();
11836        assert_eq!(resize(&mut p), nearest, "config Nearest");
11837        p.set_interpolation(Interpolation::Bilinear).unwrap();
11838        assert_eq!(resize(&mut p), bilinear, "set_interpolation(Bilinear)");
11839
11840        unsafe { std::env::set_var("EDGEFIRST_INTERPOLATION", "nearest") };
11841        let mut p = ImageProcessor::with_config(config(Interpolation::Bilinear)).unwrap();
11842        assert_eq!(
11843            resize(&mut p),
11844            nearest,
11845            "EDGEFIRST_INTERPOLATION pins Nearest"
11846        );
11847        p.set_interpolation(Interpolation::Bilinear).unwrap();
11848        assert_eq!(
11849            resize(&mut p),
11850            nearest,
11851            "the env pin outlives set_interpolation"
11852        );
11853    }
11854
11855    /// Run `body` with `EDGEFIRST_FORCE_BACKEND` temporarily set (or
11856    /// removed), restoring the prior value afterward. Tests are env-
11857    /// serialized via the process-wide `ENV_MUTEX`.
11858    fn with_force_backend<R>(value: Option<&str>, body: impl FnOnce() -> R) -> R {
11859        let _lock = acquire_env_lock();
11860        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
11861        match value {
11862            Some(v) => unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", v) },
11863            None => unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") },
11864        }
11865        body()
11866    }
11867
11868    /// Allocate an RGBA image tensor and pre-fill every byte with a
11869    /// distinctive non-zero pattern. Any test that relies on the old
11870    /// "dst is already cleared" assumption will see this pattern leak
11871    /// through to the output and fail.
11872    fn make_dirty_dst(w: usize, h: usize, mem: Option<TensorMemory>) -> TensorDyn {
11873        let dst = TensorDyn::image(
11874            w,
11875            h,
11876            PixelFormat::Rgba,
11877            DType::U8,
11878            mem,
11879            edgefirst_tensor::CpuAccess::ReadWrite,
11880        )
11881        .unwrap();
11882        {
11883            use edgefirst_tensor::TensorMapTrait;
11884            let u8t = dst.as_typed::<u8>().unwrap();
11885            let mut map = u8t.map().unwrap();
11886            for (i, b) in map.as_mut_slice().iter_mut().enumerate() {
11887                *b = 0xA0u8.wrapping_add((i as u8) & 0x3F);
11888            }
11889        }
11890        dst
11891    }
11892
11893    /// Allocate an RGBA background filled with a constant colour.
11894    fn make_bg(w: usize, h: usize, mem: Option<TensorMemory>, rgba: [u8; 4]) -> TensorDyn {
11895        let bg = TensorDyn::image(
11896            w,
11897            h,
11898            PixelFormat::Rgba,
11899            DType::U8,
11900            mem,
11901            edgefirst_tensor::CpuAccess::ReadWrite,
11902        )
11903        .unwrap();
11904        {
11905            use edgefirst_tensor::TensorMapTrait;
11906            let u8t = bg.as_typed::<u8>().unwrap();
11907            let mut map = u8t.map().unwrap();
11908            for chunk in map.as_mut_slice().as_chunks_mut::<4>().0 {
11909                chunk.copy_from_slice(&rgba);
11910            }
11911        }
11912        bg
11913    }
11914
11915    fn pixel_at(dst: &TensorDyn, x: usize, y: usize) -> [u8; 4] {
11916        use edgefirst_tensor::TensorMapTrait;
11917        let w = dst.width().unwrap();
11918        let off = (y * w + x) * 4;
11919        let u8t = dst.as_typed::<u8>().unwrap();
11920        let map = u8t.map().unwrap();
11921        let s = map.as_slice();
11922        [s[off], s[off + 1], s[off + 2], s[off + 3]]
11923    }
11924
11925    fn assert_every_pixel_eq(dst: &TensorDyn, expected: [u8; 4], case: &str) {
11926        use edgefirst_tensor::TensorMapTrait;
11927        let u8t = dst.as_typed::<u8>().unwrap();
11928        let map = u8t.map().unwrap();
11929        for (i, chunk) in map.as_slice().as_chunks::<4>().0.iter().enumerate() {
11930            assert_eq!(
11931                chunk, &expected,
11932                "{case}: pixel idx {i} = {chunk:?}, expected {expected:?}"
11933            );
11934        }
11935    }
11936
11937    /// Scenario 1: empty detections, empty segmentation, no background
11938    /// → dst must be fully cleared to 0x00000000.
11939    fn scenario_empty_no_bg(processor: &mut ImageProcessor, case: &str) {
11940        let mut dst = make_dirty_dst(64, 64, None);
11941        processor
11942            .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::default())
11943            .unwrap_or_else(|e| panic!("{case}/decoded_masks empty+no-bg failed: {e:?}"));
11944        assert_every_pixel_eq(&dst, [0, 0, 0, 0], &format!("{case}/decoded"));
11945
11946        let mut dst = make_dirty_dst(64, 64, None);
11947        let proto = {
11948            use edgefirst_tensor::{Tensor, TensorDyn};
11949            // Placeholder (no detections); shape [1, 4] to keep the tensor well-formed.
11950            let coeff_t = Tensor::<f32>::from_slice(&[0.0_f32; 4], &[1, 4]).unwrap();
11951            let protos_t =
11952                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
11953            ProtoData {
11954                mask_coefficients: TensorDyn::F32(coeff_t),
11955                protos: TensorDyn::F32(protos_t),
11956                layout: ProtoLayout::Nhwc,
11957            }
11958        };
11959        processor
11960            .draw_proto_masks(&mut dst, &[], &proto, MaskOverlay::default())
11961            .unwrap_or_else(|e| panic!("{case}/proto_masks empty+no-bg failed: {e:?}"));
11962        assert_every_pixel_eq(&dst, [0, 0, 0, 0], &format!("{case}/proto"));
11963    }
11964
11965    /// Scenario 2: empty detections, empty segmentation, background set
11966    /// → dst must be fully equal to bg.
11967    fn scenario_empty_with_bg(processor: &mut ImageProcessor, case: &str) {
11968        let bg_color = [42, 99, 200, 255];
11969        let bg = make_bg(64, 64, None, bg_color);
11970        let overlay = MaskOverlay::new().with_background(&bg);
11971
11972        let mut dst = make_dirty_dst(64, 64, None);
11973        processor
11974            .draw_decoded_masks(&mut dst, &[], &[], overlay)
11975            .unwrap_or_else(|e| panic!("{case}/decoded_masks empty+bg failed: {e:?}"));
11976        assert_every_pixel_eq(&dst, bg_color, &format!("{case}/decoded bg blit"));
11977
11978        let mut dst = make_dirty_dst(64, 64, None);
11979        let proto = {
11980            use edgefirst_tensor::{Tensor, TensorDyn};
11981            // Placeholder (no detections); shape [1, 4] to keep the tensor well-formed.
11982            let coeff_t = Tensor::<f32>::from_slice(&[0.0_f32; 4], &[1, 4]).unwrap();
11983            let protos_t =
11984                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
11985            ProtoData {
11986                mask_coefficients: TensorDyn::F32(coeff_t),
11987                protos: TensorDyn::F32(protos_t),
11988                layout: ProtoLayout::Nhwc,
11989            }
11990        };
11991        processor
11992            .draw_proto_masks(&mut dst, &[], &proto, overlay)
11993            .unwrap_or_else(|e| panic!("{case}/proto_masks empty+bg failed: {e:?}"));
11994        assert_every_pixel_eq(&dst, bg_color, &format!("{case}/proto bg blit"));
11995    }
11996
11997    /// Scenario 3: one detection with a fully-opaque segmentation fill,
11998    /// no background → outside the box dst must be 0x00, inside it must
11999    /// be a non-zero mask colour (the render_segmentation output).
12000    fn scenario_detect_no_bg(processor: &mut ImageProcessor, case: &str) {
12001        use edgefirst_tensor::Segmentation;
12002        use ndarray::Array3;
12003        processor
12004            .set_class_colors(&[[200, 80, 40, 255]])
12005            .expect("set_class_colors");
12006
12007        let detect = DetectBox {
12008            bbox: [0.25, 0.25, 0.75, 0.75].into(),
12009            score: 0.99,
12010            label: 0,
12011        };
12012        let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
12013        let seg = Segmentation {
12014            segmentation: edgefirst_tensor::Tensor::from_arrayview3(seg_arr.view())
12015                .unwrap()
12016                .into(),
12017            xmin: 0.25,
12018            ymin: 0.25,
12019            xmax: 0.75,
12020            ymax: 0.75,
12021        };
12022
12023        let mut dst = make_dirty_dst(64, 64, None);
12024        processor
12025            .draw_decoded_masks(&mut dst, &[detect], &[seg], MaskOverlay::default())
12026            .unwrap_or_else(|e| panic!("{case}/decoded_masks detect+no-bg failed: {e:?}"));
12027
12028        // Outside the bbox (corner): must be cleared black.
12029        let corner = pixel_at(&dst, 2, 2);
12030        assert_eq!(
12031            corner,
12032            [0, 0, 0, 0],
12033            "{case}/decoded: corner (2,2) leaked dirty pattern: {corner:?}"
12034        );
12035        // Inside the bbox (center): the mask colour must be visible.
12036        // Any non-zero pixel is acceptable — exact rendering varies
12037        // between backends (GL smoothstep, CPU nearest).
12038        let center = pixel_at(&dst, 32, 32);
12039        assert!(
12040            center != [0, 0, 0, 0],
12041            "{case}/decoded: center (32,32) was not coloured: {center:?}"
12042        );
12043    }
12044
12045    /// Scenario 4: detection + background. Outside the box must match
12046    /// bg; inside the box must NOT match bg (mask blended on top).
12047    fn scenario_detect_with_bg(processor: &mut ImageProcessor, case: &str) {
12048        use edgefirst_tensor::Segmentation;
12049        use ndarray::Array3;
12050        processor
12051            .set_class_colors(&[[200, 80, 40, 255]])
12052            .expect("set_class_colors");
12053        let bg_color = [10, 20, 30, 255];
12054        let bg = make_bg(64, 64, None, bg_color);
12055
12056        let detect = DetectBox {
12057            bbox: [0.25, 0.25, 0.75, 0.75].into(),
12058            score: 0.99,
12059            label: 0,
12060        };
12061        let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
12062        let seg = Segmentation {
12063            segmentation: edgefirst_tensor::Tensor::from_arrayview3(seg_arr.view())
12064                .unwrap()
12065                .into(),
12066            xmin: 0.25,
12067            ymin: 0.25,
12068            xmax: 0.75,
12069            ymax: 0.75,
12070        };
12071
12072        let overlay = MaskOverlay::new().with_background(&bg);
12073        let mut dst = make_dirty_dst(64, 64, None);
12074        processor
12075            .draw_decoded_masks(&mut dst, &[detect], &[seg], overlay)
12076            .unwrap_or_else(|e| panic!("{case}/decoded_masks detect+bg failed: {e:?}"));
12077
12078        // Outside the bbox (corner): bg colour.
12079        let corner = pixel_at(&dst, 2, 2);
12080        assert_eq!(
12081            corner, bg_color,
12082            "{case}/decoded: corner (2,2) should show bg {bg_color:?} got {corner:?}"
12083        );
12084        // Inside the bbox (center): mask blended on bg, must differ from
12085        // pure bg (alpha-blend with mask colour produces a distinct shade).
12086        let center = pixel_at(&dst, 32, 32);
12087        assert!(
12088            center != bg_color,
12089            "{case}/decoded: center (32,32) should differ from bg {bg_color:?}, got {center:?}"
12090        );
12091    }
12092
12093    /// Run all 4 scenarios against the processor. Skip gracefully if
12094    /// construction fails (backend unavailable on this host).
12095    fn run_all_scenarios(
12096        force_backend: Option<&'static str>,
12097        case: &'static str,
12098        require_dma_for_bg: bool,
12099    ) {
12100        if require_dma_for_bg && !edgefirst_tensor::is_dma_available() {
12101            crate::test_support::report_skip(&format!("{case} — DMA not available on this host"));
12102            return;
12103        }
12104        let processor_result = with_force_backend(force_backend, ImageProcessor::new);
12105        let mut processor = match processor_result {
12106            Ok(p) => p,
12107            Err(e) => {
12108                crate::test_support::report_skip(&format!("{case} — backend init failed: {e:?}"));
12109                return;
12110            }
12111        };
12112        scenario_empty_no_bg(&mut processor, case);
12113        scenario_empty_with_bg(&mut processor, case);
12114        scenario_detect_no_bg(&mut processor, case);
12115        scenario_detect_with_bg(&mut processor, case);
12116    }
12117
12118    #[test]
12119    fn test_draw_masks_4_scenarios_cpu() {
12120        run_all_scenarios(Some("cpu"), "cpu", false);
12121    }
12122
12123    #[test]
12124    fn test_draw_masks_4_scenarios_auto() {
12125        run_all_scenarios(None, "auto", false);
12126    }
12127
12128    #[cfg(any(
12129        target_os = "linux",
12130        target_os = "macos",
12131        target_os = "ios",
12132        target_os = "android",
12133        target_os = "windows"
12134    ))]
12135    #[cfg(feature = "opengl")]
12136    #[test]
12137    fn test_draw_masks_4_scenarios_opengl() {
12138        run_all_scenarios(Some("opengl"), "opengl", false);
12139    }
12140
12141    /// G2D forced backend: exercises the zero-detection empty-frame
12142    /// paths via `g2d_clear` and `g2d_blit`. Scenarios 3 and 4 (with
12143    /// detections) expect `NotImplemented` since G2D has no rasterizer
12144    /// for boxes / masks.
12145    #[cfg(target_os = "linux")]
12146    #[test]
12147    fn test_draw_masks_zero_detection_g2d_forced() {
12148        if !edgefirst_tensor::is_dma_available() {
12149            crate::test_support::report_skip("g2d forced — DMA not available on this host");
12150            return;
12151        }
12152        let processor_result = with_force_backend(Some("g2d"), ImageProcessor::new);
12153        let mut processor = match processor_result {
12154            Ok(p) => p,
12155            Err(e) => {
12156                crate::test_support::report_skip(&format!("g2d forced — init failed: {e:?}"));
12157                return;
12158            }
12159        };
12160
12161        // Case 1: empty + no bg. G2D requires DMA-backed dst.
12162        let mut dst = TensorDyn::image(
12163            64,
12164            64,
12165            PixelFormat::Rgba,
12166            DType::U8,
12167            Some(TensorMemory::DmaBuf),
12168            edgefirst_tensor::CpuAccess::ReadWrite,
12169        )
12170        .unwrap();
12171        {
12172            use edgefirst_tensor::TensorMapTrait;
12173            let u8t = dst.as_typed_mut::<u8>().unwrap();
12174            let mut map = u8t.map().unwrap();
12175            map.as_mut_slice().fill(0xBB);
12176        }
12177        processor
12178            .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::default())
12179            .expect("g2d empty+no-bg");
12180        assert_every_pixel_eq(&dst, [0, 0, 0, 0], "g2d/case1 cleared");
12181
12182        // Case 2: empty + bg. Both surfaces DMA-backed for g2d_blit.
12183        let bg_color = [7, 11, 13, 255];
12184        let bg = {
12185            let t = TensorDyn::image(
12186                64,
12187                64,
12188                PixelFormat::Rgba,
12189                DType::U8,
12190                Some(TensorMemory::DmaBuf),
12191                edgefirst_tensor::CpuAccess::ReadWrite,
12192            )
12193            .unwrap();
12194            {
12195                use edgefirst_tensor::TensorMapTrait;
12196                let u8t = t.as_typed::<u8>().unwrap();
12197                let mut map = u8t.map().unwrap();
12198                for chunk in map.as_mut_slice().as_chunks_mut::<4>().0 {
12199                    chunk.copy_from_slice(&bg_color);
12200                }
12201            }
12202            t
12203        };
12204        let mut dst = TensorDyn::image(
12205            64,
12206            64,
12207            PixelFormat::Rgba,
12208            DType::U8,
12209            Some(TensorMemory::DmaBuf),
12210            edgefirst_tensor::CpuAccess::ReadWrite,
12211        )
12212        .unwrap();
12213        {
12214            use edgefirst_tensor::TensorMapTrait;
12215            let u8t = dst.as_typed_mut::<u8>().unwrap();
12216            let mut map = u8t.map().unwrap();
12217            map.as_mut_slice().fill(0x55);
12218        }
12219        processor
12220            .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::new().with_background(&bg))
12221            .expect("g2d empty+bg");
12222        assert_every_pixel_eq(&dst, bg_color, "g2d/case2 bg blit");
12223
12224        // Case 3 and 4: detect present — must return NotImplemented.
12225        let detect = DetectBox {
12226            bbox: [0.25, 0.25, 0.75, 0.75].into(),
12227            score: 0.9,
12228            label: 0,
12229        };
12230        let mut dst = TensorDyn::image(
12231            64,
12232            64,
12233            PixelFormat::Rgba,
12234            DType::U8,
12235            Some(TensorMemory::DmaBuf),
12236            edgefirst_tensor::CpuAccess::ReadWrite,
12237        )
12238        .unwrap();
12239        let err = processor
12240            .draw_decoded_masks(&mut dst, &[detect], &[], MaskOverlay::default())
12241            .expect_err("g2d must reject detect-present draw_decoded_masks");
12242        assert!(
12243            matches!(err, Error::NotImplemented(_)),
12244            "g2d case3 wrong error: {err:?}"
12245        );
12246    }
12247
12248    #[test]
12249    fn test_set_format_then_cpu_convert() {
12250        // Force CPU backend; serialized under ENV_MUTEX to avoid racing with
12251        // test_force_backend_* and test_disable_env_var.
12252        let _lock = acquire_env_lock();
12253        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
12254        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
12255        let mut processor = ImageProcessor::new().unwrap();
12256
12257        // Load a source image
12258        let image = edgefirst_bench::testdata::read("zidane.jpg");
12259        let src = load_image_test_helper(&image, Some(PixelFormat::Rgba), None).unwrap();
12260
12261        // Create a raw tensor, then attach format — simulating the from_fd workflow
12262        let mut dst =
12263            TensorDyn::new(&[640, 640, 3], DType::U8, Some(TensorMemory::Mem), None).unwrap();
12264        dst.set_format(PixelFormat::Rgb).unwrap();
12265
12266        // Convert should work with the set_format-annotated tensor
12267        processor
12268            .convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
12269            .unwrap();
12270
12271        // Verify format survived conversion
12272        assert_eq!(dst.format(), Some(PixelFormat::Rgb));
12273        assert_eq!(dst.width(), Some(640));
12274        assert_eq!(dst.height(), Some(640));
12275    }
12276
12277    /// Verify that creating multiple ImageProcessors on the same thread and
12278    /// performing a resize on each does not deadlock or error.
12279    ///
12280    /// Uses automatic memory allocation (DMA → PBO → Mem fallback) so that
12281    /// hardware backends (OpenGL, G2D) are exercised on capable targets.
12282    #[test]
12283    fn test_multiple_image_processors_same_thread() {
12284        // Hold the env mutex so env-var-mutating tests can't corrupt the state
12285        // seen by ImageProcessor::new() calls during this test.
12286        let _lock = acquire_env_lock();
12287        let mut processors: Vec<ImageProcessor> = (0..4)
12288            .map(|_| ImageProcessor::new().expect("ImageProcessor::new() failed"))
12289            .collect();
12290
12291        for proc in &mut processors {
12292            let src = proc
12293                .create_image(
12294                    128,
12295                    128,
12296                    PixelFormat::Rgb,
12297                    DType::U8,
12298                    None,
12299                    edgefirst_tensor::CpuAccess::ReadWrite,
12300                )
12301                .expect("create src failed");
12302            let mut dst = proc
12303                .create_image(
12304                    64,
12305                    64,
12306                    PixelFormat::Rgb,
12307                    DType::U8,
12308                    None,
12309                    edgefirst_tensor::CpuAccess::ReadWrite,
12310                )
12311                .expect("create dst failed");
12312            proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
12313                .expect("convert failed");
12314            assert_eq!(dst.width(), Some(64));
12315            assert_eq!(dst.height(), Some(64));
12316        }
12317    }
12318
12319    /// Verify that creating ImageProcessors on separate threads and performing
12320    /// a resize on each does not deadlock or error.
12321    ///
12322    /// Uses automatic memory allocation (DMA → PBO → Mem fallback) so that
12323    /// hardware backends (OpenGL, G2D) are exercised on capable targets.
12324    /// A 60-second timeout prevents CI from hanging on deadlock regressions.
12325    #[test]
12326    fn test_multiple_image_processors_separate_threads() {
12327        use std::sync::mpsc;
12328        use std::time::Duration;
12329
12330        // The Vivante GC7000UL driver (i.MX 8M Plus) double-frees on concurrent
12331        // EGL context teardown — four processors spun up on four threads here
12332        // trips it and aborts the whole test binary (SIGABRT, not a catchable
12333        // panic). The bug is the driver's, not the HAL's; this test is kept so
12334        // it still exercises the multi-context path on every other GPU. The
12335        // board lane and scripts/on-target-test.sh set
12336        // EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS on any board with /dev/galcore
12337        // to skip just this case there, while the run stays red anywhere else
12338        // a regression appears. (Skip, not #[ignore]: the platform is decided
12339        // at runtime, not compile time.)
12340        if std::env::var_os("EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS").is_some() {
12341            crate::test_support::report_skip(
12342                "test_multiple_image_processors_separate_threads — known Vivante \
12343                 GC7000UL concurrent-EGL-teardown double-free \
12344                 (EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS set)",
12345            );
12346            return;
12347        }
12348
12349        const TIMEOUT: Duration = Duration::from_secs(60);
12350
12351        // Hold the env mutex so env-var-mutating tests can't corrupt ImageProcessor::new()
12352        // calls made inside the spawned threads during this test.
12353        let _lock = acquire_env_lock();
12354
12355        let (tx, rx) = mpsc::channel::<()>();
12356
12357        std::thread::spawn(move || {
12358            let handles: Vec<_> = (0..4)
12359                .map(|i| {
12360                    std::thread::spawn(move || {
12361                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
12362                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
12363                        });
12364                        let src = proc
12365                            .create_image(
12366                                128,
12367                                128,
12368                                PixelFormat::Rgb,
12369                                DType::U8,
12370                                None,
12371                                edgefirst_tensor::CpuAccess::ReadWrite,
12372                            )
12373                            .unwrap_or_else(|e| panic!("create src failed on thread {i}: {e}"));
12374                        let mut dst = proc
12375                            .create_image(
12376                                64,
12377                                64,
12378                                PixelFormat::Rgb,
12379                                DType::U8,
12380                                None,
12381                                edgefirst_tensor::CpuAccess::ReadWrite,
12382                            )
12383                            .unwrap_or_else(|e| panic!("create dst failed on thread {i}: {e}"));
12384                        proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
12385                            .unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
12386                        assert_eq!(dst.width(), Some(64));
12387                        assert_eq!(dst.height(), Some(64));
12388                    })
12389                })
12390                .collect();
12391
12392            for (i, h) in handles.into_iter().enumerate() {
12393                h.join()
12394                    .unwrap_or_else(|e| panic!("thread {i} panicked: {e:?}"));
12395            }
12396
12397            let _ = tx.send(());
12398        });
12399
12400        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
12401            panic!("test_multiple_image_processors_separate_threads timed out after {TIMEOUT:?}")
12402        });
12403    }
12404
12405    /// Verify that 4 fully-initialized ImageProcessors on separate threads can
12406    /// all operate concurrently without deadlocking each other.
12407    ///
12408    /// All processors are created first, then a barrier synchronizes them so
12409    /// they all start converting at the same instant — maximizing contention.
12410    /// A 60-second timeout prevents CI from hanging on deadlock regressions.
12411    #[test]
12412    fn test_image_processors_concurrent_operations() {
12413        use std::sync::{mpsc, Arc, Barrier};
12414        use std::time::Duration;
12415
12416        const N: usize = 4;
12417        const ROUNDS: usize = 10;
12418        const TIMEOUT: Duration = Duration::from_secs(60);
12419
12420        // Hold the env mutex so env-var-mutating tests can't corrupt ImageProcessor::new()
12421        // calls made inside the spawned threads during this test.
12422        let _lock = acquire_env_lock();
12423
12424        let (tx, rx) = mpsc::channel::<()>();
12425
12426        std::thread::spawn(move || {
12427            let barrier = Arc::new(Barrier::new(N));
12428
12429            let handles: Vec<_> = (0..N)
12430                .map(|i| {
12431                    let barrier = Arc::clone(&barrier);
12432                    std::thread::spawn(move || {
12433                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
12434                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
12435                        });
12436
12437                        // All threads wait here until every processor is initialized.
12438                        barrier.wait();
12439
12440                        // Now all 4 hammer the GPU concurrently.
12441                        for round in 0..ROUNDS {
12442                            let src = proc
12443                                .create_image(
12444                                    128,
12445                                    128,
12446                                    PixelFormat::Rgb,
12447                                    DType::U8,
12448                                    None,
12449                                    edgefirst_tensor::CpuAccess::ReadWrite,
12450                                )
12451                                .unwrap_or_else(|e| {
12452                                    panic!("create src failed on thread {i} round {round}: {e}")
12453                                });
12454                            let mut dst = proc
12455                                .create_image(
12456                                    64,
12457                                    64,
12458                                    PixelFormat::Rgb,
12459                                    DType::U8,
12460                                    None,
12461                                    edgefirst_tensor::CpuAccess::ReadWrite,
12462                                )
12463                                .unwrap_or_else(|e| {
12464                                    panic!("create dst failed on thread {i} round {round}: {e}")
12465                                });
12466                            proc.convert(
12467                                &src,
12468                                &mut dst,
12469                                Rotation::None,
12470                                Flip::None,
12471                                Crop::default(),
12472                            )
12473                            .unwrap_or_else(|e| {
12474                                panic!("convert failed on thread {i} round {round}: {e}")
12475                            });
12476                            assert_eq!(dst.width(), Some(64));
12477                            assert_eq!(dst.height(), Some(64));
12478                        }
12479                    })
12480                })
12481                .collect();
12482
12483            for (i, h) in handles.into_iter().enumerate() {
12484                h.join()
12485                    .unwrap_or_else(|e| panic!("thread {i} panicked: {e:?}"));
12486            }
12487
12488            let _ = tx.send(());
12489        });
12490
12491        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
12492            panic!("test_image_processors_concurrent_operations timed out after {TIMEOUT:?}")
12493        });
12494    }
12495
12496    /// THE parallel-processors demonstration test: 4 ImageProcessors on 4
12497    /// threads, each with its own GL context and worker, converting
12498    /// per-thread-DISTINCT synthetic inputs concurrently (barrier-released);
12499    /// every output must byte-match the thread's own pre-barrier sequential
12500    /// oracle from the same processor. On LifecycleOnly platforms (Mali,
12501    /// V3D, Tegra, llvmpipe, macOS) the converts genuinely overlap on the
12502    /// GPU; on Vivante they serialize via the Full policy and the test still
12503    /// must pass. Distinct inputs make any cross-processor state leakage
12504    /// (wrong texture, wrong context, clobbered upload) visible as a byte
12505    /// diff rather than a coincidental match — proven by a scratch
12506    /// cross-wire run (neighbor's input post-oracle) failing on every
12507    /// thread with ~53% of bytes diverged.
12508    ///
12509    /// Skipped under EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS like
12510    /// `test_multiple_image_processors_separate_threads`: the galcore driver
12511    /// can abort intermittently on concurrent multi-processor lifecycles
12512    /// regardless of locking (P0 spike: reproduces fully serialized).
12513    #[test]
12514    fn test_parallel_processors_unique_outputs() {
12515        use std::sync::{mpsc, Arc, Barrier};
12516        use std::time::Duration;
12517
12518        const N: usize = 4;
12519        const ROUNDS: usize = 25;
12520        const TIMEOUT: Duration = Duration::from_secs(60);
12521
12522        if std::env::var_os("EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS").is_some() {
12523            crate::test_support::report_skip(
12524                "test_parallel_processors_unique_outputs — known Vivante \
12525                 GC7000UL concurrent-multi-processor driver abort \
12526                 (EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS set)",
12527            );
12528            return;
12529        }
12530
12531        let _lock = acquire_env_lock();
12532        let (tx, rx) = mpsc::channel::<()>();
12533
12534        std::thread::spawn(move || {
12535            let barrier = Arc::new(Barrier::new(N));
12536            let handles: Vec<_> = (0..N)
12537                .map(|i| {
12538                    let barrier = Arc::clone(&barrier);
12539                    std::thread::spawn(move || {
12540                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
12541                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
12542                        });
12543                        // CI-weight geometry (llvmpipe renders on the CPU).
12544                        let (w, h) = (640usize, 480usize);
12545                        let mem = if edgefirst_tensor::is_dma_available() {
12546                            Some(TensorMemory::DmaBuf)
12547                        } else {
12548                            Some(TensorMemory::Mem)
12549                        };
12550                        let src = proc
12551                            .create_image(
12552                                w,
12553                                h,
12554                                PixelFormat::Nv12,
12555                                DType::U8,
12556                                mem,
12557                                edgefirst_tensor::CpuAccess::ReadWrite,
12558                            )
12559                            .unwrap();
12560                        {
12561                            let t = src.as_typed::<u8>().unwrap();
12562                            let mut m = t.map().unwrap();
12563                            let s = m.as_mut_slice();
12564                            for (j, b) in s[..w * h].iter_mut().enumerate() {
12565                                *b = ((i * 53 + j) % 200 + 16) as u8;
12566                            }
12567                            for b in &mut s[w * h..] {
12568                                *b = (80 + i * 24) as u8;
12569                            }
12570                        }
12571                        let lb = Crop::letterbox([114, 114, 114, 255]);
12572                        let convert_once = |proc: &mut ImageProcessor| -> Vec<u8> {
12573                            let mut dst = proc
12574                                .create_image(
12575                                    320,
12576                                    320,
12577                                    PixelFormat::Rgba,
12578                                    DType::U8,
12579                                    mem,
12580                                    edgefirst_tensor::CpuAccess::ReadWrite,
12581                                )
12582                                .unwrap();
12583                            proc.convert(&src, &mut dst, Rotation::None, Flip::None, lb)
12584                                .unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
12585                            let t = dst.as_typed::<u8>().unwrap();
12586                            let m = t.map().unwrap();
12587                            m.as_slice().to_vec()
12588                        };
12589
12590                        let oracle = convert_once(&mut proc);
12591                        barrier.wait();
12592                        for round in 0..ROUNDS {
12593                            let out = convert_once(&mut proc);
12594                            let diffs = oracle.iter().zip(&out).filter(|(a, b)| a != b).count();
12595                            assert!(
12596                                diffs == 0,
12597                                "thread {i} round {round}: {diffs}/{} bytes diverged \
12598                                 from this processor's own oracle — cross-processor \
12599                                 GL state leakage under parallel execution",
12600                                oracle.len()
12601                            );
12602                        }
12603                    })
12604                })
12605                .collect();
12606
12607            for (i, h) in handles.into_iter().enumerate() {
12608                h.join()
12609                    .unwrap_or_else(|e| panic!("parallel thread {i} panicked: {e:?}"));
12610            }
12611            let _ = tx.send(());
12612        });
12613
12614        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
12615            panic!("test_parallel_processors_unique_outputs timed out after {TIMEOUT:?}")
12616        });
12617    }
12618
12619    /// Heavy on-demand stressor for the GL serialization policy: 4
12620    /// processors × 4 threads × barrier × 200 NV12 720p → RGB 640 letterbox
12621    /// converts (DMA where available); every output must byte-match the
12622    /// thread's own pre-barrier sequential oracle from the same processor.
12623    /// Ignored by default (heavy; board tool — the CI-weight version is
12624    /// `test_parallel_processors_unique_outputs`). Run explicitly, optionally
12625    /// pinning the policy via EDGEFIRST_GL_SERIALIZE=full|lifecycle:
12626    ///   <test binary> stress_parallel_processors_oracle --ignored
12627    #[test]
12628    #[ignore = "heavy on-demand GL-parallelism stressor; run explicitly on boards"]
12629    fn stress_parallel_processors_oracle() {
12630        use std::sync::{mpsc, Arc, Barrier};
12631        use std::time::Duration;
12632
12633        const N: usize = 4;
12634        const ROUNDS: usize = 200;
12635        const TIMEOUT: Duration = Duration::from_secs(600);
12636
12637        let _lock = acquire_env_lock();
12638        let (tx, rx) = mpsc::channel::<()>();
12639
12640        std::thread::spawn(move || {
12641            let barrier = Arc::new(Barrier::new(N));
12642            let handles: Vec<_> = (0..N)
12643                .map(|i| {
12644                    let barrier = Arc::clone(&barrier);
12645                    std::thread::spawn(move || {
12646                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
12647                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
12648                        });
12649                        let (w, h) = (1280usize, 720usize);
12650                        let mem = if edgefirst_tensor::is_dma_available() {
12651                            Some(TensorMemory::DmaBuf)
12652                        } else {
12653                            Some(TensorMemory::Mem)
12654                        };
12655
12656                        // Per-thread-distinct synthetic NV12 so cross-wired
12657                        // GL state between processors shows up as a byte diff.
12658                        let src = proc
12659                            .create_image(
12660                                w,
12661                                h,
12662                                PixelFormat::Nv12,
12663                                DType::U8,
12664                                mem,
12665                                edgefirst_tensor::CpuAccess::ReadWrite,
12666                            )
12667                            .unwrap();
12668                        {
12669                            let t = src.as_typed::<u8>().unwrap();
12670                            let mut m = t.map().unwrap();
12671                            let s = m.as_mut_slice();
12672                            for (j, b) in s[..w * h].iter_mut().enumerate() {
12673                                *b = ((i * 37 + j) % 200 + 16) as u8;
12674                            }
12675                            for b in &mut s[w * h..] {
12676                                *b = (96 + i * 16) as u8;
12677                            }
12678                        }
12679                        let lb = Crop::letterbox([114, 114, 114, 255]);
12680
12681                        let convert_once = |proc: &mut ImageProcessor| -> Vec<u8> {
12682                            let mut dst = proc
12683                                .create_image(
12684                                    640,
12685                                    640,
12686                                    PixelFormat::Rgb,
12687                                    DType::U8,
12688                                    mem,
12689                                    edgefirst_tensor::CpuAccess::ReadWrite,
12690                                )
12691                                .unwrap();
12692                            proc.convert(&src, &mut dst, Rotation::None, Flip::None, lb)
12693                                .unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
12694                            let t = dst.as_typed::<u8>().unwrap();
12695                            let m = t.map().unwrap();
12696                            m.as_slice().to_vec()
12697                        };
12698
12699                        let oracle = convert_once(&mut proc);
12700                        barrier.wait();
12701                        for round in 0..ROUNDS {
12702                            let out = convert_once(&mut proc);
12703                            let diffs = oracle.iter().zip(&out).filter(|(a, b)| a != b).count();
12704                            assert!(
12705                                diffs == 0,
12706                                "thread {i} round {round}: {diffs}/{} bytes diverged \
12707                                 from the pre-barrier oracle",
12708                                oracle.len()
12709                            );
12710                        }
12711                    })
12712                })
12713                .collect();
12714
12715            for (i, h) in handles.into_iter().enumerate() {
12716                h.join()
12717                    .unwrap_or_else(|e| panic!("stressor thread {i} panicked: {e:?}"));
12718            }
12719            let _ = tx.send(());
12720        });
12721
12722        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
12723            panic!("stress_parallel_processors_oracle timed out after {TIMEOUT:?}")
12724        });
12725    }
12726
12727    // =========================================================================
12728    // F16 / F32 auto-chain fallback integration tests
12729    // =========================================================================
12730
12731    /// Proves the auto-chain (OpenGL → G2D → CPU) NEVER errors for a float
12732    /// combo the GL path does NOT cover.
12733    ///
12734    /// `Yuyv → Rgb F32` is not handled by the GL float render path (which
12735    /// only covers `Rgba → PlanarRgb F16` and `Rgba → Rgb F32`), so the
12736    /// chain falls through to the CPU float path. Before commit 868a7649
12737    /// added CPU U8→F32/F16 support this would have returned `Err`; now it
12738    /// must return `Ok` with output in `[0, 1]` and all values finite.
12739    #[test]
12740    fn convert_f32_auto_never_errors_non_gl_combo() {
12741        const W: usize = 64;
12742        const H: usize = 64;
12743
12744        // Build a small synthetic YUYV source (Y=128, U=128, V=128 → near-grey).
12745        // YUYV packs two pixels into 4 bytes: [Y0, U, Y1, V] per macropixel.
12746        let src = TensorDyn::image(
12747            W,
12748            H,
12749            PixelFormat::Yuyv,
12750            DType::U8,
12751            Some(TensorMemory::Mem),
12752            edgefirst_tensor::CpuAccess::ReadWrite,
12753        )
12754        .unwrap();
12755        {
12756            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
12757            let data = map.as_mut_slice();
12758            for chunk in data.as_chunks_mut::<4>().0 {
12759                chunk[0] = 128; // Y0
12760                chunk[1] = 128; // U
12761                chunk[2] = 160; // Y1 — distinct so a layout bug is visible
12762                chunk[3] = 128; // V
12763            }
12764        }
12765
12766        let mut dst = TensorDyn::image(
12767            W,
12768            H,
12769            PixelFormat::Rgb,
12770            DType::F32,
12771            Some(TensorMemory::Mem),
12772            edgefirst_tensor::CpuAccess::ReadWrite,
12773        )
12774        .unwrap();
12775
12776        let mut proc = ImageProcessor::new().unwrap();
12777        let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
12778        assert!(
12779            result.is_ok(),
12780            "auto-chain Yuyv→Rgb F32 must not error: {:?}",
12781            result.err()
12782        );
12783
12784        // Verify all output values are finite and in [0, 1].
12785        let map = dst.as_typed::<f32>().unwrap().map().unwrap();
12786        let floats = map.as_slice();
12787        assert_eq!(floats.len(), W * H * 3, "unexpected output element count");
12788        for (i, &v) in floats.iter().enumerate() {
12789            assert!(
12790                v.is_finite() && (0.0..=1.0).contains(&v),
12791                "output[{i}]={v} is not finite or not in [0,1]"
12792            );
12793        }
12794
12795        // WEAK-1: Anti-all-zero spot-check.  A Y=128 YUYV source normalises to
12796        // ≈0.502 on the luma channel.  If the buffer is all-zero (e.g. the CPU
12797        // path never wrote to it) this assertion catches the regression.
12798        let first_non_zero = floats.iter().find(|&&v| v > 0.01);
12799        assert!(
12800            first_non_zero.is_some(),
12801            "all-zero output detected — CPU path likely did not write to the destination buffer"
12802        );
12803        // Y=128 → luma ≈ 0.502.  Spot-check the first pixel's R channel
12804        // (which carries luma for a near-grey YUV source).
12805        let r0 = floats[0];
12806        assert!(
12807            (r0 - 0.502_f32).abs() < 0.05,
12808            "first pixel R={r0} expected ≈0.502 (Y=128 neutral grey from YUYV source)"
12809        );
12810    }
12811
12812    /// Proves CPU-forced `Rgba → PlanarRgb F16` correctness.
12813    ///
12814    /// Uses a source with clearly distinct per-channel values so a
12815    /// plane-swap or layout bug surfaces immediately. Tolerance is 2^-9
12816    /// (one F16 ULP at 0.5, i.e. roughly 1/512).
12817    #[test]
12818    // `ImageProcessorConfig` carries a Linux-only `egl_display` field, so
12819    // `{ backend, ..Default::default() }` is a genuine update on Linux but
12820    // covers no remaining fields on macOS, where `clippy::needless_update`
12821    // then fires. `allow` (not `expect`) because the lint is platform-
12822    // conditional — it does not fire on Linux.
12823    #[allow(clippy::needless_update)]
12824    fn convert_f16_forced_cpu_correct() {
12825        const W: usize = 16;
12826        const H: usize = 16;
12827        const TOL: f32 = 1.0 / 512.0; // 2^-9
12828
12829        // pixel (y,x): R = 50+x, G = 100+y*8, B = 200
12830        let src = TensorDyn::image(
12831            W,
12832            H,
12833            PixelFormat::Rgba,
12834            DType::U8,
12835            Some(TensorMemory::Mem),
12836            edgefirst_tensor::CpuAccess::ReadWrite,
12837        )
12838        .unwrap();
12839        {
12840            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
12841            let data = map.as_mut_slice();
12842            for y in 0..H {
12843                for x in 0..W {
12844                    let i = y * W + x;
12845                    data[i * 4] = (50 + x) as u8; // R: 50..65
12846                    data[i * 4 + 1] = (100 + y * 8) as u8; // G: 100..220
12847                    data[i * 4 + 2] = 200; // B: constant
12848                    data[i * 4 + 3] = 255;
12849                }
12850            }
12851        }
12852
12853        let mut dst = TensorDyn::image(
12854            W,
12855            H,
12856            PixelFormat::PlanarRgb,
12857            DType::F16,
12858            Some(TensorMemory::Mem),
12859            edgefirst_tensor::CpuAccess::ReadWrite,
12860        )
12861        .unwrap();
12862
12863        let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
12864            backend: ComputeBackend::Cpu,
12865            ..Default::default()
12866        })
12867        .unwrap();
12868        proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
12869            .expect("forced-CPU Rgba→PlanarRgb F16 must not error");
12870
12871        let src_map = src.as_typed::<u8>().unwrap().map().unwrap();
12872        let src_bytes = src_map.as_slice();
12873        let dst_map = dst.as_typed::<half::f16>().unwrap().map().unwrap();
12874        let dst_halfs = dst_map.as_slice();
12875
12876        let plane = W * H;
12877        assert_eq!(dst_halfs.len(), plane * 3, "wrong output element count");
12878
12879        for y in 0..H {
12880            for x in 0..W {
12881                let i = y * W + x;
12882                let r_exp = src_bytes[i * 4] as f32 / 255.0;
12883                let g_exp = src_bytes[i * 4 + 1] as f32 / 255.0;
12884                let b_exp = src_bytes[i * 4 + 2] as f32 / 255.0;
12885
12886                let r_got = dst_halfs[i].to_f32();
12887                let g_got = dst_halfs[plane + i].to_f32();
12888                let b_got = dst_halfs[2 * plane + i].to_f32();
12889
12890                assert!(
12891                    (r_got - r_exp).abs() <= TOL,
12892                    "R plane ({x},{y}): got {r_got}, expected {r_exp}"
12893                );
12894                assert!(
12895                    (g_got - g_exp).abs() <= TOL,
12896                    "G plane ({x},{y}): got {g_got}, expected {g_exp}"
12897                );
12898                assert!(
12899                    (b_got - b_exp).abs() <= TOL,
12900                    "B plane ({x},{y}): got {b_got}, expected {b_exp}"
12901                );
12902
12903                // Catch plane-swap: R and G must differ (they have different formulas).
12904                if src_bytes[i * 4] != src_bytes[i * 4 + 1] {
12905                    assert_ne!(r_got, g_got, "R and G planes must differ at ({x},{y})");
12906                }
12907            }
12908        }
12909    }
12910
12911    /// Proves the auto-chain falls through to CPU for `Rgba → Rgb F32` with
12912    /// rotation set.
12913    ///
12914    /// The GL float render path rejects any call where rotation ≠ None,
12915    /// returning an error that causes the chain to continue. Before the CPU
12916    /// float fallback this would have produced an error at the end of the
12917    /// chain; now it must reach CPU and return `Ok` with finite `[0, 1]` output.
12918    #[test]
12919    fn convert_f32_with_rotation_falls_back() {
12920        const W: usize = 16;
12921        const H: usize = 16;
12922
12923        // RGBA8 source with a known gradient (distinct per-channel values).
12924        let src = TensorDyn::image(
12925            W,
12926            H,
12927            PixelFormat::Rgba,
12928            DType::U8,
12929            Some(TensorMemory::Mem),
12930            edgefirst_tensor::CpuAccess::ReadWrite,
12931        )
12932        .unwrap();
12933        {
12934            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
12935            let data = map.as_mut_slice();
12936            for y in 0..H {
12937                for x in 0..W {
12938                    let i = y * W + x;
12939                    data[i * 4] = (x * 16) as u8; // R
12940                    data[i * 4 + 1] = (y * 16) as u8; // G
12941                    data[i * 4 + 2] = 128; // B
12942                    data[i * 4 + 3] = 255;
12943                }
12944            }
12945        }
12946
12947        // Rotation swaps W and H, so dst is [W, H] (H×W output).
12948        let mut dst = TensorDyn::image(
12949            H, // dst W = src H after 90° rotation
12950            W, // dst H = src W after 90° rotation
12951            PixelFormat::Rgb,
12952            DType::F32,
12953            Some(TensorMemory::Mem),
12954            edgefirst_tensor::CpuAccess::ReadWrite,
12955        )
12956        .unwrap();
12957
12958        let mut proc = ImageProcessor::new().unwrap();
12959        let result = proc.convert(
12960            &src,
12961            &mut dst,
12962            Rotation::Clockwise90,
12963            Flip::None,
12964            Crop::default(),
12965        );
12966        assert!(
12967            result.is_ok(),
12968            "auto-chain Rgba→Rgb F32 with Rot90 must not error: {:?}",
12969            result.err()
12970        );
12971
12972        let map = dst.as_typed::<f32>().unwrap().map().unwrap();
12973        let floats = map.as_slice();
12974        assert_eq!(floats.len(), H * W * 3, "unexpected output element count");
12975        for (i, &v) in floats.iter().enumerate() {
12976            assert!(
12977                v.is_finite() && (0.0..=1.0).contains(&v),
12978                "output[{i}]={v} is not finite or not in [0,1]"
12979            );
12980        }
12981    }
12982
12983    /// GL-vs-CPU identity parity for `Rgba → PlanarRgb F16`.
12984    ///
12985    /// Converts the same RGBA8 source via the `OpenGl` backend and the
12986    /// forced `Cpu` backend, then verifies the two F16 output tensors agree
12987    /// element-wise within 2^-8 (two F16 ULPs at 0.5). Skipped when OpenGL
12988    /// or F16 render is unavailable, and when the host cannot hold the
12989    /// zero-copy F16 destination the GL float route needs.
12990    ///
12991    /// **The GL route is asserted, not assumed** (issue #179).
12992    /// `ComputeBackend::OpenGl` is not a forced backend: it leaves
12993    /// `forced_backend` at `None`, so a GL decline logs at debug and the CPU
12994    /// serves the same convert. Both sides of the comparison would then be
12995    /// the CPU's answer and the parity claim would be a tautology — which is
12996    /// exactly how a host whose RGBA16F FBO is incomplete (desktop NVIDIA:
12997    /// `GL_FRAMEBUFFER_INCOMPLETE_ATTACHMENT`) passed while proving nothing,
12998    /// `supported_render_dtypes().f16` above notwithstanding: that flag is
12999    /// an extension query, not a completed render. `convert_fallback_count()`
13000    /// staying flat across the GL convert is the proof, and a decline is
13001    /// reported as a skip rather than a failure because it is a property of
13002    /// the host's driver, not of this code — except under
13003    /// `HAL_TEST_REQUIRE_GL=1`, which turns it into a named failure, the
13004    /// same opt-in `crates/image/tests/dst_view_stride.rs` and
13005    /// `reconstructed_view_draw.rs` carry for a GL backend that fails to
13006    /// come up. A lane that has asserted it has GL should not be quietly
13007    /// losing this test's coverage.
13008    #[test]
13009    #[cfg(all(
13010        any(
13011            target_os = "linux",
13012            target_os = "macos",
13013            target_os = "ios",
13014            target_os = "android",
13015            target_os = "windows"
13016        ),
13017        feature = "opengl"
13018    ))]
13019    fn convert_f16_gl_cpu_parity_identity() {
13020        // The macOS coverage lane sets `HAL_TEST_REQUIRE_GL=1` for both
13021        // passes but deliberately hobbles pass 1 (unsigned binaries, the
13022        // ANGLE dlopen gate closed). Excluded there the same way
13023        // `gl_backend_available_canary` excludes it, so the gate below
13024        // enforces in pass 2 only.
13025        #[cfg(target_os = "macos")]
13026        if std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1")
13027            && std::env::var_os("HAL_TEST_ALLOW_DLOPEN_ANGLE").is_none()
13028        {
13029            crate::test_support::report_skip(
13030                "convert_f16_gl_cpu_parity_identity - ANGLE dlopen gate closed (coverage pass 1)",
13031            );
13032            return;
13033        }
13034        if !is_opengl_available() {
13035            crate::test_support::report_skip(
13036                "convert_f16_gl_cpu_parity_identity - OpenGL not available",
13037            );
13038            return;
13039        }
13040
13041        const W: usize = 16;
13042        const H: usize = 16;
13043        const TOL: f32 = 1.0 / 256.0; // 2^-8
13044
13045        // pixel (y,x): R = 40+x, G = 80+y*10, B = 180
13046        let src = TensorDyn::image(
13047            W,
13048            H,
13049            PixelFormat::Rgba,
13050            DType::U8,
13051            Some(TensorMemory::Mem),
13052            edgefirst_tensor::CpuAccess::ReadWrite,
13053        )
13054        .unwrap();
13055        {
13056            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
13057            let data = map.as_mut_slice();
13058            for y in 0..H {
13059                for x in 0..W {
13060                    let i = y * W + x;
13061                    data[i * 4] = (40 + x) as u8; // R
13062                    data[i * 4 + 1] = (80 + y * 10) as u8; // G
13063                    data[i * 4 + 2] = 180; // B
13064                    data[i * 4 + 3] = 255;
13065                }
13066            }
13067        }
13068
13069        // GL path.
13070        let gl_result = {
13071            let mut gl_proc = match ImageProcessor::with_config(ImageProcessorConfig {
13072                backend: ComputeBackend::OpenGl,
13073                ..Default::default()
13074            }) {
13075                Ok(p) => p,
13076                Err(e) => {
13077                    crate::test_support::report_skip(&format!(
13078                        "convert_f16_gl_cpu_parity_identity - GL backend unavailable: {e}"
13079                    ));
13080                    return;
13081                }
13082            };
13083
13084            if !gl_proc.supported_render_dtypes().f16 {
13085                crate::test_support::report_skip(
13086                    "convert_f16_gl_cpu_parity_identity - F16 render not supported",
13087                );
13088                return;
13089            }
13090
13091            // A destination the GL float route actually serves. A
13092            // `TensorMemory::Mem` float destination has no arm in
13093            // `float_dispatch::classify_float_render` at all: it classifies as
13094            // `FloatRenderPath::None`, the u8 route rejects the dtype, and the
13095            // CPU serves every convert. With a host destination the GL half
13096            // below was therefore unreachable and this test could only ever
13097            // skip. `(Rgba, PlanarRgb, F16, DmaBuf)` -- `ZeroCopyF16Nchw` --
13098            // is the zero-copy float tuple EVERY platform's capability set
13099            // serves: it is unconditional in `classify_float_render`, so it
13100            // holds for the `PlanarF16` set Linux, Android and ANGLE/macOS
13101            // declare as well as the `All` set Windows declares.
13102            //
13103            // One spelling covers all of them. `TensorMemory::DmaBuf` is the
13104            // portable name for the platform-native zero-copy GPU buffer --
13105            // a DMA-BUF on Linux, an IOSurface on macOS/iOS, an
13106            // AHardwareBuffer on Android, an `ID3D11Texture2D` on Windows --
13107            // and the classifier matches on that one variant for all of them,
13108            // so this needs no `cfg`. Where the platform cannot hold one the
13109            // allocation fails or falls back, and both are reported below.
13110            let mut dst = match TensorDyn::image(
13111                W,
13112                H,
13113                PixelFormat::PlanarRgb,
13114                DType::F16,
13115                Some(TensorMemory::DmaBuf),
13116                edgefirst_tensor::CpuAccess::ReadWrite,
13117            ) {
13118                Ok(t) if t.memory() == TensorMemory::DmaBuf => t,
13119                // No routable float destination on this host. Reported here,
13120                // BEFORE the `HAL_TEST_REQUIRE_GL` gate below: that gate
13121                // exists to catch a GL backend declining a destination it
13122                // should serve, not a host that cannot hold one.
13123                Ok(t) => {
13124                    crate::test_support::report_skip(&format!(
13125                        "convert_f16_gl_cpu_parity_identity - the zero-copy F16 \
13126                         destination fell back to {:?}",
13127                        t.memory()
13128                    ));
13129                    return;
13130                }
13131                Err(e) => {
13132                    crate::test_support::report_skip(&format!(
13133                        "convert_f16_gl_cpu_parity_identity - no zero-copy F16 \
13134                         destination on this host: {e}"
13135                    ));
13136                    return;
13137                }
13138            };
13139            let require_gl = std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1");
13140            let fallbacks_before = gl_proc.convert_fallback_count();
13141            match gl_proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default()) {
13142                Ok(()) => {
13143                    if gl_proc.convert_fallback_count() != fallbacks_before {
13144                        assert!(
13145                            !require_gl,
13146                            "HAL_TEST_REQUIRE_GL=1 but \
13147                             convert_f16_gl_cpu_parity_identity would have skipped \
13148                             while reporting success: the GL F16 path declined and \
13149                             the CPU served the convert"
13150                        );
13151                        crate::test_support::report_skip(
13152                            "convert_f16_gl_cpu_parity_identity - the GL F16 path \
13153                             declined and the CPU served the convert; comparing that \
13154                             against the forced-CPU result would prove nothing",
13155                        );
13156                        return;
13157                    }
13158                    dst
13159                }
13160                Err(e) => {
13161                    crate::test_support::report_skip(&format!(
13162                        "convert_f16_gl_cpu_parity_identity - GL convert failed: {e}"
13163                    ));
13164                    return;
13165                }
13166            }
13167        };
13168
13169        // CPU path.
13170        let cpu_result = {
13171            let mut cpu_proc = ImageProcessor::with_config(ImageProcessorConfig {
13172                backend: ComputeBackend::Cpu,
13173                ..Default::default()
13174            })
13175            .unwrap();
13176            let mut dst = TensorDyn::image(
13177                W,
13178                H,
13179                PixelFormat::PlanarRgb,
13180                DType::F16,
13181                Some(TensorMemory::Mem),
13182                edgefirst_tensor::CpuAccess::ReadWrite,
13183            )
13184            .unwrap();
13185            cpu_proc
13186                .convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
13187                .expect("forced-CPU Rgba→PlanarRgb F16 must not error");
13188            dst
13189        };
13190
13191        // Compare element-wise. Both sides are read through `copy_to_flat`:
13192        // the GL destination is a packed zero-copy surface whose rows can be
13193        // spaced at a driver pitch, so its mapping is not a flat run of the
13194        // logical elements.
13195        let flat_f16 = |t: &TensorDyn| -> Vec<half::f16> {
13196            let typed = t.as_typed::<half::f16>().expect("F16 image");
13197            let mut bytes = vec![0u8; typed.shape().iter().product::<usize>() * 2];
13198            typed
13199                .copy_to_flat(&mut bytes)
13200                .expect("compact any padded rows");
13201            bytes
13202                .as_chunks::<2>()
13203                .0
13204                .iter()
13205                .map(|&c| half::f16::from_le_bytes(c))
13206                .collect()
13207        };
13208        let gl_halfs = flat_f16(&gl_result);
13209        let cpu_halfs = flat_f16(&cpu_result);
13210
13211        assert_eq!(
13212            gl_halfs.len(),
13213            cpu_halfs.len(),
13214            "GL and CPU output sizes differ"
13215        );
13216
13217        let plane = W * H;
13218        let channel_names = ["R", "G", "B"];
13219        for (idx, (gl_h, cpu_h)) in gl_halfs.iter().zip(cpu_halfs.iter()).enumerate() {
13220            let gl_v = gl_h.to_f32();
13221            let cpu_v = cpu_h.to_f32();
13222            let err = (gl_v - cpu_v).abs();
13223            let ch = channel_names[idx / plane];
13224            let pixel = idx % plane;
13225            assert!(
13226                err <= TOL,
13227                "GL vs CPU mismatch at {ch}[{pixel}]: GL={gl_v}, CPU={cpu_v}, err={err} > tol={TOL}"
13228            );
13229        }
13230    }
13231
13232    // =========================================================================
13233    // GAP-1: supported_render_dtypes() Linux smoke test
13234    // =========================================================================
13235
13236    /// Exercises the real Linux/Windows GL path that reads
13237    /// `gl.supported_render_dtypes()`. Skipped when no GL backend is
13238    /// available (CI/host without a GPU).
13239    #[test]
13240    #[cfg(all(any(target_os = "linux", target_os = "windows"), feature = "opengl"))]
13241    fn supported_render_dtypes_linux_smoke() {
13242        let proc = match ImageProcessor::new() {
13243            Ok(p) => p,
13244            Err(e) => {
13245                crate::test_support::report_skip(&format!(
13246                    "supported_render_dtypes_linux_smoke — ImageProcessor::new() failed: {e}"
13247                ));
13248                return;
13249            }
13250        };
13251        if proc.opengl.is_none() {
13252            crate::test_support::report_skip(
13253                "supported_render_dtypes_linux_smoke — no GL backend on this host",
13254            );
13255            return;
13256        }
13257        // The call must complete without panicking or deadlocking.
13258        let support = proc.supported_render_dtypes();
13259        eprintln!(
13260            "supported_render_dtypes_linux_smoke: f16={} f32={}",
13261            support.f16, support.f32
13262        );
13263        // No assertion on the specific values — they are hardware-dependent.
13264    }
13265
13266    // =========================================================================
13267    // GAP-2: F16 PlanarRgb with width NOT divisible by 4 falls back to CPU
13268    // =========================================================================
13269
13270    /// The GL float render path rejects PlanarRgb F16 destinations whose width
13271    /// is not a multiple of 4 (the packed RGBA16F swizzle trick requires W%4==0).
13272    /// The auto-chain must transparently fall through to the CPU path, which has
13273    /// no such restriction.  W=18, H=16 is chosen so W%4 == 2.
13274    #[test]
13275    fn convert_f16_pbo_non_4_aligned_width_falls_back() {
13276        const W: usize = 18; // 18 % 4 == 2 — NOT divisible by 4
13277        const H: usize = 16;
13278
13279        // RGBA8 source filled with a flat mid-grey.
13280        let src = TensorDyn::image(
13281            W,
13282            H,
13283            PixelFormat::Rgba,
13284            DType::U8,
13285            Some(TensorMemory::Mem),
13286            edgefirst_tensor::CpuAccess::ReadWrite,
13287        )
13288        .unwrap();
13289        {
13290            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
13291            let data = map.as_mut_slice();
13292            for chunk in data.as_chunks_mut::<4>().0 {
13293                chunk[0] = 128;
13294                chunk[1] = 64;
13295                chunk[2] = 200;
13296                chunk[3] = 255;
13297            }
13298        }
13299
13300        // F16 PlanarRgb destination in Mem (GL would use PBO, but we want
13301        // to exercise the fallback chain without hardware dependency).
13302        let mut dst = TensorDyn::image(
13303            W,
13304            H,
13305            PixelFormat::PlanarRgb,
13306            DType::F16,
13307            Some(TensorMemory::Mem),
13308            edgefirst_tensor::CpuAccess::ReadWrite,
13309        )
13310        .unwrap();
13311
13312        // Use the default auto-chain so the GL path can attempt and reject,
13313        // then the CPU path succeeds.
13314        let mut proc = ImageProcessor::new().unwrap();
13315        let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
13316        assert!(
13317            result.is_ok(),
13318            "auto-chain PlanarRgb F16 W%4!=0 must not error (CPU fallback): {:?}",
13319            result.err()
13320        );
13321
13322        // All output values must be finite and in [0, 1].
13323        let map = dst.as_typed::<half::f16>().unwrap().map().unwrap();
13324        let halfs = map.as_slice();
13325        assert_eq!(halfs.len(), W * H * 3, "unexpected element count");
13326        for (i, h) in halfs.iter().enumerate() {
13327            let v = h.to_f32();
13328            assert!(
13329                v.is_finite() && (0.0..=1.0).contains(&v),
13330                "output[{i}]={v} is not finite or not in [0,1]"
13331            );
13332        }
13333    }
13334
13335    // =========================================================================
13336    // GAP-4: NV12 → Rgb F32 and NV12 → PlanarRgb F16, forced CPU
13337    // =========================================================================
13338
13339    /// CPU widen-composition: NV12 (non-RGBA source) → Rgb F32.
13340    ///
13341    /// NV12 requires a two-stage CPU conversion (NV12→Rgba then Rgba→F32)
13342    /// which was untested. A wrong intermediate format selection silently
13343    /// produces garbage. Y=128, U=V=128 → near-neutral grey → R≈G≈B≈0.5.
13344    #[test]
13345    // Linux-only `egl_display` field makes `..Default::default()` needless
13346    // on macOS only; see `convert_f16_forced_cpu_correct`.
13347    #[allow(clippy::needless_update)]
13348    fn convert_nv12_to_rgb_f32_cpu() {
13349        const W: usize = 16;
13350        const H: usize = 16; // must be even for NV12
13351
13352        // Build a valid NV12 tensor: shape [H*3/2, W], luma=128, chroma=128.
13353        let src = TensorDyn::image(
13354            W,
13355            H,
13356            PixelFormat::Nv12,
13357            DType::U8,
13358            Some(TensorMemory::Mem),
13359            edgefirst_tensor::CpuAccess::ReadWrite,
13360        )
13361        .unwrap();
13362        {
13363            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
13364            map.as_mut_slice().fill(128); // Y=128, U=V=128 → neutral grey
13365        }
13366
13367        let mut dst = TensorDyn::image(
13368            W,
13369            H,
13370            PixelFormat::Rgb,
13371            DType::F32,
13372            Some(TensorMemory::Mem),
13373            edgefirst_tensor::CpuAccess::ReadWrite,
13374        )
13375        .unwrap();
13376
13377        let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
13378            backend: ComputeBackend::Cpu,
13379            ..Default::default()
13380        })
13381        .unwrap();
13382
13383        let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
13384        assert!(
13385            result.is_ok(),
13386            "forced-CPU NV12→Rgb F32 must not error: {:?}",
13387            result.err()
13388        );
13389
13390        let map = dst.as_typed::<f32>().unwrap().map().unwrap();
13391        let floats = map.as_slice();
13392        assert_eq!(floats.len(), W * H * 3, "unexpected element count");
13393        for (i, &v) in floats.iter().enumerate() {
13394            assert!(
13395                v.is_finite() && (0.0..=1.0).contains(&v),
13396                "output[{i}]={v} is not finite or not in [0,1]"
13397            );
13398        }
13399        // Anti-all-zero: Y=128 → luma channel ≈ 0.5 after YUV→RGB.
13400        let non_zero = floats.iter().any(|&v| v > 0.01);
13401        assert!(non_zero, "all-zero output from NV12→Rgb F32 CPU path");
13402    }
13403
13404    /// CPU widen-composition: NV12 (non-RGBA source) → PlanarRgb F16.
13405    ///
13406    /// Same rationale as `convert_nv12_to_rgb_f32_cpu` but for F16 output.
13407    #[test]
13408    // Linux-only `egl_display` field makes `..Default::default()` needless
13409    // on macOS only; see `convert_f16_forced_cpu_correct`.
13410    #[allow(clippy::needless_update)]
13411    fn convert_nv12_to_planar_rgb_f16_cpu() {
13412        const W: usize = 16;
13413        const H: usize = 16;
13414
13415        let src = TensorDyn::image(
13416            W,
13417            H,
13418            PixelFormat::Nv12,
13419            DType::U8,
13420            Some(TensorMemory::Mem),
13421            edgefirst_tensor::CpuAccess::ReadWrite,
13422        )
13423        .unwrap();
13424        {
13425            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
13426            map.as_mut_slice().fill(128);
13427        }
13428
13429        let mut dst = TensorDyn::image(
13430            W,
13431            H,
13432            PixelFormat::PlanarRgb,
13433            DType::F16,
13434            Some(TensorMemory::Mem),
13435            edgefirst_tensor::CpuAccess::ReadWrite,
13436        )
13437        .unwrap();
13438
13439        let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
13440            backend: ComputeBackend::Cpu,
13441            ..Default::default()
13442        })
13443        .unwrap();
13444
13445        let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
13446        assert!(
13447            result.is_ok(),
13448            "forced-CPU NV12→PlanarRgb F16 must not error: {:?}",
13449            result.err()
13450        );
13451
13452        let map = dst.as_typed::<half::f16>().unwrap().map().unwrap();
13453        let halfs = map.as_slice();
13454        assert_eq!(halfs.len(), W * H * 3, "unexpected element count");
13455        for (i, h) in halfs.iter().enumerate() {
13456            let v = h.to_f32();
13457            assert!(
13458                v.is_finite() && (0.0..=1.0).contains(&v),
13459                "output[{i}]={v} is not finite or not in [0,1]"
13460            );
13461        }
13462        let non_zero = halfs.iter().any(|h| h.to_f32() > 0.01);
13463        assert!(non_zero, "all-zero output from NV12→PlanarRgb F16 CPU path");
13464    }
13465
13466    // =========================================================================
13467    // GAP-5: create_image F32 + DMA must return NotSupported
13468    // =========================================================================
13469
13470    /// `create_image_desc` without a compression request is exactly
13471    /// `create_image` (the processor's memory negotiation applies); with
13472    /// `Compression::Any` off-Android it resolves linear and counts the
13473    /// fallback in the processor-visible mirror.
13474    #[test]
13475    fn create_image_desc_negotiates_and_counts_fallbacks() {
13476        use edgefirst_tensor::{CpuAccess, ImageDesc};
13477        let proc = ImageProcessor::new().unwrap();
13478
13479        let desc =
13480            ImageDesc::new(64, 64, PixelFormat::Rgba, DType::U8).with_access(CpuAccess::ReadWrite);
13481        let plain = proc.create_image_desc(&desc).unwrap();
13482        let classic = proc
13483            .create_image(
13484                64,
13485                64,
13486                PixelFormat::Rgba,
13487                DType::U8,
13488                None,
13489                CpuAccess::ReadWrite,
13490            )
13491            .unwrap();
13492        assert_eq!(plain.memory(), classic.memory(), "same negotiation path");
13493        assert_eq!(plain.compression(), None);
13494
13495        #[cfg(not(target_os = "android"))]
13496        {
13497            use edgefirst_tensor::Compression;
13498            let before = proc.compression_fallback_count();
13499            let desc = ImageDesc::new(64, 64, PixelFormat::Rgba, DType::U8)
13500                .with_compression(Compression::Any);
13501            let t = proc.create_image_desc(&desc).unwrap();
13502            assert_eq!(t.compression(), None, "no vendor tile scheme off-Android");
13503            assert!(
13504                proc.compression_fallback_count() > before,
13505                "Any resolving linear must count"
13506            );
13507        }
13508    }
13509
13510    /// A contiguous request through `create_image_desc` is a CMA DMA-BUF,
13511    /// or an error where there is no CMA heap; never a PBO or host memory.
13512    #[test]
13513    #[cfg(target_os = "linux")]
13514    fn create_image_desc_contiguous_is_cma_dma_or_an_error() {
13515        use edgefirst_tensor::{Contiguity, ImageDesc, TensorMemory};
13516        let proc = ImageProcessor::new().unwrap();
13517        let desc = ImageDesc::new(64, 64, PixelFormat::Rgba, DType::U8).with_contiguous(true);
13518        match proc.create_image_desc(&desc) {
13519            Ok(t) => {
13520                assert_eq!(t.memory(), TensorMemory::DmaBuf);
13521                assert_eq!(t.contiguity(), Contiguity::Contiguous);
13522            }
13523            Err(e) => assert!(e.to_string().contains("linux,cma"), "{e}"),
13524        }
13525    }
13526
13527    /// There is no DRM FourCC for F32 images, so `create_image` with
13528    /// `TensorMemory::DmaBuf` and `DType::F32` must return `Err(NotSupported)`.
13529    #[test]
13530    #[cfg(target_os = "linux")]
13531    fn create_image_f32_dma_rejected() {
13532        let proc = ImageProcessor::new().unwrap();
13533        let result = proc.create_image(
13534            64,
13535            64,
13536            PixelFormat::Rgb,
13537            DType::F32,
13538            Some(TensorMemory::DmaBuf),
13539            edgefirst_tensor::CpuAccess::ReadWrite,
13540        );
13541        assert!(
13542            result.is_err(),
13543            "create_image(F32, Dma) must fail — no DRM fourcc for f32"
13544        );
13545    }
13546
13547    /// Verify that `import_image` stores the supplied `Option<Colorimetry>` on
13548    /// the returned `TensorDyn`.
13549    ///
13550    /// `import_image` requires a DMA-backed fd on Linux.  When DMA is
13551    /// unavailable we skip the DMA call but still verify the storage contract
13552    /// by inspecting `set_colorimetry` / `colorimetry` on a plain `TensorDyn`
13553    /// constructed the same way the function body does it — and we confirm via
13554    /// code-read that the new parameter is unconditionally stored.
13555    #[test]
13556    #[cfg(target_os = "linux")]
13557    fn import_image_carries_colorimetry() {
13558        use edgefirst_tensor::{ColorEncoding, ColorRange, Colorimetry, TensorMemory};
13559
13560        let expected = Colorimetry::default()
13561            .with_encoding(ColorEncoding::Bt709)
13562            .with_range(ColorRange::Limited);
13563
13564        if !is_dma_available() {
13565            // DMA unavailable on this host: exercise the storage path via
13566            // TensorDyn directly (mirrors what import_image does internally).
13567            let mut t = TensorDyn::image(
13568                8,
13569                8,
13570                PixelFormat::Rgba,
13571                DType::U8,
13572                Some(TensorMemory::Mem),
13573                edgefirst_tensor::CpuAccess::ReadWrite,
13574            )
13575            .expect("alloc");
13576            assert_eq!(t.colorimetry(), None, "colorimetry must start as None");
13577            t.set_colorimetry(Some(expected));
13578            assert_eq!(
13579                t.colorimetry(),
13580                Some(expected),
13581                "set_colorimetry must round-trip"
13582            );
13583            crate::test_support::report_skip("import_image_carries_colorimetry (DMA unavailable); storage contract verified via TensorDyn");
13584            return;
13585        }
13586
13587        // DMA is available: allocate a real DMA tensor, extract its fd, and
13588        // call import_image with an explicit Colorimetry.
13589        use edgefirst_tensor::{PlaneDescriptor, Tensor};
13590
13591        let rgba_bytes = 64 * 64 * 4; // 64×64 RGBA8
13592        let dma_tensor = Tensor::<u8>::new(
13593            &[rgba_bytes],
13594            Some(TensorMemory::DmaBuf),
13595            Some("import_test"),
13596        )
13597        .expect("dma alloc");
13598        let pd =
13599            PlaneDescriptor::new(dma_tensor.dmabuf().expect("dma fd")).expect("PlaneDescriptor");
13600
13601        let proc = ImageProcessor::new().expect("ImageProcessor");
13602        let result = proc.import_image(
13603            pd,
13604            None,
13605            64,
13606            64,
13607            PixelFormat::Rgba,
13608            DType::U8,
13609            Some(expected),
13610        );
13611        let tensor = result.expect("import_image must succeed on DMA fd");
13612        assert_eq!(
13613            tensor.colorimetry(),
13614            Some(expected),
13615            "import_image must store the supplied colorimetry on the returned TensorDyn"
13616        );
13617    }
13618}