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::{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");
36let mut src = Tensor::<u8>::image(info.width, info.height, info.format,
37                                   Some(TensorMemory::Mem))?;
38let mut decoder = ImageDecoder::new();
39src.load_image(&mut decoder, &image).expect("decode");
40// Convert the native NV12 frame to packed RGB for downstream processing.
41let mut converter = ImageProcessor::new()?;
42let mut dst = converter.create_image(640, 480, PixelFormat::Rgb, DType::U8, None)?;
43converter.convert(&src.into(), &mut dst, Rotation::None, Flip::None, Crop::default())?;
44# Ok(())
45# }
46```
47
48## Environment Variables
49The behavior of the `edgefirst_image::ImageProcessor` struct can be influenced by the
50following environment variables:
51- `EDGEFIRST_FORCE_BACKEND`: When set to `cpu`, `g2d`, or `opengl` (case-insensitive),
52  only that single backend is initialized and no fallback chain is used. If the
53  forced backend fails to initialize, an error is returned immediately. This is
54  useful for benchmarking individual backends in isolation. When this variable is
55  set, the `EDGEFIRST_DISABLE_*` variables are ignored.
56- `EDGEFIRST_DISABLE_GL`: If set to `1`, disables the use of OpenGL for image
57  conversion, forcing the use of CPU or other available hardware methods.
58- `EDGEFIRST_DISABLE_G2D`: If set to `1`, disables the use of G2D for image
59  conversion, forcing the use of CPU or other available hardware methods.
60- `EDGEFIRST_DISABLE_CPU`: If set to `1`, disables the use of CPU for image
61  conversion, forcing the use of hardware acceleration methods. If no hardware
62  acceleration methods are available, an error will be returned when attempting
63  to create an `ImageProcessor`.
64
65Additionally the TensorMemory used by default allocations can be controlled using the
66`EDGEFIRST_TENSOR_FORCE_MEM` environment variable. If set to `1`, default tensor memory
67uses system memory. This will disable the use of specialized memory regions for tensors
68and hardware acceleration. However, this will increase the performance of the CPU converter.
69*/
70#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
71
72/// Retained constructor: installs the coverage flush-on-abort handler for this
73/// crate's instrumented test binary. See `edgefirst_tensor::covguard`. Only
74/// present under coverage on Linux (`.init_array` is ELF-only; flush is Linux-only).
75#[cfg(all(coverage, target_os = "linux"))]
76#[used]
77#[link_section = ".init_array"]
78static __EDGEFIRST_COV_INSTALL: extern "C" fn() = {
79    extern "C" fn ctor() {
80        edgefirst_tensor::covguard::install();
81    }
82    ctor
83};
84
85/// Pitch alignment requirement for DMA-BUF tensors that may be imported as
86/// EGLImages by the GL backend. Mali Valhall (i.MX 95 / G310) rejects
87/// `eglCreateImageKHR` with `EGL_BAD_ALLOC` for any DMA-BUF whose row pitch
88/// is not a multiple of 64 bytes; Vivante GC7000UL (i.MX 8MP) accepts any
89/// pitch so the constant is harmless on that path. 64 is the smallest
90/// alignment that satisfies every embedded ARM GPU we ship to.
91///
92/// Applied automatically inside [`ImageProcessor::create_image`] when the
93/// allocation lands on `TensorMemory::Dma`. External callers that allocate
94/// their own DMA-BUF tensors (e.g. GStreamer plugins, video pipelines) can
95/// use [`align_width_for_gpu_pitch`] to compute a width whose resulting row
96/// stride satisfies this requirement.
97pub const GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES: usize = 64;
98
99/// Round `width` (in pixels) up so the resulting row stride
100/// `width * bpp` is a multiple of [`GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES`]
101/// AND a multiple of `bpp` (so the rounded width is an integer pixel count).
102///
103/// `bpp` must be the per-pixel byte count for the image's primary plane
104/// (e.g. 4 for RGBA8/BGRA8, 3 for RGB888, 1 for Grey/NV12-luma).
105///
106/// External callers — GStreamer plugins, video pipelines, anyone wrapping a
107/// foreign DMA-BUF — should call this when sizing the destination so that
108/// `eglCreateImageKHR` doesn't reject the import on Mali. Pre-aligned widths
109/// (640, 1280, 1920, 3008, 3840 …) round-trip unchanged; misaligned widths
110/// are bumped up to the next valid value.
111///
112/// # Overflow behaviour
113///
114/// All arithmetic is checked. If the alignment computation or the rounded
115/// width would overflow `usize`, the function logs a warning and returns the
116/// original `width` unchanged rather than wrapping or producing a smaller
117/// value. Callers can rely on the returned width being **at least** the
118/// requested width.
119///
120/// `bpp == 0` and `width == 0` short-circuit to return the input unchanged.
121///
122/// # Examples
123///
124/// ```
125/// use edgefirst_image::align_width_for_gpu_pitch;
126///
127/// // RGBA8 (bpp=4): width must round to a multiple of 16 pixels (64-byte stride).
128/// assert_eq!(align_width_for_gpu_pitch(1920, 4), 1920); // already aligned
129/// assert_eq!(align_width_for_gpu_pitch(3004, 4), 3008); // crowd.png case: +4 px
130/// assert_eq!(align_width_for_gpu_pitch(1281, 4), 1296); // +15 px
131///
132/// // RGB888 (bpp=3): width must round to a multiple of 64 pixels (192-byte stride).
133/// assert_eq!(align_width_for_gpu_pitch(640, 3), 640);
134/// assert_eq!(align_width_for_gpu_pitch(641, 3), 704);
135/// ```
136pub fn align_width_for_gpu_pitch(width: usize, bpp: usize) -> usize {
137    if bpp == 0 || width == 0 {
138        return width;
139    }
140
141    // The minimum aligned stride must be a common multiple of both the
142    // GPU's pitch alignment and the per-pixel byte count. Using the LCM
143    // guarantees the rounded stride is an integer multiple of `bpp`, so
144    // converting back to a pixel count is exact.
145    //
146    // Compute the alignment in pixels (`width_alignment`) so we never need
147    // to multiply `width * bpp`, which is the only operation that could
148    // realistically overflow for large caller-supplied widths.
149    let Some(lcm_alignment) = checked_num_integer_lcm(GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES, bpp)
150    else {
151        log::warn!(
152            "align_width_for_gpu_pitch: lcm({GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES}, {bpp}) \
153             overflows usize, returning unaligned width {width}"
154        );
155        return width;
156    };
157    if lcm_alignment == 0 {
158        return width;
159    }
160
161    debug_assert_eq!(lcm_alignment % bpp, 0);
162    let width_alignment = lcm_alignment / bpp;
163    if width_alignment == 0 {
164        return width;
165    }
166
167    let remainder = width % width_alignment;
168    if remainder == 0 {
169        return width;
170    }
171
172    let pad = width_alignment - remainder;
173    match width.checked_add(pad) {
174        Some(aligned) => aligned,
175        None => {
176            log::warn!(
177                "align_width_for_gpu_pitch: width {width} + pad {pad} overflows usize, \
178                 returning unaligned (caller should use a smaller width or pre-aligned size)"
179            );
180            width
181        }
182    }
183}
184
185/// Round `min_pitch_bytes` up to the next multiple of
186/// [`GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES`]. Returns `None` if the rounded
187/// value would overflow `usize`. Returns `Some(0)` for input 0.
188///
189/// Used internally by [`ImageProcessor::create_image`] to compute the
190/// padded row stride for DMA-backed image allocations. External callers
191/// that need pixel-counted alignment (instead of raw byte pitch) should
192/// use [`align_width_for_gpu_pitch`] instead.
193#[cfg(target_os = "linux")]
194pub(crate) fn align_pitch_bytes_to_gpu_alignment(min_pitch_bytes: usize) -> Option<usize> {
195    let alignment = GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES;
196    if min_pitch_bytes == 0 {
197        return Some(0);
198    }
199    let remainder = min_pitch_bytes % alignment;
200    if remainder == 0 {
201        return Some(min_pitch_bytes);
202    }
203    min_pitch_bytes.checked_add(alignment - remainder)
204}
205
206/// Overflow-safe least common multiple. Returns `None` when `(a / gcd) * b`
207/// would wrap.
208fn checked_num_integer_lcm(a: usize, b: usize) -> Option<usize> {
209    if a == 0 || b == 0 {
210        return Some(0);
211    }
212    let g = num_integer_gcd(a, b);
213    // a / g is exact (g divides a by definition) and at most a, so this
214    // division never panics. Only the subsequent multiply can overflow.
215    (a / g).checked_mul(b)
216}
217
218fn num_integer_gcd(a: usize, b: usize) -> usize {
219    if b == 0 {
220        a
221    } else {
222        num_integer_gcd(b, a % b)
223    }
224}
225
226/// Bytes-per-pixel for the primary plane of `format` at element size `elem`.
227/// Returns `None` for formats that don't have a single packed BPP (semi-planar
228/// chroma is handled separately, returning the luma-plane bpp).
229///
230/// External callers can use this together with [`align_width_for_gpu_pitch`]
231/// to size their own DMA-BUFs without having to remember per-format BPPs:
232///
233/// ```
234/// use edgefirst_image::{align_width_for_gpu_pitch, primary_plane_bpp};
235/// use edgefirst_tensor::PixelFormat;
236///
237/// let bpp = primary_plane_bpp(PixelFormat::Rgba, 1).unwrap();
238/// let aligned = align_width_for_gpu_pitch(3004, bpp);
239/// assert_eq!(aligned, 3008);
240/// ```
241pub fn primary_plane_bpp(format: PixelFormat, elem: usize) -> Option<usize> {
242    use edgefirst_tensor::PixelLayout;
243    match format.layout() {
244        PixelLayout::Packed => Some(format.channels() * elem),
245        PixelLayout::Planar => Some(elem),
246        // For NV12/NV16 the luma plane is single-channel so the pitch
247        // matches `elem`; the chroma plane uses the same pitch in bytes
248        // (UV is half-width but two interleaved channels = same pitch).
249        PixelLayout::SemiPlanar => Some(elem),
250        // `PixelLayout` is non-exhaustive — fall through unaligned for
251        // any future variant we don't yet recognise.
252        _ => None,
253    }
254}
255
256/// Return the GPU-aligned pitch in bytes when a DMA-backed image of
257/// `width × fmt` would need row-stride padding, or `None` when the
258/// natural pitch already satisfies `GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES`
259/// or the caller has explicitly requested non-DMA memory.
260///
261/// Mali G310 (i.MX 95) rejects `eglCreateImage` from DMA-BUFs whose
262/// `PLANE0_PITCH_EXT` is not a multiple of 64 bytes, surfacing as
263/// `EGL_BAD_ALLOC`. The `load_image_test_helper` test-only helper
264/// in this crate uses this to decide whether to allocate a tensor
265/// with padded row stride before invoking the decode path; production
266/// callers do the equivalent peek → allocate → decode dance themselves
267/// (see crate-level docs).
268#[cfg(all(target_os = "linux", test))]
269pub(crate) fn padded_dma_pitch_for(
270    fmt: PixelFormat,
271    width: usize,
272    memory: &Option<TensorMemory>,
273) -> Option<usize> {
274    // Only pad when the caller explicitly requested DMA, or when they
275    // left memory selection to the allocator AND DMA is actually
276    // available. `Tensor::image_with_stride(..., None)` always routes
277    // through DMA allocation, so treating `None` as "DMA wanted"
278    // unconditionally would convert a normally-working image load into
279    // a hard failure on systems where DMA is unavailable (sandboxed
280    // CI, missing `/dev/dma_heap`, permission-denied containers) —
281    // whereas `Tensor::image(..., None)` would have fallen back to
282    // SHM/Mem there.
283    match memory {
284        Some(TensorMemory::Dma) => {}
285        None if edgefirst_tensor::is_dma_available() => {}
286        _ => return None,
287    }
288    // Padding only applies to packed layouts — `Tensor::image_with_stride`
289    // rejects semi-planar / planar formats, and those take their own
290    // per-plane pitches on import anyway.
291    if fmt.layout() != PixelLayout::Packed {
292        return None;
293    }
294    let bpp = primary_plane_bpp(fmt, 1)?;
295    let natural = width.checked_mul(bpp)?;
296    let aligned = align_pitch_bytes_to_gpu_alignment(natural)?;
297    if aligned > natural {
298        Some(aligned)
299    } else {
300        None
301    }
302}
303
304pub use cpu::CPUProcessor;
305pub use edgefirst_codec as codec;
306
307#[cfg(test)]
308use edgefirst_decoder::ProtoLayout;
309use edgefirst_decoder::{DetectBox, ProtoData, Segmentation};
310#[doc(inline)]
311pub use edgefirst_tensor::Region;
312#[cfg(any(test, all(target_os = "linux", feature = "opengl")))]
313use edgefirst_tensor::Tensor;
314use edgefirst_tensor::{
315    DType, PixelFormat, PixelLayout, TensorDyn, TensorMemory, TensorTrait as _,
316};
317use enum_dispatch::enum_dispatch;
318pub use error::{Error, Result};
319#[cfg(target_os = "linux")]
320pub use g2d::G2DProcessor;
321#[cfg(all(any(target_os = "linux", target_os = "macos"), feature = "opengl"))]
322pub use opengl_headless::EglDisplayKind;
323#[cfg(all(any(target_os = "linux", target_os = "macos"), feature = "opengl"))]
324pub use opengl_headless::GLProcessorThreaded;
325#[cfg(all(any(target_os = "linux", target_os = "macos"), feature = "opengl"))]
326pub use opengl_headless::Int8InterpolationMode;
327#[cfg(target_os = "linux")]
328#[cfg(feature = "opengl")]
329pub use opengl_headless::{probe_egl_displays, EglDisplayInfo};
330// EGLImage cache counter snapshots (diagnostics): see
331// `GLProcessorThreaded::egl_cache_stats` and the steady-state import gate in
332// `crates/image/ARCHITECTURE.md § image.convert.gl.egl_import`.
333#[cfg(all(any(target_os = "linux", target_os = "macos"), feature = "opengl"))]
334pub use opengl_headless::{CacheStats, GlCacheStats};
335use std::{fmt::Display, time::Instant};
336
337mod colorimetry;
338mod cpu;
339mod error;
340mod g2d;
341#[path = "gl/mod.rs"]
342mod opengl_headless;
343
344// Use `edgefirst_tensor::PixelFormat` variants (Rgb, Rgba, Grey, etc.) and
345// `TensorDyn` / `Tensor<u8>` with `.format()` metadata instead.
346
347#[derive(Debug, Clone, Copy, PartialEq, Eq)]
348pub enum Rotation {
349    None = 0,
350    Clockwise90 = 1,
351    Rotate180 = 2,
352    CounterClockwise90 = 3,
353}
354impl Rotation {
355    /// Creates a Rotation enum from an angle in degrees. The angle must be a
356    /// multiple of 90.
357    ///
358    /// # Panics
359    /// Panics if the angle is not a multiple of 90.
360    ///
361    /// # Examples
362    /// ```rust
363    /// # use edgefirst_image::Rotation;
364    /// let rotation = Rotation::from_degrees_clockwise(270);
365    /// assert_eq!(rotation, Rotation::CounterClockwise90);
366    /// ```
367    pub fn from_degrees_clockwise(angle: usize) -> Rotation {
368        match angle.rem_euclid(360) {
369            0 => Rotation::None,
370            90 => Rotation::Clockwise90,
371            180 => Rotation::Rotate180,
372            270 => Rotation::CounterClockwise90,
373            _ => panic!("rotation angle is not a multiple of 90"),
374        }
375    }
376}
377
378#[derive(Debug, Clone, Copy, PartialEq, Eq)]
379pub enum Flip {
380    None = 0,
381    Vertical = 1,
382    Horizontal = 2,
383}
384
385/// Controls how the color palette index is chosen for each detected object.
386#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
387pub enum ColorMode {
388    /// Color is chosen by object class label (`det.label`). Default.
389    ///
390    /// Preserves backward compatibility and is correct for semantic
391    /// segmentation where colors carry class meaning.
392    #[default]
393    Class,
394    /// Color is chosen by instance order (loop index, zero-based).
395    ///
396    /// Each detected object gets a unique color regardless of class,
397    /// useful for instance segmentation.
398    Instance,
399    /// Color is chosen by track ID (future use; currently behaves like
400    /// [`Instance`](Self::Instance)).
401    Track,
402}
403
404impl ColorMode {
405    /// Return the palette index for a detection given its loop index and label.
406    #[inline]
407    pub fn index(self, idx: usize, label: usize) -> usize {
408        match self {
409            ColorMode::Class => label,
410            ColorMode::Instance | ColorMode::Track => idx,
411        }
412    }
413}
414
415/// Controls the resolution and coordinate frame of masks produced by
416/// [`ImageProcessor::materialize_masks`].
417///
418/// - [`Proto`](Self::Proto) returns per-detection tiles at proto-plane
419///   resolution (e.g. 48×32 u8 for a typical COCO bbox on a 160×160 proto
420///   plane). This is the historical behavior of `materialize_masks` and the
421///   fastest path because no upsample runs inside HAL. Mask values are
422///   continuous sigmoid output quantized to `uint8 [0, 255]`.
423/// - [`Scaled`](Self::Scaled) returns per-detection tiles at caller-specified
424///   pixel resolution by upsampling the full proto plane once and cropping by
425///   bbox after sigmoid. The upsample uses bilinear interpolation with
426///   edge-clamp sampling — semantically equivalent to Ultralytics'
427///   `process_masks_retina` reference. When a `letterbox` is also passed to
428///   [`materialize_masks`], the inverse letterbox transform is applied during
429///   the upsample so mask pixels land in original-content coordinates
430///   (drop-in for overlay on the original image). Mask values are binary
431///   `uint8 {0, 255}` after thresholding sigmoid > 0.5 — interchangeable
432///   with `Proto` output via the same `> 127` test.
433///
434/// [`materialize_masks`]: ImageProcessor::materialize_masks
435#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
436pub enum MaskResolution {
437    /// Per-detection tile at proto-plane resolution (default).
438    #[default]
439    Proto,
440    /// Per-detection tile at `(width, height)` pixel resolution in the
441    /// coordinate frame determined by the `letterbox` parameter of
442    /// [`ImageProcessor::materialize_masks`].
443    Scaled {
444        /// Target pixel width of the output coordinate frame.
445        width: u32,
446        /// Target pixel height of the output coordinate frame.
447        height: u32,
448    },
449}
450
451/// Options for mask overlay rendering.
452///
453/// Controls how segmentation masks are composited onto the destination image:
454/// - `background`: when set, the background image is drawn first and masks
455///   are composited over it (result written to `dst`). When `None`, `dst` is
456///   cleared to `0x00000000` (fully transparent) before masks are drawn.
457///   **`dst` is always fully overwritten — its prior contents are never
458///   preserved.** Callers who used to pre-load an image into `dst` before
459///   calling `draw_decoded_masks` / `draw_proto_masks` must now supply that
460///   image via `background` instead (behaviour changed in v0.16.4).
461/// - `opacity`: scales the alpha of rendered mask colors. `1.0` (default)
462///   preserves the class color's alpha unchanged; `0.5` makes masks
463///   semi-transparent.
464/// - `color_mode`: controls whether colors are assigned by class label,
465///   instance index, or track ID. Defaults to [`ColorMode::Class`].
466#[derive(Debug, Clone, Copy)]
467pub struct MaskOverlay<'a> {
468    /// Compositing source image. Must have the same dimensions and pixel
469    /// format as `dst`. When `Some`, the output is `background + masks`.
470    /// When `None`, `dst` is cleared to `0x00000000` before masks are drawn.
471    pub background: Option<&'a TensorDyn>,
472    pub opacity: f32,
473    /// Normalized letterbox region `[xmin, ymin, xmax, ymax]` in model-input
474    /// space that contains actual image content (the rest is padding).
475    ///
476    /// When set, bounding boxes and mask coordinates from the decoder (which
477    /// are in model-input normalized space) are mapped back to the original
478    /// image coordinate space before rendering.
479    ///
480    /// Use [`with_letterbox_crop`](Self::with_letterbox_crop) to compute this
481    /// from the [`Crop`] that was used in the model input [`convert`](crate::ImageProcessorTrait::convert) call.
482    pub letterbox: Option<[f32; 4]>,
483    pub color_mode: ColorMode,
484}
485
486impl Default for MaskOverlay<'_> {
487    fn default() -> Self {
488        Self {
489            background: None,
490            opacity: 1.0,
491            letterbox: None,
492            color_mode: ColorMode::Class,
493        }
494    }
495}
496
497impl<'a> MaskOverlay<'a> {
498    pub fn new() -> Self {
499        Self::default()
500    }
501
502    /// Set the compositing source image.
503    ///
504    /// `bg` must have the same dimensions and pixel format as the `dst` passed
505    /// to [`draw_decoded_masks`](crate::ImageProcessorTrait::draw_decoded_masks) /
506    /// [`draw_proto_masks`](crate::ImageProcessorTrait::draw_proto_masks).
507    /// The output will be `bg + masks`. Without a background, `dst` is cleared
508    /// to `0x00000000`.
509    pub fn with_background(mut self, bg: &'a TensorDyn) -> Self {
510        self.background = Some(bg);
511        self
512    }
513
514    pub fn with_opacity(mut self, opacity: f32) -> Self {
515        self.opacity = opacity.clamp(0.0, 1.0);
516        self
517    }
518
519    pub fn with_color_mode(mut self, mode: ColorMode) -> Self {
520        self.color_mode = mode;
521        self
522    }
523
524    /// Set the letterbox transform from the [`Crop`] used when preparing the
525    /// model input, so that bounding boxes and masks are correctly mapped back
526    /// to the original image coordinate space during rendering.
527    ///
528    /// Pass the same `crop` that was given to
529    /// [`convert`](crate::ImageProcessorTrait::convert) along with the model
530    /// input dimensions (`model_w` × `model_h`).
531    ///
532    /// Has no effect when `crop.dst_rect` is `None` (no letterbox applied).
533    pub fn with_letterbox_crop(
534        mut self,
535        crop: &Crop,
536        src_w: usize,
537        src_h: usize,
538        model_w: usize,
539        model_h: usize,
540    ) -> Self {
541        // The letterbox placement is resolved from the same source/destination
542        // dimensions `convert()` used, so the inverse map matches the render.
543        if let Ok(resolved) = crop.resolve(src_w, src_h, model_w, model_h) {
544            if let Some(r) = resolved.dst_rect {
545                self.letterbox = Some([
546                    r.left as f32 / model_w as f32,
547                    r.top as f32 / model_h as f32,
548                    (r.left + r.width) as f32 / model_w as f32,
549                    (r.top + r.height) as f32 / model_h as f32,
550                ]);
551            }
552        }
553        self
554    }
555}
556
557/// Apply the inverse letterbox transform to a bounding box.
558///
559/// `letterbox` is `[lx0, ly0, lx1, ly1]` — the normalized region of the model
560/// input that contains actual image content (output of
561/// [`MaskOverlay::with_letterbox_crop`]).
562///
563/// Converts model-input-normalized coords to output-image-normalized coords,
564/// clamped to `[0.0, 1.0]`. Also canonicalises the bbox (ensures xmin ≤ xmax).
565#[inline]
566fn unletter_bbox(bbox: DetectBox, lb: [f32; 4]) -> DetectBox {
567    let b = bbox.bbox.to_canonical();
568    let [lx0, ly0, lx1, ly1] = lb;
569    let inv_w = if lx1 > lx0 { 1.0 / (lx1 - lx0) } else { 1.0 };
570    let inv_h = if ly1 > ly0 { 1.0 / (ly1 - ly0) } else { 1.0 };
571    DetectBox {
572        bbox: edgefirst_decoder::BoundingBox {
573            xmin: ((b.xmin - lx0) * inv_w).clamp(0.0, 1.0),
574            ymin: ((b.ymin - ly0) * inv_h).clamp(0.0, 1.0),
575            xmax: ((b.xmax - lx0) * inv_w).clamp(0.0, 1.0),
576            ymax: ((b.ymax - ly0) * inv_h).clamp(0.0, 1.0),
577        },
578        ..bbox
579    }
580}
581
582/// How a source is fit into the requested destination shape.
583#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
584pub enum Fit {
585    /// Stretch the source (or source crop) to fill the whole destination.
586    #[default]
587    Stretch,
588    /// Preserve the *source* aspect ratio, centring it in the destination and
589    /// padding the remainder with `pad` (RGBA — e.g. `[114, 114, 114, 255]` for
590    /// YOLO-style preprocessing).
591    Letterbox { pad: [u8; 4] },
592}
593
594/// Source-side convert geometry: which sub-rectangle of the source to sample
595/// (`source`) and how to fit it into the destination (`fit`). Destination
596/// *placement* is the destination itself — a tensor, or a [`Region`] view /
597/// `batch` tile of one — not a field here.
598#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
599pub struct Crop {
600    /// Sub-rectangle of the source to sample. `None` samples the whole source.
601    pub source: Option<Region>,
602    /// How the source is fit into the destination shape.
603    pub fit: Fit,
604}
605
606impl Crop {
607    /// A no-op crop: whole source, stretched to fill the whole destination.
608    pub fn new() -> Self {
609        Self::default()
610    }
611
612    /// Alias for [`Crop::new`] — whole source, stretch to fill.
613    pub fn no_crop() -> Self {
614        Self::default()
615    }
616
617    /// Letterbox fit: preserve the source aspect ratio, padding the remainder
618    /// with `pad` (RGBA).
619    pub fn letterbox(pad: [u8; 4]) -> Self {
620        Self {
621            source: None,
622            fit: Fit::Letterbox { pad },
623        }
624    }
625
626    /// Sample only `source` of the input (builder).
627    pub fn with_source(mut self, source: Option<Region>) -> Self {
628        self.source = source;
629        self
630    }
631
632    /// Set the fit mode (builder).
633    pub fn with_fit(mut self, fit: Fit) -> Self {
634        self.fit = fit;
635        self
636    }
637
638    /// Resolve to the effective backend geometry for a `src_w`×`src_h` source
639    /// and `dst_w`×`dst_h` destination: the source sampling rect, the
640    /// destination placement rect (`None` = whole destination), and the pad
641    /// colour. **The single place letterbox placement is computed** — every
642    /// backend consumes the resolved rects rather than re-deriving them.
643    pub(crate) fn resolve(
644        &self,
645        src_w: usize,
646        src_h: usize,
647        dst_w: usize,
648        dst_h: usize,
649    ) -> Result<ResolvedCrop, Error> {
650        let src_rect = self.source.map(region_to_rect);
651        // The letterbox aspect uses the *effective* source content — the source
652        // crop when set, else the full source.
653        let (sw, sh) = match self.source {
654            Some(r) => (r.width, r.height),
655            None => (src_w, src_h),
656        };
657        let resolved = match self.fit {
658            Fit::Stretch => ResolvedCrop {
659                src_rect,
660                dst_rect: None,
661                dst_color: None,
662            },
663            Fit::Letterbox { pad } => ResolvedCrop {
664                src_rect,
665                dst_rect: Some(letterbox_rect(sw, sh, dst_w, dst_h)),
666                dst_color: Some(pad),
667            },
668        };
669        resolved.check_crop_dims(src_w, src_h, dst_w, dst_h)?;
670        Ok(resolved)
671    }
672
673    /// Validate against `TensorDyn` source and destination dimensions.
674    pub fn check_crop_dyn(
675        &self,
676        src: &edgefirst_tensor::TensorDyn,
677        dst: &edgefirst_tensor::TensorDyn,
678    ) -> Result<(), Error> {
679        self.resolve(
680            src.width().unwrap_or(0),
681            src.height().unwrap_or(0),
682            dst.width().unwrap_or(0),
683            dst.height().unwrap_or(0),
684        )
685        .map(|_| ())
686    }
687}
688
689/// Resolved crop geometry consumed by the backends. Produced by
690/// [`Crop::resolve`]; the backends read these fields directly (the same shape
691/// the public `Crop` carried before destination placement moved to the view).
692#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
693pub(crate) struct ResolvedCrop {
694    pub(crate) src_rect: Option<Rect>,
695    pub(crate) dst_rect: Option<Rect>,
696    pub(crate) dst_color: Option<[u8; 4]>,
697}
698
699impl ResolvedCrop {
700    /// A no-op resolved crop (whole source → whole destination, no pad).
701    #[allow(dead_code)] // used by unit tests and the batch render paths
702    pub(crate) fn no_crop() -> Self {
703        Self::default()
704    }
705
706    /// Validate the resolved rects against explicit dimensions.
707    pub(crate) fn check_crop_dims(
708        &self,
709        src_w: usize,
710        src_h: usize,
711        dst_w: usize,
712        dst_h: usize,
713    ) -> Result<(), Error> {
714        let src_ok = self
715            .src_rect
716            .is_none_or(|r| r.left + r.width <= src_w && r.top + r.height <= src_h);
717        let dst_ok = self
718            .dst_rect
719            .is_none_or(|r| r.left + r.width <= dst_w && r.top + r.height <= dst_h);
720        match (src_ok, dst_ok) {
721            (true, true) => Ok(()),
722            (true, false) => Err(Error::CropInvalid(format!(
723                "Dest crop invalid: {:?}",
724                self.dst_rect
725            ))),
726            (false, true) => Err(Error::CropInvalid(format!(
727                "Src crop invalid: {:?}",
728                self.src_rect
729            ))),
730            (false, false) => Err(Error::CropInvalid(format!(
731                "Dest and Src crop invalid: {:?} {:?}",
732                self.dst_rect, self.src_rect
733            ))),
734        }
735    }
736}
737
738/// Convert a pixel [`Region`] to the internal [`Rect`] placement type.
739fn region_to_rect(r: Region) -> Rect {
740    Rect {
741        left: r.x,
742        top: r.y,
743        width: r.width,
744        height: r.height,
745    }
746}
747
748/// Centred aspect-preserving placement of an `sw`×`sh` source within a `dw`×`dh`
749/// destination — the canonical letterbox rectangle (one home, replacing the
750/// per-caller `calculate_letterbox` copies).
751fn letterbox_rect(sw: usize, sh: usize, dw: usize, dh: usize) -> Rect {
752    if sw == 0 || sh == 0 {
753        return Rect::new(0, 0, dw, dh);
754    }
755    let src_aspect = sw as f64 / sh as f64;
756    let dst_aspect = dw as f64 / dh as f64;
757    let (new_w, new_h) = if src_aspect > dst_aspect {
758        (dw, ((dw as f64 / src_aspect).round() as usize).max(1))
759    } else {
760        (((dh as f64 * src_aspect).round() as usize).max(1), dh)
761    };
762    let left = dw.saturating_sub(new_w) / 2;
763    let top = dh.saturating_sub(new_h) / 2;
764    Rect::new(left, top, new_w, new_h)
765}
766
767/// Internal placement rectangle (top-left + size). The **public** pixel
768/// sub-region type is [`Region`] (re-exported from `edgefirst-tensor`);
769/// `Rect` is the crate-private resolved-placement representation used by the
770/// CPU/G2D/GL backends after [`Crop::resolve`].
771#[derive(Debug, Clone, Copy, PartialEq, Eq)]
772pub(crate) struct Rect {
773    pub left: usize,
774    pub top: usize,
775    pub width: usize,
776    pub height: usize,
777}
778
779impl Rect {
780    // Creates a new Rect with the specified left, top, width, and height.
781    pub fn new(left: usize, top: usize, width: usize, height: usize) -> Self {
782        Self {
783            left,
784            top,
785            width,
786            height,
787        }
788    }
789}
790
791#[enum_dispatch(ImageProcessor)]
792pub trait ImageProcessorTrait {
793    /// Converts the source image to the destination image format and size. The
794    /// image is cropped first, then flipped, then rotated
795    ///
796    /// # Arguments
797    ///
798    /// * `dst` - The destination image to be converted to.
799    /// * `src` - The source image to convert from.
800    /// * `rotation` - The rotation to apply to the destination image.
801    /// * `flip` - Flips the image
802    /// * `crop` - An optional rectangle specifying the area to crop from the
803    ///   source image
804    ///
805    /// # Returns
806    ///
807    /// A `Result` indicating success or failure of the conversion.
808    fn convert(
809        &mut self,
810        src: &TensorDyn,
811        dst: &mut TensorDyn,
812        rotation: Rotation,
813        flip: Flip,
814        crop: Crop,
815    ) -> Result<()>;
816
817    /// Draw pre-decoded detection boxes and segmentation masks onto `dst`.
818    ///
819    /// Supports two segmentation modes based on the mask channel count:
820    /// - **Instance segmentation** (`C=1`): one `Segmentation` per detection,
821    ///   `segmentation` and `detect` are zipped.
822    /// - **Semantic segmentation** (`C>1`): a single `Segmentation` covering
823    ///   all classes; only the first element is used.
824    ///
825    /// # Format requirements
826    ///
827    /// - CPU backend: `dst` must be `RGBA` or `RGB`.
828    /// - OpenGL backend: `dst` must be `RGBA`, `BGRA`, or `RGB`.
829    /// - G2D backend: only produces the base frame (empty detections);
830    ///   returns `NotImplemented` when any detection or segmentation is
831    ///   supplied.
832    ///
833    /// # Output contract
834    ///
835    /// This function always fully writes `dst` — it never relies on the
836    /// caller having pre-cleared the destination. The four cases are:
837    ///
838    /// | detections | background | output                              |
839    /// |------------|------------|-------------------------------------|
840    /// | none       | none       | dst cleared to `0x00000000`         |
841    /// | none       | set        | dst ← background                    |
842    /// | set        | none       | masks drawn over cleared dst        |
843    /// | set        | set        | masks drawn over background         |
844    ///
845    /// Each backend implements this with its native primitives: G2D uses
846    /// `g2d_clear` / `g2d_blit`, OpenGL uses `glClear` / DMA-BUF GPU blit
847    /// plus the mask program, and CPU uses direct buffer fill / memcpy as
848    /// the terminal fallback. CPU-memcpy of DMA buffers is avoided on the
849    /// accelerated paths.
850    ///
851    /// An empty `segmentation` slice is valid — only bounding boxes are drawn.
852    ///
853    /// `overlay` controls compositing: `background` is the compositing source
854    /// (must match `dst` in size and format); `opacity` scales mask alpha.
855    ///
856    /// # Buffer aliasing
857    ///
858    /// `dst` and `overlay.background` must reference **distinct underlying
859    /// buffers**. An aliased pair returns [`Error::AliasedBuffers`] without
860    /// dispatching to any backend — the GL path would otherwise read and
861    /// write the same texture in a single draw, which is undefined behaviour
862    /// on most drivers. Aliasing is detected via
863    /// [`TensorDyn::aliases`](edgefirst_tensor::TensorDyn::aliases), which
864    /// catches both shared-allocation clones and separate imports over the
865    /// same dmabuf fd.
866    ///
867    /// # Migration from v0.16.3 and earlier
868    ///
869    /// Prior to v0.16.4 the call silently preserved `dst`'s contents on empty
870    /// detections. That invariant no longer holds — `dst` is always fully
871    /// written. Callers who pre-loaded an image into `dst` before calling this
872    /// function must now pass that image via `overlay.background` instead.
873    fn draw_decoded_masks(
874        &mut self,
875        dst: &mut TensorDyn,
876        detect: &[DetectBox],
877        segmentation: &[Segmentation],
878        overlay: MaskOverlay<'_>,
879    ) -> Result<()>;
880
881    /// Draw masks from proto data onto image (fused decode+draw).
882    ///
883    /// For YOLO segmentation models, this avoids materializing intermediate
884    /// `Array3<u8>` masks. The `ProtoData` contains mask coefficients and the
885    /// prototype tensor; the renderer computes `mask_coeff @ protos` directly
886    /// at the output resolution using bilinear sampling.
887    ///
888    /// `detect` and `proto_data.mask_coefficients` must have the same length
889    /// (enforced by zip — excess entries are silently ignored). An empty
890    /// `detect` slice is valid and produces the base frame — cleared or
891    /// background-blitted — via the selected backend's native primitive.
892    ///
893    /// # Format requirements and output contract
894    ///
895    /// Same as [`draw_decoded_masks`](Self::draw_decoded_masks), including
896    /// the "always fully writes dst" guarantee across all four
897    /// detection/background combinations.
898    ///
899    /// `overlay` controls compositing — see [`draw_decoded_masks`](Self::draw_decoded_masks).
900    fn draw_proto_masks(
901        &mut self,
902        dst: &mut TensorDyn,
903        detect: &[DetectBox],
904        proto_data: &ProtoData,
905        overlay: MaskOverlay<'_>,
906    ) -> Result<()>;
907
908    /// Sets the colors used for rendering segmentation masks. Up to 20 colors
909    /// can be set.
910    fn set_class_colors(&mut self, colors: &[[u8; 4]]) -> Result<()>;
911
912    /// Like [`convert`](Self::convert), but does not wait for the GPU to finish.
913    ///
914    /// This is the batch-preprocessing primitive: a caller renders `N` tiles
915    /// into one batched destination by looping
916    /// `convert_deferred(&src[n], &mut dst.batch(n)?, …)` and then calling
917    /// [`flush`](Self::flush) **once**. On the OpenGL backend every deferred
918    /// convert into sibling views of one buffer shares a single EGLImage import
919    /// (the tile is a `glViewport`/`glScissor` ROI into the parent) and skips the
920    /// per-tile `glFinish`; `flush` then issues a single GPU sync. The result of
921    /// a deferred convert is **not** safe to read on the CPU (or map via CUDA)
922    /// until `flush` returns.
923    ///
924    /// The default implementation is eager — it simply calls
925    /// [`convert`](Self::convert), so CPU/G2D and any backend without a deferred
926    /// fast path remain correct (each call completes synchronously and `flush`
927    /// is a no-op).
928    fn convert_deferred(
929        &mut self,
930        src: &TensorDyn,
931        dst: &mut TensorDyn,
932        rotation: Rotation,
933        flip: Flip,
934        crop: Crop,
935    ) -> Result<()> {
936        self.convert(src, dst, rotation, flip, crop)
937    }
938
939    /// Complete all work enqueued by [`convert_deferred`](Self::convert_deferred)
940    /// since the last flush, issuing a single GPU synchronization.
941    ///
942    /// After this returns, every deferred destination is finished and safe to
943    /// read back or `cuda_map`. Backends with no deferred path (the default)
944    /// return `Ok(())` immediately, since their converts already completed.
945    fn flush(&mut self) -> Result<()> {
946        Ok(())
947    }
948}
949
950/// Configuration for [`ImageProcessor`] construction.
951///
952/// Use with [`ImageProcessor::with_config`] to override the default EGL
953/// display auto-detection and backend selection. The default configuration
954/// preserves the existing auto-detection behaviour.
955#[derive(Debug, Clone, Default)]
956pub struct ImageProcessorConfig {
957    /// Force OpenGL to use this EGL display type instead of auto-detecting.
958    ///
959    /// When `None`, the processor probes displays in priority order: GBM,
960    /// PlatformDevice, Default. Use [`probe_egl_displays`] to discover
961    /// which displays are available on the current system.
962    ///
963    /// Ignored when `EDGEFIRST_DISABLE_GL=1` is set, and on macOS
964    /// (ANGLE/Metal is the only display there; a `Some` value logs a
965    /// debug note and is otherwise ignored).
966    #[cfg(all(any(target_os = "linux", target_os = "macos"), feature = "opengl"))]
967    pub egl_display: Option<EglDisplayKind>,
968
969    /// Preferred compute backend.
970    ///
971    /// When set to a specific backend (not [`ComputeBackend::Auto`]), the
972    /// processor initializes that backend with no fallback — returns an error if the conversion is not supported.
973    /// This takes precedence over `EDGEFIRST_FORCE_BACKEND` and the
974    /// `EDGEFIRST_DISABLE_*` environment variables.
975    ///
976    /// - [`ComputeBackend::OpenGl`]: init OpenGL + CPU, skip G2D
977    /// - [`ComputeBackend::G2d`]: init G2D + CPU, skip OpenGL
978    /// - [`ComputeBackend::Cpu`]: init CPU only
979    /// - [`ComputeBackend::Auto`]: existing env-var-driven selection
980    pub backend: ComputeBackend,
981
982    /// Colorimetry/performance trade-off for `convert()` (see
983    /// [`ColorimetryMode`]). Defaults to [`ColorimetryMode::Fast`]. The
984    /// `EDGEFIRST_COLORIMETRY` environment variable (`fast` | `exact`)
985    /// overrides this setting when present.
986    pub colorimetry: ColorimetryMode,
987}
988
989/// How `convert()` trades colorimetric exactness against speed on platforms
990/// where the exact path is expensive.
991///
992/// Today this affects one decision: NV12 sources on Vivante GC7000UL
993/// (i.MX 8M Plus), where the hardware external sampler converts ~12× faster
994/// than the colorimetry-exact in-shader matrix (2.5 ms vs 29 ms at 720p)
995/// but applies the driver's fixed BT.601-limited matrix regardless of the
996/// source's tagged colorimetry. Platforms where the exact path is already
997/// the fastest correct path (Mali, V3D, Tegra, ANGLE) behave identically in
998/// both modes.
999///
1000/// Override at runtime with `EDGEFIRST_COLORIMETRY=fast|exact` (takes
1001/// precedence over the config field), or per-source by forcing a path with
1002/// `EDGEFIRST_NV_CONVERT_PATH`.
1003#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1004pub enum ColorimetryMode {
1005    /// Prefer the fastest path whose output is correct-enough video RGB
1006    /// (default; issue #106 policy). On Vivante, NV12 takes the hardware
1007    /// sampler even when the source is not BT.601-limited.
1008    #[default]
1009    Fast,
1010    /// Prefer bit-exact colorimetry everywhere: the fast path is used only
1011    /// when it matches the source's resolved (encoding, range) exactly.
1012    Exact,
1013}
1014
1015/// Compute backend selection for [`ImageProcessor`].
1016///
1017/// Use with [`ImageProcessorConfig::backend`] to select which backend the
1018/// processor should prefer. When a specific backend is selected, the
1019/// processor initializes that backend plus CPU as a fallback. When `Auto`
1020/// is used, the existing environment-variable-driven selection applies.
1021#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1022pub enum ComputeBackend {
1023    /// Auto-detect based on available hardware and environment variables.
1024    #[default]
1025    Auto,
1026    /// CPU-only processing (no hardware acceleration).
1027    Cpu,
1028    /// Prefer G2D hardware blitter (+ CPU fallback).
1029    G2d,
1030    /// Prefer OpenGL ES (+ CPU fallback).
1031    OpenGl,
1032}
1033
1034/// Backend forced via the `EDGEFIRST_FORCE_BACKEND` environment variable
1035/// or [`ImageProcessorConfig::backend`].
1036///
1037/// When set, the [`ImageProcessor`] only initializes and dispatches to the
1038/// selected backend — no fallback chain is used.
1039#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1040pub(crate) enum ForcedBackend {
1041    Cpu,
1042    G2d,
1043    OpenGl,
1044}
1045
1046/// Reports which float-color-buffer extensions the GPU backend detected.
1047/// Returned by [`ImageProcessor::supported_render_dtypes`]; the two flags
1048/// are independent.
1049///
1050/// **Linux:** reflects the real probe results from `GL_EXT_color_buffer_half_float`
1051/// and `GL_EXT_color_buffer_float`. On V3D (RPi 5) and Mali-G310 (i.MX 95)
1052/// both flags are typically `true`; on Vivante GC7000UL both are forced
1053/// `false` (float readback measured 170–320 ms — disabled). Tegra Orin
1054/// exposes both via PBO; the flags match the GPU report.
1055///
1056/// **macOS (ANGLE):** `f16 == true` gates the RGBA16F-packed IOSurface
1057/// path for F16 `PlanarRgb` destinations. `f32` reflects the GL
1058/// extension probe but is not actionable — ANGLE's
1059/// `EGL_ANGLE_iosurface_client_buffer` rejects every `(GL_FLOAT, *)`
1060/// combination with `EGL_BAD_ATTRIBUTE`, so there is no F32 IOSurface
1061/// path.
1062///
1063/// Regardless of these flags, [`ImageProcessor::convert`] never returns
1064/// an error due to float capability — it falls back to CPU when the GPU
1065/// path is unavailable.
1066#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1067pub struct RenderDtypeSupport {
1068    /// `GL_EXT_color_buffer_float` is available on the current GPU.
1069    ///
1070    /// On Linux, `true` enables F32 `Rgb` NHWC PBO readback. On macOS
1071    /// this flag is informational only — no F32 IOSurface path exists.
1072    pub f32: bool,
1073    /// `GL_EXT_color_buffer_half_float` is available on the current GPU.
1074    ///
1075    /// On Linux, `true` enables F16 `PlanarRgb` NCHW PBO readback and,
1076    /// on V3D/Mali, zero-copy DMA-BUF render. On macOS, `true` enables
1077    /// F16 `PlanarRgb` via RGBA16F-packed IOSurface (zero-copy).
1078    pub f16: bool,
1079}
1080
1081/// Returns `true` when a float PBO destination should be attempted for `dtype`.
1082///
1083/// Only F16 and F32 are eligible, and only when the corresponding flag in
1084/// `support` is set. U8/I8 and all other dtypes return `false` — they are
1085/// handled by the existing `dtype.size() == 1` PBO gate.
1086///
1087/// Linux-only: the float PBO readback path is the Linux GL backend's
1088/// mechanism; macOS routes F16 through the RGBA16F-packed IOSurface
1089/// instead and never calls this. The sole runtime caller in
1090/// `create_image` is `cfg(all(target_os = "linux", feature = "opengl"))`,
1091/// so leaving this ungated makes it dead code on macOS under
1092/// `-D warnings`. Its unit test (`float_pbo_eligibility`) carries the
1093/// matching gate.
1094#[cfg(all(target_os = "linux", feature = "opengl"))]
1095pub(crate) fn float_pbo_eligible(dtype: DType, support: RenderDtypeSupport) -> bool {
1096    match dtype {
1097        DType::F16 => support.f16,
1098        DType::F32 => support.f32,
1099        _ => false,
1100    }
1101}
1102
1103/// Image converter that uses available hardware acceleration or CPU as a
1104/// fallback.
1105#[derive(Debug)]
1106pub struct ImageProcessor {
1107    /// CPU-based image converter as a fallback. This is only None if the
1108    /// EDGEFIRST_DISABLE_CPU environment variable is set.
1109    pub cpu: Option<CPUProcessor>,
1110
1111    #[cfg(target_os = "linux")]
1112    /// G2D-based image converter for Linux systems. This is only available if
1113    /// the EDGEFIRST_DISABLE_G2D environment variable is not set and libg2d.so
1114    /// is available.
1115    pub g2d: Option<G2DProcessor>,
1116    #[cfg(target_os = "linux")]
1117    #[cfg(feature = "opengl")]
1118    /// OpenGL-based image converter for Linux systems. This is only available
1119    /// if the EDGEFIRST_DISABLE_GL environment variable is not set and OpenGL
1120    /// ES is available.
1121    pub opengl: Option<GLProcessorThreaded>,
1122    #[cfg(target_os = "macos")]
1123    #[cfg(feature = "opengl")]
1124    /// OpenGL-based image converter for macOS via ANGLE + IOSurface —
1125    /// the same unified `GLProcessorThreaded` engine as Linux (its
1126    /// worker owns a per-processor ANGLE context). Available when
1127    /// ANGLE's libEGL.dylib can be loaded (see README.md § macOS GPU
1128    /// Acceleration).
1129    pub opengl: Option<GLProcessorThreaded>,
1130
1131    /// When set, only the specified backend is used — no fallback chain.
1132    pub(crate) forced_backend: Option<ForcedBackend>,
1133}
1134
1135unsafe impl Send for ImageProcessor {}
1136unsafe impl Sync for ImageProcessor {}
1137
1138impl ImageProcessor {
1139    /// Creates a new `ImageProcessor` instance, initializing available
1140    /// hardware converters based on the system capabilities and environment
1141    /// variables.
1142    ///
1143    /// # Examples
1144    /// ```rust,no_run
1145    /// # use edgefirst_image::{ImageProcessor, Rotation, Flip, Crop, ImageProcessorTrait};
1146    /// # use edgefirst_codec::{peek_info, ImageDecoder, ImageLoad};
1147    /// # use edgefirst_tensor::{PixelFormat, DType, Tensor, TensorMemory};
1148    /// # fn main() -> Result<(), edgefirst_image::Error> {
1149    /// let image = std::fs::read("zidane.jpg")?;
1150    /// // The codec emits the source's native format (a colour JPEG decodes to
1151    /// // NV12) and configures the destination tensor during the decode.
1152    /// let info = peek_info(&image).expect("peek");
1153    /// let mut src = Tensor::<u8>::image(info.width, info.height, info.format,
1154    ///                                    Some(TensorMemory::Mem))?;
1155    /// let mut decoder = ImageDecoder::new();
1156    /// src.load_image(&mut decoder, &image).expect("decode");
1157    /// let mut converter = ImageProcessor::new()?;
1158    /// let mut dst = converter.create_image(640, 480, PixelFormat::Rgb, DType::U8, None)?;
1159    /// converter.convert(&src.into(), &mut dst, Rotation::None, Flip::None, Crop::default())?;
1160    /// # Ok(())
1161    /// # }
1162    /// ```
1163    pub fn new() -> Result<Self> {
1164        Self::with_config(ImageProcessorConfig::default())
1165    }
1166
1167    /// Report which float dtypes the GPU can render to.
1168    ///
1169    /// Probes `GL_EXT_color_buffer_half_float` and
1170    /// `GL_EXT_color_buffer_float` once at `ImageProcessor::new()` time
1171    /// and caches the result. Call this once at startup to decide whether
1172    /// to request F16 or F32 destination tensors; [`create_image`] uses
1173    /// the result internally to auto-select float PBO when supported.
1174    ///
1175    /// Returns `RenderDtypeSupport { f32: false, f16: false }` when no
1176    /// OpenGL backend is active or the `opengl` feature is disabled.
1177    ///
1178    /// [`create_image`]: Self::create_image
1179    pub fn supported_render_dtypes(&self) -> RenderDtypeSupport {
1180        #[cfg(all(target_os = "macos", feature = "opengl"))]
1181        if let Some(gl) = self.opengl.as_ref() {
1182            return gl.supported_render_dtypes();
1183        }
1184        #[cfg(all(target_os = "linux", feature = "opengl"))]
1185        if let Some(gl) = self.opengl.as_ref() {
1186            return gl.supported_render_dtypes();
1187        }
1188        RenderDtypeSupport {
1189            f32: false,
1190            f16: false,
1191        }
1192    }
1193
1194    /// Creates a new `ImageProcessor` with the given configuration.
1195    ///
1196    /// When [`ImageProcessorConfig::backend`] is set to a specific backend,
1197    /// environment variables are ignored and the processor initializes the
1198    /// requested backend plus CPU as a fallback.
1199    ///
1200    /// When `Auto`, the existing `EDGEFIRST_FORCE_BACKEND` and
1201    /// `EDGEFIRST_DISABLE_*` environment variables apply.
1202    #[allow(unused_variables)]
1203    pub fn with_config(config: ImageProcessorConfig) -> Result<Self> {
1204        // ── Config-driven backend selection ──────────────────────────
1205        // When the caller explicitly requests a backend via the config,
1206        // skip all environment variable logic.
1207        match config.backend {
1208            ComputeBackend::Cpu => {
1209                log::info!("ComputeBackend::Cpu — CPU only");
1210                return Ok(Self {
1211                    cpu: Some(CPUProcessor::new()),
1212                    #[cfg(target_os = "linux")]
1213                    g2d: None,
1214                    #[cfg(target_os = "linux")]
1215                    #[cfg(feature = "opengl")]
1216                    opengl: None,
1217                    #[cfg(target_os = "macos")]
1218                    #[cfg(feature = "opengl")]
1219                    opengl: None,
1220                    forced_backend: None,
1221                });
1222            }
1223            ComputeBackend::G2d => {
1224                log::info!("ComputeBackend::G2d — G2D + CPU fallback");
1225                #[cfg(target_os = "linux")]
1226                {
1227                    let g2d = match G2DProcessor::new() {
1228                        Ok(g) => Some(g),
1229                        Err(e) => {
1230                            log::warn!("G2D requested but failed to initialize: {e:?}");
1231                            None
1232                        }
1233                    };
1234                    return Ok(Self {
1235                        cpu: Some(CPUProcessor::new()),
1236                        g2d,
1237                        #[cfg(feature = "opengl")]
1238                        opengl: None,
1239                        forced_backend: None,
1240                    });
1241                }
1242                #[cfg(not(target_os = "linux"))]
1243                {
1244                    log::warn!("G2D requested but not available on this platform, using CPU");
1245                    return Ok(Self {
1246                        cpu: Some(CPUProcessor::new()),
1247                        #[cfg(target_os = "macos")]
1248                        #[cfg(feature = "opengl")]
1249                        opengl: None,
1250                        forced_backend: None,
1251                    });
1252                }
1253            }
1254            ComputeBackend::OpenGl => {
1255                log::info!("ComputeBackend::OpenGl — OpenGL + CPU fallback");
1256                #[cfg(target_os = "linux")]
1257                {
1258                    #[cfg(feature = "opengl")]
1259                    let opengl = match GLProcessorThreaded::new(config.egl_display) {
1260                        Ok(gl) => Some(gl),
1261                        Err(e) => {
1262                            log::warn!("OpenGL requested but failed to initialize: {e:?}");
1263                            None
1264                        }
1265                    };
1266                    return Ok(Self {
1267                        cpu: Some(CPUProcessor::new()),
1268                        g2d: None,
1269                        #[cfg(feature = "opengl")]
1270                        opengl,
1271                        forced_backend: None,
1272                    }
1273                    .apply_colorimetry_mode(config.colorimetry));
1274                }
1275                #[cfg(target_os = "macos")]
1276                {
1277                    #[cfg(feature = "opengl")]
1278                    let opengl = match GLProcessorThreaded::new(config.egl_display) {
1279                        Ok(gl) => Some(gl),
1280                        Err(e) => {
1281                            log::warn!(
1282                                "OpenGL requested on macOS but ANGLE init failed: {e:?} \
1283                                 (install ANGLE via `brew install startergo/angle/angle` \
1284                                 and re-sign the dylibs — see README.md § macOS GPU \
1285                                 Acceleration). Falling back to CPU."
1286                            );
1287                            None
1288                        }
1289                    };
1290                    return Ok(Self {
1291                        cpu: Some(CPUProcessor::new()),
1292                        #[cfg(feature = "opengl")]
1293                        opengl,
1294                        forced_backend: None,
1295                    }
1296                    .apply_colorimetry_mode(config.colorimetry));
1297                }
1298                #[cfg(not(any(target_os = "linux", target_os = "macos")))]
1299                {
1300                    log::warn!("OpenGL requested but not available on this platform, using CPU");
1301                    return Ok(Self {
1302                        cpu: Some(CPUProcessor::new()),
1303                        forced_backend: None,
1304                    });
1305                }
1306            }
1307            ComputeBackend::Auto => { /* fall through to env-var logic below */ }
1308        }
1309
1310        // ── EDGEFIRST_FORCE_BACKEND ──────────────────────────────────
1311        // When set, only the requested backend is initialised and no
1312        // fallback chain is used. Accepted values (case-insensitive):
1313        //   "cpu", "g2d", "opengl"
1314        if let Ok(val) = std::env::var("EDGEFIRST_FORCE_BACKEND") {
1315            let val_lower = val.to_lowercase();
1316            let forced = match val_lower.as_str() {
1317                "cpu" => ForcedBackend::Cpu,
1318                "g2d" => ForcedBackend::G2d,
1319                "opengl" => ForcedBackend::OpenGl,
1320                other => {
1321                    return Err(Error::ForcedBackendUnavailable(format!(
1322                        "unknown EDGEFIRST_FORCE_BACKEND value: {other:?} (expected cpu, g2d, or opengl)"
1323                    )));
1324                }
1325            };
1326
1327            log::info!("EDGEFIRST_FORCE_BACKEND={val} — only initializing {val_lower} backend");
1328
1329            return match forced {
1330                ForcedBackend::Cpu => Ok(Self {
1331                    cpu: Some(CPUProcessor::new()),
1332                    #[cfg(target_os = "linux")]
1333                    g2d: None,
1334                    #[cfg(target_os = "linux")]
1335                    #[cfg(feature = "opengl")]
1336                    opengl: None,
1337                    #[cfg(target_os = "macos")]
1338                    #[cfg(feature = "opengl")]
1339                    opengl: None,
1340                    forced_backend: Some(ForcedBackend::Cpu),
1341                }),
1342                ForcedBackend::G2d => {
1343                    #[cfg(target_os = "linux")]
1344                    {
1345                        let g2d = G2DProcessor::new().map_err(|e| {
1346                            Error::ForcedBackendUnavailable(format!(
1347                                "g2d forced but failed to initialize: {e:?}"
1348                            ))
1349                        })?;
1350                        Ok(Self {
1351                            cpu: None,
1352                            g2d: Some(g2d),
1353                            #[cfg(feature = "opengl")]
1354                            opengl: None,
1355                            forced_backend: Some(ForcedBackend::G2d),
1356                        })
1357                    }
1358                    #[cfg(not(target_os = "linux"))]
1359                    {
1360                        Err(Error::ForcedBackendUnavailable(
1361                            "g2d backend is only available on Linux".into(),
1362                        ))
1363                    }
1364                }
1365                ForcedBackend::OpenGl => {
1366                    #[cfg(target_os = "linux")]
1367                    #[cfg(feature = "opengl")]
1368                    {
1369                        let opengl = GLProcessorThreaded::new(config.egl_display).map_err(|e| {
1370                            Error::ForcedBackendUnavailable(format!(
1371                                "opengl forced but failed to initialize: {e:?}"
1372                            ))
1373                        })?;
1374                        Ok(Self {
1375                            cpu: None,
1376                            g2d: None,
1377                            opengl: Some(opengl),
1378                            forced_backend: Some(ForcedBackend::OpenGl),
1379                        }
1380                        .apply_colorimetry_mode(config.colorimetry))
1381                    }
1382                    #[cfg(target_os = "macos")]
1383                    #[cfg(feature = "opengl")]
1384                    {
1385                        let opengl = GLProcessorThreaded::new(config.egl_display).map_err(|e| {
1386                            Error::ForcedBackendUnavailable(format!(
1387                                "opengl forced on macOS but ANGLE init failed: {e:?}"
1388                            ))
1389                        })?;
1390                        Ok(Self {
1391                            cpu: None,
1392                            opengl: Some(opengl),
1393                            forced_backend: Some(ForcedBackend::OpenGl),
1394                        }
1395                        .apply_colorimetry_mode(config.colorimetry))
1396                    }
1397                    #[cfg(not(all(
1398                        any(target_os = "linux", target_os = "macos"),
1399                        feature = "opengl"
1400                    )))]
1401                    {
1402                        Err(Error::ForcedBackendUnavailable(
1403                            "opengl backend requires Linux or macOS with the 'opengl' feature \
1404                             enabled"
1405                                .into(),
1406                        ))
1407                    }
1408                }
1409            };
1410        }
1411
1412        // ── Existing DISABLE logic (unchanged) ──────────────────────
1413        #[cfg(target_os = "linux")]
1414        let g2d = if std::env::var("EDGEFIRST_DISABLE_G2D")
1415            .map(|x| x != "0" && x.to_lowercase() != "false")
1416            .unwrap_or(false)
1417        {
1418            log::debug!("EDGEFIRST_DISABLE_G2D is set");
1419            None
1420        } else {
1421            match G2DProcessor::new() {
1422                Ok(g2d_converter) => Some(g2d_converter),
1423                Err(err) => {
1424                    log::warn!("Failed to initialize G2D converter: {err:?}");
1425                    None
1426                }
1427            }
1428        };
1429
1430        #[cfg(target_os = "linux")]
1431        #[cfg(feature = "opengl")]
1432        let opengl = if std::env::var("EDGEFIRST_DISABLE_GL")
1433            .map(|x| x != "0" && x.to_lowercase() != "false")
1434            .unwrap_or(false)
1435        {
1436            log::debug!("EDGEFIRST_DISABLE_GL is set");
1437            None
1438        } else {
1439            match GLProcessorThreaded::new(config.egl_display) {
1440                Ok(gl_converter) => Some(gl_converter),
1441                Err(err) => {
1442                    log::warn!("Failed to initialize GL converter: {err:?}");
1443                    None
1444                }
1445            }
1446        };
1447
1448        #[cfg(target_os = "macos")]
1449        #[cfg(feature = "opengl")]
1450        let opengl = if std::env::var("EDGEFIRST_DISABLE_GL")
1451            .map(|x| x != "0" && x.to_lowercase() != "false")
1452            .unwrap_or(false)
1453        {
1454            log::debug!("EDGEFIRST_DISABLE_GL is set");
1455            None
1456        } else {
1457            match GLProcessorThreaded::new(config.egl_display) {
1458                Ok(gl_converter) => Some(gl_converter),
1459                Err(err) => {
1460                    log::debug!(
1461                        "macOS GL backend unavailable: {err:?} \
1462                         (CPU fallback will be used)"
1463                    );
1464                    None
1465                }
1466            }
1467        };
1468
1469        let cpu = if std::env::var("EDGEFIRST_DISABLE_CPU")
1470            .map(|x| x != "0" && x.to_lowercase() != "false")
1471            .unwrap_or(false)
1472        {
1473            log::debug!("EDGEFIRST_DISABLE_CPU is set");
1474            None
1475        } else {
1476            Some(CPUProcessor::new())
1477        };
1478        Ok(Self {
1479            cpu,
1480            #[cfg(target_os = "linux")]
1481            g2d,
1482            #[cfg(target_os = "linux")]
1483            #[cfg(feature = "opengl")]
1484            opengl,
1485            #[cfg(target_os = "macos")]
1486            #[cfg(feature = "opengl")]
1487            opengl,
1488            forced_backend: None,
1489        }
1490        .apply_colorimetry_mode(config.colorimetry))
1491    }
1492
1493    /// Apply the configured [`ColorimetryMode`] to whichever backend honours
1494    /// it (currently the Linux GL backend); no-op elsewhere. Constructor
1495    /// plumbing for [`ImageProcessorConfig::colorimetry`].
1496    fn apply_colorimetry_mode(self, _mode: ColorimetryMode) -> Self {
1497        #[cfg(all(any(target_os = "linux", target_os = "macos"), feature = "opengl"))]
1498        {
1499            let mut me = self;
1500            if let Err(e) = me.set_colorimetry_mode(_mode) {
1501                log::warn!("Failed to apply ColorimetryMode::{_mode:?}: {e:?}");
1502            }
1503            me
1504        }
1505        #[cfg(not(all(any(target_os = "linux", target_os = "macos"), feature = "opengl")))]
1506        {
1507            let _ = _mode;
1508            self
1509        }
1510    }
1511
1512    /// Sets the colorimetry/performance trade-off (see [`ColorimetryMode`])
1513    /// on the OpenGL backend. No-op if OpenGL is not available. The
1514    /// `EDGEFIRST_COLORIMETRY` environment variable takes precedence — when
1515    /// it is set, this call logs and keeps the env-selected mode.
1516    #[cfg(all(any(target_os = "linux", target_os = "macos"), feature = "opengl"))]
1517    pub fn set_colorimetry_mode(&mut self, mode: ColorimetryMode) -> Result<()> {
1518        if let Some(ref mut gl) = self.opengl {
1519            gl.set_colorimetry_mode(mode)?;
1520        }
1521        Ok(())
1522    }
1523
1524    /// Sets the interpolation mode for int8 proto textures on the OpenGL
1525    /// backend. No-op if OpenGL is not available.
1526    #[cfg(all(any(target_os = "linux", target_os = "macos"), feature = "opengl"))]
1527    pub fn set_int8_interpolation_mode(&mut self, mode: Int8InterpolationMode) -> Result<()> {
1528        if let Some(ref mut gl) = self.opengl {
1529            gl.set_int8_interpolation_mode(mode)?;
1530        }
1531        Ok(())
1532    }
1533
1534    /// Create a [`TensorDyn`] image with the best available memory backend.
1535    ///
1536    /// Priority: DMA-buf → float PBO (F16/F32) → u8/i8 PBO → system memory.
1537    ///
1538    /// Use this method instead of [`TensorDyn::image()`] when the tensor will
1539    /// be used with [`ImageProcessor::convert()`]. It selects the optimal
1540    /// memory backing (including PBO for GPU zero-copy) which direct
1541    /// allocation cannot achieve.
1542    ///
1543    /// This method is on [`ImageProcessor`] rather than [`ImageProcessorTrait`]
1544    /// because optimal allocation requires knowledge of the active compute
1545    /// backends (e.g. the GL context handle for PBO allocation). Individual
1546    /// backend implementations ([`CPUProcessor`], etc.) do not have this
1547    /// cross-backend visibility.
1548    ///
1549    /// **Float dtype behaviour:** when `dtype` is `F16` or `F32` and
1550    /// [`supported_render_dtypes`] reports the GPU supports that type,
1551    /// `memory: None` auto-selects a float PBO (Linux) or IOSurface (macOS
1552    /// F16 only). If GPU float support is absent the allocation falls through
1553    /// to `TensorMemory::Mem`; [`convert`] then uses the CPU path.
1554    /// Passing `memory: Some(TensorMemory::Dma)` with `dtype: F32` always
1555    /// returns `Error::NotSupported` — no 32-bit-float DRM fourcc exists.
1556    ///
1557    /// [`supported_render_dtypes`]: Self::supported_render_dtypes
1558    /// [`convert`]: ImageProcessorTrait::convert
1559    ///
1560    /// # Arguments
1561    ///
1562    /// * `width` - Image width in pixels
1563    /// * `height` - Image height in pixels
1564    /// * `format` - Pixel format
1565    /// * `dtype` - Element data type (e.g. `DType::U8`, `DType::F16`, `DType::F32`)
1566    /// * `memory` - Optional memory type override; when `None`, the best
1567    ///   available backend is selected automatically.
1568    ///
1569    /// # Returns
1570    ///
1571    /// A [`TensorDyn`] backed by the highest-performance memory type
1572    /// available on this system.
1573    ///
1574    /// # Pitch alignment for DMA-backed allocations
1575    ///
1576    /// DMA-BUF imports into the GL backend (Mali Valhall on i.MX 95
1577    /// specifically) require every row pitch to be a multiple of
1578    /// [`GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES`] (currently 64). When this
1579    /// method lands on `TensorMemory::Dma`, the underlying allocation is
1580    /// silently padded so the row stride satisfies that requirement.
1581    ///
1582    /// **The user-requested `width` is preserved** — `tensor.width()`
1583    /// returns the same value you passed in. The padding is carried by
1584    /// [`TensorDyn::row_stride`] / `effective_row_stride()`, which the
1585    /// GL backend reads when importing the buffer as an EGLImage.
1586    /// Callers that compute byte offsets from the tensor must use the
1587    /// stride, not `width × bytes_per_pixel`; the CPU mapping spans the
1588    /// full `stride × height` bytes.
1589    ///
1590    /// Pre-aligned widths (640, 1280, 1920, 3008, 3840 …) allocate
1591    /// exactly `width × bpp × height` bytes with no padding. PBO and
1592    /// Mem fallbacks never pad — they don't go through EGLImage import.
1593    ///
1594    /// See also [`align_width_for_gpu_pitch`] for an advisory helper
1595    /// that external callers (GStreamer plugins, video pipelines) can
1596    /// use to size their own DMA-BUFs for GL compatibility.
1597    ///
1598    /// # Errors
1599    ///
1600    /// Returns an error if all allocation strategies fail.
1601    pub fn create_image(
1602        &self,
1603        width: usize,
1604        height: usize,
1605        format: PixelFormat,
1606        dtype: DType,
1607        memory: Option<TensorMemory>,
1608    ) -> Result<TensorDyn> {
1609        // Compute the GPU-aligned row stride in bytes for this image.
1610        // `None` means either the format has no defined primary-plane bpp
1611        // (unknown future layout) or the stride calculation would overflow
1612        // — in both cases we fall back to the natural layout via the plain
1613        // `TensorDyn::image` constructor, and the slow-path warning inside
1614        // `draw_*_masks` will fire if the subsequent GL import fails.
1615        //
1616        // DMA allocation is Linux-only (see `TensorMemory::Dma` cfg gate),
1617        // so both the stride computation and the helper closure are gated
1618        // accordingly — the callers below are already Linux-only.
1619        #[cfg(target_os = "linux")]
1620        let dma_stride_bytes: Option<usize> = primary_plane_bpp(format, dtype.size())
1621            .and_then(|bpp| width.checked_mul(bpp))
1622            .and_then(align_pitch_bytes_to_gpu_alignment);
1623
1624        // Helper: allocate a DMA image, using the padded-stride constructor
1625        // when the computed stride exceeds the natural pitch, otherwise the
1626        // plain constructor (byte-identical result in the common case).
1627        #[cfg(target_os = "linux")]
1628        let try_dma = || -> Result<TensorDyn> {
1629            // Stride padding is only meaningful for packed pixel layouts
1630            // (RGBA8, BGRA8, RGB888, Grey) — the formats the GL backend
1631            // renders into. Semi-planar (NV12, NV16) and planar (PlanarRgb,
1632            // PlanarRgba) tensors go through `TensorDyn::image(...)` with
1633            // their natural layout; they're imported from camera capture
1634            // via `from_fd` far more often than allocated here, and
1635            // `Tensor::image_with_stride` explicitly rejects them.
1636            let packed = format.layout() == edgefirst_tensor::PixelLayout::Packed;
1637            match dma_stride_bytes {
1638                Some(stride)
1639                    if packed
1640                        && primary_plane_bpp(format, dtype.size())
1641                            .and_then(|bpp| width.checked_mul(bpp))
1642                            .is_some_and(|natural| stride > natural) =>
1643                {
1644                    log::debug!(
1645                        "create_image: padding row stride for {format:?} {width}x{height} \
1646                         from natural pitch to {stride} bytes for GPU alignment"
1647                    );
1648                    Ok(TensorDyn::image_with_stride(
1649                        width,
1650                        height,
1651                        format,
1652                        dtype,
1653                        stride,
1654                        Some(edgefirst_tensor::TensorMemory::Dma),
1655                    )?)
1656                }
1657                _ => Ok(TensorDyn::image(
1658                    width,
1659                    height,
1660                    format,
1661                    dtype,
1662                    Some(edgefirst_tensor::TensorMemory::Dma),
1663                )?),
1664            }
1665        };
1666
1667        // If an explicit memory type is requested, honour it directly.
1668        // On Linux, `TensorMemory::Dma` gets the padded-stride treatment;
1669        // other memory types take the user-requested width verbatim.
1670        // On macOS, `TensorMemory::Dma` dispatches through `TensorDyn::image`
1671        // which selects the IOSurface allocation path (FourCC-formatted)
1672        // for image-mappable formats, or falls back to SHM/Mem otherwise.
1673        match memory {
1674            #[cfg(target_os = "linux")]
1675            Some(TensorMemory::Dma) => {
1676                // F32 has no 32-bit-float DRM fourcc; callers must use PBO instead.
1677                if dtype == DType::F32 {
1678                    return Err(Error::NotSupported(
1679                        "F32 has no 32-bit-float DRM format for DMA-BUF; \
1680                         use TensorMemory::Pbo for F32"
1681                            .to_string(),
1682                    ));
1683                }
1684                return try_dma();
1685            }
1686            Some(mem) => {
1687                return Ok(TensorDyn::image(width, height, format, dtype, Some(mem))?);
1688            }
1689            None => {}
1690        }
1691
1692        // macOS: when the GL backend is active with the IOSurface
1693        // transfer path, prefer Dma (IOSurface) for zero-copy import.
1694        // The Tensor allocator falls through to SHM/Mem automatically
1695        // for formats without an IOSurface mapping (NV12, planar, etc.).
1696        #[cfg(target_os = "macos")]
1697        #[cfg(feature = "opengl")]
1698        if let Some(gl) = self.opengl.as_ref() {
1699            let _ = gl; // probe_transfer_backend lives behind the platform trait
1700            if let Ok(img) = TensorDyn::image(
1701                width,
1702                height,
1703                format,
1704                dtype,
1705                Some(edgefirst_tensor::TensorMemory::Dma),
1706            ) {
1707                return Ok(img);
1708            }
1709        }
1710
1711        // Try DMA first on Linux — skip only when GL has explicitly selected PBO
1712        // as the preferred transfer path (PBO is better than DMA in that case).
1713        #[cfg(target_os = "linux")]
1714        {
1715            #[cfg(feature = "opengl")]
1716            let gl_uses_pbo = self
1717                .opengl
1718                .as_ref()
1719                .is_some_and(|gl| gl.transfer_backend() == opengl_headless::TransferBackend::Pbo);
1720            #[cfg(not(feature = "opengl"))]
1721            let gl_uses_pbo = false;
1722
1723            if !gl_uses_pbo {
1724                if let Ok(img) = try_dma() {
1725                    return Ok(img);
1726                }
1727            }
1728        }
1729
1730        // Try PBO (if GL available).
1731        // PBO buffers are u8-sized; the int8 shader emulates i8 output via
1732        // XOR 0x80 on the same underlying buffer, so both U8 and I8 work.
1733        #[cfg(target_os = "linux")]
1734        #[cfg(feature = "opengl")]
1735        if dtype.size() == 1 {
1736            if let Some(gl) = &self.opengl {
1737                match gl.create_pbo_image(width, height, format) {
1738                    Ok(t) => {
1739                        if dtype == DType::I8 {
1740                            // SAFETY: Tensor<u8> and Tensor<i8> are layout-
1741                            // identical (same element size, no T-dependent
1742                            // drop glue). The int8 shader applies XOR 0x80
1743                            // on the same PBO buffer. Same rationale as
1744                            // gl::processor::tensor_i8_as_u8_mut.
1745                            // Invariant: PBO tensors never have chroma
1746                            // (create_pbo_image → Tensor::wrap sets it None).
1747                            debug_assert!(
1748                                t.chroma().is_none(),
1749                                "PBO i8 transmute requires chroma == None"
1750                            );
1751                            let t_i8: Tensor<i8> = unsafe { std::mem::transmute(t) };
1752                            return Ok(TensorDyn::from(t_i8));
1753                        }
1754                        return Ok(TensorDyn::from(t));
1755                    }
1756                    Err(e) => log::debug!("PBO image creation failed, falling back to Mem: {e:?}"),
1757                }
1758            }
1759        }
1760
1761        // Try float PBO when the GPU backend reports support for this dtype.
1762        // Falls through to Mem on error (same policy as u8 PBO above).
1763        #[cfg(target_os = "linux")]
1764        #[cfg(feature = "opengl")]
1765        if float_pbo_eligible(dtype, self.supported_render_dtypes()) {
1766            if let Some(gl) = &self.opengl {
1767                match gl.create_pbo_image_dtype(width, height, format, dtype) {
1768                    Ok(t) => return Ok(t),
1769                    Err(e) => {
1770                        log::debug!(
1771                            "Float PBO image creation failed for {dtype:?}, \
1772                             falling back to Mem: {e:?}"
1773                        );
1774                    }
1775                }
1776            }
1777        }
1778
1779        // Fallback to Mem
1780        Ok(TensorDyn::image(
1781            width,
1782            height,
1783            format,
1784            dtype,
1785            Some(edgefirst_tensor::TensorMemory::Mem),
1786        )?)
1787    }
1788
1789    /// Import an external DMA-BUF image.
1790    ///
1791    /// Each [`PlaneDescriptor`] owns an already-duped fd; this method
1792    /// consumes the descriptors and takes ownership of those fds (whether
1793    /// the call succeeds or fails).
1794    ///
1795    /// The caller must ensure the DMA-BUF allocation is large enough for the
1796    /// specified width, height, format, and any stride/offset on the plane
1797    /// descriptors. No buffer-size validation is performed; an undersized
1798    /// buffer may cause GPU faults or EGL import failure.
1799    ///
1800    /// # Arguments
1801    ///
1802    /// * `image` - Plane descriptor for the primary (or only) plane
1803    /// * `chroma` - Optional plane descriptor for the UV chroma plane
1804    ///   (required for multiplane NV12)
1805    /// * `width` - Image width in pixels
1806    /// * `height` - Image height in pixels
1807    /// * `format` - Pixel format of the buffer
1808    /// * `dtype` - Element data type (e.g. `DType::U8`)
1809    ///
1810    /// # Returns
1811    ///
1812    /// A `TensorDyn` configured as an image.
1813    ///
1814    /// # Errors
1815    ///
1816    /// * [`Error::NotSupported`] if `chroma` is `Some` for a non-semi-planar
1817    ///   format, or multiplane NV16 (not yet supported), or the fd is not
1818    ///   DMA-backed
1819    /// * [`Error::InvalidShape`] if NV12 height is odd
1820    ///
1821    /// # Platform
1822    ///
1823    /// Linux only.
1824    ///
1825    /// # Examples
1826    ///
1827    /// ```rust,ignore
1828    /// use edgefirst_tensor::PlaneDescriptor;
1829    ///
1830    /// // Single-plane RGBA
1831    /// let pd = PlaneDescriptor::new(fd.as_fd())?;
1832    /// let src = proc.import_image(pd, None, 1920, 1080, PixelFormat::Rgba, DType::U8, None)?;
1833    ///
1834    /// // Multi-plane NV12 with stride
1835    /// let y_pd = PlaneDescriptor::new(y_fd.as_fd())?.with_stride(2048);
1836    /// let uv_pd = PlaneDescriptor::new(uv_fd.as_fd())?.with_stride(2048);
1837    /// let src = proc.import_image(y_pd, Some(uv_pd), 1920, 1080,
1838    ///                             PixelFormat::Nv12, DType::U8, None)?;
1839    /// ```
1840    // Import inherently needs plane(s) + geometry + format + dtype + colorimetry;
1841    // a params struct would obscure more than it clarifies here.
1842    #[allow(clippy::too_many_arguments)]
1843    #[cfg(target_os = "linux")]
1844    pub fn import_image(
1845        &self,
1846        image: edgefirst_tensor::PlaneDescriptor,
1847        chroma: Option<edgefirst_tensor::PlaneDescriptor>,
1848        width: usize,
1849        height: usize,
1850        format: PixelFormat,
1851        dtype: DType,
1852        colorimetry: Option<edgefirst_tensor::Colorimetry>,
1853    ) -> Result<TensorDyn> {
1854        use edgefirst_tensor::{Tensor, TensorMemory};
1855
1856        // Capture stride/offset from descriptors before consuming them
1857        let image_stride = image.stride();
1858        let image_offset = image.offset();
1859        let chroma_stride = chroma.as_ref().and_then(|c| c.stride());
1860        let chroma_offset = chroma.as_ref().and_then(|c| c.offset());
1861
1862        if let Some(chroma_pd) = chroma {
1863            // ── Multiplane path ──────────────────────────────────────
1864            // Multiplane tensors are backed by Tensor<u8> (or transmuted to
1865            // Tensor<i8>). Reject other dtypes to avoid silently returning a
1866            // tensor with the wrong element type.
1867            if dtype != DType::U8 && dtype != DType::I8 {
1868                return Err(Error::NotSupported(format!(
1869                    "multiplane import only supports U8/I8, got {dtype:?}"
1870                )));
1871            }
1872            if format.layout() != PixelLayout::SemiPlanar {
1873                return Err(Error::NotSupported(format!(
1874                    "import_image with chroma requires a semi-planar format, got {format:?}"
1875                )));
1876            }
1877
1878            let chroma_h = match format {
1879                PixelFormat::Nv12 => {
1880                    // NV12 (4:2:0): ceil(H/2) chroma rows — odd heights are valid.
1881                    height.div_ceil(2)
1882                }
1883                // NV16 multiplane will be supported in a future release;
1884                // the GL backend currently only handles NV12 plane1 attributes.
1885                PixelFormat::Nv16 => {
1886                    return Err(Error::NotSupported(
1887                        "multiplane NV16 is not yet supported; use contiguous NV16 instead".into(),
1888                    ))
1889                }
1890                _ => {
1891                    return Err(Error::NotSupported(format!(
1892                        "unsupported semi-planar format: {format:?}"
1893                    )))
1894                }
1895            };
1896
1897            let luma = Tensor::<u8>::from_fd(image.into_fd(), &[height, width], Some("luma"))?;
1898            if luma.memory() != TensorMemory::Dma {
1899                return Err(Error::NotSupported(format!(
1900                    "luma fd must be DMA-backed, got {:?}",
1901                    luma.memory()
1902                )));
1903            }
1904
1905            let chroma_tensor =
1906                Tensor::<u8>::from_fd(chroma_pd.into_fd(), &[chroma_h, width], Some("chroma"))?;
1907            if chroma_tensor.memory() != TensorMemory::Dma {
1908                return Err(Error::NotSupported(format!(
1909                    "chroma fd must be DMA-backed, got {:?}",
1910                    chroma_tensor.memory()
1911                )));
1912            }
1913
1914            // from_planes creates the combined tensor with format set,
1915            // preserving luma's row_stride (currently None since luma was raw).
1916            let mut tensor = Tensor::<u8>::from_planes(luma, chroma_tensor, format)?;
1917
1918            // Apply stride/offset to the combined tensor (luma plane)
1919            if let Some(s) = image_stride {
1920                tensor.set_row_stride(s)?;
1921            }
1922            if let Some(o) = image_offset {
1923                tensor.set_plane_offset(o);
1924            }
1925
1926            // Apply stride/offset to the chroma sub-tensor.
1927            // The chroma tensor is a raw 2D [chroma_h, width] tensor without
1928            // format metadata, so we validate stride manually rather than
1929            // using set_row_stride (which requires format).
1930            if let Some(chroma_ref) = tensor.chroma_mut() {
1931                if let Some(s) = chroma_stride {
1932                    if s < width {
1933                        return Err(Error::InvalidShape(format!(
1934                            "chroma stride {s} < minimum {width} for {format:?}"
1935                        )));
1936                    }
1937                    chroma_ref.set_row_stride_unchecked(s);
1938                }
1939                if let Some(o) = chroma_offset {
1940                    chroma_ref.set_plane_offset(o);
1941                }
1942            }
1943
1944            if dtype == DType::I8 {
1945                // SAFETY: Tensor<u8> and Tensor<i8> have identical layout because
1946                // the struct contains only type-erased storage (OwnedFd, shape, name),
1947                // no inline T values. This assertion catches layout drift at compile time.
1948                const {
1949                    assert!(std::mem::size_of::<Tensor<u8>>() == std::mem::size_of::<Tensor<i8>>());
1950                    assert!(
1951                        std::mem::align_of::<Tensor<u8>>() == std::mem::align_of::<Tensor<i8>>()
1952                    );
1953                }
1954                let tensor_i8: Tensor<i8> = unsafe { std::mem::transmute(tensor) };
1955                let mut dyn_tensor = TensorDyn::from(tensor_i8);
1956                dyn_tensor.set_colorimetry(colorimetry);
1957                return Ok(dyn_tensor);
1958            }
1959            let mut dyn_tensor = TensorDyn::from(tensor);
1960            dyn_tensor.set_colorimetry(colorimetry);
1961            Ok(dyn_tensor)
1962        } else {
1963            // ── Single-plane path ────────────────────────────────────
1964            // Canonical shape (Packed [H,W,C] / Planar [C,H,W] / SemiPlanar
1965            // [total_h, W]); `image_shape` supports NV12/NV16/NV24 (the old
1966            // hand-rolled match erroneously rejected NV24).
1967            let shape = format.image_shape(width, height).ok_or_else(|| {
1968                Error::NotSupported(format!(
1969                    "unsupported pixel format for import_image: {format:?}"
1970                ))
1971            })?;
1972            let tensor = TensorDyn::from_fd(image.into_fd(), &shape, dtype, None)?;
1973            if tensor.memory() != TensorMemory::Dma {
1974                return Err(Error::NotSupported(format!(
1975                    "import_image requires DMA-backed fd, got {:?}",
1976                    tensor.memory()
1977                )));
1978            }
1979            let mut tensor = tensor.with_format(format)?;
1980            if let Some(s) = image_stride {
1981                tensor.set_row_stride(s)?;
1982            }
1983            if let Some(o) = image_offset {
1984                tensor.set_plane_offset(o);
1985            }
1986            tensor.set_colorimetry(colorimetry);
1987            Ok(tensor)
1988        }
1989    }
1990
1991    /// Decode model outputs and draw segmentation masks onto `dst`.
1992    ///
1993    /// This is the primary mask rendering API. The processor decodes via the
1994    /// provided [`Decoder`], selects the optimal rendering path (hybrid
1995    /// CPU+GL or fused GPU), and composites masks onto `dst`.
1996    ///
1997    /// Returns the detected bounding boxes.
1998    pub fn draw_masks(
1999        &mut self,
2000        decoder: &edgefirst_decoder::Decoder,
2001        outputs: &[&TensorDyn],
2002        dst: &mut TensorDyn,
2003        overlay: MaskOverlay<'_>,
2004    ) -> Result<Vec<DetectBox>> {
2005        let mut output_boxes = Vec::with_capacity(100);
2006
2007        // Try proto path first (fused rendering without materializing masks)
2008        let proto_result = decoder
2009            .decode_proto(outputs, &mut output_boxes)
2010            .map_err(|e| Error::Internal(format!("decode_proto: {e:#?}")))?;
2011
2012        if let Some(proto_data) = proto_result {
2013            self.draw_proto_masks(dst, &output_boxes, &proto_data, overlay)?;
2014        } else {
2015            // Detection-only or unsupported model: full decode + render
2016            let mut output_masks = Vec::with_capacity(100);
2017            decoder
2018                .decode(outputs, &mut output_boxes, &mut output_masks)
2019                .map_err(|e| Error::Internal(format!("decode: {e:#?}")))?;
2020            self.draw_decoded_masks(dst, &output_boxes, &output_masks, overlay)?;
2021        }
2022        Ok(output_boxes)
2023    }
2024
2025    /// Decode tracked model outputs and draw segmentation masks onto `dst`.
2026    ///
2027    /// Like [`draw_masks`](Self::draw_masks) but integrates a tracker for
2028    /// maintaining object identities across frames. The tracker runs after
2029    /// NMS but before mask extraction.
2030    ///
2031    /// Returns detected boxes and track info.
2032    #[cfg(feature = "tracker")]
2033    pub fn draw_masks_tracked<TR: edgefirst_tracker::Tracker<DetectBox>>(
2034        &mut self,
2035        decoder: &edgefirst_decoder::Decoder,
2036        tracker: &mut TR,
2037        timestamp: u64,
2038        outputs: &[&TensorDyn],
2039        dst: &mut TensorDyn,
2040        overlay: MaskOverlay<'_>,
2041    ) -> Result<(Vec<DetectBox>, Vec<edgefirst_tracker::TrackInfo>)> {
2042        let mut output_boxes = Vec::with_capacity(100);
2043        let mut output_tracks = Vec::new();
2044
2045        let proto_result = decoder
2046            .decode_proto_tracked(
2047                tracker,
2048                timestamp,
2049                outputs,
2050                &mut output_boxes,
2051                &mut output_tracks,
2052            )
2053            .map_err(|e| Error::Internal(format!("decode_proto_tracked: {e:#?}")))?;
2054
2055        if let Some(proto_data) = proto_result {
2056            self.draw_proto_masks(dst, &output_boxes, &proto_data, overlay)?;
2057        } else {
2058            // Note: decode_proto_tracked returns None for detection-only/ModelPack
2059            // models WITHOUT calling the tracker. The else branch below is the
2060            // first (and only) tracker call for those model types.
2061            let mut output_masks = Vec::with_capacity(100);
2062            decoder
2063                .decode_tracked(
2064                    tracker,
2065                    timestamp,
2066                    outputs,
2067                    &mut output_boxes,
2068                    &mut output_masks,
2069                    &mut output_tracks,
2070                )
2071                .map_err(|e| Error::Internal(format!("decode_tracked: {e:#?}")))?;
2072            self.draw_decoded_masks(dst, &output_boxes, &output_masks, overlay)?;
2073        }
2074        Ok((output_boxes, output_tracks))
2075    }
2076
2077    /// Materialize per-instance segmentation masks from raw prototype data.
2078    ///
2079    /// Computes `mask_coeff @ protos` with sigmoid activation for each detection,
2080    /// producing compact masks at prototype resolution (e.g., 160×160 crops).
2081    /// Mask values are continuous sigmoid confidence outputs quantized to u8
2082    /// (0 = background, 255 = full confidence), NOT binary thresholded.
2083    ///
2084    /// The returned [`Vec<Segmentation>`] can be:
2085    /// - Inspected or exported for analytics, IoU computation, etc.
2086    /// - Passed directly to [`ImageProcessorTrait::draw_decoded_masks`] for
2087    ///   GPU-interpolated rendering.
2088    ///
2089    /// # Performance Note
2090    ///
2091    /// Calling `materialize_masks` + `draw_decoded_masks` separately prevents
2092    /// the HAL from using its internal fused optimization path. For render-only
2093    /// use cases, prefer [`ImageProcessorTrait::draw_proto_masks`] which selects
2094    /// the fastest path automatically (currently 1.6×–27× faster on tested
2095    /// platforms). Use this method when you need access to the intermediate masks.
2096    ///
2097    /// # Errors
2098    ///
2099    /// Returns [`Error::NoConverter`] if the CPU backend is not available.
2100    pub fn materialize_masks(
2101        &mut self,
2102        detect: &[DetectBox],
2103        proto_data: &ProtoData,
2104        letterbox: Option<[f32; 4]>,
2105        resolution: MaskResolution,
2106    ) -> Result<Vec<Segmentation>> {
2107        let cpu = self.cpu.as_mut().ok_or(Error::NoConverter)?;
2108        match resolution {
2109            MaskResolution::Proto => cpu.materialize_segmentations(detect, proto_data, letterbox),
2110            MaskResolution::Scaled { width, height } => {
2111                cpu.materialize_scaled_segmentations(detect, proto_data, letterbox, width, height)
2112            }
2113        }
2114    }
2115}
2116
2117impl ImageProcessorTrait for ImageProcessor {
2118    /// Converts the source image to the destination image format and size. The
2119    /// image is cropped first, then flipped, then rotated
2120    ///
2121    /// Prefer hardware accelerators when available, falling back to CPU if
2122    /// necessary.
2123    fn convert(
2124        &mut self,
2125        src: &TensorDyn,
2126        dst: &mut TensorDyn,
2127        rotation: Rotation,
2128        flip: Flip,
2129        crop: Crop,
2130    ) -> Result<()> {
2131        let start = Instant::now();
2132        let src_fmt = src.format();
2133        let dst_fmt = dst.format();
2134        let _span = tracing::trace_span!(
2135            "image.convert",
2136            ?src_fmt,
2137            ?dst_fmt,
2138            src_memory = ?src.memory(),
2139            dst_memory = ?dst.memory(),
2140            ?rotation,
2141            ?flip,
2142        )
2143        .entered();
2144        log::trace!(
2145            "convert: {src_fmt:?}({:?}/{:?}) → {dst_fmt:?}({:?}/{:?}), \
2146             rotation={rotation:?}, flip={flip:?}, backend={:?}",
2147            src.dtype(),
2148            src.memory(),
2149            dst.dtype(),
2150            dst.memory(),
2151            self.forced_backend,
2152        );
2153
2154        // ── Forced backend: no fallback chain ────────────────────────
2155        if let Some(forced) = self.forced_backend {
2156            return match forced {
2157                ForcedBackend::Cpu => {
2158                    if let Some(cpu) = self.cpu.as_mut() {
2159                        let r = cpu.convert(src, dst, rotation, flip, crop);
2160                        log::trace!(
2161                            "convert: forced=cpu result={} ({:?})",
2162                            if r.is_ok() { "ok" } else { "err" },
2163                            start.elapsed()
2164                        );
2165                        return r;
2166                    }
2167                    Err(Error::ForcedBackendUnavailable("cpu".into()))
2168                }
2169                ForcedBackend::G2d => {
2170                    #[cfg(target_os = "linux")]
2171                    if let Some(g2d) = self.g2d.as_mut() {
2172                        let r = g2d.convert(src, dst, rotation, flip, crop);
2173                        log::trace!(
2174                            "convert: forced=g2d result={} ({:?})",
2175                            if r.is_ok() { "ok" } else { "err" },
2176                            start.elapsed()
2177                        );
2178                        return r;
2179                    }
2180                    Err(Error::ForcedBackendUnavailable("g2d".into()))
2181                }
2182                ForcedBackend::OpenGl => {
2183                    #[cfg(any(target_os = "linux", target_os = "macos"))]
2184                    #[cfg(feature = "opengl")]
2185                    if let Some(opengl) = self.opengl.as_mut() {
2186                        let r = opengl.convert(src, dst, rotation, flip, crop);
2187                        log::trace!(
2188                            "convert: forced=opengl result={} ({:?})",
2189                            if r.is_ok() { "ok" } else { "err" },
2190                            start.elapsed()
2191                        );
2192                        return r;
2193                    }
2194                    Err(Error::ForcedBackendUnavailable("opengl".into()))
2195                }
2196            };
2197        }
2198
2199        // ── Auto fallback chain: OpenGL → G2D → CPU ──────────────────
2200        #[cfg(any(target_os = "linux", target_os = "macos"))]
2201        #[cfg(feature = "opengl")]
2202        if let Some(opengl) = self.opengl.as_mut() {
2203            match opengl.convert(src, dst, rotation, flip, crop) {
2204                Ok(_) => {
2205                    log::trace!(
2206                        "convert: auto selected=opengl for {src_fmt:?}→{dst_fmt:?} ({:?})",
2207                        start.elapsed()
2208                    );
2209                    return Ok(());
2210                }
2211                Err(e) => {
2212                    log::trace!("convert: auto opengl declined {src_fmt:?}→{dst_fmt:?}: {e}");
2213                }
2214            }
2215        }
2216
2217        #[cfg(target_os = "linux")]
2218        if let Some(g2d) = self.g2d.as_mut() {
2219            // G2D is matrix-only (no range control, no BT.2020). For any
2220            // conversion with a YUV side, resolve that side's colorimetry and
2221            // skip G2D entirely when it cannot be expressed (full-range YUV or
2222            // BT.2020), letting the chain fall through to GL/CPU which honour
2223            // range and BT.2020. YUV→RGB uses the source colorimetry; RGB→YUV
2224            // uses the destination. RGB→RGB has no YUV side and is unaffected.
2225            let src_is_yuv = src.format().is_some_and(|f| f.is_yuv());
2226            let dst_is_yuv = dst.format().is_some_and(|f| f.is_yuv());
2227            let g2d_eligible = if src_is_yuv || dst_is_yuv {
2228                let cm = if src_is_yuv {
2229                    crate::colorimetry::effective_colorimetry(src)
2230                } else {
2231                    crate::colorimetry::effective_colorimetry(dst)
2232                };
2233                crate::g2d::g2d_can_handle(&cm, true)
2234            } else {
2235                true
2236            };
2237            if !g2d_eligible {
2238                log::trace!(
2239                    "convert: auto g2d skipped {src_fmt:?}→{dst_fmt:?} \
2240                     (colorimetry not expressible: full-range/BT.2020)"
2241                );
2242            } else {
2243                match g2d.convert(src, dst, rotation, flip, crop) {
2244                    Ok(_) => {
2245                        log::trace!(
2246                            "convert: auto selected=g2d for {src_fmt:?}→{dst_fmt:?} ({:?})",
2247                            start.elapsed()
2248                        );
2249                        return Ok(());
2250                    }
2251                    Err(e) => {
2252                        log::trace!("convert: auto g2d declined {src_fmt:?}→{dst_fmt:?}: {e}");
2253                    }
2254                }
2255            }
2256        }
2257
2258        if let Some(cpu) = self.cpu.as_mut() {
2259            match cpu.convert(src, dst, rotation, flip, crop) {
2260                Ok(_) => {
2261                    log::trace!(
2262                        "convert: auto selected=cpu for {src_fmt:?}→{dst_fmt:?} ({:?})",
2263                        start.elapsed()
2264                    );
2265                    return Ok(());
2266                }
2267                Err(e) => {
2268                    log::trace!("convert: auto cpu failed {src_fmt:?}→{dst_fmt:?}: {e}");
2269                    return Err(e);
2270                }
2271            }
2272        }
2273        Err(Error::NoConverter)
2274    }
2275
2276    fn convert_deferred(
2277        &mut self,
2278        src: &TensorDyn,
2279        dst: &mut TensorDyn,
2280        rotation: Rotation,
2281        flip: Flip,
2282        crop: Crop,
2283    ) -> Result<()> {
2284        // Deferred batching is an OpenGL optimization (shared parent EGLImage +
2285        // no per-tile glFinish). Route to the GL backend's deferred path when GL
2286        // is forced or auto-selectable; on a GL decline fall back to an eager
2287        // convert (the auto chain), which is correct everywhere — it completes
2288        // synchronously and `flush` stays a no-op for non-GL backends.
2289        #[cfg(any(target_os = "linux", target_os = "macos"))]
2290        #[cfg(feature = "opengl")]
2291        {
2292            let gl_forced = matches!(self.forced_backend, Some(ForcedBackend::OpenGl));
2293            if gl_forced || self.forced_backend.is_none() {
2294                if let Some(opengl) = self.opengl.as_mut() {
2295                    match opengl.convert_deferred(src, dst, rotation, flip, crop) {
2296                        Ok(()) => return Ok(()),
2297                        Err(e) => {
2298                            log::trace!("convert_deferred: gl declined: {e}; eager fallback");
2299                            // A forced-GL caller gets the GL error, matching
2300                            // `convert`'s no-fallback forced-backend contract.
2301                            if gl_forced {
2302                                return Err(e);
2303                            }
2304                        }
2305                    }
2306                }
2307            }
2308        }
2309        self.convert(src, dst, rotation, flip, crop)
2310    }
2311
2312    fn flush(&mut self) -> Result<()> {
2313        let _span = tracing::trace_span!("image.flush").entered();
2314        // Only the OpenGL backend defers; flushing it issues the single GPU
2315        // sync. CPU/G2D converts already completed, so there is nothing to flush.
2316        #[cfg(any(target_os = "linux", target_os = "macos"))]
2317        #[cfg(feature = "opengl")]
2318        if let Some(opengl) = self.opengl.as_mut() {
2319            return opengl.flush();
2320        }
2321        Ok(())
2322    }
2323
2324    fn draw_decoded_masks(
2325        &mut self,
2326        dst: &mut TensorDyn,
2327        detect: &[DetectBox],
2328        segmentation: &[Segmentation],
2329        overlay: MaskOverlay<'_>,
2330    ) -> Result<()> {
2331        let _span = tracing::trace_span!(
2332            "image.draw_decoded_masks",
2333            n_detections = detect.len(),
2334            n_segmentations = segmentation.len(),
2335        )
2336        .entered();
2337        let start = Instant::now();
2338
2339        if let Some(bg) = overlay.background {
2340            if bg.aliases(dst) {
2341                return Err(Error::AliasedBuffers(
2342                    "background must not reference the same buffer as dst".to_string(),
2343                ));
2344            }
2345        }
2346
2347        // Un-letterbox detect boxes and segmentation bboxes for rendering when
2348        // a letterbox was applied to prepare the model input.
2349        let lb_boxes: Vec<DetectBox>;
2350        let lb_segs: Vec<Segmentation>;
2351        let (detect, segmentation) = if let Some(lb) = overlay.letterbox {
2352            lb_boxes = detect.iter().map(|&d| unletter_bbox(d, lb)).collect();
2353            // Keep segmentation bboxes in sync with the transformed detect boxes
2354            // when we have a 1:1 correspondence (instance segmentation).
2355            lb_segs = if segmentation.len() == lb_boxes.len() {
2356                segmentation
2357                    .iter()
2358                    .zip(lb_boxes.iter())
2359                    .map(|(s, d)| Segmentation {
2360                        xmin: d.bbox.xmin,
2361                        ymin: d.bbox.ymin,
2362                        xmax: d.bbox.xmax,
2363                        ymax: d.bbox.ymax,
2364                        segmentation: s.segmentation.clone(),
2365                    })
2366                    .collect()
2367            } else {
2368                segmentation.to_vec()
2369            };
2370            (lb_boxes.as_slice(), lb_segs.as_slice())
2371        } else {
2372            (detect, segmentation)
2373        };
2374        #[cfg(target_os = "linux")]
2375        let is_empty_frame = detect.is_empty() && segmentation.is_empty();
2376
2377        // ── Forced backend: no fallback chain ────────────────────────
2378        if let Some(forced) = self.forced_backend {
2379            return match forced {
2380                ForcedBackend::Cpu => {
2381                    if let Some(cpu) = self.cpu.as_mut() {
2382                        return cpu.draw_decoded_masks(dst, detect, segmentation, overlay);
2383                    }
2384                    Err(Error::ForcedBackendUnavailable("cpu".into()))
2385                }
2386                ForcedBackend::G2d => {
2387                    // G2D can only produce empty frames (clear / bg blit).
2388                    // For populated frames it has no rasterizer — fail loudly.
2389                    #[cfg(target_os = "linux")]
2390                    if let Some(g2d) = self.g2d.as_mut() {
2391                        return g2d.draw_decoded_masks(dst, detect, segmentation, overlay);
2392                    }
2393                    Err(Error::ForcedBackendUnavailable("g2d".into()))
2394                }
2395                ForcedBackend::OpenGl => {
2396                    // GL handles background natively via GPU blit, and now
2397                    // actively clears when there is no background.
2398                    #[cfg(target_os = "linux")]
2399                    #[cfg(feature = "opengl")]
2400                    if let Some(opengl) = self.opengl.as_mut() {
2401                        return opengl.draw_decoded_masks(dst, detect, segmentation, overlay);
2402                    }
2403                    Err(Error::ForcedBackendUnavailable("opengl".into()))
2404                }
2405            };
2406        }
2407
2408        // ── Auto dispatch ──────────────────────────────────────────
2409        // Empty frames prefer G2D when available — a single g2d_clear or
2410        // g2d_blit is the cheapest HW path to produce the correct output
2411        // and avoids spinning up the GL pipeline every zero-detection
2412        // frame in a triple-buffered display loop.
2413        #[cfg(target_os = "linux")]
2414        if is_empty_frame {
2415            if let Some(g2d) = self.g2d.as_mut() {
2416                match g2d.draw_decoded_masks(dst, detect, segmentation, overlay) {
2417                    Ok(_) => {
2418                        log::trace!(
2419                            "draw_decoded_masks empty frame via g2d in {:?}",
2420                            start.elapsed()
2421                        );
2422                        return Ok(());
2423                    }
2424                    Err(e) => log::trace!("g2d empty-frame path unavailable: {e:?}"),
2425                }
2426            }
2427        }
2428
2429        // Populated frames (or G2D unavailable): GL first, CPU fallback.
2430        // Both backends now own their own base-layer handling (bg blit
2431        // or clear), so we hand the overlay through untouched.
2432        #[cfg(target_os = "linux")]
2433        #[cfg(feature = "opengl")]
2434        if let Some(opengl) = self.opengl.as_mut() {
2435            log::trace!(
2436                "draw_decoded_masks started with opengl in {:?}",
2437                start.elapsed()
2438            );
2439            match opengl.draw_decoded_masks(dst, detect, segmentation, overlay) {
2440                Ok(_) => {
2441                    log::trace!("draw_decoded_masks with opengl in {:?}", start.elapsed());
2442                    return Ok(());
2443                }
2444                Err(e) => {
2445                    log::trace!("draw_decoded_masks didn't work with opengl: {e:?}")
2446                }
2447            }
2448        }
2449
2450        log::trace!(
2451            "draw_decoded_masks started with cpu in {:?}",
2452            start.elapsed()
2453        );
2454        if let Some(cpu) = self.cpu.as_mut() {
2455            match cpu.draw_decoded_masks(dst, detect, segmentation, overlay) {
2456                Ok(_) => {
2457                    log::trace!("draw_decoded_masks with cpu in {:?}", start.elapsed());
2458                    return Ok(());
2459                }
2460                Err(e) => {
2461                    log::trace!("draw_decoded_masks didn't work with cpu: {e:?}");
2462                    return Err(e);
2463                }
2464            }
2465        }
2466        Err(Error::NoConverter)
2467    }
2468
2469    fn draw_proto_masks(
2470        &mut self,
2471        dst: &mut TensorDyn,
2472        detect: &[DetectBox],
2473        proto_data: &ProtoData,
2474        overlay: MaskOverlay<'_>,
2475    ) -> Result<()> {
2476        let start = Instant::now();
2477
2478        if let Some(bg) = overlay.background {
2479            if bg.aliases(dst) {
2480                return Err(Error::AliasedBuffers(
2481                    "background must not reference the same buffer as dst".to_string(),
2482                ));
2483            }
2484        }
2485
2486        // Un-letterbox detect boxes for rendering when a letterbox was applied
2487        // to prepare the model input.  The original `detect` coords are still
2488        // passed to `materialize_segmentations` (which needs model-space coords
2489        // to correctly crop the proto tensor) alongside `overlay.letterbox` so
2490        // it can emit `Segmentation` structs in output-image space.
2491        let lb_boxes: Vec<DetectBox>;
2492        let render_detect = if let Some(lb) = overlay.letterbox {
2493            lb_boxes = detect.iter().map(|&d| unletter_bbox(d, lb)).collect();
2494            lb_boxes.as_slice()
2495        } else {
2496            detect
2497        };
2498        #[cfg(target_os = "linux")]
2499        let is_empty_frame = detect.is_empty();
2500
2501        // ── Forced backend: no fallback chain ────────────────────────
2502        if let Some(forced) = self.forced_backend {
2503            return match forced {
2504                ForcedBackend::Cpu => {
2505                    if let Some(cpu) = self.cpu.as_mut() {
2506                        return cpu.draw_proto_masks(dst, render_detect, proto_data, overlay);
2507                    }
2508                    Err(Error::ForcedBackendUnavailable("cpu".into()))
2509                }
2510                ForcedBackend::G2d => {
2511                    #[cfg(target_os = "linux")]
2512                    if let Some(g2d) = self.g2d.as_mut() {
2513                        return g2d.draw_proto_masks(dst, render_detect, proto_data, overlay);
2514                    }
2515                    Err(Error::ForcedBackendUnavailable("g2d".into()))
2516                }
2517                ForcedBackend::OpenGl => {
2518                    #[cfg(target_os = "linux")]
2519                    #[cfg(feature = "opengl")]
2520                    if let Some(opengl) = self.opengl.as_mut() {
2521                        return opengl.draw_proto_masks(dst, render_detect, proto_data, overlay);
2522                    }
2523                    Err(Error::ForcedBackendUnavailable("opengl".into()))
2524                }
2525            };
2526        }
2527
2528        // ── Auto dispatch ──────────────────────────────────────────
2529        // Empty frames: prefer G2D — cheapest HW path (clear or bg blit).
2530        #[cfg(target_os = "linux")]
2531        if is_empty_frame {
2532            if let Some(g2d) = self.g2d.as_mut() {
2533                match g2d.draw_proto_masks(dst, render_detect, proto_data, overlay) {
2534                    Ok(_) => {
2535                        log::trace!(
2536                            "draw_proto_masks empty frame via g2d in {:?}",
2537                            start.elapsed()
2538                        );
2539                        return Ok(());
2540                    }
2541                    Err(e) => log::trace!("g2d empty-frame path unavailable: {e:?}"),
2542                }
2543            }
2544        }
2545
2546        // Hybrid path: CPU materialize + GL overlay (benchmarked faster than
2547        // full-GPU draw_proto_masks on all tested platforms: 27× on imx8mp,
2548        // 4× on imx95, 2.5× on rpi5, 1.6× on x86).
2549        // GL owns its own bg-blit / glClear — we pass the overlay through.
2550        //
2551        // CPU materialize needs `&mut` for its MaskScratch buffers; GL also
2552        // needs `&mut`. The CPU borrow is scoped to its block so the
2553        // subsequent GL borrow is free to take over `self`.
2554        #[cfg(target_os = "linux")]
2555        #[cfg(feature = "opengl")]
2556        if let (Some(_), Some(_)) = (self.cpu.as_ref(), self.opengl.as_ref()) {
2557            let segmentation = match self.cpu.as_mut() {
2558                Some(cpu) => {
2559                    log::trace!(
2560                        "draw_proto_masks started with hybrid (cpu+opengl) in {:?}",
2561                        start.elapsed()
2562                    );
2563                    cpu.materialize_segmentations(detect, proto_data, overlay.letterbox)?
2564                }
2565                None => unreachable!("cpu presence checked above"),
2566            };
2567            if let Some(opengl) = self.opengl.as_mut() {
2568                match opengl.draw_decoded_masks(dst, render_detect, &segmentation, overlay) {
2569                    Ok(_) => {
2570                        log::trace!(
2571                            "draw_proto_masks with hybrid (cpu+opengl) in {:?}",
2572                            start.elapsed()
2573                        );
2574                        return Ok(());
2575                    }
2576                    Err(e) => {
2577                        log::trace!(
2578                            "draw_proto_masks hybrid path failed, falling back to cpu: {e:?}"
2579                        );
2580                    }
2581                }
2582            }
2583        }
2584
2585        let Some(cpu) = self.cpu.as_mut() else {
2586            return Err(Error::Internal(
2587                "draw_proto_masks requires CPU backend for fallback path".into(),
2588            ));
2589        };
2590        log::trace!("draw_proto_masks started with cpu in {:?}", start.elapsed());
2591        cpu.draw_proto_masks(dst, render_detect, proto_data, overlay)
2592    }
2593
2594    fn set_class_colors(&mut self, colors: &[[u8; 4]]) -> Result<()> {
2595        let start = Instant::now();
2596
2597        // ── Forced backend: no fallback chain ────────────────────────
2598        if let Some(forced) = self.forced_backend {
2599            return match forced {
2600                ForcedBackend::Cpu => {
2601                    if let Some(cpu) = self.cpu.as_mut() {
2602                        return cpu.set_class_colors(colors);
2603                    }
2604                    Err(Error::ForcedBackendUnavailable("cpu".into()))
2605                }
2606                ForcedBackend::G2d => Err(Error::NotSupported(
2607                    "g2d does not support set_class_colors".into(),
2608                )),
2609                ForcedBackend::OpenGl => {
2610                    #[cfg(target_os = "linux")]
2611                    #[cfg(feature = "opengl")]
2612                    if let Some(opengl) = self.opengl.as_mut() {
2613                        return opengl.set_class_colors(colors);
2614                    }
2615                    Err(Error::ForcedBackendUnavailable("opengl".into()))
2616                }
2617            };
2618        }
2619
2620        // skip G2D as it doesn't support rendering to image
2621
2622        #[cfg(target_os = "linux")]
2623        #[cfg(feature = "opengl")]
2624        if let Some(opengl) = self.opengl.as_mut() {
2625            log::trace!("image started with opengl in {:?}", start.elapsed());
2626            match opengl.set_class_colors(colors) {
2627                Ok(_) => {
2628                    log::trace!("colors set with opengl in {:?}", start.elapsed());
2629                    return Ok(());
2630                }
2631                Err(e) => {
2632                    log::trace!("colors didn't set with opengl: {e:?}")
2633                }
2634            }
2635        }
2636        log::trace!("image started with cpu in {:?}", start.elapsed());
2637        if let Some(cpu) = self.cpu.as_mut() {
2638            match cpu.set_class_colors(colors) {
2639                Ok(_) => {
2640                    log::trace!("colors set with cpu in {:?}", start.elapsed());
2641                    return Ok(());
2642                }
2643                Err(e) => {
2644                    log::trace!("colors didn't set with cpu: {e:?}");
2645                    return Err(e);
2646                }
2647            }
2648        }
2649        Err(Error::NoConverter)
2650    }
2651}
2652
2653// ---------------------------------------------------------------------------
2654// Image loading / saving helpers
2655// ---------------------------------------------------------------------------
2656
2657/// Test-only convenience helper that peeks the image header, allocates a
2658/// tensor sized to the image (honoring DMA pitch padding on Linux when
2659/// requested), and decodes via [`edgefirst_codec`]. Mirrors the semantics of
2660/// the removed public `load_image` API for test sites; production callers
2661/// should use the explicit peek → allocate → decode pattern directly.
2662#[cfg(test)]
2663pub(crate) fn load_image_test_helper(
2664    image: &[u8],
2665    format: Option<PixelFormat>,
2666    memory: Option<TensorMemory>,
2667) -> Result<TensorDyn> {
2668    use edgefirst_codec::{peek_info, ImageDecoder, ImageLoad};
2669
2670    // Peek the source header to get its NATIVE format and dimensions. The
2671    // codec now emits the source's native format (JPEG → Nv12/Grey, PNG →
2672    // Rgb/Rgba/Grey) and configures the destination tensor itself.
2673    let info = peek_info(image)?;
2674    let native_fmt = info.format;
2675    let w = info.width;
2676    let h = info.height;
2677
2678    let mut decoder = ImageDecoder::new();
2679
2680    // Decode into a native-format tensor. The decoder sets the tensor's
2681    // dims+format, so we allocate it sized to the native layout.
2682    #[cfg(target_os = "linux")]
2683    let native_src = {
2684        if let Some(aligned_pitch) = padded_dma_pitch_for(native_fmt, w, &memory) {
2685            let mut dma = Tensor::<u8>::image_with_stride(
2686                w,
2687                h,
2688                native_fmt,
2689                aligned_pitch,
2690                Some(TensorMemory::Dma),
2691            )?;
2692            dma.load_image(&mut decoder, image)?;
2693            TensorDyn::from(dma)
2694        } else {
2695            let mut img = Tensor::<u8>::image(w, h, native_fmt, memory)?;
2696            img.load_image(&mut decoder, image)?;
2697            TensorDyn::from(img)
2698        }
2699    };
2700    #[cfg(not(target_os = "linux"))]
2701    let native_src = {
2702        let mut img = Tensor::<u8>::image(w, h, native_fmt, memory)?;
2703        img.load_image(&mut decoder, image)?;
2704        TensorDyn::from(img)
2705    };
2706
2707    // If the caller requested a different format, convert into it (same
2708    // dims) using a headless CPU-backed processor so the helper works
2709    // without GPU/G2D hardware.
2710    match format {
2711        Some(f) if f != native_fmt => {
2712            let mut dst = TensorDyn::image(w, h, f, DType::U8, memory)?;
2713            // `ImageProcessorConfig` has platform-specific fields: on Linux it
2714            // carries extra GL/G2D options so `..Default::default()` is needed,
2715            // but on macOS `backend` is the only field, making the update
2716            // redundant (clippy::needless_update). Allow it for cross-platform
2717            // parity — the alternative (field reassign) trips
2718            // clippy::field_reassign_with_default on Linux instead.
2719            #[allow(clippy::needless_update)]
2720            let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
2721                backend: ComputeBackend::Cpu,
2722                ..Default::default()
2723            })?;
2724            proc.convert(
2725                &native_src,
2726                &mut dst,
2727                Rotation::None,
2728                Flip::None,
2729                Crop::default(),
2730            )?;
2731            Ok(dst)
2732        }
2733        _ => Ok(native_src),
2734    }
2735}
2736
2737/// Save a [`TensorDyn`] image as a JPEG file.
2738///
2739/// Only packed RGB and RGBA formats are supported.
2740pub fn save_jpeg(tensor: &TensorDyn, path: impl AsRef<std::path::Path>, quality: u8) -> Result<()> {
2741    let t = tensor.as_u8().ok_or(Error::UnsupportedFormat(
2742        "save_jpeg requires u8 tensor".to_string(),
2743    ))?;
2744    let fmt = t.format().ok_or(Error::NotAnImage)?;
2745    if fmt.layout() != PixelLayout::Packed {
2746        return Err(Error::NotImplemented(
2747            "Saving planar images is not supported".to_string(),
2748        ));
2749    }
2750
2751    let colour = match fmt {
2752        PixelFormat::Rgb => jpeg_encoder::ColorType::Rgb,
2753        PixelFormat::Rgba => jpeg_encoder::ColorType::Rgba,
2754        _ => {
2755            return Err(Error::NotImplemented(
2756                "Unsupported image format for saving".to_string(),
2757            ));
2758        }
2759    };
2760
2761    let w = t.width().ok_or(Error::NotAnImage)?;
2762    let h = t.height().ok_or(Error::NotAnImage)?;
2763    let encoder = jpeg_encoder::Encoder::new_file(path, quality)?;
2764    let tensor_map = t.map()?;
2765
2766    encoder.encode(&tensor_map, w as u16, h as u16, colour)?;
2767
2768    Ok(())
2769}
2770
2771pub(crate) struct FunctionTimer<T: Display> {
2772    name: T,
2773    start: std::time::Instant,
2774}
2775
2776impl<T: Display> FunctionTimer<T> {
2777    pub fn new(name: T) -> Self {
2778        Self {
2779            name,
2780            start: std::time::Instant::now(),
2781        }
2782    }
2783}
2784
2785impl<T: Display> Drop for FunctionTimer<T> {
2786    fn drop(&mut self) {
2787        log::trace!("{} elapsed: {:?}", self.name, self.start.elapsed())
2788    }
2789}
2790
2791const DEFAULT_COLORS: [[f32; 4]; 20] = [
2792    [0., 1., 0., 0.7],
2793    [1., 0.5568628, 0., 0.7],
2794    [0.25882353, 0.15294118, 0.13333333, 0.7],
2795    [0.8, 0.7647059, 0.78039216, 0.7],
2796    [0.3137255, 0.3137255, 0.3137255, 0.7],
2797    [0.1411765, 0.3098039, 0.1215686, 0.7],
2798    [1., 0.95686275, 0.5137255, 0.7],
2799    [0.3529412, 0.32156863, 0., 0.7],
2800    [0.4235294, 0.6235294, 0.6509804, 0.7],
2801    [0.5098039, 0.5098039, 0.7294118, 0.7],
2802    [0.00784314, 0.18823529, 0.29411765, 0.7],
2803    [0.0, 0.2706, 1.0, 0.7],
2804    [0.0, 0.0, 0.0, 0.7],
2805    [0.0, 0.5, 0.0, 0.7],
2806    [1.0, 0.0, 0.0, 0.7],
2807    [0.0, 0.0, 1.0, 0.7],
2808    [1.0, 0.5, 0.5, 0.7],
2809    [0.1333, 0.5451, 0.1333, 0.7],
2810    [0.1176, 0.4118, 0.8235, 0.7],
2811    [1., 1., 1., 0.7],
2812];
2813
2814const fn denorm<const M: usize, const N: usize>(a: [[f32; M]; N]) -> [[u8; M]; N] {
2815    let mut result = [[0; M]; N];
2816    let mut i = 0;
2817    while i < N {
2818        let mut j = 0;
2819        while j < M {
2820            result[i][j] = (a[i][j] * 255.0).round() as u8;
2821            j += 1;
2822        }
2823        i += 1;
2824    }
2825    result
2826}
2827
2828const DEFAULT_COLORS_U8: [[u8; 4]; 20] = denorm(DEFAULT_COLORS);
2829
2830#[cfg(test)]
2831#[cfg_attr(coverage_nightly, coverage(off))]
2832mod alignment_tests {
2833    use super::*;
2834
2835    #[test]
2836    fn align_width_rgba8_common_widths() {
2837        // RGBA8 (bpp=4, lcm(64,4)=64, so width must round to multiple of 16 px).
2838        assert_eq!(align_width_for_gpu_pitch(640, 4), 640); // 2560 byte pitch — already aligned
2839        assert_eq!(align_width_for_gpu_pitch(1280, 4), 1280); // 5120
2840        assert_eq!(align_width_for_gpu_pitch(1920, 4), 1920); // 7680
2841        assert_eq!(align_width_for_gpu_pitch(3840, 4), 3840); // 15360
2842                                                              // crowd.png case from the imx95 investigation:
2843        assert_eq!(align_width_for_gpu_pitch(3004, 4), 3008); // 12016 → 12032
2844        assert_eq!(align_width_for_gpu_pitch(3000, 4), 3008); // 12000 → 12032
2845        assert_eq!(align_width_for_gpu_pitch(17, 4), 32); // 68 → 128
2846        assert_eq!(align_width_for_gpu_pitch(1, 4), 16); // 4 → 64
2847    }
2848
2849    #[test]
2850    fn align_width_rgb888_packed() {
2851        // RGB888 (bpp=3, lcm(64,3)=192, so width must round to multiple of 64 px).
2852        assert_eq!(align_width_for_gpu_pitch(64, 3), 64); // 192 byte pitch
2853        assert_eq!(align_width_for_gpu_pitch(640, 3), 640); // 1920
2854        assert_eq!(align_width_for_gpu_pitch(1, 3), 64); // 3 → 192
2855        assert_eq!(align_width_for_gpu_pitch(65, 3), 128); // 195 → 384
2856                                                           // Verify the rounded width × bpp is a clean multiple of the LCM.
2857        for w in [3004usize, 1281, 100, 17] {
2858            let padded = align_width_for_gpu_pitch(w, 3);
2859            assert!(padded >= w);
2860            assert_eq!((padded * 3) % 64, 0);
2861            assert_eq!((padded * 3) % 3, 0);
2862        }
2863    }
2864
2865    #[test]
2866    fn align_width_grey_u8() {
2867        // Grey (bpp=1, lcm(64,1)=64, so width must round to multiple of 64 px).
2868        assert_eq!(align_width_for_gpu_pitch(64, 1), 64);
2869        assert_eq!(align_width_for_gpu_pitch(640, 1), 640);
2870        assert_eq!(align_width_for_gpu_pitch(1, 1), 64);
2871        assert_eq!(align_width_for_gpu_pitch(65, 1), 128);
2872    }
2873
2874    #[test]
2875    fn align_width_zero_inputs() {
2876        assert_eq!(align_width_for_gpu_pitch(0, 4), 0);
2877        assert_eq!(align_width_for_gpu_pitch(640, 0), 640);
2878    }
2879
2880    #[test]
2881    fn align_width_never_returns_smaller_than_input() {
2882        // Spot-check the "returned width >= input width" contract across a
2883        // range of values that would previously have hit `width * bpp`
2884        // overflow paths.
2885        for &bpp in &[1usize, 2, 3, 4, 8] {
2886            for &w in &[
2887                1usize,
2888                17,
2889                64,
2890                65,
2891                100,
2892                1280,
2893                1281,
2894                1920,
2895                3004,
2896                3072,
2897                3840,
2898                usize::MAX / 8,
2899                usize::MAX / 4,
2900                usize::MAX / 2,
2901                usize::MAX - 1,
2902                usize::MAX,
2903            ] {
2904                let aligned = align_width_for_gpu_pitch(w, bpp);
2905                assert!(
2906                    aligned >= w,
2907                    "align_width_for_gpu_pitch({w}, {bpp}) = {aligned} < {w}"
2908                );
2909            }
2910        }
2911    }
2912
2913    #[test]
2914    fn align_width_overflow_returns_unaligned_not_smaller() {
2915        // For width values close to usize::MAX, padding up would wrap. The
2916        // function must return the original width rather than wrapping or
2917        // panicking. A pre-aligned width round-trips unchanged even at the
2918        // extreme.
2919        let aligned_extreme = usize::MAX - 15; // 16-pixel boundary for RGBA8
2920        assert_eq!(
2921            align_width_for_gpu_pitch(aligned_extreme, 4),
2922            aligned_extreme
2923        );
2924        // A misaligned extreme value cannot be rounded up — the function
2925        // returns the original.
2926        let misaligned_extreme = usize::MAX - 1;
2927        let result = align_width_for_gpu_pitch(misaligned_extreme, 4);
2928        assert!(
2929            result == misaligned_extreme || result >= misaligned_extreme,
2930            "extreme misaligned width must not be rounded down to {result}"
2931        );
2932    }
2933
2934    #[test]
2935    fn checked_lcm_basic_and_overflow() {
2936        assert_eq!(checked_num_integer_lcm(64, 4), Some(64));
2937        assert_eq!(checked_num_integer_lcm(64, 3), Some(192));
2938        assert_eq!(checked_num_integer_lcm(64, 1), Some(64));
2939        assert_eq!(checked_num_integer_lcm(0, 4), Some(0));
2940        assert_eq!(checked_num_integer_lcm(64, 0), Some(0));
2941        // Coprime values whose product exceeds usize::MAX must return None.
2942        assert_eq!(
2943            checked_num_integer_lcm(usize::MAX, usize::MAX - 1),
2944            None,
2945            "coprime extreme values must overflow detect, not panic"
2946        );
2947    }
2948
2949    #[test]
2950    fn primary_plane_bpp_known_formats() {
2951        // Packed formats use channels × elem_size.
2952        assert_eq!(primary_plane_bpp(PixelFormat::Rgba, 1), Some(4));
2953        assert_eq!(primary_plane_bpp(PixelFormat::Bgra, 1), Some(4));
2954        assert_eq!(primary_plane_bpp(PixelFormat::Rgb, 1), Some(3));
2955        assert_eq!(primary_plane_bpp(PixelFormat::Grey, 1), Some(1));
2956        // Semi-planar (NV12) reports the luma plane's bpp.
2957        assert_eq!(primary_plane_bpp(PixelFormat::Nv12, 1), Some(1));
2958    }
2959}
2960
2961#[cfg(test)]
2962#[cfg_attr(coverage_nightly, coverage(off))]
2963#[allow(deprecated)]
2964mod image_tests {
2965    use super::*;
2966    use crate::{CPUProcessor, Rotation};
2967    #[cfg(target_os = "linux")]
2968    use edgefirst_tensor::is_dma_available;
2969    use edgefirst_tensor::{TensorMapTrait, TensorMemory, TensorTrait};
2970    use image::buffer::ConvertBuffer;
2971
2972    /// Test helper: call `ImageProcessorTrait::convert()` on two `TensorDyn`s
2973    /// by going through the `TensorDyn` API.
2974    ///
2975    /// Returns the `(src_image, dst_image)` reconstructed from the TensorDyn
2976    /// round-trip so the caller can feed them to `compare_images` etc.
2977    fn convert_img(
2978        proc: &mut dyn ImageProcessorTrait,
2979        src: TensorDyn,
2980        dst: TensorDyn,
2981        rotation: Rotation,
2982        flip: Flip,
2983        crop: Crop,
2984    ) -> (Result<()>, TensorDyn, TensorDyn) {
2985        let src_fourcc = src.format().unwrap();
2986        let dst_fourcc = dst.format().unwrap();
2987        let src_dyn = src;
2988        let mut dst_dyn = dst;
2989        let result = proc.convert(&src_dyn, &mut dst_dyn, rotation, flip, crop);
2990        let src_back = {
2991            let mut __t = src_dyn.into_u8().unwrap();
2992            __t.set_format(src_fourcc).unwrap();
2993            TensorDyn::from(__t)
2994        };
2995        let dst_back = {
2996            let mut __t = dst_dyn.into_u8().unwrap();
2997            __t.set_format(dst_fourcc).unwrap();
2998            TensorDyn::from(__t)
2999        };
3000        (result, src_back, dst_back)
3001    }
3002
3003    #[ctor::ctor(unsafe)]
3004    fn init() {
3005        env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
3006    }
3007
3008    macro_rules! function {
3009        () => {{
3010            fn f() {}
3011            fn type_name_of<T>(_: T) -> &'static str {
3012                std::any::type_name::<T>()
3013            }
3014            let name = type_name_of(f);
3015
3016            // Find and cut the rest of the path
3017            match &name[..name.len() - 3].rfind(':') {
3018                Some(pos) => &name[pos + 1..name.len() - 3],
3019                None => &name[..name.len() - 3],
3020            }
3021        }};
3022    }
3023
3024    /// Master oracle for the view/batch **destination** batch engine: render `N`
3025    /// tiles into row-bands of ONE tall destination and assert each band equals
3026    /// the same source converted standalone — proving correct band placement and
3027    /// that a later tile's letterbox clear / draw never wipes a sibling band. On
3028    /// the Linux GL backend the `N` `convert_deferred` calls share ONE parent
3029    /// EGLImage import (each tile is a `glViewport`/`glScissor` ROI) and sync
3030    /// once at `flush()`; other backends fall back to an eager per-band convert
3031    /// (CPU writes via offset + parent stride). Either way the oracle must hold.
3032    ///
3033    /// Identical source/tile size makes the convert an exact copy, so the
3034    /// assertion is backend-agnostic (no GL-vs-CPU resampling drift). Distinct
3035    /// solid colors per tile make any sibling wipe a hard failure.
3036    #[test]
3037    fn batch_view_dst_tiles_match_standalone() {
3038        let mut proc = match ImageProcessor::new() {
3039            Ok(p) => p,
3040            Err(e) => {
3041                eprintln!(
3042                    "SKIPPED: {} — ImageProcessor init failed ({e:?})",
3043                    function!()
3044                );
3045                return;
3046            }
3047        };
3048        let n = 3usize;
3049        let (w, h) = (32usize, 24usize);
3050        let colors: [[u8; 4]; 3] = [[210, 40, 40, 255], [40, 210, 40, 255], [40, 40, 210, 255]];
3051        let make_src = |c: [u8; 4]| -> TensorDyn {
3052            let bytes: Vec<u8> = c.iter().copied().cycle().take(w * h * 4).collect();
3053            load_bytes_to_tensor(w, h, PixelFormat::Rgba, Some(TensorMemory::Mem), &bytes).unwrap()
3054        };
3055        // Tall destination: N stacked row-bands. DMA so the Linux GL band path
3056        // runs (one parent import + per-tile glViewport); skip if unavailable.
3057        let parent = match TensorDyn::image(
3058            w,
3059            n * h,
3060            PixelFormat::Rgba,
3061            DType::U8,
3062            Some(TensorMemory::Dma),
3063        ) {
3064            Ok(d) => d,
3065            Err(e) => {
3066                eprintln!(
3067                    "SKIPPED: {} — tall DMA destination alloc failed ({e:?})",
3068                    function!()
3069                );
3070                return;
3071            }
3072        };
3073
3074        // Deferred batch: one parent import, glViewport/scissor per band, one sync.
3075        for (i, &c) in colors.iter().enumerate().take(n) {
3076            let mut tile = parent.view(Region::new(0, i * h, w, h)).unwrap();
3077            proc.convert_deferred(
3078                &make_src(c),
3079                &mut tile,
3080                Rotation::None,
3081                Flip::None,
3082                Crop::no_crop(),
3083            )
3084            .unwrap_or_else(|e| panic!("convert_deferred tile {i}: {e:?}"));
3085        }
3086        proc.flush().unwrap();
3087
3088        for (i, &c) in colors.iter().enumerate().take(n) {
3089            // Standalone full-buffer convert of the same source = the oracle.
3090            let mut solo =
3091                TensorDyn::image(w, h, PixelFormat::Rgba, DType::U8, Some(TensorMemory::Dma))
3092                    .unwrap();
3093            proc.convert(
3094                &make_src(c),
3095                &mut solo,
3096                Rotation::None,
3097                Flip::None,
3098                Crop::no_crop(),
3099            )
3100            .unwrap();
3101
3102            let band = parent.view(Region::new(0, i * h, w, h)).unwrap();
3103            let band_bytes = band.as_u8().unwrap().map().unwrap().as_slice().to_vec();
3104            let solo_bytes = solo.as_u8().unwrap().map().unwrap().as_slice().to_vec();
3105            assert_eq!(
3106                band_bytes, solo_bytes,
3107                "tile {i}: band differs from standalone convert (placement or sibling wipe)"
3108            );
3109            assert!(
3110                band_bytes.chunks_exact(4).all(|p| p == c),
3111                "tile {i}: band is not the expected solid color {c:?} (sibling wipe?)"
3112            );
3113        }
3114    }
3115
3116    #[test]
3117    fn test_invalid_crop() {
3118        let src = TensorDyn::image(100, 100, PixelFormat::Rgb, DType::U8, None).unwrap();
3119        let dst = TensorDyn::image(100, 100, PixelFormat::Rgb, DType::U8, None).unwrap();
3120
3121        // A source crop exceeding the source bounds is rejected.
3122        let crop = Crop::new().with_source(Some(Region::new(50, 50, 60, 60)));
3123        assert!(matches!(
3124            crop.check_crop_dyn(&src, &dst),
3125            Err(Error::CropInvalid(_))
3126        ));
3127
3128        // A source crop within bounds is valid.
3129        let crop = Crop::new().with_source(Some(Region::new(0, 0, 10, 10)));
3130        assert!(crop.check_crop_dyn(&src, &dst).is_ok());
3131
3132        // Letterbox is always valid — placement is computed within the dst.
3133        assert!(Crop::letterbox([0, 0, 0, 255])
3134            .check_crop_dyn(&src, &dst)
3135            .is_ok());
3136    }
3137
3138    #[test]
3139    fn test_invalid_tensor_format() -> Result<(), Error> {
3140        // 4D tensor cannot be set to a 3-channel pixel format
3141        let mut tensor = Tensor::<u8>::new(&[720, 1280, 4, 1], None, None)?;
3142        let result = tensor.set_format(PixelFormat::Rgb);
3143        assert!(result.is_err(), "4D tensor should reject set_format");
3144
3145        // Tensor with wrong channel count for the format
3146        let mut tensor = Tensor::<u8>::new(&[720, 1280, 4], None, None)?;
3147        let result = tensor.set_format(PixelFormat::Rgb);
3148        assert!(result.is_err(), "4-channel tensor should reject RGB format");
3149
3150        Ok(())
3151    }
3152
3153    #[test]
3154    fn test_invalid_image_file() -> Result<(), Error> {
3155        let result = crate::load_image_test_helper(&[123; 5000], None, None);
3156        assert!(
3157            matches!(result, Err(Error::Codec(_))),
3158            "unrecognised bytes should surface as Error::Codec, got {result:?}"
3159        );
3160        Ok(())
3161    }
3162
3163    #[test]
3164    fn test_invalid_jpeg_format() -> Result<(), Error> {
3165        let result = crate::load_image_test_helper(&[123; 5000], Some(PixelFormat::Yuyv), None);
3166        // YUYV is not a valid decode target; peek_info fails before the magic-
3167        // bytes check, so the precise variant depends on which error fires first.
3168        assert!(
3169            matches!(result, Err(Error::Codec(_))),
3170            "Yuyv target with garbage bytes should surface as Error::Codec, got {result:?}"
3171        );
3172        Ok(())
3173    }
3174
3175    #[test]
3176    fn test_load_resize_save() {
3177        let file = edgefirst_bench::testdata::read("zidane.jpg");
3178        let img = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
3179        assert_eq!(img.width(), Some(1280));
3180        assert_eq!(img.height(), Some(720));
3181
3182        let dst = TensorDyn::image(640, 360, PixelFormat::Rgba, DType::U8, None).unwrap();
3183        let mut converter = CPUProcessor::new();
3184        let (result, _img, dst) = convert_img(
3185            &mut converter,
3186            img,
3187            dst,
3188            Rotation::None,
3189            Flip::None,
3190            Crop::no_crop(),
3191        );
3192        result.unwrap();
3193        assert_eq!(dst.width(), Some(640));
3194        assert_eq!(dst.height(), Some(360));
3195
3196        crate::save_jpeg(&dst, "zidane_resized.jpg", 80).unwrap();
3197
3198        let file = std::fs::read("zidane_resized.jpg").unwrap();
3199        // With `format: None` the helper returns the source's native format.
3200        // The codec now decodes colour JPEGs to NV12 (was RGB previously).
3201        let img = crate::load_image_test_helper(&file, None, None).unwrap();
3202        assert_eq!(img.width(), Some(640));
3203        assert_eq!(img.height(), Some(360));
3204        assert_eq!(img.format().unwrap(), PixelFormat::Nv12);
3205    }
3206
3207    #[test]
3208    fn test_from_tensor_planar() -> Result<(), Error> {
3209        let mut tensor = Tensor::new(&[3, 720, 1280], None, None)?;
3210        tensor
3211            .map()?
3212            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.8bps"));
3213        let planar = {
3214            tensor
3215                .set_format(PixelFormat::PlanarRgb)
3216                .map_err(|e| crate::Error::Internal(e.to_string()))?;
3217            TensorDyn::from(tensor)
3218        };
3219
3220        let rbga = load_bytes_to_tensor(
3221            1280,
3222            720,
3223            PixelFormat::Rgba,
3224            None,
3225            &edgefirst_bench::testdata::read("camera720p.rgba"),
3226        )?;
3227        compare_images_convert_to_rgb(&planar, &rbga, 0.98, function!());
3228
3229        Ok(())
3230    }
3231
3232    #[test]
3233    fn test_from_tensor_invalid_format() {
3234        // PixelFormat::from_fourcc_str returns None for unknown FourCC codes.
3235        // Since there's no "TEST" pixel format, this validates graceful handling.
3236        assert!(PixelFormat::from_fourcc(u32::from_le_bytes(*b"TEST")).is_none());
3237    }
3238
3239    #[test]
3240    #[should_panic(expected = "Failed to save planar RGB image")]
3241    fn test_save_planar() {
3242        let planar_img = load_bytes_to_tensor(
3243            1280,
3244            720,
3245            PixelFormat::PlanarRgb,
3246            None,
3247            &edgefirst_bench::testdata::read("camera720p.8bps"),
3248        )
3249        .unwrap();
3250
3251        let save_path = "/tmp/planar_rgb.jpg";
3252        crate::save_jpeg(&planar_img, save_path, 90).expect("Failed to save planar RGB image");
3253    }
3254
3255    #[test]
3256    #[should_panic(expected = "Failed to save YUYV image")]
3257    fn test_save_yuyv() {
3258        let planar_img = load_bytes_to_tensor(
3259            1280,
3260            720,
3261            PixelFormat::Yuyv,
3262            None,
3263            &edgefirst_bench::testdata::read("camera720p.yuyv"),
3264        )
3265        .unwrap();
3266
3267        let save_path = "/tmp/yuyv.jpg";
3268        crate::save_jpeg(&planar_img, save_path, 90).expect("Failed to save YUYV image");
3269    }
3270
3271    #[test]
3272    fn test_rotation_angle() {
3273        assert_eq!(Rotation::from_degrees_clockwise(0), Rotation::None);
3274        assert_eq!(Rotation::from_degrees_clockwise(90), Rotation::Clockwise90);
3275        assert_eq!(Rotation::from_degrees_clockwise(180), Rotation::Rotate180);
3276        assert_eq!(
3277            Rotation::from_degrees_clockwise(270),
3278            Rotation::CounterClockwise90
3279        );
3280        assert_eq!(Rotation::from_degrees_clockwise(360), Rotation::None);
3281        assert_eq!(Rotation::from_degrees_clockwise(450), Rotation::Clockwise90);
3282        assert_eq!(Rotation::from_degrees_clockwise(540), Rotation::Rotate180);
3283        assert_eq!(
3284            Rotation::from_degrees_clockwise(630),
3285            Rotation::CounterClockwise90
3286        );
3287    }
3288
3289    #[test]
3290    #[should_panic(expected = "rotation angle is not a multiple of 90")]
3291    fn test_rotation_angle_panic() {
3292        Rotation::from_degrees_clockwise(361);
3293    }
3294
3295    #[test]
3296    fn test_disable_env_var() -> Result<(), Error> {
3297        // Acquire the env-var mutex for the entire test body so we never race
3298        // with test_force_backend_* or test_draw_proto_masks_no_cpu_returns_error.
3299        let _lock = acquire_env_lock();
3300
3301        // Snapshot ALL env vars we might touch so the RAII guard restores them
3302        // on exit (even on panic), preventing env-var poisoning of other tests.
3303        let _guard = EnvGuard::snapshot(&[
3304            "EDGEFIRST_FORCE_BACKEND",
3305            "EDGEFIRST_DISABLE_GL",
3306            "EDGEFIRST_DISABLE_G2D",
3307            "EDGEFIRST_DISABLE_CPU",
3308        ]);
3309
3310        // EDGEFIRST_FORCE_BACKEND takes precedence over EDGEFIRST_DISABLE_*,
3311        // so clear it for the duration of this test.
3312        unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") };
3313
3314        #[cfg(target_os = "linux")]
3315        {
3316            unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
3317            let converter = ImageProcessor::new()?;
3318            assert!(converter.g2d.is_none());
3319            unsafe { std::env::remove_var("EDGEFIRST_DISABLE_G2D") };
3320        }
3321
3322        #[cfg(target_os = "linux")]
3323        #[cfg(feature = "opengl")]
3324        {
3325            unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
3326            let converter = ImageProcessor::new()?;
3327            assert!(converter.opengl.is_none());
3328            unsafe { std::env::remove_var("EDGEFIRST_DISABLE_GL") };
3329        }
3330
3331        unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
3332        let converter = ImageProcessor::new()?;
3333        assert!(converter.cpu.is_none());
3334        unsafe { std::env::remove_var("EDGEFIRST_DISABLE_CPU") };
3335
3336        // Disable everything — convert must return NoConverter.
3337        unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
3338        unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
3339        unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
3340        let mut converter = ImageProcessor::new()?;
3341
3342        let src = TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None)?;
3343        let dst = TensorDyn::image(640, 360, PixelFormat::Rgba, DType::U8, None)?;
3344        let (result, _src, _dst) = convert_img(
3345            &mut converter,
3346            src,
3347            dst,
3348            Rotation::None,
3349            Flip::None,
3350            Crop::no_crop(),
3351        );
3352        assert!(matches!(result, Err(Error::NoConverter)));
3353        // _guard restores all env vars on drop.
3354        Ok(())
3355    }
3356
3357    #[test]
3358    fn test_unsupported_conversion() {
3359        let src = TensorDyn::image(1280, 720, PixelFormat::Nv12, DType::U8, None).unwrap();
3360        let dst = TensorDyn::image(640, 360, PixelFormat::Nv12, DType::U8, None).unwrap();
3361        let mut converter = ImageProcessor::new().unwrap();
3362        let (result, _src, _dst) = convert_img(
3363            &mut converter,
3364            src,
3365            dst,
3366            Rotation::None,
3367            Flip::None,
3368            Crop::no_crop(),
3369        );
3370        log::debug!("result: {:?}", result);
3371        assert!(matches!(
3372            result,
3373            Err(Error::NotSupported(e)) if e.starts_with("Conversion from NV12 to NV12")
3374        ));
3375    }
3376
3377    #[test]
3378    fn test_load_grey() {
3379        // A single-component (greyscale) JPEG decodes to its native GREY
3380        // format, which has no even-dimension constraint, so the 1024×681
3381        // `grey.jpg` loads and converts to RGBA successfully.
3382        let grey_img = crate::load_image_test_helper(
3383            &edgefirst_bench::testdata::read("grey.jpg"),
3384            Some(PixelFormat::Rgba),
3385            None,
3386        )
3387        .unwrap();
3388        assert_eq!(grey_img.width(), Some(1024));
3389        assert_eq!(grey_img.height(), Some(681));
3390
3391        // `grey-rgb.jpg` holds the same grey content but is encoded as a
3392        // 3-component (colour) JPEG, so the codec decodes it to native NV12.
3393        // Its 1024×681 dimensions have an odd height; NV12 now represents odd
3394        // dimensions via the `H + ceil(H/2)` combined-plane height, so the
3395        // decode succeeds and converts to RGBA at the true dimensions.
3396        let grey_but_rgb = crate::load_image_test_helper(
3397            &edgefirst_bench::testdata::read("grey-rgb.jpg"),
3398            Some(PixelFormat::Rgba),
3399            None,
3400        )
3401        .expect("odd-height colour JPEG should decode to NV12 and convert to RGBA");
3402        assert_eq!(grey_but_rgb.width(), Some(1024));
3403        assert_eq!(grey_but_rgb.height(), Some(681));
3404    }
3405
3406    #[test]
3407    fn test_new_nv12() {
3408        let nv12 = TensorDyn::image(1280, 720, PixelFormat::Nv12, DType::U8, None).unwrap();
3409        assert_eq!(nv12.height(), Some(720));
3410        assert_eq!(nv12.width(), Some(1280));
3411        assert_eq!(nv12.format().unwrap(), PixelFormat::Nv12);
3412        // PixelFormat::Nv12.channels() returns 1 (luma plane channel count)
3413        assert_eq!(nv12.format().unwrap().channels(), 1);
3414        assert!(nv12.format().is_some_and(
3415            |f| f.layout() == PixelLayout::Planar || f.layout() == PixelLayout::SemiPlanar
3416        ))
3417    }
3418
3419    #[test]
3420    #[cfg(target_os = "linux")]
3421    fn test_new_image_converter() {
3422        let dst_width = 640;
3423        let dst_height = 360;
3424        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
3425        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
3426
3427        let mut converter = ImageProcessor::new().unwrap();
3428        let converter_dst = converter
3429            .create_image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None)
3430            .unwrap();
3431        let (result, src, converter_dst) = convert_img(
3432            &mut converter,
3433            src,
3434            converter_dst,
3435            Rotation::None,
3436            Flip::None,
3437            Crop::no_crop(),
3438        );
3439        result.unwrap();
3440
3441        let cpu_dst =
3442            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
3443        let mut cpu_converter = CPUProcessor::new();
3444        let (result, _src, cpu_dst) = convert_img(
3445            &mut cpu_converter,
3446            src,
3447            cpu_dst,
3448            Rotation::None,
3449            Flip::None,
3450            Crop::no_crop(),
3451        );
3452        result.unwrap();
3453
3454        compare_images(&converter_dst, &cpu_dst, 0.98, function!());
3455    }
3456
3457    #[test]
3458    #[cfg(target_os = "linux")]
3459    fn test_create_image_dtype_i8() {
3460        let mut converter = ImageProcessor::new().unwrap();
3461
3462        // I8 image should allocate successfully via create_image
3463        let dst = converter
3464            .create_image(320, 240, PixelFormat::Rgb, DType::I8, None)
3465            .unwrap();
3466        assert_eq!(dst.dtype(), DType::I8);
3467        assert!(dst.width() == Some(320));
3468        assert!(dst.height() == Some(240));
3469        assert_eq!(dst.format(), Some(PixelFormat::Rgb));
3470
3471        // U8 for comparison
3472        let dst_u8 = converter
3473            .create_image(320, 240, PixelFormat::Rgb, DType::U8, None)
3474            .unwrap();
3475        assert_eq!(dst_u8.dtype(), DType::U8);
3476
3477        // Convert into I8 dst should succeed
3478        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
3479        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
3480        let mut dst_i8 = converter
3481            .create_image(320, 240, PixelFormat::Rgb, DType::I8, None)
3482            .unwrap();
3483        converter
3484            .convert(
3485                &src,
3486                &mut dst_i8,
3487                Rotation::None,
3488                Flip::None,
3489                Crop::no_crop(),
3490            )
3491            .unwrap();
3492    }
3493
3494    #[test]
3495    #[cfg(target_os = "linux")]
3496    fn test_create_image_nv12_dma_non_aligned_width() {
3497        // create_image is fully stride-aware: a non-64-aligned NV12 DMA tensor
3498        // may legitimately carry a GPU-pitch-padded row stride — that is the
3499        // intended behaviour, not a bug. Verify the logical geometry is preserved
3500        // for any width and that a reported stride is a valid (>= logical)
3501        // padding, rather than asserting the absence of a stride.
3502        let converter = ImageProcessor::new().unwrap();
3503
3504        // 100 is intentionally not a multiple of 64 (the GPU pitch alignment).
3505        let result = converter.create_image(
3506            100,
3507            64,
3508            PixelFormat::Nv12,
3509            DType::U8,
3510            Some(TensorMemory::Dma),
3511        );
3512
3513        match result {
3514            Ok(img) => {
3515                assert_eq!(img.width(), Some(100));
3516                assert_eq!(img.height(), Some(64));
3517                assert_eq!(img.format(), Some(PixelFormat::Nv12));
3518                if let Some(stride) = img.row_stride() {
3519                    assert!(
3520                        stride >= 100,
3521                        "NV12 row_stride {stride} must be >= the logical width (100)",
3522                    );
3523                }
3524            }
3525            Err(e) => {
3526                // Skip cleanly on hosts without a dma-heap.
3527                eprintln!("SKIPPED: create_image NV12 DMA non-aligned width: {e}");
3528            }
3529        }
3530    }
3531
3532    #[test]
3533    #[ignore] // Hangs on desktop platforms where DMA-buf is unavailable and PBO
3534              // fallback triggers a GPU driver hang during SHM→texture upload (e.g.,
3535              // NVIDIA without /dev/dma_heap permissions). Works on embedded targets.
3536    fn test_crop_skip() {
3537        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
3538        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
3539
3540        let mut converter = ImageProcessor::new().unwrap();
3541        let converter_dst = converter
3542            .create_image(1280, 720, PixelFormat::Rgba, DType::U8, None)
3543            .unwrap();
3544        let crop = Crop::new().with_source(Some(Region::new(0, 0, 640, 640)));
3545        let (result, src, converter_dst) = convert_img(
3546            &mut converter,
3547            src,
3548            converter_dst,
3549            Rotation::None,
3550            Flip::None,
3551            crop,
3552        );
3553        result.unwrap();
3554
3555        let cpu_dst = TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None).unwrap();
3556        let mut cpu_converter = CPUProcessor::new();
3557        let (result, _src, cpu_dst) = convert_img(
3558            &mut cpu_converter,
3559            src,
3560            cpu_dst,
3561            Rotation::None,
3562            Flip::None,
3563            crop,
3564        );
3565        result.unwrap();
3566
3567        compare_images(&converter_dst, &cpu_dst, 0.99999, function!());
3568    }
3569
3570    #[test]
3571    fn test_invalid_pixel_format() {
3572        // PixelFormat::from_fourcc returns None for unknown formats,
3573        // so TensorDyn::image cannot be called with an invalid format.
3574        assert!(PixelFormat::from_fourcc(u32::from_le_bytes(*b"TEST")).is_none());
3575    }
3576
3577    // Helper function to check if G2D library is available (Linux/i.MX8 only)
3578    #[cfg(target_os = "linux")]
3579    static G2D_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
3580
3581    #[cfg(target_os = "linux")]
3582    fn is_g2d_available() -> bool {
3583        *G2D_AVAILABLE.get_or_init(|| G2DProcessor::new().is_ok())
3584    }
3585
3586    #[cfg(target_os = "linux")]
3587    #[cfg(feature = "opengl")]
3588    static GL_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
3589
3590    #[cfg(target_os = "linux")]
3591    #[cfg(feature = "opengl")]
3592    // Helper function to check if OpenGL is available
3593    fn is_opengl_available() -> bool {
3594        #[cfg(all(target_os = "linux", feature = "opengl"))]
3595        {
3596            *GL_AVAILABLE.get_or_init(|| GLProcessorThreaded::new(None).is_ok())
3597        }
3598
3599        #[cfg(not(all(target_os = "linux", feature = "opengl")))]
3600        {
3601            false
3602        }
3603    }
3604
3605    /// CI canary: fails the lane when the GL backend cannot initialize.
3606    ///
3607    /// Every GL test in this suite self-skips when the backend is
3608    /// unavailable — correct for developer machines, but it means a broken
3609    /// CI GL stack (e.g. the macOS ANGLE re-sign step regressing, the exact
3610    /// failure mode documented in the workflow) ships an untested GL
3611    /// backend behind a green lane. Gated on `HAL_TEST_REQUIRE_GL=1`, set
3612    /// only by CI jobs that install a working GL stack; local runs without
3613    /// one pass trivially. On macOS it additionally requires
3614    /// `HAL_TEST_ALLOW_DLOPEN_ANGLE`, so coverage pass 1 (unsigned
3615    /// binaries, dlopen gate closed) skips it and pass 2 (signed) enforces.
3616    #[test]
3617    #[cfg(feature = "opengl")]
3618    fn gl_backend_available_canary() {
3619        let require_gl = std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1");
3620        if !require_gl {
3621            eprintln!(
3622                "SKIPPED: {} — HAL_TEST_REQUIRE_GL is not set to 1",
3623                function!()
3624            );
3625            return;
3626        }
3627        #[cfg(target_os = "macos")]
3628        if std::env::var_os("HAL_TEST_ALLOW_DLOPEN_ANGLE").is_none() {
3629            eprintln!(
3630                "SKIPPED: {} — ANGLE dlopen gate closed (coverage pass 1)",
3631                function!()
3632            );
3633            return;
3634        }
3635        GLProcessorThreaded::new(None).expect(
3636            "HAL_TEST_REQUIRE_GL=1 but the GL backend failed to initialize — \
3637             check the ANGLE install/re-sign step and binary entitlements \
3638             (macOS) or the EGL stack (Linux)",
3639        );
3640    }
3641
3642    #[test]
3643    fn test_load_jpeg_with_exif() {
3644        use edgefirst_codec::peek_info;
3645
3646        // The migrated codec NEVER applies EXIF orientation: it decodes to the
3647        // source's native (un-rotated) dimensions and reports the rotation via
3648        // ImageInfo. `zidane_rotated_exif.jpg` carries EXIF orientation 6
3649        // (90° clockwise) over a 1280×720 frame.
3650        let file = edgefirst_bench::testdata::read("zidane_rotated_exif.jpg").to_vec();
3651        let info = peek_info(&file).unwrap();
3652        assert_eq!(info.rotation_degrees, 90);
3653        assert!(!info.flip_horizontal);
3654
3655        let loaded = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
3656        // Native (un-rotated) dimensions — the decode does not rotate.
3657        assert_eq!(loaded.width(), Some(1280));
3658        assert_eq!(loaded.height(), Some(720));
3659
3660        // Applying the reported rotation downstream reproduces the upright
3661        // image: it matches `zidane.jpg` rotated by the same 90° clockwise.
3662        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
3663        let cpu_src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
3664
3665        let rotation = Rotation::from_degrees_clockwise(info.rotation_degrees as usize);
3666        let (dst_width, dst_height) = (cpu_src.height().unwrap(), cpu_src.width().unwrap());
3667
3668        let cpu_dst =
3669            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
3670        let mut cpu_converter = CPUProcessor::new();
3671
3672        // Rotate the native-orientation `loaded` frame and the native `zidane`
3673        // frame by the same reported rotation; the results must agree.
3674        let loaded_rotated =
3675            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
3676        let (r0, _loaded, loaded_rotated) = convert_img(
3677            &mut cpu_converter,
3678            loaded,
3679            loaded_rotated,
3680            rotation,
3681            Flip::None,
3682            Crop::no_crop(),
3683        );
3684        r0.unwrap();
3685
3686        let (result, _cpu_src, cpu_dst) = convert_img(
3687            &mut cpu_converter,
3688            cpu_src,
3689            cpu_dst,
3690            rotation,
3691            Flip::None,
3692            Crop::no_crop(),
3693        );
3694        result.unwrap();
3695
3696        compare_images(&loaded_rotated, &cpu_dst, 0.98, function!());
3697    }
3698
3699    #[test]
3700    fn test_load_png_with_exif() {
3701        use edgefirst_codec::peek_info;
3702
3703        // PNGs also report EXIF orientation without applying it.
3704        // `zidane_rotated_exif_180.png` carries EXIF orientation 3 (180°).
3705        let file = edgefirst_bench::testdata::read("zidane_rotated_exif_180.png").to_vec();
3706        let info = peek_info(&file).unwrap();
3707        assert_eq!(info.rotation_degrees, 180);
3708        assert!(!info.flip_horizontal);
3709
3710        let loaded = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
3711        // Native (un-rotated) dimensions — PNG decodes upright as authored.
3712        assert_eq!(loaded.height(), Some(720));
3713        assert_eq!(loaded.width(), Some(1280));
3714
3715        // The PNG fixture stores upright `zidane` pixels tagged with a 180°
3716        // EXIF orientation. Because the codec no longer applies the rotation,
3717        // the decoded pixels match `zidane.jpg` directly (no convert needed).
3718        // Re-applying the reported rotation to both must still agree.
3719        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
3720        let cpu_src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
3721
3722        let rotation = Rotation::from_degrees_clockwise(info.rotation_degrees as usize);
3723        let cpu_dst = TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None).unwrap();
3724        let mut cpu_converter = CPUProcessor::new();
3725
3726        let (result, _cpu_src, cpu_dst) = convert_img(
3727            &mut cpu_converter,
3728            cpu_src,
3729            cpu_dst,
3730            rotation,
3731            Flip::None,
3732            Crop::no_crop(),
3733        );
3734        result.unwrap();
3735
3736        // Rotate the decoded PNG by the same reported angle so both frames are
3737        // in the same (180°-rotated) orientation before comparing.
3738        let loaded_rotated =
3739            TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None).unwrap();
3740        let (r0, _loaded, loaded_rotated) = convert_img(
3741            &mut cpu_converter,
3742            loaded,
3743            loaded_rotated,
3744            rotation,
3745            Flip::None,
3746            Crop::no_crop(),
3747        );
3748        r0.unwrap();
3749
3750        // Threshold 0.95 (was 0.98): `loaded` comes from a lossless PNG decode
3751        // while `cpu_src` (zidane.jpg) now decodes through native NV12 (chroma
3752        // subsampling) before the RGBA conversion, so the two paths differ by a
3753        // couple of percent versus the old direct-RGB JPEG decode.
3754        compare_images(&loaded_rotated, &cpu_dst, 0.95, function!());
3755    }
3756
3757    /// Synthesise an RGB JPEG with a deterministic pattern at `(width, height)`
3758    /// using the workspace's `jpeg-encoder` crate (the `image` crate is
3759    /// compiled without its JPEG feature). Used to exercise the decoder /
3760    /// pitch-padding paths for arbitrary dimensions without having to bundle
3761    /// a fixture file per test size.
3762    #[cfg(target_os = "linux")]
3763    fn make_rgb_jpeg(width: u32, height: u32) -> Vec<u8> {
3764        let mut bytes = Vec::with_capacity((width * height * 3) as usize);
3765        for y in 0..height {
3766            for x in 0..width {
3767                bytes.push(((x + y) & 0xFF) as u8);
3768                bytes.push(((x.wrapping_mul(3)) & 0xFF) as u8);
3769                bytes.push(((y.wrapping_mul(5)) & 0xFF) as u8);
3770            }
3771        }
3772        let mut out = Vec::new();
3773        let encoder = jpeg_encoder::Encoder::new(&mut out, 85);
3774        encoder
3775            .encode(
3776                &bytes,
3777                width as u16,
3778                height as u16,
3779                jpeg_encoder::ColorType::Rgb,
3780            )
3781            .expect("jpeg-encoder must succeed on trivial input");
3782        out
3783    }
3784
3785    /// End-to-end: a 375×333 RGBA JPEG (width NOT divisible by 4) loaded
3786    /// via the pitch-padded DMA path and letterboxed through the GL
3787    /// backend must produce correct output. Before the Rgba/Bgra
3788    /// width%4 relaxation in `DmaImportAttrs::from_tensor`, this case
3789    /// failed the pre-check and forced a CPU texture upload fallback;
3790    /// with the relaxation, EGL import succeeds at the driver level and
3791    /// the GL fast path runs. Output correctness is checked against a
3792    /// CPU reference (convert ran with `EDGEFIRST_FORCE_BACKEND=cpu`).
3793    #[test]
3794    #[cfg(target_os = "linux")]
3795    #[cfg(feature = "opengl")]
3796    fn test_convert_rgba_non_4_aligned_width_end_to_end() {
3797        use edgefirst_tensor::is_dma_available;
3798        if !is_dma_available() {
3799            eprintln!(
3800                "SKIPPED: test_convert_rgba_non_4_aligned_width_end_to_end — DMA not available"
3801            );
3802            return;
3803        }
3804        // 375 is the canonical failure width from dataset loaders —
3805        // 375 * 4 = 1500 bytes/row, pitch-padded to 1536. Width%4 = 3,
3806        // so the old pre-check rejected it; new code accepts it.
3807        let jpeg = make_rgb_jpeg(375, 333);
3808        let src_gl = crate::load_image_test_helper(&jpeg, Some(PixelFormat::Rgba), None).unwrap();
3809        assert_eq!(src_gl.width(), Some(375));
3810        // Row stride must still be pitch-padded (separate concern from width).
3811        let stride = src_gl.row_stride().unwrap();
3812        assert_eq!(stride, 1536, "expected padded pitch 1536, got {stride}");
3813
3814        // GL-backed convert into a pitch-aligned 640×640 Rgba dest.
3815        let mut gl_proc = ImageProcessor::new().unwrap();
3816        let gl_dst = gl_proc
3817            .create_image(640, 640, PixelFormat::Rgba, DType::U8, None)
3818            .unwrap();
3819        let (r_gl, _src_gl, gl_dst) = convert_img(
3820            &mut gl_proc,
3821            src_gl,
3822            gl_dst,
3823            Rotation::None,
3824            Flip::None,
3825            Crop::no_crop(),
3826        );
3827        r_gl.expect("GL-backed convert must succeed for 375x333 Rgba src");
3828
3829        // CPU reference via a fresh load so the two paths start from
3830        // byte-identical inputs. `with_config(backend=Cpu)` forces the
3831        // CPU-only processor regardless of which backends the host has
3832        // available.
3833        let src_cpu =
3834            crate::load_image_test_helper(&jpeg, Some(PixelFormat::Rgba), Some(TensorMemory::Mem))
3835                .unwrap();
3836        let mut cpu_proc = ImageProcessor::with_config(ImageProcessorConfig {
3837            backend: ComputeBackend::Cpu,
3838            ..Default::default()
3839        })
3840        .unwrap();
3841        let cpu_dst = TensorDyn::image(
3842            640,
3843            640,
3844            PixelFormat::Rgba,
3845            DType::U8,
3846            Some(TensorMemory::Mem),
3847        )
3848        .unwrap();
3849        let (r_cpu, _src_cpu, cpu_dst) = convert_img(
3850            &mut cpu_proc,
3851            src_cpu,
3852            cpu_dst,
3853            Rotation::None,
3854            Flip::None,
3855            Crop::no_crop(),
3856        );
3857        r_cpu.unwrap();
3858
3859        // Structural similarity: the GL path may have gone through EGL
3860        // import OR fallen back to CPU texture upload — either way, the
3861        // output must match the CPU reference closely.
3862        compare_images(&gl_dst, &cpu_dst, 0.95, function!());
3863    }
3864
3865    /// Regression lock: loading a JPEG at a non-64-aligned RGBA pitch (e.g.
3866    /// 500×333 → natural pitch 2000, needs to be padded to 2048) must go
3867    /// through `image_with_stride` and set `row_stride()` / `effective_row_stride()`
3868    /// to the padded value. The earlier pitch-padding commit fixed this in
3869    /// `load_jpeg`; a regression would surface as `row_stride == None` or
3870    /// `effective_row_stride == 2000`.
3871    #[test]
3872    #[cfg(target_os = "linux")]
3873    fn test_load_jpeg_rgba_non_aligned_pitch_padded_dma() {
3874        use edgefirst_tensor::is_dma_available;
3875        if !is_dma_available() {
3876            eprintln!(
3877                "SKIPPED: test_load_jpeg_rgba_non_aligned_pitch_padded_dma — DMA not available"
3878            );
3879            return;
3880        }
3881        // Widths that force a non-64-aligned natural RGBA pitch. All three
3882        // are divisible by 4 so the EGL width-alignment pre-check passes.
3883        // The pitch-padding fix is what makes these importable at all.
3884        for &w in &[500u32, 612, 428] {
3885            let jpeg = make_rgb_jpeg(w, 333);
3886            let loaded =
3887                crate::load_image_test_helper(&jpeg, Some(PixelFormat::Rgba), None).unwrap();
3888            let natural = (w as usize) * 4;
3889            let aligned = crate::align_pitch_bytes_to_gpu_alignment(natural).unwrap();
3890            assert!(
3891                aligned > natural,
3892                "test sanity: width {w} should be unaligned"
3893            );
3894            let stride = loaded
3895                .row_stride()
3896                .expect("padded DMA path must set an explicit row_stride — regression if None");
3897            assert_eq!(
3898                stride, aligned,
3899                "width {w}: expected padded stride {aligned}, got {stride} \
3900                 (regression: pitch-padding branch skipped?)"
3901            );
3902            let eff = loaded.effective_row_stride().unwrap();
3903            assert_eq!(
3904                eff, aligned,
3905                "effective_row_stride must match stored stride"
3906            );
3907            assert_eq!(loaded.width(), Some(w as usize));
3908            assert_eq!(loaded.height(), Some(333));
3909        }
3910    }
3911
3912    /// `padded_dma_pitch_for` must respect the caller's memory choice and
3913    /// must NOT route into the pitch-padded DMA path when the caller left
3914    /// the choice to the allocator (`None`) but DMA is unavailable on the
3915    /// host. The padded path requires `image_with_stride`, which always
3916    /// allocates DMA — taking it on a system without `/dev/dma_heap`
3917    /// would convert a normally-working image load into a hard failure
3918    /// (since `Tensor::image(..., None)` would have fallen back to
3919    /// SHM/Mem).
3920    #[test]
3921    #[cfg(target_os = "linux")]
3922    fn test_padded_dma_pitch_for_respects_memory_choice() {
3923        use edgefirst_tensor::{is_dma_available, TensorMemory};
3924
3925        // 500×4 = 2000 → padded to 2048 by GPU alignment. Use it for
3926        // every case so any "no padding" answer is unambiguous.
3927        let unaligned_w = 500;
3928
3929        // Caller asks for Mem / Shm: never pad, regardless of DMA.
3930        assert_eq!(
3931            crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &Some(TensorMemory::Mem),),
3932            None,
3933            "Mem must never trigger DMA padding"
3934        );
3935        assert_eq!(
3936            crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &Some(TensorMemory::Shm),),
3937            None,
3938            "Shm must never trigger DMA padding"
3939        );
3940
3941        // Caller explicitly asks for DMA: always pad if width needs it.
3942        // Even if the runtime can't actually allocate DMA, the caller
3943        // owns that decision and the resulting allocation error is
3944        // their problem, not ours.
3945        assert_eq!(
3946            crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &Some(TensorMemory::Dma),),
3947            Some(2048),
3948            "explicit Dma must pad regardless of runtime DMA availability"
3949        );
3950
3951        // Caller leaves it to the allocator: behaviour depends on
3952        // host-runtime DMA availability. This is the case the fix
3953        // guards against.
3954        let none_result = crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &None);
3955        if is_dma_available() {
3956            assert_eq!(
3957                none_result,
3958                Some(2048),
3959                "memory=None + DMA available → pad (will route through DMA)"
3960            );
3961        } else {
3962            assert_eq!(
3963                none_result, None,
3964                "memory=None + DMA unavailable → must NOT pad (would force \
3965                 image_with_stride into a DMA-only allocation that fails). \
3966                 Regression: padded_dma_pitch_for ignored is_dma_available()."
3967            );
3968        }
3969    }
3970
3971    // Synthesise a small greyscale PNG in memory at `(width, height)` with a
3972    // deterministic ramp pattern so multiple tests can cross-check output
3973    // without bundling an extra fixture file.
3974    fn make_grey_png(width: u32, height: u32) -> Vec<u8> {
3975        let mut bytes = Vec::with_capacity((width * height) as usize);
3976        for y in 0..height {
3977            for x in 0..width {
3978                bytes.push(((x + y) & 0xFF) as u8);
3979            }
3980        }
3981        let img = image::GrayImage::from_vec(width, height, bytes).unwrap();
3982        let mut buf = Vec::new();
3983        img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
3984            .unwrap();
3985        buf
3986    }
3987
3988    /// Greyscale PNG with a width that forces a pitch-misaligned natural
3989    /// row stride (612 bytes is not a multiple of the 64-byte GPU pitch
3990    /// alignment) must still load via the pitch-padded DMA path. Gated on
3991    /// DMA availability because `image_with_stride` is DMA-only.
3992    #[test]
3993    #[cfg(target_os = "linux")]
3994    fn test_load_png_grey_misaligned_width_dma() {
3995        use edgefirst_tensor::is_dma_available;
3996        if !is_dma_available() {
3997            eprintln!("SKIPPED: test_load_png_grey_misaligned_width_dma — DMA not available");
3998            return;
3999        }
4000        let png = make_grey_png(612, 388);
4001        let loaded = crate::load_image_test_helper(&png, Some(PixelFormat::Grey), None).unwrap();
4002        assert_eq!(loaded.width(), Some(612));
4003        assert_eq!(loaded.height(), Some(388));
4004        assert_eq!(loaded.format(), Some(PixelFormat::Grey));
4005
4006        // Round-trip pixels — natural-pitch DMA-BUFs pad the stride so we
4007        // must indirect through row_stride() rather than assume width.
4008        let map = loaded.as_u8().unwrap().map().unwrap();
4009        let stride = loaded.row_stride().unwrap_or(612);
4010        assert!(stride >= 612);
4011        let bytes: &[u8] = &map;
4012        for y in 0..388usize {
4013            for x in 0..612usize {
4014                let expected = ((x + y) & 0xFF) as u8;
4015                let got = bytes[y * stride + x];
4016                assert_eq!(
4017                    got, expected,
4018                    "grey png mismatch at ({x},{y}): got {got} expected {expected}"
4019                );
4020            }
4021        }
4022    }
4023
4024    /// Greyscale PNG loaded with explicit Mem backing — runs on any
4025    /// platform (no DMA permission requirement) and covers the
4026    /// decoder-native Luma → Grey no-conversion path.
4027    #[test]
4028    fn test_load_png_grey_mem() {
4029        use edgefirst_tensor::TensorMemory;
4030        let png = make_grey_png(612, 100);
4031        let loaded =
4032            crate::load_image_test_helper(&png, Some(PixelFormat::Grey), Some(TensorMemory::Mem))
4033                .unwrap();
4034        assert_eq!(loaded.width(), Some(612));
4035        assert_eq!(loaded.height(), Some(100));
4036        assert_eq!(loaded.format(), Some(PixelFormat::Grey));
4037        let map = loaded.as_u8().unwrap().map().unwrap();
4038        let bytes: &[u8] = &map;
4039        // Mem allocation uses the natural pitch — 612 bytes per row, exact.
4040        assert_eq!(bytes.len(), 612 * 100);
4041        for y in 0..100 {
4042            for x in 0..612 {
4043                assert_eq!(bytes[y * 612 + x], ((x + y) & 0xFF) as u8);
4044            }
4045        }
4046    }
4047
4048    /// Greyscale PNG decoded into RGB — exercises the decoder-colorspace
4049    /// mismatch path (Luma → Rgb via CPU converter). Uses Mem memory to
4050    /// stay portable to host-side test environments.
4051    #[test]
4052    fn test_load_png_grey_to_rgb_mem() {
4053        use edgefirst_tensor::TensorMemory;
4054        let png = make_grey_png(620, 240);
4055        let loaded =
4056            crate::load_image_test_helper(&png, Some(PixelFormat::Rgb), Some(TensorMemory::Mem))
4057                .unwrap();
4058        assert_eq!(loaded.width(), Some(620));
4059        assert_eq!(loaded.height(), Some(240));
4060        assert_eq!(loaded.format(), Some(PixelFormat::Rgb));
4061
4062        // Greyscale promoted to RGB replicates luma into each channel.
4063        let map = loaded.as_u8().unwrap().map().unwrap();
4064        let bytes: &[u8] = &map;
4065        for (x, y) in [(0usize, 0usize), (100, 50), (619, 239)] {
4066            let expected = ((x + y) & 0xFF) as u8;
4067            let off = (y * 620 + x) * 3;
4068            assert_eq!(bytes[off], expected, "R@{x},{y}");
4069            assert_eq!(bytes[off + 1], expected, "G@{x},{y}");
4070            assert_eq!(bytes[off + 2], expected, "B@{x},{y}");
4071        }
4072    }
4073
4074    #[test]
4075    #[cfg(target_os = "linux")]
4076    fn test_g2d_resize() {
4077        if !is_g2d_available() {
4078            eprintln!("SKIPPED: test_g2d_resize - G2D library (libg2d.so.2) not available");
4079            return;
4080        }
4081        if !is_dma_available() {
4082            eprintln!(
4083                "SKIPPED: test_g2d_resize - DMA memory allocation not available (permission denied or no DMA-BUF support)"
4084            );
4085            return;
4086        }
4087
4088        let dst_width = 640;
4089        let dst_height = 360;
4090        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4091        let src =
4092            crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), Some(TensorMemory::Dma))
4093                .unwrap();
4094
4095        let g2d_dst = TensorDyn::image(
4096            dst_width,
4097            dst_height,
4098            PixelFormat::Rgba,
4099            DType::U8,
4100            Some(TensorMemory::Dma),
4101        )
4102        .unwrap();
4103        let mut g2d_converter = G2DProcessor::new().unwrap();
4104        let (result, src, g2d_dst) = convert_img(
4105            &mut g2d_converter,
4106            src,
4107            g2d_dst,
4108            Rotation::None,
4109            Flip::None,
4110            Crop::no_crop(),
4111        );
4112        result.unwrap();
4113
4114        let cpu_dst =
4115            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
4116        let mut cpu_converter = CPUProcessor::new();
4117        let (result, _src, cpu_dst) = convert_img(
4118            &mut cpu_converter,
4119            src,
4120            cpu_dst,
4121            Rotation::None,
4122            Flip::None,
4123            Crop::no_crop(),
4124        );
4125        result.unwrap();
4126
4127        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
4128        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
4129        // the YUV-matrix delta that forced 0.95 has closed; tightened to
4130        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
4131        // structural gap not exercised by these limited-range fixtures.
4132        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
4133    }
4134
4135    #[test]
4136    #[cfg(target_os = "linux")]
4137    #[cfg(feature = "opengl")]
4138    fn test_opengl_resize() {
4139        if !is_opengl_available() {
4140            eprintln!("SKIPPED: {} - OpenGL not available", function!());
4141            return;
4142        }
4143
4144        let dst_width = 640;
4145        let dst_height = 360;
4146        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4147        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4148
4149        let cpu_dst =
4150            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
4151        let mut cpu_converter = CPUProcessor::new();
4152        let (result, src, cpu_dst) = convert_img(
4153            &mut cpu_converter,
4154            src,
4155            cpu_dst,
4156            Rotation::None,
4157            Flip::None,
4158            Crop::no_crop(),
4159        );
4160        result.unwrap();
4161
4162        let mut src = src;
4163        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
4164
4165        for _ in 0..5 {
4166            let gl_dst =
4167                TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None)
4168                    .unwrap();
4169            let (result, src_back, gl_dst) = convert_img(
4170                &mut gl_converter,
4171                src,
4172                gl_dst,
4173                Rotation::None,
4174                Flip::None,
4175                Crop::no_crop(),
4176            );
4177            result.unwrap();
4178            src = src_back;
4179
4180            compare_images(&gl_dst, &cpu_dst, 0.98, function!());
4181        }
4182    }
4183
4184    #[test]
4185    #[cfg(target_os = "linux")]
4186    #[cfg(feature = "opengl")]
4187    fn test_opengl_10_threads() {
4188        if !is_opengl_available() {
4189            eprintln!("SKIPPED: {} - OpenGL not available", function!());
4190            return;
4191        }
4192
4193        let handles: Vec<_> = (0..10)
4194            .map(|i| {
4195                std::thread::Builder::new()
4196                    .name(format!("Thread {i}"))
4197                    .spawn(test_opengl_resize)
4198                    .unwrap()
4199            })
4200            .collect();
4201        handles.into_iter().for_each(|h| {
4202            if let Err(e) = h.join() {
4203                std::panic::resume_unwind(e)
4204            }
4205        });
4206    }
4207
4208    #[test]
4209    #[cfg(target_os = "linux")]
4210    #[cfg(feature = "opengl")]
4211    fn test_opengl_grey() {
4212        if !is_opengl_available() {
4213            eprintln!("SKIPPED: {} - OpenGL not available", function!());
4214            return;
4215        }
4216
4217        let img = crate::load_image_test_helper(
4218            &edgefirst_bench::testdata::read("grey.jpg"),
4219            Some(PixelFormat::Grey),
4220            None,
4221        )
4222        .unwrap();
4223
4224        let gl_dst = TensorDyn::image(640, 640, PixelFormat::Grey, DType::U8, None).unwrap();
4225        let cpu_dst = TensorDyn::image(640, 640, PixelFormat::Grey, DType::U8, None).unwrap();
4226
4227        let mut converter = CPUProcessor::new();
4228
4229        let (result, img, cpu_dst) = convert_img(
4230            &mut converter,
4231            img,
4232            cpu_dst,
4233            Rotation::None,
4234            Flip::None,
4235            Crop::no_crop(),
4236        );
4237        result.unwrap();
4238
4239        let mut gl = GLProcessorThreaded::new(None).unwrap();
4240        let (result, _img, gl_dst) = convert_img(
4241            &mut gl,
4242            img,
4243            gl_dst,
4244            Rotation::None,
4245            Flip::None,
4246            Crop::no_crop(),
4247        );
4248        result.unwrap();
4249
4250        compare_images(&gl_dst, &cpu_dst, 0.98, function!());
4251    }
4252
4253    #[test]
4254    #[cfg(target_os = "linux")]
4255    fn test_g2d_src_crop() {
4256        if !is_g2d_available() {
4257            eprintln!("SKIPPED: test_g2d_src_crop - G2D library (libg2d.so.2) not available");
4258            return;
4259        }
4260        if !is_dma_available() {
4261            eprintln!(
4262                "SKIPPED: test_g2d_src_crop - DMA memory allocation not available (permission denied or no DMA-BUF support)"
4263            );
4264            return;
4265        }
4266
4267        let dst_width = 640;
4268        let dst_height = 640;
4269        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4270        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4271
4272        let cpu_dst =
4273            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
4274        let mut cpu_converter = CPUProcessor::new();
4275        let crop = Crop::new().with_source(Some(Region::new(0, 0, 640, 360)));
4276        let (result, src, cpu_dst) = convert_img(
4277            &mut cpu_converter,
4278            src,
4279            cpu_dst,
4280            Rotation::None,
4281            Flip::None,
4282            crop,
4283        );
4284        result.unwrap();
4285
4286        let g2d_dst =
4287            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
4288        let mut g2d_converter = G2DProcessor::new().unwrap();
4289        let (result, _src, g2d_dst) = convert_img(
4290            &mut g2d_converter,
4291            src,
4292            g2d_dst,
4293            Rotation::None,
4294            Flip::None,
4295            crop,
4296        );
4297        result.unwrap();
4298
4299        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
4300        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
4301        // the YUV-matrix delta that forced 0.95 has closed; tightened to
4302        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
4303        // structural gap not exercised by these limited-range fixtures.
4304        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
4305    }
4306
4307    #[test]
4308    #[cfg(target_os = "linux")]
4309    fn test_g2d_dst_crop() {
4310        if !is_g2d_available() {
4311            eprintln!("SKIPPED: test_g2d_dst_crop - G2D library (libg2d.so.2) not available");
4312            return;
4313        }
4314        if !is_dma_available() {
4315            eprintln!(
4316                "SKIPPED: test_g2d_dst_crop - DMA memory allocation not available (permission denied or no DMA-BUF support)"
4317            );
4318            return;
4319        }
4320
4321        let dst_width = 640;
4322        let dst_height = 640;
4323        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4324        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4325
4326        let cpu_dst =
4327            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
4328        let mut cpu_converter = CPUProcessor::new();
4329        let crop = Crop::new();
4330        let (result, src, cpu_dst) = convert_img(
4331            &mut cpu_converter,
4332            src,
4333            cpu_dst,
4334            Rotation::None,
4335            Flip::None,
4336            crop,
4337        );
4338        result.unwrap();
4339
4340        let g2d_dst =
4341            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
4342        let mut g2d_converter = G2DProcessor::new().unwrap();
4343        let (result, _src, g2d_dst) = convert_img(
4344            &mut g2d_converter,
4345            src,
4346            g2d_dst,
4347            Rotation::None,
4348            Flip::None,
4349            crop,
4350        );
4351        result.unwrap();
4352
4353        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
4354        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
4355        // the YUV-matrix delta that forced 0.95 has closed; tightened to
4356        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
4357        // structural gap not exercised by these limited-range fixtures.
4358        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
4359    }
4360
4361    #[test]
4362    #[cfg(target_os = "linux")]
4363    fn test_g2d_all_rgba() {
4364        if !is_g2d_available() {
4365            eprintln!("SKIPPED: test_g2d_all_rgba - G2D library (libg2d.so.2) not available");
4366            return;
4367        }
4368        if !is_dma_available() {
4369            eprintln!(
4370                "SKIPPED: test_g2d_all_rgba - DMA memory allocation not available (permission denied or no DMA-BUF support)"
4371            );
4372            return;
4373        }
4374
4375        let dst_width = 640;
4376        let dst_height = 640;
4377        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4378        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4379        let src_dyn = src;
4380
4381        let mut cpu_dst =
4382            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
4383        let mut cpu_converter = CPUProcessor::new();
4384        let mut g2d_dst =
4385            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
4386        let mut g2d_converter = G2DProcessor::new().unwrap();
4387
4388        let crop = Crop::new().with_source(Some(Region::new(50, 120, 1024, 576)));
4389
4390        for rot in [
4391            Rotation::None,
4392            Rotation::Clockwise90,
4393            Rotation::Rotate180,
4394            Rotation::CounterClockwise90,
4395        ] {
4396            cpu_dst
4397                .as_u8()
4398                .unwrap()
4399                .map()
4400                .unwrap()
4401                .as_mut_slice()
4402                .fill(114);
4403            g2d_dst
4404                .as_u8()
4405                .unwrap()
4406                .map()
4407                .unwrap()
4408                .as_mut_slice()
4409                .fill(114);
4410            for flip in [Flip::None, Flip::Horizontal, Flip::Vertical] {
4411                let mut cpu_dst_dyn = cpu_dst;
4412                cpu_converter
4413                    .convert(&src_dyn, &mut cpu_dst_dyn, Rotation::None, Flip::None, crop)
4414                    .unwrap();
4415                cpu_dst = {
4416                    let mut __t = cpu_dst_dyn.into_u8().unwrap();
4417                    __t.set_format(PixelFormat::Rgba).unwrap();
4418                    TensorDyn::from(__t)
4419                };
4420
4421                let mut g2d_dst_dyn = g2d_dst;
4422                g2d_converter
4423                    .convert(&src_dyn, &mut g2d_dst_dyn, Rotation::None, Flip::None, crop)
4424                    .unwrap();
4425                g2d_dst = {
4426                    let mut __t = g2d_dst_dyn.into_u8().unwrap();
4427                    __t.set_format(PixelFormat::Rgba).unwrap();
4428                    TensorDyn::from(__t)
4429                };
4430
4431                compare_images(
4432                    &g2d_dst,
4433                    &cpu_dst,
4434                    0.98,
4435                    &format!("{} {:?} {:?}", function!(), rot, flip),
4436                );
4437            }
4438        }
4439    }
4440
4441    #[test]
4442    #[cfg(target_os = "linux")]
4443    #[cfg(feature = "opengl")]
4444    fn test_opengl_src_crop() {
4445        if !is_opengl_available() {
4446            eprintln!("SKIPPED: {} - OpenGL not available", function!());
4447            return;
4448        }
4449
4450        let dst_width = 640;
4451        let dst_height = 360;
4452        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4453        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4454        let crop = Crop::new().with_source(Some(Region::new(320, 180, 1280 - 320, 720 - 180)));
4455
4456        let cpu_dst =
4457            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
4458        let mut cpu_converter = CPUProcessor::new();
4459        let (result, src, cpu_dst) = convert_img(
4460            &mut cpu_converter,
4461            src,
4462            cpu_dst,
4463            Rotation::None,
4464            Flip::None,
4465            crop,
4466        );
4467        result.unwrap();
4468
4469        let gl_dst =
4470            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
4471        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
4472        let (result, _src, gl_dst) = convert_img(
4473            &mut gl_converter,
4474            src,
4475            gl_dst,
4476            Rotation::None,
4477            Flip::None,
4478            crop,
4479        );
4480        result.unwrap();
4481
4482        compare_images(&gl_dst, &cpu_dst, 0.98, function!());
4483    }
4484
4485    #[test]
4486    #[cfg(target_os = "linux")]
4487    #[cfg(feature = "opengl")]
4488    fn test_opengl_dst_crop() {
4489        if !is_opengl_available() {
4490            eprintln!("SKIPPED: {} - OpenGL not available", function!());
4491            return;
4492        }
4493
4494        let dst_width = 640;
4495        let dst_height = 640;
4496        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4497        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4498
4499        let cpu_dst =
4500            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
4501        let mut cpu_converter = CPUProcessor::new();
4502        let crop = Crop::new();
4503        let (result, src, cpu_dst) = convert_img(
4504            &mut cpu_converter,
4505            src,
4506            cpu_dst,
4507            Rotation::None,
4508            Flip::None,
4509            crop,
4510        );
4511        result.unwrap();
4512
4513        let gl_dst =
4514            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
4515        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
4516        let (result, _src, gl_dst) = convert_img(
4517            &mut gl_converter,
4518            src,
4519            gl_dst,
4520            Rotation::None,
4521            Flip::None,
4522            crop,
4523        );
4524        result.unwrap();
4525
4526        compare_images(&gl_dst, &cpu_dst, 0.98, function!());
4527    }
4528
4529    #[test]
4530    #[cfg(target_os = "linux")]
4531    #[cfg(feature = "opengl")]
4532    fn test_opengl_all_rgba() {
4533        if !is_opengl_available() {
4534            eprintln!("SKIPPED: {} - OpenGL not available", function!());
4535            return;
4536        }
4537
4538        let dst_width = 640;
4539        let dst_height = 640;
4540        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4541
4542        let mut cpu_converter = CPUProcessor::new();
4543
4544        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
4545
4546        let mut mem = vec![None, Some(TensorMemory::Mem), Some(TensorMemory::Shm)];
4547        if is_dma_available() {
4548            mem.push(Some(TensorMemory::Dma));
4549        }
4550        let crop = Crop::new().with_source(Some(Region::new(50, 120, 1024, 576)));
4551        for m in mem {
4552            let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), m).unwrap();
4553            let src_dyn = src;
4554
4555            for rot in [
4556                Rotation::None,
4557                Rotation::Clockwise90,
4558                Rotation::Rotate180,
4559                Rotation::CounterClockwise90,
4560            ] {
4561                for flip in [Flip::None, Flip::Horizontal, Flip::Vertical] {
4562                    let cpu_dst =
4563                        TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, m)
4564                            .unwrap();
4565                    let gl_dst =
4566                        TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, m)
4567                            .unwrap();
4568                    cpu_dst
4569                        .as_u8()
4570                        .unwrap()
4571                        .map()
4572                        .unwrap()
4573                        .as_mut_slice()
4574                        .fill(114);
4575                    gl_dst
4576                        .as_u8()
4577                        .unwrap()
4578                        .map()
4579                        .unwrap()
4580                        .as_mut_slice()
4581                        .fill(114);
4582
4583                    let mut cpu_dst_dyn = cpu_dst;
4584                    cpu_converter
4585                        .convert(&src_dyn, &mut cpu_dst_dyn, Rotation::None, Flip::None, crop)
4586                        .unwrap();
4587                    let cpu_dst = {
4588                        let mut __t = cpu_dst_dyn.into_u8().unwrap();
4589                        __t.set_format(PixelFormat::Rgba).unwrap();
4590                        TensorDyn::from(__t)
4591                    };
4592
4593                    let mut gl_dst_dyn = gl_dst;
4594                    gl_converter
4595                        .convert(&src_dyn, &mut gl_dst_dyn, Rotation::None, Flip::None, crop)
4596                        .map_err(|e| {
4597                            log::error!("error mem {m:?} rot {rot:?} error: {e:?}");
4598                            e
4599                        })
4600                        .unwrap();
4601                    let gl_dst = {
4602                        let mut __t = gl_dst_dyn.into_u8().unwrap();
4603                        __t.set_format(PixelFormat::Rgba).unwrap();
4604                        TensorDyn::from(__t)
4605                    };
4606
4607                    compare_images(
4608                        &gl_dst,
4609                        &cpu_dst,
4610                        0.98,
4611                        &format!("{} {:?} {:?}", function!(), rot, flip),
4612                    );
4613                }
4614            }
4615        }
4616    }
4617
4618    #[test]
4619    #[cfg(target_os = "linux")]
4620    fn test_cpu_rotate() {
4621        for rot in [
4622            Rotation::Clockwise90,
4623            Rotation::Rotate180,
4624            Rotation::CounterClockwise90,
4625        ] {
4626            test_cpu_rotate_(rot);
4627        }
4628    }
4629
4630    #[cfg(target_os = "linux")]
4631    fn test_cpu_rotate_(rot: Rotation) {
4632        // This test rotates the image 4 times and checks that the image was returned to
4633        // be the same Currently doesn't check if rotations actually rotated in
4634        // right direction
4635        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4636
4637        let unchanged_src =
4638            crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4639        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4640
4641        let (dst_width, dst_height) = match rot {
4642            Rotation::None | Rotation::Rotate180 => (src.width().unwrap(), src.height().unwrap()),
4643            Rotation::Clockwise90 | Rotation::CounterClockwise90 => {
4644                (src.height().unwrap(), src.width().unwrap())
4645            }
4646        };
4647
4648        let cpu_dst =
4649            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
4650        let mut cpu_converter = CPUProcessor::new();
4651
4652        // After rotating 4 times, the image should be the same as the original
4653
4654        let (result, src, cpu_dst) = convert_img(
4655            &mut cpu_converter,
4656            src,
4657            cpu_dst,
4658            rot,
4659            Flip::None,
4660            Crop::no_crop(),
4661        );
4662        result.unwrap();
4663
4664        let (result, cpu_dst, src) = convert_img(
4665            &mut cpu_converter,
4666            cpu_dst,
4667            src,
4668            rot,
4669            Flip::None,
4670            Crop::no_crop(),
4671        );
4672        result.unwrap();
4673
4674        let (result, src, cpu_dst) = convert_img(
4675            &mut cpu_converter,
4676            src,
4677            cpu_dst,
4678            rot,
4679            Flip::None,
4680            Crop::no_crop(),
4681        );
4682        result.unwrap();
4683
4684        let (result, _cpu_dst, src) = convert_img(
4685            &mut cpu_converter,
4686            cpu_dst,
4687            src,
4688            rot,
4689            Flip::None,
4690            Crop::no_crop(),
4691        );
4692        result.unwrap();
4693
4694        compare_images(&src, &unchanged_src, 0.98, function!());
4695    }
4696
4697    #[test]
4698    #[cfg(target_os = "linux")]
4699    #[cfg(feature = "opengl")]
4700    fn test_opengl_rotate() {
4701        if !is_opengl_available() {
4702            eprintln!("SKIPPED: {} - OpenGL not available", function!());
4703            return;
4704        }
4705
4706        let size = (1280, 720);
4707        let mut mem = vec![None, Some(TensorMemory::Shm), Some(TensorMemory::Mem)];
4708
4709        if is_dma_available() {
4710            mem.push(Some(TensorMemory::Dma));
4711        }
4712        for m in mem {
4713            for rot in [
4714                Rotation::Clockwise90,
4715                Rotation::Rotate180,
4716                Rotation::CounterClockwise90,
4717            ] {
4718                test_opengl_rotate_(size, rot, m);
4719            }
4720        }
4721    }
4722
4723    #[cfg(target_os = "linux")]
4724    #[cfg(feature = "opengl")]
4725    fn test_opengl_rotate_(
4726        size: (usize, usize),
4727        rot: Rotation,
4728        tensor_memory: Option<TensorMemory>,
4729    ) {
4730        let (dst_width, dst_height) = match rot {
4731            Rotation::None | Rotation::Rotate180 => size,
4732            Rotation::Clockwise90 | Rotation::CounterClockwise90 => (size.1, size.0),
4733        };
4734
4735        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4736        let src =
4737            crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), tensor_memory).unwrap();
4738
4739        let cpu_dst =
4740            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
4741        let mut cpu_converter = CPUProcessor::new();
4742
4743        let (result, mut src, cpu_dst) = convert_img(
4744            &mut cpu_converter,
4745            src,
4746            cpu_dst,
4747            rot,
4748            Flip::None,
4749            Crop::no_crop(),
4750        );
4751        result.unwrap();
4752
4753        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
4754
4755        for _ in 0..5 {
4756            let gl_dst = TensorDyn::image(
4757                dst_width,
4758                dst_height,
4759                PixelFormat::Rgba,
4760                DType::U8,
4761                tensor_memory,
4762            )
4763            .unwrap();
4764            let (result, src_back, gl_dst) = convert_img(
4765                &mut gl_converter,
4766                src,
4767                gl_dst,
4768                rot,
4769                Flip::None,
4770                Crop::no_crop(),
4771            );
4772            result.unwrap();
4773            src = src_back;
4774            compare_images(&gl_dst, &cpu_dst, 0.98, function!());
4775        }
4776    }
4777
4778    #[test]
4779    #[cfg(target_os = "linux")]
4780    fn test_g2d_rotate() {
4781        if !is_g2d_available() {
4782            eprintln!("SKIPPED: test_g2d_rotate - G2D library (libg2d.so.2) not available");
4783            return;
4784        }
4785        if !is_dma_available() {
4786            eprintln!(
4787                "SKIPPED: test_g2d_rotate - DMA memory allocation not available (permission denied or no DMA-BUF support)"
4788            );
4789            return;
4790        }
4791
4792        let size = (1280, 720);
4793        for rot in [
4794            Rotation::Clockwise90,
4795            Rotation::Rotate180,
4796            Rotation::CounterClockwise90,
4797        ] {
4798            test_g2d_rotate_(size, rot);
4799        }
4800    }
4801
4802    #[cfg(target_os = "linux")]
4803    fn test_g2d_rotate_(size: (usize, usize), rot: Rotation) {
4804        let (dst_width, dst_height) = match rot {
4805            Rotation::None | Rotation::Rotate180 => size,
4806            Rotation::Clockwise90 | Rotation::CounterClockwise90 => (size.1, size.0),
4807        };
4808
4809        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4810        let src =
4811            crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), Some(TensorMemory::Dma))
4812                .unwrap();
4813
4814        let cpu_dst =
4815            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
4816        let mut cpu_converter = CPUProcessor::new();
4817
4818        let (result, src, cpu_dst) = convert_img(
4819            &mut cpu_converter,
4820            src,
4821            cpu_dst,
4822            rot,
4823            Flip::None,
4824            Crop::no_crop(),
4825        );
4826        result.unwrap();
4827
4828        let g2d_dst = TensorDyn::image(
4829            dst_width,
4830            dst_height,
4831            PixelFormat::Rgba,
4832            DType::U8,
4833            Some(TensorMemory::Dma),
4834        )
4835        .unwrap();
4836        let mut g2d_converter = G2DProcessor::new().unwrap();
4837
4838        let (result, _src, g2d_dst) = convert_img(
4839            &mut g2d_converter,
4840            src,
4841            g2d_dst,
4842            rot,
4843            Flip::None,
4844            Crop::no_crop(),
4845        );
4846        result.unwrap();
4847
4848        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
4849        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
4850        // the YUV-matrix delta that forced 0.95 has closed; tightened to
4851        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
4852        // structural gap not exercised by these limited-range fixtures.
4853        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
4854    }
4855
4856    #[test]
4857    fn test_rgba_to_yuyv_resize_cpu() {
4858        let src = load_bytes_to_tensor(
4859            1280,
4860            720,
4861            PixelFormat::Rgba,
4862            None,
4863            &edgefirst_bench::testdata::read("camera720p.rgba"),
4864        )
4865        .unwrap();
4866
4867        let (dst_width, dst_height) = (640, 360);
4868
4869        let dst =
4870            TensorDyn::image(dst_width, dst_height, PixelFormat::Yuyv, DType::U8, None).unwrap();
4871
4872        let dst_through_yuyv =
4873            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
4874        let dst_direct =
4875            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
4876
4877        let mut cpu_converter = CPUProcessor::new();
4878
4879        let (result, src, dst) = convert_img(
4880            &mut cpu_converter,
4881            src,
4882            dst,
4883            Rotation::None,
4884            Flip::None,
4885            Crop::no_crop(),
4886        );
4887        result.unwrap();
4888
4889        let (result, _dst, dst_through_yuyv) = convert_img(
4890            &mut cpu_converter,
4891            dst,
4892            dst_through_yuyv,
4893            Rotation::None,
4894            Flip::None,
4895            Crop::no_crop(),
4896        );
4897        result.unwrap();
4898
4899        let (result, _src, dst_direct) = convert_img(
4900            &mut cpu_converter,
4901            src,
4902            dst_direct,
4903            Rotation::None,
4904            Flip::None,
4905            Crop::no_crop(),
4906        );
4907        result.unwrap();
4908
4909        compare_images(&dst_through_yuyv, &dst_direct, 0.98, function!());
4910    }
4911
4912    #[test]
4913    #[cfg(target_os = "linux")]
4914    #[cfg(feature = "opengl")]
4915    #[ignore = "opengl doesn't support rendering to PixelFormat::Yuyv texture"]
4916    fn test_rgba_to_yuyv_resize_opengl() {
4917        if !is_opengl_available() {
4918            eprintln!("SKIPPED: {} - OpenGL not available", function!());
4919            return;
4920        }
4921
4922        if !is_dma_available() {
4923            eprintln!(
4924                "SKIPPED: {} - DMA memory allocation not available (permission denied or no DMA-BUF support)",
4925                function!()
4926            );
4927            return;
4928        }
4929
4930        let src = load_bytes_to_tensor(
4931            1280,
4932            720,
4933            PixelFormat::Rgba,
4934            None,
4935            &edgefirst_bench::testdata::read("camera720p.rgba"),
4936        )
4937        .unwrap();
4938
4939        let (dst_width, dst_height) = (640, 360);
4940
4941        let dst = TensorDyn::image(
4942            dst_width,
4943            dst_height,
4944            PixelFormat::Yuyv,
4945            DType::U8,
4946            Some(TensorMemory::Dma),
4947        )
4948        .unwrap();
4949
4950        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
4951
4952        let (result, src, dst) = convert_img(
4953            &mut gl_converter,
4954            src,
4955            dst,
4956            Rotation::None,
4957            Flip::None,
4958            Crop::letterbox([255, 255, 255, 255]),
4959        );
4960        result.unwrap();
4961
4962        std::fs::write(
4963            "rgba_to_yuyv_opengl.yuyv",
4964            dst.as_u8().unwrap().map().unwrap().as_slice(),
4965        )
4966        .unwrap();
4967        let cpu_dst = TensorDyn::image(
4968            dst_width,
4969            dst_height,
4970            PixelFormat::Yuyv,
4971            DType::U8,
4972            Some(TensorMemory::Dma),
4973        )
4974        .unwrap();
4975        let (result, _src, cpu_dst) = convert_img(
4976            &mut CPUProcessor::new(),
4977            src,
4978            cpu_dst,
4979            Rotation::None,
4980            Flip::None,
4981            Crop::no_crop(),
4982        );
4983        result.unwrap();
4984
4985        compare_images_convert_to_rgb(&dst, &cpu_dst, 0.98, function!());
4986    }
4987
4988    #[test]
4989    #[cfg(target_os = "linux")]
4990    fn test_rgba_to_yuyv_resize_g2d() {
4991        if !is_g2d_available() {
4992            eprintln!(
4993                "SKIPPED: test_rgba_to_yuyv_resize_g2d - G2D library (libg2d.so.2) not available"
4994            );
4995            return;
4996        }
4997        if !is_dma_available() {
4998            eprintln!(
4999                "SKIPPED: test_rgba_to_yuyv_resize_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
5000            );
5001            return;
5002        }
5003
5004        let src = load_bytes_to_tensor(
5005            1280,
5006            720,
5007            PixelFormat::Rgba,
5008            Some(TensorMemory::Dma),
5009            &edgefirst_bench::testdata::read("camera720p.rgba"),
5010        )
5011        .unwrap();
5012
5013        let (dst_width, dst_height) = (1280, 720);
5014
5015        let cpu_dst = TensorDyn::image(
5016            dst_width,
5017            dst_height,
5018            PixelFormat::Yuyv,
5019            DType::U8,
5020            Some(TensorMemory::Dma),
5021        )
5022        .unwrap();
5023
5024        let g2d_dst = TensorDyn::image(
5025            dst_width,
5026            dst_height,
5027            PixelFormat::Yuyv,
5028            DType::U8,
5029            Some(TensorMemory::Dma),
5030        )
5031        .unwrap();
5032
5033        let mut g2d_converter = G2DProcessor::new().unwrap();
5034        let crop = Crop::new();
5035
5036        g2d_dst
5037            .as_u8()
5038            .unwrap()
5039            .map()
5040            .unwrap()
5041            .as_mut_slice()
5042            .fill(128);
5043        let (result, src, g2d_dst) = convert_img(
5044            &mut g2d_converter,
5045            src,
5046            g2d_dst,
5047            Rotation::None,
5048            Flip::None,
5049            crop,
5050        );
5051        result.unwrap();
5052
5053        let cpu_dst_img = cpu_dst;
5054        cpu_dst_img
5055            .as_u8()
5056            .unwrap()
5057            .map()
5058            .unwrap()
5059            .as_mut_slice()
5060            .fill(128);
5061        let (result, _src, cpu_dst) = convert_img(
5062            &mut CPUProcessor::new(),
5063            src,
5064            cpu_dst_img,
5065            Rotation::None,
5066            Flip::None,
5067            crop,
5068        );
5069        result.unwrap();
5070
5071        compare_images_convert_to_rgb(&cpu_dst, &g2d_dst, 0.98, function!());
5072    }
5073
5074    #[test]
5075    fn test_yuyv_to_rgba_cpu() {
5076        let file = edgefirst_bench::testdata::read("camera720p.yuyv").to_vec();
5077        let src = TensorDyn::image(1280, 720, PixelFormat::Yuyv, DType::U8, None).unwrap();
5078        src.as_u8()
5079            .unwrap()
5080            .map()
5081            .unwrap()
5082            .as_mut_slice()
5083            .copy_from_slice(&file);
5084
5085        let dst = TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None).unwrap();
5086        let mut cpu_converter = CPUProcessor::new();
5087
5088        let (result, _src, dst) = convert_img(
5089            &mut cpu_converter,
5090            src,
5091            dst,
5092            Rotation::None,
5093            Flip::None,
5094            Crop::no_crop(),
5095        );
5096        result.unwrap();
5097
5098        let target_image = TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None).unwrap();
5099        target_image
5100            .as_u8()
5101            .unwrap()
5102            .map()
5103            .unwrap()
5104            .as_mut_slice()
5105            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
5106
5107        // CPU path resolves the untagged 720p source to BT.709 limited (height
5108        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
5109        compare_images(&dst, &target_image, 0.98, function!());
5110    }
5111
5112    #[test]
5113    fn test_yuyv_to_rgb_cpu() {
5114        let file = edgefirst_bench::testdata::read("camera720p.yuyv").to_vec();
5115        let src = TensorDyn::image(1280, 720, PixelFormat::Yuyv, DType::U8, None).unwrap();
5116        src.as_u8()
5117            .unwrap()
5118            .map()
5119            .unwrap()
5120            .as_mut_slice()
5121            .copy_from_slice(&file);
5122
5123        let dst = TensorDyn::image(1280, 720, PixelFormat::Rgb, DType::U8, None).unwrap();
5124        let mut cpu_converter = CPUProcessor::new();
5125
5126        let (result, _src, dst) = convert_img(
5127            &mut cpu_converter,
5128            src,
5129            dst,
5130            Rotation::None,
5131            Flip::None,
5132            Crop::no_crop(),
5133        );
5134        result.unwrap();
5135
5136        let target_image = TensorDyn::image(1280, 720, PixelFormat::Rgb, DType::U8, None).unwrap();
5137        target_image
5138            .as_u8()
5139            .unwrap()
5140            .map()
5141            .unwrap()
5142            .as_mut_slice()
5143            .as_chunks_mut::<3>()
5144            .0
5145            .iter_mut()
5146            .zip(
5147                edgefirst_bench::testdata::read("camera720p.rgba")
5148                    .as_chunks::<4>()
5149                    .0,
5150            )
5151            .for_each(|(dst, src)| *dst = [src[0], src[1], src[2]]);
5152
5153        // CPU path resolves the untagged 720p source to BT.709 limited (height
5154        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
5155        compare_images(&dst, &target_image, 0.98, function!());
5156    }
5157
5158    #[test]
5159    #[cfg(target_os = "linux")]
5160    fn test_yuyv_to_rgba_g2d() {
5161        if !is_g2d_available() {
5162            eprintln!("SKIPPED: test_yuyv_to_rgba_g2d - G2D library (libg2d.so.2) not available");
5163            return;
5164        }
5165        if !is_dma_available() {
5166            eprintln!(
5167                "SKIPPED: test_yuyv_to_rgba_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
5168            );
5169            return;
5170        }
5171
5172        let src = load_bytes_to_tensor(
5173            1280,
5174            720,
5175            PixelFormat::Yuyv,
5176            None,
5177            &edgefirst_bench::testdata::read("camera720p.yuyv"),
5178        )
5179        .unwrap();
5180
5181        let dst = TensorDyn::image(
5182            1280,
5183            720,
5184            PixelFormat::Rgba,
5185            DType::U8,
5186            Some(TensorMemory::Dma),
5187        )
5188        .unwrap();
5189        let mut g2d_converter = G2DProcessor::new().unwrap();
5190
5191        let (result, _src, dst) = convert_img(
5192            &mut g2d_converter,
5193            src,
5194            dst,
5195            Rotation::None,
5196            Flip::None,
5197            Crop::no_crop(),
5198        );
5199        result.unwrap();
5200
5201        let target_image = TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None).unwrap();
5202        target_image
5203            .as_u8()
5204            .unwrap()
5205            .map()
5206            .unwrap()
5207            .as_mut_slice()
5208            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
5209
5210        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
5211        // so the matrix delta vs the reference that forced 0.95 has closed;
5212        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
5213        compare_images(&dst, &target_image, 0.98, function!());
5214    }
5215
5216    #[test]
5217    #[cfg(target_os = "linux")]
5218    #[cfg(feature = "opengl")]
5219    fn test_yuyv_to_rgba_opengl() {
5220        if !is_opengl_available() {
5221            eprintln!("SKIPPED: {} - OpenGL not available", function!());
5222            return;
5223        }
5224        if !is_dma_available() {
5225            eprintln!(
5226                "SKIPPED: {} - DMA memory allocation not available (permission denied or no DMA-BUF support)",
5227                function!()
5228            );
5229            return;
5230        }
5231
5232        let src = load_bytes_to_tensor(
5233            1280,
5234            720,
5235            PixelFormat::Yuyv,
5236            Some(TensorMemory::Dma),
5237            &edgefirst_bench::testdata::read("camera720p.yuyv"),
5238        )
5239        .unwrap();
5240
5241        let dst = TensorDyn::image(
5242            1280,
5243            720,
5244            PixelFormat::Rgba,
5245            DType::U8,
5246            Some(TensorMemory::Dma),
5247        )
5248        .unwrap();
5249        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
5250
5251        let (result, _src, dst) = convert_img(
5252            &mut gl_converter,
5253            src,
5254            dst,
5255            Rotation::None,
5256            Flip::None,
5257            Crop::no_crop(),
5258        );
5259        result.unwrap();
5260
5261        let target_image = TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None).unwrap();
5262        target_image
5263            .as_u8()
5264            .unwrap()
5265            .map()
5266            .unwrap()
5267            .as_mut_slice()
5268            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
5269
5270        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
5271        // so the matrix delta vs the reference that forced 0.95 has closed;
5272        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
5273        compare_images(&dst, &target_image, 0.98, function!());
5274    }
5275
5276    /// macOS analog of `test_yuyv_to_rgba_opengl` — drives the ANGLE +
5277    /// IOSurface backend end-to-end and compares against the same
5278    /// reference image. Skips silently if ANGLE isn't installed so the
5279    /// test suite still passes on CI hosts without the Homebrew tap.
5280    /// Step-1 probe: proves ANGLE's Metal IOSurface-client-buffer path accepts
5281    /// an `L008`→`GL_RED` (R8) binding — the foundation for sampling the
5282    /// contiguous semi-planar YUV buffer as a single R8 texture. Renders a
5283    /// GREY (R8 IOSurface) source through the GL backend to RGBA and checks the
5284    /// luma round-trips to R=G=B (identity GREY→RGB).
5285    #[test]
5286    #[cfg(target_os = "macos")]
5287    #[cfg(feature = "opengl")]
5288    fn test_grey_r8_iosurface_to_rgba_opengl_macos() {
5289        let mut proc = match GLProcessorThreaded::new(None) {
5290            Ok(p) => p,
5291            Err(e) => {
5292                eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
5293                return;
5294            }
5295        };
5296
5297        let (w, h) = (16usize, 16usize);
5298        let src = TensorDyn::image(w, h, PixelFormat::Grey, DType::U8, Some(TensorMemory::Dma))
5299            .expect("GREY IOSurface (R8/L008) should allocate — proves the FourCC mapping");
5300        // Known luma ramp: value = (x * 13 + y * 7) & 0xff.
5301        {
5302            let su8 = src.as_u8().unwrap();
5303            let stride = src.as_u8().unwrap().effective_row_stride().unwrap();
5304            let mut m = su8.map().unwrap();
5305            let buf = m.as_mut_slice();
5306            for y in 0..h {
5307                for x in 0..w {
5308                    buf[y * stride + x] = ((x * 13 + y * 7) & 0xff) as u8;
5309                }
5310            }
5311        }
5312
5313        let dst =
5314            TensorDyn::image(w, h, PixelFormat::Rgba, DType::U8, Some(TensorMemory::Dma)).unwrap();
5315        let (result, src_back, dst) = convert_img(
5316            &mut proc,
5317            src,
5318            dst,
5319            Rotation::None,
5320            Flip::None,
5321            Crop::no_crop(),
5322        );
5323        result.expect("GREY(R8 IOSurface) → RGBA must convert on ANGLE (R8 binding works)");
5324
5325        let src_stride = src_back.as_u8().unwrap().effective_row_stride().unwrap();
5326        let src_map = src_back.as_u8().unwrap().map().unwrap();
5327        let sbytes = src_map.as_slice();
5328        let dst_stride = dst.as_u8().unwrap().effective_row_stride().unwrap();
5329        let dst_map = dst.as_u8().unwrap().map().unwrap();
5330        let dbytes = dst_map.as_slice();
5331        for y in 0..h {
5332            for x in 0..w {
5333                let yv = sbytes[y * src_stride + x] as i16;
5334                let p = y * dst_stride + x * 4;
5335                for c in 0..3 {
5336                    assert!(
5337                        (dbytes[p + c] as i16 - yv).abs() <= 2,
5338                        "pixel ({x},{y}) ch{c} = {} expected ~{yv} (GREY→RGB identity)",
5339                        dbytes[p + c]
5340                    );
5341                }
5342            }
5343        }
5344    }
5345
5346    /// Two-pass GPU chain: NV12 (R8 IOSurface) → PlanarRgb F16, the profiler's
5347    /// preprocess. Verifies the chained `convert_nv_to_planar_float`
5348    /// (NV12→RGBA8 then the verified RGBA8→PlanarRgb F16) executes on ANGLE and
5349    /// produces a sane F16 planar result: a neutral-grey NV12 input (Y=U=V=128,
5350    /// BT.601 full ⇒ RGB≈0.5) must yield all three planes ≈0.5 (half-float).
5351    #[test]
5352    #[cfg(target_os = "macos")]
5353    #[cfg(feature = "opengl")]
5354    fn test_nv12_to_planar_f16_two_pass_opengl_macos() {
5355        let mut gpu = match GLProcessorThreaded::new(None) {
5356            Ok(p) => p,
5357            Err(e) => {
5358                eprintln!("SKIPPED: {} — init failed ({e:?})", function!());
5359                return;
5360            }
5361        };
5362        let (w, h) = (64usize, 64usize);
5363        let src =
5364            match TensorDyn::image(w, h, PixelFormat::Nv12, DType::U8, Some(TensorMemory::Dma)) {
5365                Ok(t) => t,
5366                Err(e) => {
5367                    eprintln!("SKIPPED: {} — NV12 IOSurface alloc: {e:?}", function!());
5368                    return;
5369                }
5370            };
5371        src.as_u8().unwrap().map().unwrap().as_mut_slice().fill(128); // Y=U=V=128
5372
5373        let dst = match TensorDyn::image(
5374            w,
5375            h,
5376            PixelFormat::PlanarRgb,
5377            DType::F16,
5378            Some(TensorMemory::Dma),
5379        ) {
5380            Ok(t) => t,
5381            Err(e) => {
5382                eprintln!("SKIPPED: {} — F16 PlanarRgb IOSurface: {e:?}", function!());
5383                return;
5384            }
5385        };
5386        let mut dst = dst;
5387        // Call convert directly (the convert_img helper restores u8 only).
5388        if let Err(e) = ImageProcessorTrait::convert(
5389            &mut gpu,
5390            &src,
5391            &mut dst,
5392            Rotation::None,
5393            Flip::None,
5394            Crop::no_crop(),
5395        ) {
5396            // GL_EXT_color_buffer_half_float may be absent on some configs; the
5397            // RGBA8 pass-1 + F16 pass-2 path then can't render. Skip rather than
5398            // fail on a capability gap (the same policy as the F16 path tests).
5399            eprintln!(
5400                "SKIPPED: {} — NV12→PlanarRgb F16 not available ({e:?})",
5401                function!()
5402            );
5403            return;
5404        }
5405        let dt = dst.as_f16().expect("dst is F16 PlanarRgb");
5406        let map = dt.map().unwrap();
5407        let vals = map.as_slice();
5408        // Neutral grey → ~0.5 in every plane. Allow generous tolerance for the
5409        // mediump YUV math + half-float rounding.
5410        let mut checked = 0usize;
5411        for &v in vals.iter() {
5412            let f = f32::from(v);
5413            assert!(
5414                (0.40..=0.60).contains(&f),
5415                "planar F16 value {f} not ~0.5 for neutral-grey NV12"
5416            );
5417            checked += 1;
5418        }
5419        assert!(
5420            checked >= w * h * 3,
5421            "expected >= 3 planes of samples, got {checked}"
5422        );
5423    }
5424
5425    /// Profiler-shaped two-pass: a reused **R8/Grey pool** (allocated larger
5426    /// than the frame, the NV24 worst case `3·H`) is reconfigured to an NV12
5427    /// frame, filled at the preserved physical stride, and converted with a
5428    /// letterbox `src_rect` crop into a model-sized PlanarRgb F16 destination —
5429    /// exactly the orchestrator's preprocess. Guards against the pooled
5430    /// two-pass NV→PlanarRgb F16 path hanging/erroring (the exact-size
5431    /// `test_nv12_to_planar_f16_two_pass` never exercised the larger pool).
5432    #[test]
5433    #[cfg(target_os = "macos")]
5434    #[cfg(feature = "opengl")]
5435    fn test_nv12_to_planar_f16_two_pass_pool_opengl_macos() {
5436        let mut gpu = match GLProcessorThreaded::new(None) {
5437            Ok(p) => p,
5438            Err(e) => {
5439                eprintln!("SKIPPED: {} — init failed ({e:?})", function!());
5440                return;
5441            }
5442        };
5443        // Frame 96×64 in a 256×768 R8 pool (3·256 height; bpr padded past 96).
5444        let (fw, fh) = (96usize, 64usize);
5445        let (pool_w, pool_h) = (256usize, 768usize);
5446        let (model_w, model_h) = (128usize, 128usize);
5447
5448        let mut src = match TensorDyn::image(
5449            pool_w,
5450            pool_h,
5451            PixelFormat::Grey,
5452            DType::U8,
5453            Some(TensorMemory::Dma),
5454        ) {
5455            Ok(t) => t,
5456            Err(e) => {
5457                eprintln!("SKIPPED: {} — R8 pool alloc: {e:?}", function!());
5458                return;
5459            }
5460        };
5461        src.configure_image(fw, fh, PixelFormat::Nv12)
5462            .unwrap_or_else(|e| panic!("configure_image NV12 on pool: {e}"));
5463        let stride = src.as_u8().unwrap().effective_row_stride().unwrap();
5464        src.as_u8().unwrap().map().unwrap().as_mut_slice().fill(128); // neutral grey
5465
5466        let mut dst = match TensorDyn::image(
5467            model_w,
5468            model_h,
5469            PixelFormat::PlanarRgb,
5470            DType::F16,
5471            Some(TensorMemory::Dma),
5472        ) {
5473            Ok(t) => t,
5474            Err(e) => {
5475                eprintln!("SKIPPED: {} — F16 PlanarRgb dst: {e:?}", function!());
5476                return;
5477            }
5478        };
5479
5480        // Letterbox crop like the profiler: the backend computes the band.
5481        let _ = model_w;
5482        let crop = Crop::new()
5483            .with_source(Some(Region::new(0, 0, fw, fh)))
5484            .with_fit(Fit::Letterbox {
5485                pad: [0, 0, 0, 255],
5486            });
5487        if let Err(e) =
5488            ImageProcessorTrait::convert(&mut gpu, &src, &mut dst, Rotation::None, Flip::None, crop)
5489        {
5490            eprintln!(
5491                "SKIPPED: {} — NV12→PlanarRgb F16 unavailable ({e:?})",
5492                function!()
5493            );
5494            return;
5495        }
5496        let _ = stride;
5497        // Neutral grey → ~0.5 inside the letterbox band; just assert the convert
5498        // completed and produced finite values (no hang, no NaN garbage).
5499        let dt = dst.as_f16().expect("dst F16");
5500        let map = dt.map().unwrap();
5501        let any_half = map.as_slice().iter().any(|&v| {
5502            let f = f32::from(v);
5503            (0.40..=0.60).contains(&f)
5504        });
5505        assert!(any_half, "expected ~0.5 grey samples in the letterbox band");
5506    }
5507
5508    /// Mirrors the orchestrator: the GL processor is created on one thread
5509    /// and `convert()` is called from a *different* thread (the profiler's
5510    /// Pre-processing worker). Reproduces (or rules out) the GL-context /
5511    /// `glFinish` cross-thread hang seen in the live pipeline. A 20 s watchdog
5512    /// fails loudly rather than hanging the whole test binary.
5513    #[test]
5514    #[cfg(target_os = "macos")]
5515    #[cfg(feature = "opengl")]
5516    fn test_nv12_to_planar_f16_cross_thread_opengl_macos() {
5517        use std::sync::mpsc;
5518        // Public ImageProcessor (Send) created HERE (the main test thread),
5519        // exactly like the orchestrator builds `config.processor` during setup.
5520        let mut proc = match ImageProcessor::new() {
5521            Ok(p) => p,
5522            Err(e) => {
5523                eprintln!("SKIPPED: {} — init failed ({e:?})", function!());
5524                return;
5525            }
5526        };
5527        let (fw, fh) = (96usize, 64usize);
5528        let mut src = match TensorDyn::image(
5529            256,
5530            768,
5531            PixelFormat::Grey,
5532            DType::U8,
5533            Some(TensorMemory::Dma),
5534        ) {
5535            Ok(t) => t,
5536            Err(e) => {
5537                eprintln!("SKIPPED: {} — pool: {e:?}", function!());
5538                return;
5539            }
5540        };
5541        src.configure_image(fw, fh, PixelFormat::Nv12).unwrap();
5542        src.as_u8().unwrap().map().unwrap().as_mut_slice().fill(128);
5543        let mut dst = match TensorDyn::image(
5544            128,
5545            128,
5546            PixelFormat::PlanarRgb,
5547            DType::F16,
5548            Some(TensorMemory::Dma),
5549        ) {
5550            Ok(t) => t,
5551            Err(e) => {
5552                eprintln!("SKIPPED: {} — dst: {e:?}", function!());
5553                return;
5554            }
5555        };
5556        let crop = Crop::new().with_source(Some(Region::new(0, 0, fw, fh)));
5557
5558        // ...then MOVED to a worker thread where convert() runs — exactly the
5559        // orchestrator's create-on-setup / convert-on-Pre-processing split.
5560        let (tx, rx) = mpsc::channel::<bool>();
5561        let worker = std::thread::spawn(move || {
5562            let _ = ImageProcessorTrait::convert(
5563                &mut proc,
5564                &src,
5565                &mut dst,
5566                Rotation::None,
5567                Flip::None,
5568                crop,
5569            );
5570            let _ = tx.send(true);
5571        });
5572        match rx.recv_timeout(std::time::Duration::from_secs(20)) {
5573            Ok(_) => { let _ = worker.join(); }
5574            Err(_) => panic!(
5575                "cross-thread NV12→PlanarRgb convert HUNG (>20s) — reproduces the orchestrator deadlock"
5576            ),
5577        }
5578    }
5579
5580    /// Reproduces the profiler's progressive-slowdown/hang: one processor
5581    /// converting many **varying-size** NV frames (like a COCO dataset) from a
5582    /// reused R8 pool into a fixed PlanarRgb F16 model input. The two-pass path
5583    /// reallocated its RGBA intermediate per frame-size, churning/leaking
5584    /// pbuffers until the GPU stalled. Asserts per-convert latency stays bounded
5585    /// (no runaway) over many iterations.
5586    #[test]
5587    #[cfg(target_os = "macos")]
5588    #[cfg(feature = "opengl")]
5589    fn test_nv_to_planar_f16_varying_sizes_no_leak_opengl_macos() {
5590        let mut gpu = match GLProcessorThreaded::new(None) {
5591            Ok(p) => p,
5592            Err(e) => {
5593                eprintln!("SKIPPED: {} — init failed ({e:?})", function!());
5594                return;
5595            }
5596        };
5597        // Mirror the orchestrator's ring buffers at depth 4: a pool of source R8
5598        // tensors AND a pool of PlanarRgb F16 dst slots, both cycled per frame.
5599        let (max_w, max_h) = (640usize, 640usize);
5600        let depth = 4usize;
5601        let mut srcs = Vec::new();
5602        let mut dsts = Vec::new();
5603        for _ in 0..depth {
5604            srcs.push(
5605                match TensorDyn::image(
5606                    max_w,
5607                    max_h * 3,
5608                    PixelFormat::Grey,
5609                    DType::U8,
5610                    Some(TensorMemory::Dma),
5611                ) {
5612                    Ok(t) => t,
5613                    Err(e) => {
5614                        eprintln!("SKIPPED: {} — pool: {e:?}", function!());
5615                        return;
5616                    }
5617                },
5618            );
5619            dsts.push(
5620                match TensorDyn::image(
5621                    640,
5622                    640,
5623                    PixelFormat::PlanarRgb,
5624                    DType::F16,
5625                    Some(TensorMemory::Dma),
5626                ) {
5627                    Ok(t) => t,
5628                    Err(e) => {
5629                        eprintln!("SKIPPED: {} — dst: {e:?}", function!());
5630                        return;
5631                    }
5632                },
5633            );
5634        }
5635        // COCO-like assorted frame sizes (all ≤ max), cycled.
5636        let sizes = [
5637            (640, 480),
5638            (500, 375),
5639            (640, 427),
5640            (333, 500),
5641            (480, 640),
5642            (612, 612),
5643            (428, 640),
5644            (576, 432),
5645        ];
5646        let mut first_ms = 0f64;
5647        let mut last_ms = 0f64;
5648        let iters = 40usize;
5649        for i in 0..iters {
5650            let (fw, fh) = sizes[i % sizes.len()];
5651            let src = &mut srcs[i % depth];
5652            let dst = &mut dsts[i % depth];
5653            src.configure_image(fw, fh, PixelFormat::Nv24).unwrap();
5654            src.as_u8().unwrap().map().unwrap().as_mut_slice().fill(128);
5655            let crop = Crop::new().with_source(Some(Region::new(0, 0, fw, fh)));
5656            let t0 = std::time::Instant::now();
5657            ImageProcessorTrait::convert(&mut gpu, src, dst, Rotation::None, Flip::None, crop)
5658                .unwrap_or_else(|e| panic!("convert iter {i} ({fw}×{fh}): {e}"));
5659            let ms = t0.elapsed().as_secs_f64() * 1e3;
5660            if i == 2 {
5661                first_ms = ms;
5662            }
5663            if i == iters - 1 {
5664                last_ms = ms;
5665            }
5666        }
5667        eprintln!("first={first_ms:.2}ms last={last_ms:.2}ms");
5668        assert!(
5669            last_ms < first_ms * 5.0 + 5.0,
5670            "convert latency ran away: first {first_ms:.2}ms → last {last_ms:.2}ms (intermediate/pbuffer leak)"
5671        );
5672    }
5673
5674    /// Step-2 verification: NV12/NV16/NV24 (R8 IOSurface) → RGBA on the GPU
5675    /// must match the CPU `yuv` kernels within shader rounding. Fills an
5676    /// IOSurface source and a Mem source from the same logical YUV pattern
5677    /// (each at its own row stride), converts the IOSurface on the GPU and the
5678    /// Mem one on the CPU, and compares. Exercises the in-shader semi-planar
5679    /// addressing for all three subsamplings (incl. NV24's 2×-wide UV rows).
5680    #[test]
5681    #[cfg(target_os = "macos")]
5682    #[cfg(feature = "opengl")]
5683    fn test_nv12_nv16_nv24_to_rgba_opengl_macos() {
5684        let mut gpu = match GLProcessorThreaded::new(None) {
5685            Ok(p) => p,
5686            Err(e) => {
5687                eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
5688                return;
5689            }
5690        };
5691        let mut cpu = CPUProcessor::new();
5692
5693        // Fill Y plus the interleaved UV plane in the canonical semi-planar
5694        // layout — exactly what the codec writes and the CPU `yuv`-crate reader
5695        // expects: the Y plane is `h` rows at the buffer's row stride; the UV
5696        // plane starts at `h * stride`; each chroma row advances
5697        // `uv_grid_rows * stride` bytes (NV24 carries a full-resolution `2*W`
5698        // byte line == two grid rows, NV12/NV16 one row of `W/2` pairs); each
5699        // (Cb,Cr) pair is two consecutive bytes at column `cx * 2`. This is
5700        // stride-correct for both the tight Mem buffer and the padded IOSurface.
5701        //
5702        // Takes explicit w/h so the closure can be reused across multiple frame
5703        // sizes (even and odd) without capturing a fixed outer variable.
5704        let fill = |buf: &mut [u8], stride: usize, fmt: PixelFormat, w: usize, h: usize| {
5705            for y in 0..h {
5706                for x in 0..w {
5707                    buf[y * stride + x] = ((x * 9 + y * 5) & 0xff) as u8;
5708                }
5709            }
5710            let (cw, ch, uv_grid_rows) = match fmt {
5711                PixelFormat::Nv12 => (w / 2, h / 2, 1usize),
5712                PixelFormat::Nv16 => (w / 2, h, 1usize),
5713                _ => (w, h, 2usize), // Nv24: full-res chroma, 2W bytes/row
5714            };
5715            let uv_plane = h * stride;
5716            for cy in 0..ch {
5717                for cx in 0..cw {
5718                    let off = uv_plane + cy * uv_grid_rows * stride + cx * 2;
5719                    buf[off] = ((cx * 11 + 30) & 0xff) as u8;
5720                    buf[off + 1] = ((cy * 7 + 200) & 0xff) as u8;
5721                }
5722            }
5723        };
5724
5725        for fmt in [PixelFormat::Nv12, PixelFormat::Nv16, PixelFormat::Nv24] {
5726            for (w, h) in [
5727                (16usize, 16usize), // original even-dim case
5728                (15, 16),           // odd-W
5729                (16, 15),           // odd-H
5730            ] {
5731                let mem = TensorDyn::image(w, h, fmt, DType::U8, None).unwrap();
5732                let mem_stride = mem.as_u8().unwrap().effective_row_stride().unwrap();
5733                fill(
5734                    mem.as_u8().unwrap().map().unwrap().as_mut_slice(),
5735                    mem_stride,
5736                    fmt,
5737                    w,
5738                    h,
5739                );
5740                let cpu_dst = TensorDyn::image(w, h, PixelFormat::Rgba, DType::U8, None).unwrap();
5741                let (r, _s, cpu_dst) = convert_img(
5742                    &mut cpu,
5743                    mem,
5744                    cpu_dst,
5745                    Rotation::None,
5746                    Flip::None,
5747                    Crop::no_crop(),
5748                );
5749                r.unwrap_or_else(|e| panic!("CPU {fmt:?}->{w}x{h}->RGBA: {e}"));
5750
5751                let ios = TensorDyn::image(w, h, fmt, DType::U8, Some(TensorMemory::Dma))
5752                    .unwrap_or_else(|e| panic!("{fmt:?} {w}x{h} IOSurface alloc: {e}"));
5753                let ios_stride = ios.as_u8().unwrap().effective_row_stride().unwrap();
5754                fill(
5755                    ios.as_u8().unwrap().map().unwrap().as_mut_slice(),
5756                    ios_stride,
5757                    fmt,
5758                    w,
5759                    h,
5760                );
5761                let gpu_dst =
5762                    TensorDyn::image(w, h, PixelFormat::Rgba, DType::U8, Some(TensorMemory::Dma))
5763                        .unwrap();
5764                let (r, _s, gpu_dst) = convert_img(
5765                    &mut gpu,
5766                    ios,
5767                    gpu_dst,
5768                    Rotation::None,
5769                    Flip::None,
5770                    Crop::no_crop(),
5771                );
5772                r.unwrap_or_else(|e| panic!("GPU {fmt:?}->{w}x{h}->RGBA on ANGLE: {e}"));
5773
5774                let cs = cpu_dst.as_u8().unwrap().effective_row_stride().unwrap();
5775                let cmap = cpu_dst.as_u8().unwrap().map().unwrap();
5776                let cb = cmap.as_slice();
5777                let gs = gpu_dst.as_u8().unwrap().effective_row_stride().unwrap();
5778                let gmap = gpu_dst.as_u8().unwrap().map().unwrap();
5779                let gb = gmap.as_slice();
5780                let mut max_d = 0i16;
5781                for y in 0..h {
5782                    for x in 0..w {
5783                        for c in 0..3 {
5784                            let cv = cb[y * cs + x * 4 + c] as i16;
5785                            let gv = gb[y * gs + x * 4 + c] as i16;
5786                            max_d = max_d.max((cv - gv).abs());
5787                        }
5788                    }
5789                }
5790                assert!(
5791                    max_d <= 3,
5792                    "{fmt:?} {w}x{h}: GPU vs CPU RGBA max channel diff {max_d} > 3"
5793                );
5794            }
5795        }
5796    }
5797
5798    /// Phase-0 gate: the reused-pool / larger-surface case. A single R8 pool
5799    /// IOSurface (allocated bigger than the frame, so its `bytesPerRow` exceeds
5800    /// the frame's even width) is reconfigured to each NV frame, filled at the
5801    /// preserved physical stride, and converted on the GPU. This proves ANGLE
5802    /// binds the *whole* physical surface as a pbuffer and that `texelFetch`
5803    /// resolves the frame's Y/UV texels through the surface's real `bytesPerRow`
5804    /// (the physical-grid / logical-ROI decoupling). GPU must match CPU ≤3 LSB.
5805    #[test]
5806    #[cfg(target_os = "macos")]
5807    #[cfg(feature = "opengl")]
5808    fn test_nv_to_rgba_larger_pool_surface_opengl_macos() {
5809        let mut gpu = match GLProcessorThreaded::new(None) {
5810            Ok(p) => p,
5811            Err(e) => {
5812                eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
5813                return;
5814            }
5815        };
5816        let mut cpu = CPUProcessor::new();
5817        // The pool is generously oversized (256-wide → bpr 256, well beyond any
5818        // frame's even width) and tall enough for NV24's 3·H at the test frames.
5819        let (pool_w, pool_h) = (256usize, 256usize);
5820
5821        // Canonical semi-planar fill: Y plane at the row stride, then the UV
5822        // plane at `h * stride` with each chroma row advancing `uv_grid_rows *
5823        // stride` bytes. Stride-correct for both tight Mem and padded IOSurface.
5824        let fill = |buf: &mut [u8], stride: usize, fmt: PixelFormat, w: usize, h: usize| {
5825            for y in 0..h {
5826                for x in 0..w {
5827                    buf[y * stride + x] = ((x * 9 + y * 5) & 0xff) as u8;
5828                }
5829            }
5830            let (cw, ch, uv_grid_rows) = match fmt {
5831                PixelFormat::Nv12 => (w / 2, h / 2, 1usize),
5832                PixelFormat::Nv16 => (w / 2, h, 1usize),
5833                _ => (w, h, 2usize), // Nv24: full-res chroma, 2W bytes/row
5834            };
5835            let uv_plane = h * stride;
5836            for cy in 0..ch {
5837                for cx in 0..cw {
5838                    let off = uv_plane + cy * uv_grid_rows * stride + cx * 2;
5839                    buf[off] = ((cx * 11 + 30) & 0xff) as u8;
5840                    buf[off + 1] = ((cy * 7 + 200) & 0xff) as u8;
5841                }
5842            }
5843        };
5844
5845        for fmt in [PixelFormat::Nv12, PixelFormat::Nv16, PixelFormat::Nv24] {
5846            for (w, h) in [
5847                (40usize, 24usize), // original even-dim case
5848                (15, 16),           // odd-W
5849                (16, 15),           // odd-H
5850            ] {
5851                // `ew` is the minimum even extent; the pool stride must exceed it
5852                // to actually exercise the physical-stride shader decoupling.
5853                let ew = w.next_multiple_of(2);
5854
5855                // CPU reference from a tightly-packed Mem tensor at the frame size.
5856                let mem = TensorDyn::image(w, h, fmt, DType::U8, None).unwrap();
5857                let mem_stride = mem.as_u8().unwrap().effective_row_stride().unwrap();
5858                fill(
5859                    mem.as_u8().unwrap().map().unwrap().as_mut_slice(),
5860                    mem_stride,
5861                    fmt,
5862                    w,
5863                    h,
5864                );
5865                let cpu_dst = TensorDyn::image(w, h, PixelFormat::Rgba, DType::U8, None).unwrap();
5866                let (r, _s, cpu_dst) = convert_img(
5867                    &mut cpu,
5868                    mem,
5869                    cpu_dst,
5870                    Rotation::None,
5871                    Flip::None,
5872                    Crop::no_crop(),
5873                );
5874                r.unwrap_or_else(|e| panic!("CPU {fmt:?}->{w}x{h}->RGBA: {e}"));
5875
5876                // GPU source: a LARGER R8 pool surface, reconfigured down to the
5877                // frame. Phase 1 preserves the pool's padded `bytesPerRow` as the
5878                // tensor's row stride; the fill writes the frame at that stride.
5879                let mut ios = match TensorDyn::image(
5880                    pool_w,
5881                    pool_h,
5882                    PixelFormat::Grey,
5883                    DType::U8,
5884                    Some(TensorMemory::Dma),
5885                ) {
5886                    Ok(t) => t,
5887                    Err(e) => {
5888                        eprintln!("SKIPPED: {} — R8 pool IOSurface alloc: {e:?}", function!());
5889                        return;
5890                    }
5891                };
5892                ios.configure_image(w, h, fmt)
5893                    .unwrap_or_else(|e| panic!("configure_image {fmt:?} {w}x{h} on pool: {e}"));
5894                let ios_stride = ios.as_u8().unwrap().effective_row_stride().unwrap();
5895                assert!(
5896                    ios_stride > ew,
5897                    "{fmt:?} {w}x{h}: pool stride {ios_stride} should exceed even width {ew} \
5898                     (test must exercise padding)"
5899                );
5900                fill(
5901                    ios.as_u8().unwrap().map().unwrap().as_mut_slice(),
5902                    ios_stride,
5903                    fmt,
5904                    w,
5905                    h,
5906                );
5907
5908                let gpu_dst =
5909                    TensorDyn::image(w, h, PixelFormat::Rgba, DType::U8, Some(TensorMemory::Dma))
5910                        .unwrap();
5911                let (r, _s, gpu_dst) = convert_img(
5912                    &mut gpu,
5913                    ios,
5914                    gpu_dst,
5915                    Rotation::None,
5916                    Flip::None,
5917                    Crop::no_crop(),
5918                );
5919                r.unwrap_or_else(|e| {
5920                    panic!("GPU {fmt:?}->{w}x{h}->RGBA (pool surface) on ANGLE: {e}")
5921                });
5922
5923                let cs = cpu_dst.as_u8().unwrap().effective_row_stride().unwrap();
5924                let cmap = cpu_dst.as_u8().unwrap().map().unwrap();
5925                let cb = cmap.as_slice();
5926                let gs = gpu_dst.as_u8().unwrap().effective_row_stride().unwrap();
5927                let gmap = gpu_dst.as_u8().unwrap().map().unwrap();
5928                let gb = gmap.as_slice();
5929                let mut max_d = 0i16;
5930                for y in 0..h {
5931                    for x in 0..w {
5932                        for c in 0..3 {
5933                            let cv = cb[y * cs + x * 4 + c] as i16;
5934                            let gv = gb[y * gs + x * 4 + c] as i16;
5935                            max_d = max_d.max((cv - gv).abs());
5936                        }
5937                    }
5938                }
5939                assert!(
5940                    max_d <= 3,
5941                    "{fmt:?} {w}x{h}: GPU(pool surface) vs CPU RGBA max channel diff {max_d} > 3"
5942                );
5943            }
5944        }
5945    }
5946
5947    #[test]
5948    #[cfg(target_os = "macos")]
5949    #[cfg(feature = "opengl")]
5950    fn test_yuyv_to_rgba_opengl_macos() {
5951        let mut proc = match GLProcessorThreaded::new(None) {
5952            Ok(p) => p,
5953            Err(e) => {
5954                eprintln!(
5955                    "SKIPPED: {} — GL engine init failed ({e:?}). \
5956                     Install ANGLE via `brew install startergo/angle/angle` \
5957                     and re-sign per README.md § macOS GPU Acceleration to \
5958                     run this test.",
5959                    function!()
5960                );
5961                return;
5962            }
5963        };
5964
5965        let src = load_bytes_to_tensor(
5966            1280,
5967            720,
5968            PixelFormat::Yuyv,
5969            Some(TensorMemory::Dma),
5970            &edgefirst_bench::testdata::read("camera720p.yuyv"),
5971        )
5972        .unwrap();
5973
5974        let dst = TensorDyn::image(
5975            1280,
5976            720,
5977            PixelFormat::Rgba,
5978            DType::U8,
5979            Some(TensorMemory::Dma),
5980        )
5981        .unwrap();
5982
5983        let (result, _src, dst) = convert_img(
5984            &mut proc,
5985            src,
5986            dst,
5987            Rotation::None,
5988            Flip::None,
5989            Crop::no_crop(),
5990        );
5991        result.unwrap();
5992
5993        let target_image = TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None).unwrap();
5994        target_image
5995            .as_u8()
5996            .unwrap()
5997            .map()
5998            .unwrap()
5999            .as_mut_slice()
6000            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
6001
6002        // macOS YUYV shader now threads per-tensor colorimetry: the untagged
6003        // camera720p source resolves to BT.709 limited (matching the BT.709
6004        // reference), measured 0.9973 on ANGLE — up from 0.9733 under the old
6005        // BT.601-full stop-gap. 0.98 leaves headroom for cross-GPU variance.
6006        compare_images(&dst, &target_image, 0.98, function!());
6007    }
6008
6009    /// Multi-resolution smoke test: convert YUYV→RGBA via the GL
6010    /// backend at a small (64×32) frame and a 4K (3840×2160) frame,
6011    /// both filled with a synthetic mid-grey pattern. Validates the
6012    /// shader math at the chroma-pairing boundary on small textures
6013    /// and exercises the IOSurface bytes-per-row alignment path at 4K
6014    /// (3840 pixels × 2 bytes/pixel = 7680 bytes, naturally 64-aligned).
6015    ///
6016    /// Resolutions below 32 pixels wide aren't tested because the
6017    /// IOSurface allocator pads bpr to 64 bytes — for a 4-px-wide
6018    /// YUYV surface that's 8 bytes data + 56 bytes padding per row,
6019    /// which exercises a sampling pattern that's ANGLE-version
6020    /// dependent rather than HAL-correctness dependent.
6021    ///
6022    /// This complements `test_yuyv_to_rgba_opengl_macos` (which checks
6023    /// pixel-exact correctness against a reference image at 720p) by
6024    /// ensuring the pipeline does not crash or produce gross errors at
6025    /// resolution extremes. Pixel-exact validation at 4K would require
6026    /// a 30 MB reference file we don't want to bundle.
6027    #[test]
6028    #[cfg(target_os = "macos")]
6029    #[cfg(feature = "opengl")]
6030    fn test_yuyv_to_rgba_opengl_macos_multi_resolution() {
6031        let mut proc = match GLProcessorThreaded::new(None) {
6032            Ok(p) => p,
6033            Err(e) => {
6034                eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
6035                return;
6036            }
6037        };
6038
6039        for (w, h) in [(64usize, 32usize), (3840, 2160)] {
6040            // Synthetic YUYV: Y=128 (mid-grey luma), U=V=128 (neutral
6041            // chroma) → RGB grey at the output.
6042            let bytes_per_row = w * 2;
6043            let mut yuyv = vec![0u8; bytes_per_row * h];
6044            for chunk in yuyv.chunks_exact_mut(4) {
6045                chunk[0] = 128; // Y0
6046                chunk[1] = 128; // U
6047                chunk[2] = 128; // Y1
6048                chunk[3] = 128; // V
6049            }
6050
6051            let src = load_bytes_to_tensor(w, h, PixelFormat::Yuyv, Some(TensorMemory::Dma), &yuyv)
6052                .unwrap();
6053
6054            let dst = TensorDyn::image(w, h, PixelFormat::Rgba, DType::U8, Some(TensorMemory::Dma))
6055                .unwrap();
6056
6057            let (result, _src, dst) = convert_img(
6058                &mut proc,
6059                src,
6060                dst,
6061                Rotation::None,
6062                Flip::None,
6063                Crop::no_crop(),
6064            );
6065            result.expect("GL convert should succeed at this resolution");
6066
6067            // The neutral-chroma input must produce a near-grey output;
6068            // BT.709 limited-range maps Y=128/UV=128 → roughly
6069            // (130, 130, 130). Allow ±4 LSB for `mediump float` shader
6070            // rounding.
6071            let dst_u8 = dst.as_u8().unwrap();
6072            let dst_map = dst_u8.map().unwrap();
6073            let dst_bytes = dst_map.as_slice();
6074            assert_eq!(dst_bytes.len(), w * h * 4, "RGBA byte count");
6075            for px in dst_bytes.chunks_exact(4) {
6076                for (i, &c) in px[..3].iter().enumerate() {
6077                    assert!(
6078                        (120..=140).contains(&c),
6079                        "{}: channel {i} = {c} (expected ~128 ±12) at {w}×{h}",
6080                        function!(),
6081                    );
6082                }
6083                assert_eq!(px[3], 255, "alpha must be 1.0");
6084            }
6085        }
6086    }
6087
6088    /// Verify that two consecutive convert() calls on the same source
6089    /// tensor reuse the cached EGL pbuffer. Tests the cache hit path
6090    /// added with the macOS GL backend hardening — without it, each
6091    /// frame would pay `eglCreatePbufferFromClientBuffer` + destroy.
6092    ///
6093    /// This is a behaviour test rather than a perf test (the timing
6094    /// difference is 100-200µs which is too noisy to assert on); we
6095    /// check that the second call succeeds and produces a result
6096    /// identical to the first.
6097    #[test]
6098    #[cfg(target_os = "macos")]
6099    #[cfg(feature = "opengl")]
6100    fn test_macos_gl_pbuffer_cache_reuses_surfaces() {
6101        let mut proc = match GLProcessorThreaded::new(None) {
6102            Ok(p) => p,
6103            Err(e) => {
6104                eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
6105                return;
6106            }
6107        };
6108
6109        // Allocate one source + one destination, run convert twice.
6110        let mut yuyv = vec![0u8; 64 * 32 * 2];
6111        for chunk in yuyv.chunks_exact_mut(4) {
6112            chunk[0] = 200;
6113            chunk[1] = 100;
6114            chunk[2] = 200;
6115            chunk[3] = 156;
6116        }
6117        let src = load_bytes_to_tensor(64, 32, PixelFormat::Yuyv, Some(TensorMemory::Dma), &yuyv)
6118            .unwrap();
6119        let dst = TensorDyn::image(
6120            64,
6121            32,
6122            PixelFormat::Rgba,
6123            DType::U8,
6124            Some(TensorMemory::Dma),
6125        )
6126        .unwrap();
6127
6128        let (r1, src, dst) = convert_img(
6129            &mut proc,
6130            src,
6131            dst,
6132            Rotation::None,
6133            Flip::None,
6134            Crop::no_crop(),
6135        );
6136        r1.unwrap();
6137        let first: Vec<u8> = dst.as_u8().unwrap().map().unwrap().as_slice().to_vec();
6138
6139        let (r2, _src, dst) = convert_img(
6140            &mut proc,
6141            src,
6142            dst,
6143            Rotation::None,
6144            Flip::None,
6145            Crop::no_crop(),
6146        );
6147        r2.unwrap();
6148        let second: Vec<u8> = dst.as_u8().unwrap().map().unwrap().as_slice().to_vec();
6149
6150        assert_eq!(first, second, "cache-hit conversion must be deterministic");
6151    }
6152
6153    /// Steady-state import gate (macOS half of the Linux
6154    /// `dma_pool_steady_state_zero_imports` test): an N-frame convert loop
6155    /// over a fixed pool of IOSurface tensors must create ZERO new EGL
6156    /// pbuffers after the pool has been seen once — pbuffer-cache misses
6157    /// stay flat while hits grow. Counter-based hardening of
6158    /// `test_macos_gl_pbuffer_cache_reuses_surfaces` above: a refactor that
6159    /// re-imports per frame passes the pixel-equality test but fails this.
6160    #[test]
6161    #[cfg(target_os = "macos")]
6162    #[cfg(feature = "opengl")]
6163    fn test_macos_gl_pbuffer_cache_steady_state() {
6164        let mut proc = match GLProcessorThreaded::new(None) {
6165            Ok(p) => p,
6166            Err(e) => {
6167                eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
6168                return;
6169            }
6170        };
6171
6172        let (w, h) = (64usize, 32usize);
6173        const POOL: usize = 3;
6174        const FRAMES: usize = 100;
6175
6176        let yuyv = vec![128u8; w * h * 2];
6177        let pool: Vec<TensorDyn> = (0..POOL)
6178            .map(|_| {
6179                load_bytes_to_tensor(w, h, PixelFormat::Yuyv, Some(TensorMemory::Dma), &yuyv)
6180                    .unwrap()
6181            })
6182            .collect();
6183        let mut dst =
6184            TensorDyn::image(w, h, PixelFormat::Rgba, DType::U8, Some(TensorMemory::Dma)).unwrap();
6185
6186        // Warmup: two passes over the pool import every surface once.
6187        for src in pool.iter().cycle().take(POOL * 2) {
6188            proc.convert(src, &mut dst, Rotation::None, Flip::None, Crop::no_crop())
6189                .unwrap();
6190        }
6191        let warm = proc.egl_cache_stats().unwrap();
6192
6193        for src in pool.iter().cycle().take(FRAMES) {
6194            proc.convert(src, &mut dst, Rotation::None, Flip::None, Crop::no_crop())
6195                .unwrap();
6196        }
6197        let steady = proc.egl_cache_stats().unwrap();
6198
6199        assert_eq!(
6200            warm.total_misses(),
6201            steady.total_misses(),
6202            "steady-state loop created new imports (warm {warm:?}, steady {steady:?})"
6203        );
6204        let hits = |s: &GlCacheStats| s.src.hits + s.dst.hits + s.nv_r8.hits;
6205        assert!(
6206            hits(&steady) - hits(&warm) >= FRAMES as u64,
6207            "expected at least {FRAMES} import-cache hits over the loop, got {}",
6208            hits(&steady) - hits(&warm)
6209        );
6210    }
6211
6212    /// Backend assertion for the F16 zero-copy path: when the GL backend
6213    /// initialized (ANGLE on macOS) and reports F16 color-buffer support,
6214    /// the NV12→PlanarRgb-F16 IOSurface convert MUST be handled by the GL
6215    /// engine — proven by the engine's import-cache counters moving, not
6216    /// just by output correctness. This is the guard against the
6217    /// silent-CPU-fallback failure mode: a misclassified IOSurface F16
6218    /// destination keeps every output-correctness test green while
6219    /// quietly running ~10× slower on CPU; only a backend observable
6220    /// catches it. Skips ONLY when GL itself is unavailable or the
6221    /// configuration lacks F16 — a convert error or a CPU-routed convert
6222    /// with the capability present is a FAILURE.
6223    #[test]
6224    #[cfg(target_os = "macos")]
6225    #[cfg(feature = "opengl")]
6226    fn test_macos_gl_f16_planar_is_gl_backed() {
6227        let mut proc = ImageProcessor::new().expect("ImageProcessor");
6228        let Some(ref gl) = proc.opengl else {
6229            eprintln!("SKIPPED: {} — GL backend unavailable", function!());
6230            return;
6231        };
6232        if !gl.supported_render_dtypes().f16 {
6233            eprintln!(
6234                "SKIPPED: {} — configuration lacks F16 color-buffer support",
6235                function!()
6236            );
6237            return;
6238        }
6239        let stats_before = gl.egl_cache_stats().expect("cache stats");
6240
6241        let src = TensorDyn::image(
6242            1280,
6243            720,
6244            PixelFormat::Nv12,
6245            DType::U8,
6246            Some(TensorMemory::Dma),
6247        )
6248        .unwrap();
6249        {
6250            let t = src.as_u8().unwrap();
6251            let mut m = t.map().unwrap();
6252            for (i, b) in m.as_mut_slice().iter_mut().enumerate() {
6253                *b = ((i * 31) % 211) as u8;
6254            }
6255        }
6256        let mut dst = TensorDyn::image(
6257            640,
6258            640,
6259            PixelFormat::PlanarRgb,
6260            DType::F16,
6261            Some(TensorMemory::Dma),
6262        )
6263        .unwrap();
6264
6265        proc.convert(
6266            &src,
6267            &mut dst,
6268            Rotation::None,
6269            Flip::None,
6270            Crop::letterbox([114, 114, 114, 255]),
6271        )
6272        .expect("F16 capability reported but the NV12→PlanarF16 convert failed");
6273        let stats_after = proc
6274            .opengl
6275            .as_ref()
6276            .expect("GL backend present")
6277            .egl_cache_stats()
6278            .expect("cache stats");
6279        // ≥ 2 misses: the fused convert imports the zero-copy NV source
6280        // (pass 1) AND the F16 destination (pass 2). Requiring both means a
6281        // convert that imported its source, failed mid-engine, and fell back
6282        // to CPU (1 miss) cannot satisfy this gate.
6283        assert!(
6284            stats_after.total_misses() >= stats_before.total_misses() + 2,
6285            "convert succeeded but the GL engine did not import both the \
6286             source and the F16 destination — the work did not (fully) run \
6287             on the GL backend (silent CPU fallback); misses before={} after={}",
6288            stats_before.total_misses(),
6289            stats_after.total_misses()
6290        );
6291    }
6292
6293    /// Portable oracle for the fused NV12→PlanarRgb-F16 engine convert
6294    /// (two GL passes: NV→RGBA intermediate, then the packed RGBA16F
6295    /// render). New on every platform with F16 render support — macOS
6296    /// IOSurface and Linux DMA-BUF alike. Compares against the CPU
6297    /// backend's reference within the float-path tolerance.
6298    #[test]
6299    #[cfg(feature = "opengl")]
6300    fn test_nv12_to_planar_f16_fused_engine_vs_cpu() {
6301        let mut gl = match ImageProcessor::with_config(ImageProcessorConfig {
6302            backend: ComputeBackend::OpenGl,
6303            ..Default::default()
6304        }) {
6305            Ok(p) if p.opengl.is_some() => p,
6306            _ => {
6307                eprintln!("SKIPPED: {} — GL backend unavailable", function!());
6308                return;
6309            }
6310        };
6311        if !gl
6312            .opengl
6313            .as_ref()
6314            .map(|g| g.supported_render_dtypes().f16)
6315            .unwrap_or(false)
6316        {
6317            eprintln!("SKIPPED: {} — no F16 render support", function!());
6318            return;
6319        }
6320        let mem = if edgefirst_tensor::is_gpu_buffer_available() {
6321            TensorMemory::Dma
6322        } else {
6323            eprintln!("SKIPPED: {} — no zero-copy buffers", function!());
6324            return;
6325        };
6326
6327        let src = TensorDyn::image(1280, 720, PixelFormat::Nv12, DType::U8, Some(mem)).unwrap();
6328        {
6329            // Smooth gradients, NOT noise: the GL and CPU paths upsample
6330            // chroma with different kernels (nearest vs bilinear), which
6331            // legitimately diverges on per-texel chroma noise. Gradients
6332            // keep that kernel difference sub-LSB while still exercising
6333            // the full matrix math and the letterbox geometry.
6334            let t = src.as_u8().unwrap();
6335            let mut m = t.map().unwrap();
6336            let buf = m.as_mut_slice();
6337            let (w, h) = (1280usize, 720usize);
6338            for y in 0..h {
6339                for x in 0..w {
6340                    buf[y * w + x] = ((x * 255) / w) as u8; // luma ramp
6341                }
6342            }
6343            for y in 0..(h / 2) {
6344                for x in 0..(w / 2) {
6345                    let o = h * w + y * w + 2 * x;
6346                    buf[o] = ((y * 255) / (h / 2)) as u8; // U vertical ramp
6347                    buf[o + 1] = (((x + y) * 255) / (w / 2 + h / 2)) as u8; // V diagonal
6348                }
6349            }
6350        }
6351        let crop = Crop::letterbox([114, 114, 114, 255]);
6352        let mut gl_dst =
6353            TensorDyn::image(640, 640, PixelFormat::PlanarRgb, DType::F16, Some(mem)).unwrap();
6354        // Drive the GL backend DIRECTLY: a convert through `ImageProcessor`
6355        // silently falls back to CPU on a GL error, turning this oracle into
6356        // a CPU-vs-CPU tautology. A direct call surfaces the engine error.
6357        gl.opengl
6358            .as_mut()
6359            .expect("GL backend present")
6360            .convert(&src, &mut gl_dst, Rotation::None, Flip::None, crop)
6361            .expect("fused NV12→PlanarF16 GL convert");
6362
6363        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
6364            backend: ComputeBackend::Cpu,
6365            ..Default::default()
6366        })
6367        .unwrap();
6368        let mut cpu_dst = TensorDyn::image(
6369            640,
6370            640,
6371            PixelFormat::PlanarRgb,
6372            DType::F16,
6373            Some(TensorMemory::Mem),
6374        )
6375        .unwrap();
6376        cpu.convert(&src, &mut cpu_dst, Rotation::None, Flip::None, crop)
6377            .expect("CPU reference convert");
6378
6379        let g = gl_dst.as_f16().unwrap().map().unwrap().as_slice().to_vec();
6380        let c = cpu_dst.as_f16().unwrap().map().unwrap().as_slice().to_vec();
6381        assert_eq!(g.len(), c.len());
6382        let mut max_diff = 0.0f32;
6383        let mut max_at = 0usize;
6384        for (i, (a, b)) in g.iter().zip(c.iter()).enumerate() {
6385            let d = (a.to_f32() - b.to_f32()).abs();
6386            if d > max_diff {
6387                max_diff = d;
6388                max_at = i;
6389            }
6390        }
6391        // Localize: plane (R/G/B), row, col of the worst element.
6392        let (plane, rem) = (max_at / (640 * 640), max_at % (640 * 640));
6393        let (row, col) = (rem / 640, rem % 640);
6394        eprintln!(
6395            "fused-vs-cpu: max_diff={max_diff} at plane={plane} row={row} col={col} \
6396             gl={} cpu={}",
6397            g[max_at].to_f32(),
6398            c[max_at].to_f32()
6399        );
6400        // Two GPU passes (8-bit intermediate + linear filtering) vs the
6401        // CPU's direct path: allow a few 8-bit steps of divergence.
6402        assert!(
6403            max_diff <= 4.0 / 255.0 + 1e-3,
6404            "fused NV12→PlanarF16 diverges from CPU reference: max_diff={max_diff}"
6405        );
6406    }
6407
6408    /// Zero-copy source → heap destination through the GL engine,
6409    /// driven on the GL backend DIRECTLY so a GL error cannot hide
6410    /// behind the `ImageProcessor` CPU fallback (the shape that exposed
6411    /// the macOS `glReadnPixels` failure: imported source, rendered,
6412    /// then errored at the heap readback). RGBA→BGRA so the convert is
6413    /// a pure byte shuffle: no chroma kernel or colorimetry ambiguity
6414    /// (Vivante's NV fast path legitimately diverges from the CPU
6415    /// reference), and the readback stays GL_RGBA (V3D rejects RGB
6416    /// readbacks).
6417    #[test]
6418    #[cfg(feature = "opengl")]
6419    fn test_zero_copy_src_to_mem_dst_gl_direct() {
6420        let mut proc = match ImageProcessor::new() {
6421            Ok(p) if p.opengl.is_some() => p,
6422            _ => {
6423                eprintln!("SKIPPED: {} — GL backend unavailable", function!());
6424                return;
6425            }
6426        };
6427        if !edgefirst_tensor::is_gpu_buffer_available() {
6428            eprintln!("SKIPPED: {} — no zero-copy buffers", function!());
6429            return;
6430        }
6431
6432        let src = TensorDyn::image(
6433            1280,
6434            720,
6435            PixelFormat::Rgba,
6436            DType::U8,
6437            Some(TensorMemory::Dma),
6438        )
6439        .unwrap();
6440        {
6441            let t = src.as_u8().unwrap();
6442            let mut m = t.map().unwrap();
6443            for (i, b) in m.as_mut_slice().iter_mut().enumerate() {
6444                *b = ((i * 31) % 211) as u8;
6445            }
6446        }
6447        let mut gl_dst = TensorDyn::image(
6448            1280,
6449            720,
6450            PixelFormat::Bgra,
6451            DType::U8,
6452            Some(TensorMemory::Mem),
6453        )
6454        .unwrap();
6455        proc.opengl
6456            .as_mut()
6457            .expect("GL backend present")
6458            .convert(
6459                &src,
6460                &mut gl_dst,
6461                Rotation::None,
6462                Flip::None,
6463                Crop::no_crop(),
6464            )
6465            .expect("zero-copy src → heap dst GL convert");
6466
6467        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
6468            backend: ComputeBackend::Cpu,
6469            ..Default::default()
6470        })
6471        .unwrap();
6472        let mut cpu_dst = TensorDyn::image(
6473            1280,
6474            720,
6475            PixelFormat::Bgra,
6476            DType::U8,
6477            Some(TensorMemory::Mem),
6478        )
6479        .unwrap();
6480        cpu.convert(
6481            &src,
6482            &mut cpu_dst,
6483            Rotation::None,
6484            Flip::None,
6485            Crop::no_crop(),
6486        )
6487        .expect("CPU reference convert");
6488
6489        let g = gl_dst.as_u8().unwrap().map().unwrap().as_slice().to_vec();
6490        let c = cpu_dst.as_u8().unwrap().map().unwrap().as_slice().to_vec();
6491        assert_eq!(g.len(), c.len());
6492        let max_diff = g
6493            .iter()
6494            .zip(c.iter())
6495            .map(|(a, b)| a.abs_diff(*b))
6496            .max()
6497            .unwrap();
6498        // Same-size byte shuffle: GL samples texel centers 1:1, so allow
6499        // only rounding slack.
6500        assert!(
6501            max_diff <= 2,
6502            "zero-copy src → heap dst diverges from CPU reference: max_diff={max_diff}"
6503        );
6504    }
6505
6506    #[test]
6507    #[cfg(target_os = "linux")]
6508    fn test_yuyv_to_rgb_g2d() {
6509        if !is_g2d_available() {
6510            eprintln!("SKIPPED: test_yuyv_to_rgb_g2d - G2D library (libg2d.so.2) not available");
6511            return;
6512        }
6513        if !is_dma_available() {
6514            eprintln!(
6515                "SKIPPED: test_yuyv_to_rgb_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
6516            );
6517            return;
6518        }
6519
6520        let src = load_bytes_to_tensor(
6521            1280,
6522            720,
6523            PixelFormat::Yuyv,
6524            None,
6525            &edgefirst_bench::testdata::read("camera720p.yuyv"),
6526        )
6527        .unwrap();
6528
6529        let g2d_dst = TensorDyn::image(
6530            1280,
6531            720,
6532            PixelFormat::Rgb,
6533            DType::U8,
6534            Some(TensorMemory::Dma),
6535        )
6536        .unwrap();
6537        let mut g2d_converter = G2DProcessor::new().unwrap();
6538
6539        let (result, src, g2d_dst) = convert_img(
6540            &mut g2d_converter,
6541            src,
6542            g2d_dst,
6543            Rotation::None,
6544            Flip::None,
6545            Crop::no_crop(),
6546        );
6547        result.unwrap();
6548
6549        let cpu_dst = TensorDyn::image(1280, 720, PixelFormat::Rgb, DType::U8, None).unwrap();
6550        let mut cpu_converter: CPUProcessor = CPUProcessor::new();
6551
6552        let (result, _src, cpu_dst) = convert_img(
6553            &mut cpu_converter,
6554            src,
6555            cpu_dst,
6556            Rotation::None,
6557            Flip::None,
6558            Crop::no_crop(),
6559        );
6560        result.unwrap();
6561
6562        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
6563        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
6564        // the YUV-matrix delta that forced 0.95 has closed; tightened to
6565        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
6566        // structural gap not exercised by these limited-range fixtures.
6567        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
6568    }
6569
6570    #[test]
6571    #[cfg(target_os = "linux")]
6572    fn test_yuyv_to_yuyv_resize_g2d() {
6573        if !is_g2d_available() {
6574            eprintln!(
6575                "SKIPPED: test_yuyv_to_yuyv_resize_g2d - G2D library (libg2d.so.2) not available"
6576            );
6577            return;
6578        }
6579        if !is_dma_available() {
6580            eprintln!(
6581                "SKIPPED: test_yuyv_to_yuyv_resize_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
6582            );
6583            return;
6584        }
6585
6586        let src = load_bytes_to_tensor(
6587            1280,
6588            720,
6589            PixelFormat::Yuyv,
6590            None,
6591            &edgefirst_bench::testdata::read("camera720p.yuyv"),
6592        )
6593        .unwrap();
6594
6595        let g2d_dst = TensorDyn::image(
6596            600,
6597            400,
6598            PixelFormat::Yuyv,
6599            DType::U8,
6600            Some(TensorMemory::Dma),
6601        )
6602        .unwrap();
6603        let mut g2d_converter = G2DProcessor::new().unwrap();
6604
6605        let (result, src, g2d_dst) = convert_img(
6606            &mut g2d_converter,
6607            src,
6608            g2d_dst,
6609            Rotation::None,
6610            Flip::None,
6611            Crop::no_crop(),
6612        );
6613        result.unwrap();
6614
6615        let cpu_dst = TensorDyn::image(600, 400, PixelFormat::Yuyv, DType::U8, None).unwrap();
6616        let mut cpu_converter: CPUProcessor = CPUProcessor::new();
6617
6618        let (result, _src, cpu_dst) = convert_img(
6619            &mut cpu_converter,
6620            src,
6621            cpu_dst,
6622            Rotation::None,
6623            Flip::None,
6624            Crop::no_crop(),
6625        );
6626        result.unwrap();
6627
6628        // G2D has poor colorimetry support: its hardware YUYV resize/sampling
6629        // diverges from the CPU reference by enough that the similarity score
6630        // sits around 0.85 on every measured G2D core (i.MX 8M Plus, i.MX 95).
6631        // The threshold is held at 0.85 so the test guards against gross
6632        // regressions while tolerating the driver's inherent colorimetry error.
6633        // TODO: compare YUYV↔YUYV directly without a YUYV→RGB convert.
6634        eprintln!(
6635            "WARNING: G2D has poor colorimetry support — YUYV resize diverges from the \
6636             CPU reference (~0.85 similarity); threshold held at 0.85, not 0.95."
6637        );
6638        compare_images_convert_to_rgb(&g2d_dst, &cpu_dst, 0.85, function!());
6639    }
6640
6641    #[test]
6642    fn test_yuyv_to_rgba_resize_cpu() {
6643        let src = load_bytes_to_tensor(
6644            1280,
6645            720,
6646            PixelFormat::Yuyv,
6647            None,
6648            &edgefirst_bench::testdata::read("camera720p.yuyv"),
6649        )
6650        .unwrap();
6651
6652        let (dst_width, dst_height) = (960, 540);
6653
6654        let dst =
6655            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
6656        let mut cpu_converter = CPUProcessor::new();
6657
6658        let (result, _src, dst) = convert_img(
6659            &mut cpu_converter,
6660            src,
6661            dst,
6662            Rotation::None,
6663            Flip::None,
6664            Crop::no_crop(),
6665        );
6666        result.unwrap();
6667
6668        let dst_target =
6669            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
6670        let src_target = load_bytes_to_tensor(
6671            1280,
6672            720,
6673            PixelFormat::Rgba,
6674            None,
6675            &edgefirst_bench::testdata::read("camera720p.rgba"),
6676        )
6677        .unwrap();
6678        let (result, _src_target, dst_target) = convert_img(
6679            &mut cpu_converter,
6680            src_target,
6681            dst_target,
6682            Rotation::None,
6683            Flip::None,
6684            Crop::no_crop(),
6685        );
6686        result.unwrap();
6687
6688        // CPU path resolves the untagged 720p source to BT.709 limited (height
6689        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
6690        compare_images(&dst, &dst_target, 0.98, function!());
6691    }
6692
6693    #[test]
6694    #[cfg(target_os = "linux")]
6695    fn test_yuyv_to_rgba_crop_flip_g2d() {
6696        if !is_g2d_available() {
6697            eprintln!(
6698                "SKIPPED: test_yuyv_to_rgba_crop_flip_g2d - G2D library (libg2d.so.2) not available"
6699            );
6700            return;
6701        }
6702        if !is_dma_available() {
6703            eprintln!(
6704                "SKIPPED: test_yuyv_to_rgba_crop_flip_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
6705            );
6706            return;
6707        }
6708
6709        let src = load_bytes_to_tensor(
6710            1280,
6711            720,
6712            PixelFormat::Yuyv,
6713            Some(TensorMemory::Dma),
6714            &edgefirst_bench::testdata::read("camera720p.yuyv"),
6715        )
6716        .unwrap();
6717
6718        let (dst_width, dst_height) = (640, 640);
6719
6720        let dst_g2d = TensorDyn::image(
6721            dst_width,
6722            dst_height,
6723            PixelFormat::Rgba,
6724            DType::U8,
6725            Some(TensorMemory::Dma),
6726        )
6727        .unwrap();
6728        let mut g2d_converter = G2DProcessor::new().unwrap();
6729        let crop = Crop::new().with_source(Some(Region::new(20, 15, 400, 300)));
6730
6731        let (result, src, dst_g2d) = convert_img(
6732            &mut g2d_converter,
6733            src,
6734            dst_g2d,
6735            Rotation::None,
6736            Flip::Horizontal,
6737            crop,
6738        );
6739        result.unwrap();
6740
6741        let dst_cpu = TensorDyn::image(
6742            dst_width,
6743            dst_height,
6744            PixelFormat::Rgba,
6745            DType::U8,
6746            Some(TensorMemory::Dma),
6747        )
6748        .unwrap();
6749        let mut cpu_converter = CPUProcessor::new();
6750
6751        let (result, _src, dst_cpu) = convert_img(
6752            &mut cpu_converter,
6753            src,
6754            dst_cpu,
6755            Rotation::None,
6756            Flip::Horizontal,
6757            crop,
6758        );
6759        result.unwrap();
6760        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
6761        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
6762        // the YUV-matrix delta that forced 0.95 has closed; tightened to
6763        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
6764        // structural gap not exercised by these limited-range fixtures.
6765        compare_images(&dst_g2d, &dst_cpu, 0.98, function!());
6766    }
6767
6768    #[test]
6769    #[cfg(target_os = "linux")]
6770    #[cfg(feature = "opengl")]
6771    fn test_yuyv_to_rgba_crop_flip_opengl() {
6772        if !is_opengl_available() {
6773            eprintln!("SKIPPED: {} - OpenGL not available", function!());
6774            return;
6775        }
6776
6777        if !is_dma_available() {
6778            eprintln!(
6779                "SKIPPED: {} - DMA memory allocation not available (permission denied or no DMA-BUF support)",
6780                function!()
6781            );
6782            return;
6783        }
6784
6785        let src = load_bytes_to_tensor(
6786            1280,
6787            720,
6788            PixelFormat::Yuyv,
6789            Some(TensorMemory::Dma),
6790            &edgefirst_bench::testdata::read("camera720p.yuyv"),
6791        )
6792        .unwrap();
6793
6794        let (dst_width, dst_height) = (640, 640);
6795
6796        let dst_gl = TensorDyn::image(
6797            dst_width,
6798            dst_height,
6799            PixelFormat::Rgba,
6800            DType::U8,
6801            Some(TensorMemory::Dma),
6802        )
6803        .unwrap();
6804        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
6805        let crop = Crop::new().with_source(Some(Region::new(20, 15, 400, 300)));
6806
6807        let (result, src, dst_gl) = convert_img(
6808            &mut gl_converter,
6809            src,
6810            dst_gl,
6811            Rotation::None,
6812            Flip::Horizontal,
6813            crop,
6814        );
6815        result.unwrap();
6816
6817        let dst_cpu = TensorDyn::image(
6818            dst_width,
6819            dst_height,
6820            PixelFormat::Rgba,
6821            DType::U8,
6822            Some(TensorMemory::Dma),
6823        )
6824        .unwrap();
6825        let mut cpu_converter = CPUProcessor::new();
6826
6827        let (result, _src, dst_cpu) = convert_img(
6828            &mut cpu_converter,
6829            src,
6830            dst_cpu,
6831            Rotation::None,
6832            Flip::Horizontal,
6833            crop,
6834        );
6835        result.unwrap();
6836        // Post-WS1 the GL path applies the resolved colorimetry via the EGL
6837        // YUV color-space/sample-range hints, so the matrix delta that forced
6838        // 0.95 has closed; tightened to 0.98 (driver-matrix rounding confirmed
6839        // on the GPU lanes).
6840        compare_images(&dst_gl, &dst_cpu, 0.98, function!());
6841    }
6842
6843    #[test]
6844    fn test_vyuy_to_rgba_cpu() {
6845        let file = edgefirst_bench::testdata::read("camera720p.vyuy").to_vec();
6846        let src = TensorDyn::image(1280, 720, PixelFormat::Vyuy, DType::U8, None).unwrap();
6847        src.as_u8()
6848            .unwrap()
6849            .map()
6850            .unwrap()
6851            .as_mut_slice()
6852            .copy_from_slice(&file);
6853
6854        let dst = TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None).unwrap();
6855        let mut cpu_converter = CPUProcessor::new();
6856
6857        let (result, _src, dst) = convert_img(
6858            &mut cpu_converter,
6859            src,
6860            dst,
6861            Rotation::None,
6862            Flip::None,
6863            Crop::no_crop(),
6864        );
6865        result.unwrap();
6866
6867        let target_image = TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None).unwrap();
6868        target_image
6869            .as_u8()
6870            .unwrap()
6871            .map()
6872            .unwrap()
6873            .as_mut_slice()
6874            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
6875
6876        // CPU path resolves the untagged 720p source to BT.709 limited (height
6877        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
6878        compare_images(&dst, &target_image, 0.98, function!());
6879    }
6880
6881    #[test]
6882    fn test_vyuy_to_rgb_cpu() {
6883        let file = edgefirst_bench::testdata::read("camera720p.vyuy").to_vec();
6884        let src = TensorDyn::image(1280, 720, PixelFormat::Vyuy, DType::U8, None).unwrap();
6885        src.as_u8()
6886            .unwrap()
6887            .map()
6888            .unwrap()
6889            .as_mut_slice()
6890            .copy_from_slice(&file);
6891
6892        let dst = TensorDyn::image(1280, 720, PixelFormat::Rgb, DType::U8, None).unwrap();
6893        let mut cpu_converter = CPUProcessor::new();
6894
6895        let (result, _src, dst) = convert_img(
6896            &mut cpu_converter,
6897            src,
6898            dst,
6899            Rotation::None,
6900            Flip::None,
6901            Crop::no_crop(),
6902        );
6903        result.unwrap();
6904
6905        let target_image = TensorDyn::image(1280, 720, PixelFormat::Rgb, DType::U8, None).unwrap();
6906        target_image
6907            .as_u8()
6908            .unwrap()
6909            .map()
6910            .unwrap()
6911            .as_mut_slice()
6912            .as_chunks_mut::<3>()
6913            .0
6914            .iter_mut()
6915            .zip(
6916                edgefirst_bench::testdata::read("camera720p.rgba")
6917                    .as_chunks::<4>()
6918                    .0,
6919            )
6920            .for_each(|(dst, src)| *dst = [src[0], src[1], src[2]]);
6921
6922        // CPU path resolves the untagged 720p source to BT.709 limited (height
6923        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
6924        compare_images(&dst, &target_image, 0.98, function!());
6925    }
6926
6927    #[test]
6928    #[cfg(target_os = "linux")]
6929    #[ignore = "G2D does not support VYUY; re-enable when hardware support is added"]
6930    fn test_vyuy_to_rgba_g2d() {
6931        if !is_g2d_available() {
6932            eprintln!("SKIPPED: test_vyuy_to_rgba_g2d - G2D library (libg2d.so.2) not available");
6933            return;
6934        }
6935        if !is_dma_available() {
6936            eprintln!(
6937                "SKIPPED: test_vyuy_to_rgba_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
6938            );
6939            return;
6940        }
6941
6942        let src = load_bytes_to_tensor(
6943            1280,
6944            720,
6945            PixelFormat::Vyuy,
6946            None,
6947            &edgefirst_bench::testdata::read("camera720p.vyuy"),
6948        )
6949        .unwrap();
6950
6951        let dst = TensorDyn::image(
6952            1280,
6953            720,
6954            PixelFormat::Rgba,
6955            DType::U8,
6956            Some(TensorMemory::Dma),
6957        )
6958        .unwrap();
6959        let mut g2d_converter = G2DProcessor::new().unwrap();
6960
6961        let (result, _src, dst) = convert_img(
6962            &mut g2d_converter,
6963            src,
6964            dst,
6965            Rotation::None,
6966            Flip::None,
6967            Crop::no_crop(),
6968        );
6969        match result {
6970            Err(Error::G2D(_)) => {
6971                eprintln!("SKIPPED: test_vyuy_to_rgba_g2d - G2D does not support PixelFormat::Vyuy format");
6972                return;
6973            }
6974            r => r.unwrap(),
6975        }
6976
6977        let target_image = TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None).unwrap();
6978        target_image
6979            .as_u8()
6980            .unwrap()
6981            .map()
6982            .unwrap()
6983            .as_mut_slice()
6984            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
6985
6986        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
6987        // so the matrix delta vs the reference that forced 0.95 has closed;
6988        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
6989        compare_images(&dst, &target_image, 0.98, function!());
6990    }
6991
6992    #[test]
6993    #[cfg(target_os = "linux")]
6994    #[ignore = "G2D does not support VYUY; re-enable when hardware support is added"]
6995    fn test_vyuy_to_rgb_g2d() {
6996        if !is_g2d_available() {
6997            eprintln!("SKIPPED: test_vyuy_to_rgb_g2d - G2D library (libg2d.so.2) not available");
6998            return;
6999        }
7000        if !is_dma_available() {
7001            eprintln!(
7002                "SKIPPED: test_vyuy_to_rgb_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
7003            );
7004            return;
7005        }
7006
7007        let src = load_bytes_to_tensor(
7008            1280,
7009            720,
7010            PixelFormat::Vyuy,
7011            None,
7012            &edgefirst_bench::testdata::read("camera720p.vyuy"),
7013        )
7014        .unwrap();
7015
7016        let g2d_dst = TensorDyn::image(
7017            1280,
7018            720,
7019            PixelFormat::Rgb,
7020            DType::U8,
7021            Some(TensorMemory::Dma),
7022        )
7023        .unwrap();
7024        let mut g2d_converter = G2DProcessor::new().unwrap();
7025
7026        let (result, src, g2d_dst) = convert_img(
7027            &mut g2d_converter,
7028            src,
7029            g2d_dst,
7030            Rotation::None,
7031            Flip::None,
7032            Crop::no_crop(),
7033        );
7034        match result {
7035            Err(Error::G2D(_)) => {
7036                eprintln!(
7037                    "SKIPPED: test_vyuy_to_rgb_g2d - G2D does not support PixelFormat::Vyuy format"
7038                );
7039                return;
7040            }
7041            r => r.unwrap(),
7042        }
7043
7044        let cpu_dst = TensorDyn::image(1280, 720, PixelFormat::Rgb, DType::U8, None).unwrap();
7045        let mut cpu_converter: CPUProcessor = CPUProcessor::new();
7046
7047        let (result, _src, cpu_dst) = convert_img(
7048            &mut cpu_converter,
7049            src,
7050            cpu_dst,
7051            Rotation::None,
7052            Flip::None,
7053            Crop::no_crop(),
7054        );
7055        result.unwrap();
7056
7057        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
7058        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
7059        // the YUV-matrix delta that forced 0.95 has closed; tightened to
7060        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
7061        // structural gap not exercised by these limited-range fixtures.
7062        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
7063    }
7064
7065    #[test]
7066    #[cfg(target_os = "linux")]
7067    #[cfg(feature = "opengl")]
7068    fn test_vyuy_to_rgba_opengl() {
7069        if !is_opengl_available() {
7070            eprintln!("SKIPPED: {} - OpenGL not available", function!());
7071            return;
7072        }
7073        if !is_dma_available() {
7074            eprintln!(
7075                "SKIPPED: {} - DMA memory allocation not available (permission denied or no DMA-BUF support)",
7076                function!()
7077            );
7078            return;
7079        }
7080
7081        let src = load_bytes_to_tensor(
7082            1280,
7083            720,
7084            PixelFormat::Vyuy,
7085            Some(TensorMemory::Dma),
7086            &edgefirst_bench::testdata::read("camera720p.vyuy"),
7087        )
7088        .unwrap();
7089
7090        let dst = TensorDyn::image(
7091            1280,
7092            720,
7093            PixelFormat::Rgba,
7094            DType::U8,
7095            Some(TensorMemory::Dma),
7096        )
7097        .unwrap();
7098        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
7099
7100        let (result, _src, dst) = convert_img(
7101            &mut gl_converter,
7102            src,
7103            dst,
7104            Rotation::None,
7105            Flip::None,
7106            Crop::no_crop(),
7107        );
7108        match result {
7109            Err(Error::NotSupported(_)) => {
7110                eprintln!(
7111                    "SKIPPED: {} - OpenGL does not support PixelFormat::Vyuy DMA format",
7112                    function!()
7113                );
7114                return;
7115            }
7116            r => r.unwrap(),
7117        }
7118
7119        let target_image = TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None).unwrap();
7120        target_image
7121            .as_u8()
7122            .unwrap()
7123            .map()
7124            .unwrap()
7125            .as_mut_slice()
7126            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
7127
7128        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
7129        // so the matrix delta vs the reference that forced 0.95 has closed;
7130        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
7131        compare_images(&dst, &target_image, 0.98, function!());
7132    }
7133
7134    #[test]
7135    fn test_nv12_to_rgba_cpu() {
7136        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
7137        let src = TensorDyn::image(1280, 720, PixelFormat::Nv12, DType::U8, None).unwrap();
7138        src.as_u8().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
7139            .copy_from_slice(&file);
7140
7141        let dst = TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None).unwrap();
7142        let mut cpu_converter = CPUProcessor::new();
7143
7144        let (result, _src, dst) = convert_img(
7145            &mut cpu_converter,
7146            src,
7147            dst,
7148            Rotation::None,
7149            Flip::None,
7150            Crop::no_crop(),
7151        );
7152        result.unwrap();
7153
7154        let target_image = crate::load_image_test_helper(
7155            &edgefirst_bench::testdata::read("zidane.jpg"),
7156            Some(PixelFormat::Rgba),
7157            None,
7158        )
7159        .unwrap();
7160
7161        // Threshold 0.95 (was 0.98): the reference now decodes the colour JPEG
7162        // to native NV12 and then converts to RGBA (was a direct JPEG → RGBA
7163        // decode), so it differs slightly from the RGBA derived from the
7164        // separate `zidane.nv12` fixture.
7165        compare_images(&dst, &target_image, 0.95, function!());
7166    }
7167
7168    #[test]
7169    fn test_nv12_odd_height_to_rgb_cpu() {
7170        // Odd height (even width) — the logical-odd case, e.g. 640×483. The
7171        // contiguous NV12 buffer is `[5 + ceil(5/2), 8]` = `[8, 8]` (5 luma rows
7172        // + 3 chroma rows). A neutral-grey fill (Y=U=V=128, BT.601 full-range)
7173        // must convert to a uniform grey RGB, exercising the odd-height
7174        // chroma-row count and the logical-height derivation in convert.
7175        // (Odd *width* is rounded to an even buffer at allocation, so it is
7176        // covered by the decode integration tests rather than here.)
7177        // CPU-only test: pin to tight host memory (None auto-selects pitch-padded
7178        // DMA on i.MX, which would leave the dst's row padding unconverted and
7179        // break the flat byte scan below).
7180        let mut src =
7181            TensorDyn::image(8, 5, PixelFormat::Nv12, DType::U8, Some(TensorMemory::Mem)).unwrap();
7182        assert_eq!(src.shape(), &[8, 8]);
7183        assert_eq!((src.width(), src.height()), (Some(8), Some(5)));
7184        src.as_u8().unwrap().map().unwrap().as_mut_slice().fill(128);
7185        // Tag BT.601 full-range so Y=128 decodes to grey 128 (the neutral-grey
7186        // identity this test asserts). Without a tag, the colorimetry heuristic
7187        // resolves an SD tensor to BT.601 *limited*, expanding Y=128 → ~131.
7188        src.set_colorimetry(Some(
7189            edgefirst_tensor::Colorimetry::default()
7190                .with_encoding(edgefirst_tensor::ColorEncoding::Bt601)
7191                .with_range(edgefirst_tensor::ColorRange::Full),
7192        ));
7193
7194        let dst =
7195            TensorDyn::image(8, 5, PixelFormat::Rgb, DType::U8, Some(TensorMemory::Mem)).unwrap();
7196        let mut cpu_converter = CPUProcessor::new();
7197        let (result, _src, dst) = convert_img(
7198            &mut cpu_converter,
7199            src,
7200            dst,
7201            Rotation::None,
7202            Flip::None,
7203            Crop::no_crop(),
7204        );
7205        result.unwrap();
7206
7207        assert_eq!((dst.width(), dst.height()), (Some(8), Some(5)));
7208        let map = dst.as_u8().unwrap().map().unwrap();
7209        for (i, &b) in map.as_slice().iter().enumerate() {
7210            assert!(
7211                (b as i16 - 128).abs() <= 2,
7212                "pixel byte {i} = {b}, expected ~128 for neutral-grey NV12"
7213            );
7214        }
7215    }
7216
7217    #[test]
7218    fn test_nv24_to_rgb_cpu() {
7219        // NV24 (4:4:4) at 8×4: contiguous buffer is [4*3, 8] = [12, 8] — Y plane
7220        // (4 rows) + full-res interleaved UV plane (8 rows = 2H, 2W bytes per
7221        // chroma row). Neutral-grey fill (Y=U=V=128) must convert to uniform
7222        // grey RGB, exercising the 2× UV stride and shape[0]/3 height recovery.
7223        // CPU-only test: pin to tight host memory (see test_nv12_odd_height_to_rgb_cpu).
7224        let mut src =
7225            TensorDyn::image(8, 4, PixelFormat::Nv24, DType::U8, Some(TensorMemory::Mem)).unwrap();
7226        assert_eq!(src.shape(), &[12, 8]);
7227        assert_eq!((src.width(), src.height()), (Some(8), Some(4)));
7228        src.as_u8().unwrap().map().unwrap().as_mut_slice().fill(128);
7229        // Tag BT.601 full-range (see test_nv12_odd_height_to_rgb_cpu): without it
7230        // the heuristic picks limited range and Y=128 expands to ~131.
7231        src.set_colorimetry(Some(
7232            edgefirst_tensor::Colorimetry::default()
7233                .with_encoding(edgefirst_tensor::ColorEncoding::Bt601)
7234                .with_range(edgefirst_tensor::ColorRange::Full),
7235        ));
7236
7237        let dst =
7238            TensorDyn::image(8, 4, PixelFormat::Rgb, DType::U8, Some(TensorMemory::Mem)).unwrap();
7239        let mut cpu_converter = CPUProcessor::new();
7240        let (result, _src, dst) = convert_img(
7241            &mut cpu_converter,
7242            src,
7243            dst,
7244            Rotation::None,
7245            Flip::None,
7246            Crop::no_crop(),
7247        );
7248        result.unwrap();
7249
7250        assert_eq!((dst.width(), dst.height()), (Some(8), Some(4)));
7251        let map = dst.as_u8().unwrap().map().unwrap();
7252        for (i, &b) in map.as_slice().iter().enumerate() {
7253            assert!(
7254                (b as i16 - 128).abs() <= 2,
7255                "pixel byte {i} = {b}, expected ~128 for neutral-grey NV24"
7256            );
7257        }
7258    }
7259
7260    #[test]
7261    fn cpu_nv12_to_rgb_respects_tagged_bt2020() {
7262        // A uniform but *saturated* chroma sample (U/V far from neutral) so the
7263        // YUV→RGB matrix — not just the range — drives the result. Decoding the
7264        // same NV12 bytes under BT.601 / BT.709 / BT.2020 must yield three
7265        // distinct RGB triples, proving the CPU path honours the source's tagged
7266        // ColorEncoding instead of a hardcoded matrix. (G2D declines BT.2020 and
7267        // falls through to this CPU path; QA F9.)
7268        // CPU-only test: pin to tight host memory (see test_nv12_odd_height_to_rgb_cpu).
7269        fn decode_tagged(enc: edgefirst_tensor::ColorEncoding) -> [u8; 3] {
7270            let mut src =
7271                TensorDyn::image(8, 4, PixelFormat::Nv12, DType::U8, Some(TensorMemory::Mem))
7272                    .unwrap();
7273            // NV12 8×4: 32-byte Y plane + 16-byte interleaved UV plane (4:2:0).
7274            assert_eq!(src.shape(), &[6, 8]);
7275            {
7276                let mut map = src.as_u8().unwrap().map().unwrap();
7277                let buf = map.as_mut_slice();
7278                buf[..32].fill(120); // Y
7279                for px in buf[32..].chunks_exact_mut(2) {
7280                    px[0] = 180; // U / Cb
7281                    px[1] = 64; // V / Cr
7282                }
7283            }
7284            // Range held constant (Limited) across all three so only the encoding
7285            // matrix varies between runs.
7286            src.set_colorimetry(Some(
7287                edgefirst_tensor::Colorimetry::default()
7288                    .with_encoding(enc)
7289                    .with_range(edgefirst_tensor::ColorRange::Limited),
7290            ));
7291            let dst = TensorDyn::image(8, 4, PixelFormat::Rgb, DType::U8, Some(TensorMemory::Mem))
7292                .unwrap();
7293            let mut cpu = CPUProcessor::new();
7294            let (result, _src, dst) = convert_img(
7295                &mut cpu,
7296                src,
7297                dst,
7298                Rotation::None,
7299                Flip::None,
7300                Crop::no_crop(),
7301            );
7302            result.unwrap();
7303            let map = dst.as_u8().unwrap().map().unwrap();
7304            let s = map.as_slice();
7305            [s[0], s[1], s[2]]
7306        }
7307
7308        let bt601 = decode_tagged(edgefirst_tensor::ColorEncoding::Bt601);
7309        let bt709 = decode_tagged(edgefirst_tensor::ColorEncoding::Bt709);
7310        let bt2020 = decode_tagged(edgefirst_tensor::ColorEncoding::Bt2020);
7311
7312        assert_ne!(
7313            bt2020, bt601,
7314            "BT.2020 must decode differently from BT.601 ({bt2020:?} vs {bt601:?})"
7315        );
7316        assert_ne!(
7317            bt2020, bt709,
7318            "BT.2020 must decode differently from BT.709 ({bt2020:?} vs {bt709:?})"
7319        );
7320        assert_ne!(
7321            bt601, bt709,
7322            "BT.601 must decode differently from BT.709 ({bt601:?} vs {bt709:?})"
7323        );
7324    }
7325
7326    #[test]
7327    fn test_nv12_to_rgb_cpu() {
7328        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
7329        let src = TensorDyn::image(1280, 720, PixelFormat::Nv12, DType::U8, None).unwrap();
7330        src.as_u8().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
7331            .copy_from_slice(&file);
7332
7333        let dst = TensorDyn::image(1280, 720, PixelFormat::Rgb, DType::U8, None).unwrap();
7334        let mut cpu_converter = CPUProcessor::new();
7335
7336        let (result, _src, dst) = convert_img(
7337            &mut cpu_converter,
7338            src,
7339            dst,
7340            Rotation::None,
7341            Flip::None,
7342            Crop::no_crop(),
7343        );
7344        result.unwrap();
7345
7346        let target_image = crate::load_image_test_helper(
7347            &edgefirst_bench::testdata::read("zidane.jpg"),
7348            Some(PixelFormat::Rgb),
7349            None,
7350        )
7351        .unwrap();
7352
7353        // Threshold 0.95 (was 0.98): the reference now decodes the colour JPEG
7354        // to native NV12 and then converts to RGB (was a direct JPEG → RGB
7355        // decode), so it differs slightly from the RGB derived from the
7356        // separate `zidane.nv12` fixture.
7357        compare_images(&dst, &target_image, 0.95, function!());
7358    }
7359
7360    #[test]
7361    fn test_nv12_to_grey_cpu() {
7362        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
7363        let src = TensorDyn::image(1280, 720, PixelFormat::Nv12, DType::U8, None).unwrap();
7364        src.as_u8().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
7365            .copy_from_slice(&file);
7366
7367        let dst = TensorDyn::image(1280, 720, PixelFormat::Grey, DType::U8, None).unwrap();
7368        let mut cpu_converter = CPUProcessor::new();
7369
7370        let (result, _src, dst) = convert_img(
7371            &mut cpu_converter,
7372            src,
7373            dst,
7374            Rotation::None,
7375            Flip::None,
7376            Crop::no_crop(),
7377        );
7378        result.unwrap();
7379
7380        let target_image = crate::load_image_test_helper(
7381            &edgefirst_bench::testdata::read("zidane.jpg"),
7382            Some(PixelFormat::Grey),
7383            None,
7384        )
7385        .unwrap();
7386
7387        // Threshold 0.95 (was 0.98): the reference grey frame now comes from
7388        // the colour JPEG decoded to native NV12 and then converted to GREY
7389        // (was a direct JPEG → GREY decode), so it differs slightly from the
7390        // grey derived from the `zidane.nv12` fixture.
7391        compare_images(&dst, &target_image, 0.95, function!());
7392    }
7393
7394    #[test]
7395    fn test_nv12_to_yuyv_cpu() {
7396        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
7397        let src = TensorDyn::image(1280, 720, PixelFormat::Nv12, DType::U8, None).unwrap();
7398        src.as_u8().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
7399            .copy_from_slice(&file);
7400
7401        let dst = TensorDyn::image(1280, 720, PixelFormat::Yuyv, DType::U8, None).unwrap();
7402        let mut cpu_converter = CPUProcessor::new();
7403
7404        let (result, _src, dst) = convert_img(
7405            &mut cpu_converter,
7406            src,
7407            dst,
7408            Rotation::None,
7409            Flip::None,
7410            Crop::no_crop(),
7411        );
7412        result.unwrap();
7413
7414        let target_image = crate::load_image_test_helper(
7415            &edgefirst_bench::testdata::read("zidane.jpg"),
7416            Some(PixelFormat::Rgb),
7417            None,
7418        )
7419        .unwrap();
7420
7421        // Threshold 0.95 (was 0.98): the reference now decodes the colour JPEG
7422        // to native NV12 and then converts to RGB (was a direct JPEG → RGB
7423        // decode), so it differs slightly from the YUYV-sourced frame derived
7424        // from the separate `zidane.nv12` fixture.
7425        compare_images_convert_to_rgb(&dst, &target_image, 0.95, function!());
7426    }
7427
7428    #[test]
7429    fn test_cpu_resize_nv16() {
7430        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
7431        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
7432
7433        let cpu_nv16_dst = TensorDyn::image(640, 640, PixelFormat::Nv16, DType::U8, None).unwrap();
7434        let cpu_rgb_dst = TensorDyn::image(640, 640, PixelFormat::Rgb, DType::U8, None).unwrap();
7435        let mut cpu_converter = CPUProcessor::new();
7436        let crop = Crop::letterbox([255, 128, 0, 255]);
7437
7438        let (result, src, cpu_nv16_dst) = convert_img(
7439            &mut cpu_converter,
7440            src,
7441            cpu_nv16_dst,
7442            Rotation::None,
7443            Flip::None,
7444            crop,
7445        );
7446        result.unwrap();
7447
7448        let (result, _src, cpu_rgb_dst) = convert_img(
7449            &mut cpu_converter,
7450            src,
7451            cpu_rgb_dst,
7452            Rotation::None,
7453            Flip::None,
7454            crop,
7455        );
7456        result.unwrap();
7457        compare_images_convert_to_rgb(&cpu_nv16_dst, &cpu_rgb_dst, 0.99, function!());
7458    }
7459
7460    fn load_bytes_to_tensor(
7461        width: usize,
7462        height: usize,
7463        format: PixelFormat,
7464        memory: Option<TensorMemory>,
7465        bytes: &[u8],
7466    ) -> Result<TensorDyn, Error> {
7467        let src = TensorDyn::image(width, height, format, DType::U8, memory)?;
7468        src.as_u8()
7469            .unwrap()
7470            .map()?
7471            .as_mut_slice()
7472            .copy_from_slice(bytes);
7473        Ok(src)
7474    }
7475
7476    // DEDUP: this function is also defined verbatim in
7477    // `crates/image/src/gl/tests.rs` (inside `mod gl_tests`). Both copies
7478    // must be kept in sync. Cross-module sharing would require either a
7479    // `pub(crate)` test-helper module (which pollutes the non-test API) or a
7480    // separate test-utils crate — both are disproportionate for a single
7481    // helper. If the implementation ever diverges, extract to a shared
7482    // `test_helpers` module in this crate.
7483    fn compare_images(img1: &TensorDyn, img2: &TensorDyn, threshold: f64, name: &str) {
7484        assert_eq!(img1.height(), img2.height(), "Heights differ");
7485        assert_eq!(img1.width(), img2.width(), "Widths differ");
7486        assert_eq!(
7487            img1.format().unwrap(),
7488            img2.format().unwrap(),
7489            "PixelFormat differ"
7490        );
7491        assert!(
7492            matches!(
7493                img1.format().unwrap(),
7494                PixelFormat::Rgb | PixelFormat::Rgba | PixelFormat::Grey | PixelFormat::PlanarRgb
7495            ),
7496            "format must be Rgb or Rgba for comparison"
7497        );
7498
7499        let image1 = match img1.format().unwrap() {
7500            PixelFormat::Rgb => image::RgbImage::from_vec(
7501                img1.width().unwrap() as u32,
7502                img1.height().unwrap() as u32,
7503                img1.as_u8().unwrap().map().unwrap().to_vec(),
7504            )
7505            .unwrap(),
7506            PixelFormat::Rgba => image::RgbaImage::from_vec(
7507                img1.width().unwrap() as u32,
7508                img1.height().unwrap() as u32,
7509                img1.as_u8().unwrap().map().unwrap().to_vec(),
7510            )
7511            .unwrap()
7512            .convert(),
7513            PixelFormat::Grey => image::GrayImage::from_vec(
7514                img1.width().unwrap() as u32,
7515                img1.height().unwrap() as u32,
7516                img1.as_u8().unwrap().map().unwrap().to_vec(),
7517            )
7518            .unwrap()
7519            .convert(),
7520            PixelFormat::PlanarRgb => image::GrayImage::from_vec(
7521                img1.width().unwrap() as u32,
7522                (img1.height().unwrap() * 3) as u32,
7523                img1.as_u8().unwrap().map().unwrap().to_vec(),
7524            )
7525            .unwrap()
7526            .convert(),
7527            _ => return,
7528        };
7529
7530        let image2 = match img2.format().unwrap() {
7531            PixelFormat::Rgb => image::RgbImage::from_vec(
7532                img2.width().unwrap() as u32,
7533                img2.height().unwrap() as u32,
7534                img2.as_u8().unwrap().map().unwrap().to_vec(),
7535            )
7536            .unwrap(),
7537            PixelFormat::Rgba => image::RgbaImage::from_vec(
7538                img2.width().unwrap() as u32,
7539                img2.height().unwrap() as u32,
7540                img2.as_u8().unwrap().map().unwrap().to_vec(),
7541            )
7542            .unwrap()
7543            .convert(),
7544            PixelFormat::Grey => image::GrayImage::from_vec(
7545                img2.width().unwrap() as u32,
7546                img2.height().unwrap() as u32,
7547                img2.as_u8().unwrap().map().unwrap().to_vec(),
7548            )
7549            .unwrap()
7550            .convert(),
7551            PixelFormat::PlanarRgb => image::GrayImage::from_vec(
7552                img2.width().unwrap() as u32,
7553                (img2.height().unwrap() * 3) as u32,
7554                img2.as_u8().unwrap().map().unwrap().to_vec(),
7555            )
7556            .unwrap()
7557            .convert(),
7558            _ => return,
7559        };
7560
7561        let similarity = image_compare::rgb_similarity_structure(
7562            &image_compare::Algorithm::RootMeanSquared,
7563            &image1,
7564            &image2,
7565        )
7566        .expect("Image Comparison failed");
7567        if similarity.score < threshold {
7568            // image1.save(format!("{name}_1.png"));
7569            // image2.save(format!("{name}_2.png"));
7570            similarity
7571                .image
7572                .to_color_map()
7573                .save(format!("{name}.png"))
7574                .unwrap();
7575            panic!(
7576                "{name}: converted image and target image have similarity score too low: {} < {}",
7577                similarity.score, threshold
7578            )
7579        }
7580    }
7581
7582    fn compare_images_convert_to_rgb(
7583        img1: &TensorDyn,
7584        img2: &TensorDyn,
7585        threshold: f64,
7586        name: &str,
7587    ) {
7588        assert_eq!(img1.height(), img2.height(), "Heights differ");
7589        assert_eq!(img1.width(), img2.width(), "Widths differ");
7590
7591        let mut img_rgb1 = TensorDyn::image(
7592            img1.width().unwrap(),
7593            img1.height().unwrap(),
7594            PixelFormat::Rgb,
7595            DType::U8,
7596            Some(TensorMemory::Mem),
7597        )
7598        .unwrap();
7599        let mut img_rgb2 = TensorDyn::image(
7600            img1.width().unwrap(),
7601            img1.height().unwrap(),
7602            PixelFormat::Rgb,
7603            DType::U8,
7604            Some(TensorMemory::Mem),
7605        )
7606        .unwrap();
7607        let mut __cv = CPUProcessor::default();
7608        let r1 = __cv.convert(
7609            img1,
7610            &mut img_rgb1,
7611            crate::Rotation::None,
7612            crate::Flip::None,
7613            crate::Crop::default(),
7614        );
7615        let r2 = __cv.convert(
7616            img2,
7617            &mut img_rgb2,
7618            crate::Rotation::None,
7619            crate::Flip::None,
7620            crate::Crop::default(),
7621        );
7622        if r1.is_err() || r2.is_err() {
7623            // Fallback: compare raw bytes as greyscale strip
7624            let w = img1.width().unwrap() as u32;
7625            let data1 = img1.as_u8().unwrap().map().unwrap().to_vec();
7626            let data2 = img2.as_u8().unwrap().map().unwrap().to_vec();
7627            let h1 = (data1.len() as u32) / w;
7628            let h2 = (data2.len() as u32) / w;
7629            let g1 = image::GrayImage::from_vec(w, h1, data1).unwrap();
7630            let g2 = image::GrayImage::from_vec(w, h2, data2).unwrap();
7631            let similarity = image_compare::gray_similarity_structure(
7632                &image_compare::Algorithm::RootMeanSquared,
7633                &g1,
7634                &g2,
7635            )
7636            .expect("Image Comparison failed");
7637            if similarity.score < threshold {
7638                panic!(
7639                    "{name}: converted image and target image have similarity score too low: {} < {}",
7640                    similarity.score, threshold
7641                )
7642            }
7643            return;
7644        }
7645
7646        let image1 = image::RgbImage::from_vec(
7647            img_rgb1.width().unwrap() as u32,
7648            img_rgb1.height().unwrap() as u32,
7649            img_rgb1.as_u8().unwrap().map().unwrap().to_vec(),
7650        )
7651        .unwrap();
7652
7653        let image2 = image::RgbImage::from_vec(
7654            img_rgb2.width().unwrap() as u32,
7655            img_rgb2.height().unwrap() as u32,
7656            img_rgb2.as_u8().unwrap().map().unwrap().to_vec(),
7657        )
7658        .unwrap();
7659
7660        let similarity = image_compare::rgb_similarity_structure(
7661            &image_compare::Algorithm::RootMeanSquared,
7662            &image1,
7663            &image2,
7664        )
7665        .expect("Image Comparison failed");
7666        if similarity.score < threshold {
7667            // image1.save(format!("{name}_1.png"));
7668            // image2.save(format!("{name}_2.png"));
7669            similarity
7670                .image
7671                .to_color_map()
7672                .save(format!("{name}.png"))
7673                .unwrap();
7674            panic!(
7675                "{name}: converted image and target image have similarity score too low: {} < {}",
7676                similarity.score, threshold
7677            )
7678        }
7679    }
7680
7681    // =========================================================================
7682    // PixelFormat::Nv12 Format Tests
7683    // =========================================================================
7684
7685    #[test]
7686    fn test_nv12_image_creation() {
7687        let width = 640;
7688        let height = 480;
7689        let img = TensorDyn::image(width, height, PixelFormat::Nv12, DType::U8, None).unwrap();
7690
7691        assert_eq!(img.width(), Some(width));
7692        assert_eq!(img.height(), Some(height));
7693        assert_eq!(img.format().unwrap(), PixelFormat::Nv12);
7694        // PixelFormat::Nv12 uses shape [H*3/2, W] to store Y plane + UV plane
7695        assert_eq!(img.as_u8().unwrap().shape(), &[height * 3 / 2, width]);
7696    }
7697
7698    #[test]
7699    fn test_nv12_channels() {
7700        let img = TensorDyn::image(640, 480, PixelFormat::Nv12, DType::U8, None).unwrap();
7701        // PixelFormat::Nv12.channels() returns 1 (luma plane)
7702        assert_eq!(img.format().unwrap().channels(), 1);
7703    }
7704
7705    // =========================================================================
7706    // Tensor Format Metadata Tests
7707    // =========================================================================
7708
7709    #[test]
7710    fn test_tensor_set_format_planar() {
7711        let mut tensor = Tensor::<u8>::new(&[3, 480, 640], None, None).unwrap();
7712        tensor.set_format(PixelFormat::PlanarRgb).unwrap();
7713        assert_eq!(tensor.format(), Some(PixelFormat::PlanarRgb));
7714        assert_eq!(tensor.width(), Some(640));
7715        assert_eq!(tensor.height(), Some(480));
7716    }
7717
7718    #[test]
7719    fn test_tensor_set_format_interleaved() {
7720        let mut tensor = Tensor::<u8>::new(&[480, 640, 4], None, None).unwrap();
7721        tensor.set_format(PixelFormat::Rgba).unwrap();
7722        assert_eq!(tensor.format(), Some(PixelFormat::Rgba));
7723        assert_eq!(tensor.width(), Some(640));
7724        assert_eq!(tensor.height(), Some(480));
7725    }
7726
7727    #[test]
7728    fn test_tensordyn_image_rgb() {
7729        let img = TensorDyn::image(640, 480, PixelFormat::Rgb, DType::U8, None).unwrap();
7730        assert_eq!(img.width(), Some(640));
7731        assert_eq!(img.height(), Some(480));
7732        assert_eq!(img.format(), Some(PixelFormat::Rgb));
7733    }
7734
7735    #[test]
7736    fn test_tensordyn_image_planar_rgb() {
7737        let img = TensorDyn::image(640, 480, PixelFormat::PlanarRgb, DType::U8, None).unwrap();
7738        assert_eq!(img.width(), Some(640));
7739        assert_eq!(img.height(), Some(480));
7740        assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
7741    }
7742
7743    #[test]
7744    fn test_rgb_int8_format() {
7745        // Int8 variant: same PixelFormat::Rgb but with DType::I8
7746        let img = TensorDyn::image(
7747            1280,
7748            720,
7749            PixelFormat::Rgb,
7750            DType::I8,
7751            Some(TensorMemory::Mem),
7752        )
7753        .unwrap();
7754        assert_eq!(img.width(), Some(1280));
7755        assert_eq!(img.height(), Some(720));
7756        assert_eq!(img.format(), Some(PixelFormat::Rgb));
7757        assert_eq!(img.dtype(), DType::I8);
7758    }
7759
7760    #[test]
7761    fn test_planar_rgb_int8_format() {
7762        let img = TensorDyn::image(
7763            1280,
7764            720,
7765            PixelFormat::PlanarRgb,
7766            DType::I8,
7767            Some(TensorMemory::Mem),
7768        )
7769        .unwrap();
7770        assert_eq!(img.width(), Some(1280));
7771        assert_eq!(img.height(), Some(720));
7772        assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
7773        assert_eq!(img.dtype(), DType::I8);
7774    }
7775
7776    #[test]
7777    fn test_rgb_from_tensor() {
7778        let mut tensor = Tensor::<u8>::new(&[720, 1280, 3], None, None).unwrap();
7779        tensor.set_format(PixelFormat::Rgb).unwrap();
7780        let img = TensorDyn::from(tensor);
7781        assert_eq!(img.width(), Some(1280));
7782        assert_eq!(img.height(), Some(720));
7783        assert_eq!(img.format(), Some(PixelFormat::Rgb));
7784    }
7785
7786    #[test]
7787    fn test_planar_rgb_from_tensor() {
7788        let mut tensor = Tensor::<u8>::new(&[3, 720, 1280], None, None).unwrap();
7789        tensor.set_format(PixelFormat::PlanarRgb).unwrap();
7790        let img = TensorDyn::from(tensor);
7791        assert_eq!(img.width(), Some(1280));
7792        assert_eq!(img.height(), Some(720));
7793        assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
7794    }
7795
7796    #[test]
7797    fn test_dtype_determines_int8() {
7798        // DType::I8 indicates int8 data
7799        let u8_img = TensorDyn::image(64, 64, PixelFormat::Rgb, DType::U8, None).unwrap();
7800        let i8_img = TensorDyn::image(64, 64, PixelFormat::Rgb, DType::I8, None).unwrap();
7801        assert_eq!(u8_img.dtype(), DType::U8);
7802        assert_eq!(i8_img.dtype(), DType::I8);
7803    }
7804
7805    #[test]
7806    fn test_pixel_layout_packed_vs_planar() {
7807        // Packed vs planar layout classification
7808        assert_eq!(PixelFormat::Rgb.layout(), PixelLayout::Packed);
7809        assert_eq!(PixelFormat::Rgba.layout(), PixelLayout::Packed);
7810        assert_eq!(PixelFormat::PlanarRgb.layout(), PixelLayout::Planar);
7811        assert_eq!(PixelFormat::Nv12.layout(), PixelLayout::SemiPlanar);
7812    }
7813
7814    /// Integration test that exercises the PBO-to-PBO convert path.
7815    /// Uses ImageProcessor::create_image() to allocate PBO-backed tensors,
7816    /// then converts between them. Skipped when GL is unavailable or the
7817    /// backend is not PBO (e.g. DMA-buf systems).
7818    #[cfg(target_os = "linux")]
7819    #[cfg(feature = "opengl")]
7820    #[test]
7821    fn test_convert_pbo_to_pbo() {
7822        let mut converter = ImageProcessor::new().unwrap();
7823
7824        // Skip if GL is not available or backend is not PBO
7825        let is_pbo = converter
7826            .opengl
7827            .as_ref()
7828            .is_some_and(|gl| gl.transfer_backend() == opengl_headless::TransferBackend::Pbo);
7829        if !is_pbo {
7830            eprintln!("Skipping test_convert_pbo_to_pbo: backend is not PBO");
7831            return;
7832        }
7833
7834        let src_w = 640;
7835        let src_h = 480;
7836        let dst_w = 320;
7837        let dst_h = 240;
7838
7839        // Create PBO-backed source image
7840        let pbo_src = converter
7841            .create_image(src_w, src_h, PixelFormat::Rgba, DType::U8, None)
7842            .unwrap();
7843        assert_eq!(
7844            pbo_src.as_u8().unwrap().memory(),
7845            TensorMemory::Pbo,
7846            "create_image should produce a PBO tensor"
7847        );
7848
7849        // Fill source PBO with test pattern: load JPEG then convert Mem→PBO
7850        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
7851        let jpeg_src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
7852
7853        // Resize JPEG into a Mem temp of the right size, then copy into PBO
7854        let mem_src = TensorDyn::image(
7855            src_w,
7856            src_h,
7857            PixelFormat::Rgba,
7858            DType::U8,
7859            Some(TensorMemory::Mem),
7860        )
7861        .unwrap();
7862        let (result, _jpeg_src, mem_src) = convert_img(
7863            &mut CPUProcessor::new(),
7864            jpeg_src,
7865            mem_src,
7866            Rotation::None,
7867            Flip::None,
7868            Crop::no_crop(),
7869        );
7870        result.unwrap();
7871
7872        // Copy pixel data into the PBO source by mapping it
7873        {
7874            let src_data = mem_src.as_u8().unwrap().map().unwrap();
7875            let mut pbo_map = pbo_src.as_u8().unwrap().map().unwrap();
7876            pbo_map.copy_from_slice(&src_data);
7877        }
7878
7879        // Create PBO-backed destination image
7880        let pbo_dst = converter
7881            .create_image(dst_w, dst_h, PixelFormat::Rgba, DType::U8, None)
7882            .unwrap();
7883        assert_eq!(pbo_dst.as_u8().unwrap().memory(), TensorMemory::Pbo);
7884
7885        // Convert PBO→PBO (this exercises convert_pbo_to_pbo)
7886        let mut pbo_dst = pbo_dst;
7887        let result = converter.convert(
7888            &pbo_src,
7889            &mut pbo_dst,
7890            Rotation::None,
7891            Flip::None,
7892            Crop::no_crop(),
7893        );
7894        result.unwrap();
7895
7896        // Verify: compare with CPU-only conversion of the same input
7897        let cpu_dst = TensorDyn::image(
7898            dst_w,
7899            dst_h,
7900            PixelFormat::Rgba,
7901            DType::U8,
7902            Some(TensorMemory::Mem),
7903        )
7904        .unwrap();
7905        let (result, _mem_src, cpu_dst) = convert_img(
7906            &mut CPUProcessor::new(),
7907            mem_src,
7908            cpu_dst,
7909            Rotation::None,
7910            Flip::None,
7911            Crop::no_crop(),
7912        );
7913        result.unwrap();
7914
7915        let pbo_dst_img = {
7916            let mut __t = pbo_dst.into_u8().unwrap();
7917            __t.set_format(PixelFormat::Rgba).unwrap();
7918            TensorDyn::from(__t)
7919        };
7920        compare_images(&pbo_dst_img, &cpu_dst, 0.95, function!());
7921        log::info!("test_convert_pbo_to_pbo: PASS — PBO-to-PBO convert matches CPU reference");
7922    }
7923
7924    #[test]
7925    fn test_image_bgra() {
7926        let img = TensorDyn::image(
7927            640,
7928            480,
7929            PixelFormat::Bgra,
7930            DType::U8,
7931            Some(edgefirst_tensor::TensorMemory::Mem),
7932        )
7933        .unwrap();
7934        assert_eq!(img.width(), Some(640));
7935        assert_eq!(img.height(), Some(480));
7936        assert_eq!(img.format().unwrap().channels(), 4);
7937        assert_eq!(img.format().unwrap(), PixelFormat::Bgra);
7938    }
7939
7940    // ========================================================================
7941    // Tests for EDGEFIRST_FORCE_BACKEND env var
7942    // ========================================================================
7943
7944    #[test]
7945    fn test_force_backend_cpu() {
7946        let _lock = acquire_env_lock();
7947        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
7948        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
7949        let converter = ImageProcessor::new().unwrap();
7950        assert!(converter.cpu.is_some());
7951        assert_eq!(converter.forced_backend, Some(ForcedBackend::Cpu));
7952    }
7953
7954    #[test]
7955    fn test_force_backend_invalid() {
7956        let _lock = acquire_env_lock();
7957        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
7958        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "invalid") };
7959        let result = ImageProcessor::new();
7960        assert!(
7961            matches!(&result, Err(Error::ForcedBackendUnavailable(s)) if s.contains("unknown")),
7962            "invalid backend value should return ForcedBackendUnavailable error: {result:?}"
7963        );
7964    }
7965
7966    #[test]
7967    fn test_force_backend_unset() {
7968        let _lock = acquire_env_lock();
7969        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
7970        unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") };
7971        let converter = ImageProcessor::new().unwrap();
7972        assert!(converter.forced_backend.is_none());
7973    }
7974
7975    // ========================================================================
7976    // Tests for hybrid mask path error handling
7977    // ========================================================================
7978
7979    #[test]
7980    fn test_draw_proto_masks_no_cpu_returns_error() {
7981        // Serialize against all other env-var-mutating tests.
7982        let _lock = acquire_env_lock();
7983        let _guard = EnvGuard::snapshot(&[
7984            "EDGEFIRST_FORCE_BACKEND",
7985            "EDGEFIRST_DISABLE_GL",
7986            "EDGEFIRST_DISABLE_G2D",
7987            "EDGEFIRST_DISABLE_CPU",
7988        ]);
7989
7990        // Disable all backends so cpu.is_none() after construction.
7991        unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
7992        unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
7993        unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
7994
7995        let mut converter = ImageProcessor::new().unwrap();
7996        assert!(converter.cpu.is_none(), "CPU should be disabled");
7997
7998        let dst = TensorDyn::image(
7999            640,
8000            480,
8001            PixelFormat::Rgba,
8002            DType::U8,
8003            Some(TensorMemory::Mem),
8004        )
8005        .unwrap();
8006        let mut dst_dyn = dst;
8007        let det = [DetectBox {
8008            bbox: edgefirst_decoder::BoundingBox {
8009                xmin: 0.1,
8010                ymin: 0.1,
8011                xmax: 0.5,
8012                ymax: 0.5,
8013            },
8014            score: 0.9,
8015            label: 0,
8016        }];
8017        let proto_data = {
8018            use edgefirst_tensor::{Tensor, TensorDyn};
8019            let coeff_t = Tensor::<f32>::from_slice(&[0.5_f32; 4], &[1, 4]).unwrap();
8020            let protos_t =
8021                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
8022            ProtoData {
8023                mask_coefficients: TensorDyn::F32(coeff_t),
8024                protos: TensorDyn::F32(protos_t),
8025                layout: ProtoLayout::Nhwc,
8026            }
8027        };
8028        let result =
8029            converter.draw_proto_masks(&mut dst_dyn, &det, &proto_data, Default::default());
8030        assert!(
8031            matches!(&result, Err(Error::Internal(s)) if s.contains("CPU backend")),
8032            "draw_proto_masks without CPU should return Internal error: {result:?}"
8033        );
8034    }
8035
8036    #[test]
8037    fn test_draw_proto_masks_cpu_fallback_works() {
8038        // Force CPU-only backend to ensure the CPU fallback path executes.
8039        // Serialized under ENV_MUTEX so we don't race with disable-var tests.
8040        let _lock = acquire_env_lock();
8041        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
8042        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
8043        let mut converter = ImageProcessor::new().unwrap();
8044        assert!(converter.cpu.is_some());
8045
8046        let dst = TensorDyn::image(
8047            64,
8048            64,
8049            PixelFormat::Rgba,
8050            DType::U8,
8051            Some(TensorMemory::Mem),
8052        )
8053        .unwrap();
8054        let mut dst_dyn = dst;
8055        let det = [DetectBox {
8056            bbox: edgefirst_decoder::BoundingBox {
8057                xmin: 0.1,
8058                ymin: 0.1,
8059                xmax: 0.5,
8060                ymax: 0.5,
8061            },
8062            score: 0.9,
8063            label: 0,
8064        }];
8065        let proto_data = {
8066            use edgefirst_tensor::{Tensor, TensorDyn};
8067            let coeff_t = Tensor::<f32>::from_slice(&[0.5_f32; 4], &[1, 4]).unwrap();
8068            let protos_t =
8069                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
8070            ProtoData {
8071                mask_coefficients: TensorDyn::F32(coeff_t),
8072                protos: TensorDyn::F32(protos_t),
8073                layout: ProtoLayout::Nhwc,
8074            }
8075        };
8076        let result =
8077            converter.draw_proto_masks(&mut dst_dyn, &det, &proto_data, Default::default());
8078        assert!(result.is_ok(), "CPU fallback path should work: {result:?}");
8079    }
8080
8081    // ============================================================
8082    // draw_decoded_masks / draw_proto_masks — 4-scenario pixel-
8083    // verified tests. Exercises each backend against the full
8084    // output-contract matrix:
8085    //
8086    //   | detections | background | expected dst             |
8087    //   |------------|------------|--------------------------|
8088    //   | empty      | none       | fully cleared (0x00)     |
8089    //   | empty      | set        | fully equal to bg        |
8090    //   | set        | none       | cleared outside box +    |
8091    //   |            |            | mask-coloured inside     |
8092    //   | set        | set        | bg outside box + mask    |
8093    //   |            |            | blended inside           |
8094    //
8095    // Every test pre-fills dst with a non-zero "dirty" pattern so
8096    // that any silent `return Ok(())` leaks the pattern into the
8097    // asserted output and fails loudly.
8098    // ============================================================
8099
8100    // =========================================================================
8101    // Env-var serialisation helpers
8102    //
8103    // ALL tests that mutate any EDGEFIRST_* backend env var must hold
8104    // ENV_MUTEX for their full duration.  This single mutex serialises
8105    // test_disable_env_var, test_draw_proto_masks_no_cpu_returns_error,
8106    // test_force_backend_*, with_force_backend, and with_env — preventing
8107    // any two of them from racing in a parallel `cargo test` run.
8108    // =========================================================================
8109
8110    /// Acquire the process-wide env-var mutex.  Returns a guard that must be
8111    /// kept alive for the entire duration of the test body.
8112    fn acquire_env_lock() -> std::sync::MutexGuard<'static, ()> {
8113        use std::sync::{Mutex, OnceLock};
8114        static ENV_MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
8115        ENV_MUTEX
8116            .get_or_init(|| Mutex::new(()))
8117            .lock()
8118            .unwrap_or_else(|e| e.into_inner())
8119    }
8120
8121    /// RAII guard that snapshots a set of env vars on construction and
8122    /// restores them on `Drop`, even if the test panics.
8123    struct EnvGuard {
8124        vars: Vec<(&'static str, Option<String>)>,
8125    }
8126
8127    impl EnvGuard {
8128        /// Snapshot the current values of `names`.  Call this while holding
8129        /// the env lock (the lock is not taken here — that is the caller's
8130        /// responsibility so the lock scope can be wider than the guard).
8131        fn snapshot(names: &[&'static str]) -> Self {
8132            Self {
8133                vars: names.iter().map(|&k| (k, std::env::var(k).ok())).collect(),
8134            }
8135        }
8136    }
8137
8138    impl Drop for EnvGuard {
8139        fn drop(&mut self) {
8140            for (k, v) in &self.vars {
8141                match v {
8142                    Some(s) => unsafe { std::env::set_var(k, s) },
8143                    None => unsafe { std::env::remove_var(k) },
8144                }
8145            }
8146        }
8147    }
8148
8149    /// Run `body` with `EDGEFIRST_FORCE_BACKEND` temporarily set (or
8150    /// removed), restoring the prior value afterward. Tests are env-
8151    /// serialized via the process-wide `ENV_MUTEX`.
8152    fn with_force_backend<R>(value: Option<&str>, body: impl FnOnce() -> R) -> R {
8153        let _lock = acquire_env_lock();
8154        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
8155        match value {
8156            Some(v) => unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", v) },
8157            None => unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") },
8158        }
8159        body()
8160    }
8161
8162    /// Allocate an RGBA image tensor and pre-fill every byte with a
8163    /// distinctive non-zero pattern. Any test that relies on the old
8164    /// "dst is already cleared" assumption will see this pattern leak
8165    /// through to the output and fail.
8166    fn make_dirty_dst(w: usize, h: usize, mem: Option<TensorMemory>) -> TensorDyn {
8167        let dst = TensorDyn::image(w, h, PixelFormat::Rgba, DType::U8, mem).unwrap();
8168        {
8169            use edgefirst_tensor::TensorMapTrait;
8170            let u8t = dst.as_u8().unwrap();
8171            let mut map = u8t.map().unwrap();
8172            for (i, b) in map.as_mut_slice().iter_mut().enumerate() {
8173                *b = 0xA0u8.wrapping_add((i as u8) & 0x3F);
8174            }
8175        }
8176        dst
8177    }
8178
8179    /// Allocate an RGBA background filled with a constant colour.
8180    fn make_bg(w: usize, h: usize, mem: Option<TensorMemory>, rgba: [u8; 4]) -> TensorDyn {
8181        let bg = TensorDyn::image(w, h, PixelFormat::Rgba, DType::U8, mem).unwrap();
8182        {
8183            use edgefirst_tensor::TensorMapTrait;
8184            let u8t = bg.as_u8().unwrap();
8185            let mut map = u8t.map().unwrap();
8186            for chunk in map.as_mut_slice().chunks_exact_mut(4) {
8187                chunk.copy_from_slice(&rgba);
8188            }
8189        }
8190        bg
8191    }
8192
8193    fn pixel_at(dst: &TensorDyn, x: usize, y: usize) -> [u8; 4] {
8194        use edgefirst_tensor::TensorMapTrait;
8195        let w = dst.width().unwrap();
8196        let off = (y * w + x) * 4;
8197        let u8t = dst.as_u8().unwrap();
8198        let map = u8t.map().unwrap();
8199        let s = map.as_slice();
8200        [s[off], s[off + 1], s[off + 2], s[off + 3]]
8201    }
8202
8203    fn assert_every_pixel_eq(dst: &TensorDyn, expected: [u8; 4], case: &str) {
8204        use edgefirst_tensor::TensorMapTrait;
8205        let u8t = dst.as_u8().unwrap();
8206        let map = u8t.map().unwrap();
8207        for (i, chunk) in map.as_slice().chunks_exact(4).enumerate() {
8208            assert_eq!(
8209                chunk, &expected,
8210                "{case}: pixel idx {i} = {chunk:?}, expected {expected:?}"
8211            );
8212        }
8213    }
8214
8215    /// Scenario 1: empty detections, empty segmentation, no background
8216    /// → dst must be fully cleared to 0x00000000.
8217    fn scenario_empty_no_bg(processor: &mut ImageProcessor, case: &str) {
8218        let mut dst = make_dirty_dst(64, 64, None);
8219        processor
8220            .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::default())
8221            .unwrap_or_else(|e| panic!("{case}/decoded_masks empty+no-bg failed: {e:?}"));
8222        assert_every_pixel_eq(&dst, [0, 0, 0, 0], &format!("{case}/decoded"));
8223
8224        let mut dst = make_dirty_dst(64, 64, None);
8225        let proto = {
8226            use edgefirst_tensor::{Tensor, TensorDyn};
8227            // Placeholder (no detections); shape [1, 4] to keep the tensor well-formed.
8228            let coeff_t = Tensor::<f32>::from_slice(&[0.0_f32; 4], &[1, 4]).unwrap();
8229            let protos_t =
8230                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
8231            ProtoData {
8232                mask_coefficients: TensorDyn::F32(coeff_t),
8233                protos: TensorDyn::F32(protos_t),
8234                layout: ProtoLayout::Nhwc,
8235            }
8236        };
8237        processor
8238            .draw_proto_masks(&mut dst, &[], &proto, MaskOverlay::default())
8239            .unwrap_or_else(|e| panic!("{case}/proto_masks empty+no-bg failed: {e:?}"));
8240        assert_every_pixel_eq(&dst, [0, 0, 0, 0], &format!("{case}/proto"));
8241    }
8242
8243    /// Scenario 2: empty detections, empty segmentation, background set
8244    /// → dst must be fully equal to bg.
8245    fn scenario_empty_with_bg(processor: &mut ImageProcessor, case: &str) {
8246        let bg_color = [42, 99, 200, 255];
8247        let bg = make_bg(64, 64, None, bg_color);
8248        let overlay = MaskOverlay::new().with_background(&bg);
8249
8250        let mut dst = make_dirty_dst(64, 64, None);
8251        processor
8252            .draw_decoded_masks(&mut dst, &[], &[], overlay)
8253            .unwrap_or_else(|e| panic!("{case}/decoded_masks empty+bg failed: {e:?}"));
8254        assert_every_pixel_eq(&dst, bg_color, &format!("{case}/decoded bg blit"));
8255
8256        let mut dst = make_dirty_dst(64, 64, None);
8257        let proto = {
8258            use edgefirst_tensor::{Tensor, TensorDyn};
8259            // Placeholder (no detections); shape [1, 4] to keep the tensor well-formed.
8260            let coeff_t = Tensor::<f32>::from_slice(&[0.0_f32; 4], &[1, 4]).unwrap();
8261            let protos_t =
8262                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
8263            ProtoData {
8264                mask_coefficients: TensorDyn::F32(coeff_t),
8265                protos: TensorDyn::F32(protos_t),
8266                layout: ProtoLayout::Nhwc,
8267            }
8268        };
8269        processor
8270            .draw_proto_masks(&mut dst, &[], &proto, overlay)
8271            .unwrap_or_else(|e| panic!("{case}/proto_masks empty+bg failed: {e:?}"));
8272        assert_every_pixel_eq(&dst, bg_color, &format!("{case}/proto bg blit"));
8273    }
8274
8275    /// Scenario 3: one detection with a fully-opaque segmentation fill,
8276    /// no background → outside the box dst must be 0x00, inside it must
8277    /// be a non-zero mask colour (the render_segmentation output).
8278    fn scenario_detect_no_bg(processor: &mut ImageProcessor, case: &str) {
8279        use edgefirst_decoder::Segmentation;
8280        use ndarray::Array3;
8281        processor
8282            .set_class_colors(&[[200, 80, 40, 255]])
8283            .expect("set_class_colors");
8284
8285        let detect = DetectBox {
8286            bbox: [0.25, 0.25, 0.75, 0.75].into(),
8287            score: 0.99,
8288            label: 0,
8289        };
8290        let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
8291        let seg = Segmentation {
8292            segmentation: seg_arr,
8293            xmin: 0.25,
8294            ymin: 0.25,
8295            xmax: 0.75,
8296            ymax: 0.75,
8297        };
8298
8299        let mut dst = make_dirty_dst(64, 64, None);
8300        processor
8301            .draw_decoded_masks(&mut dst, &[detect], &[seg], MaskOverlay::default())
8302            .unwrap_or_else(|e| panic!("{case}/decoded_masks detect+no-bg failed: {e:?}"));
8303
8304        // Outside the bbox (corner): must be cleared black.
8305        let corner = pixel_at(&dst, 2, 2);
8306        assert_eq!(
8307            corner,
8308            [0, 0, 0, 0],
8309            "{case}/decoded: corner (2,2) leaked dirty pattern: {corner:?}"
8310        );
8311        // Inside the bbox (center): the mask colour must be visible.
8312        // Any non-zero pixel is acceptable — exact rendering varies
8313        // between backends (GL smoothstep, CPU nearest).
8314        let center = pixel_at(&dst, 32, 32);
8315        assert!(
8316            center != [0, 0, 0, 0],
8317            "{case}/decoded: center (32,32) was not coloured: {center:?}"
8318        );
8319    }
8320
8321    /// Scenario 4: detection + background. Outside the box must match
8322    /// bg; inside the box must NOT match bg (mask blended on top).
8323    fn scenario_detect_with_bg(processor: &mut ImageProcessor, case: &str) {
8324        use edgefirst_decoder::Segmentation;
8325        use ndarray::Array3;
8326        processor
8327            .set_class_colors(&[[200, 80, 40, 255]])
8328            .expect("set_class_colors");
8329        let bg_color = [10, 20, 30, 255];
8330        let bg = make_bg(64, 64, None, bg_color);
8331
8332        let detect = DetectBox {
8333            bbox: [0.25, 0.25, 0.75, 0.75].into(),
8334            score: 0.99,
8335            label: 0,
8336        };
8337        let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
8338        let seg = Segmentation {
8339            segmentation: seg_arr,
8340            xmin: 0.25,
8341            ymin: 0.25,
8342            xmax: 0.75,
8343            ymax: 0.75,
8344        };
8345
8346        let overlay = MaskOverlay::new().with_background(&bg);
8347        let mut dst = make_dirty_dst(64, 64, None);
8348        processor
8349            .draw_decoded_masks(&mut dst, &[detect], &[seg], overlay)
8350            .unwrap_or_else(|e| panic!("{case}/decoded_masks detect+bg failed: {e:?}"));
8351
8352        // Outside the bbox (corner): bg colour.
8353        let corner = pixel_at(&dst, 2, 2);
8354        assert_eq!(
8355            corner, bg_color,
8356            "{case}/decoded: corner (2,2) should show bg {bg_color:?} got {corner:?}"
8357        );
8358        // Inside the bbox (center): mask blended on bg, must differ from
8359        // pure bg (alpha-blend with mask colour produces a distinct shade).
8360        let center = pixel_at(&dst, 32, 32);
8361        assert!(
8362            center != bg_color,
8363            "{case}/decoded: center (32,32) should differ from bg {bg_color:?}, got {center:?}"
8364        );
8365    }
8366
8367    /// Run all 4 scenarios against the processor. Skip gracefully if
8368    /// construction fails (backend unavailable on this host).
8369    fn run_all_scenarios(
8370        force_backend: Option<&'static str>,
8371        case: &'static str,
8372        require_dma_for_bg: bool,
8373    ) {
8374        if require_dma_for_bg && !edgefirst_tensor::is_dma_available() {
8375            eprintln!("SKIPPED: {case} — DMA not available on this host");
8376            return;
8377        }
8378        let processor_result = with_force_backend(force_backend, ImageProcessor::new);
8379        let mut processor = match processor_result {
8380            Ok(p) => p,
8381            Err(e) => {
8382                eprintln!("SKIPPED: {case} — backend init failed: {e:?}");
8383                return;
8384            }
8385        };
8386        scenario_empty_no_bg(&mut processor, case);
8387        scenario_empty_with_bg(&mut processor, case);
8388        scenario_detect_no_bg(&mut processor, case);
8389        scenario_detect_with_bg(&mut processor, case);
8390    }
8391
8392    #[test]
8393    fn test_draw_masks_4_scenarios_cpu() {
8394        run_all_scenarios(Some("cpu"), "cpu", false);
8395    }
8396
8397    #[test]
8398    fn test_draw_masks_4_scenarios_auto() {
8399        run_all_scenarios(None, "auto", false);
8400    }
8401
8402    #[cfg(target_os = "linux")]
8403    #[cfg(feature = "opengl")]
8404    #[test]
8405    fn test_draw_masks_4_scenarios_opengl() {
8406        run_all_scenarios(Some("opengl"), "opengl", false);
8407    }
8408
8409    /// G2D forced backend: exercises the zero-detection empty-frame
8410    /// paths via `g2d_clear` and `g2d_blit`. Scenarios 3 and 4 (with
8411    /// detections) expect `NotImplemented` since G2D has no rasterizer
8412    /// for boxes / masks.
8413    #[cfg(target_os = "linux")]
8414    #[test]
8415    fn test_draw_masks_zero_detection_g2d_forced() {
8416        if !edgefirst_tensor::is_dma_available() {
8417            eprintln!("SKIPPED: g2d forced — DMA not available on this host");
8418            return;
8419        }
8420        let processor_result = with_force_backend(Some("g2d"), ImageProcessor::new);
8421        let mut processor = match processor_result {
8422            Ok(p) => p,
8423            Err(e) => {
8424                eprintln!("SKIPPED: g2d forced — init failed: {e:?}");
8425                return;
8426            }
8427        };
8428
8429        // Case 1: empty + no bg. G2D requires DMA-backed dst.
8430        let mut dst = TensorDyn::image(
8431            64,
8432            64,
8433            PixelFormat::Rgba,
8434            DType::U8,
8435            Some(TensorMemory::Dma),
8436        )
8437        .unwrap();
8438        {
8439            use edgefirst_tensor::TensorMapTrait;
8440            let u8t = dst.as_u8_mut().unwrap();
8441            let mut map = u8t.map().unwrap();
8442            map.as_mut_slice().fill(0xBB);
8443        }
8444        processor
8445            .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::default())
8446            .expect("g2d empty+no-bg");
8447        assert_every_pixel_eq(&dst, [0, 0, 0, 0], "g2d/case1 cleared");
8448
8449        // Case 2: empty + bg. Both surfaces DMA-backed for g2d_blit.
8450        let bg_color = [7, 11, 13, 255];
8451        let bg = {
8452            let t = TensorDyn::image(
8453                64,
8454                64,
8455                PixelFormat::Rgba,
8456                DType::U8,
8457                Some(TensorMemory::Dma),
8458            )
8459            .unwrap();
8460            {
8461                use edgefirst_tensor::TensorMapTrait;
8462                let u8t = t.as_u8().unwrap();
8463                let mut map = u8t.map().unwrap();
8464                for chunk in map.as_mut_slice().chunks_exact_mut(4) {
8465                    chunk.copy_from_slice(&bg_color);
8466                }
8467            }
8468            t
8469        };
8470        let mut dst = TensorDyn::image(
8471            64,
8472            64,
8473            PixelFormat::Rgba,
8474            DType::U8,
8475            Some(TensorMemory::Dma),
8476        )
8477        .unwrap();
8478        {
8479            use edgefirst_tensor::TensorMapTrait;
8480            let u8t = dst.as_u8_mut().unwrap();
8481            let mut map = u8t.map().unwrap();
8482            map.as_mut_slice().fill(0x55);
8483        }
8484        processor
8485            .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::new().with_background(&bg))
8486            .expect("g2d empty+bg");
8487        assert_every_pixel_eq(&dst, bg_color, "g2d/case2 bg blit");
8488
8489        // Case 3 and 4: detect present — must return NotImplemented.
8490        let detect = DetectBox {
8491            bbox: [0.25, 0.25, 0.75, 0.75].into(),
8492            score: 0.9,
8493            label: 0,
8494        };
8495        let mut dst = TensorDyn::image(
8496            64,
8497            64,
8498            PixelFormat::Rgba,
8499            DType::U8,
8500            Some(TensorMemory::Dma),
8501        )
8502        .unwrap();
8503        let err = processor
8504            .draw_decoded_masks(&mut dst, &[detect], &[], MaskOverlay::default())
8505            .expect_err("g2d must reject detect-present draw_decoded_masks");
8506        assert!(
8507            matches!(err, Error::NotImplemented(_)),
8508            "g2d case3 wrong error: {err:?}"
8509        );
8510    }
8511
8512    #[test]
8513    fn test_set_format_then_cpu_convert() {
8514        // Force CPU backend; serialized under ENV_MUTEX to avoid racing with
8515        // test_force_backend_* and test_disable_env_var.
8516        let _lock = acquire_env_lock();
8517        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
8518        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
8519        let mut processor = ImageProcessor::new().unwrap();
8520
8521        // Load a source image
8522        let image = edgefirst_bench::testdata::read("zidane.jpg");
8523        let src = load_image_test_helper(&image, Some(PixelFormat::Rgba), None).unwrap();
8524
8525        // Create a raw tensor, then attach format — simulating the from_fd workflow
8526        let mut dst =
8527            TensorDyn::new(&[640, 640, 3], DType::U8, Some(TensorMemory::Mem), None).unwrap();
8528        dst.set_format(PixelFormat::Rgb).unwrap();
8529
8530        // Convert should work with the set_format-annotated tensor
8531        processor
8532            .convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
8533            .unwrap();
8534
8535        // Verify format survived conversion
8536        assert_eq!(dst.format(), Some(PixelFormat::Rgb));
8537        assert_eq!(dst.width(), Some(640));
8538        assert_eq!(dst.height(), Some(640));
8539    }
8540
8541    /// Verify that creating multiple ImageProcessors on the same thread and
8542    /// performing a resize on each does not deadlock or error.
8543    ///
8544    /// Uses automatic memory allocation (DMA → PBO → Mem fallback) so that
8545    /// hardware backends (OpenGL, G2D) are exercised on capable targets.
8546    #[test]
8547    fn test_multiple_image_processors_same_thread() {
8548        // Hold the env mutex so env-var-mutating tests can't corrupt the state
8549        // seen by ImageProcessor::new() calls during this test.
8550        let _lock = acquire_env_lock();
8551        let mut processors: Vec<ImageProcessor> = (0..4)
8552            .map(|_| ImageProcessor::new().expect("ImageProcessor::new() failed"))
8553            .collect();
8554
8555        for proc in &mut processors {
8556            let src = proc
8557                .create_image(128, 128, PixelFormat::Rgb, DType::U8, None)
8558                .expect("create src failed");
8559            let mut dst = proc
8560                .create_image(64, 64, PixelFormat::Rgb, DType::U8, None)
8561                .expect("create dst failed");
8562            proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
8563                .expect("convert failed");
8564            assert_eq!(dst.width(), Some(64));
8565            assert_eq!(dst.height(), Some(64));
8566        }
8567    }
8568
8569    /// Verify that creating ImageProcessors on separate threads and performing
8570    /// a resize on each does not deadlock or error.
8571    ///
8572    /// Uses automatic memory allocation (DMA → PBO → Mem fallback) so that
8573    /// hardware backends (OpenGL, G2D) are exercised on capable targets.
8574    /// A 60-second timeout prevents CI from hanging on deadlock regressions.
8575    #[test]
8576    fn test_multiple_image_processors_separate_threads() {
8577        use std::sync::mpsc;
8578        use std::time::Duration;
8579
8580        // The Vivante GC7000UL driver (i.MX 8M Plus) double-frees on concurrent
8581        // EGL context teardown — four processors spun up on four threads here
8582        // trips it and aborts the whole test binary (SIGABRT, not a catchable
8583        // panic). The bug is the driver's, not the HAL's; this test is kept so
8584        // it still exercises the multi-context path on every other GPU. The
8585        // on-target GitHub Actions imx8mp runner sets EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS
8586        // to skip just this case there, while the run stays red anywhere else
8587        // a regression appears. (Skip, not #[ignore]: the platform is decided
8588        // at runtime, not compile time.)
8589        if std::env::var_os("EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS").is_some() {
8590            eprintln!(
8591                "SKIPPED: test_multiple_image_processors_separate_threads — known Vivante \
8592                 GC7000UL concurrent-EGL-teardown double-free \
8593                 (EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS set)"
8594            );
8595            return;
8596        }
8597
8598        const TIMEOUT: Duration = Duration::from_secs(60);
8599
8600        // Hold the env mutex so env-var-mutating tests can't corrupt ImageProcessor::new()
8601        // calls made inside the spawned threads during this test.
8602        let _lock = acquire_env_lock();
8603
8604        let (tx, rx) = mpsc::channel::<()>();
8605
8606        std::thread::spawn(move || {
8607            let handles: Vec<_> = (0..4)
8608                .map(|i| {
8609                    std::thread::spawn(move || {
8610                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
8611                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
8612                        });
8613                        let src = proc
8614                            .create_image(128, 128, PixelFormat::Rgb, DType::U8, None)
8615                            .unwrap_or_else(|e| panic!("create src failed on thread {i}: {e}"));
8616                        let mut dst = proc
8617                            .create_image(64, 64, PixelFormat::Rgb, DType::U8, None)
8618                            .unwrap_or_else(|e| panic!("create dst failed on thread {i}: {e}"));
8619                        proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
8620                            .unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
8621                        assert_eq!(dst.width(), Some(64));
8622                        assert_eq!(dst.height(), Some(64));
8623                    })
8624                })
8625                .collect();
8626
8627            for (i, h) in handles.into_iter().enumerate() {
8628                h.join()
8629                    .unwrap_or_else(|e| panic!("thread {i} panicked: {e:?}"));
8630            }
8631
8632            let _ = tx.send(());
8633        });
8634
8635        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
8636            panic!("test_multiple_image_processors_separate_threads timed out after {TIMEOUT:?}")
8637        });
8638    }
8639
8640    /// Verify that 4 fully-initialized ImageProcessors on separate threads can
8641    /// all operate concurrently without deadlocking each other.
8642    ///
8643    /// All processors are created first, then a barrier synchronizes them so
8644    /// they all start converting at the same instant — maximizing contention.
8645    /// A 60-second timeout prevents CI from hanging on deadlock regressions.
8646    #[test]
8647    fn test_image_processors_concurrent_operations() {
8648        use std::sync::{mpsc, Arc, Barrier};
8649        use std::time::Duration;
8650
8651        const N: usize = 4;
8652        const ROUNDS: usize = 10;
8653        const TIMEOUT: Duration = Duration::from_secs(60);
8654
8655        // Hold the env mutex so env-var-mutating tests can't corrupt ImageProcessor::new()
8656        // calls made inside the spawned threads during this test.
8657        let _lock = acquire_env_lock();
8658
8659        let (tx, rx) = mpsc::channel::<()>();
8660
8661        std::thread::spawn(move || {
8662            let barrier = Arc::new(Barrier::new(N));
8663
8664            let handles: Vec<_> = (0..N)
8665                .map(|i| {
8666                    let barrier = Arc::clone(&barrier);
8667                    std::thread::spawn(move || {
8668                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
8669                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
8670                        });
8671
8672                        // All threads wait here until every processor is initialized.
8673                        barrier.wait();
8674
8675                        // Now all 4 hammer the GPU concurrently.
8676                        for round in 0..ROUNDS {
8677                            let src = proc
8678                                .create_image(128, 128, PixelFormat::Rgb, DType::U8, None)
8679                                .unwrap_or_else(|e| {
8680                                    panic!("create src failed on thread {i} round {round}: {e}")
8681                                });
8682                            let mut dst = proc
8683                                .create_image(64, 64, PixelFormat::Rgb, DType::U8, None)
8684                                .unwrap_or_else(|e| {
8685                                    panic!("create dst failed on thread {i} round {round}: {e}")
8686                                });
8687                            proc.convert(
8688                                &src,
8689                                &mut dst,
8690                                Rotation::None,
8691                                Flip::None,
8692                                Crop::default(),
8693                            )
8694                            .unwrap_or_else(|e| {
8695                                panic!("convert failed on thread {i} round {round}: {e}")
8696                            });
8697                            assert_eq!(dst.width(), Some(64));
8698                            assert_eq!(dst.height(), Some(64));
8699                        }
8700                    })
8701                })
8702                .collect();
8703
8704            for (i, h) in handles.into_iter().enumerate() {
8705                h.join()
8706                    .unwrap_or_else(|e| panic!("thread {i} panicked: {e:?}"));
8707            }
8708
8709            let _ = tx.send(());
8710        });
8711
8712        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
8713            panic!("test_image_processors_concurrent_operations timed out after {TIMEOUT:?}")
8714        });
8715    }
8716
8717    /// THE parallel-processors demonstration test: 4 ImageProcessors on 4
8718    /// threads, each with its own GL context and worker, converting
8719    /// per-thread-DISTINCT synthetic inputs concurrently (barrier-released);
8720    /// every output must byte-match the thread's own pre-barrier sequential
8721    /// oracle from the same processor. On LifecycleOnly platforms (Mali,
8722    /// V3D, Tegra, llvmpipe, macOS) the converts genuinely overlap on the
8723    /// GPU; on Vivante they serialize via the Full policy and the test still
8724    /// must pass. Distinct inputs make any cross-processor state leakage
8725    /// (wrong texture, wrong context, clobbered upload) visible as a byte
8726    /// diff rather than a coincidental match — proven by a scratch
8727    /// cross-wire run (neighbor's input post-oracle) failing on every
8728    /// thread with ~53% of bytes diverged.
8729    ///
8730    /// Skipped under EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS like
8731    /// `test_multiple_image_processors_separate_threads`: the galcore driver
8732    /// can abort intermittently on concurrent multi-processor lifecycles
8733    /// regardless of locking (P0 spike: reproduces fully serialized).
8734    #[test]
8735    fn test_parallel_processors_unique_outputs() {
8736        use std::sync::{mpsc, Arc, Barrier};
8737        use std::time::Duration;
8738
8739        const N: usize = 4;
8740        const ROUNDS: usize = 25;
8741        const TIMEOUT: Duration = Duration::from_secs(60);
8742
8743        if std::env::var_os("EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS").is_some() {
8744            eprintln!(
8745                "SKIPPED: test_parallel_processors_unique_outputs — known Vivante \
8746                 GC7000UL concurrent-multi-processor driver abort \
8747                 (EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS set)"
8748            );
8749            return;
8750        }
8751
8752        let _lock = acquire_env_lock();
8753        let (tx, rx) = mpsc::channel::<()>();
8754
8755        std::thread::spawn(move || {
8756            let barrier = Arc::new(Barrier::new(N));
8757            let handles: Vec<_> = (0..N)
8758                .map(|i| {
8759                    let barrier = Arc::clone(&barrier);
8760                    std::thread::spawn(move || {
8761                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
8762                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
8763                        });
8764                        // CI-weight geometry (llvmpipe renders on the CPU).
8765                        let (w, h) = (640usize, 480usize);
8766                        let mem = if edgefirst_tensor::is_dma_available() {
8767                            Some(TensorMemory::Dma)
8768                        } else {
8769                            Some(TensorMemory::Mem)
8770                        };
8771                        let src = proc
8772                            .create_image(w, h, PixelFormat::Nv12, DType::U8, mem)
8773                            .unwrap();
8774                        {
8775                            let t = src.as_u8().unwrap();
8776                            let mut m = t.map().unwrap();
8777                            let s = m.as_mut_slice();
8778                            for (j, b) in s[..w * h].iter_mut().enumerate() {
8779                                *b = ((i * 53 + j) % 200 + 16) as u8;
8780                            }
8781                            for b in &mut s[w * h..] {
8782                                *b = (80 + i * 24) as u8;
8783                            }
8784                        }
8785                        let lb = Crop::letterbox([114, 114, 114, 255]);
8786                        let convert_once = |proc: &mut ImageProcessor| -> Vec<u8> {
8787                            let mut dst = proc
8788                                .create_image(320, 320, PixelFormat::Rgba, DType::U8, mem)
8789                                .unwrap();
8790                            proc.convert(&src, &mut dst, Rotation::None, Flip::None, lb)
8791                                .unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
8792                            let t = dst.as_u8().unwrap();
8793                            let m = t.map().unwrap();
8794                            m.as_slice().to_vec()
8795                        };
8796
8797                        let oracle = convert_once(&mut proc);
8798                        barrier.wait();
8799                        for round in 0..ROUNDS {
8800                            let out = convert_once(&mut proc);
8801                            let diffs = oracle.iter().zip(&out).filter(|(a, b)| a != b).count();
8802                            assert!(
8803                                diffs == 0,
8804                                "thread {i} round {round}: {diffs}/{} bytes diverged \
8805                                 from this processor's own oracle — cross-processor \
8806                                 GL state leakage under parallel execution",
8807                                oracle.len()
8808                            );
8809                        }
8810                    })
8811                })
8812                .collect();
8813
8814            for (i, h) in handles.into_iter().enumerate() {
8815                h.join()
8816                    .unwrap_or_else(|e| panic!("parallel thread {i} panicked: {e:?}"));
8817            }
8818            let _ = tx.send(());
8819        });
8820
8821        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
8822            panic!("test_parallel_processors_unique_outputs timed out after {TIMEOUT:?}")
8823        });
8824    }
8825
8826    /// Heavy on-demand stressor for the GL serialization policy: 4
8827    /// processors × 4 threads × barrier × 200 NV12 720p → RGB 640 letterbox
8828    /// converts (DMA where available); every output must byte-match the
8829    /// thread's own pre-barrier sequential oracle from the same processor.
8830    /// Ignored by default (heavy; board tool — the CI-weight version is
8831    /// `test_parallel_processors_unique_outputs`). Run explicitly, optionally
8832    /// pinning the policy via EDGEFIRST_GL_SERIALIZE=full|lifecycle:
8833    ///   <test binary> stress_parallel_processors_oracle --ignored
8834    #[test]
8835    #[ignore = "heavy on-demand GL-parallelism stressor; run explicitly on boards"]
8836    fn stress_parallel_processors_oracle() {
8837        use std::sync::{mpsc, Arc, Barrier};
8838        use std::time::Duration;
8839
8840        const N: usize = 4;
8841        const ROUNDS: usize = 200;
8842        const TIMEOUT: Duration = Duration::from_secs(600);
8843
8844        let _lock = acquire_env_lock();
8845        let (tx, rx) = mpsc::channel::<()>();
8846
8847        std::thread::spawn(move || {
8848            let barrier = Arc::new(Barrier::new(N));
8849            let handles: Vec<_> = (0..N)
8850                .map(|i| {
8851                    let barrier = Arc::clone(&barrier);
8852                    std::thread::spawn(move || {
8853                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
8854                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
8855                        });
8856                        let (w, h) = (1280usize, 720usize);
8857                        let mem = if edgefirst_tensor::is_dma_available() {
8858                            Some(TensorMemory::Dma)
8859                        } else {
8860                            Some(TensorMemory::Mem)
8861                        };
8862
8863                        // Per-thread-distinct synthetic NV12 so cross-wired
8864                        // GL state between processors shows up as a byte diff.
8865                        let src = proc
8866                            .create_image(w, h, PixelFormat::Nv12, DType::U8, mem)
8867                            .unwrap();
8868                        {
8869                            let t = src.as_u8().unwrap();
8870                            let mut m = t.map().unwrap();
8871                            let s = m.as_mut_slice();
8872                            for (j, b) in s[..w * h].iter_mut().enumerate() {
8873                                *b = ((i * 37 + j) % 200 + 16) as u8;
8874                            }
8875                            for b in &mut s[w * h..] {
8876                                *b = (96 + i * 16) as u8;
8877                            }
8878                        }
8879                        let lb = Crop::letterbox([114, 114, 114, 255]);
8880
8881                        let convert_once = |proc: &mut ImageProcessor| -> Vec<u8> {
8882                            let mut dst = proc
8883                                .create_image(640, 640, PixelFormat::Rgb, DType::U8, mem)
8884                                .unwrap();
8885                            proc.convert(&src, &mut dst, Rotation::None, Flip::None, lb)
8886                                .unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
8887                            let t = dst.as_u8().unwrap();
8888                            let m = t.map().unwrap();
8889                            m.as_slice().to_vec()
8890                        };
8891
8892                        let oracle = convert_once(&mut proc);
8893                        barrier.wait();
8894                        for round in 0..ROUNDS {
8895                            let out = convert_once(&mut proc);
8896                            let diffs = oracle.iter().zip(&out).filter(|(a, b)| a != b).count();
8897                            assert!(
8898                                diffs == 0,
8899                                "thread {i} round {round}: {diffs}/{} bytes diverged \
8900                                 from the pre-barrier oracle",
8901                                oracle.len()
8902                            );
8903                        }
8904                    })
8905                })
8906                .collect();
8907
8908            for (i, h) in handles.into_iter().enumerate() {
8909                h.join()
8910                    .unwrap_or_else(|e| panic!("stressor thread {i} panicked: {e:?}"));
8911            }
8912            let _ = tx.send(());
8913        });
8914
8915        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
8916            panic!("stress_parallel_processors_oracle timed out after {TIMEOUT:?}")
8917        });
8918    }
8919
8920    // =========================================================================
8921    // F16 / F32 auto-chain fallback integration tests
8922    // =========================================================================
8923
8924    /// Proves the auto-chain (OpenGL → G2D → CPU) NEVER errors for a float
8925    /// combo the GL path does NOT cover.
8926    ///
8927    /// `Yuyv → Rgb F32` is not handled by the GL float render path (which
8928    /// only covers `Rgba → PlanarRgb F16` and `Rgba → Rgb F32`), so the
8929    /// chain falls through to the CPU float path. Before commit 868a7649
8930    /// added CPU U8→F32/F16 support this would have returned `Err`; now it
8931    /// must return `Ok` with output in `[0, 1]` and all values finite.
8932    #[test]
8933    fn convert_f32_auto_never_errors_non_gl_combo() {
8934        const W: usize = 64;
8935        const H: usize = 64;
8936
8937        // Build a small synthetic YUYV source (Y=128, U=128, V=128 → near-grey).
8938        // YUYV packs two pixels into 4 bytes: [Y0, U, Y1, V] per macropixel.
8939        let src =
8940            TensorDyn::image(W, H, PixelFormat::Yuyv, DType::U8, Some(TensorMemory::Mem)).unwrap();
8941        {
8942            let mut map = src.as_u8().unwrap().map().unwrap();
8943            let data = map.as_mut_slice();
8944            for chunk in data.chunks_exact_mut(4) {
8945                chunk[0] = 128; // Y0
8946                chunk[1] = 128; // U
8947                chunk[2] = 160; // Y1 — distinct so a layout bug is visible
8948                chunk[3] = 128; // V
8949            }
8950        }
8951
8952        let mut dst =
8953            TensorDyn::image(W, H, PixelFormat::Rgb, DType::F32, Some(TensorMemory::Mem)).unwrap();
8954
8955        let mut proc = ImageProcessor::new().unwrap();
8956        let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
8957        assert!(
8958            result.is_ok(),
8959            "auto-chain Yuyv→Rgb F32 must not error: {:?}",
8960            result.err()
8961        );
8962
8963        // Verify all output values are finite and in [0, 1].
8964        let map = dst.as_f32().unwrap().map().unwrap();
8965        let floats = map.as_slice();
8966        assert_eq!(floats.len(), W * H * 3, "unexpected output element count");
8967        for (i, &v) in floats.iter().enumerate() {
8968            assert!(
8969                v.is_finite() && (0.0..=1.0).contains(&v),
8970                "output[{i}]={v} is not finite or not in [0,1]"
8971            );
8972        }
8973
8974        // WEAK-1: Anti-all-zero spot-check.  A Y=128 YUYV source normalises to
8975        // ≈0.502 on the luma channel.  If the buffer is all-zero (e.g. the CPU
8976        // path never wrote to it) this assertion catches the regression.
8977        let first_non_zero = floats.iter().find(|&&v| v > 0.01);
8978        assert!(
8979            first_non_zero.is_some(),
8980            "all-zero output detected — CPU path likely did not write to the destination buffer"
8981        );
8982        // Y=128 → luma ≈ 0.502.  Spot-check the first pixel's R channel
8983        // (which carries luma for a near-grey YUV source).
8984        let r0 = floats[0];
8985        assert!(
8986            (r0 - 0.502_f32).abs() < 0.05,
8987            "first pixel R={r0} expected ≈0.502 (Y=128 neutral grey from YUYV source)"
8988        );
8989    }
8990
8991    /// Proves CPU-forced `Rgba → PlanarRgb F16` correctness.
8992    ///
8993    /// Uses a source with clearly distinct per-channel values so a
8994    /// plane-swap or layout bug surfaces immediately. Tolerance is 2^-9
8995    /// (one F16 ULP at 0.5, i.e. roughly 1/512).
8996    #[test]
8997    // `ImageProcessorConfig` carries a Linux-only `egl_display` field, so
8998    // `{ backend, ..Default::default() }` is a genuine update on Linux but
8999    // covers no remaining fields on macOS, where `clippy::needless_update`
9000    // then fires. `allow` (not `expect`) because the lint is platform-
9001    // conditional — it does not fire on Linux.
9002    #[allow(clippy::needless_update)]
9003    fn convert_f16_forced_cpu_correct() {
9004        const W: usize = 16;
9005        const H: usize = 16;
9006        const TOL: f32 = 1.0 / 512.0; // 2^-9
9007
9008        // pixel (y,x): R = 50+x, G = 100+y*8, B = 200
9009        let src =
9010            TensorDyn::image(W, H, PixelFormat::Rgba, DType::U8, Some(TensorMemory::Mem)).unwrap();
9011        {
9012            let mut map = src.as_u8().unwrap().map().unwrap();
9013            let data = map.as_mut_slice();
9014            for y in 0..H {
9015                for x in 0..W {
9016                    let i = y * W + x;
9017                    data[i * 4] = (50 + x) as u8; // R: 50..65
9018                    data[i * 4 + 1] = (100 + y * 8) as u8; // G: 100..220
9019                    data[i * 4 + 2] = 200; // B: constant
9020                    data[i * 4 + 3] = 255;
9021                }
9022            }
9023        }
9024
9025        let mut dst = TensorDyn::image(
9026            W,
9027            H,
9028            PixelFormat::PlanarRgb,
9029            DType::F16,
9030            Some(TensorMemory::Mem),
9031        )
9032        .unwrap();
9033
9034        let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
9035            backend: ComputeBackend::Cpu,
9036            ..Default::default()
9037        })
9038        .unwrap();
9039        proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
9040            .expect("forced-CPU Rgba→PlanarRgb F16 must not error");
9041
9042        let src_map = src.as_u8().unwrap().map().unwrap();
9043        let src_bytes = src_map.as_slice();
9044        let dst_map = dst.as_f16().unwrap().map().unwrap();
9045        let dst_halfs = dst_map.as_slice();
9046
9047        let plane = W * H;
9048        assert_eq!(dst_halfs.len(), plane * 3, "wrong output element count");
9049
9050        for y in 0..H {
9051            for x in 0..W {
9052                let i = y * W + x;
9053                let r_exp = src_bytes[i * 4] as f32 / 255.0;
9054                let g_exp = src_bytes[i * 4 + 1] as f32 / 255.0;
9055                let b_exp = src_bytes[i * 4 + 2] as f32 / 255.0;
9056
9057                let r_got = dst_halfs[i].to_f32();
9058                let g_got = dst_halfs[plane + i].to_f32();
9059                let b_got = dst_halfs[2 * plane + i].to_f32();
9060
9061                assert!(
9062                    (r_got - r_exp).abs() <= TOL,
9063                    "R plane ({x},{y}): got {r_got}, expected {r_exp}"
9064                );
9065                assert!(
9066                    (g_got - g_exp).abs() <= TOL,
9067                    "G plane ({x},{y}): got {g_got}, expected {g_exp}"
9068                );
9069                assert!(
9070                    (b_got - b_exp).abs() <= TOL,
9071                    "B plane ({x},{y}): got {b_got}, expected {b_exp}"
9072                );
9073
9074                // Catch plane-swap: R and G must differ (they have different formulas).
9075                if src_bytes[i * 4] != src_bytes[i * 4 + 1] {
9076                    assert_ne!(r_got, g_got, "R and G planes must differ at ({x},{y})");
9077                }
9078            }
9079        }
9080    }
9081
9082    /// Proves the auto-chain falls through to CPU for `Rgba → Rgb F32` with
9083    /// rotation set.
9084    ///
9085    /// The GL float render path rejects any call where rotation ≠ None,
9086    /// returning an error that causes the chain to continue. Before the CPU
9087    /// float fallback this would have produced an error at the end of the
9088    /// chain; now it must reach CPU and return `Ok` with finite `[0, 1]` output.
9089    #[test]
9090    fn convert_f32_with_rotation_falls_back() {
9091        const W: usize = 16;
9092        const H: usize = 16;
9093
9094        // RGBA8 source with a known gradient (distinct per-channel values).
9095        let src =
9096            TensorDyn::image(W, H, PixelFormat::Rgba, DType::U8, Some(TensorMemory::Mem)).unwrap();
9097        {
9098            let mut map = src.as_u8().unwrap().map().unwrap();
9099            let data = map.as_mut_slice();
9100            for y in 0..H {
9101                for x in 0..W {
9102                    let i = y * W + x;
9103                    data[i * 4] = (x * 16) as u8; // R
9104                    data[i * 4 + 1] = (y * 16) as u8; // G
9105                    data[i * 4 + 2] = 128; // B
9106                    data[i * 4 + 3] = 255;
9107                }
9108            }
9109        }
9110
9111        // Rotation swaps W and H, so dst is [W, H] (H×W output).
9112        let mut dst = TensorDyn::image(
9113            H, // dst W = src H after 90° rotation
9114            W, // dst H = src W after 90° rotation
9115            PixelFormat::Rgb,
9116            DType::F32,
9117            Some(TensorMemory::Mem),
9118        )
9119        .unwrap();
9120
9121        let mut proc = ImageProcessor::new().unwrap();
9122        let result = proc.convert(
9123            &src,
9124            &mut dst,
9125            Rotation::Clockwise90,
9126            Flip::None,
9127            Crop::default(),
9128        );
9129        assert!(
9130            result.is_ok(),
9131            "auto-chain Rgba→Rgb F32 with Rot90 must not error: {:?}",
9132            result.err()
9133        );
9134
9135        let map = dst.as_f32().unwrap().map().unwrap();
9136        let floats = map.as_slice();
9137        assert_eq!(floats.len(), H * W * 3, "unexpected output element count");
9138        for (i, &v) in floats.iter().enumerate() {
9139            assert!(
9140                v.is_finite() && (0.0..=1.0).contains(&v),
9141                "output[{i}]={v} is not finite or not in [0,1]"
9142            );
9143        }
9144    }
9145
9146    /// GL-vs-CPU identity parity for `Rgba → PlanarRgb F16`.
9147    ///
9148    /// Converts the same RGBA8 source via forced `OpenGl` and forced `Cpu`,
9149    /// then verifies the two F16 output tensors agree element-wise within
9150    /// 2^-8 (two F16 ULPs at 0.5). Skipped when OpenGL or F16 render is
9151    /// unavailable.
9152    #[test]
9153    #[cfg(all(target_os = "linux", feature = "opengl"))]
9154    fn convert_f16_gl_cpu_parity_identity() {
9155        if !is_opengl_available() {
9156            eprintln!("SKIPPED: convert_f16_gl_cpu_parity_identity - OpenGL not available");
9157            return;
9158        }
9159
9160        const W: usize = 16;
9161        const H: usize = 16;
9162        const TOL: f32 = 1.0 / 256.0; // 2^-8
9163
9164        // pixel (y,x): R = 40+x, G = 80+y*10, B = 180
9165        let src =
9166            TensorDyn::image(W, H, PixelFormat::Rgba, DType::U8, Some(TensorMemory::Mem)).unwrap();
9167        {
9168            let mut map = src.as_u8().unwrap().map().unwrap();
9169            let data = map.as_mut_slice();
9170            for y in 0..H {
9171                for x in 0..W {
9172                    let i = y * W + x;
9173                    data[i * 4] = (40 + x) as u8; // R
9174                    data[i * 4 + 1] = (80 + y * 10) as u8; // G
9175                    data[i * 4 + 2] = 180; // B
9176                    data[i * 4 + 3] = 255;
9177                }
9178            }
9179        }
9180
9181        // GL path.
9182        let gl_result = {
9183            let mut gl_proc = match ImageProcessor::with_config(ImageProcessorConfig {
9184                backend: ComputeBackend::OpenGl,
9185                ..Default::default()
9186            }) {
9187                Ok(p) => p,
9188                Err(e) => {
9189                    eprintln!(
9190                        "SKIPPED: convert_f16_gl_cpu_parity_identity - GL backend unavailable: {e}"
9191                    );
9192                    return;
9193                }
9194            };
9195
9196            if !gl_proc.supported_render_dtypes().f16 {
9197                eprintln!("SKIPPED: convert_f16_gl_cpu_parity_identity - F16 render not supported");
9198                return;
9199            }
9200
9201            let mut dst = TensorDyn::image(
9202                W,
9203                H,
9204                PixelFormat::PlanarRgb,
9205                DType::F16,
9206                Some(TensorMemory::Mem),
9207            )
9208            .unwrap();
9209            match gl_proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default()) {
9210                Ok(()) => dst,
9211                Err(e) => {
9212                    eprintln!(
9213                        "SKIPPED: convert_f16_gl_cpu_parity_identity - GL convert failed: {e}"
9214                    );
9215                    return;
9216                }
9217            }
9218        };
9219
9220        // CPU path.
9221        let cpu_result = {
9222            let mut cpu_proc = ImageProcessor::with_config(ImageProcessorConfig {
9223                backend: ComputeBackend::Cpu,
9224                ..Default::default()
9225            })
9226            .unwrap();
9227            let mut dst = TensorDyn::image(
9228                W,
9229                H,
9230                PixelFormat::PlanarRgb,
9231                DType::F16,
9232                Some(TensorMemory::Mem),
9233            )
9234            .unwrap();
9235            cpu_proc
9236                .convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
9237                .expect("forced-CPU Rgba→PlanarRgb F16 must not error");
9238            dst
9239        };
9240
9241        // Compare element-wise.
9242        let gl_map = gl_result.as_f16().unwrap().map().unwrap();
9243        let cpu_map = cpu_result.as_f16().unwrap().map().unwrap();
9244        let gl_halfs = gl_map.as_slice();
9245        let cpu_halfs = cpu_map.as_slice();
9246
9247        assert_eq!(
9248            gl_halfs.len(),
9249            cpu_halfs.len(),
9250            "GL and CPU output sizes differ"
9251        );
9252
9253        let plane = W * H;
9254        let channel_names = ["R", "G", "B"];
9255        for (idx, (gl_h, cpu_h)) in gl_halfs.iter().zip(cpu_halfs.iter()).enumerate() {
9256            let gl_v = gl_h.to_f32();
9257            let cpu_v = cpu_h.to_f32();
9258            let err = (gl_v - cpu_v).abs();
9259            let ch = channel_names[idx / plane];
9260            let pixel = idx % plane;
9261            assert!(
9262                err <= TOL,
9263                "GL vs CPU mismatch at {ch}[{pixel}]: GL={gl_v}, CPU={cpu_v}, err={err} > tol={TOL}"
9264            );
9265        }
9266    }
9267
9268    // =========================================================================
9269    // GAP-1: supported_render_dtypes() Linux smoke test
9270    // =========================================================================
9271
9272    /// Exercises the real Linux GL path that reads `gl.supported_render_dtypes()`.
9273    /// Skipped when no GL backend is available (CI/host without a GPU).
9274    #[test]
9275    #[cfg(all(target_os = "linux", feature = "opengl"))]
9276    fn supported_render_dtypes_linux_smoke() {
9277        let proc = match ImageProcessor::new() {
9278            Ok(p) => p,
9279            Err(e) => {
9280                eprintln!("SKIPPED: supported_render_dtypes_linux_smoke — ImageProcessor::new() failed: {e}");
9281                return;
9282            }
9283        };
9284        if proc.opengl.is_none() {
9285            eprintln!("SKIPPED: supported_render_dtypes_linux_smoke — no GL backend on this host");
9286            return;
9287        }
9288        // The call must complete without panicking or deadlocking.
9289        let support = proc.supported_render_dtypes();
9290        eprintln!(
9291            "supported_render_dtypes_linux_smoke: f16={} f32={}",
9292            support.f16, support.f32
9293        );
9294        // No assertion on the specific values — they are hardware-dependent.
9295    }
9296
9297    // =========================================================================
9298    // GAP-2: F16 PlanarRgb with width NOT divisible by 4 falls back to CPU
9299    // =========================================================================
9300
9301    /// The GL float render path rejects PlanarRgb F16 destinations whose width
9302    /// is not a multiple of 4 (the packed RGBA16F swizzle trick requires W%4==0).
9303    /// The auto-chain must transparently fall through to the CPU path, which has
9304    /// no such restriction.  W=18, H=16 is chosen so W%4 == 2.
9305    #[test]
9306    fn convert_f16_pbo_non_4_aligned_width_falls_back() {
9307        const W: usize = 18; // 18 % 4 == 2 — NOT divisible by 4
9308        const H: usize = 16;
9309
9310        // RGBA8 source filled with a flat mid-grey.
9311        let src =
9312            TensorDyn::image(W, H, PixelFormat::Rgba, DType::U8, Some(TensorMemory::Mem)).unwrap();
9313        {
9314            let mut map = src.as_u8().unwrap().map().unwrap();
9315            let data = map.as_mut_slice();
9316            for chunk in data.chunks_exact_mut(4) {
9317                chunk[0] = 128;
9318                chunk[1] = 64;
9319                chunk[2] = 200;
9320                chunk[3] = 255;
9321            }
9322        }
9323
9324        // F16 PlanarRgb destination in Mem (GL would use PBO, but we want
9325        // to exercise the fallback chain without hardware dependency).
9326        let mut dst = TensorDyn::image(
9327            W,
9328            H,
9329            PixelFormat::PlanarRgb,
9330            DType::F16,
9331            Some(TensorMemory::Mem),
9332        )
9333        .unwrap();
9334
9335        // Use the default auto-chain so the GL path can attempt and reject,
9336        // then the CPU path succeeds.
9337        let mut proc = ImageProcessor::new().unwrap();
9338        let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
9339        assert!(
9340            result.is_ok(),
9341            "auto-chain PlanarRgb F16 W%4!=0 must not error (CPU fallback): {:?}",
9342            result.err()
9343        );
9344
9345        // All output values must be finite and in [0, 1].
9346        let map = dst.as_f16().unwrap().map().unwrap();
9347        let halfs = map.as_slice();
9348        assert_eq!(halfs.len(), W * H * 3, "unexpected element count");
9349        for (i, h) in halfs.iter().enumerate() {
9350            let v = h.to_f32();
9351            assert!(
9352                v.is_finite() && (0.0..=1.0).contains(&v),
9353                "output[{i}]={v} is not finite or not in [0,1]"
9354            );
9355        }
9356    }
9357
9358    // =========================================================================
9359    // GAP-4: NV12 → Rgb F32 and NV12 → PlanarRgb F16, forced CPU
9360    // =========================================================================
9361
9362    /// CPU widen-composition: NV12 (non-RGBA source) → Rgb F32.
9363    ///
9364    /// NV12 requires a two-stage CPU conversion (NV12→Rgba then Rgba→F32)
9365    /// which was untested. A wrong intermediate format selection silently
9366    /// produces garbage. Y=128, U=V=128 → near-neutral grey → R≈G≈B≈0.5.
9367    #[test]
9368    // Linux-only `egl_display` field makes `..Default::default()` needless
9369    // on macOS only; see `convert_f16_forced_cpu_correct`.
9370    #[allow(clippy::needless_update)]
9371    fn convert_nv12_to_rgb_f32_cpu() {
9372        const W: usize = 16;
9373        const H: usize = 16; // must be even for NV12
9374
9375        // Build a valid NV12 tensor: shape [H*3/2, W], luma=128, chroma=128.
9376        let src =
9377            TensorDyn::image(W, H, PixelFormat::Nv12, DType::U8, Some(TensorMemory::Mem)).unwrap();
9378        {
9379            let mut map = src.as_u8().unwrap().map().unwrap();
9380            map.as_mut_slice().fill(128); // Y=128, U=V=128 → neutral grey
9381        }
9382
9383        let mut dst =
9384            TensorDyn::image(W, H, PixelFormat::Rgb, DType::F32, Some(TensorMemory::Mem)).unwrap();
9385
9386        let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
9387            backend: ComputeBackend::Cpu,
9388            ..Default::default()
9389        })
9390        .unwrap();
9391
9392        let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
9393        assert!(
9394            result.is_ok(),
9395            "forced-CPU NV12→Rgb F32 must not error: {:?}",
9396            result.err()
9397        );
9398
9399        let map = dst.as_f32().unwrap().map().unwrap();
9400        let floats = map.as_slice();
9401        assert_eq!(floats.len(), W * H * 3, "unexpected element count");
9402        for (i, &v) in floats.iter().enumerate() {
9403            assert!(
9404                v.is_finite() && (0.0..=1.0).contains(&v),
9405                "output[{i}]={v} is not finite or not in [0,1]"
9406            );
9407        }
9408        // Anti-all-zero: Y=128 → luma channel ≈ 0.5 after YUV→RGB.
9409        let non_zero = floats.iter().any(|&v| v > 0.01);
9410        assert!(non_zero, "all-zero output from NV12→Rgb F32 CPU path");
9411    }
9412
9413    /// CPU widen-composition: NV12 (non-RGBA source) → PlanarRgb F16.
9414    ///
9415    /// Same rationale as `convert_nv12_to_rgb_f32_cpu` but for F16 output.
9416    #[test]
9417    // Linux-only `egl_display` field makes `..Default::default()` needless
9418    // on macOS only; see `convert_f16_forced_cpu_correct`.
9419    #[allow(clippy::needless_update)]
9420    fn convert_nv12_to_planar_rgb_f16_cpu() {
9421        const W: usize = 16;
9422        const H: usize = 16;
9423
9424        let src =
9425            TensorDyn::image(W, H, PixelFormat::Nv12, DType::U8, Some(TensorMemory::Mem)).unwrap();
9426        {
9427            let mut map = src.as_u8().unwrap().map().unwrap();
9428            map.as_mut_slice().fill(128);
9429        }
9430
9431        let mut dst = TensorDyn::image(
9432            W,
9433            H,
9434            PixelFormat::PlanarRgb,
9435            DType::F16,
9436            Some(TensorMemory::Mem),
9437        )
9438        .unwrap();
9439
9440        let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
9441            backend: ComputeBackend::Cpu,
9442            ..Default::default()
9443        })
9444        .unwrap();
9445
9446        let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
9447        assert!(
9448            result.is_ok(),
9449            "forced-CPU NV12→PlanarRgb F16 must not error: {:?}",
9450            result.err()
9451        );
9452
9453        let map = dst.as_f16().unwrap().map().unwrap();
9454        let halfs = map.as_slice();
9455        assert_eq!(halfs.len(), W * H * 3, "unexpected element count");
9456        for (i, h) in halfs.iter().enumerate() {
9457            let v = h.to_f32();
9458            assert!(
9459                v.is_finite() && (0.0..=1.0).contains(&v),
9460                "output[{i}]={v} is not finite or not in [0,1]"
9461            );
9462        }
9463        let non_zero = halfs.iter().any(|h| h.to_f32() > 0.01);
9464        assert!(non_zero, "all-zero output from NV12→PlanarRgb F16 CPU path");
9465    }
9466
9467    // =========================================================================
9468    // GAP-5: create_image F32 + DMA must return NotSupported
9469    // =========================================================================
9470
9471    /// There is no DRM FourCC for F32 images, so `create_image` with
9472    /// `TensorMemory::Dma` and `DType::F32` must return `Err(NotSupported)`.
9473    #[test]
9474    #[cfg(target_os = "linux")]
9475    fn create_image_f32_dma_rejected() {
9476        let proc = ImageProcessor::new().unwrap();
9477        let result = proc.create_image(
9478            64,
9479            64,
9480            PixelFormat::Rgb,
9481            DType::F32,
9482            Some(TensorMemory::Dma),
9483        );
9484        assert!(
9485            result.is_err(),
9486            "create_image(F32, Dma) must fail — no DRM fourcc for f32"
9487        );
9488    }
9489
9490    /// Verify that `import_image` stores the supplied `Option<Colorimetry>` on
9491    /// the returned `TensorDyn`.
9492    ///
9493    /// `import_image` requires a DMA-backed fd on Linux.  When DMA is
9494    /// unavailable we skip the DMA call but still verify the storage contract
9495    /// by inspecting `set_colorimetry` / `colorimetry` on a plain `TensorDyn`
9496    /// constructed the same way the function body does it — and we confirm via
9497    /// code-read that the new parameter is unconditionally stored.
9498    #[test]
9499    #[cfg(target_os = "linux")]
9500    fn import_image_carries_colorimetry() {
9501        use edgefirst_tensor::{ColorEncoding, ColorRange, Colorimetry, TensorMemory};
9502
9503        let expected = Colorimetry::default()
9504            .with_encoding(ColorEncoding::Bt709)
9505            .with_range(ColorRange::Limited);
9506
9507        if !is_dma_available() {
9508            // DMA unavailable on this host: exercise the storage path via
9509            // TensorDyn directly (mirrors what import_image does internally).
9510            let mut t =
9511                TensorDyn::image(8, 8, PixelFormat::Rgba, DType::U8, Some(TensorMemory::Mem))
9512                    .expect("alloc");
9513            assert_eq!(t.colorimetry(), None, "colorimetry must start as None");
9514            t.set_colorimetry(Some(expected));
9515            assert_eq!(
9516                t.colorimetry(),
9517                Some(expected),
9518                "set_colorimetry must round-trip"
9519            );
9520            eprintln!("SKIPPED import_image_carries_colorimetry (DMA unavailable); storage contract verified via TensorDyn");
9521            return;
9522        }
9523
9524        // DMA is available: allocate a real DMA tensor, extract its fd, and
9525        // call import_image with an explicit Colorimetry.
9526        use edgefirst_tensor::{PlaneDescriptor, Tensor};
9527
9528        let rgba_bytes = 64 * 64 * 4; // 64×64 RGBA8
9529        let dma_tensor =
9530            Tensor::<u8>::new(&[rgba_bytes], Some(TensorMemory::Dma), Some("import_test"))
9531                .expect("dma alloc");
9532        let pd =
9533            PlaneDescriptor::new(dma_tensor.dmabuf().expect("dma fd")).expect("PlaneDescriptor");
9534
9535        let proc = ImageProcessor::new().expect("ImageProcessor");
9536        let result = proc.import_image(
9537            pd,
9538            None,
9539            64,
9540            64,
9541            PixelFormat::Rgba,
9542            DType::U8,
9543            Some(expected),
9544        );
9545        let tensor = result.expect("import_image must succeed on DMA fd");
9546        assert_eq!(
9547            tensor.colorimetry(),
9548            Some(expected),
9549            "import_image must store the supplied colorimetry on the returned TensorDyn"
9550        );
9551    }
9552}