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
14The main features of the `edgefirst_image` crate include:
15- Support for various image formats, including YUYV, RGB, RGBA, and GREY.
16- Support for source crop, destination crop, rotation, and flipping.
17- Image conversion using hardware acceleration (G2D, OpenGL) when available.
18- CPU-based image conversion as a fallback option.
19
20The crate uses [`TensorDyn`] from `edgefirst_tensor` to represent images,
21with [`PixelFormat`] metadata describing the pixel layout. The
22[`ImageProcessor`] struct manages the conversion process, selecting
23the appropriate conversion method based on the available hardware.
24
25## Examples
26
27```rust
28# use edgefirst_image::{ImageProcessor, Rotation, Flip, Crop, ImageProcessorTrait};
29# use edgefirst_codec::{peek_info, ImageDecoder, ImageLoad};
30# use edgefirst_tensor::{CpuAccess, PixelFormat, DType, Tensor, TensorMemory};
31# fn main() -> Result<(), edgefirst_image::Error> {
32let image = edgefirst_bench::testdata::read("zidane.jpg");
33// The codec emits the source's native format (a colour JPEG decodes to NV12)
34// and configures the destination tensor's dims+format during the decode.
35let info = peek_info(&image).expect("peek");
36// Heap tensors are CPU-touched on both sides (codec decode writes, the
37// engine's upload/CPU-fallback paths read), so declare ReadWrite. Pure
38// hardware pipelines allocate their convert destinations with
39// `CpuAccess::None` instead.
40let mut src = Tensor::<u8>::image(info.width, info.height, info.format,
41                                   Some(TensorMemory::Mem), CpuAccess::ReadWrite)?;
42let mut decoder = ImageDecoder::new();
43src.load_image(&mut decoder, &image).expect("decode");
44// Convert the native NV12 frame to packed RGB for downstream processing.
45let mut converter = ImageProcessor::new()?;
46let mut dst =
47    converter.create_image(640, 480, PixelFormat::Rgb, DType::U8, None, CpuAccess::ReadWrite)?;
48converter.convert(&src.into(), &mut dst, Rotation::None, Flip::None, Crop::default())?;
49# Ok(())
50# }
51```
52
53## Environment Variables
54The behavior of the `edgefirst_image::ImageProcessor` struct can be influenced by the
55following environment variables:
56- `EDGEFIRST_FORCE_BACKEND`: When set to `cpu`, `g2d`, or `opengl` (case-insensitive),
57  only that single backend is initialized and no fallback chain is used. If the
58  forced backend fails to initialize, an error is returned immediately. This is
59  useful for benchmarking individual backends in isolation. When this variable is
60  set, the `EDGEFIRST_DISABLE_*` variables are ignored.
61- `EDGEFIRST_DISABLE_GL`: If set to `1`, disables the use of OpenGL for image
62  conversion, forcing the use of CPU or other available hardware methods.
63- `EDGEFIRST_DISABLE_G2D`: If set to `1`, disables the use of G2D for image
64  conversion, forcing the use of CPU or other available hardware methods.
65- `EDGEFIRST_DISABLE_CPU`: If set to `1`, disables the use of CPU for image
66  conversion, forcing the use of hardware acceleration methods. If no hardware
67  acceleration methods are available, an error will be returned when attempting
68  to create an `ImageProcessor`.
69
70Additionally the TensorMemory used by default allocations can be controlled using the
71`EDGEFIRST_TENSOR_FORCE_MEM` environment variable. If set to `1`, default tensor memory
72uses system memory. This will disable the use of specialized memory regions for tensors
73and hardware acceleration. However, this will increase the performance of the CPU converter.
74*/
75#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
76
77/// Retained constructor: installs the coverage flush-on-abort handler for this
78/// crate's instrumented test binary. See `edgefirst_tensor::covguard`. Only
79/// present under coverage on Linux (`.init_array` is ELF-only; flush is Linux-only).
80#[cfg(all(coverage, target_os = "linux"))]
81#[used]
82#[link_section = ".init_array"]
83static __EDGEFIRST_COV_INSTALL: extern "C" fn() = {
84    extern "C" fn ctor() {
85        edgefirst_tensor::covguard::install();
86    }
87    ctor
88};
89
90/// Pitch alignment requirement for DMA-BUF tensors that may be imported as
91/// EGLImages by the GL backend. Mali Valhall (i.MX 95 / G310) rejects
92/// `eglCreateImageKHR` with `EGL_BAD_ALLOC` for any DMA-BUF whose row pitch
93/// is not a multiple of 64 bytes; Vivante GC7000UL (i.MX 8MP) accepts any
94/// pitch so the constant is harmless on that path. 64 is the smallest
95/// alignment that satisfies every embedded ARM GPU we ship to.
96///
97/// Applied automatically inside [`ImageProcessor::create_image`] when the
98/// allocation lands on `TensorMemory::Dma`. External callers that allocate
99/// their own DMA-BUF tensors (e.g. GStreamer plugins, video pipelines) can
100/// use [`align_width_for_gpu_pitch`] to compute a width whose resulting row
101/// stride satisfies this requirement.
102pub const GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES: usize = 64;
103
104/// Round `width` (in pixels) up so the resulting row stride
105/// `width * bpp` is a multiple of [`GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES`]
106/// AND a multiple of `bpp` (so the rounded width is an integer pixel count).
107///
108/// `bpp` must be the per-pixel byte count for the image's primary plane
109/// (e.g. 4 for RGBA8/BGRA8, 3 for RGB888, 1 for Grey/NV12-luma).
110///
111/// External callers — GStreamer plugins, video pipelines, anyone wrapping a
112/// foreign DMA-BUF — should call this when sizing the destination so that
113/// `eglCreateImageKHR` doesn't reject the import on Mali. Pre-aligned widths
114/// (640, 1280, 1920, 3008, 3840 …) round-trip unchanged; misaligned widths
115/// are bumped up to the next valid value.
116///
117/// # Overflow behaviour
118///
119/// All arithmetic is checked. If the alignment computation or the rounded
120/// width would overflow `usize`, the function logs a warning and returns the
121/// original `width` unchanged rather than wrapping or producing a smaller
122/// value. Callers can rely on the returned width being **at least** the
123/// requested width.
124///
125/// `bpp == 0` and `width == 0` short-circuit to return the input unchanged.
126///
127/// # Examples
128///
129/// ```
130/// use edgefirst_image::align_width_for_gpu_pitch;
131///
132/// // RGBA8 (bpp=4): width must round to a multiple of 16 pixels (64-byte stride).
133/// assert_eq!(align_width_for_gpu_pitch(1920, 4), 1920); // already aligned
134/// assert_eq!(align_width_for_gpu_pitch(3004, 4), 3008); // crowd.png case: +4 px
135/// assert_eq!(align_width_for_gpu_pitch(1281, 4), 1296); // +15 px
136///
137/// // RGB888 (bpp=3): width must round to a multiple of 64 pixels (192-byte stride).
138/// assert_eq!(align_width_for_gpu_pitch(640, 3), 640);
139/// assert_eq!(align_width_for_gpu_pitch(641, 3), 704);
140/// ```
141pub fn align_width_for_gpu_pitch(width: usize, bpp: usize) -> usize {
142    if bpp == 0 || width == 0 {
143        return width;
144    }
145
146    // The minimum aligned stride must be a common multiple of both the
147    // GPU's pitch alignment and the per-pixel byte count. Using the LCM
148    // guarantees the rounded stride is an integer multiple of `bpp`, so
149    // converting back to a pixel count is exact.
150    //
151    // Compute the alignment in pixels (`width_alignment`) so we never need
152    // to multiply `width * bpp`, which is the only operation that could
153    // realistically overflow for large caller-supplied widths.
154    let Some(lcm_alignment) = checked_num_integer_lcm(GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES, bpp)
155    else {
156        log::warn!(
157            "align_width_for_gpu_pitch: lcm({GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES}, {bpp}) \
158             overflows usize, returning unaligned width {width}"
159        );
160        return width;
161    };
162    if lcm_alignment == 0 {
163        return width;
164    }
165
166    debug_assert_eq!(lcm_alignment % bpp, 0);
167    let width_alignment = lcm_alignment / bpp;
168    if width_alignment == 0 {
169        return width;
170    }
171
172    let remainder = width % width_alignment;
173    if remainder == 0 {
174        return width;
175    }
176
177    let pad = width_alignment - remainder;
178    match width.checked_add(pad) {
179        Some(aligned) => aligned,
180        None => {
181            log::warn!(
182                "align_width_for_gpu_pitch: width {width} + pad {pad} overflows usize, \
183                 returning unaligned (caller should use a smaller width or pre-aligned size)"
184            );
185            width
186        }
187    }
188}
189
190/// Round `min_pitch_bytes` up to the next multiple of
191/// [`GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES`]. Returns `None` if the rounded
192/// value would overflow `usize`. Returns `Some(0)` for input 0.
193///
194/// Used internally by [`ImageProcessor::create_image`] to compute the
195/// padded row stride for DMA-backed image allocations. External callers
196/// that need pixel-counted alignment (instead of raw byte pitch) should
197/// use [`align_width_for_gpu_pitch`] instead.
198#[cfg(target_os = "linux")]
199pub(crate) fn align_pitch_bytes_to_gpu_alignment(min_pitch_bytes: usize) -> Option<usize> {
200    let alignment = GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES;
201    if min_pitch_bytes == 0 {
202        return Some(0);
203    }
204    let remainder = min_pitch_bytes % alignment;
205    if remainder == 0 {
206        return Some(min_pitch_bytes);
207    }
208    min_pitch_bytes.checked_add(alignment - remainder)
209}
210
211/// Overflow-safe least common multiple. Returns `None` when `(a / gcd) * b`
212/// would wrap.
213fn checked_num_integer_lcm(a: usize, b: usize) -> Option<usize> {
214    if a == 0 || b == 0 {
215        return Some(0);
216    }
217    let g = num_integer_gcd(a, b);
218    // a / g is exact (g divides a by definition) and at most a, so this
219    // division never panics. Only the subsequent multiply can overflow.
220    (a / g).checked_mul(b)
221}
222
223fn num_integer_gcd(a: usize, b: usize) -> usize {
224    if b == 0 {
225        a
226    } else {
227        num_integer_gcd(b, a % b)
228    }
229}
230
231/// Bytes-per-pixel for the primary plane of `format` at element size `elem`.
232/// Returns `None` for formats that don't have a single packed BPP (semi-planar
233/// chroma is handled separately, returning the luma-plane bpp).
234///
235/// External callers can use this together with [`align_width_for_gpu_pitch`]
236/// to size their own DMA-BUFs without having to remember per-format BPPs:
237///
238/// ```
239/// use edgefirst_image::{align_width_for_gpu_pitch, primary_plane_bpp};
240/// use edgefirst_tensor::PixelFormat;
241///
242/// let bpp = primary_plane_bpp(PixelFormat::Rgba, 1).unwrap();
243/// let aligned = align_width_for_gpu_pitch(3004, bpp);
244/// assert_eq!(aligned, 3008);
245/// ```
246pub fn primary_plane_bpp(format: PixelFormat, elem: usize) -> Option<usize> {
247    use edgefirst_tensor::PixelLayout;
248    match format.layout() {
249        PixelLayout::Packed => Some(format.channels() * elem),
250        PixelLayout::Planar => Some(elem),
251        // For NV12/NV16 the luma plane is single-channel so the pitch
252        // matches `elem`; the chroma plane uses the same pitch in bytes
253        // (UV is half-width but two interleaved channels = same pitch).
254        PixelLayout::SemiPlanar => Some(elem),
255        // `PixelLayout` is non-exhaustive — fall through unaligned for
256        // any future variant we don't yet recognise.
257        _ => None,
258    }
259}
260
261/// Return the GPU-aligned pitch in bytes when a DMA-backed image of
262/// `width × fmt` would need row-stride padding, or `None` when the
263/// natural pitch already satisfies `GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES`
264/// or the caller has explicitly requested non-DMA memory.
265///
266/// Mali G310 (i.MX 95) rejects `eglCreateImage` from DMA-BUFs whose
267/// `PLANE0_PITCH_EXT` is not a multiple of 64 bytes, surfacing as
268/// `EGL_BAD_ALLOC`. The `load_image_test_helper` test-only helper
269/// in this crate uses this to decide whether to allocate a tensor
270/// with padded row stride before invoking the decode path; production
271/// callers do the equivalent peek → allocate → decode dance themselves
272/// (see crate-level docs).
273#[cfg(all(target_os = "linux", test))]
274pub(crate) fn padded_dma_pitch_for(
275    fmt: PixelFormat,
276    width: usize,
277    memory: &Option<TensorMemory>,
278) -> Option<usize> {
279    // Only pad when the caller explicitly requested DMA, or when they
280    // left memory selection to the allocator AND DMA is actually
281    // available. `Tensor::image_with_stride(..., None)` always routes
282    // through DMA allocation, so treating `None` as "DMA wanted"
283    // unconditionally would convert a normally-working image load into
284    // a hard failure on systems where DMA is unavailable (sandboxed
285    // CI, missing `/dev/dma_heap`, permission-denied containers) —
286    // whereas `Tensor::image(..., None)` would have fallen back to
287    // SHM/Mem there.
288    match memory {
289        Some(TensorMemory::Dma) => {}
290        None if edgefirst_tensor::is_dma_available() => {}
291        _ => return None,
292    }
293    // Padding only applies to packed layouts — `Tensor::image_with_stride`
294    // rejects semi-planar / planar formats, and those take their own
295    // per-plane pitches on import anyway.
296    if fmt.layout() != PixelLayout::Packed {
297        return None;
298    }
299    let bpp = primary_plane_bpp(fmt, 1)?;
300    let natural = width.checked_mul(bpp)?;
301    let aligned = align_pitch_bytes_to_gpu_alignment(natural)?;
302    if aligned > natural {
303        Some(aligned)
304    } else {
305        None
306    }
307}
308
309pub use cpu::CPUProcessor;
310pub use edgefirst_codec as codec;
311
312#[cfg(test)]
313use edgefirst_decoder::ProtoLayout;
314use edgefirst_decoder::{DetectBox, ProtoData, Segmentation};
315#[doc(inline)]
316pub use edgefirst_tensor::Region;
317#[cfg(any(test, all(target_os = "linux", feature = "opengl")))]
318use edgefirst_tensor::Tensor;
319use edgefirst_tensor::{
320    DType, PixelFormat, PixelLayout, TensorDyn, TensorMemory, TensorTrait as _,
321};
322use enum_dispatch::enum_dispatch;
323pub use error::{Error, Result};
324#[cfg(target_os = "linux")]
325pub use g2d::G2DProcessor;
326#[cfg(all(
327    any(
328        target_os = "linux",
329        target_os = "macos",
330        target_os = "ios",
331        target_os = "android"
332    ),
333    feature = "opengl"
334))]
335pub use opengl_headless::EglDisplayKind;
336#[cfg(all(
337    any(
338        target_os = "linux",
339        target_os = "macos",
340        target_os = "ios",
341        target_os = "android"
342    ),
343    feature = "opengl"
344))]
345pub use opengl_headless::GLProcessorThreaded;
346#[cfg(all(
347    any(
348        target_os = "linux",
349        target_os = "macos",
350        target_os = "ios",
351        target_os = "android"
352    ),
353    feature = "opengl"
354))]
355pub use opengl_headless::Int8InterpolationMode;
356#[cfg(target_os = "linux")]
357#[cfg(feature = "opengl")]
358pub use opengl_headless::{probe_egl_displays, EglDisplayInfo};
359// EGLImage cache counter snapshots (diagnostics): see
360// `GLProcessorThreaded::egl_cache_stats` and the steady-state import gate in
361// `crates/image/ARCHITECTURE.md § image.convert.gl.egl_import`.
362#[cfg(all(
363    any(
364        target_os = "linux",
365        target_os = "macos",
366        target_os = "ios",
367        target_os = "android"
368    ),
369    feature = "opengl"
370))]
371pub use opengl_headless::{CacheStats, ConvertStats, GlCacheStats};
372use std::{fmt::Display, time::Instant};
373
374mod colorimetry;
375mod cpu;
376mod error;
377mod g2d;
378#[path = "gl/mod.rs"]
379mod opengl_headless;
380mod tiling;
381pub use tiling::{tile_grid, TilePlacement, TileSpec, TilingConfig};
382
383// Use `edgefirst_tensor::PixelFormat` variants (Rgb, Rgba, Grey, etc.) and
384// `TensorDyn` / `Tensor<u8>` with `.format()` metadata instead.
385
386#[derive(Debug, Clone, Copy, PartialEq, Eq)]
387pub enum Rotation {
388    None = 0,
389    Clockwise90 = 1,
390    Rotate180 = 2,
391    CounterClockwise90 = 3,
392}
393impl Rotation {
394    /// Creates a Rotation enum from an angle in degrees. The angle must be a
395    /// multiple of 90.
396    ///
397    /// # Panics
398    /// Panics if the angle is not a multiple of 90.
399    ///
400    /// # Examples
401    /// ```rust
402    /// # use edgefirst_image::Rotation;
403    /// let rotation = Rotation::from_degrees_clockwise(270);
404    /// assert_eq!(rotation, Rotation::CounterClockwise90);
405    /// ```
406    pub fn from_degrees_clockwise(angle: usize) -> Rotation {
407        match angle.rem_euclid(360) {
408            0 => Rotation::None,
409            90 => Rotation::Clockwise90,
410            180 => Rotation::Rotate180,
411            270 => Rotation::CounterClockwise90,
412            _ => panic!("rotation angle is not a multiple of 90"),
413        }
414    }
415}
416
417#[derive(Debug, Clone, Copy, PartialEq, Eq)]
418pub enum Flip {
419    None = 0,
420    Vertical = 1,
421    Horizontal = 2,
422}
423
424/// Controls how the color palette index is chosen for each detected object.
425#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
426pub enum ColorMode {
427    /// Color is chosen by object class label (`det.label`). Default.
428    ///
429    /// Preserves backward compatibility and is correct for semantic
430    /// segmentation where colors carry class meaning.
431    #[default]
432    Class,
433    /// Color is chosen by instance order (loop index, zero-based).
434    ///
435    /// Each detected object gets a unique color regardless of class,
436    /// useful for instance segmentation.
437    Instance,
438    /// Color is chosen by track ID (future use; currently behaves like
439    /// [`Instance`](Self::Instance)).
440    Track,
441}
442
443impl ColorMode {
444    /// Return the palette index for a detection given its loop index and label.
445    #[inline]
446    pub fn index(self, idx: usize, label: usize) -> usize {
447        match self {
448            ColorMode::Class => label,
449            ColorMode::Instance | ColorMode::Track => idx,
450        }
451    }
452}
453
454/// Controls the resolution and coordinate frame of masks produced by
455/// [`ImageProcessor::materialize_masks`].
456///
457/// - [`Proto`](Self::Proto) returns per-detection tiles at proto-plane
458///   resolution (e.g. 48×32 u8 for a typical COCO bbox on a 160×160 proto
459///   plane). This is the historical behavior of `materialize_masks` and the
460///   fastest path because no upsample runs inside HAL. Mask values are
461///   continuous sigmoid output quantized to `uint8 [0, 255]`.
462/// - [`Scaled`](Self::Scaled) returns per-detection tiles at caller-specified
463///   pixel resolution by upsampling the full proto plane once and cropping by
464///   bbox after sigmoid. The upsample uses bilinear interpolation with
465///   edge-clamp sampling — semantically equivalent to Ultralytics'
466///   `process_masks_retina` reference. When a `letterbox` is also passed to
467///   [`materialize_masks`], the inverse letterbox transform is applied during
468///   the upsample so mask pixels land in original-content coordinates
469///   (drop-in for overlay on the original image). Mask values are binary
470///   `uint8 {0, 255}` after thresholding sigmoid > 0.5 — interchangeable
471///   with `Proto` output via the same `> 127` test.
472///
473/// [`materialize_masks`]: ImageProcessor::materialize_masks
474#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
475pub enum MaskResolution {
476    /// Per-detection tile at proto-plane resolution (default).
477    #[default]
478    Proto,
479    /// Per-detection tile at `(width, height)` pixel resolution in the
480    /// coordinate frame determined by the `letterbox` parameter of
481    /// [`ImageProcessor::materialize_masks`].
482    Scaled {
483        /// Target pixel width of the output coordinate frame.
484        width: u32,
485        /// Target pixel height of the output coordinate frame.
486        height: u32,
487    },
488}
489
490/// Options for mask overlay rendering.
491///
492/// Controls how segmentation masks are composited onto the destination image:
493/// - `background`: when set, the background image is drawn first and masks
494///   are composited over it (result written to `dst`). When `None`, `dst` is
495///   cleared to `0x00000000` (fully transparent) before masks are drawn.
496///   **`dst` is always fully overwritten — its prior contents are never
497///   preserved.** Callers who used to pre-load an image into `dst` before
498///   calling `draw_decoded_masks` / `draw_proto_masks` must now supply that
499///   image via `background` instead (behaviour changed in v0.16.4).
500/// - `opacity`: scales the alpha of rendered mask colors. `1.0` (default)
501///   preserves the class color's alpha unchanged; `0.5` makes masks
502///   semi-transparent.
503/// - `color_mode`: controls whether colors are assigned by class label,
504///   instance index, or track ID. Defaults to [`ColorMode::Class`].
505#[derive(Debug, Clone, Copy)]
506pub struct MaskOverlay<'a> {
507    /// Compositing source image. Must have the same dimensions and pixel
508    /// format as `dst`. When `Some`, the output is `background + masks`.
509    /// When `None`, `dst` is cleared to `0x00000000` before masks are drawn.
510    pub background: Option<&'a TensorDyn>,
511    pub opacity: f32,
512    /// Normalized letterbox region `[xmin, ymin, xmax, ymax]` in model-input
513    /// space that contains actual image content (the rest is padding).
514    ///
515    /// When set, bounding boxes and mask coordinates from the decoder (which
516    /// are in model-input normalized space) are mapped back to the original
517    /// image coordinate space before rendering.
518    ///
519    /// Use [`with_letterbox_crop`](Self::with_letterbox_crop) to compute this
520    /// from the [`Crop`] that was used in the model input [`convert`](crate::ImageProcessorTrait::convert) call.
521    pub letterbox: Option<[f32; 4]>,
522    pub color_mode: ColorMode,
523}
524
525impl Default for MaskOverlay<'_> {
526    fn default() -> Self {
527        Self {
528            background: None,
529            opacity: 1.0,
530            letterbox: None,
531            color_mode: ColorMode::Class,
532        }
533    }
534}
535
536impl<'a> MaskOverlay<'a> {
537    pub fn new() -> Self {
538        Self::default()
539    }
540
541    /// Set the compositing source image.
542    ///
543    /// `bg` must have the same dimensions and pixel format as the `dst` passed
544    /// to [`draw_decoded_masks`](crate::ImageProcessorTrait::draw_decoded_masks) /
545    /// [`draw_proto_masks`](crate::ImageProcessorTrait::draw_proto_masks).
546    /// The output will be `bg + masks`. Without a background, `dst` is cleared
547    /// to `0x00000000`.
548    pub fn with_background(mut self, bg: &'a TensorDyn) -> Self {
549        self.background = Some(bg);
550        self
551    }
552
553    pub fn with_opacity(mut self, opacity: f32) -> Self {
554        self.opacity = opacity.clamp(0.0, 1.0);
555        self
556    }
557
558    pub fn with_color_mode(mut self, mode: ColorMode) -> Self {
559        self.color_mode = mode;
560        self
561    }
562
563    /// Set the letterbox transform from the [`Crop`] used when preparing the
564    /// model input, so that bounding boxes and masks are correctly mapped back
565    /// to the original image coordinate space during rendering.
566    ///
567    /// Pass the same `crop` that was given to
568    /// [`convert`](crate::ImageProcessorTrait::convert) along with the model
569    /// input dimensions (`model_w` × `model_h`).
570    ///
571    /// Has no effect when `crop.dst_rect` is `None` (no letterbox applied).
572    pub fn with_letterbox_crop(
573        mut self,
574        crop: &Crop,
575        src_w: usize,
576        src_h: usize,
577        model_w: usize,
578        model_h: usize,
579    ) -> Self {
580        // The letterbox placement is resolved from the same source/destination
581        // dimensions `convert()` used, so the inverse map matches the render.
582        if let Ok(resolved) = crop.resolve(src_w, src_h, model_w, model_h) {
583            if let Some(r) = resolved.dst_rect {
584                self.letterbox = Some([
585                    r.left as f32 / model_w as f32,
586                    r.top as f32 / model_h as f32,
587                    (r.left + r.width) as f32 / model_w as f32,
588                    (r.top + r.height) as f32 / model_h as f32,
589                ]);
590            }
591        }
592        self
593    }
594}
595
596/// Apply the inverse letterbox transform to a bounding box.
597///
598/// `letterbox` is `[lx0, ly0, lx1, ly1]` — the normalized region of the model
599/// input that contains actual image content (output of
600/// [`MaskOverlay::with_letterbox_crop`]).
601///
602/// Converts model-input-normalized coords to output-image-normalized coords,
603/// clamped to `[0.0, 1.0]`. Also canonicalises the bbox (ensures xmin ≤ xmax).
604///
605/// Thin wrapper over [`edgefirst_decoder::tiling::unletter_norm`] — the single
606/// home for the inverse-letterbox math lives in the lower `decoder` crate so the
607/// tiled-detection lift and this mask path share one implementation.
608#[inline]
609fn unletter_bbox(bbox: DetectBox, lb: [f32; 4]) -> DetectBox {
610    DetectBox {
611        bbox: edgefirst_decoder::tiling::unletter_norm(bbox.bbox, lb),
612        ..bbox
613    }
614}
615
616/// How a source is fit into the requested destination shape.
617#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
618pub enum Fit {
619    /// Stretch the source (or source crop) to fill the whole destination.
620    #[default]
621    Stretch,
622    /// Preserve the *source* aspect ratio, centring it in the destination and
623    /// padding the remainder with `pad` (RGBA — e.g. `[114, 114, 114, 255]` for
624    /// YOLO-style preprocessing).
625    Letterbox { pad: [u8; 4] },
626}
627
628/// Source-side convert geometry: which sub-rectangle of the source to sample
629/// (`source`) and how to fit it into the destination (`fit`). Destination
630/// *placement* is the destination itself — a tensor, or a [`Region`] view /
631/// `batch` tile of one — not a field here.
632#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
633pub struct Crop {
634    /// Sub-rectangle of the source to sample. `None` samples the whole source.
635    pub source: Option<Region>,
636    /// How the source is fit into the destination shape.
637    pub fit: Fit,
638}
639
640impl Crop {
641    /// A no-op crop: whole source, stretched to fill the whole destination.
642    pub fn new() -> Self {
643        Self::default()
644    }
645
646    /// Alias for [`Crop::new`] — whole source, stretch to fill.
647    pub fn no_crop() -> Self {
648        Self::default()
649    }
650
651    /// Letterbox fit: preserve the source aspect ratio, padding the remainder
652    /// with `pad` (RGBA).
653    pub fn letterbox(pad: [u8; 4]) -> Self {
654        Self {
655            source: None,
656            fit: Fit::Letterbox { pad },
657        }
658    }
659
660    /// Sample only `source` of the input (builder).
661    pub fn with_source(mut self, source: Option<Region>) -> Self {
662        self.source = source;
663        self
664    }
665
666    /// Set the fit mode (builder).
667    pub fn with_fit(mut self, fit: Fit) -> Self {
668        self.fit = fit;
669        self
670    }
671
672    /// Resolve to the effective backend geometry for a `src_w`×`src_h` source
673    /// and `dst_w`×`dst_h` destination: the source sampling rect, the
674    /// destination placement rect (`None` = whole destination), and the pad
675    /// colour. **The single place letterbox placement is computed** — every
676    /// backend consumes the resolved rects rather than re-deriving them.
677    pub(crate) fn resolve(
678        &self,
679        src_w: usize,
680        src_h: usize,
681        dst_w: usize,
682        dst_h: usize,
683    ) -> Result<ResolvedCrop, Error> {
684        let src_rect = self.source.map(region_to_rect);
685        // The letterbox aspect uses the *effective* source content — the source
686        // crop when set, else the full source.
687        let (sw, sh) = match self.source {
688            Some(r) => (r.width, r.height),
689            None => (src_w, src_h),
690        };
691        let resolved = match self.fit {
692            Fit::Stretch => ResolvedCrop {
693                src_rect,
694                dst_rect: None,
695                dst_color: None,
696            },
697            Fit::Letterbox { pad } => ResolvedCrop {
698                src_rect,
699                dst_rect: Some(letterbox_rect(sw, sh, dst_w, dst_h)),
700                dst_color: Some(pad),
701            },
702        };
703        resolved.check_crop_dims(src_w, src_h, dst_w, dst_h)?;
704        Ok(resolved)
705    }
706
707    /// Validate against `TensorDyn` source and destination dimensions.
708    pub fn check_crop_dyn(
709        &self,
710        src: &edgefirst_tensor::TensorDyn,
711        dst: &edgefirst_tensor::TensorDyn,
712    ) -> Result<(), Error> {
713        self.resolve(
714            src.width().unwrap_or(0),
715            src.height().unwrap_or(0),
716            dst.width().unwrap_or(0),
717            dst.height().unwrap_or(0),
718        )
719        .map(|_| ())
720    }
721}
722
723/// Resolved crop geometry consumed by the backends. Produced by
724/// [`Crop::resolve`]; the backends read these fields directly (the same shape
725/// the public `Crop` carried before destination placement moved to the view).
726#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
727pub(crate) struct ResolvedCrop {
728    pub(crate) src_rect: Option<Rect>,
729    pub(crate) dst_rect: Option<Rect>,
730    pub(crate) dst_color: Option<[u8; 4]>,
731}
732
733impl ResolvedCrop {
734    /// A no-op resolved crop (whole source → whole destination, no pad).
735    #[allow(dead_code)] // used by unit tests and the batch render paths
736    pub(crate) fn no_crop() -> Self {
737        Self::default()
738    }
739
740    /// Validate the resolved rects against explicit dimensions.
741    pub(crate) fn check_crop_dims(
742        &self,
743        src_w: usize,
744        src_h: usize,
745        dst_w: usize,
746        dst_h: usize,
747    ) -> Result<(), Error> {
748        let src_ok = self
749            .src_rect
750            .is_none_or(|r| r.left + r.width <= src_w && r.top + r.height <= src_h);
751        let dst_ok = self
752            .dst_rect
753            .is_none_or(|r| r.left + r.width <= dst_w && r.top + r.height <= dst_h);
754        match (src_ok, dst_ok) {
755            (true, true) => Ok(()),
756            (true, false) => Err(Error::CropInvalid(format!(
757                "Dest crop invalid: {:?}",
758                self.dst_rect
759            ))),
760            (false, true) => Err(Error::CropInvalid(format!(
761                "Src crop invalid: {:?}",
762                self.src_rect
763            ))),
764            (false, false) => Err(Error::CropInvalid(format!(
765                "Dest and Src crop invalid: {:?} {:?}",
766                self.dst_rect, self.src_rect
767            ))),
768        }
769    }
770}
771
772/// Convert a pixel [`Region`] to the internal [`Rect`] placement type.
773fn region_to_rect(r: Region) -> Rect {
774    Rect {
775        left: r.x,
776        top: r.y,
777        width: r.width,
778        height: r.height,
779    }
780}
781
782/// Centred aspect-preserving placement of an `sw`×`sh` source within a `dw`×`dh`
783/// destination — the canonical letterbox rectangle (one home, replacing the
784/// per-caller `calculate_letterbox` copies).
785fn letterbox_rect(sw: usize, sh: usize, dw: usize, dh: usize) -> Rect {
786    if sw == 0 || sh == 0 {
787        return Rect::new(0, 0, dw, dh);
788    }
789    let src_aspect = sw as f64 / sh as f64;
790    let dst_aspect = dw as f64 / dh as f64;
791    let (new_w, new_h) = if src_aspect > dst_aspect {
792        (dw, ((dw as f64 / src_aspect).round() as usize).max(1))
793    } else {
794        (((dh as f64 * src_aspect).round() as usize).max(1), dh)
795    };
796    let left = dw.saturating_sub(new_w) / 2;
797    let top = dh.saturating_sub(new_h) / 2;
798    Rect::new(left, top, new_w, new_h)
799}
800
801/// Internal placement rectangle (top-left + size). The **public** pixel
802/// sub-region type is [`Region`] (re-exported from `edgefirst-tensor`);
803/// `Rect` is the crate-private resolved-placement representation used by the
804/// CPU/G2D/GL backends after [`Crop::resolve`].
805#[derive(Debug, Clone, Copy, PartialEq, Eq)]
806pub(crate) struct Rect {
807    pub left: usize,
808    pub top: usize,
809    pub width: usize,
810    pub height: usize,
811}
812
813impl Rect {
814    // Creates a new Rect with the specified left, top, width, and height.
815    pub fn new(left: usize, top: usize, width: usize, height: usize) -> Self {
816        Self {
817            left,
818            top,
819            width,
820            height,
821        }
822    }
823}
824
825#[enum_dispatch(ImageProcessor)]
826pub trait ImageProcessorTrait {
827    /// Converts the source image to the destination image format and size. The
828    /// image is cropped first, then flipped, then rotated
829    ///
830    /// # Arguments
831    ///
832    /// * `dst` - The destination image to be converted to.
833    /// * `src` - The source image to convert from.
834    /// * `rotation` - The rotation to apply to the destination image.
835    /// * `flip` - Flips the image
836    /// * `crop` - An optional rectangle specifying the area to crop from the
837    ///   source image
838    ///
839    /// # Returns
840    ///
841    /// A `Result` indicating success or failure of the conversion.
842    fn convert(
843        &mut self,
844        src: &TensorDyn,
845        dst: &mut TensorDyn,
846        rotation: Rotation,
847        flip: Flip,
848        crop: Crop,
849    ) -> Result<()>;
850
851    /// Draw pre-decoded detection boxes and segmentation masks onto `dst`.
852    ///
853    /// Supports two segmentation modes based on the mask channel count:
854    /// - **Instance segmentation** (`C=1`): one `Segmentation` per detection,
855    ///   `segmentation` and `detect` are zipped.
856    /// - **Semantic segmentation** (`C>1`): a single `Segmentation` covering
857    ///   all classes; only the first element is used.
858    ///
859    /// # Format requirements
860    ///
861    /// - CPU backend: `dst` must be `RGBA` or `RGB`.
862    /// - OpenGL backend: `dst` must be `RGBA`, `BGRA`, or `RGB`.
863    /// - G2D backend: only produces the base frame (empty detections);
864    ///   returns `NotImplemented` when any detection or segmentation is
865    ///   supplied.
866    ///
867    /// # Output contract
868    ///
869    /// This function always fully writes `dst` — it never relies on the
870    /// caller having pre-cleared the destination. The four cases are:
871    ///
872    /// | detections | background | output                              |
873    /// |------------|------------|-------------------------------------|
874    /// | none       | none       | dst cleared to `0x00000000`         |
875    /// | none       | set        | dst ← background                    |
876    /// | set        | none       | masks drawn over cleared dst        |
877    /// | set        | set        | masks drawn over background         |
878    ///
879    /// Each backend implements this with its native primitives: G2D uses
880    /// `g2d_clear` / `g2d_blit`, OpenGL uses `glClear` / DMA-BUF GPU blit
881    /// plus the mask program, and CPU uses direct buffer fill / memcpy as
882    /// the terminal fallback. CPU-memcpy of DMA buffers is avoided on the
883    /// accelerated paths.
884    ///
885    /// An empty `segmentation` slice is valid — only bounding boxes are drawn.
886    ///
887    /// `overlay` controls compositing: `background` is the compositing source
888    /// (must match `dst` in size and format); `opacity` scales mask alpha.
889    ///
890    /// # Buffer aliasing
891    ///
892    /// `dst` and `overlay.background` must reference **distinct underlying
893    /// buffers**. An aliased pair returns [`Error::AliasedBuffers`] without
894    /// dispatching to any backend — the GL path would otherwise read and
895    /// write the same texture in a single draw, which is undefined behaviour
896    /// on most drivers. Aliasing is detected via
897    /// [`TensorDyn::aliases`](edgefirst_tensor::TensorDyn::aliases), which
898    /// catches both shared-allocation clones and separate imports over the
899    /// same dmabuf fd.
900    ///
901    /// # Migration from v0.16.3 and earlier
902    ///
903    /// Prior to v0.16.4 the call silently preserved `dst`'s contents on empty
904    /// detections. That invariant no longer holds — `dst` is always fully
905    /// written. Callers who pre-loaded an image into `dst` before calling this
906    /// function must now pass that image via `overlay.background` instead.
907    fn draw_decoded_masks(
908        &mut self,
909        dst: &mut TensorDyn,
910        detect: &[DetectBox],
911        segmentation: &[Segmentation],
912        overlay: MaskOverlay<'_>,
913    ) -> Result<()>;
914
915    /// Draw masks from proto data onto image (fused decode+draw).
916    ///
917    /// For YOLO segmentation models, this avoids materializing intermediate
918    /// `Array3<u8>` masks. The `ProtoData` contains mask coefficients and the
919    /// prototype tensor; the renderer computes `mask_coeff @ protos` directly
920    /// at the output resolution using bilinear sampling.
921    ///
922    /// `detect` and `proto_data.mask_coefficients` must have the same length
923    /// (enforced by zip — excess entries are silently ignored). An empty
924    /// `detect` slice is valid and produces the base frame — cleared or
925    /// background-blitted — via the selected backend's native primitive.
926    ///
927    /// # Format requirements and output contract
928    ///
929    /// Same as [`draw_decoded_masks`](Self::draw_decoded_masks), including
930    /// the "always fully writes dst" guarantee across all four
931    /// detection/background combinations.
932    ///
933    /// `overlay` controls compositing — see [`draw_decoded_masks`](Self::draw_decoded_masks).
934    fn draw_proto_masks(
935        &mut self,
936        dst: &mut TensorDyn,
937        detect: &[DetectBox],
938        proto_data: &ProtoData,
939        overlay: MaskOverlay<'_>,
940    ) -> Result<()>;
941
942    /// Sets the colors used for rendering segmentation masks. Up to 20 colors
943    /// can be set.
944    fn set_class_colors(&mut self, colors: &[[u8; 4]]) -> Result<()>;
945
946    /// Like [`convert`](Self::convert), but does not wait for the GPU to finish.
947    ///
948    /// This is the batch-preprocessing primitive: a caller renders `N` tiles
949    /// into one batched destination by looping
950    /// `convert_deferred(&src[n], &mut dst.batch(n)?, …)` and then calling
951    /// [`flush`](Self::flush) **once**. On the OpenGL backend every deferred
952    /// convert into sibling views of one buffer shares a single EGLImage import
953    /// (the tile is a `glViewport`/`glScissor` ROI into the parent) and skips the
954    /// per-tile `glFinish`; `flush` then issues a single GPU sync. The result of
955    /// a deferred convert is **not** safe to read on the CPU (or map via CUDA)
956    /// until `flush` returns.
957    ///
958    /// The default implementation is eager — it simply calls
959    /// [`convert`](Self::convert), so CPU/G2D and any backend without a deferred
960    /// fast path remain correct (each call completes synchronously and `flush`
961    /// is a no-op).
962    fn convert_deferred(
963        &mut self,
964        src: &TensorDyn,
965        dst: &mut TensorDyn,
966        rotation: Rotation,
967        flip: Flip,
968        crop: Crop,
969    ) -> Result<()> {
970        self.convert(src, dst, rotation, flip, crop)
971    }
972
973    /// Complete all work enqueued by [`convert_deferred`](Self::convert_deferred)
974    /// since the last flush, issuing a single GPU synchronization.
975    ///
976    /// After this returns, every deferred destination is finished and safe to
977    /// read back or `cuda_map`. Backends with no deferred path (the default)
978    /// return `Ok(())` immediately, since their converts already completed.
979    fn flush(&mut self) -> Result<()> {
980        Ok(())
981    }
982}
983
984/// Configuration for [`ImageProcessor`] construction.
985///
986/// Use with [`ImageProcessor::with_config`] to override the default EGL
987/// display auto-detection and backend selection. The default configuration
988/// preserves the existing auto-detection behaviour.
989#[derive(Debug, Clone, Default)]
990pub struct ImageProcessorConfig {
991    /// Force OpenGL to use this EGL display type instead of auto-detecting.
992    ///
993    /// When `None`, the processor probes displays in priority order: GBM,
994    /// PlatformDevice, Default. Use [`probe_egl_displays`] to discover
995    /// which displays are available on the current system.
996    ///
997    /// Ignored when `EDGEFIRST_DISABLE_GL=1` is set, and on macOS
998    /// (ANGLE/Metal is the only display there; a `Some` value logs a
999    /// debug note and is otherwise ignored).
1000    #[cfg(all(
1001        any(
1002            target_os = "linux",
1003            target_os = "macos",
1004            target_os = "ios",
1005            target_os = "android"
1006        ),
1007        feature = "opengl"
1008    ))]
1009    pub egl_display: Option<EglDisplayKind>,
1010
1011    /// Preferred compute backend.
1012    ///
1013    /// When set to a specific backend (not [`ComputeBackend::Auto`]), the
1014    /// processor initializes that backend with no fallback — returns an error if the conversion is not supported.
1015    /// This takes precedence over `EDGEFIRST_FORCE_BACKEND` and the
1016    /// `EDGEFIRST_DISABLE_*` environment variables.
1017    ///
1018    /// - [`ComputeBackend::OpenGl`]: init OpenGL + CPU, skip G2D
1019    /// - [`ComputeBackend::G2d`]: init G2D + CPU, skip OpenGL
1020    /// - [`ComputeBackend::Cpu`]: init CPU only
1021    /// - [`ComputeBackend::Auto`]: existing env-var-driven selection
1022    pub backend: ComputeBackend,
1023
1024    /// Colorimetry/performance trade-off for `convert()` (see
1025    /// [`ColorimetryMode`]). Defaults to [`ColorimetryMode::Fast`]. The
1026    /// `EDGEFIRST_COLORIMETRY` environment variable (`fast` | `exact`)
1027    /// overrides this setting when present.
1028    pub colorimetry: ColorimetryMode,
1029}
1030
1031/// How `convert()` trades colorimetric exactness against speed on platforms
1032/// where the exact path is expensive.
1033///
1034/// Today this affects one decision: NV12 sources on Vivante GC7000UL
1035/// (i.MX 8M Plus), where the hardware external sampler converts ~12× faster
1036/// than the colorimetry-exact in-shader matrix (2.5 ms vs 29 ms at 720p)
1037/// but applies the driver's fixed BT.601-limited matrix regardless of the
1038/// source's tagged colorimetry. Platforms where the exact path is already
1039/// the fastest correct path (Mali, V3D, Tegra, ANGLE) behave identically in
1040/// both modes.
1041///
1042/// Override at runtime with `EDGEFIRST_COLORIMETRY=fast|exact` (takes
1043/// precedence over the config field), or per-source by forcing a path with
1044/// `EDGEFIRST_NV_CONVERT_PATH`.
1045#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1046pub enum ColorimetryMode {
1047    /// Prefer the fastest path whose output is correct-enough video RGB
1048    /// (default; issue #106 policy). On Vivante, NV12 takes the hardware
1049    /// sampler even when the source is not BT.601-limited.
1050    #[default]
1051    Fast,
1052    /// Prefer bit-exact colorimetry everywhere: the fast path is used only
1053    /// when it matches the source's resolved (encoding, range) exactly.
1054    Exact,
1055}
1056
1057/// Compute backend selection for [`ImageProcessor`].
1058///
1059/// Use with [`ImageProcessorConfig::backend`] to select which backend the
1060/// processor should prefer. When a specific backend is selected, the
1061/// processor initializes that backend plus CPU as a fallback. When `Auto`
1062/// is used, the existing environment-variable-driven selection applies.
1063#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1064pub enum ComputeBackend {
1065    /// Auto-detect based on available hardware and environment variables.
1066    #[default]
1067    Auto,
1068    /// CPU-only processing (no hardware acceleration).
1069    Cpu,
1070    /// Prefer G2D hardware blitter (+ CPU fallback).
1071    G2d,
1072    /// Prefer OpenGL ES (+ CPU fallback).
1073    OpenGl,
1074}
1075
1076/// Backend forced via the `EDGEFIRST_FORCE_BACKEND` environment variable
1077/// or [`ImageProcessorConfig::backend`].
1078///
1079/// When set, the [`ImageProcessor`] only initializes and dispatches to the
1080/// selected backend — no fallback chain is used.
1081#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1082pub(crate) enum ForcedBackend {
1083    Cpu,
1084    G2d,
1085    OpenGl,
1086}
1087
1088/// Reports which float-color-buffer extensions the GPU backend detected.
1089/// Returned by [`ImageProcessor::supported_render_dtypes`]; the two flags
1090/// are independent.
1091///
1092/// **Linux:** reflects the real probe results from `GL_EXT_color_buffer_half_float`
1093/// and `GL_EXT_color_buffer_float`. On V3D (RPi 5) and Mali-G310 (i.MX 95)
1094/// both flags are typically `true`; on Vivante GC7000UL both are forced
1095/// `false` (float readback measured 170–320 ms — disabled). Tegra Orin
1096/// exposes both via PBO; the flags match the GPU report.
1097///
1098/// **macOS (ANGLE):** `f16 == true` gates the RGBA16F-packed IOSurface
1099/// path for F16 `PlanarRgb` destinations. `f32` reflects the GL
1100/// extension probe but is not actionable — ANGLE's
1101/// `EGL_ANGLE_iosurface_client_buffer` rejects every `(GL_FLOAT, *)`
1102/// combination with `EGL_BAD_ATTRIBUTE`, so there is no F32 IOSurface
1103/// path.
1104///
1105/// Regardless of these flags, [`ImageProcessor::convert`] never returns
1106/// an error due to float capability — it falls back to CPU when the GPU
1107/// path is unavailable.
1108#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1109pub struct RenderDtypeSupport {
1110    /// `GL_EXT_color_buffer_float` is available on the current GPU.
1111    ///
1112    /// On Linux, `true` enables F32 `Rgb` NHWC PBO readback. On macOS
1113    /// this flag is informational only — no F32 IOSurface path exists.
1114    pub f32: bool,
1115    /// `GL_EXT_color_buffer_half_float` is available on the current GPU.
1116    ///
1117    /// On Linux, `true` enables F16 `PlanarRgb` NCHW PBO readback and,
1118    /// on V3D/Mali, zero-copy DMA-BUF render. On macOS, `true` enables
1119    /// F16 `PlanarRgb` via RGBA16F-packed IOSurface (zero-copy).
1120    pub f16: bool,
1121}
1122
1123/// Returns `true` when a float PBO destination should be attempted for `dtype`.
1124///
1125/// Only F16 and F32 are eligible, and only when the corresponding flag in
1126/// `support` is set. U8/I8 and all other dtypes return `false` — they are
1127/// handled by the existing `dtype.size() == 1` PBO gate.
1128///
1129/// Linux-only: the float PBO readback path is the Linux GL backend's
1130/// mechanism; macOS routes F16 through the RGBA16F-packed IOSurface
1131/// instead and never calls this. The sole runtime caller in
1132/// `create_image` is `cfg(all(target_os = "linux", feature = "opengl"))`,
1133/// so leaving this ungated makes it dead code on macOS under
1134/// `-D warnings`. Its unit test (`float_pbo_eligibility`) carries the
1135/// matching gate.
1136#[cfg(all(target_os = "linux", feature = "opengl"))]
1137pub(crate) fn float_pbo_eligible(dtype: DType, support: RenderDtypeSupport) -> bool {
1138    match dtype {
1139        DType::F16 => support.f16,
1140        DType::F32 => support.f32,
1141        _ => false,
1142    }
1143}
1144
1145/// Image converter that uses available hardware acceleration or CPU as a
1146/// fallback.
1147#[derive(Debug)]
1148pub struct ImageProcessor {
1149    /// CPU-based image converter as a fallback. This is only None if the
1150    /// EDGEFIRST_DISABLE_CPU environment variable is set.
1151    pub cpu: Option<CPUProcessor>,
1152
1153    #[cfg(target_os = "linux")]
1154    /// G2D-based image converter for Linux systems. This is only available if
1155    /// the EDGEFIRST_DISABLE_G2D environment variable is not set and libg2d.so
1156    /// is available.
1157    pub g2d: Option<G2DProcessor>,
1158    #[cfg(target_os = "linux")]
1159    #[cfg(feature = "opengl")]
1160    /// OpenGL-based image converter for Linux systems. This is only available
1161    /// if the EDGEFIRST_DISABLE_GL environment variable is not set and OpenGL
1162    /// ES is available.
1163    pub opengl: Option<GLProcessorThreaded>,
1164    #[cfg(any(target_os = "macos", target_os = "ios", target_os = "android"))]
1165    #[cfg(feature = "opengl")]
1166    /// OpenGL-based image converter — the same unified
1167    /// `GLProcessorThreaded` engine as Linux (its worker owns a
1168    /// per-processor context). macOS/iOS run it via ANGLE + IOSurface
1169    /// (available when ANGLE's libEGL.dylib can be loaded — see
1170    /// README.md § macOS GPU Acceleration); Android runs it via the
1171    /// native EGL driver + AHardwareBuffer.
1172    pub opengl: Option<GLProcessorThreaded>,
1173
1174    /// When set, only the specified backend is used — no fallback chain.
1175    pub(crate) forced_backend: Option<ForcedBackend>,
1176
1177    /// Converts where the GL backend declined and the auto chain fell
1178    /// through toward G2D/CPU. Owned by the dispatcher (the GL worker never
1179    /// sees these frames), unlike the per-feed counters in
1180    /// `GLProcessorThreaded::convert_stats`. Read via
1181    /// [`convert_fallback_count`](Self::convert_fallback_count).
1182    pub(crate) convert_fallbacks: std::sync::atomic::AtomicU64,
1183}
1184
1185unsafe impl Send for ImageProcessor {}
1186unsafe impl Sync for ImageProcessor {}
1187
1188impl ImageProcessor {
1189    /// Creates a new `ImageProcessor` instance, initializing available
1190    /// hardware converters based on the system capabilities and environment
1191    /// variables.
1192    ///
1193    /// # Examples
1194    /// ```rust,no_run
1195    /// # use edgefirst_image::{ImageProcessor, Rotation, Flip, Crop, ImageProcessorTrait};
1196    /// # use edgefirst_codec::{peek_info, ImageDecoder, ImageLoad};
1197    /// # use edgefirst_tensor::{CpuAccess, PixelFormat, DType, Tensor, TensorMemory};
1198    /// # fn main() -> Result<(), edgefirst_image::Error> {
1199    /// let image = std::fs::read("zidane.jpg")?;
1200    /// // The codec emits the source's native format (a colour JPEG decodes to
1201    /// // NV12) and configures the destination tensor during the decode.
1202    /// let info = peek_info(&image).expect("peek");
1203    /// let mut src = Tensor::<u8>::image(info.width, info.height, info.format,
1204    ///                                    Some(TensorMemory::Mem), CpuAccess::ReadWrite)?;
1205    /// let mut decoder = ImageDecoder::new();
1206    /// src.load_image(&mut decoder, &image).expect("decode");
1207    /// let mut converter = ImageProcessor::new()?;
1208    /// let mut dst =
1209    ///     converter.create_image(640, 480, PixelFormat::Rgb, DType::U8, None, CpuAccess::ReadWrite)?;
1210    /// converter.convert(&src.into(), &mut dst, Rotation::None, Flip::None, Crop::default())?;
1211    /// # Ok(())
1212    /// # }
1213    /// ```
1214    pub fn new() -> Result<Self> {
1215        Self::with_config(ImageProcessorConfig::default())
1216    }
1217
1218    /// Number of converts where the auto chain's GL backend declined and the
1219    /// frame fell through toward G2D/CPU — each one silently lost any
1220    /// zero-copy guarantee. A pipeline that expects to stay on the GPU
1221    /// asserts this stays flat across its steady-state loop; pair with
1222    /// `GLProcessorThreaded::convert_stats` to see how the frames that DID
1223    /// reach GL were fed. Always 0 under a forced backend (no chain).
1224    pub fn convert_fallback_count(&self) -> u64 {
1225        self.convert_fallbacks
1226            .load(std::sync::atomic::Ordering::Relaxed)
1227    }
1228
1229    /// Number of [`Compression::Any`](edgefirst_tensor::Compression)
1230    /// requests since process start that resolved to a linear layout
1231    /// (process-wide mirror of
1232    /// [`edgefirst_tensor::compression_fallback_count`], surfaced here
1233    /// beside [`convert_fallback_count`](Self::convert_fallback_count)
1234    /// so pipeline telemetry reads from one place). A pipeline that
1235    /// expects compressed convert destinations asserts this stays flat
1236    /// after warmup.
1237    pub fn compression_fallback_count(&self) -> u64 {
1238        edgefirst_tensor::compression_fallback_count()
1239    }
1240
1241    /// Convert and return a native sync-fence fd that signals when the
1242    /// GPU work completes, instead of blocking the CPU — the GL→NPU
1243    /// handoff (`EGL_ANDROID_native_fence_sync` on Android).
1244    ///
1245    /// `Ok(Some(fd))`: the destination buffer is still in flight; hand
1246    /// the fd to the consumer (e.g.
1247    /// `ANeuralNetworksExecution_startComputeWithDependencies`) or
1248    /// `poll()` it before reading. `Ok(None)`: the convert completed with
1249    /// the normal blocking contract (no native fence on this platform, or
1250    /// a non-GL backend handled the frame) — the destination is already
1251    /// safe. Semantics are otherwise identical to
1252    /// [`convert`](ImageProcessorTrait::convert), including the
1253    /// GL→G2D→CPU fallback chain.
1254    #[cfg(unix)]
1255    pub fn convert_with_fence(
1256        &mut self,
1257        src: &TensorDyn,
1258        dst: &mut TensorDyn,
1259        rotation: Rotation,
1260        flip: Flip,
1261        crop: Crop,
1262    ) -> Result<Option<std::os::fd::OwnedFd>> {
1263        #[cfg(any(
1264            target_os = "linux",
1265            target_os = "macos",
1266            target_os = "ios",
1267            target_os = "android"
1268        ))]
1269        #[cfg(feature = "opengl")]
1270        {
1271            let gl_forced = matches!(self.forced_backend, Some(ForcedBackend::OpenGl));
1272            if self.forced_backend.is_none() || gl_forced {
1273                if let Some(opengl) = self.opengl.as_mut() {
1274                    match opengl.convert_with_fence(src, dst, rotation, flip, crop) {
1275                        Ok(fd) => return Ok(fd),
1276                        Err(e) if gl_forced => return Err(e),
1277                        Err(e) => {
1278                            // Not counted here: the blocking chain below
1279                            // re-runs the dispatcher, whose decline arm
1280                            // counts the fallback exactly once.
1281                            log::debug!(
1282                                "convert_with_fence: opengl declined, \
1283                                 falling back to the blocking chain: {e}"
1284                            );
1285                        }
1286                    }
1287                } else if gl_forced {
1288                    return Err(Error::ForcedBackendUnavailable("opengl".into()));
1289                }
1290            }
1291        }
1292        // Blocking chain (G2D/CPU, forced non-GL, or non-GL builds):
1293        // completion == return, so no fence is needed.
1294        self.convert(src, dst, rotation, flip, crop)?;
1295        Ok(None)
1296    }
1297
1298    /// Report which float dtypes the GPU can render to.
1299    ///
1300    /// Probes `GL_EXT_color_buffer_half_float` and
1301    /// `GL_EXT_color_buffer_float` once at `ImageProcessor::new()` time
1302    /// and caches the result. Call this once at startup to decide whether
1303    /// to request F16 or F32 destination tensors; [`create_image`] uses
1304    /// the result internally to auto-select float PBO when supported.
1305    ///
1306    /// Returns `RenderDtypeSupport { f32: false, f16: false }` when no
1307    /// OpenGL backend is active or the `opengl` feature is disabled.
1308    ///
1309    /// [`create_image`]: Self::create_image
1310    pub fn supported_render_dtypes(&self) -> RenderDtypeSupport {
1311        #[cfg(all(
1312            any(target_os = "macos", target_os = "ios", target_os = "android"),
1313            feature = "opengl"
1314        ))]
1315        if let Some(gl) = self.opengl.as_ref() {
1316            return gl.supported_render_dtypes();
1317        }
1318        #[cfg(all(target_os = "linux", feature = "opengl"))]
1319        if let Some(gl) = self.opengl.as_ref() {
1320            return gl.supported_render_dtypes();
1321        }
1322        RenderDtypeSupport {
1323            f32: false,
1324            f16: false,
1325        }
1326    }
1327
1328    /// Creates a new `ImageProcessor` with the given configuration.
1329    ///
1330    /// When [`ImageProcessorConfig::backend`] is set to a specific backend,
1331    /// environment variables are ignored and the processor initializes the
1332    /// requested backend plus CPU as a fallback.
1333    ///
1334    /// When `Auto`, the existing `EDGEFIRST_FORCE_BACKEND` and
1335    /// `EDGEFIRST_DISABLE_*` environment variables apply.
1336    #[allow(unused_variables)]
1337    pub fn with_config(config: ImageProcessorConfig) -> Result<Self> {
1338        // ── Config-driven backend selection ──────────────────────────
1339        // When the caller explicitly requests a backend via the config,
1340        // skip all environment variable logic.
1341        match config.backend {
1342            ComputeBackend::Cpu => {
1343                log::info!("ComputeBackend::Cpu — CPU only");
1344                return Ok(Self {
1345                    cpu: Some(CPUProcessor::new()),
1346                    #[cfg(target_os = "linux")]
1347                    g2d: None,
1348                    #[cfg(target_os = "linux")]
1349                    #[cfg(feature = "opengl")]
1350                    opengl: None,
1351                    #[cfg(any(target_os = "macos", target_os = "ios", target_os = "android"))]
1352                    #[cfg(feature = "opengl")]
1353                    opengl: None,
1354                    convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1355                    forced_backend: None,
1356                });
1357            }
1358            ComputeBackend::G2d => {
1359                log::info!("ComputeBackend::G2d — G2D + CPU fallback");
1360                #[cfg(target_os = "linux")]
1361                {
1362                    let g2d = match G2DProcessor::new() {
1363                        Ok(g) => Some(g),
1364                        Err(e) => {
1365                            log::warn!("G2D requested but failed to initialize: {e:?}");
1366                            None
1367                        }
1368                    };
1369                    return Ok(Self {
1370                        cpu: Some(CPUProcessor::new()),
1371                        g2d,
1372                        #[cfg(feature = "opengl")]
1373                        opengl: None,
1374                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1375                        forced_backend: None,
1376                    });
1377                }
1378                #[cfg(not(target_os = "linux"))]
1379                {
1380                    log::warn!("G2D requested but not available on this platform, using CPU");
1381                    return Ok(Self {
1382                        cpu: Some(CPUProcessor::new()),
1383                        #[cfg(any(target_os = "macos", target_os = "ios", target_os = "android"))]
1384                        #[cfg(feature = "opengl")]
1385                        opengl: None,
1386                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1387                        forced_backend: None,
1388                    });
1389                }
1390            }
1391            ComputeBackend::OpenGl => {
1392                log::info!("ComputeBackend::OpenGl — OpenGL + CPU fallback");
1393                #[cfg(target_os = "linux")]
1394                {
1395                    #[cfg(feature = "opengl")]
1396                    let opengl = match GLProcessorThreaded::new(config.egl_display) {
1397                        Ok(gl) => Some(gl),
1398                        Err(e) => {
1399                            log::warn!("OpenGL requested but failed to initialize: {e:?}");
1400                            None
1401                        }
1402                    };
1403                    return Ok(Self {
1404                        cpu: Some(CPUProcessor::new()),
1405                        g2d: None,
1406                        #[cfg(feature = "opengl")]
1407                        opengl,
1408                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1409                        forced_backend: None,
1410                    }
1411                    .apply_colorimetry_mode(config.colorimetry));
1412                }
1413                #[cfg(any(target_os = "macos", target_os = "ios"))]
1414                {
1415                    #[cfg(feature = "opengl")]
1416                    let opengl = match GLProcessorThreaded::new(config.egl_display) {
1417                        Ok(gl) => Some(gl),
1418                        Err(e) => {
1419                            log::warn!(
1420                                "OpenGL requested on macOS but ANGLE init failed: {e:?} \
1421                                 (install ANGLE via `brew install startergo/angle/angle` \
1422                                 and re-sign the dylibs — see README.md § macOS GPU \
1423                                 Acceleration). Falling back to CPU."
1424                            );
1425                            None
1426                        }
1427                    };
1428                    return Ok(Self {
1429                        cpu: Some(CPUProcessor::new()),
1430                        #[cfg(feature = "opengl")]
1431                        opengl,
1432                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1433                        forced_backend: None,
1434                    }
1435                    .apply_colorimetry_mode(config.colorimetry));
1436                }
1437                #[cfg(target_os = "android")]
1438                {
1439                    #[cfg(feature = "opengl")]
1440                    let opengl = match GLProcessorThreaded::new(config.egl_display) {
1441                        Ok(gl) => Some(gl),
1442                        Err(e) => {
1443                            log::warn!(
1444                                "OpenGL requested but native EGL init failed: {e:?}. \
1445                                 Falling back to CPU."
1446                            );
1447                            None
1448                        }
1449                    };
1450                    return Ok(Self {
1451                        cpu: Some(CPUProcessor::new()),
1452                        #[cfg(feature = "opengl")]
1453                        opengl,
1454                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1455                        forced_backend: None,
1456                    }
1457                    .apply_colorimetry_mode(config.colorimetry));
1458                }
1459                #[cfg(not(any(
1460                    target_os = "linux",
1461                    target_os = "macos",
1462                    target_os = "ios",
1463                    target_os = "android"
1464                )))]
1465                {
1466                    log::warn!("OpenGL requested but not available on this platform, using CPU");
1467                    return Ok(Self {
1468                        cpu: Some(CPUProcessor::new()),
1469                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1470                        forced_backend: None,
1471                    });
1472                }
1473            }
1474            ComputeBackend::Auto => { /* fall through to env-var logic below */ }
1475        }
1476
1477        // ── EDGEFIRST_FORCE_BACKEND ──────────────────────────────────
1478        // When set, only the requested backend is initialised and no
1479        // fallback chain is used. Accepted values (case-insensitive):
1480        //   "cpu", "g2d", "opengl"
1481        if let Ok(val) = std::env::var("EDGEFIRST_FORCE_BACKEND") {
1482            let val_lower = val.to_lowercase();
1483            let forced = match val_lower.as_str() {
1484                "cpu" => ForcedBackend::Cpu,
1485                "g2d" => ForcedBackend::G2d,
1486                "opengl" => ForcedBackend::OpenGl,
1487                other => {
1488                    return Err(Error::ForcedBackendUnavailable(format!(
1489                        "unknown EDGEFIRST_FORCE_BACKEND value: {other:?} (expected cpu, g2d, or opengl)"
1490                    )));
1491                }
1492            };
1493
1494            log::info!("EDGEFIRST_FORCE_BACKEND={val} — only initializing {val_lower} backend");
1495
1496            return match forced {
1497                ForcedBackend::Cpu => Ok(Self {
1498                    cpu: Some(CPUProcessor::new()),
1499                    #[cfg(target_os = "linux")]
1500                    g2d: None,
1501                    #[cfg(target_os = "linux")]
1502                    #[cfg(feature = "opengl")]
1503                    opengl: None,
1504                    #[cfg(any(target_os = "macos", target_os = "ios", target_os = "android"))]
1505                    #[cfg(feature = "opengl")]
1506                    opengl: None,
1507                    convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1508                    forced_backend: Some(ForcedBackend::Cpu),
1509                }),
1510                ForcedBackend::G2d => {
1511                    #[cfg(target_os = "linux")]
1512                    {
1513                        let g2d = G2DProcessor::new().map_err(|e| {
1514                            Error::ForcedBackendUnavailable(format!(
1515                                "g2d forced but failed to initialize: {e:?}"
1516                            ))
1517                        })?;
1518                        Ok(Self {
1519                            cpu: None,
1520                            g2d: Some(g2d),
1521                            #[cfg(feature = "opengl")]
1522                            opengl: None,
1523                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1524                            forced_backend: Some(ForcedBackend::G2d),
1525                        })
1526                    }
1527                    #[cfg(not(target_os = "linux"))]
1528                    {
1529                        Err(Error::ForcedBackendUnavailable(
1530                            "g2d backend is only available on Linux".into(),
1531                        ))
1532                    }
1533                }
1534                ForcedBackend::OpenGl => {
1535                    #[cfg(target_os = "linux")]
1536                    #[cfg(feature = "opengl")]
1537                    {
1538                        let opengl = GLProcessorThreaded::new(config.egl_display).map_err(|e| {
1539                            Error::ForcedBackendUnavailable(format!(
1540                                "opengl forced but failed to initialize: {e:?}"
1541                            ))
1542                        })?;
1543                        Ok(Self {
1544                            cpu: None,
1545                            g2d: None,
1546                            opengl: Some(opengl),
1547                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1548                            forced_backend: Some(ForcedBackend::OpenGl),
1549                        }
1550                        .apply_colorimetry_mode(config.colorimetry))
1551                    }
1552                    #[cfg(any(target_os = "macos", target_os = "ios"))]
1553                    #[cfg(feature = "opengl")]
1554                    {
1555                        let opengl = GLProcessorThreaded::new(config.egl_display).map_err(|e| {
1556                            Error::ForcedBackendUnavailable(format!(
1557                                "opengl forced on macOS but ANGLE init failed: {e:?}"
1558                            ))
1559                        })?;
1560                        Ok(Self {
1561                            cpu: None,
1562                            opengl: Some(opengl),
1563                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1564                            forced_backend: Some(ForcedBackend::OpenGl),
1565                        }
1566                        .apply_colorimetry_mode(config.colorimetry))
1567                    }
1568                    #[cfg(target_os = "android")]
1569                    #[cfg(feature = "opengl")]
1570                    {
1571                        let opengl = GLProcessorThreaded::new(config.egl_display).map_err(|e| {
1572                            Error::ForcedBackendUnavailable(format!(
1573                                "opengl forced but native EGL init failed: {e:?}"
1574                            ))
1575                        })?;
1576                        Ok(Self {
1577                            cpu: None,
1578                            opengl: Some(opengl),
1579                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1580                            forced_backend: Some(ForcedBackend::OpenGl),
1581                        }
1582                        .apply_colorimetry_mode(config.colorimetry))
1583                    }
1584                    #[cfg(not(all(
1585                        any(
1586                            target_os = "linux",
1587                            target_os = "macos",
1588                            target_os = "ios",
1589                            target_os = "android"
1590                        ),
1591                        feature = "opengl"
1592                    )))]
1593                    {
1594                        Err(Error::ForcedBackendUnavailable(
1595                            "opengl backend requires Linux or macOS with the 'opengl' feature \
1596                             enabled"
1597                                .into(),
1598                        ))
1599                    }
1600                }
1601            };
1602        }
1603
1604        // ── Existing DISABLE logic (unchanged) ──────────────────────
1605        #[cfg(target_os = "linux")]
1606        let g2d = if std::env::var("EDGEFIRST_DISABLE_G2D")
1607            .map(|x| x != "0" && x.to_lowercase() != "false")
1608            .unwrap_or(false)
1609        {
1610            log::debug!("EDGEFIRST_DISABLE_G2D is set");
1611            None
1612        } else {
1613            match G2DProcessor::new() {
1614                Ok(g2d_converter) => Some(g2d_converter),
1615                Err(err) => {
1616                    log::warn!("Failed to initialize G2D converter: {err:?}");
1617                    None
1618                }
1619            }
1620        };
1621
1622        #[cfg(target_os = "linux")]
1623        #[cfg(feature = "opengl")]
1624        let opengl = if std::env::var("EDGEFIRST_DISABLE_GL")
1625            .map(|x| x != "0" && x.to_lowercase() != "false")
1626            .unwrap_or(false)
1627        {
1628            log::debug!("EDGEFIRST_DISABLE_GL is set");
1629            None
1630        } else {
1631            match GLProcessorThreaded::new(config.egl_display) {
1632                Ok(gl_converter) => Some(gl_converter),
1633                Err(err) => {
1634                    log::warn!("Failed to initialize GL converter: {err:?}");
1635                    None
1636                }
1637            }
1638        };
1639
1640        #[cfg(any(target_os = "macos", target_os = "ios", target_os = "android"))]
1641        #[cfg(feature = "opengl")]
1642        let opengl = if std::env::var("EDGEFIRST_DISABLE_GL")
1643            .map(|x| x != "0" && x.to_lowercase() != "false")
1644            .unwrap_or(false)
1645        {
1646            log::debug!("EDGEFIRST_DISABLE_GL is set");
1647            None
1648        } else {
1649            match GLProcessorThreaded::new(config.egl_display) {
1650                Ok(gl_converter) => Some(gl_converter),
1651                Err(err) => {
1652                    log::debug!(
1653                        "GL backend unavailable: {err:?} \
1654                         (CPU fallback will be used)"
1655                    );
1656                    None
1657                }
1658            }
1659        };
1660
1661        let cpu = if std::env::var("EDGEFIRST_DISABLE_CPU")
1662            .map(|x| x != "0" && x.to_lowercase() != "false")
1663            .unwrap_or(false)
1664        {
1665            log::debug!("EDGEFIRST_DISABLE_CPU is set");
1666            None
1667        } else {
1668            Some(CPUProcessor::new())
1669        };
1670        Ok(Self {
1671            cpu,
1672            #[cfg(target_os = "linux")]
1673            g2d,
1674            #[cfg(target_os = "linux")]
1675            #[cfg(feature = "opengl")]
1676            opengl,
1677            #[cfg(any(target_os = "macos", target_os = "ios", target_os = "android"))]
1678            #[cfg(feature = "opengl")]
1679            opengl,
1680            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1681            forced_backend: None,
1682        }
1683        .apply_colorimetry_mode(config.colorimetry))
1684    }
1685
1686    /// Apply the configured [`ColorimetryMode`] to whichever backend honours
1687    /// it (currently the Linux GL backend); no-op elsewhere. Constructor
1688    /// plumbing for [`ImageProcessorConfig::colorimetry`].
1689    fn apply_colorimetry_mode(self, _mode: ColorimetryMode) -> Self {
1690        #[cfg(all(
1691            any(
1692                target_os = "linux",
1693                target_os = "macos",
1694                target_os = "ios",
1695                target_os = "android"
1696            ),
1697            feature = "opengl"
1698        ))]
1699        {
1700            let mut me = self;
1701            if let Err(e) = me.set_colorimetry_mode(_mode) {
1702                log::warn!("Failed to apply ColorimetryMode::{_mode:?}: {e:?}");
1703            }
1704            me
1705        }
1706        #[cfg(not(all(
1707            any(
1708                target_os = "linux",
1709                target_os = "macos",
1710                target_os = "ios",
1711                target_os = "android"
1712            ),
1713            feature = "opengl"
1714        )))]
1715        {
1716            let _ = _mode;
1717            self
1718        }
1719    }
1720
1721    /// Sets the colorimetry/performance trade-off (see [`ColorimetryMode`])
1722    /// on the OpenGL backend. No-op if OpenGL is not available. The
1723    /// `EDGEFIRST_COLORIMETRY` environment variable takes precedence — when
1724    /// it is set, this call logs and keeps the env-selected mode.
1725    #[cfg(all(
1726        any(
1727            target_os = "linux",
1728            target_os = "macos",
1729            target_os = "ios",
1730            target_os = "android"
1731        ),
1732        feature = "opengl"
1733    ))]
1734    pub fn set_colorimetry_mode(&mut self, mode: ColorimetryMode) -> Result<()> {
1735        if let Some(ref mut gl) = self.opengl {
1736            gl.set_colorimetry_mode(mode)?;
1737        }
1738        Ok(())
1739    }
1740
1741    /// Sets the interpolation mode for int8 proto textures on the OpenGL
1742    /// backend. No-op if OpenGL is not available.
1743    #[cfg(all(
1744        any(
1745            target_os = "linux",
1746            target_os = "macos",
1747            target_os = "ios",
1748            target_os = "android"
1749        ),
1750        feature = "opengl"
1751    ))]
1752    pub fn set_int8_interpolation_mode(&mut self, mode: Int8InterpolationMode) -> Result<()> {
1753        if let Some(ref mut gl) = self.opengl {
1754            gl.set_int8_interpolation_mode(mode)?;
1755        }
1756        Ok(())
1757    }
1758
1759    /// Create a [`TensorDyn`] image with the best available memory backend.
1760    ///
1761    /// Priority: DMA-buf → float PBO (F16/F32) → u8/i8 PBO → system memory.
1762    ///
1763    /// Use this method instead of [`TensorDyn::image()`] when the tensor will
1764    /// be used with [`ImageProcessor::convert()`]. It selects the optimal
1765    /// memory backing (including PBO for GPU zero-copy) which direct
1766    /// allocation cannot achieve.
1767    ///
1768    /// This method is on [`ImageProcessor`] rather than [`ImageProcessorTrait`]
1769    /// because optimal allocation requires knowledge of the active compute
1770    /// backends (e.g. the GL context handle for PBO allocation). Individual
1771    /// backend implementations ([`CPUProcessor`], etc.) do not have this
1772    /// cross-backend visibility.
1773    ///
1774    /// **Float dtype behaviour:** when `dtype` is `F16` or `F32` and
1775    /// [`supported_render_dtypes`] reports the GPU supports that type,
1776    /// `memory: None` auto-selects a float PBO (Linux) or IOSurface (macOS
1777    /// F16 only). If GPU float support is absent the allocation falls through
1778    /// to `TensorMemory::Mem`; [`convert`] then uses the CPU path.
1779    /// Passing `memory: Some(TensorMemory::Dma)` with `dtype: F32` always
1780    /// returns `Error::NotSupported` — no 32-bit-float DRM fourcc exists.
1781    ///
1782    /// [`supported_render_dtypes`]: Self::supported_render_dtypes
1783    /// [`convert`]: ImageProcessorTrait::convert
1784    ///
1785    /// # Arguments
1786    ///
1787    /// * `width` - Image width in pixels
1788    /// * `height` - Image height in pixels
1789    /// * `format` - Pixel format
1790    /// * `dtype` - Element data type (e.g. `DType::U8`, `DType::F16`, `DType::F32`)
1791    /// * `memory` - Optional memory type override; when `None`, the best
1792    ///   available backend is selected automatically.
1793    ///
1794    /// # Returns
1795    ///
1796    /// A [`TensorDyn`] backed by the highest-performance memory type
1797    /// available on this system.
1798    ///
1799    /// # Pitch alignment for DMA-backed allocations
1800    ///
1801    /// DMA-BUF imports into the GL backend (Mali Valhall on i.MX 95
1802    /// specifically) require every row pitch to be a multiple of
1803    /// [`GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES`] (currently 64). When this
1804    /// method lands on `TensorMemory::Dma`, the underlying allocation is
1805    /// silently padded so the row stride satisfies that requirement.
1806    ///
1807    /// **The user-requested `width` is preserved** — `tensor.width()`
1808    /// returns the same value you passed in. The padding is carried by
1809    /// [`TensorDyn::row_stride`] / `effective_row_stride()`, which the
1810    /// GL backend reads when importing the buffer as an EGLImage.
1811    /// Callers that compute byte offsets from the tensor must use the
1812    /// stride, not `width × bytes_per_pixel`; the CPU mapping spans the
1813    /// full `stride × height` bytes.
1814    ///
1815    /// Pre-aligned widths (640, 1280, 1920, 3008, 3840 …) allocate
1816    /// exactly `width × bpp × height` bytes with no padding. PBO and
1817    /// Mem fallbacks never pad — they don't go through EGLImage import.
1818    ///
1819    /// See also [`align_width_for_gpu_pitch`] for an advisory helper
1820    /// that external callers (GStreamer plugins, video pipelines) can
1821    /// use to size their own DMA-BUFs for GL compatibility.
1822    ///
1823    /// # Errors
1824    ///
1825    /// Returns an error if all allocation strategies fail.
1826    /// Allocate an image tensor from a declarative
1827    /// [`ImageDesc`](edgefirst_tensor::ImageDesc) request — the
1828    /// full-featured variant of [`create_image`](Self::create_image).
1829    ///
1830    /// Without a compression request this is exactly `create_image` (the
1831    /// processor's memory negotiation applies). With one, the allocation
1832    /// rides the tensor desc path un-negotiated: the layout decision
1833    /// belongs to the platform allocator, and the request's guards and
1834    /// fallback counting live there (see
1835    /// [`Tensor::image_desc`](edgefirst_tensor::Tensor::image_desc)).
1836    pub fn create_image_desc(&self, desc: &edgefirst_tensor::ImageDesc) -> Result<TensorDyn> {
1837        if desc.compression().is_none() {
1838            return self.create_image(
1839                desc.width(),
1840                desc.height(),
1841                desc.format(),
1842                desc.dtype(),
1843                desc.memory(),
1844                desc.access(),
1845            );
1846        }
1847        Ok(TensorDyn::image_desc(desc)?)
1848    }
1849
1850    pub fn create_image(
1851        &self,
1852        width: usize,
1853        height: usize,
1854        format: PixelFormat,
1855        dtype: DType,
1856        memory: Option<TensorMemory>,
1857        access: edgefirst_tensor::CpuAccess,
1858    ) -> Result<TensorDyn> {
1859        // Compute the GPU-aligned row stride in bytes for this image.
1860        // `None` means either the format has no defined primary-plane bpp
1861        // (unknown future layout) or the stride calculation would overflow
1862        // — in both cases we fall back to the natural layout via the plain
1863        // `TensorDyn::image` constructor, and the slow-path warning inside
1864        // `draw_*_masks` will fire if the subsequent GL import fails.
1865        //
1866        // DMA allocation is Linux-only (see `TensorMemory::Dma` cfg gate),
1867        // so both the stride computation and the helper closure are gated
1868        // accordingly — the callers below are already Linux-only.
1869        #[cfg(target_os = "linux")]
1870        let dma_stride_bytes: Option<usize> = primary_plane_bpp(format, dtype.size())
1871            .and_then(|bpp| width.checked_mul(bpp))
1872            .and_then(align_pitch_bytes_to_gpu_alignment);
1873
1874        // Helper: allocate a DMA image, using the padded-stride constructor
1875        // when the computed stride exceeds the natural pitch, otherwise the
1876        // plain constructor (byte-identical result in the common case).
1877        #[cfg(target_os = "linux")]
1878        let try_dma = || -> Result<TensorDyn> {
1879            // Stride padding is only meaningful for packed pixel layouts
1880            // (RGBA8, BGRA8, RGB888, Grey) — the formats the GL backend
1881            // renders into. Semi-planar (NV12, NV16) and planar (PlanarRgb,
1882            // PlanarRgba) tensors go through `TensorDyn::image(...)` with
1883            // their natural layout; they're imported from camera capture
1884            // via `from_fd` far more often than allocated here, and
1885            // `Tensor::image_with_stride` explicitly rejects them.
1886            let packed = format.layout() == edgefirst_tensor::PixelLayout::Packed;
1887            match dma_stride_bytes {
1888                Some(stride)
1889                    if packed
1890                        && primary_plane_bpp(format, dtype.size())
1891                            .and_then(|bpp| width.checked_mul(bpp))
1892                            .is_some_and(|natural| stride > natural) =>
1893                {
1894                    log::debug!(
1895                        "create_image: padding row stride for {format:?} {width}x{height} \
1896                         from natural pitch to {stride} bytes for GPU alignment"
1897                    );
1898                    Ok(TensorDyn::image_with_stride(
1899                        width,
1900                        height,
1901                        format,
1902                        dtype,
1903                        stride,
1904                        Some(edgefirst_tensor::TensorMemory::Dma),
1905                        access,
1906                    )?)
1907                }
1908                _ => Ok(TensorDyn::image(
1909                    width,
1910                    height,
1911                    format,
1912                    dtype,
1913                    Some(edgefirst_tensor::TensorMemory::Dma),
1914                    access,
1915                )?),
1916            }
1917        };
1918
1919        // If an explicit memory type is requested, honour it directly.
1920        // On Linux, `TensorMemory::Dma` gets the padded-stride treatment;
1921        // other memory types take the user-requested width verbatim.
1922        // On macOS, `TensorMemory::Dma` dispatches through `TensorDyn::image`
1923        // which selects the IOSurface allocation path (FourCC-formatted)
1924        // for image-mappable formats, or falls back to SHM/Mem otherwise.
1925        match memory {
1926            #[cfg(target_os = "linux")]
1927            Some(TensorMemory::Dma) => {
1928                // F32 has no 32-bit-float DRM fourcc; callers must use PBO instead.
1929                if dtype == DType::F32 {
1930                    return Err(Error::NotSupported(
1931                        "F32 has no 32-bit-float DRM format for DMA-BUF; \
1932                         use TensorMemory::Pbo for F32"
1933                            .to_string(),
1934                    ));
1935                }
1936                return try_dma();
1937            }
1938            Some(mem) => {
1939                return Ok(TensorDyn::image(
1940                    width,
1941                    height,
1942                    format,
1943                    dtype,
1944                    Some(mem),
1945                    access,
1946                )?);
1947            }
1948            None => {}
1949        }
1950
1951        // macOS: when the GL backend is active with the IOSurface
1952        // transfer path, prefer Dma (IOSurface on Apple, AHardwareBuffer
1953        // on Android) for zero-copy import. Formats without a zero-copy
1954        // mapping now ERROR under explicit Dma (the explicit-Dma
1955        // contract), so auto-select catches that error here and falls
1956        // back to host storage — loudly, via the debug log below.
1957        #[cfg(any(target_os = "macos", target_os = "ios", target_os = "android"))]
1958        #[cfg(feature = "opengl")]
1959        if let Some(gl) = self.opengl.as_ref() {
1960            let _ = gl; // probe_transfer_backend lives behind the platform trait
1961            match TensorDyn::image(
1962                width,
1963                height,
1964                format,
1965                dtype,
1966                Some(edgefirst_tensor::TensorMemory::Dma),
1967                access,
1968            ) {
1969                Ok(img) => return Ok(img),
1970                Err(e) => {
1971                    // Falling back to a non-zero-copy destination is a real
1972                    // perf cliff — never do it silently (on-device triage
1973                    // starts from this line).
1974                    log::debug!(
1975                        "create_image: zero-copy Dma allocation declined \
1976                         ({format:?}/{dtype:?} {width}x{height}): {e:?}; using fallback storage"
1977                    );
1978                }
1979            }
1980        }
1981
1982        // Try DMA first on Linux — skip only when GL has explicitly selected PBO
1983        // as the preferred transfer path (PBO is better than DMA in that case).
1984        #[cfg(target_os = "linux")]
1985        {
1986            #[cfg(feature = "opengl")]
1987            let gl_uses_pbo = self
1988                .opengl
1989                .as_ref()
1990                .is_some_and(|gl| gl.transfer_backend() == opengl_headless::TransferBackend::Pbo);
1991            #[cfg(not(feature = "opengl"))]
1992            let gl_uses_pbo = false;
1993
1994            if !gl_uses_pbo {
1995                if let Ok(img) = try_dma() {
1996                    return Ok(img);
1997                }
1998            }
1999        }
2000
2001        // Try PBO (if GL available).
2002        // PBO buffers are u8-sized; the int8 shader emulates i8 output via
2003        // XOR 0x80 on the same underlying buffer, so both U8 and I8 work.
2004        #[cfg(target_os = "linux")]
2005        #[cfg(feature = "opengl")]
2006        if dtype.size() == 1 {
2007            if let Some(gl) = &self.opengl {
2008                match gl.create_pbo_image(width, height, format) {
2009                    Ok(t) => {
2010                        if dtype == DType::I8 {
2011                            // SAFETY: Tensor<u8> and Tensor<i8> are layout-
2012                            // identical (same element size, no T-dependent
2013                            // drop glue). The int8 shader applies XOR 0x80
2014                            // on the same PBO buffer. Same rationale as
2015                            // gl::processor::tensor_i8_as_u8_mut.
2016                            // Invariant: PBO tensors never have chroma
2017                            // (create_pbo_image → Tensor::wrap sets it None).
2018                            debug_assert!(
2019                                t.chroma().is_none(),
2020                                "PBO i8 transmute requires chroma == None"
2021                            );
2022                            let t_i8: Tensor<i8> = unsafe { std::mem::transmute(t) };
2023                            return Ok(TensorDyn::from(t_i8));
2024                        }
2025                        return Ok(TensorDyn::from(t));
2026                    }
2027                    Err(e) => log::debug!("PBO image creation failed, falling back to Mem: {e:?}"),
2028                }
2029            }
2030        }
2031
2032        // Try float PBO when the GPU backend reports support for this dtype.
2033        // Falls through to Mem on error (same policy as u8 PBO above).
2034        #[cfg(target_os = "linux")]
2035        #[cfg(feature = "opengl")]
2036        if float_pbo_eligible(dtype, self.supported_render_dtypes()) {
2037            if let Some(gl) = &self.opengl {
2038                match gl.create_pbo_image_dtype(width, height, format, dtype) {
2039                    Ok(t) => return Ok(t),
2040                    Err(e) => {
2041                        log::debug!(
2042                            "Float PBO image creation failed for {dtype:?}, \
2043                             falling back to Mem: {e:?}"
2044                        );
2045                    }
2046                }
2047            }
2048        }
2049
2050        // Fallback to Mem
2051        Ok(TensorDyn::image(
2052            width,
2053            height,
2054            format,
2055            dtype,
2056            Some(edgefirst_tensor::TensorMemory::Mem),
2057            access,
2058        )?)
2059    }
2060
2061    /// Import an external DMA-BUF image.
2062    ///
2063    /// Each [`PlaneDescriptor`] owns an already-duped fd; this method
2064    /// consumes the descriptors and takes ownership of those fds (whether
2065    /// the call succeeds or fails).
2066    ///
2067    /// The caller must ensure the DMA-BUF allocation is large enough for the
2068    /// specified width, height, format, and any stride/offset on the plane
2069    /// descriptors. No buffer-size validation is performed; an undersized
2070    /// buffer may cause GPU faults or EGL import failure.
2071    ///
2072    /// # Arguments
2073    ///
2074    /// * `image` - Plane descriptor for the primary (or only) plane
2075    /// * `chroma` - Optional plane descriptor for the UV chroma plane
2076    ///   (required for multiplane NV12)
2077    /// * `width` - Image width in pixels
2078    /// * `height` - Image height in pixels
2079    /// * `format` - Pixel format of the buffer
2080    /// * `dtype` - Element data type (e.g. `DType::U8`)
2081    ///
2082    /// # Returns
2083    ///
2084    /// A `TensorDyn` configured as an image.
2085    ///
2086    /// # Errors
2087    ///
2088    /// * [`Error::NotSupported`] if `chroma` is `Some` for a non-semi-planar
2089    ///   format, or multiplane NV16 (not yet supported), or the fd is not
2090    ///   DMA-backed
2091    /// * [`Error::InvalidShape`] if NV12 height is odd
2092    ///
2093    /// # Platform
2094    ///
2095    /// Linux only.
2096    ///
2097    /// # Examples
2098    ///
2099    /// ```rust,ignore
2100    /// use edgefirst_tensor::PlaneDescriptor;
2101    ///
2102    /// // Single-plane RGBA
2103    /// let pd = PlaneDescriptor::new(fd.as_fd())?;
2104    /// let src = proc.import_image(pd, None, 1920, 1080, PixelFormat::Rgba, DType::U8, None)?;
2105    ///
2106    /// // Multi-plane NV12 with stride
2107    /// let y_pd = PlaneDescriptor::new(y_fd.as_fd())?.with_stride(2048);
2108    /// let uv_pd = PlaneDescriptor::new(uv_fd.as_fd())?.with_stride(2048);
2109    /// let src = proc.import_image(y_pd, Some(uv_pd), 1920, 1080,
2110    ///                             PixelFormat::Nv12, DType::U8, None)?;
2111    /// ```
2112    // Import inherently needs plane(s) + geometry + format + dtype + colorimetry;
2113    // a params struct would obscure more than it clarifies here.
2114    #[allow(clippy::too_many_arguments)]
2115    #[cfg(target_os = "linux")]
2116    pub fn import_image(
2117        &self,
2118        image: edgefirst_tensor::PlaneDescriptor,
2119        chroma: Option<edgefirst_tensor::PlaneDescriptor>,
2120        width: usize,
2121        height: usize,
2122        format: PixelFormat,
2123        dtype: DType,
2124        colorimetry: Option<edgefirst_tensor::Colorimetry>,
2125    ) -> Result<TensorDyn> {
2126        use edgefirst_tensor::{Tensor, TensorMemory};
2127
2128        // Capture stride/offset from descriptors before consuming them
2129        let image_stride = image.stride();
2130        let image_offset = image.offset();
2131        let chroma_stride = chroma.as_ref().and_then(|c| c.stride());
2132        let chroma_offset = chroma.as_ref().and_then(|c| c.offset());
2133
2134        if let Some(chroma_pd) = chroma {
2135            // ── Multiplane path ──────────────────────────────────────
2136            // Multiplane tensors are backed by Tensor<u8> (or transmuted to
2137            // Tensor<i8>). Reject other dtypes to avoid silently returning a
2138            // tensor with the wrong element type.
2139            if dtype != DType::U8 && dtype != DType::I8 {
2140                return Err(Error::NotSupported(format!(
2141                    "multiplane import only supports U8/I8, got {dtype:?}"
2142                )));
2143            }
2144            if format.layout() != PixelLayout::SemiPlanar {
2145                return Err(Error::NotSupported(format!(
2146                    "import_image with chroma requires a semi-planar format, got {format:?}"
2147                )));
2148            }
2149
2150            let chroma_h = match format {
2151                PixelFormat::Nv12 => {
2152                    // NV12 (4:2:0): ceil(H/2) chroma rows — odd heights are valid.
2153                    height.div_ceil(2)
2154                }
2155                // NV16 multiplane will be supported in a future release;
2156                // the GL backend currently only handles NV12 plane1 attributes.
2157                PixelFormat::Nv16 => {
2158                    return Err(Error::NotSupported(
2159                        "multiplane NV16 is not yet supported; use contiguous NV16 instead".into(),
2160                    ))
2161                }
2162                _ => {
2163                    return Err(Error::NotSupported(format!(
2164                        "unsupported semi-planar format: {format:?}"
2165                    )))
2166                }
2167            };
2168
2169            let luma = Tensor::<u8>::from_fd(image.into_fd(), &[height, width], Some("luma"))?;
2170            if luma.memory() != TensorMemory::Dma {
2171                return Err(Error::NotSupported(format!(
2172                    "luma fd must be DMA-backed, got {:?}",
2173                    luma.memory()
2174                )));
2175            }
2176
2177            let chroma_tensor =
2178                Tensor::<u8>::from_fd(chroma_pd.into_fd(), &[chroma_h, width], Some("chroma"))?;
2179            if chroma_tensor.memory() != TensorMemory::Dma {
2180                return Err(Error::NotSupported(format!(
2181                    "chroma fd must be DMA-backed, got {:?}",
2182                    chroma_tensor.memory()
2183                )));
2184            }
2185
2186            // from_planes creates the combined tensor with format set,
2187            // preserving luma's row_stride (currently None since luma was raw).
2188            let mut tensor = Tensor::<u8>::from_planes(luma, chroma_tensor, format)?;
2189
2190            // Apply stride/offset to the combined tensor (luma plane)
2191            if let Some(s) = image_stride {
2192                tensor.set_row_stride(s)?;
2193            }
2194            if let Some(o) = image_offset {
2195                tensor.set_plane_offset(o);
2196            }
2197
2198            // Apply stride/offset to the chroma sub-tensor.
2199            // The chroma tensor is a raw 2D [chroma_h, width] tensor without
2200            // format metadata, so we validate stride manually rather than
2201            // using set_row_stride (which requires format).
2202            if let Some(chroma_ref) = tensor.chroma_mut() {
2203                if let Some(s) = chroma_stride {
2204                    if s < width {
2205                        return Err(Error::InvalidShape(format!(
2206                            "chroma stride {s} < minimum {width} for {format:?}"
2207                        )));
2208                    }
2209                    chroma_ref.set_row_stride_unchecked(s);
2210                }
2211                if let Some(o) = chroma_offset {
2212                    chroma_ref.set_plane_offset(o);
2213                }
2214            }
2215
2216            if dtype == DType::I8 {
2217                // SAFETY: Tensor<u8> and Tensor<i8> have identical layout because
2218                // the struct contains only type-erased storage (OwnedFd, shape, name),
2219                // no inline T values. This assertion catches layout drift at compile time.
2220                const {
2221                    assert!(std::mem::size_of::<Tensor<u8>>() == std::mem::size_of::<Tensor<i8>>());
2222                    assert!(
2223                        std::mem::align_of::<Tensor<u8>>() == std::mem::align_of::<Tensor<i8>>()
2224                    );
2225                }
2226                let tensor_i8: Tensor<i8> = unsafe { std::mem::transmute(tensor) };
2227                let mut dyn_tensor = TensorDyn::from(tensor_i8);
2228                dyn_tensor.set_colorimetry(colorimetry);
2229                return Ok(dyn_tensor);
2230            }
2231            let mut dyn_tensor = TensorDyn::from(tensor);
2232            dyn_tensor.set_colorimetry(colorimetry);
2233            Ok(dyn_tensor)
2234        } else {
2235            // ── Single-plane path ────────────────────────────────────
2236            // Canonical shape (Packed [H,W,C] / Planar [C,H,W] / SemiPlanar
2237            // [total_h, W]); `image_shape` supports NV12/NV16/NV24 (the old
2238            // hand-rolled match erroneously rejected NV24).
2239            let shape = format.image_shape(width, height).ok_or_else(|| {
2240                Error::NotSupported(format!(
2241                    "unsupported pixel format for import_image: {format:?}"
2242                ))
2243            })?;
2244            let tensor = TensorDyn::from_fd(image.into_fd(), &shape, dtype, None)?;
2245            if tensor.memory() != TensorMemory::Dma {
2246                return Err(Error::NotSupported(format!(
2247                    "import_image requires DMA-backed fd, got {:?}",
2248                    tensor.memory()
2249                )));
2250            }
2251            let mut tensor = tensor.with_format(format)?;
2252            if let Some(s) = image_stride {
2253                tensor.set_row_stride(s)?;
2254            }
2255            if let Some(o) = image_offset {
2256                tensor.set_plane_offset(o);
2257            }
2258            tensor.set_colorimetry(colorimetry);
2259            Ok(tensor)
2260        }
2261    }
2262
2263    /// Decode model outputs and draw segmentation masks onto `dst`.
2264    ///
2265    /// This is the primary mask rendering API. The processor decodes via the
2266    /// provided [`Decoder`], selects the optimal rendering path (hybrid
2267    /// CPU+GL or fused GPU), and composites masks onto `dst`.
2268    ///
2269    /// Returns the detected bounding boxes.
2270    pub fn draw_masks(
2271        &mut self,
2272        decoder: &edgefirst_decoder::Decoder,
2273        outputs: &[&TensorDyn],
2274        dst: &mut TensorDyn,
2275        overlay: MaskOverlay<'_>,
2276    ) -> Result<Vec<DetectBox>> {
2277        let mut output_boxes = Vec::with_capacity(100);
2278
2279        // Try proto path first (fused rendering without materializing masks)
2280        let proto_result = decoder
2281            .decode_proto(outputs, &mut output_boxes)
2282            .map_err(|e| Error::Internal(format!("decode_proto: {e:#?}")))?;
2283
2284        if let Some(proto_data) = proto_result {
2285            self.draw_proto_masks(dst, &output_boxes, &proto_data, overlay)?;
2286        } else {
2287            // Detection-only or unsupported model: full decode + render
2288            let mut output_masks = Vec::with_capacity(100);
2289            decoder
2290                .decode(outputs, &mut output_boxes, &mut output_masks)
2291                .map_err(|e| Error::Internal(format!("decode: {e:#?}")))?;
2292            self.draw_decoded_masks(dst, &output_boxes, &output_masks, overlay)?;
2293        }
2294        Ok(output_boxes)
2295    }
2296
2297    /// Decode tracked model outputs and draw segmentation masks onto `dst`.
2298    ///
2299    /// Like [`draw_masks`](Self::draw_masks) but integrates a tracker for
2300    /// maintaining object identities across frames. The tracker runs after
2301    /// NMS but before mask extraction.
2302    ///
2303    /// Returns detected boxes and track info.
2304    #[cfg(feature = "tracker")]
2305    pub fn draw_masks_tracked<TR: edgefirst_tracker::Tracker<DetectBox>>(
2306        &mut self,
2307        decoder: &edgefirst_decoder::Decoder,
2308        tracker: &mut TR,
2309        timestamp: u64,
2310        outputs: &[&TensorDyn],
2311        dst: &mut TensorDyn,
2312        overlay: MaskOverlay<'_>,
2313    ) -> Result<(Vec<DetectBox>, Vec<edgefirst_tracker::TrackInfo>)> {
2314        let mut output_boxes = Vec::with_capacity(100);
2315        let mut output_tracks = Vec::new();
2316
2317        let proto_result = decoder
2318            .decode_proto_tracked(
2319                tracker,
2320                timestamp,
2321                outputs,
2322                &mut output_boxes,
2323                &mut output_tracks,
2324            )
2325            .map_err(|e| Error::Internal(format!("decode_proto_tracked: {e:#?}")))?;
2326
2327        if let Some(proto_data) = proto_result {
2328            self.draw_proto_masks(dst, &output_boxes, &proto_data, overlay)?;
2329        } else {
2330            // Note: decode_proto_tracked returns None for detection-only/ModelPack
2331            // models WITHOUT calling the tracker. The else branch below is the
2332            // first (and only) tracker call for those model types.
2333            let mut output_masks = Vec::with_capacity(100);
2334            decoder
2335                .decode_tracked(
2336                    tracker,
2337                    timestamp,
2338                    outputs,
2339                    &mut output_boxes,
2340                    &mut output_masks,
2341                    &mut output_tracks,
2342                )
2343                .map_err(|e| Error::Internal(format!("decode_tracked: {e:#?}")))?;
2344            self.draw_decoded_masks(dst, &output_boxes, &output_masks, overlay)?;
2345        }
2346        Ok((output_boxes, output_tracks))
2347    }
2348
2349    /// Materialize per-instance segmentation masks from raw prototype data.
2350    ///
2351    /// Computes `mask_coeff @ protos` with sigmoid activation for each detection,
2352    /// producing compact masks at prototype resolution (e.g., 160×160 crops).
2353    /// Mask values are continuous sigmoid confidence outputs quantized to u8
2354    /// (0 = background, 255 = full confidence), NOT binary thresholded.
2355    ///
2356    /// The returned [`Vec<Segmentation>`] can be:
2357    /// - Inspected or exported for analytics, IoU computation, etc.
2358    /// - Passed directly to [`ImageProcessorTrait::draw_decoded_masks`] for
2359    ///   GPU-interpolated rendering.
2360    ///
2361    /// # Performance Note
2362    ///
2363    /// Calling `materialize_masks` + `draw_decoded_masks` separately prevents
2364    /// the HAL from using its internal fused optimization path. For render-only
2365    /// use cases, prefer [`ImageProcessorTrait::draw_proto_masks`] which selects
2366    /// the fastest path automatically (currently 1.6×–27× faster on tested
2367    /// platforms). Use this method when you need access to the intermediate masks.
2368    ///
2369    /// # Errors
2370    ///
2371    /// Returns [`Error::NoConverter`] if the CPU backend is not available.
2372    pub fn materialize_masks(
2373        &mut self,
2374        detect: &[DetectBox],
2375        proto_data: &ProtoData,
2376        letterbox: Option<[f32; 4]>,
2377        resolution: MaskResolution,
2378    ) -> Result<Vec<Segmentation>> {
2379        let cpu = self.cpu.as_mut().ok_or(Error::NoConverter)?;
2380        match resolution {
2381            MaskResolution::Proto => cpu.materialize_segmentations(detect, proto_data, letterbox),
2382            MaskResolution::Scaled { width, height } => {
2383                cpu.materialize_scaled_segmentations(detect, proto_data, letterbox, width, height)
2384            }
2385        }
2386    }
2387}
2388
2389impl ImageProcessorTrait for ImageProcessor {
2390    /// Converts the source image to the destination image format and size. The
2391    /// image is cropped first, then flipped, then rotated
2392    ///
2393    /// Prefer hardware accelerators when available, falling back to CPU if
2394    /// necessary.
2395    fn convert(
2396        &mut self,
2397        src: &TensorDyn,
2398        dst: &mut TensorDyn,
2399        rotation: Rotation,
2400        flip: Flip,
2401        crop: Crop,
2402    ) -> Result<()> {
2403        let start = Instant::now();
2404        let src_fmt = src.format();
2405        let dst_fmt = dst.format();
2406        let _span = tracing::trace_span!(
2407            "image.convert",
2408            ?src_fmt,
2409            ?dst_fmt,
2410            src_memory = ?src.memory(),
2411            dst_memory = ?dst.memory(),
2412            ?rotation,
2413            ?flip,
2414        )
2415        .entered();
2416        log::trace!(
2417            "convert: {src_fmt:?}({:?}/{:?}) → {dst_fmt:?}({:?}/{:?}), \
2418             rotation={rotation:?}, flip={flip:?}, backend={:?}",
2419            src.dtype(),
2420            src.memory(),
2421            dst.dtype(),
2422            dst.memory(),
2423            self.forced_backend,
2424        );
2425
2426        // ── Forced backend: no fallback chain ────────────────────────
2427        if let Some(forced) = self.forced_backend {
2428            return match forced {
2429                ForcedBackend::Cpu => {
2430                    if let Some(cpu) = self.cpu.as_mut() {
2431                        let r = cpu.convert(src, dst, rotation, flip, crop);
2432                        log::trace!(
2433                            "convert: forced=cpu result={} ({:?})",
2434                            if r.is_ok() { "ok" } else { "err" },
2435                            start.elapsed()
2436                        );
2437                        return r;
2438                    }
2439                    Err(Error::ForcedBackendUnavailable("cpu".into()))
2440                }
2441                ForcedBackend::G2d => {
2442                    #[cfg(target_os = "linux")]
2443                    if let Some(g2d) = self.g2d.as_mut() {
2444                        let r = g2d.convert(src, dst, rotation, flip, crop);
2445                        log::trace!(
2446                            "convert: forced=g2d result={} ({:?})",
2447                            if r.is_ok() { "ok" } else { "err" },
2448                            start.elapsed()
2449                        );
2450                        return r;
2451                    }
2452                    Err(Error::ForcedBackendUnavailable("g2d".into()))
2453                }
2454                ForcedBackend::OpenGl => {
2455                    #[cfg(any(
2456                        target_os = "linux",
2457                        target_os = "macos",
2458                        target_os = "ios",
2459                        target_os = "android"
2460                    ))]
2461                    #[cfg(feature = "opengl")]
2462                    if let Some(opengl) = self.opengl.as_mut() {
2463                        let r = opengl.convert(src, dst, rotation, flip, crop);
2464                        log::trace!(
2465                            "convert: forced=opengl result={} ({:?})",
2466                            if r.is_ok() { "ok" } else { "err" },
2467                            start.elapsed()
2468                        );
2469                        return r;
2470                    }
2471                    Err(Error::ForcedBackendUnavailable("opengl".into()))
2472                }
2473            };
2474        }
2475
2476        // ── Auto fallback chain: OpenGL → G2D → CPU ──────────────────
2477        #[cfg(any(
2478            target_os = "linux",
2479            target_os = "macos",
2480            target_os = "ios",
2481            target_os = "android"
2482        ))]
2483        #[cfg(feature = "opengl")]
2484        if let Some(opengl) = self.opengl.as_mut() {
2485            match opengl.convert(src, dst, rotation, flip, crop) {
2486                Ok(_) => {
2487                    log::trace!(
2488                        "convert: auto selected=opengl for {src_fmt:?}→{dst_fmt:?} ({:?})",
2489                        start.elapsed()
2490                    );
2491                    return Ok(());
2492                }
2493                Err(e) => {
2494                    self.convert_fallbacks
2495                        .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2496                    log::debug!(
2497                        "convert: auto opengl declined {src_fmt:?}@{:?}→{dst_fmt:?}@{:?}, \
2498                         falling back toward G2D/CPU: {e}",
2499                        src.memory(),
2500                        dst.memory(),
2501                    );
2502                }
2503            }
2504        }
2505
2506        #[cfg(target_os = "linux")]
2507        if let Some(g2d) = self.g2d.as_mut() {
2508            // G2D is matrix-only (no range control, no BT.2020). For any
2509            // conversion with a YUV side, resolve that side's colorimetry and
2510            // skip G2D entirely when it cannot be expressed (full-range YUV or
2511            // BT.2020), letting the chain fall through to GL/CPU which honour
2512            // range and BT.2020. YUV→RGB uses the source colorimetry; RGB→YUV
2513            // uses the destination. RGB→RGB has no YUV side and is unaffected.
2514            let src_is_yuv = src.format().is_some_and(|f| f.is_yuv());
2515            let dst_is_yuv = dst.format().is_some_and(|f| f.is_yuv());
2516            let g2d_eligible = if src_is_yuv || dst_is_yuv {
2517                let cm = if src_is_yuv {
2518                    crate::colorimetry::effective_colorimetry(src)
2519                } else {
2520                    crate::colorimetry::effective_colorimetry(dst)
2521                };
2522                crate::g2d::g2d_can_handle(&cm, true)
2523            } else {
2524                true
2525            };
2526            if !g2d_eligible {
2527                log::trace!(
2528                    "convert: auto g2d skipped {src_fmt:?}→{dst_fmt:?} \
2529                     (colorimetry not expressible: full-range/BT.2020)"
2530                );
2531            } else {
2532                match g2d.convert(src, dst, rotation, flip, crop) {
2533                    Ok(_) => {
2534                        log::trace!(
2535                            "convert: auto selected=g2d for {src_fmt:?}→{dst_fmt:?} ({:?})",
2536                            start.elapsed()
2537                        );
2538                        return Ok(());
2539                    }
2540                    Err(e) => {
2541                        log::trace!("convert: auto g2d declined {src_fmt:?}→{dst_fmt:?}: {e}");
2542                    }
2543                }
2544            }
2545        }
2546
2547        if let Some(cpu) = self.cpu.as_mut() {
2548            match cpu.convert(src, dst, rotation, flip, crop) {
2549                Ok(_) => {
2550                    log::trace!(
2551                        "convert: auto selected=cpu for {src_fmt:?}→{dst_fmt:?} ({:?})",
2552                        start.elapsed()
2553                    );
2554                    return Ok(());
2555                }
2556                Err(e) => {
2557                    log::trace!("convert: auto cpu failed {src_fmt:?}→{dst_fmt:?}: {e}");
2558                    return Err(e);
2559                }
2560            }
2561        }
2562        Err(Error::NoConverter)
2563    }
2564
2565    fn convert_deferred(
2566        &mut self,
2567        src: &TensorDyn,
2568        dst: &mut TensorDyn,
2569        rotation: Rotation,
2570        flip: Flip,
2571        crop: Crop,
2572    ) -> Result<()> {
2573        // Deferred batching is an OpenGL optimization (shared parent EGLImage +
2574        // no per-tile glFinish). Route to the GL backend's deferred path when GL
2575        // is forced or auto-selectable; on a GL decline fall back to an eager
2576        // convert (the auto chain), which is correct everywhere — it completes
2577        // synchronously and `flush` stays a no-op for non-GL backends.
2578        #[cfg(any(
2579            target_os = "linux",
2580            target_os = "macos",
2581            target_os = "ios",
2582            target_os = "android"
2583        ))]
2584        #[cfg(feature = "opengl")]
2585        {
2586            let gl_forced = matches!(self.forced_backend, Some(ForcedBackend::OpenGl));
2587            if gl_forced || self.forced_backend.is_none() {
2588                if let Some(opengl) = self.opengl.as_mut() {
2589                    match opengl.convert_deferred(src, dst, rotation, flip, crop) {
2590                        Ok(()) => return Ok(()),
2591                        Err(e) => {
2592                            log::trace!("convert_deferred: gl declined: {e}; eager fallback");
2593                            // A forced-GL caller gets the GL error, matching
2594                            // `convert`'s no-fallback forced-backend contract.
2595                            if gl_forced {
2596                                return Err(e);
2597                            }
2598                        }
2599                    }
2600                }
2601            }
2602        }
2603        self.convert(src, dst, rotation, flip, crop)
2604    }
2605
2606    fn flush(&mut self) -> Result<()> {
2607        let _span = tracing::trace_span!("image.flush").entered();
2608        // Only the OpenGL backend defers; flushing it issues the single GPU
2609        // sync. CPU/G2D converts already completed, so there is nothing to flush.
2610        #[cfg(any(
2611            target_os = "linux",
2612            target_os = "macos",
2613            target_os = "ios",
2614            target_os = "android"
2615        ))]
2616        #[cfg(feature = "opengl")]
2617        if let Some(opengl) = self.opengl.as_mut() {
2618            return opengl.flush();
2619        }
2620        Ok(())
2621    }
2622
2623    fn draw_decoded_masks(
2624        &mut self,
2625        dst: &mut TensorDyn,
2626        detect: &[DetectBox],
2627        segmentation: &[Segmentation],
2628        overlay: MaskOverlay<'_>,
2629    ) -> Result<()> {
2630        let _span = tracing::trace_span!(
2631            "image.draw_decoded_masks",
2632            n_detections = detect.len(),
2633            n_segmentations = segmentation.len(),
2634        )
2635        .entered();
2636        let start = Instant::now();
2637
2638        if let Some(bg) = overlay.background {
2639            if bg.aliases(dst) {
2640                return Err(Error::AliasedBuffers(
2641                    "background must not reference the same buffer as dst".to_string(),
2642                ));
2643            }
2644        }
2645
2646        // Un-letterbox detect boxes and segmentation bboxes for rendering when
2647        // a letterbox was applied to prepare the model input.
2648        let lb_boxes: Vec<DetectBox>;
2649        let lb_segs: Vec<Segmentation>;
2650        let (detect, segmentation) = if let Some(lb) = overlay.letterbox {
2651            lb_boxes = detect.iter().map(|&d| unletter_bbox(d, lb)).collect();
2652            // Keep segmentation bboxes in sync with the transformed detect boxes
2653            // when we have a 1:1 correspondence (instance segmentation).
2654            lb_segs = if segmentation.len() == lb_boxes.len() {
2655                segmentation
2656                    .iter()
2657                    .zip(lb_boxes.iter())
2658                    .map(|(s, d)| Segmentation {
2659                        xmin: d.bbox.xmin,
2660                        ymin: d.bbox.ymin,
2661                        xmax: d.bbox.xmax,
2662                        ymax: d.bbox.ymax,
2663                        segmentation: s.segmentation.clone(),
2664                    })
2665                    .collect()
2666            } else {
2667                segmentation.to_vec()
2668            };
2669            (lb_boxes.as_slice(), lb_segs.as_slice())
2670        } else {
2671            (detect, segmentation)
2672        };
2673        #[cfg(target_os = "linux")]
2674        let is_empty_frame = detect.is_empty() && segmentation.is_empty();
2675
2676        // ── Forced backend: no fallback chain ────────────────────────
2677        if let Some(forced) = self.forced_backend {
2678            return match forced {
2679                ForcedBackend::Cpu => {
2680                    if let Some(cpu) = self.cpu.as_mut() {
2681                        return cpu.draw_decoded_masks(dst, detect, segmentation, overlay);
2682                    }
2683                    Err(Error::ForcedBackendUnavailable("cpu".into()))
2684                }
2685                ForcedBackend::G2d => {
2686                    // G2D can only produce empty frames (clear / bg blit).
2687                    // For populated frames it has no rasterizer — fail loudly.
2688                    #[cfg(target_os = "linux")]
2689                    if let Some(g2d) = self.g2d.as_mut() {
2690                        return g2d.draw_decoded_masks(dst, detect, segmentation, overlay);
2691                    }
2692                    Err(Error::ForcedBackendUnavailable("g2d".into()))
2693                }
2694                ForcedBackend::OpenGl => {
2695                    // GL handles background natively via GPU blit, and now
2696                    // actively clears when there is no background.
2697                    #[cfg(target_os = "linux")]
2698                    #[cfg(feature = "opengl")]
2699                    if let Some(opengl) = self.opengl.as_mut() {
2700                        return opengl.draw_decoded_masks(dst, detect, segmentation, overlay);
2701                    }
2702                    Err(Error::ForcedBackendUnavailable("opengl".into()))
2703                }
2704            };
2705        }
2706
2707        // ── Auto dispatch ──────────────────────────────────────────
2708        // Empty frames prefer G2D when available — a single g2d_clear or
2709        // g2d_blit is the cheapest HW path to produce the correct output
2710        // and avoids spinning up the GL pipeline every zero-detection
2711        // frame in a triple-buffered display loop.
2712        #[cfg(target_os = "linux")]
2713        if is_empty_frame {
2714            if let Some(g2d) = self.g2d.as_mut() {
2715                match g2d.draw_decoded_masks(dst, detect, segmentation, overlay) {
2716                    Ok(_) => {
2717                        log::trace!(
2718                            "draw_decoded_masks empty frame via g2d in {:?}",
2719                            start.elapsed()
2720                        );
2721                        return Ok(());
2722                    }
2723                    Err(e) => log::trace!("g2d empty-frame path unavailable: {e:?}"),
2724                }
2725            }
2726        }
2727
2728        // Populated frames (or G2D unavailable): GL first, CPU fallback.
2729        // Both backends now own their own base-layer handling (bg blit
2730        // or clear), so we hand the overlay through untouched.
2731        #[cfg(target_os = "linux")]
2732        #[cfg(feature = "opengl")]
2733        if let Some(opengl) = self.opengl.as_mut() {
2734            log::trace!(
2735                "draw_decoded_masks started with opengl in {:?}",
2736                start.elapsed()
2737            );
2738            match opengl.draw_decoded_masks(dst, detect, segmentation, overlay) {
2739                Ok(_) => {
2740                    log::trace!("draw_decoded_masks with opengl in {:?}", start.elapsed());
2741                    return Ok(());
2742                }
2743                Err(e) => {
2744                    log::trace!("draw_decoded_masks didn't work with opengl: {e:?}")
2745                }
2746            }
2747        }
2748
2749        log::trace!(
2750            "draw_decoded_masks started with cpu in {:?}",
2751            start.elapsed()
2752        );
2753        if let Some(cpu) = self.cpu.as_mut() {
2754            match cpu.draw_decoded_masks(dst, detect, segmentation, overlay) {
2755                Ok(_) => {
2756                    log::trace!("draw_decoded_masks with cpu in {:?}", start.elapsed());
2757                    return Ok(());
2758                }
2759                Err(e) => {
2760                    log::trace!("draw_decoded_masks didn't work with cpu: {e:?}");
2761                    return Err(e);
2762                }
2763            }
2764        }
2765        Err(Error::NoConverter)
2766    }
2767
2768    fn draw_proto_masks(
2769        &mut self,
2770        dst: &mut TensorDyn,
2771        detect: &[DetectBox],
2772        proto_data: &ProtoData,
2773        overlay: MaskOverlay<'_>,
2774    ) -> Result<()> {
2775        let start = Instant::now();
2776
2777        if let Some(bg) = overlay.background {
2778            if bg.aliases(dst) {
2779                return Err(Error::AliasedBuffers(
2780                    "background must not reference the same buffer as dst".to_string(),
2781                ));
2782            }
2783        }
2784
2785        // Un-letterbox detect boxes for rendering when a letterbox was applied
2786        // to prepare the model input.  The original `detect` coords are still
2787        // passed to `materialize_segmentations` (which needs model-space coords
2788        // to correctly crop the proto tensor) alongside `overlay.letterbox` so
2789        // it can emit `Segmentation` structs in output-image space.
2790        let lb_boxes: Vec<DetectBox>;
2791        let render_detect = if let Some(lb) = overlay.letterbox {
2792            lb_boxes = detect.iter().map(|&d| unletter_bbox(d, lb)).collect();
2793            lb_boxes.as_slice()
2794        } else {
2795            detect
2796        };
2797        #[cfg(target_os = "linux")]
2798        let is_empty_frame = detect.is_empty();
2799
2800        // ── Forced backend: no fallback chain ────────────────────────
2801        if let Some(forced) = self.forced_backend {
2802            return match forced {
2803                ForcedBackend::Cpu => {
2804                    if let Some(cpu) = self.cpu.as_mut() {
2805                        return cpu.draw_proto_masks(dst, render_detect, proto_data, overlay);
2806                    }
2807                    Err(Error::ForcedBackendUnavailable("cpu".into()))
2808                }
2809                ForcedBackend::G2d => {
2810                    #[cfg(target_os = "linux")]
2811                    if let Some(g2d) = self.g2d.as_mut() {
2812                        return g2d.draw_proto_masks(dst, render_detect, proto_data, overlay);
2813                    }
2814                    Err(Error::ForcedBackendUnavailable("g2d".into()))
2815                }
2816                ForcedBackend::OpenGl => {
2817                    #[cfg(target_os = "linux")]
2818                    #[cfg(feature = "opengl")]
2819                    if let Some(opengl) = self.opengl.as_mut() {
2820                        return opengl.draw_proto_masks(dst, render_detect, proto_data, overlay);
2821                    }
2822                    Err(Error::ForcedBackendUnavailable("opengl".into()))
2823                }
2824            };
2825        }
2826
2827        // ── Auto dispatch ──────────────────────────────────────────
2828        // Empty frames: prefer G2D — cheapest HW path (clear or bg blit).
2829        #[cfg(target_os = "linux")]
2830        if is_empty_frame {
2831            if let Some(g2d) = self.g2d.as_mut() {
2832                match g2d.draw_proto_masks(dst, render_detect, proto_data, overlay) {
2833                    Ok(_) => {
2834                        log::trace!(
2835                            "draw_proto_masks empty frame via g2d in {:?}",
2836                            start.elapsed()
2837                        );
2838                        return Ok(());
2839                    }
2840                    Err(e) => log::trace!("g2d empty-frame path unavailable: {e:?}"),
2841                }
2842            }
2843        }
2844
2845        // Hybrid path: CPU materialize + GL overlay (benchmarked faster than
2846        // full-GPU draw_proto_masks on all tested platforms: 27× on imx8mp,
2847        // 4× on imx95, 2.5× on rpi5, 1.6× on x86).
2848        // GL owns its own bg-blit / glClear — we pass the overlay through.
2849        //
2850        // CPU materialize needs `&mut` for its MaskScratch buffers; GL also
2851        // needs `&mut`. The CPU borrow is scoped to its block so the
2852        // subsequent GL borrow is free to take over `self`.
2853        #[cfg(target_os = "linux")]
2854        #[cfg(feature = "opengl")]
2855        if let (Some(_), Some(_)) = (self.cpu.as_ref(), self.opengl.as_ref()) {
2856            let segmentation = match self.cpu.as_mut() {
2857                Some(cpu) => {
2858                    log::trace!(
2859                        "draw_proto_masks started with hybrid (cpu+opengl) in {:?}",
2860                        start.elapsed()
2861                    );
2862                    cpu.materialize_segmentations(detect, proto_data, overlay.letterbox)?
2863                }
2864                None => unreachable!("cpu presence checked above"),
2865            };
2866            if let Some(opengl) = self.opengl.as_mut() {
2867                match opengl.draw_decoded_masks(dst, render_detect, &segmentation, overlay) {
2868                    Ok(_) => {
2869                        log::trace!(
2870                            "draw_proto_masks with hybrid (cpu+opengl) in {:?}",
2871                            start.elapsed()
2872                        );
2873                        return Ok(());
2874                    }
2875                    Err(e) => {
2876                        log::trace!(
2877                            "draw_proto_masks hybrid path failed, falling back to cpu: {e:?}"
2878                        );
2879                    }
2880                }
2881            }
2882        }
2883
2884        let Some(cpu) = self.cpu.as_mut() else {
2885            return Err(Error::Internal(
2886                "draw_proto_masks requires CPU backend for fallback path".into(),
2887            ));
2888        };
2889        log::trace!("draw_proto_masks started with cpu in {:?}", start.elapsed());
2890        cpu.draw_proto_masks(dst, render_detect, proto_data, overlay)
2891    }
2892
2893    fn set_class_colors(&mut self, colors: &[[u8; 4]]) -> Result<()> {
2894        let start = Instant::now();
2895
2896        // ── Forced backend: no fallback chain ────────────────────────
2897        if let Some(forced) = self.forced_backend {
2898            return match forced {
2899                ForcedBackend::Cpu => {
2900                    if let Some(cpu) = self.cpu.as_mut() {
2901                        return cpu.set_class_colors(colors);
2902                    }
2903                    Err(Error::ForcedBackendUnavailable("cpu".into()))
2904                }
2905                ForcedBackend::G2d => Err(Error::NotSupported(
2906                    "g2d does not support set_class_colors".into(),
2907                )),
2908                ForcedBackend::OpenGl => {
2909                    #[cfg(target_os = "linux")]
2910                    #[cfg(feature = "opengl")]
2911                    if let Some(opengl) = self.opengl.as_mut() {
2912                        return opengl.set_class_colors(colors);
2913                    }
2914                    Err(Error::ForcedBackendUnavailable("opengl".into()))
2915                }
2916            };
2917        }
2918
2919        // skip G2D as it doesn't support rendering to image
2920
2921        #[cfg(target_os = "linux")]
2922        #[cfg(feature = "opengl")]
2923        if let Some(opengl) = self.opengl.as_mut() {
2924            log::trace!("image started with opengl in {:?}", start.elapsed());
2925            match opengl.set_class_colors(colors) {
2926                Ok(_) => {
2927                    log::trace!("colors set with opengl in {:?}", start.elapsed());
2928                    return Ok(());
2929                }
2930                Err(e) => {
2931                    log::trace!("colors didn't set with opengl: {e:?}")
2932                }
2933            }
2934        }
2935        log::trace!("image started with cpu in {:?}", start.elapsed());
2936        if let Some(cpu) = self.cpu.as_mut() {
2937            match cpu.set_class_colors(colors) {
2938                Ok(_) => {
2939                    log::trace!("colors set with cpu in {:?}", start.elapsed());
2940                    return Ok(());
2941                }
2942                Err(e) => {
2943                    log::trace!("colors didn't set with cpu: {e:?}");
2944                    return Err(e);
2945                }
2946            }
2947        }
2948        Err(Error::NoConverter)
2949    }
2950}
2951
2952// ---------------------------------------------------------------------------
2953// Image loading / saving helpers
2954// ---------------------------------------------------------------------------
2955
2956/// Test-only convenience helper that peeks the image header, allocates a
2957/// tensor sized to the image (honoring DMA pitch padding on Linux when
2958/// requested), and decodes via [`edgefirst_codec`]. Mirrors the semantics of
2959/// the removed public `load_image` API for test sites; production callers
2960/// should use the explicit peek → allocate → decode pattern directly.
2961#[cfg(test)]
2962pub(crate) fn load_image_test_helper(
2963    image: &[u8],
2964    format: Option<PixelFormat>,
2965    memory: Option<TensorMemory>,
2966) -> Result<TensorDyn> {
2967    use edgefirst_codec::{peek_info, ImageDecoder, ImageLoad};
2968
2969    // Peek the source header to get its NATIVE format and dimensions. The
2970    // codec now emits the source's native format (JPEG → Nv12/Grey, PNG →
2971    // Rgb/Rgba/Grey) and configures the destination tensor itself.
2972    let info = peek_info(image)?;
2973    let native_fmt = info.format;
2974    let w = info.width;
2975    let h = info.height;
2976
2977    let mut decoder = ImageDecoder::new();
2978
2979    // Decode into a native-format tensor. The decoder sets the tensor's
2980    // dims+format, so we allocate it sized to the native layout.
2981    #[cfg(target_os = "linux")]
2982    let native_src = {
2983        if let Some(aligned_pitch) = padded_dma_pitch_for(native_fmt, w, &memory) {
2984            let mut dma = Tensor::<u8>::image_with_stride(
2985                w,
2986                h,
2987                native_fmt,
2988                aligned_pitch,
2989                Some(TensorMemory::Dma),
2990                edgefirst_tensor::CpuAccess::ReadWrite,
2991            )?;
2992            dma.load_image(&mut decoder, image)?;
2993            TensorDyn::from(dma)
2994        } else {
2995            let mut img = Tensor::<u8>::image(
2996                w,
2997                h,
2998                native_fmt,
2999                memory,
3000                edgefirst_tensor::CpuAccess::ReadWrite,
3001            )?;
3002            img.load_image(&mut decoder, image)?;
3003            TensorDyn::from(img)
3004        }
3005    };
3006    #[cfg(not(target_os = "linux"))]
3007    let native_src = {
3008        let mut img = Tensor::<u8>::image(
3009            w,
3010            h,
3011            native_fmt,
3012            memory,
3013            edgefirst_tensor::CpuAccess::ReadWrite,
3014        )?;
3015        img.load_image(&mut decoder, image)?;
3016        TensorDyn::from(img)
3017    };
3018
3019    // If the caller requested a different format, convert into it (same
3020    // dims) using a headless CPU-backed processor so the helper works
3021    // without GPU/G2D hardware.
3022    match format {
3023        Some(f) if f != native_fmt => {
3024            let mut dst = TensorDyn::image(
3025                w,
3026                h,
3027                f,
3028                DType::U8,
3029                memory,
3030                edgefirst_tensor::CpuAccess::ReadWrite,
3031            )?;
3032            // `ImageProcessorConfig` has platform-specific fields: on Linux it
3033            // carries extra GL/G2D options so `..Default::default()` is needed,
3034            // but on macOS `backend` is the only field, making the update
3035            // redundant (clippy::needless_update). Allow it for cross-platform
3036            // parity — the alternative (field reassign) trips
3037            // clippy::field_reassign_with_default on Linux instead.
3038            #[allow(clippy::needless_update)]
3039            let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
3040                backend: ComputeBackend::Cpu,
3041                ..Default::default()
3042            })?;
3043            proc.convert(
3044                &native_src,
3045                &mut dst,
3046                Rotation::None,
3047                Flip::None,
3048                Crop::default(),
3049            )?;
3050            Ok(dst)
3051        }
3052        _ => Ok(native_src),
3053    }
3054}
3055
3056/// Save a [`TensorDyn`] image as a JPEG file.
3057///
3058/// Only packed RGB and RGBA formats are supported.
3059pub fn save_jpeg(tensor: &TensorDyn, path: impl AsRef<std::path::Path>, quality: u8) -> Result<()> {
3060    let t = tensor.as_u8().ok_or(Error::UnsupportedFormat(
3061        "save_jpeg requires u8 tensor".to_string(),
3062    ))?;
3063    let fmt = t.format().ok_or(Error::NotAnImage)?;
3064    if fmt.layout() != PixelLayout::Packed {
3065        return Err(Error::NotImplemented(
3066            "Saving planar images is not supported".to_string(),
3067        ));
3068    }
3069
3070    let colour = match fmt {
3071        PixelFormat::Rgb => jpeg_encoder::ColorType::Rgb,
3072        PixelFormat::Rgba => jpeg_encoder::ColorType::Rgba,
3073        _ => {
3074            return Err(Error::NotImplemented(
3075                "Unsupported image format for saving".to_string(),
3076            ));
3077        }
3078    };
3079
3080    let w = t.width().ok_or(Error::NotAnImage)?;
3081    let h = t.height().ok_or(Error::NotAnImage)?;
3082    let encoder = jpeg_encoder::Encoder::new_file(path, quality)?;
3083    let tensor_map = t.map_read()?;
3084
3085    encoder.encode(&tensor_map, w as u16, h as u16, colour)?;
3086
3087    Ok(())
3088}
3089
3090pub(crate) struct FunctionTimer<T: Display> {
3091    name: T,
3092    start: std::time::Instant,
3093}
3094
3095impl<T: Display> FunctionTimer<T> {
3096    pub fn new(name: T) -> Self {
3097        Self {
3098            name,
3099            start: std::time::Instant::now(),
3100        }
3101    }
3102}
3103
3104impl<T: Display> Drop for FunctionTimer<T> {
3105    fn drop(&mut self) {
3106        log::trace!("{} elapsed: {:?}", self.name, self.start.elapsed())
3107    }
3108}
3109
3110const DEFAULT_COLORS: [[f32; 4]; 20] = [
3111    [0., 1., 0., 0.7],
3112    [1., 0.5568628, 0., 0.7],
3113    [0.25882353, 0.15294118, 0.13333333, 0.7],
3114    [0.8, 0.7647059, 0.78039216, 0.7],
3115    [0.3137255, 0.3137255, 0.3137255, 0.7],
3116    [0.1411765, 0.3098039, 0.1215686, 0.7],
3117    [1., 0.95686275, 0.5137255, 0.7],
3118    [0.3529412, 0.32156863, 0., 0.7],
3119    [0.4235294, 0.6235294, 0.6509804, 0.7],
3120    [0.5098039, 0.5098039, 0.7294118, 0.7],
3121    [0.00784314, 0.18823529, 0.29411765, 0.7],
3122    [0.0, 0.2706, 1.0, 0.7],
3123    [0.0, 0.0, 0.0, 0.7],
3124    [0.0, 0.5, 0.0, 0.7],
3125    [1.0, 0.0, 0.0, 0.7],
3126    [0.0, 0.0, 1.0, 0.7],
3127    [1.0, 0.5, 0.5, 0.7],
3128    [0.1333, 0.5451, 0.1333, 0.7],
3129    [0.1176, 0.4118, 0.8235, 0.7],
3130    [1., 1., 1., 0.7],
3131];
3132
3133const fn denorm<const M: usize, const N: usize>(a: [[f32; M]; N]) -> [[u8; M]; N] {
3134    let mut result = [[0; M]; N];
3135    let mut i = 0;
3136    while i < N {
3137        let mut j = 0;
3138        while j < M {
3139            result[i][j] = (a[i][j] * 255.0).round() as u8;
3140            j += 1;
3141        }
3142        i += 1;
3143    }
3144    result
3145}
3146
3147const DEFAULT_COLORS_U8: [[u8; 4]; 20] = denorm(DEFAULT_COLORS);
3148
3149#[cfg(test)]
3150#[cfg_attr(coverage_nightly, coverage(off))]
3151mod alignment_tests {
3152    use super::*;
3153
3154    #[test]
3155    fn align_width_rgba8_common_widths() {
3156        // RGBA8 (bpp=4, lcm(64,4)=64, so width must round to multiple of 16 px).
3157        assert_eq!(align_width_for_gpu_pitch(640, 4), 640); // 2560 byte pitch — already aligned
3158        assert_eq!(align_width_for_gpu_pitch(1280, 4), 1280); // 5120
3159        assert_eq!(align_width_for_gpu_pitch(1920, 4), 1920); // 7680
3160        assert_eq!(align_width_for_gpu_pitch(3840, 4), 3840); // 15360
3161                                                              // crowd.png case from the imx95 investigation:
3162        assert_eq!(align_width_for_gpu_pitch(3004, 4), 3008); // 12016 → 12032
3163        assert_eq!(align_width_for_gpu_pitch(3000, 4), 3008); // 12000 → 12032
3164        assert_eq!(align_width_for_gpu_pitch(17, 4), 32); // 68 → 128
3165        assert_eq!(align_width_for_gpu_pitch(1, 4), 16); // 4 → 64
3166    }
3167
3168    #[test]
3169    fn align_width_rgb888_packed() {
3170        // RGB888 (bpp=3, lcm(64,3)=192, so width must round to multiple of 64 px).
3171        assert_eq!(align_width_for_gpu_pitch(64, 3), 64); // 192 byte pitch
3172        assert_eq!(align_width_for_gpu_pitch(640, 3), 640); // 1920
3173        assert_eq!(align_width_for_gpu_pitch(1, 3), 64); // 3 → 192
3174        assert_eq!(align_width_for_gpu_pitch(65, 3), 128); // 195 → 384
3175                                                           // Verify the rounded width × bpp is a clean multiple of the LCM.
3176        for w in [3004usize, 1281, 100, 17] {
3177            let padded = align_width_for_gpu_pitch(w, 3);
3178            assert!(padded >= w);
3179            assert_eq!((padded * 3) % 64, 0);
3180            assert_eq!((padded * 3) % 3, 0);
3181        }
3182    }
3183
3184    #[test]
3185    fn align_width_grey_u8() {
3186        // Grey (bpp=1, lcm(64,1)=64, so width must round to multiple of 64 px).
3187        assert_eq!(align_width_for_gpu_pitch(64, 1), 64);
3188        assert_eq!(align_width_for_gpu_pitch(640, 1), 640);
3189        assert_eq!(align_width_for_gpu_pitch(1, 1), 64);
3190        assert_eq!(align_width_for_gpu_pitch(65, 1), 128);
3191    }
3192
3193    #[test]
3194    fn align_width_zero_inputs() {
3195        assert_eq!(align_width_for_gpu_pitch(0, 4), 0);
3196        assert_eq!(align_width_for_gpu_pitch(640, 0), 640);
3197    }
3198
3199    #[test]
3200    fn align_width_never_returns_smaller_than_input() {
3201        // Spot-check the "returned width >= input width" contract across a
3202        // range of values that would previously have hit `width * bpp`
3203        // overflow paths.
3204        for &bpp in &[1usize, 2, 3, 4, 8] {
3205            for &w in &[
3206                1usize,
3207                17,
3208                64,
3209                65,
3210                100,
3211                1280,
3212                1281,
3213                1920,
3214                3004,
3215                3072,
3216                3840,
3217                usize::MAX / 8,
3218                usize::MAX / 4,
3219                usize::MAX / 2,
3220                usize::MAX - 1,
3221                usize::MAX,
3222            ] {
3223                let aligned = align_width_for_gpu_pitch(w, bpp);
3224                assert!(
3225                    aligned >= w,
3226                    "align_width_for_gpu_pitch({w}, {bpp}) = {aligned} < {w}"
3227                );
3228            }
3229        }
3230    }
3231
3232    #[test]
3233    fn align_width_overflow_returns_unaligned_not_smaller() {
3234        // For width values close to usize::MAX, padding up would wrap. The
3235        // function must return the original width rather than wrapping or
3236        // panicking. A pre-aligned width round-trips unchanged even at the
3237        // extreme.
3238        let aligned_extreme = usize::MAX - 15; // 16-pixel boundary for RGBA8
3239        assert_eq!(
3240            align_width_for_gpu_pitch(aligned_extreme, 4),
3241            aligned_extreme
3242        );
3243        // A misaligned extreme value cannot be rounded up — the function
3244        // returns the original.
3245        let misaligned_extreme = usize::MAX - 1;
3246        let result = align_width_for_gpu_pitch(misaligned_extreme, 4);
3247        assert!(
3248            result == misaligned_extreme || result >= misaligned_extreme,
3249            "extreme misaligned width must not be rounded down to {result}"
3250        );
3251    }
3252
3253    #[test]
3254    fn checked_lcm_basic_and_overflow() {
3255        assert_eq!(checked_num_integer_lcm(64, 4), Some(64));
3256        assert_eq!(checked_num_integer_lcm(64, 3), Some(192));
3257        assert_eq!(checked_num_integer_lcm(64, 1), Some(64));
3258        assert_eq!(checked_num_integer_lcm(0, 4), Some(0));
3259        assert_eq!(checked_num_integer_lcm(64, 0), Some(0));
3260        // Coprime values whose product exceeds usize::MAX must return None.
3261        assert_eq!(
3262            checked_num_integer_lcm(usize::MAX, usize::MAX - 1),
3263            None,
3264            "coprime extreme values must overflow detect, not panic"
3265        );
3266    }
3267
3268    #[test]
3269    fn primary_plane_bpp_known_formats() {
3270        // Packed formats use channels × elem_size.
3271        assert_eq!(primary_plane_bpp(PixelFormat::Rgba, 1), Some(4));
3272        assert_eq!(primary_plane_bpp(PixelFormat::Bgra, 1), Some(4));
3273        assert_eq!(primary_plane_bpp(PixelFormat::Rgb, 1), Some(3));
3274        assert_eq!(primary_plane_bpp(PixelFormat::Grey, 1), Some(1));
3275        // Semi-planar (NV12) reports the luma plane's bpp.
3276        assert_eq!(primary_plane_bpp(PixelFormat::Nv12, 1), Some(1));
3277    }
3278}
3279
3280#[cfg(test)]
3281#[cfg_attr(coverage_nightly, coverage(off))]
3282#[allow(deprecated)]
3283mod image_tests {
3284    use super::*;
3285    use crate::{CPUProcessor, Rotation};
3286    #[cfg(target_os = "linux")]
3287    use edgefirst_tensor::is_dma_available;
3288    use edgefirst_tensor::{TensorMapTrait, TensorMemory, TensorTrait};
3289    use image::buffer::ConvertBuffer;
3290
3291    /// Test helper: call `ImageProcessorTrait::convert()` on two `TensorDyn`s
3292    /// by going through the `TensorDyn` API.
3293    ///
3294    /// Returns the `(src_image, dst_image)` reconstructed from the TensorDyn
3295    /// round-trip so the caller can feed them to `compare_images` etc.
3296    fn convert_img(
3297        proc: &mut dyn ImageProcessorTrait,
3298        src: TensorDyn,
3299        dst: TensorDyn,
3300        rotation: Rotation,
3301        flip: Flip,
3302        crop: Crop,
3303    ) -> (Result<()>, TensorDyn, TensorDyn) {
3304        let src_fourcc = src.format().unwrap();
3305        let dst_fourcc = dst.format().unwrap();
3306        let src_dyn = src;
3307        let mut dst_dyn = dst;
3308        let result = proc.convert(&src_dyn, &mut dst_dyn, rotation, flip, crop);
3309        let src_back = {
3310            let mut __t = src_dyn.into_u8().unwrap();
3311            __t.set_format(src_fourcc).unwrap();
3312            TensorDyn::from(__t)
3313        };
3314        let dst_back = {
3315            let mut __t = dst_dyn.into_u8().unwrap();
3316            __t.set_format(dst_fourcc).unwrap();
3317            TensorDyn::from(__t)
3318        };
3319        (result, src_back, dst_back)
3320    }
3321
3322    #[ctor::ctor(unsafe)]
3323    fn init() {
3324        env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
3325    }
3326
3327    macro_rules! function {
3328        () => {{
3329            fn f() {}
3330            fn type_name_of<T>(_: T) -> &'static str {
3331                std::any::type_name::<T>()
3332            }
3333            let name = type_name_of(f);
3334
3335            // Find and cut the rest of the path
3336            match &name[..name.len() - 3].rfind(':') {
3337                Some(pos) => &name[pos + 1..name.len() - 3],
3338                None => &name[..name.len() - 3],
3339            }
3340        }};
3341    }
3342
3343    /// Master oracle for the view/batch **destination** batch engine: render `N`
3344    /// tiles into row-bands of ONE tall destination and assert each band equals
3345    /// the same source converted standalone — proving correct band placement and
3346    /// that a later tile's letterbox clear / draw never wipes a sibling band. On
3347    /// the Linux GL backend the `N` `convert_deferred` calls share ONE parent
3348    /// EGLImage import (each tile is a `glViewport`/`glScissor` ROI) and sync
3349    /// once at `flush()`; other backends fall back to an eager per-band convert
3350    /// (CPU writes via offset + parent stride). Either way the oracle must hold.
3351    ///
3352    /// Identical source/tile size makes the convert an exact copy, so the
3353    /// assertion is backend-agnostic (no GL-vs-CPU resampling drift). Distinct
3354    /// solid colors per tile make any sibling wipe a hard failure.
3355    #[test]
3356    fn batch_view_dst_tiles_match_standalone() {
3357        let mut proc = match ImageProcessor::new() {
3358            Ok(p) => p,
3359            Err(e) => {
3360                eprintln!(
3361                    "SKIPPED: {} — ImageProcessor init failed ({e:?})",
3362                    function!()
3363                );
3364                return;
3365            }
3366        };
3367        let n = 3usize;
3368        let (w, h) = (32usize, 24usize);
3369        let colors: [[u8; 4]; 3] = [[210, 40, 40, 255], [40, 210, 40, 255], [40, 40, 210, 255]];
3370        let make_src = |c: [u8; 4]| -> TensorDyn {
3371            let bytes: Vec<u8> = c.iter().copied().cycle().take(w * h * 4).collect();
3372            load_bytes_to_tensor(w, h, PixelFormat::Rgba, Some(TensorMemory::Mem), &bytes).unwrap()
3373        };
3374        // Tall destination: N stacked row-bands. DMA so the Linux GL band path
3375        // runs (one parent import + per-tile glViewport); skip if unavailable.
3376        let parent = match TensorDyn::image(
3377            w,
3378            n * h,
3379            PixelFormat::Rgba,
3380            DType::U8,
3381            Some(TensorMemory::Dma),
3382            edgefirst_tensor::CpuAccess::ReadWrite,
3383        ) {
3384            Ok(d) => d,
3385            Err(e) => {
3386                eprintln!(
3387                    "SKIPPED: {} — tall DMA destination alloc failed ({e:?})",
3388                    function!()
3389                );
3390                return;
3391            }
3392        };
3393
3394        // Deferred batch: one parent import, glViewport/scissor per band, one sync.
3395        for (i, &c) in colors.iter().enumerate().take(n) {
3396            let mut tile = parent.view(Region::new(0, i * h, w, h)).unwrap();
3397            proc.convert_deferred(
3398                &make_src(c),
3399                &mut tile,
3400                Rotation::None,
3401                Flip::None,
3402                Crop::no_crop(),
3403            )
3404            .unwrap_or_else(|e| panic!("convert_deferred tile {i}: {e:?}"));
3405        }
3406        proc.flush().unwrap();
3407
3408        for (i, &c) in colors.iter().enumerate().take(n) {
3409            // Standalone full-buffer convert of the same source = the oracle.
3410            let mut solo = TensorDyn::image(
3411                w,
3412                h,
3413                PixelFormat::Rgba,
3414                DType::U8,
3415                Some(TensorMemory::Dma),
3416                edgefirst_tensor::CpuAccess::ReadWrite,
3417            )
3418            .unwrap();
3419            proc.convert(
3420                &make_src(c),
3421                &mut solo,
3422                Rotation::None,
3423                Flip::None,
3424                Crop::no_crop(),
3425            )
3426            .unwrap();
3427
3428            let band = parent.view(Region::new(0, i * h, w, h)).unwrap();
3429            let band_bytes = band.as_u8().unwrap().map().unwrap().as_slice().to_vec();
3430            let solo_bytes = solo.as_u8().unwrap().map().unwrap().as_slice().to_vec();
3431            assert_eq!(
3432                band_bytes, solo_bytes,
3433                "tile {i}: band differs from standalone convert (placement or sibling wipe)"
3434            );
3435            assert!(
3436                band_bytes.chunks_exact(4).all(|p| p == c),
3437                "tile {i}: band is not the expected solid color {c:?} (sibling wipe?)"
3438            );
3439        }
3440    }
3441
3442    /// Build a `w`×`h` RGBA frame whose pixels encode their position, so a crop
3443    /// at any origin has distinct content (catches crop-origin / band-placement
3444    /// bugs a solid color cannot).
3445    #[cfg(test)]
3446    fn gradient_frame(w: usize, h: usize) -> TensorDyn {
3447        let mut bytes = vec![0u8; w * h * 4];
3448        for y in 0..h {
3449            for x in 0..w {
3450                let i = (y * w + x) * 4;
3451                bytes[i] = x as u8;
3452                bytes[i + 1] = y as u8;
3453                bytes[i + 2] = (x ^ y) as u8;
3454                bytes[i + 3] = 255;
3455            }
3456        }
3457        load_bytes_to_tensor(w, h, PixelFormat::Rgba, Some(TensorMemory::Mem), &bytes).unwrap()
3458    }
3459
3460    /// `tile_into` band == standalone crop-convert of the same source region,
3461    /// on the CPU backend (runs on CI without a GPU). Distinct gradient content
3462    /// makes a wrong crop origin or a sibling-band wipe a hard failure.
3463    #[test]
3464    fn tile_into_cpu_distinct_content_parity() {
3465        let mut proc = match ImageProcessor::with_config(ImageProcessorConfig {
3466            backend: ComputeBackend::Cpu,
3467            ..Default::default()
3468        }) {
3469            Ok(p) => p,
3470            Err(e) => {
3471                eprintln!("SKIPPED: {} — CPU init failed ({e:?})", function!());
3472                return;
3473            }
3474        };
3475        let (fw, fh) = (96usize, 64usize);
3476        let src = gradient_frame(fw, fh);
3477        let cfg = TilingConfig::new(32, 32).with_overlap(0.0); // exact 3×2 tiling
3478        let n = tile_grid(fh, fw, 32, 32, 0.0).len();
3479        assert_eq!(n, 6);
3480
3481        let mut parent = proc
3482            .alloc_tile_batch(
3483                n,
3484                &cfg,
3485                PixelFormat::Rgba,
3486                DType::U8,
3487                Some(TensorMemory::Mem),
3488                edgefirst_tensor::CpuAccess::ReadWrite,
3489            )
3490            .unwrap();
3491        let placements = proc.tile_into(&src, &mut parent, &cfg).unwrap();
3492        assert_eq!(placements.len(), n);
3493
3494        for p in &placements {
3495            let source = Region::new(
3496                p.origin.0 as usize,
3497                p.origin.1 as usize,
3498                p.crop_size.0 as usize,
3499                p.crop_size.1 as usize,
3500            );
3501            let mut solo = TensorDyn::image(
3502                32,
3503                32,
3504                PixelFormat::Rgba,
3505                DType::U8,
3506                Some(TensorMemory::Mem),
3507                edgefirst_tensor::CpuAccess::ReadWrite,
3508            )
3509            .unwrap();
3510            proc.convert(
3511                &src,
3512                &mut solo,
3513                Rotation::None,
3514                Flip::None,
3515                Crop::default()
3516                    .with_source(Some(source))
3517                    .with_fit(Fit::Stretch),
3518            )
3519            .unwrap();
3520            let band = parent.view(Region::new(0, p.index * 32, 32, 32)).unwrap();
3521            let band_bytes = band.as_u8().unwrap().map().unwrap().as_slice().to_vec();
3522            let solo_bytes = solo.as_u8().unwrap().map().unwrap().as_slice().to_vec();
3523            assert_eq!(
3524                band_bytes, solo_bytes,
3525                "tile {} band differs from standalone crop-convert",
3526                p.index
3527            );
3528        }
3529    }
3530
3531    /// Streaming `tile_one` into a single slot == the corresponding batched
3532    /// `tile_into` band (proves the two paths agree).
3533    #[test]
3534    fn tile_one_matches_tile_into_band() {
3535        let mut proc = match ImageProcessor::with_config(ImageProcessorConfig {
3536            backend: ComputeBackend::Cpu,
3537            ..Default::default()
3538        }) {
3539            Ok(p) => p,
3540            Err(e) => {
3541                eprintln!("SKIPPED: {} — CPU init failed ({e:?})", function!());
3542                return;
3543            }
3544        };
3545        let (fw, fh) = (96usize, 64usize);
3546        let src = gradient_frame(fw, fh);
3547        let cfg = TilingConfig::new(32, 32).with_overlap(0.0);
3548        let n = tile_grid(fh, fw, 32, 32, 0.0).len();
3549
3550        let mut parent = proc
3551            .alloc_tile_batch(
3552                n,
3553                &cfg,
3554                PixelFormat::Rgba,
3555                DType::U8,
3556                Some(TensorMemory::Mem),
3557                edgefirst_tensor::CpuAccess::ReadWrite,
3558            )
3559            .unwrap();
3560        proc.tile_into(&src, &mut parent, &cfg).unwrap();
3561
3562        let plan = proc.plan_tiles(fw, fh, &cfg).unwrap();
3563        for p in &plan {
3564            let mut slot = TensorDyn::image(
3565                32,
3566                32,
3567                PixelFormat::Rgba,
3568                DType::U8,
3569                Some(TensorMemory::Mem),
3570                edgefirst_tensor::CpuAccess::ReadWrite,
3571            )
3572            .unwrap();
3573            proc.tile_one(&src, &mut slot, p, &cfg).unwrap();
3574            proc.flush().unwrap();
3575            let band = parent.view(Region::new(0, p.index * 32, 32, 32)).unwrap();
3576            let slot_bytes = slot.as_u8().unwrap().map().unwrap().as_slice().to_vec();
3577            let band_bytes = band.as_u8().unwrap().map().unwrap().as_slice().to_vec();
3578            assert_eq!(
3579                slot_bytes, band_bytes,
3580                "tile {} stream != batch band",
3581                p.index
3582            );
3583        }
3584    }
3585
3586    /// `plan_tiles` returns correct per-tile metadata for a 4K frame (pure, no
3587    /// GPU render).
3588    /// `tile_into` through the auto backend with a DMA parent — exercises the
3589    /// GL single-import band path on a GPU machine (skips when DMA/GL is
3590    /// unavailable). The source is sampled via `Crop::with_source`, never
3591    /// `src.view()`, so all tiles share one source import.
3592    #[test]
3593    fn tile_into_auto_dma_parity() {
3594        let mut proc = match ImageProcessor::new() {
3595            Ok(p) => p,
3596            Err(e) => {
3597                eprintln!(
3598                    "SKIPPED: {} — ImageProcessor init failed ({e:?})",
3599                    function!()
3600                );
3601                return;
3602            }
3603        };
3604        let (fw, fh) = (96usize, 64usize);
3605        let src = gradient_frame(fw, fh);
3606        let cfg = TilingConfig::new(32, 32).with_overlap(0.0);
3607        let n = tile_grid(fh, fw, 32, 32, 0.0).len();
3608
3609        let mut parent = match proc.alloc_tile_batch(
3610            n,
3611            &cfg,
3612            PixelFormat::Rgba,
3613            DType::U8,
3614            Some(TensorMemory::Dma),
3615            edgefirst_tensor::CpuAccess::ReadWrite,
3616        ) {
3617            Ok(p) => p,
3618            Err(e) => {
3619                eprintln!(
3620                    "SKIPPED: {} — tall DMA parent alloc failed ({e:?})",
3621                    function!()
3622                );
3623                return;
3624            }
3625        };
3626        let placements = proc.tile_into(&src, &mut parent, &cfg).unwrap();
3627
3628        for p in &placements {
3629            let source = Region::new(
3630                p.origin.0 as usize,
3631                p.origin.1 as usize,
3632                p.crop_size.0 as usize,
3633                p.crop_size.1 as usize,
3634            );
3635            let mut solo = TensorDyn::image(
3636                32,
3637                32,
3638                PixelFormat::Rgba,
3639                DType::U8,
3640                Some(TensorMemory::Dma),
3641                edgefirst_tensor::CpuAccess::ReadWrite,
3642            )
3643            .unwrap();
3644            proc.convert(
3645                &src,
3646                &mut solo,
3647                Rotation::None,
3648                Flip::None,
3649                Crop::default()
3650                    .with_source(Some(source))
3651                    .with_fit(Fit::Stretch),
3652            )
3653            .unwrap();
3654            let band = parent.view(Region::new(0, p.index * 32, 32, 32)).unwrap();
3655            // Structural-similarity tolerance (the house GPU-parity bar) rather
3656            // than exact bytes: rendering a tile into a viewport band at a
3657            // non-zero offset vs. a standalone origin render diverges by sampling
3658            // rounding on virtualized GPUs (paravirtual Metal/ANGLE on macOS CI),
3659            // the same tolerance class as the other cross-backend parity tests.
3660            compare_images(
3661                &band,
3662                &solo,
3663                0.98,
3664                &format!("{}_tile{}", function!(), p.index),
3665            );
3666        }
3667    }
3668
3669    /// `tile_into` rejects a destination too small to hold all tile bands.
3670    #[test]
3671    fn tile_into_undersized_dst_errors() {
3672        let mut proc = match ImageProcessor::with_config(ImageProcessorConfig {
3673            backend: ComputeBackend::Cpu,
3674            ..Default::default()
3675        }) {
3676            Ok(p) => p,
3677            Err(e) => {
3678                eprintln!("SKIPPED: {} — CPU init failed ({e:?})", function!());
3679                return;
3680            }
3681        };
3682        let (fw, fh) = (96usize, 64usize);
3683        let src = gradient_frame(fw, fh);
3684        let cfg = TilingConfig::new(32, 32).with_overlap(0.0); // 6 tiles
3685                                                               // Allocate a parent for only 2 bands, far short of 6.
3686        let mut small = TensorDyn::image(
3687            32,
3688            2 * 32,
3689            PixelFormat::Rgba,
3690            DType::U8,
3691            Some(TensorMemory::Mem),
3692            edgefirst_tensor::CpuAccess::ReadWrite,
3693        )
3694        .unwrap();
3695        let r = proc.tile_into(&src, &mut small, &cfg);
3696        assert!(
3697            matches!(r, Err(Error::InvalidShape(_))),
3698            "expected InvalidShape, got {r:?}"
3699        );
3700    }
3701
3702    /// `alloc_tile_batch` / `plan_tiles` reject an invalid config (zero tile).
3703    #[test]
3704    fn tiling_alloc_rejects_invalid_config() {
3705        let proc = match ImageProcessor::with_config(ImageProcessorConfig {
3706            backend: ComputeBackend::Cpu,
3707            ..Default::default()
3708        }) {
3709            Ok(p) => p,
3710            Err(e) => {
3711                eprintln!("SKIPPED: {} — CPU init failed ({e:?})", function!());
3712                return;
3713            }
3714        };
3715        let bad = TilingConfig::new(0, 640);
3716        assert!(proc.plan_tiles(1920, 1080, &bad).is_err());
3717        assert!(proc
3718            .alloc_tile_batch(
3719                4,
3720                &bad,
3721                PixelFormat::Rgba,
3722                DType::U8,
3723                Some(TensorMemory::Mem),
3724                edgefirst_tensor::CpuAccess::ReadWrite,
3725            )
3726            .is_err());
3727    }
3728
3729    #[test]
3730    fn plan_tiles_metadata_4k() {
3731        let proc = match ImageProcessor::with_config(ImageProcessorConfig {
3732            backend: ComputeBackend::Cpu,
3733            ..Default::default()
3734        }) {
3735            Ok(p) => p,
3736            Err(e) => {
3737                eprintln!("SKIPPED: {} — CPU init failed ({e:?})", function!());
3738                return;
3739            }
3740        };
3741        let cfg = TilingConfig::new(640, 640).with_overlap(0.2);
3742        let plan = proc.plan_tiles(3840, 2160, &cfg).unwrap();
3743        assert_eq!(plan.len(), 32);
3744        assert!(plan.iter().all(|p| p.count == 32));
3745        assert!(plan.iter().all(|p| p.crop_size == (640.0, 640.0)));
3746        assert!(plan.iter().all(|p| p.letterbox.is_none())); // stretch fit
3747        assert!(plan.iter().all(|p| p.frame_dims == (3840.0, 2160.0)));
3748        assert_eq!(plan[0].origin, (0.0, 0.0));
3749    }
3750
3751    #[test]
3752    fn test_invalid_crop() {
3753        let src = TensorDyn::image(
3754            100,
3755            100,
3756            PixelFormat::Rgb,
3757            DType::U8,
3758            None,
3759            edgefirst_tensor::CpuAccess::ReadWrite,
3760        )
3761        .unwrap();
3762        let dst = TensorDyn::image(
3763            100,
3764            100,
3765            PixelFormat::Rgb,
3766            DType::U8,
3767            None,
3768            edgefirst_tensor::CpuAccess::ReadWrite,
3769        )
3770        .unwrap();
3771
3772        // A source crop exceeding the source bounds is rejected.
3773        let crop = Crop::new().with_source(Some(Region::new(50, 50, 60, 60)));
3774        assert!(matches!(
3775            crop.check_crop_dyn(&src, &dst),
3776            Err(Error::CropInvalid(_))
3777        ));
3778
3779        // A source crop within bounds is valid.
3780        let crop = Crop::new().with_source(Some(Region::new(0, 0, 10, 10)));
3781        assert!(crop.check_crop_dyn(&src, &dst).is_ok());
3782
3783        // Letterbox is always valid — placement is computed within the dst.
3784        assert!(Crop::letterbox([0, 0, 0, 255])
3785            .check_crop_dyn(&src, &dst)
3786            .is_ok());
3787    }
3788
3789    #[test]
3790    fn test_invalid_tensor_format() -> Result<(), Error> {
3791        // 4D tensor cannot be set to a 3-channel pixel format
3792        let mut tensor = Tensor::<u8>::new(&[720, 1280, 4, 1], None, None)?;
3793        let result = tensor.set_format(PixelFormat::Rgb);
3794        assert!(result.is_err(), "4D tensor should reject set_format");
3795
3796        // Tensor with wrong channel count for the format
3797        let mut tensor = Tensor::<u8>::new(&[720, 1280, 4], None, None)?;
3798        let result = tensor.set_format(PixelFormat::Rgb);
3799        assert!(result.is_err(), "4-channel tensor should reject RGB format");
3800
3801        Ok(())
3802    }
3803
3804    #[test]
3805    fn test_invalid_image_file() -> Result<(), Error> {
3806        let result = crate::load_image_test_helper(&[123; 5000], None, None);
3807        assert!(
3808            matches!(result, Err(Error::Codec(_))),
3809            "unrecognised bytes should surface as Error::Codec, got {result:?}"
3810        );
3811        Ok(())
3812    }
3813
3814    #[test]
3815    fn test_invalid_jpeg_format() -> Result<(), Error> {
3816        let result = crate::load_image_test_helper(&[123; 5000], Some(PixelFormat::Yuyv), None);
3817        // YUYV is not a valid decode target; peek_info fails before the magic-
3818        // bytes check, so the precise variant depends on which error fires first.
3819        assert!(
3820            matches!(result, Err(Error::Codec(_))),
3821            "Yuyv target with garbage bytes should surface as Error::Codec, got {result:?}"
3822        );
3823        Ok(())
3824    }
3825
3826    #[test]
3827    fn test_load_resize_save() {
3828        let file = edgefirst_bench::testdata::read("zidane.jpg");
3829        let img = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
3830        assert_eq!(img.width(), Some(1280));
3831        assert_eq!(img.height(), Some(720));
3832
3833        let dst = TensorDyn::image(
3834            640,
3835            360,
3836            PixelFormat::Rgba,
3837            DType::U8,
3838            None,
3839            edgefirst_tensor::CpuAccess::ReadWrite,
3840        )
3841        .unwrap();
3842        let mut converter = CPUProcessor::new();
3843        let (result, _img, dst) = convert_img(
3844            &mut converter,
3845            img,
3846            dst,
3847            Rotation::None,
3848            Flip::None,
3849            Crop::no_crop(),
3850        );
3851        result.unwrap();
3852        assert_eq!(dst.width(), Some(640));
3853        assert_eq!(dst.height(), Some(360));
3854
3855        crate::save_jpeg(&dst, "zidane_resized.jpg", 80).unwrap();
3856
3857        let file = std::fs::read("zidane_resized.jpg").unwrap();
3858        // With `format: None` the helper returns the source's native format.
3859        // The codec now decodes colour JPEGs to NV12 (was RGB previously).
3860        let img = crate::load_image_test_helper(&file, None, None).unwrap();
3861        assert_eq!(img.width(), Some(640));
3862        assert_eq!(img.height(), Some(360));
3863        assert_eq!(img.format().unwrap(), PixelFormat::Nv12);
3864    }
3865
3866    #[test]
3867    fn test_from_tensor_planar() -> Result<(), Error> {
3868        let mut tensor = Tensor::new(&[3, 720, 1280], None, None)?;
3869        tensor
3870            .map()?
3871            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.8bps"));
3872        let planar = {
3873            tensor
3874                .set_format(PixelFormat::PlanarRgb)
3875                .map_err(|e| crate::Error::Internal(e.to_string()))?;
3876            TensorDyn::from(tensor)
3877        };
3878
3879        let rbga = load_bytes_to_tensor(
3880            1280,
3881            720,
3882            PixelFormat::Rgba,
3883            None,
3884            &edgefirst_bench::testdata::read("camera720p.rgba"),
3885        )?;
3886        compare_images_convert_to_rgb(&planar, &rbga, 0.98, function!());
3887
3888        Ok(())
3889    }
3890
3891    #[test]
3892    fn test_from_tensor_invalid_format() {
3893        // PixelFormat::from_fourcc_str returns None for unknown FourCC codes.
3894        // Since there's no "TEST" pixel format, this validates graceful handling.
3895        assert!(PixelFormat::from_fourcc(u32::from_le_bytes(*b"TEST")).is_none());
3896    }
3897
3898    #[test]
3899    #[should_panic(expected = "Failed to save planar RGB image")]
3900    fn test_save_planar() {
3901        let planar_img = load_bytes_to_tensor(
3902            1280,
3903            720,
3904            PixelFormat::PlanarRgb,
3905            None,
3906            &edgefirst_bench::testdata::read("camera720p.8bps"),
3907        )
3908        .unwrap();
3909
3910        let save_path = "/tmp/planar_rgb.jpg";
3911        crate::save_jpeg(&planar_img, save_path, 90).expect("Failed to save planar RGB image");
3912    }
3913
3914    #[test]
3915    #[should_panic(expected = "Failed to save YUYV image")]
3916    fn test_save_yuyv() {
3917        let planar_img = load_bytes_to_tensor(
3918            1280,
3919            720,
3920            PixelFormat::Yuyv,
3921            None,
3922            &edgefirst_bench::testdata::read("camera720p.yuyv"),
3923        )
3924        .unwrap();
3925
3926        let save_path = "/tmp/yuyv.jpg";
3927        crate::save_jpeg(&planar_img, save_path, 90).expect("Failed to save YUYV image");
3928    }
3929
3930    #[test]
3931    fn test_rotation_angle() {
3932        assert_eq!(Rotation::from_degrees_clockwise(0), Rotation::None);
3933        assert_eq!(Rotation::from_degrees_clockwise(90), Rotation::Clockwise90);
3934        assert_eq!(Rotation::from_degrees_clockwise(180), Rotation::Rotate180);
3935        assert_eq!(
3936            Rotation::from_degrees_clockwise(270),
3937            Rotation::CounterClockwise90
3938        );
3939        assert_eq!(Rotation::from_degrees_clockwise(360), Rotation::None);
3940        assert_eq!(Rotation::from_degrees_clockwise(450), Rotation::Clockwise90);
3941        assert_eq!(Rotation::from_degrees_clockwise(540), Rotation::Rotate180);
3942        assert_eq!(
3943            Rotation::from_degrees_clockwise(630),
3944            Rotation::CounterClockwise90
3945        );
3946    }
3947
3948    #[test]
3949    #[should_panic(expected = "rotation angle is not a multiple of 90")]
3950    fn test_rotation_angle_panic() {
3951        Rotation::from_degrees_clockwise(361);
3952    }
3953
3954    #[test]
3955    fn test_disable_env_var() -> Result<(), Error> {
3956        // Acquire the env-var mutex for the entire test body so we never race
3957        // with test_force_backend_* or test_draw_proto_masks_no_cpu_returns_error.
3958        let _lock = acquire_env_lock();
3959
3960        // Snapshot ALL env vars we might touch so the RAII guard restores them
3961        // on exit (even on panic), preventing env-var poisoning of other tests.
3962        let _guard = EnvGuard::snapshot(&[
3963            "EDGEFIRST_FORCE_BACKEND",
3964            "EDGEFIRST_DISABLE_GL",
3965            "EDGEFIRST_DISABLE_G2D",
3966            "EDGEFIRST_DISABLE_CPU",
3967        ]);
3968
3969        // EDGEFIRST_FORCE_BACKEND takes precedence over EDGEFIRST_DISABLE_*,
3970        // so clear it for the duration of this test.
3971        unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") };
3972
3973        #[cfg(target_os = "linux")]
3974        {
3975            unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
3976            let converter = ImageProcessor::new()?;
3977            assert!(converter.g2d.is_none());
3978            unsafe { std::env::remove_var("EDGEFIRST_DISABLE_G2D") };
3979        }
3980
3981        #[cfg(target_os = "linux")]
3982        #[cfg(feature = "opengl")]
3983        {
3984            unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
3985            let converter = ImageProcessor::new()?;
3986            assert!(converter.opengl.is_none());
3987            unsafe { std::env::remove_var("EDGEFIRST_DISABLE_GL") };
3988        }
3989
3990        unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
3991        let converter = ImageProcessor::new()?;
3992        assert!(converter.cpu.is_none());
3993        unsafe { std::env::remove_var("EDGEFIRST_DISABLE_CPU") };
3994
3995        // Disable everything — convert must return NoConverter.
3996        unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
3997        unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
3998        unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
3999        let mut converter = ImageProcessor::new()?;
4000
4001        let src = TensorDyn::image(
4002            1280,
4003            720,
4004            PixelFormat::Rgba,
4005            DType::U8,
4006            None,
4007            edgefirst_tensor::CpuAccess::ReadWrite,
4008        )?;
4009        let dst = TensorDyn::image(
4010            640,
4011            360,
4012            PixelFormat::Rgba,
4013            DType::U8,
4014            None,
4015            edgefirst_tensor::CpuAccess::ReadWrite,
4016        )?;
4017        let (result, _src, _dst) = convert_img(
4018            &mut converter,
4019            src,
4020            dst,
4021            Rotation::None,
4022            Flip::None,
4023            Crop::no_crop(),
4024        );
4025        assert!(matches!(result, Err(Error::NoConverter)));
4026        // _guard restores all env vars on drop.
4027        Ok(())
4028    }
4029
4030    #[test]
4031    fn test_unsupported_conversion() {
4032        let src = TensorDyn::image(
4033            1280,
4034            720,
4035            PixelFormat::Nv12,
4036            DType::U8,
4037            None,
4038            edgefirst_tensor::CpuAccess::ReadWrite,
4039        )
4040        .unwrap();
4041        let dst = TensorDyn::image(
4042            640,
4043            360,
4044            PixelFormat::Nv12,
4045            DType::U8,
4046            None,
4047            edgefirst_tensor::CpuAccess::ReadWrite,
4048        )
4049        .unwrap();
4050        let mut converter = ImageProcessor::new().unwrap();
4051        let (result, _src, _dst) = convert_img(
4052            &mut converter,
4053            src,
4054            dst,
4055            Rotation::None,
4056            Flip::None,
4057            Crop::no_crop(),
4058        );
4059        log::debug!("result: {:?}", result);
4060        assert!(matches!(
4061            result,
4062            Err(Error::NotSupported(e)) if e.starts_with("Conversion from NV12 to NV12")
4063        ));
4064    }
4065
4066    #[test]
4067    fn test_load_grey() {
4068        // A single-component (greyscale) JPEG decodes to its native GREY
4069        // format, which has no even-dimension constraint, so the 1024×681
4070        // `grey.jpg` loads and converts to RGBA successfully.
4071        let grey_img = crate::load_image_test_helper(
4072            &edgefirst_bench::testdata::read("grey.jpg"),
4073            Some(PixelFormat::Rgba),
4074            None,
4075        )
4076        .unwrap();
4077        assert_eq!(grey_img.width(), Some(1024));
4078        assert_eq!(grey_img.height(), Some(681));
4079
4080        // `grey-rgb.jpg` holds the same grey content but is encoded as a
4081        // 3-component (colour) JPEG, so the codec decodes it to native NV12.
4082        // Its 1024×681 dimensions have an odd height; NV12 now represents odd
4083        // dimensions via the `H + ceil(H/2)` combined-plane height, so the
4084        // decode succeeds and converts to RGBA at the true dimensions.
4085        let grey_but_rgb = crate::load_image_test_helper(
4086            &edgefirst_bench::testdata::read("grey-rgb.jpg"),
4087            Some(PixelFormat::Rgba),
4088            None,
4089        )
4090        .expect("odd-height colour JPEG should decode to NV12 and convert to RGBA");
4091        assert_eq!(grey_but_rgb.width(), Some(1024));
4092        assert_eq!(grey_but_rgb.height(), Some(681));
4093    }
4094
4095    #[test]
4096    fn test_new_nv12() {
4097        let nv12 = TensorDyn::image(
4098            1280,
4099            720,
4100            PixelFormat::Nv12,
4101            DType::U8,
4102            None,
4103            edgefirst_tensor::CpuAccess::ReadWrite,
4104        )
4105        .unwrap();
4106        assert_eq!(nv12.height(), Some(720));
4107        assert_eq!(nv12.width(), Some(1280));
4108        assert_eq!(nv12.format().unwrap(), PixelFormat::Nv12);
4109        // PixelFormat::Nv12.channels() returns 1 (luma plane channel count)
4110        assert_eq!(nv12.format().unwrap().channels(), 1);
4111        assert!(nv12.format().is_some_and(
4112            |f| f.layout() == PixelLayout::Planar || f.layout() == PixelLayout::SemiPlanar
4113        ))
4114    }
4115
4116    #[test]
4117    #[cfg(target_os = "linux")]
4118    fn test_new_image_converter() {
4119        let dst_width = 640;
4120        let dst_height = 360;
4121        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4122        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4123
4124        let mut converter = ImageProcessor::new().unwrap();
4125        let converter_dst = converter
4126            .create_image(
4127                dst_width,
4128                dst_height,
4129                PixelFormat::Rgba,
4130                DType::U8,
4131                None,
4132                edgefirst_tensor::CpuAccess::ReadWrite,
4133            )
4134            .unwrap();
4135        let (result, src, converter_dst) = convert_img(
4136            &mut converter,
4137            src,
4138            converter_dst,
4139            Rotation::None,
4140            Flip::None,
4141            Crop::no_crop(),
4142        );
4143        result.unwrap();
4144
4145        let cpu_dst = TensorDyn::image(
4146            dst_width,
4147            dst_height,
4148            PixelFormat::Rgba,
4149            DType::U8,
4150            None,
4151            edgefirst_tensor::CpuAccess::ReadWrite,
4152        )
4153        .unwrap();
4154        let mut cpu_converter = CPUProcessor::new();
4155        let (result, _src, cpu_dst) = convert_img(
4156            &mut cpu_converter,
4157            src,
4158            cpu_dst,
4159            Rotation::None,
4160            Flip::None,
4161            Crop::no_crop(),
4162        );
4163        result.unwrap();
4164
4165        compare_images(&converter_dst, &cpu_dst, 0.98, function!());
4166    }
4167
4168    #[test]
4169    #[cfg(target_os = "linux")]
4170    fn test_create_image_dtype_i8() {
4171        let mut converter = ImageProcessor::new().unwrap();
4172
4173        // I8 image should allocate successfully via create_image
4174        let dst = converter
4175            .create_image(
4176                320,
4177                240,
4178                PixelFormat::Rgb,
4179                DType::I8,
4180                None,
4181                edgefirst_tensor::CpuAccess::ReadWrite,
4182            )
4183            .unwrap();
4184        assert_eq!(dst.dtype(), DType::I8);
4185        assert!(dst.width() == Some(320));
4186        assert!(dst.height() == Some(240));
4187        assert_eq!(dst.format(), Some(PixelFormat::Rgb));
4188
4189        // U8 for comparison
4190        let dst_u8 = converter
4191            .create_image(
4192                320,
4193                240,
4194                PixelFormat::Rgb,
4195                DType::U8,
4196                None,
4197                edgefirst_tensor::CpuAccess::ReadWrite,
4198            )
4199            .unwrap();
4200        assert_eq!(dst_u8.dtype(), DType::U8);
4201
4202        // Convert into I8 dst should succeed
4203        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4204        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4205        let mut dst_i8 = converter
4206            .create_image(
4207                320,
4208                240,
4209                PixelFormat::Rgb,
4210                DType::I8,
4211                None,
4212                edgefirst_tensor::CpuAccess::ReadWrite,
4213            )
4214            .unwrap();
4215        converter
4216            .convert(
4217                &src,
4218                &mut dst_i8,
4219                Rotation::None,
4220                Flip::None,
4221                Crop::no_crop(),
4222            )
4223            .unwrap();
4224    }
4225
4226    #[test]
4227    #[cfg(target_os = "linux")]
4228    fn test_create_image_nv12_dma_non_aligned_width() {
4229        // create_image is fully stride-aware: a non-64-aligned NV12 DMA tensor
4230        // may legitimately carry a GPU-pitch-padded row stride — that is the
4231        // intended behaviour, not a bug. Verify the logical geometry is preserved
4232        // for any width and that a reported stride is a valid (>= logical)
4233        // padding, rather than asserting the absence of a stride.
4234        let converter = ImageProcessor::new().unwrap();
4235
4236        // 100 is intentionally not a multiple of 64 (the GPU pitch alignment).
4237        let result = converter.create_image(
4238            100,
4239            64,
4240            PixelFormat::Nv12,
4241            DType::U8,
4242            Some(TensorMemory::Dma),
4243            edgefirst_tensor::CpuAccess::ReadWrite,
4244        );
4245
4246        match result {
4247            Ok(img) => {
4248                assert_eq!(img.width(), Some(100));
4249                assert_eq!(img.height(), Some(64));
4250                assert_eq!(img.format(), Some(PixelFormat::Nv12));
4251                if let Some(stride) = img.row_stride() {
4252                    assert!(
4253                        stride >= 100,
4254                        "NV12 row_stride {stride} must be >= the logical width (100)",
4255                    );
4256                }
4257            }
4258            Err(e) => {
4259                // Skip cleanly on hosts without a dma-heap.
4260                eprintln!("SKIPPED: create_image NV12 DMA non-aligned width: {e}");
4261            }
4262        }
4263    }
4264
4265    #[test]
4266    #[ignore] // Hangs on desktop platforms where DMA-buf is unavailable and PBO
4267              // fallback triggers a GPU driver hang during SHM→texture upload (e.g.,
4268              // NVIDIA without /dev/dma_heap permissions). Works on embedded targets.
4269    fn test_crop_skip() {
4270        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4271        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4272
4273        let mut converter = ImageProcessor::new().unwrap();
4274        let converter_dst = converter
4275            .create_image(
4276                1280,
4277                720,
4278                PixelFormat::Rgba,
4279                DType::U8,
4280                None,
4281                edgefirst_tensor::CpuAccess::ReadWrite,
4282            )
4283            .unwrap();
4284        let crop = Crop::new().with_source(Some(Region::new(0, 0, 640, 640)));
4285        let (result, src, converter_dst) = convert_img(
4286            &mut converter,
4287            src,
4288            converter_dst,
4289            Rotation::None,
4290            Flip::None,
4291            crop,
4292        );
4293        result.unwrap();
4294
4295        let cpu_dst = TensorDyn::image(
4296            1280,
4297            720,
4298            PixelFormat::Rgba,
4299            DType::U8,
4300            None,
4301            edgefirst_tensor::CpuAccess::ReadWrite,
4302        )
4303        .unwrap();
4304        let mut cpu_converter = CPUProcessor::new();
4305        let (result, _src, cpu_dst) = convert_img(
4306            &mut cpu_converter,
4307            src,
4308            cpu_dst,
4309            Rotation::None,
4310            Flip::None,
4311            crop,
4312        );
4313        result.unwrap();
4314
4315        compare_images(&converter_dst, &cpu_dst, 0.99999, function!());
4316    }
4317
4318    #[test]
4319    fn test_invalid_pixel_format() {
4320        // PixelFormat::from_fourcc returns None for unknown formats,
4321        // so TensorDyn::image cannot be called with an invalid format.
4322        assert!(PixelFormat::from_fourcc(u32::from_le_bytes(*b"TEST")).is_none());
4323    }
4324
4325    // Helper function to check if G2D library is available (Linux/i.MX8 only)
4326    #[cfg(target_os = "linux")]
4327    static G2D_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
4328
4329    #[cfg(target_os = "linux")]
4330    fn is_g2d_available() -> bool {
4331        *G2D_AVAILABLE.get_or_init(|| G2DProcessor::new().is_ok())
4332    }
4333
4334    #[cfg(target_os = "linux")]
4335    #[cfg(feature = "opengl")]
4336    static GL_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
4337
4338    #[cfg(target_os = "linux")]
4339    #[cfg(feature = "opengl")]
4340    // Helper function to check if OpenGL is available
4341    fn is_opengl_available() -> bool {
4342        #[cfg(all(target_os = "linux", feature = "opengl"))]
4343        {
4344            *GL_AVAILABLE.get_or_init(|| GLProcessorThreaded::new(None).is_ok())
4345        }
4346
4347        #[cfg(not(all(target_os = "linux", feature = "opengl")))]
4348        {
4349            false
4350        }
4351    }
4352
4353    /// CI canary: fails the lane when the GL backend cannot initialize.
4354    ///
4355    /// Every GL test in this suite self-skips when the backend is
4356    /// unavailable — correct for developer machines, but it means a broken
4357    /// CI GL stack (e.g. the macOS ANGLE re-sign step regressing, the exact
4358    /// failure mode documented in the workflow) ships an untested GL
4359    /// backend behind a green lane. Gated on `HAL_TEST_REQUIRE_GL=1`, set
4360    /// only by CI jobs that install a working GL stack; local runs without
4361    /// one pass trivially. On macOS it additionally requires
4362    /// `HAL_TEST_ALLOW_DLOPEN_ANGLE`, so coverage pass 1 (unsigned
4363    /// binaries, dlopen gate closed) skips it and pass 2 (signed) enforces.
4364    #[test]
4365    #[cfg(feature = "opengl")]
4366    fn gl_backend_available_canary() {
4367        let require_gl = std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1");
4368        if !require_gl {
4369            eprintln!(
4370                "SKIPPED: {} — HAL_TEST_REQUIRE_GL is not set to 1",
4371                function!()
4372            );
4373            return;
4374        }
4375        #[cfg(target_os = "macos")]
4376        if std::env::var_os("HAL_TEST_ALLOW_DLOPEN_ANGLE").is_none() {
4377            eprintln!(
4378                "SKIPPED: {} — ANGLE dlopen gate closed (coverage pass 1)",
4379                function!()
4380            );
4381            return;
4382        }
4383        GLProcessorThreaded::new(None).expect(
4384            "HAL_TEST_REQUIRE_GL=1 but the GL backend failed to initialize — \
4385             check the ANGLE install/re-sign step and binary entitlements \
4386             (macOS) or the EGL stack (Linux)",
4387        );
4388    }
4389
4390    #[test]
4391    fn test_load_jpeg_with_exif() {
4392        use edgefirst_codec::peek_info;
4393
4394        // The migrated codec NEVER applies EXIF orientation: it decodes to the
4395        // source's native (un-rotated) dimensions and reports the rotation via
4396        // ImageInfo. `zidane_rotated_exif.jpg` carries EXIF orientation 6
4397        // (90° clockwise) over a 1280×720 frame.
4398        let file = edgefirst_bench::testdata::read("zidane_rotated_exif.jpg").to_vec();
4399        let info = peek_info(&file).unwrap();
4400        assert_eq!(info.rotation_degrees, 90);
4401        assert!(!info.flip_horizontal);
4402
4403        let loaded = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4404        // Native (un-rotated) dimensions — the decode does not rotate.
4405        assert_eq!(loaded.width(), Some(1280));
4406        assert_eq!(loaded.height(), Some(720));
4407
4408        // Applying the reported rotation downstream reproduces the upright
4409        // image: it matches `zidane.jpg` rotated by the same 90° clockwise.
4410        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4411        let cpu_src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4412
4413        let rotation = Rotation::from_degrees_clockwise(info.rotation_degrees as usize);
4414        let (dst_width, dst_height) = (cpu_src.height().unwrap(), cpu_src.width().unwrap());
4415
4416        let cpu_dst = TensorDyn::image(
4417            dst_width,
4418            dst_height,
4419            PixelFormat::Rgba,
4420            DType::U8,
4421            None,
4422            edgefirst_tensor::CpuAccess::ReadWrite,
4423        )
4424        .unwrap();
4425        let mut cpu_converter = CPUProcessor::new();
4426
4427        // Rotate the native-orientation `loaded` frame and the native `zidane`
4428        // frame by the same reported rotation; the results must agree.
4429        let loaded_rotated = TensorDyn::image(
4430            dst_width,
4431            dst_height,
4432            PixelFormat::Rgba,
4433            DType::U8,
4434            None,
4435            edgefirst_tensor::CpuAccess::ReadWrite,
4436        )
4437        .unwrap();
4438        let (r0, _loaded, loaded_rotated) = convert_img(
4439            &mut cpu_converter,
4440            loaded,
4441            loaded_rotated,
4442            rotation,
4443            Flip::None,
4444            Crop::no_crop(),
4445        );
4446        r0.unwrap();
4447
4448        let (result, _cpu_src, cpu_dst) = convert_img(
4449            &mut cpu_converter,
4450            cpu_src,
4451            cpu_dst,
4452            rotation,
4453            Flip::None,
4454            Crop::no_crop(),
4455        );
4456        result.unwrap();
4457
4458        compare_images(&loaded_rotated, &cpu_dst, 0.98, function!());
4459    }
4460
4461    #[test]
4462    fn test_load_png_with_exif() {
4463        use edgefirst_codec::peek_info;
4464
4465        // PNGs also report EXIF orientation without applying it.
4466        // `zidane_rotated_exif_180.png` carries EXIF orientation 3 (180°).
4467        let file = edgefirst_bench::testdata::read("zidane_rotated_exif_180.png").to_vec();
4468        let info = peek_info(&file).unwrap();
4469        assert_eq!(info.rotation_degrees, 180);
4470        assert!(!info.flip_horizontal);
4471
4472        let loaded = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4473        // Native (un-rotated) dimensions — PNG decodes upright as authored.
4474        assert_eq!(loaded.height(), Some(720));
4475        assert_eq!(loaded.width(), Some(1280));
4476
4477        // The PNG fixture stores upright `zidane` pixels tagged with a 180°
4478        // EXIF orientation. Because the codec no longer applies the rotation,
4479        // the decoded pixels match `zidane.jpg` directly (no convert needed).
4480        // Re-applying the reported rotation to both must still agree.
4481        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4482        let cpu_src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4483
4484        let rotation = Rotation::from_degrees_clockwise(info.rotation_degrees as usize);
4485        let cpu_dst = TensorDyn::image(
4486            1280,
4487            720,
4488            PixelFormat::Rgba,
4489            DType::U8,
4490            None,
4491            edgefirst_tensor::CpuAccess::ReadWrite,
4492        )
4493        .unwrap();
4494        let mut cpu_converter = CPUProcessor::new();
4495
4496        let (result, _cpu_src, cpu_dst) = convert_img(
4497            &mut cpu_converter,
4498            cpu_src,
4499            cpu_dst,
4500            rotation,
4501            Flip::None,
4502            Crop::no_crop(),
4503        );
4504        result.unwrap();
4505
4506        // Rotate the decoded PNG by the same reported angle so both frames are
4507        // in the same (180°-rotated) orientation before comparing.
4508        let loaded_rotated = TensorDyn::image(
4509            1280,
4510            720,
4511            PixelFormat::Rgba,
4512            DType::U8,
4513            None,
4514            edgefirst_tensor::CpuAccess::ReadWrite,
4515        )
4516        .unwrap();
4517        let (r0, _loaded, loaded_rotated) = convert_img(
4518            &mut cpu_converter,
4519            loaded,
4520            loaded_rotated,
4521            rotation,
4522            Flip::None,
4523            Crop::no_crop(),
4524        );
4525        r0.unwrap();
4526
4527        // Threshold 0.95 (was 0.98): `loaded` comes from a lossless PNG decode
4528        // while `cpu_src` (zidane.jpg) now decodes through native NV12 (chroma
4529        // subsampling) before the RGBA conversion, so the two paths differ by a
4530        // couple of percent versus the old direct-RGB JPEG decode.
4531        compare_images(&loaded_rotated, &cpu_dst, 0.95, function!());
4532    }
4533
4534    /// Synthesise an RGB JPEG with a deterministic pattern at `(width, height)`
4535    /// using the workspace's `jpeg-encoder` crate (the `image` crate is
4536    /// compiled without its JPEG feature). Used to exercise the decoder /
4537    /// pitch-padding paths for arbitrary dimensions without having to bundle
4538    /// a fixture file per test size.
4539    #[cfg(target_os = "linux")]
4540    fn make_rgb_jpeg(width: u32, height: u32) -> Vec<u8> {
4541        let mut bytes = Vec::with_capacity((width * height * 3) as usize);
4542        for y in 0..height {
4543            for x in 0..width {
4544                bytes.push(((x + y) & 0xFF) as u8);
4545                bytes.push(((x.wrapping_mul(3)) & 0xFF) as u8);
4546                bytes.push(((y.wrapping_mul(5)) & 0xFF) as u8);
4547            }
4548        }
4549        let mut out = Vec::new();
4550        let encoder = jpeg_encoder::Encoder::new(&mut out, 85);
4551        encoder
4552            .encode(
4553                &bytes,
4554                width as u16,
4555                height as u16,
4556                jpeg_encoder::ColorType::Rgb,
4557            )
4558            .expect("jpeg-encoder must succeed on trivial input");
4559        out
4560    }
4561
4562    /// End-to-end: a 375×333 RGBA JPEG (width NOT divisible by 4) loaded
4563    /// via the pitch-padded DMA path and letterboxed through the GL
4564    /// backend must produce correct output. Before the Rgba/Bgra
4565    /// width%4 relaxation in `DmaImportAttrs::from_tensor`, this case
4566    /// failed the pre-check and forced a CPU texture upload fallback;
4567    /// with the relaxation, EGL import succeeds at the driver level and
4568    /// the GL fast path runs. Output correctness is checked against a
4569    /// CPU reference (convert ran with `EDGEFIRST_FORCE_BACKEND=cpu`).
4570    #[test]
4571    #[cfg(target_os = "linux")]
4572    #[cfg(feature = "opengl")]
4573    fn test_convert_rgba_non_4_aligned_width_end_to_end() {
4574        use edgefirst_tensor::is_dma_available;
4575        if !is_dma_available() {
4576            eprintln!(
4577                "SKIPPED: test_convert_rgba_non_4_aligned_width_end_to_end — DMA not available"
4578            );
4579            return;
4580        }
4581        // 375 is the canonical failure width from dataset loaders —
4582        // 375 * 4 = 1500 bytes/row, pitch-padded to 1536. Width%4 = 3,
4583        // so the old pre-check rejected it; new code accepts it.
4584        let jpeg = make_rgb_jpeg(375, 333);
4585        let src_gl = crate::load_image_test_helper(&jpeg, Some(PixelFormat::Rgba), None).unwrap();
4586        assert_eq!(src_gl.width(), Some(375));
4587        // Row stride must still be pitch-padded (separate concern from width).
4588        let stride = src_gl.row_stride().unwrap();
4589        assert_eq!(stride, 1536, "expected padded pitch 1536, got {stride}");
4590
4591        // GL-backed convert into a pitch-aligned 640×640 Rgba dest.
4592        let mut gl_proc = ImageProcessor::new().unwrap();
4593        let gl_dst = gl_proc
4594            .create_image(
4595                640,
4596                640,
4597                PixelFormat::Rgba,
4598                DType::U8,
4599                None,
4600                edgefirst_tensor::CpuAccess::ReadWrite,
4601            )
4602            .unwrap();
4603        let (r_gl, _src_gl, gl_dst) = convert_img(
4604            &mut gl_proc,
4605            src_gl,
4606            gl_dst,
4607            Rotation::None,
4608            Flip::None,
4609            Crop::no_crop(),
4610        );
4611        r_gl.expect("GL-backed convert must succeed for 375x333 Rgba src");
4612
4613        // CPU reference via a fresh load so the two paths start from
4614        // byte-identical inputs. `with_config(backend=Cpu)` forces the
4615        // CPU-only processor regardless of which backends the host has
4616        // available.
4617        let src_cpu =
4618            crate::load_image_test_helper(&jpeg, Some(PixelFormat::Rgba), Some(TensorMemory::Mem))
4619                .unwrap();
4620        let mut cpu_proc = ImageProcessor::with_config(ImageProcessorConfig {
4621            backend: ComputeBackend::Cpu,
4622            ..Default::default()
4623        })
4624        .unwrap();
4625        let cpu_dst = TensorDyn::image(
4626            640,
4627            640,
4628            PixelFormat::Rgba,
4629            DType::U8,
4630            Some(TensorMemory::Mem),
4631            edgefirst_tensor::CpuAccess::ReadWrite,
4632        )
4633        .unwrap();
4634        let (r_cpu, _src_cpu, cpu_dst) = convert_img(
4635            &mut cpu_proc,
4636            src_cpu,
4637            cpu_dst,
4638            Rotation::None,
4639            Flip::None,
4640            Crop::no_crop(),
4641        );
4642        r_cpu.unwrap();
4643
4644        // Structural similarity: the GL path may have gone through EGL
4645        // import OR fallen back to CPU texture upload — either way, the
4646        // output must match the CPU reference closely.
4647        compare_images(&gl_dst, &cpu_dst, 0.95, function!());
4648    }
4649
4650    /// Regression lock: loading a JPEG at a non-64-aligned RGBA pitch (e.g.
4651    /// 500×333 → natural pitch 2000, needs to be padded to 2048) must go
4652    /// through `image_with_stride` and set `row_stride()` / `effective_row_stride()`
4653    /// to the padded value. The earlier pitch-padding commit fixed this in
4654    /// `load_jpeg`; a regression would surface as `row_stride == None` or
4655    /// `effective_row_stride == 2000`.
4656    #[test]
4657    #[cfg(target_os = "linux")]
4658    fn test_load_jpeg_rgba_non_aligned_pitch_padded_dma() {
4659        use edgefirst_tensor::is_dma_available;
4660        if !is_dma_available() {
4661            eprintln!(
4662                "SKIPPED: test_load_jpeg_rgba_non_aligned_pitch_padded_dma — DMA not available"
4663            );
4664            return;
4665        }
4666        // Widths that force a non-64-aligned natural RGBA pitch. All three
4667        // are divisible by 4 so the EGL width-alignment pre-check passes.
4668        // The pitch-padding fix is what makes these importable at all.
4669        for &w in &[500u32, 612, 428] {
4670            let jpeg = make_rgb_jpeg(w, 333);
4671            let loaded =
4672                crate::load_image_test_helper(&jpeg, Some(PixelFormat::Rgba), None).unwrap();
4673            let natural = (w as usize) * 4;
4674            let aligned = crate::align_pitch_bytes_to_gpu_alignment(natural).unwrap();
4675            assert!(
4676                aligned > natural,
4677                "test sanity: width {w} should be unaligned"
4678            );
4679            let stride = loaded
4680                .row_stride()
4681                .expect("padded DMA path must set an explicit row_stride — regression if None");
4682            assert_eq!(
4683                stride, aligned,
4684                "width {w}: expected padded stride {aligned}, got {stride} \
4685                 (regression: pitch-padding branch skipped?)"
4686            );
4687            let eff = loaded.effective_row_stride().unwrap();
4688            assert_eq!(
4689                eff, aligned,
4690                "effective_row_stride must match stored stride"
4691            );
4692            assert_eq!(loaded.width(), Some(w as usize));
4693            assert_eq!(loaded.height(), Some(333));
4694        }
4695    }
4696
4697    /// `padded_dma_pitch_for` must respect the caller's memory choice and
4698    /// must NOT route into the pitch-padded DMA path when the caller left
4699    /// the choice to the allocator (`None`) but DMA is unavailable on the
4700    /// host. The padded path requires `image_with_stride`, which always
4701    /// allocates DMA — taking it on a system without `/dev/dma_heap`
4702    /// would convert a normally-working image load into a hard failure
4703    /// (since `Tensor::image(..., None)` would have fallen back to
4704    /// SHM/Mem).
4705    #[test]
4706    #[cfg(target_os = "linux")]
4707    fn test_padded_dma_pitch_for_respects_memory_choice() {
4708        use edgefirst_tensor::{is_dma_available, TensorMemory};
4709
4710        // 500×4 = 2000 → padded to 2048 by GPU alignment. Use it for
4711        // every case so any "no padding" answer is unambiguous.
4712        let unaligned_w = 500;
4713
4714        // Caller asks for Mem / Shm: never pad, regardless of DMA.
4715        assert_eq!(
4716            crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &Some(TensorMemory::Mem),),
4717            None,
4718            "Mem must never trigger DMA padding"
4719        );
4720        assert_eq!(
4721            crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &Some(TensorMemory::Shm),),
4722            None,
4723            "Shm must never trigger DMA padding"
4724        );
4725
4726        // Caller explicitly asks for DMA: always pad if width needs it.
4727        // Even if the runtime can't actually allocate DMA, the caller
4728        // owns that decision and the resulting allocation error is
4729        // their problem, not ours.
4730        assert_eq!(
4731            crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &Some(TensorMemory::Dma),),
4732            Some(2048),
4733            "explicit Dma must pad regardless of runtime DMA availability"
4734        );
4735
4736        // Caller leaves it to the allocator: behaviour depends on
4737        // host-runtime DMA availability. This is the case the fix
4738        // guards against.
4739        let none_result = crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &None);
4740        if is_dma_available() {
4741            assert_eq!(
4742                none_result,
4743                Some(2048),
4744                "memory=None + DMA available → pad (will route through DMA)"
4745            );
4746        } else {
4747            assert_eq!(
4748                none_result, None,
4749                "memory=None + DMA unavailable → must NOT pad (would force \
4750                 image_with_stride into a DMA-only allocation that fails). \
4751                 Regression: padded_dma_pitch_for ignored is_dma_available()."
4752            );
4753        }
4754    }
4755
4756    // Synthesise a small greyscale PNG in memory at `(width, height)` with a
4757    // deterministic ramp pattern so multiple tests can cross-check output
4758    // without bundling an extra fixture file.
4759    fn make_grey_png(width: u32, height: u32) -> Vec<u8> {
4760        let mut bytes = Vec::with_capacity((width * height) as usize);
4761        for y in 0..height {
4762            for x in 0..width {
4763                bytes.push(((x + y) & 0xFF) as u8);
4764            }
4765        }
4766        let img = image::GrayImage::from_vec(width, height, bytes).unwrap();
4767        let mut buf = Vec::new();
4768        img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
4769            .unwrap();
4770        buf
4771    }
4772
4773    /// Greyscale PNG with a width that forces a pitch-misaligned natural
4774    /// row stride (612 bytes is not a multiple of the 64-byte GPU pitch
4775    /// alignment) must still load via the pitch-padded DMA path. Gated on
4776    /// DMA availability because `image_with_stride` is DMA-only.
4777    #[test]
4778    #[cfg(target_os = "linux")]
4779    fn test_load_png_grey_misaligned_width_dma() {
4780        use edgefirst_tensor::is_dma_available;
4781        if !is_dma_available() {
4782            eprintln!("SKIPPED: test_load_png_grey_misaligned_width_dma — DMA not available");
4783            return;
4784        }
4785        let png = make_grey_png(612, 388);
4786        let loaded = crate::load_image_test_helper(&png, Some(PixelFormat::Grey), None).unwrap();
4787        assert_eq!(loaded.width(), Some(612));
4788        assert_eq!(loaded.height(), Some(388));
4789        assert_eq!(loaded.format(), Some(PixelFormat::Grey));
4790
4791        // Round-trip pixels — natural-pitch DMA-BUFs pad the stride so we
4792        // must indirect through row_stride() rather than assume width.
4793        let map = loaded.as_u8().unwrap().map().unwrap();
4794        let stride = loaded.row_stride().unwrap_or(612);
4795        assert!(stride >= 612);
4796        let bytes: &[u8] = &map;
4797        for y in 0..388usize {
4798            for x in 0..612usize {
4799                let expected = ((x + y) & 0xFF) as u8;
4800                let got = bytes[y * stride + x];
4801                assert_eq!(
4802                    got, expected,
4803                    "grey png mismatch at ({x},{y}): got {got} expected {expected}"
4804                );
4805            }
4806        }
4807    }
4808
4809    /// Greyscale PNG loaded with explicit Mem backing — runs on any
4810    /// platform (no DMA permission requirement) and covers the
4811    /// decoder-native Luma → Grey no-conversion path.
4812    #[test]
4813    fn test_load_png_grey_mem() {
4814        use edgefirst_tensor::TensorMemory;
4815        let png = make_grey_png(612, 100);
4816        let loaded =
4817            crate::load_image_test_helper(&png, Some(PixelFormat::Grey), Some(TensorMemory::Mem))
4818                .unwrap();
4819        assert_eq!(loaded.width(), Some(612));
4820        assert_eq!(loaded.height(), Some(100));
4821        assert_eq!(loaded.format(), Some(PixelFormat::Grey));
4822        let map = loaded.as_u8().unwrap().map().unwrap();
4823        let bytes: &[u8] = &map;
4824        // Mem allocation uses the natural pitch — 612 bytes per row, exact.
4825        assert_eq!(bytes.len(), 612 * 100);
4826        for y in 0..100 {
4827            for x in 0..612 {
4828                assert_eq!(bytes[y * 612 + x], ((x + y) & 0xFF) as u8);
4829            }
4830        }
4831    }
4832
4833    /// Greyscale PNG decoded into RGB — exercises the decoder-colorspace
4834    /// mismatch path (Luma → Rgb via CPU converter). Uses Mem memory to
4835    /// stay portable to host-side test environments.
4836    #[test]
4837    fn test_load_png_grey_to_rgb_mem() {
4838        use edgefirst_tensor::TensorMemory;
4839        let png = make_grey_png(620, 240);
4840        let loaded =
4841            crate::load_image_test_helper(&png, Some(PixelFormat::Rgb), Some(TensorMemory::Mem))
4842                .unwrap();
4843        assert_eq!(loaded.width(), Some(620));
4844        assert_eq!(loaded.height(), Some(240));
4845        assert_eq!(loaded.format(), Some(PixelFormat::Rgb));
4846
4847        // Greyscale promoted to RGB replicates luma into each channel.
4848        let map = loaded.as_u8().unwrap().map().unwrap();
4849        let bytes: &[u8] = &map;
4850        for (x, y) in [(0usize, 0usize), (100, 50), (619, 239)] {
4851            let expected = ((x + y) & 0xFF) as u8;
4852            let off = (y * 620 + x) * 3;
4853            assert_eq!(bytes[off], expected, "R@{x},{y}");
4854            assert_eq!(bytes[off + 1], expected, "G@{x},{y}");
4855            assert_eq!(bytes[off + 2], expected, "B@{x},{y}");
4856        }
4857    }
4858
4859    #[test]
4860    #[cfg(target_os = "linux")]
4861    fn test_g2d_resize() {
4862        if !is_g2d_available() {
4863            eprintln!("SKIPPED: test_g2d_resize - G2D library (libg2d.so.2) not available");
4864            return;
4865        }
4866        if !is_dma_available() {
4867            eprintln!(
4868                "SKIPPED: test_g2d_resize - DMA memory allocation not available (permission denied or no DMA-BUF support)"
4869            );
4870            return;
4871        }
4872
4873        let dst_width = 640;
4874        let dst_height = 360;
4875        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4876        let src =
4877            crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), Some(TensorMemory::Dma))
4878                .unwrap();
4879
4880        let g2d_dst = TensorDyn::image(
4881            dst_width,
4882            dst_height,
4883            PixelFormat::Rgba,
4884            DType::U8,
4885            Some(TensorMemory::Dma),
4886            edgefirst_tensor::CpuAccess::ReadWrite,
4887        )
4888        .unwrap();
4889        let mut g2d_converter = G2DProcessor::new().unwrap();
4890        let (result, src, g2d_dst) = convert_img(
4891            &mut g2d_converter,
4892            src,
4893            g2d_dst,
4894            Rotation::None,
4895            Flip::None,
4896            Crop::no_crop(),
4897        );
4898        result.unwrap();
4899
4900        let cpu_dst = TensorDyn::image(
4901            dst_width,
4902            dst_height,
4903            PixelFormat::Rgba,
4904            DType::U8,
4905            None,
4906            edgefirst_tensor::CpuAccess::ReadWrite,
4907        )
4908        .unwrap();
4909        let mut cpu_converter = CPUProcessor::new();
4910        let (result, _src, cpu_dst) = convert_img(
4911            &mut cpu_converter,
4912            src,
4913            cpu_dst,
4914            Rotation::None,
4915            Flip::None,
4916            Crop::no_crop(),
4917        );
4918        result.unwrap();
4919
4920        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
4921        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
4922        // the YUV-matrix delta that forced 0.95 has closed; tightened to
4923        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
4924        // structural gap not exercised by these limited-range fixtures.
4925        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
4926    }
4927
4928    #[test]
4929    #[cfg(target_os = "linux")]
4930    #[cfg(feature = "opengl")]
4931    fn test_opengl_resize() {
4932        if !is_opengl_available() {
4933            eprintln!("SKIPPED: {} - OpenGL not available", function!());
4934            return;
4935        }
4936
4937        let dst_width = 640;
4938        let dst_height = 360;
4939        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4940        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4941
4942        let cpu_dst = TensorDyn::image(
4943            dst_width,
4944            dst_height,
4945            PixelFormat::Rgba,
4946            DType::U8,
4947            None,
4948            edgefirst_tensor::CpuAccess::ReadWrite,
4949        )
4950        .unwrap();
4951        let mut cpu_converter = CPUProcessor::new();
4952        let (result, src, cpu_dst) = convert_img(
4953            &mut cpu_converter,
4954            src,
4955            cpu_dst,
4956            Rotation::None,
4957            Flip::None,
4958            Crop::no_crop(),
4959        );
4960        result.unwrap();
4961
4962        let mut src = src;
4963        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
4964
4965        for _ in 0..5 {
4966            let gl_dst = TensorDyn::image(
4967                dst_width,
4968                dst_height,
4969                PixelFormat::Rgba,
4970                DType::U8,
4971                None,
4972                edgefirst_tensor::CpuAccess::ReadWrite,
4973            )
4974            .unwrap();
4975            let (result, src_back, gl_dst) = convert_img(
4976                &mut gl_converter,
4977                src,
4978                gl_dst,
4979                Rotation::None,
4980                Flip::None,
4981                Crop::no_crop(),
4982            );
4983            result.unwrap();
4984            src = src_back;
4985
4986            compare_images(&gl_dst, &cpu_dst, 0.98, function!());
4987        }
4988    }
4989
4990    #[test]
4991    #[cfg(target_os = "linux")]
4992    #[cfg(feature = "opengl")]
4993    fn test_opengl_10_threads() {
4994        if !is_opengl_available() {
4995            eprintln!("SKIPPED: {} - OpenGL not available", function!());
4996            return;
4997        }
4998
4999        let handles: Vec<_> = (0..10)
5000            .map(|i| {
5001                std::thread::Builder::new()
5002                    .name(format!("Thread {i}"))
5003                    .spawn(test_opengl_resize)
5004                    .unwrap()
5005            })
5006            .collect();
5007        handles.into_iter().for_each(|h| {
5008            if let Err(e) = h.join() {
5009                std::panic::resume_unwind(e)
5010            }
5011        });
5012    }
5013
5014    #[test]
5015    #[cfg(target_os = "linux")]
5016    #[cfg(feature = "opengl")]
5017    fn test_opengl_grey() {
5018        if !is_opengl_available() {
5019            eprintln!("SKIPPED: {} - OpenGL not available", function!());
5020            return;
5021        }
5022
5023        let img = crate::load_image_test_helper(
5024            &edgefirst_bench::testdata::read("grey.jpg"),
5025            Some(PixelFormat::Grey),
5026            None,
5027        )
5028        .unwrap();
5029
5030        let gl_dst = TensorDyn::image(
5031            640,
5032            640,
5033            PixelFormat::Grey,
5034            DType::U8,
5035            None,
5036            edgefirst_tensor::CpuAccess::ReadWrite,
5037        )
5038        .unwrap();
5039        let cpu_dst = TensorDyn::image(
5040            640,
5041            640,
5042            PixelFormat::Grey,
5043            DType::U8,
5044            None,
5045            edgefirst_tensor::CpuAccess::ReadWrite,
5046        )
5047        .unwrap();
5048
5049        let mut converter = CPUProcessor::new();
5050
5051        let (result, img, cpu_dst) = convert_img(
5052            &mut converter,
5053            img,
5054            cpu_dst,
5055            Rotation::None,
5056            Flip::None,
5057            Crop::no_crop(),
5058        );
5059        result.unwrap();
5060
5061        let mut gl = GLProcessorThreaded::new(None).unwrap();
5062        let (result, _img, gl_dst) = convert_img(
5063            &mut gl,
5064            img,
5065            gl_dst,
5066            Rotation::None,
5067            Flip::None,
5068            Crop::no_crop(),
5069        );
5070        result.unwrap();
5071
5072        compare_images(&gl_dst, &cpu_dst, 0.98, function!());
5073    }
5074
5075    #[test]
5076    #[cfg(target_os = "linux")]
5077    fn test_g2d_src_crop() {
5078        if !is_g2d_available() {
5079            eprintln!("SKIPPED: test_g2d_src_crop - G2D library (libg2d.so.2) not available");
5080            return;
5081        }
5082        if !is_dma_available() {
5083            eprintln!(
5084                "SKIPPED: test_g2d_src_crop - DMA memory allocation not available (permission denied or no DMA-BUF support)"
5085            );
5086            return;
5087        }
5088
5089        let dst_width = 640;
5090        let dst_height = 640;
5091        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5092        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5093
5094        let cpu_dst = TensorDyn::image(
5095            dst_width,
5096            dst_height,
5097            PixelFormat::Rgba,
5098            DType::U8,
5099            None,
5100            edgefirst_tensor::CpuAccess::ReadWrite,
5101        )
5102        .unwrap();
5103        let mut cpu_converter = CPUProcessor::new();
5104        let crop = Crop::new().with_source(Some(Region::new(0, 0, 640, 360)));
5105        let (result, src, cpu_dst) = convert_img(
5106            &mut cpu_converter,
5107            src,
5108            cpu_dst,
5109            Rotation::None,
5110            Flip::None,
5111            crop,
5112        );
5113        result.unwrap();
5114
5115        let g2d_dst = TensorDyn::image(
5116            dst_width,
5117            dst_height,
5118            PixelFormat::Rgba,
5119            DType::U8,
5120            None,
5121            edgefirst_tensor::CpuAccess::ReadWrite,
5122        )
5123        .unwrap();
5124        let mut g2d_converter = G2DProcessor::new().unwrap();
5125        let (result, _src, g2d_dst) = convert_img(
5126            &mut g2d_converter,
5127            src,
5128            g2d_dst,
5129            Rotation::None,
5130            Flip::None,
5131            crop,
5132        );
5133        result.unwrap();
5134
5135        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
5136        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
5137        // the YUV-matrix delta that forced 0.95 has closed; tightened to
5138        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
5139        // structural gap not exercised by these limited-range fixtures.
5140        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
5141    }
5142
5143    #[test]
5144    #[cfg(target_os = "linux")]
5145    fn test_g2d_dst_crop() {
5146        if !is_g2d_available() {
5147            eprintln!("SKIPPED: test_g2d_dst_crop - G2D library (libg2d.so.2) not available");
5148            return;
5149        }
5150        if !is_dma_available() {
5151            eprintln!(
5152                "SKIPPED: test_g2d_dst_crop - DMA memory allocation not available (permission denied or no DMA-BUF support)"
5153            );
5154            return;
5155        }
5156
5157        let dst_width = 640;
5158        let dst_height = 640;
5159        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5160        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5161
5162        let cpu_dst = TensorDyn::image(
5163            dst_width,
5164            dst_height,
5165            PixelFormat::Rgba,
5166            DType::U8,
5167            None,
5168            edgefirst_tensor::CpuAccess::ReadWrite,
5169        )
5170        .unwrap();
5171        let mut cpu_converter = CPUProcessor::new();
5172        let crop = Crop::new();
5173        let (result, src, cpu_dst) = convert_img(
5174            &mut cpu_converter,
5175            src,
5176            cpu_dst,
5177            Rotation::None,
5178            Flip::None,
5179            crop,
5180        );
5181        result.unwrap();
5182
5183        let g2d_dst = TensorDyn::image(
5184            dst_width,
5185            dst_height,
5186            PixelFormat::Rgba,
5187            DType::U8,
5188            None,
5189            edgefirst_tensor::CpuAccess::ReadWrite,
5190        )
5191        .unwrap();
5192        let mut g2d_converter = G2DProcessor::new().unwrap();
5193        let (result, _src, g2d_dst) = convert_img(
5194            &mut g2d_converter,
5195            src,
5196            g2d_dst,
5197            Rotation::None,
5198            Flip::None,
5199            crop,
5200        );
5201        result.unwrap();
5202
5203        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
5204        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
5205        // the YUV-matrix delta that forced 0.95 has closed; tightened to
5206        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
5207        // structural gap not exercised by these limited-range fixtures.
5208        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
5209    }
5210
5211    #[test]
5212    #[cfg(target_os = "linux")]
5213    fn test_g2d_all_rgba() {
5214        if !is_g2d_available() {
5215            eprintln!("SKIPPED: test_g2d_all_rgba - G2D library (libg2d.so.2) not available");
5216            return;
5217        }
5218        if !is_dma_available() {
5219            eprintln!(
5220                "SKIPPED: test_g2d_all_rgba - DMA memory allocation not available (permission denied or no DMA-BUF support)"
5221            );
5222            return;
5223        }
5224
5225        let dst_width = 640;
5226        let dst_height = 640;
5227        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5228        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5229        let src_dyn = src;
5230
5231        let mut cpu_dst = TensorDyn::image(
5232            dst_width,
5233            dst_height,
5234            PixelFormat::Rgba,
5235            DType::U8,
5236            None,
5237            edgefirst_tensor::CpuAccess::ReadWrite,
5238        )
5239        .unwrap();
5240        let mut cpu_converter = CPUProcessor::new();
5241        let mut g2d_dst = TensorDyn::image(
5242            dst_width,
5243            dst_height,
5244            PixelFormat::Rgba,
5245            DType::U8,
5246            None,
5247            edgefirst_tensor::CpuAccess::ReadWrite,
5248        )
5249        .unwrap();
5250        let mut g2d_converter = G2DProcessor::new().unwrap();
5251
5252        let crop = Crop::new().with_source(Some(Region::new(50, 120, 1024, 576)));
5253
5254        for rot in [
5255            Rotation::None,
5256            Rotation::Clockwise90,
5257            Rotation::Rotate180,
5258            Rotation::CounterClockwise90,
5259        ] {
5260            cpu_dst
5261                .as_u8()
5262                .unwrap()
5263                .map()
5264                .unwrap()
5265                .as_mut_slice()
5266                .fill(114);
5267            g2d_dst
5268                .as_u8()
5269                .unwrap()
5270                .map()
5271                .unwrap()
5272                .as_mut_slice()
5273                .fill(114);
5274            for flip in [Flip::None, Flip::Horizontal, Flip::Vertical] {
5275                let mut cpu_dst_dyn = cpu_dst;
5276                cpu_converter
5277                    .convert(&src_dyn, &mut cpu_dst_dyn, Rotation::None, Flip::None, crop)
5278                    .unwrap();
5279                cpu_dst = {
5280                    let mut __t = cpu_dst_dyn.into_u8().unwrap();
5281                    __t.set_format(PixelFormat::Rgba).unwrap();
5282                    TensorDyn::from(__t)
5283                };
5284
5285                let mut g2d_dst_dyn = g2d_dst;
5286                g2d_converter
5287                    .convert(&src_dyn, &mut g2d_dst_dyn, Rotation::None, Flip::None, crop)
5288                    .unwrap();
5289                g2d_dst = {
5290                    let mut __t = g2d_dst_dyn.into_u8().unwrap();
5291                    __t.set_format(PixelFormat::Rgba).unwrap();
5292                    TensorDyn::from(__t)
5293                };
5294
5295                compare_images(
5296                    &g2d_dst,
5297                    &cpu_dst,
5298                    0.98,
5299                    &format!("{} {:?} {:?}", function!(), rot, flip),
5300                );
5301            }
5302        }
5303    }
5304
5305    #[test]
5306    #[cfg(target_os = "linux")]
5307    #[cfg(feature = "opengl")]
5308    fn test_opengl_src_crop() {
5309        if !is_opengl_available() {
5310            eprintln!("SKIPPED: {} - OpenGL not available", function!());
5311            return;
5312        }
5313
5314        let dst_width = 640;
5315        let dst_height = 360;
5316        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5317        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5318        let crop = Crop::new().with_source(Some(Region::new(320, 180, 1280 - 320, 720 - 180)));
5319
5320        let cpu_dst = TensorDyn::image(
5321            dst_width,
5322            dst_height,
5323            PixelFormat::Rgba,
5324            DType::U8,
5325            None,
5326            edgefirst_tensor::CpuAccess::ReadWrite,
5327        )
5328        .unwrap();
5329        let mut cpu_converter = CPUProcessor::new();
5330        let (result, src, cpu_dst) = convert_img(
5331            &mut cpu_converter,
5332            src,
5333            cpu_dst,
5334            Rotation::None,
5335            Flip::None,
5336            crop,
5337        );
5338        result.unwrap();
5339
5340        let gl_dst = TensorDyn::image(
5341            dst_width,
5342            dst_height,
5343            PixelFormat::Rgba,
5344            DType::U8,
5345            None,
5346            edgefirst_tensor::CpuAccess::ReadWrite,
5347        )
5348        .unwrap();
5349        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
5350        let (result, _src, gl_dst) = convert_img(
5351            &mut gl_converter,
5352            src,
5353            gl_dst,
5354            Rotation::None,
5355            Flip::None,
5356            crop,
5357        );
5358        result.unwrap();
5359
5360        compare_images(&gl_dst, &cpu_dst, 0.98, function!());
5361    }
5362
5363    #[test]
5364    #[cfg(target_os = "linux")]
5365    #[cfg(feature = "opengl")]
5366    fn test_opengl_dst_crop() {
5367        if !is_opengl_available() {
5368            eprintln!("SKIPPED: {} - OpenGL not available", function!());
5369            return;
5370        }
5371
5372        let dst_width = 640;
5373        let dst_height = 640;
5374        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5375        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5376
5377        let cpu_dst = TensorDyn::image(
5378            dst_width,
5379            dst_height,
5380            PixelFormat::Rgba,
5381            DType::U8,
5382            None,
5383            edgefirst_tensor::CpuAccess::ReadWrite,
5384        )
5385        .unwrap();
5386        let mut cpu_converter = CPUProcessor::new();
5387        let crop = Crop::new();
5388        let (result, src, cpu_dst) = convert_img(
5389            &mut cpu_converter,
5390            src,
5391            cpu_dst,
5392            Rotation::None,
5393            Flip::None,
5394            crop,
5395        );
5396        result.unwrap();
5397
5398        let gl_dst = TensorDyn::image(
5399            dst_width,
5400            dst_height,
5401            PixelFormat::Rgba,
5402            DType::U8,
5403            None,
5404            edgefirst_tensor::CpuAccess::ReadWrite,
5405        )
5406        .unwrap();
5407        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
5408        let (result, _src, gl_dst) = convert_img(
5409            &mut gl_converter,
5410            src,
5411            gl_dst,
5412            Rotation::None,
5413            Flip::None,
5414            crop,
5415        );
5416        result.unwrap();
5417
5418        compare_images(&gl_dst, &cpu_dst, 0.98, function!());
5419    }
5420
5421    #[test]
5422    #[cfg(target_os = "linux")]
5423    #[cfg(feature = "opengl")]
5424    fn test_opengl_all_rgba() {
5425        if !is_opengl_available() {
5426            eprintln!("SKIPPED: {} - OpenGL not available", function!());
5427            return;
5428        }
5429
5430        let dst_width = 640;
5431        let dst_height = 640;
5432        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5433
5434        let mut cpu_converter = CPUProcessor::new();
5435
5436        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
5437
5438        let mut mem = vec![None, Some(TensorMemory::Mem), Some(TensorMemory::Shm)];
5439        if is_dma_available() {
5440            mem.push(Some(TensorMemory::Dma));
5441        }
5442        let crop = Crop::new().with_source(Some(Region::new(50, 120, 1024, 576)));
5443        for m in mem {
5444            let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), m).unwrap();
5445            let src_dyn = src;
5446
5447            for rot in [
5448                Rotation::None,
5449                Rotation::Clockwise90,
5450                Rotation::Rotate180,
5451                Rotation::CounterClockwise90,
5452            ] {
5453                for flip in [Flip::None, Flip::Horizontal, Flip::Vertical] {
5454                    let cpu_dst = TensorDyn::image(
5455                        dst_width,
5456                        dst_height,
5457                        PixelFormat::Rgba,
5458                        DType::U8,
5459                        m,
5460                        edgefirst_tensor::CpuAccess::ReadWrite,
5461                    )
5462                    .unwrap();
5463                    let gl_dst = TensorDyn::image(
5464                        dst_width,
5465                        dst_height,
5466                        PixelFormat::Rgba,
5467                        DType::U8,
5468                        m,
5469                        edgefirst_tensor::CpuAccess::ReadWrite,
5470                    )
5471                    .unwrap();
5472                    cpu_dst
5473                        .as_u8()
5474                        .unwrap()
5475                        .map()
5476                        .unwrap()
5477                        .as_mut_slice()
5478                        .fill(114);
5479                    gl_dst
5480                        .as_u8()
5481                        .unwrap()
5482                        .map()
5483                        .unwrap()
5484                        .as_mut_slice()
5485                        .fill(114);
5486
5487                    let mut cpu_dst_dyn = cpu_dst;
5488                    cpu_converter
5489                        .convert(&src_dyn, &mut cpu_dst_dyn, Rotation::None, Flip::None, crop)
5490                        .unwrap();
5491                    let cpu_dst = {
5492                        let mut __t = cpu_dst_dyn.into_u8().unwrap();
5493                        __t.set_format(PixelFormat::Rgba).unwrap();
5494                        TensorDyn::from(__t)
5495                    };
5496
5497                    let mut gl_dst_dyn = gl_dst;
5498                    gl_converter
5499                        .convert(&src_dyn, &mut gl_dst_dyn, Rotation::None, Flip::None, crop)
5500                        .map_err(|e| {
5501                            log::error!("error mem {m:?} rot {rot:?} error: {e:?}");
5502                            e
5503                        })
5504                        .unwrap();
5505                    let gl_dst = {
5506                        let mut __t = gl_dst_dyn.into_u8().unwrap();
5507                        __t.set_format(PixelFormat::Rgba).unwrap();
5508                        TensorDyn::from(__t)
5509                    };
5510
5511                    compare_images(
5512                        &gl_dst,
5513                        &cpu_dst,
5514                        0.98,
5515                        &format!("{} {:?} {:?}", function!(), rot, flip),
5516                    );
5517                }
5518            }
5519        }
5520    }
5521
5522    #[test]
5523    #[cfg(target_os = "linux")]
5524    fn test_cpu_rotate() {
5525        for rot in [
5526            Rotation::Clockwise90,
5527            Rotation::Rotate180,
5528            Rotation::CounterClockwise90,
5529        ] {
5530            test_cpu_rotate_(rot);
5531        }
5532    }
5533
5534    #[cfg(target_os = "linux")]
5535    fn test_cpu_rotate_(rot: Rotation) {
5536        // This test rotates the image 4 times and checks that the image was returned to
5537        // be the same Currently doesn't check if rotations actually rotated in
5538        // right direction
5539        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5540
5541        let unchanged_src =
5542            crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5543        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5544
5545        let (dst_width, dst_height) = match rot {
5546            Rotation::None | Rotation::Rotate180 => (src.width().unwrap(), src.height().unwrap()),
5547            Rotation::Clockwise90 | Rotation::CounterClockwise90 => {
5548                (src.height().unwrap(), src.width().unwrap())
5549            }
5550        };
5551
5552        let cpu_dst = TensorDyn::image(
5553            dst_width,
5554            dst_height,
5555            PixelFormat::Rgba,
5556            DType::U8,
5557            None,
5558            edgefirst_tensor::CpuAccess::ReadWrite,
5559        )
5560        .unwrap();
5561        let mut cpu_converter = CPUProcessor::new();
5562
5563        // After rotating 4 times, the image should be the same as the original
5564
5565        let (result, src, cpu_dst) = convert_img(
5566            &mut cpu_converter,
5567            src,
5568            cpu_dst,
5569            rot,
5570            Flip::None,
5571            Crop::no_crop(),
5572        );
5573        result.unwrap();
5574
5575        let (result, cpu_dst, src) = convert_img(
5576            &mut cpu_converter,
5577            cpu_dst,
5578            src,
5579            rot,
5580            Flip::None,
5581            Crop::no_crop(),
5582        );
5583        result.unwrap();
5584
5585        let (result, src, cpu_dst) = convert_img(
5586            &mut cpu_converter,
5587            src,
5588            cpu_dst,
5589            rot,
5590            Flip::None,
5591            Crop::no_crop(),
5592        );
5593        result.unwrap();
5594
5595        let (result, _cpu_dst, src) = convert_img(
5596            &mut cpu_converter,
5597            cpu_dst,
5598            src,
5599            rot,
5600            Flip::None,
5601            Crop::no_crop(),
5602        );
5603        result.unwrap();
5604
5605        compare_images(&src, &unchanged_src, 0.98, function!());
5606    }
5607
5608    #[test]
5609    #[cfg(target_os = "linux")]
5610    #[cfg(feature = "opengl")]
5611    fn test_opengl_rotate() {
5612        if !is_opengl_available() {
5613            eprintln!("SKIPPED: {} - OpenGL not available", function!());
5614            return;
5615        }
5616
5617        let size = (1280, 720);
5618        let mut mem = vec![None, Some(TensorMemory::Shm), Some(TensorMemory::Mem)];
5619
5620        if is_dma_available() {
5621            mem.push(Some(TensorMemory::Dma));
5622        }
5623        for m in mem {
5624            for rot in [
5625                Rotation::Clockwise90,
5626                Rotation::Rotate180,
5627                Rotation::CounterClockwise90,
5628            ] {
5629                test_opengl_rotate_(size, rot, m);
5630            }
5631        }
5632    }
5633
5634    #[cfg(target_os = "linux")]
5635    #[cfg(feature = "opengl")]
5636    fn test_opengl_rotate_(
5637        size: (usize, usize),
5638        rot: Rotation,
5639        tensor_memory: Option<TensorMemory>,
5640    ) {
5641        let (dst_width, dst_height) = match rot {
5642            Rotation::None | Rotation::Rotate180 => size,
5643            Rotation::Clockwise90 | Rotation::CounterClockwise90 => (size.1, size.0),
5644        };
5645
5646        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5647        let src =
5648            crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), tensor_memory).unwrap();
5649
5650        let cpu_dst = TensorDyn::image(
5651            dst_width,
5652            dst_height,
5653            PixelFormat::Rgba,
5654            DType::U8,
5655            None,
5656            edgefirst_tensor::CpuAccess::ReadWrite,
5657        )
5658        .unwrap();
5659        let mut cpu_converter = CPUProcessor::new();
5660
5661        let (result, mut src, cpu_dst) = convert_img(
5662            &mut cpu_converter,
5663            src,
5664            cpu_dst,
5665            rot,
5666            Flip::None,
5667            Crop::no_crop(),
5668        );
5669        result.unwrap();
5670
5671        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
5672
5673        for _ in 0..5 {
5674            let gl_dst = TensorDyn::image(
5675                dst_width,
5676                dst_height,
5677                PixelFormat::Rgba,
5678                DType::U8,
5679                tensor_memory,
5680                edgefirst_tensor::CpuAccess::ReadWrite,
5681            )
5682            .unwrap();
5683            let (result, src_back, gl_dst) = convert_img(
5684                &mut gl_converter,
5685                src,
5686                gl_dst,
5687                rot,
5688                Flip::None,
5689                Crop::no_crop(),
5690            );
5691            result.unwrap();
5692            src = src_back;
5693            compare_images(&gl_dst, &cpu_dst, 0.98, function!());
5694        }
5695    }
5696
5697    #[test]
5698    #[cfg(target_os = "linux")]
5699    fn test_g2d_rotate() {
5700        if !is_g2d_available() {
5701            eprintln!("SKIPPED: test_g2d_rotate - G2D library (libg2d.so.2) not available");
5702            return;
5703        }
5704        if !is_dma_available() {
5705            eprintln!(
5706                "SKIPPED: test_g2d_rotate - DMA memory allocation not available (permission denied or no DMA-BUF support)"
5707            );
5708            return;
5709        }
5710
5711        let size = (1280, 720);
5712        for rot in [
5713            Rotation::Clockwise90,
5714            Rotation::Rotate180,
5715            Rotation::CounterClockwise90,
5716        ] {
5717            test_g2d_rotate_(size, rot);
5718        }
5719    }
5720
5721    #[cfg(target_os = "linux")]
5722    fn test_g2d_rotate_(size: (usize, usize), rot: Rotation) {
5723        let (dst_width, dst_height) = match rot {
5724            Rotation::None | Rotation::Rotate180 => size,
5725            Rotation::Clockwise90 | Rotation::CounterClockwise90 => (size.1, size.0),
5726        };
5727
5728        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5729        let src =
5730            crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), Some(TensorMemory::Dma))
5731                .unwrap();
5732
5733        let cpu_dst = TensorDyn::image(
5734            dst_width,
5735            dst_height,
5736            PixelFormat::Rgba,
5737            DType::U8,
5738            None,
5739            edgefirst_tensor::CpuAccess::ReadWrite,
5740        )
5741        .unwrap();
5742        let mut cpu_converter = CPUProcessor::new();
5743
5744        let (result, src, cpu_dst) = convert_img(
5745            &mut cpu_converter,
5746            src,
5747            cpu_dst,
5748            rot,
5749            Flip::None,
5750            Crop::no_crop(),
5751        );
5752        result.unwrap();
5753
5754        let g2d_dst = TensorDyn::image(
5755            dst_width,
5756            dst_height,
5757            PixelFormat::Rgba,
5758            DType::U8,
5759            Some(TensorMemory::Dma),
5760            edgefirst_tensor::CpuAccess::ReadWrite,
5761        )
5762        .unwrap();
5763        let mut g2d_converter = G2DProcessor::new().unwrap();
5764
5765        let (result, _src, g2d_dst) = convert_img(
5766            &mut g2d_converter,
5767            src,
5768            g2d_dst,
5769            rot,
5770            Flip::None,
5771            Crop::no_crop(),
5772        );
5773        result.unwrap();
5774
5775        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
5776        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
5777        // the YUV-matrix delta that forced 0.95 has closed; tightened to
5778        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
5779        // structural gap not exercised by these limited-range fixtures.
5780        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
5781    }
5782
5783    #[test]
5784    fn test_rgba_to_yuyv_resize_cpu() {
5785        let src = load_bytes_to_tensor(
5786            1280,
5787            720,
5788            PixelFormat::Rgba,
5789            None,
5790            &edgefirst_bench::testdata::read("camera720p.rgba"),
5791        )
5792        .unwrap();
5793
5794        let (dst_width, dst_height) = (640, 360);
5795
5796        let dst = TensorDyn::image(
5797            dst_width,
5798            dst_height,
5799            PixelFormat::Yuyv,
5800            DType::U8,
5801            None,
5802            edgefirst_tensor::CpuAccess::ReadWrite,
5803        )
5804        .unwrap();
5805
5806        let dst_through_yuyv = TensorDyn::image(
5807            dst_width,
5808            dst_height,
5809            PixelFormat::Rgba,
5810            DType::U8,
5811            None,
5812            edgefirst_tensor::CpuAccess::ReadWrite,
5813        )
5814        .unwrap();
5815        let dst_direct = TensorDyn::image(
5816            dst_width,
5817            dst_height,
5818            PixelFormat::Rgba,
5819            DType::U8,
5820            None,
5821            edgefirst_tensor::CpuAccess::ReadWrite,
5822        )
5823        .unwrap();
5824
5825        let mut cpu_converter = CPUProcessor::new();
5826
5827        let (result, src, dst) = convert_img(
5828            &mut cpu_converter,
5829            src,
5830            dst,
5831            Rotation::None,
5832            Flip::None,
5833            Crop::no_crop(),
5834        );
5835        result.unwrap();
5836
5837        let (result, _dst, dst_through_yuyv) = convert_img(
5838            &mut cpu_converter,
5839            dst,
5840            dst_through_yuyv,
5841            Rotation::None,
5842            Flip::None,
5843            Crop::no_crop(),
5844        );
5845        result.unwrap();
5846
5847        let (result, _src, dst_direct) = convert_img(
5848            &mut cpu_converter,
5849            src,
5850            dst_direct,
5851            Rotation::None,
5852            Flip::None,
5853            Crop::no_crop(),
5854        );
5855        result.unwrap();
5856
5857        compare_images(&dst_through_yuyv, &dst_direct, 0.98, function!());
5858    }
5859
5860    #[test]
5861    #[cfg(target_os = "linux")]
5862    #[cfg(feature = "opengl")]
5863    #[ignore = "opengl doesn't support rendering to PixelFormat::Yuyv texture"]
5864    fn test_rgba_to_yuyv_resize_opengl() {
5865        if !is_opengl_available() {
5866            eprintln!("SKIPPED: {} - OpenGL not available", function!());
5867            return;
5868        }
5869
5870        if !is_dma_available() {
5871            eprintln!(
5872                "SKIPPED: {} - DMA memory allocation not available (permission denied or no DMA-BUF support)",
5873                function!()
5874            );
5875            return;
5876        }
5877
5878        let src = load_bytes_to_tensor(
5879            1280,
5880            720,
5881            PixelFormat::Rgba,
5882            None,
5883            &edgefirst_bench::testdata::read("camera720p.rgba"),
5884        )
5885        .unwrap();
5886
5887        let (dst_width, dst_height) = (640, 360);
5888
5889        let dst = TensorDyn::image(
5890            dst_width,
5891            dst_height,
5892            PixelFormat::Yuyv,
5893            DType::U8,
5894            Some(TensorMemory::Dma),
5895            edgefirst_tensor::CpuAccess::ReadWrite,
5896        )
5897        .unwrap();
5898
5899        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
5900
5901        let (result, src, dst) = convert_img(
5902            &mut gl_converter,
5903            src,
5904            dst,
5905            Rotation::None,
5906            Flip::None,
5907            Crop::letterbox([255, 255, 255, 255]),
5908        );
5909        result.unwrap();
5910
5911        std::fs::write(
5912            "rgba_to_yuyv_opengl.yuyv",
5913            dst.as_u8().unwrap().map().unwrap().as_slice(),
5914        )
5915        .unwrap();
5916        let cpu_dst = TensorDyn::image(
5917            dst_width,
5918            dst_height,
5919            PixelFormat::Yuyv,
5920            DType::U8,
5921            Some(TensorMemory::Dma),
5922            edgefirst_tensor::CpuAccess::ReadWrite,
5923        )
5924        .unwrap();
5925        let (result, _src, cpu_dst) = convert_img(
5926            &mut CPUProcessor::new(),
5927            src,
5928            cpu_dst,
5929            Rotation::None,
5930            Flip::None,
5931            Crop::no_crop(),
5932        );
5933        result.unwrap();
5934
5935        compare_images_convert_to_rgb(&dst, &cpu_dst, 0.98, function!());
5936    }
5937
5938    #[test]
5939    #[cfg(target_os = "linux")]
5940    fn test_rgba_to_yuyv_resize_g2d() {
5941        if !is_g2d_available() {
5942            eprintln!(
5943                "SKIPPED: test_rgba_to_yuyv_resize_g2d - G2D library (libg2d.so.2) not available"
5944            );
5945            return;
5946        }
5947        if !is_dma_available() {
5948            eprintln!(
5949                "SKIPPED: test_rgba_to_yuyv_resize_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
5950            );
5951            return;
5952        }
5953
5954        let src = load_bytes_to_tensor(
5955            1280,
5956            720,
5957            PixelFormat::Rgba,
5958            Some(TensorMemory::Dma),
5959            &edgefirst_bench::testdata::read("camera720p.rgba"),
5960        )
5961        .unwrap();
5962
5963        let (dst_width, dst_height) = (1280, 720);
5964
5965        let cpu_dst = TensorDyn::image(
5966            dst_width,
5967            dst_height,
5968            PixelFormat::Yuyv,
5969            DType::U8,
5970            Some(TensorMemory::Dma),
5971            edgefirst_tensor::CpuAccess::ReadWrite,
5972        )
5973        .unwrap();
5974
5975        let g2d_dst = TensorDyn::image(
5976            dst_width,
5977            dst_height,
5978            PixelFormat::Yuyv,
5979            DType::U8,
5980            Some(TensorMemory::Dma),
5981            edgefirst_tensor::CpuAccess::ReadWrite,
5982        )
5983        .unwrap();
5984
5985        let mut g2d_converter = G2DProcessor::new().unwrap();
5986        let crop = Crop::new();
5987
5988        g2d_dst
5989            .as_u8()
5990            .unwrap()
5991            .map()
5992            .unwrap()
5993            .as_mut_slice()
5994            .fill(128);
5995        let (result, src, g2d_dst) = convert_img(
5996            &mut g2d_converter,
5997            src,
5998            g2d_dst,
5999            Rotation::None,
6000            Flip::None,
6001            crop,
6002        );
6003        result.unwrap();
6004
6005        let cpu_dst_img = cpu_dst;
6006        cpu_dst_img
6007            .as_u8()
6008            .unwrap()
6009            .map()
6010            .unwrap()
6011            .as_mut_slice()
6012            .fill(128);
6013        let (result, _src, cpu_dst) = convert_img(
6014            &mut CPUProcessor::new(),
6015            src,
6016            cpu_dst_img,
6017            Rotation::None,
6018            Flip::None,
6019            crop,
6020        );
6021        result.unwrap();
6022
6023        compare_images_convert_to_rgb(&cpu_dst, &g2d_dst, 0.98, function!());
6024    }
6025
6026    #[test]
6027    fn test_yuyv_to_rgba_cpu() {
6028        let file = edgefirst_bench::testdata::read("camera720p.yuyv").to_vec();
6029        let src = TensorDyn::image(
6030            1280,
6031            720,
6032            PixelFormat::Yuyv,
6033            DType::U8,
6034            None,
6035            edgefirst_tensor::CpuAccess::ReadWrite,
6036        )
6037        .unwrap();
6038        src.as_u8()
6039            .unwrap()
6040            .map()
6041            .unwrap()
6042            .as_mut_slice()
6043            .copy_from_slice(&file);
6044
6045        let dst = TensorDyn::image(
6046            1280,
6047            720,
6048            PixelFormat::Rgba,
6049            DType::U8,
6050            None,
6051            edgefirst_tensor::CpuAccess::ReadWrite,
6052        )
6053        .unwrap();
6054        let mut cpu_converter = CPUProcessor::new();
6055
6056        let (result, _src, dst) = convert_img(
6057            &mut cpu_converter,
6058            src,
6059            dst,
6060            Rotation::None,
6061            Flip::None,
6062            Crop::no_crop(),
6063        );
6064        result.unwrap();
6065
6066        let target_image = TensorDyn::image(
6067            1280,
6068            720,
6069            PixelFormat::Rgba,
6070            DType::U8,
6071            None,
6072            edgefirst_tensor::CpuAccess::ReadWrite,
6073        )
6074        .unwrap();
6075        target_image
6076            .as_u8()
6077            .unwrap()
6078            .map()
6079            .unwrap()
6080            .as_mut_slice()
6081            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
6082
6083        // CPU path resolves the untagged 720p source to BT.709 limited (height
6084        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
6085        compare_images(&dst, &target_image, 0.98, function!());
6086    }
6087
6088    #[test]
6089    fn test_yuyv_to_rgb_cpu() {
6090        let file = edgefirst_bench::testdata::read("camera720p.yuyv").to_vec();
6091        let src = TensorDyn::image(
6092            1280,
6093            720,
6094            PixelFormat::Yuyv,
6095            DType::U8,
6096            None,
6097            edgefirst_tensor::CpuAccess::ReadWrite,
6098        )
6099        .unwrap();
6100        src.as_u8()
6101            .unwrap()
6102            .map()
6103            .unwrap()
6104            .as_mut_slice()
6105            .copy_from_slice(&file);
6106
6107        let dst = TensorDyn::image(
6108            1280,
6109            720,
6110            PixelFormat::Rgb,
6111            DType::U8,
6112            None,
6113            edgefirst_tensor::CpuAccess::ReadWrite,
6114        )
6115        .unwrap();
6116        let mut cpu_converter = CPUProcessor::new();
6117
6118        let (result, _src, dst) = convert_img(
6119            &mut cpu_converter,
6120            src,
6121            dst,
6122            Rotation::None,
6123            Flip::None,
6124            Crop::no_crop(),
6125        );
6126        result.unwrap();
6127
6128        let target_image = TensorDyn::image(
6129            1280,
6130            720,
6131            PixelFormat::Rgb,
6132            DType::U8,
6133            None,
6134            edgefirst_tensor::CpuAccess::ReadWrite,
6135        )
6136        .unwrap();
6137        target_image
6138            .as_u8()
6139            .unwrap()
6140            .map()
6141            .unwrap()
6142            .as_mut_slice()
6143            .as_chunks_mut::<3>()
6144            .0
6145            .iter_mut()
6146            .zip(
6147                edgefirst_bench::testdata::read("camera720p.rgba")
6148                    .as_chunks::<4>()
6149                    .0,
6150            )
6151            .for_each(|(dst, src)| *dst = [src[0], src[1], src[2]]);
6152
6153        // CPU path resolves the untagged 720p source to BT.709 limited (height
6154        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
6155        compare_images(&dst, &target_image, 0.98, function!());
6156    }
6157
6158    #[test]
6159    #[cfg(target_os = "linux")]
6160    fn test_yuyv_to_rgba_g2d() {
6161        if !is_g2d_available() {
6162            eprintln!("SKIPPED: test_yuyv_to_rgba_g2d - G2D library (libg2d.so.2) not available");
6163            return;
6164        }
6165        if !is_dma_available() {
6166            eprintln!(
6167                "SKIPPED: test_yuyv_to_rgba_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
6168            );
6169            return;
6170        }
6171
6172        let src = load_bytes_to_tensor(
6173            1280,
6174            720,
6175            PixelFormat::Yuyv,
6176            None,
6177            &edgefirst_bench::testdata::read("camera720p.yuyv"),
6178        )
6179        .unwrap();
6180
6181        let dst = TensorDyn::image(
6182            1280,
6183            720,
6184            PixelFormat::Rgba,
6185            DType::U8,
6186            Some(TensorMemory::Dma),
6187            edgefirst_tensor::CpuAccess::ReadWrite,
6188        )
6189        .unwrap();
6190        let mut g2d_converter = G2DProcessor::new().unwrap();
6191
6192        let (result, _src, dst) = convert_img(
6193            &mut g2d_converter,
6194            src,
6195            dst,
6196            Rotation::None,
6197            Flip::None,
6198            Crop::no_crop(),
6199        );
6200        result.unwrap();
6201
6202        let target_image = TensorDyn::image(
6203            1280,
6204            720,
6205            PixelFormat::Rgba,
6206            DType::U8,
6207            None,
6208            edgefirst_tensor::CpuAccess::ReadWrite,
6209        )
6210        .unwrap();
6211        target_image
6212            .as_u8()
6213            .unwrap()
6214            .map()
6215            .unwrap()
6216            .as_mut_slice()
6217            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
6218
6219        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
6220        // so the matrix delta vs the reference that forced 0.95 has closed;
6221        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
6222        compare_images(&dst, &target_image, 0.98, function!());
6223    }
6224
6225    #[test]
6226    #[cfg(target_os = "linux")]
6227    #[cfg(feature = "opengl")]
6228    fn test_yuyv_to_rgba_opengl() {
6229        if !is_opengl_available() {
6230            eprintln!("SKIPPED: {} - OpenGL not available", function!());
6231            return;
6232        }
6233        if !is_dma_available() {
6234            eprintln!(
6235                "SKIPPED: {} - DMA memory allocation not available (permission denied or no DMA-BUF support)",
6236                function!()
6237            );
6238            return;
6239        }
6240
6241        let src = load_bytes_to_tensor(
6242            1280,
6243            720,
6244            PixelFormat::Yuyv,
6245            Some(TensorMemory::Dma),
6246            &edgefirst_bench::testdata::read("camera720p.yuyv"),
6247        )
6248        .unwrap();
6249
6250        let dst = TensorDyn::image(
6251            1280,
6252            720,
6253            PixelFormat::Rgba,
6254            DType::U8,
6255            Some(TensorMemory::Dma),
6256            edgefirst_tensor::CpuAccess::ReadWrite,
6257        )
6258        .unwrap();
6259        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
6260
6261        let (result, _src, dst) = convert_img(
6262            &mut gl_converter,
6263            src,
6264            dst,
6265            Rotation::None,
6266            Flip::None,
6267            Crop::no_crop(),
6268        );
6269        result.unwrap();
6270
6271        let target_image = TensorDyn::image(
6272            1280,
6273            720,
6274            PixelFormat::Rgba,
6275            DType::U8,
6276            None,
6277            edgefirst_tensor::CpuAccess::ReadWrite,
6278        )
6279        .unwrap();
6280        target_image
6281            .as_u8()
6282            .unwrap()
6283            .map()
6284            .unwrap()
6285            .as_mut_slice()
6286            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
6287
6288        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
6289        // so the matrix delta vs the reference that forced 0.95 has closed;
6290        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
6291        compare_images(&dst, &target_image, 0.98, function!());
6292    }
6293
6294    /// macOS analog of `test_yuyv_to_rgba_opengl` — drives the ANGLE +
6295    /// IOSurface backend end-to-end and compares against the same
6296    /// reference image. Skips silently if ANGLE isn't installed so the
6297    /// test suite still passes on CI hosts without the Homebrew tap.
6298    /// Step-1 probe: proves ANGLE's Metal IOSurface-client-buffer path accepts
6299    /// an `L008`→`GL_RED` (R8) binding — the foundation for sampling the
6300    /// contiguous semi-planar YUV buffer as a single R8 texture. Renders a
6301    /// GREY (R8 IOSurface) source through the GL backend to RGBA and checks the
6302    /// luma round-trips to R=G=B (identity GREY→RGB).
6303    #[test]
6304    #[cfg(target_os = "macos")]
6305    #[cfg(feature = "opengl")]
6306    fn test_grey_r8_iosurface_to_rgba_opengl_macos() {
6307        let mut proc = match GLProcessorThreaded::new(None) {
6308            Ok(p) => p,
6309            Err(e) => {
6310                eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
6311                return;
6312            }
6313        };
6314
6315        let (w, h) = (16usize, 16usize);
6316        let src = TensorDyn::image(
6317            w,
6318            h,
6319            PixelFormat::Grey,
6320            DType::U8,
6321            Some(TensorMemory::Dma),
6322            edgefirst_tensor::CpuAccess::ReadWrite,
6323        )
6324        .expect("GREY IOSurface (R8/L008) should allocate — proves the FourCC mapping");
6325        // Known luma ramp: value = (x * 13 + y * 7) & 0xff.
6326        {
6327            let su8 = src.as_u8().unwrap();
6328            let stride = src.as_u8().unwrap().effective_row_stride().unwrap();
6329            let mut m = su8.map().unwrap();
6330            let buf = m.as_mut_slice();
6331            for y in 0..h {
6332                for x in 0..w {
6333                    buf[y * stride + x] = ((x * 13 + y * 7) & 0xff) as u8;
6334                }
6335            }
6336        }
6337
6338        let dst = TensorDyn::image(
6339            w,
6340            h,
6341            PixelFormat::Rgba,
6342            DType::U8,
6343            Some(TensorMemory::Dma),
6344            edgefirst_tensor::CpuAccess::ReadWrite,
6345        )
6346        .unwrap();
6347        let (result, src_back, dst) = convert_img(
6348            &mut proc,
6349            src,
6350            dst,
6351            Rotation::None,
6352            Flip::None,
6353            Crop::no_crop(),
6354        );
6355        result.expect("GREY(R8 IOSurface) → RGBA must convert on ANGLE (R8 binding works)");
6356
6357        let src_stride = src_back.as_u8().unwrap().effective_row_stride().unwrap();
6358        let src_map = src_back.as_u8().unwrap().map().unwrap();
6359        let sbytes = src_map.as_slice();
6360        let dst_stride = dst.as_u8().unwrap().effective_row_stride().unwrap();
6361        let dst_map = dst.as_u8().unwrap().map().unwrap();
6362        let dbytes = dst_map.as_slice();
6363        for y in 0..h {
6364            for x in 0..w {
6365                let yv = sbytes[y * src_stride + x] as i16;
6366                let p = y * dst_stride + x * 4;
6367                for c in 0..3 {
6368                    assert!(
6369                        (dbytes[p + c] as i16 - yv).abs() <= 2,
6370                        "pixel ({x},{y}) ch{c} = {} expected ~{yv} (GREY→RGB identity)",
6371                        dbytes[p + c]
6372                    );
6373                }
6374            }
6375        }
6376    }
6377
6378    /// Two-pass GPU chain: NV12 (R8 IOSurface) → PlanarRgb F16, the profiler's
6379    /// preprocess. Verifies the chained `convert_nv_to_planar_float`
6380    /// (NV12→RGBA8 then the verified RGBA8→PlanarRgb F16) executes on ANGLE and
6381    /// produces a sane F16 planar result: a neutral-grey NV12 input (Y=U=V=128,
6382    /// BT.601 full ⇒ RGB≈0.5) must yield all three planes ≈0.5 (half-float).
6383    #[test]
6384    #[cfg(target_os = "macos")]
6385    #[cfg(feature = "opengl")]
6386    fn test_nv12_to_planar_f16_two_pass_opengl_macos() {
6387        let mut gpu = match GLProcessorThreaded::new(None) {
6388            Ok(p) => p,
6389            Err(e) => {
6390                eprintln!("SKIPPED: {} — init failed ({e:?})", function!());
6391                return;
6392            }
6393        };
6394        let (w, h) = (64usize, 64usize);
6395        let src = match TensorDyn::image(
6396            w,
6397            h,
6398            PixelFormat::Nv12,
6399            DType::U8,
6400            Some(TensorMemory::Dma),
6401            edgefirst_tensor::CpuAccess::ReadWrite,
6402        ) {
6403            Ok(t) => t,
6404            Err(e) => {
6405                eprintln!("SKIPPED: {} — NV12 IOSurface alloc: {e:?}", function!());
6406                return;
6407            }
6408        };
6409        src.as_u8().unwrap().map().unwrap().as_mut_slice().fill(128); // Y=U=V=128
6410
6411        let dst = match TensorDyn::image(
6412            w,
6413            h,
6414            PixelFormat::PlanarRgb,
6415            DType::F16,
6416            Some(TensorMemory::Dma),
6417            edgefirst_tensor::CpuAccess::ReadWrite,
6418        ) {
6419            Ok(t) => t,
6420            Err(e) => {
6421                eprintln!("SKIPPED: {} — F16 PlanarRgb IOSurface: {e:?}", function!());
6422                return;
6423            }
6424        };
6425        let mut dst = dst;
6426        // Call convert directly (the convert_img helper restores u8 only).
6427        if let Err(e) = ImageProcessorTrait::convert(
6428            &mut gpu,
6429            &src,
6430            &mut dst,
6431            Rotation::None,
6432            Flip::None,
6433            Crop::no_crop(),
6434        ) {
6435            // GL_EXT_color_buffer_half_float may be absent on some configs; the
6436            // RGBA8 pass-1 + F16 pass-2 path then can't render. Skip rather than
6437            // fail on a capability gap (the same policy as the F16 path tests).
6438            eprintln!(
6439                "SKIPPED: {} — NV12→PlanarRgb F16 not available ({e:?})",
6440                function!()
6441            );
6442            return;
6443        }
6444        let dt = dst.as_f16().expect("dst is F16 PlanarRgb");
6445        let map = dt.map().unwrap();
6446        let vals = map.as_slice();
6447        // Neutral grey → ~0.5 in every plane. Allow generous tolerance for the
6448        // mediump YUV math + half-float rounding.
6449        let mut checked = 0usize;
6450        for &v in vals.iter() {
6451            let f = f32::from(v);
6452            assert!(
6453                (0.40..=0.60).contains(&f),
6454                "planar F16 value {f} not ~0.5 for neutral-grey NV12"
6455            );
6456            checked += 1;
6457        }
6458        assert!(
6459            checked >= w * h * 3,
6460            "expected >= 3 planes of samples, got {checked}"
6461        );
6462    }
6463
6464    /// Profiler-shaped two-pass: a reused **R8/Grey pool** (allocated larger
6465    /// than the frame, the NV24 worst case `3·H`) is reconfigured to an NV12
6466    /// frame, filled at the preserved physical stride, and converted with a
6467    /// letterbox `src_rect` crop into a model-sized PlanarRgb F16 destination —
6468    /// exactly the orchestrator's preprocess. Guards against the pooled
6469    /// two-pass NV→PlanarRgb F16 path hanging/erroring (the exact-size
6470    /// `test_nv12_to_planar_f16_two_pass` never exercised the larger pool).
6471    #[test]
6472    #[cfg(target_os = "macos")]
6473    #[cfg(feature = "opengl")]
6474    fn test_nv12_to_planar_f16_two_pass_pool_opengl_macos() {
6475        let mut gpu = match GLProcessorThreaded::new(None) {
6476            Ok(p) => p,
6477            Err(e) => {
6478                eprintln!("SKIPPED: {} — init failed ({e:?})", function!());
6479                return;
6480            }
6481        };
6482        // Frame 96×64 in a 256×768 R8 pool (3·256 height; bpr padded past 96).
6483        let (fw, fh) = (96usize, 64usize);
6484        let (pool_w, pool_h) = (256usize, 768usize);
6485        let (model_w, model_h) = (128usize, 128usize);
6486
6487        let mut src = match TensorDyn::image(
6488            pool_w,
6489            pool_h,
6490            PixelFormat::Grey,
6491            DType::U8,
6492            Some(TensorMemory::Dma),
6493            edgefirst_tensor::CpuAccess::ReadWrite,
6494        ) {
6495            Ok(t) => t,
6496            Err(e) => {
6497                eprintln!("SKIPPED: {} — R8 pool alloc: {e:?}", function!());
6498                return;
6499            }
6500        };
6501        src.configure_image(fw, fh, PixelFormat::Nv12)
6502            .unwrap_or_else(|e| panic!("configure_image NV12 on pool: {e}"));
6503        let stride = src.as_u8().unwrap().effective_row_stride().unwrap();
6504        src.as_u8().unwrap().map().unwrap().as_mut_slice().fill(128); // neutral grey
6505
6506        let mut dst = match TensorDyn::image(
6507            model_w,
6508            model_h,
6509            PixelFormat::PlanarRgb,
6510            DType::F16,
6511            Some(TensorMemory::Dma),
6512            edgefirst_tensor::CpuAccess::ReadWrite,
6513        ) {
6514            Ok(t) => t,
6515            Err(e) => {
6516                eprintln!("SKIPPED: {} — F16 PlanarRgb dst: {e:?}", function!());
6517                return;
6518            }
6519        };
6520
6521        // Letterbox crop like the profiler: the backend computes the band.
6522        let _ = model_w;
6523        let crop = Crop::new()
6524            .with_source(Some(Region::new(0, 0, fw, fh)))
6525            .with_fit(Fit::Letterbox {
6526                pad: [0, 0, 0, 255],
6527            });
6528        if let Err(e) =
6529            ImageProcessorTrait::convert(&mut gpu, &src, &mut dst, Rotation::None, Flip::None, crop)
6530        {
6531            eprintln!(
6532                "SKIPPED: {} — NV12→PlanarRgb F16 unavailable ({e:?})",
6533                function!()
6534            );
6535            return;
6536        }
6537        let _ = stride;
6538        // Neutral grey → ~0.5 inside the letterbox band; just assert the convert
6539        // completed and produced finite values (no hang, no NaN garbage).
6540        let dt = dst.as_f16().expect("dst F16");
6541        let map = dt.map().unwrap();
6542        let any_half = map.as_slice().iter().any(|&v| {
6543            let f = f32::from(v);
6544            (0.40..=0.60).contains(&f)
6545        });
6546        assert!(any_half, "expected ~0.5 grey samples in the letterbox band");
6547    }
6548
6549    /// Mirrors the orchestrator: the GL processor is created on one thread
6550    /// and `convert()` is called from a *different* thread (the profiler's
6551    /// Pre-processing worker). Reproduces (or rules out) the GL-context /
6552    /// `glFinish` cross-thread hang seen in the live pipeline. A 20 s watchdog
6553    /// fails loudly rather than hanging the whole test binary.
6554    #[test]
6555    #[cfg(target_os = "macos")]
6556    #[cfg(feature = "opengl")]
6557    fn test_nv12_to_planar_f16_cross_thread_opengl_macos() {
6558        use std::sync::mpsc;
6559        // Public ImageProcessor (Send) created HERE (the main test thread),
6560        // exactly like the orchestrator builds `config.processor` during setup.
6561        let mut proc = match ImageProcessor::new() {
6562            Ok(p) => p,
6563            Err(e) => {
6564                eprintln!("SKIPPED: {} — init failed ({e:?})", function!());
6565                return;
6566            }
6567        };
6568        let (fw, fh) = (96usize, 64usize);
6569        let mut src = match TensorDyn::image(
6570            256,
6571            768,
6572            PixelFormat::Grey,
6573            DType::U8,
6574            Some(TensorMemory::Dma),
6575            edgefirst_tensor::CpuAccess::ReadWrite,
6576        ) {
6577            Ok(t) => t,
6578            Err(e) => {
6579                eprintln!("SKIPPED: {} — pool: {e:?}", function!());
6580                return;
6581            }
6582        };
6583        src.configure_image(fw, fh, PixelFormat::Nv12).unwrap();
6584        src.as_u8().unwrap().map().unwrap().as_mut_slice().fill(128);
6585        let mut dst = match TensorDyn::image(
6586            128,
6587            128,
6588            PixelFormat::PlanarRgb,
6589            DType::F16,
6590            Some(TensorMemory::Dma),
6591            edgefirst_tensor::CpuAccess::ReadWrite,
6592        ) {
6593            Ok(t) => t,
6594            Err(e) => {
6595                eprintln!("SKIPPED: {} — dst: {e:?}", function!());
6596                return;
6597            }
6598        };
6599        let crop = Crop::new().with_source(Some(Region::new(0, 0, fw, fh)));
6600
6601        // ...then MOVED to a worker thread where convert() runs — exactly the
6602        // orchestrator's create-on-setup / convert-on-Pre-processing split.
6603        let (tx, rx) = mpsc::channel::<bool>();
6604        let worker = std::thread::spawn(move || {
6605            let _ = ImageProcessorTrait::convert(
6606                &mut proc,
6607                &src,
6608                &mut dst,
6609                Rotation::None,
6610                Flip::None,
6611                crop,
6612            );
6613            let _ = tx.send(true);
6614        });
6615        match rx.recv_timeout(std::time::Duration::from_secs(20)) {
6616            Ok(_) => { let _ = worker.join(); }
6617            Err(_) => panic!(
6618                "cross-thread NV12→PlanarRgb convert HUNG (>20s) — reproduces the orchestrator deadlock"
6619            ),
6620        }
6621    }
6622
6623    /// Reproduces the profiler's progressive-slowdown/hang: one processor
6624    /// converting many **varying-size** NV frames (like a COCO dataset) from a
6625    /// reused R8 pool into a fixed PlanarRgb F16 model input. The two-pass path
6626    /// reallocated its RGBA intermediate per frame-size, churning/leaking
6627    /// pbuffers until the GPU stalled. Asserts per-convert latency stays bounded
6628    /// (no runaway) over many iterations.
6629    #[test]
6630    #[cfg(target_os = "macos")]
6631    #[cfg(feature = "opengl")]
6632    fn test_nv_to_planar_f16_varying_sizes_no_leak_opengl_macos() {
6633        let mut gpu = match GLProcessorThreaded::new(None) {
6634            Ok(p) => p,
6635            Err(e) => {
6636                eprintln!("SKIPPED: {} — init failed ({e:?})", function!());
6637                return;
6638            }
6639        };
6640        // Mirror the orchestrator's ring buffers at depth 4: a pool of source R8
6641        // tensors AND a pool of PlanarRgb F16 dst slots, both cycled per frame.
6642        let (max_w, max_h) = (640usize, 640usize);
6643        let depth = 4usize;
6644        let mut srcs = Vec::new();
6645        let mut dsts = Vec::new();
6646        for _ in 0..depth {
6647            srcs.push(
6648                match TensorDyn::image(
6649                    max_w,
6650                    max_h * 3,
6651                    PixelFormat::Grey,
6652                    DType::U8,
6653                    Some(TensorMemory::Dma),
6654                    edgefirst_tensor::CpuAccess::ReadWrite,
6655                ) {
6656                    Ok(t) => t,
6657                    Err(e) => {
6658                        eprintln!("SKIPPED: {} — pool: {e:?}", function!());
6659                        return;
6660                    }
6661                },
6662            );
6663            dsts.push(
6664                match TensorDyn::image(
6665                    640,
6666                    640,
6667                    PixelFormat::PlanarRgb,
6668                    DType::F16,
6669                    Some(TensorMemory::Dma),
6670                    edgefirst_tensor::CpuAccess::ReadWrite,
6671                ) {
6672                    Ok(t) => t,
6673                    Err(e) => {
6674                        eprintln!("SKIPPED: {} — dst: {e:?}", function!());
6675                        return;
6676                    }
6677                },
6678            );
6679        }
6680        // COCO-like assorted frame sizes (all ≤ max), cycled.
6681        let sizes = [
6682            (640, 480),
6683            (500, 375),
6684            (640, 427),
6685            (333, 500),
6686            (480, 640),
6687            (612, 612),
6688            (428, 640),
6689            (576, 432),
6690        ];
6691        let mut first_ms = 0f64;
6692        let mut last_ms = 0f64;
6693        let iters = 40usize;
6694        for i in 0..iters {
6695            let (fw, fh) = sizes[i % sizes.len()];
6696            let src = &mut srcs[i % depth];
6697            let dst = &mut dsts[i % depth];
6698            src.configure_image(fw, fh, PixelFormat::Nv24).unwrap();
6699            src.as_u8().unwrap().map().unwrap().as_mut_slice().fill(128);
6700            let crop = Crop::new().with_source(Some(Region::new(0, 0, fw, fh)));
6701            let t0 = std::time::Instant::now();
6702            ImageProcessorTrait::convert(&mut gpu, src, dst, Rotation::None, Flip::None, crop)
6703                .unwrap_or_else(|e| panic!("convert iter {i} ({fw}×{fh}): {e}"));
6704            let ms = t0.elapsed().as_secs_f64() * 1e3;
6705            if i == 2 {
6706                first_ms = ms;
6707            }
6708            if i == iters - 1 {
6709                last_ms = ms;
6710            }
6711        }
6712        eprintln!("first={first_ms:.2}ms last={last_ms:.2}ms");
6713        assert!(
6714            last_ms < first_ms * 5.0 + 5.0,
6715            "convert latency ran away: first {first_ms:.2}ms → last {last_ms:.2}ms (intermediate/pbuffer leak)"
6716        );
6717    }
6718
6719    /// Step-2 verification: NV12/NV16/NV24 (R8 IOSurface) → RGBA on the GPU
6720    /// must match the CPU `yuv` kernels within shader rounding. Fills an
6721    /// IOSurface source and a Mem source from the same logical YUV pattern
6722    /// (each at its own row stride), converts the IOSurface on the GPU and the
6723    /// Mem one on the CPU, and compares. Exercises the in-shader semi-planar
6724    /// addressing for all three subsamplings (incl. NV24's 2×-wide UV rows).
6725    #[test]
6726    #[cfg(target_os = "macos")]
6727    #[cfg(feature = "opengl")]
6728    fn test_nv12_nv16_nv24_to_rgba_opengl_macos() {
6729        let mut gpu = match GLProcessorThreaded::new(None) {
6730            Ok(p) => p,
6731            Err(e) => {
6732                eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
6733                return;
6734            }
6735        };
6736        let mut cpu = CPUProcessor::new();
6737
6738        // Fill Y plus the interleaved UV plane in the canonical semi-planar
6739        // layout — exactly what the codec writes and the CPU `yuv`-crate reader
6740        // expects: the Y plane is `h` rows at the buffer's row stride; the UV
6741        // plane starts at `h * stride`; each chroma row advances
6742        // `uv_grid_rows * stride` bytes (NV24 carries a full-resolution `2*W`
6743        // byte line == two grid rows, NV12/NV16 one row of `W/2` pairs); each
6744        // (Cb,Cr) pair is two consecutive bytes at column `cx * 2`. This is
6745        // stride-correct for both the tight Mem buffer and the padded IOSurface.
6746        //
6747        // Takes explicit w/h so the closure can be reused across multiple frame
6748        // sizes (even and odd) without capturing a fixed outer variable.
6749        let fill = |buf: &mut [u8], stride: usize, fmt: PixelFormat, w: usize, h: usize| {
6750            for y in 0..h {
6751                for x in 0..w {
6752                    buf[y * stride + x] = ((x * 9 + y * 5) & 0xff) as u8;
6753                }
6754            }
6755            let (cw, ch, uv_grid_rows) = match fmt {
6756                PixelFormat::Nv12 => (w / 2, h / 2, 1usize),
6757                PixelFormat::Nv16 => (w / 2, h, 1usize),
6758                _ => (w, h, 2usize), // Nv24: full-res chroma, 2W bytes/row
6759            };
6760            let uv_plane = h * stride;
6761            for cy in 0..ch {
6762                for cx in 0..cw {
6763                    let off = uv_plane + cy * uv_grid_rows * stride + cx * 2;
6764                    buf[off] = ((cx * 11 + 30) & 0xff) as u8;
6765                    buf[off + 1] = ((cy * 7 + 200) & 0xff) as u8;
6766                }
6767            }
6768        };
6769
6770        for fmt in [PixelFormat::Nv12, PixelFormat::Nv16, PixelFormat::Nv24] {
6771            for (w, h) in [
6772                (16usize, 16usize), // original even-dim case
6773                (15, 16),           // odd-W
6774                (16, 15),           // odd-H
6775            ] {
6776                let mem = TensorDyn::image(
6777                    w,
6778                    h,
6779                    fmt,
6780                    DType::U8,
6781                    None,
6782                    edgefirst_tensor::CpuAccess::ReadWrite,
6783                )
6784                .unwrap();
6785                let mem_stride = mem.as_u8().unwrap().effective_row_stride().unwrap();
6786                fill(
6787                    mem.as_u8().unwrap().map().unwrap().as_mut_slice(),
6788                    mem_stride,
6789                    fmt,
6790                    w,
6791                    h,
6792                );
6793                let cpu_dst = TensorDyn::image(
6794                    w,
6795                    h,
6796                    PixelFormat::Rgba,
6797                    DType::U8,
6798                    None,
6799                    edgefirst_tensor::CpuAccess::ReadWrite,
6800                )
6801                .unwrap();
6802                let (r, _s, cpu_dst) = convert_img(
6803                    &mut cpu,
6804                    mem,
6805                    cpu_dst,
6806                    Rotation::None,
6807                    Flip::None,
6808                    Crop::no_crop(),
6809                );
6810                r.unwrap_or_else(|e| panic!("CPU {fmt:?}->{w}x{h}->RGBA: {e}"));
6811
6812                let ios = TensorDyn::image(
6813                    w,
6814                    h,
6815                    fmt,
6816                    DType::U8,
6817                    Some(TensorMemory::Dma),
6818                    edgefirst_tensor::CpuAccess::ReadWrite,
6819                )
6820                .unwrap_or_else(|e| panic!("{fmt:?} {w}x{h} IOSurface alloc: {e}"));
6821                let ios_stride = ios.as_u8().unwrap().effective_row_stride().unwrap();
6822                fill(
6823                    ios.as_u8().unwrap().map().unwrap().as_mut_slice(),
6824                    ios_stride,
6825                    fmt,
6826                    w,
6827                    h,
6828                );
6829                let gpu_dst = TensorDyn::image(
6830                    w,
6831                    h,
6832                    PixelFormat::Rgba,
6833                    DType::U8,
6834                    Some(TensorMemory::Dma),
6835                    edgefirst_tensor::CpuAccess::ReadWrite,
6836                )
6837                .unwrap();
6838                let (r, _s, gpu_dst) = convert_img(
6839                    &mut gpu,
6840                    ios,
6841                    gpu_dst,
6842                    Rotation::None,
6843                    Flip::None,
6844                    Crop::no_crop(),
6845                );
6846                r.unwrap_or_else(|e| panic!("GPU {fmt:?}->{w}x{h}->RGBA on ANGLE: {e}"));
6847
6848                let cs = cpu_dst.as_u8().unwrap().effective_row_stride().unwrap();
6849                let cmap = cpu_dst.as_u8().unwrap().map().unwrap();
6850                let cb = cmap.as_slice();
6851                let gs = gpu_dst.as_u8().unwrap().effective_row_stride().unwrap();
6852                let gmap = gpu_dst.as_u8().unwrap().map().unwrap();
6853                let gb = gmap.as_slice();
6854                let mut max_d = 0i16;
6855                for y in 0..h {
6856                    for x in 0..w {
6857                        for c in 0..3 {
6858                            let cv = cb[y * cs + x * 4 + c] as i16;
6859                            let gv = gb[y * gs + x * 4 + c] as i16;
6860                            max_d = max_d.max((cv - gv).abs());
6861                        }
6862                    }
6863                }
6864                assert!(
6865                    max_d <= 3,
6866                    "{fmt:?} {w}x{h}: GPU vs CPU RGBA max channel diff {max_d} > 3"
6867                );
6868            }
6869        }
6870    }
6871
6872    /// Phase-0 gate: the reused-pool / larger-surface case. A single R8 pool
6873    /// IOSurface (allocated bigger than the frame, so its `bytesPerRow` exceeds
6874    /// the frame's even width) is reconfigured to each NV frame, filled at the
6875    /// preserved physical stride, and converted on the GPU. This proves ANGLE
6876    /// binds the *whole* physical surface as a pbuffer and that `texelFetch`
6877    /// resolves the frame's Y/UV texels through the surface's real `bytesPerRow`
6878    /// (the physical-grid / logical-ROI decoupling). GPU must match CPU ≤3 LSB.
6879    #[test]
6880    #[cfg(target_os = "macos")]
6881    #[cfg(feature = "opengl")]
6882    fn test_nv_to_rgba_larger_pool_surface_opengl_macos() {
6883        let mut gpu = match GLProcessorThreaded::new(None) {
6884            Ok(p) => p,
6885            Err(e) => {
6886                eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
6887                return;
6888            }
6889        };
6890        let mut cpu = CPUProcessor::new();
6891        // The pool is generously oversized (256-wide → bpr 256, well beyond any
6892        // frame's even width) and tall enough for NV24's 3·H at the test frames.
6893        let (pool_w, pool_h) = (256usize, 256usize);
6894
6895        // Canonical semi-planar fill: Y plane at the row stride, then the UV
6896        // plane at `h * stride` with each chroma row advancing `uv_grid_rows *
6897        // stride` bytes. Stride-correct for both tight Mem and padded IOSurface.
6898        let fill = |buf: &mut [u8], stride: usize, fmt: PixelFormat, w: usize, h: usize| {
6899            for y in 0..h {
6900                for x in 0..w {
6901                    buf[y * stride + x] = ((x * 9 + y * 5) & 0xff) as u8;
6902                }
6903            }
6904            let (cw, ch, uv_grid_rows) = match fmt {
6905                PixelFormat::Nv12 => (w / 2, h / 2, 1usize),
6906                PixelFormat::Nv16 => (w / 2, h, 1usize),
6907                _ => (w, h, 2usize), // Nv24: full-res chroma, 2W bytes/row
6908            };
6909            let uv_plane = h * stride;
6910            for cy in 0..ch {
6911                for cx in 0..cw {
6912                    let off = uv_plane + cy * uv_grid_rows * stride + cx * 2;
6913                    buf[off] = ((cx * 11 + 30) & 0xff) as u8;
6914                    buf[off + 1] = ((cy * 7 + 200) & 0xff) as u8;
6915                }
6916            }
6917        };
6918
6919        for fmt in [PixelFormat::Nv12, PixelFormat::Nv16, PixelFormat::Nv24] {
6920            for (w, h) in [
6921                (40usize, 24usize), // original even-dim case
6922                (15, 16),           // odd-W
6923                (16, 15),           // odd-H
6924            ] {
6925                // `ew` is the minimum even extent; the pool stride must exceed it
6926                // to actually exercise the physical-stride shader decoupling.
6927                let ew = w.next_multiple_of(2);
6928
6929                // CPU reference from a tightly-packed Mem tensor at the frame size.
6930                let mem = TensorDyn::image(
6931                    w,
6932                    h,
6933                    fmt,
6934                    DType::U8,
6935                    None,
6936                    edgefirst_tensor::CpuAccess::ReadWrite,
6937                )
6938                .unwrap();
6939                let mem_stride = mem.as_u8().unwrap().effective_row_stride().unwrap();
6940                fill(
6941                    mem.as_u8().unwrap().map().unwrap().as_mut_slice(),
6942                    mem_stride,
6943                    fmt,
6944                    w,
6945                    h,
6946                );
6947                let cpu_dst = TensorDyn::image(
6948                    w,
6949                    h,
6950                    PixelFormat::Rgba,
6951                    DType::U8,
6952                    None,
6953                    edgefirst_tensor::CpuAccess::ReadWrite,
6954                )
6955                .unwrap();
6956                let (r, _s, cpu_dst) = convert_img(
6957                    &mut cpu,
6958                    mem,
6959                    cpu_dst,
6960                    Rotation::None,
6961                    Flip::None,
6962                    Crop::no_crop(),
6963                );
6964                r.unwrap_or_else(|e| panic!("CPU {fmt:?}->{w}x{h}->RGBA: {e}"));
6965
6966                // GPU source: a LARGER R8 pool surface, reconfigured down to the
6967                // frame. Phase 1 preserves the pool's padded `bytesPerRow` as the
6968                // tensor's row stride; the fill writes the frame at that stride.
6969                let mut ios = match TensorDyn::image(
6970                    pool_w,
6971                    pool_h,
6972                    PixelFormat::Grey,
6973                    DType::U8,
6974                    Some(TensorMemory::Dma),
6975                    edgefirst_tensor::CpuAccess::ReadWrite,
6976                ) {
6977                    Ok(t) => t,
6978                    Err(e) => {
6979                        eprintln!("SKIPPED: {} — R8 pool IOSurface alloc: {e:?}", function!());
6980                        return;
6981                    }
6982                };
6983                ios.configure_image(w, h, fmt)
6984                    .unwrap_or_else(|e| panic!("configure_image {fmt:?} {w}x{h} on pool: {e}"));
6985                let ios_stride = ios.as_u8().unwrap().effective_row_stride().unwrap();
6986                assert!(
6987                    ios_stride > ew,
6988                    "{fmt:?} {w}x{h}: pool stride {ios_stride} should exceed even width {ew} \
6989                     (test must exercise padding)"
6990                );
6991                fill(
6992                    ios.as_u8().unwrap().map().unwrap().as_mut_slice(),
6993                    ios_stride,
6994                    fmt,
6995                    w,
6996                    h,
6997                );
6998
6999                let gpu_dst = TensorDyn::image(
7000                    w,
7001                    h,
7002                    PixelFormat::Rgba,
7003                    DType::U8,
7004                    Some(TensorMemory::Dma),
7005                    edgefirst_tensor::CpuAccess::ReadWrite,
7006                )
7007                .unwrap();
7008                let (r, _s, gpu_dst) = convert_img(
7009                    &mut gpu,
7010                    ios,
7011                    gpu_dst,
7012                    Rotation::None,
7013                    Flip::None,
7014                    Crop::no_crop(),
7015                );
7016                r.unwrap_or_else(|e| {
7017                    panic!("GPU {fmt:?}->{w}x{h}->RGBA (pool surface) on ANGLE: {e}")
7018                });
7019
7020                let cs = cpu_dst.as_u8().unwrap().effective_row_stride().unwrap();
7021                let cmap = cpu_dst.as_u8().unwrap().map().unwrap();
7022                let cb = cmap.as_slice();
7023                let gs = gpu_dst.as_u8().unwrap().effective_row_stride().unwrap();
7024                let gmap = gpu_dst.as_u8().unwrap().map().unwrap();
7025                let gb = gmap.as_slice();
7026                let mut max_d = 0i16;
7027                for y in 0..h {
7028                    for x in 0..w {
7029                        for c in 0..3 {
7030                            let cv = cb[y * cs + x * 4 + c] as i16;
7031                            let gv = gb[y * gs + x * 4 + c] as i16;
7032                            max_d = max_d.max((cv - gv).abs());
7033                        }
7034                    }
7035                }
7036                assert!(
7037                    max_d <= 3,
7038                    "{fmt:?} {w}x{h}: GPU(pool surface) vs CPU RGBA max channel diff {max_d} > 3"
7039                );
7040            }
7041        }
7042    }
7043
7044    #[test]
7045    #[cfg(target_os = "macos")]
7046    #[cfg(feature = "opengl")]
7047    fn test_yuyv_to_rgba_opengl_macos() {
7048        let mut proc = match GLProcessorThreaded::new(None) {
7049            Ok(p) => p,
7050            Err(e) => {
7051                eprintln!(
7052                    "SKIPPED: {} — GL engine init failed ({e:?}). \
7053                     Install ANGLE via `brew install startergo/angle/angle` \
7054                     and re-sign per README.md § macOS GPU Acceleration to \
7055                     run this test.",
7056                    function!()
7057                );
7058                return;
7059            }
7060        };
7061
7062        let src = load_bytes_to_tensor(
7063            1280,
7064            720,
7065            PixelFormat::Yuyv,
7066            Some(TensorMemory::Dma),
7067            &edgefirst_bench::testdata::read("camera720p.yuyv"),
7068        )
7069        .unwrap();
7070
7071        let dst = TensorDyn::image(
7072            1280,
7073            720,
7074            PixelFormat::Rgba,
7075            DType::U8,
7076            Some(TensorMemory::Dma),
7077            edgefirst_tensor::CpuAccess::ReadWrite,
7078        )
7079        .unwrap();
7080
7081        let (result, _src, dst) = convert_img(
7082            &mut proc,
7083            src,
7084            dst,
7085            Rotation::None,
7086            Flip::None,
7087            Crop::no_crop(),
7088        );
7089        result.unwrap();
7090
7091        let target_image = TensorDyn::image(
7092            1280,
7093            720,
7094            PixelFormat::Rgba,
7095            DType::U8,
7096            None,
7097            edgefirst_tensor::CpuAccess::ReadWrite,
7098        )
7099        .unwrap();
7100        target_image
7101            .as_u8()
7102            .unwrap()
7103            .map()
7104            .unwrap()
7105            .as_mut_slice()
7106            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
7107
7108        // macOS YUYV shader now threads per-tensor colorimetry: the untagged
7109        // camera720p source resolves to BT.709 limited (matching the BT.709
7110        // reference), measured 0.9973 on ANGLE — up from 0.9733 under the old
7111        // BT.601-full stop-gap. 0.98 leaves headroom for cross-GPU variance.
7112        compare_images(&dst, &target_image, 0.98, function!());
7113    }
7114
7115    /// Multi-resolution smoke test: convert YUYV→RGBA via the GL
7116    /// backend at a small (64×32) frame and a 4K (3840×2160) frame,
7117    /// both filled with a synthetic mid-grey pattern. Validates the
7118    /// shader math at the chroma-pairing boundary on small textures
7119    /// and exercises the IOSurface bytes-per-row alignment path at 4K
7120    /// (3840 pixels × 2 bytes/pixel = 7680 bytes, naturally 64-aligned).
7121    ///
7122    /// Resolutions below 32 pixels wide aren't tested because the
7123    /// IOSurface allocator pads bpr to 64 bytes — for a 4-px-wide
7124    /// YUYV surface that's 8 bytes data + 56 bytes padding per row,
7125    /// which exercises a sampling pattern that's ANGLE-version
7126    /// dependent rather than HAL-correctness dependent.
7127    ///
7128    /// This complements `test_yuyv_to_rgba_opengl_macos` (which checks
7129    /// pixel-exact correctness against a reference image at 720p) by
7130    /// ensuring the pipeline does not crash or produce gross errors at
7131    /// resolution extremes. Pixel-exact validation at 4K would require
7132    /// a 30 MB reference file we don't want to bundle.
7133    #[test]
7134    #[cfg(target_os = "macos")]
7135    #[cfg(feature = "opengl")]
7136    fn test_yuyv_to_rgba_opengl_macos_multi_resolution() {
7137        let mut proc = match GLProcessorThreaded::new(None) {
7138            Ok(p) => p,
7139            Err(e) => {
7140                eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
7141                return;
7142            }
7143        };
7144
7145        for (w, h) in [(64usize, 32usize), (3840, 2160)] {
7146            // Synthetic YUYV: Y=128 (mid-grey luma), U=V=128 (neutral
7147            // chroma) → RGB grey at the output.
7148            let bytes_per_row = w * 2;
7149            let mut yuyv = vec![0u8; bytes_per_row * h];
7150            for chunk in yuyv.chunks_exact_mut(4) {
7151                chunk[0] = 128; // Y0
7152                chunk[1] = 128; // U
7153                chunk[2] = 128; // Y1
7154                chunk[3] = 128; // V
7155            }
7156
7157            let src = load_bytes_to_tensor(w, h, PixelFormat::Yuyv, Some(TensorMemory::Dma), &yuyv)
7158                .unwrap();
7159
7160            let dst = TensorDyn::image(
7161                w,
7162                h,
7163                PixelFormat::Rgba,
7164                DType::U8,
7165                Some(TensorMemory::Dma),
7166                edgefirst_tensor::CpuAccess::ReadWrite,
7167            )
7168            .unwrap();
7169
7170            let (result, _src, dst) = convert_img(
7171                &mut proc,
7172                src,
7173                dst,
7174                Rotation::None,
7175                Flip::None,
7176                Crop::no_crop(),
7177            );
7178            result.expect("GL convert should succeed at this resolution");
7179
7180            // The neutral-chroma input must produce a near-grey output;
7181            // BT.709 limited-range maps Y=128/UV=128 → roughly
7182            // (130, 130, 130). Allow ±4 LSB for `mediump float` shader
7183            // rounding.
7184            let dst_u8 = dst.as_u8().unwrap();
7185            let dst_map = dst_u8.map().unwrap();
7186            let dst_bytes = dst_map.as_slice();
7187            assert_eq!(dst_bytes.len(), w * h * 4, "RGBA byte count");
7188            for px in dst_bytes.chunks_exact(4) {
7189                for (i, &c) in px[..3].iter().enumerate() {
7190                    assert!(
7191                        (120..=140).contains(&c),
7192                        "{}: channel {i} = {c} (expected ~128 ±12) at {w}×{h}",
7193                        function!(),
7194                    );
7195                }
7196                assert_eq!(px[3], 255, "alpha must be 1.0");
7197            }
7198        }
7199    }
7200
7201    /// Verify that two consecutive convert() calls on the same source
7202    /// tensor reuse the cached EGL pbuffer. Tests the cache hit path
7203    /// added with the macOS GL backend hardening — without it, each
7204    /// frame would pay `eglCreatePbufferFromClientBuffer` + destroy.
7205    ///
7206    /// This is a behaviour test rather than a perf test (the timing
7207    /// difference is 100-200µs which is too noisy to assert on); we
7208    /// check that the second call succeeds and produces a result
7209    /// identical to the first.
7210    #[test]
7211    #[cfg(target_os = "macos")]
7212    #[cfg(feature = "opengl")]
7213    fn test_macos_gl_pbuffer_cache_reuses_surfaces() {
7214        let mut proc = match GLProcessorThreaded::new(None) {
7215            Ok(p) => p,
7216            Err(e) => {
7217                eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
7218                return;
7219            }
7220        };
7221
7222        // Allocate one source + one destination, run convert twice.
7223        let mut yuyv = vec![0u8; 64 * 32 * 2];
7224        for chunk in yuyv.chunks_exact_mut(4) {
7225            chunk[0] = 200;
7226            chunk[1] = 100;
7227            chunk[2] = 200;
7228            chunk[3] = 156;
7229        }
7230        let src = load_bytes_to_tensor(64, 32, PixelFormat::Yuyv, Some(TensorMemory::Dma), &yuyv)
7231            .unwrap();
7232        let dst = TensorDyn::image(
7233            64,
7234            32,
7235            PixelFormat::Rgba,
7236            DType::U8,
7237            Some(TensorMemory::Dma),
7238            edgefirst_tensor::CpuAccess::ReadWrite,
7239        )
7240        .unwrap();
7241
7242        let (r1, src, dst) = convert_img(
7243            &mut proc,
7244            src,
7245            dst,
7246            Rotation::None,
7247            Flip::None,
7248            Crop::no_crop(),
7249        );
7250        r1.unwrap();
7251        let first: Vec<u8> = dst.as_u8().unwrap().map().unwrap().as_slice().to_vec();
7252
7253        let (r2, _src, dst) = convert_img(
7254            &mut proc,
7255            src,
7256            dst,
7257            Rotation::None,
7258            Flip::None,
7259            Crop::no_crop(),
7260        );
7261        r2.unwrap();
7262        let second: Vec<u8> = dst.as_u8().unwrap().map().unwrap().as_slice().to_vec();
7263
7264        assert_eq!(first, second, "cache-hit conversion must be deterministic");
7265    }
7266
7267    /// Steady-state import gate (macOS half of the Linux
7268    /// `dma_pool_steady_state_zero_imports` test): an N-frame convert loop
7269    /// over a fixed pool of IOSurface tensors must create ZERO new EGL
7270    /// pbuffers after the pool has been seen once — pbuffer-cache misses
7271    /// stay flat while hits grow. Counter-based hardening of
7272    /// `test_macos_gl_pbuffer_cache_reuses_surfaces` above: a refactor that
7273    /// re-imports per frame passes the pixel-equality test but fails this.
7274    #[test]
7275    #[cfg(target_os = "macos")]
7276    #[cfg(feature = "opengl")]
7277    fn test_macos_gl_pbuffer_cache_steady_state() {
7278        let mut proc = match GLProcessorThreaded::new(None) {
7279            Ok(p) => p,
7280            Err(e) => {
7281                eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
7282                return;
7283            }
7284        };
7285
7286        let (w, h) = (64usize, 32usize);
7287        const POOL: usize = 3;
7288        const FRAMES: usize = 100;
7289
7290        let yuyv = vec![128u8; w * h * 2];
7291        let pool: Vec<TensorDyn> = (0..POOL)
7292            .map(|_| {
7293                load_bytes_to_tensor(w, h, PixelFormat::Yuyv, Some(TensorMemory::Dma), &yuyv)
7294                    .unwrap()
7295            })
7296            .collect();
7297        let mut dst = TensorDyn::image(
7298            w,
7299            h,
7300            PixelFormat::Rgba,
7301            DType::U8,
7302            Some(TensorMemory::Dma),
7303            edgefirst_tensor::CpuAccess::ReadWrite,
7304        )
7305        .unwrap();
7306
7307        // Warmup: two passes over the pool import every surface once.
7308        for src in pool.iter().cycle().take(POOL * 2) {
7309            proc.convert(src, &mut dst, Rotation::None, Flip::None, Crop::no_crop())
7310                .unwrap();
7311        }
7312        let warm = proc.egl_cache_stats().unwrap();
7313
7314        for src in pool.iter().cycle().take(FRAMES) {
7315            proc.convert(src, &mut dst, Rotation::None, Flip::None, Crop::no_crop())
7316                .unwrap();
7317        }
7318        let steady = proc.egl_cache_stats().unwrap();
7319
7320        assert_eq!(
7321            warm.total_misses(),
7322            steady.total_misses(),
7323            "steady-state loop created new imports (warm {warm:?}, steady {steady:?})"
7324        );
7325        let hits = |s: &GlCacheStats| s.src.hits + s.dst.hits + s.nv_r8.hits;
7326        assert!(
7327            hits(&steady) - hits(&warm) >= FRAMES as u64,
7328            "expected at least {FRAMES} import-cache hits over the loop, got {}",
7329            hits(&steady) - hits(&warm)
7330        );
7331    }
7332
7333    /// Backend assertion for the F16 zero-copy path: when the GL backend
7334    /// initialized (ANGLE on macOS) and reports F16 color-buffer support,
7335    /// the NV12→PlanarRgb-F16 IOSurface convert MUST be handled by the GL
7336    /// engine — proven by the engine's import-cache counters moving, not
7337    /// just by output correctness. This is the guard against the
7338    /// silent-CPU-fallback failure mode: a misclassified IOSurface F16
7339    /// destination keeps every output-correctness test green while
7340    /// quietly running ~10× slower on CPU; only a backend observable
7341    /// catches it. Skips ONLY when GL itself is unavailable or the
7342    /// configuration lacks F16 — a convert error or a CPU-routed convert
7343    /// with the capability present is a FAILURE.
7344    #[test]
7345    #[cfg(target_os = "macos")]
7346    #[cfg(feature = "opengl")]
7347    fn test_macos_gl_f16_planar_is_gl_backed() {
7348        let mut proc = ImageProcessor::new().expect("ImageProcessor");
7349        let Some(ref gl) = proc.opengl else {
7350            eprintln!("SKIPPED: {} — GL backend unavailable", function!());
7351            return;
7352        };
7353        if !gl.supported_render_dtypes().f16 {
7354            eprintln!(
7355                "SKIPPED: {} — configuration lacks F16 color-buffer support",
7356                function!()
7357            );
7358            return;
7359        }
7360        let stats_before = gl.egl_cache_stats().expect("cache stats");
7361
7362        let src = TensorDyn::image(
7363            1280,
7364            720,
7365            PixelFormat::Nv12,
7366            DType::U8,
7367            Some(TensorMemory::Dma),
7368            edgefirst_tensor::CpuAccess::ReadWrite,
7369        )
7370        .unwrap();
7371        {
7372            let t = src.as_u8().unwrap();
7373            let mut m = t.map().unwrap();
7374            for (i, b) in m.as_mut_slice().iter_mut().enumerate() {
7375                *b = ((i * 31) % 211) as u8;
7376            }
7377        }
7378        let mut dst = TensorDyn::image(
7379            640,
7380            640,
7381            PixelFormat::PlanarRgb,
7382            DType::F16,
7383            Some(TensorMemory::Dma),
7384            edgefirst_tensor::CpuAccess::ReadWrite,
7385        )
7386        .unwrap();
7387
7388        proc.convert(
7389            &src,
7390            &mut dst,
7391            Rotation::None,
7392            Flip::None,
7393            Crop::letterbox([114, 114, 114, 255]),
7394        )
7395        .expect("F16 capability reported but the NV12→PlanarF16 convert failed");
7396        let stats_after = proc
7397            .opengl
7398            .as_ref()
7399            .expect("GL backend present")
7400            .egl_cache_stats()
7401            .expect("cache stats");
7402        // ≥ 2 misses: the fused convert imports the zero-copy NV source
7403        // (pass 1) AND the F16 destination (pass 2). Requiring both means a
7404        // convert that imported its source, failed mid-engine, and fell back
7405        // to CPU (1 miss) cannot satisfy this gate.
7406        assert!(
7407            stats_after.total_misses() >= stats_before.total_misses() + 2,
7408            "convert succeeded but the GL engine did not import both the \
7409             source and the F16 destination — the work did not (fully) run \
7410             on the GL backend (silent CPU fallback); misses before={} after={}",
7411            stats_before.total_misses(),
7412            stats_after.total_misses()
7413        );
7414    }
7415
7416    /// Portable oracle for the fused NV12→PlanarRgb-F16 engine convert
7417    /// (two GL passes: NV→RGBA intermediate, then the packed RGBA16F
7418    /// render). New on every platform with F16 render support — macOS
7419    /// IOSurface and Linux DMA-BUF alike. Compares against the CPU
7420    /// backend's reference within the float-path tolerance.
7421    #[test]
7422    #[cfg(feature = "opengl")]
7423    fn test_nv12_to_planar_f16_fused_engine_vs_cpu() {
7424        let mut gl = match ImageProcessor::with_config(ImageProcessorConfig {
7425            backend: ComputeBackend::OpenGl,
7426            ..Default::default()
7427        }) {
7428            Ok(p) if p.opengl.is_some() => p,
7429            _ => {
7430                eprintln!("SKIPPED: {} — GL backend unavailable", function!());
7431                return;
7432            }
7433        };
7434        if !gl
7435            .opengl
7436            .as_ref()
7437            .map(|g| g.supported_render_dtypes().f16)
7438            .unwrap_or(false)
7439        {
7440            eprintln!("SKIPPED: {} — no F16 render support", function!());
7441            return;
7442        }
7443        let mem = if edgefirst_tensor::is_gpu_buffer_available() {
7444            TensorMemory::Dma
7445        } else {
7446            eprintln!("SKIPPED: {} — no zero-copy buffers", function!());
7447            return;
7448        };
7449
7450        let src = TensorDyn::image(
7451            1280,
7452            720,
7453            PixelFormat::Nv12,
7454            DType::U8,
7455            Some(mem),
7456            edgefirst_tensor::CpuAccess::ReadWrite,
7457        )
7458        .unwrap();
7459        {
7460            // Smooth gradients, NOT noise: the GL and CPU paths upsample
7461            // chroma with different kernels (nearest vs bilinear), which
7462            // legitimately diverges on per-texel chroma noise. Gradients
7463            // keep that kernel difference sub-LSB while still exercising
7464            // the full matrix math and the letterbox geometry.
7465            let t = src.as_u8().unwrap();
7466            let mut m = t.map().unwrap();
7467            let buf = m.as_mut_slice();
7468            let (w, h) = (1280usize, 720usize);
7469            for y in 0..h {
7470                for x in 0..w {
7471                    buf[y * w + x] = ((x * 255) / w) as u8; // luma ramp
7472                }
7473            }
7474            for y in 0..(h / 2) {
7475                for x in 0..(w / 2) {
7476                    let o = h * w + y * w + 2 * x;
7477                    buf[o] = ((y * 255) / (h / 2)) as u8; // U vertical ramp
7478                    buf[o + 1] = (((x + y) * 255) / (w / 2 + h / 2)) as u8; // V diagonal
7479                }
7480            }
7481        }
7482        let crop = Crop::letterbox([114, 114, 114, 255]);
7483        let mut gl_dst = TensorDyn::image(
7484            640,
7485            640,
7486            PixelFormat::PlanarRgb,
7487            DType::F16,
7488            Some(mem),
7489            edgefirst_tensor::CpuAccess::ReadWrite,
7490        )
7491        .unwrap();
7492        // Drive the GL backend DIRECTLY: a convert through `ImageProcessor`
7493        // silently falls back to CPU on a GL error, turning this oracle into
7494        // a CPU-vs-CPU tautology. A direct call surfaces the engine error.
7495        gl.opengl
7496            .as_mut()
7497            .expect("GL backend present")
7498            .convert(&src, &mut gl_dst, Rotation::None, Flip::None, crop)
7499            .expect("fused NV12→PlanarF16 GL convert");
7500
7501        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
7502            backend: ComputeBackend::Cpu,
7503            ..Default::default()
7504        })
7505        .unwrap();
7506        let mut cpu_dst = TensorDyn::image(
7507            640,
7508            640,
7509            PixelFormat::PlanarRgb,
7510            DType::F16,
7511            Some(TensorMemory::Mem),
7512            edgefirst_tensor::CpuAccess::ReadWrite,
7513        )
7514        .unwrap();
7515        cpu.convert(&src, &mut cpu_dst, Rotation::None, Flip::None, crop)
7516            .expect("CPU reference convert");
7517
7518        let g = gl_dst.as_f16().unwrap().map().unwrap().as_slice().to_vec();
7519        let c = cpu_dst.as_f16().unwrap().map().unwrap().as_slice().to_vec();
7520        assert_eq!(g.len(), c.len());
7521        let mut max_diff = 0.0f32;
7522        let mut max_at = 0usize;
7523        for (i, (a, b)) in g.iter().zip(c.iter()).enumerate() {
7524            let d = (a.to_f32() - b.to_f32()).abs();
7525            if d > max_diff {
7526                max_diff = d;
7527                max_at = i;
7528            }
7529        }
7530        // Localize: plane (R/G/B), row, col of the worst element.
7531        let (plane, rem) = (max_at / (640 * 640), max_at % (640 * 640));
7532        let (row, col) = (rem / 640, rem % 640);
7533        eprintln!(
7534            "fused-vs-cpu: max_diff={max_diff} at plane={plane} row={row} col={col} \
7535             gl={} cpu={}",
7536            g[max_at].to_f32(),
7537            c[max_at].to_f32()
7538        );
7539        // Two GPU passes (8-bit intermediate + linear filtering) vs the
7540        // CPU's direct path: allow a few 8-bit steps of divergence.
7541        assert!(
7542            max_diff <= 4.0 / 255.0 + 1e-3,
7543            "fused NV12→PlanarF16 diverges from CPU reference: max_diff={max_diff}"
7544        );
7545    }
7546
7547    /// Zero-copy source → heap destination through the GL engine,
7548    /// driven on the GL backend DIRECTLY so a GL error cannot hide
7549    /// behind the `ImageProcessor` CPU fallback (the shape that exposed
7550    /// the macOS `glReadnPixels` failure: imported source, rendered,
7551    /// then errored at the heap readback). RGBA→BGRA so the convert is
7552    /// a pure byte shuffle: no chroma kernel or colorimetry ambiguity
7553    /// (Vivante's NV fast path legitimately diverges from the CPU
7554    /// reference), and the readback stays GL_RGBA (V3D rejects RGB
7555    /// readbacks).
7556    #[test]
7557    #[cfg(feature = "opengl")]
7558    fn test_zero_copy_src_to_mem_dst_gl_direct() {
7559        let mut proc = match ImageProcessor::new() {
7560            Ok(p) if p.opengl.is_some() => p,
7561            _ => {
7562                eprintln!("SKIPPED: {} — GL backend unavailable", function!());
7563                return;
7564            }
7565        };
7566        if !edgefirst_tensor::is_gpu_buffer_available() {
7567            eprintln!("SKIPPED: {} — no zero-copy buffers", function!());
7568            return;
7569        }
7570
7571        let src = TensorDyn::image(
7572            1280,
7573            720,
7574            PixelFormat::Rgba,
7575            DType::U8,
7576            Some(TensorMemory::Dma),
7577            edgefirst_tensor::CpuAccess::ReadWrite,
7578        )
7579        .unwrap();
7580        {
7581            let t = src.as_u8().unwrap();
7582            let mut m = t.map().unwrap();
7583            for (i, b) in m.as_mut_slice().iter_mut().enumerate() {
7584                *b = ((i * 31) % 211) as u8;
7585            }
7586        }
7587        let mut gl_dst = TensorDyn::image(
7588            1280,
7589            720,
7590            PixelFormat::Bgra,
7591            DType::U8,
7592            Some(TensorMemory::Mem),
7593            edgefirst_tensor::CpuAccess::ReadWrite,
7594        )
7595        .unwrap();
7596        proc.opengl
7597            .as_mut()
7598            .expect("GL backend present")
7599            .convert(
7600                &src,
7601                &mut gl_dst,
7602                Rotation::None,
7603                Flip::None,
7604                Crop::no_crop(),
7605            )
7606            .expect("zero-copy src → heap dst GL convert");
7607
7608        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
7609            backend: ComputeBackend::Cpu,
7610            ..Default::default()
7611        })
7612        .unwrap();
7613        let mut cpu_dst = TensorDyn::image(
7614            1280,
7615            720,
7616            PixelFormat::Bgra,
7617            DType::U8,
7618            Some(TensorMemory::Mem),
7619            edgefirst_tensor::CpuAccess::ReadWrite,
7620        )
7621        .unwrap();
7622        cpu.convert(
7623            &src,
7624            &mut cpu_dst,
7625            Rotation::None,
7626            Flip::None,
7627            Crop::no_crop(),
7628        )
7629        .expect("CPU reference convert");
7630
7631        let g = gl_dst.as_u8().unwrap().map().unwrap().as_slice().to_vec();
7632        let c = cpu_dst.as_u8().unwrap().map().unwrap().as_slice().to_vec();
7633        assert_eq!(g.len(), c.len());
7634        let max_diff = g
7635            .iter()
7636            .zip(c.iter())
7637            .map(|(a, b)| a.abs_diff(*b))
7638            .max()
7639            .unwrap();
7640        // Same-size byte shuffle: GL samples texel centers 1:1, so allow
7641        // only rounding slack.
7642        assert!(
7643            max_diff <= 2,
7644            "zero-copy src → heap dst diverges from CPU reference: max_diff={max_diff}"
7645        );
7646    }
7647
7648    #[test]
7649    #[cfg(target_os = "linux")]
7650    fn test_yuyv_to_rgb_g2d() {
7651        if !is_g2d_available() {
7652            eprintln!("SKIPPED: test_yuyv_to_rgb_g2d - G2D library (libg2d.so.2) not available");
7653            return;
7654        }
7655        if !is_dma_available() {
7656            eprintln!(
7657                "SKIPPED: test_yuyv_to_rgb_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
7658            );
7659            return;
7660        }
7661
7662        let src = load_bytes_to_tensor(
7663            1280,
7664            720,
7665            PixelFormat::Yuyv,
7666            None,
7667            &edgefirst_bench::testdata::read("camera720p.yuyv"),
7668        )
7669        .unwrap();
7670
7671        let g2d_dst = TensorDyn::image(
7672            1280,
7673            720,
7674            PixelFormat::Rgb,
7675            DType::U8,
7676            Some(TensorMemory::Dma),
7677            edgefirst_tensor::CpuAccess::ReadWrite,
7678        )
7679        .unwrap();
7680        let mut g2d_converter = G2DProcessor::new().unwrap();
7681
7682        let (result, src, g2d_dst) = convert_img(
7683            &mut g2d_converter,
7684            src,
7685            g2d_dst,
7686            Rotation::None,
7687            Flip::None,
7688            Crop::no_crop(),
7689        );
7690        result.unwrap();
7691
7692        let cpu_dst = TensorDyn::image(
7693            1280,
7694            720,
7695            PixelFormat::Rgb,
7696            DType::U8,
7697            None,
7698            edgefirst_tensor::CpuAccess::ReadWrite,
7699        )
7700        .unwrap();
7701        let mut cpu_converter: CPUProcessor = CPUProcessor::new();
7702
7703        let (result, _src, cpu_dst) = convert_img(
7704            &mut cpu_converter,
7705            src,
7706            cpu_dst,
7707            Rotation::None,
7708            Flip::None,
7709            Crop::no_crop(),
7710        );
7711        result.unwrap();
7712
7713        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
7714        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
7715        // the YUV-matrix delta that forced 0.95 has closed; tightened to
7716        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
7717        // structural gap not exercised by these limited-range fixtures.
7718        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
7719    }
7720
7721    #[test]
7722    #[cfg(target_os = "linux")]
7723    fn test_yuyv_to_yuyv_resize_g2d() {
7724        if !is_g2d_available() {
7725            eprintln!(
7726                "SKIPPED: test_yuyv_to_yuyv_resize_g2d - G2D library (libg2d.so.2) not available"
7727            );
7728            return;
7729        }
7730        if !is_dma_available() {
7731            eprintln!(
7732                "SKIPPED: test_yuyv_to_yuyv_resize_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
7733            );
7734            return;
7735        }
7736
7737        let src = load_bytes_to_tensor(
7738            1280,
7739            720,
7740            PixelFormat::Yuyv,
7741            None,
7742            &edgefirst_bench::testdata::read("camera720p.yuyv"),
7743        )
7744        .unwrap();
7745
7746        let g2d_dst = TensorDyn::image(
7747            600,
7748            400,
7749            PixelFormat::Yuyv,
7750            DType::U8,
7751            Some(TensorMemory::Dma),
7752            edgefirst_tensor::CpuAccess::ReadWrite,
7753        )
7754        .unwrap();
7755        let mut g2d_converter = G2DProcessor::new().unwrap();
7756
7757        let (result, src, g2d_dst) = convert_img(
7758            &mut g2d_converter,
7759            src,
7760            g2d_dst,
7761            Rotation::None,
7762            Flip::None,
7763            Crop::no_crop(),
7764        );
7765        result.unwrap();
7766
7767        let cpu_dst = TensorDyn::image(
7768            600,
7769            400,
7770            PixelFormat::Yuyv,
7771            DType::U8,
7772            None,
7773            edgefirst_tensor::CpuAccess::ReadWrite,
7774        )
7775        .unwrap();
7776        let mut cpu_converter: CPUProcessor = CPUProcessor::new();
7777
7778        let (result, _src, cpu_dst) = convert_img(
7779            &mut cpu_converter,
7780            src,
7781            cpu_dst,
7782            Rotation::None,
7783            Flip::None,
7784            Crop::no_crop(),
7785        );
7786        result.unwrap();
7787
7788        // G2D has poor colorimetry support: its hardware YUYV resize/sampling
7789        // diverges from the CPU reference by enough that the similarity score
7790        // sits around 0.85 on every measured G2D core (i.MX 8M Plus, i.MX 95).
7791        // The threshold is held at 0.85 so the test guards against gross
7792        // regressions while tolerating the driver's inherent colorimetry error.
7793        // TODO: compare YUYV↔YUYV directly without a YUYV→RGB convert.
7794        eprintln!(
7795            "WARNING: G2D has poor colorimetry support — YUYV resize diverges from the \
7796             CPU reference (~0.85 similarity); threshold held at 0.85, not 0.95."
7797        );
7798        compare_images_convert_to_rgb(&g2d_dst, &cpu_dst, 0.85, function!());
7799    }
7800
7801    #[test]
7802    fn test_yuyv_to_rgba_resize_cpu() {
7803        let src = load_bytes_to_tensor(
7804            1280,
7805            720,
7806            PixelFormat::Yuyv,
7807            None,
7808            &edgefirst_bench::testdata::read("camera720p.yuyv"),
7809        )
7810        .unwrap();
7811
7812        let (dst_width, dst_height) = (960, 540);
7813
7814        let dst = TensorDyn::image(
7815            dst_width,
7816            dst_height,
7817            PixelFormat::Rgba,
7818            DType::U8,
7819            None,
7820            edgefirst_tensor::CpuAccess::ReadWrite,
7821        )
7822        .unwrap();
7823        let mut cpu_converter = CPUProcessor::new();
7824
7825        let (result, _src, dst) = convert_img(
7826            &mut cpu_converter,
7827            src,
7828            dst,
7829            Rotation::None,
7830            Flip::None,
7831            Crop::no_crop(),
7832        );
7833        result.unwrap();
7834
7835        let dst_target = TensorDyn::image(
7836            dst_width,
7837            dst_height,
7838            PixelFormat::Rgba,
7839            DType::U8,
7840            None,
7841            edgefirst_tensor::CpuAccess::ReadWrite,
7842        )
7843        .unwrap();
7844        let src_target = load_bytes_to_tensor(
7845            1280,
7846            720,
7847            PixelFormat::Rgba,
7848            None,
7849            &edgefirst_bench::testdata::read("camera720p.rgba"),
7850        )
7851        .unwrap();
7852        let (result, _src_target, dst_target) = convert_img(
7853            &mut cpu_converter,
7854            src_target,
7855            dst_target,
7856            Rotation::None,
7857            Flip::None,
7858            Crop::no_crop(),
7859        );
7860        result.unwrap();
7861
7862        // CPU path resolves the untagged 720p source to BT.709 limited (height
7863        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
7864        compare_images(&dst, &dst_target, 0.98, function!());
7865    }
7866
7867    #[test]
7868    #[cfg(target_os = "linux")]
7869    fn test_yuyv_to_rgba_crop_flip_g2d() {
7870        if !is_g2d_available() {
7871            eprintln!(
7872                "SKIPPED: test_yuyv_to_rgba_crop_flip_g2d - G2D library (libg2d.so.2) not available"
7873            );
7874            return;
7875        }
7876        if !is_dma_available() {
7877            eprintln!(
7878                "SKIPPED: test_yuyv_to_rgba_crop_flip_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
7879            );
7880            return;
7881        }
7882
7883        let src = load_bytes_to_tensor(
7884            1280,
7885            720,
7886            PixelFormat::Yuyv,
7887            Some(TensorMemory::Dma),
7888            &edgefirst_bench::testdata::read("camera720p.yuyv"),
7889        )
7890        .unwrap();
7891
7892        let (dst_width, dst_height) = (640, 640);
7893
7894        let dst_g2d = TensorDyn::image(
7895            dst_width,
7896            dst_height,
7897            PixelFormat::Rgba,
7898            DType::U8,
7899            Some(TensorMemory::Dma),
7900            edgefirst_tensor::CpuAccess::ReadWrite,
7901        )
7902        .unwrap();
7903        let mut g2d_converter = G2DProcessor::new().unwrap();
7904        let crop = Crop::new().with_source(Some(Region::new(20, 15, 400, 300)));
7905
7906        let (result, src, dst_g2d) = convert_img(
7907            &mut g2d_converter,
7908            src,
7909            dst_g2d,
7910            Rotation::None,
7911            Flip::Horizontal,
7912            crop,
7913        );
7914        result.unwrap();
7915
7916        let dst_cpu = TensorDyn::image(
7917            dst_width,
7918            dst_height,
7919            PixelFormat::Rgba,
7920            DType::U8,
7921            Some(TensorMemory::Dma),
7922            edgefirst_tensor::CpuAccess::ReadWrite,
7923        )
7924        .unwrap();
7925        let mut cpu_converter = CPUProcessor::new();
7926
7927        let (result, _src, dst_cpu) = convert_img(
7928            &mut cpu_converter,
7929            src,
7930            dst_cpu,
7931            Rotation::None,
7932            Flip::Horizontal,
7933            crop,
7934        );
7935        result.unwrap();
7936        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
7937        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
7938        // the YUV-matrix delta that forced 0.95 has closed; tightened to
7939        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
7940        // structural gap not exercised by these limited-range fixtures.
7941        compare_images(&dst_g2d, &dst_cpu, 0.98, function!());
7942    }
7943
7944    #[test]
7945    #[cfg(target_os = "linux")]
7946    #[cfg(feature = "opengl")]
7947    fn test_yuyv_to_rgba_crop_flip_opengl() {
7948        if !is_opengl_available() {
7949            eprintln!("SKIPPED: {} - OpenGL not available", function!());
7950            return;
7951        }
7952
7953        if !is_dma_available() {
7954            eprintln!(
7955                "SKIPPED: {} - DMA memory allocation not available (permission denied or no DMA-BUF support)",
7956                function!()
7957            );
7958            return;
7959        }
7960
7961        let src = load_bytes_to_tensor(
7962            1280,
7963            720,
7964            PixelFormat::Yuyv,
7965            Some(TensorMemory::Dma),
7966            &edgefirst_bench::testdata::read("camera720p.yuyv"),
7967        )
7968        .unwrap();
7969
7970        let (dst_width, dst_height) = (640, 640);
7971
7972        let dst_gl = TensorDyn::image(
7973            dst_width,
7974            dst_height,
7975            PixelFormat::Rgba,
7976            DType::U8,
7977            Some(TensorMemory::Dma),
7978            edgefirst_tensor::CpuAccess::ReadWrite,
7979        )
7980        .unwrap();
7981        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
7982        let crop = Crop::new().with_source(Some(Region::new(20, 15, 400, 300)));
7983
7984        let (result, src, dst_gl) = convert_img(
7985            &mut gl_converter,
7986            src,
7987            dst_gl,
7988            Rotation::None,
7989            Flip::Horizontal,
7990            crop,
7991        );
7992        result.unwrap();
7993
7994        let dst_cpu = TensorDyn::image(
7995            dst_width,
7996            dst_height,
7997            PixelFormat::Rgba,
7998            DType::U8,
7999            Some(TensorMemory::Dma),
8000            edgefirst_tensor::CpuAccess::ReadWrite,
8001        )
8002        .unwrap();
8003        let mut cpu_converter = CPUProcessor::new();
8004
8005        let (result, _src, dst_cpu) = convert_img(
8006            &mut cpu_converter,
8007            src,
8008            dst_cpu,
8009            Rotation::None,
8010            Flip::Horizontal,
8011            crop,
8012        );
8013        result.unwrap();
8014        // Post-WS1 the GL path applies the resolved colorimetry via the EGL
8015        // YUV color-space/sample-range hints, so the matrix delta that forced
8016        // 0.95 has closed; tightened to 0.98 (driver-matrix rounding confirmed
8017        // on the GPU lanes).
8018        compare_images(&dst_gl, &dst_cpu, 0.98, function!());
8019    }
8020
8021    #[test]
8022    fn test_vyuy_to_rgba_cpu() {
8023        let file = edgefirst_bench::testdata::read("camera720p.vyuy").to_vec();
8024        let src = TensorDyn::image(
8025            1280,
8026            720,
8027            PixelFormat::Vyuy,
8028            DType::U8,
8029            None,
8030            edgefirst_tensor::CpuAccess::ReadWrite,
8031        )
8032        .unwrap();
8033        src.as_u8()
8034            .unwrap()
8035            .map()
8036            .unwrap()
8037            .as_mut_slice()
8038            .copy_from_slice(&file);
8039
8040        let dst = TensorDyn::image(
8041            1280,
8042            720,
8043            PixelFormat::Rgba,
8044            DType::U8,
8045            None,
8046            edgefirst_tensor::CpuAccess::ReadWrite,
8047        )
8048        .unwrap();
8049        let mut cpu_converter = CPUProcessor::new();
8050
8051        let (result, _src, dst) = convert_img(
8052            &mut cpu_converter,
8053            src,
8054            dst,
8055            Rotation::None,
8056            Flip::None,
8057            Crop::no_crop(),
8058        );
8059        result.unwrap();
8060
8061        let target_image = TensorDyn::image(
8062            1280,
8063            720,
8064            PixelFormat::Rgba,
8065            DType::U8,
8066            None,
8067            edgefirst_tensor::CpuAccess::ReadWrite,
8068        )
8069        .unwrap();
8070        target_image
8071            .as_u8()
8072            .unwrap()
8073            .map()
8074            .unwrap()
8075            .as_mut_slice()
8076            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
8077
8078        // CPU path resolves the untagged 720p source to BT.709 limited (height
8079        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
8080        compare_images(&dst, &target_image, 0.98, function!());
8081    }
8082
8083    #[test]
8084    fn test_vyuy_to_rgb_cpu() {
8085        let file = edgefirst_bench::testdata::read("camera720p.vyuy").to_vec();
8086        let src = TensorDyn::image(
8087            1280,
8088            720,
8089            PixelFormat::Vyuy,
8090            DType::U8,
8091            None,
8092            edgefirst_tensor::CpuAccess::ReadWrite,
8093        )
8094        .unwrap();
8095        src.as_u8()
8096            .unwrap()
8097            .map()
8098            .unwrap()
8099            .as_mut_slice()
8100            .copy_from_slice(&file);
8101
8102        let dst = TensorDyn::image(
8103            1280,
8104            720,
8105            PixelFormat::Rgb,
8106            DType::U8,
8107            None,
8108            edgefirst_tensor::CpuAccess::ReadWrite,
8109        )
8110        .unwrap();
8111        let mut cpu_converter = CPUProcessor::new();
8112
8113        let (result, _src, dst) = convert_img(
8114            &mut cpu_converter,
8115            src,
8116            dst,
8117            Rotation::None,
8118            Flip::None,
8119            Crop::no_crop(),
8120        );
8121        result.unwrap();
8122
8123        let target_image = TensorDyn::image(
8124            1280,
8125            720,
8126            PixelFormat::Rgb,
8127            DType::U8,
8128            None,
8129            edgefirst_tensor::CpuAccess::ReadWrite,
8130        )
8131        .unwrap();
8132        target_image
8133            .as_u8()
8134            .unwrap()
8135            .map()
8136            .unwrap()
8137            .as_mut_slice()
8138            .as_chunks_mut::<3>()
8139            .0
8140            .iter_mut()
8141            .zip(
8142                edgefirst_bench::testdata::read("camera720p.rgba")
8143                    .as_chunks::<4>()
8144                    .0,
8145            )
8146            .for_each(|(dst, src)| *dst = [src[0], src[1], src[2]]);
8147
8148        // CPU path resolves the untagged 720p source to BT.709 limited (height
8149        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
8150        compare_images(&dst, &target_image, 0.98, function!());
8151    }
8152
8153    #[test]
8154    #[cfg(target_os = "linux")]
8155    #[ignore = "G2D does not support VYUY; re-enable when hardware support is added"]
8156    fn test_vyuy_to_rgba_g2d() {
8157        if !is_g2d_available() {
8158            eprintln!("SKIPPED: test_vyuy_to_rgba_g2d - G2D library (libg2d.so.2) not available");
8159            return;
8160        }
8161        if !is_dma_available() {
8162            eprintln!(
8163                "SKIPPED: test_vyuy_to_rgba_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
8164            );
8165            return;
8166        }
8167
8168        let src = load_bytes_to_tensor(
8169            1280,
8170            720,
8171            PixelFormat::Vyuy,
8172            None,
8173            &edgefirst_bench::testdata::read("camera720p.vyuy"),
8174        )
8175        .unwrap();
8176
8177        let dst = TensorDyn::image(
8178            1280,
8179            720,
8180            PixelFormat::Rgba,
8181            DType::U8,
8182            Some(TensorMemory::Dma),
8183            edgefirst_tensor::CpuAccess::ReadWrite,
8184        )
8185        .unwrap();
8186        let mut g2d_converter = G2DProcessor::new().unwrap();
8187
8188        let (result, _src, dst) = convert_img(
8189            &mut g2d_converter,
8190            src,
8191            dst,
8192            Rotation::None,
8193            Flip::None,
8194            Crop::no_crop(),
8195        );
8196        match result {
8197            Err(Error::G2D(_)) => {
8198                eprintln!("SKIPPED: test_vyuy_to_rgba_g2d - G2D does not support PixelFormat::Vyuy format");
8199                return;
8200            }
8201            r => r.unwrap(),
8202        }
8203
8204        let target_image = TensorDyn::image(
8205            1280,
8206            720,
8207            PixelFormat::Rgba,
8208            DType::U8,
8209            None,
8210            edgefirst_tensor::CpuAccess::ReadWrite,
8211        )
8212        .unwrap();
8213        target_image
8214            .as_u8()
8215            .unwrap()
8216            .map()
8217            .unwrap()
8218            .as_mut_slice()
8219            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
8220
8221        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
8222        // so the matrix delta vs the reference that forced 0.95 has closed;
8223        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
8224        compare_images(&dst, &target_image, 0.98, function!());
8225    }
8226
8227    #[test]
8228    #[cfg(target_os = "linux")]
8229    #[ignore = "G2D does not support VYUY; re-enable when hardware support is added"]
8230    fn test_vyuy_to_rgb_g2d() {
8231        if !is_g2d_available() {
8232            eprintln!("SKIPPED: test_vyuy_to_rgb_g2d - G2D library (libg2d.so.2) not available");
8233            return;
8234        }
8235        if !is_dma_available() {
8236            eprintln!(
8237                "SKIPPED: test_vyuy_to_rgb_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
8238            );
8239            return;
8240        }
8241
8242        let src = load_bytes_to_tensor(
8243            1280,
8244            720,
8245            PixelFormat::Vyuy,
8246            None,
8247            &edgefirst_bench::testdata::read("camera720p.vyuy"),
8248        )
8249        .unwrap();
8250
8251        let g2d_dst = TensorDyn::image(
8252            1280,
8253            720,
8254            PixelFormat::Rgb,
8255            DType::U8,
8256            Some(TensorMemory::Dma),
8257            edgefirst_tensor::CpuAccess::ReadWrite,
8258        )
8259        .unwrap();
8260        let mut g2d_converter = G2DProcessor::new().unwrap();
8261
8262        let (result, src, g2d_dst) = convert_img(
8263            &mut g2d_converter,
8264            src,
8265            g2d_dst,
8266            Rotation::None,
8267            Flip::None,
8268            Crop::no_crop(),
8269        );
8270        match result {
8271            Err(Error::G2D(_)) => {
8272                eprintln!(
8273                    "SKIPPED: test_vyuy_to_rgb_g2d - G2D does not support PixelFormat::Vyuy format"
8274                );
8275                return;
8276            }
8277            r => r.unwrap(),
8278        }
8279
8280        let cpu_dst = TensorDyn::image(
8281            1280,
8282            720,
8283            PixelFormat::Rgb,
8284            DType::U8,
8285            None,
8286            edgefirst_tensor::CpuAccess::ReadWrite,
8287        )
8288        .unwrap();
8289        let mut cpu_converter: CPUProcessor = CPUProcessor::new();
8290
8291        let (result, _src, cpu_dst) = convert_img(
8292            &mut cpu_converter,
8293            src,
8294            cpu_dst,
8295            Rotation::None,
8296            Flip::None,
8297            Crop::no_crop(),
8298        );
8299        result.unwrap();
8300
8301        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
8302        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
8303        // the YUV-matrix delta that forced 0.95 has closed; tightened to
8304        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
8305        // structural gap not exercised by these limited-range fixtures.
8306        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
8307    }
8308
8309    #[test]
8310    #[cfg(target_os = "linux")]
8311    #[cfg(feature = "opengl")]
8312    fn test_vyuy_to_rgba_opengl() {
8313        if !is_opengl_available() {
8314            eprintln!("SKIPPED: {} - OpenGL not available", function!());
8315            return;
8316        }
8317        if !is_dma_available() {
8318            eprintln!(
8319                "SKIPPED: {} - DMA memory allocation not available (permission denied or no DMA-BUF support)",
8320                function!()
8321            );
8322            return;
8323        }
8324
8325        let src = load_bytes_to_tensor(
8326            1280,
8327            720,
8328            PixelFormat::Vyuy,
8329            Some(TensorMemory::Dma),
8330            &edgefirst_bench::testdata::read("camera720p.vyuy"),
8331        )
8332        .unwrap();
8333
8334        let dst = TensorDyn::image(
8335            1280,
8336            720,
8337            PixelFormat::Rgba,
8338            DType::U8,
8339            Some(TensorMemory::Dma),
8340            edgefirst_tensor::CpuAccess::ReadWrite,
8341        )
8342        .unwrap();
8343        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
8344
8345        let (result, _src, dst) = convert_img(
8346            &mut gl_converter,
8347            src,
8348            dst,
8349            Rotation::None,
8350            Flip::None,
8351            Crop::no_crop(),
8352        );
8353        match result {
8354            Err(Error::NotSupported(_)) => {
8355                eprintln!(
8356                    "SKIPPED: {} - OpenGL does not support PixelFormat::Vyuy DMA format",
8357                    function!()
8358                );
8359                return;
8360            }
8361            r => r.unwrap(),
8362        }
8363
8364        let target_image = TensorDyn::image(
8365            1280,
8366            720,
8367            PixelFormat::Rgba,
8368            DType::U8,
8369            None,
8370            edgefirst_tensor::CpuAccess::ReadWrite,
8371        )
8372        .unwrap();
8373        target_image
8374            .as_u8()
8375            .unwrap()
8376            .map()
8377            .unwrap()
8378            .as_mut_slice()
8379            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
8380
8381        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
8382        // so the matrix delta vs the reference that forced 0.95 has closed;
8383        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
8384        compare_images(&dst, &target_image, 0.98, function!());
8385    }
8386
8387    #[test]
8388    fn test_nv12_to_rgba_cpu() {
8389        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
8390        let src = TensorDyn::image(
8391            1280,
8392            720,
8393            PixelFormat::Nv12,
8394            DType::U8,
8395            None,
8396            edgefirst_tensor::CpuAccess::ReadWrite,
8397        )
8398        .unwrap();
8399        src.as_u8().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
8400            .copy_from_slice(&file);
8401
8402        let dst = TensorDyn::image(
8403            1280,
8404            720,
8405            PixelFormat::Rgba,
8406            DType::U8,
8407            None,
8408            edgefirst_tensor::CpuAccess::ReadWrite,
8409        )
8410        .unwrap();
8411        let mut cpu_converter = CPUProcessor::new();
8412
8413        let (result, _src, dst) = convert_img(
8414            &mut cpu_converter,
8415            src,
8416            dst,
8417            Rotation::None,
8418            Flip::None,
8419            Crop::no_crop(),
8420        );
8421        result.unwrap();
8422
8423        let target_image = crate::load_image_test_helper(
8424            &edgefirst_bench::testdata::read("zidane.jpg"),
8425            Some(PixelFormat::Rgba),
8426            None,
8427        )
8428        .unwrap();
8429
8430        // Threshold 0.95 (was 0.98): the reference now decodes the colour JPEG
8431        // to native NV12 and then converts to RGBA (was a direct JPEG → RGBA
8432        // decode), so it differs slightly from the RGBA derived from the
8433        // separate `zidane.nv12` fixture.
8434        compare_images(&dst, &target_image, 0.95, function!());
8435    }
8436
8437    #[test]
8438    fn test_nv12_odd_height_to_rgb_cpu() {
8439        // Odd height (even width) — the logical-odd case, e.g. 640×483. The
8440        // contiguous NV12 buffer is `[5 + ceil(5/2), 8]` = `[8, 8]` (5 luma rows
8441        // + 3 chroma rows). A neutral-grey fill (Y=U=V=128, BT.601 full-range)
8442        // must convert to a uniform grey RGB, exercising the odd-height
8443        // chroma-row count and the logical-height derivation in convert.
8444        // (Odd *width* is rounded to an even buffer at allocation, so it is
8445        // covered by the decode integration tests rather than here.)
8446        // CPU-only test: pin to tight host memory (None auto-selects pitch-padded
8447        // DMA on i.MX, which would leave the dst's row padding unconverted and
8448        // break the flat byte scan below).
8449        let mut src = TensorDyn::image(
8450            8,
8451            5,
8452            PixelFormat::Nv12,
8453            DType::U8,
8454            Some(TensorMemory::Mem),
8455            edgefirst_tensor::CpuAccess::ReadWrite,
8456        )
8457        .unwrap();
8458        assert_eq!(src.shape(), &[8, 8]);
8459        assert_eq!((src.width(), src.height()), (Some(8), Some(5)));
8460        src.as_u8().unwrap().map().unwrap().as_mut_slice().fill(128);
8461        // Tag BT.601 full-range so Y=128 decodes to grey 128 (the neutral-grey
8462        // identity this test asserts). Without a tag, the colorimetry heuristic
8463        // resolves an SD tensor to BT.601 *limited*, expanding Y=128 → ~131.
8464        src.set_colorimetry(Some(
8465            edgefirst_tensor::Colorimetry::default()
8466                .with_encoding(edgefirst_tensor::ColorEncoding::Bt601)
8467                .with_range(edgefirst_tensor::ColorRange::Full),
8468        ));
8469
8470        let dst = TensorDyn::image(
8471            8,
8472            5,
8473            PixelFormat::Rgb,
8474            DType::U8,
8475            Some(TensorMemory::Mem),
8476            edgefirst_tensor::CpuAccess::ReadWrite,
8477        )
8478        .unwrap();
8479        let mut cpu_converter = CPUProcessor::new();
8480        let (result, _src, dst) = convert_img(
8481            &mut cpu_converter,
8482            src,
8483            dst,
8484            Rotation::None,
8485            Flip::None,
8486            Crop::no_crop(),
8487        );
8488        result.unwrap();
8489
8490        assert_eq!((dst.width(), dst.height()), (Some(8), Some(5)));
8491        let map = dst.as_u8().unwrap().map().unwrap();
8492        for (i, &b) in map.as_slice().iter().enumerate() {
8493            assert!(
8494                (b as i16 - 128).abs() <= 2,
8495                "pixel byte {i} = {b}, expected ~128 for neutral-grey NV12"
8496            );
8497        }
8498    }
8499
8500    #[test]
8501    fn test_nv24_to_rgb_cpu() {
8502        // NV24 (4:4:4) at 8×4: contiguous buffer is [4*3, 8] = [12, 8] — Y plane
8503        // (4 rows) + full-res interleaved UV plane (8 rows = 2H, 2W bytes per
8504        // chroma row). Neutral-grey fill (Y=U=V=128) must convert to uniform
8505        // grey RGB, exercising the 2× UV stride and shape[0]/3 height recovery.
8506        // CPU-only test: pin to tight host memory (see test_nv12_odd_height_to_rgb_cpu).
8507        let mut src = TensorDyn::image(
8508            8,
8509            4,
8510            PixelFormat::Nv24,
8511            DType::U8,
8512            Some(TensorMemory::Mem),
8513            edgefirst_tensor::CpuAccess::ReadWrite,
8514        )
8515        .unwrap();
8516        assert_eq!(src.shape(), &[12, 8]);
8517        assert_eq!((src.width(), src.height()), (Some(8), Some(4)));
8518        src.as_u8().unwrap().map().unwrap().as_mut_slice().fill(128);
8519        // Tag BT.601 full-range (see test_nv12_odd_height_to_rgb_cpu): without it
8520        // the heuristic picks limited range and Y=128 expands to ~131.
8521        src.set_colorimetry(Some(
8522            edgefirst_tensor::Colorimetry::default()
8523                .with_encoding(edgefirst_tensor::ColorEncoding::Bt601)
8524                .with_range(edgefirst_tensor::ColorRange::Full),
8525        ));
8526
8527        let dst = TensorDyn::image(
8528            8,
8529            4,
8530            PixelFormat::Rgb,
8531            DType::U8,
8532            Some(TensorMemory::Mem),
8533            edgefirst_tensor::CpuAccess::ReadWrite,
8534        )
8535        .unwrap();
8536        let mut cpu_converter = CPUProcessor::new();
8537        let (result, _src, dst) = convert_img(
8538            &mut cpu_converter,
8539            src,
8540            dst,
8541            Rotation::None,
8542            Flip::None,
8543            Crop::no_crop(),
8544        );
8545        result.unwrap();
8546
8547        assert_eq!((dst.width(), dst.height()), (Some(8), Some(4)));
8548        let map = dst.as_u8().unwrap().map().unwrap();
8549        for (i, &b) in map.as_slice().iter().enumerate() {
8550            assert!(
8551                (b as i16 - 128).abs() <= 2,
8552                "pixel byte {i} = {b}, expected ~128 for neutral-grey NV24"
8553            );
8554        }
8555    }
8556
8557    #[test]
8558    fn cpu_nv12_to_rgb_respects_tagged_bt2020() {
8559        // A uniform but *saturated* chroma sample (U/V far from neutral) so the
8560        // YUV→RGB matrix — not just the range — drives the result. Decoding the
8561        // same NV12 bytes under BT.601 / BT.709 / BT.2020 must yield three
8562        // distinct RGB triples, proving the CPU path honours the source's tagged
8563        // ColorEncoding instead of a hardcoded matrix. (G2D declines BT.2020 and
8564        // falls through to this CPU path; QA F9.)
8565        // CPU-only test: pin to tight host memory (see test_nv12_odd_height_to_rgb_cpu).
8566        fn decode_tagged(enc: edgefirst_tensor::ColorEncoding) -> [u8; 3] {
8567            let mut src = TensorDyn::image(
8568                8,
8569                4,
8570                PixelFormat::Nv12,
8571                DType::U8,
8572                Some(TensorMemory::Mem),
8573                edgefirst_tensor::CpuAccess::ReadWrite,
8574            )
8575            .unwrap();
8576            // NV12 8×4: 32-byte Y plane + 16-byte interleaved UV plane (4:2:0).
8577            assert_eq!(src.shape(), &[6, 8]);
8578            {
8579                let mut map = src.as_u8().unwrap().map().unwrap();
8580                let buf = map.as_mut_slice();
8581                buf[..32].fill(120); // Y
8582                for px in buf[32..].chunks_exact_mut(2) {
8583                    px[0] = 180; // U / Cb
8584                    px[1] = 64; // V / Cr
8585                }
8586            }
8587            // Range held constant (Limited) across all three so only the encoding
8588            // matrix varies between runs.
8589            src.set_colorimetry(Some(
8590                edgefirst_tensor::Colorimetry::default()
8591                    .with_encoding(enc)
8592                    .with_range(edgefirst_tensor::ColorRange::Limited),
8593            ));
8594            let dst = TensorDyn::image(
8595                8,
8596                4,
8597                PixelFormat::Rgb,
8598                DType::U8,
8599                Some(TensorMemory::Mem),
8600                edgefirst_tensor::CpuAccess::ReadWrite,
8601            )
8602            .unwrap();
8603            let mut cpu = CPUProcessor::new();
8604            let (result, _src, dst) = convert_img(
8605                &mut cpu,
8606                src,
8607                dst,
8608                Rotation::None,
8609                Flip::None,
8610                Crop::no_crop(),
8611            );
8612            result.unwrap();
8613            let map = dst.as_u8().unwrap().map().unwrap();
8614            let s = map.as_slice();
8615            [s[0], s[1], s[2]]
8616        }
8617
8618        let bt601 = decode_tagged(edgefirst_tensor::ColorEncoding::Bt601);
8619        let bt709 = decode_tagged(edgefirst_tensor::ColorEncoding::Bt709);
8620        let bt2020 = decode_tagged(edgefirst_tensor::ColorEncoding::Bt2020);
8621
8622        assert_ne!(
8623            bt2020, bt601,
8624            "BT.2020 must decode differently from BT.601 ({bt2020:?} vs {bt601:?})"
8625        );
8626        assert_ne!(
8627            bt2020, bt709,
8628            "BT.2020 must decode differently from BT.709 ({bt2020:?} vs {bt709:?})"
8629        );
8630        assert_ne!(
8631            bt601, bt709,
8632            "BT.601 must decode differently from BT.709 ({bt601:?} vs {bt709:?})"
8633        );
8634    }
8635
8636    #[test]
8637    fn test_nv12_to_rgb_cpu() {
8638        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
8639        let src = TensorDyn::image(
8640            1280,
8641            720,
8642            PixelFormat::Nv12,
8643            DType::U8,
8644            None,
8645            edgefirst_tensor::CpuAccess::ReadWrite,
8646        )
8647        .unwrap();
8648        src.as_u8().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
8649            .copy_from_slice(&file);
8650
8651        let dst = TensorDyn::image(
8652            1280,
8653            720,
8654            PixelFormat::Rgb,
8655            DType::U8,
8656            None,
8657            edgefirst_tensor::CpuAccess::ReadWrite,
8658        )
8659        .unwrap();
8660        let mut cpu_converter = CPUProcessor::new();
8661
8662        let (result, _src, dst) = convert_img(
8663            &mut cpu_converter,
8664            src,
8665            dst,
8666            Rotation::None,
8667            Flip::None,
8668            Crop::no_crop(),
8669        );
8670        result.unwrap();
8671
8672        let target_image = crate::load_image_test_helper(
8673            &edgefirst_bench::testdata::read("zidane.jpg"),
8674            Some(PixelFormat::Rgb),
8675            None,
8676        )
8677        .unwrap();
8678
8679        // Threshold 0.95 (was 0.98): the reference now decodes the colour JPEG
8680        // to native NV12 and then converts to RGB (was a direct JPEG → RGB
8681        // decode), so it differs slightly from the RGB derived from the
8682        // separate `zidane.nv12` fixture.
8683        compare_images(&dst, &target_image, 0.95, function!());
8684    }
8685
8686    #[test]
8687    fn test_nv12_to_grey_cpu() {
8688        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
8689        let src = TensorDyn::image(
8690            1280,
8691            720,
8692            PixelFormat::Nv12,
8693            DType::U8,
8694            None,
8695            edgefirst_tensor::CpuAccess::ReadWrite,
8696        )
8697        .unwrap();
8698        src.as_u8().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
8699            .copy_from_slice(&file);
8700
8701        let dst = TensorDyn::image(
8702            1280,
8703            720,
8704            PixelFormat::Grey,
8705            DType::U8,
8706            None,
8707            edgefirst_tensor::CpuAccess::ReadWrite,
8708        )
8709        .unwrap();
8710        let mut cpu_converter = CPUProcessor::new();
8711
8712        let (result, _src, dst) = convert_img(
8713            &mut cpu_converter,
8714            src,
8715            dst,
8716            Rotation::None,
8717            Flip::None,
8718            Crop::no_crop(),
8719        );
8720        result.unwrap();
8721
8722        let target_image = crate::load_image_test_helper(
8723            &edgefirst_bench::testdata::read("zidane.jpg"),
8724            Some(PixelFormat::Grey),
8725            None,
8726        )
8727        .unwrap();
8728
8729        // Threshold 0.95 (was 0.98): the reference grey frame now comes from
8730        // the colour JPEG decoded to native NV12 and then converted to GREY
8731        // (was a direct JPEG → GREY decode), so it differs slightly from the
8732        // grey derived from the `zidane.nv12` fixture.
8733        compare_images(&dst, &target_image, 0.95, function!());
8734    }
8735
8736    #[test]
8737    fn test_nv12_to_yuyv_cpu() {
8738        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
8739        let src = TensorDyn::image(
8740            1280,
8741            720,
8742            PixelFormat::Nv12,
8743            DType::U8,
8744            None,
8745            edgefirst_tensor::CpuAccess::ReadWrite,
8746        )
8747        .unwrap();
8748        src.as_u8().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
8749            .copy_from_slice(&file);
8750
8751        let dst = TensorDyn::image(
8752            1280,
8753            720,
8754            PixelFormat::Yuyv,
8755            DType::U8,
8756            None,
8757            edgefirst_tensor::CpuAccess::ReadWrite,
8758        )
8759        .unwrap();
8760        let mut cpu_converter = CPUProcessor::new();
8761
8762        let (result, _src, dst) = convert_img(
8763            &mut cpu_converter,
8764            src,
8765            dst,
8766            Rotation::None,
8767            Flip::None,
8768            Crop::no_crop(),
8769        );
8770        result.unwrap();
8771
8772        let target_image = crate::load_image_test_helper(
8773            &edgefirst_bench::testdata::read("zidane.jpg"),
8774            Some(PixelFormat::Rgb),
8775            None,
8776        )
8777        .unwrap();
8778
8779        // Threshold 0.95 (was 0.98): the reference now decodes the colour JPEG
8780        // to native NV12 and then converts to RGB (was a direct JPEG → RGB
8781        // decode), so it differs slightly from the YUYV-sourced frame derived
8782        // from the separate `zidane.nv12` fixture.
8783        compare_images_convert_to_rgb(&dst, &target_image, 0.95, function!());
8784    }
8785
8786    #[test]
8787    fn test_cpu_resize_nv16() {
8788        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
8789        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
8790
8791        let cpu_nv16_dst = TensorDyn::image(
8792            640,
8793            640,
8794            PixelFormat::Nv16,
8795            DType::U8,
8796            None,
8797            edgefirst_tensor::CpuAccess::ReadWrite,
8798        )
8799        .unwrap();
8800        let cpu_rgb_dst = TensorDyn::image(
8801            640,
8802            640,
8803            PixelFormat::Rgb,
8804            DType::U8,
8805            None,
8806            edgefirst_tensor::CpuAccess::ReadWrite,
8807        )
8808        .unwrap();
8809        let mut cpu_converter = CPUProcessor::new();
8810        let crop = Crop::letterbox([255, 128, 0, 255]);
8811
8812        let (result, src, cpu_nv16_dst) = convert_img(
8813            &mut cpu_converter,
8814            src,
8815            cpu_nv16_dst,
8816            Rotation::None,
8817            Flip::None,
8818            crop,
8819        );
8820        result.unwrap();
8821
8822        let (result, _src, cpu_rgb_dst) = convert_img(
8823            &mut cpu_converter,
8824            src,
8825            cpu_rgb_dst,
8826            Rotation::None,
8827            Flip::None,
8828            crop,
8829        );
8830        result.unwrap();
8831        compare_images_convert_to_rgb(&cpu_nv16_dst, &cpu_rgb_dst, 0.99, function!());
8832    }
8833
8834    fn load_bytes_to_tensor(
8835        width: usize,
8836        height: usize,
8837        format: PixelFormat,
8838        memory: Option<TensorMemory>,
8839        bytes: &[u8],
8840    ) -> Result<TensorDyn, Error> {
8841        let src = TensorDyn::image(
8842            width,
8843            height,
8844            format,
8845            DType::U8,
8846            memory,
8847            edgefirst_tensor::CpuAccess::ReadWrite,
8848        )?;
8849        src.as_u8()
8850            .unwrap()
8851            .map()?
8852            .as_mut_slice()
8853            .copy_from_slice(bytes);
8854        Ok(src)
8855    }
8856
8857    // DEDUP: this function is also defined verbatim in
8858    // `crates/image/src/gl/tests.rs` (inside `mod gl_tests`). Both copies
8859    // must be kept in sync. Cross-module sharing would require either a
8860    // `pub(crate)` test-helper module (which pollutes the non-test API) or a
8861    // separate test-utils crate — both are disproportionate for a single
8862    // helper. If the implementation ever diverges, extract to a shared
8863    // `test_helpers` module in this crate.
8864    fn compare_images(img1: &TensorDyn, img2: &TensorDyn, threshold: f64, name: &str) {
8865        assert_eq!(img1.height(), img2.height(), "Heights differ");
8866        assert_eq!(img1.width(), img2.width(), "Widths differ");
8867        assert_eq!(
8868            img1.format().unwrap(),
8869            img2.format().unwrap(),
8870            "PixelFormat differ"
8871        );
8872        assert!(
8873            matches!(
8874                img1.format().unwrap(),
8875                PixelFormat::Rgb | PixelFormat::Rgba | PixelFormat::Grey | PixelFormat::PlanarRgb
8876            ),
8877            "format must be Rgb or Rgba for comparison"
8878        );
8879
8880        let image1 = match img1.format().unwrap() {
8881            PixelFormat::Rgb => image::RgbImage::from_vec(
8882                img1.width().unwrap() as u32,
8883                img1.height().unwrap() as u32,
8884                img1.as_u8().unwrap().map().unwrap().to_vec(),
8885            )
8886            .unwrap(),
8887            PixelFormat::Rgba => image::RgbaImage::from_vec(
8888                img1.width().unwrap() as u32,
8889                img1.height().unwrap() as u32,
8890                img1.as_u8().unwrap().map().unwrap().to_vec(),
8891            )
8892            .unwrap()
8893            .convert(),
8894            PixelFormat::Grey => image::GrayImage::from_vec(
8895                img1.width().unwrap() as u32,
8896                img1.height().unwrap() as u32,
8897                img1.as_u8().unwrap().map().unwrap().to_vec(),
8898            )
8899            .unwrap()
8900            .convert(),
8901            PixelFormat::PlanarRgb => image::GrayImage::from_vec(
8902                img1.width().unwrap() as u32,
8903                (img1.height().unwrap() * 3) as u32,
8904                img1.as_u8().unwrap().map().unwrap().to_vec(),
8905            )
8906            .unwrap()
8907            .convert(),
8908            _ => return,
8909        };
8910
8911        let image2 = match img2.format().unwrap() {
8912            PixelFormat::Rgb => image::RgbImage::from_vec(
8913                img2.width().unwrap() as u32,
8914                img2.height().unwrap() as u32,
8915                img2.as_u8().unwrap().map().unwrap().to_vec(),
8916            )
8917            .unwrap(),
8918            PixelFormat::Rgba => image::RgbaImage::from_vec(
8919                img2.width().unwrap() as u32,
8920                img2.height().unwrap() as u32,
8921                img2.as_u8().unwrap().map().unwrap().to_vec(),
8922            )
8923            .unwrap()
8924            .convert(),
8925            PixelFormat::Grey => image::GrayImage::from_vec(
8926                img2.width().unwrap() as u32,
8927                img2.height().unwrap() as u32,
8928                img2.as_u8().unwrap().map().unwrap().to_vec(),
8929            )
8930            .unwrap()
8931            .convert(),
8932            PixelFormat::PlanarRgb => image::GrayImage::from_vec(
8933                img2.width().unwrap() as u32,
8934                (img2.height().unwrap() * 3) as u32,
8935                img2.as_u8().unwrap().map().unwrap().to_vec(),
8936            )
8937            .unwrap()
8938            .convert(),
8939            _ => return,
8940        };
8941
8942        let similarity = image_compare::rgb_similarity_structure(
8943            &image_compare::Algorithm::RootMeanSquared,
8944            &image1,
8945            &image2,
8946        )
8947        .expect("Image Comparison failed");
8948        if similarity.score < threshold {
8949            // image1.save(format!("{name}_1.png"));
8950            // image2.save(format!("{name}_2.png"));
8951            similarity
8952                .image
8953                .to_color_map()
8954                .save(format!("{name}.png"))
8955                .unwrap();
8956            panic!(
8957                "{name}: converted image and target image have similarity score too low: {} < {}",
8958                similarity.score, threshold
8959            )
8960        }
8961    }
8962
8963    fn compare_images_convert_to_rgb(
8964        img1: &TensorDyn,
8965        img2: &TensorDyn,
8966        threshold: f64,
8967        name: &str,
8968    ) {
8969        assert_eq!(img1.height(), img2.height(), "Heights differ");
8970        assert_eq!(img1.width(), img2.width(), "Widths differ");
8971
8972        let mut img_rgb1 = TensorDyn::image(
8973            img1.width().unwrap(),
8974            img1.height().unwrap(),
8975            PixelFormat::Rgb,
8976            DType::U8,
8977            Some(TensorMemory::Mem),
8978            edgefirst_tensor::CpuAccess::ReadWrite,
8979        )
8980        .unwrap();
8981        let mut img_rgb2 = TensorDyn::image(
8982            img1.width().unwrap(),
8983            img1.height().unwrap(),
8984            PixelFormat::Rgb,
8985            DType::U8,
8986            Some(TensorMemory::Mem),
8987            edgefirst_tensor::CpuAccess::ReadWrite,
8988        )
8989        .unwrap();
8990        let mut __cv = CPUProcessor::default();
8991        let r1 = __cv.convert(
8992            img1,
8993            &mut img_rgb1,
8994            crate::Rotation::None,
8995            crate::Flip::None,
8996            crate::Crop::default(),
8997        );
8998        let r2 = __cv.convert(
8999            img2,
9000            &mut img_rgb2,
9001            crate::Rotation::None,
9002            crate::Flip::None,
9003            crate::Crop::default(),
9004        );
9005        if r1.is_err() || r2.is_err() {
9006            // Fallback: compare raw bytes as greyscale strip
9007            let w = img1.width().unwrap() as u32;
9008            let data1 = img1.as_u8().unwrap().map().unwrap().to_vec();
9009            let data2 = img2.as_u8().unwrap().map().unwrap().to_vec();
9010            let h1 = (data1.len() as u32) / w;
9011            let h2 = (data2.len() as u32) / w;
9012            let g1 = image::GrayImage::from_vec(w, h1, data1).unwrap();
9013            let g2 = image::GrayImage::from_vec(w, h2, data2).unwrap();
9014            let similarity = image_compare::gray_similarity_structure(
9015                &image_compare::Algorithm::RootMeanSquared,
9016                &g1,
9017                &g2,
9018            )
9019            .expect("Image Comparison failed");
9020            if similarity.score < threshold {
9021                panic!(
9022                    "{name}: converted image and target image have similarity score too low: {} < {}",
9023                    similarity.score, threshold
9024                )
9025            }
9026            return;
9027        }
9028
9029        let image1 = image::RgbImage::from_vec(
9030            img_rgb1.width().unwrap() as u32,
9031            img_rgb1.height().unwrap() as u32,
9032            img_rgb1.as_u8().unwrap().map().unwrap().to_vec(),
9033        )
9034        .unwrap();
9035
9036        let image2 = image::RgbImage::from_vec(
9037            img_rgb2.width().unwrap() as u32,
9038            img_rgb2.height().unwrap() as u32,
9039            img_rgb2.as_u8().unwrap().map().unwrap().to_vec(),
9040        )
9041        .unwrap();
9042
9043        let similarity = image_compare::rgb_similarity_structure(
9044            &image_compare::Algorithm::RootMeanSquared,
9045            &image1,
9046            &image2,
9047        )
9048        .expect("Image Comparison failed");
9049        if similarity.score < threshold {
9050            // image1.save(format!("{name}_1.png"));
9051            // image2.save(format!("{name}_2.png"));
9052            similarity
9053                .image
9054                .to_color_map()
9055                .save(format!("{name}.png"))
9056                .unwrap();
9057            panic!(
9058                "{name}: converted image and target image have similarity score too low: {} < {}",
9059                similarity.score, threshold
9060            )
9061        }
9062    }
9063
9064    // =========================================================================
9065    // PixelFormat::Nv12 Format Tests
9066    // =========================================================================
9067
9068    #[test]
9069    fn test_nv12_image_creation() {
9070        let width = 640;
9071        let height = 480;
9072        let img = TensorDyn::image(
9073            width,
9074            height,
9075            PixelFormat::Nv12,
9076            DType::U8,
9077            None,
9078            edgefirst_tensor::CpuAccess::ReadWrite,
9079        )
9080        .unwrap();
9081
9082        assert_eq!(img.width(), Some(width));
9083        assert_eq!(img.height(), Some(height));
9084        assert_eq!(img.format().unwrap(), PixelFormat::Nv12);
9085        // PixelFormat::Nv12 uses shape [H*3/2, W] to store Y plane + UV plane
9086        assert_eq!(img.as_u8().unwrap().shape(), &[height * 3 / 2, width]);
9087    }
9088
9089    #[test]
9090    fn test_nv12_channels() {
9091        let img = TensorDyn::image(
9092            640,
9093            480,
9094            PixelFormat::Nv12,
9095            DType::U8,
9096            None,
9097            edgefirst_tensor::CpuAccess::ReadWrite,
9098        )
9099        .unwrap();
9100        // PixelFormat::Nv12.channels() returns 1 (luma plane)
9101        assert_eq!(img.format().unwrap().channels(), 1);
9102    }
9103
9104    // =========================================================================
9105    // Tensor Format Metadata Tests
9106    // =========================================================================
9107
9108    #[test]
9109    fn test_tensor_set_format_planar() {
9110        let mut tensor = Tensor::<u8>::new(&[3, 480, 640], None, None).unwrap();
9111        tensor.set_format(PixelFormat::PlanarRgb).unwrap();
9112        assert_eq!(tensor.format(), Some(PixelFormat::PlanarRgb));
9113        assert_eq!(tensor.width(), Some(640));
9114        assert_eq!(tensor.height(), Some(480));
9115    }
9116
9117    #[test]
9118    fn test_tensor_set_format_interleaved() {
9119        let mut tensor = Tensor::<u8>::new(&[480, 640, 4], None, None).unwrap();
9120        tensor.set_format(PixelFormat::Rgba).unwrap();
9121        assert_eq!(tensor.format(), Some(PixelFormat::Rgba));
9122        assert_eq!(tensor.width(), Some(640));
9123        assert_eq!(tensor.height(), Some(480));
9124    }
9125
9126    #[test]
9127    fn test_tensordyn_image_rgb() {
9128        let img = TensorDyn::image(
9129            640,
9130            480,
9131            PixelFormat::Rgb,
9132            DType::U8,
9133            None,
9134            edgefirst_tensor::CpuAccess::ReadWrite,
9135        )
9136        .unwrap();
9137        assert_eq!(img.width(), Some(640));
9138        assert_eq!(img.height(), Some(480));
9139        assert_eq!(img.format(), Some(PixelFormat::Rgb));
9140    }
9141
9142    #[test]
9143    fn test_tensordyn_image_planar_rgb() {
9144        let img = TensorDyn::image(
9145            640,
9146            480,
9147            PixelFormat::PlanarRgb,
9148            DType::U8,
9149            None,
9150            edgefirst_tensor::CpuAccess::ReadWrite,
9151        )
9152        .unwrap();
9153        assert_eq!(img.width(), Some(640));
9154        assert_eq!(img.height(), Some(480));
9155        assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
9156    }
9157
9158    #[test]
9159    fn test_rgb_int8_format() {
9160        // Int8 variant: same PixelFormat::Rgb but with DType::I8
9161        let img = TensorDyn::image(
9162            1280,
9163            720,
9164            PixelFormat::Rgb,
9165            DType::I8,
9166            Some(TensorMemory::Mem),
9167            edgefirst_tensor::CpuAccess::ReadWrite,
9168        )
9169        .unwrap();
9170        assert_eq!(img.width(), Some(1280));
9171        assert_eq!(img.height(), Some(720));
9172        assert_eq!(img.format(), Some(PixelFormat::Rgb));
9173        assert_eq!(img.dtype(), DType::I8);
9174    }
9175
9176    #[test]
9177    fn test_planar_rgb_int8_format() {
9178        let img = TensorDyn::image(
9179            1280,
9180            720,
9181            PixelFormat::PlanarRgb,
9182            DType::I8,
9183            Some(TensorMemory::Mem),
9184            edgefirst_tensor::CpuAccess::ReadWrite,
9185        )
9186        .unwrap();
9187        assert_eq!(img.width(), Some(1280));
9188        assert_eq!(img.height(), Some(720));
9189        assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
9190        assert_eq!(img.dtype(), DType::I8);
9191    }
9192
9193    #[test]
9194    fn test_rgb_from_tensor() {
9195        let mut tensor = Tensor::<u8>::new(&[720, 1280, 3], None, None).unwrap();
9196        tensor.set_format(PixelFormat::Rgb).unwrap();
9197        let img = TensorDyn::from(tensor);
9198        assert_eq!(img.width(), Some(1280));
9199        assert_eq!(img.height(), Some(720));
9200        assert_eq!(img.format(), Some(PixelFormat::Rgb));
9201    }
9202
9203    #[test]
9204    fn test_planar_rgb_from_tensor() {
9205        let mut tensor = Tensor::<u8>::new(&[3, 720, 1280], None, None).unwrap();
9206        tensor.set_format(PixelFormat::PlanarRgb).unwrap();
9207        let img = TensorDyn::from(tensor);
9208        assert_eq!(img.width(), Some(1280));
9209        assert_eq!(img.height(), Some(720));
9210        assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
9211    }
9212
9213    #[test]
9214    fn test_dtype_determines_int8() {
9215        // DType::I8 indicates int8 data
9216        let u8_img = TensorDyn::image(
9217            64,
9218            64,
9219            PixelFormat::Rgb,
9220            DType::U8,
9221            None,
9222            edgefirst_tensor::CpuAccess::ReadWrite,
9223        )
9224        .unwrap();
9225        let i8_img = TensorDyn::image(
9226            64,
9227            64,
9228            PixelFormat::Rgb,
9229            DType::I8,
9230            None,
9231            edgefirst_tensor::CpuAccess::ReadWrite,
9232        )
9233        .unwrap();
9234        assert_eq!(u8_img.dtype(), DType::U8);
9235        assert_eq!(i8_img.dtype(), DType::I8);
9236    }
9237
9238    #[test]
9239    fn test_pixel_layout_packed_vs_planar() {
9240        // Packed vs planar layout classification
9241        assert_eq!(PixelFormat::Rgb.layout(), PixelLayout::Packed);
9242        assert_eq!(PixelFormat::Rgba.layout(), PixelLayout::Packed);
9243        assert_eq!(PixelFormat::PlanarRgb.layout(), PixelLayout::Planar);
9244        assert_eq!(PixelFormat::Nv12.layout(), PixelLayout::SemiPlanar);
9245    }
9246
9247    /// Integration test that exercises the PBO-to-PBO convert path.
9248    /// Uses ImageProcessor::create_image() to allocate PBO-backed tensors,
9249    /// then converts between them. Skipped when GL is unavailable or the
9250    /// backend is not PBO (e.g. DMA-buf systems).
9251    #[cfg(target_os = "linux")]
9252    #[cfg(feature = "opengl")]
9253    #[test]
9254    fn test_convert_pbo_to_pbo() {
9255        let mut converter = ImageProcessor::new().unwrap();
9256
9257        // Skip if GL is not available or backend is not PBO
9258        let is_pbo = converter
9259            .opengl
9260            .as_ref()
9261            .is_some_and(|gl| gl.transfer_backend() == opengl_headless::TransferBackend::Pbo);
9262        if !is_pbo {
9263            eprintln!("Skipping test_convert_pbo_to_pbo: backend is not PBO");
9264            return;
9265        }
9266
9267        let src_w = 640;
9268        let src_h = 480;
9269        let dst_w = 320;
9270        let dst_h = 240;
9271
9272        // Create PBO-backed source image
9273        let pbo_src = converter
9274            .create_image(
9275                src_w,
9276                src_h,
9277                PixelFormat::Rgba,
9278                DType::U8,
9279                None,
9280                edgefirst_tensor::CpuAccess::ReadWrite,
9281            )
9282            .unwrap();
9283        assert_eq!(
9284            pbo_src.as_u8().unwrap().memory(),
9285            TensorMemory::Pbo,
9286            "create_image should produce a PBO tensor"
9287        );
9288
9289        // Fill source PBO with test pattern: load JPEG then convert Mem→PBO
9290        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
9291        let jpeg_src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
9292
9293        // Resize JPEG into a Mem temp of the right size, then copy into PBO
9294        let mem_src = TensorDyn::image(
9295            src_w,
9296            src_h,
9297            PixelFormat::Rgba,
9298            DType::U8,
9299            Some(TensorMemory::Mem),
9300            edgefirst_tensor::CpuAccess::ReadWrite,
9301        )
9302        .unwrap();
9303        let (result, _jpeg_src, mem_src) = convert_img(
9304            &mut CPUProcessor::new(),
9305            jpeg_src,
9306            mem_src,
9307            Rotation::None,
9308            Flip::None,
9309            Crop::no_crop(),
9310        );
9311        result.unwrap();
9312
9313        // Copy pixel data into the PBO source by mapping it
9314        {
9315            let src_data = mem_src.as_u8().unwrap().map().unwrap();
9316            let mut pbo_map = pbo_src.as_u8().unwrap().map().unwrap();
9317            pbo_map.copy_from_slice(&src_data);
9318        }
9319
9320        // Create PBO-backed destination image
9321        let pbo_dst = converter
9322            .create_image(
9323                dst_w,
9324                dst_h,
9325                PixelFormat::Rgba,
9326                DType::U8,
9327                None,
9328                edgefirst_tensor::CpuAccess::ReadWrite,
9329            )
9330            .unwrap();
9331        assert_eq!(pbo_dst.as_u8().unwrap().memory(), TensorMemory::Pbo);
9332
9333        // Convert PBO→PBO (this exercises convert_pbo_to_pbo)
9334        let mut pbo_dst = pbo_dst;
9335        let result = converter.convert(
9336            &pbo_src,
9337            &mut pbo_dst,
9338            Rotation::None,
9339            Flip::None,
9340            Crop::no_crop(),
9341        );
9342        result.unwrap();
9343
9344        // Verify: compare with CPU-only conversion of the same input
9345        let cpu_dst = TensorDyn::image(
9346            dst_w,
9347            dst_h,
9348            PixelFormat::Rgba,
9349            DType::U8,
9350            Some(TensorMemory::Mem),
9351            edgefirst_tensor::CpuAccess::ReadWrite,
9352        )
9353        .unwrap();
9354        let (result, _mem_src, cpu_dst) = convert_img(
9355            &mut CPUProcessor::new(),
9356            mem_src,
9357            cpu_dst,
9358            Rotation::None,
9359            Flip::None,
9360            Crop::no_crop(),
9361        );
9362        result.unwrap();
9363
9364        let pbo_dst_img = {
9365            let mut __t = pbo_dst.into_u8().unwrap();
9366            __t.set_format(PixelFormat::Rgba).unwrap();
9367            TensorDyn::from(__t)
9368        };
9369        compare_images(&pbo_dst_img, &cpu_dst, 0.95, function!());
9370        log::info!("test_convert_pbo_to_pbo: PASS — PBO-to-PBO convert matches CPU reference");
9371    }
9372
9373    #[test]
9374    fn test_image_bgra() {
9375        let img = TensorDyn::image(
9376            640,
9377            480,
9378            PixelFormat::Bgra,
9379            DType::U8,
9380            Some(edgefirst_tensor::TensorMemory::Mem),
9381            edgefirst_tensor::CpuAccess::ReadWrite,
9382        )
9383        .unwrap();
9384        assert_eq!(img.width(), Some(640));
9385        assert_eq!(img.height(), Some(480));
9386        assert_eq!(img.format().unwrap().channels(), 4);
9387        assert_eq!(img.format().unwrap(), PixelFormat::Bgra);
9388    }
9389
9390    // ========================================================================
9391    // Tests for EDGEFIRST_FORCE_BACKEND env var
9392    // ========================================================================
9393
9394    #[test]
9395    fn test_force_backend_cpu() {
9396        let _lock = acquire_env_lock();
9397        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
9398        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
9399        let converter = ImageProcessor::new().unwrap();
9400        assert!(converter.cpu.is_some());
9401        assert_eq!(converter.forced_backend, Some(ForcedBackend::Cpu));
9402    }
9403
9404    #[test]
9405    fn test_force_backend_invalid() {
9406        let _lock = acquire_env_lock();
9407        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
9408        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "invalid") };
9409        let result = ImageProcessor::new();
9410        assert!(
9411            matches!(&result, Err(Error::ForcedBackendUnavailable(s)) if s.contains("unknown")),
9412            "invalid backend value should return ForcedBackendUnavailable error: {result:?}"
9413        );
9414    }
9415
9416    #[test]
9417    fn test_force_backend_unset() {
9418        let _lock = acquire_env_lock();
9419        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
9420        unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") };
9421        let converter = ImageProcessor::new().unwrap();
9422        assert!(converter.forced_backend.is_none());
9423    }
9424
9425    // ========================================================================
9426    // Tests for hybrid mask path error handling
9427    // ========================================================================
9428
9429    #[test]
9430    fn test_draw_proto_masks_no_cpu_returns_error() {
9431        // Serialize against all other env-var-mutating tests.
9432        let _lock = acquire_env_lock();
9433        let _guard = EnvGuard::snapshot(&[
9434            "EDGEFIRST_FORCE_BACKEND",
9435            "EDGEFIRST_DISABLE_GL",
9436            "EDGEFIRST_DISABLE_G2D",
9437            "EDGEFIRST_DISABLE_CPU",
9438        ]);
9439
9440        // Disable all backends so cpu.is_none() after construction.
9441        unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
9442        unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
9443        unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
9444
9445        let mut converter = ImageProcessor::new().unwrap();
9446        assert!(converter.cpu.is_none(), "CPU should be disabled");
9447
9448        let dst = TensorDyn::image(
9449            640,
9450            480,
9451            PixelFormat::Rgba,
9452            DType::U8,
9453            Some(TensorMemory::Mem),
9454            edgefirst_tensor::CpuAccess::ReadWrite,
9455        )
9456        .unwrap();
9457        let mut dst_dyn = dst;
9458        let det = [DetectBox {
9459            bbox: edgefirst_decoder::BoundingBox {
9460                xmin: 0.1,
9461                ymin: 0.1,
9462                xmax: 0.5,
9463                ymax: 0.5,
9464            },
9465            score: 0.9,
9466            label: 0,
9467        }];
9468        let proto_data = {
9469            use edgefirst_tensor::{Tensor, TensorDyn};
9470            let coeff_t = Tensor::<f32>::from_slice(&[0.5_f32; 4], &[1, 4]).unwrap();
9471            let protos_t =
9472                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
9473            ProtoData {
9474                mask_coefficients: TensorDyn::F32(coeff_t),
9475                protos: TensorDyn::F32(protos_t),
9476                layout: ProtoLayout::Nhwc,
9477            }
9478        };
9479        let result =
9480            converter.draw_proto_masks(&mut dst_dyn, &det, &proto_data, Default::default());
9481        assert!(
9482            matches!(&result, Err(Error::Internal(s)) if s.contains("CPU backend")),
9483            "draw_proto_masks without CPU should return Internal error: {result:?}"
9484        );
9485    }
9486
9487    #[test]
9488    fn test_draw_proto_masks_cpu_fallback_works() {
9489        // Force CPU-only backend to ensure the CPU fallback path executes.
9490        // Serialized under ENV_MUTEX so we don't race with disable-var tests.
9491        let _lock = acquire_env_lock();
9492        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
9493        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
9494        let mut converter = ImageProcessor::new().unwrap();
9495        assert!(converter.cpu.is_some());
9496
9497        let dst = TensorDyn::image(
9498            64,
9499            64,
9500            PixelFormat::Rgba,
9501            DType::U8,
9502            Some(TensorMemory::Mem),
9503            edgefirst_tensor::CpuAccess::ReadWrite,
9504        )
9505        .unwrap();
9506        let mut dst_dyn = dst;
9507        let det = [DetectBox {
9508            bbox: edgefirst_decoder::BoundingBox {
9509                xmin: 0.1,
9510                ymin: 0.1,
9511                xmax: 0.5,
9512                ymax: 0.5,
9513            },
9514            score: 0.9,
9515            label: 0,
9516        }];
9517        let proto_data = {
9518            use edgefirst_tensor::{Tensor, TensorDyn};
9519            let coeff_t = Tensor::<f32>::from_slice(&[0.5_f32; 4], &[1, 4]).unwrap();
9520            let protos_t =
9521                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
9522            ProtoData {
9523                mask_coefficients: TensorDyn::F32(coeff_t),
9524                protos: TensorDyn::F32(protos_t),
9525                layout: ProtoLayout::Nhwc,
9526            }
9527        };
9528        let result =
9529            converter.draw_proto_masks(&mut dst_dyn, &det, &proto_data, Default::default());
9530        assert!(result.is_ok(), "CPU fallback path should work: {result:?}");
9531    }
9532
9533    // ============================================================
9534    // draw_decoded_masks / draw_proto_masks — 4-scenario pixel-
9535    // verified tests. Exercises each backend against the full
9536    // output-contract matrix:
9537    //
9538    //   | detections | background | expected dst             |
9539    //   |------------|------------|--------------------------|
9540    //   | empty      | none       | fully cleared (0x00)     |
9541    //   | empty      | set        | fully equal to bg        |
9542    //   | set        | none       | cleared outside box +    |
9543    //   |            |            | mask-coloured inside     |
9544    //   | set        | set        | bg outside box + mask    |
9545    //   |            |            | blended inside           |
9546    //
9547    // Every test pre-fills dst with a non-zero "dirty" pattern so
9548    // that any silent `return Ok(())` leaks the pattern into the
9549    // asserted output and fails loudly.
9550    // ============================================================
9551
9552    // =========================================================================
9553    // Env-var serialisation helpers
9554    //
9555    // ALL tests that mutate any EDGEFIRST_* backend env var must hold
9556    // ENV_MUTEX for their full duration.  This single mutex serialises
9557    // test_disable_env_var, test_draw_proto_masks_no_cpu_returns_error,
9558    // test_force_backend_*, with_force_backend, and with_env — preventing
9559    // any two of them from racing in a parallel `cargo test` run.
9560    // =========================================================================
9561
9562    /// Acquire the process-wide env-var mutex.  Returns a guard that must be
9563    /// kept alive for the entire duration of the test body.
9564    fn acquire_env_lock() -> std::sync::MutexGuard<'static, ()> {
9565        use std::sync::{Mutex, OnceLock};
9566        static ENV_MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
9567        ENV_MUTEX
9568            .get_or_init(|| Mutex::new(()))
9569            .lock()
9570            .unwrap_or_else(|e| e.into_inner())
9571    }
9572
9573    /// RAII guard that snapshots a set of env vars on construction and
9574    /// restores them on `Drop`, even if the test panics.
9575    struct EnvGuard {
9576        vars: Vec<(&'static str, Option<String>)>,
9577    }
9578
9579    impl EnvGuard {
9580        /// Snapshot the current values of `names`.  Call this while holding
9581        /// the env lock (the lock is not taken here — that is the caller's
9582        /// responsibility so the lock scope can be wider than the guard).
9583        fn snapshot(names: &[&'static str]) -> Self {
9584            Self {
9585                vars: names.iter().map(|&k| (k, std::env::var(k).ok())).collect(),
9586            }
9587        }
9588    }
9589
9590    impl Drop for EnvGuard {
9591        fn drop(&mut self) {
9592            for (k, v) in &self.vars {
9593                match v {
9594                    Some(s) => unsafe { std::env::set_var(k, s) },
9595                    None => unsafe { std::env::remove_var(k) },
9596                }
9597            }
9598        }
9599    }
9600
9601    /// Run `body` with `EDGEFIRST_FORCE_BACKEND` temporarily set (or
9602    /// removed), restoring the prior value afterward. Tests are env-
9603    /// serialized via the process-wide `ENV_MUTEX`.
9604    fn with_force_backend<R>(value: Option<&str>, body: impl FnOnce() -> R) -> R {
9605        let _lock = acquire_env_lock();
9606        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
9607        match value {
9608            Some(v) => unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", v) },
9609            None => unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") },
9610        }
9611        body()
9612    }
9613
9614    /// Allocate an RGBA image tensor and pre-fill every byte with a
9615    /// distinctive non-zero pattern. Any test that relies on the old
9616    /// "dst is already cleared" assumption will see this pattern leak
9617    /// through to the output and fail.
9618    fn make_dirty_dst(w: usize, h: usize, mem: Option<TensorMemory>) -> TensorDyn {
9619        let dst = TensorDyn::image(
9620            w,
9621            h,
9622            PixelFormat::Rgba,
9623            DType::U8,
9624            mem,
9625            edgefirst_tensor::CpuAccess::ReadWrite,
9626        )
9627        .unwrap();
9628        {
9629            use edgefirst_tensor::TensorMapTrait;
9630            let u8t = dst.as_u8().unwrap();
9631            let mut map = u8t.map().unwrap();
9632            for (i, b) in map.as_mut_slice().iter_mut().enumerate() {
9633                *b = 0xA0u8.wrapping_add((i as u8) & 0x3F);
9634            }
9635        }
9636        dst
9637    }
9638
9639    /// Allocate an RGBA background filled with a constant colour.
9640    fn make_bg(w: usize, h: usize, mem: Option<TensorMemory>, rgba: [u8; 4]) -> TensorDyn {
9641        let bg = TensorDyn::image(
9642            w,
9643            h,
9644            PixelFormat::Rgba,
9645            DType::U8,
9646            mem,
9647            edgefirst_tensor::CpuAccess::ReadWrite,
9648        )
9649        .unwrap();
9650        {
9651            use edgefirst_tensor::TensorMapTrait;
9652            let u8t = bg.as_u8().unwrap();
9653            let mut map = u8t.map().unwrap();
9654            for chunk in map.as_mut_slice().chunks_exact_mut(4) {
9655                chunk.copy_from_slice(&rgba);
9656            }
9657        }
9658        bg
9659    }
9660
9661    fn pixel_at(dst: &TensorDyn, x: usize, y: usize) -> [u8; 4] {
9662        use edgefirst_tensor::TensorMapTrait;
9663        let w = dst.width().unwrap();
9664        let off = (y * w + x) * 4;
9665        let u8t = dst.as_u8().unwrap();
9666        let map = u8t.map().unwrap();
9667        let s = map.as_slice();
9668        [s[off], s[off + 1], s[off + 2], s[off + 3]]
9669    }
9670
9671    fn assert_every_pixel_eq(dst: &TensorDyn, expected: [u8; 4], case: &str) {
9672        use edgefirst_tensor::TensorMapTrait;
9673        let u8t = dst.as_u8().unwrap();
9674        let map = u8t.map().unwrap();
9675        for (i, chunk) in map.as_slice().chunks_exact(4).enumerate() {
9676            assert_eq!(
9677                chunk, &expected,
9678                "{case}: pixel idx {i} = {chunk:?}, expected {expected:?}"
9679            );
9680        }
9681    }
9682
9683    /// Scenario 1: empty detections, empty segmentation, no background
9684    /// → dst must be fully cleared to 0x00000000.
9685    fn scenario_empty_no_bg(processor: &mut ImageProcessor, case: &str) {
9686        let mut dst = make_dirty_dst(64, 64, None);
9687        processor
9688            .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::default())
9689            .unwrap_or_else(|e| panic!("{case}/decoded_masks empty+no-bg failed: {e:?}"));
9690        assert_every_pixel_eq(&dst, [0, 0, 0, 0], &format!("{case}/decoded"));
9691
9692        let mut dst = make_dirty_dst(64, 64, None);
9693        let proto = {
9694            use edgefirst_tensor::{Tensor, TensorDyn};
9695            // Placeholder (no detections); shape [1, 4] to keep the tensor well-formed.
9696            let coeff_t = Tensor::<f32>::from_slice(&[0.0_f32; 4], &[1, 4]).unwrap();
9697            let protos_t =
9698                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
9699            ProtoData {
9700                mask_coefficients: TensorDyn::F32(coeff_t),
9701                protos: TensorDyn::F32(protos_t),
9702                layout: ProtoLayout::Nhwc,
9703            }
9704        };
9705        processor
9706            .draw_proto_masks(&mut dst, &[], &proto, MaskOverlay::default())
9707            .unwrap_or_else(|e| panic!("{case}/proto_masks empty+no-bg failed: {e:?}"));
9708        assert_every_pixel_eq(&dst, [0, 0, 0, 0], &format!("{case}/proto"));
9709    }
9710
9711    /// Scenario 2: empty detections, empty segmentation, background set
9712    /// → dst must be fully equal to bg.
9713    fn scenario_empty_with_bg(processor: &mut ImageProcessor, case: &str) {
9714        let bg_color = [42, 99, 200, 255];
9715        let bg = make_bg(64, 64, None, bg_color);
9716        let overlay = MaskOverlay::new().with_background(&bg);
9717
9718        let mut dst = make_dirty_dst(64, 64, None);
9719        processor
9720            .draw_decoded_masks(&mut dst, &[], &[], overlay)
9721            .unwrap_or_else(|e| panic!("{case}/decoded_masks empty+bg failed: {e:?}"));
9722        assert_every_pixel_eq(&dst, bg_color, &format!("{case}/decoded bg blit"));
9723
9724        let mut dst = make_dirty_dst(64, 64, None);
9725        let proto = {
9726            use edgefirst_tensor::{Tensor, TensorDyn};
9727            // Placeholder (no detections); shape [1, 4] to keep the tensor well-formed.
9728            let coeff_t = Tensor::<f32>::from_slice(&[0.0_f32; 4], &[1, 4]).unwrap();
9729            let protos_t =
9730                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
9731            ProtoData {
9732                mask_coefficients: TensorDyn::F32(coeff_t),
9733                protos: TensorDyn::F32(protos_t),
9734                layout: ProtoLayout::Nhwc,
9735            }
9736        };
9737        processor
9738            .draw_proto_masks(&mut dst, &[], &proto, overlay)
9739            .unwrap_or_else(|e| panic!("{case}/proto_masks empty+bg failed: {e:?}"));
9740        assert_every_pixel_eq(&dst, bg_color, &format!("{case}/proto bg blit"));
9741    }
9742
9743    /// Scenario 3: one detection with a fully-opaque segmentation fill,
9744    /// no background → outside the box dst must be 0x00, inside it must
9745    /// be a non-zero mask colour (the render_segmentation output).
9746    fn scenario_detect_no_bg(processor: &mut ImageProcessor, case: &str) {
9747        use edgefirst_decoder::Segmentation;
9748        use ndarray::Array3;
9749        processor
9750            .set_class_colors(&[[200, 80, 40, 255]])
9751            .expect("set_class_colors");
9752
9753        let detect = DetectBox {
9754            bbox: [0.25, 0.25, 0.75, 0.75].into(),
9755            score: 0.99,
9756            label: 0,
9757        };
9758        let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
9759        let seg = Segmentation {
9760            segmentation: seg_arr,
9761            xmin: 0.25,
9762            ymin: 0.25,
9763            xmax: 0.75,
9764            ymax: 0.75,
9765        };
9766
9767        let mut dst = make_dirty_dst(64, 64, None);
9768        processor
9769            .draw_decoded_masks(&mut dst, &[detect], &[seg], MaskOverlay::default())
9770            .unwrap_or_else(|e| panic!("{case}/decoded_masks detect+no-bg failed: {e:?}"));
9771
9772        // Outside the bbox (corner): must be cleared black.
9773        let corner = pixel_at(&dst, 2, 2);
9774        assert_eq!(
9775            corner,
9776            [0, 0, 0, 0],
9777            "{case}/decoded: corner (2,2) leaked dirty pattern: {corner:?}"
9778        );
9779        // Inside the bbox (center): the mask colour must be visible.
9780        // Any non-zero pixel is acceptable — exact rendering varies
9781        // between backends (GL smoothstep, CPU nearest).
9782        let center = pixel_at(&dst, 32, 32);
9783        assert!(
9784            center != [0, 0, 0, 0],
9785            "{case}/decoded: center (32,32) was not coloured: {center:?}"
9786        );
9787    }
9788
9789    /// Scenario 4: detection + background. Outside the box must match
9790    /// bg; inside the box must NOT match bg (mask blended on top).
9791    fn scenario_detect_with_bg(processor: &mut ImageProcessor, case: &str) {
9792        use edgefirst_decoder::Segmentation;
9793        use ndarray::Array3;
9794        processor
9795            .set_class_colors(&[[200, 80, 40, 255]])
9796            .expect("set_class_colors");
9797        let bg_color = [10, 20, 30, 255];
9798        let bg = make_bg(64, 64, None, bg_color);
9799
9800        let detect = DetectBox {
9801            bbox: [0.25, 0.25, 0.75, 0.75].into(),
9802            score: 0.99,
9803            label: 0,
9804        };
9805        let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
9806        let seg = Segmentation {
9807            segmentation: seg_arr,
9808            xmin: 0.25,
9809            ymin: 0.25,
9810            xmax: 0.75,
9811            ymax: 0.75,
9812        };
9813
9814        let overlay = MaskOverlay::new().with_background(&bg);
9815        let mut dst = make_dirty_dst(64, 64, None);
9816        processor
9817            .draw_decoded_masks(&mut dst, &[detect], &[seg], overlay)
9818            .unwrap_or_else(|e| panic!("{case}/decoded_masks detect+bg failed: {e:?}"));
9819
9820        // Outside the bbox (corner): bg colour.
9821        let corner = pixel_at(&dst, 2, 2);
9822        assert_eq!(
9823            corner, bg_color,
9824            "{case}/decoded: corner (2,2) should show bg {bg_color:?} got {corner:?}"
9825        );
9826        // Inside the bbox (center): mask blended on bg, must differ from
9827        // pure bg (alpha-blend with mask colour produces a distinct shade).
9828        let center = pixel_at(&dst, 32, 32);
9829        assert!(
9830            center != bg_color,
9831            "{case}/decoded: center (32,32) should differ from bg {bg_color:?}, got {center:?}"
9832        );
9833    }
9834
9835    /// Run all 4 scenarios against the processor. Skip gracefully if
9836    /// construction fails (backend unavailable on this host).
9837    fn run_all_scenarios(
9838        force_backend: Option<&'static str>,
9839        case: &'static str,
9840        require_dma_for_bg: bool,
9841    ) {
9842        if require_dma_for_bg && !edgefirst_tensor::is_dma_available() {
9843            eprintln!("SKIPPED: {case} — DMA not available on this host");
9844            return;
9845        }
9846        let processor_result = with_force_backend(force_backend, ImageProcessor::new);
9847        let mut processor = match processor_result {
9848            Ok(p) => p,
9849            Err(e) => {
9850                eprintln!("SKIPPED: {case} — backend init failed: {e:?}");
9851                return;
9852            }
9853        };
9854        scenario_empty_no_bg(&mut processor, case);
9855        scenario_empty_with_bg(&mut processor, case);
9856        scenario_detect_no_bg(&mut processor, case);
9857        scenario_detect_with_bg(&mut processor, case);
9858    }
9859
9860    #[test]
9861    fn test_draw_masks_4_scenarios_cpu() {
9862        run_all_scenarios(Some("cpu"), "cpu", false);
9863    }
9864
9865    #[test]
9866    fn test_draw_masks_4_scenarios_auto() {
9867        run_all_scenarios(None, "auto", false);
9868    }
9869
9870    #[cfg(target_os = "linux")]
9871    #[cfg(feature = "opengl")]
9872    #[test]
9873    fn test_draw_masks_4_scenarios_opengl() {
9874        run_all_scenarios(Some("opengl"), "opengl", false);
9875    }
9876
9877    /// G2D forced backend: exercises the zero-detection empty-frame
9878    /// paths via `g2d_clear` and `g2d_blit`. Scenarios 3 and 4 (with
9879    /// detections) expect `NotImplemented` since G2D has no rasterizer
9880    /// for boxes / masks.
9881    #[cfg(target_os = "linux")]
9882    #[test]
9883    fn test_draw_masks_zero_detection_g2d_forced() {
9884        if !edgefirst_tensor::is_dma_available() {
9885            eprintln!("SKIPPED: g2d forced — DMA not available on this host");
9886            return;
9887        }
9888        let processor_result = with_force_backend(Some("g2d"), ImageProcessor::new);
9889        let mut processor = match processor_result {
9890            Ok(p) => p,
9891            Err(e) => {
9892                eprintln!("SKIPPED: g2d forced — init failed: {e:?}");
9893                return;
9894            }
9895        };
9896
9897        // Case 1: empty + no bg. G2D requires DMA-backed dst.
9898        let mut dst = TensorDyn::image(
9899            64,
9900            64,
9901            PixelFormat::Rgba,
9902            DType::U8,
9903            Some(TensorMemory::Dma),
9904            edgefirst_tensor::CpuAccess::ReadWrite,
9905        )
9906        .unwrap();
9907        {
9908            use edgefirst_tensor::TensorMapTrait;
9909            let u8t = dst.as_u8_mut().unwrap();
9910            let mut map = u8t.map().unwrap();
9911            map.as_mut_slice().fill(0xBB);
9912        }
9913        processor
9914            .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::default())
9915            .expect("g2d empty+no-bg");
9916        assert_every_pixel_eq(&dst, [0, 0, 0, 0], "g2d/case1 cleared");
9917
9918        // Case 2: empty + bg. Both surfaces DMA-backed for g2d_blit.
9919        let bg_color = [7, 11, 13, 255];
9920        let bg = {
9921            let t = TensorDyn::image(
9922                64,
9923                64,
9924                PixelFormat::Rgba,
9925                DType::U8,
9926                Some(TensorMemory::Dma),
9927                edgefirst_tensor::CpuAccess::ReadWrite,
9928            )
9929            .unwrap();
9930            {
9931                use edgefirst_tensor::TensorMapTrait;
9932                let u8t = t.as_u8().unwrap();
9933                let mut map = u8t.map().unwrap();
9934                for chunk in map.as_mut_slice().chunks_exact_mut(4) {
9935                    chunk.copy_from_slice(&bg_color);
9936                }
9937            }
9938            t
9939        };
9940        let mut dst = TensorDyn::image(
9941            64,
9942            64,
9943            PixelFormat::Rgba,
9944            DType::U8,
9945            Some(TensorMemory::Dma),
9946            edgefirst_tensor::CpuAccess::ReadWrite,
9947        )
9948        .unwrap();
9949        {
9950            use edgefirst_tensor::TensorMapTrait;
9951            let u8t = dst.as_u8_mut().unwrap();
9952            let mut map = u8t.map().unwrap();
9953            map.as_mut_slice().fill(0x55);
9954        }
9955        processor
9956            .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::new().with_background(&bg))
9957            .expect("g2d empty+bg");
9958        assert_every_pixel_eq(&dst, bg_color, "g2d/case2 bg blit");
9959
9960        // Case 3 and 4: detect present — must return NotImplemented.
9961        let detect = DetectBox {
9962            bbox: [0.25, 0.25, 0.75, 0.75].into(),
9963            score: 0.9,
9964            label: 0,
9965        };
9966        let mut dst = TensorDyn::image(
9967            64,
9968            64,
9969            PixelFormat::Rgba,
9970            DType::U8,
9971            Some(TensorMemory::Dma),
9972            edgefirst_tensor::CpuAccess::ReadWrite,
9973        )
9974        .unwrap();
9975        let err = processor
9976            .draw_decoded_masks(&mut dst, &[detect], &[], MaskOverlay::default())
9977            .expect_err("g2d must reject detect-present draw_decoded_masks");
9978        assert!(
9979            matches!(err, Error::NotImplemented(_)),
9980            "g2d case3 wrong error: {err:?}"
9981        );
9982    }
9983
9984    #[test]
9985    fn test_set_format_then_cpu_convert() {
9986        // Force CPU backend; serialized under ENV_MUTEX to avoid racing with
9987        // test_force_backend_* and test_disable_env_var.
9988        let _lock = acquire_env_lock();
9989        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
9990        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
9991        let mut processor = ImageProcessor::new().unwrap();
9992
9993        // Load a source image
9994        let image = edgefirst_bench::testdata::read("zidane.jpg");
9995        let src = load_image_test_helper(&image, Some(PixelFormat::Rgba), None).unwrap();
9996
9997        // Create a raw tensor, then attach format — simulating the from_fd workflow
9998        let mut dst =
9999            TensorDyn::new(&[640, 640, 3], DType::U8, Some(TensorMemory::Mem), None).unwrap();
10000        dst.set_format(PixelFormat::Rgb).unwrap();
10001
10002        // Convert should work with the set_format-annotated tensor
10003        processor
10004            .convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
10005            .unwrap();
10006
10007        // Verify format survived conversion
10008        assert_eq!(dst.format(), Some(PixelFormat::Rgb));
10009        assert_eq!(dst.width(), Some(640));
10010        assert_eq!(dst.height(), Some(640));
10011    }
10012
10013    /// Verify that creating multiple ImageProcessors on the same thread and
10014    /// performing a resize on each does not deadlock or error.
10015    ///
10016    /// Uses automatic memory allocation (DMA → PBO → Mem fallback) so that
10017    /// hardware backends (OpenGL, G2D) are exercised on capable targets.
10018    #[test]
10019    fn test_multiple_image_processors_same_thread() {
10020        // Hold the env mutex so env-var-mutating tests can't corrupt the state
10021        // seen by ImageProcessor::new() calls during this test.
10022        let _lock = acquire_env_lock();
10023        let mut processors: Vec<ImageProcessor> = (0..4)
10024            .map(|_| ImageProcessor::new().expect("ImageProcessor::new() failed"))
10025            .collect();
10026
10027        for proc in &mut processors {
10028            let src = proc
10029                .create_image(
10030                    128,
10031                    128,
10032                    PixelFormat::Rgb,
10033                    DType::U8,
10034                    None,
10035                    edgefirst_tensor::CpuAccess::ReadWrite,
10036                )
10037                .expect("create src failed");
10038            let mut dst = proc
10039                .create_image(
10040                    64,
10041                    64,
10042                    PixelFormat::Rgb,
10043                    DType::U8,
10044                    None,
10045                    edgefirst_tensor::CpuAccess::ReadWrite,
10046                )
10047                .expect("create dst failed");
10048            proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
10049                .expect("convert failed");
10050            assert_eq!(dst.width(), Some(64));
10051            assert_eq!(dst.height(), Some(64));
10052        }
10053    }
10054
10055    /// Verify that creating ImageProcessors on separate threads and performing
10056    /// a resize on each does not deadlock or error.
10057    ///
10058    /// Uses automatic memory allocation (DMA → PBO → Mem fallback) so that
10059    /// hardware backends (OpenGL, G2D) are exercised on capable targets.
10060    /// A 60-second timeout prevents CI from hanging on deadlock regressions.
10061    #[test]
10062    fn test_multiple_image_processors_separate_threads() {
10063        use std::sync::mpsc;
10064        use std::time::Duration;
10065
10066        // The Vivante GC7000UL driver (i.MX 8M Plus) double-frees on concurrent
10067        // EGL context teardown — four processors spun up on four threads here
10068        // trips it and aborts the whole test binary (SIGABRT, not a catchable
10069        // panic). The bug is the driver's, not the HAL's; this test is kept so
10070        // it still exercises the multi-context path on every other GPU. The
10071        // on-target GitHub Actions imx8mp runner sets EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS
10072        // to skip just this case there, while the run stays red anywhere else
10073        // a regression appears. (Skip, not #[ignore]: the platform is decided
10074        // at runtime, not compile time.)
10075        if std::env::var_os("EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS").is_some() {
10076            eprintln!(
10077                "SKIPPED: test_multiple_image_processors_separate_threads — known Vivante \
10078                 GC7000UL concurrent-EGL-teardown double-free \
10079                 (EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS set)"
10080            );
10081            return;
10082        }
10083
10084        const TIMEOUT: Duration = Duration::from_secs(60);
10085
10086        // Hold the env mutex so env-var-mutating tests can't corrupt ImageProcessor::new()
10087        // calls made inside the spawned threads during this test.
10088        let _lock = acquire_env_lock();
10089
10090        let (tx, rx) = mpsc::channel::<()>();
10091
10092        std::thread::spawn(move || {
10093            let handles: Vec<_> = (0..4)
10094                .map(|i| {
10095                    std::thread::spawn(move || {
10096                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
10097                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
10098                        });
10099                        let src = proc
10100                            .create_image(
10101                                128,
10102                                128,
10103                                PixelFormat::Rgb,
10104                                DType::U8,
10105                                None,
10106                                edgefirst_tensor::CpuAccess::ReadWrite,
10107                            )
10108                            .unwrap_or_else(|e| panic!("create src failed on thread {i}: {e}"));
10109                        let mut dst = proc
10110                            .create_image(
10111                                64,
10112                                64,
10113                                PixelFormat::Rgb,
10114                                DType::U8,
10115                                None,
10116                                edgefirst_tensor::CpuAccess::ReadWrite,
10117                            )
10118                            .unwrap_or_else(|e| panic!("create dst failed on thread {i}: {e}"));
10119                        proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
10120                            .unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
10121                        assert_eq!(dst.width(), Some(64));
10122                        assert_eq!(dst.height(), Some(64));
10123                    })
10124                })
10125                .collect();
10126
10127            for (i, h) in handles.into_iter().enumerate() {
10128                h.join()
10129                    .unwrap_or_else(|e| panic!("thread {i} panicked: {e:?}"));
10130            }
10131
10132            let _ = tx.send(());
10133        });
10134
10135        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
10136            panic!("test_multiple_image_processors_separate_threads timed out after {TIMEOUT:?}")
10137        });
10138    }
10139
10140    /// Verify that 4 fully-initialized ImageProcessors on separate threads can
10141    /// all operate concurrently without deadlocking each other.
10142    ///
10143    /// All processors are created first, then a barrier synchronizes them so
10144    /// they all start converting at the same instant — maximizing contention.
10145    /// A 60-second timeout prevents CI from hanging on deadlock regressions.
10146    #[test]
10147    fn test_image_processors_concurrent_operations() {
10148        use std::sync::{mpsc, Arc, Barrier};
10149        use std::time::Duration;
10150
10151        const N: usize = 4;
10152        const ROUNDS: usize = 10;
10153        const TIMEOUT: Duration = Duration::from_secs(60);
10154
10155        // Hold the env mutex so env-var-mutating tests can't corrupt ImageProcessor::new()
10156        // calls made inside the spawned threads during this test.
10157        let _lock = acquire_env_lock();
10158
10159        let (tx, rx) = mpsc::channel::<()>();
10160
10161        std::thread::spawn(move || {
10162            let barrier = Arc::new(Barrier::new(N));
10163
10164            let handles: Vec<_> = (0..N)
10165                .map(|i| {
10166                    let barrier = Arc::clone(&barrier);
10167                    std::thread::spawn(move || {
10168                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
10169                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
10170                        });
10171
10172                        // All threads wait here until every processor is initialized.
10173                        barrier.wait();
10174
10175                        // Now all 4 hammer the GPU concurrently.
10176                        for round in 0..ROUNDS {
10177                            let src = proc
10178                                .create_image(
10179                                    128,
10180                                    128,
10181                                    PixelFormat::Rgb,
10182                                    DType::U8,
10183                                    None,
10184                                    edgefirst_tensor::CpuAccess::ReadWrite,
10185                                )
10186                                .unwrap_or_else(|e| {
10187                                    panic!("create src failed on thread {i} round {round}: {e}")
10188                                });
10189                            let mut dst = proc
10190                                .create_image(
10191                                    64,
10192                                    64,
10193                                    PixelFormat::Rgb,
10194                                    DType::U8,
10195                                    None,
10196                                    edgefirst_tensor::CpuAccess::ReadWrite,
10197                                )
10198                                .unwrap_or_else(|e| {
10199                                    panic!("create dst failed on thread {i} round {round}: {e}")
10200                                });
10201                            proc.convert(
10202                                &src,
10203                                &mut dst,
10204                                Rotation::None,
10205                                Flip::None,
10206                                Crop::default(),
10207                            )
10208                            .unwrap_or_else(|e| {
10209                                panic!("convert failed on thread {i} round {round}: {e}")
10210                            });
10211                            assert_eq!(dst.width(), Some(64));
10212                            assert_eq!(dst.height(), Some(64));
10213                        }
10214                    })
10215                })
10216                .collect();
10217
10218            for (i, h) in handles.into_iter().enumerate() {
10219                h.join()
10220                    .unwrap_or_else(|e| panic!("thread {i} panicked: {e:?}"));
10221            }
10222
10223            let _ = tx.send(());
10224        });
10225
10226        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
10227            panic!("test_image_processors_concurrent_operations timed out after {TIMEOUT:?}")
10228        });
10229    }
10230
10231    /// THE parallel-processors demonstration test: 4 ImageProcessors on 4
10232    /// threads, each with its own GL context and worker, converting
10233    /// per-thread-DISTINCT synthetic inputs concurrently (barrier-released);
10234    /// every output must byte-match the thread's own pre-barrier sequential
10235    /// oracle from the same processor. On LifecycleOnly platforms (Mali,
10236    /// V3D, Tegra, llvmpipe, macOS) the converts genuinely overlap on the
10237    /// GPU; on Vivante they serialize via the Full policy and the test still
10238    /// must pass. Distinct inputs make any cross-processor state leakage
10239    /// (wrong texture, wrong context, clobbered upload) visible as a byte
10240    /// diff rather than a coincidental match — proven by a scratch
10241    /// cross-wire run (neighbor's input post-oracle) failing on every
10242    /// thread with ~53% of bytes diverged.
10243    ///
10244    /// Skipped under EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS like
10245    /// `test_multiple_image_processors_separate_threads`: the galcore driver
10246    /// can abort intermittently on concurrent multi-processor lifecycles
10247    /// regardless of locking (P0 spike: reproduces fully serialized).
10248    #[test]
10249    fn test_parallel_processors_unique_outputs() {
10250        use std::sync::{mpsc, Arc, Barrier};
10251        use std::time::Duration;
10252
10253        const N: usize = 4;
10254        const ROUNDS: usize = 25;
10255        const TIMEOUT: Duration = Duration::from_secs(60);
10256
10257        if std::env::var_os("EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS").is_some() {
10258            eprintln!(
10259                "SKIPPED: test_parallel_processors_unique_outputs — known Vivante \
10260                 GC7000UL concurrent-multi-processor driver abort \
10261                 (EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS set)"
10262            );
10263            return;
10264        }
10265
10266        let _lock = acquire_env_lock();
10267        let (tx, rx) = mpsc::channel::<()>();
10268
10269        std::thread::spawn(move || {
10270            let barrier = Arc::new(Barrier::new(N));
10271            let handles: Vec<_> = (0..N)
10272                .map(|i| {
10273                    let barrier = Arc::clone(&barrier);
10274                    std::thread::spawn(move || {
10275                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
10276                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
10277                        });
10278                        // CI-weight geometry (llvmpipe renders on the CPU).
10279                        let (w, h) = (640usize, 480usize);
10280                        let mem = if edgefirst_tensor::is_dma_available() {
10281                            Some(TensorMemory::Dma)
10282                        } else {
10283                            Some(TensorMemory::Mem)
10284                        };
10285                        let src = proc
10286                            .create_image(
10287                                w,
10288                                h,
10289                                PixelFormat::Nv12,
10290                                DType::U8,
10291                                mem,
10292                                edgefirst_tensor::CpuAccess::ReadWrite,
10293                            )
10294                            .unwrap();
10295                        {
10296                            let t = src.as_u8().unwrap();
10297                            let mut m = t.map().unwrap();
10298                            let s = m.as_mut_slice();
10299                            for (j, b) in s[..w * h].iter_mut().enumerate() {
10300                                *b = ((i * 53 + j) % 200 + 16) as u8;
10301                            }
10302                            for b in &mut s[w * h..] {
10303                                *b = (80 + i * 24) as u8;
10304                            }
10305                        }
10306                        let lb = Crop::letterbox([114, 114, 114, 255]);
10307                        let convert_once = |proc: &mut ImageProcessor| -> Vec<u8> {
10308                            let mut dst = proc
10309                                .create_image(
10310                                    320,
10311                                    320,
10312                                    PixelFormat::Rgba,
10313                                    DType::U8,
10314                                    mem,
10315                                    edgefirst_tensor::CpuAccess::ReadWrite,
10316                                )
10317                                .unwrap();
10318                            proc.convert(&src, &mut dst, Rotation::None, Flip::None, lb)
10319                                .unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
10320                            let t = dst.as_u8().unwrap();
10321                            let m = t.map().unwrap();
10322                            m.as_slice().to_vec()
10323                        };
10324
10325                        let oracle = convert_once(&mut proc);
10326                        barrier.wait();
10327                        for round in 0..ROUNDS {
10328                            let out = convert_once(&mut proc);
10329                            let diffs = oracle.iter().zip(&out).filter(|(a, b)| a != b).count();
10330                            assert!(
10331                                diffs == 0,
10332                                "thread {i} round {round}: {diffs}/{} bytes diverged \
10333                                 from this processor's own oracle — cross-processor \
10334                                 GL state leakage under parallel execution",
10335                                oracle.len()
10336                            );
10337                        }
10338                    })
10339                })
10340                .collect();
10341
10342            for (i, h) in handles.into_iter().enumerate() {
10343                h.join()
10344                    .unwrap_or_else(|e| panic!("parallel thread {i} panicked: {e:?}"));
10345            }
10346            let _ = tx.send(());
10347        });
10348
10349        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
10350            panic!("test_parallel_processors_unique_outputs timed out after {TIMEOUT:?}")
10351        });
10352    }
10353
10354    /// Heavy on-demand stressor for the GL serialization policy: 4
10355    /// processors × 4 threads × barrier × 200 NV12 720p → RGB 640 letterbox
10356    /// converts (DMA where available); every output must byte-match the
10357    /// thread's own pre-barrier sequential oracle from the same processor.
10358    /// Ignored by default (heavy; board tool — the CI-weight version is
10359    /// `test_parallel_processors_unique_outputs`). Run explicitly, optionally
10360    /// pinning the policy via EDGEFIRST_GL_SERIALIZE=full|lifecycle:
10361    ///   <test binary> stress_parallel_processors_oracle --ignored
10362    #[test]
10363    #[ignore = "heavy on-demand GL-parallelism stressor; run explicitly on boards"]
10364    fn stress_parallel_processors_oracle() {
10365        use std::sync::{mpsc, Arc, Barrier};
10366        use std::time::Duration;
10367
10368        const N: usize = 4;
10369        const ROUNDS: usize = 200;
10370        const TIMEOUT: Duration = Duration::from_secs(600);
10371
10372        let _lock = acquire_env_lock();
10373        let (tx, rx) = mpsc::channel::<()>();
10374
10375        std::thread::spawn(move || {
10376            let barrier = Arc::new(Barrier::new(N));
10377            let handles: Vec<_> = (0..N)
10378                .map(|i| {
10379                    let barrier = Arc::clone(&barrier);
10380                    std::thread::spawn(move || {
10381                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
10382                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
10383                        });
10384                        let (w, h) = (1280usize, 720usize);
10385                        let mem = if edgefirst_tensor::is_dma_available() {
10386                            Some(TensorMemory::Dma)
10387                        } else {
10388                            Some(TensorMemory::Mem)
10389                        };
10390
10391                        // Per-thread-distinct synthetic NV12 so cross-wired
10392                        // GL state between processors shows up as a byte diff.
10393                        let src = proc
10394                            .create_image(
10395                                w,
10396                                h,
10397                                PixelFormat::Nv12,
10398                                DType::U8,
10399                                mem,
10400                                edgefirst_tensor::CpuAccess::ReadWrite,
10401                            )
10402                            .unwrap();
10403                        {
10404                            let t = src.as_u8().unwrap();
10405                            let mut m = t.map().unwrap();
10406                            let s = m.as_mut_slice();
10407                            for (j, b) in s[..w * h].iter_mut().enumerate() {
10408                                *b = ((i * 37 + j) % 200 + 16) as u8;
10409                            }
10410                            for b in &mut s[w * h..] {
10411                                *b = (96 + i * 16) as u8;
10412                            }
10413                        }
10414                        let lb = Crop::letterbox([114, 114, 114, 255]);
10415
10416                        let convert_once = |proc: &mut ImageProcessor| -> Vec<u8> {
10417                            let mut dst = proc
10418                                .create_image(
10419                                    640,
10420                                    640,
10421                                    PixelFormat::Rgb,
10422                                    DType::U8,
10423                                    mem,
10424                                    edgefirst_tensor::CpuAccess::ReadWrite,
10425                                )
10426                                .unwrap();
10427                            proc.convert(&src, &mut dst, Rotation::None, Flip::None, lb)
10428                                .unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
10429                            let t = dst.as_u8().unwrap();
10430                            let m = t.map().unwrap();
10431                            m.as_slice().to_vec()
10432                        };
10433
10434                        let oracle = convert_once(&mut proc);
10435                        barrier.wait();
10436                        for round in 0..ROUNDS {
10437                            let out = convert_once(&mut proc);
10438                            let diffs = oracle.iter().zip(&out).filter(|(a, b)| a != b).count();
10439                            assert!(
10440                                diffs == 0,
10441                                "thread {i} round {round}: {diffs}/{} bytes diverged \
10442                                 from the pre-barrier oracle",
10443                                oracle.len()
10444                            );
10445                        }
10446                    })
10447                })
10448                .collect();
10449
10450            for (i, h) in handles.into_iter().enumerate() {
10451                h.join()
10452                    .unwrap_or_else(|e| panic!("stressor thread {i} panicked: {e:?}"));
10453            }
10454            let _ = tx.send(());
10455        });
10456
10457        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
10458            panic!("stress_parallel_processors_oracle timed out after {TIMEOUT:?}")
10459        });
10460    }
10461
10462    // =========================================================================
10463    // F16 / F32 auto-chain fallback integration tests
10464    // =========================================================================
10465
10466    /// Proves the auto-chain (OpenGL → G2D → CPU) NEVER errors for a float
10467    /// combo the GL path does NOT cover.
10468    ///
10469    /// `Yuyv → Rgb F32` is not handled by the GL float render path (which
10470    /// only covers `Rgba → PlanarRgb F16` and `Rgba → Rgb F32`), so the
10471    /// chain falls through to the CPU float path. Before commit 868a7649
10472    /// added CPU U8→F32/F16 support this would have returned `Err`; now it
10473    /// must return `Ok` with output in `[0, 1]` and all values finite.
10474    #[test]
10475    fn convert_f32_auto_never_errors_non_gl_combo() {
10476        const W: usize = 64;
10477        const H: usize = 64;
10478
10479        // Build a small synthetic YUYV source (Y=128, U=128, V=128 → near-grey).
10480        // YUYV packs two pixels into 4 bytes: [Y0, U, Y1, V] per macropixel.
10481        let src = TensorDyn::image(
10482            W,
10483            H,
10484            PixelFormat::Yuyv,
10485            DType::U8,
10486            Some(TensorMemory::Mem),
10487            edgefirst_tensor::CpuAccess::ReadWrite,
10488        )
10489        .unwrap();
10490        {
10491            let mut map = src.as_u8().unwrap().map().unwrap();
10492            let data = map.as_mut_slice();
10493            for chunk in data.chunks_exact_mut(4) {
10494                chunk[0] = 128; // Y0
10495                chunk[1] = 128; // U
10496                chunk[2] = 160; // Y1 — distinct so a layout bug is visible
10497                chunk[3] = 128; // V
10498            }
10499        }
10500
10501        let mut dst = TensorDyn::image(
10502            W,
10503            H,
10504            PixelFormat::Rgb,
10505            DType::F32,
10506            Some(TensorMemory::Mem),
10507            edgefirst_tensor::CpuAccess::ReadWrite,
10508        )
10509        .unwrap();
10510
10511        let mut proc = ImageProcessor::new().unwrap();
10512        let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
10513        assert!(
10514            result.is_ok(),
10515            "auto-chain Yuyv→Rgb F32 must not error: {:?}",
10516            result.err()
10517        );
10518
10519        // Verify all output values are finite and in [0, 1].
10520        let map = dst.as_f32().unwrap().map().unwrap();
10521        let floats = map.as_slice();
10522        assert_eq!(floats.len(), W * H * 3, "unexpected output element count");
10523        for (i, &v) in floats.iter().enumerate() {
10524            assert!(
10525                v.is_finite() && (0.0..=1.0).contains(&v),
10526                "output[{i}]={v} is not finite or not in [0,1]"
10527            );
10528        }
10529
10530        // WEAK-1: Anti-all-zero spot-check.  A Y=128 YUYV source normalises to
10531        // ≈0.502 on the luma channel.  If the buffer is all-zero (e.g. the CPU
10532        // path never wrote to it) this assertion catches the regression.
10533        let first_non_zero = floats.iter().find(|&&v| v > 0.01);
10534        assert!(
10535            first_non_zero.is_some(),
10536            "all-zero output detected — CPU path likely did not write to the destination buffer"
10537        );
10538        // Y=128 → luma ≈ 0.502.  Spot-check the first pixel's R channel
10539        // (which carries luma for a near-grey YUV source).
10540        let r0 = floats[0];
10541        assert!(
10542            (r0 - 0.502_f32).abs() < 0.05,
10543            "first pixel R={r0} expected ≈0.502 (Y=128 neutral grey from YUYV source)"
10544        );
10545    }
10546
10547    /// Proves CPU-forced `Rgba → PlanarRgb F16` correctness.
10548    ///
10549    /// Uses a source with clearly distinct per-channel values so a
10550    /// plane-swap or layout bug surfaces immediately. Tolerance is 2^-9
10551    /// (one F16 ULP at 0.5, i.e. roughly 1/512).
10552    #[test]
10553    // `ImageProcessorConfig` carries a Linux-only `egl_display` field, so
10554    // `{ backend, ..Default::default() }` is a genuine update on Linux but
10555    // covers no remaining fields on macOS, where `clippy::needless_update`
10556    // then fires. `allow` (not `expect`) because the lint is platform-
10557    // conditional — it does not fire on Linux.
10558    #[allow(clippy::needless_update)]
10559    fn convert_f16_forced_cpu_correct() {
10560        const W: usize = 16;
10561        const H: usize = 16;
10562        const TOL: f32 = 1.0 / 512.0; // 2^-9
10563
10564        // pixel (y,x): R = 50+x, G = 100+y*8, B = 200
10565        let src = TensorDyn::image(
10566            W,
10567            H,
10568            PixelFormat::Rgba,
10569            DType::U8,
10570            Some(TensorMemory::Mem),
10571            edgefirst_tensor::CpuAccess::ReadWrite,
10572        )
10573        .unwrap();
10574        {
10575            let mut map = src.as_u8().unwrap().map().unwrap();
10576            let data = map.as_mut_slice();
10577            for y in 0..H {
10578                for x in 0..W {
10579                    let i = y * W + x;
10580                    data[i * 4] = (50 + x) as u8; // R: 50..65
10581                    data[i * 4 + 1] = (100 + y * 8) as u8; // G: 100..220
10582                    data[i * 4 + 2] = 200; // B: constant
10583                    data[i * 4 + 3] = 255;
10584                }
10585            }
10586        }
10587
10588        let mut dst = TensorDyn::image(
10589            W,
10590            H,
10591            PixelFormat::PlanarRgb,
10592            DType::F16,
10593            Some(TensorMemory::Mem),
10594            edgefirst_tensor::CpuAccess::ReadWrite,
10595        )
10596        .unwrap();
10597
10598        let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
10599            backend: ComputeBackend::Cpu,
10600            ..Default::default()
10601        })
10602        .unwrap();
10603        proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
10604            .expect("forced-CPU Rgba→PlanarRgb F16 must not error");
10605
10606        let src_map = src.as_u8().unwrap().map().unwrap();
10607        let src_bytes = src_map.as_slice();
10608        let dst_map = dst.as_f16().unwrap().map().unwrap();
10609        let dst_halfs = dst_map.as_slice();
10610
10611        let plane = W * H;
10612        assert_eq!(dst_halfs.len(), plane * 3, "wrong output element count");
10613
10614        for y in 0..H {
10615            for x in 0..W {
10616                let i = y * W + x;
10617                let r_exp = src_bytes[i * 4] as f32 / 255.0;
10618                let g_exp = src_bytes[i * 4 + 1] as f32 / 255.0;
10619                let b_exp = src_bytes[i * 4 + 2] as f32 / 255.0;
10620
10621                let r_got = dst_halfs[i].to_f32();
10622                let g_got = dst_halfs[plane + i].to_f32();
10623                let b_got = dst_halfs[2 * plane + i].to_f32();
10624
10625                assert!(
10626                    (r_got - r_exp).abs() <= TOL,
10627                    "R plane ({x},{y}): got {r_got}, expected {r_exp}"
10628                );
10629                assert!(
10630                    (g_got - g_exp).abs() <= TOL,
10631                    "G plane ({x},{y}): got {g_got}, expected {g_exp}"
10632                );
10633                assert!(
10634                    (b_got - b_exp).abs() <= TOL,
10635                    "B plane ({x},{y}): got {b_got}, expected {b_exp}"
10636                );
10637
10638                // Catch plane-swap: R and G must differ (they have different formulas).
10639                if src_bytes[i * 4] != src_bytes[i * 4 + 1] {
10640                    assert_ne!(r_got, g_got, "R and G planes must differ at ({x},{y})");
10641                }
10642            }
10643        }
10644    }
10645
10646    /// Proves the auto-chain falls through to CPU for `Rgba → Rgb F32` with
10647    /// rotation set.
10648    ///
10649    /// The GL float render path rejects any call where rotation ≠ None,
10650    /// returning an error that causes the chain to continue. Before the CPU
10651    /// float fallback this would have produced an error at the end of the
10652    /// chain; now it must reach CPU and return `Ok` with finite `[0, 1]` output.
10653    #[test]
10654    fn convert_f32_with_rotation_falls_back() {
10655        const W: usize = 16;
10656        const H: usize = 16;
10657
10658        // RGBA8 source with a known gradient (distinct per-channel values).
10659        let src = TensorDyn::image(
10660            W,
10661            H,
10662            PixelFormat::Rgba,
10663            DType::U8,
10664            Some(TensorMemory::Mem),
10665            edgefirst_tensor::CpuAccess::ReadWrite,
10666        )
10667        .unwrap();
10668        {
10669            let mut map = src.as_u8().unwrap().map().unwrap();
10670            let data = map.as_mut_slice();
10671            for y in 0..H {
10672                for x in 0..W {
10673                    let i = y * W + x;
10674                    data[i * 4] = (x * 16) as u8; // R
10675                    data[i * 4 + 1] = (y * 16) as u8; // G
10676                    data[i * 4 + 2] = 128; // B
10677                    data[i * 4 + 3] = 255;
10678                }
10679            }
10680        }
10681
10682        // Rotation swaps W and H, so dst is [W, H] (H×W output).
10683        let mut dst = TensorDyn::image(
10684            H, // dst W = src H after 90° rotation
10685            W, // dst H = src W after 90° rotation
10686            PixelFormat::Rgb,
10687            DType::F32,
10688            Some(TensorMemory::Mem),
10689            edgefirst_tensor::CpuAccess::ReadWrite,
10690        )
10691        .unwrap();
10692
10693        let mut proc = ImageProcessor::new().unwrap();
10694        let result = proc.convert(
10695            &src,
10696            &mut dst,
10697            Rotation::Clockwise90,
10698            Flip::None,
10699            Crop::default(),
10700        );
10701        assert!(
10702            result.is_ok(),
10703            "auto-chain Rgba→Rgb F32 with Rot90 must not error: {:?}",
10704            result.err()
10705        );
10706
10707        let map = dst.as_f32().unwrap().map().unwrap();
10708        let floats = map.as_slice();
10709        assert_eq!(floats.len(), H * W * 3, "unexpected output element count");
10710        for (i, &v) in floats.iter().enumerate() {
10711            assert!(
10712                v.is_finite() && (0.0..=1.0).contains(&v),
10713                "output[{i}]={v} is not finite or not in [0,1]"
10714            );
10715        }
10716    }
10717
10718    /// GL-vs-CPU identity parity for `Rgba → PlanarRgb F16`.
10719    ///
10720    /// Converts the same RGBA8 source via forced `OpenGl` and forced `Cpu`,
10721    /// then verifies the two F16 output tensors agree element-wise within
10722    /// 2^-8 (two F16 ULPs at 0.5). Skipped when OpenGL or F16 render is
10723    /// unavailable.
10724    #[test]
10725    #[cfg(all(target_os = "linux", feature = "opengl"))]
10726    fn convert_f16_gl_cpu_parity_identity() {
10727        if !is_opengl_available() {
10728            eprintln!("SKIPPED: convert_f16_gl_cpu_parity_identity - OpenGL not available");
10729            return;
10730        }
10731
10732        const W: usize = 16;
10733        const H: usize = 16;
10734        const TOL: f32 = 1.0 / 256.0; // 2^-8
10735
10736        // pixel (y,x): R = 40+x, G = 80+y*10, B = 180
10737        let src = TensorDyn::image(
10738            W,
10739            H,
10740            PixelFormat::Rgba,
10741            DType::U8,
10742            Some(TensorMemory::Mem),
10743            edgefirst_tensor::CpuAccess::ReadWrite,
10744        )
10745        .unwrap();
10746        {
10747            let mut map = src.as_u8().unwrap().map().unwrap();
10748            let data = map.as_mut_slice();
10749            for y in 0..H {
10750                for x in 0..W {
10751                    let i = y * W + x;
10752                    data[i * 4] = (40 + x) as u8; // R
10753                    data[i * 4 + 1] = (80 + y * 10) as u8; // G
10754                    data[i * 4 + 2] = 180; // B
10755                    data[i * 4 + 3] = 255;
10756                }
10757            }
10758        }
10759
10760        // GL path.
10761        let gl_result = {
10762            let mut gl_proc = match ImageProcessor::with_config(ImageProcessorConfig {
10763                backend: ComputeBackend::OpenGl,
10764                ..Default::default()
10765            }) {
10766                Ok(p) => p,
10767                Err(e) => {
10768                    eprintln!(
10769                        "SKIPPED: convert_f16_gl_cpu_parity_identity - GL backend unavailable: {e}"
10770                    );
10771                    return;
10772                }
10773            };
10774
10775            if !gl_proc.supported_render_dtypes().f16 {
10776                eprintln!("SKIPPED: convert_f16_gl_cpu_parity_identity - F16 render not supported");
10777                return;
10778            }
10779
10780            let mut dst = TensorDyn::image(
10781                W,
10782                H,
10783                PixelFormat::PlanarRgb,
10784                DType::F16,
10785                Some(TensorMemory::Mem),
10786                edgefirst_tensor::CpuAccess::ReadWrite,
10787            )
10788            .unwrap();
10789            match gl_proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default()) {
10790                Ok(()) => dst,
10791                Err(e) => {
10792                    eprintln!(
10793                        "SKIPPED: convert_f16_gl_cpu_parity_identity - GL convert failed: {e}"
10794                    );
10795                    return;
10796                }
10797            }
10798        };
10799
10800        // CPU path.
10801        let cpu_result = {
10802            let mut cpu_proc = ImageProcessor::with_config(ImageProcessorConfig {
10803                backend: ComputeBackend::Cpu,
10804                ..Default::default()
10805            })
10806            .unwrap();
10807            let mut dst = TensorDyn::image(
10808                W,
10809                H,
10810                PixelFormat::PlanarRgb,
10811                DType::F16,
10812                Some(TensorMemory::Mem),
10813                edgefirst_tensor::CpuAccess::ReadWrite,
10814            )
10815            .unwrap();
10816            cpu_proc
10817                .convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
10818                .expect("forced-CPU Rgba→PlanarRgb F16 must not error");
10819            dst
10820        };
10821
10822        // Compare element-wise.
10823        let gl_map = gl_result.as_f16().unwrap().map().unwrap();
10824        let cpu_map = cpu_result.as_f16().unwrap().map().unwrap();
10825        let gl_halfs = gl_map.as_slice();
10826        let cpu_halfs = cpu_map.as_slice();
10827
10828        assert_eq!(
10829            gl_halfs.len(),
10830            cpu_halfs.len(),
10831            "GL and CPU output sizes differ"
10832        );
10833
10834        let plane = W * H;
10835        let channel_names = ["R", "G", "B"];
10836        for (idx, (gl_h, cpu_h)) in gl_halfs.iter().zip(cpu_halfs.iter()).enumerate() {
10837            let gl_v = gl_h.to_f32();
10838            let cpu_v = cpu_h.to_f32();
10839            let err = (gl_v - cpu_v).abs();
10840            let ch = channel_names[idx / plane];
10841            let pixel = idx % plane;
10842            assert!(
10843                err <= TOL,
10844                "GL vs CPU mismatch at {ch}[{pixel}]: GL={gl_v}, CPU={cpu_v}, err={err} > tol={TOL}"
10845            );
10846        }
10847    }
10848
10849    // =========================================================================
10850    // GAP-1: supported_render_dtypes() Linux smoke test
10851    // =========================================================================
10852
10853    /// Exercises the real Linux GL path that reads `gl.supported_render_dtypes()`.
10854    /// Skipped when no GL backend is available (CI/host without a GPU).
10855    #[test]
10856    #[cfg(all(target_os = "linux", feature = "opengl"))]
10857    fn supported_render_dtypes_linux_smoke() {
10858        let proc = match ImageProcessor::new() {
10859            Ok(p) => p,
10860            Err(e) => {
10861                eprintln!("SKIPPED: supported_render_dtypes_linux_smoke — ImageProcessor::new() failed: {e}");
10862                return;
10863            }
10864        };
10865        if proc.opengl.is_none() {
10866            eprintln!("SKIPPED: supported_render_dtypes_linux_smoke — no GL backend on this host");
10867            return;
10868        }
10869        // The call must complete without panicking or deadlocking.
10870        let support = proc.supported_render_dtypes();
10871        eprintln!(
10872            "supported_render_dtypes_linux_smoke: f16={} f32={}",
10873            support.f16, support.f32
10874        );
10875        // No assertion on the specific values — they are hardware-dependent.
10876    }
10877
10878    // =========================================================================
10879    // GAP-2: F16 PlanarRgb with width NOT divisible by 4 falls back to CPU
10880    // =========================================================================
10881
10882    /// The GL float render path rejects PlanarRgb F16 destinations whose width
10883    /// is not a multiple of 4 (the packed RGBA16F swizzle trick requires W%4==0).
10884    /// The auto-chain must transparently fall through to the CPU path, which has
10885    /// no such restriction.  W=18, H=16 is chosen so W%4 == 2.
10886    #[test]
10887    fn convert_f16_pbo_non_4_aligned_width_falls_back() {
10888        const W: usize = 18; // 18 % 4 == 2 — NOT divisible by 4
10889        const H: usize = 16;
10890
10891        // RGBA8 source filled with a flat mid-grey.
10892        let src = TensorDyn::image(
10893            W,
10894            H,
10895            PixelFormat::Rgba,
10896            DType::U8,
10897            Some(TensorMemory::Mem),
10898            edgefirst_tensor::CpuAccess::ReadWrite,
10899        )
10900        .unwrap();
10901        {
10902            let mut map = src.as_u8().unwrap().map().unwrap();
10903            let data = map.as_mut_slice();
10904            for chunk in data.chunks_exact_mut(4) {
10905                chunk[0] = 128;
10906                chunk[1] = 64;
10907                chunk[2] = 200;
10908                chunk[3] = 255;
10909            }
10910        }
10911
10912        // F16 PlanarRgb destination in Mem (GL would use PBO, but we want
10913        // to exercise the fallback chain without hardware dependency).
10914        let mut dst = TensorDyn::image(
10915            W,
10916            H,
10917            PixelFormat::PlanarRgb,
10918            DType::F16,
10919            Some(TensorMemory::Mem),
10920            edgefirst_tensor::CpuAccess::ReadWrite,
10921        )
10922        .unwrap();
10923
10924        // Use the default auto-chain so the GL path can attempt and reject,
10925        // then the CPU path succeeds.
10926        let mut proc = ImageProcessor::new().unwrap();
10927        let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
10928        assert!(
10929            result.is_ok(),
10930            "auto-chain PlanarRgb F16 W%4!=0 must not error (CPU fallback): {:?}",
10931            result.err()
10932        );
10933
10934        // All output values must be finite and in [0, 1].
10935        let map = dst.as_f16().unwrap().map().unwrap();
10936        let halfs = map.as_slice();
10937        assert_eq!(halfs.len(), W * H * 3, "unexpected element count");
10938        for (i, h) in halfs.iter().enumerate() {
10939            let v = h.to_f32();
10940            assert!(
10941                v.is_finite() && (0.0..=1.0).contains(&v),
10942                "output[{i}]={v} is not finite or not in [0,1]"
10943            );
10944        }
10945    }
10946
10947    // =========================================================================
10948    // GAP-4: NV12 → Rgb F32 and NV12 → PlanarRgb F16, forced CPU
10949    // =========================================================================
10950
10951    /// CPU widen-composition: NV12 (non-RGBA source) → Rgb F32.
10952    ///
10953    /// NV12 requires a two-stage CPU conversion (NV12→Rgba then Rgba→F32)
10954    /// which was untested. A wrong intermediate format selection silently
10955    /// produces garbage. Y=128, U=V=128 → near-neutral grey → R≈G≈B≈0.5.
10956    #[test]
10957    // Linux-only `egl_display` field makes `..Default::default()` needless
10958    // on macOS only; see `convert_f16_forced_cpu_correct`.
10959    #[allow(clippy::needless_update)]
10960    fn convert_nv12_to_rgb_f32_cpu() {
10961        const W: usize = 16;
10962        const H: usize = 16; // must be even for NV12
10963
10964        // Build a valid NV12 tensor: shape [H*3/2, W], luma=128, chroma=128.
10965        let src = TensorDyn::image(
10966            W,
10967            H,
10968            PixelFormat::Nv12,
10969            DType::U8,
10970            Some(TensorMemory::Mem),
10971            edgefirst_tensor::CpuAccess::ReadWrite,
10972        )
10973        .unwrap();
10974        {
10975            let mut map = src.as_u8().unwrap().map().unwrap();
10976            map.as_mut_slice().fill(128); // Y=128, U=V=128 → neutral grey
10977        }
10978
10979        let mut dst = TensorDyn::image(
10980            W,
10981            H,
10982            PixelFormat::Rgb,
10983            DType::F32,
10984            Some(TensorMemory::Mem),
10985            edgefirst_tensor::CpuAccess::ReadWrite,
10986        )
10987        .unwrap();
10988
10989        let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
10990            backend: ComputeBackend::Cpu,
10991            ..Default::default()
10992        })
10993        .unwrap();
10994
10995        let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
10996        assert!(
10997            result.is_ok(),
10998            "forced-CPU NV12→Rgb F32 must not error: {:?}",
10999            result.err()
11000        );
11001
11002        let map = dst.as_f32().unwrap().map().unwrap();
11003        let floats = map.as_slice();
11004        assert_eq!(floats.len(), W * H * 3, "unexpected element count");
11005        for (i, &v) in floats.iter().enumerate() {
11006            assert!(
11007                v.is_finite() && (0.0..=1.0).contains(&v),
11008                "output[{i}]={v} is not finite or not in [0,1]"
11009            );
11010        }
11011        // Anti-all-zero: Y=128 → luma channel ≈ 0.5 after YUV→RGB.
11012        let non_zero = floats.iter().any(|&v| v > 0.01);
11013        assert!(non_zero, "all-zero output from NV12→Rgb F32 CPU path");
11014    }
11015
11016    /// CPU widen-composition: NV12 (non-RGBA source) → PlanarRgb F16.
11017    ///
11018    /// Same rationale as `convert_nv12_to_rgb_f32_cpu` but for F16 output.
11019    #[test]
11020    // Linux-only `egl_display` field makes `..Default::default()` needless
11021    // on macOS only; see `convert_f16_forced_cpu_correct`.
11022    #[allow(clippy::needless_update)]
11023    fn convert_nv12_to_planar_rgb_f16_cpu() {
11024        const W: usize = 16;
11025        const H: usize = 16;
11026
11027        let src = TensorDyn::image(
11028            W,
11029            H,
11030            PixelFormat::Nv12,
11031            DType::U8,
11032            Some(TensorMemory::Mem),
11033            edgefirst_tensor::CpuAccess::ReadWrite,
11034        )
11035        .unwrap();
11036        {
11037            let mut map = src.as_u8().unwrap().map().unwrap();
11038            map.as_mut_slice().fill(128);
11039        }
11040
11041        let mut dst = TensorDyn::image(
11042            W,
11043            H,
11044            PixelFormat::PlanarRgb,
11045            DType::F16,
11046            Some(TensorMemory::Mem),
11047            edgefirst_tensor::CpuAccess::ReadWrite,
11048        )
11049        .unwrap();
11050
11051        let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
11052            backend: ComputeBackend::Cpu,
11053            ..Default::default()
11054        })
11055        .unwrap();
11056
11057        let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
11058        assert!(
11059            result.is_ok(),
11060            "forced-CPU NV12→PlanarRgb F16 must not error: {:?}",
11061            result.err()
11062        );
11063
11064        let map = dst.as_f16().unwrap().map().unwrap();
11065        let halfs = map.as_slice();
11066        assert_eq!(halfs.len(), W * H * 3, "unexpected element count");
11067        for (i, h) in halfs.iter().enumerate() {
11068            let v = h.to_f32();
11069            assert!(
11070                v.is_finite() && (0.0..=1.0).contains(&v),
11071                "output[{i}]={v} is not finite or not in [0,1]"
11072            );
11073        }
11074        let non_zero = halfs.iter().any(|h| h.to_f32() > 0.01);
11075        assert!(non_zero, "all-zero output from NV12→PlanarRgb F16 CPU path");
11076    }
11077
11078    // =========================================================================
11079    // GAP-5: create_image F32 + DMA must return NotSupported
11080    // =========================================================================
11081
11082    /// `create_image_desc` without a compression request is exactly
11083    /// `create_image` (the processor's memory negotiation applies); with
11084    /// `Compression::Any` off-Android it resolves linear and counts the
11085    /// fallback in the processor-visible mirror.
11086    #[test]
11087    fn create_image_desc_negotiates_and_counts_fallbacks() {
11088        use edgefirst_tensor::{Compression, CpuAccess, ImageDesc};
11089        let proc = ImageProcessor::new().unwrap();
11090
11091        let desc =
11092            ImageDesc::new(64, 64, PixelFormat::Rgba, DType::U8).with_access(CpuAccess::ReadWrite);
11093        let plain = proc.create_image_desc(&desc).unwrap();
11094        let classic = proc
11095            .create_image(
11096                64,
11097                64,
11098                PixelFormat::Rgba,
11099                DType::U8,
11100                None,
11101                CpuAccess::ReadWrite,
11102            )
11103            .unwrap();
11104        assert_eq!(plain.memory(), classic.memory(), "same negotiation path");
11105        assert_eq!(plain.compression(), None);
11106
11107        #[cfg(not(target_os = "android"))]
11108        {
11109            let before = proc.compression_fallback_count();
11110            let desc = ImageDesc::new(64, 64, PixelFormat::Rgba, DType::U8)
11111                .with_compression(Compression::Any);
11112            let t = proc.create_image_desc(&desc).unwrap();
11113            assert_eq!(t.compression(), None, "no vendor tile scheme off-Android");
11114            assert!(
11115                proc.compression_fallback_count() > before,
11116                "Any resolving linear must count"
11117            );
11118        }
11119    }
11120
11121    /// There is no DRM FourCC for F32 images, so `create_image` with
11122    /// `TensorMemory::Dma` and `DType::F32` must return `Err(NotSupported)`.
11123    #[test]
11124    #[cfg(target_os = "linux")]
11125    fn create_image_f32_dma_rejected() {
11126        let proc = ImageProcessor::new().unwrap();
11127        let result = proc.create_image(
11128            64,
11129            64,
11130            PixelFormat::Rgb,
11131            DType::F32,
11132            Some(TensorMemory::Dma),
11133            edgefirst_tensor::CpuAccess::ReadWrite,
11134        );
11135        assert!(
11136            result.is_err(),
11137            "create_image(F32, Dma) must fail — no DRM fourcc for f32"
11138        );
11139    }
11140
11141    /// Verify that `import_image` stores the supplied `Option<Colorimetry>` on
11142    /// the returned `TensorDyn`.
11143    ///
11144    /// `import_image` requires a DMA-backed fd on Linux.  When DMA is
11145    /// unavailable we skip the DMA call but still verify the storage contract
11146    /// by inspecting `set_colorimetry` / `colorimetry` on a plain `TensorDyn`
11147    /// constructed the same way the function body does it — and we confirm via
11148    /// code-read that the new parameter is unconditionally stored.
11149    #[test]
11150    #[cfg(target_os = "linux")]
11151    fn import_image_carries_colorimetry() {
11152        use edgefirst_tensor::{ColorEncoding, ColorRange, Colorimetry, TensorMemory};
11153
11154        let expected = Colorimetry::default()
11155            .with_encoding(ColorEncoding::Bt709)
11156            .with_range(ColorRange::Limited);
11157
11158        if !is_dma_available() {
11159            // DMA unavailable on this host: exercise the storage path via
11160            // TensorDyn directly (mirrors what import_image does internally).
11161            let mut t = TensorDyn::image(
11162                8,
11163                8,
11164                PixelFormat::Rgba,
11165                DType::U8,
11166                Some(TensorMemory::Mem),
11167                edgefirst_tensor::CpuAccess::ReadWrite,
11168            )
11169            .expect("alloc");
11170            assert_eq!(t.colorimetry(), None, "colorimetry must start as None");
11171            t.set_colorimetry(Some(expected));
11172            assert_eq!(
11173                t.colorimetry(),
11174                Some(expected),
11175                "set_colorimetry must round-trip"
11176            );
11177            eprintln!("SKIPPED import_image_carries_colorimetry (DMA unavailable); storage contract verified via TensorDyn");
11178            return;
11179        }
11180
11181        // DMA is available: allocate a real DMA tensor, extract its fd, and
11182        // call import_image with an explicit Colorimetry.
11183        use edgefirst_tensor::{PlaneDescriptor, Tensor};
11184
11185        let rgba_bytes = 64 * 64 * 4; // 64×64 RGBA8
11186        let dma_tensor =
11187            Tensor::<u8>::new(&[rgba_bytes], Some(TensorMemory::Dma), Some("import_test"))
11188                .expect("dma alloc");
11189        let pd =
11190            PlaneDescriptor::new(dma_tensor.dmabuf().expect("dma fd")).expect("PlaneDescriptor");
11191
11192        let proc = ImageProcessor::new().expect("ImageProcessor");
11193        let result = proc.import_image(
11194            pd,
11195            None,
11196            64,
11197            64,
11198            PixelFormat::Rgba,
11199            DType::U8,
11200            Some(expected),
11201        );
11202        let tensor = result.expect("import_image must succeed on DMA fd");
11203        assert_eq!(
11204            tensor.colorimetry(),
11205            Some(expected),
11206            "import_image must store the supplied colorimetry on the returned TensorDyn"
11207        );
11208    }
11209}