Skip to main content

edgefirst_image/
lib.rs

1// SPDX-FileCopyrightText: Copyright 2025 Au-Zone Technologies
2// SPDX-License-Identifier: Apache-2.0
3
4/*!
5
6## EdgeFirst HAL - Image Converter
7
8The `edgefirst_image` crate is part of the EdgeFirst Hardware Abstraction
9Layer (HAL) and provides functionality for converting images between
10different formats and sizes.  The crate is designed to work with hardware
11acceleration when available, but also provides a CPU-based fallback for
12environments where hardware acceleration is not present or not suitable.
13
14The main features of the `edgefirst_image` crate include:
15- Support for various image formats, including YUYV, RGB, RGBA, and GREY.
16- Support for source crop, destination crop, rotation, and flipping.
17- Image conversion using hardware acceleration (G2D, OpenGL) when available.
18- CPU-based image conversion as a fallback option.
19
20The crate uses [`TensorDyn`] from `edgefirst_tensor` to represent images,
21with [`PixelFormat`] metadata describing the pixel layout. The
22[`ImageProcessor`] struct manages the conversion process, selecting
23the appropriate conversion method based on the available hardware.
24
25## Examples
26
27```rust
28# use edgefirst_image::{ImageProcessor, Rotation, Flip, Crop, ImageProcessorTrait};
29# use edgefirst_codec::{peek_info, ImageDecoder, ImageLoad};
30# use edgefirst_tensor::{CpuAccess, PixelFormat, DType, Tensor, TensorMemory};
31# fn main() -> Result<(), edgefirst_image::Error> {
32let image = edgefirst_bench::testdata::read("zidane.jpg");
33// The codec emits the source's native format (a colour JPEG decodes to NV12)
34// and configures the destination tensor's dims+format during the decode.
35let info = peek_info(&image).expect("peek");
36// Heap tensors are CPU-touched on both sides (codec decode writes, the
37// engine's upload/CPU-fallback paths read), so declare ReadWrite. Pure
38// hardware pipelines allocate their convert destinations with
39// `CpuAccess::None` instead.
40let mut src = Tensor::<u8>::image(info.width, info.height, info.format,
41                                   Some(TensorMemory::Mem), CpuAccess::ReadWrite)?;
42let mut decoder = ImageDecoder::new();
43src.load_image(&mut decoder, &image).expect("decode");
44// Convert the native NV12 frame to packed RGB for downstream processing.
45let mut converter = ImageProcessor::new()?;
46let mut dst =
47    converter.create_image(640, 480, PixelFormat::Rgb, DType::U8, None, CpuAccess::ReadWrite)?;
48converter.convert(&src.into(), &mut dst, Rotation::None, Flip::None, Crop::default())?;
49# Ok(())
50# }
51```
52
53## Environment Variables
54The behavior of the `edgefirst_image::ImageProcessor` struct can be influenced by the
55following environment variables:
56- `EDGEFIRST_FORCE_BACKEND`: When set to `cpu`, `g2d`, or `opengl` (case-insensitive),
57  only that single backend is initialized and no fallback chain is used. If the
58  forced backend fails to initialize, an error is returned immediately. This is
59  useful for benchmarking individual backends in isolation. When this variable is
60  set, the `EDGEFIRST_DISABLE_*` variables are ignored.
61- `EDGEFIRST_DISABLE_GL`: If set to `1`, disables the use of OpenGL for image
62  conversion, forcing the use of CPU or other available hardware methods.
63- `EDGEFIRST_DISABLE_G2D`: If set to `1`, disables the use of G2D for image
64  conversion, forcing the use of CPU or other available hardware methods.
65- `EDGEFIRST_DISABLE_CPU`: If set to `1`, disables the use of CPU for image
66  conversion, forcing the use of hardware acceleration methods. If no hardware
67  acceleration methods are available, an error will be returned when attempting
68  to create an `ImageProcessor`.
69
70Additionally the TensorMemory used by default allocations can be controlled using the
71`EDGEFIRST_TENSOR_FORCE_MEM` environment variable. If set to `1`, default tensor memory
72uses system memory. This will disable the use of specialized memory regions for tensors
73and hardware acceleration. However, this will increase the performance of the CPU converter.
74*/
75#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
76
77/// Retained constructor: installs the coverage flush-on-abort handler for this
78/// crate's instrumented test binary. See `edgefirst_tensor::covguard`. Only
79/// present under coverage on Linux (`.init_array` is ELF-only; flush is Linux-only).
80#[cfg(all(coverage, target_os = "linux"))]
81#[used]
82#[link_section = ".init_array"]
83static __EDGEFIRST_COV_INSTALL: extern "C" fn() = {
84    extern "C" fn ctor() {
85        edgefirst_tensor::covguard::install();
86    }
87    ctor
88};
89
90/// Pitch alignment requirement for DMA-BUF tensors that may be imported as
91/// EGLImages by the GL backend. Mali Valhall (i.MX 95 / G310) rejects
92/// `eglCreateImageKHR` with `EGL_BAD_ALLOC` for any DMA-BUF whose row pitch
93/// is not a multiple of 64 bytes; Vivante GC7000UL (i.MX 8MP) accepts any
94/// pitch so the constant is harmless on that path. 64 is the smallest
95/// alignment that satisfies every embedded ARM GPU we ship to.
96///
97/// Applied automatically inside [`ImageProcessor::create_image`] when the
98/// allocation lands on `TensorMemory::Dma`. External callers that allocate
99/// their own DMA-BUF tensors (e.g. GStreamer plugins, video pipelines) can
100/// use [`align_width_for_gpu_pitch`] to compute a width whose resulting row
101/// stride satisfies this requirement.
102pub const GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES: usize = 64;
103
104/// Round `width` (in pixels) up so the resulting row stride
105/// `width * bpp` is a multiple of [`GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES`]
106/// AND a multiple of `bpp` (so the rounded width is an integer pixel count).
107///
108/// `bpp` must be the per-pixel byte count for the image's primary plane
109/// (e.g. 4 for RGBA8/BGRA8, 3 for RGB888, 1 for Grey/NV12-luma).
110///
111/// External callers — GStreamer plugins, video pipelines, anyone wrapping a
112/// foreign DMA-BUF — should call this when sizing the destination so that
113/// `eglCreateImageKHR` doesn't reject the import on Mali. Pre-aligned widths
114/// (640, 1280, 1920, 3008, 3840 …) round-trip unchanged; misaligned widths
115/// are bumped up to the next valid value.
116///
117/// # Overflow behaviour
118///
119/// All arithmetic is checked. If the alignment computation or the rounded
120/// width would overflow `usize`, the function logs a warning and returns the
121/// original `width` unchanged rather than wrapping or producing a smaller
122/// value. Callers can rely on the returned width being **at least** the
123/// requested width.
124///
125/// `bpp == 0` and `width == 0` short-circuit to return the input unchanged.
126///
127/// # Examples
128///
129/// ```
130/// use edgefirst_image::align_width_for_gpu_pitch;
131///
132/// // RGBA8 (bpp=4): width must round to a multiple of 16 pixels (64-byte stride).
133/// assert_eq!(align_width_for_gpu_pitch(1920, 4), 1920); // already aligned
134/// assert_eq!(align_width_for_gpu_pitch(3004, 4), 3008); // crowd.png case: +4 px
135/// assert_eq!(align_width_for_gpu_pitch(1281, 4), 1296); // +15 px
136///
137/// // RGB888 (bpp=3): width must round to a multiple of 64 pixels (192-byte stride).
138/// assert_eq!(align_width_for_gpu_pitch(640, 3), 640);
139/// assert_eq!(align_width_for_gpu_pitch(641, 3), 704);
140/// ```
141pub fn align_width_for_gpu_pitch(width: usize, bpp: usize) -> usize {
142    if bpp == 0 || width == 0 {
143        return width;
144    }
145
146    // The minimum aligned stride must be a common multiple of both the
147    // GPU's pitch alignment and the per-pixel byte count. Using the LCM
148    // guarantees the rounded stride is an integer multiple of `bpp`, so
149    // converting back to a pixel count is exact.
150    //
151    // Compute the alignment in pixels (`width_alignment`) so we never need
152    // to multiply `width * bpp`, which is the only operation that could
153    // realistically overflow for large caller-supplied widths.
154    let Some(lcm_alignment) = checked_num_integer_lcm(GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES, bpp)
155    else {
156        log::warn!(
157            "align_width_for_gpu_pitch: lcm({GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES}, {bpp}) \
158             overflows usize, returning unaligned width {width}"
159        );
160        return width;
161    };
162    if lcm_alignment == 0 {
163        return width;
164    }
165
166    debug_assert_eq!(lcm_alignment % bpp, 0);
167    let width_alignment = lcm_alignment / bpp;
168    if width_alignment == 0 {
169        return width;
170    }
171
172    let remainder = width % width_alignment;
173    if remainder == 0 {
174        return width;
175    }
176
177    let pad = width_alignment - remainder;
178    match width.checked_add(pad) {
179        Some(aligned) => aligned,
180        None => {
181            log::warn!(
182                "align_width_for_gpu_pitch: width {width} + pad {pad} overflows usize, \
183                 returning unaligned (caller should use a smaller width or pre-aligned size)"
184            );
185            width
186        }
187    }
188}
189
190/// Round `min_pitch_bytes` up to the next multiple of
191/// [`GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES`]. Returns `None` if the rounded
192/// value would overflow `usize`. Returns `Some(0)` for input 0.
193///
194/// Used internally by [`ImageProcessor::create_image`] to compute the
195/// padded row stride for DMA-backed image allocations. External callers
196/// that need pixel-counted alignment (instead of raw byte pitch) should
197/// use [`align_width_for_gpu_pitch`] instead.
198#[cfg(target_os = "linux")]
199pub(crate) fn align_pitch_bytes_to_gpu_alignment(min_pitch_bytes: usize) -> Option<usize> {
200    let alignment = GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES;
201    if min_pitch_bytes == 0 {
202        return Some(0);
203    }
204    let remainder = min_pitch_bytes % alignment;
205    if remainder == 0 {
206        return Some(min_pitch_bytes);
207    }
208    min_pitch_bytes.checked_add(alignment - remainder)
209}
210
211/// Overflow-safe least common multiple. Returns `None` when `(a / gcd) * b`
212/// would wrap.
213fn checked_num_integer_lcm(a: usize, b: usize) -> Option<usize> {
214    if a == 0 || b == 0 {
215        return Some(0);
216    }
217    let g = num_integer_gcd(a, b);
218    // a / g is exact (g divides a by definition) and at most a, so this
219    // division never panics. Only the subsequent multiply can overflow.
220    (a / g).checked_mul(b)
221}
222
223fn num_integer_gcd(a: usize, b: usize) -> usize {
224    if b == 0 {
225        a
226    } else {
227        num_integer_gcd(b, a % b)
228    }
229}
230
231/// Bytes-per-pixel for the primary plane of `format` at element size `elem`.
232/// Returns `None` for formats that don't have a single packed BPP (semi-planar
233/// chroma is handled separately, returning the luma-plane bpp).
234///
235/// External callers can use this together with [`align_width_for_gpu_pitch`]
236/// to size their own DMA-BUFs without having to remember per-format BPPs:
237///
238/// ```
239/// use edgefirst_image::{align_width_for_gpu_pitch, primary_plane_bpp};
240/// use edgefirst_tensor::PixelFormat;
241///
242/// let bpp = primary_plane_bpp(PixelFormat::Rgba, 1).unwrap();
243/// let aligned = align_width_for_gpu_pitch(3004, bpp);
244/// assert_eq!(aligned, 3008);
245/// ```
246pub fn primary_plane_bpp(format: PixelFormat, elem: usize) -> Option<usize> {
247    use edgefirst_tensor::PixelLayout;
248    match format.layout() {
249        PixelLayout::Packed => Some(format.channels() * elem),
250        PixelLayout::Planar => Some(elem),
251        // For NV12/NV16 the luma plane is single-channel so the pitch
252        // matches `elem`; the chroma plane uses the same pitch in bytes
253        // (UV is half-width but two interleaved channels = same pitch).
254        PixelLayout::SemiPlanar => Some(elem),
255        // `PixelLayout` is non-exhaustive — fall through unaligned for
256        // any future variant we don't yet recognise.
257        _ => None,
258    }
259}
260
261/// Return the GPU-aligned pitch in bytes when a DMA-backed image of
262/// `width × fmt` would need row-stride padding, or `None` when the
263/// natural pitch already satisfies `GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES`
264/// or the caller has explicitly requested non-DMA memory.
265///
266/// Mali G310 (i.MX 95) rejects `eglCreateImage` from DMA-BUFs whose
267/// `PLANE0_PITCH_EXT` is not a multiple of 64 bytes, surfacing as
268/// `EGL_BAD_ALLOC`. The `load_image_test_helper` test-only helper
269/// in this crate uses this to decide whether to allocate a tensor
270/// with padded row stride before invoking the decode path; production
271/// callers do the equivalent peek → allocate → decode dance themselves
272/// (see crate-level docs).
273#[cfg(all(target_os = "linux", test))]
274pub(crate) fn padded_dma_pitch_for(
275    fmt: PixelFormat,
276    width: usize,
277    memory: &Option<TensorMemory>,
278) -> Option<usize> {
279    // Only pad when the caller explicitly requested DMA, or when they
280    // left memory selection to the allocator AND DMA is actually
281    // available. `Tensor::image_with_stride(..., None)` always routes
282    // through DMA allocation, so treating `None` as "DMA wanted"
283    // unconditionally would convert a normally-working image load into
284    // a hard failure on systems where DMA is unavailable (sandboxed
285    // CI, missing `/dev/dma_heap`, permission-denied containers) —
286    // whereas `Tensor::image(..., None)` would have fallen back to
287    // SHM/Mem there.
288    match memory {
289        Some(TensorMemory::Dma) => {}
290        None if edgefirst_tensor::is_dma_available() => {}
291        _ => return None,
292    }
293    // Padding only applies to packed layouts — `Tensor::image_with_stride`
294    // rejects semi-planar / planar formats, and those take their own
295    // per-plane pitches on import anyway.
296    if fmt.layout() != PixelLayout::Packed {
297        return None;
298    }
299    let bpp = primary_plane_bpp(fmt, 1)?;
300    let natural = width.checked_mul(bpp)?;
301    let aligned = align_pitch_bytes_to_gpu_alignment(natural)?;
302    if aligned > natural {
303        Some(aligned)
304    } else {
305        None
306    }
307}
308
309pub use cpu::CPUProcessor;
310pub use edgefirst_codec as codec;
311
312#[cfg(test)]
313use edgefirst_decoder::ProtoLayout;
314use edgefirst_decoder::{DetectBox, ProtoData, Segmentation};
315#[doc(inline)]
316pub use edgefirst_tensor::Region;
317#[cfg(any(test, all(target_os = "linux", feature = "opengl")))]
318use edgefirst_tensor::Tensor;
319use edgefirst_tensor::{
320    DType, PixelFormat, PixelLayout, TensorDyn, TensorMemory, TensorTrait as _,
321};
322use enum_dispatch::enum_dispatch;
323pub use error::{Error, Result};
324#[cfg(target_os = "linux")]
325pub use g2d::G2DProcessor;
326#[cfg(all(
327    any(
328        target_os = "linux",
329        target_os = "macos",
330        target_os = "ios",
331        target_os = "android"
332    ),
333    feature = "opengl"
334))]
335pub use opengl_headless::EglDisplayKind;
336#[cfg(all(
337    any(
338        target_os = "linux",
339        target_os = "macos",
340        target_os = "ios",
341        target_os = "android"
342    ),
343    feature = "opengl"
344))]
345pub use opengl_headless::GLProcessorThreaded;
346#[cfg(all(
347    any(
348        target_os = "linux",
349        target_os = "macos",
350        target_os = "ios",
351        target_os = "android"
352    ),
353    feature = "opengl"
354))]
355pub use opengl_headless::Int8InterpolationMode;
356#[cfg(target_os = "linux")]
357#[cfg(feature = "opengl")]
358pub use opengl_headless::{probe_egl_displays, EglDisplayInfo};
359// EGLImage cache counter snapshots (diagnostics): see
360// `GLProcessorThreaded::egl_cache_stats` and the steady-state import gate in
361// `crates/image/ARCHITECTURE.md § image.convert.gl.egl_import`.
362#[cfg(all(
363    any(
364        target_os = "linux",
365        target_os = "macos",
366        target_os = "ios",
367        target_os = "android"
368    ),
369    feature = "opengl"
370))]
371pub use opengl_headless::{CacheStats, ConvertStats, GlCacheStats};
372use std::{fmt::Display, time::Instant};
373
374mod colorimetry;
375mod cpu;
376mod error;
377mod g2d;
378#[path = "gl/mod.rs"]
379mod opengl_headless;
380
381// Use `edgefirst_tensor::PixelFormat` variants (Rgb, Rgba, Grey, etc.) and
382// `TensorDyn` / `Tensor<u8>` with `.format()` metadata instead.
383
384#[derive(Debug, Clone, Copy, PartialEq, Eq)]
385pub enum Rotation {
386    None = 0,
387    Clockwise90 = 1,
388    Rotate180 = 2,
389    CounterClockwise90 = 3,
390}
391impl Rotation {
392    /// Creates a Rotation enum from an angle in degrees. The angle must be a
393    /// multiple of 90.
394    ///
395    /// # Panics
396    /// Panics if the angle is not a multiple of 90.
397    ///
398    /// # Examples
399    /// ```rust
400    /// # use edgefirst_image::Rotation;
401    /// let rotation = Rotation::from_degrees_clockwise(270);
402    /// assert_eq!(rotation, Rotation::CounterClockwise90);
403    /// ```
404    pub fn from_degrees_clockwise(angle: usize) -> Rotation {
405        match angle.rem_euclid(360) {
406            0 => Rotation::None,
407            90 => Rotation::Clockwise90,
408            180 => Rotation::Rotate180,
409            270 => Rotation::CounterClockwise90,
410            _ => panic!("rotation angle is not a multiple of 90"),
411        }
412    }
413}
414
415#[derive(Debug, Clone, Copy, PartialEq, Eq)]
416pub enum Flip {
417    None = 0,
418    Vertical = 1,
419    Horizontal = 2,
420}
421
422/// Controls how the color palette index is chosen for each detected object.
423#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
424pub enum ColorMode {
425    /// Color is chosen by object class label (`det.label`). Default.
426    ///
427    /// Preserves backward compatibility and is correct for semantic
428    /// segmentation where colors carry class meaning.
429    #[default]
430    Class,
431    /// Color is chosen by instance order (loop index, zero-based).
432    ///
433    /// Each detected object gets a unique color regardless of class,
434    /// useful for instance segmentation.
435    Instance,
436    /// Color is chosen by track ID (future use; currently behaves like
437    /// [`Instance`](Self::Instance)).
438    Track,
439}
440
441impl ColorMode {
442    /// Return the palette index for a detection given its loop index and label.
443    #[inline]
444    pub fn index(self, idx: usize, label: usize) -> usize {
445        match self {
446            ColorMode::Class => label,
447            ColorMode::Instance | ColorMode::Track => idx,
448        }
449    }
450}
451
452/// Controls the resolution and coordinate frame of masks produced by
453/// [`ImageProcessor::materialize_masks`].
454///
455/// - [`Proto`](Self::Proto) returns per-detection tiles at proto-plane
456///   resolution (e.g. 48×32 u8 for a typical COCO bbox on a 160×160 proto
457///   plane). This is the historical behavior of `materialize_masks` and the
458///   fastest path because no upsample runs inside HAL. Mask values are
459///   continuous sigmoid output quantized to `uint8 [0, 255]`.
460/// - [`Scaled`](Self::Scaled) returns per-detection tiles at caller-specified
461///   pixel resolution by upsampling the full proto plane once and cropping by
462///   bbox after sigmoid. The upsample uses bilinear interpolation with
463///   edge-clamp sampling — semantically equivalent to Ultralytics'
464///   `process_masks_retina` reference. When a `letterbox` is also passed to
465///   [`materialize_masks`], the inverse letterbox transform is applied during
466///   the upsample so mask pixels land in original-content coordinates
467///   (drop-in for overlay on the original image). Mask values are binary
468///   `uint8 {0, 255}` after thresholding sigmoid > 0.5 — interchangeable
469///   with `Proto` output via the same `> 127` test.
470///
471/// [`materialize_masks`]: ImageProcessor::materialize_masks
472#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
473pub enum MaskResolution {
474    /// Per-detection tile at proto-plane resolution (default).
475    #[default]
476    Proto,
477    /// Per-detection tile at `(width, height)` pixel resolution in the
478    /// coordinate frame determined by the `letterbox` parameter of
479    /// [`ImageProcessor::materialize_masks`].
480    Scaled {
481        /// Target pixel width of the output coordinate frame.
482        width: u32,
483        /// Target pixel height of the output coordinate frame.
484        height: u32,
485    },
486}
487
488/// Options for mask overlay rendering.
489///
490/// Controls how segmentation masks are composited onto the destination image:
491/// - `background`: when set, the background image is drawn first and masks
492///   are composited over it (result written to `dst`). When `None`, `dst` is
493///   cleared to `0x00000000` (fully transparent) before masks are drawn.
494///   **`dst` is always fully overwritten — its prior contents are never
495///   preserved.** Callers who used to pre-load an image into `dst` before
496///   calling `draw_decoded_masks` / `draw_proto_masks` must now supply that
497///   image via `background` instead (behaviour changed in v0.16.4).
498/// - `opacity`: scales the alpha of rendered mask colors. `1.0` (default)
499///   preserves the class color's alpha unchanged; `0.5` makes masks
500///   semi-transparent.
501/// - `color_mode`: controls whether colors are assigned by class label,
502///   instance index, or track ID. Defaults to [`ColorMode::Class`].
503#[derive(Debug, Clone, Copy)]
504pub struct MaskOverlay<'a> {
505    /// Compositing source image. Must have the same dimensions and pixel
506    /// format as `dst`. When `Some`, the output is `background + masks`.
507    /// When `None`, `dst` is cleared to `0x00000000` before masks are drawn.
508    pub background: Option<&'a TensorDyn>,
509    pub opacity: f32,
510    /// Normalized letterbox region `[xmin, ymin, xmax, ymax]` in model-input
511    /// space that contains actual image content (the rest is padding).
512    ///
513    /// When set, bounding boxes and mask coordinates from the decoder (which
514    /// are in model-input normalized space) are mapped back to the original
515    /// image coordinate space before rendering.
516    ///
517    /// Use [`with_letterbox_crop`](Self::with_letterbox_crop) to compute this
518    /// from the [`Crop`] that was used in the model input [`convert`](crate::ImageProcessorTrait::convert) call.
519    pub letterbox: Option<[f32; 4]>,
520    pub color_mode: ColorMode,
521}
522
523impl Default for MaskOverlay<'_> {
524    fn default() -> Self {
525        Self {
526            background: None,
527            opacity: 1.0,
528            letterbox: None,
529            color_mode: ColorMode::Class,
530        }
531    }
532}
533
534impl<'a> MaskOverlay<'a> {
535    pub fn new() -> Self {
536        Self::default()
537    }
538
539    /// Set the compositing source image.
540    ///
541    /// `bg` must have the same dimensions and pixel format as the `dst` passed
542    /// to [`draw_decoded_masks`](crate::ImageProcessorTrait::draw_decoded_masks) /
543    /// [`draw_proto_masks`](crate::ImageProcessorTrait::draw_proto_masks).
544    /// The output will be `bg + masks`. Without a background, `dst` is cleared
545    /// to `0x00000000`.
546    pub fn with_background(mut self, bg: &'a TensorDyn) -> Self {
547        self.background = Some(bg);
548        self
549    }
550
551    pub fn with_opacity(mut self, opacity: f32) -> Self {
552        self.opacity = opacity.clamp(0.0, 1.0);
553        self
554    }
555
556    pub fn with_color_mode(mut self, mode: ColorMode) -> Self {
557        self.color_mode = mode;
558        self
559    }
560
561    /// Set the letterbox transform from the [`Crop`] used when preparing the
562    /// model input, so that bounding boxes and masks are correctly mapped back
563    /// to the original image coordinate space during rendering.
564    ///
565    /// Pass the same `crop` that was given to
566    /// [`convert`](crate::ImageProcessorTrait::convert) along with the model
567    /// input dimensions (`model_w` × `model_h`).
568    ///
569    /// Has no effect when `crop.dst_rect` is `None` (no letterbox applied).
570    pub fn with_letterbox_crop(
571        mut self,
572        crop: &Crop,
573        src_w: usize,
574        src_h: usize,
575        model_w: usize,
576        model_h: usize,
577    ) -> Self {
578        // The letterbox placement is resolved from the same source/destination
579        // dimensions `convert()` used, so the inverse map matches the render.
580        if let Ok(resolved) = crop.resolve(src_w, src_h, model_w, model_h) {
581            if let Some(r) = resolved.dst_rect {
582                self.letterbox = Some([
583                    r.left as f32 / model_w as f32,
584                    r.top as f32 / model_h as f32,
585                    (r.left + r.width) as f32 / model_w as f32,
586                    (r.top + r.height) as f32 / model_h as f32,
587                ]);
588            }
589        }
590        self
591    }
592}
593
594/// Apply the inverse letterbox transform to a bounding box.
595///
596/// `letterbox` is `[lx0, ly0, lx1, ly1]` — the normalized region of the model
597/// input that contains actual image content (output of
598/// [`MaskOverlay::with_letterbox_crop`]).
599///
600/// Converts model-input-normalized coords to output-image-normalized coords,
601/// clamped to `[0.0, 1.0]`. Also canonicalises the bbox (ensures xmin ≤ xmax).
602#[inline]
603fn unletter_bbox(bbox: DetectBox, lb: [f32; 4]) -> DetectBox {
604    let b = bbox.bbox.to_canonical();
605    let [lx0, ly0, lx1, ly1] = lb;
606    let inv_w = if lx1 > lx0 { 1.0 / (lx1 - lx0) } else { 1.0 };
607    let inv_h = if ly1 > ly0 { 1.0 / (ly1 - ly0) } else { 1.0 };
608    DetectBox {
609        bbox: edgefirst_decoder::BoundingBox {
610            xmin: ((b.xmin - lx0) * inv_w).clamp(0.0, 1.0),
611            ymin: ((b.ymin - ly0) * inv_h).clamp(0.0, 1.0),
612            xmax: ((b.xmax - lx0) * inv_w).clamp(0.0, 1.0),
613            ymax: ((b.ymax - ly0) * inv_h).clamp(0.0, 1.0),
614        },
615        ..bbox
616    }
617}
618
619/// How a source is fit into the requested destination shape.
620#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
621pub enum Fit {
622    /// Stretch the source (or source crop) to fill the whole destination.
623    #[default]
624    Stretch,
625    /// Preserve the *source* aspect ratio, centring it in the destination and
626    /// padding the remainder with `pad` (RGBA — e.g. `[114, 114, 114, 255]` for
627    /// YOLO-style preprocessing).
628    Letterbox { pad: [u8; 4] },
629}
630
631/// Source-side convert geometry: which sub-rectangle of the source to sample
632/// (`source`) and how to fit it into the destination (`fit`). Destination
633/// *placement* is the destination itself — a tensor, or a [`Region`] view /
634/// `batch` tile of one — not a field here.
635#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
636pub struct Crop {
637    /// Sub-rectangle of the source to sample. `None` samples the whole source.
638    pub source: Option<Region>,
639    /// How the source is fit into the destination shape.
640    pub fit: Fit,
641}
642
643impl Crop {
644    /// A no-op crop: whole source, stretched to fill the whole destination.
645    pub fn new() -> Self {
646        Self::default()
647    }
648
649    /// Alias for [`Crop::new`] — whole source, stretch to fill.
650    pub fn no_crop() -> Self {
651        Self::default()
652    }
653
654    /// Letterbox fit: preserve the source aspect ratio, padding the remainder
655    /// with `pad` (RGBA).
656    pub fn letterbox(pad: [u8; 4]) -> Self {
657        Self {
658            source: None,
659            fit: Fit::Letterbox { pad },
660        }
661    }
662
663    /// Sample only `source` of the input (builder).
664    pub fn with_source(mut self, source: Option<Region>) -> Self {
665        self.source = source;
666        self
667    }
668
669    /// Set the fit mode (builder).
670    pub fn with_fit(mut self, fit: Fit) -> Self {
671        self.fit = fit;
672        self
673    }
674
675    /// Resolve to the effective backend geometry for a `src_w`×`src_h` source
676    /// and `dst_w`×`dst_h` destination: the source sampling rect, the
677    /// destination placement rect (`None` = whole destination), and the pad
678    /// colour. **The single place letterbox placement is computed** — every
679    /// backend consumes the resolved rects rather than re-deriving them.
680    pub(crate) fn resolve(
681        &self,
682        src_w: usize,
683        src_h: usize,
684        dst_w: usize,
685        dst_h: usize,
686    ) -> Result<ResolvedCrop, Error> {
687        let src_rect = self.source.map(region_to_rect);
688        // The letterbox aspect uses the *effective* source content — the source
689        // crop when set, else the full source.
690        let (sw, sh) = match self.source {
691            Some(r) => (r.width, r.height),
692            None => (src_w, src_h),
693        };
694        let resolved = match self.fit {
695            Fit::Stretch => ResolvedCrop {
696                src_rect,
697                dst_rect: None,
698                dst_color: None,
699            },
700            Fit::Letterbox { pad } => ResolvedCrop {
701                src_rect,
702                dst_rect: Some(letterbox_rect(sw, sh, dst_w, dst_h)),
703                dst_color: Some(pad),
704            },
705        };
706        resolved.check_crop_dims(src_w, src_h, dst_w, dst_h)?;
707        Ok(resolved)
708    }
709
710    /// Validate against `TensorDyn` source and destination dimensions.
711    pub fn check_crop_dyn(
712        &self,
713        src: &edgefirst_tensor::TensorDyn,
714        dst: &edgefirst_tensor::TensorDyn,
715    ) -> Result<(), Error> {
716        self.resolve(
717            src.width().unwrap_or(0),
718            src.height().unwrap_or(0),
719            dst.width().unwrap_or(0),
720            dst.height().unwrap_or(0),
721        )
722        .map(|_| ())
723    }
724}
725
726/// Resolved crop geometry consumed by the backends. Produced by
727/// [`Crop::resolve`]; the backends read these fields directly (the same shape
728/// the public `Crop` carried before destination placement moved to the view).
729#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
730pub(crate) struct ResolvedCrop {
731    pub(crate) src_rect: Option<Rect>,
732    pub(crate) dst_rect: Option<Rect>,
733    pub(crate) dst_color: Option<[u8; 4]>,
734}
735
736impl ResolvedCrop {
737    /// A no-op resolved crop (whole source → whole destination, no pad).
738    #[allow(dead_code)] // used by unit tests and the batch render paths
739    pub(crate) fn no_crop() -> Self {
740        Self::default()
741    }
742
743    /// Validate the resolved rects against explicit dimensions.
744    pub(crate) fn check_crop_dims(
745        &self,
746        src_w: usize,
747        src_h: usize,
748        dst_w: usize,
749        dst_h: usize,
750    ) -> Result<(), Error> {
751        let src_ok = self
752            .src_rect
753            .is_none_or(|r| r.left + r.width <= src_w && r.top + r.height <= src_h);
754        let dst_ok = self
755            .dst_rect
756            .is_none_or(|r| r.left + r.width <= dst_w && r.top + r.height <= dst_h);
757        match (src_ok, dst_ok) {
758            (true, true) => Ok(()),
759            (true, false) => Err(Error::CropInvalid(format!(
760                "Dest crop invalid: {:?}",
761                self.dst_rect
762            ))),
763            (false, true) => Err(Error::CropInvalid(format!(
764                "Src crop invalid: {:?}",
765                self.src_rect
766            ))),
767            (false, false) => Err(Error::CropInvalid(format!(
768                "Dest and Src crop invalid: {:?} {:?}",
769                self.dst_rect, self.src_rect
770            ))),
771        }
772    }
773}
774
775/// Convert a pixel [`Region`] to the internal [`Rect`] placement type.
776fn region_to_rect(r: Region) -> Rect {
777    Rect {
778        left: r.x,
779        top: r.y,
780        width: r.width,
781        height: r.height,
782    }
783}
784
785/// Centred aspect-preserving placement of an `sw`×`sh` source within a `dw`×`dh`
786/// destination — the canonical letterbox rectangle (one home, replacing the
787/// per-caller `calculate_letterbox` copies).
788fn letterbox_rect(sw: usize, sh: usize, dw: usize, dh: usize) -> Rect {
789    if sw == 0 || sh == 0 {
790        return Rect::new(0, 0, dw, dh);
791    }
792    let src_aspect = sw as f64 / sh as f64;
793    let dst_aspect = dw as f64 / dh as f64;
794    let (new_w, new_h) = if src_aspect > dst_aspect {
795        (dw, ((dw as f64 / src_aspect).round() as usize).max(1))
796    } else {
797        (((dh as f64 * src_aspect).round() as usize).max(1), dh)
798    };
799    let left = dw.saturating_sub(new_w) / 2;
800    let top = dh.saturating_sub(new_h) / 2;
801    Rect::new(left, top, new_w, new_h)
802}
803
804/// Internal placement rectangle (top-left + size). The **public** pixel
805/// sub-region type is [`Region`] (re-exported from `edgefirst-tensor`);
806/// `Rect` is the crate-private resolved-placement representation used by the
807/// CPU/G2D/GL backends after [`Crop::resolve`].
808#[derive(Debug, Clone, Copy, PartialEq, Eq)]
809pub(crate) struct Rect {
810    pub left: usize,
811    pub top: usize,
812    pub width: usize,
813    pub height: usize,
814}
815
816impl Rect {
817    // Creates a new Rect with the specified left, top, width, and height.
818    pub fn new(left: usize, top: usize, width: usize, height: usize) -> Self {
819        Self {
820            left,
821            top,
822            width,
823            height,
824        }
825    }
826}
827
828#[enum_dispatch(ImageProcessor)]
829pub trait ImageProcessorTrait {
830    /// Converts the source image to the destination image format and size. The
831    /// image is cropped first, then flipped, then rotated
832    ///
833    /// # Arguments
834    ///
835    /// * `dst` - The destination image to be converted to.
836    /// * `src` - The source image to convert from.
837    /// * `rotation` - The rotation to apply to the destination image.
838    /// * `flip` - Flips the image
839    /// * `crop` - An optional rectangle specifying the area to crop from the
840    ///   source image
841    ///
842    /// # Returns
843    ///
844    /// A `Result` indicating success or failure of the conversion.
845    fn convert(
846        &mut self,
847        src: &TensorDyn,
848        dst: &mut TensorDyn,
849        rotation: Rotation,
850        flip: Flip,
851        crop: Crop,
852    ) -> Result<()>;
853
854    /// Draw pre-decoded detection boxes and segmentation masks onto `dst`.
855    ///
856    /// Supports two segmentation modes based on the mask channel count:
857    /// - **Instance segmentation** (`C=1`): one `Segmentation` per detection,
858    ///   `segmentation` and `detect` are zipped.
859    /// - **Semantic segmentation** (`C>1`): a single `Segmentation` covering
860    ///   all classes; only the first element is used.
861    ///
862    /// # Format requirements
863    ///
864    /// - CPU backend: `dst` must be `RGBA` or `RGB`.
865    /// - OpenGL backend: `dst` must be `RGBA`, `BGRA`, or `RGB`.
866    /// - G2D backend: only produces the base frame (empty detections);
867    ///   returns `NotImplemented` when any detection or segmentation is
868    ///   supplied.
869    ///
870    /// # Output contract
871    ///
872    /// This function always fully writes `dst` — it never relies on the
873    /// caller having pre-cleared the destination. The four cases are:
874    ///
875    /// | detections | background | output                              |
876    /// |------------|------------|-------------------------------------|
877    /// | none       | none       | dst cleared to `0x00000000`         |
878    /// | none       | set        | dst ← background                    |
879    /// | set        | none       | masks drawn over cleared dst        |
880    /// | set        | set        | masks drawn over background         |
881    ///
882    /// Each backend implements this with its native primitives: G2D uses
883    /// `g2d_clear` / `g2d_blit`, OpenGL uses `glClear` / DMA-BUF GPU blit
884    /// plus the mask program, and CPU uses direct buffer fill / memcpy as
885    /// the terminal fallback. CPU-memcpy of DMA buffers is avoided on the
886    /// accelerated paths.
887    ///
888    /// An empty `segmentation` slice is valid — only bounding boxes are drawn.
889    ///
890    /// `overlay` controls compositing: `background` is the compositing source
891    /// (must match `dst` in size and format); `opacity` scales mask alpha.
892    ///
893    /// # Buffer aliasing
894    ///
895    /// `dst` and `overlay.background` must reference **distinct underlying
896    /// buffers**. An aliased pair returns [`Error::AliasedBuffers`] without
897    /// dispatching to any backend — the GL path would otherwise read and
898    /// write the same texture in a single draw, which is undefined behaviour
899    /// on most drivers. Aliasing is detected via
900    /// [`TensorDyn::aliases`](edgefirst_tensor::TensorDyn::aliases), which
901    /// catches both shared-allocation clones and separate imports over the
902    /// same dmabuf fd.
903    ///
904    /// # Migration from v0.16.3 and earlier
905    ///
906    /// Prior to v0.16.4 the call silently preserved `dst`'s contents on empty
907    /// detections. That invariant no longer holds — `dst` is always fully
908    /// written. Callers who pre-loaded an image into `dst` before calling this
909    /// function must now pass that image via `overlay.background` instead.
910    fn draw_decoded_masks(
911        &mut self,
912        dst: &mut TensorDyn,
913        detect: &[DetectBox],
914        segmentation: &[Segmentation],
915        overlay: MaskOverlay<'_>,
916    ) -> Result<()>;
917
918    /// Draw masks from proto data onto image (fused decode+draw).
919    ///
920    /// For YOLO segmentation models, this avoids materializing intermediate
921    /// `Array3<u8>` masks. The `ProtoData` contains mask coefficients and the
922    /// prototype tensor; the renderer computes `mask_coeff @ protos` directly
923    /// at the output resolution using bilinear sampling.
924    ///
925    /// `detect` and `proto_data.mask_coefficients` must have the same length
926    /// (enforced by zip — excess entries are silently ignored). An empty
927    /// `detect` slice is valid and produces the base frame — cleared or
928    /// background-blitted — via the selected backend's native primitive.
929    ///
930    /// # Format requirements and output contract
931    ///
932    /// Same as [`draw_decoded_masks`](Self::draw_decoded_masks), including
933    /// the "always fully writes dst" guarantee across all four
934    /// detection/background combinations.
935    ///
936    /// `overlay` controls compositing — see [`draw_decoded_masks`](Self::draw_decoded_masks).
937    fn draw_proto_masks(
938        &mut self,
939        dst: &mut TensorDyn,
940        detect: &[DetectBox],
941        proto_data: &ProtoData,
942        overlay: MaskOverlay<'_>,
943    ) -> Result<()>;
944
945    /// Sets the colors used for rendering segmentation masks. Up to 20 colors
946    /// can be set.
947    fn set_class_colors(&mut self, colors: &[[u8; 4]]) -> Result<()>;
948
949    /// Like [`convert`](Self::convert), but does not wait for the GPU to finish.
950    ///
951    /// This is the batch-preprocessing primitive: a caller renders `N` tiles
952    /// into one batched destination by looping
953    /// `convert_deferred(&src[n], &mut dst.batch(n)?, …)` and then calling
954    /// [`flush`](Self::flush) **once**. On the OpenGL backend every deferred
955    /// convert into sibling views of one buffer shares a single EGLImage import
956    /// (the tile is a `glViewport`/`glScissor` ROI into the parent) and skips the
957    /// per-tile `glFinish`; `flush` then issues a single GPU sync. The result of
958    /// a deferred convert is **not** safe to read on the CPU (or map via CUDA)
959    /// until `flush` returns.
960    ///
961    /// The default implementation is eager — it simply calls
962    /// [`convert`](Self::convert), so CPU/G2D and any backend without a deferred
963    /// fast path remain correct (each call completes synchronously and `flush`
964    /// is a no-op).
965    fn convert_deferred(
966        &mut self,
967        src: &TensorDyn,
968        dst: &mut TensorDyn,
969        rotation: Rotation,
970        flip: Flip,
971        crop: Crop,
972    ) -> Result<()> {
973        self.convert(src, dst, rotation, flip, crop)
974    }
975
976    /// Complete all work enqueued by [`convert_deferred`](Self::convert_deferred)
977    /// since the last flush, issuing a single GPU synchronization.
978    ///
979    /// After this returns, every deferred destination is finished and safe to
980    /// read back or `cuda_map`. Backends with no deferred path (the default)
981    /// return `Ok(())` immediately, since their converts already completed.
982    fn flush(&mut self) -> Result<()> {
983        Ok(())
984    }
985}
986
987/// Configuration for [`ImageProcessor`] construction.
988///
989/// Use with [`ImageProcessor::with_config`] to override the default EGL
990/// display auto-detection and backend selection. The default configuration
991/// preserves the existing auto-detection behaviour.
992#[derive(Debug, Clone, Default)]
993pub struct ImageProcessorConfig {
994    /// Force OpenGL to use this EGL display type instead of auto-detecting.
995    ///
996    /// When `None`, the processor probes displays in priority order: GBM,
997    /// PlatformDevice, Default. Use [`probe_egl_displays`] to discover
998    /// which displays are available on the current system.
999    ///
1000    /// Ignored when `EDGEFIRST_DISABLE_GL=1` is set, and on macOS
1001    /// (ANGLE/Metal is the only display there; a `Some` value logs a
1002    /// debug note and is otherwise ignored).
1003    #[cfg(all(
1004        any(
1005            target_os = "linux",
1006            target_os = "macos",
1007            target_os = "ios",
1008            target_os = "android"
1009        ),
1010        feature = "opengl"
1011    ))]
1012    pub egl_display: Option<EglDisplayKind>,
1013
1014    /// Preferred compute backend.
1015    ///
1016    /// When set to a specific backend (not [`ComputeBackend::Auto`]), the
1017    /// processor initializes that backend with no fallback — returns an error if the conversion is not supported.
1018    /// This takes precedence over `EDGEFIRST_FORCE_BACKEND` and the
1019    /// `EDGEFIRST_DISABLE_*` environment variables.
1020    ///
1021    /// - [`ComputeBackend::OpenGl`]: init OpenGL + CPU, skip G2D
1022    /// - [`ComputeBackend::G2d`]: init G2D + CPU, skip OpenGL
1023    /// - [`ComputeBackend::Cpu`]: init CPU only
1024    /// - [`ComputeBackend::Auto`]: existing env-var-driven selection
1025    pub backend: ComputeBackend,
1026
1027    /// Colorimetry/performance trade-off for `convert()` (see
1028    /// [`ColorimetryMode`]). Defaults to [`ColorimetryMode::Fast`]. The
1029    /// `EDGEFIRST_COLORIMETRY` environment variable (`fast` | `exact`)
1030    /// overrides this setting when present.
1031    pub colorimetry: ColorimetryMode,
1032}
1033
1034/// How `convert()` trades colorimetric exactness against speed on platforms
1035/// where the exact path is expensive.
1036///
1037/// Today this affects one decision: NV12 sources on Vivante GC7000UL
1038/// (i.MX 8M Plus), where the hardware external sampler converts ~12× faster
1039/// than the colorimetry-exact in-shader matrix (2.5 ms vs 29 ms at 720p)
1040/// but applies the driver's fixed BT.601-limited matrix regardless of the
1041/// source's tagged colorimetry. Platforms where the exact path is already
1042/// the fastest correct path (Mali, V3D, Tegra, ANGLE) behave identically in
1043/// both modes.
1044///
1045/// Override at runtime with `EDGEFIRST_COLORIMETRY=fast|exact` (takes
1046/// precedence over the config field), or per-source by forcing a path with
1047/// `EDGEFIRST_NV_CONVERT_PATH`.
1048#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1049pub enum ColorimetryMode {
1050    /// Prefer the fastest path whose output is correct-enough video RGB
1051    /// (default; issue #106 policy). On Vivante, NV12 takes the hardware
1052    /// sampler even when the source is not BT.601-limited.
1053    #[default]
1054    Fast,
1055    /// Prefer bit-exact colorimetry everywhere: the fast path is used only
1056    /// when it matches the source's resolved (encoding, range) exactly.
1057    Exact,
1058}
1059
1060/// Compute backend selection for [`ImageProcessor`].
1061///
1062/// Use with [`ImageProcessorConfig::backend`] to select which backend the
1063/// processor should prefer. When a specific backend is selected, the
1064/// processor initializes that backend plus CPU as a fallback. When `Auto`
1065/// is used, the existing environment-variable-driven selection applies.
1066#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1067pub enum ComputeBackend {
1068    /// Auto-detect based on available hardware and environment variables.
1069    #[default]
1070    Auto,
1071    /// CPU-only processing (no hardware acceleration).
1072    Cpu,
1073    /// Prefer G2D hardware blitter (+ CPU fallback).
1074    G2d,
1075    /// Prefer OpenGL ES (+ CPU fallback).
1076    OpenGl,
1077}
1078
1079/// Backend forced via the `EDGEFIRST_FORCE_BACKEND` environment variable
1080/// or [`ImageProcessorConfig::backend`].
1081///
1082/// When set, the [`ImageProcessor`] only initializes and dispatches to the
1083/// selected backend — no fallback chain is used.
1084#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1085pub(crate) enum ForcedBackend {
1086    Cpu,
1087    G2d,
1088    OpenGl,
1089}
1090
1091/// Reports which float-color-buffer extensions the GPU backend detected.
1092/// Returned by [`ImageProcessor::supported_render_dtypes`]; the two flags
1093/// are independent.
1094///
1095/// **Linux:** reflects the real probe results from `GL_EXT_color_buffer_half_float`
1096/// and `GL_EXT_color_buffer_float`. On V3D (RPi 5) and Mali-G310 (i.MX 95)
1097/// both flags are typically `true`; on Vivante GC7000UL both are forced
1098/// `false` (float readback measured 170–320 ms — disabled). Tegra Orin
1099/// exposes both via PBO; the flags match the GPU report.
1100///
1101/// **macOS (ANGLE):** `f16 == true` gates the RGBA16F-packed IOSurface
1102/// path for F16 `PlanarRgb` destinations. `f32` reflects the GL
1103/// extension probe but is not actionable — ANGLE's
1104/// `EGL_ANGLE_iosurface_client_buffer` rejects every `(GL_FLOAT, *)`
1105/// combination with `EGL_BAD_ATTRIBUTE`, so there is no F32 IOSurface
1106/// path.
1107///
1108/// Regardless of these flags, [`ImageProcessor::convert`] never returns
1109/// an error due to float capability — it falls back to CPU when the GPU
1110/// path is unavailable.
1111#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1112pub struct RenderDtypeSupport {
1113    /// `GL_EXT_color_buffer_float` is available on the current GPU.
1114    ///
1115    /// On Linux, `true` enables F32 `Rgb` NHWC PBO readback. On macOS
1116    /// this flag is informational only — no F32 IOSurface path exists.
1117    pub f32: bool,
1118    /// `GL_EXT_color_buffer_half_float` is available on the current GPU.
1119    ///
1120    /// On Linux, `true` enables F16 `PlanarRgb` NCHW PBO readback and,
1121    /// on V3D/Mali, zero-copy DMA-BUF render. On macOS, `true` enables
1122    /// F16 `PlanarRgb` via RGBA16F-packed IOSurface (zero-copy).
1123    pub f16: bool,
1124}
1125
1126/// Returns `true` when a float PBO destination should be attempted for `dtype`.
1127///
1128/// Only F16 and F32 are eligible, and only when the corresponding flag in
1129/// `support` is set. U8/I8 and all other dtypes return `false` — they are
1130/// handled by the existing `dtype.size() == 1` PBO gate.
1131///
1132/// Linux-only: the float PBO readback path is the Linux GL backend's
1133/// mechanism; macOS routes F16 through the RGBA16F-packed IOSurface
1134/// instead and never calls this. The sole runtime caller in
1135/// `create_image` is `cfg(all(target_os = "linux", feature = "opengl"))`,
1136/// so leaving this ungated makes it dead code on macOS under
1137/// `-D warnings`. Its unit test (`float_pbo_eligibility`) carries the
1138/// matching gate.
1139#[cfg(all(target_os = "linux", feature = "opengl"))]
1140pub(crate) fn float_pbo_eligible(dtype: DType, support: RenderDtypeSupport) -> bool {
1141    match dtype {
1142        DType::F16 => support.f16,
1143        DType::F32 => support.f32,
1144        _ => false,
1145    }
1146}
1147
1148/// Image converter that uses available hardware acceleration or CPU as a
1149/// fallback.
1150#[derive(Debug)]
1151pub struct ImageProcessor {
1152    /// CPU-based image converter as a fallback. This is only None if the
1153    /// EDGEFIRST_DISABLE_CPU environment variable is set.
1154    pub cpu: Option<CPUProcessor>,
1155
1156    #[cfg(target_os = "linux")]
1157    /// G2D-based image converter for Linux systems. This is only available if
1158    /// the EDGEFIRST_DISABLE_G2D environment variable is not set and libg2d.so
1159    /// is available.
1160    pub g2d: Option<G2DProcessor>,
1161    #[cfg(target_os = "linux")]
1162    #[cfg(feature = "opengl")]
1163    /// OpenGL-based image converter for Linux systems. This is only available
1164    /// if the EDGEFIRST_DISABLE_GL environment variable is not set and OpenGL
1165    /// ES is available.
1166    pub opengl: Option<GLProcessorThreaded>,
1167    #[cfg(any(target_os = "macos", target_os = "ios", target_os = "android"))]
1168    #[cfg(feature = "opengl")]
1169    /// OpenGL-based image converter — the same unified
1170    /// `GLProcessorThreaded` engine as Linux (its worker owns a
1171    /// per-processor context). macOS/iOS run it via ANGLE + IOSurface
1172    /// (available when ANGLE's libEGL.dylib can be loaded — see
1173    /// README.md § macOS GPU Acceleration); Android runs it via the
1174    /// native EGL driver + AHardwareBuffer.
1175    pub opengl: Option<GLProcessorThreaded>,
1176
1177    /// When set, only the specified backend is used — no fallback chain.
1178    pub(crate) forced_backend: Option<ForcedBackend>,
1179
1180    /// Converts where the GL backend declined and the auto chain fell
1181    /// through toward G2D/CPU. Owned by the dispatcher (the GL worker never
1182    /// sees these frames), unlike the per-feed counters in
1183    /// `GLProcessorThreaded::convert_stats`. Read via
1184    /// [`convert_fallback_count`](Self::convert_fallback_count).
1185    pub(crate) convert_fallbacks: std::sync::atomic::AtomicU64,
1186}
1187
1188unsafe impl Send for ImageProcessor {}
1189unsafe impl Sync for ImageProcessor {}
1190
1191impl ImageProcessor {
1192    /// Creates a new `ImageProcessor` instance, initializing available
1193    /// hardware converters based on the system capabilities and environment
1194    /// variables.
1195    ///
1196    /// # Examples
1197    /// ```rust,no_run
1198    /// # use edgefirst_image::{ImageProcessor, Rotation, Flip, Crop, ImageProcessorTrait};
1199    /// # use edgefirst_codec::{peek_info, ImageDecoder, ImageLoad};
1200    /// # use edgefirst_tensor::{CpuAccess, PixelFormat, DType, Tensor, TensorMemory};
1201    /// # fn main() -> Result<(), edgefirst_image::Error> {
1202    /// let image = std::fs::read("zidane.jpg")?;
1203    /// // The codec emits the source's native format (a colour JPEG decodes to
1204    /// // NV12) and configures the destination tensor during the decode.
1205    /// let info = peek_info(&image).expect("peek");
1206    /// let mut src = Tensor::<u8>::image(info.width, info.height, info.format,
1207    ///                                    Some(TensorMemory::Mem), CpuAccess::ReadWrite)?;
1208    /// let mut decoder = ImageDecoder::new();
1209    /// src.load_image(&mut decoder, &image).expect("decode");
1210    /// let mut converter = ImageProcessor::new()?;
1211    /// let mut dst =
1212    ///     converter.create_image(640, 480, PixelFormat::Rgb, DType::U8, None, CpuAccess::ReadWrite)?;
1213    /// converter.convert(&src.into(), &mut dst, Rotation::None, Flip::None, Crop::default())?;
1214    /// # Ok(())
1215    /// # }
1216    /// ```
1217    pub fn new() -> Result<Self> {
1218        Self::with_config(ImageProcessorConfig::default())
1219    }
1220
1221    /// Number of converts where the auto chain's GL backend declined and the
1222    /// frame fell through toward G2D/CPU — each one silently lost any
1223    /// zero-copy guarantee. A pipeline that expects to stay on the GPU
1224    /// asserts this stays flat across its steady-state loop; pair with
1225    /// `GLProcessorThreaded::convert_stats` to see how the frames that DID
1226    /// reach GL were fed. Always 0 under a forced backend (no chain).
1227    pub fn convert_fallback_count(&self) -> u64 {
1228        self.convert_fallbacks
1229            .load(std::sync::atomic::Ordering::Relaxed)
1230    }
1231
1232    /// Number of [`Compression::Any`](edgefirst_tensor::Compression)
1233    /// requests since process start that resolved to a linear layout
1234    /// (process-wide mirror of
1235    /// [`edgefirst_tensor::compression_fallback_count`], surfaced here
1236    /// beside [`convert_fallback_count`](Self::convert_fallback_count)
1237    /// so pipeline telemetry reads from one place). A pipeline that
1238    /// expects compressed convert destinations asserts this stays flat
1239    /// after warmup.
1240    pub fn compression_fallback_count(&self) -> u64 {
1241        edgefirst_tensor::compression_fallback_count()
1242    }
1243
1244    /// Convert and return a native sync-fence fd that signals when the
1245    /// GPU work completes, instead of blocking the CPU — the GL→NPU
1246    /// handoff (`EGL_ANDROID_native_fence_sync` on Android).
1247    ///
1248    /// `Ok(Some(fd))`: the destination buffer is still in flight; hand
1249    /// the fd to the consumer (e.g.
1250    /// `ANeuralNetworksExecution_startComputeWithDependencies`) or
1251    /// `poll()` it before reading. `Ok(None)`: the convert completed with
1252    /// the normal blocking contract (no native fence on this platform, or
1253    /// a non-GL backend handled the frame) — the destination is already
1254    /// safe. Semantics are otherwise identical to
1255    /// [`convert`](ImageProcessorTrait::convert), including the
1256    /// GL→G2D→CPU fallback chain.
1257    #[cfg(unix)]
1258    pub fn convert_with_fence(
1259        &mut self,
1260        src: &TensorDyn,
1261        dst: &mut TensorDyn,
1262        rotation: Rotation,
1263        flip: Flip,
1264        crop: Crop,
1265    ) -> Result<Option<std::os::fd::OwnedFd>> {
1266        #[cfg(any(
1267            target_os = "linux",
1268            target_os = "macos",
1269            target_os = "ios",
1270            target_os = "android"
1271        ))]
1272        #[cfg(feature = "opengl")]
1273        {
1274            let gl_forced = matches!(self.forced_backend, Some(ForcedBackend::OpenGl));
1275            if self.forced_backend.is_none() || gl_forced {
1276                if let Some(opengl) = self.opengl.as_mut() {
1277                    match opengl.convert_with_fence(src, dst, rotation, flip, crop) {
1278                        Ok(fd) => return Ok(fd),
1279                        Err(e) if gl_forced => return Err(e),
1280                        Err(e) => {
1281                            // Not counted here: the blocking chain below
1282                            // re-runs the dispatcher, whose decline arm
1283                            // counts the fallback exactly once.
1284                            log::debug!(
1285                                "convert_with_fence: opengl declined, \
1286                                 falling back to the blocking chain: {e}"
1287                            );
1288                        }
1289                    }
1290                } else if gl_forced {
1291                    return Err(Error::ForcedBackendUnavailable("opengl".into()));
1292                }
1293            }
1294        }
1295        // Blocking chain (G2D/CPU, forced non-GL, or non-GL builds):
1296        // completion == return, so no fence is needed.
1297        self.convert(src, dst, rotation, flip, crop)?;
1298        Ok(None)
1299    }
1300
1301    /// Report which float dtypes the GPU can render to.
1302    ///
1303    /// Probes `GL_EXT_color_buffer_half_float` and
1304    /// `GL_EXT_color_buffer_float` once at `ImageProcessor::new()` time
1305    /// and caches the result. Call this once at startup to decide whether
1306    /// to request F16 or F32 destination tensors; [`create_image`] uses
1307    /// the result internally to auto-select float PBO when supported.
1308    ///
1309    /// Returns `RenderDtypeSupport { f32: false, f16: false }` when no
1310    /// OpenGL backend is active or the `opengl` feature is disabled.
1311    ///
1312    /// [`create_image`]: Self::create_image
1313    pub fn supported_render_dtypes(&self) -> RenderDtypeSupport {
1314        #[cfg(all(
1315            any(target_os = "macos", target_os = "ios", target_os = "android"),
1316            feature = "opengl"
1317        ))]
1318        if let Some(gl) = self.opengl.as_ref() {
1319            return gl.supported_render_dtypes();
1320        }
1321        #[cfg(all(target_os = "linux", feature = "opengl"))]
1322        if let Some(gl) = self.opengl.as_ref() {
1323            return gl.supported_render_dtypes();
1324        }
1325        RenderDtypeSupport {
1326            f32: false,
1327            f16: false,
1328        }
1329    }
1330
1331    /// Creates a new `ImageProcessor` with the given configuration.
1332    ///
1333    /// When [`ImageProcessorConfig::backend`] is set to a specific backend,
1334    /// environment variables are ignored and the processor initializes the
1335    /// requested backend plus CPU as a fallback.
1336    ///
1337    /// When `Auto`, the existing `EDGEFIRST_FORCE_BACKEND` and
1338    /// `EDGEFIRST_DISABLE_*` environment variables apply.
1339    #[allow(unused_variables)]
1340    pub fn with_config(config: ImageProcessorConfig) -> Result<Self> {
1341        // ── Config-driven backend selection ──────────────────────────
1342        // When the caller explicitly requests a backend via the config,
1343        // skip all environment variable logic.
1344        match config.backend {
1345            ComputeBackend::Cpu => {
1346                log::info!("ComputeBackend::Cpu — CPU only");
1347                return Ok(Self {
1348                    cpu: Some(CPUProcessor::new()),
1349                    #[cfg(target_os = "linux")]
1350                    g2d: None,
1351                    #[cfg(target_os = "linux")]
1352                    #[cfg(feature = "opengl")]
1353                    opengl: None,
1354                    #[cfg(any(target_os = "macos", target_os = "ios", target_os = "android"))]
1355                    #[cfg(feature = "opengl")]
1356                    opengl: None,
1357                    convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1358                    forced_backend: None,
1359                });
1360            }
1361            ComputeBackend::G2d => {
1362                log::info!("ComputeBackend::G2d — G2D + CPU fallback");
1363                #[cfg(target_os = "linux")]
1364                {
1365                    let g2d = match G2DProcessor::new() {
1366                        Ok(g) => Some(g),
1367                        Err(e) => {
1368                            log::warn!("G2D requested but failed to initialize: {e:?}");
1369                            None
1370                        }
1371                    };
1372                    return Ok(Self {
1373                        cpu: Some(CPUProcessor::new()),
1374                        g2d,
1375                        #[cfg(feature = "opengl")]
1376                        opengl: None,
1377                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1378                        forced_backend: None,
1379                    });
1380                }
1381                #[cfg(not(target_os = "linux"))]
1382                {
1383                    log::warn!("G2D requested but not available on this platform, using CPU");
1384                    return Ok(Self {
1385                        cpu: Some(CPUProcessor::new()),
1386                        #[cfg(any(target_os = "macos", target_os = "ios", target_os = "android"))]
1387                        #[cfg(feature = "opengl")]
1388                        opengl: None,
1389                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1390                        forced_backend: None,
1391                    });
1392                }
1393            }
1394            ComputeBackend::OpenGl => {
1395                log::info!("ComputeBackend::OpenGl — OpenGL + CPU fallback");
1396                #[cfg(target_os = "linux")]
1397                {
1398                    #[cfg(feature = "opengl")]
1399                    let opengl = match GLProcessorThreaded::new(config.egl_display) {
1400                        Ok(gl) => Some(gl),
1401                        Err(e) => {
1402                            log::warn!("OpenGL requested but failed to initialize: {e:?}");
1403                            None
1404                        }
1405                    };
1406                    return Ok(Self {
1407                        cpu: Some(CPUProcessor::new()),
1408                        g2d: None,
1409                        #[cfg(feature = "opengl")]
1410                        opengl,
1411                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1412                        forced_backend: None,
1413                    }
1414                    .apply_colorimetry_mode(config.colorimetry));
1415                }
1416                #[cfg(any(target_os = "macos", target_os = "ios"))]
1417                {
1418                    #[cfg(feature = "opengl")]
1419                    let opengl = match GLProcessorThreaded::new(config.egl_display) {
1420                        Ok(gl) => Some(gl),
1421                        Err(e) => {
1422                            log::warn!(
1423                                "OpenGL requested on macOS but ANGLE init failed: {e:?} \
1424                                 (install ANGLE via `brew install startergo/angle/angle` \
1425                                 and re-sign the dylibs — see README.md § macOS GPU \
1426                                 Acceleration). Falling back to CPU."
1427                            );
1428                            None
1429                        }
1430                    };
1431                    return Ok(Self {
1432                        cpu: Some(CPUProcessor::new()),
1433                        #[cfg(feature = "opengl")]
1434                        opengl,
1435                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1436                        forced_backend: None,
1437                    }
1438                    .apply_colorimetry_mode(config.colorimetry));
1439                }
1440                #[cfg(target_os = "android")]
1441                {
1442                    #[cfg(feature = "opengl")]
1443                    let opengl = match GLProcessorThreaded::new(config.egl_display) {
1444                        Ok(gl) => Some(gl),
1445                        Err(e) => {
1446                            log::warn!(
1447                                "OpenGL requested but native EGL init failed: {e:?}. \
1448                                 Falling back to CPU."
1449                            );
1450                            None
1451                        }
1452                    };
1453                    return Ok(Self {
1454                        cpu: Some(CPUProcessor::new()),
1455                        #[cfg(feature = "opengl")]
1456                        opengl,
1457                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1458                        forced_backend: None,
1459                    }
1460                    .apply_colorimetry_mode(config.colorimetry));
1461                }
1462                #[cfg(not(any(
1463                    target_os = "linux",
1464                    target_os = "macos",
1465                    target_os = "ios",
1466                    target_os = "android"
1467                )))]
1468                {
1469                    log::warn!("OpenGL requested but not available on this platform, using CPU");
1470                    return Ok(Self {
1471                        cpu: Some(CPUProcessor::new()),
1472                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1473                        forced_backend: None,
1474                    });
1475                }
1476            }
1477            ComputeBackend::Auto => { /* fall through to env-var logic below */ }
1478        }
1479
1480        // ── EDGEFIRST_FORCE_BACKEND ──────────────────────────────────
1481        // When set, only the requested backend is initialised and no
1482        // fallback chain is used. Accepted values (case-insensitive):
1483        //   "cpu", "g2d", "opengl"
1484        if let Ok(val) = std::env::var("EDGEFIRST_FORCE_BACKEND") {
1485            let val_lower = val.to_lowercase();
1486            let forced = match val_lower.as_str() {
1487                "cpu" => ForcedBackend::Cpu,
1488                "g2d" => ForcedBackend::G2d,
1489                "opengl" => ForcedBackend::OpenGl,
1490                other => {
1491                    return Err(Error::ForcedBackendUnavailable(format!(
1492                        "unknown EDGEFIRST_FORCE_BACKEND value: {other:?} (expected cpu, g2d, or opengl)"
1493                    )));
1494                }
1495            };
1496
1497            log::info!("EDGEFIRST_FORCE_BACKEND={val} — only initializing {val_lower} backend");
1498
1499            return match forced {
1500                ForcedBackend::Cpu => Ok(Self {
1501                    cpu: Some(CPUProcessor::new()),
1502                    #[cfg(target_os = "linux")]
1503                    g2d: None,
1504                    #[cfg(target_os = "linux")]
1505                    #[cfg(feature = "opengl")]
1506                    opengl: None,
1507                    #[cfg(any(target_os = "macos", target_os = "ios", target_os = "android"))]
1508                    #[cfg(feature = "opengl")]
1509                    opengl: None,
1510                    convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1511                    forced_backend: Some(ForcedBackend::Cpu),
1512                }),
1513                ForcedBackend::G2d => {
1514                    #[cfg(target_os = "linux")]
1515                    {
1516                        let g2d = G2DProcessor::new().map_err(|e| {
1517                            Error::ForcedBackendUnavailable(format!(
1518                                "g2d forced but failed to initialize: {e:?}"
1519                            ))
1520                        })?;
1521                        Ok(Self {
1522                            cpu: None,
1523                            g2d: Some(g2d),
1524                            #[cfg(feature = "opengl")]
1525                            opengl: None,
1526                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1527                            forced_backend: Some(ForcedBackend::G2d),
1528                        })
1529                    }
1530                    #[cfg(not(target_os = "linux"))]
1531                    {
1532                        Err(Error::ForcedBackendUnavailable(
1533                            "g2d backend is only available on Linux".into(),
1534                        ))
1535                    }
1536                }
1537                ForcedBackend::OpenGl => {
1538                    #[cfg(target_os = "linux")]
1539                    #[cfg(feature = "opengl")]
1540                    {
1541                        let opengl = GLProcessorThreaded::new(config.egl_display).map_err(|e| {
1542                            Error::ForcedBackendUnavailable(format!(
1543                                "opengl forced but failed to initialize: {e:?}"
1544                            ))
1545                        })?;
1546                        Ok(Self {
1547                            cpu: None,
1548                            g2d: None,
1549                            opengl: Some(opengl),
1550                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1551                            forced_backend: Some(ForcedBackend::OpenGl),
1552                        }
1553                        .apply_colorimetry_mode(config.colorimetry))
1554                    }
1555                    #[cfg(any(target_os = "macos", target_os = "ios"))]
1556                    #[cfg(feature = "opengl")]
1557                    {
1558                        let opengl = GLProcessorThreaded::new(config.egl_display).map_err(|e| {
1559                            Error::ForcedBackendUnavailable(format!(
1560                                "opengl forced on macOS but ANGLE init failed: {e:?}"
1561                            ))
1562                        })?;
1563                        Ok(Self {
1564                            cpu: None,
1565                            opengl: Some(opengl),
1566                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1567                            forced_backend: Some(ForcedBackend::OpenGl),
1568                        }
1569                        .apply_colorimetry_mode(config.colorimetry))
1570                    }
1571                    #[cfg(target_os = "android")]
1572                    #[cfg(feature = "opengl")]
1573                    {
1574                        let opengl = GLProcessorThreaded::new(config.egl_display).map_err(|e| {
1575                            Error::ForcedBackendUnavailable(format!(
1576                                "opengl forced but native EGL init failed: {e:?}"
1577                            ))
1578                        })?;
1579                        Ok(Self {
1580                            cpu: None,
1581                            opengl: Some(opengl),
1582                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1583                            forced_backend: Some(ForcedBackend::OpenGl),
1584                        }
1585                        .apply_colorimetry_mode(config.colorimetry))
1586                    }
1587                    #[cfg(not(all(
1588                        any(
1589                            target_os = "linux",
1590                            target_os = "macos",
1591                            target_os = "ios",
1592                            target_os = "android"
1593                        ),
1594                        feature = "opengl"
1595                    )))]
1596                    {
1597                        Err(Error::ForcedBackendUnavailable(
1598                            "opengl backend requires Linux or macOS with the 'opengl' feature \
1599                             enabled"
1600                                .into(),
1601                        ))
1602                    }
1603                }
1604            };
1605        }
1606
1607        // ── Existing DISABLE logic (unchanged) ──────────────────────
1608        #[cfg(target_os = "linux")]
1609        let g2d = if std::env::var("EDGEFIRST_DISABLE_G2D")
1610            .map(|x| x != "0" && x.to_lowercase() != "false")
1611            .unwrap_or(false)
1612        {
1613            log::debug!("EDGEFIRST_DISABLE_G2D is set");
1614            None
1615        } else {
1616            match G2DProcessor::new() {
1617                Ok(g2d_converter) => Some(g2d_converter),
1618                Err(err) => {
1619                    log::warn!("Failed to initialize G2D converter: {err:?}");
1620                    None
1621                }
1622            }
1623        };
1624
1625        #[cfg(target_os = "linux")]
1626        #[cfg(feature = "opengl")]
1627        let opengl = if std::env::var("EDGEFIRST_DISABLE_GL")
1628            .map(|x| x != "0" && x.to_lowercase() != "false")
1629            .unwrap_or(false)
1630        {
1631            log::debug!("EDGEFIRST_DISABLE_GL is set");
1632            None
1633        } else {
1634            match GLProcessorThreaded::new(config.egl_display) {
1635                Ok(gl_converter) => Some(gl_converter),
1636                Err(err) => {
1637                    log::warn!("Failed to initialize GL converter: {err:?}");
1638                    None
1639                }
1640            }
1641        };
1642
1643        #[cfg(any(target_os = "macos", target_os = "ios", target_os = "android"))]
1644        #[cfg(feature = "opengl")]
1645        let opengl = if std::env::var("EDGEFIRST_DISABLE_GL")
1646            .map(|x| x != "0" && x.to_lowercase() != "false")
1647            .unwrap_or(false)
1648        {
1649            log::debug!("EDGEFIRST_DISABLE_GL is set");
1650            None
1651        } else {
1652            match GLProcessorThreaded::new(config.egl_display) {
1653                Ok(gl_converter) => Some(gl_converter),
1654                Err(err) => {
1655                    log::debug!(
1656                        "GL backend unavailable: {err:?} \
1657                         (CPU fallback will be used)"
1658                    );
1659                    None
1660                }
1661            }
1662        };
1663
1664        let cpu = if std::env::var("EDGEFIRST_DISABLE_CPU")
1665            .map(|x| x != "0" && x.to_lowercase() != "false")
1666            .unwrap_or(false)
1667        {
1668            log::debug!("EDGEFIRST_DISABLE_CPU is set");
1669            None
1670        } else {
1671            Some(CPUProcessor::new())
1672        };
1673        Ok(Self {
1674            cpu,
1675            #[cfg(target_os = "linux")]
1676            g2d,
1677            #[cfg(target_os = "linux")]
1678            #[cfg(feature = "opengl")]
1679            opengl,
1680            #[cfg(any(target_os = "macos", target_os = "ios", target_os = "android"))]
1681            #[cfg(feature = "opengl")]
1682            opengl,
1683            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1684            forced_backend: None,
1685        }
1686        .apply_colorimetry_mode(config.colorimetry))
1687    }
1688
1689    /// Apply the configured [`ColorimetryMode`] to whichever backend honours
1690    /// it (currently the Linux GL backend); no-op elsewhere. Constructor
1691    /// plumbing for [`ImageProcessorConfig::colorimetry`].
1692    fn apply_colorimetry_mode(self, _mode: ColorimetryMode) -> Self {
1693        #[cfg(all(
1694            any(
1695                target_os = "linux",
1696                target_os = "macos",
1697                target_os = "ios",
1698                target_os = "android"
1699            ),
1700            feature = "opengl"
1701        ))]
1702        {
1703            let mut me = self;
1704            if let Err(e) = me.set_colorimetry_mode(_mode) {
1705                log::warn!("Failed to apply ColorimetryMode::{_mode:?}: {e:?}");
1706            }
1707            me
1708        }
1709        #[cfg(not(all(
1710            any(
1711                target_os = "linux",
1712                target_os = "macos",
1713                target_os = "ios",
1714                target_os = "android"
1715            ),
1716            feature = "opengl"
1717        )))]
1718        {
1719            let _ = _mode;
1720            self
1721        }
1722    }
1723
1724    /// Sets the colorimetry/performance trade-off (see [`ColorimetryMode`])
1725    /// on the OpenGL backend. No-op if OpenGL is not available. The
1726    /// `EDGEFIRST_COLORIMETRY` environment variable takes precedence — when
1727    /// it is set, this call logs and keeps the env-selected mode.
1728    #[cfg(all(
1729        any(
1730            target_os = "linux",
1731            target_os = "macos",
1732            target_os = "ios",
1733            target_os = "android"
1734        ),
1735        feature = "opengl"
1736    ))]
1737    pub fn set_colorimetry_mode(&mut self, mode: ColorimetryMode) -> Result<()> {
1738        if let Some(ref mut gl) = self.opengl {
1739            gl.set_colorimetry_mode(mode)?;
1740        }
1741        Ok(())
1742    }
1743
1744    /// Sets the interpolation mode for int8 proto textures on the OpenGL
1745    /// backend. No-op if OpenGL is not available.
1746    #[cfg(all(
1747        any(
1748            target_os = "linux",
1749            target_os = "macos",
1750            target_os = "ios",
1751            target_os = "android"
1752        ),
1753        feature = "opengl"
1754    ))]
1755    pub fn set_int8_interpolation_mode(&mut self, mode: Int8InterpolationMode) -> Result<()> {
1756        if let Some(ref mut gl) = self.opengl {
1757            gl.set_int8_interpolation_mode(mode)?;
1758        }
1759        Ok(())
1760    }
1761
1762    /// Create a [`TensorDyn`] image with the best available memory backend.
1763    ///
1764    /// Priority: DMA-buf → float PBO (F16/F32) → u8/i8 PBO → system memory.
1765    ///
1766    /// Use this method instead of [`TensorDyn::image()`] when the tensor will
1767    /// be used with [`ImageProcessor::convert()`]. It selects the optimal
1768    /// memory backing (including PBO for GPU zero-copy) which direct
1769    /// allocation cannot achieve.
1770    ///
1771    /// This method is on [`ImageProcessor`] rather than [`ImageProcessorTrait`]
1772    /// because optimal allocation requires knowledge of the active compute
1773    /// backends (e.g. the GL context handle for PBO allocation). Individual
1774    /// backend implementations ([`CPUProcessor`], etc.) do not have this
1775    /// cross-backend visibility.
1776    ///
1777    /// **Float dtype behaviour:** when `dtype` is `F16` or `F32` and
1778    /// [`supported_render_dtypes`] reports the GPU supports that type,
1779    /// `memory: None` auto-selects a float PBO (Linux) or IOSurface (macOS
1780    /// F16 only). If GPU float support is absent the allocation falls through
1781    /// to `TensorMemory::Mem`; [`convert`] then uses the CPU path.
1782    /// Passing `memory: Some(TensorMemory::Dma)` with `dtype: F32` always
1783    /// returns `Error::NotSupported` — no 32-bit-float DRM fourcc exists.
1784    ///
1785    /// [`supported_render_dtypes`]: Self::supported_render_dtypes
1786    /// [`convert`]: ImageProcessorTrait::convert
1787    ///
1788    /// # Arguments
1789    ///
1790    /// * `width` - Image width in pixels
1791    /// * `height` - Image height in pixels
1792    /// * `format` - Pixel format
1793    /// * `dtype` - Element data type (e.g. `DType::U8`, `DType::F16`, `DType::F32`)
1794    /// * `memory` - Optional memory type override; when `None`, the best
1795    ///   available backend is selected automatically.
1796    ///
1797    /// # Returns
1798    ///
1799    /// A [`TensorDyn`] backed by the highest-performance memory type
1800    /// available on this system.
1801    ///
1802    /// # Pitch alignment for DMA-backed allocations
1803    ///
1804    /// DMA-BUF imports into the GL backend (Mali Valhall on i.MX 95
1805    /// specifically) require every row pitch to be a multiple of
1806    /// [`GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES`] (currently 64). When this
1807    /// method lands on `TensorMemory::Dma`, the underlying allocation is
1808    /// silently padded so the row stride satisfies that requirement.
1809    ///
1810    /// **The user-requested `width` is preserved** — `tensor.width()`
1811    /// returns the same value you passed in. The padding is carried by
1812    /// [`TensorDyn::row_stride`] / `effective_row_stride()`, which the
1813    /// GL backend reads when importing the buffer as an EGLImage.
1814    /// Callers that compute byte offsets from the tensor must use the
1815    /// stride, not `width × bytes_per_pixel`; the CPU mapping spans the
1816    /// full `stride × height` bytes.
1817    ///
1818    /// Pre-aligned widths (640, 1280, 1920, 3008, 3840 …) allocate
1819    /// exactly `width × bpp × height` bytes with no padding. PBO and
1820    /// Mem fallbacks never pad — they don't go through EGLImage import.
1821    ///
1822    /// See also [`align_width_for_gpu_pitch`] for an advisory helper
1823    /// that external callers (GStreamer plugins, video pipelines) can
1824    /// use to size their own DMA-BUFs for GL compatibility.
1825    ///
1826    /// # Errors
1827    ///
1828    /// Returns an error if all allocation strategies fail.
1829    /// Allocate an image tensor from a declarative
1830    /// [`ImageDesc`](edgefirst_tensor::ImageDesc) request — the
1831    /// full-featured variant of [`create_image`](Self::create_image).
1832    ///
1833    /// Without a compression request this is exactly `create_image` (the
1834    /// processor's memory negotiation applies). With one, the allocation
1835    /// rides the tensor desc path un-negotiated: the layout decision
1836    /// belongs to the platform allocator, and the request's guards and
1837    /// fallback counting live there (see
1838    /// [`Tensor::image_desc`](edgefirst_tensor::Tensor::image_desc)).
1839    pub fn create_image_desc(&self, desc: &edgefirst_tensor::ImageDesc) -> Result<TensorDyn> {
1840        if desc.compression().is_none() {
1841            return self.create_image(
1842                desc.width(),
1843                desc.height(),
1844                desc.format(),
1845                desc.dtype(),
1846                desc.memory(),
1847                desc.access(),
1848            );
1849        }
1850        Ok(TensorDyn::image_desc(desc)?)
1851    }
1852
1853    pub fn create_image(
1854        &self,
1855        width: usize,
1856        height: usize,
1857        format: PixelFormat,
1858        dtype: DType,
1859        memory: Option<TensorMemory>,
1860        access: edgefirst_tensor::CpuAccess,
1861    ) -> Result<TensorDyn> {
1862        // Compute the GPU-aligned row stride in bytes for this image.
1863        // `None` means either the format has no defined primary-plane bpp
1864        // (unknown future layout) or the stride calculation would overflow
1865        // — in both cases we fall back to the natural layout via the plain
1866        // `TensorDyn::image` constructor, and the slow-path warning inside
1867        // `draw_*_masks` will fire if the subsequent GL import fails.
1868        //
1869        // DMA allocation is Linux-only (see `TensorMemory::Dma` cfg gate),
1870        // so both the stride computation and the helper closure are gated
1871        // accordingly — the callers below are already Linux-only.
1872        #[cfg(target_os = "linux")]
1873        let dma_stride_bytes: Option<usize> = primary_plane_bpp(format, dtype.size())
1874            .and_then(|bpp| width.checked_mul(bpp))
1875            .and_then(align_pitch_bytes_to_gpu_alignment);
1876
1877        // Helper: allocate a DMA image, using the padded-stride constructor
1878        // when the computed stride exceeds the natural pitch, otherwise the
1879        // plain constructor (byte-identical result in the common case).
1880        #[cfg(target_os = "linux")]
1881        let try_dma = || -> Result<TensorDyn> {
1882            // Stride padding is only meaningful for packed pixel layouts
1883            // (RGBA8, BGRA8, RGB888, Grey) — the formats the GL backend
1884            // renders into. Semi-planar (NV12, NV16) and planar (PlanarRgb,
1885            // PlanarRgba) tensors go through `TensorDyn::image(...)` with
1886            // their natural layout; they're imported from camera capture
1887            // via `from_fd` far more often than allocated here, and
1888            // `Tensor::image_with_stride` explicitly rejects them.
1889            let packed = format.layout() == edgefirst_tensor::PixelLayout::Packed;
1890            match dma_stride_bytes {
1891                Some(stride)
1892                    if packed
1893                        && primary_plane_bpp(format, dtype.size())
1894                            .and_then(|bpp| width.checked_mul(bpp))
1895                            .is_some_and(|natural| stride > natural) =>
1896                {
1897                    log::debug!(
1898                        "create_image: padding row stride for {format:?} {width}x{height} \
1899                         from natural pitch to {stride} bytes for GPU alignment"
1900                    );
1901                    Ok(TensorDyn::image_with_stride(
1902                        width,
1903                        height,
1904                        format,
1905                        dtype,
1906                        stride,
1907                        Some(edgefirst_tensor::TensorMemory::Dma),
1908                        access,
1909                    )?)
1910                }
1911                _ => Ok(TensorDyn::image(
1912                    width,
1913                    height,
1914                    format,
1915                    dtype,
1916                    Some(edgefirst_tensor::TensorMemory::Dma),
1917                    access,
1918                )?),
1919            }
1920        };
1921
1922        // If an explicit memory type is requested, honour it directly.
1923        // On Linux, `TensorMemory::Dma` gets the padded-stride treatment;
1924        // other memory types take the user-requested width verbatim.
1925        // On macOS, `TensorMemory::Dma` dispatches through `TensorDyn::image`
1926        // which selects the IOSurface allocation path (FourCC-formatted)
1927        // for image-mappable formats, or falls back to SHM/Mem otherwise.
1928        match memory {
1929            #[cfg(target_os = "linux")]
1930            Some(TensorMemory::Dma) => {
1931                // F32 has no 32-bit-float DRM fourcc; callers must use PBO instead.
1932                if dtype == DType::F32 {
1933                    return Err(Error::NotSupported(
1934                        "F32 has no 32-bit-float DRM format for DMA-BUF; \
1935                         use TensorMemory::Pbo for F32"
1936                            .to_string(),
1937                    ));
1938                }
1939                return try_dma();
1940            }
1941            Some(mem) => {
1942                return Ok(TensorDyn::image(
1943                    width,
1944                    height,
1945                    format,
1946                    dtype,
1947                    Some(mem),
1948                    access,
1949                )?);
1950            }
1951            None => {}
1952        }
1953
1954        // macOS: when the GL backend is active with the IOSurface
1955        // transfer path, prefer Dma (IOSurface on Apple, AHardwareBuffer
1956        // on Android) for zero-copy import. Formats without a zero-copy
1957        // mapping now ERROR under explicit Dma (the explicit-Dma
1958        // contract), so auto-select catches that error here and falls
1959        // back to host storage — loudly, via the debug log below.
1960        #[cfg(any(target_os = "macos", target_os = "ios", target_os = "android"))]
1961        #[cfg(feature = "opengl")]
1962        if let Some(gl) = self.opengl.as_ref() {
1963            let _ = gl; // probe_transfer_backend lives behind the platform trait
1964            match TensorDyn::image(
1965                width,
1966                height,
1967                format,
1968                dtype,
1969                Some(edgefirst_tensor::TensorMemory::Dma),
1970                access,
1971            ) {
1972                Ok(img) => return Ok(img),
1973                Err(e) => {
1974                    // Falling back to a non-zero-copy destination is a real
1975                    // perf cliff — never do it silently (on-device triage
1976                    // starts from this line).
1977                    log::debug!(
1978                        "create_image: zero-copy Dma allocation declined \
1979                         ({format:?}/{dtype:?} {width}x{height}): {e:?}; using fallback storage"
1980                    );
1981                }
1982            }
1983        }
1984
1985        // Try DMA first on Linux — skip only when GL has explicitly selected PBO
1986        // as the preferred transfer path (PBO is better than DMA in that case).
1987        #[cfg(target_os = "linux")]
1988        {
1989            #[cfg(feature = "opengl")]
1990            let gl_uses_pbo = self
1991                .opengl
1992                .as_ref()
1993                .is_some_and(|gl| gl.transfer_backend() == opengl_headless::TransferBackend::Pbo);
1994            #[cfg(not(feature = "opengl"))]
1995            let gl_uses_pbo = false;
1996
1997            if !gl_uses_pbo {
1998                if let Ok(img) = try_dma() {
1999                    return Ok(img);
2000                }
2001            }
2002        }
2003
2004        // Try PBO (if GL available).
2005        // PBO buffers are u8-sized; the int8 shader emulates i8 output via
2006        // XOR 0x80 on the same underlying buffer, so both U8 and I8 work.
2007        #[cfg(target_os = "linux")]
2008        #[cfg(feature = "opengl")]
2009        if dtype.size() == 1 {
2010            if let Some(gl) = &self.opengl {
2011                match gl.create_pbo_image(width, height, format) {
2012                    Ok(t) => {
2013                        if dtype == DType::I8 {
2014                            // SAFETY: Tensor<u8> and Tensor<i8> are layout-
2015                            // identical (same element size, no T-dependent
2016                            // drop glue). The int8 shader applies XOR 0x80
2017                            // on the same PBO buffer. Same rationale as
2018                            // gl::processor::tensor_i8_as_u8_mut.
2019                            // Invariant: PBO tensors never have chroma
2020                            // (create_pbo_image → Tensor::wrap sets it None).
2021                            debug_assert!(
2022                                t.chroma().is_none(),
2023                                "PBO i8 transmute requires chroma == None"
2024                            );
2025                            let t_i8: Tensor<i8> = unsafe { std::mem::transmute(t) };
2026                            return Ok(TensorDyn::from(t_i8));
2027                        }
2028                        return Ok(TensorDyn::from(t));
2029                    }
2030                    Err(e) => log::debug!("PBO image creation failed, falling back to Mem: {e:?}"),
2031                }
2032            }
2033        }
2034
2035        // Try float PBO when the GPU backend reports support for this dtype.
2036        // Falls through to Mem on error (same policy as u8 PBO above).
2037        #[cfg(target_os = "linux")]
2038        #[cfg(feature = "opengl")]
2039        if float_pbo_eligible(dtype, self.supported_render_dtypes()) {
2040            if let Some(gl) = &self.opengl {
2041                match gl.create_pbo_image_dtype(width, height, format, dtype) {
2042                    Ok(t) => return Ok(t),
2043                    Err(e) => {
2044                        log::debug!(
2045                            "Float PBO image creation failed for {dtype:?}, \
2046                             falling back to Mem: {e:?}"
2047                        );
2048                    }
2049                }
2050            }
2051        }
2052
2053        // Fallback to Mem
2054        Ok(TensorDyn::image(
2055            width,
2056            height,
2057            format,
2058            dtype,
2059            Some(edgefirst_tensor::TensorMemory::Mem),
2060            access,
2061        )?)
2062    }
2063
2064    /// Import an external DMA-BUF image.
2065    ///
2066    /// Each [`PlaneDescriptor`] owns an already-duped fd; this method
2067    /// consumes the descriptors and takes ownership of those fds (whether
2068    /// the call succeeds or fails).
2069    ///
2070    /// The caller must ensure the DMA-BUF allocation is large enough for the
2071    /// specified width, height, format, and any stride/offset on the plane
2072    /// descriptors. No buffer-size validation is performed; an undersized
2073    /// buffer may cause GPU faults or EGL import failure.
2074    ///
2075    /// # Arguments
2076    ///
2077    /// * `image` - Plane descriptor for the primary (or only) plane
2078    /// * `chroma` - Optional plane descriptor for the UV chroma plane
2079    ///   (required for multiplane NV12)
2080    /// * `width` - Image width in pixels
2081    /// * `height` - Image height in pixels
2082    /// * `format` - Pixel format of the buffer
2083    /// * `dtype` - Element data type (e.g. `DType::U8`)
2084    ///
2085    /// # Returns
2086    ///
2087    /// A `TensorDyn` configured as an image.
2088    ///
2089    /// # Errors
2090    ///
2091    /// * [`Error::NotSupported`] if `chroma` is `Some` for a non-semi-planar
2092    ///   format, or multiplane NV16 (not yet supported), or the fd is not
2093    ///   DMA-backed
2094    /// * [`Error::InvalidShape`] if NV12 height is odd
2095    ///
2096    /// # Platform
2097    ///
2098    /// Linux only.
2099    ///
2100    /// # Examples
2101    ///
2102    /// ```rust,ignore
2103    /// use edgefirst_tensor::PlaneDescriptor;
2104    ///
2105    /// // Single-plane RGBA
2106    /// let pd = PlaneDescriptor::new(fd.as_fd())?;
2107    /// let src = proc.import_image(pd, None, 1920, 1080, PixelFormat::Rgba, DType::U8, None)?;
2108    ///
2109    /// // Multi-plane NV12 with stride
2110    /// let y_pd = PlaneDescriptor::new(y_fd.as_fd())?.with_stride(2048);
2111    /// let uv_pd = PlaneDescriptor::new(uv_fd.as_fd())?.with_stride(2048);
2112    /// let src = proc.import_image(y_pd, Some(uv_pd), 1920, 1080,
2113    ///                             PixelFormat::Nv12, DType::U8, None)?;
2114    /// ```
2115    // Import inherently needs plane(s) + geometry + format + dtype + colorimetry;
2116    // a params struct would obscure more than it clarifies here.
2117    #[allow(clippy::too_many_arguments)]
2118    #[cfg(target_os = "linux")]
2119    pub fn import_image(
2120        &self,
2121        image: edgefirst_tensor::PlaneDescriptor,
2122        chroma: Option<edgefirst_tensor::PlaneDescriptor>,
2123        width: usize,
2124        height: usize,
2125        format: PixelFormat,
2126        dtype: DType,
2127        colorimetry: Option<edgefirst_tensor::Colorimetry>,
2128    ) -> Result<TensorDyn> {
2129        use edgefirst_tensor::{Tensor, TensorMemory};
2130
2131        // Capture stride/offset from descriptors before consuming them
2132        let image_stride = image.stride();
2133        let image_offset = image.offset();
2134        let chroma_stride = chroma.as_ref().and_then(|c| c.stride());
2135        let chroma_offset = chroma.as_ref().and_then(|c| c.offset());
2136
2137        if let Some(chroma_pd) = chroma {
2138            // ── Multiplane path ──────────────────────────────────────
2139            // Multiplane tensors are backed by Tensor<u8> (or transmuted to
2140            // Tensor<i8>). Reject other dtypes to avoid silently returning a
2141            // tensor with the wrong element type.
2142            if dtype != DType::U8 && dtype != DType::I8 {
2143                return Err(Error::NotSupported(format!(
2144                    "multiplane import only supports U8/I8, got {dtype:?}"
2145                )));
2146            }
2147            if format.layout() != PixelLayout::SemiPlanar {
2148                return Err(Error::NotSupported(format!(
2149                    "import_image with chroma requires a semi-planar format, got {format:?}"
2150                )));
2151            }
2152
2153            let chroma_h = match format {
2154                PixelFormat::Nv12 => {
2155                    // NV12 (4:2:0): ceil(H/2) chroma rows — odd heights are valid.
2156                    height.div_ceil(2)
2157                }
2158                // NV16 multiplane will be supported in a future release;
2159                // the GL backend currently only handles NV12 plane1 attributes.
2160                PixelFormat::Nv16 => {
2161                    return Err(Error::NotSupported(
2162                        "multiplane NV16 is not yet supported; use contiguous NV16 instead".into(),
2163                    ))
2164                }
2165                _ => {
2166                    return Err(Error::NotSupported(format!(
2167                        "unsupported semi-planar format: {format:?}"
2168                    )))
2169                }
2170            };
2171
2172            let luma = Tensor::<u8>::from_fd(image.into_fd(), &[height, width], Some("luma"))?;
2173            if luma.memory() != TensorMemory::Dma {
2174                return Err(Error::NotSupported(format!(
2175                    "luma fd must be DMA-backed, got {:?}",
2176                    luma.memory()
2177                )));
2178            }
2179
2180            let chroma_tensor =
2181                Tensor::<u8>::from_fd(chroma_pd.into_fd(), &[chroma_h, width], Some("chroma"))?;
2182            if chroma_tensor.memory() != TensorMemory::Dma {
2183                return Err(Error::NotSupported(format!(
2184                    "chroma fd must be DMA-backed, got {:?}",
2185                    chroma_tensor.memory()
2186                )));
2187            }
2188
2189            // from_planes creates the combined tensor with format set,
2190            // preserving luma's row_stride (currently None since luma was raw).
2191            let mut tensor = Tensor::<u8>::from_planes(luma, chroma_tensor, format)?;
2192
2193            // Apply stride/offset to the combined tensor (luma plane)
2194            if let Some(s) = image_stride {
2195                tensor.set_row_stride(s)?;
2196            }
2197            if let Some(o) = image_offset {
2198                tensor.set_plane_offset(o);
2199            }
2200
2201            // Apply stride/offset to the chroma sub-tensor.
2202            // The chroma tensor is a raw 2D [chroma_h, width] tensor without
2203            // format metadata, so we validate stride manually rather than
2204            // using set_row_stride (which requires format).
2205            if let Some(chroma_ref) = tensor.chroma_mut() {
2206                if let Some(s) = chroma_stride {
2207                    if s < width {
2208                        return Err(Error::InvalidShape(format!(
2209                            "chroma stride {s} < minimum {width} for {format:?}"
2210                        )));
2211                    }
2212                    chroma_ref.set_row_stride_unchecked(s);
2213                }
2214                if let Some(o) = chroma_offset {
2215                    chroma_ref.set_plane_offset(o);
2216                }
2217            }
2218
2219            if dtype == DType::I8 {
2220                // SAFETY: Tensor<u8> and Tensor<i8> have identical layout because
2221                // the struct contains only type-erased storage (OwnedFd, shape, name),
2222                // no inline T values. This assertion catches layout drift at compile time.
2223                const {
2224                    assert!(std::mem::size_of::<Tensor<u8>>() == std::mem::size_of::<Tensor<i8>>());
2225                    assert!(
2226                        std::mem::align_of::<Tensor<u8>>() == std::mem::align_of::<Tensor<i8>>()
2227                    );
2228                }
2229                let tensor_i8: Tensor<i8> = unsafe { std::mem::transmute(tensor) };
2230                let mut dyn_tensor = TensorDyn::from(tensor_i8);
2231                dyn_tensor.set_colorimetry(colorimetry);
2232                return Ok(dyn_tensor);
2233            }
2234            let mut dyn_tensor = TensorDyn::from(tensor);
2235            dyn_tensor.set_colorimetry(colorimetry);
2236            Ok(dyn_tensor)
2237        } else {
2238            // ── Single-plane path ────────────────────────────────────
2239            // Canonical shape (Packed [H,W,C] / Planar [C,H,W] / SemiPlanar
2240            // [total_h, W]); `image_shape` supports NV12/NV16/NV24 (the old
2241            // hand-rolled match erroneously rejected NV24).
2242            let shape = format.image_shape(width, height).ok_or_else(|| {
2243                Error::NotSupported(format!(
2244                    "unsupported pixel format for import_image: {format:?}"
2245                ))
2246            })?;
2247            let tensor = TensorDyn::from_fd(image.into_fd(), &shape, dtype, None)?;
2248            if tensor.memory() != TensorMemory::Dma {
2249                return Err(Error::NotSupported(format!(
2250                    "import_image requires DMA-backed fd, got {:?}",
2251                    tensor.memory()
2252                )));
2253            }
2254            let mut tensor = tensor.with_format(format)?;
2255            if let Some(s) = image_stride {
2256                tensor.set_row_stride(s)?;
2257            }
2258            if let Some(o) = image_offset {
2259                tensor.set_plane_offset(o);
2260            }
2261            tensor.set_colorimetry(colorimetry);
2262            Ok(tensor)
2263        }
2264    }
2265
2266    /// Decode model outputs and draw segmentation masks onto `dst`.
2267    ///
2268    /// This is the primary mask rendering API. The processor decodes via the
2269    /// provided [`Decoder`], selects the optimal rendering path (hybrid
2270    /// CPU+GL or fused GPU), and composites masks onto `dst`.
2271    ///
2272    /// Returns the detected bounding boxes.
2273    pub fn draw_masks(
2274        &mut self,
2275        decoder: &edgefirst_decoder::Decoder,
2276        outputs: &[&TensorDyn],
2277        dst: &mut TensorDyn,
2278        overlay: MaskOverlay<'_>,
2279    ) -> Result<Vec<DetectBox>> {
2280        let mut output_boxes = Vec::with_capacity(100);
2281
2282        // Try proto path first (fused rendering without materializing masks)
2283        let proto_result = decoder
2284            .decode_proto(outputs, &mut output_boxes)
2285            .map_err(|e| Error::Internal(format!("decode_proto: {e:#?}")))?;
2286
2287        if let Some(proto_data) = proto_result {
2288            self.draw_proto_masks(dst, &output_boxes, &proto_data, overlay)?;
2289        } else {
2290            // Detection-only or unsupported model: full decode + render
2291            let mut output_masks = Vec::with_capacity(100);
2292            decoder
2293                .decode(outputs, &mut output_boxes, &mut output_masks)
2294                .map_err(|e| Error::Internal(format!("decode: {e:#?}")))?;
2295            self.draw_decoded_masks(dst, &output_boxes, &output_masks, overlay)?;
2296        }
2297        Ok(output_boxes)
2298    }
2299
2300    /// Decode tracked model outputs and draw segmentation masks onto `dst`.
2301    ///
2302    /// Like [`draw_masks`](Self::draw_masks) but integrates a tracker for
2303    /// maintaining object identities across frames. The tracker runs after
2304    /// NMS but before mask extraction.
2305    ///
2306    /// Returns detected boxes and track info.
2307    #[cfg(feature = "tracker")]
2308    pub fn draw_masks_tracked<TR: edgefirst_tracker::Tracker<DetectBox>>(
2309        &mut self,
2310        decoder: &edgefirst_decoder::Decoder,
2311        tracker: &mut TR,
2312        timestamp: u64,
2313        outputs: &[&TensorDyn],
2314        dst: &mut TensorDyn,
2315        overlay: MaskOverlay<'_>,
2316    ) -> Result<(Vec<DetectBox>, Vec<edgefirst_tracker::TrackInfo>)> {
2317        let mut output_boxes = Vec::with_capacity(100);
2318        let mut output_tracks = Vec::new();
2319
2320        let proto_result = decoder
2321            .decode_proto_tracked(
2322                tracker,
2323                timestamp,
2324                outputs,
2325                &mut output_boxes,
2326                &mut output_tracks,
2327            )
2328            .map_err(|e| Error::Internal(format!("decode_proto_tracked: {e:#?}")))?;
2329
2330        if let Some(proto_data) = proto_result {
2331            self.draw_proto_masks(dst, &output_boxes, &proto_data, overlay)?;
2332        } else {
2333            // Note: decode_proto_tracked returns None for detection-only/ModelPack
2334            // models WITHOUT calling the tracker. The else branch below is the
2335            // first (and only) tracker call for those model types.
2336            let mut output_masks = Vec::with_capacity(100);
2337            decoder
2338                .decode_tracked(
2339                    tracker,
2340                    timestamp,
2341                    outputs,
2342                    &mut output_boxes,
2343                    &mut output_masks,
2344                    &mut output_tracks,
2345                )
2346                .map_err(|e| Error::Internal(format!("decode_tracked: {e:#?}")))?;
2347            self.draw_decoded_masks(dst, &output_boxes, &output_masks, overlay)?;
2348        }
2349        Ok((output_boxes, output_tracks))
2350    }
2351
2352    /// Materialize per-instance segmentation masks from raw prototype data.
2353    ///
2354    /// Computes `mask_coeff @ protos` with sigmoid activation for each detection,
2355    /// producing compact masks at prototype resolution (e.g., 160×160 crops).
2356    /// Mask values are continuous sigmoid confidence outputs quantized to u8
2357    /// (0 = background, 255 = full confidence), NOT binary thresholded.
2358    ///
2359    /// The returned [`Vec<Segmentation>`] can be:
2360    /// - Inspected or exported for analytics, IoU computation, etc.
2361    /// - Passed directly to [`ImageProcessorTrait::draw_decoded_masks`] for
2362    ///   GPU-interpolated rendering.
2363    ///
2364    /// # Performance Note
2365    ///
2366    /// Calling `materialize_masks` + `draw_decoded_masks` separately prevents
2367    /// the HAL from using its internal fused optimization path. For render-only
2368    /// use cases, prefer [`ImageProcessorTrait::draw_proto_masks`] which selects
2369    /// the fastest path automatically (currently 1.6×–27× faster on tested
2370    /// platforms). Use this method when you need access to the intermediate masks.
2371    ///
2372    /// # Errors
2373    ///
2374    /// Returns [`Error::NoConverter`] if the CPU backend is not available.
2375    pub fn materialize_masks(
2376        &mut self,
2377        detect: &[DetectBox],
2378        proto_data: &ProtoData,
2379        letterbox: Option<[f32; 4]>,
2380        resolution: MaskResolution,
2381    ) -> Result<Vec<Segmentation>> {
2382        let cpu = self.cpu.as_mut().ok_or(Error::NoConverter)?;
2383        match resolution {
2384            MaskResolution::Proto => cpu.materialize_segmentations(detect, proto_data, letterbox),
2385            MaskResolution::Scaled { width, height } => {
2386                cpu.materialize_scaled_segmentations(detect, proto_data, letterbox, width, height)
2387            }
2388        }
2389    }
2390}
2391
2392impl ImageProcessorTrait for ImageProcessor {
2393    /// Converts the source image to the destination image format and size. The
2394    /// image is cropped first, then flipped, then rotated
2395    ///
2396    /// Prefer hardware accelerators when available, falling back to CPU if
2397    /// necessary.
2398    fn convert(
2399        &mut self,
2400        src: &TensorDyn,
2401        dst: &mut TensorDyn,
2402        rotation: Rotation,
2403        flip: Flip,
2404        crop: Crop,
2405    ) -> Result<()> {
2406        let start = Instant::now();
2407        let src_fmt = src.format();
2408        let dst_fmt = dst.format();
2409        let _span = tracing::trace_span!(
2410            "image.convert",
2411            ?src_fmt,
2412            ?dst_fmt,
2413            src_memory = ?src.memory(),
2414            dst_memory = ?dst.memory(),
2415            ?rotation,
2416            ?flip,
2417        )
2418        .entered();
2419        log::trace!(
2420            "convert: {src_fmt:?}({:?}/{:?}) → {dst_fmt:?}({:?}/{:?}), \
2421             rotation={rotation:?}, flip={flip:?}, backend={:?}",
2422            src.dtype(),
2423            src.memory(),
2424            dst.dtype(),
2425            dst.memory(),
2426            self.forced_backend,
2427        );
2428
2429        // ── Forced backend: no fallback chain ────────────────────────
2430        if let Some(forced) = self.forced_backend {
2431            return match forced {
2432                ForcedBackend::Cpu => {
2433                    if let Some(cpu) = self.cpu.as_mut() {
2434                        let r = cpu.convert(src, dst, rotation, flip, crop);
2435                        log::trace!(
2436                            "convert: forced=cpu result={} ({:?})",
2437                            if r.is_ok() { "ok" } else { "err" },
2438                            start.elapsed()
2439                        );
2440                        return r;
2441                    }
2442                    Err(Error::ForcedBackendUnavailable("cpu".into()))
2443                }
2444                ForcedBackend::G2d => {
2445                    #[cfg(target_os = "linux")]
2446                    if let Some(g2d) = self.g2d.as_mut() {
2447                        let r = g2d.convert(src, dst, rotation, flip, crop);
2448                        log::trace!(
2449                            "convert: forced=g2d result={} ({:?})",
2450                            if r.is_ok() { "ok" } else { "err" },
2451                            start.elapsed()
2452                        );
2453                        return r;
2454                    }
2455                    Err(Error::ForcedBackendUnavailable("g2d".into()))
2456                }
2457                ForcedBackend::OpenGl => {
2458                    #[cfg(any(
2459                        target_os = "linux",
2460                        target_os = "macos",
2461                        target_os = "ios",
2462                        target_os = "android"
2463                    ))]
2464                    #[cfg(feature = "opengl")]
2465                    if let Some(opengl) = self.opengl.as_mut() {
2466                        let r = opengl.convert(src, dst, rotation, flip, crop);
2467                        log::trace!(
2468                            "convert: forced=opengl result={} ({:?})",
2469                            if r.is_ok() { "ok" } else { "err" },
2470                            start.elapsed()
2471                        );
2472                        return r;
2473                    }
2474                    Err(Error::ForcedBackendUnavailable("opengl".into()))
2475                }
2476            };
2477        }
2478
2479        // ── Auto fallback chain: OpenGL → G2D → CPU ──────────────────
2480        #[cfg(any(
2481            target_os = "linux",
2482            target_os = "macos",
2483            target_os = "ios",
2484            target_os = "android"
2485        ))]
2486        #[cfg(feature = "opengl")]
2487        if let Some(opengl) = self.opengl.as_mut() {
2488            match opengl.convert(src, dst, rotation, flip, crop) {
2489                Ok(_) => {
2490                    log::trace!(
2491                        "convert: auto selected=opengl for {src_fmt:?}→{dst_fmt:?} ({:?})",
2492                        start.elapsed()
2493                    );
2494                    return Ok(());
2495                }
2496                Err(e) => {
2497                    self.convert_fallbacks
2498                        .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2499                    log::debug!(
2500                        "convert: auto opengl declined {src_fmt:?}@{:?}→{dst_fmt:?}@{:?}, \
2501                         falling back toward G2D/CPU: {e}",
2502                        src.memory(),
2503                        dst.memory(),
2504                    );
2505                }
2506            }
2507        }
2508
2509        #[cfg(target_os = "linux")]
2510        if let Some(g2d) = self.g2d.as_mut() {
2511            // G2D is matrix-only (no range control, no BT.2020). For any
2512            // conversion with a YUV side, resolve that side's colorimetry and
2513            // skip G2D entirely when it cannot be expressed (full-range YUV or
2514            // BT.2020), letting the chain fall through to GL/CPU which honour
2515            // range and BT.2020. YUV→RGB uses the source colorimetry; RGB→YUV
2516            // uses the destination. RGB→RGB has no YUV side and is unaffected.
2517            let src_is_yuv = src.format().is_some_and(|f| f.is_yuv());
2518            let dst_is_yuv = dst.format().is_some_and(|f| f.is_yuv());
2519            let g2d_eligible = if src_is_yuv || dst_is_yuv {
2520                let cm = if src_is_yuv {
2521                    crate::colorimetry::effective_colorimetry(src)
2522                } else {
2523                    crate::colorimetry::effective_colorimetry(dst)
2524                };
2525                crate::g2d::g2d_can_handle(&cm, true)
2526            } else {
2527                true
2528            };
2529            if !g2d_eligible {
2530                log::trace!(
2531                    "convert: auto g2d skipped {src_fmt:?}→{dst_fmt:?} \
2532                     (colorimetry not expressible: full-range/BT.2020)"
2533                );
2534            } else {
2535                match g2d.convert(src, dst, rotation, flip, crop) {
2536                    Ok(_) => {
2537                        log::trace!(
2538                            "convert: auto selected=g2d for {src_fmt:?}→{dst_fmt:?} ({:?})",
2539                            start.elapsed()
2540                        );
2541                        return Ok(());
2542                    }
2543                    Err(e) => {
2544                        log::trace!("convert: auto g2d declined {src_fmt:?}→{dst_fmt:?}: {e}");
2545                    }
2546                }
2547            }
2548        }
2549
2550        if let Some(cpu) = self.cpu.as_mut() {
2551            match cpu.convert(src, dst, rotation, flip, crop) {
2552                Ok(_) => {
2553                    log::trace!(
2554                        "convert: auto selected=cpu for {src_fmt:?}→{dst_fmt:?} ({:?})",
2555                        start.elapsed()
2556                    );
2557                    return Ok(());
2558                }
2559                Err(e) => {
2560                    log::trace!("convert: auto cpu failed {src_fmt:?}→{dst_fmt:?}: {e}");
2561                    return Err(e);
2562                }
2563            }
2564        }
2565        Err(Error::NoConverter)
2566    }
2567
2568    fn convert_deferred(
2569        &mut self,
2570        src: &TensorDyn,
2571        dst: &mut TensorDyn,
2572        rotation: Rotation,
2573        flip: Flip,
2574        crop: Crop,
2575    ) -> Result<()> {
2576        // Deferred batching is an OpenGL optimization (shared parent EGLImage +
2577        // no per-tile glFinish). Route to the GL backend's deferred path when GL
2578        // is forced or auto-selectable; on a GL decline fall back to an eager
2579        // convert (the auto chain), which is correct everywhere — it completes
2580        // synchronously and `flush` stays a no-op for non-GL backends.
2581        #[cfg(any(
2582            target_os = "linux",
2583            target_os = "macos",
2584            target_os = "ios",
2585            target_os = "android"
2586        ))]
2587        #[cfg(feature = "opengl")]
2588        {
2589            let gl_forced = matches!(self.forced_backend, Some(ForcedBackend::OpenGl));
2590            if gl_forced || self.forced_backend.is_none() {
2591                if let Some(opengl) = self.opengl.as_mut() {
2592                    match opengl.convert_deferred(src, dst, rotation, flip, crop) {
2593                        Ok(()) => return Ok(()),
2594                        Err(e) => {
2595                            log::trace!("convert_deferred: gl declined: {e}; eager fallback");
2596                            // A forced-GL caller gets the GL error, matching
2597                            // `convert`'s no-fallback forced-backend contract.
2598                            if gl_forced {
2599                                return Err(e);
2600                            }
2601                        }
2602                    }
2603                }
2604            }
2605        }
2606        self.convert(src, dst, rotation, flip, crop)
2607    }
2608
2609    fn flush(&mut self) -> Result<()> {
2610        let _span = tracing::trace_span!("image.flush").entered();
2611        // Only the OpenGL backend defers; flushing it issues the single GPU
2612        // sync. CPU/G2D converts already completed, so there is nothing to flush.
2613        #[cfg(any(
2614            target_os = "linux",
2615            target_os = "macos",
2616            target_os = "ios",
2617            target_os = "android"
2618        ))]
2619        #[cfg(feature = "opengl")]
2620        if let Some(opengl) = self.opengl.as_mut() {
2621            return opengl.flush();
2622        }
2623        Ok(())
2624    }
2625
2626    fn draw_decoded_masks(
2627        &mut self,
2628        dst: &mut TensorDyn,
2629        detect: &[DetectBox],
2630        segmentation: &[Segmentation],
2631        overlay: MaskOverlay<'_>,
2632    ) -> Result<()> {
2633        let _span = tracing::trace_span!(
2634            "image.draw_decoded_masks",
2635            n_detections = detect.len(),
2636            n_segmentations = segmentation.len(),
2637        )
2638        .entered();
2639        let start = Instant::now();
2640
2641        if let Some(bg) = overlay.background {
2642            if bg.aliases(dst) {
2643                return Err(Error::AliasedBuffers(
2644                    "background must not reference the same buffer as dst".to_string(),
2645                ));
2646            }
2647        }
2648
2649        // Un-letterbox detect boxes and segmentation bboxes for rendering when
2650        // a letterbox was applied to prepare the model input.
2651        let lb_boxes: Vec<DetectBox>;
2652        let lb_segs: Vec<Segmentation>;
2653        let (detect, segmentation) = if let Some(lb) = overlay.letterbox {
2654            lb_boxes = detect.iter().map(|&d| unletter_bbox(d, lb)).collect();
2655            // Keep segmentation bboxes in sync with the transformed detect boxes
2656            // when we have a 1:1 correspondence (instance segmentation).
2657            lb_segs = if segmentation.len() == lb_boxes.len() {
2658                segmentation
2659                    .iter()
2660                    .zip(lb_boxes.iter())
2661                    .map(|(s, d)| Segmentation {
2662                        xmin: d.bbox.xmin,
2663                        ymin: d.bbox.ymin,
2664                        xmax: d.bbox.xmax,
2665                        ymax: d.bbox.ymax,
2666                        segmentation: s.segmentation.clone(),
2667                    })
2668                    .collect()
2669            } else {
2670                segmentation.to_vec()
2671            };
2672            (lb_boxes.as_slice(), lb_segs.as_slice())
2673        } else {
2674            (detect, segmentation)
2675        };
2676        #[cfg(target_os = "linux")]
2677        let is_empty_frame = detect.is_empty() && segmentation.is_empty();
2678
2679        // ── Forced backend: no fallback chain ────────────────────────
2680        if let Some(forced) = self.forced_backend {
2681            return match forced {
2682                ForcedBackend::Cpu => {
2683                    if let Some(cpu) = self.cpu.as_mut() {
2684                        return cpu.draw_decoded_masks(dst, detect, segmentation, overlay);
2685                    }
2686                    Err(Error::ForcedBackendUnavailable("cpu".into()))
2687                }
2688                ForcedBackend::G2d => {
2689                    // G2D can only produce empty frames (clear / bg blit).
2690                    // For populated frames it has no rasterizer — fail loudly.
2691                    #[cfg(target_os = "linux")]
2692                    if let Some(g2d) = self.g2d.as_mut() {
2693                        return g2d.draw_decoded_masks(dst, detect, segmentation, overlay);
2694                    }
2695                    Err(Error::ForcedBackendUnavailable("g2d".into()))
2696                }
2697                ForcedBackend::OpenGl => {
2698                    // GL handles background natively via GPU blit, and now
2699                    // actively clears when there is no background.
2700                    #[cfg(target_os = "linux")]
2701                    #[cfg(feature = "opengl")]
2702                    if let Some(opengl) = self.opengl.as_mut() {
2703                        return opengl.draw_decoded_masks(dst, detect, segmentation, overlay);
2704                    }
2705                    Err(Error::ForcedBackendUnavailable("opengl".into()))
2706                }
2707            };
2708        }
2709
2710        // ── Auto dispatch ──────────────────────────────────────────
2711        // Empty frames prefer G2D when available — a single g2d_clear or
2712        // g2d_blit is the cheapest HW path to produce the correct output
2713        // and avoids spinning up the GL pipeline every zero-detection
2714        // frame in a triple-buffered display loop.
2715        #[cfg(target_os = "linux")]
2716        if is_empty_frame {
2717            if let Some(g2d) = self.g2d.as_mut() {
2718                match g2d.draw_decoded_masks(dst, detect, segmentation, overlay) {
2719                    Ok(_) => {
2720                        log::trace!(
2721                            "draw_decoded_masks empty frame via g2d in {:?}",
2722                            start.elapsed()
2723                        );
2724                        return Ok(());
2725                    }
2726                    Err(e) => log::trace!("g2d empty-frame path unavailable: {e:?}"),
2727                }
2728            }
2729        }
2730
2731        // Populated frames (or G2D unavailable): GL first, CPU fallback.
2732        // Both backends now own their own base-layer handling (bg blit
2733        // or clear), so we hand the overlay through untouched.
2734        #[cfg(target_os = "linux")]
2735        #[cfg(feature = "opengl")]
2736        if let Some(opengl) = self.opengl.as_mut() {
2737            log::trace!(
2738                "draw_decoded_masks started with opengl in {:?}",
2739                start.elapsed()
2740            );
2741            match opengl.draw_decoded_masks(dst, detect, segmentation, overlay) {
2742                Ok(_) => {
2743                    log::trace!("draw_decoded_masks with opengl in {:?}", start.elapsed());
2744                    return Ok(());
2745                }
2746                Err(e) => {
2747                    log::trace!("draw_decoded_masks didn't work with opengl: {e:?}")
2748                }
2749            }
2750        }
2751
2752        log::trace!(
2753            "draw_decoded_masks started with cpu in {:?}",
2754            start.elapsed()
2755        );
2756        if let Some(cpu) = self.cpu.as_mut() {
2757            match cpu.draw_decoded_masks(dst, detect, segmentation, overlay) {
2758                Ok(_) => {
2759                    log::trace!("draw_decoded_masks with cpu in {:?}", start.elapsed());
2760                    return Ok(());
2761                }
2762                Err(e) => {
2763                    log::trace!("draw_decoded_masks didn't work with cpu: {e:?}");
2764                    return Err(e);
2765                }
2766            }
2767        }
2768        Err(Error::NoConverter)
2769    }
2770
2771    fn draw_proto_masks(
2772        &mut self,
2773        dst: &mut TensorDyn,
2774        detect: &[DetectBox],
2775        proto_data: &ProtoData,
2776        overlay: MaskOverlay<'_>,
2777    ) -> Result<()> {
2778        let start = Instant::now();
2779
2780        if let Some(bg) = overlay.background {
2781            if bg.aliases(dst) {
2782                return Err(Error::AliasedBuffers(
2783                    "background must not reference the same buffer as dst".to_string(),
2784                ));
2785            }
2786        }
2787
2788        // Un-letterbox detect boxes for rendering when a letterbox was applied
2789        // to prepare the model input.  The original `detect` coords are still
2790        // passed to `materialize_segmentations` (which needs model-space coords
2791        // to correctly crop the proto tensor) alongside `overlay.letterbox` so
2792        // it can emit `Segmentation` structs in output-image space.
2793        let lb_boxes: Vec<DetectBox>;
2794        let render_detect = if let Some(lb) = overlay.letterbox {
2795            lb_boxes = detect.iter().map(|&d| unletter_bbox(d, lb)).collect();
2796            lb_boxes.as_slice()
2797        } else {
2798            detect
2799        };
2800        #[cfg(target_os = "linux")]
2801        let is_empty_frame = detect.is_empty();
2802
2803        // ── Forced backend: no fallback chain ────────────────────────
2804        if let Some(forced) = self.forced_backend {
2805            return match forced {
2806                ForcedBackend::Cpu => {
2807                    if let Some(cpu) = self.cpu.as_mut() {
2808                        return cpu.draw_proto_masks(dst, render_detect, proto_data, overlay);
2809                    }
2810                    Err(Error::ForcedBackendUnavailable("cpu".into()))
2811                }
2812                ForcedBackend::G2d => {
2813                    #[cfg(target_os = "linux")]
2814                    if let Some(g2d) = self.g2d.as_mut() {
2815                        return g2d.draw_proto_masks(dst, render_detect, proto_data, overlay);
2816                    }
2817                    Err(Error::ForcedBackendUnavailable("g2d".into()))
2818                }
2819                ForcedBackend::OpenGl => {
2820                    #[cfg(target_os = "linux")]
2821                    #[cfg(feature = "opengl")]
2822                    if let Some(opengl) = self.opengl.as_mut() {
2823                        return opengl.draw_proto_masks(dst, render_detect, proto_data, overlay);
2824                    }
2825                    Err(Error::ForcedBackendUnavailable("opengl".into()))
2826                }
2827            };
2828        }
2829
2830        // ── Auto dispatch ──────────────────────────────────────────
2831        // Empty frames: prefer G2D — cheapest HW path (clear or bg blit).
2832        #[cfg(target_os = "linux")]
2833        if is_empty_frame {
2834            if let Some(g2d) = self.g2d.as_mut() {
2835                match g2d.draw_proto_masks(dst, render_detect, proto_data, overlay) {
2836                    Ok(_) => {
2837                        log::trace!(
2838                            "draw_proto_masks empty frame via g2d in {:?}",
2839                            start.elapsed()
2840                        );
2841                        return Ok(());
2842                    }
2843                    Err(e) => log::trace!("g2d empty-frame path unavailable: {e:?}"),
2844                }
2845            }
2846        }
2847
2848        // Hybrid path: CPU materialize + GL overlay (benchmarked faster than
2849        // full-GPU draw_proto_masks on all tested platforms: 27× on imx8mp,
2850        // 4× on imx95, 2.5× on rpi5, 1.6× on x86).
2851        // GL owns its own bg-blit / glClear — we pass the overlay through.
2852        //
2853        // CPU materialize needs `&mut` for its MaskScratch buffers; GL also
2854        // needs `&mut`. The CPU borrow is scoped to its block so the
2855        // subsequent GL borrow is free to take over `self`.
2856        #[cfg(target_os = "linux")]
2857        #[cfg(feature = "opengl")]
2858        if let (Some(_), Some(_)) = (self.cpu.as_ref(), self.opengl.as_ref()) {
2859            let segmentation = match self.cpu.as_mut() {
2860                Some(cpu) => {
2861                    log::trace!(
2862                        "draw_proto_masks started with hybrid (cpu+opengl) in {:?}",
2863                        start.elapsed()
2864                    );
2865                    cpu.materialize_segmentations(detect, proto_data, overlay.letterbox)?
2866                }
2867                None => unreachable!("cpu presence checked above"),
2868            };
2869            if let Some(opengl) = self.opengl.as_mut() {
2870                match opengl.draw_decoded_masks(dst, render_detect, &segmentation, overlay) {
2871                    Ok(_) => {
2872                        log::trace!(
2873                            "draw_proto_masks with hybrid (cpu+opengl) in {:?}",
2874                            start.elapsed()
2875                        );
2876                        return Ok(());
2877                    }
2878                    Err(e) => {
2879                        log::trace!(
2880                            "draw_proto_masks hybrid path failed, falling back to cpu: {e:?}"
2881                        );
2882                    }
2883                }
2884            }
2885        }
2886
2887        let Some(cpu) = self.cpu.as_mut() else {
2888            return Err(Error::Internal(
2889                "draw_proto_masks requires CPU backend for fallback path".into(),
2890            ));
2891        };
2892        log::trace!("draw_proto_masks started with cpu in {:?}", start.elapsed());
2893        cpu.draw_proto_masks(dst, render_detect, proto_data, overlay)
2894    }
2895
2896    fn set_class_colors(&mut self, colors: &[[u8; 4]]) -> Result<()> {
2897        let start = Instant::now();
2898
2899        // ── Forced backend: no fallback chain ────────────────────────
2900        if let Some(forced) = self.forced_backend {
2901            return match forced {
2902                ForcedBackend::Cpu => {
2903                    if let Some(cpu) = self.cpu.as_mut() {
2904                        return cpu.set_class_colors(colors);
2905                    }
2906                    Err(Error::ForcedBackendUnavailable("cpu".into()))
2907                }
2908                ForcedBackend::G2d => Err(Error::NotSupported(
2909                    "g2d does not support set_class_colors".into(),
2910                )),
2911                ForcedBackend::OpenGl => {
2912                    #[cfg(target_os = "linux")]
2913                    #[cfg(feature = "opengl")]
2914                    if let Some(opengl) = self.opengl.as_mut() {
2915                        return opengl.set_class_colors(colors);
2916                    }
2917                    Err(Error::ForcedBackendUnavailable("opengl".into()))
2918                }
2919            };
2920        }
2921
2922        // skip G2D as it doesn't support rendering to image
2923
2924        #[cfg(target_os = "linux")]
2925        #[cfg(feature = "opengl")]
2926        if let Some(opengl) = self.opengl.as_mut() {
2927            log::trace!("image started with opengl in {:?}", start.elapsed());
2928            match opengl.set_class_colors(colors) {
2929                Ok(_) => {
2930                    log::trace!("colors set with opengl in {:?}", start.elapsed());
2931                    return Ok(());
2932                }
2933                Err(e) => {
2934                    log::trace!("colors didn't set with opengl: {e:?}")
2935                }
2936            }
2937        }
2938        log::trace!("image started with cpu in {:?}", start.elapsed());
2939        if let Some(cpu) = self.cpu.as_mut() {
2940            match cpu.set_class_colors(colors) {
2941                Ok(_) => {
2942                    log::trace!("colors set with cpu in {:?}", start.elapsed());
2943                    return Ok(());
2944                }
2945                Err(e) => {
2946                    log::trace!("colors didn't set with cpu: {e:?}");
2947                    return Err(e);
2948                }
2949            }
2950        }
2951        Err(Error::NoConverter)
2952    }
2953}
2954
2955// ---------------------------------------------------------------------------
2956// Image loading / saving helpers
2957// ---------------------------------------------------------------------------
2958
2959/// Test-only convenience helper that peeks the image header, allocates a
2960/// tensor sized to the image (honoring DMA pitch padding on Linux when
2961/// requested), and decodes via [`edgefirst_codec`]. Mirrors the semantics of
2962/// the removed public `load_image` API for test sites; production callers
2963/// should use the explicit peek → allocate → decode pattern directly.
2964#[cfg(test)]
2965pub(crate) fn load_image_test_helper(
2966    image: &[u8],
2967    format: Option<PixelFormat>,
2968    memory: Option<TensorMemory>,
2969) -> Result<TensorDyn> {
2970    use edgefirst_codec::{peek_info, ImageDecoder, ImageLoad};
2971
2972    // Peek the source header to get its NATIVE format and dimensions. The
2973    // codec now emits the source's native format (JPEG → Nv12/Grey, PNG →
2974    // Rgb/Rgba/Grey) and configures the destination tensor itself.
2975    let info = peek_info(image)?;
2976    let native_fmt = info.format;
2977    let w = info.width;
2978    let h = info.height;
2979
2980    let mut decoder = ImageDecoder::new();
2981
2982    // Decode into a native-format tensor. The decoder sets the tensor's
2983    // dims+format, so we allocate it sized to the native layout.
2984    #[cfg(target_os = "linux")]
2985    let native_src = {
2986        if let Some(aligned_pitch) = padded_dma_pitch_for(native_fmt, w, &memory) {
2987            let mut dma = Tensor::<u8>::image_with_stride(
2988                w,
2989                h,
2990                native_fmt,
2991                aligned_pitch,
2992                Some(TensorMemory::Dma),
2993                edgefirst_tensor::CpuAccess::ReadWrite,
2994            )?;
2995            dma.load_image(&mut decoder, image)?;
2996            TensorDyn::from(dma)
2997        } else {
2998            let mut img = Tensor::<u8>::image(
2999                w,
3000                h,
3001                native_fmt,
3002                memory,
3003                edgefirst_tensor::CpuAccess::ReadWrite,
3004            )?;
3005            img.load_image(&mut decoder, image)?;
3006            TensorDyn::from(img)
3007        }
3008    };
3009    #[cfg(not(target_os = "linux"))]
3010    let native_src = {
3011        let mut img = Tensor::<u8>::image(
3012            w,
3013            h,
3014            native_fmt,
3015            memory,
3016            edgefirst_tensor::CpuAccess::ReadWrite,
3017        )?;
3018        img.load_image(&mut decoder, image)?;
3019        TensorDyn::from(img)
3020    };
3021
3022    // If the caller requested a different format, convert into it (same
3023    // dims) using a headless CPU-backed processor so the helper works
3024    // without GPU/G2D hardware.
3025    match format {
3026        Some(f) if f != native_fmt => {
3027            let mut dst = TensorDyn::image(
3028                w,
3029                h,
3030                f,
3031                DType::U8,
3032                memory,
3033                edgefirst_tensor::CpuAccess::ReadWrite,
3034            )?;
3035            // `ImageProcessorConfig` has platform-specific fields: on Linux it
3036            // carries extra GL/G2D options so `..Default::default()` is needed,
3037            // but on macOS `backend` is the only field, making the update
3038            // redundant (clippy::needless_update). Allow it for cross-platform
3039            // parity — the alternative (field reassign) trips
3040            // clippy::field_reassign_with_default on Linux instead.
3041            #[allow(clippy::needless_update)]
3042            let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
3043                backend: ComputeBackend::Cpu,
3044                ..Default::default()
3045            })?;
3046            proc.convert(
3047                &native_src,
3048                &mut dst,
3049                Rotation::None,
3050                Flip::None,
3051                Crop::default(),
3052            )?;
3053            Ok(dst)
3054        }
3055        _ => Ok(native_src),
3056    }
3057}
3058
3059/// Save a [`TensorDyn`] image as a JPEG file.
3060///
3061/// Only packed RGB and RGBA formats are supported.
3062pub fn save_jpeg(tensor: &TensorDyn, path: impl AsRef<std::path::Path>, quality: u8) -> Result<()> {
3063    let t = tensor.as_u8().ok_or(Error::UnsupportedFormat(
3064        "save_jpeg requires u8 tensor".to_string(),
3065    ))?;
3066    let fmt = t.format().ok_or(Error::NotAnImage)?;
3067    if fmt.layout() != PixelLayout::Packed {
3068        return Err(Error::NotImplemented(
3069            "Saving planar images is not supported".to_string(),
3070        ));
3071    }
3072
3073    let colour = match fmt {
3074        PixelFormat::Rgb => jpeg_encoder::ColorType::Rgb,
3075        PixelFormat::Rgba => jpeg_encoder::ColorType::Rgba,
3076        _ => {
3077            return Err(Error::NotImplemented(
3078                "Unsupported image format for saving".to_string(),
3079            ));
3080        }
3081    };
3082
3083    let w = t.width().ok_or(Error::NotAnImage)?;
3084    let h = t.height().ok_or(Error::NotAnImage)?;
3085    let encoder = jpeg_encoder::Encoder::new_file(path, quality)?;
3086    let tensor_map = t.map_read()?;
3087
3088    encoder.encode(&tensor_map, w as u16, h as u16, colour)?;
3089
3090    Ok(())
3091}
3092
3093pub(crate) struct FunctionTimer<T: Display> {
3094    name: T,
3095    start: std::time::Instant,
3096}
3097
3098impl<T: Display> FunctionTimer<T> {
3099    pub fn new(name: T) -> Self {
3100        Self {
3101            name,
3102            start: std::time::Instant::now(),
3103        }
3104    }
3105}
3106
3107impl<T: Display> Drop for FunctionTimer<T> {
3108    fn drop(&mut self) {
3109        log::trace!("{} elapsed: {:?}", self.name, self.start.elapsed())
3110    }
3111}
3112
3113const DEFAULT_COLORS: [[f32; 4]; 20] = [
3114    [0., 1., 0., 0.7],
3115    [1., 0.5568628, 0., 0.7],
3116    [0.25882353, 0.15294118, 0.13333333, 0.7],
3117    [0.8, 0.7647059, 0.78039216, 0.7],
3118    [0.3137255, 0.3137255, 0.3137255, 0.7],
3119    [0.1411765, 0.3098039, 0.1215686, 0.7],
3120    [1., 0.95686275, 0.5137255, 0.7],
3121    [0.3529412, 0.32156863, 0., 0.7],
3122    [0.4235294, 0.6235294, 0.6509804, 0.7],
3123    [0.5098039, 0.5098039, 0.7294118, 0.7],
3124    [0.00784314, 0.18823529, 0.29411765, 0.7],
3125    [0.0, 0.2706, 1.0, 0.7],
3126    [0.0, 0.0, 0.0, 0.7],
3127    [0.0, 0.5, 0.0, 0.7],
3128    [1.0, 0.0, 0.0, 0.7],
3129    [0.0, 0.0, 1.0, 0.7],
3130    [1.0, 0.5, 0.5, 0.7],
3131    [0.1333, 0.5451, 0.1333, 0.7],
3132    [0.1176, 0.4118, 0.8235, 0.7],
3133    [1., 1., 1., 0.7],
3134];
3135
3136const fn denorm<const M: usize, const N: usize>(a: [[f32; M]; N]) -> [[u8; M]; N] {
3137    let mut result = [[0; M]; N];
3138    let mut i = 0;
3139    while i < N {
3140        let mut j = 0;
3141        while j < M {
3142            result[i][j] = (a[i][j] * 255.0).round() as u8;
3143            j += 1;
3144        }
3145        i += 1;
3146    }
3147    result
3148}
3149
3150const DEFAULT_COLORS_U8: [[u8; 4]; 20] = denorm(DEFAULT_COLORS);
3151
3152#[cfg(test)]
3153#[cfg_attr(coverage_nightly, coverage(off))]
3154mod alignment_tests {
3155    use super::*;
3156
3157    #[test]
3158    fn align_width_rgba8_common_widths() {
3159        // RGBA8 (bpp=4, lcm(64,4)=64, so width must round to multiple of 16 px).
3160        assert_eq!(align_width_for_gpu_pitch(640, 4), 640); // 2560 byte pitch — already aligned
3161        assert_eq!(align_width_for_gpu_pitch(1280, 4), 1280); // 5120
3162        assert_eq!(align_width_for_gpu_pitch(1920, 4), 1920); // 7680
3163        assert_eq!(align_width_for_gpu_pitch(3840, 4), 3840); // 15360
3164                                                              // crowd.png case from the imx95 investigation:
3165        assert_eq!(align_width_for_gpu_pitch(3004, 4), 3008); // 12016 → 12032
3166        assert_eq!(align_width_for_gpu_pitch(3000, 4), 3008); // 12000 → 12032
3167        assert_eq!(align_width_for_gpu_pitch(17, 4), 32); // 68 → 128
3168        assert_eq!(align_width_for_gpu_pitch(1, 4), 16); // 4 → 64
3169    }
3170
3171    #[test]
3172    fn align_width_rgb888_packed() {
3173        // RGB888 (bpp=3, lcm(64,3)=192, so width must round to multiple of 64 px).
3174        assert_eq!(align_width_for_gpu_pitch(64, 3), 64); // 192 byte pitch
3175        assert_eq!(align_width_for_gpu_pitch(640, 3), 640); // 1920
3176        assert_eq!(align_width_for_gpu_pitch(1, 3), 64); // 3 → 192
3177        assert_eq!(align_width_for_gpu_pitch(65, 3), 128); // 195 → 384
3178                                                           // Verify the rounded width × bpp is a clean multiple of the LCM.
3179        for w in [3004usize, 1281, 100, 17] {
3180            let padded = align_width_for_gpu_pitch(w, 3);
3181            assert!(padded >= w);
3182            assert_eq!((padded * 3) % 64, 0);
3183            assert_eq!((padded * 3) % 3, 0);
3184        }
3185    }
3186
3187    #[test]
3188    fn align_width_grey_u8() {
3189        // Grey (bpp=1, lcm(64,1)=64, so width must round to multiple of 64 px).
3190        assert_eq!(align_width_for_gpu_pitch(64, 1), 64);
3191        assert_eq!(align_width_for_gpu_pitch(640, 1), 640);
3192        assert_eq!(align_width_for_gpu_pitch(1, 1), 64);
3193        assert_eq!(align_width_for_gpu_pitch(65, 1), 128);
3194    }
3195
3196    #[test]
3197    fn align_width_zero_inputs() {
3198        assert_eq!(align_width_for_gpu_pitch(0, 4), 0);
3199        assert_eq!(align_width_for_gpu_pitch(640, 0), 640);
3200    }
3201
3202    #[test]
3203    fn align_width_never_returns_smaller_than_input() {
3204        // Spot-check the "returned width >= input width" contract across a
3205        // range of values that would previously have hit `width * bpp`
3206        // overflow paths.
3207        for &bpp in &[1usize, 2, 3, 4, 8] {
3208            for &w in &[
3209                1usize,
3210                17,
3211                64,
3212                65,
3213                100,
3214                1280,
3215                1281,
3216                1920,
3217                3004,
3218                3072,
3219                3840,
3220                usize::MAX / 8,
3221                usize::MAX / 4,
3222                usize::MAX / 2,
3223                usize::MAX - 1,
3224                usize::MAX,
3225            ] {
3226                let aligned = align_width_for_gpu_pitch(w, bpp);
3227                assert!(
3228                    aligned >= w,
3229                    "align_width_for_gpu_pitch({w}, {bpp}) = {aligned} < {w}"
3230                );
3231            }
3232        }
3233    }
3234
3235    #[test]
3236    fn align_width_overflow_returns_unaligned_not_smaller() {
3237        // For width values close to usize::MAX, padding up would wrap. The
3238        // function must return the original width rather than wrapping or
3239        // panicking. A pre-aligned width round-trips unchanged even at the
3240        // extreme.
3241        let aligned_extreme = usize::MAX - 15; // 16-pixel boundary for RGBA8
3242        assert_eq!(
3243            align_width_for_gpu_pitch(aligned_extreme, 4),
3244            aligned_extreme
3245        );
3246        // A misaligned extreme value cannot be rounded up — the function
3247        // returns the original.
3248        let misaligned_extreme = usize::MAX - 1;
3249        let result = align_width_for_gpu_pitch(misaligned_extreme, 4);
3250        assert!(
3251            result == misaligned_extreme || result >= misaligned_extreme,
3252            "extreme misaligned width must not be rounded down to {result}"
3253        );
3254    }
3255
3256    #[test]
3257    fn checked_lcm_basic_and_overflow() {
3258        assert_eq!(checked_num_integer_lcm(64, 4), Some(64));
3259        assert_eq!(checked_num_integer_lcm(64, 3), Some(192));
3260        assert_eq!(checked_num_integer_lcm(64, 1), Some(64));
3261        assert_eq!(checked_num_integer_lcm(0, 4), Some(0));
3262        assert_eq!(checked_num_integer_lcm(64, 0), Some(0));
3263        // Coprime values whose product exceeds usize::MAX must return None.
3264        assert_eq!(
3265            checked_num_integer_lcm(usize::MAX, usize::MAX - 1),
3266            None,
3267            "coprime extreme values must overflow detect, not panic"
3268        );
3269    }
3270
3271    #[test]
3272    fn primary_plane_bpp_known_formats() {
3273        // Packed formats use channels × elem_size.
3274        assert_eq!(primary_plane_bpp(PixelFormat::Rgba, 1), Some(4));
3275        assert_eq!(primary_plane_bpp(PixelFormat::Bgra, 1), Some(4));
3276        assert_eq!(primary_plane_bpp(PixelFormat::Rgb, 1), Some(3));
3277        assert_eq!(primary_plane_bpp(PixelFormat::Grey, 1), Some(1));
3278        // Semi-planar (NV12) reports the luma plane's bpp.
3279        assert_eq!(primary_plane_bpp(PixelFormat::Nv12, 1), Some(1));
3280    }
3281}
3282
3283#[cfg(test)]
3284#[cfg_attr(coverage_nightly, coverage(off))]
3285#[allow(deprecated)]
3286mod image_tests {
3287    use super::*;
3288    use crate::{CPUProcessor, Rotation};
3289    #[cfg(target_os = "linux")]
3290    use edgefirst_tensor::is_dma_available;
3291    use edgefirst_tensor::{TensorMapTrait, TensorMemory, TensorTrait};
3292    use image::buffer::ConvertBuffer;
3293
3294    /// Test helper: call `ImageProcessorTrait::convert()` on two `TensorDyn`s
3295    /// by going through the `TensorDyn` API.
3296    ///
3297    /// Returns the `(src_image, dst_image)` reconstructed from the TensorDyn
3298    /// round-trip so the caller can feed them to `compare_images` etc.
3299    fn convert_img(
3300        proc: &mut dyn ImageProcessorTrait,
3301        src: TensorDyn,
3302        dst: TensorDyn,
3303        rotation: Rotation,
3304        flip: Flip,
3305        crop: Crop,
3306    ) -> (Result<()>, TensorDyn, TensorDyn) {
3307        let src_fourcc = src.format().unwrap();
3308        let dst_fourcc = dst.format().unwrap();
3309        let src_dyn = src;
3310        let mut dst_dyn = dst;
3311        let result = proc.convert(&src_dyn, &mut dst_dyn, rotation, flip, crop);
3312        let src_back = {
3313            let mut __t = src_dyn.into_u8().unwrap();
3314            __t.set_format(src_fourcc).unwrap();
3315            TensorDyn::from(__t)
3316        };
3317        let dst_back = {
3318            let mut __t = dst_dyn.into_u8().unwrap();
3319            __t.set_format(dst_fourcc).unwrap();
3320            TensorDyn::from(__t)
3321        };
3322        (result, src_back, dst_back)
3323    }
3324
3325    #[ctor::ctor(unsafe)]
3326    fn init() {
3327        env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
3328    }
3329
3330    macro_rules! function {
3331        () => {{
3332            fn f() {}
3333            fn type_name_of<T>(_: T) -> &'static str {
3334                std::any::type_name::<T>()
3335            }
3336            let name = type_name_of(f);
3337
3338            // Find and cut the rest of the path
3339            match &name[..name.len() - 3].rfind(':') {
3340                Some(pos) => &name[pos + 1..name.len() - 3],
3341                None => &name[..name.len() - 3],
3342            }
3343        }};
3344    }
3345
3346    /// Master oracle for the view/batch **destination** batch engine: render `N`
3347    /// tiles into row-bands of ONE tall destination and assert each band equals
3348    /// the same source converted standalone — proving correct band placement and
3349    /// that a later tile's letterbox clear / draw never wipes a sibling band. On
3350    /// the Linux GL backend the `N` `convert_deferred` calls share ONE parent
3351    /// EGLImage import (each tile is a `glViewport`/`glScissor` ROI) and sync
3352    /// once at `flush()`; other backends fall back to an eager per-band convert
3353    /// (CPU writes via offset + parent stride). Either way the oracle must hold.
3354    ///
3355    /// Identical source/tile size makes the convert an exact copy, so the
3356    /// assertion is backend-agnostic (no GL-vs-CPU resampling drift). Distinct
3357    /// solid colors per tile make any sibling wipe a hard failure.
3358    #[test]
3359    fn batch_view_dst_tiles_match_standalone() {
3360        let mut proc = match ImageProcessor::new() {
3361            Ok(p) => p,
3362            Err(e) => {
3363                eprintln!(
3364                    "SKIPPED: {} — ImageProcessor init failed ({e:?})",
3365                    function!()
3366                );
3367                return;
3368            }
3369        };
3370        let n = 3usize;
3371        let (w, h) = (32usize, 24usize);
3372        let colors: [[u8; 4]; 3] = [[210, 40, 40, 255], [40, 210, 40, 255], [40, 40, 210, 255]];
3373        let make_src = |c: [u8; 4]| -> TensorDyn {
3374            let bytes: Vec<u8> = c.iter().copied().cycle().take(w * h * 4).collect();
3375            load_bytes_to_tensor(w, h, PixelFormat::Rgba, Some(TensorMemory::Mem), &bytes).unwrap()
3376        };
3377        // Tall destination: N stacked row-bands. DMA so the Linux GL band path
3378        // runs (one parent import + per-tile glViewport); skip if unavailable.
3379        let parent = match TensorDyn::image(
3380            w,
3381            n * h,
3382            PixelFormat::Rgba,
3383            DType::U8,
3384            Some(TensorMemory::Dma),
3385            edgefirst_tensor::CpuAccess::ReadWrite,
3386        ) {
3387            Ok(d) => d,
3388            Err(e) => {
3389                eprintln!(
3390                    "SKIPPED: {} — tall DMA destination alloc failed ({e:?})",
3391                    function!()
3392                );
3393                return;
3394            }
3395        };
3396
3397        // Deferred batch: one parent import, glViewport/scissor per band, one sync.
3398        for (i, &c) in colors.iter().enumerate().take(n) {
3399            let mut tile = parent.view(Region::new(0, i * h, w, h)).unwrap();
3400            proc.convert_deferred(
3401                &make_src(c),
3402                &mut tile,
3403                Rotation::None,
3404                Flip::None,
3405                Crop::no_crop(),
3406            )
3407            .unwrap_or_else(|e| panic!("convert_deferred tile {i}: {e:?}"));
3408        }
3409        proc.flush().unwrap();
3410
3411        for (i, &c) in colors.iter().enumerate().take(n) {
3412            // Standalone full-buffer convert of the same source = the oracle.
3413            let mut solo = TensorDyn::image(
3414                w,
3415                h,
3416                PixelFormat::Rgba,
3417                DType::U8,
3418                Some(TensorMemory::Dma),
3419                edgefirst_tensor::CpuAccess::ReadWrite,
3420            )
3421            .unwrap();
3422            proc.convert(
3423                &make_src(c),
3424                &mut solo,
3425                Rotation::None,
3426                Flip::None,
3427                Crop::no_crop(),
3428            )
3429            .unwrap();
3430
3431            let band = parent.view(Region::new(0, i * h, w, h)).unwrap();
3432            let band_bytes = band.as_u8().unwrap().map().unwrap().as_slice().to_vec();
3433            let solo_bytes = solo.as_u8().unwrap().map().unwrap().as_slice().to_vec();
3434            assert_eq!(
3435                band_bytes, solo_bytes,
3436                "tile {i}: band differs from standalone convert (placement or sibling wipe)"
3437            );
3438            assert!(
3439                band_bytes.chunks_exact(4).all(|p| p == c),
3440                "tile {i}: band is not the expected solid color {c:?} (sibling wipe?)"
3441            );
3442        }
3443    }
3444
3445    #[test]
3446    fn test_invalid_crop() {
3447        let src = TensorDyn::image(
3448            100,
3449            100,
3450            PixelFormat::Rgb,
3451            DType::U8,
3452            None,
3453            edgefirst_tensor::CpuAccess::ReadWrite,
3454        )
3455        .unwrap();
3456        let dst = TensorDyn::image(
3457            100,
3458            100,
3459            PixelFormat::Rgb,
3460            DType::U8,
3461            None,
3462            edgefirst_tensor::CpuAccess::ReadWrite,
3463        )
3464        .unwrap();
3465
3466        // A source crop exceeding the source bounds is rejected.
3467        let crop = Crop::new().with_source(Some(Region::new(50, 50, 60, 60)));
3468        assert!(matches!(
3469            crop.check_crop_dyn(&src, &dst),
3470            Err(Error::CropInvalid(_))
3471        ));
3472
3473        // A source crop within bounds is valid.
3474        let crop = Crop::new().with_source(Some(Region::new(0, 0, 10, 10)));
3475        assert!(crop.check_crop_dyn(&src, &dst).is_ok());
3476
3477        // Letterbox is always valid — placement is computed within the dst.
3478        assert!(Crop::letterbox([0, 0, 0, 255])
3479            .check_crop_dyn(&src, &dst)
3480            .is_ok());
3481    }
3482
3483    #[test]
3484    fn test_invalid_tensor_format() -> Result<(), Error> {
3485        // 4D tensor cannot be set to a 3-channel pixel format
3486        let mut tensor = Tensor::<u8>::new(&[720, 1280, 4, 1], None, None)?;
3487        let result = tensor.set_format(PixelFormat::Rgb);
3488        assert!(result.is_err(), "4D tensor should reject set_format");
3489
3490        // Tensor with wrong channel count for the format
3491        let mut tensor = Tensor::<u8>::new(&[720, 1280, 4], None, None)?;
3492        let result = tensor.set_format(PixelFormat::Rgb);
3493        assert!(result.is_err(), "4-channel tensor should reject RGB format");
3494
3495        Ok(())
3496    }
3497
3498    #[test]
3499    fn test_invalid_image_file() -> Result<(), Error> {
3500        let result = crate::load_image_test_helper(&[123; 5000], None, None);
3501        assert!(
3502            matches!(result, Err(Error::Codec(_))),
3503            "unrecognised bytes should surface as Error::Codec, got {result:?}"
3504        );
3505        Ok(())
3506    }
3507
3508    #[test]
3509    fn test_invalid_jpeg_format() -> Result<(), Error> {
3510        let result = crate::load_image_test_helper(&[123; 5000], Some(PixelFormat::Yuyv), None);
3511        // YUYV is not a valid decode target; peek_info fails before the magic-
3512        // bytes check, so the precise variant depends on which error fires first.
3513        assert!(
3514            matches!(result, Err(Error::Codec(_))),
3515            "Yuyv target with garbage bytes should surface as Error::Codec, got {result:?}"
3516        );
3517        Ok(())
3518    }
3519
3520    #[test]
3521    fn test_load_resize_save() {
3522        let file = edgefirst_bench::testdata::read("zidane.jpg");
3523        let img = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
3524        assert_eq!(img.width(), Some(1280));
3525        assert_eq!(img.height(), Some(720));
3526
3527        let dst = TensorDyn::image(
3528            640,
3529            360,
3530            PixelFormat::Rgba,
3531            DType::U8,
3532            None,
3533            edgefirst_tensor::CpuAccess::ReadWrite,
3534        )
3535        .unwrap();
3536        let mut converter = CPUProcessor::new();
3537        let (result, _img, dst) = convert_img(
3538            &mut converter,
3539            img,
3540            dst,
3541            Rotation::None,
3542            Flip::None,
3543            Crop::no_crop(),
3544        );
3545        result.unwrap();
3546        assert_eq!(dst.width(), Some(640));
3547        assert_eq!(dst.height(), Some(360));
3548
3549        crate::save_jpeg(&dst, "zidane_resized.jpg", 80).unwrap();
3550
3551        let file = std::fs::read("zidane_resized.jpg").unwrap();
3552        // With `format: None` the helper returns the source's native format.
3553        // The codec now decodes colour JPEGs to NV12 (was RGB previously).
3554        let img = crate::load_image_test_helper(&file, None, None).unwrap();
3555        assert_eq!(img.width(), Some(640));
3556        assert_eq!(img.height(), Some(360));
3557        assert_eq!(img.format().unwrap(), PixelFormat::Nv12);
3558    }
3559
3560    #[test]
3561    fn test_from_tensor_planar() -> Result<(), Error> {
3562        let mut tensor = Tensor::new(&[3, 720, 1280], None, None)?;
3563        tensor
3564            .map()?
3565            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.8bps"));
3566        let planar = {
3567            tensor
3568                .set_format(PixelFormat::PlanarRgb)
3569                .map_err(|e| crate::Error::Internal(e.to_string()))?;
3570            TensorDyn::from(tensor)
3571        };
3572
3573        let rbga = load_bytes_to_tensor(
3574            1280,
3575            720,
3576            PixelFormat::Rgba,
3577            None,
3578            &edgefirst_bench::testdata::read("camera720p.rgba"),
3579        )?;
3580        compare_images_convert_to_rgb(&planar, &rbga, 0.98, function!());
3581
3582        Ok(())
3583    }
3584
3585    #[test]
3586    fn test_from_tensor_invalid_format() {
3587        // PixelFormat::from_fourcc_str returns None for unknown FourCC codes.
3588        // Since there's no "TEST" pixel format, this validates graceful handling.
3589        assert!(PixelFormat::from_fourcc(u32::from_le_bytes(*b"TEST")).is_none());
3590    }
3591
3592    #[test]
3593    #[should_panic(expected = "Failed to save planar RGB image")]
3594    fn test_save_planar() {
3595        let planar_img = load_bytes_to_tensor(
3596            1280,
3597            720,
3598            PixelFormat::PlanarRgb,
3599            None,
3600            &edgefirst_bench::testdata::read("camera720p.8bps"),
3601        )
3602        .unwrap();
3603
3604        let save_path = "/tmp/planar_rgb.jpg";
3605        crate::save_jpeg(&planar_img, save_path, 90).expect("Failed to save planar RGB image");
3606    }
3607
3608    #[test]
3609    #[should_panic(expected = "Failed to save YUYV image")]
3610    fn test_save_yuyv() {
3611        let planar_img = load_bytes_to_tensor(
3612            1280,
3613            720,
3614            PixelFormat::Yuyv,
3615            None,
3616            &edgefirst_bench::testdata::read("camera720p.yuyv"),
3617        )
3618        .unwrap();
3619
3620        let save_path = "/tmp/yuyv.jpg";
3621        crate::save_jpeg(&planar_img, save_path, 90).expect("Failed to save YUYV image");
3622    }
3623
3624    #[test]
3625    fn test_rotation_angle() {
3626        assert_eq!(Rotation::from_degrees_clockwise(0), Rotation::None);
3627        assert_eq!(Rotation::from_degrees_clockwise(90), Rotation::Clockwise90);
3628        assert_eq!(Rotation::from_degrees_clockwise(180), Rotation::Rotate180);
3629        assert_eq!(
3630            Rotation::from_degrees_clockwise(270),
3631            Rotation::CounterClockwise90
3632        );
3633        assert_eq!(Rotation::from_degrees_clockwise(360), Rotation::None);
3634        assert_eq!(Rotation::from_degrees_clockwise(450), Rotation::Clockwise90);
3635        assert_eq!(Rotation::from_degrees_clockwise(540), Rotation::Rotate180);
3636        assert_eq!(
3637            Rotation::from_degrees_clockwise(630),
3638            Rotation::CounterClockwise90
3639        );
3640    }
3641
3642    #[test]
3643    #[should_panic(expected = "rotation angle is not a multiple of 90")]
3644    fn test_rotation_angle_panic() {
3645        Rotation::from_degrees_clockwise(361);
3646    }
3647
3648    #[test]
3649    fn test_disable_env_var() -> Result<(), Error> {
3650        // Acquire the env-var mutex for the entire test body so we never race
3651        // with test_force_backend_* or test_draw_proto_masks_no_cpu_returns_error.
3652        let _lock = acquire_env_lock();
3653
3654        // Snapshot ALL env vars we might touch so the RAII guard restores them
3655        // on exit (even on panic), preventing env-var poisoning of other tests.
3656        let _guard = EnvGuard::snapshot(&[
3657            "EDGEFIRST_FORCE_BACKEND",
3658            "EDGEFIRST_DISABLE_GL",
3659            "EDGEFIRST_DISABLE_G2D",
3660            "EDGEFIRST_DISABLE_CPU",
3661        ]);
3662
3663        // EDGEFIRST_FORCE_BACKEND takes precedence over EDGEFIRST_DISABLE_*,
3664        // so clear it for the duration of this test.
3665        unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") };
3666
3667        #[cfg(target_os = "linux")]
3668        {
3669            unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
3670            let converter = ImageProcessor::new()?;
3671            assert!(converter.g2d.is_none());
3672            unsafe { std::env::remove_var("EDGEFIRST_DISABLE_G2D") };
3673        }
3674
3675        #[cfg(target_os = "linux")]
3676        #[cfg(feature = "opengl")]
3677        {
3678            unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
3679            let converter = ImageProcessor::new()?;
3680            assert!(converter.opengl.is_none());
3681            unsafe { std::env::remove_var("EDGEFIRST_DISABLE_GL") };
3682        }
3683
3684        unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
3685        let converter = ImageProcessor::new()?;
3686        assert!(converter.cpu.is_none());
3687        unsafe { std::env::remove_var("EDGEFIRST_DISABLE_CPU") };
3688
3689        // Disable everything — convert must return NoConverter.
3690        unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
3691        unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
3692        unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
3693        let mut converter = ImageProcessor::new()?;
3694
3695        let src = TensorDyn::image(
3696            1280,
3697            720,
3698            PixelFormat::Rgba,
3699            DType::U8,
3700            None,
3701            edgefirst_tensor::CpuAccess::ReadWrite,
3702        )?;
3703        let dst = TensorDyn::image(
3704            640,
3705            360,
3706            PixelFormat::Rgba,
3707            DType::U8,
3708            None,
3709            edgefirst_tensor::CpuAccess::ReadWrite,
3710        )?;
3711        let (result, _src, _dst) = convert_img(
3712            &mut converter,
3713            src,
3714            dst,
3715            Rotation::None,
3716            Flip::None,
3717            Crop::no_crop(),
3718        );
3719        assert!(matches!(result, Err(Error::NoConverter)));
3720        // _guard restores all env vars on drop.
3721        Ok(())
3722    }
3723
3724    #[test]
3725    fn test_unsupported_conversion() {
3726        let src = TensorDyn::image(
3727            1280,
3728            720,
3729            PixelFormat::Nv12,
3730            DType::U8,
3731            None,
3732            edgefirst_tensor::CpuAccess::ReadWrite,
3733        )
3734        .unwrap();
3735        let dst = TensorDyn::image(
3736            640,
3737            360,
3738            PixelFormat::Nv12,
3739            DType::U8,
3740            None,
3741            edgefirst_tensor::CpuAccess::ReadWrite,
3742        )
3743        .unwrap();
3744        let mut converter = ImageProcessor::new().unwrap();
3745        let (result, _src, _dst) = convert_img(
3746            &mut converter,
3747            src,
3748            dst,
3749            Rotation::None,
3750            Flip::None,
3751            Crop::no_crop(),
3752        );
3753        log::debug!("result: {:?}", result);
3754        assert!(matches!(
3755            result,
3756            Err(Error::NotSupported(e)) if e.starts_with("Conversion from NV12 to NV12")
3757        ));
3758    }
3759
3760    #[test]
3761    fn test_load_grey() {
3762        // A single-component (greyscale) JPEG decodes to its native GREY
3763        // format, which has no even-dimension constraint, so the 1024×681
3764        // `grey.jpg` loads and converts to RGBA successfully.
3765        let grey_img = crate::load_image_test_helper(
3766            &edgefirst_bench::testdata::read("grey.jpg"),
3767            Some(PixelFormat::Rgba),
3768            None,
3769        )
3770        .unwrap();
3771        assert_eq!(grey_img.width(), Some(1024));
3772        assert_eq!(grey_img.height(), Some(681));
3773
3774        // `grey-rgb.jpg` holds the same grey content but is encoded as a
3775        // 3-component (colour) JPEG, so the codec decodes it to native NV12.
3776        // Its 1024×681 dimensions have an odd height; NV12 now represents odd
3777        // dimensions via the `H + ceil(H/2)` combined-plane height, so the
3778        // decode succeeds and converts to RGBA at the true dimensions.
3779        let grey_but_rgb = crate::load_image_test_helper(
3780            &edgefirst_bench::testdata::read("grey-rgb.jpg"),
3781            Some(PixelFormat::Rgba),
3782            None,
3783        )
3784        .expect("odd-height colour JPEG should decode to NV12 and convert to RGBA");
3785        assert_eq!(grey_but_rgb.width(), Some(1024));
3786        assert_eq!(grey_but_rgb.height(), Some(681));
3787    }
3788
3789    #[test]
3790    fn test_new_nv12() {
3791        let nv12 = TensorDyn::image(
3792            1280,
3793            720,
3794            PixelFormat::Nv12,
3795            DType::U8,
3796            None,
3797            edgefirst_tensor::CpuAccess::ReadWrite,
3798        )
3799        .unwrap();
3800        assert_eq!(nv12.height(), Some(720));
3801        assert_eq!(nv12.width(), Some(1280));
3802        assert_eq!(nv12.format().unwrap(), PixelFormat::Nv12);
3803        // PixelFormat::Nv12.channels() returns 1 (luma plane channel count)
3804        assert_eq!(nv12.format().unwrap().channels(), 1);
3805        assert!(nv12.format().is_some_and(
3806            |f| f.layout() == PixelLayout::Planar || f.layout() == PixelLayout::SemiPlanar
3807        ))
3808    }
3809
3810    #[test]
3811    #[cfg(target_os = "linux")]
3812    fn test_new_image_converter() {
3813        let dst_width = 640;
3814        let dst_height = 360;
3815        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
3816        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
3817
3818        let mut converter = ImageProcessor::new().unwrap();
3819        let converter_dst = converter
3820            .create_image(
3821                dst_width,
3822                dst_height,
3823                PixelFormat::Rgba,
3824                DType::U8,
3825                None,
3826                edgefirst_tensor::CpuAccess::ReadWrite,
3827            )
3828            .unwrap();
3829        let (result, src, converter_dst) = convert_img(
3830            &mut converter,
3831            src,
3832            converter_dst,
3833            Rotation::None,
3834            Flip::None,
3835            Crop::no_crop(),
3836        );
3837        result.unwrap();
3838
3839        let cpu_dst = TensorDyn::image(
3840            dst_width,
3841            dst_height,
3842            PixelFormat::Rgba,
3843            DType::U8,
3844            None,
3845            edgefirst_tensor::CpuAccess::ReadWrite,
3846        )
3847        .unwrap();
3848        let mut cpu_converter = CPUProcessor::new();
3849        let (result, _src, cpu_dst) = convert_img(
3850            &mut cpu_converter,
3851            src,
3852            cpu_dst,
3853            Rotation::None,
3854            Flip::None,
3855            Crop::no_crop(),
3856        );
3857        result.unwrap();
3858
3859        compare_images(&converter_dst, &cpu_dst, 0.98, function!());
3860    }
3861
3862    #[test]
3863    #[cfg(target_os = "linux")]
3864    fn test_create_image_dtype_i8() {
3865        let mut converter = ImageProcessor::new().unwrap();
3866
3867        // I8 image should allocate successfully via create_image
3868        let dst = converter
3869            .create_image(
3870                320,
3871                240,
3872                PixelFormat::Rgb,
3873                DType::I8,
3874                None,
3875                edgefirst_tensor::CpuAccess::ReadWrite,
3876            )
3877            .unwrap();
3878        assert_eq!(dst.dtype(), DType::I8);
3879        assert!(dst.width() == Some(320));
3880        assert!(dst.height() == Some(240));
3881        assert_eq!(dst.format(), Some(PixelFormat::Rgb));
3882
3883        // U8 for comparison
3884        let dst_u8 = converter
3885            .create_image(
3886                320,
3887                240,
3888                PixelFormat::Rgb,
3889                DType::U8,
3890                None,
3891                edgefirst_tensor::CpuAccess::ReadWrite,
3892            )
3893            .unwrap();
3894        assert_eq!(dst_u8.dtype(), DType::U8);
3895
3896        // Convert into I8 dst should succeed
3897        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
3898        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
3899        let mut dst_i8 = converter
3900            .create_image(
3901                320,
3902                240,
3903                PixelFormat::Rgb,
3904                DType::I8,
3905                None,
3906                edgefirst_tensor::CpuAccess::ReadWrite,
3907            )
3908            .unwrap();
3909        converter
3910            .convert(
3911                &src,
3912                &mut dst_i8,
3913                Rotation::None,
3914                Flip::None,
3915                Crop::no_crop(),
3916            )
3917            .unwrap();
3918    }
3919
3920    #[test]
3921    #[cfg(target_os = "linux")]
3922    fn test_create_image_nv12_dma_non_aligned_width() {
3923        // create_image is fully stride-aware: a non-64-aligned NV12 DMA tensor
3924        // may legitimately carry a GPU-pitch-padded row stride — that is the
3925        // intended behaviour, not a bug. Verify the logical geometry is preserved
3926        // for any width and that a reported stride is a valid (>= logical)
3927        // padding, rather than asserting the absence of a stride.
3928        let converter = ImageProcessor::new().unwrap();
3929
3930        // 100 is intentionally not a multiple of 64 (the GPU pitch alignment).
3931        let result = converter.create_image(
3932            100,
3933            64,
3934            PixelFormat::Nv12,
3935            DType::U8,
3936            Some(TensorMemory::Dma),
3937            edgefirst_tensor::CpuAccess::ReadWrite,
3938        );
3939
3940        match result {
3941            Ok(img) => {
3942                assert_eq!(img.width(), Some(100));
3943                assert_eq!(img.height(), Some(64));
3944                assert_eq!(img.format(), Some(PixelFormat::Nv12));
3945                if let Some(stride) = img.row_stride() {
3946                    assert!(
3947                        stride >= 100,
3948                        "NV12 row_stride {stride} must be >= the logical width (100)",
3949                    );
3950                }
3951            }
3952            Err(e) => {
3953                // Skip cleanly on hosts without a dma-heap.
3954                eprintln!("SKIPPED: create_image NV12 DMA non-aligned width: {e}");
3955            }
3956        }
3957    }
3958
3959    #[test]
3960    #[ignore] // Hangs on desktop platforms where DMA-buf is unavailable and PBO
3961              // fallback triggers a GPU driver hang during SHM→texture upload (e.g.,
3962              // NVIDIA without /dev/dma_heap permissions). Works on embedded targets.
3963    fn test_crop_skip() {
3964        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
3965        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
3966
3967        let mut converter = ImageProcessor::new().unwrap();
3968        let converter_dst = converter
3969            .create_image(
3970                1280,
3971                720,
3972                PixelFormat::Rgba,
3973                DType::U8,
3974                None,
3975                edgefirst_tensor::CpuAccess::ReadWrite,
3976            )
3977            .unwrap();
3978        let crop = Crop::new().with_source(Some(Region::new(0, 0, 640, 640)));
3979        let (result, src, converter_dst) = convert_img(
3980            &mut converter,
3981            src,
3982            converter_dst,
3983            Rotation::None,
3984            Flip::None,
3985            crop,
3986        );
3987        result.unwrap();
3988
3989        let cpu_dst = TensorDyn::image(
3990            1280,
3991            720,
3992            PixelFormat::Rgba,
3993            DType::U8,
3994            None,
3995            edgefirst_tensor::CpuAccess::ReadWrite,
3996        )
3997        .unwrap();
3998        let mut cpu_converter = CPUProcessor::new();
3999        let (result, _src, cpu_dst) = convert_img(
4000            &mut cpu_converter,
4001            src,
4002            cpu_dst,
4003            Rotation::None,
4004            Flip::None,
4005            crop,
4006        );
4007        result.unwrap();
4008
4009        compare_images(&converter_dst, &cpu_dst, 0.99999, function!());
4010    }
4011
4012    #[test]
4013    fn test_invalid_pixel_format() {
4014        // PixelFormat::from_fourcc returns None for unknown formats,
4015        // so TensorDyn::image cannot be called with an invalid format.
4016        assert!(PixelFormat::from_fourcc(u32::from_le_bytes(*b"TEST")).is_none());
4017    }
4018
4019    // Helper function to check if G2D library is available (Linux/i.MX8 only)
4020    #[cfg(target_os = "linux")]
4021    static G2D_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
4022
4023    #[cfg(target_os = "linux")]
4024    fn is_g2d_available() -> bool {
4025        *G2D_AVAILABLE.get_or_init(|| G2DProcessor::new().is_ok())
4026    }
4027
4028    #[cfg(target_os = "linux")]
4029    #[cfg(feature = "opengl")]
4030    static GL_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
4031
4032    #[cfg(target_os = "linux")]
4033    #[cfg(feature = "opengl")]
4034    // Helper function to check if OpenGL is available
4035    fn is_opengl_available() -> bool {
4036        #[cfg(all(target_os = "linux", feature = "opengl"))]
4037        {
4038            *GL_AVAILABLE.get_or_init(|| GLProcessorThreaded::new(None).is_ok())
4039        }
4040
4041        #[cfg(not(all(target_os = "linux", feature = "opengl")))]
4042        {
4043            false
4044        }
4045    }
4046
4047    /// CI canary: fails the lane when the GL backend cannot initialize.
4048    ///
4049    /// Every GL test in this suite self-skips when the backend is
4050    /// unavailable — correct for developer machines, but it means a broken
4051    /// CI GL stack (e.g. the macOS ANGLE re-sign step regressing, the exact
4052    /// failure mode documented in the workflow) ships an untested GL
4053    /// backend behind a green lane. Gated on `HAL_TEST_REQUIRE_GL=1`, set
4054    /// only by CI jobs that install a working GL stack; local runs without
4055    /// one pass trivially. On macOS it additionally requires
4056    /// `HAL_TEST_ALLOW_DLOPEN_ANGLE`, so coverage pass 1 (unsigned
4057    /// binaries, dlopen gate closed) skips it and pass 2 (signed) enforces.
4058    #[test]
4059    #[cfg(feature = "opengl")]
4060    fn gl_backend_available_canary() {
4061        let require_gl = std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1");
4062        if !require_gl {
4063            eprintln!(
4064                "SKIPPED: {} — HAL_TEST_REQUIRE_GL is not set to 1",
4065                function!()
4066            );
4067            return;
4068        }
4069        #[cfg(target_os = "macos")]
4070        if std::env::var_os("HAL_TEST_ALLOW_DLOPEN_ANGLE").is_none() {
4071            eprintln!(
4072                "SKIPPED: {} — ANGLE dlopen gate closed (coverage pass 1)",
4073                function!()
4074            );
4075            return;
4076        }
4077        GLProcessorThreaded::new(None).expect(
4078            "HAL_TEST_REQUIRE_GL=1 but the GL backend failed to initialize — \
4079             check the ANGLE install/re-sign step and binary entitlements \
4080             (macOS) or the EGL stack (Linux)",
4081        );
4082    }
4083
4084    #[test]
4085    fn test_load_jpeg_with_exif() {
4086        use edgefirst_codec::peek_info;
4087
4088        // The migrated codec NEVER applies EXIF orientation: it decodes to the
4089        // source's native (un-rotated) dimensions and reports the rotation via
4090        // ImageInfo. `zidane_rotated_exif.jpg` carries EXIF orientation 6
4091        // (90° clockwise) over a 1280×720 frame.
4092        let file = edgefirst_bench::testdata::read("zidane_rotated_exif.jpg").to_vec();
4093        let info = peek_info(&file).unwrap();
4094        assert_eq!(info.rotation_degrees, 90);
4095        assert!(!info.flip_horizontal);
4096
4097        let loaded = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4098        // Native (un-rotated) dimensions — the decode does not rotate.
4099        assert_eq!(loaded.width(), Some(1280));
4100        assert_eq!(loaded.height(), Some(720));
4101
4102        // Applying the reported rotation downstream reproduces the upright
4103        // image: it matches `zidane.jpg` rotated by the same 90° clockwise.
4104        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4105        let cpu_src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4106
4107        let rotation = Rotation::from_degrees_clockwise(info.rotation_degrees as usize);
4108        let (dst_width, dst_height) = (cpu_src.height().unwrap(), cpu_src.width().unwrap());
4109
4110        let cpu_dst = TensorDyn::image(
4111            dst_width,
4112            dst_height,
4113            PixelFormat::Rgba,
4114            DType::U8,
4115            None,
4116            edgefirst_tensor::CpuAccess::ReadWrite,
4117        )
4118        .unwrap();
4119        let mut cpu_converter = CPUProcessor::new();
4120
4121        // Rotate the native-orientation `loaded` frame and the native `zidane`
4122        // frame by the same reported rotation; the results must agree.
4123        let loaded_rotated = TensorDyn::image(
4124            dst_width,
4125            dst_height,
4126            PixelFormat::Rgba,
4127            DType::U8,
4128            None,
4129            edgefirst_tensor::CpuAccess::ReadWrite,
4130        )
4131        .unwrap();
4132        let (r0, _loaded, loaded_rotated) = convert_img(
4133            &mut cpu_converter,
4134            loaded,
4135            loaded_rotated,
4136            rotation,
4137            Flip::None,
4138            Crop::no_crop(),
4139        );
4140        r0.unwrap();
4141
4142        let (result, _cpu_src, cpu_dst) = convert_img(
4143            &mut cpu_converter,
4144            cpu_src,
4145            cpu_dst,
4146            rotation,
4147            Flip::None,
4148            Crop::no_crop(),
4149        );
4150        result.unwrap();
4151
4152        compare_images(&loaded_rotated, &cpu_dst, 0.98, function!());
4153    }
4154
4155    #[test]
4156    fn test_load_png_with_exif() {
4157        use edgefirst_codec::peek_info;
4158
4159        // PNGs also report EXIF orientation without applying it.
4160        // `zidane_rotated_exif_180.png` carries EXIF orientation 3 (180°).
4161        let file = edgefirst_bench::testdata::read("zidane_rotated_exif_180.png").to_vec();
4162        let info = peek_info(&file).unwrap();
4163        assert_eq!(info.rotation_degrees, 180);
4164        assert!(!info.flip_horizontal);
4165
4166        let loaded = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4167        // Native (un-rotated) dimensions — PNG decodes upright as authored.
4168        assert_eq!(loaded.height(), Some(720));
4169        assert_eq!(loaded.width(), Some(1280));
4170
4171        // The PNG fixture stores upright `zidane` pixels tagged with a 180°
4172        // EXIF orientation. Because the codec no longer applies the rotation,
4173        // the decoded pixels match `zidane.jpg` directly (no convert needed).
4174        // Re-applying the reported rotation to both must still agree.
4175        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4176        let cpu_src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4177
4178        let rotation = Rotation::from_degrees_clockwise(info.rotation_degrees as usize);
4179        let cpu_dst = TensorDyn::image(
4180            1280,
4181            720,
4182            PixelFormat::Rgba,
4183            DType::U8,
4184            None,
4185            edgefirst_tensor::CpuAccess::ReadWrite,
4186        )
4187        .unwrap();
4188        let mut cpu_converter = CPUProcessor::new();
4189
4190        let (result, _cpu_src, cpu_dst) = convert_img(
4191            &mut cpu_converter,
4192            cpu_src,
4193            cpu_dst,
4194            rotation,
4195            Flip::None,
4196            Crop::no_crop(),
4197        );
4198        result.unwrap();
4199
4200        // Rotate the decoded PNG by the same reported angle so both frames are
4201        // in the same (180°-rotated) orientation before comparing.
4202        let loaded_rotated = TensorDyn::image(
4203            1280,
4204            720,
4205            PixelFormat::Rgba,
4206            DType::U8,
4207            None,
4208            edgefirst_tensor::CpuAccess::ReadWrite,
4209        )
4210        .unwrap();
4211        let (r0, _loaded, loaded_rotated) = convert_img(
4212            &mut cpu_converter,
4213            loaded,
4214            loaded_rotated,
4215            rotation,
4216            Flip::None,
4217            Crop::no_crop(),
4218        );
4219        r0.unwrap();
4220
4221        // Threshold 0.95 (was 0.98): `loaded` comes from a lossless PNG decode
4222        // while `cpu_src` (zidane.jpg) now decodes through native NV12 (chroma
4223        // subsampling) before the RGBA conversion, so the two paths differ by a
4224        // couple of percent versus the old direct-RGB JPEG decode.
4225        compare_images(&loaded_rotated, &cpu_dst, 0.95, function!());
4226    }
4227
4228    /// Synthesise an RGB JPEG with a deterministic pattern at `(width, height)`
4229    /// using the workspace's `jpeg-encoder` crate (the `image` crate is
4230    /// compiled without its JPEG feature). Used to exercise the decoder /
4231    /// pitch-padding paths for arbitrary dimensions without having to bundle
4232    /// a fixture file per test size.
4233    #[cfg(target_os = "linux")]
4234    fn make_rgb_jpeg(width: u32, height: u32) -> Vec<u8> {
4235        let mut bytes = Vec::with_capacity((width * height * 3) as usize);
4236        for y in 0..height {
4237            for x in 0..width {
4238                bytes.push(((x + y) & 0xFF) as u8);
4239                bytes.push(((x.wrapping_mul(3)) & 0xFF) as u8);
4240                bytes.push(((y.wrapping_mul(5)) & 0xFF) as u8);
4241            }
4242        }
4243        let mut out = Vec::new();
4244        let encoder = jpeg_encoder::Encoder::new(&mut out, 85);
4245        encoder
4246            .encode(
4247                &bytes,
4248                width as u16,
4249                height as u16,
4250                jpeg_encoder::ColorType::Rgb,
4251            )
4252            .expect("jpeg-encoder must succeed on trivial input");
4253        out
4254    }
4255
4256    /// End-to-end: a 375×333 RGBA JPEG (width NOT divisible by 4) loaded
4257    /// via the pitch-padded DMA path and letterboxed through the GL
4258    /// backend must produce correct output. Before the Rgba/Bgra
4259    /// width%4 relaxation in `DmaImportAttrs::from_tensor`, this case
4260    /// failed the pre-check and forced a CPU texture upload fallback;
4261    /// with the relaxation, EGL import succeeds at the driver level and
4262    /// the GL fast path runs. Output correctness is checked against a
4263    /// CPU reference (convert ran with `EDGEFIRST_FORCE_BACKEND=cpu`).
4264    #[test]
4265    #[cfg(target_os = "linux")]
4266    #[cfg(feature = "opengl")]
4267    fn test_convert_rgba_non_4_aligned_width_end_to_end() {
4268        use edgefirst_tensor::is_dma_available;
4269        if !is_dma_available() {
4270            eprintln!(
4271                "SKIPPED: test_convert_rgba_non_4_aligned_width_end_to_end — DMA not available"
4272            );
4273            return;
4274        }
4275        // 375 is the canonical failure width from dataset loaders —
4276        // 375 * 4 = 1500 bytes/row, pitch-padded to 1536. Width%4 = 3,
4277        // so the old pre-check rejected it; new code accepts it.
4278        let jpeg = make_rgb_jpeg(375, 333);
4279        let src_gl = crate::load_image_test_helper(&jpeg, Some(PixelFormat::Rgba), None).unwrap();
4280        assert_eq!(src_gl.width(), Some(375));
4281        // Row stride must still be pitch-padded (separate concern from width).
4282        let stride = src_gl.row_stride().unwrap();
4283        assert_eq!(stride, 1536, "expected padded pitch 1536, got {stride}");
4284
4285        // GL-backed convert into a pitch-aligned 640×640 Rgba dest.
4286        let mut gl_proc = ImageProcessor::new().unwrap();
4287        let gl_dst = gl_proc
4288            .create_image(
4289                640,
4290                640,
4291                PixelFormat::Rgba,
4292                DType::U8,
4293                None,
4294                edgefirst_tensor::CpuAccess::ReadWrite,
4295            )
4296            .unwrap();
4297        let (r_gl, _src_gl, gl_dst) = convert_img(
4298            &mut gl_proc,
4299            src_gl,
4300            gl_dst,
4301            Rotation::None,
4302            Flip::None,
4303            Crop::no_crop(),
4304        );
4305        r_gl.expect("GL-backed convert must succeed for 375x333 Rgba src");
4306
4307        // CPU reference via a fresh load so the two paths start from
4308        // byte-identical inputs. `with_config(backend=Cpu)` forces the
4309        // CPU-only processor regardless of which backends the host has
4310        // available.
4311        let src_cpu =
4312            crate::load_image_test_helper(&jpeg, Some(PixelFormat::Rgba), Some(TensorMemory::Mem))
4313                .unwrap();
4314        let mut cpu_proc = ImageProcessor::with_config(ImageProcessorConfig {
4315            backend: ComputeBackend::Cpu,
4316            ..Default::default()
4317        })
4318        .unwrap();
4319        let cpu_dst = TensorDyn::image(
4320            640,
4321            640,
4322            PixelFormat::Rgba,
4323            DType::U8,
4324            Some(TensorMemory::Mem),
4325            edgefirst_tensor::CpuAccess::ReadWrite,
4326        )
4327        .unwrap();
4328        let (r_cpu, _src_cpu, cpu_dst) = convert_img(
4329            &mut cpu_proc,
4330            src_cpu,
4331            cpu_dst,
4332            Rotation::None,
4333            Flip::None,
4334            Crop::no_crop(),
4335        );
4336        r_cpu.unwrap();
4337
4338        // Structural similarity: the GL path may have gone through EGL
4339        // import OR fallen back to CPU texture upload — either way, the
4340        // output must match the CPU reference closely.
4341        compare_images(&gl_dst, &cpu_dst, 0.95, function!());
4342    }
4343
4344    /// Regression lock: loading a JPEG at a non-64-aligned RGBA pitch (e.g.
4345    /// 500×333 → natural pitch 2000, needs to be padded to 2048) must go
4346    /// through `image_with_stride` and set `row_stride()` / `effective_row_stride()`
4347    /// to the padded value. The earlier pitch-padding commit fixed this in
4348    /// `load_jpeg`; a regression would surface as `row_stride == None` or
4349    /// `effective_row_stride == 2000`.
4350    #[test]
4351    #[cfg(target_os = "linux")]
4352    fn test_load_jpeg_rgba_non_aligned_pitch_padded_dma() {
4353        use edgefirst_tensor::is_dma_available;
4354        if !is_dma_available() {
4355            eprintln!(
4356                "SKIPPED: test_load_jpeg_rgba_non_aligned_pitch_padded_dma — DMA not available"
4357            );
4358            return;
4359        }
4360        // Widths that force a non-64-aligned natural RGBA pitch. All three
4361        // are divisible by 4 so the EGL width-alignment pre-check passes.
4362        // The pitch-padding fix is what makes these importable at all.
4363        for &w in &[500u32, 612, 428] {
4364            let jpeg = make_rgb_jpeg(w, 333);
4365            let loaded =
4366                crate::load_image_test_helper(&jpeg, Some(PixelFormat::Rgba), None).unwrap();
4367            let natural = (w as usize) * 4;
4368            let aligned = crate::align_pitch_bytes_to_gpu_alignment(natural).unwrap();
4369            assert!(
4370                aligned > natural,
4371                "test sanity: width {w} should be unaligned"
4372            );
4373            let stride = loaded
4374                .row_stride()
4375                .expect("padded DMA path must set an explicit row_stride — regression if None");
4376            assert_eq!(
4377                stride, aligned,
4378                "width {w}: expected padded stride {aligned}, got {stride} \
4379                 (regression: pitch-padding branch skipped?)"
4380            );
4381            let eff = loaded.effective_row_stride().unwrap();
4382            assert_eq!(
4383                eff, aligned,
4384                "effective_row_stride must match stored stride"
4385            );
4386            assert_eq!(loaded.width(), Some(w as usize));
4387            assert_eq!(loaded.height(), Some(333));
4388        }
4389    }
4390
4391    /// `padded_dma_pitch_for` must respect the caller's memory choice and
4392    /// must NOT route into the pitch-padded DMA path when the caller left
4393    /// the choice to the allocator (`None`) but DMA is unavailable on the
4394    /// host. The padded path requires `image_with_stride`, which always
4395    /// allocates DMA — taking it on a system without `/dev/dma_heap`
4396    /// would convert a normally-working image load into a hard failure
4397    /// (since `Tensor::image(..., None)` would have fallen back to
4398    /// SHM/Mem).
4399    #[test]
4400    #[cfg(target_os = "linux")]
4401    fn test_padded_dma_pitch_for_respects_memory_choice() {
4402        use edgefirst_tensor::{is_dma_available, TensorMemory};
4403
4404        // 500×4 = 2000 → padded to 2048 by GPU alignment. Use it for
4405        // every case so any "no padding" answer is unambiguous.
4406        let unaligned_w = 500;
4407
4408        // Caller asks for Mem / Shm: never pad, regardless of DMA.
4409        assert_eq!(
4410            crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &Some(TensorMemory::Mem),),
4411            None,
4412            "Mem must never trigger DMA padding"
4413        );
4414        assert_eq!(
4415            crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &Some(TensorMemory::Shm),),
4416            None,
4417            "Shm must never trigger DMA padding"
4418        );
4419
4420        // Caller explicitly asks for DMA: always pad if width needs it.
4421        // Even if the runtime can't actually allocate DMA, the caller
4422        // owns that decision and the resulting allocation error is
4423        // their problem, not ours.
4424        assert_eq!(
4425            crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &Some(TensorMemory::Dma),),
4426            Some(2048),
4427            "explicit Dma must pad regardless of runtime DMA availability"
4428        );
4429
4430        // Caller leaves it to the allocator: behaviour depends on
4431        // host-runtime DMA availability. This is the case the fix
4432        // guards against.
4433        let none_result = crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &None);
4434        if is_dma_available() {
4435            assert_eq!(
4436                none_result,
4437                Some(2048),
4438                "memory=None + DMA available → pad (will route through DMA)"
4439            );
4440        } else {
4441            assert_eq!(
4442                none_result, None,
4443                "memory=None + DMA unavailable → must NOT pad (would force \
4444                 image_with_stride into a DMA-only allocation that fails). \
4445                 Regression: padded_dma_pitch_for ignored is_dma_available()."
4446            );
4447        }
4448    }
4449
4450    // Synthesise a small greyscale PNG in memory at `(width, height)` with a
4451    // deterministic ramp pattern so multiple tests can cross-check output
4452    // without bundling an extra fixture file.
4453    fn make_grey_png(width: u32, height: u32) -> Vec<u8> {
4454        let mut bytes = Vec::with_capacity((width * height) as usize);
4455        for y in 0..height {
4456            for x in 0..width {
4457                bytes.push(((x + y) & 0xFF) as u8);
4458            }
4459        }
4460        let img = image::GrayImage::from_vec(width, height, bytes).unwrap();
4461        let mut buf = Vec::new();
4462        img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
4463            .unwrap();
4464        buf
4465    }
4466
4467    /// Greyscale PNG with a width that forces a pitch-misaligned natural
4468    /// row stride (612 bytes is not a multiple of the 64-byte GPU pitch
4469    /// alignment) must still load via the pitch-padded DMA path. Gated on
4470    /// DMA availability because `image_with_stride` is DMA-only.
4471    #[test]
4472    #[cfg(target_os = "linux")]
4473    fn test_load_png_grey_misaligned_width_dma() {
4474        use edgefirst_tensor::is_dma_available;
4475        if !is_dma_available() {
4476            eprintln!("SKIPPED: test_load_png_grey_misaligned_width_dma — DMA not available");
4477            return;
4478        }
4479        let png = make_grey_png(612, 388);
4480        let loaded = crate::load_image_test_helper(&png, Some(PixelFormat::Grey), None).unwrap();
4481        assert_eq!(loaded.width(), Some(612));
4482        assert_eq!(loaded.height(), Some(388));
4483        assert_eq!(loaded.format(), Some(PixelFormat::Grey));
4484
4485        // Round-trip pixels — natural-pitch DMA-BUFs pad the stride so we
4486        // must indirect through row_stride() rather than assume width.
4487        let map = loaded.as_u8().unwrap().map().unwrap();
4488        let stride = loaded.row_stride().unwrap_or(612);
4489        assert!(stride >= 612);
4490        let bytes: &[u8] = &map;
4491        for y in 0..388usize {
4492            for x in 0..612usize {
4493                let expected = ((x + y) & 0xFF) as u8;
4494                let got = bytes[y * stride + x];
4495                assert_eq!(
4496                    got, expected,
4497                    "grey png mismatch at ({x},{y}): got {got} expected {expected}"
4498                );
4499            }
4500        }
4501    }
4502
4503    /// Greyscale PNG loaded with explicit Mem backing — runs on any
4504    /// platform (no DMA permission requirement) and covers the
4505    /// decoder-native Luma → Grey no-conversion path.
4506    #[test]
4507    fn test_load_png_grey_mem() {
4508        use edgefirst_tensor::TensorMemory;
4509        let png = make_grey_png(612, 100);
4510        let loaded =
4511            crate::load_image_test_helper(&png, Some(PixelFormat::Grey), Some(TensorMemory::Mem))
4512                .unwrap();
4513        assert_eq!(loaded.width(), Some(612));
4514        assert_eq!(loaded.height(), Some(100));
4515        assert_eq!(loaded.format(), Some(PixelFormat::Grey));
4516        let map = loaded.as_u8().unwrap().map().unwrap();
4517        let bytes: &[u8] = &map;
4518        // Mem allocation uses the natural pitch — 612 bytes per row, exact.
4519        assert_eq!(bytes.len(), 612 * 100);
4520        for y in 0..100 {
4521            for x in 0..612 {
4522                assert_eq!(bytes[y * 612 + x], ((x + y) & 0xFF) as u8);
4523            }
4524        }
4525    }
4526
4527    /// Greyscale PNG decoded into RGB — exercises the decoder-colorspace
4528    /// mismatch path (Luma → Rgb via CPU converter). Uses Mem memory to
4529    /// stay portable to host-side test environments.
4530    #[test]
4531    fn test_load_png_grey_to_rgb_mem() {
4532        use edgefirst_tensor::TensorMemory;
4533        let png = make_grey_png(620, 240);
4534        let loaded =
4535            crate::load_image_test_helper(&png, Some(PixelFormat::Rgb), Some(TensorMemory::Mem))
4536                .unwrap();
4537        assert_eq!(loaded.width(), Some(620));
4538        assert_eq!(loaded.height(), Some(240));
4539        assert_eq!(loaded.format(), Some(PixelFormat::Rgb));
4540
4541        // Greyscale promoted to RGB replicates luma into each channel.
4542        let map = loaded.as_u8().unwrap().map().unwrap();
4543        let bytes: &[u8] = &map;
4544        for (x, y) in [(0usize, 0usize), (100, 50), (619, 239)] {
4545            let expected = ((x + y) & 0xFF) as u8;
4546            let off = (y * 620 + x) * 3;
4547            assert_eq!(bytes[off], expected, "R@{x},{y}");
4548            assert_eq!(bytes[off + 1], expected, "G@{x},{y}");
4549            assert_eq!(bytes[off + 2], expected, "B@{x},{y}");
4550        }
4551    }
4552
4553    #[test]
4554    #[cfg(target_os = "linux")]
4555    fn test_g2d_resize() {
4556        if !is_g2d_available() {
4557            eprintln!("SKIPPED: test_g2d_resize - G2D library (libg2d.so.2) not available");
4558            return;
4559        }
4560        if !is_dma_available() {
4561            eprintln!(
4562                "SKIPPED: test_g2d_resize - DMA memory allocation not available (permission denied or no DMA-BUF support)"
4563            );
4564            return;
4565        }
4566
4567        let dst_width = 640;
4568        let dst_height = 360;
4569        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4570        let src =
4571            crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), Some(TensorMemory::Dma))
4572                .unwrap();
4573
4574        let g2d_dst = TensorDyn::image(
4575            dst_width,
4576            dst_height,
4577            PixelFormat::Rgba,
4578            DType::U8,
4579            Some(TensorMemory::Dma),
4580            edgefirst_tensor::CpuAccess::ReadWrite,
4581        )
4582        .unwrap();
4583        let mut g2d_converter = G2DProcessor::new().unwrap();
4584        let (result, src, g2d_dst) = convert_img(
4585            &mut g2d_converter,
4586            src,
4587            g2d_dst,
4588            Rotation::None,
4589            Flip::None,
4590            Crop::no_crop(),
4591        );
4592        result.unwrap();
4593
4594        let cpu_dst = TensorDyn::image(
4595            dst_width,
4596            dst_height,
4597            PixelFormat::Rgba,
4598            DType::U8,
4599            None,
4600            edgefirst_tensor::CpuAccess::ReadWrite,
4601        )
4602        .unwrap();
4603        let mut cpu_converter = CPUProcessor::new();
4604        let (result, _src, cpu_dst) = convert_img(
4605            &mut cpu_converter,
4606            src,
4607            cpu_dst,
4608            Rotation::None,
4609            Flip::None,
4610            Crop::no_crop(),
4611        );
4612        result.unwrap();
4613
4614        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
4615        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
4616        // the YUV-matrix delta that forced 0.95 has closed; tightened to
4617        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
4618        // structural gap not exercised by these limited-range fixtures.
4619        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
4620    }
4621
4622    #[test]
4623    #[cfg(target_os = "linux")]
4624    #[cfg(feature = "opengl")]
4625    fn test_opengl_resize() {
4626        if !is_opengl_available() {
4627            eprintln!("SKIPPED: {} - OpenGL not available", function!());
4628            return;
4629        }
4630
4631        let dst_width = 640;
4632        let dst_height = 360;
4633        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4634        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4635
4636        let cpu_dst = TensorDyn::image(
4637            dst_width,
4638            dst_height,
4639            PixelFormat::Rgba,
4640            DType::U8,
4641            None,
4642            edgefirst_tensor::CpuAccess::ReadWrite,
4643        )
4644        .unwrap();
4645        let mut cpu_converter = CPUProcessor::new();
4646        let (result, src, cpu_dst) = convert_img(
4647            &mut cpu_converter,
4648            src,
4649            cpu_dst,
4650            Rotation::None,
4651            Flip::None,
4652            Crop::no_crop(),
4653        );
4654        result.unwrap();
4655
4656        let mut src = src;
4657        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
4658
4659        for _ in 0..5 {
4660            let gl_dst = TensorDyn::image(
4661                dst_width,
4662                dst_height,
4663                PixelFormat::Rgba,
4664                DType::U8,
4665                None,
4666                edgefirst_tensor::CpuAccess::ReadWrite,
4667            )
4668            .unwrap();
4669            let (result, src_back, gl_dst) = convert_img(
4670                &mut gl_converter,
4671                src,
4672                gl_dst,
4673                Rotation::None,
4674                Flip::None,
4675                Crop::no_crop(),
4676            );
4677            result.unwrap();
4678            src = src_back;
4679
4680            compare_images(&gl_dst, &cpu_dst, 0.98, function!());
4681        }
4682    }
4683
4684    #[test]
4685    #[cfg(target_os = "linux")]
4686    #[cfg(feature = "opengl")]
4687    fn test_opengl_10_threads() {
4688        if !is_opengl_available() {
4689            eprintln!("SKIPPED: {} - OpenGL not available", function!());
4690            return;
4691        }
4692
4693        let handles: Vec<_> = (0..10)
4694            .map(|i| {
4695                std::thread::Builder::new()
4696                    .name(format!("Thread {i}"))
4697                    .spawn(test_opengl_resize)
4698                    .unwrap()
4699            })
4700            .collect();
4701        handles.into_iter().for_each(|h| {
4702            if let Err(e) = h.join() {
4703                std::panic::resume_unwind(e)
4704            }
4705        });
4706    }
4707
4708    #[test]
4709    #[cfg(target_os = "linux")]
4710    #[cfg(feature = "opengl")]
4711    fn test_opengl_grey() {
4712        if !is_opengl_available() {
4713            eprintln!("SKIPPED: {} - OpenGL not available", function!());
4714            return;
4715        }
4716
4717        let img = crate::load_image_test_helper(
4718            &edgefirst_bench::testdata::read("grey.jpg"),
4719            Some(PixelFormat::Grey),
4720            None,
4721        )
4722        .unwrap();
4723
4724        let gl_dst = TensorDyn::image(
4725            640,
4726            640,
4727            PixelFormat::Grey,
4728            DType::U8,
4729            None,
4730            edgefirst_tensor::CpuAccess::ReadWrite,
4731        )
4732        .unwrap();
4733        let cpu_dst = TensorDyn::image(
4734            640,
4735            640,
4736            PixelFormat::Grey,
4737            DType::U8,
4738            None,
4739            edgefirst_tensor::CpuAccess::ReadWrite,
4740        )
4741        .unwrap();
4742
4743        let mut converter = CPUProcessor::new();
4744
4745        let (result, img, cpu_dst) = convert_img(
4746            &mut converter,
4747            img,
4748            cpu_dst,
4749            Rotation::None,
4750            Flip::None,
4751            Crop::no_crop(),
4752        );
4753        result.unwrap();
4754
4755        let mut gl = GLProcessorThreaded::new(None).unwrap();
4756        let (result, _img, gl_dst) = convert_img(
4757            &mut gl,
4758            img,
4759            gl_dst,
4760            Rotation::None,
4761            Flip::None,
4762            Crop::no_crop(),
4763        );
4764        result.unwrap();
4765
4766        compare_images(&gl_dst, &cpu_dst, 0.98, function!());
4767    }
4768
4769    #[test]
4770    #[cfg(target_os = "linux")]
4771    fn test_g2d_src_crop() {
4772        if !is_g2d_available() {
4773            eprintln!("SKIPPED: test_g2d_src_crop - G2D library (libg2d.so.2) not available");
4774            return;
4775        }
4776        if !is_dma_available() {
4777            eprintln!(
4778                "SKIPPED: test_g2d_src_crop - DMA memory allocation not available (permission denied or no DMA-BUF support)"
4779            );
4780            return;
4781        }
4782
4783        let dst_width = 640;
4784        let dst_height = 640;
4785        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4786        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4787
4788        let cpu_dst = TensorDyn::image(
4789            dst_width,
4790            dst_height,
4791            PixelFormat::Rgba,
4792            DType::U8,
4793            None,
4794            edgefirst_tensor::CpuAccess::ReadWrite,
4795        )
4796        .unwrap();
4797        let mut cpu_converter = CPUProcessor::new();
4798        let crop = Crop::new().with_source(Some(Region::new(0, 0, 640, 360)));
4799        let (result, src, cpu_dst) = convert_img(
4800            &mut cpu_converter,
4801            src,
4802            cpu_dst,
4803            Rotation::None,
4804            Flip::None,
4805            crop,
4806        );
4807        result.unwrap();
4808
4809        let g2d_dst = TensorDyn::image(
4810            dst_width,
4811            dst_height,
4812            PixelFormat::Rgba,
4813            DType::U8,
4814            None,
4815            edgefirst_tensor::CpuAccess::ReadWrite,
4816        )
4817        .unwrap();
4818        let mut g2d_converter = G2DProcessor::new().unwrap();
4819        let (result, _src, g2d_dst) = convert_img(
4820            &mut g2d_converter,
4821            src,
4822            g2d_dst,
4823            Rotation::None,
4824            Flip::None,
4825            crop,
4826        );
4827        result.unwrap();
4828
4829        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
4830        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
4831        // the YUV-matrix delta that forced 0.95 has closed; tightened to
4832        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
4833        // structural gap not exercised by these limited-range fixtures.
4834        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
4835    }
4836
4837    #[test]
4838    #[cfg(target_os = "linux")]
4839    fn test_g2d_dst_crop() {
4840        if !is_g2d_available() {
4841            eprintln!("SKIPPED: test_g2d_dst_crop - G2D library (libg2d.so.2) not available");
4842            return;
4843        }
4844        if !is_dma_available() {
4845            eprintln!(
4846                "SKIPPED: test_g2d_dst_crop - DMA memory allocation not available (permission denied or no DMA-BUF support)"
4847            );
4848            return;
4849        }
4850
4851        let dst_width = 640;
4852        let dst_height = 640;
4853        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4854        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4855
4856        let cpu_dst = TensorDyn::image(
4857            dst_width,
4858            dst_height,
4859            PixelFormat::Rgba,
4860            DType::U8,
4861            None,
4862            edgefirst_tensor::CpuAccess::ReadWrite,
4863        )
4864        .unwrap();
4865        let mut cpu_converter = CPUProcessor::new();
4866        let crop = Crop::new();
4867        let (result, src, cpu_dst) = convert_img(
4868            &mut cpu_converter,
4869            src,
4870            cpu_dst,
4871            Rotation::None,
4872            Flip::None,
4873            crop,
4874        );
4875        result.unwrap();
4876
4877        let g2d_dst = TensorDyn::image(
4878            dst_width,
4879            dst_height,
4880            PixelFormat::Rgba,
4881            DType::U8,
4882            None,
4883            edgefirst_tensor::CpuAccess::ReadWrite,
4884        )
4885        .unwrap();
4886        let mut g2d_converter = G2DProcessor::new().unwrap();
4887        let (result, _src, g2d_dst) = convert_img(
4888            &mut g2d_converter,
4889            src,
4890            g2d_dst,
4891            Rotation::None,
4892            Flip::None,
4893            crop,
4894        );
4895        result.unwrap();
4896
4897        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
4898        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
4899        // the YUV-matrix delta that forced 0.95 has closed; tightened to
4900        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
4901        // structural gap not exercised by these limited-range fixtures.
4902        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
4903    }
4904
4905    #[test]
4906    #[cfg(target_os = "linux")]
4907    fn test_g2d_all_rgba() {
4908        if !is_g2d_available() {
4909            eprintln!("SKIPPED: test_g2d_all_rgba - G2D library (libg2d.so.2) not available");
4910            return;
4911        }
4912        if !is_dma_available() {
4913            eprintln!(
4914                "SKIPPED: test_g2d_all_rgba - DMA memory allocation not available (permission denied or no DMA-BUF support)"
4915            );
4916            return;
4917        }
4918
4919        let dst_width = 640;
4920        let dst_height = 640;
4921        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4922        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4923        let src_dyn = src;
4924
4925        let mut cpu_dst = TensorDyn::image(
4926            dst_width,
4927            dst_height,
4928            PixelFormat::Rgba,
4929            DType::U8,
4930            None,
4931            edgefirst_tensor::CpuAccess::ReadWrite,
4932        )
4933        .unwrap();
4934        let mut cpu_converter = CPUProcessor::new();
4935        let mut g2d_dst = TensorDyn::image(
4936            dst_width,
4937            dst_height,
4938            PixelFormat::Rgba,
4939            DType::U8,
4940            None,
4941            edgefirst_tensor::CpuAccess::ReadWrite,
4942        )
4943        .unwrap();
4944        let mut g2d_converter = G2DProcessor::new().unwrap();
4945
4946        let crop = Crop::new().with_source(Some(Region::new(50, 120, 1024, 576)));
4947
4948        for rot in [
4949            Rotation::None,
4950            Rotation::Clockwise90,
4951            Rotation::Rotate180,
4952            Rotation::CounterClockwise90,
4953        ] {
4954            cpu_dst
4955                .as_u8()
4956                .unwrap()
4957                .map()
4958                .unwrap()
4959                .as_mut_slice()
4960                .fill(114);
4961            g2d_dst
4962                .as_u8()
4963                .unwrap()
4964                .map()
4965                .unwrap()
4966                .as_mut_slice()
4967                .fill(114);
4968            for flip in [Flip::None, Flip::Horizontal, Flip::Vertical] {
4969                let mut cpu_dst_dyn = cpu_dst;
4970                cpu_converter
4971                    .convert(&src_dyn, &mut cpu_dst_dyn, Rotation::None, Flip::None, crop)
4972                    .unwrap();
4973                cpu_dst = {
4974                    let mut __t = cpu_dst_dyn.into_u8().unwrap();
4975                    __t.set_format(PixelFormat::Rgba).unwrap();
4976                    TensorDyn::from(__t)
4977                };
4978
4979                let mut g2d_dst_dyn = g2d_dst;
4980                g2d_converter
4981                    .convert(&src_dyn, &mut g2d_dst_dyn, Rotation::None, Flip::None, crop)
4982                    .unwrap();
4983                g2d_dst = {
4984                    let mut __t = g2d_dst_dyn.into_u8().unwrap();
4985                    __t.set_format(PixelFormat::Rgba).unwrap();
4986                    TensorDyn::from(__t)
4987                };
4988
4989                compare_images(
4990                    &g2d_dst,
4991                    &cpu_dst,
4992                    0.98,
4993                    &format!("{} {:?} {:?}", function!(), rot, flip),
4994                );
4995            }
4996        }
4997    }
4998
4999    #[test]
5000    #[cfg(target_os = "linux")]
5001    #[cfg(feature = "opengl")]
5002    fn test_opengl_src_crop() {
5003        if !is_opengl_available() {
5004            eprintln!("SKIPPED: {} - OpenGL not available", function!());
5005            return;
5006        }
5007
5008        let dst_width = 640;
5009        let dst_height = 360;
5010        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5011        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5012        let crop = Crop::new().with_source(Some(Region::new(320, 180, 1280 - 320, 720 - 180)));
5013
5014        let cpu_dst = TensorDyn::image(
5015            dst_width,
5016            dst_height,
5017            PixelFormat::Rgba,
5018            DType::U8,
5019            None,
5020            edgefirst_tensor::CpuAccess::ReadWrite,
5021        )
5022        .unwrap();
5023        let mut cpu_converter = CPUProcessor::new();
5024        let (result, src, cpu_dst) = convert_img(
5025            &mut cpu_converter,
5026            src,
5027            cpu_dst,
5028            Rotation::None,
5029            Flip::None,
5030            crop,
5031        );
5032        result.unwrap();
5033
5034        let gl_dst = TensorDyn::image(
5035            dst_width,
5036            dst_height,
5037            PixelFormat::Rgba,
5038            DType::U8,
5039            None,
5040            edgefirst_tensor::CpuAccess::ReadWrite,
5041        )
5042        .unwrap();
5043        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
5044        let (result, _src, gl_dst) = convert_img(
5045            &mut gl_converter,
5046            src,
5047            gl_dst,
5048            Rotation::None,
5049            Flip::None,
5050            crop,
5051        );
5052        result.unwrap();
5053
5054        compare_images(&gl_dst, &cpu_dst, 0.98, function!());
5055    }
5056
5057    #[test]
5058    #[cfg(target_os = "linux")]
5059    #[cfg(feature = "opengl")]
5060    fn test_opengl_dst_crop() {
5061        if !is_opengl_available() {
5062            eprintln!("SKIPPED: {} - OpenGL not available", function!());
5063            return;
5064        }
5065
5066        let dst_width = 640;
5067        let dst_height = 640;
5068        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5069        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5070
5071        let cpu_dst = TensorDyn::image(
5072            dst_width,
5073            dst_height,
5074            PixelFormat::Rgba,
5075            DType::U8,
5076            None,
5077            edgefirst_tensor::CpuAccess::ReadWrite,
5078        )
5079        .unwrap();
5080        let mut cpu_converter = CPUProcessor::new();
5081        let crop = Crop::new();
5082        let (result, src, cpu_dst) = convert_img(
5083            &mut cpu_converter,
5084            src,
5085            cpu_dst,
5086            Rotation::None,
5087            Flip::None,
5088            crop,
5089        );
5090        result.unwrap();
5091
5092        let gl_dst = TensorDyn::image(
5093            dst_width,
5094            dst_height,
5095            PixelFormat::Rgba,
5096            DType::U8,
5097            None,
5098            edgefirst_tensor::CpuAccess::ReadWrite,
5099        )
5100        .unwrap();
5101        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
5102        let (result, _src, gl_dst) = convert_img(
5103            &mut gl_converter,
5104            src,
5105            gl_dst,
5106            Rotation::None,
5107            Flip::None,
5108            crop,
5109        );
5110        result.unwrap();
5111
5112        compare_images(&gl_dst, &cpu_dst, 0.98, function!());
5113    }
5114
5115    #[test]
5116    #[cfg(target_os = "linux")]
5117    #[cfg(feature = "opengl")]
5118    fn test_opengl_all_rgba() {
5119        if !is_opengl_available() {
5120            eprintln!("SKIPPED: {} - OpenGL not available", function!());
5121            return;
5122        }
5123
5124        let dst_width = 640;
5125        let dst_height = 640;
5126        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5127
5128        let mut cpu_converter = CPUProcessor::new();
5129
5130        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
5131
5132        let mut mem = vec![None, Some(TensorMemory::Mem), Some(TensorMemory::Shm)];
5133        if is_dma_available() {
5134            mem.push(Some(TensorMemory::Dma));
5135        }
5136        let crop = Crop::new().with_source(Some(Region::new(50, 120, 1024, 576)));
5137        for m in mem {
5138            let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), m).unwrap();
5139            let src_dyn = src;
5140
5141            for rot in [
5142                Rotation::None,
5143                Rotation::Clockwise90,
5144                Rotation::Rotate180,
5145                Rotation::CounterClockwise90,
5146            ] {
5147                for flip in [Flip::None, Flip::Horizontal, Flip::Vertical] {
5148                    let cpu_dst = TensorDyn::image(
5149                        dst_width,
5150                        dst_height,
5151                        PixelFormat::Rgba,
5152                        DType::U8,
5153                        m,
5154                        edgefirst_tensor::CpuAccess::ReadWrite,
5155                    )
5156                    .unwrap();
5157                    let gl_dst = TensorDyn::image(
5158                        dst_width,
5159                        dst_height,
5160                        PixelFormat::Rgba,
5161                        DType::U8,
5162                        m,
5163                        edgefirst_tensor::CpuAccess::ReadWrite,
5164                    )
5165                    .unwrap();
5166                    cpu_dst
5167                        .as_u8()
5168                        .unwrap()
5169                        .map()
5170                        .unwrap()
5171                        .as_mut_slice()
5172                        .fill(114);
5173                    gl_dst
5174                        .as_u8()
5175                        .unwrap()
5176                        .map()
5177                        .unwrap()
5178                        .as_mut_slice()
5179                        .fill(114);
5180
5181                    let mut cpu_dst_dyn = cpu_dst;
5182                    cpu_converter
5183                        .convert(&src_dyn, &mut cpu_dst_dyn, Rotation::None, Flip::None, crop)
5184                        .unwrap();
5185                    let cpu_dst = {
5186                        let mut __t = cpu_dst_dyn.into_u8().unwrap();
5187                        __t.set_format(PixelFormat::Rgba).unwrap();
5188                        TensorDyn::from(__t)
5189                    };
5190
5191                    let mut gl_dst_dyn = gl_dst;
5192                    gl_converter
5193                        .convert(&src_dyn, &mut gl_dst_dyn, Rotation::None, Flip::None, crop)
5194                        .map_err(|e| {
5195                            log::error!("error mem {m:?} rot {rot:?} error: {e:?}");
5196                            e
5197                        })
5198                        .unwrap();
5199                    let gl_dst = {
5200                        let mut __t = gl_dst_dyn.into_u8().unwrap();
5201                        __t.set_format(PixelFormat::Rgba).unwrap();
5202                        TensorDyn::from(__t)
5203                    };
5204
5205                    compare_images(
5206                        &gl_dst,
5207                        &cpu_dst,
5208                        0.98,
5209                        &format!("{} {:?} {:?}", function!(), rot, flip),
5210                    );
5211                }
5212            }
5213        }
5214    }
5215
5216    #[test]
5217    #[cfg(target_os = "linux")]
5218    fn test_cpu_rotate() {
5219        for rot in [
5220            Rotation::Clockwise90,
5221            Rotation::Rotate180,
5222            Rotation::CounterClockwise90,
5223        ] {
5224            test_cpu_rotate_(rot);
5225        }
5226    }
5227
5228    #[cfg(target_os = "linux")]
5229    fn test_cpu_rotate_(rot: Rotation) {
5230        // This test rotates the image 4 times and checks that the image was returned to
5231        // be the same Currently doesn't check if rotations actually rotated in
5232        // right direction
5233        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5234
5235        let unchanged_src =
5236            crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5237        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5238
5239        let (dst_width, dst_height) = match rot {
5240            Rotation::None | Rotation::Rotate180 => (src.width().unwrap(), src.height().unwrap()),
5241            Rotation::Clockwise90 | Rotation::CounterClockwise90 => {
5242                (src.height().unwrap(), src.width().unwrap())
5243            }
5244        };
5245
5246        let cpu_dst = TensorDyn::image(
5247            dst_width,
5248            dst_height,
5249            PixelFormat::Rgba,
5250            DType::U8,
5251            None,
5252            edgefirst_tensor::CpuAccess::ReadWrite,
5253        )
5254        .unwrap();
5255        let mut cpu_converter = CPUProcessor::new();
5256
5257        // After rotating 4 times, the image should be the same as the original
5258
5259        let (result, src, cpu_dst) = convert_img(
5260            &mut cpu_converter,
5261            src,
5262            cpu_dst,
5263            rot,
5264            Flip::None,
5265            Crop::no_crop(),
5266        );
5267        result.unwrap();
5268
5269        let (result, cpu_dst, src) = convert_img(
5270            &mut cpu_converter,
5271            cpu_dst,
5272            src,
5273            rot,
5274            Flip::None,
5275            Crop::no_crop(),
5276        );
5277        result.unwrap();
5278
5279        let (result, src, cpu_dst) = convert_img(
5280            &mut cpu_converter,
5281            src,
5282            cpu_dst,
5283            rot,
5284            Flip::None,
5285            Crop::no_crop(),
5286        );
5287        result.unwrap();
5288
5289        let (result, _cpu_dst, src) = convert_img(
5290            &mut cpu_converter,
5291            cpu_dst,
5292            src,
5293            rot,
5294            Flip::None,
5295            Crop::no_crop(),
5296        );
5297        result.unwrap();
5298
5299        compare_images(&src, &unchanged_src, 0.98, function!());
5300    }
5301
5302    #[test]
5303    #[cfg(target_os = "linux")]
5304    #[cfg(feature = "opengl")]
5305    fn test_opengl_rotate() {
5306        if !is_opengl_available() {
5307            eprintln!("SKIPPED: {} - OpenGL not available", function!());
5308            return;
5309        }
5310
5311        let size = (1280, 720);
5312        let mut mem = vec![None, Some(TensorMemory::Shm), Some(TensorMemory::Mem)];
5313
5314        if is_dma_available() {
5315            mem.push(Some(TensorMemory::Dma));
5316        }
5317        for m in mem {
5318            for rot in [
5319                Rotation::Clockwise90,
5320                Rotation::Rotate180,
5321                Rotation::CounterClockwise90,
5322            ] {
5323                test_opengl_rotate_(size, rot, m);
5324            }
5325        }
5326    }
5327
5328    #[cfg(target_os = "linux")]
5329    #[cfg(feature = "opengl")]
5330    fn test_opengl_rotate_(
5331        size: (usize, usize),
5332        rot: Rotation,
5333        tensor_memory: Option<TensorMemory>,
5334    ) {
5335        let (dst_width, dst_height) = match rot {
5336            Rotation::None | Rotation::Rotate180 => size,
5337            Rotation::Clockwise90 | Rotation::CounterClockwise90 => (size.1, size.0),
5338        };
5339
5340        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5341        let src =
5342            crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), tensor_memory).unwrap();
5343
5344        let cpu_dst = TensorDyn::image(
5345            dst_width,
5346            dst_height,
5347            PixelFormat::Rgba,
5348            DType::U8,
5349            None,
5350            edgefirst_tensor::CpuAccess::ReadWrite,
5351        )
5352        .unwrap();
5353        let mut cpu_converter = CPUProcessor::new();
5354
5355        let (result, mut src, cpu_dst) = convert_img(
5356            &mut cpu_converter,
5357            src,
5358            cpu_dst,
5359            rot,
5360            Flip::None,
5361            Crop::no_crop(),
5362        );
5363        result.unwrap();
5364
5365        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
5366
5367        for _ in 0..5 {
5368            let gl_dst = TensorDyn::image(
5369                dst_width,
5370                dst_height,
5371                PixelFormat::Rgba,
5372                DType::U8,
5373                tensor_memory,
5374                edgefirst_tensor::CpuAccess::ReadWrite,
5375            )
5376            .unwrap();
5377            let (result, src_back, gl_dst) = convert_img(
5378                &mut gl_converter,
5379                src,
5380                gl_dst,
5381                rot,
5382                Flip::None,
5383                Crop::no_crop(),
5384            );
5385            result.unwrap();
5386            src = src_back;
5387            compare_images(&gl_dst, &cpu_dst, 0.98, function!());
5388        }
5389    }
5390
5391    #[test]
5392    #[cfg(target_os = "linux")]
5393    fn test_g2d_rotate() {
5394        if !is_g2d_available() {
5395            eprintln!("SKIPPED: test_g2d_rotate - G2D library (libg2d.so.2) not available");
5396            return;
5397        }
5398        if !is_dma_available() {
5399            eprintln!(
5400                "SKIPPED: test_g2d_rotate - DMA memory allocation not available (permission denied or no DMA-BUF support)"
5401            );
5402            return;
5403        }
5404
5405        let size = (1280, 720);
5406        for rot in [
5407            Rotation::Clockwise90,
5408            Rotation::Rotate180,
5409            Rotation::CounterClockwise90,
5410        ] {
5411            test_g2d_rotate_(size, rot);
5412        }
5413    }
5414
5415    #[cfg(target_os = "linux")]
5416    fn test_g2d_rotate_(size: (usize, usize), rot: Rotation) {
5417        let (dst_width, dst_height) = match rot {
5418            Rotation::None | Rotation::Rotate180 => size,
5419            Rotation::Clockwise90 | Rotation::CounterClockwise90 => (size.1, size.0),
5420        };
5421
5422        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5423        let src =
5424            crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), Some(TensorMemory::Dma))
5425                .unwrap();
5426
5427        let cpu_dst = TensorDyn::image(
5428            dst_width,
5429            dst_height,
5430            PixelFormat::Rgba,
5431            DType::U8,
5432            None,
5433            edgefirst_tensor::CpuAccess::ReadWrite,
5434        )
5435        .unwrap();
5436        let mut cpu_converter = CPUProcessor::new();
5437
5438        let (result, src, cpu_dst) = convert_img(
5439            &mut cpu_converter,
5440            src,
5441            cpu_dst,
5442            rot,
5443            Flip::None,
5444            Crop::no_crop(),
5445        );
5446        result.unwrap();
5447
5448        let g2d_dst = TensorDyn::image(
5449            dst_width,
5450            dst_height,
5451            PixelFormat::Rgba,
5452            DType::U8,
5453            Some(TensorMemory::Dma),
5454            edgefirst_tensor::CpuAccess::ReadWrite,
5455        )
5456        .unwrap();
5457        let mut g2d_converter = G2DProcessor::new().unwrap();
5458
5459        let (result, _src, g2d_dst) = convert_img(
5460            &mut g2d_converter,
5461            src,
5462            g2d_dst,
5463            rot,
5464            Flip::None,
5465            Crop::no_crop(),
5466        );
5467        result.unwrap();
5468
5469        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
5470        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
5471        // the YUV-matrix delta that forced 0.95 has closed; tightened to
5472        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
5473        // structural gap not exercised by these limited-range fixtures.
5474        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
5475    }
5476
5477    #[test]
5478    fn test_rgba_to_yuyv_resize_cpu() {
5479        let src = load_bytes_to_tensor(
5480            1280,
5481            720,
5482            PixelFormat::Rgba,
5483            None,
5484            &edgefirst_bench::testdata::read("camera720p.rgba"),
5485        )
5486        .unwrap();
5487
5488        let (dst_width, dst_height) = (640, 360);
5489
5490        let dst = TensorDyn::image(
5491            dst_width,
5492            dst_height,
5493            PixelFormat::Yuyv,
5494            DType::U8,
5495            None,
5496            edgefirst_tensor::CpuAccess::ReadWrite,
5497        )
5498        .unwrap();
5499
5500        let dst_through_yuyv = TensorDyn::image(
5501            dst_width,
5502            dst_height,
5503            PixelFormat::Rgba,
5504            DType::U8,
5505            None,
5506            edgefirst_tensor::CpuAccess::ReadWrite,
5507        )
5508        .unwrap();
5509        let dst_direct = TensorDyn::image(
5510            dst_width,
5511            dst_height,
5512            PixelFormat::Rgba,
5513            DType::U8,
5514            None,
5515            edgefirst_tensor::CpuAccess::ReadWrite,
5516        )
5517        .unwrap();
5518
5519        let mut cpu_converter = CPUProcessor::new();
5520
5521        let (result, src, dst) = convert_img(
5522            &mut cpu_converter,
5523            src,
5524            dst,
5525            Rotation::None,
5526            Flip::None,
5527            Crop::no_crop(),
5528        );
5529        result.unwrap();
5530
5531        let (result, _dst, dst_through_yuyv) = convert_img(
5532            &mut cpu_converter,
5533            dst,
5534            dst_through_yuyv,
5535            Rotation::None,
5536            Flip::None,
5537            Crop::no_crop(),
5538        );
5539        result.unwrap();
5540
5541        let (result, _src, dst_direct) = convert_img(
5542            &mut cpu_converter,
5543            src,
5544            dst_direct,
5545            Rotation::None,
5546            Flip::None,
5547            Crop::no_crop(),
5548        );
5549        result.unwrap();
5550
5551        compare_images(&dst_through_yuyv, &dst_direct, 0.98, function!());
5552    }
5553
5554    #[test]
5555    #[cfg(target_os = "linux")]
5556    #[cfg(feature = "opengl")]
5557    #[ignore = "opengl doesn't support rendering to PixelFormat::Yuyv texture"]
5558    fn test_rgba_to_yuyv_resize_opengl() {
5559        if !is_opengl_available() {
5560            eprintln!("SKIPPED: {} - OpenGL not available", function!());
5561            return;
5562        }
5563
5564        if !is_dma_available() {
5565            eprintln!(
5566                "SKIPPED: {} - DMA memory allocation not available (permission denied or no DMA-BUF support)",
5567                function!()
5568            );
5569            return;
5570        }
5571
5572        let src = load_bytes_to_tensor(
5573            1280,
5574            720,
5575            PixelFormat::Rgba,
5576            None,
5577            &edgefirst_bench::testdata::read("camera720p.rgba"),
5578        )
5579        .unwrap();
5580
5581        let (dst_width, dst_height) = (640, 360);
5582
5583        let dst = TensorDyn::image(
5584            dst_width,
5585            dst_height,
5586            PixelFormat::Yuyv,
5587            DType::U8,
5588            Some(TensorMemory::Dma),
5589            edgefirst_tensor::CpuAccess::ReadWrite,
5590        )
5591        .unwrap();
5592
5593        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
5594
5595        let (result, src, dst) = convert_img(
5596            &mut gl_converter,
5597            src,
5598            dst,
5599            Rotation::None,
5600            Flip::None,
5601            Crop::letterbox([255, 255, 255, 255]),
5602        );
5603        result.unwrap();
5604
5605        std::fs::write(
5606            "rgba_to_yuyv_opengl.yuyv",
5607            dst.as_u8().unwrap().map().unwrap().as_slice(),
5608        )
5609        .unwrap();
5610        let cpu_dst = TensorDyn::image(
5611            dst_width,
5612            dst_height,
5613            PixelFormat::Yuyv,
5614            DType::U8,
5615            Some(TensorMemory::Dma),
5616            edgefirst_tensor::CpuAccess::ReadWrite,
5617        )
5618        .unwrap();
5619        let (result, _src, cpu_dst) = convert_img(
5620            &mut CPUProcessor::new(),
5621            src,
5622            cpu_dst,
5623            Rotation::None,
5624            Flip::None,
5625            Crop::no_crop(),
5626        );
5627        result.unwrap();
5628
5629        compare_images_convert_to_rgb(&dst, &cpu_dst, 0.98, function!());
5630    }
5631
5632    #[test]
5633    #[cfg(target_os = "linux")]
5634    fn test_rgba_to_yuyv_resize_g2d() {
5635        if !is_g2d_available() {
5636            eprintln!(
5637                "SKIPPED: test_rgba_to_yuyv_resize_g2d - G2D library (libg2d.so.2) not available"
5638            );
5639            return;
5640        }
5641        if !is_dma_available() {
5642            eprintln!(
5643                "SKIPPED: test_rgba_to_yuyv_resize_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
5644            );
5645            return;
5646        }
5647
5648        let src = load_bytes_to_tensor(
5649            1280,
5650            720,
5651            PixelFormat::Rgba,
5652            Some(TensorMemory::Dma),
5653            &edgefirst_bench::testdata::read("camera720p.rgba"),
5654        )
5655        .unwrap();
5656
5657        let (dst_width, dst_height) = (1280, 720);
5658
5659        let cpu_dst = TensorDyn::image(
5660            dst_width,
5661            dst_height,
5662            PixelFormat::Yuyv,
5663            DType::U8,
5664            Some(TensorMemory::Dma),
5665            edgefirst_tensor::CpuAccess::ReadWrite,
5666        )
5667        .unwrap();
5668
5669        let g2d_dst = TensorDyn::image(
5670            dst_width,
5671            dst_height,
5672            PixelFormat::Yuyv,
5673            DType::U8,
5674            Some(TensorMemory::Dma),
5675            edgefirst_tensor::CpuAccess::ReadWrite,
5676        )
5677        .unwrap();
5678
5679        let mut g2d_converter = G2DProcessor::new().unwrap();
5680        let crop = Crop::new();
5681
5682        g2d_dst
5683            .as_u8()
5684            .unwrap()
5685            .map()
5686            .unwrap()
5687            .as_mut_slice()
5688            .fill(128);
5689        let (result, src, g2d_dst) = convert_img(
5690            &mut g2d_converter,
5691            src,
5692            g2d_dst,
5693            Rotation::None,
5694            Flip::None,
5695            crop,
5696        );
5697        result.unwrap();
5698
5699        let cpu_dst_img = cpu_dst;
5700        cpu_dst_img
5701            .as_u8()
5702            .unwrap()
5703            .map()
5704            .unwrap()
5705            .as_mut_slice()
5706            .fill(128);
5707        let (result, _src, cpu_dst) = convert_img(
5708            &mut CPUProcessor::new(),
5709            src,
5710            cpu_dst_img,
5711            Rotation::None,
5712            Flip::None,
5713            crop,
5714        );
5715        result.unwrap();
5716
5717        compare_images_convert_to_rgb(&cpu_dst, &g2d_dst, 0.98, function!());
5718    }
5719
5720    #[test]
5721    fn test_yuyv_to_rgba_cpu() {
5722        let file = edgefirst_bench::testdata::read("camera720p.yuyv").to_vec();
5723        let src = TensorDyn::image(
5724            1280,
5725            720,
5726            PixelFormat::Yuyv,
5727            DType::U8,
5728            None,
5729            edgefirst_tensor::CpuAccess::ReadWrite,
5730        )
5731        .unwrap();
5732        src.as_u8()
5733            .unwrap()
5734            .map()
5735            .unwrap()
5736            .as_mut_slice()
5737            .copy_from_slice(&file);
5738
5739        let dst = TensorDyn::image(
5740            1280,
5741            720,
5742            PixelFormat::Rgba,
5743            DType::U8,
5744            None,
5745            edgefirst_tensor::CpuAccess::ReadWrite,
5746        )
5747        .unwrap();
5748        let mut cpu_converter = CPUProcessor::new();
5749
5750        let (result, _src, dst) = convert_img(
5751            &mut cpu_converter,
5752            src,
5753            dst,
5754            Rotation::None,
5755            Flip::None,
5756            Crop::no_crop(),
5757        );
5758        result.unwrap();
5759
5760        let target_image = TensorDyn::image(
5761            1280,
5762            720,
5763            PixelFormat::Rgba,
5764            DType::U8,
5765            None,
5766            edgefirst_tensor::CpuAccess::ReadWrite,
5767        )
5768        .unwrap();
5769        target_image
5770            .as_u8()
5771            .unwrap()
5772            .map()
5773            .unwrap()
5774            .as_mut_slice()
5775            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
5776
5777        // CPU path resolves the untagged 720p source to BT.709 limited (height
5778        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
5779        compare_images(&dst, &target_image, 0.98, function!());
5780    }
5781
5782    #[test]
5783    fn test_yuyv_to_rgb_cpu() {
5784        let file = edgefirst_bench::testdata::read("camera720p.yuyv").to_vec();
5785        let src = TensorDyn::image(
5786            1280,
5787            720,
5788            PixelFormat::Yuyv,
5789            DType::U8,
5790            None,
5791            edgefirst_tensor::CpuAccess::ReadWrite,
5792        )
5793        .unwrap();
5794        src.as_u8()
5795            .unwrap()
5796            .map()
5797            .unwrap()
5798            .as_mut_slice()
5799            .copy_from_slice(&file);
5800
5801        let dst = TensorDyn::image(
5802            1280,
5803            720,
5804            PixelFormat::Rgb,
5805            DType::U8,
5806            None,
5807            edgefirst_tensor::CpuAccess::ReadWrite,
5808        )
5809        .unwrap();
5810        let mut cpu_converter = CPUProcessor::new();
5811
5812        let (result, _src, dst) = convert_img(
5813            &mut cpu_converter,
5814            src,
5815            dst,
5816            Rotation::None,
5817            Flip::None,
5818            Crop::no_crop(),
5819        );
5820        result.unwrap();
5821
5822        let target_image = TensorDyn::image(
5823            1280,
5824            720,
5825            PixelFormat::Rgb,
5826            DType::U8,
5827            None,
5828            edgefirst_tensor::CpuAccess::ReadWrite,
5829        )
5830        .unwrap();
5831        target_image
5832            .as_u8()
5833            .unwrap()
5834            .map()
5835            .unwrap()
5836            .as_mut_slice()
5837            .as_chunks_mut::<3>()
5838            .0
5839            .iter_mut()
5840            .zip(
5841                edgefirst_bench::testdata::read("camera720p.rgba")
5842                    .as_chunks::<4>()
5843                    .0,
5844            )
5845            .for_each(|(dst, src)| *dst = [src[0], src[1], src[2]]);
5846
5847        // CPU path resolves the untagged 720p source to BT.709 limited (height
5848        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
5849        compare_images(&dst, &target_image, 0.98, function!());
5850    }
5851
5852    #[test]
5853    #[cfg(target_os = "linux")]
5854    fn test_yuyv_to_rgba_g2d() {
5855        if !is_g2d_available() {
5856            eprintln!("SKIPPED: test_yuyv_to_rgba_g2d - G2D library (libg2d.so.2) not available");
5857            return;
5858        }
5859        if !is_dma_available() {
5860            eprintln!(
5861                "SKIPPED: test_yuyv_to_rgba_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
5862            );
5863            return;
5864        }
5865
5866        let src = load_bytes_to_tensor(
5867            1280,
5868            720,
5869            PixelFormat::Yuyv,
5870            None,
5871            &edgefirst_bench::testdata::read("camera720p.yuyv"),
5872        )
5873        .unwrap();
5874
5875        let dst = TensorDyn::image(
5876            1280,
5877            720,
5878            PixelFormat::Rgba,
5879            DType::U8,
5880            Some(TensorMemory::Dma),
5881            edgefirst_tensor::CpuAccess::ReadWrite,
5882        )
5883        .unwrap();
5884        let mut g2d_converter = G2DProcessor::new().unwrap();
5885
5886        let (result, _src, dst) = convert_img(
5887            &mut g2d_converter,
5888            src,
5889            dst,
5890            Rotation::None,
5891            Flip::None,
5892            Crop::no_crop(),
5893        );
5894        result.unwrap();
5895
5896        let target_image = TensorDyn::image(
5897            1280,
5898            720,
5899            PixelFormat::Rgba,
5900            DType::U8,
5901            None,
5902            edgefirst_tensor::CpuAccess::ReadWrite,
5903        )
5904        .unwrap();
5905        target_image
5906            .as_u8()
5907            .unwrap()
5908            .map()
5909            .unwrap()
5910            .as_mut_slice()
5911            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
5912
5913        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
5914        // so the matrix delta vs the reference that forced 0.95 has closed;
5915        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
5916        compare_images(&dst, &target_image, 0.98, function!());
5917    }
5918
5919    #[test]
5920    #[cfg(target_os = "linux")]
5921    #[cfg(feature = "opengl")]
5922    fn test_yuyv_to_rgba_opengl() {
5923        if !is_opengl_available() {
5924            eprintln!("SKIPPED: {} - OpenGL not available", function!());
5925            return;
5926        }
5927        if !is_dma_available() {
5928            eprintln!(
5929                "SKIPPED: {} - DMA memory allocation not available (permission denied or no DMA-BUF support)",
5930                function!()
5931            );
5932            return;
5933        }
5934
5935        let src = load_bytes_to_tensor(
5936            1280,
5937            720,
5938            PixelFormat::Yuyv,
5939            Some(TensorMemory::Dma),
5940            &edgefirst_bench::testdata::read("camera720p.yuyv"),
5941        )
5942        .unwrap();
5943
5944        let dst = TensorDyn::image(
5945            1280,
5946            720,
5947            PixelFormat::Rgba,
5948            DType::U8,
5949            Some(TensorMemory::Dma),
5950            edgefirst_tensor::CpuAccess::ReadWrite,
5951        )
5952        .unwrap();
5953        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
5954
5955        let (result, _src, dst) = convert_img(
5956            &mut gl_converter,
5957            src,
5958            dst,
5959            Rotation::None,
5960            Flip::None,
5961            Crop::no_crop(),
5962        );
5963        result.unwrap();
5964
5965        let target_image = TensorDyn::image(
5966            1280,
5967            720,
5968            PixelFormat::Rgba,
5969            DType::U8,
5970            None,
5971            edgefirst_tensor::CpuAccess::ReadWrite,
5972        )
5973        .unwrap();
5974        target_image
5975            .as_u8()
5976            .unwrap()
5977            .map()
5978            .unwrap()
5979            .as_mut_slice()
5980            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
5981
5982        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
5983        // so the matrix delta vs the reference that forced 0.95 has closed;
5984        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
5985        compare_images(&dst, &target_image, 0.98, function!());
5986    }
5987
5988    /// macOS analog of `test_yuyv_to_rgba_opengl` — drives the ANGLE +
5989    /// IOSurface backend end-to-end and compares against the same
5990    /// reference image. Skips silently if ANGLE isn't installed so the
5991    /// test suite still passes on CI hosts without the Homebrew tap.
5992    /// Step-1 probe: proves ANGLE's Metal IOSurface-client-buffer path accepts
5993    /// an `L008`→`GL_RED` (R8) binding — the foundation for sampling the
5994    /// contiguous semi-planar YUV buffer as a single R8 texture. Renders a
5995    /// GREY (R8 IOSurface) source through the GL backend to RGBA and checks the
5996    /// luma round-trips to R=G=B (identity GREY→RGB).
5997    #[test]
5998    #[cfg(target_os = "macos")]
5999    #[cfg(feature = "opengl")]
6000    fn test_grey_r8_iosurface_to_rgba_opengl_macos() {
6001        let mut proc = match GLProcessorThreaded::new(None) {
6002            Ok(p) => p,
6003            Err(e) => {
6004                eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
6005                return;
6006            }
6007        };
6008
6009        let (w, h) = (16usize, 16usize);
6010        let src = TensorDyn::image(
6011            w,
6012            h,
6013            PixelFormat::Grey,
6014            DType::U8,
6015            Some(TensorMemory::Dma),
6016            edgefirst_tensor::CpuAccess::ReadWrite,
6017        )
6018        .expect("GREY IOSurface (R8/L008) should allocate — proves the FourCC mapping");
6019        // Known luma ramp: value = (x * 13 + y * 7) & 0xff.
6020        {
6021            let su8 = src.as_u8().unwrap();
6022            let stride = src.as_u8().unwrap().effective_row_stride().unwrap();
6023            let mut m = su8.map().unwrap();
6024            let buf = m.as_mut_slice();
6025            for y in 0..h {
6026                for x in 0..w {
6027                    buf[y * stride + x] = ((x * 13 + y * 7) & 0xff) as u8;
6028                }
6029            }
6030        }
6031
6032        let dst = TensorDyn::image(
6033            w,
6034            h,
6035            PixelFormat::Rgba,
6036            DType::U8,
6037            Some(TensorMemory::Dma),
6038            edgefirst_tensor::CpuAccess::ReadWrite,
6039        )
6040        .unwrap();
6041        let (result, src_back, dst) = convert_img(
6042            &mut proc,
6043            src,
6044            dst,
6045            Rotation::None,
6046            Flip::None,
6047            Crop::no_crop(),
6048        );
6049        result.expect("GREY(R8 IOSurface) → RGBA must convert on ANGLE (R8 binding works)");
6050
6051        let src_stride = src_back.as_u8().unwrap().effective_row_stride().unwrap();
6052        let src_map = src_back.as_u8().unwrap().map().unwrap();
6053        let sbytes = src_map.as_slice();
6054        let dst_stride = dst.as_u8().unwrap().effective_row_stride().unwrap();
6055        let dst_map = dst.as_u8().unwrap().map().unwrap();
6056        let dbytes = dst_map.as_slice();
6057        for y in 0..h {
6058            for x in 0..w {
6059                let yv = sbytes[y * src_stride + x] as i16;
6060                let p = y * dst_stride + x * 4;
6061                for c in 0..3 {
6062                    assert!(
6063                        (dbytes[p + c] as i16 - yv).abs() <= 2,
6064                        "pixel ({x},{y}) ch{c} = {} expected ~{yv} (GREY→RGB identity)",
6065                        dbytes[p + c]
6066                    );
6067                }
6068            }
6069        }
6070    }
6071
6072    /// Two-pass GPU chain: NV12 (R8 IOSurface) → PlanarRgb F16, the profiler's
6073    /// preprocess. Verifies the chained `convert_nv_to_planar_float`
6074    /// (NV12→RGBA8 then the verified RGBA8→PlanarRgb F16) executes on ANGLE and
6075    /// produces a sane F16 planar result: a neutral-grey NV12 input (Y=U=V=128,
6076    /// BT.601 full ⇒ RGB≈0.5) must yield all three planes ≈0.5 (half-float).
6077    #[test]
6078    #[cfg(target_os = "macos")]
6079    #[cfg(feature = "opengl")]
6080    fn test_nv12_to_planar_f16_two_pass_opengl_macos() {
6081        let mut gpu = match GLProcessorThreaded::new(None) {
6082            Ok(p) => p,
6083            Err(e) => {
6084                eprintln!("SKIPPED: {} — init failed ({e:?})", function!());
6085                return;
6086            }
6087        };
6088        let (w, h) = (64usize, 64usize);
6089        let src = match TensorDyn::image(
6090            w,
6091            h,
6092            PixelFormat::Nv12,
6093            DType::U8,
6094            Some(TensorMemory::Dma),
6095            edgefirst_tensor::CpuAccess::ReadWrite,
6096        ) {
6097            Ok(t) => t,
6098            Err(e) => {
6099                eprintln!("SKIPPED: {} — NV12 IOSurface alloc: {e:?}", function!());
6100                return;
6101            }
6102        };
6103        src.as_u8().unwrap().map().unwrap().as_mut_slice().fill(128); // Y=U=V=128
6104
6105        let dst = match TensorDyn::image(
6106            w,
6107            h,
6108            PixelFormat::PlanarRgb,
6109            DType::F16,
6110            Some(TensorMemory::Dma),
6111            edgefirst_tensor::CpuAccess::ReadWrite,
6112        ) {
6113            Ok(t) => t,
6114            Err(e) => {
6115                eprintln!("SKIPPED: {} — F16 PlanarRgb IOSurface: {e:?}", function!());
6116                return;
6117            }
6118        };
6119        let mut dst = dst;
6120        // Call convert directly (the convert_img helper restores u8 only).
6121        if let Err(e) = ImageProcessorTrait::convert(
6122            &mut gpu,
6123            &src,
6124            &mut dst,
6125            Rotation::None,
6126            Flip::None,
6127            Crop::no_crop(),
6128        ) {
6129            // GL_EXT_color_buffer_half_float may be absent on some configs; the
6130            // RGBA8 pass-1 + F16 pass-2 path then can't render. Skip rather than
6131            // fail on a capability gap (the same policy as the F16 path tests).
6132            eprintln!(
6133                "SKIPPED: {} — NV12→PlanarRgb F16 not available ({e:?})",
6134                function!()
6135            );
6136            return;
6137        }
6138        let dt = dst.as_f16().expect("dst is F16 PlanarRgb");
6139        let map = dt.map().unwrap();
6140        let vals = map.as_slice();
6141        // Neutral grey → ~0.5 in every plane. Allow generous tolerance for the
6142        // mediump YUV math + half-float rounding.
6143        let mut checked = 0usize;
6144        for &v in vals.iter() {
6145            let f = f32::from(v);
6146            assert!(
6147                (0.40..=0.60).contains(&f),
6148                "planar F16 value {f} not ~0.5 for neutral-grey NV12"
6149            );
6150            checked += 1;
6151        }
6152        assert!(
6153            checked >= w * h * 3,
6154            "expected >= 3 planes of samples, got {checked}"
6155        );
6156    }
6157
6158    /// Profiler-shaped two-pass: a reused **R8/Grey pool** (allocated larger
6159    /// than the frame, the NV24 worst case `3·H`) is reconfigured to an NV12
6160    /// frame, filled at the preserved physical stride, and converted with a
6161    /// letterbox `src_rect` crop into a model-sized PlanarRgb F16 destination —
6162    /// exactly the orchestrator's preprocess. Guards against the pooled
6163    /// two-pass NV→PlanarRgb F16 path hanging/erroring (the exact-size
6164    /// `test_nv12_to_planar_f16_two_pass` never exercised the larger pool).
6165    #[test]
6166    #[cfg(target_os = "macos")]
6167    #[cfg(feature = "opengl")]
6168    fn test_nv12_to_planar_f16_two_pass_pool_opengl_macos() {
6169        let mut gpu = match GLProcessorThreaded::new(None) {
6170            Ok(p) => p,
6171            Err(e) => {
6172                eprintln!("SKIPPED: {} — init failed ({e:?})", function!());
6173                return;
6174            }
6175        };
6176        // Frame 96×64 in a 256×768 R8 pool (3·256 height; bpr padded past 96).
6177        let (fw, fh) = (96usize, 64usize);
6178        let (pool_w, pool_h) = (256usize, 768usize);
6179        let (model_w, model_h) = (128usize, 128usize);
6180
6181        let mut src = match TensorDyn::image(
6182            pool_w,
6183            pool_h,
6184            PixelFormat::Grey,
6185            DType::U8,
6186            Some(TensorMemory::Dma),
6187            edgefirst_tensor::CpuAccess::ReadWrite,
6188        ) {
6189            Ok(t) => t,
6190            Err(e) => {
6191                eprintln!("SKIPPED: {} — R8 pool alloc: {e:?}", function!());
6192                return;
6193            }
6194        };
6195        src.configure_image(fw, fh, PixelFormat::Nv12)
6196            .unwrap_or_else(|e| panic!("configure_image NV12 on pool: {e}"));
6197        let stride = src.as_u8().unwrap().effective_row_stride().unwrap();
6198        src.as_u8().unwrap().map().unwrap().as_mut_slice().fill(128); // neutral grey
6199
6200        let mut dst = match TensorDyn::image(
6201            model_w,
6202            model_h,
6203            PixelFormat::PlanarRgb,
6204            DType::F16,
6205            Some(TensorMemory::Dma),
6206            edgefirst_tensor::CpuAccess::ReadWrite,
6207        ) {
6208            Ok(t) => t,
6209            Err(e) => {
6210                eprintln!("SKIPPED: {} — F16 PlanarRgb dst: {e:?}", function!());
6211                return;
6212            }
6213        };
6214
6215        // Letterbox crop like the profiler: the backend computes the band.
6216        let _ = model_w;
6217        let crop = Crop::new()
6218            .with_source(Some(Region::new(0, 0, fw, fh)))
6219            .with_fit(Fit::Letterbox {
6220                pad: [0, 0, 0, 255],
6221            });
6222        if let Err(e) =
6223            ImageProcessorTrait::convert(&mut gpu, &src, &mut dst, Rotation::None, Flip::None, crop)
6224        {
6225            eprintln!(
6226                "SKIPPED: {} — NV12→PlanarRgb F16 unavailable ({e:?})",
6227                function!()
6228            );
6229            return;
6230        }
6231        let _ = stride;
6232        // Neutral grey → ~0.5 inside the letterbox band; just assert the convert
6233        // completed and produced finite values (no hang, no NaN garbage).
6234        let dt = dst.as_f16().expect("dst F16");
6235        let map = dt.map().unwrap();
6236        let any_half = map.as_slice().iter().any(|&v| {
6237            let f = f32::from(v);
6238            (0.40..=0.60).contains(&f)
6239        });
6240        assert!(any_half, "expected ~0.5 grey samples in the letterbox band");
6241    }
6242
6243    /// Mirrors the orchestrator: the GL processor is created on one thread
6244    /// and `convert()` is called from a *different* thread (the profiler's
6245    /// Pre-processing worker). Reproduces (or rules out) the GL-context /
6246    /// `glFinish` cross-thread hang seen in the live pipeline. A 20 s watchdog
6247    /// fails loudly rather than hanging the whole test binary.
6248    #[test]
6249    #[cfg(target_os = "macos")]
6250    #[cfg(feature = "opengl")]
6251    fn test_nv12_to_planar_f16_cross_thread_opengl_macos() {
6252        use std::sync::mpsc;
6253        // Public ImageProcessor (Send) created HERE (the main test thread),
6254        // exactly like the orchestrator builds `config.processor` during setup.
6255        let mut proc = match ImageProcessor::new() {
6256            Ok(p) => p,
6257            Err(e) => {
6258                eprintln!("SKIPPED: {} — init failed ({e:?})", function!());
6259                return;
6260            }
6261        };
6262        let (fw, fh) = (96usize, 64usize);
6263        let mut src = match TensorDyn::image(
6264            256,
6265            768,
6266            PixelFormat::Grey,
6267            DType::U8,
6268            Some(TensorMemory::Dma),
6269            edgefirst_tensor::CpuAccess::ReadWrite,
6270        ) {
6271            Ok(t) => t,
6272            Err(e) => {
6273                eprintln!("SKIPPED: {} — pool: {e:?}", function!());
6274                return;
6275            }
6276        };
6277        src.configure_image(fw, fh, PixelFormat::Nv12).unwrap();
6278        src.as_u8().unwrap().map().unwrap().as_mut_slice().fill(128);
6279        let mut dst = match TensorDyn::image(
6280            128,
6281            128,
6282            PixelFormat::PlanarRgb,
6283            DType::F16,
6284            Some(TensorMemory::Dma),
6285            edgefirst_tensor::CpuAccess::ReadWrite,
6286        ) {
6287            Ok(t) => t,
6288            Err(e) => {
6289                eprintln!("SKIPPED: {} — dst: {e:?}", function!());
6290                return;
6291            }
6292        };
6293        let crop = Crop::new().with_source(Some(Region::new(0, 0, fw, fh)));
6294
6295        // ...then MOVED to a worker thread where convert() runs — exactly the
6296        // orchestrator's create-on-setup / convert-on-Pre-processing split.
6297        let (tx, rx) = mpsc::channel::<bool>();
6298        let worker = std::thread::spawn(move || {
6299            let _ = ImageProcessorTrait::convert(
6300                &mut proc,
6301                &src,
6302                &mut dst,
6303                Rotation::None,
6304                Flip::None,
6305                crop,
6306            );
6307            let _ = tx.send(true);
6308        });
6309        match rx.recv_timeout(std::time::Duration::from_secs(20)) {
6310            Ok(_) => { let _ = worker.join(); }
6311            Err(_) => panic!(
6312                "cross-thread NV12→PlanarRgb convert HUNG (>20s) — reproduces the orchestrator deadlock"
6313            ),
6314        }
6315    }
6316
6317    /// Reproduces the profiler's progressive-slowdown/hang: one processor
6318    /// converting many **varying-size** NV frames (like a COCO dataset) from a
6319    /// reused R8 pool into a fixed PlanarRgb F16 model input. The two-pass path
6320    /// reallocated its RGBA intermediate per frame-size, churning/leaking
6321    /// pbuffers until the GPU stalled. Asserts per-convert latency stays bounded
6322    /// (no runaway) over many iterations.
6323    #[test]
6324    #[cfg(target_os = "macos")]
6325    #[cfg(feature = "opengl")]
6326    fn test_nv_to_planar_f16_varying_sizes_no_leak_opengl_macos() {
6327        let mut gpu = match GLProcessorThreaded::new(None) {
6328            Ok(p) => p,
6329            Err(e) => {
6330                eprintln!("SKIPPED: {} — init failed ({e:?})", function!());
6331                return;
6332            }
6333        };
6334        // Mirror the orchestrator's ring buffers at depth 4: a pool of source R8
6335        // tensors AND a pool of PlanarRgb F16 dst slots, both cycled per frame.
6336        let (max_w, max_h) = (640usize, 640usize);
6337        let depth = 4usize;
6338        let mut srcs = Vec::new();
6339        let mut dsts = Vec::new();
6340        for _ in 0..depth {
6341            srcs.push(
6342                match TensorDyn::image(
6343                    max_w,
6344                    max_h * 3,
6345                    PixelFormat::Grey,
6346                    DType::U8,
6347                    Some(TensorMemory::Dma),
6348                    edgefirst_tensor::CpuAccess::ReadWrite,
6349                ) {
6350                    Ok(t) => t,
6351                    Err(e) => {
6352                        eprintln!("SKIPPED: {} — pool: {e:?}", function!());
6353                        return;
6354                    }
6355                },
6356            );
6357            dsts.push(
6358                match TensorDyn::image(
6359                    640,
6360                    640,
6361                    PixelFormat::PlanarRgb,
6362                    DType::F16,
6363                    Some(TensorMemory::Dma),
6364                    edgefirst_tensor::CpuAccess::ReadWrite,
6365                ) {
6366                    Ok(t) => t,
6367                    Err(e) => {
6368                        eprintln!("SKIPPED: {} — dst: {e:?}", function!());
6369                        return;
6370                    }
6371                },
6372            );
6373        }
6374        // COCO-like assorted frame sizes (all ≤ max), cycled.
6375        let sizes = [
6376            (640, 480),
6377            (500, 375),
6378            (640, 427),
6379            (333, 500),
6380            (480, 640),
6381            (612, 612),
6382            (428, 640),
6383            (576, 432),
6384        ];
6385        let mut first_ms = 0f64;
6386        let mut last_ms = 0f64;
6387        let iters = 40usize;
6388        for i in 0..iters {
6389            let (fw, fh) = sizes[i % sizes.len()];
6390            let src = &mut srcs[i % depth];
6391            let dst = &mut dsts[i % depth];
6392            src.configure_image(fw, fh, PixelFormat::Nv24).unwrap();
6393            src.as_u8().unwrap().map().unwrap().as_mut_slice().fill(128);
6394            let crop = Crop::new().with_source(Some(Region::new(0, 0, fw, fh)));
6395            let t0 = std::time::Instant::now();
6396            ImageProcessorTrait::convert(&mut gpu, src, dst, Rotation::None, Flip::None, crop)
6397                .unwrap_or_else(|e| panic!("convert iter {i} ({fw}×{fh}): {e}"));
6398            let ms = t0.elapsed().as_secs_f64() * 1e3;
6399            if i == 2 {
6400                first_ms = ms;
6401            }
6402            if i == iters - 1 {
6403                last_ms = ms;
6404            }
6405        }
6406        eprintln!("first={first_ms:.2}ms last={last_ms:.2}ms");
6407        assert!(
6408            last_ms < first_ms * 5.0 + 5.0,
6409            "convert latency ran away: first {first_ms:.2}ms → last {last_ms:.2}ms (intermediate/pbuffer leak)"
6410        );
6411    }
6412
6413    /// Step-2 verification: NV12/NV16/NV24 (R8 IOSurface) → RGBA on the GPU
6414    /// must match the CPU `yuv` kernels within shader rounding. Fills an
6415    /// IOSurface source and a Mem source from the same logical YUV pattern
6416    /// (each at its own row stride), converts the IOSurface on the GPU and the
6417    /// Mem one on the CPU, and compares. Exercises the in-shader semi-planar
6418    /// addressing for all three subsamplings (incl. NV24's 2×-wide UV rows).
6419    #[test]
6420    #[cfg(target_os = "macos")]
6421    #[cfg(feature = "opengl")]
6422    fn test_nv12_nv16_nv24_to_rgba_opengl_macos() {
6423        let mut gpu = match GLProcessorThreaded::new(None) {
6424            Ok(p) => p,
6425            Err(e) => {
6426                eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
6427                return;
6428            }
6429        };
6430        let mut cpu = CPUProcessor::new();
6431
6432        // Fill Y plus the interleaved UV plane in the canonical semi-planar
6433        // layout — exactly what the codec writes and the CPU `yuv`-crate reader
6434        // expects: the Y plane is `h` rows at the buffer's row stride; the UV
6435        // plane starts at `h * stride`; each chroma row advances
6436        // `uv_grid_rows * stride` bytes (NV24 carries a full-resolution `2*W`
6437        // byte line == two grid rows, NV12/NV16 one row of `W/2` pairs); each
6438        // (Cb,Cr) pair is two consecutive bytes at column `cx * 2`. This is
6439        // stride-correct for both the tight Mem buffer and the padded IOSurface.
6440        //
6441        // Takes explicit w/h so the closure can be reused across multiple frame
6442        // sizes (even and odd) without capturing a fixed outer variable.
6443        let fill = |buf: &mut [u8], stride: usize, fmt: PixelFormat, w: usize, h: usize| {
6444            for y in 0..h {
6445                for x in 0..w {
6446                    buf[y * stride + x] = ((x * 9 + y * 5) & 0xff) as u8;
6447                }
6448            }
6449            let (cw, ch, uv_grid_rows) = match fmt {
6450                PixelFormat::Nv12 => (w / 2, h / 2, 1usize),
6451                PixelFormat::Nv16 => (w / 2, h, 1usize),
6452                _ => (w, h, 2usize), // Nv24: full-res chroma, 2W bytes/row
6453            };
6454            let uv_plane = h * stride;
6455            for cy in 0..ch {
6456                for cx in 0..cw {
6457                    let off = uv_plane + cy * uv_grid_rows * stride + cx * 2;
6458                    buf[off] = ((cx * 11 + 30) & 0xff) as u8;
6459                    buf[off + 1] = ((cy * 7 + 200) & 0xff) as u8;
6460                }
6461            }
6462        };
6463
6464        for fmt in [PixelFormat::Nv12, PixelFormat::Nv16, PixelFormat::Nv24] {
6465            for (w, h) in [
6466                (16usize, 16usize), // original even-dim case
6467                (15, 16),           // odd-W
6468                (16, 15),           // odd-H
6469            ] {
6470                let mem = TensorDyn::image(
6471                    w,
6472                    h,
6473                    fmt,
6474                    DType::U8,
6475                    None,
6476                    edgefirst_tensor::CpuAccess::ReadWrite,
6477                )
6478                .unwrap();
6479                let mem_stride = mem.as_u8().unwrap().effective_row_stride().unwrap();
6480                fill(
6481                    mem.as_u8().unwrap().map().unwrap().as_mut_slice(),
6482                    mem_stride,
6483                    fmt,
6484                    w,
6485                    h,
6486                );
6487                let cpu_dst = TensorDyn::image(
6488                    w,
6489                    h,
6490                    PixelFormat::Rgba,
6491                    DType::U8,
6492                    None,
6493                    edgefirst_tensor::CpuAccess::ReadWrite,
6494                )
6495                .unwrap();
6496                let (r, _s, cpu_dst) = convert_img(
6497                    &mut cpu,
6498                    mem,
6499                    cpu_dst,
6500                    Rotation::None,
6501                    Flip::None,
6502                    Crop::no_crop(),
6503                );
6504                r.unwrap_or_else(|e| panic!("CPU {fmt:?}->{w}x{h}->RGBA: {e}"));
6505
6506                let ios = TensorDyn::image(
6507                    w,
6508                    h,
6509                    fmt,
6510                    DType::U8,
6511                    Some(TensorMemory::Dma),
6512                    edgefirst_tensor::CpuAccess::ReadWrite,
6513                )
6514                .unwrap_or_else(|e| panic!("{fmt:?} {w}x{h} IOSurface alloc: {e}"));
6515                let ios_stride = ios.as_u8().unwrap().effective_row_stride().unwrap();
6516                fill(
6517                    ios.as_u8().unwrap().map().unwrap().as_mut_slice(),
6518                    ios_stride,
6519                    fmt,
6520                    w,
6521                    h,
6522                );
6523                let gpu_dst = TensorDyn::image(
6524                    w,
6525                    h,
6526                    PixelFormat::Rgba,
6527                    DType::U8,
6528                    Some(TensorMemory::Dma),
6529                    edgefirst_tensor::CpuAccess::ReadWrite,
6530                )
6531                .unwrap();
6532                let (r, _s, gpu_dst) = convert_img(
6533                    &mut gpu,
6534                    ios,
6535                    gpu_dst,
6536                    Rotation::None,
6537                    Flip::None,
6538                    Crop::no_crop(),
6539                );
6540                r.unwrap_or_else(|e| panic!("GPU {fmt:?}->{w}x{h}->RGBA on ANGLE: {e}"));
6541
6542                let cs = cpu_dst.as_u8().unwrap().effective_row_stride().unwrap();
6543                let cmap = cpu_dst.as_u8().unwrap().map().unwrap();
6544                let cb = cmap.as_slice();
6545                let gs = gpu_dst.as_u8().unwrap().effective_row_stride().unwrap();
6546                let gmap = gpu_dst.as_u8().unwrap().map().unwrap();
6547                let gb = gmap.as_slice();
6548                let mut max_d = 0i16;
6549                for y in 0..h {
6550                    for x in 0..w {
6551                        for c in 0..3 {
6552                            let cv = cb[y * cs + x * 4 + c] as i16;
6553                            let gv = gb[y * gs + x * 4 + c] as i16;
6554                            max_d = max_d.max((cv - gv).abs());
6555                        }
6556                    }
6557                }
6558                assert!(
6559                    max_d <= 3,
6560                    "{fmt:?} {w}x{h}: GPU vs CPU RGBA max channel diff {max_d} > 3"
6561                );
6562            }
6563        }
6564    }
6565
6566    /// Phase-0 gate: the reused-pool / larger-surface case. A single R8 pool
6567    /// IOSurface (allocated bigger than the frame, so its `bytesPerRow` exceeds
6568    /// the frame's even width) is reconfigured to each NV frame, filled at the
6569    /// preserved physical stride, and converted on the GPU. This proves ANGLE
6570    /// binds the *whole* physical surface as a pbuffer and that `texelFetch`
6571    /// resolves the frame's Y/UV texels through the surface's real `bytesPerRow`
6572    /// (the physical-grid / logical-ROI decoupling). GPU must match CPU ≤3 LSB.
6573    #[test]
6574    #[cfg(target_os = "macos")]
6575    #[cfg(feature = "opengl")]
6576    fn test_nv_to_rgba_larger_pool_surface_opengl_macos() {
6577        let mut gpu = match GLProcessorThreaded::new(None) {
6578            Ok(p) => p,
6579            Err(e) => {
6580                eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
6581                return;
6582            }
6583        };
6584        let mut cpu = CPUProcessor::new();
6585        // The pool is generously oversized (256-wide → bpr 256, well beyond any
6586        // frame's even width) and tall enough for NV24's 3·H at the test frames.
6587        let (pool_w, pool_h) = (256usize, 256usize);
6588
6589        // Canonical semi-planar fill: Y plane at the row stride, then the UV
6590        // plane at `h * stride` with each chroma row advancing `uv_grid_rows *
6591        // stride` bytes. Stride-correct for both tight Mem and padded IOSurface.
6592        let fill = |buf: &mut [u8], stride: usize, fmt: PixelFormat, w: usize, h: usize| {
6593            for y in 0..h {
6594                for x in 0..w {
6595                    buf[y * stride + x] = ((x * 9 + y * 5) & 0xff) as u8;
6596                }
6597            }
6598            let (cw, ch, uv_grid_rows) = match fmt {
6599                PixelFormat::Nv12 => (w / 2, h / 2, 1usize),
6600                PixelFormat::Nv16 => (w / 2, h, 1usize),
6601                _ => (w, h, 2usize), // Nv24: full-res chroma, 2W bytes/row
6602            };
6603            let uv_plane = h * stride;
6604            for cy in 0..ch {
6605                for cx in 0..cw {
6606                    let off = uv_plane + cy * uv_grid_rows * stride + cx * 2;
6607                    buf[off] = ((cx * 11 + 30) & 0xff) as u8;
6608                    buf[off + 1] = ((cy * 7 + 200) & 0xff) as u8;
6609                }
6610            }
6611        };
6612
6613        for fmt in [PixelFormat::Nv12, PixelFormat::Nv16, PixelFormat::Nv24] {
6614            for (w, h) in [
6615                (40usize, 24usize), // original even-dim case
6616                (15, 16),           // odd-W
6617                (16, 15),           // odd-H
6618            ] {
6619                // `ew` is the minimum even extent; the pool stride must exceed it
6620                // to actually exercise the physical-stride shader decoupling.
6621                let ew = w.next_multiple_of(2);
6622
6623                // CPU reference from a tightly-packed Mem tensor at the frame size.
6624                let mem = TensorDyn::image(
6625                    w,
6626                    h,
6627                    fmt,
6628                    DType::U8,
6629                    None,
6630                    edgefirst_tensor::CpuAccess::ReadWrite,
6631                )
6632                .unwrap();
6633                let mem_stride = mem.as_u8().unwrap().effective_row_stride().unwrap();
6634                fill(
6635                    mem.as_u8().unwrap().map().unwrap().as_mut_slice(),
6636                    mem_stride,
6637                    fmt,
6638                    w,
6639                    h,
6640                );
6641                let cpu_dst = TensorDyn::image(
6642                    w,
6643                    h,
6644                    PixelFormat::Rgba,
6645                    DType::U8,
6646                    None,
6647                    edgefirst_tensor::CpuAccess::ReadWrite,
6648                )
6649                .unwrap();
6650                let (r, _s, cpu_dst) = convert_img(
6651                    &mut cpu,
6652                    mem,
6653                    cpu_dst,
6654                    Rotation::None,
6655                    Flip::None,
6656                    Crop::no_crop(),
6657                );
6658                r.unwrap_or_else(|e| panic!("CPU {fmt:?}->{w}x{h}->RGBA: {e}"));
6659
6660                // GPU source: a LARGER R8 pool surface, reconfigured down to the
6661                // frame. Phase 1 preserves the pool's padded `bytesPerRow` as the
6662                // tensor's row stride; the fill writes the frame at that stride.
6663                let mut ios = match TensorDyn::image(
6664                    pool_w,
6665                    pool_h,
6666                    PixelFormat::Grey,
6667                    DType::U8,
6668                    Some(TensorMemory::Dma),
6669                    edgefirst_tensor::CpuAccess::ReadWrite,
6670                ) {
6671                    Ok(t) => t,
6672                    Err(e) => {
6673                        eprintln!("SKIPPED: {} — R8 pool IOSurface alloc: {e:?}", function!());
6674                        return;
6675                    }
6676                };
6677                ios.configure_image(w, h, fmt)
6678                    .unwrap_or_else(|e| panic!("configure_image {fmt:?} {w}x{h} on pool: {e}"));
6679                let ios_stride = ios.as_u8().unwrap().effective_row_stride().unwrap();
6680                assert!(
6681                    ios_stride > ew,
6682                    "{fmt:?} {w}x{h}: pool stride {ios_stride} should exceed even width {ew} \
6683                     (test must exercise padding)"
6684                );
6685                fill(
6686                    ios.as_u8().unwrap().map().unwrap().as_mut_slice(),
6687                    ios_stride,
6688                    fmt,
6689                    w,
6690                    h,
6691                );
6692
6693                let gpu_dst = TensorDyn::image(
6694                    w,
6695                    h,
6696                    PixelFormat::Rgba,
6697                    DType::U8,
6698                    Some(TensorMemory::Dma),
6699                    edgefirst_tensor::CpuAccess::ReadWrite,
6700                )
6701                .unwrap();
6702                let (r, _s, gpu_dst) = convert_img(
6703                    &mut gpu,
6704                    ios,
6705                    gpu_dst,
6706                    Rotation::None,
6707                    Flip::None,
6708                    Crop::no_crop(),
6709                );
6710                r.unwrap_or_else(|e| {
6711                    panic!("GPU {fmt:?}->{w}x{h}->RGBA (pool surface) on ANGLE: {e}")
6712                });
6713
6714                let cs = cpu_dst.as_u8().unwrap().effective_row_stride().unwrap();
6715                let cmap = cpu_dst.as_u8().unwrap().map().unwrap();
6716                let cb = cmap.as_slice();
6717                let gs = gpu_dst.as_u8().unwrap().effective_row_stride().unwrap();
6718                let gmap = gpu_dst.as_u8().unwrap().map().unwrap();
6719                let gb = gmap.as_slice();
6720                let mut max_d = 0i16;
6721                for y in 0..h {
6722                    for x in 0..w {
6723                        for c in 0..3 {
6724                            let cv = cb[y * cs + x * 4 + c] as i16;
6725                            let gv = gb[y * gs + x * 4 + c] as i16;
6726                            max_d = max_d.max((cv - gv).abs());
6727                        }
6728                    }
6729                }
6730                assert!(
6731                    max_d <= 3,
6732                    "{fmt:?} {w}x{h}: GPU(pool surface) vs CPU RGBA max channel diff {max_d} > 3"
6733                );
6734            }
6735        }
6736    }
6737
6738    #[test]
6739    #[cfg(target_os = "macos")]
6740    #[cfg(feature = "opengl")]
6741    fn test_yuyv_to_rgba_opengl_macos() {
6742        let mut proc = match GLProcessorThreaded::new(None) {
6743            Ok(p) => p,
6744            Err(e) => {
6745                eprintln!(
6746                    "SKIPPED: {} — GL engine init failed ({e:?}). \
6747                     Install ANGLE via `brew install startergo/angle/angle` \
6748                     and re-sign per README.md § macOS GPU Acceleration to \
6749                     run this test.",
6750                    function!()
6751                );
6752                return;
6753            }
6754        };
6755
6756        let src = load_bytes_to_tensor(
6757            1280,
6758            720,
6759            PixelFormat::Yuyv,
6760            Some(TensorMemory::Dma),
6761            &edgefirst_bench::testdata::read("camera720p.yuyv"),
6762        )
6763        .unwrap();
6764
6765        let dst = TensorDyn::image(
6766            1280,
6767            720,
6768            PixelFormat::Rgba,
6769            DType::U8,
6770            Some(TensorMemory::Dma),
6771            edgefirst_tensor::CpuAccess::ReadWrite,
6772        )
6773        .unwrap();
6774
6775        let (result, _src, dst) = convert_img(
6776            &mut proc,
6777            src,
6778            dst,
6779            Rotation::None,
6780            Flip::None,
6781            Crop::no_crop(),
6782        );
6783        result.unwrap();
6784
6785        let target_image = TensorDyn::image(
6786            1280,
6787            720,
6788            PixelFormat::Rgba,
6789            DType::U8,
6790            None,
6791            edgefirst_tensor::CpuAccess::ReadWrite,
6792        )
6793        .unwrap();
6794        target_image
6795            .as_u8()
6796            .unwrap()
6797            .map()
6798            .unwrap()
6799            .as_mut_slice()
6800            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
6801
6802        // macOS YUYV shader now threads per-tensor colorimetry: the untagged
6803        // camera720p source resolves to BT.709 limited (matching the BT.709
6804        // reference), measured 0.9973 on ANGLE — up from 0.9733 under the old
6805        // BT.601-full stop-gap. 0.98 leaves headroom for cross-GPU variance.
6806        compare_images(&dst, &target_image, 0.98, function!());
6807    }
6808
6809    /// Multi-resolution smoke test: convert YUYV→RGBA via the GL
6810    /// backend at a small (64×32) frame and a 4K (3840×2160) frame,
6811    /// both filled with a synthetic mid-grey pattern. Validates the
6812    /// shader math at the chroma-pairing boundary on small textures
6813    /// and exercises the IOSurface bytes-per-row alignment path at 4K
6814    /// (3840 pixels × 2 bytes/pixel = 7680 bytes, naturally 64-aligned).
6815    ///
6816    /// Resolutions below 32 pixels wide aren't tested because the
6817    /// IOSurface allocator pads bpr to 64 bytes — for a 4-px-wide
6818    /// YUYV surface that's 8 bytes data + 56 bytes padding per row,
6819    /// which exercises a sampling pattern that's ANGLE-version
6820    /// dependent rather than HAL-correctness dependent.
6821    ///
6822    /// This complements `test_yuyv_to_rgba_opengl_macos` (which checks
6823    /// pixel-exact correctness against a reference image at 720p) by
6824    /// ensuring the pipeline does not crash or produce gross errors at
6825    /// resolution extremes. Pixel-exact validation at 4K would require
6826    /// a 30 MB reference file we don't want to bundle.
6827    #[test]
6828    #[cfg(target_os = "macos")]
6829    #[cfg(feature = "opengl")]
6830    fn test_yuyv_to_rgba_opengl_macos_multi_resolution() {
6831        let mut proc = match GLProcessorThreaded::new(None) {
6832            Ok(p) => p,
6833            Err(e) => {
6834                eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
6835                return;
6836            }
6837        };
6838
6839        for (w, h) in [(64usize, 32usize), (3840, 2160)] {
6840            // Synthetic YUYV: Y=128 (mid-grey luma), U=V=128 (neutral
6841            // chroma) → RGB grey at the output.
6842            let bytes_per_row = w * 2;
6843            let mut yuyv = vec![0u8; bytes_per_row * h];
6844            for chunk in yuyv.chunks_exact_mut(4) {
6845                chunk[0] = 128; // Y0
6846                chunk[1] = 128; // U
6847                chunk[2] = 128; // Y1
6848                chunk[3] = 128; // V
6849            }
6850
6851            let src = load_bytes_to_tensor(w, h, PixelFormat::Yuyv, Some(TensorMemory::Dma), &yuyv)
6852                .unwrap();
6853
6854            let dst = TensorDyn::image(
6855                w,
6856                h,
6857                PixelFormat::Rgba,
6858                DType::U8,
6859                Some(TensorMemory::Dma),
6860                edgefirst_tensor::CpuAccess::ReadWrite,
6861            )
6862            .unwrap();
6863
6864            let (result, _src, dst) = convert_img(
6865                &mut proc,
6866                src,
6867                dst,
6868                Rotation::None,
6869                Flip::None,
6870                Crop::no_crop(),
6871            );
6872            result.expect("GL convert should succeed at this resolution");
6873
6874            // The neutral-chroma input must produce a near-grey output;
6875            // BT.709 limited-range maps Y=128/UV=128 → roughly
6876            // (130, 130, 130). Allow ±4 LSB for `mediump float` shader
6877            // rounding.
6878            let dst_u8 = dst.as_u8().unwrap();
6879            let dst_map = dst_u8.map().unwrap();
6880            let dst_bytes = dst_map.as_slice();
6881            assert_eq!(dst_bytes.len(), w * h * 4, "RGBA byte count");
6882            for px in dst_bytes.chunks_exact(4) {
6883                for (i, &c) in px[..3].iter().enumerate() {
6884                    assert!(
6885                        (120..=140).contains(&c),
6886                        "{}: channel {i} = {c} (expected ~128 ±12) at {w}×{h}",
6887                        function!(),
6888                    );
6889                }
6890                assert_eq!(px[3], 255, "alpha must be 1.0");
6891            }
6892        }
6893    }
6894
6895    /// Verify that two consecutive convert() calls on the same source
6896    /// tensor reuse the cached EGL pbuffer. Tests the cache hit path
6897    /// added with the macOS GL backend hardening — without it, each
6898    /// frame would pay `eglCreatePbufferFromClientBuffer` + destroy.
6899    ///
6900    /// This is a behaviour test rather than a perf test (the timing
6901    /// difference is 100-200µs which is too noisy to assert on); we
6902    /// check that the second call succeeds and produces a result
6903    /// identical to the first.
6904    #[test]
6905    #[cfg(target_os = "macos")]
6906    #[cfg(feature = "opengl")]
6907    fn test_macos_gl_pbuffer_cache_reuses_surfaces() {
6908        let mut proc = match GLProcessorThreaded::new(None) {
6909            Ok(p) => p,
6910            Err(e) => {
6911                eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
6912                return;
6913            }
6914        };
6915
6916        // Allocate one source + one destination, run convert twice.
6917        let mut yuyv = vec![0u8; 64 * 32 * 2];
6918        for chunk in yuyv.chunks_exact_mut(4) {
6919            chunk[0] = 200;
6920            chunk[1] = 100;
6921            chunk[2] = 200;
6922            chunk[3] = 156;
6923        }
6924        let src = load_bytes_to_tensor(64, 32, PixelFormat::Yuyv, Some(TensorMemory::Dma), &yuyv)
6925            .unwrap();
6926        let dst = TensorDyn::image(
6927            64,
6928            32,
6929            PixelFormat::Rgba,
6930            DType::U8,
6931            Some(TensorMemory::Dma),
6932            edgefirst_tensor::CpuAccess::ReadWrite,
6933        )
6934        .unwrap();
6935
6936        let (r1, src, dst) = convert_img(
6937            &mut proc,
6938            src,
6939            dst,
6940            Rotation::None,
6941            Flip::None,
6942            Crop::no_crop(),
6943        );
6944        r1.unwrap();
6945        let first: Vec<u8> = dst.as_u8().unwrap().map().unwrap().as_slice().to_vec();
6946
6947        let (r2, _src, dst) = convert_img(
6948            &mut proc,
6949            src,
6950            dst,
6951            Rotation::None,
6952            Flip::None,
6953            Crop::no_crop(),
6954        );
6955        r2.unwrap();
6956        let second: Vec<u8> = dst.as_u8().unwrap().map().unwrap().as_slice().to_vec();
6957
6958        assert_eq!(first, second, "cache-hit conversion must be deterministic");
6959    }
6960
6961    /// Steady-state import gate (macOS half of the Linux
6962    /// `dma_pool_steady_state_zero_imports` test): an N-frame convert loop
6963    /// over a fixed pool of IOSurface tensors must create ZERO new EGL
6964    /// pbuffers after the pool has been seen once — pbuffer-cache misses
6965    /// stay flat while hits grow. Counter-based hardening of
6966    /// `test_macos_gl_pbuffer_cache_reuses_surfaces` above: a refactor that
6967    /// re-imports per frame passes the pixel-equality test but fails this.
6968    #[test]
6969    #[cfg(target_os = "macos")]
6970    #[cfg(feature = "opengl")]
6971    fn test_macos_gl_pbuffer_cache_steady_state() {
6972        let mut proc = match GLProcessorThreaded::new(None) {
6973            Ok(p) => p,
6974            Err(e) => {
6975                eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
6976                return;
6977            }
6978        };
6979
6980        let (w, h) = (64usize, 32usize);
6981        const POOL: usize = 3;
6982        const FRAMES: usize = 100;
6983
6984        let yuyv = vec![128u8; w * h * 2];
6985        let pool: Vec<TensorDyn> = (0..POOL)
6986            .map(|_| {
6987                load_bytes_to_tensor(w, h, PixelFormat::Yuyv, Some(TensorMemory::Dma), &yuyv)
6988                    .unwrap()
6989            })
6990            .collect();
6991        let mut dst = TensorDyn::image(
6992            w,
6993            h,
6994            PixelFormat::Rgba,
6995            DType::U8,
6996            Some(TensorMemory::Dma),
6997            edgefirst_tensor::CpuAccess::ReadWrite,
6998        )
6999        .unwrap();
7000
7001        // Warmup: two passes over the pool import every surface once.
7002        for src in pool.iter().cycle().take(POOL * 2) {
7003            proc.convert(src, &mut dst, Rotation::None, Flip::None, Crop::no_crop())
7004                .unwrap();
7005        }
7006        let warm = proc.egl_cache_stats().unwrap();
7007
7008        for src in pool.iter().cycle().take(FRAMES) {
7009            proc.convert(src, &mut dst, Rotation::None, Flip::None, Crop::no_crop())
7010                .unwrap();
7011        }
7012        let steady = proc.egl_cache_stats().unwrap();
7013
7014        assert_eq!(
7015            warm.total_misses(),
7016            steady.total_misses(),
7017            "steady-state loop created new imports (warm {warm:?}, steady {steady:?})"
7018        );
7019        let hits = |s: &GlCacheStats| s.src.hits + s.dst.hits + s.nv_r8.hits;
7020        assert!(
7021            hits(&steady) - hits(&warm) >= FRAMES as u64,
7022            "expected at least {FRAMES} import-cache hits over the loop, got {}",
7023            hits(&steady) - hits(&warm)
7024        );
7025    }
7026
7027    /// Backend assertion for the F16 zero-copy path: when the GL backend
7028    /// initialized (ANGLE on macOS) and reports F16 color-buffer support,
7029    /// the NV12→PlanarRgb-F16 IOSurface convert MUST be handled by the GL
7030    /// engine — proven by the engine's import-cache counters moving, not
7031    /// just by output correctness. This is the guard against the
7032    /// silent-CPU-fallback failure mode: a misclassified IOSurface F16
7033    /// destination keeps every output-correctness test green while
7034    /// quietly running ~10× slower on CPU; only a backend observable
7035    /// catches it. Skips ONLY when GL itself is unavailable or the
7036    /// configuration lacks F16 — a convert error or a CPU-routed convert
7037    /// with the capability present is a FAILURE.
7038    #[test]
7039    #[cfg(target_os = "macos")]
7040    #[cfg(feature = "opengl")]
7041    fn test_macos_gl_f16_planar_is_gl_backed() {
7042        let mut proc = ImageProcessor::new().expect("ImageProcessor");
7043        let Some(ref gl) = proc.opengl else {
7044            eprintln!("SKIPPED: {} — GL backend unavailable", function!());
7045            return;
7046        };
7047        if !gl.supported_render_dtypes().f16 {
7048            eprintln!(
7049                "SKIPPED: {} — configuration lacks F16 color-buffer support",
7050                function!()
7051            );
7052            return;
7053        }
7054        let stats_before = gl.egl_cache_stats().expect("cache stats");
7055
7056        let src = TensorDyn::image(
7057            1280,
7058            720,
7059            PixelFormat::Nv12,
7060            DType::U8,
7061            Some(TensorMemory::Dma),
7062            edgefirst_tensor::CpuAccess::ReadWrite,
7063        )
7064        .unwrap();
7065        {
7066            let t = src.as_u8().unwrap();
7067            let mut m = t.map().unwrap();
7068            for (i, b) in m.as_mut_slice().iter_mut().enumerate() {
7069                *b = ((i * 31) % 211) as u8;
7070            }
7071        }
7072        let mut dst = TensorDyn::image(
7073            640,
7074            640,
7075            PixelFormat::PlanarRgb,
7076            DType::F16,
7077            Some(TensorMemory::Dma),
7078            edgefirst_tensor::CpuAccess::ReadWrite,
7079        )
7080        .unwrap();
7081
7082        proc.convert(
7083            &src,
7084            &mut dst,
7085            Rotation::None,
7086            Flip::None,
7087            Crop::letterbox([114, 114, 114, 255]),
7088        )
7089        .expect("F16 capability reported but the NV12→PlanarF16 convert failed");
7090        let stats_after = proc
7091            .opengl
7092            .as_ref()
7093            .expect("GL backend present")
7094            .egl_cache_stats()
7095            .expect("cache stats");
7096        // ≥ 2 misses: the fused convert imports the zero-copy NV source
7097        // (pass 1) AND the F16 destination (pass 2). Requiring both means a
7098        // convert that imported its source, failed mid-engine, and fell back
7099        // to CPU (1 miss) cannot satisfy this gate.
7100        assert!(
7101            stats_after.total_misses() >= stats_before.total_misses() + 2,
7102            "convert succeeded but the GL engine did not import both the \
7103             source and the F16 destination — the work did not (fully) run \
7104             on the GL backend (silent CPU fallback); misses before={} after={}",
7105            stats_before.total_misses(),
7106            stats_after.total_misses()
7107        );
7108    }
7109
7110    /// Portable oracle for the fused NV12→PlanarRgb-F16 engine convert
7111    /// (two GL passes: NV→RGBA intermediate, then the packed RGBA16F
7112    /// render). New on every platform with F16 render support — macOS
7113    /// IOSurface and Linux DMA-BUF alike. Compares against the CPU
7114    /// backend's reference within the float-path tolerance.
7115    #[test]
7116    #[cfg(feature = "opengl")]
7117    fn test_nv12_to_planar_f16_fused_engine_vs_cpu() {
7118        let mut gl = match ImageProcessor::with_config(ImageProcessorConfig {
7119            backend: ComputeBackend::OpenGl,
7120            ..Default::default()
7121        }) {
7122            Ok(p) if p.opengl.is_some() => p,
7123            _ => {
7124                eprintln!("SKIPPED: {} — GL backend unavailable", function!());
7125                return;
7126            }
7127        };
7128        if !gl
7129            .opengl
7130            .as_ref()
7131            .map(|g| g.supported_render_dtypes().f16)
7132            .unwrap_or(false)
7133        {
7134            eprintln!("SKIPPED: {} — no F16 render support", function!());
7135            return;
7136        }
7137        let mem = if edgefirst_tensor::is_gpu_buffer_available() {
7138            TensorMemory::Dma
7139        } else {
7140            eprintln!("SKIPPED: {} — no zero-copy buffers", function!());
7141            return;
7142        };
7143
7144        let src = TensorDyn::image(
7145            1280,
7146            720,
7147            PixelFormat::Nv12,
7148            DType::U8,
7149            Some(mem),
7150            edgefirst_tensor::CpuAccess::ReadWrite,
7151        )
7152        .unwrap();
7153        {
7154            // Smooth gradients, NOT noise: the GL and CPU paths upsample
7155            // chroma with different kernels (nearest vs bilinear), which
7156            // legitimately diverges on per-texel chroma noise. Gradients
7157            // keep that kernel difference sub-LSB while still exercising
7158            // the full matrix math and the letterbox geometry.
7159            let t = src.as_u8().unwrap();
7160            let mut m = t.map().unwrap();
7161            let buf = m.as_mut_slice();
7162            let (w, h) = (1280usize, 720usize);
7163            for y in 0..h {
7164                for x in 0..w {
7165                    buf[y * w + x] = ((x * 255) / w) as u8; // luma ramp
7166                }
7167            }
7168            for y in 0..(h / 2) {
7169                for x in 0..(w / 2) {
7170                    let o = h * w + y * w + 2 * x;
7171                    buf[o] = ((y * 255) / (h / 2)) as u8; // U vertical ramp
7172                    buf[o + 1] = (((x + y) * 255) / (w / 2 + h / 2)) as u8; // V diagonal
7173                }
7174            }
7175        }
7176        let crop = Crop::letterbox([114, 114, 114, 255]);
7177        let mut gl_dst = TensorDyn::image(
7178            640,
7179            640,
7180            PixelFormat::PlanarRgb,
7181            DType::F16,
7182            Some(mem),
7183            edgefirst_tensor::CpuAccess::ReadWrite,
7184        )
7185        .unwrap();
7186        // Drive the GL backend DIRECTLY: a convert through `ImageProcessor`
7187        // silently falls back to CPU on a GL error, turning this oracle into
7188        // a CPU-vs-CPU tautology. A direct call surfaces the engine error.
7189        gl.opengl
7190            .as_mut()
7191            .expect("GL backend present")
7192            .convert(&src, &mut gl_dst, Rotation::None, Flip::None, crop)
7193            .expect("fused NV12→PlanarF16 GL convert");
7194
7195        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
7196            backend: ComputeBackend::Cpu,
7197            ..Default::default()
7198        })
7199        .unwrap();
7200        let mut cpu_dst = TensorDyn::image(
7201            640,
7202            640,
7203            PixelFormat::PlanarRgb,
7204            DType::F16,
7205            Some(TensorMemory::Mem),
7206            edgefirst_tensor::CpuAccess::ReadWrite,
7207        )
7208        .unwrap();
7209        cpu.convert(&src, &mut cpu_dst, Rotation::None, Flip::None, crop)
7210            .expect("CPU reference convert");
7211
7212        let g = gl_dst.as_f16().unwrap().map().unwrap().as_slice().to_vec();
7213        let c = cpu_dst.as_f16().unwrap().map().unwrap().as_slice().to_vec();
7214        assert_eq!(g.len(), c.len());
7215        let mut max_diff = 0.0f32;
7216        let mut max_at = 0usize;
7217        for (i, (a, b)) in g.iter().zip(c.iter()).enumerate() {
7218            let d = (a.to_f32() - b.to_f32()).abs();
7219            if d > max_diff {
7220                max_diff = d;
7221                max_at = i;
7222            }
7223        }
7224        // Localize: plane (R/G/B), row, col of the worst element.
7225        let (plane, rem) = (max_at / (640 * 640), max_at % (640 * 640));
7226        let (row, col) = (rem / 640, rem % 640);
7227        eprintln!(
7228            "fused-vs-cpu: max_diff={max_diff} at plane={plane} row={row} col={col} \
7229             gl={} cpu={}",
7230            g[max_at].to_f32(),
7231            c[max_at].to_f32()
7232        );
7233        // Two GPU passes (8-bit intermediate + linear filtering) vs the
7234        // CPU's direct path: allow a few 8-bit steps of divergence.
7235        assert!(
7236            max_diff <= 4.0 / 255.0 + 1e-3,
7237            "fused NV12→PlanarF16 diverges from CPU reference: max_diff={max_diff}"
7238        );
7239    }
7240
7241    /// Zero-copy source → heap destination through the GL engine,
7242    /// driven on the GL backend DIRECTLY so a GL error cannot hide
7243    /// behind the `ImageProcessor` CPU fallback (the shape that exposed
7244    /// the macOS `glReadnPixels` failure: imported source, rendered,
7245    /// then errored at the heap readback). RGBA→BGRA so the convert is
7246    /// a pure byte shuffle: no chroma kernel or colorimetry ambiguity
7247    /// (Vivante's NV fast path legitimately diverges from the CPU
7248    /// reference), and the readback stays GL_RGBA (V3D rejects RGB
7249    /// readbacks).
7250    #[test]
7251    #[cfg(feature = "opengl")]
7252    fn test_zero_copy_src_to_mem_dst_gl_direct() {
7253        let mut proc = match ImageProcessor::new() {
7254            Ok(p) if p.opengl.is_some() => p,
7255            _ => {
7256                eprintln!("SKIPPED: {} — GL backend unavailable", function!());
7257                return;
7258            }
7259        };
7260        if !edgefirst_tensor::is_gpu_buffer_available() {
7261            eprintln!("SKIPPED: {} — no zero-copy buffers", function!());
7262            return;
7263        }
7264
7265        let src = TensorDyn::image(
7266            1280,
7267            720,
7268            PixelFormat::Rgba,
7269            DType::U8,
7270            Some(TensorMemory::Dma),
7271            edgefirst_tensor::CpuAccess::ReadWrite,
7272        )
7273        .unwrap();
7274        {
7275            let t = src.as_u8().unwrap();
7276            let mut m = t.map().unwrap();
7277            for (i, b) in m.as_mut_slice().iter_mut().enumerate() {
7278                *b = ((i * 31) % 211) as u8;
7279            }
7280        }
7281        let mut gl_dst = TensorDyn::image(
7282            1280,
7283            720,
7284            PixelFormat::Bgra,
7285            DType::U8,
7286            Some(TensorMemory::Mem),
7287            edgefirst_tensor::CpuAccess::ReadWrite,
7288        )
7289        .unwrap();
7290        proc.opengl
7291            .as_mut()
7292            .expect("GL backend present")
7293            .convert(
7294                &src,
7295                &mut gl_dst,
7296                Rotation::None,
7297                Flip::None,
7298                Crop::no_crop(),
7299            )
7300            .expect("zero-copy src → heap dst GL convert");
7301
7302        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
7303            backend: ComputeBackend::Cpu,
7304            ..Default::default()
7305        })
7306        .unwrap();
7307        let mut cpu_dst = TensorDyn::image(
7308            1280,
7309            720,
7310            PixelFormat::Bgra,
7311            DType::U8,
7312            Some(TensorMemory::Mem),
7313            edgefirst_tensor::CpuAccess::ReadWrite,
7314        )
7315        .unwrap();
7316        cpu.convert(
7317            &src,
7318            &mut cpu_dst,
7319            Rotation::None,
7320            Flip::None,
7321            Crop::no_crop(),
7322        )
7323        .expect("CPU reference convert");
7324
7325        let g = gl_dst.as_u8().unwrap().map().unwrap().as_slice().to_vec();
7326        let c = cpu_dst.as_u8().unwrap().map().unwrap().as_slice().to_vec();
7327        assert_eq!(g.len(), c.len());
7328        let max_diff = g
7329            .iter()
7330            .zip(c.iter())
7331            .map(|(a, b)| a.abs_diff(*b))
7332            .max()
7333            .unwrap();
7334        // Same-size byte shuffle: GL samples texel centers 1:1, so allow
7335        // only rounding slack.
7336        assert!(
7337            max_diff <= 2,
7338            "zero-copy src → heap dst diverges from CPU reference: max_diff={max_diff}"
7339        );
7340    }
7341
7342    #[test]
7343    #[cfg(target_os = "linux")]
7344    fn test_yuyv_to_rgb_g2d() {
7345        if !is_g2d_available() {
7346            eprintln!("SKIPPED: test_yuyv_to_rgb_g2d - G2D library (libg2d.so.2) not available");
7347            return;
7348        }
7349        if !is_dma_available() {
7350            eprintln!(
7351                "SKIPPED: test_yuyv_to_rgb_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
7352            );
7353            return;
7354        }
7355
7356        let src = load_bytes_to_tensor(
7357            1280,
7358            720,
7359            PixelFormat::Yuyv,
7360            None,
7361            &edgefirst_bench::testdata::read("camera720p.yuyv"),
7362        )
7363        .unwrap();
7364
7365        let g2d_dst = TensorDyn::image(
7366            1280,
7367            720,
7368            PixelFormat::Rgb,
7369            DType::U8,
7370            Some(TensorMemory::Dma),
7371            edgefirst_tensor::CpuAccess::ReadWrite,
7372        )
7373        .unwrap();
7374        let mut g2d_converter = G2DProcessor::new().unwrap();
7375
7376        let (result, src, g2d_dst) = convert_img(
7377            &mut g2d_converter,
7378            src,
7379            g2d_dst,
7380            Rotation::None,
7381            Flip::None,
7382            Crop::no_crop(),
7383        );
7384        result.unwrap();
7385
7386        let cpu_dst = TensorDyn::image(
7387            1280,
7388            720,
7389            PixelFormat::Rgb,
7390            DType::U8,
7391            None,
7392            edgefirst_tensor::CpuAccess::ReadWrite,
7393        )
7394        .unwrap();
7395        let mut cpu_converter: CPUProcessor = CPUProcessor::new();
7396
7397        let (result, _src, cpu_dst) = convert_img(
7398            &mut cpu_converter,
7399            src,
7400            cpu_dst,
7401            Rotation::None,
7402            Flip::None,
7403            Crop::no_crop(),
7404        );
7405        result.unwrap();
7406
7407        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
7408        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
7409        // the YUV-matrix delta that forced 0.95 has closed; tightened to
7410        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
7411        // structural gap not exercised by these limited-range fixtures.
7412        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
7413    }
7414
7415    #[test]
7416    #[cfg(target_os = "linux")]
7417    fn test_yuyv_to_yuyv_resize_g2d() {
7418        if !is_g2d_available() {
7419            eprintln!(
7420                "SKIPPED: test_yuyv_to_yuyv_resize_g2d - G2D library (libg2d.so.2) not available"
7421            );
7422            return;
7423        }
7424        if !is_dma_available() {
7425            eprintln!(
7426                "SKIPPED: test_yuyv_to_yuyv_resize_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
7427            );
7428            return;
7429        }
7430
7431        let src = load_bytes_to_tensor(
7432            1280,
7433            720,
7434            PixelFormat::Yuyv,
7435            None,
7436            &edgefirst_bench::testdata::read("camera720p.yuyv"),
7437        )
7438        .unwrap();
7439
7440        let g2d_dst = TensorDyn::image(
7441            600,
7442            400,
7443            PixelFormat::Yuyv,
7444            DType::U8,
7445            Some(TensorMemory::Dma),
7446            edgefirst_tensor::CpuAccess::ReadWrite,
7447        )
7448        .unwrap();
7449        let mut g2d_converter = G2DProcessor::new().unwrap();
7450
7451        let (result, src, g2d_dst) = convert_img(
7452            &mut g2d_converter,
7453            src,
7454            g2d_dst,
7455            Rotation::None,
7456            Flip::None,
7457            Crop::no_crop(),
7458        );
7459        result.unwrap();
7460
7461        let cpu_dst = TensorDyn::image(
7462            600,
7463            400,
7464            PixelFormat::Yuyv,
7465            DType::U8,
7466            None,
7467            edgefirst_tensor::CpuAccess::ReadWrite,
7468        )
7469        .unwrap();
7470        let mut cpu_converter: CPUProcessor = CPUProcessor::new();
7471
7472        let (result, _src, cpu_dst) = convert_img(
7473            &mut cpu_converter,
7474            src,
7475            cpu_dst,
7476            Rotation::None,
7477            Flip::None,
7478            Crop::no_crop(),
7479        );
7480        result.unwrap();
7481
7482        // G2D has poor colorimetry support: its hardware YUYV resize/sampling
7483        // diverges from the CPU reference by enough that the similarity score
7484        // sits around 0.85 on every measured G2D core (i.MX 8M Plus, i.MX 95).
7485        // The threshold is held at 0.85 so the test guards against gross
7486        // regressions while tolerating the driver's inherent colorimetry error.
7487        // TODO: compare YUYV↔YUYV directly without a YUYV→RGB convert.
7488        eprintln!(
7489            "WARNING: G2D has poor colorimetry support — YUYV resize diverges from the \
7490             CPU reference (~0.85 similarity); threshold held at 0.85, not 0.95."
7491        );
7492        compare_images_convert_to_rgb(&g2d_dst, &cpu_dst, 0.85, function!());
7493    }
7494
7495    #[test]
7496    fn test_yuyv_to_rgba_resize_cpu() {
7497        let src = load_bytes_to_tensor(
7498            1280,
7499            720,
7500            PixelFormat::Yuyv,
7501            None,
7502            &edgefirst_bench::testdata::read("camera720p.yuyv"),
7503        )
7504        .unwrap();
7505
7506        let (dst_width, dst_height) = (960, 540);
7507
7508        let dst = TensorDyn::image(
7509            dst_width,
7510            dst_height,
7511            PixelFormat::Rgba,
7512            DType::U8,
7513            None,
7514            edgefirst_tensor::CpuAccess::ReadWrite,
7515        )
7516        .unwrap();
7517        let mut cpu_converter = CPUProcessor::new();
7518
7519        let (result, _src, dst) = convert_img(
7520            &mut cpu_converter,
7521            src,
7522            dst,
7523            Rotation::None,
7524            Flip::None,
7525            Crop::no_crop(),
7526        );
7527        result.unwrap();
7528
7529        let dst_target = TensorDyn::image(
7530            dst_width,
7531            dst_height,
7532            PixelFormat::Rgba,
7533            DType::U8,
7534            None,
7535            edgefirst_tensor::CpuAccess::ReadWrite,
7536        )
7537        .unwrap();
7538        let src_target = load_bytes_to_tensor(
7539            1280,
7540            720,
7541            PixelFormat::Rgba,
7542            None,
7543            &edgefirst_bench::testdata::read("camera720p.rgba"),
7544        )
7545        .unwrap();
7546        let (result, _src_target, dst_target) = convert_img(
7547            &mut cpu_converter,
7548            src_target,
7549            dst_target,
7550            Rotation::None,
7551            Flip::None,
7552            Crop::no_crop(),
7553        );
7554        result.unwrap();
7555
7556        // CPU path resolves the untagged 720p source to BT.709 limited (height
7557        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
7558        compare_images(&dst, &dst_target, 0.98, function!());
7559    }
7560
7561    #[test]
7562    #[cfg(target_os = "linux")]
7563    fn test_yuyv_to_rgba_crop_flip_g2d() {
7564        if !is_g2d_available() {
7565            eprintln!(
7566                "SKIPPED: test_yuyv_to_rgba_crop_flip_g2d - G2D library (libg2d.so.2) not available"
7567            );
7568            return;
7569        }
7570        if !is_dma_available() {
7571            eprintln!(
7572                "SKIPPED: test_yuyv_to_rgba_crop_flip_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
7573            );
7574            return;
7575        }
7576
7577        let src = load_bytes_to_tensor(
7578            1280,
7579            720,
7580            PixelFormat::Yuyv,
7581            Some(TensorMemory::Dma),
7582            &edgefirst_bench::testdata::read("camera720p.yuyv"),
7583        )
7584        .unwrap();
7585
7586        let (dst_width, dst_height) = (640, 640);
7587
7588        let dst_g2d = TensorDyn::image(
7589            dst_width,
7590            dst_height,
7591            PixelFormat::Rgba,
7592            DType::U8,
7593            Some(TensorMemory::Dma),
7594            edgefirst_tensor::CpuAccess::ReadWrite,
7595        )
7596        .unwrap();
7597        let mut g2d_converter = G2DProcessor::new().unwrap();
7598        let crop = Crop::new().with_source(Some(Region::new(20, 15, 400, 300)));
7599
7600        let (result, src, dst_g2d) = convert_img(
7601            &mut g2d_converter,
7602            src,
7603            dst_g2d,
7604            Rotation::None,
7605            Flip::Horizontal,
7606            crop,
7607        );
7608        result.unwrap();
7609
7610        let dst_cpu = TensorDyn::image(
7611            dst_width,
7612            dst_height,
7613            PixelFormat::Rgba,
7614            DType::U8,
7615            Some(TensorMemory::Dma),
7616            edgefirst_tensor::CpuAccess::ReadWrite,
7617        )
7618        .unwrap();
7619        let mut cpu_converter = CPUProcessor::new();
7620
7621        let (result, _src, dst_cpu) = convert_img(
7622            &mut cpu_converter,
7623            src,
7624            dst_cpu,
7625            Rotation::None,
7626            Flip::Horizontal,
7627            crop,
7628        );
7629        result.unwrap();
7630        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
7631        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
7632        // the YUV-matrix delta that forced 0.95 has closed; tightened to
7633        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
7634        // structural gap not exercised by these limited-range fixtures.
7635        compare_images(&dst_g2d, &dst_cpu, 0.98, function!());
7636    }
7637
7638    #[test]
7639    #[cfg(target_os = "linux")]
7640    #[cfg(feature = "opengl")]
7641    fn test_yuyv_to_rgba_crop_flip_opengl() {
7642        if !is_opengl_available() {
7643            eprintln!("SKIPPED: {} - OpenGL not available", function!());
7644            return;
7645        }
7646
7647        if !is_dma_available() {
7648            eprintln!(
7649                "SKIPPED: {} - DMA memory allocation not available (permission denied or no DMA-BUF support)",
7650                function!()
7651            );
7652            return;
7653        }
7654
7655        let src = load_bytes_to_tensor(
7656            1280,
7657            720,
7658            PixelFormat::Yuyv,
7659            Some(TensorMemory::Dma),
7660            &edgefirst_bench::testdata::read("camera720p.yuyv"),
7661        )
7662        .unwrap();
7663
7664        let (dst_width, dst_height) = (640, 640);
7665
7666        let dst_gl = TensorDyn::image(
7667            dst_width,
7668            dst_height,
7669            PixelFormat::Rgba,
7670            DType::U8,
7671            Some(TensorMemory::Dma),
7672            edgefirst_tensor::CpuAccess::ReadWrite,
7673        )
7674        .unwrap();
7675        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
7676        let crop = Crop::new().with_source(Some(Region::new(20, 15, 400, 300)));
7677
7678        let (result, src, dst_gl) = convert_img(
7679            &mut gl_converter,
7680            src,
7681            dst_gl,
7682            Rotation::None,
7683            Flip::Horizontal,
7684            crop,
7685        );
7686        result.unwrap();
7687
7688        let dst_cpu = TensorDyn::image(
7689            dst_width,
7690            dst_height,
7691            PixelFormat::Rgba,
7692            DType::U8,
7693            Some(TensorMemory::Dma),
7694            edgefirst_tensor::CpuAccess::ReadWrite,
7695        )
7696        .unwrap();
7697        let mut cpu_converter = CPUProcessor::new();
7698
7699        let (result, _src, dst_cpu) = convert_img(
7700            &mut cpu_converter,
7701            src,
7702            dst_cpu,
7703            Rotation::None,
7704            Flip::Horizontal,
7705            crop,
7706        );
7707        result.unwrap();
7708        // Post-WS1 the GL path applies the resolved colorimetry via the EGL
7709        // YUV color-space/sample-range hints, so the matrix delta that forced
7710        // 0.95 has closed; tightened to 0.98 (driver-matrix rounding confirmed
7711        // on the GPU lanes).
7712        compare_images(&dst_gl, &dst_cpu, 0.98, function!());
7713    }
7714
7715    #[test]
7716    fn test_vyuy_to_rgba_cpu() {
7717        let file = edgefirst_bench::testdata::read("camera720p.vyuy").to_vec();
7718        let src = TensorDyn::image(
7719            1280,
7720            720,
7721            PixelFormat::Vyuy,
7722            DType::U8,
7723            None,
7724            edgefirst_tensor::CpuAccess::ReadWrite,
7725        )
7726        .unwrap();
7727        src.as_u8()
7728            .unwrap()
7729            .map()
7730            .unwrap()
7731            .as_mut_slice()
7732            .copy_from_slice(&file);
7733
7734        let dst = TensorDyn::image(
7735            1280,
7736            720,
7737            PixelFormat::Rgba,
7738            DType::U8,
7739            None,
7740            edgefirst_tensor::CpuAccess::ReadWrite,
7741        )
7742        .unwrap();
7743        let mut cpu_converter = CPUProcessor::new();
7744
7745        let (result, _src, dst) = convert_img(
7746            &mut cpu_converter,
7747            src,
7748            dst,
7749            Rotation::None,
7750            Flip::None,
7751            Crop::no_crop(),
7752        );
7753        result.unwrap();
7754
7755        let target_image = TensorDyn::image(
7756            1280,
7757            720,
7758            PixelFormat::Rgba,
7759            DType::U8,
7760            None,
7761            edgefirst_tensor::CpuAccess::ReadWrite,
7762        )
7763        .unwrap();
7764        target_image
7765            .as_u8()
7766            .unwrap()
7767            .map()
7768            .unwrap()
7769            .as_mut_slice()
7770            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
7771
7772        // CPU path resolves the untagged 720p source to BT.709 limited (height
7773        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
7774        compare_images(&dst, &target_image, 0.98, function!());
7775    }
7776
7777    #[test]
7778    fn test_vyuy_to_rgb_cpu() {
7779        let file = edgefirst_bench::testdata::read("camera720p.vyuy").to_vec();
7780        let src = TensorDyn::image(
7781            1280,
7782            720,
7783            PixelFormat::Vyuy,
7784            DType::U8,
7785            None,
7786            edgefirst_tensor::CpuAccess::ReadWrite,
7787        )
7788        .unwrap();
7789        src.as_u8()
7790            .unwrap()
7791            .map()
7792            .unwrap()
7793            .as_mut_slice()
7794            .copy_from_slice(&file);
7795
7796        let dst = TensorDyn::image(
7797            1280,
7798            720,
7799            PixelFormat::Rgb,
7800            DType::U8,
7801            None,
7802            edgefirst_tensor::CpuAccess::ReadWrite,
7803        )
7804        .unwrap();
7805        let mut cpu_converter = CPUProcessor::new();
7806
7807        let (result, _src, dst) = convert_img(
7808            &mut cpu_converter,
7809            src,
7810            dst,
7811            Rotation::None,
7812            Flip::None,
7813            Crop::no_crop(),
7814        );
7815        result.unwrap();
7816
7817        let target_image = TensorDyn::image(
7818            1280,
7819            720,
7820            PixelFormat::Rgb,
7821            DType::U8,
7822            None,
7823            edgefirst_tensor::CpuAccess::ReadWrite,
7824        )
7825        .unwrap();
7826        target_image
7827            .as_u8()
7828            .unwrap()
7829            .map()
7830            .unwrap()
7831            .as_mut_slice()
7832            .as_chunks_mut::<3>()
7833            .0
7834            .iter_mut()
7835            .zip(
7836                edgefirst_bench::testdata::read("camera720p.rgba")
7837                    .as_chunks::<4>()
7838                    .0,
7839            )
7840            .for_each(|(dst, src)| *dst = [src[0], src[1], src[2]]);
7841
7842        // CPU path resolves the untagged 720p source to BT.709 limited (height
7843        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
7844        compare_images(&dst, &target_image, 0.98, function!());
7845    }
7846
7847    #[test]
7848    #[cfg(target_os = "linux")]
7849    #[ignore = "G2D does not support VYUY; re-enable when hardware support is added"]
7850    fn test_vyuy_to_rgba_g2d() {
7851        if !is_g2d_available() {
7852            eprintln!("SKIPPED: test_vyuy_to_rgba_g2d - G2D library (libg2d.so.2) not available");
7853            return;
7854        }
7855        if !is_dma_available() {
7856            eprintln!(
7857                "SKIPPED: test_vyuy_to_rgba_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
7858            );
7859            return;
7860        }
7861
7862        let src = load_bytes_to_tensor(
7863            1280,
7864            720,
7865            PixelFormat::Vyuy,
7866            None,
7867            &edgefirst_bench::testdata::read("camera720p.vyuy"),
7868        )
7869        .unwrap();
7870
7871        let dst = TensorDyn::image(
7872            1280,
7873            720,
7874            PixelFormat::Rgba,
7875            DType::U8,
7876            Some(TensorMemory::Dma),
7877            edgefirst_tensor::CpuAccess::ReadWrite,
7878        )
7879        .unwrap();
7880        let mut g2d_converter = G2DProcessor::new().unwrap();
7881
7882        let (result, _src, dst) = convert_img(
7883            &mut g2d_converter,
7884            src,
7885            dst,
7886            Rotation::None,
7887            Flip::None,
7888            Crop::no_crop(),
7889        );
7890        match result {
7891            Err(Error::G2D(_)) => {
7892                eprintln!("SKIPPED: test_vyuy_to_rgba_g2d - G2D does not support PixelFormat::Vyuy format");
7893                return;
7894            }
7895            r => r.unwrap(),
7896        }
7897
7898        let target_image = TensorDyn::image(
7899            1280,
7900            720,
7901            PixelFormat::Rgba,
7902            DType::U8,
7903            None,
7904            edgefirst_tensor::CpuAccess::ReadWrite,
7905        )
7906        .unwrap();
7907        target_image
7908            .as_u8()
7909            .unwrap()
7910            .map()
7911            .unwrap()
7912            .as_mut_slice()
7913            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
7914
7915        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
7916        // so the matrix delta vs the reference that forced 0.95 has closed;
7917        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
7918        compare_images(&dst, &target_image, 0.98, function!());
7919    }
7920
7921    #[test]
7922    #[cfg(target_os = "linux")]
7923    #[ignore = "G2D does not support VYUY; re-enable when hardware support is added"]
7924    fn test_vyuy_to_rgb_g2d() {
7925        if !is_g2d_available() {
7926            eprintln!("SKIPPED: test_vyuy_to_rgb_g2d - G2D library (libg2d.so.2) not available");
7927            return;
7928        }
7929        if !is_dma_available() {
7930            eprintln!(
7931                "SKIPPED: test_vyuy_to_rgb_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
7932            );
7933            return;
7934        }
7935
7936        let src = load_bytes_to_tensor(
7937            1280,
7938            720,
7939            PixelFormat::Vyuy,
7940            None,
7941            &edgefirst_bench::testdata::read("camera720p.vyuy"),
7942        )
7943        .unwrap();
7944
7945        let g2d_dst = TensorDyn::image(
7946            1280,
7947            720,
7948            PixelFormat::Rgb,
7949            DType::U8,
7950            Some(TensorMemory::Dma),
7951            edgefirst_tensor::CpuAccess::ReadWrite,
7952        )
7953        .unwrap();
7954        let mut g2d_converter = G2DProcessor::new().unwrap();
7955
7956        let (result, src, g2d_dst) = convert_img(
7957            &mut g2d_converter,
7958            src,
7959            g2d_dst,
7960            Rotation::None,
7961            Flip::None,
7962            Crop::no_crop(),
7963        );
7964        match result {
7965            Err(Error::G2D(_)) => {
7966                eprintln!(
7967                    "SKIPPED: test_vyuy_to_rgb_g2d - G2D does not support PixelFormat::Vyuy format"
7968                );
7969                return;
7970            }
7971            r => r.unwrap(),
7972        }
7973
7974        let cpu_dst = TensorDyn::image(
7975            1280,
7976            720,
7977            PixelFormat::Rgb,
7978            DType::U8,
7979            None,
7980            edgefirst_tensor::CpuAccess::ReadWrite,
7981        )
7982        .unwrap();
7983        let mut cpu_converter: CPUProcessor = CPUProcessor::new();
7984
7985        let (result, _src, cpu_dst) = convert_img(
7986            &mut cpu_converter,
7987            src,
7988            cpu_dst,
7989            Rotation::None,
7990            Flip::None,
7991            Crop::no_crop(),
7992        );
7993        result.unwrap();
7994
7995        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
7996        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
7997        // the YUV-matrix delta that forced 0.95 has closed; tightened to
7998        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
7999        // structural gap not exercised by these limited-range fixtures.
8000        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
8001    }
8002
8003    #[test]
8004    #[cfg(target_os = "linux")]
8005    #[cfg(feature = "opengl")]
8006    fn test_vyuy_to_rgba_opengl() {
8007        if !is_opengl_available() {
8008            eprintln!("SKIPPED: {} - OpenGL not available", function!());
8009            return;
8010        }
8011        if !is_dma_available() {
8012            eprintln!(
8013                "SKIPPED: {} - DMA memory allocation not available (permission denied or no DMA-BUF support)",
8014                function!()
8015            );
8016            return;
8017        }
8018
8019        let src = load_bytes_to_tensor(
8020            1280,
8021            720,
8022            PixelFormat::Vyuy,
8023            Some(TensorMemory::Dma),
8024            &edgefirst_bench::testdata::read("camera720p.vyuy"),
8025        )
8026        .unwrap();
8027
8028        let dst = TensorDyn::image(
8029            1280,
8030            720,
8031            PixelFormat::Rgba,
8032            DType::U8,
8033            Some(TensorMemory::Dma),
8034            edgefirst_tensor::CpuAccess::ReadWrite,
8035        )
8036        .unwrap();
8037        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
8038
8039        let (result, _src, dst) = convert_img(
8040            &mut gl_converter,
8041            src,
8042            dst,
8043            Rotation::None,
8044            Flip::None,
8045            Crop::no_crop(),
8046        );
8047        match result {
8048            Err(Error::NotSupported(_)) => {
8049                eprintln!(
8050                    "SKIPPED: {} - OpenGL does not support PixelFormat::Vyuy DMA format",
8051                    function!()
8052                );
8053                return;
8054            }
8055            r => r.unwrap(),
8056        }
8057
8058        let target_image = TensorDyn::image(
8059            1280,
8060            720,
8061            PixelFormat::Rgba,
8062            DType::U8,
8063            None,
8064            edgefirst_tensor::CpuAccess::ReadWrite,
8065        )
8066        .unwrap();
8067        target_image
8068            .as_u8()
8069            .unwrap()
8070            .map()
8071            .unwrap()
8072            .as_mut_slice()
8073            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
8074
8075        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
8076        // so the matrix delta vs the reference that forced 0.95 has closed;
8077        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
8078        compare_images(&dst, &target_image, 0.98, function!());
8079    }
8080
8081    #[test]
8082    fn test_nv12_to_rgba_cpu() {
8083        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
8084        let src = TensorDyn::image(
8085            1280,
8086            720,
8087            PixelFormat::Nv12,
8088            DType::U8,
8089            None,
8090            edgefirst_tensor::CpuAccess::ReadWrite,
8091        )
8092        .unwrap();
8093        src.as_u8().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
8094            .copy_from_slice(&file);
8095
8096        let dst = TensorDyn::image(
8097            1280,
8098            720,
8099            PixelFormat::Rgba,
8100            DType::U8,
8101            None,
8102            edgefirst_tensor::CpuAccess::ReadWrite,
8103        )
8104        .unwrap();
8105        let mut cpu_converter = CPUProcessor::new();
8106
8107        let (result, _src, dst) = convert_img(
8108            &mut cpu_converter,
8109            src,
8110            dst,
8111            Rotation::None,
8112            Flip::None,
8113            Crop::no_crop(),
8114        );
8115        result.unwrap();
8116
8117        let target_image = crate::load_image_test_helper(
8118            &edgefirst_bench::testdata::read("zidane.jpg"),
8119            Some(PixelFormat::Rgba),
8120            None,
8121        )
8122        .unwrap();
8123
8124        // Threshold 0.95 (was 0.98): the reference now decodes the colour JPEG
8125        // to native NV12 and then converts to RGBA (was a direct JPEG → RGBA
8126        // decode), so it differs slightly from the RGBA derived from the
8127        // separate `zidane.nv12` fixture.
8128        compare_images(&dst, &target_image, 0.95, function!());
8129    }
8130
8131    #[test]
8132    fn test_nv12_odd_height_to_rgb_cpu() {
8133        // Odd height (even width) — the logical-odd case, e.g. 640×483. The
8134        // contiguous NV12 buffer is `[5 + ceil(5/2), 8]` = `[8, 8]` (5 luma rows
8135        // + 3 chroma rows). A neutral-grey fill (Y=U=V=128, BT.601 full-range)
8136        // must convert to a uniform grey RGB, exercising the odd-height
8137        // chroma-row count and the logical-height derivation in convert.
8138        // (Odd *width* is rounded to an even buffer at allocation, so it is
8139        // covered by the decode integration tests rather than here.)
8140        // CPU-only test: pin to tight host memory (None auto-selects pitch-padded
8141        // DMA on i.MX, which would leave the dst's row padding unconverted and
8142        // break the flat byte scan below).
8143        let mut src = TensorDyn::image(
8144            8,
8145            5,
8146            PixelFormat::Nv12,
8147            DType::U8,
8148            Some(TensorMemory::Mem),
8149            edgefirst_tensor::CpuAccess::ReadWrite,
8150        )
8151        .unwrap();
8152        assert_eq!(src.shape(), &[8, 8]);
8153        assert_eq!((src.width(), src.height()), (Some(8), Some(5)));
8154        src.as_u8().unwrap().map().unwrap().as_mut_slice().fill(128);
8155        // Tag BT.601 full-range so Y=128 decodes to grey 128 (the neutral-grey
8156        // identity this test asserts). Without a tag, the colorimetry heuristic
8157        // resolves an SD tensor to BT.601 *limited*, expanding Y=128 → ~131.
8158        src.set_colorimetry(Some(
8159            edgefirst_tensor::Colorimetry::default()
8160                .with_encoding(edgefirst_tensor::ColorEncoding::Bt601)
8161                .with_range(edgefirst_tensor::ColorRange::Full),
8162        ));
8163
8164        let dst = TensorDyn::image(
8165            8,
8166            5,
8167            PixelFormat::Rgb,
8168            DType::U8,
8169            Some(TensorMemory::Mem),
8170            edgefirst_tensor::CpuAccess::ReadWrite,
8171        )
8172        .unwrap();
8173        let mut cpu_converter = CPUProcessor::new();
8174        let (result, _src, dst) = convert_img(
8175            &mut cpu_converter,
8176            src,
8177            dst,
8178            Rotation::None,
8179            Flip::None,
8180            Crop::no_crop(),
8181        );
8182        result.unwrap();
8183
8184        assert_eq!((dst.width(), dst.height()), (Some(8), Some(5)));
8185        let map = dst.as_u8().unwrap().map().unwrap();
8186        for (i, &b) in map.as_slice().iter().enumerate() {
8187            assert!(
8188                (b as i16 - 128).abs() <= 2,
8189                "pixel byte {i} = {b}, expected ~128 for neutral-grey NV12"
8190            );
8191        }
8192    }
8193
8194    #[test]
8195    fn test_nv24_to_rgb_cpu() {
8196        // NV24 (4:4:4) at 8×4: contiguous buffer is [4*3, 8] = [12, 8] — Y plane
8197        // (4 rows) + full-res interleaved UV plane (8 rows = 2H, 2W bytes per
8198        // chroma row). Neutral-grey fill (Y=U=V=128) must convert to uniform
8199        // grey RGB, exercising the 2× UV stride and shape[0]/3 height recovery.
8200        // CPU-only test: pin to tight host memory (see test_nv12_odd_height_to_rgb_cpu).
8201        let mut src = TensorDyn::image(
8202            8,
8203            4,
8204            PixelFormat::Nv24,
8205            DType::U8,
8206            Some(TensorMemory::Mem),
8207            edgefirst_tensor::CpuAccess::ReadWrite,
8208        )
8209        .unwrap();
8210        assert_eq!(src.shape(), &[12, 8]);
8211        assert_eq!((src.width(), src.height()), (Some(8), Some(4)));
8212        src.as_u8().unwrap().map().unwrap().as_mut_slice().fill(128);
8213        // Tag BT.601 full-range (see test_nv12_odd_height_to_rgb_cpu): without it
8214        // the heuristic picks limited range and Y=128 expands to ~131.
8215        src.set_colorimetry(Some(
8216            edgefirst_tensor::Colorimetry::default()
8217                .with_encoding(edgefirst_tensor::ColorEncoding::Bt601)
8218                .with_range(edgefirst_tensor::ColorRange::Full),
8219        ));
8220
8221        let dst = TensorDyn::image(
8222            8,
8223            4,
8224            PixelFormat::Rgb,
8225            DType::U8,
8226            Some(TensorMemory::Mem),
8227            edgefirst_tensor::CpuAccess::ReadWrite,
8228        )
8229        .unwrap();
8230        let mut cpu_converter = CPUProcessor::new();
8231        let (result, _src, dst) = convert_img(
8232            &mut cpu_converter,
8233            src,
8234            dst,
8235            Rotation::None,
8236            Flip::None,
8237            Crop::no_crop(),
8238        );
8239        result.unwrap();
8240
8241        assert_eq!((dst.width(), dst.height()), (Some(8), Some(4)));
8242        let map = dst.as_u8().unwrap().map().unwrap();
8243        for (i, &b) in map.as_slice().iter().enumerate() {
8244            assert!(
8245                (b as i16 - 128).abs() <= 2,
8246                "pixel byte {i} = {b}, expected ~128 for neutral-grey NV24"
8247            );
8248        }
8249    }
8250
8251    #[test]
8252    fn cpu_nv12_to_rgb_respects_tagged_bt2020() {
8253        // A uniform but *saturated* chroma sample (U/V far from neutral) so the
8254        // YUV→RGB matrix — not just the range — drives the result. Decoding the
8255        // same NV12 bytes under BT.601 / BT.709 / BT.2020 must yield three
8256        // distinct RGB triples, proving the CPU path honours the source's tagged
8257        // ColorEncoding instead of a hardcoded matrix. (G2D declines BT.2020 and
8258        // falls through to this CPU path; QA F9.)
8259        // CPU-only test: pin to tight host memory (see test_nv12_odd_height_to_rgb_cpu).
8260        fn decode_tagged(enc: edgefirst_tensor::ColorEncoding) -> [u8; 3] {
8261            let mut src = TensorDyn::image(
8262                8,
8263                4,
8264                PixelFormat::Nv12,
8265                DType::U8,
8266                Some(TensorMemory::Mem),
8267                edgefirst_tensor::CpuAccess::ReadWrite,
8268            )
8269            .unwrap();
8270            // NV12 8×4: 32-byte Y plane + 16-byte interleaved UV plane (4:2:0).
8271            assert_eq!(src.shape(), &[6, 8]);
8272            {
8273                let mut map = src.as_u8().unwrap().map().unwrap();
8274                let buf = map.as_mut_slice();
8275                buf[..32].fill(120); // Y
8276                for px in buf[32..].chunks_exact_mut(2) {
8277                    px[0] = 180; // U / Cb
8278                    px[1] = 64; // V / Cr
8279                }
8280            }
8281            // Range held constant (Limited) across all three so only the encoding
8282            // matrix varies between runs.
8283            src.set_colorimetry(Some(
8284                edgefirst_tensor::Colorimetry::default()
8285                    .with_encoding(enc)
8286                    .with_range(edgefirst_tensor::ColorRange::Limited),
8287            ));
8288            let dst = TensorDyn::image(
8289                8,
8290                4,
8291                PixelFormat::Rgb,
8292                DType::U8,
8293                Some(TensorMemory::Mem),
8294                edgefirst_tensor::CpuAccess::ReadWrite,
8295            )
8296            .unwrap();
8297            let mut cpu = CPUProcessor::new();
8298            let (result, _src, dst) = convert_img(
8299                &mut cpu,
8300                src,
8301                dst,
8302                Rotation::None,
8303                Flip::None,
8304                Crop::no_crop(),
8305            );
8306            result.unwrap();
8307            let map = dst.as_u8().unwrap().map().unwrap();
8308            let s = map.as_slice();
8309            [s[0], s[1], s[2]]
8310        }
8311
8312        let bt601 = decode_tagged(edgefirst_tensor::ColorEncoding::Bt601);
8313        let bt709 = decode_tagged(edgefirst_tensor::ColorEncoding::Bt709);
8314        let bt2020 = decode_tagged(edgefirst_tensor::ColorEncoding::Bt2020);
8315
8316        assert_ne!(
8317            bt2020, bt601,
8318            "BT.2020 must decode differently from BT.601 ({bt2020:?} vs {bt601:?})"
8319        );
8320        assert_ne!(
8321            bt2020, bt709,
8322            "BT.2020 must decode differently from BT.709 ({bt2020:?} vs {bt709:?})"
8323        );
8324        assert_ne!(
8325            bt601, bt709,
8326            "BT.601 must decode differently from BT.709 ({bt601:?} vs {bt709:?})"
8327        );
8328    }
8329
8330    #[test]
8331    fn test_nv12_to_rgb_cpu() {
8332        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
8333        let src = TensorDyn::image(
8334            1280,
8335            720,
8336            PixelFormat::Nv12,
8337            DType::U8,
8338            None,
8339            edgefirst_tensor::CpuAccess::ReadWrite,
8340        )
8341        .unwrap();
8342        src.as_u8().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
8343            .copy_from_slice(&file);
8344
8345        let dst = TensorDyn::image(
8346            1280,
8347            720,
8348            PixelFormat::Rgb,
8349            DType::U8,
8350            None,
8351            edgefirst_tensor::CpuAccess::ReadWrite,
8352        )
8353        .unwrap();
8354        let mut cpu_converter = CPUProcessor::new();
8355
8356        let (result, _src, dst) = convert_img(
8357            &mut cpu_converter,
8358            src,
8359            dst,
8360            Rotation::None,
8361            Flip::None,
8362            Crop::no_crop(),
8363        );
8364        result.unwrap();
8365
8366        let target_image = crate::load_image_test_helper(
8367            &edgefirst_bench::testdata::read("zidane.jpg"),
8368            Some(PixelFormat::Rgb),
8369            None,
8370        )
8371        .unwrap();
8372
8373        // Threshold 0.95 (was 0.98): the reference now decodes the colour JPEG
8374        // to native NV12 and then converts to RGB (was a direct JPEG → RGB
8375        // decode), so it differs slightly from the RGB derived from the
8376        // separate `zidane.nv12` fixture.
8377        compare_images(&dst, &target_image, 0.95, function!());
8378    }
8379
8380    #[test]
8381    fn test_nv12_to_grey_cpu() {
8382        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
8383        let src = TensorDyn::image(
8384            1280,
8385            720,
8386            PixelFormat::Nv12,
8387            DType::U8,
8388            None,
8389            edgefirst_tensor::CpuAccess::ReadWrite,
8390        )
8391        .unwrap();
8392        src.as_u8().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
8393            .copy_from_slice(&file);
8394
8395        let dst = TensorDyn::image(
8396            1280,
8397            720,
8398            PixelFormat::Grey,
8399            DType::U8,
8400            None,
8401            edgefirst_tensor::CpuAccess::ReadWrite,
8402        )
8403        .unwrap();
8404        let mut cpu_converter = CPUProcessor::new();
8405
8406        let (result, _src, dst) = convert_img(
8407            &mut cpu_converter,
8408            src,
8409            dst,
8410            Rotation::None,
8411            Flip::None,
8412            Crop::no_crop(),
8413        );
8414        result.unwrap();
8415
8416        let target_image = crate::load_image_test_helper(
8417            &edgefirst_bench::testdata::read("zidane.jpg"),
8418            Some(PixelFormat::Grey),
8419            None,
8420        )
8421        .unwrap();
8422
8423        // Threshold 0.95 (was 0.98): the reference grey frame now comes from
8424        // the colour JPEG decoded to native NV12 and then converted to GREY
8425        // (was a direct JPEG → GREY decode), so it differs slightly from the
8426        // grey derived from the `zidane.nv12` fixture.
8427        compare_images(&dst, &target_image, 0.95, function!());
8428    }
8429
8430    #[test]
8431    fn test_nv12_to_yuyv_cpu() {
8432        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
8433        let src = TensorDyn::image(
8434            1280,
8435            720,
8436            PixelFormat::Nv12,
8437            DType::U8,
8438            None,
8439            edgefirst_tensor::CpuAccess::ReadWrite,
8440        )
8441        .unwrap();
8442        src.as_u8().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
8443            .copy_from_slice(&file);
8444
8445        let dst = TensorDyn::image(
8446            1280,
8447            720,
8448            PixelFormat::Yuyv,
8449            DType::U8,
8450            None,
8451            edgefirst_tensor::CpuAccess::ReadWrite,
8452        )
8453        .unwrap();
8454        let mut cpu_converter = CPUProcessor::new();
8455
8456        let (result, _src, dst) = convert_img(
8457            &mut cpu_converter,
8458            src,
8459            dst,
8460            Rotation::None,
8461            Flip::None,
8462            Crop::no_crop(),
8463        );
8464        result.unwrap();
8465
8466        let target_image = crate::load_image_test_helper(
8467            &edgefirst_bench::testdata::read("zidane.jpg"),
8468            Some(PixelFormat::Rgb),
8469            None,
8470        )
8471        .unwrap();
8472
8473        // Threshold 0.95 (was 0.98): the reference now decodes the colour JPEG
8474        // to native NV12 and then converts to RGB (was a direct JPEG → RGB
8475        // decode), so it differs slightly from the YUYV-sourced frame derived
8476        // from the separate `zidane.nv12` fixture.
8477        compare_images_convert_to_rgb(&dst, &target_image, 0.95, function!());
8478    }
8479
8480    #[test]
8481    fn test_cpu_resize_nv16() {
8482        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
8483        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
8484
8485        let cpu_nv16_dst = TensorDyn::image(
8486            640,
8487            640,
8488            PixelFormat::Nv16,
8489            DType::U8,
8490            None,
8491            edgefirst_tensor::CpuAccess::ReadWrite,
8492        )
8493        .unwrap();
8494        let cpu_rgb_dst = TensorDyn::image(
8495            640,
8496            640,
8497            PixelFormat::Rgb,
8498            DType::U8,
8499            None,
8500            edgefirst_tensor::CpuAccess::ReadWrite,
8501        )
8502        .unwrap();
8503        let mut cpu_converter = CPUProcessor::new();
8504        let crop = Crop::letterbox([255, 128, 0, 255]);
8505
8506        let (result, src, cpu_nv16_dst) = convert_img(
8507            &mut cpu_converter,
8508            src,
8509            cpu_nv16_dst,
8510            Rotation::None,
8511            Flip::None,
8512            crop,
8513        );
8514        result.unwrap();
8515
8516        let (result, _src, cpu_rgb_dst) = convert_img(
8517            &mut cpu_converter,
8518            src,
8519            cpu_rgb_dst,
8520            Rotation::None,
8521            Flip::None,
8522            crop,
8523        );
8524        result.unwrap();
8525        compare_images_convert_to_rgb(&cpu_nv16_dst, &cpu_rgb_dst, 0.99, function!());
8526    }
8527
8528    fn load_bytes_to_tensor(
8529        width: usize,
8530        height: usize,
8531        format: PixelFormat,
8532        memory: Option<TensorMemory>,
8533        bytes: &[u8],
8534    ) -> Result<TensorDyn, Error> {
8535        let src = TensorDyn::image(
8536            width,
8537            height,
8538            format,
8539            DType::U8,
8540            memory,
8541            edgefirst_tensor::CpuAccess::ReadWrite,
8542        )?;
8543        src.as_u8()
8544            .unwrap()
8545            .map()?
8546            .as_mut_slice()
8547            .copy_from_slice(bytes);
8548        Ok(src)
8549    }
8550
8551    // DEDUP: this function is also defined verbatim in
8552    // `crates/image/src/gl/tests.rs` (inside `mod gl_tests`). Both copies
8553    // must be kept in sync. Cross-module sharing would require either a
8554    // `pub(crate)` test-helper module (which pollutes the non-test API) or a
8555    // separate test-utils crate — both are disproportionate for a single
8556    // helper. If the implementation ever diverges, extract to a shared
8557    // `test_helpers` module in this crate.
8558    fn compare_images(img1: &TensorDyn, img2: &TensorDyn, threshold: f64, name: &str) {
8559        assert_eq!(img1.height(), img2.height(), "Heights differ");
8560        assert_eq!(img1.width(), img2.width(), "Widths differ");
8561        assert_eq!(
8562            img1.format().unwrap(),
8563            img2.format().unwrap(),
8564            "PixelFormat differ"
8565        );
8566        assert!(
8567            matches!(
8568                img1.format().unwrap(),
8569                PixelFormat::Rgb | PixelFormat::Rgba | PixelFormat::Grey | PixelFormat::PlanarRgb
8570            ),
8571            "format must be Rgb or Rgba for comparison"
8572        );
8573
8574        let image1 = match img1.format().unwrap() {
8575            PixelFormat::Rgb => image::RgbImage::from_vec(
8576                img1.width().unwrap() as u32,
8577                img1.height().unwrap() as u32,
8578                img1.as_u8().unwrap().map().unwrap().to_vec(),
8579            )
8580            .unwrap(),
8581            PixelFormat::Rgba => image::RgbaImage::from_vec(
8582                img1.width().unwrap() as u32,
8583                img1.height().unwrap() as u32,
8584                img1.as_u8().unwrap().map().unwrap().to_vec(),
8585            )
8586            .unwrap()
8587            .convert(),
8588            PixelFormat::Grey => image::GrayImage::from_vec(
8589                img1.width().unwrap() as u32,
8590                img1.height().unwrap() as u32,
8591                img1.as_u8().unwrap().map().unwrap().to_vec(),
8592            )
8593            .unwrap()
8594            .convert(),
8595            PixelFormat::PlanarRgb => image::GrayImage::from_vec(
8596                img1.width().unwrap() as u32,
8597                (img1.height().unwrap() * 3) as u32,
8598                img1.as_u8().unwrap().map().unwrap().to_vec(),
8599            )
8600            .unwrap()
8601            .convert(),
8602            _ => return,
8603        };
8604
8605        let image2 = match img2.format().unwrap() {
8606            PixelFormat::Rgb => image::RgbImage::from_vec(
8607                img2.width().unwrap() as u32,
8608                img2.height().unwrap() as u32,
8609                img2.as_u8().unwrap().map().unwrap().to_vec(),
8610            )
8611            .unwrap(),
8612            PixelFormat::Rgba => image::RgbaImage::from_vec(
8613                img2.width().unwrap() as u32,
8614                img2.height().unwrap() as u32,
8615                img2.as_u8().unwrap().map().unwrap().to_vec(),
8616            )
8617            .unwrap()
8618            .convert(),
8619            PixelFormat::Grey => image::GrayImage::from_vec(
8620                img2.width().unwrap() as u32,
8621                img2.height().unwrap() as u32,
8622                img2.as_u8().unwrap().map().unwrap().to_vec(),
8623            )
8624            .unwrap()
8625            .convert(),
8626            PixelFormat::PlanarRgb => image::GrayImage::from_vec(
8627                img2.width().unwrap() as u32,
8628                (img2.height().unwrap() * 3) as u32,
8629                img2.as_u8().unwrap().map().unwrap().to_vec(),
8630            )
8631            .unwrap()
8632            .convert(),
8633            _ => return,
8634        };
8635
8636        let similarity = image_compare::rgb_similarity_structure(
8637            &image_compare::Algorithm::RootMeanSquared,
8638            &image1,
8639            &image2,
8640        )
8641        .expect("Image Comparison failed");
8642        if similarity.score < threshold {
8643            // image1.save(format!("{name}_1.png"));
8644            // image2.save(format!("{name}_2.png"));
8645            similarity
8646                .image
8647                .to_color_map()
8648                .save(format!("{name}.png"))
8649                .unwrap();
8650            panic!(
8651                "{name}: converted image and target image have similarity score too low: {} < {}",
8652                similarity.score, threshold
8653            )
8654        }
8655    }
8656
8657    fn compare_images_convert_to_rgb(
8658        img1: &TensorDyn,
8659        img2: &TensorDyn,
8660        threshold: f64,
8661        name: &str,
8662    ) {
8663        assert_eq!(img1.height(), img2.height(), "Heights differ");
8664        assert_eq!(img1.width(), img2.width(), "Widths differ");
8665
8666        let mut img_rgb1 = TensorDyn::image(
8667            img1.width().unwrap(),
8668            img1.height().unwrap(),
8669            PixelFormat::Rgb,
8670            DType::U8,
8671            Some(TensorMemory::Mem),
8672            edgefirst_tensor::CpuAccess::ReadWrite,
8673        )
8674        .unwrap();
8675        let mut img_rgb2 = TensorDyn::image(
8676            img1.width().unwrap(),
8677            img1.height().unwrap(),
8678            PixelFormat::Rgb,
8679            DType::U8,
8680            Some(TensorMemory::Mem),
8681            edgefirst_tensor::CpuAccess::ReadWrite,
8682        )
8683        .unwrap();
8684        let mut __cv = CPUProcessor::default();
8685        let r1 = __cv.convert(
8686            img1,
8687            &mut img_rgb1,
8688            crate::Rotation::None,
8689            crate::Flip::None,
8690            crate::Crop::default(),
8691        );
8692        let r2 = __cv.convert(
8693            img2,
8694            &mut img_rgb2,
8695            crate::Rotation::None,
8696            crate::Flip::None,
8697            crate::Crop::default(),
8698        );
8699        if r1.is_err() || r2.is_err() {
8700            // Fallback: compare raw bytes as greyscale strip
8701            let w = img1.width().unwrap() as u32;
8702            let data1 = img1.as_u8().unwrap().map().unwrap().to_vec();
8703            let data2 = img2.as_u8().unwrap().map().unwrap().to_vec();
8704            let h1 = (data1.len() as u32) / w;
8705            let h2 = (data2.len() as u32) / w;
8706            let g1 = image::GrayImage::from_vec(w, h1, data1).unwrap();
8707            let g2 = image::GrayImage::from_vec(w, h2, data2).unwrap();
8708            let similarity = image_compare::gray_similarity_structure(
8709                &image_compare::Algorithm::RootMeanSquared,
8710                &g1,
8711                &g2,
8712            )
8713            .expect("Image Comparison failed");
8714            if similarity.score < threshold {
8715                panic!(
8716                    "{name}: converted image and target image have similarity score too low: {} < {}",
8717                    similarity.score, threshold
8718                )
8719            }
8720            return;
8721        }
8722
8723        let image1 = image::RgbImage::from_vec(
8724            img_rgb1.width().unwrap() as u32,
8725            img_rgb1.height().unwrap() as u32,
8726            img_rgb1.as_u8().unwrap().map().unwrap().to_vec(),
8727        )
8728        .unwrap();
8729
8730        let image2 = image::RgbImage::from_vec(
8731            img_rgb2.width().unwrap() as u32,
8732            img_rgb2.height().unwrap() as u32,
8733            img_rgb2.as_u8().unwrap().map().unwrap().to_vec(),
8734        )
8735        .unwrap();
8736
8737        let similarity = image_compare::rgb_similarity_structure(
8738            &image_compare::Algorithm::RootMeanSquared,
8739            &image1,
8740            &image2,
8741        )
8742        .expect("Image Comparison failed");
8743        if similarity.score < threshold {
8744            // image1.save(format!("{name}_1.png"));
8745            // image2.save(format!("{name}_2.png"));
8746            similarity
8747                .image
8748                .to_color_map()
8749                .save(format!("{name}.png"))
8750                .unwrap();
8751            panic!(
8752                "{name}: converted image and target image have similarity score too low: {} < {}",
8753                similarity.score, threshold
8754            )
8755        }
8756    }
8757
8758    // =========================================================================
8759    // PixelFormat::Nv12 Format Tests
8760    // =========================================================================
8761
8762    #[test]
8763    fn test_nv12_image_creation() {
8764        let width = 640;
8765        let height = 480;
8766        let img = TensorDyn::image(
8767            width,
8768            height,
8769            PixelFormat::Nv12,
8770            DType::U8,
8771            None,
8772            edgefirst_tensor::CpuAccess::ReadWrite,
8773        )
8774        .unwrap();
8775
8776        assert_eq!(img.width(), Some(width));
8777        assert_eq!(img.height(), Some(height));
8778        assert_eq!(img.format().unwrap(), PixelFormat::Nv12);
8779        // PixelFormat::Nv12 uses shape [H*3/2, W] to store Y plane + UV plane
8780        assert_eq!(img.as_u8().unwrap().shape(), &[height * 3 / 2, width]);
8781    }
8782
8783    #[test]
8784    fn test_nv12_channels() {
8785        let img = TensorDyn::image(
8786            640,
8787            480,
8788            PixelFormat::Nv12,
8789            DType::U8,
8790            None,
8791            edgefirst_tensor::CpuAccess::ReadWrite,
8792        )
8793        .unwrap();
8794        // PixelFormat::Nv12.channels() returns 1 (luma plane)
8795        assert_eq!(img.format().unwrap().channels(), 1);
8796    }
8797
8798    // =========================================================================
8799    // Tensor Format Metadata Tests
8800    // =========================================================================
8801
8802    #[test]
8803    fn test_tensor_set_format_planar() {
8804        let mut tensor = Tensor::<u8>::new(&[3, 480, 640], None, None).unwrap();
8805        tensor.set_format(PixelFormat::PlanarRgb).unwrap();
8806        assert_eq!(tensor.format(), Some(PixelFormat::PlanarRgb));
8807        assert_eq!(tensor.width(), Some(640));
8808        assert_eq!(tensor.height(), Some(480));
8809    }
8810
8811    #[test]
8812    fn test_tensor_set_format_interleaved() {
8813        let mut tensor = Tensor::<u8>::new(&[480, 640, 4], None, None).unwrap();
8814        tensor.set_format(PixelFormat::Rgba).unwrap();
8815        assert_eq!(tensor.format(), Some(PixelFormat::Rgba));
8816        assert_eq!(tensor.width(), Some(640));
8817        assert_eq!(tensor.height(), Some(480));
8818    }
8819
8820    #[test]
8821    fn test_tensordyn_image_rgb() {
8822        let img = TensorDyn::image(
8823            640,
8824            480,
8825            PixelFormat::Rgb,
8826            DType::U8,
8827            None,
8828            edgefirst_tensor::CpuAccess::ReadWrite,
8829        )
8830        .unwrap();
8831        assert_eq!(img.width(), Some(640));
8832        assert_eq!(img.height(), Some(480));
8833        assert_eq!(img.format(), Some(PixelFormat::Rgb));
8834    }
8835
8836    #[test]
8837    fn test_tensordyn_image_planar_rgb() {
8838        let img = TensorDyn::image(
8839            640,
8840            480,
8841            PixelFormat::PlanarRgb,
8842            DType::U8,
8843            None,
8844            edgefirst_tensor::CpuAccess::ReadWrite,
8845        )
8846        .unwrap();
8847        assert_eq!(img.width(), Some(640));
8848        assert_eq!(img.height(), Some(480));
8849        assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
8850    }
8851
8852    #[test]
8853    fn test_rgb_int8_format() {
8854        // Int8 variant: same PixelFormat::Rgb but with DType::I8
8855        let img = TensorDyn::image(
8856            1280,
8857            720,
8858            PixelFormat::Rgb,
8859            DType::I8,
8860            Some(TensorMemory::Mem),
8861            edgefirst_tensor::CpuAccess::ReadWrite,
8862        )
8863        .unwrap();
8864        assert_eq!(img.width(), Some(1280));
8865        assert_eq!(img.height(), Some(720));
8866        assert_eq!(img.format(), Some(PixelFormat::Rgb));
8867        assert_eq!(img.dtype(), DType::I8);
8868    }
8869
8870    #[test]
8871    fn test_planar_rgb_int8_format() {
8872        let img = TensorDyn::image(
8873            1280,
8874            720,
8875            PixelFormat::PlanarRgb,
8876            DType::I8,
8877            Some(TensorMemory::Mem),
8878            edgefirst_tensor::CpuAccess::ReadWrite,
8879        )
8880        .unwrap();
8881        assert_eq!(img.width(), Some(1280));
8882        assert_eq!(img.height(), Some(720));
8883        assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
8884        assert_eq!(img.dtype(), DType::I8);
8885    }
8886
8887    #[test]
8888    fn test_rgb_from_tensor() {
8889        let mut tensor = Tensor::<u8>::new(&[720, 1280, 3], None, None).unwrap();
8890        tensor.set_format(PixelFormat::Rgb).unwrap();
8891        let img = TensorDyn::from(tensor);
8892        assert_eq!(img.width(), Some(1280));
8893        assert_eq!(img.height(), Some(720));
8894        assert_eq!(img.format(), Some(PixelFormat::Rgb));
8895    }
8896
8897    #[test]
8898    fn test_planar_rgb_from_tensor() {
8899        let mut tensor = Tensor::<u8>::new(&[3, 720, 1280], None, None).unwrap();
8900        tensor.set_format(PixelFormat::PlanarRgb).unwrap();
8901        let img = TensorDyn::from(tensor);
8902        assert_eq!(img.width(), Some(1280));
8903        assert_eq!(img.height(), Some(720));
8904        assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
8905    }
8906
8907    #[test]
8908    fn test_dtype_determines_int8() {
8909        // DType::I8 indicates int8 data
8910        let u8_img = TensorDyn::image(
8911            64,
8912            64,
8913            PixelFormat::Rgb,
8914            DType::U8,
8915            None,
8916            edgefirst_tensor::CpuAccess::ReadWrite,
8917        )
8918        .unwrap();
8919        let i8_img = TensorDyn::image(
8920            64,
8921            64,
8922            PixelFormat::Rgb,
8923            DType::I8,
8924            None,
8925            edgefirst_tensor::CpuAccess::ReadWrite,
8926        )
8927        .unwrap();
8928        assert_eq!(u8_img.dtype(), DType::U8);
8929        assert_eq!(i8_img.dtype(), DType::I8);
8930    }
8931
8932    #[test]
8933    fn test_pixel_layout_packed_vs_planar() {
8934        // Packed vs planar layout classification
8935        assert_eq!(PixelFormat::Rgb.layout(), PixelLayout::Packed);
8936        assert_eq!(PixelFormat::Rgba.layout(), PixelLayout::Packed);
8937        assert_eq!(PixelFormat::PlanarRgb.layout(), PixelLayout::Planar);
8938        assert_eq!(PixelFormat::Nv12.layout(), PixelLayout::SemiPlanar);
8939    }
8940
8941    /// Integration test that exercises the PBO-to-PBO convert path.
8942    /// Uses ImageProcessor::create_image() to allocate PBO-backed tensors,
8943    /// then converts between them. Skipped when GL is unavailable or the
8944    /// backend is not PBO (e.g. DMA-buf systems).
8945    #[cfg(target_os = "linux")]
8946    #[cfg(feature = "opengl")]
8947    #[test]
8948    fn test_convert_pbo_to_pbo() {
8949        let mut converter = ImageProcessor::new().unwrap();
8950
8951        // Skip if GL is not available or backend is not PBO
8952        let is_pbo = converter
8953            .opengl
8954            .as_ref()
8955            .is_some_and(|gl| gl.transfer_backend() == opengl_headless::TransferBackend::Pbo);
8956        if !is_pbo {
8957            eprintln!("Skipping test_convert_pbo_to_pbo: backend is not PBO");
8958            return;
8959        }
8960
8961        let src_w = 640;
8962        let src_h = 480;
8963        let dst_w = 320;
8964        let dst_h = 240;
8965
8966        // Create PBO-backed source image
8967        let pbo_src = converter
8968            .create_image(
8969                src_w,
8970                src_h,
8971                PixelFormat::Rgba,
8972                DType::U8,
8973                None,
8974                edgefirst_tensor::CpuAccess::ReadWrite,
8975            )
8976            .unwrap();
8977        assert_eq!(
8978            pbo_src.as_u8().unwrap().memory(),
8979            TensorMemory::Pbo,
8980            "create_image should produce a PBO tensor"
8981        );
8982
8983        // Fill source PBO with test pattern: load JPEG then convert Mem→PBO
8984        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
8985        let jpeg_src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
8986
8987        // Resize JPEG into a Mem temp of the right size, then copy into PBO
8988        let mem_src = TensorDyn::image(
8989            src_w,
8990            src_h,
8991            PixelFormat::Rgba,
8992            DType::U8,
8993            Some(TensorMemory::Mem),
8994            edgefirst_tensor::CpuAccess::ReadWrite,
8995        )
8996        .unwrap();
8997        let (result, _jpeg_src, mem_src) = convert_img(
8998            &mut CPUProcessor::new(),
8999            jpeg_src,
9000            mem_src,
9001            Rotation::None,
9002            Flip::None,
9003            Crop::no_crop(),
9004        );
9005        result.unwrap();
9006
9007        // Copy pixel data into the PBO source by mapping it
9008        {
9009            let src_data = mem_src.as_u8().unwrap().map().unwrap();
9010            let mut pbo_map = pbo_src.as_u8().unwrap().map().unwrap();
9011            pbo_map.copy_from_slice(&src_data);
9012        }
9013
9014        // Create PBO-backed destination image
9015        let pbo_dst = converter
9016            .create_image(
9017                dst_w,
9018                dst_h,
9019                PixelFormat::Rgba,
9020                DType::U8,
9021                None,
9022                edgefirst_tensor::CpuAccess::ReadWrite,
9023            )
9024            .unwrap();
9025        assert_eq!(pbo_dst.as_u8().unwrap().memory(), TensorMemory::Pbo);
9026
9027        // Convert PBO→PBO (this exercises convert_pbo_to_pbo)
9028        let mut pbo_dst = pbo_dst;
9029        let result = converter.convert(
9030            &pbo_src,
9031            &mut pbo_dst,
9032            Rotation::None,
9033            Flip::None,
9034            Crop::no_crop(),
9035        );
9036        result.unwrap();
9037
9038        // Verify: compare with CPU-only conversion of the same input
9039        let cpu_dst = TensorDyn::image(
9040            dst_w,
9041            dst_h,
9042            PixelFormat::Rgba,
9043            DType::U8,
9044            Some(TensorMemory::Mem),
9045            edgefirst_tensor::CpuAccess::ReadWrite,
9046        )
9047        .unwrap();
9048        let (result, _mem_src, cpu_dst) = convert_img(
9049            &mut CPUProcessor::new(),
9050            mem_src,
9051            cpu_dst,
9052            Rotation::None,
9053            Flip::None,
9054            Crop::no_crop(),
9055        );
9056        result.unwrap();
9057
9058        let pbo_dst_img = {
9059            let mut __t = pbo_dst.into_u8().unwrap();
9060            __t.set_format(PixelFormat::Rgba).unwrap();
9061            TensorDyn::from(__t)
9062        };
9063        compare_images(&pbo_dst_img, &cpu_dst, 0.95, function!());
9064        log::info!("test_convert_pbo_to_pbo: PASS — PBO-to-PBO convert matches CPU reference");
9065    }
9066
9067    #[test]
9068    fn test_image_bgra() {
9069        let img = TensorDyn::image(
9070            640,
9071            480,
9072            PixelFormat::Bgra,
9073            DType::U8,
9074            Some(edgefirst_tensor::TensorMemory::Mem),
9075            edgefirst_tensor::CpuAccess::ReadWrite,
9076        )
9077        .unwrap();
9078        assert_eq!(img.width(), Some(640));
9079        assert_eq!(img.height(), Some(480));
9080        assert_eq!(img.format().unwrap().channels(), 4);
9081        assert_eq!(img.format().unwrap(), PixelFormat::Bgra);
9082    }
9083
9084    // ========================================================================
9085    // Tests for EDGEFIRST_FORCE_BACKEND env var
9086    // ========================================================================
9087
9088    #[test]
9089    fn test_force_backend_cpu() {
9090        let _lock = acquire_env_lock();
9091        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
9092        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
9093        let converter = ImageProcessor::new().unwrap();
9094        assert!(converter.cpu.is_some());
9095        assert_eq!(converter.forced_backend, Some(ForcedBackend::Cpu));
9096    }
9097
9098    #[test]
9099    fn test_force_backend_invalid() {
9100        let _lock = acquire_env_lock();
9101        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
9102        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "invalid") };
9103        let result = ImageProcessor::new();
9104        assert!(
9105            matches!(&result, Err(Error::ForcedBackendUnavailable(s)) if s.contains("unknown")),
9106            "invalid backend value should return ForcedBackendUnavailable error: {result:?}"
9107        );
9108    }
9109
9110    #[test]
9111    fn test_force_backend_unset() {
9112        let _lock = acquire_env_lock();
9113        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
9114        unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") };
9115        let converter = ImageProcessor::new().unwrap();
9116        assert!(converter.forced_backend.is_none());
9117    }
9118
9119    // ========================================================================
9120    // Tests for hybrid mask path error handling
9121    // ========================================================================
9122
9123    #[test]
9124    fn test_draw_proto_masks_no_cpu_returns_error() {
9125        // Serialize against all other env-var-mutating tests.
9126        let _lock = acquire_env_lock();
9127        let _guard = EnvGuard::snapshot(&[
9128            "EDGEFIRST_FORCE_BACKEND",
9129            "EDGEFIRST_DISABLE_GL",
9130            "EDGEFIRST_DISABLE_G2D",
9131            "EDGEFIRST_DISABLE_CPU",
9132        ]);
9133
9134        // Disable all backends so cpu.is_none() after construction.
9135        unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
9136        unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
9137        unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
9138
9139        let mut converter = ImageProcessor::new().unwrap();
9140        assert!(converter.cpu.is_none(), "CPU should be disabled");
9141
9142        let dst = TensorDyn::image(
9143            640,
9144            480,
9145            PixelFormat::Rgba,
9146            DType::U8,
9147            Some(TensorMemory::Mem),
9148            edgefirst_tensor::CpuAccess::ReadWrite,
9149        )
9150        .unwrap();
9151        let mut dst_dyn = dst;
9152        let det = [DetectBox {
9153            bbox: edgefirst_decoder::BoundingBox {
9154                xmin: 0.1,
9155                ymin: 0.1,
9156                xmax: 0.5,
9157                ymax: 0.5,
9158            },
9159            score: 0.9,
9160            label: 0,
9161        }];
9162        let proto_data = {
9163            use edgefirst_tensor::{Tensor, TensorDyn};
9164            let coeff_t = Tensor::<f32>::from_slice(&[0.5_f32; 4], &[1, 4]).unwrap();
9165            let protos_t =
9166                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
9167            ProtoData {
9168                mask_coefficients: TensorDyn::F32(coeff_t),
9169                protos: TensorDyn::F32(protos_t),
9170                layout: ProtoLayout::Nhwc,
9171            }
9172        };
9173        let result =
9174            converter.draw_proto_masks(&mut dst_dyn, &det, &proto_data, Default::default());
9175        assert!(
9176            matches!(&result, Err(Error::Internal(s)) if s.contains("CPU backend")),
9177            "draw_proto_masks without CPU should return Internal error: {result:?}"
9178        );
9179    }
9180
9181    #[test]
9182    fn test_draw_proto_masks_cpu_fallback_works() {
9183        // Force CPU-only backend to ensure the CPU fallback path executes.
9184        // Serialized under ENV_MUTEX so we don't race with disable-var tests.
9185        let _lock = acquire_env_lock();
9186        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
9187        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
9188        let mut converter = ImageProcessor::new().unwrap();
9189        assert!(converter.cpu.is_some());
9190
9191        let dst = TensorDyn::image(
9192            64,
9193            64,
9194            PixelFormat::Rgba,
9195            DType::U8,
9196            Some(TensorMemory::Mem),
9197            edgefirst_tensor::CpuAccess::ReadWrite,
9198        )
9199        .unwrap();
9200        let mut dst_dyn = dst;
9201        let det = [DetectBox {
9202            bbox: edgefirst_decoder::BoundingBox {
9203                xmin: 0.1,
9204                ymin: 0.1,
9205                xmax: 0.5,
9206                ymax: 0.5,
9207            },
9208            score: 0.9,
9209            label: 0,
9210        }];
9211        let proto_data = {
9212            use edgefirst_tensor::{Tensor, TensorDyn};
9213            let coeff_t = Tensor::<f32>::from_slice(&[0.5_f32; 4], &[1, 4]).unwrap();
9214            let protos_t =
9215                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
9216            ProtoData {
9217                mask_coefficients: TensorDyn::F32(coeff_t),
9218                protos: TensorDyn::F32(protos_t),
9219                layout: ProtoLayout::Nhwc,
9220            }
9221        };
9222        let result =
9223            converter.draw_proto_masks(&mut dst_dyn, &det, &proto_data, Default::default());
9224        assert!(result.is_ok(), "CPU fallback path should work: {result:?}");
9225    }
9226
9227    // ============================================================
9228    // draw_decoded_masks / draw_proto_masks — 4-scenario pixel-
9229    // verified tests. Exercises each backend against the full
9230    // output-contract matrix:
9231    //
9232    //   | detections | background | expected dst             |
9233    //   |------------|------------|--------------------------|
9234    //   | empty      | none       | fully cleared (0x00)     |
9235    //   | empty      | set        | fully equal to bg        |
9236    //   | set        | none       | cleared outside box +    |
9237    //   |            |            | mask-coloured inside     |
9238    //   | set        | set        | bg outside box + mask    |
9239    //   |            |            | blended inside           |
9240    //
9241    // Every test pre-fills dst with a non-zero "dirty" pattern so
9242    // that any silent `return Ok(())` leaks the pattern into the
9243    // asserted output and fails loudly.
9244    // ============================================================
9245
9246    // =========================================================================
9247    // Env-var serialisation helpers
9248    //
9249    // ALL tests that mutate any EDGEFIRST_* backend env var must hold
9250    // ENV_MUTEX for their full duration.  This single mutex serialises
9251    // test_disable_env_var, test_draw_proto_masks_no_cpu_returns_error,
9252    // test_force_backend_*, with_force_backend, and with_env — preventing
9253    // any two of them from racing in a parallel `cargo test` run.
9254    // =========================================================================
9255
9256    /// Acquire the process-wide env-var mutex.  Returns a guard that must be
9257    /// kept alive for the entire duration of the test body.
9258    fn acquire_env_lock() -> std::sync::MutexGuard<'static, ()> {
9259        use std::sync::{Mutex, OnceLock};
9260        static ENV_MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
9261        ENV_MUTEX
9262            .get_or_init(|| Mutex::new(()))
9263            .lock()
9264            .unwrap_or_else(|e| e.into_inner())
9265    }
9266
9267    /// RAII guard that snapshots a set of env vars on construction and
9268    /// restores them on `Drop`, even if the test panics.
9269    struct EnvGuard {
9270        vars: Vec<(&'static str, Option<String>)>,
9271    }
9272
9273    impl EnvGuard {
9274        /// Snapshot the current values of `names`.  Call this while holding
9275        /// the env lock (the lock is not taken here — that is the caller's
9276        /// responsibility so the lock scope can be wider than the guard).
9277        fn snapshot(names: &[&'static str]) -> Self {
9278            Self {
9279                vars: names.iter().map(|&k| (k, std::env::var(k).ok())).collect(),
9280            }
9281        }
9282    }
9283
9284    impl Drop for EnvGuard {
9285        fn drop(&mut self) {
9286            for (k, v) in &self.vars {
9287                match v {
9288                    Some(s) => unsafe { std::env::set_var(k, s) },
9289                    None => unsafe { std::env::remove_var(k) },
9290                }
9291            }
9292        }
9293    }
9294
9295    /// Run `body` with `EDGEFIRST_FORCE_BACKEND` temporarily set (or
9296    /// removed), restoring the prior value afterward. Tests are env-
9297    /// serialized via the process-wide `ENV_MUTEX`.
9298    fn with_force_backend<R>(value: Option<&str>, body: impl FnOnce() -> R) -> R {
9299        let _lock = acquire_env_lock();
9300        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
9301        match value {
9302            Some(v) => unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", v) },
9303            None => unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") },
9304        }
9305        body()
9306    }
9307
9308    /// Allocate an RGBA image tensor and pre-fill every byte with a
9309    /// distinctive non-zero pattern. Any test that relies on the old
9310    /// "dst is already cleared" assumption will see this pattern leak
9311    /// through to the output and fail.
9312    fn make_dirty_dst(w: usize, h: usize, mem: Option<TensorMemory>) -> TensorDyn {
9313        let dst = TensorDyn::image(
9314            w,
9315            h,
9316            PixelFormat::Rgba,
9317            DType::U8,
9318            mem,
9319            edgefirst_tensor::CpuAccess::ReadWrite,
9320        )
9321        .unwrap();
9322        {
9323            use edgefirst_tensor::TensorMapTrait;
9324            let u8t = dst.as_u8().unwrap();
9325            let mut map = u8t.map().unwrap();
9326            for (i, b) in map.as_mut_slice().iter_mut().enumerate() {
9327                *b = 0xA0u8.wrapping_add((i as u8) & 0x3F);
9328            }
9329        }
9330        dst
9331    }
9332
9333    /// Allocate an RGBA background filled with a constant colour.
9334    fn make_bg(w: usize, h: usize, mem: Option<TensorMemory>, rgba: [u8; 4]) -> TensorDyn {
9335        let bg = TensorDyn::image(
9336            w,
9337            h,
9338            PixelFormat::Rgba,
9339            DType::U8,
9340            mem,
9341            edgefirst_tensor::CpuAccess::ReadWrite,
9342        )
9343        .unwrap();
9344        {
9345            use edgefirst_tensor::TensorMapTrait;
9346            let u8t = bg.as_u8().unwrap();
9347            let mut map = u8t.map().unwrap();
9348            for chunk in map.as_mut_slice().chunks_exact_mut(4) {
9349                chunk.copy_from_slice(&rgba);
9350            }
9351        }
9352        bg
9353    }
9354
9355    fn pixel_at(dst: &TensorDyn, x: usize, y: usize) -> [u8; 4] {
9356        use edgefirst_tensor::TensorMapTrait;
9357        let w = dst.width().unwrap();
9358        let off = (y * w + x) * 4;
9359        let u8t = dst.as_u8().unwrap();
9360        let map = u8t.map().unwrap();
9361        let s = map.as_slice();
9362        [s[off], s[off + 1], s[off + 2], s[off + 3]]
9363    }
9364
9365    fn assert_every_pixel_eq(dst: &TensorDyn, expected: [u8; 4], case: &str) {
9366        use edgefirst_tensor::TensorMapTrait;
9367        let u8t = dst.as_u8().unwrap();
9368        let map = u8t.map().unwrap();
9369        for (i, chunk) in map.as_slice().chunks_exact(4).enumerate() {
9370            assert_eq!(
9371                chunk, &expected,
9372                "{case}: pixel idx {i} = {chunk:?}, expected {expected:?}"
9373            );
9374        }
9375    }
9376
9377    /// Scenario 1: empty detections, empty segmentation, no background
9378    /// → dst must be fully cleared to 0x00000000.
9379    fn scenario_empty_no_bg(processor: &mut ImageProcessor, case: &str) {
9380        let mut dst = make_dirty_dst(64, 64, None);
9381        processor
9382            .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::default())
9383            .unwrap_or_else(|e| panic!("{case}/decoded_masks empty+no-bg failed: {e:?}"));
9384        assert_every_pixel_eq(&dst, [0, 0, 0, 0], &format!("{case}/decoded"));
9385
9386        let mut dst = make_dirty_dst(64, 64, None);
9387        let proto = {
9388            use edgefirst_tensor::{Tensor, TensorDyn};
9389            // Placeholder (no detections); shape [1, 4] to keep the tensor well-formed.
9390            let coeff_t = Tensor::<f32>::from_slice(&[0.0_f32; 4], &[1, 4]).unwrap();
9391            let protos_t =
9392                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
9393            ProtoData {
9394                mask_coefficients: TensorDyn::F32(coeff_t),
9395                protos: TensorDyn::F32(protos_t),
9396                layout: ProtoLayout::Nhwc,
9397            }
9398        };
9399        processor
9400            .draw_proto_masks(&mut dst, &[], &proto, MaskOverlay::default())
9401            .unwrap_or_else(|e| panic!("{case}/proto_masks empty+no-bg failed: {e:?}"));
9402        assert_every_pixel_eq(&dst, [0, 0, 0, 0], &format!("{case}/proto"));
9403    }
9404
9405    /// Scenario 2: empty detections, empty segmentation, background set
9406    /// → dst must be fully equal to bg.
9407    fn scenario_empty_with_bg(processor: &mut ImageProcessor, case: &str) {
9408        let bg_color = [42, 99, 200, 255];
9409        let bg = make_bg(64, 64, None, bg_color);
9410        let overlay = MaskOverlay::new().with_background(&bg);
9411
9412        let mut dst = make_dirty_dst(64, 64, None);
9413        processor
9414            .draw_decoded_masks(&mut dst, &[], &[], overlay)
9415            .unwrap_or_else(|e| panic!("{case}/decoded_masks empty+bg failed: {e:?}"));
9416        assert_every_pixel_eq(&dst, bg_color, &format!("{case}/decoded bg blit"));
9417
9418        let mut dst = make_dirty_dst(64, 64, None);
9419        let proto = {
9420            use edgefirst_tensor::{Tensor, TensorDyn};
9421            // Placeholder (no detections); shape [1, 4] to keep the tensor well-formed.
9422            let coeff_t = Tensor::<f32>::from_slice(&[0.0_f32; 4], &[1, 4]).unwrap();
9423            let protos_t =
9424                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
9425            ProtoData {
9426                mask_coefficients: TensorDyn::F32(coeff_t),
9427                protos: TensorDyn::F32(protos_t),
9428                layout: ProtoLayout::Nhwc,
9429            }
9430        };
9431        processor
9432            .draw_proto_masks(&mut dst, &[], &proto, overlay)
9433            .unwrap_or_else(|e| panic!("{case}/proto_masks empty+bg failed: {e:?}"));
9434        assert_every_pixel_eq(&dst, bg_color, &format!("{case}/proto bg blit"));
9435    }
9436
9437    /// Scenario 3: one detection with a fully-opaque segmentation fill,
9438    /// no background → outside the box dst must be 0x00, inside it must
9439    /// be a non-zero mask colour (the render_segmentation output).
9440    fn scenario_detect_no_bg(processor: &mut ImageProcessor, case: &str) {
9441        use edgefirst_decoder::Segmentation;
9442        use ndarray::Array3;
9443        processor
9444            .set_class_colors(&[[200, 80, 40, 255]])
9445            .expect("set_class_colors");
9446
9447        let detect = DetectBox {
9448            bbox: [0.25, 0.25, 0.75, 0.75].into(),
9449            score: 0.99,
9450            label: 0,
9451        };
9452        let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
9453        let seg = Segmentation {
9454            segmentation: seg_arr,
9455            xmin: 0.25,
9456            ymin: 0.25,
9457            xmax: 0.75,
9458            ymax: 0.75,
9459        };
9460
9461        let mut dst = make_dirty_dst(64, 64, None);
9462        processor
9463            .draw_decoded_masks(&mut dst, &[detect], &[seg], MaskOverlay::default())
9464            .unwrap_or_else(|e| panic!("{case}/decoded_masks detect+no-bg failed: {e:?}"));
9465
9466        // Outside the bbox (corner): must be cleared black.
9467        let corner = pixel_at(&dst, 2, 2);
9468        assert_eq!(
9469            corner,
9470            [0, 0, 0, 0],
9471            "{case}/decoded: corner (2,2) leaked dirty pattern: {corner:?}"
9472        );
9473        // Inside the bbox (center): the mask colour must be visible.
9474        // Any non-zero pixel is acceptable — exact rendering varies
9475        // between backends (GL smoothstep, CPU nearest).
9476        let center = pixel_at(&dst, 32, 32);
9477        assert!(
9478            center != [0, 0, 0, 0],
9479            "{case}/decoded: center (32,32) was not coloured: {center:?}"
9480        );
9481    }
9482
9483    /// Scenario 4: detection + background. Outside the box must match
9484    /// bg; inside the box must NOT match bg (mask blended on top).
9485    fn scenario_detect_with_bg(processor: &mut ImageProcessor, case: &str) {
9486        use edgefirst_decoder::Segmentation;
9487        use ndarray::Array3;
9488        processor
9489            .set_class_colors(&[[200, 80, 40, 255]])
9490            .expect("set_class_colors");
9491        let bg_color = [10, 20, 30, 255];
9492        let bg = make_bg(64, 64, None, bg_color);
9493
9494        let detect = DetectBox {
9495            bbox: [0.25, 0.25, 0.75, 0.75].into(),
9496            score: 0.99,
9497            label: 0,
9498        };
9499        let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
9500        let seg = Segmentation {
9501            segmentation: seg_arr,
9502            xmin: 0.25,
9503            ymin: 0.25,
9504            xmax: 0.75,
9505            ymax: 0.75,
9506        };
9507
9508        let overlay = MaskOverlay::new().with_background(&bg);
9509        let mut dst = make_dirty_dst(64, 64, None);
9510        processor
9511            .draw_decoded_masks(&mut dst, &[detect], &[seg], overlay)
9512            .unwrap_or_else(|e| panic!("{case}/decoded_masks detect+bg failed: {e:?}"));
9513
9514        // Outside the bbox (corner): bg colour.
9515        let corner = pixel_at(&dst, 2, 2);
9516        assert_eq!(
9517            corner, bg_color,
9518            "{case}/decoded: corner (2,2) should show bg {bg_color:?} got {corner:?}"
9519        );
9520        // Inside the bbox (center): mask blended on bg, must differ from
9521        // pure bg (alpha-blend with mask colour produces a distinct shade).
9522        let center = pixel_at(&dst, 32, 32);
9523        assert!(
9524            center != bg_color,
9525            "{case}/decoded: center (32,32) should differ from bg {bg_color:?}, got {center:?}"
9526        );
9527    }
9528
9529    /// Run all 4 scenarios against the processor. Skip gracefully if
9530    /// construction fails (backend unavailable on this host).
9531    fn run_all_scenarios(
9532        force_backend: Option<&'static str>,
9533        case: &'static str,
9534        require_dma_for_bg: bool,
9535    ) {
9536        if require_dma_for_bg && !edgefirst_tensor::is_dma_available() {
9537            eprintln!("SKIPPED: {case} — DMA not available on this host");
9538            return;
9539        }
9540        let processor_result = with_force_backend(force_backend, ImageProcessor::new);
9541        let mut processor = match processor_result {
9542            Ok(p) => p,
9543            Err(e) => {
9544                eprintln!("SKIPPED: {case} — backend init failed: {e:?}");
9545                return;
9546            }
9547        };
9548        scenario_empty_no_bg(&mut processor, case);
9549        scenario_empty_with_bg(&mut processor, case);
9550        scenario_detect_no_bg(&mut processor, case);
9551        scenario_detect_with_bg(&mut processor, case);
9552    }
9553
9554    #[test]
9555    fn test_draw_masks_4_scenarios_cpu() {
9556        run_all_scenarios(Some("cpu"), "cpu", false);
9557    }
9558
9559    #[test]
9560    fn test_draw_masks_4_scenarios_auto() {
9561        run_all_scenarios(None, "auto", false);
9562    }
9563
9564    #[cfg(target_os = "linux")]
9565    #[cfg(feature = "opengl")]
9566    #[test]
9567    fn test_draw_masks_4_scenarios_opengl() {
9568        run_all_scenarios(Some("opengl"), "opengl", false);
9569    }
9570
9571    /// G2D forced backend: exercises the zero-detection empty-frame
9572    /// paths via `g2d_clear` and `g2d_blit`. Scenarios 3 and 4 (with
9573    /// detections) expect `NotImplemented` since G2D has no rasterizer
9574    /// for boxes / masks.
9575    #[cfg(target_os = "linux")]
9576    #[test]
9577    fn test_draw_masks_zero_detection_g2d_forced() {
9578        if !edgefirst_tensor::is_dma_available() {
9579            eprintln!("SKIPPED: g2d forced — DMA not available on this host");
9580            return;
9581        }
9582        let processor_result = with_force_backend(Some("g2d"), ImageProcessor::new);
9583        let mut processor = match processor_result {
9584            Ok(p) => p,
9585            Err(e) => {
9586                eprintln!("SKIPPED: g2d forced — init failed: {e:?}");
9587                return;
9588            }
9589        };
9590
9591        // Case 1: empty + no bg. G2D requires DMA-backed dst.
9592        let mut dst = TensorDyn::image(
9593            64,
9594            64,
9595            PixelFormat::Rgba,
9596            DType::U8,
9597            Some(TensorMemory::Dma),
9598            edgefirst_tensor::CpuAccess::ReadWrite,
9599        )
9600        .unwrap();
9601        {
9602            use edgefirst_tensor::TensorMapTrait;
9603            let u8t = dst.as_u8_mut().unwrap();
9604            let mut map = u8t.map().unwrap();
9605            map.as_mut_slice().fill(0xBB);
9606        }
9607        processor
9608            .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::default())
9609            .expect("g2d empty+no-bg");
9610        assert_every_pixel_eq(&dst, [0, 0, 0, 0], "g2d/case1 cleared");
9611
9612        // Case 2: empty + bg. Both surfaces DMA-backed for g2d_blit.
9613        let bg_color = [7, 11, 13, 255];
9614        let bg = {
9615            let t = TensorDyn::image(
9616                64,
9617                64,
9618                PixelFormat::Rgba,
9619                DType::U8,
9620                Some(TensorMemory::Dma),
9621                edgefirst_tensor::CpuAccess::ReadWrite,
9622            )
9623            .unwrap();
9624            {
9625                use edgefirst_tensor::TensorMapTrait;
9626                let u8t = t.as_u8().unwrap();
9627                let mut map = u8t.map().unwrap();
9628                for chunk in map.as_mut_slice().chunks_exact_mut(4) {
9629                    chunk.copy_from_slice(&bg_color);
9630                }
9631            }
9632            t
9633        };
9634        let mut dst = TensorDyn::image(
9635            64,
9636            64,
9637            PixelFormat::Rgba,
9638            DType::U8,
9639            Some(TensorMemory::Dma),
9640            edgefirst_tensor::CpuAccess::ReadWrite,
9641        )
9642        .unwrap();
9643        {
9644            use edgefirst_tensor::TensorMapTrait;
9645            let u8t = dst.as_u8_mut().unwrap();
9646            let mut map = u8t.map().unwrap();
9647            map.as_mut_slice().fill(0x55);
9648        }
9649        processor
9650            .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::new().with_background(&bg))
9651            .expect("g2d empty+bg");
9652        assert_every_pixel_eq(&dst, bg_color, "g2d/case2 bg blit");
9653
9654        // Case 3 and 4: detect present — must return NotImplemented.
9655        let detect = DetectBox {
9656            bbox: [0.25, 0.25, 0.75, 0.75].into(),
9657            score: 0.9,
9658            label: 0,
9659        };
9660        let mut dst = TensorDyn::image(
9661            64,
9662            64,
9663            PixelFormat::Rgba,
9664            DType::U8,
9665            Some(TensorMemory::Dma),
9666            edgefirst_tensor::CpuAccess::ReadWrite,
9667        )
9668        .unwrap();
9669        let err = processor
9670            .draw_decoded_masks(&mut dst, &[detect], &[], MaskOverlay::default())
9671            .expect_err("g2d must reject detect-present draw_decoded_masks");
9672        assert!(
9673            matches!(err, Error::NotImplemented(_)),
9674            "g2d case3 wrong error: {err:?}"
9675        );
9676    }
9677
9678    #[test]
9679    fn test_set_format_then_cpu_convert() {
9680        // Force CPU backend; serialized under ENV_MUTEX to avoid racing with
9681        // test_force_backend_* and test_disable_env_var.
9682        let _lock = acquire_env_lock();
9683        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
9684        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
9685        let mut processor = ImageProcessor::new().unwrap();
9686
9687        // Load a source image
9688        let image = edgefirst_bench::testdata::read("zidane.jpg");
9689        let src = load_image_test_helper(&image, Some(PixelFormat::Rgba), None).unwrap();
9690
9691        // Create a raw tensor, then attach format — simulating the from_fd workflow
9692        let mut dst =
9693            TensorDyn::new(&[640, 640, 3], DType::U8, Some(TensorMemory::Mem), None).unwrap();
9694        dst.set_format(PixelFormat::Rgb).unwrap();
9695
9696        // Convert should work with the set_format-annotated tensor
9697        processor
9698            .convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
9699            .unwrap();
9700
9701        // Verify format survived conversion
9702        assert_eq!(dst.format(), Some(PixelFormat::Rgb));
9703        assert_eq!(dst.width(), Some(640));
9704        assert_eq!(dst.height(), Some(640));
9705    }
9706
9707    /// Verify that creating multiple ImageProcessors on the same thread and
9708    /// performing a resize on each does not deadlock or error.
9709    ///
9710    /// Uses automatic memory allocation (DMA → PBO → Mem fallback) so that
9711    /// hardware backends (OpenGL, G2D) are exercised on capable targets.
9712    #[test]
9713    fn test_multiple_image_processors_same_thread() {
9714        // Hold the env mutex so env-var-mutating tests can't corrupt the state
9715        // seen by ImageProcessor::new() calls during this test.
9716        let _lock = acquire_env_lock();
9717        let mut processors: Vec<ImageProcessor> = (0..4)
9718            .map(|_| ImageProcessor::new().expect("ImageProcessor::new() failed"))
9719            .collect();
9720
9721        for proc in &mut processors {
9722            let src = proc
9723                .create_image(
9724                    128,
9725                    128,
9726                    PixelFormat::Rgb,
9727                    DType::U8,
9728                    None,
9729                    edgefirst_tensor::CpuAccess::ReadWrite,
9730                )
9731                .expect("create src failed");
9732            let mut dst = proc
9733                .create_image(
9734                    64,
9735                    64,
9736                    PixelFormat::Rgb,
9737                    DType::U8,
9738                    None,
9739                    edgefirst_tensor::CpuAccess::ReadWrite,
9740                )
9741                .expect("create dst failed");
9742            proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
9743                .expect("convert failed");
9744            assert_eq!(dst.width(), Some(64));
9745            assert_eq!(dst.height(), Some(64));
9746        }
9747    }
9748
9749    /// Verify that creating ImageProcessors on separate threads and performing
9750    /// a resize on each does not deadlock or error.
9751    ///
9752    /// Uses automatic memory allocation (DMA → PBO → Mem fallback) so that
9753    /// hardware backends (OpenGL, G2D) are exercised on capable targets.
9754    /// A 60-second timeout prevents CI from hanging on deadlock regressions.
9755    #[test]
9756    fn test_multiple_image_processors_separate_threads() {
9757        use std::sync::mpsc;
9758        use std::time::Duration;
9759
9760        // The Vivante GC7000UL driver (i.MX 8M Plus) double-frees on concurrent
9761        // EGL context teardown — four processors spun up on four threads here
9762        // trips it and aborts the whole test binary (SIGABRT, not a catchable
9763        // panic). The bug is the driver's, not the HAL's; this test is kept so
9764        // it still exercises the multi-context path on every other GPU. The
9765        // on-target GitHub Actions imx8mp runner sets EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS
9766        // to skip just this case there, while the run stays red anywhere else
9767        // a regression appears. (Skip, not #[ignore]: the platform is decided
9768        // at runtime, not compile time.)
9769        if std::env::var_os("EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS").is_some() {
9770            eprintln!(
9771                "SKIPPED: test_multiple_image_processors_separate_threads — known Vivante \
9772                 GC7000UL concurrent-EGL-teardown double-free \
9773                 (EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS set)"
9774            );
9775            return;
9776        }
9777
9778        const TIMEOUT: Duration = Duration::from_secs(60);
9779
9780        // Hold the env mutex so env-var-mutating tests can't corrupt ImageProcessor::new()
9781        // calls made inside the spawned threads during this test.
9782        let _lock = acquire_env_lock();
9783
9784        let (tx, rx) = mpsc::channel::<()>();
9785
9786        std::thread::spawn(move || {
9787            let handles: Vec<_> = (0..4)
9788                .map(|i| {
9789                    std::thread::spawn(move || {
9790                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
9791                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
9792                        });
9793                        let src = proc
9794                            .create_image(
9795                                128,
9796                                128,
9797                                PixelFormat::Rgb,
9798                                DType::U8,
9799                                None,
9800                                edgefirst_tensor::CpuAccess::ReadWrite,
9801                            )
9802                            .unwrap_or_else(|e| panic!("create src failed on thread {i}: {e}"));
9803                        let mut dst = proc
9804                            .create_image(
9805                                64,
9806                                64,
9807                                PixelFormat::Rgb,
9808                                DType::U8,
9809                                None,
9810                                edgefirst_tensor::CpuAccess::ReadWrite,
9811                            )
9812                            .unwrap_or_else(|e| panic!("create dst failed on thread {i}: {e}"));
9813                        proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
9814                            .unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
9815                        assert_eq!(dst.width(), Some(64));
9816                        assert_eq!(dst.height(), Some(64));
9817                    })
9818                })
9819                .collect();
9820
9821            for (i, h) in handles.into_iter().enumerate() {
9822                h.join()
9823                    .unwrap_or_else(|e| panic!("thread {i} panicked: {e:?}"));
9824            }
9825
9826            let _ = tx.send(());
9827        });
9828
9829        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
9830            panic!("test_multiple_image_processors_separate_threads timed out after {TIMEOUT:?}")
9831        });
9832    }
9833
9834    /// Verify that 4 fully-initialized ImageProcessors on separate threads can
9835    /// all operate concurrently without deadlocking each other.
9836    ///
9837    /// All processors are created first, then a barrier synchronizes them so
9838    /// they all start converting at the same instant — maximizing contention.
9839    /// A 60-second timeout prevents CI from hanging on deadlock regressions.
9840    #[test]
9841    fn test_image_processors_concurrent_operations() {
9842        use std::sync::{mpsc, Arc, Barrier};
9843        use std::time::Duration;
9844
9845        const N: usize = 4;
9846        const ROUNDS: usize = 10;
9847        const TIMEOUT: Duration = Duration::from_secs(60);
9848
9849        // Hold the env mutex so env-var-mutating tests can't corrupt ImageProcessor::new()
9850        // calls made inside the spawned threads during this test.
9851        let _lock = acquire_env_lock();
9852
9853        let (tx, rx) = mpsc::channel::<()>();
9854
9855        std::thread::spawn(move || {
9856            let barrier = Arc::new(Barrier::new(N));
9857
9858            let handles: Vec<_> = (0..N)
9859                .map(|i| {
9860                    let barrier = Arc::clone(&barrier);
9861                    std::thread::spawn(move || {
9862                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
9863                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
9864                        });
9865
9866                        // All threads wait here until every processor is initialized.
9867                        barrier.wait();
9868
9869                        // Now all 4 hammer the GPU concurrently.
9870                        for round in 0..ROUNDS {
9871                            let src = proc
9872                                .create_image(
9873                                    128,
9874                                    128,
9875                                    PixelFormat::Rgb,
9876                                    DType::U8,
9877                                    None,
9878                                    edgefirst_tensor::CpuAccess::ReadWrite,
9879                                )
9880                                .unwrap_or_else(|e| {
9881                                    panic!("create src failed on thread {i} round {round}: {e}")
9882                                });
9883                            let mut dst = proc
9884                                .create_image(
9885                                    64,
9886                                    64,
9887                                    PixelFormat::Rgb,
9888                                    DType::U8,
9889                                    None,
9890                                    edgefirst_tensor::CpuAccess::ReadWrite,
9891                                )
9892                                .unwrap_or_else(|e| {
9893                                    panic!("create dst failed on thread {i} round {round}: {e}")
9894                                });
9895                            proc.convert(
9896                                &src,
9897                                &mut dst,
9898                                Rotation::None,
9899                                Flip::None,
9900                                Crop::default(),
9901                            )
9902                            .unwrap_or_else(|e| {
9903                                panic!("convert failed on thread {i} round {round}: {e}")
9904                            });
9905                            assert_eq!(dst.width(), Some(64));
9906                            assert_eq!(dst.height(), Some(64));
9907                        }
9908                    })
9909                })
9910                .collect();
9911
9912            for (i, h) in handles.into_iter().enumerate() {
9913                h.join()
9914                    .unwrap_or_else(|e| panic!("thread {i} panicked: {e:?}"));
9915            }
9916
9917            let _ = tx.send(());
9918        });
9919
9920        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
9921            panic!("test_image_processors_concurrent_operations timed out after {TIMEOUT:?}")
9922        });
9923    }
9924
9925    /// THE parallel-processors demonstration test: 4 ImageProcessors on 4
9926    /// threads, each with its own GL context and worker, converting
9927    /// per-thread-DISTINCT synthetic inputs concurrently (barrier-released);
9928    /// every output must byte-match the thread's own pre-barrier sequential
9929    /// oracle from the same processor. On LifecycleOnly platforms (Mali,
9930    /// V3D, Tegra, llvmpipe, macOS) the converts genuinely overlap on the
9931    /// GPU; on Vivante they serialize via the Full policy and the test still
9932    /// must pass. Distinct inputs make any cross-processor state leakage
9933    /// (wrong texture, wrong context, clobbered upload) visible as a byte
9934    /// diff rather than a coincidental match — proven by a scratch
9935    /// cross-wire run (neighbor's input post-oracle) failing on every
9936    /// thread with ~53% of bytes diverged.
9937    ///
9938    /// Skipped under EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS like
9939    /// `test_multiple_image_processors_separate_threads`: the galcore driver
9940    /// can abort intermittently on concurrent multi-processor lifecycles
9941    /// regardless of locking (P0 spike: reproduces fully serialized).
9942    #[test]
9943    fn test_parallel_processors_unique_outputs() {
9944        use std::sync::{mpsc, Arc, Barrier};
9945        use std::time::Duration;
9946
9947        const N: usize = 4;
9948        const ROUNDS: usize = 25;
9949        const TIMEOUT: Duration = Duration::from_secs(60);
9950
9951        if std::env::var_os("EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS").is_some() {
9952            eprintln!(
9953                "SKIPPED: test_parallel_processors_unique_outputs — known Vivante \
9954                 GC7000UL concurrent-multi-processor driver abort \
9955                 (EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS set)"
9956            );
9957            return;
9958        }
9959
9960        let _lock = acquire_env_lock();
9961        let (tx, rx) = mpsc::channel::<()>();
9962
9963        std::thread::spawn(move || {
9964            let barrier = Arc::new(Barrier::new(N));
9965            let handles: Vec<_> = (0..N)
9966                .map(|i| {
9967                    let barrier = Arc::clone(&barrier);
9968                    std::thread::spawn(move || {
9969                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
9970                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
9971                        });
9972                        // CI-weight geometry (llvmpipe renders on the CPU).
9973                        let (w, h) = (640usize, 480usize);
9974                        let mem = if edgefirst_tensor::is_dma_available() {
9975                            Some(TensorMemory::Dma)
9976                        } else {
9977                            Some(TensorMemory::Mem)
9978                        };
9979                        let src = proc
9980                            .create_image(
9981                                w,
9982                                h,
9983                                PixelFormat::Nv12,
9984                                DType::U8,
9985                                mem,
9986                                edgefirst_tensor::CpuAccess::ReadWrite,
9987                            )
9988                            .unwrap();
9989                        {
9990                            let t = src.as_u8().unwrap();
9991                            let mut m = t.map().unwrap();
9992                            let s = m.as_mut_slice();
9993                            for (j, b) in s[..w * h].iter_mut().enumerate() {
9994                                *b = ((i * 53 + j) % 200 + 16) as u8;
9995                            }
9996                            for b in &mut s[w * h..] {
9997                                *b = (80 + i * 24) as u8;
9998                            }
9999                        }
10000                        let lb = Crop::letterbox([114, 114, 114, 255]);
10001                        let convert_once = |proc: &mut ImageProcessor| -> Vec<u8> {
10002                            let mut dst = proc
10003                                .create_image(
10004                                    320,
10005                                    320,
10006                                    PixelFormat::Rgba,
10007                                    DType::U8,
10008                                    mem,
10009                                    edgefirst_tensor::CpuAccess::ReadWrite,
10010                                )
10011                                .unwrap();
10012                            proc.convert(&src, &mut dst, Rotation::None, Flip::None, lb)
10013                                .unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
10014                            let t = dst.as_u8().unwrap();
10015                            let m = t.map().unwrap();
10016                            m.as_slice().to_vec()
10017                        };
10018
10019                        let oracle = convert_once(&mut proc);
10020                        barrier.wait();
10021                        for round in 0..ROUNDS {
10022                            let out = convert_once(&mut proc);
10023                            let diffs = oracle.iter().zip(&out).filter(|(a, b)| a != b).count();
10024                            assert!(
10025                                diffs == 0,
10026                                "thread {i} round {round}: {diffs}/{} bytes diverged \
10027                                 from this processor's own oracle — cross-processor \
10028                                 GL state leakage under parallel execution",
10029                                oracle.len()
10030                            );
10031                        }
10032                    })
10033                })
10034                .collect();
10035
10036            for (i, h) in handles.into_iter().enumerate() {
10037                h.join()
10038                    .unwrap_or_else(|e| panic!("parallel thread {i} panicked: {e:?}"));
10039            }
10040            let _ = tx.send(());
10041        });
10042
10043        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
10044            panic!("test_parallel_processors_unique_outputs timed out after {TIMEOUT:?}")
10045        });
10046    }
10047
10048    /// Heavy on-demand stressor for the GL serialization policy: 4
10049    /// processors × 4 threads × barrier × 200 NV12 720p → RGB 640 letterbox
10050    /// converts (DMA where available); every output must byte-match the
10051    /// thread's own pre-barrier sequential oracle from the same processor.
10052    /// Ignored by default (heavy; board tool — the CI-weight version is
10053    /// `test_parallel_processors_unique_outputs`). Run explicitly, optionally
10054    /// pinning the policy via EDGEFIRST_GL_SERIALIZE=full|lifecycle:
10055    ///   <test binary> stress_parallel_processors_oracle --ignored
10056    #[test]
10057    #[ignore = "heavy on-demand GL-parallelism stressor; run explicitly on boards"]
10058    fn stress_parallel_processors_oracle() {
10059        use std::sync::{mpsc, Arc, Barrier};
10060        use std::time::Duration;
10061
10062        const N: usize = 4;
10063        const ROUNDS: usize = 200;
10064        const TIMEOUT: Duration = Duration::from_secs(600);
10065
10066        let _lock = acquire_env_lock();
10067        let (tx, rx) = mpsc::channel::<()>();
10068
10069        std::thread::spawn(move || {
10070            let barrier = Arc::new(Barrier::new(N));
10071            let handles: Vec<_> = (0..N)
10072                .map(|i| {
10073                    let barrier = Arc::clone(&barrier);
10074                    std::thread::spawn(move || {
10075                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
10076                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
10077                        });
10078                        let (w, h) = (1280usize, 720usize);
10079                        let mem = if edgefirst_tensor::is_dma_available() {
10080                            Some(TensorMemory::Dma)
10081                        } else {
10082                            Some(TensorMemory::Mem)
10083                        };
10084
10085                        // Per-thread-distinct synthetic NV12 so cross-wired
10086                        // GL state between processors shows up as a byte diff.
10087                        let src = proc
10088                            .create_image(
10089                                w,
10090                                h,
10091                                PixelFormat::Nv12,
10092                                DType::U8,
10093                                mem,
10094                                edgefirst_tensor::CpuAccess::ReadWrite,
10095                            )
10096                            .unwrap();
10097                        {
10098                            let t = src.as_u8().unwrap();
10099                            let mut m = t.map().unwrap();
10100                            let s = m.as_mut_slice();
10101                            for (j, b) in s[..w * h].iter_mut().enumerate() {
10102                                *b = ((i * 37 + j) % 200 + 16) as u8;
10103                            }
10104                            for b in &mut s[w * h..] {
10105                                *b = (96 + i * 16) as u8;
10106                            }
10107                        }
10108                        let lb = Crop::letterbox([114, 114, 114, 255]);
10109
10110                        let convert_once = |proc: &mut ImageProcessor| -> Vec<u8> {
10111                            let mut dst = proc
10112                                .create_image(
10113                                    640,
10114                                    640,
10115                                    PixelFormat::Rgb,
10116                                    DType::U8,
10117                                    mem,
10118                                    edgefirst_tensor::CpuAccess::ReadWrite,
10119                                )
10120                                .unwrap();
10121                            proc.convert(&src, &mut dst, Rotation::None, Flip::None, lb)
10122                                .unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
10123                            let t = dst.as_u8().unwrap();
10124                            let m = t.map().unwrap();
10125                            m.as_slice().to_vec()
10126                        };
10127
10128                        let oracle = convert_once(&mut proc);
10129                        barrier.wait();
10130                        for round in 0..ROUNDS {
10131                            let out = convert_once(&mut proc);
10132                            let diffs = oracle.iter().zip(&out).filter(|(a, b)| a != b).count();
10133                            assert!(
10134                                diffs == 0,
10135                                "thread {i} round {round}: {diffs}/{} bytes diverged \
10136                                 from the pre-barrier oracle",
10137                                oracle.len()
10138                            );
10139                        }
10140                    })
10141                })
10142                .collect();
10143
10144            for (i, h) in handles.into_iter().enumerate() {
10145                h.join()
10146                    .unwrap_or_else(|e| panic!("stressor thread {i} panicked: {e:?}"));
10147            }
10148            let _ = tx.send(());
10149        });
10150
10151        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
10152            panic!("stress_parallel_processors_oracle timed out after {TIMEOUT:?}")
10153        });
10154    }
10155
10156    // =========================================================================
10157    // F16 / F32 auto-chain fallback integration tests
10158    // =========================================================================
10159
10160    /// Proves the auto-chain (OpenGL → G2D → CPU) NEVER errors for a float
10161    /// combo the GL path does NOT cover.
10162    ///
10163    /// `Yuyv → Rgb F32` is not handled by the GL float render path (which
10164    /// only covers `Rgba → PlanarRgb F16` and `Rgba → Rgb F32`), so the
10165    /// chain falls through to the CPU float path. Before commit 868a7649
10166    /// added CPU U8→F32/F16 support this would have returned `Err`; now it
10167    /// must return `Ok` with output in `[0, 1]` and all values finite.
10168    #[test]
10169    fn convert_f32_auto_never_errors_non_gl_combo() {
10170        const W: usize = 64;
10171        const H: usize = 64;
10172
10173        // Build a small synthetic YUYV source (Y=128, U=128, V=128 → near-grey).
10174        // YUYV packs two pixels into 4 bytes: [Y0, U, Y1, V] per macropixel.
10175        let src = TensorDyn::image(
10176            W,
10177            H,
10178            PixelFormat::Yuyv,
10179            DType::U8,
10180            Some(TensorMemory::Mem),
10181            edgefirst_tensor::CpuAccess::ReadWrite,
10182        )
10183        .unwrap();
10184        {
10185            let mut map = src.as_u8().unwrap().map().unwrap();
10186            let data = map.as_mut_slice();
10187            for chunk in data.chunks_exact_mut(4) {
10188                chunk[0] = 128; // Y0
10189                chunk[1] = 128; // U
10190                chunk[2] = 160; // Y1 — distinct so a layout bug is visible
10191                chunk[3] = 128; // V
10192            }
10193        }
10194
10195        let mut dst = TensorDyn::image(
10196            W,
10197            H,
10198            PixelFormat::Rgb,
10199            DType::F32,
10200            Some(TensorMemory::Mem),
10201            edgefirst_tensor::CpuAccess::ReadWrite,
10202        )
10203        .unwrap();
10204
10205        let mut proc = ImageProcessor::new().unwrap();
10206        let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
10207        assert!(
10208            result.is_ok(),
10209            "auto-chain Yuyv→Rgb F32 must not error: {:?}",
10210            result.err()
10211        );
10212
10213        // Verify all output values are finite and in [0, 1].
10214        let map = dst.as_f32().unwrap().map().unwrap();
10215        let floats = map.as_slice();
10216        assert_eq!(floats.len(), W * H * 3, "unexpected output element count");
10217        for (i, &v) in floats.iter().enumerate() {
10218            assert!(
10219                v.is_finite() && (0.0..=1.0).contains(&v),
10220                "output[{i}]={v} is not finite or not in [0,1]"
10221            );
10222        }
10223
10224        // WEAK-1: Anti-all-zero spot-check.  A Y=128 YUYV source normalises to
10225        // ≈0.502 on the luma channel.  If the buffer is all-zero (e.g. the CPU
10226        // path never wrote to it) this assertion catches the regression.
10227        let first_non_zero = floats.iter().find(|&&v| v > 0.01);
10228        assert!(
10229            first_non_zero.is_some(),
10230            "all-zero output detected — CPU path likely did not write to the destination buffer"
10231        );
10232        // Y=128 → luma ≈ 0.502.  Spot-check the first pixel's R channel
10233        // (which carries luma for a near-grey YUV source).
10234        let r0 = floats[0];
10235        assert!(
10236            (r0 - 0.502_f32).abs() < 0.05,
10237            "first pixel R={r0} expected ≈0.502 (Y=128 neutral grey from YUYV source)"
10238        );
10239    }
10240
10241    /// Proves CPU-forced `Rgba → PlanarRgb F16` correctness.
10242    ///
10243    /// Uses a source with clearly distinct per-channel values so a
10244    /// plane-swap or layout bug surfaces immediately. Tolerance is 2^-9
10245    /// (one F16 ULP at 0.5, i.e. roughly 1/512).
10246    #[test]
10247    // `ImageProcessorConfig` carries a Linux-only `egl_display` field, so
10248    // `{ backend, ..Default::default() }` is a genuine update on Linux but
10249    // covers no remaining fields on macOS, where `clippy::needless_update`
10250    // then fires. `allow` (not `expect`) because the lint is platform-
10251    // conditional — it does not fire on Linux.
10252    #[allow(clippy::needless_update)]
10253    fn convert_f16_forced_cpu_correct() {
10254        const W: usize = 16;
10255        const H: usize = 16;
10256        const TOL: f32 = 1.0 / 512.0; // 2^-9
10257
10258        // pixel (y,x): R = 50+x, G = 100+y*8, B = 200
10259        let src = TensorDyn::image(
10260            W,
10261            H,
10262            PixelFormat::Rgba,
10263            DType::U8,
10264            Some(TensorMemory::Mem),
10265            edgefirst_tensor::CpuAccess::ReadWrite,
10266        )
10267        .unwrap();
10268        {
10269            let mut map = src.as_u8().unwrap().map().unwrap();
10270            let data = map.as_mut_slice();
10271            for y in 0..H {
10272                for x in 0..W {
10273                    let i = y * W + x;
10274                    data[i * 4] = (50 + x) as u8; // R: 50..65
10275                    data[i * 4 + 1] = (100 + y * 8) as u8; // G: 100..220
10276                    data[i * 4 + 2] = 200; // B: constant
10277                    data[i * 4 + 3] = 255;
10278                }
10279            }
10280        }
10281
10282        let mut dst = TensorDyn::image(
10283            W,
10284            H,
10285            PixelFormat::PlanarRgb,
10286            DType::F16,
10287            Some(TensorMemory::Mem),
10288            edgefirst_tensor::CpuAccess::ReadWrite,
10289        )
10290        .unwrap();
10291
10292        let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
10293            backend: ComputeBackend::Cpu,
10294            ..Default::default()
10295        })
10296        .unwrap();
10297        proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
10298            .expect("forced-CPU Rgba→PlanarRgb F16 must not error");
10299
10300        let src_map = src.as_u8().unwrap().map().unwrap();
10301        let src_bytes = src_map.as_slice();
10302        let dst_map = dst.as_f16().unwrap().map().unwrap();
10303        let dst_halfs = dst_map.as_slice();
10304
10305        let plane = W * H;
10306        assert_eq!(dst_halfs.len(), plane * 3, "wrong output element count");
10307
10308        for y in 0..H {
10309            for x in 0..W {
10310                let i = y * W + x;
10311                let r_exp = src_bytes[i * 4] as f32 / 255.0;
10312                let g_exp = src_bytes[i * 4 + 1] as f32 / 255.0;
10313                let b_exp = src_bytes[i * 4 + 2] as f32 / 255.0;
10314
10315                let r_got = dst_halfs[i].to_f32();
10316                let g_got = dst_halfs[plane + i].to_f32();
10317                let b_got = dst_halfs[2 * plane + i].to_f32();
10318
10319                assert!(
10320                    (r_got - r_exp).abs() <= TOL,
10321                    "R plane ({x},{y}): got {r_got}, expected {r_exp}"
10322                );
10323                assert!(
10324                    (g_got - g_exp).abs() <= TOL,
10325                    "G plane ({x},{y}): got {g_got}, expected {g_exp}"
10326                );
10327                assert!(
10328                    (b_got - b_exp).abs() <= TOL,
10329                    "B plane ({x},{y}): got {b_got}, expected {b_exp}"
10330                );
10331
10332                // Catch plane-swap: R and G must differ (they have different formulas).
10333                if src_bytes[i * 4] != src_bytes[i * 4 + 1] {
10334                    assert_ne!(r_got, g_got, "R and G planes must differ at ({x},{y})");
10335                }
10336            }
10337        }
10338    }
10339
10340    /// Proves the auto-chain falls through to CPU for `Rgba → Rgb F32` with
10341    /// rotation set.
10342    ///
10343    /// The GL float render path rejects any call where rotation ≠ None,
10344    /// returning an error that causes the chain to continue. Before the CPU
10345    /// float fallback this would have produced an error at the end of the
10346    /// chain; now it must reach CPU and return `Ok` with finite `[0, 1]` output.
10347    #[test]
10348    fn convert_f32_with_rotation_falls_back() {
10349        const W: usize = 16;
10350        const H: usize = 16;
10351
10352        // RGBA8 source with a known gradient (distinct per-channel values).
10353        let src = TensorDyn::image(
10354            W,
10355            H,
10356            PixelFormat::Rgba,
10357            DType::U8,
10358            Some(TensorMemory::Mem),
10359            edgefirst_tensor::CpuAccess::ReadWrite,
10360        )
10361        .unwrap();
10362        {
10363            let mut map = src.as_u8().unwrap().map().unwrap();
10364            let data = map.as_mut_slice();
10365            for y in 0..H {
10366                for x in 0..W {
10367                    let i = y * W + x;
10368                    data[i * 4] = (x * 16) as u8; // R
10369                    data[i * 4 + 1] = (y * 16) as u8; // G
10370                    data[i * 4 + 2] = 128; // B
10371                    data[i * 4 + 3] = 255;
10372                }
10373            }
10374        }
10375
10376        // Rotation swaps W and H, so dst is [W, H] (H×W output).
10377        let mut dst = TensorDyn::image(
10378            H, // dst W = src H after 90° rotation
10379            W, // dst H = src W after 90° rotation
10380            PixelFormat::Rgb,
10381            DType::F32,
10382            Some(TensorMemory::Mem),
10383            edgefirst_tensor::CpuAccess::ReadWrite,
10384        )
10385        .unwrap();
10386
10387        let mut proc = ImageProcessor::new().unwrap();
10388        let result = proc.convert(
10389            &src,
10390            &mut dst,
10391            Rotation::Clockwise90,
10392            Flip::None,
10393            Crop::default(),
10394        );
10395        assert!(
10396            result.is_ok(),
10397            "auto-chain Rgba→Rgb F32 with Rot90 must not error: {:?}",
10398            result.err()
10399        );
10400
10401        let map = dst.as_f32().unwrap().map().unwrap();
10402        let floats = map.as_slice();
10403        assert_eq!(floats.len(), H * W * 3, "unexpected output element count");
10404        for (i, &v) in floats.iter().enumerate() {
10405            assert!(
10406                v.is_finite() && (0.0..=1.0).contains(&v),
10407                "output[{i}]={v} is not finite or not in [0,1]"
10408            );
10409        }
10410    }
10411
10412    /// GL-vs-CPU identity parity for `Rgba → PlanarRgb F16`.
10413    ///
10414    /// Converts the same RGBA8 source via forced `OpenGl` and forced `Cpu`,
10415    /// then verifies the two F16 output tensors agree element-wise within
10416    /// 2^-8 (two F16 ULPs at 0.5). Skipped when OpenGL or F16 render is
10417    /// unavailable.
10418    #[test]
10419    #[cfg(all(target_os = "linux", feature = "opengl"))]
10420    fn convert_f16_gl_cpu_parity_identity() {
10421        if !is_opengl_available() {
10422            eprintln!("SKIPPED: convert_f16_gl_cpu_parity_identity - OpenGL not available");
10423            return;
10424        }
10425
10426        const W: usize = 16;
10427        const H: usize = 16;
10428        const TOL: f32 = 1.0 / 256.0; // 2^-8
10429
10430        // pixel (y,x): R = 40+x, G = 80+y*10, B = 180
10431        let src = TensorDyn::image(
10432            W,
10433            H,
10434            PixelFormat::Rgba,
10435            DType::U8,
10436            Some(TensorMemory::Mem),
10437            edgefirst_tensor::CpuAccess::ReadWrite,
10438        )
10439        .unwrap();
10440        {
10441            let mut map = src.as_u8().unwrap().map().unwrap();
10442            let data = map.as_mut_slice();
10443            for y in 0..H {
10444                for x in 0..W {
10445                    let i = y * W + x;
10446                    data[i * 4] = (40 + x) as u8; // R
10447                    data[i * 4 + 1] = (80 + y * 10) as u8; // G
10448                    data[i * 4 + 2] = 180; // B
10449                    data[i * 4 + 3] = 255;
10450                }
10451            }
10452        }
10453
10454        // GL path.
10455        let gl_result = {
10456            let mut gl_proc = match ImageProcessor::with_config(ImageProcessorConfig {
10457                backend: ComputeBackend::OpenGl,
10458                ..Default::default()
10459            }) {
10460                Ok(p) => p,
10461                Err(e) => {
10462                    eprintln!(
10463                        "SKIPPED: convert_f16_gl_cpu_parity_identity - GL backend unavailable: {e}"
10464                    );
10465                    return;
10466                }
10467            };
10468
10469            if !gl_proc.supported_render_dtypes().f16 {
10470                eprintln!("SKIPPED: convert_f16_gl_cpu_parity_identity - F16 render not supported");
10471                return;
10472            }
10473
10474            let mut dst = TensorDyn::image(
10475                W,
10476                H,
10477                PixelFormat::PlanarRgb,
10478                DType::F16,
10479                Some(TensorMemory::Mem),
10480                edgefirst_tensor::CpuAccess::ReadWrite,
10481            )
10482            .unwrap();
10483            match gl_proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default()) {
10484                Ok(()) => dst,
10485                Err(e) => {
10486                    eprintln!(
10487                        "SKIPPED: convert_f16_gl_cpu_parity_identity - GL convert failed: {e}"
10488                    );
10489                    return;
10490                }
10491            }
10492        };
10493
10494        // CPU path.
10495        let cpu_result = {
10496            let mut cpu_proc = ImageProcessor::with_config(ImageProcessorConfig {
10497                backend: ComputeBackend::Cpu,
10498                ..Default::default()
10499            })
10500            .unwrap();
10501            let mut dst = TensorDyn::image(
10502                W,
10503                H,
10504                PixelFormat::PlanarRgb,
10505                DType::F16,
10506                Some(TensorMemory::Mem),
10507                edgefirst_tensor::CpuAccess::ReadWrite,
10508            )
10509            .unwrap();
10510            cpu_proc
10511                .convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
10512                .expect("forced-CPU Rgba→PlanarRgb F16 must not error");
10513            dst
10514        };
10515
10516        // Compare element-wise.
10517        let gl_map = gl_result.as_f16().unwrap().map().unwrap();
10518        let cpu_map = cpu_result.as_f16().unwrap().map().unwrap();
10519        let gl_halfs = gl_map.as_slice();
10520        let cpu_halfs = cpu_map.as_slice();
10521
10522        assert_eq!(
10523            gl_halfs.len(),
10524            cpu_halfs.len(),
10525            "GL and CPU output sizes differ"
10526        );
10527
10528        let plane = W * H;
10529        let channel_names = ["R", "G", "B"];
10530        for (idx, (gl_h, cpu_h)) in gl_halfs.iter().zip(cpu_halfs.iter()).enumerate() {
10531            let gl_v = gl_h.to_f32();
10532            let cpu_v = cpu_h.to_f32();
10533            let err = (gl_v - cpu_v).abs();
10534            let ch = channel_names[idx / plane];
10535            let pixel = idx % plane;
10536            assert!(
10537                err <= TOL,
10538                "GL vs CPU mismatch at {ch}[{pixel}]: GL={gl_v}, CPU={cpu_v}, err={err} > tol={TOL}"
10539            );
10540        }
10541    }
10542
10543    // =========================================================================
10544    // GAP-1: supported_render_dtypes() Linux smoke test
10545    // =========================================================================
10546
10547    /// Exercises the real Linux GL path that reads `gl.supported_render_dtypes()`.
10548    /// Skipped when no GL backend is available (CI/host without a GPU).
10549    #[test]
10550    #[cfg(all(target_os = "linux", feature = "opengl"))]
10551    fn supported_render_dtypes_linux_smoke() {
10552        let proc = match ImageProcessor::new() {
10553            Ok(p) => p,
10554            Err(e) => {
10555                eprintln!("SKIPPED: supported_render_dtypes_linux_smoke — ImageProcessor::new() failed: {e}");
10556                return;
10557            }
10558        };
10559        if proc.opengl.is_none() {
10560            eprintln!("SKIPPED: supported_render_dtypes_linux_smoke — no GL backend on this host");
10561            return;
10562        }
10563        // The call must complete without panicking or deadlocking.
10564        let support = proc.supported_render_dtypes();
10565        eprintln!(
10566            "supported_render_dtypes_linux_smoke: f16={} f32={}",
10567            support.f16, support.f32
10568        );
10569        // No assertion on the specific values — they are hardware-dependent.
10570    }
10571
10572    // =========================================================================
10573    // GAP-2: F16 PlanarRgb with width NOT divisible by 4 falls back to CPU
10574    // =========================================================================
10575
10576    /// The GL float render path rejects PlanarRgb F16 destinations whose width
10577    /// is not a multiple of 4 (the packed RGBA16F swizzle trick requires W%4==0).
10578    /// The auto-chain must transparently fall through to the CPU path, which has
10579    /// no such restriction.  W=18, H=16 is chosen so W%4 == 2.
10580    #[test]
10581    fn convert_f16_pbo_non_4_aligned_width_falls_back() {
10582        const W: usize = 18; // 18 % 4 == 2 — NOT divisible by 4
10583        const H: usize = 16;
10584
10585        // RGBA8 source filled with a flat mid-grey.
10586        let src = TensorDyn::image(
10587            W,
10588            H,
10589            PixelFormat::Rgba,
10590            DType::U8,
10591            Some(TensorMemory::Mem),
10592            edgefirst_tensor::CpuAccess::ReadWrite,
10593        )
10594        .unwrap();
10595        {
10596            let mut map = src.as_u8().unwrap().map().unwrap();
10597            let data = map.as_mut_slice();
10598            for chunk in data.chunks_exact_mut(4) {
10599                chunk[0] = 128;
10600                chunk[1] = 64;
10601                chunk[2] = 200;
10602                chunk[3] = 255;
10603            }
10604        }
10605
10606        // F16 PlanarRgb destination in Mem (GL would use PBO, but we want
10607        // to exercise the fallback chain without hardware dependency).
10608        let mut dst = TensorDyn::image(
10609            W,
10610            H,
10611            PixelFormat::PlanarRgb,
10612            DType::F16,
10613            Some(TensorMemory::Mem),
10614            edgefirst_tensor::CpuAccess::ReadWrite,
10615        )
10616        .unwrap();
10617
10618        // Use the default auto-chain so the GL path can attempt and reject,
10619        // then the CPU path succeeds.
10620        let mut proc = ImageProcessor::new().unwrap();
10621        let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
10622        assert!(
10623            result.is_ok(),
10624            "auto-chain PlanarRgb F16 W%4!=0 must not error (CPU fallback): {:?}",
10625            result.err()
10626        );
10627
10628        // All output values must be finite and in [0, 1].
10629        let map = dst.as_f16().unwrap().map().unwrap();
10630        let halfs = map.as_slice();
10631        assert_eq!(halfs.len(), W * H * 3, "unexpected element count");
10632        for (i, h) in halfs.iter().enumerate() {
10633            let v = h.to_f32();
10634            assert!(
10635                v.is_finite() && (0.0..=1.0).contains(&v),
10636                "output[{i}]={v} is not finite or not in [0,1]"
10637            );
10638        }
10639    }
10640
10641    // =========================================================================
10642    // GAP-4: NV12 → Rgb F32 and NV12 → PlanarRgb F16, forced CPU
10643    // =========================================================================
10644
10645    /// CPU widen-composition: NV12 (non-RGBA source) → Rgb F32.
10646    ///
10647    /// NV12 requires a two-stage CPU conversion (NV12→Rgba then Rgba→F32)
10648    /// which was untested. A wrong intermediate format selection silently
10649    /// produces garbage. Y=128, U=V=128 → near-neutral grey → R≈G≈B≈0.5.
10650    #[test]
10651    // Linux-only `egl_display` field makes `..Default::default()` needless
10652    // on macOS only; see `convert_f16_forced_cpu_correct`.
10653    #[allow(clippy::needless_update)]
10654    fn convert_nv12_to_rgb_f32_cpu() {
10655        const W: usize = 16;
10656        const H: usize = 16; // must be even for NV12
10657
10658        // Build a valid NV12 tensor: shape [H*3/2, W], luma=128, chroma=128.
10659        let src = TensorDyn::image(
10660            W,
10661            H,
10662            PixelFormat::Nv12,
10663            DType::U8,
10664            Some(TensorMemory::Mem),
10665            edgefirst_tensor::CpuAccess::ReadWrite,
10666        )
10667        .unwrap();
10668        {
10669            let mut map = src.as_u8().unwrap().map().unwrap();
10670            map.as_mut_slice().fill(128); // Y=128, U=V=128 → neutral grey
10671        }
10672
10673        let mut dst = TensorDyn::image(
10674            W,
10675            H,
10676            PixelFormat::Rgb,
10677            DType::F32,
10678            Some(TensorMemory::Mem),
10679            edgefirst_tensor::CpuAccess::ReadWrite,
10680        )
10681        .unwrap();
10682
10683        let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
10684            backend: ComputeBackend::Cpu,
10685            ..Default::default()
10686        })
10687        .unwrap();
10688
10689        let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
10690        assert!(
10691            result.is_ok(),
10692            "forced-CPU NV12→Rgb F32 must not error: {:?}",
10693            result.err()
10694        );
10695
10696        let map = dst.as_f32().unwrap().map().unwrap();
10697        let floats = map.as_slice();
10698        assert_eq!(floats.len(), W * H * 3, "unexpected element count");
10699        for (i, &v) in floats.iter().enumerate() {
10700            assert!(
10701                v.is_finite() && (0.0..=1.0).contains(&v),
10702                "output[{i}]={v} is not finite or not in [0,1]"
10703            );
10704        }
10705        // Anti-all-zero: Y=128 → luma channel ≈ 0.5 after YUV→RGB.
10706        let non_zero = floats.iter().any(|&v| v > 0.01);
10707        assert!(non_zero, "all-zero output from NV12→Rgb F32 CPU path");
10708    }
10709
10710    /// CPU widen-composition: NV12 (non-RGBA source) → PlanarRgb F16.
10711    ///
10712    /// Same rationale as `convert_nv12_to_rgb_f32_cpu` but for F16 output.
10713    #[test]
10714    // Linux-only `egl_display` field makes `..Default::default()` needless
10715    // on macOS only; see `convert_f16_forced_cpu_correct`.
10716    #[allow(clippy::needless_update)]
10717    fn convert_nv12_to_planar_rgb_f16_cpu() {
10718        const W: usize = 16;
10719        const H: usize = 16;
10720
10721        let src = TensorDyn::image(
10722            W,
10723            H,
10724            PixelFormat::Nv12,
10725            DType::U8,
10726            Some(TensorMemory::Mem),
10727            edgefirst_tensor::CpuAccess::ReadWrite,
10728        )
10729        .unwrap();
10730        {
10731            let mut map = src.as_u8().unwrap().map().unwrap();
10732            map.as_mut_slice().fill(128);
10733        }
10734
10735        let mut dst = TensorDyn::image(
10736            W,
10737            H,
10738            PixelFormat::PlanarRgb,
10739            DType::F16,
10740            Some(TensorMemory::Mem),
10741            edgefirst_tensor::CpuAccess::ReadWrite,
10742        )
10743        .unwrap();
10744
10745        let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
10746            backend: ComputeBackend::Cpu,
10747            ..Default::default()
10748        })
10749        .unwrap();
10750
10751        let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
10752        assert!(
10753            result.is_ok(),
10754            "forced-CPU NV12→PlanarRgb F16 must not error: {:?}",
10755            result.err()
10756        );
10757
10758        let map = dst.as_f16().unwrap().map().unwrap();
10759        let halfs = map.as_slice();
10760        assert_eq!(halfs.len(), W * H * 3, "unexpected element count");
10761        for (i, h) in halfs.iter().enumerate() {
10762            let v = h.to_f32();
10763            assert!(
10764                v.is_finite() && (0.0..=1.0).contains(&v),
10765                "output[{i}]={v} is not finite or not in [0,1]"
10766            );
10767        }
10768        let non_zero = halfs.iter().any(|h| h.to_f32() > 0.01);
10769        assert!(non_zero, "all-zero output from NV12→PlanarRgb F16 CPU path");
10770    }
10771
10772    // =========================================================================
10773    // GAP-5: create_image F32 + DMA must return NotSupported
10774    // =========================================================================
10775
10776    /// `create_image_desc` without a compression request is exactly
10777    /// `create_image` (the processor's memory negotiation applies); with
10778    /// `Compression::Any` off-Android it resolves linear and counts the
10779    /// fallback in the processor-visible mirror.
10780    #[test]
10781    fn create_image_desc_negotiates_and_counts_fallbacks() {
10782        use edgefirst_tensor::{Compression, CpuAccess, ImageDesc};
10783        let proc = ImageProcessor::new().unwrap();
10784
10785        let desc =
10786            ImageDesc::new(64, 64, PixelFormat::Rgba, DType::U8).with_access(CpuAccess::ReadWrite);
10787        let plain = proc.create_image_desc(&desc).unwrap();
10788        let classic = proc
10789            .create_image(
10790                64,
10791                64,
10792                PixelFormat::Rgba,
10793                DType::U8,
10794                None,
10795                CpuAccess::ReadWrite,
10796            )
10797            .unwrap();
10798        assert_eq!(plain.memory(), classic.memory(), "same negotiation path");
10799        assert_eq!(plain.compression(), None);
10800
10801        #[cfg(not(target_os = "android"))]
10802        {
10803            let before = proc.compression_fallback_count();
10804            let desc = ImageDesc::new(64, 64, PixelFormat::Rgba, DType::U8)
10805                .with_compression(Compression::Any);
10806            let t = proc.create_image_desc(&desc).unwrap();
10807            assert_eq!(t.compression(), None, "no vendor tile scheme off-Android");
10808            assert!(
10809                proc.compression_fallback_count() > before,
10810                "Any resolving linear must count"
10811            );
10812        }
10813    }
10814
10815    /// There is no DRM FourCC for F32 images, so `create_image` with
10816    /// `TensorMemory::Dma` and `DType::F32` must return `Err(NotSupported)`.
10817    #[test]
10818    #[cfg(target_os = "linux")]
10819    fn create_image_f32_dma_rejected() {
10820        let proc = ImageProcessor::new().unwrap();
10821        let result = proc.create_image(
10822            64,
10823            64,
10824            PixelFormat::Rgb,
10825            DType::F32,
10826            Some(TensorMemory::Dma),
10827            edgefirst_tensor::CpuAccess::ReadWrite,
10828        );
10829        assert!(
10830            result.is_err(),
10831            "create_image(F32, Dma) must fail — no DRM fourcc for f32"
10832        );
10833    }
10834
10835    /// Verify that `import_image` stores the supplied `Option<Colorimetry>` on
10836    /// the returned `TensorDyn`.
10837    ///
10838    /// `import_image` requires a DMA-backed fd on Linux.  When DMA is
10839    /// unavailable we skip the DMA call but still verify the storage contract
10840    /// by inspecting `set_colorimetry` / `colorimetry` on a plain `TensorDyn`
10841    /// constructed the same way the function body does it — and we confirm via
10842    /// code-read that the new parameter is unconditionally stored.
10843    #[test]
10844    #[cfg(target_os = "linux")]
10845    fn import_image_carries_colorimetry() {
10846        use edgefirst_tensor::{ColorEncoding, ColorRange, Colorimetry, TensorMemory};
10847
10848        let expected = Colorimetry::default()
10849            .with_encoding(ColorEncoding::Bt709)
10850            .with_range(ColorRange::Limited);
10851
10852        if !is_dma_available() {
10853            // DMA unavailable on this host: exercise the storage path via
10854            // TensorDyn directly (mirrors what import_image does internally).
10855            let mut t = TensorDyn::image(
10856                8,
10857                8,
10858                PixelFormat::Rgba,
10859                DType::U8,
10860                Some(TensorMemory::Mem),
10861                edgefirst_tensor::CpuAccess::ReadWrite,
10862            )
10863            .expect("alloc");
10864            assert_eq!(t.colorimetry(), None, "colorimetry must start as None");
10865            t.set_colorimetry(Some(expected));
10866            assert_eq!(
10867                t.colorimetry(),
10868                Some(expected),
10869                "set_colorimetry must round-trip"
10870            );
10871            eprintln!("SKIPPED import_image_carries_colorimetry (DMA unavailable); storage contract verified via TensorDyn");
10872            return;
10873        }
10874
10875        // DMA is available: allocate a real DMA tensor, extract its fd, and
10876        // call import_image with an explicit Colorimetry.
10877        use edgefirst_tensor::{PlaneDescriptor, Tensor};
10878
10879        let rgba_bytes = 64 * 64 * 4; // 64×64 RGBA8
10880        let dma_tensor =
10881            Tensor::<u8>::new(&[rgba_bytes], Some(TensorMemory::Dma), Some("import_test"))
10882                .expect("dma alloc");
10883        let pd =
10884            PlaneDescriptor::new(dma_tensor.dmabuf().expect("dma fd")).expect("PlaneDescriptor");
10885
10886        let proc = ImageProcessor::new().expect("ImageProcessor");
10887        let result = proc.import_image(
10888            pd,
10889            None,
10890            64,
10891            64,
10892            PixelFormat::Rgba,
10893            DType::U8,
10894            Some(expected),
10895        );
10896        let tensor = result.expect("import_image must succeed on DMA fd");
10897        assert_eq!(
10898            tensor.colorimetry(),
10899            Some(expected),
10900            "import_image must store the supplied colorimetry on the returned TensorDyn"
10901        );
10902    }
10903}