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;
413mod tiling;
414pub use tiling::{tile_grid, TilePlacement, TileSpec, TilingConfig};
415
416// Use `edgefirst_tensor::PixelFormat` variants (Rgb, Rgba, Grey, etc.) and
417// `TensorDyn` / `Tensor<u8>` with `.format()` metadata instead.
418
419#[derive(Debug, Clone, Copy, PartialEq, Eq)]
420pub enum Rotation {
421    None = 0,
422    Clockwise90 = 1,
423    Rotate180 = 2,
424    CounterClockwise90 = 3,
425}
426impl Rotation {
427    /// Creates a Rotation enum from an angle in degrees. The angle must be a
428    /// multiple of 90.
429    ///
430    /// # Panics
431    /// Panics if the angle is not a multiple of 90.
432    ///
433    /// # Examples
434    /// ```rust
435    /// # use edgefirst_image::Rotation;
436    /// let rotation = Rotation::from_degrees_clockwise(270);
437    /// assert_eq!(rotation, Rotation::CounterClockwise90);
438    /// ```
439    pub fn from_degrees_clockwise(angle: usize) -> Rotation {
440        match angle.rem_euclid(360) {
441            0 => Rotation::None,
442            90 => Rotation::Clockwise90,
443            180 => Rotation::Rotate180,
444            270 => Rotation::CounterClockwise90,
445            _ => panic!("rotation angle is not a multiple of 90"),
446        }
447    }
448}
449
450#[derive(Debug, Clone, Copy, PartialEq, Eq)]
451pub enum Flip {
452    None = 0,
453    Vertical = 1,
454    Horizontal = 2,
455}
456
457/// Controls how the color palette index is chosen for each detected object.
458#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
459pub enum ColorMode {
460    /// Color is chosen by object class label (`det.label`). Default.
461    ///
462    /// Preserves backward compatibility and is correct for semantic
463    /// segmentation where colors carry class meaning.
464    #[default]
465    Class,
466    /// Color is chosen by instance order (loop index, zero-based).
467    ///
468    /// Each detected object gets a unique color regardless of class,
469    /// useful for instance segmentation.
470    Instance,
471    /// Color is chosen by track ID (future use; currently behaves like
472    /// [`Instance`](Self::Instance)).
473    Track,
474}
475
476impl ColorMode {
477    /// Return the palette index for a detection given its loop index and label.
478    #[inline]
479    pub fn index(self, idx: usize, label: usize) -> usize {
480        match self {
481            ColorMode::Class => label,
482            ColorMode::Instance | ColorMode::Track => idx,
483        }
484    }
485}
486
487/// Controls the resolution and coordinate frame of masks produced by
488/// [`ImageProcessor::materialize_masks`].
489///
490/// - [`Proto`](Self::Proto) returns per-detection tiles at proto-plane
491///   resolution (e.g. 48×32 u8 for a typical COCO bbox on a 160×160 proto
492///   plane). This is the historical behavior of `materialize_masks` and the
493///   fastest path because no upsample runs inside HAL. Mask values are
494///   continuous sigmoid output quantized to `uint8 [0, 255]`.
495/// - [`Scaled`](Self::Scaled) returns per-detection tiles at caller-specified
496///   pixel resolution by upsampling the full proto plane once and cropping by
497///   bbox after sigmoid. The upsample uses bilinear interpolation with
498///   edge-clamp sampling — semantically equivalent to Ultralytics'
499///   `process_masks_retina` reference. When a `letterbox` is also passed to
500///   [`materialize_masks`], the inverse letterbox transform is applied during
501///   the upsample so mask pixels land in original-content coordinates
502///   (drop-in for overlay on the original image). Mask values are binary
503///   `uint8 {0, 255}` after thresholding sigmoid > 0.5 — interchangeable
504///   with `Proto` output via the same `> 127` test.
505///
506/// [`materialize_masks`]: ImageProcessor::materialize_masks
507#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
508pub enum MaskResolution {
509    /// Per-detection tile at proto-plane resolution (default).
510    #[default]
511    Proto,
512    /// Per-detection tile at `(width, height)` pixel resolution in the
513    /// coordinate frame determined by the `letterbox` parameter of
514    /// [`ImageProcessor::materialize_masks`].
515    Scaled {
516        /// Target pixel width of the output coordinate frame.
517        width: u32,
518        /// Target pixel height of the output coordinate frame.
519        height: u32,
520    },
521}
522
523/// Options for mask overlay rendering.
524///
525/// Controls how segmentation masks are composited onto the destination image:
526/// - `background`: when set, the background image is drawn first and masks
527///   are composited over it (result written to `dst`). When `None`, `dst` is
528///   cleared to `0x00000000` (fully transparent) before masks are drawn.
529///   **`dst` is always fully overwritten — its prior contents are never
530///   preserved.** Callers who used to pre-load an image into `dst` before
531///   calling `draw_decoded_masks` / `draw_proto_masks` must now supply that
532///   image via `background` instead (behaviour changed in v0.16.4).
533/// - `opacity`: scales the alpha of rendered mask colors. `1.0` (default)
534///   preserves the class color's alpha unchanged; `0.5` makes masks
535///   semi-transparent.
536/// - `color_mode`: controls whether colors are assigned by class label,
537///   instance index, or track ID. Defaults to [`ColorMode::Class`].
538#[derive(Debug, Clone, Copy)]
539pub struct MaskOverlay<'a> {
540    /// Compositing source image. Must have the same dimensions and pixel
541    /// format as `dst`. When `Some`, the output is `background + masks`.
542    /// When `None`, `dst` is cleared to `0x00000000` before masks are drawn.
543    pub background: Option<&'a TensorDyn>,
544    pub opacity: f32,
545    /// Normalized letterbox region `[xmin, ymin, xmax, ymax]` in model-input
546    /// space that contains actual image content (the rest is padding).
547    ///
548    /// When set, bounding boxes and mask coordinates from the decoder (which
549    /// are in model-input normalized space) are mapped back to the original
550    /// image coordinate space before rendering.
551    ///
552    /// Use [`with_letterbox_crop`](Self::with_letterbox_crop) to compute this
553    /// from the [`Crop`] that was used in the model input [`convert`](crate::ImageProcessorTrait::convert) call.
554    pub letterbox: Option<[f32; 4]>,
555    pub color_mode: ColorMode,
556}
557
558impl Default for MaskOverlay<'_> {
559    fn default() -> Self {
560        Self {
561            background: None,
562            opacity: 1.0,
563            letterbox: None,
564            color_mode: ColorMode::Class,
565        }
566    }
567}
568
569impl<'a> MaskOverlay<'a> {
570    pub fn new() -> Self {
571        Self::default()
572    }
573
574    /// Set the compositing source image.
575    ///
576    /// `bg` must have the same dimensions and pixel format as the `dst` passed
577    /// to [`draw_decoded_masks`](crate::ImageProcessorTrait::draw_decoded_masks) /
578    /// [`draw_proto_masks`](crate::ImageProcessorTrait::draw_proto_masks).
579    /// The output will be `bg + masks`. Without a background, `dst` is cleared
580    /// to `0x00000000`.
581    pub fn with_background(mut self, bg: &'a TensorDyn) -> Self {
582        self.background = Some(bg);
583        self
584    }
585
586    pub fn with_opacity(mut self, opacity: f32) -> Self {
587        self.opacity = opacity.clamp(0.0, 1.0);
588        self
589    }
590
591    pub fn with_color_mode(mut self, mode: ColorMode) -> Self {
592        self.color_mode = mode;
593        self
594    }
595
596    /// Set the letterbox transform from the [`Crop`] used when preparing the
597    /// model input, so that bounding boxes and masks are correctly mapped back
598    /// to the original image coordinate space during rendering.
599    ///
600    /// Pass the same `crop` that was given to
601    /// [`convert`](crate::ImageProcessorTrait::convert) along with the model
602    /// input dimensions (`model_w` × `model_h`).
603    ///
604    /// Has no effect when `crop.dst_rect` is `None` (no letterbox applied).
605    pub fn with_letterbox_crop(
606        mut self,
607        crop: &Crop,
608        src_w: usize,
609        src_h: usize,
610        model_w: usize,
611        model_h: usize,
612    ) -> Self {
613        // The letterbox placement is resolved from the same source/destination
614        // dimensions `convert()` used, so the inverse map matches the render.
615        if let Ok(resolved) = crop.resolve(src_w, src_h, model_w, model_h) {
616            if let Some(r) = resolved.dst_rect {
617                self.letterbox = Some([
618                    r.left as f32 / model_w as f32,
619                    r.top as f32 / model_h as f32,
620                    (r.left + r.width) as f32 / model_w as f32,
621                    (r.top + r.height) as f32 / model_h as f32,
622                ]);
623            }
624        }
625        self
626    }
627}
628
629/// Copy a [`Segmentation`], replacing its bounds with `bbox`.
630///
631/// `TensorDyn` is deliberately not `Clone` (it may own an fd), so the mask
632/// bytes are copied explicitly. This is only reached on the letterbox path
633/// where the bounds must be rewritten to match an un-letterboxed detect box.
634fn copy_segmentation(s: &Segmentation, bbox: [f32; 4]) -> Result<Segmentation> {
635    use edgefirst_tensor::TensorMapTrait as _;
636    let src = s
637        .segmentation
638        .as_typed::<u8>()
639        .ok_or_else(|| Error::InvalidShape("segmentation must be a U8 tensor".into()))?;
640    let map = src.map_read()?;
641    // `Some(Mem)`, NOT `None`. `None` means auto-select, which prefers DMA on
642    // Linux and IOSurface on macOS -- so this would take a dma_heap ioctl and
643    // hold an fd PER DETECTION PER FRAME for a mask of a few hundred bytes,
644    // and turn every downstream map_read() in the render loop into a
645    // DMA_BUF_IOCTL_SYNC instead of a no-op. A mask copy is CPU-only scratch.
646    // `mask_to_tensor` reaches Mem the same way, via from_arrayview3.
647    let dst = edgefirst_tensor::Tensor::<u8>::new(
648        s.segmentation.shape(),
649        Some(edgefirst_tensor::TensorMemory::Mem),
650        None,
651    )
652    .map_err(|e| Error::InvalidShape(format!("segmentation copy: {e}")))?;
653    {
654        let mut dmap = dst.map_mut()?;
655        dmap.as_mut_slice().copy_from_slice(map.as_slice());
656    }
657    Ok(Segmentation {
658        xmin: bbox[0],
659        ymin: bbox[1],
660        xmax: bbox[2],
661        ymax: bbox[3],
662        segmentation: dst.into(),
663    })
664}
665
666/// Apply the inverse letterbox transform to a bounding box.
667///
668/// `lb` is `[lx0, ly0, lx1, ly1]` — the normalized region of the model input
669/// that contains actual image content (output of
670/// [`MaskOverlay::with_letterbox_crop`]). Converts model-input-normalized
671/// coords to output-image-normalized coords, clamped to `[0.0, 1.0]`, and
672/// canonicalises the bbox (ensures xmin <= xmax).
673///
674/// Thin wrapper over [`edgefirst_tensor::unletter_norm`]: the single home for
675/// the inverse-letterbox math sits beside the type it transforms, so this mask
676/// path and `edgefirst_decoder::tiling`'s detection lift share one
677/// implementation.
678#[inline]
679fn unletter_bbox(bbox: DetectBox, lb: [f32; 4]) -> DetectBox {
680    DetectBox {
681        bbox: edgefirst_tensor::unletter_norm(bbox.bbox, lb),
682        ..bbox
683    }
684}
685
686/// How a source is fit into the requested destination shape.
687#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
688pub enum Fit {
689    /// Stretch the source (or source crop) to fill the whole destination.
690    #[default]
691    Stretch,
692    /// Preserve the *source* aspect ratio, centring it in the destination and
693    /// padding the remainder with `pad` (RGBA — e.g. `[114, 114, 114, 255]` for
694    /// YOLO-style preprocessing).
695    Letterbox { pad: [u8; 4] },
696}
697
698/// Source-side convert geometry: which sub-rectangle of the source to sample
699/// (`source`) and how to fit it into the destination (`fit`). Destination
700/// *placement* is the destination itself — a tensor, or a [`Region`] view /
701/// `batch` tile of one — not a field here.
702#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
703pub struct Crop {
704    /// Sub-rectangle of the source to sample. `None` samples the whole source.
705    pub source: Option<Region>,
706    /// How the source is fit into the destination shape.
707    pub fit: Fit,
708}
709
710impl Crop {
711    /// A no-op crop: whole source, stretched to fill the whole destination.
712    pub fn new() -> Self {
713        Self::default()
714    }
715
716    /// Alias for [`Crop::new`] — whole source, stretch to fill.
717    pub fn no_crop() -> Self {
718        Self::default()
719    }
720
721    /// Letterbox fit: preserve the source aspect ratio, padding the remainder
722    /// with `pad` (RGBA).
723    pub fn letterbox(pad: [u8; 4]) -> Self {
724        Self {
725            source: None,
726            fit: Fit::Letterbox { pad },
727        }
728    }
729
730    /// Sample only `source` of the input (builder).
731    pub fn with_source(mut self, source: Option<Region>) -> Self {
732        self.source = source;
733        self
734    }
735
736    /// Set the fit mode (builder).
737    pub fn with_fit(mut self, fit: Fit) -> Self {
738        self.fit = fit;
739        self
740    }
741
742    /// Resolve to the effective backend geometry for a `src_w`×`src_h` source
743    /// and `dst_w`×`dst_h` destination: the source sampling rect, the
744    /// destination placement rect (`None` = whole destination), and the pad
745    /// colour. **The single place letterbox placement is computed** — every
746    /// backend consumes the resolved rects rather than re-deriving them.
747    pub(crate) fn resolve(
748        &self,
749        src_w: usize,
750        src_h: usize,
751        dst_w: usize,
752        dst_h: usize,
753    ) -> Result<ResolvedCrop, Error> {
754        let src_rect = self.source.map(region_to_rect);
755        // The letterbox aspect uses the *effective* source content — the source
756        // crop when set, else the full source.
757        let (sw, sh) = match self.source {
758            Some(r) => (r.width, r.height),
759            None => (src_w, src_h),
760        };
761        let resolved = match self.fit {
762            Fit::Stretch => ResolvedCrop {
763                src_rect,
764                dst_rect: None,
765                dst_color: None,
766            },
767            Fit::Letterbox { pad } => ResolvedCrop {
768                src_rect,
769                dst_rect: Some(letterbox_rect(sw, sh, dst_w, dst_h)),
770                dst_color: Some(pad),
771            },
772        };
773        resolved.check_crop_dims(src_w, src_h, dst_w, dst_h)?;
774        Ok(resolved)
775    }
776
777    /// Validate against `TensorDyn` source and destination dimensions.
778    pub fn check_crop_dyn(
779        &self,
780        src: &edgefirst_tensor::TensorDyn,
781        dst: &edgefirst_tensor::TensorDyn,
782    ) -> Result<(), Error> {
783        self.resolve(
784            src.width().unwrap_or(0),
785            src.height().unwrap_or(0),
786            dst.width().unwrap_or(0),
787            dst.height().unwrap_or(0),
788        )
789        .map(|_| ())
790    }
791}
792
793/// Resolved crop geometry consumed by the backends. Produced by
794/// [`Crop::resolve`]; the backends read these fields directly (the same shape
795/// the public `Crop` carried before destination placement moved to the view).
796#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
797pub(crate) struct ResolvedCrop {
798    pub(crate) src_rect: Option<Rect>,
799    pub(crate) dst_rect: Option<Rect>,
800    pub(crate) dst_color: Option<[u8; 4]>,
801}
802
803impl ResolvedCrop {
804    /// A no-op resolved crop (whole source → whole destination, no pad).
805    #[allow(dead_code)] // used by unit tests and the batch render paths
806    pub(crate) fn no_crop() -> Self {
807        Self::default()
808    }
809
810    /// Validate the resolved rects against explicit dimensions.
811    pub(crate) fn check_crop_dims(
812        &self,
813        src_w: usize,
814        src_h: usize,
815        dst_w: usize,
816        dst_h: usize,
817    ) -> Result<(), Error> {
818        let src_ok = self
819            .src_rect
820            .is_none_or(|r| r.left + r.width <= src_w && r.top + r.height <= src_h);
821        let dst_ok = self
822            .dst_rect
823            .is_none_or(|r| r.left + r.width <= dst_w && r.top + r.height <= dst_h);
824        match (src_ok, dst_ok) {
825            (true, true) => Ok(()),
826            (true, false) => Err(Error::CropInvalid(format!(
827                "Dest crop invalid: {:?}",
828                self.dst_rect
829            ))),
830            (false, true) => Err(Error::CropInvalid(format!(
831                "Src crop invalid: {:?}",
832                self.src_rect
833            ))),
834            (false, false) => Err(Error::CropInvalid(format!(
835                "Dest and Src crop invalid: {:?} {:?}",
836                self.dst_rect, self.src_rect
837            ))),
838        }
839    }
840}
841
842/// Convert a pixel [`Region`] to the internal [`Rect`] placement type.
843fn region_to_rect(r: Region) -> Rect {
844    Rect {
845        left: r.x,
846        top: r.y,
847        width: r.width,
848        height: r.height,
849    }
850}
851
852/// Centred aspect-preserving placement of an `sw`×`sh` source within a `dw`×`dh`
853/// destination — the canonical letterbox rectangle (one home, replacing the
854/// per-caller `calculate_letterbox` copies).
855fn letterbox_rect(sw: usize, sh: usize, dw: usize, dh: usize) -> Rect {
856    if sw == 0 || sh == 0 {
857        return Rect::new(0, 0, dw, dh);
858    }
859    let src_aspect = sw as f64 / sh as f64;
860    let dst_aspect = dw as f64 / dh as f64;
861    let (new_w, new_h) = if src_aspect > dst_aspect {
862        (dw, ((dw as f64 / src_aspect).round() as usize).max(1))
863    } else {
864        (((dh as f64 * src_aspect).round() as usize).max(1), dh)
865    };
866    let left = dw.saturating_sub(new_w) / 2;
867    let top = dh.saturating_sub(new_h) / 2;
868    Rect::new(left, top, new_w, new_h)
869}
870
871/// Internal placement rectangle (top-left + size). The **public** pixel
872/// sub-region type is [`Region`] (re-exported from `edgefirst-tensor`);
873/// `Rect` is the crate-private resolved-placement representation used by the
874/// CPU/G2D/GL backends after [`Crop::resolve`].
875#[derive(Debug, Clone, Copy, PartialEq, Eq)]
876pub(crate) struct Rect {
877    pub left: usize,
878    pub top: usize,
879    pub width: usize,
880    pub height: usize,
881}
882
883impl Rect {
884    // Creates a new Rect with the specified left, top, width, and height.
885    pub fn new(left: usize, top: usize, width: usize, height: usize) -> Self {
886        Self {
887            left,
888            top,
889            width,
890            height,
891        }
892    }
893}
894
895#[enum_dispatch(ImageProcessor)]
896pub trait ImageProcessorTrait {
897    /// Convert `src` into `dst`'s format and size, applying the crop first,
898    /// then the flip, then the rotation.
899    ///
900    /// The destination is normally an RGB-family colour — `Grey`, `Rgb`,
901    /// `Rgba`/`Bgra`, packed `HWC` or planar `CHW`. That is what the
902    /// accelerated paths target and what models expect. The CPU backend also
903    /// accepts a handful of YUV destinations (`Yuyv`→`Yuyv`, `Yuyv`→`Nv16`,
904    /// and the `Vyuy` equivalents); no GPU or G2D path does, so those pairs
905    /// always land on the CPU.
906    ///
907    /// # Arguments
908    ///
909    /// * `src` - The source image to convert from.
910    /// * `dst` - The destination image. Its shape and [`PixelFormat`] define
911    ///   the output; pass a [`view`](edgefirst_tensor::TensorDyn::view) or
912    ///   [`batch`](edgefirst_tensor::TensorDyn::batch) of a larger tensor to
913    ///   render into one tile of a batch.
914    /// * `rotation` - Rotation applied to the output, in 90° steps.
915    /// * `flip` - Horizontal, vertical, or both.
916    /// * `crop` - Source-side geometry: which sub-rectangle of `src` to sample
917    ///   ([`Crop::source`]) and how to fit it into `dst`'s shape
918    ///   ([`Crop::fit`] — stretch, or letterbox with a pad colour). Destination
919    ///   placement lives on `dst`, not here.
920    ///
921    /// # Errors
922    ///
923    /// - [`Error::CropInvalid`] if `crop.source` falls outside `src`.
924    /// - [`Error::NotAnImage`] if either tensor carries no `PixelFormat`.
925    /// - [`Error::NoConverter`] if no backend accepts the format pair — the
926    ///   dispatch tries OpenGL, then G2D, then CPU, so this means all three
927    ///   declined.
928    ///
929    /// Backend-specific failures (EGL import, G2D blit, allocation) surface as
930    /// their own variants. A backend *declining* a pair is not an error while
931    /// another remains in the chain; it silently costs the zero-copy path, and
932    /// [`ImageProcessor::convert_fallback_count`] counts those.
933    fn convert(
934        &mut self,
935        src: &TensorDyn,
936        dst: &mut TensorDyn,
937        rotation: Rotation,
938        flip: Flip,
939        crop: Crop,
940    ) -> Result<()>;
941
942    /// Draw pre-decoded detection boxes and segmentation masks onto `dst`.
943    ///
944    /// Supports two segmentation modes based on the mask channel count:
945    /// - **Instance segmentation** (`C=1`): one `Segmentation` per detection,
946    ///   `segmentation` and `detect` are zipped.
947    /// - **Semantic segmentation** (`C>1`): a single `Segmentation` covering
948    ///   all classes; only the first element is used.
949    ///
950    /// # Format requirements
951    ///
952    /// - CPU backend: `dst` must be `RGBA` or `RGB`.
953    /// - OpenGL backend: `dst` must be `RGBA`, `BGRA`, or `RGB`.
954    /// - G2D backend: only produces the base frame (empty detections);
955    ///   returns `NotImplemented` when any detection or segmentation is
956    ///   supplied.
957    ///
958    /// # Output contract
959    ///
960    /// This function always fully writes `dst` — it never relies on the
961    /// caller having pre-cleared the destination. The four cases are:
962    ///
963    /// | detections | background | output                              |
964    /// |------------|------------|-------------------------------------|
965    /// | none       | none       | dst cleared to `0x00000000`         |
966    /// | none       | set        | dst ← background                    |
967    /// | set        | none       | masks drawn over cleared dst        |
968    /// | set        | set        | masks drawn over background         |
969    ///
970    /// Each backend implements this with its native primitives: G2D uses
971    /// `g2d_clear` / `g2d_blit`, OpenGL uses `glClear` / DMA-BUF GPU blit
972    /// plus the mask program, and CPU uses direct buffer fill / memcpy as
973    /// the terminal fallback. CPU-memcpy of DMA buffers is avoided on the
974    /// accelerated paths.
975    ///
976    /// An empty `segmentation` slice is valid — only bounding boxes are drawn.
977    ///
978    /// `overlay` controls compositing: `background` is the compositing source
979    /// (must match `dst` in size and format); `opacity` scales mask alpha.
980    ///
981    /// A `Bgra` background onto a zero-copy destination (a D3D11 texture, an
982    /// IOSurface, a dma-buf) returns [`Error::NotSupported`]: the GL
983    /// base-layer draw has no `Bgra` arm and the CPU backend renders only
984    /// `Rgba`/`Rgb`. It previously returned `Ok` with `dst` unwritten. A
985    /// `Bgra` destination with no background is unaffected.
986    ///
987    /// # Buffer aliasing
988    ///
989    /// `dst` and `overlay.background` must reference **distinct underlying
990    /// buffers**. An aliased pair returns [`Error::AliasedBuffers`] without
991    /// dispatching to any backend — the GL path would otherwise read and
992    /// write the same texture in a single draw, which is undefined behaviour
993    /// on most drivers. Aliasing is detected via
994    /// [`TensorDyn::aliases`](edgefirst_tensor::TensorDyn::aliases), which
995    /// catches both shared-allocation clones and separate imports over the
996    /// same dmabuf fd.
997    ///
998    /// # Migration from v0.16.3 and earlier
999    ///
1000    /// Prior to v0.16.4 the call silently preserved `dst`'s contents on empty
1001    /// detections. That invariant no longer holds — `dst` is always fully
1002    /// written. Callers who pre-loaded an image into `dst` before calling this
1003    /// function must now pass that image via `overlay.background` instead.
1004    fn draw_decoded_masks(
1005        &mut self,
1006        dst: &mut TensorDyn,
1007        detect: &[DetectBox],
1008        segmentation: &[Segmentation],
1009        overlay: MaskOverlay<'_>,
1010    ) -> Result<()>;
1011
1012    /// Draw masks from proto data onto image (fused decode+draw).
1013    ///
1014    /// For YOLO segmentation models, this avoids materializing intermediate
1015    /// `Array3<u8>` masks. The `ProtoData` contains mask coefficients and the
1016    /// prototype tensor; the renderer computes `mask_coeff @ protos` directly
1017    /// at the output resolution using bilinear sampling.
1018    ///
1019    /// `detect` and `proto_data.mask_coefficients` must have the same length
1020    /// (enforced by zip — excess entries are silently ignored). An empty
1021    /// `detect` slice is valid and produces the base frame — cleared or
1022    /// background-blitted — via the selected backend's native primitive.
1023    ///
1024    /// # Format requirements and output contract
1025    ///
1026    /// Same as [`draw_decoded_masks`](Self::draw_decoded_masks), including
1027    /// the "always fully writes dst" guarantee across all four
1028    /// detection/background combinations.
1029    ///
1030    /// `overlay` controls compositing — see [`draw_decoded_masks`](Self::draw_decoded_masks).
1031    fn draw_proto_masks(
1032        &mut self,
1033        dst: &mut TensorDyn,
1034        detect: &[DetectBox],
1035        proto_data: &ProtoData,
1036        overlay: MaskOverlay<'_>,
1037    ) -> Result<()>;
1038
1039    /// Sets the colors used for rendering segmentation masks. Up to 20 colors
1040    /// can be set.
1041    ///
1042    /// On [`ImageProcessor`] with no forced backend this reaches every
1043    /// available backend rather than the first that accepts, because a
1044    /// later draw may land on any of them. A forced backend receives the
1045    /// colors alone.
1046    fn set_class_colors(&mut self, colors: &[[u8; 4]]) -> Result<()>;
1047
1048    /// Like [`convert`](Self::convert), but does not wait for the GPU to finish.
1049    ///
1050    /// This is the batch-preprocessing primitive: a caller renders `N` tiles
1051    /// into one batched destination by looping
1052    /// `convert_deferred(&src[n], &mut dst.batch(n)?, …)` and then calling
1053    /// [`flush`](Self::flush) **once**. On the OpenGL backend every deferred
1054    /// convert into sibling views of one buffer shares a single EGLImage import
1055    /// (the tile is a `glViewport`/`glScissor` ROI into the parent) and skips the
1056    /// per-tile `glFinish`; `flush` then issues a single GPU sync. The result of
1057    /// a deferred convert is **not** safe to read on the CPU (or map via CUDA)
1058    /// until `flush` returns.
1059    ///
1060    /// The default implementation is eager — it simply calls
1061    /// [`convert`](Self::convert), so CPU/G2D and any backend without a deferred
1062    /// fast path remain correct (each call completes synchronously and `flush`
1063    /// is a no-op).
1064    fn convert_deferred(
1065        &mut self,
1066        src: &TensorDyn,
1067        dst: &mut TensorDyn,
1068        rotation: Rotation,
1069        flip: Flip,
1070        crop: Crop,
1071    ) -> Result<()> {
1072        self.convert(src, dst, rotation, flip, crop)
1073    }
1074
1075    /// Complete all work enqueued by [`convert_deferred`](Self::convert_deferred)
1076    /// since the last flush, issuing a single GPU synchronization.
1077    ///
1078    /// After this returns, every deferred destination is finished and safe to
1079    /// read back or `cuda_map`. Backends with no deferred path (the default)
1080    /// return `Ok(())` immediately, since their converts already completed.
1081    fn flush(&mut self) -> Result<()> {
1082        Ok(())
1083    }
1084}
1085
1086/// Configuration for [`ImageProcessor`] construction.
1087///
1088/// Use with [`ImageProcessor::with_config`] to override the default EGL
1089/// display auto-detection and backend selection. The default configuration
1090/// preserves the existing auto-detection behaviour.
1091#[derive(Debug, Clone, Default)]
1092pub struct ImageProcessorConfig {
1093    /// Force OpenGL to use this EGL display type instead of auto-detecting.
1094    ///
1095    /// When `None`, the processor probes displays in priority order: GBM,
1096    /// PlatformDevice, Default. Use [`probe_egl_displays`] to discover
1097    /// which displays are available on the current system.
1098    ///
1099    /// Ignored when `EDGEFIRST_DISABLE_GL=1` is set, and on macOS and
1100    /// Windows (ANGLE is the only display there — Metal and Direct3D 11
1101    /// respectively; a `Some` value logs a debug note and is otherwise
1102    /// ignored).
1103    #[cfg(all(
1104        any(
1105            target_os = "linux",
1106            target_os = "macos",
1107            target_os = "ios",
1108            target_os = "android",
1109            target_os = "windows"
1110        ),
1111        feature = "opengl"
1112    ))]
1113    pub egl_display: Option<EglDisplayKind>,
1114
1115    /// Preferred compute backend.
1116    ///
1117    /// When set to a specific backend (not [`ComputeBackend::Auto`]), the
1118    /// processor initializes that backend with no fallback — returns an error if the conversion is not supported.
1119    /// This takes precedence over `EDGEFIRST_FORCE_BACKEND` and the
1120    /// `EDGEFIRST_DISABLE_*` environment variables.
1121    ///
1122    /// - [`ComputeBackend::OpenGl`]: init OpenGL + CPU, skip G2D
1123    /// - [`ComputeBackend::G2d`]: init G2D + CPU, skip OpenGL
1124    /// - [`ComputeBackend::Cpu`]: init CPU only
1125    /// - [`ComputeBackend::Auto`]: existing env-var-driven selection
1126    pub backend: ComputeBackend,
1127
1128    /// Colorimetry/performance trade-off for `convert()` (see
1129    /// [`ColorimetryMode`]). Defaults to [`ColorimetryMode::Fast`]. The
1130    /// `EDGEFIRST_COLORIMETRY` environment variable (`fast` | `exact`)
1131    /// overrides this setting when present.
1132    pub colorimetry: ColorimetryMode,
1133
1134    /// How many buffer imports the OpenGL backend keeps cached, per cache.
1135    ///
1136    /// `None` (the default) uses `EDGEFIRST_EGL_CACHE_CAPACITY` if set, and
1137    /// 16 otherwise. Precedence is:
1138    ///
1139    /// ```text
1140    /// egl_cache_capacity  >  EDGEFIRST_EGL_CACHE_CAPACITY  >  16
1141    /// ```
1142    ///
1143    /// Config beats the environment deliberately, matching
1144    /// [`backend`](Self::backend): an embedder that has measured its own
1145    /// pool must not be silently overridden by a stray environment variable
1146    /// in the deployment. `Some(0)` is rejected and falls through to the
1147    /// environment, then the default, exactly as an unparseable env value
1148    /// does.
1149    ///
1150    /// # This is a buffer *count*, not a size
1151    ///
1152    /// A cached import pins its buffer — the underlying dma-buf or IOSurface
1153    /// cannot be freed while an entry names it (that is what makes the cache
1154    /// correct as well as fast; see the "EGL image cache inside HAL" section
1155    /// of `ARCHITECTURE.md`). So the memory this holds depends entirely on
1156    /// how big the caller's buffers are:
1157    ///
1158    /// ```text
1159    /// worst-case pinned bytes                                (upper bound)
1160    ///     = capacity x 3 caches x frame size x live ImageProcessors
1161    ///
1162    /// capacity 16, 640x640 RGB   (1.2 MB)  ->   59 MB per processor
1163    /// capacity 16, 1080p NV12    (3.1 MB)  ->  149 MB per processor
1164    /// capacity 16, 4K NV12      (12.4 MB)  ->  597 MB per processor
1165    /// ```
1166    ///
1167    /// A ceiling rather than an exact total: the source and NV R8 caches
1168    /// often hold two imports of the *same* buffer, and two entries naming
1169    /// one buffer pin it once. The per-cache figure is the exact one.
1170    ///
1171    /// The bound is **per [`ImageProcessor`], times three caches** (source,
1172    /// destination, and the NV R8 source cache) — not per process and not
1173    /// per library. Two `ImageProcessor`s in one process have six
1174    /// independent caches and six independent capacities.
1175    ///
1176    /// Whether the worst case is ever approached is up to the caller. A
1177    /// pooled producer — V4L2 capture, typically 4–8 buffers over one or two
1178    /// sizes — settles at its pool size and never nears the bound. A stream
1179    /// that allocates a fresh buffer per frame holds the last `capacity` of
1180    /// them outright, which is the difference between "16 is free" and "16
1181    /// is 597 MB".
1182    ///
1183    /// Size it from measurement rather than guesswork:
1184    /// [`CacheStats::peak_entries`] reports the working set actually
1185    /// reached, and [`CacheStats::evictions`] tells you when the bound is
1186    /// costing you re-imports (it should be zero in a steady state).
1187    ///
1188    /// Ignored when the OpenGL backend is not in use.
1189    pub egl_cache_capacity: Option<usize>,
1190}
1191
1192/// How `convert()` trades colorimetric exactness against speed on platforms
1193/// where the exact path is expensive.
1194///
1195/// Today this affects one decision: NV12 sources on Vivante GC7000UL
1196/// (i.MX 8M Plus), where the hardware external sampler converts ~12× faster
1197/// than the colorimetry-exact in-shader matrix (2.5 ms vs 29 ms at 720p)
1198/// but applies the driver's fixed BT.601-limited matrix regardless of the
1199/// source's tagged colorimetry. Platforms where the exact path is already
1200/// the fastest correct path (Mali, V3D, Tegra, ANGLE) behave identically in
1201/// both modes.
1202///
1203/// Override at runtime with `EDGEFIRST_COLORIMETRY=fast|exact` (takes
1204/// precedence over the config field), or per-source by forcing a path with
1205/// `EDGEFIRST_NV_CONVERT_PATH`.
1206#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1207pub enum ColorimetryMode {
1208    /// Prefer the fastest path whose output is correct-enough video RGB
1209    /// (default; issue #106 policy). On Vivante, NV12 takes the hardware
1210    /// sampler even when the source is not BT.601-limited.
1211    #[default]
1212    Fast,
1213    /// Prefer bit-exact colorimetry everywhere: the fast path is used only
1214    /// when it matches the source's resolved (encoding, range) exactly.
1215    Exact,
1216}
1217
1218/// Compute backend selection for [`ImageProcessor`].
1219///
1220/// Use with [`ImageProcessorConfig::backend`] to select which backend the
1221/// processor should prefer. When a specific backend is selected, the
1222/// processor initializes that backend plus CPU as a fallback. When `Auto`
1223/// is used, the existing environment-variable-driven selection applies.
1224#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1225pub enum ComputeBackend {
1226    /// Auto-detect based on available hardware and environment variables.
1227    #[default]
1228    Auto,
1229    /// CPU-only processing (no hardware acceleration).
1230    Cpu,
1231    /// Prefer G2D hardware blitter (+ CPU fallback).
1232    G2d,
1233    /// Prefer OpenGL ES (+ CPU fallback).
1234    OpenGl,
1235}
1236
1237/// Backend forced via the `EDGEFIRST_FORCE_BACKEND` environment variable
1238/// or [`ImageProcessorConfig::backend`].
1239///
1240/// When set, the [`ImageProcessor`] only initializes and dispatches to the
1241/// selected backend — no fallback chain is used.
1242#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1243pub(crate) enum ForcedBackend {
1244    Cpu,
1245    G2d,
1246    OpenGl,
1247}
1248
1249/// Reports which float-color-buffer extensions the GPU backend detected.
1250/// Returned by [`ImageProcessor::supported_render_dtypes`]; the two flags
1251/// are independent.
1252///
1253/// **Linux:** reflects the real probe results from `GL_EXT_color_buffer_half_float`
1254/// and `GL_EXT_color_buffer_float`. On V3D (RPi 5) and Mali-G310 (i.MX 95)
1255/// both flags are typically `true`; on Vivante GC7000UL both are forced
1256/// `false` (float readback measured 170–320 ms — disabled). Tegra Orin
1257/// exposes both via PBO; the flags match the GPU report.
1258///
1259/// **macOS (ANGLE):** `f16 == true` gates the RGBA16F-packed IOSurface
1260/// path for F16 `PlanarRgb` destinations. `f32` reflects the GL
1261/// extension probe but is not actionable — ANGLE's
1262/// `EGL_ANGLE_iosurface_client_buffer` rejects every `(GL_FLOAT, *)`
1263/// combination with `EGL_BAD_ATTRIBUTE`, so there is no F32 IOSurface
1264/// path.
1265///
1266/// **Windows (ANGLE/D3D11):** `f16` enables the RGBA16F texture paths and
1267/// `f32` the RGBA32F ones for every float layout (`float_dispatch.rs`) —
1268/// `PlanarRgb` and `PlanarRgba` NCHW, interleaved `Rgb`, and `Rgba`.
1269///
1270/// Regardless of these flags, [`ImageProcessor::convert`] never returns
1271/// an error due to float capability — it falls back to CPU when the GPU
1272/// path is unavailable.
1273#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1274pub struct RenderDtypeSupport {
1275    /// `GL_EXT_color_buffer_float` is available on the current GPU.
1276    ///
1277    /// On Linux, `true` enables F32 `Rgb` NHWC PBO readback. On macOS
1278    /// this flag is informational only — no F32 IOSurface path exists.
1279    /// On Windows, `true` enables the RGBA32F zero-copy texture render
1280    /// for every F32 layout.
1281    pub f32: bool,
1282    /// `GL_EXT_color_buffer_half_float` is available on the current GPU.
1283    ///
1284    /// On Linux, `true` enables F16 `PlanarRgb` NCHW PBO readback and,
1285    /// on V3D/Mali, zero-copy DMA-BUF render. On macOS, `true` enables
1286    /// F16 `PlanarRgb` via RGBA16F-packed IOSurface (zero-copy). On
1287    /// Windows, `true` enables the RGBA16F zero-copy texture render for
1288    /// every F16 layout.
1289    pub f16: bool,
1290}
1291
1292/// Returns `true` when a float PBO destination should be attempted for `dtype`.
1293///
1294/// Only F16 and F32 are eligible, and only when the corresponding flag in
1295/// `support` is set. U8/I8 and all other dtypes return `false` — they are
1296/// handled by the existing `dtype.size() == 1` PBO gate.
1297///
1298/// The same question `create_image` asks before trying a zero-copy float
1299/// destination, which is why that gate calls this rather than restating it:
1300/// a float image is only worth allocating on the GPU where the display can
1301/// render into that dtype at all.
1302///
1303/// Compiled on every platform with a GL backend -- the name is historical
1304/// (it began as the float *PBO* gate) and both callers are live everywhere
1305/// the `opengl` feature builds a backend.
1306#[cfg(all(
1307    any(
1308        target_os = "linux",
1309        target_os = "windows",
1310        target_os = "macos",
1311        target_os = "ios",
1312        target_os = "android"
1313    ),
1314    feature = "opengl"
1315))]
1316pub(crate) fn float_pbo_eligible(dtype: DType, support: RenderDtypeSupport) -> bool {
1317    match dtype {
1318        DType::F16 => support.f16,
1319        DType::F32 => support.f32,
1320        _ => false,
1321    }
1322}
1323
1324/// Image converter that uses available hardware acceleration or CPU as a
1325/// fallback.
1326#[derive(Debug)]
1327pub struct ImageProcessor {
1328    /// CPU-based image converter as a fallback. This is only None if the
1329    /// EDGEFIRST_DISABLE_CPU environment variable is set.
1330    pub cpu: Option<CPUProcessor>,
1331
1332    #[cfg(target_os = "linux")]
1333    /// G2D-based image converter for Linux systems. This is only available if
1334    /// the EDGEFIRST_DISABLE_G2D environment variable is not set and libg2d.so
1335    /// is available.
1336    pub g2d: Option<G2DProcessor>,
1337    #[cfg(target_os = "linux")]
1338    #[cfg(feature = "opengl")]
1339    /// OpenGL-based image converter for Linux systems. This is only available
1340    /// if the EDGEFIRST_DISABLE_GL environment variable is not set and OpenGL
1341    /// ES is available.
1342    pub opengl: Option<GLProcessorThreaded>,
1343    #[cfg(any(
1344        target_os = "macos",
1345        target_os = "ios",
1346        target_os = "android",
1347        target_os = "windows"
1348    ))]
1349    #[cfg(feature = "opengl")]
1350    /// OpenGL-based image converter — the same unified
1351    /// `GLProcessorThreaded` engine as Linux (its worker owns a
1352    /// per-processor context). macOS/iOS run it via ANGLE + IOSurface
1353    /// (available when ANGLE's libEGL.dylib can be loaded — see
1354    /// README.md § macOS GPU Acceleration); Android runs it via the
1355    /// native EGL driver + AHardwareBuffer; Windows runs it via ANGLE +
1356    /// Direct3D 11 with PBO transfers (available when ANGLE's libEGL.dll
1357    /// can be loaded — see README.md § Windows GPU Acceleration).
1358    pub opengl: Option<GLProcessorThreaded>,
1359
1360    /// When set, only the specified backend is used — no fallback chain.
1361    pub(crate) forced_backend: Option<ForcedBackend>,
1362
1363    /// Converts where the GL backend declined and the auto chain fell
1364    /// through toward G2D/CPU. Owned by the dispatcher (the GL worker never
1365    /// sees these frames), unlike the per-feed counters in
1366    /// `GLProcessorThreaded::convert_stats`. Read via
1367    /// [`convert_fallback_count`](Self::convert_fallback_count).
1368    pub(crate) convert_fallbacks: std::sync::atomic::AtomicU64,
1369}
1370
1371unsafe impl Send for ImageProcessor {}
1372unsafe impl Sync for ImageProcessor {}
1373
1374impl ImageProcessor {
1375    /// Creates a new `ImageProcessor` instance, initializing available
1376    /// hardware converters based on the system capabilities and environment
1377    /// variables.
1378    ///
1379    /// # Examples
1380    /// ```rust,no_run
1381    /// # use edgefirst_image::{ImageProcessor, Rotation, Flip, Crop, ImageProcessorTrait};
1382    /// # use edgefirst_codec::{peek_info, ImageDecoder, ImageLoad};
1383    /// # use edgefirst_tensor::{CpuAccess, PixelFormat, DType, Tensor, TensorMemory};
1384    /// # fn main() -> Result<(), edgefirst_image::Error> {
1385    /// let image = std::fs::read("zidane.jpg")?;
1386    /// // The codec emits the source's native format (a colour JPEG decodes to
1387    /// // NV12) and configures the destination tensor during the decode.
1388    /// let info = peek_info(&image).expect("peek");
1389    /// let mut src = Tensor::<u8>::image(info.width, info.height, info.format,
1390    ///                                    Some(TensorMemory::Mem), CpuAccess::ReadWrite)?;
1391    /// let mut decoder = ImageDecoder::new();
1392    /// src.load_image(&mut decoder, &image).expect("decode");
1393    /// let mut converter = ImageProcessor::new()?;
1394    /// let mut dst =
1395    ///     converter.create_image(640, 480, PixelFormat::Rgb, DType::U8, None, CpuAccess::ReadWrite)?;
1396    /// converter.convert(&src.into(), &mut dst, Rotation::None, Flip::None, Crop::default())?;
1397    /// # Ok(())
1398    /// # }
1399    /// ```
1400    pub fn new() -> Result<Self> {
1401        Self::with_config(ImageProcessorConfig::default())
1402    }
1403
1404    /// Number of converts where the auto chain's GL backend declined and the
1405    /// frame fell through toward G2D/CPU — each one silently lost any
1406    /// zero-copy guarantee. A pipeline that expects to stay on the GPU
1407    /// asserts this stays flat across its steady-state loop; pair with
1408    /// `GLProcessorThreaded::convert_stats` to see how the frames that DID
1409    /// reach GL were fed. Always 0 under a forced backend (no chain).
1410    pub fn convert_fallback_count(&self) -> u64 {
1411        self.convert_fallbacks
1412            .load(std::sync::atomic::Ordering::Relaxed)
1413    }
1414
1415    /// Number of [`Compression::Any`](edgefirst_tensor::Compression)
1416    /// requests since process start that resolved to a linear layout
1417    /// (process-wide mirror of
1418    /// [`edgefirst_tensor::compression_fallback_count`], surfaced here
1419    /// beside [`convert_fallback_count`](Self::convert_fallback_count)
1420    /// so pipeline telemetry reads from one place). A pipeline that
1421    /// expects compressed convert destinations asserts this stays flat
1422    /// after warmup.
1423    pub fn compression_fallback_count(&self) -> u64 {
1424        edgefirst_tensor::compression_fallback_count()
1425    }
1426
1427    /// Convert and return a native fence that signals when the GPU work
1428    /// completes, instead of blocking the CPU — the GL→NPU handoff
1429    /// (`EGL_ANDROID_native_fence_sync` on Android). On Windows the handle
1430    /// is a manual-reset event set when the GPU work completes; wait on it
1431    /// with `WaitForSingleObject`.
1432    ///
1433    /// `Ok(Some(fence))`: the destination buffer is still in flight; hand
1434    /// the fence to the consumer (e.g.
1435    /// `ANeuralNetworksExecution_startComputeWithDependencies` on Android,
1436    /// or `WaitForSingleObject` on Windows) or poll it before reading.
1437    /// `Ok(None)`: the convert completed with the normal blocking contract
1438    /// (no native fence on this platform, or a non-GL backend handled the
1439    /// frame) — the destination is already safe. Semantics are otherwise
1440    /// identical to [`convert`](ImageProcessorTrait::convert), including
1441    /// the GL→G2D→CPU fallback chain.
1442    ///
1443    /// On Windows, **for a texture destination**, the event and the
1444    /// destination's own `Tensor::gpu_completion` name the same fence value:
1445    /// one flush and one signal cover the convert, and the value recorded on
1446    /// `dst` is the one this event waits for, so a consumer may use either.
1447    /// A destination that is not a texture records nothing, and the event
1448    /// then carries a value of its own that only it can be waited on for.
1449    pub fn convert_with_fence(
1450        &mut self,
1451        src: &TensorDyn,
1452        dst: &mut TensorDyn,
1453        rotation: Rotation,
1454        flip: Flip,
1455        crop: Crop,
1456    ) -> Result<Option<CompletionFence>> {
1457        #[cfg(any(
1458            target_os = "linux",
1459            target_os = "macos",
1460            target_os = "ios",
1461            target_os = "android",
1462            target_os = "windows"
1463        ))]
1464        #[cfg(feature = "opengl")]
1465        {
1466            let gl_forced = matches!(self.forced_backend, Some(ForcedBackend::OpenGl));
1467            if self.forced_backend.is_none() || gl_forced {
1468                if let Some(opengl) = self.opengl.as_mut() {
1469                    match opengl.convert_with_fence(src, dst, rotation, flip, crop) {
1470                        Ok(fd) => return Ok(fd),
1471                        Err(e) if gl_forced => return Err(e),
1472                        Err(e) => {
1473                            // Not counted here: the blocking chain below
1474                            // re-runs the dispatcher, whose decline arm
1475                            // counts the fallback exactly once.
1476                            log::debug!(
1477                                "convert_with_fence: opengl declined, \
1478                                 falling back to the blocking chain: {e}"
1479                            );
1480                        }
1481                    }
1482                } else if gl_forced {
1483                    return Err(Error::ForcedBackendUnavailable("opengl".into()));
1484                }
1485            }
1486        }
1487        // Blocking chain (G2D/CPU, forced non-GL, or non-GL builds):
1488        // completion == return, so no fence is needed.
1489        self.convert(src, dst, rotation, flip, crop)?;
1490        Ok(None)
1491    }
1492
1493    /// Report which float dtypes the GPU can render to.
1494    ///
1495    /// Probes `GL_EXT_color_buffer_half_float` and
1496    /// `GL_EXT_color_buffer_float` once at `ImageProcessor::new()` time
1497    /// and caches the result. Call this once at startup to decide whether
1498    /// to request F16 or F32 destination tensors; [`create_image`] uses
1499    /// the result internally to auto-select float PBO when supported.
1500    ///
1501    /// Returns `RenderDtypeSupport { f32: false, f16: false }` when no
1502    /// OpenGL backend is active or the `opengl` feature is disabled.
1503    ///
1504    /// [`create_image`]: Self::create_image
1505    pub fn supported_render_dtypes(&self) -> RenderDtypeSupport {
1506        #[cfg(all(
1507            any(
1508                target_os = "macos",
1509                target_os = "ios",
1510                target_os = "android",
1511                target_os = "windows"
1512            ),
1513            feature = "opengl"
1514        ))]
1515        if let Some(gl) = self.opengl.as_ref() {
1516            return gl.supported_render_dtypes();
1517        }
1518        #[cfg(all(target_os = "linux", feature = "opengl"))]
1519        if let Some(gl) = self.opengl.as_ref() {
1520            return gl.supported_render_dtypes();
1521        }
1522        RenderDtypeSupport {
1523            f32: false,
1524            f16: false,
1525        }
1526    }
1527
1528    /// Creates a new `ImageProcessor` with the given configuration.
1529    ///
1530    /// When [`ImageProcessorConfig::backend`] is set to a specific backend,
1531    /// environment variables are ignored and the processor initializes the
1532    /// requested backend plus CPU as a fallback.
1533    ///
1534    /// When `Auto`, the existing `EDGEFIRST_FORCE_BACKEND` and
1535    /// `EDGEFIRST_DISABLE_*` environment variables apply.
1536    #[allow(unused_variables)]
1537    pub fn with_config(config: ImageProcessorConfig) -> Result<Self> {
1538        // ── Config-driven backend selection ──────────────────────────
1539        // When the caller explicitly requests a backend via the config,
1540        // skip all environment variable logic.
1541        match config.backend {
1542            ComputeBackend::Cpu => {
1543                log::info!("ComputeBackend::Cpu — CPU only");
1544                return Ok(Self {
1545                    cpu: Some(CPUProcessor::new()),
1546                    #[cfg(target_os = "linux")]
1547                    g2d: None,
1548                    #[cfg(target_os = "linux")]
1549                    #[cfg(feature = "opengl")]
1550                    opengl: None,
1551                    #[cfg(any(
1552                        target_os = "macos",
1553                        target_os = "ios",
1554                        target_os = "android",
1555                        target_os = "windows"
1556                    ))]
1557                    #[cfg(feature = "opengl")]
1558                    opengl: None,
1559                    convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1560                    forced_backend: None,
1561                });
1562            }
1563            ComputeBackend::G2d => {
1564                log::info!("ComputeBackend::G2d — G2D + CPU fallback");
1565                #[cfg(target_os = "linux")]
1566                {
1567                    let g2d = match G2DProcessor::new() {
1568                        Ok(g) => Some(g),
1569                        Err(e) => {
1570                            log::warn!("G2D requested but failed to initialize: {e:?}");
1571                            None
1572                        }
1573                    };
1574                    return Ok(Self {
1575                        cpu: Some(CPUProcessor::new()),
1576                        g2d,
1577                        #[cfg(feature = "opengl")]
1578                        opengl: None,
1579                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1580                        forced_backend: None,
1581                    });
1582                }
1583                #[cfg(not(target_os = "linux"))]
1584                {
1585                    log::warn!("G2D requested but not available on this platform, using CPU");
1586                    return Ok(Self {
1587                        cpu: Some(CPUProcessor::new()),
1588                        #[cfg(any(
1589                            target_os = "macos",
1590                            target_os = "ios",
1591                            target_os = "android",
1592                            target_os = "windows"
1593                        ))]
1594                        #[cfg(feature = "opengl")]
1595                        opengl: None,
1596                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1597                        forced_backend: None,
1598                    });
1599                }
1600            }
1601            ComputeBackend::OpenGl => {
1602                log::info!("ComputeBackend::OpenGl — OpenGL + CPU fallback");
1603                #[cfg(target_os = "linux")]
1604                {
1605                    #[cfg(feature = "opengl")]
1606                    let opengl = match GLProcessorThreaded::with_cache_capacity(
1607                        config.egl_display,
1608                        config.egl_cache_capacity,
1609                    ) {
1610                        Ok(gl) => Some(gl),
1611                        Err(e) => {
1612                            log::warn!("OpenGL requested but failed to initialize: {e:?}");
1613                            None
1614                        }
1615                    };
1616                    return Ok(Self {
1617                        cpu: Some(CPUProcessor::new()),
1618                        g2d: None,
1619                        #[cfg(feature = "opengl")]
1620                        opengl,
1621                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1622                        forced_backend: None,
1623                    }
1624                    .apply_colorimetry_mode(config.colorimetry));
1625                }
1626                #[cfg(any(target_os = "macos", target_os = "ios"))]
1627                {
1628                    #[cfg(feature = "opengl")]
1629                    let opengl = match GLProcessorThreaded::with_cache_capacity(
1630                        config.egl_display,
1631                        config.egl_cache_capacity,
1632                    ) {
1633                        Ok(gl) => Some(gl),
1634                        Err(e) => {
1635                            log::warn!(
1636                                "OpenGL requested on macOS but ANGLE init failed: {e:?} \
1637                                 (install ANGLE via `brew install startergo/angle/angle` \
1638                                 and re-sign the dylibs — see README.md § macOS GPU \
1639                                 Acceleration). Falling back to CPU."
1640                            );
1641                            None
1642                        }
1643                    };
1644                    return Ok(Self {
1645                        cpu: Some(CPUProcessor::new()),
1646                        #[cfg(feature = "opengl")]
1647                        opengl,
1648                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1649                        forced_backend: None,
1650                    }
1651                    .apply_colorimetry_mode(config.colorimetry));
1652                }
1653                #[cfg(target_os = "android")]
1654                {
1655                    #[cfg(feature = "opengl")]
1656                    let opengl = match GLProcessorThreaded::with_cache_capacity(
1657                        config.egl_display,
1658                        config.egl_cache_capacity,
1659                    ) {
1660                        Ok(gl) => Some(gl),
1661                        Err(e) => {
1662                            log::warn!(
1663                                "OpenGL requested but native EGL init failed: {e:?}. \
1664                                 Falling back to CPU."
1665                            );
1666                            None
1667                        }
1668                    };
1669                    return Ok(Self {
1670                        cpu: Some(CPUProcessor::new()),
1671                        #[cfg(feature = "opengl")]
1672                        opengl,
1673                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1674                        forced_backend: None,
1675                    }
1676                    .apply_colorimetry_mode(config.colorimetry));
1677                }
1678                #[cfg(target_os = "windows")]
1679                {
1680                    #[cfg(feature = "opengl")]
1681                    let opengl = match GLProcessorThreaded::with_cache_capacity(
1682                        config.egl_display,
1683                        config.egl_cache_capacity,
1684                    ) {
1685                        Ok(gl) => Some(gl),
1686                        Err(e) => {
1687                            log::warn!(
1688                                "OpenGL requested on Windows but ANGLE/D3D11 init failed: {e:?} \
1689                                 (fetch ANGLE with `scripts/fetch-angle.sh` and set \
1690                                 EDGEFIRST_ANGLE_PATH to the directory containing libEGL.dll + \
1691                                 libGLESv2.dll, or place the two DLLs next to the executable — \
1692                                 see README.md § Windows GPU Acceleration). Falling back to CPU."
1693                            );
1694                            None
1695                        }
1696                    };
1697                    return Ok(Self {
1698                        cpu: Some(CPUProcessor::new()),
1699                        #[cfg(feature = "opengl")]
1700                        opengl,
1701                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1702                        forced_backend: None,
1703                    }
1704                    .apply_colorimetry_mode(config.colorimetry));
1705                }
1706                #[cfg(not(any(
1707                    target_os = "linux",
1708                    target_os = "macos",
1709                    target_os = "ios",
1710                    target_os = "android",
1711                    target_os = "windows"
1712                )))]
1713                {
1714                    log::warn!("OpenGL requested but not available on this platform, using CPU");
1715                    return Ok(Self {
1716                        cpu: Some(CPUProcessor::new()),
1717                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1718                        forced_backend: None,
1719                    });
1720                }
1721            }
1722            ComputeBackend::Auto => { /* fall through to env-var logic below */ }
1723        }
1724
1725        // ── EDGEFIRST_FORCE_BACKEND ──────────────────────────────────
1726        // When set, only the requested backend is initialised and no
1727        // fallback chain is used. Accepted values (case-insensitive):
1728        //   "cpu", "g2d", "opengl"
1729        if let Ok(val) = std::env::var("EDGEFIRST_FORCE_BACKEND") {
1730            let val_lower = val.to_lowercase();
1731            let forced = match val_lower.as_str() {
1732                "cpu" => ForcedBackend::Cpu,
1733                "g2d" => ForcedBackend::G2d,
1734                "opengl" => ForcedBackend::OpenGl,
1735                other => {
1736                    return Err(Error::ForcedBackendUnavailable(format!(
1737                        "unknown EDGEFIRST_FORCE_BACKEND value: {other:?} (expected cpu, g2d, or opengl)"
1738                    )));
1739                }
1740            };
1741
1742            log::info!("EDGEFIRST_FORCE_BACKEND={val} — only initializing {val_lower} backend");
1743
1744            return match forced {
1745                ForcedBackend::Cpu => Ok(Self {
1746                    cpu: Some(CPUProcessor::new()),
1747                    #[cfg(target_os = "linux")]
1748                    g2d: None,
1749                    #[cfg(target_os = "linux")]
1750                    #[cfg(feature = "opengl")]
1751                    opengl: None,
1752                    #[cfg(any(
1753                        target_os = "macos",
1754                        target_os = "ios",
1755                        target_os = "android",
1756                        target_os = "windows"
1757                    ))]
1758                    #[cfg(feature = "opengl")]
1759                    opengl: None,
1760                    convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1761                    forced_backend: Some(ForcedBackend::Cpu),
1762                }),
1763                ForcedBackend::G2d => {
1764                    #[cfg(target_os = "linux")]
1765                    {
1766                        let g2d = G2DProcessor::new().map_err(|e| {
1767                            Error::ForcedBackendUnavailable(format!(
1768                                "g2d forced but failed to initialize: {e:?}"
1769                            ))
1770                        })?;
1771                        Ok(Self {
1772                            cpu: None,
1773                            g2d: Some(g2d),
1774                            #[cfg(feature = "opengl")]
1775                            opengl: None,
1776                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1777                            forced_backend: Some(ForcedBackend::G2d),
1778                        })
1779                    }
1780                    #[cfg(not(target_os = "linux"))]
1781                    {
1782                        Err(Error::ForcedBackendUnavailable(
1783                            "g2d backend is only available on Linux".into(),
1784                        ))
1785                    }
1786                }
1787                ForcedBackend::OpenGl => {
1788                    #[cfg(target_os = "linux")]
1789                    #[cfg(feature = "opengl")]
1790                    {
1791                        let opengl = GLProcessorThreaded::with_cache_capacity(
1792                            config.egl_display,
1793                            config.egl_cache_capacity,
1794                        )
1795                        .map_err(|e| {
1796                            Error::ForcedBackendUnavailable(format!(
1797                                "opengl forced but failed to initialize: {e:?}"
1798                            ))
1799                        })?;
1800                        Ok(Self {
1801                            cpu: None,
1802                            g2d: None,
1803                            opengl: Some(opengl),
1804                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1805                            forced_backend: Some(ForcedBackend::OpenGl),
1806                        }
1807                        .apply_colorimetry_mode(config.colorimetry))
1808                    }
1809                    #[cfg(any(target_os = "macos", target_os = "ios"))]
1810                    #[cfg(feature = "opengl")]
1811                    {
1812                        let opengl = GLProcessorThreaded::with_cache_capacity(
1813                            config.egl_display,
1814                            config.egl_cache_capacity,
1815                        )
1816                        .map_err(|e| {
1817                            Error::ForcedBackendUnavailable(format!(
1818                                "opengl forced on macOS but ANGLE init failed: {e:?}"
1819                            ))
1820                        })?;
1821                        Ok(Self {
1822                            cpu: None,
1823                            opengl: Some(opengl),
1824                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1825                            forced_backend: Some(ForcedBackend::OpenGl),
1826                        }
1827                        .apply_colorimetry_mode(config.colorimetry))
1828                    }
1829                    #[cfg(target_os = "android")]
1830                    #[cfg(feature = "opengl")]
1831                    {
1832                        let opengl = GLProcessorThreaded::with_cache_capacity(
1833                            config.egl_display,
1834                            config.egl_cache_capacity,
1835                        )
1836                        .map_err(|e| {
1837                            Error::ForcedBackendUnavailable(format!(
1838                                "opengl forced but native EGL init failed: {e:?}"
1839                            ))
1840                        })?;
1841                        Ok(Self {
1842                            cpu: None,
1843                            opengl: Some(opengl),
1844                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1845                            forced_backend: Some(ForcedBackend::OpenGl),
1846                        }
1847                        .apply_colorimetry_mode(config.colorimetry))
1848                    }
1849                    #[cfg(target_os = "windows")]
1850                    #[cfg(feature = "opengl")]
1851                    {
1852                        let opengl = GLProcessorThreaded::with_cache_capacity(
1853                            config.egl_display,
1854                            config.egl_cache_capacity,
1855                        )
1856                        .map_err(|e| {
1857                            Error::ForcedBackendUnavailable(format!(
1858                                "opengl forced on Windows but ANGLE/D3D11 init failed: {e:?}"
1859                            ))
1860                        })?;
1861                        Ok(Self {
1862                            cpu: None,
1863                            opengl: Some(opengl),
1864                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1865                            forced_backend: Some(ForcedBackend::OpenGl),
1866                        }
1867                        .apply_colorimetry_mode(config.colorimetry))
1868                    }
1869                    #[cfg(not(all(
1870                        any(
1871                            target_os = "linux",
1872                            target_os = "macos",
1873                            target_os = "ios",
1874                            target_os = "android",
1875                            target_os = "windows"
1876                        ),
1877                        feature = "opengl"
1878                    )))]
1879                    {
1880                        Err(Error::ForcedBackendUnavailable(
1881                            "opengl backend requires Linux, macOS, Android or Windows with the \
1882                             'opengl' feature enabled"
1883                                .into(),
1884                        ))
1885                    }
1886                }
1887            };
1888        }
1889
1890        // ── Existing DISABLE logic (unchanged) ──────────────────────
1891        #[cfg(target_os = "linux")]
1892        let g2d = if std::env::var("EDGEFIRST_DISABLE_G2D")
1893            .map(|x| x != "0" && x.to_lowercase() != "false")
1894            .unwrap_or(false)
1895        {
1896            log::debug!("EDGEFIRST_DISABLE_G2D is set");
1897            None
1898        } else {
1899            match G2DProcessor::new() {
1900                Ok(g2d_converter) => Some(g2d_converter),
1901                Err(err) => {
1902                    log::warn!("Failed to initialize G2D converter: {err:?}");
1903                    None
1904                }
1905            }
1906        };
1907
1908        #[cfg(target_os = "linux")]
1909        #[cfg(feature = "opengl")]
1910        let opengl = if std::env::var("EDGEFIRST_DISABLE_GL")
1911            .map(|x| x != "0" && x.to_lowercase() != "false")
1912            .unwrap_or(false)
1913        {
1914            log::debug!("EDGEFIRST_DISABLE_GL is set");
1915            None
1916        } else {
1917            match GLProcessorThreaded::with_cache_capacity(
1918                config.egl_display,
1919                config.egl_cache_capacity,
1920            ) {
1921                Ok(gl_converter) => Some(gl_converter),
1922                Err(err) => {
1923                    log::warn!("Failed to initialize GL converter: {err:?}");
1924                    None
1925                }
1926            }
1927        };
1928
1929        #[cfg(any(
1930            target_os = "macos",
1931            target_os = "ios",
1932            target_os = "android",
1933            target_os = "windows"
1934        ))]
1935        #[cfg(feature = "opengl")]
1936        let opengl = if std::env::var("EDGEFIRST_DISABLE_GL")
1937            .map(|x| x != "0" && x.to_lowercase() != "false")
1938            .unwrap_or(false)
1939        {
1940            log::debug!("EDGEFIRST_DISABLE_GL is set");
1941            None
1942        } else {
1943            match GLProcessorThreaded::with_cache_capacity(
1944                config.egl_display,
1945                config.egl_cache_capacity,
1946            ) {
1947                Ok(gl_converter) => Some(gl_converter),
1948                Err(err) => {
1949                    log::debug!(
1950                        "GL backend unavailable: {err:?} \
1951                         (CPU fallback will be used)"
1952                    );
1953                    None
1954                }
1955            }
1956        };
1957
1958        let cpu = if std::env::var("EDGEFIRST_DISABLE_CPU")
1959            .map(|x| x != "0" && x.to_lowercase() != "false")
1960            .unwrap_or(false)
1961        {
1962            log::debug!("EDGEFIRST_DISABLE_CPU is set");
1963            None
1964        } else {
1965            Some(CPUProcessor::new())
1966        };
1967        Ok(Self {
1968            cpu,
1969            #[cfg(target_os = "linux")]
1970            g2d,
1971            #[cfg(target_os = "linux")]
1972            #[cfg(feature = "opengl")]
1973            opengl,
1974            #[cfg(any(
1975                target_os = "macos",
1976                target_os = "ios",
1977                target_os = "android",
1978                target_os = "windows"
1979            ))]
1980            #[cfg(feature = "opengl")]
1981            opengl,
1982            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1983            forced_backend: None,
1984        }
1985        .apply_colorimetry_mode(config.colorimetry))
1986    }
1987
1988    /// Apply the configured [`ColorimetryMode`] to whichever backend honours
1989    /// it (currently the Linux GL backend); no-op elsewhere. Constructor
1990    /// plumbing for [`ImageProcessorConfig::colorimetry`].
1991    fn apply_colorimetry_mode(self, _mode: ColorimetryMode) -> Self {
1992        #[cfg(all(
1993            any(
1994                target_os = "linux",
1995                target_os = "macos",
1996                target_os = "ios",
1997                target_os = "android",
1998                target_os = "windows"
1999            ),
2000            feature = "opengl"
2001        ))]
2002        {
2003            let mut me = self;
2004            if let Err(e) = me.set_colorimetry_mode(_mode) {
2005                log::warn!("Failed to apply ColorimetryMode::{_mode:?}: {e:?}");
2006            }
2007            me
2008        }
2009        #[cfg(not(all(
2010            any(
2011                target_os = "linux",
2012                target_os = "macos",
2013                target_os = "ios",
2014                target_os = "android",
2015                target_os = "windows"
2016            ),
2017            feature = "opengl"
2018        )))]
2019        {
2020            let _ = _mode;
2021            self
2022        }
2023    }
2024
2025    /// Sets the colorimetry/performance trade-off (see [`ColorimetryMode`])
2026    /// on the OpenGL backend. No-op if OpenGL is not available. The
2027    /// `EDGEFIRST_COLORIMETRY` environment variable takes precedence — when
2028    /// it is set, this call logs and keeps the env-selected mode.
2029    #[cfg(all(
2030        any(
2031            target_os = "linux",
2032            target_os = "macos",
2033            target_os = "ios",
2034            target_os = "android",
2035            target_os = "windows"
2036        ),
2037        feature = "opengl"
2038    ))]
2039    pub fn set_colorimetry_mode(&mut self, mode: ColorimetryMode) -> Result<()> {
2040        if let Some(ref mut gl) = self.opengl {
2041            gl.set_colorimetry_mode(mode)?;
2042        }
2043        Ok(())
2044    }
2045
2046    /// Sets the interpolation mode for int8 proto textures on the OpenGL
2047    /// backend. No-op if OpenGL is not available.
2048    #[cfg(all(
2049        any(
2050            target_os = "linux",
2051            target_os = "macos",
2052            target_os = "ios",
2053            target_os = "android",
2054            target_os = "windows"
2055        ),
2056        feature = "opengl"
2057    ))]
2058    pub fn set_int8_interpolation_mode(&mut self, mode: Int8InterpolationMode) -> Result<()> {
2059        if let Some(ref mut gl) = self.opengl {
2060            gl.set_int8_interpolation_mode(mode)?;
2061        }
2062        Ok(())
2063    }
2064
2065    /// Allocate an image tensor from a declarative
2066    /// [`ImageDesc`](edgefirst_tensor::ImageDesc) request — the
2067    /// full-featured variant of [`create_image`](Self::create_image).
2068    ///
2069    /// Without a compression request this is exactly `create_image` (the
2070    /// processor's memory negotiation applies). With one, the allocation
2071    /// rides the tensor desc path un-negotiated: the layout decision
2072    /// belongs to the platform allocator, and the request's guards and
2073    /// fallback counting live there (see
2074    /// [`Tensor::image_desc`](edgefirst_tensor::Tensor::image_desc)).
2075    pub fn create_image_desc(&self, desc: &edgefirst_tensor::ImageDesc) -> Result<TensorDyn> {
2076        if desc.compression().is_none() {
2077            return self.create_image(
2078                desc.width(),
2079                desc.height(),
2080                desc.format(),
2081                desc.dtype(),
2082                desc.memory(),
2083                desc.access(),
2084            );
2085        }
2086        Ok(TensorDyn::image_desc(desc)?)
2087    }
2088
2089    /// Create a [`TensorDyn`] image with the best available memory backend.
2090    ///
2091    /// Priority: DMA-buf → float PBO (F16/F32) → u8/i8 PBO → system memory.
2092    ///
2093    /// Use this method instead of [`TensorDyn::image()`] when the tensor will
2094    /// be used with [`ImageProcessor::convert()`]. It selects the optimal
2095    /// memory backing (including PBO for GPU zero-copy) which direct
2096    /// allocation cannot achieve.
2097    ///
2098    /// This method is on [`ImageProcessor`] rather than [`ImageProcessorTrait`]
2099    /// because optimal allocation requires knowledge of the active compute
2100    /// backends (e.g. the GL context handle for PBO allocation). Individual
2101    /// backend implementations ([`CPUProcessor`], etc.) do not have this
2102    /// cross-backend visibility.
2103    ///
2104    /// **Float dtype behaviour:** when `dtype` is `F16` or `F32` and
2105    /// [`supported_render_dtypes`] reports the GPU supports that type,
2106    /// `memory: None` auto-selects a float PBO (Linux) or IOSurface (macOS
2107    /// F16 only). If GPU float support is absent the allocation falls through
2108    /// to `TensorMemory::Mem`; [`convert`] then uses the CPU path.
2109    /// Passing `memory: Some(TensorMemory::DmaBuf)` with `dtype: F32` always
2110    /// returns `Error::NotSupported` — no 32-bit-float DRM fourcc exists.
2111    ///
2112    /// [`supported_render_dtypes`]: Self::supported_render_dtypes
2113    /// [`convert`]: ImageProcessorTrait::convert
2114    ///
2115    /// # Arguments
2116    ///
2117    /// * `width` - Image width in pixels
2118    /// * `height` - Image height in pixels
2119    /// * `format` - Pixel format
2120    /// * `dtype` - Element data type (e.g. `DType::U8`, `DType::F16`, `DType::F32`)
2121    /// * `memory` - Optional memory type override; when `None`, the best
2122    ///   available backend is selected automatically.
2123    /// * `access` - Declares the CPU involvement you plan on. Use
2124    ///   [`CpuAccess::None`](edgefirst_tensor::CpuAccess) for a destination
2125    ///   that only hardware reads (an NPU input), and `ReadWrite` when you
2126    ///   will map it. Hardware access is always implied. Mapping a tensor
2127    ///   beyond its declaration is counted by
2128    ///   [`unplanned_cpu_access_count`](edgefirst_tensor::unplanned_cpu_access_count).
2129    ///
2130    /// # Returns
2131    ///
2132    /// A [`TensorDyn`] backed by the highest-performance memory type
2133    /// available on this system.
2134    ///
2135    /// # Examples
2136    ///
2137    /// ```rust,no_run
2138    /// # use edgefirst_image::ImageProcessor;
2139    /// # use edgefirst_tensor::{CpuAccess, DType, PixelFormat};
2140    /// # fn main() -> Result<(), edgefirst_image::Error> {
2141    /// let processor = ImageProcessor::new()?;
2142    ///
2143    /// // Model input the NPU reads directly — no CPU mapping planned.
2144    /// let input = processor.create_image(
2145    ///     640, 640, PixelFormat::Rgb, DType::U8, None, CpuAccess::None,
2146    /// )?;
2147    /// # let _ = input;
2148    /// # Ok(())
2149    /// # }
2150    /// ```
2151    ///
2152    /// # Pitch alignment for DMA-backed allocations
2153    ///
2154    /// DMA-BUF imports into the GL backend (Mali Valhall on i.MX 95
2155    /// specifically) require every row pitch to be a multiple of
2156    /// [`GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES`] (currently 64). When this
2157    /// method lands on `TensorMemory::DmaBuf`, the underlying allocation is
2158    /// silently padded so the row stride satisfies that requirement.
2159    ///
2160    /// **The user-requested `width` is preserved** — `tensor.width()`
2161    /// returns the same value you passed in. The padding is carried by
2162    /// [`TensorDyn::row_stride`] / `effective_row_stride()`, which the
2163    /// GL backend reads when importing the buffer as an EGLImage.
2164    /// Callers that compute byte offsets from the tensor must use the
2165    /// stride, not `width × bytes_per_pixel`; the CPU mapping spans the
2166    /// full `stride × height` bytes.
2167    ///
2168    /// Pre-aligned widths (640, 1280, 1920, 3008, 3840 …) allocate
2169    /// exactly `width × bpp × height` bytes with no padding. PBO and
2170    /// Mem fallbacks never pad — they don't go through EGLImage import.
2171    ///
2172    /// See also [`align_width_for_gpu_pitch`] for an advisory helper
2173    /// that external callers (GStreamer plugins, video pipelines) can
2174    /// use to size their own DMA-BUFs for GL compatibility.
2175    ///
2176    /// # Errors
2177    ///
2178    /// Returns an error if all allocation strategies fail.
2179    pub fn create_image(
2180        &self,
2181        width: usize,
2182        height: usize,
2183        format: PixelFormat,
2184        dtype: DType,
2185        memory: Option<TensorMemory>,
2186        access: edgefirst_tensor::CpuAccess,
2187    ) -> Result<TensorDyn> {
2188        // Compute the GPU-aligned row stride in bytes for this image.
2189        // `None` means either the format has no defined primary-plane bpp
2190        // (unknown future layout) or the stride calculation would overflow
2191        // — in both cases we fall back to the natural layout via the plain
2192        // `TensorDyn::image` constructor, and the slow-path warning inside
2193        // `draw_*_masks` will fire if the subsequent GL import fails.
2194        //
2195        // DMA allocation is Linux-only (see `TensorMemory::DmaBuf` cfg gate),
2196        // so both the stride computation and the helper closure are gated
2197        // accordingly — the callers below are already Linux-only.
2198        #[cfg(target_os = "linux")]
2199        let dma_stride_bytes: Option<usize> = primary_plane_bpp(format, dtype.size())
2200            .and_then(|bpp| width.checked_mul(bpp))
2201            .and_then(align_pitch_bytes_to_gpu_alignment);
2202
2203        // Helper: allocate a DMA image, using the padded-stride constructor
2204        // when the computed stride exceeds the natural pitch, otherwise the
2205        // plain constructor (byte-identical result in the common case).
2206        #[cfg(target_os = "linux")]
2207        let try_dma = || -> Result<TensorDyn> {
2208            // Stride padding is only meaningful for packed pixel layouts
2209            // (RGBA8, BGRA8, RGB888, Grey) — the formats the GL backend
2210            // renders into. Semi-planar (NV12, NV16) and planar (PlanarRgb,
2211            // PlanarRgba) tensors go through `TensorDyn::image(...)` with
2212            // their natural layout; they're imported from camera capture
2213            // via `from_fd` far more often than allocated here, and
2214            // `Tensor::image_with_stride` explicitly rejects them.
2215            let packed = format.layout() == edgefirst_tensor::PixelLayout::Packed;
2216            match dma_stride_bytes {
2217                Some(stride)
2218                    if packed
2219                        && primary_plane_bpp(format, dtype.size())
2220                            .and_then(|bpp| width.checked_mul(bpp))
2221                            .is_some_and(|natural| stride > natural) =>
2222                {
2223                    log::debug!(
2224                        "create_image: padding row stride for {format:?} {width}x{height} \
2225                         from natural pitch to {stride} bytes for GPU alignment"
2226                    );
2227                    Ok(TensorDyn::image_with_stride(
2228                        width,
2229                        height,
2230                        format,
2231                        dtype,
2232                        stride,
2233                        Some(edgefirst_tensor::TensorMemory::DmaBuf),
2234                        access,
2235                    )?)
2236                }
2237                _ => Ok(TensorDyn::image(
2238                    width,
2239                    height,
2240                    format,
2241                    dtype,
2242                    Some(edgefirst_tensor::TensorMemory::DmaBuf),
2243                    access,
2244                )?),
2245            }
2246        };
2247
2248        // If an explicit memory type is requested, honour it directly.
2249        // On Linux, `TensorMemory::DmaBuf` gets the padded-stride treatment;
2250        // other memory types take the user-requested width verbatim.
2251        // On macOS, `TensorMemory::DmaBuf` dispatches through `TensorDyn::image`
2252        // which selects the IOSurface allocation path (FourCC-formatted)
2253        // for image-mappable formats, or falls back to SHM/Mem otherwise.
2254        match memory {
2255            #[cfg(target_os = "linux")]
2256            Some(TensorMemory::DmaBuf) => {
2257                // F32 has no 32-bit-float DRM fourcc; callers must use PBO instead.
2258                if dtype == DType::F32 {
2259                    return Err(Error::NotSupported(
2260                        "F32 has no 32-bit-float DRM format for DMA-BUF; \
2261                         use TensorMemory::Pbo for F32"
2262                            .to_string(),
2263                    ));
2264                }
2265                return try_dma();
2266            }
2267            Some(mem) => {
2268                return Ok(TensorDyn::image(
2269                    width,
2270                    height,
2271                    format,
2272                    dtype,
2273                    Some(mem),
2274                    access,
2275                )?);
2276            }
2277            None => {}
2278        }
2279
2280        // macOS/Android/Windows: when the GL backend is active, prefer Dma
2281        // (IOSurface on Apple, AHardwareBuffer on Android, a D3D11 texture
2282        // on Windows) for zero-copy import. Formats without a zero-copy
2283        // mapping now ERROR under explicit Dma (the explicit-Dma
2284        // contract), so auto-select catches that error here and falls
2285        // back to host storage — loudly, via the debug log below.
2286        #[cfg(any(
2287            target_os = "macos",
2288            target_os = "ios",
2289            target_os = "android",
2290            target_os = "windows"
2291        ))]
2292        #[cfg(feature = "opengl")]
2293        if let Some(gl) = self.opengl.as_ref() {
2294            let _ = gl;
2295            // Float textures only where the display can render into them;
2296            // otherwise the float PBO step below applies. The same question
2297            // that step asks, asked through the same helper so the two
2298            // cannot answer differently.
2299            let support = self.supported_render_dtypes();
2300            let dtype_ok =
2301                !matches!(dtype, DType::F16 | DType::F32) || float_pbo_eligible(dtype, support);
2302            if dtype_ok {
2303                match TensorDyn::image(
2304                    width,
2305                    height,
2306                    format,
2307                    dtype,
2308                    Some(edgefirst_tensor::TensorMemory::DmaBuf),
2309                    access,
2310                ) {
2311                    Ok(img) => return Ok(img),
2312                    Err(e) => {
2313                        // Falling back to a non-zero-copy destination is a real
2314                        // perf cliff — never do it silently (on-device triage
2315                        // starts from this line).
2316                        log::debug!(
2317                            "create_image: zero-copy Dma allocation declined \
2318                             ({format:?}/{dtype:?} {width}x{height}): {e:?}; using fallback storage"
2319                        );
2320                    }
2321                }
2322            }
2323        }
2324
2325        // Try DMA first on Linux — skip only when GL has explicitly selected PBO
2326        // as the preferred transfer path (PBO is better than DMA in that case).
2327        #[cfg(target_os = "linux")]
2328        {
2329            #[cfg(feature = "opengl")]
2330            let gl_uses_pbo = self
2331                .opengl
2332                .as_ref()
2333                .is_some_and(|gl| gl.transfer_backend() == opengl_headless::TransferBackend::Pbo);
2334            #[cfg(not(feature = "opengl"))]
2335            let gl_uses_pbo = false;
2336
2337            if !gl_uses_pbo {
2338                if let Ok(img) = try_dma() {
2339                    return Ok(img);
2340                }
2341            }
2342        }
2343
2344        // Try PBO (if GL available).
2345        // PBO buffers are u8-sized; the int8 shader emulates i8 output via
2346        // XOR 0x80 on the same underlying buffer, so both U8 and I8 work.
2347        #[cfg(any(target_os = "linux", target_os = "windows"))]
2348        #[cfg(feature = "opengl")]
2349        if dtype.size() == 1 {
2350            if let Some(gl) = &self.opengl {
2351                match gl.create_pbo_image(width, height, format) {
2352                    Ok(t) => {
2353                        if dtype == DType::I8 {
2354                            // SAFETY: Tensor<u8> and Tensor<i8> are layout-
2355                            // identical (same element size, no T-dependent
2356                            // drop glue). The int8 shader applies XOR 0x80
2357                            // on the same PBO buffer. Same rationale as
2358                            // gl::processor::tensor_i8_as_u8_mut.
2359                            // Invariant: PBO tensors never have chroma
2360                            // (create_pbo_image → Tensor::wrap sets it None).
2361                            debug_assert!(
2362                                t.chroma().is_none(),
2363                                "PBO i8 transmute requires chroma == None"
2364                            );
2365                            let t_i8: Tensor<i8> = unsafe { std::mem::transmute(t) };
2366                            return Ok(TensorDyn::from(t_i8));
2367                        }
2368                        return Ok(TensorDyn::from(t));
2369                    }
2370                    Err(e) => log::debug!("PBO image creation failed, falling back to Mem: {e:?}"),
2371                }
2372            }
2373        }
2374
2375        // Try float PBO when the GPU backend reports support for this dtype.
2376        // Falls through to Mem on error (same policy as u8 PBO above).
2377        #[cfg(any(target_os = "linux", target_os = "windows"))]
2378        #[cfg(feature = "opengl")]
2379        if float_pbo_eligible(dtype, self.supported_render_dtypes()) {
2380            if let Some(gl) = &self.opengl {
2381                match gl.create_pbo_image_dtype(width, height, format, dtype) {
2382                    Ok(t) => return Ok(t),
2383                    Err(e) => {
2384                        log::debug!(
2385                            "Float PBO image creation failed for {dtype:?}, \
2386                             falling back to Mem: {e:?}"
2387                        );
2388                    }
2389                }
2390            }
2391        }
2392
2393        // Fallback to Mem
2394        Ok(TensorDyn::image(
2395            width,
2396            height,
2397            format,
2398            dtype,
2399            Some(edgefirst_tensor::TensorMemory::Mem),
2400            access,
2401        )?)
2402    }
2403
2404    /// Import an external DMA-BUF image.
2405    ///
2406    /// Each [`PlaneDescriptor`] owns an already-duped fd; this method
2407    /// consumes the descriptors and takes ownership of those fds (whether
2408    /// the call succeeds or fails).
2409    ///
2410    /// The caller must ensure the DMA-BUF allocation is large enough for the
2411    /// specified width, height, format, and any stride/offset on the plane
2412    /// descriptors. No buffer-size validation is performed; an undersized
2413    /// buffer may cause GPU faults or EGL import failure.
2414    ///
2415    /// # Arguments
2416    ///
2417    /// * `image` - Plane descriptor for the primary (or only) plane
2418    /// * `chroma` - Optional plane descriptor for the UV chroma plane
2419    ///   (required for multiplane NV12)
2420    /// * `width` - Image width in pixels
2421    /// * `height` - Image height in pixels
2422    /// * `format` - Pixel format of the buffer
2423    /// * `dtype` - Element data type (e.g. `DType::U8`)
2424    ///
2425    /// # Returns
2426    ///
2427    /// A `TensorDyn` configured as an image.
2428    ///
2429    /// # Errors
2430    ///
2431    /// * [`Error::NotSupported`] if `chroma` is `Some` for a non-semi-planar
2432    ///   format, or multiplane NV16 (not yet supported), or the fd is not
2433    ///   DMA-backed
2434    /// * [`Error::InvalidShape`] if NV12 height is odd
2435    ///
2436    /// # Platform
2437    ///
2438    /// Linux only.
2439    ///
2440    /// # Examples
2441    ///
2442    /// ```rust,ignore
2443    /// use edgefirst_tensor::PlaneDescriptor;
2444    ///
2445    /// // Single-plane RGBA
2446    /// let pd = PlaneDescriptor::new(fd.as_fd())?;
2447    /// let src = proc.import_image(pd, None, 1920, 1080, PixelFormat::Rgba, DType::U8, None)?;
2448    ///
2449    /// // Multi-plane NV12 with stride
2450    /// let y_pd = PlaneDescriptor::new(y_fd.as_fd())?.with_stride(2048);
2451    /// let uv_pd = PlaneDescriptor::new(uv_fd.as_fd())?.with_stride(2048);
2452    /// let src = proc.import_image(y_pd, Some(uv_pd), 1920, 1080,
2453    ///                             PixelFormat::Nv12, DType::U8, None)?;
2454    /// ```
2455    // Import inherently needs plane(s) + geometry + format + dtype + colorimetry;
2456    // a params struct would obscure more than it clarifies here.
2457    #[allow(clippy::too_many_arguments)]
2458    #[cfg(target_os = "linux")]
2459    pub fn import_image(
2460        &self,
2461        image: edgefirst_tensor::PlaneDescriptor,
2462        chroma: Option<edgefirst_tensor::PlaneDescriptor>,
2463        width: usize,
2464        height: usize,
2465        format: PixelFormat,
2466        dtype: DType,
2467        colorimetry: Option<edgefirst_tensor::Colorimetry>,
2468    ) -> Result<TensorDyn> {
2469        use edgefirst_tensor::{Tensor, TensorMemory};
2470
2471        // Capture stride/offset from descriptors before consuming them
2472        let image_stride = image.stride();
2473        let image_offset = image.offset();
2474        let chroma_stride = chroma.as_ref().and_then(|c| c.stride());
2475        let chroma_offset = chroma.as_ref().and_then(|c| c.offset());
2476
2477        if let Some(chroma_pd) = chroma {
2478            // ── Multiplane path ──────────────────────────────────────
2479            // Multiplane tensors are backed by Tensor<u8> (or transmuted to
2480            // Tensor<i8>). Reject other dtypes to avoid silently returning a
2481            // tensor with the wrong element type.
2482            if dtype != DType::U8 && dtype != DType::I8 {
2483                return Err(Error::NotSupported(format!(
2484                    "multiplane import only supports U8/I8, got {dtype:?}"
2485                )));
2486            }
2487            if format.layout() != PixelLayout::SemiPlanar {
2488                return Err(Error::NotSupported(format!(
2489                    "import_image with chroma requires a semi-planar format, got {format:?}"
2490                )));
2491            }
2492
2493            let chroma_h = match format {
2494                PixelFormat::Nv12 => {
2495                    // NV12 (4:2:0): ceil(H/2) chroma rows — odd heights are valid.
2496                    height.div_ceil(2)
2497                }
2498                // NV16 multiplane will be supported in a future release;
2499                // the GL backend currently only handles NV12 plane1 attributes.
2500                PixelFormat::Nv16 => {
2501                    return Err(Error::NotSupported(
2502                        "multiplane NV16 is not yet supported; use contiguous NV16 instead".into(),
2503                    ))
2504                }
2505                _ => {
2506                    return Err(Error::NotSupported(format!(
2507                        "unsupported semi-planar format: {format:?}"
2508                    )))
2509                }
2510            };
2511
2512            let luma = Tensor::<u8>::from_fd(image.into_fd(), &[height, width], Some("luma"))?;
2513            if luma.memory() != TensorMemory::DmaBuf {
2514                return Err(Error::NotSupported(format!(
2515                    "luma fd must be DMA-backed, got {:?}",
2516                    luma.memory()
2517                )));
2518            }
2519
2520            let chroma_tensor =
2521                Tensor::<u8>::from_fd(chroma_pd.into_fd(), &[chroma_h, width], Some("chroma"))?;
2522            if chroma_tensor.memory() != TensorMemory::DmaBuf {
2523                return Err(Error::NotSupported(format!(
2524                    "chroma fd must be DMA-backed, got {:?}",
2525                    chroma_tensor.memory()
2526                )));
2527            }
2528
2529            // from_planes creates the combined tensor with format set,
2530            // preserving luma's row_stride (currently None since luma was raw).
2531            let mut tensor = Tensor::<u8>::from_planes(luma, chroma_tensor, format)?;
2532
2533            // Apply stride/offset to the combined tensor (luma plane)
2534            if let Some(s) = image_stride {
2535                tensor.set_row_stride(s)?;
2536            }
2537            if let Some(o) = image_offset {
2538                tensor.set_plane_offset(o);
2539            }
2540
2541            // Apply stride/offset to the chroma sub-tensor.
2542            // The chroma tensor is a raw 2D [chroma_h, width] tensor without
2543            // format metadata, so we validate stride manually rather than
2544            // using set_row_stride (which requires format).
2545            if let Some(chroma_ref) = tensor.chroma_mut() {
2546                if let Some(s) = chroma_stride {
2547                    if s < width {
2548                        return Err(Error::InvalidShape(format!(
2549                            "chroma stride {s} < minimum {width} for {format:?}"
2550                        )));
2551                    }
2552                    chroma_ref.set_row_stride_unchecked(s);
2553                }
2554                if let Some(o) = chroma_offset {
2555                    chroma_ref.set_plane_offset(o);
2556                }
2557            }
2558
2559            if dtype == DType::I8 {
2560                // SAFETY: Tensor<u8> and Tensor<i8> have identical layout because
2561                // the struct contains only type-erased storage (OwnedFd, shape, name),
2562                // no inline T values. This assertion catches layout drift at compile time.
2563                const {
2564                    assert!(std::mem::size_of::<Tensor<u8>>() == std::mem::size_of::<Tensor<i8>>());
2565                    assert!(
2566                        std::mem::align_of::<Tensor<u8>>() == std::mem::align_of::<Tensor<i8>>()
2567                    );
2568                }
2569                let tensor_i8: Tensor<i8> = unsafe { std::mem::transmute(tensor) };
2570                let mut dyn_tensor = TensorDyn::from(tensor_i8);
2571                dyn_tensor.set_colorimetry(colorimetry);
2572                return Ok(dyn_tensor);
2573            }
2574            let mut dyn_tensor = TensorDyn::from(tensor);
2575            dyn_tensor.set_colorimetry(colorimetry);
2576            Ok(dyn_tensor)
2577        } else {
2578            // ── Single-plane path ────────────────────────────────────
2579            // Canonical shape (Packed [H,W,C] / Planar [C,H,W] / SemiPlanar
2580            // [total_h, W]); `allocation_shape` supports NV12/NV16/NV24 (the old
2581            // hand-rolled match erroneously rejected NV24).
2582            let shape = format.allocation_shape(width, height).ok_or_else(|| {
2583                Error::NotSupported(format!(
2584                    "unsupported pixel format for import_image: {format:?}"
2585                ))
2586            })?;
2587            let tensor = TensorDyn::from_fd(image.into_fd(), &shape, dtype, None)?;
2588            if tensor.memory() != TensorMemory::DmaBuf {
2589                return Err(Error::NotSupported(format!(
2590                    "import_image requires DMA-backed fd, got {:?}",
2591                    tensor.memory()
2592                )));
2593            }
2594            let mut tensor = tensor.with_format(format)?;
2595            if let Some(s) = image_stride {
2596                tensor.set_row_stride(s)?;
2597            }
2598            if let Some(o) = image_offset {
2599                tensor.set_plane_offset(o);
2600            }
2601            tensor.set_colorimetry(colorimetry);
2602            Ok(tensor)
2603        }
2604    }
2605
2606    /// Decode model outputs and draw segmentation masks onto `dst`.
2607    ///
2608    /// The processor decodes via the provided
2609    /// [`Decoder`](edgefirst_decoder::Decoder), selects the optimal rendering
2610    /// path (hybrid CPU+GL or fused GPU), and composites masks onto `dst`.
2611    ///
2612    /// Returns the detected bounding boxes.
2613    ///
2614    /// **Requires the `decode` feature**, which is off by default because it
2615    /// is the only thing in this crate that needs `edgefirst-decoder`. This
2616    /// method sequences a decode and a draw; both halves are available
2617    /// unconditionally and are equally fused, so without the feature write:
2618    ///
2619    /// ```ignore
2620    /// let mut boxes = Vec::new();
2621    /// match decoder.decode_proto(outputs, &mut boxes)? {
2622    ///     Some(protos) => proc.draw_proto_masks(dst, &boxes, &protos, overlay)?,
2623    ///     None => {
2624    ///         let mut masks = Vec::new();
2625    ///         decoder.decode(outputs, &mut boxes, &mut masks)?;
2626    ///         proc.draw_decoded_masks(dst, &boxes, &masks, overlay)?;
2627    ///     }
2628    /// }
2629    /// ```
2630    #[cfg(feature = "decode")]
2631    pub fn draw_masks(
2632        &mut self,
2633        decoder: &edgefirst_decoder::Decoder,
2634        outputs: &[&TensorDyn],
2635        dst: &mut TensorDyn,
2636        overlay: MaskOverlay<'_>,
2637    ) -> Result<Vec<DetectBox>> {
2638        let mut output_boxes = Vec::with_capacity(100);
2639
2640        // Try proto path first (fused rendering without materializing masks)
2641        let proto_result = decoder
2642            .decode_proto(outputs, &mut output_boxes)
2643            .map_err(|e| Error::Internal(format!("decode_proto: {e:#?}")))?;
2644
2645        if let Some(proto_data) = proto_result {
2646            self.draw_proto_masks(dst, &output_boxes, &proto_data, overlay)?;
2647        } else {
2648            // Detection-only or unsupported model: full decode + render
2649            let mut output_masks = Vec::with_capacity(100);
2650            decoder
2651                .decode(outputs, &mut output_boxes, &mut output_masks)
2652                .map_err(|e| Error::Internal(format!("decode: {e:#?}")))?;
2653            self.draw_decoded_masks(dst, &output_boxes, &output_masks, overlay)?;
2654        }
2655        Ok(output_boxes)
2656    }
2657
2658    /// Decode tracked model outputs and draw segmentation masks onto `dst`.
2659    ///
2660    /// Like [`draw_masks`](Self::draw_masks) but integrates a tracker for
2661    /// maintaining object identities across frames. The tracker runs after
2662    /// NMS but before mask extraction.
2663    ///
2664    /// Returns detected boxes and track info.
2665    #[cfg(feature = "tracker")]
2666    pub fn draw_masks_tracked<TR: edgefirst_tracker::Tracker<DetectBox>>(
2667        &mut self,
2668        decoder: &edgefirst_decoder::Decoder,
2669        tracker: &mut TR,
2670        timestamp: u64,
2671        outputs: &[&TensorDyn],
2672        dst: &mut TensorDyn,
2673        overlay: MaskOverlay<'_>,
2674    ) -> Result<(Vec<DetectBox>, Vec<edgefirst_tracker::TrackInfo>)> {
2675        let mut output_boxes = Vec::with_capacity(100);
2676        let mut output_tracks = Vec::new();
2677
2678        let proto_result = decoder
2679            .decode_proto_tracked(
2680                tracker,
2681                timestamp,
2682                outputs,
2683                &mut output_boxes,
2684                &mut output_tracks,
2685            )
2686            .map_err(|e| Error::Internal(format!("decode_proto_tracked: {e:#?}")))?;
2687
2688        if let Some(proto_data) = proto_result {
2689            self.draw_proto_masks(dst, &output_boxes, &proto_data, overlay)?;
2690        } else {
2691            // Note: decode_proto_tracked returns None for detection-only/ModelPack
2692            // models WITHOUT calling the tracker. The else branch below is the
2693            // first (and only) tracker call for those model types.
2694            let mut output_masks = Vec::with_capacity(100);
2695            decoder
2696                .decode_tracked(
2697                    tracker,
2698                    timestamp,
2699                    outputs,
2700                    &mut output_boxes,
2701                    &mut output_masks,
2702                    &mut output_tracks,
2703                )
2704                .map_err(|e| Error::Internal(format!("decode_tracked: {e:#?}")))?;
2705            self.draw_decoded_masks(dst, &output_boxes, &output_masks, overlay)?;
2706        }
2707        Ok((output_boxes, output_tracks))
2708    }
2709
2710    /// Materialize per-instance segmentation masks from raw prototype data.
2711    ///
2712    /// Computes `mask_coeff @ protos` with sigmoid activation for each detection,
2713    /// producing compact masks at prototype resolution (e.g., 160×160 crops).
2714    /// Mask values are continuous sigmoid confidence outputs quantized to u8
2715    /// (0 = background, 255 = full confidence), NOT binary thresholded.
2716    ///
2717    /// The returned [`Vec<Segmentation>`] can be:
2718    /// - Inspected or exported for analytics, IoU computation, etc.
2719    /// - Passed directly to [`ImageProcessorTrait::draw_decoded_masks`] for
2720    ///   GPU-interpolated rendering.
2721    ///
2722    /// # Performance Note
2723    ///
2724    /// Calling `materialize_masks` + `draw_decoded_masks` separately prevents
2725    /// the HAL from using its internal fused optimization path. For render-only
2726    /// use cases, prefer [`ImageProcessorTrait::draw_proto_masks`] which selects
2727    /// the fastest path automatically (currently 1.6×–27× faster on tested
2728    /// platforms). Use this method when you need access to the intermediate masks.
2729    ///
2730    /// # Errors
2731    ///
2732    /// Returns [`Error::NoConverter`] if the CPU backend is not available.
2733    pub fn materialize_masks(
2734        &mut self,
2735        detect: &[DetectBox],
2736        proto_data: &ProtoData,
2737        letterbox: Option<[f32; 4]>,
2738        resolution: MaskResolution,
2739    ) -> Result<Vec<Segmentation>> {
2740        let cpu = self.cpu.as_mut().ok_or(Error::NoConverter)?;
2741        match resolution {
2742            MaskResolution::Proto => cpu.materialize_segmentations(detect, proto_data, letterbox),
2743            MaskResolution::Scaled { width, height } => {
2744                cpu.materialize_scaled_segmentations(detect, proto_data, letterbox, width, height)
2745            }
2746        }
2747    }
2748}
2749
2750impl ImageProcessorTrait for ImageProcessor {
2751    /// Converts the source image to the destination image format and size. The
2752    /// image is cropped first, then flipped, then rotated
2753    ///
2754    /// Prefer hardware accelerators when available, falling back to CPU if
2755    /// necessary.
2756    fn convert(
2757        &mut self,
2758        src: &TensorDyn,
2759        dst: &mut TensorDyn,
2760        rotation: Rotation,
2761        flip: Flip,
2762        crop: Crop,
2763    ) -> Result<()> {
2764        let start = Instant::now();
2765        let src_fmt = src.format();
2766        let dst_fmt = dst.format();
2767        let _span = tracing::trace_span!(
2768            "image.convert",
2769            ?src_fmt,
2770            ?dst_fmt,
2771            src_memory = ?src.memory(),
2772            dst_memory = ?dst.memory(),
2773            ?rotation,
2774            ?flip,
2775        )
2776        .entered();
2777        log::trace!(
2778            "convert: {src_fmt:?}({:?}/{:?}) → {dst_fmt:?}({:?}/{:?}), \
2779             rotation={rotation:?}, flip={flip:?}, backend={:?}",
2780            src.dtype(),
2781            src.memory(),
2782            dst.dtype(),
2783            dst.memory(),
2784            self.forced_backend,
2785        );
2786
2787        // ── Forced backend: no fallback chain ────────────────────────
2788        if let Some(forced) = self.forced_backend {
2789            return match forced {
2790                ForcedBackend::Cpu => {
2791                    if let Some(cpu) = self.cpu.as_mut() {
2792                        let r = cpu.convert(src, dst, rotation, flip, crop);
2793                        log::trace!(
2794                            "convert: forced=cpu result={} ({:?})",
2795                            if r.is_ok() { "ok" } else { "err" },
2796                            start.elapsed()
2797                        );
2798                        return r;
2799                    }
2800                    Err(Error::ForcedBackendUnavailable("cpu".into()))
2801                }
2802                ForcedBackend::G2d => {
2803                    #[cfg(target_os = "linux")]
2804                    if let Some(g2d) = self.g2d.as_mut() {
2805                        let r = g2d.convert(src, dst, rotation, flip, crop);
2806                        log::trace!(
2807                            "convert: forced=g2d result={} ({:?})",
2808                            if r.is_ok() { "ok" } else { "err" },
2809                            start.elapsed()
2810                        );
2811                        return r;
2812                    }
2813                    Err(Error::ForcedBackendUnavailable("g2d".into()))
2814                }
2815                ForcedBackend::OpenGl => {
2816                    #[cfg(any(
2817                        target_os = "linux",
2818                        target_os = "macos",
2819                        target_os = "ios",
2820                        target_os = "android",
2821                        target_os = "windows"
2822                    ))]
2823                    #[cfg(feature = "opengl")]
2824                    if let Some(opengl) = self.opengl.as_mut() {
2825                        let r = opengl.convert(src, dst, rotation, flip, crop);
2826                        log::trace!(
2827                            "convert: forced=opengl result={} ({:?})",
2828                            if r.is_ok() { "ok" } else { "err" },
2829                            start.elapsed()
2830                        );
2831                        return r;
2832                    }
2833                    Err(Error::ForcedBackendUnavailable("opengl".into()))
2834                }
2835            };
2836        }
2837
2838        // ── Auto fallback chain: OpenGL → G2D → CPU ──────────────────
2839        #[cfg(any(
2840            target_os = "linux",
2841            target_os = "macos",
2842            target_os = "ios",
2843            target_os = "android",
2844            target_os = "windows"
2845        ))]
2846        #[cfg(feature = "opengl")]
2847        if let Some(opengl) = self.opengl.as_mut() {
2848            match opengl.convert(src, dst, rotation, flip, crop) {
2849                Ok(_) => {
2850                    log::trace!(
2851                        "convert: auto selected=opengl for {src_fmt:?}→{dst_fmt:?} ({:?})",
2852                        start.elapsed()
2853                    );
2854                    return Ok(());
2855                }
2856                Err(e) => {
2857                    self.convert_fallbacks
2858                        .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2859                    log::debug!(
2860                        "convert: auto opengl declined {src_fmt:?}@{:?}→{dst_fmt:?}@{:?}, \
2861                         falling back toward G2D/CPU: {e}",
2862                        src.memory(),
2863                        dst.memory(),
2864                    );
2865                }
2866            }
2867        }
2868
2869        #[cfg(target_os = "linux")]
2870        if let Some(g2d) = self.g2d.as_mut() {
2871            // G2D is matrix-only (no range control, no BT.2020). For any
2872            // conversion with a YUV side, resolve that side's colorimetry and
2873            // skip G2D entirely when it cannot be expressed (full-range YUV or
2874            // BT.2020), letting the chain fall through to GL/CPU which honour
2875            // range and BT.2020. YUV→RGB uses the source colorimetry; RGB→YUV
2876            // uses the destination. RGB→RGB has no YUV side and is unaffected.
2877            let src_is_yuv = src.format().is_some_and(|f| f.is_yuv());
2878            let dst_is_yuv = dst.format().is_some_and(|f| f.is_yuv());
2879            let g2d_eligible = if src_is_yuv || dst_is_yuv {
2880                let cm = if src_is_yuv {
2881                    crate::colorimetry::effective_colorimetry(src)
2882                } else {
2883                    crate::colorimetry::effective_colorimetry(dst)
2884                };
2885                crate::g2d::g2d_can_handle(&cm, true)
2886            } else {
2887                true
2888            };
2889            if !g2d_eligible {
2890                log::trace!(
2891                    "convert: auto g2d skipped {src_fmt:?}→{dst_fmt:?} \
2892                     (colorimetry not expressible: full-range/BT.2020)"
2893                );
2894            } else {
2895                match g2d.convert(src, dst, rotation, flip, crop) {
2896                    Ok(_) => {
2897                        log::trace!(
2898                            "convert: auto selected=g2d for {src_fmt:?}→{dst_fmt:?} ({:?})",
2899                            start.elapsed()
2900                        );
2901                        return Ok(());
2902                    }
2903                    Err(e) => {
2904                        log::trace!("convert: auto g2d declined {src_fmt:?}→{dst_fmt:?}: {e}");
2905                    }
2906                }
2907            }
2908        }
2909
2910        if let Some(cpu) = self.cpu.as_mut() {
2911            match cpu.convert(src, dst, rotation, flip, crop) {
2912                Ok(_) => {
2913                    log::trace!(
2914                        "convert: auto selected=cpu for {src_fmt:?}→{dst_fmt:?} ({:?})",
2915                        start.elapsed()
2916                    );
2917                    return Ok(());
2918                }
2919                Err(e) => {
2920                    log::trace!("convert: auto cpu failed {src_fmt:?}→{dst_fmt:?}: {e}");
2921                    return Err(e);
2922                }
2923            }
2924        }
2925        Err(Error::NoConverter)
2926    }
2927
2928    fn convert_deferred(
2929        &mut self,
2930        src: &TensorDyn,
2931        dst: &mut TensorDyn,
2932        rotation: Rotation,
2933        flip: Flip,
2934        crop: Crop,
2935    ) -> Result<()> {
2936        // Deferred batching is an OpenGL optimization (shared parent EGLImage +
2937        // no per-tile glFinish). Route to the GL backend's deferred path when GL
2938        // is forced or auto-selectable; on a GL decline fall back to an eager
2939        // convert (the auto chain), which is correct everywhere — it completes
2940        // synchronously and `flush` stays a no-op for non-GL backends.
2941        #[cfg(any(
2942            target_os = "linux",
2943            target_os = "macos",
2944            target_os = "ios",
2945            target_os = "android",
2946            target_os = "windows"
2947        ))]
2948        #[cfg(feature = "opengl")]
2949        {
2950            let gl_forced = matches!(self.forced_backend, Some(ForcedBackend::OpenGl));
2951            if gl_forced || self.forced_backend.is_none() {
2952                if let Some(opengl) = self.opengl.as_mut() {
2953                    match opengl.convert_deferred(src, dst, rotation, flip, crop) {
2954                        Ok(()) => return Ok(()),
2955                        Err(e) => {
2956                            log::trace!("convert_deferred: gl declined: {e}; eager fallback");
2957                            // A forced-GL caller gets the GL error, matching
2958                            // `convert`'s no-fallback forced-backend contract.
2959                            if gl_forced {
2960                                return Err(e);
2961                            }
2962                        }
2963                    }
2964                }
2965            }
2966        }
2967        self.convert(src, dst, rotation, flip, crop)
2968    }
2969
2970    fn flush(&mut self) -> Result<()> {
2971        let _span = tracing::trace_span!("image.flush").entered();
2972        // Only the OpenGL backend defers; flushing it issues the single GPU
2973        // sync. CPU/G2D converts already completed, so there is nothing to flush.
2974        #[cfg(any(
2975            target_os = "linux",
2976            target_os = "macos",
2977            target_os = "ios",
2978            target_os = "android",
2979            target_os = "windows"
2980        ))]
2981        #[cfg(feature = "opengl")]
2982        if let Some(opengl) = self.opengl.as_mut() {
2983            return opengl.flush();
2984        }
2985        Ok(())
2986    }
2987
2988    fn draw_decoded_masks(
2989        &mut self,
2990        dst: &mut TensorDyn,
2991        detect: &[DetectBox],
2992        segmentation: &[Segmentation],
2993        overlay: MaskOverlay<'_>,
2994    ) -> Result<()> {
2995        let _span = tracing::trace_span!(
2996            "image.draw_decoded_masks",
2997            n_detections = detect.len(),
2998            n_segmentations = segmentation.len(),
2999        )
3000        .entered();
3001        let start = Instant::now();
3002
3003        if let Some(bg) = overlay.background {
3004            if bg.aliases(dst) {
3005                return Err(Error::AliasedBuffers(
3006                    "background must not reference the same buffer as dst".to_string(),
3007                ));
3008            }
3009        }
3010
3011        // Un-letterbox detect boxes and segmentation bboxes for rendering when
3012        // a letterbox was applied to prepare the model input.
3013        let lb_boxes: Vec<DetectBox>;
3014        let lb_segs: Vec<Segmentation>;
3015        let (detect, segmentation) = if let Some(lb) = overlay.letterbox {
3016            lb_boxes = detect.iter().map(|&d| unletter_bbox(d, lb)).collect();
3017            // Keep segmentation bboxes in sync with the transformed detect boxes
3018            // when we have a 1:1 correspondence (instance segmentation).
3019            if segmentation.len() == lb_boxes.len() {
3020                // Only the four bounds change; the mask bytes are copied
3021                // wholesale because `TensorDyn` is not `Clone`. The memcpy is
3022                // what `Array3<u8>::clone()` already paid; `copy_segmentation`
3023                // pins the allocation to Mem so the ALLOCATOR cost matches too
3024                // (see there). Rendering from borrowed masks plus a separate
3025                // bounds list would avoid the copy entirely.
3026                lb_segs = segmentation
3027                    .iter()
3028                    .zip(lb_boxes.iter())
3029                    .map(|(s, d)| copy_segmentation(s, d.bbox.into()))
3030                    .collect::<Result<Vec<_>>>()?;
3031                (lb_boxes.as_slice(), lb_segs.as_slice())
3032            } else {
3033                // No 1:1 correspondence, so the segmentation bounds are left
3034                // as they are -- borrow rather than the copy `to_vec()` used
3035                // to make.
3036                (lb_boxes.as_slice(), segmentation)
3037            }
3038        } else {
3039            (detect, segmentation)
3040        };
3041        #[cfg(target_os = "linux")]
3042        let is_empty_frame = detect.is_empty() && segmentation.is_empty();
3043
3044        // ── Forced backend: no fallback chain ────────────────────────
3045        if let Some(forced) = self.forced_backend {
3046            return match forced {
3047                ForcedBackend::Cpu => {
3048                    if let Some(cpu) = self.cpu.as_mut() {
3049                        return cpu.draw_decoded_masks(dst, detect, segmentation, overlay);
3050                    }
3051                    Err(Error::ForcedBackendUnavailable("cpu".into()))
3052                }
3053                ForcedBackend::G2d => {
3054                    // G2D can only produce empty frames (clear / bg blit).
3055                    // For populated frames it has no rasterizer — fail loudly.
3056                    #[cfg(target_os = "linux")]
3057                    if let Some(g2d) = self.g2d.as_mut() {
3058                        return g2d.draw_decoded_masks(dst, detect, segmentation, overlay);
3059                    }
3060                    Err(Error::ForcedBackendUnavailable("g2d".into()))
3061                }
3062                ForcedBackend::OpenGl => {
3063                    // GL handles background natively via GPU blit, and now
3064                    // actively clears when there is no background.
3065                    #[cfg(any(
3066                        target_os = "linux",
3067                        target_os = "macos",
3068                        target_os = "ios",
3069                        target_os = "android",
3070                        target_os = "windows"
3071                    ))]
3072                    #[cfg(feature = "opengl")]
3073                    if let Some(opengl) = self.opengl.as_mut() {
3074                        return opengl.draw_decoded_masks(dst, detect, segmentation, overlay);
3075                    }
3076                    Err(Error::ForcedBackendUnavailable("opengl".into()))
3077                }
3078            };
3079        }
3080
3081        // ── Auto dispatch ──────────────────────────────────────────
3082        // Empty frames prefer G2D when available — a single g2d_clear or
3083        // g2d_blit is the cheapest HW path to produce the correct output
3084        // and avoids spinning up the GL pipeline every zero-detection
3085        // frame in a triple-buffered display loop.
3086        #[cfg(target_os = "linux")]
3087        if is_empty_frame {
3088            if let Some(g2d) = self.g2d.as_mut() {
3089                match g2d.draw_decoded_masks(dst, detect, segmentation, overlay) {
3090                    Ok(_) => {
3091                        log::trace!(
3092                            "draw_decoded_masks empty frame via g2d in {:?}",
3093                            start.elapsed()
3094                        );
3095                        return Ok(());
3096                    }
3097                    Err(e) => log::trace!("g2d empty-frame path unavailable: {e:?}"),
3098                }
3099            }
3100        }
3101
3102        // Populated frames (or G2D unavailable): GL first, CPU fallback.
3103        // Both backends now own their own base-layer handling (bg blit
3104        // or clear), so we hand the overlay through untouched.
3105        #[cfg(any(
3106            target_os = "linux",
3107            target_os = "macos",
3108            target_os = "ios",
3109            target_os = "android",
3110            target_os = "windows"
3111        ))]
3112        #[cfg(feature = "opengl")]
3113        if let Some(opengl) = self.opengl.as_mut() {
3114            log::trace!(
3115                "draw_decoded_masks started with opengl in {:?}",
3116                start.elapsed()
3117            );
3118            match opengl.draw_decoded_masks(dst, detect, segmentation, overlay) {
3119                Ok(_) => {
3120                    log::trace!("draw_decoded_masks with opengl in {:?}", start.elapsed());
3121                    return Ok(());
3122                }
3123                Err(e) => {
3124                    log::trace!("draw_decoded_masks didn't work with opengl: {e:?}")
3125                }
3126            }
3127        }
3128
3129        log::trace!(
3130            "draw_decoded_masks started with cpu in {:?}",
3131            start.elapsed()
3132        );
3133        if let Some(cpu) = self.cpu.as_mut() {
3134            match cpu.draw_decoded_masks(dst, detect, segmentation, overlay) {
3135                Ok(_) => {
3136                    log::trace!("draw_decoded_masks with cpu in {:?}", start.elapsed());
3137                    return Ok(());
3138                }
3139                Err(e) => {
3140                    log::trace!("draw_decoded_masks didn't work with cpu: {e:?}");
3141                    return Err(e);
3142                }
3143            }
3144        }
3145        Err(Error::NoConverter)
3146    }
3147
3148    fn draw_proto_masks(
3149        &mut self,
3150        dst: &mut TensorDyn,
3151        detect: &[DetectBox],
3152        proto_data: &ProtoData,
3153        overlay: MaskOverlay<'_>,
3154    ) -> Result<()> {
3155        let start = Instant::now();
3156
3157        if let Some(bg) = overlay.background {
3158            if bg.aliases(dst) {
3159                return Err(Error::AliasedBuffers(
3160                    "background must not reference the same buffer as dst".to_string(),
3161                ));
3162            }
3163        }
3164
3165        // Un-letterbox detect boxes for rendering when a letterbox was applied
3166        // to prepare the model input.  The original `detect` coords are still
3167        // passed to `materialize_segmentations` (which needs model-space coords
3168        // to correctly crop the proto tensor) alongside `overlay.letterbox` so
3169        // it can emit `Segmentation` structs in output-image space.
3170        let lb_boxes: Vec<DetectBox>;
3171        let render_detect = if let Some(lb) = overlay.letterbox {
3172            lb_boxes = detect.iter().map(|&d| unletter_bbox(d, lb)).collect();
3173            lb_boxes.as_slice()
3174        } else {
3175            detect
3176        };
3177        #[cfg(target_os = "linux")]
3178        let is_empty_frame = detect.is_empty();
3179
3180        // ── Forced backend: no fallback chain ────────────────────────
3181        if let Some(forced) = self.forced_backend {
3182            return match forced {
3183                ForcedBackend::Cpu => {
3184                    if let Some(cpu) = self.cpu.as_mut() {
3185                        return cpu.draw_proto_masks(dst, render_detect, proto_data, overlay);
3186                    }
3187                    Err(Error::ForcedBackendUnavailable("cpu".into()))
3188                }
3189                ForcedBackend::G2d => {
3190                    #[cfg(target_os = "linux")]
3191                    if let Some(g2d) = self.g2d.as_mut() {
3192                        return g2d.draw_proto_masks(dst, render_detect, proto_data, overlay);
3193                    }
3194                    Err(Error::ForcedBackendUnavailable("g2d".into()))
3195                }
3196                ForcedBackend::OpenGl => {
3197                    #[cfg(any(
3198                        target_os = "linux",
3199                        target_os = "macos",
3200                        target_os = "ios",
3201                        target_os = "android",
3202                        target_os = "windows"
3203                    ))]
3204                    #[cfg(feature = "opengl")]
3205                    if let Some(opengl) = self.opengl.as_mut() {
3206                        return opengl.draw_proto_masks(dst, render_detect, proto_data, overlay);
3207                    }
3208                    Err(Error::ForcedBackendUnavailable("opengl".into()))
3209                }
3210            };
3211        }
3212
3213        // ── Auto dispatch ──────────────────────────────────────────
3214        // Empty frames: prefer G2D — cheapest HW path (clear or bg blit).
3215        #[cfg(target_os = "linux")]
3216        if is_empty_frame {
3217            if let Some(g2d) = self.g2d.as_mut() {
3218                match g2d.draw_proto_masks(dst, render_detect, proto_data, overlay) {
3219                    Ok(_) => {
3220                        log::trace!(
3221                            "draw_proto_masks empty frame via g2d in {:?}",
3222                            start.elapsed()
3223                        );
3224                        return Ok(());
3225                    }
3226                    Err(e) => log::trace!("g2d empty-frame path unavailable: {e:?}"),
3227                }
3228            }
3229        }
3230
3231        // Hybrid path: CPU materialize + GL overlay (benchmarked faster than
3232        // full-GPU draw_proto_masks on all tested platforms: 27× on imx8mp,
3233        // 4× on imx95, 2.5× on rpi5, 1.6× on x86).
3234        // GL owns its own bg-blit / glClear — we pass the overlay through.
3235        //
3236        // CPU materialize needs `&mut` for its MaskScratch buffers; GL also
3237        // needs `&mut`. The CPU borrow is scoped to its block so the
3238        // subsequent GL borrow is free to take over `self`.
3239        #[cfg(any(
3240            target_os = "linux",
3241            target_os = "macos",
3242            target_os = "ios",
3243            target_os = "android",
3244            target_os = "windows"
3245        ))]
3246        #[cfg(feature = "opengl")]
3247        if let (Some(_), Some(_)) = (self.cpu.as_ref(), self.opengl.as_ref()) {
3248            let segmentation = match self.cpu.as_mut() {
3249                Some(cpu) => {
3250                    log::trace!(
3251                        "draw_proto_masks started with hybrid (cpu+opengl) in {:?}",
3252                        start.elapsed()
3253                    );
3254                    cpu.materialize_segmentations(detect, proto_data, overlay.letterbox)?
3255                }
3256                None => unreachable!("cpu presence checked above"),
3257            };
3258            if let Some(opengl) = self.opengl.as_mut() {
3259                match opengl.draw_decoded_masks(dst, render_detect, &segmentation, overlay) {
3260                    Ok(_) => {
3261                        log::trace!(
3262                            "draw_proto_masks with hybrid (cpu+opengl) in {:?}",
3263                            start.elapsed()
3264                        );
3265                        return Ok(());
3266                    }
3267                    Err(e) => {
3268                        log::trace!(
3269                            "draw_proto_masks hybrid path failed, falling back to cpu: {e:?}"
3270                        );
3271                    }
3272                }
3273            }
3274        }
3275
3276        let Some(cpu) = self.cpu.as_mut() else {
3277            return Err(Error::Internal(
3278                "draw_proto_masks requires CPU backend for fallback path".into(),
3279            ));
3280        };
3281        log::trace!("draw_proto_masks started with cpu in {:?}", start.elapsed());
3282        cpu.draw_proto_masks(dst, render_detect, proto_data, overlay)
3283    }
3284
3285    fn set_class_colors(&mut self, colors: &[[u8; 4]]) -> Result<()> {
3286        let start = Instant::now();
3287
3288        // ── Forced backend: no fallback chain ────────────────────────
3289        if let Some(forced) = self.forced_backend {
3290            return match forced {
3291                ForcedBackend::Cpu => {
3292                    if let Some(cpu) = self.cpu.as_mut() {
3293                        return cpu.set_class_colors(colors);
3294                    }
3295                    Err(Error::ForcedBackendUnavailable("cpu".into()))
3296                }
3297                ForcedBackend::G2d => Err(Error::NotSupported(
3298                    "g2d does not support set_class_colors".into(),
3299                )),
3300                ForcedBackend::OpenGl => {
3301                    #[cfg(any(
3302                        target_os = "linux",
3303                        target_os = "macos",
3304                        target_os = "ios",
3305                        target_os = "android",
3306                        target_os = "windows"
3307                    ))]
3308                    #[cfg(feature = "opengl")]
3309                    if let Some(opengl) = self.opengl.as_mut() {
3310                        return opengl.set_class_colors(colors);
3311                    }
3312                    Err(Error::ForcedBackendUnavailable("opengl".into()))
3313                }
3314            };
3315        }
3316
3317        // skip G2D as it doesn't support rendering to image
3318
3319        // Every available backend, not the first that accepts. A palette is
3320        // state a later draw reads, and which backend that draw lands on is
3321        // not known here: GL declines a `view()` destination, an unsupported
3322        // format or an import failure, and the CPU backend takes over. A
3323        // backend that never received the colours would render
3324        // `DEFAULT_COLORS_U8` with nothing to show the caller why.
3325        let mut backends = 0usize;
3326        let mut first_err = None;
3327        #[cfg(any(
3328            target_os = "linux",
3329            target_os = "macos",
3330            target_os = "ios",
3331            target_os = "android",
3332            target_os = "windows"
3333        ))]
3334        #[cfg(feature = "opengl")]
3335        if let Some(opengl) = self.opengl.as_mut() {
3336            backends += 1;
3337            match opengl.set_class_colors(colors) {
3338                Ok(()) => log::trace!("colors set with opengl in {:?}", start.elapsed()),
3339                Err(e) => {
3340                    log::trace!("colors didn't set with opengl: {e:?}");
3341                    first_err.get_or_insert(e);
3342                }
3343            }
3344        }
3345        if let Some(cpu) = self.cpu.as_mut() {
3346            backends += 1;
3347            match cpu.set_class_colors(colors) {
3348                Ok(()) => log::trace!("colors set with cpu in {:?}", start.elapsed()),
3349                Err(e) => {
3350                    log::trace!("colors didn't set with cpu: {e:?}");
3351                    first_err.get_or_insert(e);
3352                }
3353            }
3354        }
3355        // The first error, so a GL failure is not hidden by the CPU backend
3356        // accepting afterwards; the colours the other backends took are kept
3357        // either way, since a partial palette beats a stale one.
3358        match first_err {
3359            Some(e) => Err(e),
3360            None if backends > 0 => Ok(()),
3361            None => Err(Error::NoConverter),
3362        }
3363    }
3364}
3365
3366// ---------------------------------------------------------------------------
3367// Image loading / saving helpers
3368// ---------------------------------------------------------------------------
3369
3370/// Test-only convenience helper that peeks the image header, allocates a
3371/// tensor sized to the image (honoring DMA pitch padding on Linux when
3372/// requested), and decodes via [`edgefirst_codec`]. Mirrors the semantics of
3373/// the removed public `load_image` API for test sites; production callers
3374/// should use the explicit peek → allocate → decode pattern directly.
3375#[cfg(all(test, feature = "codec"))]
3376pub(crate) fn load_image_test_helper(
3377    image: &[u8],
3378    format: Option<PixelFormat>,
3379    memory: Option<TensorMemory>,
3380) -> Result<TensorDyn> {
3381    use edgefirst_codec::{peek_info, ImageDecoder, ImageLoad};
3382
3383    // Peek the source header to get its NATIVE format and dimensions. The
3384    // codec now emits the source's native format (JPEG → Nv12/Grey, PNG →
3385    // Rgb/Rgba/Grey) and configures the destination tensor itself.
3386    let info = peek_info(image)?;
3387    let native_fmt = info.format;
3388    let w = info.width;
3389    let h = info.height;
3390
3391    let mut decoder = ImageDecoder::new();
3392
3393    // Decode into a native-format tensor. The decoder sets the tensor's
3394    // dims+format, so we allocate it sized to the native layout.
3395    #[cfg(target_os = "linux")]
3396    let native_src = {
3397        if let Some(aligned_pitch) = padded_dma_pitch_for(native_fmt, w, &memory) {
3398            let mut dma = Tensor::<u8>::image_with_stride(
3399                w,
3400                h,
3401                native_fmt,
3402                aligned_pitch,
3403                Some(TensorMemory::DmaBuf),
3404                edgefirst_tensor::CpuAccess::ReadWrite,
3405            )?;
3406            dma.load_image(&mut decoder, image)?;
3407            TensorDyn::from(dma)
3408        } else {
3409            let mut img = Tensor::<u8>::image(
3410                w,
3411                h,
3412                native_fmt,
3413                memory,
3414                edgefirst_tensor::CpuAccess::ReadWrite,
3415            )?;
3416            img.load_image(&mut decoder, image)?;
3417            TensorDyn::from(img)
3418        }
3419    };
3420    #[cfg(not(target_os = "linux"))]
3421    let native_src = {
3422        let mut img = Tensor::<u8>::image(
3423            w,
3424            h,
3425            native_fmt,
3426            memory,
3427            edgefirst_tensor::CpuAccess::ReadWrite,
3428        )?;
3429        img.load_image(&mut decoder, image)?;
3430        TensorDyn::from(img)
3431    };
3432
3433    // If the caller requested a different format, convert into it (same
3434    // dims) using a headless CPU-backed processor so the helper works
3435    // without GPU/G2D hardware.
3436    match format {
3437        Some(f) if f != native_fmt => {
3438            let mut dst = TensorDyn::image(
3439                w,
3440                h,
3441                f,
3442                DType::U8,
3443                memory,
3444                edgefirst_tensor::CpuAccess::ReadWrite,
3445            )?;
3446            // `ImageProcessorConfig` has platform-specific fields: on Linux it
3447            // carries extra GL/G2D options so `..Default::default()` is needed,
3448            // but on macOS `backend` is the only field, making the update
3449            // redundant (clippy::needless_update). Allow it for cross-platform
3450            // parity — the alternative (field reassign) trips
3451            // clippy::field_reassign_with_default on Linux instead.
3452            #[allow(clippy::needless_update)]
3453            let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
3454                backend: ComputeBackend::Cpu,
3455                ..Default::default()
3456            })?;
3457            proc.convert(
3458                &native_src,
3459                &mut dst,
3460                Rotation::None,
3461                Flip::None,
3462                Crop::default(),
3463            )?;
3464            Ok(dst)
3465        }
3466        _ => Ok(native_src),
3467    }
3468}
3469
3470/// Save a [`TensorDyn`] image as a JPEG file.
3471///
3472/// Only packed RGB and RGBA formats are supported.
3473pub fn save_jpeg(tensor: &TensorDyn, path: impl AsRef<std::path::Path>, quality: u8) -> Result<()> {
3474    let t = tensor.as_typed::<u8>().ok_or(Error::UnsupportedFormat(
3475        "save_jpeg requires u8 tensor".to_string(),
3476    ))?;
3477    let fmt = t.format().ok_or(Error::NotAnImage)?;
3478    if fmt.layout() != PixelLayout::Packed {
3479        return Err(Error::NotImplemented(
3480            "Saving planar images is not supported".to_string(),
3481        ));
3482    }
3483
3484    let colour = match fmt {
3485        PixelFormat::Rgb => jpeg_encoder::ColorType::Rgb,
3486        PixelFormat::Rgba => jpeg_encoder::ColorType::Rgba,
3487        _ => {
3488            return Err(Error::NotImplemented(
3489                "Unsupported image format for saving".to_string(),
3490            ));
3491        }
3492    };
3493
3494    let w = t.width().ok_or(Error::NotAnImage)?;
3495    let h = t.height().ok_or(Error::NotAnImage)?;
3496    let encoder = jpeg_encoder::Encoder::new_file(path, quality)?;
3497    let tensor_map = t.map_read()?;
3498
3499    encoder.encode(&tensor_map, w as u16, h as u16, colour)?;
3500
3501    Ok(())
3502}
3503
3504pub(crate) struct FunctionTimer<T: Display> {
3505    name: T,
3506    start: std::time::Instant,
3507}
3508
3509impl<T: Display> FunctionTimer<T> {
3510    pub fn new(name: T) -> Self {
3511        Self {
3512            name,
3513            start: std::time::Instant::now(),
3514        }
3515    }
3516}
3517
3518impl<T: Display> Drop for FunctionTimer<T> {
3519    fn drop(&mut self) {
3520        log::trace!("{} elapsed: {:?}", self.name, self.start.elapsed())
3521    }
3522}
3523
3524const DEFAULT_COLORS: [[f32; 4]; 20] = [
3525    [0., 1., 0., 0.7],
3526    [1., 0.5568628, 0., 0.7],
3527    [0.25882353, 0.15294118, 0.13333333, 0.7],
3528    [0.8, 0.7647059, 0.78039216, 0.7],
3529    [0.3137255, 0.3137255, 0.3137255, 0.7],
3530    [0.1411765, 0.3098039, 0.1215686, 0.7],
3531    [1., 0.95686275, 0.5137255, 0.7],
3532    [0.3529412, 0.32156863, 0., 0.7],
3533    [0.4235294, 0.6235294, 0.6509804, 0.7],
3534    [0.5098039, 0.5098039, 0.7294118, 0.7],
3535    [0.00784314, 0.18823529, 0.29411765, 0.7],
3536    [0.0, 0.2706, 1.0, 0.7],
3537    [0.0, 0.0, 0.0, 0.7],
3538    [0.0, 0.5, 0.0, 0.7],
3539    [1.0, 0.0, 0.0, 0.7],
3540    [0.0, 0.0, 1.0, 0.7],
3541    [1.0, 0.5, 0.5, 0.7],
3542    [0.1333, 0.5451, 0.1333, 0.7],
3543    [0.1176, 0.4118, 0.8235, 0.7],
3544    [1., 1., 1., 0.7],
3545];
3546
3547const fn denorm<const M: usize, const N: usize>(a: [[f32; M]; N]) -> [[u8; M]; N] {
3548    let mut result = [[0; M]; N];
3549    let mut i = 0;
3550    while i < N {
3551        let mut j = 0;
3552        while j < M {
3553            result[i][j] = (a[i][j] * 255.0).round() as u8;
3554            j += 1;
3555        }
3556        i += 1;
3557    }
3558    result
3559}
3560
3561const DEFAULT_COLORS_U8: [[u8; 4]; 20] = denorm(DEFAULT_COLORS);
3562
3563#[cfg(test)]
3564#[cfg_attr(coverage_nightly, coverage(off))]
3565mod alignment_tests {
3566    use super::*;
3567
3568    #[test]
3569    fn align_width_rgba8_common_widths() {
3570        // RGBA8 (bpp=4, lcm(64,4)=64, so width must round to multiple of 16 px).
3571        assert_eq!(align_width_for_gpu_pitch(640, 4), 640); // 2560 byte pitch — already aligned
3572        assert_eq!(align_width_for_gpu_pitch(1280, 4), 1280); // 5120
3573        assert_eq!(align_width_for_gpu_pitch(1920, 4), 1920); // 7680
3574        assert_eq!(align_width_for_gpu_pitch(3840, 4), 3840); // 15360
3575                                                              // crowd.png case from the imx95 investigation:
3576        assert_eq!(align_width_for_gpu_pitch(3004, 4), 3008); // 12016 → 12032
3577        assert_eq!(align_width_for_gpu_pitch(3000, 4), 3008); // 12000 → 12032
3578        assert_eq!(align_width_for_gpu_pitch(17, 4), 32); // 68 → 128
3579        assert_eq!(align_width_for_gpu_pitch(1, 4), 16); // 4 → 64
3580    }
3581
3582    #[test]
3583    fn align_width_rgb888_packed() {
3584        // RGB888 (bpp=3, lcm(64,3)=192, so width must round to multiple of 64 px).
3585        assert_eq!(align_width_for_gpu_pitch(64, 3), 64); // 192 byte pitch
3586        assert_eq!(align_width_for_gpu_pitch(640, 3), 640); // 1920
3587        assert_eq!(align_width_for_gpu_pitch(1, 3), 64); // 3 → 192
3588        assert_eq!(align_width_for_gpu_pitch(65, 3), 128); // 195 → 384
3589                                                           // Verify the rounded width × bpp is a clean multiple of the LCM.
3590        for w in [3004usize, 1281, 100, 17] {
3591            let padded = align_width_for_gpu_pitch(w, 3);
3592            assert!(padded >= w);
3593            assert_eq!((padded * 3) % 64, 0);
3594            assert_eq!((padded * 3) % 3, 0);
3595        }
3596    }
3597
3598    #[test]
3599    fn align_width_grey_u8() {
3600        // Grey (bpp=1, lcm(64,1)=64, so width must round to multiple of 64 px).
3601        assert_eq!(align_width_for_gpu_pitch(64, 1), 64);
3602        assert_eq!(align_width_for_gpu_pitch(640, 1), 640);
3603        assert_eq!(align_width_for_gpu_pitch(1, 1), 64);
3604        assert_eq!(align_width_for_gpu_pitch(65, 1), 128);
3605    }
3606
3607    #[test]
3608    fn align_width_zero_inputs() {
3609        assert_eq!(align_width_for_gpu_pitch(0, 4), 0);
3610        assert_eq!(align_width_for_gpu_pitch(640, 0), 640);
3611    }
3612
3613    #[test]
3614    fn align_width_never_returns_smaller_than_input() {
3615        // Spot-check the "returned width >= input width" contract across a
3616        // range of values that would previously have hit `width * bpp`
3617        // overflow paths.
3618        for &bpp in &[1usize, 2, 3, 4, 8] {
3619            for &w in &[
3620                1usize,
3621                17,
3622                64,
3623                65,
3624                100,
3625                1280,
3626                1281,
3627                1920,
3628                3004,
3629                3072,
3630                3840,
3631                usize::MAX / 8,
3632                usize::MAX / 4,
3633                usize::MAX / 2,
3634                usize::MAX - 1,
3635                usize::MAX,
3636            ] {
3637                let aligned = align_width_for_gpu_pitch(w, bpp);
3638                assert!(
3639                    aligned >= w,
3640                    "align_width_for_gpu_pitch({w}, {bpp}) = {aligned} < {w}"
3641                );
3642            }
3643        }
3644    }
3645
3646    #[test]
3647    fn align_width_overflow_returns_unaligned_not_smaller() {
3648        // For width values close to usize::MAX, padding up would wrap. The
3649        // function must return the original width rather than wrapping or
3650        // panicking. A pre-aligned width round-trips unchanged even at the
3651        // extreme.
3652        let aligned_extreme = usize::MAX - 15; // 16-pixel boundary for RGBA8
3653        assert_eq!(
3654            align_width_for_gpu_pitch(aligned_extreme, 4),
3655            aligned_extreme
3656        );
3657        // A misaligned extreme value cannot be rounded up — the function
3658        // returns the original.
3659        let misaligned_extreme = usize::MAX - 1;
3660        let result = align_width_for_gpu_pitch(misaligned_extreme, 4);
3661        assert!(
3662            result == misaligned_extreme || result >= misaligned_extreme,
3663            "extreme misaligned width must not be rounded down to {result}"
3664        );
3665    }
3666
3667    #[test]
3668    fn checked_lcm_basic_and_overflow() {
3669        assert_eq!(checked_num_integer_lcm(64, 4), Some(64));
3670        assert_eq!(checked_num_integer_lcm(64, 3), Some(192));
3671        assert_eq!(checked_num_integer_lcm(64, 1), Some(64));
3672        assert_eq!(checked_num_integer_lcm(0, 4), Some(0));
3673        assert_eq!(checked_num_integer_lcm(64, 0), Some(0));
3674        // Coprime values whose product exceeds usize::MAX must return None.
3675        assert_eq!(
3676            checked_num_integer_lcm(usize::MAX, usize::MAX - 1),
3677            None,
3678            "coprime extreme values must overflow detect, not panic"
3679        );
3680    }
3681
3682    #[test]
3683    fn primary_plane_bpp_known_formats() {
3684        // Packed formats use channels × elem_size.
3685        assert_eq!(primary_plane_bpp(PixelFormat::Rgba, 1), Some(4));
3686        assert_eq!(primary_plane_bpp(PixelFormat::Bgra, 1), Some(4));
3687        assert_eq!(primary_plane_bpp(PixelFormat::Rgb, 1), Some(3));
3688        assert_eq!(primary_plane_bpp(PixelFormat::Grey, 1), Some(1));
3689        // Semi-planar (NV12) reports the luma plane's bpp.
3690        assert_eq!(primary_plane_bpp(PixelFormat::Nv12, 1), Some(1));
3691    }
3692}
3693
3694#[cfg(test)]
3695#[cfg_attr(coverage_nightly, coverage(off))]
3696#[allow(deprecated)]
3697mod image_tests {
3698    use super::*;
3699    use crate::{CPUProcessor, Rotation};
3700    #[cfg(target_os = "linux")]
3701    use edgefirst_tensor::is_dma_available;
3702    use edgefirst_tensor::{TensorMapTrait, TensorMemory, TensorTrait};
3703    use image::buffer::ConvertBuffer;
3704
3705    /// Test helper: call `ImageProcessorTrait::convert()` on two `TensorDyn`s
3706    /// by going through the `TensorDyn` API.
3707    ///
3708    /// Returns the `(src_image, dst_image)` reconstructed from the TensorDyn
3709    /// round-trip so the caller can feed them to `compare_images` etc.
3710    fn convert_img(
3711        proc: &mut dyn ImageProcessorTrait,
3712        src: TensorDyn,
3713        dst: TensorDyn,
3714        rotation: Rotation,
3715        flip: Flip,
3716        crop: Crop,
3717    ) -> (Result<()>, TensorDyn, TensorDyn) {
3718        let src_fourcc = src.format().unwrap();
3719        let dst_fourcc = dst.format().unwrap();
3720        let src_dyn = src;
3721        let mut dst_dyn = dst;
3722        let result = proc.convert(&src_dyn, &mut dst_dyn, rotation, flip, crop);
3723        let src_back = {
3724            let mut __t = src_dyn.into_u8().unwrap();
3725            __t.set_format(src_fourcc).unwrap();
3726            TensorDyn::from(__t)
3727        };
3728        let dst_back = {
3729            let mut __t = dst_dyn.into_u8().unwrap();
3730            __t.set_format(dst_fourcc).unwrap();
3731            TensorDyn::from(__t)
3732        };
3733        (result, src_back, dst_back)
3734    }
3735
3736    #[ctor::ctor(unsafe)]
3737    fn init() {
3738        env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
3739    }
3740
3741    macro_rules! function {
3742        () => {{
3743            fn f() {}
3744            fn type_name_of<T>(_: T) -> &'static str {
3745                std::any::type_name::<T>()
3746            }
3747            let name = type_name_of(f);
3748
3749            // Find and cut the rest of the path
3750            match &name[..name.len() - 3].rfind(':') {
3751                Some(pos) => &name[pos + 1..name.len() - 3],
3752                None => &name[..name.len() - 3],
3753            }
3754        }};
3755    }
3756
3757    /// Master oracle for the view/batch **destination** batch engine: render `N`
3758    /// tiles into row-bands of ONE tall destination and assert each band equals
3759    /// the same source converted standalone — proving correct band placement and
3760    /// that a later tile's letterbox clear / draw never wipes a sibling band. On
3761    /// the Linux GL backend the `N` `convert_deferred` calls share ONE parent
3762    /// EGLImage import (each tile is a `glViewport`/`glScissor` ROI) and sync
3763    /// once at `flush()`; other backends fall back to an eager per-band convert
3764    /// (CPU writes via offset + parent stride). Either way the oracle must hold.
3765    ///
3766    /// Identical source/tile size makes the convert an exact copy, so the
3767    /// assertion is backend-agnostic (no GL-vs-CPU resampling drift). Distinct
3768    /// solid colors per tile make any sibling wipe a hard failure.
3769    #[test]
3770    fn batch_view_dst_tiles_match_standalone() {
3771        let mut proc = match ImageProcessor::new() {
3772            Ok(p) => p,
3773            Err(e) => {
3774                eprintln!(
3775                    "SKIPPED: {} — ImageProcessor init failed ({e:?})",
3776                    function!()
3777                );
3778                return;
3779            }
3780        };
3781        let n = 3usize;
3782        let (w, h) = (32usize, 24usize);
3783        let colors: [[u8; 4]; 3] = [[210, 40, 40, 255], [40, 210, 40, 255], [40, 40, 210, 255]];
3784        let make_src = |c: [u8; 4]| -> TensorDyn {
3785            let bytes: Vec<u8> = c.iter().copied().cycle().take(w * h * 4).collect();
3786            load_bytes_to_tensor(w, h, PixelFormat::Rgba, Some(TensorMemory::Mem), &bytes).unwrap()
3787        };
3788        // Tall destination: N stacked row-bands. DMA so the Linux GL band path
3789        // runs (one parent import + per-tile glViewport); skip if unavailable.
3790        let parent = match TensorDyn::image(
3791            w,
3792            n * h,
3793            PixelFormat::Rgba,
3794            DType::U8,
3795            Some(TensorMemory::DmaBuf),
3796            edgefirst_tensor::CpuAccess::ReadWrite,
3797        ) {
3798            Ok(d) => d,
3799            Err(e) => {
3800                eprintln!(
3801                    "SKIPPED: {} — tall DMA destination alloc failed ({e:?})",
3802                    function!()
3803                );
3804                return;
3805            }
3806        };
3807
3808        // Deferred batch: one parent import, glViewport/scissor per band, one sync.
3809        for (i, &c) in colors.iter().enumerate().take(n) {
3810            let mut tile = parent.view(Region::new(0, i * h, w, h)).unwrap();
3811            proc.convert_deferred(
3812                &make_src(c),
3813                &mut tile,
3814                Rotation::None,
3815                Flip::None,
3816                Crop::no_crop(),
3817            )
3818            .unwrap_or_else(|e| panic!("convert_deferred tile {i}: {e:?}"));
3819        }
3820        proc.flush().unwrap();
3821
3822        for (i, &c) in colors.iter().enumerate().take(n) {
3823            // Standalone full-buffer convert of the same source = the oracle.
3824            let mut solo = TensorDyn::image(
3825                w,
3826                h,
3827                PixelFormat::Rgba,
3828                DType::U8,
3829                Some(TensorMemory::DmaBuf),
3830                edgefirst_tensor::CpuAccess::ReadWrite,
3831            )
3832            .unwrap();
3833            proc.convert(
3834                &make_src(c),
3835                &mut solo,
3836                Rotation::None,
3837                Flip::None,
3838                Crop::no_crop(),
3839            )
3840            .unwrap();
3841
3842            let band = parent.view(Region::new(0, i * h, w, h)).unwrap();
3843            let band_bytes = band
3844                .as_typed::<u8>()
3845                .unwrap()
3846                .map()
3847                .unwrap()
3848                .as_slice()
3849                .to_vec();
3850            let solo_bytes = solo
3851                .as_typed::<u8>()
3852                .unwrap()
3853                .map()
3854                .unwrap()
3855                .as_slice()
3856                .to_vec();
3857            assert_eq!(
3858                band_bytes, solo_bytes,
3859                "tile {i}: band differs from standalone convert (placement or sibling wipe)"
3860            );
3861            assert!(
3862                band_bytes.as_chunks::<4>().0.iter().all(|&p| p == c),
3863                "tile {i}: band is not the expected solid color {c:?} (sibling wipe?)"
3864            );
3865        }
3866    }
3867
3868    /// Build a `w`×`h` RGBA frame whose pixels encode their position, so a crop
3869    /// at any origin has distinct content (catches crop-origin / band-placement
3870    /// bugs a solid color cannot).
3871    #[cfg(test)]
3872    fn gradient_frame(w: usize, h: usize) -> TensorDyn {
3873        let mut bytes = vec![0u8; w * h * 4];
3874        for y in 0..h {
3875            for x in 0..w {
3876                let i = (y * w + x) * 4;
3877                bytes[i] = x as u8;
3878                bytes[i + 1] = y as u8;
3879                bytes[i + 2] = (x ^ y) as u8;
3880                bytes[i + 3] = 255;
3881            }
3882        }
3883        load_bytes_to_tensor(w, h, PixelFormat::Rgba, Some(TensorMemory::Mem), &bytes).unwrap()
3884    }
3885
3886    /// `tile_into` band == standalone crop-convert of the same source region,
3887    /// on the CPU backend (runs on CI without a GPU). Distinct gradient content
3888    /// makes a wrong crop origin or a sibling-band wipe a hard failure.
3889    #[test]
3890    fn tile_into_cpu_distinct_content_parity() {
3891        let mut proc = match ImageProcessor::with_config(ImageProcessorConfig {
3892            backend: ComputeBackend::Cpu,
3893            ..Default::default()
3894        }) {
3895            Ok(p) => p,
3896            Err(e) => {
3897                eprintln!("SKIPPED: {} — CPU init failed ({e:?})", function!());
3898                return;
3899            }
3900        };
3901        let (fw, fh) = (96usize, 64usize);
3902        let src = gradient_frame(fw, fh);
3903        let cfg = TilingConfig::new(32, 32).with_overlap(0.0); // exact 3×2 tiling
3904        let n = tile_grid(fh, fw, 32, 32, 0.0).len();
3905        assert_eq!(n, 6);
3906
3907        let mut parent = proc
3908            .alloc_tile_batch(
3909                n,
3910                &cfg,
3911                PixelFormat::Rgba,
3912                DType::U8,
3913                Some(TensorMemory::Mem),
3914                edgefirst_tensor::CpuAccess::ReadWrite,
3915            )
3916            .unwrap();
3917        let placements = proc.tile_into(&src, &mut parent, &cfg).unwrap();
3918        assert_eq!(placements.len(), n);
3919
3920        for p in &placements {
3921            let source = Region::new(
3922                p.origin.0 as usize,
3923                p.origin.1 as usize,
3924                p.crop_size.0 as usize,
3925                p.crop_size.1 as usize,
3926            );
3927            let mut solo = TensorDyn::image(
3928                32,
3929                32,
3930                PixelFormat::Rgba,
3931                DType::U8,
3932                Some(TensorMemory::Mem),
3933                edgefirst_tensor::CpuAccess::ReadWrite,
3934            )
3935            .unwrap();
3936            proc.convert(
3937                &src,
3938                &mut solo,
3939                Rotation::None,
3940                Flip::None,
3941                Crop::default()
3942                    .with_source(Some(source))
3943                    .with_fit(Fit::Stretch),
3944            )
3945            .unwrap();
3946            let band = parent.view(Region::new(0, p.index * 32, 32, 32)).unwrap();
3947            let band_bytes = band
3948                .as_typed::<u8>()
3949                .unwrap()
3950                .map()
3951                .unwrap()
3952                .as_slice()
3953                .to_vec();
3954            let solo_bytes = solo
3955                .as_typed::<u8>()
3956                .unwrap()
3957                .map()
3958                .unwrap()
3959                .as_slice()
3960                .to_vec();
3961            assert_eq!(
3962                band_bytes, solo_bytes,
3963                "tile {} band differs from standalone crop-convert",
3964                p.index
3965            );
3966        }
3967    }
3968
3969    /// Streaming `tile_one` into a single slot == the corresponding batched
3970    /// `tile_into` band (proves the two paths agree).
3971    #[test]
3972    fn tile_one_matches_tile_into_band() {
3973        let mut proc = match ImageProcessor::with_config(ImageProcessorConfig {
3974            backend: ComputeBackend::Cpu,
3975            ..Default::default()
3976        }) {
3977            Ok(p) => p,
3978            Err(e) => {
3979                eprintln!("SKIPPED: {} — CPU init failed ({e:?})", function!());
3980                return;
3981            }
3982        };
3983        let (fw, fh) = (96usize, 64usize);
3984        let src = gradient_frame(fw, fh);
3985        let cfg = TilingConfig::new(32, 32).with_overlap(0.0);
3986        let n = tile_grid(fh, fw, 32, 32, 0.0).len();
3987
3988        let mut parent = proc
3989            .alloc_tile_batch(
3990                n,
3991                &cfg,
3992                PixelFormat::Rgba,
3993                DType::U8,
3994                Some(TensorMemory::Mem),
3995                edgefirst_tensor::CpuAccess::ReadWrite,
3996            )
3997            .unwrap();
3998        proc.tile_into(&src, &mut parent, &cfg).unwrap();
3999
4000        let plan = proc.plan_tiles(fw, fh, &cfg).unwrap();
4001        for p in &plan {
4002            let mut slot = TensorDyn::image(
4003                32,
4004                32,
4005                PixelFormat::Rgba,
4006                DType::U8,
4007                Some(TensorMemory::Mem),
4008                edgefirst_tensor::CpuAccess::ReadWrite,
4009            )
4010            .unwrap();
4011            proc.tile_one(&src, &mut slot, p, &cfg).unwrap();
4012            proc.flush().unwrap();
4013            let band = parent.view(Region::new(0, p.index * 32, 32, 32)).unwrap();
4014            let slot_bytes = slot
4015                .as_typed::<u8>()
4016                .unwrap()
4017                .map()
4018                .unwrap()
4019                .as_slice()
4020                .to_vec();
4021            let band_bytes = band
4022                .as_typed::<u8>()
4023                .unwrap()
4024                .map()
4025                .unwrap()
4026                .as_slice()
4027                .to_vec();
4028            assert_eq!(
4029                slot_bytes, band_bytes,
4030                "tile {} stream != batch band",
4031                p.index
4032            );
4033        }
4034    }
4035
4036    /// `plan_tiles` returns correct per-tile metadata for a 4K frame (pure, no
4037    /// GPU render).
4038    /// `tile_into` through the auto backend with a DMA parent — exercises the
4039    /// GL single-import band path on a GPU machine (skips when DMA/GL is
4040    /// unavailable). The source is sampled via `Crop::with_source`, never
4041    /// `src.view()`, so all tiles share one source import.
4042    #[test]
4043    fn tile_into_auto_dma_parity() {
4044        let mut proc = match ImageProcessor::new() {
4045            Ok(p) => p,
4046            Err(e) => {
4047                eprintln!(
4048                    "SKIPPED: {} — ImageProcessor init failed ({e:?})",
4049                    function!()
4050                );
4051                return;
4052            }
4053        };
4054        let (fw, fh) = (96usize, 64usize);
4055        let src = gradient_frame(fw, fh);
4056        let cfg = TilingConfig::new(32, 32).with_overlap(0.0);
4057        let n = tile_grid(fh, fw, 32, 32, 0.0).len();
4058
4059        let mut parent = match proc.alloc_tile_batch(
4060            n,
4061            &cfg,
4062            PixelFormat::Rgba,
4063            DType::U8,
4064            Some(TensorMemory::DmaBuf),
4065            edgefirst_tensor::CpuAccess::ReadWrite,
4066        ) {
4067            Ok(p) => p,
4068            Err(e) => {
4069                eprintln!(
4070                    "SKIPPED: {} — tall DMA parent alloc failed ({e:?})",
4071                    function!()
4072                );
4073                return;
4074            }
4075        };
4076        let placements = proc.tile_into(&src, &mut parent, &cfg).unwrap();
4077
4078        for p in &placements {
4079            let source = Region::new(
4080                p.origin.0 as usize,
4081                p.origin.1 as usize,
4082                p.crop_size.0 as usize,
4083                p.crop_size.1 as usize,
4084            );
4085            let mut solo = TensorDyn::image(
4086                32,
4087                32,
4088                PixelFormat::Rgba,
4089                DType::U8,
4090                Some(TensorMemory::DmaBuf),
4091                edgefirst_tensor::CpuAccess::ReadWrite,
4092            )
4093            .unwrap();
4094            proc.convert(
4095                &src,
4096                &mut solo,
4097                Rotation::None,
4098                Flip::None,
4099                Crop::default()
4100                    .with_source(Some(source))
4101                    .with_fit(Fit::Stretch),
4102            )
4103            .unwrap();
4104            let band = parent.view(Region::new(0, p.index * 32, 32, 32)).unwrap();
4105            // Structural-similarity tolerance (the house GPU-parity bar) rather
4106            // than exact bytes: rendering a tile into a viewport band at a
4107            // non-zero offset vs. a standalone origin render diverges by sampling
4108            // rounding on virtualized GPUs (paravirtual Metal/ANGLE on macOS CI),
4109            // the same tolerance class as the other cross-backend parity tests.
4110            compare_images(
4111                &band,
4112                &solo,
4113                0.98,
4114                &format!("{}_tile{}", function!(), p.index),
4115            );
4116        }
4117    }
4118
4119    /// `tile_into` rejects a destination too small to hold all tile bands.
4120    #[test]
4121    fn tile_into_undersized_dst_errors() {
4122        let mut proc = match ImageProcessor::with_config(ImageProcessorConfig {
4123            backend: ComputeBackend::Cpu,
4124            ..Default::default()
4125        }) {
4126            Ok(p) => p,
4127            Err(e) => {
4128                eprintln!("SKIPPED: {} — CPU init failed ({e:?})", function!());
4129                return;
4130            }
4131        };
4132        let (fw, fh) = (96usize, 64usize);
4133        let src = gradient_frame(fw, fh);
4134        let cfg = TilingConfig::new(32, 32).with_overlap(0.0); // 6 tiles
4135                                                               // Allocate a parent for only 2 bands, far short of 6.
4136        let mut small = TensorDyn::image(
4137            32,
4138            2 * 32,
4139            PixelFormat::Rgba,
4140            DType::U8,
4141            Some(TensorMemory::Mem),
4142            edgefirst_tensor::CpuAccess::ReadWrite,
4143        )
4144        .unwrap();
4145        let r = proc.tile_into(&src, &mut small, &cfg);
4146        assert!(
4147            matches!(r, Err(Error::InvalidShape(_))),
4148            "expected InvalidShape, got {r:?}"
4149        );
4150    }
4151
4152    /// `alloc_tile_batch` / `plan_tiles` reject an invalid config (zero tile).
4153    #[test]
4154    fn tiling_alloc_rejects_invalid_config() {
4155        let proc = match ImageProcessor::with_config(ImageProcessorConfig {
4156            backend: ComputeBackend::Cpu,
4157            ..Default::default()
4158        }) {
4159            Ok(p) => p,
4160            Err(e) => {
4161                eprintln!("SKIPPED: {} — CPU init failed ({e:?})", function!());
4162                return;
4163            }
4164        };
4165        let bad = TilingConfig::new(0, 640);
4166        assert!(proc.plan_tiles(1920, 1080, &bad).is_err());
4167        assert!(proc
4168            .alloc_tile_batch(
4169                4,
4170                &bad,
4171                PixelFormat::Rgba,
4172                DType::U8,
4173                Some(TensorMemory::Mem),
4174                edgefirst_tensor::CpuAccess::ReadWrite,
4175            )
4176            .is_err());
4177    }
4178
4179    #[test]
4180    fn plan_tiles_metadata_4k() {
4181        let proc = match ImageProcessor::with_config(ImageProcessorConfig {
4182            backend: ComputeBackend::Cpu,
4183            ..Default::default()
4184        }) {
4185            Ok(p) => p,
4186            Err(e) => {
4187                eprintln!("SKIPPED: {} — CPU init failed ({e:?})", function!());
4188                return;
4189            }
4190        };
4191        let cfg = TilingConfig::new(640, 640).with_overlap(0.2);
4192        let plan = proc.plan_tiles(3840, 2160, &cfg).unwrap();
4193        assert_eq!(plan.len(), 32);
4194        assert!(plan.iter().all(|p| p.count == 32));
4195        assert!(plan.iter().all(|p| p.crop_size == (640.0, 640.0)));
4196        assert!(plan.iter().all(|p| p.letterbox.is_none())); // stretch fit
4197        assert!(plan.iter().all(|p| p.frame_dims == (3840.0, 2160.0)));
4198        assert_eq!(plan[0].origin, (0.0, 0.0));
4199    }
4200
4201    #[test]
4202    fn test_invalid_crop() {
4203        let src = TensorDyn::image(
4204            100,
4205            100,
4206            PixelFormat::Rgb,
4207            DType::U8,
4208            None,
4209            edgefirst_tensor::CpuAccess::ReadWrite,
4210        )
4211        .unwrap();
4212        let dst = TensorDyn::image(
4213            100,
4214            100,
4215            PixelFormat::Rgb,
4216            DType::U8,
4217            None,
4218            edgefirst_tensor::CpuAccess::ReadWrite,
4219        )
4220        .unwrap();
4221
4222        // A source crop exceeding the source bounds is rejected.
4223        let crop = Crop::new().with_source(Some(Region::new(50, 50, 60, 60)));
4224        assert!(matches!(
4225            crop.check_crop_dyn(&src, &dst),
4226            Err(Error::CropInvalid(_))
4227        ));
4228
4229        // A source crop within bounds is valid.
4230        let crop = Crop::new().with_source(Some(Region::new(0, 0, 10, 10)));
4231        assert!(crop.check_crop_dyn(&src, &dst).is_ok());
4232
4233        // Letterbox is always valid — placement is computed within the dst.
4234        assert!(Crop::letterbox([0, 0, 0, 255])
4235            .check_crop_dyn(&src, &dst)
4236            .is_ok());
4237    }
4238
4239    #[test]
4240    fn test_invalid_tensor_format() -> Result<(), Error> {
4241        // 4D tensor cannot be set to a 3-channel pixel format
4242        let mut tensor = Tensor::<u8>::new(&[720, 1280, 4, 1], None, None)?;
4243        let result = tensor.set_format(PixelFormat::Rgb);
4244        assert!(result.is_err(), "4D tensor should reject set_format");
4245
4246        // Tensor with wrong channel count for the format
4247        let mut tensor = Tensor::<u8>::new(&[720, 1280, 4], None, None)?;
4248        let result = tensor.set_format(PixelFormat::Rgb);
4249        assert!(result.is_err(), "4-channel tensor should reject RGB format");
4250
4251        Ok(())
4252    }
4253
4254    #[test]
4255    fn test_invalid_image_file() -> Result<(), Error> {
4256        let result = crate::load_image_test_helper(&[123; 5000], None, None);
4257        assert!(
4258            matches!(result, Err(Error::Codec(_))),
4259            "unrecognised bytes should surface as Error::Codec, got {result:?}"
4260        );
4261        Ok(())
4262    }
4263
4264    #[test]
4265    fn test_invalid_jpeg_format() -> Result<(), Error> {
4266        let result = crate::load_image_test_helper(&[123; 5000], Some(PixelFormat::Yuyv), None);
4267        // YUYV is not a valid decode target; peek_info fails before the magic-
4268        // bytes check, so the precise variant depends on which error fires first.
4269        assert!(
4270            matches!(result, Err(Error::Codec(_))),
4271            "Yuyv target with garbage bytes should surface as Error::Codec, got {result:?}"
4272        );
4273        Ok(())
4274    }
4275
4276    #[test]
4277    fn test_load_resize_save() {
4278        let file = edgefirst_bench::testdata::read("zidane.jpg");
4279        let img = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4280        assert_eq!(img.width(), Some(1280));
4281        assert_eq!(img.height(), Some(720));
4282
4283        let dst = TensorDyn::image(
4284            640,
4285            360,
4286            PixelFormat::Rgba,
4287            DType::U8,
4288            None,
4289            edgefirst_tensor::CpuAccess::ReadWrite,
4290        )
4291        .unwrap();
4292        let mut converter = CPUProcessor::new();
4293        let (result, _img, dst) = convert_img(
4294            &mut converter,
4295            img,
4296            dst,
4297            Rotation::None,
4298            Flip::None,
4299            Crop::no_crop(),
4300        );
4301        result.unwrap();
4302        assert_eq!(dst.width(), Some(640));
4303        assert_eq!(dst.height(), Some(360));
4304
4305        crate::save_jpeg(&dst, "zidane_resized.jpg", 80).unwrap();
4306
4307        let file = std::fs::read("zidane_resized.jpg").unwrap();
4308        // With `format: None` the helper returns the source's native format.
4309        // The codec now decodes colour JPEGs to NV12 (was RGB previously).
4310        let img = crate::load_image_test_helper(&file, None, None).unwrap();
4311        assert_eq!(img.width(), Some(640));
4312        assert_eq!(img.height(), Some(360));
4313        assert_eq!(img.format().unwrap(), PixelFormat::Nv12);
4314    }
4315
4316    #[test]
4317    fn test_from_tensor_planar() -> Result<(), Error> {
4318        let mut tensor = Tensor::new(&[3, 720, 1280], None, None)?;
4319        tensor
4320            .map()?
4321            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.8bps"));
4322        let planar = {
4323            tensor
4324                .set_format(PixelFormat::PlanarRgb)
4325                .map_err(|e| crate::Error::Internal(e.to_string()))?;
4326            TensorDyn::from(tensor)
4327        };
4328
4329        let rbga = load_bytes_to_tensor(
4330            1280,
4331            720,
4332            PixelFormat::Rgba,
4333            None,
4334            &edgefirst_bench::testdata::read("camera720p.rgba"),
4335        )?;
4336        compare_images_convert_to_rgb(&planar, &rbga, 0.98, function!());
4337
4338        Ok(())
4339    }
4340
4341    #[test]
4342    fn test_from_tensor_invalid_format() {
4343        // PixelFormat::from_fourcc_str returns None for unknown FourCC codes.
4344        // Since there's no "TEST" pixel format, this validates graceful handling.
4345        assert!(PixelFormat::from_fourcc(u32::from_le_bytes(*b"TEST")).is_none());
4346    }
4347
4348    #[test]
4349    #[should_panic(expected = "Failed to save planar RGB image")]
4350    fn test_save_planar() {
4351        let planar_img = load_bytes_to_tensor(
4352            1280,
4353            720,
4354            PixelFormat::PlanarRgb,
4355            None,
4356            &edgefirst_bench::testdata::read("camera720p.8bps"),
4357        )
4358        .unwrap();
4359
4360        let save_path = "/tmp/planar_rgb.jpg";
4361        crate::save_jpeg(&planar_img, save_path, 90).expect("Failed to save planar RGB image");
4362    }
4363
4364    #[test]
4365    #[should_panic(expected = "Failed to save YUYV image")]
4366    fn test_save_yuyv() {
4367        let planar_img = load_bytes_to_tensor(
4368            1280,
4369            720,
4370            PixelFormat::Yuyv,
4371            None,
4372            &edgefirst_bench::testdata::read("camera720p.yuyv"),
4373        )
4374        .unwrap();
4375
4376        let save_path = "/tmp/yuyv.jpg";
4377        crate::save_jpeg(&planar_img, save_path, 90).expect("Failed to save YUYV image");
4378    }
4379
4380    #[test]
4381    fn test_rotation_angle() {
4382        assert_eq!(Rotation::from_degrees_clockwise(0), Rotation::None);
4383        assert_eq!(Rotation::from_degrees_clockwise(90), Rotation::Clockwise90);
4384        assert_eq!(Rotation::from_degrees_clockwise(180), Rotation::Rotate180);
4385        assert_eq!(
4386            Rotation::from_degrees_clockwise(270),
4387            Rotation::CounterClockwise90
4388        );
4389        assert_eq!(Rotation::from_degrees_clockwise(360), Rotation::None);
4390        assert_eq!(Rotation::from_degrees_clockwise(450), Rotation::Clockwise90);
4391        assert_eq!(Rotation::from_degrees_clockwise(540), Rotation::Rotate180);
4392        assert_eq!(
4393            Rotation::from_degrees_clockwise(630),
4394            Rotation::CounterClockwise90
4395        );
4396    }
4397
4398    #[test]
4399    #[should_panic(expected = "rotation angle is not a multiple of 90")]
4400    fn test_rotation_angle_panic() {
4401        Rotation::from_degrees_clockwise(361);
4402    }
4403
4404    #[test]
4405    fn test_disable_env_var() -> Result<(), Error> {
4406        // Acquire the env-var mutex for the entire test body so we never race
4407        // with test_force_backend_* or test_draw_proto_masks_no_cpu_returns_error.
4408        let _lock = acquire_env_lock();
4409
4410        // Snapshot ALL env vars we might touch so the RAII guard restores them
4411        // on exit (even on panic), preventing env-var poisoning of other tests.
4412        let _guard = EnvGuard::snapshot(&[
4413            "EDGEFIRST_FORCE_BACKEND",
4414            "EDGEFIRST_DISABLE_GL",
4415            "EDGEFIRST_DISABLE_G2D",
4416            "EDGEFIRST_DISABLE_CPU",
4417        ]);
4418
4419        // EDGEFIRST_FORCE_BACKEND takes precedence over EDGEFIRST_DISABLE_*,
4420        // so clear it for the duration of this test.
4421        unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") };
4422
4423        #[cfg(target_os = "linux")]
4424        {
4425            unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
4426            let converter = ImageProcessor::new()?;
4427            assert!(converter.g2d.is_none());
4428            unsafe { std::env::remove_var("EDGEFIRST_DISABLE_G2D") };
4429        }
4430
4431        #[cfg(any(target_os = "linux", target_os = "windows"))]
4432        #[cfg(feature = "opengl")]
4433        {
4434            unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
4435            let converter = ImageProcessor::new()?;
4436            assert!(converter.opengl.is_none());
4437            unsafe { std::env::remove_var("EDGEFIRST_DISABLE_GL") };
4438        }
4439
4440        unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
4441        let converter = ImageProcessor::new()?;
4442        assert!(converter.cpu.is_none());
4443        unsafe { std::env::remove_var("EDGEFIRST_DISABLE_CPU") };
4444
4445        // Disable everything — convert must return NoConverter.
4446        unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
4447        unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
4448        unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
4449        let mut converter = ImageProcessor::new()?;
4450
4451        let src = TensorDyn::image(
4452            1280,
4453            720,
4454            PixelFormat::Rgba,
4455            DType::U8,
4456            None,
4457            edgefirst_tensor::CpuAccess::ReadWrite,
4458        )?;
4459        let dst = TensorDyn::image(
4460            640,
4461            360,
4462            PixelFormat::Rgba,
4463            DType::U8,
4464            None,
4465            edgefirst_tensor::CpuAccess::ReadWrite,
4466        )?;
4467        let (result, _src, _dst) = convert_img(
4468            &mut converter,
4469            src,
4470            dst,
4471            Rotation::None,
4472            Flip::None,
4473            Crop::no_crop(),
4474        );
4475        assert!(matches!(result, Err(Error::NoConverter)));
4476        // _guard restores all env vars on drop.
4477        Ok(())
4478    }
4479
4480    #[test]
4481    fn test_unsupported_conversion() {
4482        let src = TensorDyn::image(
4483            1280,
4484            720,
4485            PixelFormat::Nv12,
4486            DType::U8,
4487            None,
4488            edgefirst_tensor::CpuAccess::ReadWrite,
4489        )
4490        .unwrap();
4491        let dst = TensorDyn::image(
4492            640,
4493            360,
4494            PixelFormat::Nv12,
4495            DType::U8,
4496            None,
4497            edgefirst_tensor::CpuAccess::ReadWrite,
4498        )
4499        .unwrap();
4500        let mut converter = ImageProcessor::new().unwrap();
4501        let (result, _src, _dst) = convert_img(
4502            &mut converter,
4503            src,
4504            dst,
4505            Rotation::None,
4506            Flip::None,
4507            Crop::no_crop(),
4508        );
4509        log::debug!("result: {:?}", result);
4510        assert!(matches!(
4511            result,
4512            Err(Error::NotSupported(e)) if e.starts_with("Conversion from NV12 to NV12")
4513        ));
4514    }
4515
4516    #[test]
4517    fn test_load_grey() {
4518        // A single-component (greyscale) JPEG decodes to its native GREY
4519        // format, which has no even-dimension constraint, so the 1024×681
4520        // `grey.jpg` loads and converts to RGBA successfully.
4521        let grey_img = crate::load_image_test_helper(
4522            &edgefirst_bench::testdata::read("grey.jpg"),
4523            Some(PixelFormat::Rgba),
4524            None,
4525        )
4526        .unwrap();
4527        assert_eq!(grey_img.width(), Some(1024));
4528        assert_eq!(grey_img.height(), Some(681));
4529
4530        // `grey-rgb.jpg` holds the same grey content but is encoded as a
4531        // 3-component (colour) JPEG, so the codec decodes it to native NV12.
4532        // Its 1024×681 dimensions have an odd height; NV12 now represents odd
4533        // dimensions via the `H + ceil(H/2)` combined-plane height, so the
4534        // decode succeeds and converts to RGBA at the true dimensions.
4535        let grey_but_rgb = crate::load_image_test_helper(
4536            &edgefirst_bench::testdata::read("grey-rgb.jpg"),
4537            Some(PixelFormat::Rgba),
4538            None,
4539        )
4540        .expect("odd-height colour JPEG should decode to NV12 and convert to RGBA");
4541        assert_eq!(grey_but_rgb.width(), Some(1024));
4542        assert_eq!(grey_but_rgb.height(), Some(681));
4543    }
4544
4545    #[test]
4546    fn test_new_nv12() {
4547        let nv12 = TensorDyn::image(
4548            1280,
4549            720,
4550            PixelFormat::Nv12,
4551            DType::U8,
4552            None,
4553            edgefirst_tensor::CpuAccess::ReadWrite,
4554        )
4555        .unwrap();
4556        assert_eq!(nv12.height(), Some(720));
4557        assert_eq!(nv12.width(), Some(1280));
4558        assert_eq!(nv12.format().unwrap(), PixelFormat::Nv12);
4559        // PixelFormat::Nv12.channels() returns 1 (luma plane channel count)
4560        assert_eq!(nv12.format().unwrap().channels(), 1);
4561        assert!(nv12.format().is_some_and(
4562            |f| f.layout() == PixelLayout::Planar || f.layout() == PixelLayout::SemiPlanar
4563        ))
4564    }
4565
4566    #[test]
4567    #[cfg(any(target_os = "linux", target_os = "windows"))]
4568    fn test_new_image_converter() {
4569        let dst_width = 640;
4570        let dst_height = 360;
4571        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4572        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4573
4574        let mut converter = ImageProcessor::new().unwrap();
4575        let converter_dst = converter
4576            .create_image(
4577                dst_width,
4578                dst_height,
4579                PixelFormat::Rgba,
4580                DType::U8,
4581                None,
4582                edgefirst_tensor::CpuAccess::ReadWrite,
4583            )
4584            .unwrap();
4585        let (result, src, converter_dst) = convert_img(
4586            &mut converter,
4587            src,
4588            converter_dst,
4589            Rotation::None,
4590            Flip::None,
4591            Crop::no_crop(),
4592        );
4593        result.unwrap();
4594
4595        let cpu_dst = TensorDyn::image(
4596            dst_width,
4597            dst_height,
4598            PixelFormat::Rgba,
4599            DType::U8,
4600            None,
4601            edgefirst_tensor::CpuAccess::ReadWrite,
4602        )
4603        .unwrap();
4604        let mut cpu_converter = CPUProcessor::new();
4605        let (result, _src, cpu_dst) = convert_img(
4606            &mut cpu_converter,
4607            src,
4608            cpu_dst,
4609            Rotation::None,
4610            Flip::None,
4611            Crop::no_crop(),
4612        );
4613        result.unwrap();
4614
4615        compare_images(&converter_dst, &cpu_dst, 0.98, function!());
4616    }
4617
4618    #[test]
4619    #[cfg(any(target_os = "linux", target_os = "windows"))]
4620    fn test_create_image_dtype_i8() {
4621        let mut converter = ImageProcessor::new().unwrap();
4622
4623        // I8 image should allocate successfully via create_image
4624        let dst = converter
4625            .create_image(
4626                320,
4627                240,
4628                PixelFormat::Rgb,
4629                DType::I8,
4630                None,
4631                edgefirst_tensor::CpuAccess::ReadWrite,
4632            )
4633            .unwrap();
4634        assert_eq!(dst.dtype(), DType::I8);
4635        assert!(dst.width() == Some(320));
4636        assert!(dst.height() == Some(240));
4637        assert_eq!(dst.format(), Some(PixelFormat::Rgb));
4638
4639        // U8 for comparison
4640        let dst_u8 = converter
4641            .create_image(
4642                320,
4643                240,
4644                PixelFormat::Rgb,
4645                DType::U8,
4646                None,
4647                edgefirst_tensor::CpuAccess::ReadWrite,
4648            )
4649            .unwrap();
4650        assert_eq!(dst_u8.dtype(), DType::U8);
4651
4652        // Convert into I8 dst should succeed
4653        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4654        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4655        let mut dst_i8 = converter
4656            .create_image(
4657                320,
4658                240,
4659                PixelFormat::Rgb,
4660                DType::I8,
4661                None,
4662                edgefirst_tensor::CpuAccess::ReadWrite,
4663            )
4664            .unwrap();
4665        converter
4666            .convert(
4667                &src,
4668                &mut dst_i8,
4669                Rotation::None,
4670                Flip::None,
4671                Crop::no_crop(),
4672            )
4673            .unwrap();
4674    }
4675
4676    #[test]
4677    #[cfg(target_os = "linux")]
4678    fn test_create_image_nv12_dma_non_aligned_width() {
4679        // create_image is fully stride-aware: a non-64-aligned NV12 DMA tensor
4680        // may legitimately carry a GPU-pitch-padded row stride — that is the
4681        // intended behaviour, not a bug. Verify the logical geometry is preserved
4682        // for any width and that a reported stride is a valid (>= logical)
4683        // padding, rather than asserting the absence of a stride.
4684        let converter = ImageProcessor::new().unwrap();
4685
4686        // 100 is intentionally not a multiple of 64 (the GPU pitch alignment).
4687        let result = converter.create_image(
4688            100,
4689            64,
4690            PixelFormat::Nv12,
4691            DType::U8,
4692            Some(TensorMemory::DmaBuf),
4693            edgefirst_tensor::CpuAccess::ReadWrite,
4694        );
4695
4696        match result {
4697            Ok(img) => {
4698                assert_eq!(img.width(), Some(100));
4699                assert_eq!(img.height(), Some(64));
4700                assert_eq!(img.format(), Some(PixelFormat::Nv12));
4701                if let Some(stride) = img.row_stride() {
4702                    assert!(
4703                        stride >= 100,
4704                        "NV12 row_stride {stride} must be >= the logical width (100)",
4705                    );
4706                }
4707            }
4708            Err(e) => {
4709                // Skip cleanly on hosts without a dma-heap.
4710                eprintln!("SKIPPED: create_image NV12 DMA non-aligned width: {e}");
4711            }
4712        }
4713    }
4714
4715    #[test]
4716    #[ignore] // Hangs on desktop platforms where DMA-buf is unavailable and PBO
4717              // fallback triggers a GPU driver hang during SHM→texture upload (e.g.,
4718              // NVIDIA without /dev/dma_heap permissions). Works on embedded targets.
4719    fn test_crop_skip() {
4720        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4721        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4722
4723        let mut converter = ImageProcessor::new().unwrap();
4724        let converter_dst = converter
4725            .create_image(
4726                1280,
4727                720,
4728                PixelFormat::Rgba,
4729                DType::U8,
4730                None,
4731                edgefirst_tensor::CpuAccess::ReadWrite,
4732            )
4733            .unwrap();
4734        let crop = Crop::new().with_source(Some(Region::new(0, 0, 640, 640)));
4735        let (result, src, converter_dst) = convert_img(
4736            &mut converter,
4737            src,
4738            converter_dst,
4739            Rotation::None,
4740            Flip::None,
4741            crop,
4742        );
4743        result.unwrap();
4744
4745        let cpu_dst = TensorDyn::image(
4746            1280,
4747            720,
4748            PixelFormat::Rgba,
4749            DType::U8,
4750            None,
4751            edgefirst_tensor::CpuAccess::ReadWrite,
4752        )
4753        .unwrap();
4754        let mut cpu_converter = CPUProcessor::new();
4755        let (result, _src, cpu_dst) = convert_img(
4756            &mut cpu_converter,
4757            src,
4758            cpu_dst,
4759            Rotation::None,
4760            Flip::None,
4761            crop,
4762        );
4763        result.unwrap();
4764
4765        compare_images(&converter_dst, &cpu_dst, 0.99999, function!());
4766    }
4767
4768    #[test]
4769    fn test_invalid_pixel_format() {
4770        // PixelFormat::from_fourcc returns None for unknown formats,
4771        // so TensorDyn::image cannot be called with an invalid format.
4772        assert!(PixelFormat::from_fourcc(u32::from_le_bytes(*b"TEST")).is_none());
4773    }
4774
4775    // Helper function to check if G2D library is available (Linux/i.MX8 only)
4776    #[cfg(target_os = "linux")]
4777    static G2D_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
4778
4779    #[cfg(target_os = "linux")]
4780    fn is_g2d_available() -> bool {
4781        *G2D_AVAILABLE.get_or_init(|| G2DProcessor::new().is_ok())
4782    }
4783
4784    // Every GL platform: Linux EGL, ANGLE/Metal (macOS, iOS), Android EGL,
4785    // ANGLE/D3D11 (Windows). `GLProcessorThreaded::new` fails cleanly where
4786    // the driver or ANGLE is missing, so the `test_opengl_*` callers below
4787    // self-skip there (and `HAL_TEST_REQUIRE_GL=1` lanes catch that via
4788    // `gl_backend_available_canary`).
4789    #[cfg(any(
4790        target_os = "linux",
4791        target_os = "macos",
4792        target_os = "ios",
4793        target_os = "android",
4794        target_os = "windows"
4795    ))]
4796    #[cfg(feature = "opengl")]
4797    static GL_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
4798
4799    #[cfg(any(
4800        target_os = "linux",
4801        target_os = "macos",
4802        target_os = "ios",
4803        target_os = "android",
4804        target_os = "windows"
4805    ))]
4806    #[cfg(feature = "opengl")]
4807    // Helper function to check if OpenGL is available
4808    fn is_opengl_available() -> bool {
4809        *GL_AVAILABLE.get_or_init(|| GLProcessorThreaded::new(None).is_ok())
4810    }
4811
4812    /// CI canary: fails the lane when the GL backend cannot initialize.
4813    ///
4814    /// Every GL test in this suite self-skips when the backend is
4815    /// unavailable — correct for developer machines, but it means a broken
4816    /// CI GL stack (e.g. the macOS ANGLE re-sign step regressing, the exact
4817    /// failure mode documented in the workflow) ships an untested GL
4818    /// backend behind a green lane. Gated on `HAL_TEST_REQUIRE_GL=1`, set
4819    /// only by CI jobs that install a working GL stack; local runs without
4820    /// one pass trivially. On macOS it additionally requires
4821    /// `HAL_TEST_ALLOW_DLOPEN_ANGLE`, so coverage pass 1 (unsigned
4822    /// binaries, dlopen gate closed) skips it and pass 2 (signed) enforces.
4823    #[test]
4824    #[cfg(feature = "opengl")]
4825    fn gl_backend_available_canary() {
4826        let require_gl = std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1");
4827        if !require_gl {
4828            eprintln!(
4829                "SKIPPED: {} — HAL_TEST_REQUIRE_GL is not set to 1",
4830                function!()
4831            );
4832            return;
4833        }
4834        #[cfg(target_os = "macos")]
4835        if std::env::var_os("HAL_TEST_ALLOW_DLOPEN_ANGLE").is_none() {
4836            eprintln!(
4837                "SKIPPED: {} — ANGLE dlopen gate closed (coverage pass 1)",
4838                function!()
4839            );
4840            return;
4841        }
4842        GLProcessorThreaded::new(None).expect(
4843            "HAL_TEST_REQUIRE_GL=1 but the GL backend failed to initialize — \
4844             check the ANGLE install/re-sign step and binary entitlements \
4845             (macOS) or the EGL stack (Linux)",
4846        );
4847    }
4848
4849    #[test]
4850    fn test_load_jpeg_with_exif() {
4851        use edgefirst_codec::peek_info;
4852
4853        // The migrated codec NEVER applies EXIF orientation: it decodes to the
4854        // source's native (un-rotated) dimensions and reports the rotation via
4855        // ImageInfo. `zidane_rotated_exif.jpg` carries EXIF orientation 6
4856        // (90° clockwise) over a 1280×720 frame.
4857        let file = edgefirst_bench::testdata::read("zidane_rotated_exif.jpg").to_vec();
4858        let info = peek_info(&file).unwrap();
4859        assert_eq!(info.rotation_degrees, 90);
4860        assert!(!info.flip_horizontal);
4861
4862        let loaded = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4863        // Native (un-rotated) dimensions — the decode does not rotate.
4864        assert_eq!(loaded.width(), Some(1280));
4865        assert_eq!(loaded.height(), Some(720));
4866
4867        // Applying the reported rotation downstream reproduces the upright
4868        // image: it matches `zidane.jpg` rotated by the same 90° clockwise.
4869        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4870        let cpu_src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4871
4872        let rotation = Rotation::from_degrees_clockwise(info.rotation_degrees as usize);
4873        let (dst_width, dst_height) = (cpu_src.height().unwrap(), cpu_src.width().unwrap());
4874
4875        let cpu_dst = TensorDyn::image(
4876            dst_width,
4877            dst_height,
4878            PixelFormat::Rgba,
4879            DType::U8,
4880            None,
4881            edgefirst_tensor::CpuAccess::ReadWrite,
4882        )
4883        .unwrap();
4884        let mut cpu_converter = CPUProcessor::new();
4885
4886        // Rotate the native-orientation `loaded` frame and the native `zidane`
4887        // frame by the same reported rotation; the results must agree.
4888        let loaded_rotated = TensorDyn::image(
4889            dst_width,
4890            dst_height,
4891            PixelFormat::Rgba,
4892            DType::U8,
4893            None,
4894            edgefirst_tensor::CpuAccess::ReadWrite,
4895        )
4896        .unwrap();
4897        let (r0, _loaded, loaded_rotated) = convert_img(
4898            &mut cpu_converter,
4899            loaded,
4900            loaded_rotated,
4901            rotation,
4902            Flip::None,
4903            Crop::no_crop(),
4904        );
4905        r0.unwrap();
4906
4907        let (result, _cpu_src, cpu_dst) = convert_img(
4908            &mut cpu_converter,
4909            cpu_src,
4910            cpu_dst,
4911            rotation,
4912            Flip::None,
4913            Crop::no_crop(),
4914        );
4915        result.unwrap();
4916
4917        compare_images(&loaded_rotated, &cpu_dst, 0.98, function!());
4918    }
4919
4920    #[test]
4921    fn test_load_png_with_exif() {
4922        use edgefirst_codec::peek_info;
4923
4924        // PNGs also report EXIF orientation without applying it.
4925        // `zidane_rotated_exif_180.png` carries EXIF orientation 3 (180°).
4926        let file = edgefirst_bench::testdata::read("zidane_rotated_exif_180.png").to_vec();
4927        let info = peek_info(&file).unwrap();
4928        assert_eq!(info.rotation_degrees, 180);
4929        assert!(!info.flip_horizontal);
4930
4931        let loaded = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4932        // Native (un-rotated) dimensions — PNG decodes upright as authored.
4933        assert_eq!(loaded.height(), Some(720));
4934        assert_eq!(loaded.width(), Some(1280));
4935
4936        // The PNG fixture stores upright `zidane` pixels tagged with a 180°
4937        // EXIF orientation. Because the codec no longer applies the rotation,
4938        // the decoded pixels match `zidane.jpg` directly (no convert needed).
4939        // Re-applying the reported rotation to both must still agree.
4940        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4941        let cpu_src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4942
4943        let rotation = Rotation::from_degrees_clockwise(info.rotation_degrees as usize);
4944        let cpu_dst = TensorDyn::image(
4945            1280,
4946            720,
4947            PixelFormat::Rgba,
4948            DType::U8,
4949            None,
4950            edgefirst_tensor::CpuAccess::ReadWrite,
4951        )
4952        .unwrap();
4953        let mut cpu_converter = CPUProcessor::new();
4954
4955        let (result, _cpu_src, cpu_dst) = convert_img(
4956            &mut cpu_converter,
4957            cpu_src,
4958            cpu_dst,
4959            rotation,
4960            Flip::None,
4961            Crop::no_crop(),
4962        );
4963        result.unwrap();
4964
4965        // Rotate the decoded PNG by the same reported angle so both frames are
4966        // in the same (180°-rotated) orientation before comparing.
4967        let loaded_rotated = TensorDyn::image(
4968            1280,
4969            720,
4970            PixelFormat::Rgba,
4971            DType::U8,
4972            None,
4973            edgefirst_tensor::CpuAccess::ReadWrite,
4974        )
4975        .unwrap();
4976        let (r0, _loaded, loaded_rotated) = convert_img(
4977            &mut cpu_converter,
4978            loaded,
4979            loaded_rotated,
4980            rotation,
4981            Flip::None,
4982            Crop::no_crop(),
4983        );
4984        r0.unwrap();
4985
4986        // Threshold 0.95 (was 0.98): `loaded` comes from a lossless PNG decode
4987        // while `cpu_src` (zidane.jpg) now decodes through native NV12 (chroma
4988        // subsampling) before the RGBA conversion, so the two paths differ by a
4989        // couple of percent versus the old direct-RGB JPEG decode.
4990        compare_images(&loaded_rotated, &cpu_dst, 0.95, function!());
4991    }
4992
4993    /// Synthesise an RGB JPEG with a deterministic pattern at `(width, height)`
4994    /// using the workspace's `jpeg-encoder` crate (the `image` crate is
4995    /// compiled without its JPEG feature). Used to exercise the decoder /
4996    /// pitch-padding paths for arbitrary dimensions without having to bundle
4997    /// a fixture file per test size.
4998    #[cfg(target_os = "linux")]
4999    fn make_rgb_jpeg(width: u32, height: u32) -> Vec<u8> {
5000        let mut bytes = Vec::with_capacity((width * height * 3) as usize);
5001        for y in 0..height {
5002            for x in 0..width {
5003                bytes.push(((x + y) & 0xFF) as u8);
5004                bytes.push(((x.wrapping_mul(3)) & 0xFF) as u8);
5005                bytes.push(((y.wrapping_mul(5)) & 0xFF) as u8);
5006            }
5007        }
5008        let mut out = Vec::new();
5009        let encoder = jpeg_encoder::Encoder::new(&mut out, 85);
5010        encoder
5011            .encode(
5012                &bytes,
5013                width as u16,
5014                height as u16,
5015                jpeg_encoder::ColorType::Rgb,
5016            )
5017            .expect("jpeg-encoder must succeed on trivial input");
5018        out
5019    }
5020
5021    /// End-to-end: a 375×333 RGBA JPEG (width NOT divisible by 4) loaded
5022    /// via the pitch-padded DMA path and letterboxed through the GL
5023    /// backend must produce correct output. Before the Rgba/Bgra
5024    /// width%4 relaxation in `DmaImportAttrs::from_tensor`, this case
5025    /// failed the pre-check and forced a CPU texture upload fallback;
5026    /// with the relaxation, EGL import succeeds at the driver level and
5027    /// the GL fast path runs. Output correctness is checked against a
5028    /// CPU reference (convert ran with `EDGEFIRST_FORCE_BACKEND=cpu`).
5029    #[test]
5030    #[cfg(target_os = "linux")]
5031    #[cfg(feature = "opengl")]
5032    fn test_convert_rgba_non_4_aligned_width_end_to_end() {
5033        use edgefirst_tensor::is_dma_available;
5034        if !is_dma_available() {
5035            eprintln!(
5036                "SKIPPED: test_convert_rgba_non_4_aligned_width_end_to_end — DMA not available"
5037            );
5038            return;
5039        }
5040        // 375 is the canonical failure width from dataset loaders —
5041        // 375 * 4 = 1500 bytes/row, pitch-padded to 1536. Width%4 = 3,
5042        // so the old pre-check rejected it; new code accepts it.
5043        let jpeg = make_rgb_jpeg(375, 333);
5044        let src_gl = crate::load_image_test_helper(&jpeg, Some(PixelFormat::Rgba), None).unwrap();
5045        assert_eq!(src_gl.width(), Some(375));
5046        // Row stride must still be pitch-padded (separate concern from width).
5047        let stride = src_gl.row_stride().unwrap();
5048        assert_eq!(stride, 1536, "expected padded pitch 1536, got {stride}");
5049
5050        // GL-backed convert into a pitch-aligned 640×640 Rgba dest.
5051        let mut gl_proc = ImageProcessor::new().unwrap();
5052        let gl_dst = gl_proc
5053            .create_image(
5054                640,
5055                640,
5056                PixelFormat::Rgba,
5057                DType::U8,
5058                None,
5059                edgefirst_tensor::CpuAccess::ReadWrite,
5060            )
5061            .unwrap();
5062        let (r_gl, _src_gl, gl_dst) = convert_img(
5063            &mut gl_proc,
5064            src_gl,
5065            gl_dst,
5066            Rotation::None,
5067            Flip::None,
5068            Crop::no_crop(),
5069        );
5070        r_gl.expect("GL-backed convert must succeed for 375x333 Rgba src");
5071
5072        // CPU reference via a fresh load so the two paths start from
5073        // byte-identical inputs. `with_config(backend=Cpu)` forces the
5074        // CPU-only processor regardless of which backends the host has
5075        // available.
5076        let src_cpu =
5077            crate::load_image_test_helper(&jpeg, Some(PixelFormat::Rgba), Some(TensorMemory::Mem))
5078                .unwrap();
5079        let mut cpu_proc = ImageProcessor::with_config(ImageProcessorConfig {
5080            backend: ComputeBackend::Cpu,
5081            ..Default::default()
5082        })
5083        .unwrap();
5084        let cpu_dst = TensorDyn::image(
5085            640,
5086            640,
5087            PixelFormat::Rgba,
5088            DType::U8,
5089            Some(TensorMemory::Mem),
5090            edgefirst_tensor::CpuAccess::ReadWrite,
5091        )
5092        .unwrap();
5093        let (r_cpu, _src_cpu, cpu_dst) = convert_img(
5094            &mut cpu_proc,
5095            src_cpu,
5096            cpu_dst,
5097            Rotation::None,
5098            Flip::None,
5099            Crop::no_crop(),
5100        );
5101        r_cpu.unwrap();
5102
5103        // Structural similarity: the GL path may have gone through EGL
5104        // import OR fallen back to CPU texture upload — either way, the
5105        // output must match the CPU reference closely.
5106        compare_images(&gl_dst, &cpu_dst, 0.95, function!());
5107    }
5108
5109    /// Regression lock: loading a JPEG at a non-64-aligned RGBA pitch (e.g.
5110    /// 500×333 → natural pitch 2000, needs to be padded to 2048) must go
5111    /// through `image_with_stride` and set `row_stride()` / `effective_row_stride()`
5112    /// to the padded value. The earlier pitch-padding commit fixed this in
5113    /// `load_jpeg`; a regression would surface as `row_stride == None` or
5114    /// `effective_row_stride == 2000`.
5115    #[test]
5116    #[cfg(target_os = "linux")]
5117    fn test_load_jpeg_rgba_non_aligned_pitch_padded_dma() {
5118        use edgefirst_tensor::is_dma_available;
5119        if !is_dma_available() {
5120            eprintln!(
5121                "SKIPPED: test_load_jpeg_rgba_non_aligned_pitch_padded_dma — DMA not available"
5122            );
5123            return;
5124        }
5125        // Widths that force a non-64-aligned natural RGBA pitch. All three
5126        // are divisible by 4 so the EGL width-alignment pre-check passes.
5127        // The pitch-padding fix is what makes these importable at all.
5128        for &w in &[500u32, 612, 428] {
5129            let jpeg = make_rgb_jpeg(w, 333);
5130            let loaded =
5131                crate::load_image_test_helper(&jpeg, Some(PixelFormat::Rgba), None).unwrap();
5132            let natural = (w as usize) * 4;
5133            let aligned = crate::align_pitch_bytes_to_gpu_alignment(natural).unwrap();
5134            assert!(
5135                aligned > natural,
5136                "test sanity: width {w} should be unaligned"
5137            );
5138            let stride = loaded
5139                .row_stride()
5140                .expect("padded DMA path must set an explicit row_stride — regression if None");
5141            assert_eq!(
5142                stride, aligned,
5143                "width {w}: expected padded stride {aligned}, got {stride} \
5144                 (regression: pitch-padding branch skipped?)"
5145            );
5146            let eff = loaded.effective_row_stride().unwrap();
5147            assert_eq!(
5148                eff, aligned,
5149                "effective_row_stride must match stored stride"
5150            );
5151            assert_eq!(loaded.width(), Some(w as usize));
5152            assert_eq!(loaded.height(), Some(333));
5153        }
5154    }
5155
5156    /// `padded_dma_pitch_for` must respect the caller's memory choice and
5157    /// must NOT route into the pitch-padded DMA path when the caller left
5158    /// the choice to the allocator (`None`) but DMA is unavailable on the
5159    /// host. The padded path requires `image_with_stride`, which always
5160    /// allocates DMA — taking it on a system without `/dev/dma_heap`
5161    /// would convert a normally-working image load into a hard failure
5162    /// (since `Tensor::image(..., None)` would have fallen back to
5163    /// SHM/Mem).
5164    #[test]
5165    #[cfg(target_os = "linux")]
5166    fn test_padded_dma_pitch_for_respects_memory_choice() {
5167        use edgefirst_tensor::{is_dma_available, TensorMemory};
5168
5169        // 500×4 = 2000 → padded to 2048 by GPU alignment. Use it for
5170        // every case so any "no padding" answer is unambiguous.
5171        let unaligned_w = 500;
5172
5173        // Caller asks for Mem / Shm: never pad, regardless of DMA.
5174        assert_eq!(
5175            crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &Some(TensorMemory::Mem),),
5176            None,
5177            "Mem must never trigger DMA padding"
5178        );
5179        assert_eq!(
5180            crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &Some(TensorMemory::Shm),),
5181            None,
5182            "Shm must never trigger DMA padding"
5183        );
5184
5185        // Caller explicitly asks for DMA: always pad if width needs it.
5186        // Even if the runtime can't actually allocate DMA, the caller
5187        // owns that decision and the resulting allocation error is
5188        // their problem, not ours.
5189        assert_eq!(
5190            crate::padded_dma_pitch_for(
5191                PixelFormat::Rgba,
5192                unaligned_w,
5193                &Some(TensorMemory::DmaBuf),
5194            ),
5195            Some(2048),
5196            "explicit Dma must pad regardless of runtime DMA availability"
5197        );
5198
5199        // Caller leaves it to the allocator: behaviour depends on
5200        // host-runtime DMA availability. This is the case the fix
5201        // guards against.
5202        let none_result = crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &None);
5203        if is_dma_available() {
5204            assert_eq!(
5205                none_result,
5206                Some(2048),
5207                "memory=None + DMA available → pad (will route through DMA)"
5208            );
5209        } else {
5210            assert_eq!(
5211                none_result, None,
5212                "memory=None + DMA unavailable → must NOT pad (would force \
5213                 image_with_stride into a DMA-only allocation that fails). \
5214                 Regression: padded_dma_pitch_for ignored is_dma_available()."
5215            );
5216        }
5217    }
5218
5219    // Synthesise a small greyscale PNG in memory at `(width, height)` with a
5220    // deterministic ramp pattern so multiple tests can cross-check output
5221    // without bundling an extra fixture file.
5222    fn make_grey_png(width: u32, height: u32) -> Vec<u8> {
5223        let mut bytes = Vec::with_capacity((width * height) as usize);
5224        for y in 0..height {
5225            for x in 0..width {
5226                bytes.push(((x + y) & 0xFF) as u8);
5227            }
5228        }
5229        let img = image::GrayImage::from_vec(width, height, bytes).unwrap();
5230        let mut buf = Vec::new();
5231        img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
5232            .unwrap();
5233        buf
5234    }
5235
5236    /// Greyscale PNG with a width that forces a pitch-misaligned natural
5237    /// row stride (612 bytes is not a multiple of the 64-byte GPU pitch
5238    /// alignment) must still load via the pitch-padded DMA path. Gated on
5239    /// DMA availability because `image_with_stride` is DMA-only.
5240    #[test]
5241    #[cfg(target_os = "linux")]
5242    fn test_load_png_grey_misaligned_width_dma() {
5243        use edgefirst_tensor::is_dma_available;
5244        if !is_dma_available() {
5245            eprintln!("SKIPPED: test_load_png_grey_misaligned_width_dma — DMA not available");
5246            return;
5247        }
5248        let png = make_grey_png(612, 388);
5249        let loaded = crate::load_image_test_helper(&png, Some(PixelFormat::Grey), None).unwrap();
5250        assert_eq!(loaded.width(), Some(612));
5251        assert_eq!(loaded.height(), Some(388));
5252        assert_eq!(loaded.format(), Some(PixelFormat::Grey));
5253
5254        // Round-trip pixels — natural-pitch DMA-BUFs pad the stride so we
5255        // must indirect through row_stride() rather than assume width.
5256        let map = loaded.as_typed::<u8>().unwrap().map().unwrap();
5257        let stride = loaded.row_stride().unwrap_or(612);
5258        assert!(stride >= 612);
5259        let bytes: &[u8] = &map;
5260        for y in 0..388usize {
5261            for x in 0..612usize {
5262                let expected = ((x + y) & 0xFF) as u8;
5263                let got = bytes[y * stride + x];
5264                assert_eq!(
5265                    got, expected,
5266                    "grey png mismatch at ({x},{y}): got {got} expected {expected}"
5267                );
5268            }
5269        }
5270    }
5271
5272    /// Greyscale PNG loaded with explicit Mem backing — runs on any
5273    /// platform (no DMA permission requirement) and covers the
5274    /// decoder-native Luma → Grey no-conversion path.
5275    #[test]
5276    fn test_load_png_grey_mem() {
5277        use edgefirst_tensor::TensorMemory;
5278        let png = make_grey_png(612, 100);
5279        let loaded =
5280            crate::load_image_test_helper(&png, Some(PixelFormat::Grey), Some(TensorMemory::Mem))
5281                .unwrap();
5282        assert_eq!(loaded.width(), Some(612));
5283        assert_eq!(loaded.height(), Some(100));
5284        assert_eq!(loaded.format(), Some(PixelFormat::Grey));
5285        let map = loaded.as_typed::<u8>().unwrap().map().unwrap();
5286        let bytes: &[u8] = &map;
5287        // Mem allocation uses the natural pitch — 612 bytes per row, exact.
5288        assert_eq!(bytes.len(), 612 * 100);
5289        for y in 0..100 {
5290            for x in 0..612 {
5291                assert_eq!(bytes[y * 612 + x], ((x + y) & 0xFF) as u8);
5292            }
5293        }
5294    }
5295
5296    /// Greyscale PNG decoded into RGB — exercises the decoder-colorspace
5297    /// mismatch path (Luma → Rgb via CPU converter). Uses Mem memory to
5298    /// stay portable to host-side test environments.
5299    #[test]
5300    fn test_load_png_grey_to_rgb_mem() {
5301        use edgefirst_tensor::TensorMemory;
5302        let png = make_grey_png(620, 240);
5303        let loaded =
5304            crate::load_image_test_helper(&png, Some(PixelFormat::Rgb), Some(TensorMemory::Mem))
5305                .unwrap();
5306        assert_eq!(loaded.width(), Some(620));
5307        assert_eq!(loaded.height(), Some(240));
5308        assert_eq!(loaded.format(), Some(PixelFormat::Rgb));
5309
5310        // Greyscale promoted to RGB replicates luma into each channel.
5311        let map = loaded.as_typed::<u8>().unwrap().map().unwrap();
5312        let bytes: &[u8] = &map;
5313        for (x, y) in [(0usize, 0usize), (100, 50), (619, 239)] {
5314            let expected = ((x + y) & 0xFF) as u8;
5315            let off = (y * 620 + x) * 3;
5316            assert_eq!(bytes[off], expected, "R@{x},{y}");
5317            assert_eq!(bytes[off + 1], expected, "G@{x},{y}");
5318            assert_eq!(bytes[off + 2], expected, "B@{x},{y}");
5319        }
5320    }
5321
5322    #[test]
5323    #[cfg(target_os = "linux")]
5324    fn test_g2d_resize() {
5325        if !is_g2d_available() {
5326            eprintln!("SKIPPED: test_g2d_resize - G2D library (libg2d.so.2) not available");
5327            return;
5328        }
5329        if !is_dma_available() {
5330            eprintln!(
5331                "SKIPPED: test_g2d_resize - DMA memory allocation not available (permission denied or no DMA-BUF support)"
5332            );
5333            return;
5334        }
5335
5336        let dst_width = 640;
5337        let dst_height = 360;
5338        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5339        let src = crate::load_image_test_helper(
5340            &file,
5341            Some(PixelFormat::Rgba),
5342            Some(TensorMemory::DmaBuf),
5343        )
5344        .unwrap();
5345
5346        let g2d_dst = TensorDyn::image(
5347            dst_width,
5348            dst_height,
5349            PixelFormat::Rgba,
5350            DType::U8,
5351            Some(TensorMemory::DmaBuf),
5352            edgefirst_tensor::CpuAccess::ReadWrite,
5353        )
5354        .unwrap();
5355        let mut g2d_converter = G2DProcessor::new().unwrap();
5356        let (result, src, g2d_dst) = convert_img(
5357            &mut g2d_converter,
5358            src,
5359            g2d_dst,
5360            Rotation::None,
5361            Flip::None,
5362            Crop::no_crop(),
5363        );
5364        result.unwrap();
5365
5366        let cpu_dst = TensorDyn::image(
5367            dst_width,
5368            dst_height,
5369            PixelFormat::Rgba,
5370            DType::U8,
5371            None,
5372            edgefirst_tensor::CpuAccess::ReadWrite,
5373        )
5374        .unwrap();
5375        let mut cpu_converter = CPUProcessor::new();
5376        let (result, _src, cpu_dst) = convert_img(
5377            &mut cpu_converter,
5378            src,
5379            cpu_dst,
5380            Rotation::None,
5381            Flip::None,
5382            Crop::no_crop(),
5383        );
5384        result.unwrap();
5385
5386        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
5387        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
5388        // the YUV-matrix delta that forced 0.95 has closed; tightened to
5389        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
5390        // structural gap not exercised by these limited-range fixtures.
5391        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
5392    }
5393
5394    #[test]
5395    #[cfg(any(
5396        target_os = "linux",
5397        target_os = "macos",
5398        target_os = "ios",
5399        target_os = "android",
5400        target_os = "windows"
5401    ))]
5402    #[cfg(feature = "opengl")]
5403    fn test_opengl_resize() {
5404        if !is_opengl_available() {
5405            eprintln!("SKIPPED: {} - OpenGL not available", function!());
5406            return;
5407        }
5408
5409        let dst_width = 640;
5410        let dst_height = 360;
5411        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5412        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5413
5414        let cpu_dst = TensorDyn::image(
5415            dst_width,
5416            dst_height,
5417            PixelFormat::Rgba,
5418            DType::U8,
5419            None,
5420            edgefirst_tensor::CpuAccess::ReadWrite,
5421        )
5422        .unwrap();
5423        let mut cpu_converter = CPUProcessor::new();
5424        let (result, src, cpu_dst) = convert_img(
5425            &mut cpu_converter,
5426            src,
5427            cpu_dst,
5428            Rotation::None,
5429            Flip::None,
5430            Crop::no_crop(),
5431        );
5432        result.unwrap();
5433
5434        let mut src = src;
5435        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
5436
5437        for _ in 0..5 {
5438            let gl_dst = TensorDyn::image(
5439                dst_width,
5440                dst_height,
5441                PixelFormat::Rgba,
5442                DType::U8,
5443                None,
5444                edgefirst_tensor::CpuAccess::ReadWrite,
5445            )
5446            .unwrap();
5447            let (result, src_back, gl_dst) = convert_img(
5448                &mut gl_converter,
5449                src,
5450                gl_dst,
5451                Rotation::None,
5452                Flip::None,
5453                Crop::no_crop(),
5454            );
5455            result.unwrap();
5456            src = src_back;
5457
5458            compare_images(&gl_dst, &cpu_dst, 0.98, function!());
5459        }
5460    }
5461
5462    #[test]
5463    #[cfg(any(
5464        target_os = "linux",
5465        target_os = "macos",
5466        target_os = "ios",
5467        target_os = "android",
5468        target_os = "windows"
5469    ))]
5470    #[cfg(feature = "opengl")]
5471    fn test_opengl_10_threads() {
5472        if !is_opengl_available() {
5473            eprintln!("SKIPPED: {} - OpenGL not available", function!());
5474            return;
5475        }
5476
5477        let handles: Vec<_> = (0..10)
5478            .map(|i| {
5479                std::thread::Builder::new()
5480                    .name(format!("Thread {i}"))
5481                    .spawn(test_opengl_resize)
5482                    .unwrap()
5483            })
5484            .collect();
5485        handles.into_iter().for_each(|h| {
5486            if let Err(e) = h.join() {
5487                std::panic::resume_unwind(e)
5488            }
5489        });
5490    }
5491
5492    #[test]
5493    #[cfg(any(
5494        target_os = "linux",
5495        target_os = "macos",
5496        target_os = "ios",
5497        target_os = "android",
5498        target_os = "windows"
5499    ))]
5500    #[cfg(feature = "opengl")]
5501    fn test_opengl_grey() {
5502        if !is_opengl_available() {
5503            eprintln!("SKIPPED: {} - OpenGL not available", function!());
5504            return;
5505        }
5506
5507        let img = crate::load_image_test_helper(
5508            &edgefirst_bench::testdata::read("grey.jpg"),
5509            Some(PixelFormat::Grey),
5510            None,
5511        )
5512        .unwrap();
5513
5514        let gl_dst = TensorDyn::image(
5515            640,
5516            640,
5517            PixelFormat::Grey,
5518            DType::U8,
5519            None,
5520            edgefirst_tensor::CpuAccess::ReadWrite,
5521        )
5522        .unwrap();
5523        let cpu_dst = TensorDyn::image(
5524            640,
5525            640,
5526            PixelFormat::Grey,
5527            DType::U8,
5528            None,
5529            edgefirst_tensor::CpuAccess::ReadWrite,
5530        )
5531        .unwrap();
5532
5533        let mut converter = CPUProcessor::new();
5534
5535        let (result, img, cpu_dst) = convert_img(
5536            &mut converter,
5537            img,
5538            cpu_dst,
5539            Rotation::None,
5540            Flip::None,
5541            Crop::no_crop(),
5542        );
5543        result.unwrap();
5544
5545        let mut gl = GLProcessorThreaded::new(None).unwrap();
5546        let (result, _img, gl_dst) = convert_img(
5547            &mut gl,
5548            img,
5549            gl_dst,
5550            Rotation::None,
5551            Flip::None,
5552            Crop::no_crop(),
5553        );
5554        result.unwrap();
5555
5556        compare_images(&gl_dst, &cpu_dst, 0.98, function!());
5557    }
5558
5559    #[test]
5560    #[cfg(target_os = "linux")]
5561    fn test_g2d_src_crop() {
5562        if !is_g2d_available() {
5563            eprintln!("SKIPPED: test_g2d_src_crop - G2D library (libg2d.so.2) not available");
5564            return;
5565        }
5566        if !is_dma_available() {
5567            eprintln!(
5568                "SKIPPED: test_g2d_src_crop - DMA memory allocation not available (permission denied or no DMA-BUF support)"
5569            );
5570            return;
5571        }
5572
5573        let dst_width = 640;
5574        let dst_height = 640;
5575        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5576        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5577
5578        let cpu_dst = TensorDyn::image(
5579            dst_width,
5580            dst_height,
5581            PixelFormat::Rgba,
5582            DType::U8,
5583            None,
5584            edgefirst_tensor::CpuAccess::ReadWrite,
5585        )
5586        .unwrap();
5587        let mut cpu_converter = CPUProcessor::new();
5588        let crop = Crop::new().with_source(Some(Region::new(0, 0, 640, 360)));
5589        let (result, src, cpu_dst) = convert_img(
5590            &mut cpu_converter,
5591            src,
5592            cpu_dst,
5593            Rotation::None,
5594            Flip::None,
5595            crop,
5596        );
5597        result.unwrap();
5598
5599        let g2d_dst = TensorDyn::image(
5600            dst_width,
5601            dst_height,
5602            PixelFormat::Rgba,
5603            DType::U8,
5604            None,
5605            edgefirst_tensor::CpuAccess::ReadWrite,
5606        )
5607        .unwrap();
5608        let mut g2d_converter = G2DProcessor::new().unwrap();
5609        let (result, _src, g2d_dst) = convert_img(
5610            &mut g2d_converter,
5611            src,
5612            g2d_dst,
5613            Rotation::None,
5614            Flip::None,
5615            crop,
5616        );
5617        result.unwrap();
5618
5619        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
5620        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
5621        // the YUV-matrix delta that forced 0.95 has closed; tightened to
5622        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
5623        // structural gap not exercised by these limited-range fixtures.
5624        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
5625    }
5626
5627    #[test]
5628    #[cfg(target_os = "linux")]
5629    fn test_g2d_dst_crop() {
5630        if !is_g2d_available() {
5631            eprintln!("SKIPPED: test_g2d_dst_crop - G2D library (libg2d.so.2) not available");
5632            return;
5633        }
5634        if !is_dma_available() {
5635            eprintln!(
5636                "SKIPPED: test_g2d_dst_crop - DMA memory allocation not available (permission denied or no DMA-BUF support)"
5637            );
5638            return;
5639        }
5640
5641        let dst_width = 640;
5642        let dst_height = 640;
5643        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5644        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5645
5646        let cpu_dst = TensorDyn::image(
5647            dst_width,
5648            dst_height,
5649            PixelFormat::Rgba,
5650            DType::U8,
5651            None,
5652            edgefirst_tensor::CpuAccess::ReadWrite,
5653        )
5654        .unwrap();
5655        let mut cpu_converter = CPUProcessor::new();
5656        let crop = Crop::new();
5657        let (result, src, cpu_dst) = convert_img(
5658            &mut cpu_converter,
5659            src,
5660            cpu_dst,
5661            Rotation::None,
5662            Flip::None,
5663            crop,
5664        );
5665        result.unwrap();
5666
5667        let g2d_dst = TensorDyn::image(
5668            dst_width,
5669            dst_height,
5670            PixelFormat::Rgba,
5671            DType::U8,
5672            None,
5673            edgefirst_tensor::CpuAccess::ReadWrite,
5674        )
5675        .unwrap();
5676        let mut g2d_converter = G2DProcessor::new().unwrap();
5677        let (result, _src, g2d_dst) = convert_img(
5678            &mut g2d_converter,
5679            src,
5680            g2d_dst,
5681            Rotation::None,
5682            Flip::None,
5683            crop,
5684        );
5685        result.unwrap();
5686
5687        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
5688        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
5689        // the YUV-matrix delta that forced 0.95 has closed; tightened to
5690        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
5691        // structural gap not exercised by these limited-range fixtures.
5692        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
5693    }
5694
5695    #[test]
5696    #[cfg(target_os = "linux")]
5697    fn test_g2d_all_rgba() {
5698        if !is_g2d_available() {
5699            eprintln!("SKIPPED: test_g2d_all_rgba - G2D library (libg2d.so.2) not available");
5700            return;
5701        }
5702        if !is_dma_available() {
5703            eprintln!(
5704                "SKIPPED: test_g2d_all_rgba - DMA memory allocation not available (permission denied or no DMA-BUF support)"
5705            );
5706            return;
5707        }
5708
5709        let dst_width = 640;
5710        let dst_height = 640;
5711        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5712        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5713        let src_dyn = src;
5714
5715        let mut cpu_dst = TensorDyn::image(
5716            dst_width,
5717            dst_height,
5718            PixelFormat::Rgba,
5719            DType::U8,
5720            None,
5721            edgefirst_tensor::CpuAccess::ReadWrite,
5722        )
5723        .unwrap();
5724        let mut cpu_converter = CPUProcessor::new();
5725        let mut g2d_dst = TensorDyn::image(
5726            dst_width,
5727            dst_height,
5728            PixelFormat::Rgba,
5729            DType::U8,
5730            None,
5731            edgefirst_tensor::CpuAccess::ReadWrite,
5732        )
5733        .unwrap();
5734        let mut g2d_converter = G2DProcessor::new().unwrap();
5735
5736        let crop = Crop::new().with_source(Some(Region::new(50, 120, 1024, 576)));
5737
5738        for rot in [
5739            Rotation::None,
5740            Rotation::Clockwise90,
5741            Rotation::Rotate180,
5742            Rotation::CounterClockwise90,
5743        ] {
5744            cpu_dst
5745                .as_typed::<u8>()
5746                .unwrap()
5747                .map()
5748                .unwrap()
5749                .as_mut_slice()
5750                .fill(114);
5751            g2d_dst
5752                .as_typed::<u8>()
5753                .unwrap()
5754                .map()
5755                .unwrap()
5756                .as_mut_slice()
5757                .fill(114);
5758            for flip in [Flip::None, Flip::Horizontal, Flip::Vertical] {
5759                let mut cpu_dst_dyn = cpu_dst;
5760                cpu_converter
5761                    .convert(&src_dyn, &mut cpu_dst_dyn, Rotation::None, Flip::None, crop)
5762                    .unwrap();
5763                cpu_dst = {
5764                    let mut __t = cpu_dst_dyn.into_u8().unwrap();
5765                    __t.set_format(PixelFormat::Rgba).unwrap();
5766                    TensorDyn::from(__t)
5767                };
5768
5769                let mut g2d_dst_dyn = g2d_dst;
5770                g2d_converter
5771                    .convert(&src_dyn, &mut g2d_dst_dyn, Rotation::None, Flip::None, crop)
5772                    .unwrap();
5773                g2d_dst = {
5774                    let mut __t = g2d_dst_dyn.into_u8().unwrap();
5775                    __t.set_format(PixelFormat::Rgba).unwrap();
5776                    TensorDyn::from(__t)
5777                };
5778
5779                compare_images(
5780                    &g2d_dst,
5781                    &cpu_dst,
5782                    0.98,
5783                    &format!("{} {:?} {:?}", function!(), rot, flip),
5784                );
5785            }
5786        }
5787    }
5788
5789    #[test]
5790    #[cfg(any(
5791        target_os = "linux",
5792        target_os = "macos",
5793        target_os = "ios",
5794        target_os = "android",
5795        target_os = "windows"
5796    ))]
5797    #[cfg(feature = "opengl")]
5798    fn test_opengl_src_crop() {
5799        if !is_opengl_available() {
5800            eprintln!("SKIPPED: {} - OpenGL not available", function!());
5801            return;
5802        }
5803
5804        let dst_width = 640;
5805        let dst_height = 360;
5806        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5807        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5808        let crop = Crop::new().with_source(Some(Region::new(320, 180, 1280 - 320, 720 - 180)));
5809
5810        let cpu_dst = TensorDyn::image(
5811            dst_width,
5812            dst_height,
5813            PixelFormat::Rgba,
5814            DType::U8,
5815            None,
5816            edgefirst_tensor::CpuAccess::ReadWrite,
5817        )
5818        .unwrap();
5819        let mut cpu_converter = CPUProcessor::new();
5820        let (result, src, cpu_dst) = convert_img(
5821            &mut cpu_converter,
5822            src,
5823            cpu_dst,
5824            Rotation::None,
5825            Flip::None,
5826            crop,
5827        );
5828        result.unwrap();
5829
5830        let gl_dst = TensorDyn::image(
5831            dst_width,
5832            dst_height,
5833            PixelFormat::Rgba,
5834            DType::U8,
5835            None,
5836            edgefirst_tensor::CpuAccess::ReadWrite,
5837        )
5838        .unwrap();
5839        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
5840        let (result, _src, gl_dst) = convert_img(
5841            &mut gl_converter,
5842            src,
5843            gl_dst,
5844            Rotation::None,
5845            Flip::None,
5846            crop,
5847        );
5848        result.unwrap();
5849
5850        compare_images(&gl_dst, &cpu_dst, 0.98, function!());
5851    }
5852
5853    #[test]
5854    #[cfg(any(
5855        target_os = "linux",
5856        target_os = "macos",
5857        target_os = "ios",
5858        target_os = "android",
5859        target_os = "windows"
5860    ))]
5861    #[cfg(feature = "opengl")]
5862    fn test_opengl_dst_crop() {
5863        if !is_opengl_available() {
5864            eprintln!("SKIPPED: {} - OpenGL not available", function!());
5865            return;
5866        }
5867
5868        let dst_width = 640;
5869        let dst_height = 640;
5870        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5871        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5872
5873        let cpu_dst = TensorDyn::image(
5874            dst_width,
5875            dst_height,
5876            PixelFormat::Rgba,
5877            DType::U8,
5878            None,
5879            edgefirst_tensor::CpuAccess::ReadWrite,
5880        )
5881        .unwrap();
5882        let mut cpu_converter = CPUProcessor::new();
5883        let crop = Crop::new();
5884        let (result, src, cpu_dst) = convert_img(
5885            &mut cpu_converter,
5886            src,
5887            cpu_dst,
5888            Rotation::None,
5889            Flip::None,
5890            crop,
5891        );
5892        result.unwrap();
5893
5894        let gl_dst = TensorDyn::image(
5895            dst_width,
5896            dst_height,
5897            PixelFormat::Rgba,
5898            DType::U8,
5899            None,
5900            edgefirst_tensor::CpuAccess::ReadWrite,
5901        )
5902        .unwrap();
5903        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
5904        let (result, _src, gl_dst) = convert_img(
5905            &mut gl_converter,
5906            src,
5907            gl_dst,
5908            Rotation::None,
5909            Flip::None,
5910            crop,
5911        );
5912        result.unwrap();
5913
5914        compare_images(&gl_dst, &cpu_dst, 0.98, function!());
5915    }
5916    /// A deterministic RGBA8 `Mem` source: `R = x`, `G = y`, `B = x + y`,
5917    /// `A = 255`, each `& 255`. Written at the tensor's own row stride so a
5918    /// padded allocation still gets the pattern at the right columns.
5919    #[cfg(all(target_os = "windows", feature = "opengl"))]
5920    fn synthetic_rgba(w: usize, h: usize) -> TensorDyn {
5921        let t = TensorDyn::image(
5922            w,
5923            h,
5924            PixelFormat::Rgba,
5925            DType::U8,
5926            Some(TensorMemory::Mem),
5927            edgefirst_tensor::CpuAccess::ReadWrite,
5928        )
5929        .unwrap();
5930        fill_synthetic_rgba(&t);
5931        t
5932    }
5933
5934    /// Every pixel of `actual` equals `expected`, compared through the
5935    /// tensors' own row compaction so a padded texture and a tight `Mem`
5936    /// image compare as images. `compare_images` scores similarity and
5937    /// accepts anything at a 0.0 threshold; this is the exact check.
5938    #[cfg(all(target_os = "windows", feature = "opengl"))]
5939    fn assert_pixels_identical(actual: &TensorDyn, expected: &TensorDyn, name: &str) {
5940        assert_eq!(actual.shape(), expected.shape(), "{name}: shapes differ");
5941        let flat = |t: &TensorDyn| {
5942            let typed = t.as_typed::<u8>().expect("u8 image");
5943            let mut out = vec![0u8; t.shape().iter().product::<usize>()];
5944            typed.copy_to_flat(&mut out).expect("compact the rows");
5945            out
5946        };
5947        let (a, e) = (flat(actual), flat(expected));
5948        let mismatches = a.iter().zip(&e).filter(|(x, y)| x != y).count();
5949        assert_eq!(
5950            mismatches,
5951            0,
5952            "{name}: {mismatches} of {} bytes differ (first rows: {:?} vs {:?})",
5953            a.len(),
5954            &a[..16.min(a.len())],
5955            &e[..16.min(e.len())]
5956        );
5957    }
5958
5959    /// Write the `synthetic_rgba` pattern into any writable RGBA8 tensor,
5960    /// a texture included, at its own row stride.
5961    #[cfg(all(target_os = "windows", feature = "opengl"))]
5962    fn fill_synthetic_rgba(t: &TensorDyn) {
5963        let w = t.width().unwrap();
5964        let h = t.height().unwrap();
5965        let bound = t.as_typed::<u8>().unwrap();
5966        let stride = bound.effective_row_stride().unwrap_or(w * 4);
5967        let mut m = bound.map().unwrap();
5968        let px = m.as_mut_slice();
5969        for y in 0..h {
5970            for x in 0..w {
5971                let o = y * stride + x * 4;
5972                px[o] = x as u8;
5973                px[o + 1] = y as u8;
5974                px[o + 2] = (x + y) as u8;
5975                px[o + 3] = 255;
5976            }
5977        }
5978    }
5979
5980    /// Element-wise comparison of two float images of the same format and
5981    /// dtype.
5982    ///
5983    /// Both sides are read through `copy_to_flat`, the tensor's own row
5984    /// compaction, for the reason `compare_images` does: a Windows texture
5985    /// destination pads its rows, so reading the head of the mapping as a
5986    /// tight image would read padding as values. F16 values are widened with
5987    /// `half::f16::to_f64` before the comparison.
5988    #[cfg(all(target_os = "windows", feature = "opengl"))]
5989    fn compare_float_images(a: &TensorDyn, b: &TensorDyn, tol: f64, what: &str) {
5990        assert_eq!(a.format(), b.format(), "{what}: pixel formats differ");
5991        assert_eq!(a.dtype(), b.dtype(), "{what}: dtypes differ");
5992        assert_eq!(a.shape(), b.shape(), "{what}: shapes differ");
5993        let values = |t: &TensorDyn| -> Vec<f64> {
5994            let elems: usize = t.shape().iter().product();
5995            match t.dtype() {
5996                DType::F32 => {
5997                    let typed = t.as_typed::<f32>().expect("F32 image");
5998                    let mut bytes = vec![0u8; elems * 4];
5999                    typed
6000                        .copy_to_flat(&mut bytes)
6001                        .expect("compact the padded rows");
6002                    bytes
6003                        .as_chunks::<4>()
6004                        .0
6005                        .iter()
6006                        .map(|c| f32::from_le_bytes(*c) as f64)
6007                        .collect()
6008                }
6009                DType::F16 => {
6010                    let typed = t.as_typed::<half::f16>().expect("F16 image");
6011                    let mut bytes = vec![0u8; elems * 2];
6012                    typed
6013                        .copy_to_flat(&mut bytes)
6014                        .expect("compact the padded rows");
6015                    bytes
6016                        .as_chunks::<2>()
6017                        .0
6018                        .iter()
6019                        .map(|c| half::f16::from_le_bytes(*c).to_f64())
6020                        .collect()
6021                }
6022                other => panic!("{what}: {other:?} is not a float dtype"),
6023            }
6024        };
6025        let (av, bv) = (values(a), values(b));
6026        assert_eq!(av.len(), bv.len(), "{what}: element counts differ");
6027        for (i, (x, y)) in av.iter().zip(bv.iter()).enumerate() {
6028            assert!(
6029                (x - y).abs() <= tol,
6030                "{what}: element {i} differs by {} (gpu {x}, cpu {y}, tol {tol})",
6031                (x - y).abs()
6032            );
6033        }
6034    }
6035
6036    /// The float PBO readback has to land each destination row at the
6037    /// destination's own pitch, exactly as the u8 PBO readback does.
6038    ///
6039    /// The packed render target's rows ARE the destination's rows -- `(W/4,
6040    /// 3H)` RGBA16F texels, one plane row per surface row, for
6041    /// `PlanarRgb`/F16; `(W*3, H)` R32F texels, one image row per surface
6042    /// row, for `Rgb`/F32 -- and the read was tight whatever pitch the
6043    /// destination declared. A pool tensor narrowed with `configure_image`
6044    /// that keeps the pool's pitch is exactly that destination, so every row
6045    /// after the first landed short.
6046    ///
6047    /// Both routes the pitch can take are covered. `POOL_W`'s pitch is a
6048    /// whole number of texels for both formats, which `GL_PACK_ROW_LENGTH`
6049    /// expresses; the F16 case adds a 4-byte pad, which an 8-byte texel does
6050    /// not divide, so those rows are read tight and spread afterwards. The
6051    /// tight stride is the control: it must still take the read it always
6052    /// did.
6053    ///
6054    /// The oracle is the same convert into a tight PBO of the same geometry.
6055    /// `compare_float_images` reads both sides through `copy_to_flat`, the
6056    /// tensor's own row compaction, so it compares logical elements and not
6057    /// mapping offsets.
6058    #[test]
6059    #[cfg(all(target_os = "windows", feature = "opengl"))]
6060    fn windows_padded_float_pbo_rows_land_at_the_declared_stride() {
6061        if !is_opengl_available() {
6062            eprintln!("SKIPPED: {} - OpenGL not available", function!());
6063            return;
6064        }
6065        const W: usize = 64;
6066        const H: usize = 32;
6067        const POOL_W: usize = 96;
6068
6069        let mut gpu = ImageProcessor::new().unwrap();
6070        let support = gpu.supported_render_dtypes();
6071        let src = synthetic_rgba(W, H);
6072
6073        // (format, dtype, supported, the pitches to declare)
6074        let cases: [(PixelFormat, DType, bool, &[usize]); 2] = [
6075            (
6076                PixelFormat::PlanarRgb,
6077                DType::F16,
6078                support.f16,
6079                // Tight plane row, the pool's pitch (a whole texel count),
6080                // and a 4-byte pad that no 8-byte texel divides.
6081                &[W * 2, POOL_W * 2, W * 2 + 4],
6082            ),
6083            (
6084                PixelFormat::Rgb,
6085                DType::F32,
6086                support.f32,
6087                &[W * 3 * 4, POOL_W * 3 * 4],
6088            ),
6089        ];
6090
6091        for (fmt, dtype, supported, strides) in cases {
6092            if !supported {
6093                eprintln!("SKIPPED: {} - {fmt:?}/{dtype:?} not reported", function!());
6094                continue;
6095            }
6096            let Some(gl) = gpu.opengl.as_ref() else {
6097                eprintln!("SKIPPED: {} - no GL processor", function!());
6098                return;
6099            };
6100            // The oracle: a tight PBO of the target geometry, same route.
6101            let mut reference = match gl.create_pbo_image_dtype(W, H, fmt, dtype) {
6102                Ok(t) => t,
6103                Err(e) => {
6104                    eprintln!("SKIPPED: {} - no float PBO images: {e:?}", function!());
6105                    return;
6106                }
6107            };
6108            assert_eq!(
6109                reference.memory(),
6110                TensorMemory::Pbo,
6111                "{fmt:?}/{dtype:?}: the destination must be a PBO to reach this readback"
6112            );
6113            gpu.convert(
6114                &src,
6115                &mut reference,
6116                Rotation::None,
6117                Flip::None,
6118                Crop::default(),
6119            )
6120            .unwrap_or_else(|e| panic!("{fmt:?}/{dtype:?}: tight convert failed: {e}"));
6121
6122            for &stride in strides {
6123                let label = format!("{fmt:?}/{dtype:?} at a {stride} B pitch");
6124                // Allocate at the pool width, then narrow the geometry and
6125                // declare the pitch, as the u8 stride tests do.
6126                let mut padded = gpu
6127                    .opengl
6128                    .as_ref()
6129                    .expect("GL processor")
6130                    .create_pbo_image_dtype(POOL_W, H, fmt, dtype)
6131                    .unwrap_or_else(|e| panic!("{label}: pool PBO: {e:?}"));
6132                padded
6133                    .configure_image(W, H, fmt)
6134                    .unwrap_or_else(|e| panic!("{label}: narrow the pool tensor: {e}"));
6135                padded
6136                    .set_row_stride(stride)
6137                    .unwrap_or_else(|e| panic!("{label}: declare the pitch: {e}"));
6138                assert_eq!(padded.effective_row_stride(), Some(stride), "{label}");
6139
6140                let before = gpu.convert_fallback_count();
6141                gpu.convert(
6142                    &src,
6143                    &mut padded,
6144                    Rotation::None,
6145                    Flip::None,
6146                    Crop::default(),
6147                )
6148                .unwrap_or_else(|e| panic!("{label}: padded convert failed: {e}"));
6149                // A CPU fallback writes the destination with the stride-aware
6150                // CPU writers and would pass without the GL readback running.
6151                assert_eq!(
6152                    gpu.convert_fallback_count(),
6153                    before,
6154                    "{label}: fell back to the CPU backend, so the float PBO readback under test never ran"
6155                );
6156
6157                let tol = if dtype == DType::F16 {
6158                    1.0 / 1024.0
6159                } else {
6160                    1e-6
6161                };
6162                compare_float_images(&padded, &reference, tol, &label);
6163            }
6164        }
6165    }
6166
6167    /// Every float destination layout the GL backend reports it can render
6168    /// must render zero-copy into the texture and match the CPU converter
6169    /// element for element: F16 and F32 planar (three and four planes),
6170    /// interleaved float RGB, and float RGBA.
6171    ///
6172    /// The destination memory is requested explicitly so each layout is
6173    /// exercised as a texture whatever `create_image`'s own allocation
6174    /// policy would pick for it; the policy itself is covered by
6175    /// `windows_create_image_prefers_textures_and_convert_with_fence_returns_a_set_event`.
6176    #[test]
6177    #[cfg(all(target_os = "windows", feature = "opengl"))]
6178    fn windows_float_destinations_match_the_cpu_converter_for_every_layout() {
6179        if !is_opengl_available() {
6180            eprintln!("SKIPPED: {} - OpenGL not available", function!());
6181            return;
6182        }
6183        if !edgefirst_tensor::is_gpu_buffer_available() {
6184            eprintln!("SKIPPED: {} - no zero-copy GPU buffers", function!());
6185            return;
6186        }
6187        let mut gpu = ImageProcessor::new().unwrap();
6188        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
6189            backend: ComputeBackend::Cpu,
6190            ..Default::default()
6191        })
6192        .unwrap();
6193        let support = gpu.supported_render_dtypes();
6194        eprintln!("{}: supported_render_dtypes = {support:?}", function!());
6195        let src = synthetic_rgba(640, 480);
6196        for (fmt, dtype, need) in [
6197            (PixelFormat::PlanarRgb, DType::F16, support.f16),
6198            (PixelFormat::PlanarRgb, DType::F32, support.f32),
6199            (PixelFormat::PlanarRgba, DType::F16, support.f16),
6200            (PixelFormat::PlanarRgba, DType::F32, support.f32),
6201            (PixelFormat::Rgb, DType::F16, support.f16),
6202            (PixelFormat::Rgb, DType::F32, support.f32),
6203            (PixelFormat::Rgba, DType::F16, support.f16),
6204            (PixelFormat::Rgba, DType::F32, support.f32),
6205        ] {
6206            if !need {
6207                eprintln!("{}: {fmt:?}/{dtype:?} not reported", function!());
6208                continue;
6209            }
6210            let mut dst_gpu = gpu
6211                .create_image(
6212                    640,
6213                    480,
6214                    fmt,
6215                    dtype,
6216                    Some(TensorMemory::DmaBuf),
6217                    edgefirst_tensor::CpuAccess::ReadWrite,
6218                )
6219                .unwrap();
6220            assert_eq!(
6221                dst_gpu.memory(),
6222                TensorMemory::DmaBuf,
6223                "{fmt:?}/{dtype:?} must be a texture"
6224            );
6225            let mut dst_cpu = TensorDyn::image(
6226                640,
6227                480,
6228                fmt,
6229                dtype,
6230                Some(TensorMemory::Mem),
6231                edgefirst_tensor::CpuAccess::ReadWrite,
6232            )
6233            .unwrap();
6234            let before = gpu.convert_fallback_count();
6235            gpu.convert(
6236                &src,
6237                &mut dst_gpu,
6238                Rotation::None,
6239                Flip::None,
6240                Crop::default(),
6241            )
6242            .unwrap();
6243            cpu.convert(
6244                &src,
6245                &mut dst_cpu,
6246                Rotation::None,
6247                Flip::None,
6248                Crop::default(),
6249            )
6250            .unwrap();
6251            assert_eq!(
6252                gpu.convert_fallback_count(),
6253                before,
6254                "{fmt:?}/{dtype:?} fell back to CPU"
6255            );
6256            let tol = if dtype == DType::F16 {
6257                1.0 / 1024.0
6258            } else {
6259                1e-6
6260            };
6261            compare_float_images(&dst_gpu, &dst_cpu, tol, &format!("{fmt:?}/{dtype:?}"));
6262        }
6263    }
6264
6265    // The tests below wait on a Windows completion fence directly, so they
6266    // need the raw wait call — no crate in this workspace wraps it.
6267    #[cfg(target_os = "windows")]
6268    #[link(name = "kernel32")]
6269    extern "system" {
6270        fn WaitForSingleObject(handle: *mut std::ffi::c_void, milliseconds: u32) -> u32;
6271    }
6272
6273    /// A batch of deferred converts records one distinct fence value per
6274    /// tile and submits once, at `flush`.
6275    ///
6276    /// The values are what a GPU consumer waits on, so they have to stay
6277    /// per-destination and strictly increasing; the submission is what
6278    /// batching exists to spare, so there must be exactly one of it. The
6279    /// destinations are compared against the same convert done standalone,
6280    /// which is what proves the deferred submission did not lose a tile.
6281    #[cfg(all(target_os = "windows", feature = "opengl"))]
6282    #[test]
6283    fn windows_deferred_batch_records_one_value_per_tile_and_submits_once() {
6284        use edgefirst_tensor::CpuAccess;
6285
6286        if !edgefirst_tensor::is_gpu_buffer_available() {
6287            eprintln!("SKIPPED: {} - no zero-copy GPU buffers", function!());
6288            return;
6289        }
6290        let mut p = match ImageProcessor::new() {
6291            Ok(p) => p,
6292            Err(e) => {
6293                eprintln!(
6294                    "SKIPPED: {} - ImageProcessor init failed ({e:?})",
6295                    function!()
6296                );
6297                return;
6298            }
6299        };
6300        let (w, h, tiles) = (640usize, 480usize, 4usize);
6301        let src = synthetic_rgba(w, h);
6302        let mut dsts: Vec<TensorDyn> = (0..tiles)
6303            .map(|_| {
6304                p.create_image(w, h, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
6305                    .unwrap()
6306            })
6307            .collect();
6308        if dsts[0].memory() != TensorMemory::DmaBuf {
6309            eprintln!("SKIPPED: {} - destinations are not textures", function!());
6310            return;
6311        }
6312        for dst in &mut dsts {
6313            p.convert_deferred(&src, dst, Rotation::None, Flip::None, Crop::default())
6314                .unwrap();
6315        }
6316        let values: Vec<u64> = dsts
6317            .iter()
6318            .map(|d| {
6319                d.gpu_completion()
6320                    .unwrap()
6321                    .expect("each deferred convert records its own completion")
6322                    .value
6323            })
6324            .collect();
6325        for pair in values.windows(2) {
6326            assert!(
6327                pair[1] > pair[0],
6328                "the batch allocated {values:?}, which is not one increasing value per tile"
6329            );
6330        }
6331        p.flush().unwrap();
6332        // The one submission the batch owed has happened, so every recorded
6333        // value is reachable.
6334        let dev = edgefirst_tensor::d3d11::device().unwrap();
6335        let last = *values.last().unwrap();
6336        let ev = dev.event_for(last).unwrap();
6337        // SAFETY: `ev` owns a live Win32 event handle for the duration of
6338        // this call.
6339        assert_eq!(
6340            unsafe {
6341                WaitForSingleObject(
6342                    std::os::windows::io::AsRawHandle::as_raw_handle(&ev) as _,
6343                    5000,
6344                )
6345            },
6346            0,
6347            "the batch's newest value {last} never arrived after flush"
6348        );
6349
6350        let mut solo = p
6351            .create_image(w, h, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
6352            .unwrap();
6353        p.convert(&src, &mut solo, Rotation::None, Flip::None, Crop::default())
6354            .unwrap();
6355        // Read through the tensor's own compaction: a Windows texture's
6356        // staging rows are pitch-padded, so a flat read of the mapping would
6357        // compare padding as pixels.
6358        let flat = |img: &TensorDyn| -> Vec<u8> {
6359            let t = img.as_typed::<u8>().expect("u8 image");
6360            let mut out = vec![0u8; t.shape().iter().product::<usize>()];
6361            t.copy_to_flat(&mut out).expect("compact the padded rows");
6362            out
6363        };
6364        let want = flat(&solo);
6365        for (i, dst) in dsts.iter().enumerate() {
6366            assert_eq!(
6367                flat(dst),
6368                want,
6369                "tile {i} does not match a standalone convert"
6370            );
6371        }
6372    }
6373
6374    /// A processor dropped with a batch still open submits what that batch
6375    /// recorded.
6376    ///
6377    /// The values are already published on destinations that outlive the
6378    /// processor, so a consumer may be waiting on one; a deferred signal that
6379    /// is never submitted is a wait nothing can satisfy. `flush` is what
6380    /// normally pays it, and the whole point here is that the caller never
6381    /// calls it.
6382    #[cfg(all(target_os = "windows", feature = "opengl"))]
6383    #[test]
6384    fn windows_dropping_a_processor_submits_an_open_batch() {
6385        use edgefirst_tensor::CpuAccess;
6386
6387        if !edgefirst_tensor::is_gpu_buffer_available() {
6388            eprintln!("SKIPPED: {} - no zero-copy GPU buffers", function!());
6389            return;
6390        }
6391        let src = synthetic_rgba(320, 240);
6392        let recorded = {
6393            let mut p = match ImageProcessor::new() {
6394                Ok(p) => p,
6395                Err(e) => {
6396                    eprintln!("SKIPPED: {} - init failed ({e:?})", function!());
6397                    return;
6398                }
6399            };
6400            let mut dst = p
6401                .create_image(
6402                    320,
6403                    240,
6404                    PixelFormat::Rgba,
6405                    DType::U8,
6406                    None,
6407                    CpuAccess::Read,
6408                )
6409                .unwrap();
6410            if dst.memory() != TensorMemory::DmaBuf {
6411                eprintln!("SKIPPED: {} - destination is not a texture", function!());
6412                return;
6413            }
6414            p.convert_deferred(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
6415                .unwrap();
6416            // No `flush`: the drop below is what has to settle the batch.
6417            dst.gpu_completion()
6418                .unwrap()
6419                .expect("the deferred convert recorded a completion")
6420                .value
6421        };
6422
6423        let dev = edgefirst_tensor::d3d11::device().unwrap();
6424        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
6425        while dev.completed_value() < recorded {
6426            assert!(
6427                std::time::Instant::now() < deadline,
6428                "the dropped processor never submitted its batch: fence at {}, recorded {recorded}",
6429                dev.completed_value()
6430            );
6431            std::thread::yield_now();
6432        }
6433    }
6434
6435    /// After Task B5, `create_image(.., None, ..)` reaches the Windows
6436    /// Dma-first arm (a D3D11 texture, not a PBO or `Mem` tensor), and
6437    /// `convert_with_fence` is available on every platform: on Windows it
6438    /// returns a manual-reset event set when the GPU work completes.
6439    #[cfg(all(target_os = "windows", feature = "opengl"))]
6440    #[test]
6441    fn windows_create_image_prefers_textures_and_convert_with_fence_returns_a_set_event() {
6442        use edgefirst_tensor::CpuAccess;
6443        use std::os::windows::io::AsRawHandle;
6444
6445        if !edgefirst_tensor::is_gpu_buffer_available() {
6446            eprintln!("SKIPPED: {} - no zero-copy GPU buffers", function!());
6447            return;
6448        }
6449        let mut p = ImageProcessor::new().unwrap();
6450        let src = synthetic_rgba(320, 240);
6451        let mut dst = p
6452            .create_image(
6453                320,
6454                240,
6455                PixelFormat::Rgba,
6456                DType::U8,
6457                None,
6458                CpuAccess::Read,
6459            )
6460            .unwrap();
6461        // Without a GL backend (CI's no-ANGLE coverage pass) `create_image`
6462        // answers host memory and there is no fence to wait on; the lanes
6463        // that set HAL_TEST_REQUIRE_GL=1 still hold the texture-first claim.
6464        if dst.memory() != TensorMemory::DmaBuf
6465            && !std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1")
6466        {
6467            eprintln!(
6468                "SKIPPED: {} - create_image gave {:?}, not a texture (no GL backend)",
6469                function!(),
6470                dst.memory()
6471            );
6472            return;
6473        }
6474        assert_eq!(
6475            dst.memory(),
6476            TensorMemory::DmaBuf,
6477            "create_image(None) must prefer a texture on Windows"
6478        );
6479        // Read before the wait: the recorded value must be the one the fence
6480        // names, not an older point the destination happened to carry.
6481        let before = dst.gpu_completion().unwrap().map(|c| c.value);
6482        let fence = p
6483            .convert_with_fence(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
6484            .unwrap()
6485            .expect("Windows exports a fence");
6486        let recorded = dst
6487            .gpu_completion()
6488            .unwrap()
6489            .expect("the convert recorded a completion")
6490            .value;
6491        assert!(
6492            before.is_none_or(|b| recorded > b),
6493            "the fenced convert recorded a newer value than {before:?}"
6494        );
6495        // One flush and one signal for the whole call: the value the
6496        // destination carries is the newest the device has allocated, so
6497        // nothing was signalled after `record_completion`. Two signals (the
6498        // shape this replaced) would leave `last_signalled` one past it.
6499        assert_eq!(
6500            edgefirst_tensor::d3d11::device().unwrap().last_signalled(),
6501            recorded,
6502            "a fenced convert must signal exactly once"
6503        );
6504        // SAFETY: `fence` owns a live Win32 event handle returned by
6505        // `convert_with_fence`; WaitForSingleObject only reads the handle,
6506        // which stays valid for the duration of this call because `fence`
6507        // is not dropped until after it.
6508        assert_eq!(
6509            unsafe { WaitForSingleObject(fence.as_raw_handle() as _, 5000) },
6510            0,
6511            "fence was not set within 5 seconds"
6512        );
6513        // The event and the destination's own completion name the same
6514        // value, so a consumer may wait on either: the event fired, and the
6515        // device's completed value has reached the value `dst` reports.
6516        assert!(
6517            edgefirst_tensor::d3d11::device().unwrap().completed_value() >= recorded,
6518            "the event fired, so the recorded value {recorded} is complete"
6519        );
6520        assert_eq!(
6521            dst.gpu_completion().unwrap().map(|c| c.value),
6522            Some(recorded),
6523            "the destination still reports the value the event waited on"
6524        );
6525        let mut expected = TensorDyn::image(
6526            320,
6527            240,
6528            PixelFormat::Rgba,
6529            DType::U8,
6530            Some(TensorMemory::Mem),
6531            CpuAccess::ReadWrite,
6532        )
6533        .unwrap();
6534        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
6535            backend: ComputeBackend::Cpu,
6536            ..Default::default()
6537        })
6538        .unwrap();
6539        cpu.convert(
6540            &src,
6541            &mut expected,
6542            Rotation::None,
6543            Flip::None,
6544            Crop::default(),
6545        )
6546        .unwrap();
6547        compare_images(&dst, &expected, 0.0, "fenced convert");
6548    }
6549
6550    /// A Windows GL test's destination must be a texture; without a GL backend
6551    /// (CI's no-ANGLE coverage pass) `create_image` answers host memory and the
6552    /// test skips, unless the lane set HAL_TEST_REQUIRE_GL=1, which keeps the
6553    /// texture-first claim a hard assertion.
6554    #[cfg(all(target_os = "windows", feature = "opengl"))]
6555    fn texture_or_skip(t: &TensorDyn, test: &str) -> bool {
6556        if t.memory() == TensorMemory::DmaBuf {
6557            return true;
6558        }
6559        if std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1") {
6560            panic!(
6561                "{test}: create_image(None) must prefer a texture on Windows, got {:?}",
6562                t.memory()
6563            );
6564        }
6565        eprintln!(
6566            "SKIPPED: {test} - create_image gave {:?}, not a texture (no GL backend)",
6567            t.memory()
6568        );
6569        false
6570    }
6571
6572    /// A draw into a texture destination records a completion on it the way
6573    /// a convert does, so a device consumer can wait on the drawn frame. The
6574    /// destination is `CpuAccess::Read`: only the GL backend can write it,
6575    /// so a CPU fallback fails the draw outright instead of leaving the
6576    /// completion empty.
6577    #[cfg(all(target_os = "windows", feature = "opengl"))]
6578    #[test]
6579    fn windows_draw_decoded_masks_into_a_texture_records_a_completion() {
6580        use edgefirst_tensor::{CpuAccess, Segmentation};
6581        use ndarray::Array3;
6582
6583        if !edgefirst_tensor::is_gpu_buffer_available() {
6584            eprintln!("SKIPPED: {} - no zero-copy GPU buffers", function!());
6585            return;
6586        }
6587        let mut p = ImageProcessor::new().unwrap();
6588        let mut dst = p
6589            .create_image(64, 64, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
6590            .unwrap();
6591        if !texture_or_skip(&dst, function!()) {
6592            return;
6593        }
6594        assert!(
6595            dst.gpu_completion().unwrap().is_none(),
6596            "nothing has written this texture yet"
6597        );
6598
6599        let detect = DetectBox {
6600            bbox: [0.25, 0.25, 0.75, 0.75].into(),
6601            score: 0.99,
6602            label: 0,
6603        };
6604        let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
6605        let seg = Segmentation {
6606            segmentation: edgefirst_tensor::Tensor::from_arrayview3(seg_arr.view())
6607                .unwrap()
6608                .into(),
6609            xmin: 0.25,
6610            ymin: 0.25,
6611            xmax: 0.75,
6612            ymax: 0.75,
6613        };
6614        p.draw_decoded_masks(&mut dst, &[detect], &[seg], MaskOverlay::default())
6615            .expect("draw into a read-only texture");
6616
6617        let completion = dst
6618            .gpu_completion()
6619            .unwrap()
6620            .expect("the draw recorded no completion on dst");
6621        assert!(completion.value > 0, "completion value must be non-zero");
6622
6623        // The drawn frame is read back through the texture's staging copy;
6624        // `copy_to_flat` compacts its padded rows into tight RGBA pixels and
6625        // needs only a read mapping, which the read-only texture allows.
6626        // The corner is cleared and the box centre carries the mask colour.
6627        let mut flat = vec![0u8; 64 * 64 * 4];
6628        dst.as_typed::<u8>()
6629            .unwrap()
6630            .copy_to_flat(&mut flat)
6631            .expect("read the drawn texture");
6632        let px = |x: usize, y: usize| -> [u8; 4] {
6633            let o = (y * 64 + x) * 4;
6634            [flat[o], flat[o + 1], flat[o + 2], flat[o + 3]]
6635        };
6636        assert_eq!(px(2, 2), [0, 0, 0, 0], "corner (2,2) was not cleared");
6637        assert_ne!(px(32, 32), [0, 0, 0, 0], "centre (32,32) was not coloured");
6638    }
6639
6640    /// The full-GPU proto draw (forced GL, no CPU materialize) records a
6641    /// completion on a texture destination too.
6642    #[cfg(all(target_os = "windows", feature = "opengl"))]
6643    #[test]
6644    fn windows_draw_proto_masks_into_a_texture_records_a_completion() {
6645        use edgefirst_tensor::{CpuAccess, Tensor};
6646
6647        if !edgefirst_tensor::is_gpu_buffer_available() {
6648            eprintln!("SKIPPED: {} - no zero-copy GPU buffers", function!());
6649            return;
6650        }
6651        // Forcing OpenGL fails outright without a GL backend (CI's no-ANGLE
6652        // coverage pass); that is the same skip the texture guard below gives,
6653        // unless the lane set HAL_TEST_REQUIRE_GL=1.
6654        let mut p = match with_force_backend(Some("opengl"), ImageProcessor::new) {
6655            Ok(p) => p,
6656            Err(e) if !std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1") => {
6657                eprintln!("SKIPPED: {} - no OpenGL backend: {e}", function!());
6658                return;
6659            }
6660            Err(e) => panic!("forced OpenGL backend unavailable: {e}"),
6661        };
6662        let mut dst = p
6663            .create_image(64, 64, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
6664            .unwrap();
6665        if !texture_or_skip(&dst, function!()) {
6666            return;
6667        }
6668
6669        let detect = DetectBox {
6670            bbox: [0.25, 0.25, 0.75, 0.75].into(),
6671            score: 0.99,
6672            label: 0,
6673        };
6674        // One prototype channel of ones and a positive coefficient: every
6675        // pixel of the box is foreground.
6676        let proto = ProtoData {
6677            mask_coefficients: TensorDyn::F32(
6678                Tensor::<f32>::from_slice(&[1.0_f32], &[1, 1]).unwrap(),
6679            ),
6680            protos: TensorDyn::F32(
6681                Tensor::<f32>::from_slice(&vec![1.0_f32; 8 * 8], &[8, 8, 1]).unwrap(),
6682            ),
6683            layout: ProtoLayout::Nhwc,
6684        };
6685        p.draw_proto_masks(&mut dst, &[detect], &proto, MaskOverlay::default())
6686            .expect("forced-GL proto draw into a read-only texture");
6687
6688        let completion = dst
6689            .gpu_completion()
6690            .unwrap()
6691            .expect("the proto draw recorded no completion on dst");
6692        assert!(completion.value > 0, "completion value must be non-zero");
6693    }
6694
6695    /// An empty draw with a host-memory background onto a texture
6696    /// destination leaves the background in the texture, rows in order.
6697    /// The gradient pattern makes a missing or vertically flipped base
6698    /// layer differ from the expected image.
6699    #[cfg(all(target_os = "windows", feature = "opengl"))]
6700    #[test]
6701    fn windows_draw_decoded_masks_composites_a_host_background_onto_a_texture() {
6702        use edgefirst_tensor::CpuAccess;
6703
6704        if !edgefirst_tensor::is_gpu_buffer_available() {
6705            eprintln!("SKIPPED: {} - no zero-copy GPU buffers", function!());
6706            return;
6707        }
6708        let mut p = ImageProcessor::new().unwrap();
6709        let bg = synthetic_rgba(64, 48);
6710        let mut dst = p
6711            .create_image(64, 48, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
6712            .unwrap();
6713        if !texture_or_skip(&dst, function!()) {
6714            return;
6715        }
6716        p.draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::new().with_background(&bg))
6717            .expect("empty draw with a host background onto a texture");
6718        assert_pixels_identical(&dst, &bg, "host background onto a texture");
6719        assert!(dst.gpu_completion().unwrap().is_some());
6720    }
6721
6722    /// The same draw with the background in a texture of its own: the
6723    /// base layer is sampled from the background texture directly.
6724    #[cfg(all(target_os = "windows", feature = "opengl"))]
6725    #[test]
6726    fn windows_draw_decoded_masks_composites_a_texture_background_onto_a_texture() {
6727        use edgefirst_tensor::CpuAccess;
6728
6729        if !edgefirst_tensor::is_gpu_buffer_available() {
6730            eprintln!("SKIPPED: {} - no zero-copy GPU buffers", function!());
6731            return;
6732        }
6733        let mut p = ImageProcessor::new().unwrap();
6734        let expected = synthetic_rgba(64, 48);
6735        let bg = p
6736            .create_image(
6737                64,
6738                48,
6739                PixelFormat::Rgba,
6740                DType::U8,
6741                None,
6742                CpuAccess::ReadWrite,
6743            )
6744            .unwrap();
6745        if !texture_or_skip(&bg, function!()) {
6746            return;
6747        }
6748        fill_synthetic_rgba(&bg);
6749        let mut dst = p
6750            .create_image(64, 48, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
6751            .unwrap();
6752        assert_eq!(dst.memory(), TensorMemory::DmaBuf);
6753        p.draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::new().with_background(&bg))
6754            .expect("empty draw with a texture background onto a texture");
6755        assert_pixels_identical(&dst, &expected, "texture background onto a texture");
6756        assert!(dst.gpu_completion().unwrap().is_some());
6757    }
6758
6759    /// A draw into a `view()` of a texture fills that band and leaves its
6760    /// sibling alone. The GL draw declines a sub-region destination
6761    /// (`check_draw_dst_placement`) and the CPU backend, which offsets into
6762    /// the parent, takes it; the zero-copy arm would otherwise have cleared
6763    /// the parent's first rows instead of the view's.
6764    #[cfg(all(target_os = "windows", feature = "opengl"))]
6765    #[test]
6766    fn windows_draw_decoded_masks_into_a_texture_view_fills_only_its_band() {
6767        use edgefirst_tensor::CpuAccess;
6768
6769        if !edgefirst_tensor::is_gpu_buffer_available() {
6770            eprintln!("SKIPPED: {} - no zero-copy GPU buffers", function!());
6771            return;
6772        }
6773        let (w, h) = (32usize, 64usize);
6774        let mut p = ImageProcessor::new().unwrap();
6775        let parent = p
6776            .create_image(
6777                w,
6778                h,
6779                PixelFormat::Rgba,
6780                DType::U8,
6781                None,
6782                CpuAccess::ReadWrite,
6783            )
6784            .unwrap();
6785        if !texture_or_skip(&parent, function!()) {
6786            return;
6787        }
6788        fill_synthetic_rgba(&parent);
6789
6790        // The lower band only. An empty draw with no background clears its
6791        // destination, so a write at the parent's origin would show up as
6792        // cleared pixels in the upper band.
6793        let mut band = parent
6794            .view(edgefirst_tensor::Region::new(0, h / 2, w, h / 2))
6795            .unwrap();
6796        p.draw_decoded_masks(&mut band, &[], &[], MaskOverlay::default())
6797            .expect("empty draw into a texture view");
6798        drop(band);
6799
6800        let mut flat = vec![0u8; w * h * 4];
6801        parent
6802            .as_typed::<u8>()
6803            .unwrap()
6804            .copy_to_flat(&mut flat)
6805            .expect("read the parent texture");
6806        for y in 0..h {
6807            for x in 0..w {
6808                let o = (y * w + x) * 4;
6809                let got = [flat[o], flat[o + 1], flat[o + 2], flat[o + 3]];
6810                let want = if y < h / 2 {
6811                    [x as u8, y as u8, (x + y) as u8, 255]
6812                } else {
6813                    [0, 0, 0, 0]
6814                };
6815                assert_eq!(got, want, "pixel ({x},{y})");
6816            }
6817        }
6818
6819        // The GL decline is what sent the draw above to the CPU: with GL
6820        // forced there is no CPU arm to fall through to, so it surfaces.
6821        let mut forced = with_force_backend(Some("opengl"), ImageProcessor::new).unwrap();
6822        let mut band = parent
6823            .view(edgefirst_tensor::Region::new(0, h / 2, w, h / 2))
6824            .unwrap();
6825        let err = forced
6826            .draw_decoded_masks(&mut band, &[], &[], MaskOverlay::default())
6827            .expect_err("forced GL must decline a view destination");
6828        assert!(
6829            matches!(err, Error::NotSupported(_)),
6830            "expected NotSupported, got {err:?}"
6831        );
6832    }
6833
6834    /// `set_class_colors` reaches the CPU backend even when GL accepts it, so
6835    /// a draw GL declines still renders the caller's palette. The view
6836    /// destination is the reachable decline: `check_draw_dst_placement`
6837    /// refuses it and the CPU backend draws the mask.
6838    #[cfg(all(target_os = "windows", feature = "opengl"))]
6839    #[test]
6840    fn windows_class_colors_reach_the_cpu_backend_behind_a_declined_gl_draw() {
6841        use edgefirst_tensor::{CpuAccess, Segmentation};
6842        use ndarray::Array3;
6843
6844        if !edgefirst_tensor::is_gpu_buffer_available() {
6845            eprintln!("SKIPPED: {} - no zero-copy GPU buffers", function!());
6846            return;
6847        }
6848        const CUSTOM: [u8; 4] = [7, 200, 13, 255];
6849        assert_ne!(
6850            CUSTOM[..3],
6851            DEFAULT_COLORS_U8[0][..3],
6852            "the custom colour must differ from the default palette's, or \
6853             this test cannot tell them apart"
6854        );
6855
6856        let (w, h) = (32usize, 64usize);
6857        let mut p = ImageProcessor::new().unwrap();
6858        let parent = p
6859            .create_image(
6860                w,
6861                h,
6862                PixelFormat::Rgba,
6863                DType::U8,
6864                None,
6865                CpuAccess::ReadWrite,
6866            )
6867            .unwrap();
6868        // Without a GL backend (CI's no-ANGLE coverage pass) `create_image`
6869        // answers host memory and nothing declines the draw; the test needs
6870        // the texture-backed parent so the view is the reachable decline. The
6871        // lanes that set HAL_TEST_REQUIRE_GL=1 still require the texture.
6872        if parent.memory() != TensorMemory::DmaBuf
6873            && !std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1")
6874        {
6875            eprintln!(
6876                "SKIPPED: {} - create_image gave {:?}, not a texture (no GL backend)",
6877                function!(),
6878                parent.memory()
6879            );
6880            return;
6881        }
6882        p.set_class_colors(&[CUSTOM]).unwrap();
6883
6884        let detect = DetectBox {
6885            bbox: [0.25, 0.25, 0.75, 0.75].into(),
6886            score: 0.99,
6887            label: 0,
6888        };
6889        let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
6890        let seg = Segmentation {
6891            segmentation: edgefirst_tensor::Tensor::from_arrayview3(seg_arr.view())
6892                .unwrap()
6893                .into(),
6894            xmin: 0.25,
6895            ymin: 0.25,
6896            xmax: 0.75,
6897            ymax: 0.75,
6898        };
6899        let mut band = parent
6900            .view(edgefirst_tensor::Region::new(0, h / 2, w, h / 2))
6901            .unwrap();
6902        p.draw_decoded_masks(&mut band, &[detect], &[seg], MaskOverlay::default())
6903            .expect("draw into a texture view");
6904        drop(band);
6905
6906        let mut flat = vec![0u8; w * h * 4];
6907        parent
6908            .as_typed::<u8>()
6909            .unwrap()
6910            .copy_to_flat(&mut flat)
6911            .expect("read the parent texture");
6912        // The band's centre, inside the box. The mask blend writes RGB only;
6913        // alpha is whatever the base layer left, which is the clear.
6914        let o = ((h / 2 + h / 4) * w + w / 2) * 4;
6915        assert_eq!(
6916            &flat[o..o + 3],
6917            &CUSTOM[..3],
6918            "the CPU backend drew the default palette, so it never received \
6919             set_class_colors"
6920        );
6921    }
6922
6923    /// Two independently-created `ImageProcessor`s, each converting into its
6924    /// own texture destination in lockstep, must not bleed GL state into
6925    /// each other across 200 iterations (the destination created by
6926    /// `create_image(None)` is the texture arm this task adds).
6927    #[cfg(all(target_os = "windows", feature = "opengl"))]
6928    #[test]
6929    fn two_processors_alternate_on_texture_destinations_without_state_bleed() {
6930        use edgefirst_tensor::CpuAccess;
6931
6932        if !edgefirst_tensor::is_gpu_buffer_available() {
6933            eprintln!("SKIPPED: {} - no zero-copy GPU buffers", function!());
6934            return;
6935        }
6936        let mut a = ImageProcessor::new().unwrap();
6937        let mut b = ImageProcessor::new().unwrap();
6938        let src_a = synthetic_rgba(512, 512);
6939        let src_b = synthetic_rgba(256, 256);
6940        let mut dst_a = a
6941            .create_image(
6942                512,
6943                512,
6944                PixelFormat::Rgba,
6945                DType::U8,
6946                None,
6947                CpuAccess::Read,
6948            )
6949            .unwrap();
6950        let mut dst_b = b
6951            .create_image(
6952                256,
6953                256,
6954                PixelFormat::Rgba,
6955                DType::U8,
6956                None,
6957                CpuAccess::Read,
6958            )
6959            .unwrap();
6960        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
6961            backend: ComputeBackend::Cpu,
6962            ..Default::default()
6963        })
6964        .unwrap();
6965        let mut ref_a = TensorDyn::image(
6966            512,
6967            512,
6968            PixelFormat::Rgba,
6969            DType::U8,
6970            Some(TensorMemory::Mem),
6971            CpuAccess::ReadWrite,
6972        )
6973        .unwrap();
6974        let mut ref_b = TensorDyn::image(
6975            256,
6976            256,
6977            PixelFormat::Rgba,
6978            DType::U8,
6979            Some(TensorMemory::Mem),
6980            CpuAccess::ReadWrite,
6981        )
6982        .unwrap();
6983        cpu.convert(
6984            &src_a,
6985            &mut ref_a,
6986            Rotation::None,
6987            Flip::None,
6988            Crop::default(),
6989        )
6990        .unwrap();
6991        cpu.convert(
6992            &src_b,
6993            &mut ref_b,
6994            Rotation::None,
6995            Flip::None,
6996            Crop::default(),
6997        )
6998        .unwrap();
6999        for _ in 0..200 {
7000            a.convert(
7001                &src_a,
7002                &mut dst_a,
7003                Rotation::None,
7004                Flip::None,
7005                Crop::default(),
7006            )
7007            .unwrap();
7008            b.convert(
7009                &src_b,
7010                &mut dst_b,
7011                Rotation::None,
7012                Flip::None,
7013                Crop::default(),
7014            )
7015            .unwrap();
7016            compare_images(&dst_a, &ref_a, 0.0, "processor A");
7017            compare_images(&dst_b, &ref_b, 0.0, "processor B");
7018        }
7019    }
7020
7021    #[test]
7022    #[cfg(any(
7023        target_os = "linux",
7024        target_os = "macos",
7025        target_os = "ios",
7026        target_os = "android",
7027        target_os = "windows"
7028    ))]
7029    #[cfg(feature = "opengl")]
7030    fn test_opengl_all_rgba() {
7031        if !is_opengl_available() {
7032            eprintln!("SKIPPED: {} - OpenGL not available", function!());
7033            return;
7034        }
7035
7036        let dst_width = 640;
7037        let dst_height = 640;
7038        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
7039
7040        let mut cpu_converter = CPUProcessor::new();
7041
7042        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
7043
7044        // Every backing this host can allocate: Shm is Unix-only, and the
7045        // zero-copy slot is DMA-BUF on Linux / IOSurface on macOS (nothing on
7046        // Windows yet).
7047        let mut mem = vec![None, Some(TensorMemory::Mem)];
7048        if edgefirst_tensor::is_shm_available() {
7049            mem.push(Some(TensorMemory::Shm));
7050        }
7051        if edgefirst_tensor::is_gpu_buffer_available() {
7052            mem.push(Some(TensorMemory::DmaBuf));
7053        }
7054        let crop = Crop::new().with_source(Some(Region::new(50, 120, 1024, 576)));
7055        for m in mem {
7056            let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), m).unwrap();
7057            let src_dyn = src;
7058
7059            for rot in [
7060                Rotation::None,
7061                Rotation::Clockwise90,
7062                Rotation::Rotate180,
7063                Rotation::CounterClockwise90,
7064            ] {
7065                for flip in [Flip::None, Flip::Horizontal, Flip::Vertical] {
7066                    let cpu_dst = TensorDyn::image(
7067                        dst_width,
7068                        dst_height,
7069                        PixelFormat::Rgba,
7070                        DType::U8,
7071                        m,
7072                        edgefirst_tensor::CpuAccess::ReadWrite,
7073                    )
7074                    .unwrap();
7075                    let gl_dst = TensorDyn::image(
7076                        dst_width,
7077                        dst_height,
7078                        PixelFormat::Rgba,
7079                        DType::U8,
7080                        m,
7081                        edgefirst_tensor::CpuAccess::ReadWrite,
7082                    )
7083                    .unwrap();
7084                    cpu_dst
7085                        .as_typed::<u8>()
7086                        .unwrap()
7087                        .map()
7088                        .unwrap()
7089                        .as_mut_slice()
7090                        .fill(114);
7091                    gl_dst
7092                        .as_typed::<u8>()
7093                        .unwrap()
7094                        .map()
7095                        .unwrap()
7096                        .as_mut_slice()
7097                        .fill(114);
7098
7099                    let mut cpu_dst_dyn = cpu_dst;
7100                    cpu_converter
7101                        .convert(&src_dyn, &mut cpu_dst_dyn, Rotation::None, Flip::None, crop)
7102                        .unwrap();
7103                    let cpu_dst = {
7104                        let mut __t = cpu_dst_dyn.into_u8().unwrap();
7105                        __t.set_format(PixelFormat::Rgba).unwrap();
7106                        TensorDyn::from(__t)
7107                    };
7108
7109                    let mut gl_dst_dyn = gl_dst;
7110                    gl_converter
7111                        .convert(&src_dyn, &mut gl_dst_dyn, Rotation::None, Flip::None, crop)
7112                        .map_err(|e| {
7113                            log::error!("error mem {m:?} rot {rot:?} error: {e:?}");
7114                            e
7115                        })
7116                        .unwrap();
7117                    let gl_dst = {
7118                        let mut __t = gl_dst_dyn.into_u8().unwrap();
7119                        __t.set_format(PixelFormat::Rgba).unwrap();
7120                        TensorDyn::from(__t)
7121                    };
7122
7123                    compare_images(
7124                        &gl_dst,
7125                        &cpu_dst,
7126                        0.98,
7127                        &format!("{} {:?} {:?}", function!(), rot, flip),
7128                    );
7129                }
7130            }
7131        }
7132    }
7133
7134    #[test]
7135    #[cfg(target_os = "linux")]
7136    fn test_cpu_rotate() {
7137        for rot in [
7138            Rotation::Clockwise90,
7139            Rotation::Rotate180,
7140            Rotation::CounterClockwise90,
7141        ] {
7142            test_cpu_rotate_(rot);
7143        }
7144    }
7145
7146    #[cfg(target_os = "linux")]
7147    fn test_cpu_rotate_(rot: Rotation) {
7148        // This test rotates the image 4 times and checks that the image was returned to
7149        // be the same Currently doesn't check if rotations actually rotated in
7150        // right direction
7151        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
7152
7153        let unchanged_src =
7154            crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
7155        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
7156
7157        let (dst_width, dst_height) = match rot {
7158            Rotation::None | Rotation::Rotate180 => (src.width().unwrap(), src.height().unwrap()),
7159            Rotation::Clockwise90 | Rotation::CounterClockwise90 => {
7160                (src.height().unwrap(), src.width().unwrap())
7161            }
7162        };
7163
7164        let cpu_dst = TensorDyn::image(
7165            dst_width,
7166            dst_height,
7167            PixelFormat::Rgba,
7168            DType::U8,
7169            None,
7170            edgefirst_tensor::CpuAccess::ReadWrite,
7171        )
7172        .unwrap();
7173        let mut cpu_converter = CPUProcessor::new();
7174
7175        // After rotating 4 times, the image should be the same as the original
7176
7177        let (result, src, cpu_dst) = convert_img(
7178            &mut cpu_converter,
7179            src,
7180            cpu_dst,
7181            rot,
7182            Flip::None,
7183            Crop::no_crop(),
7184        );
7185        result.unwrap();
7186
7187        let (result, cpu_dst, src) = convert_img(
7188            &mut cpu_converter,
7189            cpu_dst,
7190            src,
7191            rot,
7192            Flip::None,
7193            Crop::no_crop(),
7194        );
7195        result.unwrap();
7196
7197        let (result, src, cpu_dst) = convert_img(
7198            &mut cpu_converter,
7199            src,
7200            cpu_dst,
7201            rot,
7202            Flip::None,
7203            Crop::no_crop(),
7204        );
7205        result.unwrap();
7206
7207        let (result, _cpu_dst, src) = convert_img(
7208            &mut cpu_converter,
7209            cpu_dst,
7210            src,
7211            rot,
7212            Flip::None,
7213            Crop::no_crop(),
7214        );
7215        result.unwrap();
7216
7217        compare_images(&src, &unchanged_src, 0.98, function!());
7218    }
7219
7220    #[test]
7221    #[cfg(any(
7222        target_os = "linux",
7223        target_os = "macos",
7224        target_os = "ios",
7225        target_os = "android",
7226        target_os = "windows"
7227    ))]
7228    #[cfg(feature = "opengl")]
7229    fn test_opengl_rotate() {
7230        if !is_opengl_available() {
7231            eprintln!("SKIPPED: {} - OpenGL not available", function!());
7232            return;
7233        }
7234
7235        let size = (1280, 720);
7236        // Same backing list as `test_opengl_all_rgba`.
7237        let mut mem = vec![None, Some(TensorMemory::Mem)];
7238        if edgefirst_tensor::is_shm_available() {
7239            mem.push(Some(TensorMemory::Shm));
7240        }
7241        if edgefirst_tensor::is_gpu_buffer_available() {
7242            mem.push(Some(TensorMemory::DmaBuf));
7243        }
7244        for m in mem {
7245            for rot in [
7246                Rotation::Clockwise90,
7247                Rotation::Rotate180,
7248                Rotation::CounterClockwise90,
7249            ] {
7250                test_opengl_rotate_(size, rot, m);
7251            }
7252        }
7253    }
7254
7255    #[cfg(any(
7256        target_os = "linux",
7257        target_os = "macos",
7258        target_os = "ios",
7259        target_os = "android",
7260        target_os = "windows"
7261    ))]
7262    #[cfg(feature = "opengl")]
7263    fn test_opengl_rotate_(
7264        size: (usize, usize),
7265        rot: Rotation,
7266        tensor_memory: Option<TensorMemory>,
7267    ) {
7268        let (dst_width, dst_height) = match rot {
7269            Rotation::None | Rotation::Rotate180 => size,
7270            Rotation::Clockwise90 | Rotation::CounterClockwise90 => (size.1, size.0),
7271        };
7272
7273        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
7274        let src =
7275            crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), tensor_memory).unwrap();
7276
7277        let cpu_dst = TensorDyn::image(
7278            dst_width,
7279            dst_height,
7280            PixelFormat::Rgba,
7281            DType::U8,
7282            None,
7283            edgefirst_tensor::CpuAccess::ReadWrite,
7284        )
7285        .unwrap();
7286        let mut cpu_converter = CPUProcessor::new();
7287
7288        let (result, mut src, cpu_dst) = convert_img(
7289            &mut cpu_converter,
7290            src,
7291            cpu_dst,
7292            rot,
7293            Flip::None,
7294            Crop::no_crop(),
7295        );
7296        result.unwrap();
7297
7298        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
7299
7300        for _ in 0..5 {
7301            let gl_dst = TensorDyn::image(
7302                dst_width,
7303                dst_height,
7304                PixelFormat::Rgba,
7305                DType::U8,
7306                tensor_memory,
7307                edgefirst_tensor::CpuAccess::ReadWrite,
7308            )
7309            .unwrap();
7310            let (result, src_back, gl_dst) = convert_img(
7311                &mut gl_converter,
7312                src,
7313                gl_dst,
7314                rot,
7315                Flip::None,
7316                Crop::no_crop(),
7317            );
7318            result.unwrap();
7319            src = src_back;
7320            compare_images(&gl_dst, &cpu_dst, 0.98, function!());
7321        }
7322    }
7323
7324    #[test]
7325    #[cfg(target_os = "linux")]
7326    fn test_g2d_rotate() {
7327        if !is_g2d_available() {
7328            eprintln!("SKIPPED: test_g2d_rotate - G2D library (libg2d.so.2) not available");
7329            return;
7330        }
7331        if !is_dma_available() {
7332            eprintln!(
7333                "SKIPPED: test_g2d_rotate - DMA memory allocation not available (permission denied or no DMA-BUF support)"
7334            );
7335            return;
7336        }
7337
7338        let size = (1280, 720);
7339        for rot in [
7340            Rotation::Clockwise90,
7341            Rotation::Rotate180,
7342            Rotation::CounterClockwise90,
7343        ] {
7344            test_g2d_rotate_(size, rot);
7345        }
7346    }
7347
7348    #[cfg(target_os = "linux")]
7349    fn test_g2d_rotate_(size: (usize, usize), rot: Rotation) {
7350        let (dst_width, dst_height) = match rot {
7351            Rotation::None | Rotation::Rotate180 => size,
7352            Rotation::Clockwise90 | Rotation::CounterClockwise90 => (size.1, size.0),
7353        };
7354
7355        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
7356        let src = crate::load_image_test_helper(
7357            &file,
7358            Some(PixelFormat::Rgba),
7359            Some(TensorMemory::DmaBuf),
7360        )
7361        .unwrap();
7362
7363        let cpu_dst = TensorDyn::image(
7364            dst_width,
7365            dst_height,
7366            PixelFormat::Rgba,
7367            DType::U8,
7368            None,
7369            edgefirst_tensor::CpuAccess::ReadWrite,
7370        )
7371        .unwrap();
7372        let mut cpu_converter = CPUProcessor::new();
7373
7374        let (result, src, cpu_dst) = convert_img(
7375            &mut cpu_converter,
7376            src,
7377            cpu_dst,
7378            rot,
7379            Flip::None,
7380            Crop::no_crop(),
7381        );
7382        result.unwrap();
7383
7384        let g2d_dst = TensorDyn::image(
7385            dst_width,
7386            dst_height,
7387            PixelFormat::Rgba,
7388            DType::U8,
7389            Some(TensorMemory::DmaBuf),
7390            edgefirst_tensor::CpuAccess::ReadWrite,
7391        )
7392        .unwrap();
7393        let mut g2d_converter = G2DProcessor::new().unwrap();
7394
7395        let (result, _src, g2d_dst) = convert_img(
7396            &mut g2d_converter,
7397            src,
7398            g2d_dst,
7399            rot,
7400            Flip::None,
7401            Crop::no_crop(),
7402        );
7403        result.unwrap();
7404
7405        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
7406        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
7407        // the YUV-matrix delta that forced 0.95 has closed; tightened to
7408        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
7409        // structural gap not exercised by these limited-range fixtures.
7410        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
7411    }
7412
7413    #[test]
7414    fn test_rgba_to_yuyv_resize_cpu() {
7415        let src = load_bytes_to_tensor(
7416            1280,
7417            720,
7418            PixelFormat::Rgba,
7419            None,
7420            &edgefirst_bench::testdata::read("camera720p.rgba"),
7421        )
7422        .unwrap();
7423
7424        let (dst_width, dst_height) = (640, 360);
7425
7426        let dst = TensorDyn::image(
7427            dst_width,
7428            dst_height,
7429            PixelFormat::Yuyv,
7430            DType::U8,
7431            None,
7432            edgefirst_tensor::CpuAccess::ReadWrite,
7433        )
7434        .unwrap();
7435
7436        let dst_through_yuyv = TensorDyn::image(
7437            dst_width,
7438            dst_height,
7439            PixelFormat::Rgba,
7440            DType::U8,
7441            None,
7442            edgefirst_tensor::CpuAccess::ReadWrite,
7443        )
7444        .unwrap();
7445        let dst_direct = TensorDyn::image(
7446            dst_width,
7447            dst_height,
7448            PixelFormat::Rgba,
7449            DType::U8,
7450            None,
7451            edgefirst_tensor::CpuAccess::ReadWrite,
7452        )
7453        .unwrap();
7454
7455        let mut cpu_converter = CPUProcessor::new();
7456
7457        let (result, src, dst) = convert_img(
7458            &mut cpu_converter,
7459            src,
7460            dst,
7461            Rotation::None,
7462            Flip::None,
7463            Crop::no_crop(),
7464        );
7465        result.unwrap();
7466
7467        let (result, _dst, dst_through_yuyv) = convert_img(
7468            &mut cpu_converter,
7469            dst,
7470            dst_through_yuyv,
7471            Rotation::None,
7472            Flip::None,
7473            Crop::no_crop(),
7474        );
7475        result.unwrap();
7476
7477        let (result, _src, dst_direct) = convert_img(
7478            &mut cpu_converter,
7479            src,
7480            dst_direct,
7481            Rotation::None,
7482            Flip::None,
7483            Crop::no_crop(),
7484        );
7485        result.unwrap();
7486
7487        compare_images(&dst_through_yuyv, &dst_direct, 0.98, function!());
7488    }
7489
7490    #[test]
7491    #[cfg(any(
7492        target_os = "linux",
7493        target_os = "macos",
7494        target_os = "ios",
7495        target_os = "android",
7496        target_os = "windows"
7497    ))]
7498    #[cfg(feature = "opengl")]
7499    #[ignore = "opengl doesn't support rendering to PixelFormat::Yuyv texture"]
7500    fn test_rgba_to_yuyv_resize_opengl() {
7501        if !is_opengl_available() {
7502            eprintln!("SKIPPED: {} - OpenGL not available", function!());
7503            return;
7504        }
7505
7506        if !edgefirst_tensor::is_gpu_buffer_available() {
7507            eprintln!(
7508                "SKIPPED: {} - zero-copy GPU buffer (DMA-BUF / IOSurface) not available on this host",
7509                function!()
7510            );
7511            return;
7512        }
7513
7514        let src = load_bytes_to_tensor(
7515            1280,
7516            720,
7517            PixelFormat::Rgba,
7518            None,
7519            &edgefirst_bench::testdata::read("camera720p.rgba"),
7520        )
7521        .unwrap();
7522
7523        let (dst_width, dst_height) = (640, 360);
7524
7525        let dst = TensorDyn::image(
7526            dst_width,
7527            dst_height,
7528            PixelFormat::Yuyv,
7529            DType::U8,
7530            Some(TensorMemory::DmaBuf),
7531            edgefirst_tensor::CpuAccess::ReadWrite,
7532        )
7533        .unwrap();
7534
7535        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
7536
7537        let (result, src, dst) = convert_img(
7538            &mut gl_converter,
7539            src,
7540            dst,
7541            Rotation::None,
7542            Flip::None,
7543            Crop::letterbox([255, 255, 255, 255]),
7544        );
7545        result.unwrap();
7546
7547        std::fs::write(
7548            "rgba_to_yuyv_opengl.yuyv",
7549            dst.as_typed::<u8>().unwrap().map().unwrap().as_slice(),
7550        )
7551        .unwrap();
7552        let cpu_dst = TensorDyn::image(
7553            dst_width,
7554            dst_height,
7555            PixelFormat::Yuyv,
7556            DType::U8,
7557            Some(TensorMemory::DmaBuf),
7558            edgefirst_tensor::CpuAccess::ReadWrite,
7559        )
7560        .unwrap();
7561        let (result, _src, cpu_dst) = convert_img(
7562            &mut CPUProcessor::new(),
7563            src,
7564            cpu_dst,
7565            Rotation::None,
7566            Flip::None,
7567            Crop::no_crop(),
7568        );
7569        result.unwrap();
7570
7571        compare_images_convert_to_rgb(&dst, &cpu_dst, 0.98, function!());
7572    }
7573
7574    #[test]
7575    #[cfg(target_os = "linux")]
7576    fn test_rgba_to_yuyv_resize_g2d() {
7577        if !is_g2d_available() {
7578            eprintln!(
7579                "SKIPPED: test_rgba_to_yuyv_resize_g2d - G2D library (libg2d.so.2) not available"
7580            );
7581            return;
7582        }
7583        if !is_dma_available() {
7584            eprintln!(
7585                "SKIPPED: test_rgba_to_yuyv_resize_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
7586            );
7587            return;
7588        }
7589
7590        let src = load_bytes_to_tensor(
7591            1280,
7592            720,
7593            PixelFormat::Rgba,
7594            Some(TensorMemory::DmaBuf),
7595            &edgefirst_bench::testdata::read("camera720p.rgba"),
7596        )
7597        .unwrap();
7598
7599        let (dst_width, dst_height) = (1280, 720);
7600
7601        let cpu_dst = TensorDyn::image(
7602            dst_width,
7603            dst_height,
7604            PixelFormat::Yuyv,
7605            DType::U8,
7606            Some(TensorMemory::DmaBuf),
7607            edgefirst_tensor::CpuAccess::ReadWrite,
7608        )
7609        .unwrap();
7610
7611        let g2d_dst = TensorDyn::image(
7612            dst_width,
7613            dst_height,
7614            PixelFormat::Yuyv,
7615            DType::U8,
7616            Some(TensorMemory::DmaBuf),
7617            edgefirst_tensor::CpuAccess::ReadWrite,
7618        )
7619        .unwrap();
7620
7621        let mut g2d_converter = G2DProcessor::new().unwrap();
7622        let crop = Crop::new();
7623
7624        g2d_dst
7625            .as_typed::<u8>()
7626            .unwrap()
7627            .map()
7628            .unwrap()
7629            .as_mut_slice()
7630            .fill(128);
7631        let (result, src, g2d_dst) = convert_img(
7632            &mut g2d_converter,
7633            src,
7634            g2d_dst,
7635            Rotation::None,
7636            Flip::None,
7637            crop,
7638        );
7639        result.unwrap();
7640
7641        let cpu_dst_img = cpu_dst;
7642        cpu_dst_img
7643            .as_typed::<u8>()
7644            .unwrap()
7645            .map()
7646            .unwrap()
7647            .as_mut_slice()
7648            .fill(128);
7649        let (result, _src, cpu_dst) = convert_img(
7650            &mut CPUProcessor::new(),
7651            src,
7652            cpu_dst_img,
7653            Rotation::None,
7654            Flip::None,
7655            crop,
7656        );
7657        result.unwrap();
7658
7659        compare_images_convert_to_rgb(&cpu_dst, &g2d_dst, 0.98, function!());
7660    }
7661
7662    #[test]
7663    fn test_yuyv_to_rgba_cpu() {
7664        let file = edgefirst_bench::testdata::read("camera720p.yuyv").to_vec();
7665        let src = TensorDyn::image(
7666            1280,
7667            720,
7668            PixelFormat::Yuyv,
7669            DType::U8,
7670            None,
7671            edgefirst_tensor::CpuAccess::ReadWrite,
7672        )
7673        .unwrap();
7674        src.as_typed::<u8>()
7675            .unwrap()
7676            .map()
7677            .unwrap()
7678            .as_mut_slice()
7679            .copy_from_slice(&file);
7680
7681        let dst = TensorDyn::image(
7682            1280,
7683            720,
7684            PixelFormat::Rgba,
7685            DType::U8,
7686            None,
7687            edgefirst_tensor::CpuAccess::ReadWrite,
7688        )
7689        .unwrap();
7690        let mut cpu_converter = CPUProcessor::new();
7691
7692        let (result, _src, dst) = convert_img(
7693            &mut cpu_converter,
7694            src,
7695            dst,
7696            Rotation::None,
7697            Flip::None,
7698            Crop::no_crop(),
7699        );
7700        result.unwrap();
7701
7702        let target_image = TensorDyn::image(
7703            1280,
7704            720,
7705            PixelFormat::Rgba,
7706            DType::U8,
7707            None,
7708            edgefirst_tensor::CpuAccess::ReadWrite,
7709        )
7710        .unwrap();
7711        target_image
7712            .as_typed::<u8>()
7713            .unwrap()
7714            .map()
7715            .unwrap()
7716            .as_mut_slice()
7717            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
7718
7719        // CPU path resolves the untagged 720p source to BT.709 limited (height
7720        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
7721        compare_images(&dst, &target_image, 0.98, function!());
7722    }
7723
7724    #[test]
7725    fn test_yuyv_to_rgb_cpu() {
7726        let file = edgefirst_bench::testdata::read("camera720p.yuyv").to_vec();
7727        let src = TensorDyn::image(
7728            1280,
7729            720,
7730            PixelFormat::Yuyv,
7731            DType::U8,
7732            None,
7733            edgefirst_tensor::CpuAccess::ReadWrite,
7734        )
7735        .unwrap();
7736        src.as_typed::<u8>()
7737            .unwrap()
7738            .map()
7739            .unwrap()
7740            .as_mut_slice()
7741            .copy_from_slice(&file);
7742
7743        let dst = TensorDyn::image(
7744            1280,
7745            720,
7746            PixelFormat::Rgb,
7747            DType::U8,
7748            None,
7749            edgefirst_tensor::CpuAccess::ReadWrite,
7750        )
7751        .unwrap();
7752        let mut cpu_converter = CPUProcessor::new();
7753
7754        let (result, _src, dst) = convert_img(
7755            &mut cpu_converter,
7756            src,
7757            dst,
7758            Rotation::None,
7759            Flip::None,
7760            Crop::no_crop(),
7761        );
7762        result.unwrap();
7763
7764        let target_image = TensorDyn::image(
7765            1280,
7766            720,
7767            PixelFormat::Rgb,
7768            DType::U8,
7769            None,
7770            edgefirst_tensor::CpuAccess::ReadWrite,
7771        )
7772        .unwrap();
7773        target_image
7774            .as_typed::<u8>()
7775            .unwrap()
7776            .map()
7777            .unwrap()
7778            .as_mut_slice()
7779            .as_chunks_mut::<3>()
7780            .0
7781            .iter_mut()
7782            .zip(
7783                edgefirst_bench::testdata::read("camera720p.rgba")
7784                    .as_chunks::<4>()
7785                    .0,
7786            )
7787            .for_each(|(dst, src)| *dst = [src[0], src[1], src[2]]);
7788
7789        // CPU path resolves the untagged 720p source to BT.709 limited (height
7790        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
7791        compare_images(&dst, &target_image, 0.98, function!());
7792    }
7793
7794    #[test]
7795    #[cfg(target_os = "linux")]
7796    fn test_yuyv_to_rgba_g2d() {
7797        if !is_g2d_available() {
7798            eprintln!("SKIPPED: test_yuyv_to_rgba_g2d - G2D library (libg2d.so.2) not available");
7799            return;
7800        }
7801        if !is_dma_available() {
7802            eprintln!(
7803                "SKIPPED: test_yuyv_to_rgba_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
7804            );
7805            return;
7806        }
7807
7808        let src = load_bytes_to_tensor(
7809            1280,
7810            720,
7811            PixelFormat::Yuyv,
7812            None,
7813            &edgefirst_bench::testdata::read("camera720p.yuyv"),
7814        )
7815        .unwrap();
7816
7817        let dst = TensorDyn::image(
7818            1280,
7819            720,
7820            PixelFormat::Rgba,
7821            DType::U8,
7822            Some(TensorMemory::DmaBuf),
7823            edgefirst_tensor::CpuAccess::ReadWrite,
7824        )
7825        .unwrap();
7826        let mut g2d_converter = G2DProcessor::new().unwrap();
7827
7828        let (result, _src, dst) = convert_img(
7829            &mut g2d_converter,
7830            src,
7831            dst,
7832            Rotation::None,
7833            Flip::None,
7834            Crop::no_crop(),
7835        );
7836        result.unwrap();
7837
7838        let target_image = TensorDyn::image(
7839            1280,
7840            720,
7841            PixelFormat::Rgba,
7842            DType::U8,
7843            None,
7844            edgefirst_tensor::CpuAccess::ReadWrite,
7845        )
7846        .unwrap();
7847        target_image
7848            .as_typed::<u8>()
7849            .unwrap()
7850            .map()
7851            .unwrap()
7852            .as_mut_slice()
7853            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
7854
7855        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
7856        // so the matrix delta vs the reference that forced 0.95 has closed;
7857        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
7858        compare_images(&dst, &target_image, 0.98, function!());
7859    }
7860
7861    #[test]
7862    #[cfg(any(
7863        target_os = "linux",
7864        target_os = "macos",
7865        target_os = "ios",
7866        target_os = "android",
7867        target_os = "windows"
7868    ))]
7869    #[cfg(feature = "opengl")]
7870    fn test_yuyv_to_rgba_opengl() {
7871        if !is_opengl_available() {
7872            eprintln!("SKIPPED: {} - OpenGL not available", function!());
7873            return;
7874        }
7875        // Zero-copy YUYV source: DMA-BUF on Linux, IOSurface on macOS, a
7876        // D3D11 texture on Windows; skips where no such backing exists.
7877        if !edgefirst_tensor::is_gpu_buffer_available() {
7878            eprintln!(
7879                "SKIPPED: {} - zero-copy GPU buffer (DMA-BUF / IOSurface) not available on this host",
7880                function!()
7881            );
7882            return;
7883        }
7884
7885        let src = load_bytes_to_tensor(
7886            1280,
7887            720,
7888            PixelFormat::Yuyv,
7889            Some(TensorMemory::DmaBuf),
7890            &edgefirst_bench::testdata::read("camera720p.yuyv"),
7891        )
7892        .unwrap();
7893
7894        let dst = TensorDyn::image(
7895            1280,
7896            720,
7897            PixelFormat::Rgba,
7898            DType::U8,
7899            Some(TensorMemory::DmaBuf),
7900            edgefirst_tensor::CpuAccess::ReadWrite,
7901        )
7902        .unwrap();
7903        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
7904
7905        let (result, _src, dst) = convert_img(
7906            &mut gl_converter,
7907            src,
7908            dst,
7909            Rotation::None,
7910            Flip::None,
7911            Crop::no_crop(),
7912        );
7913        // Desktop NVIDIA cannot sample YUYV as a GL texture; i.MX/Vivante can.
7914        // Production ImageProcessor falls back to CPU; this oracle skips.
7915        if let Err(crate::Error::NotSupported(msg)) = &result {
7916            eprintln!("SKIPPED: {} — {msg}", function!());
7917            return;
7918        }
7919        result.unwrap();
7920
7921        let target_image = TensorDyn::image(
7922            1280,
7923            720,
7924            PixelFormat::Rgba,
7925            DType::U8,
7926            None,
7927            edgefirst_tensor::CpuAccess::ReadWrite,
7928        )
7929        .unwrap();
7930        target_image
7931            .as_typed::<u8>()
7932            .unwrap()
7933            .map()
7934            .unwrap()
7935            .as_mut_slice()
7936            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
7937
7938        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
7939        // so the matrix delta vs the reference that forced 0.95 has closed;
7940        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
7941        compare_images(&dst, &target_image, 0.98, function!());
7942    }
7943
7944    /// macOS analog of `test_yuyv_to_rgba_opengl` — drives the ANGLE +
7945    /// IOSurface backend end-to-end and compares against the same
7946    /// reference image. Skips silently if ANGLE isn't installed so the
7947    /// test suite still passes on CI hosts without the Homebrew tap.
7948    /// Step-1 probe: proves ANGLE's Metal IOSurface-client-buffer path accepts
7949    /// an `L008`→`GL_RED` (R8) binding — the foundation for sampling the
7950    /// contiguous semi-planar YUV buffer as a single R8 texture. Renders a
7951    /// GREY (R8 IOSurface) source through the GL backend to RGBA and checks the
7952    /// luma round-trips to R=G=B (identity GREY→RGB).
7953    #[test]
7954    #[cfg(target_os = "macos")]
7955    #[cfg(feature = "opengl")]
7956    fn test_grey_r8_iosurface_to_rgba_opengl_macos() {
7957        let mut proc = match GLProcessorThreaded::new(None) {
7958            Ok(p) => p,
7959            Err(e) => {
7960                eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
7961                return;
7962            }
7963        };
7964
7965        let (w, h) = (16usize, 16usize);
7966        let src = TensorDyn::image(
7967            w,
7968            h,
7969            PixelFormat::Grey,
7970            DType::U8,
7971            Some(TensorMemory::DmaBuf),
7972            edgefirst_tensor::CpuAccess::ReadWrite,
7973        )
7974        .expect("GREY IOSurface (R8/L008) should allocate — proves the FourCC mapping");
7975        // Known luma ramp: value = (x * 13 + y * 7) & 0xff.
7976        {
7977            let su8 = src.as_typed::<u8>().unwrap();
7978            let stride = src
7979                .as_typed::<u8>()
7980                .unwrap()
7981                .effective_row_stride()
7982                .unwrap();
7983            let mut m = su8.map().unwrap();
7984            let buf = m.as_mut_slice();
7985            for y in 0..h {
7986                for x in 0..w {
7987                    buf[y * stride + x] = ((x * 13 + y * 7) & 0xff) as u8;
7988                }
7989            }
7990        }
7991
7992        let dst = TensorDyn::image(
7993            w,
7994            h,
7995            PixelFormat::Rgba,
7996            DType::U8,
7997            Some(TensorMemory::DmaBuf),
7998            edgefirst_tensor::CpuAccess::ReadWrite,
7999        )
8000        .unwrap();
8001        let (result, src_back, dst) = convert_img(
8002            &mut proc,
8003            src,
8004            dst,
8005            Rotation::None,
8006            Flip::None,
8007            Crop::no_crop(),
8008        );
8009        result.expect("GREY(R8 IOSurface) → RGBA must convert on ANGLE (R8 binding works)");
8010
8011        let src_stride = src_back
8012            .as_typed::<u8>()
8013            .unwrap()
8014            .effective_row_stride()
8015            .unwrap();
8016        let src_map = src_back.as_typed::<u8>().unwrap().map().unwrap();
8017        let sbytes = src_map.as_slice();
8018        let dst_stride = dst
8019            .as_typed::<u8>()
8020            .unwrap()
8021            .effective_row_stride()
8022            .unwrap();
8023        let dst_map = dst.as_typed::<u8>().unwrap().map().unwrap();
8024        let dbytes = dst_map.as_slice();
8025        for y in 0..h {
8026            for x in 0..w {
8027                let yv = sbytes[y * src_stride + x] as i16;
8028                let p = y * dst_stride + x * 4;
8029                for c in 0..3 {
8030                    assert!(
8031                        (dbytes[p + c] as i16 - yv).abs() <= 2,
8032                        "pixel ({x},{y}) ch{c} = {} expected ~{yv} (GREY→RGB identity)",
8033                        dbytes[p + c]
8034                    );
8035                }
8036            }
8037        }
8038    }
8039
8040    /// Two-pass GPU chain: NV12 (R8 IOSurface) → PlanarRgb F16, the profiler's
8041    /// preprocess. Verifies the chained `convert_nv_to_planar_float`
8042    /// (NV12→RGBA8 then the verified RGBA8→PlanarRgb F16) executes on ANGLE and
8043    /// produces a sane F16 planar result: a neutral-grey NV12 input (Y=U=V=128,
8044    /// BT.601 full ⇒ RGB≈0.5) must yield all three planes ≈0.5 (half-float).
8045    #[test]
8046    #[cfg(target_os = "macos")]
8047    #[cfg(feature = "opengl")]
8048    fn test_nv12_to_planar_f16_two_pass_opengl_macos() {
8049        let mut gpu = match GLProcessorThreaded::new(None) {
8050            Ok(p) => p,
8051            Err(e) => {
8052                eprintln!("SKIPPED: {} — init failed ({e:?})", function!());
8053                return;
8054            }
8055        };
8056        let (w, h) = (64usize, 64usize);
8057        let src = match TensorDyn::image(
8058            w,
8059            h,
8060            PixelFormat::Nv12,
8061            DType::U8,
8062            Some(TensorMemory::DmaBuf),
8063            edgefirst_tensor::CpuAccess::ReadWrite,
8064        ) {
8065            Ok(t) => t,
8066            Err(e) => {
8067                eprintln!("SKIPPED: {} — NV12 IOSurface alloc: {e:?}", function!());
8068                return;
8069            }
8070        };
8071        src.as_typed::<u8>()
8072            .unwrap()
8073            .map()
8074            .unwrap()
8075            .as_mut_slice()
8076            .fill(128); // Y=U=V=128
8077
8078        let dst = match TensorDyn::image(
8079            w,
8080            h,
8081            PixelFormat::PlanarRgb,
8082            DType::F16,
8083            Some(TensorMemory::DmaBuf),
8084            edgefirst_tensor::CpuAccess::ReadWrite,
8085        ) {
8086            Ok(t) => t,
8087            Err(e) => {
8088                eprintln!("SKIPPED: {} — F16 PlanarRgb IOSurface: {e:?}", function!());
8089                return;
8090            }
8091        };
8092        let mut dst = dst;
8093        // Call convert directly (the convert_img helper restores u8 only).
8094        if let Err(e) = ImageProcessorTrait::convert(
8095            &mut gpu,
8096            &src,
8097            &mut dst,
8098            Rotation::None,
8099            Flip::None,
8100            Crop::no_crop(),
8101        ) {
8102            // GL_EXT_color_buffer_half_float may be absent on some configs; the
8103            // RGBA8 pass-1 + F16 pass-2 path then can't render. Skip rather than
8104            // fail on a capability gap (the same policy as the F16 path tests).
8105            eprintln!(
8106                "SKIPPED: {} — NV12→PlanarRgb F16 not available ({e:?})",
8107                function!()
8108            );
8109            return;
8110        }
8111        let dt = dst.as_typed::<half::f16>().expect("dst is F16 PlanarRgb");
8112        let map = dt.map().unwrap();
8113        let vals = map.as_slice();
8114        // Neutral grey → ~0.5 in every plane. Allow generous tolerance for the
8115        // mediump YUV math + half-float rounding.
8116        let mut checked = 0usize;
8117        for &v in vals.iter() {
8118            let f = f32::from(v);
8119            assert!(
8120                (0.40..=0.60).contains(&f),
8121                "planar F16 value {f} not ~0.5 for neutral-grey NV12"
8122            );
8123            checked += 1;
8124        }
8125        assert!(
8126            checked >= w * h * 3,
8127            "expected >= 3 planes of samples, got {checked}"
8128        );
8129    }
8130
8131    /// Profiler-shaped two-pass: a reused **R8/Grey pool** (allocated larger
8132    /// than the frame, the NV24 worst case `3·H`) is reconfigured to an NV12
8133    /// frame, filled at the preserved physical stride, and converted with a
8134    /// letterbox `src_rect` crop into a model-sized PlanarRgb F16 destination —
8135    /// exactly the orchestrator's preprocess. Guards against the pooled
8136    /// two-pass NV→PlanarRgb F16 path hanging/erroring (the exact-size
8137    /// `test_nv12_to_planar_f16_two_pass` never exercised the larger pool).
8138    #[test]
8139    #[cfg(target_os = "macos")]
8140    #[cfg(feature = "opengl")]
8141    fn test_nv12_to_planar_f16_two_pass_pool_opengl_macos() {
8142        let mut gpu = match GLProcessorThreaded::new(None) {
8143            Ok(p) => p,
8144            Err(e) => {
8145                eprintln!("SKIPPED: {} — init failed ({e:?})", function!());
8146                return;
8147            }
8148        };
8149        // Frame 96×64 in a 256×768 R8 pool (3·256 height; bpr padded past 96).
8150        let (fw, fh) = (96usize, 64usize);
8151        let (pool_w, pool_h) = (256usize, 768usize);
8152        let (model_w, model_h) = (128usize, 128usize);
8153
8154        let mut src = match TensorDyn::image(
8155            pool_w,
8156            pool_h,
8157            PixelFormat::Grey,
8158            DType::U8,
8159            Some(TensorMemory::DmaBuf),
8160            edgefirst_tensor::CpuAccess::ReadWrite,
8161        ) {
8162            Ok(t) => t,
8163            Err(e) => {
8164                eprintln!("SKIPPED: {} — R8 pool alloc: {e:?}", function!());
8165                return;
8166            }
8167        };
8168        src.configure_image(fw, fh, PixelFormat::Nv12)
8169            .unwrap_or_else(|e| panic!("configure_image NV12 on pool: {e}"));
8170        let stride = src
8171            .as_typed::<u8>()
8172            .unwrap()
8173            .effective_row_stride()
8174            .unwrap();
8175        src.as_typed::<u8>()
8176            .unwrap()
8177            .map()
8178            .unwrap()
8179            .as_mut_slice()
8180            .fill(128); // neutral grey
8181
8182        let mut dst = match TensorDyn::image(
8183            model_w,
8184            model_h,
8185            PixelFormat::PlanarRgb,
8186            DType::F16,
8187            Some(TensorMemory::DmaBuf),
8188            edgefirst_tensor::CpuAccess::ReadWrite,
8189        ) {
8190            Ok(t) => t,
8191            Err(e) => {
8192                eprintln!("SKIPPED: {} — F16 PlanarRgb dst: {e:?}", function!());
8193                return;
8194            }
8195        };
8196
8197        // Letterbox crop like the profiler: the backend computes the band.
8198        let _ = model_w;
8199        let crop = Crop::new()
8200            .with_source(Some(Region::new(0, 0, fw, fh)))
8201            .with_fit(Fit::Letterbox {
8202                pad: [0, 0, 0, 255],
8203            });
8204        if let Err(e) =
8205            ImageProcessorTrait::convert(&mut gpu, &src, &mut dst, Rotation::None, Flip::None, crop)
8206        {
8207            eprintln!(
8208                "SKIPPED: {} — NV12→PlanarRgb F16 unavailable ({e:?})",
8209                function!()
8210            );
8211            return;
8212        }
8213        let _ = stride;
8214        // Neutral grey → ~0.5 inside the letterbox band; just assert the convert
8215        // completed and produced finite values (no hang, no NaN garbage).
8216        let dt = dst.as_typed::<half::f16>().expect("dst F16");
8217        let map = dt.map().unwrap();
8218        let any_half = map.as_slice().iter().any(|&v| {
8219            let f = f32::from(v);
8220            (0.40..=0.60).contains(&f)
8221        });
8222        assert!(any_half, "expected ~0.5 grey samples in the letterbox band");
8223    }
8224
8225    /// Mirrors the orchestrator: the GL processor is created on one thread
8226    /// and `convert()` is called from a *different* thread (the profiler's
8227    /// Pre-processing worker). Reproduces (or rules out) the GL-context /
8228    /// `glFinish` cross-thread hang seen in the live pipeline. A 20 s watchdog
8229    /// fails loudly rather than hanging the whole test binary.
8230    #[test]
8231    #[cfg(target_os = "macos")]
8232    #[cfg(feature = "opengl")]
8233    fn test_nv12_to_planar_f16_cross_thread_opengl_macos() {
8234        use std::sync::mpsc;
8235        // Public ImageProcessor (Send) created HERE (the main test thread),
8236        // exactly like the orchestrator builds `config.processor` during setup.
8237        let mut proc = match ImageProcessor::new() {
8238            Ok(p) => p,
8239            Err(e) => {
8240                eprintln!("SKIPPED: {} — init failed ({e:?})", function!());
8241                return;
8242            }
8243        };
8244        let (fw, fh) = (96usize, 64usize);
8245        let mut src = match TensorDyn::image(
8246            256,
8247            768,
8248            PixelFormat::Grey,
8249            DType::U8,
8250            Some(TensorMemory::DmaBuf),
8251            edgefirst_tensor::CpuAccess::ReadWrite,
8252        ) {
8253            Ok(t) => t,
8254            Err(e) => {
8255                eprintln!("SKIPPED: {} — pool: {e:?}", function!());
8256                return;
8257            }
8258        };
8259        src.configure_image(fw, fh, PixelFormat::Nv12).unwrap();
8260        src.as_typed::<u8>()
8261            .unwrap()
8262            .map()
8263            .unwrap()
8264            .as_mut_slice()
8265            .fill(128);
8266        let mut dst = match TensorDyn::image(
8267            128,
8268            128,
8269            PixelFormat::PlanarRgb,
8270            DType::F16,
8271            Some(TensorMemory::DmaBuf),
8272            edgefirst_tensor::CpuAccess::ReadWrite,
8273        ) {
8274            Ok(t) => t,
8275            Err(e) => {
8276                eprintln!("SKIPPED: {} — dst: {e:?}", function!());
8277                return;
8278            }
8279        };
8280        let crop = Crop::new().with_source(Some(Region::new(0, 0, fw, fh)));
8281
8282        // ...then MOVED to a worker thread where convert() runs — exactly the
8283        // orchestrator's create-on-setup / convert-on-Pre-processing split.
8284        let (tx, rx) = mpsc::channel::<bool>();
8285        let worker = std::thread::spawn(move || {
8286            let _ = ImageProcessorTrait::convert(
8287                &mut proc,
8288                &src,
8289                &mut dst,
8290                Rotation::None,
8291                Flip::None,
8292                crop,
8293            );
8294            let _ = tx.send(true);
8295        });
8296        match rx.recv_timeout(std::time::Duration::from_secs(20)) {
8297            Ok(_) => { let _ = worker.join(); }
8298            Err(_) => panic!(
8299                "cross-thread NV12→PlanarRgb convert HUNG (>20s) — reproduces the orchestrator deadlock"
8300            ),
8301        }
8302    }
8303
8304    /// Reproduces the profiler's progressive-slowdown/hang: one processor
8305    /// converting many **varying-size** NV frames (like a COCO dataset) from a
8306    /// reused R8 pool into a fixed PlanarRgb F16 model input. The two-pass path
8307    /// reallocated its RGBA intermediate per frame-size, churning/leaking
8308    /// pbuffers until the GPU stalled. Asserts per-convert latency stays bounded
8309    /// (no runaway) over many iterations.
8310    #[test]
8311    #[cfg(target_os = "macos")]
8312    #[cfg(feature = "opengl")]
8313    fn test_nv_to_planar_f16_varying_sizes_no_leak_opengl_macos() {
8314        let mut gpu = match GLProcessorThreaded::new(None) {
8315            Ok(p) => p,
8316            Err(e) => {
8317                eprintln!("SKIPPED: {} — init failed ({e:?})", function!());
8318                return;
8319            }
8320        };
8321        // Mirror the orchestrator's ring buffers at depth 4: a pool of source R8
8322        // tensors AND a pool of PlanarRgb F16 dst slots, both cycled per frame.
8323        let (max_w, max_h) = (640usize, 640usize);
8324        let depth = 4usize;
8325        let mut srcs = Vec::new();
8326        let mut dsts = Vec::new();
8327        for _ in 0..depth {
8328            srcs.push(
8329                match TensorDyn::image(
8330                    max_w,
8331                    max_h * 3,
8332                    PixelFormat::Grey,
8333                    DType::U8,
8334                    Some(TensorMemory::DmaBuf),
8335                    edgefirst_tensor::CpuAccess::ReadWrite,
8336                ) {
8337                    Ok(t) => t,
8338                    Err(e) => {
8339                        eprintln!("SKIPPED: {} — pool: {e:?}", function!());
8340                        return;
8341                    }
8342                },
8343            );
8344            dsts.push(
8345                match TensorDyn::image(
8346                    640,
8347                    640,
8348                    PixelFormat::PlanarRgb,
8349                    DType::F16,
8350                    Some(TensorMemory::DmaBuf),
8351                    edgefirst_tensor::CpuAccess::ReadWrite,
8352                ) {
8353                    Ok(t) => t,
8354                    Err(e) => {
8355                        eprintln!("SKIPPED: {} — dst: {e:?}", function!());
8356                        return;
8357                    }
8358                },
8359            );
8360        }
8361        // COCO-like assorted frame sizes (all ≤ max), cycled.
8362        let sizes = [
8363            (640, 480),
8364            (500, 375),
8365            (640, 427),
8366            (333, 500),
8367            (480, 640),
8368            (612, 612),
8369            (428, 640),
8370            (576, 432),
8371        ];
8372        let mut first_ms = 0f64;
8373        let mut last_ms = 0f64;
8374        let iters = 40usize;
8375        for i in 0..iters {
8376            let (fw, fh) = sizes[i % sizes.len()];
8377            let src = &mut srcs[i % depth];
8378            let dst = &mut dsts[i % depth];
8379            src.configure_image(fw, fh, PixelFormat::Nv24).unwrap();
8380            src.as_typed::<u8>()
8381                .unwrap()
8382                .map()
8383                .unwrap()
8384                .as_mut_slice()
8385                .fill(128);
8386            let crop = Crop::new().with_source(Some(Region::new(0, 0, fw, fh)));
8387            let t0 = std::time::Instant::now();
8388            ImageProcessorTrait::convert(&mut gpu, src, dst, Rotation::None, Flip::None, crop)
8389                .unwrap_or_else(|e| panic!("convert iter {i} ({fw}×{fh}): {e}"));
8390            let ms = t0.elapsed().as_secs_f64() * 1e3;
8391            if i == 2 {
8392                first_ms = ms;
8393            }
8394            if i == iters - 1 {
8395                last_ms = ms;
8396            }
8397        }
8398        eprintln!("first={first_ms:.2}ms last={last_ms:.2}ms");
8399        assert!(
8400            last_ms < first_ms * 5.0 + 5.0,
8401            "convert latency ran away: first {first_ms:.2}ms → last {last_ms:.2}ms (intermediate/pbuffer leak)"
8402        );
8403    }
8404
8405    /// Step-2 verification: NV12/NV16/NV24 (R8 IOSurface) → RGBA on the GPU
8406    /// must match the CPU `yuv` kernels within shader rounding. Fills an
8407    /// IOSurface source and a Mem source from the same logical YUV pattern
8408    /// (each at its own row stride), converts the IOSurface on the GPU and the
8409    /// Mem one on the CPU, and compares. Exercises the in-shader semi-planar
8410    /// addressing for all three subsamplings (incl. NV24's 2×-wide UV rows).
8411    #[test]
8412    #[cfg(target_os = "macos")]
8413    #[cfg(feature = "opengl")]
8414    fn test_nv12_nv16_nv24_to_rgba_opengl_macos() {
8415        let mut gpu = match GLProcessorThreaded::new(None) {
8416            Ok(p) => p,
8417            Err(e) => {
8418                eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
8419                return;
8420            }
8421        };
8422        let mut cpu = CPUProcessor::new();
8423
8424        // Fill Y plus the interleaved UV plane in the canonical semi-planar
8425        // layout — exactly what the codec writes and the CPU `yuv`-crate reader
8426        // expects: the Y plane is `h` rows at the buffer's row stride; the UV
8427        // plane starts at `h * stride`; each chroma row advances
8428        // `uv_grid_rows * stride` bytes (NV24 carries a full-resolution `2*W`
8429        // byte line == two grid rows, NV12/NV16 one row of `W/2` pairs); each
8430        // (Cb,Cr) pair is two consecutive bytes at column `cx * 2`. This is
8431        // stride-correct for both the tight Mem buffer and the padded IOSurface.
8432        //
8433        // Takes explicit w/h so the closure can be reused across multiple frame
8434        // sizes (even and odd) without capturing a fixed outer variable.
8435        let fill = |buf: &mut [u8], stride: usize, fmt: PixelFormat, w: usize, h: usize| {
8436            for y in 0..h {
8437                for x in 0..w {
8438                    buf[y * stride + x] = ((x * 9 + y * 5) & 0xff) as u8;
8439                }
8440            }
8441            let (cw, ch, uv_grid_rows) = match fmt {
8442                PixelFormat::Nv12 => (w / 2, h / 2, 1usize),
8443                PixelFormat::Nv16 => (w / 2, h, 1usize),
8444                _ => (w, h, 2usize), // Nv24: full-res chroma, 2W bytes/row
8445            };
8446            let uv_plane = h * stride;
8447            for cy in 0..ch {
8448                for cx in 0..cw {
8449                    let off = uv_plane + cy * uv_grid_rows * stride + cx * 2;
8450                    buf[off] = ((cx * 11 + 30) & 0xff) as u8;
8451                    buf[off + 1] = ((cy * 7 + 200) & 0xff) as u8;
8452                }
8453            }
8454        };
8455
8456        for fmt in [PixelFormat::Nv12, PixelFormat::Nv16, PixelFormat::Nv24] {
8457            for (w, h) in [
8458                (16usize, 16usize), // original even-dim case
8459                (15, 16),           // odd-W
8460                (16, 15),           // odd-H
8461            ] {
8462                let mem = TensorDyn::image(
8463                    w,
8464                    h,
8465                    fmt,
8466                    DType::U8,
8467                    None,
8468                    edgefirst_tensor::CpuAccess::ReadWrite,
8469                )
8470                .unwrap();
8471                let mem_stride = mem
8472                    .as_typed::<u8>()
8473                    .unwrap()
8474                    .effective_row_stride()
8475                    .unwrap();
8476                fill(
8477                    mem.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice(),
8478                    mem_stride,
8479                    fmt,
8480                    w,
8481                    h,
8482                );
8483                let cpu_dst = TensorDyn::image(
8484                    w,
8485                    h,
8486                    PixelFormat::Rgba,
8487                    DType::U8,
8488                    None,
8489                    edgefirst_tensor::CpuAccess::ReadWrite,
8490                )
8491                .unwrap();
8492                let (r, _s, cpu_dst) = convert_img(
8493                    &mut cpu,
8494                    mem,
8495                    cpu_dst,
8496                    Rotation::None,
8497                    Flip::None,
8498                    Crop::no_crop(),
8499                );
8500                r.unwrap_or_else(|e| panic!("CPU {fmt:?}->{w}x{h}->RGBA: {e}"));
8501
8502                let ios = TensorDyn::image(
8503                    w,
8504                    h,
8505                    fmt,
8506                    DType::U8,
8507                    Some(TensorMemory::DmaBuf),
8508                    edgefirst_tensor::CpuAccess::ReadWrite,
8509                )
8510                .unwrap_or_else(|e| panic!("{fmt:?} {w}x{h} IOSurface alloc: {e}"));
8511                let ios_stride = ios
8512                    .as_typed::<u8>()
8513                    .unwrap()
8514                    .effective_row_stride()
8515                    .unwrap();
8516                fill(
8517                    ios.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice(),
8518                    ios_stride,
8519                    fmt,
8520                    w,
8521                    h,
8522                );
8523                let gpu_dst = TensorDyn::image(
8524                    w,
8525                    h,
8526                    PixelFormat::Rgba,
8527                    DType::U8,
8528                    Some(TensorMemory::DmaBuf),
8529                    edgefirst_tensor::CpuAccess::ReadWrite,
8530                )
8531                .unwrap();
8532                let (r, _s, gpu_dst) = convert_img(
8533                    &mut gpu,
8534                    ios,
8535                    gpu_dst,
8536                    Rotation::None,
8537                    Flip::None,
8538                    Crop::no_crop(),
8539                );
8540                r.unwrap_or_else(|e| panic!("GPU {fmt:?}->{w}x{h}->RGBA on ANGLE: {e}"));
8541
8542                let cs = cpu_dst
8543                    .as_typed::<u8>()
8544                    .unwrap()
8545                    .effective_row_stride()
8546                    .unwrap();
8547                let cmap = cpu_dst.as_typed::<u8>().unwrap().map().unwrap();
8548                let cb = cmap.as_slice();
8549                let gs = gpu_dst
8550                    .as_typed::<u8>()
8551                    .unwrap()
8552                    .effective_row_stride()
8553                    .unwrap();
8554                let gmap = gpu_dst.as_typed::<u8>().unwrap().map().unwrap();
8555                let gb = gmap.as_slice();
8556                let mut max_d = 0i16;
8557                for y in 0..h {
8558                    for x in 0..w {
8559                        for c in 0..3 {
8560                            let cv = cb[y * cs + x * 4 + c] as i16;
8561                            let gv = gb[y * gs + x * 4 + c] as i16;
8562                            max_d = max_d.max((cv - gv).abs());
8563                        }
8564                    }
8565                }
8566                assert!(
8567                    max_d <= 3,
8568                    "{fmt:?} {w}x{h}: GPU vs CPU RGBA max channel diff {max_d} > 3"
8569                );
8570            }
8571        }
8572    }
8573
8574    /// Phase-0 gate: the reused-pool / larger-surface case. A single R8 pool
8575    /// IOSurface (allocated bigger than the frame, so its `bytesPerRow` exceeds
8576    /// the frame's even width) is reconfigured to each NV frame, filled at the
8577    /// preserved physical stride, and converted on the GPU. This proves ANGLE
8578    /// binds the *whole* physical surface as a pbuffer and that `texelFetch`
8579    /// resolves the frame's Y/UV texels through the surface's real `bytesPerRow`
8580    /// (the physical-grid / logical-ROI decoupling). GPU must match CPU ≤3 LSB.
8581    #[test]
8582    #[cfg(target_os = "macos")]
8583    #[cfg(feature = "opengl")]
8584    fn test_nv_to_rgba_larger_pool_surface_opengl_macos() {
8585        let mut gpu = match GLProcessorThreaded::new(None) {
8586            Ok(p) => p,
8587            Err(e) => {
8588                eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
8589                return;
8590            }
8591        };
8592        let mut cpu = CPUProcessor::new();
8593        // The pool is generously oversized (256-wide → bpr 256, well beyond any
8594        // frame's even width) and tall enough for NV24's 3·H at the test frames.
8595        let (pool_w, pool_h) = (256usize, 256usize);
8596
8597        // Canonical semi-planar fill: Y plane at the row stride, then the UV
8598        // plane at `h * stride` with each chroma row advancing `uv_grid_rows *
8599        // stride` bytes. Stride-correct for both tight Mem and padded IOSurface.
8600        let fill = |buf: &mut [u8], stride: usize, fmt: PixelFormat, w: usize, h: usize| {
8601            for y in 0..h {
8602                for x in 0..w {
8603                    buf[y * stride + x] = ((x * 9 + y * 5) & 0xff) as u8;
8604                }
8605            }
8606            let (cw, ch, uv_grid_rows) = match fmt {
8607                PixelFormat::Nv12 => (w / 2, h / 2, 1usize),
8608                PixelFormat::Nv16 => (w / 2, h, 1usize),
8609                _ => (w, h, 2usize), // Nv24: full-res chroma, 2W bytes/row
8610            };
8611            let uv_plane = h * stride;
8612            for cy in 0..ch {
8613                for cx in 0..cw {
8614                    let off = uv_plane + cy * uv_grid_rows * stride + cx * 2;
8615                    buf[off] = ((cx * 11 + 30) & 0xff) as u8;
8616                    buf[off + 1] = ((cy * 7 + 200) & 0xff) as u8;
8617                }
8618            }
8619        };
8620
8621        for fmt in [PixelFormat::Nv12, PixelFormat::Nv16, PixelFormat::Nv24] {
8622            for (w, h) in [
8623                (40usize, 24usize), // original even-dim case
8624                (15, 16),           // odd-W
8625                (16, 15),           // odd-H
8626            ] {
8627                // `ew` is the minimum even extent; the pool stride must exceed it
8628                // to actually exercise the physical-stride shader decoupling.
8629                let ew = w.next_multiple_of(2);
8630
8631                // CPU reference from a tightly-packed Mem tensor at the frame size.
8632                let mem = TensorDyn::image(
8633                    w,
8634                    h,
8635                    fmt,
8636                    DType::U8,
8637                    None,
8638                    edgefirst_tensor::CpuAccess::ReadWrite,
8639                )
8640                .unwrap();
8641                let mem_stride = mem
8642                    .as_typed::<u8>()
8643                    .unwrap()
8644                    .effective_row_stride()
8645                    .unwrap();
8646                fill(
8647                    mem.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice(),
8648                    mem_stride,
8649                    fmt,
8650                    w,
8651                    h,
8652                );
8653                let cpu_dst = TensorDyn::image(
8654                    w,
8655                    h,
8656                    PixelFormat::Rgba,
8657                    DType::U8,
8658                    None,
8659                    edgefirst_tensor::CpuAccess::ReadWrite,
8660                )
8661                .unwrap();
8662                let (r, _s, cpu_dst) = convert_img(
8663                    &mut cpu,
8664                    mem,
8665                    cpu_dst,
8666                    Rotation::None,
8667                    Flip::None,
8668                    Crop::no_crop(),
8669                );
8670                r.unwrap_or_else(|e| panic!("CPU {fmt:?}->{w}x{h}->RGBA: {e}"));
8671
8672                // GPU source: a LARGER R8 pool surface, reconfigured down to the
8673                // frame. Phase 1 preserves the pool's padded `bytesPerRow` as the
8674                // tensor's row stride; the fill writes the frame at that stride.
8675                let mut ios = match TensorDyn::image(
8676                    pool_w,
8677                    pool_h,
8678                    PixelFormat::Grey,
8679                    DType::U8,
8680                    Some(TensorMemory::DmaBuf),
8681                    edgefirst_tensor::CpuAccess::ReadWrite,
8682                ) {
8683                    Ok(t) => t,
8684                    Err(e) => {
8685                        eprintln!("SKIPPED: {} — R8 pool IOSurface alloc: {e:?}", function!());
8686                        return;
8687                    }
8688                };
8689                ios.configure_image(w, h, fmt)
8690                    .unwrap_or_else(|e| panic!("configure_image {fmt:?} {w}x{h} on pool: {e}"));
8691                let ios_stride = ios
8692                    .as_typed::<u8>()
8693                    .unwrap()
8694                    .effective_row_stride()
8695                    .unwrap();
8696                assert!(
8697                    ios_stride > ew,
8698                    "{fmt:?} {w}x{h}: pool stride {ios_stride} should exceed even width {ew} \
8699                     (test must exercise padding)"
8700                );
8701                fill(
8702                    ios.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice(),
8703                    ios_stride,
8704                    fmt,
8705                    w,
8706                    h,
8707                );
8708
8709                let gpu_dst = TensorDyn::image(
8710                    w,
8711                    h,
8712                    PixelFormat::Rgba,
8713                    DType::U8,
8714                    Some(TensorMemory::DmaBuf),
8715                    edgefirst_tensor::CpuAccess::ReadWrite,
8716                )
8717                .unwrap();
8718                let (r, _s, gpu_dst) = convert_img(
8719                    &mut gpu,
8720                    ios,
8721                    gpu_dst,
8722                    Rotation::None,
8723                    Flip::None,
8724                    Crop::no_crop(),
8725                );
8726                r.unwrap_or_else(|e| {
8727                    panic!("GPU {fmt:?}->{w}x{h}->RGBA (pool surface) on ANGLE: {e}")
8728                });
8729
8730                let cs = cpu_dst
8731                    .as_typed::<u8>()
8732                    .unwrap()
8733                    .effective_row_stride()
8734                    .unwrap();
8735                let cmap = cpu_dst.as_typed::<u8>().unwrap().map().unwrap();
8736                let cb = cmap.as_slice();
8737                let gs = gpu_dst
8738                    .as_typed::<u8>()
8739                    .unwrap()
8740                    .effective_row_stride()
8741                    .unwrap();
8742                let gmap = gpu_dst.as_typed::<u8>().unwrap().map().unwrap();
8743                let gb = gmap.as_slice();
8744                let mut max_d = 0i16;
8745                for y in 0..h {
8746                    for x in 0..w {
8747                        for c in 0..3 {
8748                            let cv = cb[y * cs + x * 4 + c] as i16;
8749                            let gv = gb[y * gs + x * 4 + c] as i16;
8750                            max_d = max_d.max((cv - gv).abs());
8751                        }
8752                    }
8753                }
8754                assert!(
8755                    max_d <= 3,
8756                    "{fmt:?} {w}x{h}: GPU(pool surface) vs CPU RGBA max channel diff {max_d} > 3"
8757                );
8758            }
8759        }
8760    }
8761
8762    #[test]
8763    #[cfg(target_os = "macos")]
8764    #[cfg(feature = "opengl")]
8765    fn test_yuyv_to_rgba_opengl_macos() {
8766        let mut proc = match GLProcessorThreaded::new(None) {
8767            Ok(p) => p,
8768            Err(e) => {
8769                eprintln!(
8770                    "SKIPPED: {} — GL engine init failed ({e:?}). \
8771                     Install ANGLE via `brew install startergo/angle/angle` \
8772                     and re-sign per README.md § macOS GPU Acceleration to \
8773                     run this test.",
8774                    function!()
8775                );
8776                return;
8777            }
8778        };
8779
8780        let src = load_bytes_to_tensor(
8781            1280,
8782            720,
8783            PixelFormat::Yuyv,
8784            Some(TensorMemory::DmaBuf),
8785            &edgefirst_bench::testdata::read("camera720p.yuyv"),
8786        )
8787        .unwrap();
8788
8789        let dst = TensorDyn::image(
8790            1280,
8791            720,
8792            PixelFormat::Rgba,
8793            DType::U8,
8794            Some(TensorMemory::DmaBuf),
8795            edgefirst_tensor::CpuAccess::ReadWrite,
8796        )
8797        .unwrap();
8798
8799        let (result, _src, dst) = convert_img(
8800            &mut proc,
8801            src,
8802            dst,
8803            Rotation::None,
8804            Flip::None,
8805            Crop::no_crop(),
8806        );
8807        result.unwrap();
8808
8809        let target_image = TensorDyn::image(
8810            1280,
8811            720,
8812            PixelFormat::Rgba,
8813            DType::U8,
8814            None,
8815            edgefirst_tensor::CpuAccess::ReadWrite,
8816        )
8817        .unwrap();
8818        target_image
8819            .as_typed::<u8>()
8820            .unwrap()
8821            .map()
8822            .unwrap()
8823            .as_mut_slice()
8824            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
8825
8826        // macOS YUYV shader now threads per-tensor colorimetry: the untagged
8827        // camera720p source resolves to BT.709 limited (matching the BT.709
8828        // reference), measured 0.9973 on ANGLE — up from 0.9733 under the old
8829        // BT.601-full stop-gap. 0.98 leaves headroom for cross-GPU variance.
8830        compare_images(&dst, &target_image, 0.98, function!());
8831    }
8832
8833    /// Multi-resolution smoke test: convert YUYV→RGBA via the GL
8834    /// backend at a small (64×32) frame and a 4K (3840×2160) frame,
8835    /// both filled with a synthetic mid-grey pattern. Validates the
8836    /// shader math at the chroma-pairing boundary on small textures
8837    /// and exercises the IOSurface bytes-per-row alignment path at 4K
8838    /// (3840 pixels × 2 bytes/pixel = 7680 bytes, naturally 64-aligned).
8839    ///
8840    /// Resolutions below 32 pixels wide aren't tested because the
8841    /// IOSurface allocator pads bpr to 64 bytes — for a 4-px-wide
8842    /// YUYV surface that's 8 bytes data + 56 bytes padding per row,
8843    /// which exercises a sampling pattern that's ANGLE-version
8844    /// dependent rather than HAL-correctness dependent.
8845    ///
8846    /// This complements `test_yuyv_to_rgba_opengl_macos` (which checks
8847    /// pixel-exact correctness against a reference image at 720p) by
8848    /// ensuring the pipeline does not crash or produce gross errors at
8849    /// resolution extremes. Pixel-exact validation at 4K would require
8850    /// a 30 MB reference file we don't want to bundle.
8851    #[test]
8852    #[cfg(target_os = "macos")]
8853    #[cfg(feature = "opengl")]
8854    fn test_yuyv_to_rgba_opengl_macos_multi_resolution() {
8855        let mut proc = match GLProcessorThreaded::new(None) {
8856            Ok(p) => p,
8857            Err(e) => {
8858                eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
8859                return;
8860            }
8861        };
8862
8863        for (w, h) in [(64usize, 32usize), (3840, 2160)] {
8864            // Synthetic YUYV: Y=128 (mid-grey luma), U=V=128 (neutral
8865            // chroma) → RGB grey at the output.
8866            let bytes_per_row = w * 2;
8867            let mut yuyv = vec![0u8; bytes_per_row * h];
8868            for chunk in yuyv.as_chunks_mut::<4>().0 {
8869                chunk[0] = 128; // Y0
8870                chunk[1] = 128; // U
8871                chunk[2] = 128; // Y1
8872                chunk[3] = 128; // V
8873            }
8874
8875            let src =
8876                load_bytes_to_tensor(w, h, PixelFormat::Yuyv, Some(TensorMemory::DmaBuf), &yuyv)
8877                    .unwrap();
8878
8879            let dst = TensorDyn::image(
8880                w,
8881                h,
8882                PixelFormat::Rgba,
8883                DType::U8,
8884                Some(TensorMemory::DmaBuf),
8885                edgefirst_tensor::CpuAccess::ReadWrite,
8886            )
8887            .unwrap();
8888
8889            let (result, _src, dst) = convert_img(
8890                &mut proc,
8891                src,
8892                dst,
8893                Rotation::None,
8894                Flip::None,
8895                Crop::no_crop(),
8896            );
8897            result.expect("GL convert should succeed at this resolution");
8898
8899            // The neutral-chroma input must produce a near-grey output;
8900            // BT.709 limited-range maps Y=128/UV=128 → roughly
8901            // (130, 130, 130). Allow ±4 LSB for `mediump float` shader
8902            // rounding.
8903            let dst_u8 = dst.as_typed::<u8>().unwrap();
8904            let dst_map = dst_u8.map().unwrap();
8905            let dst_bytes = dst_map.as_slice();
8906            assert_eq!(dst_bytes.len(), w * h * 4, "RGBA byte count");
8907            for px in dst_bytes.as_chunks::<4>().0 {
8908                for (i, &c) in px[..3].iter().enumerate() {
8909                    assert!(
8910                        (120..=140).contains(&c),
8911                        "{}: channel {i} = {c} (expected ~128 ±12) at {w}×{h}",
8912                        function!(),
8913                    );
8914                }
8915                assert_eq!(px[3], 255, "alpha must be 1.0");
8916            }
8917        }
8918    }
8919
8920    /// Verify that two consecutive convert() calls on the same source
8921    /// tensor reuse the cached EGL pbuffer. Tests the cache hit path
8922    /// added with the macOS GL backend hardening — without it, each
8923    /// frame would pay `eglCreatePbufferFromClientBuffer` + destroy.
8924    ///
8925    /// This is a behaviour test rather than a perf test (the timing
8926    /// difference is 100-200µs which is too noisy to assert on); we
8927    /// check that the second call succeeds and produces a result
8928    /// identical to the first.
8929    #[test]
8930    #[cfg(target_os = "macos")]
8931    #[cfg(feature = "opengl")]
8932    fn test_macos_gl_pbuffer_cache_reuses_surfaces() {
8933        let mut proc = match GLProcessorThreaded::new(None) {
8934            Ok(p) => p,
8935            Err(e) => {
8936                eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
8937                return;
8938            }
8939        };
8940
8941        // Allocate one source + one destination, run convert twice.
8942        let mut yuyv = vec![0u8; 64 * 32 * 2];
8943        for chunk in yuyv.as_chunks_mut::<4>().0 {
8944            chunk[0] = 200;
8945            chunk[1] = 100;
8946            chunk[2] = 200;
8947            chunk[3] = 156;
8948        }
8949        let src =
8950            load_bytes_to_tensor(64, 32, PixelFormat::Yuyv, Some(TensorMemory::DmaBuf), &yuyv)
8951                .unwrap();
8952        let dst = TensorDyn::image(
8953            64,
8954            32,
8955            PixelFormat::Rgba,
8956            DType::U8,
8957            Some(TensorMemory::DmaBuf),
8958            edgefirst_tensor::CpuAccess::ReadWrite,
8959        )
8960        .unwrap();
8961
8962        let (r1, src, dst) = convert_img(
8963            &mut proc,
8964            src,
8965            dst,
8966            Rotation::None,
8967            Flip::None,
8968            Crop::no_crop(),
8969        );
8970        r1.unwrap();
8971        let first: Vec<u8> = dst
8972            .as_typed::<u8>()
8973            .unwrap()
8974            .map()
8975            .unwrap()
8976            .as_slice()
8977            .to_vec();
8978
8979        let (r2, _src, dst) = convert_img(
8980            &mut proc,
8981            src,
8982            dst,
8983            Rotation::None,
8984            Flip::None,
8985            Crop::no_crop(),
8986        );
8987        r2.unwrap();
8988        let second: Vec<u8> = dst
8989            .as_typed::<u8>()
8990            .unwrap()
8991            .map()
8992            .unwrap()
8993            .as_slice()
8994            .to_vec();
8995
8996        assert_eq!(first, second, "cache-hit conversion must be deterministic");
8997    }
8998
8999    /// Steady-state import gate (macOS half of the Linux
9000    /// `dma_pool_steady_state_zero_imports` test): an N-frame convert loop
9001    /// over a fixed pool of IOSurface tensors must create ZERO new EGL
9002    /// pbuffers after the pool has been seen once — pbuffer-cache misses
9003    /// stay flat while hits grow. Counter-based hardening of
9004    /// `test_macos_gl_pbuffer_cache_reuses_surfaces` above: a refactor that
9005    /// re-imports per frame passes the pixel-equality test but fails this.
9006    #[test]
9007    #[cfg(target_os = "macos")]
9008    #[cfg(feature = "opengl")]
9009    fn test_macos_gl_pbuffer_cache_steady_state() {
9010        let mut proc = match GLProcessorThreaded::new(None) {
9011            Ok(p) => p,
9012            Err(e) => {
9013                eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
9014                return;
9015            }
9016        };
9017
9018        let (w, h) = (64usize, 32usize);
9019        const POOL: usize = 3;
9020        const FRAMES: usize = 100;
9021
9022        let yuyv = vec![128u8; w * h * 2];
9023        let pool: Vec<TensorDyn> = (0..POOL)
9024            .map(|_| {
9025                load_bytes_to_tensor(w, h, PixelFormat::Yuyv, Some(TensorMemory::DmaBuf), &yuyv)
9026                    .unwrap()
9027            })
9028            .collect();
9029        let mut dst = TensorDyn::image(
9030            w,
9031            h,
9032            PixelFormat::Rgba,
9033            DType::U8,
9034            Some(TensorMemory::DmaBuf),
9035            edgefirst_tensor::CpuAccess::ReadWrite,
9036        )
9037        .unwrap();
9038
9039        // Warmup: two passes over the pool import every surface once.
9040        for src in pool.iter().cycle().take(POOL * 2) {
9041            proc.convert(src, &mut dst, Rotation::None, Flip::None, Crop::no_crop())
9042                .unwrap();
9043        }
9044        let warm = proc.egl_cache_stats().unwrap();
9045
9046        for src in pool.iter().cycle().take(FRAMES) {
9047            proc.convert(src, &mut dst, Rotation::None, Flip::None, Crop::no_crop())
9048                .unwrap();
9049        }
9050        let steady = proc.egl_cache_stats().unwrap();
9051
9052        assert_eq!(
9053            warm.total_misses(),
9054            steady.total_misses(),
9055            "steady-state loop created new imports (warm {warm:?}, steady {steady:?})"
9056        );
9057        let hits = |s: &GlCacheStats| s.src.hits + s.dst.hits + s.nv_r8.hits;
9058        assert!(
9059            hits(&steady) - hits(&warm) >= FRAMES as u64,
9060            "expected at least {FRAMES} import-cache hits over the loop, got {}",
9061            hits(&steady) - hits(&warm)
9062        );
9063    }
9064
9065    /// Backend assertion for the F16 zero-copy path: when the GL backend
9066    /// initialized (ANGLE on macOS) and reports F16 color-buffer support,
9067    /// the NV12→PlanarRgb-F16 IOSurface convert MUST be handled by the GL
9068    /// engine — proven by the engine's import-cache counters moving, not
9069    /// just by output correctness. This is the guard against the
9070    /// silent-CPU-fallback failure mode: a misclassified IOSurface F16
9071    /// destination keeps every output-correctness test green while
9072    /// quietly running ~10× slower on CPU; only a backend observable
9073    /// catches it. Skips ONLY when GL itself is unavailable or the
9074    /// configuration lacks F16 — a convert error or a CPU-routed convert
9075    /// with the capability present is a FAILURE.
9076    #[test]
9077    #[cfg(target_os = "macos")]
9078    #[cfg(feature = "opengl")]
9079    fn test_macos_gl_f16_planar_is_gl_backed() {
9080        let mut proc = ImageProcessor::new().expect("ImageProcessor");
9081        let Some(ref gl) = proc.opengl else {
9082            eprintln!("SKIPPED: {} — GL backend unavailable", function!());
9083            return;
9084        };
9085        if !gl.supported_render_dtypes().f16 {
9086            eprintln!(
9087                "SKIPPED: {} — configuration lacks F16 color-buffer support",
9088                function!()
9089            );
9090            return;
9091        }
9092        let stats_before = gl.egl_cache_stats().expect("cache stats");
9093
9094        let src = TensorDyn::image(
9095            1280,
9096            720,
9097            PixelFormat::Nv12,
9098            DType::U8,
9099            Some(TensorMemory::DmaBuf),
9100            edgefirst_tensor::CpuAccess::ReadWrite,
9101        )
9102        .unwrap();
9103        {
9104            let t = src.as_typed::<u8>().unwrap();
9105            let mut m = t.map().unwrap();
9106            for (i, b) in m.as_mut_slice().iter_mut().enumerate() {
9107                *b = ((i * 31) % 211) as u8;
9108            }
9109        }
9110        let mut dst = TensorDyn::image(
9111            640,
9112            640,
9113            PixelFormat::PlanarRgb,
9114            DType::F16,
9115            Some(TensorMemory::DmaBuf),
9116            edgefirst_tensor::CpuAccess::ReadWrite,
9117        )
9118        .unwrap();
9119
9120        proc.convert(
9121            &src,
9122            &mut dst,
9123            Rotation::None,
9124            Flip::None,
9125            Crop::letterbox([114, 114, 114, 255]),
9126        )
9127        .expect("F16 capability reported but the NV12→PlanarF16 convert failed");
9128        let stats_after = proc
9129            .opengl
9130            .as_ref()
9131            .expect("GL backend present")
9132            .egl_cache_stats()
9133            .expect("cache stats");
9134        // ≥ 2 misses: the fused convert imports the zero-copy NV source
9135        // (pass 1) AND the F16 destination (pass 2). Requiring both means a
9136        // convert that imported its source, failed mid-engine, and fell back
9137        // to CPU (1 miss) cannot satisfy this gate.
9138        assert!(
9139            stats_after.total_misses() >= stats_before.total_misses() + 2,
9140            "convert succeeded but the GL engine did not import both the \
9141             source and the F16 destination — the work did not (fully) run \
9142             on the GL backend (silent CPU fallback); misses before={} after={}",
9143            stats_before.total_misses(),
9144            stats_after.total_misses()
9145        );
9146    }
9147
9148    /// Portable oracle for the fused NV12→PlanarRgb-F16 engine convert
9149    /// (two GL passes: NV→RGBA intermediate, then the packed RGBA16F
9150    /// render). New on every platform with F16 render support — macOS
9151    /// IOSurface and Linux DMA-BUF alike. Compares against the CPU
9152    /// backend's reference within the float-path tolerance.
9153    #[test]
9154    #[cfg(feature = "opengl")]
9155    fn test_nv12_to_planar_f16_fused_engine_vs_cpu() {
9156        let mut gl = match ImageProcessor::with_config(ImageProcessorConfig {
9157            backend: ComputeBackend::OpenGl,
9158            ..Default::default()
9159        }) {
9160            Ok(p) if p.opengl.is_some() => p,
9161            _ => {
9162                eprintln!("SKIPPED: {} — GL backend unavailable", function!());
9163                return;
9164            }
9165        };
9166        if !gl
9167            .opengl
9168            .as_ref()
9169            .map(|g| g.supported_render_dtypes().f16)
9170            .unwrap_or(false)
9171        {
9172            eprintln!("SKIPPED: {} — no F16 render support", function!());
9173            return;
9174        }
9175        let mem = if edgefirst_tensor::is_gpu_buffer_available() {
9176            TensorMemory::DmaBuf
9177        } else {
9178            eprintln!("SKIPPED: {} — no zero-copy buffers", function!());
9179            return;
9180        };
9181
9182        let src = TensorDyn::image(
9183            1280,
9184            720,
9185            PixelFormat::Nv12,
9186            DType::U8,
9187            Some(mem),
9188            edgefirst_tensor::CpuAccess::ReadWrite,
9189        )
9190        .unwrap();
9191        {
9192            // Smooth gradients, NOT noise: the GL and CPU paths upsample
9193            // chroma with different kernels (nearest vs bilinear), which
9194            // legitimately diverges on per-texel chroma noise. Gradients
9195            // keep that kernel difference sub-LSB while still exercising
9196            // the full matrix math and the letterbox geometry.
9197            let t = src.as_typed::<u8>().unwrap();
9198            let mut m = t.map().unwrap();
9199            let buf = m.as_mut_slice();
9200            let (w, h) = (1280usize, 720usize);
9201            for y in 0..h {
9202                for x in 0..w {
9203                    buf[y * w + x] = ((x * 255) / w) as u8; // luma ramp
9204                }
9205            }
9206            for y in 0..(h / 2) {
9207                for x in 0..(w / 2) {
9208                    let o = h * w + y * w + 2 * x;
9209                    buf[o] = ((y * 255) / (h / 2)) as u8; // U vertical ramp
9210                    buf[o + 1] = (((x + y) * 255) / (w / 2 + h / 2)) as u8; // V diagonal
9211                }
9212            }
9213        }
9214        let crop = Crop::letterbox([114, 114, 114, 255]);
9215        let mut gl_dst = TensorDyn::image(
9216            640,
9217            640,
9218            PixelFormat::PlanarRgb,
9219            DType::F16,
9220            Some(mem),
9221            edgefirst_tensor::CpuAccess::ReadWrite,
9222        )
9223        .unwrap();
9224        // Drive the GL backend DIRECTLY: a convert through `ImageProcessor`
9225        // silently falls back to CPU on a GL error, turning this oracle into
9226        // a CPU-vs-CPU tautology. A direct call surfaces the engine error.
9227        // GPUs that allocate DMA-BUF but cannot complete an RGBA16F FBO
9228        // (desktop NVIDIA: GL_FRAMEBUFFER_INCOMPLETE_ATTACHMENT 0x8cd6)
9229        // take the production CPU fallback; this oracle cannot run there.
9230        match gl.opengl.as_mut().expect("GL backend present").convert(
9231            &src,
9232            &mut gl_dst,
9233            Rotation::None,
9234            Flip::None,
9235            crop,
9236        ) {
9237            Ok(()) => {}
9238            Err(crate::Error::NotSupported(msg)) => {
9239                eprintln!("SKIPPED: {} — {msg}", function!());
9240                return;
9241            }
9242            Err(e) => panic!("fused NV12→PlanarF16 GL convert: {e}"),
9243        }
9244
9245        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
9246            backend: ComputeBackend::Cpu,
9247            ..Default::default()
9248        })
9249        .unwrap();
9250        let mut cpu_dst = TensorDyn::image(
9251            640,
9252            640,
9253            PixelFormat::PlanarRgb,
9254            DType::F16,
9255            Some(TensorMemory::Mem),
9256            edgefirst_tensor::CpuAccess::ReadWrite,
9257        )
9258        .unwrap();
9259        cpu.convert(&src, &mut cpu_dst, Rotation::None, Flip::None, crop)
9260            .expect("CPU reference convert");
9261
9262        let g = gl_dst
9263            .as_typed::<half::f16>()
9264            .unwrap()
9265            .map()
9266            .unwrap()
9267            .as_slice()
9268            .to_vec();
9269        let c = cpu_dst
9270            .as_typed::<half::f16>()
9271            .unwrap()
9272            .map()
9273            .unwrap()
9274            .as_slice()
9275            .to_vec();
9276        assert_eq!(g.len(), c.len());
9277        let mut max_diff = 0.0f32;
9278        let mut max_at = 0usize;
9279        for (i, (a, b)) in g.iter().zip(c.iter()).enumerate() {
9280            let d = (a.to_f32() - b.to_f32()).abs();
9281            if d > max_diff {
9282                max_diff = d;
9283                max_at = i;
9284            }
9285        }
9286        // Localize: plane (R/G/B), row, col of the worst element.
9287        let (plane, rem) = (max_at / (640 * 640), max_at % (640 * 640));
9288        let (row, col) = (rem / 640, rem % 640);
9289        eprintln!(
9290            "fused-vs-cpu: max_diff={max_diff} at plane={plane} row={row} col={col} \
9291             gl={} cpu={}",
9292            g[max_at].to_f32(),
9293            c[max_at].to_f32()
9294        );
9295        // Two GPU passes (8-bit intermediate + linear filtering) vs the
9296        // CPU's direct path: allow a few 8-bit steps of divergence.
9297        assert!(
9298            max_diff <= 4.0 / 255.0 + 1e-3,
9299            "fused NV12→PlanarF16 diverges from CPU reference: max_diff={max_diff}"
9300        );
9301    }
9302
9303    /// Zero-copy source → heap destination through the GL engine,
9304    /// driven on the GL backend DIRECTLY so a GL error cannot hide
9305    /// behind the `ImageProcessor` CPU fallback (the shape that exposed
9306    /// the macOS `glReadnPixels` failure: imported source, rendered,
9307    /// then errored at the heap readback). RGBA→BGRA so the convert is
9308    /// a pure byte shuffle: no chroma kernel or colorimetry ambiguity
9309    /// (Vivante's NV fast path legitimately diverges from the CPU
9310    /// reference), and the readback stays GL_RGBA (V3D rejects RGB
9311    /// readbacks).
9312    #[test]
9313    #[cfg(feature = "opengl")]
9314    fn test_zero_copy_src_to_mem_dst_gl_direct() {
9315        let mut proc = match ImageProcessor::new() {
9316            Ok(p) if p.opengl.is_some() => p,
9317            _ => {
9318                eprintln!("SKIPPED: {} — GL backend unavailable", function!());
9319                return;
9320            }
9321        };
9322        if !edgefirst_tensor::is_gpu_buffer_available() {
9323            eprintln!("SKIPPED: {} — no zero-copy buffers", function!());
9324            return;
9325        }
9326
9327        let src = TensorDyn::image(
9328            1280,
9329            720,
9330            PixelFormat::Rgba,
9331            DType::U8,
9332            Some(TensorMemory::DmaBuf),
9333            edgefirst_tensor::CpuAccess::ReadWrite,
9334        )
9335        .unwrap();
9336        {
9337            let t = src.as_typed::<u8>().unwrap();
9338            let mut m = t.map().unwrap();
9339            for (i, b) in m.as_mut_slice().iter_mut().enumerate() {
9340                *b = ((i * 31) % 211) as u8;
9341            }
9342        }
9343        let mut gl_dst = TensorDyn::image(
9344            1280,
9345            720,
9346            PixelFormat::Bgra,
9347            DType::U8,
9348            Some(TensorMemory::Mem),
9349            edgefirst_tensor::CpuAccess::ReadWrite,
9350        )
9351        .unwrap();
9352        proc.opengl
9353            .as_mut()
9354            .expect("GL backend present")
9355            .convert(
9356                &src,
9357                &mut gl_dst,
9358                Rotation::None,
9359                Flip::None,
9360                Crop::no_crop(),
9361            )
9362            .expect("zero-copy src → heap dst GL convert");
9363
9364        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
9365            backend: ComputeBackend::Cpu,
9366            ..Default::default()
9367        })
9368        .unwrap();
9369        let mut cpu_dst = TensorDyn::image(
9370            1280,
9371            720,
9372            PixelFormat::Bgra,
9373            DType::U8,
9374            Some(TensorMemory::Mem),
9375            edgefirst_tensor::CpuAccess::ReadWrite,
9376        )
9377        .unwrap();
9378        cpu.convert(
9379            &src,
9380            &mut cpu_dst,
9381            Rotation::None,
9382            Flip::None,
9383            Crop::no_crop(),
9384        )
9385        .expect("CPU reference convert");
9386
9387        let g = gl_dst
9388            .as_typed::<u8>()
9389            .unwrap()
9390            .map()
9391            .unwrap()
9392            .as_slice()
9393            .to_vec();
9394        let c = cpu_dst
9395            .as_typed::<u8>()
9396            .unwrap()
9397            .map()
9398            .unwrap()
9399            .as_slice()
9400            .to_vec();
9401        assert_eq!(g.len(), c.len());
9402        let max_diff = g
9403            .iter()
9404            .zip(c.iter())
9405            .map(|(a, b)| a.abs_diff(*b))
9406            .max()
9407            .unwrap();
9408        // Same-size byte shuffle: GL samples texel centers 1:1, so allow
9409        // only rounding slack.
9410        assert!(
9411            max_diff <= 2,
9412            "zero-copy src → heap dst diverges from CPU reference: max_diff={max_diff}"
9413        );
9414    }
9415
9416    #[test]
9417    #[cfg(target_os = "linux")]
9418    fn test_yuyv_to_rgb_g2d() {
9419        if !is_g2d_available() {
9420            eprintln!("SKIPPED: test_yuyv_to_rgb_g2d - G2D library (libg2d.so.2) not available");
9421            return;
9422        }
9423        if !is_dma_available() {
9424            eprintln!(
9425                "SKIPPED: test_yuyv_to_rgb_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
9426            );
9427            return;
9428        }
9429
9430        let src = load_bytes_to_tensor(
9431            1280,
9432            720,
9433            PixelFormat::Yuyv,
9434            None,
9435            &edgefirst_bench::testdata::read("camera720p.yuyv"),
9436        )
9437        .unwrap();
9438
9439        let g2d_dst = TensorDyn::image(
9440            1280,
9441            720,
9442            PixelFormat::Rgb,
9443            DType::U8,
9444            Some(TensorMemory::DmaBuf),
9445            edgefirst_tensor::CpuAccess::ReadWrite,
9446        )
9447        .unwrap();
9448        let mut g2d_converter = G2DProcessor::new().unwrap();
9449
9450        let (result, src, g2d_dst) = convert_img(
9451            &mut g2d_converter,
9452            src,
9453            g2d_dst,
9454            Rotation::None,
9455            Flip::None,
9456            Crop::no_crop(),
9457        );
9458        result.unwrap();
9459
9460        let cpu_dst = TensorDyn::image(
9461            1280,
9462            720,
9463            PixelFormat::Rgb,
9464            DType::U8,
9465            None,
9466            edgefirst_tensor::CpuAccess::ReadWrite,
9467        )
9468        .unwrap();
9469        let mut cpu_converter: CPUProcessor = CPUProcessor::new();
9470
9471        let (result, _src, cpu_dst) = convert_img(
9472            &mut cpu_converter,
9473            src,
9474            cpu_dst,
9475            Rotation::None,
9476            Flip::None,
9477            Crop::no_crop(),
9478        );
9479        result.unwrap();
9480
9481        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
9482        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
9483        // the YUV-matrix delta that forced 0.95 has closed; tightened to
9484        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
9485        // structural gap not exercised by these limited-range fixtures.
9486        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
9487    }
9488
9489    #[test]
9490    #[cfg(target_os = "linux")]
9491    fn test_yuyv_to_yuyv_resize_g2d() {
9492        if !is_g2d_available() {
9493            eprintln!(
9494                "SKIPPED: test_yuyv_to_yuyv_resize_g2d - G2D library (libg2d.so.2) not available"
9495            );
9496            return;
9497        }
9498        if !is_dma_available() {
9499            eprintln!(
9500                "SKIPPED: test_yuyv_to_yuyv_resize_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
9501            );
9502            return;
9503        }
9504
9505        let src = load_bytes_to_tensor(
9506            1280,
9507            720,
9508            PixelFormat::Yuyv,
9509            None,
9510            &edgefirst_bench::testdata::read("camera720p.yuyv"),
9511        )
9512        .unwrap();
9513
9514        let g2d_dst = TensorDyn::image(
9515            600,
9516            400,
9517            PixelFormat::Yuyv,
9518            DType::U8,
9519            Some(TensorMemory::DmaBuf),
9520            edgefirst_tensor::CpuAccess::ReadWrite,
9521        )
9522        .unwrap();
9523        let mut g2d_converter = G2DProcessor::new().unwrap();
9524
9525        let (result, src, g2d_dst) = convert_img(
9526            &mut g2d_converter,
9527            src,
9528            g2d_dst,
9529            Rotation::None,
9530            Flip::None,
9531            Crop::no_crop(),
9532        );
9533        result.unwrap();
9534
9535        let cpu_dst = TensorDyn::image(
9536            600,
9537            400,
9538            PixelFormat::Yuyv,
9539            DType::U8,
9540            None,
9541            edgefirst_tensor::CpuAccess::ReadWrite,
9542        )
9543        .unwrap();
9544        let mut cpu_converter: CPUProcessor = CPUProcessor::new();
9545
9546        let (result, _src, cpu_dst) = convert_img(
9547            &mut cpu_converter,
9548            src,
9549            cpu_dst,
9550            Rotation::None,
9551            Flip::None,
9552            Crop::no_crop(),
9553        );
9554        result.unwrap();
9555
9556        // G2D has poor colorimetry support: its hardware YUYV resize/sampling
9557        // diverges from the CPU reference by enough that the similarity score
9558        // sits around 0.85 on every measured G2D core (i.MX 8M Plus, i.MX 95).
9559        // The threshold is held at 0.85 so the test guards against gross
9560        // regressions while tolerating the driver's inherent colorimetry error.
9561        // TODO: compare YUYV↔YUYV directly without a YUYV→RGB convert.
9562        eprintln!(
9563            "WARNING: G2D has poor colorimetry support — YUYV resize diverges from the \
9564             CPU reference (~0.85 similarity); threshold held at 0.85, not 0.95."
9565        );
9566        compare_images_convert_to_rgb(&g2d_dst, &cpu_dst, 0.85, function!());
9567    }
9568
9569    #[test]
9570    fn test_yuyv_to_rgba_resize_cpu() {
9571        let src = load_bytes_to_tensor(
9572            1280,
9573            720,
9574            PixelFormat::Yuyv,
9575            None,
9576            &edgefirst_bench::testdata::read("camera720p.yuyv"),
9577        )
9578        .unwrap();
9579
9580        let (dst_width, dst_height) = (960, 540);
9581
9582        let dst = TensorDyn::image(
9583            dst_width,
9584            dst_height,
9585            PixelFormat::Rgba,
9586            DType::U8,
9587            None,
9588            edgefirst_tensor::CpuAccess::ReadWrite,
9589        )
9590        .unwrap();
9591        let mut cpu_converter = CPUProcessor::new();
9592
9593        let (result, _src, dst) = convert_img(
9594            &mut cpu_converter,
9595            src,
9596            dst,
9597            Rotation::None,
9598            Flip::None,
9599            Crop::no_crop(),
9600        );
9601        result.unwrap();
9602
9603        let dst_target = TensorDyn::image(
9604            dst_width,
9605            dst_height,
9606            PixelFormat::Rgba,
9607            DType::U8,
9608            None,
9609            edgefirst_tensor::CpuAccess::ReadWrite,
9610        )
9611        .unwrap();
9612        let src_target = load_bytes_to_tensor(
9613            1280,
9614            720,
9615            PixelFormat::Rgba,
9616            None,
9617            &edgefirst_bench::testdata::read("camera720p.rgba"),
9618        )
9619        .unwrap();
9620        let (result, _src_target, dst_target) = convert_img(
9621            &mut cpu_converter,
9622            src_target,
9623            dst_target,
9624            Rotation::None,
9625            Flip::None,
9626            Crop::no_crop(),
9627        );
9628        result.unwrap();
9629
9630        // CPU path resolves the untagged 720p source to BT.709 limited (height
9631        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
9632        compare_images(&dst, &dst_target, 0.98, function!());
9633    }
9634
9635    #[test]
9636    #[cfg(target_os = "linux")]
9637    fn test_yuyv_to_rgba_crop_flip_g2d() {
9638        if !is_g2d_available() {
9639            eprintln!(
9640                "SKIPPED: test_yuyv_to_rgba_crop_flip_g2d - G2D library (libg2d.so.2) not available"
9641            );
9642            return;
9643        }
9644        if !is_dma_available() {
9645            eprintln!(
9646                "SKIPPED: test_yuyv_to_rgba_crop_flip_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
9647            );
9648            return;
9649        }
9650
9651        let src = load_bytes_to_tensor(
9652            1280,
9653            720,
9654            PixelFormat::Yuyv,
9655            Some(TensorMemory::DmaBuf),
9656            &edgefirst_bench::testdata::read("camera720p.yuyv"),
9657        )
9658        .unwrap();
9659
9660        let (dst_width, dst_height) = (640, 640);
9661
9662        let dst_g2d = TensorDyn::image(
9663            dst_width,
9664            dst_height,
9665            PixelFormat::Rgba,
9666            DType::U8,
9667            Some(TensorMemory::DmaBuf),
9668            edgefirst_tensor::CpuAccess::ReadWrite,
9669        )
9670        .unwrap();
9671        let mut g2d_converter = G2DProcessor::new().unwrap();
9672        let crop = Crop::new().with_source(Some(Region::new(20, 15, 400, 300)));
9673
9674        let (result, src, dst_g2d) = convert_img(
9675            &mut g2d_converter,
9676            src,
9677            dst_g2d,
9678            Rotation::None,
9679            Flip::Horizontal,
9680            crop,
9681        );
9682        result.unwrap();
9683
9684        let dst_cpu = TensorDyn::image(
9685            dst_width,
9686            dst_height,
9687            PixelFormat::Rgba,
9688            DType::U8,
9689            Some(TensorMemory::DmaBuf),
9690            edgefirst_tensor::CpuAccess::ReadWrite,
9691        )
9692        .unwrap();
9693        let mut cpu_converter = CPUProcessor::new();
9694
9695        let (result, _src, dst_cpu) = convert_img(
9696            &mut cpu_converter,
9697            src,
9698            dst_cpu,
9699            Rotation::None,
9700            Flip::Horizontal,
9701            crop,
9702        );
9703        result.unwrap();
9704        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
9705        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
9706        // the YUV-matrix delta that forced 0.95 has closed; tightened to
9707        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
9708        // structural gap not exercised by these limited-range fixtures.
9709        compare_images(&dst_g2d, &dst_cpu, 0.98, function!());
9710    }
9711
9712    #[test]
9713    #[cfg(any(
9714        target_os = "linux",
9715        target_os = "macos",
9716        target_os = "ios",
9717        target_os = "android",
9718        target_os = "windows"
9719    ))]
9720    #[cfg(feature = "opengl")]
9721    fn test_yuyv_to_rgba_crop_flip_opengl() {
9722        if !is_opengl_available() {
9723            eprintln!("SKIPPED: {} - OpenGL not available", function!());
9724            return;
9725        }
9726
9727        if !edgefirst_tensor::is_gpu_buffer_available() {
9728            eprintln!(
9729                "SKIPPED: {} - zero-copy GPU buffer (DMA-BUF / IOSurface) not available on this host",
9730                function!()
9731            );
9732            return;
9733        }
9734
9735        let src = load_bytes_to_tensor(
9736            1280,
9737            720,
9738            PixelFormat::Yuyv,
9739            Some(TensorMemory::DmaBuf),
9740            &edgefirst_bench::testdata::read("camera720p.yuyv"),
9741        )
9742        .unwrap();
9743
9744        let (dst_width, dst_height) = (640, 640);
9745
9746        let dst_gl = TensorDyn::image(
9747            dst_width,
9748            dst_height,
9749            PixelFormat::Rgba,
9750            DType::U8,
9751            Some(TensorMemory::DmaBuf),
9752            edgefirst_tensor::CpuAccess::ReadWrite,
9753        )
9754        .unwrap();
9755        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
9756        let crop = Crop::new().with_source(Some(Region::new(20, 15, 400, 300)));
9757
9758        let (result, src, dst_gl) = convert_img(
9759            &mut gl_converter,
9760            src,
9761            dst_gl,
9762            Rotation::None,
9763            Flip::Horizontal,
9764            crop,
9765        );
9766        // Desktop NVIDIA cannot sample YUYV as a GL texture; i.MX/Vivante can.
9767        if let Err(crate::Error::NotSupported(msg)) = &result {
9768            eprintln!("SKIPPED: {} — {msg}", function!());
9769            return;
9770        }
9771        result.unwrap();
9772
9773        let dst_cpu = TensorDyn::image(
9774            dst_width,
9775            dst_height,
9776            PixelFormat::Rgba,
9777            DType::U8,
9778            Some(TensorMemory::DmaBuf),
9779            edgefirst_tensor::CpuAccess::ReadWrite,
9780        )
9781        .unwrap();
9782        let mut cpu_converter = CPUProcessor::new();
9783
9784        let (result, _src, dst_cpu) = convert_img(
9785            &mut cpu_converter,
9786            src,
9787            dst_cpu,
9788            Rotation::None,
9789            Flip::Horizontal,
9790            crop,
9791        );
9792        result.unwrap();
9793        // Post-WS1 the GL path applies the resolved colorimetry via the EGL
9794        // YUV color-space/sample-range hints, so the matrix delta that forced
9795        // 0.95 has closed; tightened to 0.98 (driver-matrix rounding confirmed
9796        // on the GPU lanes).
9797        compare_images(&dst_gl, &dst_cpu, 0.98, function!());
9798    }
9799
9800    #[test]
9801    fn test_vyuy_to_rgba_cpu() {
9802        let file = edgefirst_bench::testdata::read("camera720p.vyuy").to_vec();
9803        let src = TensorDyn::image(
9804            1280,
9805            720,
9806            PixelFormat::Vyuy,
9807            DType::U8,
9808            None,
9809            edgefirst_tensor::CpuAccess::ReadWrite,
9810        )
9811        .unwrap();
9812        src.as_typed::<u8>()
9813            .unwrap()
9814            .map()
9815            .unwrap()
9816            .as_mut_slice()
9817            .copy_from_slice(&file);
9818
9819        let dst = TensorDyn::image(
9820            1280,
9821            720,
9822            PixelFormat::Rgba,
9823            DType::U8,
9824            None,
9825            edgefirst_tensor::CpuAccess::ReadWrite,
9826        )
9827        .unwrap();
9828        let mut cpu_converter = CPUProcessor::new();
9829
9830        let (result, _src, dst) = convert_img(
9831            &mut cpu_converter,
9832            src,
9833            dst,
9834            Rotation::None,
9835            Flip::None,
9836            Crop::no_crop(),
9837        );
9838        result.unwrap();
9839
9840        let target_image = TensorDyn::image(
9841            1280,
9842            720,
9843            PixelFormat::Rgba,
9844            DType::U8,
9845            None,
9846            edgefirst_tensor::CpuAccess::ReadWrite,
9847        )
9848        .unwrap();
9849        target_image
9850            .as_typed::<u8>()
9851            .unwrap()
9852            .map()
9853            .unwrap()
9854            .as_mut_slice()
9855            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
9856
9857        // CPU path resolves the untagged 720p source to BT.709 limited (height
9858        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
9859        compare_images(&dst, &target_image, 0.98, function!());
9860    }
9861
9862    #[test]
9863    fn test_vyuy_to_rgb_cpu() {
9864        let file = edgefirst_bench::testdata::read("camera720p.vyuy").to_vec();
9865        let src = TensorDyn::image(
9866            1280,
9867            720,
9868            PixelFormat::Vyuy,
9869            DType::U8,
9870            None,
9871            edgefirst_tensor::CpuAccess::ReadWrite,
9872        )
9873        .unwrap();
9874        src.as_typed::<u8>()
9875            .unwrap()
9876            .map()
9877            .unwrap()
9878            .as_mut_slice()
9879            .copy_from_slice(&file);
9880
9881        let dst = TensorDyn::image(
9882            1280,
9883            720,
9884            PixelFormat::Rgb,
9885            DType::U8,
9886            None,
9887            edgefirst_tensor::CpuAccess::ReadWrite,
9888        )
9889        .unwrap();
9890        let mut cpu_converter = CPUProcessor::new();
9891
9892        let (result, _src, dst) = convert_img(
9893            &mut cpu_converter,
9894            src,
9895            dst,
9896            Rotation::None,
9897            Flip::None,
9898            Crop::no_crop(),
9899        );
9900        result.unwrap();
9901
9902        let target_image = TensorDyn::image(
9903            1280,
9904            720,
9905            PixelFormat::Rgb,
9906            DType::U8,
9907            None,
9908            edgefirst_tensor::CpuAccess::ReadWrite,
9909        )
9910        .unwrap();
9911        target_image
9912            .as_typed::<u8>()
9913            .unwrap()
9914            .map()
9915            .unwrap()
9916            .as_mut_slice()
9917            .as_chunks_mut::<3>()
9918            .0
9919            .iter_mut()
9920            .zip(
9921                edgefirst_bench::testdata::read("camera720p.rgba")
9922                    .as_chunks::<4>()
9923                    .0,
9924            )
9925            .for_each(|(dst, src)| *dst = [src[0], src[1], src[2]]);
9926
9927        // CPU path resolves the untagged 720p source to BT.709 limited (height
9928        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
9929        compare_images(&dst, &target_image, 0.98, function!());
9930    }
9931
9932    #[test]
9933    #[cfg(target_os = "linux")]
9934    #[ignore = "G2D does not support VYUY; re-enable when hardware support is added"]
9935    fn test_vyuy_to_rgba_g2d() {
9936        if !is_g2d_available() {
9937            eprintln!("SKIPPED: test_vyuy_to_rgba_g2d - G2D library (libg2d.so.2) not available");
9938            return;
9939        }
9940        if !is_dma_available() {
9941            eprintln!(
9942                "SKIPPED: test_vyuy_to_rgba_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
9943            );
9944            return;
9945        }
9946
9947        let src = load_bytes_to_tensor(
9948            1280,
9949            720,
9950            PixelFormat::Vyuy,
9951            None,
9952            &edgefirst_bench::testdata::read("camera720p.vyuy"),
9953        )
9954        .unwrap();
9955
9956        let dst = TensorDyn::image(
9957            1280,
9958            720,
9959            PixelFormat::Rgba,
9960            DType::U8,
9961            Some(TensorMemory::DmaBuf),
9962            edgefirst_tensor::CpuAccess::ReadWrite,
9963        )
9964        .unwrap();
9965        let mut g2d_converter = G2DProcessor::new().unwrap();
9966
9967        let (result, _src, dst) = convert_img(
9968            &mut g2d_converter,
9969            src,
9970            dst,
9971            Rotation::None,
9972            Flip::None,
9973            Crop::no_crop(),
9974        );
9975        match result {
9976            Err(Error::G2D(_)) => {
9977                eprintln!("SKIPPED: test_vyuy_to_rgba_g2d - G2D does not support PixelFormat::Vyuy format");
9978                return;
9979            }
9980            r => r.unwrap(),
9981        }
9982
9983        let target_image = TensorDyn::image(
9984            1280,
9985            720,
9986            PixelFormat::Rgba,
9987            DType::U8,
9988            None,
9989            edgefirst_tensor::CpuAccess::ReadWrite,
9990        )
9991        .unwrap();
9992        target_image
9993            .as_typed::<u8>()
9994            .unwrap()
9995            .map()
9996            .unwrap()
9997            .as_mut_slice()
9998            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
9999
10000        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
10001        // so the matrix delta vs the reference that forced 0.95 has closed;
10002        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
10003        compare_images(&dst, &target_image, 0.98, function!());
10004    }
10005
10006    #[test]
10007    #[cfg(target_os = "linux")]
10008    #[ignore = "G2D does not support VYUY; re-enable when hardware support is added"]
10009    fn test_vyuy_to_rgb_g2d() {
10010        if !is_g2d_available() {
10011            eprintln!("SKIPPED: test_vyuy_to_rgb_g2d - G2D library (libg2d.so.2) not available");
10012            return;
10013        }
10014        if !is_dma_available() {
10015            eprintln!(
10016                "SKIPPED: test_vyuy_to_rgb_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
10017            );
10018            return;
10019        }
10020
10021        let src = load_bytes_to_tensor(
10022            1280,
10023            720,
10024            PixelFormat::Vyuy,
10025            None,
10026            &edgefirst_bench::testdata::read("camera720p.vyuy"),
10027        )
10028        .unwrap();
10029
10030        let g2d_dst = TensorDyn::image(
10031            1280,
10032            720,
10033            PixelFormat::Rgb,
10034            DType::U8,
10035            Some(TensorMemory::DmaBuf),
10036            edgefirst_tensor::CpuAccess::ReadWrite,
10037        )
10038        .unwrap();
10039        let mut g2d_converter = G2DProcessor::new().unwrap();
10040
10041        let (result, src, g2d_dst) = convert_img(
10042            &mut g2d_converter,
10043            src,
10044            g2d_dst,
10045            Rotation::None,
10046            Flip::None,
10047            Crop::no_crop(),
10048        );
10049        match result {
10050            Err(Error::G2D(_)) => {
10051                eprintln!(
10052                    "SKIPPED: test_vyuy_to_rgb_g2d - G2D does not support PixelFormat::Vyuy format"
10053                );
10054                return;
10055            }
10056            r => r.unwrap(),
10057        }
10058
10059        let cpu_dst = TensorDyn::image(
10060            1280,
10061            720,
10062            PixelFormat::Rgb,
10063            DType::U8,
10064            None,
10065            edgefirst_tensor::CpuAccess::ReadWrite,
10066        )
10067        .unwrap();
10068        let mut cpu_converter: CPUProcessor = CPUProcessor::new();
10069
10070        let (result, _src, cpu_dst) = convert_img(
10071            &mut cpu_converter,
10072            src,
10073            cpu_dst,
10074            Rotation::None,
10075            Flip::None,
10076            Crop::no_crop(),
10077        );
10078        result.unwrap();
10079
10080        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
10081        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
10082        // the YUV-matrix delta that forced 0.95 has closed; tightened to
10083        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
10084        // structural gap not exercised by these limited-range fixtures.
10085        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
10086    }
10087
10088    #[test]
10089    #[cfg(any(
10090        target_os = "linux",
10091        target_os = "macos",
10092        target_os = "ios",
10093        target_os = "android",
10094        target_os = "windows"
10095    ))]
10096    #[cfg(feature = "opengl")]
10097    fn test_vyuy_to_rgba_opengl() {
10098        if !is_opengl_available() {
10099            eprintln!("SKIPPED: {} - OpenGL not available", function!());
10100            return;
10101        }
10102        if !edgefirst_tensor::is_gpu_buffer_available() {
10103            eprintln!(
10104                "SKIPPED: {} - zero-copy GPU buffer (DMA-BUF / IOSurface) not available on this host",
10105                function!()
10106            );
10107            return;
10108        }
10109
10110        // VYUY has a DMA-BUF FourCC on Linux but no IOSurface mapping on
10111        // macOS/iOS, so the zero-copy allocation itself is the per-format
10112        // probe: a platform that declines it is a skip, not a failure.
10113        let src = match load_bytes_to_tensor(
10114            1280,
10115            720,
10116            PixelFormat::Vyuy,
10117            Some(TensorMemory::DmaBuf),
10118            &edgefirst_bench::testdata::read("camera720p.vyuy"),
10119        ) {
10120            Ok(src) => src,
10121            Err(e) => {
10122                eprintln!(
10123                    "SKIPPED: {} - zero-copy VYUY source not allocatable on this platform ({e})",
10124                    function!()
10125                );
10126                return;
10127            }
10128        };
10129
10130        let dst = TensorDyn::image(
10131            1280,
10132            720,
10133            PixelFormat::Rgba,
10134            DType::U8,
10135            Some(TensorMemory::DmaBuf),
10136            edgefirst_tensor::CpuAccess::ReadWrite,
10137        )
10138        .unwrap();
10139        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
10140
10141        let (result, _src, dst) = convert_img(
10142            &mut gl_converter,
10143            src,
10144            dst,
10145            Rotation::None,
10146            Flip::None,
10147            Crop::no_crop(),
10148        );
10149        match result {
10150            Err(Error::NotSupported(_)) => {
10151                eprintln!(
10152                    "SKIPPED: {} - OpenGL does not support PixelFormat::Vyuy DMA format",
10153                    function!()
10154                );
10155                return;
10156            }
10157            r => r.unwrap(),
10158        }
10159
10160        let target_image = TensorDyn::image(
10161            1280,
10162            720,
10163            PixelFormat::Rgba,
10164            DType::U8,
10165            None,
10166            edgefirst_tensor::CpuAccess::ReadWrite,
10167        )
10168        .unwrap();
10169        target_image
10170            .as_typed::<u8>()
10171            .unwrap()
10172            .map()
10173            .unwrap()
10174            .as_mut_slice()
10175            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
10176
10177        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
10178        // so the matrix delta vs the reference that forced 0.95 has closed;
10179        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
10180        compare_images(&dst, &target_image, 0.98, function!());
10181    }
10182
10183    #[test]
10184    fn test_nv12_to_rgba_cpu() {
10185        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
10186        let src = TensorDyn::image(
10187            1280,
10188            720,
10189            PixelFormat::Nv12,
10190            DType::U8,
10191            None,
10192            edgefirst_tensor::CpuAccess::ReadWrite,
10193        )
10194        .unwrap();
10195        src.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
10196            .copy_from_slice(&file);
10197
10198        let dst = TensorDyn::image(
10199            1280,
10200            720,
10201            PixelFormat::Rgba,
10202            DType::U8,
10203            None,
10204            edgefirst_tensor::CpuAccess::ReadWrite,
10205        )
10206        .unwrap();
10207        let mut cpu_converter = CPUProcessor::new();
10208
10209        let (result, _src, dst) = convert_img(
10210            &mut cpu_converter,
10211            src,
10212            dst,
10213            Rotation::None,
10214            Flip::None,
10215            Crop::no_crop(),
10216        );
10217        result.unwrap();
10218
10219        let target_image = crate::load_image_test_helper(
10220            &edgefirst_bench::testdata::read("zidane.jpg"),
10221            Some(PixelFormat::Rgba),
10222            None,
10223        )
10224        .unwrap();
10225
10226        // Threshold 0.95 (was 0.98): the reference now decodes the colour JPEG
10227        // to native NV12 and then converts to RGBA (was a direct JPEG → RGBA
10228        // decode), so it differs slightly from the RGBA derived from the
10229        // separate `zidane.nv12` fixture.
10230        compare_images(&dst, &target_image, 0.95, function!());
10231    }
10232
10233    #[test]
10234    fn test_nv12_odd_height_to_rgb_cpu() {
10235        // Odd height (even width) — the logical-odd case, e.g. 640×483. The
10236        // contiguous NV12 buffer is `[5 + ceil(5/2), 8]` = `[8, 8]` (5 luma rows
10237        // + 3 chroma rows). A neutral-grey fill (Y=U=V=128, BT.601 full-range)
10238        // must convert to a uniform grey RGB, exercising the odd-height
10239        // chroma-row count and the logical-height derivation in convert.
10240        // (Odd *width* is rounded to an even buffer at allocation, so it is
10241        // covered by the decode integration tests rather than here.)
10242        // CPU-only test: pin to tight host memory (None auto-selects pitch-padded
10243        // DMA on i.MX, which would leave the dst's row padding unconverted and
10244        // break the flat byte scan below).
10245        let mut src = TensorDyn::image(
10246            8,
10247            5,
10248            PixelFormat::Nv12,
10249            DType::U8,
10250            Some(TensorMemory::Mem),
10251            edgefirst_tensor::CpuAccess::ReadWrite,
10252        )
10253        .unwrap();
10254        assert_eq!(src.shape(), &[8, 8]);
10255        assert_eq!((src.width(), src.height()), (Some(8), Some(5)));
10256        src.as_typed::<u8>()
10257            .unwrap()
10258            .map()
10259            .unwrap()
10260            .as_mut_slice()
10261            .fill(128);
10262        // Tag BT.601 full-range so Y=128 decodes to grey 128 (the neutral-grey
10263        // identity this test asserts). Without a tag, the colorimetry heuristic
10264        // resolves an SD tensor to BT.601 *limited*, expanding Y=128 → ~131.
10265        src.set_colorimetry(Some(
10266            edgefirst_tensor::Colorimetry::default()
10267                .with_encoding(edgefirst_tensor::ColorEncoding::Bt601)
10268                .with_range(edgefirst_tensor::ColorRange::Full),
10269        ));
10270
10271        let dst = TensorDyn::image(
10272            8,
10273            5,
10274            PixelFormat::Rgb,
10275            DType::U8,
10276            Some(TensorMemory::Mem),
10277            edgefirst_tensor::CpuAccess::ReadWrite,
10278        )
10279        .unwrap();
10280        let mut cpu_converter = CPUProcessor::new();
10281        let (result, _src, dst) = convert_img(
10282            &mut cpu_converter,
10283            src,
10284            dst,
10285            Rotation::None,
10286            Flip::None,
10287            Crop::no_crop(),
10288        );
10289        result.unwrap();
10290
10291        assert_eq!((dst.width(), dst.height()), (Some(8), Some(5)));
10292        let map = dst.as_typed::<u8>().unwrap().map().unwrap();
10293        for (i, &b) in map.as_slice().iter().enumerate() {
10294            assert!(
10295                (b as i16 - 128).abs() <= 2,
10296                "pixel byte {i} = {b}, expected ~128 for neutral-grey NV12"
10297            );
10298        }
10299    }
10300
10301    #[test]
10302    fn test_nv24_to_rgb_cpu() {
10303        // NV24 (4:4:4) at 8×4: contiguous buffer is [4*3, 8] = [12, 8] — Y plane
10304        // (4 rows) + full-res interleaved UV plane (8 rows = 2H, 2W bytes per
10305        // chroma row). Neutral-grey fill (Y=U=V=128) must convert to uniform
10306        // grey RGB, exercising the 2× UV stride and shape[0]/3 height recovery.
10307        // CPU-only test: pin to tight host memory (see test_nv12_odd_height_to_rgb_cpu).
10308        let mut src = TensorDyn::image(
10309            8,
10310            4,
10311            PixelFormat::Nv24,
10312            DType::U8,
10313            Some(TensorMemory::Mem),
10314            edgefirst_tensor::CpuAccess::ReadWrite,
10315        )
10316        .unwrap();
10317        assert_eq!(src.shape(), &[12, 8]);
10318        assert_eq!((src.width(), src.height()), (Some(8), Some(4)));
10319        src.as_typed::<u8>()
10320            .unwrap()
10321            .map()
10322            .unwrap()
10323            .as_mut_slice()
10324            .fill(128);
10325        // Tag BT.601 full-range (see test_nv12_odd_height_to_rgb_cpu): without it
10326        // the heuristic picks limited range and Y=128 expands to ~131.
10327        src.set_colorimetry(Some(
10328            edgefirst_tensor::Colorimetry::default()
10329                .with_encoding(edgefirst_tensor::ColorEncoding::Bt601)
10330                .with_range(edgefirst_tensor::ColorRange::Full),
10331        ));
10332
10333        let dst = TensorDyn::image(
10334            8,
10335            4,
10336            PixelFormat::Rgb,
10337            DType::U8,
10338            Some(TensorMemory::Mem),
10339            edgefirst_tensor::CpuAccess::ReadWrite,
10340        )
10341        .unwrap();
10342        let mut cpu_converter = CPUProcessor::new();
10343        let (result, _src, dst) = convert_img(
10344            &mut cpu_converter,
10345            src,
10346            dst,
10347            Rotation::None,
10348            Flip::None,
10349            Crop::no_crop(),
10350        );
10351        result.unwrap();
10352
10353        assert_eq!((dst.width(), dst.height()), (Some(8), Some(4)));
10354        let map = dst.as_typed::<u8>().unwrap().map().unwrap();
10355        for (i, &b) in map.as_slice().iter().enumerate() {
10356            assert!(
10357                (b as i16 - 128).abs() <= 2,
10358                "pixel byte {i} = {b}, expected ~128 for neutral-grey NV24"
10359            );
10360        }
10361    }
10362
10363    #[test]
10364    fn cpu_nv12_to_rgb_respects_tagged_bt2020() {
10365        // A uniform but *saturated* chroma sample (U/V far from neutral) so the
10366        // YUV→RGB matrix — not just the range — drives the result. Decoding the
10367        // same NV12 bytes under BT.601 / BT.709 / BT.2020 must yield three
10368        // distinct RGB triples, proving the CPU path honours the source's tagged
10369        // ColorEncoding instead of a hardcoded matrix. (G2D declines BT.2020 and
10370        // falls through to this CPU path; QA F9.)
10371        // CPU-only test: pin to tight host memory (see test_nv12_odd_height_to_rgb_cpu).
10372        fn decode_tagged(enc: edgefirst_tensor::ColorEncoding) -> [u8; 3] {
10373            let mut src = TensorDyn::image(
10374                8,
10375                4,
10376                PixelFormat::Nv12,
10377                DType::U8,
10378                Some(TensorMemory::Mem),
10379                edgefirst_tensor::CpuAccess::ReadWrite,
10380            )
10381            .unwrap();
10382            // NV12 8×4: 32-byte Y plane + 16-byte interleaved UV plane (4:2:0).
10383            assert_eq!(src.shape(), &[6, 8]);
10384            {
10385                let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
10386                let buf = map.as_mut_slice();
10387                buf[..32].fill(120); // Y
10388                for px in buf[32..].as_chunks_mut::<2>().0 {
10389                    px[0] = 180; // U / Cb
10390                    px[1] = 64; // V / Cr
10391                }
10392            }
10393            // Range held constant (Limited) across all three so only the encoding
10394            // matrix varies between runs.
10395            src.set_colorimetry(Some(
10396                edgefirst_tensor::Colorimetry::default()
10397                    .with_encoding(enc)
10398                    .with_range(edgefirst_tensor::ColorRange::Limited),
10399            ));
10400            let dst = TensorDyn::image(
10401                8,
10402                4,
10403                PixelFormat::Rgb,
10404                DType::U8,
10405                Some(TensorMemory::Mem),
10406                edgefirst_tensor::CpuAccess::ReadWrite,
10407            )
10408            .unwrap();
10409            let mut cpu = CPUProcessor::new();
10410            let (result, _src, dst) = convert_img(
10411                &mut cpu,
10412                src,
10413                dst,
10414                Rotation::None,
10415                Flip::None,
10416                Crop::no_crop(),
10417            );
10418            result.unwrap();
10419            let map = dst.as_typed::<u8>().unwrap().map().unwrap();
10420            let s = map.as_slice();
10421            [s[0], s[1], s[2]]
10422        }
10423
10424        let bt601 = decode_tagged(edgefirst_tensor::ColorEncoding::Bt601);
10425        let bt709 = decode_tagged(edgefirst_tensor::ColorEncoding::Bt709);
10426        let bt2020 = decode_tagged(edgefirst_tensor::ColorEncoding::Bt2020);
10427
10428        assert_ne!(
10429            bt2020, bt601,
10430            "BT.2020 must decode differently from BT.601 ({bt2020:?} vs {bt601:?})"
10431        );
10432        assert_ne!(
10433            bt2020, bt709,
10434            "BT.2020 must decode differently from BT.709 ({bt2020:?} vs {bt709:?})"
10435        );
10436        assert_ne!(
10437            bt601, bt709,
10438            "BT.601 must decode differently from BT.709 ({bt601:?} vs {bt709:?})"
10439        );
10440    }
10441
10442    #[test]
10443    fn test_nv12_to_rgb_cpu() {
10444        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
10445        let src = TensorDyn::image(
10446            1280,
10447            720,
10448            PixelFormat::Nv12,
10449            DType::U8,
10450            None,
10451            edgefirst_tensor::CpuAccess::ReadWrite,
10452        )
10453        .unwrap();
10454        src.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
10455            .copy_from_slice(&file);
10456
10457        let dst = TensorDyn::image(
10458            1280,
10459            720,
10460            PixelFormat::Rgb,
10461            DType::U8,
10462            None,
10463            edgefirst_tensor::CpuAccess::ReadWrite,
10464        )
10465        .unwrap();
10466        let mut cpu_converter = CPUProcessor::new();
10467
10468        let (result, _src, dst) = convert_img(
10469            &mut cpu_converter,
10470            src,
10471            dst,
10472            Rotation::None,
10473            Flip::None,
10474            Crop::no_crop(),
10475        );
10476        result.unwrap();
10477
10478        let target_image = crate::load_image_test_helper(
10479            &edgefirst_bench::testdata::read("zidane.jpg"),
10480            Some(PixelFormat::Rgb),
10481            None,
10482        )
10483        .unwrap();
10484
10485        // Threshold 0.95 (was 0.98): the reference now decodes the colour JPEG
10486        // to native NV12 and then converts to RGB (was a direct JPEG → RGB
10487        // decode), so it differs slightly from the RGB derived from the
10488        // separate `zidane.nv12` fixture.
10489        compare_images(&dst, &target_image, 0.95, function!());
10490    }
10491
10492    #[test]
10493    fn test_nv12_to_grey_cpu() {
10494        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
10495        let src = TensorDyn::image(
10496            1280,
10497            720,
10498            PixelFormat::Nv12,
10499            DType::U8,
10500            None,
10501            edgefirst_tensor::CpuAccess::ReadWrite,
10502        )
10503        .unwrap();
10504        src.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
10505            .copy_from_slice(&file);
10506
10507        let dst = TensorDyn::image(
10508            1280,
10509            720,
10510            PixelFormat::Grey,
10511            DType::U8,
10512            None,
10513            edgefirst_tensor::CpuAccess::ReadWrite,
10514        )
10515        .unwrap();
10516        let mut cpu_converter = CPUProcessor::new();
10517
10518        let (result, _src, dst) = convert_img(
10519            &mut cpu_converter,
10520            src,
10521            dst,
10522            Rotation::None,
10523            Flip::None,
10524            Crop::no_crop(),
10525        );
10526        result.unwrap();
10527
10528        let target_image = crate::load_image_test_helper(
10529            &edgefirst_bench::testdata::read("zidane.jpg"),
10530            Some(PixelFormat::Grey),
10531            None,
10532        )
10533        .unwrap();
10534
10535        // Threshold 0.95 (was 0.98): the reference grey frame now comes from
10536        // the colour JPEG decoded to native NV12 and then converted to GREY
10537        // (was a direct JPEG → GREY decode), so it differs slightly from the
10538        // grey derived from the `zidane.nv12` fixture.
10539        compare_images(&dst, &target_image, 0.95, function!());
10540    }
10541
10542    #[test]
10543    fn test_nv12_to_yuyv_cpu() {
10544        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
10545        let src = TensorDyn::image(
10546            1280,
10547            720,
10548            PixelFormat::Nv12,
10549            DType::U8,
10550            None,
10551            edgefirst_tensor::CpuAccess::ReadWrite,
10552        )
10553        .unwrap();
10554        src.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
10555            .copy_from_slice(&file);
10556
10557        let dst = TensorDyn::image(
10558            1280,
10559            720,
10560            PixelFormat::Yuyv,
10561            DType::U8,
10562            None,
10563            edgefirst_tensor::CpuAccess::ReadWrite,
10564        )
10565        .unwrap();
10566        let mut cpu_converter = CPUProcessor::new();
10567
10568        let (result, _src, dst) = convert_img(
10569            &mut cpu_converter,
10570            src,
10571            dst,
10572            Rotation::None,
10573            Flip::None,
10574            Crop::no_crop(),
10575        );
10576        result.unwrap();
10577
10578        let target_image = crate::load_image_test_helper(
10579            &edgefirst_bench::testdata::read("zidane.jpg"),
10580            Some(PixelFormat::Rgb),
10581            None,
10582        )
10583        .unwrap();
10584
10585        // Threshold 0.95 (was 0.98): the reference now decodes the colour JPEG
10586        // to native NV12 and then converts to RGB (was a direct JPEG → RGB
10587        // decode), so it differs slightly from the YUYV-sourced frame derived
10588        // from the separate `zidane.nv12` fixture.
10589        compare_images_convert_to_rgb(&dst, &target_image, 0.95, function!());
10590    }
10591
10592    #[test]
10593    fn test_cpu_resize_nv16() {
10594        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
10595        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
10596
10597        let cpu_nv16_dst = TensorDyn::image(
10598            640,
10599            640,
10600            PixelFormat::Nv16,
10601            DType::U8,
10602            None,
10603            edgefirst_tensor::CpuAccess::ReadWrite,
10604        )
10605        .unwrap();
10606        let cpu_rgb_dst = TensorDyn::image(
10607            640,
10608            640,
10609            PixelFormat::Rgb,
10610            DType::U8,
10611            None,
10612            edgefirst_tensor::CpuAccess::ReadWrite,
10613        )
10614        .unwrap();
10615        let mut cpu_converter = CPUProcessor::new();
10616        let crop = Crop::letterbox([255, 128, 0, 255]);
10617
10618        let (result, src, cpu_nv16_dst) = convert_img(
10619            &mut cpu_converter,
10620            src,
10621            cpu_nv16_dst,
10622            Rotation::None,
10623            Flip::None,
10624            crop,
10625        );
10626        result.unwrap();
10627
10628        let (result, _src, cpu_rgb_dst) = convert_img(
10629            &mut cpu_converter,
10630            src,
10631            cpu_rgb_dst,
10632            Rotation::None,
10633            Flip::None,
10634            crop,
10635        );
10636        result.unwrap();
10637        compare_images_convert_to_rgb(
10638            &cpu_nv16_dst,
10639            &cpu_rgb_dst,
10640            yuv_threshold(0.99),
10641            function!(),
10642        );
10643    }
10644
10645    /// Similarity threshold for a comparison whose path runs a YUV↔RGB
10646    /// conversion.
10647    ///
10648    /// [`crate::cpu::convert`]'s `yuv_mode()` deliberately selects
10649    /// `YuvConversionMode::Fast` on cores without Armv8.1 `rdm`
10650    /// (Cortex-A53/A35 class, e.g. the i.MX 8M Plus), because `Balanced`
10651    /// emulates `SQRDMLAH` there and costs roughly 2×. `Fast` is a
10652    /// lower-precision integer approximation with a documented error of up to
10653    /// ~2/255, so a threshold calibrated on an `rdm`-capable core is
10654    /// unreachable on those cores **by design**: NV16 measures 0.9885 against
10655    /// 0.99 on an A53, while every `rdm` core passes.
10656    ///
10657    /// No CI runner is A53-class, so this only reproduces on target.
10658    ///
10659    /// Relaxing per-core keeps the assertion tight where the precise path
10660    /// runs, instead of loosening it everywhere to accommodate one core.
10661    fn yuv_threshold(balanced: f64) -> f64 {
10662        #[cfg(target_arch = "aarch64")]
10663        {
10664            if !std::arch::is_aarch64_feature_detected!("rdm") {
10665                return balanced - 0.01;
10666            }
10667        }
10668        balanced
10669    }
10670
10671    fn load_bytes_to_tensor(
10672        width: usize,
10673        height: usize,
10674        format: PixelFormat,
10675        memory: Option<TensorMemory>,
10676        bytes: &[u8],
10677    ) -> Result<TensorDyn, Error> {
10678        let src = TensorDyn::image(
10679            width,
10680            height,
10681            format,
10682            DType::U8,
10683            memory,
10684            edgefirst_tensor::CpuAccess::ReadWrite,
10685        )?;
10686        src.as_typed::<u8>()
10687            .unwrap()
10688            .map()?
10689            .as_mut_slice()
10690            .copy_from_slice(bytes);
10691        Ok(src)
10692    }
10693
10694    // DEDUP: this function is also defined verbatim in
10695    // `crates/image/src/gl/tests.rs` (inside `mod gl_tests`). Both copies
10696    // must be kept in sync. Cross-module sharing would require either a
10697    // `pub(crate)` test-helper module (which pollutes the non-test API) or a
10698    // separate test-utils crate — both are disproportionate for a single
10699    // helper. If the implementation ever diverges, extract to a shared
10700    // `test_helpers` module in this crate.
10701    fn compare_images(img1: &TensorDyn, img2: &TensorDyn, threshold: f64, name: &str) {
10702        assert_eq!(img1.height(), img2.height(), "Heights differ");
10703        assert_eq!(img1.width(), img2.width(), "Widths differ");
10704        assert_eq!(
10705            img1.format().unwrap(),
10706            img2.format().unwrap(),
10707            "PixelFormat differ"
10708        );
10709        assert!(
10710            matches!(
10711                img1.format().unwrap(),
10712                PixelFormat::Rgb | PixelFormat::Rgba | PixelFormat::Grey | PixelFormat::PlanarRgb
10713            ),
10714            "format must be Rgb or Rgba for comparison"
10715        );
10716
10717        // A pitch-padded destination -- the D3D11 staging texture behind a
10718        // Windows `TensorMemory::DmaBuf` tensor pads an RGBA row to 128 bytes
10719        // -- spaces its rows at `effective_row_stride()`, so reading the head
10720        // of the mapping as a tight image would read padding as pixels.
10721        // `copy_to_flat` is the tensor's own compaction and degenerates to one
10722        // memcpy when the tensor is tight.
10723        fn flat(img: &TensorDyn) -> Vec<u8> {
10724            let t = img.as_typed::<u8>().expect("u8 image");
10725            let mut out = vec![0u8; t.shape().iter().product::<usize>()];
10726            t.copy_to_flat(&mut out).expect("compact the padded rows");
10727            out
10728        }
10729
10730        let image1 = match img1.format().unwrap() {
10731            PixelFormat::Rgb => image::RgbImage::from_vec(
10732                img1.width().unwrap() as u32,
10733                img1.height().unwrap() as u32,
10734                flat(img1),
10735            )
10736            .unwrap(),
10737            PixelFormat::Rgba => image::RgbaImage::from_vec(
10738                img1.width().unwrap() as u32,
10739                img1.height().unwrap() as u32,
10740                flat(img1),
10741            )
10742            .unwrap()
10743            .convert(),
10744            PixelFormat::Grey => image::GrayImage::from_vec(
10745                img1.width().unwrap() as u32,
10746                img1.height().unwrap() as u32,
10747                flat(img1),
10748            )
10749            .unwrap()
10750            .convert(),
10751            PixelFormat::PlanarRgb => image::GrayImage::from_vec(
10752                img1.width().unwrap() as u32,
10753                (img1.height().unwrap() * 3) as u32,
10754                flat(img1),
10755            )
10756            .unwrap()
10757            .convert(),
10758            _ => return,
10759        };
10760
10761        let image2 = match img2.format().unwrap() {
10762            PixelFormat::Rgb => image::RgbImage::from_vec(
10763                img2.width().unwrap() as u32,
10764                img2.height().unwrap() as u32,
10765                flat(img2),
10766            )
10767            .unwrap(),
10768            PixelFormat::Rgba => image::RgbaImage::from_vec(
10769                img2.width().unwrap() as u32,
10770                img2.height().unwrap() as u32,
10771                flat(img2),
10772            )
10773            .unwrap()
10774            .convert(),
10775            PixelFormat::Grey => image::GrayImage::from_vec(
10776                img2.width().unwrap() as u32,
10777                img2.height().unwrap() as u32,
10778                flat(img2),
10779            )
10780            .unwrap()
10781            .convert(),
10782            PixelFormat::PlanarRgb => image::GrayImage::from_vec(
10783                img2.width().unwrap() as u32,
10784                (img2.height().unwrap() * 3) as u32,
10785                flat(img2),
10786            )
10787            .unwrap()
10788            .convert(),
10789            _ => return,
10790        };
10791
10792        let similarity = image_compare::rgb_similarity_structure(
10793            &image_compare::Algorithm::RootMeanSquared,
10794            &image1,
10795            &image2,
10796        )
10797        .expect("Image Comparison failed");
10798        if similarity.score < threshold {
10799            // image1.save(format!("{name}_1.png"));
10800            // image2.save(format!("{name}_2.png"));
10801            similarity
10802                .image
10803                .to_color_map()
10804                .save(format!("{name}.png"))
10805                .unwrap();
10806            panic!(
10807                "{name}: converted image and target image have similarity score too low: {} < {}",
10808                similarity.score, threshold
10809            )
10810        }
10811    }
10812
10813    fn compare_images_convert_to_rgb(
10814        img1: &TensorDyn,
10815        img2: &TensorDyn,
10816        threshold: f64,
10817        name: &str,
10818    ) {
10819        assert_eq!(img1.height(), img2.height(), "Heights differ");
10820        assert_eq!(img1.width(), img2.width(), "Widths differ");
10821
10822        let mut img_rgb1 = TensorDyn::image(
10823            img1.width().unwrap(),
10824            img1.height().unwrap(),
10825            PixelFormat::Rgb,
10826            DType::U8,
10827            Some(TensorMemory::Mem),
10828            edgefirst_tensor::CpuAccess::ReadWrite,
10829        )
10830        .unwrap();
10831        let mut img_rgb2 = TensorDyn::image(
10832            img1.width().unwrap(),
10833            img1.height().unwrap(),
10834            PixelFormat::Rgb,
10835            DType::U8,
10836            Some(TensorMemory::Mem),
10837            edgefirst_tensor::CpuAccess::ReadWrite,
10838        )
10839        .unwrap();
10840        let mut __cv = CPUProcessor::default();
10841        let r1 = __cv.convert(
10842            img1,
10843            &mut img_rgb1,
10844            crate::Rotation::None,
10845            crate::Flip::None,
10846            crate::Crop::default(),
10847        );
10848        let r2 = __cv.convert(
10849            img2,
10850            &mut img_rgb2,
10851            crate::Rotation::None,
10852            crate::Flip::None,
10853            crate::Crop::default(),
10854        );
10855        if r1.is_err() || r2.is_err() {
10856            // Fallback: compare raw bytes as greyscale strip
10857            let w = img1.width().unwrap() as u32;
10858            let data1 = img1.as_typed::<u8>().unwrap().map().unwrap().to_vec();
10859            let data2 = img2.as_typed::<u8>().unwrap().map().unwrap().to_vec();
10860            let h1 = (data1.len() as u32) / w;
10861            let h2 = (data2.len() as u32) / w;
10862            let g1 = image::GrayImage::from_vec(w, h1, data1).unwrap();
10863            let g2 = image::GrayImage::from_vec(w, h2, data2).unwrap();
10864            let similarity = image_compare::gray_similarity_structure(
10865                &image_compare::Algorithm::RootMeanSquared,
10866                &g1,
10867                &g2,
10868            )
10869            .expect("Image Comparison failed");
10870            if similarity.score < threshold {
10871                panic!(
10872                    "{name}: converted image and target image have similarity score too low: {} < {}",
10873                    similarity.score, threshold
10874                )
10875            }
10876            return;
10877        }
10878
10879        let image1 = image::RgbImage::from_vec(
10880            img_rgb1.width().unwrap() as u32,
10881            img_rgb1.height().unwrap() as u32,
10882            img_rgb1.as_typed::<u8>().unwrap().map().unwrap().to_vec(),
10883        )
10884        .unwrap();
10885
10886        let image2 = image::RgbImage::from_vec(
10887            img_rgb2.width().unwrap() as u32,
10888            img_rgb2.height().unwrap() as u32,
10889            img_rgb2.as_typed::<u8>().unwrap().map().unwrap().to_vec(),
10890        )
10891        .unwrap();
10892
10893        let similarity = image_compare::rgb_similarity_structure(
10894            &image_compare::Algorithm::RootMeanSquared,
10895            &image1,
10896            &image2,
10897        )
10898        .expect("Image Comparison failed");
10899        if similarity.score < threshold {
10900            // image1.save(format!("{name}_1.png"));
10901            // image2.save(format!("{name}_2.png"));
10902            similarity
10903                .image
10904                .to_color_map()
10905                .save(format!("{name}.png"))
10906                .unwrap();
10907            panic!(
10908                "{name}: converted image and target image have similarity score too low: {} < {}",
10909                similarity.score, threshold
10910            )
10911        }
10912    }
10913
10914    // =========================================================================
10915    // PixelFormat::Nv12 Format Tests
10916    // =========================================================================
10917
10918    #[test]
10919    fn test_nv12_image_creation() {
10920        let width = 640;
10921        let height = 480;
10922        let img = TensorDyn::image(
10923            width,
10924            height,
10925            PixelFormat::Nv12,
10926            DType::U8,
10927            None,
10928            edgefirst_tensor::CpuAccess::ReadWrite,
10929        )
10930        .unwrap();
10931
10932        assert_eq!(img.width(), Some(width));
10933        assert_eq!(img.height(), Some(height));
10934        assert_eq!(img.format().unwrap(), PixelFormat::Nv12);
10935        // PixelFormat::Nv12 uses shape [H*3/2, W] to store Y plane + UV plane
10936        assert_eq!(
10937            img.as_typed::<u8>().unwrap().shape(),
10938            &[height * 3 / 2, width]
10939        );
10940    }
10941
10942    #[test]
10943    fn test_nv12_channels() {
10944        let img = TensorDyn::image(
10945            640,
10946            480,
10947            PixelFormat::Nv12,
10948            DType::U8,
10949            None,
10950            edgefirst_tensor::CpuAccess::ReadWrite,
10951        )
10952        .unwrap();
10953        // PixelFormat::Nv12.channels() returns 1 (luma plane)
10954        assert_eq!(img.format().unwrap().channels(), 1);
10955    }
10956
10957    // =========================================================================
10958    // Tensor Format Metadata Tests
10959    // =========================================================================
10960
10961    #[test]
10962    fn test_tensor_set_format_planar() {
10963        let mut tensor = Tensor::<u8>::new(&[3, 480, 640], None, None).unwrap();
10964        tensor.set_format(PixelFormat::PlanarRgb).unwrap();
10965        assert_eq!(tensor.format(), Some(PixelFormat::PlanarRgb));
10966        assert_eq!(tensor.width(), Some(640));
10967        assert_eq!(tensor.height(), Some(480));
10968    }
10969
10970    #[test]
10971    fn test_tensor_set_format_interleaved() {
10972        let mut tensor = Tensor::<u8>::new(&[480, 640, 4], None, None).unwrap();
10973        tensor.set_format(PixelFormat::Rgba).unwrap();
10974        assert_eq!(tensor.format(), Some(PixelFormat::Rgba));
10975        assert_eq!(tensor.width(), Some(640));
10976        assert_eq!(tensor.height(), Some(480));
10977    }
10978
10979    #[test]
10980    fn test_tensordyn_image_rgb() {
10981        let img = TensorDyn::image(
10982            640,
10983            480,
10984            PixelFormat::Rgb,
10985            DType::U8,
10986            None,
10987            edgefirst_tensor::CpuAccess::ReadWrite,
10988        )
10989        .unwrap();
10990        assert_eq!(img.width(), Some(640));
10991        assert_eq!(img.height(), Some(480));
10992        assert_eq!(img.format(), Some(PixelFormat::Rgb));
10993    }
10994
10995    #[test]
10996    fn test_tensordyn_image_planar_rgb() {
10997        let img = TensorDyn::image(
10998            640,
10999            480,
11000            PixelFormat::PlanarRgb,
11001            DType::U8,
11002            None,
11003            edgefirst_tensor::CpuAccess::ReadWrite,
11004        )
11005        .unwrap();
11006        assert_eq!(img.width(), Some(640));
11007        assert_eq!(img.height(), Some(480));
11008        assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
11009    }
11010
11011    #[test]
11012    fn test_rgb_int8_format() {
11013        // Int8 variant: same PixelFormat::Rgb but with DType::I8
11014        let img = TensorDyn::image(
11015            1280,
11016            720,
11017            PixelFormat::Rgb,
11018            DType::I8,
11019            Some(TensorMemory::Mem),
11020            edgefirst_tensor::CpuAccess::ReadWrite,
11021        )
11022        .unwrap();
11023        assert_eq!(img.width(), Some(1280));
11024        assert_eq!(img.height(), Some(720));
11025        assert_eq!(img.format(), Some(PixelFormat::Rgb));
11026        assert_eq!(img.dtype(), DType::I8);
11027    }
11028
11029    #[test]
11030    fn test_planar_rgb_int8_format() {
11031        let img = TensorDyn::image(
11032            1280,
11033            720,
11034            PixelFormat::PlanarRgb,
11035            DType::I8,
11036            Some(TensorMemory::Mem),
11037            edgefirst_tensor::CpuAccess::ReadWrite,
11038        )
11039        .unwrap();
11040        assert_eq!(img.width(), Some(1280));
11041        assert_eq!(img.height(), Some(720));
11042        assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
11043        assert_eq!(img.dtype(), DType::I8);
11044    }
11045
11046    #[test]
11047    fn test_rgb_from_tensor() {
11048        let mut tensor = Tensor::<u8>::new(&[720, 1280, 3], None, None).unwrap();
11049        tensor.set_format(PixelFormat::Rgb).unwrap();
11050        let img = TensorDyn::from(tensor);
11051        assert_eq!(img.width(), Some(1280));
11052        assert_eq!(img.height(), Some(720));
11053        assert_eq!(img.format(), Some(PixelFormat::Rgb));
11054    }
11055
11056    #[test]
11057    fn test_planar_rgb_from_tensor() {
11058        let mut tensor = Tensor::<u8>::new(&[3, 720, 1280], None, None).unwrap();
11059        tensor.set_format(PixelFormat::PlanarRgb).unwrap();
11060        let img = TensorDyn::from(tensor);
11061        assert_eq!(img.width(), Some(1280));
11062        assert_eq!(img.height(), Some(720));
11063        assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
11064    }
11065
11066    #[test]
11067    fn test_dtype_determines_int8() {
11068        // DType::I8 indicates int8 data
11069        let u8_img = TensorDyn::image(
11070            64,
11071            64,
11072            PixelFormat::Rgb,
11073            DType::U8,
11074            None,
11075            edgefirst_tensor::CpuAccess::ReadWrite,
11076        )
11077        .unwrap();
11078        let i8_img = TensorDyn::image(
11079            64,
11080            64,
11081            PixelFormat::Rgb,
11082            DType::I8,
11083            None,
11084            edgefirst_tensor::CpuAccess::ReadWrite,
11085        )
11086        .unwrap();
11087        assert_eq!(u8_img.dtype(), DType::U8);
11088        assert_eq!(i8_img.dtype(), DType::I8);
11089    }
11090
11091    #[test]
11092    fn test_pixel_layout_packed_vs_planar() {
11093        // Packed vs planar layout classification
11094        assert_eq!(PixelFormat::Rgb.layout(), PixelLayout::Packed);
11095        assert_eq!(PixelFormat::Rgba.layout(), PixelLayout::Packed);
11096        assert_eq!(PixelFormat::PlanarRgb.layout(), PixelLayout::Planar);
11097        assert_eq!(PixelFormat::Nv12.layout(), PixelLayout::SemiPlanar);
11098    }
11099
11100    /// Integration test that exercises the PBO-to-PBO convert path.
11101    /// A PBO cannot hand out a host address that outlives its map guard, and
11102    /// the error has to say why rather than reading as unfinished work.
11103    ///
11104    /// `glMapBufferRange`'s pointer is valid only until `glUnmapBuffer`, and
11105    /// this backend maps with `MAP_READ_BIT | MAP_WRITE_BIT` — there is no
11106    /// `GL_MAP_PERSISTENT_BIT` and no `glBufferStorage` in the tree. Pinning
11107    /// would mean holding the buffer mapped across GL work, which is the
11108    /// opposite of what a PBO is for. `EXT_buffer_storage` would change the
11109    /// answer, but that is a buffer-allocation feature, not wiring.
11110    #[cfg(target_os = "linux")]
11111    #[cfg(feature = "opengl")]
11112    #[test]
11113    fn pbo_reports_why_it_cannot_pin() {
11114        use edgefirst_tensor::{Error, TensorTrait};
11115
11116        let converter = ImageProcessor::new().unwrap();
11117        let is_pbo = converter
11118            .opengl
11119            .as_ref()
11120            .is_some_and(|gl| gl.transfer_backend() == opengl_headless::TransferBackend::Pbo);
11121        if !is_pbo {
11122            // Direct stderr, not eprintln!: libtest captures println!/eprintln!
11123            // and replays it only for FAILING tests, so a skip would print
11124            // "... ok" with its reason discarded and be indistinguishable from
11125            // having run. See TESTING.md.
11126            use std::io::Write;
11127            let _ = writeln!(
11128                &mut std::io::stderr(),
11129                "SKIPPED: pbo_reports_why_it_cannot_pin - transfer backend is not PBO"
11130            );
11131            return;
11132        }
11133
11134        let t = converter
11135            .create_image(
11136                64,
11137                64,
11138                PixelFormat::Rgba,
11139                DType::U8,
11140                None,
11141                edgefirst_tensor::CpuAccess::ReadWrite,
11142            )
11143            .unwrap();
11144        assert_eq!(t.as_typed::<u8>().unwrap().memory(), TensorMemory::Pbo);
11145        let t = t.as_typed::<u8>().unwrap();
11146
11147        match t.pin_host(edgefirst_tensor::CpuAccess::ReadWrite) {
11148            Err(Error::NotImplemented(m)) => {
11149                assert!(
11150                    m.contains("glMapBufferRange"),
11151                    "must name the constraint: {m}"
11152                );
11153                assert!(m.contains("map()"), "must point at the supported API: {m}");
11154            }
11155            other => panic!("expected an explained NotImplemented, got {other:?}"),
11156        }
11157        // Sync is unavailable for the same reason, and must not silently succeed.
11158        assert!(matches!(
11159            t.sync_for_cpu(edgefirst_tensor::CpuAccess::Read),
11160            Err(Error::NotImplemented(_))
11161        ));
11162
11163        // The supported path still works, which is what makes the refusal safe.
11164        let g = t.map().expect("map must work where pin cannot");
11165        assert!(!g.as_slice().is_empty());
11166    }
11167
11168    /// Uses ImageProcessor::create_image() to allocate PBO-backed tensors,
11169    /// then converts between them. Skipped when GL is unavailable or the
11170    /// backend is not PBO (e.g. DMA-buf systems).
11171    #[cfg(target_os = "linux")]
11172    #[cfg(feature = "opengl")]
11173    #[test]
11174    fn test_convert_pbo_to_pbo() {
11175        let mut converter = ImageProcessor::new().unwrap();
11176
11177        // Skip if GL is not available or backend is not PBO
11178        let is_pbo = converter
11179            .opengl
11180            .as_ref()
11181            .is_some_and(|gl| gl.transfer_backend() == opengl_headless::TransferBackend::Pbo);
11182        if !is_pbo {
11183            log::warn!("Skipping test_convert_pbo_to_pbo: backend is not PBO");
11184            return;
11185        }
11186
11187        let src_w = 640;
11188        let src_h = 480;
11189        let dst_w = 320;
11190        let dst_h = 240;
11191
11192        // Create PBO-backed source image
11193        let pbo_src = converter
11194            .create_image(
11195                src_w,
11196                src_h,
11197                PixelFormat::Rgba,
11198                DType::U8,
11199                None,
11200                edgefirst_tensor::CpuAccess::ReadWrite,
11201            )
11202            .unwrap();
11203        assert_eq!(
11204            pbo_src.as_typed::<u8>().unwrap().memory(),
11205            TensorMemory::Pbo,
11206            "create_image should produce a PBO tensor"
11207        );
11208
11209        // Fill source PBO with test pattern: load JPEG then convert Mem→PBO
11210        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
11211        let jpeg_src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
11212
11213        // Resize JPEG into a Mem temp of the right size, then copy into PBO
11214        let mem_src = TensorDyn::image(
11215            src_w,
11216            src_h,
11217            PixelFormat::Rgba,
11218            DType::U8,
11219            Some(TensorMemory::Mem),
11220            edgefirst_tensor::CpuAccess::ReadWrite,
11221        )
11222        .unwrap();
11223        let (result, _jpeg_src, mem_src) = convert_img(
11224            &mut CPUProcessor::new(),
11225            jpeg_src,
11226            mem_src,
11227            Rotation::None,
11228            Flip::None,
11229            Crop::no_crop(),
11230        );
11231        result.unwrap();
11232
11233        // Copy pixel data into the PBO source by mapping it
11234        {
11235            let src_data = mem_src.as_typed::<u8>().unwrap().map().unwrap();
11236            let mut pbo_map = pbo_src.as_typed::<u8>().unwrap().map().unwrap();
11237            pbo_map.copy_from_slice(&src_data);
11238        }
11239
11240        // Create PBO-backed destination image
11241        let pbo_dst = converter
11242            .create_image(
11243                dst_w,
11244                dst_h,
11245                PixelFormat::Rgba,
11246                DType::U8,
11247                None,
11248                edgefirst_tensor::CpuAccess::ReadWrite,
11249            )
11250            .unwrap();
11251        assert_eq!(
11252            pbo_dst.as_typed::<u8>().unwrap().memory(),
11253            TensorMemory::Pbo
11254        );
11255
11256        // Convert PBO→PBO (this exercises convert_pbo_to_pbo)
11257        let mut pbo_dst = pbo_dst;
11258        let result = converter.convert(
11259            &pbo_src,
11260            &mut pbo_dst,
11261            Rotation::None,
11262            Flip::None,
11263            Crop::no_crop(),
11264        );
11265        result.unwrap();
11266
11267        // Verify: compare with CPU-only conversion of the same input
11268        let cpu_dst = TensorDyn::image(
11269            dst_w,
11270            dst_h,
11271            PixelFormat::Rgba,
11272            DType::U8,
11273            Some(TensorMemory::Mem),
11274            edgefirst_tensor::CpuAccess::ReadWrite,
11275        )
11276        .unwrap();
11277        let (result, _mem_src, cpu_dst) = convert_img(
11278            &mut CPUProcessor::new(),
11279            mem_src,
11280            cpu_dst,
11281            Rotation::None,
11282            Flip::None,
11283            Crop::no_crop(),
11284        );
11285        result.unwrap();
11286
11287        let pbo_dst_img = {
11288            let mut __t = pbo_dst.into_u8().unwrap();
11289            __t.set_format(PixelFormat::Rgba).unwrap();
11290            TensorDyn::from(__t)
11291        };
11292        compare_images(&pbo_dst_img, &cpu_dst, 0.95, function!());
11293        log::info!("test_convert_pbo_to_pbo: PASS — PBO-to-PBO convert matches CPU reference");
11294    }
11295
11296    #[test]
11297    fn test_image_bgra() {
11298        let img = TensorDyn::image(
11299            640,
11300            480,
11301            PixelFormat::Bgra,
11302            DType::U8,
11303            Some(edgefirst_tensor::TensorMemory::Mem),
11304            edgefirst_tensor::CpuAccess::ReadWrite,
11305        )
11306        .unwrap();
11307        assert_eq!(img.width(), Some(640));
11308        assert_eq!(img.height(), Some(480));
11309        assert_eq!(img.format().unwrap().channels(), 4);
11310        assert_eq!(img.format().unwrap(), PixelFormat::Bgra);
11311    }
11312
11313    // ========================================================================
11314    // Tests for EDGEFIRST_FORCE_BACKEND env var
11315    // ========================================================================
11316
11317    #[test]
11318    fn test_force_backend_cpu() {
11319        let _lock = acquire_env_lock();
11320        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
11321        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
11322        let converter = ImageProcessor::new().unwrap();
11323        assert!(converter.cpu.is_some());
11324        assert_eq!(converter.forced_backend, Some(ForcedBackend::Cpu));
11325    }
11326
11327    #[test]
11328    fn test_force_backend_invalid() {
11329        let _lock = acquire_env_lock();
11330        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
11331        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "invalid") };
11332        let result = ImageProcessor::new();
11333        assert!(
11334            matches!(&result, Err(Error::ForcedBackendUnavailable(s)) if s.contains("unknown")),
11335            "invalid backend value should return ForcedBackendUnavailable error: {result:?}"
11336        );
11337    }
11338
11339    #[test]
11340    fn test_force_backend_unset() {
11341        let _lock = acquire_env_lock();
11342        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
11343        unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") };
11344        let converter = ImageProcessor::new().unwrap();
11345        assert!(converter.forced_backend.is_none());
11346    }
11347
11348    // ========================================================================
11349    // Tests for hybrid mask path error handling
11350    // ========================================================================
11351
11352    #[test]
11353    fn test_draw_proto_masks_no_cpu_returns_error() {
11354        // Serialize against all other env-var-mutating tests.
11355        let _lock = acquire_env_lock();
11356        let _guard = EnvGuard::snapshot(&[
11357            "EDGEFIRST_FORCE_BACKEND",
11358            "EDGEFIRST_DISABLE_GL",
11359            "EDGEFIRST_DISABLE_G2D",
11360            "EDGEFIRST_DISABLE_CPU",
11361        ]);
11362
11363        // Disable all backends so cpu.is_none() after construction.
11364        unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
11365        unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
11366        unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
11367
11368        let mut converter = ImageProcessor::new().unwrap();
11369        assert!(converter.cpu.is_none(), "CPU should be disabled");
11370
11371        let dst = TensorDyn::image(
11372            640,
11373            480,
11374            PixelFormat::Rgba,
11375            DType::U8,
11376            Some(TensorMemory::Mem),
11377            edgefirst_tensor::CpuAccess::ReadWrite,
11378        )
11379        .unwrap();
11380        let mut dst_dyn = dst;
11381        let det = [DetectBox {
11382            bbox: edgefirst_tensor::BoundingBox {
11383                xmin: 0.1,
11384                ymin: 0.1,
11385                xmax: 0.5,
11386                ymax: 0.5,
11387            },
11388            score: 0.9,
11389            label: 0,
11390        }];
11391        let proto_data = {
11392            use edgefirst_tensor::{Tensor, TensorDyn};
11393            let coeff_t = Tensor::<f32>::from_slice(&[0.5_f32; 4], &[1, 4]).unwrap();
11394            let protos_t =
11395                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
11396            ProtoData {
11397                mask_coefficients: TensorDyn::F32(coeff_t),
11398                protos: TensorDyn::F32(protos_t),
11399                layout: ProtoLayout::Nhwc,
11400            }
11401        };
11402        let result =
11403            converter.draw_proto_masks(&mut dst_dyn, &det, &proto_data, Default::default());
11404        assert!(
11405            matches!(&result, Err(Error::Internal(s)) if s.contains("CPU backend")),
11406            "draw_proto_masks without CPU should return Internal error: {result:?}"
11407        );
11408    }
11409
11410    #[test]
11411    fn test_draw_proto_masks_cpu_fallback_works() {
11412        // Force CPU-only backend to ensure the CPU fallback path executes.
11413        // Serialized under ENV_MUTEX so we don't race with disable-var tests.
11414        let _lock = acquire_env_lock();
11415        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
11416        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
11417        let mut converter = ImageProcessor::new().unwrap();
11418        assert!(converter.cpu.is_some());
11419
11420        let dst = TensorDyn::image(
11421            64,
11422            64,
11423            PixelFormat::Rgba,
11424            DType::U8,
11425            Some(TensorMemory::Mem),
11426            edgefirst_tensor::CpuAccess::ReadWrite,
11427        )
11428        .unwrap();
11429        let mut dst_dyn = dst;
11430        let det = [DetectBox {
11431            bbox: edgefirst_tensor::BoundingBox {
11432                xmin: 0.1,
11433                ymin: 0.1,
11434                xmax: 0.5,
11435                ymax: 0.5,
11436            },
11437            score: 0.9,
11438            label: 0,
11439        }];
11440        let proto_data = {
11441            use edgefirst_tensor::{Tensor, TensorDyn};
11442            let coeff_t = Tensor::<f32>::from_slice(&[0.5_f32; 4], &[1, 4]).unwrap();
11443            let protos_t =
11444                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
11445            ProtoData {
11446                mask_coefficients: TensorDyn::F32(coeff_t),
11447                protos: TensorDyn::F32(protos_t),
11448                layout: ProtoLayout::Nhwc,
11449            }
11450        };
11451        let result =
11452            converter.draw_proto_masks(&mut dst_dyn, &det, &proto_data, Default::default());
11453        assert!(result.is_ok(), "CPU fallback path should work: {result:?}");
11454    }
11455
11456    // ============================================================
11457    // draw_decoded_masks / draw_proto_masks — 4-scenario pixel-
11458    // verified tests. Exercises each backend against the full
11459    // output-contract matrix:
11460    //
11461    //   | detections | background | expected dst             |
11462    //   |------------|------------|--------------------------|
11463    //   | empty      | none       | fully cleared (0x00)     |
11464    //   | empty      | set        | fully equal to bg        |
11465    //   | set        | none       | cleared outside box +    |
11466    //   |            |            | mask-coloured inside     |
11467    //   | set        | set        | bg outside box + mask    |
11468    //   |            |            | blended inside           |
11469    //
11470    // Every test pre-fills dst with a non-zero "dirty" pattern so
11471    // that any silent `return Ok(())` leaks the pattern into the
11472    // asserted output and fails loudly.
11473    // ============================================================
11474
11475    // =========================================================================
11476    // Env-var serialisation helpers
11477    //
11478    // ALL tests that mutate any EDGEFIRST_* backend env var must hold
11479    // ENV_MUTEX for their full duration.  This single mutex serialises
11480    // test_disable_env_var, test_draw_proto_masks_no_cpu_returns_error,
11481    // test_force_backend_*, with_force_backend, and with_env — preventing
11482    // any two of them from racing in a parallel `cargo test` run.
11483    // =========================================================================
11484
11485    /// Acquire the process-wide env-var mutex.  Returns a guard that must be
11486    /// kept alive for the entire duration of the test body.
11487    fn acquire_env_lock() -> std::sync::MutexGuard<'static, ()> {
11488        use std::sync::{Mutex, OnceLock};
11489        static ENV_MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
11490        ENV_MUTEX
11491            .get_or_init(|| Mutex::new(()))
11492            .lock()
11493            .unwrap_or_else(|e| e.into_inner())
11494    }
11495
11496    /// RAII guard that snapshots a set of env vars on construction and
11497    /// restores them on `Drop`, even if the test panics.
11498    struct EnvGuard {
11499        vars: Vec<(&'static str, Option<String>)>,
11500    }
11501
11502    impl EnvGuard {
11503        /// Snapshot the current values of `names`.  Call this while holding
11504        /// the env lock (the lock is not taken here — that is the caller's
11505        /// responsibility so the lock scope can be wider than the guard).
11506        fn snapshot(names: &[&'static str]) -> Self {
11507            Self {
11508                vars: names.iter().map(|&k| (k, std::env::var(k).ok())).collect(),
11509            }
11510        }
11511    }
11512
11513    impl Drop for EnvGuard {
11514        fn drop(&mut self) {
11515            for (k, v) in &self.vars {
11516                match v {
11517                    Some(s) => unsafe { std::env::set_var(k, s) },
11518                    None => unsafe { std::env::remove_var(k) },
11519                }
11520            }
11521        }
11522    }
11523
11524    /// Run `body` with `EDGEFIRST_FORCE_BACKEND` temporarily set (or
11525    /// removed), restoring the prior value afterward. Tests are env-
11526    /// serialized via the process-wide `ENV_MUTEX`.
11527    fn with_force_backend<R>(value: Option<&str>, body: impl FnOnce() -> R) -> R {
11528        let _lock = acquire_env_lock();
11529        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
11530        match value {
11531            Some(v) => unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", v) },
11532            None => unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") },
11533        }
11534        body()
11535    }
11536
11537    /// Allocate an RGBA image tensor and pre-fill every byte with a
11538    /// distinctive non-zero pattern. Any test that relies on the old
11539    /// "dst is already cleared" assumption will see this pattern leak
11540    /// through to the output and fail.
11541    fn make_dirty_dst(w: usize, h: usize, mem: Option<TensorMemory>) -> TensorDyn {
11542        let dst = TensorDyn::image(
11543            w,
11544            h,
11545            PixelFormat::Rgba,
11546            DType::U8,
11547            mem,
11548            edgefirst_tensor::CpuAccess::ReadWrite,
11549        )
11550        .unwrap();
11551        {
11552            use edgefirst_tensor::TensorMapTrait;
11553            let u8t = dst.as_typed::<u8>().unwrap();
11554            let mut map = u8t.map().unwrap();
11555            for (i, b) in map.as_mut_slice().iter_mut().enumerate() {
11556                *b = 0xA0u8.wrapping_add((i as u8) & 0x3F);
11557            }
11558        }
11559        dst
11560    }
11561
11562    /// Allocate an RGBA background filled with a constant colour.
11563    fn make_bg(w: usize, h: usize, mem: Option<TensorMemory>, rgba: [u8; 4]) -> TensorDyn {
11564        let bg = TensorDyn::image(
11565            w,
11566            h,
11567            PixelFormat::Rgba,
11568            DType::U8,
11569            mem,
11570            edgefirst_tensor::CpuAccess::ReadWrite,
11571        )
11572        .unwrap();
11573        {
11574            use edgefirst_tensor::TensorMapTrait;
11575            let u8t = bg.as_typed::<u8>().unwrap();
11576            let mut map = u8t.map().unwrap();
11577            for chunk in map.as_mut_slice().as_chunks_mut::<4>().0 {
11578                chunk.copy_from_slice(&rgba);
11579            }
11580        }
11581        bg
11582    }
11583
11584    fn pixel_at(dst: &TensorDyn, x: usize, y: usize) -> [u8; 4] {
11585        use edgefirst_tensor::TensorMapTrait;
11586        let w = dst.width().unwrap();
11587        let off = (y * w + x) * 4;
11588        let u8t = dst.as_typed::<u8>().unwrap();
11589        let map = u8t.map().unwrap();
11590        let s = map.as_slice();
11591        [s[off], s[off + 1], s[off + 2], s[off + 3]]
11592    }
11593
11594    fn assert_every_pixel_eq(dst: &TensorDyn, expected: [u8; 4], case: &str) {
11595        use edgefirst_tensor::TensorMapTrait;
11596        let u8t = dst.as_typed::<u8>().unwrap();
11597        let map = u8t.map().unwrap();
11598        for (i, chunk) in map.as_slice().as_chunks::<4>().0.iter().enumerate() {
11599            assert_eq!(
11600                chunk, &expected,
11601                "{case}: pixel idx {i} = {chunk:?}, expected {expected:?}"
11602            );
11603        }
11604    }
11605
11606    /// Scenario 1: empty detections, empty segmentation, no background
11607    /// → dst must be fully cleared to 0x00000000.
11608    fn scenario_empty_no_bg(processor: &mut ImageProcessor, case: &str) {
11609        let mut dst = make_dirty_dst(64, 64, None);
11610        processor
11611            .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::default())
11612            .unwrap_or_else(|e| panic!("{case}/decoded_masks empty+no-bg failed: {e:?}"));
11613        assert_every_pixel_eq(&dst, [0, 0, 0, 0], &format!("{case}/decoded"));
11614
11615        let mut dst = make_dirty_dst(64, 64, None);
11616        let proto = {
11617            use edgefirst_tensor::{Tensor, TensorDyn};
11618            // Placeholder (no detections); shape [1, 4] to keep the tensor well-formed.
11619            let coeff_t = Tensor::<f32>::from_slice(&[0.0_f32; 4], &[1, 4]).unwrap();
11620            let protos_t =
11621                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
11622            ProtoData {
11623                mask_coefficients: TensorDyn::F32(coeff_t),
11624                protos: TensorDyn::F32(protos_t),
11625                layout: ProtoLayout::Nhwc,
11626            }
11627        };
11628        processor
11629            .draw_proto_masks(&mut dst, &[], &proto, MaskOverlay::default())
11630            .unwrap_or_else(|e| panic!("{case}/proto_masks empty+no-bg failed: {e:?}"));
11631        assert_every_pixel_eq(&dst, [0, 0, 0, 0], &format!("{case}/proto"));
11632    }
11633
11634    /// Scenario 2: empty detections, empty segmentation, background set
11635    /// → dst must be fully equal to bg.
11636    fn scenario_empty_with_bg(processor: &mut ImageProcessor, case: &str) {
11637        let bg_color = [42, 99, 200, 255];
11638        let bg = make_bg(64, 64, None, bg_color);
11639        let overlay = MaskOverlay::new().with_background(&bg);
11640
11641        let mut dst = make_dirty_dst(64, 64, None);
11642        processor
11643            .draw_decoded_masks(&mut dst, &[], &[], overlay)
11644            .unwrap_or_else(|e| panic!("{case}/decoded_masks empty+bg failed: {e:?}"));
11645        assert_every_pixel_eq(&dst, bg_color, &format!("{case}/decoded bg blit"));
11646
11647        let mut dst = make_dirty_dst(64, 64, None);
11648        let proto = {
11649            use edgefirst_tensor::{Tensor, TensorDyn};
11650            // Placeholder (no detections); shape [1, 4] to keep the tensor well-formed.
11651            let coeff_t = Tensor::<f32>::from_slice(&[0.0_f32; 4], &[1, 4]).unwrap();
11652            let protos_t =
11653                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
11654            ProtoData {
11655                mask_coefficients: TensorDyn::F32(coeff_t),
11656                protos: TensorDyn::F32(protos_t),
11657                layout: ProtoLayout::Nhwc,
11658            }
11659        };
11660        processor
11661            .draw_proto_masks(&mut dst, &[], &proto, overlay)
11662            .unwrap_or_else(|e| panic!("{case}/proto_masks empty+bg failed: {e:?}"));
11663        assert_every_pixel_eq(&dst, bg_color, &format!("{case}/proto bg blit"));
11664    }
11665
11666    /// Scenario 3: one detection with a fully-opaque segmentation fill,
11667    /// no background → outside the box dst must be 0x00, inside it must
11668    /// be a non-zero mask colour (the render_segmentation output).
11669    fn scenario_detect_no_bg(processor: &mut ImageProcessor, case: &str) {
11670        use edgefirst_tensor::Segmentation;
11671        use ndarray::Array3;
11672        processor
11673            .set_class_colors(&[[200, 80, 40, 255]])
11674            .expect("set_class_colors");
11675
11676        let detect = DetectBox {
11677            bbox: [0.25, 0.25, 0.75, 0.75].into(),
11678            score: 0.99,
11679            label: 0,
11680        };
11681        let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
11682        let seg = Segmentation {
11683            segmentation: edgefirst_tensor::Tensor::from_arrayview3(seg_arr.view())
11684                .unwrap()
11685                .into(),
11686            xmin: 0.25,
11687            ymin: 0.25,
11688            xmax: 0.75,
11689            ymax: 0.75,
11690        };
11691
11692        let mut dst = make_dirty_dst(64, 64, None);
11693        processor
11694            .draw_decoded_masks(&mut dst, &[detect], &[seg], MaskOverlay::default())
11695            .unwrap_or_else(|e| panic!("{case}/decoded_masks detect+no-bg failed: {e:?}"));
11696
11697        // Outside the bbox (corner): must be cleared black.
11698        let corner = pixel_at(&dst, 2, 2);
11699        assert_eq!(
11700            corner,
11701            [0, 0, 0, 0],
11702            "{case}/decoded: corner (2,2) leaked dirty pattern: {corner:?}"
11703        );
11704        // Inside the bbox (center): the mask colour must be visible.
11705        // Any non-zero pixel is acceptable — exact rendering varies
11706        // between backends (GL smoothstep, CPU nearest).
11707        let center = pixel_at(&dst, 32, 32);
11708        assert!(
11709            center != [0, 0, 0, 0],
11710            "{case}/decoded: center (32,32) was not coloured: {center:?}"
11711        );
11712    }
11713
11714    /// Scenario 4: detection + background. Outside the box must match
11715    /// bg; inside the box must NOT match bg (mask blended on top).
11716    fn scenario_detect_with_bg(processor: &mut ImageProcessor, case: &str) {
11717        use edgefirst_tensor::Segmentation;
11718        use ndarray::Array3;
11719        processor
11720            .set_class_colors(&[[200, 80, 40, 255]])
11721            .expect("set_class_colors");
11722        let bg_color = [10, 20, 30, 255];
11723        let bg = make_bg(64, 64, None, bg_color);
11724
11725        let detect = DetectBox {
11726            bbox: [0.25, 0.25, 0.75, 0.75].into(),
11727            score: 0.99,
11728            label: 0,
11729        };
11730        let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
11731        let seg = Segmentation {
11732            segmentation: edgefirst_tensor::Tensor::from_arrayview3(seg_arr.view())
11733                .unwrap()
11734                .into(),
11735            xmin: 0.25,
11736            ymin: 0.25,
11737            xmax: 0.75,
11738            ymax: 0.75,
11739        };
11740
11741        let overlay = MaskOverlay::new().with_background(&bg);
11742        let mut dst = make_dirty_dst(64, 64, None);
11743        processor
11744            .draw_decoded_masks(&mut dst, &[detect], &[seg], overlay)
11745            .unwrap_or_else(|e| panic!("{case}/decoded_masks detect+bg failed: {e:?}"));
11746
11747        // Outside the bbox (corner): bg colour.
11748        let corner = pixel_at(&dst, 2, 2);
11749        assert_eq!(
11750            corner, bg_color,
11751            "{case}/decoded: corner (2,2) should show bg {bg_color:?} got {corner:?}"
11752        );
11753        // Inside the bbox (center): mask blended on bg, must differ from
11754        // pure bg (alpha-blend with mask colour produces a distinct shade).
11755        let center = pixel_at(&dst, 32, 32);
11756        assert!(
11757            center != bg_color,
11758            "{case}/decoded: center (32,32) should differ from bg {bg_color:?}, got {center:?}"
11759        );
11760    }
11761
11762    /// Run all 4 scenarios against the processor. Skip gracefully if
11763    /// construction fails (backend unavailable on this host).
11764    fn run_all_scenarios(
11765        force_backend: Option<&'static str>,
11766        case: &'static str,
11767        require_dma_for_bg: bool,
11768    ) {
11769        if require_dma_for_bg && !edgefirst_tensor::is_dma_available() {
11770            eprintln!("SKIPPED: {case} — DMA not available on this host");
11771            return;
11772        }
11773        let processor_result = with_force_backend(force_backend, ImageProcessor::new);
11774        let mut processor = match processor_result {
11775            Ok(p) => p,
11776            Err(e) => {
11777                eprintln!("SKIPPED: {case} — backend init failed: {e:?}");
11778                return;
11779            }
11780        };
11781        scenario_empty_no_bg(&mut processor, case);
11782        scenario_empty_with_bg(&mut processor, case);
11783        scenario_detect_no_bg(&mut processor, case);
11784        scenario_detect_with_bg(&mut processor, case);
11785    }
11786
11787    #[test]
11788    fn test_draw_masks_4_scenarios_cpu() {
11789        run_all_scenarios(Some("cpu"), "cpu", false);
11790    }
11791
11792    #[test]
11793    fn test_draw_masks_4_scenarios_auto() {
11794        run_all_scenarios(None, "auto", false);
11795    }
11796
11797    #[cfg(any(
11798        target_os = "linux",
11799        target_os = "macos",
11800        target_os = "ios",
11801        target_os = "android",
11802        target_os = "windows"
11803    ))]
11804    #[cfg(feature = "opengl")]
11805    #[test]
11806    fn test_draw_masks_4_scenarios_opengl() {
11807        run_all_scenarios(Some("opengl"), "opengl", false);
11808    }
11809
11810    /// G2D forced backend: exercises the zero-detection empty-frame
11811    /// paths via `g2d_clear` and `g2d_blit`. Scenarios 3 and 4 (with
11812    /// detections) expect `NotImplemented` since G2D has no rasterizer
11813    /// for boxes / masks.
11814    #[cfg(target_os = "linux")]
11815    #[test]
11816    fn test_draw_masks_zero_detection_g2d_forced() {
11817        if !edgefirst_tensor::is_dma_available() {
11818            eprintln!("SKIPPED: g2d forced — DMA not available on this host");
11819            return;
11820        }
11821        let processor_result = with_force_backend(Some("g2d"), ImageProcessor::new);
11822        let mut processor = match processor_result {
11823            Ok(p) => p,
11824            Err(e) => {
11825                eprintln!("SKIPPED: g2d forced — init failed: {e:?}");
11826                return;
11827            }
11828        };
11829
11830        // Case 1: empty + no bg. G2D requires DMA-backed dst.
11831        let mut dst = TensorDyn::image(
11832            64,
11833            64,
11834            PixelFormat::Rgba,
11835            DType::U8,
11836            Some(TensorMemory::DmaBuf),
11837            edgefirst_tensor::CpuAccess::ReadWrite,
11838        )
11839        .unwrap();
11840        {
11841            use edgefirst_tensor::TensorMapTrait;
11842            let u8t = dst.as_typed_mut::<u8>().unwrap();
11843            let mut map = u8t.map().unwrap();
11844            map.as_mut_slice().fill(0xBB);
11845        }
11846        processor
11847            .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::default())
11848            .expect("g2d empty+no-bg");
11849        assert_every_pixel_eq(&dst, [0, 0, 0, 0], "g2d/case1 cleared");
11850
11851        // Case 2: empty + bg. Both surfaces DMA-backed for g2d_blit.
11852        let bg_color = [7, 11, 13, 255];
11853        let bg = {
11854            let t = TensorDyn::image(
11855                64,
11856                64,
11857                PixelFormat::Rgba,
11858                DType::U8,
11859                Some(TensorMemory::DmaBuf),
11860                edgefirst_tensor::CpuAccess::ReadWrite,
11861            )
11862            .unwrap();
11863            {
11864                use edgefirst_tensor::TensorMapTrait;
11865                let u8t = t.as_typed::<u8>().unwrap();
11866                let mut map = u8t.map().unwrap();
11867                for chunk in map.as_mut_slice().as_chunks_mut::<4>().0 {
11868                    chunk.copy_from_slice(&bg_color);
11869                }
11870            }
11871            t
11872        };
11873        let mut dst = TensorDyn::image(
11874            64,
11875            64,
11876            PixelFormat::Rgba,
11877            DType::U8,
11878            Some(TensorMemory::DmaBuf),
11879            edgefirst_tensor::CpuAccess::ReadWrite,
11880        )
11881        .unwrap();
11882        {
11883            use edgefirst_tensor::TensorMapTrait;
11884            let u8t = dst.as_typed_mut::<u8>().unwrap();
11885            let mut map = u8t.map().unwrap();
11886            map.as_mut_slice().fill(0x55);
11887        }
11888        processor
11889            .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::new().with_background(&bg))
11890            .expect("g2d empty+bg");
11891        assert_every_pixel_eq(&dst, bg_color, "g2d/case2 bg blit");
11892
11893        // Case 3 and 4: detect present — must return NotImplemented.
11894        let detect = DetectBox {
11895            bbox: [0.25, 0.25, 0.75, 0.75].into(),
11896            score: 0.9,
11897            label: 0,
11898        };
11899        let mut dst = TensorDyn::image(
11900            64,
11901            64,
11902            PixelFormat::Rgba,
11903            DType::U8,
11904            Some(TensorMemory::DmaBuf),
11905            edgefirst_tensor::CpuAccess::ReadWrite,
11906        )
11907        .unwrap();
11908        let err = processor
11909            .draw_decoded_masks(&mut dst, &[detect], &[], MaskOverlay::default())
11910            .expect_err("g2d must reject detect-present draw_decoded_masks");
11911        assert!(
11912            matches!(err, Error::NotImplemented(_)),
11913            "g2d case3 wrong error: {err:?}"
11914        );
11915    }
11916
11917    #[test]
11918    fn test_set_format_then_cpu_convert() {
11919        // Force CPU backend; serialized under ENV_MUTEX to avoid racing with
11920        // test_force_backend_* and test_disable_env_var.
11921        let _lock = acquire_env_lock();
11922        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
11923        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
11924        let mut processor = ImageProcessor::new().unwrap();
11925
11926        // Load a source image
11927        let image = edgefirst_bench::testdata::read("zidane.jpg");
11928        let src = load_image_test_helper(&image, Some(PixelFormat::Rgba), None).unwrap();
11929
11930        // Create a raw tensor, then attach format — simulating the from_fd workflow
11931        let mut dst =
11932            TensorDyn::new(&[640, 640, 3], DType::U8, Some(TensorMemory::Mem), None).unwrap();
11933        dst.set_format(PixelFormat::Rgb).unwrap();
11934
11935        // Convert should work with the set_format-annotated tensor
11936        processor
11937            .convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
11938            .unwrap();
11939
11940        // Verify format survived conversion
11941        assert_eq!(dst.format(), Some(PixelFormat::Rgb));
11942        assert_eq!(dst.width(), Some(640));
11943        assert_eq!(dst.height(), Some(640));
11944    }
11945
11946    /// Verify that creating multiple ImageProcessors on the same thread and
11947    /// performing a resize on each does not deadlock or error.
11948    ///
11949    /// Uses automatic memory allocation (DMA → PBO → Mem fallback) so that
11950    /// hardware backends (OpenGL, G2D) are exercised on capable targets.
11951    #[test]
11952    fn test_multiple_image_processors_same_thread() {
11953        // Hold the env mutex so env-var-mutating tests can't corrupt the state
11954        // seen by ImageProcessor::new() calls during this test.
11955        let _lock = acquire_env_lock();
11956        let mut processors: Vec<ImageProcessor> = (0..4)
11957            .map(|_| ImageProcessor::new().expect("ImageProcessor::new() failed"))
11958            .collect();
11959
11960        for proc in &mut processors {
11961            let src = proc
11962                .create_image(
11963                    128,
11964                    128,
11965                    PixelFormat::Rgb,
11966                    DType::U8,
11967                    None,
11968                    edgefirst_tensor::CpuAccess::ReadWrite,
11969                )
11970                .expect("create src failed");
11971            let mut dst = proc
11972                .create_image(
11973                    64,
11974                    64,
11975                    PixelFormat::Rgb,
11976                    DType::U8,
11977                    None,
11978                    edgefirst_tensor::CpuAccess::ReadWrite,
11979                )
11980                .expect("create dst failed");
11981            proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
11982                .expect("convert failed");
11983            assert_eq!(dst.width(), Some(64));
11984            assert_eq!(dst.height(), Some(64));
11985        }
11986    }
11987
11988    /// Verify that creating ImageProcessors on separate threads and performing
11989    /// a resize on each does not deadlock or error.
11990    ///
11991    /// Uses automatic memory allocation (DMA → PBO → Mem fallback) so that
11992    /// hardware backends (OpenGL, G2D) are exercised on capable targets.
11993    /// A 60-second timeout prevents CI from hanging on deadlock regressions.
11994    #[test]
11995    fn test_multiple_image_processors_separate_threads() {
11996        use std::sync::mpsc;
11997        use std::time::Duration;
11998
11999        // The Vivante GC7000UL driver (i.MX 8M Plus) double-frees on concurrent
12000        // EGL context teardown — four processors spun up on four threads here
12001        // trips it and aborts the whole test binary (SIGABRT, not a catchable
12002        // panic). The bug is the driver's, not the HAL's; this test is kept so
12003        // it still exercises the multi-context path on every other GPU. The
12004        // on-target GitHub Actions imx8mp runner sets EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS
12005        // to skip just this case there, while the run stays red anywhere else
12006        // a regression appears. (Skip, not #[ignore]: the platform is decided
12007        // at runtime, not compile time.)
12008        if std::env::var_os("EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS").is_some() {
12009            eprintln!(
12010                "SKIPPED: test_multiple_image_processors_separate_threads — known Vivante \
12011                 GC7000UL concurrent-EGL-teardown double-free \
12012                 (EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS set)"
12013            );
12014            return;
12015        }
12016
12017        const TIMEOUT: Duration = Duration::from_secs(60);
12018
12019        // Hold the env mutex so env-var-mutating tests can't corrupt ImageProcessor::new()
12020        // calls made inside the spawned threads during this test.
12021        let _lock = acquire_env_lock();
12022
12023        let (tx, rx) = mpsc::channel::<()>();
12024
12025        std::thread::spawn(move || {
12026            let handles: Vec<_> = (0..4)
12027                .map(|i| {
12028                    std::thread::spawn(move || {
12029                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
12030                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
12031                        });
12032                        let src = proc
12033                            .create_image(
12034                                128,
12035                                128,
12036                                PixelFormat::Rgb,
12037                                DType::U8,
12038                                None,
12039                                edgefirst_tensor::CpuAccess::ReadWrite,
12040                            )
12041                            .unwrap_or_else(|e| panic!("create src failed on thread {i}: {e}"));
12042                        let mut dst = proc
12043                            .create_image(
12044                                64,
12045                                64,
12046                                PixelFormat::Rgb,
12047                                DType::U8,
12048                                None,
12049                                edgefirst_tensor::CpuAccess::ReadWrite,
12050                            )
12051                            .unwrap_or_else(|e| panic!("create dst failed on thread {i}: {e}"));
12052                        proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
12053                            .unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
12054                        assert_eq!(dst.width(), Some(64));
12055                        assert_eq!(dst.height(), Some(64));
12056                    })
12057                })
12058                .collect();
12059
12060            for (i, h) in handles.into_iter().enumerate() {
12061                h.join()
12062                    .unwrap_or_else(|e| panic!("thread {i} panicked: {e:?}"));
12063            }
12064
12065            let _ = tx.send(());
12066        });
12067
12068        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
12069            panic!("test_multiple_image_processors_separate_threads timed out after {TIMEOUT:?}")
12070        });
12071    }
12072
12073    /// Verify that 4 fully-initialized ImageProcessors on separate threads can
12074    /// all operate concurrently without deadlocking each other.
12075    ///
12076    /// All processors are created first, then a barrier synchronizes them so
12077    /// they all start converting at the same instant — maximizing contention.
12078    /// A 60-second timeout prevents CI from hanging on deadlock regressions.
12079    #[test]
12080    fn test_image_processors_concurrent_operations() {
12081        use std::sync::{mpsc, Arc, Barrier};
12082        use std::time::Duration;
12083
12084        const N: usize = 4;
12085        const ROUNDS: usize = 10;
12086        const TIMEOUT: Duration = Duration::from_secs(60);
12087
12088        // Hold the env mutex so env-var-mutating tests can't corrupt ImageProcessor::new()
12089        // calls made inside the spawned threads during this test.
12090        let _lock = acquire_env_lock();
12091
12092        let (tx, rx) = mpsc::channel::<()>();
12093
12094        std::thread::spawn(move || {
12095            let barrier = Arc::new(Barrier::new(N));
12096
12097            let handles: Vec<_> = (0..N)
12098                .map(|i| {
12099                    let barrier = Arc::clone(&barrier);
12100                    std::thread::spawn(move || {
12101                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
12102                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
12103                        });
12104
12105                        // All threads wait here until every processor is initialized.
12106                        barrier.wait();
12107
12108                        // Now all 4 hammer the GPU concurrently.
12109                        for round in 0..ROUNDS {
12110                            let src = proc
12111                                .create_image(
12112                                    128,
12113                                    128,
12114                                    PixelFormat::Rgb,
12115                                    DType::U8,
12116                                    None,
12117                                    edgefirst_tensor::CpuAccess::ReadWrite,
12118                                )
12119                                .unwrap_or_else(|e| {
12120                                    panic!("create src failed on thread {i} round {round}: {e}")
12121                                });
12122                            let mut dst = proc
12123                                .create_image(
12124                                    64,
12125                                    64,
12126                                    PixelFormat::Rgb,
12127                                    DType::U8,
12128                                    None,
12129                                    edgefirst_tensor::CpuAccess::ReadWrite,
12130                                )
12131                                .unwrap_or_else(|e| {
12132                                    panic!("create dst failed on thread {i} round {round}: {e}")
12133                                });
12134                            proc.convert(
12135                                &src,
12136                                &mut dst,
12137                                Rotation::None,
12138                                Flip::None,
12139                                Crop::default(),
12140                            )
12141                            .unwrap_or_else(|e| {
12142                                panic!("convert failed on thread {i} round {round}: {e}")
12143                            });
12144                            assert_eq!(dst.width(), Some(64));
12145                            assert_eq!(dst.height(), Some(64));
12146                        }
12147                    })
12148                })
12149                .collect();
12150
12151            for (i, h) in handles.into_iter().enumerate() {
12152                h.join()
12153                    .unwrap_or_else(|e| panic!("thread {i} panicked: {e:?}"));
12154            }
12155
12156            let _ = tx.send(());
12157        });
12158
12159        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
12160            panic!("test_image_processors_concurrent_operations timed out after {TIMEOUT:?}")
12161        });
12162    }
12163
12164    /// THE parallel-processors demonstration test: 4 ImageProcessors on 4
12165    /// threads, each with its own GL context and worker, converting
12166    /// per-thread-DISTINCT synthetic inputs concurrently (barrier-released);
12167    /// every output must byte-match the thread's own pre-barrier sequential
12168    /// oracle from the same processor. On LifecycleOnly platforms (Mali,
12169    /// V3D, Tegra, llvmpipe, macOS) the converts genuinely overlap on the
12170    /// GPU; on Vivante they serialize via the Full policy and the test still
12171    /// must pass. Distinct inputs make any cross-processor state leakage
12172    /// (wrong texture, wrong context, clobbered upload) visible as a byte
12173    /// diff rather than a coincidental match — proven by a scratch
12174    /// cross-wire run (neighbor's input post-oracle) failing on every
12175    /// thread with ~53% of bytes diverged.
12176    ///
12177    /// Skipped under EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS like
12178    /// `test_multiple_image_processors_separate_threads`: the galcore driver
12179    /// can abort intermittently on concurrent multi-processor lifecycles
12180    /// regardless of locking (P0 spike: reproduces fully serialized).
12181    #[test]
12182    fn test_parallel_processors_unique_outputs() {
12183        use std::sync::{mpsc, Arc, Barrier};
12184        use std::time::Duration;
12185
12186        const N: usize = 4;
12187        const ROUNDS: usize = 25;
12188        const TIMEOUT: Duration = Duration::from_secs(60);
12189
12190        if std::env::var_os("EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS").is_some() {
12191            eprintln!(
12192                "SKIPPED: test_parallel_processors_unique_outputs — known Vivante \
12193                 GC7000UL concurrent-multi-processor driver abort \
12194                 (EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS set)"
12195            );
12196            return;
12197        }
12198
12199        let _lock = acquire_env_lock();
12200        let (tx, rx) = mpsc::channel::<()>();
12201
12202        std::thread::spawn(move || {
12203            let barrier = Arc::new(Barrier::new(N));
12204            let handles: Vec<_> = (0..N)
12205                .map(|i| {
12206                    let barrier = Arc::clone(&barrier);
12207                    std::thread::spawn(move || {
12208                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
12209                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
12210                        });
12211                        // CI-weight geometry (llvmpipe renders on the CPU).
12212                        let (w, h) = (640usize, 480usize);
12213                        let mem = if edgefirst_tensor::is_dma_available() {
12214                            Some(TensorMemory::DmaBuf)
12215                        } else {
12216                            Some(TensorMemory::Mem)
12217                        };
12218                        let src = proc
12219                            .create_image(
12220                                w,
12221                                h,
12222                                PixelFormat::Nv12,
12223                                DType::U8,
12224                                mem,
12225                                edgefirst_tensor::CpuAccess::ReadWrite,
12226                            )
12227                            .unwrap();
12228                        {
12229                            let t = src.as_typed::<u8>().unwrap();
12230                            let mut m = t.map().unwrap();
12231                            let s = m.as_mut_slice();
12232                            for (j, b) in s[..w * h].iter_mut().enumerate() {
12233                                *b = ((i * 53 + j) % 200 + 16) as u8;
12234                            }
12235                            for b in &mut s[w * h..] {
12236                                *b = (80 + i * 24) as u8;
12237                            }
12238                        }
12239                        let lb = Crop::letterbox([114, 114, 114, 255]);
12240                        let convert_once = |proc: &mut ImageProcessor| -> Vec<u8> {
12241                            let mut dst = proc
12242                                .create_image(
12243                                    320,
12244                                    320,
12245                                    PixelFormat::Rgba,
12246                                    DType::U8,
12247                                    mem,
12248                                    edgefirst_tensor::CpuAccess::ReadWrite,
12249                                )
12250                                .unwrap();
12251                            proc.convert(&src, &mut dst, Rotation::None, Flip::None, lb)
12252                                .unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
12253                            let t = dst.as_typed::<u8>().unwrap();
12254                            let m = t.map().unwrap();
12255                            m.as_slice().to_vec()
12256                        };
12257
12258                        let oracle = convert_once(&mut proc);
12259                        barrier.wait();
12260                        for round in 0..ROUNDS {
12261                            let out = convert_once(&mut proc);
12262                            let diffs = oracle.iter().zip(&out).filter(|(a, b)| a != b).count();
12263                            assert!(
12264                                diffs == 0,
12265                                "thread {i} round {round}: {diffs}/{} bytes diverged \
12266                                 from this processor's own oracle — cross-processor \
12267                                 GL state leakage under parallel execution",
12268                                oracle.len()
12269                            );
12270                        }
12271                    })
12272                })
12273                .collect();
12274
12275            for (i, h) in handles.into_iter().enumerate() {
12276                h.join()
12277                    .unwrap_or_else(|e| panic!("parallel thread {i} panicked: {e:?}"));
12278            }
12279            let _ = tx.send(());
12280        });
12281
12282        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
12283            panic!("test_parallel_processors_unique_outputs timed out after {TIMEOUT:?}")
12284        });
12285    }
12286
12287    /// Heavy on-demand stressor for the GL serialization policy: 4
12288    /// processors × 4 threads × barrier × 200 NV12 720p → RGB 640 letterbox
12289    /// converts (DMA where available); every output must byte-match the
12290    /// thread's own pre-barrier sequential oracle from the same processor.
12291    /// Ignored by default (heavy; board tool — the CI-weight version is
12292    /// `test_parallel_processors_unique_outputs`). Run explicitly, optionally
12293    /// pinning the policy via EDGEFIRST_GL_SERIALIZE=full|lifecycle:
12294    ///   <test binary> stress_parallel_processors_oracle --ignored
12295    #[test]
12296    #[ignore = "heavy on-demand GL-parallelism stressor; run explicitly on boards"]
12297    fn stress_parallel_processors_oracle() {
12298        use std::sync::{mpsc, Arc, Barrier};
12299        use std::time::Duration;
12300
12301        const N: usize = 4;
12302        const ROUNDS: usize = 200;
12303        const TIMEOUT: Duration = Duration::from_secs(600);
12304
12305        let _lock = acquire_env_lock();
12306        let (tx, rx) = mpsc::channel::<()>();
12307
12308        std::thread::spawn(move || {
12309            let barrier = Arc::new(Barrier::new(N));
12310            let handles: Vec<_> = (0..N)
12311                .map(|i| {
12312                    let barrier = Arc::clone(&barrier);
12313                    std::thread::spawn(move || {
12314                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
12315                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
12316                        });
12317                        let (w, h) = (1280usize, 720usize);
12318                        let mem = if edgefirst_tensor::is_dma_available() {
12319                            Some(TensorMemory::DmaBuf)
12320                        } else {
12321                            Some(TensorMemory::Mem)
12322                        };
12323
12324                        // Per-thread-distinct synthetic NV12 so cross-wired
12325                        // GL state between processors shows up as a byte diff.
12326                        let src = proc
12327                            .create_image(
12328                                w,
12329                                h,
12330                                PixelFormat::Nv12,
12331                                DType::U8,
12332                                mem,
12333                                edgefirst_tensor::CpuAccess::ReadWrite,
12334                            )
12335                            .unwrap();
12336                        {
12337                            let t = src.as_typed::<u8>().unwrap();
12338                            let mut m = t.map().unwrap();
12339                            let s = m.as_mut_slice();
12340                            for (j, b) in s[..w * h].iter_mut().enumerate() {
12341                                *b = ((i * 37 + j) % 200 + 16) as u8;
12342                            }
12343                            for b in &mut s[w * h..] {
12344                                *b = (96 + i * 16) as u8;
12345                            }
12346                        }
12347                        let lb = Crop::letterbox([114, 114, 114, 255]);
12348
12349                        let convert_once = |proc: &mut ImageProcessor| -> Vec<u8> {
12350                            let mut dst = proc
12351                                .create_image(
12352                                    640,
12353                                    640,
12354                                    PixelFormat::Rgb,
12355                                    DType::U8,
12356                                    mem,
12357                                    edgefirst_tensor::CpuAccess::ReadWrite,
12358                                )
12359                                .unwrap();
12360                            proc.convert(&src, &mut dst, Rotation::None, Flip::None, lb)
12361                                .unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
12362                            let t = dst.as_typed::<u8>().unwrap();
12363                            let m = t.map().unwrap();
12364                            m.as_slice().to_vec()
12365                        };
12366
12367                        let oracle = convert_once(&mut proc);
12368                        barrier.wait();
12369                        for round in 0..ROUNDS {
12370                            let out = convert_once(&mut proc);
12371                            let diffs = oracle.iter().zip(&out).filter(|(a, b)| a != b).count();
12372                            assert!(
12373                                diffs == 0,
12374                                "thread {i} round {round}: {diffs}/{} bytes diverged \
12375                                 from the pre-barrier oracle",
12376                                oracle.len()
12377                            );
12378                        }
12379                    })
12380                })
12381                .collect();
12382
12383            for (i, h) in handles.into_iter().enumerate() {
12384                h.join()
12385                    .unwrap_or_else(|e| panic!("stressor thread {i} panicked: {e:?}"));
12386            }
12387            let _ = tx.send(());
12388        });
12389
12390        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
12391            panic!("stress_parallel_processors_oracle timed out after {TIMEOUT:?}")
12392        });
12393    }
12394
12395    // =========================================================================
12396    // F16 / F32 auto-chain fallback integration tests
12397    // =========================================================================
12398
12399    /// Proves the auto-chain (OpenGL → G2D → CPU) NEVER errors for a float
12400    /// combo the GL path does NOT cover.
12401    ///
12402    /// `Yuyv → Rgb F32` is not handled by the GL float render path (which
12403    /// only covers `Rgba → PlanarRgb F16` and `Rgba → Rgb F32`), so the
12404    /// chain falls through to the CPU float path. Before commit 868a7649
12405    /// added CPU U8→F32/F16 support this would have returned `Err`; now it
12406    /// must return `Ok` with output in `[0, 1]` and all values finite.
12407    #[test]
12408    fn convert_f32_auto_never_errors_non_gl_combo() {
12409        const W: usize = 64;
12410        const H: usize = 64;
12411
12412        // Build a small synthetic YUYV source (Y=128, U=128, V=128 → near-grey).
12413        // YUYV packs two pixels into 4 bytes: [Y0, U, Y1, V] per macropixel.
12414        let src = TensorDyn::image(
12415            W,
12416            H,
12417            PixelFormat::Yuyv,
12418            DType::U8,
12419            Some(TensorMemory::Mem),
12420            edgefirst_tensor::CpuAccess::ReadWrite,
12421        )
12422        .unwrap();
12423        {
12424            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
12425            let data = map.as_mut_slice();
12426            for chunk in data.as_chunks_mut::<4>().0 {
12427                chunk[0] = 128; // Y0
12428                chunk[1] = 128; // U
12429                chunk[2] = 160; // Y1 — distinct so a layout bug is visible
12430                chunk[3] = 128; // V
12431            }
12432        }
12433
12434        let mut dst = TensorDyn::image(
12435            W,
12436            H,
12437            PixelFormat::Rgb,
12438            DType::F32,
12439            Some(TensorMemory::Mem),
12440            edgefirst_tensor::CpuAccess::ReadWrite,
12441        )
12442        .unwrap();
12443
12444        let mut proc = ImageProcessor::new().unwrap();
12445        let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
12446        assert!(
12447            result.is_ok(),
12448            "auto-chain Yuyv→Rgb F32 must not error: {:?}",
12449            result.err()
12450        );
12451
12452        // Verify all output values are finite and in [0, 1].
12453        let map = dst.as_typed::<f32>().unwrap().map().unwrap();
12454        let floats = map.as_slice();
12455        assert_eq!(floats.len(), W * H * 3, "unexpected output element count");
12456        for (i, &v) in floats.iter().enumerate() {
12457            assert!(
12458                v.is_finite() && (0.0..=1.0).contains(&v),
12459                "output[{i}]={v} is not finite or not in [0,1]"
12460            );
12461        }
12462
12463        // WEAK-1: Anti-all-zero spot-check.  A Y=128 YUYV source normalises to
12464        // ≈0.502 on the luma channel.  If the buffer is all-zero (e.g. the CPU
12465        // path never wrote to it) this assertion catches the regression.
12466        let first_non_zero = floats.iter().find(|&&v| v > 0.01);
12467        assert!(
12468            first_non_zero.is_some(),
12469            "all-zero output detected — CPU path likely did not write to the destination buffer"
12470        );
12471        // Y=128 → luma ≈ 0.502.  Spot-check the first pixel's R channel
12472        // (which carries luma for a near-grey YUV source).
12473        let r0 = floats[0];
12474        assert!(
12475            (r0 - 0.502_f32).abs() < 0.05,
12476            "first pixel R={r0} expected ≈0.502 (Y=128 neutral grey from YUYV source)"
12477        );
12478    }
12479
12480    /// Proves CPU-forced `Rgba → PlanarRgb F16` correctness.
12481    ///
12482    /// Uses a source with clearly distinct per-channel values so a
12483    /// plane-swap or layout bug surfaces immediately. Tolerance is 2^-9
12484    /// (one F16 ULP at 0.5, i.e. roughly 1/512).
12485    #[test]
12486    // `ImageProcessorConfig` carries a Linux-only `egl_display` field, so
12487    // `{ backend, ..Default::default() }` is a genuine update on Linux but
12488    // covers no remaining fields on macOS, where `clippy::needless_update`
12489    // then fires. `allow` (not `expect`) because the lint is platform-
12490    // conditional — it does not fire on Linux.
12491    #[allow(clippy::needless_update)]
12492    fn convert_f16_forced_cpu_correct() {
12493        const W: usize = 16;
12494        const H: usize = 16;
12495        const TOL: f32 = 1.0 / 512.0; // 2^-9
12496
12497        // pixel (y,x): R = 50+x, G = 100+y*8, B = 200
12498        let src = TensorDyn::image(
12499            W,
12500            H,
12501            PixelFormat::Rgba,
12502            DType::U8,
12503            Some(TensorMemory::Mem),
12504            edgefirst_tensor::CpuAccess::ReadWrite,
12505        )
12506        .unwrap();
12507        {
12508            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
12509            let data = map.as_mut_slice();
12510            for y in 0..H {
12511                for x in 0..W {
12512                    let i = y * W + x;
12513                    data[i * 4] = (50 + x) as u8; // R: 50..65
12514                    data[i * 4 + 1] = (100 + y * 8) as u8; // G: 100..220
12515                    data[i * 4 + 2] = 200; // B: constant
12516                    data[i * 4 + 3] = 255;
12517                }
12518            }
12519        }
12520
12521        let mut dst = TensorDyn::image(
12522            W,
12523            H,
12524            PixelFormat::PlanarRgb,
12525            DType::F16,
12526            Some(TensorMemory::Mem),
12527            edgefirst_tensor::CpuAccess::ReadWrite,
12528        )
12529        .unwrap();
12530
12531        let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
12532            backend: ComputeBackend::Cpu,
12533            ..Default::default()
12534        })
12535        .unwrap();
12536        proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
12537            .expect("forced-CPU Rgba→PlanarRgb F16 must not error");
12538
12539        let src_map = src.as_typed::<u8>().unwrap().map().unwrap();
12540        let src_bytes = src_map.as_slice();
12541        let dst_map = dst.as_typed::<half::f16>().unwrap().map().unwrap();
12542        let dst_halfs = dst_map.as_slice();
12543
12544        let plane = W * H;
12545        assert_eq!(dst_halfs.len(), plane * 3, "wrong output element count");
12546
12547        for y in 0..H {
12548            for x in 0..W {
12549                let i = y * W + x;
12550                let r_exp = src_bytes[i * 4] as f32 / 255.0;
12551                let g_exp = src_bytes[i * 4 + 1] as f32 / 255.0;
12552                let b_exp = src_bytes[i * 4 + 2] as f32 / 255.0;
12553
12554                let r_got = dst_halfs[i].to_f32();
12555                let g_got = dst_halfs[plane + i].to_f32();
12556                let b_got = dst_halfs[2 * plane + i].to_f32();
12557
12558                assert!(
12559                    (r_got - r_exp).abs() <= TOL,
12560                    "R plane ({x},{y}): got {r_got}, expected {r_exp}"
12561                );
12562                assert!(
12563                    (g_got - g_exp).abs() <= TOL,
12564                    "G plane ({x},{y}): got {g_got}, expected {g_exp}"
12565                );
12566                assert!(
12567                    (b_got - b_exp).abs() <= TOL,
12568                    "B plane ({x},{y}): got {b_got}, expected {b_exp}"
12569                );
12570
12571                // Catch plane-swap: R and G must differ (they have different formulas).
12572                if src_bytes[i * 4] != src_bytes[i * 4 + 1] {
12573                    assert_ne!(r_got, g_got, "R and G planes must differ at ({x},{y})");
12574                }
12575            }
12576        }
12577    }
12578
12579    /// Proves the auto-chain falls through to CPU for `Rgba → Rgb F32` with
12580    /// rotation set.
12581    ///
12582    /// The GL float render path rejects any call where rotation ≠ None,
12583    /// returning an error that causes the chain to continue. Before the CPU
12584    /// float fallback this would have produced an error at the end of the
12585    /// chain; now it must reach CPU and return `Ok` with finite `[0, 1]` output.
12586    #[test]
12587    fn convert_f32_with_rotation_falls_back() {
12588        const W: usize = 16;
12589        const H: usize = 16;
12590
12591        // RGBA8 source with a known gradient (distinct per-channel values).
12592        let src = TensorDyn::image(
12593            W,
12594            H,
12595            PixelFormat::Rgba,
12596            DType::U8,
12597            Some(TensorMemory::Mem),
12598            edgefirst_tensor::CpuAccess::ReadWrite,
12599        )
12600        .unwrap();
12601        {
12602            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
12603            let data = map.as_mut_slice();
12604            for y in 0..H {
12605                for x in 0..W {
12606                    let i = y * W + x;
12607                    data[i * 4] = (x * 16) as u8; // R
12608                    data[i * 4 + 1] = (y * 16) as u8; // G
12609                    data[i * 4 + 2] = 128; // B
12610                    data[i * 4 + 3] = 255;
12611                }
12612            }
12613        }
12614
12615        // Rotation swaps W and H, so dst is [W, H] (H×W output).
12616        let mut dst = TensorDyn::image(
12617            H, // dst W = src H after 90° rotation
12618            W, // dst H = src W after 90° rotation
12619            PixelFormat::Rgb,
12620            DType::F32,
12621            Some(TensorMemory::Mem),
12622            edgefirst_tensor::CpuAccess::ReadWrite,
12623        )
12624        .unwrap();
12625
12626        let mut proc = ImageProcessor::new().unwrap();
12627        let result = proc.convert(
12628            &src,
12629            &mut dst,
12630            Rotation::Clockwise90,
12631            Flip::None,
12632            Crop::default(),
12633        );
12634        assert!(
12635            result.is_ok(),
12636            "auto-chain Rgba→Rgb F32 with Rot90 must not error: {:?}",
12637            result.err()
12638        );
12639
12640        let map = dst.as_typed::<f32>().unwrap().map().unwrap();
12641        let floats = map.as_slice();
12642        assert_eq!(floats.len(), H * W * 3, "unexpected output element count");
12643        for (i, &v) in floats.iter().enumerate() {
12644            assert!(
12645                v.is_finite() && (0.0..=1.0).contains(&v),
12646                "output[{i}]={v} is not finite or not in [0,1]"
12647            );
12648        }
12649    }
12650
12651    /// GL-vs-CPU identity parity for `Rgba → PlanarRgb F16`.
12652    ///
12653    /// Converts the same RGBA8 source via forced `OpenGl` and forced `Cpu`,
12654    /// then verifies the two F16 output tensors agree element-wise within
12655    /// 2^-8 (two F16 ULPs at 0.5). Skipped when OpenGL or F16 render is
12656    /// unavailable.
12657    #[test]
12658    #[cfg(all(
12659        any(
12660            target_os = "linux",
12661            target_os = "macos",
12662            target_os = "ios",
12663            target_os = "android",
12664            target_os = "windows"
12665        ),
12666        feature = "opengl"
12667    ))]
12668    fn convert_f16_gl_cpu_parity_identity() {
12669        if !is_opengl_available() {
12670            eprintln!("SKIPPED: convert_f16_gl_cpu_parity_identity - OpenGL not available");
12671            return;
12672        }
12673
12674        const W: usize = 16;
12675        const H: usize = 16;
12676        const TOL: f32 = 1.0 / 256.0; // 2^-8
12677
12678        // pixel (y,x): R = 40+x, G = 80+y*10, B = 180
12679        let src = TensorDyn::image(
12680            W,
12681            H,
12682            PixelFormat::Rgba,
12683            DType::U8,
12684            Some(TensorMemory::Mem),
12685            edgefirst_tensor::CpuAccess::ReadWrite,
12686        )
12687        .unwrap();
12688        {
12689            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
12690            let data = map.as_mut_slice();
12691            for y in 0..H {
12692                for x in 0..W {
12693                    let i = y * W + x;
12694                    data[i * 4] = (40 + x) as u8; // R
12695                    data[i * 4 + 1] = (80 + y * 10) as u8; // G
12696                    data[i * 4 + 2] = 180; // B
12697                    data[i * 4 + 3] = 255;
12698                }
12699            }
12700        }
12701
12702        // GL path.
12703        let gl_result = {
12704            let mut gl_proc = match ImageProcessor::with_config(ImageProcessorConfig {
12705                backend: ComputeBackend::OpenGl,
12706                ..Default::default()
12707            }) {
12708                Ok(p) => p,
12709                Err(e) => {
12710                    eprintln!(
12711                        "SKIPPED: convert_f16_gl_cpu_parity_identity - GL backend unavailable: {e}"
12712                    );
12713                    return;
12714                }
12715            };
12716
12717            if !gl_proc.supported_render_dtypes().f16 {
12718                eprintln!("SKIPPED: convert_f16_gl_cpu_parity_identity - F16 render not supported");
12719                return;
12720            }
12721
12722            let mut dst = TensorDyn::image(
12723                W,
12724                H,
12725                PixelFormat::PlanarRgb,
12726                DType::F16,
12727                Some(TensorMemory::Mem),
12728                edgefirst_tensor::CpuAccess::ReadWrite,
12729            )
12730            .unwrap();
12731            match gl_proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default()) {
12732                Ok(()) => dst,
12733                Err(e) => {
12734                    eprintln!(
12735                        "SKIPPED: convert_f16_gl_cpu_parity_identity - GL convert failed: {e}"
12736                    );
12737                    return;
12738                }
12739            }
12740        };
12741
12742        // CPU path.
12743        let cpu_result = {
12744            let mut cpu_proc = ImageProcessor::with_config(ImageProcessorConfig {
12745                backend: ComputeBackend::Cpu,
12746                ..Default::default()
12747            })
12748            .unwrap();
12749            let mut dst = TensorDyn::image(
12750                W,
12751                H,
12752                PixelFormat::PlanarRgb,
12753                DType::F16,
12754                Some(TensorMemory::Mem),
12755                edgefirst_tensor::CpuAccess::ReadWrite,
12756            )
12757            .unwrap();
12758            cpu_proc
12759                .convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
12760                .expect("forced-CPU Rgba→PlanarRgb F16 must not error");
12761            dst
12762        };
12763
12764        // Compare element-wise.
12765        let gl_map = gl_result.as_typed::<half::f16>().unwrap().map().unwrap();
12766        let cpu_map = cpu_result.as_typed::<half::f16>().unwrap().map().unwrap();
12767        let gl_halfs = gl_map.as_slice();
12768        let cpu_halfs = cpu_map.as_slice();
12769
12770        assert_eq!(
12771            gl_halfs.len(),
12772            cpu_halfs.len(),
12773            "GL and CPU output sizes differ"
12774        );
12775
12776        let plane = W * H;
12777        let channel_names = ["R", "G", "B"];
12778        for (idx, (gl_h, cpu_h)) in gl_halfs.iter().zip(cpu_halfs.iter()).enumerate() {
12779            let gl_v = gl_h.to_f32();
12780            let cpu_v = cpu_h.to_f32();
12781            let err = (gl_v - cpu_v).abs();
12782            let ch = channel_names[idx / plane];
12783            let pixel = idx % plane;
12784            assert!(
12785                err <= TOL,
12786                "GL vs CPU mismatch at {ch}[{pixel}]: GL={gl_v}, CPU={cpu_v}, err={err} > tol={TOL}"
12787            );
12788        }
12789    }
12790
12791    // =========================================================================
12792    // GAP-1: supported_render_dtypes() Linux smoke test
12793    // =========================================================================
12794
12795    /// Exercises the real Linux/Windows GL path that reads
12796    /// `gl.supported_render_dtypes()`. Skipped when no GL backend is
12797    /// available (CI/host without a GPU).
12798    #[test]
12799    #[cfg(all(any(target_os = "linux", target_os = "windows"), feature = "opengl"))]
12800    fn supported_render_dtypes_linux_smoke() {
12801        let proc = match ImageProcessor::new() {
12802            Ok(p) => p,
12803            Err(e) => {
12804                eprintln!("SKIPPED: supported_render_dtypes_linux_smoke — ImageProcessor::new() failed: {e}");
12805                return;
12806            }
12807        };
12808        if proc.opengl.is_none() {
12809            eprintln!("SKIPPED: supported_render_dtypes_linux_smoke — no GL backend on this host");
12810            return;
12811        }
12812        // The call must complete without panicking or deadlocking.
12813        let support = proc.supported_render_dtypes();
12814        eprintln!(
12815            "supported_render_dtypes_linux_smoke: f16={} f32={}",
12816            support.f16, support.f32
12817        );
12818        // No assertion on the specific values — they are hardware-dependent.
12819    }
12820
12821    // =========================================================================
12822    // GAP-2: F16 PlanarRgb with width NOT divisible by 4 falls back to CPU
12823    // =========================================================================
12824
12825    /// The GL float render path rejects PlanarRgb F16 destinations whose width
12826    /// is not a multiple of 4 (the packed RGBA16F swizzle trick requires W%4==0).
12827    /// The auto-chain must transparently fall through to the CPU path, which has
12828    /// no such restriction.  W=18, H=16 is chosen so W%4 == 2.
12829    #[test]
12830    fn convert_f16_pbo_non_4_aligned_width_falls_back() {
12831        const W: usize = 18; // 18 % 4 == 2 — NOT divisible by 4
12832        const H: usize = 16;
12833
12834        // RGBA8 source filled with a flat mid-grey.
12835        let src = TensorDyn::image(
12836            W,
12837            H,
12838            PixelFormat::Rgba,
12839            DType::U8,
12840            Some(TensorMemory::Mem),
12841            edgefirst_tensor::CpuAccess::ReadWrite,
12842        )
12843        .unwrap();
12844        {
12845            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
12846            let data = map.as_mut_slice();
12847            for chunk in data.as_chunks_mut::<4>().0 {
12848                chunk[0] = 128;
12849                chunk[1] = 64;
12850                chunk[2] = 200;
12851                chunk[3] = 255;
12852            }
12853        }
12854
12855        // F16 PlanarRgb destination in Mem (GL would use PBO, but we want
12856        // to exercise the fallback chain without hardware dependency).
12857        let mut dst = TensorDyn::image(
12858            W,
12859            H,
12860            PixelFormat::PlanarRgb,
12861            DType::F16,
12862            Some(TensorMemory::Mem),
12863            edgefirst_tensor::CpuAccess::ReadWrite,
12864        )
12865        .unwrap();
12866
12867        // Use the default auto-chain so the GL path can attempt and reject,
12868        // then the CPU path succeeds.
12869        let mut proc = ImageProcessor::new().unwrap();
12870        let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
12871        assert!(
12872            result.is_ok(),
12873            "auto-chain PlanarRgb F16 W%4!=0 must not error (CPU fallback): {:?}",
12874            result.err()
12875        );
12876
12877        // All output values must be finite and in [0, 1].
12878        let map = dst.as_typed::<half::f16>().unwrap().map().unwrap();
12879        let halfs = map.as_slice();
12880        assert_eq!(halfs.len(), W * H * 3, "unexpected element count");
12881        for (i, h) in halfs.iter().enumerate() {
12882            let v = h.to_f32();
12883            assert!(
12884                v.is_finite() && (0.0..=1.0).contains(&v),
12885                "output[{i}]={v} is not finite or not in [0,1]"
12886            );
12887        }
12888    }
12889
12890    // =========================================================================
12891    // GAP-4: NV12 → Rgb F32 and NV12 → PlanarRgb F16, forced CPU
12892    // =========================================================================
12893
12894    /// CPU widen-composition: NV12 (non-RGBA source) → Rgb F32.
12895    ///
12896    /// NV12 requires a two-stage CPU conversion (NV12→Rgba then Rgba→F32)
12897    /// which was untested. A wrong intermediate format selection silently
12898    /// produces garbage. Y=128, U=V=128 → near-neutral grey → R≈G≈B≈0.5.
12899    #[test]
12900    // Linux-only `egl_display` field makes `..Default::default()` needless
12901    // on macOS only; see `convert_f16_forced_cpu_correct`.
12902    #[allow(clippy::needless_update)]
12903    fn convert_nv12_to_rgb_f32_cpu() {
12904        const W: usize = 16;
12905        const H: usize = 16; // must be even for NV12
12906
12907        // Build a valid NV12 tensor: shape [H*3/2, W], luma=128, chroma=128.
12908        let src = TensorDyn::image(
12909            W,
12910            H,
12911            PixelFormat::Nv12,
12912            DType::U8,
12913            Some(TensorMemory::Mem),
12914            edgefirst_tensor::CpuAccess::ReadWrite,
12915        )
12916        .unwrap();
12917        {
12918            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
12919            map.as_mut_slice().fill(128); // Y=128, U=V=128 → neutral grey
12920        }
12921
12922        let mut dst = TensorDyn::image(
12923            W,
12924            H,
12925            PixelFormat::Rgb,
12926            DType::F32,
12927            Some(TensorMemory::Mem),
12928            edgefirst_tensor::CpuAccess::ReadWrite,
12929        )
12930        .unwrap();
12931
12932        let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
12933            backend: ComputeBackend::Cpu,
12934            ..Default::default()
12935        })
12936        .unwrap();
12937
12938        let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
12939        assert!(
12940            result.is_ok(),
12941            "forced-CPU NV12→Rgb F32 must not error: {:?}",
12942            result.err()
12943        );
12944
12945        let map = dst.as_typed::<f32>().unwrap().map().unwrap();
12946        let floats = map.as_slice();
12947        assert_eq!(floats.len(), W * H * 3, "unexpected element count");
12948        for (i, &v) in floats.iter().enumerate() {
12949            assert!(
12950                v.is_finite() && (0.0..=1.0).contains(&v),
12951                "output[{i}]={v} is not finite or not in [0,1]"
12952            );
12953        }
12954        // Anti-all-zero: Y=128 → luma channel ≈ 0.5 after YUV→RGB.
12955        let non_zero = floats.iter().any(|&v| v > 0.01);
12956        assert!(non_zero, "all-zero output from NV12→Rgb F32 CPU path");
12957    }
12958
12959    /// CPU widen-composition: NV12 (non-RGBA source) → PlanarRgb F16.
12960    ///
12961    /// Same rationale as `convert_nv12_to_rgb_f32_cpu` but for F16 output.
12962    #[test]
12963    // Linux-only `egl_display` field makes `..Default::default()` needless
12964    // on macOS only; see `convert_f16_forced_cpu_correct`.
12965    #[allow(clippy::needless_update)]
12966    fn convert_nv12_to_planar_rgb_f16_cpu() {
12967        const W: usize = 16;
12968        const H: usize = 16;
12969
12970        let src = TensorDyn::image(
12971            W,
12972            H,
12973            PixelFormat::Nv12,
12974            DType::U8,
12975            Some(TensorMemory::Mem),
12976            edgefirst_tensor::CpuAccess::ReadWrite,
12977        )
12978        .unwrap();
12979        {
12980            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
12981            map.as_mut_slice().fill(128);
12982        }
12983
12984        let mut dst = TensorDyn::image(
12985            W,
12986            H,
12987            PixelFormat::PlanarRgb,
12988            DType::F16,
12989            Some(TensorMemory::Mem),
12990            edgefirst_tensor::CpuAccess::ReadWrite,
12991        )
12992        .unwrap();
12993
12994        let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
12995            backend: ComputeBackend::Cpu,
12996            ..Default::default()
12997        })
12998        .unwrap();
12999
13000        let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
13001        assert!(
13002            result.is_ok(),
13003            "forced-CPU NV12→PlanarRgb F16 must not error: {:?}",
13004            result.err()
13005        );
13006
13007        let map = dst.as_typed::<half::f16>().unwrap().map().unwrap();
13008        let halfs = map.as_slice();
13009        assert_eq!(halfs.len(), W * H * 3, "unexpected element count");
13010        for (i, h) in halfs.iter().enumerate() {
13011            let v = h.to_f32();
13012            assert!(
13013                v.is_finite() && (0.0..=1.0).contains(&v),
13014                "output[{i}]={v} is not finite or not in [0,1]"
13015            );
13016        }
13017        let non_zero = halfs.iter().any(|h| h.to_f32() > 0.01);
13018        assert!(non_zero, "all-zero output from NV12→PlanarRgb F16 CPU path");
13019    }
13020
13021    // =========================================================================
13022    // GAP-5: create_image F32 + DMA must return NotSupported
13023    // =========================================================================
13024
13025    /// `create_image_desc` without a compression request is exactly
13026    /// `create_image` (the processor's memory negotiation applies); with
13027    /// `Compression::Any` off-Android it resolves linear and counts the
13028    /// fallback in the processor-visible mirror.
13029    #[test]
13030    fn create_image_desc_negotiates_and_counts_fallbacks() {
13031        use edgefirst_tensor::{CpuAccess, ImageDesc};
13032        let proc = ImageProcessor::new().unwrap();
13033
13034        let desc =
13035            ImageDesc::new(64, 64, PixelFormat::Rgba, DType::U8).with_access(CpuAccess::ReadWrite);
13036        let plain = proc.create_image_desc(&desc).unwrap();
13037        let classic = proc
13038            .create_image(
13039                64,
13040                64,
13041                PixelFormat::Rgba,
13042                DType::U8,
13043                None,
13044                CpuAccess::ReadWrite,
13045            )
13046            .unwrap();
13047        assert_eq!(plain.memory(), classic.memory(), "same negotiation path");
13048        assert_eq!(plain.compression(), None);
13049
13050        #[cfg(not(target_os = "android"))]
13051        {
13052            use edgefirst_tensor::Compression;
13053            let before = proc.compression_fallback_count();
13054            let desc = ImageDesc::new(64, 64, PixelFormat::Rgba, DType::U8)
13055                .with_compression(Compression::Any);
13056            let t = proc.create_image_desc(&desc).unwrap();
13057            assert_eq!(t.compression(), None, "no vendor tile scheme off-Android");
13058            assert!(
13059                proc.compression_fallback_count() > before,
13060                "Any resolving linear must count"
13061            );
13062        }
13063    }
13064
13065    /// There is no DRM FourCC for F32 images, so `create_image` with
13066    /// `TensorMemory::DmaBuf` and `DType::F32` must return `Err(NotSupported)`.
13067    #[test]
13068    #[cfg(target_os = "linux")]
13069    fn create_image_f32_dma_rejected() {
13070        let proc = ImageProcessor::new().unwrap();
13071        let result = proc.create_image(
13072            64,
13073            64,
13074            PixelFormat::Rgb,
13075            DType::F32,
13076            Some(TensorMemory::DmaBuf),
13077            edgefirst_tensor::CpuAccess::ReadWrite,
13078        );
13079        assert!(
13080            result.is_err(),
13081            "create_image(F32, Dma) must fail — no DRM fourcc for f32"
13082        );
13083    }
13084
13085    /// Verify that `import_image` stores the supplied `Option<Colorimetry>` on
13086    /// the returned `TensorDyn`.
13087    ///
13088    /// `import_image` requires a DMA-backed fd on Linux.  When DMA is
13089    /// unavailable we skip the DMA call but still verify the storage contract
13090    /// by inspecting `set_colorimetry` / `colorimetry` on a plain `TensorDyn`
13091    /// constructed the same way the function body does it — and we confirm via
13092    /// code-read that the new parameter is unconditionally stored.
13093    #[test]
13094    #[cfg(target_os = "linux")]
13095    fn import_image_carries_colorimetry() {
13096        use edgefirst_tensor::{ColorEncoding, ColorRange, Colorimetry, TensorMemory};
13097
13098        let expected = Colorimetry::default()
13099            .with_encoding(ColorEncoding::Bt709)
13100            .with_range(ColorRange::Limited);
13101
13102        if !is_dma_available() {
13103            // DMA unavailable on this host: exercise the storage path via
13104            // TensorDyn directly (mirrors what import_image does internally).
13105            let mut t = TensorDyn::image(
13106                8,
13107                8,
13108                PixelFormat::Rgba,
13109                DType::U8,
13110                Some(TensorMemory::Mem),
13111                edgefirst_tensor::CpuAccess::ReadWrite,
13112            )
13113            .expect("alloc");
13114            assert_eq!(t.colorimetry(), None, "colorimetry must start as None");
13115            t.set_colorimetry(Some(expected));
13116            assert_eq!(
13117                t.colorimetry(),
13118                Some(expected),
13119                "set_colorimetry must round-trip"
13120            );
13121            eprintln!("SKIPPED import_image_carries_colorimetry (DMA unavailable); storage contract verified via TensorDyn");
13122            return;
13123        }
13124
13125        // DMA is available: allocate a real DMA tensor, extract its fd, and
13126        // call import_image with an explicit Colorimetry.
13127        use edgefirst_tensor::{PlaneDescriptor, Tensor};
13128
13129        let rgba_bytes = 64 * 64 * 4; // 64×64 RGBA8
13130        let dma_tensor = Tensor::<u8>::new(
13131            &[rgba_bytes],
13132            Some(TensorMemory::DmaBuf),
13133            Some("import_test"),
13134        )
13135        .expect("dma alloc");
13136        let pd =
13137            PlaneDescriptor::new(dma_tensor.dmabuf().expect("dma fd")).expect("PlaneDescriptor");
13138
13139        let proc = ImageProcessor::new().expect("ImageProcessor");
13140        let result = proc.import_image(
13141            pd,
13142            None,
13143            64,
13144            64,
13145            PixelFormat::Rgba,
13146            DType::U8,
13147            Some(expected),
13148        );
13149        let tensor = result.expect("import_image must succeed on DMA fd");
13150        assert_eq!(
13151            tensor.colorimetry(),
13152            Some(expected),
13153            "import_image must store the supplied colorimetry on the returned TensorDyn"
13154        );
13155    }
13156}