Skip to main content

edgefirst_image/
lib.rs

1// SPDX-FileCopyrightText: Copyright 2025 Au-Zone Technologies
2// SPDX-License-Identifier: Apache-2.0
3
4/*!
5
6## EdgeFirst HAL - Image Converter
7
8The `edgefirst_image` crate is part of the EdgeFirst Hardware Abstraction
9Layer (HAL) and provides functionality for converting images between
10different formats and sizes.  The crate is designed to work with hardware
11acceleration when available, but also provides a CPU-based fallback for
12environments where hardware acceleration is not present or not suitable.
13
14What the crate covers:
15- Packed and planar RGB families (`Rgb`, `Rgba`, `Bgra`, `Grey`,
16  `PlanarRgb`, `PlanarRgba`), semi-planar YUV sources (`Nv12`, `Nv16`,
17  `Nv24`), and packed YUV (`Yuyv`, `Vyuy`).
18- Source crop, resize, letterbox, 90° rotation, and flipping in one call.
19- Segmentation-mask rendering, including a fused GPU proto path.
20- SAHI-style input tiling for small-object detection in large frames
21  ([`TilingConfig`], [`ImageProcessor::tile_into`]).
22- Hardware acceleration (OpenGL, NXP G2D) with a CPU fallback that is
23  always available.
24
25The crate uses [`TensorDyn`] from `edgefirst_tensor` to represent images,
26with [`PixelFormat`] metadata describing the pixel layout. The
27[`ImageProcessor`] struct manages the conversion process, selecting
28the appropriate conversion method based on the available hardware.
29
30Geometry is **source-side**: [`Crop`] says which sub-rectangle of the source
31to sample and how to fit it into the destination's shape. Where the output
32lands is the destination itself — pass a
33[`view`](edgefirst_tensor::TensorDyn::view) or
34[`batch`](edgefirst_tensor::TensorDyn::batch) of a larger tensor to render
35into one tile of a batch.
36
37## Examples
38
39```rust
40# use edgefirst_image::{ImageProcessor, Rotation, Flip, Crop, ImageProcessorTrait};
41# use edgefirst_codec::{peek_info, ImageDecoder, ImageLoad};
42# use edgefirst_tensor::{CpuAccess, PixelFormat, DType, Tensor, TensorMemory};
43# fn main() -> Result<(), edgefirst_image::Error> {
44let image = edgefirst_bench::testdata::read("zidane.jpg");
45// The codec emits the source's native format (a colour JPEG decodes to NV12)
46// and configures the destination tensor's dims+format during the decode.
47let info = peek_info(&image).expect("peek");
48// Heap tensors are CPU-touched on both sides (codec decode writes, the
49// engine's upload/CPU-fallback paths read), so declare ReadWrite. Pure
50// hardware pipelines allocate their convert destinations with
51// `CpuAccess::None` instead.
52let mut src = Tensor::<u8>::image(info.width, info.height, info.format,
53                                   Some(TensorMemory::Mem), CpuAccess::ReadWrite)?;
54let mut decoder = ImageDecoder::new();
55src.load_image(&mut decoder, &image).expect("decode");
56// Convert the native NV12 frame to packed RGB for downstream processing.
57let mut converter = ImageProcessor::new()?;
58let mut dst =
59    converter.create_image(640, 480, PixelFormat::Rgb, DType::U8, None, CpuAccess::ReadWrite)?;
60converter.convert(&src.into(), &mut dst, Rotation::None, Flip::None, Crop::default())?;
61# Ok(())
62# }
63```
64
65## Environment Variables
66The behavior of the `edgefirst_image::ImageProcessor` struct can be influenced by the
67following environment variables:
68- `EDGEFIRST_FORCE_BACKEND`: When set to `cpu`, `g2d`, or `opengl` (case-insensitive),
69  only that single backend is initialized and no fallback chain is used. If the
70  forced backend fails to initialize, an error is returned immediately. This is
71  useful for benchmarking individual backends in isolation. When this variable is
72  set, the `EDGEFIRST_DISABLE_*` variables are ignored.
73- `EDGEFIRST_DISABLE_GL`: If set to `1`, disables the use of OpenGL for image
74  conversion, forcing the use of CPU or other available hardware methods.
75- `EDGEFIRST_DISABLE_G2D`: If set to `1`, disables the use of G2D for image
76  conversion, forcing the use of CPU or other available hardware methods.
77- `EDGEFIRST_DISABLE_CPU`: If set to `1`, disables the use of CPU for image
78  conversion, forcing the use of hardware acceleration methods. If no hardware
79  acceleration methods are available, an error will be returned when attempting
80  to create an `ImageProcessor`.
81
82Additionally the TensorMemory used by default allocations can be controlled using the
83`EDGEFIRST_TENSOR_FORCE_MEM` environment variable. If set to `1`, default tensor memory
84uses system memory. This will disable the use of specialized memory regions for tensors
85and hardware acceleration. However, this will increase the performance of the CPU converter.
86*/
87#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
88
89/// Retained constructor: installs the coverage flush-on-abort handler for this
90/// crate's instrumented test binary. See `edgefirst_tensor::covguard`. Only
91/// present under coverage on Linux (`.init_array` is ELF-only; flush is Linux-only).
92#[cfg(all(coverage, target_os = "linux"))]
93#[used]
94#[link_section = ".init_array"]
95static __EDGEFIRST_COV_INSTALL: extern "C" fn() = {
96    extern "C" fn ctor() {
97        edgefirst_tensor::covguard::install();
98    }
99    ctor
100};
101
102/// Pitch alignment requirement for DMA-BUF tensors that may be imported as
103/// EGLImages by the GL backend. Mali Valhall (i.MX 95 / G310) rejects
104/// `eglCreateImageKHR` with `EGL_BAD_ALLOC` for any DMA-BUF whose row pitch
105/// is not a multiple of 64 bytes; Vivante GC7000UL (i.MX 8MP) accepts any
106/// pitch so the constant is harmless on that path. 64 is the smallest
107/// alignment that satisfies every embedded ARM GPU we ship to.
108///
109/// Applied automatically inside [`ImageProcessor::create_image`] when the
110/// allocation lands on `TensorMemory::DmaBuf`. External callers that allocate
111/// their own DMA-BUF tensors (e.g. GStreamer plugins, video pipelines) can
112/// use [`align_width_for_gpu_pitch`] to compute a width whose resulting row
113/// stride satisfies this requirement.
114pub const GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES: usize = 64;
115
116/// Round `width` (in pixels) up so the resulting row stride
117/// `width * bpp` is a multiple of [`GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES`]
118/// AND a multiple of `bpp` (so the rounded width is an integer pixel count).
119///
120/// `bpp` must be the per-pixel byte count for the image's primary plane
121/// (e.g. 4 for RGBA8/BGRA8, 3 for RGB888, 1 for Grey/NV12-luma).
122///
123/// External callers — GStreamer plugins, video pipelines, anyone wrapping a
124/// foreign DMA-BUF — should call this when sizing the destination so that
125/// `eglCreateImageKHR` doesn't reject the import on Mali. Pre-aligned widths
126/// (640, 1280, 1920, 3008, 3840 …) round-trip unchanged; misaligned widths
127/// are bumped up to the next valid value.
128///
129/// # Overflow behaviour
130///
131/// All arithmetic is checked. If the alignment computation or the rounded
132/// width would overflow `usize`, the function logs a warning and returns the
133/// original `width` unchanged rather than wrapping or producing a smaller
134/// value. Callers can rely on the returned width being **at least** the
135/// requested width.
136///
137/// `bpp == 0` and `width == 0` short-circuit to return the input unchanged.
138///
139/// # Examples
140///
141/// ```
142/// use edgefirst_image::align_width_for_gpu_pitch;
143///
144/// // RGBA8 (bpp=4): width must round to a multiple of 16 pixels (64-byte stride).
145/// assert_eq!(align_width_for_gpu_pitch(1920, 4), 1920); // already aligned
146/// assert_eq!(align_width_for_gpu_pitch(3004, 4), 3008); // crowd.png case: +4 px
147/// assert_eq!(align_width_for_gpu_pitch(1281, 4), 1296); // +15 px
148///
149/// // RGB888 (bpp=3): width must round to a multiple of 64 pixels (192-byte stride).
150/// assert_eq!(align_width_for_gpu_pitch(640, 3), 640);
151/// assert_eq!(align_width_for_gpu_pitch(641, 3), 704);
152/// ```
153pub fn align_width_for_gpu_pitch(width: usize, bpp: usize) -> usize {
154    if bpp == 0 || width == 0 {
155        return width;
156    }
157
158    // The minimum aligned stride must be a common multiple of both the
159    // GPU's pitch alignment and the per-pixel byte count. Using the LCM
160    // guarantees the rounded stride is an integer multiple of `bpp`, so
161    // converting back to a pixel count is exact.
162    //
163    // Compute the alignment in pixels (`width_alignment`) so we never need
164    // to multiply `width * bpp`, which is the only operation that could
165    // realistically overflow for large caller-supplied widths.
166    let Some(lcm_alignment) = checked_num_integer_lcm(GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES, bpp)
167    else {
168        log::warn!(
169            "align_width_for_gpu_pitch: lcm({GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES}, {bpp}) \
170             overflows usize, returning unaligned width {width}"
171        );
172        return width;
173    };
174    if lcm_alignment == 0 {
175        return width;
176    }
177
178    debug_assert_eq!(lcm_alignment % bpp, 0);
179    let width_alignment = lcm_alignment / bpp;
180    if width_alignment == 0 {
181        return width;
182    }
183
184    let remainder = width % width_alignment;
185    if remainder == 0 {
186        return width;
187    }
188
189    let pad = width_alignment - remainder;
190    match width.checked_add(pad) {
191        Some(aligned) => aligned,
192        None => {
193            log::warn!(
194                "align_width_for_gpu_pitch: width {width} + pad {pad} overflows usize, \
195                 returning unaligned (caller should use a smaller width or pre-aligned size)"
196            );
197            width
198        }
199    }
200}
201
202/// Round `min_pitch_bytes` up to the next multiple of
203/// [`GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES`]. Returns `None` if the rounded
204/// value would overflow `usize`. Returns `Some(0)` for input 0.
205///
206/// Used internally by [`ImageProcessor::create_image`] to compute the
207/// padded row stride for DMA-backed image allocations. External callers
208/// that need pixel-counted alignment (instead of raw byte pitch) should
209/// use [`align_width_for_gpu_pitch`] instead.
210#[cfg(target_os = "linux")]
211pub(crate) fn align_pitch_bytes_to_gpu_alignment(min_pitch_bytes: usize) -> Option<usize> {
212    let alignment = GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES;
213    if min_pitch_bytes == 0 {
214        return Some(0);
215    }
216    let remainder = min_pitch_bytes % alignment;
217    if remainder == 0 {
218        return Some(min_pitch_bytes);
219    }
220    min_pitch_bytes.checked_add(alignment - remainder)
221}
222
223/// Overflow-safe least common multiple. Returns `None` when `(a / gcd) * b`
224/// would wrap.
225fn checked_num_integer_lcm(a: usize, b: usize) -> Option<usize> {
226    if a == 0 || b == 0 {
227        return Some(0);
228    }
229    let g = num_integer_gcd(a, b);
230    // a / g is exact (g divides a by definition) and at most a, so this
231    // division never panics. Only the subsequent multiply can overflow.
232    (a / g).checked_mul(b)
233}
234
235fn num_integer_gcd(a: usize, b: usize) -> usize {
236    if b == 0 {
237        a
238    } else {
239        num_integer_gcd(b, a % b)
240    }
241}
242
243/// Bytes-per-pixel for the primary plane of `format` at element size `elem`.
244/// Returns `None` for formats that don't have a single packed BPP (semi-planar
245/// chroma is handled separately, returning the luma-plane bpp).
246///
247/// External callers can use this together with [`align_width_for_gpu_pitch`]
248/// to size their own DMA-BUFs without having to remember per-format BPPs:
249///
250/// ```
251/// use edgefirst_image::{align_width_for_gpu_pitch, primary_plane_bpp};
252/// use edgefirst_tensor::PixelFormat;
253///
254/// let bpp = primary_plane_bpp(PixelFormat::Rgba, 1).unwrap();
255/// let aligned = align_width_for_gpu_pitch(3004, bpp);
256/// assert_eq!(aligned, 3008);
257/// ```
258pub fn primary_plane_bpp(format: PixelFormat, elem: usize) -> Option<usize> {
259    use edgefirst_tensor::PixelLayout;
260    match format.layout() {
261        PixelLayout::Packed => Some(format.channels() * elem),
262        PixelLayout::Planar => Some(elem),
263        // For NV12/NV16 the luma plane is single-channel so the pitch
264        // matches `elem`; the chroma plane uses the same pitch in bytes
265        // (UV is half-width but two interleaved channels = same pitch).
266        PixelLayout::SemiPlanar => Some(elem),
267        // `PixelLayout` is non-exhaustive — fall through unaligned for
268        // any future variant we don't yet recognise.
269        _ => None,
270    }
271}
272
273/// Return the GPU-aligned pitch in bytes when a DMA-backed image of
274/// `width × fmt` would need row-stride padding, or `None` when the
275/// natural pitch already satisfies `GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES`
276/// or the caller has explicitly requested non-DMA memory.
277///
278/// Mali G310 (i.MX 95) rejects `eglCreateImage` from DMA-BUFs whose
279/// `PLANE0_PITCH_EXT` is not a multiple of 64 bytes, surfacing as
280/// `EGL_BAD_ALLOC`. The `load_image_test_helper` test-only helper
281/// in this crate uses this to decide whether to allocate a tensor
282/// with padded row stride before invoking the decode path; production
283/// callers do the equivalent peek → allocate → decode dance themselves
284/// (see crate-level docs).
285#[cfg(all(target_os = "linux", test))]
286pub(crate) fn padded_dma_pitch_for(
287    fmt: PixelFormat,
288    width: usize,
289    memory: &Option<TensorMemory>,
290) -> Option<usize> {
291    // Only pad when the caller explicitly requested DMA, or when they
292    // left memory selection to the allocator AND DMA is actually
293    // available. `Tensor::image_with_stride(..., None)` always routes
294    // through DMA allocation, so treating `None` as "DMA wanted"
295    // unconditionally would convert a normally-working image load into
296    // a hard failure on systems where DMA is unavailable (sandboxed
297    // CI, missing `/dev/dma_heap`, permission-denied containers) —
298    // whereas `Tensor::image(..., None)` would have fallen back to
299    // SHM/Mem there.
300    match memory {
301        Some(TensorMemory::DmaBuf) => {}
302        None if edgefirst_tensor::is_dma_available() => {}
303        _ => return None,
304    }
305    // Padding only applies to packed layouts — `Tensor::image_with_stride`
306    // rejects semi-planar / planar formats, and those take their own
307    // per-plane pitches on import anyway.
308    if fmt.layout() != PixelLayout::Packed {
309        return None;
310    }
311    let bpp = primary_plane_bpp(fmt, 1)?;
312    let natural = width.checked_mul(bpp)?;
313    let aligned = align_pitch_bytes_to_gpu_alignment(natural)?;
314    if aligned > natural {
315        Some(aligned)
316    } else {
317        None
318    }
319}
320
321pub use cpu::CPUProcessor;
322#[cfg(feature = "codec")]
323pub use edgefirst_codec as codec;
324
325#[cfg(test)]
326use edgefirst_tensor::ProtoLayout;
327#[doc(inline)]
328pub use edgefirst_tensor::Region;
329#[cfg(any(
330    test,
331    all(any(target_os = "linux", target_os = "windows"), feature = "opengl")
332))]
333use edgefirst_tensor::Tensor;
334use edgefirst_tensor::{
335    DType, PixelFormat, PixelLayout, TensorDyn, TensorMemory, TensorTrait as _,
336};
337use edgefirst_tensor::{DetectBox, ProtoData, Segmentation};
338use enum_dispatch::enum_dispatch;
339pub use error::{Error, Result};
340#[cfg(target_os = "linux")]
341pub use g2d::G2DProcessor;
342/// Handle returned by `convert_with_fence`: a sync_file fd on Unix, an
343/// event handle on Windows that is set when the GPU work completes.
344#[cfg(all(
345    any(
346        target_os = "linux",
347        target_os = "macos",
348        target_os = "ios",
349        target_os = "android",
350        target_os = "windows"
351    ),
352    feature = "opengl"
353))]
354pub use opengl_headless::CompletionFence;
355#[cfg(all(
356    any(
357        target_os = "linux",
358        target_os = "macos",
359        target_os = "ios",
360        target_os = "android",
361        target_os = "windows"
362    ),
363    feature = "opengl"
364))]
365pub use opengl_headless::EglDisplayKind;
366#[cfg(all(
367    any(
368        target_os = "linux",
369        target_os = "macos",
370        target_os = "ios",
371        target_os = "android",
372        target_os = "windows"
373    ),
374    feature = "opengl"
375))]
376pub use opengl_headless::GLProcessorThreaded;
377#[cfg(all(
378    any(
379        target_os = "linux",
380        target_os = "macos",
381        target_os = "ios",
382        target_os = "android",
383        target_os = "windows"
384    ),
385    feature = "opengl"
386))]
387pub use opengl_headless::Int8InterpolationMode;
388#[cfg(target_os = "linux")]
389#[cfg(feature = "opengl")]
390pub use opengl_headless::{probe_egl_displays, EglDisplayInfo};
391// EGLImage cache counter snapshots (diagnostics): see
392// `GLProcessorThreaded::egl_cache_stats` and the steady-state import gate in
393// `crates/image/ARCHITECTURE.md § image.convert.gl.egl_import`.
394#[cfg(all(
395    any(
396        target_os = "linux",
397        target_os = "macos",
398        target_os = "ios",
399        target_os = "android",
400        target_os = "windows"
401    ),
402    feature = "opengl"
403))]
404pub use opengl_headless::{CacheStats, ConvertStats, GlCacheStats};
405use std::{fmt::Display, time::Instant};
406
407mod colorimetry;
408mod cpu;
409mod error;
410mod g2d;
411#[path = "gl/mod.rs"]
412mod opengl_headless;
413#[cfg(test)]
414mod test_support;
415mod tiling;
416pub use tiling::{tile_grid, TilePlacement, TileSpec, TilingConfig};
417
418// Use `edgefirst_tensor::PixelFormat` variants (Rgb, Rgba, Grey, etc.) and
419// `TensorDyn` / `Tensor<u8>` with `.format()` metadata instead.
420
421#[derive(Debug, Clone, Copy, PartialEq, Eq)]
422pub enum Rotation {
423    None = 0,
424    Clockwise90 = 1,
425    Rotate180 = 2,
426    CounterClockwise90 = 3,
427}
428impl Rotation {
429    /// Creates a Rotation enum from an angle in degrees. The angle must be a
430    /// multiple of 90.
431    ///
432    /// # Panics
433    /// Panics if the angle is not a multiple of 90.
434    ///
435    /// # Examples
436    /// ```rust
437    /// # use edgefirst_image::Rotation;
438    /// let rotation = Rotation::from_degrees_clockwise(270);
439    /// assert_eq!(rotation, Rotation::CounterClockwise90);
440    /// ```
441    pub fn from_degrees_clockwise(angle: usize) -> Rotation {
442        match angle.rem_euclid(360) {
443            0 => Rotation::None,
444            90 => Rotation::Clockwise90,
445            180 => Rotation::Rotate180,
446            270 => Rotation::CounterClockwise90,
447            _ => panic!("rotation angle is not a multiple of 90"),
448        }
449    }
450}
451
452#[derive(Debug, Clone, Copy, PartialEq, Eq)]
453pub enum Flip {
454    None = 0,
455    Vertical = 1,
456    Horizontal = 2,
457}
458
459/// Controls how the color palette index is chosen for each detected object.
460#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
461pub enum ColorMode {
462    /// Color is chosen by object class label (`det.label`). Default.
463    ///
464    /// Preserves backward compatibility and is correct for semantic
465    /// segmentation where colors carry class meaning.
466    #[default]
467    Class,
468    /// Color is chosen by instance order (loop index, zero-based).
469    ///
470    /// Each detected object gets a unique color regardless of class,
471    /// useful for instance segmentation.
472    Instance,
473    /// Color is chosen by track ID (future use; currently behaves like
474    /// [`Instance`](Self::Instance)).
475    Track,
476}
477
478impl ColorMode {
479    /// Return the palette index for a detection given its loop index and label.
480    #[inline]
481    pub fn index(self, idx: usize, label: usize) -> usize {
482        match self {
483            ColorMode::Class => label,
484            ColorMode::Instance | ColorMode::Track => idx,
485        }
486    }
487}
488
489/// Controls the resolution and coordinate frame of masks produced by
490/// [`ImageProcessor::materialize_masks`].
491///
492/// - [`Proto`](Self::Proto) returns per-detection tiles at proto-plane
493///   resolution (e.g. 48×32 u8 for a typical COCO bbox on a 160×160 proto
494///   plane). This is the historical behavior of `materialize_masks` and the
495///   fastest path because no upsample runs inside HAL. Mask values are
496///   continuous sigmoid output quantized to `uint8 [0, 255]`.
497/// - [`Scaled`](Self::Scaled) returns per-detection tiles at caller-specified
498///   pixel resolution by upsampling the full proto plane once and cropping by
499///   bbox after sigmoid. The upsample uses bilinear interpolation with
500///   edge-clamp sampling — semantically equivalent to Ultralytics'
501///   `process_masks_retina` reference. When a `letterbox` is also passed to
502///   [`materialize_masks`], the inverse letterbox transform is applied during
503///   the upsample so mask pixels land in original-content coordinates
504///   (drop-in for overlay on the original image). Mask values are binary
505///   `uint8 {0, 255}` after thresholding sigmoid > 0.5 — interchangeable
506///   with `Proto` output via the same `> 127` test.
507///
508/// [`materialize_masks`]: ImageProcessor::materialize_masks
509#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
510pub enum MaskResolution {
511    /// Per-detection tile at proto-plane resolution (default).
512    #[default]
513    Proto,
514    /// Per-detection tile at `(width, height)` pixel resolution in the
515    /// coordinate frame determined by the `letterbox` parameter of
516    /// [`ImageProcessor::materialize_masks`].
517    Scaled {
518        /// Target pixel width of the output coordinate frame.
519        width: u32,
520        /// Target pixel height of the output coordinate frame.
521        height: u32,
522    },
523}
524
525/// Options for mask overlay rendering.
526///
527/// Controls how segmentation masks are composited onto the destination image:
528/// - `background`: when set, the background image is drawn first and masks
529///   are composited over it (result written to `dst`). When `None`, `dst` is
530///   cleared to `0x00000000` (fully transparent) before masks are drawn.
531///   **`dst` is always fully overwritten — its prior contents are never
532///   preserved.** Callers who used to pre-load an image into `dst` before
533///   calling `draw_decoded_masks` / `draw_proto_masks` must now supply that
534///   image via `background` instead (behaviour changed in v0.16.4).
535/// - `opacity`: scales the alpha of rendered mask colors. `1.0` (default)
536///   preserves the class color's alpha unchanged; `0.5` makes masks
537///   semi-transparent.
538/// - `color_mode`: controls whether colors are assigned by class label,
539///   instance index, or track ID. Defaults to [`ColorMode::Class`].
540#[derive(Debug, Clone, Copy)]
541pub struct MaskOverlay<'a> {
542    /// Compositing source image. Must have the same dimensions and pixel
543    /// format as `dst`. When `Some`, the output is `background + masks`.
544    /// When `None`, `dst` is cleared to `0x00000000` before masks are drawn.
545    pub background: Option<&'a TensorDyn>,
546    pub opacity: f32,
547    /// Normalized letterbox region `[xmin, ymin, xmax, ymax]` in model-input
548    /// space that contains actual image content (the rest is padding).
549    ///
550    /// When set, bounding boxes and mask coordinates from the decoder (which
551    /// are in model-input normalized space) are mapped back to the original
552    /// image coordinate space before rendering.
553    ///
554    /// Use [`with_letterbox_crop`](Self::with_letterbox_crop) to compute this
555    /// from the [`Crop`] that was used in the model input [`convert`](crate::ImageProcessorTrait::convert) call.
556    pub letterbox: Option<[f32; 4]>,
557    pub color_mode: ColorMode,
558}
559
560impl Default for MaskOverlay<'_> {
561    fn default() -> Self {
562        Self {
563            background: None,
564            opacity: 1.0,
565            letterbox: None,
566            color_mode: ColorMode::Class,
567        }
568    }
569}
570
571impl<'a> MaskOverlay<'a> {
572    pub fn new() -> Self {
573        Self::default()
574    }
575
576    /// Set the compositing source image.
577    ///
578    /// `bg` must have the same dimensions and pixel format as the `dst` passed
579    /// to [`draw_decoded_masks`](crate::ImageProcessorTrait::draw_decoded_masks) /
580    /// [`draw_proto_masks`](crate::ImageProcessorTrait::draw_proto_masks).
581    /// The output will be `bg + masks`. Without a background, `dst` is cleared
582    /// to `0x00000000`.
583    pub fn with_background(mut self, bg: &'a TensorDyn) -> Self {
584        self.background = Some(bg);
585        self
586    }
587
588    pub fn with_opacity(mut self, opacity: f32) -> Self {
589        self.opacity = opacity.clamp(0.0, 1.0);
590        self
591    }
592
593    pub fn with_color_mode(mut self, mode: ColorMode) -> Self {
594        self.color_mode = mode;
595        self
596    }
597
598    /// Set the letterbox transform from the [`Crop`] used when preparing the
599    /// model input, so that bounding boxes and masks are correctly mapped back
600    /// to the original image coordinate space during rendering.
601    ///
602    /// Pass the same `crop` that was given to
603    /// [`convert`](crate::ImageProcessorTrait::convert) along with the model
604    /// input dimensions (`model_w` × `model_h`).
605    ///
606    /// Has no effect when `crop.dst_rect` is `None` (no letterbox applied).
607    pub fn with_letterbox_crop(
608        mut self,
609        crop: &Crop,
610        src_w: usize,
611        src_h: usize,
612        model_w: usize,
613        model_h: usize,
614    ) -> Self {
615        // The letterbox placement is resolved from the same source/destination
616        // dimensions `convert()` used, so the inverse map matches the render.
617        if let Ok(resolved) = crop.resolve(src_w, src_h, model_w, model_h) {
618            if let Some(r) = resolved.dst_rect {
619                self.letterbox = Some([
620                    r.left as f32 / model_w as f32,
621                    r.top as f32 / model_h as f32,
622                    (r.left + r.width) as f32 / model_w as f32,
623                    (r.top + r.height) as f32 / model_h as f32,
624                ]);
625            }
626        }
627        self
628    }
629}
630
631/// Copy a [`Segmentation`], replacing its bounds with `bbox`.
632///
633/// `TensorDyn` is deliberately not `Clone` (it may own an fd), so the mask
634/// bytes are copied explicitly. This is only reached on the letterbox path
635/// where the bounds must be rewritten to match an un-letterboxed detect box.
636fn copy_segmentation(s: &Segmentation, bbox: [f32; 4]) -> Result<Segmentation> {
637    use edgefirst_tensor::TensorMapTrait as _;
638    let src = s
639        .segmentation
640        .as_typed::<u8>()
641        .ok_or_else(|| Error::InvalidShape("segmentation must be a U8 tensor".into()))?;
642    let map = src.map_read()?;
643    // `Some(Mem)`, NOT `None`. `None` means auto-select, which prefers DMA on
644    // Linux and IOSurface on macOS -- so this would take a dma_heap ioctl and
645    // hold an fd PER DETECTION PER FRAME for a mask of a few hundred bytes,
646    // and turn every downstream map_read() in the render loop into a
647    // DMA_BUF_IOCTL_SYNC instead of a no-op. A mask copy is CPU-only scratch.
648    // `mask_to_tensor` reaches Mem the same way, via from_arrayview3.
649    let dst = edgefirst_tensor::Tensor::<u8>::new(
650        s.segmentation.shape(),
651        Some(edgefirst_tensor::TensorMemory::Mem),
652        None,
653    )
654    .map_err(|e| Error::InvalidShape(format!("segmentation copy: {e}")))?;
655    {
656        let mut dmap = dst.map_mut()?;
657        dmap.as_mut_slice().copy_from_slice(map.as_slice());
658    }
659    Ok(Segmentation {
660        xmin: bbox[0],
661        ymin: bbox[1],
662        xmax: bbox[2],
663        ymax: bbox[3],
664        segmentation: dst.into(),
665    })
666}
667
668/// Apply the inverse letterbox transform to a bounding box.
669///
670/// `lb` is `[lx0, ly0, lx1, ly1]` — the normalized region of the model input
671/// that contains actual image content (output of
672/// [`MaskOverlay::with_letterbox_crop`]). Converts model-input-normalized
673/// coords to output-image-normalized coords, clamped to `[0.0, 1.0]`, and
674/// canonicalises the bbox (ensures xmin <= xmax).
675///
676/// Thin wrapper over [`edgefirst_tensor::unletter_norm`]: the single home for
677/// the inverse-letterbox math sits beside the type it transforms, so this mask
678/// path and `edgefirst_decoder::tiling`'s detection lift share one
679/// implementation.
680#[inline]
681fn unletter_bbox(bbox: DetectBox, lb: [f32; 4]) -> DetectBox {
682    DetectBox {
683        bbox: edgefirst_tensor::unletter_norm(bbox.bbox, lb),
684        ..bbox
685    }
686}
687
688/// How a source is fit into the requested destination shape.
689#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
690pub enum Fit {
691    /// Stretch the source (or source crop) to fill the whole destination.
692    #[default]
693    Stretch,
694    /// Preserve the *source* aspect ratio, centring it in the destination and
695    /// padding the remainder with `pad` (RGBA — e.g. `[114, 114, 114, 255]` for
696    /// YOLO-style preprocessing).
697    Letterbox { pad: [u8; 4] },
698}
699
700/// Source-side convert geometry: which sub-rectangle of the source to sample
701/// (`source`) and how to fit it into the destination (`fit`). Destination
702/// *placement* is the destination itself — a tensor, or a [`Region`] view /
703/// `batch` tile of one — not a field here.
704#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
705pub struct Crop {
706    /// Sub-rectangle of the source to sample. `None` samples the whole source.
707    pub source: Option<Region>,
708    /// How the source is fit into the destination shape.
709    pub fit: Fit,
710}
711
712impl Crop {
713    /// A no-op crop: whole source, stretched to fill the whole destination.
714    pub fn new() -> Self {
715        Self::default()
716    }
717
718    /// Alias for [`Crop::new`] — whole source, stretch to fill.
719    pub fn no_crop() -> Self {
720        Self::default()
721    }
722
723    /// Letterbox fit: preserve the source aspect ratio, padding the remainder
724    /// with `pad` (RGBA).
725    pub fn letterbox(pad: [u8; 4]) -> Self {
726        Self {
727            source: None,
728            fit: Fit::Letterbox { pad },
729        }
730    }
731
732    /// Sample only `source` of the input (builder).
733    pub fn with_source(mut self, source: Option<Region>) -> Self {
734        self.source = source;
735        self
736    }
737
738    /// Set the fit mode (builder).
739    pub fn with_fit(mut self, fit: Fit) -> Self {
740        self.fit = fit;
741        self
742    }
743
744    /// Resolve to the effective backend geometry for a `src_w`×`src_h` source
745    /// and `dst_w`×`dst_h` destination: the source sampling rect, the
746    /// destination placement rect (`None` = whole destination), and the pad
747    /// colour. **The single place letterbox placement is computed** — every
748    /// backend consumes the resolved rects rather than re-deriving them.
749    pub(crate) fn resolve(
750        &self,
751        src_w: usize,
752        src_h: usize,
753        dst_w: usize,
754        dst_h: usize,
755    ) -> Result<ResolvedCrop, Error> {
756        let src_rect = self.source.map(region_to_rect);
757        // The letterbox aspect uses the *effective* source content — the source
758        // crop when set, else the full source.
759        let (sw, sh) = match self.source {
760            Some(r) => (r.width, r.height),
761            None => (src_w, src_h),
762        };
763        let resolved = match self.fit {
764            Fit::Stretch => ResolvedCrop {
765                src_rect,
766                dst_rect: None,
767                dst_color: None,
768            },
769            Fit::Letterbox { pad } => ResolvedCrop {
770                src_rect,
771                dst_rect: Some(letterbox_rect(sw, sh, dst_w, dst_h)),
772                dst_color: Some(pad),
773            },
774        };
775        resolved.check_crop_dims(src_w, src_h, dst_w, dst_h)?;
776        Ok(resolved)
777    }
778
779    /// Validate against `TensorDyn` source and destination dimensions.
780    pub fn check_crop_dyn(
781        &self,
782        src: &edgefirst_tensor::TensorDyn,
783        dst: &edgefirst_tensor::TensorDyn,
784    ) -> Result<(), Error> {
785        self.resolve(
786            src.width().unwrap_or(0),
787            src.height().unwrap_or(0),
788            dst.width().unwrap_or(0),
789            dst.height().unwrap_or(0),
790        )
791        .map(|_| ())
792    }
793}
794
795/// Resolved crop geometry consumed by the backends. Produced by
796/// [`Crop::resolve`]; the backends read these fields directly (the same shape
797/// the public `Crop` carried before destination placement moved to the view).
798#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
799pub(crate) struct ResolvedCrop {
800    pub(crate) src_rect: Option<Rect>,
801    pub(crate) dst_rect: Option<Rect>,
802    pub(crate) dst_color: Option<[u8; 4]>,
803}
804
805impl ResolvedCrop {
806    /// A no-op resolved crop (whole source → whole destination, no pad).
807    #[allow(dead_code)] // used by unit tests and the batch render paths
808    pub(crate) fn no_crop() -> Self {
809        Self::default()
810    }
811
812    /// Validate the resolved rects against explicit dimensions.
813    pub(crate) fn check_crop_dims(
814        &self,
815        src_w: usize,
816        src_h: usize,
817        dst_w: usize,
818        dst_h: usize,
819    ) -> Result<(), Error> {
820        let src_ok = self
821            .src_rect
822            .is_none_or(|r| r.left + r.width <= src_w && r.top + r.height <= src_h);
823        let dst_ok = self
824            .dst_rect
825            .is_none_or(|r| r.left + r.width <= dst_w && r.top + r.height <= dst_h);
826        match (src_ok, dst_ok) {
827            (true, true) => Ok(()),
828            (true, false) => Err(Error::CropInvalid(format!(
829                "Dest crop invalid: {:?}",
830                self.dst_rect
831            ))),
832            (false, true) => Err(Error::CropInvalid(format!(
833                "Src crop invalid: {:?}",
834                self.src_rect
835            ))),
836            (false, false) => Err(Error::CropInvalid(format!(
837                "Dest and Src crop invalid: {:?} {:?}",
838                self.dst_rect, self.src_rect
839            ))),
840        }
841    }
842}
843
844/// Convert a pixel [`Region`] to the internal [`Rect`] placement type.
845fn region_to_rect(r: Region) -> Rect {
846    Rect {
847        left: r.x,
848        top: r.y,
849        width: r.width,
850        height: r.height,
851    }
852}
853
854/// Centred aspect-preserving placement of an `sw`×`sh` source within a `dw`×`dh`
855/// destination — the canonical letterbox rectangle (one home, replacing the
856/// per-caller `calculate_letterbox` copies).
857fn letterbox_rect(sw: usize, sh: usize, dw: usize, dh: usize) -> Rect {
858    if sw == 0 || sh == 0 {
859        return Rect::new(0, 0, dw, dh);
860    }
861    let src_aspect = sw as f64 / sh as f64;
862    let dst_aspect = dw as f64 / dh as f64;
863    let (new_w, new_h) = if src_aspect > dst_aspect {
864        (dw, ((dw as f64 / src_aspect).round() as usize).max(1))
865    } else {
866        (((dh as f64 * src_aspect).round() as usize).max(1), dh)
867    };
868    let left = dw.saturating_sub(new_w) / 2;
869    let top = dh.saturating_sub(new_h) / 2;
870    Rect::new(left, top, new_w, new_h)
871}
872
873/// Internal placement rectangle (top-left + size). The **public** pixel
874/// sub-region type is [`Region`] (re-exported from `edgefirst-tensor`);
875/// `Rect` is the crate-private resolved-placement representation used by the
876/// CPU/G2D/GL backends after [`Crop::resolve`].
877#[derive(Debug, Clone, Copy, PartialEq, Eq)]
878pub(crate) struct Rect {
879    pub left: usize,
880    pub top: usize,
881    pub width: usize,
882    pub height: usize,
883}
884
885impl Rect {
886    // Creates a new Rect with the specified left, top, width, and height.
887    pub fn new(left: usize, top: usize, width: usize, height: usize) -> Self {
888        Self {
889            left,
890            top,
891            width,
892            height,
893        }
894    }
895}
896
897#[enum_dispatch(ImageProcessor)]
898pub trait ImageProcessorTrait {
899    /// Convert `src` into `dst`'s format and size, applying the crop first,
900    /// then the flip, then the rotation.
901    ///
902    /// The destination is normally an RGB-family colour — `Grey`, `Rgb`,
903    /// `Rgba`/`Bgra`, packed `HWC` or planar `CHW`. That is what the
904    /// accelerated paths target and what models expect. The CPU backend also
905    /// accepts a handful of YUV destinations (`Yuyv`→`Yuyv`, `Yuyv`→`Nv16`,
906    /// and the `Vyuy` equivalents); no GPU or G2D path does, so those pairs
907    /// always land on the CPU.
908    ///
909    /// # Arguments
910    ///
911    /// * `src` - The source image to convert from.
912    /// * `dst` - The destination image. Its shape and [`PixelFormat`] define
913    ///   the output; pass a [`view`](edgefirst_tensor::TensorDyn::view) or
914    ///   [`batch`](edgefirst_tensor::TensorDyn::batch) of a larger tensor to
915    ///   render into one tile of a batch.
916    /// * `rotation` - Rotation applied to the output, in 90° steps.
917    /// * `flip` - Horizontal, vertical, or both.
918    /// * `crop` - Source-side geometry: which sub-rectangle of `src` to sample
919    ///   ([`Crop::source`]) and how to fit it into `dst`'s shape
920    ///   ([`Crop::fit`] — stretch, or letterbox with a pad colour). Destination
921    ///   placement lives on `dst`, not here.
922    ///
923    /// # Errors
924    ///
925    /// - [`Error::CropInvalid`] if `crop.source` falls outside `src`.
926    /// - [`Error::NotAnImage`] if either tensor carries no `PixelFormat`.
927    /// - [`Error::NoConverter`] if no backend accepts the format pair — the
928    ///   dispatch tries OpenGL, then G2D, then CPU, so this means all three
929    ///   declined.
930    ///
931    /// Backend-specific failures (EGL import, G2D blit, allocation) surface as
932    /// their own variants. A backend *declining* a pair is not an error while
933    /// another remains in the chain; it silently costs the zero-copy path, and
934    /// [`ImageProcessor::convert_fallback_count`] counts those.
935    fn convert(
936        &mut self,
937        src: &TensorDyn,
938        dst: &mut TensorDyn,
939        rotation: Rotation,
940        flip: Flip,
941        crop: Crop,
942    ) -> Result<()>;
943
944    /// Draw pre-decoded detection boxes and segmentation masks onto `dst`.
945    ///
946    /// Supports two segmentation modes based on the mask channel count:
947    /// - **Instance segmentation** (`C=1`): one `Segmentation` per detection,
948    ///   `segmentation` and `detect` are zipped.
949    /// - **Semantic segmentation** (`C>1`): a single `Segmentation` covering
950    ///   all classes; only the first element is used.
951    ///
952    /// # Format requirements
953    ///
954    /// - CPU backend: `dst` must be `RGBA` or `RGB`.
955    /// - OpenGL backend: `dst` must be `RGBA`, `BGRA`, or `RGB`.
956    /// - G2D backend: only produces the base frame (empty detections);
957    ///   returns `NotImplemented` when any detection or segmentation is
958    ///   supplied.
959    ///
960    /// # Output contract
961    ///
962    /// This function always fully writes `dst` — it never relies on the
963    /// caller having pre-cleared the destination. The four cases are:
964    ///
965    /// | detections | background | output                              |
966    /// |------------|------------|-------------------------------------|
967    /// | none       | none       | dst cleared to `0x00000000`         |
968    /// | none       | set        | dst ← background                    |
969    /// | set        | none       | masks drawn over cleared dst        |
970    /// | set        | set        | masks drawn over background         |
971    ///
972    /// Each backend implements this with its native primitives: G2D uses
973    /// `g2d_clear` / `g2d_blit`, OpenGL uses `glClear` / DMA-BUF GPU blit
974    /// plus the mask program, and CPU uses direct buffer fill / memcpy as
975    /// the terminal fallback. CPU-memcpy of DMA buffers is avoided on the
976    /// accelerated paths.
977    ///
978    /// An empty `segmentation` slice is valid — only bounding boxes are drawn.
979    ///
980    /// `overlay` controls compositing: `background` is the compositing source
981    /// (must match `dst` in size and format); `opacity` scales mask alpha.
982    ///
983    /// A `Bgra` background onto a zero-copy destination (a D3D11 texture, an
984    /// IOSurface, a dma-buf) returns [`Error::NotSupported`]: the GL
985    /// base-layer draw has no `Bgra` arm and the CPU backend renders only
986    /// `Rgba`/`Rgb`. It previously returned `Ok` with `dst` unwritten. A
987    /// `Bgra` destination with no background is unaffected.
988    ///
989    /// # Buffer aliasing
990    ///
991    /// `dst` and `overlay.background` must reference **distinct underlying
992    /// buffers**. An aliased pair returns [`Error::AliasedBuffers`] without
993    /// dispatching to any backend — the GL path would otherwise read and
994    /// write the same texture in a single draw, which is undefined behaviour
995    /// on most drivers. Aliasing is detected via
996    /// [`TensorDyn::aliases`](edgefirst_tensor::TensorDyn::aliases), which
997    /// catches both shared-allocation clones and separate imports over the
998    /// same dmabuf fd.
999    ///
1000    /// # Migration from v0.16.3 and earlier
1001    ///
1002    /// Prior to v0.16.4 the call silently preserved `dst`'s contents on empty
1003    /// detections. That invariant no longer holds — `dst` is always fully
1004    /// written. Callers who pre-loaded an image into `dst` before calling this
1005    /// function must now pass that image via `overlay.background` instead.
1006    fn draw_decoded_masks(
1007        &mut self,
1008        dst: &mut TensorDyn,
1009        detect: &[DetectBox],
1010        segmentation: &[Segmentation],
1011        overlay: MaskOverlay<'_>,
1012    ) -> Result<()>;
1013
1014    /// Draw masks from proto data onto image (fused decode+draw).
1015    ///
1016    /// For YOLO segmentation models, this avoids materializing intermediate
1017    /// `Array3<u8>` masks. The `ProtoData` contains mask coefficients and the
1018    /// prototype tensor; the renderer computes `mask_coeff @ protos` directly
1019    /// at the output resolution using bilinear sampling.
1020    ///
1021    /// `detect` and `proto_data.mask_coefficients` must have the same length
1022    /// (enforced by zip — excess entries are silently ignored). An empty
1023    /// `detect` slice is valid and produces the base frame — cleared or
1024    /// background-blitted — via the selected backend's native primitive.
1025    ///
1026    /// # Format requirements and output contract
1027    ///
1028    /// Same as [`draw_decoded_masks`](Self::draw_decoded_masks), including
1029    /// the "always fully writes dst" guarantee across all four
1030    /// detection/background combinations.
1031    ///
1032    /// `overlay` controls compositing — see [`draw_decoded_masks`](Self::draw_decoded_masks).
1033    fn draw_proto_masks(
1034        &mut self,
1035        dst: &mut TensorDyn,
1036        detect: &[DetectBox],
1037        proto_data: &ProtoData,
1038        overlay: MaskOverlay<'_>,
1039    ) -> Result<()>;
1040
1041    /// Sets the colors used for rendering segmentation masks. Up to 20 colors
1042    /// can be set.
1043    ///
1044    /// On [`ImageProcessor`] with no forced backend this reaches every
1045    /// available backend rather than the first that accepts, because a
1046    /// later draw may land on any of them. A forced backend receives the
1047    /// colors alone.
1048    fn set_class_colors(&mut self, colors: &[[u8; 4]]) -> Result<()>;
1049
1050    /// Like [`convert`](Self::convert), but does not wait for the GPU to finish.
1051    ///
1052    /// This is the batch-preprocessing primitive: a caller renders `N` tiles
1053    /// into one batched destination by looping
1054    /// `convert_deferred(&src[n], &mut dst.batch(n)?, …)` and then calling
1055    /// [`flush`](Self::flush) **once**. On the OpenGL backend every deferred
1056    /// convert into sibling views of one buffer shares a single EGLImage import
1057    /// (the tile is a `glViewport`/`glScissor` ROI into the parent) and skips the
1058    /// per-tile `glFinish`; `flush` then issues a single GPU sync. The result of
1059    /// a deferred convert is **not** safe to read on the CPU (or map via CUDA)
1060    /// until `flush` returns.
1061    ///
1062    /// The default implementation is eager — it simply calls
1063    /// [`convert`](Self::convert), so CPU/G2D and any backend without a deferred
1064    /// fast path remain correct (each call completes synchronously and `flush`
1065    /// is a no-op).
1066    fn convert_deferred(
1067        &mut self,
1068        src: &TensorDyn,
1069        dst: &mut TensorDyn,
1070        rotation: Rotation,
1071        flip: Flip,
1072        crop: Crop,
1073    ) -> Result<()> {
1074        self.convert(src, dst, rotation, flip, crop)
1075    }
1076
1077    /// Complete all work enqueued by [`convert_deferred`](Self::convert_deferred)
1078    /// since the last flush, issuing a single GPU synchronization.
1079    ///
1080    /// After this returns, every deferred destination is finished and safe to
1081    /// read back or `cuda_map`. Backends with no deferred path (the default)
1082    /// return `Ok(())` immediately, since their converts already completed.
1083    fn flush(&mut self) -> Result<()> {
1084        Ok(())
1085    }
1086}
1087
1088/// Configuration for [`ImageProcessor`] construction.
1089///
1090/// Use with [`ImageProcessor::with_config`] to override the default EGL
1091/// display auto-detection and backend selection. The default configuration
1092/// preserves the existing auto-detection behaviour.
1093#[derive(Debug, Clone, Default)]
1094pub struct ImageProcessorConfig {
1095    /// Force OpenGL to use this EGL display type instead of auto-detecting.
1096    ///
1097    /// When `None`, the processor probes displays in priority order: GBM,
1098    /// PlatformDevice, Default. Use [`probe_egl_displays`] to discover
1099    /// which displays are available on the current system.
1100    ///
1101    /// Ignored when `EDGEFIRST_DISABLE_GL=1` is set, and on macOS and
1102    /// Windows (ANGLE is the only display there — Metal and Direct3D 11
1103    /// respectively; a `Some` value logs a debug note and is otherwise
1104    /// ignored).
1105    #[cfg(all(
1106        any(
1107            target_os = "linux",
1108            target_os = "macos",
1109            target_os = "ios",
1110            target_os = "android",
1111            target_os = "windows"
1112        ),
1113        feature = "opengl"
1114    ))]
1115    pub egl_display: Option<EglDisplayKind>,
1116
1117    /// Preferred compute backend.
1118    ///
1119    /// When set to a specific backend (not [`ComputeBackend::Auto`]), the
1120    /// processor initializes that backend with no fallback — returns an error if the conversion is not supported.
1121    /// This takes precedence over `EDGEFIRST_FORCE_BACKEND` and the
1122    /// `EDGEFIRST_DISABLE_*` environment variables.
1123    ///
1124    /// - [`ComputeBackend::OpenGl`]: init OpenGL + CPU, skip G2D
1125    /// - [`ComputeBackend::G2d`]: init G2D + CPU, skip OpenGL
1126    /// - [`ComputeBackend::Cpu`]: init CPU only
1127    /// - [`ComputeBackend::Auto`]: existing env-var-driven selection
1128    pub backend: ComputeBackend,
1129
1130    /// Colorimetry/performance trade-off for `convert()` (see
1131    /// [`ColorimetryMode`]). Defaults to [`ColorimetryMode::Fast`]. The
1132    /// `EDGEFIRST_COLORIMETRY` environment variable (`fast` | `exact`)
1133    /// overrides this setting when present.
1134    pub colorimetry: ColorimetryMode,
1135
1136    /// How many buffer imports the OpenGL backend keeps cached, per cache.
1137    ///
1138    /// `None` (the default) uses `EDGEFIRST_EGL_CACHE_CAPACITY` if set, and
1139    /// 16 otherwise. Precedence is:
1140    ///
1141    /// ```text
1142    /// egl_cache_capacity  >  EDGEFIRST_EGL_CACHE_CAPACITY  >  16
1143    /// ```
1144    ///
1145    /// Config beats the environment deliberately, matching
1146    /// [`backend`](Self::backend): an embedder that has measured its own
1147    /// pool must not be silently overridden by a stray environment variable
1148    /// in the deployment. `Some(0)` is rejected and falls through to the
1149    /// environment, then the default, exactly as an unparseable env value
1150    /// does.
1151    ///
1152    /// # This is a buffer *count*, not a size
1153    ///
1154    /// A cached import pins its buffer — the underlying dma-buf or IOSurface
1155    /// cannot be freed while an entry names it (that is what makes the cache
1156    /// correct as well as fast; see the "EGL image cache inside HAL" section
1157    /// of `ARCHITECTURE.md`). So the memory this holds depends entirely on
1158    /// how big the caller's buffers are:
1159    ///
1160    /// ```text
1161    /// worst-case pinned bytes                                (upper bound)
1162    ///     = capacity x 3 caches x frame size x live ImageProcessors
1163    ///
1164    /// capacity 16, 640x640 RGB   (1.2 MB)  ->   59 MB per processor
1165    /// capacity 16, 1080p NV12    (3.1 MB)  ->  149 MB per processor
1166    /// capacity 16, 4K NV12      (12.4 MB)  ->  597 MB per processor
1167    /// ```
1168    ///
1169    /// A ceiling rather than an exact total: the source and NV R8 caches
1170    /// often hold two imports of the *same* buffer, and two entries naming
1171    /// one buffer pin it once. The per-cache figure is the exact one.
1172    ///
1173    /// The bound is **per [`ImageProcessor`], times three caches** (source,
1174    /// destination, and the NV R8 source cache) — not per process and not
1175    /// per library. Two `ImageProcessor`s in one process have six
1176    /// independent caches and six independent capacities.
1177    ///
1178    /// Whether the worst case is ever approached is up to the caller. A
1179    /// pooled producer — V4L2 capture, typically 4–8 buffers over one or two
1180    /// sizes — settles at its pool size and never nears the bound. A stream
1181    /// that allocates a fresh buffer per frame holds the last `capacity` of
1182    /// them outright, which is the difference between "16 is free" and "16
1183    /// is 597 MB".
1184    ///
1185    /// Size it from measurement rather than guesswork:
1186    /// [`CacheStats::peak_entries`] reports the working set actually
1187    /// reached, and [`CacheStats::evictions`] tells you when the bound is
1188    /// costing you re-imports (it should be zero in a steady state).
1189    ///
1190    /// Ignored when the OpenGL backend is not in use.
1191    pub egl_cache_capacity: Option<usize>,
1192}
1193
1194/// How `convert()` trades colorimetric exactness against speed on platforms
1195/// where the exact path is expensive.
1196///
1197/// Today this affects one decision: NV12 sources on Vivante GC7000UL
1198/// (i.MX 8M Plus), where the hardware external sampler converts ~12× faster
1199/// than the colorimetry-exact in-shader matrix (2.5 ms vs 29 ms at 720p)
1200/// but applies the driver's fixed BT.601-limited matrix regardless of the
1201/// source's tagged colorimetry. Platforms where the exact path is already
1202/// the fastest correct path (Mali, V3D, Tegra, ANGLE) behave identically in
1203/// both modes.
1204///
1205/// Override at runtime with `EDGEFIRST_COLORIMETRY=fast|exact` (takes
1206/// precedence over the config field), or per-source by forcing a path with
1207/// `EDGEFIRST_NV_CONVERT_PATH`.
1208#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1209pub enum ColorimetryMode {
1210    /// Prefer the fastest path whose output is correct-enough video RGB
1211    /// (default; issue #106 policy). On Vivante, NV12 takes the hardware
1212    /// sampler even when the source is not BT.601-limited.
1213    #[default]
1214    Fast,
1215    /// Prefer bit-exact colorimetry everywhere: the fast path is used only
1216    /// when it matches the source's resolved (encoding, range) exactly.
1217    Exact,
1218}
1219
1220/// Compute backend selection for [`ImageProcessor`].
1221///
1222/// Use with [`ImageProcessorConfig::backend`] to select which backend the
1223/// processor should prefer. When a specific backend is selected, the
1224/// processor initializes that backend plus CPU as a fallback. When `Auto`
1225/// is used, the existing environment-variable-driven selection applies.
1226#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1227pub enum ComputeBackend {
1228    /// Auto-detect based on available hardware and environment variables.
1229    #[default]
1230    Auto,
1231    /// CPU-only processing (no hardware acceleration).
1232    Cpu,
1233    /// Prefer G2D hardware blitter (+ CPU fallback).
1234    G2d,
1235    /// Prefer OpenGL ES (+ CPU fallback).
1236    OpenGl,
1237}
1238
1239/// Backend forced via the `EDGEFIRST_FORCE_BACKEND` environment variable
1240/// or [`ImageProcessorConfig::backend`].
1241///
1242/// When set, the [`ImageProcessor`] only initializes and dispatches to the
1243/// selected backend — no fallback chain is used.
1244#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1245pub(crate) enum ForcedBackend {
1246    Cpu,
1247    G2d,
1248    OpenGl,
1249}
1250
1251/// Reports which float-color-buffer extensions the GPU backend detected.
1252/// Returned by [`ImageProcessor::supported_render_dtypes`]; the two flags
1253/// are independent.
1254///
1255/// **Linux:** reflects the real probe results from `GL_EXT_color_buffer_half_float`
1256/// and `GL_EXT_color_buffer_float`. On V3D (RPi 5) and Mali-G310 (i.MX 95)
1257/// both flags are typically `true`; on Vivante GC7000UL both are forced
1258/// `false` (float readback measured 170–320 ms — disabled). Tegra Orin
1259/// exposes both via PBO; the flags match the GPU report.
1260///
1261/// **macOS (ANGLE):** `f16 == true` gates the RGBA16F-packed IOSurface
1262/// path for F16 `PlanarRgb` destinations. `f32` reflects the GL
1263/// extension probe but is not actionable — ANGLE's
1264/// `EGL_ANGLE_iosurface_client_buffer` rejects every `(GL_FLOAT, *)`
1265/// combination with `EGL_BAD_ATTRIBUTE`, so there is no F32 IOSurface
1266/// path.
1267///
1268/// **Windows (ANGLE/D3D11):** `f16` enables the RGBA16F texture paths and
1269/// `f32` the RGBA32F ones for every float layout (`float_dispatch.rs`) —
1270/// `PlanarRgb` and `PlanarRgba` NCHW, interleaved `Rgb`, and `Rgba`.
1271///
1272/// Regardless of these flags, [`ImageProcessor::convert`] never returns
1273/// an error due to float capability — it falls back to CPU when the GPU
1274/// path is unavailable.
1275#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1276pub struct RenderDtypeSupport {
1277    /// `GL_EXT_color_buffer_float` is available on the current GPU.
1278    ///
1279    /// On Linux, `true` enables F32 `Rgb` NHWC PBO readback. On macOS
1280    /// this flag is informational only — no F32 IOSurface path exists.
1281    /// On Windows, `true` enables the RGBA32F zero-copy texture render
1282    /// for every F32 layout.
1283    pub f32: bool,
1284    /// `GL_EXT_color_buffer_half_float` is available on the current GPU.
1285    ///
1286    /// On Linux, `true` enables F16 `PlanarRgb` NCHW PBO readback and,
1287    /// on V3D/Mali, zero-copy DMA-BUF render. On macOS, `true` enables
1288    /// F16 `PlanarRgb` via RGBA16F-packed IOSurface (zero-copy). On
1289    /// Windows, `true` enables the RGBA16F zero-copy texture render for
1290    /// every F16 layout.
1291    pub f16: bool,
1292}
1293
1294/// Returns `true` when a float PBO destination should be attempted for `dtype`.
1295///
1296/// Only F16 and F32 are eligible, and only when the corresponding flag in
1297/// `support` is set. U8/I8 and all other dtypes return `false` — they are
1298/// handled by the existing `dtype.size() == 1` PBO gate.
1299///
1300/// The same question `create_image` asks before trying a zero-copy float
1301/// destination, which is why that gate calls this rather than restating it:
1302/// a float image is only worth allocating on the GPU where the display can
1303/// render into that dtype at all.
1304///
1305/// Compiled on every platform with a GL backend -- the name is historical
1306/// (it began as the float *PBO* gate) and both callers are live everywhere
1307/// the `opengl` feature builds a backend.
1308#[cfg(all(
1309    any(
1310        target_os = "linux",
1311        target_os = "windows",
1312        target_os = "macos",
1313        target_os = "ios",
1314        target_os = "android"
1315    ),
1316    feature = "opengl"
1317))]
1318pub(crate) fn float_pbo_eligible(dtype: DType, support: RenderDtypeSupport) -> bool {
1319    match dtype {
1320        DType::F16 => support.f16,
1321        DType::F32 => support.f32,
1322        _ => false,
1323    }
1324}
1325
1326/// Image converter that uses available hardware acceleration or CPU as a
1327/// fallback.
1328#[derive(Debug)]
1329pub struct ImageProcessor {
1330    /// CPU-based image converter as a fallback. This is only None if the
1331    /// EDGEFIRST_DISABLE_CPU environment variable is set.
1332    pub cpu: Option<CPUProcessor>,
1333
1334    #[cfg(target_os = "linux")]
1335    /// G2D-based image converter for Linux systems. This is only available if
1336    /// the EDGEFIRST_DISABLE_G2D environment variable is not set and libg2d.so
1337    /// is available.
1338    pub g2d: Option<G2DProcessor>,
1339    #[cfg(target_os = "linux")]
1340    #[cfg(feature = "opengl")]
1341    /// OpenGL-based image converter for Linux systems. This is only available
1342    /// if the EDGEFIRST_DISABLE_GL environment variable is not set and OpenGL
1343    /// ES is available.
1344    pub opengl: Option<GLProcessorThreaded>,
1345    #[cfg(any(
1346        target_os = "macos",
1347        target_os = "ios",
1348        target_os = "android",
1349        target_os = "windows"
1350    ))]
1351    #[cfg(feature = "opengl")]
1352    /// OpenGL-based image converter — the same unified
1353    /// `GLProcessorThreaded` engine as Linux (its worker owns a
1354    /// per-processor context). macOS/iOS run it via ANGLE + IOSurface
1355    /// (available when ANGLE's libEGL.dylib can be loaded — see
1356    /// README.md § macOS GPU Acceleration); Android runs it via the
1357    /// native EGL driver + AHardwareBuffer; Windows runs it via ANGLE +
1358    /// Direct3D 11 with PBO transfers (available when ANGLE's libEGL.dll
1359    /// can be loaded — see README.md § Windows GPU Acceleration).
1360    pub opengl: Option<GLProcessorThreaded>,
1361
1362    /// When set, only the specified backend is used — no fallback chain.
1363    pub(crate) forced_backend: Option<ForcedBackend>,
1364
1365    /// Converts where the GL backend declined and the auto chain fell
1366    /// through toward G2D/CPU. Owned by the dispatcher (the GL worker never
1367    /// sees these frames), unlike the per-feed counters in
1368    /// `GLProcessorThreaded::convert_stats`. Read via
1369    /// [`convert_fallback_count`](Self::convert_fallback_count).
1370    pub(crate) convert_fallbacks: std::sync::atomic::AtomicU64,
1371}
1372
1373unsafe impl Send for ImageProcessor {}
1374unsafe impl Sync for ImageProcessor {}
1375
1376impl ImageProcessor {
1377    /// Creates a new `ImageProcessor` instance, initializing available
1378    /// hardware converters based on the system capabilities and environment
1379    /// variables.
1380    ///
1381    /// # Examples
1382    /// ```rust,no_run
1383    /// # use edgefirst_image::{ImageProcessor, Rotation, Flip, Crop, ImageProcessorTrait};
1384    /// # use edgefirst_codec::{peek_info, ImageDecoder, ImageLoad};
1385    /// # use edgefirst_tensor::{CpuAccess, PixelFormat, DType, Tensor, TensorMemory};
1386    /// # fn main() -> Result<(), edgefirst_image::Error> {
1387    /// let image = std::fs::read("zidane.jpg")?;
1388    /// // The codec emits the source's native format (a colour JPEG decodes to
1389    /// // NV12) and configures the destination tensor during the decode.
1390    /// let info = peek_info(&image).expect("peek");
1391    /// let mut src = Tensor::<u8>::image(info.width, info.height, info.format,
1392    ///                                    Some(TensorMemory::Mem), CpuAccess::ReadWrite)?;
1393    /// let mut decoder = ImageDecoder::new();
1394    /// src.load_image(&mut decoder, &image).expect("decode");
1395    /// let mut converter = ImageProcessor::new()?;
1396    /// let mut dst =
1397    ///     converter.create_image(640, 480, PixelFormat::Rgb, DType::U8, None, CpuAccess::ReadWrite)?;
1398    /// converter.convert(&src.into(), &mut dst, Rotation::None, Flip::None, Crop::default())?;
1399    /// # Ok(())
1400    /// # }
1401    /// ```
1402    pub fn new() -> Result<Self> {
1403        Self::with_config(ImageProcessorConfig::default())
1404    }
1405
1406    /// Number of converts where the auto chain's GL backend declined and the
1407    /// frame fell through toward G2D/CPU — each one silently lost any
1408    /// zero-copy guarantee. A pipeline that expects to stay on the GPU
1409    /// asserts this stays flat across its steady-state loop; pair with
1410    /// `GLProcessorThreaded::convert_stats` to see how the frames that DID
1411    /// reach GL were fed. Always 0 under a forced backend (no chain).
1412    pub fn convert_fallback_count(&self) -> u64 {
1413        self.convert_fallbacks
1414            .load(std::sync::atomic::Ordering::Relaxed)
1415    }
1416
1417    /// Number of [`Compression::Any`](edgefirst_tensor::Compression)
1418    /// requests since process start that resolved to a linear layout
1419    /// (process-wide mirror of
1420    /// [`edgefirst_tensor::compression_fallback_count`], surfaced here
1421    /// beside [`convert_fallback_count`](Self::convert_fallback_count)
1422    /// so pipeline telemetry reads from one place). A pipeline that
1423    /// expects compressed convert destinations asserts this stays flat
1424    /// after warmup.
1425    pub fn compression_fallback_count(&self) -> u64 {
1426        edgefirst_tensor::compression_fallback_count()
1427    }
1428
1429    /// Convert and return a native fence that signals when the GPU work
1430    /// completes, instead of blocking the CPU — the GL→NPU handoff
1431    /// (`EGL_ANDROID_native_fence_sync` on Android). On Windows the handle
1432    /// is a manual-reset event set when the GPU work completes; wait on it
1433    /// with `WaitForSingleObject`.
1434    ///
1435    /// `Ok(Some(fence))`: the destination buffer is still in flight; hand
1436    /// the fence to the consumer (e.g.
1437    /// `ANeuralNetworksExecution_startComputeWithDependencies` on Android,
1438    /// or `WaitForSingleObject` on Windows) or poll it before reading.
1439    /// `Ok(None)`: the convert completed with the normal blocking contract
1440    /// (no native fence on this platform, or a non-GL backend handled the
1441    /// frame) — the destination is already safe. Semantics are otherwise
1442    /// identical to [`convert`](ImageProcessorTrait::convert), including
1443    /// the GL→G2D→CPU fallback chain.
1444    ///
1445    /// On Windows, **for a texture destination**, the event and the
1446    /// destination's own `Tensor::gpu_completion` name the same fence value:
1447    /// one flush and one signal cover the convert, and the value recorded on
1448    /// `dst` is the one this event waits for, so a consumer may use either.
1449    /// A destination that is not a texture records nothing, and the event
1450    /// then carries a value of its own that only it can be waited on for.
1451    pub fn convert_with_fence(
1452        &mut self,
1453        src: &TensorDyn,
1454        dst: &mut TensorDyn,
1455        rotation: Rotation,
1456        flip: Flip,
1457        crop: Crop,
1458    ) -> Result<Option<CompletionFence>> {
1459        #[cfg(any(
1460            target_os = "linux",
1461            target_os = "macos",
1462            target_os = "ios",
1463            target_os = "android",
1464            target_os = "windows"
1465        ))]
1466        #[cfg(feature = "opengl")]
1467        {
1468            let gl_forced = matches!(self.forced_backend, Some(ForcedBackend::OpenGl));
1469            if self.forced_backend.is_none() || gl_forced {
1470                if let Some(opengl) = self.opengl.as_mut() {
1471                    match opengl.convert_with_fence(src, dst, rotation, flip, crop) {
1472                        Ok(fd) => return Ok(fd),
1473                        Err(e) if gl_forced => return Err(e),
1474                        Err(e) => {
1475                            // Not counted here: the blocking chain below
1476                            // re-runs the dispatcher, whose decline arm
1477                            // counts the fallback exactly once.
1478                            log::debug!(
1479                                "convert_with_fence: opengl declined, \
1480                                 falling back to the blocking chain: {e}"
1481                            );
1482                        }
1483                    }
1484                } else if gl_forced {
1485                    return Err(Error::ForcedBackendUnavailable("opengl".into()));
1486                }
1487            }
1488        }
1489        // Blocking chain (G2D/CPU, forced non-GL, or non-GL builds):
1490        // completion == return, so no fence is needed.
1491        self.convert(src, dst, rotation, flip, crop)?;
1492        Ok(None)
1493    }
1494
1495    /// Report which float dtypes the GPU can render to.
1496    ///
1497    /// Probes `GL_EXT_color_buffer_half_float` and
1498    /// `GL_EXT_color_buffer_float` once at `ImageProcessor::new()` time
1499    /// and caches the result. Call this once at startup to decide whether
1500    /// to request F16 or F32 destination tensors; [`create_image`] uses
1501    /// the result internally to auto-select float PBO when supported.
1502    ///
1503    /// Returns `RenderDtypeSupport { f32: false, f16: false }` when no
1504    /// OpenGL backend is active or the `opengl` feature is disabled.
1505    ///
1506    /// [`create_image`]: Self::create_image
1507    pub fn supported_render_dtypes(&self) -> RenderDtypeSupport {
1508        #[cfg(all(
1509            any(
1510                target_os = "macos",
1511                target_os = "ios",
1512                target_os = "android",
1513                target_os = "windows"
1514            ),
1515            feature = "opengl"
1516        ))]
1517        if let Some(gl) = self.opengl.as_ref() {
1518            return gl.supported_render_dtypes();
1519        }
1520        #[cfg(all(target_os = "linux", feature = "opengl"))]
1521        if let Some(gl) = self.opengl.as_ref() {
1522            return gl.supported_render_dtypes();
1523        }
1524        RenderDtypeSupport {
1525            f32: false,
1526            f16: false,
1527        }
1528    }
1529
1530    /// Creates a new `ImageProcessor` with the given configuration.
1531    ///
1532    /// When [`ImageProcessorConfig::backend`] is set to a specific backend,
1533    /// environment variables are ignored and the processor initializes the
1534    /// requested backend plus CPU as a fallback.
1535    ///
1536    /// When `Auto`, the existing `EDGEFIRST_FORCE_BACKEND` and
1537    /// `EDGEFIRST_DISABLE_*` environment variables apply.
1538    #[allow(unused_variables)]
1539    pub fn with_config(config: ImageProcessorConfig) -> Result<Self> {
1540        // ── Config-driven backend selection ──────────────────────────
1541        // When the caller explicitly requests a backend via the config,
1542        // skip all environment variable logic.
1543        match config.backend {
1544            ComputeBackend::Cpu => {
1545                log::info!("ComputeBackend::Cpu — CPU only");
1546                return Ok(Self {
1547                    cpu: Some(CPUProcessor::new()),
1548                    #[cfg(target_os = "linux")]
1549                    g2d: None,
1550                    #[cfg(target_os = "linux")]
1551                    #[cfg(feature = "opengl")]
1552                    opengl: None,
1553                    #[cfg(any(
1554                        target_os = "macos",
1555                        target_os = "ios",
1556                        target_os = "android",
1557                        target_os = "windows"
1558                    ))]
1559                    #[cfg(feature = "opengl")]
1560                    opengl: None,
1561                    convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1562                    forced_backend: None,
1563                });
1564            }
1565            ComputeBackend::G2d => {
1566                log::info!("ComputeBackend::G2d — G2D + CPU fallback");
1567                #[cfg(target_os = "linux")]
1568                {
1569                    let g2d = match G2DProcessor::new() {
1570                        Ok(g) => Some(g),
1571                        Err(e) => {
1572                            log::warn!("G2D requested but failed to initialize: {e:?}");
1573                            None
1574                        }
1575                    };
1576                    return Ok(Self {
1577                        cpu: Some(CPUProcessor::new()),
1578                        g2d,
1579                        #[cfg(feature = "opengl")]
1580                        opengl: None,
1581                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1582                        forced_backend: None,
1583                    });
1584                }
1585                #[cfg(not(target_os = "linux"))]
1586                {
1587                    log::warn!("G2D requested but not available on this platform, using CPU");
1588                    return Ok(Self {
1589                        cpu: Some(CPUProcessor::new()),
1590                        #[cfg(any(
1591                            target_os = "macos",
1592                            target_os = "ios",
1593                            target_os = "android",
1594                            target_os = "windows"
1595                        ))]
1596                        #[cfg(feature = "opengl")]
1597                        opengl: None,
1598                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1599                        forced_backend: None,
1600                    });
1601                }
1602            }
1603            ComputeBackend::OpenGl => {
1604                log::info!("ComputeBackend::OpenGl — OpenGL + CPU fallback");
1605                #[cfg(target_os = "linux")]
1606                {
1607                    #[cfg(feature = "opengl")]
1608                    let opengl = match GLProcessorThreaded::with_cache_capacity(
1609                        config.egl_display,
1610                        config.egl_cache_capacity,
1611                    ) {
1612                        Ok(gl) => Some(gl),
1613                        Err(e) => {
1614                            log::warn!("OpenGL requested but failed to initialize: {e:?}");
1615                            None
1616                        }
1617                    };
1618                    return Ok(Self {
1619                        cpu: Some(CPUProcessor::new()),
1620                        g2d: None,
1621                        #[cfg(feature = "opengl")]
1622                        opengl,
1623                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1624                        forced_backend: None,
1625                    }
1626                    .apply_colorimetry_mode(config.colorimetry));
1627                }
1628                #[cfg(any(target_os = "macos", target_os = "ios"))]
1629                {
1630                    #[cfg(feature = "opengl")]
1631                    let opengl = match GLProcessorThreaded::with_cache_capacity(
1632                        config.egl_display,
1633                        config.egl_cache_capacity,
1634                    ) {
1635                        Ok(gl) => Some(gl),
1636                        Err(e) => {
1637                            log::warn!(
1638                                "OpenGL requested on macOS but ANGLE init failed: {e:?} \
1639                                 (install ANGLE via `brew install startergo/angle/angle` \
1640                                 and re-sign the dylibs — see README.md § macOS GPU \
1641                                 Acceleration). Falling back to CPU."
1642                            );
1643                            None
1644                        }
1645                    };
1646                    return Ok(Self {
1647                        cpu: Some(CPUProcessor::new()),
1648                        #[cfg(feature = "opengl")]
1649                        opengl,
1650                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1651                        forced_backend: None,
1652                    }
1653                    .apply_colorimetry_mode(config.colorimetry));
1654                }
1655                #[cfg(target_os = "android")]
1656                {
1657                    #[cfg(feature = "opengl")]
1658                    let opengl = match GLProcessorThreaded::with_cache_capacity(
1659                        config.egl_display,
1660                        config.egl_cache_capacity,
1661                    ) {
1662                        Ok(gl) => Some(gl),
1663                        Err(e) => {
1664                            log::warn!(
1665                                "OpenGL requested but native EGL init failed: {e:?}. \
1666                                 Falling back to CPU."
1667                            );
1668                            None
1669                        }
1670                    };
1671                    return Ok(Self {
1672                        cpu: Some(CPUProcessor::new()),
1673                        #[cfg(feature = "opengl")]
1674                        opengl,
1675                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1676                        forced_backend: None,
1677                    }
1678                    .apply_colorimetry_mode(config.colorimetry));
1679                }
1680                #[cfg(target_os = "windows")]
1681                {
1682                    #[cfg(feature = "opengl")]
1683                    let opengl = match GLProcessorThreaded::with_cache_capacity(
1684                        config.egl_display,
1685                        config.egl_cache_capacity,
1686                    ) {
1687                        Ok(gl) => Some(gl),
1688                        Err(e) => {
1689                            log::warn!(
1690                                "OpenGL requested on Windows but ANGLE/D3D11 init failed: {e:?} \
1691                                 (fetch ANGLE with `scripts/fetch-angle.sh` and set \
1692                                 EDGEFIRST_ANGLE_PATH to the directory containing libEGL.dll + \
1693                                 libGLESv2.dll, or place the two DLLs next to the executable — \
1694                                 see README.md § Windows GPU Acceleration). Falling back to CPU."
1695                            );
1696                            None
1697                        }
1698                    };
1699                    return Ok(Self {
1700                        cpu: Some(CPUProcessor::new()),
1701                        #[cfg(feature = "opengl")]
1702                        opengl,
1703                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1704                        forced_backend: None,
1705                    }
1706                    .apply_colorimetry_mode(config.colorimetry));
1707                }
1708                #[cfg(not(any(
1709                    target_os = "linux",
1710                    target_os = "macos",
1711                    target_os = "ios",
1712                    target_os = "android",
1713                    target_os = "windows"
1714                )))]
1715                {
1716                    log::warn!("OpenGL requested but not available on this platform, using CPU");
1717                    return Ok(Self {
1718                        cpu: Some(CPUProcessor::new()),
1719                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1720                        forced_backend: None,
1721                    });
1722                }
1723            }
1724            ComputeBackend::Auto => { /* fall through to env-var logic below */ }
1725        }
1726
1727        // ── EDGEFIRST_FORCE_BACKEND ──────────────────────────────────
1728        // When set, only the requested backend is initialised and no
1729        // fallback chain is used. Accepted values (case-insensitive):
1730        //   "cpu", "g2d", "opengl"
1731        if let Ok(val) = std::env::var("EDGEFIRST_FORCE_BACKEND") {
1732            let val_lower = val.to_lowercase();
1733            let forced = match val_lower.as_str() {
1734                "cpu" => ForcedBackend::Cpu,
1735                "g2d" => ForcedBackend::G2d,
1736                "opengl" => ForcedBackend::OpenGl,
1737                other => {
1738                    return Err(Error::ForcedBackendUnavailable(format!(
1739                        "unknown EDGEFIRST_FORCE_BACKEND value: {other:?} (expected cpu, g2d, or opengl)"
1740                    )));
1741                }
1742            };
1743
1744            log::info!("EDGEFIRST_FORCE_BACKEND={val} — only initializing {val_lower} backend");
1745
1746            return match forced {
1747                ForcedBackend::Cpu => Ok(Self {
1748                    cpu: Some(CPUProcessor::new()),
1749                    #[cfg(target_os = "linux")]
1750                    g2d: None,
1751                    #[cfg(target_os = "linux")]
1752                    #[cfg(feature = "opengl")]
1753                    opengl: None,
1754                    #[cfg(any(
1755                        target_os = "macos",
1756                        target_os = "ios",
1757                        target_os = "android",
1758                        target_os = "windows"
1759                    ))]
1760                    #[cfg(feature = "opengl")]
1761                    opengl: None,
1762                    convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1763                    forced_backend: Some(ForcedBackend::Cpu),
1764                }),
1765                ForcedBackend::G2d => {
1766                    #[cfg(target_os = "linux")]
1767                    {
1768                        let g2d = G2DProcessor::new().map_err(|e| {
1769                            Error::ForcedBackendUnavailable(format!(
1770                                "g2d forced but failed to initialize: {e:?}"
1771                            ))
1772                        })?;
1773                        Ok(Self {
1774                            cpu: None,
1775                            g2d: Some(g2d),
1776                            #[cfg(feature = "opengl")]
1777                            opengl: None,
1778                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1779                            forced_backend: Some(ForcedBackend::G2d),
1780                        })
1781                    }
1782                    #[cfg(not(target_os = "linux"))]
1783                    {
1784                        Err(Error::ForcedBackendUnavailable(
1785                            "g2d backend is only available on Linux".into(),
1786                        ))
1787                    }
1788                }
1789                ForcedBackend::OpenGl => {
1790                    #[cfg(target_os = "linux")]
1791                    #[cfg(feature = "opengl")]
1792                    {
1793                        let opengl = GLProcessorThreaded::with_cache_capacity(
1794                            config.egl_display,
1795                            config.egl_cache_capacity,
1796                        )
1797                        .map_err(|e| {
1798                            Error::ForcedBackendUnavailable(format!(
1799                                "opengl forced but failed to initialize: {e:?}"
1800                            ))
1801                        })?;
1802                        Ok(Self {
1803                            cpu: None,
1804                            g2d: None,
1805                            opengl: Some(opengl),
1806                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1807                            forced_backend: Some(ForcedBackend::OpenGl),
1808                        }
1809                        .apply_colorimetry_mode(config.colorimetry))
1810                    }
1811                    #[cfg(any(target_os = "macos", target_os = "ios"))]
1812                    #[cfg(feature = "opengl")]
1813                    {
1814                        let opengl = GLProcessorThreaded::with_cache_capacity(
1815                            config.egl_display,
1816                            config.egl_cache_capacity,
1817                        )
1818                        .map_err(|e| {
1819                            Error::ForcedBackendUnavailable(format!(
1820                                "opengl forced on macOS but ANGLE init failed: {e:?}"
1821                            ))
1822                        })?;
1823                        Ok(Self {
1824                            cpu: None,
1825                            opengl: Some(opengl),
1826                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1827                            forced_backend: Some(ForcedBackend::OpenGl),
1828                        }
1829                        .apply_colorimetry_mode(config.colorimetry))
1830                    }
1831                    #[cfg(target_os = "android")]
1832                    #[cfg(feature = "opengl")]
1833                    {
1834                        let opengl = GLProcessorThreaded::with_cache_capacity(
1835                            config.egl_display,
1836                            config.egl_cache_capacity,
1837                        )
1838                        .map_err(|e| {
1839                            Error::ForcedBackendUnavailable(format!(
1840                                "opengl forced but native EGL init failed: {e:?}"
1841                            ))
1842                        })?;
1843                        Ok(Self {
1844                            cpu: None,
1845                            opengl: Some(opengl),
1846                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1847                            forced_backend: Some(ForcedBackend::OpenGl),
1848                        }
1849                        .apply_colorimetry_mode(config.colorimetry))
1850                    }
1851                    #[cfg(target_os = "windows")]
1852                    #[cfg(feature = "opengl")]
1853                    {
1854                        let opengl = GLProcessorThreaded::with_cache_capacity(
1855                            config.egl_display,
1856                            config.egl_cache_capacity,
1857                        )
1858                        .map_err(|e| {
1859                            Error::ForcedBackendUnavailable(format!(
1860                                "opengl forced on Windows but ANGLE/D3D11 init failed: {e:?}"
1861                            ))
1862                        })?;
1863                        Ok(Self {
1864                            cpu: None,
1865                            opengl: Some(opengl),
1866                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1867                            forced_backend: Some(ForcedBackend::OpenGl),
1868                        }
1869                        .apply_colorimetry_mode(config.colorimetry))
1870                    }
1871                    #[cfg(not(all(
1872                        any(
1873                            target_os = "linux",
1874                            target_os = "macos",
1875                            target_os = "ios",
1876                            target_os = "android",
1877                            target_os = "windows"
1878                        ),
1879                        feature = "opengl"
1880                    )))]
1881                    {
1882                        Err(Error::ForcedBackendUnavailable(
1883                            "opengl backend requires Linux, macOS, Android or Windows with the \
1884                             'opengl' feature enabled"
1885                                .into(),
1886                        ))
1887                    }
1888                }
1889            };
1890        }
1891
1892        // ── Existing DISABLE logic (unchanged) ──────────────────────
1893        #[cfg(target_os = "linux")]
1894        let g2d = if std::env::var("EDGEFIRST_DISABLE_G2D")
1895            .map(|x| x != "0" && x.to_lowercase() != "false")
1896            .unwrap_or(false)
1897        {
1898            log::debug!("EDGEFIRST_DISABLE_G2D is set");
1899            None
1900        } else {
1901            match G2DProcessor::new() {
1902                Ok(g2d_converter) => Some(g2d_converter),
1903                Err(err) => {
1904                    log::warn!("Failed to initialize G2D converter: {err:?}");
1905                    None
1906                }
1907            }
1908        };
1909
1910        #[cfg(target_os = "linux")]
1911        #[cfg(feature = "opengl")]
1912        let opengl = if std::env::var("EDGEFIRST_DISABLE_GL")
1913            .map(|x| x != "0" && x.to_lowercase() != "false")
1914            .unwrap_or(false)
1915        {
1916            log::debug!("EDGEFIRST_DISABLE_GL is set");
1917            None
1918        } else {
1919            match GLProcessorThreaded::with_cache_capacity(
1920                config.egl_display,
1921                config.egl_cache_capacity,
1922            ) {
1923                Ok(gl_converter) => Some(gl_converter),
1924                Err(err) => {
1925                    log::warn!("Failed to initialize GL converter: {err:?}");
1926                    None
1927                }
1928            }
1929        };
1930
1931        #[cfg(any(
1932            target_os = "macos",
1933            target_os = "ios",
1934            target_os = "android",
1935            target_os = "windows"
1936        ))]
1937        #[cfg(feature = "opengl")]
1938        let opengl = if std::env::var("EDGEFIRST_DISABLE_GL")
1939            .map(|x| x != "0" && x.to_lowercase() != "false")
1940            .unwrap_or(false)
1941        {
1942            log::debug!("EDGEFIRST_DISABLE_GL is set");
1943            None
1944        } else {
1945            match GLProcessorThreaded::with_cache_capacity(
1946                config.egl_display,
1947                config.egl_cache_capacity,
1948            ) {
1949                Ok(gl_converter) => Some(gl_converter),
1950                Err(err) => {
1951                    log::debug!(
1952                        "GL backend unavailable: {err:?} \
1953                         (CPU fallback will be used)"
1954                    );
1955                    None
1956                }
1957            }
1958        };
1959
1960        let cpu = if std::env::var("EDGEFIRST_DISABLE_CPU")
1961            .map(|x| x != "0" && x.to_lowercase() != "false")
1962            .unwrap_or(false)
1963        {
1964            log::debug!("EDGEFIRST_DISABLE_CPU is set");
1965            None
1966        } else {
1967            Some(CPUProcessor::new())
1968        };
1969        Ok(Self {
1970            cpu,
1971            #[cfg(target_os = "linux")]
1972            g2d,
1973            #[cfg(target_os = "linux")]
1974            #[cfg(feature = "opengl")]
1975            opengl,
1976            #[cfg(any(
1977                target_os = "macos",
1978                target_os = "ios",
1979                target_os = "android",
1980                target_os = "windows"
1981            ))]
1982            #[cfg(feature = "opengl")]
1983            opengl,
1984            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1985            forced_backend: None,
1986        }
1987        .apply_colorimetry_mode(config.colorimetry))
1988    }
1989
1990    /// Apply the configured [`ColorimetryMode`] to whichever backend honours
1991    /// it (currently the Linux GL backend); no-op elsewhere. Constructor
1992    /// plumbing for [`ImageProcessorConfig::colorimetry`].
1993    fn apply_colorimetry_mode(self, _mode: ColorimetryMode) -> Self {
1994        #[cfg(all(
1995            any(
1996                target_os = "linux",
1997                target_os = "macos",
1998                target_os = "ios",
1999                target_os = "android",
2000                target_os = "windows"
2001            ),
2002            feature = "opengl"
2003        ))]
2004        {
2005            let mut me = self;
2006            if let Err(e) = me.set_colorimetry_mode(_mode) {
2007                log::warn!("Failed to apply ColorimetryMode::{_mode:?}: {e:?}");
2008            }
2009            me
2010        }
2011        #[cfg(not(all(
2012            any(
2013                target_os = "linux",
2014                target_os = "macos",
2015                target_os = "ios",
2016                target_os = "android",
2017                target_os = "windows"
2018            ),
2019            feature = "opengl"
2020        )))]
2021        {
2022            let _ = _mode;
2023            self
2024        }
2025    }
2026
2027    /// Sets the colorimetry/performance trade-off (see [`ColorimetryMode`])
2028    /// on the OpenGL backend. No-op if OpenGL is not available. The
2029    /// `EDGEFIRST_COLORIMETRY` environment variable takes precedence — when
2030    /// it is set, this call logs and keeps the env-selected mode.
2031    #[cfg(all(
2032        any(
2033            target_os = "linux",
2034            target_os = "macos",
2035            target_os = "ios",
2036            target_os = "android",
2037            target_os = "windows"
2038        ),
2039        feature = "opengl"
2040    ))]
2041    pub fn set_colorimetry_mode(&mut self, mode: ColorimetryMode) -> Result<()> {
2042        if let Some(ref mut gl) = self.opengl {
2043            gl.set_colorimetry_mode(mode)?;
2044        }
2045        Ok(())
2046    }
2047
2048    /// Sets the interpolation mode for int8 proto textures on the OpenGL
2049    /// backend. No-op if OpenGL is not available.
2050    #[cfg(all(
2051        any(
2052            target_os = "linux",
2053            target_os = "macos",
2054            target_os = "ios",
2055            target_os = "android",
2056            target_os = "windows"
2057        ),
2058        feature = "opengl"
2059    ))]
2060    pub fn set_int8_interpolation_mode(&mut self, mode: Int8InterpolationMode) -> Result<()> {
2061        if let Some(ref mut gl) = self.opengl {
2062            gl.set_int8_interpolation_mode(mode)?;
2063        }
2064        Ok(())
2065    }
2066
2067    /// Allocate an image tensor from a declarative
2068    /// [`ImageDesc`](edgefirst_tensor::ImageDesc) request — the
2069    /// full-featured variant of [`create_image`](Self::create_image).
2070    ///
2071    /// Without a compression request this is exactly `create_image` (the
2072    /// processor's memory negotiation applies). With one, the allocation
2073    /// rides the tensor desc path un-negotiated: the layout decision
2074    /// belongs to the platform allocator, and the request's guards and
2075    /// fallback counting live there (see
2076    /// [`Tensor::image_desc`](edgefirst_tensor::Tensor::image_desc)).
2077    pub fn create_image_desc(&self, desc: &edgefirst_tensor::ImageDesc) -> Result<TensorDyn> {
2078        if desc.compression().is_none() {
2079            return self.create_image(
2080                desc.width(),
2081                desc.height(),
2082                desc.format(),
2083                desc.dtype(),
2084                desc.memory(),
2085                desc.access(),
2086            );
2087        }
2088        Ok(TensorDyn::image_desc(desc)?)
2089    }
2090
2091    /// Create a [`TensorDyn`] image with the best available memory backend.
2092    ///
2093    /// Priority: DMA-buf → float PBO (F16/F32) → u8/i8 PBO → system memory.
2094    ///
2095    /// Use this method instead of [`TensorDyn::image()`] when the tensor will
2096    /// be used with [`ImageProcessor::convert()`]. It selects the optimal
2097    /// memory backing (including PBO for GPU zero-copy) which direct
2098    /// allocation cannot achieve.
2099    ///
2100    /// This method is on [`ImageProcessor`] rather than [`ImageProcessorTrait`]
2101    /// because optimal allocation requires knowledge of the active compute
2102    /// backends (e.g. the GL context handle for PBO allocation). Individual
2103    /// backend implementations ([`CPUProcessor`], etc.) do not have this
2104    /// cross-backend visibility.
2105    ///
2106    /// **Float dtype behaviour:** when `dtype` is `F16` or `F32` and
2107    /// [`supported_render_dtypes`] reports the GPU supports that type,
2108    /// `memory: None` auto-selects a float PBO (Linux) or IOSurface (macOS
2109    /// F16 only). If GPU float support is absent the allocation falls through
2110    /// to `TensorMemory::Mem`; [`convert`] then uses the CPU path.
2111    /// Passing `memory: Some(TensorMemory::DmaBuf)` with `dtype: F32` always
2112    /// returns `Error::NotSupported` — no 32-bit-float DRM fourcc exists.
2113    ///
2114    /// [`supported_render_dtypes`]: Self::supported_render_dtypes
2115    /// [`convert`]: ImageProcessorTrait::convert
2116    ///
2117    /// # Arguments
2118    ///
2119    /// * `width` - Image width in pixels
2120    /// * `height` - Image height in pixels
2121    /// * `format` - Pixel format
2122    /// * `dtype` - Element data type (e.g. `DType::U8`, `DType::F16`, `DType::F32`)
2123    /// * `memory` - Optional memory type override; when `None`, the best
2124    ///   available backend is selected automatically.
2125    /// * `access` - Declares the CPU involvement you plan on. Use
2126    ///   [`CpuAccess::None`](edgefirst_tensor::CpuAccess) for a destination
2127    ///   that only hardware reads (an NPU input), and `ReadWrite` when you
2128    ///   will map it. Hardware access is always implied. Mapping a tensor
2129    ///   beyond its declaration is counted by
2130    ///   [`unplanned_cpu_access_count`](edgefirst_tensor::unplanned_cpu_access_count).
2131    ///
2132    /// # Returns
2133    ///
2134    /// A [`TensorDyn`] backed by the highest-performance memory type
2135    /// available on this system.
2136    ///
2137    /// # Examples
2138    ///
2139    /// ```rust,no_run
2140    /// # use edgefirst_image::ImageProcessor;
2141    /// # use edgefirst_tensor::{CpuAccess, DType, PixelFormat};
2142    /// # fn main() -> Result<(), edgefirst_image::Error> {
2143    /// let processor = ImageProcessor::new()?;
2144    ///
2145    /// // Model input the NPU reads directly — no CPU mapping planned.
2146    /// let input = processor.create_image(
2147    ///     640, 640, PixelFormat::Rgb, DType::U8, None, CpuAccess::None,
2148    /// )?;
2149    /// # let _ = input;
2150    /// # Ok(())
2151    /// # }
2152    /// ```
2153    ///
2154    /// # Pitch alignment for DMA-backed allocations
2155    ///
2156    /// DMA-BUF imports into the GL backend (Mali Valhall on i.MX 95
2157    /// specifically) require every row pitch to be a multiple of
2158    /// [`GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES`] (currently 64). When this
2159    /// method lands on `TensorMemory::DmaBuf`, the underlying allocation is
2160    /// silently padded so the row stride satisfies that requirement.
2161    ///
2162    /// **The user-requested `width` is preserved** — `tensor.width()`
2163    /// returns the same value you passed in. The padding is carried by
2164    /// [`TensorDyn::row_stride`] / `effective_row_stride()`, which the
2165    /// GL backend reads when importing the buffer as an EGLImage.
2166    /// Callers that compute byte offsets from the tensor must use the
2167    /// stride, not `width × bytes_per_pixel`; the CPU mapping spans the
2168    /// full `stride × height` bytes.
2169    ///
2170    /// Pre-aligned widths (640, 1280, 1920, 3008, 3840 …) allocate
2171    /// exactly `width × bpp × height` bytes with no padding. PBO and
2172    /// Mem fallbacks never pad — they don't go through EGLImage import.
2173    ///
2174    /// See also [`align_width_for_gpu_pitch`] for an advisory helper
2175    /// that external callers (GStreamer plugins, video pipelines) can
2176    /// use to size their own DMA-BUFs for GL compatibility.
2177    ///
2178    /// # Errors
2179    ///
2180    /// Returns an error if all allocation strategies fail.
2181    pub fn create_image(
2182        &self,
2183        width: usize,
2184        height: usize,
2185        format: PixelFormat,
2186        dtype: DType,
2187        memory: Option<TensorMemory>,
2188        access: edgefirst_tensor::CpuAccess,
2189    ) -> Result<TensorDyn> {
2190        // Compute the GPU-aligned row stride in bytes for this image.
2191        // `None` means either the format has no defined primary-plane bpp
2192        // (unknown future layout) or the stride calculation would overflow
2193        // — in both cases we fall back to the natural layout via the plain
2194        // `TensorDyn::image` constructor, and the slow-path warning inside
2195        // `draw_*_masks` will fire if the subsequent GL import fails.
2196        //
2197        // DMA allocation is Linux-only (see `TensorMemory::DmaBuf` cfg gate),
2198        // so both the stride computation and the helper closure are gated
2199        // accordingly — the callers below are already Linux-only.
2200        #[cfg(target_os = "linux")]
2201        let dma_stride_bytes: Option<usize> = primary_plane_bpp(format, dtype.size())
2202            .and_then(|bpp| width.checked_mul(bpp))
2203            .and_then(align_pitch_bytes_to_gpu_alignment);
2204
2205        // Helper: allocate a DMA image, using the padded-stride constructor
2206        // when the computed stride exceeds the natural pitch, otherwise the
2207        // plain constructor (byte-identical result in the common case).
2208        #[cfg(target_os = "linux")]
2209        let try_dma = || -> Result<TensorDyn> {
2210            // Stride padding is only meaningful for packed pixel layouts
2211            // (RGBA8, BGRA8, RGB888, Grey) — the formats the GL backend
2212            // renders into. Semi-planar (NV12, NV16) and planar (PlanarRgb,
2213            // PlanarRgba) tensors go through `TensorDyn::image(...)` with
2214            // their natural layout; they're imported from camera capture
2215            // via `from_fd` far more often than allocated here, and
2216            // `Tensor::image_with_stride` explicitly rejects them.
2217            let packed = format.layout() == edgefirst_tensor::PixelLayout::Packed;
2218            match dma_stride_bytes {
2219                Some(stride)
2220                    if packed
2221                        && primary_plane_bpp(format, dtype.size())
2222                            .and_then(|bpp| width.checked_mul(bpp))
2223                            .is_some_and(|natural| stride > natural) =>
2224                {
2225                    log::debug!(
2226                        "create_image: padding row stride for {format:?} {width}x{height} \
2227                         from natural pitch to {stride} bytes for GPU alignment"
2228                    );
2229                    Ok(TensorDyn::image_with_stride(
2230                        width,
2231                        height,
2232                        format,
2233                        dtype,
2234                        stride,
2235                        Some(edgefirst_tensor::TensorMemory::DmaBuf),
2236                        access,
2237                    )?)
2238                }
2239                _ => Ok(TensorDyn::image(
2240                    width,
2241                    height,
2242                    format,
2243                    dtype,
2244                    Some(edgefirst_tensor::TensorMemory::DmaBuf),
2245                    access,
2246                )?),
2247            }
2248        };
2249
2250        // If an explicit memory type is requested, honour it directly.
2251        // On Linux, `TensorMemory::DmaBuf` gets the padded-stride treatment;
2252        // other memory types take the user-requested width verbatim.
2253        // On macOS, `TensorMemory::DmaBuf` dispatches through `TensorDyn::image`
2254        // which selects the IOSurface allocation path (FourCC-formatted)
2255        // for image-mappable formats, or falls back to SHM/Mem otherwise.
2256        match memory {
2257            #[cfg(target_os = "linux")]
2258            Some(TensorMemory::DmaBuf) => {
2259                // F32 has no 32-bit-float DRM fourcc; callers must use PBO instead.
2260                if dtype == DType::F32 {
2261                    return Err(Error::NotSupported(
2262                        "F32 has no 32-bit-float DRM format for DMA-BUF; \
2263                         use TensorMemory::Pbo for F32"
2264                            .to_string(),
2265                    ));
2266                }
2267                return try_dma();
2268            }
2269            Some(mem) => {
2270                return Ok(TensorDyn::image(
2271                    width,
2272                    height,
2273                    format,
2274                    dtype,
2275                    Some(mem),
2276                    access,
2277                )?);
2278            }
2279            None => {}
2280        }
2281
2282        // macOS/Android/Windows: when the GL backend is active, prefer Dma
2283        // (IOSurface on Apple, AHardwareBuffer on Android, a D3D11 texture
2284        // on Windows) for zero-copy import. Formats without a zero-copy
2285        // mapping now ERROR under explicit Dma (the explicit-Dma
2286        // contract), so auto-select catches that error here and falls
2287        // back to host storage — loudly, via the debug log below.
2288        #[cfg(any(
2289            target_os = "macos",
2290            target_os = "ios",
2291            target_os = "android",
2292            target_os = "windows"
2293        ))]
2294        #[cfg(feature = "opengl")]
2295        if let Some(gl) = self.opengl.as_ref() {
2296            let _ = gl;
2297            // Float textures only where the display can render into them;
2298            // otherwise the float PBO step below applies. The same question
2299            // that step asks, asked through the same helper so the two
2300            // cannot answer differently.
2301            let support = self.supported_render_dtypes();
2302            let dtype_ok =
2303                !matches!(dtype, DType::F16 | DType::F32) || float_pbo_eligible(dtype, support);
2304            if dtype_ok {
2305                match TensorDyn::image(
2306                    width,
2307                    height,
2308                    format,
2309                    dtype,
2310                    Some(edgefirst_tensor::TensorMemory::DmaBuf),
2311                    access,
2312                ) {
2313                    Ok(img) => return Ok(img),
2314                    Err(e) => {
2315                        // Falling back to a non-zero-copy destination is a real
2316                        // perf cliff — never do it silently (on-device triage
2317                        // starts from this line).
2318                        log::debug!(
2319                            "create_image: zero-copy Dma allocation declined \
2320                             ({format:?}/{dtype:?} {width}x{height}): {e:?}; using fallback storage"
2321                        );
2322                    }
2323                }
2324            }
2325        }
2326
2327        // Try DMA first on Linux — skip only when GL has explicitly selected PBO
2328        // as the preferred transfer path (PBO is better than DMA in that case).
2329        #[cfg(target_os = "linux")]
2330        {
2331            #[cfg(feature = "opengl")]
2332            let gl_uses_pbo = self
2333                .opengl
2334                .as_ref()
2335                .is_some_and(|gl| gl.transfer_backend() == opengl_headless::TransferBackend::Pbo);
2336            #[cfg(not(feature = "opengl"))]
2337            let gl_uses_pbo = false;
2338
2339            if !gl_uses_pbo {
2340                if let Ok(img) = try_dma() {
2341                    return Ok(img);
2342                }
2343            }
2344        }
2345
2346        // Try PBO (if GL available).
2347        // PBO buffers are u8-sized; the int8 shader emulates i8 output via
2348        // XOR 0x80 on the same underlying buffer, so both U8 and I8 work.
2349        #[cfg(any(target_os = "linux", target_os = "windows"))]
2350        #[cfg(feature = "opengl")]
2351        if dtype.size() == 1 {
2352            if let Some(gl) = &self.opengl {
2353                match gl.create_pbo_image(width, height, format) {
2354                    Ok(t) => {
2355                        if dtype == DType::I8 {
2356                            // SAFETY: Tensor<u8> and Tensor<i8> are layout-
2357                            // identical (same element size, no T-dependent
2358                            // drop glue). The int8 shader applies XOR 0x80
2359                            // on the same PBO buffer. Same rationale as
2360                            // gl::processor::tensor_i8_as_u8_mut.
2361                            // Invariant: PBO tensors never have chroma
2362                            // (create_pbo_image → Tensor::wrap sets it None).
2363                            debug_assert!(
2364                                t.chroma().is_none(),
2365                                "PBO i8 transmute requires chroma == None"
2366                            );
2367                            let t_i8: Tensor<i8> = unsafe { std::mem::transmute(t) };
2368                            return Ok(TensorDyn::from(t_i8));
2369                        }
2370                        return Ok(TensorDyn::from(t));
2371                    }
2372                    Err(e) => log::debug!("PBO image creation failed, falling back to Mem: {e:?}"),
2373                }
2374            }
2375        }
2376
2377        // Try float PBO when the GPU backend reports support for this dtype.
2378        // Falls through to Mem on error (same policy as u8 PBO above).
2379        #[cfg(any(target_os = "linux", target_os = "windows"))]
2380        #[cfg(feature = "opengl")]
2381        if float_pbo_eligible(dtype, self.supported_render_dtypes()) {
2382            if let Some(gl) = &self.opengl {
2383                match gl.create_pbo_image_dtype(width, height, format, dtype) {
2384                    Ok(t) => return Ok(t),
2385                    Err(e) => {
2386                        log::debug!(
2387                            "Float PBO image creation failed for {dtype:?}, \
2388                             falling back to Mem: {e:?}"
2389                        );
2390                    }
2391                }
2392            }
2393        }
2394
2395        // Fallback to Mem
2396        Ok(TensorDyn::image(
2397            width,
2398            height,
2399            format,
2400            dtype,
2401            Some(edgefirst_tensor::TensorMemory::Mem),
2402            access,
2403        )?)
2404    }
2405
2406    /// Import an external DMA-BUF image.
2407    ///
2408    /// Each [`PlaneDescriptor`] owns an already-duped fd; this method
2409    /// consumes the descriptors and takes ownership of those fds (whether
2410    /// the call succeeds or fails).
2411    ///
2412    /// The caller must ensure the DMA-BUF allocation is large enough for the
2413    /// specified width, height, format, and any stride/offset on the plane
2414    /// descriptors. No buffer-size validation is performed; an undersized
2415    /// buffer may cause GPU faults or EGL import failure.
2416    ///
2417    /// # Arguments
2418    ///
2419    /// * `image` - Plane descriptor for the primary (or only) plane
2420    /// * `chroma` - Optional plane descriptor for the UV chroma plane
2421    ///   (required for multiplane NV12)
2422    /// * `width` - Image width in pixels
2423    /// * `height` - Image height in pixels
2424    /// * `format` - Pixel format of the buffer
2425    /// * `dtype` - Element data type (e.g. `DType::U8`)
2426    ///
2427    /// # Returns
2428    ///
2429    /// A `TensorDyn` configured as an image.
2430    ///
2431    /// # Errors
2432    ///
2433    /// * [`Error::NotSupported`] if `chroma` is `Some` for a non-semi-planar
2434    ///   format, or multiplane NV16 (not yet supported), or the fd is not
2435    ///   DMA-backed
2436    /// * [`Error::InvalidShape`] if NV12 height is odd
2437    ///
2438    /// # Platform
2439    ///
2440    /// Linux only.
2441    ///
2442    /// # Examples
2443    ///
2444    /// ```rust,ignore
2445    /// use edgefirst_tensor::PlaneDescriptor;
2446    ///
2447    /// // Single-plane RGBA
2448    /// let pd = PlaneDescriptor::new(fd.as_fd())?;
2449    /// let src = proc.import_image(pd, None, 1920, 1080, PixelFormat::Rgba, DType::U8, None)?;
2450    ///
2451    /// // Multi-plane NV12 with stride
2452    /// let y_pd = PlaneDescriptor::new(y_fd.as_fd())?.with_stride(2048);
2453    /// let uv_pd = PlaneDescriptor::new(uv_fd.as_fd())?.with_stride(2048);
2454    /// let src = proc.import_image(y_pd, Some(uv_pd), 1920, 1080,
2455    ///                             PixelFormat::Nv12, DType::U8, None)?;
2456    /// ```
2457    // Import inherently needs plane(s) + geometry + format + dtype + colorimetry;
2458    // a params struct would obscure more than it clarifies here.
2459    #[allow(clippy::too_many_arguments)]
2460    #[cfg(target_os = "linux")]
2461    pub fn import_image(
2462        &self,
2463        image: edgefirst_tensor::PlaneDescriptor,
2464        chroma: Option<edgefirst_tensor::PlaneDescriptor>,
2465        width: usize,
2466        height: usize,
2467        format: PixelFormat,
2468        dtype: DType,
2469        colorimetry: Option<edgefirst_tensor::Colorimetry>,
2470    ) -> Result<TensorDyn> {
2471        use edgefirst_tensor::{Tensor, TensorMemory};
2472
2473        // Capture stride/offset from descriptors before consuming them
2474        let image_stride = image.stride();
2475        let image_offset = image.offset();
2476        let chroma_stride = chroma.as_ref().and_then(|c| c.stride());
2477        let chroma_offset = chroma.as_ref().and_then(|c| c.offset());
2478
2479        if let Some(chroma_pd) = chroma {
2480            // ── Multiplane path ──────────────────────────────────────
2481            // Multiplane tensors are backed by Tensor<u8> (or transmuted to
2482            // Tensor<i8>). Reject other dtypes to avoid silently returning a
2483            // tensor with the wrong element type.
2484            if dtype != DType::U8 && dtype != DType::I8 {
2485                return Err(Error::NotSupported(format!(
2486                    "multiplane import only supports U8/I8, got {dtype:?}"
2487                )));
2488            }
2489            if format.layout() != PixelLayout::SemiPlanar {
2490                return Err(Error::NotSupported(format!(
2491                    "import_image with chroma requires a semi-planar format, got {format:?}"
2492                )));
2493            }
2494
2495            let chroma_h = match format {
2496                PixelFormat::Nv12 => {
2497                    // NV12 (4:2:0): ceil(H/2) chroma rows — odd heights are valid.
2498                    height.div_ceil(2)
2499                }
2500                // NV16 multiplane will be supported in a future release;
2501                // the GL backend currently only handles NV12 plane1 attributes.
2502                PixelFormat::Nv16 => {
2503                    return Err(Error::NotSupported(
2504                        "multiplane NV16 is not yet supported; use contiguous NV16 instead".into(),
2505                    ))
2506                }
2507                _ => {
2508                    return Err(Error::NotSupported(format!(
2509                        "unsupported semi-planar format: {format:?}"
2510                    )))
2511                }
2512            };
2513
2514            let luma = Tensor::<u8>::from_fd(image.into_fd(), &[height, width], Some("luma"))?;
2515            if luma.memory() != TensorMemory::DmaBuf {
2516                return Err(Error::NotSupported(format!(
2517                    "luma fd must be DMA-backed, got {:?}",
2518                    luma.memory()
2519                )));
2520            }
2521
2522            let mut chroma_tensor =
2523                Tensor::<u8>::from_fd(chroma_pd.into_fd(), &[chroma_h, width], Some("chroma"))?;
2524            if chroma_tensor.memory() != TensorMemory::DmaBuf {
2525                return Err(Error::NotSupported(format!(
2526                    "chroma fd must be DMA-backed, got {:?}",
2527                    chroma_tensor.memory()
2528                )));
2529            }
2530
2531            // Geometry on the chroma plane must be set before from_planes
2532            // consumes it: the combined handle answers chroma() from the
2533            // library, and there is no chroma_mut() on the dynamic lens.
2534            if let Some(s) = chroma_stride {
2535                if s < width {
2536                    return Err(Error::InvalidShape(format!(
2537                        "chroma stride {s} < minimum {width} for {format:?}"
2538                    )));
2539                }
2540                chroma_tensor.set_row_stride_unchecked(s);
2541            }
2542            if let Some(o) = chroma_offset {
2543                chroma_tensor.set_plane_offset(o);
2544            }
2545
2546            // from_planes creates the combined tensor with format set,
2547            // preserving luma's row_stride (currently None since luma was raw).
2548            let mut tensor = Tensor::<u8>::from_planes(luma, chroma_tensor, format)?;
2549
2550            // Apply stride/offset to the combined tensor (luma plane)
2551            if let Some(s) = image_stride {
2552                tensor.set_row_stride(s)?;
2553            }
2554            if let Some(o) = image_offset {
2555                tensor.set_plane_offset(o);
2556            }
2557
2558            if dtype == DType::I8 {
2559                // SAFETY: Tensor<u8> and Tensor<i8> have identical layout because
2560                // the struct contains only type-erased storage (OwnedFd, shape, name),
2561                // no inline T values. This assertion catches layout drift at compile time.
2562                const {
2563                    assert!(std::mem::size_of::<Tensor<u8>>() == std::mem::size_of::<Tensor<i8>>());
2564                    assert!(
2565                        std::mem::align_of::<Tensor<u8>>() == std::mem::align_of::<Tensor<i8>>()
2566                    );
2567                }
2568                let tensor_i8: Tensor<i8> = unsafe { std::mem::transmute(tensor) };
2569                let mut dyn_tensor = TensorDyn::from(tensor_i8);
2570                dyn_tensor.set_colorimetry(colorimetry);
2571                return Ok(dyn_tensor);
2572            }
2573            let mut dyn_tensor = TensorDyn::from(tensor);
2574            dyn_tensor.set_colorimetry(colorimetry);
2575            Ok(dyn_tensor)
2576        } else {
2577            // ── Single-plane path ────────────────────────────────────
2578            // Canonical shape (Packed [H,W,C] / Planar [C,H,W] / SemiPlanar
2579            // [total_h, W]); `allocation_shape` supports NV12/NV16/NV24 (the old
2580            // hand-rolled match erroneously rejected NV24).
2581            let shape = format.allocation_shape(width, height).ok_or_else(|| {
2582                Error::NotSupported(format!(
2583                    "unsupported pixel format for import_image: {format:?}"
2584                ))
2585            })?;
2586            let tensor = TensorDyn::from_fd(image.into_fd(), &shape, dtype, None)?;
2587            if tensor.memory() != TensorMemory::DmaBuf {
2588                return Err(Error::NotSupported(format!(
2589                    "import_image requires DMA-backed fd, got {:?}",
2590                    tensor.memory()
2591                )));
2592            }
2593            let mut tensor = tensor.with_format(format)?;
2594            if let Some(s) = image_stride {
2595                tensor.set_row_stride(s)?;
2596            }
2597            if let Some(o) = image_offset {
2598                tensor.set_plane_offset(o);
2599            }
2600            tensor.set_colorimetry(colorimetry);
2601            Ok(tensor)
2602        }
2603    }
2604
2605    /// Decode model outputs and draw segmentation masks onto `dst`.
2606    ///
2607    /// The processor decodes via the provided
2608    /// [`Decoder`](edgefirst_decoder::Decoder), selects the optimal rendering
2609    /// path (hybrid CPU+GL or fused GPU), and composites masks onto `dst`.
2610    ///
2611    /// Returns the detected bounding boxes.
2612    ///
2613    /// **Requires the `decode` feature**, which is off by default because it
2614    /// is the only thing in this crate that needs `edgefirst-decoder`. This
2615    /// method sequences a decode and a draw; both halves are available
2616    /// unconditionally and are equally fused, so without the feature write:
2617    ///
2618    /// ```ignore
2619    /// let mut boxes = Vec::new();
2620    /// match decoder.decode_proto(outputs, &mut boxes)? {
2621    ///     Some(protos) => proc.draw_proto_masks(dst, &boxes, &protos, overlay)?,
2622    ///     None => {
2623    ///         let mut masks = Vec::new();
2624    ///         decoder.decode(outputs, &mut boxes, &mut masks)?;
2625    ///         proc.draw_decoded_masks(dst, &boxes, &masks, overlay)?;
2626    ///     }
2627    /// }
2628    /// ```
2629    #[cfg(feature = "decode")]
2630    pub fn draw_masks(
2631        &mut self,
2632        decoder: &edgefirst_decoder::Decoder,
2633        outputs: &[&TensorDyn],
2634        dst: &mut TensorDyn,
2635        overlay: MaskOverlay<'_>,
2636    ) -> Result<Vec<DetectBox>> {
2637        let mut output_boxes = Vec::with_capacity(100);
2638
2639        // Try proto path first (fused rendering without materializing masks)
2640        let proto_result = decoder
2641            .decode_proto(outputs, &mut output_boxes)
2642            .map_err(|e| Error::Internal(format!("decode_proto: {e:#?}")))?;
2643
2644        if let Some(proto_data) = proto_result {
2645            self.draw_proto_masks(dst, &output_boxes, &proto_data, overlay)?;
2646        } else {
2647            // Detection-only or unsupported model: full decode + render
2648            let mut output_masks = Vec::with_capacity(100);
2649            decoder
2650                .decode(outputs, &mut output_boxes, &mut output_masks)
2651                .map_err(|e| Error::Internal(format!("decode: {e:#?}")))?;
2652            self.draw_decoded_masks(dst, &output_boxes, &output_masks, overlay)?;
2653        }
2654        Ok(output_boxes)
2655    }
2656
2657    /// Decode tracked model outputs and draw segmentation masks onto `dst`.
2658    ///
2659    /// Like [`draw_masks`](Self::draw_masks) but integrates a tracker for
2660    /// maintaining object identities across frames. The tracker runs after
2661    /// NMS but before mask extraction.
2662    ///
2663    /// Returns detected boxes and track info.
2664    #[cfg(feature = "tracker")]
2665    pub fn draw_masks_tracked<TR: edgefirst_tracker::Tracker<DetectBox>>(
2666        &mut self,
2667        decoder: &edgefirst_decoder::Decoder,
2668        tracker: &mut TR,
2669        timestamp: u64,
2670        outputs: &[&TensorDyn],
2671        dst: &mut TensorDyn,
2672        overlay: MaskOverlay<'_>,
2673    ) -> Result<(Vec<DetectBox>, Vec<edgefirst_tracker::TrackInfo>)> {
2674        let mut output_boxes = Vec::with_capacity(100);
2675        let mut output_tracks = Vec::new();
2676
2677        let proto_result = decoder
2678            .decode_proto_tracked(
2679                tracker,
2680                timestamp,
2681                outputs,
2682                &mut output_boxes,
2683                &mut output_tracks,
2684            )
2685            .map_err(|e| Error::Internal(format!("decode_proto_tracked: {e:#?}")))?;
2686
2687        if let Some(proto_data) = proto_result {
2688            self.draw_proto_masks(dst, &output_boxes, &proto_data, overlay)?;
2689        } else {
2690            // Note: decode_proto_tracked returns None for detection-only/ModelPack
2691            // models WITHOUT calling the tracker. The else branch below is the
2692            // first (and only) tracker call for those model types.
2693            let mut output_masks = Vec::with_capacity(100);
2694            decoder
2695                .decode_tracked(
2696                    tracker,
2697                    timestamp,
2698                    outputs,
2699                    &mut output_boxes,
2700                    &mut output_masks,
2701                    &mut output_tracks,
2702                )
2703                .map_err(|e| Error::Internal(format!("decode_tracked: {e:#?}")))?;
2704            self.draw_decoded_masks(dst, &output_boxes, &output_masks, overlay)?;
2705        }
2706        Ok((output_boxes, output_tracks))
2707    }
2708
2709    /// Materialize per-instance segmentation masks from raw prototype data.
2710    ///
2711    /// Computes `mask_coeff @ protos` with sigmoid activation for each detection,
2712    /// producing compact masks at prototype resolution (e.g., 160×160 crops).
2713    /// Mask values are continuous sigmoid confidence outputs quantized to u8
2714    /// (0 = background, 255 = full confidence), NOT binary thresholded.
2715    ///
2716    /// The returned [`Vec<Segmentation>`] can be:
2717    /// - Inspected or exported for analytics, IoU computation, etc.
2718    /// - Passed directly to [`ImageProcessorTrait::draw_decoded_masks`] for
2719    ///   GPU-interpolated rendering.
2720    ///
2721    /// # Performance Note
2722    ///
2723    /// Calling `materialize_masks` + `draw_decoded_masks` separately prevents
2724    /// the HAL from using its internal fused optimization path. For render-only
2725    /// use cases, prefer [`ImageProcessorTrait::draw_proto_masks`] which selects
2726    /// the fastest path automatically (currently 1.6×–27× faster on tested
2727    /// platforms). Use this method when you need access to the intermediate masks.
2728    ///
2729    /// # Errors
2730    ///
2731    /// Returns [`Error::NoConverter`] if the CPU backend is not available.
2732    pub fn materialize_masks(
2733        &mut self,
2734        detect: &[DetectBox],
2735        proto_data: &ProtoData,
2736        letterbox: Option<[f32; 4]>,
2737        resolution: MaskResolution,
2738    ) -> Result<Vec<Segmentation>> {
2739        let cpu = self.cpu.as_mut().ok_or(Error::NoConverter)?;
2740        match resolution {
2741            MaskResolution::Proto => cpu.materialize_segmentations(detect, proto_data, letterbox),
2742            MaskResolution::Scaled { width, height } => {
2743                cpu.materialize_scaled_segmentations(detect, proto_data, letterbox, width, height)
2744            }
2745        }
2746    }
2747}
2748
2749impl ImageProcessorTrait for ImageProcessor {
2750    /// Converts the source image to the destination image format and size. The
2751    /// image is cropped first, then flipped, then rotated
2752    ///
2753    /// Prefer hardware accelerators when available, falling back to CPU if
2754    /// necessary.
2755    fn convert(
2756        &mut self,
2757        src: &TensorDyn,
2758        dst: &mut TensorDyn,
2759        rotation: Rotation,
2760        flip: Flip,
2761        crop: Crop,
2762    ) -> Result<()> {
2763        let start = Instant::now();
2764        let src_fmt = src.format();
2765        let dst_fmt = dst.format();
2766        let _span = tracing::trace_span!(
2767            "image.convert",
2768            ?src_fmt,
2769            ?dst_fmt,
2770            src_memory = ?src.memory(),
2771            dst_memory = ?dst.memory(),
2772            ?rotation,
2773            ?flip,
2774        )
2775        .entered();
2776        log::trace!(
2777            "convert: {src_fmt:?}({:?}/{:?}) → {dst_fmt:?}({:?}/{:?}), \
2778             rotation={rotation:?}, flip={flip:?}, backend={:?}",
2779            src.dtype(),
2780            src.memory(),
2781            dst.dtype(),
2782            dst.memory(),
2783            self.forced_backend,
2784        );
2785
2786        // ── Forced backend: no fallback chain ────────────────────────
2787        if let Some(forced) = self.forced_backend {
2788            return match forced {
2789                ForcedBackend::Cpu => {
2790                    if let Some(cpu) = self.cpu.as_mut() {
2791                        let r = cpu.convert(src, dst, rotation, flip, crop);
2792                        log::trace!(
2793                            "convert: forced=cpu result={} ({:?})",
2794                            if r.is_ok() { "ok" } else { "err" },
2795                            start.elapsed()
2796                        );
2797                        return r;
2798                    }
2799                    Err(Error::ForcedBackendUnavailable("cpu".into()))
2800                }
2801                ForcedBackend::G2d => {
2802                    #[cfg(target_os = "linux")]
2803                    if let Some(g2d) = self.g2d.as_mut() {
2804                        let r = g2d.convert(src, dst, rotation, flip, crop);
2805                        log::trace!(
2806                            "convert: forced=g2d result={} ({:?})",
2807                            if r.is_ok() { "ok" } else { "err" },
2808                            start.elapsed()
2809                        );
2810                        return r;
2811                    }
2812                    Err(Error::ForcedBackendUnavailable("g2d".into()))
2813                }
2814                ForcedBackend::OpenGl => {
2815                    #[cfg(any(
2816                        target_os = "linux",
2817                        target_os = "macos",
2818                        target_os = "ios",
2819                        target_os = "android",
2820                        target_os = "windows"
2821                    ))]
2822                    #[cfg(feature = "opengl")]
2823                    if let Some(opengl) = self.opengl.as_mut() {
2824                        let r = opengl.convert(src, dst, rotation, flip, crop);
2825                        log::trace!(
2826                            "convert: forced=opengl result={} ({:?})",
2827                            if r.is_ok() { "ok" } else { "err" },
2828                            start.elapsed()
2829                        );
2830                        return r;
2831                    }
2832                    Err(Error::ForcedBackendUnavailable("opengl".into()))
2833                }
2834            };
2835        }
2836
2837        // ── Auto fallback chain: OpenGL → G2D → CPU ──────────────────
2838        #[cfg(any(
2839            target_os = "linux",
2840            target_os = "macos",
2841            target_os = "ios",
2842            target_os = "android",
2843            target_os = "windows"
2844        ))]
2845        #[cfg(feature = "opengl")]
2846        if let Some(opengl) = self.opengl.as_mut() {
2847            match opengl.convert(src, dst, rotation, flip, crop) {
2848                Ok(_) => {
2849                    log::trace!(
2850                        "convert: auto selected=opengl for {src_fmt:?}→{dst_fmt:?} ({:?})",
2851                        start.elapsed()
2852                    );
2853                    return Ok(());
2854                }
2855                Err(e) => {
2856                    self.convert_fallbacks
2857                        .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2858                    log::debug!(
2859                        "convert: auto opengl declined {src_fmt:?}@{:?}→{dst_fmt:?}@{:?}, \
2860                         falling back toward G2D/CPU: {e}",
2861                        src.memory(),
2862                        dst.memory(),
2863                    );
2864                }
2865            }
2866        }
2867
2868        #[cfg(target_os = "linux")]
2869        if let Some(g2d) = self.g2d.as_mut() {
2870            // G2D is matrix-only (no range control, no BT.2020). For any
2871            // conversion with a YUV side, resolve that side's colorimetry and
2872            // skip G2D entirely when it cannot be expressed (full-range YUV or
2873            // BT.2020), letting the chain fall through to GL/CPU which honour
2874            // range and BT.2020. YUV→RGB uses the source colorimetry; RGB→YUV
2875            // uses the destination. RGB→RGB has no YUV side and is unaffected.
2876            let src_is_yuv = src.format().is_some_and(|f| f.is_yuv());
2877            let dst_is_yuv = dst.format().is_some_and(|f| f.is_yuv());
2878            let g2d_eligible = if src_is_yuv || dst_is_yuv {
2879                let cm = if src_is_yuv {
2880                    crate::colorimetry::effective_colorimetry(src)
2881                } else {
2882                    crate::colorimetry::effective_colorimetry(dst)
2883                };
2884                crate::g2d::g2d_can_handle(&cm, true)
2885            } else {
2886                true
2887            };
2888            if !g2d_eligible {
2889                log::trace!(
2890                    "convert: auto g2d skipped {src_fmt:?}→{dst_fmt:?} \
2891                     (colorimetry not expressible: full-range/BT.2020)"
2892                );
2893            } else {
2894                match g2d.convert(src, dst, rotation, flip, crop) {
2895                    Ok(_) => {
2896                        log::trace!(
2897                            "convert: auto selected=g2d for {src_fmt:?}→{dst_fmt:?} ({:?})",
2898                            start.elapsed()
2899                        );
2900                        return Ok(());
2901                    }
2902                    Err(e) => {
2903                        log::trace!("convert: auto g2d declined {src_fmt:?}→{dst_fmt:?}: {e}");
2904                    }
2905                }
2906            }
2907        }
2908
2909        if let Some(cpu) = self.cpu.as_mut() {
2910            match cpu.convert(src, dst, rotation, flip, crop) {
2911                Ok(_) => {
2912                    log::trace!(
2913                        "convert: auto selected=cpu for {src_fmt:?}→{dst_fmt:?} ({:?})",
2914                        start.elapsed()
2915                    );
2916                    return Ok(());
2917                }
2918                Err(e) => {
2919                    log::trace!("convert: auto cpu failed {src_fmt:?}→{dst_fmt:?}: {e}");
2920                    return Err(e);
2921                }
2922            }
2923        }
2924        Err(Error::NoConverter)
2925    }
2926
2927    fn convert_deferred(
2928        &mut self,
2929        src: &TensorDyn,
2930        dst: &mut TensorDyn,
2931        rotation: Rotation,
2932        flip: Flip,
2933        crop: Crop,
2934    ) -> Result<()> {
2935        // Deferred batching is an OpenGL optimization (shared parent EGLImage +
2936        // no per-tile glFinish). Route to the GL backend's deferred path when GL
2937        // is forced or auto-selectable; on a GL decline fall back to an eager
2938        // convert (the auto chain), which is correct everywhere — it completes
2939        // synchronously and `flush` stays a no-op for non-GL backends.
2940        #[cfg(any(
2941            target_os = "linux",
2942            target_os = "macos",
2943            target_os = "ios",
2944            target_os = "android",
2945            target_os = "windows"
2946        ))]
2947        #[cfg(feature = "opengl")]
2948        {
2949            let gl_forced = matches!(self.forced_backend, Some(ForcedBackend::OpenGl));
2950            if gl_forced || self.forced_backend.is_none() {
2951                if let Some(opengl) = self.opengl.as_mut() {
2952                    match opengl.convert_deferred(src, dst, rotation, flip, crop) {
2953                        Ok(()) => return Ok(()),
2954                        Err(e) => {
2955                            log::trace!("convert_deferred: gl declined: {e}; eager fallback");
2956                            // A forced-GL caller gets the GL error, matching
2957                            // `convert`'s no-fallback forced-backend contract.
2958                            if gl_forced {
2959                                return Err(e);
2960                            }
2961                        }
2962                    }
2963                }
2964            }
2965        }
2966        self.convert(src, dst, rotation, flip, crop)
2967    }
2968
2969    fn flush(&mut self) -> Result<()> {
2970        let _span = tracing::trace_span!("image.flush").entered();
2971        // Only the OpenGL backend defers; flushing it issues the single GPU
2972        // sync. CPU/G2D converts already completed, so there is nothing to flush.
2973        #[cfg(any(
2974            target_os = "linux",
2975            target_os = "macos",
2976            target_os = "ios",
2977            target_os = "android",
2978            target_os = "windows"
2979        ))]
2980        #[cfg(feature = "opengl")]
2981        if let Some(opengl) = self.opengl.as_mut() {
2982            return opengl.flush();
2983        }
2984        Ok(())
2985    }
2986
2987    fn draw_decoded_masks(
2988        &mut self,
2989        dst: &mut TensorDyn,
2990        detect: &[DetectBox],
2991        segmentation: &[Segmentation],
2992        overlay: MaskOverlay<'_>,
2993    ) -> Result<()> {
2994        let _span = tracing::trace_span!(
2995            "image.draw_decoded_masks",
2996            n_detections = detect.len(),
2997            n_segmentations = segmentation.len(),
2998        )
2999        .entered();
3000        let start = Instant::now();
3001
3002        if let Some(bg) = overlay.background {
3003            if bg.aliases(dst) {
3004                return Err(Error::AliasedBuffers(
3005                    "background must not reference the same buffer as dst".to_string(),
3006                ));
3007            }
3008        }
3009
3010        // Un-letterbox detect boxes and segmentation bboxes for rendering when
3011        // a letterbox was applied to prepare the model input.
3012        let lb_boxes: Vec<DetectBox>;
3013        let lb_segs: Vec<Segmentation>;
3014        let (detect, segmentation) = if let Some(lb) = overlay.letterbox {
3015            lb_boxes = detect.iter().map(|&d| unletter_bbox(d, lb)).collect();
3016            // Keep segmentation bboxes in sync with the transformed detect boxes
3017            // when we have a 1:1 correspondence (instance segmentation).
3018            if segmentation.len() == lb_boxes.len() {
3019                // Only the four bounds change; the mask bytes are copied
3020                // wholesale because `TensorDyn` is not `Clone`. The memcpy is
3021                // what `Array3<u8>::clone()` already paid; `copy_segmentation`
3022                // pins the allocation to Mem so the ALLOCATOR cost matches too
3023                // (see there). Rendering from borrowed masks plus a separate
3024                // bounds list would avoid the copy entirely.
3025                lb_segs = segmentation
3026                    .iter()
3027                    .zip(lb_boxes.iter())
3028                    .map(|(s, d)| copy_segmentation(s, d.bbox.into()))
3029                    .collect::<Result<Vec<_>>>()?;
3030                (lb_boxes.as_slice(), lb_segs.as_slice())
3031            } else {
3032                // No 1:1 correspondence, so the segmentation bounds are left
3033                // as they are -- borrow rather than the copy `to_vec()` used
3034                // to make.
3035                (lb_boxes.as_slice(), segmentation)
3036            }
3037        } else {
3038            (detect, segmentation)
3039        };
3040        #[cfg(target_os = "linux")]
3041        let is_empty_frame = detect.is_empty() && segmentation.is_empty();
3042
3043        // ── Forced backend: no fallback chain ────────────────────────
3044        if let Some(forced) = self.forced_backend {
3045            return match forced {
3046                ForcedBackend::Cpu => {
3047                    if let Some(cpu) = self.cpu.as_mut() {
3048                        return cpu.draw_decoded_masks(dst, detect, segmentation, overlay);
3049                    }
3050                    Err(Error::ForcedBackendUnavailable("cpu".into()))
3051                }
3052                ForcedBackend::G2d => {
3053                    // G2D can only produce empty frames (clear / bg blit).
3054                    // For populated frames it has no rasterizer — fail loudly.
3055                    #[cfg(target_os = "linux")]
3056                    if let Some(g2d) = self.g2d.as_mut() {
3057                        return g2d.draw_decoded_masks(dst, detect, segmentation, overlay);
3058                    }
3059                    Err(Error::ForcedBackendUnavailable("g2d".into()))
3060                }
3061                ForcedBackend::OpenGl => {
3062                    // GL handles background natively via GPU blit, and now
3063                    // actively clears when there is no background.
3064                    #[cfg(any(
3065                        target_os = "linux",
3066                        target_os = "macos",
3067                        target_os = "ios",
3068                        target_os = "android",
3069                        target_os = "windows"
3070                    ))]
3071                    #[cfg(feature = "opengl")]
3072                    if let Some(opengl) = self.opengl.as_mut() {
3073                        return opengl.draw_decoded_masks(dst, detect, segmentation, overlay);
3074                    }
3075                    Err(Error::ForcedBackendUnavailable("opengl".into()))
3076                }
3077            };
3078        }
3079
3080        // ── Auto dispatch ──────────────────────────────────────────
3081        // Empty frames prefer G2D when available — a single g2d_clear or
3082        // g2d_blit is the cheapest HW path to produce the correct output
3083        // and avoids spinning up the GL pipeline every zero-detection
3084        // frame in a triple-buffered display loop.
3085        #[cfg(target_os = "linux")]
3086        if is_empty_frame {
3087            if let Some(g2d) = self.g2d.as_mut() {
3088                match g2d.draw_decoded_masks(dst, detect, segmentation, overlay) {
3089                    Ok(_) => {
3090                        log::trace!(
3091                            "draw_decoded_masks empty frame via g2d in {:?}",
3092                            start.elapsed()
3093                        );
3094                        return Ok(());
3095                    }
3096                    Err(e) => log::trace!("g2d empty-frame path unavailable: {e:?}"),
3097                }
3098            }
3099        }
3100
3101        // Populated frames (or G2D unavailable): GL first, CPU fallback.
3102        // Both backends now own their own base-layer handling (bg blit
3103        // or clear), so we hand the overlay through untouched.
3104        #[cfg(any(
3105            target_os = "linux",
3106            target_os = "macos",
3107            target_os = "ios",
3108            target_os = "android",
3109            target_os = "windows"
3110        ))]
3111        #[cfg(feature = "opengl")]
3112        if let Some(opengl) = self.opengl.as_mut() {
3113            log::trace!(
3114                "draw_decoded_masks started with opengl in {:?}",
3115                start.elapsed()
3116            );
3117            match opengl.draw_decoded_masks(dst, detect, segmentation, overlay) {
3118                Ok(_) => {
3119                    log::trace!("draw_decoded_masks with opengl in {:?}", start.elapsed());
3120                    return Ok(());
3121                }
3122                Err(e) => {
3123                    log::trace!("draw_decoded_masks didn't work with opengl: {e:?}")
3124                }
3125            }
3126        }
3127
3128        log::trace!(
3129            "draw_decoded_masks started with cpu in {:?}",
3130            start.elapsed()
3131        );
3132        if let Some(cpu) = self.cpu.as_mut() {
3133            match cpu.draw_decoded_masks(dst, detect, segmentation, overlay) {
3134                Ok(_) => {
3135                    log::trace!("draw_decoded_masks with cpu in {:?}", start.elapsed());
3136                    return Ok(());
3137                }
3138                Err(e) => {
3139                    log::trace!("draw_decoded_masks didn't work with cpu: {e:?}");
3140                    return Err(e);
3141                }
3142            }
3143        }
3144        Err(Error::NoConverter)
3145    }
3146
3147    fn draw_proto_masks(
3148        &mut self,
3149        dst: &mut TensorDyn,
3150        detect: &[DetectBox],
3151        proto_data: &ProtoData,
3152        overlay: MaskOverlay<'_>,
3153    ) -> Result<()> {
3154        let start = Instant::now();
3155
3156        if let Some(bg) = overlay.background {
3157            if bg.aliases(dst) {
3158                return Err(Error::AliasedBuffers(
3159                    "background must not reference the same buffer as dst".to_string(),
3160                ));
3161            }
3162        }
3163
3164        // Un-letterbox detect boxes for rendering when a letterbox was applied
3165        // to prepare the model input.  The original `detect` coords are still
3166        // passed to `materialize_segmentations` (which needs model-space coords
3167        // to correctly crop the proto tensor) alongside `overlay.letterbox` so
3168        // it can emit `Segmentation` structs in output-image space.
3169        let lb_boxes: Vec<DetectBox>;
3170        let render_detect = if let Some(lb) = overlay.letterbox {
3171            lb_boxes = detect.iter().map(|&d| unletter_bbox(d, lb)).collect();
3172            lb_boxes.as_slice()
3173        } else {
3174            detect
3175        };
3176        #[cfg(target_os = "linux")]
3177        let is_empty_frame = detect.is_empty();
3178
3179        // ── Forced backend: no fallback chain ────────────────────────
3180        if let Some(forced) = self.forced_backend {
3181            return match forced {
3182                ForcedBackend::Cpu => {
3183                    if let Some(cpu) = self.cpu.as_mut() {
3184                        return cpu.draw_proto_masks(dst, render_detect, proto_data, overlay);
3185                    }
3186                    Err(Error::ForcedBackendUnavailable("cpu".into()))
3187                }
3188                ForcedBackend::G2d => {
3189                    #[cfg(target_os = "linux")]
3190                    if let Some(g2d) = self.g2d.as_mut() {
3191                        return g2d.draw_proto_masks(dst, render_detect, proto_data, overlay);
3192                    }
3193                    Err(Error::ForcedBackendUnavailable("g2d".into()))
3194                }
3195                ForcedBackend::OpenGl => {
3196                    #[cfg(any(
3197                        target_os = "linux",
3198                        target_os = "macos",
3199                        target_os = "ios",
3200                        target_os = "android",
3201                        target_os = "windows"
3202                    ))]
3203                    #[cfg(feature = "opengl")]
3204                    if let Some(opengl) = self.opengl.as_mut() {
3205                        return opengl.draw_proto_masks(dst, render_detect, proto_data, overlay);
3206                    }
3207                    Err(Error::ForcedBackendUnavailable("opengl".into()))
3208                }
3209            };
3210        }
3211
3212        // ── Auto dispatch ──────────────────────────────────────────
3213        // Empty frames: prefer G2D — cheapest HW path (clear or bg blit).
3214        #[cfg(target_os = "linux")]
3215        if is_empty_frame {
3216            if let Some(g2d) = self.g2d.as_mut() {
3217                match g2d.draw_proto_masks(dst, render_detect, proto_data, overlay) {
3218                    Ok(_) => {
3219                        log::trace!(
3220                            "draw_proto_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        // Hybrid path: CPU materialize + GL overlay (benchmarked faster than
3231        // full-GPU draw_proto_masks on all tested platforms: 27× on imx8mp,
3232        // 4× on imx95, 2.5× on rpi5, 1.6× on x86).
3233        // GL owns its own bg-blit / glClear — we pass the overlay through.
3234        //
3235        // CPU materialize needs `&mut` for its MaskScratch buffers; GL also
3236        // needs `&mut`. The CPU borrow is scoped to its block so the
3237        // subsequent GL borrow is free to take over `self`.
3238        #[cfg(any(
3239            target_os = "linux",
3240            target_os = "macos",
3241            target_os = "ios",
3242            target_os = "android",
3243            target_os = "windows"
3244        ))]
3245        #[cfg(feature = "opengl")]
3246        if let (Some(_), Some(_)) = (self.cpu.as_ref(), self.opengl.as_ref()) {
3247            let segmentation = match self.cpu.as_mut() {
3248                Some(cpu) => {
3249                    log::trace!(
3250                        "draw_proto_masks started with hybrid (cpu+opengl) in {:?}",
3251                        start.elapsed()
3252                    );
3253                    cpu.materialize_segmentations(detect, proto_data, overlay.letterbox)?
3254                }
3255                None => unreachable!("cpu presence checked above"),
3256            };
3257            if let Some(opengl) = self.opengl.as_mut() {
3258                match opengl.draw_decoded_masks(dst, render_detect, &segmentation, overlay) {
3259                    Ok(_) => {
3260                        log::trace!(
3261                            "draw_proto_masks with hybrid (cpu+opengl) in {:?}",
3262                            start.elapsed()
3263                        );
3264                        return Ok(());
3265                    }
3266                    Err(e) => {
3267                        log::trace!(
3268                            "draw_proto_masks hybrid path failed, falling back to cpu: {e:?}"
3269                        );
3270                    }
3271                }
3272            }
3273        }
3274
3275        let Some(cpu) = self.cpu.as_mut() else {
3276            return Err(Error::Internal(
3277                "draw_proto_masks requires CPU backend for fallback path".into(),
3278            ));
3279        };
3280        log::trace!("draw_proto_masks started with cpu in {:?}", start.elapsed());
3281        cpu.draw_proto_masks(dst, render_detect, proto_data, overlay)
3282    }
3283
3284    fn set_class_colors(&mut self, colors: &[[u8; 4]]) -> Result<()> {
3285        let start = Instant::now();
3286
3287        // ── Forced backend: no fallback chain ────────────────────────
3288        if let Some(forced) = self.forced_backend {
3289            return match forced {
3290                ForcedBackend::Cpu => {
3291                    if let Some(cpu) = self.cpu.as_mut() {
3292                        return cpu.set_class_colors(colors);
3293                    }
3294                    Err(Error::ForcedBackendUnavailable("cpu".into()))
3295                }
3296                ForcedBackend::G2d => Err(Error::NotSupported(
3297                    "g2d does not support set_class_colors".into(),
3298                )),
3299                ForcedBackend::OpenGl => {
3300                    #[cfg(any(
3301                        target_os = "linux",
3302                        target_os = "macos",
3303                        target_os = "ios",
3304                        target_os = "android",
3305                        target_os = "windows"
3306                    ))]
3307                    #[cfg(feature = "opengl")]
3308                    if let Some(opengl) = self.opengl.as_mut() {
3309                        return opengl.set_class_colors(colors);
3310                    }
3311                    Err(Error::ForcedBackendUnavailable("opengl".into()))
3312                }
3313            };
3314        }
3315
3316        // skip G2D as it doesn't support rendering to image
3317
3318        // Every available backend, not the first that accepts. A palette is
3319        // state a later draw reads, and which backend that draw lands on is
3320        // not known here: GL declines a `view()` destination, an unsupported
3321        // format or an import failure, and the CPU backend takes over. A
3322        // backend that never received the colours would render
3323        // `DEFAULT_COLORS_U8` with nothing to show the caller why.
3324        let mut backends = 0usize;
3325        let mut first_err = None;
3326        #[cfg(any(
3327            target_os = "linux",
3328            target_os = "macos",
3329            target_os = "ios",
3330            target_os = "android",
3331            target_os = "windows"
3332        ))]
3333        #[cfg(feature = "opengl")]
3334        if let Some(opengl) = self.opengl.as_mut() {
3335            backends += 1;
3336            match opengl.set_class_colors(colors) {
3337                Ok(()) => log::trace!("colors set with opengl in {:?}", start.elapsed()),
3338                Err(e) => {
3339                    log::trace!("colors didn't set with opengl: {e:?}");
3340                    first_err.get_or_insert(e);
3341                }
3342            }
3343        }
3344        if let Some(cpu) = self.cpu.as_mut() {
3345            backends += 1;
3346            match cpu.set_class_colors(colors) {
3347                Ok(()) => log::trace!("colors set with cpu in {:?}", start.elapsed()),
3348                Err(e) => {
3349                    log::trace!("colors didn't set with cpu: {e:?}");
3350                    first_err.get_or_insert(e);
3351                }
3352            }
3353        }
3354        // The first error, so a GL failure is not hidden by the CPU backend
3355        // accepting afterwards; the colours the other backends took are kept
3356        // either way, since a partial palette beats a stale one.
3357        match first_err {
3358            Some(e) => Err(e),
3359            None if backends > 0 => Ok(()),
3360            None => Err(Error::NoConverter),
3361        }
3362    }
3363}
3364
3365// ---------------------------------------------------------------------------
3366// Image loading / saving helpers
3367// ---------------------------------------------------------------------------
3368
3369/// Test-only convenience helper that peeks the image header, allocates a
3370/// tensor sized to the image (honoring DMA pitch padding on Linux when
3371/// requested), and decodes via [`edgefirst_codec`]. Mirrors the semantics of
3372/// the removed public `load_image` API for test sites; production callers
3373/// should use the explicit peek → allocate → decode pattern directly.
3374#[cfg(all(test, feature = "codec"))]
3375pub(crate) fn load_image_test_helper(
3376    image: &[u8],
3377    format: Option<PixelFormat>,
3378    memory: Option<TensorMemory>,
3379) -> Result<TensorDyn> {
3380    use edgefirst_codec::{peek_info, ImageDecoder, ImageLoad};
3381
3382    // Peek the source header to get its NATIVE format and dimensions. The
3383    // codec now emits the source's native format (JPEG → Nv12/Grey, PNG →
3384    // Rgb/Rgba/Grey) and configures the destination tensor itself.
3385    let info = peek_info(image)?;
3386    let native_fmt = info.format;
3387    let w = info.width;
3388    let h = info.height;
3389
3390    let mut decoder = ImageDecoder::new();
3391
3392    // Decode into a native-format tensor. The decoder sets the tensor's
3393    // dims+format, so we allocate it sized to the native layout.
3394    #[cfg(target_os = "linux")]
3395    let native_src = {
3396        if let Some(aligned_pitch) = padded_dma_pitch_for(native_fmt, w, &memory) {
3397            let mut dma = Tensor::<u8>::image_with_stride(
3398                w,
3399                h,
3400                native_fmt,
3401                aligned_pitch,
3402                Some(TensorMemory::DmaBuf),
3403                edgefirst_tensor::CpuAccess::ReadWrite,
3404            )?;
3405            dma.load_image(&mut decoder, image)?;
3406            TensorDyn::from(dma)
3407        } else {
3408            let mut img = Tensor::<u8>::image(
3409                w,
3410                h,
3411                native_fmt,
3412                memory,
3413                edgefirst_tensor::CpuAccess::ReadWrite,
3414            )?;
3415            img.load_image(&mut decoder, image)?;
3416            TensorDyn::from(img)
3417        }
3418    };
3419    #[cfg(not(target_os = "linux"))]
3420    let native_src = {
3421        let mut img = Tensor::<u8>::image(
3422            w,
3423            h,
3424            native_fmt,
3425            memory,
3426            edgefirst_tensor::CpuAccess::ReadWrite,
3427        )?;
3428        img.load_image(&mut decoder, image)?;
3429        TensorDyn::from(img)
3430    };
3431
3432    // If the caller requested a different format, convert into it (same
3433    // dims) using a headless CPU-backed processor so the helper works
3434    // without GPU/G2D hardware.
3435    match format {
3436        Some(f) if f != native_fmt => {
3437            let mut dst = TensorDyn::image(
3438                w,
3439                h,
3440                f,
3441                DType::U8,
3442                memory,
3443                edgefirst_tensor::CpuAccess::ReadWrite,
3444            )?;
3445            // `ImageProcessorConfig` has platform-specific fields: on Linux it
3446            // carries extra GL/G2D options so `..Default::default()` is needed,
3447            // but on macOS `backend` is the only field, making the update
3448            // redundant (clippy::needless_update). Allow it for cross-platform
3449            // parity — the alternative (field reassign) trips
3450            // clippy::field_reassign_with_default on Linux instead.
3451            #[allow(clippy::needless_update)]
3452            let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
3453                backend: ComputeBackend::Cpu,
3454                ..Default::default()
3455            })?;
3456            proc.convert(
3457                &native_src,
3458                &mut dst,
3459                Rotation::None,
3460                Flip::None,
3461                Crop::default(),
3462            )?;
3463            Ok(dst)
3464        }
3465        _ => Ok(native_src),
3466    }
3467}
3468
3469/// Save a [`TensorDyn`] image as a JPEG file.
3470///
3471/// Only packed RGB and RGBA formats are supported.
3472pub fn save_jpeg(tensor: &TensorDyn, path: impl AsRef<std::path::Path>, quality: u8) -> Result<()> {
3473    let t = tensor.as_typed::<u8>().ok_or(Error::UnsupportedFormat(
3474        "save_jpeg requires u8 tensor".to_string(),
3475    ))?;
3476    let fmt = t.format().ok_or(Error::NotAnImage)?;
3477    if fmt.layout() != PixelLayout::Packed {
3478        return Err(Error::NotImplemented(
3479            "Saving planar images is not supported".to_string(),
3480        ));
3481    }
3482
3483    let colour = match fmt {
3484        PixelFormat::Rgb => jpeg_encoder::ColorType::Rgb,
3485        PixelFormat::Rgba => jpeg_encoder::ColorType::Rgba,
3486        _ => {
3487            return Err(Error::NotImplemented(
3488                "Unsupported image format for saving".to_string(),
3489            ));
3490        }
3491    };
3492
3493    let w = t.width().ok_or(Error::NotAnImage)?;
3494    let h = t.height().ok_or(Error::NotAnImage)?;
3495    let encoder = jpeg_encoder::Encoder::new_file(path, quality)?;
3496    let tensor_map = t.map_read()?;
3497
3498    encoder.encode(&tensor_map, w as u16, h as u16, colour)?;
3499
3500    Ok(())
3501}
3502
3503pub(crate) struct FunctionTimer<T: Display> {
3504    name: T,
3505    start: std::time::Instant,
3506}
3507
3508impl<T: Display> FunctionTimer<T> {
3509    pub fn new(name: T) -> Self {
3510        Self {
3511            name,
3512            start: std::time::Instant::now(),
3513        }
3514    }
3515}
3516
3517impl<T: Display> Drop for FunctionTimer<T> {
3518    fn drop(&mut self) {
3519        log::trace!("{} elapsed: {:?}", self.name, self.start.elapsed())
3520    }
3521}
3522
3523const DEFAULT_COLORS: [[f32; 4]; 20] = [
3524    [0., 1., 0., 0.7],
3525    [1., 0.5568628, 0., 0.7],
3526    [0.25882353, 0.15294118, 0.13333333, 0.7],
3527    [0.8, 0.7647059, 0.78039216, 0.7],
3528    [0.3137255, 0.3137255, 0.3137255, 0.7],
3529    [0.1411765, 0.3098039, 0.1215686, 0.7],
3530    [1., 0.95686275, 0.5137255, 0.7],
3531    [0.3529412, 0.32156863, 0., 0.7],
3532    [0.4235294, 0.6235294, 0.6509804, 0.7],
3533    [0.5098039, 0.5098039, 0.7294118, 0.7],
3534    [0.00784314, 0.18823529, 0.29411765, 0.7],
3535    [0.0, 0.2706, 1.0, 0.7],
3536    [0.0, 0.0, 0.0, 0.7],
3537    [0.0, 0.5, 0.0, 0.7],
3538    [1.0, 0.0, 0.0, 0.7],
3539    [0.0, 0.0, 1.0, 0.7],
3540    [1.0, 0.5, 0.5, 0.7],
3541    [0.1333, 0.5451, 0.1333, 0.7],
3542    [0.1176, 0.4118, 0.8235, 0.7],
3543    [1., 1., 1., 0.7],
3544];
3545
3546const fn denorm<const M: usize, const N: usize>(a: [[f32; M]; N]) -> [[u8; M]; N] {
3547    let mut result = [[0; M]; N];
3548    let mut i = 0;
3549    while i < N {
3550        let mut j = 0;
3551        while j < M {
3552            result[i][j] = (a[i][j] * 255.0).round() as u8;
3553            j += 1;
3554        }
3555        i += 1;
3556    }
3557    result
3558}
3559
3560const DEFAULT_COLORS_U8: [[u8; 4]; 20] = denorm(DEFAULT_COLORS);
3561
3562#[cfg(test)]
3563#[cfg_attr(coverage_nightly, coverage(off))]
3564mod alignment_tests {
3565    use super::*;
3566
3567    #[test]
3568    fn align_width_rgba8_common_widths() {
3569        // RGBA8 (bpp=4, lcm(64,4)=64, so width must round to multiple of 16 px).
3570        assert_eq!(align_width_for_gpu_pitch(640, 4), 640); // 2560 byte pitch — already aligned
3571        assert_eq!(align_width_for_gpu_pitch(1280, 4), 1280); // 5120
3572        assert_eq!(align_width_for_gpu_pitch(1920, 4), 1920); // 7680
3573        assert_eq!(align_width_for_gpu_pitch(3840, 4), 3840); // 15360
3574                                                              // crowd.png case from the imx95 investigation:
3575        assert_eq!(align_width_for_gpu_pitch(3004, 4), 3008); // 12016 → 12032
3576        assert_eq!(align_width_for_gpu_pitch(3000, 4), 3008); // 12000 → 12032
3577        assert_eq!(align_width_for_gpu_pitch(17, 4), 32); // 68 → 128
3578        assert_eq!(align_width_for_gpu_pitch(1, 4), 16); // 4 → 64
3579    }
3580
3581    #[test]
3582    fn align_width_rgb888_packed() {
3583        // RGB888 (bpp=3, lcm(64,3)=192, so width must round to multiple of 64 px).
3584        assert_eq!(align_width_for_gpu_pitch(64, 3), 64); // 192 byte pitch
3585        assert_eq!(align_width_for_gpu_pitch(640, 3), 640); // 1920
3586        assert_eq!(align_width_for_gpu_pitch(1, 3), 64); // 3 → 192
3587        assert_eq!(align_width_for_gpu_pitch(65, 3), 128); // 195 → 384
3588                                                           // Verify the rounded width × bpp is a clean multiple of the LCM.
3589        for w in [3004usize, 1281, 100, 17] {
3590            let padded = align_width_for_gpu_pitch(w, 3);
3591            assert!(padded >= w);
3592            assert_eq!((padded * 3) % 64, 0);
3593            assert_eq!((padded * 3) % 3, 0);
3594        }
3595    }
3596
3597    #[test]
3598    fn align_width_grey_u8() {
3599        // Grey (bpp=1, lcm(64,1)=64, so width must round to multiple of 64 px).
3600        assert_eq!(align_width_for_gpu_pitch(64, 1), 64);
3601        assert_eq!(align_width_for_gpu_pitch(640, 1), 640);
3602        assert_eq!(align_width_for_gpu_pitch(1, 1), 64);
3603        assert_eq!(align_width_for_gpu_pitch(65, 1), 128);
3604    }
3605
3606    #[test]
3607    fn align_width_zero_inputs() {
3608        assert_eq!(align_width_for_gpu_pitch(0, 4), 0);
3609        assert_eq!(align_width_for_gpu_pitch(640, 0), 640);
3610    }
3611
3612    #[test]
3613    fn align_width_never_returns_smaller_than_input() {
3614        // Spot-check the "returned width >= input width" contract across a
3615        // range of values that would previously have hit `width * bpp`
3616        // overflow paths.
3617        for &bpp in &[1usize, 2, 3, 4, 8] {
3618            for &w in &[
3619                1usize,
3620                17,
3621                64,
3622                65,
3623                100,
3624                1280,
3625                1281,
3626                1920,
3627                3004,
3628                3072,
3629                3840,
3630                usize::MAX / 8,
3631                usize::MAX / 4,
3632                usize::MAX / 2,
3633                usize::MAX - 1,
3634                usize::MAX,
3635            ] {
3636                let aligned = align_width_for_gpu_pitch(w, bpp);
3637                assert!(
3638                    aligned >= w,
3639                    "align_width_for_gpu_pitch({w}, {bpp}) = {aligned} < {w}"
3640                );
3641            }
3642        }
3643    }
3644
3645    #[test]
3646    fn align_width_overflow_returns_unaligned_not_smaller() {
3647        // For width values close to usize::MAX, padding up would wrap. The
3648        // function must return the original width rather than wrapping or
3649        // panicking. A pre-aligned width round-trips unchanged even at the
3650        // extreme.
3651        let aligned_extreme = usize::MAX - 15; // 16-pixel boundary for RGBA8
3652        assert_eq!(
3653            align_width_for_gpu_pitch(aligned_extreme, 4),
3654            aligned_extreme
3655        );
3656        // A misaligned extreme value cannot be rounded up — the function
3657        // returns the original.
3658        let misaligned_extreme = usize::MAX - 1;
3659        let result = align_width_for_gpu_pitch(misaligned_extreme, 4);
3660        assert!(
3661            result == misaligned_extreme || result >= misaligned_extreme,
3662            "extreme misaligned width must not be rounded down to {result}"
3663        );
3664    }
3665
3666    #[test]
3667    fn checked_lcm_basic_and_overflow() {
3668        assert_eq!(checked_num_integer_lcm(64, 4), Some(64));
3669        assert_eq!(checked_num_integer_lcm(64, 3), Some(192));
3670        assert_eq!(checked_num_integer_lcm(64, 1), Some(64));
3671        assert_eq!(checked_num_integer_lcm(0, 4), Some(0));
3672        assert_eq!(checked_num_integer_lcm(64, 0), Some(0));
3673        // Coprime values whose product exceeds usize::MAX must return None.
3674        assert_eq!(
3675            checked_num_integer_lcm(usize::MAX, usize::MAX - 1),
3676            None,
3677            "coprime extreme values must overflow detect, not panic"
3678        );
3679    }
3680
3681    #[test]
3682    fn primary_plane_bpp_known_formats() {
3683        // Packed formats use channels × elem_size.
3684        assert_eq!(primary_plane_bpp(PixelFormat::Rgba, 1), Some(4));
3685        assert_eq!(primary_plane_bpp(PixelFormat::Bgra, 1), Some(4));
3686        assert_eq!(primary_plane_bpp(PixelFormat::Rgb, 1), Some(3));
3687        assert_eq!(primary_plane_bpp(PixelFormat::Grey, 1), Some(1));
3688        // Semi-planar (NV12) reports the luma plane's bpp.
3689        assert_eq!(primary_plane_bpp(PixelFormat::Nv12, 1), Some(1));
3690    }
3691}
3692
3693#[cfg(test)]
3694#[cfg_attr(coverage_nightly, coverage(off))]
3695#[allow(deprecated)]
3696mod image_tests {
3697    use super::*;
3698    use crate::{CPUProcessor, Rotation};
3699    #[cfg(target_os = "linux")]
3700    use edgefirst_tensor::is_dma_available;
3701    use edgefirst_tensor::{TensorMapTrait, TensorMemory, TensorTrait};
3702    use image::buffer::ConvertBuffer;
3703
3704    /// Test helper: call `ImageProcessorTrait::convert()` on two `TensorDyn`s
3705    /// by going through the `TensorDyn` API.
3706    ///
3707    /// Returns the `(src_image, dst_image)` reconstructed from the TensorDyn
3708    /// round-trip so the caller can feed them to `compare_images` etc.
3709    fn convert_img(
3710        proc: &mut dyn ImageProcessorTrait,
3711        src: TensorDyn,
3712        dst: TensorDyn,
3713        rotation: Rotation,
3714        flip: Flip,
3715        crop: Crop,
3716    ) -> (Result<()>, TensorDyn, TensorDyn) {
3717        let src_fourcc = src.format().unwrap();
3718        let dst_fourcc = dst.format().unwrap();
3719        let src_dyn = src;
3720        let mut dst_dyn = dst;
3721        let result = proc.convert(&src_dyn, &mut dst_dyn, rotation, flip, crop);
3722        let src_back = {
3723            let mut __t = src_dyn.into_u8().unwrap();
3724            __t.set_format(src_fourcc).unwrap();
3725            TensorDyn::from(__t)
3726        };
3727        let dst_back = {
3728            let mut __t = dst_dyn.into_u8().unwrap();
3729            __t.set_format(dst_fourcc).unwrap();
3730            TensorDyn::from(__t)
3731        };
3732        (result, src_back, dst_back)
3733    }
3734
3735    #[ctor::ctor(unsafe)]
3736    fn init() {
3737        env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
3738    }
3739
3740    macro_rules! function {
3741        () => {{
3742            fn f() {}
3743            fn type_name_of<T>(_: T) -> &'static str {
3744                std::any::type_name::<T>()
3745            }
3746            let name = type_name_of(f);
3747
3748            // Find and cut the rest of the path
3749            match &name[..name.len() - 3].rfind(':') {
3750                Some(pos) => &name[pos + 1..name.len() - 3],
3751                None => &name[..name.len() - 3],
3752            }
3753        }};
3754    }
3755
3756    /// Master oracle for the view/batch **destination** batch engine: render `N`
3757    /// tiles into row-bands of ONE tall destination and assert each band equals
3758    /// the same source converted standalone — proving correct band placement and
3759    /// that a later tile's letterbox clear / draw never wipes a sibling band. On
3760    /// the Linux GL backend the `N` `convert_deferred` calls share ONE parent
3761    /// EGLImage import (each tile is a `glViewport`/`glScissor` ROI) and sync
3762    /// once at `flush()`; other backends fall back to an eager per-band convert
3763    /// (CPU writes via offset + parent stride). Either way the oracle must hold.
3764    ///
3765    /// Identical source/tile size makes the convert an exact copy, so the
3766    /// assertion is backend-agnostic (no GL-vs-CPU resampling drift). Distinct
3767    /// solid colors per tile make any sibling wipe a hard failure.
3768    #[test]
3769    fn batch_view_dst_tiles_match_standalone() {
3770        let mut proc = match ImageProcessor::new() {
3771            Ok(p) => p,
3772            Err(e) => {
3773                crate::test_support::report_skip(&format!(
3774                    "{} — ImageProcessor init failed ({e:?})",
3775                    function!()
3776                ));
3777                return;
3778            }
3779        };
3780        let n = 3usize;
3781        let (w, h) = (32usize, 24usize);
3782        let colors: [[u8; 4]; 3] = [[210, 40, 40, 255], [40, 210, 40, 255], [40, 40, 210, 255]];
3783        let make_src = |c: [u8; 4]| -> TensorDyn {
3784            let bytes: Vec<u8> = c.iter().copied().cycle().take(w * h * 4).collect();
3785            load_bytes_to_tensor(w, h, PixelFormat::Rgba, Some(TensorMemory::Mem), &bytes).unwrap()
3786        };
3787        // Tall destination: N stacked row-bands. DMA so the Linux GL band path
3788        // runs (one parent import + per-tile glViewport); skip if unavailable.
3789        let parent = match TensorDyn::image(
3790            w,
3791            n * h,
3792            PixelFormat::Rgba,
3793            DType::U8,
3794            Some(TensorMemory::DmaBuf),
3795            edgefirst_tensor::CpuAccess::ReadWrite,
3796        ) {
3797            Ok(d) => d,
3798            Err(e) => {
3799                crate::test_support::report_skip(&format!(
3800                    "{} — tall DMA destination alloc failed ({e:?})",
3801                    function!()
3802                ));
3803                return;
3804            }
3805        };
3806
3807        // Deferred batch: one parent import, glViewport/scissor per band, one sync.
3808        for (i, &c) in colors.iter().enumerate().take(n) {
3809            let mut tile = parent.view(Region::new(0, i * h, w, h)).unwrap();
3810            proc.convert_deferred(
3811                &make_src(c),
3812                &mut tile,
3813                Rotation::None,
3814                Flip::None,
3815                Crop::no_crop(),
3816            )
3817            .unwrap_or_else(|e| panic!("convert_deferred tile {i}: {e:?}"));
3818        }
3819        proc.flush().unwrap();
3820
3821        for (i, &c) in colors.iter().enumerate().take(n) {
3822            // Standalone full-buffer convert of the same source = the oracle.
3823            let mut solo = TensorDyn::image(
3824                w,
3825                h,
3826                PixelFormat::Rgba,
3827                DType::U8,
3828                Some(TensorMemory::DmaBuf),
3829                edgefirst_tensor::CpuAccess::ReadWrite,
3830            )
3831            .unwrap();
3832            proc.convert(
3833                &make_src(c),
3834                &mut solo,
3835                Rotation::None,
3836                Flip::None,
3837                Crop::no_crop(),
3838            )
3839            .unwrap();
3840
3841            let band = parent.view(Region::new(0, i * h, w, h)).unwrap();
3842            let band_bytes = band
3843                .as_typed::<u8>()
3844                .unwrap()
3845                .map()
3846                .unwrap()
3847                .as_slice()
3848                .to_vec();
3849            let solo_bytes = solo
3850                .as_typed::<u8>()
3851                .unwrap()
3852                .map()
3853                .unwrap()
3854                .as_slice()
3855                .to_vec();
3856            assert_eq!(
3857                band_bytes, solo_bytes,
3858                "tile {i}: band differs from standalone convert (placement or sibling wipe)"
3859            );
3860            assert!(
3861                band_bytes.as_chunks::<4>().0.iter().all(|&p| p == c),
3862                "tile {i}: band is not the expected solid color {c:?} (sibling wipe?)"
3863            );
3864        }
3865    }
3866
3867    /// Build a `w`×`h` RGBA frame whose pixels encode their position, so a crop
3868    /// at any origin has distinct content (catches crop-origin / band-placement
3869    /// bugs a solid color cannot).
3870    #[cfg(test)]
3871    fn gradient_frame(w: usize, h: usize) -> TensorDyn {
3872        let mut bytes = vec![0u8; w * h * 4];
3873        for y in 0..h {
3874            for x in 0..w {
3875                let i = (y * w + x) * 4;
3876                bytes[i] = x as u8;
3877                bytes[i + 1] = y as u8;
3878                bytes[i + 2] = (x ^ y) as u8;
3879                bytes[i + 3] = 255;
3880            }
3881        }
3882        load_bytes_to_tensor(w, h, PixelFormat::Rgba, Some(TensorMemory::Mem), &bytes).unwrap()
3883    }
3884
3885    /// `tile_into` band == standalone crop-convert of the same source region,
3886    /// on the CPU backend (runs on CI without a GPU). Distinct gradient content
3887    /// makes a wrong crop origin or a sibling-band wipe a hard failure.
3888    #[test]
3889    fn tile_into_cpu_distinct_content_parity() {
3890        let mut proc = match ImageProcessor::with_config(ImageProcessorConfig {
3891            backend: ComputeBackend::Cpu,
3892            ..Default::default()
3893        }) {
3894            Ok(p) => p,
3895            Err(e) => {
3896                crate::test_support::report_skip(&format!(
3897                    "{} — CPU init failed ({e:?})",
3898                    function!()
3899                ));
3900                return;
3901            }
3902        };
3903        let (fw, fh) = (96usize, 64usize);
3904        let src = gradient_frame(fw, fh);
3905        let cfg = TilingConfig::new(32, 32).with_overlap(0.0); // exact 3×2 tiling
3906        let n = tile_grid(fh, fw, 32, 32, 0.0).len();
3907        assert_eq!(n, 6);
3908
3909        let mut parent = proc
3910            .alloc_tile_batch(
3911                n,
3912                &cfg,
3913                PixelFormat::Rgba,
3914                DType::U8,
3915                Some(TensorMemory::Mem),
3916                edgefirst_tensor::CpuAccess::ReadWrite,
3917            )
3918            .unwrap();
3919        let placements = proc.tile_into(&src, &mut parent, &cfg).unwrap();
3920        assert_eq!(placements.len(), n);
3921
3922        for p in &placements {
3923            let source = Region::new(
3924                p.origin.0 as usize,
3925                p.origin.1 as usize,
3926                p.crop_size.0 as usize,
3927                p.crop_size.1 as usize,
3928            );
3929            let mut solo = TensorDyn::image(
3930                32,
3931                32,
3932                PixelFormat::Rgba,
3933                DType::U8,
3934                Some(TensorMemory::Mem),
3935                edgefirst_tensor::CpuAccess::ReadWrite,
3936            )
3937            .unwrap();
3938            proc.convert(
3939                &src,
3940                &mut solo,
3941                Rotation::None,
3942                Flip::None,
3943                Crop::default()
3944                    .with_source(Some(source))
3945                    .with_fit(Fit::Stretch),
3946            )
3947            .unwrap();
3948            let band = parent.view(Region::new(0, p.index * 32, 32, 32)).unwrap();
3949            let band_bytes = band
3950                .as_typed::<u8>()
3951                .unwrap()
3952                .map()
3953                .unwrap()
3954                .as_slice()
3955                .to_vec();
3956            let solo_bytes = solo
3957                .as_typed::<u8>()
3958                .unwrap()
3959                .map()
3960                .unwrap()
3961                .as_slice()
3962                .to_vec();
3963            assert_eq!(
3964                band_bytes, solo_bytes,
3965                "tile {} band differs from standalone crop-convert",
3966                p.index
3967            );
3968        }
3969    }
3970
3971    /// Streaming `tile_one` into a single slot == the corresponding batched
3972    /// `tile_into` band (proves the two paths agree).
3973    #[test]
3974    fn tile_one_matches_tile_into_band() {
3975        let mut proc = match ImageProcessor::with_config(ImageProcessorConfig {
3976            backend: ComputeBackend::Cpu,
3977            ..Default::default()
3978        }) {
3979            Ok(p) => p,
3980            Err(e) => {
3981                crate::test_support::report_skip(&format!(
3982                    "{} — CPU init failed ({e:?})",
3983                    function!()
3984                ));
3985                return;
3986            }
3987        };
3988        let (fw, fh) = (96usize, 64usize);
3989        let src = gradient_frame(fw, fh);
3990        let cfg = TilingConfig::new(32, 32).with_overlap(0.0);
3991        let n = tile_grid(fh, fw, 32, 32, 0.0).len();
3992
3993        let mut parent = proc
3994            .alloc_tile_batch(
3995                n,
3996                &cfg,
3997                PixelFormat::Rgba,
3998                DType::U8,
3999                Some(TensorMemory::Mem),
4000                edgefirst_tensor::CpuAccess::ReadWrite,
4001            )
4002            .unwrap();
4003        proc.tile_into(&src, &mut parent, &cfg).unwrap();
4004
4005        let plan = proc.plan_tiles(fw, fh, &cfg).unwrap();
4006        for p in &plan {
4007            let mut slot = TensorDyn::image(
4008                32,
4009                32,
4010                PixelFormat::Rgba,
4011                DType::U8,
4012                Some(TensorMemory::Mem),
4013                edgefirst_tensor::CpuAccess::ReadWrite,
4014            )
4015            .unwrap();
4016            proc.tile_one(&src, &mut slot, p, &cfg).unwrap();
4017            proc.flush().unwrap();
4018            let band = parent.view(Region::new(0, p.index * 32, 32, 32)).unwrap();
4019            let slot_bytes = slot
4020                .as_typed::<u8>()
4021                .unwrap()
4022                .map()
4023                .unwrap()
4024                .as_slice()
4025                .to_vec();
4026            let band_bytes = band
4027                .as_typed::<u8>()
4028                .unwrap()
4029                .map()
4030                .unwrap()
4031                .as_slice()
4032                .to_vec();
4033            assert_eq!(
4034                slot_bytes, band_bytes,
4035                "tile {} stream != batch band",
4036                p.index
4037            );
4038        }
4039    }
4040
4041    /// `plan_tiles` returns correct per-tile metadata for a 4K frame (pure, no
4042    /// GPU render).
4043    /// `tile_into` through the auto backend with a DMA parent — exercises the
4044    /// GL single-import band path on a GPU machine (skips when DMA/GL is
4045    /// unavailable). The source is sampled via `Crop::with_source`, never
4046    /// `src.view()`, so all tiles share one source import.
4047    #[test]
4048    fn tile_into_auto_dma_parity() {
4049        let mut proc = match ImageProcessor::new() {
4050            Ok(p) => p,
4051            Err(e) => {
4052                crate::test_support::report_skip(&format!(
4053                    "{} — ImageProcessor init failed ({e:?})",
4054                    function!()
4055                ));
4056                return;
4057            }
4058        };
4059        let (fw, fh) = (96usize, 64usize);
4060        let src = gradient_frame(fw, fh);
4061        let cfg = TilingConfig::new(32, 32).with_overlap(0.0);
4062        let n = tile_grid(fh, fw, 32, 32, 0.0).len();
4063
4064        let mut parent = match proc.alloc_tile_batch(
4065            n,
4066            &cfg,
4067            PixelFormat::Rgba,
4068            DType::U8,
4069            Some(TensorMemory::DmaBuf),
4070            edgefirst_tensor::CpuAccess::ReadWrite,
4071        ) {
4072            Ok(p) => p,
4073            Err(e) => {
4074                crate::test_support::report_skip(&format!(
4075                    "{} — tall DMA parent alloc failed ({e:?})",
4076                    function!()
4077                ));
4078                return;
4079            }
4080        };
4081        let placements = proc.tile_into(&src, &mut parent, &cfg).unwrap();
4082
4083        for p in &placements {
4084            let source = Region::new(
4085                p.origin.0 as usize,
4086                p.origin.1 as usize,
4087                p.crop_size.0 as usize,
4088                p.crop_size.1 as usize,
4089            );
4090            let mut solo = TensorDyn::image(
4091                32,
4092                32,
4093                PixelFormat::Rgba,
4094                DType::U8,
4095                Some(TensorMemory::DmaBuf),
4096                edgefirst_tensor::CpuAccess::ReadWrite,
4097            )
4098            .unwrap();
4099            proc.convert(
4100                &src,
4101                &mut solo,
4102                Rotation::None,
4103                Flip::None,
4104                Crop::default()
4105                    .with_source(Some(source))
4106                    .with_fit(Fit::Stretch),
4107            )
4108            .unwrap();
4109            let band = parent.view(Region::new(0, p.index * 32, 32, 32)).unwrap();
4110            // Structural-similarity tolerance (the house GPU-parity bar) rather
4111            // than exact bytes: rendering a tile into a viewport band at a
4112            // non-zero offset vs. a standalone origin render diverges by sampling
4113            // rounding on virtualized GPUs (paravirtual Metal/ANGLE on macOS CI),
4114            // the same tolerance class as the other cross-backend parity tests.
4115            compare_images(
4116                &band,
4117                &solo,
4118                0.98,
4119                &format!("{}_tile{}", function!(), p.index),
4120            );
4121        }
4122    }
4123
4124    /// `tile_into` rejects a destination too small to hold all tile bands.
4125    #[test]
4126    fn tile_into_undersized_dst_errors() {
4127        let mut proc = match ImageProcessor::with_config(ImageProcessorConfig {
4128            backend: ComputeBackend::Cpu,
4129            ..Default::default()
4130        }) {
4131            Ok(p) => p,
4132            Err(e) => {
4133                crate::test_support::report_skip(&format!(
4134                    "{} — CPU init failed ({e:?})",
4135                    function!()
4136                ));
4137                return;
4138            }
4139        };
4140        let (fw, fh) = (96usize, 64usize);
4141        let src = gradient_frame(fw, fh);
4142        let cfg = TilingConfig::new(32, 32).with_overlap(0.0); // 6 tiles
4143                                                               // Allocate a parent for only 2 bands, far short of 6.
4144        let mut small = TensorDyn::image(
4145            32,
4146            2 * 32,
4147            PixelFormat::Rgba,
4148            DType::U8,
4149            Some(TensorMemory::Mem),
4150            edgefirst_tensor::CpuAccess::ReadWrite,
4151        )
4152        .unwrap();
4153        let r = proc.tile_into(&src, &mut small, &cfg);
4154        assert!(
4155            matches!(r, Err(Error::InvalidShape(_))),
4156            "expected InvalidShape, got {r:?}"
4157        );
4158    }
4159
4160    /// `alloc_tile_batch` / `plan_tiles` reject an invalid config (zero tile).
4161    #[test]
4162    fn tiling_alloc_rejects_invalid_config() {
4163        let proc = match ImageProcessor::with_config(ImageProcessorConfig {
4164            backend: ComputeBackend::Cpu,
4165            ..Default::default()
4166        }) {
4167            Ok(p) => p,
4168            Err(e) => {
4169                crate::test_support::report_skip(&format!(
4170                    "{} — CPU init failed ({e:?})",
4171                    function!()
4172                ));
4173                return;
4174            }
4175        };
4176        let bad = TilingConfig::new(0, 640);
4177        assert!(proc.plan_tiles(1920, 1080, &bad).is_err());
4178        assert!(proc
4179            .alloc_tile_batch(
4180                4,
4181                &bad,
4182                PixelFormat::Rgba,
4183                DType::U8,
4184                Some(TensorMemory::Mem),
4185                edgefirst_tensor::CpuAccess::ReadWrite,
4186            )
4187            .is_err());
4188    }
4189
4190    #[test]
4191    fn plan_tiles_metadata_4k() {
4192        let proc = match ImageProcessor::with_config(ImageProcessorConfig {
4193            backend: ComputeBackend::Cpu,
4194            ..Default::default()
4195        }) {
4196            Ok(p) => p,
4197            Err(e) => {
4198                crate::test_support::report_skip(&format!(
4199                    "{} — CPU init failed ({e:?})",
4200                    function!()
4201                ));
4202                return;
4203            }
4204        };
4205        let cfg = TilingConfig::new(640, 640).with_overlap(0.2);
4206        let plan = proc.plan_tiles(3840, 2160, &cfg).unwrap();
4207        assert_eq!(plan.len(), 32);
4208        assert!(plan.iter().all(|p| p.count == 32));
4209        assert!(plan.iter().all(|p| p.crop_size == (640.0, 640.0)));
4210        assert!(plan.iter().all(|p| p.letterbox.is_none())); // stretch fit
4211        assert!(plan.iter().all(|p| p.frame_dims == (3840.0, 2160.0)));
4212        assert_eq!(plan[0].origin, (0.0, 0.0));
4213    }
4214
4215    #[test]
4216    fn test_invalid_crop() {
4217        let src = TensorDyn::image(
4218            100,
4219            100,
4220            PixelFormat::Rgb,
4221            DType::U8,
4222            None,
4223            edgefirst_tensor::CpuAccess::ReadWrite,
4224        )
4225        .unwrap();
4226        let dst = TensorDyn::image(
4227            100,
4228            100,
4229            PixelFormat::Rgb,
4230            DType::U8,
4231            None,
4232            edgefirst_tensor::CpuAccess::ReadWrite,
4233        )
4234        .unwrap();
4235
4236        // A source crop exceeding the source bounds is rejected.
4237        let crop = Crop::new().with_source(Some(Region::new(50, 50, 60, 60)));
4238        assert!(matches!(
4239            crop.check_crop_dyn(&src, &dst),
4240            Err(Error::CropInvalid(_))
4241        ));
4242
4243        // A source crop within bounds is valid.
4244        let crop = Crop::new().with_source(Some(Region::new(0, 0, 10, 10)));
4245        assert!(crop.check_crop_dyn(&src, &dst).is_ok());
4246
4247        // Letterbox is always valid — placement is computed within the dst.
4248        assert!(Crop::letterbox([0, 0, 0, 255])
4249            .check_crop_dyn(&src, &dst)
4250            .is_ok());
4251    }
4252
4253    #[test]
4254    fn test_invalid_tensor_format() -> Result<(), Error> {
4255        // 4D tensor cannot be set to a 3-channel pixel format
4256        let mut tensor = Tensor::<u8>::new(&[720, 1280, 4, 1], None, None)?;
4257        let result = tensor.set_format(PixelFormat::Rgb);
4258        assert!(result.is_err(), "4D tensor should reject set_format");
4259
4260        // Tensor with wrong channel count for the format
4261        let mut tensor = Tensor::<u8>::new(&[720, 1280, 4], None, None)?;
4262        let result = tensor.set_format(PixelFormat::Rgb);
4263        assert!(result.is_err(), "4-channel tensor should reject RGB format");
4264
4265        Ok(())
4266    }
4267
4268    #[test]
4269    fn test_invalid_image_file() -> Result<(), Error> {
4270        let result = crate::load_image_test_helper(&[123; 5000], None, None);
4271        assert!(
4272            matches!(result, Err(Error::Codec(_))),
4273            "unrecognised bytes should surface as Error::Codec, got {result:?}"
4274        );
4275        Ok(())
4276    }
4277
4278    #[test]
4279    fn test_invalid_jpeg_format() -> Result<(), Error> {
4280        let result = crate::load_image_test_helper(&[123; 5000], Some(PixelFormat::Yuyv), None);
4281        // YUYV is not a valid decode target; peek_info fails before the magic-
4282        // bytes check, so the precise variant depends on which error fires first.
4283        assert!(
4284            matches!(result, Err(Error::Codec(_))),
4285            "Yuyv target with garbage bytes should surface as Error::Codec, got {result:?}"
4286        );
4287        Ok(())
4288    }
4289
4290    #[test]
4291    fn test_load_resize_save() {
4292        let file = edgefirst_bench::testdata::read("zidane.jpg");
4293        let img = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4294        assert_eq!(img.width(), Some(1280));
4295        assert_eq!(img.height(), Some(720));
4296
4297        let dst = TensorDyn::image(
4298            640,
4299            360,
4300            PixelFormat::Rgba,
4301            DType::U8,
4302            None,
4303            edgefirst_tensor::CpuAccess::ReadWrite,
4304        )
4305        .unwrap();
4306        let mut converter = CPUProcessor::new();
4307        let (result, _img, dst) = convert_img(
4308            &mut converter,
4309            img,
4310            dst,
4311            Rotation::None,
4312            Flip::None,
4313            Crop::no_crop(),
4314        );
4315        result.unwrap();
4316        assert_eq!(dst.width(), Some(640));
4317        assert_eq!(dst.height(), Some(360));
4318
4319        crate::save_jpeg(&dst, "zidane_resized.jpg", 80).unwrap();
4320
4321        let file = std::fs::read("zidane_resized.jpg").unwrap();
4322        // With `format: None` the helper returns the source's native format.
4323        // The codec now decodes colour JPEGs to NV12 (was RGB previously).
4324        let img = crate::load_image_test_helper(&file, None, None).unwrap();
4325        assert_eq!(img.width(), Some(640));
4326        assert_eq!(img.height(), Some(360));
4327        assert_eq!(img.format().unwrap(), PixelFormat::Nv12);
4328    }
4329
4330    #[test]
4331    fn test_from_tensor_planar() -> Result<(), Error> {
4332        let mut tensor = Tensor::new(&[3, 720, 1280], None, None)?;
4333        tensor
4334            .map()?
4335            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.8bps"));
4336        let planar = {
4337            tensor
4338                .set_format(PixelFormat::PlanarRgb)
4339                .map_err(|e| crate::Error::Internal(e.to_string()))?;
4340            TensorDyn::from(tensor)
4341        };
4342
4343        let rbga = load_bytes_to_tensor(
4344            1280,
4345            720,
4346            PixelFormat::Rgba,
4347            None,
4348            &edgefirst_bench::testdata::read("camera720p.rgba"),
4349        )?;
4350        compare_images_convert_to_rgb(&planar, &rbga, 0.98, function!());
4351
4352        Ok(())
4353    }
4354
4355    #[test]
4356    fn test_from_tensor_invalid_format() {
4357        // PixelFormat::from_fourcc_str returns None for unknown FourCC codes.
4358        // Since there's no "TEST" pixel format, this validates graceful handling.
4359        assert!(PixelFormat::from_fourcc(u32::from_le_bytes(*b"TEST")).is_none());
4360    }
4361
4362    #[test]
4363    #[should_panic(expected = "Failed to save planar RGB image")]
4364    fn test_save_planar() {
4365        let planar_img = load_bytes_to_tensor(
4366            1280,
4367            720,
4368            PixelFormat::PlanarRgb,
4369            None,
4370            &edgefirst_bench::testdata::read("camera720p.8bps"),
4371        )
4372        .unwrap();
4373
4374        let save_path = "/tmp/planar_rgb.jpg";
4375        crate::save_jpeg(&planar_img, save_path, 90).expect("Failed to save planar RGB image");
4376    }
4377
4378    #[test]
4379    #[should_panic(expected = "Failed to save YUYV image")]
4380    fn test_save_yuyv() {
4381        let planar_img = load_bytes_to_tensor(
4382            1280,
4383            720,
4384            PixelFormat::Yuyv,
4385            None,
4386            &edgefirst_bench::testdata::read("camera720p.yuyv"),
4387        )
4388        .unwrap();
4389
4390        let save_path = "/tmp/yuyv.jpg";
4391        crate::save_jpeg(&planar_img, save_path, 90).expect("Failed to save YUYV image");
4392    }
4393
4394    #[test]
4395    fn test_rotation_angle() {
4396        assert_eq!(Rotation::from_degrees_clockwise(0), Rotation::None);
4397        assert_eq!(Rotation::from_degrees_clockwise(90), Rotation::Clockwise90);
4398        assert_eq!(Rotation::from_degrees_clockwise(180), Rotation::Rotate180);
4399        assert_eq!(
4400            Rotation::from_degrees_clockwise(270),
4401            Rotation::CounterClockwise90
4402        );
4403        assert_eq!(Rotation::from_degrees_clockwise(360), Rotation::None);
4404        assert_eq!(Rotation::from_degrees_clockwise(450), Rotation::Clockwise90);
4405        assert_eq!(Rotation::from_degrees_clockwise(540), Rotation::Rotate180);
4406        assert_eq!(
4407            Rotation::from_degrees_clockwise(630),
4408            Rotation::CounterClockwise90
4409        );
4410    }
4411
4412    #[test]
4413    #[should_panic(expected = "rotation angle is not a multiple of 90")]
4414    fn test_rotation_angle_panic() {
4415        Rotation::from_degrees_clockwise(361);
4416    }
4417
4418    #[test]
4419    fn test_disable_env_var() -> Result<(), Error> {
4420        // Acquire the env-var mutex for the entire test body so we never race
4421        // with test_force_backend_* or test_draw_proto_masks_no_cpu_returns_error.
4422        let _lock = acquire_env_lock();
4423
4424        // Snapshot ALL env vars we might touch so the RAII guard restores them
4425        // on exit (even on panic), preventing env-var poisoning of other tests.
4426        let _guard = EnvGuard::snapshot(&[
4427            "EDGEFIRST_FORCE_BACKEND",
4428            "EDGEFIRST_DISABLE_GL",
4429            "EDGEFIRST_DISABLE_G2D",
4430            "EDGEFIRST_DISABLE_CPU",
4431        ]);
4432
4433        // EDGEFIRST_FORCE_BACKEND takes precedence over EDGEFIRST_DISABLE_*,
4434        // so clear it for the duration of this test.
4435        unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") };
4436
4437        #[cfg(target_os = "linux")]
4438        {
4439            unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
4440            let converter = ImageProcessor::new()?;
4441            assert!(converter.g2d.is_none());
4442            unsafe { std::env::remove_var("EDGEFIRST_DISABLE_G2D") };
4443        }
4444
4445        #[cfg(any(target_os = "linux", target_os = "windows"))]
4446        #[cfg(feature = "opengl")]
4447        {
4448            unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
4449            let converter = ImageProcessor::new()?;
4450            assert!(converter.opengl.is_none());
4451            unsafe { std::env::remove_var("EDGEFIRST_DISABLE_GL") };
4452        }
4453
4454        unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
4455        let converter = ImageProcessor::new()?;
4456        assert!(converter.cpu.is_none());
4457        unsafe { std::env::remove_var("EDGEFIRST_DISABLE_CPU") };
4458
4459        // Disable everything — convert must return NoConverter.
4460        unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
4461        unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
4462        unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
4463        let mut converter = ImageProcessor::new()?;
4464
4465        let src = TensorDyn::image(
4466            1280,
4467            720,
4468            PixelFormat::Rgba,
4469            DType::U8,
4470            None,
4471            edgefirst_tensor::CpuAccess::ReadWrite,
4472        )?;
4473        let dst = TensorDyn::image(
4474            640,
4475            360,
4476            PixelFormat::Rgba,
4477            DType::U8,
4478            None,
4479            edgefirst_tensor::CpuAccess::ReadWrite,
4480        )?;
4481        let (result, _src, _dst) = convert_img(
4482            &mut converter,
4483            src,
4484            dst,
4485            Rotation::None,
4486            Flip::None,
4487            Crop::no_crop(),
4488        );
4489        assert!(matches!(result, Err(Error::NoConverter)));
4490        // _guard restores all env vars on drop.
4491        Ok(())
4492    }
4493
4494    #[test]
4495    fn test_unsupported_conversion() {
4496        let src = TensorDyn::image(
4497            1280,
4498            720,
4499            PixelFormat::Nv12,
4500            DType::U8,
4501            None,
4502            edgefirst_tensor::CpuAccess::ReadWrite,
4503        )
4504        .unwrap();
4505        let dst = TensorDyn::image(
4506            640,
4507            360,
4508            PixelFormat::Nv12,
4509            DType::U8,
4510            None,
4511            edgefirst_tensor::CpuAccess::ReadWrite,
4512        )
4513        .unwrap();
4514        let mut converter = ImageProcessor::new().unwrap();
4515        let (result, _src, _dst) = convert_img(
4516            &mut converter,
4517            src,
4518            dst,
4519            Rotation::None,
4520            Flip::None,
4521            Crop::no_crop(),
4522        );
4523        log::debug!("result: {:?}", result);
4524        assert!(matches!(
4525            result,
4526            Err(Error::NotSupported(e)) if e.starts_with("Conversion from NV12 to NV12")
4527        ));
4528    }
4529
4530    #[test]
4531    fn test_load_grey() {
4532        // A single-component (greyscale) JPEG decodes to its native GREY
4533        // format, which has no even-dimension constraint, so the 1024×681
4534        // `grey.jpg` loads and converts to RGBA successfully.
4535        let grey_img = crate::load_image_test_helper(
4536            &edgefirst_bench::testdata::read("grey.jpg"),
4537            Some(PixelFormat::Rgba),
4538            None,
4539        )
4540        .unwrap();
4541        assert_eq!(grey_img.width(), Some(1024));
4542        assert_eq!(grey_img.height(), Some(681));
4543
4544        // `grey-rgb.jpg` holds the same grey content but is encoded as a
4545        // 3-component (colour) JPEG, so the codec decodes it to native NV12.
4546        // Its 1024×681 dimensions have an odd height; NV12 now represents odd
4547        // dimensions via the `H + ceil(H/2)` combined-plane height, so the
4548        // decode succeeds and converts to RGBA at the true dimensions.
4549        let grey_but_rgb = crate::load_image_test_helper(
4550            &edgefirst_bench::testdata::read("grey-rgb.jpg"),
4551            Some(PixelFormat::Rgba),
4552            None,
4553        )
4554        .expect("odd-height colour JPEG should decode to NV12 and convert to RGBA");
4555        assert_eq!(grey_but_rgb.width(), Some(1024));
4556        assert_eq!(grey_but_rgb.height(), Some(681));
4557    }
4558
4559    #[test]
4560    fn test_new_nv12() {
4561        let nv12 = TensorDyn::image(
4562            1280,
4563            720,
4564            PixelFormat::Nv12,
4565            DType::U8,
4566            None,
4567            edgefirst_tensor::CpuAccess::ReadWrite,
4568        )
4569        .unwrap();
4570        assert_eq!(nv12.height(), Some(720));
4571        assert_eq!(nv12.width(), Some(1280));
4572        assert_eq!(nv12.format().unwrap(), PixelFormat::Nv12);
4573        // PixelFormat::Nv12.channels() returns 1 (luma plane channel count)
4574        assert_eq!(nv12.format().unwrap().channels(), 1);
4575        assert!(nv12.format().is_some_and(
4576            |f| f.layout() == PixelLayout::Planar || f.layout() == PixelLayout::SemiPlanar
4577        ))
4578    }
4579
4580    #[test]
4581    #[cfg(any(target_os = "linux", target_os = "windows"))]
4582    fn test_new_image_converter() {
4583        let dst_width = 640;
4584        let dst_height = 360;
4585        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4586        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4587
4588        let mut converter = ImageProcessor::new().unwrap();
4589        let converter_dst = converter
4590            .create_image(
4591                dst_width,
4592                dst_height,
4593                PixelFormat::Rgba,
4594                DType::U8,
4595                None,
4596                edgefirst_tensor::CpuAccess::ReadWrite,
4597            )
4598            .unwrap();
4599        let (result, src, converter_dst) = convert_img(
4600            &mut converter,
4601            src,
4602            converter_dst,
4603            Rotation::None,
4604            Flip::None,
4605            Crop::no_crop(),
4606        );
4607        result.unwrap();
4608
4609        let cpu_dst = TensorDyn::image(
4610            dst_width,
4611            dst_height,
4612            PixelFormat::Rgba,
4613            DType::U8,
4614            None,
4615            edgefirst_tensor::CpuAccess::ReadWrite,
4616        )
4617        .unwrap();
4618        let mut cpu_converter = CPUProcessor::new();
4619        let (result, _src, cpu_dst) = convert_img(
4620            &mut cpu_converter,
4621            src,
4622            cpu_dst,
4623            Rotation::None,
4624            Flip::None,
4625            Crop::no_crop(),
4626        );
4627        result.unwrap();
4628
4629        compare_images(&converter_dst, &cpu_dst, 0.98, function!());
4630    }
4631
4632    #[test]
4633    #[cfg(any(target_os = "linux", target_os = "windows"))]
4634    fn test_create_image_dtype_i8() {
4635        let mut converter = ImageProcessor::new().unwrap();
4636
4637        // I8 image should allocate successfully via create_image
4638        let dst = converter
4639            .create_image(
4640                320,
4641                240,
4642                PixelFormat::Rgb,
4643                DType::I8,
4644                None,
4645                edgefirst_tensor::CpuAccess::ReadWrite,
4646            )
4647            .unwrap();
4648        assert_eq!(dst.dtype(), DType::I8);
4649        assert!(dst.width() == Some(320));
4650        assert!(dst.height() == Some(240));
4651        assert_eq!(dst.format(), Some(PixelFormat::Rgb));
4652
4653        // U8 for comparison
4654        let dst_u8 = converter
4655            .create_image(
4656                320,
4657                240,
4658                PixelFormat::Rgb,
4659                DType::U8,
4660                None,
4661                edgefirst_tensor::CpuAccess::ReadWrite,
4662            )
4663            .unwrap();
4664        assert_eq!(dst_u8.dtype(), DType::U8);
4665
4666        // Convert into I8 dst should succeed
4667        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4668        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4669        let mut dst_i8 = converter
4670            .create_image(
4671                320,
4672                240,
4673                PixelFormat::Rgb,
4674                DType::I8,
4675                None,
4676                edgefirst_tensor::CpuAccess::ReadWrite,
4677            )
4678            .unwrap();
4679        converter
4680            .convert(
4681                &src,
4682                &mut dst_i8,
4683                Rotation::None,
4684                Flip::None,
4685                Crop::no_crop(),
4686            )
4687            .unwrap();
4688    }
4689
4690    #[test]
4691    #[cfg(target_os = "linux")]
4692    fn test_create_image_nv12_dma_non_aligned_width() {
4693        // create_image is fully stride-aware: a non-64-aligned NV12 DMA tensor
4694        // may legitimately carry a GPU-pitch-padded row stride — that is the
4695        // intended behaviour, not a bug. Verify the logical geometry is preserved
4696        // for any width and that a reported stride is a valid (>= logical)
4697        // padding, rather than asserting the absence of a stride.
4698        let converter = ImageProcessor::new().unwrap();
4699
4700        // 100 is intentionally not a multiple of 64 (the GPU pitch alignment).
4701        let result = converter.create_image(
4702            100,
4703            64,
4704            PixelFormat::Nv12,
4705            DType::U8,
4706            Some(TensorMemory::DmaBuf),
4707            edgefirst_tensor::CpuAccess::ReadWrite,
4708        );
4709
4710        match result {
4711            Ok(img) => {
4712                assert_eq!(img.width(), Some(100));
4713                assert_eq!(img.height(), Some(64));
4714                assert_eq!(img.format(), Some(PixelFormat::Nv12));
4715                if let Some(stride) = img.row_stride() {
4716                    assert!(
4717                        stride >= 100,
4718                        "NV12 row_stride {stride} must be >= the logical width (100)",
4719                    );
4720                }
4721            }
4722            Err(e) => {
4723                // Skip cleanly on hosts without a dma-heap.
4724                crate::test_support::report_skip(&format!(
4725                    "create_image NV12 DMA non-aligned width: {e}"
4726                ));
4727            }
4728        }
4729    }
4730
4731    #[test]
4732    #[ignore] // Hangs on desktop platforms where DMA-buf is unavailable and PBO
4733              // fallback triggers a GPU driver hang during SHM→texture upload (e.g.,
4734              // NVIDIA without /dev/dma_heap permissions). Works on embedded targets.
4735    fn test_crop_skip() {
4736        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4737        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4738
4739        let mut converter = ImageProcessor::new().unwrap();
4740        let converter_dst = converter
4741            .create_image(
4742                1280,
4743                720,
4744                PixelFormat::Rgba,
4745                DType::U8,
4746                None,
4747                edgefirst_tensor::CpuAccess::ReadWrite,
4748            )
4749            .unwrap();
4750        let crop = Crop::new().with_source(Some(Region::new(0, 0, 640, 640)));
4751        let (result, src, converter_dst) = convert_img(
4752            &mut converter,
4753            src,
4754            converter_dst,
4755            Rotation::None,
4756            Flip::None,
4757            crop,
4758        );
4759        result.unwrap();
4760
4761        let cpu_dst = TensorDyn::image(
4762            1280,
4763            720,
4764            PixelFormat::Rgba,
4765            DType::U8,
4766            None,
4767            edgefirst_tensor::CpuAccess::ReadWrite,
4768        )
4769        .unwrap();
4770        let mut cpu_converter = CPUProcessor::new();
4771        let (result, _src, cpu_dst) = convert_img(
4772            &mut cpu_converter,
4773            src,
4774            cpu_dst,
4775            Rotation::None,
4776            Flip::None,
4777            crop,
4778        );
4779        result.unwrap();
4780
4781        compare_images(&converter_dst, &cpu_dst, 0.99999, function!());
4782    }
4783
4784    #[test]
4785    fn test_invalid_pixel_format() {
4786        // PixelFormat::from_fourcc returns None for unknown formats,
4787        // so TensorDyn::image cannot be called with an invalid format.
4788        assert!(PixelFormat::from_fourcc(u32::from_le_bytes(*b"TEST")).is_none());
4789    }
4790
4791    // Helper function to check if G2D library is available (Linux/i.MX8 only)
4792    #[cfg(target_os = "linux")]
4793    static G2D_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
4794
4795    #[cfg(target_os = "linux")]
4796    fn is_g2d_available() -> bool {
4797        *G2D_AVAILABLE.get_or_init(|| G2DProcessor::new().is_ok())
4798    }
4799
4800    // Every GL platform: Linux EGL, ANGLE/Metal (macOS, iOS), Android EGL,
4801    // ANGLE/D3D11 (Windows). `GLProcessorThreaded::new` fails cleanly where
4802    // the driver or ANGLE is missing, so the `test_opengl_*` callers below
4803    // self-skip there (and `HAL_TEST_REQUIRE_GL=1` lanes catch that via
4804    // `gl_backend_available_canary`).
4805    #[cfg(any(
4806        target_os = "linux",
4807        target_os = "macos",
4808        target_os = "ios",
4809        target_os = "android",
4810        target_os = "windows"
4811    ))]
4812    #[cfg(feature = "opengl")]
4813    static GL_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
4814
4815    #[cfg(any(
4816        target_os = "linux",
4817        target_os = "macos",
4818        target_os = "ios",
4819        target_os = "android",
4820        target_os = "windows"
4821    ))]
4822    #[cfg(feature = "opengl")]
4823    // Helper function to check if OpenGL is available
4824    fn is_opengl_available() -> bool {
4825        *GL_AVAILABLE.get_or_init(|| GLProcessorThreaded::new(None).is_ok())
4826    }
4827
4828    /// CI canary: fails the lane when the GL backend cannot initialize.
4829    ///
4830    /// Every GL test in this suite self-skips when the backend is
4831    /// unavailable — correct for developer machines, but it means a broken
4832    /// CI GL stack (e.g. the macOS ANGLE re-sign step regressing, the exact
4833    /// failure mode documented in the workflow) ships an untested GL
4834    /// backend behind a green lane. Gated on `HAL_TEST_REQUIRE_GL=1`, set
4835    /// only by CI jobs that install a working GL stack; local runs without
4836    /// one pass trivially. On macOS it additionally requires
4837    /// `HAL_TEST_ALLOW_DLOPEN_ANGLE`, so coverage pass 1 (unsigned
4838    /// binaries, dlopen gate closed) skips it and pass 2 (signed) enforces.
4839    #[test]
4840    #[cfg(feature = "opengl")]
4841    fn gl_backend_available_canary() {
4842        let require_gl = std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1");
4843        if !require_gl {
4844            crate::test_support::report_skip(&format!(
4845                "{} — HAL_TEST_REQUIRE_GL is not set to 1",
4846                function!()
4847            ));
4848            return;
4849        }
4850        #[cfg(target_os = "macos")]
4851        if std::env::var_os("HAL_TEST_ALLOW_DLOPEN_ANGLE").is_none() {
4852            crate::test_support::report_skip(&format!(
4853                "{} — ANGLE dlopen gate closed (coverage pass 1)",
4854                function!()
4855            ));
4856            return;
4857        }
4858        GLProcessorThreaded::new(None).expect(
4859            "HAL_TEST_REQUIRE_GL=1 but the GL backend failed to initialize — \
4860             check the ANGLE install/re-sign step and binary entitlements \
4861             (macOS) or the EGL stack (Linux)",
4862        );
4863    }
4864
4865    #[test]
4866    fn test_load_jpeg_with_exif() {
4867        use edgefirst_codec::peek_info;
4868
4869        // The migrated codec NEVER applies EXIF orientation: it decodes to the
4870        // source's native (un-rotated) dimensions and reports the rotation via
4871        // ImageInfo. `zidane_rotated_exif.jpg` carries EXIF orientation 6
4872        // (90° clockwise) over a 1280×720 frame.
4873        let file = edgefirst_bench::testdata::read("zidane_rotated_exif.jpg").to_vec();
4874        let info = peek_info(&file).unwrap();
4875        assert_eq!(info.rotation_degrees, 90);
4876        assert!(!info.flip_horizontal);
4877
4878        let loaded = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4879        // Native (un-rotated) dimensions — the decode does not rotate.
4880        assert_eq!(loaded.width(), Some(1280));
4881        assert_eq!(loaded.height(), Some(720));
4882
4883        // Applying the reported rotation downstream reproduces the upright
4884        // image: it matches `zidane.jpg` rotated by the same 90° clockwise.
4885        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4886        let cpu_src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4887
4888        let rotation = Rotation::from_degrees_clockwise(info.rotation_degrees as usize);
4889        let (dst_width, dst_height) = (cpu_src.height().unwrap(), cpu_src.width().unwrap());
4890
4891        let cpu_dst = TensorDyn::image(
4892            dst_width,
4893            dst_height,
4894            PixelFormat::Rgba,
4895            DType::U8,
4896            None,
4897            edgefirst_tensor::CpuAccess::ReadWrite,
4898        )
4899        .unwrap();
4900        let mut cpu_converter = CPUProcessor::new();
4901
4902        // Rotate the native-orientation `loaded` frame and the native `zidane`
4903        // frame by the same reported rotation; the results must agree.
4904        let loaded_rotated = TensorDyn::image(
4905            dst_width,
4906            dst_height,
4907            PixelFormat::Rgba,
4908            DType::U8,
4909            None,
4910            edgefirst_tensor::CpuAccess::ReadWrite,
4911        )
4912        .unwrap();
4913        let (r0, _loaded, loaded_rotated) = convert_img(
4914            &mut cpu_converter,
4915            loaded,
4916            loaded_rotated,
4917            rotation,
4918            Flip::None,
4919            Crop::no_crop(),
4920        );
4921        r0.unwrap();
4922
4923        let (result, _cpu_src, cpu_dst) = convert_img(
4924            &mut cpu_converter,
4925            cpu_src,
4926            cpu_dst,
4927            rotation,
4928            Flip::None,
4929            Crop::no_crop(),
4930        );
4931        result.unwrap();
4932
4933        compare_images(&loaded_rotated, &cpu_dst, 0.98, function!());
4934    }
4935
4936    #[test]
4937    fn test_load_png_with_exif() {
4938        use edgefirst_codec::peek_info;
4939
4940        // PNGs also report EXIF orientation without applying it.
4941        // `zidane_rotated_exif_180.png` carries EXIF orientation 3 (180°).
4942        let file = edgefirst_bench::testdata::read("zidane_rotated_exif_180.png").to_vec();
4943        let info = peek_info(&file).unwrap();
4944        assert_eq!(info.rotation_degrees, 180);
4945        assert!(!info.flip_horizontal);
4946
4947        let loaded = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4948        // Native (un-rotated) dimensions — PNG decodes upright as authored.
4949        assert_eq!(loaded.height(), Some(720));
4950        assert_eq!(loaded.width(), Some(1280));
4951
4952        // The PNG fixture stores upright `zidane` pixels tagged with a 180°
4953        // EXIF orientation. Because the codec no longer applies the rotation,
4954        // the decoded pixels match `zidane.jpg` directly (no convert needed).
4955        // Re-applying the reported rotation to both must still agree.
4956        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4957        let cpu_src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4958
4959        let rotation = Rotation::from_degrees_clockwise(info.rotation_degrees as usize);
4960        let cpu_dst = TensorDyn::image(
4961            1280,
4962            720,
4963            PixelFormat::Rgba,
4964            DType::U8,
4965            None,
4966            edgefirst_tensor::CpuAccess::ReadWrite,
4967        )
4968        .unwrap();
4969        let mut cpu_converter = CPUProcessor::new();
4970
4971        let (result, _cpu_src, cpu_dst) = convert_img(
4972            &mut cpu_converter,
4973            cpu_src,
4974            cpu_dst,
4975            rotation,
4976            Flip::None,
4977            Crop::no_crop(),
4978        );
4979        result.unwrap();
4980
4981        // Rotate the decoded PNG by the same reported angle so both frames are
4982        // in the same (180°-rotated) orientation before comparing.
4983        let loaded_rotated = TensorDyn::image(
4984            1280,
4985            720,
4986            PixelFormat::Rgba,
4987            DType::U8,
4988            None,
4989            edgefirst_tensor::CpuAccess::ReadWrite,
4990        )
4991        .unwrap();
4992        let (r0, _loaded, loaded_rotated) = convert_img(
4993            &mut cpu_converter,
4994            loaded,
4995            loaded_rotated,
4996            rotation,
4997            Flip::None,
4998            Crop::no_crop(),
4999        );
5000        r0.unwrap();
5001
5002        // Threshold 0.95 (was 0.98): `loaded` comes from a lossless PNG decode
5003        // while `cpu_src` (zidane.jpg) now decodes through native NV12 (chroma
5004        // subsampling) before the RGBA conversion, so the two paths differ by a
5005        // couple of percent versus the old direct-RGB JPEG decode.
5006        compare_images(&loaded_rotated, &cpu_dst, 0.95, function!());
5007    }
5008
5009    /// Synthesise an RGB JPEG with a deterministic pattern at `(width, height)`
5010    /// using the workspace's `jpeg-encoder` crate (the `image` crate is
5011    /// compiled without its JPEG feature). Used to exercise the decoder /
5012    /// pitch-padding paths for arbitrary dimensions without having to bundle
5013    /// a fixture file per test size.
5014    #[cfg(target_os = "linux")]
5015    fn make_rgb_jpeg(width: u32, height: u32) -> Vec<u8> {
5016        let mut bytes = Vec::with_capacity((width * height * 3) as usize);
5017        for y in 0..height {
5018            for x in 0..width {
5019                bytes.push(((x + y) & 0xFF) as u8);
5020                bytes.push(((x.wrapping_mul(3)) & 0xFF) as u8);
5021                bytes.push(((y.wrapping_mul(5)) & 0xFF) as u8);
5022            }
5023        }
5024        let mut out = Vec::new();
5025        let encoder = jpeg_encoder::Encoder::new(&mut out, 85);
5026        encoder
5027            .encode(
5028                &bytes,
5029                width as u16,
5030                height as u16,
5031                jpeg_encoder::ColorType::Rgb,
5032            )
5033            .expect("jpeg-encoder must succeed on trivial input");
5034        out
5035    }
5036
5037    /// End-to-end: a 375×333 RGBA JPEG (width NOT divisible by 4) loaded
5038    /// via the pitch-padded DMA path and letterboxed through the GL
5039    /// backend must produce correct output. Before the Rgba/Bgra
5040    /// width%4 relaxation in `DmaImportAttrs::from_tensor`, this case
5041    /// failed the pre-check and forced a CPU texture upload fallback;
5042    /// with the relaxation, EGL import succeeds at the driver level and
5043    /// the GL fast path runs. Output correctness is checked against a
5044    /// CPU reference (convert ran with `EDGEFIRST_FORCE_BACKEND=cpu`).
5045    #[test]
5046    #[cfg(target_os = "linux")]
5047    #[cfg(feature = "opengl")]
5048    fn test_convert_rgba_non_4_aligned_width_end_to_end() {
5049        use edgefirst_tensor::is_dma_available;
5050        if !is_dma_available() {
5051            crate::test_support::report_skip(
5052                "test_convert_rgba_non_4_aligned_width_end_to_end — DMA not available",
5053            );
5054            return;
5055        }
5056        // 375 is the canonical failure width from dataset loaders —
5057        // 375 * 4 = 1500 bytes/row, pitch-padded to 1536. Width%4 = 3,
5058        // so the old pre-check rejected it; new code accepts it.
5059        let jpeg = make_rgb_jpeg(375, 333);
5060        let src_gl = crate::load_image_test_helper(&jpeg, Some(PixelFormat::Rgba), None).unwrap();
5061        assert_eq!(src_gl.width(), Some(375));
5062        // Row stride must still be pitch-padded (separate concern from width).
5063        let stride = src_gl.row_stride().unwrap();
5064        assert_eq!(stride, 1536, "expected padded pitch 1536, got {stride}");
5065
5066        // GL-backed convert into a pitch-aligned 640×640 Rgba dest.
5067        let mut gl_proc = ImageProcessor::new().unwrap();
5068        let gl_dst = gl_proc
5069            .create_image(
5070                640,
5071                640,
5072                PixelFormat::Rgba,
5073                DType::U8,
5074                None,
5075                edgefirst_tensor::CpuAccess::ReadWrite,
5076            )
5077            .unwrap();
5078        let (r_gl, _src_gl, gl_dst) = convert_img(
5079            &mut gl_proc,
5080            src_gl,
5081            gl_dst,
5082            Rotation::None,
5083            Flip::None,
5084            Crop::no_crop(),
5085        );
5086        r_gl.expect("GL-backed convert must succeed for 375x333 Rgba src");
5087
5088        // CPU reference via a fresh load so the two paths start from
5089        // byte-identical inputs. `with_config(backend=Cpu)` forces the
5090        // CPU-only processor regardless of which backends the host has
5091        // available.
5092        let src_cpu =
5093            crate::load_image_test_helper(&jpeg, Some(PixelFormat::Rgba), Some(TensorMemory::Mem))
5094                .unwrap();
5095        let mut cpu_proc = ImageProcessor::with_config(ImageProcessorConfig {
5096            backend: ComputeBackend::Cpu,
5097            ..Default::default()
5098        })
5099        .unwrap();
5100        let cpu_dst = TensorDyn::image(
5101            640,
5102            640,
5103            PixelFormat::Rgba,
5104            DType::U8,
5105            Some(TensorMemory::Mem),
5106            edgefirst_tensor::CpuAccess::ReadWrite,
5107        )
5108        .unwrap();
5109        let (r_cpu, _src_cpu, cpu_dst) = convert_img(
5110            &mut cpu_proc,
5111            src_cpu,
5112            cpu_dst,
5113            Rotation::None,
5114            Flip::None,
5115            Crop::no_crop(),
5116        );
5117        r_cpu.unwrap();
5118
5119        // Structural similarity: the GL path may have gone through EGL
5120        // import OR fallen back to CPU texture upload — either way, the
5121        // output must match the CPU reference closely.
5122        compare_images(&gl_dst, &cpu_dst, 0.95, function!());
5123    }
5124
5125    /// Regression lock: loading a JPEG at a non-64-aligned RGBA pitch (e.g.
5126    /// 500×333 → natural pitch 2000, needs to be padded to 2048) must go
5127    /// through `image_with_stride` and set `row_stride()` / `effective_row_stride()`
5128    /// to the padded value. The earlier pitch-padding commit fixed this in
5129    /// `load_jpeg`; a regression would surface as `row_stride == None` or
5130    /// `effective_row_stride == 2000`.
5131    #[test]
5132    #[cfg(target_os = "linux")]
5133    fn test_load_jpeg_rgba_non_aligned_pitch_padded_dma() {
5134        use edgefirst_tensor::is_dma_available;
5135        if !is_dma_available() {
5136            crate::test_support::report_skip(
5137                "test_load_jpeg_rgba_non_aligned_pitch_padded_dma — DMA not available",
5138            );
5139            return;
5140        }
5141        // Widths that force a non-64-aligned natural RGBA pitch. All three
5142        // are divisible by 4 so the EGL width-alignment pre-check passes.
5143        // The pitch-padding fix is what makes these importable at all.
5144        for &w in &[500u32, 612, 428] {
5145            let jpeg = make_rgb_jpeg(w, 333);
5146            let loaded =
5147                crate::load_image_test_helper(&jpeg, Some(PixelFormat::Rgba), None).unwrap();
5148            let natural = (w as usize) * 4;
5149            let aligned = crate::align_pitch_bytes_to_gpu_alignment(natural).unwrap();
5150            assert!(
5151                aligned > natural,
5152                "test sanity: width {w} should be unaligned"
5153            );
5154            let stride = loaded
5155                .row_stride()
5156                .expect("padded DMA path must set an explicit row_stride — regression if None");
5157            assert_eq!(
5158                stride, aligned,
5159                "width {w}: expected padded stride {aligned}, got {stride} \
5160                 (regression: pitch-padding branch skipped?)"
5161            );
5162            let eff = loaded.effective_row_stride().unwrap();
5163            assert_eq!(
5164                eff, aligned,
5165                "effective_row_stride must match stored stride"
5166            );
5167            assert_eq!(loaded.width(), Some(w as usize));
5168            assert_eq!(loaded.height(), Some(333));
5169        }
5170    }
5171
5172    /// `padded_dma_pitch_for` must respect the caller's memory choice and
5173    /// must NOT route into the pitch-padded DMA path when the caller left
5174    /// the choice to the allocator (`None`) but DMA is unavailable on the
5175    /// host. The padded path requires `image_with_stride`, which always
5176    /// allocates DMA — taking it on a system without `/dev/dma_heap`
5177    /// would convert a normally-working image load into a hard failure
5178    /// (since `Tensor::image(..., None)` would have fallen back to
5179    /// SHM/Mem).
5180    #[test]
5181    #[cfg(target_os = "linux")]
5182    fn test_padded_dma_pitch_for_respects_memory_choice() {
5183        use edgefirst_tensor::{is_dma_available, TensorMemory};
5184
5185        // 500×4 = 2000 → padded to 2048 by GPU alignment. Use it for
5186        // every case so any "no padding" answer is unambiguous.
5187        let unaligned_w = 500;
5188
5189        // Caller asks for Mem / Shm: never pad, regardless of DMA.
5190        assert_eq!(
5191            crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &Some(TensorMemory::Mem),),
5192            None,
5193            "Mem must never trigger DMA padding"
5194        );
5195        assert_eq!(
5196            crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &Some(TensorMemory::Shm),),
5197            None,
5198            "Shm must never trigger DMA padding"
5199        );
5200
5201        // Caller explicitly asks for DMA: always pad if width needs it.
5202        // Even if the runtime can't actually allocate DMA, the caller
5203        // owns that decision and the resulting allocation error is
5204        // their problem, not ours.
5205        assert_eq!(
5206            crate::padded_dma_pitch_for(
5207                PixelFormat::Rgba,
5208                unaligned_w,
5209                &Some(TensorMemory::DmaBuf),
5210            ),
5211            Some(2048),
5212            "explicit Dma must pad regardless of runtime DMA availability"
5213        );
5214
5215        // Caller leaves it to the allocator: behaviour depends on
5216        // host-runtime DMA availability. This is the case the fix
5217        // guards against.
5218        let none_result = crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &None);
5219        if is_dma_available() {
5220            assert_eq!(
5221                none_result,
5222                Some(2048),
5223                "memory=None + DMA available → pad (will route through DMA)"
5224            );
5225        } else {
5226            assert_eq!(
5227                none_result, None,
5228                "memory=None + DMA unavailable → must NOT pad (would force \
5229                 image_with_stride into a DMA-only allocation that fails). \
5230                 Regression: padded_dma_pitch_for ignored is_dma_available()."
5231            );
5232        }
5233    }
5234
5235    // Synthesise a small greyscale PNG in memory at `(width, height)` with a
5236    // deterministic ramp pattern so multiple tests can cross-check output
5237    // without bundling an extra fixture file.
5238    fn make_grey_png(width: u32, height: u32) -> Vec<u8> {
5239        let mut bytes = Vec::with_capacity((width * height) as usize);
5240        for y in 0..height {
5241            for x in 0..width {
5242                bytes.push(((x + y) & 0xFF) as u8);
5243            }
5244        }
5245        let img = image::GrayImage::from_vec(width, height, bytes).unwrap();
5246        let mut buf = Vec::new();
5247        img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
5248            .unwrap();
5249        buf
5250    }
5251
5252    /// Greyscale PNG with a width that forces a pitch-misaligned natural
5253    /// row stride (612 bytes is not a multiple of the 64-byte GPU pitch
5254    /// alignment) must still load via the pitch-padded DMA path. Gated on
5255    /// DMA availability because `image_with_stride` is DMA-only.
5256    #[test]
5257    #[cfg(target_os = "linux")]
5258    fn test_load_png_grey_misaligned_width_dma() {
5259        use edgefirst_tensor::is_dma_available;
5260        if !is_dma_available() {
5261            crate::test_support::report_skip(
5262                "test_load_png_grey_misaligned_width_dma — DMA not available",
5263            );
5264            return;
5265        }
5266        let png = make_grey_png(612, 388);
5267        let loaded = crate::load_image_test_helper(&png, Some(PixelFormat::Grey), None).unwrap();
5268        assert_eq!(loaded.width(), Some(612));
5269        assert_eq!(loaded.height(), Some(388));
5270        assert_eq!(loaded.format(), Some(PixelFormat::Grey));
5271
5272        // Round-trip pixels — natural-pitch DMA-BUFs pad the stride so we
5273        // must indirect through row_stride() rather than assume width.
5274        let map = loaded.as_typed::<u8>().unwrap().map().unwrap();
5275        let stride = loaded.row_stride().unwrap_or(612);
5276        assert!(stride >= 612);
5277        let bytes: &[u8] = &map;
5278        for y in 0..388usize {
5279            for x in 0..612usize {
5280                let expected = ((x + y) & 0xFF) as u8;
5281                let got = bytes[y * stride + x];
5282                assert_eq!(
5283                    got, expected,
5284                    "grey png mismatch at ({x},{y}): got {got} expected {expected}"
5285                );
5286            }
5287        }
5288    }
5289
5290    /// Greyscale PNG loaded with explicit Mem backing — runs on any
5291    /// platform (no DMA permission requirement) and covers the
5292    /// decoder-native Luma → Grey no-conversion path.
5293    #[test]
5294    fn test_load_png_grey_mem() {
5295        use edgefirst_tensor::TensorMemory;
5296        let png = make_grey_png(612, 100);
5297        let loaded =
5298            crate::load_image_test_helper(&png, Some(PixelFormat::Grey), Some(TensorMemory::Mem))
5299                .unwrap();
5300        assert_eq!(loaded.width(), Some(612));
5301        assert_eq!(loaded.height(), Some(100));
5302        assert_eq!(loaded.format(), Some(PixelFormat::Grey));
5303        let map = loaded.as_typed::<u8>().unwrap().map().unwrap();
5304        let bytes: &[u8] = &map;
5305        // Mem allocation uses the natural pitch — 612 bytes per row, exact.
5306        assert_eq!(bytes.len(), 612 * 100);
5307        for y in 0..100 {
5308            for x in 0..612 {
5309                assert_eq!(bytes[y * 612 + x], ((x + y) & 0xFF) as u8);
5310            }
5311        }
5312    }
5313
5314    /// Greyscale PNG decoded into RGB — exercises the decoder-colorspace
5315    /// mismatch path (Luma → Rgb via CPU converter). Uses Mem memory to
5316    /// stay portable to host-side test environments.
5317    #[test]
5318    fn test_load_png_grey_to_rgb_mem() {
5319        use edgefirst_tensor::TensorMemory;
5320        let png = make_grey_png(620, 240);
5321        let loaded =
5322            crate::load_image_test_helper(&png, Some(PixelFormat::Rgb), Some(TensorMemory::Mem))
5323                .unwrap();
5324        assert_eq!(loaded.width(), Some(620));
5325        assert_eq!(loaded.height(), Some(240));
5326        assert_eq!(loaded.format(), Some(PixelFormat::Rgb));
5327
5328        // Greyscale promoted to RGB replicates luma into each channel.
5329        let map = loaded.as_typed::<u8>().unwrap().map().unwrap();
5330        let bytes: &[u8] = &map;
5331        for (x, y) in [(0usize, 0usize), (100, 50), (619, 239)] {
5332            let expected = ((x + y) & 0xFF) as u8;
5333            let off = (y * 620 + x) * 3;
5334            assert_eq!(bytes[off], expected, "R@{x},{y}");
5335            assert_eq!(bytes[off + 1], expected, "G@{x},{y}");
5336            assert_eq!(bytes[off + 2], expected, "B@{x},{y}");
5337        }
5338    }
5339
5340    #[test]
5341    #[cfg(target_os = "linux")]
5342    fn test_g2d_resize() {
5343        if !is_g2d_available() {
5344            crate::test_support::report_skip(
5345                "test_g2d_resize - G2D library (libg2d.so.2) not available",
5346            );
5347            return;
5348        }
5349        if !is_dma_available() {
5350            crate::test_support::report_skip("test_g2d_resize - DMA memory allocation not available (permission denied or no DMA-BUF support)");
5351            return;
5352        }
5353
5354        let dst_width = 640;
5355        let dst_height = 360;
5356        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5357        let src = crate::load_image_test_helper(
5358            &file,
5359            Some(PixelFormat::Rgba),
5360            Some(TensorMemory::DmaBuf),
5361        )
5362        .unwrap();
5363
5364        let g2d_dst = TensorDyn::image(
5365            dst_width,
5366            dst_height,
5367            PixelFormat::Rgba,
5368            DType::U8,
5369            Some(TensorMemory::DmaBuf),
5370            edgefirst_tensor::CpuAccess::ReadWrite,
5371        )
5372        .unwrap();
5373        let mut g2d_converter = G2DProcessor::new().unwrap();
5374        let (result, src, g2d_dst) = convert_img(
5375            &mut g2d_converter,
5376            src,
5377            g2d_dst,
5378            Rotation::None,
5379            Flip::None,
5380            Crop::no_crop(),
5381        );
5382        result.unwrap();
5383
5384        let cpu_dst = TensorDyn::image(
5385            dst_width,
5386            dst_height,
5387            PixelFormat::Rgba,
5388            DType::U8,
5389            None,
5390            edgefirst_tensor::CpuAccess::ReadWrite,
5391        )
5392        .unwrap();
5393        let mut cpu_converter = CPUProcessor::new();
5394        let (result, _src, cpu_dst) = convert_img(
5395            &mut cpu_converter,
5396            src,
5397            cpu_dst,
5398            Rotation::None,
5399            Flip::None,
5400            Crop::no_crop(),
5401        );
5402        result.unwrap();
5403
5404        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
5405        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
5406        // the YUV-matrix delta that forced 0.95 has closed; tightened to
5407        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
5408        // structural gap not exercised by these limited-range fixtures.
5409        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
5410    }
5411
5412    #[test]
5413    #[cfg(any(
5414        target_os = "linux",
5415        target_os = "macos",
5416        target_os = "ios",
5417        target_os = "android",
5418        target_os = "windows"
5419    ))]
5420    #[cfg(feature = "opengl")]
5421    fn test_opengl_resize() {
5422        if !is_opengl_available() {
5423            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
5424            return;
5425        }
5426
5427        let dst_width = 640;
5428        let dst_height = 360;
5429        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5430        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5431
5432        let cpu_dst = TensorDyn::image(
5433            dst_width,
5434            dst_height,
5435            PixelFormat::Rgba,
5436            DType::U8,
5437            None,
5438            edgefirst_tensor::CpuAccess::ReadWrite,
5439        )
5440        .unwrap();
5441        let mut cpu_converter = CPUProcessor::new();
5442        let (result, src, cpu_dst) = convert_img(
5443            &mut cpu_converter,
5444            src,
5445            cpu_dst,
5446            Rotation::None,
5447            Flip::None,
5448            Crop::no_crop(),
5449        );
5450        result.unwrap();
5451
5452        let mut src = src;
5453        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
5454
5455        for _ in 0..5 {
5456            let gl_dst = TensorDyn::image(
5457                dst_width,
5458                dst_height,
5459                PixelFormat::Rgba,
5460                DType::U8,
5461                None,
5462                edgefirst_tensor::CpuAccess::ReadWrite,
5463            )
5464            .unwrap();
5465            let (result, src_back, gl_dst) = convert_img(
5466                &mut gl_converter,
5467                src,
5468                gl_dst,
5469                Rotation::None,
5470                Flip::None,
5471                Crop::no_crop(),
5472            );
5473            result.unwrap();
5474            src = src_back;
5475
5476            compare_images(&gl_dst, &cpu_dst, 0.98, function!());
5477        }
5478    }
5479
5480    #[test]
5481    #[cfg(any(
5482        target_os = "linux",
5483        target_os = "macos",
5484        target_os = "ios",
5485        target_os = "android",
5486        target_os = "windows"
5487    ))]
5488    #[cfg(feature = "opengl")]
5489    fn test_opengl_10_threads() {
5490        if !is_opengl_available() {
5491            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
5492            return;
5493        }
5494
5495        let handles: Vec<_> = (0..10)
5496            .map(|i| {
5497                std::thread::Builder::new()
5498                    .name(format!("Thread {i}"))
5499                    .spawn(test_opengl_resize)
5500                    .unwrap()
5501            })
5502            .collect();
5503        handles.into_iter().for_each(|h| {
5504            if let Err(e) = h.join() {
5505                std::panic::resume_unwind(e)
5506            }
5507        });
5508    }
5509
5510    #[test]
5511    #[cfg(any(
5512        target_os = "linux",
5513        target_os = "macos",
5514        target_os = "ios",
5515        target_os = "android",
5516        target_os = "windows"
5517    ))]
5518    #[cfg(feature = "opengl")]
5519    fn test_opengl_grey() {
5520        if !is_opengl_available() {
5521            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
5522            return;
5523        }
5524
5525        let img = crate::load_image_test_helper(
5526            &edgefirst_bench::testdata::read("grey.jpg"),
5527            Some(PixelFormat::Grey),
5528            None,
5529        )
5530        .unwrap();
5531
5532        let gl_dst = TensorDyn::image(
5533            640,
5534            640,
5535            PixelFormat::Grey,
5536            DType::U8,
5537            None,
5538            edgefirst_tensor::CpuAccess::ReadWrite,
5539        )
5540        .unwrap();
5541        let cpu_dst = TensorDyn::image(
5542            640,
5543            640,
5544            PixelFormat::Grey,
5545            DType::U8,
5546            None,
5547            edgefirst_tensor::CpuAccess::ReadWrite,
5548        )
5549        .unwrap();
5550
5551        let mut converter = CPUProcessor::new();
5552
5553        let (result, img, cpu_dst) = convert_img(
5554            &mut converter,
5555            img,
5556            cpu_dst,
5557            Rotation::None,
5558            Flip::None,
5559            Crop::no_crop(),
5560        );
5561        result.unwrap();
5562
5563        let mut gl = GLProcessorThreaded::new(None).unwrap();
5564        let (result, _img, gl_dst) = convert_img(
5565            &mut gl,
5566            img,
5567            gl_dst,
5568            Rotation::None,
5569            Flip::None,
5570            Crop::no_crop(),
5571        );
5572        result.unwrap();
5573
5574        compare_images(&gl_dst, &cpu_dst, 0.98, function!());
5575    }
5576
5577    #[test]
5578    #[cfg(target_os = "linux")]
5579    fn test_g2d_src_crop() {
5580        if !is_g2d_available() {
5581            crate::test_support::report_skip(
5582                "test_g2d_src_crop - G2D library (libg2d.so.2) not available",
5583            );
5584            return;
5585        }
5586        if !is_dma_available() {
5587            crate::test_support::report_skip("test_g2d_src_crop - DMA memory allocation not available (permission denied or no DMA-BUF support)");
5588            return;
5589        }
5590
5591        let dst_width = 640;
5592        let dst_height = 640;
5593        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5594        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5595
5596        let cpu_dst = TensorDyn::image(
5597            dst_width,
5598            dst_height,
5599            PixelFormat::Rgba,
5600            DType::U8,
5601            None,
5602            edgefirst_tensor::CpuAccess::ReadWrite,
5603        )
5604        .unwrap();
5605        let mut cpu_converter = CPUProcessor::new();
5606        let crop = Crop::new().with_source(Some(Region::new(0, 0, 640, 360)));
5607        let (result, src, cpu_dst) = convert_img(
5608            &mut cpu_converter,
5609            src,
5610            cpu_dst,
5611            Rotation::None,
5612            Flip::None,
5613            crop,
5614        );
5615        result.unwrap();
5616
5617        let g2d_dst = TensorDyn::image(
5618            dst_width,
5619            dst_height,
5620            PixelFormat::Rgba,
5621            DType::U8,
5622            None,
5623            edgefirst_tensor::CpuAccess::ReadWrite,
5624        )
5625        .unwrap();
5626        let mut g2d_converter = G2DProcessor::new().unwrap();
5627        let (result, _src, g2d_dst) = convert_img(
5628            &mut g2d_converter,
5629            src,
5630            g2d_dst,
5631            Rotation::None,
5632            Flip::None,
5633            crop,
5634        );
5635        result.unwrap();
5636
5637        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
5638        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
5639        // the YUV-matrix delta that forced 0.95 has closed; tightened to
5640        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
5641        // structural gap not exercised by these limited-range fixtures.
5642        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
5643    }
5644
5645    #[test]
5646    #[cfg(target_os = "linux")]
5647    fn test_g2d_dst_crop() {
5648        if !is_g2d_available() {
5649            crate::test_support::report_skip(
5650                "test_g2d_dst_crop - G2D library (libg2d.so.2) not available",
5651            );
5652            return;
5653        }
5654        if !is_dma_available() {
5655            crate::test_support::report_skip("test_g2d_dst_crop - DMA memory allocation not available (permission denied or no DMA-BUF support)");
5656            return;
5657        }
5658
5659        let dst_width = 640;
5660        let dst_height = 640;
5661        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5662        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5663
5664        let cpu_dst = TensorDyn::image(
5665            dst_width,
5666            dst_height,
5667            PixelFormat::Rgba,
5668            DType::U8,
5669            None,
5670            edgefirst_tensor::CpuAccess::ReadWrite,
5671        )
5672        .unwrap();
5673        let mut cpu_converter = CPUProcessor::new();
5674        let crop = Crop::new();
5675        let (result, src, cpu_dst) = convert_img(
5676            &mut cpu_converter,
5677            src,
5678            cpu_dst,
5679            Rotation::None,
5680            Flip::None,
5681            crop,
5682        );
5683        result.unwrap();
5684
5685        let g2d_dst = TensorDyn::image(
5686            dst_width,
5687            dst_height,
5688            PixelFormat::Rgba,
5689            DType::U8,
5690            None,
5691            edgefirst_tensor::CpuAccess::ReadWrite,
5692        )
5693        .unwrap();
5694        let mut g2d_converter = G2DProcessor::new().unwrap();
5695        let (result, _src, g2d_dst) = convert_img(
5696            &mut g2d_converter,
5697            src,
5698            g2d_dst,
5699            Rotation::None,
5700            Flip::None,
5701            crop,
5702        );
5703        result.unwrap();
5704
5705        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
5706        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
5707        // the YUV-matrix delta that forced 0.95 has closed; tightened to
5708        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
5709        // structural gap not exercised by these limited-range fixtures.
5710        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
5711    }
5712
5713    #[test]
5714    #[cfg(target_os = "linux")]
5715    fn test_g2d_all_rgba() {
5716        if !is_g2d_available() {
5717            crate::test_support::report_skip(
5718                "test_g2d_all_rgba - G2D library (libg2d.so.2) not available",
5719            );
5720            return;
5721        }
5722        if !is_dma_available() {
5723            crate::test_support::report_skip("test_g2d_all_rgba - DMA memory allocation not available (permission denied or no DMA-BUF support)");
5724            return;
5725        }
5726
5727        let dst_width = 640;
5728        let dst_height = 640;
5729        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5730        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5731        let src_dyn = src;
5732
5733        let mut cpu_dst = TensorDyn::image(
5734            dst_width,
5735            dst_height,
5736            PixelFormat::Rgba,
5737            DType::U8,
5738            None,
5739            edgefirst_tensor::CpuAccess::ReadWrite,
5740        )
5741        .unwrap();
5742        let mut cpu_converter = CPUProcessor::new();
5743        let mut g2d_dst = TensorDyn::image(
5744            dst_width,
5745            dst_height,
5746            PixelFormat::Rgba,
5747            DType::U8,
5748            None,
5749            edgefirst_tensor::CpuAccess::ReadWrite,
5750        )
5751        .unwrap();
5752        let mut g2d_converter = G2DProcessor::new().unwrap();
5753
5754        let crop = Crop::new().with_source(Some(Region::new(50, 120, 1024, 576)));
5755
5756        for rot in [
5757            Rotation::None,
5758            Rotation::Clockwise90,
5759            Rotation::Rotate180,
5760            Rotation::CounterClockwise90,
5761        ] {
5762            cpu_dst
5763                .as_typed::<u8>()
5764                .unwrap()
5765                .map()
5766                .unwrap()
5767                .as_mut_slice()
5768                .fill(114);
5769            g2d_dst
5770                .as_typed::<u8>()
5771                .unwrap()
5772                .map()
5773                .unwrap()
5774                .as_mut_slice()
5775                .fill(114);
5776            for flip in [Flip::None, Flip::Horizontal, Flip::Vertical] {
5777                let mut cpu_dst_dyn = cpu_dst;
5778                cpu_converter
5779                    .convert(&src_dyn, &mut cpu_dst_dyn, Rotation::None, Flip::None, crop)
5780                    .unwrap();
5781                cpu_dst = {
5782                    let mut __t = cpu_dst_dyn.into_u8().unwrap();
5783                    __t.set_format(PixelFormat::Rgba).unwrap();
5784                    TensorDyn::from(__t)
5785                };
5786
5787                let mut g2d_dst_dyn = g2d_dst;
5788                g2d_converter
5789                    .convert(&src_dyn, &mut g2d_dst_dyn, Rotation::None, Flip::None, crop)
5790                    .unwrap();
5791                g2d_dst = {
5792                    let mut __t = g2d_dst_dyn.into_u8().unwrap();
5793                    __t.set_format(PixelFormat::Rgba).unwrap();
5794                    TensorDyn::from(__t)
5795                };
5796
5797                compare_images(
5798                    &g2d_dst,
5799                    &cpu_dst,
5800                    0.98,
5801                    &format!("{} {:?} {:?}", function!(), rot, flip),
5802                );
5803            }
5804        }
5805    }
5806
5807    #[test]
5808    #[cfg(any(
5809        target_os = "linux",
5810        target_os = "macos",
5811        target_os = "ios",
5812        target_os = "android",
5813        target_os = "windows"
5814    ))]
5815    #[cfg(feature = "opengl")]
5816    fn test_opengl_src_crop() {
5817        if !is_opengl_available() {
5818            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
5819            return;
5820        }
5821
5822        let dst_width = 640;
5823        let dst_height = 360;
5824        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5825        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5826        let crop = Crop::new().with_source(Some(Region::new(320, 180, 1280 - 320, 720 - 180)));
5827
5828        let cpu_dst = TensorDyn::image(
5829            dst_width,
5830            dst_height,
5831            PixelFormat::Rgba,
5832            DType::U8,
5833            None,
5834            edgefirst_tensor::CpuAccess::ReadWrite,
5835        )
5836        .unwrap();
5837        let mut cpu_converter = CPUProcessor::new();
5838        let (result, src, cpu_dst) = convert_img(
5839            &mut cpu_converter,
5840            src,
5841            cpu_dst,
5842            Rotation::None,
5843            Flip::None,
5844            crop,
5845        );
5846        result.unwrap();
5847
5848        let gl_dst = TensorDyn::image(
5849            dst_width,
5850            dst_height,
5851            PixelFormat::Rgba,
5852            DType::U8,
5853            None,
5854            edgefirst_tensor::CpuAccess::ReadWrite,
5855        )
5856        .unwrap();
5857        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
5858        let (result, _src, gl_dst) = convert_img(
5859            &mut gl_converter,
5860            src,
5861            gl_dst,
5862            Rotation::None,
5863            Flip::None,
5864            crop,
5865        );
5866        result.unwrap();
5867
5868        compare_images(&gl_dst, &cpu_dst, 0.98, function!());
5869    }
5870
5871    #[test]
5872    #[cfg(any(
5873        target_os = "linux",
5874        target_os = "macos",
5875        target_os = "ios",
5876        target_os = "android",
5877        target_os = "windows"
5878    ))]
5879    #[cfg(feature = "opengl")]
5880    fn test_opengl_dst_crop() {
5881        if !is_opengl_available() {
5882            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
5883            return;
5884        }
5885
5886        let dst_width = 640;
5887        let dst_height = 640;
5888        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5889        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5890
5891        let cpu_dst = TensorDyn::image(
5892            dst_width,
5893            dst_height,
5894            PixelFormat::Rgba,
5895            DType::U8,
5896            None,
5897            edgefirst_tensor::CpuAccess::ReadWrite,
5898        )
5899        .unwrap();
5900        let mut cpu_converter = CPUProcessor::new();
5901        let crop = Crop::new();
5902        let (result, src, cpu_dst) = convert_img(
5903            &mut cpu_converter,
5904            src,
5905            cpu_dst,
5906            Rotation::None,
5907            Flip::None,
5908            crop,
5909        );
5910        result.unwrap();
5911
5912        let gl_dst = TensorDyn::image(
5913            dst_width,
5914            dst_height,
5915            PixelFormat::Rgba,
5916            DType::U8,
5917            None,
5918            edgefirst_tensor::CpuAccess::ReadWrite,
5919        )
5920        .unwrap();
5921        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
5922        let (result, _src, gl_dst) = convert_img(
5923            &mut gl_converter,
5924            src,
5925            gl_dst,
5926            Rotation::None,
5927            Flip::None,
5928            crop,
5929        );
5930        result.unwrap();
5931
5932        compare_images(&gl_dst, &cpu_dst, 0.98, function!());
5933    }
5934    /// A deterministic RGBA8 `Mem` source: `R = x`, `G = y`, `B = x + y`,
5935    /// `A = 255`, each `& 255`. Written at the tensor's own row stride so a
5936    /// padded allocation still gets the pattern at the right columns.
5937    #[cfg(all(target_os = "windows", feature = "opengl"))]
5938    fn synthetic_rgba(w: usize, h: usize) -> TensorDyn {
5939        let t = TensorDyn::image(
5940            w,
5941            h,
5942            PixelFormat::Rgba,
5943            DType::U8,
5944            Some(TensorMemory::Mem),
5945            edgefirst_tensor::CpuAccess::ReadWrite,
5946        )
5947        .unwrap();
5948        fill_synthetic_rgba(&t);
5949        t
5950    }
5951
5952    /// Every pixel of `actual` equals `expected`, compared through the
5953    /// tensors' own row compaction so a padded texture and a tight `Mem`
5954    /// image compare as images. `compare_images` scores similarity and
5955    /// accepts anything at a 0.0 threshold; this is the exact check.
5956    #[cfg(all(target_os = "windows", feature = "opengl"))]
5957    fn assert_pixels_identical(actual: &TensorDyn, expected: &TensorDyn, name: &str) {
5958        assert_eq!(actual.shape(), expected.shape(), "{name}: shapes differ");
5959        let flat = |t: &TensorDyn| {
5960            let typed = t.as_typed::<u8>().expect("u8 image");
5961            let mut out = vec![0u8; t.shape().iter().product::<usize>()];
5962            typed.copy_to_flat(&mut out).expect("compact the rows");
5963            out
5964        };
5965        let (a, e) = (flat(actual), flat(expected));
5966        let mismatches = a.iter().zip(&e).filter(|(x, y)| x != y).count();
5967        assert_eq!(
5968            mismatches,
5969            0,
5970            "{name}: {mismatches} of {} bytes differ (first rows: {:?} vs {:?})",
5971            a.len(),
5972            &a[..16.min(a.len())],
5973            &e[..16.min(e.len())]
5974        );
5975    }
5976
5977    /// Write the `synthetic_rgba` pattern into any writable RGBA8 tensor,
5978    /// a texture included, at its own row stride.
5979    #[cfg(all(target_os = "windows", feature = "opengl"))]
5980    fn fill_synthetic_rgba(t: &TensorDyn) {
5981        let w = t.width().unwrap();
5982        let h = t.height().unwrap();
5983        let bound = t.as_typed::<u8>().unwrap();
5984        let stride = bound.effective_row_stride().unwrap_or(w * 4);
5985        let mut m = bound.map().unwrap();
5986        let px = m.as_mut_slice();
5987        for y in 0..h {
5988            for x in 0..w {
5989                let o = y * stride + x * 4;
5990                px[o] = x as u8;
5991                px[o + 1] = y as u8;
5992                px[o + 2] = (x + y) as u8;
5993                px[o + 3] = 255;
5994            }
5995        }
5996    }
5997
5998    /// Element-wise comparison of two float images of the same format and
5999    /// dtype.
6000    ///
6001    /// Both sides are read through `copy_to_flat`, the tensor's own row
6002    /// compaction, for the reason `compare_images` does: a Windows texture
6003    /// destination pads its rows, so reading the head of the mapping as a
6004    /// tight image would read padding as values. F16 values are widened with
6005    /// `half::f16::to_f64` before the comparison.
6006    #[cfg(all(target_os = "windows", feature = "opengl"))]
6007    fn compare_float_images(a: &TensorDyn, b: &TensorDyn, tol: f64, what: &str) {
6008        assert_eq!(a.format(), b.format(), "{what}: pixel formats differ");
6009        assert_eq!(a.dtype(), b.dtype(), "{what}: dtypes differ");
6010        assert_eq!(a.shape(), b.shape(), "{what}: shapes differ");
6011        let values = |t: &TensorDyn| -> Vec<f64> {
6012            let elems: usize = t.shape().iter().product();
6013            match t.dtype() {
6014                DType::F32 => {
6015                    let typed = t.as_typed::<f32>().expect("F32 image");
6016                    let mut bytes = vec![0u8; elems * 4];
6017                    typed
6018                        .copy_to_flat(&mut bytes)
6019                        .expect("compact the padded rows");
6020                    bytes
6021                        .as_chunks::<4>()
6022                        .0
6023                        .iter()
6024                        .map(|c| f32::from_le_bytes(*c) as f64)
6025                        .collect()
6026                }
6027                DType::F16 => {
6028                    let typed = t.as_typed::<half::f16>().expect("F16 image");
6029                    let mut bytes = vec![0u8; elems * 2];
6030                    typed
6031                        .copy_to_flat(&mut bytes)
6032                        .expect("compact the padded rows");
6033                    bytes
6034                        .as_chunks::<2>()
6035                        .0
6036                        .iter()
6037                        .map(|c| half::f16::from_le_bytes(*c).to_f64())
6038                        .collect()
6039                }
6040                other => panic!("{what}: {other:?} is not a float dtype"),
6041            }
6042        };
6043        let (av, bv) = (values(a), values(b));
6044        assert_eq!(av.len(), bv.len(), "{what}: element counts differ");
6045        for (i, (x, y)) in av.iter().zip(bv.iter()).enumerate() {
6046            assert!(
6047                (x - y).abs() <= tol,
6048                "{what}: element {i} differs by {} (gpu {x}, cpu {y}, tol {tol})",
6049                (x - y).abs()
6050            );
6051        }
6052    }
6053
6054    /// The float PBO readback has to land each destination row at the
6055    /// destination's own pitch, exactly as the u8 PBO readback does.
6056    ///
6057    /// The packed render target's rows ARE the destination's rows -- `(W/4,
6058    /// 3H)` RGBA16F texels, one plane row per surface row, for
6059    /// `PlanarRgb`/F16; `(W*3, H)` R32F texels, one image row per surface
6060    /// row, for `Rgb`/F32 -- and the read was tight whatever pitch the
6061    /// destination declared. A pool tensor narrowed with `configure_image`
6062    /// that keeps the pool's pitch is exactly that destination, so every row
6063    /// after the first landed short.
6064    ///
6065    /// Both routes the pitch can take are covered. `POOL_W`'s pitch is a
6066    /// whole number of texels for both formats, which `GL_PACK_ROW_LENGTH`
6067    /// expresses; the F16 case adds a 4-byte pad, which an 8-byte texel does
6068    /// not divide, so those rows are read tight and spread afterwards. The
6069    /// tight stride is the control: it must still take the read it always
6070    /// did.
6071    ///
6072    /// The oracle is the same convert into a tight PBO of the same geometry.
6073    /// `compare_float_images` reads both sides through `copy_to_flat`, the
6074    /// tensor's own row compaction, so it compares logical elements and not
6075    /// mapping offsets.
6076    #[test]
6077    #[cfg(all(target_os = "windows", feature = "opengl"))]
6078    fn windows_padded_float_pbo_rows_land_at_the_declared_stride() {
6079        if !is_opengl_available() {
6080            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
6081            return;
6082        }
6083        const W: usize = 64;
6084        const H: usize = 32;
6085        const POOL_W: usize = 96;
6086
6087        let mut gpu = ImageProcessor::new().unwrap();
6088        let support = gpu.supported_render_dtypes();
6089        let src = synthetic_rgba(W, H);
6090
6091        // (format, dtype, supported, the pitches to declare)
6092        let cases: [(PixelFormat, DType, bool, &[usize]); 2] = [
6093            (
6094                PixelFormat::PlanarRgb,
6095                DType::F16,
6096                support.f16,
6097                // Tight plane row, the pool's pitch (a whole texel count),
6098                // and a 4-byte pad that no 8-byte texel divides.
6099                &[W * 2, POOL_W * 2, W * 2 + 4],
6100            ),
6101            (
6102                PixelFormat::Rgb,
6103                DType::F32,
6104                support.f32,
6105                &[W * 3 * 4, POOL_W * 3 * 4],
6106            ),
6107        ];
6108
6109        for (fmt, dtype, supported, strides) in cases {
6110            if !supported {
6111                crate::test_support::report_skip(&format!(
6112                    "{} - {fmt:?}/{dtype:?} not reported",
6113                    function!()
6114                ));
6115                continue;
6116            }
6117            let Some(gl) = gpu.opengl.as_ref() else {
6118                crate::test_support::report_skip(&format!("{} - no GL processor", function!()));
6119                return;
6120            };
6121            // The oracle: a tight PBO of the target geometry, same route.
6122            let mut reference = match gl.create_pbo_image_dtype(W, H, fmt, dtype) {
6123                Ok(t) => t,
6124                Err(e) => {
6125                    crate::test_support::report_skip(&format!(
6126                        "{} - no float PBO images: {e:?}",
6127                        function!()
6128                    ));
6129                    return;
6130                }
6131            };
6132            assert_eq!(
6133                reference.memory(),
6134                TensorMemory::Pbo,
6135                "{fmt:?}/{dtype:?}: the destination must be a PBO to reach this readback"
6136            );
6137            gpu.convert(
6138                &src,
6139                &mut reference,
6140                Rotation::None,
6141                Flip::None,
6142                Crop::default(),
6143            )
6144            .unwrap_or_else(|e| panic!("{fmt:?}/{dtype:?}: tight convert failed: {e}"));
6145
6146            for &stride in strides {
6147                let label = format!("{fmt:?}/{dtype:?} at a {stride} B pitch");
6148                // Allocate at the pool width, then narrow the geometry and
6149                // declare the pitch, as the u8 stride tests do.
6150                let mut padded = gpu
6151                    .opengl
6152                    .as_ref()
6153                    .expect("GL processor")
6154                    .create_pbo_image_dtype(POOL_W, H, fmt, dtype)
6155                    .unwrap_or_else(|e| panic!("{label}: pool PBO: {e:?}"));
6156                padded
6157                    .configure_image(W, H, fmt)
6158                    .unwrap_or_else(|e| panic!("{label}: narrow the pool tensor: {e}"));
6159                padded
6160                    .set_row_stride(stride)
6161                    .unwrap_or_else(|e| panic!("{label}: declare the pitch: {e}"));
6162                assert_eq!(padded.effective_row_stride(), Some(stride), "{label}");
6163
6164                let before = gpu.convert_fallback_count();
6165                gpu.convert(
6166                    &src,
6167                    &mut padded,
6168                    Rotation::None,
6169                    Flip::None,
6170                    Crop::default(),
6171                )
6172                .unwrap_or_else(|e| panic!("{label}: padded convert failed: {e}"));
6173                // A CPU fallback writes the destination with the stride-aware
6174                // CPU writers and would pass without the GL readback running.
6175                assert_eq!(
6176                    gpu.convert_fallback_count(),
6177                    before,
6178                    "{label}: fell back to the CPU backend, so the float PBO readback under test never ran"
6179                );
6180
6181                let tol = if dtype == DType::F16 {
6182                    1.0 / 1024.0
6183                } else {
6184                    1e-6
6185                };
6186                compare_float_images(&padded, &reference, tol, &label);
6187            }
6188        }
6189    }
6190
6191    /// Every float destination layout the GL backend reports it can render
6192    /// must render zero-copy into the texture and match the CPU converter
6193    /// element for element: F16 and F32 planar (three and four planes),
6194    /// interleaved float RGB, and float RGBA.
6195    ///
6196    /// The destination memory is requested explicitly so each layout is
6197    /// exercised as a texture whatever `create_image`'s own allocation
6198    /// policy would pick for it; the policy itself is covered by
6199    /// `windows_create_image_prefers_textures_and_convert_with_fence_returns_a_set_event`.
6200    #[test]
6201    #[cfg(all(target_os = "windows", feature = "opengl"))]
6202    fn windows_float_destinations_match_the_cpu_converter_for_every_layout() {
6203        if !is_opengl_available() {
6204            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
6205            return;
6206        }
6207        if !edgefirst_tensor::is_gpu_buffer_available() {
6208            crate::test_support::report_skip(&format!(
6209                "{} - no zero-copy GPU buffers",
6210                function!()
6211            ));
6212            return;
6213        }
6214        let mut gpu = ImageProcessor::new().unwrap();
6215        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
6216            backend: ComputeBackend::Cpu,
6217            ..Default::default()
6218        })
6219        .unwrap();
6220        let support = gpu.supported_render_dtypes();
6221        eprintln!("{}: supported_render_dtypes = {support:?}", function!());
6222        let src = synthetic_rgba(640, 480);
6223        for (fmt, dtype, need) in [
6224            (PixelFormat::PlanarRgb, DType::F16, support.f16),
6225            (PixelFormat::PlanarRgb, DType::F32, support.f32),
6226            (PixelFormat::PlanarRgba, DType::F16, support.f16),
6227            (PixelFormat::PlanarRgba, DType::F32, support.f32),
6228            (PixelFormat::Rgb, DType::F16, support.f16),
6229            (PixelFormat::Rgb, DType::F32, support.f32),
6230            (PixelFormat::Rgba, DType::F16, support.f16),
6231            (PixelFormat::Rgba, DType::F32, support.f32),
6232        ] {
6233            if !need {
6234                eprintln!("{}: {fmt:?}/{dtype:?} not reported", function!());
6235                continue;
6236            }
6237            let mut dst_gpu = gpu
6238                .create_image(
6239                    640,
6240                    480,
6241                    fmt,
6242                    dtype,
6243                    Some(TensorMemory::DmaBuf),
6244                    edgefirst_tensor::CpuAccess::ReadWrite,
6245                )
6246                .unwrap();
6247            assert_eq!(
6248                dst_gpu.memory(),
6249                TensorMemory::DmaBuf,
6250                "{fmt:?}/{dtype:?} must be a texture"
6251            );
6252            let mut dst_cpu = TensorDyn::image(
6253                640,
6254                480,
6255                fmt,
6256                dtype,
6257                Some(TensorMemory::Mem),
6258                edgefirst_tensor::CpuAccess::ReadWrite,
6259            )
6260            .unwrap();
6261            let before = gpu.convert_fallback_count();
6262            gpu.convert(
6263                &src,
6264                &mut dst_gpu,
6265                Rotation::None,
6266                Flip::None,
6267                Crop::default(),
6268            )
6269            .unwrap();
6270            cpu.convert(
6271                &src,
6272                &mut dst_cpu,
6273                Rotation::None,
6274                Flip::None,
6275                Crop::default(),
6276            )
6277            .unwrap();
6278            assert_eq!(
6279                gpu.convert_fallback_count(),
6280                before,
6281                "{fmt:?}/{dtype:?} fell back to CPU"
6282            );
6283            let tol = if dtype == DType::F16 {
6284                1.0 / 1024.0
6285            } else {
6286                1e-6
6287            };
6288            compare_float_images(&dst_gpu, &dst_cpu, tol, &format!("{fmt:?}/{dtype:?}"));
6289        }
6290    }
6291
6292    // The tests below wait on a Windows completion fence directly, so they
6293    // need the raw wait call — no crate in this workspace wraps it.
6294    #[cfg(target_os = "windows")]
6295    #[link(name = "kernel32")]
6296    extern "system" {
6297        fn WaitForSingleObject(handle: *mut std::ffi::c_void, milliseconds: u32) -> u32;
6298    }
6299
6300    /// A batch of deferred converts records one distinct fence value per
6301    /// tile and submits once, at `flush`.
6302    ///
6303    /// The values are what a GPU consumer waits on, so they have to stay
6304    /// per-destination and strictly increasing; the submission is what
6305    /// batching exists to spare, so there must be exactly one of it. The
6306    /// destinations are compared against the same convert done standalone,
6307    /// which is what proves the deferred submission did not lose a tile.
6308    #[cfg(all(target_os = "windows", feature = "opengl"))]
6309    #[test]
6310    fn windows_deferred_batch_records_one_value_per_tile_and_submits_once() {
6311        use edgefirst_tensor::CpuAccess;
6312
6313        if !edgefirst_tensor::is_gpu_buffer_available() {
6314            crate::test_support::report_skip(&format!(
6315                "{} - no zero-copy GPU buffers",
6316                function!()
6317            ));
6318            return;
6319        }
6320        let mut p = match ImageProcessor::new() {
6321            Ok(p) => p,
6322            Err(e) => {
6323                crate::test_support::report_skip(&format!(
6324                    "{} - ImageProcessor init failed ({e:?})",
6325                    function!()
6326                ));
6327                return;
6328            }
6329        };
6330        let (w, h, tiles) = (640usize, 480usize, 4usize);
6331        let src = synthetic_rgba(w, h);
6332        let mut dsts: Vec<TensorDyn> = (0..tiles)
6333            .map(|_| {
6334                p.create_image(w, h, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
6335                    .unwrap()
6336            })
6337            .collect();
6338        if dsts[0].memory() != TensorMemory::DmaBuf {
6339            crate::test_support::report_skip(&format!(
6340                "{} - destinations are not textures",
6341                function!()
6342            ));
6343            return;
6344        }
6345        for dst in &mut dsts {
6346            p.convert_deferred(&src, dst, Rotation::None, Flip::None, Crop::default())
6347                .unwrap();
6348        }
6349        let values: Vec<u64> = dsts
6350            .iter()
6351            .map(|d| {
6352                d.gpu_completion()
6353                    .unwrap()
6354                    .expect("each deferred convert records its own completion")
6355                    .value
6356            })
6357            .collect();
6358        for pair in values.windows(2) {
6359            assert!(
6360                pair[1] > pair[0],
6361                "the batch allocated {values:?}, which is not one increasing value per tile"
6362            );
6363        }
6364        p.flush().unwrap();
6365        // The one submission the batch owed has happened, so every recorded
6366        // value is reachable.
6367        let dev = edgefirst_tensor::d3d11::device().unwrap();
6368        let last = *values.last().unwrap();
6369        let ev = dev.event_for(last).unwrap();
6370        // SAFETY: `ev` owns a live Win32 event handle for the duration of
6371        // this call.
6372        assert_eq!(
6373            unsafe {
6374                WaitForSingleObject(
6375                    std::os::windows::io::AsRawHandle::as_raw_handle(&ev) as _,
6376                    5000,
6377                )
6378            },
6379            0,
6380            "the batch's newest value {last} never arrived after flush"
6381        );
6382
6383        let mut solo = p
6384            .create_image(w, h, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
6385            .unwrap();
6386        p.convert(&src, &mut solo, Rotation::None, Flip::None, Crop::default())
6387            .unwrap();
6388        // Read through the tensor's own compaction: a Windows texture's
6389        // staging rows are pitch-padded, so a flat read of the mapping would
6390        // compare padding as pixels.
6391        let flat = |img: &TensorDyn| -> Vec<u8> {
6392            let t = img.as_typed::<u8>().expect("u8 image");
6393            let mut out = vec![0u8; t.shape().iter().product::<usize>()];
6394            t.copy_to_flat(&mut out).expect("compact the padded rows");
6395            out
6396        };
6397        let want = flat(&solo);
6398        for (i, dst) in dsts.iter().enumerate() {
6399            assert_eq!(
6400                flat(dst),
6401                want,
6402                "tile {i} does not match a standalone convert"
6403            );
6404        }
6405    }
6406
6407    /// A processor dropped with a batch still open submits what that batch
6408    /// recorded.
6409    ///
6410    /// The values are already published on destinations that outlive the
6411    /// processor, so a consumer may be waiting on one; a deferred signal that
6412    /// is never submitted is a wait nothing can satisfy. `flush` is what
6413    /// normally pays it, and the whole point here is that the caller never
6414    /// calls it.
6415    #[cfg(all(target_os = "windows", feature = "opengl"))]
6416    #[test]
6417    fn windows_dropping_a_processor_submits_an_open_batch() {
6418        use edgefirst_tensor::CpuAccess;
6419
6420        if !edgefirst_tensor::is_gpu_buffer_available() {
6421            crate::test_support::report_skip(&format!(
6422                "{} - no zero-copy GPU buffers",
6423                function!()
6424            ));
6425            return;
6426        }
6427        let src = synthetic_rgba(320, 240);
6428        let recorded = {
6429            let mut p = match ImageProcessor::new() {
6430                Ok(p) => p,
6431                Err(e) => {
6432                    crate::test_support::report_skip(&format!(
6433                        "{} - init failed ({e:?})",
6434                        function!()
6435                    ));
6436                    return;
6437                }
6438            };
6439            let mut dst = p
6440                .create_image(
6441                    320,
6442                    240,
6443                    PixelFormat::Rgba,
6444                    DType::U8,
6445                    None,
6446                    CpuAccess::Read,
6447                )
6448                .unwrap();
6449            if dst.memory() != TensorMemory::DmaBuf {
6450                crate::test_support::report_skip(&format!(
6451                    "{} - destination is not a texture",
6452                    function!()
6453                ));
6454                return;
6455            }
6456            p.convert_deferred(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
6457                .unwrap();
6458            // No `flush`: the drop below is what has to settle the batch.
6459            dst.gpu_completion()
6460                .unwrap()
6461                .expect("the deferred convert recorded a completion")
6462                .value
6463        };
6464
6465        let dev = edgefirst_tensor::d3d11::device().unwrap();
6466        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
6467        while dev.completed_value() < recorded {
6468            assert!(
6469                std::time::Instant::now() < deadline,
6470                "the dropped processor never submitted its batch: fence at {}, recorded {recorded}",
6471                dev.completed_value()
6472            );
6473            std::thread::yield_now();
6474        }
6475    }
6476
6477    /// After Task B5, `create_image(.., None, ..)` reaches the Windows
6478    /// Dma-first arm (a D3D11 texture, not a PBO or `Mem` tensor), and
6479    /// `convert_with_fence` is available on every platform: on Windows it
6480    /// returns a manual-reset event set when the GPU work completes.
6481    #[cfg(all(target_os = "windows", feature = "opengl"))]
6482    #[test]
6483    fn windows_create_image_prefers_textures_and_convert_with_fence_returns_a_set_event() {
6484        use edgefirst_tensor::CpuAccess;
6485        use std::os::windows::io::AsRawHandle;
6486
6487        if !edgefirst_tensor::is_gpu_buffer_available() {
6488            crate::test_support::report_skip(&format!(
6489                "{} - no zero-copy GPU buffers",
6490                function!()
6491            ));
6492            return;
6493        }
6494        let mut p = ImageProcessor::new().unwrap();
6495        let src = synthetic_rgba(320, 240);
6496        let mut dst = p
6497            .create_image(
6498                320,
6499                240,
6500                PixelFormat::Rgba,
6501                DType::U8,
6502                None,
6503                CpuAccess::Read,
6504            )
6505            .unwrap();
6506        // Without a GL backend (CI's no-ANGLE coverage pass) `create_image`
6507        // answers host memory and there is no fence to wait on; the lanes
6508        // that set HAL_TEST_REQUIRE_GL=1 still hold the texture-first claim.
6509        if dst.memory() != TensorMemory::DmaBuf
6510            && !std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1")
6511        {
6512            crate::test_support::report_skip(&format!(
6513                "{} - create_image gave {:?}, not a texture (no GL backend)",
6514                function!(),
6515                dst.memory()
6516            ));
6517            return;
6518        }
6519        assert_eq!(
6520            dst.memory(),
6521            TensorMemory::DmaBuf,
6522            "create_image(None) must prefer a texture on Windows"
6523        );
6524        // Read before the wait: the recorded value must be the one the fence
6525        // names, not an older point the destination happened to carry.
6526        let before = dst.gpu_completion().unwrap().map(|c| c.value);
6527        let fence = p
6528            .convert_with_fence(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
6529            .unwrap()
6530            .expect("Windows exports a fence");
6531        let recorded = dst
6532            .gpu_completion()
6533            .unwrap()
6534            .expect("the convert recorded a completion")
6535            .value;
6536        assert!(
6537            before.is_none_or(|b| recorded > b),
6538            "the fenced convert recorded a newer value than {before:?}"
6539        );
6540        // One flush and one signal for the whole call: the value the
6541        // destination carries is the newest the device has allocated, so
6542        // nothing was signalled after `record_completion`. Two signals (the
6543        // shape this replaced) would leave `last_signalled` one past it.
6544        assert_eq!(
6545            edgefirst_tensor::d3d11::device().unwrap().last_signalled(),
6546            recorded,
6547            "a fenced convert must signal exactly once"
6548        );
6549        // SAFETY: `fence` owns a live Win32 event handle returned by
6550        // `convert_with_fence`; WaitForSingleObject only reads the handle,
6551        // which stays valid for the duration of this call because `fence`
6552        // is not dropped until after it.
6553        assert_eq!(
6554            unsafe { WaitForSingleObject(fence.as_raw_handle() as _, 5000) },
6555            0,
6556            "fence was not set within 5 seconds"
6557        );
6558        // The event and the destination's own completion name the same
6559        // value, so a consumer may wait on either: the event fired, and the
6560        // device's completed value has reached the value `dst` reports.
6561        assert!(
6562            edgefirst_tensor::d3d11::device().unwrap().completed_value() >= recorded,
6563            "the event fired, so the recorded value {recorded} is complete"
6564        );
6565        assert_eq!(
6566            dst.gpu_completion().unwrap().map(|c| c.value),
6567            Some(recorded),
6568            "the destination still reports the value the event waited on"
6569        );
6570        let mut expected = TensorDyn::image(
6571            320,
6572            240,
6573            PixelFormat::Rgba,
6574            DType::U8,
6575            Some(TensorMemory::Mem),
6576            CpuAccess::ReadWrite,
6577        )
6578        .unwrap();
6579        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
6580            backend: ComputeBackend::Cpu,
6581            ..Default::default()
6582        })
6583        .unwrap();
6584        cpu.convert(
6585            &src,
6586            &mut expected,
6587            Rotation::None,
6588            Flip::None,
6589            Crop::default(),
6590        )
6591        .unwrap();
6592        compare_images(&dst, &expected, 0.0, "fenced convert");
6593    }
6594
6595    /// A Windows GL test's destination must be a texture; without a GL backend
6596    /// (CI's no-ANGLE coverage pass) `create_image` answers host memory and the
6597    /// test skips, unless the lane set HAL_TEST_REQUIRE_GL=1, which keeps the
6598    /// texture-first claim a hard assertion.
6599    #[cfg(all(target_os = "windows", feature = "opengl"))]
6600    fn texture_or_skip(t: &TensorDyn, test: &str) -> bool {
6601        if t.memory() == TensorMemory::DmaBuf {
6602            return true;
6603        }
6604        if std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1") {
6605            panic!(
6606                "{test}: create_image(None) must prefer a texture on Windows, got {:?}",
6607                t.memory()
6608            );
6609        }
6610        crate::test_support::report_skip(&format!(
6611            "{test} - create_image gave {:?}, not a texture (no GL backend)",
6612            t.memory()
6613        ));
6614        false
6615    }
6616
6617    /// A draw into a texture destination records a completion on it the way
6618    /// a convert does, so a device consumer can wait on the drawn frame. The
6619    /// destination is `CpuAccess::Read`: only the GL backend can write it,
6620    /// so a CPU fallback fails the draw outright instead of leaving the
6621    /// completion empty.
6622    #[cfg(all(target_os = "windows", feature = "opengl"))]
6623    #[test]
6624    fn windows_draw_decoded_masks_into_a_texture_records_a_completion() {
6625        use edgefirst_tensor::{CpuAccess, Segmentation};
6626        use ndarray::Array3;
6627
6628        if !edgefirst_tensor::is_gpu_buffer_available() {
6629            crate::test_support::report_skip(&format!(
6630                "{} - no zero-copy GPU buffers",
6631                function!()
6632            ));
6633            return;
6634        }
6635        let mut p = ImageProcessor::new().unwrap();
6636        let mut dst = p
6637            .create_image(64, 64, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
6638            .unwrap();
6639        if !texture_or_skip(&dst, function!()) {
6640            return;
6641        }
6642        assert!(
6643            dst.gpu_completion().unwrap().is_none(),
6644            "nothing has written this texture yet"
6645        );
6646
6647        let detect = DetectBox {
6648            bbox: [0.25, 0.25, 0.75, 0.75].into(),
6649            score: 0.99,
6650            label: 0,
6651        };
6652        let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
6653        let seg = Segmentation {
6654            segmentation: edgefirst_tensor::Tensor::from_arrayview3(seg_arr.view())
6655                .unwrap()
6656                .into(),
6657            xmin: 0.25,
6658            ymin: 0.25,
6659            xmax: 0.75,
6660            ymax: 0.75,
6661        };
6662        p.draw_decoded_masks(&mut dst, &[detect], &[seg], MaskOverlay::default())
6663            .expect("draw into a read-only texture");
6664
6665        let completion = dst
6666            .gpu_completion()
6667            .unwrap()
6668            .expect("the draw recorded no completion on dst");
6669        assert!(completion.value > 0, "completion value must be non-zero");
6670
6671        // The drawn frame is read back through the texture's staging copy;
6672        // `copy_to_flat` compacts its padded rows into tight RGBA pixels and
6673        // needs only a read mapping, which the read-only texture allows.
6674        // The corner is cleared and the box centre carries the mask colour.
6675        let mut flat = vec![0u8; 64 * 64 * 4];
6676        dst.as_typed::<u8>()
6677            .unwrap()
6678            .copy_to_flat(&mut flat)
6679            .expect("read the drawn texture");
6680        let px = |x: usize, y: usize| -> [u8; 4] {
6681            let o = (y * 64 + x) * 4;
6682            [flat[o], flat[o + 1], flat[o + 2], flat[o + 3]]
6683        };
6684        assert_eq!(px(2, 2), [0, 0, 0, 0], "corner (2,2) was not cleared");
6685        assert_ne!(px(32, 32), [0, 0, 0, 0], "centre (32,32) was not coloured");
6686    }
6687
6688    /// The full-GPU proto draw (forced GL, no CPU materialize) records a
6689    /// completion on a texture destination too.
6690    #[cfg(all(target_os = "windows", feature = "opengl"))]
6691    #[test]
6692    fn windows_draw_proto_masks_into_a_texture_records_a_completion() {
6693        use edgefirst_tensor::{CpuAccess, Tensor};
6694
6695        if !edgefirst_tensor::is_gpu_buffer_available() {
6696            crate::test_support::report_skip(&format!(
6697                "{} - no zero-copy GPU buffers",
6698                function!()
6699            ));
6700            return;
6701        }
6702        // Forcing OpenGL fails outright without a GL backend (CI's no-ANGLE
6703        // coverage pass); that is the same skip the texture guard below gives,
6704        // unless the lane set HAL_TEST_REQUIRE_GL=1.
6705        let mut p = match with_force_backend(Some("opengl"), ImageProcessor::new) {
6706            Ok(p) => p,
6707            Err(e) if !std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1") => {
6708                crate::test_support::report_skip(&format!(
6709                    "{} - no OpenGL backend: {e}",
6710                    function!()
6711                ));
6712                return;
6713            }
6714            Err(e) => panic!("forced OpenGL backend unavailable: {e}"),
6715        };
6716        let mut dst = p
6717            .create_image(64, 64, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
6718            .unwrap();
6719        if !texture_or_skip(&dst, function!()) {
6720            return;
6721        }
6722
6723        let detect = DetectBox {
6724            bbox: [0.25, 0.25, 0.75, 0.75].into(),
6725            score: 0.99,
6726            label: 0,
6727        };
6728        // One prototype channel of ones and a positive coefficient: every
6729        // pixel of the box is foreground.
6730        let proto = ProtoData {
6731            mask_coefficients: TensorDyn::F32(
6732                Tensor::<f32>::from_slice(&[1.0_f32], &[1, 1]).unwrap(),
6733            ),
6734            protos: TensorDyn::F32(
6735                Tensor::<f32>::from_slice(&vec![1.0_f32; 8 * 8], &[8, 8, 1]).unwrap(),
6736            ),
6737            layout: ProtoLayout::Nhwc,
6738        };
6739        p.draw_proto_masks(&mut dst, &[detect], &proto, MaskOverlay::default())
6740            .expect("forced-GL proto draw into a read-only texture");
6741
6742        let completion = dst
6743            .gpu_completion()
6744            .unwrap()
6745            .expect("the proto draw recorded no completion on dst");
6746        assert!(completion.value > 0, "completion value must be non-zero");
6747    }
6748
6749    /// An empty draw with a host-memory background onto a texture
6750    /// destination leaves the background in the texture, rows in order.
6751    /// The gradient pattern makes a missing or vertically flipped base
6752    /// layer differ from the expected image.
6753    #[cfg(all(target_os = "windows", feature = "opengl"))]
6754    #[test]
6755    fn windows_draw_decoded_masks_composites_a_host_background_onto_a_texture() {
6756        use edgefirst_tensor::CpuAccess;
6757
6758        if !edgefirst_tensor::is_gpu_buffer_available() {
6759            crate::test_support::report_skip(&format!(
6760                "{} - no zero-copy GPU buffers",
6761                function!()
6762            ));
6763            return;
6764        }
6765        let mut p = ImageProcessor::new().unwrap();
6766        let bg = synthetic_rgba(64, 48);
6767        let mut dst = p
6768            .create_image(64, 48, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
6769            .unwrap();
6770        if !texture_or_skip(&dst, function!()) {
6771            return;
6772        }
6773        p.draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::new().with_background(&bg))
6774            .expect("empty draw with a host background onto a texture");
6775        assert_pixels_identical(&dst, &bg, "host background onto a texture");
6776        assert!(dst.gpu_completion().unwrap().is_some());
6777    }
6778
6779    /// The same draw with the background in a texture of its own: the
6780    /// base layer is sampled from the background texture directly.
6781    #[cfg(all(target_os = "windows", feature = "opengl"))]
6782    #[test]
6783    fn windows_draw_decoded_masks_composites_a_texture_background_onto_a_texture() {
6784        use edgefirst_tensor::CpuAccess;
6785
6786        if !edgefirst_tensor::is_gpu_buffer_available() {
6787            crate::test_support::report_skip(&format!(
6788                "{} - no zero-copy GPU buffers",
6789                function!()
6790            ));
6791            return;
6792        }
6793        let mut p = ImageProcessor::new().unwrap();
6794        let expected = synthetic_rgba(64, 48);
6795        let bg = p
6796            .create_image(
6797                64,
6798                48,
6799                PixelFormat::Rgba,
6800                DType::U8,
6801                None,
6802                CpuAccess::ReadWrite,
6803            )
6804            .unwrap();
6805        if !texture_or_skip(&bg, function!()) {
6806            return;
6807        }
6808        fill_synthetic_rgba(&bg);
6809        let mut dst = p
6810            .create_image(64, 48, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
6811            .unwrap();
6812        assert_eq!(dst.memory(), TensorMemory::DmaBuf);
6813        p.draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::new().with_background(&bg))
6814            .expect("empty draw with a texture background onto a texture");
6815        assert_pixels_identical(&dst, &expected, "texture background onto a texture");
6816        assert!(dst.gpu_completion().unwrap().is_some());
6817    }
6818
6819    /// A draw into a `view()` of a texture fills that band and leaves its
6820    /// sibling alone. The GL draw declines a sub-region destination
6821    /// (`check_draw_dst_placement`) and the CPU backend, which offsets into
6822    /// the parent, takes it; the zero-copy arm would otherwise have cleared
6823    /// the parent's first rows instead of the view's.
6824    #[cfg(all(target_os = "windows", feature = "opengl"))]
6825    #[test]
6826    fn windows_draw_decoded_masks_into_a_texture_view_fills_only_its_band() {
6827        use edgefirst_tensor::CpuAccess;
6828
6829        if !edgefirst_tensor::is_gpu_buffer_available() {
6830            crate::test_support::report_skip(&format!(
6831                "{} - no zero-copy GPU buffers",
6832                function!()
6833            ));
6834            return;
6835        }
6836        let (w, h) = (32usize, 64usize);
6837        let mut p = ImageProcessor::new().unwrap();
6838        let parent = p
6839            .create_image(
6840                w,
6841                h,
6842                PixelFormat::Rgba,
6843                DType::U8,
6844                None,
6845                CpuAccess::ReadWrite,
6846            )
6847            .unwrap();
6848        if !texture_or_skip(&parent, function!()) {
6849            return;
6850        }
6851        fill_synthetic_rgba(&parent);
6852
6853        // The lower band only. An empty draw with no background clears its
6854        // destination, so a write at the parent's origin would show up as
6855        // cleared pixels in the upper band.
6856        let mut band = parent
6857            .view(edgefirst_tensor::Region::new(0, h / 2, w, h / 2))
6858            .unwrap();
6859        p.draw_decoded_masks(&mut band, &[], &[], MaskOverlay::default())
6860            .expect("empty draw into a texture view");
6861        drop(band);
6862
6863        let mut flat = vec![0u8; w * h * 4];
6864        parent
6865            .as_typed::<u8>()
6866            .unwrap()
6867            .copy_to_flat(&mut flat)
6868            .expect("read the parent texture");
6869        for y in 0..h {
6870            for x in 0..w {
6871                let o = (y * w + x) * 4;
6872                let got = [flat[o], flat[o + 1], flat[o + 2], flat[o + 3]];
6873                let want = if y < h / 2 {
6874                    [x as u8, y as u8, (x + y) as u8, 255]
6875                } else {
6876                    [0, 0, 0, 0]
6877                };
6878                assert_eq!(got, want, "pixel ({x},{y})");
6879            }
6880        }
6881
6882        // The GL decline is what sent the draw above to the CPU: with GL
6883        // forced there is no CPU arm to fall through to, so it surfaces.
6884        let mut forced = with_force_backend(Some("opengl"), ImageProcessor::new).unwrap();
6885        let mut band = parent
6886            .view(edgefirst_tensor::Region::new(0, h / 2, w, h / 2))
6887            .unwrap();
6888        let err = forced
6889            .draw_decoded_masks(&mut band, &[], &[], MaskOverlay::default())
6890            .expect_err("forced GL must decline a view destination");
6891        assert!(
6892            matches!(err, Error::NotSupported(_)),
6893            "expected NotSupported, got {err:?}"
6894        );
6895    }
6896
6897    /// `set_class_colors` reaches the CPU backend even when GL accepts it, so
6898    /// a draw GL declines still renders the caller's palette. The view
6899    /// destination is the reachable decline: `check_draw_dst_placement`
6900    /// refuses it and the CPU backend draws the mask.
6901    #[cfg(all(target_os = "windows", feature = "opengl"))]
6902    #[test]
6903    fn windows_class_colors_reach_the_cpu_backend_behind_a_declined_gl_draw() {
6904        use edgefirst_tensor::{CpuAccess, Segmentation};
6905        use ndarray::Array3;
6906
6907        if !edgefirst_tensor::is_gpu_buffer_available() {
6908            crate::test_support::report_skip(&format!(
6909                "{} - no zero-copy GPU buffers",
6910                function!()
6911            ));
6912            return;
6913        }
6914        const CUSTOM: [u8; 4] = [7, 200, 13, 255];
6915        assert_ne!(
6916            CUSTOM[..3],
6917            DEFAULT_COLORS_U8[0][..3],
6918            "the custom colour must differ from the default palette's, or \
6919             this test cannot tell them apart"
6920        );
6921
6922        let (w, h) = (32usize, 64usize);
6923        let mut p = ImageProcessor::new().unwrap();
6924        let parent = p
6925            .create_image(
6926                w,
6927                h,
6928                PixelFormat::Rgba,
6929                DType::U8,
6930                None,
6931                CpuAccess::ReadWrite,
6932            )
6933            .unwrap();
6934        // Without a GL backend (CI's no-ANGLE coverage pass) `create_image`
6935        // answers host memory and nothing declines the draw; the test needs
6936        // the texture-backed parent so the view is the reachable decline. The
6937        // lanes that set HAL_TEST_REQUIRE_GL=1 still require the texture.
6938        if parent.memory() != TensorMemory::DmaBuf
6939            && !std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1")
6940        {
6941            crate::test_support::report_skip(&format!(
6942                "{} - create_image gave {:?}, not a texture (no GL backend)",
6943                function!(),
6944                parent.memory()
6945            ));
6946            return;
6947        }
6948        p.set_class_colors(&[CUSTOM]).unwrap();
6949
6950        let detect = DetectBox {
6951            bbox: [0.25, 0.25, 0.75, 0.75].into(),
6952            score: 0.99,
6953            label: 0,
6954        };
6955        let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
6956        let seg = Segmentation {
6957            segmentation: edgefirst_tensor::Tensor::from_arrayview3(seg_arr.view())
6958                .unwrap()
6959                .into(),
6960            xmin: 0.25,
6961            ymin: 0.25,
6962            xmax: 0.75,
6963            ymax: 0.75,
6964        };
6965        let mut band = parent
6966            .view(edgefirst_tensor::Region::new(0, h / 2, w, h / 2))
6967            .unwrap();
6968        p.draw_decoded_masks(&mut band, &[detect], &[seg], MaskOverlay::default())
6969            .expect("draw into a texture view");
6970        drop(band);
6971
6972        let mut flat = vec![0u8; w * h * 4];
6973        parent
6974            .as_typed::<u8>()
6975            .unwrap()
6976            .copy_to_flat(&mut flat)
6977            .expect("read the parent texture");
6978        // The band's centre, inside the box. The mask blend writes RGB only;
6979        // alpha is whatever the base layer left, which is the clear.
6980        let o = ((h / 2 + h / 4) * w + w / 2) * 4;
6981        assert_eq!(
6982            &flat[o..o + 3],
6983            &CUSTOM[..3],
6984            "the CPU backend drew the default palette, so it never received \
6985             set_class_colors"
6986        );
6987    }
6988
6989    /// Two independently-created `ImageProcessor`s, each converting into its
6990    /// own texture destination in lockstep, must not bleed GL state into
6991    /// each other across 200 iterations (the destination created by
6992    /// `create_image(None)` is the texture arm this task adds).
6993    #[cfg(all(target_os = "windows", feature = "opengl"))]
6994    #[test]
6995    fn two_processors_alternate_on_texture_destinations_without_state_bleed() {
6996        use edgefirst_tensor::CpuAccess;
6997
6998        if !edgefirst_tensor::is_gpu_buffer_available() {
6999            crate::test_support::report_skip(&format!(
7000                "{} - no zero-copy GPU buffers",
7001                function!()
7002            ));
7003            return;
7004        }
7005        let mut a = ImageProcessor::new().unwrap();
7006        let mut b = ImageProcessor::new().unwrap();
7007        let src_a = synthetic_rgba(512, 512);
7008        let src_b = synthetic_rgba(256, 256);
7009        let mut dst_a = a
7010            .create_image(
7011                512,
7012                512,
7013                PixelFormat::Rgba,
7014                DType::U8,
7015                None,
7016                CpuAccess::Read,
7017            )
7018            .unwrap();
7019        let mut dst_b = b
7020            .create_image(
7021                256,
7022                256,
7023                PixelFormat::Rgba,
7024                DType::U8,
7025                None,
7026                CpuAccess::Read,
7027            )
7028            .unwrap();
7029        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
7030            backend: ComputeBackend::Cpu,
7031            ..Default::default()
7032        })
7033        .unwrap();
7034        let mut ref_a = TensorDyn::image(
7035            512,
7036            512,
7037            PixelFormat::Rgba,
7038            DType::U8,
7039            Some(TensorMemory::Mem),
7040            CpuAccess::ReadWrite,
7041        )
7042        .unwrap();
7043        let mut ref_b = TensorDyn::image(
7044            256,
7045            256,
7046            PixelFormat::Rgba,
7047            DType::U8,
7048            Some(TensorMemory::Mem),
7049            CpuAccess::ReadWrite,
7050        )
7051        .unwrap();
7052        cpu.convert(
7053            &src_a,
7054            &mut ref_a,
7055            Rotation::None,
7056            Flip::None,
7057            Crop::default(),
7058        )
7059        .unwrap();
7060        cpu.convert(
7061            &src_b,
7062            &mut ref_b,
7063            Rotation::None,
7064            Flip::None,
7065            Crop::default(),
7066        )
7067        .unwrap();
7068        for _ in 0..200 {
7069            a.convert(
7070                &src_a,
7071                &mut dst_a,
7072                Rotation::None,
7073                Flip::None,
7074                Crop::default(),
7075            )
7076            .unwrap();
7077            b.convert(
7078                &src_b,
7079                &mut dst_b,
7080                Rotation::None,
7081                Flip::None,
7082                Crop::default(),
7083            )
7084            .unwrap();
7085            compare_images(&dst_a, &ref_a, 0.0, "processor A");
7086            compare_images(&dst_b, &ref_b, 0.0, "processor B");
7087        }
7088    }
7089
7090    #[test]
7091    #[cfg(any(
7092        target_os = "linux",
7093        target_os = "macos",
7094        target_os = "ios",
7095        target_os = "android",
7096        target_os = "windows"
7097    ))]
7098    #[cfg(feature = "opengl")]
7099    fn test_opengl_all_rgba() {
7100        if !is_opengl_available() {
7101            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
7102            return;
7103        }
7104
7105        let dst_width = 640;
7106        let dst_height = 640;
7107        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
7108
7109        let mut cpu_converter = CPUProcessor::new();
7110
7111        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
7112
7113        // Every backing this host can allocate: Shm is Unix-only, and the
7114        // zero-copy slot is DMA-BUF on Linux / IOSurface on macOS (nothing on
7115        // Windows yet).
7116        let mut mem = vec![None, Some(TensorMemory::Mem)];
7117        if edgefirst_tensor::is_shm_available() {
7118            mem.push(Some(TensorMemory::Shm));
7119        }
7120        if edgefirst_tensor::is_gpu_buffer_available() {
7121            mem.push(Some(TensorMemory::DmaBuf));
7122        }
7123        let crop = Crop::new().with_source(Some(Region::new(50, 120, 1024, 576)));
7124        for m in mem {
7125            let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), m).unwrap();
7126            let src_dyn = src;
7127
7128            for rot in [
7129                Rotation::None,
7130                Rotation::Clockwise90,
7131                Rotation::Rotate180,
7132                Rotation::CounterClockwise90,
7133            ] {
7134                for flip in [Flip::None, Flip::Horizontal, Flip::Vertical] {
7135                    let cpu_dst = TensorDyn::image(
7136                        dst_width,
7137                        dst_height,
7138                        PixelFormat::Rgba,
7139                        DType::U8,
7140                        m,
7141                        edgefirst_tensor::CpuAccess::ReadWrite,
7142                    )
7143                    .unwrap();
7144                    let gl_dst = TensorDyn::image(
7145                        dst_width,
7146                        dst_height,
7147                        PixelFormat::Rgba,
7148                        DType::U8,
7149                        m,
7150                        edgefirst_tensor::CpuAccess::ReadWrite,
7151                    )
7152                    .unwrap();
7153                    cpu_dst
7154                        .as_typed::<u8>()
7155                        .unwrap()
7156                        .map()
7157                        .unwrap()
7158                        .as_mut_slice()
7159                        .fill(114);
7160                    gl_dst
7161                        .as_typed::<u8>()
7162                        .unwrap()
7163                        .map()
7164                        .unwrap()
7165                        .as_mut_slice()
7166                        .fill(114);
7167
7168                    let mut cpu_dst_dyn = cpu_dst;
7169                    cpu_converter
7170                        .convert(&src_dyn, &mut cpu_dst_dyn, Rotation::None, Flip::None, crop)
7171                        .unwrap();
7172                    let cpu_dst = {
7173                        let mut __t = cpu_dst_dyn.into_u8().unwrap();
7174                        __t.set_format(PixelFormat::Rgba).unwrap();
7175                        TensorDyn::from(__t)
7176                    };
7177
7178                    let mut gl_dst_dyn = gl_dst;
7179                    gl_converter
7180                        .convert(&src_dyn, &mut gl_dst_dyn, Rotation::None, Flip::None, crop)
7181                        .map_err(|e| {
7182                            log::error!("error mem {m:?} rot {rot:?} error: {e:?}");
7183                            e
7184                        })
7185                        .unwrap();
7186                    let gl_dst = {
7187                        let mut __t = gl_dst_dyn.into_u8().unwrap();
7188                        __t.set_format(PixelFormat::Rgba).unwrap();
7189                        TensorDyn::from(__t)
7190                    };
7191
7192                    compare_images(
7193                        &gl_dst,
7194                        &cpu_dst,
7195                        0.98,
7196                        &format!("{} {:?} {:?}", function!(), rot, flip),
7197                    );
7198                }
7199            }
7200        }
7201    }
7202
7203    #[test]
7204    #[cfg(target_os = "linux")]
7205    fn test_cpu_rotate() {
7206        for rot in [
7207            Rotation::Clockwise90,
7208            Rotation::Rotate180,
7209            Rotation::CounterClockwise90,
7210        ] {
7211            test_cpu_rotate_(rot);
7212        }
7213    }
7214
7215    #[cfg(target_os = "linux")]
7216    fn test_cpu_rotate_(rot: Rotation) {
7217        // This test rotates the image 4 times and checks that the image was returned to
7218        // be the same Currently doesn't check if rotations actually rotated in
7219        // right direction
7220        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
7221
7222        let unchanged_src =
7223            crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
7224        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
7225
7226        let (dst_width, dst_height) = match rot {
7227            Rotation::None | Rotation::Rotate180 => (src.width().unwrap(), src.height().unwrap()),
7228            Rotation::Clockwise90 | Rotation::CounterClockwise90 => {
7229                (src.height().unwrap(), src.width().unwrap())
7230            }
7231        };
7232
7233        let cpu_dst = TensorDyn::image(
7234            dst_width,
7235            dst_height,
7236            PixelFormat::Rgba,
7237            DType::U8,
7238            None,
7239            edgefirst_tensor::CpuAccess::ReadWrite,
7240        )
7241        .unwrap();
7242        let mut cpu_converter = CPUProcessor::new();
7243
7244        // After rotating 4 times, the image should be the same as the original
7245
7246        let (result, src, cpu_dst) = convert_img(
7247            &mut cpu_converter,
7248            src,
7249            cpu_dst,
7250            rot,
7251            Flip::None,
7252            Crop::no_crop(),
7253        );
7254        result.unwrap();
7255
7256        let (result, cpu_dst, src) = convert_img(
7257            &mut cpu_converter,
7258            cpu_dst,
7259            src,
7260            rot,
7261            Flip::None,
7262            Crop::no_crop(),
7263        );
7264        result.unwrap();
7265
7266        let (result, src, cpu_dst) = convert_img(
7267            &mut cpu_converter,
7268            src,
7269            cpu_dst,
7270            rot,
7271            Flip::None,
7272            Crop::no_crop(),
7273        );
7274        result.unwrap();
7275
7276        let (result, _cpu_dst, src) = convert_img(
7277            &mut cpu_converter,
7278            cpu_dst,
7279            src,
7280            rot,
7281            Flip::None,
7282            Crop::no_crop(),
7283        );
7284        result.unwrap();
7285
7286        compare_images(&src, &unchanged_src, 0.98, function!());
7287    }
7288
7289    #[test]
7290    #[cfg(any(
7291        target_os = "linux",
7292        target_os = "macos",
7293        target_os = "ios",
7294        target_os = "android",
7295        target_os = "windows"
7296    ))]
7297    #[cfg(feature = "opengl")]
7298    fn test_opengl_rotate() {
7299        if !is_opengl_available() {
7300            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
7301            return;
7302        }
7303
7304        let size = (1280, 720);
7305        // Same backing list as `test_opengl_all_rgba`.
7306        let mut mem = vec![None, Some(TensorMemory::Mem)];
7307        if edgefirst_tensor::is_shm_available() {
7308            mem.push(Some(TensorMemory::Shm));
7309        }
7310        if edgefirst_tensor::is_gpu_buffer_available() {
7311            mem.push(Some(TensorMemory::DmaBuf));
7312        }
7313        for m in mem {
7314            for rot in [
7315                Rotation::Clockwise90,
7316                Rotation::Rotate180,
7317                Rotation::CounterClockwise90,
7318            ] {
7319                test_opengl_rotate_(size, rot, m);
7320            }
7321        }
7322    }
7323
7324    #[cfg(any(
7325        target_os = "linux",
7326        target_os = "macos",
7327        target_os = "ios",
7328        target_os = "android",
7329        target_os = "windows"
7330    ))]
7331    #[cfg(feature = "opengl")]
7332    fn test_opengl_rotate_(
7333        size: (usize, usize),
7334        rot: Rotation,
7335        tensor_memory: Option<TensorMemory>,
7336    ) {
7337        let (dst_width, dst_height) = match rot {
7338            Rotation::None | Rotation::Rotate180 => size,
7339            Rotation::Clockwise90 | Rotation::CounterClockwise90 => (size.1, size.0),
7340        };
7341
7342        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
7343        let src =
7344            crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), tensor_memory).unwrap();
7345
7346        let cpu_dst = TensorDyn::image(
7347            dst_width,
7348            dst_height,
7349            PixelFormat::Rgba,
7350            DType::U8,
7351            None,
7352            edgefirst_tensor::CpuAccess::ReadWrite,
7353        )
7354        .unwrap();
7355        let mut cpu_converter = CPUProcessor::new();
7356
7357        let (result, mut src, cpu_dst) = convert_img(
7358            &mut cpu_converter,
7359            src,
7360            cpu_dst,
7361            rot,
7362            Flip::None,
7363            Crop::no_crop(),
7364        );
7365        result.unwrap();
7366
7367        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
7368
7369        for _ in 0..5 {
7370            let gl_dst = TensorDyn::image(
7371                dst_width,
7372                dst_height,
7373                PixelFormat::Rgba,
7374                DType::U8,
7375                tensor_memory,
7376                edgefirst_tensor::CpuAccess::ReadWrite,
7377            )
7378            .unwrap();
7379            let (result, src_back, gl_dst) = convert_img(
7380                &mut gl_converter,
7381                src,
7382                gl_dst,
7383                rot,
7384                Flip::None,
7385                Crop::no_crop(),
7386            );
7387            result.unwrap();
7388            src = src_back;
7389            compare_images(&gl_dst, &cpu_dst, 0.98, function!());
7390        }
7391    }
7392
7393    #[test]
7394    #[cfg(target_os = "linux")]
7395    fn test_g2d_rotate() {
7396        if !is_g2d_available() {
7397            crate::test_support::report_skip(
7398                "test_g2d_rotate - G2D library (libg2d.so.2) not available",
7399            );
7400            return;
7401        }
7402        if !is_dma_available() {
7403            crate::test_support::report_skip("test_g2d_rotate - DMA memory allocation not available (permission denied or no DMA-BUF support)");
7404            return;
7405        }
7406
7407        let size = (1280, 720);
7408        for rot in [
7409            Rotation::Clockwise90,
7410            Rotation::Rotate180,
7411            Rotation::CounterClockwise90,
7412        ] {
7413            test_g2d_rotate_(size, rot);
7414        }
7415    }
7416
7417    #[cfg(target_os = "linux")]
7418    fn test_g2d_rotate_(size: (usize, usize), rot: Rotation) {
7419        let (dst_width, dst_height) = match rot {
7420            Rotation::None | Rotation::Rotate180 => size,
7421            Rotation::Clockwise90 | Rotation::CounterClockwise90 => (size.1, size.0),
7422        };
7423
7424        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
7425        let src = crate::load_image_test_helper(
7426            &file,
7427            Some(PixelFormat::Rgba),
7428            Some(TensorMemory::DmaBuf),
7429        )
7430        .unwrap();
7431
7432        let cpu_dst = TensorDyn::image(
7433            dst_width,
7434            dst_height,
7435            PixelFormat::Rgba,
7436            DType::U8,
7437            None,
7438            edgefirst_tensor::CpuAccess::ReadWrite,
7439        )
7440        .unwrap();
7441        let mut cpu_converter = CPUProcessor::new();
7442
7443        let (result, src, cpu_dst) = convert_img(
7444            &mut cpu_converter,
7445            src,
7446            cpu_dst,
7447            rot,
7448            Flip::None,
7449            Crop::no_crop(),
7450        );
7451        result.unwrap();
7452
7453        let g2d_dst = TensorDyn::image(
7454            dst_width,
7455            dst_height,
7456            PixelFormat::Rgba,
7457            DType::U8,
7458            Some(TensorMemory::DmaBuf),
7459            edgefirst_tensor::CpuAccess::ReadWrite,
7460        )
7461        .unwrap();
7462        let mut g2d_converter = G2DProcessor::new().unwrap();
7463
7464        let (result, _src, g2d_dst) = convert_img(
7465            &mut g2d_converter,
7466            src,
7467            g2d_dst,
7468            rot,
7469            Flip::None,
7470            Crop::no_crop(),
7471        );
7472        result.unwrap();
7473
7474        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
7475        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
7476        // the YUV-matrix delta that forced 0.95 has closed; tightened to
7477        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
7478        // structural gap not exercised by these limited-range fixtures.
7479        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
7480    }
7481
7482    #[test]
7483    fn test_rgba_to_yuyv_resize_cpu() {
7484        let src = load_bytes_to_tensor(
7485            1280,
7486            720,
7487            PixelFormat::Rgba,
7488            None,
7489            &edgefirst_bench::testdata::read("camera720p.rgba"),
7490        )
7491        .unwrap();
7492
7493        let (dst_width, dst_height) = (640, 360);
7494
7495        let dst = TensorDyn::image(
7496            dst_width,
7497            dst_height,
7498            PixelFormat::Yuyv,
7499            DType::U8,
7500            None,
7501            edgefirst_tensor::CpuAccess::ReadWrite,
7502        )
7503        .unwrap();
7504
7505        let dst_through_yuyv = TensorDyn::image(
7506            dst_width,
7507            dst_height,
7508            PixelFormat::Rgba,
7509            DType::U8,
7510            None,
7511            edgefirst_tensor::CpuAccess::ReadWrite,
7512        )
7513        .unwrap();
7514        let dst_direct = TensorDyn::image(
7515            dst_width,
7516            dst_height,
7517            PixelFormat::Rgba,
7518            DType::U8,
7519            None,
7520            edgefirst_tensor::CpuAccess::ReadWrite,
7521        )
7522        .unwrap();
7523
7524        let mut cpu_converter = CPUProcessor::new();
7525
7526        let (result, src, dst) = convert_img(
7527            &mut cpu_converter,
7528            src,
7529            dst,
7530            Rotation::None,
7531            Flip::None,
7532            Crop::no_crop(),
7533        );
7534        result.unwrap();
7535
7536        let (result, _dst, dst_through_yuyv) = convert_img(
7537            &mut cpu_converter,
7538            dst,
7539            dst_through_yuyv,
7540            Rotation::None,
7541            Flip::None,
7542            Crop::no_crop(),
7543        );
7544        result.unwrap();
7545
7546        let (result, _src, dst_direct) = convert_img(
7547            &mut cpu_converter,
7548            src,
7549            dst_direct,
7550            Rotation::None,
7551            Flip::None,
7552            Crop::no_crop(),
7553        );
7554        result.unwrap();
7555
7556        compare_images(&dst_through_yuyv, &dst_direct, 0.98, function!());
7557    }
7558
7559    #[test]
7560    #[cfg(any(
7561        target_os = "linux",
7562        target_os = "macos",
7563        target_os = "ios",
7564        target_os = "android",
7565        target_os = "windows"
7566    ))]
7567    #[cfg(feature = "opengl")]
7568    #[ignore = "opengl doesn't support rendering to PixelFormat::Yuyv texture"]
7569    fn test_rgba_to_yuyv_resize_opengl() {
7570        if !is_opengl_available() {
7571            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
7572            return;
7573        }
7574
7575        if !edgefirst_tensor::is_gpu_buffer_available() {
7576            crate::test_support::report_skip(&format!(
7577                "{} - zero-copy GPU buffer (DMA-BUF / IOSurface) not available on this host",
7578                function!()
7579            ));
7580            return;
7581        }
7582
7583        let src = load_bytes_to_tensor(
7584            1280,
7585            720,
7586            PixelFormat::Rgba,
7587            None,
7588            &edgefirst_bench::testdata::read("camera720p.rgba"),
7589        )
7590        .unwrap();
7591
7592        let (dst_width, dst_height) = (640, 360);
7593
7594        let dst = TensorDyn::image(
7595            dst_width,
7596            dst_height,
7597            PixelFormat::Yuyv,
7598            DType::U8,
7599            Some(TensorMemory::DmaBuf),
7600            edgefirst_tensor::CpuAccess::ReadWrite,
7601        )
7602        .unwrap();
7603
7604        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
7605
7606        let (result, src, dst) = convert_img(
7607            &mut gl_converter,
7608            src,
7609            dst,
7610            Rotation::None,
7611            Flip::None,
7612            Crop::letterbox([255, 255, 255, 255]),
7613        );
7614        result.unwrap();
7615
7616        std::fs::write(
7617            "rgba_to_yuyv_opengl.yuyv",
7618            dst.as_typed::<u8>().unwrap().map().unwrap().as_slice(),
7619        )
7620        .unwrap();
7621        let cpu_dst = TensorDyn::image(
7622            dst_width,
7623            dst_height,
7624            PixelFormat::Yuyv,
7625            DType::U8,
7626            Some(TensorMemory::DmaBuf),
7627            edgefirst_tensor::CpuAccess::ReadWrite,
7628        )
7629        .unwrap();
7630        let (result, _src, cpu_dst) = convert_img(
7631            &mut CPUProcessor::new(),
7632            src,
7633            cpu_dst,
7634            Rotation::None,
7635            Flip::None,
7636            Crop::no_crop(),
7637        );
7638        result.unwrap();
7639
7640        compare_images_convert_to_rgb(&dst, &cpu_dst, 0.98, function!());
7641    }
7642
7643    #[test]
7644    #[cfg(target_os = "linux")]
7645    fn test_rgba_to_yuyv_resize_g2d() {
7646        if !is_g2d_available() {
7647            crate::test_support::report_skip(
7648                "test_rgba_to_yuyv_resize_g2d - G2D library (libg2d.so.2) not available",
7649            );
7650            return;
7651        }
7652        if !is_dma_available() {
7653            crate::test_support::report_skip("test_rgba_to_yuyv_resize_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
7654            return;
7655        }
7656
7657        let src = load_bytes_to_tensor(
7658            1280,
7659            720,
7660            PixelFormat::Rgba,
7661            Some(TensorMemory::DmaBuf),
7662            &edgefirst_bench::testdata::read("camera720p.rgba"),
7663        )
7664        .unwrap();
7665
7666        let (dst_width, dst_height) = (1280, 720);
7667
7668        let cpu_dst = TensorDyn::image(
7669            dst_width,
7670            dst_height,
7671            PixelFormat::Yuyv,
7672            DType::U8,
7673            Some(TensorMemory::DmaBuf),
7674            edgefirst_tensor::CpuAccess::ReadWrite,
7675        )
7676        .unwrap();
7677
7678        let g2d_dst = TensorDyn::image(
7679            dst_width,
7680            dst_height,
7681            PixelFormat::Yuyv,
7682            DType::U8,
7683            Some(TensorMemory::DmaBuf),
7684            edgefirst_tensor::CpuAccess::ReadWrite,
7685        )
7686        .unwrap();
7687
7688        let mut g2d_converter = G2DProcessor::new().unwrap();
7689        let crop = Crop::new();
7690
7691        g2d_dst
7692            .as_typed::<u8>()
7693            .unwrap()
7694            .map()
7695            .unwrap()
7696            .as_mut_slice()
7697            .fill(128);
7698        let (result, src, g2d_dst) = convert_img(
7699            &mut g2d_converter,
7700            src,
7701            g2d_dst,
7702            Rotation::None,
7703            Flip::None,
7704            crop,
7705        );
7706        result.unwrap();
7707
7708        let cpu_dst_img = cpu_dst;
7709        cpu_dst_img
7710            .as_typed::<u8>()
7711            .unwrap()
7712            .map()
7713            .unwrap()
7714            .as_mut_slice()
7715            .fill(128);
7716        let (result, _src, cpu_dst) = convert_img(
7717            &mut CPUProcessor::new(),
7718            src,
7719            cpu_dst_img,
7720            Rotation::None,
7721            Flip::None,
7722            crop,
7723        );
7724        result.unwrap();
7725
7726        compare_images_convert_to_rgb(&cpu_dst, &g2d_dst, 0.98, function!());
7727    }
7728
7729    #[test]
7730    fn test_yuyv_to_rgba_cpu() {
7731        let file = edgefirst_bench::testdata::read("camera720p.yuyv").to_vec();
7732        let src = TensorDyn::image(
7733            1280,
7734            720,
7735            PixelFormat::Yuyv,
7736            DType::U8,
7737            None,
7738            edgefirst_tensor::CpuAccess::ReadWrite,
7739        )
7740        .unwrap();
7741        src.as_typed::<u8>()
7742            .unwrap()
7743            .map()
7744            .unwrap()
7745            .as_mut_slice()
7746            .copy_from_slice(&file);
7747
7748        let dst = TensorDyn::image(
7749            1280,
7750            720,
7751            PixelFormat::Rgba,
7752            DType::U8,
7753            None,
7754            edgefirst_tensor::CpuAccess::ReadWrite,
7755        )
7756        .unwrap();
7757        let mut cpu_converter = CPUProcessor::new();
7758
7759        let (result, _src, dst) = convert_img(
7760            &mut cpu_converter,
7761            src,
7762            dst,
7763            Rotation::None,
7764            Flip::None,
7765            Crop::no_crop(),
7766        );
7767        result.unwrap();
7768
7769        let target_image = TensorDyn::image(
7770            1280,
7771            720,
7772            PixelFormat::Rgba,
7773            DType::U8,
7774            None,
7775            edgefirst_tensor::CpuAccess::ReadWrite,
7776        )
7777        .unwrap();
7778        target_image
7779            .as_typed::<u8>()
7780            .unwrap()
7781            .map()
7782            .unwrap()
7783            .as_mut_slice()
7784            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
7785
7786        // CPU path resolves the untagged 720p source to BT.709 limited (height
7787        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
7788        compare_images(&dst, &target_image, 0.98, function!());
7789    }
7790
7791    #[test]
7792    fn test_yuyv_to_rgb_cpu() {
7793        let file = edgefirst_bench::testdata::read("camera720p.yuyv").to_vec();
7794        let src = TensorDyn::image(
7795            1280,
7796            720,
7797            PixelFormat::Yuyv,
7798            DType::U8,
7799            None,
7800            edgefirst_tensor::CpuAccess::ReadWrite,
7801        )
7802        .unwrap();
7803        src.as_typed::<u8>()
7804            .unwrap()
7805            .map()
7806            .unwrap()
7807            .as_mut_slice()
7808            .copy_from_slice(&file);
7809
7810        let dst = TensorDyn::image(
7811            1280,
7812            720,
7813            PixelFormat::Rgb,
7814            DType::U8,
7815            None,
7816            edgefirst_tensor::CpuAccess::ReadWrite,
7817        )
7818        .unwrap();
7819        let mut cpu_converter = CPUProcessor::new();
7820
7821        let (result, _src, dst) = convert_img(
7822            &mut cpu_converter,
7823            src,
7824            dst,
7825            Rotation::None,
7826            Flip::None,
7827            Crop::no_crop(),
7828        );
7829        result.unwrap();
7830
7831        let target_image = TensorDyn::image(
7832            1280,
7833            720,
7834            PixelFormat::Rgb,
7835            DType::U8,
7836            None,
7837            edgefirst_tensor::CpuAccess::ReadWrite,
7838        )
7839        .unwrap();
7840        target_image
7841            .as_typed::<u8>()
7842            .unwrap()
7843            .map()
7844            .unwrap()
7845            .as_mut_slice()
7846            .as_chunks_mut::<3>()
7847            .0
7848            .iter_mut()
7849            .zip(
7850                edgefirst_bench::testdata::read("camera720p.rgba")
7851                    .as_chunks::<4>()
7852                    .0,
7853            )
7854            .for_each(|(dst, src)| *dst = [src[0], src[1], src[2]]);
7855
7856        // CPU path resolves the untagged 720p source to BT.709 limited (height
7857        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
7858        compare_images(&dst, &target_image, 0.98, function!());
7859    }
7860
7861    #[test]
7862    #[cfg(target_os = "linux")]
7863    fn test_yuyv_to_rgba_g2d() {
7864        if !is_g2d_available() {
7865            crate::test_support::report_skip(
7866                "test_yuyv_to_rgba_g2d - G2D library (libg2d.so.2) not available",
7867            );
7868            return;
7869        }
7870        if !is_dma_available() {
7871            crate::test_support::report_skip("test_yuyv_to_rgba_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
7872            return;
7873        }
7874
7875        let src = load_bytes_to_tensor(
7876            1280,
7877            720,
7878            PixelFormat::Yuyv,
7879            None,
7880            &edgefirst_bench::testdata::read("camera720p.yuyv"),
7881        )
7882        .unwrap();
7883
7884        let dst = TensorDyn::image(
7885            1280,
7886            720,
7887            PixelFormat::Rgba,
7888            DType::U8,
7889            Some(TensorMemory::DmaBuf),
7890            edgefirst_tensor::CpuAccess::ReadWrite,
7891        )
7892        .unwrap();
7893        let mut g2d_converter = G2DProcessor::new().unwrap();
7894
7895        let (result, _src, dst) = convert_img(
7896            &mut g2d_converter,
7897            src,
7898            dst,
7899            Rotation::None,
7900            Flip::None,
7901            Crop::no_crop(),
7902        );
7903        result.unwrap();
7904
7905        let target_image = TensorDyn::image(
7906            1280,
7907            720,
7908            PixelFormat::Rgba,
7909            DType::U8,
7910            None,
7911            edgefirst_tensor::CpuAccess::ReadWrite,
7912        )
7913        .unwrap();
7914        target_image
7915            .as_typed::<u8>()
7916            .unwrap()
7917            .map()
7918            .unwrap()
7919            .as_mut_slice()
7920            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
7921
7922        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
7923        // so the matrix delta vs the reference that forced 0.95 has closed;
7924        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
7925        compare_images(&dst, &target_image, 0.98, function!());
7926    }
7927
7928    #[test]
7929    #[cfg(any(
7930        target_os = "linux",
7931        target_os = "macos",
7932        target_os = "ios",
7933        target_os = "android",
7934        target_os = "windows"
7935    ))]
7936    #[cfg(feature = "opengl")]
7937    fn test_yuyv_to_rgba_opengl() {
7938        if !is_opengl_available() {
7939            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
7940            return;
7941        }
7942        // Zero-copy YUYV source: DMA-BUF on Linux, IOSurface on macOS, a
7943        // D3D11 texture on Windows; skips where no such backing exists.
7944        if !edgefirst_tensor::is_gpu_buffer_available() {
7945            crate::test_support::report_skip(&format!(
7946                "{} - zero-copy GPU buffer (DMA-BUF / IOSurface) not available on this host",
7947                function!()
7948            ));
7949            return;
7950        }
7951
7952        let src = load_bytes_to_tensor(
7953            1280,
7954            720,
7955            PixelFormat::Yuyv,
7956            Some(TensorMemory::DmaBuf),
7957            &edgefirst_bench::testdata::read("camera720p.yuyv"),
7958        )
7959        .unwrap();
7960
7961        let dst = TensorDyn::image(
7962            1280,
7963            720,
7964            PixelFormat::Rgba,
7965            DType::U8,
7966            Some(TensorMemory::DmaBuf),
7967            edgefirst_tensor::CpuAccess::ReadWrite,
7968        )
7969        .unwrap();
7970        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
7971
7972        let (result, _src, dst) = convert_img(
7973            &mut gl_converter,
7974            src,
7975            dst,
7976            Rotation::None,
7977            Flip::None,
7978            Crop::no_crop(),
7979        );
7980        // Desktop NVIDIA cannot sample YUYV as a GL texture; i.MX/Vivante can.
7981        // Production ImageProcessor falls back to CPU; this oracle skips.
7982        if let Err(crate::Error::NotSupported(msg)) = &result {
7983            crate::test_support::report_skip(&format!("{} — {msg}", function!()));
7984            return;
7985        }
7986        result.unwrap();
7987
7988        let target_image = TensorDyn::image(
7989            1280,
7990            720,
7991            PixelFormat::Rgba,
7992            DType::U8,
7993            None,
7994            edgefirst_tensor::CpuAccess::ReadWrite,
7995        )
7996        .unwrap();
7997        target_image
7998            .as_typed::<u8>()
7999            .unwrap()
8000            .map()
8001            .unwrap()
8002            .as_mut_slice()
8003            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
8004
8005        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
8006        // so the matrix delta vs the reference that forced 0.95 has closed;
8007        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
8008        compare_images(&dst, &target_image, 0.98, function!());
8009    }
8010
8011    /// macOS analog of `test_yuyv_to_rgba_opengl` — drives the ANGLE +
8012    /// IOSurface backend end-to-end and compares against the same
8013    /// reference image. Skips silently if ANGLE isn't installed so the
8014    /// test suite still passes on CI hosts without the Homebrew tap.
8015    /// Step-1 probe: proves ANGLE's Metal IOSurface-client-buffer path accepts
8016    /// an `L008`→`GL_RED` (R8) binding — the foundation for sampling the
8017    /// contiguous semi-planar YUV buffer as a single R8 texture. Renders a
8018    /// GREY (R8 IOSurface) source through the GL backend to RGBA and checks the
8019    /// luma round-trips to R=G=B (identity GREY→RGB).
8020    #[test]
8021    #[cfg(target_os = "macos")]
8022    #[cfg(feature = "opengl")]
8023    fn test_grey_r8_iosurface_to_rgba_opengl_macos() {
8024        let mut proc = match GLProcessorThreaded::new(None) {
8025            Ok(p) => p,
8026            Err(e) => {
8027                crate::test_support::report_skip(&format!(
8028                    "{} — GL engine init failed ({e:?})",
8029                    function!()
8030                ));
8031                return;
8032            }
8033        };
8034
8035        let (w, h) = (16usize, 16usize);
8036        let src = TensorDyn::image(
8037            w,
8038            h,
8039            PixelFormat::Grey,
8040            DType::U8,
8041            Some(TensorMemory::DmaBuf),
8042            edgefirst_tensor::CpuAccess::ReadWrite,
8043        )
8044        .expect("GREY IOSurface (R8/L008) should allocate — proves the FourCC mapping");
8045        // Known luma ramp: value = (x * 13 + y * 7) & 0xff.
8046        {
8047            let su8 = src.as_typed::<u8>().unwrap();
8048            let stride = src
8049                .as_typed::<u8>()
8050                .unwrap()
8051                .effective_row_stride()
8052                .unwrap();
8053            let mut m = su8.map().unwrap();
8054            let buf = m.as_mut_slice();
8055            for y in 0..h {
8056                for x in 0..w {
8057                    buf[y * stride + x] = ((x * 13 + y * 7) & 0xff) as u8;
8058                }
8059            }
8060        }
8061
8062        let dst = TensorDyn::image(
8063            w,
8064            h,
8065            PixelFormat::Rgba,
8066            DType::U8,
8067            Some(TensorMemory::DmaBuf),
8068            edgefirst_tensor::CpuAccess::ReadWrite,
8069        )
8070        .unwrap();
8071        let (result, src_back, dst) = convert_img(
8072            &mut proc,
8073            src,
8074            dst,
8075            Rotation::None,
8076            Flip::None,
8077            Crop::no_crop(),
8078        );
8079        result.expect("GREY(R8 IOSurface) → RGBA must convert on ANGLE (R8 binding works)");
8080
8081        let src_stride = src_back
8082            .as_typed::<u8>()
8083            .unwrap()
8084            .effective_row_stride()
8085            .unwrap();
8086        let src_map = src_back.as_typed::<u8>().unwrap().map().unwrap();
8087        let sbytes = src_map.as_slice();
8088        let dst_stride = dst
8089            .as_typed::<u8>()
8090            .unwrap()
8091            .effective_row_stride()
8092            .unwrap();
8093        let dst_map = dst.as_typed::<u8>().unwrap().map().unwrap();
8094        let dbytes = dst_map.as_slice();
8095        for y in 0..h {
8096            for x in 0..w {
8097                let yv = sbytes[y * src_stride + x] as i16;
8098                let p = y * dst_stride + x * 4;
8099                for c in 0..3 {
8100                    assert!(
8101                        (dbytes[p + c] as i16 - yv).abs() <= 2,
8102                        "pixel ({x},{y}) ch{c} = {} expected ~{yv} (GREY→RGB identity)",
8103                        dbytes[p + c]
8104                    );
8105                }
8106            }
8107        }
8108    }
8109
8110    /// Two-pass GPU chain: NV12 (R8 IOSurface) → PlanarRgb F16, the profiler's
8111    /// preprocess. Verifies the chained `convert_nv_to_planar_float`
8112    /// (NV12→RGBA8 then the verified RGBA8→PlanarRgb F16) executes on ANGLE and
8113    /// produces a sane F16 planar result: a neutral-grey NV12 input (Y=U=V=128,
8114    /// BT.601 full ⇒ RGB≈0.5) must yield all three planes ≈0.5 (half-float).
8115    #[test]
8116    #[cfg(target_os = "macos")]
8117    #[cfg(feature = "opengl")]
8118    fn test_nv12_to_planar_f16_two_pass_opengl_macos() {
8119        let mut gpu = match GLProcessorThreaded::new(None) {
8120            Ok(p) => p,
8121            Err(e) => {
8122                crate::test_support::report_skip(&format!("{} — init failed ({e:?})", function!()));
8123                return;
8124            }
8125        };
8126        let (w, h) = (64usize, 64usize);
8127        let src = match TensorDyn::image(
8128            w,
8129            h,
8130            PixelFormat::Nv12,
8131            DType::U8,
8132            Some(TensorMemory::DmaBuf),
8133            edgefirst_tensor::CpuAccess::ReadWrite,
8134        ) {
8135            Ok(t) => t,
8136            Err(e) => {
8137                crate::test_support::report_skip(&format!(
8138                    "{} — NV12 IOSurface alloc: {e:?}",
8139                    function!()
8140                ));
8141                return;
8142            }
8143        };
8144        src.as_typed::<u8>()
8145            .unwrap()
8146            .map()
8147            .unwrap()
8148            .as_mut_slice()
8149            .fill(128); // Y=U=V=128
8150
8151        let dst = match TensorDyn::image(
8152            w,
8153            h,
8154            PixelFormat::PlanarRgb,
8155            DType::F16,
8156            Some(TensorMemory::DmaBuf),
8157            edgefirst_tensor::CpuAccess::ReadWrite,
8158        ) {
8159            Ok(t) => t,
8160            Err(e) => {
8161                crate::test_support::report_skip(&format!(
8162                    "{} — F16 PlanarRgb IOSurface: {e:?}",
8163                    function!()
8164                ));
8165                return;
8166            }
8167        };
8168        let mut dst = dst;
8169        // Call convert directly (the convert_img helper restores u8 only).
8170        if let Err(e) = ImageProcessorTrait::convert(
8171            &mut gpu,
8172            &src,
8173            &mut dst,
8174            Rotation::None,
8175            Flip::None,
8176            Crop::no_crop(),
8177        ) {
8178            // GL_EXT_color_buffer_half_float may be absent on some configs; the
8179            // RGBA8 pass-1 + F16 pass-2 path then can't render. Skip rather than
8180            // fail on a capability gap (the same policy as the F16 path tests).
8181            crate::test_support::report_skip(&format!(
8182                "{} — NV12→PlanarRgb F16 not available ({e:?})",
8183                function!()
8184            ));
8185            return;
8186        }
8187        let dt = dst.as_typed::<half::f16>().expect("dst is F16 PlanarRgb");
8188        let map = dt.map().unwrap();
8189        let vals = map.as_slice();
8190        // Neutral grey → ~0.5 in every plane. Allow generous tolerance for the
8191        // mediump YUV math + half-float rounding.
8192        let mut checked = 0usize;
8193        for &v in vals.iter() {
8194            let f = f32::from(v);
8195            assert!(
8196                (0.40..=0.60).contains(&f),
8197                "planar F16 value {f} not ~0.5 for neutral-grey NV12"
8198            );
8199            checked += 1;
8200        }
8201        assert!(
8202            checked >= w * h * 3,
8203            "expected >= 3 planes of samples, got {checked}"
8204        );
8205    }
8206
8207    /// Profiler-shaped two-pass: a reused **R8/Grey pool** (allocated larger
8208    /// than the frame, the NV24 worst case `3·H`) is reconfigured to an NV12
8209    /// frame, filled at the preserved physical stride, and converted with a
8210    /// letterbox `src_rect` crop into a model-sized PlanarRgb F16 destination —
8211    /// exactly the orchestrator's preprocess. Guards against the pooled
8212    /// two-pass NV→PlanarRgb F16 path hanging/erroring (the exact-size
8213    /// `test_nv12_to_planar_f16_two_pass` never exercised the larger pool).
8214    #[test]
8215    #[cfg(target_os = "macos")]
8216    #[cfg(feature = "opengl")]
8217    fn test_nv12_to_planar_f16_two_pass_pool_opengl_macos() {
8218        let mut gpu = match GLProcessorThreaded::new(None) {
8219            Ok(p) => p,
8220            Err(e) => {
8221                crate::test_support::report_skip(&format!("{} — init failed ({e:?})", function!()));
8222                return;
8223            }
8224        };
8225        // Frame 96×64 in a 256×768 R8 pool (3·256 height; bpr padded past 96).
8226        let (fw, fh) = (96usize, 64usize);
8227        let (pool_w, pool_h) = (256usize, 768usize);
8228        let (model_w, model_h) = (128usize, 128usize);
8229
8230        let mut src = match TensorDyn::image(
8231            pool_w,
8232            pool_h,
8233            PixelFormat::Grey,
8234            DType::U8,
8235            Some(TensorMemory::DmaBuf),
8236            edgefirst_tensor::CpuAccess::ReadWrite,
8237        ) {
8238            Ok(t) => t,
8239            Err(e) => {
8240                crate::test_support::report_skip(&format!(
8241                    "{} — R8 pool alloc: {e:?}",
8242                    function!()
8243                ));
8244                return;
8245            }
8246        };
8247        src.configure_image(fw, fh, PixelFormat::Nv12)
8248            .unwrap_or_else(|e| panic!("configure_image NV12 on pool: {e}"));
8249        let stride = src
8250            .as_typed::<u8>()
8251            .unwrap()
8252            .effective_row_stride()
8253            .unwrap();
8254        src.as_typed::<u8>()
8255            .unwrap()
8256            .map()
8257            .unwrap()
8258            .as_mut_slice()
8259            .fill(128); // neutral grey
8260
8261        let mut dst = match TensorDyn::image(
8262            model_w,
8263            model_h,
8264            PixelFormat::PlanarRgb,
8265            DType::F16,
8266            Some(TensorMemory::DmaBuf),
8267            edgefirst_tensor::CpuAccess::ReadWrite,
8268        ) {
8269            Ok(t) => t,
8270            Err(e) => {
8271                crate::test_support::report_skip(&format!(
8272                    "{} — F16 PlanarRgb dst: {e:?}",
8273                    function!()
8274                ));
8275                return;
8276            }
8277        };
8278
8279        // Letterbox crop like the profiler: the backend computes the band.
8280        let _ = model_w;
8281        let crop = Crop::new()
8282            .with_source(Some(Region::new(0, 0, fw, fh)))
8283            .with_fit(Fit::Letterbox {
8284                pad: [0, 0, 0, 255],
8285            });
8286        if let Err(e) =
8287            ImageProcessorTrait::convert(&mut gpu, &src, &mut dst, Rotation::None, Flip::None, crop)
8288        {
8289            crate::test_support::report_skip(&format!(
8290                "{} — NV12→PlanarRgb F16 unavailable ({e:?})",
8291                function!()
8292            ));
8293            return;
8294        }
8295        let _ = stride;
8296        // Neutral grey → ~0.5 inside the letterbox band; just assert the convert
8297        // completed and produced finite values (no hang, no NaN garbage).
8298        let dt = dst.as_typed::<half::f16>().expect("dst F16");
8299        let map = dt.map().unwrap();
8300        let any_half = map.as_slice().iter().any(|&v| {
8301            let f = f32::from(v);
8302            (0.40..=0.60).contains(&f)
8303        });
8304        assert!(any_half, "expected ~0.5 grey samples in the letterbox band");
8305    }
8306
8307    /// Mirrors the orchestrator: the GL processor is created on one thread
8308    /// and `convert()` is called from a *different* thread (the profiler's
8309    /// Pre-processing worker). Reproduces (or rules out) the GL-context /
8310    /// `glFinish` cross-thread hang seen in the live pipeline. A 20 s watchdog
8311    /// fails loudly rather than hanging the whole test binary.
8312    #[test]
8313    #[cfg(target_os = "macos")]
8314    #[cfg(feature = "opengl")]
8315    fn test_nv12_to_planar_f16_cross_thread_opengl_macos() {
8316        use std::sync::mpsc;
8317        // Public ImageProcessor (Send) created HERE (the main test thread),
8318        // exactly like the orchestrator builds `config.processor` during setup.
8319        let mut proc = match ImageProcessor::new() {
8320            Ok(p) => p,
8321            Err(e) => {
8322                crate::test_support::report_skip(&format!("{} — init failed ({e:?})", function!()));
8323                return;
8324            }
8325        };
8326        let (fw, fh) = (96usize, 64usize);
8327        let mut src = match TensorDyn::image(
8328            256,
8329            768,
8330            PixelFormat::Grey,
8331            DType::U8,
8332            Some(TensorMemory::DmaBuf),
8333            edgefirst_tensor::CpuAccess::ReadWrite,
8334        ) {
8335            Ok(t) => t,
8336            Err(e) => {
8337                crate::test_support::report_skip(&format!("{} — pool: {e:?}", function!()));
8338                return;
8339            }
8340        };
8341        src.configure_image(fw, fh, PixelFormat::Nv12).unwrap();
8342        src.as_typed::<u8>()
8343            .unwrap()
8344            .map()
8345            .unwrap()
8346            .as_mut_slice()
8347            .fill(128);
8348        let mut dst = match TensorDyn::image(
8349            128,
8350            128,
8351            PixelFormat::PlanarRgb,
8352            DType::F16,
8353            Some(TensorMemory::DmaBuf),
8354            edgefirst_tensor::CpuAccess::ReadWrite,
8355        ) {
8356            Ok(t) => t,
8357            Err(e) => {
8358                crate::test_support::report_skip(&format!("{} — dst: {e:?}", function!()));
8359                return;
8360            }
8361        };
8362        let crop = Crop::new().with_source(Some(Region::new(0, 0, fw, fh)));
8363
8364        // ...then MOVED to a worker thread where convert() runs — exactly the
8365        // orchestrator's create-on-setup / convert-on-Pre-processing split.
8366        let (tx, rx) = mpsc::channel::<bool>();
8367        let worker = std::thread::spawn(move || {
8368            let _ = ImageProcessorTrait::convert(
8369                &mut proc,
8370                &src,
8371                &mut dst,
8372                Rotation::None,
8373                Flip::None,
8374                crop,
8375            );
8376            let _ = tx.send(true);
8377        });
8378        match rx.recv_timeout(std::time::Duration::from_secs(20)) {
8379            Ok(_) => { let _ = worker.join(); }
8380            Err(_) => panic!(
8381                "cross-thread NV12→PlanarRgb convert HUNG (>20s) — reproduces the orchestrator deadlock"
8382            ),
8383        }
8384    }
8385
8386    /// Reproduces the profiler's progressive-slowdown/hang: one processor
8387    /// converting many **varying-size** NV frames (like a COCO dataset) from a
8388    /// reused R8 pool into a fixed PlanarRgb F16 model input. The two-pass path
8389    /// reallocated its RGBA intermediate per frame-size, churning/leaking
8390    /// pbuffers until the GPU stalled. Asserts per-convert latency stays bounded
8391    /// (no runaway) over many iterations.
8392    #[test]
8393    #[cfg(target_os = "macos")]
8394    #[cfg(feature = "opengl")]
8395    fn test_nv_to_planar_f16_varying_sizes_no_leak_opengl_macos() {
8396        let mut gpu = match GLProcessorThreaded::new(None) {
8397            Ok(p) => p,
8398            Err(e) => {
8399                crate::test_support::report_skip(&format!("{} — init failed ({e:?})", function!()));
8400                return;
8401            }
8402        };
8403        // Mirror the orchestrator's ring buffers at depth 4: a pool of source R8
8404        // tensors AND a pool of PlanarRgb F16 dst slots, both cycled per frame.
8405        let (max_w, max_h) = (640usize, 640usize);
8406        let depth = 4usize;
8407        let mut srcs = Vec::new();
8408        let mut dsts = Vec::new();
8409        for _ in 0..depth {
8410            srcs.push(
8411                match TensorDyn::image(
8412                    max_w,
8413                    max_h * 3,
8414                    PixelFormat::Grey,
8415                    DType::U8,
8416                    Some(TensorMemory::DmaBuf),
8417                    edgefirst_tensor::CpuAccess::ReadWrite,
8418                ) {
8419                    Ok(t) => t,
8420                    Err(e) => {
8421                        crate::test_support::report_skip(&format!("{} — pool: {e:?}", function!()));
8422                        return;
8423                    }
8424                },
8425            );
8426            dsts.push(
8427                match TensorDyn::image(
8428                    640,
8429                    640,
8430                    PixelFormat::PlanarRgb,
8431                    DType::F16,
8432                    Some(TensorMemory::DmaBuf),
8433                    edgefirst_tensor::CpuAccess::ReadWrite,
8434                ) {
8435                    Ok(t) => t,
8436                    Err(e) => {
8437                        crate::test_support::report_skip(&format!("{} — dst: {e:?}", function!()));
8438                        return;
8439                    }
8440                },
8441            );
8442        }
8443        // COCO-like assorted frame sizes (all ≤ max), cycled.
8444        let sizes = [
8445            (640, 480),
8446            (500, 375),
8447            (640, 427),
8448            (333, 500),
8449            (480, 640),
8450            (612, 612),
8451            (428, 640),
8452            (576, 432),
8453        ];
8454        let mut first_ms = 0f64;
8455        let mut last_ms = 0f64;
8456        let iters = 40usize;
8457        for i in 0..iters {
8458            let (fw, fh) = sizes[i % sizes.len()];
8459            let src = &mut srcs[i % depth];
8460            let dst = &mut dsts[i % depth];
8461            src.configure_image(fw, fh, PixelFormat::Nv24).unwrap();
8462            src.as_typed::<u8>()
8463                .unwrap()
8464                .map()
8465                .unwrap()
8466                .as_mut_slice()
8467                .fill(128);
8468            let crop = Crop::new().with_source(Some(Region::new(0, 0, fw, fh)));
8469            let t0 = std::time::Instant::now();
8470            ImageProcessorTrait::convert(&mut gpu, src, dst, Rotation::None, Flip::None, crop)
8471                .unwrap_or_else(|e| panic!("convert iter {i} ({fw}×{fh}): {e}"));
8472            let ms = t0.elapsed().as_secs_f64() * 1e3;
8473            if i == 2 {
8474                first_ms = ms;
8475            }
8476            if i == iters - 1 {
8477                last_ms = ms;
8478            }
8479        }
8480        eprintln!("first={first_ms:.2}ms last={last_ms:.2}ms");
8481        assert!(
8482            last_ms < first_ms * 5.0 + 5.0,
8483            "convert latency ran away: first {first_ms:.2}ms → last {last_ms:.2}ms (intermediate/pbuffer leak)"
8484        );
8485    }
8486
8487    /// Step-2 verification: NV12/NV16/NV24 (R8 IOSurface) → RGBA on the GPU
8488    /// must match the CPU `yuv` kernels within shader rounding. Fills an
8489    /// IOSurface source and a Mem source from the same logical YUV pattern
8490    /// (each at its own row stride), converts the IOSurface on the GPU and the
8491    /// Mem one on the CPU, and compares. Exercises the in-shader semi-planar
8492    /// addressing for all three subsamplings (incl. NV24's 2×-wide UV rows).
8493    #[test]
8494    #[cfg(target_os = "macos")]
8495    #[cfg(feature = "opengl")]
8496    fn test_nv12_nv16_nv24_to_rgba_opengl_macos() {
8497        let mut gpu = match GLProcessorThreaded::new(None) {
8498            Ok(p) => p,
8499            Err(e) => {
8500                crate::test_support::report_skip(&format!(
8501                    "{} — GL engine init failed ({e:?})",
8502                    function!()
8503                ));
8504                return;
8505            }
8506        };
8507        let mut cpu = CPUProcessor::new();
8508
8509        // Fill Y plus the interleaved UV plane in the canonical semi-planar
8510        // layout — exactly what the codec writes and the CPU `yuv`-crate reader
8511        // expects: the Y plane is `h` rows at the buffer's row stride; the UV
8512        // plane starts at `h * stride`; each chroma row advances
8513        // `uv_grid_rows * stride` bytes (NV24 carries a full-resolution `2*W`
8514        // byte line == two grid rows, NV12/NV16 one row of `W/2` pairs); each
8515        // (Cb,Cr) pair is two consecutive bytes at column `cx * 2`. This is
8516        // stride-correct for both the tight Mem buffer and the padded IOSurface.
8517        //
8518        // Takes explicit w/h so the closure can be reused across multiple frame
8519        // sizes (even and odd) without capturing a fixed outer variable.
8520        let fill = |buf: &mut [u8], stride: usize, fmt: PixelFormat, w: usize, h: usize| {
8521            for y in 0..h {
8522                for x in 0..w {
8523                    buf[y * stride + x] = ((x * 9 + y * 5) & 0xff) as u8;
8524                }
8525            }
8526            let (cw, ch, uv_grid_rows) = match fmt {
8527                PixelFormat::Nv12 => (w / 2, h / 2, 1usize),
8528                PixelFormat::Nv16 => (w / 2, h, 1usize),
8529                _ => (w, h, 2usize), // Nv24: full-res chroma, 2W bytes/row
8530            };
8531            let uv_plane = h * stride;
8532            for cy in 0..ch {
8533                for cx in 0..cw {
8534                    let off = uv_plane + cy * uv_grid_rows * stride + cx * 2;
8535                    buf[off] = ((cx * 11 + 30) & 0xff) as u8;
8536                    buf[off + 1] = ((cy * 7 + 200) & 0xff) as u8;
8537                }
8538            }
8539        };
8540
8541        for fmt in [PixelFormat::Nv12, PixelFormat::Nv16, PixelFormat::Nv24] {
8542            for (w, h) in [
8543                (16usize, 16usize), // original even-dim case
8544                (15, 16),           // odd-W
8545                (16, 15),           // odd-H
8546            ] {
8547                let mem = TensorDyn::image(
8548                    w,
8549                    h,
8550                    fmt,
8551                    DType::U8,
8552                    None,
8553                    edgefirst_tensor::CpuAccess::ReadWrite,
8554                )
8555                .unwrap();
8556                let mem_stride = mem
8557                    .as_typed::<u8>()
8558                    .unwrap()
8559                    .effective_row_stride()
8560                    .unwrap();
8561                fill(
8562                    mem.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice(),
8563                    mem_stride,
8564                    fmt,
8565                    w,
8566                    h,
8567                );
8568                let cpu_dst = TensorDyn::image(
8569                    w,
8570                    h,
8571                    PixelFormat::Rgba,
8572                    DType::U8,
8573                    None,
8574                    edgefirst_tensor::CpuAccess::ReadWrite,
8575                )
8576                .unwrap();
8577                let (r, _s, cpu_dst) = convert_img(
8578                    &mut cpu,
8579                    mem,
8580                    cpu_dst,
8581                    Rotation::None,
8582                    Flip::None,
8583                    Crop::no_crop(),
8584                );
8585                r.unwrap_or_else(|e| panic!("CPU {fmt:?}->{w}x{h}->RGBA: {e}"));
8586
8587                let ios = TensorDyn::image(
8588                    w,
8589                    h,
8590                    fmt,
8591                    DType::U8,
8592                    Some(TensorMemory::DmaBuf),
8593                    edgefirst_tensor::CpuAccess::ReadWrite,
8594                )
8595                .unwrap_or_else(|e| panic!("{fmt:?} {w}x{h} IOSurface alloc: {e}"));
8596                let ios_stride = ios
8597                    .as_typed::<u8>()
8598                    .unwrap()
8599                    .effective_row_stride()
8600                    .unwrap();
8601                fill(
8602                    ios.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice(),
8603                    ios_stride,
8604                    fmt,
8605                    w,
8606                    h,
8607                );
8608                let gpu_dst = TensorDyn::image(
8609                    w,
8610                    h,
8611                    PixelFormat::Rgba,
8612                    DType::U8,
8613                    Some(TensorMemory::DmaBuf),
8614                    edgefirst_tensor::CpuAccess::ReadWrite,
8615                )
8616                .unwrap();
8617                let (r, _s, gpu_dst) = convert_img(
8618                    &mut gpu,
8619                    ios,
8620                    gpu_dst,
8621                    Rotation::None,
8622                    Flip::None,
8623                    Crop::no_crop(),
8624                );
8625                r.unwrap_or_else(|e| panic!("GPU {fmt:?}->{w}x{h}->RGBA on ANGLE: {e}"));
8626
8627                let cs = cpu_dst
8628                    .as_typed::<u8>()
8629                    .unwrap()
8630                    .effective_row_stride()
8631                    .unwrap();
8632                let cmap = cpu_dst.as_typed::<u8>().unwrap().map().unwrap();
8633                let cb = cmap.as_slice();
8634                let gs = gpu_dst
8635                    .as_typed::<u8>()
8636                    .unwrap()
8637                    .effective_row_stride()
8638                    .unwrap();
8639                let gmap = gpu_dst.as_typed::<u8>().unwrap().map().unwrap();
8640                let gb = gmap.as_slice();
8641                let mut max_d = 0i16;
8642                for y in 0..h {
8643                    for x in 0..w {
8644                        for c in 0..3 {
8645                            let cv = cb[y * cs + x * 4 + c] as i16;
8646                            let gv = gb[y * gs + x * 4 + c] as i16;
8647                            max_d = max_d.max((cv - gv).abs());
8648                        }
8649                    }
8650                }
8651                assert!(
8652                    max_d <= 3,
8653                    "{fmt:?} {w}x{h}: GPU vs CPU RGBA max channel diff {max_d} > 3"
8654                );
8655            }
8656        }
8657    }
8658
8659    /// Phase-0 gate: the reused-pool / larger-surface case. A single R8 pool
8660    /// IOSurface (allocated bigger than the frame, so its `bytesPerRow` exceeds
8661    /// the frame's even width) is reconfigured to each NV frame, filled at the
8662    /// preserved physical stride, and converted on the GPU. This proves ANGLE
8663    /// binds the *whole* physical surface as a pbuffer and that `texelFetch`
8664    /// resolves the frame's Y/UV texels through the surface's real `bytesPerRow`
8665    /// (the physical-grid / logical-ROI decoupling). GPU must match CPU ≤3 LSB.
8666    #[test]
8667    #[cfg(target_os = "macos")]
8668    #[cfg(feature = "opengl")]
8669    fn test_nv_to_rgba_larger_pool_surface_opengl_macos() {
8670        let mut gpu = match GLProcessorThreaded::new(None) {
8671            Ok(p) => p,
8672            Err(e) => {
8673                crate::test_support::report_skip(&format!(
8674                    "{} — GL engine init failed ({e:?})",
8675                    function!()
8676                ));
8677                return;
8678            }
8679        };
8680        let mut cpu = CPUProcessor::new();
8681        // The pool is generously oversized (256-wide → bpr 256, well beyond any
8682        // frame's even width) and tall enough for NV24's 3·H at the test frames.
8683        let (pool_w, pool_h) = (256usize, 256usize);
8684
8685        // Canonical semi-planar fill: Y plane at the row stride, then the UV
8686        // plane at `h * stride` with each chroma row advancing `uv_grid_rows *
8687        // stride` bytes. Stride-correct for both tight Mem and padded IOSurface.
8688        let fill = |buf: &mut [u8], stride: usize, fmt: PixelFormat, w: usize, h: usize| {
8689            for y in 0..h {
8690                for x in 0..w {
8691                    buf[y * stride + x] = ((x * 9 + y * 5) & 0xff) as u8;
8692                }
8693            }
8694            let (cw, ch, uv_grid_rows) = match fmt {
8695                PixelFormat::Nv12 => (w / 2, h / 2, 1usize),
8696                PixelFormat::Nv16 => (w / 2, h, 1usize),
8697                _ => (w, h, 2usize), // Nv24: full-res chroma, 2W bytes/row
8698            };
8699            let uv_plane = h * stride;
8700            for cy in 0..ch {
8701                for cx in 0..cw {
8702                    let off = uv_plane + cy * uv_grid_rows * stride + cx * 2;
8703                    buf[off] = ((cx * 11 + 30) & 0xff) as u8;
8704                    buf[off + 1] = ((cy * 7 + 200) & 0xff) as u8;
8705                }
8706            }
8707        };
8708
8709        for fmt in [PixelFormat::Nv12, PixelFormat::Nv16, PixelFormat::Nv24] {
8710            for (w, h) in [
8711                (40usize, 24usize), // original even-dim case
8712                (15, 16),           // odd-W
8713                (16, 15),           // odd-H
8714            ] {
8715                // `ew` is the minimum even extent; the pool stride must exceed it
8716                // to actually exercise the physical-stride shader decoupling.
8717                let ew = w.next_multiple_of(2);
8718
8719                // CPU reference from a tightly-packed Mem tensor at the frame size.
8720                let mem = TensorDyn::image(
8721                    w,
8722                    h,
8723                    fmt,
8724                    DType::U8,
8725                    None,
8726                    edgefirst_tensor::CpuAccess::ReadWrite,
8727                )
8728                .unwrap();
8729                let mem_stride = mem
8730                    .as_typed::<u8>()
8731                    .unwrap()
8732                    .effective_row_stride()
8733                    .unwrap();
8734                fill(
8735                    mem.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice(),
8736                    mem_stride,
8737                    fmt,
8738                    w,
8739                    h,
8740                );
8741                let cpu_dst = TensorDyn::image(
8742                    w,
8743                    h,
8744                    PixelFormat::Rgba,
8745                    DType::U8,
8746                    None,
8747                    edgefirst_tensor::CpuAccess::ReadWrite,
8748                )
8749                .unwrap();
8750                let (r, _s, cpu_dst) = convert_img(
8751                    &mut cpu,
8752                    mem,
8753                    cpu_dst,
8754                    Rotation::None,
8755                    Flip::None,
8756                    Crop::no_crop(),
8757                );
8758                r.unwrap_or_else(|e| panic!("CPU {fmt:?}->{w}x{h}->RGBA: {e}"));
8759
8760                // GPU source: a LARGER R8 pool surface, reconfigured down to the
8761                // frame. Phase 1 preserves the pool's padded `bytesPerRow` as the
8762                // tensor's row stride; the fill writes the frame at that stride.
8763                let mut ios = match TensorDyn::image(
8764                    pool_w,
8765                    pool_h,
8766                    PixelFormat::Grey,
8767                    DType::U8,
8768                    Some(TensorMemory::DmaBuf),
8769                    edgefirst_tensor::CpuAccess::ReadWrite,
8770                ) {
8771                    Ok(t) => t,
8772                    Err(e) => {
8773                        crate::test_support::report_skip(&format!(
8774                            "{} — R8 pool IOSurface alloc: {e:?}",
8775                            function!()
8776                        ));
8777                        return;
8778                    }
8779                };
8780                ios.configure_image(w, h, fmt)
8781                    .unwrap_or_else(|e| panic!("configure_image {fmt:?} {w}x{h} on pool: {e}"));
8782                let ios_stride = ios
8783                    .as_typed::<u8>()
8784                    .unwrap()
8785                    .effective_row_stride()
8786                    .unwrap();
8787                assert!(
8788                    ios_stride > ew,
8789                    "{fmt:?} {w}x{h}: pool stride {ios_stride} should exceed even width {ew} \
8790                     (test must exercise padding)"
8791                );
8792                fill(
8793                    ios.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice(),
8794                    ios_stride,
8795                    fmt,
8796                    w,
8797                    h,
8798                );
8799
8800                let gpu_dst = TensorDyn::image(
8801                    w,
8802                    h,
8803                    PixelFormat::Rgba,
8804                    DType::U8,
8805                    Some(TensorMemory::DmaBuf),
8806                    edgefirst_tensor::CpuAccess::ReadWrite,
8807                )
8808                .unwrap();
8809                let (r, _s, gpu_dst) = convert_img(
8810                    &mut gpu,
8811                    ios,
8812                    gpu_dst,
8813                    Rotation::None,
8814                    Flip::None,
8815                    Crop::no_crop(),
8816                );
8817                r.unwrap_or_else(|e| {
8818                    panic!("GPU {fmt:?}->{w}x{h}->RGBA (pool surface) on ANGLE: {e}")
8819                });
8820
8821                let cs = cpu_dst
8822                    .as_typed::<u8>()
8823                    .unwrap()
8824                    .effective_row_stride()
8825                    .unwrap();
8826                let cmap = cpu_dst.as_typed::<u8>().unwrap().map().unwrap();
8827                let cb = cmap.as_slice();
8828                let gs = gpu_dst
8829                    .as_typed::<u8>()
8830                    .unwrap()
8831                    .effective_row_stride()
8832                    .unwrap();
8833                let gmap = gpu_dst.as_typed::<u8>().unwrap().map().unwrap();
8834                let gb = gmap.as_slice();
8835                let mut max_d = 0i16;
8836                for y in 0..h {
8837                    for x in 0..w {
8838                        for c in 0..3 {
8839                            let cv = cb[y * cs + x * 4 + c] as i16;
8840                            let gv = gb[y * gs + x * 4 + c] as i16;
8841                            max_d = max_d.max((cv - gv).abs());
8842                        }
8843                    }
8844                }
8845                assert!(
8846                    max_d <= 3,
8847                    "{fmt:?} {w}x{h}: GPU(pool surface) vs CPU RGBA max channel diff {max_d} > 3"
8848                );
8849            }
8850        }
8851    }
8852
8853    #[test]
8854    #[cfg(target_os = "macos")]
8855    #[cfg(feature = "opengl")]
8856    fn test_yuyv_to_rgba_opengl_macos() {
8857        let mut proc = match GLProcessorThreaded::new(None) {
8858            Ok(p) => p,
8859            Err(e) => {
8860                crate::test_support::report_skip(&format!(
8861                    "{} — GL engine init failed ({e:?}). \
8862                     Install ANGLE via `brew install startergo/angle/angle` \
8863                     and re-sign per README.md § macOS GPU Acceleration to \
8864                     run this test.",
8865                    function!()
8866                ));
8867                return;
8868            }
8869        };
8870
8871        let src = load_bytes_to_tensor(
8872            1280,
8873            720,
8874            PixelFormat::Yuyv,
8875            Some(TensorMemory::DmaBuf),
8876            &edgefirst_bench::testdata::read("camera720p.yuyv"),
8877        )
8878        .unwrap();
8879
8880        let dst = TensorDyn::image(
8881            1280,
8882            720,
8883            PixelFormat::Rgba,
8884            DType::U8,
8885            Some(TensorMemory::DmaBuf),
8886            edgefirst_tensor::CpuAccess::ReadWrite,
8887        )
8888        .unwrap();
8889
8890        let (result, _src, dst) = convert_img(
8891            &mut proc,
8892            src,
8893            dst,
8894            Rotation::None,
8895            Flip::None,
8896            Crop::no_crop(),
8897        );
8898        result.unwrap();
8899
8900        let target_image = TensorDyn::image(
8901            1280,
8902            720,
8903            PixelFormat::Rgba,
8904            DType::U8,
8905            None,
8906            edgefirst_tensor::CpuAccess::ReadWrite,
8907        )
8908        .unwrap();
8909        target_image
8910            .as_typed::<u8>()
8911            .unwrap()
8912            .map()
8913            .unwrap()
8914            .as_mut_slice()
8915            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
8916
8917        // macOS YUYV shader now threads per-tensor colorimetry: the untagged
8918        // camera720p source resolves to BT.709 limited (matching the BT.709
8919        // reference), measured 0.9973 on ANGLE — up from 0.9733 under the old
8920        // BT.601-full stop-gap. 0.98 leaves headroom for cross-GPU variance.
8921        compare_images(&dst, &target_image, 0.98, function!());
8922    }
8923
8924    /// Multi-resolution smoke test: convert YUYV→RGBA via the GL
8925    /// backend at a small (64×32) frame and a 4K (3840×2160) frame,
8926    /// both filled with a synthetic mid-grey pattern. Validates the
8927    /// shader math at the chroma-pairing boundary on small textures
8928    /// and exercises the IOSurface bytes-per-row alignment path at 4K
8929    /// (3840 pixels × 2 bytes/pixel = 7680 bytes, naturally 64-aligned).
8930    ///
8931    /// Resolutions below 32 pixels wide aren't tested because the
8932    /// IOSurface allocator pads bpr to 64 bytes — for a 4-px-wide
8933    /// YUYV surface that's 8 bytes data + 56 bytes padding per row,
8934    /// which exercises a sampling pattern that's ANGLE-version
8935    /// dependent rather than HAL-correctness dependent.
8936    ///
8937    /// This complements `test_yuyv_to_rgba_opengl_macos` (which checks
8938    /// pixel-exact correctness against a reference image at 720p) by
8939    /// ensuring the pipeline does not crash or produce gross errors at
8940    /// resolution extremes. Pixel-exact validation at 4K would require
8941    /// a 30 MB reference file we don't want to bundle.
8942    #[test]
8943    #[cfg(target_os = "macos")]
8944    #[cfg(feature = "opengl")]
8945    fn test_yuyv_to_rgba_opengl_macos_multi_resolution() {
8946        let mut proc = match GLProcessorThreaded::new(None) {
8947            Ok(p) => p,
8948            Err(e) => {
8949                crate::test_support::report_skip(&format!(
8950                    "{} — GL engine init failed ({e:?})",
8951                    function!()
8952                ));
8953                return;
8954            }
8955        };
8956
8957        for (w, h) in [(64usize, 32usize), (3840, 2160)] {
8958            // Synthetic YUYV: Y=128 (mid-grey luma), U=V=128 (neutral
8959            // chroma) → RGB grey at the output.
8960            let bytes_per_row = w * 2;
8961            let mut yuyv = vec![0u8; bytes_per_row * h];
8962            for chunk in yuyv.as_chunks_mut::<4>().0 {
8963                chunk[0] = 128; // Y0
8964                chunk[1] = 128; // U
8965                chunk[2] = 128; // Y1
8966                chunk[3] = 128; // V
8967            }
8968
8969            let src =
8970                load_bytes_to_tensor(w, h, PixelFormat::Yuyv, Some(TensorMemory::DmaBuf), &yuyv)
8971                    .unwrap();
8972
8973            let dst = TensorDyn::image(
8974                w,
8975                h,
8976                PixelFormat::Rgba,
8977                DType::U8,
8978                Some(TensorMemory::DmaBuf),
8979                edgefirst_tensor::CpuAccess::ReadWrite,
8980            )
8981            .unwrap();
8982
8983            let (result, _src, dst) = convert_img(
8984                &mut proc,
8985                src,
8986                dst,
8987                Rotation::None,
8988                Flip::None,
8989                Crop::no_crop(),
8990            );
8991            result.expect("GL convert should succeed at this resolution");
8992
8993            // The neutral-chroma input must produce a near-grey output;
8994            // BT.709 limited-range maps Y=128/UV=128 → roughly
8995            // (130, 130, 130). Allow ±4 LSB for `mediump float` shader
8996            // rounding.
8997            let dst_u8 = dst.as_typed::<u8>().unwrap();
8998            let dst_map = dst_u8.map().unwrap();
8999            let dst_bytes = dst_map.as_slice();
9000            assert_eq!(dst_bytes.len(), w * h * 4, "RGBA byte count");
9001            for px in dst_bytes.as_chunks::<4>().0 {
9002                for (i, &c) in px[..3].iter().enumerate() {
9003                    assert!(
9004                        (120..=140).contains(&c),
9005                        "{}: channel {i} = {c} (expected ~128 ±12) at {w}×{h}",
9006                        function!(),
9007                    );
9008                }
9009                assert_eq!(px[3], 255, "alpha must be 1.0");
9010            }
9011        }
9012    }
9013
9014    /// Verify that two consecutive convert() calls on the same source
9015    /// tensor reuse the cached EGL pbuffer. Tests the cache hit path
9016    /// added with the macOS GL backend hardening — without it, each
9017    /// frame would pay `eglCreatePbufferFromClientBuffer` + destroy.
9018    ///
9019    /// This is a behaviour test rather than a perf test (the timing
9020    /// difference is 100-200µs which is too noisy to assert on); we
9021    /// check that the second call succeeds and produces a result
9022    /// identical to the first.
9023    #[test]
9024    #[cfg(target_os = "macos")]
9025    #[cfg(feature = "opengl")]
9026    fn test_macos_gl_pbuffer_cache_reuses_surfaces() {
9027        let mut proc = match GLProcessorThreaded::new(None) {
9028            Ok(p) => p,
9029            Err(e) => {
9030                crate::test_support::report_skip(&format!(
9031                    "{} — GL engine init failed ({e:?})",
9032                    function!()
9033                ));
9034                return;
9035            }
9036        };
9037
9038        // Allocate one source + one destination, run convert twice.
9039        let mut yuyv = vec![0u8; 64 * 32 * 2];
9040        for chunk in yuyv.as_chunks_mut::<4>().0 {
9041            chunk[0] = 200;
9042            chunk[1] = 100;
9043            chunk[2] = 200;
9044            chunk[3] = 156;
9045        }
9046        let src =
9047            load_bytes_to_tensor(64, 32, PixelFormat::Yuyv, Some(TensorMemory::DmaBuf), &yuyv)
9048                .unwrap();
9049        let dst = TensorDyn::image(
9050            64,
9051            32,
9052            PixelFormat::Rgba,
9053            DType::U8,
9054            Some(TensorMemory::DmaBuf),
9055            edgefirst_tensor::CpuAccess::ReadWrite,
9056        )
9057        .unwrap();
9058
9059        let (r1, src, dst) = convert_img(
9060            &mut proc,
9061            src,
9062            dst,
9063            Rotation::None,
9064            Flip::None,
9065            Crop::no_crop(),
9066        );
9067        r1.unwrap();
9068        let first: Vec<u8> = dst
9069            .as_typed::<u8>()
9070            .unwrap()
9071            .map()
9072            .unwrap()
9073            .as_slice()
9074            .to_vec();
9075
9076        let (r2, _src, dst) = convert_img(
9077            &mut proc,
9078            src,
9079            dst,
9080            Rotation::None,
9081            Flip::None,
9082            Crop::no_crop(),
9083        );
9084        r2.unwrap();
9085        let second: Vec<u8> = dst
9086            .as_typed::<u8>()
9087            .unwrap()
9088            .map()
9089            .unwrap()
9090            .as_slice()
9091            .to_vec();
9092
9093        assert_eq!(first, second, "cache-hit conversion must be deterministic");
9094    }
9095
9096    /// Steady-state import gate (macOS half of the Linux
9097    /// `dma_pool_steady_state_zero_imports` test): an N-frame convert loop
9098    /// over a fixed pool of IOSurface tensors must create ZERO new EGL
9099    /// pbuffers after the pool has been seen once — pbuffer-cache misses
9100    /// stay flat while hits grow. Counter-based hardening of
9101    /// `test_macos_gl_pbuffer_cache_reuses_surfaces` above: a refactor that
9102    /// re-imports per frame passes the pixel-equality test but fails this.
9103    #[test]
9104    #[cfg(target_os = "macos")]
9105    #[cfg(feature = "opengl")]
9106    fn test_macos_gl_pbuffer_cache_steady_state() {
9107        let mut proc = match GLProcessorThreaded::new(None) {
9108            Ok(p) => p,
9109            Err(e) => {
9110                crate::test_support::report_skip(&format!(
9111                    "{} — GL engine init failed ({e:?})",
9112                    function!()
9113                ));
9114                return;
9115            }
9116        };
9117
9118        let (w, h) = (64usize, 32usize);
9119        const POOL: usize = 3;
9120        const FRAMES: usize = 100;
9121
9122        let yuyv = vec![128u8; w * h * 2];
9123        let pool: Vec<TensorDyn> = (0..POOL)
9124            .map(|_| {
9125                load_bytes_to_tensor(w, h, PixelFormat::Yuyv, Some(TensorMemory::DmaBuf), &yuyv)
9126                    .unwrap()
9127            })
9128            .collect();
9129        let mut dst = TensorDyn::image(
9130            w,
9131            h,
9132            PixelFormat::Rgba,
9133            DType::U8,
9134            Some(TensorMemory::DmaBuf),
9135            edgefirst_tensor::CpuAccess::ReadWrite,
9136        )
9137        .unwrap();
9138
9139        // Warmup: two passes over the pool import every surface once.
9140        for src in pool.iter().cycle().take(POOL * 2) {
9141            proc.convert(src, &mut dst, Rotation::None, Flip::None, Crop::no_crop())
9142                .unwrap();
9143        }
9144        let warm = proc.egl_cache_stats().unwrap();
9145
9146        for src in pool.iter().cycle().take(FRAMES) {
9147            proc.convert(src, &mut dst, Rotation::None, Flip::None, Crop::no_crop())
9148                .unwrap();
9149        }
9150        let steady = proc.egl_cache_stats().unwrap();
9151
9152        assert_eq!(
9153            warm.total_misses(),
9154            steady.total_misses(),
9155            "steady-state loop created new imports (warm {warm:?}, steady {steady:?})"
9156        );
9157        let hits = |s: &GlCacheStats| s.src.hits + s.dst.hits + s.nv_r8.hits;
9158        assert!(
9159            hits(&steady) - hits(&warm) >= FRAMES as u64,
9160            "expected at least {FRAMES} import-cache hits over the loop, got {}",
9161            hits(&steady) - hits(&warm)
9162        );
9163    }
9164
9165    /// Backend assertion for the F16 zero-copy path: when the GL backend
9166    /// initialized (ANGLE on macOS) and reports F16 color-buffer support,
9167    /// the NV12→PlanarRgb-F16 IOSurface convert MUST be handled by the GL
9168    /// engine — proven by the engine's import-cache counters moving, not
9169    /// just by output correctness. This is the guard against the
9170    /// silent-CPU-fallback failure mode: a misclassified IOSurface F16
9171    /// destination keeps every output-correctness test green while
9172    /// quietly running ~10× slower on CPU; only a backend observable
9173    /// catches it. Skips ONLY when GL itself is unavailable or the
9174    /// configuration lacks F16 — a convert error or a CPU-routed convert
9175    /// with the capability present is a FAILURE.
9176    #[test]
9177    #[cfg(target_os = "macos")]
9178    #[cfg(feature = "opengl")]
9179    fn test_macos_gl_f16_planar_is_gl_backed() {
9180        let mut proc = ImageProcessor::new().expect("ImageProcessor");
9181        let Some(ref gl) = proc.opengl else {
9182            crate::test_support::report_skip(&format!("{} — GL backend unavailable", function!()));
9183            return;
9184        };
9185        if !gl.supported_render_dtypes().f16 {
9186            crate::test_support::report_skip(&format!(
9187                "{} — configuration lacks F16 color-buffer support",
9188                function!()
9189            ));
9190            return;
9191        }
9192        let stats_before = gl.egl_cache_stats().expect("cache stats");
9193
9194        let src = TensorDyn::image(
9195            1280,
9196            720,
9197            PixelFormat::Nv12,
9198            DType::U8,
9199            Some(TensorMemory::DmaBuf),
9200            edgefirst_tensor::CpuAccess::ReadWrite,
9201        )
9202        .unwrap();
9203        {
9204            let t = src.as_typed::<u8>().unwrap();
9205            let mut m = t.map().unwrap();
9206            for (i, b) in m.as_mut_slice().iter_mut().enumerate() {
9207                *b = ((i * 31) % 211) as u8;
9208            }
9209        }
9210        let mut dst = TensorDyn::image(
9211            640,
9212            640,
9213            PixelFormat::PlanarRgb,
9214            DType::F16,
9215            Some(TensorMemory::DmaBuf),
9216            edgefirst_tensor::CpuAccess::ReadWrite,
9217        )
9218        .unwrap();
9219
9220        proc.convert(
9221            &src,
9222            &mut dst,
9223            Rotation::None,
9224            Flip::None,
9225            Crop::letterbox([114, 114, 114, 255]),
9226        )
9227        .expect("F16 capability reported but the NV12→PlanarF16 convert failed");
9228        let stats_after = proc
9229            .opengl
9230            .as_ref()
9231            .expect("GL backend present")
9232            .egl_cache_stats()
9233            .expect("cache stats");
9234        // ≥ 2 misses: the fused convert imports the zero-copy NV source
9235        // (pass 1) AND the F16 destination (pass 2). Requiring both means a
9236        // convert that imported its source, failed mid-engine, and fell back
9237        // to CPU (1 miss) cannot satisfy this gate.
9238        assert!(
9239            stats_after.total_misses() >= stats_before.total_misses() + 2,
9240            "convert succeeded but the GL engine did not import both the \
9241             source and the F16 destination — the work did not (fully) run \
9242             on the GL backend (silent CPU fallback); misses before={} after={}",
9243            stats_before.total_misses(),
9244            stats_after.total_misses()
9245        );
9246    }
9247
9248    /// Portable oracle for the fused NV12→PlanarRgb-F16 engine convert
9249    /// (two GL passes: NV→RGBA intermediate, then the packed RGBA16F
9250    /// render). New on every platform with F16 render support — macOS
9251    /// IOSurface and Linux DMA-BUF alike. Compares against the CPU
9252    /// backend's reference within the float-path tolerance.
9253    #[test]
9254    #[cfg(feature = "opengl")]
9255    fn test_nv12_to_planar_f16_fused_engine_vs_cpu() {
9256        let mut gl = match ImageProcessor::with_config(ImageProcessorConfig {
9257            backend: ComputeBackend::OpenGl,
9258            ..Default::default()
9259        }) {
9260            Ok(p) if p.opengl.is_some() => p,
9261            _ => {
9262                crate::test_support::report_skip(&format!(
9263                    "{} — GL backend unavailable",
9264                    function!()
9265                ));
9266                return;
9267            }
9268        };
9269        if !gl
9270            .opengl
9271            .as_ref()
9272            .map(|g| g.supported_render_dtypes().f16)
9273            .unwrap_or(false)
9274        {
9275            crate::test_support::report_skip(&format!("{} — no F16 render support", function!()));
9276            return;
9277        }
9278        let mem = if edgefirst_tensor::is_gpu_buffer_available() {
9279            TensorMemory::DmaBuf
9280        } else {
9281            crate::test_support::report_skip(&format!("{} — no zero-copy buffers", function!()));
9282            return;
9283        };
9284
9285        let src = TensorDyn::image(
9286            1280,
9287            720,
9288            PixelFormat::Nv12,
9289            DType::U8,
9290            Some(mem),
9291            edgefirst_tensor::CpuAccess::ReadWrite,
9292        )
9293        .unwrap();
9294        {
9295            // Smooth gradients, NOT noise: the GL and CPU paths upsample
9296            // chroma with different kernels (nearest vs bilinear), which
9297            // legitimately diverges on per-texel chroma noise. Gradients
9298            // keep that kernel difference sub-LSB while still exercising
9299            // the full matrix math and the letterbox geometry.
9300            let t = src.as_typed::<u8>().unwrap();
9301            let mut m = t.map().unwrap();
9302            let buf = m.as_mut_slice();
9303            let (w, h) = (1280usize, 720usize);
9304            for y in 0..h {
9305                for x in 0..w {
9306                    buf[y * w + x] = ((x * 255) / w) as u8; // luma ramp
9307                }
9308            }
9309            for y in 0..(h / 2) {
9310                for x in 0..(w / 2) {
9311                    let o = h * w + y * w + 2 * x;
9312                    buf[o] = ((y * 255) / (h / 2)) as u8; // U vertical ramp
9313                    buf[o + 1] = (((x + y) * 255) / (w / 2 + h / 2)) as u8; // V diagonal
9314                }
9315            }
9316        }
9317        let crop = Crop::letterbox([114, 114, 114, 255]);
9318        let mut gl_dst = TensorDyn::image(
9319            640,
9320            640,
9321            PixelFormat::PlanarRgb,
9322            DType::F16,
9323            Some(mem),
9324            edgefirst_tensor::CpuAccess::ReadWrite,
9325        )
9326        .unwrap();
9327        // Drive the GL backend DIRECTLY: a convert through `ImageProcessor`
9328        // silently falls back to CPU on a GL error, turning this oracle into
9329        // a CPU-vs-CPU tautology. A direct call surfaces the engine error.
9330        // GPUs that allocate DMA-BUF but cannot complete an RGBA16F FBO
9331        // (desktop NVIDIA: GL_FRAMEBUFFER_INCOMPLETE_ATTACHMENT 0x8cd6)
9332        // take the production CPU fallback; this oracle cannot run there.
9333        match gl.opengl.as_mut().expect("GL backend present").convert(
9334            &src,
9335            &mut gl_dst,
9336            Rotation::None,
9337            Flip::None,
9338            crop,
9339        ) {
9340            Ok(()) => {}
9341            Err(crate::Error::NotSupported(msg)) => {
9342                crate::test_support::report_skip(&format!("{} — {msg}", function!()));
9343                return;
9344            }
9345            Err(e) => panic!("fused NV12→PlanarF16 GL convert: {e}"),
9346        }
9347
9348        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
9349            backend: ComputeBackend::Cpu,
9350            ..Default::default()
9351        })
9352        .unwrap();
9353        let mut cpu_dst = TensorDyn::image(
9354            640,
9355            640,
9356            PixelFormat::PlanarRgb,
9357            DType::F16,
9358            Some(TensorMemory::Mem),
9359            edgefirst_tensor::CpuAccess::ReadWrite,
9360        )
9361        .unwrap();
9362        cpu.convert(&src, &mut cpu_dst, Rotation::None, Flip::None, crop)
9363            .expect("CPU reference convert");
9364
9365        let g = gl_dst
9366            .as_typed::<half::f16>()
9367            .unwrap()
9368            .map()
9369            .unwrap()
9370            .as_slice()
9371            .to_vec();
9372        let c = cpu_dst
9373            .as_typed::<half::f16>()
9374            .unwrap()
9375            .map()
9376            .unwrap()
9377            .as_slice()
9378            .to_vec();
9379        assert_eq!(g.len(), c.len());
9380        let mut max_diff = 0.0f32;
9381        let mut max_at = 0usize;
9382        for (i, (a, b)) in g.iter().zip(c.iter()).enumerate() {
9383            let d = (a.to_f32() - b.to_f32()).abs();
9384            if d > max_diff {
9385                max_diff = d;
9386                max_at = i;
9387            }
9388        }
9389        // Localize: plane (R/G/B), row, col of the worst element.
9390        let (plane, rem) = (max_at / (640 * 640), max_at % (640 * 640));
9391        let (row, col) = (rem / 640, rem % 640);
9392        eprintln!(
9393            "fused-vs-cpu: max_diff={max_diff} at plane={plane} row={row} col={col} \
9394             gl={} cpu={}",
9395            g[max_at].to_f32(),
9396            c[max_at].to_f32()
9397        );
9398        // Two GPU passes (8-bit intermediate + linear filtering) vs the
9399        // CPU's direct path: allow a few 8-bit steps of divergence.
9400        assert!(
9401            max_diff <= 4.0 / 255.0 + 1e-3,
9402            "fused NV12→PlanarF16 diverges from CPU reference: max_diff={max_diff}"
9403        );
9404    }
9405
9406    /// Zero-copy source → heap destination through the GL engine,
9407    /// driven on the GL backend DIRECTLY so a GL error cannot hide
9408    /// behind the `ImageProcessor` CPU fallback (the shape that exposed
9409    /// the macOS `glReadnPixels` failure: imported source, rendered,
9410    /// then errored at the heap readback). RGBA→BGRA so the convert is
9411    /// a pure byte shuffle: no chroma kernel or colorimetry ambiguity
9412    /// (Vivante's NV fast path legitimately diverges from the CPU
9413    /// reference), and the readback stays GL_RGBA (V3D rejects RGB
9414    /// readbacks).
9415    #[test]
9416    #[cfg(feature = "opengl")]
9417    fn test_zero_copy_src_to_mem_dst_gl_direct() {
9418        let mut proc = match ImageProcessor::new() {
9419            Ok(p) if p.opengl.is_some() => p,
9420            _ => {
9421                crate::test_support::report_skip(&format!(
9422                    "{} — GL backend unavailable",
9423                    function!()
9424                ));
9425                return;
9426            }
9427        };
9428        if !edgefirst_tensor::is_gpu_buffer_available() {
9429            crate::test_support::report_skip(&format!("{} — no zero-copy buffers", function!()));
9430            return;
9431        }
9432
9433        let src = TensorDyn::image(
9434            1280,
9435            720,
9436            PixelFormat::Rgba,
9437            DType::U8,
9438            Some(TensorMemory::DmaBuf),
9439            edgefirst_tensor::CpuAccess::ReadWrite,
9440        )
9441        .unwrap();
9442        {
9443            let t = src.as_typed::<u8>().unwrap();
9444            let mut m = t.map().unwrap();
9445            for (i, b) in m.as_mut_slice().iter_mut().enumerate() {
9446                *b = ((i * 31) % 211) as u8;
9447            }
9448        }
9449        let mut gl_dst = TensorDyn::image(
9450            1280,
9451            720,
9452            PixelFormat::Bgra,
9453            DType::U8,
9454            Some(TensorMemory::Mem),
9455            edgefirst_tensor::CpuAccess::ReadWrite,
9456        )
9457        .unwrap();
9458        proc.opengl
9459            .as_mut()
9460            .expect("GL backend present")
9461            .convert(
9462                &src,
9463                &mut gl_dst,
9464                Rotation::None,
9465                Flip::None,
9466                Crop::no_crop(),
9467            )
9468            .expect("zero-copy src → heap dst GL convert");
9469
9470        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
9471            backend: ComputeBackend::Cpu,
9472            ..Default::default()
9473        })
9474        .unwrap();
9475        let mut cpu_dst = TensorDyn::image(
9476            1280,
9477            720,
9478            PixelFormat::Bgra,
9479            DType::U8,
9480            Some(TensorMemory::Mem),
9481            edgefirst_tensor::CpuAccess::ReadWrite,
9482        )
9483        .unwrap();
9484        cpu.convert(
9485            &src,
9486            &mut cpu_dst,
9487            Rotation::None,
9488            Flip::None,
9489            Crop::no_crop(),
9490        )
9491        .expect("CPU reference convert");
9492
9493        let g = gl_dst
9494            .as_typed::<u8>()
9495            .unwrap()
9496            .map()
9497            .unwrap()
9498            .as_slice()
9499            .to_vec();
9500        let c = cpu_dst
9501            .as_typed::<u8>()
9502            .unwrap()
9503            .map()
9504            .unwrap()
9505            .as_slice()
9506            .to_vec();
9507        assert_eq!(g.len(), c.len());
9508        let max_diff = g
9509            .iter()
9510            .zip(c.iter())
9511            .map(|(a, b)| a.abs_diff(*b))
9512            .max()
9513            .unwrap();
9514        // Same-size byte shuffle: GL samples texel centers 1:1, so allow
9515        // only rounding slack.
9516        assert!(
9517            max_diff <= 2,
9518            "zero-copy src → heap dst diverges from CPU reference: max_diff={max_diff}"
9519        );
9520    }
9521
9522    #[test]
9523    #[cfg(target_os = "linux")]
9524    fn test_yuyv_to_rgb_g2d() {
9525        if !is_g2d_available() {
9526            crate::test_support::report_skip(
9527                "test_yuyv_to_rgb_g2d - G2D library (libg2d.so.2) not available",
9528            );
9529            return;
9530        }
9531        if !is_dma_available() {
9532            crate::test_support::report_skip("test_yuyv_to_rgb_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
9533            return;
9534        }
9535
9536        let src = load_bytes_to_tensor(
9537            1280,
9538            720,
9539            PixelFormat::Yuyv,
9540            None,
9541            &edgefirst_bench::testdata::read("camera720p.yuyv"),
9542        )
9543        .unwrap();
9544
9545        let g2d_dst = TensorDyn::image(
9546            1280,
9547            720,
9548            PixelFormat::Rgb,
9549            DType::U8,
9550            Some(TensorMemory::DmaBuf),
9551            edgefirst_tensor::CpuAccess::ReadWrite,
9552        )
9553        .unwrap();
9554        let mut g2d_converter = G2DProcessor::new().unwrap();
9555
9556        let (result, src, g2d_dst) = convert_img(
9557            &mut g2d_converter,
9558            src,
9559            g2d_dst,
9560            Rotation::None,
9561            Flip::None,
9562            Crop::no_crop(),
9563        );
9564        result.unwrap();
9565
9566        let cpu_dst = TensorDyn::image(
9567            1280,
9568            720,
9569            PixelFormat::Rgb,
9570            DType::U8,
9571            None,
9572            edgefirst_tensor::CpuAccess::ReadWrite,
9573        )
9574        .unwrap();
9575        let mut cpu_converter: CPUProcessor = CPUProcessor::new();
9576
9577        let (result, _src, cpu_dst) = convert_img(
9578            &mut cpu_converter,
9579            src,
9580            cpu_dst,
9581            Rotation::None,
9582            Flip::None,
9583            Crop::no_crop(),
9584        );
9585        result.unwrap();
9586
9587        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
9588        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
9589        // the YUV-matrix delta that forced 0.95 has closed; tightened to
9590        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
9591        // structural gap not exercised by these limited-range fixtures.
9592        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
9593    }
9594
9595    #[test]
9596    #[cfg(target_os = "linux")]
9597    fn test_yuyv_to_yuyv_resize_g2d() {
9598        if !is_g2d_available() {
9599            crate::test_support::report_skip(
9600                "test_yuyv_to_yuyv_resize_g2d - G2D library (libg2d.so.2) not available",
9601            );
9602            return;
9603        }
9604        if !is_dma_available() {
9605            crate::test_support::report_skip("test_yuyv_to_yuyv_resize_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
9606            return;
9607        }
9608
9609        let src = load_bytes_to_tensor(
9610            1280,
9611            720,
9612            PixelFormat::Yuyv,
9613            None,
9614            &edgefirst_bench::testdata::read("camera720p.yuyv"),
9615        )
9616        .unwrap();
9617
9618        let g2d_dst = TensorDyn::image(
9619            600,
9620            400,
9621            PixelFormat::Yuyv,
9622            DType::U8,
9623            Some(TensorMemory::DmaBuf),
9624            edgefirst_tensor::CpuAccess::ReadWrite,
9625        )
9626        .unwrap();
9627        let mut g2d_converter = G2DProcessor::new().unwrap();
9628
9629        let (result, src, g2d_dst) = convert_img(
9630            &mut g2d_converter,
9631            src,
9632            g2d_dst,
9633            Rotation::None,
9634            Flip::None,
9635            Crop::no_crop(),
9636        );
9637        result.unwrap();
9638
9639        let cpu_dst = TensorDyn::image(
9640            600,
9641            400,
9642            PixelFormat::Yuyv,
9643            DType::U8,
9644            None,
9645            edgefirst_tensor::CpuAccess::ReadWrite,
9646        )
9647        .unwrap();
9648        let mut cpu_converter: CPUProcessor = CPUProcessor::new();
9649
9650        let (result, _src, cpu_dst) = convert_img(
9651            &mut cpu_converter,
9652            src,
9653            cpu_dst,
9654            Rotation::None,
9655            Flip::None,
9656            Crop::no_crop(),
9657        );
9658        result.unwrap();
9659
9660        // G2D has poor colorimetry support: its hardware YUYV resize/sampling
9661        // diverges from the CPU reference by enough that the similarity score
9662        // sits around 0.85 on every measured G2D core (i.MX 8M Plus, i.MX 95).
9663        // The threshold is held at 0.85 so the test guards against gross
9664        // regressions while tolerating the driver's inherent colorimetry error.
9665        // TODO: compare YUYV↔YUYV directly without a YUYV→RGB convert.
9666        eprintln!(
9667            "WARNING: G2D has poor colorimetry support — YUYV resize diverges from the \
9668             CPU reference (~0.85 similarity); threshold held at 0.85, not 0.95."
9669        );
9670        compare_images_convert_to_rgb(&g2d_dst, &cpu_dst, 0.85, function!());
9671    }
9672
9673    #[test]
9674    fn test_yuyv_to_rgba_resize_cpu() {
9675        let src = load_bytes_to_tensor(
9676            1280,
9677            720,
9678            PixelFormat::Yuyv,
9679            None,
9680            &edgefirst_bench::testdata::read("camera720p.yuyv"),
9681        )
9682        .unwrap();
9683
9684        let (dst_width, dst_height) = (960, 540);
9685
9686        let dst = TensorDyn::image(
9687            dst_width,
9688            dst_height,
9689            PixelFormat::Rgba,
9690            DType::U8,
9691            None,
9692            edgefirst_tensor::CpuAccess::ReadWrite,
9693        )
9694        .unwrap();
9695        let mut cpu_converter = CPUProcessor::new();
9696
9697        let (result, _src, dst) = convert_img(
9698            &mut cpu_converter,
9699            src,
9700            dst,
9701            Rotation::None,
9702            Flip::None,
9703            Crop::no_crop(),
9704        );
9705        result.unwrap();
9706
9707        let dst_target = TensorDyn::image(
9708            dst_width,
9709            dst_height,
9710            PixelFormat::Rgba,
9711            DType::U8,
9712            None,
9713            edgefirst_tensor::CpuAccess::ReadWrite,
9714        )
9715        .unwrap();
9716        let src_target = load_bytes_to_tensor(
9717            1280,
9718            720,
9719            PixelFormat::Rgba,
9720            None,
9721            &edgefirst_bench::testdata::read("camera720p.rgba"),
9722        )
9723        .unwrap();
9724        let (result, _src_target, dst_target) = convert_img(
9725            &mut cpu_converter,
9726            src_target,
9727            dst_target,
9728            Rotation::None,
9729            Flip::None,
9730            Crop::no_crop(),
9731        );
9732        result.unwrap();
9733
9734        // CPU path resolves the untagged 720p source to BT.709 limited (height
9735        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
9736        compare_images(&dst, &dst_target, 0.98, function!());
9737    }
9738
9739    #[test]
9740    #[cfg(target_os = "linux")]
9741    fn test_yuyv_to_rgba_crop_flip_g2d() {
9742        if !is_g2d_available() {
9743            crate::test_support::report_skip(
9744                "test_yuyv_to_rgba_crop_flip_g2d - G2D library (libg2d.so.2) not available",
9745            );
9746            return;
9747        }
9748        if !is_dma_available() {
9749            crate::test_support::report_skip("test_yuyv_to_rgba_crop_flip_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
9750            return;
9751        }
9752
9753        let src = load_bytes_to_tensor(
9754            1280,
9755            720,
9756            PixelFormat::Yuyv,
9757            Some(TensorMemory::DmaBuf),
9758            &edgefirst_bench::testdata::read("camera720p.yuyv"),
9759        )
9760        .unwrap();
9761
9762        let (dst_width, dst_height) = (640, 640);
9763
9764        let dst_g2d = TensorDyn::image(
9765            dst_width,
9766            dst_height,
9767            PixelFormat::Rgba,
9768            DType::U8,
9769            Some(TensorMemory::DmaBuf),
9770            edgefirst_tensor::CpuAccess::ReadWrite,
9771        )
9772        .unwrap();
9773        let mut g2d_converter = G2DProcessor::new().unwrap();
9774        let crop = Crop::new().with_source(Some(Region::new(20, 15, 400, 300)));
9775
9776        let (result, src, dst_g2d) = convert_img(
9777            &mut g2d_converter,
9778            src,
9779            dst_g2d,
9780            Rotation::None,
9781            Flip::Horizontal,
9782            crop,
9783        );
9784        result.unwrap();
9785
9786        let dst_cpu = TensorDyn::image(
9787            dst_width,
9788            dst_height,
9789            PixelFormat::Rgba,
9790            DType::U8,
9791            Some(TensorMemory::DmaBuf),
9792            edgefirst_tensor::CpuAccess::ReadWrite,
9793        )
9794        .unwrap();
9795        let mut cpu_converter = CPUProcessor::new();
9796
9797        let (result, _src, dst_cpu) = convert_img(
9798            &mut cpu_converter,
9799            src,
9800            dst_cpu,
9801            Rotation::None,
9802            Flip::Horizontal,
9803            crop,
9804        );
9805        result.unwrap();
9806        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
9807        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
9808        // the YUV-matrix delta that forced 0.95 has closed; tightened to
9809        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
9810        // structural gap not exercised by these limited-range fixtures.
9811        compare_images(&dst_g2d, &dst_cpu, 0.98, function!());
9812    }
9813
9814    #[test]
9815    #[cfg(any(
9816        target_os = "linux",
9817        target_os = "macos",
9818        target_os = "ios",
9819        target_os = "android",
9820        target_os = "windows"
9821    ))]
9822    #[cfg(feature = "opengl")]
9823    fn test_yuyv_to_rgba_crop_flip_opengl() {
9824        if !is_opengl_available() {
9825            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
9826            return;
9827        }
9828
9829        if !edgefirst_tensor::is_gpu_buffer_available() {
9830            crate::test_support::report_skip(&format!(
9831                "{} - zero-copy GPU buffer (DMA-BUF / IOSurface) not available on this host",
9832                function!()
9833            ));
9834            return;
9835        }
9836
9837        let src = load_bytes_to_tensor(
9838            1280,
9839            720,
9840            PixelFormat::Yuyv,
9841            Some(TensorMemory::DmaBuf),
9842            &edgefirst_bench::testdata::read("camera720p.yuyv"),
9843        )
9844        .unwrap();
9845
9846        let (dst_width, dst_height) = (640, 640);
9847
9848        let dst_gl = TensorDyn::image(
9849            dst_width,
9850            dst_height,
9851            PixelFormat::Rgba,
9852            DType::U8,
9853            Some(TensorMemory::DmaBuf),
9854            edgefirst_tensor::CpuAccess::ReadWrite,
9855        )
9856        .unwrap();
9857        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
9858        let crop = Crop::new().with_source(Some(Region::new(20, 15, 400, 300)));
9859
9860        let (result, src, dst_gl) = convert_img(
9861            &mut gl_converter,
9862            src,
9863            dst_gl,
9864            Rotation::None,
9865            Flip::Horizontal,
9866            crop,
9867        );
9868        // Desktop NVIDIA cannot sample YUYV as a GL texture; i.MX/Vivante can.
9869        if let Err(crate::Error::NotSupported(msg)) = &result {
9870            crate::test_support::report_skip(&format!("{} — {msg}", function!()));
9871            return;
9872        }
9873        result.unwrap();
9874
9875        let dst_cpu = TensorDyn::image(
9876            dst_width,
9877            dst_height,
9878            PixelFormat::Rgba,
9879            DType::U8,
9880            Some(TensorMemory::DmaBuf),
9881            edgefirst_tensor::CpuAccess::ReadWrite,
9882        )
9883        .unwrap();
9884        let mut cpu_converter = CPUProcessor::new();
9885
9886        let (result, _src, dst_cpu) = convert_img(
9887            &mut cpu_converter,
9888            src,
9889            dst_cpu,
9890            Rotation::None,
9891            Flip::Horizontal,
9892            crop,
9893        );
9894        result.unwrap();
9895        // Post-WS1 the GL path applies the resolved colorimetry via the EGL
9896        // YUV color-space/sample-range hints, so the matrix delta that forced
9897        // 0.95 has closed; tightened to 0.98 (driver-matrix rounding confirmed
9898        // on the GPU lanes).
9899        compare_images(&dst_gl, &dst_cpu, 0.98, function!());
9900    }
9901
9902    #[test]
9903    fn test_vyuy_to_rgba_cpu() {
9904        let file = edgefirst_bench::testdata::read("camera720p.vyuy").to_vec();
9905        let src = TensorDyn::image(
9906            1280,
9907            720,
9908            PixelFormat::Vyuy,
9909            DType::U8,
9910            None,
9911            edgefirst_tensor::CpuAccess::ReadWrite,
9912        )
9913        .unwrap();
9914        src.as_typed::<u8>()
9915            .unwrap()
9916            .map()
9917            .unwrap()
9918            .as_mut_slice()
9919            .copy_from_slice(&file);
9920
9921        let dst = TensorDyn::image(
9922            1280,
9923            720,
9924            PixelFormat::Rgba,
9925            DType::U8,
9926            None,
9927            edgefirst_tensor::CpuAccess::ReadWrite,
9928        )
9929        .unwrap();
9930        let mut cpu_converter = CPUProcessor::new();
9931
9932        let (result, _src, dst) = convert_img(
9933            &mut cpu_converter,
9934            src,
9935            dst,
9936            Rotation::None,
9937            Flip::None,
9938            Crop::no_crop(),
9939        );
9940        result.unwrap();
9941
9942        let target_image = TensorDyn::image(
9943            1280,
9944            720,
9945            PixelFormat::Rgba,
9946            DType::U8,
9947            None,
9948            edgefirst_tensor::CpuAccess::ReadWrite,
9949        )
9950        .unwrap();
9951        target_image
9952            .as_typed::<u8>()
9953            .unwrap()
9954            .map()
9955            .unwrap()
9956            .as_mut_slice()
9957            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
9958
9959        // CPU path resolves the untagged 720p source to BT.709 limited (height
9960        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
9961        compare_images(&dst, &target_image, 0.98, function!());
9962    }
9963
9964    #[test]
9965    fn test_vyuy_to_rgb_cpu() {
9966        let file = edgefirst_bench::testdata::read("camera720p.vyuy").to_vec();
9967        let src = TensorDyn::image(
9968            1280,
9969            720,
9970            PixelFormat::Vyuy,
9971            DType::U8,
9972            None,
9973            edgefirst_tensor::CpuAccess::ReadWrite,
9974        )
9975        .unwrap();
9976        src.as_typed::<u8>()
9977            .unwrap()
9978            .map()
9979            .unwrap()
9980            .as_mut_slice()
9981            .copy_from_slice(&file);
9982
9983        let dst = TensorDyn::image(
9984            1280,
9985            720,
9986            PixelFormat::Rgb,
9987            DType::U8,
9988            None,
9989            edgefirst_tensor::CpuAccess::ReadWrite,
9990        )
9991        .unwrap();
9992        let mut cpu_converter = CPUProcessor::new();
9993
9994        let (result, _src, dst) = convert_img(
9995            &mut cpu_converter,
9996            src,
9997            dst,
9998            Rotation::None,
9999            Flip::None,
10000            Crop::no_crop(),
10001        );
10002        result.unwrap();
10003
10004        let target_image = TensorDyn::image(
10005            1280,
10006            720,
10007            PixelFormat::Rgb,
10008            DType::U8,
10009            None,
10010            edgefirst_tensor::CpuAccess::ReadWrite,
10011        )
10012        .unwrap();
10013        target_image
10014            .as_typed::<u8>()
10015            .unwrap()
10016            .map()
10017            .unwrap()
10018            .as_mut_slice()
10019            .as_chunks_mut::<3>()
10020            .0
10021            .iter_mut()
10022            .zip(
10023                edgefirst_bench::testdata::read("camera720p.rgba")
10024                    .as_chunks::<4>()
10025                    .0,
10026            )
10027            .for_each(|(dst, src)| *dst = [src[0], src[1], src[2]]);
10028
10029        // CPU path resolves the untagged 720p source to BT.709 limited (height
10030        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
10031        compare_images(&dst, &target_image, 0.98, function!());
10032    }
10033
10034    #[test]
10035    #[cfg(target_os = "linux")]
10036    #[ignore = "G2D does not support VYUY; re-enable when hardware support is added"]
10037    fn test_vyuy_to_rgba_g2d() {
10038        if !is_g2d_available() {
10039            crate::test_support::report_skip(
10040                "test_vyuy_to_rgba_g2d - G2D library (libg2d.so.2) not available",
10041            );
10042            return;
10043        }
10044        if !is_dma_available() {
10045            crate::test_support::report_skip("test_vyuy_to_rgba_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
10046            return;
10047        }
10048
10049        let src = load_bytes_to_tensor(
10050            1280,
10051            720,
10052            PixelFormat::Vyuy,
10053            None,
10054            &edgefirst_bench::testdata::read("camera720p.vyuy"),
10055        )
10056        .unwrap();
10057
10058        let dst = TensorDyn::image(
10059            1280,
10060            720,
10061            PixelFormat::Rgba,
10062            DType::U8,
10063            Some(TensorMemory::DmaBuf),
10064            edgefirst_tensor::CpuAccess::ReadWrite,
10065        )
10066        .unwrap();
10067        let mut g2d_converter = G2DProcessor::new().unwrap();
10068
10069        let (result, _src, dst) = convert_img(
10070            &mut g2d_converter,
10071            src,
10072            dst,
10073            Rotation::None,
10074            Flip::None,
10075            Crop::no_crop(),
10076        );
10077        match result {
10078            Err(Error::G2D(_)) => {
10079                crate::test_support::report_skip(
10080                    "test_vyuy_to_rgba_g2d - G2D does not support PixelFormat::Vyuy format",
10081                );
10082                return;
10083            }
10084            r => r.unwrap(),
10085        }
10086
10087        let target_image = TensorDyn::image(
10088            1280,
10089            720,
10090            PixelFormat::Rgba,
10091            DType::U8,
10092            None,
10093            edgefirst_tensor::CpuAccess::ReadWrite,
10094        )
10095        .unwrap();
10096        target_image
10097            .as_typed::<u8>()
10098            .unwrap()
10099            .map()
10100            .unwrap()
10101            .as_mut_slice()
10102            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
10103
10104        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
10105        // so the matrix delta vs the reference that forced 0.95 has closed;
10106        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
10107        compare_images(&dst, &target_image, 0.98, function!());
10108    }
10109
10110    #[test]
10111    #[cfg(target_os = "linux")]
10112    #[ignore = "G2D does not support VYUY; re-enable when hardware support is added"]
10113    fn test_vyuy_to_rgb_g2d() {
10114        if !is_g2d_available() {
10115            crate::test_support::report_skip(
10116                "test_vyuy_to_rgb_g2d - G2D library (libg2d.so.2) not available",
10117            );
10118            return;
10119        }
10120        if !is_dma_available() {
10121            crate::test_support::report_skip("test_vyuy_to_rgb_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
10122            return;
10123        }
10124
10125        let src = load_bytes_to_tensor(
10126            1280,
10127            720,
10128            PixelFormat::Vyuy,
10129            None,
10130            &edgefirst_bench::testdata::read("camera720p.vyuy"),
10131        )
10132        .unwrap();
10133
10134        let g2d_dst = TensorDyn::image(
10135            1280,
10136            720,
10137            PixelFormat::Rgb,
10138            DType::U8,
10139            Some(TensorMemory::DmaBuf),
10140            edgefirst_tensor::CpuAccess::ReadWrite,
10141        )
10142        .unwrap();
10143        let mut g2d_converter = G2DProcessor::new().unwrap();
10144
10145        let (result, src, g2d_dst) = convert_img(
10146            &mut g2d_converter,
10147            src,
10148            g2d_dst,
10149            Rotation::None,
10150            Flip::None,
10151            Crop::no_crop(),
10152        );
10153        match result {
10154            Err(Error::G2D(_)) => {
10155                crate::test_support::report_skip(
10156                    "test_vyuy_to_rgb_g2d - G2D does not support PixelFormat::Vyuy format",
10157                );
10158                return;
10159            }
10160            r => r.unwrap(),
10161        }
10162
10163        let cpu_dst = TensorDyn::image(
10164            1280,
10165            720,
10166            PixelFormat::Rgb,
10167            DType::U8,
10168            None,
10169            edgefirst_tensor::CpuAccess::ReadWrite,
10170        )
10171        .unwrap();
10172        let mut cpu_converter: CPUProcessor = CPUProcessor::new();
10173
10174        let (result, _src, cpu_dst) = convert_img(
10175            &mut cpu_converter,
10176            src,
10177            cpu_dst,
10178            Rotation::None,
10179            Flip::None,
10180            Crop::no_crop(),
10181        );
10182        result.unwrap();
10183
10184        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
10185        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
10186        // the YUV-matrix delta that forced 0.95 has closed; tightened to
10187        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
10188        // structural gap not exercised by these limited-range fixtures.
10189        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
10190    }
10191
10192    #[test]
10193    #[cfg(any(
10194        target_os = "linux",
10195        target_os = "macos",
10196        target_os = "ios",
10197        target_os = "android",
10198        target_os = "windows"
10199    ))]
10200    #[cfg(feature = "opengl")]
10201    fn test_vyuy_to_rgba_opengl() {
10202        if !is_opengl_available() {
10203            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
10204            return;
10205        }
10206        if !edgefirst_tensor::is_gpu_buffer_available() {
10207            crate::test_support::report_skip(&format!(
10208                "{} - zero-copy GPU buffer (DMA-BUF / IOSurface) not available on this host",
10209                function!()
10210            ));
10211            return;
10212        }
10213
10214        // VYUY has a DMA-BUF FourCC on Linux but no IOSurface mapping on
10215        // macOS/iOS, so the zero-copy allocation itself is the per-format
10216        // probe: a platform that declines it is a skip, not a failure.
10217        let src = match load_bytes_to_tensor(
10218            1280,
10219            720,
10220            PixelFormat::Vyuy,
10221            Some(TensorMemory::DmaBuf),
10222            &edgefirst_bench::testdata::read("camera720p.vyuy"),
10223        ) {
10224            Ok(src) => src,
10225            Err(e) => {
10226                crate::test_support::report_skip(&format!(
10227                    "{} - zero-copy VYUY source not allocatable on this platform ({e})",
10228                    function!()
10229                ));
10230                return;
10231            }
10232        };
10233
10234        let dst = TensorDyn::image(
10235            1280,
10236            720,
10237            PixelFormat::Rgba,
10238            DType::U8,
10239            Some(TensorMemory::DmaBuf),
10240            edgefirst_tensor::CpuAccess::ReadWrite,
10241        )
10242        .unwrap();
10243        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
10244
10245        let (result, _src, dst) = convert_img(
10246            &mut gl_converter,
10247            src,
10248            dst,
10249            Rotation::None,
10250            Flip::None,
10251            Crop::no_crop(),
10252        );
10253        match result {
10254            Err(Error::NotSupported(_)) => {
10255                crate::test_support::report_skip(&format!(
10256                    "{} - OpenGL does not support PixelFormat::Vyuy DMA format",
10257                    function!()
10258                ));
10259                return;
10260            }
10261            r => r.unwrap(),
10262        }
10263
10264        let target_image = TensorDyn::image(
10265            1280,
10266            720,
10267            PixelFormat::Rgba,
10268            DType::U8,
10269            None,
10270            edgefirst_tensor::CpuAccess::ReadWrite,
10271        )
10272        .unwrap();
10273        target_image
10274            .as_typed::<u8>()
10275            .unwrap()
10276            .map()
10277            .unwrap()
10278            .as_mut_slice()
10279            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
10280
10281        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
10282        // so the matrix delta vs the reference that forced 0.95 has closed;
10283        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
10284        compare_images(&dst, &target_image, 0.98, function!());
10285    }
10286
10287    #[test]
10288    fn test_nv12_to_rgba_cpu() {
10289        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
10290        let src = TensorDyn::image(
10291            1280,
10292            720,
10293            PixelFormat::Nv12,
10294            DType::U8,
10295            None,
10296            edgefirst_tensor::CpuAccess::ReadWrite,
10297        )
10298        .unwrap();
10299        src.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
10300            .copy_from_slice(&file);
10301
10302        let dst = TensorDyn::image(
10303            1280,
10304            720,
10305            PixelFormat::Rgba,
10306            DType::U8,
10307            None,
10308            edgefirst_tensor::CpuAccess::ReadWrite,
10309        )
10310        .unwrap();
10311        let mut cpu_converter = CPUProcessor::new();
10312
10313        let (result, _src, dst) = convert_img(
10314            &mut cpu_converter,
10315            src,
10316            dst,
10317            Rotation::None,
10318            Flip::None,
10319            Crop::no_crop(),
10320        );
10321        result.unwrap();
10322
10323        let target_image = crate::load_image_test_helper(
10324            &edgefirst_bench::testdata::read("zidane.jpg"),
10325            Some(PixelFormat::Rgba),
10326            None,
10327        )
10328        .unwrap();
10329
10330        // Threshold 0.95 (was 0.98): the reference now decodes the colour JPEG
10331        // to native NV12 and then converts to RGBA (was a direct JPEG → RGBA
10332        // decode), so it differs slightly from the RGBA derived from the
10333        // separate `zidane.nv12` fixture.
10334        compare_images(&dst, &target_image, 0.95, function!());
10335    }
10336
10337    #[test]
10338    fn test_nv12_odd_height_to_rgb_cpu() {
10339        // Odd height (even width) — the logical-odd case, e.g. 640×483. The
10340        // contiguous NV12 buffer is `[5 + ceil(5/2), 8]` = `[8, 8]` (5 luma rows
10341        // + 3 chroma rows). A neutral-grey fill (Y=U=V=128, BT.601 full-range)
10342        // must convert to a uniform grey RGB, exercising the odd-height
10343        // chroma-row count and the logical-height derivation in convert.
10344        // (Odd *width* is rounded to an even buffer at allocation, so it is
10345        // covered by the decode integration tests rather than here.)
10346        // CPU-only test: pin to tight host memory (None auto-selects pitch-padded
10347        // DMA on i.MX, which would leave the dst's row padding unconverted and
10348        // break the flat byte scan below).
10349        let mut src = TensorDyn::image(
10350            8,
10351            5,
10352            PixelFormat::Nv12,
10353            DType::U8,
10354            Some(TensorMemory::Mem),
10355            edgefirst_tensor::CpuAccess::ReadWrite,
10356        )
10357        .unwrap();
10358        assert_eq!(src.shape(), &[8, 8]);
10359        assert_eq!((src.width(), src.height()), (Some(8), Some(5)));
10360        src.as_typed::<u8>()
10361            .unwrap()
10362            .map()
10363            .unwrap()
10364            .as_mut_slice()
10365            .fill(128);
10366        // Tag BT.601 full-range so Y=128 decodes to grey 128 (the neutral-grey
10367        // identity this test asserts). Without a tag, the colorimetry heuristic
10368        // resolves an SD tensor to BT.601 *limited*, expanding Y=128 → ~131.
10369        src.set_colorimetry(Some(
10370            edgefirst_tensor::Colorimetry::default()
10371                .with_encoding(edgefirst_tensor::ColorEncoding::Bt601)
10372                .with_range(edgefirst_tensor::ColorRange::Full),
10373        ));
10374
10375        let dst = TensorDyn::image(
10376            8,
10377            5,
10378            PixelFormat::Rgb,
10379            DType::U8,
10380            Some(TensorMemory::Mem),
10381            edgefirst_tensor::CpuAccess::ReadWrite,
10382        )
10383        .unwrap();
10384        let mut cpu_converter = CPUProcessor::new();
10385        let (result, _src, dst) = convert_img(
10386            &mut cpu_converter,
10387            src,
10388            dst,
10389            Rotation::None,
10390            Flip::None,
10391            Crop::no_crop(),
10392        );
10393        result.unwrap();
10394
10395        assert_eq!((dst.width(), dst.height()), (Some(8), Some(5)));
10396        let map = dst.as_typed::<u8>().unwrap().map().unwrap();
10397        for (i, &b) in map.as_slice().iter().enumerate() {
10398            assert!(
10399                (b as i16 - 128).abs() <= 2,
10400                "pixel byte {i} = {b}, expected ~128 for neutral-grey NV12"
10401            );
10402        }
10403    }
10404
10405    #[test]
10406    fn test_nv24_to_rgb_cpu() {
10407        // NV24 (4:4:4) at 8×4: contiguous buffer is [4*3, 8] = [12, 8] — Y plane
10408        // (4 rows) + full-res interleaved UV plane (8 rows = 2H, 2W bytes per
10409        // chroma row). Neutral-grey fill (Y=U=V=128) must convert to uniform
10410        // grey RGB, exercising the 2× UV stride and shape[0]/3 height recovery.
10411        // CPU-only test: pin to tight host memory (see test_nv12_odd_height_to_rgb_cpu).
10412        let mut src = TensorDyn::image(
10413            8,
10414            4,
10415            PixelFormat::Nv24,
10416            DType::U8,
10417            Some(TensorMemory::Mem),
10418            edgefirst_tensor::CpuAccess::ReadWrite,
10419        )
10420        .unwrap();
10421        assert_eq!(src.shape(), &[12, 8]);
10422        assert_eq!((src.width(), src.height()), (Some(8), Some(4)));
10423        src.as_typed::<u8>()
10424            .unwrap()
10425            .map()
10426            .unwrap()
10427            .as_mut_slice()
10428            .fill(128);
10429        // Tag BT.601 full-range (see test_nv12_odd_height_to_rgb_cpu): without it
10430        // the heuristic picks limited range and Y=128 expands to ~131.
10431        src.set_colorimetry(Some(
10432            edgefirst_tensor::Colorimetry::default()
10433                .with_encoding(edgefirst_tensor::ColorEncoding::Bt601)
10434                .with_range(edgefirst_tensor::ColorRange::Full),
10435        ));
10436
10437        let dst = TensorDyn::image(
10438            8,
10439            4,
10440            PixelFormat::Rgb,
10441            DType::U8,
10442            Some(TensorMemory::Mem),
10443            edgefirst_tensor::CpuAccess::ReadWrite,
10444        )
10445        .unwrap();
10446        let mut cpu_converter = CPUProcessor::new();
10447        let (result, _src, dst) = convert_img(
10448            &mut cpu_converter,
10449            src,
10450            dst,
10451            Rotation::None,
10452            Flip::None,
10453            Crop::no_crop(),
10454        );
10455        result.unwrap();
10456
10457        assert_eq!((dst.width(), dst.height()), (Some(8), Some(4)));
10458        let map = dst.as_typed::<u8>().unwrap().map().unwrap();
10459        for (i, &b) in map.as_slice().iter().enumerate() {
10460            assert!(
10461                (b as i16 - 128).abs() <= 2,
10462                "pixel byte {i} = {b}, expected ~128 for neutral-grey NV24"
10463            );
10464        }
10465    }
10466
10467    #[test]
10468    fn cpu_nv12_to_rgb_respects_tagged_bt2020() {
10469        // A uniform but *saturated* chroma sample (U/V far from neutral) so the
10470        // YUV→RGB matrix — not just the range — drives the result. Decoding the
10471        // same NV12 bytes under BT.601 / BT.709 / BT.2020 must yield three
10472        // distinct RGB triples, proving the CPU path honours the source's tagged
10473        // ColorEncoding instead of a hardcoded matrix. (G2D declines BT.2020 and
10474        // falls through to this CPU path; QA F9.)
10475        // CPU-only test: pin to tight host memory (see test_nv12_odd_height_to_rgb_cpu).
10476        fn decode_tagged(enc: edgefirst_tensor::ColorEncoding) -> [u8; 3] {
10477            let mut src = TensorDyn::image(
10478                8,
10479                4,
10480                PixelFormat::Nv12,
10481                DType::U8,
10482                Some(TensorMemory::Mem),
10483                edgefirst_tensor::CpuAccess::ReadWrite,
10484            )
10485            .unwrap();
10486            // NV12 8×4: 32-byte Y plane + 16-byte interleaved UV plane (4:2:0).
10487            assert_eq!(src.shape(), &[6, 8]);
10488            {
10489                let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
10490                let buf = map.as_mut_slice();
10491                buf[..32].fill(120); // Y
10492                for px in buf[32..].as_chunks_mut::<2>().0 {
10493                    px[0] = 180; // U / Cb
10494                    px[1] = 64; // V / Cr
10495                }
10496            }
10497            // Range held constant (Limited) across all three so only the encoding
10498            // matrix varies between runs.
10499            src.set_colorimetry(Some(
10500                edgefirst_tensor::Colorimetry::default()
10501                    .with_encoding(enc)
10502                    .with_range(edgefirst_tensor::ColorRange::Limited),
10503            ));
10504            let dst = TensorDyn::image(
10505                8,
10506                4,
10507                PixelFormat::Rgb,
10508                DType::U8,
10509                Some(TensorMemory::Mem),
10510                edgefirst_tensor::CpuAccess::ReadWrite,
10511            )
10512            .unwrap();
10513            let mut cpu = CPUProcessor::new();
10514            let (result, _src, dst) = convert_img(
10515                &mut cpu,
10516                src,
10517                dst,
10518                Rotation::None,
10519                Flip::None,
10520                Crop::no_crop(),
10521            );
10522            result.unwrap();
10523            let map = dst.as_typed::<u8>().unwrap().map().unwrap();
10524            let s = map.as_slice();
10525            [s[0], s[1], s[2]]
10526        }
10527
10528        let bt601 = decode_tagged(edgefirst_tensor::ColorEncoding::Bt601);
10529        let bt709 = decode_tagged(edgefirst_tensor::ColorEncoding::Bt709);
10530        let bt2020 = decode_tagged(edgefirst_tensor::ColorEncoding::Bt2020);
10531
10532        assert_ne!(
10533            bt2020, bt601,
10534            "BT.2020 must decode differently from BT.601 ({bt2020:?} vs {bt601:?})"
10535        );
10536        assert_ne!(
10537            bt2020, bt709,
10538            "BT.2020 must decode differently from BT.709 ({bt2020:?} vs {bt709:?})"
10539        );
10540        assert_ne!(
10541            bt601, bt709,
10542            "BT.601 must decode differently from BT.709 ({bt601:?} vs {bt709:?})"
10543        );
10544    }
10545
10546    #[test]
10547    fn test_nv12_to_rgb_cpu() {
10548        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
10549        let src = TensorDyn::image(
10550            1280,
10551            720,
10552            PixelFormat::Nv12,
10553            DType::U8,
10554            None,
10555            edgefirst_tensor::CpuAccess::ReadWrite,
10556        )
10557        .unwrap();
10558        src.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
10559            .copy_from_slice(&file);
10560
10561        let dst = TensorDyn::image(
10562            1280,
10563            720,
10564            PixelFormat::Rgb,
10565            DType::U8,
10566            None,
10567            edgefirst_tensor::CpuAccess::ReadWrite,
10568        )
10569        .unwrap();
10570        let mut cpu_converter = CPUProcessor::new();
10571
10572        let (result, _src, dst) = convert_img(
10573            &mut cpu_converter,
10574            src,
10575            dst,
10576            Rotation::None,
10577            Flip::None,
10578            Crop::no_crop(),
10579        );
10580        result.unwrap();
10581
10582        let target_image = crate::load_image_test_helper(
10583            &edgefirst_bench::testdata::read("zidane.jpg"),
10584            Some(PixelFormat::Rgb),
10585            None,
10586        )
10587        .unwrap();
10588
10589        // Threshold 0.95 (was 0.98): the reference now decodes the colour JPEG
10590        // to native NV12 and then converts to RGB (was a direct JPEG → RGB
10591        // decode), so it differs slightly from the RGB derived from the
10592        // separate `zidane.nv12` fixture.
10593        compare_images(&dst, &target_image, 0.95, function!());
10594    }
10595
10596    #[test]
10597    fn test_nv12_to_grey_cpu() {
10598        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
10599        let src = TensorDyn::image(
10600            1280,
10601            720,
10602            PixelFormat::Nv12,
10603            DType::U8,
10604            None,
10605            edgefirst_tensor::CpuAccess::ReadWrite,
10606        )
10607        .unwrap();
10608        src.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
10609            .copy_from_slice(&file);
10610
10611        let dst = TensorDyn::image(
10612            1280,
10613            720,
10614            PixelFormat::Grey,
10615            DType::U8,
10616            None,
10617            edgefirst_tensor::CpuAccess::ReadWrite,
10618        )
10619        .unwrap();
10620        let mut cpu_converter = CPUProcessor::new();
10621
10622        let (result, _src, dst) = convert_img(
10623            &mut cpu_converter,
10624            src,
10625            dst,
10626            Rotation::None,
10627            Flip::None,
10628            Crop::no_crop(),
10629        );
10630        result.unwrap();
10631
10632        let target_image = crate::load_image_test_helper(
10633            &edgefirst_bench::testdata::read("zidane.jpg"),
10634            Some(PixelFormat::Grey),
10635            None,
10636        )
10637        .unwrap();
10638
10639        // Threshold 0.95 (was 0.98): the reference grey frame now comes from
10640        // the colour JPEG decoded to native NV12 and then converted to GREY
10641        // (was a direct JPEG → GREY decode), so it differs slightly from the
10642        // grey derived from the `zidane.nv12` fixture.
10643        compare_images(&dst, &target_image, 0.95, function!());
10644    }
10645
10646    #[test]
10647    fn test_nv12_to_yuyv_cpu() {
10648        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
10649        let src = TensorDyn::image(
10650            1280,
10651            720,
10652            PixelFormat::Nv12,
10653            DType::U8,
10654            None,
10655            edgefirst_tensor::CpuAccess::ReadWrite,
10656        )
10657        .unwrap();
10658        src.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
10659            .copy_from_slice(&file);
10660
10661        let dst = TensorDyn::image(
10662            1280,
10663            720,
10664            PixelFormat::Yuyv,
10665            DType::U8,
10666            None,
10667            edgefirst_tensor::CpuAccess::ReadWrite,
10668        )
10669        .unwrap();
10670        let mut cpu_converter = CPUProcessor::new();
10671
10672        let (result, _src, dst) = convert_img(
10673            &mut cpu_converter,
10674            src,
10675            dst,
10676            Rotation::None,
10677            Flip::None,
10678            Crop::no_crop(),
10679        );
10680        result.unwrap();
10681
10682        let target_image = crate::load_image_test_helper(
10683            &edgefirst_bench::testdata::read("zidane.jpg"),
10684            Some(PixelFormat::Rgb),
10685            None,
10686        )
10687        .unwrap();
10688
10689        // Threshold 0.95 (was 0.98): the reference now decodes the colour JPEG
10690        // to native NV12 and then converts to RGB (was a direct JPEG → RGB
10691        // decode), so it differs slightly from the YUYV-sourced frame derived
10692        // from the separate `zidane.nv12` fixture.
10693        compare_images_convert_to_rgb(&dst, &target_image, 0.95, function!());
10694    }
10695
10696    #[test]
10697    fn test_cpu_resize_nv16() {
10698        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
10699        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
10700
10701        let cpu_nv16_dst = TensorDyn::image(
10702            640,
10703            640,
10704            PixelFormat::Nv16,
10705            DType::U8,
10706            None,
10707            edgefirst_tensor::CpuAccess::ReadWrite,
10708        )
10709        .unwrap();
10710        let cpu_rgb_dst = TensorDyn::image(
10711            640,
10712            640,
10713            PixelFormat::Rgb,
10714            DType::U8,
10715            None,
10716            edgefirst_tensor::CpuAccess::ReadWrite,
10717        )
10718        .unwrap();
10719        let mut cpu_converter = CPUProcessor::new();
10720        let crop = Crop::letterbox([255, 128, 0, 255]);
10721
10722        let (result, src, cpu_nv16_dst) = convert_img(
10723            &mut cpu_converter,
10724            src,
10725            cpu_nv16_dst,
10726            Rotation::None,
10727            Flip::None,
10728            crop,
10729        );
10730        result.unwrap();
10731
10732        let (result, _src, cpu_rgb_dst) = convert_img(
10733            &mut cpu_converter,
10734            src,
10735            cpu_rgb_dst,
10736            Rotation::None,
10737            Flip::None,
10738            crop,
10739        );
10740        result.unwrap();
10741        compare_images_convert_to_rgb(
10742            &cpu_nv16_dst,
10743            &cpu_rgb_dst,
10744            yuv_threshold(0.99),
10745            function!(),
10746        );
10747    }
10748
10749    /// Similarity threshold for a comparison whose path runs a YUV↔RGB
10750    /// conversion.
10751    ///
10752    /// [`crate::cpu::convert`]'s `yuv_mode()` deliberately selects
10753    /// `YuvConversionMode::Fast` on cores without Armv8.1 `rdm`
10754    /// (Cortex-A53/A35 class, e.g. the i.MX 8M Plus), because `Balanced`
10755    /// emulates `SQRDMLAH` there and costs roughly 2×. `Fast` is a
10756    /// lower-precision integer approximation with a documented error of up to
10757    /// ~2/255, so a threshold calibrated on an `rdm`-capable core is
10758    /// unreachable on those cores **by design**: NV16 measures 0.9885 against
10759    /// 0.99 on an A53, while every `rdm` core passes.
10760    ///
10761    /// No CI runner is A53-class, so this only reproduces on target.
10762    ///
10763    /// Relaxing per-core keeps the assertion tight where the precise path
10764    /// runs, instead of loosening it everywhere to accommodate one core.
10765    fn yuv_threshold(balanced: f64) -> f64 {
10766        #[cfg(target_arch = "aarch64")]
10767        {
10768            if !std::arch::is_aarch64_feature_detected!("rdm") {
10769                return balanced - 0.01;
10770            }
10771        }
10772        balanced
10773    }
10774
10775    fn load_bytes_to_tensor(
10776        width: usize,
10777        height: usize,
10778        format: PixelFormat,
10779        memory: Option<TensorMemory>,
10780        bytes: &[u8],
10781    ) -> Result<TensorDyn, Error> {
10782        let src = TensorDyn::image(
10783            width,
10784            height,
10785            format,
10786            DType::U8,
10787            memory,
10788            edgefirst_tensor::CpuAccess::ReadWrite,
10789        )?;
10790        src.as_typed::<u8>()
10791            .unwrap()
10792            .map()?
10793            .as_mut_slice()
10794            .copy_from_slice(bytes);
10795        Ok(src)
10796    }
10797
10798    // DEDUP: this function is also defined verbatim in
10799    // `crates/image/src/gl/tests.rs` (inside `mod gl_tests`). Both copies
10800    // must be kept in sync. Cross-module sharing would require either a
10801    // `pub(crate)` test-helper module (which pollutes the non-test API) or a
10802    // separate test-utils crate — both are disproportionate for a single
10803    // helper. If the implementation ever diverges, extract to a shared
10804    // `test_helpers` module in this crate.
10805    fn compare_images(img1: &TensorDyn, img2: &TensorDyn, threshold: f64, name: &str) {
10806        assert_eq!(img1.height(), img2.height(), "Heights differ");
10807        assert_eq!(img1.width(), img2.width(), "Widths differ");
10808        assert_eq!(
10809            img1.format().unwrap(),
10810            img2.format().unwrap(),
10811            "PixelFormat differ"
10812        );
10813        assert!(
10814            matches!(
10815                img1.format().unwrap(),
10816                PixelFormat::Rgb | PixelFormat::Rgba | PixelFormat::Grey | PixelFormat::PlanarRgb
10817            ),
10818            "format must be Rgb or Rgba for comparison"
10819        );
10820
10821        // A pitch-padded destination -- the D3D11 staging texture behind a
10822        // Windows `TensorMemory::DmaBuf` tensor pads an RGBA row to 128 bytes
10823        // -- spaces its rows at `effective_row_stride()`, so reading the head
10824        // of the mapping as a tight image would read padding as pixels.
10825        // `copy_to_flat` is the tensor's own compaction and degenerates to one
10826        // memcpy when the tensor is tight.
10827        fn flat(img: &TensorDyn) -> Vec<u8> {
10828            let t = img.as_typed::<u8>().expect("u8 image");
10829            let mut out = vec![0u8; t.shape().iter().product::<usize>()];
10830            t.copy_to_flat(&mut out).expect("compact the padded rows");
10831            out
10832        }
10833
10834        let image1 = match img1.format().unwrap() {
10835            PixelFormat::Rgb => image::RgbImage::from_vec(
10836                img1.width().unwrap() as u32,
10837                img1.height().unwrap() as u32,
10838                flat(img1),
10839            )
10840            .unwrap(),
10841            PixelFormat::Rgba => image::RgbaImage::from_vec(
10842                img1.width().unwrap() as u32,
10843                img1.height().unwrap() as u32,
10844                flat(img1),
10845            )
10846            .unwrap()
10847            .convert(),
10848            PixelFormat::Grey => image::GrayImage::from_vec(
10849                img1.width().unwrap() as u32,
10850                img1.height().unwrap() as u32,
10851                flat(img1),
10852            )
10853            .unwrap()
10854            .convert(),
10855            PixelFormat::PlanarRgb => image::GrayImage::from_vec(
10856                img1.width().unwrap() as u32,
10857                (img1.height().unwrap() * 3) as u32,
10858                flat(img1),
10859            )
10860            .unwrap()
10861            .convert(),
10862            _ => return,
10863        };
10864
10865        let image2 = match img2.format().unwrap() {
10866            PixelFormat::Rgb => image::RgbImage::from_vec(
10867                img2.width().unwrap() as u32,
10868                img2.height().unwrap() as u32,
10869                flat(img2),
10870            )
10871            .unwrap(),
10872            PixelFormat::Rgba => image::RgbaImage::from_vec(
10873                img2.width().unwrap() as u32,
10874                img2.height().unwrap() as u32,
10875                flat(img2),
10876            )
10877            .unwrap()
10878            .convert(),
10879            PixelFormat::Grey => image::GrayImage::from_vec(
10880                img2.width().unwrap() as u32,
10881                img2.height().unwrap() as u32,
10882                flat(img2),
10883            )
10884            .unwrap()
10885            .convert(),
10886            PixelFormat::PlanarRgb => image::GrayImage::from_vec(
10887                img2.width().unwrap() as u32,
10888                (img2.height().unwrap() * 3) as u32,
10889                flat(img2),
10890            )
10891            .unwrap()
10892            .convert(),
10893            _ => return,
10894        };
10895
10896        let similarity = image_compare::rgb_similarity_structure(
10897            &image_compare::Algorithm::RootMeanSquared,
10898            &image1,
10899            &image2,
10900        )
10901        .expect("Image Comparison failed");
10902        if similarity.score < threshold {
10903            // image1.save(format!("{name}_1.png"));
10904            // image2.save(format!("{name}_2.png"));
10905            similarity
10906                .image
10907                .to_color_map()
10908                .save(format!("{name}.png"))
10909                .unwrap();
10910            panic!(
10911                "{name}: converted image and target image have similarity score too low: {} < {}",
10912                similarity.score, threshold
10913            )
10914        }
10915    }
10916
10917    fn compare_images_convert_to_rgb(
10918        img1: &TensorDyn,
10919        img2: &TensorDyn,
10920        threshold: f64,
10921        name: &str,
10922    ) {
10923        assert_eq!(img1.height(), img2.height(), "Heights differ");
10924        assert_eq!(img1.width(), img2.width(), "Widths differ");
10925
10926        let mut img_rgb1 = TensorDyn::image(
10927            img1.width().unwrap(),
10928            img1.height().unwrap(),
10929            PixelFormat::Rgb,
10930            DType::U8,
10931            Some(TensorMemory::Mem),
10932            edgefirst_tensor::CpuAccess::ReadWrite,
10933        )
10934        .unwrap();
10935        let mut img_rgb2 = TensorDyn::image(
10936            img1.width().unwrap(),
10937            img1.height().unwrap(),
10938            PixelFormat::Rgb,
10939            DType::U8,
10940            Some(TensorMemory::Mem),
10941            edgefirst_tensor::CpuAccess::ReadWrite,
10942        )
10943        .unwrap();
10944        let mut __cv = CPUProcessor::default();
10945        let r1 = __cv.convert(
10946            img1,
10947            &mut img_rgb1,
10948            crate::Rotation::None,
10949            crate::Flip::None,
10950            crate::Crop::default(),
10951        );
10952        let r2 = __cv.convert(
10953            img2,
10954            &mut img_rgb2,
10955            crate::Rotation::None,
10956            crate::Flip::None,
10957            crate::Crop::default(),
10958        );
10959        if r1.is_err() || r2.is_err() {
10960            // Fallback: compare raw bytes as greyscale strip
10961            let w = img1.width().unwrap() as u32;
10962            let data1 = img1.as_typed::<u8>().unwrap().map().unwrap().to_vec();
10963            let data2 = img2.as_typed::<u8>().unwrap().map().unwrap().to_vec();
10964            let h1 = (data1.len() as u32) / w;
10965            let h2 = (data2.len() as u32) / w;
10966            let g1 = image::GrayImage::from_vec(w, h1, data1).unwrap();
10967            let g2 = image::GrayImage::from_vec(w, h2, data2).unwrap();
10968            let similarity = image_compare::gray_similarity_structure(
10969                &image_compare::Algorithm::RootMeanSquared,
10970                &g1,
10971                &g2,
10972            )
10973            .expect("Image Comparison failed");
10974            if similarity.score < threshold {
10975                panic!(
10976                    "{name}: converted image and target image have similarity score too low: {} < {}",
10977                    similarity.score, threshold
10978                )
10979            }
10980            return;
10981        }
10982
10983        let image1 = image::RgbImage::from_vec(
10984            img_rgb1.width().unwrap() as u32,
10985            img_rgb1.height().unwrap() as u32,
10986            img_rgb1.as_typed::<u8>().unwrap().map().unwrap().to_vec(),
10987        )
10988        .unwrap();
10989
10990        let image2 = image::RgbImage::from_vec(
10991            img_rgb2.width().unwrap() as u32,
10992            img_rgb2.height().unwrap() as u32,
10993            img_rgb2.as_typed::<u8>().unwrap().map().unwrap().to_vec(),
10994        )
10995        .unwrap();
10996
10997        let similarity = image_compare::rgb_similarity_structure(
10998            &image_compare::Algorithm::RootMeanSquared,
10999            &image1,
11000            &image2,
11001        )
11002        .expect("Image Comparison failed");
11003        if similarity.score < threshold {
11004            // image1.save(format!("{name}_1.png"));
11005            // image2.save(format!("{name}_2.png"));
11006            similarity
11007                .image
11008                .to_color_map()
11009                .save(format!("{name}.png"))
11010                .unwrap();
11011            panic!(
11012                "{name}: converted image and target image have similarity score too low: {} < {}",
11013                similarity.score, threshold
11014            )
11015        }
11016    }
11017
11018    // =========================================================================
11019    // PixelFormat::Nv12 Format Tests
11020    // =========================================================================
11021
11022    #[test]
11023    fn test_nv12_image_creation() {
11024        let width = 640;
11025        let height = 480;
11026        let img = TensorDyn::image(
11027            width,
11028            height,
11029            PixelFormat::Nv12,
11030            DType::U8,
11031            None,
11032            edgefirst_tensor::CpuAccess::ReadWrite,
11033        )
11034        .unwrap();
11035
11036        assert_eq!(img.width(), Some(width));
11037        assert_eq!(img.height(), Some(height));
11038        assert_eq!(img.format().unwrap(), PixelFormat::Nv12);
11039        // PixelFormat::Nv12 uses shape [H*3/2, W] to store Y plane + UV plane
11040        assert_eq!(
11041            img.as_typed::<u8>().unwrap().shape(),
11042            &[height * 3 / 2, width]
11043        );
11044    }
11045
11046    #[test]
11047    fn test_nv12_channels() {
11048        let img = TensorDyn::image(
11049            640,
11050            480,
11051            PixelFormat::Nv12,
11052            DType::U8,
11053            None,
11054            edgefirst_tensor::CpuAccess::ReadWrite,
11055        )
11056        .unwrap();
11057        // PixelFormat::Nv12.channels() returns 1 (luma plane)
11058        assert_eq!(img.format().unwrap().channels(), 1);
11059    }
11060
11061    // =========================================================================
11062    // Tensor Format Metadata Tests
11063    // =========================================================================
11064
11065    #[test]
11066    fn test_tensor_set_format_planar() {
11067        let mut tensor = Tensor::<u8>::new(&[3, 480, 640], None, None).unwrap();
11068        tensor.set_format(PixelFormat::PlanarRgb).unwrap();
11069        assert_eq!(tensor.format(), Some(PixelFormat::PlanarRgb));
11070        assert_eq!(tensor.width(), Some(640));
11071        assert_eq!(tensor.height(), Some(480));
11072    }
11073
11074    #[test]
11075    fn test_tensor_set_format_interleaved() {
11076        let mut tensor = Tensor::<u8>::new(&[480, 640, 4], None, None).unwrap();
11077        tensor.set_format(PixelFormat::Rgba).unwrap();
11078        assert_eq!(tensor.format(), Some(PixelFormat::Rgba));
11079        assert_eq!(tensor.width(), Some(640));
11080        assert_eq!(tensor.height(), Some(480));
11081    }
11082
11083    #[test]
11084    fn test_tensordyn_image_rgb() {
11085        let img = TensorDyn::image(
11086            640,
11087            480,
11088            PixelFormat::Rgb,
11089            DType::U8,
11090            None,
11091            edgefirst_tensor::CpuAccess::ReadWrite,
11092        )
11093        .unwrap();
11094        assert_eq!(img.width(), Some(640));
11095        assert_eq!(img.height(), Some(480));
11096        assert_eq!(img.format(), Some(PixelFormat::Rgb));
11097    }
11098
11099    #[test]
11100    fn test_tensordyn_image_planar_rgb() {
11101        let img = TensorDyn::image(
11102            640,
11103            480,
11104            PixelFormat::PlanarRgb,
11105            DType::U8,
11106            None,
11107            edgefirst_tensor::CpuAccess::ReadWrite,
11108        )
11109        .unwrap();
11110        assert_eq!(img.width(), Some(640));
11111        assert_eq!(img.height(), Some(480));
11112        assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
11113    }
11114
11115    #[test]
11116    fn test_rgb_int8_format() {
11117        // Int8 variant: same PixelFormat::Rgb but with DType::I8
11118        let img = TensorDyn::image(
11119            1280,
11120            720,
11121            PixelFormat::Rgb,
11122            DType::I8,
11123            Some(TensorMemory::Mem),
11124            edgefirst_tensor::CpuAccess::ReadWrite,
11125        )
11126        .unwrap();
11127        assert_eq!(img.width(), Some(1280));
11128        assert_eq!(img.height(), Some(720));
11129        assert_eq!(img.format(), Some(PixelFormat::Rgb));
11130        assert_eq!(img.dtype(), DType::I8);
11131    }
11132
11133    #[test]
11134    fn test_planar_rgb_int8_format() {
11135        let img = TensorDyn::image(
11136            1280,
11137            720,
11138            PixelFormat::PlanarRgb,
11139            DType::I8,
11140            Some(TensorMemory::Mem),
11141            edgefirst_tensor::CpuAccess::ReadWrite,
11142        )
11143        .unwrap();
11144        assert_eq!(img.width(), Some(1280));
11145        assert_eq!(img.height(), Some(720));
11146        assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
11147        assert_eq!(img.dtype(), DType::I8);
11148    }
11149
11150    #[test]
11151    fn test_rgb_from_tensor() {
11152        let mut tensor = Tensor::<u8>::new(&[720, 1280, 3], None, None).unwrap();
11153        tensor.set_format(PixelFormat::Rgb).unwrap();
11154        let img = TensorDyn::from(tensor);
11155        assert_eq!(img.width(), Some(1280));
11156        assert_eq!(img.height(), Some(720));
11157        assert_eq!(img.format(), Some(PixelFormat::Rgb));
11158    }
11159
11160    #[test]
11161    fn test_planar_rgb_from_tensor() {
11162        let mut tensor = Tensor::<u8>::new(&[3, 720, 1280], None, None).unwrap();
11163        tensor.set_format(PixelFormat::PlanarRgb).unwrap();
11164        let img = TensorDyn::from(tensor);
11165        assert_eq!(img.width(), Some(1280));
11166        assert_eq!(img.height(), Some(720));
11167        assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
11168    }
11169
11170    #[test]
11171    fn test_dtype_determines_int8() {
11172        // DType::I8 indicates int8 data
11173        let u8_img = TensorDyn::image(
11174            64,
11175            64,
11176            PixelFormat::Rgb,
11177            DType::U8,
11178            None,
11179            edgefirst_tensor::CpuAccess::ReadWrite,
11180        )
11181        .unwrap();
11182        let i8_img = TensorDyn::image(
11183            64,
11184            64,
11185            PixelFormat::Rgb,
11186            DType::I8,
11187            None,
11188            edgefirst_tensor::CpuAccess::ReadWrite,
11189        )
11190        .unwrap();
11191        assert_eq!(u8_img.dtype(), DType::U8);
11192        assert_eq!(i8_img.dtype(), DType::I8);
11193    }
11194
11195    #[test]
11196    fn test_pixel_layout_packed_vs_planar() {
11197        // Packed vs planar layout classification
11198        assert_eq!(PixelFormat::Rgb.layout(), PixelLayout::Packed);
11199        assert_eq!(PixelFormat::Rgba.layout(), PixelLayout::Packed);
11200        assert_eq!(PixelFormat::PlanarRgb.layout(), PixelLayout::Planar);
11201        assert_eq!(PixelFormat::Nv12.layout(), PixelLayout::SemiPlanar);
11202    }
11203
11204    /// Integration test that exercises the PBO-to-PBO convert path.
11205    /// A PBO cannot hand out a host address that outlives its map guard, and
11206    /// the error has to say why rather than reading as unfinished work.
11207    ///
11208    /// `glMapBufferRange`'s pointer is valid only until `glUnmapBuffer`, and
11209    /// this backend maps with `MAP_READ_BIT | MAP_WRITE_BIT` — there is no
11210    /// `GL_MAP_PERSISTENT_BIT` and no `glBufferStorage` in the tree. Pinning
11211    /// would mean holding the buffer mapped across GL work, which is the
11212    /// opposite of what a PBO is for. `EXT_buffer_storage` would change the
11213    /// answer, but that is a buffer-allocation feature, not wiring.
11214    #[cfg(target_os = "linux")]
11215    #[cfg(feature = "opengl")]
11216    #[test]
11217    fn pbo_reports_why_it_cannot_pin() {
11218        use edgefirst_tensor::{Error, TensorTrait};
11219
11220        let converter = ImageProcessor::new().unwrap();
11221        let is_pbo = converter
11222            .opengl
11223            .as_ref()
11224            .is_some_and(|gl| gl.transfer_backend() == opengl_headless::TransferBackend::Pbo);
11225        if !is_pbo {
11226            // Direct stderr, not eprintln!: libtest captures println!/eprintln!
11227            // and replays it only for FAILING tests, so a skip would print
11228            // "... ok" with its reason discarded and be indistinguishable from
11229            // having run. See TESTING.md.
11230            use std::io::Write;
11231            let _ = writeln!(
11232                &mut std::io::stderr(),
11233                "SKIPPED: pbo_reports_why_it_cannot_pin - transfer backend is not PBO"
11234            );
11235            return;
11236        }
11237
11238        let t = converter
11239            .create_image(
11240                64,
11241                64,
11242                PixelFormat::Rgba,
11243                DType::U8,
11244                None,
11245                edgefirst_tensor::CpuAccess::ReadWrite,
11246            )
11247            .unwrap();
11248        assert_eq!(t.as_typed::<u8>().unwrap().memory(), TensorMemory::Pbo);
11249        let t = t.as_typed::<u8>().unwrap();
11250
11251        match t.pin_host(edgefirst_tensor::CpuAccess::ReadWrite) {
11252            Err(Error::NotImplemented(m)) => {
11253                assert!(
11254                    m.contains("glMapBufferRange"),
11255                    "must name the constraint: {m}"
11256                );
11257                assert!(m.contains("map()"), "must point at the supported API: {m}");
11258            }
11259            other => panic!("expected an explained NotImplemented, got {other:?}"),
11260        }
11261        // Sync is unavailable for the same reason, and must not silently succeed.
11262        assert!(matches!(
11263            t.sync_for_cpu(edgefirst_tensor::CpuAccess::Read),
11264            Err(Error::NotImplemented(_))
11265        ));
11266
11267        // The supported path still works, which is what makes the refusal safe.
11268        let g = t.map().expect("map must work where pin cannot");
11269        assert!(!g.as_slice().is_empty());
11270    }
11271
11272    /// Uses ImageProcessor::create_image() to allocate PBO-backed tensors,
11273    /// then converts between them. Skipped when GL is unavailable or the
11274    /// backend is not PBO (e.g. DMA-buf systems).
11275    #[cfg(target_os = "linux")]
11276    #[cfg(feature = "opengl")]
11277    #[test]
11278    fn test_convert_pbo_to_pbo() {
11279        let mut converter = ImageProcessor::new().unwrap();
11280
11281        // Skip if GL is not available or backend is not PBO
11282        let is_pbo = converter
11283            .opengl
11284            .as_ref()
11285            .is_some_and(|gl| gl.transfer_backend() == opengl_headless::TransferBackend::Pbo);
11286        if !is_pbo {
11287            log::warn!("Skipping test_convert_pbo_to_pbo: backend is not PBO");
11288            return;
11289        }
11290
11291        let src_w = 640;
11292        let src_h = 480;
11293        let dst_w = 320;
11294        let dst_h = 240;
11295
11296        // Create PBO-backed source image
11297        let pbo_src = converter
11298            .create_image(
11299                src_w,
11300                src_h,
11301                PixelFormat::Rgba,
11302                DType::U8,
11303                None,
11304                edgefirst_tensor::CpuAccess::ReadWrite,
11305            )
11306            .unwrap();
11307        assert_eq!(
11308            pbo_src.as_typed::<u8>().unwrap().memory(),
11309            TensorMemory::Pbo,
11310            "create_image should produce a PBO tensor"
11311        );
11312
11313        // Fill source PBO with test pattern: load JPEG then convert Mem→PBO
11314        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
11315        let jpeg_src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
11316
11317        // Resize JPEG into a Mem temp of the right size, then copy into PBO
11318        let mem_src = TensorDyn::image(
11319            src_w,
11320            src_h,
11321            PixelFormat::Rgba,
11322            DType::U8,
11323            Some(TensorMemory::Mem),
11324            edgefirst_tensor::CpuAccess::ReadWrite,
11325        )
11326        .unwrap();
11327        let (result, _jpeg_src, mem_src) = convert_img(
11328            &mut CPUProcessor::new(),
11329            jpeg_src,
11330            mem_src,
11331            Rotation::None,
11332            Flip::None,
11333            Crop::no_crop(),
11334        );
11335        result.unwrap();
11336
11337        // Copy pixel data into the PBO source by mapping it
11338        {
11339            let src_data = mem_src.as_typed::<u8>().unwrap().map().unwrap();
11340            let mut pbo_map = pbo_src.as_typed::<u8>().unwrap().map().unwrap();
11341            pbo_map.copy_from_slice(&src_data);
11342        }
11343
11344        // Create PBO-backed destination image
11345        let pbo_dst = converter
11346            .create_image(
11347                dst_w,
11348                dst_h,
11349                PixelFormat::Rgba,
11350                DType::U8,
11351                None,
11352                edgefirst_tensor::CpuAccess::ReadWrite,
11353            )
11354            .unwrap();
11355        assert_eq!(
11356            pbo_dst.as_typed::<u8>().unwrap().memory(),
11357            TensorMemory::Pbo
11358        );
11359
11360        // Convert PBO→PBO (this exercises convert_pbo_to_pbo)
11361        let mut pbo_dst = pbo_dst;
11362        let result = converter.convert(
11363            &pbo_src,
11364            &mut pbo_dst,
11365            Rotation::None,
11366            Flip::None,
11367            Crop::no_crop(),
11368        );
11369        result.unwrap();
11370
11371        // Verify: compare with CPU-only conversion of the same input
11372        let cpu_dst = TensorDyn::image(
11373            dst_w,
11374            dst_h,
11375            PixelFormat::Rgba,
11376            DType::U8,
11377            Some(TensorMemory::Mem),
11378            edgefirst_tensor::CpuAccess::ReadWrite,
11379        )
11380        .unwrap();
11381        let (result, _mem_src, cpu_dst) = convert_img(
11382            &mut CPUProcessor::new(),
11383            mem_src,
11384            cpu_dst,
11385            Rotation::None,
11386            Flip::None,
11387            Crop::no_crop(),
11388        );
11389        result.unwrap();
11390
11391        let pbo_dst_img = {
11392            let mut __t = pbo_dst.into_u8().unwrap();
11393            __t.set_format(PixelFormat::Rgba).unwrap();
11394            TensorDyn::from(__t)
11395        };
11396        compare_images(&pbo_dst_img, &cpu_dst, 0.95, function!());
11397        log::info!("test_convert_pbo_to_pbo: PASS — PBO-to-PBO convert matches CPU reference");
11398    }
11399
11400    #[test]
11401    fn test_image_bgra() {
11402        let img = TensorDyn::image(
11403            640,
11404            480,
11405            PixelFormat::Bgra,
11406            DType::U8,
11407            Some(edgefirst_tensor::TensorMemory::Mem),
11408            edgefirst_tensor::CpuAccess::ReadWrite,
11409        )
11410        .unwrap();
11411        assert_eq!(img.width(), Some(640));
11412        assert_eq!(img.height(), Some(480));
11413        assert_eq!(img.format().unwrap().channels(), 4);
11414        assert_eq!(img.format().unwrap(), PixelFormat::Bgra);
11415    }
11416
11417    // ========================================================================
11418    // Tests for EDGEFIRST_FORCE_BACKEND env var
11419    // ========================================================================
11420
11421    #[test]
11422    fn test_force_backend_cpu() {
11423        let _lock = acquire_env_lock();
11424        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
11425        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
11426        let converter = ImageProcessor::new().unwrap();
11427        assert!(converter.cpu.is_some());
11428        assert_eq!(converter.forced_backend, Some(ForcedBackend::Cpu));
11429    }
11430
11431    #[test]
11432    fn test_force_backend_invalid() {
11433        let _lock = acquire_env_lock();
11434        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
11435        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "invalid") };
11436        let result = ImageProcessor::new();
11437        assert!(
11438            matches!(&result, Err(Error::ForcedBackendUnavailable(s)) if s.contains("unknown")),
11439            "invalid backend value should return ForcedBackendUnavailable error: {result:?}"
11440        );
11441    }
11442
11443    #[test]
11444    fn test_force_backend_unset() {
11445        let _lock = acquire_env_lock();
11446        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
11447        unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") };
11448        let converter = ImageProcessor::new().unwrap();
11449        assert!(converter.forced_backend.is_none());
11450    }
11451
11452    // ========================================================================
11453    // Tests for hybrid mask path error handling
11454    // ========================================================================
11455
11456    #[test]
11457    fn test_draw_proto_masks_no_cpu_returns_error() {
11458        // Serialize against all other env-var-mutating tests.
11459        let _lock = acquire_env_lock();
11460        let _guard = EnvGuard::snapshot(&[
11461            "EDGEFIRST_FORCE_BACKEND",
11462            "EDGEFIRST_DISABLE_GL",
11463            "EDGEFIRST_DISABLE_G2D",
11464            "EDGEFIRST_DISABLE_CPU",
11465        ]);
11466
11467        // Disable all backends so cpu.is_none() after construction.
11468        unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
11469        unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
11470        unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
11471
11472        let mut converter = ImageProcessor::new().unwrap();
11473        assert!(converter.cpu.is_none(), "CPU should be disabled");
11474
11475        let dst = TensorDyn::image(
11476            640,
11477            480,
11478            PixelFormat::Rgba,
11479            DType::U8,
11480            Some(TensorMemory::Mem),
11481            edgefirst_tensor::CpuAccess::ReadWrite,
11482        )
11483        .unwrap();
11484        let mut dst_dyn = dst;
11485        let det = [DetectBox {
11486            bbox: edgefirst_tensor::BoundingBox {
11487                xmin: 0.1,
11488                ymin: 0.1,
11489                xmax: 0.5,
11490                ymax: 0.5,
11491            },
11492            score: 0.9,
11493            label: 0,
11494        }];
11495        let proto_data = {
11496            use edgefirst_tensor::{Tensor, TensorDyn};
11497            let coeff_t = Tensor::<f32>::from_slice(&[0.5_f32; 4], &[1, 4]).unwrap();
11498            let protos_t =
11499                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
11500            ProtoData {
11501                mask_coefficients: TensorDyn::F32(coeff_t),
11502                protos: TensorDyn::F32(protos_t),
11503                layout: ProtoLayout::Nhwc,
11504            }
11505        };
11506        let result =
11507            converter.draw_proto_masks(&mut dst_dyn, &det, &proto_data, Default::default());
11508        assert!(
11509            matches!(&result, Err(Error::Internal(s)) if s.contains("CPU backend")),
11510            "draw_proto_masks without CPU should return Internal error: {result:?}"
11511        );
11512    }
11513
11514    #[test]
11515    fn test_draw_proto_masks_cpu_fallback_works() {
11516        // Force CPU-only backend to ensure the CPU fallback path executes.
11517        // Serialized under ENV_MUTEX so we don't race with disable-var tests.
11518        let _lock = acquire_env_lock();
11519        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
11520        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
11521        let mut converter = ImageProcessor::new().unwrap();
11522        assert!(converter.cpu.is_some());
11523
11524        let dst = TensorDyn::image(
11525            64,
11526            64,
11527            PixelFormat::Rgba,
11528            DType::U8,
11529            Some(TensorMemory::Mem),
11530            edgefirst_tensor::CpuAccess::ReadWrite,
11531        )
11532        .unwrap();
11533        let mut dst_dyn = dst;
11534        let det = [DetectBox {
11535            bbox: edgefirst_tensor::BoundingBox {
11536                xmin: 0.1,
11537                ymin: 0.1,
11538                xmax: 0.5,
11539                ymax: 0.5,
11540            },
11541            score: 0.9,
11542            label: 0,
11543        }];
11544        let proto_data = {
11545            use edgefirst_tensor::{Tensor, TensorDyn};
11546            let coeff_t = Tensor::<f32>::from_slice(&[0.5_f32; 4], &[1, 4]).unwrap();
11547            let protos_t =
11548                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
11549            ProtoData {
11550                mask_coefficients: TensorDyn::F32(coeff_t),
11551                protos: TensorDyn::F32(protos_t),
11552                layout: ProtoLayout::Nhwc,
11553            }
11554        };
11555        let result =
11556            converter.draw_proto_masks(&mut dst_dyn, &det, &proto_data, Default::default());
11557        assert!(result.is_ok(), "CPU fallback path should work: {result:?}");
11558    }
11559
11560    // ============================================================
11561    // draw_decoded_masks / draw_proto_masks — 4-scenario pixel-
11562    // verified tests. Exercises each backend against the full
11563    // output-contract matrix:
11564    //
11565    //   | detections | background | expected dst             |
11566    //   |------------|------------|--------------------------|
11567    //   | empty      | none       | fully cleared (0x00)     |
11568    //   | empty      | set        | fully equal to bg        |
11569    //   | set        | none       | cleared outside box +    |
11570    //   |            |            | mask-coloured inside     |
11571    //   | set        | set        | bg outside box + mask    |
11572    //   |            |            | blended inside           |
11573    //
11574    // Every test pre-fills dst with a non-zero "dirty" pattern so
11575    // that any silent `return Ok(())` leaks the pattern into the
11576    // asserted output and fails loudly.
11577    // ============================================================
11578
11579    // =========================================================================
11580    // Env-var serialisation helpers
11581    //
11582    // ALL tests that mutate any EDGEFIRST_* backend env var must hold
11583    // ENV_MUTEX for their full duration.  This single mutex serialises
11584    // test_disable_env_var, test_draw_proto_masks_no_cpu_returns_error,
11585    // test_force_backend_*, with_force_backend, and with_env — preventing
11586    // any two of them from racing in a parallel `cargo test` run.
11587    // =========================================================================
11588
11589    /// Acquire the process-wide env-var mutex.  Returns a guard that must be
11590    /// kept alive for the entire duration of the test body.
11591    fn acquire_env_lock() -> std::sync::MutexGuard<'static, ()> {
11592        use std::sync::{Mutex, OnceLock};
11593        static ENV_MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
11594        ENV_MUTEX
11595            .get_or_init(|| Mutex::new(()))
11596            .lock()
11597            .unwrap_or_else(|e| e.into_inner())
11598    }
11599
11600    /// RAII guard that snapshots a set of env vars on construction and
11601    /// restores them on `Drop`, even if the test panics.
11602    struct EnvGuard {
11603        vars: Vec<(&'static str, Option<String>)>,
11604    }
11605
11606    impl EnvGuard {
11607        /// Snapshot the current values of `names`.  Call this while holding
11608        /// the env lock (the lock is not taken here — that is the caller's
11609        /// responsibility so the lock scope can be wider than the guard).
11610        fn snapshot(names: &[&'static str]) -> Self {
11611            Self {
11612                vars: names.iter().map(|&k| (k, std::env::var(k).ok())).collect(),
11613            }
11614        }
11615    }
11616
11617    impl Drop for EnvGuard {
11618        fn drop(&mut self) {
11619            for (k, v) in &self.vars {
11620                match v {
11621                    Some(s) => unsafe { std::env::set_var(k, s) },
11622                    None => unsafe { std::env::remove_var(k) },
11623                }
11624            }
11625        }
11626    }
11627
11628    /// Run `body` with `EDGEFIRST_FORCE_BACKEND` temporarily set (or
11629    /// removed), restoring the prior value afterward. Tests are env-
11630    /// serialized via the process-wide `ENV_MUTEX`.
11631    fn with_force_backend<R>(value: Option<&str>, body: impl FnOnce() -> R) -> R {
11632        let _lock = acquire_env_lock();
11633        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
11634        match value {
11635            Some(v) => unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", v) },
11636            None => unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") },
11637        }
11638        body()
11639    }
11640
11641    /// Allocate an RGBA image tensor and pre-fill every byte with a
11642    /// distinctive non-zero pattern. Any test that relies on the old
11643    /// "dst is already cleared" assumption will see this pattern leak
11644    /// through to the output and fail.
11645    fn make_dirty_dst(w: usize, h: usize, mem: Option<TensorMemory>) -> TensorDyn {
11646        let dst = TensorDyn::image(
11647            w,
11648            h,
11649            PixelFormat::Rgba,
11650            DType::U8,
11651            mem,
11652            edgefirst_tensor::CpuAccess::ReadWrite,
11653        )
11654        .unwrap();
11655        {
11656            use edgefirst_tensor::TensorMapTrait;
11657            let u8t = dst.as_typed::<u8>().unwrap();
11658            let mut map = u8t.map().unwrap();
11659            for (i, b) in map.as_mut_slice().iter_mut().enumerate() {
11660                *b = 0xA0u8.wrapping_add((i as u8) & 0x3F);
11661            }
11662        }
11663        dst
11664    }
11665
11666    /// Allocate an RGBA background filled with a constant colour.
11667    fn make_bg(w: usize, h: usize, mem: Option<TensorMemory>, rgba: [u8; 4]) -> TensorDyn {
11668        let bg = TensorDyn::image(
11669            w,
11670            h,
11671            PixelFormat::Rgba,
11672            DType::U8,
11673            mem,
11674            edgefirst_tensor::CpuAccess::ReadWrite,
11675        )
11676        .unwrap();
11677        {
11678            use edgefirst_tensor::TensorMapTrait;
11679            let u8t = bg.as_typed::<u8>().unwrap();
11680            let mut map = u8t.map().unwrap();
11681            for chunk in map.as_mut_slice().as_chunks_mut::<4>().0 {
11682                chunk.copy_from_slice(&rgba);
11683            }
11684        }
11685        bg
11686    }
11687
11688    fn pixel_at(dst: &TensorDyn, x: usize, y: usize) -> [u8; 4] {
11689        use edgefirst_tensor::TensorMapTrait;
11690        let w = dst.width().unwrap();
11691        let off = (y * w + x) * 4;
11692        let u8t = dst.as_typed::<u8>().unwrap();
11693        let map = u8t.map().unwrap();
11694        let s = map.as_slice();
11695        [s[off], s[off + 1], s[off + 2], s[off + 3]]
11696    }
11697
11698    fn assert_every_pixel_eq(dst: &TensorDyn, expected: [u8; 4], case: &str) {
11699        use edgefirst_tensor::TensorMapTrait;
11700        let u8t = dst.as_typed::<u8>().unwrap();
11701        let map = u8t.map().unwrap();
11702        for (i, chunk) in map.as_slice().as_chunks::<4>().0.iter().enumerate() {
11703            assert_eq!(
11704                chunk, &expected,
11705                "{case}: pixel idx {i} = {chunk:?}, expected {expected:?}"
11706            );
11707        }
11708    }
11709
11710    /// Scenario 1: empty detections, empty segmentation, no background
11711    /// → dst must be fully cleared to 0x00000000.
11712    fn scenario_empty_no_bg(processor: &mut ImageProcessor, case: &str) {
11713        let mut dst = make_dirty_dst(64, 64, None);
11714        processor
11715            .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::default())
11716            .unwrap_or_else(|e| panic!("{case}/decoded_masks empty+no-bg failed: {e:?}"));
11717        assert_every_pixel_eq(&dst, [0, 0, 0, 0], &format!("{case}/decoded"));
11718
11719        let mut dst = make_dirty_dst(64, 64, None);
11720        let proto = {
11721            use edgefirst_tensor::{Tensor, TensorDyn};
11722            // Placeholder (no detections); shape [1, 4] to keep the tensor well-formed.
11723            let coeff_t = Tensor::<f32>::from_slice(&[0.0_f32; 4], &[1, 4]).unwrap();
11724            let protos_t =
11725                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
11726            ProtoData {
11727                mask_coefficients: TensorDyn::F32(coeff_t),
11728                protos: TensorDyn::F32(protos_t),
11729                layout: ProtoLayout::Nhwc,
11730            }
11731        };
11732        processor
11733            .draw_proto_masks(&mut dst, &[], &proto, MaskOverlay::default())
11734            .unwrap_or_else(|e| panic!("{case}/proto_masks empty+no-bg failed: {e:?}"));
11735        assert_every_pixel_eq(&dst, [0, 0, 0, 0], &format!("{case}/proto"));
11736    }
11737
11738    /// Scenario 2: empty detections, empty segmentation, background set
11739    /// → dst must be fully equal to bg.
11740    fn scenario_empty_with_bg(processor: &mut ImageProcessor, case: &str) {
11741        let bg_color = [42, 99, 200, 255];
11742        let bg = make_bg(64, 64, None, bg_color);
11743        let overlay = MaskOverlay::new().with_background(&bg);
11744
11745        let mut dst = make_dirty_dst(64, 64, None);
11746        processor
11747            .draw_decoded_masks(&mut dst, &[], &[], overlay)
11748            .unwrap_or_else(|e| panic!("{case}/decoded_masks empty+bg failed: {e:?}"));
11749        assert_every_pixel_eq(&dst, bg_color, &format!("{case}/decoded bg blit"));
11750
11751        let mut dst = make_dirty_dst(64, 64, None);
11752        let proto = {
11753            use edgefirst_tensor::{Tensor, TensorDyn};
11754            // Placeholder (no detections); shape [1, 4] to keep the tensor well-formed.
11755            let coeff_t = Tensor::<f32>::from_slice(&[0.0_f32; 4], &[1, 4]).unwrap();
11756            let protos_t =
11757                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
11758            ProtoData {
11759                mask_coefficients: TensorDyn::F32(coeff_t),
11760                protos: TensorDyn::F32(protos_t),
11761                layout: ProtoLayout::Nhwc,
11762            }
11763        };
11764        processor
11765            .draw_proto_masks(&mut dst, &[], &proto, overlay)
11766            .unwrap_or_else(|e| panic!("{case}/proto_masks empty+bg failed: {e:?}"));
11767        assert_every_pixel_eq(&dst, bg_color, &format!("{case}/proto bg blit"));
11768    }
11769
11770    /// Scenario 3: one detection with a fully-opaque segmentation fill,
11771    /// no background → outside the box dst must be 0x00, inside it must
11772    /// be a non-zero mask colour (the render_segmentation output).
11773    fn scenario_detect_no_bg(processor: &mut ImageProcessor, case: &str) {
11774        use edgefirst_tensor::Segmentation;
11775        use ndarray::Array3;
11776        processor
11777            .set_class_colors(&[[200, 80, 40, 255]])
11778            .expect("set_class_colors");
11779
11780        let detect = DetectBox {
11781            bbox: [0.25, 0.25, 0.75, 0.75].into(),
11782            score: 0.99,
11783            label: 0,
11784        };
11785        let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
11786        let seg = Segmentation {
11787            segmentation: edgefirst_tensor::Tensor::from_arrayview3(seg_arr.view())
11788                .unwrap()
11789                .into(),
11790            xmin: 0.25,
11791            ymin: 0.25,
11792            xmax: 0.75,
11793            ymax: 0.75,
11794        };
11795
11796        let mut dst = make_dirty_dst(64, 64, None);
11797        processor
11798            .draw_decoded_masks(&mut dst, &[detect], &[seg], MaskOverlay::default())
11799            .unwrap_or_else(|e| panic!("{case}/decoded_masks detect+no-bg failed: {e:?}"));
11800
11801        // Outside the bbox (corner): must be cleared black.
11802        let corner = pixel_at(&dst, 2, 2);
11803        assert_eq!(
11804            corner,
11805            [0, 0, 0, 0],
11806            "{case}/decoded: corner (2,2) leaked dirty pattern: {corner:?}"
11807        );
11808        // Inside the bbox (center): the mask colour must be visible.
11809        // Any non-zero pixel is acceptable — exact rendering varies
11810        // between backends (GL smoothstep, CPU nearest).
11811        let center = pixel_at(&dst, 32, 32);
11812        assert!(
11813            center != [0, 0, 0, 0],
11814            "{case}/decoded: center (32,32) was not coloured: {center:?}"
11815        );
11816    }
11817
11818    /// Scenario 4: detection + background. Outside the box must match
11819    /// bg; inside the box must NOT match bg (mask blended on top).
11820    fn scenario_detect_with_bg(processor: &mut ImageProcessor, case: &str) {
11821        use edgefirst_tensor::Segmentation;
11822        use ndarray::Array3;
11823        processor
11824            .set_class_colors(&[[200, 80, 40, 255]])
11825            .expect("set_class_colors");
11826        let bg_color = [10, 20, 30, 255];
11827        let bg = make_bg(64, 64, None, bg_color);
11828
11829        let detect = DetectBox {
11830            bbox: [0.25, 0.25, 0.75, 0.75].into(),
11831            score: 0.99,
11832            label: 0,
11833        };
11834        let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
11835        let seg = Segmentation {
11836            segmentation: edgefirst_tensor::Tensor::from_arrayview3(seg_arr.view())
11837                .unwrap()
11838                .into(),
11839            xmin: 0.25,
11840            ymin: 0.25,
11841            xmax: 0.75,
11842            ymax: 0.75,
11843        };
11844
11845        let overlay = MaskOverlay::new().with_background(&bg);
11846        let mut dst = make_dirty_dst(64, 64, None);
11847        processor
11848            .draw_decoded_masks(&mut dst, &[detect], &[seg], overlay)
11849            .unwrap_or_else(|e| panic!("{case}/decoded_masks detect+bg failed: {e:?}"));
11850
11851        // Outside the bbox (corner): bg colour.
11852        let corner = pixel_at(&dst, 2, 2);
11853        assert_eq!(
11854            corner, bg_color,
11855            "{case}/decoded: corner (2,2) should show bg {bg_color:?} got {corner:?}"
11856        );
11857        // Inside the bbox (center): mask blended on bg, must differ from
11858        // pure bg (alpha-blend with mask colour produces a distinct shade).
11859        let center = pixel_at(&dst, 32, 32);
11860        assert!(
11861            center != bg_color,
11862            "{case}/decoded: center (32,32) should differ from bg {bg_color:?}, got {center:?}"
11863        );
11864    }
11865
11866    /// Run all 4 scenarios against the processor. Skip gracefully if
11867    /// construction fails (backend unavailable on this host).
11868    fn run_all_scenarios(
11869        force_backend: Option<&'static str>,
11870        case: &'static str,
11871        require_dma_for_bg: bool,
11872    ) {
11873        if require_dma_for_bg && !edgefirst_tensor::is_dma_available() {
11874            crate::test_support::report_skip(&format!("{case} — DMA not available on this host"));
11875            return;
11876        }
11877        let processor_result = with_force_backend(force_backend, ImageProcessor::new);
11878        let mut processor = match processor_result {
11879            Ok(p) => p,
11880            Err(e) => {
11881                crate::test_support::report_skip(&format!("{case} — backend init failed: {e:?}"));
11882                return;
11883            }
11884        };
11885        scenario_empty_no_bg(&mut processor, case);
11886        scenario_empty_with_bg(&mut processor, case);
11887        scenario_detect_no_bg(&mut processor, case);
11888        scenario_detect_with_bg(&mut processor, case);
11889    }
11890
11891    #[test]
11892    fn test_draw_masks_4_scenarios_cpu() {
11893        run_all_scenarios(Some("cpu"), "cpu", false);
11894    }
11895
11896    #[test]
11897    fn test_draw_masks_4_scenarios_auto() {
11898        run_all_scenarios(None, "auto", false);
11899    }
11900
11901    #[cfg(any(
11902        target_os = "linux",
11903        target_os = "macos",
11904        target_os = "ios",
11905        target_os = "android",
11906        target_os = "windows"
11907    ))]
11908    #[cfg(feature = "opengl")]
11909    #[test]
11910    fn test_draw_masks_4_scenarios_opengl() {
11911        run_all_scenarios(Some("opengl"), "opengl", false);
11912    }
11913
11914    /// G2D forced backend: exercises the zero-detection empty-frame
11915    /// paths via `g2d_clear` and `g2d_blit`. Scenarios 3 and 4 (with
11916    /// detections) expect `NotImplemented` since G2D has no rasterizer
11917    /// for boxes / masks.
11918    #[cfg(target_os = "linux")]
11919    #[test]
11920    fn test_draw_masks_zero_detection_g2d_forced() {
11921        if !edgefirst_tensor::is_dma_available() {
11922            crate::test_support::report_skip("g2d forced — DMA not available on this host");
11923            return;
11924        }
11925        let processor_result = with_force_backend(Some("g2d"), ImageProcessor::new);
11926        let mut processor = match processor_result {
11927            Ok(p) => p,
11928            Err(e) => {
11929                crate::test_support::report_skip(&format!("g2d forced — init failed: {e:?}"));
11930                return;
11931            }
11932        };
11933
11934        // Case 1: empty + no bg. G2D requires DMA-backed dst.
11935        let mut dst = TensorDyn::image(
11936            64,
11937            64,
11938            PixelFormat::Rgba,
11939            DType::U8,
11940            Some(TensorMemory::DmaBuf),
11941            edgefirst_tensor::CpuAccess::ReadWrite,
11942        )
11943        .unwrap();
11944        {
11945            use edgefirst_tensor::TensorMapTrait;
11946            let u8t = dst.as_typed_mut::<u8>().unwrap();
11947            let mut map = u8t.map().unwrap();
11948            map.as_mut_slice().fill(0xBB);
11949        }
11950        processor
11951            .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::default())
11952            .expect("g2d empty+no-bg");
11953        assert_every_pixel_eq(&dst, [0, 0, 0, 0], "g2d/case1 cleared");
11954
11955        // Case 2: empty + bg. Both surfaces DMA-backed for g2d_blit.
11956        let bg_color = [7, 11, 13, 255];
11957        let bg = {
11958            let t = TensorDyn::image(
11959                64,
11960                64,
11961                PixelFormat::Rgba,
11962                DType::U8,
11963                Some(TensorMemory::DmaBuf),
11964                edgefirst_tensor::CpuAccess::ReadWrite,
11965            )
11966            .unwrap();
11967            {
11968                use edgefirst_tensor::TensorMapTrait;
11969                let u8t = t.as_typed::<u8>().unwrap();
11970                let mut map = u8t.map().unwrap();
11971                for chunk in map.as_mut_slice().as_chunks_mut::<4>().0 {
11972                    chunk.copy_from_slice(&bg_color);
11973                }
11974            }
11975            t
11976        };
11977        let mut dst = TensorDyn::image(
11978            64,
11979            64,
11980            PixelFormat::Rgba,
11981            DType::U8,
11982            Some(TensorMemory::DmaBuf),
11983            edgefirst_tensor::CpuAccess::ReadWrite,
11984        )
11985        .unwrap();
11986        {
11987            use edgefirst_tensor::TensorMapTrait;
11988            let u8t = dst.as_typed_mut::<u8>().unwrap();
11989            let mut map = u8t.map().unwrap();
11990            map.as_mut_slice().fill(0x55);
11991        }
11992        processor
11993            .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::new().with_background(&bg))
11994            .expect("g2d empty+bg");
11995        assert_every_pixel_eq(&dst, bg_color, "g2d/case2 bg blit");
11996
11997        // Case 3 and 4: detect present — must return NotImplemented.
11998        let detect = DetectBox {
11999            bbox: [0.25, 0.25, 0.75, 0.75].into(),
12000            score: 0.9,
12001            label: 0,
12002        };
12003        let mut dst = TensorDyn::image(
12004            64,
12005            64,
12006            PixelFormat::Rgba,
12007            DType::U8,
12008            Some(TensorMemory::DmaBuf),
12009            edgefirst_tensor::CpuAccess::ReadWrite,
12010        )
12011        .unwrap();
12012        let err = processor
12013            .draw_decoded_masks(&mut dst, &[detect], &[], MaskOverlay::default())
12014            .expect_err("g2d must reject detect-present draw_decoded_masks");
12015        assert!(
12016            matches!(err, Error::NotImplemented(_)),
12017            "g2d case3 wrong error: {err:?}"
12018        );
12019    }
12020
12021    #[test]
12022    fn test_set_format_then_cpu_convert() {
12023        // Force CPU backend; serialized under ENV_MUTEX to avoid racing with
12024        // test_force_backend_* and test_disable_env_var.
12025        let _lock = acquire_env_lock();
12026        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
12027        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
12028        let mut processor = ImageProcessor::new().unwrap();
12029
12030        // Load a source image
12031        let image = edgefirst_bench::testdata::read("zidane.jpg");
12032        let src = load_image_test_helper(&image, Some(PixelFormat::Rgba), None).unwrap();
12033
12034        // Create a raw tensor, then attach format — simulating the from_fd workflow
12035        let mut dst =
12036            TensorDyn::new(&[640, 640, 3], DType::U8, Some(TensorMemory::Mem), None).unwrap();
12037        dst.set_format(PixelFormat::Rgb).unwrap();
12038
12039        // Convert should work with the set_format-annotated tensor
12040        processor
12041            .convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
12042            .unwrap();
12043
12044        // Verify format survived conversion
12045        assert_eq!(dst.format(), Some(PixelFormat::Rgb));
12046        assert_eq!(dst.width(), Some(640));
12047        assert_eq!(dst.height(), Some(640));
12048    }
12049
12050    /// Verify that creating multiple ImageProcessors on the same thread and
12051    /// performing a resize on each does not deadlock or error.
12052    ///
12053    /// Uses automatic memory allocation (DMA → PBO → Mem fallback) so that
12054    /// hardware backends (OpenGL, G2D) are exercised on capable targets.
12055    #[test]
12056    fn test_multiple_image_processors_same_thread() {
12057        // Hold the env mutex so env-var-mutating tests can't corrupt the state
12058        // seen by ImageProcessor::new() calls during this test.
12059        let _lock = acquire_env_lock();
12060        let mut processors: Vec<ImageProcessor> = (0..4)
12061            .map(|_| ImageProcessor::new().expect("ImageProcessor::new() failed"))
12062            .collect();
12063
12064        for proc in &mut processors {
12065            let src = proc
12066                .create_image(
12067                    128,
12068                    128,
12069                    PixelFormat::Rgb,
12070                    DType::U8,
12071                    None,
12072                    edgefirst_tensor::CpuAccess::ReadWrite,
12073                )
12074                .expect("create src failed");
12075            let mut dst = proc
12076                .create_image(
12077                    64,
12078                    64,
12079                    PixelFormat::Rgb,
12080                    DType::U8,
12081                    None,
12082                    edgefirst_tensor::CpuAccess::ReadWrite,
12083                )
12084                .expect("create dst failed");
12085            proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
12086                .expect("convert failed");
12087            assert_eq!(dst.width(), Some(64));
12088            assert_eq!(dst.height(), Some(64));
12089        }
12090    }
12091
12092    /// Verify that creating ImageProcessors on separate threads and performing
12093    /// a resize on each does not deadlock or error.
12094    ///
12095    /// Uses automatic memory allocation (DMA → PBO → Mem fallback) so that
12096    /// hardware backends (OpenGL, G2D) are exercised on capable targets.
12097    /// A 60-second timeout prevents CI from hanging on deadlock regressions.
12098    #[test]
12099    fn test_multiple_image_processors_separate_threads() {
12100        use std::sync::mpsc;
12101        use std::time::Duration;
12102
12103        // The Vivante GC7000UL driver (i.MX 8M Plus) double-frees on concurrent
12104        // EGL context teardown — four processors spun up on four threads here
12105        // trips it and aborts the whole test binary (SIGABRT, not a catchable
12106        // panic). The bug is the driver's, not the HAL's; this test is kept so
12107        // it still exercises the multi-context path on every other GPU. The
12108        // on-target GitHub Actions imx8mp runner sets EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS
12109        // to skip just this case there, while the run stays red anywhere else
12110        // a regression appears. (Skip, not #[ignore]: the platform is decided
12111        // at runtime, not compile time.)
12112        if std::env::var_os("EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS").is_some() {
12113            crate::test_support::report_skip(
12114                "test_multiple_image_processors_separate_threads — known Vivante \
12115                 GC7000UL concurrent-EGL-teardown double-free \
12116                 (EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS set)",
12117            );
12118            return;
12119        }
12120
12121        const TIMEOUT: Duration = Duration::from_secs(60);
12122
12123        // Hold the env mutex so env-var-mutating tests can't corrupt ImageProcessor::new()
12124        // calls made inside the spawned threads during this test.
12125        let _lock = acquire_env_lock();
12126
12127        let (tx, rx) = mpsc::channel::<()>();
12128
12129        std::thread::spawn(move || {
12130            let handles: Vec<_> = (0..4)
12131                .map(|i| {
12132                    std::thread::spawn(move || {
12133                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
12134                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
12135                        });
12136                        let src = proc
12137                            .create_image(
12138                                128,
12139                                128,
12140                                PixelFormat::Rgb,
12141                                DType::U8,
12142                                None,
12143                                edgefirst_tensor::CpuAccess::ReadWrite,
12144                            )
12145                            .unwrap_or_else(|e| panic!("create src failed on thread {i}: {e}"));
12146                        let mut dst = proc
12147                            .create_image(
12148                                64,
12149                                64,
12150                                PixelFormat::Rgb,
12151                                DType::U8,
12152                                None,
12153                                edgefirst_tensor::CpuAccess::ReadWrite,
12154                            )
12155                            .unwrap_or_else(|e| panic!("create dst failed on thread {i}: {e}"));
12156                        proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
12157                            .unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
12158                        assert_eq!(dst.width(), Some(64));
12159                        assert_eq!(dst.height(), Some(64));
12160                    })
12161                })
12162                .collect();
12163
12164            for (i, h) in handles.into_iter().enumerate() {
12165                h.join()
12166                    .unwrap_or_else(|e| panic!("thread {i} panicked: {e:?}"));
12167            }
12168
12169            let _ = tx.send(());
12170        });
12171
12172        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
12173            panic!("test_multiple_image_processors_separate_threads timed out after {TIMEOUT:?}")
12174        });
12175    }
12176
12177    /// Verify that 4 fully-initialized ImageProcessors on separate threads can
12178    /// all operate concurrently without deadlocking each other.
12179    ///
12180    /// All processors are created first, then a barrier synchronizes them so
12181    /// they all start converting at the same instant — maximizing contention.
12182    /// A 60-second timeout prevents CI from hanging on deadlock regressions.
12183    #[test]
12184    fn test_image_processors_concurrent_operations() {
12185        use std::sync::{mpsc, Arc, Barrier};
12186        use std::time::Duration;
12187
12188        const N: usize = 4;
12189        const ROUNDS: usize = 10;
12190        const TIMEOUT: Duration = Duration::from_secs(60);
12191
12192        // Hold the env mutex so env-var-mutating tests can't corrupt ImageProcessor::new()
12193        // calls made inside the spawned threads during this test.
12194        let _lock = acquire_env_lock();
12195
12196        let (tx, rx) = mpsc::channel::<()>();
12197
12198        std::thread::spawn(move || {
12199            let barrier = Arc::new(Barrier::new(N));
12200
12201            let handles: Vec<_> = (0..N)
12202                .map(|i| {
12203                    let barrier = Arc::clone(&barrier);
12204                    std::thread::spawn(move || {
12205                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
12206                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
12207                        });
12208
12209                        // All threads wait here until every processor is initialized.
12210                        barrier.wait();
12211
12212                        // Now all 4 hammer the GPU concurrently.
12213                        for round in 0..ROUNDS {
12214                            let src = proc
12215                                .create_image(
12216                                    128,
12217                                    128,
12218                                    PixelFormat::Rgb,
12219                                    DType::U8,
12220                                    None,
12221                                    edgefirst_tensor::CpuAccess::ReadWrite,
12222                                )
12223                                .unwrap_or_else(|e| {
12224                                    panic!("create src failed on thread {i} round {round}: {e}")
12225                                });
12226                            let mut dst = proc
12227                                .create_image(
12228                                    64,
12229                                    64,
12230                                    PixelFormat::Rgb,
12231                                    DType::U8,
12232                                    None,
12233                                    edgefirst_tensor::CpuAccess::ReadWrite,
12234                                )
12235                                .unwrap_or_else(|e| {
12236                                    panic!("create dst failed on thread {i} round {round}: {e}")
12237                                });
12238                            proc.convert(
12239                                &src,
12240                                &mut dst,
12241                                Rotation::None,
12242                                Flip::None,
12243                                Crop::default(),
12244                            )
12245                            .unwrap_or_else(|e| {
12246                                panic!("convert failed on thread {i} round {round}: {e}")
12247                            });
12248                            assert_eq!(dst.width(), Some(64));
12249                            assert_eq!(dst.height(), Some(64));
12250                        }
12251                    })
12252                })
12253                .collect();
12254
12255            for (i, h) in handles.into_iter().enumerate() {
12256                h.join()
12257                    .unwrap_or_else(|e| panic!("thread {i} panicked: {e:?}"));
12258            }
12259
12260            let _ = tx.send(());
12261        });
12262
12263        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
12264            panic!("test_image_processors_concurrent_operations timed out after {TIMEOUT:?}")
12265        });
12266    }
12267
12268    /// THE parallel-processors demonstration test: 4 ImageProcessors on 4
12269    /// threads, each with its own GL context and worker, converting
12270    /// per-thread-DISTINCT synthetic inputs concurrently (barrier-released);
12271    /// every output must byte-match the thread's own pre-barrier sequential
12272    /// oracle from the same processor. On LifecycleOnly platforms (Mali,
12273    /// V3D, Tegra, llvmpipe, macOS) the converts genuinely overlap on the
12274    /// GPU; on Vivante they serialize via the Full policy and the test still
12275    /// must pass. Distinct inputs make any cross-processor state leakage
12276    /// (wrong texture, wrong context, clobbered upload) visible as a byte
12277    /// diff rather than a coincidental match — proven by a scratch
12278    /// cross-wire run (neighbor's input post-oracle) failing on every
12279    /// thread with ~53% of bytes diverged.
12280    ///
12281    /// Skipped under EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS like
12282    /// `test_multiple_image_processors_separate_threads`: the galcore driver
12283    /// can abort intermittently on concurrent multi-processor lifecycles
12284    /// regardless of locking (P0 spike: reproduces fully serialized).
12285    #[test]
12286    fn test_parallel_processors_unique_outputs() {
12287        use std::sync::{mpsc, Arc, Barrier};
12288        use std::time::Duration;
12289
12290        const N: usize = 4;
12291        const ROUNDS: usize = 25;
12292        const TIMEOUT: Duration = Duration::from_secs(60);
12293
12294        if std::env::var_os("EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS").is_some() {
12295            crate::test_support::report_skip(
12296                "test_parallel_processors_unique_outputs — known Vivante \
12297                 GC7000UL concurrent-multi-processor driver abort \
12298                 (EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS set)",
12299            );
12300            return;
12301        }
12302
12303        let _lock = acquire_env_lock();
12304        let (tx, rx) = mpsc::channel::<()>();
12305
12306        std::thread::spawn(move || {
12307            let barrier = Arc::new(Barrier::new(N));
12308            let handles: Vec<_> = (0..N)
12309                .map(|i| {
12310                    let barrier = Arc::clone(&barrier);
12311                    std::thread::spawn(move || {
12312                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
12313                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
12314                        });
12315                        // CI-weight geometry (llvmpipe renders on the CPU).
12316                        let (w, h) = (640usize, 480usize);
12317                        let mem = if edgefirst_tensor::is_dma_available() {
12318                            Some(TensorMemory::DmaBuf)
12319                        } else {
12320                            Some(TensorMemory::Mem)
12321                        };
12322                        let src = proc
12323                            .create_image(
12324                                w,
12325                                h,
12326                                PixelFormat::Nv12,
12327                                DType::U8,
12328                                mem,
12329                                edgefirst_tensor::CpuAccess::ReadWrite,
12330                            )
12331                            .unwrap();
12332                        {
12333                            let t = src.as_typed::<u8>().unwrap();
12334                            let mut m = t.map().unwrap();
12335                            let s = m.as_mut_slice();
12336                            for (j, b) in s[..w * h].iter_mut().enumerate() {
12337                                *b = ((i * 53 + j) % 200 + 16) as u8;
12338                            }
12339                            for b in &mut s[w * h..] {
12340                                *b = (80 + i * 24) as u8;
12341                            }
12342                        }
12343                        let lb = Crop::letterbox([114, 114, 114, 255]);
12344                        let convert_once = |proc: &mut ImageProcessor| -> Vec<u8> {
12345                            let mut dst = proc
12346                                .create_image(
12347                                    320,
12348                                    320,
12349                                    PixelFormat::Rgba,
12350                                    DType::U8,
12351                                    mem,
12352                                    edgefirst_tensor::CpuAccess::ReadWrite,
12353                                )
12354                                .unwrap();
12355                            proc.convert(&src, &mut dst, Rotation::None, Flip::None, lb)
12356                                .unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
12357                            let t = dst.as_typed::<u8>().unwrap();
12358                            let m = t.map().unwrap();
12359                            m.as_slice().to_vec()
12360                        };
12361
12362                        let oracle = convert_once(&mut proc);
12363                        barrier.wait();
12364                        for round in 0..ROUNDS {
12365                            let out = convert_once(&mut proc);
12366                            let diffs = oracle.iter().zip(&out).filter(|(a, b)| a != b).count();
12367                            assert!(
12368                                diffs == 0,
12369                                "thread {i} round {round}: {diffs}/{} bytes diverged \
12370                                 from this processor's own oracle — cross-processor \
12371                                 GL state leakage under parallel execution",
12372                                oracle.len()
12373                            );
12374                        }
12375                    })
12376                })
12377                .collect();
12378
12379            for (i, h) in handles.into_iter().enumerate() {
12380                h.join()
12381                    .unwrap_or_else(|e| panic!("parallel thread {i} panicked: {e:?}"));
12382            }
12383            let _ = tx.send(());
12384        });
12385
12386        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
12387            panic!("test_parallel_processors_unique_outputs timed out after {TIMEOUT:?}")
12388        });
12389    }
12390
12391    /// Heavy on-demand stressor for the GL serialization policy: 4
12392    /// processors × 4 threads × barrier × 200 NV12 720p → RGB 640 letterbox
12393    /// converts (DMA where available); every output must byte-match the
12394    /// thread's own pre-barrier sequential oracle from the same processor.
12395    /// Ignored by default (heavy; board tool — the CI-weight version is
12396    /// `test_parallel_processors_unique_outputs`). Run explicitly, optionally
12397    /// pinning the policy via EDGEFIRST_GL_SERIALIZE=full|lifecycle:
12398    ///   <test binary> stress_parallel_processors_oracle --ignored
12399    #[test]
12400    #[ignore = "heavy on-demand GL-parallelism stressor; run explicitly on boards"]
12401    fn stress_parallel_processors_oracle() {
12402        use std::sync::{mpsc, Arc, Barrier};
12403        use std::time::Duration;
12404
12405        const N: usize = 4;
12406        const ROUNDS: usize = 200;
12407        const TIMEOUT: Duration = Duration::from_secs(600);
12408
12409        let _lock = acquire_env_lock();
12410        let (tx, rx) = mpsc::channel::<()>();
12411
12412        std::thread::spawn(move || {
12413            let barrier = Arc::new(Barrier::new(N));
12414            let handles: Vec<_> = (0..N)
12415                .map(|i| {
12416                    let barrier = Arc::clone(&barrier);
12417                    std::thread::spawn(move || {
12418                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
12419                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
12420                        });
12421                        let (w, h) = (1280usize, 720usize);
12422                        let mem = if edgefirst_tensor::is_dma_available() {
12423                            Some(TensorMemory::DmaBuf)
12424                        } else {
12425                            Some(TensorMemory::Mem)
12426                        };
12427
12428                        // Per-thread-distinct synthetic NV12 so cross-wired
12429                        // GL state between processors shows up as a byte diff.
12430                        let src = proc
12431                            .create_image(
12432                                w,
12433                                h,
12434                                PixelFormat::Nv12,
12435                                DType::U8,
12436                                mem,
12437                                edgefirst_tensor::CpuAccess::ReadWrite,
12438                            )
12439                            .unwrap();
12440                        {
12441                            let t = src.as_typed::<u8>().unwrap();
12442                            let mut m = t.map().unwrap();
12443                            let s = m.as_mut_slice();
12444                            for (j, b) in s[..w * h].iter_mut().enumerate() {
12445                                *b = ((i * 37 + j) % 200 + 16) as u8;
12446                            }
12447                            for b in &mut s[w * h..] {
12448                                *b = (96 + i * 16) as u8;
12449                            }
12450                        }
12451                        let lb = Crop::letterbox([114, 114, 114, 255]);
12452
12453                        let convert_once = |proc: &mut ImageProcessor| -> Vec<u8> {
12454                            let mut dst = proc
12455                                .create_image(
12456                                    640,
12457                                    640,
12458                                    PixelFormat::Rgb,
12459                                    DType::U8,
12460                                    mem,
12461                                    edgefirst_tensor::CpuAccess::ReadWrite,
12462                                )
12463                                .unwrap();
12464                            proc.convert(&src, &mut dst, Rotation::None, Flip::None, lb)
12465                                .unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
12466                            let t = dst.as_typed::<u8>().unwrap();
12467                            let m = t.map().unwrap();
12468                            m.as_slice().to_vec()
12469                        };
12470
12471                        let oracle = convert_once(&mut proc);
12472                        barrier.wait();
12473                        for round in 0..ROUNDS {
12474                            let out = convert_once(&mut proc);
12475                            let diffs = oracle.iter().zip(&out).filter(|(a, b)| a != b).count();
12476                            assert!(
12477                                diffs == 0,
12478                                "thread {i} round {round}: {diffs}/{} bytes diverged \
12479                                 from the pre-barrier oracle",
12480                                oracle.len()
12481                            );
12482                        }
12483                    })
12484                })
12485                .collect();
12486
12487            for (i, h) in handles.into_iter().enumerate() {
12488                h.join()
12489                    .unwrap_or_else(|e| panic!("stressor thread {i} panicked: {e:?}"));
12490            }
12491            let _ = tx.send(());
12492        });
12493
12494        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
12495            panic!("stress_parallel_processors_oracle timed out after {TIMEOUT:?}")
12496        });
12497    }
12498
12499    // =========================================================================
12500    // F16 / F32 auto-chain fallback integration tests
12501    // =========================================================================
12502
12503    /// Proves the auto-chain (OpenGL → G2D → CPU) NEVER errors for a float
12504    /// combo the GL path does NOT cover.
12505    ///
12506    /// `Yuyv → Rgb F32` is not handled by the GL float render path (which
12507    /// only covers `Rgba → PlanarRgb F16` and `Rgba → Rgb F32`), so the
12508    /// chain falls through to the CPU float path. Before commit 868a7649
12509    /// added CPU U8→F32/F16 support this would have returned `Err`; now it
12510    /// must return `Ok` with output in `[0, 1]` and all values finite.
12511    #[test]
12512    fn convert_f32_auto_never_errors_non_gl_combo() {
12513        const W: usize = 64;
12514        const H: usize = 64;
12515
12516        // Build a small synthetic YUYV source (Y=128, U=128, V=128 → near-grey).
12517        // YUYV packs two pixels into 4 bytes: [Y0, U, Y1, V] per macropixel.
12518        let src = TensorDyn::image(
12519            W,
12520            H,
12521            PixelFormat::Yuyv,
12522            DType::U8,
12523            Some(TensorMemory::Mem),
12524            edgefirst_tensor::CpuAccess::ReadWrite,
12525        )
12526        .unwrap();
12527        {
12528            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
12529            let data = map.as_mut_slice();
12530            for chunk in data.as_chunks_mut::<4>().0 {
12531                chunk[0] = 128; // Y0
12532                chunk[1] = 128; // U
12533                chunk[2] = 160; // Y1 — distinct so a layout bug is visible
12534                chunk[3] = 128; // V
12535            }
12536        }
12537
12538        let mut dst = TensorDyn::image(
12539            W,
12540            H,
12541            PixelFormat::Rgb,
12542            DType::F32,
12543            Some(TensorMemory::Mem),
12544            edgefirst_tensor::CpuAccess::ReadWrite,
12545        )
12546        .unwrap();
12547
12548        let mut proc = ImageProcessor::new().unwrap();
12549        let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
12550        assert!(
12551            result.is_ok(),
12552            "auto-chain Yuyv→Rgb F32 must not error: {:?}",
12553            result.err()
12554        );
12555
12556        // Verify all output values are finite and in [0, 1].
12557        let map = dst.as_typed::<f32>().unwrap().map().unwrap();
12558        let floats = map.as_slice();
12559        assert_eq!(floats.len(), W * H * 3, "unexpected output element count");
12560        for (i, &v) in floats.iter().enumerate() {
12561            assert!(
12562                v.is_finite() && (0.0..=1.0).contains(&v),
12563                "output[{i}]={v} is not finite or not in [0,1]"
12564            );
12565        }
12566
12567        // WEAK-1: Anti-all-zero spot-check.  A Y=128 YUYV source normalises to
12568        // ≈0.502 on the luma channel.  If the buffer is all-zero (e.g. the CPU
12569        // path never wrote to it) this assertion catches the regression.
12570        let first_non_zero = floats.iter().find(|&&v| v > 0.01);
12571        assert!(
12572            first_non_zero.is_some(),
12573            "all-zero output detected — CPU path likely did not write to the destination buffer"
12574        );
12575        // Y=128 → luma ≈ 0.502.  Spot-check the first pixel's R channel
12576        // (which carries luma for a near-grey YUV source).
12577        let r0 = floats[0];
12578        assert!(
12579            (r0 - 0.502_f32).abs() < 0.05,
12580            "first pixel R={r0} expected ≈0.502 (Y=128 neutral grey from YUYV source)"
12581        );
12582    }
12583
12584    /// Proves CPU-forced `Rgba → PlanarRgb F16` correctness.
12585    ///
12586    /// Uses a source with clearly distinct per-channel values so a
12587    /// plane-swap or layout bug surfaces immediately. Tolerance is 2^-9
12588    /// (one F16 ULP at 0.5, i.e. roughly 1/512).
12589    #[test]
12590    // `ImageProcessorConfig` carries a Linux-only `egl_display` field, so
12591    // `{ backend, ..Default::default() }` is a genuine update on Linux but
12592    // covers no remaining fields on macOS, where `clippy::needless_update`
12593    // then fires. `allow` (not `expect`) because the lint is platform-
12594    // conditional — it does not fire on Linux.
12595    #[allow(clippy::needless_update)]
12596    fn convert_f16_forced_cpu_correct() {
12597        const W: usize = 16;
12598        const H: usize = 16;
12599        const TOL: f32 = 1.0 / 512.0; // 2^-9
12600
12601        // pixel (y,x): R = 50+x, G = 100+y*8, B = 200
12602        let src = TensorDyn::image(
12603            W,
12604            H,
12605            PixelFormat::Rgba,
12606            DType::U8,
12607            Some(TensorMemory::Mem),
12608            edgefirst_tensor::CpuAccess::ReadWrite,
12609        )
12610        .unwrap();
12611        {
12612            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
12613            let data = map.as_mut_slice();
12614            for y in 0..H {
12615                for x in 0..W {
12616                    let i = y * W + x;
12617                    data[i * 4] = (50 + x) as u8; // R: 50..65
12618                    data[i * 4 + 1] = (100 + y * 8) as u8; // G: 100..220
12619                    data[i * 4 + 2] = 200; // B: constant
12620                    data[i * 4 + 3] = 255;
12621                }
12622            }
12623        }
12624
12625        let mut dst = TensorDyn::image(
12626            W,
12627            H,
12628            PixelFormat::PlanarRgb,
12629            DType::F16,
12630            Some(TensorMemory::Mem),
12631            edgefirst_tensor::CpuAccess::ReadWrite,
12632        )
12633        .unwrap();
12634
12635        let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
12636            backend: ComputeBackend::Cpu,
12637            ..Default::default()
12638        })
12639        .unwrap();
12640        proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
12641            .expect("forced-CPU Rgba→PlanarRgb F16 must not error");
12642
12643        let src_map = src.as_typed::<u8>().unwrap().map().unwrap();
12644        let src_bytes = src_map.as_slice();
12645        let dst_map = dst.as_typed::<half::f16>().unwrap().map().unwrap();
12646        let dst_halfs = dst_map.as_slice();
12647
12648        let plane = W * H;
12649        assert_eq!(dst_halfs.len(), plane * 3, "wrong output element count");
12650
12651        for y in 0..H {
12652            for x in 0..W {
12653                let i = y * W + x;
12654                let r_exp = src_bytes[i * 4] as f32 / 255.0;
12655                let g_exp = src_bytes[i * 4 + 1] as f32 / 255.0;
12656                let b_exp = src_bytes[i * 4 + 2] as f32 / 255.0;
12657
12658                let r_got = dst_halfs[i].to_f32();
12659                let g_got = dst_halfs[plane + i].to_f32();
12660                let b_got = dst_halfs[2 * plane + i].to_f32();
12661
12662                assert!(
12663                    (r_got - r_exp).abs() <= TOL,
12664                    "R plane ({x},{y}): got {r_got}, expected {r_exp}"
12665                );
12666                assert!(
12667                    (g_got - g_exp).abs() <= TOL,
12668                    "G plane ({x},{y}): got {g_got}, expected {g_exp}"
12669                );
12670                assert!(
12671                    (b_got - b_exp).abs() <= TOL,
12672                    "B plane ({x},{y}): got {b_got}, expected {b_exp}"
12673                );
12674
12675                // Catch plane-swap: R and G must differ (they have different formulas).
12676                if src_bytes[i * 4] != src_bytes[i * 4 + 1] {
12677                    assert_ne!(r_got, g_got, "R and G planes must differ at ({x},{y})");
12678                }
12679            }
12680        }
12681    }
12682
12683    /// Proves the auto-chain falls through to CPU for `Rgba → Rgb F32` with
12684    /// rotation set.
12685    ///
12686    /// The GL float render path rejects any call where rotation ≠ None,
12687    /// returning an error that causes the chain to continue. Before the CPU
12688    /// float fallback this would have produced an error at the end of the
12689    /// chain; now it must reach CPU and return `Ok` with finite `[0, 1]` output.
12690    #[test]
12691    fn convert_f32_with_rotation_falls_back() {
12692        const W: usize = 16;
12693        const H: usize = 16;
12694
12695        // RGBA8 source with a known gradient (distinct per-channel values).
12696        let src = TensorDyn::image(
12697            W,
12698            H,
12699            PixelFormat::Rgba,
12700            DType::U8,
12701            Some(TensorMemory::Mem),
12702            edgefirst_tensor::CpuAccess::ReadWrite,
12703        )
12704        .unwrap();
12705        {
12706            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
12707            let data = map.as_mut_slice();
12708            for y in 0..H {
12709                for x in 0..W {
12710                    let i = y * W + x;
12711                    data[i * 4] = (x * 16) as u8; // R
12712                    data[i * 4 + 1] = (y * 16) as u8; // G
12713                    data[i * 4 + 2] = 128; // B
12714                    data[i * 4 + 3] = 255;
12715                }
12716            }
12717        }
12718
12719        // Rotation swaps W and H, so dst is [W, H] (H×W output).
12720        let mut dst = TensorDyn::image(
12721            H, // dst W = src H after 90° rotation
12722            W, // dst H = src W after 90° rotation
12723            PixelFormat::Rgb,
12724            DType::F32,
12725            Some(TensorMemory::Mem),
12726            edgefirst_tensor::CpuAccess::ReadWrite,
12727        )
12728        .unwrap();
12729
12730        let mut proc = ImageProcessor::new().unwrap();
12731        let result = proc.convert(
12732            &src,
12733            &mut dst,
12734            Rotation::Clockwise90,
12735            Flip::None,
12736            Crop::default(),
12737        );
12738        assert!(
12739            result.is_ok(),
12740            "auto-chain Rgba→Rgb F32 with Rot90 must not error: {:?}",
12741            result.err()
12742        );
12743
12744        let map = dst.as_typed::<f32>().unwrap().map().unwrap();
12745        let floats = map.as_slice();
12746        assert_eq!(floats.len(), H * W * 3, "unexpected output element count");
12747        for (i, &v) in floats.iter().enumerate() {
12748            assert!(
12749                v.is_finite() && (0.0..=1.0).contains(&v),
12750                "output[{i}]={v} is not finite or not in [0,1]"
12751            );
12752        }
12753    }
12754
12755    /// GL-vs-CPU identity parity for `Rgba → PlanarRgb F16`.
12756    ///
12757    /// Converts the same RGBA8 source via the `OpenGl` backend and the
12758    /// forced `Cpu` backend, then verifies the two F16 output tensors agree
12759    /// element-wise within 2^-8 (two F16 ULPs at 0.5). Skipped when OpenGL
12760    /// or F16 render is unavailable, and when the host cannot hold the
12761    /// zero-copy F16 destination the GL float route needs.
12762    ///
12763    /// **The GL route is asserted, not assumed** (issue #179).
12764    /// `ComputeBackend::OpenGl` is not a forced backend: it leaves
12765    /// `forced_backend` at `None`, so a GL decline logs at debug and the CPU
12766    /// serves the same convert. Both sides of the comparison would then be
12767    /// the CPU's answer and the parity claim would be a tautology — which is
12768    /// exactly how a host whose RGBA16F FBO is incomplete (desktop NVIDIA:
12769    /// `GL_FRAMEBUFFER_INCOMPLETE_ATTACHMENT`) passed while proving nothing,
12770    /// `supported_render_dtypes().f16` above notwithstanding: that flag is
12771    /// an extension query, not a completed render. `convert_fallback_count()`
12772    /// staying flat across the GL convert is the proof, and a decline is
12773    /// reported as a skip rather than a failure because it is a property of
12774    /// the host's driver, not of this code — except under
12775    /// `HAL_TEST_REQUIRE_GL=1`, which turns it into a named failure, the
12776    /// same opt-in `crates/image/tests/dst_view_stride.rs` and
12777    /// `reconstructed_view_draw.rs` carry for a GL backend that fails to
12778    /// come up. A lane that has asserted it has GL should not be quietly
12779    /// losing this test's coverage.
12780    #[test]
12781    #[cfg(all(
12782        any(
12783            target_os = "linux",
12784            target_os = "macos",
12785            target_os = "ios",
12786            target_os = "android",
12787            target_os = "windows"
12788        ),
12789        feature = "opengl"
12790    ))]
12791    fn convert_f16_gl_cpu_parity_identity() {
12792        // The macOS coverage lane sets `HAL_TEST_REQUIRE_GL=1` for both
12793        // passes but deliberately hobbles pass 1 (unsigned binaries, the
12794        // ANGLE dlopen gate closed). Excluded there the same way
12795        // `gl_backend_available_canary` excludes it, so the gate below
12796        // enforces in pass 2 only.
12797        #[cfg(target_os = "macos")]
12798        if std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1")
12799            && std::env::var_os("HAL_TEST_ALLOW_DLOPEN_ANGLE").is_none()
12800        {
12801            crate::test_support::report_skip(
12802                "convert_f16_gl_cpu_parity_identity - ANGLE dlopen gate closed (coverage pass 1)",
12803            );
12804            return;
12805        }
12806        if !is_opengl_available() {
12807            crate::test_support::report_skip(
12808                "convert_f16_gl_cpu_parity_identity - OpenGL not available",
12809            );
12810            return;
12811        }
12812
12813        const W: usize = 16;
12814        const H: usize = 16;
12815        const TOL: f32 = 1.0 / 256.0; // 2^-8
12816
12817        // pixel (y,x): R = 40+x, G = 80+y*10, B = 180
12818        let src = TensorDyn::image(
12819            W,
12820            H,
12821            PixelFormat::Rgba,
12822            DType::U8,
12823            Some(TensorMemory::Mem),
12824            edgefirst_tensor::CpuAccess::ReadWrite,
12825        )
12826        .unwrap();
12827        {
12828            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
12829            let data = map.as_mut_slice();
12830            for y in 0..H {
12831                for x in 0..W {
12832                    let i = y * W + x;
12833                    data[i * 4] = (40 + x) as u8; // R
12834                    data[i * 4 + 1] = (80 + y * 10) as u8; // G
12835                    data[i * 4 + 2] = 180; // B
12836                    data[i * 4 + 3] = 255;
12837                }
12838            }
12839        }
12840
12841        // GL path.
12842        let gl_result = {
12843            let mut gl_proc = match ImageProcessor::with_config(ImageProcessorConfig {
12844                backend: ComputeBackend::OpenGl,
12845                ..Default::default()
12846            }) {
12847                Ok(p) => p,
12848                Err(e) => {
12849                    crate::test_support::report_skip(&format!(
12850                        "convert_f16_gl_cpu_parity_identity - GL backend unavailable: {e}"
12851                    ));
12852                    return;
12853                }
12854            };
12855
12856            if !gl_proc.supported_render_dtypes().f16 {
12857                crate::test_support::report_skip(
12858                    "convert_f16_gl_cpu_parity_identity - F16 render not supported",
12859                );
12860                return;
12861            }
12862
12863            // A destination the GL float route actually serves. A
12864            // `TensorMemory::Mem` float destination has no arm in
12865            // `float_dispatch::classify_float_render` at all: it classifies as
12866            // `FloatRenderPath::None`, the u8 route rejects the dtype, and the
12867            // CPU serves every convert. With a host destination the GL half
12868            // below was therefore unreachable and this test could only ever
12869            // skip. `(Rgba, PlanarRgb, F16, DmaBuf)` -- `ZeroCopyF16Nchw` --
12870            // is the zero-copy float tuple EVERY platform's capability set
12871            // serves: it is unconditional in `classify_float_render`, so it
12872            // holds for the `PlanarF16` set Linux, Android and ANGLE/macOS
12873            // declare as well as the `All` set Windows declares.
12874            //
12875            // One spelling covers all of them. `TensorMemory::DmaBuf` is the
12876            // portable name for the platform-native zero-copy GPU buffer --
12877            // a DMA-BUF on Linux, an IOSurface on macOS/iOS, an
12878            // AHardwareBuffer on Android, an `ID3D11Texture2D` on Windows --
12879            // and the classifier matches on that one variant for all of them,
12880            // so this needs no `cfg`. Where the platform cannot hold one the
12881            // allocation fails or falls back, and both are reported below.
12882            let mut dst = match TensorDyn::image(
12883                W,
12884                H,
12885                PixelFormat::PlanarRgb,
12886                DType::F16,
12887                Some(TensorMemory::DmaBuf),
12888                edgefirst_tensor::CpuAccess::ReadWrite,
12889            ) {
12890                Ok(t) if t.memory() == TensorMemory::DmaBuf => t,
12891                // No routable float destination on this host. Reported here,
12892                // BEFORE the `HAL_TEST_REQUIRE_GL` gate below: that gate
12893                // exists to catch a GL backend declining a destination it
12894                // should serve, not a host that cannot hold one.
12895                Ok(t) => {
12896                    crate::test_support::report_skip(&format!(
12897                        "convert_f16_gl_cpu_parity_identity - the zero-copy F16 \
12898                         destination fell back to {:?}",
12899                        t.memory()
12900                    ));
12901                    return;
12902                }
12903                Err(e) => {
12904                    crate::test_support::report_skip(&format!(
12905                        "convert_f16_gl_cpu_parity_identity - no zero-copy F16 \
12906                         destination on this host: {e}"
12907                    ));
12908                    return;
12909                }
12910            };
12911            let require_gl = std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1");
12912            let fallbacks_before = gl_proc.convert_fallback_count();
12913            match gl_proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default()) {
12914                Ok(()) => {
12915                    if gl_proc.convert_fallback_count() != fallbacks_before {
12916                        assert!(
12917                            !require_gl,
12918                            "HAL_TEST_REQUIRE_GL=1 but \
12919                             convert_f16_gl_cpu_parity_identity would have skipped \
12920                             while reporting success: the GL F16 path declined and \
12921                             the CPU served the convert"
12922                        );
12923                        crate::test_support::report_skip(
12924                            "convert_f16_gl_cpu_parity_identity - the GL F16 path \
12925                             declined and the CPU served the convert; comparing that \
12926                             against the forced-CPU result would prove nothing",
12927                        );
12928                        return;
12929                    }
12930                    dst
12931                }
12932                Err(e) => {
12933                    crate::test_support::report_skip(&format!(
12934                        "convert_f16_gl_cpu_parity_identity - GL convert failed: {e}"
12935                    ));
12936                    return;
12937                }
12938            }
12939        };
12940
12941        // CPU path.
12942        let cpu_result = {
12943            let mut cpu_proc = ImageProcessor::with_config(ImageProcessorConfig {
12944                backend: ComputeBackend::Cpu,
12945                ..Default::default()
12946            })
12947            .unwrap();
12948            let mut dst = TensorDyn::image(
12949                W,
12950                H,
12951                PixelFormat::PlanarRgb,
12952                DType::F16,
12953                Some(TensorMemory::Mem),
12954                edgefirst_tensor::CpuAccess::ReadWrite,
12955            )
12956            .unwrap();
12957            cpu_proc
12958                .convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
12959                .expect("forced-CPU Rgba→PlanarRgb F16 must not error");
12960            dst
12961        };
12962
12963        // Compare element-wise. Both sides are read through `copy_to_flat`:
12964        // the GL destination is a packed zero-copy surface whose rows can be
12965        // spaced at a driver pitch, so its mapping is not a flat run of the
12966        // logical elements.
12967        let flat_f16 = |t: &TensorDyn| -> Vec<half::f16> {
12968            let typed = t.as_typed::<half::f16>().expect("F16 image");
12969            let mut bytes = vec![0u8; typed.shape().iter().product::<usize>() * 2];
12970            typed
12971                .copy_to_flat(&mut bytes)
12972                .expect("compact any padded rows");
12973            bytes
12974                .as_chunks::<2>()
12975                .0
12976                .iter()
12977                .map(|&c| half::f16::from_le_bytes(c))
12978                .collect()
12979        };
12980        let gl_halfs = flat_f16(&gl_result);
12981        let cpu_halfs = flat_f16(&cpu_result);
12982
12983        assert_eq!(
12984            gl_halfs.len(),
12985            cpu_halfs.len(),
12986            "GL and CPU output sizes differ"
12987        );
12988
12989        let plane = W * H;
12990        let channel_names = ["R", "G", "B"];
12991        for (idx, (gl_h, cpu_h)) in gl_halfs.iter().zip(cpu_halfs.iter()).enumerate() {
12992            let gl_v = gl_h.to_f32();
12993            let cpu_v = cpu_h.to_f32();
12994            let err = (gl_v - cpu_v).abs();
12995            let ch = channel_names[idx / plane];
12996            let pixel = idx % plane;
12997            assert!(
12998                err <= TOL,
12999                "GL vs CPU mismatch at {ch}[{pixel}]: GL={gl_v}, CPU={cpu_v}, err={err} > tol={TOL}"
13000            );
13001        }
13002    }
13003
13004    // =========================================================================
13005    // GAP-1: supported_render_dtypes() Linux smoke test
13006    // =========================================================================
13007
13008    /// Exercises the real Linux/Windows GL path that reads
13009    /// `gl.supported_render_dtypes()`. Skipped when no GL backend is
13010    /// available (CI/host without a GPU).
13011    #[test]
13012    #[cfg(all(any(target_os = "linux", target_os = "windows"), feature = "opengl"))]
13013    fn supported_render_dtypes_linux_smoke() {
13014        let proc = match ImageProcessor::new() {
13015            Ok(p) => p,
13016            Err(e) => {
13017                crate::test_support::report_skip(&format!(
13018                    "supported_render_dtypes_linux_smoke — ImageProcessor::new() failed: {e}"
13019                ));
13020                return;
13021            }
13022        };
13023        if proc.opengl.is_none() {
13024            crate::test_support::report_skip(
13025                "supported_render_dtypes_linux_smoke — no GL backend on this host",
13026            );
13027            return;
13028        }
13029        // The call must complete without panicking or deadlocking.
13030        let support = proc.supported_render_dtypes();
13031        eprintln!(
13032            "supported_render_dtypes_linux_smoke: f16={} f32={}",
13033            support.f16, support.f32
13034        );
13035        // No assertion on the specific values — they are hardware-dependent.
13036    }
13037
13038    // =========================================================================
13039    // GAP-2: F16 PlanarRgb with width NOT divisible by 4 falls back to CPU
13040    // =========================================================================
13041
13042    /// The GL float render path rejects PlanarRgb F16 destinations whose width
13043    /// is not a multiple of 4 (the packed RGBA16F swizzle trick requires W%4==0).
13044    /// The auto-chain must transparently fall through to the CPU path, which has
13045    /// no such restriction.  W=18, H=16 is chosen so W%4 == 2.
13046    #[test]
13047    fn convert_f16_pbo_non_4_aligned_width_falls_back() {
13048        const W: usize = 18; // 18 % 4 == 2 — NOT divisible by 4
13049        const H: usize = 16;
13050
13051        // RGBA8 source filled with a flat mid-grey.
13052        let src = TensorDyn::image(
13053            W,
13054            H,
13055            PixelFormat::Rgba,
13056            DType::U8,
13057            Some(TensorMemory::Mem),
13058            edgefirst_tensor::CpuAccess::ReadWrite,
13059        )
13060        .unwrap();
13061        {
13062            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
13063            let data = map.as_mut_slice();
13064            for chunk in data.as_chunks_mut::<4>().0 {
13065                chunk[0] = 128;
13066                chunk[1] = 64;
13067                chunk[2] = 200;
13068                chunk[3] = 255;
13069            }
13070        }
13071
13072        // F16 PlanarRgb destination in Mem (GL would use PBO, but we want
13073        // to exercise the fallback chain without hardware dependency).
13074        let mut dst = TensorDyn::image(
13075            W,
13076            H,
13077            PixelFormat::PlanarRgb,
13078            DType::F16,
13079            Some(TensorMemory::Mem),
13080            edgefirst_tensor::CpuAccess::ReadWrite,
13081        )
13082        .unwrap();
13083
13084        // Use the default auto-chain so the GL path can attempt and reject,
13085        // then the CPU path succeeds.
13086        let mut proc = ImageProcessor::new().unwrap();
13087        let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
13088        assert!(
13089            result.is_ok(),
13090            "auto-chain PlanarRgb F16 W%4!=0 must not error (CPU fallback): {:?}",
13091            result.err()
13092        );
13093
13094        // All output values must be finite and in [0, 1].
13095        let map = dst.as_typed::<half::f16>().unwrap().map().unwrap();
13096        let halfs = map.as_slice();
13097        assert_eq!(halfs.len(), W * H * 3, "unexpected element count");
13098        for (i, h) in halfs.iter().enumerate() {
13099            let v = h.to_f32();
13100            assert!(
13101                v.is_finite() && (0.0..=1.0).contains(&v),
13102                "output[{i}]={v} is not finite or not in [0,1]"
13103            );
13104        }
13105    }
13106
13107    // =========================================================================
13108    // GAP-4: NV12 → Rgb F32 and NV12 → PlanarRgb F16, forced CPU
13109    // =========================================================================
13110
13111    /// CPU widen-composition: NV12 (non-RGBA source) → Rgb F32.
13112    ///
13113    /// NV12 requires a two-stage CPU conversion (NV12→Rgba then Rgba→F32)
13114    /// which was untested. A wrong intermediate format selection silently
13115    /// produces garbage. Y=128, U=V=128 → near-neutral grey → R≈G≈B≈0.5.
13116    #[test]
13117    // Linux-only `egl_display` field makes `..Default::default()` needless
13118    // on macOS only; see `convert_f16_forced_cpu_correct`.
13119    #[allow(clippy::needless_update)]
13120    fn convert_nv12_to_rgb_f32_cpu() {
13121        const W: usize = 16;
13122        const H: usize = 16; // must be even for NV12
13123
13124        // Build a valid NV12 tensor: shape [H*3/2, W], luma=128, chroma=128.
13125        let src = TensorDyn::image(
13126            W,
13127            H,
13128            PixelFormat::Nv12,
13129            DType::U8,
13130            Some(TensorMemory::Mem),
13131            edgefirst_tensor::CpuAccess::ReadWrite,
13132        )
13133        .unwrap();
13134        {
13135            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
13136            map.as_mut_slice().fill(128); // Y=128, U=V=128 → neutral grey
13137        }
13138
13139        let mut dst = TensorDyn::image(
13140            W,
13141            H,
13142            PixelFormat::Rgb,
13143            DType::F32,
13144            Some(TensorMemory::Mem),
13145            edgefirst_tensor::CpuAccess::ReadWrite,
13146        )
13147        .unwrap();
13148
13149        let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
13150            backend: ComputeBackend::Cpu,
13151            ..Default::default()
13152        })
13153        .unwrap();
13154
13155        let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
13156        assert!(
13157            result.is_ok(),
13158            "forced-CPU NV12→Rgb F32 must not error: {:?}",
13159            result.err()
13160        );
13161
13162        let map = dst.as_typed::<f32>().unwrap().map().unwrap();
13163        let floats = map.as_slice();
13164        assert_eq!(floats.len(), W * H * 3, "unexpected element count");
13165        for (i, &v) in floats.iter().enumerate() {
13166            assert!(
13167                v.is_finite() && (0.0..=1.0).contains(&v),
13168                "output[{i}]={v} is not finite or not in [0,1]"
13169            );
13170        }
13171        // Anti-all-zero: Y=128 → luma channel ≈ 0.5 after YUV→RGB.
13172        let non_zero = floats.iter().any(|&v| v > 0.01);
13173        assert!(non_zero, "all-zero output from NV12→Rgb F32 CPU path");
13174    }
13175
13176    /// CPU widen-composition: NV12 (non-RGBA source) → PlanarRgb F16.
13177    ///
13178    /// Same rationale as `convert_nv12_to_rgb_f32_cpu` but for F16 output.
13179    #[test]
13180    // Linux-only `egl_display` field makes `..Default::default()` needless
13181    // on macOS only; see `convert_f16_forced_cpu_correct`.
13182    #[allow(clippy::needless_update)]
13183    fn convert_nv12_to_planar_rgb_f16_cpu() {
13184        const W: usize = 16;
13185        const H: usize = 16;
13186
13187        let src = TensorDyn::image(
13188            W,
13189            H,
13190            PixelFormat::Nv12,
13191            DType::U8,
13192            Some(TensorMemory::Mem),
13193            edgefirst_tensor::CpuAccess::ReadWrite,
13194        )
13195        .unwrap();
13196        {
13197            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
13198            map.as_mut_slice().fill(128);
13199        }
13200
13201        let mut dst = TensorDyn::image(
13202            W,
13203            H,
13204            PixelFormat::PlanarRgb,
13205            DType::F16,
13206            Some(TensorMemory::Mem),
13207            edgefirst_tensor::CpuAccess::ReadWrite,
13208        )
13209        .unwrap();
13210
13211        let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
13212            backend: ComputeBackend::Cpu,
13213            ..Default::default()
13214        })
13215        .unwrap();
13216
13217        let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
13218        assert!(
13219            result.is_ok(),
13220            "forced-CPU NV12→PlanarRgb F16 must not error: {:?}",
13221            result.err()
13222        );
13223
13224        let map = dst.as_typed::<half::f16>().unwrap().map().unwrap();
13225        let halfs = map.as_slice();
13226        assert_eq!(halfs.len(), W * H * 3, "unexpected element count");
13227        for (i, h) in halfs.iter().enumerate() {
13228            let v = h.to_f32();
13229            assert!(
13230                v.is_finite() && (0.0..=1.0).contains(&v),
13231                "output[{i}]={v} is not finite or not in [0,1]"
13232            );
13233        }
13234        let non_zero = halfs.iter().any(|h| h.to_f32() > 0.01);
13235        assert!(non_zero, "all-zero output from NV12→PlanarRgb F16 CPU path");
13236    }
13237
13238    // =========================================================================
13239    // GAP-5: create_image F32 + DMA must return NotSupported
13240    // =========================================================================
13241
13242    /// `create_image_desc` without a compression request is exactly
13243    /// `create_image` (the processor's memory negotiation applies); with
13244    /// `Compression::Any` off-Android it resolves linear and counts the
13245    /// fallback in the processor-visible mirror.
13246    #[test]
13247    fn create_image_desc_negotiates_and_counts_fallbacks() {
13248        use edgefirst_tensor::{CpuAccess, ImageDesc};
13249        let proc = ImageProcessor::new().unwrap();
13250
13251        let desc =
13252            ImageDesc::new(64, 64, PixelFormat::Rgba, DType::U8).with_access(CpuAccess::ReadWrite);
13253        let plain = proc.create_image_desc(&desc).unwrap();
13254        let classic = proc
13255            .create_image(
13256                64,
13257                64,
13258                PixelFormat::Rgba,
13259                DType::U8,
13260                None,
13261                CpuAccess::ReadWrite,
13262            )
13263            .unwrap();
13264        assert_eq!(plain.memory(), classic.memory(), "same negotiation path");
13265        assert_eq!(plain.compression(), None);
13266
13267        #[cfg(not(target_os = "android"))]
13268        {
13269            use edgefirst_tensor::Compression;
13270            let before = proc.compression_fallback_count();
13271            let desc = ImageDesc::new(64, 64, PixelFormat::Rgba, DType::U8)
13272                .with_compression(Compression::Any);
13273            let t = proc.create_image_desc(&desc).unwrap();
13274            assert_eq!(t.compression(), None, "no vendor tile scheme off-Android");
13275            assert!(
13276                proc.compression_fallback_count() > before,
13277                "Any resolving linear must count"
13278            );
13279        }
13280    }
13281
13282    /// There is no DRM FourCC for F32 images, so `create_image` with
13283    /// `TensorMemory::DmaBuf` and `DType::F32` must return `Err(NotSupported)`.
13284    #[test]
13285    #[cfg(target_os = "linux")]
13286    fn create_image_f32_dma_rejected() {
13287        let proc = ImageProcessor::new().unwrap();
13288        let result = proc.create_image(
13289            64,
13290            64,
13291            PixelFormat::Rgb,
13292            DType::F32,
13293            Some(TensorMemory::DmaBuf),
13294            edgefirst_tensor::CpuAccess::ReadWrite,
13295        );
13296        assert!(
13297            result.is_err(),
13298            "create_image(F32, Dma) must fail — no DRM fourcc for f32"
13299        );
13300    }
13301
13302    /// Verify that `import_image` stores the supplied `Option<Colorimetry>` on
13303    /// the returned `TensorDyn`.
13304    ///
13305    /// `import_image` requires a DMA-backed fd on Linux.  When DMA is
13306    /// unavailable we skip the DMA call but still verify the storage contract
13307    /// by inspecting `set_colorimetry` / `colorimetry` on a plain `TensorDyn`
13308    /// constructed the same way the function body does it — and we confirm via
13309    /// code-read that the new parameter is unconditionally stored.
13310    #[test]
13311    #[cfg(target_os = "linux")]
13312    fn import_image_carries_colorimetry() {
13313        use edgefirst_tensor::{ColorEncoding, ColorRange, Colorimetry, TensorMemory};
13314
13315        let expected = Colorimetry::default()
13316            .with_encoding(ColorEncoding::Bt709)
13317            .with_range(ColorRange::Limited);
13318
13319        if !is_dma_available() {
13320            // DMA unavailable on this host: exercise the storage path via
13321            // TensorDyn directly (mirrors what import_image does internally).
13322            let mut t = TensorDyn::image(
13323                8,
13324                8,
13325                PixelFormat::Rgba,
13326                DType::U8,
13327                Some(TensorMemory::Mem),
13328                edgefirst_tensor::CpuAccess::ReadWrite,
13329            )
13330            .expect("alloc");
13331            assert_eq!(t.colorimetry(), None, "colorimetry must start as None");
13332            t.set_colorimetry(Some(expected));
13333            assert_eq!(
13334                t.colorimetry(),
13335                Some(expected),
13336                "set_colorimetry must round-trip"
13337            );
13338            crate::test_support::report_skip("import_image_carries_colorimetry (DMA unavailable); storage contract verified via TensorDyn");
13339            return;
13340        }
13341
13342        // DMA is available: allocate a real DMA tensor, extract its fd, and
13343        // call import_image with an explicit Colorimetry.
13344        use edgefirst_tensor::{PlaneDescriptor, Tensor};
13345
13346        let rgba_bytes = 64 * 64 * 4; // 64×64 RGBA8
13347        let dma_tensor = Tensor::<u8>::new(
13348            &[rgba_bytes],
13349            Some(TensorMemory::DmaBuf),
13350            Some("import_test"),
13351        )
13352        .expect("dma alloc");
13353        let pd =
13354            PlaneDescriptor::new(dma_tensor.dmabuf().expect("dma fd")).expect("PlaneDescriptor");
13355
13356        let proc = ImageProcessor::new().expect("ImageProcessor");
13357        let result = proc.import_image(
13358            pd,
13359            None,
13360            64,
13361            64,
13362            PixelFormat::Rgba,
13363            DType::U8,
13364            Some(expected),
13365        );
13366        let tensor = result.expect("import_image must succeed on DMA fd");
13367        assert_eq!(
13368            tensor.colorimetry(),
13369            Some(expected),
13370            "import_image must store the supplied colorimetry on the returned TensorDyn"
13371        );
13372    }
13373}